Skip to content

Commit 48aaa2b

Browse files
vanceingallsclaude
andcommitted
fix(vfx): join the preview capture to the seek-completion barrier, not race it
`paintVfx` fired `capturePreviewThenPaint` and forgot it, so nothing the CLI awaits waited for a `self` chain's paint. Every snapshot-family command (`snapshot`, `check`, `compare`, `validate`, `layout`) seeks through `seekCompositionTimeline`, which drains `window.__hfWaitForSeekCompletion` and then allows one settle race before screenshotting — and only the `"hf-seek"` event's `waitUntil()` could put anything in that barrier. The screenshot therefore landed on however many hosts happened to have finished, which varied with host count and machine speed. Extracts the "track, untrack on settle, retain the first rejection" block `dispatch()` built inline into an exported `registerSeekCompletion`, and has `paintVfx` register the preview capture through it. Also awaits the per-host paints with `Promise.all` instead of sequentially: N hosts cost about two animation frames now, not 2N. Measured on the pre-fix bundle with two `self` hosts, both output canvases read `[0,0,0,0]` at the moment the barrier returned; they painted only because the settle race that follows happened to be long enough. Both tests added here fail on that bundle. Co-Authored-By: Claude Opus 5 <[email protected]>
1 parent d243cd9 commit 48aaa2b

4 files changed

Lines changed: 220 additions & 3 deletions

File tree

‎packages/core/src/runtime/adapters/seek-dispatch.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,21 @@ function dispatch(time: number): void {
4949
accepting = false;
5050
}
5151
if (pending.length === 0) return;
52-
const completion = Promise.all(pending)
52+
registerSeekCompletion(Promise.all(pending));
53+
}
54+
55+
/**
56+
* Join `promise` to the seek-completion barrier: `waitForSeekCompletion` will
57+
* not resolve until it settles, and its rejection (the first one) becomes the
58+
* barrier's.
59+
*
60+
* Exported because the `"hf-seek"` event's synchronous `waitUntil()` is not
61+
* the only asynchronous seek work a frame can owe. `runtime/vfx.ts` registers
62+
* its preview-side capture here, so the CLI seek paths that already await this
63+
* barrier wait for a `self` chain's paint instead of racing it.
64+
*/
65+
export function registerSeekCompletion(promise: Promise<unknown>): void {
66+
const completion = promise
5367
.then<SeekCompletionResult>(() => ({ status: "fulfilled" }))
5468
.catch<SeekCompletionResult>((reason: unknown) => ({ status: "rejected", reason }));
5569
_pendingCompletions.add(completion);

‎packages/core/src/runtime/vfx.test.ts‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22
import type { HfVfxCapture } from "../vfx";
3+
import { resetSeekDispatchState, waitForSeekCompletion } from "./adapters/seek-dispatch";
34
import { initVfx, paintVfx } from "./vfx";
45

56
/**
@@ -382,6 +383,9 @@ describe("vfx runtime — self capture", () => {
382383
installCanvasMock(() => gl);
383384
errors = [];
384385
override.capture = "self";
386+
// `paintVfx` now registers its preview capture into the module-level
387+
// seek-completion set; a leftover entry would stall the next test's barrier.
388+
resetSeekDispatchState();
385389
delete compositeWindow().__hf_page_composite_pending;
386390
delete compositeWindow().__hf_page_composite_resolve;
387391
vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
@@ -395,6 +399,8 @@ describe("vfx runtime — self capture", () => {
395399
initVfx(document.body, 30);
396400
delete compositeWindow().__hf_page_composite_pending;
397401
delete compositeWindow().__hf_page_composite_resolve;
402+
resetSeekDispatchState();
403+
vi.unstubAllGlobals();
398404
vi.restoreAllMocks();
399405
});
400406

@@ -528,4 +534,57 @@ describe("vfx runtime — self capture", () => {
528534
expect(ctx.drawn).toHaveLength(1);
529535
expect(compositeWindow().__hf_page_composite_pending).toBeUndefined();
530536
});
537+
538+
/**
539+
* `seekCompositionTimeline` — the seek every `snapshot`/`check`/`compare`/
540+
* `validate`/`layout` run goes through — awaits
541+
* `window.__hfWaitForSeekCompletion` (this function) and then screenshots.
542+
* If the preview capture is not in that set, the screenshot is a race the
543+
* host count decides.
544+
*
545+
* rAF is stubbed to never call back so the ONLY thing that can finish
546+
* `awaitCanvasPaint` is a `paint` event; with the real one the barrier would
547+
* resolve on a timer and the case would prove nothing.
548+
*/
549+
it("holds the seek-completion barrier until every capture host has painted", async () => {
550+
vi.stubGlobal("requestAnimationFrame", () => 1);
551+
const ctxA = createMockCtx2d();
552+
const ctxB = createMockCtx2d();
553+
const srcOf = (host: HTMLElement): HTMLCanvasElement =>
554+
host.querySelector("canvas.hf-vfx-src") as HTMLCanvasElement;
555+
const a = makeCaptureHost(ctxA, "cap-a");
556+
const b = makeCaptureHost(ctxB, "cap-b");
557+
initVfx(document.body, 30);
558+
const flush = (): Promise<void> => new Promise<void>((r) => setTimeout(r, 0));
559+
560+
paintVfx(0.5);
561+
let resolved = false;
562+
const barrier = waitForSeekCompletion().then(() => {
563+
resolved = true;
564+
});
565+
await flush();
566+
const beforeAnyPaint = resolved;
567+
568+
srcOf(a).dispatchEvent(new Event("paint"));
569+
await flush();
570+
// Still held: one host of two is not a finished frame.
571+
const afterFirstPaint = resolved;
572+
573+
srcOf(b).dispatchEvent(new Event("paint"));
574+
await flush();
575+
await barrier;
576+
577+
expect({
578+
beforeAnyPaint,
579+
afterFirstPaint,
580+
resolved,
581+
drawn: [ctxA.drawn.length, ctxB.drawn.length],
582+
}).toEqual({
583+
beforeAnyPaint: false,
584+
afterFirstPaint: false,
585+
resolved: true,
586+
drawn: [1, 1],
587+
});
588+
expect(errors).toEqual([]);
589+
});
531590
});

‎packages/core/src/runtime/vfx.ts‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
type HfVfxParam,
3232
type HfVfxParamValues,
3333
} from "../vfx";
34+
import { registerSeekCompletion } from "./adapters/seek-dispatch";
3435
import { isCanvasElement, isHtmlElement } from "./domRealm";
3536

3637
/** The prefix `frameCapture.ts` matches to fail a render fast. */
@@ -588,7 +589,10 @@ async function capturePreviewThenPaint(
588589
seq: number,
589590
speculative: boolean,
590591
): Promise<void> {
591-
for (const entry of entries) await awaitCanvasPaint(entry.src!.canvas);
592+
// In parallel, not in sequence: each wait costs up to two animation frames,
593+
// so N hosts awaited one after another cost 2N — more slack than the CLI's
594+
// post-barrier settle leaves, and the cost grows with the composition.
595+
await Promise.all(entries.map((entry) => awaitCanvasPaint(entry.src!.canvas)));
592596
// A newer seek, or the engine's own resolve, owns these pixels now.
593597
if (seq !== paintSeq || resolvedSeq === seq) return;
594598
for (const entry of entries) {
@@ -608,6 +612,17 @@ async function capturePreviewThenPaint(
608612
* A chain with no capture paints inline. A capturing chain cannot: its texture
609613
* comes from `drawElementImage`, which needs a paint record that does not exist
610614
* yet at this point in the task.
615+
*
616+
* That deferred capture is REGISTERED INTO THE SHARED SEEK-COMPLETION BARRIER
617+
* (`adapters/seek-dispatch.ts`) rather than left fire-and-forget, so the paths
618+
* that already drain the barrier wait for it instead of racing it:
619+
* `seekCompositionTimeline` awaits `window.__hfWaitForSeekCompletion` before
620+
* its settle race and screenshot, which covers `snapshot`, `check`, `compare`,
621+
* `validate` and `layout`. Racing it is the silent-blank-under-snapshot defect
622+
* this module has already shipped once (dcfbda9fd) — there the capture never
623+
* ran at all, here it ran too late, and both read as an unpainted layer. The
624+
* engine path is unaffected: `frameCapture.ts` drains the same barrier after
625+
* video injection and before its own composite resolve.
611626
*/
612627
export function paintVfx(t: number, options?: { engineMode?: boolean }): void {
613628
lastPaintTime = t;
@@ -628,5 +643,5 @@ export function paintVfx(t: number, options?: { engineMode?: boolean }): void {
628643
// `__hf_page_composite_resolve` — so a `self` chain painted nothing there, in
629644
// silence. The runtime has to be able to finish its own frame.
630645
if (options?.engineMode) armPageComposite();
631-
void capturePreviewThenPaint(capturing, t, seq, options?.engineMode === true);
646+
registerSeekCompletion(capturePreviewThenPaint(capturing, t, seq, options?.engineMode === true));
632647
}

‎packages/producer/src/services/vfxDeterminism.test.ts‎

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ const HOST_W = 160;
2424
const HOST_H = 120;
2525
/** The red block fills the left half of `.hf-vfx-in`. */
2626
const SQUARE_W = 80;
27+
/** Clearance between the two hosts of `twoHostFixture`. */
28+
const GAP = 20;
29+
/** Opaque red — what a correctly painted identity chain puts at the probe point. */
30+
const red = [255, 0, 0, 255];
2731

2832
interface CompositeWindow extends Window {
2933
__hf_page_composite_pending?: boolean;
@@ -209,6 +213,86 @@ async function renderSeekOnly(page: Page, t: number): Promise<void> {
209213
}, t);
210214
}
211215

216+
/**
217+
* TWO `self` hosts, so a per-host sequential wait costs twice as many frames
218+
* as a single-host one. One host can be painted by luck; two cannot.
219+
*/
220+
function twoHostFixture(chain: string): string {
221+
const host = (side: string): string => `
222+
<div id="host-${side}" class="vfx-host clip" data-start="0" data-duration="4"
223+
data-vfx-chain='${chain}'>
224+
<canvas layoutsubtree class="hf-vfx-src"><div class="hf-vfx-in"><div class="square"></div></div></canvas>
225+
<canvas class="hf-vfx-out"></canvas>
226+
</div>`;
227+
return `<!doctype html>
228+
<style>
229+
html, body { margin: 0; background: #0000ff; }
230+
.vfx-host { position: absolute; top: 0; width: ${HOST_W}px; height: ${HOST_H}px; }
231+
#host-a { left: 0; }
232+
#host-b { left: ${HOST_W + GAP}px; }
233+
.vfx-host > canvas { position: absolute; inset: 0; width: ${HOST_W}px; height: ${HOST_H}px; }
234+
.hf-vfx-in { position: absolute; left: 0; top: 0; width: ${HOST_W}px; height: ${HOST_H}px; }
235+
.square {
236+
position: absolute; left: 0; top: 0;
237+
width: ${SQUARE_W}px; height: ${HOST_H}px; background: #ff0000;
238+
}
239+
</style>
240+
<div data-composition-id="root" data-start="0" data-duration="4"
241+
data-width="${HOST_W * 2 + GAP}" data-height="${HOST_H}">${host("a")}${host("b")}
242+
</div>`;
243+
}
244+
245+
/**
246+
* What `seekCompositionTimeline` does, by hand: dispatch the seek, drain the
247+
* seek-completion barrier, then ONE settle race — `setTimeout(100)` against a
248+
* double rAF, copied from the CLI's default `animationFrameSettle: "race"`
249+
* (`packages/cli/src/capture/captureCompositionFrame.ts`). Whatever has not
250+
* painted by the time this returns is what `hyperframes snapshot` screenshots
251+
* as blank.
252+
*/
253+
async function seekAndDrainBarrier(page: Page, t: number): Promise<void> {
254+
await page.evaluate((time: number) => {
255+
(window as CompositeWindow).__player!.renderSeek(time);
256+
}, t);
257+
// Asserted, not optional-chained: a bundle that never exposed the barrier
258+
// would make the whole case a no-op that passes.
259+
const drained = await page.evaluate(async () => {
260+
const wait = Reflect.get(window, "__hfWaitForSeekCompletion");
261+
if (typeof wait !== "function") return false;
262+
await Reflect.apply(wait, window, []);
263+
return true;
264+
});
265+
expect(drained).toBe(true);
266+
}
267+
268+
/**
269+
* The CLI's default `animationFrameSettle: "race"`, copied verbatim from
270+
* `packages/cli/src/capture/captureCompositionFrame.ts`: a 100 ms timeout
271+
* against a double rAF, whichever lands first. This is all the slack a real
272+
* snapshot leaves between the barrier and the screenshot.
273+
*/
274+
async function settleRace(page: Page): Promise<void> {
275+
await page.evaluate(`new Promise(function(r) {
276+
var settled = false;
277+
function finish() { if (settled) return; settled = true; r(); }
278+
window.setTimeout(finish, 100);
279+
requestAnimationFrame(function() { requestAnimationFrame(finish); });
280+
})`);
281+
}
282+
283+
/** RGBA at one interior point of every `.hf-vfx-out` on the page. */
284+
async function sampleEveryOut(page: Page): Promise<number[][]> {
285+
return page.evaluate(() =>
286+
[...document.querySelectorAll("canvas.hf-vfx-out")].map((node) => {
287+
const gl = (node as HTMLCanvasElement).getContext("webgl2");
288+
if (!gl) return [];
289+
const px = new Uint8Array(4);
290+
gl.readPixels(40, 60, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, px);
291+
return [...px];
292+
}),
293+
);
294+
}
295+
212296
/**
213297
* Chain order is nesting order. The exporter puts effects that run BEFORE the
214298
* 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", () => {
475559
}
476560
}, 60_000);
477561

562+
/**
563+
* The same snapshot-family path, but joined rather than raced. Every
564+
* `hyperframes` seek caller (`snapshot`/`check`/`compare`/`validate`/
565+
* `layout`) drains `__hfWaitForSeekCompletion` and then allows exactly one
566+
* settle race before screenshotting. The preview-side capture used to be
567+
* fire-and-forget AND sequential per host, so N hosts cost up to 2N frames
568+
* while the settle bought about two — the screenshot landed on hosts that
569+
* had not painted yet, and which ones varied run to run.
570+
*
571+
* Two hosts, so one host finishing in time cannot hide the defect. No
572+
* `page.screenshot` before the read: that is the engine's phase-2 paint
573+
* force, and taking one would test the engine protocol by the back door.
574+
*/
575+
it("has every self-capture host painted the moment the CLI's seek barrier returns", async () => {
576+
const page = await open(twoHostFixture(waveWarpChain({ height: 0, width: 93.4 })), {
577+
width: HOST_W * 2 + GAP,
578+
height: HOST_H + GAP,
579+
});
580+
try {
581+
expect(await page.evaluate(() => document.querySelectorAll("[data-vfx-chain]").length)).toBe(
582+
2,
583+
);
584+
585+
await seekAndDrainBarrier(page, 0);
586+
587+
// Read immediately — no waitForFunction, no extra rAF. Measured on the
588+
// pre-fix bundle this is `[[0,0,0,0],[0,0,0,0]]`: the barrier returned
589+
// with neither host painted, and only the settle race that follows
590+
// happened to cover them. That slack is not a guarantee — it is two
591+
// frames against a cost that grows with host count — so the contract is
592+
// pinned here, where the barrier's promise is the only thing holding.
593+
const atBarrier = await sampleEveryOut(page);
594+
await settleRace(page);
595+
const atScreenshot = await sampleEveryOut(page);
596+
597+
expect({ atBarrier, atScreenshot, errors: pageErrors.get(page) }).toEqual({
598+
atBarrier: [red, red],
599+
atScreenshot: [red, red],
600+
errors: [],
601+
});
602+
} finally {
603+
await page.close();
604+
}
605+
}, 60_000);
606+
478607
it("captures a .hf-vfx-in that is a sub-composition mount", async () => {
479608
const page = await openMounted({
480609
"main.html": mountFixture(waveWarpChain({ height: 0, width: 93.4 })),

0 commit comments

Comments
 (0)