diff --git a/packages/engine/src/services/videoFrameExtractor.test.ts b/packages/engine/src/services/videoFrameExtractor.test.ts index 374f983a34..e4c8635ded 100644 --- a/packages/engine/src/services/videoFrameExtractor.test.ts +++ b/packages/engine/src/services/videoFrameExtractor.test.ts @@ -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-" 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 { 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", diff --git a/packages/engine/src/services/videoFrameExtractor.ts b/packages/engine/src/services/videoFrameExtractor.ts index 30d84fab70..be8fe4973c 100644 --- a/packages/engine/src/services/videoFrameExtractor.ts +++ b/packages/engine/src/services/videoFrameExtractor.ts @@ -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 { + return process.platform !== "darwin" || isFfmpegFilterAvailable("zscale"); +} + +async function hdrToSdrTransformKey(): Promise { + 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): 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> { + 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, }, }; diff --git a/packages/engine/src/utils/psnrFilterAvailability.test.ts b/packages/engine/src/utils/psnrFilterAvailability.test.ts index 53fb9ae0f4..d8a45cdcec 100644 --- a/packages/engine/src/utils/psnrFilterAvailability.test.ts +++ b/packages/engine/src/utils/psnrFilterAvailability.test.ts @@ -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 })); diff --git a/packages/engine/src/utils/psnrFilterAvailability.ts b/packages/engine/src/utils/psnrFilterAvailability.ts index c252b45664..def9e1c5ba 100644 --- a/packages/engine/src/utils/psnrFilterAvailability.ts +++ b/packages/engine/src/utils/psnrFilterAvailability.ts @@ -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 | null = null; +let cachedFilterList: Promise | null = null; /** * Returns true when the resident ffmpeg exposes the `psnr` filter. False on @@ -27,32 +27,34 @@ let cached: Promise | null = null; * from tests that need to re-probe. */ export function isPsnrFilterAvailable(): Promise { - if (cached === null) cached = probe(); - return cached; + return isFfmpegFilterAvailable("psnr"); +} + +export async function isFfmpegFilterAvailable(name: string): Promise { + 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 { +async function listFilters(): Promise { // 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; } }