diff --git a/packages/core/src/compiler/htmlDocument.test.ts b/packages/core/src/compiler/htmlDocument.test.ts index 3c0c1a7331e..466d993e020 100644 --- a/packages/core/src/compiler/htmlDocument.test.ts +++ b/packages/core/src/compiler/htmlDocument.test.ts @@ -8,6 +8,7 @@ import { injectScriptsIntoHtml, injectTagsAtHeadStart, insertBeforeCloseTag, + insertRuntimeTag, hasCompositionOutsideTemplates, isFullHtmlDocument, parseHTMLContent, @@ -454,3 +455,29 @@ describe("hasCompositionOutsideTemplates", () => { expect(hasCompositionOutsideTemplates(html)).toBe(expected); }); }); + +describe("insertRuntimeTag", () => { + const runtime = ''; + const page = (attrs: string) => + `
`; + + it("puts the canvas tracker ahead of author head scripts for a page that opts into blur", () => { + const out = insertRuntimeTag(page('data-motion-blur="on"'), runtime); + const tracker = out.indexOf("data-hf-accel-tracker"); + expect(tracker).toBeGreaterThan(-1); + expect(tracker).toBeLessThan(out.indexOf("window.author")); + expect(out.indexOf("window.author")).toBeLessThan(out.indexOf(runtime)); + }); + + it("adds the tracker once, however many times the runtime is placed", () => { + const once = insertRuntimeTag(page('data-motion-blur="on"'), runtime); + const twice = insertRuntimeTag(once, runtime); + expect(twice.split("data-hf-accel-tracker").length).toBe(2); + }); + + it("leaves pages that did not opt in alone", () => { + for (const attrs of ["", 'data-motion-blur="off"', 'data-motion-blur-shutter="on"']) { + expect(insertRuntimeTag(page(attrs), runtime)).not.toContain("data-hf-accel-tracker"); + } + }); +}); diff --git a/packages/core/src/compiler/htmlDocument.ts b/packages/core/src/compiler/htmlDocument.ts index a1861a7d289..d0709058203 100644 --- a/packages/core/src/compiler/htmlDocument.ts +++ b/packages/core/src/compiler/htmlDocument.ts @@ -1,3 +1,4 @@ +import { acceleratedCanvasTrackerSource } from "../pageMotionBlur.js"; import { parseHTML } from "linkedom"; export const RUNTIME_BOOTSTRAP_ATTR = "data-hyperframes-preview-runtime"; @@ -347,7 +348,7 @@ export function insertBeforeCloseTag( return insertBeforeDocumentTag(html, `]*>/i); @@ -363,6 +364,16 @@ export function insertRuntimeTag(html: string, tag: string): string { return tag + html; } +const ACCEL_TRACKER_ATTR = "data-hf-accel-tracker"; +const OPTS_INTO_MOTION_BLUR = /\sdata-motion-blur\s*=\s*["']?on[\s"'>]/i; + +export function insertRuntimeTag(html: string, tag: string): string { + const withRuntime = insertTagInHead(html, tag); + if (!OPTS_INTO_MOTION_BLUR.test(html) || html.includes(ACCEL_TRACKER_ATTR)) return withRuntime; + const tracker = ``; + return injectTagsAtHeadStart(withRuntime, tracker); +} + /** * Insert raw tag markup at the very start of ``, ahead of every author * script (inline or external). Falls back to just before ``, then to the diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 75ead8efa5f..3cee99b92f7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -437,4 +437,8 @@ export { type HfVfxParam, type HfVfxParamValues, } from "./vfx.js"; -export { PAGE_DEVICE_SCALE_GLOBAL, PAGE_MOTION_BLUR_OFF_GLOBAL } from "./pageMotionBlur.js"; +export { + PAGE_DEVICE_SCALE_GLOBAL, + PAGE_MOTION_BLUR_OFF_GLOBAL, + acceleratedCanvasTrackerSource, +} from "./pageMotionBlur.js"; diff --git a/packages/core/src/pageMotionBlur.test.ts b/packages/core/src/pageMotionBlur.test.ts new file mode 100644 index 00000000000..9c7e1e62cf4 --- /dev/null +++ b/packages/core/src/pageMotionBlur.test.ts @@ -0,0 +1,59 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + acceleratedCanvasTrackerSource, + installAcceleratedCanvasTracker, + isAcceleratedCanvas, +} from "./pageMotionBlur"; + +type Proto = Record; +const proto = HTMLCanvasElement.prototype as unknown as Proto; +const saved: Proto = {}; + +beforeEach(() => { + for (const name of ["getContext", "transferControlToOffscreen"]) saved[name] = proto[name]; + proto.getContext = vi.fn(() => ({})); + proto.transferControlToOffscreen = vi.fn(() => ({})); +}); + +afterEach(() => { + for (const name of Object.keys(saved)) proto[name] = saved[name]; + Reflect.deleteProperty(window, "__hfAcceleratedCanvases"); +}); + +describe("accelerated canvas tracker", () => { + it("remembers WebGL and WebGPU canvases, not 2D ones", () => { + installAcceleratedCanvasTracker(); + const [gl, gpu, flat] = [1, 2, 3].map(() => document.createElement("canvas")); + gl?.getContext("webgl2"); + gpu?.getContext("webgpu"); + flat?.getContext("2d"); + expect([gl, gpu, flat].map((c) => c !== undefined && isAcceleratedCanvas(c))).toEqual([ + true, + true, + false, + ]); + }); + + it("treats a canvas handed to an OffscreenCanvas as accelerated", () => { + installAcceleratedCanvasTracker(); + const canvas = document.createElement("canvas"); + canvas.transferControlToOffscreen(); + expect(isAcceleratedCanvas(canvas)).toBe(true); + }); + + it("installs once however many times it runs", () => { + const original = proto.getContext as ReturnType; + installAcceleratedCanvasTracker(); + installAcceleratedCanvasTracker(); + document.createElement("canvas").getContext("2d"); + expect(original).toHaveBeenCalledTimes(1); + }); + + it("works from its source text, as the engine and the preview inline it", () => { + (0, eval)(acceleratedCanvasTrackerSource()); + const canvas = document.createElement("canvas"); + canvas.getContext("webgl"); + expect(isAcceleratedCanvas(canvas)).toBe(true); + }); +}); diff --git a/packages/core/src/pageMotionBlur.ts b/packages/core/src/pageMotionBlur.ts index 4b422e1cef1..9169613f1eb 100644 --- a/packages/core/src/pageMotionBlur.ts +++ b/packages/core/src/pageMotionBlur.ts @@ -12,3 +12,48 @@ export function supportsCaptureMotionBlur(win: Window = window): boolean { const canvas = w.HTMLCanvasElement?.prototype; return !!ctx && !!canvas && "drawElementImage" in ctx && "requestPaint" in canvas; } + +const ACCELERATED_CANVASES_GLOBAL = "__hfAcceleratedCanvases"; + +/** + * Remember canvases html-in-canvas cannot capture: WebGL, WebGPU, or handed to an OffscreenCanvas. + * Self-contained: the engine and the preview run its source ahead of every author script. + */ +export function installAcceleratedCanvasTracker(): void { + const key = "__hfAcceleratedCanvases"; + const known: unknown = Reflect.get(window, key); + const seen = known instanceof WeakSet ? known : new WeakSet(); + if (seen !== known) Object.defineProperty(window, key, { value: seen }); + const accelerated = ["webgl", "webgl2", "experimental-webgl", "webgpu"]; + const wrap = ( + name: string, + onCall: (canvas: object, args: unknown[], result: unknown) => void, + ) => { + const proto = HTMLCanvasElement.prototype; + const original: unknown = Reflect.get(proto, name); + if (typeof original !== "function" || "__hfAccelTracked" in original) return; + const tracked = function (this: object, ...args: unknown[]): unknown { + const result: unknown = Reflect.apply(original, this, args); + onCall(this, args, result); + return result; + }; + Object.defineProperty(tracked, "__hfAccelTracked", { value: true }); + Object.defineProperty(proto, name, { value: tracked, writable: true, configurable: true }); + }; + wrap("getContext", (canvas, args, ctx) => { + if (ctx && accelerated.includes(String(args[0]))) seen.add(canvas); + }); + wrap("transferControlToOffscreen", (canvas) => { + seen.add(canvas); + }); +} + +export function isAcceleratedCanvas(canvas: HTMLCanvasElement): boolean { + const seen: unknown = Reflect.get(window, ACCELERATED_CANVASES_GLOBAL); + return seen instanceof WeakSet && seen.has(canvas); +} + +/** The tracker as a script body, for `evaluateOnNewDocument` and inline `