diff --git a/packages/core/src/runtime/vfx.ts b/packages/core/src/runtime/vfx.ts
index 82ea436db0..37341374c9 100644
--- a/packages/core/src/runtime/vfx.ts
+++ b/packages/core/src/runtime/vfx.ts
@@ -117,8 +117,15 @@ export type VfxRegistry = VfxEntry[];
let registry: VfxRegistry = [];
let registryFps = 30;
-/** The time the last `paintVfx` was given; engine mode paints later, on resolve. */
+/** The time the last `paintVfx` was given; a capturing chain paints later. */
let lastPaintTime = 0;
+/**
+ * Monotonic seek token. Every `paintVfx` claims one; an async capture that
+ * comes back to find a newer token has been superseded and must not repaint.
+ */
+let paintSeq = 0;
+/** The token the page-composite resolver last completed, so a race can yield. */
+let resolvedSeq = -1;
/** The resolver we installed, and whoever owned the slot when we wrapped it. */
let vfxResolver: (() => boolean) | null = null;
let priorResolver: (() => boolean) | null = null;
@@ -460,7 +467,7 @@ function uploadCaptureTexture(gl: WebGL2RenderingContext, src: VfxCaptureSource)
* even though its children are not — leaving the captured frame in it would
* show the unprocessed layer through every transparent pixel of `.hf-vfx-out`.
*/
-function captureEntry(entry: VfxEntry): boolean {
+function captureEntry(entry: VfxEntry, quiet = false): boolean {
const src = entry.src;
const size = deviceSize(entry.host);
if (!src || !size) return false;
@@ -470,10 +477,17 @@ function captureEntry(entry: VfxEntry): boolean {
try {
src.ctx.drawElementImage(src.inner, 0, 0, size.width, size.height);
} catch (err) {
- reportVfxError(
- `${describeHost(entry.host)}: drawElementImage failed: ${(err as Error).message} ` +
- `In Studio, enable chrome://flags/#canvas-draw-element.`,
- );
+ // A speculative capture is quiet on purpose: it is the engine path's SECOND
+ // attempt at the same frame, racing the host's own `resolve`, and "no cached
+ // paint record yet" there is expected, recoverable, and must not fail a
+ // render. The authoritative attempt (preview paint, or `resolveVfxCapture`)
+ // still owns the loud error contract.
+ if (!quiet) {
+ reportVfxError(
+ `${describeHost(entry.host)}: drawElementImage failed: ${(err as Error).message} ` +
+ `In Studio, enable chrome://flags/#canvas-draw-element.`,
+ );
+ }
return false;
}
uploadCaptureTexture(entry.gl, src);
@@ -490,6 +504,12 @@ function resolveVfxCapture(): boolean {
paintEntry(entry, lastPaintTime);
painted = true;
}
+ // Deliberately NOT idempotent: the engine resolves only after its own
+ // before-capture hooks have mutated the DOM (video frames injected as
,
+ // scenes cloned), so its capture is the authoritative one even when a
+ // fallback already painted this frame from the pre-hook DOM. Recording the
+ // token is what lets that earlier fallback stand down, not the reverse.
+ resolvedSeq = paintSeq;
(window as CompositeWindow).__hf_page_composite_pending = false;
return painted;
}
@@ -543,10 +563,21 @@ function awaitCanvasPaint(canvas: HTMLCanvasElement): Promise {
});
}
-async function capturePreviewThenPaint(entries: VfxEntry[], t: number): Promise {
+async function capturePreviewThenPaint(
+ entries: VfxEntry[],
+ t: number,
+ seq: number,
+ speculative: boolean,
+): Promise {
for (const entry of entries) await 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) {
- if (captureEntry(entry)) paintEntry(entry, t);
+ // `initVfx` re-scans when a sub-composition mounts and releases the
+ // outgoing registry's programs and textures; painting a released entry is
+ // a silent GL error, not a frame.
+ if (!registry.includes(entry)) continue;
+ if (captureEntry(entry, speculative)) paintEntry(entry, t);
}
}
@@ -561,6 +592,7 @@ async function capturePreviewThenPaint(entries: VfxEntry[], t: number): Promise<
*/
export function paintVfx(t: number, options?: { engineMode?: boolean }): void {
lastPaintTime = t;
+ const seq = ++paintSeq;
const capturing: VfxEntry[] = [];
for (const entry of registry) {
if (!isPaintableHost(entry.host)) continue;
@@ -568,9 +600,14 @@ export function paintVfx(t: number, options?: { engineMode?: boolean }): void {
else paintEntry(entry, t);
}
if (capturing.length === 0) return;
- if (options?.engineMode) {
- armPageComposite();
- return;
- }
- void capturePreviewThenPaint(capturing, t);
+ // Engine mode arms the page-composite protocol AND the preview-side capture,
+ // then paints on whichever completes first. Arming alone was a bet that every
+ // capture host runs under `frameCapture.ts`, and it does not: `hyperframes
+ // snapshot` (and `check`/`compare`/`validate`/`layout`, and Studio's
+ // thumbnail capture) seek through the same `seekCompositionTimeline` →
+ // `renderSeek`, never read `__hf_page_composite_pending`, and never call
+ // `__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);
}
diff --git a/packages/producer/src/services/vfxDeterminism.test.ts b/packages/producer/src/services/vfxDeterminism.test.ts
index e299129a84..e85f6915a8 100644
--- a/packages/producer/src/services/vfxDeterminism.test.ts
+++ b/packages/producer/src/services/vfxDeterminism.test.ts
@@ -428,6 +428,53 @@ describe("data-vfx-chain in the browser", () => {
}
}, 60_000);
+ /**
+ * `hyperframes snapshot` (and `check`/`compare`/`validate`/`layout`, and
+ * Studio's thumbnail capture) seek through the same `renderSeek` the engine
+ * does, but never read
+ * `__hf_page_composite_pending` and never call `__hf_page_composite_resolve`
+ * — so arming the protocol alone left every `self` chain unpainted and
+ * silent there. No `page.screenshot` in this case ON PURPOSE: a screenshot
+ * is the engine's phase-2 paint force, and taking one would test the engine
+ * protocol again by the back door instead of the runtime's own fallback.
+ */
+ it("paints a self-capture host when nobody ever resolves the composite", async () => {
+ const page = await open(fixture(waveWarpChain({ height: 0, width: 93.4 })));
+ try {
+ const armed = await page.evaluate(() => {
+ (window as CompositeWindow).__player!.renderSeek(0);
+ return (window as CompositeWindow).__hf_page_composite_pending === true;
+ });
+ expect({ armed, errors: pageErrors.get(page) }).toEqual({ armed: true, errors: [] });
+
+ await page.waitForFunction(
+ () => {
+ const out = document.querySelector("canvas.hf-vfx-out") as HTMLCanvasElement;
+ const gl = out.getContext("webgl2");
+ if (!gl) return false;
+ const px = new Uint8Array(4);
+ gl.readPixels(40, 60, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, px);
+ return px[3] === 255;
+ },
+ { timeout: 5_000 },
+ );
+
+ const s = await sample(page, [10, 60, 90]);
+ expect([s.width, s.height]).toEqual([HOST_W, HOST_H]);
+ expect(s.left).toEqual([255, 0, 0, 255]);
+ expect(s.right).toEqual([0, 0, 0, 0]);
+ expect(s.rows).toEqual([[[0, SQUARE_W]], [[0, SQUARE_W]], [[0, SQUARE_W]]]);
+ // The engine contract is untouched: the flag stays armed, so a host that
+ // DOES run the three-phase protocol still gets its authoritative capture.
+ expect(
+ await page.evaluate(() => (window as CompositeWindow).__hf_page_composite_pending),
+ ).toBe(true);
+ expect(pageErrors.get(page)).toEqual([]);
+ } 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 })),