fix(core): the playhead keeps moving at a cut while the incoming take has not started (#6768)

* fix(core): the playhead keeps moving at a cut while the incoming take has not started

* fix(core): shorten a comment in the clock

* fix(player): keep the WebKit stops script out of the type check

* fix(core): cut two comments from the clock
This commit is contained in:
Miguel Ángel
2026-10-30 02:25:26 +00:00
committed by GitHub
parent a30a520970
commit 471d60a8ac
3 changed files with 199 additions and 21 deletions
+38 -11
View File
@@ -338,7 +338,7 @@ describe("TransportClock", () => {
expect(clock.getSource()).toBe("monotonic");
});
it("holds, never rewinds, the playhead for a clip that started late, then follows it", () => {
it("never rewinds the playhead for a clip that started late, and runs on while it catches up", () => {
const { clock, advance } = createClock({ duration: 30 });
clock.play();
advancePolled(clock, advance, 6300);
@@ -347,20 +347,43 @@ describe("TransportClock", () => {
expect(clock.now()).toBeCloseTo(6.3, 5);
advance(100);
sfx.currentTime = 0.02;
expect(clock.now()).toBeCloseTo(6.3, 5);
expect(clock.now()).toBeCloseTo(6.4, 5); // steering catches the clip up; the playhead does not wait
advance(100);
sfx.currentTime = 0.12;
expect(clock.now()).toBeCloseTo(6.37, 5);
sfx.currentTime = 0.4; // now it is ahead of the playhead: the playhead follows it
expect(clock.now()).toBeCloseTo(6.65, 5);
});
it("holds the playhead while its element seeks, even to a time ahead", () => {
it("keeps the playhead moving while a just-played element is frozen behind it, as WebKit leaves it", () => {
const { clock, advance } = createClock({ duration: 30 });
clock.play();
advancePolled(clock, advance, 6300);
const take = createMockAudioEl(0, false); // playing, its time frozen at its start
clock.attachAudioSource({ el: take, compositionStart: 6, mediaStart: 0 });
let previous = clock.now();
for (let i = 0; i < 20; i++) {
advance(16);
const now = clock.now();
expect(now).toBeGreaterThan(previous);
previous = now;
}
});
it("keeps the playhead moving while the element it follows is seeked", () => {
const { clock, advance } = createClock({ duration: 30 });
clock.play();
advancePolled(clock, advance, 2000);
const voice = Object.assign(createMockAudioEl(3, false), { seeking: true });
const voice = Object.assign(createMockAudioEl(0.1, false), { seeking: false });
clock.attachAudioSource({ el: voice, compositionStart: 2, mediaStart: 0 });
advance(150);
expect(clock.now()).toBeCloseTo(2, 5);
expect(clock.now()).toBeCloseTo(2.1, 5);
voice.seeking = true;
voice.currentTime = 3; // WebKit reports the target at once
let previous = clock.now();
for (let i = 0; i < 20; i++) {
advance(16);
const now = clock.now();
expect(now).toBeGreaterThan(previous);
previous = now;
}
});
it("holds the playhead on an element that is buffering behind it", () => {
@@ -395,14 +418,18 @@ describe("TransportClock", () => {
expect(clock.now()).toBeCloseTo(0.316, 5);
});
it("holds the playhead for a looping bed that starts late", () => {
it("keeps the playhead moving for a looping bed that starts late", () => {
const { clock, advance } = createClock({ duration: 30 });
clock.play();
advancePolled(clock, advance, 2000);
const bed = Object.assign(createMockAudioEl(0, false), { loop: true, duration: 8 });
const bed = Object.assign(createMockAudioEl(0, false), {
loop: true,
duration: 8,
readyState: 4,
});
clock.attachAudioSource({ el: bed, compositionStart: 1.9, mediaStart: 0 });
advance(16);
expect(clock.now()).toBeCloseTo(2, 5);
expect(clock.now()).toBeCloseTo(2.016, 5);
});
it("continues from the last audio time when the element drops out", () => {
+4 -10
View File
@@ -1,5 +1,4 @@
import { timeAtSourceTime, type RateSpec } from "../speedRamp.js";
import { MEDIA_HARD_SYNC_SECONDS } from "./mediaSteer.js";
import { playRangeHoldTime } from "./protocol.js";
export type TransportClockSnapshot = {
@@ -77,7 +76,6 @@ export class TransportClock {
}
}
// Monotonic fallback
this._applyStallCorrection();
const elapsed = (this._nowMs() - this._playStartMs) / 1000;
const t = this._baseTime + elapsed * this._rate;
@@ -90,16 +88,12 @@ export class TransportClock {
): number | null {
const { el, compositionStart, mediaStart, rate } = source;
if (el.paused || !Number.isFinite(el.currentTime)) return null;
if (el.seeking) return this._lastNow;
if (el.seeking) return null;
const spec = rate ?? 1;
const time = timeAtSourceTime(spec, el.currentTime - mediaStart) + compositionStart;
return time >= this._lastNow ? time : this._heldTimeWhenBehind(el, this._lastNow - time, spec);
}
private _heldTimeWhenBehind(el: HTMLMediaElement, behind: number, spec: RateSpec): number | null {
if (el.loop && behind > timeAtSourceTime(spec, el.duration) / 2) return null;
const buffering = el.readyState < HAVE_FUTURE_DATA;
return buffering || behind <= MEDIA_HARD_SYNC_SECONDS ? this._lastNow : null;
if (time >= this._lastNow) return time;
const wrapped = el.loop && this._lastNow - time > timeAtSourceTime(spec, el.duration) / 2;
return !wrapped && el.readyState < HAVE_FUTURE_DATA ? this._lastNow : null;
}
/** Folds a >500ms gap since the last read into `_playStartMs` so it's never reported as played time. */
@@ -0,0 +1,157 @@
#!/usr/bin/env bun
// Plays an edit in WebKit and counts how often the playhead stands still at the cuts.
// Flags: --edit tentake|primary, --runs 3, --browser webkit|manual, --port 8099.
// webkit drives Playwright's WebKit (`playwright-core` and `playwright-core install webkit`); manual only serves
// /host.html and waits for a real Safari to open it, click Play and post the result.
import { existsSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { buildEdit, compositionHtml, type EditSpec } from "./compositions.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const PLAYER_PKG = resolve(HERE, "../..");
const REPO = resolve(PLAYER_PKG, "../..");
const MEDIA = join(HERE, "_media");
const arg = (name: string, d: string) => {
const i = process.argv.indexOf(`--${name}`);
return i > 0 ? (process.argv[i + 1] ?? d) : d;
};
const edit = buildEdit(arg("edit", "tentake") as EditSpec["name"]);
const RUNS = Number(arg("runs", "3"));
const STOP_MS = 50;
const gsap = [
join(PLAYER_PKG, "node_modules/gsap/dist/gsap.min.js"),
join(REPO, "node_modules/gsap/dist/gsap.min.js"),
].find(existsSync)!;
const file = (p: string, type: string) =>
new Response(Bun.file(p), { headers: { "Content-Type": type, "Cache-Control": "no-store" } });
const HOST = `<!doctype html><meta charset="utf-8">
<style>body{margin:0;background:#000}hyperframes-player{width:360px;height:640px;display:block}button{font-size:28px}</style>
<button id="go">Play</button>
<hyperframes-player id="p" src="/comp.html" width="720" height="1280"></hyperframes-player>
<script>
var p = document.getElementById("p"), samples = [], t0 = 0, done = false;
function finish() {
if (done) return; done = true;
var body = JSON.stringify({ t0: t0, end: performance.now(), samples: samples });
fetch("/result", { method: "POST", body: body }).then(function () { window.__done = true; });
}
p.addEventListener("ended", finish);
document.getElementById("go").onclick = function () {
t0 = performance.now(); p.play();
(function tick() {
if (done) return;
var w = p.shadowRoot.querySelector("iframe").contentWindow, t = w.__player && w.__player.getTime ? w.__player.getTime() : p.currentTime;
var els = [].filter.call(w.document.querySelectorAll("audio"), function (a) { return !a.paused; }).map(function (a) { return a.id + ":" + a.currentTime.toFixed(2) + (a.seeking ? "S" : "") + a.readyState; }).join(" ");
samples.push([performance.now(), t, els]);
if (performance.now() - t0 > ${(edit.duration + 20) * 1000}) return finish();
requestAnimationFrame(tick);
})();
};
</script>
<script src="/player.js"></script>`;
type Result = { t0: number; end: number; samples: [number, number, string?][] };
/** Stops are runs where the playhead stood still for more than STOP_MS, each tied to the cut it started at. */
export function measure(r: Result, cuts: number[]) {
const s = r.samples;
const stops: { at: number; ms: number }[] = [];
let from = 0;
for (let i = 1; i <= s.length; i++) {
if (i < s.length && s[i]![1] === s[from]![1]) continue;
const ms = (i < s.length ? s[i]![0] : r.end) - s[from]![0];
if (ms > STOP_MS && s[from]![1] > 0.5) stops.push({ at: s[from]![1], ms });
from = i;
}
const perCut = cuts.map((c) => stops.filter((x) => x.at >= c - 0.3 && x.at < c + 1.5));
const played = (r.end - r.t0) / 1000;
return {
stops: stops.length,
stopsPerCut: stops.length / cuts.length,
longestMs: Math.round(Math.max(0, ...stops.map((x) => x.ms))),
cutsWithStop: perCut.filter((c) => c.length).length,
cuts: cuts.length,
last: s.length ? Math.round(s[s.length - 1]![1] * 100) / 100 : 0,
worst: [...stops]
.sort((a, b) => b.ms - a.ms)
.slice(0, 4)
.map((x) => `${x.at.toFixed(2)}s:${Math.round(x.ms)}ms`),
overrunS: Math.round((played - edit.duration) * 10) / 10,
};
}
const results: Result[] = [];
const TRACE = process.argv.includes("--trace");
let resolveResult: (() => void) | null = null;
const server = Bun.serve({
port: Number(arg("port", "0")),
async fetch(req) {
const p = new URL(req.url).pathname;
const html = (b: string) => new Response(b, { headers: { "Content-Type": "text/html" } });
if (p === "/host.html") return html(HOST);
if (p === "/comp.html") return html(compositionHtml(edit, "/media"));
if (p === "/player.js")
return file(join(PLAYER_PKG, "dist/hyperframes-player.global.js"), "text/javascript");
if (p === "/vendor/hyperframe.runtime.iife.js")
return file(join(REPO, "packages/core/dist/hyperframe.runtime.iife.js"), "text/javascript");
if (p === "/vendor/gsap.min.js") return file(gsap, "text/javascript");
if (p === "/result") {
results.push((await req.json()) as Result);
resolveResult?.();
return new Response("ok");
}
if (p.startsWith("/media/")) {
const f = Bun.file(join(MEDIA, p.slice(7)));
const m = /^bytes=(\d+)-(\d*)$/.exec(req.headers.get("range") ?? "");
const h: Record<string, string> = { "Content-Type": "video/mp4", "Accept-Ranges": "bytes" };
if (!m) return new Response(f, { headers: h });
const a = Number(m[1]);
const b = m[2] ? Math.min(Number(m[2]), f.size - 1) : f.size - 1;
h["Content-Range"] = `bytes ${a}-${b}/${f.size}`;
return new Response(f.slice(a, b + 1), { status: 206, headers: h });
}
return new Response("not found", { status: 404 });
},
});
const cuts = edit.takes.slice(1).map((t) => t.start);
const origin = `http://127.0.0.1:${server.port}`;
async function drivePlaywright() {
const name = "playwright-core"; // not a dependency: installed by hand, so kept out of the type check
const { webkit } = (await import(name)) as { webkit: any }; // oxlint-disable-line no-explicit-any
const browser = await webkit.launch();
console.log(`WebKit ${browser.version()}`);
for (let i = 0; i < RUNS; i++) {
const page = await browser.newPage();
await page.goto(`${origin}/host.html`);
await page.waitForFunction(() => document.querySelector("hyperframes-player") !== null);
await page.waitForTimeout(8000); // media load
const done = new Promise<void>((r) => (resolveResult = r));
await page.click("#go");
await done;
await page.close();
}
await browser.close();
}
if (arg("browser", "webkit") === "webkit") await drivePlaywright();
else {
console.log(`open ${origin}/host.html in Safari, click Play (${RUNS} runs)`);
while (results.length < RUNS) await new Promise<void>((r) => (resolveResult = r));
}
console.log(`${edit.name}: ${edit.takes.length} takes, ${edit.duration} s, ${RUNS} runs`);
for (const [i, r] of results.entries()) {
console.log(`run ${i + 1}`, JSON.stringify(measure(r, cuts)));
if (TRACE) {
const w = measure(r, cuts).worst[0];
const at = w ? Number(w.split("s:")[0]) : 0;
const j = r.samples.findIndex((x) => x[1] >= at);
for (const x of r.samples.slice(Math.max(0, j - 6), j + 40))
console.log(` ${(x[0] - r.t0).toFixed(0)}ms t=${x[1].toFixed(3)} ${x[2]}`);
}
}
server.stop(true);
process.exit(0);