From 112ba53302726f2135dc7b28b03033b5a897ffbd Mon Sep 17 00:00:00 2001 From: James Russo Date: Tue, 3 Nov 2026 03:59:23 +0000 Subject: [PATCH] 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) * 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) * chore: keep the comment share of the touched files from rising Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- packages/core/package-subpaths.json | 6 ++ packages/core/package.json | 10 +++ packages/core/src/pageMotionBlur.test.ts | 40 +++++++++++ packages/core/src/pageMotionBlur.ts | 11 ++- .../captureMotionBlur.pixels.browser.test.ts | 68 +++++++++++++++++++ .../src/player/components/Player.test.ts | 7 ++ .../studio/src/player/components/Player.tsx | 3 + 7 files changed, 143 insertions(+), 2 deletions(-) diff --git a/packages/core/package-subpaths.json b/packages/core/package-subpaths.json index d1aca87de1..a21e3f030f 100644 --- a/packages/core/package-subpaths.json +++ b/packages/core/package-subpaths.json @@ -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", diff --git a/packages/core/package.json b/packages/core/package.json index 61385984df..4073f50008 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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" diff --git a/packages/core/src/pageMotionBlur.test.ts b/packages/core/src/pageMotionBlur.test.ts index 9c7e1e62cf..3d88c4d31f 100644 --- a/packages/core/src/pageMotionBlur.test.ts +++ b/packages/core/src/pageMotionBlur.test.ts @@ -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).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(); + } + }); }); diff --git a/packages/core/src/pageMotionBlur.ts b/packages/core/src/pageMotionBlur.ts index 6ba2617415..b4abf0205f 100644 --- a/packages/core/src/pageMotionBlur.ts +++ b/packages/core/src/pageMotionBlur.ts @@ -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 => { + const view = (canvas as Node).ownerDocument?.defaultView; + const own: unknown = view ? Reflect.get(view, key) : undefined; + return own && typeof (own as WeakSet).add === "function" + ? (own as WeakSet) + : 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); }); } diff --git a/packages/engine/src/services/captureMotionBlur.pixels.browser.test.ts b/packages/engine/src/services/captureMotionBlur.pixels.browser.test.ts index 7566ad1b5d..6ea0e8ab51 100644 --- a/packages/engine/src/services/captureMotionBlur.pixels.browser.test.ts +++ b/packages/engine/src/services/captureMotionBlur.pixels.browser.test.ts @@ -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}`, + true, + "capture", + ).replace("", ``); + const page = await browser.newPage(); + try { + // Studio installs the tracker in its own window when it loads, before any preview exists. + await page.setContent( + ``, + ); + 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) => { diff --git a/packages/studio/src/player/components/Player.test.ts b/packages/studio/src/player/components/Player.test.ts index 6c12a0606f..2f027be97a 100644 --- a/packages/studio/src/player/components/Player.test.ts +++ b/packages/studio/src/player/components/Player.test.ts @@ -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); + }); +}); diff --git a/packages/studio/src/player/components/Player.tsx b/packages/studio/src/player/components/Player.tsx index e340f441d9..5907947706 100644 --- a/packages/studio/src/player/components/Player.tsx +++ b/packages/studio/src/player/components/Player.tsx @@ -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 { if (typeof window !== "undefined") void loadPlayerModule().catch(() => {}); +if (typeof window !== "undefined") installAcceleratedCanvasTracker(); + interface PlayerProps { projectId?: string; directUrl?: string;