fix(export): Add robust WebCodecs codec probing, even-dimension alignment, and seamless server fallback
This commit is contained in:
1 parent
af19c3c968
commit
86040a727f
1 file changed
+211
-131
+211
-131
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user