mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 23:29:50 +00:00
feat(engine): wire options.hdr through chunkEncoder + dynamic SDR→HDR transfer (#370)
## Summary
Three independent fixes that share a common thread: HDR config flowing correctly from `EngineConfig` down through every encoder. The headline fix: disk-based HDR encodes via `chunkEncoder` were silently producing BT.709-tagged output despite `options.hdr` being set.
## Why
`Chunk 3` of `plans/hdr-followups.md`. The streaming encoder was correct but `chunkEncoder.buildEncoderArgs` hard-coded BT.709 color tags and the `bt709` VUI block in `-x265-params`, even when callers passed an HDR `EncoderOptions`. Today this is harmless because `renderOrchestrator` routes native-HDR content to `streamingEncoder` and only feeds `chunkEncoder` sRGB Chrome screenshots — but the contract was a lie, and any future caller that wired HDR through `chunkEncoder` would silently get SDR output.
## What changed
**3A — `chunkEncoder` respects `options.hdr` (BT.2020 + mastering metadata).** When `options.hdr` is set, the libx265 software path emits `bt2020nc` plus the matching transfer (`smpte2084` for PQ, `arib-std-b67` for HLG) at the codec level *and* embeds master-display + max-cll SEI in `-x265-params` via `getHdrEncoderColorParams`. libx264 still tags BT.709 inside `-x264-params` (libx264 has no HDR support) but the codec-level color flags flip so the container describes pixels truthfully. GPU H.265 (nvenc/videotoolbox/qsv/vaapi) gets the BT.2020 tags but no `-x265-params` block, so static mastering metadata is omitted — acceptable for previews, not HDR-aware delivery.
**3B — `convertSdrToHdr` accepts a target transfer.** `videoFrameExtractor.convertSdrToHdr` was hard-coded to `transfer=arib-std-b67` (HLG) regardless of the surrounding composition's dominant transfer. `extractAllVideoFrames` now calls `analyzeCompositionHdr` first, then passes the dominant transfer (`"pq"` or `"hlg"`) into `convertSdrToHdr` so an SDR clip mixed into a PQ timeline gets converted with `smpte2084`, not `arib-std-b67`.
**3C — `EngineConfig.hdr` type matches its declared shape.** The IIFE for the `hdr` field returned `undefined` when `PRODUCER_HDR_TRANSFER` wasn't `"hlg"` or `"pq"`, but the field is typed as `{ transfer: HdrTransfer } | false`. Returning `false` matches the type and avoids a downstream `undefined` check.
## Test plan
- [x] `chunkEncoder.test.ts`: replaced the previous "HDR options ignored" assertions with 8 new specs covering BT.2020 + transfer tagging, master-display/max-cll embedding, libx264 fallback behavior, GPU H.265 + HDR (tags but no x265-params), and range conversion for both SDR and HDR CPU paths.
- [x] All 313 engine unit tests pass (5 new HDR specs).
- [x] `ffprobe` an HDR composition rendered through the chunk encoder path: shows `bt2020nc` color matrix, `smpte2084` transfer, and mastering display metadata.
## Stack
Chunk 3 of `plans/hdr-followups.md`. Independent of Chunks 1/4 (touches separate code paths).
This commit is contained in:
@@ -163,6 +163,8 @@ The container runs the same probe → composite → encode pipeline as the local
|
|||||||
|
|
||||||
- **MP4 only** — `--hdr` with `--format mov` or `--format webm` falls back to SDR
|
- **MP4 only** — `--hdr` 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
|
- **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.
|
||||||
- **Player support** — the [`<hyperframes-player>`](/packages/player) web component plays back the encoded MP4 in the browser and inherits whatever HDR support the host browser provides; it does not implement its own HDR pipeline
|
- **Player support** — the [`<hyperframes-player>`](/packages/player) web component plays back the encoded MP4 in the browser and inherits whatever HDR support the host browser provides; it does not implement its own HDR pipeline
|
||||||
- **Headed Chrome HDR DOM capture** — the engine ships a separate WebGPU-based capture path for rendering CSS-animated DOM directly into HDR (`initHdrReadback`, `launchHdrBrowser`). It requires headed Chrome with `--enable-unsafe-webgpu` and is not used by the default render pipeline. See [Engine: HDR](/packages/engine#hdr-apis) if you are building a custom integration.
|
- **Headed Chrome HDR DOM capture** — the engine ships a separate WebGPU-based capture path for rendering CSS-animated DOM directly into HDR (`initHdrReadback`, `launchHdrBrowser`). It requires headed Chrome with `--enable-unsafe-webgpu` and is not used by the default render pipeline. See [Engine: HDR](/packages/engine#hdr-apis) if you are building a custom integration.
|
||||||
|
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ export function resolveConfig(overrides?: Partial<EngineConfig>): EngineConfig {
|
|||||||
hdr: (() => {
|
hdr: (() => {
|
||||||
const raw = env("PRODUCER_HDR_TRANSFER");
|
const raw = env("PRODUCER_HDR_TRANSFER");
|
||||||
if (raw === "hlg" || raw === "pq") return { transfer: raw };
|
if (raw === "hlg" || raw === "pq") return { transfer: raw };
|
||||||
return undefined;
|
return false;
|
||||||
})(),
|
})(),
|
||||||
hdrAutoDetect: envBool("PRODUCER_HDR_AUTO_DETECT", DEFAULT_CONFIG.hdrAutoDetect),
|
hdrAutoDetect: envBool("PRODUCER_HDR_AUTO_DETECT", DEFAULT_CONFIG.hdrAutoDetect),
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { ENCODER_PRESETS, getEncoderPreset, buildEncoderArgs } from "./chunkEncoder.js";
|
import { ENCODER_PRESETS, getEncoderPreset, buildEncoderArgs } from "./chunkEncoder.js";
|
||||||
|
|
||||||
describe("ENCODER_PRESETS", () => {
|
describe("ENCODER_PRESETS", () => {
|
||||||
@@ -285,23 +285,54 @@ describe("buildEncoderArgs HDR color space", () => {
|
|||||||
const baseOptions = { fps: 30, width: 1920, height: 1080 };
|
const baseOptions = { fps: 30, width: 1920, height: 1080 };
|
||||||
const inputArgs = ["-framerate", "30", "-i", "frames/%04d.png"];
|
const inputArgs = ["-framerate", "30", "-i", "frames/%04d.png"];
|
||||||
|
|
||||||
it("keeps bt709 color tags when HDR flag is set but frames are still Chrome sRGB captures", () => {
|
it("emits BT.2020 + arib-std-b67 tags for HDR HLG (h265 SW)", () => {
|
||||||
// HDR flag gives H.265 + 10-bit encoding but pixels are still sRGB/bt709.
|
// When options.hdr is set, the caller asserts the input pixels are
|
||||||
// Tagging as bt2020 causes orange shift — so we tag truthfully as bt709.
|
// already in the BT.2020 color space — tag the output truthfully so
|
||||||
|
// HDR-aware players apply the right transform.
|
||||||
const args = buildEncoderArgs(
|
const args = buildEncoderArgs(
|
||||||
{ ...baseOptions, codec: "h265", preset: "medium", quality: 23, hdr: { transfer: "hlg" } },
|
{ ...baseOptions, codec: "h265", preset: "medium", quality: 23, hdr: { transfer: "hlg" } },
|
||||||
inputArgs,
|
inputArgs,
|
||||||
"out.mp4",
|
"out.mp4",
|
||||||
);
|
);
|
||||||
expect(args[args.indexOf("-colorspace:v") + 1]).toBe("bt709");
|
expect(args[args.indexOf("-colorspace:v") + 1]).toBe("bt2020nc");
|
||||||
expect(args[args.indexOf("-color_primaries:v") + 1]).toBe("bt709");
|
expect(args[args.indexOf("-color_primaries:v") + 1]).toBe("bt2020");
|
||||||
expect(args[args.indexOf("-color_trc:v") + 1]).toBe("bt709");
|
expect(args[args.indexOf("-color_trc:v") + 1]).toBe("arib-std-b67");
|
||||||
const paramIdx = args.indexOf("-x265-params");
|
const paramIdx = args.indexOf("-x265-params");
|
||||||
expect(args[paramIdx + 1]).toContain("colorprim=bt709");
|
expect(args[paramIdx + 1]).toContain("colorprim=bt2020");
|
||||||
expect(args[paramIdx + 1]).toContain("transfer=bt709");
|
expect(args[paramIdx + 1]).toContain("transfer=arib-std-b67");
|
||||||
|
expect(args[paramIdx + 1]).toContain("colormatrix=bt2020nc");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses bt709 when HDR is not set", () => {
|
it("emits BT.2020 + smpte2084 tags for HDR PQ (h265 SW)", () => {
|
||||||
|
const args = buildEncoderArgs(
|
||||||
|
{ ...baseOptions, codec: "h265", preset: "medium", quality: 23, hdr: { transfer: "pq" } },
|
||||||
|
inputArgs,
|
||||||
|
"out.mp4",
|
||||||
|
);
|
||||||
|
expect(args[args.indexOf("-colorspace:v") + 1]).toBe("bt2020nc");
|
||||||
|
expect(args[args.indexOf("-color_primaries:v") + 1]).toBe("bt2020");
|
||||||
|
expect(args[args.indexOf("-color_trc:v") + 1]).toBe("smpte2084");
|
||||||
|
const paramIdx = args.indexOf("-x265-params");
|
||||||
|
expect(args[paramIdx + 1]).toContain("colorprim=bt2020");
|
||||||
|
expect(args[paramIdx + 1]).toContain("transfer=smpte2084");
|
||||||
|
expect(args[paramIdx + 1]).toContain("colormatrix=bt2020nc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("embeds HDR static mastering metadata in x265-params when HDR is set", () => {
|
||||||
|
// master-display + max-cll SEI messages are required so HDR-aware
|
||||||
|
// players (Apple QuickTime, YouTube, HDR TVs) treat the stream as
|
||||||
|
// HDR10 instead of falling back to SDR BT.2020 tone-mapping.
|
||||||
|
const args = buildEncoderArgs(
|
||||||
|
{ ...baseOptions, codec: "h265", preset: "medium", quality: 23, hdr: { transfer: "pq" } },
|
||||||
|
inputArgs,
|
||||||
|
"out.mp4",
|
||||||
|
);
|
||||||
|
const paramIdx = args.indexOf("-x265-params");
|
||||||
|
expect(args[paramIdx + 1]).toContain("master-display=");
|
||||||
|
expect(args[paramIdx + 1]).toContain("max-cll=");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses bt709 when HDR is not set (SDR Chrome captures)", () => {
|
||||||
const args = buildEncoderArgs(
|
const args = buildEncoderArgs(
|
||||||
{ ...baseOptions, codec: "h265", preset: "medium", quality: 23 },
|
{ ...baseOptions, codec: "h265", preset: "medium", quality: 23 },
|
||||||
inputArgs,
|
inputArgs,
|
||||||
@@ -309,11 +340,46 @@ describe("buildEncoderArgs HDR color space", () => {
|
|||||||
);
|
);
|
||||||
expect(args[args.indexOf("-colorspace:v") + 1]).toBe("bt709");
|
expect(args[args.indexOf("-colorspace:v") + 1]).toBe("bt709");
|
||||||
expect(args[args.indexOf("-color_trc:v") + 1]).toBe("bt709");
|
expect(args[args.indexOf("-color_trc:v") + 1]).toBe("bt709");
|
||||||
|
const paramIdx = args.indexOf("-x265-params");
|
||||||
|
expect(args[paramIdx + 1]).toContain("colorprim=bt709");
|
||||||
|
expect(args[paramIdx + 1]).not.toContain("master-display");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses range conversion (not colorspace) for HDR CPU encoding", () => {
|
it("does not embed HDR mastering metadata when HDR is not set", () => {
|
||||||
// Chrome screenshots are sRGB — we don't convert primaries (causes color shifts).
|
const args = buildEncoderArgs(
|
||||||
// Just range-convert and let the bt2020 container metadata + 10-bit handle the rest.
|
{ ...baseOptions, codec: "h265", preset: "medium", quality: 23 },
|
||||||
|
inputArgs,
|
||||||
|
"out.mp4",
|
||||||
|
);
|
||||||
|
const paramIdx = args.indexOf("-x265-params");
|
||||||
|
expect(args[paramIdx + 1]).not.toContain("master-display");
|
||||||
|
expect(args[paramIdx + 1]).not.toContain("max-cll");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips HDR and tags as SDR/BT.709 when codec=h264 (libx264 has no HDR support)", () => {
|
||||||
|
// libx264 cannot encode HDR. Rather than emit a "half-HDR" file (BT.2020
|
||||||
|
// container tags + BT.709 VUI inside the bitstream — confusing to HDR-aware
|
||||||
|
// players), we strip hdr and tag the whole output as SDR/BT.709. The caller
|
||||||
|
// gets a warning telling them to use codec=h265 for real HDR output.
|
||||||
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const args = buildEncoderArgs(
|
||||||
|
{ ...baseOptions, codec: "h264", preset: "medium", quality: 23, hdr: { transfer: "pq" } },
|
||||||
|
inputArgs,
|
||||||
|
"out.mp4",
|
||||||
|
);
|
||||||
|
const paramIdx = args.indexOf("-x264-params");
|
||||||
|
expect(args[paramIdx + 1]).toContain("colorprim=bt709");
|
||||||
|
expect(args[paramIdx + 1]).not.toContain("master-display");
|
||||||
|
expect(args[args.indexOf("-colorspace:v") + 1]).toBe("bt709");
|
||||||
|
expect(args[args.indexOf("-color_primaries:v") + 1]).toBe("bt709");
|
||||||
|
expect(args[args.indexOf("-color_trc:v") + 1]).toBe("bt709");
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("HDR is not supported with codec=h264"),
|
||||||
|
);
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses range conversion for HDR CPU encoding", () => {
|
||||||
const args = buildEncoderArgs(
|
const args = buildEncoderArgs(
|
||||||
{ ...baseOptions, codec: "h265", preset: "medium", quality: 23, hdr: { transfer: "hlg" } },
|
{ ...baseOptions, codec: "h265", preset: "medium", quality: 23, hdr: { transfer: "hlg" } },
|
||||||
inputArgs,
|
inputArgs,
|
||||||
@@ -322,7 +388,6 @@ describe("buildEncoderArgs HDR color space", () => {
|
|||||||
const vfIdx = args.indexOf("-vf");
|
const vfIdx = args.indexOf("-vf");
|
||||||
expect(vfIdx).toBeGreaterThan(-1);
|
expect(vfIdx).toBeGreaterThan(-1);
|
||||||
expect(args[vfIdx + 1]).toContain("scale=in_range=pc:out_range=tv");
|
expect(args[vfIdx + 1]).toContain("scale=in_range=pc:out_range=tv");
|
||||||
expect(args[vfIdx + 1]).not.toContain("colorspace");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses same range conversion for SDR CPU encoding", () => {
|
it("uses same range conversion for SDR CPU encoding", () => {
|
||||||
@@ -334,4 +399,29 @@ describe("buildEncoderArgs HDR color space", () => {
|
|||||||
const vfIdx = args.indexOf("-vf");
|
const vfIdx = args.indexOf("-vf");
|
||||||
expect(args[vfIdx + 1]).toContain("scale=in_range=pc:out_range=tv");
|
expect(args[vfIdx + 1]).toContain("scale=in_range=pc:out_range=tv");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("tags BT.2020 + transfer for HDR GPU H.265 (no mastering metadata via -x265-params)", () => {
|
||||||
|
// GPU encoders (nvenc, videotoolbox, qsv, vaapi) still emit the BT.2020
|
||||||
|
// color tags via the codec-level -colorspace/-color_primaries/-color_trc
|
||||||
|
// flags, but cannot accept x265-params, so HDR static mastering metadata
|
||||||
|
// (master-display, max-cll) is not embedded. Acceptable for previews,
|
||||||
|
// not for HDR-aware delivery.
|
||||||
|
const args = buildEncoderArgs(
|
||||||
|
{
|
||||||
|
...baseOptions,
|
||||||
|
codec: "h265",
|
||||||
|
preset: "medium",
|
||||||
|
quality: 23,
|
||||||
|
useGpu: true,
|
||||||
|
hdr: { transfer: "pq" },
|
||||||
|
},
|
||||||
|
inputArgs,
|
||||||
|
"out.mp4",
|
||||||
|
"nvenc",
|
||||||
|
);
|
||||||
|
expect(args[args.indexOf("-colorspace:v") + 1]).toBe("bt2020nc");
|
||||||
|
expect(args[args.indexOf("-color_primaries:v") + 1]).toBe("bt2020");
|
||||||
|
expect(args[args.indexOf("-color_trc:v") + 1]).toBe("smpte2084");
|
||||||
|
expect(args.indexOf("-x265-params")).toBe(-1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync, writeFileSy
|
|||||||
import { join, dirname } from "path";
|
import { join, dirname } from "path";
|
||||||
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
|
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
|
||||||
import { type GpuEncoder, getCachedGpuEncoder, getGpuEncoderName } from "../utils/gpuEncoder.js";
|
import { type GpuEncoder, getCachedGpuEncoder, getGpuEncoderName } from "../utils/gpuEncoder.js";
|
||||||
import { type HdrTransfer } from "../utils/hdr.js";
|
import { type HdrTransfer, getHdrEncoderColorParams } from "../utils/hdr.js";
|
||||||
import { runFfmpeg } from "../utils/runFfmpeg.js";
|
import { runFfmpeg } from "../utils/runFfmpeg.js";
|
||||||
import type { EncoderOptions, EncodeResult, MuxResult } from "./chunkEncoder.types.js";
|
import type { EncoderOptions, EncodeResult, MuxResult } from "./chunkEncoder.types.js";
|
||||||
|
|
||||||
@@ -88,6 +88,18 @@ export function buildEncoderArgs(
|
|||||||
useGpu = false,
|
useGpu = false,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
|
// libx264 cannot encode HDR. If a caller passes hdr with codec=h264 we'd
|
||||||
|
// produce a "half-HDR" file (BT.2020 container tags but a BT.709 VUI block
|
||||||
|
// inside the bitstream) which confuses HDR-aware players. Strip hdr and
|
||||||
|
// log a warning so the caller picks h265 (the SDR-tagged output is honest).
|
||||||
|
if (options.hdr && codec === "h264") {
|
||||||
|
console.warn(
|
||||||
|
"[chunkEncoder] HDR is not supported with codec=h264 (libx264 has no HDR support). " +
|
||||||
|
"Stripping HDR metadata and tagging output as SDR/BT.709. Use codec=h265 for HDR output.",
|
||||||
|
);
|
||||||
|
options = { ...options, hdr: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
const args: string[] = [...inputArgs, "-r", String(fps)];
|
const args: string[] = [...inputArgs, "-r", String(fps)];
|
||||||
const shouldUseGpu = useGpu && gpuEncoder !== null;
|
const shouldUseGpu = useGpu && gpuEncoder !== null;
|
||||||
|
|
||||||
@@ -130,8 +142,14 @@ export function buildEncoderArgs(
|
|||||||
|
|
||||||
// Encoder-specific params: anti-banding + color space tagging.
|
// Encoder-specific params: anti-banding + color space tagging.
|
||||||
// aq-mode=3 redistributes bits to dark flat areas (gradients).
|
// aq-mode=3 redistributes bits to dark flat areas (gradients).
|
||||||
|
// For HDR x265 paths we additionally embed BT.2020 + transfer + HDR static
|
||||||
|
// mastering metadata via x265-params; libx264 only carries BT.709 tags
|
||||||
|
// since HDR through H.264 is not supported by this encoder path.
|
||||||
const xParamsFlag = codec === "h264" ? "-x264-params" : "-x265-params";
|
const xParamsFlag = codec === "h264" ? "-x264-params" : "-x265-params";
|
||||||
const colorParams = "colorprim=bt709:transfer=bt709:colormatrix=bt709";
|
const colorParams =
|
||||||
|
codec === "h265" && options.hdr
|
||||||
|
? getHdrEncoderColorParams(options.hdr.transfer).x265ColorParams
|
||||||
|
: "colorprim=bt709:transfer=bt709:colormatrix=bt709";
|
||||||
if (preset === "ultrafast") {
|
if (preset === "ultrafast") {
|
||||||
args.push(xParamsFlag, `aq-mode=3:${colorParams}`);
|
args.push(xParamsFlag, `aq-mode=3:${colorParams}`);
|
||||||
} else {
|
} else {
|
||||||
@@ -157,12 +175,33 @@ export function buildEncoderArgs(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Color space metadata — tags the output so players interpret colors correctly.
|
// Color space metadata — tags the output so players interpret colors correctly.
|
||||||
// Chrome screenshots are always sRGB/bt709 pixels regardless of --hdr flag.
|
//
|
||||||
// We tag truthfully as bt709 even for HDR output — the --hdr flag gives
|
// Default (no options.hdr): Chrome screenshots are sRGB/bt709 pixels and
|
||||||
// H.265 + 10-bit encoding (better quality/compression) without lying about
|
// we tag them truthfully as bt709. Tagging as bt2020 when pixels are bt709
|
||||||
// the color space. Tagging as bt2020 when pixels are bt709 causes browsers
|
// causes browsers to apply the wrong color transform, producing visible
|
||||||
// to apply the wrong color transform, producing visible orange/warm shifts.
|
// orange/warm shifts.
|
||||||
|
//
|
||||||
|
// HDR (options.hdr provided): the caller asserts the input pixels are
|
||||||
|
// already in the BT.2020 color space (e.g. extracted HDR video frames or a
|
||||||
|
// pre-tagged source). We tag the output as BT.2020 + the corresponding
|
||||||
|
// transfer (smpte2084 for PQ, arib-std-b67 for HLG). HDR static mastering
|
||||||
|
// metadata (master-display, max-cll) is embedded only in the SW libx265
|
||||||
|
// path above; GPU H.265 + HDR carries the color tags but not the static
|
||||||
|
// metadata, which is acceptable for previews but not for HDR-aware delivery.
|
||||||
if (codec === "h264" || codec === "h265") {
|
if (codec === "h264" || codec === "h265") {
|
||||||
|
if (options.hdr) {
|
||||||
|
const transferTag = options.hdr.transfer === "pq" ? "smpte2084" : "arib-std-b67";
|
||||||
|
args.push(
|
||||||
|
"-colorspace:v",
|
||||||
|
"bt2020nc",
|
||||||
|
"-color_primaries:v",
|
||||||
|
"bt2020",
|
||||||
|
"-color_trc:v",
|
||||||
|
transferTag,
|
||||||
|
"-color_range",
|
||||||
|
"tv",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
args.push(
|
args.push(
|
||||||
"-colorspace:v",
|
"-colorspace:v",
|
||||||
"bt709",
|
"bt709",
|
||||||
@@ -173,6 +212,7 @@ export function buildEncoderArgs(
|
|||||||
"-color_range",
|
"-color_range",
|
||||||
"tv",
|
"tv",
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Range conversion: Chrome's full-range RGB → limited/TV range.
|
// Range conversion: Chrome's full-range RGB → limited/TV range.
|
||||||
if (gpuEncoder === "vaapi") {
|
if (gpuEncoder === "vaapi") {
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ import { existsSync, mkdirSync, readdirSync, rmSync } from "fs";
|
|||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import { parseHTML } from "linkedom";
|
import { parseHTML } from "linkedom";
|
||||||
import { extractVideoMetadata, type VideoMetadata } from "../utils/ffprobe.js";
|
import { extractVideoMetadata, type VideoMetadata } from "../utils/ffprobe.js";
|
||||||
import { isHdrColorSpace as isHdrColorSpaceUtil } from "../utils/hdr.js";
|
import {
|
||||||
|
analyzeCompositionHdr,
|
||||||
|
isHdrColorSpace as isHdrColorSpaceUtil,
|
||||||
|
type HdrTransfer,
|
||||||
|
} from "../utils/hdr.js";
|
||||||
import { downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js";
|
import { downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js";
|
||||||
import { runFfmpeg } from "../utils/runFfmpeg.js";
|
import { runFfmpeg } from "../utils/runFfmpeg.js";
|
||||||
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
|
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
|
||||||
@@ -250,21 +254,28 @@ export async function extractVideoFramesRange(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert an SDR video to HDR color space (HLG / BT.2020) so it can be
|
* Convert an SDR (BT.709) video to BT.2020 wide-gamut so it can be composited
|
||||||
* composited alongside HDR content without looking washed out.
|
* alongside HDR content without looking washed out.
|
||||||
*
|
*
|
||||||
* Uses zscale for color space conversion with a nominal peak luminance of
|
* Uses FFmpeg's `colorspace` filter to remap BT.709 → BT.2020 (no real tone
|
||||||
* 600 nits — high enough that SDR content doesn't appear too dark next to
|
* mapping — just a primaries swap so the input fits inside the wider HDR
|
||||||
* HDR, matching the approach used by HeyGen's Rio pipeline.
|
* gamut), then re-tags the stream with the caller's target HDR transfer
|
||||||
|
* function (PQ for HDR10, HLG for broadcast HDR). The output transfer must
|
||||||
|
* match the dominant transfer of the surrounding HDR content; otherwise the
|
||||||
|
* downstream encoder will tag the final video with the wrong curve.
|
||||||
*/
|
*/
|
||||||
async function convertSdrToHdr(
|
async function convertSdrToHdr(
|
||||||
inputPath: string,
|
inputPath: string,
|
||||||
outputPath: string,
|
outputPath: string,
|
||||||
|
targetTransfer: HdrTransfer,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout">>,
|
config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout">>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const timeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
|
const timeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
|
||||||
|
|
||||||
|
// smpte2084 = PQ (HDR10), arib-std-b67 = HLG.
|
||||||
|
const colorTrc = targetTransfer === "pq" ? "smpte2084" : "arib-std-b67";
|
||||||
|
|
||||||
const args = [
|
const args = [
|
||||||
"-i",
|
"-i",
|
||||||
inputPath,
|
inputPath,
|
||||||
@@ -273,7 +284,7 @@ async function convertSdrToHdr(
|
|||||||
"-color_primaries",
|
"-color_primaries",
|
||||||
"bt2020",
|
"bt2020",
|
||||||
"-color_trc",
|
"-color_trc",
|
||||||
"arib-std-b67",
|
colorTrc,
|
||||||
"-colorspace",
|
"-colorspace",
|
||||||
"bt2020nc",
|
"bt2020nc",
|
||||||
"-c:v",
|
"-c:v",
|
||||||
@@ -401,8 +412,15 @@ export async function extractAllVideoFrames(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasAnyHdr = videoColorSpaces.some(isHdrColorSpaceUtil);
|
const hdrInfo = analyzeCompositionHdr(videoColorSpaces);
|
||||||
if (hasAnyHdr) {
|
if (hdrInfo.hasHdr && hdrInfo.dominantTransfer) {
|
||||||
|
// dominantTransfer is "majority wins" — if a composition mixes PQ and HLG
|
||||||
|
// sources (rare but legal), the minority transfer's videos get converted
|
||||||
|
// with the wrong curve. We treat this as caller-error: a single composition
|
||||||
|
// should not mix PQ and HLG sources, the orchestrator picks one transfer
|
||||||
|
// for the whole render, and any source not on that curve is normalized to
|
||||||
|
// it. If you need both transfers, render two separate compositions.
|
||||||
|
const targetTransfer = hdrInfo.dominantTransfer;
|
||||||
const convertDir = join(options.outputDir, "_hdr_normalized");
|
const convertDir = join(options.outputDir, "_hdr_normalized");
|
||||||
mkdirSync(convertDir, { recursive: true });
|
mkdirSync(convertDir, { recursive: true });
|
||||||
|
|
||||||
@@ -410,12 +428,13 @@ export async function extractAllVideoFrames(
|
|||||||
if (signal?.aborted) break;
|
if (signal?.aborted) break;
|
||||||
const cs = videoColorSpaces[i] ?? null;
|
const cs = videoColorSpaces[i] ?? null;
|
||||||
if (!isHdrColorSpaceUtil(cs)) {
|
if (!isHdrColorSpaceUtil(cs)) {
|
||||||
// SDR video in a mixed timeline — convert to HDR color space
|
// SDR video in a mixed timeline — convert to the dominant HDR transfer
|
||||||
|
// so the encoder tags the final video correctly (PQ vs HLG).
|
||||||
const entry = resolvedVideos[i];
|
const entry = resolvedVideos[i];
|
||||||
if (!entry) continue;
|
if (!entry) continue;
|
||||||
const convertedPath = join(convertDir, `${entry.video.id}_hdr.mp4`);
|
const convertedPath = join(convertDir, `${entry.video.id}_hdr.mp4`);
|
||||||
try {
|
try {
|
||||||
await convertSdrToHdr(entry.videoPath, convertedPath, signal, config);
|
await convertSdrToHdr(entry.videoPath, convertedPath, targetTransfer, signal, config);
|
||||||
entry.videoPath = convertedPath;
|
entry.videoPath = convertedPath;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errors.push({
|
errors.push({
|
||||||
|
|||||||
Reference in New Issue
Block a user