fix(engine): forced-SDR renders show tone-mapped HDR footage as Studio does (#4600)

* fix(engine): forced-SDR renders show tone-mapped HDR footage as Studio does

Forced-SDR extraction tone-maps HDR frames with zscale, but the frames kept the
BT.709 transfer tag, so Chrome re-curved them (a tone-mapped 388d96 painted as
2d8790), and JPEG frames kept the BT.709 matrix every JPEG reader decodes as
BT.601. Tone-mapped frames now get the same sRGB tag and BT.601 JPEG conversion
as other SDR frames.

macOS used VideoToolbox, which does not tone-map: its frames stay PQ and Chrome
tone-maps them itself, darker than the hable curve Linux and Studio use. macOS
now takes the zscale path whenever ffmpeg has zscale, found through the shared
ffmpeg filter probe; without it the old path stays and the render warns once.

Co-authored-by: Kenneth Shortrede <95665480+sontiac@users.noreply.github.com>

* fix(engine): forced-SDR renders tone-map HDR footage that lacks matrix or primaries tags

zscale finds no path between colourspaces without them, so the extraction
failed. Untagged HDR footage is BT.2020 in practice, and now gets those tags.

Co-authored-by: Kenneth Shortrede <95665480+sontiac@users.noreply.github.com>

* fix(engine): forced-SDR tone map also tags the transfer ffprobe read from the container

From ffmpeg 8.1 ffprobe reports a container-only PQ or HLG tag; the frames
still lack it, so zscale failed where macOS VideoToolbox used to render.

Co-authored-by: Kenneth Shortrede <95665480+sontiac@users.noreply.github.com>

* test(engine): gate the container-only transfer case on the ffprobe that reads it

* docs(engine): shorten the filter-probe comment to the comment limit

* fix(engine): keep valid per-frame colour tags when tone-mapping HDR footage

* test(engine): build the mixed HLG and PQ fixture without B-frames so its timestamps are valid

* test(engine): build the trimmed mixed fixture without B-frames too

---------

Co-authored-by: Kenneth Shortrede <95665480+sontiac@users.noreply.github.com>
This commit is contained in:
Miguel Ángel
2026-09-28 08:21:13 +00:00
committed by GitHub
co-authored by Kenneth Shortrede
parent 0744667293
commit 3a64ea867c
4 changed files with 420 additions and 42 deletions
@@ -68,6 +68,9 @@ import { RATE_RANGE } from "@hyperframes/core/audio-automation";
// below run too — they exercise the extractor in isolation against a
// synthesized VFR fixture.
const HAS_FFMPEG = spawnSync("ffmpeg", ["-version"]).status === 0;
const HAS_ZSCALE =
HAS_FFMPEG &&
/\szscale\s/.test(spawnSync("ffmpeg", ["-hide_banner", "-filters"]).stdout.toString());
describe("resolveVideoExtractionDuration", () => {
const metadata = (
@@ -3140,7 +3143,17 @@ describe.skipIf(!HAS_FFMPEG)("extractAllVideoFrames on a VFR source", () => {
}, 60_000);
});
describe.skipIf(!HAS_FFMPEG || process.platform === "darwin")("forced-SDR HDR extraction", () => {
// Release builds print "version 8.1.3" or "n8.1.3"; master builds print "N-<number>" and are newer.
const FFPROBE_VERSION = spawnSync("ffprobe", ["-version"]).stdout?.toString() ?? "";
const FFPROBE_RELEASE = FFPROBE_VERSION.match(/version n?(\d+)\.(\d+)/)
?.slice(1)
.map(Number);
const FFPROBE_READS_CONTAINER_TRANSFER =
/version N-/.test(FFPROBE_VERSION) ||
(FFPROBE_RELEASE !== undefined &&
(FFPROBE_RELEASE[0]! > 8 || (FFPROBE_RELEASE[0] === 8 && FFPROBE_RELEASE[1]! >= 1)));
describe.skipIf(!HAS_ZSCALE)("forced-SDR HDR extraction", () => {
let fixtureDir = "";
beforeAll(() => {
@@ -3151,8 +3164,11 @@ describe.skipIf(!HAS_FFMPEG || process.platform === "darwin")("forced-SDR HDR ex
rmSync(fixtureDir, { recursive: true, force: true });
});
it("matches Studio's HLG tone map and isolates transformed cache entries", async () => {
const source = join(fixtureDir, "hlg-warm.mp4");
async function synthesizeHlgClip(
path: string,
vui = "colour_primaries=9:transfer_characteristics=18:matrix_coefficients=9",
transfer = "arib-std-b67",
): Promise<void> {
const synthesized = await runFfmpeg([
"-y",
"-hide_banner",
@@ -3163,7 +3179,7 @@ describe.skipIf(!HAS_FFMPEG || process.platform === "darwin")("forced-SDR HDR ex
"-i",
"color=c=0xe0b080:s=64x64:r=1:d=1",
"-vf",
"zscale=pin=bt709:tin=bt709:min=bt709:p=bt2020:t=arib-std-b67:m=bt2020nc:r=tv,format=yuv420p",
`zscale=pin=bt709:tin=bt709:min=bt709:p=bt2020:t=${transfer}:m=bt2020nc:r=tv,format=yuv420p`,
"-c:v",
"libx264",
"-preset",
@@ -3171,16 +3187,197 @@ describe.skipIf(!HAS_FFMPEG || process.platform === "darwin")("forced-SDR HDR ex
"-color_primaries",
"bt2020",
"-color_trc",
"arib-std-b67",
transfer,
"-colorspace",
"bt2020nc",
"-bsf:v",
"h264_metadata=colour_primaries=9:transfer_characteristics=18:matrix_coefficients=9",
source,
`h264_metadata=${vui}`,
path,
]);
if (!synthesized.success) {
throw new Error(`HLG fixture synthesis failed: ${synthesized.stderr.slice(-400)}`);
}
}
it.each([
[
"HLG",
"matrix",
"arib-std-b67",
"colour_primaries=9:transfer_characteristics=18:matrix_coefficients=2",
],
[
"HLG",
"primaries",
"arib-std-b67",
"colour_primaries=2:transfer_characteristics=18:matrix_coefficients=9",
],
[
"HLG",
"matrix and primaries",
"arib-std-b67",
"colour_primaries=2:transfer_characteristics=18:matrix_coefficients=2",
],
[
"PQ",
"matrix and primaries",
"smpte2084",
"colour_primaries=2:transfer_characteristics=16:matrix_coefficients=2",
],
[
"HLG",
"transfer",
"arib-std-b67",
"colour_primaries=9:transfer_characteristics=2:matrix_coefficients=9",
],
])(
"tone-maps %s footage whose video stream has no %s tag like the fully tagged clip",
async (name, missing, transfer, vui) => {
const slug = `${name}-no-${missing}`.replace(/\W+/g, "-");
const tagged = join(fixtureDir, `${name}-fully-tagged.mp4`);
const untagged = join(fixtureDir, `${slug}.mp4`);
const vuiTransfer = transfer === "smpte2084" ? 16 : 18;
await synthesizeHlgClip(
tagged,
`colour_primaries=9:transfer_characteristics=${vuiTransfer}:matrix_coefficients=9`,
transfer,
);
await synthesizeHlgClip(untagged, vui, transfer);
const extract = (source: string, id: string) =>
extractVideoFramesRange(source, id, 0, 1, {
fps: 1,
outputDir: join(fixtureDir, `out-${id}`),
format: "png",
toneMapHdrToSdr: true,
});
if (missing === "transfer") {
// ffprobe before 8.1 reads the stream's unspecified transfer, so the clip is SDR there, as on main.
if (!FFPROBE_READS_CONTAINER_TRANSFER) {
await expect(extract(untagged, slug)).resolves.toBeDefined();
return;
}
expect((await extractVideoMetadata(untagged)).colorSpace?.colorTransfer).toBe(transfer);
}
const reference = await extract(tagged, `ref-${slug}`);
const result = await extract(untagged, slug);
expect(readFileSync(result.framePaths.get(0)!)).toEqual(
readFileSync(reference.framePaths.get(0)!),
);
},
60_000,
);
// One stream with HLG frames, then PQ frames: each is tone-mapped with its own transfer.
it("tone-maps each frame of a mixed HLG and PQ stream with its own transfer", async () => {
const segment = async (name: string, transfer: number) => {
const path = join(fixtureDir, `mixed-${name}.h264`);
const result = await runFfmpeg([
"-y",
"-v",
"error",
"-f",
"lavfi",
"-i",
"testsrc2=s=160x90:r=25:d=0.2",
"-c:v",
"libx264",
// No B-frames: genpts stamps a raw stream in decode order, so reordered frames get wrong times.
"-bf",
"0",
"-pix_fmt",
"yuv420p",
"-bsf:v",
`h264_metadata=colour_primaries=9:transfer_characteristics=${transfer}:matrix_coefficients=9`,
"-f",
"h264",
path,
]);
if (!result.success)
throw new Error(`segment synthesis failed: ${result.stderr.slice(-400)}`);
return readFileSync(path);
};
const mux = async (name: string, stream: Buffer) => {
const raw = join(fixtureDir, `${name}.h264`);
const path = join(fixtureDir, `${name}.mp4`);
writeFileSync(raw, stream);
const result = await runFfmpeg([
...["-y", "-v", "error", "-fflags", "+genpts", "-r", "25", "-f", "h264", "-i", raw],
...["-c", "copy", path],
]);
if (!result.success) throw new Error(`mux failed: ${result.stderr.slice(-400)}`);
return path;
};
const hlg = await segment("hlg", 18);
const pq = await segment("pq", 16);
const mixed = await mux("mixed-hlg-pq", Buffer.concat([hlg, pq]));
const pqOnly = await mux("mixed-pq-only", pq);
const extract = (source: string, id: string, duration: number) =>
extractVideoFramesRange(source, id, 0, duration, {
fps: 25,
outputDir: join(fixtureDir, `out-${id}`),
format: "png",
toneMapHdrToSdr: true,
});
const mixedFrames = await extract(mixed, "mixed-hlg-pq", 0.4);
const pqFrames = await extract(pqOnly, "mixed-pq-only", 0.2);
expect(readFileSync(mixedFrames.framePaths.get(5)!)).toEqual(
readFileSync(pqFrames.framePaths.get(0)!),
);
}, 60_000);
// A stream-copy trim opens on edit-list pre-roll, which ffprobe counts as packets without frames.
it("keeps per-frame tags in a mixed HLG and PQ file trimmed with a stream copy", async () => {
const run = async (args: string[]) => {
const result = await runFfmpeg(["-y", "-v", "error", ...args]);
if (!result.success) throw new Error(`fixture failed: ${result.stderr.slice(-400)}`);
};
const segment = async (name: string, transfer: number) => {
const path = join(fixtureDir, `trim-${name}.h264`);
await run([
...["-f", "lavfi", "-i", "testsrc2=s=160x90:r=25:d=2"],
...["-c:v", "libx264", "-g", "50", "-bf", "0"],
...["-pix_fmt", "yuv420p", "-bsf:v"],
`h264_metadata=colour_primaries=9:transfer_characteristics=${transfer}:matrix_coefficients=9`,
...["-f", "h264", path],
]);
return readFileSync(path);
};
const mux = async (name: string, stream: Buffer) => {
const raw = join(fixtureDir, `${name}.h264`);
const path = join(fixtureDir, `${name}.mp4`);
writeFileSync(raw, stream);
await run(["-fflags", "+genpts", "-r", "25", "-f", "h264", "-i", raw, "-c", "copy", path]);
return path;
};
const pq = await segment("pq", 16);
const mixed = await mux("trim-hlg-then-pq", Buffer.concat([await segment("hlg", 18), pq]));
const pqOnly = await mux("trim-pq-only", pq);
const trimmed = join(fixtureDir, "trim-from-hlg.mp4");
await run(["-ss", "1", "-i", mixed, "-c", "copy", trimmed]);
const extract = (source: string, id: string, start: number) =>
extractVideoFramesRange(source, id, start, 0.04, {
fps: 25,
outputDir: join(fixtureDir, `out-${id}`),
format: "png",
toneMapHdrToSdr: true,
});
// Trimmed 1.6 s is the mixed file's 2.6 s: the PQ segment's 0.6 s.
const fromTrim = await extract(trimmed, "trim-from-hlg", 1.6);
const fromPq = await extract(pqOnly, "trim-pq-only", 0.6);
const shown = readFirstFramePixel(fromTrim.framePaths.get(0)!, 40, 40);
const expected = readFirstFramePixel(fromPq.framePaths.get(0)!, 40, 40);
expect(Math.max(...shown.map((v, i) => Math.abs(v - expected[i]!)))).toBeLessThanOrEqual(2);
}, 60_000);
it("matches Studio's HLG tone map and isolates transformed cache entries", async () => {
const source = join(fixtureDir, "hlg-warm.mp4");
await synthesizeHlgClip(source);
const reference = join(fixtureDir, "studio-reference.png");
const referenceResult = await runFfmpeg([
@@ -3243,9 +3440,107 @@ describe.skipIf(!HAS_FFMPEG || process.platform === "darwin")("forced-SDR HDR ex
if (!path) throw new Error("expected extracted frame");
return readFileSync(path);
};
expect(frame(toneMapped)).toEqual(readFileSync(reference));
// Same pixels as Studio's tone map, declared as sRGB so Chrome shows them unconverted.
const rgb = (path: string): Buffer =>
spawnSync("ffmpeg", ["-v", "error", "-i", path, "-f", "rawvideo", "-pix_fmt", "rgb24", "-"])
.stdout;
const toneMappedPath = toneMapped.extracted[0]!.framePaths.get(0)!;
expect(rgb(toneMappedPath)).toEqual(rgb(reference));
expect(pngChunkTypes(toneMappedPath)).toContain("sRGB");
expect(pngChunkTypes(toneMappedPath)).not.toContain("cICP");
expect(frame(plain)).not.toEqual(frame(toneMapped));
const toneMappedJpg = await extractAllVideoFrames([video("tone-mapped-jpg")], fixtureDir, {
fps: 1,
outputDir: join(fixtureDir, "tone-mapped-jpg"),
format: "jpg",
toneMapHdrToSdr: true,
});
expect(toneMappedJpg.errors).toEqual([]);
const jpgPixels = rgb(toneMappedJpg.extracted[0]!.framePaths.get(0)!);
const referencePixels = rgb(reference);
const worst = Math.max(...[...jpgPixels].map((v, i) => Math.abs(v - referencePixels[i]!)));
expect(worst, "tone-mapped jpg against Studio's tone map").toBeLessThanOrEqual(3);
expect(frame(toneMappedAgain)).toEqual(frame(toneMapped));
// A macOS ffmpeg that has zscale uses the same tone map, not VideoToolbox.
const platform = Object.getOwnPropertyDescriptor(process, "platform")!;
Object.defineProperty(process, "platform", { value: "darwin" });
try {
const mac = await extractAllVideoFrames([video("tone-mapped-mac")], fixtureDir, {
fps: 1,
outputDir: join(fixtureDir, "tone-mapped-mac"),
format: "png",
toneMapHdrToSdr: true,
});
expect(mac.errors).toEqual([]);
expect(rgb(mac.extracted[0]!.framePaths.get(0)!)).toEqual(rgb(reference));
} finally {
Object.defineProperty(process, "platform", platform);
}
}, 60_000);
it("warns once and keeps VideoToolbox, under its own cache key, when a macOS ffmpeg has no zscale", async () => {
const source = join(fixtureDir, "hlg-no-zscale.mp4");
await synthesizeHlgClip(source);
const cacheDir = join(fixtureDir, "no-zscale-cache");
const clip = (id: string): VideoElement => ({
id,
src: source,
start: 0,
end: 1,
mediaStart: 0,
loop: false,
hasAudio: false,
});
const options = (id: string) => ({
fps: 1,
outputDir: join(fixtureDir, id),
format: "png" as const,
toneMapHdrToSdr: true,
});
const zscale = await extractAllVideoFrames(
[clip("zscale")],
fixtureDir,
options("zscale"),
undefined,
{
extractCacheDir: cacheDir,
},
);
expect(zscale.errors).toEqual([]);
const realPlatform = process.platform;
vi.resetModules();
vi.doMock("../utils/psnrFilterAvailability.js", () => ({
isFfmpegFilterAvailable: async () => false,
isPsnrFilterAvailable: async () => false,
}));
const platform = Object.getOwnPropertyDescriptor(process, "platform")!;
Object.defineProperty(process, "platform", { value: "darwin" });
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
try {
const { extractAllVideoFrames: extractOnMac } = await import("./videoFrameExtractor.js");
for (const [i, id] of ["no-zscale-1", "no-zscale-2"].entries()) {
const result = await extractOnMac([clip(id)], fixtureDir, options(id), undefined, {
extractCacheDir: cacheDir,
});
// The zscale frames above must not be served for the VideoToolbox path.
if (i === 0) expect(result.phaseBreakdown.cacheHits).toBe(0);
// Off macOS the VideoToolbox decode itself fails, which proves it was attempted.
if (realPlatform !== "darwin")
expect(JSON.stringify(result.errors)).toMatch(/videotoolbox/i);
}
const zscaleWarnings = stderr.mock.calls.filter(([message]) =>
String(message).includes("no zscale filter"),
);
expect(zscaleWarnings).toHaveLength(1);
} finally {
stderr.mockRestore();
Object.defineProperty(process, "platform", platform);
vi.doUnmock("../utils/psnrFilterAvailability.js");
vi.resetModules();
}
}, 60_000);
// A second process would read the frames back without the HDR10 light-level metadata the tone map uses.
@@ -3283,7 +3578,7 @@ describe.skipIf(!HAS_FFMPEG || process.platform === "darwin")("forced-SDR HDR ex
"-t",
"0.616666",
"-vf",
"zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=bt709:m=bt709:r=tv",
"zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=bt709:m=bt709:r=tv,setparams=color_primaries=bt709:color_trc=iec61966-2-1",
"-fps_mode",
"cfr",
"-r",
@@ -34,6 +34,7 @@ import { isKnownInactiveTimelineWindow } from "./mediaTimelineWindow.js";
import {
extractFinalVideoFrameTimestamp,
extractMediaMetadata,
type VideoColorSpace,
type VideoMetadata,
} from "../utils/ffprobe.js";
import {
@@ -49,6 +50,7 @@ import {
writeUrlDownloadTelemetry,
} from "../utils/urlDownloader.js";
import { runFfmpeg, runFfmpegPipeline, type RunFfmpegResult } from "../utils/runFfmpeg.js";
import { isFfmpegFilterAvailable } from "../utils/psnrFilterAvailability.js";
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
import { unwrapTemplate } from "../utils/htmlTemplate.js";
import {
@@ -220,11 +222,31 @@ const GC_STALENESS_MS = 24 * 60 * 60 * 1000;
const SDR_TO_HDR_COLORSPACE_FILTER = "colorspace=all=bt2020:iall=bt709:range=tv";
const HDR_TO_SDR_TONEMAP_FILTER =
"zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=bt709:m=bt709:r=tv";
const HDR_TO_SDR_TRANSFORM_KEY = "hdr2sdr-hable-bt709";
const HDR_TO_SDR_ZSCALE_TRANSFORM_KEY = "hdr2sdr-hable-srgb";
const HDR_TO_SDR_VIDEOTOOLBOX_TRANSFORM_KEY = "hdr2sdr-videotoolbox-hdr";
const SDR_CANVAS_PASSTHROUGH_FILTER = "setparams=color_primaries=bt709:color_trc=iec61966-2-1";
const SDR_JPEG_AS_BT601_FULL_RANGE_FILTER =
"scale=flags=neighbor,format=gbrp,scale=out_color_matrix=bt601:out_range=pc:flags=neighbor,format=yuv420p";
let warnedMissingZscale = false;
async function toneMapsWithZscale(): Promise<boolean> {
return process.platform !== "darwin" || isFfmpegFilterAvailable("zscale");
}
async function hdrToSdrTransformKey(): Promise<string> {
if (await toneMapsWithZscale()) return HDR_TO_SDR_ZSCALE_TRANSFORM_KEY;
if (!warnedMissingZscale) {
warnedMissingZscale = true;
process.stderr.write(
"[hyperframes:render] WARNING: this ffmpeg has no zscale filter, so forced-SDR HDR footage " +
"is tone-mapped by Chrome instead of the hable curve used on Linux and in Studio, and " +
"renders darker. Install an ffmpeg built with libzimg to match.\n",
);
}
return HDR_TO_SDR_VIDEOTOOLBOX_TRANSFORM_KEY;
}
function sdrToHdrTransformKey(transfer: HdrTransfer): string {
return `sdr2hdr-${transfer}`;
}
@@ -754,7 +776,40 @@ export function parseImageElements(html: string): ImageElement[] {
return images;
}
/** Chrome plays untagged VP9 and AV1 as BT.601, H.264 and VP8 as BT.709 from 720 lines (assumed for the rest). */
/** zscale reads each frame's own tags; set only those the first frame lacks, from ffprobe or BT.2020. */
function hdrFrameColourTags(metadata: VideoMetadata, frame: Partial<VideoColorSpace>): string[] {
const known = (value: string | undefined) =>
value && value !== "unknown" && value !== "reserved" ? value : undefined;
const probed = metadata.colorSpace;
const fill = (key: keyof VideoColorSpace, fallback?: string) =>
known(frame[key]) ? undefined : (known(probed?.[key]) ?? fallback);
const tags = [
["colorspace", fill("colorSpace", "bt2020nc")],
["color_primaries", fill("colorPrimaries", "bt2020")],
["color_trc", fill("colorTransfer")],
].filter(([, value]) => value);
return tags.length
? [`setparams=${tags.map(([key, value]) => `${key}=${value}`).join(":")}`]
: [];
}
/** The first shown frame's tags, read by ffmpeg, which decodes past the edit-list pre-roll ffprobe counts. */
async function readFirstFrameColour(
videoPath: string,
signal?: AbortSignal,
): Promise<Partial<VideoColorSpace>> {
const result = await runFfmpeg(
[
...["-hide_banner", "-nostats", "-i", videoPath, "-map", "0:v:0", "-frames:v", "1"],
...["-vf", "showinfo", "-f", "null", "-"],
],
{ signal },
);
const tags = / color_space:(\S+) color_primaries:(\S+) color_trc:(\S+)/.exec(result.stderr);
return tags ? { colorSpace: tags[1], colorPrimaries: tags[2], colorTransfer: tags[3] } : {};
}
/** Chrome plays untagged VP9 and AV1 as BT.601, H.264 and VP8 as BT.709 from 720 coded lines (assumed for the rest). */
const CHROME_BT601_UNTAGGED_CODECS = new Set(["vp9", "av1"]);
function chromeGuessForUntaggedMatrix(metadata: VideoMetadata): string[] {
@@ -866,16 +921,18 @@ export async function extractVideoFramesRange(
const framePattern = `${FRAME_FILENAME_PREFIX}%05d.${format}`;
const outputPattern = join(videoOutputDir, framePattern);
// Forced-SDR extraction tone-maps HDR before the intermediate frames reach Chrome.
// macOS: VideoToolbox hardware decoder does HDR→SDR natively on Apple Silicon.
// Linux: use the same zscale/tonemap policy as Studio proxies.
// Forced-SDR extraction tone-maps HDR with zscale, the Studio proxy policy. A macOS ffmpeg
// without zscale falls back to VideoToolbox, whose frames stay HDR and are tone-mapped by
// Chrome instead (darker), and the render warns once.
const isHdr = isHdrColorSpaceUtil(metadata.colorSpace);
const isMacOS = process.platform === "darwin";
const toneMappedToSdr = options.toneMapHdrToSdr === true && isHdr && (await toneMapsWithZscale());
const decodeWithVideoToolbox = isHdr && isMacOS && !toneMappedToSdr;
const sampleCfrAtOutputFps = !options.finalFrameOnly && !metadata.isVFR;
const args: string[] = [];
if (isHdr && isMacOS) {
if (decodeWithVideoToolbox) {
args.push("-hwaccel", "videotoolbox");
}
// Always force the alpha-aware decoder on codecs that can carry alpha. The
@@ -903,8 +960,7 @@ export async function extractVideoFramesRange(
}
const vfFilters: string[] = [];
if (isHdr && isMacOS) {
// VideoToolbox tone-maps during decode; force output to bt709 SDR format
if (decodeWithVideoToolbox) {
vfFilters.push("format=nv12");
}
if (sampleCfrAtOutputFps) {
@@ -926,11 +982,15 @@ export async function extractVideoFramesRange(
// remap only applies to SDR sources, nv12 only to HDR sources).
vfFilters.push(SDR_TO_HDR_COLORSPACE_FILTER);
}
if (options.toneMapHdrToSdr && isHdr && !isMacOS) {
vfFilters.push(HDR_TO_SDR_TONEMAP_FILTER);
if (toneMappedToSdr) {
const frameColour = await readFirstFrameColour(videoPath, signal);
vfFilters.push(...hdrFrameColourTags(metadata, frameColour), HDR_TO_SDR_TONEMAP_FILTER);
}
if (!isHdr && !options.sdrToHdrTransfer) {
vfFilters.push(...chromeGuessForUntaggedMatrix(metadata), SDR_CANVAS_PASSTHROUGH_FILTER);
if (toneMappedToSdr || (!isHdr && !options.sdrToHdrTransfer)) {
vfFilters.push(
...(isHdr ? [] : chromeGuessForUntaggedMatrix(metadata)),
SDR_CANVAS_PASSTHROUGH_FILTER,
);
if (format === "jpg") vfFilters.push(SDR_JPEG_AS_BT601_FULL_RANGE_FILTER);
}
const encodeArgs = ["-q:v", format === "jpg" ? String(Math.ceil((100 - quality) / 3)) : "0"];
@@ -1422,7 +1482,7 @@ type PreparedExtraction = {
finalFrameOnly: boolean;
format: CacheFrameFormat;
sdrToHdrTransfer?: HdrTransfer;
toneMapHdrToSdr: boolean;
hdrToSdrTransformKey?: string;
dedupeKey: string;
};
@@ -1486,7 +1546,7 @@ function supersetGroupingKey(work: PreparedExtraction, fps: number): string {
String(fps),
work.format,
work.sdrToHdrTransfer ?? "",
work.toneMapHdrToSdr ? HDR_TO_SDR_TRANSFORM_KEY : "",
work.hdrToSdrTransformKey ?? "",
work.finalFrameOnly ? "final" : "range",
].join("\0");
}
@@ -1919,7 +1979,7 @@ export async function extractAllVideoFrames(
...options,
format: work.format,
sdrToHdrTransfer: work.sdrToHdrTransfer,
toneMapHdrToSdr: work.toneMapHdrToSdr,
toneMapHdrToSdr: work.hdrToSdrTransformKey !== undefined,
finalFrameOnly: work.finalFrameOnly,
};
}
@@ -1941,7 +2001,7 @@ export async function extractAllVideoFrames(
if (!keyInput) return { work };
const transformParts = [
work.sdrToHdrTransfer ? sdrToHdrTransformKey(work.sdrToHdrTransfer) : undefined,
work.toneMapHdrToSdr ? HDR_TO_SDR_TRANSFORM_KEY : undefined,
work.hdrToSdrTransformKey,
work.finalFrameOnly ? "final-frame" : undefined,
].filter((part): part is string => part !== undefined);
const transform = transformParts.length > 0 ? transformParts.join("+") : undefined;
@@ -2161,10 +2221,12 @@ export async function extractAllVideoFrames(
const format = resolveFrameFormat(metadata, options.format);
const sdrToHdrTransfer = sdrToHdrTransfers[index];
const toneMapHdrToSdr =
options.toneMapHdrToSdr === true && isHdrColorSpaceUtil(metadata.colorSpace);
const hdrToSdrTransform =
options.toneMapHdrToSdr === true && isHdrColorSpaceUtil(metadata.colorSpace)
? await hdrToSdrTransformKey()
: undefined;
const finalFrameOnly = window.finalFrameOnly === true;
const dedupeKey = `${videoPath}\0${extractionMediaStart}\0${videoDuration}\0${fpsKey}\0${format}\0${sdrToHdrTransfer ?? ""}\0${toneMapHdrToSdr ? HDR_TO_SDR_TRANSFORM_KEY : ""}\0${finalFrameOnly ? "final" : "range"}`;
const dedupeKey = `${videoPath}\0${extractionMediaStart}\0${videoDuration}\0${fpsKey}\0${format}\0${sdrToHdrTransfer ?? ""}\0${hdrToSdrTransform ?? ""}\0${finalFrameOnly ? "final" : "range"}`;
return {
work: {
@@ -2177,7 +2239,7 @@ export async function extractAllVideoFrames(
finalFrameOnly,
format,
sdrToHdrTransfer,
toneMapHdrToSdr,
hdrToSdrTransformKey: hdrToSdrTransform,
dedupeKey,
},
};
@@ -116,6 +116,25 @@ describe("isPsnrFilterAvailable", () => {
await expect(isPsnrFilterAvailable()).resolves.toBe(false);
});
it("answers any filter name from one shared `ffmpeg -filters` probe", async () => {
const { execFile, calls } = createExecFileSpy({
kind: "ok",
stdout: [
"Filters:",
" T.. psnr VV->V Calculate the PSNR between two video streams.",
" .SC zscale V->V Apply resizing, colorspace and bit depth conversion.",
].join("\n"),
});
vi.doMock("node:child_process", () => ({ execFile }));
const { isFfmpegFilterAvailable, isPsnrFilterAvailable } =
await import("./psnrFilterAvailability.js");
await expect(isFfmpegFilterAvailable("zscale")).resolves.toBe(true);
await expect(isFfmpegFilterAvailable("libplacebo")).resolves.toBe(false);
await expect(isPsnrFilterAvailable()).resolves.toBe(true);
expect(calls).toHaveLength(1);
});
it("returns false when the ffmpeg binary is missing (ENOENT from execFile)", async () => {
const { execFile } = createExecFileSpy({ kind: "enoent" });
vi.doMock("node:child_process", () => ({ execFile }));
@@ -15,7 +15,7 @@ import { getFfmpegBinary } from "./ffmpegBinaries.js";
* not change across renders within the same CLI invocation, and re-probing
* per session would burn ~50-100ms of subprocess spawn per capture worker.
*/
let cached: Promise<boolean> | null = null;
let cachedFilterList: Promise<string | null> | null = null;
/**
* Returns true when the resident ffmpeg exposes the `psnr` filter. False on
@@ -27,32 +27,34 @@ let cached: Promise<boolean> | null = null;
* from tests that need to re-probe.
*/
export function isPsnrFilterAvailable(): Promise<boolean> {
if (cached === null) cached = probe();
return cached;
return isFfmpegFilterAvailable("psnr");
}
export async function isFfmpegFilterAvailable(name: string): Promise<boolean> {
if (cachedFilterList === null) cachedFilterList = listFilters();
const stdout = await cachedFilterList;
// `-filters` lists one filter per line; a whole-word match ignores longer names
// and banner prose that mentions the filter.
return stdout !== null && new RegExp(`(^|\\s)${name}(\\s|$)`, "m").test(stdout);
}
/** Test-only: drop the memoized probe result. */
export function resetPsnrFilterAvailabilityCache(): void {
cached = null;
cachedFilterList = null;
}
async function probe(): Promise<boolean> {
async function listFilters(): Promise<string | null> {
// Match `psnr.ts`: promisify lazily so a partial `node:child_process` mock
// (test that omits `execFile`) doesn't crash at module load — it fails at
// call time instead, and the try/catch below converts that to `false`.
// call time instead, and the try/catch below reports no filters at all.
const execFileP = promisify(execFile);
try {
const { stdout } = await execFileP(getFfmpegBinary(), ["-hide_banner", "-filters"], {
maxBuffer: 4 * 1024 * 1024,
timeout: 5_000,
});
// ffmpeg's `-filters` output lists one filter per line, e.g.
// " T.. psnr VV->V Calculate the PSNR between two video streams."
// A whole-word match keeps `multi-psnr` (hypothetical) from masquerading
// as the real filter, and dodges the banner text that mentions PSNR in
// prose on some builds.
return /(^|\s)psnr(\s|$)/m.test(stdout);
return stdout;
} catch {
return false;
return null;
}
}