From 313792408d641f702ea7624fd6abda10c957aab3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 27 Oct 2026 23:09:15 +0000 Subject: [PATCH] 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 --- .../src/services/videoFrameExtractor.test.ts | 16 ++++++++++++ .../src/services/videoFrameExtractor.ts | 2 +- .../producer/src/services/render/hdrMode.ts | 10 ++++++- .../extractVideosStage.timelineBound.test.ts | 26 +++++++++++++++++-- .../render/stages/extractVideosStage.ts | 3 ++- 5 files changed, 52 insertions(+), 5 deletions(-) diff --git a/packages/engine/src/services/videoFrameExtractor.test.ts b/packages/engine/src/services/videoFrameExtractor.test.ts index f5210c565b..dd1e359769 100644 --- a/packages/engine/src/services/videoFrameExtractor.test.ts +++ b/packages/engine/src/services/videoFrameExtractor.test.ts @@ -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); diff --git a/packages/engine/src/services/videoFrameExtractor.ts b/packages/engine/src/services/videoFrameExtractor.ts index 5f740d1b3e..1e8c970993 100644 --- a/packages/engine/src/services/videoFrameExtractor.ts +++ b/packages/engine/src/services/videoFrameExtractor.ts @@ -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(); - 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 diff --git a/packages/producer/src/services/render/hdrMode.ts b/packages/producer/src/services/render/hdrMode.ts index 289d92191c..7e2d4c6f0e 100644 --- a/packages/producer/src/services/render/hdrMode.ts +++ b/packages/producer/src/services/render/hdrMode.ts @@ -107,6 +107,14 @@ function logHdrResolution( ); } +function formatCarriesHdr(format: NonNullable): boolean { + return format === "mp4"; +} + +export function rendersSdrOnly(config: Pick): 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; } diff --git a/packages/producer/src/services/render/stages/extractVideosStage.timelineBound.test.ts b/packages/producer/src/services/render/stages/extractVideosStage.timelineBound.test.ts index e4eaf73019..b7d4c616b2 100644 --- a/packages/producer/src/services/render/stages/extractVideosStage.timelineBound.test.ts +++ b/packages/producer/src/services/render/stages/extractVideosStage.timelineBound.test.ts @@ -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 { 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 => ({ diff --git a/packages/producer/src/services/render/stages/extractVideosStage.ts b/packages/producer/src/services/render/stages/extractVideosStage.ts index 3f52b1fb29..1a883cc38e 100644 --- a/packages/producer/src/services/render/stages/extractVideosStage.ts +++ b/packages/producer/src/services/render/stages/extractVideosStage.ts @@ -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",