feat(export): sequential multi-format rendering to save RAM

This commit is contained in:
Jannik committed 2026-08-09 12:31:40 +02:00
1 parent 1acafdec0b
commit 6e3f2fba4d
3 files changed
+135 -204

No files matched your search

+4 -38
View File
@@ -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;
+4 -16
View File
@@ -1176,24 +1176,12 @@
<div class="export-engine-badge" id="exportEngineBadge">
<span class="badge-icon">⚡</span>
<div class="badge-content">
<strong id="exportEngineTitle">Client-GPU Export (Empfohlen)</strong>
<span id="exportEngineDesc">Rendert direkt im Browser bei 60–120+ FPS mit 0 MB Server-Upload.</span>
<strong id="exportEngineTitle">Multi-Format Server Export</strong>
<span id="exportEngineDesc">Rendert automatisch ProRes, WebM Alpha, QTRLE und MP4 im Hintergrund.</span>
</div>
</div>
<div class="form-group" style="margin-top: 12px;">
<label for="exportFormat">Format & Render-Engine</label>
<select id="exportFormat" class="select-input">
<optgroup label="⚡ Client-GPU Export (Blitzschnell im Browser & 0 MB Upload)">
<option value="webm_alpha" selected>WebM VP9 (.webm Alpha - DaVinci Resolve Transparenz)</option>
<option value="mp4">MP4 Video (.mp4 Full HD / 4K - YouTube & Social Media)</option>
</optgroup>
<optgroup label="🖥️ Studio Master Server Export (FFmpeg)">
<option value="prores">Apple ProRes 4444 (.mov 10-Bit Alpha - DaVinci Studio Master)</option>
<option value="qtrle">QuickTime Animation RLE (.mov - 100% Lossless Alpha)</option>
</optgroup>
</select>
</div>
<input type="hidden" id="exportFormat" value="prores">
<div class="form-row-2">
<div class="form-group">
+127 -150
View File
@@ -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...'
});
});