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 .fallowrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"packages/producer/src/runtime-conformance.ts",
"packages/producer/src/benchmark.ts",
"packages/producer/scripts/generate-font-data.ts",
"packages/producer/scripts/validate-fast-video.ts",
"packages/cli/scripts/generate-font-data.ts",
"packages/engine/scripts/test-fitTextFontSize-browser.ts",
"packages/aws-lambda/scripts/*.ts",
Expand Down
52 changes: 52 additions & 0 deletions .github/workflows/fast-video-validation.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Validates the experimental fast-capture (drawElementImage) VIDEO path on a
# native amd64 Linux runner — where chrome-headless-shell's per-frame BeginFrame
# drives a real paint each frame, so drawElementImage's snapshot is fresh and
# video captures correctly. This is the one part of the feature that could not be
# validated locally (macOS has no BeginFrame; Docker-on-rosetta hung).
# See docs/fast-capture-limitations.md (Limitation 2).
#
# Manual trigger: Actions → "Fast-capture video validation" → Run workflow.
name: Fast-capture video validation

on:
# Manual only. NOTE: currently fails by design — fast capture cannot capture
# video on any platform yet (see docs/fast-capture-limitations.md, Limitation 2).
# This is the regression gate for if/when fast video is implemented.
workflow_dispatch:
inputs:
composition:
description: Test composition to render (must contain <video>)
default: sub-composition-video
min_psnr:
description: Min fast-vs-baseline PSNR (dB) to pass
default: "25"

jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3

- name: Build test Docker image (cached)
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: .
file: Dockerfile.test
load: true
tags: hyperframes-producer:test
cache-from: type=gha,scope=regression-test-image
cache-to: type=gha,mode=max,scope=regression-test-image

- name: Validate fast-capture video (drawElement + BeginFrame)
run: |
docker run --rm \
--security-opt seccomp=unconfined \
--shm-size=4g \
-e PRODUCER_VALIDATE_COMP='${{ inputs.composition }}' \
-e PRODUCER_VALIDATE_MIN_PSNR='${{ inputs.min_psnr }}' \
--workdir /app/packages/producer \
--entrypoint bunx \
hyperframes-producer:test tsx scripts/validate-fast-video.ts

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}
Comment on lines +26 to +52
23 changes: 23 additions & 0 deletions packages/cli/src/commands/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,18 @@ export default defineCommand({
"memory thrash on constrained machines. Default: auto-detected from " +
"total RAM (<= 8 GB). Env: PRODUCER_LOW_MEMORY_MODE.",
},
"experimental-fast-capture": {
type: "boolean",
description:
"EXPERIMENTAL. Capture frames via Chrome's drawElementImage API " +
"instead of Page.captureScreenshot — reads DOM paint records directly, " +
"~46% faster on GPU. Transparent (PNG) renders on SwiftShader (Docker) " +
"auto-fall back to screenshot capture. Incompatible with page-side " +
"shader compositing. Default: false. Env: PRODUCER_EXPERIMENTAL_FAST_CAPTURE.",
// No `default` — an omitted flag must stay `undefined` so the `!= null`
// guard below leaves PRODUCER_EXPERIMENTAL_FAST_CAPTURE untouched and the
// env fallback survives (matches the --low-memory-mode idiom).
},
},
// `run` is the citty handler for `hyperframes render` — sequential flag
// validation + render dispatch. Inherited CRITICAL on main (CRAP 1290);
Expand Down Expand Up @@ -393,6 +405,13 @@ export default defineCommand({
process.env.PRODUCER_LOW_MEMORY_MODE = args["low-memory-mode"] ? "true" : "false";
}

// ── Override: experimental fast capture (drawElementImage) ───────────
if (args["experimental-fast-capture"] != null) {
process.env.PRODUCER_EXPERIMENTAL_FAST_CAPTURE = args["experimental-fast-capture"]
? "true"
: "false";
}

// ── Validate max-concurrent-renders ─────────────────────────────────
if (args["max-concurrent-renders"] != null) {
const parsed = parseInt(args["max-concurrent-renders"], 10);
Expand Down Expand Up @@ -590,6 +609,7 @@ export default defineCommand({
entryFile,
outputResolution,
pageSideCompositing: args["page-side-compositing"] !== false,
experimentalFastCapture: args["experimental-fast-capture"] === true,
pageNavigationTimeoutMs,
protocolTimeout,
playerReadyTimeout,
Expand Down Expand Up @@ -643,6 +663,8 @@ interface RenderOptions {
/** Output resolution preset; see `resolveDeviceScaleFactor` for constraints. */
outputResolution?: CanvasResolution;
pageSideCompositing?: boolean;
/** EXPERIMENTAL. drawElementImage frame capture (--experimental-fast-capture). */
experimentalFastCapture?: boolean;
/**
* Puppeteer `page.goto()` timeout for the entry HTML, in milliseconds.
* When omitted, the engine default (60s) applies. Surfaced as
Expand Down Expand Up @@ -877,6 +899,7 @@ async function renderDocker(
entryFile: options.entryFile,
outputResolution: options.outputResolution,
pageSideCompositing: options.pageSideCompositing,
experimentalFastCapture: options.experimentalFastCapture,
pageNavigationTimeoutMs: options.pageNavigationTimeoutMs,
},
});
Expand Down
19 changes: 19 additions & 0 deletions packages/cli/src/utils/dockerRunArgs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ describe("buildDockerRunArgs", () => {
videoBitrate: undefined,
quiet: true,
entryFile: "compositions/intro.html",
experimentalFastCapture: true,
},
});
// Each value must reach the container exactly once. If a future option
Expand All @@ -187,6 +188,24 @@ describe("buildDockerRunArgs", () => {
expect(args).toContain("--hdr");
expect(args).toContain("--composition");
expect(args).toContain("compositions/intro.html");
expect(args).toContain("--experimental-fast-capture");
});

it("forwards --experimental-fast-capture only when enabled", () => {
const on = buildDockerRunArgs({
...FIXED_INPUT,
options: { ...BASE, experimentalFastCapture: true },
});
expect(on).toContain("--experimental-fast-capture");

const off = buildDockerRunArgs({
...FIXED_INPUT,
options: { ...BASE, experimentalFastCapture: false },
});
expect(off).not.toContain("--experimental-fast-capture");

const absent = buildDockerRunArgs({ ...FIXED_INPUT, options: BASE });
expect(absent).not.toContain("--experimental-fast-capture");
});

it("forwards --format png-sequence to the container", () => {
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/utils/dockerRunArgs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export interface DockerRenderOptions {
/** Output resolution preset (e.g. "landscape-4k"). Forwarded as `--resolution`. */
outputResolution?: string;
pageSideCompositing?: boolean;
/** EXPERIMENTAL. drawElementImage frame capture; forwarded as `--experimental-fast-capture`. */
experimentalFastCapture?: boolean;
/**
* Puppeteer page-navigation timeout, in milliseconds. Forwarded to the
* in-container CLI as `--browser-timeout <seconds>` (the CLI takes
Expand Down Expand Up @@ -130,6 +132,7 @@ export function buildDockerRunArgs(input: DockerRunArgsInput): string[] {
...(options.entryFile ? ["--composition", options.entryFile] : []),
...(options.outputResolution ? ["--resolution", options.outputResolution] : []),
...(options.pageSideCompositing === false ? ["--no-page-side-compositing"] : []),
...(options.experimentalFastCapture ? ["--experimental-fast-capture"] : []),
...(options.pageNavigationTimeoutMs != null
? ["--browser-timeout", String(options.pageNavigationTimeoutMs / 1000)]
: []),
Expand Down
30 changes: 30 additions & 0 deletions packages/engine/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,36 @@ describe("resolveConfig", () => {
});
});

describe("useDrawElement (PRODUCER_EXPERIMENTAL_FAST_CAPTURE)", () => {
it("defaults to false", () => {
const config = resolveConfig();
expect(config.useDrawElement).toBe(false);
});

it("enabled when PRODUCER_EXPERIMENTAL_FAST_CAPTURE=true", () => {
setEnv("PRODUCER_EXPERIMENTAL_FAST_CAPTURE", "true");
const config = resolveConfig();
expect(config.useDrawElement).toBe(true);
});

it("explicit override wins over the env var", () => {
setEnv("PRODUCER_EXPERIMENTAL_FAST_CAPTURE", "true");
const config = resolveConfig({ useDrawElement: false });
expect(config.useDrawElement).toBe(false);
});

it("forces page-side compositing off when enabled (incompatible strategies)", () => {
const config = resolveConfig({ useDrawElement: true, enablePageSideCompositing: true });
expect(config.useDrawElement).toBe(true);
expect(config.enablePageSideCompositing).toBe(false);
});

it("leaves page-side compositing on when fast capture is off", () => {
const config = resolveConfig({ useDrawElement: false });
expect(config.enablePageSideCompositing).toBe(true);
});
});

describe("lowMemoryMode", () => {
it("forces on for truthy PRODUCER_LOW_MEMORY_MODE values", () => {
setEnv("PRODUCER_LOW_MEMORY_MODE", "true");
Expand Down
24 changes: 23 additions & 1 deletion packages/engine/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ export interface EngineConfig {
expectedChromiumMajor?: number;
/** Force screenshot capture mode (skip BeginFrame even on Linux). */
forceScreenshot: boolean;
/**
* EXPERIMENTAL. Use drawElementImage for frame capture (requires the
* CanvasDrawElement Chrome flag, added globally in buildChromeArgs).
* Surfaced via the CLI `--experimental-fast-capture` flag.
* Env fallback: `PRODUCER_EXPERIMENTAL_FAST_CAPTURE`.
*/
useDrawElement: boolean;
/**
* Low-memory render profile. When `true`, the orchestrator collapses the
* pipeline to its cheapest shape on memory-constrained hosts: it skips the
Expand Down Expand Up @@ -208,6 +215,7 @@ export const DEFAULT_CONFIG: EngineConfig = {
browserTimeout: 120_000,
protocolTimeout: 300_000,
forceScreenshot: false,
useDrawElement: false,
// Auto-detected per host in `resolveConfig`; defaults off for the raw
// DEFAULT_CONFIG (used directly by tests and worker-sizing fallbacks).
lowMemoryMode: false,
Expand Down Expand Up @@ -307,6 +315,7 @@ export function resolveConfig(overrides?: Partial<EngineConfig>): EngineConfig {
: undefined,

forceScreenshot: envBool("PRODUCER_FORCE_SCREENSHOT", DEFAULT_CONFIG.forceScreenshot),
useDrawElement: envBool("PRODUCER_EXPERIMENTAL_FAST_CAPTURE", DEFAULT_CONFIG.useDrawElement),
lowMemoryMode: resolveLowMemoryMode(),
enablePageSideCompositing: envBool(
"HF_PAGE_SIDE_COMPOSITING",
Expand Down Expand Up @@ -379,9 +388,22 @@ export function resolveConfig(overrides?: Partial<EngineConfig>): EngineConfig {
// Remove undefined values so they don't override defaults
const cleanEnv = Object.fromEntries(Object.entries(fromEnv).filter(([, v]) => v !== undefined));

return {
const merged = {
...DEFAULT_CONFIG,
...cleanEnv,
...overrides,
};

// drawElement capture and page-side shader compositing are mutually
// incompatible capture strategies (drawElement reads paint records directly
// and bypasses the page-side prepare→composite→resolve protocol). When
// experimental fast capture is on, force page-side compositing off so shader
// transitions fall back to the Node-side layered blend rather than silently
// dropping. This keeps the flag self-consistent and avoids a per-session
// incompatibility warning on every fast-capture render.
if (merged.useDrawElement) {
merged.enablePageSideCompositing = false;
}

return merged;
}
3 changes: 2 additions & 1 deletion packages/engine/src/services/browserManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ async function getPuppeteer(): Promise<PuppeteerNode> {

// "beginframe" = atomic compositor control via HeadlessExperimental.beginFrame (Linux only)
// "screenshot" = renderSeek + Page.captureScreenshot (all platforms)
export type CaptureMode = "beginframe" | "screenshot";
// "drawelement" = BeginFrame compositor advance + canvas.drawElementImage capture
export type CaptureMode = "beginframe" | "screenshot" | "drawelement";

export interface AcquiredBrowser {
browser: Browser;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* DrawElement integration coverage — record of browser-level validation.
*
* The unit tests in `drawElementService.test.ts` mock `page.evaluate`, so they
* cannot exercise real frame capture. The behaviours below were validated with
* local Docker harnesses (dev scaffolding under `spikes/`, not committed —
* spikes are untracked in this repo) that drive the real engine functions
* against a real Chrome/headless-shell. This file records what was checked and
* the results, and is the place to add in-suite browser tests if/when the
* package gains a headless-browser test runner.
*
* ── T1 + T2: Docker / SwiftShader (real engine fns vs real SwiftShader) ─────────
* Last validated 2026-06-08 — ALL PASS:
* T1 opaque drawElement frame vs Page.captureScreenshot baseline — PSNR = ∞
* (pixel-identical) on SwiftShader, even with CSS transforms present.
* T2a detectSwiftShader() === true inside headless-shell + --use-angle=swiftshader.
* T2b transparent + SwiftShader → resolveDrawElementCaptureMode === "screenshot"
* (fallback; the transparent drawElement path is broken on SwiftShader).
* T2c opaque + SwiftShader → "drawelement".
*
* ── E2E: full producer pipeline (--experimental-fast-capture) ───────────────────
* A real producer render (css-spinner composition → mp4) with
* PRODUCER_EXPERIMENTAL_FAST_CAPTURE=true logged "drawElement canvas injected"
* and produced a valid mp4 — proving env → resolveConfig → captureCfg →
* createCaptureSession → drawelement mode, plus jpeg-frame encode through ffmpeg.
*
* ── T3: transparent drawElement on a real GPU host ──────────────────────────────
* PSNR = ∞ vs screenshot on GPU (empty A=0, semi-transparent A≈128, opaque
* A=255). Cannot run in Docker (SwiftShader-only); validated on a GPU host.
*/

import { describe, it } from "vitest";

describe.skip("drawElementService integration (browser/Docker — see validation record above)", () => {
it.skip("T1+T2 validated against real SwiftShader (Docker)", () => {});
it.skip("E2E validated through the producer pipeline (--experimental-fast-capture)", () => {});
it.skip("T3 validated on a real-GPU host", () => {});
});
68 changes: 68 additions & 0 deletions packages/engine/src/services/drawElementService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, expect, it, vi } from "vitest";
import type { Page } from "puppeteer-core";
import { detectSwiftShader, resolveDrawElementCaptureMode } from "./drawElementService.js";

// ── detectSwiftShader ──────────────────────────────────────────────────────────

describe("detectSwiftShader", () => {
function makePage(evaluateResult: unknown): Page {
return {
evaluate: vi.fn().mockResolvedValue(evaluateResult),
} as unknown as Page;
}

it("returns true when renderer includes 'swiftshader'", async () => {
const page = makePage(true);
expect(await detectSwiftShader(page)).toBe(true);
});

it("returns false for a standard GPU renderer string", async () => {
const page = makePage(false);
expect(await detectSwiftShader(page)).toBe(false);
});

it("returns false when WebGL is unavailable", async () => {
const page = makePage(false);
expect(await detectSwiftShader(page)).toBe(false);
});

it("passes a function to page.evaluate", async () => {
const page = makePage(false);
await detectSwiftShader(page);
expect(page.evaluate).toHaveBeenCalledWith(expect.any(Function));
});
});

// ── resolveDrawElementCaptureMode ──────────────────────────────────────────────

describe("resolveDrawElementCaptureMode", () => {
// signature: (isSwiftShader, transparent, hasVideo?, beginFramePaints?)
it("opaque + SwiftShader → drawelement (opaque works on SwiftShader)", () => {
expect(resolveDrawElementCaptureMode(true, false)).toBe("drawelement");
});

it("transparent + SwiftShader → screenshot (SwiftShader bug: sub-layers dropped)", () => {
expect(resolveDrawElementCaptureMode(true, true)).toBe("screenshot");
});

it("transparent + GPU → drawelement (GPU handles transparent correctly)", () => {
expect(resolveDrawElementCaptureMode(false, true)).toBe("drawelement");
});

it("opaque + GPU → drawelement", () => {
expect(resolveDrawElementCaptureMode(false, false)).toBe("drawelement");
});

// ── video routing: drawElementImage can't capture video on any platform ──
it("video → screenshot (drawElementImage does not capture video frames)", () => {
expect(resolveDrawElementCaptureMode(false, false, /* hasVideo */ true)).toBe("screenshot");
});

it("video + GPU still screenshot (verified broken on macOS and Linux/BeginFrame)", () => {
expect(resolveDrawElementCaptureMode(false, false, true)).toBe("screenshot");
});

it("no video → drawelement", () => {
expect(resolveDrawElementCaptureMode(false, false, false)).toBe("drawelement");
});
});
Loading
Loading