Skip to content

Commit 759b399

Browse files
vanceingallsVai
andcommitted
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>
1 parent 5bc730a commit 759b399

11 files changed

Lines changed: 831 additions & 7 deletions

File tree

packages/cli/src/commands/render.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,16 @@ 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+
"EXPERIMENTAL (opt-in spike). Run shader transitions on a page-side " +
229+
"WebGL canvas inside Chrome instead of the Node-side layered blend. " +
230+
"Mac-viable lever to push past the hf#677 1.95× baseline on shader-" +
231+
"transition renders. SDR only; HDR content forces the existing path. " +
232+
"Pin a PSNR-based correctness check, not byte-equality, when using this.",
233+
default: false,
234+
},
225235
},
226236
async run({ args }) {
227237
// ── Resolve project ────────────────────────────────────────────────────
@@ -293,6 +303,16 @@ export default defineCommand({
293303
workers = parsed;
294304
}
295305

306+
// ── Wire opt-in: page-side compositing ───────────────────────────────
307+
// EXPERIMENTAL — the engine reads HF_PAGE_SIDE_COMPOSITING via
308+
// `resolveConfig()` (engine `EngineConfig.enablePageSideCompositing`).
309+
// Set the env var BEFORE `loadProducer()` runs so producer's first call
310+
// to `resolveConfig()` picks it up. Same pattern as
311+
// PRODUCER_HEADLESS_SHELL_PATH below.
312+
if (args["page-side-compositing"]) {
313+
process.env.HF_PAGE_SIDE_COMPOSITING = "true";
314+
}
315+
296316
// ── Validate max-concurrent-renders ─────────────────────────────────
297317
if (args["max-concurrent-renders"] != null) {
298318
const parsed = parseInt(args["max-concurrent-renders"], 10);

packages/engine/src/config.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,4 +138,30 @@ 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 false", () => {
144+
const config = resolveConfig();
145+
expect(config.enablePageSideCompositing).toBe(false);
146+
});
147+
148+
it("flips to true when HF_PAGE_SIDE_COMPOSITING=true", () => {
149+
setEnv("HF_PAGE_SIDE_COMPOSITING", "true");
150+
const config = resolveConfig();
151+
expect(config.enablePageSideCompositing).toBe(true);
152+
});
153+
154+
it("ignores any non-'true' value", () => {
155+
setEnv("HF_PAGE_SIDE_COMPOSITING", "1");
156+
expect(resolveConfig().enablePageSideCompositing).toBe(false);
157+
setEnv("HF_PAGE_SIDE_COMPOSITING", "yes");
158+
expect(resolveConfig().enablePageSideCompositing).toBe(false);
159+
});
160+
161+
it("explicit override wins over the env var", () => {
162+
setEnv("HF_PAGE_SIDE_COMPOSITING", "true");
163+
const config = resolveConfig({ enablePageSideCompositing: false });
164+
expect(config.enablePageSideCompositing).toBe(false);
165+
});
166+
});
141167
});

packages/engine/src/config.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,35 @@ export interface EngineConfig {
4848
expectedChromiumMajor?: number;
4949
/** Force screenshot capture mode (skip BeginFrame even on Linux). */
5050
forceScreenshot: boolean;
51+
/**
52+
* Opt-in: page-side shader-transition compositing.
53+
*
54+
* When `true`, shader transitions for SDR compositions run their blend
55+
* inside Chrome via WebGL on a page-side compositor canvas instead of
56+
* Node-side per-pixel blending (the hf#677 layered pipeline). The engine
57+
* then captures ONE opaque RGB frame per output frame via the streaming
58+
* capture path, skipping per-scene transparent screenshots and the
59+
* Node-side shader-blend worker pool entirely.
60+
*
61+
* The feature stacks on top of the hf#677 chain — it does not undo it.
62+
* When this flag is OFF (the default), behaviour is byte-identical to the
63+
* current path. When ON and the composition has no shader transitions or
64+
* has HDR content (which forces the layered path regardless), this flag
65+
* is a no-op.
66+
*
67+
* Mac viability: Chrome on Mac accelerates page-side WebGL canvases via
68+
* Metal/CoreAnimation natively. This is the lever for Mac users who
69+
* cannot use `--enable-begin-frame-control` (Chromium structural limit,
70+
* crbug.com/40656275).
71+
*
72+
* Determinism: page-side WebGL is f32, not f64. Byte-equality fixture
73+
* pins are NOT compatible with this path; the new path's correctness
74+
* pin is PSNR-based. Default OFF preserves the existing pins for the
75+
* hf#677 chain.
76+
*
77+
* Env fallback: `HF_PAGE_SIDE_COMPOSITING=true`.
78+
*/
79+
enablePageSideCompositing: boolean;
5180

5281
// ── Encoding ─────────────────────────────────────────────────────────
5382
enableChunkedEncode: boolean;
@@ -148,6 +177,7 @@ export const DEFAULT_CONFIG: EngineConfig = {
148177
browserTimeout: 120_000,
149178
protocolTimeout: 300_000,
150179
forceScreenshot: false,
180+
enablePageSideCompositing: false,
151181

152182
enableChunkedEncode: false,
153183
chunkSizeFrames: 360,
@@ -221,6 +251,10 @@ export function resolveConfig(overrides?: Partial<EngineConfig>): EngineConfig {
221251
: undefined,
222252

223253
forceScreenshot: envBool("PRODUCER_FORCE_SCREENSHOT", DEFAULT_CONFIG.forceScreenshot),
254+
enablePageSideCompositing: envBool(
255+
"HF_PAGE_SIDE_COMPOSITING",
256+
DEFAULT_CONFIG.enablePageSideCompositing,
257+
),
224258

225259
enableChunkedEncode: envBool(
226260
"PRODUCER_ENABLE_CHUNKED_ENCODE",

packages/producer/src/services/fileServer.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,29 @@ const HF_EARLY_STUB = `(function() {
428428
if (!window.__hf) window.__hf = {};
429429
})();`;
430430

431+
/**
432+
* Page-side compositing opt-in flag stub.
433+
*
434+
* When the engine is launched with `enablePageSideCompositing: true`, the
435+
* orchestrator injects this stub into the very top of every served HTML
436+
* page. The flag is read by `@hyperframes/shader-transitions`' engine-mode
437+
* `init()` to switch from the default opacity-flip mode (which leaves
438+
* shader blending to the Node side via the hf#677 layered pipeline) to a
439+
* page-side WebGL compositor that runs the shader inside Chrome and
440+
* exposes a single opaque RGB frame for the engine to capture.
441+
*
442+
* Sentinel ONLY — no logic here. The compositor itself ships inside
443+
* `@hyperframes/shader-transitions` and is loaded by the composition's
444+
* regular script bundle.
445+
*
446+
* Default OFF: when the flag is not set, behavior is byte-identical to
447+
* the existing layered path.
448+
*/
449+
export const HF_PAGE_SIDE_COMPOSITING_STUB = `(function() {
450+
if (typeof window === "undefined") return;
451+
window.__HF_PAGE_SIDE_COMPOSITING__ = true;
452+
})();`;
453+
431454
/**
432455
* Bridge script: maps window.__player (Hyperframe runtime) → window.__hf (engine protocol).
433456
* Injected after RENDER_MODE_SCRIPT so the engine's frameCapture can find window.__hf.

packages/producer/src/services/renderOrchestrator.ts

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,12 @@ import {
7878
import { join, dirname, resolve } from "path";
7979
import { randomUUID } from "crypto";
8080
import { fileURLToPath } from "url";
81-
import { createFileServer, type FileServerHandle, VIRTUAL_TIME_SHIM } from "./fileServer.js";
81+
import {
82+
createFileServer,
83+
type FileServerHandle,
84+
HF_PAGE_SIDE_COMPOSITING_STUB,
85+
VIRTUAL_TIME_SHIM,
86+
} from "./fileServer.js";
8287
import { defaultLogger, type ProducerLogger } from "../logger.js";
8388
import { type HdrImageTransferCache } from "./hdrImageTransferCache.js";
8489
import {
@@ -1620,11 +1625,18 @@ export async function executeRenderJob(
16201625

16211626
// Start file server (may already be running from duration discovery)
16221627
if (!fileServer) {
1628+
// Inject the page-side compositing opt-in flag stub BEFORE the virtual
1629+
// time shim when the engine config opts in. The shim itself does not
1630+
// depend on the flag; ordering is arbitrary but stable.
1631+
const preHeadScripts: string[] = [VIRTUAL_TIME_SHIM];
1632+
if (cfg.enablePageSideCompositing) {
1633+
preHeadScripts.unshift(HF_PAGE_SIDE_COMPOSITING_STUB);
1634+
}
16231635
fileServer = await createFileServer({
16241636
projectDir,
16251637
compiledDir: join(workDir, "compiled"),
16261638
port: 0,
1627-
preHeadScripts: [VIRTUAL_TIME_SHIM],
1639+
preHeadScripts,
16281640
});
16291641
assertNotAborted();
16301642
}
@@ -1742,11 +1754,32 @@ export async function executeRenderJob(
17421754
// issues (orange shift) with no quality benefit.
17431755
const nativeHdrIds = new Set([...nativeHdrVideoIds, ...nativeHdrImageIds]);
17441756
const hasHdrContent = Boolean(effectiveHdr && nativeHdrIds.size > 0);
1745-
const useLayeredComposite = shouldUseLayeredComposite({
1746-
hasHdrContent,
1747-
hasShaderTransitions: compiled.hasShaderTransitions,
1748-
isPngSequence,
1749-
});
1757+
// Page-side compositing opt-in: when the engine is configured to run the
1758+
// shader blend inside Chrome via a page-side WebGL canvas, the layered
1759+
// Node-side composite path is unnecessary for SDR shader transitions.
1760+
// The streaming path takes ONE opaque RGB screenshot per output frame —
1761+
// exactly the single capture the page-side compositor produces. HDR
1762+
// content still forces the layered path (HDR layers need per-layer
1763+
// alpha + native HDR raw frame compositing in Node; that's out of scope
1764+
// for this opt-in).
1765+
const usePageSideCompositingForTransitions =
1766+
cfg.enablePageSideCompositing &&
1767+
compiled.hasShaderTransitions &&
1768+
!hasHdrContent &&
1769+
!isPngSequence;
1770+
if (usePageSideCompositingForTransitions) {
1771+
log.info(
1772+
"[Render] Page-side compositing enabled — bypassing Node-side layered " +
1773+
"shader-blend path. Engine will capture one opaque RGB frame per output frame.",
1774+
);
1775+
}
1776+
const useLayeredComposite =
1777+
!usePageSideCompositingForTransitions &&
1778+
shouldUseLayeredComposite({
1779+
hasHdrContent,
1780+
hasShaderTransitions: compiled.hasShaderTransitions,
1781+
isPngSequence,
1782+
});
17501783
const encoderHdr = hasHdrContent ? effectiveHdr : undefined;
17511784
// png-sequence has no encoder, but the rest of the orchestrator still
17521785
// reads `preset.quality` for `effectiveQuality` and `preset.codec` for
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import {
3+
isPageSideCompositingSupported,
4+
PAGE_COMPOSITOR_BUILD_CANARY,
5+
PAGE_COMPOSITOR_CANVAS_ID,
6+
} from "./engineModePageComposite.js";
7+
8+
describe("isPageSideCompositingSupported", () => {
9+
afterEach(() => {
10+
vi.unstubAllGlobals();
11+
});
12+
13+
it("returns false outside the browser (no window)", () => {
14+
vi.stubGlobal("window", undefined);
15+
expect(isPageSideCompositingSupported()).toBe(false);
16+
});
17+
18+
it("returns false outside the browser (no document)", () => {
19+
vi.stubGlobal("window", {});
20+
vi.stubGlobal("document", undefined);
21+
expect(isPageSideCompositingSupported()).toBe(false);
22+
});
23+
24+
it("returns true when drawElementImage is exposed", () => {
25+
vi.stubGlobal("window", {});
26+
vi.stubGlobal("document", {
27+
createElement: () => ({
28+
setAttribute: () => undefined,
29+
layoutSubtree: true,
30+
getContext: () => ({ drawElementImage: () => undefined }),
31+
}),
32+
});
33+
expect(isPageSideCompositingSupported()).toBe(true);
34+
});
35+
36+
it("returns false when drawElementImage is missing", () => {
37+
vi.stubGlobal("window", {});
38+
vi.stubGlobal("document", {
39+
createElement: () => ({
40+
setAttribute: () => undefined,
41+
layoutSubtree: true,
42+
getContext: () => ({}),
43+
}),
44+
});
45+
expect(isPageSideCompositingSupported()).toBe(false);
46+
});
47+
});
48+
49+
describe("page-side compositor exported constants", () => {
50+
// These constants are load-bearing for the bundled-CLI smoke: the
51+
// validation script greps the shipped bundle for the canary to confirm
52+
// the page-side path is in the production tsup output, not just the
53+
// source tree.
54+
it("exports a stable canary string used by the bundled-CLI smoke", () => {
55+
expect(PAGE_COMPOSITOR_BUILD_CANARY).toBe("__hf_page_compositor_v1__");
56+
});
57+
58+
it("exports a stable canvas id", () => {
59+
expect(PAGE_COMPOSITOR_CANVAS_ID).toBe("__hf-page-side-compositor");
60+
});
61+
});

0 commit comments

Comments
 (0)