fix(studio): motion blur falls back for a WebGL canvas Studio reaches first (#7060)

* fix(core): motion blur notices a WebGL canvas the Studio page touched first

When a same-origin host page (Studio) reaches a preview element before the
scene does, for example through a MutationObserver record, Chrome gives that
element the host's prototype. The scene's getContext("webgl") then ran the
host's unpatched getContext, so the canvas never reached the accelerated
canvas set, capture motion blur stayed on, and the WebGL canvas froze during
preview playback.

The tracker now records a canvas in the set of the canvas's own window, and
installs itself in a same-origin parent too, compiled with the parent's own
Function. Cross-origin parents are skipped.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(studio): track accelerated canvases in Studio's window, not the embedder's

The preview runtime no longer installs the canvas tracker in its parent
window. That ran on every preview boot, so any same-origin page embedding
the player got its getContext wrapped, and a host CSP without
unsafe-eval reported a violation on each load. Studio now installs the
tracker in its own window when its Player module loads, and core
exposes it at @hyperframes/core/page-motion-blur.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore: keep the comment share of the touched files from rising

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
James Russo
2026-11-03 03:59:23 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 05ddddbbf0
commit 112ba53302
7 changed files with 143 additions and 2 deletions
+6
View File
@@ -134,6 +134,12 @@
"types": "./dist/studioPreviewMark.d.ts",
"environments": ["browser", "bun", "node"]
},
"./page-motion-blur": {
"source": "./src/pageMotionBlur.ts",
"runtime": "./dist/pageMotionBlur.js",
"types": "./dist/pageMotionBlur.d.ts",
"environments": ["browser", "bun", "node"]
},
"./keyframe-defaults": {
"source": "./src/keyframeDefaults.ts",
"runtime": "./dist/keyframeDefaults.js",
+10
View File
@@ -146,6 +146,12 @@
"import": "./src/studioPreviewMark.ts",
"types": "./src/studioPreviewMark.ts"
},
"./page-motion-blur": {
"bun": "./src/pageMotionBlur.ts",
"node": "./dist/pageMotionBlur.js",
"import": "./src/pageMotionBlur.ts",
"types": "./src/pageMotionBlur.ts"
},
"./keyframe-defaults": {
"bun": "./src/keyframeDefaults.ts",
"node": "./dist/keyframeDefaults.js",
@@ -712,6 +718,10 @@
"import": "./dist/studioPreviewMark.js",
"types": "./dist/studioPreviewMark.d.ts"
},
"./page-motion-blur": {
"import": "./dist/pageMotionBlur.js",
"types": "./dist/pageMotionBlur.d.ts"
},
"./keyframe-defaults": {
"import": "./dist/keyframeDefaults.js",
"types": "./dist/keyframeDefaults.d.ts"
+40
View File
@@ -56,4 +56,44 @@ describe("accelerated canvas tracker", () => {
canvas.getContext("webgl");
expect(isAcceleratedCanvas(canvas)).toBe(true);
});
it("tracks a scene canvas that has the host page's prototype", () => {
const frame = document.createElement("iframe");
document.body.append(frame);
try {
const scene = frame.contentWindow as (Window & typeof globalThis) | null;
if (!scene) throw new Error("no iframe window");
const sceneProto = scene.HTMLCanvasElement.prototype as unknown as Proto;
expect(sceneProto === proto).toBe(false);
sceneProto.getContext = vi.fn(() => ({}));
// The host installs in its own window, as Studio does; the scene runs its own copy.
installAcceleratedCanvasTracker();
scene.eval(acceleratedCanvasTrackerSource());
const canvas = scene.document.createElement("canvas");
// Chrome gives a canvas the host's prototype when the host page reaches it first.
Object.setPrototypeOf(canvas, HTMLCanvasElement.prototype);
canvas.getContext("webgl2");
const tracked: unknown = Reflect.get(scene, "__hfAcceleratedCanvases");
expect((tracked as WeakSet<object>).has(canvas)).toBe(true);
} finally {
frame.remove();
}
});
it("leaves the page that embeds the scene alone", () => {
const frame = document.createElement("iframe");
document.body.append(frame);
try {
const scene = frame.contentWindow as (Window & typeof globalThis) | null;
if (!scene) throw new Error("no iframe window");
const hostGetContext = proto.getContext;
const hostSet = Object.getOwnPropertyDescriptor(window, "__hfAcceleratedCanvases");
scene.eval(acceleratedCanvasTrackerSource());
expect(Reflect.get(scene, "__hfAcceleratedCanvases")).toBeDefined();
expect(proto.getContext).toBe(hostGetContext);
expect(Object.getOwnPropertyDescriptor(window, "__hfAcceleratedCanvases")).toEqual(hostSet);
} finally {
frame.remove();
}
});
});
+9 -2
View File
@@ -35,11 +35,18 @@ export function installAcceleratedCanvasTracker(): void {
Object.defineProperty(tracked, "__hfAccelTracked", { value: true });
Object.defineProperty(proto, name, { value: tracked, writable: true, configurable: true });
};
const ownWindowSet = (canvas: object): WeakSet<object> => {
const view = (canvas as Node).ownerDocument?.defaultView;
const own: unknown = view ? Reflect.get(view, key) : undefined;
return own && typeof (own as WeakSet<object>).add === "function"
? (own as WeakSet<object>)
: seen;
};
wrap("getContext", (canvas, args, ctx) => {
if (ctx && accelerated.includes(String(args[0]))) seen.add(canvas);
if (ctx && accelerated.includes(String(args[0]))) ownWindowSet(canvas).add(canvas);
});
wrap("transferControlToOffscreen", (canvas) => {
seen.add(canvas);
ownWindowSet(canvas).add(canvas);
});
}
@@ -33,6 +33,7 @@ type Win = Window & {
__hf_page_composite_resolve?: () => boolean;
__hfMotionBlur?: {
report: () => { state: string; tier: string | null; reasonCode: string | null; movers: number };
probe: () => { state: string; tier: string | null; reasonCode: string | null; movers: number };
};
};
@@ -301,6 +302,73 @@ describe.skipIf(!hasBrowser)("capture motion blur pixels in Chrome", () => {
expect(ink(trail) / ink(row(sharp.frame, 40))).toBeLessThan(1.1);
});
it("falls back for a WebGL canvas the host page reached first, as Studio does", async (ctx) => {
if (!supported) ctx.skip();
// The preview is a same-origin iframe. A scene mounts its markup, the host's MutationObserver
// walks it (Studio's timeline does), and only then does the scene script call getContext.
const scene = composition(
`${BOX}<script>
window.mountScene = () => document.getElementById("root").insertAdjacentHTML("beforeend", '<div id="scene"><canvas id="gl" width="64" height="36"></canvas></div>');
window.startScene = () => document.getElementById("gl").getContext("webgl");
</script>`,
true,
"capture",
).replace("<head>", `<head><script>${acceleratedCanvasTrackerSource()}</script>`);
const page = await browser.newPage();
try {
// Studio installs the tracker in its own window when it loads, before any preview exists.
await page.setContent(
`<script>${acceleratedCanvasTrackerSource()}</script><iframe id="preview" width="${W}" height="${H}"></iframe>`,
);
await page.$eval(
"#preview",
(el, html) =>
new Promise((done) => {
const frame = el as HTMLIFrameElement;
frame.onload = done;
frame.srcdoc = html;
}),
scene,
);
// The host observes the preview before the runtime boots, as Studio's player does: its
// callback runs first and so makes the wrappers of what the scene mounts.
await page.evaluate(() => {
const frame = document.getElementById("preview") as HTMLIFrameElement;
const reached: Element[] = [];
Object.assign(window, { reached });
new MutationObserver((records) => {
for (const record of records)
for (const node of Array.from(record.addedNodes))
if (node instanceof Element) reached.push(...Array.from(node.children));
}).observe(frame.contentDocument?.getElementById("root") as Element, { childList: true });
});
const preview = page.frames().find((f) => f !== page.mainFrame());
if (!preview) throw new Error("no preview frame");
await preview.addScriptTag({ content: readFileSync(RUNTIME, "utf8") });
await preview.waitForFunction(
() => (window as Win & { __playerReady?: boolean }).__playerReady === true,
);
const result = await page.evaluate(async () => {
type Scene = Win & { mountScene: () => void; startScene: () => unknown };
const frame = document.getElementById("preview") as HTMLIFrameElement;
const view = frame.contentWindow as Scene;
view.mountScene();
await new Promise((done) => setTimeout(done, 0));
const reached = (window as unknown as { reached: Element[] }).reached;
const hostRealm = reached.some(
(el) => el.id === "gl" && Object.getPrototypeOf(el) === HTMLCanvasElement.prototype,
);
view.startScene();
return { hostRealm, report: view.__hfMotionBlur?.probe() ?? null };
});
// The canvas really carries the host's prototype, so its getContext runs in the host's realm.
expect(result.hostRealm).toBe(true);
expect(result.report).toMatchObject({ tier: "filter", reasonCode: "accelerated-canvas" });
} finally {
await page.close();
}
});
it.each(TIERS)(
"blurs a box centred with translate around where it really is, in the %s tier",
async (tier, ctx) => {
@@ -526,3 +526,10 @@ describe("composition loading overlay", () => {
iframe.remove();
});
});
describe("accelerated canvases", () => {
it("are tracked in Studio's window, whose prototype a preview canvas gets when Studio reaches it first", () => {
expect("__hfAccelTracked" in HTMLCanvasElement.prototype.getContext).toBe(true);
expect(Reflect.get(window, "__hfAcceleratedCanvases")).toBeInstanceOf(WeakSet);
});
});
@@ -1,6 +1,7 @@
import { buildProjectApiPath } from "../../utils/projectRouting";
import { forwardRef, useEffect, useRef, useState } from "react";
import { isLottieAnimationLoaded } from "@hyperframes/core/runtime/lottie-readiness";
import { installAcceleratedCanvasTracker } from "@hyperframes/core/page-motion-blur";
import { useMountEffect } from "../../hooks/useMountEffect";
import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore";
import { HyperframesLoader } from "../../components/ui";
@@ -25,6 +26,8 @@ export function loadPlayerModule(): Promise<unknown> {
if (typeof window !== "undefined") void loadPlayerModule().catch(() => {});
if (typeof window !== "undefined") installAcceleratedCanvasTracker();
interface PlayerProps {
projectId?: string;
directUrl?: string;