修复编码器错乱的问题

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">
<label for="encoder-select">硬件编码器:</label>
<select id="encoder-select">
<option value="software">Software</option>
<option value="neon">NEON</option>
<option value="nvidia">NVIDIA</option>
<option value="intel">Intel</option>
<option value="vaapi" selected>VAAPI</option>
<option value="software" selected>Software</option>
</select>
</div>
<div id="loading-spinner" class="spinner-container">

View File

@@ -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();

162
server.js
View File

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