feat: Add high-speed client-side GPU video export (WebCodecs) and server batching
This commit is contained in:
1 parent
0f0872ee36
commit
af19c3c968
8 files changed
+4024
-169
No files matched your search
Generated
+37
-1
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+2
-2
@@ -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.' });
|
||||
|
||||
Reference in new issue
Block a user