修复编码器错乱的问题

This commit is contained in:
CN-JS-HuiBai
2026-04-03 01:59:39 +08:00
parent 517814610d
commit 2a0ec0ead5
3 changed files with 191 additions and 23 deletions

View File

@@ -63,11 +63,7 @@
<div class="codec-panel"> <div class="codec-panel">
<label for="encoder-select">硬件编码器:</label> <label for="encoder-select">硬件编码器:</label>
<select id="encoder-select"> <select id="encoder-select">
<option value="software">Software</option> <option value="software" selected>Software</option>
<option value="neon">NEON</option>
<option value="nvidia">NVIDIA</option>
<option value="intel">Intel</option>
<option value="vaapi" selected>VAAPI</option>
</select> </select>
</div> </div>
<div id="loading-spinner" class="spinner-container"> <div id="loading-spinner" class="spinner-container">

View File

@@ -82,6 +82,8 @@ document.addEventListener('DOMContentLoaded', () => {
let controlsHideTimeout = null; let controlsHideTimeout = null;
let controlsHovered = false; let controlsHovered = false;
let controlsPointerReleaseTimeout = null; let controlsPointerReleaseTimeout = null;
let availableEncoders = [];
let defaultEncoder = 'software';
if (videoPlayer) { if (videoPlayer) {
videoPlayer.controls = false; videoPlayer.controls = false;
@@ -114,9 +116,43 @@ document.addEventListener('DOMContentLoaded', () => {
const createStreamSessionId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; 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 buildStreamUrl = (targetSeconds = null) => {
const codec = codecSelect?.value || 'h264'; const codec = codecSelect?.value || 'h264';
const encoder = encoderSelect?.value || 'vaapi'; const encoder = encoderSelect?.value || defaultEncoder;
const streamSessionId = createStreamSessionId(); const streamSessionId = createStreamSessionId();
let streamUrl = `/api/stream?bucket=${encodeURIComponent(selectedBucket)}&key=${encodeURIComponent(selectedKey)}&codec=${encodeURIComponent(codec)}&encoder=${encodeURIComponent(encoder)}&streamSessionId=${encodeURIComponent(streamSessionId)}`; 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) { 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'); if (!res.ok) throw new Error('Failed to load config');
const data = await res.json(); const data = await res.json();
const title = data.title || 'S3 Media Transcoder'; const title = data.title || 'S3 Media Transcoder';
availableEncoders = Array.isArray(data.encoders) ? data.encoders : [];
defaultEncoder = data.defaultEncoder || 'software';
topBanner.textContent = title; topBanner.textContent = title;
topBanner.classList.remove('hidden'); topBanner.classList.remove('hidden');
document.title = title; document.title = title;
syncEncoderOptions();
} catch (err) { } catch (err) {
console.error('Config load failed:', err); console.error('Config load failed:', err);
} }
@@ -909,7 +948,7 @@ document.addEventListener('DOMContentLoaded', () => {
try { try {
const codec = codecSelect?.value || 'h264'; const codec = codecSelect?.value || 'h264';
const encoder = encoderSelect?.value || 'vaapi'; const encoder = encoderSelect?.value || defaultEncoder;
if (!selectedBucket) throw new Error('No bucket selected'); if (!selectedBucket) throw new Error('No bucket selected');
const streamUrl = buildStreamUrl(); const streamUrl = buildStreamUrl();
videoPlayer.src = streamUrl; videoPlayer.src = streamUrl;
@@ -1122,6 +1161,9 @@ document.addEventListener('DOMContentLoaded', () => {
await fetchVideos(selectedBucket); await fetchVideos(selectedBucket);
}); });
} }
if (codecSelect) {
codecSelect.addEventListener('change', syncEncoderOptions);
}
// Initial state: require login before loading data // Initial state: require login before loading data
showLogin(); showLogin();

162
server.js
View File

@@ -74,6 +74,26 @@ const createS3Client = (credentials) => {
const progressMap = {}; const progressMap = {};
const transcodeProcesses = new Map(); const transcodeProcesses = new Map();
const wsSubscriptions = 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('/'); 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) => { const shouldRetryWithSoftware = (message) => {
if (!message) return false; 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) => { 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'; 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) => { 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' }); return res.status(400).json({ error: 'Video key is required' });
} }
const safeCodec = codec === 'h265' ? 'h265' : 'h264'; const encoderCapabilities = await getEncoderCapabilities();
const safeEncoder = ['nvidia', 'intel', 'vaapi', 'neon'].includes(encoder) ? encoder : 'vaapi'; const selection = resolveEncoderSelection(encoder, codec, encoderCapabilities);
const codecMap = {
software: { h264: 'libx264', h265: 'libx265' }, if (!selection.videoCodec || !selection.encoderPreset) {
neon: { h264: 'libx264', h265: 'libx265' }, return res.status(500).json({
nvidia: { h264: 'h264_nvenc', h265: 'hevc_nvenc' }, error: 'No compatible encoder is available in the bundled ffmpeg build',
intel: { h264: 'h264_qsv', h265: 'hevc_qsv' }, detail: `Requested codec ${selection.codec} is not supported by the current ffmpeg binary`
vaapi: { h264: 'h264_vaapi', h265: 'hevc_vaapi' } });
}; }
const videoCodec = codecMap[safeEncoder][safeCodec];
const safeCodec = selection.codec;
const videoCodec = selection.videoCodec;
const safeKeySegments = key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_')); const safeKeySegments = key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_'));
const progressKey = safeKeySegments.join('/'); const progressKey = safeKeySegments.join('/');
@@ -536,7 +636,7 @@ app.get('/api/stream', async (req, res) => {
let ffmpegCommand = null; let ffmpegCommand = null;
const startStream = (encoderName) => { const startStream = (encoderName, encoderPreset) => {
const streamingOptions = createFfmpegOptions(encoderName).concat(getSeekFriendlyOutputOptions(encoderName, sourceMetadata)); const streamingOptions = createFfmpegOptions(encoderName).concat(getSeekFriendlyOutputOptions(encoderName, sourceMetadata));
ffmpegCommand = ffmpeg() ffmpegCommand = ffmpeg()
.input(tmpInputPath) .input(tmpInputPath)
@@ -575,6 +675,8 @@ app.get('/api/stream', async (req, res) => {
const progressState = { const progressState = {
status: 'transcoding', status: 'transcoding',
percent, percent,
codec: safeCodec,
encoder: encoderPreset,
frame: progress.frames || null, frame: progress.frames || null,
fps: progress.currentFps || null, fps: progress.currentFps || null,
bitrate: progress.currentKbps || null, bitrate: progress.currentKbps || null,
@@ -603,6 +705,8 @@ app.get('/api/stream', async (req, res) => {
progressMap[progressKey] = { progressMap[progressKey] = {
status: 'finished', status: 'finished',
percent: 100, percent: 100,
codec: safeCodec,
encoder: encoderPreset,
duration: progressMap[progressKey]?.duration || null, duration: progressMap[progressKey]?.duration || null,
startSeconds, startSeconds,
streamSessionId, streamSessionId,
@@ -616,9 +720,16 @@ app.get('/api/stream', async (req, res) => {
return; return;
} }
transcodeProcesses.delete(progressKey); 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 = { const failedState = {
status: 'failed', status: 'failed',
percent: progressMap[progressKey]?.percent || 0, percent: progressMap[progressKey]?.percent || 0,
codec: safeCodec,
encoder: encoderPreset,
duration: progressMap[progressKey]?.duration || null, duration: progressMap[progressKey]?.duration || null,
startSeconds, startSeconds,
streamSessionId, 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 }); ffmpegCommand.pipe(res, { end: true });
}; };
@@ -648,7 +773,7 @@ app.get('/api/stream', async (req, res) => {
} }
}); });
startStream(videoCodec); startStream(videoCodec, selection.encoderPreset);
} catch (error) { } catch (error) {
console.error('Error in stream:', error); console.error('Error in stream:', error);
if (!res.headersSent) { 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}`); 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}`);
}); });