Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
cda203d
fix(engine): a render fails when a composition script throws before i…
miguel-heygen Oct 4, 2026
ab62a4f
fix(engine): an unrelated page error no longer fails a render whose t…
miguel-heygen Oct 4, 2026
32d4108
fix(engine): only the composition's own scripts fail a render, local …
miguel-heygen Oct 4, 2026
3466ec4
style(engine): format the script failure change
miguel-heygen Oct 4, 2026
7a57e19
fix(engine): a third-party browser rejection no longer fails a render
miguel-heygen Oct 4, 2026
345a7c9
refactor(engine): one page URL for navigation and error attribution
miguel-heygen Oct 5, 2026
bc0e8c9
fix(engine): a rejection after a hash change or in an iframe no longe…
miguel-heygen Oct 5, 2026
3266cd8
fix(engine): a widget's error no longer fails a render once its scrip…
miguel-heygen Oct 5, 2026
e01928b
fix(core): name injected and inlined scripts without building code fr…
miguel-heygen Oct 5, 2026
efc8e69
fix(engine): a third-party embed's eval or inserted script no longer …
miguel-heygen Oct 5, 2026
f3a314b
fix(producer): a widget's inline handler or a sub-composition no long…
miguel-heygen Oct 5, 2026
bf6a948
refactor(engine): read each script response once and keep comments wi…
miguel-heygen Oct 5, 2026
30428de
fix(engine): a scene that throws after registering its timeline no lo…
miguel-heygen Oct 5, 2026
17d414f
fix(engine): a composition script failure names the error and its rea…
miguel-heygen Oct 5, 2026
fb80df7
fix(engine): a failed VFX chain fails the render at once instead of s…
miguel-heygen Oct 5, 2026
d10d7f3
fix(core): a VFX layer that is empty on one frame renders empty inste…
miguel-heygen Oct 5, 2026
0b3ea68
fix(core): a VFX layer that grows from zero size renders instead of s…
miguel-heygen Oct 5, 2026
8dcc8a8
fix(core): a VFX layer hidden or late while empty renders once it has…
miguel-heygen Oct 5, 2026
0ce4f5c
test(producer): cover a VFX layer that is 0×0 under a host that start…
miguel-heygen Oct 5, 2026
49017e0
test(producer): pin both VFX 0×0 marks, with a GSAP-shaped wipe and a…
miguel-heygen Oct 5, 2026
d22b01e
test(producer): a late VFX host with a GSAP wipe catches a walk that …
miguel-heygen Oct 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 12 additions & 9 deletions packages/core/src/compiler/htmlDocument.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,12 +141,15 @@ describe("htmlDocument helpers", () => {
expect(stripEmbeddedRuntimeScripts(html)).toBe(html);
});

const injectedTag = (code: string) =>
`<script>${code}\n//# sourceURL=hyperframes://injected/0</script>`;

it("injects head and body scripts without replacement-token interpolation", () => {
const html = "<html><head></head><body></body></html>";
const injected = injectScriptsIntoHtml(html, ["window.x = '$&';"], ["window.y = '$&';"]);

expect(injected).toContain("<script>window.x = '$&';</script>\n</head>");
expect(injected).toContain("<script>window.y = '$&';</script>\n</body>");
expect(injected).toContain(`${injectedTag("window.x = '$&';")}\n</head>`);
expect(injected).toContain(`${injectedTag("window.y = '$&';")}\n</body>`);
});

it("injects early head scripts before authored head scripts", () => {
Expand Down Expand Up @@ -175,16 +178,16 @@ describe("htmlDocument helpers", () => {
const injected = injectScriptsIntoHtml(html, ["window.h = 1;"], ["window.b = 1;"]);

expect(injected.split(`<script>${vendor}</script>`)).toHaveLength(3);
expect(injected).toContain("<script>window.h = 1;</script>\n</HEAD >");
expect(injected).toContain("<script>window.b = 1;</script>\n</body></html>");
expect(injected).toContain(`${injectedTag("window.h = 1;")}\n</HEAD >`);
expect(injected).toContain(`${injectedTag("window.b = 1;")}\n</body></html>`);
});

it("falls back to the document's own <body> when </head> is omitted", () => {
const vendor = 'w.print("<head>"),w.print("<body>")';
const html = `<html><head><script>${vendor}</script><body><p>x</p></body></html>`;
const injected = injectScriptsIntoHtml(html, ["window.h = 1;"], []);

expect(injected).toContain(`<script>${vendor}</script><script>window.h = 1;</script>\n<body>`);
expect(injected).toContain(`<script>${vendor}</script>${injectedTag("window.h = 1;")}\n<body>`);
});

it("injects at head start past a script that prints <head>, and before <body> without a head", () => {
Expand All @@ -205,16 +208,16 @@ describe("htmlDocument helpers", () => {
it("keeps indexes right after a character that lowercases to two (İ)", () => {
const page = "<html><head><title>İzmir</title></head><body><h1>İstanbul</h1></body></html>";
const injected = injectScriptsIntoHtml(page, ["a=1"], ["b=2"]);
expect(injected).toContain("<script>a=1</script>\n</head>");
expect(injected).toContain("<script>b=2</script>\n</body></html>");
expect(injected).toContain(`${injectedTag("a=1")}\n</head>`);
expect(injected).toContain(`${injectedTag("b=2")}\n</body></html>`);

const stripped = stripEmbeddedRuntimeScripts(
'<p>İİ</p><script src="hyperframe.runtime.iife.js"></script><p>kept</p>',
);
expect(stripped).toBe("<p>İİ</p><p>kept</p>");

const escaped = injectScriptsIntoHtml(page, ['x="İİ</SCRIPT>"'], []);
expect(escaped).toContain('<script>x="İİ<\\/SCRIPT>"</script>');
expect(escaped).toContain(injectedTag('x="İİ<\\/SCRIPT>"'));
});

it("skips a script tag written inside an attribute value", () => {
Expand Down Expand Up @@ -242,7 +245,7 @@ describe("htmlDocument helpers", () => {
it("treats a quote as a value only after =, like the browser", () => {
const page = "<html><head><meta name=it's></head><body><p>don't</p></body></html>";
expect(injectScriptsIntoHtml(page, ["H"], [])).toContain(
"<meta name=it's><script>H</script>\n</head>",
`<meta name=it's>${injectedTag("H")}\n</head>`,
);

for (const meta of [
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/compiler/htmlDocument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,10 @@ function escapeCaseInsensitiveToken(
}

function inlineScriptTags(scripts: readonly string[]): string {
return scripts.map((source) => `<script>${escapeInlineScriptSource(source)}</script>`).join("\n");
return scripts
.map((source, index) => `${source}\n//# sourceURL=hyperframes://injected/${index}`)
.map((source) => `<script>${escapeInlineScriptSource(source)}</script>`)
.join("\n");
}

const RAW_TEXT_TAGS = ["script", "style", "title", "textarea"] as const;
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/compiler/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export {
deferScriptsUntilFonts,
headStyleRuns,
inlineScriptRuns,
isJavaScriptType,
styleElementsFor,
type CompositionStyle,
type InlineScriptRun,
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/compiler/scriptRuns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export const AFTER_FONTS_SCRIPTS = `script[type="${AFTER_FONTS_SCRIPT_TYPE}"], s

export const AFTER_FONTS_CLAIM = "__hfAfterFontsClaimed";
export const INLINED_FILE_ATTR = "data-hf-inlined-src";
export const COMPOSITION_SOURCE_URL = "hyperframes-composition://body";

export const DEFERRED_FILE = `[defer][src], [defer][${INLINED_FILE_ATTR}]`;
const AFTER_FONTS_FALLBACK_ATTR = "data-hf-after-fonts-fallback";
Expand All @@ -96,7 +97,8 @@ const afterFontsFallback = () => `document.addEventListener("DOMContentLoaded",
el.replaceWith(s);
if (!waits) next();
})();
});`;
});
//# sourceURL=hyperframes://after-fonts-fallback`;

/** Gives each body script a type the browser does not run, so the runtime can run it once web fonts are ready. */
export function deferScriptsUntilFonts(
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ export {
type SubCompositionValidityReason,
} from "./compiler/subCompositionValidity";
export { RUNTIME_BOOTSTRAP_ATTR, stripEmbeddedRuntimeScripts } from "./compiler/htmlDocument";
export { COMPOSITION_SOURCE_URL } from "./compiler/scriptRuns";
export { queryByAttr } from "./utils/cssSelector";
export {
AUDIBLE_MEDIA_SELECTOR,
Expand Down
25 changes: 13 additions & 12 deletions packages/core/src/runtime/vfx.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,12 @@ function clearCompositeSlot(): void {
delete compositeWindow().__hf_page_composite_resolve;
}

/** The chain registers nothing, and its one error matches every pattern. */
function expectRefused(...patterns: RegExp[]): void {
expect(initVfx(document.body, 30)).toHaveLength(0);
for (const pattern of patterns) expect(String(errors[0]![1])).toMatch(pattern);
}

describe("vfx runtime", () => {
beforeEach(() => installVfxHarness(null));
afterEach(releaseVfxHarness);
Expand Down Expand Up @@ -279,8 +285,7 @@ describe("vfx runtime", () => {
it("reports an unknown effect type loudly", () => {
makeHost('{"version":1,"nodes":[{"type":"nope","id":"n1","params":{}}]}');

expect(initVfx(document.body, 30)).toHaveLength(0);
expect(String(errors[0]![1])).toMatch(/unknown effect type/);
expectRefused(/unknown effect type/, /^vfx: /);
});

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

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

it("arms the page-composite protocol instead of painting inline in engine mode", () => {
Expand Down Expand Up @@ -722,6 +726,7 @@ describe("vfx runtime — self capture", () => {
expect(errors[0]![0]).toBe(LABEL);
expect(String(errors[0]![1])).toMatch(/#cap-stalled/);
expect(String(errors[0]![1])).toMatch(/no paint arrived within 2000ms/);
expect(String(errors[0]![1]).startsWith("vfx-frame: ")).toBe(true);
expect(String(errors[0]![1])).toMatch(/BeginFrame/);
} finally {
vi.useRealTimers();
Expand Down Expand Up @@ -839,6 +844,7 @@ describe("vfx runtime — backdrop capture", () => {
expect(errors).toHaveLength(1);
expect(String(errors[0]![1])).toMatch(/measures 0×0/);
expect(String(errors[0]![1])).toMatch(/explicit width and height in px/);
expect(String(errors[0]![1]).startsWith("vfx-frame: ")).toBe(true);
});

it("refuses a .hf-vfx-in that is not the capture canvas's immediate child", () => {
Expand Down Expand Up @@ -940,24 +946,19 @@ describe("vfx runtime — ref (second source) params", () => {
makeRefTarget(undefined, "matte-1", false);
makeCaptureHost(createMockCtx2d(), "cap", REF_NODE);

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

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

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

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

expect(initVfx(document.body, 30)).toHaveLength(0);
expect(String(errors[0]![1])).toMatch(/needs a "matte" param/);
expectRefused(/needs a "matte" param/);
});

it("captures a hidden matte as empty instead of failing the frame", async () => {
Expand Down
55 changes: 40 additions & 15 deletions packages/core/src/runtime/vfx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,8 @@
* randomness, no state carried between paints. That is the determinism
* contract the exporter's gate depends on.
*
* Every failure is loud: the `[HyperFrames] composition script error:` prefix
* is what the engine turns into `runtime-error:<compId>` and fails fast on, so
* a broken chain stops a render instead of silently rendering the wrong frame.
* Every failure is loud: the engine records the `[HyperFrames] composition script error:`
* prefix as a composition script error, which fails a render whose timeline wait times out.
*/

import {
Expand Down Expand Up @@ -44,7 +43,7 @@ import { isCanvasElement, isHtmlElement } from "./domRealm";
*/
const VFX_REF_VISIBLE_ATTR = "data-vfx-ref-visible";

/** The prefix `frameCapture.ts` matches to fail a render fast. */
/** The prefix `frameCapture.ts` records as a composition script error. */
const VFX_ERROR_LABEL = "[HyperFrames] composition script error:";

/**
Expand Down Expand Up @@ -118,6 +117,8 @@ interface VfxCaptureSource {
visible: boolean;
/** `.hf-vfx-in` measured 0×0 and that has already been reported once. */
emptyBoxReported: boolean;
/** `.hf-vfx-in` measured 0×0 at registration or the last seek, captured or not. */
laidOutEmpty: boolean;
}

/**
Expand Down Expand Up @@ -213,6 +214,12 @@ function reportVfxError(message: string): void {
console.error(VFX_ERROR_LABEL, `vfx: ${message}`);
}

/** One frame's capture failed; later frames may still paint, so the engine does not stop the render on it. */
function reportVfxFrameError(message: string): void {
// eslint-disable-next-line no-console
console.error(VFX_ERROR_LABEL, `vfx-frame: ${message}`);
}

function compileShader(
gl: WebGL2RenderingContext,
type: number,
Expand Down Expand Up @@ -511,6 +518,7 @@ function resolveCaptureSource(
// Only a `ref` source may be visible; `resolveRefSource` sets it.
visible: false,
emptyBoxReported: false,
laidOutEmpty: deviceSize(inner) === null,
};
}

Expand Down Expand Up @@ -907,6 +915,18 @@ function resizeCaptureCanvas(src: VfxCaptureSource, size: { width: number; heigh
if (src.canvas.height !== size.height) src.canvas.height = size.height;
}

function captureEmpty(
entry: VfxEntry,
src: VfxCaptureSource,
size: { width: number; height: number },
mode: CaptureMode,
): true {
resizeCaptureCanvas(src, size);
src.ctx.clearRect(0, 0, size.width, size.height);
if (mode.upload) uploadCaptureTexture(entry.gl, src);
return true;
}

/**
* Read one source's pixels into its texture. Both `clearRect`s matter: the
* first because `drawElementImage` composites onto whatever is there, the
Expand Down Expand Up @@ -945,28 +965,23 @@ function captureSource(
// first. An empty `u_src2` is also the right answer — under Alpha the layer
// it mattes disappears, under Alpha Inverted it passes, which is what After
// Effects does with a matte that is not there yet.
if (!isPaintableSource(src)) {
resizeCaptureCanvas(src, size);
src.ctx.clearRect(0, 0, size.width, size.height);
if (mode.upload) uploadCaptureTexture(entry.gl, src);
return true;
}
if (!isPaintableSource(src)) return captureEmpty(entry, src, size, mode);
// The one capture failure Chrome does NOT report: inside a `layoutsubtree`
// canvas a child sized by `inset`/percentages measures 0×0, and
// `drawElementImage` then succeeds and draws nothing at all — no throw, no
// warning, a blank layer. Measured (vault `layoutsubtree-capture-rules`),
// so it is checked here and said out loud, once per source rather than once
// per frame.
// so it is said out loud once per source, and the frame paints from an empty
// capture rather than repeating the last one.
if (deviceSize(src.inner) === null) {
if (!src.emptyBoxReported) {
src.emptyBoxReported = true;
reportVfxError(
reportVfxFrameError(
`${describeHost(entry.host)}: the .hf-vfx-in wrapper measures 0×0, so its capture ` +
`would be empty. Inside a layoutsubtree canvas an inset or percentage box has no ` +
`size — the wrapper must state an explicit width and height in px.`,
);
}
return false;
return captureEmpty(entry, src, size, mode);
}
resizeCaptureCanvas(src, size);
src.ctx.clearRect(0, 0, size.width, size.height);
Expand Down Expand Up @@ -1201,7 +1216,7 @@ function awaitCanvasPaint(canvas: HTMLCanvasElement): Promise<"painted" | "timeo
async function awaitSourcePaints(entry: VfxEntry, sources: VfxCaptureSource[]): Promise<boolean> {
const outcomes = await Promise.all(sources.map((source) => awaitCanvasPaint(source.canvas)));
if (!outcomes.includes("timeout")) return true;
reportVfxError(
reportVfxFrameError(
`${describeHost(entry.host)}: no paint arrived within ${CAPTURE_PAINT_TIMEOUT_MS}ms, so ` +
`this frame's capture was skipped (a BeginFrame-controlled compositor without a tick ` +
`for this frame is a known cause).`,
Expand Down Expand Up @@ -1304,6 +1319,15 @@ async function capturePreviewThenPaint(
await Promise.all(entries.map((entry) => capturePaintedHost(entry, t, seq, speculative)));
}

/** A `.hf-vfx-in` first painted at 0×0 and then sized can stay blank in its capture until re-inserted (measured). */
function reinsertRegrownSources(): void {
for (const src of registry.flatMap(entrySources)) {
const empty = deviceSize(src.inner) === null;
if (src.laidOutEmpty && !empty) src.canvas.insertBefore(src.inner, src.inner.nextSibling);
src.laidOutEmpty = empty;
}
}

/**
* Repaint every registered chain for composition-local time `t`. Called from
* the runtime transport's `seek` (preview) and `renderSeek` (engine) — the two
Expand Down Expand Up @@ -1337,6 +1361,7 @@ async function capturePreviewThenPaint(
export function paintVfx(t: number, options?: { engineMode?: boolean }): void {
lastPaintTime = t;
const seq = ++paintSeq;
reinsertRegrownSources();
const capturing: VfxEntry[] = [];
for (const entry of registry) {
// Reported once, at the moment of loss; repeating it per frame is spam.
Expand Down
1 change: 1 addition & 0 deletions packages/engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ export {
type BeforeCaptureHook,
type DiscardWarmupInnerCapture,
type StaticVerificationOutcome,
VfxFailureError,
} from "./services/frameCapture.js";
export {
CaptureFailure,
Expand Down
18 changes: 18 additions & 0 deletions packages/engine/src/services/frameCapture-subTimelinePoll.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,24 @@ describe("pollSubCompositionTimelines fail-fast", () => {
expect(Date.now() - started).toBeLessThan(5_000);
});

it("stops at once when the caller says so, without waiting out the timeout", async () => {
const page = makeMockPage((expr) =>
expr.includes("__hfForceTimelineRebind") ? undefined : false,
);
const shouldStop = vi.fn(() => true);
const outcome = await pollSubCompositionTimelines(
page,
60_000,
10,
() => [],
undefined,
undefined,
shouldStop,
);
expect(outcome).toBe("timeout");
expect(shouldStop).toHaveBeenCalledTimes(1);
});

it("waits the full timeout when timelines are missing but no script failed", async () => {
const page = makeMockPage(() => false);
const outcome = await pollSubCompositionTimelines(page, 120, 10, () => []);
Expand Down
Loading
Loading