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.)`, ); } diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 183d5a4fd..015559674 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -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 ──────────────────────────────────────────────────── @@ -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); @@ -538,6 +552,7 @@ export default defineCommand({ variables, entryFile, outputResolution, + pageSideCompositing: args["page-side-compositing"] !== false, exitAfterComplete: true, }); } else { @@ -584,6 +599,7 @@ interface RenderOptions { exitAfterComplete?: boolean; /** Output resolution preset; see `resolveDeviceScaleFactor` for constraints. */ outputResolution?: CanvasResolution; + pageSideCompositing?: boolean; } export type VariablesParseError = @@ -878,6 +894,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..7409bdb85 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 --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"); + }); }); diff --git a/packages/cli/src/utils/dockerRunArgs.ts b/packages/cli/src/utils/dockerRunArgs.ts index 986a75034..5237036c8 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 === false ? ["--no-page-side-compositing"] : []), ]; } diff --git a/packages/core/src/compiler/timingCompiler.test.ts b/packages/core/src/compiler/timingCompiler.test.ts index 1dfafea83..b7dcf044c 100644 --- a/packages/core/src/compiler/timingCompiler.test.ts +++ b/packages/core/src/compiler/timingCompiler.test.ts @@ -55,6 +55,32 @@ describe("compileTimingAttrs", () => { expect(unresolved[0].start).toBe(1); }); + it("auto-injects data-start='0' when missing so video is discoverable", () => { + const html = '