mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d243cd92aa
commit
48aaa2bbb7
@@ -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<unknown>): void {
|
||||
const completion = promise
|
||||
.then<SeekCompletionResult>(() => ({ status: "fulfilled" }))
|
||||
.catch<SeekCompletionResult>((reason: unknown) => ({ status: "rejected", reason }));
|
||||
_pendingCompletions.add(completion);
|
||||
|
||||
@@ -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<void> => new Promise<void>((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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
}, 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 => `
|
||||
<div id="host-${side}" class="vfx-host clip" data-start="0" data-duration="4"
|
||||
data-vfx-chain='${chain}'>
|
||||
<canvas layoutsubtree class="hf-vfx-src"><div class="hf-vfx-in"><div class="square"></div></div></canvas>
|
||||
<canvas class="hf-vfx-out"></canvas>
|
||||
</div>`;
|
||||
return `<!doctype html>
|
||||
<style>
|
||||
html, body { margin: 0; background: #0000ff; }
|
||||
.vfx-host { position: absolute; top: 0; width: ${HOST_W}px; height: ${HOST_H}px; }
|
||||
#host-a { left: 0; }
|
||||
#host-b { left: ${HOST_W + GAP}px; }
|
||||
.vfx-host > canvas { position: absolute; inset: 0; width: ${HOST_W}px; height: ${HOST_H}px; }
|
||||
.hf-vfx-in { position: absolute; left: 0; top: 0; width: ${HOST_W}px; height: ${HOST_H}px; }
|
||||
.square {
|
||||
position: absolute; left: 0; top: 0;
|
||||
width: ${SQUARE_W}px; height: ${HOST_H}px; background: #ff0000;
|
||||
}
|
||||
</style>
|
||||
<div data-composition-id="root" data-start="0" data-duration="4"
|
||||
data-width="${HOST_W * 2 + GAP}" data-height="${HOST_H}">${host("a")}${host("b")}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
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<void> {
|
||||
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<number[][]> {
|
||||
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 })),
|
||||
|
||||
Reference in New Issue
Block a user