VideoEncoder.encode() silently drops frames once its internal queue saturates; the previous loop only polled encodeQueueSize every 10 frames, so a lagging hardware encoder lost frames in bursts (the "few frames every beat" bug). Gate every encode on the dequeue event instead, keeping the queue small but non-empty. Also resample PCM to 48 kHz when encoding opus so the muxer timescale, chunk timestamps and bitstream all agree (opus is natively 48k; a 44.1k source previously produced a file claiming 44100 while the stream was 48k).
354 lines
15 KiB
JavaScript
354 lines
15 KiB
JavaScript
import { Muxer, ArrayBufferTarget } from 'mp4-muxer';
|
|
|
|
/**
|
|
* Offline export.
|
|
*
|
|
* Drives the same Show object the preview does, with the timeline in fixed-step
|
|
* mode — the frame index is counted, never derived from a clock. That is the
|
|
* whole basis of preview/export parity: the exporter has no render path of its
|
|
* own, so there is nothing for it to diverge from.
|
|
*
|
|
* Video goes through WebCodecs VideoEncoder (hardware accelerated where
|
|
* available) into an mp4. Audio is re-encoded from the already-decoded PCM —
|
|
* AAC where available, Opus otherwise — so the file carries the same samples
|
|
* the analysis ran on.
|
|
*/
|
|
|
|
export const PRESETS = {
|
|
'720p': { width: 1280, height: 720, bitrate: 8_000_000 },
|
|
'1080p': { width: 1920, height: 1080, bitrate: 16_000_000 },
|
|
'1440p': { width: 2560, height: 1440, bitrate: 28_000_000 },
|
|
'4K': { width: 3840, height: 2160, bitrate: 45_000_000 },
|
|
};
|
|
|
|
export function isSupported() {
|
|
return typeof VideoEncoder !== 'undefined' && typeof VideoFrame !== 'undefined';
|
|
}
|
|
|
|
/** Probe for a codec configuration the browser will actually accept. */
|
|
async function pickVideoConfig(width, height, bitrate, fps) {
|
|
const candidates = [
|
|
'avc1.640034', 'avc1.640033', 'avc1.4d0034', 'avc1.42003e',
|
|
];
|
|
for (const codec of candidates) {
|
|
const config = {
|
|
codec, width, height, bitrate, framerate: fps,
|
|
avc: { format: 'avc' },
|
|
};
|
|
try {
|
|
const support = await VideoEncoder.isConfigSupported(config);
|
|
if (support.supported) return config;
|
|
} catch { /* try the next one */ }
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Pick an audio codec the browser can actually encode.
|
|
*
|
|
* AAC is the obvious choice for mp4 but is absent from Chromium builds without
|
|
* proprietary codecs — which still ship H.264 encoding, so video succeeds and
|
|
* only audio fails. Opus in mp4 is well supported by players and by every
|
|
* platform worth uploading to, so it is the fallback rather than an error.
|
|
*/
|
|
async function pickAudioConfig(sampleRate, numberOfChannels) {
|
|
const candidates = [
|
|
{ codec: 'mp4a.40.2', muxerCodec: 'aac', bitrate: 192_000 },
|
|
{ codec: 'opus', muxerCodec: 'opus', bitrate: 160_000 },
|
|
];
|
|
for (const candidate of candidates) {
|
|
try {
|
|
const support = await AudioEncoder.isConfigSupported({
|
|
codec: candidate.codec, sampleRate, numberOfChannels, bitrate: candidate.bitrate,
|
|
});
|
|
if (support.supported) return candidate;
|
|
} catch { /* try the next one */ }
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Opus only produces a native 48 kHz stream (the input rate is resampled
|
|
* inside the encoder), so a 44.1 kHz source would end up in a file whose
|
|
* container metadata claims 44100 while the bitstream is 48000. Every stream
|
|
* rate — encoder config, chunk timestamps and the muxer timescale — must
|
|
* agree, so resample to the codec's native rate instead of trusting the
|
|
* encoder to paper over the mismatch.
|
|
*/
|
|
export const OPUS_NATIVE_RATE = 48000;
|
|
|
|
/** Linear-interpolation resampler for interleaved PCM. */
|
|
function resampleInterleaved(input, fromRate, toRate, channels) {
|
|
if (fromRate === toRate) return input;
|
|
const outFrames = Math.floor((input.length / channels) * (toRate / fromRate));
|
|
const out = new Float32Array(outFrames * channels);
|
|
const ratio = fromRate / toRate;
|
|
const last = input.length / channels - 1;
|
|
for (let s = 0; s < outFrames; s++) {
|
|
const pos = s * ratio;
|
|
const i0 = Math.floor(pos);
|
|
const i1 = Math.min(i0 + 1, last);
|
|
const frac = pos - i0;
|
|
for (let c = 0; c < channels; c++) {
|
|
out[s * channels + c] =
|
|
input[i0 * channels + c] * (1 - frac) + input[i1 * channels + c] * frac;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Backpressure: VideoEncoder.encode() silently drops frames once its internal
|
|
* queue is full (encodeQueueSize is capped at an implementation-defined
|
|
* limit). Polling that number is unreliable — the only signal that a frame
|
|
* actually left the queue is the 'dequeue' event, so wait on it before every
|
|
* encode. Keeping the queue small (rather than draining it fully) leaves the
|
|
* hardware encoder a pipeline to chew on while guaranteeing nothing is dropped.
|
|
*/
|
|
const MAX_ENCODER_QUEUE = 8;
|
|
|
|
function waitForQueue(encoder, errors) {
|
|
if (encoder.encodeQueueSize < MAX_ENCODER_QUEUE) return Promise.resolve();
|
|
return new Promise((resolve, reject) => {
|
|
const cleanup = () => {
|
|
encoder.removeEventListener('dequeue', onDequeue);
|
|
encoder.removeEventListener('error', onError);
|
|
};
|
|
const onError = () => { cleanup(); reject(errors[0] || new Error('encoder error')); };
|
|
const onDequeue = () => {
|
|
if (errors.length) { cleanup(); reject(errors[0]); return; }
|
|
if (encoder.encodeQueueSize < MAX_ENCODER_QUEUE) { cleanup(); resolve(); }
|
|
};
|
|
encoder.addEventListener('dequeue', onDequeue);
|
|
encoder.addEventListener('error', onError);
|
|
});
|
|
}
|
|
|
|
export class Exporter {
|
|
constructor(show) {
|
|
this.show = show;
|
|
this.cancelled = false;
|
|
}
|
|
|
|
cancel() { this.cancelled = true; }
|
|
|
|
/**
|
|
* @param {object} options
|
|
* @param {string} options.preset key of PRESETS
|
|
* @param {[number, number]} [options.frameRange] inclusive-exclusive frame range
|
|
* @param {(progress: {frame, total, fraction, stage}) => void} [options.onProgress]
|
|
* @returns {Promise<Blob>}
|
|
*/
|
|
async export({ preset = '1080p', frameRange = null, onProgress = null } = {}) {
|
|
if (!isSupported()) {
|
|
throw new Error('WebCodecs VideoEncoder is unavailable in this browser');
|
|
}
|
|
const show = this.show;
|
|
if (!show.ready) throw new Error('no track loaded');
|
|
|
|
const { width, height, bitrate } = PRESETS[preset] || PRESETS['1080p'];
|
|
const fps = show.fps;
|
|
|
|
const [startFrame, endFrame] = frameRange || [0, show.frameCount];
|
|
const total = Math.max(1, endFrame - startFrame);
|
|
|
|
const videoConfig = await pickVideoConfig(width, height, bitrate, fps);
|
|
if (!videoConfig) throw new Error('no supported H.264 configuration found');
|
|
|
|
const channels = show.audioBuffer ? Math.min(2, show.audioBuffer.numberOfChannels) : 0;
|
|
const audioConfig = show.audioBuffer && typeof AudioEncoder !== 'undefined'
|
|
? await pickAudioConfig(show.audioBuffer.sampleRate, channels)
|
|
: null;
|
|
const hasAudio = !!audioConfig;
|
|
this.warnings = [];
|
|
if (show.audioBuffer && !hasAudio) {
|
|
this.warnings.push('no supported audio encoder — exporting video only');
|
|
}
|
|
|
|
// Every stream rate must agree so container metadata, chunk timestamps
|
|
// and the bitstream describe the same timeline. Opus is resampled to
|
|
// its native 48 kHz; AAC keeps the decoded PCM's rate.
|
|
const sourceRate = show.audioBuffer ? show.audioBuffer.sampleRate : null;
|
|
const audioRate = hasAudio && audioConfig.muxerCodec === 'opus'
|
|
? OPUS_NATIVE_RATE
|
|
: sourceRate;
|
|
|
|
const muxer = new Muxer({
|
|
target: new ArrayBufferTarget(),
|
|
video: { codec: 'avc', width, height, frameRate: fps },
|
|
...(hasAudio ? {
|
|
audio: {
|
|
codec: audioConfig.muxerCodec,
|
|
sampleRate: audioRate,
|
|
numberOfChannels: channels,
|
|
},
|
|
} : {}),
|
|
fastStart: 'in-memory',
|
|
});
|
|
|
|
const errors = [];
|
|
const videoEncoder = new VideoEncoder({
|
|
output: (chunk, meta) => muxer.addVideoChunk(chunk, meta),
|
|
error: (e) => errors.push(e),
|
|
});
|
|
videoEncoder.configure(videoConfig);
|
|
|
|
// Render at export resolution. The preview's own size is restored after.
|
|
const previousWidth = show.engine.width;
|
|
const previousHeight = show.engine.height;
|
|
show.setSize(width, height);
|
|
|
|
try {
|
|
// Compile every shader and discard a warm frame first. Programs link
|
|
// asynchronously, and an export renders each frame exactly once — there
|
|
// is no second pass to fix frame 0 with.
|
|
onProgress && onProgress({ frame: 0, total, fraction: 0, stage: 'compiling shaders' });
|
|
show.prime(startFrame);
|
|
|
|
// Warm-up so the first exported frame has the same feedback state it
|
|
// would have had in sequential playback from the range start.
|
|
if (startFrame > 0) {
|
|
onProgress && onProgress({ frame: 0, total, fraction: 0, stage: 'warming up' });
|
|
show.warmUp(startFrame, show.warmupFrames());
|
|
} else {
|
|
show.engine.compositor.reset();
|
|
}
|
|
|
|
for (let i = 0; i < total; i++) {
|
|
if (this.cancelled) throw new Error('export cancelled');
|
|
|
|
const frameIndex = startFrame + i;
|
|
const target = show.renderFrame(frameIndex);
|
|
show.present(target); // encode from the canvas, which now holds this frame
|
|
|
|
// True backpressure before every encode — VideoEncoder drops
|
|
// frames silently once its queue saturates, which would turn a
|
|
// smooth render into the stutter that first shipped here.
|
|
await waitForQueue(videoEncoder, errors);
|
|
if (errors.length) throw errors[0];
|
|
|
|
const timestamp = Math.round(((frameIndex - startFrame) * 1e6) / fps);
|
|
const videoFrame = new VideoFrame(show.engine.renderer.canvas, {
|
|
timestamp,
|
|
duration: Math.round(1e6 / fps),
|
|
});
|
|
// Keyframe every two seconds: seekable output without bloating size.
|
|
videoEncoder.encode(videoFrame, { keyFrame: i % (fps * 2) === 0 });
|
|
videoFrame.close();
|
|
|
|
// Yield periodically so the progress UI paints.
|
|
if (i % 10 === 0) {
|
|
onProgress && onProgress({
|
|
frame: i, total, fraction: i / total, stage: 'rendering',
|
|
});
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
}
|
|
if (errors.length) throw errors[0];
|
|
}
|
|
|
|
onProgress && onProgress({ frame: total, total, fraction: 1, stage: 'finishing video' });
|
|
await videoEncoder.flush();
|
|
|
|
if (hasAudio) {
|
|
onProgress && onProgress({ frame: total, total, fraction: 1, stage: 'encoding audio' });
|
|
try {
|
|
await this._encodeAudio(muxer, audioConfig, startFrame, endFrame, fps, audioRate);
|
|
} catch (err) {
|
|
// A finished silent video beats losing a long render outright.
|
|
// The muxer tolerates an audio track that received no chunks.
|
|
this.warnings.push(`audio encoding failed (${err.message}) — video only`);
|
|
}
|
|
}
|
|
|
|
muxer.finalize();
|
|
return new Blob([muxer.target.buffer], { type: 'video/mp4' });
|
|
} finally {
|
|
try { videoEncoder.close(); } catch { /* already closed */ }
|
|
show.setSize(previousWidth, previousHeight);
|
|
}
|
|
}
|
|
|
|
/** Encode the exported time range of the decoded PCM and mux it. */
|
|
async _encodeAudio(muxer, audioConfig, startFrame, endFrame, fps, audioRate) {
|
|
const buffer = this.show.audioBuffer;
|
|
const sourceRate = buffer.sampleRate;
|
|
const channels = Math.min(2, buffer.numberOfChannels);
|
|
|
|
const startSample = Math.floor((startFrame / fps) * sourceRate);
|
|
const endSample = Math.min(buffer.length, Math.ceil((endFrame / fps) * sourceRate));
|
|
const length = Math.max(0, endSample - startSample);
|
|
if (!length) return;
|
|
|
|
const errors = [];
|
|
const encoder = new AudioEncoder({
|
|
output: (chunk, meta) => muxer.addAudioChunk(chunk, meta),
|
|
error: (e) => errors.push(e),
|
|
});
|
|
encoder.configure({
|
|
codec: audioConfig.codec,
|
|
sampleRate: audioRate,
|
|
numberOfChannels: channels,
|
|
bitrate: audioConfig.bitrate,
|
|
});
|
|
|
|
const chunkFrames = 1024;
|
|
const sources = [];
|
|
for (let c = 0; c < channels; c++) sources.push(buffer.getChannelData(c));
|
|
|
|
// Slice the range, resampling to the codec's native rate when needed so
|
|
// chunk timestamps and the muxer timescale describe the same timeline.
|
|
const interleaved = new Float32Array(length * channels);
|
|
for (let i = 0; i < length; i++) {
|
|
for (let c = 0; c < channels; c++) {
|
|
interleaved[i * channels + c] = sources[c][startSample + i];
|
|
}
|
|
}
|
|
const pcm = audioRate === sourceRate
|
|
? interleaved
|
|
: resampleInterleaved(interleaved, sourceRate, audioRate, channels);
|
|
|
|
const outFrames = pcm.length / channels;
|
|
const buf = new Float32Array(chunkFrames * channels);
|
|
|
|
for (let offset = 0; offset < outFrames; offset += chunkFrames) {
|
|
const count = Math.min(chunkFrames, outFrames - offset);
|
|
buf.set(pcm.subarray(offset * channels, (offset + count) * channels), 0);
|
|
const data = new AudioData({
|
|
format: 'f32',
|
|
sampleRate: audioRate,
|
|
numberOfFrames: count,
|
|
numberOfChannels: channels,
|
|
timestamp: Math.round((offset / audioRate) * 1e6),
|
|
data: buf.slice(0, count * channels),
|
|
});
|
|
encoder.encode(data);
|
|
data.close();
|
|
if (errors.length) throw errors[0];
|
|
if (offset % (chunkFrames * 64) === 0) await new Promise((r) => setTimeout(r, 0));
|
|
}
|
|
await encoder.flush();
|
|
encoder.close();
|
|
}
|
|
}
|
|
|
|
/** Render a short range around a frame — the "test render" bridge before a full export. */
|
|
export async function exportSegment(show, centreFrame,
|
|
{ seconds = 20, preset = '1080p', onProgress, exporter = null } = {}) {
|
|
const half = Math.round((seconds * show.fps) / 2);
|
|
const start = Math.max(0, centreFrame - half);
|
|
const end = Math.min(show.frameCount, centreFrame + half);
|
|
// Accept a caller-supplied Exporter so the caller can read `warnings` and cancel.
|
|
return (exporter || new Exporter(show)).export({ preset, frameRange: [start, end], onProgress });
|
|
}
|
|
|
|
export function downloadBlob(blob, filename) {
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
setTimeout(() => URL.revokeObjectURL(url), 10000);
|
|
}
|