diff --git a/public/index.html b/public/index.html
index b752d17..c7f0504 100644
--- a/public/index.html
+++ b/public/index.html
@@ -63,11 +63,7 @@
diff --git a/public/js/main.js b/public/js/main.js
index ddf195d..2a907a8 100644
--- a/public/js/main.js
+++ b/public/js/main.js
@@ -82,6 +82,8 @@ document.addEventListener('DOMContentLoaded', () => {
let controlsHideTimeout = null;
let controlsHovered = false;
let controlsPointerReleaseTimeout = null;
+ let availableEncoders = [];
+ let defaultEncoder = 'software';
if (videoPlayer) {
videoPlayer.controls = false;
@@ -114,9 +116,43 @@ document.addEventListener('DOMContentLoaded', () => {
const createStreamSessionId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
+ const syncEncoderOptions = () => {
+ if (!encoderSelect) return;
+
+ const selectedCodec = codecSelect?.value || 'h264';
+ const previousValue = encoderSelect.value || defaultEncoder;
+ const supportedEncoders = availableEncoders.filter((encoder) => (
+ Array.isArray(encoder.availableCodecs) && encoder.availableCodecs.includes(selectedCodec)
+ ));
+
+ encoderSelect.innerHTML = '';
+
+ if (supportedEncoders.length === 0) {
+ const option = document.createElement('option');
+ option.value = 'software';
+ option.textContent = 'Software';
+ encoderSelect.appendChild(option);
+ encoderSelect.disabled = true;
+ return;
+ }
+
+ supportedEncoders.forEach((encoder) => {
+ const option = document.createElement('option');
+ option.value = encoder.key;
+ option.textContent = encoder.label;
+ encoderSelect.appendChild(option);
+ });
+
+ encoderSelect.disabled = false;
+ const nextValue = supportedEncoders.some((encoder) => encoder.key === previousValue)
+ ? previousValue
+ : (supportedEncoders.some((encoder) => encoder.key === defaultEncoder) ? defaultEncoder : supportedEncoders[0].key);
+ encoderSelect.value = nextValue;
+ };
+
const buildStreamUrl = (targetSeconds = null) => {
const codec = codecSelect?.value || 'h264';
- const encoder = encoderSelect?.value || 'vaapi';
+ const encoder = encoderSelect?.value || defaultEncoder;
const streamSessionId = createStreamSessionId();
let streamUrl = `/api/stream?bucket=${encodeURIComponent(selectedBucket)}&key=${encodeURIComponent(selectedKey)}&codec=${encodeURIComponent(codec)}&encoder=${encodeURIComponent(encoder)}&streamSessionId=${encodeURIComponent(streamSessionId)}`;
if (typeof targetSeconds === 'number' && Number.isFinite(targetSeconds) && targetSeconds > 0) {
@@ -581,9 +617,12 @@ document.addEventListener('DOMContentLoaded', () => {
if (!res.ok) throw new Error('Failed to load config');
const data = await res.json();
const title = data.title || 'S3 Media Transcoder';
+ availableEncoders = Array.isArray(data.encoders) ? data.encoders : [];
+ defaultEncoder = data.defaultEncoder || 'software';
topBanner.textContent = title;
topBanner.classList.remove('hidden');
document.title = title;
+ syncEncoderOptions();
} catch (err) {
console.error('Config load failed:', err);
}
@@ -909,7 +948,7 @@ document.addEventListener('DOMContentLoaded', () => {
try {
const codec = codecSelect?.value || 'h264';
- const encoder = encoderSelect?.value || 'vaapi';
+ const encoder = encoderSelect?.value || defaultEncoder;
if (!selectedBucket) throw new Error('No bucket selected');
const streamUrl = buildStreamUrl();
videoPlayer.src = streamUrl;
@@ -1122,6 +1161,9 @@ document.addEventListener('DOMContentLoaded', () => {
await fetchVideos(selectedBucket);
});
}
+ if (codecSelect) {
+ codecSelect.addEventListener('change', syncEncoderOptions);
+ }
// Initial state: require login before loading data
showLogin();
diff --git a/server.js b/server.js
index a5608ff..05bde05 100644
--- a/server.js
+++ b/server.js
@@ -74,6 +74,26 @@ const createS3Client = (credentials) => {
const progressMap = {};
const transcodeProcesses = new Map();
const wsSubscriptions = new Map();
+let encoderCapabilitiesPromise = null;
+
+const encoderPresets = {
+ software: {
+ label: 'Software',
+ codecs: { h264: 'libx264', h265: 'libx265' }
+ },
+ nvidia: {
+ label: 'NVIDIA',
+ codecs: { h264: 'h264_nvenc', h265: 'hevc_nvenc' }
+ },
+ intel: {
+ label: 'Intel QSV',
+ codecs: { h264: 'h264_qsv', h265: 'hevc_qsv' }
+ },
+ vaapi: {
+ label: 'VAAPI',
+ codecs: { h264: 'h264_vaapi', h265: 'hevc_vaapi' }
+ }
+};
const getProgressKey = (key) => key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_')).join('/');
@@ -172,7 +192,80 @@ const getSeekFriendlyOutputOptions = (encoderName, metadata) => {
const shouldRetryWithSoftware = (message) => {
if (!message) return false;
- return /Cannot load libcuda\.so\.1|Could not open encoder before EOF|Error while opening encoder|Operation not permitted|Invalid argument/i.test(message);
+ return /Cannot load libcuda\.so\.1|Could not open encoder before EOF|Error while opening encoder|Operation not permitted|Invalid argument|codec .* is not available|Unknown encoder|No usable encoding profile found/i.test(message);
+};
+
+const getEncoderCapabilities = () => {
+ if (!encoderCapabilitiesPromise) {
+ encoderCapabilitiesPromise = new Promise((resolve) => {
+ ffmpeg.getAvailableEncoders((error, encoders = {}) => {
+ if (error) {
+ console.warn('Failed to read ffmpeg encoder list, falling back to software only:', error.message || error);
+ resolve({
+ encoders: {},
+ presets: Object.entries(encoderPresets).map(([key, preset]) => ({
+ key,
+ label: preset.label,
+ availableCodecs: key === 'software' ? ['h264', 'h265'] : []
+ })),
+ defaultEncoder: 'software'
+ });
+ return;
+ }
+
+ const presets = Object.entries(encoderPresets).map(([key, preset]) => ({
+ key,
+ label: preset.label,
+ availableCodecs: Object.entries(preset.codecs)
+ .filter(([, encoderName]) => Boolean(encoders[encoderName]))
+ .map(([codecName]) => codecName)
+ }));
+
+ const softwarePreset = presets.find((preset) => preset.key === 'software');
+ const defaultEncoder = softwarePreset && softwarePreset.availableCodecs.includes('h264')
+ ? 'software'
+ : (presets.find((preset) => preset.availableCodecs.includes('h264'))?.key || 'software');
+
+ resolve({ encoders, presets, defaultEncoder });
+ });
+ });
+ }
+
+ return encoderCapabilitiesPromise;
+};
+
+const resolveEncoderSelection = (requestedEncoder, requestedCodec, encoderCapabilities) => {
+ const safeCodec = requestedCodec === 'h265' ? 'h265' : 'h264';
+ const requestedPreset = encoderPresets[requestedEncoder] ? requestedEncoder : encoderCapabilities.defaultEncoder || 'software';
+ const isPresetAvailable = encoderCapabilities.presets.some((preset) => (
+ preset.key === requestedPreset && preset.availableCodecs.includes(safeCodec)
+ ));
+
+ if (isPresetAvailable) {
+ return {
+ codec: safeCodec,
+ encoderPreset: requestedPreset,
+ videoCodec: encoderPresets[requestedPreset].codecs[safeCodec],
+ fallbackFrom: null
+ };
+ }
+
+ const softwarePreset = encoderCapabilities.presets.find((preset) => preset.key === 'software');
+ if (softwarePreset?.availableCodecs.includes(safeCodec)) {
+ return {
+ codec: safeCodec,
+ encoderPreset: 'software',
+ videoCodec: encoderPresets.software.codecs[safeCodec],
+ fallbackFrom: requestedPreset
+ };
+ }
+
+ return {
+ codec: safeCodec,
+ encoderPreset: null,
+ videoCodec: null,
+ fallbackFrom: requestedPreset
+ };
};
const probeFile = (filePath) => {
@@ -351,9 +444,14 @@ app.get('/api/videos', async (req, res) => {
}
});
-app.get('/api/config', (req, res) => {
+app.get('/api/config', async (req, res) => {
const title = process.env.APP_TITLE || 'S3 Media Transcoder';
- res.json({ title });
+ const encoderCapabilities = await getEncoderCapabilities();
+ res.json({
+ title,
+ encoders: encoderCapabilities.presets,
+ defaultEncoder: encoderCapabilities.defaultEncoder
+ });
});
app.post('/api/clear-download-cache', (req, res) => {
@@ -409,16 +507,18 @@ app.get('/api/stream', async (req, res) => {
return res.status(400).json({ error: 'Video key is required' });
}
- const safeCodec = codec === 'h265' ? 'h265' : 'h264';
- const safeEncoder = ['nvidia', 'intel', 'vaapi', 'neon'].includes(encoder) ? encoder : 'vaapi';
- const codecMap = {
- software: { h264: 'libx264', h265: 'libx265' },
- neon: { h264: 'libx264', h265: 'libx265' },
- nvidia: { h264: 'h264_nvenc', h265: 'hevc_nvenc' },
- intel: { h264: 'h264_qsv', h265: 'hevc_qsv' },
- vaapi: { h264: 'h264_vaapi', h265: 'hevc_vaapi' }
- };
- const videoCodec = codecMap[safeEncoder][safeCodec];
+ const encoderCapabilities = await getEncoderCapabilities();
+ const selection = resolveEncoderSelection(encoder, codec, encoderCapabilities);
+
+ if (!selection.videoCodec || !selection.encoderPreset) {
+ return res.status(500).json({
+ error: 'No compatible encoder is available in the bundled ffmpeg build',
+ detail: `Requested codec ${selection.codec} is not supported by the current ffmpeg binary`
+ });
+ }
+
+ const safeCodec = selection.codec;
+ const videoCodec = selection.videoCodec;
const safeKeySegments = key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_'));
const progressKey = safeKeySegments.join('/');
@@ -536,7 +636,7 @@ app.get('/api/stream', async (req, res) => {
let ffmpegCommand = null;
- const startStream = (encoderName) => {
+ const startStream = (encoderName, encoderPreset) => {
const streamingOptions = createFfmpegOptions(encoderName).concat(getSeekFriendlyOutputOptions(encoderName, sourceMetadata));
ffmpegCommand = ffmpeg()
.input(tmpInputPath)
@@ -575,6 +675,8 @@ app.get('/api/stream', async (req, res) => {
const progressState = {
status: 'transcoding',
percent,
+ codec: safeCodec,
+ encoder: encoderPreset,
frame: progress.frames || null,
fps: progress.currentFps || null,
bitrate: progress.currentKbps || null,
@@ -603,6 +705,8 @@ app.get('/api/stream', async (req, res) => {
progressMap[progressKey] = {
status: 'finished',
percent: 100,
+ codec: safeCodec,
+ encoder: encoderPreset,
duration: progressMap[progressKey]?.duration || null,
startSeconds,
streamSessionId,
@@ -616,9 +720,16 @@ app.get('/api/stream', async (req, res) => {
return;
}
transcodeProcesses.delete(progressKey);
+ if (encoderPreset !== 'software' && shouldRetryWithSoftware(err.message || '')) {
+ console.warn(`Encoder ${encoderPreset} (${encoderName}) failed, retrying with software encoder for ${safeCodec}.`);
+ startStream(encoderPresets.software.codecs[safeCodec], 'software');
+ return;
+ }
const failedState = {
status: 'failed',
percent: progressMap[progressKey]?.percent || 0,
+ codec: safeCodec,
+ encoder: encoderPreset,
duration: progressMap[progressKey]?.duration || null,
startSeconds,
streamSessionId,
@@ -634,6 +745,20 @@ app.get('/api/stream', async (req, res) => {
}
});
+ if (encoderPreset === 'software' && selection.fallbackFrom && selection.fallbackFrom !== 'software') {
+ const progressState = {
+ ...(progressMap[progressKey] || {}),
+ status: 'transcoding',
+ codec: safeCodec,
+ encoder: 'software',
+ startSeconds,
+ streamSessionId,
+ details: `Requested encoder ${selection.fallbackFrom} is unavailable, using software encoding`
+ };
+ progressMap[progressKey] = progressState;
+ broadcastWs(progressKey, { type: 'progress', key, progress: progressState });
+ }
+
ffmpegCommand.pipe(res, { end: true });
};
@@ -648,7 +773,7 @@ app.get('/api/stream', async (req, res) => {
}
});
- startStream(videoCodec);
+ startStream(videoCodec, selection.encoderPreset);
} catch (error) {
console.error('Error in stream:', error);
if (!res.headersSent) {
@@ -659,6 +784,11 @@ app.get('/api/stream', async (req, res) => {
}
});
-server.listen(PORT, HOST, () => {
+server.listen(PORT, HOST, async () => {
console.log(`Server running on http://${HOST}:${PORT}`);
+ const encoderCapabilities = await getEncoderCapabilities();
+ const summary = encoderCapabilities.presets
+ .map((preset) => `${preset.key}=[${preset.availableCodecs.join(', ') || 'unavailable'}]`)
+ .join(' ');
+ console.log(`Bundled ffmpeg encoder support: ${summary}`);
});