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