From 0e24d5875e66a54f0b43e8faac132b50726916af Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Thu, 2 Apr 2026 18:35:04 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DNVIDIA=E7=BC=96=E7=A0=81?= =?UTF-8?q?=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/index.html | 1 + public/js/main.js | 34 ++++++-- server.js | 206 ++++++++++++++++++++++++++++++++-------------- 3 files changed, 170 insertions(+), 71 deletions(-) diff --git a/public/index.html b/public/index.html index 8c258b7..f53306f 100644 --- a/public/index.html +++ b/public/index.html @@ -75,6 +75,7 @@ diff --git a/public/js/main.js b/public/js/main.js index 599432e..75b2941 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -9,12 +9,14 @@ document.addEventListener('DOMContentLoaded', () => { const videoPlayer = document.getElementById('video-player'); const nowPlaying = document.getElementById('now-playing'); const currentVideoTitle = document.getElementById('current-video-title'); + const transcodeBtn = document.getElementById('transcode-btn'); const playBtn = document.getElementById('play-btn'); const progressInfo = document.getElementById('progress-info'); const progressText = document.getElementById('progress-text'); const progressFill = document.getElementById('progress-fill'); let currentPollInterval = null; + let selectedKey = null; let ws = null; let wsConnected = false; let subscribedKey = null; @@ -87,6 +89,12 @@ document.addEventListener('DOMContentLoaded', () => { progressFill.style.width = '0%'; }; + if (transcodeBtn) { + transcodeBtn.addEventListener('click', () => { + startTranscode(); + }); + } + if (playBtn) { playBtn.addEventListener('click', () => { videoPlayer.play().catch(e => console.log('Play blocked:', e)); @@ -207,7 +215,7 @@ document.addEventListener('DOMContentLoaded', () => { }; // Handle video selection and trigger transcode - const selectVideo = async (key, listItemNode) => { + const selectVideo = (key, listItemNode) => { // Update UI document.querySelectorAll('.video-item').forEach(n => n.classList.remove('active')); listItemNode.classList.add('active'); @@ -217,21 +225,33 @@ document.addEventListener('DOMContentLoaded', () => { playerOverlay.classList.add('hidden'); videoPlayer.classList.add('hidden'); videoPlayer.pause(); + if (transcodeBtn) { + transcodeBtn.disabled = false; + transcodeBtn.textContent = 'Start Transcode'; + transcodeBtn.classList.remove('hidden'); + } if (playBtn) { - playBtn.disabled = true; - playBtn.textContent = '等待转码完成'; - playBtn.classList.remove('hidden'); + playBtn.classList.add('hidden'); } resetProgress(); + selectedKey = key; currentVideoKey = key; subscribeToKey(key); nowPlaying.classList.remove('hidden'); currentVideoTitle.textContent = key.split('/').pop(); + }; + const startTranscode = async () => { + if (!selectedKey) return; + if (transcodeBtn) { + transcodeBtn.disabled = true; + transcodeBtn.textContent = 'Starting...'; + } + stopPolling(); transcodingOverlay.classList.remove('hidden'); - setProgress({ status: 'Starting transcoding...', percent: 0, details: 'Starting transcoding...' }); + setProgress({ status: 'Starting download...', percent: 0, details: 'Starting download...' }); try { const codec = codecSelect?.value || 'h264'; @@ -239,14 +259,14 @@ document.addEventListener('DOMContentLoaded', () => { const res = await fetch('/api/transcode', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ key, codec, encoder }) + body: JSON.stringify({ key: selectedKey, codec, encoder }) }); const data = await res.json(); if (data.error) throw new Error(data.error); if (!wsConnected) { - pollForMp4Ready(key, data.mp4Url); + pollForMp4Ready(selectedKey, data.mp4Url); } } catch (err) { console.error(err); diff --git a/server.js b/server.js index 54e2ff7..70eeacc 100644 --- a/server.js +++ b/server.js @@ -35,12 +35,14 @@ const BUCKET_NAME = process.env.S3_BUCKET_NAME; const progressMap = {}; const wsSubscriptions = new Map(); -const addWsClient = (key, ws) => { - if (!wsSubscriptions.has(key)) { - wsSubscriptions.set(key, new Set()); +const getProgressKey = (key) => key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_')).join('/'); + +const addWsClient = (progressKey, ws) => { + if (!wsSubscriptions.has(progressKey)) { + wsSubscriptions.set(progressKey, new Set()); } - wsSubscriptions.get(key).add(ws); - ws.currentKey = key; + wsSubscriptions.get(progressKey).add(ws); + ws.currentKey = progressKey; }; const removeWsClient = (ws) => { @@ -63,6 +65,23 @@ const broadcastWs = (key, payload) => { } }; +const createFfmpegOptions = (encoderName) => { + const options = ['-preset fast']; + if (encoderName === 'libx264' || encoderName === 'libx265') { + options.push('-crf', '23'); + } else if (/_nvenc$/.test(encoderName)) { + options.push('-rc:v', 'vbr_hq', '-cq', '19'); + } else if (/_qsv$/.test(encoderName)) { + options.push('-global_quality', '23'); + } + return options; +}; + +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); +}; + const wss = new WebSocket.Server({ server }); wss.on('connection', (ws) => { @@ -70,12 +89,13 @@ wss.on('connection', (ws) => { try { const message = JSON.parse(raw.toString()); if (message.type === 'subscribe' && typeof message.key === 'string') { - if (ws.currentKey && ws.currentKey !== message.key) { + const progressKey = getProgressKey(message.key); + if (ws.currentKey && ws.currentKey !== progressKey) { removeWsClient(ws); } - addWsClient(message.key, ws); + addWsClient(progressKey, ws); - const currentProgress = progressMap[message.key]; + const currentProgress = progressMap[progressKey]; if (currentProgress) { ws.send(JSON.stringify({ type: 'progress', key: message.key, progress: currentProgress })); if (currentProgress.status === 'finished' && currentProgress.mp4Url) { @@ -174,16 +194,49 @@ app.post('/api/transcode', async (req, res) => { const response = await s3Client.send(command); const s3Stream = response.Body; - + const totalBytes = response.ContentLength || 0; + let downloadedBytes = 0; const tmpInputPath = path.join(os.tmpdir(), `s3-input-${Date.now()}-${Math.random().toString(16).slice(2)}.tmp`); + + const broadcastDownloadProgress = () => { + const percent = totalBytes ? Math.min(100, Math.round(downloadedBytes / totalBytes * 100)) : 0; + const downloadState = { + status: 'downloading', + percent, + downloadedBytes, + totalBytes, + details: totalBytes ? `Downloading ${percent}%` : 'Downloading...', + mp4Url + }; + progressMap[progressKey] = downloadState; + broadcastWs(progressKey, { type: 'progress', key, progress: downloadState }); + }; + await new Promise((resolve, reject) => { const writeStream = fs.createWriteStream(tmpInputPath); - s3Stream.pipe(writeStream); - s3Stream.on('error', reject); + s3Stream.on('data', (chunk) => { + downloadedBytes += chunk.length; + broadcastDownloadProgress(); + }); + s3Stream.on('error', (err) => { + reject(err); + }); writeStream.on('error', reject); writeStream.on('finish', resolve); + s3Stream.pipe(writeStream); }); + broadcastDownloadProgress(); + progressMap[progressKey] = { + status: 'downloaded', + percent: 100, + downloadedBytes, + totalBytes, + details: 'Download complete, starting transcode...', + mp4Url + }; + broadcastWs(progressKey, { type: 'progress', key, progress: progressMap[progressKey] }); + // Triggers fluent-ffmpeg to transcode to MP4 console.log(`Starting transcoding for ${key} with codec ${videoCodec}`); @@ -191,60 +244,85 @@ app.post('/api/transcode', async (req, res) => { fs.unlink(tmpInputPath, () => {}); }; - ffmpeg(tmpInputPath) - .videoCodec(videoCodec) - .audioCodec('aac') - .outputOptions([ - '-preset fast', - '-crf 23' - ]) - .format('mp4') - .output(mp4Path) - .on('progress', (progress) => { - const progressState = { - status: 'transcoding', - percent: Math.min(Math.max(Math.round(progress.percent || 0), 0), 100), - frame: progress.frames || null, - fps: progress.currentFps || null, - bitrate: progress.currentKbps || null, - timemark: progress.timemark || null, - details: `Transcoding... ${Math.min(Math.max(Math.round(progress.percent || 0), 0), 100)}%`, - mp4Url - }; - progressMap[progressKey] = progressState; - broadcastWs(progressKey, { type: 'progress', key: progressKey, progress: progressState }); - }) - .on('stderr', (stderrLine) => { - console.log(`ffmpeg stderr: ${stderrLine}`); - }) - .on('end', () => { - cleanupTmpInput(); - console.log(`Finished transcoding ${key} to MP4`); - let progressState; - try { - const stats = fs.statSync(mp4Path); - if (!stats.isFile() || stats.size === 0) { - throw new Error('Output MP4 is empty or missing'); + let attemptedSoftwareFallback = false; + const startFfmpeg = (encoderName) => { + console.log(`Starting ffmpeg with encoder ${encoderName} for ${key}`); + ffmpeg(tmpInputPath) + .videoCodec(encoderName) + .audioCodec('aac') + .outputOptions(createFfmpegOptions(encoderName)) + .format('mp4') + .output(mp4Path) + .on('progress', (progress) => { + const progressState = { + status: 'transcoding', + percent: Math.min(Math.max(Math.round(progress.percent || 0), 0), 100), + frame: progress.frames || null, + fps: progress.currentFps || null, + bitrate: progress.currentKbps || null, + timemark: progress.timemark || null, + details: `Transcoding... ${Math.min(Math.max(Math.round(progress.percent || 0), 0), 100)}%`, + mp4Url + }; + progressMap[progressKey] = progressState; + broadcastWs(progressKey, { type: 'progress', key, progress: progressState }); + }) + .on('stderr', (stderrLine) => { + console.log(`ffmpeg stderr: ${stderrLine}`); + }) + .on('end', () => { + cleanupTmpInput(); + console.log(`Finished transcoding ${key} to MP4`); + let progressState; + try { + const stats = fs.statSync(mp4Path); + if (!stats.isFile() || stats.size === 0) { + throw new Error('Output MP4 is empty or missing'); + } + progressState = { status: 'finished', percent: 100, details: 'Transcoding complete', mp4Url }; + } catch (verifyError) { + console.error(`Output verification failed for ${mp4Path}:`, verifyError); + progressState = { status: 'failed', percent: progressMap[progressKey]?.percent || 0, details: `Output verification failed: ${verifyError.message}`, mp4Url }; } - progressState = { status: 'finished', percent: 100, details: 'Transcoding complete', mp4Url }; - } catch (verifyError) { - console.error(`Output verification failed for ${mp4Path}:`, verifyError); - progressState = { status: 'failed', percent: progressMap[progressKey]?.percent || 0, details: `Output verification failed: ${verifyError.message}`, mp4Url }; - } - progressMap[progressKey] = progressState; - broadcastWs(progressKey, { type: 'progress', key: progressKey, progress: progressState }); - if (progressState.status === 'finished') { - broadcastWs(progressKey, { type: 'ready', key: progressKey, mp4Url }); - } - }) - .on('error', (err) => { - cleanupTmpInput(); - console.error(`Error transcoding ${key}:`, err); - const failedState = { status: 'failed', percent: progressMap[progressKey]?.percent || 0, details: err.message || 'Transcoding failed', mp4Url }; - progressMap[progressKey] = failedState; - broadcastWs(progressKey, { type: 'progress', key: progressKey, progress: failedState }); - }) - .run(); + progressMap[progressKey] = progressState; + broadcastWs(progressKey, { type: 'progress', key, progress: progressState }); + if (progressState.status === 'finished') { + broadcastWs(progressKey, { type: 'ready', key, mp4Url }); + } + }) + .on('error', (err) => { + const errMessage = err?.message || ''; + const isHardwareFailure = !attemptedSoftwareFallback && encoderName !== codecMap.software[safeCodec] && shouldRetryWithSoftware(errMessage); + if (isHardwareFailure) { + attemptedSoftwareFallback = true; + console.warn(`Hardware encoder failed for ${key}; retrying with software encoder`, errMessage); + try { + if (fs.existsSync(mp4Path)) { + fs.unlinkSync(mp4Path); + } + } catch (_) {} + const softwareEncoder = codecMap.software[safeCodec]; + progressMap[progressKey] = { + status: 'fallback', + percent: 0, + details: 'Hardware encoder unavailable, retrying with software encoder...', + mp4Url + }; + broadcastWs(progressKey, { type: 'progress', key, progress: progressMap[progressKey] }); + startFfmpeg(softwareEncoder); + return; + } + + cleanupTmpInput(); + console.error(`Error transcoding ${key}:`, err); + const failedState = { status: 'failed', percent: progressMap[progressKey]?.percent || 0, details: err.message || 'Transcoding failed', mp4Url }; + progressMap[progressKey] = failedState; + broadcastWs(progressKey, { type: 'progress', key, progress: failedState }); + }) + .run(); + }; + + startFfmpeg(videoCodec); // Return immediately so the client can start polling or waiting res.json({ message: 'Transcoding started', mp4Url });