feat: Add high-speed client-side GPU video export (WebCodecs) and server batching

This commit is contained in:
Jannik committed 2026-08-08 22:13:58 +02:00
1 parent 0f0872ee36
commit af19c3c968
8 files changed
+4024 -169

No files matched your search

+37 -1
View File
@@ -11,9 +11,23 @@
"dependencies": {
"cors": "^2.8.5",
"express": "^4.21.2",
"multer": "^1.4.5-lts.1"
"mp4-muxer": "^5.2.2",
"multer": "^1.4.5-lts.1",
"webm-muxer": "^5.1.4"
}
},
"node_modules/@types/dom-webcodecs": {
"version": "0.1.18",
"resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.18.tgz",
"integrity": "sha512-vAvE8C9DGWR+tkb19xyjk1TSUlJ7RUzzp4a9Anu7mwBT+fpyePWK1UxmH14tMO5zHmrnrRIMg5NutnnDztLxgg==",
"license": "MIT"
},
"node_modules/@types/wicg-file-system-access": {
"version": "2020.9.8",
"resolved": "https://registry.npmjs.org/@types/wicg-file-system-access/-/wicg-file-system-access-2020.9.8.tgz",
"integrity": "sha512-ggMz8nOygG7d/stpH40WVaNvBwuyYLnrg5Mbyf6bmsj/8+gb6Ei4ZZ9/4PNpcPNTT8th9Q8sM8wYmWGjMWLX/A==",
"license": "MIT"
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -601,6 +615,17 @@
"mkdirp": "bin/cmd.js"
}
},
"node_modules/mp4-muxer": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/mp4-muxer/-/mp4-muxer-5.2.2.tgz",
"integrity": "sha512-dhozjTywI0h2qFzeShagt8YYw811fh1XlwiDCE2f6Aeqf6xG2CyuShoSa5E0AZDO8pPF0JOZ3wOmWBNWIGdSpQ==",
"deprecated": "This library is superseded by Mediabunny. Please migrate to it.",
"license": "MIT",
"dependencies": {
"@types/dom-webcodecs": "^0.1.6",
"@types/wicg-file-system-access": "^2020.9.5"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@@ -1005,6 +1030,17 @@
"node": ">= 0.8"
}
},
"node_modules/webm-muxer": {
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/webm-muxer/-/webm-muxer-5.1.4.tgz",
"integrity": "sha512-ditzgFVFbfqPaugkIr4mGhAdob5K9HY6Rzlh7TRsA368yA1sp/m5O7nQCcMLdgFDeNGtFPg8B+MeXLtpzKWX6Q==",
"deprecated": "This library is superseded by Mediabunny. Please migrate to it.",
"license": "MIT",
"dependencies": {
"@types/dom-webcodecs": "^0.1.4",
"@types/wicg-file-system-access": "^2020.9.5"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+3 -1
View File
@@ -20,6 +20,8 @@
"dependencies": {
"cors": "^2.8.5",
"express": "^4.21.2",
"multer": "^1.4.5-lts.1"
"mp4-muxer": "^5.2.2",
"multer": "^1.4.5-lts.1",
"webm-muxer": "^5.1.4"
}
}
+539 -159
View File
@@ -1677,6 +1677,532 @@
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 Server Export (FFmpeg)';
if (format === 'prores') {
desc.textContent = 'Rendert Apple ProRes 4444 mit 10-Bit Alpha über FFmpeg auf dem Server (für High-End Broadcast Mastering).';
} else {
desc.textContent = 'Rendert QuickTime RLE verlustfrei über FFmpeg auf dem Server.';
}
}
}
/**
* Hardware-Accelerated In-Browser Video Export Engine (WebCodecs + Muxer)
* Supports: WebM VP9 Alpha & MP4 H.264
*/
async function exportVideoClientSide(format, targetWidth, targetHeight, fps, title) {
const isWebM = (format === 'webm_alpha');
const ext = isWebM ? 'webm' : 'mp4';
const suffix = isWebM ? 'Alpha_DaVinci' : 'Visualizer';
const outputFilename = `${title}_${suffix}.${ext}`;
const totalFrames = Math.ceil(state.duration * fps);
const sampleRate = state.audioBuffer.sampleRate;
const numChannels = Math.min(2, state.audioBuffer.numberOfChannels);
const channelData = state.audioBuffer.getChannelData(0);
const sampleWindow = 2048;
const samplesBuffer = new Float32Array(sampleWindow);
// Create Offscreen Canvas for Frame Rendering
const renderCanvas = document.createElement('canvas');
renderCanvas.width = targetWidth;
renderCanvas.height = targetHeight;
const renderCtx = renderCanvas.getContext('2d', { alpha: true, willReadFrequently: true });
// Determine Bitrate based on Resolution
let videoBitrate = 25_000_000; // 25 Mbps default for 1080p
if (targetWidth >= 3840) videoBitrate = 65_000_000; // 65 Mbps for 4K
else if (targetWidth <= 1280) videoBitrate = 14_000_000;
let muxer = null;
let videoEncoder = null;
let audioEncoder = null;
let hasAudioTrack = false;
// Verify WebCodecs support
const hasWebCodecs = (typeof VideoEncoder !== 'undefined' && typeof VideoFrame !== 'undefined');
if (!hasWebCodecs) {
throw new Error('Dein Browser unterstützt die WebCodecs-Schnittstelle nicht für Offline-Rendering. Bitte verwende Google Chrome, Microsoft Edge oder einen aktuellen Chromium-Browser.');
}
if (isWebM) {
if (typeof WebMMuxer === 'undefined') {
throw new Error('WebMMuxer-Bibliothek wurde nicht geladen.');
}
// Check audio encoder support (Opus)
let audioConfig = null;
if (typeof AudioEncoder !== 'undefined') {
try {
const testConfig = {
codec: 'opus',
sampleRate: sampleRate,
numberOfChannels: numChannels,
bitrate: 320000
};
const supported = await AudioEncoder.isConfigSupported(testConfig);
if (supported && supported.supported) {
audioConfig = testConfig;
}
} catch (e) {
console.warn('Opus AudioEncoder test failed:', e);
}
}
muxer = new WebMMuxer.Muxer({
target: new WebMMuxer.ArrayBufferTarget(),
video: {
codec: 'V_VP9',
width: targetWidth,
height: targetHeight,
alpha: true,
frameRate: fps
},
audio: audioConfig ? {
codec: 'A_OPUS',
numberOfChannels: audioConfig.numberOfChannels,
sampleRate: sampleRate,
bitrate: 320000
} : undefined
});
if (audioConfig) {
audioEncoder = new AudioEncoder({
output: (chunk, meta) => muxer.addAudioChunk(chunk, meta),
error: (e) => console.error('AudioEncoder Error:', e)
});
audioEncoder.configure(audioConfig);
hasAudioTrack = true;
}
// Configure VP9 VideoEncoder with alpha support
videoEncoder = new VideoEncoder({
output: (chunk, meta) => muxer.addVideoChunk(chunk, meta),
error: (e) => console.error('VideoEncoder Error:', e)
});
videoEncoder.configure({
codec: 'vp09.00.10.08', // VP9 Profile 0, 8-bit
width: targetWidth,
height: targetHeight,
bitrate: videoBitrate,
framerate: fps,
alpha: 'keep'
});
} else {
// MP4 Export
if (typeof Mp4Muxer === 'undefined') {
throw new Error('Mp4Muxer-Bibliothek wurde nicht geladen.');
}
// Check audio encoder support (AAC)
let audioConfig = null;
if (typeof AudioEncoder !== 'undefined') {
try {
const testConfig = {
codec: 'mp4a.40.2',
sampleRate: sampleRate,
numberOfChannels: numChannels,
bitrate: 320000
};
const supported = await AudioEncoder.isConfigSupported(testConfig);
if (supported && supported.supported) {
audioConfig = testConfig;
}
} catch (e) {
console.warn('AAC AudioEncoder test failed:', e);
}
}
muxer = new Mp4Muxer.Muxer({
target: new Mp4Muxer.ArrayBufferTarget(),
video: {
codec: 'avc',
width: targetWidth,
height: targetHeight
},
audio: audioConfig ? {
codec: 'aac',
numberOfChannels: audioConfig.numberOfChannels,
sampleRate: sampleRate
} : undefined,
fastStart: 'in-memory'
});
if (audioConfig) {
audioEncoder = new AudioEncoder({
output: (chunk, meta) => muxer.addAudioChunk(chunk, meta),
error: (e) => console.error('AudioEncoder Error:', e)
});
audioEncoder.configure(audioConfig);
hasAudioTrack = true;
}
// Determine best supported AVC profile
let selectedCodec = 'avc1.64002a'; // High Profile Level 4.2
try {
const testSupported = await VideoEncoder.isConfigSupported({
codec: selectedCodec,
width: targetWidth,
height: targetHeight,
bitrate: videoBitrate,
framerate: fps
});
if (!testSupported || !testSupported.supported) {
selectedCodec = 'avc1.4d002a'; // Main Profile Level 4.2
}
} catch (e) {
selectedCodec = 'avc1.4d002a';
}
videoEncoder = new VideoEncoder({
output: (chunk, meta) => muxer.addVideoChunk(chunk, meta),
error: (e) => console.error('VideoEncoder Error:', e)
});
videoEncoder.configure({
codec: selectedCodec,
width: targetWidth,
height: targetHeight,
bitrate: videoBitrate,
framerate: fps
});
}
// 1. Pre-encode Audio track in parallel if AudioEncoder is available
if (audioEncoder && hasAudioTrack) {
exportStatusText.textContent = 'Encodiere Audio-Track in Studio-Qualität...';
const totalSamples = state.audioBuffer.length;
const channels = numChannels;
const chunkSize = 4096;
for (let offset = 0; offset < totalSamples; offset += chunkSize) {
if (isExportCancelled) throw new Error('Export durch Benutzer abgebrochen');
const currentChunkSize = Math.min(chunkSize, totalSamples - offset);
const chunkData = new Float32Array(currentChunkSize * channels);
for (let c = 0; c < channels; c++) {
const srcData = state.audioBuffer.getChannelData(c);
for (let i = 0; i < currentChunkSize; i++) {
chunkData[c * currentChunkSize + i] = srcData[offset + i];
}
}
const timestampUs = Math.round((offset / sampleRate) * 1_000_000);
const audioData = new AudioData({
format: 'f32-planar',
sampleRate: sampleRate,
numberOfChannels: channels,
numberOfFrames: currentChunkSize,
timestamp: timestampUs,
data: chunkData
});
audioEncoder.encode(audioData);
audioData.close();
}
}
// 2. Offline Frame-by-Frame Video Render Loop
const renderStartTime = performance.now();
let lastEtaUpdate = 0;
for (let f = 0; f < totalFrames; f++) {
if (isExportCancelled) throw new Error('Export durch Benutzer abgebrochen');
const currentTime = f / fps;
const centerSample = Math.floor(currentTime * sampleRate);
const startSample = Math.max(0, centerSample - 1024);
for (let s = 0; s < sampleWindow; s++) {
const idx = startSample + s;
samplesBuffer[s] = (idx < channelData.length) ? channelData[idx] : 0;
}
const frameFreq = globalFastFFT.compute(samplesBuffer, 1024);
// Render frame to offscreen canvas
renderFrame(renderCtx, targetWidth, targetHeight, frameFreq, currentTime, state.duration, state.config, true);
// Create VideoFrame for WebCodecs
const timestampUs = Math.round((f / fps) * 1_000_000);
const durationUs = Math.round((1 / fps) * 1_000_000);
const isKeyFrame = (f % (fps * 2) === 0);
const videoFrame = new VideoFrame(renderCanvas, {
timestamp: timestampUs,
duration: durationUs,
alpha: isWebM ? 'keep' : 'discard'
});
videoEncoder.encode(videoFrame, { keyFrame: isKeyFrame });
videoFrame.close();
// Backpressure management (prevent encoder queue from filling RAM)
if (videoEncoder.encodeQueueSize > 6) {
await new Promise(resolve => {
videoEncoder.ondequeue = () => {
if (videoEncoder.encodeQueueSize <= 2) {
videoEncoder.ondequeue = null;
resolve();
}
};
});
}
// Live Progress & FPS Display
const pct = Math.round(((f + 1) / totalFrames) * 100);
exportPercent.textContent = `${pct}%`;
exportProgressBarFill.style.width = `${pct}%`;
exportStatusText.textContent = `Rendert Frame ${f + 1} / ${totalFrames} (${pct}%)`;
const now = performance.now();
if (now - lastEtaUpdate > 250 || f === totalFrames - 1) {
const elapsedSecs = (now - renderStartTime) / 1000;
const currentFps = (f + 1) / (elapsedSecs || 0.001);
const remainingFrames = totalFrames - f - 1;
const remainingSecs = remainingFrames / (currentFps || 1);
if (exportFpsLive) exportFpsLive.textContent = `Geschwindigkeit: ${currentFps.toFixed(1)} fps (GPU)`;
exportEta.textContent = `Verbleibend: ~${formatTime(remainingSecs)}`;
lastEtaUpdate = now;
}
// UI thread breathing room
if (f % 6 === 0) {
await new Promise(r => setTimeout(r, 0));
}
}
// 3. Finalize Video & Audio Containers
exportStatusText.textContent = 'Finalisiere Video-Container...';
if (exportFpsLive) exportFpsLive.textContent = 'Schreibe Metadaten...';
await videoEncoder.flush();
if (audioEncoder) await audioEncoder.flush();
muxer.finalize();
const { buffer } = muxer.target;
const mimeType = isWebM ? 'video/webm' : 'video/mp4';
const blob = new Blob([buffer], { type: mimeType });
const elapsedTotal = ((performance.now() - renderStartTime) / 1000).toFixed(1);
const avgFps = (totalFrames / ((performance.now() - renderStartTime) / 1000)).toFixed(1);
const sizeMb = (blob.size / 1024 / 1024).toFixed(1);
console.log(`✅ Client Render Complete in ${elapsedTotal}s @ ${avgFps} fps (${sizeMb} MB)`);
// Download file
const downloadUrl = URL.createObjectURL(blob);
const downloadLink = document.createElement('a');
downloadLink.style.display = 'none';
downloadLink.href = downloadUrl;
downloadLink.download = outputFilename;
document.body.appendChild(downloadLink);
downloadLink.click();
setTimeout(() => {
document.body.removeChild(downloadLink);
URL.revokeObjectURL(downloadUrl);
}, 5000);
alert(`🎉 Fertig! Dein Video "${outputFilename}" (${sizeMb} MB) wurde mit durchschnittlich ${avgFps} fps gerendert und heruntergeladen.`);
}
/**
* Batched High-Performance Server-Side FFmpeg Render Engine
* Used for Apple ProRes 4444 (.mov 10-Bit Alpha) & QuickTime RLE
*/
async function exportVideoServerSide(format, targetWidth, targetHeight, fps, title) {
let audioBlob = state.audioFile;
if (!audioBlob) {
audioBlob = audioBufferToWavBlob(state.audioBuffer);
}
const formData = new FormData();
formData.append('audio', audioBlob, state.audioFile ? state.audioFile.name : 'audio.wav');
formData.append('format', format);
formData.append('width', targetWidth);
formData.append('height', targetHeight);
formData.append('fps', fps);
formData.append('title', title);
exportStatusText.textContent = 'Verbinde mit Server FFmpeg Transcoder...';
const startRes = await fetch('/api/export/start', {
method: 'POST',
body: formData
});
if (!startRes.ok) {
const errJson = await startRes.json().catch(() => ({}));
throw new Error(errJson.error || `Server antwortete mit Status ${startRes.status}`);
}
const startData = await startRes.json();
currentExportSessionId = startData.sessionId;
// Offscreen Canvas for Rendering
const renderCanvas = document.createElement('canvas');
renderCanvas.width = targetWidth;
renderCanvas.height = targetHeight;
const renderCtx = renderCanvas.getContext('2d', { alpha: true, willReadFrequently: true });
const totalFrames = Math.ceil(state.duration * fps);
const sampleRate = state.audioBuffer.sampleRate;
const channelData = state.audioBuffer.getChannelData(0);
const sampleWindow = 2048;
const samplesBuffer = new Float32Array(sampleWindow);
const renderStartTime = performance.now();
let lastEtaUpdate = 0;
// Batching: Concatenate 15 frames into one network payload to drastically cut HTTP latency
const batchFrameCount = 15;
const singleFrameBytes = targetWidth * targetHeight * 4;
const batchBuffer = new Uint8Array(singleFrameBytes * batchFrameCount);
let framesInBatch = 0;
for (let f = 0; f < totalFrames; f++) {
if (isExportCancelled) throw new Error('Export durch Benutzer abgebrochen');
const currentTime = f / fps;
const centerSample = Math.floor(currentTime * sampleRate);
const startSample = Math.max(0, centerSample - 1024);
for (let s = 0; s < sampleWindow; s++) {
const idx = startSample + s;
samplesBuffer[s] = (idx < channelData.length) ? channelData[idx] : 0;
}
const frameFreq = globalFastFFT.compute(samplesBuffer, 1024);
// Render high-precision frame
renderFrame(renderCtx, targetWidth, targetHeight, frameFreq, currentTime, state.duration, state.config, true);
// Extract raw RGBA
const imgData = renderCtx.getImageData(0, 0, targetWidth, targetHeight);
batchBuffer.set(imgData.data, framesInBatch * singleFrameBytes);
framesInBatch++;
// Send batch when full or on last frame
if (framesInBatch === batchFrameCount || f === totalFrames - 1) {
const payload = (framesInBatch === batchFrameCount)
? batchBuffer.buffer
: batchBuffer.slice(0, framesInBatch * singleFrameBytes).buffer;
const frameRes = await fetch(`/api/export/frame-batch/${currentExportSessionId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: payload
});
if (!frameRes.ok) {
const frameErr = await frameRes.json().catch(() => ({}));
throw new Error(frameErr.error || 'Fehler beim Senden des Frame-Batches an Server FFmpeg');
}
framesInBatch = 0;
}
// Live Progress & ETA
const pct = Math.round(((f + 1) / totalFrames) * 100);
exportPercent.textContent = `${pct}%`;
exportProgressBarFill.style.width = `${pct}%`;
exportStatusText.textContent = `Rendert Frame ${f + 1} / ${totalFrames} (${pct}%)`;
const now = performance.now();
if (now - lastEtaUpdate > 400 || f === totalFrames - 1) {
const elapsedSecs = (now - renderStartTime) / 1000;
const currentFps = (f + 1) / elapsedSecs;
const remainingFrames = totalFrames - f - 1;
const remainingSecs = remainingFrames / (currentFps || 1);
if (exportFpsLive) exportFpsLive.textContent = `Geschwindigkeit: ${currentFps.toFixed(1)} fps (Server)`;
exportEta.textContent = `Verbleibend: ~${formatTime(remainingSecs)}`;
lastEtaUpdate = now;
}
if (f % 6 === 0) {
await new Promise(r => setTimeout(r, 0));
}
}
// Finish Server Encoding
exportStatusText.textContent = 'Finalisiere Video mit Studio-Codec auf dem Server...';
if (exportFpsLive) exportFpsLive.textContent = 'Schreibe Audio & Video...';
const finishRes = await fetch(`/api/export/finish/${currentExportSessionId}`, {
method: 'POST'
});
if (!finishRes.ok) {
const finishErr = await finishRes.json().catch(() => ({}));
throw new Error(finishErr.error || 'Fehler beim Finalisieren des Videos auf dem Server');
}
// Poll status until ready
let finishData = null;
while (!finishData && !isExportCancelled) {
await new Promise(r => setTimeout(r, 600));
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 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...';
}
}
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.`);
}
async function startExport() {
if (!state.audioLoaded || !state.audioBuffer) {
alert('Bitte lade zuerst eine Audio-Datei (.wav oder .mp3) im Tab "Media" hoch!');
@@ -1713,173 +2239,21 @@
isExportCancelled = false;
exportProgressCard.classList.remove('hidden');
btnStartExport.classList.add('hidden');
exportStatusText.textContent = 'Bereite Studio-Render vor...';
exportStatusText.textContent = 'Bereite Render vor...';
exportPercent.textContent = '0%';
exportProgressBarFill.style.width = '0%';
exportEta.textContent = `Gesamtlänge: ${formatTime(state.duration)}`;
const exportFpsLive = document.getElementById('exportFpsLive');
if (exportFpsLive) exportFpsLive.textContent = 'Status: Initialisiere FFmpeg Pipeline...';
if (exportFpsLive) exportFpsLive.textContent = 'Status: Initialisiere Engine...';
// Create Offscreen Canvas for Frame Rendering
const renderCanvas = document.createElement('canvas');
renderCanvas.width = targetWidth;
renderCanvas.height = targetHeight;
const renderCtx = renderCanvas.getContext('2d', { alpha: true, willReadFrequently: true });
const title = (document.getElementById('trackTitle').value || 'Visualizer').trim();
try {
// 1. Prepare Audio Blob
let audioBlob = state.audioFile;
if (!audioBlob) {
audioBlob = audioBufferToWavBlob(state.audioBuffer);
if (format === 'webm_alpha' || format === 'mp4') {
await exportVideoClientSide(format, targetWidth, targetHeight, fps, title);
} else {
await exportVideoServerSide(format, targetWidth, targetHeight, fps, title);
}
const title = (document.getElementById('trackTitle').value || 'Visualizer').trim();
// 2. Start Export Session with Backend FFmpeg Engine
const formData = new FormData();
formData.append('audio', audioBlob, state.audioFile ? state.audioFile.name : 'audio.wav');
formData.append('format', format);
formData.append('width', targetWidth);
formData.append('height', targetHeight);
formData.append('fps', fps);
formData.append('title', title);
exportStatusText.textContent = 'Verbinde mit FFmpeg Transcoder...';
const startRes = await fetch('/api/export/start', {
method: 'POST',
body: formData
});
if (!startRes.ok) {
const errJson = await startRes.json().catch(() => ({}));
throw new Error(errJson.error || `Server antwortete mit Status ${startRes.status}`);
}
const startData = await startRes.json();
currentExportSessionId = startData.sessionId;
// 3. Render and Stream Frames
const totalFrames = Math.ceil(state.duration * fps);
const sampleRate = state.audioBuffer.sampleRate;
const channelData = state.audioBuffer.getChannelData(0);
const sampleWindow = 2048;
const samplesBuffer = new Float32Array(sampleWindow);
const renderStartTime = performance.now();
let lastEtaUpdate = 0;
for (let f = 0; f < totalFrames; f++) {
if (isExportCancelled) {
throw new Error('Export durch Benutzer abgebrochen');
}
const currentTime = f / fps;
const centerSample = Math.floor(currentTime * sampleRate);
const startSample = Math.max(0, centerSample - 1024);
for (let s = 0; s < sampleWindow; s++) {
const idx = startSample + s;
samplesBuffer[s] = (idx < channelData.length) ? channelData[idx] : 0;
}
const frameFreq = globalFastFFT.compute(samplesBuffer, 1024);
// Render high-precision frame
renderFrame(renderCtx, targetWidth, targetHeight, frameFreq, currentTime, state.duration, state.config, true);
// Extract raw RGBA Buffer
const imgData = renderCtx.getImageData(0, 0, targetWidth, targetHeight);
// Stream Frame to Server
const frameRes = await fetch(`/api/export/frame/${currentExportSessionId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: imgData.data.buffer
});
if (!frameRes.ok) {
const frameErr = await frameRes.json().catch(() => ({}));
throw new Error(frameErr.error || 'Fehler beim Senden des Render-Frames an FFmpeg');
}
// Live Progress & ETA Calculation
const pct = Math.round(((f + 1) / totalFrames) * 100);
exportPercent.textContent = `${pct}%`;
exportProgressBarFill.style.width = `${pct}%`;
exportStatusText.textContent = `Rendert Frame ${f + 1} / ${totalFrames} (${pct}%)`;
const now = performance.now();
if (now - lastEtaUpdate > 400 || f === totalFrames - 1) {
const elapsedSecs = (now - renderStartTime) / 1000;
const currentFps = (f + 1) / elapsedSecs;
const remainingFrames = totalFrames - f - 1;
const remainingSecs = remainingFrames / (currentFps || 1);
if (exportFpsLive) exportFpsLive.textContent = `Geschwindigkeit: ${currentFps.toFixed(1)} fps`;
exportEta.textContent = `Verbleibend: ~${formatTime(remainingSecs)}`;
lastEtaUpdate = now;
}
// Keep UI thread responsive
if (f % 4 === 0) {
await new Promise(r => setTimeout(r, 0));
}
}
// 4. Finish Encoding & Finalize Container
exportStatusText.textContent = 'Finalisiere Video mit Studio-Codec...';
if (exportFpsLive) exportFpsLive.textContent = 'Schreibe Audio & Video...';
const finishRes = await fetch(`/api/export/finish/${currentExportSessionId}`, {
method: 'POST'
});
if (!finishRes.ok) {
const finishErr = await finishRes.json().catch(() => ({}));
throw new Error(finishErr.error || 'Fehler beim Finalisieren des Videos');
}
// Poll status until ready or error (no timeout, shows live disk size)
let finishData = null;
while (!finishData && !isExportCancelled) {
await new Promise(r => setTimeout(r, 600));
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 Encoder-Fehler');
} else {
const mb = (statusJson.sizeBytes / 1024 / 1024).toFixed(1);
exportStatusText.textContent = `Finalisiere Video (${mb} MB geschrieben)...`;
if (exportFpsLive) exportFpsLive.textContent = 'Schreibe Datei auf Festplatte...';
}
}
if (isExportCancelled) {
throw new Error('Export durch Benutzer abgebrochen');
}
// 5. 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.`);
} catch (err) {
console.error('Export Engine Error:', err);
if (!isExportCancelled) {
@@ -2117,6 +2491,11 @@
}
});
const exportFormatSelect = document.getElementById('exportFormat');
if (exportFormatSelect) {
exportFormatSelect.addEventListener('change', updateExportEngineBadge);
}
btnStartExport.addEventListener('click', startExport);
headerExportBtn.addEventListener('click', () => {
document.querySelector('[data-tab="tab-export"]').click();
@@ -2136,6 +2515,7 @@
setAspectRatio('16:9');
applyPreset('trap_nation');
setupEvents();
updateExportEngineBadge();
state.isPlaying = true;
iconPlay.classList.add('hidden');
iconPause.classList.remove('hidden');
+20 -6
View File
@@ -1173,13 +1173,25 @@
<div class="panel-section">
<h3 class="section-title">Video Export</h3>
<div class="form-group">
<label for="exportFormat">Format</label>
<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>
</div>
</div>
<div class="form-group" style="margin-top: 12px;">
<label for="exportFormat">Format & Render-Engine</label>
<select id="exportFormat" class="select-input">
<option value="prores" selected>Apple ProRes 4444 (.mov Alpha - DaVinci Studio Master)</option>
<option value="qtrle">QuickTime Animation RLE (.mov - 100% Lossless Alpha)</option>
<option value="webm_alpha">WebM VP9 (.webm Alpha - Transparent)</option>
<option value="mp4">MP4 Video (.mp4 Full HD / 4K)</option>
<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>
@@ -1230,6 +1242,8 @@
</main>
</div>
<script src="mp4-muxer.js"></script>
<script src="webm-muxer.js"></script>
<script src="presets.js"></script>
<script src="app.js"></script>
</body>
+1904
View File
File diff suppressed because it is too large. Load diff
+44
View File
@@ -1133,6 +1133,50 @@ body {
color: var(--accent-cyan);
}
/* Export Engine Badge */
.export-engine-badge {
display: flex;
align-items: center;
gap: 12px;
background: rgba(0, 240, 255, 0.08);
border: 1px solid rgba(0, 240, 255, 0.3);
border-radius: var(--radius-md);
padding: 10px 14px;
margin-bottom: 12px;
transition: all 0.3s ease;
}
.export-engine-badge.badge-server {
background: rgba(255, 170, 0, 0.08);
border-color: rgba(255, 170, 0, 0.3);
}
.export-engine-badge .badge-icon {
font-size: 1.5rem;
line-height: 1;
}
.export-engine-badge .badge-content {
display: flex;
flex-direction: column;
gap: 2px;
}
.export-engine-badge strong {
font-size: 0.85rem;
color: var(--accent-cyan);
}
.export-engine-badge.badge-server strong {
color: #ffaa00;
}
.export-engine-badge span {
font-size: 0.75rem;
color: var(--text-muted);
line-height: 1.25;
}
/* Export Progress */
.export-progress-card {
background: var(--bg-input);
+1475
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -229,8 +229,8 @@ app.post('/api/export/start', upload.single('audio'), async (req, res) => {
}
});
// Stream raw RGBA frame batch
app.post('/api/export/frame/:id', express.raw({ type: 'application/octet-stream', limit: '200mb' }), (req, res) => {
// 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);
if (!session || !session.ffmpegProc || session.done) {
return res.status(404).json({ error: 'Session nicht gefunden oder bereits beendet.' });