From 60f711686ccae602caaed24faa3e225be6d96b12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 31 Oct 2026 16:16:06 +0000 Subject: [PATCH] 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 --- packages/cli/src/utils/hdrPromotion.test.ts | 14 +++++ packages/cli/src/utils/hdrPromotion.ts | 2 + packages/cli/src/utils/projectMedia.ts | 5 +- packages/engine/src/index.ts | 2 + .../src/services/videoFrameExtractor.test.ts | 20 +++++++ .../src/services/videoFrameExtractor.ts | 19 ++++++- packages/engine/src/utils/hdr.test.ts | 12 ++++ packages/engine/src/utils/hdr.ts | 16 ++++++ .../producer/src/services/htmlCompiler.ts | 4 +- .../src/services/render/hdrMode.test.ts | 56 ++++++++++++++++++- .../producer/src/services/render/hdrMode.ts | 41 ++++++++++++-- .../producer/src/services/render/shared.ts | 1 + .../services/render/stages/compileStage.ts | 1 + .../render/stages/extractVideosStage.ts | 20 ++++--- .../src/services/renderMediaCollector.ts | 4 +- .../src/services/renderOrchestrator.ts | 1 + 16 files changed, 199 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/utils/hdrPromotion.test.ts b/packages/cli/src/utils/hdrPromotion.test.ts index d2f03a1c35..47906304e9 100644 --- a/packages/cli/src/utils/hdrPromotion.test.ts +++ b/packages/cli/src/utils/hdrPromotion.test.ts @@ -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)); diff --git a/packages/cli/src/utils/hdrPromotion.ts b/packages/cli/src/utils/hdrPromotion.ts index 8a4c63b116..567cfa15b2 100644 --- a/packages/cli/src/utils/hdrPromotion.ts +++ b/packages/cli/src/utils/hdrPromotion.ts @@ -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, extractMetadata: ExtractMetadata = extractMediaMetadata, ): Promise { + if (gradingKeepsSdr("auto", media)) return null; const assets: Array<{ asset: string; colorSpace: Awaited>["colorSpace"]; diff --git a/packages/cli/src/utils/projectMedia.ts b/packages/cli/src/utils/projectMedia.ts index 78da4adbfa..20990d76cd 100644 --- a/packages/cli/src/utils/projectMedia.ts +++ b/packages/cli/src/utils/projectMedia.ts @@ -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[]; - images: readonly { src: string }[]; + videos: readonly Pick[]; + images: readonly { src: string; graded?: boolean }[]; + gradedCanvas?: boolean; audios: readonly AudioElement[]; /** The bundled document the lists were read from. */ html: string; diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 9132c9628c..5aa3868162 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -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, diff --git a/packages/engine/src/services/videoFrameExtractor.test.ts b/packages/engine/src/services/videoFrameExtractor.test.ts index c4d36d6df3..c70421e05b 100644 --- a/packages/engine/src/services/videoFrameExtractor.test.ts +++ b/packages/engine/src/services/videoFrameExtractor.test.ts @@ -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 = ` + + + `; + 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(``), + ).toBe(true); + }); + it.each([ { label: "valueless playback-start", diff --git a/packages/engine/src/services/videoFrameExtractor.ts b/packages/engine/src/services/videoFrameExtractor.ts index 45382f2813..606f6bf833 100644 --- a/packages/engine/src/services/videoFrameExtractor.ts +++ b/packages/engine/src/services/videoFrameExtractor.ts @@ -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; diff --git a/packages/engine/src/utils/hdr.test.ts b/packages/engine/src/utils/hdr.test.ts index 5ed7d497a1..5addacfca0 100644 --- a/packages/engine/src/utils/hdr.test.ts +++ b/packages/engine/src/utils/hdr.test.ts @@ -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); + }); +}); diff --git a/packages/engine/src/utils/hdr.ts b/packages/engine/src/utils/hdr.ts index df0b75744e..9448743d35 100644 --- a/packages/engine/src/utils/hdr.ts +++ b/packages/engine/src/utils/hdr.ts @@ -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}`; } diff --git a/packages/producer/src/services/htmlCompiler.ts b/packages/producer/src/services/htmlCompiler.ts index 48d9bee82f..54a32d482d 100644 --- a/packages/producer/src/services/htmlCompiler.ts +++ b/packages/producer/src/services/htmlCompiler.ts @@ -118,6 +118,7 @@ export interface CompiledComposition { audios: AudioElement[]; images: ImageElement[]; droppedMediaIds: Set; + 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 `