mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-03 04:07:03 +00:00
fix(vfx): paint self-capture hosts under hyperframes snapshot, not only the engine path
`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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
69a7545424
commit
dcfbda9fd9
@@ -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 <img>,
|
||||
// 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<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function capturePreviewThenPaint(entries: VfxEntry[], t: number): Promise<void> {
|
||||
async function capturePreviewThenPaint(
|
||||
entries: VfxEntry[],
|
||||
t: number,
|
||||
seq: number,
|
||||
speculative: boolean,
|
||||
): Promise<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
Reference in New Issue
Block a user