From 6ed223226bf7faa9ba3b29481be5019d57884ed8 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 14 May 2026 04:29:16 +0000 Subject: [PATCH 01/17] fix(cli): prefer puppeteer cache + numeric version sort (staff review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two correctness fixes from PR #821 self-review: 1. Cache priority order. Previous order was hyperframes-managed cache → puppeteer cache. HF cache is pinned to CHROME_VERSION (131-era) which lags 17+ releases behind upstream; if a user separately installed a newer chrome-headless-shell via @puppeteer/browsers install, the CLI would silently hand engine the older HF-cache binary while engine's own resolveHeadlessShellPath would have picked the newer one. Flip the priority so puppeteer cache wins, matching engine semantics. 2. Numeric (not lexicographic) version sort. `readdirSync.sort().reverse()` over names like `linux-148.0.7778.97` and `linux-99.0.6533.123` would return `linux-99...` first because character '9' outranks '1'. Parse each name into integer segments and compare them numerically. Tests: add both-caches-populated and linux-148-beats-linux-99 cases. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/src/browser/manager.test.ts | 60 ++++++++++++++++- packages/cli/src/browser/manager.ts | 83 +++++++++++++++++++----- 2 files changed, 126 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/browser/manager.test.ts b/packages/cli/src/browser/manager.test.ts index ae826e691..118d23377 100644 --- a/packages/cli/src/browser/manager.test.ts +++ b/packages/cli/src/browser/manager.test.ts @@ -102,7 +102,10 @@ describe("findBrowser — cache resolution", () => { vi.doUnmock("@puppeteer/browsers"); }); - it("resolves to the hyperframes-managed cache when present", async () => { + it("resolves to the hyperframes-managed cache when puppeteer cache is empty", async () => { + // Only HF cache populated. Puppeteer cache is the higher-priority path + // (see "prefers puppeteer cache" test below), so this exercises the + // last-resort fallback. installFsMocks({ existing: new Set([HF_CACHE, HF_BINARY]) }); installPuppeteerBrowsersMock({ installedInHfCache: [{ browser: "chrome-headless-shell", executablePath: HF_BINARY }], @@ -129,8 +132,35 @@ describe("findBrowser — cache resolution", () => { expect(result).toEqual({ executablePath: PUPPETEER_BINARY, source: "cache" }); }); + it("prefers the puppeteer cache over the hyperframes cache when BOTH are populated", async () => { + // The HF cache is pinned to `CHROME_VERSION` (131-era) which lags upstream + // by many releases. The engine's `resolveHeadlessShellPath` scans the + // puppeteer cache and selects newest-version-first; if the CLI handed + // engine the older HF-cache binary while a newer puppeteer-cache binary + // exists, the two would silently disagree on which binary to use. + // This test pins the priority: puppeteer cache wins when both are populated. + installFsMocks({ + existing: new Set([HF_CACHE, HF_BINARY, PUPPETEER_CACHE, PUPPETEER_BINARY]), + dirs: { [PUPPETEER_CACHE]: ["linux-148.0.7778.97"] }, + }); + installPuppeteerBrowsersMock({ + installedInHfCache: [{ browser: "chrome-headless-shell", executablePath: HF_BINARY }], + }); + + const { findBrowser } = await import("./manager.js"); + const result = await findBrowser(); + + expect(result?.executablePath).toBe(PUPPETEER_BINARY); + expect(result?.source).toBe("cache"); + }); + it("picks the newest version when multiple chrome-headless-shell builds are cached", async () => { - const olderBinary = `${PUPPETEER_CACHE}/linux-131.0.6778.85/chrome-headless-shell-linux64/chrome-headless-shell`; + const olderBinary = join( + PUPPETEER_CACHE, + "linux-131.0.6778.85", + "chrome-headless-shell-linux64", + "chrome-headless-shell", + ); installFsMocks({ existing: new Set([PUPPETEER_CACHE, PUPPETEER_BINARY, olderBinary]), dirs: { [PUPPETEER_CACHE]: ["linux-131.0.6778.85", "linux-148.0.7778.97"] }, @@ -143,6 +173,32 @@ describe("findBrowser — cache resolution", () => { expect(result?.executablePath).toBe(PUPPETEER_BINARY); }); + it("uses numeric (not lexicographic) version ordering — linux-148 beats linux-99", async () => { + // Regression guard for the lexicographic-sort bug: `"linux-99..."` sorts + // after `"linux-148..."` character-by-character (because `'9' > '1'`), + // which would have caused the CLI to hand engine an ancient 99-era binary + // when a fresh 148 was sitting right next to it. Numeric semver-style + // ordering is the only correct semantic. + const linux99Binary = join( + PUPPETEER_CACHE, + "linux-99.0.6533.123", + "chrome-headless-shell-linux64", + "chrome-headless-shell", + ); + installFsMocks({ + existing: new Set([PUPPETEER_CACHE, PUPPETEER_BINARY, linux99Binary]), + // Intentionally list the entries in an order that would expose the bug + // under naive `.sort().reverse()` (which puts `linux-99...` first). + dirs: { [PUPPETEER_CACHE]: ["linux-99.0.6533.123", "linux-148.0.7778.97"] }, + }); + installPuppeteerBrowsersMock(); + + const { findBrowser } = await import("./manager.js"); + const result = await findBrowser(); + + expect(result?.executablePath).toBe(PUPPETEER_BINARY); + }); + it("falls back to system Chrome and warns on Linux when no cache has headless-shell", async () => { installFsMocks({ existing: new Set([SYSTEM_CHROME]) }); installPuppeteerBrowsersMock(); diff --git a/packages/cli/src/browser/manager.ts b/packages/cli/src/browser/manager.ts index cffc099af..fc1c9999a 100644 --- a/packages/cli/src/browser/manager.ts +++ b/packages/cli/src/browser/manager.ts @@ -73,7 +73,26 @@ function findFromEnv(): BrowserResult | undefined { } async function findFromCache(): Promise { - // 1) Hyperframes-managed cache (populated by `clearBrowser` + `install` below). + // 1) Puppeteer's managed cache — where `npx @puppeteer/browsers install + // chrome-headless-shell` lands, and where `puppeteer install` from a project + // depending on full `puppeteer` (not `puppeteer-core`) lands. The engine's + // `resolveHeadlessShellPath` reads from here and selects newest-version- + // first; the CLI must match that semantic or it will silently hand the + // engine an older binary than the engine itself would pick. + // + // We intentionally check puppeteer BEFORE the hyperframes-managed cache: + // the HF cache is pinned to `CHROME_VERSION` (above) which lags behind + // upstream Chrome by many releases. If a user installed chrome-headless-shell + // separately (via `@puppeteer/browsers install`) we want to use that + // newer binary, not the pinned-stale fallback. + const fromPuppeteer = findFromPuppeteerCache(); + if (fromPuppeteer) { + return fromPuppeteer; + } + + // 2) Hyperframes-managed cache (populated by `ensureBrowser` below as a + // download-of-last-resort). This is the fallback path: only reached when + // no puppeteer-cache binary exists. if (existsSync(CACHE_DIR)) { const installed = await getInstalledBrowsers({ cacheDir: CACHE_DIR }); const match = installed.find((b) => b.browser === Browser.CHROMEHEADLESSSHELL); @@ -82,27 +101,61 @@ async function findFromCache(): Promise { } } - // 2) Puppeteer's managed cache — where `npx @puppeteer/browsers install - // chrome-headless-shell` lands, and where `puppeteer install` from a project - // that depends on full `puppeteer` (not `puppeteer-core`) lands. The engine - // already reads from here (`resolveHeadlessShellPath`); without this branch - // the CLI would skip past a perfectly good chrome-headless-shell and fall - // through to `findFromSystem()`, picking regular Chrome which has dropped - // `HeadlessExperimental.enable` and disables the perf-optimized capture - // path. - const fromPuppeteer = findFromPuppeteerCache(); - if (fromPuppeteer) { - return fromPuppeteer; + return undefined; +} + +/** + * Parse a puppeteer-cache version directory name (`linux-148.0.7778.97`, + * `mac_arm-131.0.6778.85`, etc.) into a numeric tuple for ordering. + * + * Lexicographic sort on these strings is buggy because `"99"` > `"148"` (the + * `9` outranks the `1` character-wise), so a 99-era binary would beat a + * 148-era binary in `.sort().reverse()`. We split on `-` to drop the platform + * prefix, then on `.` to get integer segments. Returns `undefined` for names + * that don't have at least one parseable numeric segment so they sort last. + */ +function parseVersionSegments(versionDir: string): number[] | undefined { + const dashIdx = versionDir.indexOf("-"); + const versionPart = dashIdx >= 0 ? versionDir.slice(dashIdx + 1) : versionDir; + const segments = versionPart.split("."); + const parsed: number[] = []; + for (const seg of segments) { + const n = parseInt(seg, 10); + if (!Number.isFinite(n)) { + // Stop at the first non-numeric segment but keep what we've collected. + break; + } + parsed.push(n); } + return parsed.length > 0 ? parsed : undefined; +} - return undefined; +/** Numeric semver-style descending comparator for puppeteer cache dirs. */ +function compareVersionDirsDescending(a: string, b: string): number { + const pa = parseVersionSegments(a); + const pb = parseVersionSegments(b); + // Unparseable names sort after parseable ones (so we still try them, just last). + if (!pa && !pb) return 0; + if (!pa) return 1; + if (!pb) return -1; + const len = Math.max(pa.length, pb.length); + for (let i = 0; i < len; i += 1) { + const av = pa[i] ?? 0; + const bv = pb[i] ?? 0; + if (av !== bv) return bv - av; // descending (newest first) + } + return 0; } function findFromPuppeteerCache(): BrowserResult | undefined { if (!existsSync(PUPPETEER_CACHE_DIR)) return undefined; let versions: string[]; try { - versions = readdirSync(PUPPETEER_CACHE_DIR).sort().reverse(); // newest first + // Numeric semver-style sort, newest first. Lexicographic `.sort().reverse()` + // (the previous implementation, still in engine `resolveHeadlessShellPath`) + // mis-orders `linux-99...` ahead of `linux-148...` because character `'9'` + // outranks `'1'`. See `parseVersionSegments` above. + versions = [...readdirSync(PUPPETEER_CACHE_DIR)].sort(compareVersionDirsDescending); } catch { return undefined; } @@ -159,7 +212,7 @@ function warnSystemFallbackOnce(executablePath: string): void { if (isHeadlessShellBinary(executablePath)) return; _warnedSystemFallback = true; console.warn( - `[hyperframes] Using system Chrome at ${executablePath}; HeadlessExperimental.beginFrame is unavailable in regular Chrome builds, so the perf-optimized capture path falls back to screenshot mode. Install chrome-headless-shell for the optimized path:\n npx @puppeteer/browsers install chrome-headless-shell`, + `[hyperframes] Using system Chrome at ${executablePath}; HeadlessExperimental.beginFrame is unavailable in regular Chrome builds, so the perf-optimized capture path falls back to screenshot mode. Install chrome-headless-shell for the optimized path:\n npx @puppeteer/browsers install chrome-headless-shell\n(Or set HYPERFRAMES_BROWSER_PATH to point at an existing chrome-headless-shell binary.)`, ); } From 8c324d4b1015eb9b82927c7e45aeb98933f61883 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 14 May 2026 08:06:55 +0000 Subject: [PATCH 02/17] perf(engine): page-side compositing for shader transitions (opt-in spike) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an opt-in `--page-side-compositing` flag (CLI) backed by a new engine config field `enablePageSideCompositing` and env var `HF_PAGE_SIDE_COMPOSITING`. When set, SDR shader-transition compositions skip the Node-side layered blend (the hf#677 chain) and instead run the shader inside Chrome via a page-side WebGL canvas; the engine then captures ONE opaque RGB frame per output frame via the existing streaming capture path. This is the strongest non-beginFrame perf lever for Mac users, who cannot take the beginFrame `~5×` path (Chromium structural limit, crbug.com/40656275). Stacks on top of the hf#677 1.95× baseline. Default OFF — existing fixture pins (byte-exact MP4 output) are preserved. Opt-in path is intentionally PSNR-pinned, not byte-equal (WebGL is f32; Node is f64). HDR content forces the existing layered path regardless. Implementation: - engine: new `EngineConfig.enablePageSideCompositing` (default false). - producer/fileServer: new `HF_PAGE_SIDE_COMPOSITING_STUB` early-page script injected into the served HTML head when the flag is on. - producer/renderOrchestrator: when the flag + no HDR + no png-sequence, route SDR transitions through the streaming path instead of the layered HDR stage. - shader-transitions: new `engineModePageComposite.ts` installs a fullscreen WebGL compositor overlay and wraps `window.__hf.seek` so each seek inside a transition window captures both scenes via the Chromium `drawElementImage` API to GL textures, runs the fragment shader, and displays the composited result on the overlay canvas. The engine takes one screenshot per frame and sees the composited overlay. - cli: new `--page-side-compositing` flag sets `HF_PAGE_SIDE_COMPOSITING=true` before producer load. - scripts/page-side-compositing-smoke: bundled-CLI smoke that renders a representative fixture with and without the flag, validates the canary strings are in the shipped bundles, and writes a wall-time pair. Determinism trade documented in the engine config doc-comment. The smoke script enforces the bundled-CLI validation discipline from prior perf work (see internal feedback note `validate_bundled_cli_not_dev_path`). Runtime requirement: Chromium's `CanvasDrawElement` feature (already enabled by the engine's `--enable-features=CanvasDrawElement` launch flag). When the runtime feature is unavailable, the page-side installer logs a warning and falls back to opacity-flip mode — the engine still takes the streaming path; the transition window degrades to a hard scene swap. Vance will validate on Mac Chrome where the feature is supported. Co-Authored-By: Vai --- packages/cli/src/commands/render.ts | 20 ++ packages/engine/src/config.test.ts | 26 ++ packages/engine/src/config.ts | 34 ++ packages/producer/src/services/fileServer.ts | 23 ++ .../src/services/renderOrchestrator.ts | 47 ++- .../src/engineModePageComposite.test.ts | 61 ++++ .../src/engineModePageComposite.ts | 336 ++++++++++++++++++ .../shader-transitions/src/hyper-shader.ts | 36 ++ packages/shader-transitions/src/index.ts | 6 + .../fixture/index.html | 82 +++++ scripts/page-side-compositing-smoke/run.mjs | 167 +++++++++ 11 files changed, 831 insertions(+), 7 deletions(-) create mode 100644 packages/shader-transitions/src/engineModePageComposite.test.ts create mode 100644 packages/shader-transitions/src/engineModePageComposite.ts create mode 100644 scripts/page-side-compositing-smoke/fixture/index.html create mode 100644 scripts/page-side-compositing-smoke/run.mjs diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 183d5a4fd..3dfc727e7 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -222,6 +222,16 @@ export default defineCommand({ description: "Output resolution preset: landscape (1920x1080), portrait (1080x1920), landscape-4k (3840x2160), portrait-4k (2160x3840), square (1080x1080), square-4k (2160x2160). Aliases: 1080p, 4k, uhd, 1080p-square, square-1080p, 4k-square. The composition is unchanged — Chrome renders at higher DPR (deviceScaleFactor) so the captured screenshot lands at the requested dimensions. Aspect ratio must match the composition; the scale must be an integer multiple. Not yet supported with --hdr.", }, + "page-side-compositing": { + type: "boolean", + description: + "EXPERIMENTAL (opt-in spike). Run shader transitions on a page-side " + + "WebGL canvas inside Chrome instead of the Node-side layered blend. " + + "Mac-viable lever to push past the hf#677 1.95× baseline on shader-" + + "transition renders. SDR only; HDR content forces the existing path. " + + "Pin a PSNR-based correctness check, not byte-equality, when using this.", + default: false, + }, }, async run({ args }) { // ── Resolve project ──────────────────────────────────────────────────── @@ -293,6 +303,16 @@ export default defineCommand({ workers = parsed; } + // ── Wire opt-in: page-side compositing ─────────────────────────────── + // EXPERIMENTAL — the engine reads HF_PAGE_SIDE_COMPOSITING via + // `resolveConfig()` (engine `EngineConfig.enablePageSideCompositing`). + // Set the env var BEFORE `loadProducer()` runs so producer's first call + // to `resolveConfig()` picks it up. Same pattern as + // PRODUCER_HEADLESS_SHELL_PATH below. + if (args["page-side-compositing"]) { + process.env.HF_PAGE_SIDE_COMPOSITING = "true"; + } + // ── Validate max-concurrent-renders ───────────────────────────────── if (args["max-concurrent-renders"] != null) { const parsed = parseInt(args["max-concurrent-renders"], 10); diff --git a/packages/engine/src/config.test.ts b/packages/engine/src/config.test.ts index 0e29345b7..557d3aea3 100644 --- a/packages/engine/src/config.test.ts +++ b/packages/engine/src/config.test.ts @@ -138,4 +138,30 @@ describe("resolveConfig", () => { const config = resolveConfig(); expect(config.frameDataUriCacheLimit).toBe(32); }); + + describe("enablePageSideCompositing (HF_PAGE_SIDE_COMPOSITING)", () => { + it("defaults to false", () => { + const config = resolveConfig(); + expect(config.enablePageSideCompositing).toBe(false); + }); + + it("flips to true when HF_PAGE_SIDE_COMPOSITING=true", () => { + setEnv("HF_PAGE_SIDE_COMPOSITING", "true"); + const config = resolveConfig(); + expect(config.enablePageSideCompositing).toBe(true); + }); + + it("ignores any non-'true' value", () => { + setEnv("HF_PAGE_SIDE_COMPOSITING", "1"); + expect(resolveConfig().enablePageSideCompositing).toBe(false); + setEnv("HF_PAGE_SIDE_COMPOSITING", "yes"); + expect(resolveConfig().enablePageSideCompositing).toBe(false); + }); + + it("explicit override wins over the env var", () => { + setEnv("HF_PAGE_SIDE_COMPOSITING", "true"); + const config = resolveConfig({ enablePageSideCompositing: false }); + expect(config.enablePageSideCompositing).toBe(false); + }); + }); }); diff --git a/packages/engine/src/config.ts b/packages/engine/src/config.ts index c2738ee94..5cacdde00 100644 --- a/packages/engine/src/config.ts +++ b/packages/engine/src/config.ts @@ -48,6 +48,35 @@ export interface EngineConfig { expectedChromiumMajor?: number; /** Force screenshot capture mode (skip BeginFrame even on Linux). */ forceScreenshot: boolean; + /** + * Opt-in: page-side shader-transition compositing. + * + * When `true`, shader transitions for SDR compositions run their blend + * inside Chrome via WebGL on a page-side compositor canvas instead of + * Node-side per-pixel blending (the hf#677 layered pipeline). The engine + * then captures ONE opaque RGB frame per output frame via the streaming + * capture path, skipping per-scene transparent screenshots and the + * Node-side shader-blend worker pool entirely. + * + * The feature stacks on top of the hf#677 chain — it does not undo it. + * When this flag is OFF (the default), behaviour is byte-identical to the + * current path. When ON and the composition has no shader transitions or + * has HDR content (which forces the layered path regardless), this flag + * is a no-op. + * + * Mac viability: Chrome on Mac accelerates page-side WebGL canvases via + * Metal/CoreAnimation natively. This is the lever for Mac users who + * cannot use `--enable-begin-frame-control` (Chromium structural limit, + * crbug.com/40656275). + * + * Determinism: page-side WebGL is f32, not f64. Byte-equality fixture + * pins are NOT compatible with this path; the new path's correctness + * pin is PSNR-based. Default OFF preserves the existing pins for the + * hf#677 chain. + * + * Env fallback: `HF_PAGE_SIDE_COMPOSITING=true`. + */ + enablePageSideCompositing: boolean; // ── Encoding ───────────────────────────────────────────────────────── enableChunkedEncode: boolean; @@ -148,6 +177,7 @@ export const DEFAULT_CONFIG: EngineConfig = { browserTimeout: 120_000, protocolTimeout: 300_000, forceScreenshot: false, + enablePageSideCompositing: false, enableChunkedEncode: false, chunkSizeFrames: 360, @@ -221,6 +251,10 @@ export function resolveConfig(overrides?: Partial): EngineConfig { : undefined, forceScreenshot: envBool("PRODUCER_FORCE_SCREENSHOT", DEFAULT_CONFIG.forceScreenshot), + enablePageSideCompositing: envBool( + "HF_PAGE_SIDE_COMPOSITING", + DEFAULT_CONFIG.enablePageSideCompositing, + ), enableChunkedEncode: envBool( "PRODUCER_ENABLE_CHUNKED_ENCODE", diff --git a/packages/producer/src/services/fileServer.ts b/packages/producer/src/services/fileServer.ts index b79482ab2..d5597f58a 100644 --- a/packages/producer/src/services/fileServer.ts +++ b/packages/producer/src/services/fileServer.ts @@ -428,6 +428,29 @@ const HF_EARLY_STUB = `(function() { if (!window.__hf) window.__hf = {}; })();`; +/** + * Page-side compositing opt-in flag stub. + * + * When the engine is launched with `enablePageSideCompositing: true`, the + * orchestrator injects this stub into the very top of every served HTML + * page. The flag is read by `@hyperframes/shader-transitions`' engine-mode + * `init()` to switch from the default opacity-flip mode (which leaves + * shader blending to the Node side via the hf#677 layered pipeline) to a + * page-side WebGL compositor that runs the shader inside Chrome and + * exposes a single opaque RGB frame for the engine to capture. + * + * Sentinel ONLY — no logic here. The compositor itself ships inside + * `@hyperframes/shader-transitions` and is loaded by the composition's + * regular script bundle. + * + * Default OFF: when the flag is not set, behavior is byte-identical to + * the existing layered path. + */ +export const HF_PAGE_SIDE_COMPOSITING_STUB = `(function() { + if (typeof window === "undefined") return; + window.__HF_PAGE_SIDE_COMPOSITING__ = true; +})();`; + /** * Bridge script: maps window.__player (Hyperframe runtime) → window.__hf (engine protocol). * Injected after RENDER_MODE_SCRIPT so the engine's frameCapture can find window.__hf. diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index 915defc68..5b56b6203 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -78,7 +78,12 @@ import { import { join, dirname, resolve } from "path"; import { randomUUID } from "crypto"; import { fileURLToPath } from "url"; -import { createFileServer, type FileServerHandle, VIRTUAL_TIME_SHIM } from "./fileServer.js"; +import { + createFileServer, + type FileServerHandle, + HF_PAGE_SIDE_COMPOSITING_STUB, + VIRTUAL_TIME_SHIM, +} from "./fileServer.js"; import { defaultLogger, type ProducerLogger } from "../logger.js"; import { type HdrImageTransferCache } from "./hdrImageTransferCache.js"; import { @@ -1620,11 +1625,18 @@ export async function executeRenderJob( // Start file server (may already be running from duration discovery) if (!fileServer) { + // Inject the page-side compositing opt-in flag stub BEFORE the virtual + // time shim when the engine config opts in. The shim itself does not + // depend on the flag; ordering is arbitrary but stable. + const preHeadScripts: string[] = [VIRTUAL_TIME_SHIM]; + if (cfg.enablePageSideCompositing) { + preHeadScripts.unshift(HF_PAGE_SIDE_COMPOSITING_STUB); + } fileServer = await createFileServer({ projectDir, compiledDir: join(workDir, "compiled"), port: 0, - preHeadScripts: [VIRTUAL_TIME_SHIM], + preHeadScripts, }); assertNotAborted(); } @@ -1742,11 +1754,32 @@ export async function executeRenderJob( // issues (orange shift) with no quality benefit. const nativeHdrIds = new Set([...nativeHdrVideoIds, ...nativeHdrImageIds]); const hasHdrContent = Boolean(effectiveHdr && nativeHdrIds.size > 0); - const useLayeredComposite = shouldUseLayeredComposite({ - hasHdrContent, - hasShaderTransitions: compiled.hasShaderTransitions, - isPngSequence, - }); + // Page-side compositing opt-in: when the engine is configured to run the + // shader blend inside Chrome via a page-side WebGL canvas, the layered + // Node-side composite path is unnecessary for SDR shader transitions. + // The streaming path takes ONE opaque RGB screenshot per output frame — + // exactly the single capture the page-side compositor produces. HDR + // content still forces the layered path (HDR layers need per-layer + // alpha + native HDR raw frame compositing in Node; that's out of scope + // for this opt-in). + const usePageSideCompositingForTransitions = + cfg.enablePageSideCompositing && + compiled.hasShaderTransitions && + !hasHdrContent && + !isPngSequence; + if (usePageSideCompositingForTransitions) { + log.info( + "[Render] Page-side compositing enabled — bypassing Node-side layered " + + "shader-blend path. Engine will capture one opaque RGB frame per output frame.", + ); + } + const useLayeredComposite = + !usePageSideCompositingForTransitions && + shouldUseLayeredComposite({ + hasHdrContent, + hasShaderTransitions: compiled.hasShaderTransitions, + isPngSequence, + }); const encoderHdr = hasHdrContent ? effectiveHdr : undefined; // png-sequence has no encoder, but the rest of the orchestrator still // reads `preset.quality` for `effectiveQuality` and `preset.codec` for diff --git a/packages/shader-transitions/src/engineModePageComposite.test.ts b/packages/shader-transitions/src/engineModePageComposite.test.ts new file mode 100644 index 000000000..05fd8dd98 --- /dev/null +++ b/packages/shader-transitions/src/engineModePageComposite.test.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + isPageSideCompositingSupported, + PAGE_COMPOSITOR_BUILD_CANARY, + PAGE_COMPOSITOR_CANVAS_ID, +} from "./engineModePageComposite.js"; + +describe("isPageSideCompositingSupported", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("returns false outside the browser (no window)", () => { + vi.stubGlobal("window", undefined); + expect(isPageSideCompositingSupported()).toBe(false); + }); + + it("returns false outside the browser (no document)", () => { + vi.stubGlobal("window", {}); + vi.stubGlobal("document", undefined); + expect(isPageSideCompositingSupported()).toBe(false); + }); + + it("returns true when drawElementImage is exposed", () => { + vi.stubGlobal("window", {}); + vi.stubGlobal("document", { + createElement: () => ({ + setAttribute: () => undefined, + layoutSubtree: true, + getContext: () => ({ drawElementImage: () => undefined }), + }), + }); + expect(isPageSideCompositingSupported()).toBe(true); + }); + + it("returns false when drawElementImage is missing", () => { + vi.stubGlobal("window", {}); + vi.stubGlobal("document", { + createElement: () => ({ + setAttribute: () => undefined, + layoutSubtree: true, + getContext: () => ({}), + }), + }); + expect(isPageSideCompositingSupported()).toBe(false); + }); +}); + +describe("page-side compositor exported constants", () => { + // These constants are load-bearing for the bundled-CLI smoke: the + // validation script greps the shipped bundle for the canary to confirm + // the page-side path is in the production tsup output, not just the + // source tree. + it("exports a stable canary string used by the bundled-CLI smoke", () => { + expect(PAGE_COMPOSITOR_BUILD_CANARY).toBe("__hf_page_compositor_v1__"); + }); + + it("exports a stable canvas id", () => { + expect(PAGE_COMPOSITOR_CANVAS_ID).toBe("__hf-page-side-compositor"); + }); +}); diff --git a/packages/shader-transitions/src/engineModePageComposite.ts b/packages/shader-transitions/src/engineModePageComposite.ts new file mode 100644 index 000000000..c95eb9ad9 --- /dev/null +++ b/packages/shader-transitions/src/engineModePageComposite.ts @@ -0,0 +1,336 @@ +/** + * engineModePageComposite — page-side WebGL compositor for engine render mode. + * + * Opt-in via `window.__HF_PAGE_SIDE_COMPOSITING__ = true` (set by the producer + * when `EngineConfig.enablePageSideCompositing` is true). When the flag is + * off, hyper-shader's engine-mode path stays on the opacity-flip-only timeline + * and the producer's hf#677 Node-side layered pipeline runs the shader blend. + * + * When the flag is ON: + * + * 1. We install a fullscreen `` overlay + * (z-index above everything; pointer-events:none). + * 2. We wrap `window.__hf.seek` so each seek into a transition window: + * a. Captures the FROM-scene element to a GL texture. + * b. Captures the TO-scene element to a GL texture. + * c. Renders the active transition's fragment shader on the overlay + * canvas with both textures + computed progress + accent colours. + * d. Hides the FROM-scene element's contribution (so the screenshot + * taken by the engine after the seek sees only the GL overlay's + * composited result, not the DOM's own opacity-blended layers). + * Outside a transition window the overlay is hidden and the seek runs + * verbatim — the engine captures the DOM as usual. + * + * The result: the engine takes ONE opaque RGB `Page.captureScreenshot` per + * frame; the shader blend math runs in Chrome's WebGL on the GPU (Metal on + * Mac via CoreAnimation, ANGLE/D3D on Windows, SwiftShader as the software + * fallback on Linux headless). Wall-time savings vs. the Node-side path + * come from (a) eliminating the two per-frame transparent-alpha screenshots + * (one per scene), (b) eliminating the rgb48le ↔ rgba8 transfer-space + * conversion, (c) eliminating the Node-side per-pixel shader-blend loop + * (the worker pool from hf#677 #758). + * + * Determinism note: WebGL shaders execute in f32 on the GPU; the Node-side + * path executes in f64 on the CPU. The two are NOT bit-identical. Fixture + * pins that assume byte-exact MP4 output (the published harness baseline) + * must use the default-off path. PSNR ≥ 50dB pins are the correctness gate + * for the page-side path. + * + * Why the producer can't just take a screenshot of the gl-canvas only: + * the streaming capture path snaps the whole page, which is what we want — + * the overlay sits on top, opaque inside the transition window, fully + * transparent (display:none) outside. The composition's DOM still renders + * the static parts (e.g. background, header chrome) outside transitions. + */ + +import { + createContext, + setupQuad, + createProgram, + createTexture, + uploadTextureSource, + renderShader, + type AccentColors, +} from "./webgl.js"; +import { getFragSource, type ShaderName } from "./shaders/registry.js"; +import { isHtmlInCanvasCaptureSupported } from "./capture.js"; + +// Locally redeclared — see the same pattern in hyper-shader.ts. The package +// must not depend on @hyperframes/engine. +interface PageCompositeTransitionConfig { + time: number; + shader: ShaderName; + duration?: number; +} + +export interface PageCompositorInstallOptions { + scenes: string[]; + transitions: PageCompositeTransitionConfig[]; + bgColor: string; + accentColors: AccentColors; + width: number; + height: number; + /** Default duration in seconds for transitions that don't declare one. */ + defaultDuration: number; +} + +interface ResolvedTransition { + index: number; + time: number; + duration: number; + shader: string; + fromSceneId: string; + toSceneId: string; + prog: WebGLProgram; +} + +/** + * Sentinel id for the engine to recognize that page-side compositing is + * actively running (vs. merely opted in). The presence of this id on the + * page is independent of the active-transition state; the engine doesn't + * need to read it — it's used by tests and by the bundled-CLI canary + * check in the validation script. + */ +export const PAGE_COMPOSITOR_CANVAS_ID = "__hf-page-side-compositor"; + +/** + * Search string the bundled-CLI smoke greps for, to confirm the page-side + * compositor module is present in the shipped bundle (not just the source + * tree). Keep this string in the runtime path so dead-code elimination + * cannot remove it. + */ +export const PAGE_COMPOSITOR_BUILD_CANARY = "__hf_page_compositor_v1__"; + +/** + * Returns true iff this Chromium build exposes the HTML-in-Canvas + * `drawElementImage` API. Re-exported here so the engine-mode wrapper has + * a single import surface; the underlying probe is the existing one in + * `capture.ts` (used by the preview path). + */ +export function isPageSideCompositingSupported(): boolean { + if (typeof window === "undefined" || typeof document === "undefined") return false; + return isHtmlInCanvasCaptureSupported(); +} + +/** + * Install the page-side compositor. Idempotent — second calls are no-ops. + * Returns `true` when installation succeeded, `false` when the Chromium + * runtime does not support the required `drawElementImage` API (caller + * should fall back to opacity-flip mode). + */ +export function installPageSideCompositor(options: PageCompositorInstallOptions): boolean { + // Canary string — kept on the runtime path so the bundle keeps it. + if (typeof window === "undefined") return false; + (window as unknown as { __HF_PAGE_COMPOSITOR_CANARY__?: string }).__HF_PAGE_COMPOSITOR_CANARY__ = + PAGE_COMPOSITOR_BUILD_CANARY; + if (!isPageSideCompositingSupported()) { + // eslint-disable-next-line no-console + console.warn( + "[HyperShader] page-side compositing requested but drawElementImage is not " + + "supported in this Chromium build; falling back to opacity-flip mode " + + "(Node-side layered pipeline will handle the blend).", + ); + return false; + } + if (document.getElementById(PAGE_COMPOSITOR_CANVAS_ID)) return true; + + const { scenes, transitions, accentColors, width, height, defaultDuration } = options; + + // Fullscreen GL canvas overlay. `display:none` by default — only made + // visible while a transition is active. + const glCanvas = document.createElement("canvas"); + glCanvas.id = PAGE_COMPOSITOR_CANVAS_ID; + glCanvas.width = width; + glCanvas.height = height; + glCanvas.style.cssText = + "position:fixed;top:0;left:0;width:100%;height:100%;z-index:2147483646;pointer-events:none;display:none;"; + document.body.appendChild(glCanvas); + + const gl = createContext(glCanvas, width, height); + if (!gl) { + // eslint-disable-next-line no-console + console.warn("[HyperShader] page-side compositor: WebGL context unavailable."); + glCanvas.remove(); + return false; + } + const quadBuf = setupQuad(gl); + + // Pre-compile + cache fragment programs per shader name. Compiling on + // first transition would stall the very first transition frame; the + // engine's deterministic seek loop is sensitive to that. + const programs = new Map(); + for (const t of transitions) { + if (programs.has(t.shader)) continue; + try { + programs.set(t.shader, createProgram(gl, getFragSource(t.shader))); + } catch (err) { + // eslint-disable-next-line no-console + console.warn(`[HyperShader] page-side compositor: failed to compile "${t.shader}":`, err); + } + } + + // Resolve transitions to fully-typed records the seek wrapper consults. + const resolved: ResolvedTransition[] = []; + for (let i = 0; i < transitions.length; i++) { + const t = transitions[i]; + if (!t) continue; + const fromSceneId = scenes[i]; + const toSceneId = scenes[i + 1]; + const prog = programs.get(t.shader); + if (!fromSceneId || !toSceneId || !prog) continue; + resolved.push({ + index: i, + time: t.time, + duration: t.duration ?? defaultDuration, + shader: t.shader, + fromSceneId, + toSceneId, + prog, + }); + } + if (resolved.length === 0) { + glCanvas.remove(); + return false; + } + + // Per-scene background canvases. We capture each scene element to its own + // backing canvas via `drawElementImage`, then upload to a GL texture. + // The two textures persist across frames; only the texture *contents* + // change per frame. + const fromTex = createTexture(gl); + const toTex = createTexture(gl); + const sceneCaptureCanvas = document.createElement("canvas"); + sceneCaptureCanvas.width = width; + sceneCaptureCanvas.height = height; + // Layout-attached canvas (so drawElementImage has live layout to sample). + // Kept off-screen; never inserted into the document layout flow. + const stagingCanvas = document.createElement("canvas") as HTMLCanvasElement & { + layoutSubtree?: boolean; + }; + stagingCanvas.width = width; + stagingCanvas.height = height; + stagingCanvas.setAttribute("layoutsubtree", ""); + stagingCanvas.style.cssText = + "position:fixed;top:0;left:0;width:" + + String(width) + + "px;height:" + + String(height) + + "px;z-index:-9999;pointer-events:none;opacity:0;"; + document.body.appendChild(stagingCanvas); + + type DrawElementImageCtx = CanvasRenderingContext2D & { + drawElementImage: (el: Element, x: number, y: number, w: number, h: number) => void; + }; + + function captureSceneToTexture(sceneEl: HTMLElement, tex: WebGLTexture): boolean { + const ctx = stagingCanvas.getContext("2d") as DrawElementImageCtx | null; + if (!ctx || typeof ctx.drawElementImage !== "function") return false; + ctx.fillStyle = options.bgColor; + ctx.fillRect(0, 0, width, height); + try { + ctx.drawElementImage(sceneEl, 0, 0, width, height); + } catch (err) { + // eslint-disable-next-line no-console + console.warn("[HyperShader] page-side compositor: drawElementImage threw:", err); + return false; + } + uploadTextureSource(gl as WebGLRenderingContext, tex, stagingCanvas); + return true; + } + + function findActive(time: number): ResolvedTransition | null { + for (const t of resolved) { + if (time >= t.time && time <= t.time + t.duration) return t; + } + return null; + } + + // Wrap window.__hf.seek so each seek into a transition window does the + // page-side compose BEFORE returning (the engine's capture awaits the + // seek promise then immediately screenshots). Outside the window we hide + // the overlay and let the engine see the bare DOM. + type HfWindow = Window & { + __hf?: { seek?: (t: number) => unknown }; + }; + const hfWin = window as HfWindow; + const wrapSeek = (): void => { + if (!hfWin.__hf) return; + const originalSeek = hfWin.__hf.seek; + if (typeof originalSeek !== "function") return; + const wrapped = (time: number): unknown => { + // Run the engine's original seek first — populates the DOM at the + // correct timeline position so element computed styles + GSAP-driven + // transforms are valid before we sample. + const result = originalSeek.call(hfWin.__hf, time); + const active = findActive(time); + if (!active) { + glCanvas.style.display = "none"; + return result; + } + const fromEl = document.getElementById(active.fromSceneId); + const toEl = document.getElementById(active.toSceneId); + if (!(fromEl instanceof HTMLElement) || !(toEl instanceof HTMLElement)) { + glCanvas.style.display = "none"; + return result; + } + // The opacity-flip timeline in initEngineMode has set both scenes + // to opacity 1 during the transition window. We need to render + // each one in isolation to texture, then composite via shader. + // Briefly force each scene visible-and-alone during its own capture + // by reading via drawElementImage with the live DOM (drawElementImage + // captures THIS element subtree with its current computed style; + // siblings outside the subtree don't bleed in). + const fromOk = captureSceneToTexture(fromEl, fromTex); + const toOk = captureSceneToTexture(toEl, toTex); + if (!fromOk || !toOk) { + glCanvas.style.display = "none"; + return result; + } + const progress = + active.duration === 0 + ? 1 + : Math.min(1, Math.max(0, (time - active.time) / active.duration)); + renderShader( + gl as WebGLRenderingContext, + quadBuf, + active.prog, + fromTex, + toTex, + progress, + accentColors, + width, + height, + ); + // Hide both DOM scenes during the overlay-visible window so they + // don't double-paint under the screenshot. Original opacity is + // restored on the next out-of-window seek. + fromEl.style.opacity = "0"; + toEl.style.opacity = "0"; + glCanvas.style.display = "block"; + return result; + }; + hfWin.__hf.seek = wrapped; + }; + + // window.__hf.seek is wired up only after the producer's bridge script + // runs (which itself fires only after window.__player is ready). Poll + // for it briefly. Once wrapped, the wrapper is permanent for the page + // lifetime — the engine never re-wraps `seek`. + let attempts = 0; + const ivHandle = window.setInterval(() => { + attempts += 1; + if (hfWin.__hf?.seek) { + wrapSeek(); + window.clearInterval(ivHandle); + } else if (attempts > 200) { + window.clearInterval(ivHandle); + // eslint-disable-next-line no-console + console.warn( + "[HyperShader] page-side compositor: window.__hf.seek never appeared after 10s; " + + "the engine bridge did not initialize. Falling back to opacity-flip mode.", + ); + } + }, 50); + + return true; +} diff --git a/packages/shader-transitions/src/hyper-shader.ts b/packages/shader-transitions/src/hyper-shader.ts index 8bb3171d8..25e97904a 100644 --- a/packages/shader-transitions/src/hyper-shader.ts +++ b/packages/shader-transitions/src/hyper-shader.ts @@ -12,6 +12,7 @@ import { } from "./webgl.js"; import { getFragSource, type ShaderName } from "./shaders/registry.js"; import { initCapture, captureScene } from "./capture.js"; +import { installPageSideCompositor } from "./engineModePageComposite.js"; declare const gsap: { timeline: (opts: Record) => GsapTimeline; @@ -2216,6 +2217,41 @@ function initEngineMode( tl.set(`#${fromId}`, { opacity: 0 }, T + dur); } + // Page-side compositing opt-in (default OFF). When the producer launches + // with `EngineConfig.enablePageSideCompositing: true`, it sets the + // sentinel `window.__HF_PAGE_SIDE_COMPOSITING__` via an early stub. We + // detect it here and install the WebGL-on-page composite path on top of + // the opacity-flip timeline. The opacity timeline still runs (the + // installer wraps `window.__hf.seek` AFTER the timeline runs, so DOM + // state at the sampled time is correct before texture capture) but the + // installed compositor overrides the seek's final visible state during + // each transition window with a single shader-composited overlay canvas. + const pageCompositingFlag = + typeof window !== "undefined" && + Boolean( + (window as unknown as { __HF_PAGE_SIDE_COMPOSITING__?: boolean }) + .__HF_PAGE_SIDE_COMPOSITING__, + ); + if (pageCompositingFlag) { + const bgColor = config.bgColor ?? "#000"; + const accentColors: AccentColors = config.accentColor + ? deriveAccentColors(config.accentColor) + : { accent: [1, 0.6, 0.2], dark: [0.4, 0.15, 0], bright: [1, 0.85, 0.5] }; + const rawW = Number(root?.getAttribute("data-width")); + const rawH = Number(root?.getAttribute("data-height")); + const compWidth = Number.isFinite(rawW) && rawW > 0 ? rawW : 1920; + const compHeight = Number.isFinite(rawH) && rawH > 0 ? rawH : 1080; + installPageSideCompositor({ + scenes, + transitions, + bgColor, + accentColors, + width: compWidth, + height: compHeight, + defaultDuration: DEFAULT_DURATION, + }); + } + registerTimeline(compId, tl, config.timeline); return tl; } diff --git a/packages/shader-transitions/src/index.ts b/packages/shader-transitions/src/index.ts index f7d57f6c4..265427f70 100644 --- a/packages/shader-transitions/src/index.ts +++ b/packages/shader-transitions/src/index.ts @@ -1,3 +1,9 @@ export { init, type HyperShaderConfig, type TransitionConfig } from "./hyper-shader.js"; export { isHtmlInCanvasCaptureSupported } from "./capture.js"; export { SHADER_NAMES, type ShaderName } from "./shaders/registry.js"; +export { + installPageSideCompositor, + isPageSideCompositingSupported, + PAGE_COMPOSITOR_BUILD_CANARY, + PAGE_COMPOSITOR_CANVAS_ID, +} from "./engineModePageComposite.js"; diff --git a/scripts/page-side-compositing-smoke/fixture/index.html b/scripts/page-side-compositing-smoke/fixture/index.html new file mode 100644 index 000000000..4bb40813e --- /dev/null +++ b/scripts/page-side-compositing-smoke/fixture/index.html @@ -0,0 +1,82 @@ + + + + + page-side-compositing-smoke + + + + + + +
+
A
+
B
+
+ + + + diff --git a/scripts/page-side-compositing-smoke/run.mjs b/scripts/page-side-compositing-smoke/run.mjs new file mode 100644 index 000000000..0cfaf3dfc --- /dev/null +++ b/scripts/page-side-compositing-smoke/run.mjs @@ -0,0 +1,167 @@ +#!/usr/bin/env node +/** + * Bundled-CLI smoke for `--page-side-compositing` (opt-in spike). + * + * Validates that: + * 1. The bundled CLI accepts the new flag. + * 2. The local `@hyperframes/shader-transitions` IIFE bundle carries the + * page-side compositor canary string (build is wired correctly). + * 3. Rendering the fixture WITH and WITHOUT the flag both produce valid + * MP4s with the same duration. (Pixel-equality is NOT a correctness + * property here — see the determinism note in the PR body.) + * 4. Wall-time pair is captured for the PR body. + * + * Per `feedback_validate_bundled_cli_not_dev_path.md`: the canonical + * execution path is the BUNDLED CLI at `packages/cli/dist/cli.js`, never + * `bun run` against raw TS sources. Do not "improve" this script to use + * `bun run` — bundle-specific bugs (path resolvers, env bootstrap, lazy + * modules) are invisible to the dev path. + * + * Usage from the repo root: + * + * node scripts/page-side-compositing-smoke/run.mjs + * + * Outputs: + * + * /tmp/hf-page-side-smoke/raw.mp4 (baseline path, flag off) + * /tmp/hf-page-side-smoke/page-side.mp4 (page-side path, flag on) + * /tmp/hf-page-side-smoke/wall-times.json (wall-time pair for the PR) + */ + +import { execFileSync, spawnSync } from "node:child_process"; +import { copyFileSync, existsSync, mkdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(HERE, "..", ".."); +const FIXTURE_SRC = join(HERE, "fixture"); +const WORK_DIR = "/tmp/hf-page-side-smoke"; +const FIXTURE_RUN_DIR = join(WORK_DIR, "fixture"); +const CLI_PATH = join(REPO_ROOT, "packages", "cli", "dist", "cli.js"); +const SHADER_BUNDLE = join(REPO_ROOT, "packages", "shader-transitions", "dist", "index.global.js"); +// Canary string defined in +// `packages/shader-transitions/src/engineModePageComposite.ts` — +// kept identical here on purpose. Test broken → engineModePageComposite.test +// fails first. +const PAGE_COMPOSITOR_CANARY = "__hf_page_compositor_v1__"; + +function note(line) { + process.stdout.write("[smoke] " + line + "\n"); +} + +function fail(msg) { + process.stderr.write("[smoke] FAIL: " + msg + "\n"); + process.exit(1); +} + +function assertExists(path, label) { + if (!existsSync(path)) fail(label + " missing at " + path); +} + +function assertCanary() { + assertExists(SHADER_BUNDLE, "shader-transitions IIFE bundle"); + const buf = execFileSync("grep", ["-c", PAGE_COMPOSITOR_CANARY, SHADER_BUNDLE]); + const count = Number(buf.toString().trim()); + if (count < 1) { + fail( + "shader-transitions bundle is missing the page-side compositor canary " + + `("${PAGE_COMPOSITOR_CANARY}"). Rebuild @hyperframes/shader-transitions and re-run.`, + ); + } + note(`canary present in ${SHADER_BUNDLE} (${count}× hit)`); +} + +function assertCliCanary() { + assertExists(CLI_PATH, "bundled CLI"); + // The CLI bundle should carry the env-var key for HF_PAGE_SIDE_COMPOSITING + // and the page-side flag name. Either confirms the engine + producer + + // CLI side of the change is wired through tsup. + const cliCanaries = ["HF_PAGE_SIDE_COMPOSITING", "page-side-compositing"]; + for (const needle of cliCanaries) { + const buf = execFileSync("grep", ["-c", needle, CLI_PATH]); + const count = Number(buf.toString().trim()); + if (count < 1) { + fail(`bundled CLI is missing canary "${needle}". Rebuild @hyperframes/cli and re-run.`); + } + note(`CLI bundle carries "${needle}" (${count}× hit)`); + } +} + +function setupFixture() { + if (existsSync(WORK_DIR)) rmSync(WORK_DIR, { recursive: true, force: true }); + mkdirSync(FIXTURE_RUN_DIR, { recursive: true }); + copyFileSync(join(FIXTURE_SRC, "index.html"), join(FIXTURE_RUN_DIR, "index.html")); + copyFileSync(SHADER_BUNDLE, join(FIXTURE_RUN_DIR, "shader-transitions.global.js")); + note("fixture staged at " + FIXTURE_RUN_DIR); +} + +function runRender(label, outputPath, extraArgs) { + note(`render: ${label} → ${outputPath}`); + const argv = [ + CLI_PATH, + "render", + FIXTURE_RUN_DIR, + "-o", + outputPath, + "--fps", + "30", + "--workers", + "1", + "--quality", + "draft", + "--quiet", + ...extraArgs, + ]; + const t0 = Date.now(); + const res = spawnSync("node", argv, { stdio: "inherit", cwd: REPO_ROOT }); + const wallMs = Date.now() - t0; + if (res.status !== 0) { + fail(`render "${label}" exited with status ${res.status}`); + } + if (!existsSync(outputPath)) { + fail(`render "${label}" did not produce ${outputPath}`); + } + const size = statSync(outputPath).size; + if (size < 1024) { + fail(`render "${label}" produced suspiciously small output (${size} bytes)`); + } + note(` wall=${(wallMs / 1000).toFixed(2)}s size=${(size / 1024).toFixed(1)}KB`); + return { label, wallMs, sizeBytes: size, outputPath }; +} + +function main() { + note(`repo root: ${REPO_ROOT}`); + assertExists(CLI_PATH, "bundled CLI (packages/cli/dist/cli.js)"); + assertExists(SHADER_BUNDLE, "shader-transitions IIFE bundle"); + assertCliCanary(); + assertCanary(); + setupFixture(); + + const baseline = runRender("baseline (flag OFF)", join(WORK_DIR, "raw.mp4"), []); + const pageSide = runRender("page-side (flag ON)", join(WORK_DIR, "page-side.mp4"), [ + "--page-side-compositing", + ]); + + const summary = { + baseline: { wallMs: baseline.wallMs, sizeBytes: baseline.sizeBytes }, + pageSide: { wallMs: pageSide.wallMs, sizeBytes: pageSide.sizeBytes }, + walltimeRatio: baseline.wallMs / Math.max(1, pageSide.wallMs), + notes: + "fixture: 2s @ 30fps, 1280x720, single cross-warp-morph transition. " + + "Wall times include CLI startup + browser launch — for a perf signal, " + + "amortize over a longer fixture on the target host (Vance's Mac).", + }; + writeFileSync(join(WORK_DIR, "wall-times.json"), JSON.stringify(summary, null, 2)); + note("wrote " + join(WORK_DIR, "wall-times.json")); + note("baseline: " + (baseline.wallMs / 1000).toFixed(2) + "s"); + note("page-side: " + (pageSide.wallMs / 1000).toFixed(2) + "s"); + note( + `ratio (baseline/page-side): ${summary.walltimeRatio.toFixed(2)}× ` + + "(>1 means page-side faster, <1 means slower — sandbox is software " + + "WebGL, so a ratio near 1 here is expected and NOT predictive of Mac).", + ); + note("OK"); +} + +main(); From cc50280424fb4d83493cbb5ca5d1b2d5adf77b40 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 14 May 2026 14:00:37 -0700 Subject: [PATCH 03/17] fix(shader-transitions): use html2canvas for page-side compositor capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original drawElementImage approach fails in engine render mode because the virtual-time shim prevents Chromium from generating paint records for cloned elements. drawElementImage requires a cached paint record from the browser's compositor — clones created at capture time never receive one because (a) shimmed rAFs deadlock inside the seek wrapper, (b) original rAFs don't produce real paints under virtual-time control, and (c) layoutsubtree canvases don't apply CSS stylesheet rules to children. Switch scene capture to html2canvas (foreignObjectRendering: false), the same JS-based renderer already used by the preview-mode fallback path in capture.ts. html2canvas reads computed styles and renders via its own canvas drawing pipeline with no dependency on the browser paint cycle. Also fixes: - Engine seek must return the result so Puppeteer awaits async seek promises (frameCapture.ts). - GSAP opacity cache: compositor must restore scene opacity before seek, not after — GSAP caches inline values and skips re-writes. - Support check gates on WebGL availability, not drawElementImage. Perf: 15-scene shader-perf fixture (28s, 14 transitions, 30fps) Baseline (Node-side layered): 137s Page-side (html2canvas+WebGL): 33s → 4.1× speedup Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/engine/src/services/frameCapture.ts | 2 +- .../src/engineModePageComposite.test.ts | 21 +-- .../src/engineModePageComposite.ts | 171 ++++++++---------- 3 files changed, 83 insertions(+), 111 deletions(-) diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index 656c87057..f4ecfa3ad 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -698,7 +698,7 @@ async function prepareFrameForCapture( // all framework-specific logic (GSAP stepping, CSS animation sync, etc.) await page.evaluate((t: number) => { if (window.__hf && typeof window.__hf.seek === "function") { - window.__hf.seek(t); + return window.__hf.seek(t); } }, quantizedTime); const seekMs = Date.now() - seekStart; diff --git a/packages/shader-transitions/src/engineModePageComposite.test.ts b/packages/shader-transitions/src/engineModePageComposite.test.ts index 05fd8dd98..e9a0e8a32 100644 --- a/packages/shader-transitions/src/engineModePageComposite.test.ts +++ b/packages/shader-transitions/src/engineModePageComposite.test.ts @@ -21,25 +21,26 @@ describe("isPageSideCompositingSupported", () => { expect(isPageSideCompositingSupported()).toBe(false); }); - it("returns true when drawElementImage is exposed", () => { + it("returns true when WebGL is available", () => { vi.stubGlobal("window", {}); vi.stubGlobal("document", { createElement: () => ({ - setAttribute: () => undefined, - layoutSubtree: true, - getContext: () => ({ drawElementImage: () => undefined }), + getContext: (type: string) => + type === "webgl" + ? { + /* mock WebGL context */ + } + : null, }), }); expect(isPageSideCompositingSupported()).toBe(true); }); - it("returns false when drawElementImage is missing", () => { + it("returns false when WebGL is unavailable", () => { vi.stubGlobal("window", {}); vi.stubGlobal("document", { createElement: () => ({ - setAttribute: () => undefined, - layoutSubtree: true, - getContext: () => ({}), + getContext: () => null, }), }); expect(isPageSideCompositingSupported()).toBe(false); @@ -47,10 +48,6 @@ describe("isPageSideCompositingSupported", () => { }); describe("page-side compositor exported constants", () => { - // These constants are load-bearing for the bundled-CLI smoke: the - // validation script greps the shipped bundle for the canary to confirm - // the page-side path is in the production tsup output, not just the - // source tree. it("exports a stable canary string used by the bundled-CLI smoke", () => { expect(PAGE_COMPOSITOR_BUILD_CANARY).toBe("__hf_page_compositor_v1__"); }); diff --git a/packages/shader-transitions/src/engineModePageComposite.ts b/packages/shader-transitions/src/engineModePageComposite.ts index c95eb9ad9..7e4675993 100644 --- a/packages/shader-transitions/src/engineModePageComposite.ts +++ b/packages/shader-transitions/src/engineModePageComposite.ts @@ -11,38 +11,44 @@ * 1. We install a fullscreen `` overlay * (z-index above everything; pointer-events:none). * 2. We wrap `window.__hf.seek` so each seek into a transition window: - * a. Captures the FROM-scene element to a GL texture. - * b. Captures the TO-scene element to a GL texture. + * a. Captures the FROM-scene element to a GL texture via html2canvas. + * b. Captures the TO-scene element to a GL texture via html2canvas. * c. Renders the active transition's fragment shader on the overlay * canvas with both textures + computed progress + accent colours. - * d. Hides the FROM-scene element's contribution (so the screenshot - * taken by the engine after the seek sees only the GL overlay's - * composited result, not the DOM's own opacity-blended layers). + * d. Hides the FROM/TO scene elements (so the screenshot taken by the + * engine after the seek sees only the GL overlay's composited result). * Outside a transition window the overlay is hidden and the seek runs * verbatim — the engine captures the DOM as usual. * * The result: the engine takes ONE opaque RGB `Page.captureScreenshot` per - * frame; the shader blend math runs in Chrome's WebGL on the GPU (Metal on - * Mac via CoreAnimation, ANGLE/D3D on Windows, SwiftShader as the software - * fallback on Linux headless). Wall-time savings vs. the Node-side path - * come from (a) eliminating the two per-frame transparent-alpha screenshots - * (one per scene), (b) eliminating the rgb48le ↔ rgba8 transfer-space - * conversion, (c) eliminating the Node-side per-pixel shader-blend loop - * (the worker pool from hf#677 #758). + * frame; the shader blend math runs in Chrome's WebGL on the GPU. * - * Determinism note: WebGL shaders execute in f32 on the GPU; the Node-side - * path executes in f64 on the CPU. The two are NOT bit-identical. Fixture - * pins that assume byte-exact MP4 output (the published harness baseline) - * must use the default-off path. PSNR ≥ 50dB pins are the correctness gate - * for the page-side path. + * Why html2canvas instead of the preview-path's drawElementImage (capture.ts): * - * Why the producer can't just take a screenshot of the gl-canvas only: - * the streaming capture path snaps the whole page, which is what we want — - * the overlay sits on top, opaque inside the transition window, fully - * transparent (display:none) outside. The composition's DOM still renders - * the static parts (e.g. background, header chrome) outside transitions. + * In engine render mode the virtual-time shim (fileServer.ts) replaces + * requestAnimationFrame with a queue that only flushes during seekToTime(). + * The preview-path capture in capture.ts waits for two real rAFs so the + * browser compositor paints the cloned element before drawElementImage reads + * its paint record. Inside a seek wrapper we can't await shimmed rAFs + * (deadlock — they flush on the *next* seek) and original rAFs don't produce + * paint records under virtual-time control. drawElementImage returns + * "InvalidStateError: No cached paint record" for any element that wasn't + * painted by the browser's own compositor pass — which includes every clone + * we create at capture time. + * + * html2canvas avoids the problem entirely: it clones the DOM, reads computed + * styles, and renders to a canvas using its own JS drawing pipeline with no + * dependency on the browser's paint/compositor cycle. This is the same + * renderer used by the preview-mode fallback path (capture.ts, + * foreignObjectRendering: false). + * + * Determinism note: html2canvas rendering differs slightly from native + * Chromium rendering (text-shadow, gradient antialiasing, sub-pixel). The + * WebGL shader also executes in f32 vs f64 on the Node-side path. Fixture + * pins that assume byte-exact MP4 output must use the default-off path. */ +import html2canvas from "html2canvas"; import { createContext, setupQuad, @@ -53,7 +59,6 @@ import { type AccentColors, } from "./webgl.js"; import { getFragSource, type ShaderName } from "./shaders/registry.js"; -import { isHtmlInCanvasCaptureSupported } from "./capture.js"; // Locally redeclared — see the same pattern in hyper-shader.ts. The package // must not depend on @hyperframes/engine. @@ -102,21 +107,21 @@ export const PAGE_COMPOSITOR_CANVAS_ID = "__hf-page-side-compositor"; export const PAGE_COMPOSITOR_BUILD_CANARY = "__hf_page_compositor_v1__"; /** - * Returns true iff this Chromium build exposes the HTML-in-Canvas - * `drawElementImage` API. Re-exported here so the engine-mode wrapper has - * a single import surface; the underlying probe is the existing one in - * `capture.ts` (used by the preview path). + * Returns true iff the runtime supports page-side compositing. Requires + * a browser environment with WebGL. (html2canvas handles the DOM capture + * without needing drawElementImage.) */ export function isPageSideCompositingSupported(): boolean { if (typeof window === "undefined" || typeof document === "undefined") return false; - return isHtmlInCanvasCaptureSupported(); + const probe = document.createElement("canvas"); + const gl = probe.getContext("webgl") || probe.getContext("experimental-webgl"); + return gl != null; } /** * Install the page-side compositor. Idempotent — second calls are no-ops. - * Returns `true` when installation succeeded, `false` when the Chromium - * runtime does not support the required `drawElementImage` API (caller - * should fall back to opacity-flip mode). + * Returns `true` when installation succeeded, `false` when the runtime + * does not support WebGL (caller should fall back to opacity-flip mode). */ export function installPageSideCompositor(options: PageCompositorInstallOptions): boolean { // Canary string — kept on the runtime path so the bundle keeps it. @@ -126,8 +131,8 @@ export function installPageSideCompositor(options: PageCompositorInstallOptions) if (!isPageSideCompositingSupported()) { // eslint-disable-next-line no-console console.warn( - "[HyperShader] page-side compositing requested but drawElementImage is not " + - "supported in this Chromium build; falling back to opacity-flip mode " + + "[HyperShader] page-side compositing requested but WebGL is not " + + "available; falling back to opacity-flip mode " + "(Node-side layered pipeline will handle the blend).", ); return false; @@ -136,8 +141,6 @@ export function installPageSideCompositor(options: PageCompositorInstallOptions) const { scenes, transitions, accentColors, width, height, defaultDuration } = options; - // Fullscreen GL canvas overlay. `display:none` by default — only made - // visible while a transition is active. const glCanvas = document.createElement("canvas"); glCanvas.id = PAGE_COMPOSITOR_CANVAS_ID; glCanvas.width = width; @@ -155,9 +158,6 @@ export function installPageSideCompositor(options: PageCompositorInstallOptions) } const quadBuf = setupQuad(gl); - // Pre-compile + cache fragment programs per shader name. Compiling on - // first transition would stall the very first transition frame; the - // engine's deterministic seek loop is sensitive to that. const programs = new Map(); for (const t of transitions) { if (programs.has(t.shader)) continue; @@ -169,7 +169,6 @@ export function installPageSideCompositor(options: PageCompositorInstallOptions) } } - // Resolve transitions to fully-typed records the seek wrapper consults. const resolved: ResolvedTransition[] = []; for (let i = 0; i < transitions.length; i++) { const t = transitions[i]; @@ -193,49 +192,28 @@ export function installPageSideCompositor(options: PageCompositorInstallOptions) return false; } - // Per-scene background canvases. We capture each scene element to its own - // backing canvas via `drawElementImage`, then upload to a GL texture. - // The two textures persist across frames; only the texture *contents* - // change per frame. const fromTex = createTexture(gl); const toTex = createTexture(gl); - const sceneCaptureCanvas = document.createElement("canvas"); - sceneCaptureCanvas.width = width; - sceneCaptureCanvas.height = height; - // Layout-attached canvas (so drawElementImage has live layout to sample). - // Kept off-screen; never inserted into the document layout flow. - const stagingCanvas = document.createElement("canvas") as HTMLCanvasElement & { - layoutSubtree?: boolean; - }; - stagingCanvas.width = width; - stagingCanvas.height = height; - stagingCanvas.setAttribute("layoutsubtree", ""); - stagingCanvas.style.cssText = - "position:fixed;top:0;left:0;width:" + - String(width) + - "px;height:" + - String(height) + - "px;z-index:-9999;pointer-events:none;opacity:0;"; - document.body.appendChild(stagingCanvas); - - type DrawElementImageCtx = CanvasRenderingContext2D & { - drawElementImage: (el: Element, x: number, y: number, w: number, h: number) => void; - }; - function captureSceneToTexture(sceneEl: HTMLElement, tex: WebGLTexture): boolean { - const ctx = stagingCanvas.getContext("2d") as DrawElementImageCtx | null; - if (!ctx || typeof ctx.drawElementImage !== "function") return false; - ctx.fillStyle = options.bgColor; - ctx.fillRect(0, 0, width, height); + async function captureSceneToTexture(sceneEl: HTMLElement, tex: WebGLTexture): Promise { try { - ctx.drawElementImage(sceneEl, 0, 0, width, height); + const canvas = await html2canvas(sceneEl, { + width, + height, + scale: 1, + backgroundColor: options.bgColor, + logging: false, + foreignObjectRendering: false, + useCORS: true, + allowTaint: true, + }); + uploadTextureSource(gl as WebGLRenderingContext, tex, canvas); + return true; } catch (err) { // eslint-disable-next-line no-console - console.warn("[HyperShader] page-side compositor: drawElementImage threw:", err); + console.warn("[HyperShader] page-side compositor: scene capture failed:", err); return false; } - uploadTextureSource(gl as WebGLRenderingContext, tex, stagingCanvas); - return true; } function findActive(time: number): ResolvedTransition | null { @@ -245,10 +223,8 @@ export function installPageSideCompositor(options: PageCompositorInstallOptions) return null; } - // Wrap window.__hf.seek so each seek into a transition window does the - // page-side compose BEFORE returning (the engine's capture awaits the - // seek promise then immediately screenshots). Outside the window we hide - // the overlay and let the engine see the bare DOM. + // The seek wrapper returns a Promise so the engine's page.evaluate (which + // now `return`s the seek result) awaits compositing before screenshotting. type HfWindow = Window & { __hf?: { seek?: (t: number) => unknown }; }; @@ -257,10 +233,21 @@ export function installPageSideCompositor(options: PageCompositorInstallOptions) if (!hfWin.__hf) return; const originalSeek = hfWin.__hf.seek; if (typeof originalSeek !== "function") return; - const wrapped = (time: number): unknown => { - // Run the engine's original seek first — populates the DOM at the - // correct timeline position so element computed styles + GSAP-driven - // transforms are valid before we sample. + let prevFromEl: HTMLElement | null = null; + let prevToEl: HTMLElement | null = null; + const wrapped = async (time: number): Promise => { + // Restore opacity on scenes hidden by the previous frame's compositor + // pass BEFORE running GSAP seek — GSAP caches inline values and won't + // re-write opacity if it thinks the value hasn't changed. + if (prevFromEl) { + prevFromEl.style.opacity = ""; + prevFromEl = null; + } + if (prevToEl) { + prevToEl.style.opacity = ""; + prevToEl = null; + } + const result = originalSeek.call(hfWin.__hf, time); const active = findActive(time); if (!active) { @@ -273,15 +260,8 @@ export function installPageSideCompositor(options: PageCompositorInstallOptions) glCanvas.style.display = "none"; return result; } - // The opacity-flip timeline in initEngineMode has set both scenes - // to opacity 1 during the transition window. We need to render - // each one in isolation to texture, then composite via shader. - // Briefly force each scene visible-and-alone during its own capture - // by reading via drawElementImage with the live DOM (drawElementImage - // captures THIS element subtree with its current computed style; - // siblings outside the subtree don't bleed in). - const fromOk = captureSceneToTexture(fromEl, fromTex); - const toOk = captureSceneToTexture(toEl, toTex); + const fromOk = await captureSceneToTexture(fromEl, fromTex); + const toOk = await captureSceneToTexture(toEl, toTex); if (!fromOk || !toOk) { glCanvas.style.display = "none"; return result; @@ -301,21 +281,16 @@ export function installPageSideCompositor(options: PageCompositorInstallOptions) width, height, ); - // Hide both DOM scenes during the overlay-visible window so they - // don't double-paint under the screenshot. Original opacity is - // restored on the next out-of-window seek. fromEl.style.opacity = "0"; toEl.style.opacity = "0"; + prevFromEl = fromEl; + prevToEl = toEl; glCanvas.style.display = "block"; return result; }; hfWin.__hf.seek = wrapped; }; - // window.__hf.seek is wired up only after the producer's bridge script - // runs (which itself fires only after window.__player is ready). Poll - // for it briefly. Once wrapped, the wrapper is permanent for the page - // lifetime — the engine never re-wraps `seek`. let attempts = 0; const ivHandle = window.setInterval(() => { attempts += 1; From 395ff0254d19047a6ea3bd3dd60565b58cea5395 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 14 May 2026 14:11:41 -0700 Subject: [PATCH 04/17] refactor(shader-transitions): simplify review fixes for page-side compositor - Use uploadTexture (zeroes canvas backing store after upload) to prevent ~2.2GB transient memory pressure across 280 html2canvas calls per render - Add ignoreElements + stabilizeTransformedBoxShadows to html2canvas call, matching the preview-path capture.ts behavior - Parallelize from/to scene captures with Promise.all - Wrap post-capture render in try/finally so opacity is always restored - Fix WebGL context leak in isPageSideCompositingSupported probe - Remove dead ResolvedTransition.index field - Export stabilizeTransformedBoxShadows from capture.ts Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/shader-transitions/src/capture.ts | 2 +- .../src/engineModePageComposite.test.ts | 6 +- .../src/engineModePageComposite.ts | 65 +++++++++++-------- 3 files changed, 40 insertions(+), 33 deletions(-) diff --git a/packages/shader-transitions/src/capture.ts b/packages/shader-transitions/src/capture.ts index 08aa82caf..8ae834c99 100644 --- a/packages/shader-transitions/src/capture.ts +++ b/packages/shader-transitions/src/capture.ts @@ -61,7 +61,7 @@ function forceSceneVisibleInClone(source: HTMLElement, cloneDoc: Document): void }); } -function stabilizeTransformedBoxShadows(root: HTMLElement): void { +export function stabilizeTransformedBoxShadows(root: HTMLElement): void { const view = root.ownerDocument.defaultView; if (!view) return; diff --git a/packages/shader-transitions/src/engineModePageComposite.test.ts b/packages/shader-transitions/src/engineModePageComposite.test.ts index e9a0e8a32..263c90206 100644 --- a/packages/shader-transitions/src/engineModePageComposite.test.ts +++ b/packages/shader-transitions/src/engineModePageComposite.test.ts @@ -26,11 +26,7 @@ describe("isPageSideCompositingSupported", () => { vi.stubGlobal("document", { createElement: () => ({ getContext: (type: string) => - type === "webgl" - ? { - /* mock WebGL context */ - } - : null, + type === "webgl" ? { getExtension: () => ({ loseContext: () => undefined }) } : null, }), }); expect(isPageSideCompositingSupported()).toBe(true); diff --git a/packages/shader-transitions/src/engineModePageComposite.ts b/packages/shader-transitions/src/engineModePageComposite.ts index 7e4675993..5f1502d0e 100644 --- a/packages/shader-transitions/src/engineModePageComposite.ts +++ b/packages/shader-transitions/src/engineModePageComposite.ts @@ -54,11 +54,12 @@ import { setupQuad, createProgram, createTexture, - uploadTextureSource, + uploadTexture, renderShader, type AccentColors, } from "./webgl.js"; import { getFragSource, type ShaderName } from "./shaders/registry.js"; +import { stabilizeTransformedBoxShadows } from "./capture.js"; // Locally redeclared — see the same pattern in hyper-shader.ts. The package // must not depend on @hyperframes/engine. @@ -80,7 +81,6 @@ export interface PageCompositorInstallOptions { } interface ResolvedTransition { - index: number; time: number; duration: number; shader: string; @@ -115,7 +115,9 @@ export function isPageSideCompositingSupported(): boolean { if (typeof window === "undefined" || typeof document === "undefined") return false; const probe = document.createElement("canvas"); const gl = probe.getContext("webgl") || probe.getContext("experimental-webgl"); - return gl != null; + if (!gl) return false; + (gl as WebGLRenderingContext).getExtension("WEBGL_lose_context")?.loseContext(); + return true; } /** @@ -178,7 +180,6 @@ export function installPageSideCompositor(options: PageCompositorInstallOptions) const prog = programs.get(t.shader); if (!fromSceneId || !toSceneId || !prog) continue; resolved.push({ - index: i, time: t.time, duration: t.duration ?? defaultDuration, shader: t.shader, @@ -206,8 +207,13 @@ export function installPageSideCompositor(options: PageCompositorInstallOptions) foreignObjectRendering: false, useCORS: true, allowTaint: true, + onclone: (_doc, clone) => { + if (clone instanceof HTMLElement) stabilizeTransformedBoxShadows(clone); + }, + ignoreElements: (el: Element) => + el.tagName === "CANVAS" || el.hasAttribute("data-no-capture"), }); - uploadTextureSource(gl as WebGLRenderingContext, tex, canvas); + uploadTexture(gl as WebGLRenderingContext, tex, canvas); return true; } catch (err) { // eslint-disable-next-line no-console @@ -260,32 +266,37 @@ export function installPageSideCompositor(options: PageCompositorInstallOptions) glCanvas.style.display = "none"; return result; } - const fromOk = await captureSceneToTexture(fromEl, fromTex); - const toOk = await captureSceneToTexture(toEl, toTex); + const [fromOk, toOk] = await Promise.all([ + captureSceneToTexture(fromEl, fromTex), + captureSceneToTexture(toEl, toTex), + ]); if (!fromOk || !toOk) { glCanvas.style.display = "none"; return result; } - const progress = - active.duration === 0 - ? 1 - : Math.min(1, Math.max(0, (time - active.time) / active.duration)); - renderShader( - gl as WebGLRenderingContext, - quadBuf, - active.prog, - fromTex, - toTex, - progress, - accentColors, - width, - height, - ); - fromEl.style.opacity = "0"; - toEl.style.opacity = "0"; - prevFromEl = fromEl; - prevToEl = toEl; - glCanvas.style.display = "block"; + try { + const progress = + active.duration === 0 + ? 1 + : Math.min(1, Math.max(0, (time - active.time) / active.duration)); + renderShader( + gl as WebGLRenderingContext, + quadBuf, + active.prog, + fromTex, + toTex, + progress, + accentColors, + width, + height, + ); + glCanvas.style.display = "block"; + } finally { + fromEl.style.opacity = "0"; + toEl.style.opacity = "0"; + prevFromEl = fromEl; + prevToEl = toEl; + } return result; }; hfWin.__hf.seek = wrapped; From 1b71568c10d14d33c0bb6ac4df0d9d0c535ccdcd Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 14 May 2026 14:59:48 -0700 Subject: [PATCH 05/17] fix(producer): unify page-side compositing gating and Docker forwarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses three issues from staff review: 1. ignoreElements filter stripped all in-scene canvases (Chart.js, D3, p5.js) — narrowed to data-no-capture only since the compositor canvas is a body sibling never in the scene subtree. 2. Docker mode silently dropped --page-side-compositing — thread pageSideCompositing through DockerRenderOptions/buildDockerRunArgs with regression tests. 3. Fragmented gating across 4 independent sites could disagree: - Stub injection gated only on cfg flag (leaked into HDR/alpha) - Probe-created fileServer never got the stub - needsAlpha (WebM/MOV) not excluded from the gate - WebGL-unavailable fallback claimed layered path would run but orchestrator had already disabled it Fix: compute stub injection at the same site as the layered-bypass decision (after hasHdrContent is known), using addPreHeadScript on the already-running fileServer. Single predicate now gates both decisions, including !needsAlpha. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/commands/render.ts | 3 +++ packages/cli/src/utils/dockerRunArgs.test.ts | 13 +++++++++++++ packages/cli/src/utils/dockerRunArgs.ts | 2 ++ packages/producer/src/services/fileServer.ts | 4 ++++ .../producer/src/services/renderOrchestrator.ts | 17 +++++++---------- .../src/engineModePageComposite.ts | 3 +-- 6 files changed, 30 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 3dfc727e7..b5e75b579 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -558,6 +558,7 @@ export default defineCommand({ variables, entryFile, outputResolution, + pageSideCompositing: !!args["page-side-compositing"], exitAfterComplete: true, }); } else { @@ -604,6 +605,7 @@ interface RenderOptions { exitAfterComplete?: boolean; /** Output resolution preset; see `resolveDeviceScaleFactor` for constraints. */ outputResolution?: CanvasResolution; + pageSideCompositing?: boolean; } export type VariablesParseError = @@ -898,6 +900,7 @@ async function renderDocker( variables: options.variables, entryFile: options.entryFile, outputResolution: options.outputResolution, + pageSideCompositing: options.pageSideCompositing, }, }); diff --git a/packages/cli/src/utils/dockerRunArgs.test.ts b/packages/cli/src/utils/dockerRunArgs.test.ts index 8b8e09e9d..d4e0788e7 100644 --- a/packages/cli/src/utils/dockerRunArgs.test.ts +++ b/packages/cli/src/utils/dockerRunArgs.test.ts @@ -277,4 +277,17 @@ describe("buildDockerRunArgs", () => { const args = buildDockerRunArgs({ ...FIXED_INPUT, options: BASE }); expect(args).not.toContain("--resolution"); }); + + it("forwards --page-side-compositing when pageSideCompositing is true", () => { + const args = buildDockerRunArgs({ + ...FIXED_INPUT, + options: { ...BASE, pageSideCompositing: true }, + }); + expect(args).toContain("--page-side-compositing"); + }); + + it("omits --page-side-compositing when pageSideCompositing is not set", () => { + const args = buildDockerRunArgs({ ...FIXED_INPUT, options: BASE }); + expect(args).not.toContain("--page-side-compositing"); + }); }); diff --git a/packages/cli/src/utils/dockerRunArgs.ts b/packages/cli/src/utils/dockerRunArgs.ts index 986a75034..a9b357644 100644 --- a/packages/cli/src/utils/dockerRunArgs.ts +++ b/packages/cli/src/utils/dockerRunArgs.ts @@ -41,6 +41,7 @@ export interface DockerRenderOptions { entryFile?: string; /** Output resolution preset (e.g. "landscape-4k"). Forwarded as `--resolution`. */ outputResolution?: string; + pageSideCompositing?: boolean; } export function buildDockerRunArgs(input: DockerRunArgsInput): string[] { @@ -80,5 +81,6 @@ export function buildDockerRunArgs(input: DockerRunArgsInput): string[] { : []), ...(options.entryFile ? ["--composition", options.entryFile] : []), ...(options.outputResolution ? ["--resolution", options.outputResolution] : []), + ...(options.pageSideCompositing ? ["--page-side-compositing"] : []), ]; } diff --git a/packages/producer/src/services/fileServer.ts b/packages/producer/src/services/fileServer.ts index d5597f58a..8362f52c6 100644 --- a/packages/producer/src/services/fileServer.ts +++ b/packages/producer/src/services/fileServer.ts @@ -548,6 +548,7 @@ export interface FileServerHandle { url: string; port: number; close: () => void; + addPreHeadScript: (script: string) => void; } export function createFileServer(options: FileServerOptions): Promise { @@ -654,6 +655,9 @@ export function createFileServer(options: FileServerOptions): Promise { + preHeadScripts.push(script); + }, close: () => { for (const socket of connections) socket.destroy(); connections.clear(); diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index 5b56b6203..3a8ee69f1 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -1623,20 +1623,15 @@ export async function executeRenderJob( const stage4Start = Date.now(); updateJobStatus(job, "rendering", "Starting frame capture", 25, onProgress); - // Start file server (may already be running from duration discovery) + // Start file server (may already be running from duration discovery). + // The page-side compositing stub is injected later (after hasHdrContent + // is known) via addPreHeadScript — see usePageSideCompositingForTransitions. if (!fileServer) { - // Inject the page-side compositing opt-in flag stub BEFORE the virtual - // time shim when the engine config opts in. The shim itself does not - // depend on the flag; ordering is arbitrary but stable. - const preHeadScripts: string[] = [VIRTUAL_TIME_SHIM]; - if (cfg.enablePageSideCompositing) { - preHeadScripts.unshift(HF_PAGE_SIDE_COMPOSITING_STUB); - } fileServer = await createFileServer({ projectDir, compiledDir: join(workDir, "compiled"), port: 0, - preHeadScripts, + preHeadScripts: [VIRTUAL_TIME_SHIM], }); assertNotAborted(); } @@ -1766,8 +1761,10 @@ export async function executeRenderJob( cfg.enablePageSideCompositing && compiled.hasShaderTransitions && !hasHdrContent && - !isPngSequence; + !isPngSequence && + !needsAlpha; if (usePageSideCompositingForTransitions) { + fileServer.addPreHeadScript(HF_PAGE_SIDE_COMPOSITING_STUB); log.info( "[Render] Page-side compositing enabled — bypassing Node-side layered " + "shader-blend path. Engine will capture one opaque RGB frame per output frame.", diff --git a/packages/shader-transitions/src/engineModePageComposite.ts b/packages/shader-transitions/src/engineModePageComposite.ts index 5f1502d0e..f1a439bb4 100644 --- a/packages/shader-transitions/src/engineModePageComposite.ts +++ b/packages/shader-transitions/src/engineModePageComposite.ts @@ -210,8 +210,7 @@ export function installPageSideCompositor(options: PageCompositorInstallOptions) onclone: (_doc, clone) => { if (clone instanceof HTMLElement) stabilizeTransformedBoxShadows(clone); }, - ignoreElements: (el: Element) => - el.tagName === "CANVAS" || el.hasAttribute("data-no-capture"), + ignoreElements: (el: Element) => el.hasAttribute("data-no-capture"), }); uploadTexture(gl as WebGLRenderingContext, tex, canvas); return true; From 777a014bd5aad83450005f4ced9d7e74145b140e Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 14 May 2026 16:02:35 -0700 Subject: [PATCH 06/17] perf(engine): two-phase drawElementImage capture for page-side compositing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace html2canvas with native drawElementImage for scene capture in the page-side compositor. drawElementImage reads from the browser's own paint cache, giving pixel-identical output to the preview path. The blocker was that cloned elements inside layoutsubtree canvases have no cached paint record under virtual time — the compositor only paints when explicitly triggered. Fix: split the seek+composite into two phases with an engine-forced paint between them. Phase 1 (seek wrapper, page-side): - GSAP seek positions the timeline - Clone FROM/TO scenes into visible layoutsubtree staging canvases - Set window.__hf_page_composite_pending flag Engine paint force (frameCapture.ts): - Detect pending flag after seek returns - Fire micro Page.captureScreenshot (1x1 clip) via CDP to force the browser compositor to paint all visible elements including staging canvas children Phase 2 (page.evaluate, page-side): - drawElementImage reads the now-valid paint records - Upload textures to WebGL, run shader, show GL overlay Key insight: staging canvases must be visible (not opacity:0) for the browser to paint their children. They sit at z-index:-9998, behind the main DOM and covered by the GL overlay during transitions. Perf: 15-scene fixture (28s, 14 transitions, 30fps): Baseline (Node-side layered): 137s html2canvas + WebGL: 33s (3.7×) drawElementImage + WebGL: 21s (6.6×) Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/engine/src/services/frameCapture.ts | 24 ++ .../src/engineModePageComposite.test.ts | 34 ++- .../src/engineModePageComposite.ts | 279 +++++++++--------- 3 files changed, 198 insertions(+), 139 deletions(-) diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index f4ecfa3ad..4ffbcef8b 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -701,6 +701,30 @@ async function prepareFrameForCapture( return window.__hf.seek(t); } }, quantizedTime); + + // Page-side compositing two-phase protocol: if the seek wrapper set up + // staging canvases with cloned scenes, force the browser to paint them + // via a micro-screenshot, then call the page-side resolve function to + // run drawElementImage + shader composite. + const hasPending = await page.evaluate( + () => + !!(window as unknown as { __hf_page_composite_pending?: boolean }) + .__hf_page_composite_pending, + ); + if (hasPending) { + const cdp = await getCdpSession(page); + await cdp.send("Page.captureScreenshot", { + format: "jpeg", + quality: 1, + clip: { x: 0, y: 0, width: 1, height: 1, scale: 1 }, + }); + await page.evaluate(() => { + const w = window as unknown as { __hf_page_composite_resolve?: () => boolean }; + if (typeof w.__hf_page_composite_resolve === "function") { + w.__hf_page_composite_resolve(); + } + }); + } const seekMs = Date.now() - seekStart; // Before-capture hook (e.g. video frame injection) diff --git a/packages/shader-transitions/src/engineModePageComposite.test.ts b/packages/shader-transitions/src/engineModePageComposite.test.ts index 263c90206..f812db742 100644 --- a/packages/shader-transitions/src/engineModePageComposite.test.ts +++ b/packages/shader-transitions/src/engineModePageComposite.test.ts @@ -21,22 +21,48 @@ describe("isPageSideCompositingSupported", () => { expect(isPageSideCompositingSupported()).toBe(false); }); - it("returns true when WebGL is available", () => { + it("returns true when drawElementImage and WebGL are both available", () => { + vi.stubGlobal("window", {}); + vi.stubGlobal("document", { + createElement: (tag: string) => { + if (tag === "canvas") { + return { + setAttribute: () => undefined, + layoutSubtree: true, + getContext: (type: string) => { + if (type === "2d") return { drawElementImage: () => undefined }; + if (type === "webgl") + return { getExtension: () => ({ loseContext: () => undefined }) }; + return null; + }, + }; + } + return {}; + }, + }); + expect(isPageSideCompositingSupported()).toBe(true); + }); + + it("returns false when drawElementImage is missing", () => { vi.stubGlobal("window", {}); vi.stubGlobal("document", { createElement: () => ({ + setAttribute: () => undefined, getContext: (type: string) => - type === "webgl" ? { getExtension: () => ({ loseContext: () => undefined }) } : null, + type === "webgl" ? { getExtension: () => ({ loseContext: () => undefined }) } : {}, }), }); - expect(isPageSideCompositingSupported()).toBe(true); + expect(isPageSideCompositingSupported()).toBe(false); }); it("returns false when WebGL is unavailable", () => { vi.stubGlobal("window", {}); vi.stubGlobal("document", { createElement: () => ({ - getContext: () => null, + setAttribute: () => undefined, + layoutSubtree: true, + getContext: (type: string) => + type === "2d" ? { drawElementImage: () => undefined } : null, }), }); expect(isPageSideCompositingSupported()).toBe(false); diff --git a/packages/shader-transitions/src/engineModePageComposite.ts b/packages/shader-transitions/src/engineModePageComposite.ts index f1a439bb4..7b8b8cd0d 100644 --- a/packages/shader-transitions/src/engineModePageComposite.ts +++ b/packages/shader-transitions/src/engineModePageComposite.ts @@ -6,49 +6,28 @@ * off, hyper-shader's engine-mode path stays on the opacity-flip-only timeline * and the producer's hf#677 Node-side layered pipeline runs the shader blend. * - * When the flag is ON: + * Two-phase capture protocol: * - * 1. We install a fullscreen `` overlay - * (z-index above everything; pointer-events:none). - * 2. We wrap `window.__hf.seek` so each seek into a transition window: - * a. Captures the FROM-scene element to a GL texture via html2canvas. - * b. Captures the TO-scene element to a GL texture via html2canvas. - * c. Renders the active transition's fragment shader on the overlay - * canvas with both textures + computed progress + accent colours. - * d. Hides the FROM/TO scene elements (so the screenshot taken by the - * engine after the seek sees only the GL overlay's composited result). - * Outside a transition window the overlay is hidden and the seek runs - * verbatim — the engine captures the DOM as usual. + * Phase 1 (seek wrapper, runs inside page.evaluate): + * - Runs original GSAP seek to position the timeline + * - If inside a transition window, clones FROM/TO scene elements into + * layoutsubtree staging canvases + * - Sets window.__hf_page_composite_pending with transition metadata + * - Returns immediately (seek resolves) * - * The result: the engine takes ONE opaque RGB `Page.captureScreenshot` per - * frame; the shader blend math runs in Chrome's WebGL on the GPU. + * Paint force (engine-side, frameCapture.ts): + * - Engine detects the pending flag and fires a micro Page.captureScreenshot + * to force the browser compositor to paint the staging canvas clones * - * Why html2canvas instead of the preview-path's drawElementImage (capture.ts): + * Phase 2 (engine calls window.__hf_page_composite_resolve): + * - drawElementImage reads the now-valid paint records from the clones + * - Uploads textures to WebGL, runs the shader, shows the GL overlay + * - Cleans up staging canvases * - * In engine render mode the virtual-time shim (fileServer.ts) replaces - * requestAnimationFrame with a queue that only flushes during seekToTime(). - * The preview-path capture in capture.ts waits for two real rAFs so the - * browser compositor paints the cloned element before drawElementImage reads - * its paint record. Inside a seek wrapper we can't await shimmed rAFs - * (deadlock — they flush on the *next* seek) and original rAFs don't produce - * paint records under virtual-time control. drawElementImage returns - * "InvalidStateError: No cached paint record" for any element that wasn't - * painted by the browser's own compositor pass — which includes every clone - * we create at capture time. - * - * html2canvas avoids the problem entirely: it clones the DOM, reads computed - * styles, and renders to a canvas using its own JS drawing pipeline with no - * dependency on the browser's paint/compositor cycle. This is the same - * renderer used by the preview-mode fallback path (capture.ts, - * foreignObjectRendering: false). - * - * Determinism note: html2canvas rendering differs slightly from native - * Chromium rendering (text-shadow, gradient antialiasing, sub-pixel). The - * WebGL shader also executes in f32 vs f64 on the Node-side path. Fixture - * pins that assume byte-exact MP4 output must use the default-off path. + * This gives native-fidelity capture (identical to preview-path + * drawElementImage) without depending on requestAnimationFrame for paint. */ -import html2canvas from "html2canvas"; import { createContext, setupQuad, @@ -59,10 +38,8 @@ import { type AccentColors, } from "./webgl.js"; import { getFragSource, type ShaderName } from "./shaders/registry.js"; -import { stabilizeTransformedBoxShadows } from "./capture.js"; +import { isHtmlInCanvasCaptureSupported } from "./capture.js"; -// Locally redeclared — see the same pattern in hyper-shader.ts. The package -// must not depend on @hyperframes/engine. interface PageCompositeTransitionConfig { time: number; shader: ShaderName; @@ -76,7 +53,6 @@ export interface PageCompositorInstallOptions { accentColors: AccentColors; width: number; height: number; - /** Default duration in seconds for transitions that don't declare one. */ defaultDuration: number; } @@ -89,30 +65,12 @@ interface ResolvedTransition { prog: WebGLProgram; } -/** - * Sentinel id for the engine to recognize that page-side compositing is - * actively running (vs. merely opted in). The presence of this id on the - * page is independent of the active-transition state; the engine doesn't - * need to read it — it's used by tests and by the bundled-CLI canary - * check in the validation script. - */ export const PAGE_COMPOSITOR_CANVAS_ID = "__hf-page-side-compositor"; - -/** - * Search string the bundled-CLI smoke greps for, to confirm the page-side - * compositor module is present in the shipped bundle (not just the source - * tree). Keep this string in the runtime path so dead-code elimination - * cannot remove it. - */ export const PAGE_COMPOSITOR_BUILD_CANARY = "__hf_page_compositor_v1__"; -/** - * Returns true iff the runtime supports page-side compositing. Requires - * a browser environment with WebGL. (html2canvas handles the DOM capture - * without needing drawElementImage.) - */ export function isPageSideCompositingSupported(): boolean { if (typeof window === "undefined" || typeof document === "undefined") return false; + if (!isHtmlInCanvasCaptureSupported()) return false; const probe = document.createElement("canvas"); const gl = probe.getContext("webgl") || probe.getContext("experimental-webgl"); if (!gl) return false; @@ -120,20 +78,14 @@ export function isPageSideCompositingSupported(): boolean { return true; } -/** - * Install the page-side compositor. Idempotent — second calls are no-ops. - * Returns `true` when installation succeeded, `false` when the runtime - * does not support WebGL (caller should fall back to opacity-flip mode). - */ export function installPageSideCompositor(options: PageCompositorInstallOptions): boolean { - // Canary string — kept on the runtime path so the bundle keeps it. if (typeof window === "undefined") return false; (window as unknown as { __HF_PAGE_COMPOSITOR_CANARY__?: string }).__HF_PAGE_COMPOSITOR_CANARY__ = PAGE_COMPOSITOR_BUILD_CANARY; if (!isPageSideCompositingSupported()) { // eslint-disable-next-line no-console console.warn( - "[HyperShader] page-side compositing requested but WebGL is not " + + "[HyperShader] page-side compositing requested but drawElementImage/WebGL is not " + "available; falling back to opacity-flip mode " + "(Node-side layered pipeline will handle the blend).", ); @@ -196,29 +148,29 @@ export function installPageSideCompositor(options: PageCompositorInstallOptions) const fromTex = createTexture(gl); const toTex = createTexture(gl); - async function captureSceneToTexture(sceneEl: HTMLElement, tex: WebGLTexture): Promise { - try { - const canvas = await html2canvas(sceneEl, { - width, - height, - scale: 1, - backgroundColor: options.bgColor, - logging: false, - foreignObjectRendering: false, - useCORS: true, - allowTaint: true, - onclone: (_doc, clone) => { - if (clone instanceof HTMLElement) stabilizeTransformedBoxShadows(clone); - }, - ignoreElements: (el: Element) => el.hasAttribute("data-no-capture"), - }); - uploadTexture(gl as WebGLRenderingContext, tex, canvas); - return true; - } catch (err) { - // eslint-disable-next-line no-console - console.warn("[HyperShader] page-side compositor: scene capture failed:", err); - return false; - } + type DrawElementImageCtx = CanvasRenderingContext2D & { + drawElementImage: (el: Element, x: number, y: number, w: number, h: number) => void; + }; + + interface StagingCanvas extends HTMLCanvasElement { + layoutSubtree?: boolean; + } + + // Persistent staging canvases — children are swapped per transition frame. + // Kept in the DOM so the compositor paints them on the next frame. + const fromStaging = document.createElement("canvas") as StagingCanvas; + const toStaging = document.createElement("canvas") as StagingCanvas; + for (const s of [fromStaging, toStaging]) { + s.width = width; + s.height = height; + s.setAttribute("layoutsubtree", ""); + s.style.cssText = + "position:fixed;top:0;left:0;width:" + + width + + "px;height:" + + height + + "px;z-index:-9998;pointer-events:none;"; + document.body.appendChild(s); } function findActive(time: number): ResolvedTransition | null { @@ -228,8 +180,82 @@ export function installPageSideCompositor(options: PageCompositorInstallOptions) return null; } - // The seek wrapper returns a Promise so the engine's page.evaluate (which - // now `return`s the seek result) awaits compositing before screenshotting. + // ── Phase 2: resolve the pending composite after engine forces paint ── + + type PendingWindow = Window & { + __hf_page_composite_pending?: boolean; + __hf_page_composite_resolve?: () => boolean; + }; + const pWin = window as PendingWindow; + + function resolveComposite(): boolean { + const active = currentActive; + if (!active) return false; + const fromChild = fromStaging.firstElementChild; + const toChild = toStaging.firstElementChild; + if (!fromChild || !toChild) return false; + + const fromCtx = fromStaging.getContext("2d") as DrawElementImageCtx | null; + const toCtx = toStaging.getContext("2d") as DrawElementImageCtx | null; + if (!fromCtx?.drawElementImage || !toCtx?.drawElementImage) return false; + + try { + fromCtx.fillStyle = options.bgColor; + fromCtx.fillRect(0, 0, width, height); + fromCtx.drawElementImage(fromChild, 0, 0, width, height); + + toCtx.fillStyle = options.bgColor; + toCtx.fillRect(0, 0, width, height); + toCtx.drawElementImage(toChild, 0, 0, width, height); + } catch (err) { + // eslint-disable-next-line no-console + console.warn("[HyperShader] page-side compositor: drawElementImage failed:", err); + return false; + } + + uploadTexture(gl as WebGLRenderingContext, fromTex, fromStaging); + uploadTexture(gl as WebGLRenderingContext, toTex, toStaging); + + // uploadTexture zeroes canvas dimensions — restore for next frame + fromStaging.width = width; + fromStaging.height = height; + toStaging.width = width; + toStaging.height = height; + + try { + renderShader( + gl as WebGLRenderingContext, + quadBuf, + active.prog, + fromTex, + toTex, + currentProgress, + accentColors, + width, + height, + ); + glCanvas.style.display = "block"; + } finally { + const fromEl = document.getElementById(active.fromSceneId); + const toEl = document.getElementById(active.toSceneId); + if (fromEl) fromEl.style.opacity = "0"; + if (toEl) toEl.style.opacity = "0"; + prevFromId = active.fromSceneId; + prevToId = active.toSceneId; + } + pWin.__hf_page_composite_pending = false; + return true; + } + + pWin.__hf_page_composite_resolve = resolveComposite; + + // ── Phase 1: seek wrapper sets up clones and signals pending ── + + let currentActive: ResolvedTransition | null = null; + let currentProgress = 0; + let prevFromId: string | null = null; + let prevToId: string | null = null; + type HfWindow = Window & { __hf?: { seek?: (t: number) => unknown }; }; @@ -238,64 +264,47 @@ export function installPageSideCompositor(options: PageCompositorInstallOptions) if (!hfWin.__hf) return; const originalSeek = hfWin.__hf.seek; if (typeof originalSeek !== "function") return; - let prevFromEl: HTMLElement | null = null; - let prevToEl: HTMLElement | null = null; - const wrapped = async (time: number): Promise => { - // Restore opacity on scenes hidden by the previous frame's compositor - // pass BEFORE running GSAP seek — GSAP caches inline values and won't - // re-write opacity if it thinks the value hasn't changed. - if (prevFromEl) { - prevFromEl.style.opacity = ""; - prevFromEl = null; + const wrapped = (time: number): unknown => { + // Restore opacity on scenes hidden by previous frame + if (prevFromId) { + const el = document.getElementById(prevFromId); + if (el) el.style.opacity = ""; + prevFromId = null; } - if (prevToEl) { - prevToEl.style.opacity = ""; - prevToEl = null; + if (prevToId) { + const el = document.getElementById(prevToId); + if (el) el.style.opacity = ""; + prevToId = null; } const result = originalSeek.call(hfWin.__hf, time); const active = findActive(time); if (!active) { glCanvas.style.display = "none"; + pWin.__hf_page_composite_pending = false; return result; } const fromEl = document.getElementById(active.fromSceneId); const toEl = document.getElementById(active.toSceneId); if (!(fromEl instanceof HTMLElement) || !(toEl instanceof HTMLElement)) { glCanvas.style.display = "none"; + pWin.__hf_page_composite_pending = false; return result; } - const [fromOk, toOk] = await Promise.all([ - captureSceneToTexture(fromEl, fromTex), - captureSceneToTexture(toEl, toTex), - ]); - if (!fromOk || !toOk) { - glCanvas.style.display = "none"; - return result; - } - try { - const progress = - active.duration === 0 - ? 1 - : Math.min(1, Math.max(0, (time - active.time) / active.duration)); - renderShader( - gl as WebGLRenderingContext, - quadBuf, - active.prog, - fromTex, - toTex, - progress, - accentColors, - width, - height, - ); - glCanvas.style.display = "block"; - } finally { - fromEl.style.opacity = "0"; - toEl.style.opacity = "0"; - prevFromEl = fromEl; - prevToEl = toEl; - } + + // Clone scenes into staging canvases for the engine to paint + while (fromStaging.firstChild) fromStaging.removeChild(fromStaging.firstChild); + while (toStaging.firstChild) toStaging.removeChild(toStaging.firstChild); + fromStaging.appendChild(fromEl.cloneNode(true)); + toStaging.appendChild(toEl.cloneNode(true)); + + currentActive = active; + currentProgress = + active.duration === 0 + ? 1 + : Math.min(1, Math.max(0, (time - active.time) / active.duration)); + pWin.__hf_page_composite_pending = true; + return result; }; hfWin.__hf.seek = wrapped; From ea1142616e25ef0455dd9753f06966973c157db8 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 14 May 2026 16:20:18 -0700 Subject: [PATCH 07/17] perf(engine): optimize two-phase compositor hot path - uploadTextureSource instead of uploadTexture: eliminates ~2.3GB of canvas buffer alloc/dealloc churn (persistent staging canvases don't need the one-shot zeroing behavior) - Fold hasPending check into seek page.evaluate: eliminates one CDP round-trip per frame (~700 unnecessary IPC calls on non-transition frames) - Fix renderShader error handling: on failure, leave source scenes visible as fallback instead of hiding both scenes + GL overlay (which produced black frames) - Move mutable state declarations above resolveComposite to prevent TDZ risk on refactor Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/engine/src/services/frameCapture.ts | 14 ++++----- .../src/engineModePageComposite.ts | 29 +++++++------------ 2 files changed, 17 insertions(+), 26 deletions(-) diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index 4ffbcef8b..123ad0414 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -696,22 +696,20 @@ async function prepareFrameForCapture( const seekStart = Date.now(); // Seek via the __hf protocol. The page's seek() implementation handles // all framework-specific logic (GSAP stepping, CSS animation sync, etc.) - await page.evaluate((t: number) => { + // Seek + check page-side composite pending flag in one round-trip. + const hasPendingComposite = await page.evaluate((t: number) => { if (window.__hf && typeof window.__hf.seek === "function") { - return window.__hf.seek(t); + window.__hf.seek(t); } + return !!(window as unknown as { __hf_page_composite_pending?: boolean }) + .__hf_page_composite_pending; }, quantizedTime); // Page-side compositing two-phase protocol: if the seek wrapper set up // staging canvases with cloned scenes, force the browser to paint them // via a micro-screenshot, then call the page-side resolve function to // run drawElementImage + shader composite. - const hasPending = await page.evaluate( - () => - !!(window as unknown as { __hf_page_composite_pending?: boolean }) - .__hf_page_composite_pending, - ); - if (hasPending) { + if (hasPendingComposite) { const cdp = await getCdpSession(page); await cdp.send("Page.captureScreenshot", { format: "jpeg", diff --git a/packages/shader-transitions/src/engineModePageComposite.ts b/packages/shader-transitions/src/engineModePageComposite.ts index 7b8b8cd0d..4470fb95f 100644 --- a/packages/shader-transitions/src/engineModePageComposite.ts +++ b/packages/shader-transitions/src/engineModePageComposite.ts @@ -33,7 +33,7 @@ import { setupQuad, createProgram, createTexture, - uploadTexture, + uploadTextureSource, renderShader, type AccentColors, } from "./webgl.js"; @@ -180,7 +180,10 @@ export function installPageSideCompositor(options: PageCompositorInstallOptions) return null; } - // ── Phase 2: resolve the pending composite after engine forces paint ── + let currentActive: ResolvedTransition | null = null; + let currentProgress = 0; + let prevFromId: string | null = null; + let prevToId: string | null = null; type PendingWindow = Window & { __hf_page_composite_pending?: boolean; @@ -213,14 +216,8 @@ export function installPageSideCompositor(options: PageCompositorInstallOptions) return false; } - uploadTexture(gl as WebGLRenderingContext, fromTex, fromStaging); - uploadTexture(gl as WebGLRenderingContext, toTex, toStaging); - - // uploadTexture zeroes canvas dimensions — restore for next frame - fromStaging.width = width; - fromStaging.height = height; - toStaging.width = width; - toStaging.height = height; + uploadTextureSource(gl as WebGLRenderingContext, fromTex, fromStaging); + uploadTextureSource(gl as WebGLRenderingContext, toTex, toStaging); try { renderShader( @@ -235,13 +232,16 @@ export function installPageSideCompositor(options: PageCompositorInstallOptions) height, ); glCanvas.style.display = "block"; - } finally { const fromEl = document.getElementById(active.fromSceneId); const toEl = document.getElementById(active.toSceneId); if (fromEl) fromEl.style.opacity = "0"; if (toEl) toEl.style.opacity = "0"; prevFromId = active.fromSceneId; prevToId = active.toSceneId; + } catch (err) { + // eslint-disable-next-line no-console + console.warn("[HyperShader] page-side compositor: renderShader failed:", err); + glCanvas.style.display = "none"; } pWin.__hf_page_composite_pending = false; return true; @@ -249,13 +249,6 @@ export function installPageSideCompositor(options: PageCompositorInstallOptions) pWin.__hf_page_composite_resolve = resolveComposite; - // ── Phase 1: seek wrapper sets up clones and signals pending ── - - let currentActive: ResolvedTransition | null = null; - let currentProgress = 0; - let prevFromId: string | null = null; - let prevToId: string | null = null; - type HfWindow = Window & { __hf?: { seek?: (t: number) => unknown }; }; From 08da30a4b4b822fdc54e897155810155bbdcf1e7 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 14 May 2026 16:31:03 -0700 Subject: [PATCH 08/17] =?UTF-8?q?fix(engine):=20staff=20review=20=E2=80=94?= =?UTF-8?q?=20staging=20cleanup,=20pending=20flag,=20beginFrame=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Clear staging canvas children when leaving transition window (prevents visible clone bleed-through on transparent compositions) - Clear __hf_page_composite_pending on all resolveComposite exit paths - Guard micro-screenshot paint force against beginFrame mode (CDP Page.captureScreenshot conflicts with beginFrame compositor control) - Update CLI flag description: document video/canvas limitation, remove stale PSNR claim Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/commands/render.ts | 10 +++++----- packages/engine/src/services/frameCapture.ts | 2 +- .../src/engineModePageComposite.ts | 18 +++++++++++++++--- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index b5e75b579..c0b9fe13c 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -225,11 +225,11 @@ export default defineCommand({ "page-side-compositing": { type: "boolean", description: - "EXPERIMENTAL (opt-in spike). Run shader transitions on a page-side " + - "WebGL canvas inside Chrome instead of the Node-side layered blend. " + - "Mac-viable lever to push past the hf#677 1.95× baseline on shader-" + - "transition renders. SDR only; HDR content forces the existing path. " + - "Pin a PSNR-based correctness check, not byte-equality, when using this.", + "EXPERIMENTAL. Run shader transitions on a page-side WebGL canvas " + + "inside Chrome instead of the Node-side layered blend. ~6× faster " + + "for SDR shader-transition renders on Mac. HDR/alpha content forces " + + "the existing path. Scenes with