mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
refactor(producer): extract HDR compositing helpers and rename media metadata (#373)
## Summary
Four behavior-preserving refactors that reduce complexity in `renderOrchestrator.ts` and clarify the engine ffprobe utility surface. Lands after the correctness fixes (Chunks 1–5) so the refactored code is already correct.
## Why
`Chunk 7` of `plans/hdr-followups.md`. The HDR composite block had grown a ~200 LOC inline closure with 14 captured deps, a repeated capture-options spread, a `extractVideoMetadata` name that now also handles still images, and per-frame re-creation of debug helpers.
## What changed
**7A — Hoist `compositeToBuffer` into a module-scoped helper.** Extract the inline HDR closure into a top-level `compositeHdrFrame()` that takes an `HdrCompositeContext` struct. Construct the context once at the top of the HDR render block and pass it through. Removes a deeply-nested closure from the middle of the orchestrator.
**7B — `buildHdrCaptureOptions()` helper.** Factor the repeated `{ ...captureOptions, skipReadinessVideoIds: ... }` spread into a named helper at the call site.
**7C — Rename `extractVideoMetadata` → `extractMediaMetadata`.** Reflects that the helper handles still images (PNG/JPEG/WebP) in addition to video. Update all callers in engine + producer (`videoFrameExtractor`, `htmlCompiler`, regression-harness, producer ffprobe re-export, tests). Re-export the old name as a deprecated alias from `@hyperframes/engine` for backward compatibility, plus the producer re-export shim.
**7D — Hoist debug counters to module scope.** `countNonZeroAlpha` and `countNonZeroRgb48` are now module-scoped so they aren't re-created per frame and so the closure has fewer captures.
Also touches the `hdr-regression` and `hdr-hlg-regression` README + `meta.json` files reviewed during this refactor.
## Test plan
- [x] `bunx tsc --noEmit -p packages/producer && bunx tsc --noEmit -p packages/engine` clean.
- [x] Engine tests: 313 pass, 0 fail (1218 expect calls).
- [x] `bunx oxlint` + `bunx oxfmt --check` clean on 8 changed source files.
- [x] Diff is structural only — no behavioral changes.
## Stack
Chunk 7 of `plans/hdr-followups.md`. Lands after the correctness fixes (Chunks 1–5) per the suggested merge order.
This commit is contained in:
@@ -152,6 +152,7 @@ export {
|
||||
export { quantizeTimeToFrame, MEDIA_VISUAL_STYLE_PROPERTIES } from "@hyperframes/core";
|
||||
|
||||
export {
|
||||
extractMediaMetadata,
|
||||
extractVideoMetadata,
|
||||
extractAudioMetadata,
|
||||
analyzeKeyframeIntervals,
|
||||
|
||||
@@ -9,7 +9,7 @@ import { spawn } from "child_process";
|
||||
import { existsSync, mkdirSync, readdirSync, rmSync } from "fs";
|
||||
import { isAbsolute, join } from "path";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { extractVideoMetadata, type VideoMetadata } from "../utils/ffprobe.js";
|
||||
import { extractMediaMetadata, type VideoMetadata } from "../utils/ffprobe.js";
|
||||
import {
|
||||
analyzeCompositionHdr,
|
||||
isHdrColorSpace as isHdrColorSpaceUtil,
|
||||
@@ -158,7 +158,7 @@ export async function extractVideoFramesRange(
|
||||
const videoOutputDir = join(outputDir, videoId);
|
||||
if (!existsSync(videoOutputDir)) mkdirSync(videoOutputDir, { recursive: true });
|
||||
|
||||
const metadata = await extractVideoMetadata(videoPath);
|
||||
const metadata = await extractMediaMetadata(videoPath);
|
||||
const framePattern = `frame_%05d.${format}`;
|
||||
const outputPattern = join(videoOutputDir, framePattern);
|
||||
|
||||
@@ -411,7 +411,7 @@ export async function extractAllVideoFrames(
|
||||
// Phase 2: Probe color spaces and normalize if mixed HDR/SDR
|
||||
const videoColorSpaces = await Promise.all(
|
||||
resolvedVideos.map(async ({ videoPath }) => {
|
||||
const metadata = await extractVideoMetadata(videoPath);
|
||||
const metadata = await extractMediaMetadata(videoPath);
|
||||
return metadata.colorSpace;
|
||||
}),
|
||||
);
|
||||
@@ -457,7 +457,7 @@ export async function extractAllVideoFrames(
|
||||
if (signal?.aborted) break;
|
||||
const entry = resolvedVideos[i];
|
||||
if (!entry) continue;
|
||||
const metadata = await extractVideoMetadata(entry.videoPath);
|
||||
const metadata = await extractMediaMetadata(entry.videoPath);
|
||||
if (!metadata.isVFR) continue;
|
||||
|
||||
let segDuration = entry.video.end - entry.video.start;
|
||||
@@ -503,7 +503,7 @@ export async function extractAllVideoFrames(
|
||||
// Fallback: if no data-duration/data-end was specified (end is Infinity or 0),
|
||||
// probe the actual video file to get its natural duration.
|
||||
if (!Number.isFinite(videoDuration) || videoDuration <= 0) {
|
||||
const metadata = await extractVideoMetadata(videoPath);
|
||||
const metadata = await extractMediaMetadata(videoPath);
|
||||
const sourceDuration = metadata.durationSeconds - video.mediaStart;
|
||||
videoDuration = sourceDuration > 0 ? sourceDuration : metadata.durationSeconds;
|
||||
video.end = video.start + videoDuration;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { readFileSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractPngMetadataFromBuffer, extractVideoMetadata } from "./ffprobe.js";
|
||||
import { extractMediaMetadata, extractPngMetadataFromBuffer } from "./ffprobe.js";
|
||||
|
||||
function crc32(buf: Buffer): number {
|
||||
let crc = 0xffffffff;
|
||||
@@ -51,14 +51,14 @@ function buildMinimalPng(options?: {
|
||||
: buildPngWithChunks([ihdr, cicp, idat, iend]);
|
||||
}
|
||||
|
||||
describe("extractVideoMetadata", () => {
|
||||
describe("extractMediaMetadata", () => {
|
||||
it("reads HDR PNG cICP metadata when ffprobe color fields are absent", async () => {
|
||||
const fixturePath = resolve(
|
||||
__dirname,
|
||||
"../../../producer/tests/hdr-regression/src/hdr-photo-pq.png",
|
||||
);
|
||||
|
||||
const metadata = await extractVideoMetadata(fixturePath);
|
||||
const metadata = await extractMediaMetadata(fixturePath);
|
||||
|
||||
expect(metadata.colorSpace).toEqual({
|
||||
colorPrimaries: "bt2020",
|
||||
|
||||
@@ -210,7 +210,14 @@ function parseFrameRate(frameRateStr: string | undefined): number {
|
||||
return parseFloat(frameRateStr) || 0;
|
||||
}
|
||||
|
||||
export async function extractVideoMetadata(filePath: string): Promise<VideoMetadata> {
|
||||
/**
|
||||
* Probe a media file (video, image, or container) and return normalized metadata.
|
||||
*
|
||||
* Despite the legacy name `extractVideoMetadata` (still exported as a
|
||||
* deprecated alias below), this also handles still images such as PNG so it
|
||||
* can be used uniformly for any visual asset the HDR pipeline encounters.
|
||||
*/
|
||||
export async function extractMediaMetadata(filePath: string): Promise<VideoMetadata> {
|
||||
const cached = videoMetadataCache.get(filePath);
|
||||
if (cached) return cached;
|
||||
|
||||
@@ -286,6 +293,14 @@ export async function extractVideoMetadata(filePath: string): Promise<VideoMetad
|
||||
return probePromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use `extractMediaMetadata` — this name is kept for backward
|
||||
* compatibility with consumers that imported the original video-only name
|
||||
* before still-image (PNG) support was added. New callers should prefer
|
||||
* `extractMediaMetadata`.
|
||||
*/
|
||||
export const extractVideoMetadata = extractMediaMetadata;
|
||||
|
||||
export async function extractAudioMetadata(filePath: string): Promise<AudioMetadata> {
|
||||
const cached = audioMetadataCache.get(filePath);
|
||||
if (cached) return cached;
|
||||
|
||||
@@ -17,7 +17,7 @@ import process from "node:process";
|
||||
import { createRenderJob, executeRenderJob } from "./services/renderOrchestrator.js";
|
||||
import { compileForRender } from "./services/htmlCompiler.js";
|
||||
import { validateCompilation } from "./services/compilationTester.js";
|
||||
import { extractVideoMetadata } from "./utils/ffprobe.js";
|
||||
import { extractMediaMetadata } from "./utils/ffprobe.js";
|
||||
import { buildRmsEnvelope, compareAudioEnvelopes } from "./utils/audioRegression.js";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
@@ -625,7 +625,7 @@ async function runTestSuite(
|
||||
|
||||
// Visual comparison (100 frames, 1 per 1% of video duration)
|
||||
logPretty("Comparing visual quality (100 checkpoints)...", "🔍");
|
||||
const videoMetadata = await extractVideoMetadata(renderedOutputPath);
|
||||
const videoMetadata = await extractMediaMetadata(renderedOutputPath);
|
||||
const videoDuration = videoMetadata.durationSeconds;
|
||||
|
||||
const visualCheckpoints: Array<{ time: number; psnr: number; passed: boolean }> = [];
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
rewriteAssetPaths,
|
||||
rewriteCssAssetUrls,
|
||||
} from "@hyperframes/core";
|
||||
import { extractVideoMetadata, extractAudioMetadata } from "../utils/ffprobe.js";
|
||||
import { extractMediaMetadata, extractAudioMetadata } from "../utils/ffprobe.js";
|
||||
import { isPathInside, toExternalAssetKey } from "../utils/paths.js";
|
||||
import {
|
||||
parseVideoElements,
|
||||
@@ -152,7 +152,7 @@ async function resolveMediaDuration(
|
||||
|
||||
const metadata =
|
||||
tagName === "video"
|
||||
? await extractVideoMetadata(filePath)
|
||||
? await extractMediaMetadata(filePath)
|
||||
: await extractAudioMetadata(filePath);
|
||||
|
||||
const fileDuration = metadata.durationSeconds;
|
||||
@@ -1033,7 +1033,7 @@ export async function compileForRender(
|
||||
if (isHttpUrl(video.src)) continue;
|
||||
const videoPath = resolve(projectDir, video.src);
|
||||
const reencode = `ffmpeg -i "${video.src}" -c:v libx264 -r 30 -g 30 -keyint_min 30 -movflags +faststart -c:a copy output.mp4`;
|
||||
Promise.all([analyzeKeyframeIntervals(videoPath), extractVideoMetadata(videoPath)])
|
||||
Promise.all([analyzeKeyframeIntervals(videoPath), extractMediaMetadata(videoPath)])
|
||||
.then(([analysis, metadata]) => {
|
||||
if (analysis.isProblematic) {
|
||||
console.warn(
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
prepareCaptureSessionForReuse,
|
||||
type CaptureOptions,
|
||||
type CaptureSession,
|
||||
type BeforeCaptureHook,
|
||||
createVideoFrameInjector,
|
||||
encodeFramesFromDir,
|
||||
encodeFramesChunkedConcat,
|
||||
@@ -61,7 +62,7 @@ import {
|
||||
analyzeCompositionHdr,
|
||||
isHdrColorSpace,
|
||||
runFfmpeg,
|
||||
extractVideoMetadata,
|
||||
extractMediaMetadata,
|
||||
type VideoColorSpace,
|
||||
initTransparentBackground,
|
||||
captureAlphaPng,
|
||||
@@ -149,6 +150,34 @@ function getMaxFrameIndex(frameDir: string): number {
|
||||
return max;
|
||||
}
|
||||
|
||||
// Diagnostic helpers used by the HDR layered compositor when KEEP_TEMP=1
|
||||
// is set. They are pure (capture no state), so we keep them at module scope
|
||||
// to avoid re-creating closures per frame and to make them callable from
|
||||
// any future composite path that needs to log non-zero pixel counts.
|
||||
function countNonZeroAlpha(rgba: Uint8Array): number {
|
||||
let n = 0;
|
||||
for (let p = 3; p < rgba.length; p += 4) {
|
||||
if (rgba[p] !== 0) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function countNonZeroRgb48(buf: Uint8Array): number {
|
||||
let n = 0;
|
||||
for (let p = 0; p < buf.length; p += 6) {
|
||||
if (
|
||||
buf[p] !== 0 ||
|
||||
buf[p + 1] !== 0 ||
|
||||
buf[p + 2] !== 0 ||
|
||||
buf[p + 3] !== 0 ||
|
||||
buf[p + 4] !== 0 ||
|
||||
buf[p + 5] !== 0
|
||||
)
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata for a shader transition between two scenes, extracted from
|
||||
* `window.__hf.transitions`. Re-exported from the engine so the producer
|
||||
@@ -572,6 +601,269 @@ function blitHdrImageLayer(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dependencies passed to `compositeHdrFrame`.
|
||||
*
|
||||
* Every field except the per-frame arguments is captured once when the HDR
|
||||
* render path opens its `try { ... }` block and reused across every frame —
|
||||
* extracting them into an explicit struct lets the helper live at module
|
||||
* scope (no closure-over-renderJob) and keeps the per-call signature small.
|
||||
*/
|
||||
interface HdrCompositeContext {
|
||||
log: ProducerLogger;
|
||||
domSession: CaptureSession;
|
||||
beforeCaptureHook: BeforeCaptureHook | null;
|
||||
width: number;
|
||||
height: number;
|
||||
fps: number;
|
||||
effectiveHdr: { transfer: HdrTransfer };
|
||||
nativeHdrImageIds: Set<string>;
|
||||
hdrImageBuffers: Map<string, HdrImageBuffer>;
|
||||
hdrFrameDirs: Map<string, string>;
|
||||
hdrVideoStartTimes: Map<string, number>;
|
||||
imageTransfers: Map<string, HdrTransfer>;
|
||||
videoTransfers: Map<string, HdrTransfer>;
|
||||
debugDumpEnabled: boolean;
|
||||
debugDumpDir: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composite a single HDR frame into a pre-allocated `rgb48le` canvas.
|
||||
*
|
||||
* Bottom-to-top z-order: HDR layers are blitted directly from cached image
|
||||
* buffers / extracted video frames; DOM layers are screenshotted with a
|
||||
* mass-hide mask (so each layer paints only its own elements) and then
|
||||
* blended into the canvas via `blitRgba8OverRgb48le` in the active HDR
|
||||
* transfer space.
|
||||
*
|
||||
* The `elementFilter` parameter exists so the transition path can composite
|
||||
* each scene independently; pass `undefined` for whole-stack rendering.
|
||||
*
|
||||
* @param ctx - Long-lived dependencies (logger, browser session, dimensions,
|
||||
* HDR layer maps). Captured once per render — see
|
||||
* {@link HdrCompositeContext}.
|
||||
* @param canvas - Pre-allocated `width * height * 6` byte buffer. Caller must
|
||||
* zero-fill before every frame (this helper does not).
|
||||
* @param time - Seek time in seconds.
|
||||
* @param fullStacking - Stacking info for ALL elements at this time. Even when
|
||||
* filtering, every other element id is needed to build
|
||||
* the DOM-layer hide-list.
|
||||
* @param elementFilter - When set, only elements whose id is in the set are
|
||||
* composited.
|
||||
* @param debugFrameIndex - Frame index used to label per-layer diagnostic
|
||||
* dumps. Pass `-1` to disable per-layer dumps even
|
||||
* when `KEEP_TEMP=1` (e.g. for warmup frames).
|
||||
*/
|
||||
async function compositeHdrFrame(
|
||||
ctx: HdrCompositeContext,
|
||||
canvas: Buffer,
|
||||
time: number,
|
||||
fullStacking: ElementStackingInfo[],
|
||||
elementFilter?: Set<string>,
|
||||
debugFrameIndex: number = -1,
|
||||
): Promise<void> {
|
||||
const {
|
||||
log,
|
||||
domSession,
|
||||
beforeCaptureHook,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
effectiveHdr,
|
||||
nativeHdrImageIds,
|
||||
hdrImageBuffers,
|
||||
hdrFrameDirs,
|
||||
hdrVideoStartTimes,
|
||||
imageTransfers,
|
||||
videoTransfers,
|
||||
debugDumpEnabled,
|
||||
debugDumpDir,
|
||||
} = ctx;
|
||||
|
||||
const filteredStacking = elementFilter
|
||||
? fullStacking.filter((e) => elementFilter.has(e.id))
|
||||
: fullStacking;
|
||||
|
||||
const layers = groupIntoLayers(filteredStacking);
|
||||
|
||||
const shouldLog = debugDumpEnabled && debugFrameIndex >= 0;
|
||||
if (shouldLog) {
|
||||
log.info("[diag] compositeToBuffer plan", {
|
||||
frame: debugFrameIndex,
|
||||
time: time.toFixed(3),
|
||||
filterSize: elementFilter?.size,
|
||||
fullStackingCount: fullStacking.length,
|
||||
filteredCount: filteredStacking.length,
|
||||
layerCount: layers.length,
|
||||
layers: layers.map((l) =>
|
||||
l.type === "hdr"
|
||||
? {
|
||||
type: "hdr",
|
||||
id: l.element.id,
|
||||
z: l.element.zIndex,
|
||||
visible: l.element.visible,
|
||||
opacity: l.element.opacity,
|
||||
bounds: `${Math.round(l.element.x)},${Math.round(l.element.y)} ${Math.round(l.element.width)}x${Math.round(l.element.height)}`,
|
||||
}
|
||||
: { type: "dom", ids: l.elementIds },
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
for (const [layerIdx, layer] of layers.entries()) {
|
||||
if (layer.type === "hdr") {
|
||||
const before = shouldLog ? countNonZeroRgb48(canvas) : 0;
|
||||
const isHdrImage = nativeHdrImageIds.has(layer.element.id);
|
||||
if (isHdrImage) {
|
||||
blitHdrImageLayer(
|
||||
canvas,
|
||||
layer.element,
|
||||
hdrImageBuffers,
|
||||
width,
|
||||
height,
|
||||
log,
|
||||
imageTransfers.get(layer.element.id),
|
||||
effectiveHdr.transfer,
|
||||
);
|
||||
} else {
|
||||
blitHdrVideoLayer(
|
||||
canvas,
|
||||
layer.element,
|
||||
time,
|
||||
fps,
|
||||
hdrFrameDirs,
|
||||
hdrVideoStartTimes,
|
||||
width,
|
||||
height,
|
||||
log,
|
||||
videoTransfers.get(layer.element.id),
|
||||
effectiveHdr.transfer,
|
||||
);
|
||||
}
|
||||
if (shouldLog) {
|
||||
const after = countNonZeroRgb48(canvas);
|
||||
if (isHdrImage) {
|
||||
const buf = hdrImageBuffers.get(layer.element.id);
|
||||
log.info("[diag] hdr layer blit", {
|
||||
frame: debugFrameIndex,
|
||||
layerIdx,
|
||||
id: layer.element.id,
|
||||
kind: "image",
|
||||
pixelsAdded: after - before,
|
||||
totalNonZero: after,
|
||||
bufferDecoded: !!buf,
|
||||
bufferDims: buf ? `${buf.width}x${buf.height}` : null,
|
||||
});
|
||||
} else {
|
||||
const frameDir = hdrFrameDirs.get(layer.element.id);
|
||||
const startTime = hdrVideoStartTimes.get(layer.element.id) ?? 0;
|
||||
const localTime = time - startTime;
|
||||
const frameNum = Math.floor(localTime * fps) + 1;
|
||||
const expectedFrame = frameDir
|
||||
? join(frameDir, `frame_${String(frameNum).padStart(4, "0")}.png`)
|
||||
: null;
|
||||
log.info("[diag] hdr layer blit", {
|
||||
frame: debugFrameIndex,
|
||||
layerIdx,
|
||||
id: layer.element.id,
|
||||
kind: "video",
|
||||
pixelsAdded: after - before,
|
||||
totalNonZero: after,
|
||||
startTime,
|
||||
localTime: localTime.toFixed(3),
|
||||
hdrFrameNum: frameNum,
|
||||
expectedFrame,
|
||||
expectedFrameExists: expectedFrame ? existsSync(expectedFrame) : false,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// DOM layer: capture only elements in this layer.
|
||||
//
|
||||
// Each layer gets a fresh seek + inject cycle to guarantee correct
|
||||
// visibility state — avoids fragile interactions between the frame
|
||||
// injector, applyDomLayerMask, removeDomLayerMask, and GSAP re-seek.
|
||||
//
|
||||
// The mask:
|
||||
// - mass-hides every body descendant via stylesheet
|
||||
// - re-shows the layer's elements (and their descendants and
|
||||
// their injected `__render_frame_*` siblings) so deep-nested
|
||||
// content stays visible even though intermediate ancestors
|
||||
// are hidden
|
||||
// - inline-hides every other data-start element so they don't
|
||||
// paint when they happen to be descendants of a layer element
|
||||
// (most importantly: HDR videos and other-layer SDR videos
|
||||
// that live inside `#root` when capturing the root DOM layer)
|
||||
//
|
||||
// Without the mask, every DOM screenshot captures the full page
|
||||
// (root background, sibling scenes' static content, the painted
|
||||
// border/box-shadow of cards, etc.) and the resulting opaque
|
||||
// pixels overwrite previously composited HDR content beneath.
|
||||
const allElementIds = fullStacking.map((e) => e.id);
|
||||
const layerIds = new Set(layer.elementIds);
|
||||
const hideIds = allElementIds.filter((id) => !layerIds.has(id));
|
||||
|
||||
// 1. Seek GSAP to restore all animated properties from clean state
|
||||
await domSession.page.evaluate((t: number) => {
|
||||
if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t);
|
||||
}, time);
|
||||
|
||||
// 2. Run frame injector to set correct SDR video visibility
|
||||
if (beforeCaptureHook) {
|
||||
await beforeCaptureHook(domSession.page, time);
|
||||
}
|
||||
|
||||
// 3. Install the mask (mass-hide stylesheet + inline-hide non-layer ids)
|
||||
await applyDomLayerMask(domSession.page, layer.elementIds, hideIds);
|
||||
|
||||
// 4. Screenshot
|
||||
const domPng = await captureAlphaPng(domSession.page, width, height);
|
||||
|
||||
// 5. Tear down the mask
|
||||
await removeDomLayerMask(domSession.page, hideIds);
|
||||
|
||||
try {
|
||||
const { data: domRgba } = decodePng(domPng);
|
||||
const before = shouldLog ? countNonZeroRgb48(canvas) : 0;
|
||||
const alphaPixels = shouldLog ? countNonZeroAlpha(domRgba) : 0;
|
||||
blitRgba8OverRgb48le(domRgba, canvas, width, height, effectiveHdr.transfer);
|
||||
if (shouldLog && debugDumpDir) {
|
||||
const after = countNonZeroRgb48(canvas);
|
||||
const dumpName = `frame_${String(debugFrameIndex).padStart(4, "0")}_layer_${String(layerIdx).padStart(2, "0")}_dom.png`;
|
||||
const dumpPath = join(debugDumpDir, dumpName);
|
||||
writeFileSync(dumpPath, domPng);
|
||||
log.info("[diag] dom layer blit", {
|
||||
frame: debugFrameIndex,
|
||||
layerIdx,
|
||||
layerIds: layer.elementIds,
|
||||
hideCount: hideIds.length,
|
||||
pngBytes: domPng.length,
|
||||
alphaPixels,
|
||||
pixelsAdded: after - before,
|
||||
totalNonZero: after,
|
||||
dumpPath,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn("DOM layer decode/blit failed; skipping overlay", {
|
||||
layerIds: layer.elementIds,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldLog && debugDumpDir) {
|
||||
const finalNonZero = countNonZeroRgb48(canvas);
|
||||
log.info("[diag] compositeToBuffer end", {
|
||||
frame: debugFrameIndex,
|
||||
finalNonZeroPixels: finalNonZero,
|
||||
totalPixels: width * height,
|
||||
coverage: ((finalNonZero / (width * height)) * 100).toFixed(1) + "%",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function createRenderJob(config: RenderConfig): RenderJob {
|
||||
return {
|
||||
id: randomUUID(),
|
||||
@@ -1011,7 +1303,7 @@ export async function executeRenderJob(
|
||||
videoPath = fromCompiled;
|
||||
}
|
||||
if (!existsSync(videoPath)) return;
|
||||
const meta = await extractVideoMetadata(videoPath);
|
||||
const meta = await extractMediaMetadata(videoPath);
|
||||
if (isHdrColorSpace(meta.colorSpace)) {
|
||||
nativeHdrVideoIds.add(v.id);
|
||||
videoTransfers.set(v.id, detectTransfer(meta.colorSpace));
|
||||
@@ -1038,7 +1330,7 @@ export async function executeRenderJob(
|
||||
imgPath = fromCompiled;
|
||||
}
|
||||
if (!existsSync(imgPath)) return null;
|
||||
const meta = await extractVideoMetadata(imgPath);
|
||||
const meta = await extractMediaMetadata(imgPath);
|
||||
if (isHdrColorSpace(meta.colorSpace)) {
|
||||
nativeHdrImageIds.add(img.id);
|
||||
imageTransfers.set(img.id, detectTransfer(meta.colorSpace));
|
||||
@@ -1172,6 +1464,19 @@ export async function executeRenderJob(
|
||||
quality: needsAlpha ? undefined : job.config.quality === "draft" ? 80 : 95,
|
||||
};
|
||||
|
||||
// Native HDR videos (e.g. HEVC) may be undecodable by Chrome on the current
|
||||
// platform — Linux headless-shell ships without HEVC support. Their pixels
|
||||
// come from out-of-band ffmpeg extraction, so the DOM `<video>` element is
|
||||
// only kept around for layout. Skip the per-page readiness wait for these
|
||||
// IDs in every capture session we open during HDR rendering; otherwise the
|
||||
// render hangs 45s and throws "video metadata not ready" even though we
|
||||
// never asked the browser to decode the video. Encapsulating the spread
|
||||
// here avoids drifting copies across the five capture call sites below.
|
||||
const buildHdrCaptureOptions = (): CaptureOptions => ({
|
||||
...captureOptions,
|
||||
skipReadinessVideoIds: Array.from(nativeHdrVideoIds),
|
||||
});
|
||||
|
||||
const workerCount = calculateOptimalWorkers(totalFrames, job.config.workers, cfg);
|
||||
|
||||
const FORMAT_EXT: Record<string, string> = { mp4: ".mp4", webm: ".webm", mov: ".mov" };
|
||||
@@ -1262,7 +1567,7 @@ export async function executeRenderJob(
|
||||
const domSession = await createCaptureSession(
|
||||
fileServer.url,
|
||||
framesDir,
|
||||
{ ...captureOptions, skipReadinessVideoIds: Array.from(nativeHdrVideoIds) },
|
||||
buildHdrCaptureOptions(),
|
||||
createVideoFrameInjector(frameLookup),
|
||||
cfg,
|
||||
);
|
||||
@@ -1409,6 +1714,36 @@ export async function executeRenderJob(
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback probe for HDR images that weren't captured above.
|
||||
// When an image's `data-start` aligns with the exact visibility
|
||||
// boundary (or precedes a GSAP `from` tween that animates it in
|
||||
// later), Chrome reports 0 layout dimensions at that instant.
|
||||
// Re-probe slightly into the element's visible range so the
|
||||
// resample path gets real layout dims.
|
||||
for (const [imageId, startTime] of hdrImageStartTimes) {
|
||||
if (hdrExtractionDims.has(imageId)) continue;
|
||||
const img = composition.images.find((i) => i.id === imageId);
|
||||
if (!img) continue;
|
||||
const duration = img.end - img.start;
|
||||
const retryTime = startTime + Math.min(0.5, duration * 0.1);
|
||||
await domSession.page.evaluate((t: number) => {
|
||||
if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t);
|
||||
}, retryTime);
|
||||
if (domSession.onBeforeCapture) {
|
||||
await domSession.onBeforeCapture(domSession.page, retryTime);
|
||||
}
|
||||
const retryStacking = await queryElementStacking(domSession.page, nativeHdrIds);
|
||||
for (const el of retryStacking) {
|
||||
if (el.id === imageId && el.isHdr && el.layoutWidth > 0 && el.layoutHeight > 0) {
|
||||
hdrExtractionDims.set(el.id, { width: el.layoutWidth, height: el.layoutHeight });
|
||||
if (!hdrImageFitInfo.has(el.id)) {
|
||||
hdrImageFitInfo.set(el.id, { fit: el.objectFit, position: el.objectPosition });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pre-extract all HDR video frames in a single FFmpeg pass ──────
|
||||
// hdrFrameDirs is declared above the try block so the outer finally
|
||||
// can clear matching frameDirMaxIndexCache entries on any exit path.
|
||||
@@ -1529,243 +1864,41 @@ export async function executeRenderJob(
|
||||
}
|
||||
}
|
||||
|
||||
// ── compositeToBuffer: layer compositing helper ────────────────────
|
||||
// Extracted so the transition path can composite each scene independently.
|
||||
// Closes over domSession, hdrFrameDirs, composition, nativeHdrVideoIds, etc.
|
||||
//
|
||||
// @param canvas - Pre-allocated rgb48le buffer (width * height * 6 bytes)
|
||||
// @param time - Seek time in seconds
|
||||
// @param fullStacking - Complete stacking info for ALL elements (used for hideIds)
|
||||
// @param elementFilter - When set, only composite elements whose IDs are in this set.
|
||||
// When undefined, all elements are included (non-transition frame).
|
||||
// @param debugFrameIndex - Frame index used to label diagnostic dumps. -1 disables
|
||||
// per-layer dumps even when KEEP_TEMP=1 (for warmup calls).
|
||||
// ── HDR composite helper context ───────────────────────────────────
|
||||
// The actual layer-compositing logic lives at module scope in
|
||||
// `compositeHdrFrame`; we just pre-bind its long-lived dependencies
|
||||
// here so call sites stay short.
|
||||
const debugDumpEnabled = process.env.KEEP_TEMP === "1";
|
||||
const debugDumpDir = debugDumpEnabled ? join(framesDir, "debug-composite") : null;
|
||||
if (debugDumpDir && !existsSync(debugDumpDir)) {
|
||||
mkdirSync(debugDumpDir, { recursive: true });
|
||||
}
|
||||
function countNonZeroAlpha(rgba: Uint8Array): number {
|
||||
let n = 0;
|
||||
for (let p = 3; p < rgba.length; p += 4) {
|
||||
if (rgba[p] !== 0) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
function countNonZeroRgb48(buf: Uint8Array): number {
|
||||
let n = 0;
|
||||
for (let p = 0; p < buf.length; p += 6) {
|
||||
if (
|
||||
buf[p] !== 0 ||
|
||||
buf[p + 1] !== 0 ||
|
||||
buf[p + 2] !== 0 ||
|
||||
buf[p + 3] !== 0 ||
|
||||
buf[p + 4] !== 0 ||
|
||||
buf[p + 5] !== 0
|
||||
)
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
async function compositeToBuffer(
|
||||
canvas: Buffer,
|
||||
time: number,
|
||||
fullStacking: ElementStackingInfo[],
|
||||
elementFilter?: Set<string>,
|
||||
debugFrameIndex: number = -1,
|
||||
): Promise<void> {
|
||||
// Filter stacking info when rendering a single scene
|
||||
const filteredStacking = elementFilter
|
||||
? fullStacking.filter((e) => elementFilter.has(e.id))
|
||||
: fullStacking;
|
||||
|
||||
// Group filtered elements into z-ordered layers
|
||||
const layers = groupIntoLayers(filteredStacking);
|
||||
|
||||
const shouldLog = debugDumpEnabled && debugFrameIndex >= 0;
|
||||
if (shouldLog) {
|
||||
log.info("[diag] compositeToBuffer plan", {
|
||||
frame: debugFrameIndex,
|
||||
time: time.toFixed(3),
|
||||
filterSize: elementFilter?.size,
|
||||
fullStackingCount: fullStacking.length,
|
||||
filteredCount: filteredStacking.length,
|
||||
layerCount: layers.length,
|
||||
layers: layers.map((l) =>
|
||||
l.type === "hdr"
|
||||
? {
|
||||
type: "hdr",
|
||||
id: l.element.id,
|
||||
z: l.element.zIndex,
|
||||
visible: l.element.visible,
|
||||
opacity: l.element.opacity,
|
||||
bounds: `${Math.round(l.element.x)},${Math.round(l.element.y)} ${Math.round(l.element.width)}x${Math.round(l.element.height)}`,
|
||||
}
|
||||
: { type: "dom", ids: l.elementIds },
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Composite layers bottom-to-top
|
||||
for (const [layerIdx, layer] of layers.entries()) {
|
||||
if (layer.type === "hdr") {
|
||||
const before = shouldLog ? countNonZeroRgb48(canvas) : 0;
|
||||
const isHdrImage = nativeHdrImageIds.has(layer.element.id);
|
||||
if (isHdrImage) {
|
||||
blitHdrImageLayer(
|
||||
canvas,
|
||||
layer.element,
|
||||
hdrImageBuffers,
|
||||
width,
|
||||
height,
|
||||
log,
|
||||
imageTransfers.get(layer.element.id),
|
||||
effectiveHdr?.transfer,
|
||||
);
|
||||
} else {
|
||||
blitHdrVideoLayer(
|
||||
canvas,
|
||||
layer.element,
|
||||
time,
|
||||
job.config.fps,
|
||||
hdrFrameDirs,
|
||||
hdrVideoStartTimes,
|
||||
width,
|
||||
height,
|
||||
log,
|
||||
videoTransfers.get(layer.element.id),
|
||||
effectiveHdr?.transfer,
|
||||
);
|
||||
}
|
||||
if (shouldLog) {
|
||||
const after = countNonZeroRgb48(canvas);
|
||||
if (isHdrImage) {
|
||||
const buf = hdrImageBuffers.get(layer.element.id);
|
||||
log.info("[diag] hdr layer blit", {
|
||||
frame: debugFrameIndex,
|
||||
layerIdx,
|
||||
id: layer.element.id,
|
||||
kind: "image",
|
||||
pixelsAdded: after - before,
|
||||
totalNonZero: after,
|
||||
bufferDecoded: !!buf,
|
||||
bufferDims: buf ? `${buf.width}x${buf.height}` : null,
|
||||
});
|
||||
} else {
|
||||
const frameDir = hdrFrameDirs.get(layer.element.id);
|
||||
const startTime = hdrVideoStartTimes.get(layer.element.id) ?? 0;
|
||||
const localTime = time - startTime;
|
||||
const frameNum = Math.floor(localTime * job.config.fps) + 1;
|
||||
const expectedFrame = frameDir
|
||||
? join(frameDir, `frame_${String(frameNum).padStart(4, "0")}.png`)
|
||||
: null;
|
||||
log.info("[diag] hdr layer blit", {
|
||||
frame: debugFrameIndex,
|
||||
layerIdx,
|
||||
id: layer.element.id,
|
||||
kind: "video",
|
||||
pixelsAdded: after - before,
|
||||
totalNonZero: after,
|
||||
startTime,
|
||||
localTime: localTime.toFixed(3),
|
||||
hdrFrameNum: frameNum,
|
||||
expectedFrame,
|
||||
expectedFrameExists: expectedFrame ? existsSync(expectedFrame) : false,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// DOM layer: capture only elements in this layer.
|
||||
//
|
||||
// Each layer gets a fresh seek + inject cycle to guarantee correct
|
||||
// visibility state — avoids fragile interactions between the frame
|
||||
// injector, applyDomLayerMask, removeDomLayerMask, and GSAP re-seek.
|
||||
//
|
||||
// The mask:
|
||||
// - mass-hides every body descendant via stylesheet
|
||||
// - re-shows the layer's elements (and their descendants and
|
||||
// their injected `__render_frame_*` siblings) so deep-nested
|
||||
// content stays visible even though intermediate ancestors
|
||||
// are hidden
|
||||
// - inline-hides every other data-start element so they don't
|
||||
// paint when they happen to be descendants of a layer element
|
||||
// (most importantly: HDR videos and other-layer SDR videos
|
||||
// that live inside `#root` when capturing the root DOM layer)
|
||||
//
|
||||
// Without the mask, every DOM screenshot captures the full page
|
||||
// (root background, sibling scenes' static content, the painted
|
||||
// border/box-shadow of cards, etc.) and the resulting opaque
|
||||
// pixels overwrite previously composited HDR content beneath.
|
||||
const allElementIds = fullStacking.map((e) => e.id);
|
||||
const layerIds = new Set(layer.elementIds);
|
||||
const hideIds = allElementIds.filter((id) => !layerIds.has(id));
|
||||
|
||||
// 1. Seek GSAP to restore all animated properties from clean state
|
||||
await domSession.page.evaluate((t: number) => {
|
||||
if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t);
|
||||
}, time);
|
||||
|
||||
// 2. Run frame injector to set correct SDR video visibility
|
||||
if (beforeCaptureHook) {
|
||||
await beforeCaptureHook(domSession.page, time);
|
||||
}
|
||||
|
||||
// 3. Install the mask (mass-hide stylesheet + inline-hide non-layer ids)
|
||||
await applyDomLayerMask(domSession.page, layer.elementIds, hideIds);
|
||||
|
||||
// 4. Screenshot
|
||||
const domPng = await captureAlphaPng(domSession.page, width, height);
|
||||
|
||||
// 5. Tear down the mask
|
||||
await removeDomLayerMask(domSession.page, hideIds);
|
||||
|
||||
try {
|
||||
const { data: domRgba } = decodePng(domPng);
|
||||
// Invariant: this branch is only reached when HDR output is active.
|
||||
if (!effectiveHdr) {
|
||||
throw new Error(
|
||||
"Invariant violation: effectiveHdr is undefined inside HDR layer branch",
|
||||
);
|
||||
}
|
||||
const before = shouldLog ? countNonZeroRgb48(canvas) : 0;
|
||||
const alphaPixels = shouldLog ? countNonZeroAlpha(domRgba) : 0;
|
||||
blitRgba8OverRgb48le(domRgba, canvas, width, height, effectiveHdr.transfer);
|
||||
if (shouldLog && debugDumpDir) {
|
||||
const after = countNonZeroRgb48(canvas);
|
||||
const dumpName = `frame_${String(debugFrameIndex).padStart(4, "0")}_layer_${String(layerIdx).padStart(2, "0")}_dom.png`;
|
||||
const dumpPath = join(debugDumpDir, dumpName);
|
||||
writeFileSync(dumpPath, domPng);
|
||||
log.info("[diag] dom layer blit", {
|
||||
frame: debugFrameIndex,
|
||||
layerIdx,
|
||||
layerIds: layer.elementIds,
|
||||
hideCount: hideIds.length,
|
||||
pngBytes: domPng.length,
|
||||
alphaPixels,
|
||||
pixelsAdded: after - before,
|
||||
totalNonZero: after,
|
||||
dumpPath,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn("DOM layer decode/blit failed; skipping overlay", {
|
||||
layerIds: layer.elementIds,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldLog && debugDumpDir) {
|
||||
const finalNonZero = countNonZeroRgb48(canvas);
|
||||
log.info("[diag] compositeToBuffer end", {
|
||||
frame: debugFrameIndex,
|
||||
finalNonZeroPixels: finalNonZero,
|
||||
totalPixels: width * height,
|
||||
coverage: ((finalNonZero / (width * height)) * 100).toFixed(1) + "%",
|
||||
});
|
||||
}
|
||||
// INVARIANT: this entire `try` block is reachable only when HDR
|
||||
// output is enabled (`if (effectiveHdr) { ... try { ... } }`), so
|
||||
// narrowing here is safe even though `effectiveHdr` is typed as
|
||||
// `... | undefined` at the outer scope.
|
||||
if (!effectiveHdr) {
|
||||
throw new Error(
|
||||
"Internal: HDR render path entered without effectiveHdr — this is a bug.",
|
||||
);
|
||||
}
|
||||
const hdrCompositeCtx: HdrCompositeContext = {
|
||||
log,
|
||||
domSession,
|
||||
beforeCaptureHook,
|
||||
width,
|
||||
height,
|
||||
fps: job.config.fps,
|
||||
effectiveHdr,
|
||||
nativeHdrImageIds,
|
||||
hdrImageBuffers,
|
||||
hdrFrameDirs,
|
||||
hdrVideoStartTimes,
|
||||
imageTransfers,
|
||||
videoTransfers,
|
||||
debugDumpEnabled,
|
||||
debugDumpDir,
|
||||
};
|
||||
|
||||
// ── Pre-allocate transition buffers ─────────────────────────────────
|
||||
// Each buffer is width * height * 6 bytes (~37 MB at 1080p). Reused
|
||||
@@ -1932,7 +2065,14 @@ export async function executeRenderJob(
|
||||
} else {
|
||||
// ── Normal frame: full layer composite (no transition) ─────────
|
||||
normalCanvas.fill(0);
|
||||
await compositeToBuffer(normalCanvas, time, stackingInfo, undefined, i);
|
||||
await compositeHdrFrame(
|
||||
hdrCompositeCtx,
|
||||
normalCanvas,
|
||||
time,
|
||||
stackingInfo,
|
||||
undefined,
|
||||
i,
|
||||
);
|
||||
if (debugDumpEnabled && debugDumpDir && i % 30 === 0) {
|
||||
const previewPath = join(
|
||||
debugDumpDir,
|
||||
@@ -2086,7 +2226,7 @@ export async function executeRenderJob(
|
||||
fileServer.url,
|
||||
workDir,
|
||||
tasks,
|
||||
{ ...captureOptions, skipReadinessVideoIds: Array.from(nativeHdrVideoIds) },
|
||||
buildHdrCaptureOptions(),
|
||||
() => createVideoFrameInjector(frameLookup),
|
||||
abortSignal,
|
||||
(progress) => {
|
||||
@@ -2125,7 +2265,7 @@ export async function executeRenderJob(
|
||||
(await createCaptureSession(
|
||||
fileServer.url,
|
||||
framesDir,
|
||||
{ ...captureOptions, skipReadinessVideoIds: Array.from(nativeHdrVideoIds) },
|
||||
buildHdrCaptureOptions(),
|
||||
videoInjector,
|
||||
cfg,
|
||||
));
|
||||
@@ -2188,7 +2328,7 @@ export async function executeRenderJob(
|
||||
fileServer.url,
|
||||
workDir,
|
||||
tasks,
|
||||
{ ...captureOptions, skipReadinessVideoIds: Array.from(nativeHdrVideoIds) },
|
||||
buildHdrCaptureOptions(),
|
||||
() => createVideoFrameInjector(frameLookup),
|
||||
abortSignal,
|
||||
(progress) => {
|
||||
@@ -2228,7 +2368,7 @@ export async function executeRenderJob(
|
||||
(await createCaptureSession(
|
||||
fileServer.url,
|
||||
framesDir,
|
||||
{ ...captureOptions, skipReadinessVideoIds: Array.from(nativeHdrVideoIds) },
|
||||
buildHdrCaptureOptions(),
|
||||
videoInjector,
|
||||
cfg,
|
||||
));
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* @see engine/src/utils/ffprobe.ts for implementation.
|
||||
*/
|
||||
export {
|
||||
extractMediaMetadata,
|
||||
extractVideoMetadata,
|
||||
extractAudioMetadata,
|
||||
type VideoMetadata,
|
||||
|
||||
@@ -13,7 +13,7 @@ so the HLG-specific encoder/metadata path stays tested in isolation.
|
||||
|
||||
The test pins the contract that:
|
||||
|
||||
- `extractVideoMetadata` reports `bt2020/arib-std-b67/limited` for the HLG
|
||||
- `extractMediaMetadata` reports `bt2020/arib-std-b67/limited` for the HLG
|
||||
source (i.e. HLG is detected and not silently coerced to PQ).
|
||||
- `isHdrColorSpace` flips the orchestrator into the layered HDR path on the
|
||||
HLG signal.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hdr-hlg-regression",
|
||||
"description": "Regression test for HDR HLG (BT.2020 ARIB STD-B67) video pass-through. Two windows: (A) baseline HLG video + SDR DOM overlay, (B) wrapper-opacity fade applied to an HLG video. Verifies that an HLG HEVC source drives the layered HDR pipeline end-to-end (extractVideoMetadata reports hlg, ffmpegFrameSource decodes correctly, hdrEncoder writes HEVC Main10 / yuv420p10le / BT.2020 HLG with the appropriate mastering metadata) and that opacity composition does not break HLG signal pass-through.",
|
||||
"description": "Regression test for HDR HLG (BT.2020 ARIB STD-B67) video pass-through. Two windows: (A) baseline HLG video + SDR DOM overlay, (B) wrapper-opacity fade applied to an HLG video. Verifies that an HLG HEVC source drives the layered HDR pipeline end-to-end (extractMediaMetadata reports hlg, ffmpegFrameSource decodes correctly, hdrEncoder writes HEVC Main10 / yuv420p10le / BT.2020 HLG with the appropriate mastering metadata) and that opacity composition does not break HLG signal pass-through.",
|
||||
"tags": ["regression", "hdr"],
|
||||
"minPsnr": 28,
|
||||
"maxFrameFailures": 0,
|
||||
|
||||
Reference in New Issue
Block a user