diff --git a/docs/guides/rendering.mdx b/docs/guides/rendering.mdx index d81cb7e2392..f88e077760f 100644 --- a/docs/guides/rendering.mdx +++ b/docs/guides/rendering.mdx @@ -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: diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 362b420d520..0f3abb3e8a3 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -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. | diff --git a/docs/packages/producer.mdx b/docs/packages/producer.mdx index 50ab5666a10..adabe26a6a7 100644 --- a/docs/packages/producer.mdx +++ b/docs/packages/producer.mdx @@ -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, diff --git a/packages/cli/src/commands/render.test.ts b/packages/cli/src/commands/render.test.ts index 47b3398c61d..4068a67ffd1 100644 --- a/packages/cli/src/commands/render.test.ts +++ b/packages/cli/src/commands/render.test.ts @@ -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 }, diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 42f5a48f4ea..d65840e1268 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -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", @@ -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. */ @@ -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, @@ -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, diff --git a/packages/cli/src/commands/render/execute.ts b/packages/cli/src/commands/render/execute.ts index fae94c1c997..2745a0c493c 100644 --- a/packages/cli/src/commands/render/execute.ts +++ b/packages/cli/src/commands/render/execute.ts @@ -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, @@ -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, diff --git a/packages/cli/src/commands/render/plan.test.ts b/packages/cli/src/commands/render/plan.test.ts index 632813a9f5c..1aa86169573 100644 --- a/packages/cli/src/commands/render/plan.test.ts +++ b/packages/cli/src/commands/render/plan.test.ts @@ -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( diff --git a/packages/cli/src/commands/render/plan.ts b/packages/cli/src/commands/render/plan.ts index 4095ea99dd0..2fb6de6be32 100644 --- a/packages/cli/src/commands/render/plan.ts +++ b/packages/cli/src/commands/render/plan.ts @@ -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"; @@ -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; @@ -156,6 +158,7 @@ export interface RenderPlan { crf?: number; vp9CpuUsed?: number; videoBitrate?: string; + chromaSubsampling: ChromaSubsampling; hdrMode: HdrMode; pageSideCompositing: boolean; experimentalFastCapture: boolean; @@ -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 { @@ -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)) { @@ -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, diff --git a/packages/cli/src/docs/rendering.md b/packages/cli/src/docs/rendering.md index 86480c80447..4bcbdc0bb2f 100644 --- a/packages/cli/src/docs/rendering.md +++ b/packages/cli/src/docs/rendering.md @@ -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 @@ -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 diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index c12f01f2e0f..12ad765361f 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -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 } : {}), }); diff --git a/packages/cli/src/utils/dockerRunArgs.test.ts b/packages/cli/src/utils/dockerRunArgs.test.ts index 497bad2ecb3..b5299046dab 100644 --- a/packages/cli/src/utils/dockerRunArgs.test.ts +++ b/packages/cli/src/utils/dockerRunArgs.test.ts @@ -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"); diff --git a/packages/cli/src/utils/dockerRunArgs.ts b/packages/cli/src/utils/dockerRunArgs.ts index a1770189ae5..39a89d56ca3 100644 --- a/packages/cli/src/utils/dockerRunArgs.ts +++ b/packages/cli/src/utils/dockerRunArgs.ts @@ -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; @@ -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 diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 9d5a4aac575..54f6b8a55d9 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -190,6 +190,7 @@ export { HLS_MASTER_PLAYLIST, HLS_VIDEO_PLAYLIST, HLS_AUDIO_PLAYLIST, + type ChromaSubsampling, type GpuEncoder, type PackageHlsOptions, } from "./services/chunkEncoder.js"; diff --git a/packages/engine/src/services/chunkEncoder.test.ts b/packages/engine/src/services/chunkEncoder.test.ts index 4433c23bcd9..fd48e91e656 100644 --- a/packages/engine/src/services/chunkEncoder.test.ts +++ b/packages/engine/src/services/chunkEncoder.test.ts @@ -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", () => { @@ -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 }, diff --git a/packages/engine/src/services/chunkEncoder.ts b/packages/engine/src/services/chunkEncoder.ts index cbca3e3e2c9..52661f14adc 100644 --- a/packages/engine/src/services/chunkEncoder.ts +++ b/packages/engine/src/services/chunkEncoder.ts @@ -56,6 +56,13 @@ export const ENCODER_PRESETS = { high: { preset: "slow", quality: 15, codec: "h264" as const }, }; +/** + * Chroma subsampling of SDR MP4 output. "444" keeps full-resolution colour + * (libx264 picks the High 4:4:4 Predictive profile from yuv444p) for masters with + * thin coloured text and UI strokes; many players cannot decode it, so it is opt-in. + */ +export type ChromaSubsampling = "420" | "444"; + export interface EncoderPreset { preset: string; quality: number; @@ -137,11 +144,13 @@ async function shouldCopyAacSidecar( * Get encoder preset for a given quality and output format. * WebM uses VP9 with alpha-capable pixel format; MP4 uses h264 (or h265 for HDR); * MOV uses ProRes 4444 with alpha for editor-compatible transparency. + * `chromaSubsampling` only affects SDR MP4; callers reject other combinations. */ export function getEncoderPreset( quality: "draft" | "standard" | "high", format: "mp4" | "webm" | "mov" = "mp4", hdr?: { transfer: HdrTransfer }, + chromaSubsampling: ChromaSubsampling = "420", ): EncoderPreset { const base = ENCODER_PRESETS[quality]; if (format === "webm") { @@ -169,7 +178,7 @@ export function getEncoderPreset( hdr, }; } - return { ...base, pixelFormat: "yuv420p" }; + return { ...base, pixelFormat: chromaSubsampling === "444" ? "yuv444p" : "yuv420p" }; } // Re-export GPU utilities so existing consumers that import from chunkEncoder still work. diff --git a/packages/engine/src/services/frameCapture-transparentBackground.test.ts b/packages/engine/src/services/frameCapture-transparentBackground.test.ts new file mode 100644 index 00000000000..aef89f47d56 --- /dev/null +++ b/packages/engine/src/services/frameCapture-transparentBackground.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createStaticVerificationPage, + wantsTransparentBackground, + type CaptureSession, +} from "./frameCapture.js"; + +describe("wantsTransparentBackground", () => { + it("keeps PNG capture transparent by default (alpha output, motion blur)", () => { + expect(wantsTransparentBackground({ format: "png" })).toBe(true); + }); + + it("keeps JPEG capture opaque by default", () => { + expect(wantsTransparentBackground({ format: "jpeg" })).toBe(false); + expect(wantsTransparentBackground({})).toBe(false); + }); + + it("lets lossless opaque PNG capture opt out of the transparent background", () => { + expect(wantsTransparentBackground({ format: "png", transparentBackground: false })).toBe(false); + }); +}); + +describe("createStaticVerificationPage background", () => { + function makeSession(options: Record) { + const send = vi.fn().mockResolvedValue(undefined); + const verificationPage = { + evaluateOnNewDocument: vi.fn().mockResolvedValue(undefined), + setViewport: vi.fn().mockResolvedValue(undefined), + goto: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockResolvedValue(true), + createCDPSession: vi.fn().mockResolvedValue({ send }), + close: vi.fn().mockResolvedValue(undefined), + }; + const session = { + browser: { newPage: vi.fn().mockResolvedValue(verificationPage) }, + page: {}, + serverUrl: "http://127.0.0.1:3000", + subTimelineWaitMemo: {}, + scriptLoadFailures: [], + options: { + width: 1920, + height: 1080, + deviceScaleFactor: 1, + skipReadinessVideoIds: [], + ...options, + }, + } as unknown as CaptureSession; + return { session, send }; + } + + const clearsBackground = (send: ReturnType) => + send.mock.calls.some(([method]) => method === "Emulation.setDefaultBackgroundColorOverride"); + + it("clears the background for transparent PNG capture so verification matches capture", async () => { + const { session, send } = makeSession({ format: "png" }); + await createStaticVerificationPage(session); + expect(clearsBackground(send)).toBe(true); + }); + + it("keeps the page background for lossless opaque PNG capture", async () => { + const { session, send } = makeSession({ format: "png", transparentBackground: false }); + await createStaticVerificationPage(session); + expect(clearsBackground(send)).toBe(false); + }); +}); diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index 88586c4ea10..9335055e95a 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -902,6 +902,17 @@ async function waitForCloseWithTimeout(promise: Promise): Promise, +): boolean { + return options.transparentBackground ?? options.format === "png"; +} + /** * Post-readiness capture-surface init, shared by the screenshot and BeginFrame * init paths (called after the page is fully ready). When `useDrawElement` is @@ -944,10 +955,24 @@ async function initDrawElementOrTransparentBackground( // help, and the safety-net blank guard also stands down under it so diagnostic // frames arrive unmodified. const forceDE = process.env.HF_FORCE_DRAWELEMENT === "1"; + // drawElement's self-verification and worker-encode drain are JPEG-only, and its + // PNG path is built for transparent output; lossless opaque PNG uses the + // launch-mode capture instead. + const opaquePng = + session.options.format === "png" && !wantsTransparentBackground(session.options); const useDrawElement = ((session.config?.useDrawElement ?? false) || forceDE) && !supersampling && + !opaquePng && (!forceScreenshot || forceDE); + if ((session.config?.useDrawElement ?? false) && !supersampling && opaquePng) { + session.deGateReason = "opaque_png"; + session.deFallbackTrigger = "opaque_png"; + console.log( + "[engine] fast capture disabled for this render: lossless opaque PNG capture " + + "uses the launch-mode capture path.", + ); + } if ((session.config?.useDrawElement ?? false) && supersampling) { session.deGateReason = "supersampling"; session.deFallbackTrigger = "supersampling"; @@ -968,7 +993,7 @@ async function initDrawElementOrTransparentBackground( const gpuBackend = await detectGpuBackend(page); session.isSwiftShader = gpuBackend.isSwiftShader; session.gpuRenderer = classifyGpuRenderer(gpuBackend.renderer); - const transparent = session.options.format === "png"; + const transparent = wantsTransparentBackground(session.options); async function routeToFallback(): Promise { session.captureMode = session.launchCaptureMode; if (transparent) { @@ -1168,7 +1193,7 @@ async function initDrawElementOrTransparentBackground( } await finalizeDrawElementInit(session, page, logInitPhase, { transparent, forceDE }); } - } else if (session.options.format === "png") { + } else if (wantsTransparentBackground(session.options)) { await initTransparentBackground(session.page); } } @@ -1215,11 +1240,12 @@ async function finalizeDrawElementInit( } } // Worker-encode pipeline: macOS hardware GPU path only (syncToPaintEvent=true, - // beginFrameTimeTicks=0). Skip for BeginFrame (Linux/Docker) and transparent - // (PNG) output — those use the existing synchronous path unchanged. + // beginFrameTimeTicks=0). Skip for BeginFrame (Linux/Docker), transparent output + // and any PNG capture: the drain only writes JPEG. const workerEncodeEnabled = (session.config?.enableDrawElementWorkerEncode ?? false) && !transparent && + session.options.format !== "png" && session.beginFrameTimeTicks === 0; if (workerEncodeEnabled) { await initDrawElementWorkerEncode(page); @@ -1239,7 +1265,7 @@ export async function completeDeferredDrawElementInit(session: CaptureSession): const logInitPhase = (phase: string) => console.log(`[initSession:${session.captureMode}] ${phase} (deferred drawElement init)`); await finalizeDrawElementInit(session, page, logInitPhase, { - transparent: session.options.format === "png", + transparent: wantsTransparentBackground(session.options), forceDE: process.env.HF_FORCE_DRAWELEMENT === "1", }); session.deInitDeferred = false; @@ -1269,7 +1295,7 @@ export async function createCaptureSession( const isLinux = process.platform === "linux"; const forceScreenshot = config?.forceScreenshot ?? DEFAULT_CONFIG.forceScreenshot; const useDrawElement = config?.useDrawElement ?? false; - const drawElementTransparent = useDrawElement && options.format === "png"; + const drawElementTransparent = useDrawElement && wantsTransparentBackground(options); // drawElement and page-side shader compositing are mutually incompatible // capture strategies: drawElement reads the composition root's paint records // directly and skips the prepare→micro-screenshot→resolve protocol (the @@ -3467,7 +3493,7 @@ export async function createStaticVerificationPage(session: CaptureSession): Pro page.evaluate(`document.fonts?.ready`), waitForOptionalTailwindReady(page, pageReadyTimeout), ]); - if (session.options.format === "png") await initTransparentBackground(page); + if (wantsTransparentBackground(session.options)) await initTransparentBackground(page); return page; } catch (error) { await page.close().catch(() => {}); diff --git a/packages/engine/src/types.ts b/packages/engine/src/types.ts index 33bc5caa6eb..77097a4ba63 100644 --- a/packages/engine/src/types.ts +++ b/packages/engine/src/types.ts @@ -167,6 +167,13 @@ export interface CaptureOptions { fps: Fps; format?: "jpeg" | "png"; quality?: number; + /** + * Clear the page background so PNG frames carry real alpha. Absent means + * `format === "png"` (alpha output and motion blur rely on that). Pass `false` + * for lossless PNG capture of an opaque page, such as 4:4:4 MP4 output: the + * author's html/body background and Chrome's default white stay in the frame. + */ + transparentBackground?: boolean; /** * Opt into sub-frame multi-sample motion blur (issue #4010). Absent means off and the * capture path is byte-identical to a render without it. Requires `format: "png"` and diff --git a/packages/producer/README.md b/packages/producer/README.md index 515ae7692f5..c9002cf44f3 100644 --- a/packages/producer/README.md +++ b/packages/producer/README.md @@ -58,6 +58,7 @@ await startServer({ port: 8080 }); | `format` | `"mp4"` | Output container — `"mp4"`, `"webm"`, `"mov"`, `"gif"`, `"png-sequence"`, or `"hls"`. See [Transparent Video Output](#transparent-video-output) and [HLS Output](#hls-output) below. | | `hlsSegmentSeconds` | `4` | HLS target segment length in whole seconds. Ignored for every other format. | | `videoFrameFormat` | `"auto"` | Source video frame extraction format — `"auto"`, `"jpg"`, or `"png"`. Use `"png"` for UI recordings, screen captures, and color-sensitive source videos. | +| `chromaSubsampling` | `"420"` | MP4 chroma, `"420"` or `"444"`. `"444"` encodes yuv444p from lossless PNG capture for text/UI masters; SDR MP4 and software encoder only. Many players cannot decode it. | ## Transparent Video Output diff --git a/packages/producer/src/renderRequest.test.ts b/packages/producer/src/renderRequest.test.ts index 96ffd04f192..bf2ba1cc18a 100644 --- a/packages/producer/src/renderRequest.test.ts +++ b/packages/producer/src/renderRequest.test.ts @@ -352,6 +352,46 @@ describe("RenderRequest", () => { }); }); +describe("chromaSubsampling", () => { + function chromaRequest(chromaSubsampling: "420" | "444", distributed = false) { + return createRenderRequest({ + projectDir: "/project", + outputPath: "/output/video.mp4", + engineConfig: DEFAULT_CONFIG, + options: { + fps: { num: 30, den: 1 }, + quality: "standard", + format: "mp4", + chromaSubsampling, + ...(distributed ? { distributed: { width: 1920, height: 1080 } } : {}), + }, + }); + } + + it("round-trips 4:4:4 into the local render config", () => { + const value = chromaRequest("444"); + expect(parseRenderRequest(serializeRenderRequest(value))).toEqual(value); + expect(renderConfigFromRequest(value).chromaSubsampling).toBe("444"); + }); + + it("rejects an unknown chroma value at the wire boundary", () => { + const value = chromaRequest("420"); + expect(() => + parseRenderRequest({ ...value, options: { ...value.options, chromaSubsampling: "422" } }), + ).toThrow("chromaSubsampling is invalid"); + }); + + it("refuses 4:4:4 for a distributed render, which would come back 4:2:0", () => { + expect(() => distributedConfigFromRequest(chromaRequest("444", true))).toThrow( + "Distributed render does not support chromaSubsampling 444", + ); + }); + + it("still converts an explicit 4:2:0 request to a distributed config", () => { + expect(distributedConfigFromRequest(chromaRequest("420", true)).format).toBe("mp4"); + }); +}); + describe("segmented resume flags", () => { it("carries resumeSegments and keepSegments from the request into the render config", () => { const req = createRenderRequest({ diff --git a/packages/producer/src/renderRequest.ts b/packages/producer/src/renderRequest.ts index 5c5630887af..3ab1c55b9ad 100644 --- a/packages/producer/src/renderRequest.ts +++ b/packages/producer/src/renderRequest.ts @@ -57,6 +57,7 @@ export interface RenderRequestOptions { videoBitrate?: string; videoFrameFormat?: VideoFrameFormat; hdrMode?: RenderConfig["hdrMode"]; + chromaSubsampling?: RenderConfig["chromaSubsampling"]; pageMotionBlur?: RenderConfig["pageMotionBlur"]; variables?: Record; outputResolution?: CanvasResolution; @@ -181,6 +182,7 @@ function assertRequestOptionScalars(options: Record): void { } assertOptionalEnum(options, "strictness", ["strict", "best-effort"]); assertOptionalEnum(options, "hdrMode", ["auto", "force-hdr", "force-sdr"]); + assertOptionalEnum(options, "chromaSubsampling", ["420", "444"]); assertOptionalEnum(options, "pageMotionBlur", ["off"]); if (options.videoFrameFormat !== undefined && !isVideoFrameFormat(options.videoFrameFormat)) { throw new Error("Render request videoFrameFormat is invalid"); @@ -291,6 +293,11 @@ export function distributedConfigFromRequest( if (options.hdrMode === "force-hdr") { throw new Error("Distributed render does not support force-hdr"); } + // Chunk workers lock a 4:2:0 encoder triple into the plan and the CFR assemble + // re-encodes yuv420p, so a 4:4:4 request would come back 4:2:0. + if (options.chromaSubsampling === "444") { + throw new Error("Distributed render does not support chromaSubsampling 444"); + } return { fps: distributedFps(options.fps), width: distributed.width, diff --git a/packages/producer/src/server.encoderOptions.test.ts b/packages/producer/src/server.encoderOptions.test.ts index 86064c0f3bc..1c4bcdda5ad 100644 --- a/packages/producer/src/server.encoderOptions.test.ts +++ b/packages/producer/src/server.encoderOptions.test.ts @@ -157,4 +157,31 @@ describe.each(ROUTES)("POST %s — encoder options", (path) => { "outputDynamicRange and legacy hdrMode must describe the same output policy", ); }); + + it("forwards 4:4:4 chroma for an SDR mp4 render", async () => { + const response = await requestRender(path, { chromaSubsampling: "444" }); + + expect(response.status).toBe(200); + expect(capturedRenderConfigs[0]?.chromaSubsampling).toBe("444"); + }); + + it("sends no chroma override when none is requested", async () => { + await requestRender(path, {}); + + expect(capturedRenderConfigs[0]).not.toHaveProperty("chromaSubsampling"); + }); + + it.each([ + [{ chromaSubsampling: "422" }, "chromaSubsampling must be one of"], + // Fragments without quotes: the error arrives JSON-encoded, so `"444"` is escaped. + [{ chromaSubsampling: "444", format: "webm" }, "supports mp4 only (got webm)"], + [{ chromaSubsampling: "444", gpu: true }, "requires the software encoder"], + [{ chromaSubsampling: "444", hdrMode: "force-hdr" }, "SDR only"], + [ + { chromaSubsampling: "444", outputDynamicRange: "hdr" }, + "is SDR only, not outputDynamicRange", + ], + ])("rejects chroma request %j", async (body, message) => { + await expectRejected(body, message); + }); }); diff --git a/packages/producer/src/server.ts b/packages/producer/src/server.ts index 1a7ad26d953..93ecd10158b 100644 --- a/packages/producer/src/server.ts +++ b/packages/producer/src/server.ts @@ -88,6 +88,7 @@ interface RenderInput { workers?: number; crf?: number; videoBitrate?: string; + chromaSubsampling?: RenderConfig["chromaSubsampling"]; useGpu: boolean; debug: boolean; strictness: RenderConfig["strictness"]; @@ -236,7 +237,9 @@ export function parseRenderOptions(body: Record): Omit): Omit): string | undefi ) { return "outputDynamicRange and legacy hdrMode must describe the same output policy"; } + // parseEncoderOptions only sees the legacy hdrMode; HDR requested through + // outputDynamicRange would otherwise pass and fail later in the render. + if (body.chromaSubsampling === "444" && outputDynamicRange === "hdr") { + return 'chromaSubsampling "444" is SDR only, not outputDynamicRange "hdr"'; + } return validateOutputResolutionOverride(body); } diff --git a/packages/producer/src/services/render/captureImageFormat.test.ts b/packages/producer/src/services/render/captureImageFormat.test.ts index e7b663ca93d..865a5e57f7a 100644 --- a/packages/producer/src/services/render/captureImageFormat.test.ts +++ b/packages/producer/src/services/render/captureImageFormat.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "bun:test"; import { frameFileExtension } from "@hyperframes/engine"; -import { resolveCaptureImageFormat, resolveCaptureJpegQuality } from "./captureImageFormat.js"; +import { + resolveCaptureImageFormat, + resolveCaptureJpegQuality, + resolveCaptureSurface, +} from "./captureImageFormat.js"; describe("resolveCaptureImageFormat", () => { it("captures JPEG for an ordinary opaque render", () => { @@ -16,6 +20,23 @@ describe("resolveCaptureImageFormat", () => { expect(resolveCaptureImageFormat({ needsAlpha: false, motionBlur: {} })).toBe("png"); }); + it("captures PNG for 4:4:4 output, since Chrome's JPEG is already 4:2:0", () => { + expect( + resolveCaptureImageFormat({ + needsAlpha: false, + motionBlur: undefined, + chromaSubsampling: "444", + }), + ).toBe("png"); + expect( + resolveCaptureImageFormat({ + needsAlpha: false, + motionBlur: undefined, + chromaSubsampling: "420", + }), + ).toBe("jpeg"); + }); + it("agrees with the name the capture writer gives a frame", () => { // The encoder's input pattern is built from this extension, so a disagreement here is // a render that captures frames and then finds none. @@ -28,6 +49,38 @@ describe("resolveCaptureImageFormat", () => { }); }); +describe("resolveCaptureSurface", () => { + const base = { needsAlpha: false, motionBlur: undefined }; + + it("keeps the ordinary render on JPEG at the resolved quality with the engine background", () => { + expect(resolveCaptureSurface(base, "standard")).toEqual({ format: "jpeg", quality: 100 }); + expect(resolveCaptureSurface(base, "draft")).toEqual({ format: "jpeg", quality: 80 }); + }); + + it("captures 4:4:4 as opaque PNG so an unstyled page does not turn black", () => { + expect(resolveCaptureSurface({ ...base, chromaSubsampling: "444" }, "draft")).toEqual({ + format: "png", + quality: undefined, + transparentBackground: false, + }); + }); + + it("leaves alpha output and motion blur on the transparent PNG default", () => { + // No transparentBackground key means the engine falls back to format === "png". + expect(resolveCaptureSurface({ ...base, needsAlpha: true }, "standard")).toEqual({ + format: "png", + quality: undefined, + }); + expect(resolveCaptureSurface({ ...base, motionBlur: {} }, "standard")).toEqual({ + format: "png", + quality: undefined, + }); + expect( + resolveCaptureSurface({ ...base, needsAlpha: true, chromaSubsampling: "444" }, "standard"), + ).toEqual({ format: "png", quality: undefined }); + }); +}); + describe("resolveCaptureJpegQuality", () => { it("captures standard and high renders at quality 100 so graded colour survives", () => { expect(resolveCaptureJpegQuality("standard")).toBe(100); diff --git a/packages/producer/src/services/render/captureImageFormat.ts b/packages/producer/src/services/render/captureImageFormat.ts index 44074d95c3d..24049fb7beb 100644 --- a/packages/producer/src/services/render/captureImageFormat.ts +++ b/packages/producer/src/services/render/captureImageFormat.ts @@ -1,14 +1,17 @@ -import type { EngineConfig, MotionBlurOptions } from "@hyperframes/engine"; +import type { ChromaSubsampling, EngineConfig, MotionBlurOptions } from "@hyperframes/engine"; /** * The format a render captures its frames in. * - * Two independent reasons force PNG, and conflating them is how the encoder ends up + * Three independent reasons force PNG, and conflating them is how the encoder ends up * hunting for `frame_%06d.jpg` against files written as `.png`: * * - the output container keeps alpha, which JPEG cannot carry; * - motion blur is on, because the sub-frame samples are averaged pixel by pixel and - * JPEG samples would be averaged after a lossy quantization. + * JPEG samples would be averaged after a lossy quantization; + * - 4:4:4 output, because Chrome's quality-100 JPEG is already chroma-subsampled 4:2:0 + * on beginFrame and screenshot capture, and drawElement can fall back to screenshot + * mid-render, so PNG is the only capture that keeps full-resolution colour. * * Every consumer of "is this render PNG" reads the resolved capture format rather than * re-deriving it from one of the reasons. @@ -16,8 +19,9 @@ import type { EngineConfig, MotionBlurOptions } from "@hyperframes/engine"; export function resolveCaptureImageFormat(input: { needsAlpha: boolean; motionBlur: MotionBlurOptions | undefined; + chromaSubsampling?: ChromaSubsampling; }): "jpeg" | "png" { - return input.needsAlpha || input.motionBlur ? "png" : "jpeg"; + return input.needsAlpha || input.motionBlur || input.chromaSubsampling === "444" ? "png" : "jpeg"; } /** @@ -27,3 +31,26 @@ export function resolveCaptureImageFormat(input: { export function resolveCaptureJpegQuality(quality: EngineConfig["quality"]): number { return quality === "draft" ? 80 : 100; } + +/** + * The capture fields every session of a render shares: format, JPEG quality and whether + * the page background is cleared. The probe session is reused for capture, so the probe + * and the main capture options both spread this rather than re-deriving it. + * + * Only 4:4:4 without alpha opts out of the transparent background: its PNG frames are + * opaque MP4 input, and clearing html/body would turn an unstyled page black. Alpha + * output and motion blur keep the engine default (transparent PNG). + */ +export function resolveCaptureSurface( + input: Parameters[0], + quality: EngineConfig["quality"], +): { format: "jpeg" | "png"; quality: number | undefined; transparentBackground?: false } { + const format = resolveCaptureImageFormat(input); + return { + format, + quality: format === "png" ? undefined : resolveCaptureJpegQuality(quality), + ...(input.chromaSubsampling === "444" && !input.needsAlpha + ? { transparentBackground: false as const } + : {}), + }; +} diff --git a/packages/producer/src/services/render/chromaSubsampling.test.ts b/packages/producer/src/services/render/chromaSubsampling.test.ts new file mode 100644 index 00000000000..4db5bba048b --- /dev/null +++ b/packages/producer/src/services/render/chromaSubsampling.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "bun:test"; +import { + assertChromaSubsamplingSdr, + validateChromaSubsamplingConfig, +} from "./chromaSubsampling.js"; + +describe("validateChromaSubsamplingConfig", () => { + it("is a no-op for the default and for explicit 4:2:0 on every format", () => { + for (const format of ["mp4", "webm", "mov", "gif", "png-sequence", "hls"]) { + expect(() => validateChromaSubsamplingConfig({ format })).not.toThrow(); + expect(() => + validateChromaSubsamplingConfig({ format, chromaSubsampling: "420", useGpu: true }), + ).not.toThrow(); + } + }); + + it("accepts 4:4:4 for SDR mp4 with the software encoder", () => { + for (const hdrMode of [undefined, "auto", "force-sdr"] as const) { + expect(() => + validateChromaSubsamplingConfig({ format: "mp4", chromaSubsampling: "444", hdrMode }), + ).not.toThrow(); + } + // format defaults to mp4 when the caller leaves it out + expect(() => validateChromaSubsamplingConfig({ chromaSubsampling: "444" })).not.toThrow(); + }); + + it("rejects 4:4:4 for every non-mp4 format, naming the format", () => { + for (const format of ["webm", "mov", "gif", "png-sequence", "hls"]) { + expect(() => validateChromaSubsamplingConfig({ format, chromaSubsampling: "444" })).toThrow( + `supports format "mp4" only (got "${format}")`, + ); + } + }); + + it("rejects 4:4:4 with forced HDR output", () => { + expect(() => + validateChromaSubsamplingConfig({ chromaSubsampling: "444", hdrMode: "force-hdr" }), + ).toThrow("SDR only"); + }); + + it("rejects 4:4:4 with GPU encoding", () => { + expect(() => + validateChromaSubsamplingConfig({ chromaSubsampling: "444", useGpu: true }), + ).toThrow("Re-run without --gpu"); + }); +}); + +describe("assertChromaSubsamplingSdr", () => { + it("rejects 4:4:4 once auto detection finds HDR sources, pointing at --sdr", () => { + expect(() => assertChromaSubsamplingSdr({ chromaSubsampling: "444" }, true)).toThrow("--sdr"); + }); + + it("passes 4:4:4 on SDR content and ignores 4:2:0 on HDR content", () => { + expect(() => assertChromaSubsamplingSdr({ chromaSubsampling: "444" }, false)).not.toThrow(); + expect(() => assertChromaSubsamplingSdr({ chromaSubsampling: "420" }, true)).not.toThrow(); + expect(() => assertChromaSubsamplingSdr({}, true)).not.toThrow(); + }); +}); diff --git a/packages/producer/src/services/render/chromaSubsampling.ts b/packages/producer/src/services/render/chromaSubsampling.ts new file mode 100644 index 00000000000..472df3aa568 --- /dev/null +++ b/packages/producer/src/services/render/chromaSubsampling.ts @@ -0,0 +1,66 @@ +/** + * Fail-fast gates for `chromaSubsampling: "444"`. + * + * 4:4:4 is only worth shipping when every stage keeps full-resolution colour: the + * capture (PNG, see `resolveCaptureSurface`) and the encode (libx264 yuv444p). These + * gates refuse the combinations where some stage would quietly drop back to 4:2:0, + * because the output would look like a 4:4:4 master while carrying 4:2:0 colour. + */ + +import type { ChromaSubsampling } from "@hyperframes/engine"; + +export interface ChromaSubsamplingRenderConfig { + chromaSubsampling?: ChromaSubsampling; + format?: string; + hdrMode?: "auto" | "force-hdr" | "force-sdr"; + useGpu?: boolean; +} + +/** + * Reject the config combinations 4:4:4 cannot honor. No-op unless "444" is requested, + * so this runs unconditionally at the top of `executeRenderJob`. + */ +export function validateChromaSubsamplingConfig(config: ChromaSubsamplingRenderConfig): void { + if (config.chromaSubsampling !== "444") return; + + const format = config.format ?? "mp4"; + if (format !== "mp4") { + throw new Error( + `[Render] chromaSubsampling "444" supports format "mp4" only (got "${format}"). ` + + `WebM, MOV and GIF use their own pixel formats, and HLS targets broad playback. ` + + `Use --format mp4, or drop --chroma-subsampling 444.`, + ); + } + + if (config.hdrMode === "force-hdr") { + throw new Error( + `[Render] chromaSubsampling "444" is SDR only; HDR output stays 10-bit 4:2:0 H.265. ` + + `Drop --hdr, or drop --chroma-subsampling 444.`, + ); + } + + // GPU encoders disagree on 4:4:4 (VAAPI uploads NV12, VideoToolbox and AMF H.264 + // have no 4:4:4 profile), so a GPU render would silently fall back to 4:2:0 on + // some hosts and not others. + if (config.useGpu === true) { + throw new Error( + `[Render] chromaSubsampling "444" requires the software encoder (libx264); ` + + `GPU encoders do not reliably support 4:4:4. Re-run without --gpu.`, + ); + } +} + +/** + * Reject 4:4:4 once auto HDR detection has found HDR sources, before any capture + * runs. `validateChromaSubsamplingConfig` cannot see this: it depends on probing. + */ +export function assertChromaSubsamplingSdr( + config: Pick, + hasHdrContent: boolean, +): void { + if (config.chromaSubsampling !== "444" || !hasHdrContent) return; + throw new Error( + `[Render] chromaSubsampling "444" is SDR only, but this composition has HDR sources. ` + + `Re-run with --sdr (hdrMode "force-sdr"), or drop --chroma-subsampling 444.`, + ); +} diff --git a/packages/producer/src/services/render/stages/compileStage.test.ts b/packages/producer/src/services/render/stages/compileStage.test.ts index 245124eac48..e84d4f88633 100644 --- a/packages/producer/src/services/render/stages/compileStage.test.ts +++ b/packages/producer/src/services/render/stages/compileStage.test.ts @@ -472,3 +472,47 @@ describe("runCompileStage — aspect-agnostic --resolution re-map", () => { ).rejects.toThrow(/aspect ratio|--resolution portrait/i); }); }); + +describe("runCompileStage — chromaSubsampling 444 drawElement gate", () => { + let fixture: CompileFixture | null = null; + + afterEach(() => { + fixture?.cleanup(); + fixture = null; + }); + + async function compileWithChroma( + chromaSubsampling: "420" | "444" | undefined, + ): Promise<{ result: CompileStageResult; cfg: EngineConfig }> { + fixture = setupFixture(PLAIN_HTML); + const cfg = createCfg({ useDrawElement: true }); + const result = await runCompileStage({ + projectDir: join(fixture.workDir, "project"), + workDir: fixture.workDir, + htmlPath: fixture.htmlPath, + entryFile: "index.html", + job: createJob(chromaSubsampling ? { chromaSubsampling } : {}), + cfg, + needsAlpha: false, + log: noopLog, + assertNotAborted: () => {}, + }); + return { result, cfg }; + } + + it("turns drawElement off for 4:4:4, whose lossless PNG frames it cannot capture", async () => { + const { result, cfg } = await compileWithChroma("444"); + expect(cfg.useDrawElement).toBe(false); + expect(result.deCompileGate).toBe("chroma_444"); + }); + + it("leaves drawElement on for the default and for explicit 4:2:0", async () => { + for (const chroma of [undefined, "420"] as const) { + const { result, cfg } = await compileWithChroma(chroma); + expect(cfg.useDrawElement).toBe(true); + expect(result.deCompileGate).toBeUndefined(); + fixture?.cleanup(); + fixture = null; + } + }); +}); diff --git a/packages/producer/src/services/render/stages/compileStage.ts b/packages/producer/src/services/render/stages/compileStage.ts index a4a4b013093..9d578437242 100644 --- a/packages/producer/src/services/render/stages/compileStage.ts +++ b/packages/producer/src/services/render/stages/compileStage.ts @@ -286,6 +286,18 @@ export async function runCompileStage(input: CompileStageInput): Promise 0); + assertChromaSubsamplingSdr(job.config, hasHdrContent); if (hasHdrContent) assertOutputScaleSupported(deviceScaleFactor, "hdr_layered"); routePageMotionBlur = pageMotionBlurForRoute({ hasHdrContent, useLayeredComposite: false }); // DE priority inversion eligibility — evaluated BEFORE capture calibration @@ -4370,6 +4388,8 @@ async function executeRenderPipeline(input: { hasHdrContent: capturePlan.hasHdrContent, hasShaderTransitions: compiled.hasShaderTransitions, isPngSequence, + captureImageFormat: captureOptions.format ?? "jpeg", + chromaSubsampling: job.config.chromaSubsampling ?? "420", }); const encoderHdr = hasHdrContent ? effectiveHdr : undefined; // png-sequence has no encoder, but the rest of the orchestrator still @@ -4378,7 +4398,12 @@ async function executeRenderPipeline(input: { // are never written to ffmpeg in the png-sequence path. const presetFormat: "mp4" | "webm" | "mov" = outputFormat === "webm" || outputFormat === "mov" ? outputFormat : "mp4"; - const preset = getEncoderPreset(job.config.quality, presetFormat, encoderHdr); + const preset = getEncoderPreset( + job.config.quality, + presetFormat, + encoderHdr, + job.config.chromaSubsampling, + ); // CLI overrides (--crf, --video-bitrate) flow through job.config and must // win over the preset-derived defaults. The CLI enforces mutual exclusivity diff --git a/packages/studio-server/src/helpers/encoderOptions.test.ts b/packages/studio-server/src/helpers/encoderOptions.test.ts index 1c0b7b7960e..5a565b97fae 100644 --- a/packages/studio-server/src/helpers/encoderOptions.test.ts +++ b/packages/studio-server/src/helpers/encoderOptions.test.ts @@ -40,4 +40,34 @@ describe("parseEncoderOptions", () => { expect(parseEncoderOptions({ videoFrameFormat: "webp" }, "mp4").ok).toBe(false); expect(parseEncoderOptions({ hdrMode: "on" }, "mp4").ok).toBe(false); }); + + it("passes 4:4:4 chroma through for SDR mp4 on the software encoder", () => { + expect(parseEncoderOptions({ chromaSubsampling: "444", crf: 16 }, "mp4")).toEqual({ + ok: true, + options: { crf: 16, chromaSubsampling: "444" }, + }); + expect(parseEncoderOptions({ chromaSubsampling: "420" }, "webm")).toEqual({ + ok: true, + options: { chromaSubsampling: "420" }, + }); + }); + + it("rejects an unknown chroma value", () => { + expect(parseEncoderOptions({ chromaSubsampling: "422" }, "mp4")).toEqual({ + ok: false, + error: "chromaSubsampling must be one of 420, 444", + }); + expect(parseEncoderOptions({ chromaSubsampling: 444 }, "mp4").ok).toBe(false); + }); + + it.each([ + [{ chromaSubsampling: "444" }, "webm", "supports mp4 only"], + [{ chromaSubsampling: "444" }, "mov", "supports mp4 only"], + [{ chromaSubsampling: "444", gpu: true }, "mp4", "software encoder"], + [{ chromaSubsampling: "444", hdrMode: "force-hdr" }, "mp4", "SDR only"], + ])("rejects 4:4:4 that would not stay 4:4:4: %j as %s", (body, format, message) => { + const parsed = parseEncoderOptions(body, format); + expect(parsed.ok).toBe(false); + expect(parsed.ok ? "" : parsed.error).toContain(message); + }); }); diff --git a/packages/studio-server/src/helpers/encoderOptions.ts b/packages/studio-server/src/helpers/encoderOptions.ts index c99c9b1efa5..478b31bba98 100644 --- a/packages/studio-server/src/helpers/encoderOptions.ts +++ b/packages/studio-server/src/helpers/encoderOptions.ts @@ -2,12 +2,14 @@ const CRF_MAX_BY_FORMAT: Record = { mp4: 51, webm: 63 }; const VIDEO_BITRATE = /^\d+(\.\d+)?[kKmMgG]?$/; const VIDEO_FRAME_FORMATS = ["auto", "jpg", "png"] as const; const HDR_MODES = ["auto", "force-hdr", "force-sdr"] as const; +const CHROMA_SUBSAMPLINGS = ["420", "444"] as const; interface EncoderOptions { crf?: number; videoBitrate?: string; videoFrameFormat?: (typeof VIDEO_FRAME_FORMATS)[number]; hdrMode?: (typeof HDR_MODES)[number]; + chromaSubsampling?: (typeof CHROMA_SUBSAMPLINGS)[number]; } export interface EncoderOptionsBody { @@ -15,6 +17,9 @@ export interface EncoderOptionsBody { videoBitrate?: unknown; videoFrameFormat?: unknown; hdrMode?: unknown; + chromaSubsampling?: unknown; + /** Read only to refuse 4:4:4 with GPU encoding; callers resolve `gpu` themselves. */ + gpu?: unknown; } type Parsed = { ok: true; options: EncoderOptions } | { ok: false; error: string }; @@ -50,6 +55,33 @@ function parseRateControl(body: EncoderOptionsBody, format: string): Parsed { return { ok: true, options: {} }; } +/** + * 4:4:4 needs SDR MP4 from the software encoder; anything else would come back 4:2:0 + * or fail mid-render, so refuse it here with a 400 instead. + */ +function parseChromaSubsampling(body: EncoderOptionsBody, format: string): Parsed { + const chromaSubsampling = body.chromaSubsampling; + if (!isSet(chromaSubsampling)) return { ok: true, options: {} }; + if (!oneOf(chromaSubsampling, CHROMA_SUBSAMPLINGS)) { + return { + ok: false, + error: `chromaSubsampling must be one of ${CHROMA_SUBSAMPLINGS.join(", ")}`, + }; + } + if (chromaSubsampling === "444") { + if (format !== "mp4") { + return { ok: false, error: `chromaSubsampling "444" supports mp4 only (got ${format})` }; + } + if (body.gpu === true) { + return { ok: false, error: 'chromaSubsampling "444" requires the software encoder, not gpu' }; + } + if (body.hdrMode === "force-hdr") { + return { ok: false, error: 'chromaSubsampling "444" is SDR only, not force-hdr' }; + } + } + return { ok: true, options: { chromaSubsampling } }; +} + /** Explicit encoder overrides from a render request. MOV (ProRes) takes no rate control. */ export function parseEncoderOptions(body: EncoderOptionsBody, format: string): Parsed { const rate = parseRateControl(body, format); @@ -70,5 +102,7 @@ export function parseEncoderOptions(body: EncoderOptionsBody, format: string): P } options.hdrMode = body.hdrMode; } - return { ok: true, options }; + const chroma = parseChromaSubsampling(body, format); + if (!chroma.ok) return chroma; + return { ok: true, options: { ...options, ...chroma.options } }; } diff --git a/packages/studio-server/src/routes/render.test.ts b/packages/studio-server/src/routes/render.test.ts index 75adffe67fd..3ba862b99a7 100644 --- a/packages/studio-server/src/routes/render.test.ts +++ b/packages/studio-server/src/routes/render.test.ts @@ -385,6 +385,23 @@ describe("POST /projects/:id/render — crf", () => { expect(res.status).toBe(400); expect(opts).toBeUndefined(); }); + + it("forwards 4:4:4 chroma for mp4 and omits it when not requested", async () => { + const chroma = await post({ format: "mp4", chromaSubsampling: "444" }); + expect(chroma.res.status).toBe(200); + expect(chroma.opts.chromaSubsampling).toBe("444"); + expect((await post({ format: "mp4" })).opts).not.toHaveProperty("chromaSubsampling"); + }); + + it.each([ + { format: "webm", chromaSubsampling: "444" }, + { format: "mp4", chromaSubsampling: "444", gpu: true }, + { format: "mp4", chromaSubsampling: "422" }, + ])("rejects chroma request %j with 400 and does not start a render", async (body) => { + const { res, opts } = await post(body); + expect(res.status).toBe(400); + expect(opts).toBeUndefined(); + }); }); describe("POST /projects/:id/render — composition path safety", () => { diff --git a/packages/studio-server/src/types.ts b/packages/studio-server/src/types.ts index 6d6ac952831..ae186132a37 100644 --- a/packages/studio-server/src/types.ts +++ b/packages/studio-server/src/types.ts @@ -177,6 +177,8 @@ export interface StudioApiAdapter { videoBitrate?: string; videoFrameFormat?: "auto" | "jpg" | "png"; hdrMode?: "auto" | "force-hdr" | "force-sdr"; + /** "444" opts an SDR MP4 render into full-resolution chroma (PNG capture, yuv444p). */ + chromaSubsampling?: "420" | "444"; jobId: string; /** * The triggering browser profile has telemetry disabled (localStorage diff --git a/packages/studio/vite.adapter.ts b/packages/studio/vite.adapter.ts index 0c765411827..6d0cb6e62e9 100644 --- a/packages/studio/vite.adapter.ts +++ b/packages/studio/vite.adapter.ts @@ -355,6 +355,7 @@ export function createViteAdapter( ...(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 } : {}), }); diff --git a/skills-manifest.json b/skills-manifest.json index 5306b057ace..b3e0117e5bf 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -10,7 +10,7 @@ "files": 77 }, "hyperframes-cli": { - "hash": "d69b5b51429367f9", + "hash": "482b11e6ba5efc20", "files": 13 }, "hyperframes-registry": { diff --git a/skills/hyperframes-cli/references/preview-render.md b/skills/hyperframes-cli/references/preview-render.md index 9c25b3dbafa..5951ff0d153 100644 --- a/skills/hyperframes-cli/references/preview-render.md +++ b/skills/hyperframes-cli/references/preview-render.md @@ -128,6 +128,7 @@ npx hyperframes render --docker # byte-identical | `--resolution` | landscape, portrait, landscape-4k, portrait-4k, square, square-4k (+ aliases `1080p`, `4k`, `uhd`) | — | Supersample via Chrome `deviceScaleFactor`. Aspect ratio must match composition; scale must be an integer. Not with `--hdr`. | | `--crf` | 0-51 | — | Encoder CRF (lower = higher quality). Mutually exclusive with `--video-bitrate`. | | `--video-bitrate` | e.g. `10M`, `5000k` | — | Target bitrate. Mutually exclusive with `--crf`. | +| `--chroma-subsampling` | 420, 444 | 420 | `444` keeps full-resolution colour for thin coloured text/UI (yuv444p, lossless PNG capture, slower). SDR MP4 + software encoder only; rejected with `--gpu`, `--hdr`, other formats. Many players cannot decode 4:4:4. | | `--hdr` | flag | off | Force HDR output even with SDR sources. MP4 only. | | `--sdr` | flag | off | Force SDR even with HDR sources. | | `--workers` | number or `auto` | auto | Each worker spawns Chrome (~256 MB) |