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;