Two halves of one pass; TODO-FIXES.md lists every finding. The audit fixed, among ~90 items: MP4 export dead-ending and locking the UI behind a stuck "Downloading..." button; height keyframes storing the width; shadow defaults and keyframes storing undefined because fabric's get() is not a path getter; every letter animation writing to the last letter; keyframe times drifting on each drag because data-time was layer-relative on expanded rows; O(n^2 log n) playback, now indexed once per frame; and a save() that rebuilt the record canvas on every edit, now debounced. Exports also lost audio-layer sound outright: MediaRecorder records only the first audio track, so every source now mixes through one AudioContext into one destination. The new half is src/js/render.js, an offline renderer. Real-time capture was the root cause of dropped frames on heavy scenes and smeared video layers: a bare currentTime assignment is async, so drawing straight after it captures the previous frame. Each frame is now seeked, awaited on 'seeked', drawn, and pushed through a VideoEncoder; audio is mixed in one OfflineAudioContext pass and encoded to Opus. webm-writer2.js gained a second Opus track and lost three bugs, including a MAX_CLUSTER_DURATION_MSEC of ~58 days that overflowed the signed 16-bit block timecode past ~32s. Real-time capture remains the fallback: the hand-rolled muxer's output is decoded in a <video> element before being handed over, and any failure returns null so record() falls back transparently. test/webm-muxer.test.js parses the muxer output with an EBML reader in plain Node. It caught the fixed-256-byte header overflow that inspection missed. Not verified in a browser: node --check passes on every script and the muxer test passes, but a running Chrome held the Playwright profile lock, so the manual pass listed at the end of TODO-FIXES.md is still outstanding. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
516 lines
14 KiB
JavaScript
516 lines
14 KiB
JavaScript
// Frame-accurate offline renderer.
|
|
//
|
|
// The original export path captured the canvas with MediaRecorder in real
|
|
// time, so anything the browser could not draw at 60fps was simply dropped and
|
|
// video layers were sampled wherever they happened to be. This renders one
|
|
// frame at a time with no clock attached: every frame is drawn at the frame
|
|
// rate picked in the download modal, every video layer is seeked to the exact
|
|
// frame time, and the audio is mixed offline.
|
|
//
|
|
// Needs WebCodecs (Chrome/Edge). record() falls back to the real-time path
|
|
// when this is unavailable or fails.
|
|
|
|
// The muxer can only close a cluster on a video keyframe, and a cluster may
|
|
// not span more than ~32s (block timecodes are a signed 16 bit offset), so
|
|
// keyframes have to stay frequent.
|
|
const RENDER_KEYFRAME_INTERVAL = 2; // seconds
|
|
// A seek that never completes must not hang the whole export
|
|
const RENDER_MAX_SEEK_WAIT = 2000;
|
|
|
|
// recordAnimate() drives video playback in real time; while rendering offline
|
|
// the frames are seeked explicitly instead.
|
|
var offlinerender = false;
|
|
|
|
function frameAccurateSupported() {
|
|
return (
|
|
typeof VideoEncoder !== 'undefined' &&
|
|
typeof VideoFrame !== 'undefined' &&
|
|
typeof WebMWriter !== 'undefined' &&
|
|
typeof OfflineAudioContext !== 'undefined'
|
|
);
|
|
}
|
|
|
|
function renderProgress(text) {
|
|
$('#download-real').html(text);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Media positioning
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Put every time-based layer at exactly `time` and wait until it is actually
|
|
// showing that frame. A plain currentTime assignment is asynchronous - drawing
|
|
// before 'seeked' captures the previous frame.
|
|
async function seekMediaForFrame(time) {
|
|
const waits = [];
|
|
objects.forEach(function (object) {
|
|
const obj = canvasrecord.getItemById(object.id);
|
|
const p_keyframe = p_keyframes.find((x) => x.id == object.id);
|
|
if (!obj || !p_keyframe) {
|
|
return;
|
|
}
|
|
|
|
if (obj.type == 'lottie') {
|
|
obj.goToSeconds(time);
|
|
return;
|
|
}
|
|
|
|
if (obj.get('assetType') != 'video') {
|
|
return;
|
|
}
|
|
|
|
const element = $(obj.getElement())[0];
|
|
if (!element) {
|
|
return;
|
|
}
|
|
element.pause();
|
|
|
|
const visible =
|
|
time >= p_keyframe.trimstart + p_keyframe.start &&
|
|
time <= p_keyframe.end;
|
|
obj.set('visible', visible);
|
|
if (!visible) {
|
|
return;
|
|
}
|
|
|
|
var target =
|
|
(time - p_keyframe.start + p_keyframe.trimstart) / 1000;
|
|
if (element.duration) {
|
|
target = Math.min(target, Math.max(0, element.duration - 0.001));
|
|
}
|
|
target = Math.max(0, target);
|
|
if (Math.abs(element.currentTime - target) < 0.0005) {
|
|
return;
|
|
}
|
|
|
|
waits.push(
|
|
new Promise(function (resolve) {
|
|
var settled = false;
|
|
function finish() {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
element.removeEventListener('seeked', finish);
|
|
element.removeEventListener('error', finish);
|
|
resolve();
|
|
}
|
|
element.addEventListener('seeked', finish);
|
|
element.addEventListener('error', finish);
|
|
// A stuck seek must not hang the whole export
|
|
window.setTimeout(finish, RENDER_MAX_SEEK_WAIT);
|
|
try {
|
|
element.currentTime = target;
|
|
} catch (e) {
|
|
finish();
|
|
}
|
|
})
|
|
);
|
|
});
|
|
await Promise.all(waits);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Audio
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Every sound in the project, with where it sits on the timeline
|
|
function collectAudioSources() {
|
|
const sources = [];
|
|
objects.forEach(function (object) {
|
|
const obj = canvasrecord.getItemById(object.id);
|
|
const p_keyframe = p_keyframes.find((x) => x.id == object.id);
|
|
if (!obj || !p_keyframe) {
|
|
return;
|
|
}
|
|
if (obj.get('assetType') == 'audio' && obj.get('audioSrc')) {
|
|
sources.push({
|
|
src: obj.get('audioSrc'),
|
|
start: p_keyframe.start,
|
|
end: p_keyframe.end,
|
|
trimstart: p_keyframe.trimstart,
|
|
volume: obj.get('volume'),
|
|
});
|
|
} else if (obj.get('assetType') == 'video' && obj.get('source')) {
|
|
sources.push({
|
|
src: obj.get('source'),
|
|
start: p_keyframe.start,
|
|
end: p_keyframe.end,
|
|
trimstart: p_keyframe.trimstart,
|
|
volume: 1,
|
|
});
|
|
}
|
|
});
|
|
if (background_audio != false && background_audio.src) {
|
|
sources.push({
|
|
src: background_audio.src,
|
|
start: 0,
|
|
end: duration,
|
|
trimstart: 0,
|
|
volume: 1,
|
|
});
|
|
}
|
|
return sources;
|
|
}
|
|
|
|
// Mix the whole timeline in one pass. OfflineAudioContext renders faster than
|
|
// real time and is sample-accurate, unlike routing live elements into a
|
|
// MediaStream.
|
|
async function renderAudioBuffer() {
|
|
const sources = collectAudioSources();
|
|
if (sources.length == 0) {
|
|
return null;
|
|
}
|
|
|
|
const sampleRate = 48000;
|
|
const frames = Math.ceil((duration / 1000) * sampleRate);
|
|
if (!(frames > 0)) {
|
|
return null;
|
|
}
|
|
const offline = new OfflineAudioContext(2, frames, sampleRate);
|
|
|
|
const decoded = [];
|
|
for (const source of sources) {
|
|
try {
|
|
const response = await fetch(source.src);
|
|
const bytes = await response.arrayBuffer();
|
|
const buffer = await offline.decodeAudioData(bytes);
|
|
decoded.push({ source: source, buffer: buffer });
|
|
} catch (e) {
|
|
// Silent video, unsupported container, or an unreachable asset
|
|
console.warn('Skipping audio source', e);
|
|
}
|
|
}
|
|
if (decoded.length == 0) {
|
|
return null;
|
|
}
|
|
|
|
decoded.forEach(function (item) {
|
|
const node = offline.createBufferSource();
|
|
node.buffer = item.buffer;
|
|
const gain = offline.createGain();
|
|
gain.gain.value =
|
|
typeof item.source.volume == 'number' ? item.source.volume : 1;
|
|
node.connect(gain);
|
|
gain.connect(offline.destination);
|
|
|
|
const when = Math.max(0, item.source.start / 1000);
|
|
const offset = Math.max(0, item.source.trimstart / 1000);
|
|
const length = Math.max(
|
|
0,
|
|
(item.source.end - item.source.start) / 1000
|
|
);
|
|
if (length > 0 && offset < item.buffer.duration) {
|
|
node.start(when, offset, length);
|
|
}
|
|
});
|
|
|
|
return await offline.startRendering();
|
|
}
|
|
|
|
// Matroska needs the Opus identification header as CodecPrivate
|
|
function buildOpusHead(channels, sampleRate) {
|
|
const head = new Uint8Array(19);
|
|
const view = new DataView(head.buffer);
|
|
head.set([0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64], 0); // "OpusHead"
|
|
head[8] = 1; // version
|
|
head[9] = channels;
|
|
view.setUint16(10, 3840, true); // pre-skip
|
|
view.setUint32(12, sampleRate, true);
|
|
view.setUint16(16, 0, true); // output gain
|
|
head[18] = 0; // channel mapping family
|
|
return head;
|
|
}
|
|
|
|
async function encodeAudioBuffer(audioBuffer) {
|
|
const channels = Math.min(2, audioBuffer.numberOfChannels);
|
|
const sampleRate = audioBuffer.sampleRate;
|
|
const config = {
|
|
codec: 'opus',
|
|
sampleRate: sampleRate,
|
|
numberOfChannels: channels,
|
|
bitrate: 128000,
|
|
};
|
|
if (typeof AudioEncoder === 'undefined') {
|
|
return null;
|
|
}
|
|
const support = await AudioEncoder.isConfigSupported(config);
|
|
if (!support || !support.supported) {
|
|
return null;
|
|
}
|
|
|
|
const chunks = [];
|
|
var failed = false;
|
|
const encoder = new AudioEncoder({
|
|
output: function (chunk) {
|
|
chunks.push(chunk);
|
|
},
|
|
error: function (e) {
|
|
failed = true;
|
|
console.error('Audio encoding failed', e);
|
|
},
|
|
});
|
|
encoder.configure(config);
|
|
|
|
const sliceFrames = Math.round(sampleRate / 10); // 100ms
|
|
const planes = [];
|
|
for (var c = 0; c < channels; c++) {
|
|
planes.push(audioBuffer.getChannelData(c));
|
|
}
|
|
|
|
for (
|
|
var offset = 0;
|
|
offset < audioBuffer.length && !failed;
|
|
offset += sliceFrames
|
|
) {
|
|
const count = Math.min(sliceFrames, audioBuffer.length - offset);
|
|
const planar = new Float32Array(count * channels);
|
|
for (var ch = 0; ch < channels; ch++) {
|
|
planar.set(
|
|
planes[ch].subarray(offset, offset + count),
|
|
ch * count
|
|
);
|
|
}
|
|
const data = new AudioData({
|
|
format: 'f32-planar',
|
|
sampleRate: sampleRate,
|
|
numberOfFrames: count,
|
|
numberOfChannels: channels,
|
|
timestamp: Math.round((offset / sampleRate) * 1e6),
|
|
data: planar,
|
|
});
|
|
encoder.encode(data);
|
|
data.close();
|
|
}
|
|
|
|
await encoder.flush();
|
|
encoder.close();
|
|
if (failed || chunks.length == 0) {
|
|
return null;
|
|
}
|
|
return {
|
|
chunks: chunks,
|
|
sampleRate: sampleRate,
|
|
channels: channels,
|
|
codecPrivate: buildOpusHead(channels, sampleRate),
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Video
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function pickVideoCodec(width, height, fps) {
|
|
const candidates = [
|
|
{ codec: 'vp09.00.10.08', name: 'VP9' },
|
|
{ codec: 'vp8', name: 'VP8' },
|
|
];
|
|
for (const candidate of candidates) {
|
|
const config = {
|
|
codec: candidate.codec,
|
|
width: width,
|
|
height: height,
|
|
bitrate: 8000000,
|
|
framerate: fps,
|
|
};
|
|
try {
|
|
const support = await VideoEncoder.isConfigSupported(config);
|
|
if (support && support.supported) {
|
|
return { config: config, name: candidate.name };
|
|
}
|
|
} catch (e) {
|
|
// try the next one
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Orchestration
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Confirm the muxed file is actually decodable before handing it to the user.
|
|
// The muxer is hand-rolled, so a silent failure here would otherwise reach
|
|
// the user as a file that will not play.
|
|
function verifyRenderedBlob(blob) {
|
|
return new Promise(function (resolve) {
|
|
const element = document.createElement('video');
|
|
const url = URL.createObjectURL(blob);
|
|
var settled = false;
|
|
function finish(ok) {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
URL.revokeObjectURL(url);
|
|
element.removeAttribute('src');
|
|
resolve(ok);
|
|
}
|
|
element.onloadedmetadata = function () {
|
|
finish(element.videoWidth > 0 && element.videoHeight > 0);
|
|
};
|
|
element.onerror = function () {
|
|
finish(false);
|
|
};
|
|
window.setTimeout(function () {
|
|
finish(false);
|
|
}, 5000);
|
|
element.src = url;
|
|
});
|
|
}
|
|
|
|
// Returns a WebM Blob, or null to tell the caller to use the real-time path
|
|
async function renderFrameAccurate() {
|
|
const canvasElement = document.getElementById('canvasrecord');
|
|
const width = canvasElement.width;
|
|
const height = canvasElement.height;
|
|
if (!(width > 0 && height > 0)) {
|
|
return null;
|
|
}
|
|
|
|
const fps = getExportFramerate();
|
|
const selected = await pickVideoCodec(width, height, fps);
|
|
if (!selected) {
|
|
console.warn('No supported WebCodecs video configuration');
|
|
return null;
|
|
}
|
|
|
|
offlinerender = true;
|
|
var encoder = null;
|
|
try {
|
|
renderProgress('Mixing audio...');
|
|
var audio = null;
|
|
try {
|
|
const audioBuffer = await renderAudioBuffer();
|
|
if (audioBuffer) {
|
|
audio = await encodeAudioBuffer(audioBuffer);
|
|
}
|
|
} catch (e) {
|
|
console.warn('Rendering without audio', e);
|
|
audio = null;
|
|
}
|
|
|
|
const writer = new WebMWriter({
|
|
codec: selected.name,
|
|
width: width,
|
|
height: height,
|
|
audio: audio
|
|
? {
|
|
sampleRate: audio.sampleRate,
|
|
channels: audio.channels,
|
|
codecPrivate: audio.codecPrivate,
|
|
}
|
|
: null,
|
|
});
|
|
|
|
const videoChunks = [];
|
|
var encoderFailed = false;
|
|
encoder = new VideoEncoder({
|
|
output: function (chunk) {
|
|
videoChunks.push(chunk);
|
|
},
|
|
error: function (e) {
|
|
encoderFailed = true;
|
|
console.error('Video encoding failed', e);
|
|
},
|
|
});
|
|
encoder.configure(selected.config);
|
|
|
|
const totalFrames = Math.max(
|
|
1,
|
|
Math.round((duration / 1000) * fps)
|
|
);
|
|
const frameDuration = 1e6 / fps;
|
|
const keyframeEvery = Math.max(
|
|
1,
|
|
Math.round(fps * RENDER_KEYFRAME_INTERVAL)
|
|
);
|
|
|
|
for (var i = 0; i < totalFrames; i++) {
|
|
if (encoderFailed) {
|
|
break;
|
|
}
|
|
const time = (i / fps) * 1000;
|
|
|
|
await seekMediaForFrame(time);
|
|
await recordAnimate(time);
|
|
canvasrecord.renderAll();
|
|
|
|
const frame = new VideoFrame(canvasElement, {
|
|
timestamp: Math.round(i * frameDuration),
|
|
duration: Math.round(frameDuration),
|
|
});
|
|
encoder.encode(frame, { keyFrame: i % keyframeEvery == 0 });
|
|
frame.close();
|
|
|
|
// Keep the encoder queue short so memory stays bounded
|
|
while (encoder.encodeQueueSize > 8 && !encoderFailed) {
|
|
await new Promise(function (resolve) {
|
|
window.setTimeout(resolve, 4);
|
|
});
|
|
}
|
|
|
|
if (i % 5 == 0) {
|
|
renderProgress(
|
|
'Rendering ' +
|
|
Math.round(((i + 1) / totalFrames) * 100) +
|
|
'%'
|
|
);
|
|
}
|
|
}
|
|
|
|
await encoder.flush();
|
|
encoder.close();
|
|
encoder = null;
|
|
if (encoderFailed || videoChunks.length == 0) {
|
|
return null;
|
|
}
|
|
|
|
renderProgress('Muxing...');
|
|
// Interleave both tracks in timestamp order: a cluster's blocks have to
|
|
// be ordered, and this avoids threading the two producers together.
|
|
const all = [];
|
|
videoChunks.forEach(function (chunk) {
|
|
all.push({ chunk: chunk, track: 1 });
|
|
});
|
|
if (audio) {
|
|
audio.chunks.forEach(function (chunk) {
|
|
all.push({ chunk: chunk, track: 2 });
|
|
});
|
|
}
|
|
all.sort(function (a, b) {
|
|
if (a.chunk.timestamp == b.chunk.timestamp) {
|
|
return a.track - b.track;
|
|
}
|
|
return a.chunk.timestamp - b.chunk.timestamp;
|
|
});
|
|
all.forEach(function (item) {
|
|
writer.addFrame(item.chunk, item.track);
|
|
});
|
|
|
|
const blob = await writer.complete();
|
|
if (!blob || blob.size == 0) {
|
|
return null;
|
|
}
|
|
if (!(await verifyRenderedBlob(blob))) {
|
|
console.warn(
|
|
'Rendered file failed verification, using the real-time encoder'
|
|
);
|
|
return null;
|
|
}
|
|
return blob;
|
|
} catch (e) {
|
|
console.error('Frame-accurate render failed', e);
|
|
return null;
|
|
} finally {
|
|
offlinerender = false;
|
|
if (encoder && encoder.state != 'closed') {
|
|
try {
|
|
encoder.close();
|
|
} catch (e) {
|
|
// already torn down
|
|
}
|
|
}
|
|
}
|
|
}
|