mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
+24
-2
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user