Skip to content

Commit f84cc49

Browse files
vanceingallsclaudeVai
authored
perf(engine): faster shader transitions via page-side WebGL compositing (#832)
* fix(cli): prefer puppeteer cache + numeric version sort (staff review) 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) <noreply@anthropic.com> * perf(engine): page-side compositing for shader transitions (opt-in spike) 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 <vai@heygen.com> * fix(shader-transitions): use html2canvas for page-side compositor capture 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * fix(producer): unify page-side compositing gating and Docker forwarding 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) <noreply@anthropic.com> * perf(engine): two-phase drawElementImage capture for page-side compositing 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * fix(engine): staff review — staging cleanup, pending flag, beginFrame guard - 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) <noreply@anthropic.com> * feat(engine): default-on page-side compositing for SDR shader transitions Page-side compositing is now enabled by default for SDR shader-transition renders without video content. The 6.6× speedup applies automatically — no flag needed. Auto-disables when: - HDR content detected - Alpha output (WebM/MOV/PNG-sequence) - Composition contains <video> elements (cloneNode loses playback state) - beginFrame capture mode (Linux headless) Use --no-page-side-compositing to force the Node-side layered path. Changes: - Engine config: enablePageSideCompositing defaults to true - CLI: flag default flipped to true; --no-page-side-compositing disables - Orchestrator: added composition.videos.length === 0 gate - Docker: forwards --no-page-side-compositing when explicitly disabled - Config tests updated for new default Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(engine): support video elements on page-side compositing fast path Three-phase capture protocol lets shader transitions render video scenes without falling back to the slow Node-side layered pipeline: 1. Seek → compositor records transition metadata, sets pending flag 2. onBeforeCapture → video frame injector updates <img> replacements 3. prepare → cloneNode picks up current video frames, img.decode() awaits 4. micro-screenshot → forces browser to paint cloned elements 5. resolve → drawElementImage reads paint records, shader composites Key changes: - Remove `composition.videos.length === 0` gate from orchestrator - Split compositor resolve into prepare (clone) + resolve (shader) - Move onBeforeCapture before compositor prepare in frameCapture.ts - Await img.decode() on cloned data-URI images to prevent stale frames - Stop manipulating scene opacity in compositor (GL canvas overlay suffices) - Add gsap.set declaration for shader-transitions ambient types - Add video_missing_timing_attrs lint rule for <video> without id/data-start/data-end Performance: compositions with video now render at 7.5s (6 workers) instead of 2m38s on the layered path. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(core): auto-inject data-start on video/audio so frame extraction works without explicit attrs The timing compiler now injects data-start="0" on <video> and <audio> elements that lack it. This makes discoverMediaFromBrowser() find the element (it queries video[data-start]), so the frame extraction pipeline activates automatically. Videos "just work" without requiring authors to add data-start, data-end, or id attributes. Also removes the video_missing_timing_attrs lint rule — the compiler handles the missing attributes automatically, so the lint rule would only false-positive. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(core): add data-hf-auto-start sentinel on auto-injected video timing Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(producer): add discoverVideoVisibilityFromTimeline for runtime video discovery Seeks the GSAP timeline in Puppeteer to discover when each video's parent scene is visible (opacity > 0). Uses coarse sampling at 100ms steps followed by binary search refinement to frame-level precision (1/60s). Only processes videos with the data-hf-auto-start sentinel so author-specified timing is never overridden. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(producer): integrate runtime video visibility discovery into probe stage Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(producer): trigger browser probe for auto-start videos, remove debug logging The probe stage was skipping browser launch when composition duration was already known, which meant discoverVideoVisibilityFromTimeline never ran. Now needsBrowser also checks for data-hf-auto-start sentinel in compiled HTML. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(scripts): use mkdtempSync for smoke test work directory Replaces hardcoded /tmp/hf-page-side-smoke with a unique temp directory via mkdtempSync to resolve CodeQL "insecure temporary file" alert. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: format smoke test script Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Vai <vai@heygen.com>
1 parent fb90025 commit f84cc49

21 files changed

Lines changed: 1196 additions & 30 deletions

File tree

packages/cli/src/browser/manager.test.ts

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,10 @@ describe("findBrowser — cache resolution", () => {
102102
vi.doUnmock("@puppeteer/browsers");
103103
});
104104

105-
it("resolves to the hyperframes-managed cache when present", async () => {
105+
it("resolves to the hyperframes-managed cache when puppeteer cache is empty", async () => {
106+
// Only HF cache populated. Puppeteer cache is the higher-priority path
107+
// (see "prefers puppeteer cache" test below), so this exercises the
108+
// last-resort fallback.
106109
installFsMocks({ existing: new Set([HF_CACHE, HF_BINARY]) });
107110
installPuppeteerBrowsersMock({
108111
installedInHfCache: [{ browser: "chrome-headless-shell", executablePath: HF_BINARY }],
@@ -129,8 +132,35 @@ describe("findBrowser — cache resolution", () => {
129132
expect(result).toEqual({ executablePath: PUPPETEER_BINARY, source: "cache" });
130133
});
131134

135+
it("prefers the puppeteer cache over the hyperframes cache when BOTH are populated", async () => {
136+
// The HF cache is pinned to `CHROME_VERSION` (131-era) which lags upstream
137+
// by many releases. The engine's `resolveHeadlessShellPath` scans the
138+
// puppeteer cache and selects newest-version-first; if the CLI handed
139+
// engine the older HF-cache binary while a newer puppeteer-cache binary
140+
// exists, the two would silently disagree on which binary to use.
141+
// This test pins the priority: puppeteer cache wins when both are populated.
142+
installFsMocks({
143+
existing: new Set([HF_CACHE, HF_BINARY, PUPPETEER_CACHE, PUPPETEER_BINARY]),
144+
dirs: { [PUPPETEER_CACHE]: ["linux-148.0.7778.97"] },
145+
});
146+
installPuppeteerBrowsersMock({
147+
installedInHfCache: [{ browser: "chrome-headless-shell", executablePath: HF_BINARY }],
148+
});
149+
150+
const { findBrowser } = await import("./manager.js");
151+
const result = await findBrowser();
152+
153+
expect(result?.executablePath).toBe(PUPPETEER_BINARY);
154+
expect(result?.source).toBe("cache");
155+
});
156+
132157
it("picks the newest version when multiple chrome-headless-shell builds are cached", async () => {
133-
const olderBinary = `${PUPPETEER_CACHE}/linux-131.0.6778.85/chrome-headless-shell-linux64/chrome-headless-shell`;
158+
const olderBinary = join(
159+
PUPPETEER_CACHE,
160+
"linux-131.0.6778.85",
161+
"chrome-headless-shell-linux64",
162+
"chrome-headless-shell",
163+
);
134164
installFsMocks({
135165
existing: new Set([PUPPETEER_CACHE, PUPPETEER_BINARY, olderBinary]),
136166
dirs: { [PUPPETEER_CACHE]: ["linux-131.0.6778.85", "linux-148.0.7778.97"] },
@@ -143,6 +173,32 @@ describe("findBrowser — cache resolution", () => {
143173
expect(result?.executablePath).toBe(PUPPETEER_BINARY);
144174
});
145175

176+
it("uses numeric (not lexicographic) version ordering — linux-148 beats linux-99", async () => {
177+
// Regression guard for the lexicographic-sort bug: `"linux-99..."` sorts
178+
// after `"linux-148..."` character-by-character (because `'9' > '1'`),
179+
// which would have caused the CLI to hand engine an ancient 99-era binary
180+
// when a fresh 148 was sitting right next to it. Numeric semver-style
181+
// ordering is the only correct semantic.
182+
const linux99Binary = join(
183+
PUPPETEER_CACHE,
184+
"linux-99.0.6533.123",
185+
"chrome-headless-shell-linux64",
186+
"chrome-headless-shell",
187+
);
188+
installFsMocks({
189+
existing: new Set([PUPPETEER_CACHE, PUPPETEER_BINARY, linux99Binary]),
190+
// Intentionally list the entries in an order that would expose the bug
191+
// under naive `.sort().reverse()` (which puts `linux-99...` first).
192+
dirs: { [PUPPETEER_CACHE]: ["linux-99.0.6533.123", "linux-148.0.7778.97"] },
193+
});
194+
installPuppeteerBrowsersMock();
195+
196+
const { findBrowser } = await import("./manager.js");
197+
const result = await findBrowser();
198+
199+
expect(result?.executablePath).toBe(PUPPETEER_BINARY);
200+
});
201+
146202
it("falls back to system Chrome and warns on Linux when no cache has headless-shell", async () => {
147203
installFsMocks({ existing: new Set([SYSTEM_CHROME]) });
148204
installPuppeteerBrowsersMock();

packages/cli/src/browser/manager.ts

Lines changed: 68 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,26 @@ function findFromEnv(): BrowserResult | undefined {
7373
}
7474

7575
async function findFromCache(): Promise<BrowserResult | undefined> {
76-
// 1) Hyperframes-managed cache (populated by `clearBrowser` + `install` below).
76+
// 1) Puppeteer's managed cache — where `npx @puppeteer/browsers install
77+
// chrome-headless-shell` lands, and where `puppeteer install` from a project
78+
// depending on full `puppeteer` (not `puppeteer-core`) lands. The engine's
79+
// `resolveHeadlessShellPath` reads from here and selects newest-version-
80+
// first; the CLI must match that semantic or it will silently hand the
81+
// engine an older binary than the engine itself would pick.
82+
//
83+
// We intentionally check puppeteer BEFORE the hyperframes-managed cache:
84+
// the HF cache is pinned to `CHROME_VERSION` (above) which lags behind
85+
// upstream Chrome by many releases. If a user installed chrome-headless-shell
86+
// separately (via `@puppeteer/browsers install`) we want to use that
87+
// newer binary, not the pinned-stale fallback.
88+
const fromPuppeteer = findFromPuppeteerCache();
89+
if (fromPuppeteer) {
90+
return fromPuppeteer;
91+
}
92+
93+
// 2) Hyperframes-managed cache (populated by `ensureBrowser` below as a
94+
// download-of-last-resort). This is the fallback path: only reached when
95+
// no puppeteer-cache binary exists.
7796
if (existsSync(CACHE_DIR)) {
7897
const installed = await getInstalledBrowsers({ cacheDir: CACHE_DIR });
7998
const match = installed.find((b) => b.browser === Browser.CHROMEHEADLESSSHELL);
@@ -82,27 +101,61 @@ async function findFromCache(): Promise<BrowserResult | undefined> {
82101
}
83102
}
84103

85-
// 2) Puppeteer's managed cache — where `npx @puppeteer/browsers install
86-
// chrome-headless-shell` lands, and where `puppeteer install` from a project
87-
// that depends on full `puppeteer` (not `puppeteer-core`) lands. The engine
88-
// already reads from here (`resolveHeadlessShellPath`); without this branch
89-
// the CLI would skip past a perfectly good chrome-headless-shell and fall
90-
// through to `findFromSystem()`, picking regular Chrome which has dropped
91-
// `HeadlessExperimental.enable` and disables the perf-optimized capture
92-
// path.
93-
const fromPuppeteer = findFromPuppeteerCache();
94-
if (fromPuppeteer) {
95-
return fromPuppeteer;
104+
return undefined;
105+
}
106+
107+
/**
108+
* Parse a puppeteer-cache version directory name (`linux-148.0.7778.97`,
109+
* `mac_arm-131.0.6778.85`, etc.) into a numeric tuple for ordering.
110+
*
111+
* Lexicographic sort on these strings is buggy because `"99"` > `"148"` (the
112+
* `9` outranks the `1` character-wise), so a 99-era binary would beat a
113+
* 148-era binary in `.sort().reverse()`. We split on `-` to drop the platform
114+
* prefix, then on `.` to get integer segments. Returns `undefined` for names
115+
* that don't have at least one parseable numeric segment so they sort last.
116+
*/
117+
function parseVersionSegments(versionDir: string): number[] | undefined {
118+
const dashIdx = versionDir.indexOf("-");
119+
const versionPart = dashIdx >= 0 ? versionDir.slice(dashIdx + 1) : versionDir;
120+
const segments = versionPart.split(".");
121+
const parsed: number[] = [];
122+
for (const seg of segments) {
123+
const n = parseInt(seg, 10);
124+
if (!Number.isFinite(n)) {
125+
// Stop at the first non-numeric segment but keep what we've collected.
126+
break;
127+
}
128+
parsed.push(n);
96129
}
130+
return parsed.length > 0 ? parsed : undefined;
131+
}
97132

98-
return undefined;
133+
/** Numeric semver-style descending comparator for puppeteer cache dirs. */
134+
function compareVersionDirsDescending(a: string, b: string): number {
135+
const pa = parseVersionSegments(a);
136+
const pb = parseVersionSegments(b);
137+
// Unparseable names sort after parseable ones (so we still try them, just last).
138+
if (!pa && !pb) return 0;
139+
if (!pa) return 1;
140+
if (!pb) return -1;
141+
const len = Math.max(pa.length, pb.length);
142+
for (let i = 0; i < len; i += 1) {
143+
const av = pa[i] ?? 0;
144+
const bv = pb[i] ?? 0;
145+
if (av !== bv) return bv - av; // descending (newest first)
146+
}
147+
return 0;
99148
}
100149

101150
function findFromPuppeteerCache(): BrowserResult | undefined {
102151
if (!existsSync(PUPPETEER_CACHE_DIR)) return undefined;
103152
let versions: string[];
104153
try {
105-
versions = readdirSync(PUPPETEER_CACHE_DIR).sort().reverse(); // newest first
154+
// Numeric semver-style sort, newest first. Lexicographic `.sort().reverse()`
155+
// (the previous implementation, still in engine `resolveHeadlessShellPath`)
156+
// mis-orders `linux-99...` ahead of `linux-148...` because character `'9'`
157+
// outranks `'1'`. See `parseVersionSegments` above.
158+
versions = [...readdirSync(PUPPETEER_CACHE_DIR)].sort(compareVersionDirsDescending);
106159
} catch {
107160
return undefined;
108161
}
@@ -159,7 +212,7 @@ function warnSystemFallbackOnce(executablePath: string): void {
159212
if (isHeadlessShellBinary(executablePath)) return;
160213
_warnedSystemFallback = true;
161214
console.warn(
162-
`[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`,
215+
`[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.)`,
163216
);
164217
}
165218

packages/cli/src/commands/render.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,15 @@ export default defineCommand({
222222
description:
223223
"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.",
224224
},
225+
"page-side-compositing": {
226+
type: "boolean",
227+
description:
228+
"Run shader transitions on a page-side WebGL canvas inside Chrome " +
229+
"instead of the Node-side layered blend. ~6× faster for SDR " +
230+
"shader-transition renders. HDR/alpha/video content auto-disables. " +
231+
"Use --no-page-side-compositing to force the layered path.",
232+
default: true,
233+
},
225234
},
226235
async run({ args }) {
227236
// ── Resolve project ────────────────────────────────────────────────────
@@ -293,6 +302,11 @@ export default defineCommand({
293302
workers = parsed;
294303
}
295304

305+
// ── Wire opt-in: page-side compositing ───────────────────────────────
306+
if (args["page-side-compositing"] === false) {
307+
process.env.HF_PAGE_SIDE_COMPOSITING = "false";
308+
}
309+
296310
// ── Validate max-concurrent-renders ─────────────────────────────────
297311
if (args["max-concurrent-renders"] != null) {
298312
const parsed = parseInt(args["max-concurrent-renders"], 10);
@@ -538,6 +552,7 @@ export default defineCommand({
538552
variables,
539553
entryFile,
540554
outputResolution,
555+
pageSideCompositing: args["page-side-compositing"] !== false,
541556
exitAfterComplete: true,
542557
});
543558
} else {
@@ -584,6 +599,7 @@ interface RenderOptions {
584599
exitAfterComplete?: boolean;
585600
/** Output resolution preset; see `resolveDeviceScaleFactor` for constraints. */
586601
outputResolution?: CanvasResolution;
602+
pageSideCompositing?: boolean;
587603
}
588604

589605
export type VariablesParseError =
@@ -878,6 +894,7 @@ async function renderDocker(
878894
variables: options.variables,
879895
entryFile: options.entryFile,
880896
outputResolution: options.outputResolution,
897+
pageSideCompositing: options.pageSideCompositing,
881898
},
882899
});
883900

packages/cli/src/utils/dockerRunArgs.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,4 +277,17 @@ describe("buildDockerRunArgs", () => {
277277
const args = buildDockerRunArgs({ ...FIXED_INPUT, options: BASE });
278278
expect(args).not.toContain("--resolution");
279279
});
280+
281+
it("forwards --no-page-side-compositing when pageSideCompositing is false", () => {
282+
const args = buildDockerRunArgs({
283+
...FIXED_INPUT,
284+
options: { ...BASE, pageSideCompositing: false },
285+
});
286+
expect(args).toContain("--no-page-side-compositing");
287+
});
288+
289+
it("omits --no-page-side-compositing when pageSideCompositing is not explicitly false", () => {
290+
const args = buildDockerRunArgs({ ...FIXED_INPUT, options: BASE });
291+
expect(args).not.toContain("--no-page-side-compositing");
292+
});
280293
});

packages/cli/src/utils/dockerRunArgs.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export interface DockerRenderOptions {
4141
entryFile?: string;
4242
/** Output resolution preset (e.g. "landscape-4k"). Forwarded as `--resolution`. */
4343
outputResolution?: string;
44+
pageSideCompositing?: boolean;
4445
}
4546

4647
export function buildDockerRunArgs(input: DockerRunArgsInput): string[] {
@@ -80,5 +81,6 @@ export function buildDockerRunArgs(input: DockerRunArgsInput): string[] {
8081
: []),
8182
...(options.entryFile ? ["--composition", options.entryFile] : []),
8283
...(options.outputResolution ? ["--resolution", options.outputResolution] : []),
84+
...(options.pageSideCompositing === false ? ["--no-page-side-compositing"] : []),
8385
];
8486
}

packages/core/src/compiler/timingCompiler.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,32 @@ describe("compileTimingAttrs", () => {
5555
expect(unresolved[0].start).toBe(1);
5656
});
5757

58+
it("auto-injects data-start='0' when missing so video is discoverable", () => {
59+
const html = '<video src="clip.mp4" muted>';
60+
const { html: compiled, unresolved } = compileTimingAttrs(html);
61+
62+
expect(compiled).toContain('data-start="0"');
63+
expect(compiled).toContain('id="hf-video-0"');
64+
expect(unresolved).toHaveLength(1);
65+
expect(unresolved[0].start).toBe(0);
66+
});
67+
68+
it("marks auto-injected data-start with data-hf-auto-start sentinel", () => {
69+
const html = '<video src="clip.mp4" muted>';
70+
const { html: compiled } = compileTimingAttrs(html);
71+
72+
expect(compiled).toContain('data-start="0"');
73+
expect(compiled).toContain("data-hf-auto-start");
74+
});
75+
76+
it("does not add data-hf-auto-start when author provides data-start", () => {
77+
const html = '<video id="v1" src="clip.mp4" data-start="5" muted>';
78+
const { html: compiled } = compileTimingAttrs(html);
79+
80+
expect(compiled).toContain('data-start="5"');
81+
expect(compiled).not.toContain("data-hf-auto-start");
82+
});
83+
5884
it("compiles audio tags the same as video (minus data-has-audio)", () => {
5985
const html = '<audio id="a1" src="music.mp3" data-start="0" data-duration="10">';
6086
const { html: compiled } = compileTimingAttrs(html);

packages/core/src/compiler/timingCompiler.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,13 @@ function compileTag(
8585
id = `${isVideo ? "hf-video" : "hf-audio"}-${generateId()}`;
8686
result = injectAttr(result, "id", id);
8787
}
88-
const startStr = getAttr(result, "data-start");
89-
const start = startStr !== null ? parseFloat(startStr) : 0;
88+
let startStr = getAttr(result, "data-start");
89+
if (startStr === null) {
90+
result = injectAttr(result, "data-start", "0");
91+
result = injectAttr(result, "data-hf-auto-start", "");
92+
startStr = "0";
93+
}
94+
const start = parseFloat(startStr);
9095
const mediaStartStr = getAttr(result, "data-media-start");
9196
const mediaStart = mediaStartStr ? parseFloat(mediaStartStr) : 0;
9297

packages/engine/src/config.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,4 +138,23 @@ describe("resolveConfig", () => {
138138
const config = resolveConfig();
139139
expect(config.frameDataUriCacheLimit).toBe(32);
140140
});
141+
142+
describe("enablePageSideCompositing (HF_PAGE_SIDE_COMPOSITING)", () => {
143+
it("defaults to true", () => {
144+
const config = resolveConfig();
145+
expect(config.enablePageSideCompositing).toBe(true);
146+
});
147+
148+
it("disabled when HF_PAGE_SIDE_COMPOSITING=false", () => {
149+
setEnv("HF_PAGE_SIDE_COMPOSITING", "false");
150+
const config = resolveConfig();
151+
expect(config.enablePageSideCompositing).toBe(false);
152+
});
153+
154+
it("explicit override wins over the env var", () => {
155+
setEnv("HF_PAGE_SIDE_COMPOSITING", "true");
156+
const config = resolveConfig({ enablePageSideCompositing: false });
157+
expect(config.enablePageSideCompositing).toBe(false);
158+
});
159+
});
141160
});

0 commit comments

Comments
 (0)