feat: Multi-Format Rendering und Render-Verlauf
This commit is contained in:
1 parent
bae998f6c9
commit
ced7588919
4 files changed
+314
-206
No files matched your search
+101
-54
@@ -2243,46 +2243,10 @@
|
||||
currentExportSessionId = transcodeStartRes.sessionId;
|
||||
|
||||
// Step 3: Monitor Server FFmpeg Transcode
|
||||
exportStatusText.textContent = `Server transcodiert ${format === 'prores' ? 'Apple ProRes 4444 (10-Bit Alpha)' : (format === 'qtrle' ? 'Lossless QuickTime RLE' : 'Master Video')}...`;
|
||||
exportStatusText.textContent = `Server transcodiert Master Video...`;
|
||||
if (exportFpsLive) exportFpsLive.textContent = 'FFmpeg Multi-Thread Encoding...';
|
||||
|
||||
let finishData = null;
|
||||
while (!finishData && !isExportCancelled) {
|
||||
await new Promise(r => setTimeout(r, 800));
|
||||
|
||||
const statusRes = await fetch(`/api/export/status/${currentExportSessionId}`);
|
||||
if (!statusRes.ok) {
|
||||
const errData = await statusRes.json().catch(() => ({}));
|
||||
throw new Error(errData.error || 'Fehler beim Abrufen des Video-Status');
|
||||
}
|
||||
|
||||
const statusJson = await statusRes.json();
|
||||
if (statusJson.status === 'ready') {
|
||||
finishData = statusJson;
|
||||
break;
|
||||
} else if (statusJson.status === 'error') {
|
||||
throw new Error(statusJson.error || 'FFmpeg Transcoder-Fehler auf dem Server');
|
||||
} else {
|
||||
const mb = (statusJson.sizeBytes / 1024 / 1024).toFixed(1);
|
||||
exportStatusText.textContent = `Schreibe Master-Datei auf Server (${mb} MB)...`;
|
||||
}
|
||||
}
|
||||
|
||||
if (isExportCancelled) throw new Error('Export durch Benutzer abgebrochen');
|
||||
|
||||
// Trigger automatic download
|
||||
const downloadLink = document.createElement('a');
|
||||
downloadLink.style.display = 'none';
|
||||
downloadLink.href = finishData.downloadUrl;
|
||||
downloadLink.download = finishData.filename;
|
||||
document.body.appendChild(downloadLink);
|
||||
downloadLink.click();
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(downloadLink);
|
||||
}, 5000);
|
||||
|
||||
const finalMb = (finishData.sizeBytes / 1024 / 1024).toFixed(1);
|
||||
alert(`🎉 Fertig! Dein Studio-Master "${finishData.filename}" (${finalMb} MB) wurde erfolgreich transcodiert und heruntergeladen.`);
|
||||
await monitorServerSession(currentExportSessionId, format);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2414,11 +2378,15 @@
|
||||
}
|
||||
|
||||
// Poll status until ready
|
||||
await monitorServerSession(currentExportSessionId, format);
|
||||
}
|
||||
|
||||
async function monitorServerSession(sessionId, format) {
|
||||
let finishData = null;
|
||||
while (!finishData && !isExportCancelled) {
|
||||
await new Promise(r => setTimeout(r, 600));
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
|
||||
const statusRes = await fetch(`/api/export/status/${currentExportSessionId}`);
|
||||
const statusRes = await fetch(`/api/export/status/${sessionId}`);
|
||||
if (!statusRes.ok) {
|
||||
const errData = await statusRes.json().catch(() => ({}));
|
||||
throw new Error(errData.error || 'Fehler beim Abrufen des Video-Status');
|
||||
@@ -2432,25 +2400,15 @@
|
||||
throw new Error(statusJson.error || 'FFmpeg Encoder-Fehler auf dem Server');
|
||||
} else {
|
||||
const mb = (statusJson.sizeBytes / 1024 / 1024).toFixed(1);
|
||||
exportStatusText.textContent = `Finalisiere Video (${mb} MB geschrieben)...`;
|
||||
if (exportFpsLive) exportFpsLive.textContent = 'Schreibe Datei auf Festplatte...';
|
||||
exportStatusText.textContent = `Server verarbeitet Render-Job (${mb} MB geschrieben)...`;
|
||||
if (exportFpsLive) exportFpsLive.textContent = 'Warten auf FFmpeg...';
|
||||
}
|
||||
}
|
||||
|
||||
if (isExportCancelled) throw new Error('Export durch Benutzer abgebrochen');
|
||||
|
||||
// Automatic Download Trigger
|
||||
const downloadLink = document.createElement('a');
|
||||
downloadLink.style.display = 'none';
|
||||
downloadLink.href = finishData.downloadUrl;
|
||||
downloadLink.download = finishData.filename;
|
||||
document.body.appendChild(downloadLink);
|
||||
downloadLink.click();
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(downloadLink);
|
||||
}, 1000);
|
||||
|
||||
alert(`🎉 Fertig! Dein Video "${finishData.filename}" wurde in makelloser Studio-Qualität gerendert.`);
|
||||
fetchRenderHistory(); // Refresh the UI list
|
||||
alert(`🎉 Fertig! Das Rendering ist abgeschlossen. Wähle nun im Render-Verlauf dein gewünschtes Format zum Herunterladen aus.`);
|
||||
}
|
||||
|
||||
async function startExport() {
|
||||
@@ -2772,6 +2730,11 @@
|
||||
});
|
||||
btnCancelExport.addEventListener('click', cancelExport);
|
||||
|
||||
const btnRefreshHistory = document.getElementById('btnRefreshHistory');
|
||||
if (btnRefreshHistory) {
|
||||
btnRefreshHistory.addEventListener('click', fetchRenderHistory);
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.code === 'Space' && e.target.tagName !== 'INPUT') {
|
||||
e.preventDefault();
|
||||
@@ -2780,12 +2743,96 @@
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchRenderHistory() {
|
||||
try {
|
||||
const res = await fetch('/api/export/history');
|
||||
const history = await res.json();
|
||||
const listContainer = document.getElementById('exportHistoryList');
|
||||
if (!listContainer) return;
|
||||
|
||||
if (!history || history.length === 0) {
|
||||
listContainer.innerHTML = '<div class="empty-state">Noch keine Renderings im Verlauf.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
listContainer.innerHTML = '';
|
||||
history.forEach(sess => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'export-history-item';
|
||||
|
||||
let statusHtml = sess.error
|
||||
? `<span style="color: #ff3333">❌ Fehler</span>`
|
||||
: (sess.done ? `<span style="color: #00ff88">✅ Fertig</span>` : `<span style="color: #ffaa00">🔄 Rendert...</span>`);
|
||||
|
||||
const d = new Date(sess.createdAt);
|
||||
const dateStr = d.toLocaleDateString() + ' ' + d.toLocaleTimeString();
|
||||
|
||||
let downloadsHtml = '';
|
||||
if (sess.done && !sess.error && sess.formats) {
|
||||
downloadsHtml = `
|
||||
<div class="export-history-actions">
|
||||
<a href="/api/export/download/${sess.id}/mp4" class="btn btn-primary" download>⬇️ MP4</a>
|
||||
<a href="/api/export/download/${sess.id}/webm_alpha" class="btn btn-secondary" download>⬇️ WebM (Alpha)</a>
|
||||
<a href="/api/export/download/${sess.id}/prores" class="btn btn-secondary" download>⬇️ ProRes</a>
|
||||
<a href="/api/export/download/${sess.id}/qtrle" class="btn btn-secondary" download>⬇️ QTRLE</a>
|
||||
</div>
|
||||
`;
|
||||
} else if (!sess.done && !sess.error) {
|
||||
downloadsHtml = `<div class="export-history-meta">Dieses Rendering läuft gerade...</div>`;
|
||||
}
|
||||
|
||||
item.innerHTML = `
|
||||
<div class="export-history-header">
|
||||
<div class="export-history-title">${sess.title}</div>
|
||||
<div class="export-history-meta">${statusHtml}</div>
|
||||
</div>
|
||||
<div class="export-history-meta">${dateStr} | ID: ${sess.id.substring(0, 15)}...</div>
|
||||
${downloadsHtml}
|
||||
`;
|
||||
listContainer.appendChild(item);
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch render history:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkActiveSessions() {
|
||||
try {
|
||||
const res = await fetch('/api/export/history');
|
||||
const history = await res.json();
|
||||
|
||||
const runningSession = history.find(s => !s.done && !s.error);
|
||||
if (runningSession) {
|
||||
currentExportSessionId = runningSession.id;
|
||||
state.isExporting = true;
|
||||
btnStartExport.classList.add('hidden');
|
||||
exportProgressCard.classList.remove('hidden');
|
||||
exportStatusText.textContent = 'Stelle Verbindung zum Server-Render wieder her...';
|
||||
exportPercent.textContent = '...';
|
||||
monitorServerSession(runningSession.id, 'all').catch(err => {
|
||||
console.error(err);
|
||||
alert('Fehler beim Überwachen des wiederhergestellten Render-Jobs: ' + err.message);
|
||||
}).finally(() => {
|
||||
state.isExporting = false;
|
||||
currentExportSessionId = null;
|
||||
exportProgressCard.classList.add('hidden');
|
||||
btnStartExport.classList.remove('hidden');
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Could not check active sessions on load', e);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Initialize App ---
|
||||
function init() {
|
||||
setAspectRatio('16:9');
|
||||
applyPreset('trap_nation');
|
||||
setupEvents();
|
||||
updateExportEngineBadge();
|
||||
fetchRenderHistory();
|
||||
checkActiveSessions();
|
||||
state.isPlaying = true;
|
||||
iconPlay.classList.add('hidden');
|
||||
iconPause.classList.remove('hidden');
|
||||
|
||||
@@ -1236,6 +1236,19 @@
|
||||
<button id="btnCancelExport" class="btn btn-secondary btn-sm" style="margin-top: 8px;">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Render History Section -->
|
||||
<div class="panel-section">
|
||||
<div class="section-header-with-toggle">
|
||||
<h3 class="section-title">Render Verlauf</h3>
|
||||
<button id="btnRefreshHistory" class="btn btn-secondary btn-sm" title="Verlauf aktualisieren">
|
||||
🔄
|
||||
</button>
|
||||
</div>
|
||||
<div class="export-history-list" id="exportHistoryList">
|
||||
<div class="empty-state">Noch keine Renderings im Verlauf.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -1367,3 +1367,45 @@ body {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
|
||||
/* Export History */
|
||||
.export-history-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.export-history-item {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.export-history-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.export-history-title {
|
||||
font-weight: 600;
|
||||
color: var(--text-main);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.export-history-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.export-history-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.export-history-actions .btn {
|
||||
font-size: 0.75rem;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
+158
-152
@@ -98,10 +98,30 @@ function cleanupSession(id) {
|
||||
if (sess.videoPath && fs.existsSync(sess.videoPath)) {
|
||||
try { fs.unlinkSync(sess.videoPath); } catch (e) {}
|
||||
}
|
||||
activeSessions.delete(id);
|
||||
// We intentionally DO NOT delete the output files here,
|
||||
// so they remain available in the history until server restart.
|
||||
// activeSessions.delete(id); // wir behalten die Session im Speicher für die Historie
|
||||
}
|
||||
}
|
||||
|
||||
// GET history endpoint
|
||||
app.get('/api/export/history', (req, res) => {
|
||||
const history = [];
|
||||
for (const [id, sess] of activeSessions.entries()) {
|
||||
history.push({
|
||||
id: sess.id,
|
||||
title: sess.title || 'Visualizer',
|
||||
createdAt: sess.createdAt,
|
||||
done: sess.done,
|
||||
error: sess.error,
|
||||
formats: sess.formats
|
||||
});
|
||||
}
|
||||
// Neueste zuerst
|
||||
history.sort((a, b) => b.createdAt - a.createdAt);
|
||||
res.json(history);
|
||||
});
|
||||
|
||||
/**
|
||||
* ⚡ Ultra-Fast Mezzanine Transcoding Route (ProRes 4444 / QTRLE / MP4)
|
||||
* Receives compact GPU-rendered video (WebM VP9 Alpha / MP4) and original audio,
|
||||
@@ -123,66 +143,72 @@ app.post('/api/export/transcode', upload.fields([
|
||||
const title = (req.body.title || 'Visualizer').replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
|
||||
const ext = (format === 'prores' || format === 'qtrle') ? 'mov' : (format === 'webm_alpha' ? 'webm' : 'mp4');
|
||||
let formatSuffix = 'Visualizer';
|
||||
if (format === 'prores') formatSuffix = 'ProRes4444_Alpha';
|
||||
else if (format === 'qtrle') formatSuffix = 'Lossless_QTRLE_Alpha';
|
||||
else if (format === 'webm_alpha') formatSuffix = 'Alpha_DaVinci';
|
||||
|
||||
// We will render ALL formats simultaneously
|
||||
const formats = {
|
||||
mp4: {
|
||||
filename: `${title}_Master.mp4`,
|
||||
path: path.join(TEMP_DIR, `${sessionId}_${title}_Master.mp4`),
|
||||
formatId: 'mp4'
|
||||
},
|
||||
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'
|
||||
}
|
||||
};
|
||||
|
||||
const outputFilename = `${title}_${formatSuffix}.${ext}`;
|
||||
const outputPath = path.join(TEMP_DIR, `${sessionId}_${outputFilename}`);
|
||||
|
||||
console.log(`[${sessionId}] ⚡ High-Speed Transcoding to ${format.toUpperCase()} -> ${outputFilename}`);
|
||||
console.log(`[${sessionId}] ⚡ High-Speed Transcoding to ALL FORMATS`);
|
||||
|
||||
const ffmpegArgs = ['-y', '-i', videoPath];
|
||||
if (audioPath) {
|
||||
ffmpegArgs.push('-i', audioPath);
|
||||
ffmpegArgs.push('-map', '0:v:0', '-map', '1:a:0');
|
||||
}
|
||||
|
||||
if (format === 'prores') {
|
||||
// Apple ProRes 4444 with True 10-bit Alpha for DaVinci Resolve / Premiere
|
||||
// MUST use 'prores_ks' because native 'prores' ignores/drops the alpha channel (producing black background)
|
||||
ffmpegArgs.push(
|
||||
'-c:v', 'prores_ks',
|
||||
'-profile:v', '4',
|
||||
'-qscale:v', '11',
|
||||
'-pix_fmt', 'yuva444p10le',
|
||||
'-vendor', 'apl0',
|
||||
'-threads', '0',
|
||||
'-c:a', 'pcm_s16le',
|
||||
'-shortest',
|
||||
outputPath
|
||||
);
|
||||
} else if (format === 'qtrle') {
|
||||
ffmpegArgs.push(
|
||||
'-c:v', 'qtrle',
|
||||
'-pix_fmt', 'argb',
|
||||
'-threads', '0',
|
||||
'-c:a', 'pcm_s16le',
|
||||
'-shortest',
|
||||
outputPath
|
||||
);
|
||||
} else if (format === 'webm_alpha') {
|
||||
ffmpegArgs.push(
|
||||
'-c:v', 'copy',
|
||||
'-c:a', 'libopus',
|
||||
'-b:a', '320k',
|
||||
'-shortest',
|
||||
outputPath
|
||||
);
|
||||
} else {
|
||||
ffmpegArgs.push(
|
||||
'-c:v', 'libx264',
|
||||
'-crf', '14',
|
||||
'-preset', 'fast',
|
||||
'-pix_fmt', 'yuv420p',
|
||||
'-threads', '0',
|
||||
'-c:a', 'aac',
|
||||
'-b:a', '320k',
|
||||
'-shortest',
|
||||
outputPath
|
||||
);
|
||||
}
|
||||
// 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 = '';
|
||||
@@ -195,11 +221,11 @@ app.post('/api/export/transcode', upload.fields([
|
||||
|
||||
const session = {
|
||||
id: sessionId,
|
||||
title: title,
|
||||
createdAt: Date.now(),
|
||||
videoPath,
|
||||
audioPath,
|
||||
outputPath,
|
||||
outputFilename,
|
||||
format,
|
||||
formats,
|
||||
ffmpegProc,
|
||||
errorLog: '',
|
||||
done: false,
|
||||
@@ -220,8 +246,7 @@ app.post('/api/export/transcode', upload.fields([
|
||||
: `FFmpeg Transcode Fehler (Code ${code}): ${errorLog.slice(-400)}`;
|
||||
console.error(`[${sessionId}] ❌ Transcode failed:`, session.error);
|
||||
} else {
|
||||
const sizeMb = fs.existsSync(outputPath) ? (fs.statSync(outputPath).size / 1024 / 1024).toFixed(1) : '0';
|
||||
console.log(`[${sessionId}] ✅ Transcode complete! Size: ${sizeMb} MB`);
|
||||
console.log(`[${sessionId}] ✅ Transcode complete! (All Formats)`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -231,7 +256,7 @@ app.post('/api/export/transcode', upload.fields([
|
||||
});
|
||||
|
||||
activeSessions.set(sessionId, session);
|
||||
res.json({ sessionId, outputFilename });
|
||||
res.json({ sessionId, format });
|
||||
} catch (err) {
|
||||
console.error('Error starting transcode:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
@@ -257,15 +282,31 @@ app.post('/api/export/start', upload.single('audio'), async (req, res) => {
|
||||
const title = (req.body.title || 'Visualizer').replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
|
||||
const ext = (format === 'prores' || format === 'qtrle') ? 'mov' : (format === 'webm_alpha' ? 'webm' : 'mp4');
|
||||
let formatSuffix = 'Visualizer';
|
||||
if (format === 'prores') formatSuffix = 'ProRes4444_Alpha';
|
||||
else if (format === 'qtrle') formatSuffix = 'Lossless_QTRLE_Alpha';
|
||||
else if (format === 'webm_alpha') formatSuffix = 'Alpha_DaVinci';
|
||||
|
||||
const outputFilename = `${title}_${formatSuffix}.${ext}`;
|
||||
const outputPath = path.join(TEMP_DIR, `${sessionId}_${outputFilename}`);
|
||||
const formats = {
|
||||
mp4: {
|
||||
filename: `${title}_Master.mp4`,
|
||||
path: path.join(TEMP_DIR, `${sessionId}_${title}_Master.mp4`),
|
||||
formatId: 'mp4'
|
||||
},
|
||||
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'
|
||||
}
|
||||
};
|
||||
|
||||
console.log(`[${sessionId}] 🚀 Starting Render: ${format.toUpperCase()} ${width}x${height} @ ${fps}FPS -> ${outputFilename}`);
|
||||
console.log(`[${sessionId}] 🚀 Starting Render: ALL FORMATS ${width}x${height} @ ${fps}FPS`);
|
||||
|
||||
const ffmpegArgs = [
|
||||
'-y',
|
||||
@@ -277,61 +318,35 @@ app.post('/api/export/start', upload.single('audio'), async (req, res) => {
|
||||
'-i', audioPath
|
||||
];
|
||||
|
||||
if (format === 'prores') {
|
||||
// Apple ProRes 4444 with True 10-bit Alpha for DaVinci Resolve / Premiere
|
||||
// MUST use 'prores_ks' because native 'prores' ignores/drops the alpha channel (producing black background)
|
||||
ffmpegArgs.push(
|
||||
'-c:v', 'prores_ks',
|
||||
'-profile:v', '4',
|
||||
'-qscale:v', '11',
|
||||
'-pix_fmt', 'yuva444p10le',
|
||||
'-vendor', 'apl0',
|
||||
'-threads', '0',
|
||||
'-c:a', 'pcm_s16le',
|
||||
'-shortest',
|
||||
outputPath
|
||||
);
|
||||
} else if (format === 'qtrle') {
|
||||
// QuickTime Animation RLE - 100% Lossless RGBA (Direct DaVinci Resolve native)
|
||||
ffmpegArgs.push(
|
||||
'-c:v', 'qtrle',
|
||||
'-pix_fmt', 'argb',
|
||||
'-threads', '0',
|
||||
'-c:a', 'pcm_s16le',
|
||||
'-shortest',
|
||||
outputPath
|
||||
);
|
||||
} else if (format === 'webm_alpha') {
|
||||
// WebM VP9 with True Alpha (Lossless/High-bitrate studio quality)
|
||||
ffmpegArgs.push(
|
||||
'-c:v', 'libvpx-vp9',
|
||||
'-pix_fmt', 'yuva420p',
|
||||
'-b:v', '35M',
|
||||
'-minrate', '20M',
|
||||
'-maxrate', '50M',
|
||||
'-speed', '2',
|
||||
'-threads', '8',
|
||||
'-row-mt', '1',
|
||||
'-auto-alt-ref', '0',
|
||||
'-c:a', 'libopus',
|
||||
'-b:a', '320k',
|
||||
'-shortest',
|
||||
outputPath
|
||||
);
|
||||
} else {
|
||||
// MP4 H.264 (Broadcast quality CRF 14, high profile, yuv420p)
|
||||
ffmpegArgs.push(
|
||||
'-c:v', 'libx264',
|
||||
'-crf', '14',
|
||||
'-preset', 'fast',
|
||||
'-pix_fmt', 'yuv420p',
|
||||
'-threads', '0',
|
||||
'-c:a', 'aac',
|
||||
'-b:a', '320k',
|
||||
'-shortest',
|
||||
outputPath
|
||||
);
|
||||
}
|
||||
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', '8', '-row-mt', '1', '-auto-alt-ref', '0',
|
||||
'-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',
|
||||
'-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) => {
|
||||
@@ -344,10 +359,10 @@ app.post('/api/export/start', upload.single('audio'), async (req, res) => {
|
||||
|
||||
const session = {
|
||||
id: sessionId,
|
||||
title: title,
|
||||
createdAt: Date.now(),
|
||||
audioPath,
|
||||
outputPath,
|
||||
outputFilename,
|
||||
format,
|
||||
formats,
|
||||
ffmpegProc,
|
||||
errorLog: '',
|
||||
done: false,
|
||||
@@ -360,8 +375,7 @@ app.post('/api/export/start', upload.single('audio'), async (req, res) => {
|
||||
session.error = `FFmpeg Fehler (Code ${code}): ${errorLog.slice(-400)}`;
|
||||
console.error(`[${sessionId}] ❌ Render failed:`, session.error);
|
||||
} else {
|
||||
const sizeMb = fs.existsSync(outputPath) ? (fs.statSync(outputPath).size / 1024 / 1024).toFixed(1) : '0';
|
||||
console.log(`[${sessionId}] ✅ Render complete! Output size: ${sizeMb} MB`);
|
||||
console.log(`[${sessionId}] ✅ Render complete! (All Formats)`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -371,7 +385,7 @@ app.post('/api/export/start', upload.single('audio'), async (req, res) => {
|
||||
});
|
||||
|
||||
activeSessions.set(sessionId, session);
|
||||
res.json({ sessionId, outputFilename });
|
||||
res.json({ sessionId, format });
|
||||
} catch (err) {
|
||||
console.error('Error starting export:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
@@ -427,21 +441,17 @@ 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 (fs.existsSync(session.outputPath)) {
|
||||
sizeBytes = fs.statSync(session.outputPath).size;
|
||||
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) {
|
||||
if (!fs.existsSync(session.outputPath)) {
|
||||
return res.status(500).json({ status: 'error', error: 'Ausgabedatei wurde nicht gefunden.' });
|
||||
}
|
||||
return res.json({
|
||||
status: 'ready',
|
||||
downloadUrl: `/api/export/download/${session.id}`,
|
||||
filename: session.outputFilename,
|
||||
sizeBytes: sizeBytes
|
||||
});
|
||||
}
|
||||
@@ -452,33 +462,29 @@ app.get('/api/export/status/:id', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Download exported video file
|
||||
app.get('/api/export/download/:id', (req, res) => {
|
||||
// Download exported video file for a specific format
|
||||
app.get('/api/export/download/:id/:formatId', (req, res) => {
|
||||
const session = activeSessions.get(req.params.id);
|
||||
if (!session || !fs.existsSync(session.outputPath)) {
|
||||
const formatId = req.params.formatId;
|
||||
|
||||
if (!session || !session.formats || !session.formats[formatId] || !fs.existsSync(session.formats[formatId].path)) {
|
||||
return res.status(404).send('Datei nicht gefunden oder abgelaufen.');
|
||||
}
|
||||
|
||||
const stat = fs.statSync(session.outputPath);
|
||||
const mime = session.format.startsWith('prores') || session.format === 'qtrle'
|
||||
const formatObj = session.formats[formatId];
|
||||
const stat = fs.statSync(formatObj.path);
|
||||
const mime = formatId === 'prores' || formatId === 'qtrle'
|
||||
? 'video/quicktime'
|
||||
: (session.format === 'webm_alpha' ? 'video/webm' : 'video/mp4');
|
||||
: (formatId === 'webm_alpha' ? 'video/webm' : 'video/mp4');
|
||||
|
||||
res.setHeader('Content-Type', mime);
|
||||
res.setHeader('Content-Length', stat.size);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${session.outputFilename}"`);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${formatObj.filename}"`);
|
||||
|
||||
const stream = fs.createReadStream(session.outputPath);
|
||||
const stream = fs.createReadStream(formatObj.path);
|
||||
stream.pipe(res);
|
||||
|
||||
stream.on('end', () => {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (fs.existsSync(session.outputPath)) fs.unlinkSync(session.outputPath);
|
||||
cleanupSession(session.id);
|
||||
} catch (e) {}
|
||||
}, 300000);
|
||||
});
|
||||
|
||||
// Notice: We don't delete the file anymore so it's available in history
|
||||
});
|
||||
|
||||
// Cancel export session
|
||||
|
||||
Reference in new issue
Block a user