diff --git a/docs/docs.json b/docs/docs.json index dcaf6ce2c..69a979e9a 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -75,6 +75,7 @@ "guides/hyperframes-vs-remotion", "guides/gsap-animation", "guides/rendering", + "guides/gpu-capture", "guides/hdr", "guides/performance", "guides/timeline-editing", diff --git a/docs/guides/gpu-capture.mdx b/docs/guides/gpu-capture.mdx new file mode 100644 index 000000000..fbe2ff0c1 --- /dev/null +++ b/docs/guides/gpu-capture.mdx @@ -0,0 +1,109 @@ +--- +title: GPU Frame Capture +description: Opt in to hardware GPU rendering in the headless-Chrome frame capture stage for faster renders on shader-heavy compositions. +--- + +HyperFrames renders in five stages. Stage 4 — **frame capture** — opens every +HTML frame in headless Chromium and takes a screenshot. By default Chromium +runs WebGL through the **SwiftShader** software rasterizer for maximum +cross-platform compatibility. On shader-heavy compositions that software +fallback becomes the render bottleneck. + +Enable `--gpu-capture` to switch Chromium to a hardware ANGLE backend +(Direct3D 11 on Windows, Metal on macOS, OpenGL on Linux) and let your GPU +rasterize instead. + +## When to use it + +- Your render feels slow and the composition uses shader blocks from the + registry (`glitch`, `swirl-vortex`, `sdf-iris`, `light-leak`, etc.) or your + own WebGL canvases. +- You're on a workstation with a working GPU driver (NVIDIA / AMD / Intel on + Windows or Linux, any Apple Silicon or Intel Mac on macOS). +- You've already tried `--gpu` (stage-5 NVENC encoding) and still have + headroom to improve wall time. + +Skip it when: + +- You're in a CI or Docker environment without GPU passthrough. The default + SwiftShader path Just Works. +- Your composition is pure DOM/text with no WebGL. Expected speedup is + marginal (~10–25%) and the compatibility trade-off isn't worth it. + +## Usage + +```bash +hyperframes render --gpu --gpu-capture --output render.mp4 +``` + +`--gpu-capture` is independent of `--gpu` — you can enable either, both, or +neither. `--gpu` drives stage-5 NVENC encoding; `--gpu-capture` drives the +stage-4 Chromium backend. + +Set the env var `HYPERFRAMES_GPU_CAPTURE=1` (or `=true`) in your shell or CI +config to enable without passing the flag every invocation: + +```bash +export HYPERFRAMES_GPU_CAPTURE=1 +hyperframes render --output render.mp4 +``` + +## Platform matrix + +| OS | Backend selected | Default available out of the box | +| ----------- | ---------------- | --------------------------------------------------------- | +| Windows | `d3d11` | Yes on any machine with an installed display driver | +| macOS | `metal` | Yes on any Intel/Apple-Silicon Mac | +| Linux | `opengl` | Usually — needs Mesa/ANGLE libs. Headless CI may need extra setup. | + +On Docker: the host needs `nvidia-container-runtime` (NVIDIA) or equivalent +for GPU passthrough, and the container image must include the GL libs. If GPU +capture silently falls back to software inside a container, leave `--gpu-capture` +off — the env var stays off and Chromium uses SwiftShader. + +## Visual equivalence + +GPU and software rasterization can disagree by 1–2 pixels at anti-aliased +edges. In practice: + +- Average PSNR between a CPU-captured render and a GPU-captured render is + >50 dB on shader-heavy rigs, well above the 40 dB visual-equivalence + threshold. +- Deterministic output is preserved within the GPU backend — the same + composition on the same machine produces byte-identical frames across runs. + +If pixel-exact reproducibility across different rendering machines matters +(e.g. regression tests with image diffs), stick with the SwiftShader default. +If wall-time matters more, opt in. + +## Diagnosing silent fallback + +If you suspect Chromium is still using SwiftShader despite the flag: + +```js +// snippet for a one-off probe script +const puppeteer = require("puppeteer-core"); +const browser = await puppeteer.launch({ + headless: true, + args: ["--use-gl=angle", "--use-angle=d3d11", "--ignore-gpu-blocklist"], +}); +const page = await browser.newPage(); +await page.setContent(''); +const info = await page.evaluate(() => { + const gl = document.getElementById("c").getContext("webgl"); + const ext = gl.getExtension("WEBGL_debug_renderer_info"); + return gl.getParameter(ext.UNMASKED_RENDERER_WEBGL); +}); +console.log(info); +await browser.close(); +``` + +A healthy result looks like `ANGLE (NVIDIA, NVIDIA GeForce RTX 4080 … +Direct3D11 …)`. If you see `SwiftShader Device`, the hardware backend didn't +activate — usually a driver or Docker-passthrough issue. + +## Related + +- `--gpu` — hardware NVENC encoding in stage 5 (see [Rendering](./rendering)). +- `--disable-gpu` — force-disable GPU entirely, including the capture stage + and any driver initialisation. diff --git a/packages/cli/src/capture/index.ts b/packages/cli/src/capture/index.ts index a3f6ca1a6..5fcbc92e8 100644 --- a/packages/cli/src/capture/index.ts +++ b/packages/cli/src/capture/index.ts @@ -72,6 +72,13 @@ export async function captureWebsite( const { ensureBrowser } = await import("../browser/manager.js"); const browser = await ensureBrowser(); const puppeteer = await import("puppeteer-core"); + const { resolveAngleBackend } = await import("@hyperframes/engine"); + // Honor HYPERFRAMES_GPU_CAPTURE for parity with the render command's + // --gpu-capture flag. Backend selection lives in the engine so capture + // and the render pipeline pick the same flag on every platform. + const gpuCapture = + process.env.HYPERFRAMES_GPU_CAPTURE === "true" || process.env.HYPERFRAMES_GPU_CAPTURE === "1"; + const angleBackend = resolveAngleBackend(gpuCapture); const chromeBrowser = await puppeteer.default.launch({ headless: true, executablePath: browser.executablePath, @@ -81,7 +88,7 @@ export async function captureWebsite( "--enable-webgl", "--ignore-gpu-blocklist", "--use-gl=angle", - "--use-angle=swiftshader", + angleBackend, "--disable-blink-features=AutomationControlled", "--disable-background-timer-throttling", "--disable-renderer-backgrounding", diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index b123c3972..e0108b96c 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -99,6 +99,14 @@ export default defineCommand({ description: "Target video bitrate such as 10M. Mutually exclusive with --crf.", }, gpu: { type: "boolean", description: "Use GPU encoding", default: false }, + "gpu-capture": { + type: "boolean", + description: + "Use hardware GPU for frame capture (ANGLE backend: D3D11/Metal/OpenGL). " + + "Default is SwiftShader software GL. Opt in for faster captures on shader-heavy " + + "compositions; requires working GPU drivers. Env fallback: HYPERFRAMES_GPU_CAPTURE=1.", + default: false, + }, quiet: { type: "boolean", description: "Suppress verbose output", @@ -158,6 +166,14 @@ export default defineCommand({ workers = parsed; } + // ── Thread --gpu-capture into engine config via env var ──────────── + // Matches the pattern used for PRODUCER_MAX_CONCURRENT_RENDERS below: + // resolveConfig() picks up HYPERFRAMES_GPU_CAPTURE in the engine worker + // processes, avoiding per-worker RenderJob plumbing. + if (args["gpu-capture"]) { + process.env.HYPERFRAMES_GPU_CAPTURE = "true"; + } + // ── Validate max-concurrent-renders ───────────────────────────────── if (args["max-concurrent-renders"] != null) { const parsed = parseInt(args["max-concurrent-renders"], 10); @@ -309,6 +325,7 @@ export default defineCommand({ format, workers: workerCount, gpu: useGpu, + gpuCapture: Boolean(args["gpu-capture"]), hdr: args.hdr ?? false, crf, videoBitrate, @@ -321,6 +338,7 @@ export default defineCommand({ format, workers: workerCount, gpu: useGpu, + gpuCapture: Boolean(args["gpu-capture"]), hdr: args.hdr ?? false, crf, videoBitrate, @@ -337,6 +355,7 @@ interface RenderOptions { format: "mp4" | "webm" | "mov"; workers: number; gpu: boolean; + gpuCapture: boolean; hdr: boolean; crf?: number; videoBitrate?: string; @@ -461,6 +480,7 @@ async function renderDocker( format: options.format, workers: options.workers, gpu: options.gpu, + gpuCapture: options.gpuCapture, hdr: options.hdr, crf: options.crf, videoBitrate: options.videoBitrate, diff --git a/packages/cli/src/utils/dockerRunArgs.test.ts b/packages/cli/src/utils/dockerRunArgs.test.ts index 287afba65..3f7efcaf6 100644 --- a/packages/cli/src/utils/dockerRunArgs.test.ts +++ b/packages/cli/src/utils/dockerRunArgs.test.ts @@ -7,6 +7,7 @@ const BASE: DockerRenderOptions = { format: "mp4", workers: 4, gpu: false, + gpuCapture: false, hdr: false, crf: undefined, videoBitrate: undefined, @@ -53,7 +54,15 @@ describe("buildDockerRunArgs", () => { expect( buildDockerRunArgs({ ...FIXED_INPUT, - options: { ...BASE, gpu: true, hdr: true, crf: 18, videoBitrate: undefined, quiet: true }, + options: { + ...BASE, + gpu: true, + gpuCapture: true, + hdr: true, + crf: 18, + videoBitrate: undefined, + quiet: true, + }, }), ).toMatchInlineSnapshot(` [ @@ -84,6 +93,7 @@ describe("buildDockerRunArgs", () => { "18", "--quiet", "--gpu", + "--gpu-capture", "--hdr", ] `); @@ -105,20 +115,71 @@ describe("buildDockerRunArgs", () => { expect(args).not.toContain("--hdr"); }); - it("requests host GPU passthrough only when gpu is enabled", () => { + // Forwarding gap caught in PR #471 review: --gpu-capture is plumbed + // through host env (process.env.HYPERFRAMES_GPU_CAPTURE), which the + // container never sees. The containerized CLI must receive the flag + // explicitly so it can re-export the env var inside the container before + // its engine workers spawn. + it("forwards --gpu-capture to the container when gpuCapture is enabled", () => { + const args = buildDockerRunArgs({ + ...FIXED_INPUT, + options: { ...BASE, gpuCapture: true }, + }); + expect(args).toContain("--gpu-capture"); + }); + + it("omits --gpu-capture when gpuCapture is disabled", () => { + const args = buildDockerRunArgs({ ...FIXED_INPUT, options: BASE }); + expect(args).not.toContain("--gpu-capture"); + }); + + it("--gpu-capture and --gpu forward independently into the container", () => { + // The two CLI flags do separate things (NVENC encode vs hardware frame + // capture) — each is forwarded on its own so the containerized CLI can + // re-enable just the one the user asked for. + const captureOnly = buildDockerRunArgs({ + ...FIXED_INPUT, + options: { ...BASE, gpuCapture: true }, + }); + expect(captureOnly).toContain("--gpu-capture"); + expect(captureOnly).not.toContain("--gpu"); + + const encodeOnly = buildDockerRunArgs({ + ...FIXED_INPUT, + options: { ...BASE, gpu: true }, + }); + expect(encodeOnly).toContain("--gpu"); + expect(encodeOnly).not.toContain("--gpu-capture"); + }); + + // Footgun caught in PR #471 review: --gpu-capture without --gpu used to + // forward the flag into the container but never request host GPU passthrough, + // so Chromium silently fell back to swiftshader. Either flag on its own + // implies `--gpus all` now. + it("requests host GPU passthrough when --gpu OR --gpu-capture is enabled", () => { const off = buildDockerRunArgs({ ...FIXED_INPUT, options: BASE }); expect(off).not.toContain("--gpus"); - expect(off).not.toContain("--gpu"); - const on = buildDockerRunArgs({ + const encodeOnly = buildDockerRunArgs({ ...FIXED_INPUT, options: { ...BASE, gpu: true }, }); - // `--gpus all` is a docker run flag (host passthrough); `--gpu` is the - // hyperframes CLI flag forwarded into the container — both must be set. - expect(on).toContain("--gpus"); - expect(on).toContain("all"); - expect(on).toContain("--gpu"); + expect(encodeOnly).toContain("--gpus"); + expect(encodeOnly).toContain("all"); + + const captureOnly = buildDockerRunArgs({ + ...FIXED_INPUT, + options: { ...BASE, gpuCapture: true }, + }); + expect(captureOnly).toContain("--gpus"); + expect(captureOnly).toContain("all"); + + // `--gpus all` should appear exactly once even when both flags are set. + const both = buildDockerRunArgs({ + ...FIXED_INPUT, + options: { ...BASE, gpu: true, gpuCapture: true }, + }); + expect(both.filter((a) => a === "--gpus")).toHaveLength(1); }); it("forwards every renderer-shaped option (regression tripwire for silent drops)", () => { @@ -130,6 +191,7 @@ describe("buildDockerRunArgs", () => { format: "webm", workers: 8, gpu: true, + gpuCapture: true, hdr: true, crf: 16, videoBitrate: undefined, @@ -147,6 +209,7 @@ describe("buildDockerRunArgs", () => { expect(args).toContain("16"); expect(args).toContain("--quiet"); expect(args).toContain("--gpu"); + expect(args).toContain("--gpu-capture"); expect(args).toContain("--hdr"); }); diff --git a/packages/cli/src/utils/dockerRunArgs.ts b/packages/cli/src/utils/dockerRunArgs.ts index b56b5aef5..680e1d3dd 100644 --- a/packages/cli/src/utils/dockerRunArgs.ts +++ b/packages/cli/src/utils/dockerRunArgs.ts @@ -24,6 +24,7 @@ export interface DockerRenderOptions { format: "mp4" | "webm" | "mov"; workers: number; gpu: boolean; + gpuCapture: boolean; hdr: boolean; crf?: number; videoBitrate?: string; @@ -38,8 +39,11 @@ export function buildDockerRunArgs(input: DockerRunArgsInput): string[] { "--platform", "linux/amd64", "--shm-size=2g", - // GPU encoding requires host GPU passthrough. - ...(options.gpu ? ["--gpus", "all"] : []), + // GPU encoding (--gpu) needs NVENC; hardware frame capture (--gpu-capture) + // needs ANGLE talking to the host GPU. Either flag, on its own, requires + // `--gpus all` — without it, Chromium silently falls back to swiftshader + // inside the container and the user gets no perf benefit. + ...(options.gpu || options.gpuCapture ? ["--gpus", "all"] : []), "-v", `${projectDir}:/project:ro`, "-v", @@ -60,6 +64,10 @@ export function buildDockerRunArgs(input: DockerRunArgsInput): string[] { ...(options.videoBitrate ? ["--video-bitrate", options.videoBitrate] : []), ...(options.quiet ? ["--quiet"] : []), ...(options.gpu ? ["--gpu"] : []), + // The containerized CLI re-runs `hyperframes render` with this flag, + // which exports HYPERFRAMES_GPU_CAPTURE=true inside the container before + // the engine workers spawn — same code path as a local render. + ...(options.gpuCapture ? ["--gpu-capture"] : []), ...(options.hdr ? ["--hdr"] : []), ]; } diff --git a/packages/engine/src/config.test.ts b/packages/engine/src/config.test.ts index 69465d67c..b287e2e8e 100644 --- a/packages/engine/src/config.test.ts +++ b/packages/engine/src/config.test.ts @@ -66,6 +66,39 @@ describe("resolveConfig", () => { expect(config.disableGpu).toBe(false); }); + it("defaults gpuCapture to false (software GL fallback)", () => { + const config = resolveConfig(); + expect(config.gpuCapture).toBe(false); + }); + + it("reads HYPERFRAMES_GPU_CAPTURE=true as gpuCapture on", () => { + setEnv("HYPERFRAMES_GPU_CAPTURE", "true"); + + const config = resolveConfig(); + expect(config.gpuCapture).toBe(true); + }); + + it("reads HYPERFRAMES_GPU_CAPTURE=1 as gpuCapture on (local-dev shorthand)", () => { + setEnv("HYPERFRAMES_GPU_CAPTURE", "1"); + + const config = resolveConfig(); + expect(config.gpuCapture).toBe(true); + }); + + it("treats other HYPERFRAMES_GPU_CAPTURE values as false", () => { + setEnv("HYPERFRAMES_GPU_CAPTURE", "yes"); + + const config = resolveConfig(); + expect(config.gpuCapture).toBe(false); + }); + + it("explicit gpuCapture override beats env var", () => { + setEnv("HYPERFRAMES_GPU_CAPTURE", "true"); + + const config = resolveConfig({ gpuCapture: false }); + expect(config.gpuCapture).toBe(false); + }); + it("explicit overrides take precedence over env vars", () => { setEnv("PRODUCER_CORES_PER_WORKER", "5"); diff --git a/packages/engine/src/config.ts b/packages/engine/src/config.ts index a07d3a137..4561de349 100644 --- a/packages/engine/src/config.ts +++ b/packages/engine/src/config.ts @@ -31,6 +31,14 @@ export interface EngineConfig { // ── Browser ────────────────────────────────────────────────────────── chromePath?: string; disableGpu: boolean; + /** + * Use hardware GPU for frame capture (ANGLE backend: D3D11 on Windows, + * Metal on macOS, OpenGL on Linux). Defaults to `false` (SwiftShader software + * GL) for maximum cross-platform compatibility. Opt-in for faster captures + * on machines with working GPU drivers. Env fallback: + * `HYPERFRAMES_GPU_CAPTURE=true` (or `=1`). + */ + gpuCapture: boolean; enableBrowserPool: boolean; browserTimeout: number; protocolTimeout: number; @@ -113,6 +121,7 @@ export const DEFAULT_CONFIG: EngineConfig = { largeRenderThreshold: 1000, disableGpu: false, + gpuCapture: false, enableBrowserPool: false, browserTimeout: 120_000, protocolTimeout: 300_000, @@ -158,6 +167,14 @@ export function resolveConfig(overrides?: Partial): EngineConfig { if (raw === undefined) return fallback; return raw === "true"; }; + // HYPERFRAMES_GPU_CAPTURE accepts "true" or "1" for ergonomics matching + // local-dev conventions (`HYPERFRAMES_GPU_CAPTURE=1`). Other engine env + // vars are "true" only. + const envBoolOrOne = (key: string, fallback: boolean): boolean => { + const raw = env(key); + if (raw === undefined) return fallback; + return raw === "true" || raw === "1"; + }; // Env-var layer (backward compat) const fromEnv: Partial = { @@ -171,6 +188,7 @@ export function resolveConfig(overrides?: Partial): EngineConfig { chromePath: env("PRODUCER_HEADLESS_SHELL_PATH"), disableGpu: envBool("PRODUCER_DISABLE_GPU", DEFAULT_CONFIG.disableGpu), + gpuCapture: envBoolOrOne("HYPERFRAMES_GPU_CAPTURE", DEFAULT_CONFIG.gpuCapture), enableBrowserPool: envBool("PRODUCER_ENABLE_BROWSER_POOL", DEFAULT_CONFIG.enableBrowserPool), browserTimeout: envNum("PRODUCER_PUPPETEER_LAUNCH_TIMEOUT_MS", DEFAULT_CONFIG.browserTimeout), protocolTimeout: envNum( diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 0bfc00bcc..8d93d0ae3 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -50,6 +50,7 @@ export { releaseBrowser, resolveHeadlessShellPath, buildChromeArgs, + resolveAngleBackend, ENABLE_BROWSER_POOL, type BuildChromeArgsOptions, type CaptureMode, diff --git a/packages/engine/src/services/browserManager.test.ts b/packages/engine/src/services/browserManager.test.ts new file mode 100644 index 000000000..ff66d6050 --- /dev/null +++ b/packages/engine/src/services/browserManager.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { buildChromeArgs } from "./browserManager.js"; + +describe("buildChromeArgs", () => { + const platformSpy = vi.spyOn(process, "platform", "get"); + + afterEach(() => { + platformSpy.mockReset(); + }); + + it("defaults to SwiftShader software GL when gpuCapture is off", () => { + platformSpy.mockReturnValue("win32"); + const args = buildChromeArgs({ width: 1920, height: 1080 }); + expect(args).toContain("--use-angle=swiftshader"); + expect(args).not.toContain("--use-angle=d3d11"); + expect(args).not.toContain("--use-angle=metal"); + expect(args).not.toContain("--use-angle=opengl"); + }); + + it("selects D3D11 ANGLE backend on win32 when gpuCapture is on", () => { + platformSpy.mockReturnValue("win32"); + const args = buildChromeArgs({ width: 1920, height: 1080 }, { gpuCapture: true }); + expect(args).toContain("--use-angle=d3d11"); + expect(args).not.toContain("--use-angle=swiftshader"); + }); + + it("selects Metal ANGLE backend on darwin when gpuCapture is on", () => { + platformSpy.mockReturnValue("darwin"); + const args = buildChromeArgs({ width: 1920, height: 1080 }, { gpuCapture: true }); + expect(args).toContain("--use-angle=metal"); + expect(args).not.toContain("--use-angle=swiftshader"); + }); + + it("selects OpenGL ANGLE backend on linux when gpuCapture is on", () => { + platformSpy.mockReturnValue("linux"); + const args = buildChromeArgs({ width: 1920, height: 1080 }, { gpuCapture: true }); + expect(args).toContain("--use-angle=opengl"); + expect(args).not.toContain("--use-angle=swiftshader"); + }); + + it("preserves --use-gl=angle regardless of gpuCapture setting", () => { + platformSpy.mockReturnValue("win32"); + const argsOff = buildChromeArgs({ width: 1920, height: 1080 }); + const argsOn = buildChromeArgs({ width: 1920, height: 1080 }, { gpuCapture: true }); + expect(argsOff).toContain("--use-gl=angle"); + expect(argsOn).toContain("--use-gl=angle"); + }); + + it("appends --disable-gpu only when disableGpu is set", () => { + platformSpy.mockReturnValue("linux"); + const argsDefault = buildChromeArgs({ width: 1280, height: 720 }); + const argsDisabled = buildChromeArgs({ width: 1280, height: 720 }, { disableGpu: true }); + expect(argsDefault).not.toContain("--disable-gpu"); + expect(argsDisabled).toContain("--disable-gpu"); + }); + + it("gpuCapture and disableGpu compose correctly", () => { + // Odd combo but should not throw: --use-angle=d3d11 + --disable-gpu. + // Chrome resolves this at launch; build function stays pure. + platformSpy.mockReturnValue("win32"); + const args = buildChromeArgs( + { width: 1920, height: 1080 }, + { gpuCapture: true, disableGpu: true }, + ); + expect(args).toContain("--use-angle=d3d11"); + expect(args).toContain("--disable-gpu"); + }); +}); diff --git a/packages/engine/src/services/browserManager.ts b/packages/engine/src/services/browserManager.ts index f5ccf8e60..15986948a 100644 --- a/packages/engine/src/services/browserManager.ts +++ b/packages/engine/src/services/browserManager.ts @@ -240,14 +240,33 @@ export interface BuildChromeArgsOptions { captureMode?: CaptureMode; } +/** + * Picks the ANGLE backend flag based on platform. Hardware backends yield + * significantly faster frame capture on shader-heavy compositions, at the + * cost of requiring working GPU drivers. SwiftShader is the safe software + * fallback used when `gpuCapture` is disabled. + */ +export function resolveAngleBackend(gpuCapture: boolean): string { + if (!gpuCapture) return "--use-angle=swiftshader"; + switch (process.platform) { + case "win32": + return "--use-angle=d3d11"; + case "darwin": + return "--use-angle=metal"; + default: + return "--use-angle=opengl"; + } +} + export function buildChromeArgs( options: BuildChromeArgsOptions, - config?: Partial>, + config?: Partial>, ): string[] { // Chrome flags tuned for headless rendering performance. The set below is a // fairly standard "headless-for-capture" configuration — similar profiles // appear in Puppeteer's defaults, Playwright, Remotion, and Chrome's own // headless-shell guidance. + const gpuCapture = config?.gpuCapture ?? DEFAULT_CONFIG.gpuCapture; const chromeArgs = [ "--no-sandbox", "--disable-setuid-sandbox", @@ -255,7 +274,7 @@ export function buildChromeArgs( "--enable-webgl", "--ignore-gpu-blocklist", "--use-gl=angle", - "--use-angle=swiftshader", + resolveAngleBackend(gpuCapture), "--font-render-hinting=none", "--force-color-profile=srgb", `--window-size=${options.width},${options.height}`,