Files
Motionity/test/webm-muxer.test.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

287 lines
9.2 KiB
JavaScript

// Exercise the extended WebM muxer without a browser and validate the EBML it
// produces. Feeds fake encoded chunks, then parses the resulting file.
const fs = require('fs');
const vm = require('vm');
const code = fs.readFileSync(
require('path').join(__dirname, '..', 'src', 'js', 'webm-writer2.js'),
'utf8'
);
const sandbox = { Blob, console };
sandbox.self = sandbox;
vm.createContext(sandbox);
// `module` must stay undefined so the browser branch runs
vm.runInContext(code, sandbox);
const WebMWriter = sandbox.WebMWriter;
if (!WebMWriter) throw new Error('WebMWriter not exported');
class EncodedVideoChunk {
constructor(o) {
this.timestamp = o.timestamp;
this.type = o.type;
this._data = o.data;
this.byteLength = o.data.length;
}
copyTo(dst) {
dst.set(this._data);
}
}
class EncodedAudioChunk {
constructor(o) {
this.timestamp = o.timestamp;
this.type = o.type;
this._data = o.data;
this.byteLength = o.data.length;
}
copyTo(dst) {
dst.set(this._data);
}
}
function opusHead(channels, sampleRate) {
const head = new Uint8Array(19);
const view = new DataView(head.buffer);
head.set([0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64], 0);
head[8] = 1;
head[9] = channels;
view.setUint16(10, 3840, true);
view.setUint32(12, sampleRate, true);
view.setUint16(16, 0, true);
head[18] = 0;
return head;
}
// ---- EBML parser -----------------------------------------------------------
function readVarInt(buf, pos, stripMarker) {
const first = buf[pos];
if (first === 0) throw new Error('invalid varint at ' + pos);
let width = 1;
let mask = 0x80;
while (!(first & mask)) {
mask >>= 1;
width++;
}
let value = stripMarker ? first & (mask - 1) : first;
let unknown = stripMarker && (first & (mask - 1)) === mask - 1;
for (let i = 1; i < width; i++) {
value = value * 256 + buf[pos + i];
if (buf[pos + i] !== 0xff) unknown = false;
}
return { value, width, unknown };
}
function parse(buf, start, end, depth, out) {
let pos = start;
while (pos < end) {
const id = readVarInt(buf, pos, false);
const idBytes = buf.slice(pos, pos + id.width);
let idHex = 0;
for (const b of idBytes) idHex = idHex * 256 + b;
pos += id.width;
const size = readVarInt(buf, pos, true);
pos += size.width;
const dataStart = pos;
const dataEnd = size.unknown ? end : Math.min(dataStart + size.value, end);
out.push({ id: idHex, depth, dataStart, dataEnd });
const MASTER = [
0x1a45dfa3, 0x18538067, 0x1654ae6b, 0xae, 0x1f43b675, 0x1549a966,
0x114d9b74, 0x4dbb, 0xe0, 0xe1, 0x1c53bb6b, 0xbb, 0xb7,
];
if (MASTER.includes(idHex)) parse(buf, dataStart, dataEnd, depth + 1, out);
pos = dataEnd;
if (dataEnd <= dataStart && size.value === 0) continue;
}
}
function uintAt(buf, s, e) {
let v = 0;
for (let i = s; i < e; i++) v = v * 256 + buf[i];
return v;
}
function strAt(buf, s, e) {
return Buffer.from(buf.slice(s, e)).toString('ascii').replace(/\0+$/, '');
}
async function run(withAudio) {
const writer = new WebMWriter({
codec: 'VP9',
width: 640,
height: 480,
audio: withAudio
? { sampleRate: 48000, channels: 2, codecPrivate: opusHead(2, 48000) }
: null,
});
const FPS = 30;
const SECONDS = 75;
const items = [];
for (let i = 0; i < FPS * SECONDS; i++) {
items.push({
track: 1,
chunk: new EncodedVideoChunk({
timestamp: Math.round((i / FPS) * 1e6),
type: i % (FPS * 2) === 0 ? 'key' : 'delta',
data: new Uint8Array(120).fill(i & 0xff),
}),
});
}
if (withAudio) {
for (let i = 0; i < SECONDS * 10; i++) {
items.push({
track: 2,
chunk: new EncodedAudioChunk({
timestamp: Math.round(i * 0.1 * 1e6),
type: 'key',
data: new Uint8Array(40).fill(0x55),
}),
});
}
}
items.sort((a, b) =>
a.chunk.timestamp === b.chunk.timestamp
? a.track - b.track
: a.chunk.timestamp - b.chunk.timestamp
);
items.forEach((it) => writer.addFrame(it.chunk, it.track));
const blob = await writer.complete();
const buf = new Uint8Array(await blob.arrayBuffer());
const els = [];
parse(buf, 0, buf.length, 0, els);
const label = withAudio ? 'video+audio' : 'video only';
const problems = [];
if (!els.some((e) => e.id === 0x1a45dfa3)) problems.push('no EBML header');
if (!els.some((e) => e.id === 0x18538067)) problems.push('no Segment');
const trackEntries = els.filter((e) => e.id === 0xae);
const expectedTracks = withAudio ? 2 : 1;
if (trackEntries.length !== expectedTracks)
problems.push(`expected ${expectedTracks} TrackEntry, got ${trackEntries.length}`);
const codecIds = els
.filter((e) => e.id === 0x86)
.map((e) => strAt(buf, e.dataStart, e.dataEnd));
if (!codecIds.includes('V_VP9')) problems.push('missing V_VP9, got ' + codecIds);
if (withAudio && !codecIds.includes('A_OPUS'))
problems.push('missing A_OPUS, got ' + codecIds);
const trackTypes = els
.filter((e) => e.id === 0x83)
.map((e) => uintAt(buf, e.dataStart, e.dataEnd));
if (!trackTypes.includes(1)) problems.push('no video TrackType');
if (withAudio && !trackTypes.includes(2)) problems.push('no audio TrackType');
if (withAudio) {
const priv = els.find((e) => e.id === 0x63a2);
if (!priv) problems.push('no CodecPrivate');
else {
if (priv.dataEnd - priv.dataStart !== 19)
problems.push('CodecPrivate is not 19 bytes');
if (strAt(buf, priv.dataStart, priv.dataStart + 8) !== 'OpusHead')
problems.push('CodecPrivate is not OpusHead');
}
}
const clusters = els.filter((e) => e.id === 0x1f43b675);
if (clusters.length < 2)
problems.push(`expected multiple clusters over ${SECONDS}s, got ${clusters.length}`);
// Every SimpleBlock: track number valid, timecode inside signed 16 bit
const blocks = els.filter((e) => e.id === 0xa3);
if (blocks.length !== items.length)
problems.push(`expected ${items.length} blocks, got ${blocks.length}`);
const seenTracks = new Set();
let badTimecode = 0;
for (const b of blocks) {
const tn = readVarInt(buf, b.dataStart, true);
seenTracks.add(tn.value);
const tc = (buf[b.dataStart + tn.width] << 8) | buf[b.dataStart + tn.width + 1];
const signed = tc > 32767 ? tc - 65536 : tc;
if (signed < 0 || signed > 32767) badTimecode++;
}
if (badTimecode) problems.push(badTimecode + ' block timecodes out of range');
const expectTrackSet = withAudio ? [1, 2] : [1];
for (const t of expectTrackSet)
if (!seenTracks.has(t)) problems.push('no blocks for track ' + t);
for (const t of seenTracks)
if (!expectTrackSet.includes(t)) problems.push('unexpected track ' + t);
// Reconstruct absolute times: cluster timecode + block timecode must give
// back exactly the timestamps that went in, in order.
const rebuilt = [];
let lastClusterTime = -1;
for (const c of clusters) {
const tcEl = els.find(
(e) => e.id === 0xe7 && e.dataStart > c.dataStart && e.dataEnd <= c.dataEnd
);
if (!tcEl) {
problems.push('cluster without Timecode');
continue;
}
const clusterTime = uintAt(buf, tcEl.dataStart, tcEl.dataEnd);
if (clusterTime < lastClusterTime) problems.push('cluster timecodes go backwards');
lastClusterTime = clusterTime;
const inner = blocks.filter(
(b) => b.dataStart > c.dataStart && b.dataEnd <= c.dataEnd
);
if (inner.length === 0) problems.push('empty cluster');
for (const b of inner) {
const tn = readVarInt(buf, b.dataStart, true);
const raw = (buf[b.dataStart + tn.width] << 8) | buf[b.dataStart + tn.width + 1];
const rel = raw > 32767 ? raw - 65536 : raw;
if (rel < 0) problems.push('negative block timecode in cluster');
rebuilt.push({ track: tn.value, ms: clusterTime + rel });
}
}
const expected = items.map((it) => ({
track: it.track,
ms: Math.round(it.chunk.timestamp / 1000),
}));
if (rebuilt.length !== expected.length) {
problems.push('block count mismatch on rebuild');
} else {
let drift = 0;
for (let i = 0; i < expected.length; i++) {
if (rebuilt[i].track !== expected[i].track) {
problems.push('track order changed at block ' + i);
break;
}
drift = Math.max(drift, Math.abs(rebuilt[i].ms - expected[i].ms));
}
if (drift > 1) problems.push('timestamp drift of ' + drift + 'ms');
}
const durationEl = els.find((e) => e.id === 0x4489);
if (!durationEl) problems.push('no Duration');
else {
const dv = new DataView(buf.buffer, buf.byteOffset + durationEl.dataStart, 8);
const d = dv.getFloat64(0);
if (!(d > 0)) problems.push('Duration is ' + d);
else if (Math.abs(d - (SECONDS - 1 / FPS) * 1000) > 200)
problems.push('Duration ' + d + 'ms is not ~' + SECONDS * 1000);
}
console.log(
`[${label}] ${buf.length} bytes, ${clusters.length} clusters, ` +
`${blocks.length} blocks, tracks=${[...seenTracks].join(',')}`
);
if (problems.length) {
console.log(' FAIL:');
problems.forEach((p) => console.log(' - ' + p));
return false;
}
console.log(' OK');
return true;
}
(async () => {
const a = await run(false);
const b = await run(true);
process.exit(a && b ? 0 : 1);
})();