Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 58 additions & 2 deletions packages/cli/src/browser/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }],
Expand All @@ -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"] },
Expand All @@ -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();
Expand Down
83 changes: 68 additions & 15 deletions packages/cli/src/browser/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,26 @@ function findFromEnv(): BrowserResult | undefined {
}

async function findFromCache(): Promise<BrowserResult | undefined> {
// 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);
Expand All @@ -82,27 +101,61 @@ async function findFromCache(): Promise<BrowserResult | undefined> {
}
}

// 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;
}
Expand Down Expand Up @@ -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.)`,
);
}

Expand Down
17 changes: 17 additions & 0 deletions packages/cli/src/commands/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,15 @@ 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:
"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. HDR/alpha/video content auto-disables. " +
"Use --no-page-side-compositing to force the layered path.",
default: true,
},
},
async run({ args }) {
// ── Resolve project ────────────────────────────────────────────────────
Expand Down Expand Up @@ -293,6 +302,11 @@ export default defineCommand({
workers = parsed;
}

// ── Wire opt-in: page-side compositing ───────────────────────────────
if (args["page-side-compositing"] === false) {
process.env.HF_PAGE_SIDE_COMPOSITING = "false";
}

// ── Validate max-concurrent-renders ─────────────────────────────────
if (args["max-concurrent-renders"] != null) {
const parsed = parseInt(args["max-concurrent-renders"], 10);
Expand Down Expand Up @@ -538,6 +552,7 @@ export default defineCommand({
variables,
entryFile,
outputResolution,
pageSideCompositing: args["page-side-compositing"] !== false,
exitAfterComplete: true,
});
} else {
Expand Down Expand Up @@ -584,6 +599,7 @@ interface RenderOptions {
exitAfterComplete?: boolean;
/** Output resolution preset; see `resolveDeviceScaleFactor` for constraints. */
outputResolution?: CanvasResolution;
pageSideCompositing?: boolean;
}

export type VariablesParseError =
Expand Down Expand Up @@ -878,6 +894,7 @@ async function renderDocker(
variables: options.variables,
entryFile: options.entryFile,
outputResolution: options.outputResolution,
pageSideCompositing: options.pageSideCompositing,
},
});

Expand Down
13 changes: 13 additions & 0 deletions packages/cli/src/utils/dockerRunArgs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,4 +277,17 @@ describe("buildDockerRunArgs", () => {
const args = buildDockerRunArgs({ ...FIXED_INPUT, options: BASE });
expect(args).not.toContain("--resolution");
});

it("forwards --no-page-side-compositing when pageSideCompositing is false", () => {
const args = buildDockerRunArgs({
...FIXED_INPUT,
options: { ...BASE, pageSideCompositing: false },
});
expect(args).toContain("--no-page-side-compositing");
});

it("omits --no-page-side-compositing when pageSideCompositing is not explicitly false", () => {
const args = buildDockerRunArgs({ ...FIXED_INPUT, options: BASE });
expect(args).not.toContain("--no-page-side-compositing");
});
});
2 changes: 2 additions & 0 deletions packages/cli/src/utils/dockerRunArgs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] {
Expand Down Expand Up @@ -80,5 +81,6 @@ export function buildDockerRunArgs(input: DockerRunArgsInput): string[] {
: []),
...(options.entryFile ? ["--composition", options.entryFile] : []),
...(options.outputResolution ? ["--resolution", options.outputResolution] : []),
...(options.pageSideCompositing === false ? ["--no-page-side-compositing"] : []),
];
}
19 changes: 19 additions & 0 deletions packages/engine/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,4 +138,23 @@ describe("resolveConfig", () => {
const config = resolveConfig();
expect(config.frameDataUriCacheLimit).toBe(32);
});

describe("enablePageSideCompositing (HF_PAGE_SIDE_COMPOSITING)", () => {
it("defaults to true", () => {
const config = resolveConfig();
expect(config.enablePageSideCompositing).toBe(true);
});

it("disabled when HF_PAGE_SIDE_COMPOSITING=false", () => {
setEnv("HF_PAGE_SIDE_COMPOSITING", "false");
const config = resolveConfig();
expect(config.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);
});
});
});
34 changes: 34 additions & 0 deletions packages/engine/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -148,6 +177,7 @@ export const DEFAULT_CONFIG: EngineConfig = {
browserTimeout: 120_000,
protocolTimeout: 300_000,
forceScreenshot: false,
enablePageSideCompositing: true,

enableChunkedEncode: false,
chunkSizeFrames: 360,
Expand Down Expand Up @@ -221,6 +251,10 @@ export function resolveConfig(overrides?: Partial<EngineConfig>): 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",
Expand Down
24 changes: 23 additions & 1 deletion packages/engine/src/services/frameCapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -696,11 +696,33 @@ 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") {
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.
if (hasPendingComposite && session.captureMode !== "beginframe") {
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)
Expand Down
Loading
Loading