fix(export): Add robust WebCodecs codec probing, even-dimension alignment, and seamless server fallback

This commit is contained in:
Jannik committed 2026-08-08 22:38:50 +02:00
1 parent af19c3c968
commit 86040a727f
1 file changed
+211 -131
+211 -131
View File
@@ -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);
}