Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
6ed2232
fix(cli): prefer puppeteer cache + numeric version sort (staff review)
vanceingalls May 14, 2026
8c324d4
perf(engine): page-side compositing for shader transitions (opt-in sp…
vanceingalls May 14, 2026
cc50280
fix(shader-transitions): use html2canvas for page-side compositor cap…
vanceingalls May 14, 2026
395ff02
refactor(shader-transitions): simplify review fixes for page-side com…
vanceingalls May 14, 2026
1b71568
fix(producer): unify page-side compositing gating and Docker forwarding
vanceingalls May 14, 2026
777a014
perf(engine): two-phase drawElementImage capture for page-side compos…
vanceingalls May 14, 2026
ea11426
perf(engine): optimize two-phase compositor hot path
vanceingalls May 14, 2026
08da30a
fix(engine): staff review — staging cleanup, pending flag, beginFrame…
vanceingalls May 14, 2026
63eb5bb
feat(engine): default-on page-side compositing for SDR shader transit…
vanceingalls May 14, 2026
a56ef01
feat(engine): support video elements on page-side compositing fast path
vanceingalls May 15, 2026
21ba9f7
fix(core): auto-inject data-start on video/audio so frame extraction …
vanceingalls May 15, 2026
5eb38d5
feat(core): add data-hf-auto-start sentinel on auto-injected video ti…
vanceingalls May 15, 2026
6eb80ea
feat(producer): add discoverVideoVisibilityFromTimeline for runtime v…
vanceingalls May 15, 2026
ab074da
feat(producer): integrate runtime video visibility discovery into pro…
vanceingalls May 15, 2026
dbac2e7
fix(producer): trigger browser probe for auto-start videos, remove de…
vanceingalls May 15, 2026
1cbf3f4
fix(scripts): use mkdtempSync for smoke test work directory
vanceingalls May 15, 2026
a995e30
style: format smoke test script
vanceingalls May 15, 2026
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"] : []),
];
}
26 changes: 26 additions & 0 deletions packages/core/src/compiler/timingCompiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<video src="clip.mp4" muted>';
const { html: compiled, unresolved } = compileTimingAttrs(html);

expect(compiled).toContain('data-start="0"');
expect(compiled).toContain('id="hf-video-0"');
expect(unresolved).toHaveLength(1);
expect(unresolved[0].start).toBe(0);
});

it("marks auto-injected data-start with data-hf-auto-start sentinel", () => {
const html = '<video src="clip.mp4" muted>';
const { html: compiled } = compileTimingAttrs(html);

expect(compiled).toContain('data-start="0"');
expect(compiled).toContain("data-hf-auto-start");
});

it("does not add data-hf-auto-start when author provides data-start", () => {
const html = '<video id="v1" src="clip.mp4" data-start="5" muted>';
const { html: compiled } = compileTimingAttrs(html);

expect(compiled).toContain('data-start="5"');
expect(compiled).not.toContain("data-hf-auto-start");
});

it("compiles audio tags the same as video (minus data-has-audio)", () => {
const html = '<audio id="a1" src="music.mp3" data-start="0" data-duration="10">';
const { html: compiled } = compileTimingAttrs(html);
Expand Down
9 changes: 7 additions & 2 deletions packages/core/src/compiler/timingCompiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,13 @@ function compileTag(
id = `${isVideo ? "hf-video" : "hf-audio"}-${generateId()}`;
result = injectAttr(result, "id", id);
}
const startStr = getAttr(result, "data-start");
const start = startStr !== null ? parseFloat(startStr) : 0;
let startStr = getAttr(result, "data-start");
if (startStr === null) {
result = injectAttr(result, "data-start", "0");
result = injectAttr(result, "data-hf-auto-start", "");
startStr = "0";
}
const start = parseFloat(startStr);
const mediaStartStr = getAttr(result, "data-media-start");
const mediaStart = mediaStartStr ? parseFloat(mediaStartStr) : 0;

Expand Down
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);
});
});
});
Loading
Loading