diff --git a/packages/core/src/runtime/clock.test.ts b/packages/core/src/runtime/clock.test.ts
index fbfad2ec33..89f4bc92a7 100644
--- a/packages/core/src/runtime/clock.test.ts
+++ b/packages/core/src/runtime/clock.test.ts
@@ -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", () => {
diff --git a/packages/core/src/runtime/clock.ts b/packages/core/src/runtime/clock.ts
index be846c5989..85dfa97312 100644
--- a/packages/core/src/runtime/clock.ts
+++ b/packages/core/src/runtime/clock.ts
@@ -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. */
diff --git a/packages/player/tests/playbench/webkit-stops.ts b/packages/player/tests/playbench/webkit-stops.ts
new file mode 100644
index 0000000000..35427fcd16
--- /dev/null
+++ b/packages/player/tests/playbench/webkit-stops.ts
@@ -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 = `
+
+
+
+
+`;
+
+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 = { "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((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((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);