Skip to content
Open
42 changes: 42 additions & 0 deletions docs/guides/rendering.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,48 @@ Use a higher frame rate only when the source or destination needs it. It creates

The CLI uses the composition’s `data-fps` when present and otherwise defaults to 30 fps.

## 4:4:4 masters

MP4 output stores colour at half resolution in each direction (4:2:0). Thin
coloured text, UI strokes and saturated edges can come out soft or fringed. For
a master file that is mostly text and UI, render with full-resolution colour:

```bash
npx hyperframes render --chroma-subsampling 444 --output master.mp4
```

This encodes H.264 High 4:4:4 Predictive (yuv444p) and captures every frame as
a lossless PNG, because Chrome's JPEG capture is already 4:2:0 on Linux and on
screenshot capture. Rendering is slower: in our tests a text and UI
composition took about twice as long as the default render, and full-frame
footage about three and a half times as long, because PNG encoding gets more
expensive as the image gets more detailed.

Measured at the same CRF against lossless masters, chroma PSNR rose by about
16 dB for coloured text and 13 dB for a UI screen recording, while camera
footage and gradients showed no meaningful change. Keep the default for
footage-heavy videos.

`444` supports SDR MP4 with the software encoder only. It is rejected with
`--gpu`, `--hdr`, other formats, and distributed, Lambda or Cloud Run renders.

Many players cannot decode 4:4:4, so use it for masters and editing handoff,
not for files you publish directly:

| Player | 4:4:4 MP4 |
| ------ | --------- |
| Chrome 155 (Windows) | Plays |
| Edge 155 (Windows) | Plays |
| Firefox 157 (Windows) | Does not play (no YUV 4:4:4 decoder) |
| VLC 3.0 (Windows) | Plays |
| Windows Media Player and Media Player app | Does not play ("unsupported encoding settings") |
| Safari (iOS 26.6) | Plays |
| Safari 26.6 (macOS 26.6) | Plays |
| QuickTime / AVFoundation (macOS 26.6) | Plays |

Tested with H.264 High 4:4:4 Predictive, 8-bit, 1920×1080. Every player above
plays the default 4:2:0 output.

## Local or Docker

Local rendering is the normal choice:
Expand Down
1 change: 1 addition & 0 deletions docs/packages/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1277,6 +1277,7 @@ Quality and file size:
| `--crf` | from `--quality`| Override MP4/WebM encoder CRF, 0–51. Lower is better quality. Not with `--video-bitrate`; rejected for MOV. |
| `--video-bitrate` | from `--quality`| Target MP4/WebM bitrate, e.g. `10M` or `5000k`. Not with `--crf`; rejected for MOV. |
| `--vp9-cpu-used` | encoder default | libvpx-vp9 speed/quality trade-off for WebM, -8 to 8. Env: `PRODUCER_VP9_CPU_USED`. |
| `--chroma-subsampling` | `420` | MP4 chroma: `420` or `444`. `444` keeps full-resolution colour for thin coloured text and UI (yuv444p, lossless PNG capture, slower). SDR MP4 with the software encoder only; rejected with `--gpu`, `--hdr` and other formats. Many players cannot decode 4:4:4. See [4:4:4 masters](/guides/rendering#444-masters). |
| `--gif-loop` | `0` | GIF loop count, `0` for forever. Range 0–65535, `--format gif` only. |
| `--hls-segment-seconds`| `4` | HLS target segment length in whole seconds, 1–60. `--format hls` only. Also fixes the encoder GOP so every segment starts on a keyframe. |
| `--video-frame-format` | `auto` | How source video frames are extracted: `auto`, `jpg`, `png`. Use `png` for UI recordings, screen captures, and other colour-sensitive footage. |
Expand Down
6 changes: 6 additions & 0 deletions docs/packages/producer.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ const job = createRenderJob({
HDR output is available for MP4 through `hdrMode`. Transparent formats render
in SDR because HDR and alpha are not one supported output path.

`chromaSubsampling: "444"` renders an SDR MP4 master with full-resolution
colour (H.264 yuv444p, lossless PNG capture). It needs the software encoder,
is rejected with `useGpu`, HDR output, other formats and distributed renders,
and many players cannot decode it. See
[4:4:4 masters](/guides/rendering#444-masters).

```ts
const job = createRenderJob({
fps: 30,
Expand Down
15 changes: 15 additions & 0 deletions packages/cli/src/commands/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1162,6 +1162,21 @@ describe("renderLocal browser GPU config", () => {
expect(producerState.createdJobs[0]?.videoFrameFormat).toBe("png");
});

it("forwards chromaSubsampling to createRenderJob", async () => {
await renderLocal(PROJECT_DIR, "/tmp/out.mp4", {
fps: { num: 30, den: 1 },
quality: "standard",
format: "mp4",
gpu: false,
browserGpuMode: "software",
hdrMode: "auto",
quiet: true,
chromaSubsampling: "444",
});

expect(producerState.createdJobs[0]?.chromaSubsampling).toBe("444");
});

it("forwards debug mode to createRenderJob", async () => {
await renderLocal(PROJECT_DIR, "/tmp/out.mp4", {
fps: { num: 30, den: 1 },
Expand Down
12 changes: 12 additions & 0 deletions packages/cli/src/commands/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,14 @@ export default defineCommand({
description:
"libvpx-vp9 -cpu-used value for WebM encodes (-8 to 8). Higher is faster with a larger quality/size tradeoff. Env: PRODUCER_VP9_CPU_USED.",
},
"chroma-subsampling": {
type: "string",
description:
"MP4 chroma: 420 (default) or 444. 444 keeps full-resolution colour for thin coloured " +
"text and UI (yuv444p, lossless PNG capture, slower). Software encoder and SDR MP4 only; " +
"many players cannot decode 4:4:4.",
default: "420",
},
gpu: { type: "boolean", description: "Use GPU encoding", default: false },
"browser-gpu": {
type: "boolean",
Expand Down Expand Up @@ -503,6 +511,8 @@ export interface RenderOptions {
vp9CpuUsed?: number;
videoBitrate?: string;
videoFrameFormat?: VideoFrameFormat;
/** "444" opts an SDR MP4 render into full-resolution chroma. Absent means 4:2:0. */
chromaSubsampling?: "420" | "444";
quiet: boolean;
debug?: boolean;
/** Segmented capture: reuse a prior run's validated segments. */
Expand Down Expand Up @@ -832,6 +842,7 @@ async function renderDocker(
vp9CpuUsed: options.vp9CpuUsed,
videoBitrate: options.videoBitrate,
videoFrameFormat: options.videoFrameFormat,
chromaSubsampling: options.chromaSubsampling,
quiet: options.quiet,
variables: options.variables,
entryFile: options.entryFile,
Expand Down Expand Up @@ -1110,6 +1121,7 @@ async function executeLocalRender(
crf: options.crf,
videoBitrate: options.videoBitrate,
videoFrameFormat: options.videoFrameFormat,
chromaSubsampling: options.chromaSubsampling,
variables: options.variables,
entryFile: options.entryFile,
outputResolution: options.outputResolution,
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/commands/render/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ export function renderOptionsFromPlan(
vp9CpuUsed: plan.vp9CpuUsed,
videoBitrate: plan.videoBitrate,
videoFrameFormat: plan.videoFrameFormat,
chromaSubsampling: plan.chromaSubsampling,
quiet: plan.quiet,
browserPath,
debug: plan.debug,
Expand Down Expand Up @@ -357,6 +358,7 @@ export function batchRowRenderOptions(
vp9CpuUsed: plan.vp9CpuUsed,
videoBitrate: plan.videoBitrate,
videoFrameFormat: plan.videoFrameFormat,
chromaSubsampling: plan.chromaSubsampling,
quiet: batchQuiet,
browserPath,
entryFile: plan.entryFile,
Expand Down
33 changes: 33 additions & 0 deletions packages/cli/src/commands/render/plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,39 @@ describe("createRenderPlan", () => {
expect(vi.mocked(console.error).mock.calls.flat().join("\n")).toContain(flag);
});

it("defaults chroma subsampling to 4:2:0 and accepts 4:4:4 for SDR mp4", () => {
expect(createRenderPlan({ dir: projectDir }).chromaSubsampling).toBe("420");
expect(
createRenderPlan({ dir: projectDir, "chroma-subsampling": "444" }).chromaSubsampling,
).toBe("444");
expect(
createRenderPlan({ dir: projectDir, "chroma-subsampling": "444", sdr: true })
.chromaSubsampling,
).toBe("444");
});

it("rejects an unknown chroma-subsampling value", () => {
expect(() => createRenderPlan({ dir: projectDir, "chroma-subsampling": "422" })).toThrow(
CliUsageError,
);
expect(vi.mocked(console.error).mock.calls.flat().join("\n")).toContain("Must be 420 or 444");
});

it.each([
["--format webm", { format: "webm" }],
["--format mov", { format: "mov" }],
["--format gif", { format: "gif" }],
["--format png-sequence", { format: "png-sequence" }],
["--format hls", { format: "hls" }],
["--gpu", { gpu: true }],
["--hdr", { hdr: true }],
])("rejects --chroma-subsampling 444 with %s", (flag, conflicting) => {
expect(() =>
createRenderPlan({ dir: projectDir, "chroma-subsampling": "444", ...conflicting }),
).toThrow(CliUsageError);
expect(vi.mocked(console.error).mock.calls.flat().join("\n")).toContain(flag);
});

it("keeps MP4 and WebM rate controls available", () => {
expect(createRenderPlan({ dir: projectDir, format: "mp4", crf: "18" }).crf).toBe(18);
expect(
Expand Down
33 changes: 33 additions & 0 deletions packages/cli/src/commands/render/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
MAX_VP9_CPU_USED,
MIN_VP9_CPU_USED,
isVideoFrameFormat,
type ChromaSubsampling,
type VideoFrameFormat,
} from "@hyperframes/engine";
import { errorBox } from "../../ui/format.js";
Expand Down Expand Up @@ -80,6 +81,7 @@ export interface RenderCommandArgs {
crf?: string;
"video-bitrate"?: string;
"vp9-cpu-used"?: string;
"chroma-subsampling"?: string;
gpu?: boolean;
"browser-gpu"?: boolean;
quiet?: boolean;
Expand Down Expand Up @@ -156,6 +158,7 @@ export interface RenderPlan {
crf?: number;
vp9CpuUsed?: number;
videoBitrate?: string;
chromaSubsampling: ChromaSubsampling;
hdrMode: HdrMode;
pageSideCompositing: boolean;
experimentalFastCapture: boolean;
Expand Down Expand Up @@ -221,6 +224,34 @@ function resolveHfEnvOverrides(): readonly string[] {
.slice(0, MAX_REPORTED_ENV_OVERRIDES);
}

/**
* `--chroma-subsampling`. 4:4:4 needs SDR MP4 from the software encoder; any other
* pairing would come back 4:2:0, so refuse it here (the producer refuses it too).
*/
function parseChromaSubsamplingArg(
args: RenderCommandArgs,
format: RenderFormat,
): ChromaSubsampling {
const raw = args["chroma-subsampling"] ?? "420";
if (raw !== "420" && raw !== "444") {
errorBox("Invalid chroma-subsampling", `Got "${raw}". Must be 420 or 444.`);
failUsage();
}
if (raw === "420") return raw;
const conflict =
format !== "mp4" ? `--format ${format}` : args.gpu ? "--gpu" : args.hdr ? "--hdr" : undefined;
if (conflict) {
errorBox(
"Unsupported 4:4:4 output",
`--chroma-subsampling 444 cannot be combined with ${conflict}. ` +
"4:4:4 needs SDR MP4 from the software encoder (libx264).",
`Drop ${conflict}, or drop --chroma-subsampling 444.`,
);
failUsage();
}
return raw;
}

/** Parse and validate command input into an immutable execution plan. */
// fallow-ignore-next-line complexity
export function createRenderPlan(args: RenderCommandArgs, now = new Date()): RenderPlan {
Expand Down Expand Up @@ -297,6 +328,7 @@ export function createRenderPlan(args: RenderCommandArgs, now = new Date()): Ren
}
const hlsSegmentSeconds =
format === "hls" ? (hlsSegmentParse.value ?? DEFAULT_HLS_SEGMENT_SECONDS) : undefined;
const chromaSubsampling = parseChromaSubsamplingArg(args, format);

const videoFrameFormatRaw = args["video-frame-format"] ?? "auto";
if (!isVideoFrameFormat(videoFrameFormatRaw)) {
Expand Down Expand Up @@ -553,6 +585,7 @@ export function createRenderPlan(args: RenderCommandArgs, now = new Date()): Ren
crf,
vp9CpuUsed,
videoBitrate,
chromaSubsampling,
hdrMode: args.sdr ? "force-sdr" : args.hdr ? "force-hdr" : "auto",
pageSideCompositing: args["page-side-compositing"] !== false,
experimentalFastCapture: args["experimental-fast-capture"] === true,
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/docs/rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Requires: Docker installed and running.
- `--video-bitrate` — Target video bitrate such as `10M` (mutually exclusive with `--crf`)
- `--vp9-cpu-used` — WebM VP9 speed/quality tradeoff (`-8` to `8`, default: `4`). Higher values encode faster with larger output / quality tradeoff.
- `--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.
- `--chroma-subsampling` — MP4 chroma: `420` or `444` (default: `420`). `444` keeps full-resolution colour for thin coloured text and UI (yuv444p, lossless PNG capture, slower). SDR MP4 with the software encoder only; many players cannot decode 4:4:4.
- `--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
Expand All @@ -31,5 +32,6 @@ Requires: Docker installed and running.
- 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 `--chroma-subsampling 444` for text- and UI-heavy MP4 masters; keep the default for files you publish directly
- Use `npx hyperframes benchmark` to find optimal settings
- 4 workers is usually the sweet spot for most compositions
1 change: 1 addition & 0 deletions packages/cli/src/server/studioServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
...(opts.videoBitrate ? { videoBitrate: opts.videoBitrate } : {}),
...(opts.videoFrameFormat ? { videoFrameFormat: opts.videoFrameFormat } : {}),
...(opts.hdrMode ? { hdrMode: opts.hdrMode } : {}),
...(opts.chromaSubsampling ? { chromaSubsampling: opts.chromaSubsampling } : {}),
...(opts.useGpu ? { useGpu: true } : {}),
...(opts.pageMotionBlur ? { pageMotionBlur: opts.pageMotionBlur } : {}),
});
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/utils/dockerRunArgs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,16 @@ describe("buildDockerRunArgs", () => {
expect(flags(undefined)).toEqual(["run", "--rm", "--platform", "linux/amd64"]);
});

it("forwards --chroma-subsampling only for 4:4:4, keeping the default argv unchanged", () => {
const args = (chromaSubsampling?: "420" | "444") =>
buildDockerRunArgs({ ...FIXED_INPUT, options: { ...BASE, chromaSubsampling } });
const at = args("444").indexOf("--chroma-subsampling");
expect(at).toBeGreaterThan(-1);
expect(args("444")[at + 1]).toBe("444");
expect(args("420")).toEqual(args(undefined));
expect(args("420")).not.toContain("--chroma-subsampling");
});

it("omits --workers when auto sizing should happen inside the container", () => {
const args = buildDockerRunArgs({ ...FIXED_INPUT, options: BASE });
expect(args).not.toContain("--workers");
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 @@ -51,6 +51,8 @@ export interface DockerRenderOptions {
vp9CpuUsed?: number;
videoBitrate?: string;
videoFrameFormat?: "auto" | "jpg" | "png";
/** Forwarded as `--chroma-subsampling 444`; the 4:2:0 default adds no flag. */
chromaSubsampling?: "420" | "444";
quiet: boolean;
debug?: boolean;
bestEffort?: boolean;
Expand Down Expand Up @@ -145,6 +147,7 @@ export function buildDockerRunArgs(input: DockerRunArgsInput): string[] {
...(options.videoFrameFormat && options.videoFrameFormat !== "auto"
? ["--video-frame-format", options.videoFrameFormat]
: []),
...(options.chromaSubsampling === "444" ? ["--chroma-subsampling", "444"] : []),
...(options.quiet ? ["--quiet"] : []),
...(options.debug ? ["--debug"] : []),
// The in-container CLI is best-effort by default. Only forward the
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 @@ -190,6 +190,7 @@ export {
HLS_MASTER_PLAYLIST,
HLS_VIDEO_PLAYLIST,
HLS_AUDIO_PLAYLIST,
type ChromaSubsampling,
type GpuEncoder,
type PackageHlsOptions,
} from "./services/chunkEncoder.js";
Expand Down
42 changes: 42 additions & 0 deletions packages/engine/src/services/chunkEncoder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -884,6 +884,27 @@ describe("getEncoderPreset", () => {
expect(preset.codec).toBe("h264");
expect(preset.pixelFormat).toBe("yuv420p");
});

it("returns yuv444p for SDR mp4 when 4:4:4 chroma is requested", () => {
for (const q of ["draft", "standard", "high"] as const) {
const preset = getEncoderPreset(q, "mp4", undefined, "444");
expect(preset.codec).toBe("h264");
expect(preset.preset).toBe(ENCODER_PRESETS[q].preset);
expect(preset.pixelFormat).toBe("yuv444p");
}
});

it("keeps yuv420p for SDR mp4 when 4:2:0 chroma is requested explicitly", () => {
expect(getEncoderPreset("standard", "mp4", undefined, "420").pixelFormat).toBe("yuv420p");
});

it("ignores the chroma option for webm, mov and HDR presets", () => {
expect(getEncoderPreset("standard", "webm", undefined, "444").pixelFormat).toBe("yuva420p");
expect(getEncoderPreset("standard", "mov", undefined, "444").pixelFormat).toBe("yuva444p10le");
expect(getEncoderPreset("standard", "mp4", { transfer: "pq" }, "444").pixelFormat).toBe(
"yuv420p10le",
);
});
});

describe("buildEncoderArgs anti-banding", () => {
Expand Down Expand Up @@ -1166,6 +1187,27 @@ describe("buildEncoderArgs color space", () => {
expect(args[vfIdx + 1]).toBe(`${SDR_CAPTURE_TO_BT709_FILTER},pad=ceil(iw/2)*2:ceil(ih/2)*2`);
});

it("encodes 4:4:4 at odd dimensions without padding, keeping BT.709 tags", () => {
const args = buildEncoderArgs(
{
...baseOptions,
width: 1081,
height: 721,
codec: "h264",
preset: "medium",
quality: 18,
pixelFormat: "yuv444p",
},
inputArgs,
"out.mp4",
);
expect(args[args.indexOf("-c:v") + 1]).toBe("libx264");
expect(args[args.indexOf("-pix_fmt") + 1]).toBe("yuv444p");
expect(args[args.indexOf("-vf") + 1]).toBe(SDR_CAPTURE_TO_BT709_FILTER);
expect(args[args.indexOf("-colorspace:v") + 1]).toBe("bt709");
expect(args[args.indexOf("-color_range") + 1]).toBe("tv");
});

it("prepends range conversion to VAAPI filter chain", () => {
const args = buildEncoderArgs(
{ ...baseOptions, codec: "h264", preset: "medium", quality: 23, useGpu: true },
Expand Down
Loading