The exporter was losing roughly three of every four frames. A 20s render
muxed 319 samples instead of 1200 and played at 15.9 fps, with no error
reported and a file that looked superficially fine.
The cause was B-frames. WebCodecs delivers chunks in decode order, but
EncodedVideoChunk carries only a presentation timestamp — there is no decode
timestamp to recover the real order from. Handed presentation timestamps as
if they were decode timestamps, mp4-muxer saw DTS run backwards and rejected
every reordered chunk. That throw happened inside the encoder's output
callback, where it could not reach the export loop, so it surfaced as an
uncaught error and the render carried on. Only the I/P frames survived, one
per 4-frame GOP, which is exactly the stts pattern the files showed.
Writing the correct timeline instead is not available to us: it needs
negative composition offsets, and mp4-muxer emits ctts as a version-0 box,
which is unsigned. isConfigSupported says nothing about reordering, and
measurement showed latencyMode: 'realtime' does not prevent it either.
So pickVideoConfig now test-encodes 12 frames per candidate and checks the
order they come back in, taking the first profile that does not reorder.
Candidates stay in quality order, high profile down to baseline, so browsers
that never reorder keep the better profiles; baseline forbids B-slices by
spec and is the guaranteed floor. If every supported profile reorders the
export fails up front rather than after minutes of rendering.
Two guards so this class of loss cannot be silent again:
- The output callback catches, routing muxer rejections to the error list
the loop actually checks.
- Frames in and chunks accepted are counted and compared after flush, with
the reordering count and a gap histogram alongside. The count deliberately
tracks chunks the muxer took, not chunks that arrived — counting arrivals
reports success for frames rejected a line later.
Failures now raise a toast over the stage that stays until dismissed. An
export that dies after a long render should not sit unread in a panel.
Also corrects the record from d31d0fc, which claimed VideoEncoder.encode()
silently drops frames once its queue saturates. It does not: the queue grows
without bound and the only cost is memory. That commit's dequeue-gated
backpressure addressed a mechanism that does not exist and is reverted here;
the queue poll it replaced is restored, described honestly as a memory bound.
The opus resampling from that commit was a real fix and is untouched.
tools/probe-mp4.js reports per-track timescale, sample count and the stts
table, which is what identified the fault and what verifies a good export:
one row of [N x 1].
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
89 lines
3.2 KiB
JavaScript
89 lines
3.2 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* Report what an exported mp4 actually contains, per track: timescale, sample
|
||
* count, and the sample-duration table (stts).
|
||
*
|
||
* The point is to separate "the exporter dropped frames" from "the player is
|
||
* stuttering on a file that is fine". A 60 fps, N-second video must show
|
||
* timescale 60 and one stts row of [N*60, 1]. Several rows — or any row with a
|
||
* duration of 0 — means the muxed timeline is wrong, and the frames that are
|
||
* there will not be shown.
|
||
*
|
||
* node tools/probe-mp4.js path/to/export.mp4
|
||
*/
|
||
import fs from 'fs';
|
||
|
||
const path = process.argv[2];
|
||
if (!path) {
|
||
console.error('usage: node tools/probe-mp4.js <file.mp4>');
|
||
process.exit(1);
|
||
}
|
||
const buf = fs.readFileSync(path);
|
||
|
||
const CONTAINERS = ['moov', 'trak', 'mdia', 'minf', 'stbl'];
|
||
const tracks = [];
|
||
let cur = null;
|
||
|
||
function walk(start, end) {
|
||
let off = start;
|
||
while (off + 8 <= end) {
|
||
let size = buf.readUInt32BE(off);
|
||
const type = buf.toString('latin1', off + 4, off + 8);
|
||
let hdr = 8;
|
||
if (size === 1) { size = Number(buf.readBigUInt64BE(off + 8)); hdr = 16; }
|
||
if (size === 0) size = end - off;
|
||
if (CONTAINERS.includes(type)) {
|
||
if (type === 'trak') { cur = {}; tracks.push(cur); }
|
||
walk(off + hdr, off + size);
|
||
} else if (cur) {
|
||
box(type, off + hdr);
|
||
}
|
||
off += size;
|
||
}
|
||
}
|
||
|
||
function box(type, s) {
|
||
if (type === 'mdhd') {
|
||
cur.timescale = buf.readUInt32BE(s + 12);
|
||
cur.duration = buf.readUInt32BE(s + 16);
|
||
} else if (type === 'hdlr') {
|
||
cur.kind = buf.toString('latin1', s + 8, s + 12);
|
||
} else if (type === 'stsd') {
|
||
cur.format = buf.toString('latin1', s + 12, s + 16);
|
||
} else if (type === 'stsz') {
|
||
cur.sampleCount = buf.readUInt32BE(s + 8);
|
||
} else if (type === 'stts') {
|
||
const n = buf.readUInt32BE(s + 4);
|
||
const rows = [];
|
||
let samples = 0;
|
||
let total = 0;
|
||
for (let i = 0; i < n; i++) {
|
||
const count = buf.readUInt32BE(s + 8 + i * 8);
|
||
const delta = buf.readUInt32BE(s + 12 + i * 8);
|
||
rows.push({ count, delta });
|
||
samples += count;
|
||
total += count * delta;
|
||
}
|
||
cur.stts = { rows, samples, total };
|
||
}
|
||
}
|
||
|
||
walk(0, buf.length);
|
||
|
||
for (const t of tracks) {
|
||
const stts = t.stts || { rows: [], samples: 0, total: 0 };
|
||
const seconds = t.timescale ? stts.total / t.timescale : 0;
|
||
console.log(`\n[${t.kind}] ${t.format} · timescale ${t.timescale}`);
|
||
console.log(` samples: ${t.sampleCount} · media duration: ${seconds.toFixed(3)}s`);
|
||
if (t.kind === 'vide' && seconds > 0) {
|
||
console.log(` effective frame rate: ${(stts.samples / seconds).toFixed(3)} fps`);
|
||
}
|
||
console.log(` stts rows (${stts.rows.length}):`);
|
||
for (const r of stts.rows.slice(0, 20)) {
|
||
console.log(` ${r.count} × ${r.delta}${r.delta === 0 ? ' <-- zero duration: these frames never display' : ''}`);
|
||
}
|
||
if (stts.rows.length > 20) console.log(` … ${stts.rows.length - 20} more rows`);
|
||
const zeros = stts.rows.filter((r) => r.delta === 0).reduce((a, r) => a + r.count, 0);
|
||
if (zeros) console.log(` ${zeros} samples have zero duration`);
|
||
}
|