fix(producer): HDR sources are tone-mapped for formats that cannot carry HDR (#6597)

* fix(producer): HDR sources are tone-mapped for formats that cannot carry HDR

* refactor(producer): one owner for which output formats can carry HDR

* refactor(producer): drop comments the helper names already carry

* fix(engine): SDR clips are not lifted to HDR when the render tone-maps to SDR
This commit is contained in:
Miguel Ángel
2026-10-27 23:09:15 +00:00
committed by GitHub
parent 3b2058a2b7
commit 313792408d
5 changed files with 52 additions and 5 deletions
@@ -3283,6 +3283,22 @@ describe.skipIf(!HAS_FFMPEG)("extractAllVideoFrames on a VFR source", () => {
).toBe(false);
}, 60_000);
it("leaves SDR clips alone in a mixed timeline that tone-maps to SDR", async () => {
const SDR = await synthCfrClip("sdr-tonemap-peer.mp4", 1);
const HDR = await synthHdrTaggedClip("hdr-tonemap-peer.mp4", 1);
const outputDir = join(FIXTURE_DIR, "out-hdr-tonemap-mixed");
mkdirSync(outputDir, { recursive: true });
const result = await extractAllVideoFrames(
[cfrClipElement("sdr", SDR, 1), { ...cfrClipElement("hdr", HDR, 1), start: 1, end: 2 }],
FIXTURE_DIR,
{ fps: 30, outputDir, toneMapHdrToSdr: true },
);
expect(result.errors).toEqual([]);
expect(result.phaseBreakdown.hdrPreflightCount).toBe(0);
}, 60_000);
it("keeps a finite SDR past-EOF slot in a mixed HDR timeline", async () => {
const SDR_SHORT = await synthCfrClip("sdr-past-eof.mp4", 1);
const HDR_SHORT = await synthHdrTaggedClip("hdr-past-eof-peer.mp4", 1);
@@ -2141,7 +2141,7 @@ export async function extractAllVideoFrames(
// Phase 3 still calls `extractVideoFramesRange` on the same past-EOF
// mediaStart, surfacing a second raw FFmpeg error for the same clip.
const hdrSkippedIndices = new Set<number>();
if (hdrInfo.hasHdr && hdrInfo.dominantTransfer) {
if (!options.toneMapHdrToSdr && 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
@@ -107,6 +107,14 @@ function logHdrResolution(
);
}
function formatCarriesHdr(format: NonNullable<RenderConfig["format"]>): boolean {
return format === "mp4";
}
export function rendersSdrOnly(config: Pick<RenderConfig, "hdrMode" | "format">): boolean {
return config.hdrMode === "force-sdr" || !formatCarriesHdr(config.format ?? "mp4");
}
export function resolveEffectiveHdrMode(input: HdrModeInput): EffectiveHdr {
const hdrMode = input.hdrMode ?? "auto";
const videoColorSpaces = (input.extractionResult?.extracted ?? []).map(
@@ -131,7 +139,7 @@ export function resolveEffectiveHdrMode(input: HdrModeInput): EffectiveHdr {
effectiveHdr = { transfer: info.dominantTransfer };
}
if (effectiveHdr && input.outputFormat !== "mp4") {
if (effectiveHdr && !formatCarriesHdr(input.outputFormat)) {
input.log.warn(formatDowngradeWarning(input.outputFormat, forcedHdrWithoutSources));
effectiveHdr = undefined;
}
@@ -82,7 +82,13 @@ import { runExtractVideosStage } from "./extractVideosStage.js";
async function runStage(
compositionDuration: number,
materializeSymlinks: boolean,
options: { source?: string; log?: ProducerLogger; extraVideos?: VideoElement[] } = {},
options: {
source?: string;
log?: ProducerLogger;
extraVideos?: VideoElement[];
hdrMode?: "auto" | "force-hdr" | "force-sdr";
format?: "mp4" | "webm" | "mov";
} = {},
): Promise<VideoElement[]> {
const composition = {
duration: compositionDuration,
@@ -109,7 +115,8 @@ async function runStage(
job: createRenderJob({
fps: { num: 30, den: 1 },
quality: "standard",
hdrMode: "force-sdr",
hdrMode: options.hdrMode ?? "force-sdr",
format: options.format,
}),
cfg: resolveConfig(),
log: options.log,
@@ -154,6 +161,21 @@ describe.each([
expect(toneMapHdrToSdrCalls).toEqual([true]);
});
it.each([
["auto", "mp4", false],
["auto", "webm", true],
["force-hdr", "mov", true],
] as const)(
"tone maps HDR sources in %s mode to %s output: %s",
async (hdrMode, format, want) => {
toneMapHdrToSdrCalls.splice(0);
await runStage(2, materializeSymlinks, { hdrMode, format });
expect(toneMapHdrToSdrCalls).toEqual([want]);
},
);
it("drops clips starting at or after the end before extraction, keeping natural-length ones", async () => {
requestedVideoIds.splice(0);
const outside = (id: string, start: number, end: number): VideoElement => ({
@@ -65,6 +65,7 @@ import { REMOTE_MEDIA_SUBDIR } from "../../htmlCompiler.js";
import { resolveRenderFpsConfig } from "../../fileServer.js";
import type { ProducerLogger } from "../../../logger.js";
import { encoderFailureError } from "../encoderInterruption.js";
import { rendersSdrOnly } from "../hdrMode.js";
import {
compareExtractionFailureGroups,
extractionFailureGroupIdentityKey,
@@ -513,7 +514,7 @@ export async function runExtractVideosStage(
fps: job.config.fps,
outputDir: join(compiledDir, "__hyperframes_video_frames"),
format: job.config.videoFrameFormat ?? "auto",
toneMapHdrToSdr: job.config.hdrMode === "force-sdr",
toneMapHdrToSdr: rendersSdrOnly(job.config),
timelineEnd: composition.duration,
maxTransientRetries: extractionPolicy.maxTransientRetries,
collectProbeFailures: extractionPolicy.failureMode === "enforce",