diff --git a/packages/core/src/runtime/adapters/seek-dispatch.ts b/packages/core/src/runtime/adapters/seek-dispatch.ts index 391e99f8d9..011e7cdfc9 100644 --- a/packages/core/src/runtime/adapters/seek-dispatch.ts +++ b/packages/core/src/runtime/adapters/seek-dispatch.ts @@ -49,7 +49,21 @@ function dispatch(time: number): void { accepting = false; } if (pending.length === 0) return; - const completion = Promise.all(pending) + registerSeekCompletion(Promise.all(pending)); +} + +/** + * Join `promise` to the seek-completion barrier: `waitForSeekCompletion` will + * not resolve until it settles, and its rejection (the first one) becomes the + * barrier's. + * + * Exported because the `"hf-seek"` event's synchronous `waitUntil()` is not + * the only asynchronous seek work a frame can owe. `runtime/vfx.ts` registers + * 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): void { + const completion = promise .then(() => ({ status: "fulfilled" })) .catch((reason: unknown) => ({ status: "rejected", reason })); _pendingCompletions.add(completion); diff --git a/packages/core/src/runtime/vfx.test.ts b/packages/core/src/runtime/vfx.test.ts index aaddd44a97..bcc2a22775 100644 --- a/packages/core/src/runtime/vfx.test.ts +++ b/packages/core/src/runtime/vfx.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { HfVfxCapture } from "../vfx"; +import { resetSeekDispatchState, waitForSeekCompletion } from "./adapters/seek-dispatch"; import { initVfx, paintVfx } from "./vfx"; /** @@ -382,6 +383,9 @@ describe("vfx runtime — self capture", () => { installCanvasMock(() => gl); errors = []; override.capture = "self"; + // `paintVfx` now registers its preview capture into the module-level + // seek-completion set; a leftover entry would stall the next test's barrier. + resetSeekDispatchState(); delete compositeWindow().__hf_page_composite_pending; delete compositeWindow().__hf_page_composite_resolve; vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => { @@ -395,6 +399,8 @@ describe("vfx runtime — self capture", () => { initVfx(document.body, 30); delete compositeWindow().__hf_page_composite_pending; delete compositeWindow().__hf_page_composite_resolve; + resetSeekDispatchState(); + vi.unstubAllGlobals(); vi.restoreAllMocks(); }); @@ -528,4 +534,57 @@ describe("vfx runtime — self capture", () => { expect(ctx.drawn).toHaveLength(1); expect(compositeWindow().__hf_page_composite_pending).toBeUndefined(); }); + + /** + * `seekCompositionTimeline` — the seek every `snapshot`/`check`/`compare`/ + * `validate`/`layout` run goes through — awaits + * `window.__hfWaitForSeekCompletion` (this function) and then screenshots. + * If the preview capture is not in that set, the screenshot is a race the + * host count decides. + * + * rAF is stubbed to never call back so the ONLY thing that can finish + * `awaitCanvasPaint` is a `paint` event; with the real one the barrier would + * resolve on a timer and the case would prove nothing. + */ + it("holds the seek-completion barrier until every capture host has painted", async () => { + vi.stubGlobal("requestAnimationFrame", () => 1); + const ctxA = createMockCtx2d(); + const ctxB = createMockCtx2d(); + const srcOf = (host: HTMLElement): HTMLCanvasElement => + host.querySelector("canvas.hf-vfx-src") as HTMLCanvasElement; + const a = makeCaptureHost(ctxA, "cap-a"); + const b = makeCaptureHost(ctxB, "cap-b"); + initVfx(document.body, 30); + const flush = (): Promise => new Promise((r) => setTimeout(r, 0)); + + paintVfx(0.5); + let resolved = false; + const barrier = waitForSeekCompletion().then(() => { + resolved = true; + }); + await flush(); + const beforeAnyPaint = resolved; + + srcOf(a).dispatchEvent(new Event("paint")); + await flush(); + // Still held: one host of two is not a finished frame. + const afterFirstPaint = resolved; + + srcOf(b).dispatchEvent(new Event("paint")); + await flush(); + await barrier; + + expect({ + beforeAnyPaint, + afterFirstPaint, + resolved, + drawn: [ctxA.drawn.length, ctxB.drawn.length], + }).toEqual({ + beforeAnyPaint: false, + afterFirstPaint: false, + resolved: true, + drawn: [1, 1], + }); + expect(errors).toEqual([]); + }); }); diff --git a/packages/core/src/runtime/vfx.ts b/packages/core/src/runtime/vfx.ts index a453da20a3..9361678728 100644 --- a/packages/core/src/runtime/vfx.ts +++ b/packages/core/src/runtime/vfx.ts @@ -31,6 +31,7 @@ import { type HfVfxParam, type HfVfxParamValues, } from "../vfx"; +import { registerSeekCompletion } from "./adapters/seek-dispatch"; import { isCanvasElement, isHtmlElement } from "./domRealm"; /** The prefix `frameCapture.ts` matches to fail a render fast. */ @@ -588,7 +589,10 @@ async function capturePreviewThenPaint( seq: number, speculative: boolean, ): Promise { - for (const entry of entries) await awaitCanvasPaint(entry.src!.canvas); + // In parallel, not in sequence: each wait costs up to two animation frames, + // so N hosts awaited one after another cost 2N — more slack than the CLI's + // post-barrier settle leaves, and the cost grows with the composition. + await Promise.all(entries.map((entry) => awaitCanvasPaint(entry.src!.canvas))); // A newer seek, or the engine's own resolve, owns these pixels now. if (seq !== paintSeq || resolvedSeq === seq) return; for (const entry of entries) { @@ -608,6 +612,17 @@ async function capturePreviewThenPaint( * A chain with no capture paints inline. A capturing chain cannot: its texture * comes from `drawElementImage`, which needs a paint record that does not exist * yet at this point in the task. + * + * That deferred capture is REGISTERED INTO THE SHARED SEEK-COMPLETION BARRIER + * (`adapters/seek-dispatch.ts`) rather than left fire-and-forget, so the paths + * that already drain the barrier wait for it instead of racing it: + * `seekCompositionTimeline` awaits `window.__hfWaitForSeekCompletion` before + * its settle race and screenshot, which covers `snapshot`, `check`, `compare`, + * `validate` and `layout`. Racing it is the silent-blank-under-snapshot defect + * this module has already shipped once (dcfbda9fd) — there the capture never + * ran at all, here it ran too late, and both read as an unpainted layer. The + * engine path is unaffected: `frameCapture.ts` drains the same barrier after + * video injection and before its own composite resolve. */ export function paintVfx(t: number, options?: { engineMode?: boolean }): void { lastPaintTime = t; @@ -628,5 +643,5 @@ 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(); - void capturePreviewThenPaint(capturing, t, seq, options?.engineMode === true); + registerSeekCompletion(capturePreviewThenPaint(capturing, t, seq, options?.engineMode === true)); } diff --git a/packages/producer/src/services/vfxDeterminism.test.ts b/packages/producer/src/services/vfxDeterminism.test.ts index e85f6915a8..4cfd7cd3e2 100644 --- a/packages/producer/src/services/vfxDeterminism.test.ts +++ b/packages/producer/src/services/vfxDeterminism.test.ts @@ -24,6 +24,10 @@ const HOST_W = 160; const HOST_H = 120; /** The red block fills the left half of `.hf-vfx-in`. */ const SQUARE_W = 80; +/** Clearance between the two hosts of `twoHostFixture`. */ +const GAP = 20; +/** Opaque red — what a correctly painted identity chain puts at the probe point. */ +const red = [255, 0, 0, 255]; interface CompositeWindow extends Window { __hf_page_composite_pending?: boolean; @@ -209,6 +213,86 @@ async function renderSeekOnly(page: Page, t: number): Promise { }, t); } +/** + * TWO `self` hosts, so a per-host sequential wait costs twice as many frames + * as a single-host one. One host can be painted by luck; two cannot. + */ +function twoHostFixture(chain: string): string { + const host = (side: string): string => ` +
+
+ +
`; + return ` + +
${host("a")}${host("b")} +
`; +} + +/** + * What `seekCompositionTimeline` does, by hand: dispatch the seek, drain the + * seek-completion barrier, then ONE settle race — `setTimeout(100)` against a + * double rAF, copied from the CLI's default `animationFrameSettle: "race"` + * (`packages/cli/src/capture/captureCompositionFrame.ts`). Whatever has not + * painted by the time this returns is what `hyperframes snapshot` screenshots + * as blank. + */ +async function seekAndDrainBarrier(page: Page, t: number): Promise { + await page.evaluate((time: number) => { + (window as CompositeWindow).__player!.renderSeek(time); + }, t); + // Asserted, not optional-chained: a bundle that never exposed the barrier + // would make the whole case a no-op that passes. + const drained = await page.evaluate(async () => { + const wait = Reflect.get(window, "__hfWaitForSeekCompletion"); + if (typeof wait !== "function") return false; + await Reflect.apply(wait, window, []); + return true; + }); + expect(drained).toBe(true); +} + +/** + * The CLI's default `animationFrameSettle: "race"`, copied verbatim from + * `packages/cli/src/capture/captureCompositionFrame.ts`: a 100 ms timeout + * against a double rAF, whichever lands first. This is all the slack a real + * snapshot leaves between the barrier and the screenshot. + */ +async function settleRace(page: Page): Promise { + await page.evaluate(`new Promise(function(r) { + var settled = false; + function finish() { if (settled) return; settled = true; r(); } + window.setTimeout(finish, 100); + requestAnimationFrame(function() { requestAnimationFrame(finish); }); + })`); +} + +/** RGBA at one interior point of every `.hf-vfx-out` on the page. */ +async function sampleEveryOut(page: Page): Promise { + return page.evaluate(() => + [...document.querySelectorAll("canvas.hf-vfx-out")].map((node) => { + const gl = (node as HTMLCanvasElement).getContext("webgl2"); + if (!gl) return []; + const px = new Uint8Array(4); + gl.readPixels(40, 60, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, px); + return [...px]; + }), + ); +} + /** * Chain order is nesting order. The exporter puts effects that run BEFORE the * chain node on `.hf-vfx-in` (inside the capture) and effects that run AFTER it @@ -475,6 +559,51 @@ describe("data-vfx-chain in the browser", () => { } }, 60_000); + /** + * The same snapshot-family path, but joined rather than raced. Every + * `hyperframes` seek caller (`snapshot`/`check`/`compare`/`validate`/ + * `layout`) drains `__hfWaitForSeekCompletion` and then allows exactly one + * settle race before screenshotting. The preview-side capture used to be + * fire-and-forget AND sequential per host, so N hosts cost up to 2N frames + * while the settle bought about two — the screenshot landed on hosts that + * had not painted yet, and which ones varied run to run. + * + * Two hosts, so one host finishing in time cannot hide the defect. No + * `page.screenshot` before the read: that is the engine's phase-2 paint + * force, and taking one would test the engine protocol by the back door. + */ + it("has every self-capture host painted the moment the CLI's seek barrier returns", async () => { + const page = await open(twoHostFixture(waveWarpChain({ height: 0, width: 93.4 })), { + width: HOST_W * 2 + GAP, + height: HOST_H + GAP, + }); + try { + expect(await page.evaluate(() => document.querySelectorAll("[data-vfx-chain]").length)).toBe( + 2, + ); + + await seekAndDrainBarrier(page, 0); + + // Read immediately — no waitForFunction, no extra rAF. Measured on the + // pre-fix bundle this is `[[0,0,0,0],[0,0,0,0]]`: the barrier returned + // with neither host painted, and only the settle race that follows + // happened to cover them. That slack is not a guarantee — it is two + // frames against a cost that grows with host count — so the contract is + // pinned here, where the barrier's promise is the only thing holding. + const atBarrier = await sampleEveryOut(page); + await settleRace(page); + const atScreenshot = await sampleEveryOut(page); + + expect({ atBarrier, atScreenshot, errors: pageErrors.get(page) }).toEqual({ + atBarrier: [red, red], + atScreenshot: [red, red], + errors: [], + }); + } finally { + await page.close(); + } + }, 60_000); + it("captures a .hf-vfx-in that is a sub-composition mount", async () => { const page = await openMounted({ "main.html": mountFixture(waveWarpChain({ height: 0, width: 93.4 })),