From dcfbda9fd93787cf5c0c4bcd2b10d47fbde986dc Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 22 Sep 2026 21:08:37 -0700 Subject: [PATCH] fix(vfx): paint self-capture hosts under hyperframes snapshot, not only the engine path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `paintVfx(t, { engineMode: true })` armed the page-composite protocol and returned, betting that every capturing chain runs under `frameCapture.ts`. `hyperframes snapshot` (and check/compare/validate/layout, and Studio's thumbnail capture) seek through the same `renderSeek`, never read `__hf_page_composite_pending` and never call `__hf_page_composite_resolve` — so a `self` chain painted nothing there, in silence, because its content lives in a `layoutsubtree` canvas the browser never paints directly. Engine mode now arms the preview-side capture as well and paints on whichever completes first. A seek token stops a superseded async capture from repainting a newer frame, and a registry-identity check stops it painting an entry `initVfx` already released. `resolveVfxCapture` stays non-idempotent on purpose: the engine resolves only after its own before-capture hooks have mutated the DOM, so its capture is still the authoritative one. Co-Authored-By: Claude Opus 5 --- packages/core/src/runtime/vfx.ts | 63 +++++++++++++++---- .../src/services/vfxDeterminism.test.ts | 47 ++++++++++++++ 2 files changed, 97 insertions(+), 13 deletions(-) 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 })),