Files
Motionity/src/js/render.js
T
kawaandClaude Opus 5 a6ec6c980a fix: audit src/js and add a frame-accurate export path
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>
2026-08-12 12:58:51 +02:00

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
}
}
}
}