mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(render): auto-detect HDR from media probes, add --sdr flag (#526)
* feat(render): auto-detect HDR from media probes, add --sdr flag 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) <noreply@anthropic.com> * fix: align HDR auto-detect docs and tests --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
4d0e262eb3
commit
8e5593b6ba
@@ -190,11 +190,11 @@ These are mistakes that cannot be caught by the linter. For automated checks, ru
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Expected HDR output but got SDR">
|
||||
**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 `<video>` or `<img>` is tagged with BT.2020 / PQ / HLG color metadata. Two common reasons HDR is not engaged:
|
||||
**Cause:** By default, Hyperframes only switches to HDR encoding when a source `<video>` or `<img>` is tagged with BT.2020 / PQ / HLG color metadata. Common reasons HDR is not engaged:
|
||||
|
||||
1. **All sources are SDR.** `--hdr` is a no-op on SDR-only compositions. Verify with `ffprobe`:
|
||||
1. **All sources are SDR.** Auto-detect leaves SDR-only compositions in SDR. Verify with `ffprobe`:
|
||||
|
||||
```bash Terminal
|
||||
ffprobe -v error -show_streams source.mp4 | grep color_transfer
|
||||
@@ -204,7 +204,11 @@ These are mistakes that cannot be caught by the linter. For automated checks, ru
|
||||
|
||||
2. **Wrong output format.** HDR output requires MP4. `--format mov` and `--format webm` fall back to SDR — Hyperframes logs a warning when this happens.
|
||||
|
||||
`--docker` works the same as local rendering — `--hdr` is forwarded into the container and produces the same HDR10 MP4 output (slower, since the container falls back to software WebGL for SDR DOM capture).
|
||||
3. **SDR was forced.** `--sdr` disables HDR even when HDR sources are present.
|
||||
|
||||
If you need HDR regardless of source metadata, use `--hdr` to force it.
|
||||
|
||||
`--docker` works the same as local rendering — auto-detect, `--hdr`, and `--sdr` are all forwarded into the container and produce the same output decisions (slower, since the container falls back to software WebGL for SDR DOM capture).
|
||||
|
||||
See [HDR Rendering](/guides/hdr) for the full source requirements and verification steps.
|
||||
</Accordion>
|
||||
|
||||
+15
-15
@@ -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.
|
||||
|
||||
<Note>
|
||||
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.
|
||||
</Note>
|
||||
|
||||
## 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.
|
||||
</Step>
|
||||
<Step title="Render with --hdr">
|
||||
<Step title="Render normally">
|
||||
```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.
|
||||
</Step>
|
||||
<Step title="Verify the output is HDR">
|
||||
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:
|
||||
|
||||
<Steps>
|
||||
<Step title="Probes every video and image source">
|
||||
Runs `ffprobe` on each `<video>` and `<img>` source to read its color space (primaries, transfer function, matrix). Probing is gated on `--hdr` to avoid `ffprobe` overhead on SDR-only renders.
|
||||
Runs `ffprobe` on each `<video>` and `<img>` source to read its color space (primaries, transfer function, matrix). This probe drives the default auto-detect behavior and is skipped only when you explicitly force SDR with `--sdr`.
|
||||
</Step>
|
||||
<Step title="Picks the dominant HDR transfer">
|
||||
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.
|
||||
</Step>
|
||||
<Step title="Encodes to H.265 10-bit BT.2020">
|
||||
The video encoder switches to `libx265` with `-pix_fmt yuv420p10le`, color tagging `colorprim=bt2020:transfer=<smpte2084|arib-std-b67>: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.
|
||||
|
||||
<Note>
|
||||
HDR `<img>` 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 `<video>` element.
|
||||
@@ -113,7 +113,7 @@ This is the same pipeline that handles compositions where, for example, an HDR d
|
||||
| `mov` | No — falls back to SDR |
|
||||
| `webm` | No — falls back to SDR |
|
||||
|
||||
If you set `--hdr` together with `--format mov` or `--format webm`, Hyperframes logs a message and produces the equivalent SDR render. There is no error — the render still completes — so check the logs (or your verification step) to confirm you got HDR.
|
||||
If HDR is enabled and you also pass `--format mov` or `--format webm`, Hyperframes logs a message and produces the equivalent SDR render. There is no error — the render still completes — so check the logs (or your verification step) to confirm you got HDR.
|
||||
|
||||
## Verifying HDR Output
|
||||
|
||||
@@ -147,10 +147,10 @@ For HLG renders the only difference is `color_transfer=arib-std-b67` — the res
|
||||
|
||||
## Docker Rendering
|
||||
|
||||
`--hdr` is forwarded into the Docker render pipeline, so you can produce HDR10 MP4 output from the containerized renderer:
|
||||
Docker uses the same auto-detect logic as local rendering, so you can produce HDR10 MP4 output from the containerized renderer without extra flags:
|
||||
|
||||
```bash Terminal
|
||||
npx hyperframes render --hdr --docker --output output.mp4
|
||||
npx hyperframes render --docker --output output.mp4
|
||||
```
|
||||
|
||||
The container runs the same probe → composite → encode pipeline as the local renderer. Verify the output with the same `ffprobe` checks described in [Verifying HDR output](#verifying-hdr-output).
|
||||
@@ -161,7 +161,7 @@ The container runs the same probe → composite → encode pipeline as the local
|
||||
|
||||
## Limitations
|
||||
|
||||
- **MP4 only** — `--hdr` with `--format mov` or `--format webm` falls back to SDR
|
||||
- **MP4 only** — HDR output with `--format mov` or `--format webm` falls back to SDR
|
||||
- **HDR images: 16-bit PNG only** — other formats (JPEG, WebP, AVIF, APNG) are not decoded as HDR and fall through the SDR DOM path
|
||||
- **H.265 only — H.264 is stripped** — calling the encoder with `codec: "h264"` and `hdr: { transfer }` is rejected; the encoder logs a warning, drops `hdr`, and tags the output as SDR/BT.709. `libx264` cannot encode HDR, so the alternative would be a "half-HDR" file (BT.2020 container tags but a BT.709 VUI block in the bitstream) which confuses HDR-aware players.
|
||||
- **GPU H.265 emits color tags but no static mastering metadata** — `useGpu: true` with HDR (nvenc, videotoolbox, qsv, vaapi) tags the stream with BT.2020 + the correct transfer (smpte2084 / arib-std-b67) but does **not** embed `master-display` or `max-cll` SEI. ffmpeg does not let those flags pass through hardware encoders. The output is suitable for previews and authoring but not for HDR10-aware delivery (Apple TV, YouTube, Netflix). For spec-compliant HDR10 production output, leave `useGpu: false` so the SW `libx265` path embeds the mastering metadata.
|
||||
@@ -172,7 +172,7 @@ The container runs the same probe → composite → encode pipeline as the local
|
||||
|
||||
| Symptom | Likely cause |
|
||||
|---------|--------------|
|
||||
| Output looks identical to SDR | Source media is SDR — `--hdr` is a no-op without an HDR source. Run `ffprobe` on your inputs |
|
||||
| Output looks identical to SDR | Source media is SDR, or SDR was forced with `--sdr`. Run `ffprobe` on your inputs and check the render logs |
|
||||
| Output is "kind of HDR" but tone-mapped wrong on YouTube/QuickTime | Missing HDR10 static metadata on the encoded stream. Verify with the ffprobe snippet above |
|
||||
| Docker render is much slower than local | Expected — the container falls back to software WebGL for SDR DOM capture. Pixel output is the same |
|
||||
| Used `--format webm` and got SDR | Expected — HDR output is MP4 only |
|
||||
@@ -185,7 +185,7 @@ The container runs the same probe → composite → encode pipeline as the local
|
||||
Local vs Docker, quality presets, workers
|
||||
</Card>
|
||||
<Card title="CLI" icon="terminal" href="/packages/cli">
|
||||
Full `render` command reference including `--hdr`
|
||||
Full `render` command reference including HDR auto-detect, `--hdr`, and `--sdr`
|
||||
</Card>
|
||||
<Card title="Engine: HDR APIs" icon="gear" href="/packages/engine#hdr-apis">
|
||||
Public HDR utilities exported from `@hyperframes/engine`
|
||||
|
||||
@@ -122,7 +122,8 @@ Render your Hyperframes [compositions](/concepts/compositions) to MP4, MOV, or W
|
||||
| `--workers` | 1-8 or `auto` | auto | Parallel render workers (see [Workers](#workers) below) |
|
||||
| `--max-concurrent-renders` | 1-10 | 2 | Max simultaneous renders via the producer server (see [Concurrent Renders](#concurrent-renders) below) |
|
||||
| `--gpu` | — | off | GPU encoding (NVENC, VideoToolbox, VAAPI) |
|
||||
| `--hdr` | — | off | Detect HDR sources and output HDR10 (MP4 only). See [HDR Rendering](/guides/hdr) |
|
||||
| `--hdr` | — | off | Force HDR output even if no HDR sources are detected (MP4 only). See [HDR Rendering](/guides/hdr) |
|
||||
| `--sdr` | — | off | Force SDR output even if HDR sources are detected |
|
||||
| `--docker` | — | off | Use Docker for [deterministic rendering](/concepts/determinism) |
|
||||
| `--quiet` | — | off | Suppress verbose output |
|
||||
|
||||
|
||||
@@ -540,7 +540,8 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
|
||||
| `--quality` | draft, standard, high | standard | Encoding quality preset (drives CRF/bitrate) |
|
||||
| `--crf` | 0-51 | — | Override encoder CRF (lower = higher quality). Mutually exclusive with `--video-bitrate` |
|
||||
| `--video-bitrate` | e.g. `10M`, `5000k` | — | Target video bitrate. Mutually exclusive with `--crf` |
|
||||
| `--hdr` | — | off | Detect HDR sources and output HDR10 (H.265 10-bit, BT.2020 PQ/HLG). MP4 only. SDR-only compositions are unaffected. See [HDR Rendering](/guides/hdr) |
|
||||
| `--hdr` | — | off | Force HDR output even if no HDR sources are detected. MP4 only. See [HDR Rendering](/guides/hdr) |
|
||||
| `--sdr` | — | off | Force SDR output even if HDR sources are detected |
|
||||
| `--workers` | 1-8 | 4 | Parallel render workers |
|
||||
| `--gpu` | — | off | GPU encoding (NVENC, VideoToolbox, VAAPI) |
|
||||
| `--docker` | — | off | Use Docker for [deterministic rendering](/concepts/determinism) |
|
||||
|
||||
@@ -282,7 +282,7 @@ await server.close();
|
||||
|
||||
The engine exports two layers of HDR support: **color-space utilities** that classify sources and configure the FFmpeg encoder, and a **WebGPU readback runtime** for capturing CSS-animated DOM directly into HDR.
|
||||
|
||||
For end-to-end HDR rendering (HDR video and image sources composited into an HDR10 MP4) use the [producer](/packages/producer) or the CLI's `--hdr` flag — see [HDR Rendering](/guides/hdr). The APIs below are for custom integrations.
|
||||
For end-to-end HDR rendering (HDR video and image sources composited into an HDR10 MP4) use the [producer](/packages/producer) or the CLI render pipeline with HDR auto-detect / `--hdr` / `--sdr` — see [HDR Rendering](/guides/hdr). The APIs below are for custom integrations.
|
||||
|
||||
### Color space utilities
|
||||
|
||||
@@ -344,7 +344,7 @@ const pqRgb = float16ToPqRgb(rgba16, width, height, bytesPerRow);
|
||||
```
|
||||
|
||||
<Warning>
|
||||
This path requires **headed Chrome with `--enable-unsafe-webgpu`** — WebGPU is unavailable in `chrome-headless-shell`. It is *not* used by the default `--hdr` render pipeline (which extracts HDR pixels from sources via FFmpeg and composites in Node). Use it only for advanced custom pipelines that need CSS animations driving HDR pixel output.
|
||||
This path requires **headed Chrome with `--enable-unsafe-webgpu`** — WebGPU is unavailable in `chrome-headless-shell`. It is *not* used by the default HDR-aware render pipeline (which extracts HDR pixels from sources via FFmpeg and composites in Node). Use it only for advanced custom pipelines that need CSS animations driving HDR pixel output.
|
||||
</Warning>
|
||||
|
||||
## The `window.__hf` Protocol
|
||||
|
||||
@@ -9,7 +9,7 @@ export const examples: Example[] = [
|
||||
["High quality at 60fps", "hyperframes render --fps 60 --quality high --output hd.mp4"],
|
||||
["Deterministic render via Docker", "hyperframes render --docker --output deterministic.mp4"],
|
||||
["Parallel rendering with 6 workers", "hyperframes render --workers 6 --output fast.mp4"],
|
||||
["HDR output (H.265 10-bit)", "hyperframes render --hdr --output hdr-output.mp4"],
|
||||
["HDR output (auto-detected)", "hyperframes render --output hdr-output.mp4"],
|
||||
];
|
||||
import { cpus, freemem, tmpdir } from "node:os";
|
||||
import { resolve, dirname, join, basename } from "node:path";
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -58,9 +58,14 @@ function readCache<T>(path: string): T | undefined {
|
||||
}
|
||||
|
||||
function writeCache<T>(path: string, data: T): void {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const entry: CacheEntry<T> = { fetchedAt: Date.now(), data };
|
||||
writeFileSync(path, JSON.stringify(entry), "utf-8");
|
||||
try {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const entry: CacheEntry<T> = { fetchedAt: Date.now(), data };
|
||||
writeFileSync(path, JSON.stringify(entry), "utf-8");
|
||||
} catch {
|
||||
// Cache writes are opportunistic. A read-only home directory or sandboxed
|
||||
// environment should not make the registry appear unreachable.
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fetchers ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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,
|
||||
@@ -88,8 +87,6 @@ describe("buildDockerRunArgs", () => {
|
||||
"standard",
|
||||
"--format",
|
||||
"mp4",
|
||||
"--workers",
|
||||
"4",
|
||||
"--crf",
|
||||
"18",
|
||||
"--quiet",
|
||||
@@ -102,17 +99,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 +148,7 @@ describe("buildDockerRunArgs", () => {
|
||||
format: "webm",
|
||||
workers: 8,
|
||||
gpu: true,
|
||||
hdr: true,
|
||||
hdrMode: "force-hdr",
|
||||
crf: 16,
|
||||
videoBitrate: undefined,
|
||||
quiet: true,
|
||||
|
||||
@@ -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"] : []),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ type TestMetadata = {
|
||||
fps: 24 | 30 | 60;
|
||||
format?: "mp4" | "webm"; // Optional: defaults to "mp4"
|
||||
workers?: number; // Optional: auto-calculates if omitted
|
||||
/** Enable HDR color-space probing + HDR10 encode pipeline. */
|
||||
/** Force HDR in the harness; omitted/false preserves historical SDR-only test behavior. */
|
||||
hdr?: boolean;
|
||||
};
|
||||
};
|
||||
@@ -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" : "force-sdr",
|
||||
});
|
||||
|
||||
await executeRenderJob(job, tempSrcDir, renderedOutputPath);
|
||||
|
||||
@@ -213,7 +213,8 @@ export interface RenderConfig {
|
||||
* Output container format. Defaults to `"mp4"`; existing renders are
|
||||
* unaffected unless this field is set explicitly.
|
||||
*
|
||||
* - `"mp4"`: H.264 (or H.265 + HDR10 when `hdr: true`). Opaque. The
|
||||
* - `"mp4"`: H.264 by default, or H.265 + HDR10 when HDR auto-detect
|
||||
* engages or `hdrMode: "force-hdr"` is set. Opaque. The
|
||||
* default streaming/social deliverable. Faststart is applied so the
|
||||
* `moov` atom sits at the file start and the file plays from a
|
||||
* partial download.
|
||||
@@ -256,8 +257,12 @@ 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` (default): probe sources; enable HDR if any HDR content is found.
|
||||
* - `force-hdr`: enable HDR even on SDR-only compositions (falls back to HLG transfer).
|
||||
* - `force-sdr`: skip probing entirely; always render SDR.
|
||||
*/
|
||||
hdrMode?: "auto" | "force-hdr" | "force-sdr";
|
||||
}
|
||||
|
||||
export interface RenderPerfSummary {
|
||||
@@ -1827,11 +1832,11 @@ export async function executeRenderJob(
|
||||
|
||||
// Probe ORIGINAL color spaces before extraction (which may convert SDR→HDR).
|
||||
// This is needed to identify which videos are natively HDR vs converted-SDR
|
||||
// for the two-pass compositing path. Gated by --hdr flag to avoid ffprobe
|
||||
// overhead on SDR-only compositions.
|
||||
// for the two-pass compositing path. Skipped only in force-sdr mode to
|
||||
// avoid ffprobe overhead when the user has explicitly opted out.
|
||||
const nativeHdrVideoIds = new Set<string>();
|
||||
const videoTransfers = new Map<string, HdrTransfer>();
|
||||
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;
|
||||
@@ -1853,12 +1858,14 @@ export async function executeRenderJob(
|
||||
|
||||
// Probe images for HDR color spaces (16-bit PNGs tagged BT.2020 PQ/HLG).
|
||||
// Mirrors the video probe loop above so image-only compositions can
|
||||
// trigger HDR output without any video sources present.
|
||||
// trigger HDR output without any video sources present. Skipped only in
|
||||
// force-sdr mode to avoid ffprobe overhead when the user has explicitly
|
||||
// opted out.
|
||||
const nativeHdrImageIds = new Set<string>();
|
||||
const imageTransfers = new Map<string, HdrTransfer>();
|
||||
const hdrImageSrcPaths = new Map<string, string>();
|
||||
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;
|
||||
@@ -1922,34 +1929,67 @@ export async function executeRenderJob(
|
||||
}
|
||||
|
||||
// ── HDR auto-detection ──────────────────────────────────────────────
|
||||
// When --hdr is set, analyze probed video AND image color spaces.
|
||||
// If any HDR sources are found, output uses H.265 10-bit with the
|
||||
// dominant transfer (PQ if any PQ source is present, otherwise HLG).
|
||||
// Image-only compositions can trigger HDR output without any video.
|
||||
// Analyze probed video AND image color spaces. In auto mode, any HDR
|
||||
// source enables HDR output. force-hdr always enables HDR, and force-sdr
|
||||
// always disables it. Image-only compositions can trigger HDR output
|
||||
// without any video.
|
||||
let effectiveHdr: { transfer: HdrTransfer } | undefined;
|
||||
if (job.config.hdr) {
|
||||
let forcedHdrWithoutSources = false;
|
||||
{
|
||||
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" };
|
||||
forcedHdrWithoutSources = true;
|
||||
}
|
||||
} else {
|
||||
if (info?.hasHdr && info.dominantTransfer) {
|
||||
effectiveHdr = { transfer: info.dominantTransfer };
|
||||
}
|
||||
}
|
||||
}
|
||||
if (effectiveHdr && outputFormat !== "mp4") {
|
||||
const hdrSourceReason = forcedHdrWithoutSources
|
||||
? "HDR was forced without detected HDR sources"
|
||||
: "HDR source detected";
|
||||
log.warn(
|
||||
`[Render] HDR source detected but format is "${outputFormat}" — falling back to SDR. ` +
|
||||
`[Render] ${hdrSourceReason}, but format is "${outputFormat}" — falling back to SDR. ` +
|
||||
`HDR + alpha is not supported. Use --format mp4 for HDR10 output.`,
|
||||
);
|
||||
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 (forcedHdrWithoutSources) {
|
||||
log.warn(
|
||||
"[Render] HDR forced by --hdr flag, but no HDR sources were detected — defaulting to HLG. SDR-only compositions may look perceptually wrong on HDR displays.",
|
||||
);
|
||||
}
|
||||
if (effectiveHdr) {
|
||||
const reason =
|
||||
hdrMode === "force-hdr"
|
||||
? forcedHdrWithoutSources
|
||||
? "forced by --hdr flag (no HDR sources detected — defaulting to HLG)"
|
||||
: "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 ───────────────────────────────────────
|
||||
@@ -2139,8 +2179,8 @@ export async function executeRenderJob(
|
||||
const videoOnlyPath = join(workDir, `video-only${videoExt}`);
|
||||
// Only use the HDR encoder preset when there's HDR content to pass through —
|
||||
// either native HDR videos OR native HDR images. For SDR-only compositions,
|
||||
// --hdr is a no-op since H.265 10-bit causes browser color management issues
|
||||
// (orange shift) with no quality benefit.
|
||||
// auto mode stays SDR since H.265 10-bit causes browser color management
|
||||
// issues (orange shift) with no quality benefit.
|
||||
const nativeHdrIds = new Set([...nativeHdrVideoIds, ...nativeHdrImageIds]);
|
||||
const hasHdrContent = effectiveHdr && nativeHdrIds.size > 0;
|
||||
const encoderHdr = hasHdrContent ? effectiveHdr : undefined;
|
||||
|
||||
Reference in New Issue
Block a user