From face9b6b78a770122024886870db8edf95f4acc2 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 20 Oct 2026 18:03:30 -0700 Subject: [PATCH] feat(core,engine): track accelerated canvases before any page script runs html-in-canvas captures a WebGL or WebGPU canvas frozen or black, so the runtime turns blur off when the composition draws one. Two cases slipped past the tracker that spots them: a canvas handed to transferControlToOffscreen (its context is created out of reach) and a context made by an author script that runs before the runtime. - The tracker is a self-contained function that also wraps transferControlToOffscreen (a transferred canvas counts as accelerated) and keeps its registry on window so independent installs agree. - The engine evaluates it on every new document, ahead of any page script. - insertRuntimeTag (bundler and Studio preview) puts it at the very start of for pages that opt into blur; the runtime install is idempotent with it. - report() carries failedFrames, the cumulative count of frames that rendered plain DOM because their capture failed or timed out. failures keeps its streak meaning. Core's unpacked size budget rises from 3903211 to 3930000 bytes: the runtime lifecycle, the compositor and the tracker together pass the old ceiling. Folded review fixes: R6, R17 (the report counter; the engine log line lands with the runtime's reason codes). Co-Authored-By: Claude Sonnet 5.5 --- .../core/src/compiler/htmlDocument.test.ts | 27 +++++++++ packages/core/src/compiler/htmlDocument.ts | 13 +++- packages/core/src/index.ts | 6 +- packages/core/src/pageMotionBlur.test.ts | 59 +++++++++++++++++++ packages/core/src/pageMotionBlur.ts | 45 ++++++++++++++ .../runtime/captureMotionBlur/index.test.ts | 54 ++++++++++++++++- .../src/runtime/captureMotionBlur/index.ts | 29 ++------- .../src/services/pageMotionBlur.test.ts | 12 ++-- .../engine/src/services/pageMotionBlur.ts | 7 ++- 9 files changed, 219 insertions(+), 33 deletions(-) create mode 100644 packages/core/src/pageMotionBlur.test.ts 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 `