diff --git a/public/app.js b/public/app.js index a010d93..f199987 100644 --- a/public/app.js +++ b/public/app.js @@ -1709,9 +1709,60 @@ } } + /** + * Probes for the best supported WebCodecs Video configuration + * Checks Hardware acceleration first, falls back to Software and alternative codec profiles + */ + async function findBestSupportedVideoConfig(format, width, height, fps, bitrate) { + const isWebM = (format === 'webm_alpha'); + const configsToTest = []; + + if (isWebM) { + // 1. VP9 with Alpha (Hardware preference) + configsToTest.push( + { codec: 'vp09.00.10.08', width, height, bitrate, framerate: fps, alpha: 'keep', hardwareAcceleration: 'prefer-hardware' }, + { codec: 'vp09.00.10.08', width, height, bitrate, framerate: fps, alpha: 'keep', hardwareAcceleration: 'prefer-software' }, + { codec: 'vp09.00.10.08', width, height, bitrate, framerate: fps, alpha: 'keep' }, + // 2. VP9 without Alpha (Fallback if GPU doesn't support alpha in VP9) + { codec: 'vp09.00.10.08', width, height, bitrate, framerate: fps, hardwareAcceleration: 'prefer-hardware' }, + { codec: 'vp09.00.10.08', width, height, bitrate, framerate: fps, hardwareAcceleration: 'prefer-software' }, + { codec: 'vp09.00.10.08', width, height, bitrate, framerate: fps }, + // 3. VP8 Fallback + { codec: 'vp8', width, height, bitrate, framerate: fps, hardwareAcceleration: 'prefer-hardware' }, + { codec: 'vp8', width, height, bitrate, framerate: fps, hardwareAcceleration: 'prefer-software' }, + { codec: 'vp8', width, height, bitrate, framerate: fps } + ); + } else { + // MP4 / H.264 configurations (High, Main, Baseline profiles) + configsToTest.push( + { codec: 'avc1.64002a', width, height, bitrate, framerate: fps, hardwareAcceleration: 'prefer-hardware' }, + { codec: 'avc1.64002a', width, height, bitrate, framerate: fps, hardwareAcceleration: 'prefer-software' }, + { codec: 'avc1.4d002a', width, height, bitrate, framerate: fps, hardwareAcceleration: 'prefer-hardware' }, + { codec: 'avc1.4d002a', width, height, bitrate, framerate: fps, hardwareAcceleration: 'prefer-software' }, + { codec: 'avc1.42001f', width, height, bitrate, framerate: fps, hardwareAcceleration: 'prefer-hardware' }, + { codec: 'avc1.42001f', width, height, bitrate, framerate: fps, hardwareAcceleration: 'prefer-software' }, + { codec: 'avc1.42001e', width, height, bitrate, framerate: fps } + ); + } + + for (const cfg of configsToTest) { + try { + const check = await VideoEncoder.isConfigSupported(cfg); + if (check && check.supported) { + console.log('✅ WebCodecs selected config:', check.config || cfg); + return check.config || cfg; + } + } catch (e) { + // Continue searching + } + } + + return null; + } + /** * Hardware-Accelerated In-Browser Video Export Engine (WebCodecs + Muxer) - * Supports: WebM VP9 Alpha & MP4 H.264 + * Supports: WebM VP9 Alpha & MP4 H.264 with robust codec probing */ async function exportVideoClientSide(format, targetWidth, targetHeight, fps, title) { const isWebM = (format === 'webm_alpha'); @@ -1737,104 +1788,103 @@ 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.'); } + // 1. Probe & find supported VideoEncoder configuration + const chosenVideoConfig = await findBestSupportedVideoConfig(format, targetWidth, targetHeight, fps, videoBitrate); + if (!chosenVideoConfig) { + throw new Error(`Kein kompatibler Video-Codec für Format "${format}" auf diesem Gerät gefunden.`); + } + + const supportsAlpha = isWebM && (chosenVideoConfig.alpha === 'keep' || (chosenVideoConfig.codec && chosenVideoConfig.codec.startsWith('vp09'))); + + let muxer = null; + let videoEncoder = null; + let audioEncoder = null; + let hasAudioTrack = false; + let encoderError = null; + + // 2. Configure Muxer & Audio Encoder 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); + const audioTests = [ + { codec: 'opus', sampleRate: 48000, numberOfChannels: numChannels, bitrate: 320000 }, + { codec: 'opus', sampleRate: sampleRate, numberOfChannels: numChannels, bitrate: 320000 } + ]; + for (const aCfg of audioTests) { + try { + const supported = await AudioEncoder.isConfigSupported(aCfg); + if (supported && supported.supported) { + audioConfig = supported.config || aCfg; + break; + } + } catch (e) {} } } + const muxerCodec = (chosenVideoConfig.codec && chosenVideoConfig.codec.startsWith('vp8')) ? 'V_VP8' : 'V_VP9'; + muxer = new WebMMuxer.Muxer({ target: new WebMMuxer.ArrayBufferTarget(), video: { - codec: 'V_VP9', + codec: muxerCodec, width: targetWidth, height: targetHeight, - alpha: true, + alpha: supportsAlpha, frameRate: fps }, audio: audioConfig ? { codec: 'A_OPUS', numberOfChannels: audioConfig.numberOfChannels, - sampleRate: sampleRate, + sampleRate: audioConfig.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; + try { + audioEncoder = new AudioEncoder({ + output: (chunk, meta) => muxer.addAudioChunk(chunk, meta), + error: (e) => console.warn('AudioEncoder Warning:', e) + }); + audioEncoder.configure(audioConfig); + hasAudioTrack = true; + } catch (e) { + console.warn('AudioEncoder init failed, continuing without AudioEncoder:', e); + audioEncoder = null; + hasAudioTrack = false; + } } - // 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 + // MP4 Muxer 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); + const audioTests = [ + { codec: 'mp4a.40.2', sampleRate: sampleRate, numberOfChannels: numChannels, bitrate: 320000 }, + { codec: 'mp4a.40.2', sampleRate: 44100, numberOfChannels: numChannels, bitrate: 320000 } + ]; + for (const aCfg of audioTests) { + try { + const supported = await AudioEncoder.isConfigSupported(aCfg); + if (supported && supported.supported) { + audioConfig = supported.config || aCfg; + break; + } + } catch (e) {} } } @@ -1848,92 +1898,91 @@ audio: audioConfig ? { codec: 'aac', numberOfChannels: audioConfig.numberOfChannels, - sampleRate: sampleRate + sampleRate: audioConfig.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; + try { + audioEncoder = new AudioEncoder({ + output: (chunk, meta) => muxer.addAudioChunk(chunk, meta), + error: (e) => console.warn('AAC AudioEncoder Warning:', e) + }); + audioEncoder.configure(audioConfig); + hasAudioTrack = true; + } catch (e) { + console.warn('AAC AudioEncoder init failed, continuing without AudioEncoder:', e); + audioEncoder = null; + hasAudioTrack = false; + } } + } - // Determine best supported AVC profile - let selectedCodec = 'avc1.64002a'; // High Profile Level 4.2 + // 3. Instantiate & Configure VideoEncoder + videoEncoder = new VideoEncoder({ + output: (chunk, meta) => muxer.addVideoChunk(chunk, meta), + error: (e) => { + console.error('VideoEncoder Runtime Error:', e); + encoderError = e; + } + }); + + videoEncoder.configure(chosenVideoConfig); + + // 4. Pre-encode Audio track if AudioEncoder is available + if (audioEncoder && hasAudioTrack && audioEncoder.state === 'configured') { 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'; - } + exportStatusText.textContent = 'Encodiere Audio-Track in Studio-Qualität...'; + const totalSamples = state.audioBuffer.length; + const channels = numChannels; + const chunkSize = 4096; - videoEncoder = new VideoEncoder({ - output: (chunk, meta) => muxer.addVideoChunk(chunk, meta), - error: (e) => console.error('VideoEncoder Error:', e) - }); + for (let offset = 0; offset < totalSamples; offset += chunkSize) { + if (isExportCancelled) throw new Error('Export durch Benutzer abgebrochen'); - 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 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(); } - - 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(); + } catch (aErr) { + console.warn('Audio pre-encode failed, skipping audio:', aErr); + audioEncoder = null; } } - // 2. Offline Frame-by-Frame Video Render Loop + // 5. 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'); + if (encoderError) { + throw new Error(`VideoEncoder Fehler: ${encoderError.message || encoderError}`); + } + if (videoEncoder.state !== 'configured') { + throw new Error(`VideoEncoder befindet sich im Status "${videoEncoder.state}". Möglicherweise unterstützt die Grafikkarte diesen Codec nicht.`); + } + const currentTime = f / fps; const centerSample = Math.floor(currentTime * sampleRate); const startSample = Math.max(0, centerSample - 1024); @@ -1956,11 +2005,14 @@ const videoFrame = new VideoFrame(renderCanvas, { timestamp: timestampUs, duration: durationUs, - alpha: isWebM ? 'keep' : 'discard' + alpha: supportsAlpha ? 'keep' : 'discard' }); - videoEncoder.encode(videoFrame, { keyFrame: isKeyFrame }); - videoFrame.close(); + try { + videoEncoder.encode(videoFrame, { keyFrame: isKeyFrame }); + } finally { + videoFrame.close(); + } // Backpressure management (prevent encoder queue from filling RAM) if (videoEncoder.encodeQueueSize > 6) { @@ -1998,12 +2050,20 @@ } } - // 3. Finalize Video & Audio Containers + // 6. Finalize Video & Audio Containers exportStatusText.textContent = 'Finalisiere Video-Container...'; if (exportFpsLive) exportFpsLive.textContent = 'Schreibe Metadaten...'; - await videoEncoder.flush(); - if (audioEncoder) await audioEncoder.flush(); + if (encoderError) { + throw new Error(`VideoEncoder Fehler vor Finalisierung: ${encoderError.message || encoderError}`); + } + + if (videoEncoder.state === 'configured') { + await videoEncoder.flush(); + } + if (audioEncoder && audioEncoder.state === 'configured') { + await audioEncoder.flush(); + } muxer.finalize(); const { buffer } = muxer.target; @@ -2233,6 +2293,10 @@ targetHeight = Math.round(targetHeight * 0.667); } + // Force strictly even dimensions (divisible by 2) for encoder compatibility + targetWidth = Math.floor(targetWidth / 2) * 2; + targetHeight = Math.floor(targetHeight / 2) * 2; + pausePlayback(); state.isExporting = true; @@ -2250,7 +2314,23 @@ try { if (format === 'webm_alpha' || format === 'mp4') { - await exportVideoClientSide(format, targetWidth, targetHeight, fps, title); + try { + await exportVideoClientSide(format, targetWidth, targetHeight, fps, title); + } catch (clientErr) { + console.warn('Client-GPU export failed on this device, checking fallback:', clientErr); + if (isExportCancelled) throw clientErr; + + const useFallback = confirm( + `Der hardwarebeschleunigte Browser-Encoder meldet auf diesem Gerät einen Codec-Fehler:\n"${clientErr.message}"\n\nMöchtest du dieses Video stattdessen über den Server-Encoder (FFmpeg) rendern?` + ); + + if (useFallback) { + const fallbackFormat = (format === 'webm_alpha') ? 'prores' : 'mp4'; + await exportVideoServerSide(fallbackFormat, targetWidth, targetHeight, fps, title); + } else { + throw clientErr; + } + } } else { await exportVideoServerSide(format, targetWidth, targetHeight, fps, title); }