diff --git a/package-lock.json b/package-lock.json index 0773fb4..2ee39bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index bf055ee..3070d9c 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/public/app.js b/public/app.js index 5398073..a010d93 100644 --- a/public/app.js +++ b/public/app.js @@ -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'); diff --git a/public/index.html b/public/index.html index 1ef623e..5e97cbb 100644 --- a/public/index.html +++ b/public/index.html @@ -1173,13 +1173,25 @@