From 6e3f2fba4d5ebca2273ccbfad8f3dee8a31533c5 Mon Sep 17 00:00:00 2001 From: orfelorfel23 Date: Sun, 9 Aug 2026 12:31:40 +0200 Subject: [PATCH] feat(export): sequential multi-format rendering to save RAM --- public/app.js | 42 +------ public/index.html | 20 +--- server.mjs | 277 +++++++++++++++++++++------------------------- 3 files changed, 135 insertions(+), 204 deletions(-) diff --git a/public/app.js b/public/app.js index a0caed5..12b0728 100644 --- a/public/app.js +++ b/public/app.js @@ -1702,37 +1702,7 @@ return new Blob([view], { type: 'audio/wav' }); } - function updateExportEngineBadge() { - const formatSelect = document.getElementById('exportFormat'); - if (!formatSelect) return; - const format = formatSelect.value; - const badge = document.getElementById('exportEngineBadge'); - const title = document.getElementById('exportEngineTitle'); - const desc = document.getElementById('exportEngineDesc'); - const icon = badge ? badge.querySelector('.badge-icon') : null; - if (!badge || !title || !desc) return; - - if (format === 'webm_alpha' || format === 'mp4') { - badge.classList.remove('badge-server'); - if (icon) icon.textContent = '⚡'; - title.textContent = 'Client-GPU Export (Blitzschnell im Browser)'; - if (format === 'webm_alpha') { - desc.textContent = 'Rendert direkt im Browser bei 60–120+ FPS mit transparentem Alphakanal für DaVinci Resolve (0 MB Server-Upload).'; - } else { - desc.textContent = 'Rendert direkt im Browser bei 60–120+ FPS als H.264 MP4 für YouTube / Social Media (0 MB Server-Upload).'; - } - } else { - badge.classList.add('badge-server'); - if (icon) icon.textContent = '⚡🖥️'; - title.textContent = 'Studio Master Hybrid-Export (GPU + Server FFmpeg)'; - if (format === 'prores') { - desc.textContent = 'Rendert mit GPU-Beschleunigung und wandelt auf dem Server via FFmpeg in echtes Apple ProRes 4444 (10-Bit Alpha) um – in unter 2 Minuten!'; - } else { - desc.textContent = 'Rendert mit GPU-Beschleunigung und wandelt auf dem Server via FFmpeg in verlustfreies QuickTime RLE um.'; - } - } - } /** * Probes for the best supported WebCodecs Video configuration @@ -2399,9 +2369,8 @@ } else if (statusJson.status === 'error') { throw new Error(statusJson.error || 'FFmpeg Encoder-Fehler auf dem Server'); } else { - const mb = (statusJson.sizeBytes / 1024 / 1024).toFixed(1); - exportStatusText.textContent = `Server verarbeitet Render-Job (${mb} MB geschrieben)...`; - if (exportFpsLive) exportFpsLive.textContent = 'Warten auf FFmpeg...'; + exportStatusText.textContent = `Server verarbeitet Render-Job: ${statusJson.currentStatus}`; + if (exportFpsLive) exportFpsLive.textContent = ''; } } @@ -2719,10 +2688,7 @@ } }); - const exportFormatSelect = document.getElementById('exportFormat'); - if (exportFormatSelect) { - exportFormatSelect.addEventListener('change', updateExportEngineBadge); - } + // Export format listener removed as it is now static btnStartExport.addEventListener('click', startExport); headerExportBtn.addEventListener('click', () => { @@ -2830,7 +2796,7 @@ setAspectRatio('16:9'); applyPreset('trap_nation'); setupEvents(); - updateExportEngineBadge(); + // updateExportEngineBadge(); fetchRenderHistory(); checkActiveSessions(); state.isPlaying = true; diff --git a/public/index.html b/public/index.html index e3486eb..281be06 100644 --- a/public/index.html +++ b/public/index.html @@ -1176,24 +1176,12 @@
⚡
- Client-GPU Export (Empfohlen) - Rendert direkt im Browser bei 60–120+ FPS mit 0 MB Server-Upload. + Multi-Format Server Export + Rendert automatisch ProRes, WebM Alpha, QTRLE und MP4 im Hintergrund.
- -
- - -
+ +
diff --git a/server.mjs b/server.mjs index 279be11..9b42092 100644 --- a/server.mjs +++ b/server.mjs @@ -144,80 +144,30 @@ app.post('/api/export/transcode', upload.fields([ const ext = (format === 'prores' || format === 'qtrle') ? 'mov' : (format === 'webm_alpha' ? 'webm' : 'mp4'); - // We will render ALL formats simultaneously const formats = { - mp4: { - filename: `${title}_Master.mp4`, - path: path.join(TEMP_DIR, `${sessionId}_${title}_Master.mp4`), - formatId: 'mp4' + prores: { + filename: `${title}_ProRes4444_Alpha.mov`, + path: path.join(TEMP_DIR, `${sessionId}_${title}_ProRes4444_Alpha.mov`), + formatId: 'prores' }, webm_alpha: { filename: `${title}_Alpha_DaVinci.webm`, path: path.join(TEMP_DIR, `${sessionId}_${title}_Alpha_DaVinci.webm`), formatId: 'webm_alpha' }, - prores: { - filename: `${title}_ProRes4444_Alpha.mov`, - path: path.join(TEMP_DIR, `${sessionId}_${title}_ProRes4444_Alpha.mov`), - formatId: 'prores' - }, qtrle: { filename: `${title}_Lossless_QTRLE_Alpha.mov`, path: path.join(TEMP_DIR, `${sessionId}_${title}_Lossless_QTRLE_Alpha.mov`), formatId: 'qtrle' + }, + mp4: { + filename: `${title}_Master.mp4`, + path: path.join(TEMP_DIR, `${sessionId}_${title}_Master.mp4`), + formatId: 'mp4' } }; - console.log(`[${sessionId}] ⚡ High-Speed Transcoding to ALL FORMATS`); - - const ffmpegArgs = ['-y', '-i', videoPath]; - if (audioPath) { - ffmpegArgs.push('-i', audioPath); - } - - // Map video and audio to all outputs - const mapArgs = audioPath ? ['-map', '0:v:0', '-map', '1:a:0'] : ['-map', '0:v:0']; - - // 1. MP4 - ffmpegArgs.push( - ...mapArgs, - '-c:v', 'libx264', '-crf', '14', '-preset', 'fast', '-pix_fmt', 'yuv420p', '-threads', '0', - ...(audioPath ? ['-c:a', 'aac', '-b:a', '320k'] : []), - '-shortest', formats.mp4.path - ); - - // 2. WebM VP9 Alpha - ffmpegArgs.push( - ...mapArgs, - '-c:v', 'copy', - ...(audioPath ? ['-c:a', 'libopus', '-b:a', '320k'] : []), - '-shortest', formats.webm_alpha.path - ); - - // 3. ProRes 4444 - ffmpegArgs.push( - ...mapArgs, - '-c:v', 'prores_ks', '-profile:v', '4', '-qscale:v', '11', '-pix_fmt', 'yuva444p10le', '-vendor', 'apl0', '-threads', '0', - ...(audioPath ? ['-c:a', 'pcm_s16le'] : []), - '-shortest', formats.prores.path - ); - - // 4. QTRLE - ffmpegArgs.push( - ...mapArgs, - '-c:v', 'qtrle', '-pix_fmt', 'argb', '-threads', '0', - ...(audioPath ? ['-c:a', 'pcm_s16le'] : []), - '-shortest', formats.qtrle.path - ); - - const ffmpegProc = spawn('ffmpeg', ffmpegArgs); - let errorLog = ''; - ffmpegProc.stderr.on('data', (d) => { - errorLog += d.toString(); - if (errorLog.length > 10000) { - errorLog = errorLog.slice(-8000); - } - }); + console.log(`[${sessionId}] ⚡ High-Speed Transcoding starting (Sequential)`); const session = { id: sessionId, @@ -226,36 +176,19 @@ app.post('/api/export/transcode', upload.fields([ videoPath, audioPath, formats, - ffmpegProc, + ffmpegProc: null, // No single proc anymore errorLog: '', done: false, - error: null + error: null, + currentStatus: 'Startet...' }; - - ffmpegProc.on('close', (code, signal) => { - session.done = true; - if (videoPath && fs.existsSync(videoPath)) { - try { fs.unlinkSync(videoPath); } catch (e) {} - } - if (audioPath && fs.existsSync(audioPath)) { - try { fs.unlinkSync(audioPath); } catch (e) {} - } - if (code !== 0 || signal) { - session.error = signal - ? `FFmpeg Transcode Prozess wurde durch Signal ${signal} abgebrochen: ${errorLog.slice(-400)}` - : `FFmpeg Transcode Fehler (Code ${code}): ${errorLog.slice(-400)}`; - console.error(`[${sessionId}] ❌ Transcode failed:`, session.error); - } else { - console.log(`[${sessionId}] ✅ Transcode complete! (All Formats)`); - } - }); - - ffmpegProc.on('error', (err) => { - session.done = true; - session.error = err.message; - }); - activeSessions.set(sessionId, session); + + // Run sequentially in background + runSequentialEncodes(session, videoPath, audioPath, ['prores', 'webm_alpha', 'qtrle', 'mp4']).catch(err => { + console.error(`[${sessionId}] Queue Error:`, err); + }); + res.json({ sessionId, format }); } catch (err) { console.error('Error starting transcode:', err); @@ -284,29 +217,29 @@ app.post('/api/export/start', upload.single('audio'), async (req, res) => { const ext = (format === 'prores' || format === 'qtrle') ? 'mov' : (format === 'webm_alpha' ? 'webm' : 'mp4'); const formats = { - mp4: { - filename: `${title}_Master.mp4`, - path: path.join(TEMP_DIR, `${sessionId}_${title}_Master.mp4`), - formatId: 'mp4' + prores: { + filename: `${title}_ProRes4444_Alpha.mov`, + path: path.join(TEMP_DIR, `${sessionId}_${title}_ProRes4444_Alpha.mov`), + formatId: 'prores' }, webm_alpha: { filename: `${title}_Alpha_DaVinci.webm`, path: path.join(TEMP_DIR, `${sessionId}_${title}_Alpha_DaVinci.webm`), formatId: 'webm_alpha' }, - prores: { - filename: `${title}_ProRes4444_Alpha.mov`, - path: path.join(TEMP_DIR, `${sessionId}_${title}_ProRes4444_Alpha.mov`), - formatId: 'prores' - }, qtrle: { filename: `${title}_Lossless_QTRLE_Alpha.mov`, path: path.join(TEMP_DIR, `${sessionId}_${title}_Lossless_QTRLE_Alpha.mov`), formatId: 'qtrle' + }, + mp4: { + filename: `${title}_Master.mp4`, + path: path.join(TEMP_DIR, `${sessionId}_${title}_Master.mp4`), + formatId: 'mp4' } }; - console.log(`[${sessionId}] 🚀 Starting Render: ALL FORMATS ${width}x${height} @ ${fps}FPS`); + console.log(`[${sessionId}] 🚀 Starting Render: Live to ProRes ${width}x${height} @ ${fps}FPS`); const ffmpegArgs = [ '-y', @@ -315,42 +248,14 @@ app.post('/api/export/start', upload.single('audio'), async (req, res) => { '-s', `${width}x${height}`, '-framerate', fps.toString(), '-i', '-', - '-i', audioPath - ]; - - const mapArgs = ['-map', '0:v:0', '-map', '1:a:0']; - - // 1. MP4 (Hardware accelerated preset if possible, fallback to CPU) - ffmpegArgs.push( - ...mapArgs, - '-c:v', 'libx264', '-crf', '14', '-preset', 'fast', '-pix_fmt', 'yuv420p', '-threads', '0', - '-c:a', 'aac', '-b:a', '320k', '-shortest', formats.mp4.path - ); - - // 2. WebM VP9 Alpha (CPU intensive) - ffmpegArgs.push( - ...mapArgs, - '-c:v', 'libvpx-vp9', '-pix_fmt', 'yuva420p', '-b:v', '35M', '-minrate', '20M', '-maxrate', '50M', '-speed', '2', '-threads', '4', '-row-mt', '1', '-auto-alt-ref', '0', - '-c:a', 'libopus', '-b:a', '320k', '-shortest', formats.webm_alpha.path - ); - - // 3. ProRes 4444 - ffmpegArgs.push( - ...mapArgs, + '-i', audioPath, + '-map', '0:v:0', '-map', '1:a:0', '-c:v', 'prores_ks', '-profile:v', '4', '-qscale:v', '11', '-pix_fmt', 'yuva444p10le', '-vendor', 'apl0', '-threads', '0', '-c:a', 'pcm_s16le', '-shortest', formats.prores.path - ); - - // 4. QTRLE - ffmpegArgs.push( - ...mapArgs, - '-c:v', 'qtrle', '-pix_fmt', 'argb', '-threads', '0', - '-c:a', 'pcm_s16le', '-shortest', formats.qtrle.path - ); + ]; const ffmpegProc = spawn('ffmpeg', ffmpegArgs); ffmpegProc.stdin.on('error', (err) => { - // Prevent unhandled EPIPE if FFmpeg closes before stdin is flushed console.warn(`[${sessionId}] Warning on stdin pipe:`, err.message); }); @@ -366,16 +271,22 @@ app.post('/api/export/start', upload.single('audio'), async (req, res) => { ffmpegProc, errorLog: '', done: false, - error: null + error: null, + currentStatus: 'Live Empfang & ProRes Render...' }; + activeSessions.set(sessionId, session); ffmpegProc.on('close', (code) => { - session.done = true; if (code !== 0) { - session.error = `FFmpeg Fehler (Code ${code}): ${errorLog.slice(-400)}`; - console.error(`[${sessionId}] ❌ Render failed:`, session.error); + session.error = `FFmpeg Fehler beim Live-Render (Code ${code}): ${errorLog.slice(-400)}`; + console.error(`[${sessionId}] ❌ Live Render failed:`, session.error); + session.done = true; } else { - console.log(`[${sessionId}] ✅ Render complete! (All Formats)`); + console.log(`[${sessionId}] ✅ Live Render (ProRes) complete! Starting background queue...`); + // Start remaining encodes from the ProRes master file using the original audioPath + runSequentialEncodes(session, formats.prores.path, audioPath, ['webm_alpha', 'qtrle', 'mp4']).catch(err => { + console.error(`[${sessionId}] Background Queue Error:`, err); + }); } }); @@ -384,7 +295,6 @@ app.post('/api/export/start', upload.single('audio'), async (req, res) => { session.error = err.message; }); - activeSessions.set(sessionId, session); res.json({ sessionId, format }); } catch (err) { console.error('Error starting export:', err); @@ -392,6 +302,88 @@ app.post('/api/export/start', upload.single('audio'), async (req, res) => { } }); +async function runSequentialEncodes(session, inputVideoPath, inputAudioPath, targetFormats) { + for (let i = 0; i < targetFormats.length; i++) { + const formatId = targetFormats[i]; + const formatInfo = session.formats[formatId]; + if (!formatInfo) continue; + + session.currentStatus = `Rendere ${i+1}/${targetFormats.length}: ${formatId.toUpperCase()}...`; + + const ffmpegArgs = ['-y', '-i', inputVideoPath]; + if (inputAudioPath) { + ffmpegArgs.push('-i', inputAudioPath); + } + + const mapArgs = inputAudioPath ? ['-map', '0:v:0', '-map', '1:a:0'] : ['-map', '0:v:0']; + + if (formatId === 'mp4') { + ffmpegArgs.push( + ...mapArgs, + '-c:v', 'libx264', '-crf', '14', '-preset', 'fast', '-pix_fmt', 'yuv420p', '-threads', '0', + ...(inputAudioPath ? ['-c:a', 'aac', '-b:a', '320k'] : []), + '-shortest', formatInfo.path + ); + } else if (formatId === 'webm_alpha') { + ffmpegArgs.push( + ...mapArgs, + '-c:v', 'libvpx-vp9', '-pix_fmt', 'yuva420p', '-b:v', '35M', '-minrate', '20M', '-maxrate', '50M', '-speed', '2', '-threads', '4', '-row-mt', '1', '-auto-alt-ref', '0', + '-c:a', 'libopus', '-b:a', '320k', '-shortest', formatInfo.path + ); + } else if (formatId === 'prores') { + ffmpegArgs.push( + ...mapArgs, + '-c:v', 'prores_ks', '-profile:v', '4', '-qscale:v', '11', '-pix_fmt', 'yuva444p10le', '-vendor', 'apl0', '-threads', '0', + ...(inputAudioPath ? ['-c:a', 'pcm_s16le'] : []), + '-shortest', formatInfo.path + ); + } else if (formatId === 'qtrle') { + ffmpegArgs.push( + ...mapArgs, + '-c:v', 'qtrle', '-pix_fmt', 'argb', '-threads', '0', + ...(inputAudioPath ? ['-c:a', 'pcm_s16le'] : []), + '-shortest', formatInfo.path + ); + } + + console.log(`[${session.id}] ⏳ Starting sequential task: ${formatId}`); + + await new Promise((resolve, reject) => { + const proc = spawn('ffmpeg', ffmpegArgs); + session.ffmpegProc = proc; // Update current process so cleanupSession can kill it if needed + let errorLog = ''; + + proc.stderr.on('data', d => { + errorLog += d.toString(); + if (errorLog.length > 5000) errorLog = errorLog.slice(-4000); + }); + + proc.on('close', code => { + if (code !== 0) { + reject(new Error(`Code ${code} during ${formatId}: ${errorLog.slice(-300)}`)); + } else { + resolve(); + } + }); + + proc.on('error', err => reject(err)); + }); + + console.log(`[${session.id}] ✅ Sequential task finished: ${formatId}`); + } + + // Clean up original files if they were temporary + if (session.videoPath && fs.existsSync(session.videoPath)) { + try { fs.unlinkSync(session.videoPath); } catch (e) {} + } + if (session.audioPath && fs.existsSync(session.audioPath)) { + try { fs.unlinkSync(session.audioPath); } catch (e) {} + } + + session.currentStatus = 'Fertig'; + session.done = true; +} + // Stream raw RGBA frame or multi-frame batch app.post(['/api/export/frame/:id', '/api/export/frame-batch/:id'], express.raw({ type: 'application/octet-stream', limit: '500mb' }), (req, res) => { const session = activeSessions.get(req.params.id); @@ -441,24 +433,9 @@ app.get('/api/export/status/:id', (req, res) => { return res.status(500).json({ status: 'error', error: session.error }); } - // We can track the size of the mp4 format as a proxy for progress - let sizeBytes = 0; - try { - if (session.formats && session.formats.mp4 && fs.existsSync(session.formats.mp4.path)) { - sizeBytes = fs.statSync(session.formats.mp4.path).size; - } - } catch (e) {} - - if (session.done) { - return res.json({ - status: 'ready', - sizeBytes: sizeBytes - }); - } - res.json({ - status: 'finalizing', - sizeBytes: sizeBytes + status: session.done ? 'ready' : 'finalizing', + currentStatus: session.currentStatus || 'In Bearbeitung...' }); });