修复编码器错乱的问题
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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
162
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}`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user