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>
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
// 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);
|
||||
})();
|
||||
Reference in New Issue
Block a user