Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion packages/engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -172,7 +180,9 @@ export {
hideVideoElements,
showVideoElements,
queryVideoElementBounds,
queryElementStacking,
type VideoElementBounds,
type ElementStackingInfo,
} from "./services/videoFrameInjector.js";

export {
Expand Down
76 changes: 76 additions & 0 deletions packages/engine/src/services/videoFrameInjector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,3 +225,79 @@ 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.
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);
}
196 changes: 136 additions & 60 deletions packages/engine/src/utils/alphaBlit.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { deflateSync } from "zlib";
import { decodePng, blitRgba8OverRgb48le } from "./alphaBlit.js";
import { decodePng, blitRgba8OverRgb48le, blitRgb48leRegion } from "./alphaBlit.js";

// ── PNG construction helpers ─────────────────────────────────────────────────

Expand Down Expand Up @@ -189,67 +189,68 @@ function makeDomRgba(
}

describe("blitRgba8OverRgb48le", () => {
it("fully transparent DOM: HDR pixel passes through unchanged", () => {
const hdr = makeHdrFrame(1, 1, 32000, 40000, 50000);
it("fully transparent DOM: canvas unchanged", () => {
const canvas = makeHdrFrame(1, 1, 32000, 40000, 50000);
const dom = makeDomRgba(1, 1, 255, 0, 0, 0); // red but alpha=0
const out = blitRgba8OverRgb48le(dom, hdr, 1, 1);
blitRgba8OverRgb48le(dom, canvas, 1, 1);

expect(out.readUInt16LE(0)).toBe(32000);
expect(out.readUInt16LE(2)).toBe(40000);
expect(out.readUInt16LE(4)).toBe(50000);
expect(canvas.readUInt16LE(0)).toBe(32000);
expect(canvas.readUInt16LE(2)).toBe(40000);
expect(canvas.readUInt16LE(4)).toBe(50000);
});

it("fully opaque DOM: sRGB→HLG converted values", () => {
const hdr = makeHdrFrame(1, 1, 10000, 20000, 30000);
it("fully opaque DOM: sRGB→HLG converted values overwrite canvas", () => {
const canvas = makeHdrFrame(1, 1, 10000, 20000, 30000);
const dom = makeDomRgba(1, 1, 255, 128, 0, 255); // R=255, G=128, B=0, full opaque
const out = blitRgba8OverRgb48le(dom, hdr, 1, 1);
blitRgba8OverRgb48le(dom, canvas, 1, 1);

// sRGB 255 → HLG 65535 (white maps to white)
// sRGB 128 → HLG ~46484 (mid-gray maps higher due to HLG OETF)
// sRGB 0 → HLG 0
expect(out.readUInt16LE(0)).toBe(65535);
expect(out.readUInt16LE(2)).toBeGreaterThan(40000); // HLG mid-gray > sRGB mid-gray
expect(out.readUInt16LE(2)).toBeLessThan(50000);
expect(out.readUInt16LE(4)).toBe(0);
expect(canvas.readUInt16LE(0)).toBe(65535);
expect(canvas.readUInt16LE(2)).toBeGreaterThan(40000); // HLG mid-gray > sRGB mid-gray
expect(canvas.readUInt16LE(2)).toBeLessThan(50000);
expect(canvas.readUInt16LE(4)).toBe(0);
});

it("sRGB→HLG: black stays black, white stays white", () => {
const hdr = makeHdrFrame(1, 1, 0, 0, 0);
const canvasBlack = makeHdrFrame(1, 1, 0, 0, 0);
const domBlack = makeDomRgba(1, 1, 0, 0, 0, 255);
const outBlack = blitRgba8OverRgb48le(domBlack, hdr, 1, 1);
expect(outBlack.readUInt16LE(0)).toBe(0);
blitRgba8OverRgb48le(domBlack, canvasBlack, 1, 1);
expect(canvasBlack.readUInt16LE(0)).toBe(0);

const canvasWhite = makeHdrFrame(1, 1, 0, 0, 0);
const domWhite = makeDomRgba(1, 1, 255, 255, 255, 255);
const outWhite = blitRgba8OverRgb48le(domWhite, hdr, 1, 1);
expect(outWhite.readUInt16LE(0)).toBe(65535);
blitRgba8OverRgb48le(domWhite, canvasWhite, 1, 1);
expect(canvasWhite.readUInt16LE(0)).toBe(65535);
});

it("50% alpha: HLG-converted DOM blended with HDR", () => {
it("50% alpha: HLG-converted DOM blended with canvas", () => {
// DOM: white (255, 255, 255) at alpha=128 (~50%)
// HDR: black (0, 0, 0)
const hdr = makeHdrFrame(1, 1, 0, 0, 0);
// Canvas: black (0, 0, 0)
const canvas = makeHdrFrame(1, 1, 0, 0, 0);
const dom = makeDomRgba(1, 1, 255, 255, 255, 128);
const out = blitRgba8OverRgb48le(dom, hdr, 1, 1);
blitRgba8OverRgb48le(dom, canvas, 1, 1);

// sRGB 255 → HLG 65535, blended 50/50 with black
const alpha = 128 / 255;
const expectedR = Math.round(65535 * alpha);
expect(out.readUInt16LE(0)).toBeCloseTo(expectedR, -1);
expect(canvas.readUInt16LE(0)).toBeCloseTo(expectedR, -1);
});

it("50% alpha blends with non-zero HDR", () => {
// DOM: 8-bit red=200, HDR: 16-bit red=32000, alpha=128
const hdr = makeHdrFrame(1, 1, 32000, 0, 0);
it("50% alpha blends with non-zero canvas", () => {
// DOM: 8-bit red=200, canvas: 16-bit red=32000, alpha=128
const canvas = makeHdrFrame(1, 1, 32000, 0, 0);
const dom = makeDomRgba(1, 1, 200, 0, 0, 128);
const out = blitRgba8OverRgb48le(dom, hdr, 1, 1);
blitRgba8OverRgb48le(dom, canvas, 1, 1);

// sRGB 200 → HLG value, blended ~50/50 with HDR red=32000
// sRGB 200 → HLG value, blended ~50/50 with canvas red=32000
// Result should be higher than 32000 (pulled up by the HLG-converted DOM value)
expect(out.readUInt16LE(0)).toBeGreaterThan(32000);
expect(canvas.readUInt16LE(0)).toBeGreaterThan(32000);
});

it("handles a 2x2 frame correctly pixel-by-pixel", () => {
const hdr = makeHdrFrame(2, 2, 0, 0, 0);
const canvas = makeHdrFrame(2, 2, 0, 0, 0);
// First pixel: fully opaque white. Others: fully transparent.
const dom = new Uint8Array(2 * 2 * 4);
dom[0] = 255;
Expand All @@ -258,31 +259,107 @@ describe("blitRgba8OverRgb48le", () => {
dom[3] = 255; // pixel 0: opaque white
// pixels 1-3: alpha=0 (transparent)

const out = blitRgba8OverRgb48le(dom, hdr, 2, 2);
blitRgba8OverRgb48le(dom, canvas, 2, 2);

// Pixel 0: sRGB white → HLG white (65535)
expect(out.readUInt16LE(0)).toBe(65535);
expect(out.readUInt16LE(2)).toBe(65535);
expect(out.readUInt16LE(4)).toBe(65535);

// Pixel 1: transparent DOM → HDR black (0, 0, 0)
expect(out.readUInt16LE(6)).toBe(0);
expect(out.readUInt16LE(8)).toBe(0);
expect(out.readUInt16LE(10)).toBe(0);
expect(canvas.readUInt16LE(0)).toBe(65535);
expect(canvas.readUInt16LE(2)).toBe(65535);
expect(canvas.readUInt16LE(4)).toBe(65535);

// Pixel 1: transparent DOM → canvas black (0, 0, 0) unchanged
expect(canvas.readUInt16LE(6)).toBe(0);
expect(canvas.readUInt16LE(8)).toBe(0);
expect(canvas.readUInt16LE(10)).toBe(0);
});
});

describe("blitRgba8OverRgb48le with PQ transfer", () => {
it("PQ: black stays black, white maps to PQ white", () => {
const canvasBlack = makeHdrFrame(1, 1, 0, 0, 0);
const domBlack = makeDomRgba(1, 1, 0, 0, 0, 255);
blitRgba8OverRgb48le(domBlack, canvasBlack, 1, 1, "pq");
expect(canvasBlack.readUInt16LE(0)).toBe(0);

const canvasWhite = makeHdrFrame(1, 1, 0, 0, 0);
const domWhite = makeDomRgba(1, 1, 255, 255, 255, 255);
blitRgba8OverRgb48le(domWhite, canvasWhite, 1, 1, "pq");
// PQ white at SDR 203 nits is NOT 65535 (that's 10000 nits)
// SDR white in PQ ≈ 58% signal → ~38000
const pqWhite = canvasWhite.readUInt16LE(0);
expect(pqWhite).toBeGreaterThan(30000);
expect(pqWhite).toBeLessThan(45000);
});

it("PQ mid-gray differs from HLG mid-gray", () => {
const canvasHlg = makeHdrFrame(1, 1, 0, 0, 0);
const canvasPq = makeHdrFrame(1, 1, 0, 0, 0);
const dom = makeDomRgba(1, 1, 128, 128, 128, 255);

blitRgba8OverRgb48le(dom, canvasHlg, 1, 1, "hlg");
blitRgba8OverRgb48le(dom, canvasPq, 1, 1, "pq");

const hlgVal = canvasHlg.readUInt16LE(0);
const pqVal = canvasPq.readUInt16LE(0);
// PQ and HLG encode mid-gray differently
expect(hlgVal).not.toBe(pqVal);
// Both should be non-zero
expect(hlgVal).toBeGreaterThan(0);
expect(pqVal).toBeGreaterThan(0);
});
});

it("output buffer has correct size", () => {
const hdr = makeHdrFrame(4, 3, 0, 0, 0);
const dom = makeDomRgba(4, 3, 0, 0, 0, 0);
const out = blitRgba8OverRgb48le(dom, hdr, 4, 3);
expect(out.length).toBe(4 * 3 * 6);
// ── blitRgb48leRegion tests ──────────────────────────────────────────────────

describe("blitRgb48leRegion", () => {
it("copies a region at position (0,0) — full overlap", () => {
const canvas = Buffer.alloc(4 * 4 * 6); // 4x4 black
const source = makeHdrFrame(2, 2, 10000, 20000, 30000);
blitRgb48leRegion(canvas, source, 0, 0, 2, 2, 4);
expect(canvas.readUInt16LE(0)).toBe(10000);
expect(canvas.readUInt16LE(2)).toBe(20000);
expect(canvas.readUInt16LE(4)).toBe(30000);
expect(canvas.readUInt16LE(2 * 6)).toBe(0);
});

it("copies a region at offset position", () => {
const canvas = Buffer.alloc(4 * 4 * 6);
const source = makeHdrFrame(2, 2, 50000, 40000, 30000);
blitRgb48leRegion(canvas, source, 1, 1, 2, 2, 4);
expect(canvas.readUInt16LE(0)).toBe(0);
const off = (1 * 4 + 1) * 6;
expect(canvas.readUInt16LE(off)).toBe(50000);
});

it("clips when region extends beyond canvas edge", () => {
const canvas = Buffer.alloc(4 * 4 * 6);
const source = makeHdrFrame(3, 3, 10000, 20000, 30000);
blitRgb48leRegion(canvas, source, 2, 2, 3, 3, 4);
const off = (2 * 4 + 2) * 6;
expect(canvas.readUInt16LE(off)).toBe(10000);
const off2 = (3 * 4 + 3) * 6;
expect(canvas.readUInt16LE(off2)).toBe(10000);
expect(canvas.length).toBe(4 * 4 * 6);
});

it("applies opacity when provided", () => {
const canvas = Buffer.alloc(1 * 1 * 6);
const source = makeHdrFrame(1, 1, 40000, 40000, 40000);
blitRgb48leRegion(canvas, source, 0, 0, 1, 1, 1, 0.5);
expect(canvas.readUInt16LE(0)).toBe(20000);
});

it("no-op for zero-size region", () => {
const canvas = Buffer.alloc(4 * 4 * 6);
const source = makeHdrFrame(2, 2, 10000, 20000, 30000);
blitRgb48leRegion(canvas, source, 0, 0, 0, 0, 4);
expect(canvas.readUInt16LE(0)).toBe(0);
});
});

// ── Round-trip test: decodePng → blitRgba8OverRgb48le ────────────────────────

describe("decodePng + blitRgba8OverRgb48le integration", () => {
it("transparent PNG overlay leaves HDR frame untouched", () => {
it("transparent PNG overlay leaves canvas untouched", () => {
const width = 2;
const height = 2;

Expand All @@ -291,20 +368,19 @@ describe("decodePng + blitRgba8OverRgb48le integration", () => {
const png = makePng(width, height, pixels);
const { data: domRgba } = decodePng(png);

// HDR frame with known values
const hdr = makeHdrFrame(width, height, 10000, 20000, 30000);

const out = blitRgba8OverRgb48le(domRgba, hdr, width, height);
// Canvas pre-filled with known HDR values
const canvas = makeHdrFrame(width, height, 10000, 20000, 30000);
blitRgba8OverRgb48le(domRgba, canvas, width, height);

// All pixels should be unchanged HDR
// All pixels should be unchanged
for (let i = 0; i < width * height; i++) {
expect(out.readUInt16LE(i * 6 + 0)).toBe(10000);
expect(out.readUInt16LE(i * 6 + 2)).toBe(20000);
expect(out.readUInt16LE(i * 6 + 4)).toBe(30000);
expect(canvas.readUInt16LE(i * 6 + 0)).toBe(10000);
expect(canvas.readUInt16LE(i * 6 + 2)).toBe(20000);
expect(canvas.readUInt16LE(i * 6 + 4)).toBe(30000);
}
});

it("fully opaque PNG overlay covers all HDR pixels (sRGB→HLG)", () => {
it("fully opaque PNG overlay overwrites all canvas pixels (sRGB→HLG)", () => {
const width = 2;
const height = 2;

Expand All @@ -315,14 +391,14 @@ describe("decodePng + blitRgba8OverRgb48le integration", () => {
const png = makePng(width, height, pixels);
const { data: domRgba } = decodePng(png);

const hdr = makeHdrFrame(width, height, 50000, 40000, 30000);
const out = blitRgba8OverRgb48le(domRgba, hdr, width, height);
const canvas = makeHdrFrame(width, height, 50000, 40000, 30000);
blitRgba8OverRgb48le(domRgba, canvas, width, height);

// sRGB blue (0,0,255) → HLG (0, 0, 65535) — black/white map identically
for (let i = 0; i < width * height; i++) {
expect(out.readUInt16LE(i * 6 + 0)).toBe(0);
expect(out.readUInt16LE(i * 6 + 2)).toBe(0);
expect(out.readUInt16LE(i * 6 + 4)).toBe(65535);
expect(canvas.readUInt16LE(i * 6 + 0)).toBe(0);
expect(canvas.readUInt16LE(i * 6 + 2)).toBe(0);
expect(canvas.readUInt16LE(i * 6 + 4)).toBe(65535);
}
});
});
Loading
Loading