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
1 change: 1 addition & 0 deletions docs/packages/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -921,6 +921,7 @@ How it uses the machine, and when it gives up:
| `--low-memory-mode` / `--no-low-memory-mode` | auto at 8 GB RAM or less | Safe profile: one worker, screenshot capture, no calibration. See the note below. |
| `--gpu` | off | Hardware FFmpeg encoding (NVENC, VideoToolbox, AMF, VAAPI, QSV) |
| `--browser-gpu` / `--no-browser-gpu` | auto locally, off in Docker | Host GPU for Chrome and WebGL capture, or software rendering. Auto probes WebGL once and falls back. |
| `--require-beginframe` | off | Fail instead of falling back to screenshot capture when BeginFrame can't run. Rejected with `--docker`. Env: `PRODUCER_REQUIRE_BEGINFRAME`. |
| `--page-side-compositing` / `--no-page-side-compositing` | on | Page-side WebGL for SDR shader transitions in mp4, hls and gif, ~6× faster. webm and mov use the layered path; gif always uses page-side WebGL, even with `--no-page-side-compositing`. |
| `--experimental-fast-capture` | on where it can engage | Chrome's draw-element capture, ~2× faster; falls back to screenshots on its own. Env: `PRODUCER_EXPERIMENTAL_FAST_CAPTURE`. |
| `--frames-cache-dir` | `<tmpdir>/hyperframes-extract-cache-<uid>` | Where extracted source frames cache. `off`, `none`, `false`, or `0` disables it. `doctor` reports the live value. |
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/commands/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,13 @@ export default defineCommand({
"memory thrash on constrained machines. Default: auto-detected from " +
"total RAM (<= 8 GB). Env: PRODUCER_LOW_MEMORY_MODE.",
},
"require-beginframe": {
type: "boolean",
description:
"Fail the render instead of falling back to screenshot capture when BeginFrame " +
"cannot run (Linux with chrome-headless-shell only; rejected with --docker). " +
"Env: PRODUCER_REQUIRE_BEGINFRAME.",
},
"experimental-fast-capture": {
type: "boolean",
description:
Expand Down
34 changes: 34 additions & 0 deletions packages/cli/src/commands/render/plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,40 @@ describe("createRenderPlan", () => {
}
});

it("maps --require-beginframe to the engine's environment switch", () => {
expect(createRenderPlan({ dir: projectDir, "require-beginframe": true }).environment).toEqual({
PRODUCER_REQUIRE_BEGINFRAME: "true",
});
expect(createRenderPlan({ dir: projectDir }).environment).not.toHaveProperty(
"PRODUCER_REQUIRE_BEGINFRAME",
);
});

it("rejects --require-beginframe with --docker instead of dropping the requirement", () => {
expect(() =>
createRenderPlan({ dir: projectDir, docker: true, "require-beginframe": true }),
).toThrow(CliUsageError);
expect(
createRenderPlan({ dir: projectDir, docker: true, "require-beginframe": false }).environment,
).toEqual({ PRODUCER_REQUIRE_BEGINFRAME: "false" });
});

it("rejects PRODUCER_REQUIRE_BEGINFRAME=true with --docker", () => {
const previous = process.env.PRODUCER_REQUIRE_BEGINFRAME;
process.env.PRODUCER_REQUIRE_BEGINFRAME = "true";
try {
expect(() => createRenderPlan({ dir: projectDir, docker: true })).toThrow(CliUsageError);
expect(() => createRenderPlan({ dir: projectDir })).not.toThrow();
expect(
createRenderPlan({ dir: projectDir, docker: true, "require-beginframe": false })
.environment,
).toEqual({ PRODUCER_REQUIRE_BEGINFRAME: "false" });
} finally {
if (previous === undefined) delete process.env.PRODUCER_REQUIRE_BEGINFRAME;
else process.env.PRODUCER_REQUIRE_BEGINFRAME = previous;
}
});

it("resolves a relative frame-cache directory into the execution environment", () => {
const plan = createRenderPlan({ dir: projectDir, "frames-cache-dir": "./frame-cache" });
expect(plan.environment.HYPERFRAMES_EXTRACT_CACHE_DIR).toBe(resolve("./frame-cache"));
Expand Down
14 changes: 14 additions & 0 deletions packages/cli/src/commands/render/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export interface RenderCommandArgs {
resume?: boolean;
"keep-segments"?: boolean;
"low-memory-mode"?: boolean;
"require-beginframe"?: boolean;
"experimental-fast-capture"?: boolean;
"frames-cache-dir"?: string;
}
Expand Down Expand Up @@ -384,6 +385,9 @@ export function createRenderPlan(args: RenderCommandArgs, now = new Date()): Ren
if (args["low-memory-mode"] != null) {
environment.PRODUCER_LOW_MEMORY_MODE = args["low-memory-mode"] ? "true" : "false";
}
if (args["require-beginframe"] != null) {
environment.PRODUCER_REQUIRE_BEGINFRAME = args["require-beginframe"] ? "true" : "false";
}
if (args["experimental-fast-capture"] != null) {
environment.PRODUCER_EXPERIMENTAL_FAST_CAPTURE = args["experimental-fast-capture"]
? "true"
Expand Down Expand Up @@ -460,6 +464,16 @@ export function createRenderPlan(args: RenderCommandArgs, now = new Date()): Ren
);
failUsage();
}
const requireBeginFrame =
args["require-beginframe"] ?? process.env.PRODUCER_REQUIRE_BEGINFRAME === "true";
if (useDocker && requireBeginFrame) {
errorBox(
"BeginFrame is local-only",
"--require-beginframe (or PRODUCER_REQUIRE_BEGINFRAME=true) needs host Chrome's BeginFrame capture. Docker mode always captures with screenshots on software GL, so the requirement could never hold.",
"Run without --docker, or drop --require-beginframe.",
);
failUsage();
}

const videoBitrate = args["video-bitrate"]?.trim();
if (args.crf != null && videoBitrate) {
Expand Down
8 changes: 8 additions & 0 deletions packages/engine/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,14 @@ describe("resolveConfig", () => {
expect(config.enableBrowserPool).toBe(true);
});

it("requires BeginFrame only when PRODUCER_REQUIRE_BEGINFRAME is true", () => {
unsetEnv("PRODUCER_REQUIRE_BEGINFRAME");
expect(resolveConfig().requireBeginFrame).toBe(false);

setEnv("PRODUCER_REQUIRE_BEGINFRAME", "true");
expect(resolveConfig().requireBeginFrame).toBe(true);
});

it("lets env vars opt out of default streaming encode", () => {
setEnv("PRODUCER_ENABLE_STREAMING_ENCODE", "false");

Expand Down
8 changes: 8 additions & 0 deletions packages/engine/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ export interface EngineConfig {
expectedChromiumMajor?: number;
/** Force screenshot capture mode (skip BeginFrame even on Linux). */
forceScreenshot: boolean;
/**
* Fail a capture session that would not run BeginFrame, instead of
* falling back to screenshot capture.
*/
requireBeginFrame: boolean;
/**
* Static-frame dedup: reuse byte-identical frames instead of re-seeking +
* re-screenshotting (anchor-verified at init). Default ON; disable via
Expand Down Expand Up @@ -291,6 +296,7 @@ export const DEFAULT_CONFIG: EngineConfig = {
browserTimeout: 120_000,
protocolTimeout: 300_000,
forceScreenshot: false,
requireBeginFrame: false,
staticFrameDedup: true,
useDrawElement: true,
enableDrawElementWorkerEncode: true,
Expand Down Expand Up @@ -342,6 +348,7 @@ const BOOLEAN_ENGINE_CONFIG_FIELDS = [
"disableGpu",
"enableBrowserPool",
"forceScreenshot",
"requireBeginFrame",
"staticFrameDedup",
"useDrawElement",
"enableDrawElementWorkerEncode",
Expand Down Expand Up @@ -851,6 +858,7 @@ export function resolveConfig(overrides?: Partial<EngineConfig>): EngineConfig {
: undefined,

forceScreenshot: envBool("PRODUCER_FORCE_SCREENSHOT", DEFAULT_CONFIG.forceScreenshot),
requireBeginFrame: envBool("PRODUCER_REQUIRE_BEGINFRAME", DEFAULT_CONFIG.requireBeginFrame),
staticFrameDedup: resolveStaticFrameDedup(),
useDrawElement: envBool("PRODUCER_EXPERIMENTAL_FAST_CAPTURE", DEFAULT_CONFIG.useDrawElement),
enableDrawElementWorkerEncode: envBool(
Expand Down
1 change: 1 addition & 0 deletions packages/engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export {
compositionRequiresWebGpu,
assertWebGpuAdapterAvailable,
WebGpuUnavailableError,
BeginFrameRequiredError,
ENABLE_BROWSER_POOL,
BrowserLeasePool,
type BuildChromeArgsOptions,
Expand Down
1 change: 1 addition & 0 deletions packages/engine/src/services/browserLeasePool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ function fingerprint(args: string[] = ["--one"]): BrowserLaunchFingerprint {
browserTimeoutMs: 1_000,
protocolTimeoutMs: 2_000,
requestedCaptureMode: "screenshot",
requireBeginFrame: false,
};
}

Expand Down
2 changes: 2 additions & 0 deletions packages/engine/src/services/browserLeasePool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface BrowserLaunchFingerprint {
readonly browserTimeoutMs: number;
readonly protocolTimeoutMs: number;
readonly requestedCaptureMode: CaptureMode;
readonly requireBeginFrame: boolean;
}

export interface BrowserLaunchResult {
Expand Down Expand Up @@ -58,6 +59,7 @@ function fingerprintKey(fingerprint: Readonly<BrowserLaunchFingerprint>): string
fingerprint.browserTimeoutMs,
fingerprint.protocolTimeoutMs,
fingerprint.requestedCaptureMode,
fingerprint.requireBeginFrame,
]);
}

Expand Down
60 changes: 60 additions & 0 deletions packages/engine/src/services/browserManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
_probeBeginFrameSupportForTests,
_setPuppeteerForTests,
acquireBrowser,
BeginFrameRequiredError,
buildChromeArgs,
compositionRequiresWebGpu,
assertWebGpuAdapterAvailable,
Expand Down Expand Up @@ -300,6 +301,65 @@ describe("browser launch capture-mode contract", () => {
});
});

describe.skipIf(process.platform !== "linux")("BeginFrame probe failure", () => {
let dir: string;
let chromePath: string;
let launch: ReturnType<typeof vi.fn>;

function browserFailingProbe(): Browser {
return {
connected: true,
newPage: vi.fn().mockRejectedValue(new Error("renderer unavailable")),
version: vi.fn().mockResolvedValue("HeadlessChrome/152.0.0.0"),
close: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn(),
process: () => ({ kill: vi.fn(), killed: false }),
} as unknown as Browser;
}

beforeEach(() => {
_resetBrowserPoolForTests();
dir = mkdtempSync(join(tmpdir(), "hf-require-beginframe-"));
chromePath = join(dir, "chrome-headless-shell");
writeFileSync(chromePath, "");
launch = vi.fn().mockImplementation(async () => browserFailingProbe());
_setPuppeteerForTests({ launch } as unknown as PuppeteerNode);
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "warn").mockImplementation(() => {});
});

afterEach(async () => {
await drainBrowserPool();
_setPuppeteerForTests(undefined);
vi.restoreAllMocks();
rmSync(dir, { recursive: true, force: true });
});

it("relaunches in screenshot mode by default", async () => {
const lease = await acquireBrowser(["--enable-begin-frame-control"], {
chromePath,
enableBrowserPool: false,
});

expect(lease.captureMode).toBe("screenshot");
expect(launch).toHaveBeenCalledTimes(2);
expect(launch.mock.calls[1]?.[0].args).not.toContain("--enable-begin-frame-control");
await lease.release();
});

it("fails without a screenshot relaunch when BeginFrame is required", async () => {
const acquired = acquireBrowser(["--enable-begin-frame-control"], {
chromePath,
enableBrowserPool: false,
requireBeginFrame: true,
});

await expect(acquired).rejects.toThrow(BeginFrameRequiredError);
await expect(acquired).rejects.toThrow(/beginFrame probe failed.*renderer unavailable/);
expect(launch).toHaveBeenCalledOnce();
});
});

describe("resolveBrowserGpuMode", () => {
const setMockWebGlProbe = (info: { hasWebGL: boolean; vendor: string; renderer: string }) => {
const close = vi.fn().mockResolvedValue(undefined);
Expand Down
29 changes: 27 additions & 2 deletions packages/engine/src/services/browserManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -652,13 +652,17 @@ function logResolvedBrowserGpuMode(resolved: "hardware" | "software", reason: st
function createBrowserLaunchFingerprint(
chromeArgs: string[],
config?: Partial<
Pick<EngineConfig, "browserTimeout" | "protocolTimeout" | "chromePath" | "forceScreenshot">
Pick<
EngineConfig,
"browserTimeout" | "protocolTimeout" | "chromePath" | "forceScreenshot" | "requireBeginFrame"
>
>,
): BrowserLaunchFingerprint {
const launchConfig = {
browserTimeout: DEFAULT_CONFIG.browserTimeout,
protocolTimeout: DEFAULT_CONFIG.protocolTimeout,
forceScreenshot: DEFAULT_CONFIG.forceScreenshot,
requireBeginFrame: DEFAULT_CONFIG.requireBeginFrame,
...config,
};
const headlessShell = resolveHeadlessShellPath(launchConfig);
Expand All @@ -681,6 +685,7 @@ function createBrowserLaunchFingerprint(
browserTimeoutMs: launchConfig.browserTimeout,
protocolTimeoutMs: launchConfig.protocolTimeout,
requestedCaptureMode,
requireBeginFrame: launchConfig.requireBeginFrame,
};
}

Expand All @@ -692,7 +697,12 @@ export async function acquireBrowser(
config?: Partial<
Pick<
EngineConfig,
"browserTimeout" | "protocolTimeout" | "enableBrowserPool" | "chromePath" | "forceScreenshot"
| "browserTimeout"
| "protocolTimeout"
| "enableBrowserPool"
| "chromePath"
| "forceScreenshot"
| "requireBeginFrame"
>
>,
): Promise<AcquiredBrowser> {
Expand Down Expand Up @@ -735,6 +745,13 @@ async function launchBrowser(
if (!probe.supported) {
await closeBrowserAfterFailedProbe(browser);
browser = undefined;
if (fingerprint.requireBeginFrame) {
throw new BeginFrameRequiredError(
`the HeadlessExperimental.beginFrame probe failed after ${probe.durationMs}ms ` +
`(${probe.detail}). Browsers starting together on one GPU can miss this ` +
`deadline, so fewer --workers may help`,
);
}
console.warn(
`[BrowserManager] HeadlessExperimental.beginFrame probe failed after ${probe.durationMs}ms: ` +
`${probe.detail}; falling back to screenshot mode.`,
Expand Down Expand Up @@ -988,6 +1005,14 @@ export function compositionRequiresWebGpu(html: string): boolean {
return false;
}

/** BeginFrame capture was required but this session would have used screenshot capture. */
export class BeginFrameRequiredError extends Error {
constructor(reason: string) {
super(`BeginFrame capture is required, but ${reason}; not falling back to screenshot capture.`);
this.name = "BeginFrameRequiredError";
}
}

/** No hardware WebGPU adapter on this host; distinct from a browser or navigation failure. */
export class WebGpuUnavailableError extends Error {
constructor() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { PuppeteerNode } from "puppeteer-core";
import {
_resetBrowserPoolForTests,
_setPuppeteerForTests,
BeginFrameRequiredError,
drainBrowserPool,
} from "./browserManager.js";
import { createCaptureSession } from "./frameCapture.js";
import type { EngineConfig } from "../config.js";

describe.skipIf(process.platform !== "linux")("createCaptureSession with requireBeginFrame", () => {
let dir: string;
let chromePath: string;
let launch: ReturnType<typeof vi.fn>;

beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "hf-require-beginframe-session-"));
chromePath = join(dir, "chrome-headless-shell");
writeFileSync(chromePath, "");
launch = vi.fn();
_setPuppeteerForTests({ launch } as unknown as PuppeteerNode);
});

afterEach(async () => {
await drainBrowserPool();
_resetBrowserPoolForTests();
_setPuppeteerForTests(undefined);
rmSync(dir, { recursive: true, force: true });
});

function create(config: Partial<EngineConfig>) {
return createCaptureSession(
"http://127.0.0.1:3000",
join(dir, "frames"),
{ width: 320, height: 180, fps: { num: 30, den: 1 }, format: "jpeg", requiresWebGpu: false },
null,
{ chromePath, browserGpuMode: "software", requireBeginFrame: true, ...config },
);
}

it("refuses a session forced to screenshot capture before launching Chrome", async () => {
const session = create({ forceScreenshot: true });

await expect(session).rejects.toThrow(BeginFrameRequiredError);
await expect(session).rejects.toThrow(/set to screenshot capture/);
expect(launch).not.toHaveBeenCalled();
});

it("names the software GPU when that is what rules out BeginFrame", async () => {
await expect(create({ forceScreenshot: false })).rejects.toThrow(
/browser GPU resolved to software/,
);
expect(launch).not.toHaveBeenCalled();
});
});
Loading