diff --git a/packages/cli/src/capture/captureCompositionFrame.test.ts b/packages/cli/src/capture/captureCompositionFrame.test.ts index 69fa311aa85..9a21a76594f 100644 --- a/packages/cli/src/capture/captureCompositionFrame.test.ts +++ b/packages/cli/src/capture/captureCompositionFrame.test.ts @@ -1,3 +1,4 @@ +import { SEEK_SETTLES_BEFORE_PROTOCOL_TIMEOUT_MS } from "./captureTimeout.js"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; @@ -111,7 +112,10 @@ describe("seekCompositionTimeline", () => { expect(waitForFunction).not.toHaveBeenCalled(); expect(evaluate).toHaveBeenCalledTimes(4); expect(evaluate).toHaveBeenNthCalledWith(1, expect.any(Function), 1.25, false, false, false); - expect(evaluate).toHaveBeenNthCalledWith(2, expect.any(Function), { videoFrames: true }); + expect(evaluate).toHaveBeenNthCalledWith(2, expect.any(Function), { + videoFrames: true, + timeoutMs: SEEK_SETTLES_BEFORE_PROTOCOL_TIMEOUT_MS, + }); expect(evaluate.mock.calls[2]?.[0]).toContain("window.setTimeout(finish, 100)"); // Post-seek font settle: a seek can reveal glyphs whose unicode-range // subsets only start loading after the next layout (CJK snapshot reports). @@ -126,7 +130,10 @@ describe("seekCompositionTimeline", () => { expect(evaluate).toHaveBeenNthCalledWith(1, expect.any(Function), 1.25, false, false, false); await settleCompositionSeek(page); - expect(evaluate).toHaveBeenNthCalledWith(2, expect.any(Function), { videoFrames: true }); + expect(evaluate).toHaveBeenNthCalledWith(2, expect.any(Function), { + videoFrames: true, + timeoutMs: SEEK_SETTLES_BEFORE_PROTOCOL_TIMEOUT_MS, + }); expect(evaluate).toHaveBeenCalledTimes(4); }); @@ -245,7 +252,10 @@ describe("seekCompositionTimeline", () => { expect(evaluate).toHaveBeenCalledTimes(3); expect(evaluate).toHaveBeenNthCalledWith(1, expect.any(Function), 4, true, false, false); - expect(evaluate).toHaveBeenNthCalledWith(2, expect.any(Function), { videoFrames: true }); + expect(evaluate).toHaveBeenNthCalledWith(2, expect.any(Function), { + videoFrames: true, + timeoutMs: SEEK_SETTLES_BEFORE_PROTOCOL_TIMEOUT_MS, + }); expect(evaluate).toHaveBeenNthCalledWith(3, expect.any(Function), { ms: 500, frames: 2 }); const frames: (() => void)[] = []; @@ -543,8 +553,12 @@ describe("DENSE_GEOMETRY_SEEK_OPTIONS", () => { return barrier.mock.calls; }; - expect(await barrierCalls(DENSE_GEOMETRY_SEEK_OPTIONS)).toEqual([[{ videoFrames: false }]]); - expect(await barrierCalls(AUDIT_SEEK_OPTIONS)).toEqual([[{ videoFrames: true }]]); + expect(await barrierCalls(DENSE_GEOMETRY_SEEK_OPTIONS)).toEqual([ + [{ videoFrames: false, timeoutMs: SEEK_SETTLES_BEFORE_PROTOCOL_TIMEOUT_MS }], + ]); + expect(await barrierCalls(AUDIT_SEEK_OPTIONS)).toEqual([ + [{ videoFrames: true, timeoutMs: SEEK_SETTLES_BEFORE_PROTOCOL_TIMEOUT_MS }], + ]); }); it("is genuinely geometry-only — no post-seek settle waits at the 600-sample cap", () => { diff --git a/packages/cli/src/capture/captureCompositionFrame.ts b/packages/cli/src/capture/captureCompositionFrame.ts index cb7b42a76e8..4e1992c0210 100644 --- a/packages/cli/src/capture/captureCompositionFrame.ts +++ b/packages/cli/src/capture/captureCompositionFrame.ts @@ -1,4 +1,5 @@ import { oneProcessOverPipe } from "@hyperframes/engine"; +import { SEEK_SETTLES_BEFORE_PROTOCOL_TIMEOUT_MS } from "./captureTimeout.js"; import { rethrowAfterBrowserCleanup, launchManagedBrowser, @@ -72,7 +73,7 @@ export interface SeekCompositionTimelineOptions { skipWebglDraws?: boolean; } -type SeekBarrierOptions = { videoFrames: boolean }; +type SeekBarrierOptions = { videoFrames: boolean; timeoutMs: number }; type FontWaitOptions = { ms: number; frames: number }; type CompositionPageFunction = @@ -558,7 +559,10 @@ export async function settleCompositionSeek( await Reflect.apply(waitForCompletion, window, [barrier]); } }, - { videoFrames: options.waitForVideoFrames !== false }, + { + videoFrames: options.waitForVideoFrames !== false, + timeoutMs: SEEK_SETTLES_BEFORE_PROTOCOL_TIMEOUT_MS, + }, ); const animationFrameSettle = options.animationFrameSettle ?? "race"; diff --git a/packages/cli/src/capture/captureTimeout.ts b/packages/cli/src/capture/captureTimeout.ts index 43682e7a291..41b3d5e67c8 100644 --- a/packages/cli/src/capture/captureTimeout.ts +++ b/packages/cli/src/capture/captureTimeout.ts @@ -1,4 +1,6 @@ export const PUPPETEER_DEFAULT_PROTOCOL_TIMEOUT_MS = 180_000; +export const SEEK_SETTLES_BEFORE_PROTOCOL_TIMEOUT_MS = + PUPPETEER_DEFAULT_PROTOCOL_TIMEOUT_MS - 10_000; export class StageBudgetTimeoutError extends Error { readonly budgetMs: number; diff --git a/packages/cli/src/utils/checkBrowser.ts b/packages/cli/src/utils/checkBrowser.ts index d844f39e0ec..14f247d2d67 100644 --- a/packages/cli/src/utils/checkBrowser.ts +++ b/packages/cli/src/utils/checkBrowser.ts @@ -1,4 +1,5 @@ import type { CheckDeadline } from "./checkDeadline.js"; +import { SEEK_SETTLES_BEFORE_PROTOCOL_TIMEOUT_MS } from "../capture/captureTimeout.js"; import { logCheckProgress } from "./checkProgress.js"; import { BrowserCleanupError, closeManagedBrowser, onStopSignal } from "../browser/launch.js"; import { collectMotionBlurDrafts, dropMotionBlurConsoleEcho } from "./checkMotionBlur.js"; @@ -1221,6 +1222,7 @@ async function overlapGridInPage( fallback: boolean; skipDraws: boolean; videoFrames: boolean; + timeoutMs: number; }, seek: typeof seekInPage, anchors: typeof anchorsInPage, @@ -1228,11 +1230,15 @@ async function overlapGridInPage( const audit = Reflect.get(window, "__hyperframesOverlapAudit"); const waitForCompletion = Reflect.get(window, "__hfWaitForSeekCompletion"); const samples: OverlapGridSample[] = []; + const deadline = performance.now() + grid.timeoutMs; for (const time of grid.times) { console.debug(`${grid.marker}${time}`); seek(time, grid.fallback, false, grid.skipDraws); if (typeof waitForCompletion === "function") { - await Reflect.apply(waitForCompletion, window, [{ videoFrames: grid.videoFrames }]); + const timeoutMs = Math.max(0, deadline - performance.now()); + await Reflect.apply(waitForCompletion, window, [ + { videoFrames: grid.videoFrames, timeoutMs }, + ]); } const result: unknown = typeof audit === "function" ? Reflect.apply(audit, window, [{ time }]) : []; @@ -1256,6 +1262,7 @@ async function collectOverlapGrid(page: Page, times: number[]): Promise { ]); await expect(waitForSeekCompletion()).resolves.toBeUndefined(); }); + + // Main had no deadline: a hold that never settles waited out the caller's protocol timeout. + it("a seek with a hold that never settles ends at its deadline, naming what it still waits on", async () => { + registerSeekCompletion(new Promise(() => {}), "frame-source"); + registerSeekCompletion(Promise.resolve(), "video-frame"); + await expect(waitForSeekCompletion({ timeoutMs: 20 })).rejects.toThrow( + "seek did not settle within 20 ms: still waiting on frame-source", + ); + }); + + it("holds registered while the barrier waits still end at the same deadline", async () => { + let stopped = false; + const again = (): void => { + if (stopped) return; + registerSeekCompletion( + new Promise((resolve) => setTimeout(() => (again(), resolve(undefined)), 1)), + "video-frame", + ); + }; + again(); + const started = Date.now(); + try { + await expect(waitForSeekCompletion({ timeoutMs: 50 })).rejects.toThrow( + /still waiting on video-frame/, + ); + } finally { + stopped = true; + } + expect(Date.now() - started).toBeLessThan(1000); + }); + + it("without a caller's timeoutMs a seek waits as long as its holds take", async () => { + vi.useFakeTimers(); + try { + let settle = () => {}; + registerSeekCompletion(new Promise((resolve) => (settle = resolve)), "vfx"); + const barrier = waitForSeekCompletion(); + await vi.advanceTimersByTimeAsync(30 * 60_000); + settle(); + await expect(barrier).resolves.toBeUndefined(); + } finally { + vi.useRealTimers(); + } + }); + + it("a seek whose holds settle in time resolves as before", async () => { + registerSeekCompletion(new Promise((resolve) => setTimeout(resolve, 5)), "scene-window"); + await expect(waitForSeekCompletion({ timeoutMs: 1000 })).resolves.toBeUndefined(); + }); }); diff --git a/packages/core/src/runtime/adapters/seek-dispatch.ts b/packages/core/src/runtime/adapters/seek-dispatch.ts index 2173e8b1100..f6b81d68fa0 100644 --- a/packages/core/src/runtime/adapters/seek-dispatch.ts +++ b/packages/core/src/runtime/adapters/seek-dispatch.ts @@ -20,8 +20,16 @@ import { swallow } from "../diagnostics"; let _lastDispatchedTime = -1; type SeekCompletionResult = { status: "fulfilled" } | { status: "rejected"; reason: unknown }; -let _pendingCompletions = new Set>(); -let _videoFrameCompletions = new WeakSet>(); +/** What a seek waits on, named when the barrier stops waiting for it. */ +export type SeekHold = + | "video-frame" + | "hf-seek" + | "frame-source" + | "scene-window" + | "motion-blur" + | "vfx" + | "image-decode"; +let _pendingCompletions = new Map, SeekHold>(); let _pendingFailure: { reason: unknown } | undefined; let _activeCompletionBarriers = 0; @@ -50,7 +58,7 @@ function dispatch(time: number): void { accepting = false; } if (pending.length === 0) return; - registerSeekCompletion(Promise.all(pending)); + registerSeekCompletion(Promise.all(pending), "hf-seek"); } /** @@ -63,12 +71,11 @@ function dispatch(time: number): void { * its preview-side capture here, so the CLI seek paths that already await this * barrier wait for a `self` chain's paint instead of racing it. */ -export function registerSeekCompletion(promise: Promise, kind?: "video-frame"): void { +export function registerSeekCompletion(promise: Promise, hold: SeekHold): void { const completion = promise .then(() => ({ status: "fulfilled" })) .catch((reason: unknown) => ({ status: "rejected", reason })); - _pendingCompletions.add(completion); - if (kind === "video-frame") _videoFrameCompletions.add(completion); + _pendingCompletions.set(completion, hold); void completion.then((result) => { if (!_pendingCompletions.delete(completion)) return; if (result.status === "rejected" && _pendingFailure === undefined) { @@ -105,9 +112,9 @@ export function isSeekCompletionBarrierActive(): boolean { /** Settles with every pending completion, without consuming a failure the barrier reports. */ export async function seekCompletionsSettled(): Promise { for ( - let pending = [..._pendingCompletions]; + let pending = [..._pendingCompletions.keys()]; pending.length > 0; - pending = [..._pendingCompletions] + pending = [..._pendingCompletions.keys()] ) { await Promise.all(pending); } @@ -117,12 +124,34 @@ export function hasPendingSeekCompletion(): boolean { return _pendingCompletions.size > 0; } -/** `videoFrames: false` skips only the video-frame holds: a geometry read needs every other completion. */ -export async function waitForSeekCompletion(options?: { videoFrames?: boolean }): Promise { +/** All of `pending` settled, or null once `deadline` (epoch ms) passes first. */ +async function settledBy( + pending: Promise[], + deadline: number | undefined, +): Promise { + if (deadline === undefined) return Promise.all(pending); + let timer: ReturnType | undefined; + const late = new Promise((resolve) => { + timer = setTimeout(() => resolve(null), Math.max(0, deadline - Date.now())); + }); + try { + return await Promise.race([Promise.all(pending), late]); + } finally { + clearTimeout(timer); + } +} + +/** `videoFrames: false` skips only video-frame holds. Past a caller's `timeoutMs` it throws, naming what is pending. */ +export async function waitForSeekCompletion(options?: { + videoFrames?: boolean; + timeoutMs?: number; +}): Promise { const awaited = () => [..._pendingCompletions].filter( - (completion) => options?.videoFrames !== false || !_videoFrameCompletions.has(completion), + ([, hold]) => options?.videoFrames !== false || hold !== "video-frame", ); + const timeoutMs = options?.timeoutMs; + const deadline = timeoutMs === undefined ? undefined : Date.now() + timeoutMs; _activeCompletionBarriers += 1; let failed = _pendingFailure; try { @@ -130,7 +159,21 @@ export async function waitForSeekCompletion(options?: { videoFrames?: boolean }) // before either one consumes it. await Promise.resolve(); for (let pending = awaited(); pending.length > 0; pending = awaited()) { - const results = await Promise.all(pending); + // Holds that keep arriving past the deadline must not outrun it. + const results = + deadline === undefined || Date.now() < deadline + ? await settledBy( + pending.map(([completion]) => completion), + deadline, + ) + : null; + if (!results) { + const holds = [...new Set(awaited().map(([, hold]) => hold))].join(", "); + throw Object.assign( + new Error(`seek did not settle within ${timeoutMs} ms: still waiting on ${holds}`), + { name: "SeekNotSettledError" }, + ); + } const rejected = results.find((result) => result.status === "rejected"); if (failed === undefined && rejected?.status === "rejected") { failed = { reason: rejected.reason }; @@ -152,8 +195,7 @@ export async function waitForSeekCompletion(options?: { videoFrames?: boolean }) /** Reset internal state — used in tests to prevent cross-test contamination. */ export function resetSeekDispatchState(): void { _lastDispatchedTime = -1; - _pendingCompletions = new Set(); - _videoFrameCompletions = new WeakSet(); + _pendingCompletions = new Map(); _pendingFailure = undefined; _activeCompletionBarriers = 0; } diff --git a/packages/core/src/runtime/frameSources.ts b/packages/core/src/runtime/frameSources.ts index 6b944e9d650..e765e2103b0 100644 --- a/packages/core/src/runtime/frameSources.ts +++ b/packages/core/src/runtime/frameSources.ts @@ -111,7 +111,7 @@ export function createFrameSourceAdapter(timing: { if (!owned.has(source)) { owned.add(source); // Readiness trackers settle rejected setup; capture must also receive that failure. - registerSeekCompletion(source.ready); + registerSeekCompletion(source.ready, "frame-source"); } connected.push([element, source]); } @@ -140,7 +140,7 @@ export function createFrameSourceAdapter(timing: { const localTime = Math.max(0, time - start); const sourceTime = readMediaStart(element) + sourceTimeAt(readElementRateSpec(element), localTime); - registerSeekCompletion(source.seek(sourceTime)); + registerSeekCompletion(source.seek(sourceTime), "frame-source"); } }, getReadyPromise: () => { diff --git a/packages/core/src/runtime/init.seekOrder.test.ts b/packages/core/src/runtime/init.seekOrder.test.ts index 6a30f1d43e1..e4e7eb839ac 100644 --- a/packages/core/src/runtime/init.seekOrder.test.ts +++ b/packages/core/src/runtime/init.seekOrder.test.ts @@ -52,7 +52,7 @@ function mountTimingFilm() { initSandboxRuntimeModular(); step(40); let land = () => {}; - registerSeekCompletion(new Promise((resolve) => (land = resolve))); + registerSeekCompletion(new Promise((resolve) => (land = resolve)), "hf-seek"); posts.length = 0; const editTiming = async () => { document.getElementById("clip")!.setAttribute("data-duration", "12"); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 6a0d8ddcbdf..c6fda75b8ce 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -4520,7 +4520,11 @@ export function initSandboxRuntimeModular(): void { ({ name, start, end }) => start <= time && time <= end && parked(name), ); if (windowWork) { - if (landed) registerSeekCompletion(windowWork.then(() => windowWork)); + if (landed) + registerSeekCompletion( + windowWork.then(() => windowWork), + "scene-window", + ); return landed !== undefined; } const near = scenesWanted(windowScenes, time, clock.isPlaying()); @@ -4555,7 +4559,7 @@ export function initSandboxRuntimeModular(): void { moveSceneWindow(state.currentTime); }); // A seek is not done until the scene it lands in is mounted. - if (landed) registerSeekCompletion(windowWork); + if (landed) registerSeekCompletion(windowWork, "scene-window"); return landed !== undefined; }; // Mounts the named scenes and keeps them mounted until the next call names others. @@ -4650,7 +4654,7 @@ export function initSandboxRuntimeModular(): void { colorGrading.redraw(); paintVfx(state.currentTime); const blurred = paintCaptureMotionBlur(state.currentTime); - if (blurred) registerSeekCompletion(blurred); + if (blurred) registerSeekCompletion(blurred, "motion-blur"); postState(true); }; @@ -4739,7 +4743,7 @@ export function initSandboxRuntimeModular(): void { window.clearTimeout(capTimer); if (heldSeek === held) flushHeldSeek(); }); - registerSeekCompletion(landed); + registerSeekCompletion(landed, "image-decode"); return landed; }, renderSeek: (timeSeconds, options) => { @@ -4765,7 +4769,7 @@ export function initSandboxRuntimeModular(): void { colorGrading.redraw(); paintVfx(state.currentTime, { engineMode: true }); const blurred = paintCaptureMotionBlur(state.currentTime, { engineMode: true }); - if (blurred) registerSeekCompletion(blurred); + if (blurred) registerSeekCompletion(blurred, "motion-blur"); postState(true); }, getTime: () => heldSeek?.time ?? clock.now(), diff --git a/packages/core/src/runtime/vfx.ts b/packages/core/src/runtime/vfx.ts index 6f65a27e0d5..e00b97a2679 100644 --- a/packages/core/src/runtime/vfx.ts +++ b/packages/core/src/runtime/vfx.ts @@ -1412,5 +1412,8 @@ export function paintVfx(t: number, options?: { engineMode?: boolean }): void { // `__hf_page_composite_resolve` — so a `self` chain painted nothing there, in // silence. The runtime has to be able to finish its own frame. if (options?.engineMode) armPageComposite(); - registerSeekCompletion(capturePreviewThenPaint(capturing, t, seq, options?.engineMode === true)); + registerSeekCompletion( + capturePreviewThenPaint(capturing, t, seq, options?.engineMode === true), + "vfx", + ); } diff --git a/packages/core/src/runtime/window.d.ts b/packages/core/src/runtime/window.d.ts index f0ef7703906..0730d550fa8 100644 --- a/packages/core/src/runtime/window.d.ts +++ b/packages/core/src/runtime/window.d.ts @@ -136,7 +136,10 @@ declare global { * Await GPU work registered synchronously by `hf-seek` listeners through * `event.detail.waitUntil(...)`; `videoFrames: false` skips the video-frame holds. */ - __hfWaitForSeekCompletion?: (options?: { videoFrames?: boolean }) => Promise; + __hfWaitForSeekCompletion?: (options?: { + videoFrames?: boolean; + timeoutMs?: number; + }) => Promise; /** * Canonical root-timeline start for a media element. Snapshot capture uses * this runtime-owned resolver so reference expressions, authored timing