feat: add video frame format render option (#1481)

* feat: add video frame format render option

* refactor: single source of truth for video-frame-format allow-list

Addresses PR review (Via) on #1481: the ["auto","jpg","png"] set was
declared three times — render.ts (VIDEO_FRAME_FORMATS), server.ts
(inline includes), and renderConfigValidation.ts
(ALLOWED_VIDEO_FRAME_FORMATS) — three boundaries to update when a new
extraction format lands.

Hoist the constant + a reusable `isVideoFrameFormat` type guard into
@hyperframes/engine (where VideoFrameFormat is defined) and route all
three call sites through them. Behavior unchanged; also drops two
`as RenderConfig[...]` casts in favor of the guard (narrowing over
assertion, per repo TS conventions).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Xuelong Mu <xuelongmu@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
James Russo
2026-06-15 22:05:20 -07:00
committed by GitHub
co-authored by Claude Opus 4.8 Xuelong Mu
parent e812fc8895
commit 36b24acf20
18 changed files with 392 additions and 17 deletions
@@ -30,6 +30,7 @@ describe("validateDistributedRenderConfig", () => {
maxParallelChunks: 16,
runtimeCap: "lambda",
hdrMode: "force-sdr",
videoFrameFormat: "png",
};
expect(validateDistributedRenderConfig(cfg)).toBe(cfg);
});
@@ -92,6 +93,14 @@ describe("validateDistributedRenderConfig", () => {
{ ...VALID, bitrate: "fast" } satisfies SerializableDistributedRenderConfig,
"config.bitrate",
],
[
"unsupported videoFrameFormat",
{
...VALID,
videoFrameFormat: "webp",
} as unknown as SerializableDistributedRenderConfig,
"config.videoFrameFormat",
],
[
"non-positive chunkSize",
{ ...VALID, chunkSize: 0 } satisfies SerializableDistributedRenderConfig,
+15
View File
@@ -237,6 +237,21 @@ describe("renderLocal browser GPU config", () => {
expect(producerState.createdJobs[0]?.gifLoop).toBe(3);
});
it("forwards videoFrameFormat to createRenderJob", async () => {
await renderLocal("/tmp/project", "/tmp/out.mp4", {
fps: { num: 30, den: 1 },
quality: "standard",
format: "mp4",
gpu: false,
browserGpuMode: "software",
hdrMode: "auto",
quiet: true,
videoFrameFormat: "png",
});
expect(producerState.createdJobs[0]?.videoFrameFormat).toBe("png");
});
it("omits variables from createRenderJob when not provided", async () => {
await renderLocal("/tmp/project", "/tmp/out.mp4", {
fps: { num: 30, den: 1 },
+24
View File
@@ -71,6 +71,7 @@ import { buildDockerRunArgs, resolveDockerPlatform } from "../utils/dockerRunArg
import { normalizeErrorMessage } from "../utils/errorMessage.js";
import { runEnvironmentChecks } from "../browser/preflight.js";
import type { ProducerLogger, RenderJob } from "@hyperframes/producer";
import { isVideoFrameFormat, type VideoFrameFormat } from "@hyperframes/engine";
import {
normalizeResolutionFlag,
parseFps,
@@ -181,6 +182,14 @@ export default defineCommand({
type: "string",
description: "GIF loop count, 0 = infinite. Range: 0-65535. Only used with --format gif.",
},
"video-frame-format": {
type: "string",
description:
"Source video frame extraction format: auto, jpg, png (default: auto). " +
"Use png for UI recordings, screen captures, and color-sensitive source videos; " +
"alpha-capable sources always extract as PNG.",
default: "auto",
},
workers: {
type: "string",
alias: "w",
@@ -375,6 +384,16 @@ export default defineCommand({
}
const gifLoop = gifLoopParse.value ?? (format === "gif" ? 0 : undefined);
const videoFrameFormatRaw = args["video-frame-format"] ?? "auto";
if (!isVideoFrameFormat(videoFrameFormatRaw)) {
errorBox(
"Invalid video-frame-format",
`Got "${videoFrameFormatRaw}". Must be auto, jpg, or png.`,
);
process.exit(1);
}
const videoFrameFormat = videoFrameFormatRaw;
// ── Validate resolution ────────────────────────────────────────────────
let outputResolution: CanvasResolution | undefined;
if (args.resolution !== undefined) {
@@ -768,6 +787,7 @@ export default defineCommand({
hdrMode: args.sdr ? "force-sdr" : args.hdr ? "force-hdr" : "auto",
crf,
videoBitrate,
videoFrameFormat,
quiet,
variables,
entryFile,
@@ -790,6 +810,7 @@ export default defineCommand({
hdrMode: args.sdr ? "force-sdr" : args.hdr ? "force-hdr" : "auto",
crf,
videoBitrate,
videoFrameFormat,
quiet,
browserPath,
variables,
@@ -825,6 +846,7 @@ interface RenderOptions {
hdrMode: "auto" | "force-hdr" | "force-sdr";
crf?: number;
videoBitrate?: string;
videoFrameFormat?: VideoFrameFormat;
quiet: boolean;
browserPath?: string;
variables?: Record<string, unknown>;
@@ -1067,6 +1089,7 @@ async function renderDocker(
hdrMode: options.hdrMode,
crf: options.crf,
videoBitrate: options.videoBitrate,
videoFrameFormat: options.videoFrameFormat,
quiet: options.quiet,
variables: options.variables,
entryFile: options.entryFile,
@@ -1176,6 +1199,7 @@ export async function renderLocal(
hdrMode: options.hdrMode,
crf: options.crf,
videoBitrate: options.videoBitrate,
videoFrameFormat: options.videoFrameFormat,
variables: options.variables,
entryFile: options.entryFile,
outputResolution: options.outputResolution,
+2
View File
@@ -19,6 +19,7 @@ Requires: Docker installed and running.
- `-w, --workers` — Parallel workers 1-8 (default: auto)
- `--crf` — Override encoder CRF (mutually exclusive with `--video-bitrate`)
- `--video-bitrate` — Target video bitrate such as `10M` (mutually exclusive with `--crf`)
- `--video-frame-format` — Source video frame extraction format: `auto`, `jpg`, or `png` (default: `auto`). Use `png` for UI recordings, screen captures, and color-sensitive source videos.
- `--gpu` — Use GPU encoding (NVENC, VideoToolbox, AMF, VAAPI, QSV)
- `--browser-gpu` / `--no-browser-gpu` — Force host GPU or software (SwiftShader) for Chrome/WebGL capture. Default for local renders is `auto` — probe WebGL availability on first launch and fall back to software if no GPU is reachable. Docker mode always uses software.
- `-o, --output` — Custom output path
@@ -28,5 +29,6 @@ Requires: Docker installed and running.
- Use `draft` quality for fast previews during development
- Local renders auto-detect GPU on first launch; use `--browser-gpu` to force hardware (errors if no GPU) or `--no-browser-gpu` to force SwiftShader
- Use `--gpu` when a local render also benefits from hardware FFmpeg encoding
- Use `--video-frame-format png` when source videos contain saturated UI colors that should avoid JPEG extraction
- Use `npx hyperframes benchmark` to find optimal settings
- 4 workers is usually the sweet spot for most compositions
@@ -168,6 +168,7 @@ describe("buildDockerRunArgs", () => {
hdrMode: "force-hdr",
crf: 16,
videoBitrate: undefined,
videoFrameFormat: "png",
quiet: true,
entryFile: "compositions/intro.html",
},
@@ -181,6 +182,8 @@ describe("buildDockerRunArgs", () => {
expect(args).toContain("8");
expect(args).toContain("--crf");
expect(args).toContain("16");
expect(args).toContain("--video-frame-format");
expect(args).toContain("png");
expect(args).toContain("--quiet");
expect(args).toContain("--gpu");
expect(args).toContain("--no-browser-gpu");
@@ -224,6 +227,27 @@ describe("buildDockerRunArgs", () => {
expect(args).not.toContain("--crf");
});
it("forwards --video-frame-format to the container when set to png", () => {
const args = buildDockerRunArgs({
...FIXED_INPUT,
options: { ...BASE, videoFrameFormat: "png" },
});
expect(args).toContain("--video-frame-format");
expect(args).toContain("png");
});
it("omits --video-frame-format when it is auto or unset", () => {
expect(buildDockerRunArgs({ ...FIXED_INPUT, options: BASE })).not.toContain(
"--video-frame-format",
);
expect(
buildDockerRunArgs({
...FIXED_INPUT,
options: { ...BASE, videoFrameFormat: "auto" },
}),
).not.toContain("--video-frame-format");
});
it("forwards --variables JSON to the container when set", () => {
const args = buildDockerRunArgs({
...FIXED_INPUT,
+4
View File
@@ -47,6 +47,7 @@ export interface DockerRenderOptions {
hdrMode: "auto" | "force-hdr" | "force-sdr";
crf?: number;
videoBitrate?: string;
videoFrameFormat?: "auto" | "jpg" | "png";
quiet: boolean;
variables?: Record<string, unknown>;
entryFile?: string;
@@ -121,6 +122,9 @@ export function buildDockerRunArgs(input: DockerRunArgsInput): string[] {
...(options.workers != null ? ["--workers", String(options.workers)] : []),
...(options.crf != null ? ["--crf", String(options.crf)] : []),
...(options.videoBitrate ? ["--video-bitrate", options.videoBitrate] : []),
...(options.videoFrameFormat && options.videoFrameFormat !== "auto"
? ["--video-frame-format", options.videoFrameFormat]
: []),
...(options.quiet ? ["--quiet"] : []),
...(options.gpu ? ["--gpu"] : []),
...(options.browserGpu ? [] : ["--no-browser-gpu"]),
+3
View File
@@ -135,6 +135,9 @@ export {
type ExtractionOptions,
type ExtractionResult,
type ExtractionPhaseBreakdown,
type VideoFrameFormat,
VIDEO_FRAME_FORMATS,
isVideoFrameFormat,
} from "./services/videoFrameExtractor.js";
export { createVideoFrameInjector } from "./services/videoFrameInjector.js";
@@ -18,13 +18,14 @@ import {
extractAllVideoFrames,
createFrameLookupTable,
resolveProjectRelativeSrc,
resolveFrameFormat,
codecMayHaveAlpha,
decoderForCodec,
getFrameAtTime,
type VideoElement,
type ExtractedFrames,
} from "./videoFrameExtractor.js";
import { extractVideoMetadata } from "../utils/ffprobe.js";
import { extractVideoMetadata, type VideoMetadata } from "../utils/ffprobe.js";
import { runFfmpeg } from "../utils/runFfmpeg.js";
// ffmpeg is not preinstalled on GitHub's ubuntu-24.04 runners. The producer
@@ -71,6 +72,45 @@ describe("codec alpha capability", () => {
});
});
describe("resolveFrameFormat", () => {
function metadata(overrides: Partial<VideoMetadata> = {}): VideoMetadata {
return {
durationSeconds: 1,
width: 320,
height: 180,
fps: 30,
hasAudio: false,
videoCodec: "h264",
colorSpace: {
colorTransfer: "bt709",
colorPrimaries: "bt709",
colorSpace: "bt709",
},
isVFR: false,
hasAlpha: false,
...overrides,
};
}
it("keeps opaque non-alpha sources on jpg by default", () => {
expect(resolveFrameFormat(metadata(), undefined)).toBe("jpg");
expect(resolveFrameFormat(metadata(), "auto")).toBe("jpg");
});
it("honors explicit png for opaque videos", () => {
expect(resolveFrameFormat(metadata(), "png")).toBe("png");
});
it("honors explicit jpg for opaque videos", () => {
expect(resolveFrameFormat(metadata(), "jpg")).toBe("jpg");
});
it("forces png when alpha is present or the codec can carry alpha", () => {
expect(resolveFrameFormat(metadata({ hasAlpha: true }), "jpg")).toBe("png");
expect(resolveFrameFormat(metadata({ videoCodec: "vp9" }), "jpg")).toBe("png");
});
});
// Regression: a long-standing footgun where `<video src="../assets/foo">`
// inside a sub-composition silently dropped the video from extraction. The
// browser's URL resolver clamps `..` at the served origin's root (so the
@@ -339,6 +379,198 @@ describe("parseImageElements", () => {
});
});
type Rgb = [number, number, number];
const UI_FIXTURE_WIDTH = 240;
const UI_FIXTURE_HEIGHT = 160;
const RED_SAMPLE_PIXELS = [
[70, 72],
[118, 82],
[178, 92],
] as const;
function readFirstFramePixel(mediaPath: string, x: number, y: number): Rgb {
const result = spawnSync(
"ffmpeg",
[
"-v",
"error",
"-i",
mediaPath,
"-frames:v",
"1",
"-f",
"rawvideo",
"-pix_fmt",
"rgb24",
"pipe:1",
],
{ maxBuffer: UI_FIXTURE_WIDTH * UI_FIXTURE_HEIGHT * 3 + 1024 },
);
if (result.status !== 0) {
throw new Error(`ffmpeg pixel decode failed: ${result.stderr.toString().slice(-400)}`);
}
const offset = (y * UI_FIXTURE_WIDTH + x) * 3;
return [
result.stdout[offset] ?? 0,
result.stdout[offset + 1] ?? 0,
result.stdout[offset + 2] ?? 0,
];
}
function maxChannelDelta(a: Rgb, b: Rgb): number {
return Math.max(Math.abs(a[0] - b[0]), Math.abs(a[1] - b[1]), Math.abs(a[2] - b[2]));
}
// Regression for saturated UI recordings: default JPEG extraction can shift
// high-chroma reds before browser capture. Forcing PNG should keep extracted
// source-video frames effectively identical to the decoded source pixels.
describe.skipIf(!HAS_FFMPEG)("video frame extraction format", () => {
const FIXTURE_DIR = mkdtempSync(join(tmpdir(), "hf-video-frame-format-"));
const UI_FIXTURE = join(FIXTURE_DIR, "ui-red.mp4");
beforeAll(async () => {
const result = await runFfmpeg([
"-y",
"-hide_banner",
"-loglevel",
"error",
"-f",
"lavfi",
"-i",
`color=c=0xffe7ee:s=${UI_FIXTURE_WIDTH}x${UI_FIXTURE_HEIGHT}:d=1:r=1`,
"-vf",
"drawbox=x=44:y=58:w=152:h=44:color=0xdd382e@1:t=fill,drawbox=x=64:y=75:w=112:h=10:color=0xfff0f0@1:t=fill",
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-crf",
"0",
"-pix_fmt",
"yuv420p",
"-color_primaries",
"bt709",
"-color_trc",
"bt709",
"-colorspace",
"bt709",
UI_FIXTURE,
]);
if (!result.success) {
throw new Error(`UI color fixture synthesis failed: ${result.stderr.slice(-400)}`);
}
}, 30_000);
afterAll(() => {
if (existsSync(FIXTURE_DIR)) rmSync(FIXTURE_DIR, { recursive: true, force: true });
});
function fixtureVideo(): VideoElement {
return {
id: "ui",
src: UI_FIXTURE,
start: 0,
end: 1,
mediaStart: 0,
loop: false,
hasAudio: false,
};
}
it("keeps color-sensitive UI reds closer to source when extraction is forced to png", async () => {
const defaultOut = join(FIXTURE_DIR, "out-default");
const pngOut = join(FIXTURE_DIR, "out-png");
mkdirSync(defaultOut, { recursive: true });
mkdirSync(pngOut, { recursive: true });
const defaultResult = await extractAllVideoFrames([fixtureVideo()], FIXTURE_DIR, {
fps: 1,
outputDir: defaultOut,
});
const pngResult = await extractAllVideoFrames([fixtureVideo()], FIXTURE_DIR, {
fps: 1,
outputDir: pngOut,
format: "png",
});
expect(defaultResult.errors).toEqual([]);
expect(pngResult.errors).toEqual([]);
const defaultFrame = defaultResult.extracted[0]!.framePaths.get(0)!;
const pngFrame = pngResult.extracted[0]!.framePaths.get(0)!;
expect(defaultFrame.endsWith(".jpg")).toBe(true);
expect(pngFrame.endsWith(".png")).toBe(true);
let worstDefaultDelta = 0;
let worstPngDelta = 0;
for (const [x, y] of RED_SAMPLE_PIXELS) {
const sourcePixel = readFirstFramePixel(UI_FIXTURE, x, y);
worstDefaultDelta = Math.max(
worstDefaultDelta,
maxChannelDelta(sourcePixel, readFirstFramePixel(defaultFrame, x, y)),
);
worstPngDelta = Math.max(
worstPngDelta,
maxChannelDelta(sourcePixel, readFirstFramePixel(pngFrame, x, y)),
);
}
expect(worstPngDelta).toBeLessThanOrEqual(5);
expect(worstPngDelta).toBeLessThanOrEqual(worstDefaultDelta);
}, 60_000);
it("keeps jpg and png extraction caches separate", async () => {
const cacheDir = mkdtempSync(join(tmpdir(), "hf-extract-format-cache-"));
try {
const defaultOut = join(FIXTURE_DIR, "cache-default");
const pngOut = join(FIXTURE_DIR, "cache-png");
const pngHitOut = join(FIXTURE_DIR, "cache-png-hit");
mkdirSync(defaultOut, { recursive: true });
mkdirSync(pngOut, { recursive: true });
mkdirSync(pngHitOut, { recursive: true });
const defaultResult = await extractAllVideoFrames(
[fixtureVideo()],
FIXTURE_DIR,
{ fps: 1, outputDir: defaultOut },
undefined,
{ extractCacheDir: cacheDir },
);
expect(defaultResult.errors).toEqual([]);
expect(defaultResult.phaseBreakdown.cacheHits).toBe(0);
expect(defaultResult.phaseBreakdown.cacheMisses).toBe(1);
expect(defaultResult.extracted[0]!.framePaths.get(0)!.endsWith(".jpg")).toBe(true);
const pngMiss = await extractAllVideoFrames(
[fixtureVideo()],
FIXTURE_DIR,
{ fps: 1, outputDir: pngOut, format: "png" },
undefined,
{ extractCacheDir: cacheDir },
);
expect(pngMiss.errors).toEqual([]);
expect(pngMiss.phaseBreakdown.cacheHits).toBe(0);
expect(pngMiss.phaseBreakdown.cacheMisses).toBe(1);
expect(pngMiss.extracted[0]!.framePaths.get(0)!.endsWith(".png")).toBe(true);
const pngHit = await extractAllVideoFrames(
[fixtureVideo()],
FIXTURE_DIR,
{ fps: 1, outputDir: pngHitOut, format: "png" },
undefined,
{ extractCacheDir: cacheDir },
);
expect(pngHit.errors).toEqual([]);
expect(pngHit.phaseBreakdown.cacheHits).toBe(1);
expect(pngHit.phaseBreakdown.cacheMisses).toBe(0);
expect(pngHit.extracted[0]!.framePaths.get(0)!.endsWith(".png")).toBe(true);
} finally {
rmSync(cacheDir, { recursive: true, force: true });
}
}, 60_000);
});
// Regression test for the VFR (variable frame rate) freeze bug.
// Screen recordings and phone videos often have irregular timestamps.
// When such inputs hit `extractVideoFramesRange`'s `-ss <start> -i ... -t <dur>
@@ -61,11 +61,25 @@ export interface ExtractedFrames {
ownedByLookup?: boolean;
}
/**
* The single source of truth for the source-video frame-extraction allow-list.
* The CLI flag parser, the producer HTTP server, and the distributed-config
* validator all validate against this same set via {@link isVideoFrameFormat}
* so the boundaries can't drift when a new format is added.
*/
export const VIDEO_FRAME_FORMATS = ["auto", "jpg", "png"] as const;
export type VideoFrameFormat = (typeof VIDEO_FRAME_FORMATS)[number];
/** Runtime guard for {@link VideoFrameFormat} over an untrusted value. */
export function isVideoFrameFormat(value: unknown): value is VideoFrameFormat {
return typeof value === "string" && (VIDEO_FRAME_FORMATS as readonly string[]).includes(value);
}
export interface ExtractionOptions {
fps: number;
outputDir: string;
quality?: number;
format?: "jpg" | "png";
format?: VideoFrameFormat;
}
/**
@@ -433,9 +447,12 @@ export function decoderForCodec(codec: string | undefined): string {
return c;
}
function resolveFrameFormat(metadata: VideoMetadata, requested?: "jpg" | "png"): CacheFrameFormat {
if (requested) return requested;
export function resolveFrameFormat(
metadata: VideoMetadata,
requested?: VideoFrameFormat,
): CacheFrameFormat {
if (metadata.hasAlpha || codecMayHaveAlpha(metadata.videoCodec)) return "png";
if (requested === "png" || requested === "jpg") return requested;
return "jpg";
}
+10 -9
View File
@@ -47,15 +47,16 @@ await startServer({ port: 8080 });
`RenderConfig` controls the render pipeline:
| Option | Default | Description |
| ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
| `inputPath` | — | Path to the HTML composition |
| `outputPath` | — | Output video file path (or directory, for `format: "png-sequence"`) |
| `width` | 1920 | Frame width in pixels |
| `height` | 1080 | Frame height in pixels |
| `fps` | 30 | Frames per second (24, 30, or 60) |
| `quality` | `"standard"` | Encoder preset (`"draft"`, `"standard"`, `"high"`) |
| `format` | `"mp4"` | Output container — `"mp4"`, `"webm"`, `"mov"`, or `"png-sequence"`. See [Transparent Video Output](#transparent-video-output) below. |
| Option | Default | Description |
| ------------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `inputPath` | — | Path to the HTML composition |
| `outputPath` | — | Output video file path (or directory, for `format: "png-sequence"`) |
| `width` | 1920 | Frame width in pixels |
| `height` | 1080 | Frame height in pixels |
| `fps` | 30 | Frames per second (24, 30, or 60) |
| `quality` | `"standard"` | Encoder preset (`"draft"`, `"standard"`, `"high"`) |
| `format` | `"mp4"` | Output container — `"mp4"`, `"webm"`, `"mov"`, or `"png-sequence"`. See [Transparent Video Output](#transparent-video-output) below. |
| `videoFrameFormat` | `"auto"` | Source video frame extraction format — `"auto"`, `"jpg"`, or `"png"`. Use `"png"` for UI recordings, screen captures, and color-sensitive source videos. |
## Transparent Video Output
+9 -1
View File
@@ -33,8 +33,10 @@ import {
RenderCancelledError,
createRenderJob,
executeRenderJob,
type RenderConfig,
} from "./services/renderOrchestrator.js";
import { prepareHyperframeLintBody, runHyperframeLint } from "./services/hyperframeLint.js";
import { isVideoFrameFormat } from "@hyperframes/engine";
import { resolveRenderPaths } from "./utils/paths.js";
import { defaultLogger, type ProducerLogger } from "./logger.js";
import { Semaphore } from "./utils/semaphore.js";
@@ -72,6 +74,7 @@ interface RenderInput {
fps: import("@hyperframes/core").Fps;
quality: "draft" | "standard" | "high";
format?: "mp4" | "webm" | "mov";
videoFrameFormat?: RenderConfig["videoFrameFormat"];
workers?: number;
useGpu: boolean;
debug: boolean;
@@ -125,7 +128,10 @@ export function parseRenderOptions(body: Record<string, unknown>): Omit<RenderIn
const format = (
["mp4", "webm", "mov"].includes(body.format as string) ? body.format : undefined
) as "mp4" | "webm" | "mov" | undefined;
) as RenderInput["format"];
const videoFrameFormat = isVideoFrameFormat(body.videoFrameFormat)
? body.videoFrameFormat
: undefined;
const { variables, outputResolution } = parseRenderOverrides(body);
@@ -140,6 +146,7 @@ export function parseRenderOptions(body: Record<string, unknown>): Omit<RenderIn
format,
variables,
outputResolution,
videoFrameFormat,
};
}
@@ -183,6 +190,7 @@ function buildRenderJobConfig(input: RenderInput, log: ProducerLogger) {
entryFile: input.entryFile,
variables: input.variables,
outputResolution: input.outputResolution,
videoFrameFormat: input.videoFrameFormat,
logger: log,
};
}
@@ -36,7 +36,12 @@ import {
} from "node:fs";
import { join, relative, sep } from "node:path";
import { type CanvasResolution } from "@hyperframes/core";
import { type EngineConfig, getEncoderPreset, resolveConfig } from "@hyperframes/engine";
import {
type EngineConfig,
type VideoFrameFormat,
getEncoderPreset,
resolveConfig,
} from "@hyperframes/engine";
import { defaultLogger, type ProducerLogger } from "../../logger.js";
import { closeFileServerSafely } from "../fileServer.js";
import { runAudioStage } from "../render/stages/audioStage.js";
@@ -105,6 +110,12 @@ export interface DistributedRenderConfig {
crf?: number;
/** Target video bitrate (e.g. `"10M"`); mutually exclusive with `crf`. */
bitrate?: string;
/**
* Source-video frame extraction format. Defaults to `"auto"`, matching the
* in-process renderer: alpha/alpha-capable sources extract as PNG, other
* sources extract as JPG unless the caller explicitly requests `"png"`.
*/
videoFrameFormat?: VideoFrameFormat;
/** Output resolution preset; engages Chrome `deviceScaleFactor` supersampling. */
outputResolution?: CanvasResolution;
@@ -711,6 +722,7 @@ export async function plan(
format: config.format,
crf: config.crf,
bitrate: config.bitrate,
videoFrameFormat: config.videoFrameFormat,
outputResolution: config.outputResolution,
// HDR is banned in distributed mode. force-sdr keeps the
// extract / encoder paths off the HDR branches entirely.
@@ -17,6 +17,7 @@
* needs the actual planner.
*/
import { VIDEO_FRAME_FORMATS, isVideoFrameFormat } from "@hyperframes/engine";
import { type DistributedFormat } from "./shared.js";
import { type DistributedRenderConfig } from "./plan.js";
@@ -114,6 +115,13 @@ export function validateDistributedRenderConfig(
);
}
if (config.videoFrameFormat !== undefined && !isVideoFrameFormat(config.videoFrameFormat)) {
throw new InvalidConfigError(
"config.videoFrameFormat",
`must be one of ${VIDEO_FRAME_FORMATS.join(", ")}; got ${String(config.videoFrameFormat)}`,
);
}
if (config.crf !== undefined && config.bitrate !== undefined) {
throw new InvalidConfigError("config.crf", "is mutually exclusive with config.bitrate");
}
@@ -10,7 +10,7 @@ import { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { type Fps } from "@hyperframes/core";
import { type VideoElement, type VideoMetadata } from "@hyperframes/engine";
import { type VideoElement, type VideoFrameFormat, type VideoMetadata } from "@hyperframes/engine";
import { type RenderConfig, type RenderJob, createRenderJob } from "../renderOrchestrator.js";
import { defaultLogger, type ProducerLogger } from "../../logger.js";
@@ -97,6 +97,7 @@ export interface SyntheticRenderJobInput {
quality: RenderConfig["quality"];
crf?: number;
bitrate?: string;
videoFrameFormat?: VideoFrameFormat;
outputResolution?: RenderConfig["outputResolution"];
hdrMode: RenderConfig["hdrMode"];
entryFile: string;
@@ -116,6 +117,7 @@ export function buildSyntheticRenderJob(input: SyntheticRenderJobInput): RenderJ
format: input.format,
crf: input.crf,
videoBitrate: input.bitrate,
videoFrameFormat: input.videoFrameFormat,
outputResolution: input.outputResolution,
// Distributed mode hard-pins to software GPU. The plan-time validator
// refuses to fan out otherwise.
@@ -194,6 +194,7 @@ export async function runExtractVideosStage(
{
fps: fpsToNumber(job.config.fps),
outputDir: join(compiledDir, "__hyperframes_video_frames"),
format: job.config.videoFrameFormat ?? "auto",
},
abortSignal,
{ extractCacheDir: cfg.extractCacheDir },
@@ -50,6 +50,7 @@ import {
resolveConfig,
type ExtractionResult,
type ExtractionPhaseBreakdown,
type VideoFrameFormat,
closeCaptureSession,
type CaptureOptions,
type CaptureVideoMetadataHint,
@@ -245,6 +246,14 @@ export interface RenderConfig {
crf?: number;
/** Target video bitrate (e.g. "10M"). Mutually exclusive with `crf`. */
videoBitrate?: string;
/**
* Source-video frame extraction format. Defaults to `"auto"`, which preserves
* the historical behavior: alpha/alpha-capable sources extract as PNG, all
* other videos extract as JPG. Set to `"png"` for lossless source-frame
* extraction on UI recordings, screen captures, or other color-sensitive
* videos.
*/
videoFrameFormat?: VideoFrameFormat;
/** HDR rendering mode.
* - `auto` (default): probe sources; enable HDR if any HDR content is found.
* - `force-hdr`: enable HDR even on SDR-only compositions (falls back to HLG transfer).