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}`); });