From d8a4808c3e705c1186efea1c2e03cdb93b5afc13 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 27 Apr 2026 22:20:53 -0700 Subject: [PATCH 1/2] feat(render): auto-detect HDR from media probes, add --sdr flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the --hdr opt-in model with automatic detection. When no flags are passed, the renderer probes all video/image sources and enables HDR output if any HDR color space is detected. Existing --hdr flag becomes a force override. New --sdr flag forces SDR output. Behavior matrix: (no flags) + HDR content → HDR output (no flags) + SDR content → SDR output --hdr → force HDR (defaults to HLG if no HDR sources) --sdr → force SDR (skips probing) --hdr --sdr → error Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/commands/render.ts | 23 +++++++--- packages/cli/src/utils/dockerRunArgs.test.ts | 24 ++++++++--- packages/cli/src/utils/dockerRunArgs.ts | 5 ++- packages/producer/src/regression-harness.ts | 2 +- .../src/services/renderOrchestrator.ts | 43 +++++++++++++------ 5 files changed, 69 insertions(+), 28 deletions(-) diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index f91954823..63ae5b1c0 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -82,7 +82,12 @@ export default defineCommand({ }, hdr: { type: "boolean", - description: "Enable HDR: probe sources for PQ/HLG, output H.265 10-bit BT.2020", + description: "Force HDR output even if no HDR sources are detected", + default: false, + }, + sdr: { + type: "boolean", + description: "Force SDR output even if HDR sources are detected", default: false, }, crf: { @@ -293,6 +298,12 @@ export default defineCommand({ } } + // ── Validate HDR/SDR mutual exclusion ──────────────────────────────── + if (args.hdr && args.sdr) { + console.error("Error: --hdr and --sdr are mutually exclusive."); + process.exit(1); + } + // ── Render ──────────────────────────────────────────────────────────── if (useDocker) { await renderDocker(project.dir, outputPath, { @@ -301,7 +312,7 @@ export default defineCommand({ format, workers, gpu: useGpu, - hdr: args.hdr ?? false, + hdrMode: args.sdr ? "force-sdr" : args.hdr ? "force-hdr" : "auto", crf, videoBitrate, quiet, @@ -313,7 +324,7 @@ export default defineCommand({ format, workers, gpu: useGpu, - hdr: args.hdr ?? false, + hdrMode: args.sdr ? "force-sdr" : args.hdr ? "force-hdr" : "auto", crf, videoBitrate, quiet, @@ -329,7 +340,7 @@ interface RenderOptions { format: "mp4" | "webm" | "mov"; workers?: number; gpu: boolean; - hdr: boolean; + hdrMode: "auto" | "force-hdr" | "force-sdr"; crf?: number; videoBitrate?: string; quiet: boolean; @@ -453,7 +464,7 @@ async function renderDocker( format: options.format, workers: options.workers, gpu: options.gpu, - hdr: options.hdr, + hdrMode: options.hdrMode, crf: options.crf, videoBitrate: options.videoBitrate, quiet: options.quiet, @@ -519,7 +530,7 @@ async function renderLocal( format: options.format, workers: options.workers, useGpu: options.gpu, - hdr: options.hdr, + hdrMode: options.hdrMode, crf: options.crf, videoBitrate: options.videoBitrate, }); diff --git a/packages/cli/src/utils/dockerRunArgs.test.ts b/packages/cli/src/utils/dockerRunArgs.test.ts index 42a4c416a..9fb8b0da9 100644 --- a/packages/cli/src/utils/dockerRunArgs.test.ts +++ b/packages/cli/src/utils/dockerRunArgs.test.ts @@ -6,7 +6,7 @@ const BASE: DockerRenderOptions = { quality: "standard", format: "mp4", gpu: false, - hdr: false, + hdrMode: "auto", crf: undefined, videoBitrate: undefined, quiet: false, @@ -57,9 +57,8 @@ describe("buildDockerRunArgs", () => { ...FIXED_INPUT, options: { ...BASE, - workers: 4, gpu: true, - hdr: true, + hdrMode: "force-hdr", crf: 18, videoBitrate: undefined, quiet: true, @@ -102,17 +101,28 @@ describe("buildDockerRunArgs", () => { // Regression for the original PR feedback: --hdr was silently dropped from // the docker arg array. Keep this assertion explicit (in addition to the // snapshot above) so the failure message points directly at the flag. - it("forwards --hdr to the container when hdr is enabled", () => { + it("forwards --hdr to the container when hdrMode is force-hdr", () => { const args = buildDockerRunArgs({ ...FIXED_INPUT, - options: { ...BASE, hdr: true }, + options: { ...BASE, hdrMode: "force-hdr" }, }); expect(args).toContain("--hdr"); + expect(args).not.toContain("--sdr"); }); - it("omits --hdr when hdr is disabled", () => { + it("forwards --sdr to the container when hdrMode is force-sdr", () => { + const args = buildDockerRunArgs({ + ...FIXED_INPUT, + options: { ...BASE, hdrMode: "force-sdr" }, + }); + expect(args).toContain("--sdr"); + expect(args).not.toContain("--hdr"); + }); + + it("omits --hdr and --sdr when hdrMode is auto", () => { const args = buildDockerRunArgs({ ...FIXED_INPUT, options: BASE }); expect(args).not.toContain("--hdr"); + expect(args).not.toContain("--sdr"); }); it("requests host GPU passthrough only when gpu is enabled", () => { @@ -140,7 +150,7 @@ describe("buildDockerRunArgs", () => { format: "webm", workers: 8, gpu: true, - hdr: true, + hdrMode: "force-hdr", crf: 16, videoBitrate: undefined, quiet: true, diff --git a/packages/cli/src/utils/dockerRunArgs.ts b/packages/cli/src/utils/dockerRunArgs.ts index 6ec926103..9df1de9f6 100644 --- a/packages/cli/src/utils/dockerRunArgs.ts +++ b/packages/cli/src/utils/dockerRunArgs.ts @@ -24,7 +24,7 @@ export interface DockerRenderOptions { format: "mp4" | "webm" | "mov"; workers?: number; gpu: boolean; - hdr: boolean; + hdrMode: "auto" | "force-hdr" | "force-sdr"; crf?: number; videoBitrate?: string; quiet: boolean; @@ -59,6 +59,7 @@ export function buildDockerRunArgs(input: DockerRunArgsInput): string[] { ...(options.videoBitrate ? ["--video-bitrate", options.videoBitrate] : []), ...(options.quiet ? ["--quiet"] : []), ...(options.gpu ? ["--gpu"] : []), - ...(options.hdr ? ["--hdr"] : []), + ...(options.hdrMode === "force-hdr" ? ["--hdr"] : []), + ...(options.hdrMode === "force-sdr" ? ["--sdr"] : []), ]; } diff --git a/packages/producer/src/regression-harness.ts b/packages/producer/src/regression-harness.ts index 22d5f3062..23d1f827f 100644 --- a/packages/producer/src/regression-harness.ts +++ b/packages/producer/src/regression-harness.ts @@ -600,7 +600,7 @@ async function runTestSuite( workers: suite.meta.renderConfig.workers, useGpu: false, debug: false, - hdr: suite.meta.renderConfig.hdr ?? false, + hdrMode: suite.meta.renderConfig.hdr ? "force-hdr" : "auto", }); await executeRenderJob(job, tempSrcDir, renderedOutputPath); diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index d6cc3300a..782ea257d 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -256,8 +256,8 @@ export interface RenderConfig { crf?: number; /** Target video bitrate (e.g. "10M"). Mutually exclusive with `crf`. */ videoBitrate?: string; - /** Enable HDR color space probing on video/image sources. */ - hdr?: boolean; + /** HDR rendering mode. `auto` probes sources and enables HDR if any HDR content is found. */ + hdrMode?: "auto" | "force-hdr" | "force-sdr"; } export interface RenderPerfSummary { @@ -1831,7 +1831,7 @@ export async function executeRenderJob( // overhead on SDR-only compositions. const nativeHdrVideoIds = new Set(); const videoTransfers = new Map(); - if (job.config.hdr && composition.videos.length > 0) { + if (job.config.hdrMode !== "force-sdr" && composition.videos.length > 0) { await Promise.all( composition.videos.map(async (v) => { let videoPath = v.src; @@ -1858,7 +1858,7 @@ export async function executeRenderJob( const imageTransfers = new Map(); const hdrImageSrcPaths = new Map(); const imageColorSpaces: (VideoColorSpace | null)[] = []; - if (job.config.hdr && composition.images.length > 0) { + if (job.config.hdrMode !== "force-sdr" && composition.images.length > 0) { const probed = await Promise.all( composition.images.map(async (img) => { let imgPath = img.src; @@ -1927,14 +1927,24 @@ export async function executeRenderJob( // dominant transfer (PQ if any PQ source is present, otherwise HLG). // Image-only compositions can trigger HDR output without any video. let effectiveHdr: { transfer: HdrTransfer } | undefined; - if (job.config.hdr) { + { + const hdrMode = job.config.hdrMode ?? "auto"; const videoColorSpaces = (extractionResult?.extracted ?? []).map( (ext) => ext.metadata.colorSpace, ); const allColorSpaces = [...videoColorSpaces, ...imageColorSpaces]; - if (allColorSpaces.length > 0) { - const info = analyzeCompositionHdr(allColorSpaces); - if (info.hasHdr && info.dominantTransfer) { + const info = allColorSpaces.length > 0 ? analyzeCompositionHdr(allColorSpaces) : null; + + if (hdrMode === "force-sdr") { + effectiveHdr = undefined; + } else if (hdrMode === "force-hdr") { + if (info?.hasHdr && info.dominantTransfer) { + effectiveHdr = { transfer: info.dominantTransfer }; + } else { + effectiveHdr = { transfer: "hlg" }; + } + } else { + if (info?.hasHdr && info.dominantTransfer) { effectiveHdr = { transfer: info.dominantTransfer }; } } @@ -1946,10 +1956,19 @@ export async function executeRenderJob( ); effectiveHdr = undefined; } - if (effectiveHdr) { - log.info( - `[Render] HDR source detected — output: ${effectiveHdr.transfer.toUpperCase()} (BT.2020, 10-bit H.265)`, - ); + { + const hdrMode = job.config.hdrMode ?? "auto"; + if (effectiveHdr) { + const reason = + hdrMode === "force-hdr" ? "forced by --hdr flag" : "auto-detected from source(s)"; + log.info( + `[Render] HDR ${reason} — output: ${effectiveHdr.transfer.toUpperCase()} (BT.2020, 10-bit H.265)`, + ); + } else if (hdrMode === "force-sdr") { + log.info("[Render] SDR forced by --sdr flag"); + } else { + log.info("[Render] No HDR sources detected — rendering SDR"); + } } // ── Stage 3: Audio processing ─────────────────────────────────────── From bba0f7545578b8ef01b51f5e66448c875c9b4dcd Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 27 Apr 2026 23:09:55 -0700 Subject: [PATCH 2/2] fix: align HDR auto-detect docs and tests --- docs/guides/common-mistakes.mdx | 12 +++-- docs/guides/hdr.mdx | 30 ++++++------ docs/guides/rendering.mdx | 3 +- docs/packages/cli.mdx | 3 +- docs/packages/engine.mdx | 4 +- packages/cli/src/commands/render.ts | 2 +- packages/cli/src/registry/remote.ts | 11 +++-- packages/cli/src/utils/dockerRunArgs.test.ts | 2 - packages/producer/src/regression-harness.ts | 4 +- .../src/services/renderOrchestrator.ts | 47 ++++++++++++++----- 10 files changed, 74 insertions(+), 44 deletions(-) diff --git a/docs/guides/common-mistakes.mdx b/docs/guides/common-mistakes.mdx index 4872c295e..015c3e619 100644 --- a/docs/guides/common-mistakes.mdx +++ b/docs/guides/common-mistakes.mdx @@ -190,11 +190,11 @@ These are mistakes that cannot be caught by the linter. For automated checks, ru - **Symptom:** Rendered with `--hdr`, but the output looks the same as SDR or `ffprobe` reports `color_transfer=bt709`. + **Symptom:** Expected an HDR render, but the output looks the same as SDR or `ffprobe` reports `color_transfer=bt709`. - **Cause:** `--hdr` is a *detection* flag, not a *force* flag. Hyperframes only switches to HDR encoding when a source ` diff --git a/docs/guides/hdr.mdx b/docs/guides/hdr.mdx index 5774cfae6..bd079461a 100644 --- a/docs/guides/hdr.mdx +++ b/docs/guides/hdr.mdx @@ -3,10 +3,10 @@ title: HDR Rendering description: "Render compositions to HDR10 MP4 (BT.2020 PQ or HLG, 10-bit H.265) when sources contain HDR video or images." --- -Hyperframes can render to HDR10 MP4 (H.265 10-bit, BT.2020) when your composition references HDR video or HDR still images. HDR is opt-in via the `--hdr` flag — it auto-detects HDR sources and falls back to SDR when none are present. +Hyperframes can render to HDR10 MP4 (H.265 10-bit, BT.2020) when your composition references HDR video or HDR still images. HDR is auto-detected by default from your media sources and falls back to SDR when none are present. - The `--hdr` flag does not *force* HDR. It enables HDR detection. If your composition contains only SDR media, the flag is a no-op and you get a normal SDR render. + By default, Hyperframes probes your media and enables HDR only when HDR sources are present. Use `--hdr` to force HDR even without HDR sources, or `--sdr` to force SDR even when HDR sources are present. ## Quickstart @@ -20,12 +20,12 @@ Hyperframes can render to HDR10 MP4 (H.265 10-bit, BT.2020) when your compositio See [Source Media](#source-media-requirements) for full details. - + ```bash Terminal - npx hyperframes render --hdr --output output.mp4 + npx hyperframes render --output output.mp4 ``` - HDR output requires `--format mp4`. If you also pass `--format mov` or `--format webm`, Hyperframes logs a warning and falls back to SDR. + HDR output requires `--format mp4`. If Hyperframes detects HDR sources, it renders HDR automatically. If you also pass `--format mov` or `--format webm`, Hyperframes logs a warning and falls back to SDR. Use `ffprobe` to confirm the encoded stream carries HDR color tagging and HDR10 metadata: @@ -40,14 +40,14 @@ Hyperframes can render to HDR10 MP4 (H.265 10-bit, BT.2020) when your compositio ## How HDR Mode Works -When `--hdr` is set, the producer: +During render, the producer: - Runs `ffprobe` on each ` - If any source uses PQ (`smpte2084`), the output uses **PQ**. Otherwise, if any source uses HLG (`arib-std-b67`), the output uses **HLG**. If no HDR sources are found, the flag is a no-op and you get an SDR render. + If any source uses PQ (`smpte2084`), the output uses **PQ**. Otherwise, if any source uses HLG (`arib-std-b67`), the output uses **HLG**. If no HDR sources are found, the render stays SDR. The video encoder switches to `libx265` with `-pix_fmt yuv420p10le`, color tagging `colorprim=bt2020:transfer=:colormatrix=bt2020nc`, and HDR10 static metadata (`master-display` and `max-cll`). Without that metadata, players (QuickTime, YouTube, HDR TVs) tone-map the stream as if it were SDR BT.2020 — which looks wrong. @@ -89,7 +89,7 @@ Hyperframes supports HDR still images delivered as **16-bit PNGs** tagged with B src="./assets/hdr-photo.png" /> ``` -When `--hdr` is set, the image is decoded once to 16-bit linear-light RGB and composited natively into the HDR output. +When HDR is enabled, the image is decoded once to 16-bit linear-light RGB and composited natively into the HDR output. HDR `` decoding is limited to **16-bit PNG**. JPEG, WebP, AVIF, and APNG are not recognized as HDR sources — they load through the normal SDR DOM path. For HDR motion, use a `