Skip to content

Commit 8dcc8a8

Browse files
committed
fix(core): a VFX layer hidden or late while empty renders once it has a size
1 parent 0b3ea68 commit 8dcc8a8

5 files changed

Lines changed: 95 additions & 89 deletions

File tree

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

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,12 @@ function clearCompositeSlot(): void {
225225
delete compositeWindow().__hf_page_composite_resolve;
226226
}
227227

228+
/** The chain registers nothing, and its one error matches every pattern. */
229+
function expectRefused(...patterns: RegExp[]): void {
230+
expect(initVfx(document.body, 30)).toHaveLength(0);
231+
for (const pattern of patterns) expect(String(errors[0]![1])).toMatch(pattern);
232+
}
233+
228234
describe("vfx runtime", () => {
229235
beforeEach(() => installVfxHarness(null));
230236
afterEach(releaseVfxHarness);
@@ -279,9 +285,7 @@ describe("vfx runtime", () => {
279285
it("reports an unknown effect type loudly", () => {
280286
makeHost('{"version":1,"nodes":[{"type":"nope","id":"n1","params":{}}]}');
281287

282-
expect(initVfx(document.body, 30)).toHaveLength(0);
283-
expect(String(errors[0]![1])).toMatch(/unknown effect type/);
284-
expect(String(errors[0]![1]).startsWith("vfx: ")).toBe(true);
288+
expectRefused(/unknown effect type/, /^vfx: /);
285289
});
286290

287291
it("reports an unavailable WebGL2 context loudly and registers nothing", () => {
@@ -493,8 +497,7 @@ describe("vfx runtime — self capture", () => {
493497
it("names the Chrome flag when drawElementImage is missing", () => {
494498
makeCaptureHost({ clearRect: () => {} });
495499

496-
expect(initVfx(document.body, 30)).toHaveLength(0);
497-
expect(String(errors[0]![1])).toMatch(/chrome:\/\/flags\/#canvas-draw-element/);
500+
expectRefused(/chrome:\/\/flags\/#canvas-draw-element/);
498501
});
499502

500503
it("arms the page-composite protocol instead of painting inline in engine mode", () => {
@@ -943,24 +946,19 @@ describe("vfx runtime — ref (second source) params", () => {
943946
makeRefTarget(undefined, "matte-1", false);
944947
makeCaptureHost(createMockCtx2d(), "cap", REF_NODE);
945948

946-
expect(initVfx(document.body, 30)).toHaveLength(0);
947-
expect(String(errors[0]![1])).toMatch(/"matte" source/);
948-
expect(String(errors[0]![1])).toMatch(/hf-vfx-src/);
949+
expectRefused(/"matte" source/, /hf-vfx-src/);
949950
});
950951

951952
it("refuses a ref naming an element that is not in the composition", () => {
952953
makeCaptureHost(createMockCtx2d(), "cap", REF_NODE);
953954

954-
expect(initVfx(document.body, 30)).toHaveLength(0);
955-
expect(String(errors[0]![1])).toMatch(/#matte-1/);
956-
expect(String(errors[0]![1])).toMatch(/not in the composition/);
955+
expectRefused(/#matte-1/, /not in the composition/);
957956
});
958957

959958
it("refuses a node that names no ref element at all", () => {
960959
makeCaptureHost(createMockCtx2d(), "cap", ONE_NODE);
961960

962-
expect(initVfx(document.body, 30)).toHaveLength(0);
963-
expect(String(errors[0]![1])).toMatch(/needs a "matte" param/);
961+
expectRefused(/needs a "matte" param/);
964962
});
965963

966964
it("captures a hidden matte as empty instead of failing the frame", async () => {

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

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,8 @@ interface VfxCaptureSource {
117117
visible: boolean;
118118
/** `.hf-vfx-in` measured 0×0 and that has already been reported once. */
119119
emptyBoxReported: boolean;
120-
/** The last capture found `.hf-vfx-in` 0×0; Chrome draws nothing of it until it is re-inserted (measured). */
121-
capturedEmpty: boolean;
120+
/** `.hf-vfx-in` measured 0×0 at the last seek, whether or not anything captured it. */
121+
laidOutEmpty: boolean;
122122
}
123123

124124
/**
@@ -518,7 +518,7 @@ function resolveCaptureSource(
518518
// Only a `ref` source may be visible; `resolveRefSource` sets it.
519519
visible: false,
520520
emptyBoxReported: false,
521-
capturedEmpty: false,
521+
laidOutEmpty: deviceSize(inner) === null,
522522
};
523523
}
524524

@@ -981,7 +981,6 @@ function captureSource(
981981
`size — the wrapper must state an explicit width and height in px.`,
982982
);
983983
}
984-
src.capturedEmpty = true;
985984
return captureEmpty(entry, src, size, mode);
986985
}
987986
resizeCaptureCanvas(src, size);
@@ -1320,6 +1319,15 @@ async function capturePreviewThenPaint(
13201319
await Promise.all(entries.map((entry) => capturePaintedHost(entry, t, seq, speculative)));
13211320
}
13221321

1322+
/** A `.hf-vfx-in` laid out at 0×0 and then sized stays blank in its capture until it is re-inserted (measured). */
1323+
function reinsertRegrownSources(): void {
1324+
for (const src of registry.flatMap(entrySources)) {
1325+
const empty = deviceSize(src.inner) === null;
1326+
if (src.laidOutEmpty && !empty) src.canvas.insertBefore(src.inner, src.inner.nextSibling);
1327+
src.laidOutEmpty = empty;
1328+
}
1329+
}
1330+
13231331
/**
13241332
* Repaint every registered chain for composition-local time `t`. Called from
13251333
* the runtime transport's `seek` (preview) and `renderSeek` (engine) — the two
@@ -1353,6 +1361,7 @@ async function capturePreviewThenPaint(
13531361
export function paintVfx(t: number, options?: { engineMode?: boolean }): void {
13541362
lastPaintTime = t;
13551363
const seq = ++paintSeq;
1364+
reinsertRegrownSources();
13561365
const capturing: VfxEntry[] = [];
13571366
for (const entry of registry) {
13581367
// Reported once, at the moment of loss; repeating it per frame is spam.
@@ -1374,11 +1383,6 @@ export function paintVfx(t: number, options?: { engineMode?: boolean }): void {
13741383
else paintEntry(entry, t);
13751384
}
13761385
if (capturing.length === 0) return;
1377-
for (const src of capturing.flatMap(entrySources)) {
1378-
if (!src.capturedEmpty || deviceSize(src.inner) === null) continue;
1379-
src.capturedEmpty = false;
1380-
src.canvas.insertBefore(src.inner, src.inner.nextSibling);
1381-
}
13821386
// Engine mode arms the page-composite protocol AND the preview-side capture,
13831387
// then paints on whichever completes first. Arming alone was a bet that every
13841388
// capture host runs under `frameCapture.ts`, and it does not: `hyperframes

‎packages/engine/src/services/frameCapture.test.ts‎

Lines changed: 17 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -163,27 +163,28 @@ describe("classifyPageError", () => {
163163
});
164164

165165
// initializeSession registers its listeners before the incomplete fake session makes it throw.
166-
it("records the page's uncaught errors from scripts it loaded from the server", async () => {
166+
async function listenedSession() {
167167
const runtimeListeners = new Map<string, (event: unknown) => void>();
168168
const pageListeners = new Map<string, (event: unknown) => void>();
169-
const client = {
170-
on: (event: string, listener: (event: unknown) => void) =>
171-
runtimeListeners.set(event, listener),
172-
send: async () => ({}),
173-
};
174-
const page = {
175-
on: (event: string, listener: (event: unknown) => void) => pageListeners.set(event, listener),
176-
createCDPSession: async () => client,
177-
};
169+
const listen =
170+
(listeners: typeof pageListeners) => (event: string, listener: (event: unknown) => void) =>
171+
listeners.set(event, listener);
172+
const client = { on: listen(runtimeListeners), send: async () => ({}) };
173+
const page = { on: listen(pageListeners), createCDPSession: async () => client };
178174
const session = {
179175
page,
180176
serverUrl: server,
181-
scriptLoadFailures: [],
182-
pageErrors: [],
183-
warnings: [],
177+
scriptLoadFailures: [] as string[],
178+
pageErrors: [] as string[],
179+
warnings: [] as { code: string }[],
184180
browserConsoleBuffer: [],
185-
};
186-
await initializeSession(session as unknown as CaptureSession).catch(() => {});
181+
} as unknown as CaptureSession;
182+
await initializeSession(session).catch(() => {});
183+
return { session, pageListeners, runtimeListeners };
184+
}
185+
186+
it("records the page's uncaught errors from scripts it loaded from the server", async () => {
187+
const { session, pageListeners, runtimeListeners } = await listenedSession();
187188
const loaded = (url: string, resourceType: string) => ({
188189
status: () => 200,
189190
url: () => url,
@@ -201,21 +202,7 @@ describe("classifyPageError", () => {
201202
});
202203

203204
it("records the first VFX error to stop the render, and a scene's logged throw as a page error", async () => {
204-
const pageListeners = new Map<string, (event: unknown) => void>();
205-
const client = { on: () => {}, send: async () => ({}) };
206-
const page = {
207-
on: (event: string, listener: (event: unknown) => void) => pageListeners.set(event, listener),
208-
createCDPSession: async () => client,
209-
};
210-
const session = {
211-
page,
212-
serverUrl: server,
213-
scriptLoadFailures: [] as string[],
214-
pageErrors: [] as string[],
215-
warnings: [] as { code: string }[],
216-
browserConsoleBuffer: [],
217-
} as unknown as CaptureSession;
218-
await initializeSession(session).catch(() => {});
205+
const { session, pageListeners } = await listenedSession();
219206
const logged = (detail: string) =>
220207
pageListeners.get("console")?.({
221208
type: () => "error",

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,8 +153,8 @@ describe("a failed VFX chain", () => {
153153
expect(await timelineWarningCodes(files)).toEqual([]);
154154
}, 30_000);
155155

156-
// A layer animated in from zero width is empty on its first frame, which is correct; later frames paint.
157-
it("keeps one frame's empty capture a page error, not a stop", async () => {
156+
// A wrapper with no size reports one empty capture per source: a page error, not a stop.
157+
it("keeps a wrapper with no size a page error, not a stop", async () => {
158158
const files = { "index.html": vfxComposition(vfxNode("wave-warp", warp), "width:0") };
159159
const errors = await sessionErrors(files, "vfx-frame:", 2);
160160
expect(errors.codes).toEqual([]);

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

Lines changed: 52 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -686,41 +686,58 @@ describe("data-vfx-chain in the browser", () => {
686686
}
687687
}, 60_000);
688688

689-
// Chrome draws nothing of a child it first painted at 0×0 until it is re-inserted (measured in 152).
690-
it("paints a layer that grows from 0×0 on the next frame, not empty for the rest of the shot", async () => {
691-
const page = await open(fixture(waveWarpChain({ height: 0, width: 93.4 }), "width:0px"));
692-
try {
693-
expect(await seekAndResolve(page, 0)).toBe(true);
694-
expect((await sample(page, [60])).left).toEqual([0, 0, 0, 0]);
695-
expect(pageErrors.get(page)).toEqual([expect.stringContaining("vfx-frame:")]);
696-
697-
pageErrors.get(page)!.length = 0;
698-
await page.evaluate(() => {
699-
(document.querySelector(".hf-vfx-in") as HTMLElement).style.width = "";
700-
});
701-
expect(await seekAndResolve(page, 1 / 30)).toBe(true);
702-
expect((await sample(page, [60])).left).toEqual([255, 0, 0, 255]);
703-
} finally {
704-
await page.close();
705-
}
706-
}, 60_000);
707-
708-
it("paints the layer empty, not the previous frame again, once it shrinks to 0×0", async () => {
709-
const page = await open(fixture(waveWarpChain({ height: 0, width: 93.4 })));
710-
try {
711-
expect(await seekAndResolve(page, 0)).toBe(true);
712-
expect((await sample(page, [60])).left).toEqual([255, 0, 0, 255]);
713-
714-
await page.evaluate(() => {
715-
(document.querySelector(".hf-vfx-in") as HTMLElement).style.width = "0px";
716-
});
717-
expect(await seekAndResolve(page, 1 / 30)).toBe(true);
718-
expect((await sample(page, [60])).left).toEqual([0, 0, 0, 0]);
719-
expect(pageErrors.get(page)).toEqual([expect.stringContaining("vfx-frame:")]);
720-
} finally {
721-
await page.close();
722-
}
723-
}, 60_000);
689+
const RED = [255, 0, 0, 255];
690+
const CLEAR = [0, 0, 0, 0];
691+
// The wrapper's style before and after; how many one-frame reports the shot makes.
692+
it.each([
693+
[
694+
"grows from 0×0: it paints on the next frame, not empty for the rest",
695+
"width:0px",
696+
"",
697+
CLEAR,
698+
RED,
699+
1,
700+
],
701+
[
702+
"shrinks to 0×0: it paints empty, not the previous frame again",
703+
"",
704+
"width:0px",
705+
RED,
706+
CLEAR,
707+
1,
708+
],
709+
[
710+
"is hidden at 0×0, then shown and sized: it paints on that frame",
711+
"width:0px;visibility:hidden",
712+
"",
713+
CLEAR,
714+
RED,
715+
0,
716+
],
717+
])(
718+
"a layer that %s",
719+
async (_case, before, after, firstPixel, nextPixel, reports) => {
720+
const page = await open(fixture(waveWarpChain({ height: 0, width: 93.4 }), before));
721+
const restyle = (css: string) =>
722+
page.evaluate((style: string) => {
723+
(document.querySelector(".hf-vfx-in") as HTMLElement).style.cssText = style;
724+
}, css);
725+
try {
726+
expect(await seekAndResolve(page, 0)).toBe(true);
727+
expect((await sample(page, [60])).left).toEqual(firstPixel);
728+
const reported = [...pageErrors.get(page)!];
729+
pageErrors.get(page)!.length = 0;
730+
await restyle(after);
731+
expect(await seekAndResolve(page, 1 / 30)).toBe(true);
732+
expect((await sample(page, [60])).left).toEqual(nextPixel);
733+
const frameReports = [...reported, ...pageErrors.get(page)!];
734+
expect(frameReports).toEqual(Array(reports).fill(expect.stringContaining("vfx-frame:")));
735+
} finally {
736+
await page.close();
737+
}
738+
},
739+
60_000,
740+
);
724741

725742
/**
726743
* `hyperframes snapshot` (and `check`/`compare`/`validate`/`layout`, and

0 commit comments

Comments
 (0)