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);