Files
hyperframes/packages/player/tests/playbench/playbench.test.ts
Miguel Ángel 594df21537 fix(core): a video after a cut plays in sync instead of about 70 ms behind (#6494)
* fix(core): a video after a cut starts early by its measured start delay, so it plays in sync

* test(player): playbench reports how far the picture after a cut runs behind the playhead

* fix(core): a looping video no longer skews the learned video start delay

* fix(core): the video start delay is learned in less code and capped at 100 ms under test
2026-10-27 06:09:05 +00:00

179 lines
6.1 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { analyze, type Dump, type Run } from "./analyze.ts";
import { buildEdit, compositionHtml } from "./compositions.ts";
const empty = (kind: Dump["kind"]): Dump => ({
kind,
events: [],
frames: [],
samples: [],
params: [],
edges: [],
sources: [],
tap: [],
ctxs: [],
rejects: 0,
});
describe("the two edits", () => {
it("the 3-take edit is 24.8 s at 30 fps with six media elements and audio windows that overlap about 300 ms", () => {
const edit = buildEdit("primary");
expect(edit.takes).toHaveLength(3);
expect(edit.takes.reduce((a, t) => a + t.dur, 0)).toBeCloseTo(24.8, 2);
expect(edit.takes[0]!.dur).toBeCloseTo(89 / 30, 3);
for (const [a, b] of [
[0, 1],
[1, 2],
] as const) {
const overlap =
edit.takes[a]!.audio.start + edit.takes[a]!.audio.dur - edit.takes[b]!.audio.start;
expect(overlap).toBeGreaterThan(0.27);
expect(overlap).toBeLessThan(0.31);
}
const html = compositionHtml(edit, "/media");
expect(html.match(/<(video|audio) /g)).toHaveLength(6);
expect(html).toContain("data-automation=");
});
it("the 10-take edit is 53.08 s at 25 fps with twenty media elements and take 1 cut from 208.05 s", () => {
const edit = buildEdit("tentake");
expect(edit.takes).toHaveLength(10);
expect(edit.takes.reduce((a, t) => a + t.dur, 0)).toBeCloseTo(53.08, 2);
expect(edit.takes[0]!.inPoint).toBeCloseTo(208.04, 1);
expect(compositionHtml(edit, "/media").match(/<(video|audio) /g)).toHaveLength(20);
expect(buildEdit("tentake")).toEqual(edit);
});
});
describe("analyze", () => {
const edit = buildEdit("primary");
const base = (): Run => ({
edit,
comp: empty("comp"),
host: empty("host"),
playAt: 1000,
endedAt: null,
ownership: [],
});
it("counts the frames a visible outgoing video presents past its out-point, and ignores hidden ones", () => {
const run = base();
const out = edit.takes[0]!.outFrame;
run.comp.frames = [
[1100, "v0", (out - 1) / 30, 1, 1],
[1133, "v0", out / 30, 2, 1],
[1166, "v0", (out + 1) / 30, 3, 1],
[1200, "v0", (out + 2) / 30, 4, 0],
];
expect(analyze(run).firstTakeOverrunFrames).toBe(2);
});
it("measures the jump of a seek in the first 600 ms of a join and the second-frame gap of the incoming video", () => {
const run = base();
const t = edit.takes[1]!;
// The playhead runs 1 s per second from the start; the audio window of take 1 opens at t.audio.start.
for (let ms = 0; ms <= 4000; ms += 10)
run.comp.samples.push([1000 + ms, "v0", 0, 0, 0, 1, 1, ms / 1000]);
const open = 1000 + t.audio.start * 1000;
// a1 plays, is seeked forward 80 ms at open + 200 ms
for (let ms = 0; ms <= 600; ms += 10)
run.comp.samples.push([
open + ms,
"a1",
t.audio.mediaStart + ms / 1000 + (ms >= 200 ? 0.08 : 0),
0,
0,
1,
1,
null,
]);
run.comp.samples.sort((a, b) => a[0] - b[0]);
run.comp.events = [
[open + 5, "a1", "playing", 0],
[open + 200, "a1", "seeking", 0],
];
run.comp.frames = [
[1000 + t.start * 1000, "v1", t.inPoint, 1, 1],
[1000 + t.start * 1000 + 66.6, "v1", t.inPoint + 1 / 30, 2, 1],
];
const m = analyze(run);
expect(m.joinsWithMidPlaySeek).toBe(1);
expect(m.jumpMaxMs).toBeGreaterThan(70);
expect(m.jumpMaxMs).toBeLessThan(90);
expect(m.secondFrameGapMedianMs).toBeCloseTo(66.6, 1);
});
it("reports a playhead that held 30 ms across a cut", () => {
const run = base();
const cut = edit.takes[1]!.start;
let wall = 1000,
ph = 0,
held = false;
for (let i = 0; i < 400; i++) {
run.comp.samples.push([wall, "v0", 0, 0, 0, 1, 1, ph]);
wall += 10;
if (!held && ph >= cut) {
wall += 30;
held = true;
}
ph += 0.01;
}
expect(analyze(run).playheadHoldMedianMs).toBeCloseTo(30, 0);
});
it("reports an incoming picture that runs 50 ms behind the playhead after its cut", () => {
const run = base();
for (const t of edit.takes.slice(1))
for (let i = 0; i <= 50; i++) {
const ph = t.start + i * 0.01;
run.comp.samples.push([
1000 + ph * 1000,
`v${t.index}`,
t.inPoint + ph - t.start - 0.05,
0,
0,
1,
1,
ph,
]);
}
const m = analyze(run);
expect(m.cutPictureLagMedianMs).toBeCloseTo(50, 0);
expect(m.cutPictureLagMaxMs).toBeCloseTo(50, 0);
});
it("counts a take as heard only when the audio reaching the output comes from that take's stretch of the file", () => {
const run = base();
run.ownership = [[1000, "parent"]];
const t = edit.takes[1]!;
expect(t.inPoint).toBeGreaterThan(5);
// The playhead starts late and the host page has no __player, so its rows carry no playhead.
const delay = Math.round(t.dur * 600);
const open = 1000 + delay + t.start * 1000;
for (let ms = 0; ms <= (t.start + t.dur) * 1000 + delay + 1000; ms += 10) {
run.comp.samples.push([1000 + ms, "v0", 0, 0, 0, 1, 1, Math.max(0, ms - delay) / 1000]);
run.host.samples.push([1000 + ms, "x", 0, 0, 0, 1, 1, null]);
}
const row = (at: number, pos: number): Dump["tap"][number] => [
0,
48000,
0.1,
0,
Math.floor((pos % 16) / 0.5),
Math.floor(pos / 16),
at,
];
// the previous take is still audible while the delayed playhead has not reached this take
for (let ms = 0; ms < delay; ms += 100)
run.host.tap.push(row(1000 + t.start * 1000 + ms, (t.inPoint + 25) % 50));
for (let ms = 0; ms < t.dur * 1000; ms += 100)
run.host.tap.push(row(open + ms, t.inPoint + ms / 1000));
expect(analyze(run).takesHeard).toBe(1);
// audio from another stretch of the file (the proxy playing from the wrong offset) is not heard
run.host.tap = [];
for (let ms = 0; ms < t.dur * 1000; ms += 100)
run.host.tap.push(row(open + ms, (t.inPoint + 25 + ms / 1000) % 50));
expect(analyze(run).takesHeard).toBe(0);
});
});