mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(hdr): z-ordered multi-layer compositing with PQ support (#289)
* feat(hdr): add z-ordered multi-layer compositing with PQ support Per-frame z-order analysis groups elements into DOM and HDR layers, composited bottom-to-top. Adjacent DOM elements merge into single screenshots. PQ (HDR10/smpte2084) support via sRGB-to-PQ LUT with 203-nit SDR reference white. queryElementStacking walks DOM for effective z-index, groupIntoLayers splits on HDR/DOM boundaries. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(hdr): address review feedback across stack - Document groupIntoLayers tie-break (V8 stable sort → DOM order). - Expand layerCompositor docstring: merge rationale, visibility inclusion. - Add tests: empty input, negative z-index, stable tie-break at equal z. - Document getEffectiveZIndex CSS stacking-context limitations. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
a21a62b574
commit
0cc79a35b0
@@ -156,7 +156,15 @@ export {
|
||||
|
||||
export { downloadToTemp, isHttpUrl } from "./utils/urlDownloader.js";
|
||||
|
||||
export { decodePng, decodePngToRgb48le, blitRgba8OverRgb48le } from "./utils/alphaBlit.js";
|
||||
export {
|
||||
decodePng,
|
||||
decodePngToRgb48le,
|
||||
blitRgba8OverRgb48le,
|
||||
blitRgb48leRegion,
|
||||
getSrgbToHdrLut,
|
||||
} from "./utils/alphaBlit.js";
|
||||
|
||||
export { groupIntoLayers, type CompositeLayer } from "./utils/layerCompositor.js";
|
||||
|
||||
export {
|
||||
initHdrReadback,
|
||||
@@ -172,7 +180,9 @@ export {
|
||||
hideVideoElements,
|
||||
showVideoElements,
|
||||
queryVideoElementBounds,
|
||||
queryElementStacking,
|
||||
type VideoElementBounds,
|
||||
type ElementStackingInfo,
|
||||
} from "./services/videoFrameInjector.js";
|
||||
|
||||
export {
|
||||
|
||||
@@ -225,3 +225,95 @@ export async function queryVideoElementBounds(
|
||||
});
|
||||
}, videoIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stacking info for a single timed element, used by the z-ordered layer compositor.
|
||||
*/
|
||||
export interface ElementStackingInfo {
|
||||
id: string;
|
||||
zIndex: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
opacity: number;
|
||||
visible: boolean;
|
||||
isHdr: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query Chrome for ALL timed elements' stacking context.
|
||||
* Returns z-index, bounds, opacity, and whether each element is a native HDR video.
|
||||
*
|
||||
* Queries every element with `data-start` (not just videos) so the layer compositor
|
||||
* can determine z-ordering between DOM content and HDR video elements.
|
||||
*/
|
||||
export async function queryElementStacking(
|
||||
page: Page,
|
||||
nativeHdrVideoIds: Set<string>,
|
||||
): Promise<ElementStackingInfo[]> {
|
||||
const hdrIds = Array.from(nativeHdrVideoIds);
|
||||
return page.evaluate((hdrIdList: string[]): ElementStackingInfo[] => {
|
||||
const hdrSet = new Set(hdrIdList);
|
||||
const elements = document.querySelectorAll("[data-start]");
|
||||
const results: ElementStackingInfo[] = [];
|
||||
|
||||
// Walk up the DOM to find the effective z-index from the nearest
|
||||
// positioned ancestor with a z-index. CSS z-index only applies to
|
||||
// positioned elements; video elements inside positioned wrappers
|
||||
// inherit the wrapper's stacking context.
|
||||
//
|
||||
// ## Supported subset
|
||||
//
|
||||
// This implementation looks for explicit `z-index` on positioned
|
||||
// (non-static) ancestors. It does NOT detect the CSS stacking contexts
|
||||
// created implicitly by other properties — including `opacity < 1`,
|
||||
// `transform`, `filter`, `will-change`, `isolation: isolate`, and
|
||||
// `mix-blend-mode`. GSAP routinely sets `transform` on wrappers, which
|
||||
// creates an implicit stacking context with auto z-index; an HDR video
|
||||
// inside such a wrapper with no explicit z-index will return the
|
||||
// wrapper-of-the-wrapper's z-index here, potentially reordering layers
|
||||
// incorrectly relative to sibling stacking contexts.
|
||||
//
|
||||
// The workaround is to set explicit `z-index` on the positioned wrapper
|
||||
// when you want it treated as a compositing layer root. This matches
|
||||
// what compositions need to do anyway for deterministic z-ordering.
|
||||
function getEffectiveZIndex(node: Element): number {
|
||||
let current: Element | null = node;
|
||||
while (current) {
|
||||
const cs = window.getComputedStyle(current);
|
||||
const pos = cs.position;
|
||||
const z = parseInt(cs.zIndex);
|
||||
if (!Number.isNaN(z) && pos !== "static") return z;
|
||||
current = current.parentElement;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
for (const el of elements) {
|
||||
const id = el.id;
|
||||
if (!id) continue;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(el);
|
||||
const zIndex = getEffectiveZIndex(el);
|
||||
const opacity = parseFloat(style.opacity) || 1;
|
||||
const visible =
|
||||
style.visibility !== "hidden" &&
|
||||
style.display !== "none" &&
|
||||
rect.width > 0 &&
|
||||
rect.height > 0;
|
||||
results.push({
|
||||
id,
|
||||
zIndex,
|
||||
x: Math.round(rect.x),
|
||||
y: Math.round(rect.y),
|
||||
width: Math.round(rect.width),
|
||||
height: Math.round(rect.height),
|
||||
opacity,
|
||||
visible,
|
||||
isHdr: hdrSet.has(id),
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}, hdrIds);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { groupIntoLayers } from "./layerCompositor.js";
|
||||
import type { ElementStackingInfo } from "../services/videoFrameInjector.js";
|
||||
|
||||
function makeEl(id: string, zIndex: number, isHdr: boolean): ElementStackingInfo {
|
||||
return {
|
||||
id,
|
||||
zIndex,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
layoutWidth: 1920,
|
||||
layoutHeight: 1080,
|
||||
opacity: 1,
|
||||
visible: true,
|
||||
isHdr,
|
||||
transform: "none",
|
||||
borderRadius: [0, 0, 0, 0],
|
||||
};
|
||||
}
|
||||
|
||||
describe("groupIntoLayers", () => {
|
||||
it("single DOM element → 1 DOM layer", () => {
|
||||
const layers = groupIntoLayers([makeEl("text", 0, false)]);
|
||||
expect(layers).toHaveLength(1);
|
||||
expect(layers[0]!.type).toBe("dom");
|
||||
});
|
||||
|
||||
it("single HDR element → 1 HDR layer", () => {
|
||||
const layers = groupIntoLayers([makeEl("v-hdr", 0, true)]);
|
||||
expect(layers).toHaveLength(1);
|
||||
expect(layers[0]!.type).toBe("hdr");
|
||||
});
|
||||
|
||||
it("merges adjacent DOM elements into one layer", () => {
|
||||
const elements = [makeEl("bg", 0, false), makeEl("text", 1, false), makeEl("logo", 2, false)];
|
||||
const layers = groupIntoLayers(elements);
|
||||
expect(layers).toHaveLength(1);
|
||||
expect(layers[0]!.type).toBe("dom");
|
||||
if (layers[0]!.type === "dom") {
|
||||
expect(layers[0]!.elementIds).toEqual(["bg", "text", "logo"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("splits on HDR/DOM boundary: DOM → HDR → DOM = 3 layers", () => {
|
||||
const elements = [makeEl("bg", 0, false), makeEl("v-hdr", 1, true), makeEl("title", 2, false)];
|
||||
const layers = groupIntoLayers(elements);
|
||||
expect(layers).toHaveLength(3);
|
||||
expect(layers[0]!.type).toBe("dom");
|
||||
expect(layers[1]!.type).toBe("hdr");
|
||||
expect(layers[2]!.type).toBe("dom");
|
||||
});
|
||||
|
||||
it("merges adjacent DOM around multiple HDR: DOM → HDR → HDR → DOM = 4 layers", () => {
|
||||
const elements = [
|
||||
makeEl("bg", 0, false),
|
||||
makeEl("v-hdr1", 1, true),
|
||||
makeEl("v-hdr2", 2, true),
|
||||
makeEl("title", 3, false),
|
||||
];
|
||||
const layers = groupIntoLayers(elements);
|
||||
expect(layers).toHaveLength(4);
|
||||
expect(layers[0]!.type).toBe("dom");
|
||||
expect(layers[1]!.type).toBe("hdr");
|
||||
expect(layers[2]!.type).toBe("hdr");
|
||||
expect(layers[3]!.type).toBe("dom");
|
||||
});
|
||||
|
||||
it("complex case: DOM DOM HDR DOM HDR DOM = 5 layers (2 DOM merges)", () => {
|
||||
const elements = [
|
||||
makeEl("bg", 0, false),
|
||||
makeEl("caption", 1, false),
|
||||
makeEl("v-hdr1", 2, true),
|
||||
makeEl("text", 3, false),
|
||||
makeEl("v-hdr2", 4, true),
|
||||
makeEl("logo", 5, false),
|
||||
];
|
||||
const layers = groupIntoLayers(elements);
|
||||
expect(layers).toHaveLength(5);
|
||||
expect(layers.map((l) => l.type)).toEqual(["dom", "hdr", "dom", "hdr", "dom"]);
|
||||
if (layers[0]!.type === "dom") {
|
||||
expect(layers[0]!.elementIds).toEqual(["bg", "caption"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("sorts by zIndex before grouping", () => {
|
||||
const elements = [makeEl("title", 5, false), makeEl("v-hdr", 2, true), makeEl("bg", 0, false)];
|
||||
const layers = groupIntoLayers(elements);
|
||||
expect(layers).toHaveLength(3);
|
||||
expect(layers[0]!.type).toBe("dom"); // bg (z=0)
|
||||
expect(layers[1]!.type).toBe("hdr"); // v-hdr (z=2)
|
||||
expect(layers[2]!.type).toBe("dom"); // title (z=5)
|
||||
});
|
||||
|
||||
it("includes invisible elements in correct z-position", () => {
|
||||
const elements = [
|
||||
makeEl("bg", 0, false),
|
||||
{ ...makeEl("hidden-sdr", 1, false), visible: false },
|
||||
{ ...makeEl("hidden-hdr", 2, true), visible: false },
|
||||
makeEl("title", 3, false),
|
||||
];
|
||||
const layers = groupIntoLayers(elements);
|
||||
// All elements included — invisible SDR videos need their injected
|
||||
// <img> replacements hidden from other layers' screenshots
|
||||
expect(layers).toHaveLength(3);
|
||||
expect(layers[0]!.type).toBe("dom"); // bg + hidden-sdr (merged)
|
||||
expect(layers[1]!.type).toBe("hdr"); // hidden-hdr
|
||||
expect(layers[2]!.type).toBe("dom"); // title
|
||||
if (layers[0]!.type === "dom") {
|
||||
expect(layers[0]!.elementIds).toEqual(["bg", "hidden-sdr"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns an empty array for empty input", () => {
|
||||
expect(groupIntoLayers([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles negative z-index (valid CSS back layers)", () => {
|
||||
const elements = [makeEl("fg", 1, false), makeEl("bg", -5, false)];
|
||||
const layers = groupIntoLayers(elements);
|
||||
expect(layers).toHaveLength(1);
|
||||
expect(layers[0]!.type).toBe("dom");
|
||||
if (layers[0]!.type === "dom") {
|
||||
expect(layers[0]!.elementIds).toEqual(["bg", "fg"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves input order for equal z-index (stable tie-break)", () => {
|
||||
const elements = [
|
||||
makeEl("first", 0, false),
|
||||
makeEl("second", 0, false),
|
||||
makeEl("third", 0, false),
|
||||
];
|
||||
const layers = groupIntoLayers(elements);
|
||||
expect(layers).toHaveLength(1);
|
||||
if (layers[0]!.type === "dom") {
|
||||
expect(layers[0]!.elementIds).toEqual(["first", "second", "third"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Layer Compositor — z-order analysis for multi-layer HDR compositing.
|
||||
*
|
||||
* Groups timed elements into z-ordered layers (DOM or HDR) for the
|
||||
* per-frame compositing loop. Adjacent DOM elements merge into a single
|
||||
* layer to minimize Chrome screenshots.
|
||||
*/
|
||||
|
||||
import type { ElementStackingInfo } from "../services/videoFrameInjector.js";
|
||||
|
||||
export type { ElementStackingInfo };
|
||||
|
||||
export type CompositeLayer =
|
||||
| { type: "dom"; elementIds: string[] }
|
||||
| { type: "hdr"; element: ElementStackingInfo };
|
||||
|
||||
/**
|
||||
* Group z-sorted elements into composite layers. Adjacent DOM elements merge
|
||||
* into a single layer; each HDR video/image is its own layer.
|
||||
*
|
||||
* Elements are sorted by \`zIndex\` ascending (back to front). Ties fall
|
||||
* through to V8's stable sort, which preserves \`querySelectorAll\` DOM order —
|
||||
* this is the same order Chrome uses for equal-z elements in a stacking
|
||||
* context, so the blit order matches what the user sees in-browser.
|
||||
*
|
||||
* The DOM merge doesn't lose information: DOM layers are rendered via a
|
||||
* full-page screenshot with non-layer elements hidden, so within-layer
|
||||
* z-order is handled by Chrome itself.
|
||||
*
|
||||
* Invisible elements ARE included (video elements are hidden by the frame
|
||||
* injector, but their injected \`<img>\` replacements are visible — they must
|
||||
* stay in the correct z-ordered layer so sibling layers' DOM screenshots
|
||||
* hide them).
|
||||
*/
|
||||
export function groupIntoLayers(elements: ElementStackingInfo[]): CompositeLayer[] {
|
||||
// Include ALL elements regardless of visibility. Video elements are hidden by
|
||||
// the frame injector (HEVC can't decode in headless Chrome) but their injected
|
||||
// <img> replacements ARE visible. We need them in the correct z-ordered layer
|
||||
// so they get hidden from other layers' DOM screenshots.
|
||||
const sorted = [...elements].sort((a, b) => a.zIndex - b.zIndex);
|
||||
|
||||
const layers: CompositeLayer[] = [];
|
||||
|
||||
for (const el of sorted) {
|
||||
if (el.isHdr) {
|
||||
layers.push({ type: "hdr", element: el });
|
||||
} else {
|
||||
const last = layers[layers.length - 1];
|
||||
if (last && last.type === "dom") {
|
||||
last.elementIds.push(el.id);
|
||||
} else {
|
||||
layers.push({ type: "dom", elementIds: [el.id] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return layers;
|
||||
}
|
||||
@@ -64,9 +64,11 @@ import {
|
||||
decodePng,
|
||||
decodePngToRgb48le,
|
||||
blitRgba8OverRgb48le,
|
||||
blitRgb48leRegion,
|
||||
hideVideoElements,
|
||||
showVideoElements,
|
||||
queryVideoElementBounds,
|
||||
queryElementStacking,
|
||||
groupIntoLayers,
|
||||
} from "@hyperframes/engine";
|
||||
import { join, dirname, resolve } from "path";
|
||||
import { randomUUID } from "crypto";
|
||||
@@ -923,16 +925,13 @@ export async function executeRenderJob(
|
||||
|
||||
job.framesRendered = 0;
|
||||
|
||||
// ── HDR two-pass compositing path ────────────────────────────────────
|
||||
// Pass 1: Capture DOM layer with alpha (Chrome, video elements hidden)
|
||||
// Pass 2: Extract native HLG frames from video sources (FFmpeg)
|
||||
// Composite: overlay DOM on top of HDR video in FFmpeg per-frame
|
||||
//
|
||||
// This preserves HDR luminance from video sources while correctly
|
||||
// compositing DOM content (text, graphics, SDR overlays) on top.
|
||||
// Video position is applied via queried bounds; transform/opacity lands in a later PR.
|
||||
// ── HDR z-ordered multi-layer compositing ──────────────────────────────
|
||||
// Per frame: query all elements' z-order, group into layers (DOM or HDR),
|
||||
// composite bottom-to-top in Node.js memory. HDR layers use native
|
||||
// pre-extracted HLG pixels; DOM layers use Chrome alpha screenshots
|
||||
// with sRGB→HLG conversion. Video position/opacity applied via queried bounds.
|
||||
if (hasHdrVideo) {
|
||||
log.info("[Render] HDR two-pass: DOM layer + native HLG video compositing");
|
||||
log.info("[Render] HDR layered composite: z-ordered DOM + native HLG video layers");
|
||||
|
||||
// Use NATIVE HDR IDs (probed before SDR→HDR conversion) so only originally-HDR
|
||||
// videos are hidden + extracted natively. SDR videos stay in the DOM screenshot
|
||||
@@ -1035,72 +1034,114 @@ export async function executeRenderJob(
|
||||
if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t);
|
||||
}, time);
|
||||
|
||||
// Inject SDR video frames into the DOM (the hook handles all videos,
|
||||
// but hideVideoElements below will hide the HDR ones before screenshot)
|
||||
// Inject SDR video frames into the DOM
|
||||
if (beforeCaptureHook) {
|
||||
await beforeCaptureHook(domSession.page, time);
|
||||
}
|
||||
|
||||
// Query video element positions BEFORE hiding (so GSAP has already moved them)
|
||||
const bounds = await queryVideoElementBounds(domSession.page, hdrVideoIds);
|
||||
const activeBounds = bounds.filter((b) => b.visible);
|
||||
// Query ALL timed elements for z-order analysis
|
||||
const stackingInfo = await queryElementStacking(domSession.page, nativeHdrVideoIds);
|
||||
|
||||
// Pass 1: Hide HDR videos (and their injected frames), capture DOM with alpha.
|
||||
// SDR video frames remain visible in the screenshot.
|
||||
await hideVideoElements(domSession.page, hdrVideoIds);
|
||||
const domPng = await captureAlphaPng(domSession.page, width, height);
|
||||
await showVideoElements(domSession.page, hdrVideoIds);
|
||||
// Group into z-ordered layers
|
||||
const layers = groupIntoLayers(stackingInfo);
|
||||
|
||||
// Pass 2: Read pre-extracted HDR frame and composite with DOM layer
|
||||
const activeVideoId = activeBounds[0]?.videoId ?? hdrVideoIds[0];
|
||||
const video = composition.videos.find((v) => v.id === activeVideoId);
|
||||
const frameDir = activeVideoId ? hdrFrameDirs.get(activeVideoId) : undefined;
|
||||
|
||||
let composited: Buffer;
|
||||
if (video && frameDir) {
|
||||
// Frame index within the video (1-based for FFmpeg image2 output).
|
||||
// Clamp against the highest extracted frame in the directory to
|
||||
// avoid issuing an existsSync per requested time when the
|
||||
// composition outlives the source clip.
|
||||
const rawIndex = Math.round((time - video.start) * job.config.fps) + 1;
|
||||
const maxIndex = getMaxFrameIndex(frameDir);
|
||||
const inBounds = rawIndex >= 1 && (maxIndex === 0 || rawIndex <= maxIndex);
|
||||
const framePath = inBounds
|
||||
? join(frameDir, `frame_${String(rawIndex).padStart(4, "0")}.png`)
|
||||
: null;
|
||||
|
||||
let hdrRgb: Buffer;
|
||||
if (framePath !== null && existsSync(framePath)) {
|
||||
try {
|
||||
hdrRgb = decodePngToRgb48le(readFileSync(framePath)).data;
|
||||
} catch (err) {
|
||||
log.warn("Failed to decode pre-extracted HDR frame; using black", {
|
||||
framePath,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
hdrRgb = Buffer.alloc(width * height * 6);
|
||||
}
|
||||
} else {
|
||||
hdrRgb = Buffer.alloc(width * height * 6);
|
||||
}
|
||||
|
||||
// In-memory alpha composite: DOM PNG over HDR rgb48le (in-place)
|
||||
try {
|
||||
const { data: domRgba } = decodePng(domPng);
|
||||
const hdrTransfer = effectiveHdr ? effectiveHdr.transfer : ("hlg" as HdrTransfer);
|
||||
blitRgba8OverRgb48le(domRgba, hdrRgb, width, height, hdrTransfer);
|
||||
} catch (err) {
|
||||
log.warn("DOM layer decode/blit failed; skipping overlay for frame", {
|
||||
frameIndex: i,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
composited = hdrRgb;
|
||||
} else {
|
||||
composited = Buffer.alloc(width * height * 6);
|
||||
if (i % 30 === 0) {
|
||||
const hdrEl = stackingInfo.find((e) => e.isHdr);
|
||||
const hdrInLayers = layers.some((l) => l.type === "hdr");
|
||||
log.debug("[Render] HDR layer composite frame", {
|
||||
frame: i,
|
||||
time: time.toFixed(2),
|
||||
hdrElement: hdrEl
|
||||
? { z: hdrEl.zIndex, visible: hdrEl.visible, width: hdrEl.width }
|
||||
: null,
|
||||
hdrLayerPresent: hdrInLayers,
|
||||
layerCount: layers.length,
|
||||
});
|
||||
}
|
||||
|
||||
hdrEncoder.writeFrame(composited);
|
||||
// Start with a black canvas
|
||||
const canvas = Buffer.alloc(width * height * 6);
|
||||
|
||||
// Composite layers bottom-to-top
|
||||
for (const layer of layers) {
|
||||
if (layer.type === "hdr") {
|
||||
const el = layer.element;
|
||||
const frameDir = hdrFrameDirs.get(el.id);
|
||||
const video = composition.videos.find((v) => v.id === el.id);
|
||||
if (!frameDir || !video) continue;
|
||||
|
||||
// Frame index within the video (1-based for FFmpeg image2 output).
|
||||
// Clamp against the highest extracted frame in the directory to
|
||||
// avoid issuing an existsSync per requested time when the
|
||||
// composition outlives the source clip.
|
||||
const videoFrameIndex = Math.round((time - video.start) * job.config.fps) + 1;
|
||||
const maxIndex = getMaxFrameIndex(frameDir);
|
||||
const inBounds =
|
||||
videoFrameIndex >= 1 && (maxIndex === 0 || videoFrameIndex <= maxIndex);
|
||||
const framePath = inBounds
|
||||
? join(frameDir, `frame_${String(videoFrameIndex).padStart(4, "0")}.png`)
|
||||
: null;
|
||||
|
||||
if (framePath !== null && existsSync(framePath)) {
|
||||
try {
|
||||
const hdrRgb = decodePngToRgb48le(readFileSync(framePath)).data;
|
||||
blitRgb48leRegion(
|
||||
canvas,
|
||||
hdrRgb,
|
||||
el.x,
|
||||
el.y,
|
||||
el.width,
|
||||
el.height,
|
||||
width,
|
||||
height,
|
||||
el.opacity < 0.999 ? el.opacity : undefined,
|
||||
);
|
||||
} catch (err) {
|
||||
log.warn("HDR layer decode/blit failed; skipping layer for frame", {
|
||||
frameIndex: i,
|
||||
videoId: el.id,
|
||||
framePath,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// DOM layer: hide elements NOT in this layer + all HDR videos.
|
||||
// All elements (including invisible SDR videos) are in the stacking
|
||||
// info so their injected <img> replacements get hidden from other layers.
|
||||
const allElementIds = stackingInfo.map((e) => e.id);
|
||||
const layerIds = new Set(layer.elementIds);
|
||||
const hideIds = allElementIds.filter(
|
||||
(id) => !layerIds.has(id) || nativeHdrVideoIds.has(id),
|
||||
);
|
||||
|
||||
await hideVideoElements(domSession.page, hideIds);
|
||||
const domPng = await captureAlphaPng(domSession.page, width, height);
|
||||
await showVideoElements(domSession.page, hideIds);
|
||||
|
||||
// Re-seek GSAP to restore animated properties (opacity, transforms)
|
||||
// that showVideoElements clobbered via removeProperty.
|
||||
await domSession.page.evaluate((t: number) => {
|
||||
if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t);
|
||||
}, time);
|
||||
|
||||
try {
|
||||
const { data: domRgba } = decodePng(domPng);
|
||||
// We're inside `if (hasHdrVideo)` which already required `effectiveHdr` to be set,
|
||||
// but be defensive: fall back to HLG so we always feed the encoder a valid transfer.
|
||||
const hdrTransfer: HdrTransfer = effectiveHdr ? effectiveHdr.transfer : "hlg";
|
||||
blitRgba8OverRgb48le(domRgba, canvas, width, height, hdrTransfer);
|
||||
} catch (err) {
|
||||
log.warn("DOM layer decode/blit failed; skipping overlay for frame", {
|
||||
frameIndex: i,
|
||||
layerIds: layer.elementIds,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hdrEncoder.writeFrame(canvas);
|
||||
|
||||
job.framesRendered = i + 1;
|
||||
if ((i + 1) % 10 === 0 || i + 1 === job.totalFrames!) {
|
||||
|
||||
Reference in New Issue
Block a user