From f3517ac3772c35618d9eed19528b74755a352f18 Mon Sep 17 00:00:00 2001 From: PaprikaChen Date: Sat, 17 Oct 2026 01:06:55 -0700 Subject: [PATCH 1/8] feat(engine): decouple transparent background from png capture format PNG capture used to imply a cleared page background. A new CaptureOptions.transparentBackground lets lossless opaque PNG capture (4:4:4 MP4 output) keep the page background. Absent, it still follows format === "png", so alpha output and motion blur are unchanged. Opaque PNG skips drawElement, whose verification and worker-encode paths are JPEG-only. Refs #5555 Co-Authored-By: Claude Opus 5.5 --- ...frameCapture-transparentBackground.test.ts | 65 +++++++++++++++++++ packages/engine/src/services/frameCapture.ts | 40 ++++++++++-- packages/engine/src/types.ts | 7 ++ 3 files changed, 105 insertions(+), 7 deletions(-) create mode 100644 packages/engine/src/services/frameCapture-transparentBackground.test.ts 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 99b0aef478b..ba8c38a8373 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -897,6 +897,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 @@ -939,10 +950,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"; @@ -963,7 +988,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) { @@ -1163,7 +1188,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); } } @@ -1210,11 +1235,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); @@ -1234,7 +1260,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; @@ -1264,7 +1290,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 @@ -3495,7 +3521,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 ef71e03e5a4..fd895598c6b 100644 --- a/packages/engine/src/types.ts +++ b/packages/engine/src/types.ts @@ -165,6 +165,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 From c5611837807107eb53fe98ed9a105ce9a565cca4 Mon Sep 17 00:00:00 2001 From: PaprikaChen Date: Sat, 17 Oct 2026 01:07:31 -0700 Subject: [PATCH 2/8] feat(engine): add 4:4:4 chroma option to getEncoderPreset getEncoderPreset takes an optional chromaSubsampling ("420" by default). "444" returns yuv444p for SDR MP4, so libx264 encodes the High 4:4:4 Predictive profile and odd sizes need no even padding. WebM, MOV and HDR presets ignore it. The ChromaSubsampling type is exported for the producer. Refs #5555 Co-Authored-By: Claude Opus 5.5 --- packages/engine/src/index.ts | 1 + .../engine/src/services/chunkEncoder.test.ts | 42 +++++++++++++++++++ packages/engine/src/services/chunkEncoder.ts | 11 ++++- 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index e6fa67ca28b..f51f95eefba 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -188,6 +188,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 74e5e278a2a..025ac2fef58 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 98cd10aa080..3eeb771d1f3 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. From 24490e60c1f51ed3c8fec26ed7e69f732dc1cc8a Mon Sep 17 00:00:00 2001 From: PaprikaChen Date: Sat, 17 Oct 2026 01:27:59 -0700 Subject: [PATCH 3/8] feat(producer): chromaSubsampling render option with lossless png capture RenderConfig.chromaSubsampling ("420" by default) opts an SDR MP4 render into 4:4:4. "444" encodes libx264 yuv444p and captures every frame as opaque PNG: Chrome's quality-100 JPEG is already 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 the colour. drawElement is turned off for these renders. The probe and main capture options share resolveCaptureSurface, so they cannot disagree on format, quality or background. Formats other than mp4, GPU encoding and HDR output are rejected before any work starts, auto-detected HDR sources fail before capture, and distributed requests refuse 444. The default render is unchanged. Refs #5555 Co-Authored-By: Claude Opus 5.5 --- packages/producer/src/renderRequest.test.ts | 40 +++++++++++ packages/producer/src/renderRequest.ts | 7 ++ .../render/captureImageFormat.test.ts | 55 +++++++++++++++- .../src/services/render/captureImageFormat.ts | 35 ++++++++-- .../services/render/chromaSubsampling.test.ts | 58 ++++++++++++++++ .../src/services/render/chromaSubsampling.ts | 66 +++++++++++++++++++ .../render/stages/compileStage.test.ts | 44 +++++++++++++ .../services/render/stages/compileStage.ts | 12 ++++ .../src/services/render/stages/probeStage.ts | 18 ++--- .../src/services/renderOrchestrator.ts | 49 ++++++++++---- 10 files changed, 358 insertions(+), 26 deletions(-) create mode 100644 packages/producer/src/services/render/chromaSubsampling.test.ts create mode 100644 packages/producer/src/services/render/chromaSubsampling.ts 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 63121520a17..bd5f328cfaa 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"]; variables?: Record; outputResolution?: CanvasResolution; outputResolutionAspectAgnostic?: boolean; @@ -180,6 +181,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"]); if (options.videoFrameFormat !== undefined && !isVideoFrameFormat(options.videoFrameFormat)) { throw new Error("Render request videoFrameFormat is invalid"); } @@ -289,6 +291,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/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 51bf5b7f208..683825cdcec 100644 --- a/packages/producer/src/services/render/stages/compileStage.ts +++ b/packages/producer/src/services/render/stages/compileStage.ts @@ -280,6 +280,18 @@ export async function runCompileStage(input: CompileStageInput): Promise 0); + assertChromaSubsamplingSdr(job.config, hasHdrContent); // DE priority inversion eligibility — evaluated BEFORE capture calibration // because when every multi-worker resolution would be inverted to 1 anyway, // the calibration stage (a throwaway Chrome launch + timeline-spread sample @@ -4323,6 +4341,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 @@ -4331,7 +4351,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 From 06f2ff0bd9f4ef0eee7bb8945191672cc309222a Mon Sep 17 00:00:00 2001 From: PaprikaChen Date: Sat, 17 Oct 2026 01:38:39 -0700 Subject: [PATCH 4/8] feat(producer,studio-server): accept chromaSubsampling on render routes The shared parseEncoderOptions validates chromaSubsampling and answers 400 for "444" with a non-mp4 format, gpu, or force-hdr, so a request that would come back 4:2:0 never starts a render. The Studio render route, both Studio adapters and the producer HTTP server forward it. The producer server also refuses "444" with outputDynamicRange "hdr", which parseEncoderOptions cannot see. No Studio UI control; the option is route-level like crf and hdrMode. Refs #5555 Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/server/studioServer.ts | 1 + .../src/server.encoderOptions.test.ts | 27 ++++++++++++++ packages/producer/src/server.ts | 12 ++++++- .../src/helpers/encoderOptions.test.ts | 30 ++++++++++++++++ .../src/helpers/encoderOptions.ts | 36 ++++++++++++++++++- .../studio-server/src/routes/render.test.ts | 17 +++++++++ packages/studio-server/src/types.ts | 2 ++ packages/studio/vite.adapter.ts | 1 + 8 files changed, 124 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index ca404a0ec10..30e2cb4398e 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -633,6 +633,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 } : {}), }); renderJob = job; 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 b56f3fa24c1..d8ed4547a1b 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/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 9db0ee5499f..74010b69b84 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 0f0782ae40c..0251f409d49 100644 --- a/packages/studio-server/src/types.ts +++ b/packages/studio-server/src/types.ts @@ -175,6 +175,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 340bacebb9d..87684c61d2b 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 } : {}), }); const onProgress = (j: { progress: number; currentStage?: string }) => { From b967b196bdee1b4ddaa5207152e1f4dd8f8c0753 Mon Sep 17 00:00:00 2001 From: PaprikaChen Date: Sat, 17 Oct 2026 01:46:03 -0700 Subject: [PATCH 5/8] feat(cli): add --chroma-subsampling flag `hyperframes render --chroma-subsampling 444` opts an SDR MP4 render into full-resolution chroma (yuv444p with lossless PNG capture). The default stays 420. The plan rejects 444 with a non-mp4 format, --gpu or --hdr before anything renders, with a usage error that names the conflicting flag. The value reaches the local render request and, only when it is 444, the Docker argv, so default Docker invocations are unchanged. Refs #5555 Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/commands/render.test.ts | 15 +++++++++ packages/cli/src/commands/render.ts | 12 +++++++ packages/cli/src/commands/render/execute.ts | 2 ++ packages/cli/src/commands/render/plan.test.ts | 33 +++++++++++++++++++ packages/cli/src/commands/render/plan.ts | 33 +++++++++++++++++++ packages/cli/src/utils/dockerRunArgs.test.ts | 10 ++++++ packages/cli/src/utils/dockerRunArgs.ts | 3 ++ 7 files changed, 108 insertions(+) diff --git a/packages/cli/src/commands/render.test.ts b/packages/cli/src/commands/render.test.ts index 752189d78aa..adf95bf6f50 100644 --- a/packages/cli/src/commands/render.test.ts +++ b/packages/cli/src/commands/render.test.ts @@ -1133,6 +1133,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 913109d64ff..1e665aea467 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, @@ -1106,6 +1117,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 f85a55cbe59..ee1fa64e77f 100644 --- a/packages/cli/src/commands/render/execute.ts +++ b/packages/cli/src/commands/render/execute.ts @@ -151,6 +151,7 @@ export function renderOptionsFromPlan( vp9CpuUsed: plan.vp9CpuUsed, videoBitrate: plan.videoBitrate, videoFrameFormat: plan.videoFrameFormat, + chromaSubsampling: plan.chromaSubsampling, quiet: plan.quiet, browserPath, debug: plan.debug, @@ -345,6 +346,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/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 From 5e3a6f856f1db61cd71edb93a9b6b58d73f6ac58 Mon Sep 17 00:00:00 2001 From: PaprikaChen Date: Sat, 17 Oct 2026 01:59:22 -0700 Subject: [PATCH 6/8] docs: document 4:4:4 chroma subsampling Add a "4:4:4 masters" section to the rendering guide: when to use --chroma-subsampling 444, why it captures PNG, the measured chroma gain (large for text and UI, none for footage and gradients), its limits, and a player compatibility table to be filled in from testing. Document the flag in the CLI reference, the built-in rendering docs and the hyperframes-cli skill reference (with the regenerated skills manifest), and the chromaSubsampling option in the producer docs. Refs #5555 Co-Authored-By: Claude Opus 5.5 --- docs/guides/rendering.mdx | 36 +++++++++++++++++++ docs/packages/cli.mdx | 1 + docs/packages/producer.mdx | 6 ++++ packages/cli/src/docs/rendering.md | 2 ++ packages/producer/README.md | 1 + skills-manifest.json | 2 +- .../references/preview-render.md | 1 + 7 files changed, 48 insertions(+), 1 deletion(-) diff --git a/docs/guides/rendering.mdx b/docs/guides/rendering.mdx index d81cb7e2392..bf80f56f3d3 100644 --- a/docs/guides/rendering.mdx +++ b/docs/guides/rendering.mdx @@ -150,6 +150,42 @@ 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. Expect a slower capture than the default render. + +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 (Windows) | _pending_ | +| Edge (Windows) | _pending_ | +| Firefox (Windows) | _pending_ | +| VLC (Windows) | _pending_ | +| Windows Media Player | _pending_ | +| Safari (iOS) | _pending_ | +| Safari (macOS) | _pending_ | +| QuickTime (macOS) | _pending_ | + ## Local or Docker Local rendering is the normal choice: diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index f2f62e7ff97..ba5ea40c819 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -1240,6 +1240,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/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/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/skills-manifest.json b/skills-manifest.json index ec3874ae536..887571f5b0d 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -30,7 +30,7 @@ "files": 8 }, "hyperframes-cli": { - "hash": "3c8d1fac9e0a02fe", + "hash": "30182a24908ffd71", "files": 11 }, "hyperframes-core": { diff --git a/skills/hyperframes-cli/references/preview-render.md b/skills/hyperframes-cli/references/preview-render.md index b665c04e61b..62c35b4269b 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) | From 8ea96b42d04497b21437e1e7d191a61fb6134aaf Mon Sep 17 00:00:00 2001 From: PaprikaChen Date: Sat, 17 Oct 2026 19:46:15 -0700 Subject: [PATCH 7/8] docs: add 4:4:4 player compatibility results Fill the 4:4:4 compatibility table from testing H.264 High 4:4:4 Predictive output. Chrome 155, Edge 155, VLC 3.0, Safari on iOS 26.6 and Safari 26.6 on macOS play it, and so does AVFoundation on macOS 26.6, the framework QuickTime Player plays through. Firefox 157 on Windows and both Windows media players do not. Refs #5555 Co-Authored-By: Claude Opus 5.5 --- docs/guides/rendering.mdx | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/docs/guides/rendering.mdx b/docs/guides/rendering.mdx index bf80f56f3d3..908a51853ba 100644 --- a/docs/guides/rendering.mdx +++ b/docs/guides/rendering.mdx @@ -177,14 +177,17 @@ not for files you publish directly: | Player | 4:4:4 MP4 | | ------ | --------- | -| Chrome (Windows) | _pending_ | -| Edge (Windows) | _pending_ | -| Firefox (Windows) | _pending_ | -| VLC (Windows) | _pending_ | -| Windows Media Player | _pending_ | -| Safari (iOS) | _pending_ | -| Safari (macOS) | _pending_ | -| QuickTime (macOS) | _pending_ | +| 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 From c54da6e048e3701233fd28778fb28032ee3485bc Mon Sep 17 00:00:00 2001 From: PaprikaChen Date: Sat, 17 Oct 2026 20:02:15 -0700 Subject: [PATCH 8/8] docs: note the measured render cost of 4:4:4 output Replace the vague "slower capture" note with measured numbers: a text and UI composition rendered in about twice the default time, full-frame footage in about three and a half times, because PNG encoding cost grows with image detail. Refs #5555 Co-Authored-By: Claude Opus 5.5 --- docs/guides/rendering.mdx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/guides/rendering.mdx b/docs/guides/rendering.mdx index 908a51853ba..f88e077760f 100644 --- a/docs/guides/rendering.mdx +++ b/docs/guides/rendering.mdx @@ -162,7 +162,10 @@ 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. Expect a slower capture than the default render. +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