fix(engine): exports carry full colour tags with every encoder, SDR and HDR (#5742)

* fix(engine): SDR exports carry BT.709 transfer and primaries with every encoder

The SDR conversion filter set the BT.709 matrix on its frames but not the
transfer or primaries. Encoders that take colour from the frames rather than
from -color_* options (VideoToolbox on macOS) wrote color_space=bt709 with
color_transfer and color_primaries unknown. The capture and raw-input SDR
filters now end in the tagged BT.709 filter ProRes already uses.

* fix(engine): HDR exports tag BT.2020 with PQ or HLG on the frames too

VideoToolbox HDR encodes had the same gap as SDR: given only -color_* options
it wrote bt2020nc with transfer and primaries unknown, so players read the
export as SDR. Every HDR branch of the streamed and disk encoders now tags the
frames through one hdrFrameTagsFilter built from the encoder colour params.

* test(engine): colour-tag probes pass -- before the output path
This commit is contained in:
Miguel Ángel
2026-10-18 22:15:31 +00:00
committed by GitHub
parent 26d7dbca36
commit ea742c5018
6 changed files with 145 additions and 18 deletions
@@ -1226,7 +1226,9 @@ describe("buildEncoderArgs color space", () => {
"out.mp4",
"nvenc",
);
expect(args[args.indexOf("-vf") + 1]).toBe("pad=ceil(iw/2)*2:ceil(ih/2)*2");
expect(args[args.indexOf("-vf") + 1]).toBe(
"setparams=color_primaries=bt2020:color_trc=smpte2084:colorspace=bt2020nc:range=tv,pad=ceil(iw/2)*2:ceil(ih/2)*2",
);
});
it("converts alpha ProRes to BT.709 without an even-dim pad", () => {
@@ -1897,6 +1899,53 @@ describe.skipIf(!HAS_FFMPEG)("buildEncoderArgs SDR colour", () => {
}
}, 30_000);
// VideoToolbox, like a libx264/libx265 given no colour options, writes only the colour its frames carry.
const streamTagsThrough = (args: string[], encoder: string[]): string => {
const dir = mkdtempSync(join(tmpdir(), "hf-frame-tags-"));
try {
const out = join(dir, "out.mp4");
const vfAt = args.indexOf("-vf");
const vf = vfAt === -1 ? [] : ["-vf", args[vfAt + 1]!];
const source = ["-f", "lavfi", "-i", "color=c=0x808080:s=64x16:d=0.1"];
const encode = spawnSync(ffmpeg, ["-y", "-v", "error", ...source, ...vf, ...encoder, out]);
expect(encode.status).toBe(0);
const entries = "stream=color_space,color_transfer,color_primaries";
const probe = ["-v", "error", "-show_entries", entries, "-of", "csv=p=0", "--", out];
return spawnSync(getFfprobeBinary(), probe).stdout.toString().trim();
} finally {
rmSync(dir, { recursive: true, force: true });
}
};
const tinyFrame = { fps: { num: 30, den: 1 }, width: 64, height: 16, preset: "ultrafast" };
it("tags BT.709 on the frames, so an encoder that reads only them tags the stream", () => {
const args = buildEncoderArgs(
{ ...tinyFrame, codec: "h264", quality: 23 },
["-i", "in"],
"o.mp4",
);
expect(streamTagsThrough(args, ["-c:v", "libx264", "-preset", "ultrafast"])).toBe(
"bt709,bt709,bt709",
);
});
it.each([
["pq", "smpte2084", null],
["hlg", "arib-std-b67", null],
["pq", "smpte2084", "videotoolbox"],
] as const)("tags HDR %s (%s) on the frames for encoder %s", (transfer, trc, gpu) => {
const args = buildEncoderArgs(
{ ...tinyFrame, codec: "h265", quality: 23, hdr: { transfer }, useGpu: gpu !== null },
["-i", "in"],
"o.mp4",
gpu,
);
const x265 = ["-c:v", "libx265", "-pix_fmt", "yuv420p10le", "-x265-params", "log-level=error"];
expect(streamTagsThrough(args, x265)).toBe(`bt2020nc,${trc},bt2020`);
});
it("keeps the rightmost 8 columns of a 1080-wide (16n + 8) mp4", () => {
const dir = mkdtempSync(join(tmpdir(), "hf-sdr-right-edge-"));
try {
+9 -3
View File
@@ -29,7 +29,7 @@ import {
buildVideoToolboxRateControlArgs,
mapPresetForGpuEncoder,
} from "../utils/gpuEncoder.js";
import { type HdrTransfer, getHdrEncoderColorParams } from "../utils/hdr.js";
import { type HdrTransfer, getHdrEncoderColorParams, hdrFrameTagsFilter } from "../utils/hdr.js";
import { withEvenDimensionPad } from "../utils/evenDimensions.js";
import {
SDR_CAPTURE_TO_BT709_FILTER,
@@ -460,8 +460,9 @@ export function buildEncoderArgs(
// Range conversion: Chrome's full-range capture → limited/TV range; SDR also
// converts to the BT.709 matrix it is tagged with.
const hdrTags = options.hdr ? hdrFrameTagsFilter(options.hdr.transfer) : undefined;
const sdrFilter = options.hdr ? undefined : SDR_CAPTURE_TO_BT709_FILTER;
const captureFilter = sdrFilter ?? "scale=in_range=pc:out_range=tv";
const captureFilter = sdrFilter ?? `scale=in_range=pc:out_range=tv,${hdrTags}`;
if (gpuEncoder === "vaapi") {
// vaapi already runs `format=nv12,hwupload`; the nv12 conversion aligns
// odd dimensions before upload, so only prepend the colour conversion.
@@ -474,7 +475,12 @@ export function buildEncoderArgs(
// encoder. They hit the same "height not divisible by 2" abort as
// libx264 on an odd-sized 4:2:0 canvas, so pad odd dimensions up to even
// on the software side before the encode.
const vf = withEvenDimensionPad(sdrFilter ?? "", pixelFormat, options.width, options.height);
const vf = withEvenDimensionPad(
sdrFilter ?? hdrTags ?? "",
pixelFormat,
options.width,
options.height,
);
if (vf) args.push("-vf", vf);
} else {
// The scale filter handles both 8-bit and 10-bit correctly. Pad odd
@@ -1228,6 +1228,70 @@ describe("createFrameReorderBuffer abort (interleaved parallel drain)", () => {
});
});
describe.skipIf(spawnSync("ffmpeg", ["-version"]).status !== 0)(
"buildStreamingArgs frame tags",
() => {
// VideoToolbox, like a libx264/libx265 given no colour options, writes only the colour its frames carry.
const streamTagsThrough = (args: string[], encoder: string[]): string => {
const dir = mkdtempSync(join(tmpdir(), "se-frame-tags-"));
try {
const out = join(dir, "out.mp4");
const vfAt = args.indexOf("-vf");
const vf = vfAt === -1 ? [] : ["-vf", args[vfAt + 1]!];
const source = ["-f", "lavfi", "-i", "color=c=0x808080:s=64x16:d=0.1"];
const encode = spawnSync("ffmpeg", [
"-y",
"-v",
"error",
...source,
...vf,
...encoder,
out,
]);
expect(encode.status).toBe(0);
const entries = "stream=color_space,color_transfer,color_primaries";
const probe = ["-v", "error", "-show_entries", entries, "-of", "csv=p=0", "--", out];
return spawnSync("ffprobe", probe).stdout.toString().trim();
} finally {
rmSync(dir, { recursive: true, force: true });
}
};
const tiny = { width: 64, height: 16 };
it.each([
["captured frames", {}],
["raw RGB frames", { rawInputFormat: "rgb48le" as const }],
])("tags BT.709 on %s, so an encoder that reads only them tags the stream", (_, extra) => {
const args = buildStreamingArgs({ ...baseSdr, ...tiny, ...extra }, "o.mp4");
expect(streamTagsThrough(args, ["-c:v", "libx264", "-preset", "ultrafast"])).toBe(
"bt709,bt709,bt709",
);
});
it.each([
[baseHdrPq, "smpte2084", null],
[baseHdrHlg, "arib-std-b67", null],
[baseHdrPq, "smpte2084", "videotoolbox"],
] as const)(
"tags raw HDR frames %#, so an encoder that reads only them tags the stream",
(base, trc, gpu) => {
const args = buildStreamingArgs({ ...base, ...tiny, useGpu: gpu !== null }, "o.mp4", gpu);
const x265 = [
"-c:v",
"libx265",
"-pix_fmt",
"yuv420p10le",
"-x265-params",
"log-level=error",
];
expect(streamTagsThrough(args, x265)).toBe(`bt2020nc,${trc},bt2020`);
},
);
},
);
describe.skipIf(spawnSync("ffmpeg", ["-version"]).status !== 0)(
"buildStreamingArgs raw SDR colour",
() => {
@@ -37,11 +37,10 @@ import {
isExternalFfmpegInterruption,
} from "../utils/runFfmpeg.js";
import { getFfmpegBinary } from "../utils/ffmpegBinaries.js";
import { getHdrEncoderColorParams } from "../utils/hdr.js";
import { getHdrEncoderColorParams, hdrFrameTagsFilter } from "../utils/hdr.js";
import { withEvenDimensionPad } from "../utils/evenDimensions.js";
import {
SDR_CAPTURE_TO_BT709_FILTER,
SDR_RGB_TO_BT709_FILTER,
SDR_RGB_TO_TAGGED_BT709_FILTER,
} from "../utils/sdrCaptureColor.js";
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
@@ -424,24 +423,28 @@ export function buildStreamingArgs(
);
}
// Raw HDR input (from WebGPU pipeline) is already PQ-encoded, so only SDR gets a
// conversion: raw sRGB or Chrome's BT.601 JPEG screenshots to BT.709 limited range.
const sdrFilter = options.rawInputFormat
? SDR_RGB_TO_BT709_FILTER
: SDR_CAPTURE_TO_BT709_FILTER;
if (options.rawInputFormat && options.hdr) {
// No filter needed — PQ data goes straight to encoder
} else if (gpuEncoder === "vaapi") {
// Raw HDR input (from WebGPU pipeline) is already PQ-encoded and is only tagged; SDR is
// converted: raw sRGB or Chrome's BT.601 JPEG screenshots to tagged BT.709 limited range.
const frameFilter =
options.rawInputFormat && options.hdr
? hdrFrameTagsFilter(options.hdr.transfer)
: options.rawInputFormat
? SDR_RGB_TO_TAGGED_BT709_FILTER
: SDR_CAPTURE_TO_BT709_FILTER;
if (gpuEncoder === "vaapi") {
// vaapi already runs `format=nv12,hwupload`; the nv12 conversion aligns
// odd dimensions before upload, so only prepend the colour conversion.
const vfIdx = args.indexOf("-vf");
if (vfIdx !== -1) {
args[vfIdx + 1] = `${sdrFilter},${args[vfIdx + 1]}`;
args[vfIdx + 1] = `${frameFilter},${args[vfIdx + 1]}`;
}
} else {
// Pad odd dimensions up to even so 4:2:0 encoders (software and
// nvenc/videotoolbox/qsv/amf) don't abort with "height not divisible by 2".
args.push("-vf", withEvenDimensionPad(sdrFilter, pixelFormat, options.width, options.height));
args.push(
"-vf",
withEvenDimensionPad(frameFilter, pixelFormat, options.width, options.height),
);
}
// Fixed timescale for consistent A/V timing across platforms.
+5
View File
@@ -106,6 +106,11 @@ export function getHdrEncoderColorParams(
};
}
export function hdrFrameTagsFilter(transfer: HdrTransfer): string {
const { colorPrimaries, colorTrc, colorspace } = getHdrEncoderColorParams(transfer);
return `setparams=color_primaries=${colorPrimaries}:color_trc=${colorTrc}:colorspace=${colorspace}:range=tv`;
}
export interface CompositionHdrInfo {
hasHdr: boolean;
dominantTransfer: HdrTransfer | null;
+2 -2
View File
@@ -1,5 +1,5 @@
export const SDR_RGB_TO_BT709_FILTER = "scale=out_color_matrix=bt709:out_range=tv:flags=neighbor";
export const SDR_CAPTURE_TO_BT709_FILTER = `scale=in_color_matrix=bt601:in_range=pc:flags=neighbor+accurate_rnd,format=gbrp,${SDR_RGB_TO_BT709_FILTER}`;
export const SDR_RGB_TO_TAGGED_BT709_FILTER = `${SDR_RGB_TO_BT709_FILTER},setparams=color_primaries=bt709:color_trc=bt709:colorspace=bt709:range=tv`;
export const SDR_CAPTURE_TO_BT709_FILTER = `scale=in_color_matrix=bt601:in_range=pc:flags=neighbor+accurate_rnd,format=gbrp,${SDR_RGB_TO_TAGGED_BT709_FILTER}`;