fix(producer): auto renders with color-graded media stay SDR instead of promoting to HDR (#6671)

* fix(producer): auto renders with color-graded media stay SDR instead of promoting to HDR

* fix(producer): keep auto renders SDR when a graded canvas is present
This commit is contained in:
Miguel Ángel
2026-10-31 16:16:06 +00:00
committed by GitHub
parent 168429d514
commit 60f711686c
16 changed files with 199 additions and 19 deletions
@@ -38,6 +38,20 @@ function bundledMedia(videos: string[], images: string[]) {
}
describe("inspectHdrAutoPromotion", () => {
it("reports no promotion when graded media keeps the render SDR, without probing", async () => {
const extractMetadata = vi.fn(async () => metadata(HDR));
const media = bundledMedia(["assets/source-hdr.mp4"], []);
await expect(
inspectHdrAutoPromotion(
PROJECT,
{ ...media, images: [{ src: "assets/look.png", graded: true }] },
extractMetadata,
),
).resolves.toBeNull();
expect(extractMetadata).not.toHaveBeenCalled();
});
it("reports the local HDR asset that promotes automatic output", async () => {
const assetPath = resolveProjectRelativeSrc("assets/source-hdr.mp4", PROJECT.dir, PROJECT.dir);
const extractMetadata = vi.fn(async () => metadata(HDR));
+2
View File
@@ -1,6 +1,7 @@
import {
extractMediaMetadata,
findHdrAutoPromotion,
gradingKeepsSdr,
type HdrAutoPromotion,
} from "@hyperframes/engine";
import type { ProjectDir } from "./project.js";
@@ -13,6 +14,7 @@ export async function inspectHdrAutoPromotion(
media: Pick<ProjectMedia, "videos" | "images">,
extractMetadata: ExtractMetadata = extractMediaMetadata,
): Promise<HdrAutoPromotion | null> {
if (gradingKeepsSdr("auto", media)) return null;
const assets: Array<{
asset: string;
colorSpace: Awaited<ReturnType<typeof extractMediaMetadata>>["colorSpace"];
+3 -2
View File
@@ -7,8 +7,9 @@ import { loadProducer } from "./producer.js";
/** The render's own media list for a project: what it extracts, probes and mixes. */
export interface ProjectMedia {
videos: readonly Pick<VideoElement, "src" | "hasAudio">[];
images: readonly { src: string }[];
videos: readonly Pick<VideoElement, "src" | "hasAudio" | "graded">[];
images: readonly { src: string; graded?: boolean }[];
gradedCanvas?: boolean;
audios: readonly AudioElement[];
/** The bundled document the lists were read from. */
html: string;
+2
View File
@@ -221,6 +221,7 @@ export {
// ── Media processing ───────────────────────────────────────────────────────────
export {
parseVideoElements,
hasGradedCanvas,
parseImageElements,
extractVideoFramesRange,
extractAllVideoFrames,
@@ -487,6 +488,7 @@ export {
getHdrEncoderColorParams,
analyzeCompositionHdr,
findHdrAutoPromotion,
gradingKeepsSdr,
formatHdrAutoPromotionWarning,
sanitizeHdrAutoPromotionAsset,
DEFAULT_HDR10_MASTERING,
@@ -16,6 +16,7 @@ import { tmpdir } from "node:os";
import { spawnSync } from "node:child_process";
import {
parseVideoElements,
hasGradedCanvas,
parseImageElements,
extractAllVideoFrames,
extractVideoFramesRange,
@@ -738,6 +739,25 @@ describe("resolveFrameFormat", () => {
});
describe("parseVideoElements", () => {
it("marks a graded video and image, and only those", () => {
const html = `<video id="g" src="g.mp4" data-color-grading='{"preset":"clean-studio"}'></video>
<video id="p" src="p.mp4"></video>
<img id="gi" src="gi.png" data-color-grading='{"adjust":{"exposure":0.2}}'>
<img id="pi" src="pi.png">`;
expect(parseVideoElements(html).map(({ id, graded }) => [id, graded])).toEqual([
["g", true],
["p", undefined],
]);
expect(parseImageElements(html).map(({ id, graded }) => [id, graded])).toEqual([
["gi", true],
["pi", undefined],
]);
expect(hasGradedCanvas(html)).toBe(false);
expect(
hasGradedCanvas(`<canvas data-color-grading='{"preset":"clean-studio"}'></canvas>`),
).toBe(true);
});
it.each([
{
label: "valueless playback-start",
@@ -12,6 +12,7 @@ import { basename, join } from "path";
import { cpus } from "os";
import { createConcurrencyLimit } from "../utils/concurrencyLimit.js";
import { parseHTML } from "linkedom";
import { HF_COLOR_GRADING_ATTR } from "@hyperframes/core";
import { isPathInside } from "@hyperframes/parsers/asset-paths";
import { resolveProjectRelativeSrc } from "@hyperframes/parsers/asset-resolution";
import { alphaDecoderFor } from "@hyperframes/parsers/media-alpha";
@@ -104,6 +105,7 @@ export interface VideoElement {
loop: boolean;
hasAudio: boolean;
hidden?: boolean;
graded?: boolean;
}
export interface ExtractedFrames {
@@ -844,6 +846,7 @@ export function parseVideoElements(html: string): VideoElement[] {
(isAudibleVideoElement(el) && isMemberGroupHidden(groupsById, el))
? { hidden: true }
: {}),
...(el.hasAttribute(HF_COLOR_GRADING_ATTR) ? { graded: true } : {}),
});
}
@@ -855,6 +858,14 @@ export interface ImageElement {
src: string;
start: number;
end: number;
graded?: boolean;
}
export function hasGradedCanvas(html: string): boolean {
return (
parseHTML(unwrapTemplate(html)).document.querySelector(`canvas[${HF_COLOR_GRADING_ATTR}]`) !==
null
);
}
export function parseImageElements(html: string): ImageElement[] {
@@ -894,7 +905,13 @@ export function parseImageElements(html: string): ImageElement[] {
end = Infinity;
}
images.push({ id, src, start, end });
images.push({
id,
src,
start,
end,
...(el.hasAttribute(HF_COLOR_GRADING_ATTR) ? { graded: true } : {}),
});
}
return images;
+12
View File
@@ -5,6 +5,7 @@ import {
getHdrEncoderColorParams,
analyzeCompositionHdr,
findHdrAutoPromotion,
gradingKeepsSdr,
formatHdrAutoPromotionWarning,
sanitizeHdrAutoPromotionAsset,
DEFAULT_HDR10_MASTERING,
@@ -224,3 +225,14 @@ describe("HDR auto-promotion reporting", () => {
);
});
});
describe("gradingKeepsSdr", () => {
const graded = { videos: [{}], images: [{ graded: true }] };
it("keeps auto renders with graded media SDR, never a forced mode or ungraded media", () => {
expect(gradingKeepsSdr(undefined, graded)).toBe(true);
expect(gradingKeepsSdr("auto", graded)).toBe(true);
expect(gradingKeepsSdr("force-hdr", graded)).toBe(false);
expect(gradingKeepsSdr("auto", { videos: [{}], images: [] })).toBe(false);
expect(gradingKeepsSdr("auto", { videos: [], images: [], gradedCanvas: true })).toBe(true);
});
});
+16
View File
@@ -142,6 +142,22 @@ export function findHdrAutoPromotion(
: null;
}
/** Grading is SDR-only, so in auto mode a composition with graded media renders SDR, as `--sdr` does. */
export function gradingKeepsSdr(
hdrMode: "auto" | "force-hdr" | "force-sdr" | undefined,
media: {
videos: readonly { graded?: boolean }[];
images: readonly { graded?: boolean }[];
gradedCanvas?: boolean;
},
): boolean {
return (
(hdrMode ?? "auto") === "auto" &&
(media.gradedCanvas === true ||
[...media.videos, ...media.images].some(({ graded }) => graded === true))
);
}
export function formatHdrAutoPromotionWarning(promotion: HdrAutoPromotion): string {
return `[Render] HDR auto-promotion triggered by "${promotion.triggeringAsset}", output: ${promotion.output.colorSpace} / ${promotion.output.codec}`;
}
@@ -118,6 +118,7 @@ export interface CompiledComposition {
audios: AudioElement[];
images: ImageElement[];
droppedMediaIds: Set<string>;
gradedCanvas: boolean;
masterFxChain?: string;
unresolvedCompositions: UnresolvedElement[];
/** Assets that resolve outside projectDir. Keys are the path used in HTML, values are absolute filesystem paths. */
@@ -2236,7 +2237,7 @@ export async function compileForRender(
// within one composition file: two scenes declaring `<video id="clip">` — or
// two bare `<video>`s, both auto-numbered `hf-video-0` — collapsed into one
// entry and injected frames onto whichever element came first. See #3340.
const { videos, audios, images, droppedMediaIds } = collectRenderMedia(html);
const { videos, audios, images, droppedMediaIds, gradedCanvas } = collectRenderMedia(html);
const masterFxChain = parseMasterFxChain(html);
// Advisory video checks (sparse keyframes, VFR). Fire-and-forget — these spawn
@@ -2294,6 +2295,7 @@ export async function compileForRender(
audios,
images,
droppedMediaIds,
gradedCanvas,
...(masterFxChain ? { masterFxChain } : {}),
unresolvedCompositions,
externalAssets,
@@ -7,7 +7,14 @@
import { describe, expect, it, vi } from "vitest";
import type { ExtractionResult, VideoColorSpace } from "@hyperframes/engine";
import { findRenderHdrAutoPromotionTrigger, resolveEffectiveHdrMode } from "./hdrMode.js";
import {
findRenderHdrAutoPromotionTrigger,
rendersSdrOnly,
resolveEffectiveHdrMode,
} from "./hdrMode.js";
const NO_MEDIA = { videos: [], images: [] };
const GRADED_VIDEO = { videos: [{ graded: true }, {}], images: [] };
function makeLog() {
return { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() };
@@ -37,9 +44,41 @@ const HDR_PQ: VideoColorSpace = {
};
describe("resolveEffectiveHdrMode", () => {
it("keeps an auto render with graded media SDR when an HDR source would promote it, and says why", () => {
const log = makeLog();
const result = resolveEffectiveHdrMode({
media: GRADED_VIDEO,
hdrMode: "auto",
outputFormat: "mp4",
extractionResult: extractionWith([HDR_PQ]),
imageColorSpaces: [],
log,
});
expect(result).toBeUndefined();
expect(log.warn).toHaveBeenCalledWith(expect.stringContaining("color grading is SDR-only"));
expect(log.warn).not.toHaveBeenCalledWith(expect.stringContaining("auto-promotion"));
});
it("keeps --hdr with graded media HDR and warns that the grades are SDR", () => {
const log = makeLog();
const result = resolveEffectiveHdrMode({
media: { videos: [], images: [{ graded: true }] },
hdrMode: "force-hdr",
outputFormat: "mp4",
extractionResult: extractionWith([HDR_PQ]),
imageColorSpaces: [],
log,
});
expect(result).toEqual({ transfer: "pq" });
expect(log.warn).toHaveBeenCalledWith(
expect.stringContaining("graded elements are graded in SDR"),
);
});
it("returns undefined when force-sdr is set, regardless of HDR sources", () => {
const log = makeLog();
const result = resolveEffectiveHdrMode({
media: NO_MEDIA,
hdrMode: "force-sdr",
outputFormat: "mp4",
extractionResult: extractionWith([HDR_PQ]),
@@ -53,6 +92,7 @@ describe("resolveEffectiveHdrMode", () => {
it("auto-detects HDR from video sources when format=mp4", () => {
const log = makeLog();
const result = resolveEffectiveHdrMode({
media: NO_MEDIA,
hdrMode: undefined,
outputFormat: "mp4",
extractionResult: extractionWith([HDR_PQ]),
@@ -71,6 +111,7 @@ describe("resolveEffectiveHdrMode", () => {
it("auto-detects SDR with no HDR sources", () => {
const log = makeLog();
const result = resolveEffectiveHdrMode({
media: NO_MEDIA,
hdrMode: "auto",
outputFormat: "mp4",
extractionResult: extractionWith([]),
@@ -84,6 +125,7 @@ describe("resolveEffectiveHdrMode", () => {
it("force-hdr without sources falls back to HLG and warns", () => {
const log = makeLog();
const result = resolveEffectiveHdrMode({
media: NO_MEDIA,
hdrMode: "force-hdr",
outputFormat: "mp4",
extractionResult: extractionWith([]),
@@ -99,6 +141,7 @@ describe("resolveEffectiveHdrMode", () => {
it("force-hdr uses the dominant probed transfer when sources are HDR", () => {
const log = makeLog();
const result = resolveEffectiveHdrMode({
media: NO_MEDIA,
hdrMode: "force-hdr",
outputFormat: "mp4",
extractionResult: extractionWith([HDR_PQ]),
@@ -113,6 +156,7 @@ describe("resolveEffectiveHdrMode", () => {
for (const fmt of ["webm", "mov", "png-sequence"] as const) {
const log = makeLog();
const result = resolveEffectiveHdrMode({
media: NO_MEDIA,
hdrMode: "auto",
outputFormat: fmt,
extractionResult: extractionWith([HDR_PQ]),
@@ -135,6 +179,7 @@ describe("resolveEffectiveHdrMode", () => {
it("downgrades auto-detected HDR on hls with an SDR-only reason, not the alpha one", () => {
const log = makeLog();
const result = resolveEffectiveHdrMode({
media: NO_MEDIA,
hdrMode: "auto",
outputFormat: "hls",
extractionResult: extractionWith([HDR_PQ]),
@@ -154,6 +199,7 @@ describe("resolveEffectiveHdrMode", () => {
it("force-hdr without sources + non-mp4 format: still downgrades, two warns fire", () => {
const log = makeLog();
const result = resolveEffectiveHdrMode({
media: NO_MEDIA,
hdrMode: "force-hdr",
outputFormat: "webm",
extractionResult: extractionWith([]),
@@ -202,3 +248,11 @@ describe("findRenderHdrAutoPromotionTrigger", () => {
).toBe("assets/hero.png");
});
});
describe("rendersSdrOnly", () => {
it("is true for an auto render with graded media, so HDR sources are tone-mapped before the grade", () => {
expect(rendersSdrOnly({ hdrMode: undefined, format: "mp4" }, GRADED_VIDEO)).toBe(true);
expect(rendersSdrOnly({ hdrMode: "auto", format: "mp4" }, NO_MEDIA)).toBe(false);
expect(rendersSdrOnly({ hdrMode: "force-hdr", format: "mp4" }, GRADED_VIDEO)).toBe(false);
});
});
@@ -12,6 +12,7 @@
import {
analyzeCompositionHdr,
formatHdrAutoPromotionWarning,
gradingKeepsSdr,
HDR_AUTO_PROMOTION_PIPELINE,
isHdrColorSpace,
sanitizeHdrAutoPromotionAsset,
@@ -21,8 +22,10 @@ import type { ProducerLogger } from "../../logger.js";
import type { RenderConfig } from "../renderOrchestrator.js";
type EffectiveHdr = { transfer: HdrTransfer } | undefined;
type RenderMedia = Parameters<typeof gradingKeepsSdr>[1];
type HdrModeInput = {
hdrMode: RenderConfig["hdrMode"];
media: RenderMedia;
outputFormat: NonNullable<RenderConfig["format"]>;
extractionResult: ExtractionResult | null | undefined;
imageColorSpaces: (VideoColorSpace | null)[];
@@ -69,18 +72,41 @@ export function findRenderHdrAutoPromotionTrigger(input: {
return input.images.find(({ id }) => input.nativeHdrImageIds.has(id))?.src;
}
/** Warns when grading decided the output or will be wrong in it; true when it kept an HDR source's render SDR. */
function logGradingResolution(
input: HdrModeInput,
hdrMode: NonNullable<RenderConfig["hdrMode"]>,
effectiveHdr: EffectiveHdr,
hasHdrSource: boolean,
): boolean {
const keptSdr = hasHdrSource && gradingKeepsSdr(hdrMode, input.media);
if (keptSdr) {
input.log.warn(
"[Render] HDR source detected, but color grading is SDR-only, so rendering SDR: HDR sources are tone-mapped before the grade. Use --hdr to force HDR output (grades stay SDR).",
);
} else if (effectiveHdr && [...input.media.videos, ...input.media.images].some((m) => m.graded)) {
input.log.warn(
"[Render] HDR output with graded media: color grading is SDR-only, so graded elements are graded in SDR and placed in the HDR output.",
);
}
return keptSdr;
}
function logHdrResolution(
input: HdrModeInput,
hdrMode: NonNullable<RenderConfig["hdrMode"]>,
effectiveHdr: EffectiveHdr,
forcedHdrWithoutSources: boolean,
hasHdrSource: boolean,
): void {
const keptSdrForGrading = logGradingResolution(input, hdrMode, effectiveHdr, hasHdrSource);
if (forcedHdrWithoutSources) {
input.log.warn(
"[Render] HDR forced by --hdr flag, but no HDR sources were detected, defaulting to HLG. SDR-only compositions may look perceptually wrong on HDR displays.",
);
}
if (!effectiveHdr) {
if (keptSdrForGrading) return;
input.log.info(
hdrMode === "force-sdr"
? "[Render] SDR forced by --sdr flag"
@@ -111,8 +137,15 @@ 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 rendersSdrOnly(
config: Pick<RenderConfig, "hdrMode" | "format">,
media: RenderMedia,
): boolean {
return (
config.hdrMode === "force-sdr" ||
gradingKeepsSdr(config.hdrMode, media) ||
!formatCarriesHdr(config.format ?? "mp4")
);
}
export function resolveEffectiveHdrMode(input: HdrModeInput): EffectiveHdr {
@@ -126,7 +159,7 @@ export function resolveEffectiveHdrMode(input: HdrModeInput): EffectiveHdr {
let effectiveHdr: EffectiveHdr;
let forcedHdrWithoutSources = false;
if (hdrMode === "force-sdr") {
if (hdrMode === "force-sdr" || gradingKeepsSdr(hdrMode, input.media)) {
effectiveHdr = undefined;
} else if (hdrMode === "force-hdr") {
if (info?.hasHdr && info.dominantTransfer) {
@@ -144,7 +177,7 @@ export function resolveEffectiveHdrMode(input: HdrModeInput): EffectiveHdr {
effectiveHdr = undefined;
}
logHdrResolution(input, hdrMode, effectiveHdr, forcedHdrWithoutSources);
logHdrResolution(input, hdrMode, effectiveHdr, forcedHdrWithoutSources, info?.hasHdr === true);
return effectiveHdr;
}
@@ -47,6 +47,7 @@ export interface CompositionMetadata {
videos: VideoElement[];
audios: AudioElement[];
images: ImageElement[];
gradedCanvas?: boolean;
masterFxChain?: string;
width: number;
height: number;
@@ -298,6 +298,7 @@ export async function runCompileStage(input: CompileStageInput): Promise<Compile
videos: compiled.videos,
audios: compiled.audios,
images: compiled.images,
gradedCanvas: compiled.gradedCanvas,
masterFxChain: compiled.masterFxChain,
width: compiled.width,
height: compiled.height,
@@ -16,7 +16,7 @@
* - `perfStages.videoExtractMs` is set at the same end-of-stage point.
* - `materializeExtractedFramesForCompiledDir` is still called once
* when `extractionResult.extracted` is non-empty.
* - `force-sdr` mode still skips ALL ffprobe overhead.
* - `force-sdr` mode, and auto mode with graded media, still skip ALL ffprobe overhead.
*
* New for distributed mode:
* - `materializeSymlinks` (default `false`) — when `true`, the stage
@@ -44,6 +44,7 @@ import {
detectTransfer,
extractAllVideoFrames,
extractMediaMetadata,
gradingKeepsSdr,
isVideoPastTimelineEnd,
isHdrColorSpace,
rebaseVideoToWindow,
@@ -418,12 +419,14 @@ export async function runExtractVideosStage(
// Probe ORIGINAL color spaces before extraction (which may convert SDR→HDR).
// This is needed to identify which videos are natively HDR vs converted-SDR
// for the two-pass compositing path. Skipped only in force-sdr mode to
// avoid ffprobe overhead when the user has explicitly opted out.
// for the two-pass compositing path. Skipped when the render is SDR anyway:
// force-sdr, or auto mode with graded media (grading is SDR-only).
const probesHdr =
job.config.hdrMode !== "force-sdr" && !gradingKeepsSdr(job.config.hdrMode, composition);
const nativeHdrVideoIds = new Set<string>();
const videoTransfers = new Map<string, HdrTransfer>();
let hdrProbeTransientRetries = 0;
if (job.config.hdrMode !== "force-sdr" && composition.videos.length > 0) {
if (probesHdr && composition.videos.length > 0) {
log?.info("Probing video color spaces...", { videoCount: composition.videos.length });
const probeFailures = await Promise.all(
composition.videos.map(async (v) => {
@@ -473,14 +476,13 @@ export async function runExtractVideosStage(
// Probe images for HDR color spaces (16-bit PNGs tagged BT.2020 PQ/HLG).
// Mirrors the video probe loop above so image-only compositions can
// trigger HDR output without any video sources present. Skipped only in
// force-sdr mode to avoid ffprobe overhead when the user has explicitly
// opted out.
// trigger HDR output without any video sources present. Skipped when the
// video probe is.
const nativeHdrImageIds = new Set<string>();
const imageTransfers = new Map<string, HdrTransfer>();
const hdrImageSrcPaths = new Map<string, string>();
const imageColorSpaces: (VideoColorSpace | null)[] = [];
if (job.config.hdrMode !== "force-sdr" && composition.images.length > 0) {
if (probesHdr && composition.images.length > 0) {
const probed = await Promise.all(
composition.images.map(async (img) => {
// Same shared resolver as the video probe above — a percent-encoded
@@ -522,7 +524,7 @@ export async function runExtractVideosStage(
fps: job.config.fps,
outputDir: join(compiledDir, "__hyperframes_video_frames"),
format: job.config.videoFrameFormat ?? "auto",
toneMapHdrToSdr: rendersSdrOnly(job.config),
toneMapHdrToSdr: rendersSdrOnly(job.config, composition),
timelineEnd: composition.duration,
maxTransientRetries: extractionPolicy.maxTransientRetries,
collectProbeFailures: extractionPolicy.failureMode === "enforce",
@@ -26,6 +26,7 @@ import {
} from "@hyperframes/core/media-timing";
import {
parseVideoElements,
hasGradedCanvas,
parseImageElements,
parseAudioElements,
isSelfOrAncestorHidden,
@@ -211,6 +212,7 @@ export interface RenderMedia {
images: ImageElement[];
/** Ids of clips in the document that never play; the browser probe must not add them back. */
droppedMediaIds: Set<string>;
gradedCanvas: boolean;
}
/**
@@ -261,7 +263,7 @@ export function collectRenderMedia(html: string): RenderMedia {
else droppedMediaIds.add(elementId);
}
return { videos, audios, images, droppedMediaIds };
return { videos, audios, images, droppedMediaIds, gradedCanvas: hasGradedCanvas(html) };
}
function placeAudio(audio: AudioElement, window: HostWindow): AudioElement | null {
@@ -3557,6 +3557,7 @@ async function executeRenderPipeline(input: {
});
const effectiveHdr = resolveEffectiveHdrMode({
hdrMode: job.config.hdrMode,
media: composition,
outputFormat,
extractionResult,
imageColorSpaces,