mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 07:09:59 +00:00
feat(engine): add HDR two-pass compositing — DOM layer + native HLG video (#288)
## Summary Compositions with HDR video AND DOM overlays (text, graphics, SDR video) couldn't render both correctly — either HDR data was lost (Chrome captures sRGB only) or DOM overlays were missing (FFmpeg pass-through skips Chrome). This PR adds in-memory alpha compositing that combines both. ## What it does **Per-frame two-pass capture:** 1. **DOM pass** — Chrome screenshots the page with a transparent background (CDP alpha). HDR videos are hidden, leaving transparent holes where they go. 2. **HDR pass** — Pre-extracted native HLG/PQ frames (16-bit PNG from FFmpeg) are read from disk. 3. **Composite** — DOM pixels (sRGB RGBA8) are alpha-composited over HDR pixels (rgb48le) in Node.js memory, with sRGB→HLG/PQ conversion via a 256-entry lookup table. **Key components:** - `decodePng()` / `decodePngToRgb48le()` — Pure Node.js PNG decoders (no native dependencies). Support all 5 PNG filter types. - `blitRgba8OverRgb48le()` — Alpha composite with per-pixel sRGB→HDR LUT conversion. Fast paths for alpha=0 (skip) and alpha=255 (overwrite). - `initTransparentBackground()` + `captureAlphaPng()` — Split CDP transparent background setup (once) from per-frame screenshot capture (eliminates 2 CDP round-trips per frame). - Single-pass FFmpeg extraction — All HDR frames extracted in one sequential FFmpeg run (avoids duplicate frames from per-frame `-ss` fast seek). ## Key design decisions | Decision | Why | |----------|-----| | In-memory compositing (not FFmpeg overlay) | Eliminates ~2400 process spawns + temp files per render. Pure pixel math is 10x faster. | | 16-bit PNG intermediate | Raw `-f rawvideo` loses color metadata, causing moiré artifacts. PNG is self-describing. | | sRGB→HLG LUT (256 entries) | DOM content is sRGB. Without conversion, it appears orange-shifted in HLG stream. | | Native HDR detection before extraction | `extractAllVideoFrames` converts SDR→HDR. Pre-extraction probe identifies original HDR sources so only truly-HDR videos get native extraction. | ## Files changed | File | What changed | |------|-------------| | `packages/engine/src/utils/alphaBlit.ts` | **NEW** — PNG decode, sRGB→HDR LUT, alpha compositing (14 tests) | | `packages/engine/src/services/screenshotService.ts` | Transparent background CDP, `captureAlphaPng()` | | `packages/engine/src/services/videoFrameInjector.ts` | `hideVideoElements()` / `showVideoElements()` | | `packages/engine/src/services/streamingEncoder.ts` | Input color space tags for rgb48le | | `packages/producer/src/services/renderOrchestrator.ts` | Two-pass HDR capture loop, native HDR detection | ## How to test Render a composition with an HDR video background and text overlays. Both should be visible — HDR video at full quality, text crisp with correct colors (not orange-shifted). ## Stack position **3 of 6** — Stacked on #265 (HDR output pipeline). This is the foundation for all layered compositing that follows. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
@@ -0,0 +1,993 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { deflateSync } from "zlib";
|
||||
import {
|
||||
decodePng,
|
||||
decodePngToRgb48le,
|
||||
blitRgba8OverRgb48le,
|
||||
blitRgb48leRegion,
|
||||
blitRgb48leAffine,
|
||||
parseTransformMatrix,
|
||||
roundedRectAlpha,
|
||||
} from "./alphaBlit.js";
|
||||
|
||||
// ── PNG construction helpers ─────────────────────────────────────────────────
|
||||
|
||||
function uint32BE(n: number): Buffer {
|
||||
const b = Buffer.allocUnsafe(4);
|
||||
b.writeUInt32BE(n, 0);
|
||||
return b;
|
||||
}
|
||||
|
||||
function crc32(data: Buffer): number {
|
||||
let crc = 0xffffffff;
|
||||
const table = crc32Table();
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
crc = (table[(crc ^ (data[i] ?? 0)) & 0xff] ?? 0) ^ (crc >>> 8);
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
let _crcTable: Uint32Array | undefined;
|
||||
function crc32Table(): Uint32Array {
|
||||
if (_crcTable) return _crcTable;
|
||||
const t = new Uint32Array(256);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let c = i;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
t[i] = c;
|
||||
}
|
||||
_crcTable = t;
|
||||
return t;
|
||||
}
|
||||
|
||||
function makeChunk(type: string, data: Buffer): Buffer {
|
||||
const typeBuffer = Buffer.from(type, "ascii");
|
||||
const crcInput = Buffer.concat([typeBuffer, data]);
|
||||
const crcBuf = uint32BE(crc32(crcInput));
|
||||
return Buffer.concat([uint32BE(data.length), typeBuffer, data, crcBuf]);
|
||||
}
|
||||
|
||||
const PNG_SIG = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
|
||||
/**
|
||||
* Build a minimal RGBA PNG for testing.
|
||||
* pixels: flat RGBA array (row-major, 8-bit per channel)
|
||||
*/
|
||||
function makePng(width: number, height: number, pixels: number[]): Buffer {
|
||||
// IHDR
|
||||
const ihdr = Buffer.allocUnsafe(13);
|
||||
ihdr.writeUInt32BE(width, 0);
|
||||
ihdr.writeUInt32BE(height, 4);
|
||||
ihdr[8] = 8; // bit depth
|
||||
ihdr[9] = 6; // color type RGBA
|
||||
ihdr[10] = 0; // compression
|
||||
ihdr[11] = 0; // filter method
|
||||
ihdr[12] = 0; // interlace none
|
||||
|
||||
// Raw scanlines with filter byte 0 (None)
|
||||
const scanlines: number[] = [];
|
||||
for (let y = 0; y < height; y++) {
|
||||
scanlines.push(0); // filter type None
|
||||
for (let x = 0; x < width; x++) {
|
||||
const i = (y * width + x) * 4;
|
||||
scanlines.push(pixels[i] ?? 0, pixels[i + 1] ?? 0, pixels[i + 2] ?? 0, pixels[i + 3] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
const idatData = deflateSync(Buffer.from(scanlines));
|
||||
|
||||
return Buffer.concat([
|
||||
PNG_SIG,
|
||||
makeChunk("IHDR", ihdr),
|
||||
makeChunk("IDAT", idatData),
|
||||
makeChunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── decodePng tests ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("decodePng", () => {
|
||||
it("decodes a 1x1 RGBA PNG correctly", () => {
|
||||
// RGBA: red pixel, full opacity
|
||||
const png = makePng(1, 1, [255, 0, 0, 255]);
|
||||
const { width, height, data } = decodePng(png);
|
||||
expect(width).toBe(1);
|
||||
expect(height).toBe(1);
|
||||
expect(data[0]).toBe(255); // R
|
||||
expect(data[1]).toBe(0); // G
|
||||
expect(data[2]).toBe(0); // B
|
||||
expect(data[3]).toBe(255); // A
|
||||
});
|
||||
|
||||
it("decodes a 2x2 RGBA PNG with multiple pixels", () => {
|
||||
// TL=red, TR=green, BL=blue, BR=white (all full opacity)
|
||||
const pixels = [
|
||||
255,
|
||||
0,
|
||||
0,
|
||||
255, // TL red
|
||||
0,
|
||||
255,
|
||||
0,
|
||||
255, // TR green
|
||||
0,
|
||||
0,
|
||||
255,
|
||||
255, // BL blue
|
||||
255,
|
||||
255,
|
||||
255,
|
||||
255, // BR white
|
||||
];
|
||||
const png = makePng(2, 2, pixels);
|
||||
const { width, height, data } = decodePng(png);
|
||||
expect(width).toBe(2);
|
||||
expect(height).toBe(2);
|
||||
|
||||
// Top-left: red
|
||||
expect(data[0]).toBe(255);
|
||||
expect(data[1]).toBe(0);
|
||||
expect(data[2]).toBe(0);
|
||||
expect(data[3]).toBe(255);
|
||||
|
||||
// Bottom-right: white
|
||||
expect(data[12]).toBe(255);
|
||||
expect(data[13]).toBe(255);
|
||||
expect(data[14]).toBe(255);
|
||||
expect(data[15]).toBe(255);
|
||||
});
|
||||
|
||||
it("decodes a transparent pixel correctly", () => {
|
||||
const png = makePng(1, 1, [128, 64, 32, 0]);
|
||||
const { data } = decodePng(png);
|
||||
expect(data[3]).toBe(0); // alpha = 0
|
||||
});
|
||||
|
||||
it("decodes a semi-transparent pixel correctly", () => {
|
||||
const png = makePng(1, 1, [100, 150, 200, 128]);
|
||||
const { data } = decodePng(png);
|
||||
expect(data[0]).toBe(100);
|
||||
expect(data[1]).toBe(150);
|
||||
expect(data[2]).toBe(200);
|
||||
expect(data[3]).toBe(128);
|
||||
});
|
||||
|
||||
it("throws on invalid PNG signature", () => {
|
||||
const buf = Buffer.from([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
|
||||
expect(() => decodePng(buf)).toThrow("not a PNG file");
|
||||
});
|
||||
});
|
||||
|
||||
// ── PNG filter coverage ─────────────────────────────────────────────────────
|
||||
//
|
||||
// `makePng` only exercises filter type 0 (None). libpng (and Chrome) pick
|
||||
// other filter types heuristically; these tests build raw IDAT bytes with each
|
||||
// filter type so the defilter logic gets actual coverage.
|
||||
|
||||
const paethRef = (a: number, b: number, c: number): number => {
|
||||
const p = a + b - c;
|
||||
const pa = Math.abs(p - a);
|
||||
const pb = Math.abs(p - b);
|
||||
const pc = Math.abs(p - c);
|
||||
if (pa <= pb && pa <= pc) return a;
|
||||
if (pb <= pc) return b;
|
||||
return c;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a PNG with a specific filter type applied to every row. Encodes a
|
||||
* 3×2 RGBA image with unique per-channel values so any cross-channel mistake
|
||||
* in the defilter loop shows up as an assertion failure.
|
||||
*
|
||||
* @param filterType 0=None, 1=Sub, 2=Up, 3=Average, 4=Paeth
|
||||
*/
|
||||
function makePngWithFilter(filterType: 0 | 1 | 2 | 3 | 4): {
|
||||
png: Buffer;
|
||||
expectedPixels: number[];
|
||||
} {
|
||||
const width = 3;
|
||||
const height = 2;
|
||||
const bpp = 4; // RGBA, 8-bit
|
||||
const stride = width * bpp;
|
||||
|
||||
// Unique pixels so any defilter bug is observable
|
||||
const expectedPixels = [
|
||||
10, 20, 30, 255, 50, 60, 70, 255, 90, 100, 110, 255, 130, 140, 150, 255, 170, 180, 190, 255,
|
||||
210, 220, 230, 255,
|
||||
];
|
||||
|
||||
const filtered: number[] = [];
|
||||
const prev = new Uint8Array(stride);
|
||||
for (let y = 0; y < height; y++) {
|
||||
filtered.push(filterType);
|
||||
const rowStart = y * stride;
|
||||
const curr = new Uint8Array(stride);
|
||||
for (let x = 0; x < stride; x++) curr[x] = expectedPixels[rowStart + x] ?? 0;
|
||||
|
||||
const out = new Uint8Array(stride);
|
||||
for (let x = 0; x < stride; x++) {
|
||||
const a = x >= bpp ? (curr[x - bpp] ?? 0) : 0;
|
||||
const b = prev[x] ?? 0;
|
||||
const c = x >= bpp ? (prev[x - bpp] ?? 0) : 0;
|
||||
const cv = curr[x] ?? 0;
|
||||
switch (filterType) {
|
||||
case 0:
|
||||
out[x] = cv;
|
||||
break;
|
||||
case 1:
|
||||
out[x] = (cv - a) & 0xff;
|
||||
break;
|
||||
case 2:
|
||||
out[x] = (cv - b) & 0xff;
|
||||
break;
|
||||
case 3:
|
||||
out[x] = (cv - Math.floor((a + b) / 2)) & 0xff;
|
||||
break;
|
||||
case 4:
|
||||
out[x] = (cv - paethRef(a, b, c)) & 0xff;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (let x = 0; x < stride; x++) filtered.push(out[x] ?? 0);
|
||||
prev.set(curr);
|
||||
}
|
||||
|
||||
const ihdr = Buffer.allocUnsafe(13);
|
||||
ihdr.writeUInt32BE(width, 0);
|
||||
ihdr.writeUInt32BE(height, 4);
|
||||
ihdr[8] = 8;
|
||||
ihdr[9] = 6;
|
||||
ihdr[10] = 0;
|
||||
ihdr[11] = 0;
|
||||
ihdr[12] = 0;
|
||||
|
||||
const idat = deflateSync(Buffer.from(filtered));
|
||||
|
||||
return {
|
||||
png: Buffer.concat([
|
||||
PNG_SIG,
|
||||
makeChunk("IHDR", ihdr),
|
||||
makeChunk("IDAT", idat),
|
||||
makeChunk("IEND", Buffer.alloc(0)),
|
||||
]),
|
||||
expectedPixels,
|
||||
};
|
||||
}
|
||||
|
||||
describe("decodePng filter coverage", () => {
|
||||
it.each([
|
||||
[0, "None"],
|
||||
[1, "Sub"],
|
||||
[2, "Up"],
|
||||
[3, "Average"],
|
||||
[4, "Paeth"],
|
||||
] as const)("round-trips a 3×2 PNG with filter type %d (%s)", (filterType) => {
|
||||
const { png, expectedPixels } = makePngWithFilter(filterType);
|
||||
const { width, height, data } = decodePng(png);
|
||||
expect(width).toBe(3);
|
||||
expect(height).toBe(2);
|
||||
for (let i = 0; i < expectedPixels.length; i++) {
|
||||
expect(data[i]).toBe(expectedPixels[i]);
|
||||
}
|
||||
});
|
||||
|
||||
it("decodes a PNG split across multiple IDAT chunks", () => {
|
||||
// Build a normal single-IDAT PNG, then split its IDAT payload in half.
|
||||
// Chrome routinely emits multi-chunk IDATs (default ~8KB segment size).
|
||||
const { png: singleIdatPng, expectedPixels } = makePngWithFilter(0);
|
||||
|
||||
// Walk chunks to find IDAT
|
||||
let pos = 8;
|
||||
let ihdrChunk: Buffer | null = null;
|
||||
let idatPayload: Buffer | null = null;
|
||||
while (pos + 12 <= singleIdatPng.length) {
|
||||
const len = singleIdatPng.readUInt32BE(pos);
|
||||
const type = singleIdatPng.toString("ascii", pos + 4, pos + 8);
|
||||
const data = singleIdatPng.subarray(pos + 8, pos + 8 + len);
|
||||
const fullChunk = singleIdatPng.subarray(pos, pos + 12 + len);
|
||||
if (type === "IHDR") ihdrChunk = Buffer.from(fullChunk);
|
||||
if (type === "IDAT") idatPayload = Buffer.from(data);
|
||||
if (type === "IEND") break;
|
||||
pos += 12 + len;
|
||||
}
|
||||
expect(ihdrChunk).not.toBeNull();
|
||||
expect(idatPayload).not.toBeNull();
|
||||
if (!ihdrChunk || !idatPayload) return;
|
||||
|
||||
// Split the IDAT payload roughly in half across two IDAT chunks
|
||||
const split = Math.floor(idatPayload.length / 2);
|
||||
const part1 = idatPayload.subarray(0, split);
|
||||
const part2 = idatPayload.subarray(split);
|
||||
|
||||
const multiIdatPng = Buffer.concat([
|
||||
PNG_SIG,
|
||||
ihdrChunk,
|
||||
makeChunk("IDAT", Buffer.from(part1)),
|
||||
makeChunk("IDAT", Buffer.from(part2)),
|
||||
makeChunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
|
||||
const { data } = decodePng(multiIdatPng);
|
||||
for (let i = 0; i < expectedPixels.length; i++) {
|
||||
expect(data[i]).toBe(expectedPixels[i]);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws on Adam7-interlaced PNGs", () => {
|
||||
const ihdr = Buffer.allocUnsafe(13);
|
||||
ihdr.writeUInt32BE(1, 0);
|
||||
ihdr.writeUInt32BE(1, 4);
|
||||
ihdr[8] = 8;
|
||||
ihdr[9] = 6;
|
||||
ihdr[10] = 0;
|
||||
ihdr[11] = 0;
|
||||
ihdr[12] = 1; // Adam7 interlace
|
||||
const idat = deflateSync(Buffer.from([0, 0, 0, 0, 255]));
|
||||
const png = Buffer.concat([
|
||||
PNG_SIG,
|
||||
makeChunk("IHDR", ihdr),
|
||||
makeChunk("IDAT", idat),
|
||||
makeChunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
expect(() => decodePng(png)).toThrow("interlace");
|
||||
});
|
||||
|
||||
it("throws on PNGs missing the IHDR chunk", () => {
|
||||
const idat = deflateSync(Buffer.from([0, 0, 0, 0, 255]));
|
||||
const png = Buffer.concat([
|
||||
PNG_SIG,
|
||||
makeChunk("IDAT", idat),
|
||||
makeChunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
expect(() => decodePng(png)).toThrow("IHDR");
|
||||
});
|
||||
});
|
||||
|
||||
// ── decodePngToRgb48le tests ────────────────────────────────────────────────
|
||||
//
|
||||
// FFmpeg emits 16-bit RGB PNGs (big-endian on the wire). The decoder swaps to
|
||||
// little-endian for the streaming HDR encoder. These tests cover the byte-order
|
||||
// swap, precision preservation, and multi-pixel row-major layout that the
|
||||
// 8-bit suite cannot exercise.
|
||||
|
||||
/**
|
||||
* Build a 16-bit RGB PNG (colorType 2, bitDepth 16). PNG stores each 16-bit
|
||||
* sample as two big-endian bytes; the decoder must swap them to LE.
|
||||
*
|
||||
* @param pixels Flat array of [r16, g16, b16, r16, g16, b16, ...] values
|
||||
* (one entry per channel sample, 0–65535).
|
||||
*/
|
||||
function makePng16(width: number, height: number, pixels: number[]): Buffer {
|
||||
const ihdr = Buffer.allocUnsafe(13);
|
||||
ihdr.writeUInt32BE(width, 0);
|
||||
ihdr.writeUInt32BE(height, 4);
|
||||
ihdr[8] = 16; // bit depth
|
||||
ihdr[9] = 2; // color type RGB
|
||||
ihdr[10] = 0;
|
||||
ihdr[11] = 0;
|
||||
ihdr[12] = 0;
|
||||
|
||||
const stride = width * 6; // 3 channels × 2 bytes
|
||||
const filtered: number[] = [];
|
||||
for (let y = 0; y < height; y++) {
|
||||
filtered.push(0); // filter type None
|
||||
for (let x = 0; x < width; x++) {
|
||||
const baseSample = (y * width + x) * 3;
|
||||
for (let ch = 0; ch < 3; ch++) {
|
||||
const v = pixels[baseSample + ch] ?? 0;
|
||||
filtered.push((v >> 8) & 0xff); // high byte (BE on wire)
|
||||
filtered.push(v & 0xff); // low byte
|
||||
}
|
||||
}
|
||||
void stride;
|
||||
}
|
||||
|
||||
const idat = deflateSync(Buffer.from(filtered));
|
||||
return Buffer.concat([
|
||||
PNG_SIG,
|
||||
makeChunk("IHDR", ihdr),
|
||||
makeChunk("IDAT", idat),
|
||||
makeChunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
describe("decodePngToRgb48le", () => {
|
||||
it("swaps PNG big-endian samples to little-endian rgb48le", () => {
|
||||
// Pick a value where high and low bytes differ so a missed swap is observable
|
||||
const v = 0x1234;
|
||||
const png = makePng16(1, 1, [v, v, v]);
|
||||
const { width, height, data } = decodePngToRgb48le(png);
|
||||
expect(width).toBe(1);
|
||||
expect(height).toBe(1);
|
||||
expect(data.length).toBe(6);
|
||||
expect(data.readUInt16LE(0)).toBe(v);
|
||||
expect(data.readUInt16LE(2)).toBe(v);
|
||||
expect(data.readUInt16LE(4)).toBe(v);
|
||||
// Spot-check raw byte order: low byte first, then high
|
||||
expect(data[0]).toBe(0x34);
|
||||
expect(data[1]).toBe(0x12);
|
||||
});
|
||||
|
||||
it("preserves full 16-bit precision (no 8-bit truncation)", () => {
|
||||
// A value whose low byte alone would be misleading — proves both bytes survive
|
||||
const r = 0xabcd;
|
||||
const g = 0xfedc;
|
||||
const b = 0x0102;
|
||||
const png = makePng16(1, 1, [r, g, b]);
|
||||
const { data } = decodePngToRgb48le(png);
|
||||
expect(data.readUInt16LE(0)).toBe(r);
|
||||
expect(data.readUInt16LE(2)).toBe(g);
|
||||
expect(data.readUInt16LE(4)).toBe(b);
|
||||
});
|
||||
|
||||
it("decodes a 2×2 image with row-major layout", () => {
|
||||
const pixels = [
|
||||
// row 0
|
||||
1000, 2000, 3000, 4000, 5000, 6000,
|
||||
// row 1
|
||||
7000, 8000, 9000, 10000, 11000, 12000,
|
||||
];
|
||||
const png = makePng16(2, 2, pixels);
|
||||
const { width, height, data } = decodePngToRgb48le(png);
|
||||
expect(width).toBe(2);
|
||||
expect(height).toBe(2);
|
||||
expect(data.length).toBe(2 * 2 * 6);
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
expect(data.readUInt16LE(i * 6 + 0)).toBe(pixels[i * 3 + 0]);
|
||||
expect(data.readUInt16LE(i * 6 + 2)).toBe(pixels[i * 3 + 1]);
|
||||
expect(data.readUInt16LE(i * 6 + 4)).toBe(pixels[i * 3 + 2]);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects 8-bit PNGs with a clear error", () => {
|
||||
const png = makePng(1, 1, [255, 0, 0, 255]); // 8-bit RGBA
|
||||
expect(() => decodePngToRgb48le(png)).toThrow(/bit depth/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── blitRgba8OverRgb48le tests ───────────────────────────────────────────────
|
||||
|
||||
/** Build an rgb48le buffer with a single solid color (16-bit per channel) */
|
||||
function makeHdrFrame(
|
||||
width: number,
|
||||
height: number,
|
||||
r16: number,
|
||||
g16: number,
|
||||
b16: number,
|
||||
): Buffer {
|
||||
const buf = Buffer.allocUnsafe(width * height * 6);
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
buf.writeUInt16LE(r16, i * 6);
|
||||
buf.writeUInt16LE(g16, i * 6 + 2);
|
||||
buf.writeUInt16LE(b16, i * 6 + 4);
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
/** Build a raw RGBA array (Uint8Array) with a single solid color */
|
||||
function makeDomRgba(
|
||||
width: number,
|
||||
height: number,
|
||||
r: number,
|
||||
g: number,
|
||||
b: number,
|
||||
a: number,
|
||||
): Uint8Array {
|
||||
const arr = new Uint8Array(width * height * 4);
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
arr[i * 4 + 0] = r;
|
||||
arr[i * 4 + 1] = g;
|
||||
arr[i * 4 + 2] = b;
|
||||
arr[i * 4 + 3] = a;
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
describe("blitRgba8OverRgb48le", () => {
|
||||
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
|
||||
blitRgba8OverRgb48le(dom, canvas, 1, 1);
|
||||
|
||||
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 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
|
||||
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(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 canvasBlack = makeHdrFrame(1, 1, 0, 0, 0);
|
||||
const domBlack = makeDomRgba(1, 1, 0, 0, 0, 255);
|
||||
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);
|
||||
blitRgba8OverRgb48le(domWhite, canvasWhite, 1, 1);
|
||||
expect(canvasWhite.readUInt16LE(0)).toBe(65535);
|
||||
});
|
||||
|
||||
it("50% alpha: HLG-converted DOM blended with canvas", () => {
|
||||
// DOM: white (255, 255, 255) at alpha=128 (~50%)
|
||||
// Canvas: black (0, 0, 0)
|
||||
const canvas = makeHdrFrame(1, 1, 0, 0, 0);
|
||||
const dom = makeDomRgba(1, 1, 255, 255, 255, 128);
|
||||
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(canvas.readUInt16LE(0)).toBeCloseTo(expectedR, -1);
|
||||
});
|
||||
|
||||
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);
|
||||
blitRgba8OverRgb48le(dom, canvas, 1, 1);
|
||||
|
||||
// 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(canvas.readUInt16LE(0)).toBeGreaterThan(32000);
|
||||
});
|
||||
|
||||
it("α=254 still blends (no fast-path overwrite at the opaque boundary)", () => {
|
||||
// Reviewer feedback: confirm the alpha branch is taken for any α < 255.
|
||||
// α=254 should *almost* match α=255 but still leave a sliver of the canvas
|
||||
// value visible — proving we didn't accidentally fast-path α >= 254.
|
||||
const canvasOpaque = makeHdrFrame(1, 1, 0, 0, 0);
|
||||
const domOpaque = makeDomRgba(1, 1, 255, 255, 255, 255);
|
||||
blitRgba8OverRgb48le(domOpaque, canvasOpaque, 1, 1);
|
||||
const opaqueR = canvasOpaque.readUInt16LE(0);
|
||||
|
||||
const canvasNear = makeHdrFrame(1, 1, 1000, 1000, 1000);
|
||||
const domNear = makeDomRgba(1, 1, 255, 255, 255, 254);
|
||||
blitRgba8OverRgb48le(domNear, canvasNear, 1, 1);
|
||||
const nearR = canvasNear.readUInt16LE(0);
|
||||
|
||||
// α=255 over black gave us the pure HLG-of-white value
|
||||
expect(opaqueR).toBe(65535);
|
||||
// α=254 over (1000, 1000, 1000) must be *strictly less* than α=255 over black —
|
||||
// if the implementation short-circuits at α >= 254 it would also return 65535.
|
||||
expect(nearR).toBeLessThan(opaqueR);
|
||||
// …but it should still be very close (within ~1% of full white)
|
||||
expect(nearR).toBeGreaterThan(64000);
|
||||
});
|
||||
|
||||
it("handles a 2x2 frame correctly pixel-by-pixel", () => {
|
||||
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;
|
||||
dom[1] = 255;
|
||||
dom[2] = 255;
|
||||
dom[3] = 255; // pixel 0: opaque white
|
||||
// pixels 1-3: alpha=0 (transparent)
|
||||
|
||||
blitRgba8OverRgb48le(dom, canvas, 2, 2);
|
||||
|
||||
// Pixel 0: sRGB white → HLG white (65535)
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 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, 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, 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, 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, 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, 4);
|
||||
expect(canvas.readUInt16LE(0)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── parseTransformMatrix tests ───────────────────────────────────────────────
|
||||
|
||||
describe("parseTransformMatrix", () => {
|
||||
it("returns null for 'none'", () => {
|
||||
expect(parseTransformMatrix("none")).toBeNull();
|
||||
});
|
||||
|
||||
it("parses identity matrix", () => {
|
||||
const m = parseTransformMatrix("matrix(1, 0, 0, 1, 0, 0)");
|
||||
expect(m).toEqual([1, 0, 0, 1, 0, 0]);
|
||||
});
|
||||
|
||||
it("parses scale + translate", () => {
|
||||
const m = parseTransformMatrix("matrix(0.85, 0, 0, 0.85, 100, 50)");
|
||||
expect(m).toEqual([0.85, 0, 0, 0.85, 100, 50]);
|
||||
});
|
||||
|
||||
it("parses rotation (45 degrees)", () => {
|
||||
const cos = Math.cos(Math.PI / 4);
|
||||
const sin = Math.sin(Math.PI / 4);
|
||||
const m = parseTransformMatrix(`matrix(${cos}, ${sin}, ${-sin}, ${cos}, 0, 0)`);
|
||||
expect(m).not.toBeNull();
|
||||
if (!m) return;
|
||||
expect(m[0]).toBeCloseTo(cos, 10);
|
||||
expect(m[1]).toBeCloseTo(sin, 10);
|
||||
});
|
||||
|
||||
it("parses negative values", () => {
|
||||
const m = parseTransformMatrix("matrix(-1, 0, 0, -1, -50, -100)");
|
||||
expect(m).toEqual([-1, 0, 0, -1, -50, -100]);
|
||||
});
|
||||
|
||||
it("returns null for empty string", () => {
|
||||
expect(parseTransformMatrix("")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for unsupported 3d matrix", () => {
|
||||
expect(parseTransformMatrix("matrix3d(1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1)")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── blitRgb48leAffine tests ─────────────────────────────────────────────────
|
||||
|
||||
describe("blitRgb48leAffine", () => {
|
||||
it("identity matrix produces same result as blitRgb48leRegion", () => {
|
||||
const canvas1 = Buffer.alloc(4 * 4 * 6);
|
||||
const canvas2 = Buffer.alloc(4 * 4 * 6);
|
||||
const source = makeHdrFrame(2, 2, 10000, 20000, 30000);
|
||||
const identity = [1, 0, 0, 1, 0, 0];
|
||||
|
||||
blitRgb48leRegion(canvas1, source, 0, 0, 2, 2, 4, 4);
|
||||
blitRgb48leAffine(canvas2, source, identity, 2, 2, 4, 4);
|
||||
|
||||
expect(Buffer.compare(canvas1, canvas2)).toBe(0);
|
||||
});
|
||||
|
||||
it("translation moves pixels", () => {
|
||||
const canvas = Buffer.alloc(4 * 4 * 6);
|
||||
const source = makeHdrFrame(1, 1, 50000, 40000, 30000);
|
||||
const translate = [1, 0, 0, 1, 2, 1];
|
||||
blitRgb48leAffine(canvas, source, translate, 1, 1, 4, 4);
|
||||
|
||||
expect(canvas.readUInt16LE(0)).toBe(0);
|
||||
const off = (1 * 4 + 2) * 6;
|
||||
expect(canvas.readUInt16LE(off)).toBe(50000);
|
||||
});
|
||||
|
||||
it("scale down by 0.5 shrinks the output", () => {
|
||||
const canvas = Buffer.alloc(4 * 4 * 6);
|
||||
const source = makeHdrFrame(4, 4, 40000, 30000, 20000);
|
||||
const scale = [0.5, 0, 0, 0.5, 0, 0];
|
||||
blitRgb48leAffine(canvas, source, scale, 4, 4, 4, 4);
|
||||
|
||||
expect(canvas.readUInt16LE(0)).toBeGreaterThan(0);
|
||||
expect(canvas.readUInt16LE((1 * 4 + 1) * 6)).toBeGreaterThan(0);
|
||||
expect(canvas.readUInt16LE(2 * 6)).toBe(0);
|
||||
});
|
||||
|
||||
it("scale up by 2 enlarges the output", () => {
|
||||
const canvas = Buffer.alloc(4 * 4 * 6);
|
||||
const source = makeHdrFrame(2, 2, 40000, 30000, 20000);
|
||||
const scale = [2, 0, 0, 2, 0, 0];
|
||||
blitRgb48leAffine(canvas, source, scale, 2, 2, 4, 4);
|
||||
|
||||
for (let i = 0; i < 16; i++) {
|
||||
expect(canvas.readUInt16LE(i * 6)).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("opacity blends with canvas", () => {
|
||||
const canvas = makeHdrFrame(1, 1, 20000, 20000, 20000);
|
||||
const source = makeHdrFrame(1, 1, 60000, 60000, 60000);
|
||||
const identity = [1, 0, 0, 1, 0, 0];
|
||||
blitRgb48leAffine(canvas, source, identity, 1, 1, 1, 1, 0.5);
|
||||
|
||||
expect(canvas.readUInt16LE(0)).toBe(40000);
|
||||
});
|
||||
|
||||
it("out-of-bounds source coordinates are clipped", () => {
|
||||
const canvas = Buffer.alloc(2 * 2 * 6);
|
||||
const source = makeHdrFrame(1, 1, 50000, 40000, 30000);
|
||||
const translate = [1, 0, 0, 1, 10, 10];
|
||||
blitRgb48leAffine(canvas, source, translate, 1, 1, 2, 2);
|
||||
|
||||
expect(canvas.readUInt16LE(0)).toBe(0);
|
||||
expect(canvas.readUInt16LE(6)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Round-trip test: decodePng → blitRgba8OverRgb48le ────────────────────────
|
||||
|
||||
describe("decodePng + blitRgba8OverRgb48le integration", () => {
|
||||
it("transparent PNG overlay leaves canvas untouched", () => {
|
||||
const width = 2;
|
||||
const height = 2;
|
||||
|
||||
// Build a fully transparent PNG
|
||||
const pixels = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; // all alpha=0
|
||||
const png = makePng(width, height, pixels);
|
||||
const { data: domRgba } = decodePng(png);
|
||||
|
||||
// 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
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
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 overwrites all canvas pixels (sRGB→HLG)", () => {
|
||||
const width = 2;
|
||||
const height = 2;
|
||||
|
||||
// Build a fully opaque blue PNG (sRGB blue = 0,0,255)
|
||||
const pixels = Array(width * height)
|
||||
.fill(null)
|
||||
.flatMap(() => [0, 0, 255, 255]);
|
||||
const png = makePng(width, height, pixels);
|
||||
const { data: domRgba } = decodePng(png);
|
||||
|
||||
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(canvas.readUInt16LE(i * 6 + 0)).toBe(0);
|
||||
expect(canvas.readUInt16LE(i * 6 + 2)).toBe(0);
|
||||
expect(canvas.readUInt16LE(i * 6 + 4)).toBe(65535);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── roundedRectAlpha tests ──────────────────────────────────────────────────
|
||||
|
||||
describe("roundedRectAlpha", () => {
|
||||
const uniform20: [number, number, number, number] = [20, 20, 20, 20];
|
||||
|
||||
it("returns 1 for center pixel", () => {
|
||||
expect(roundedRectAlpha(50, 50, 100, 100, uniform20)).toBe(1);
|
||||
});
|
||||
|
||||
it("returns 1 for pixel well inside edge (not in corner zone)", () => {
|
||||
// On top edge but past the corner zone (x >= radius)
|
||||
expect(roundedRectAlpha(50, 5, 100, 100, uniform20)).toBe(1);
|
||||
});
|
||||
|
||||
it("returns 0 for pixel at the extreme corner (outside rounded area)", () => {
|
||||
// Top-left corner: (0, 0) is far from circle center at (20, 20)
|
||||
// dist = sqrt(400 + 400) = 28.28, well beyond radius 20
|
||||
expect(roundedRectAlpha(0, 0, 100, 100, uniform20)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 1 for pixel well inside corner circle", () => {
|
||||
// Pixel at (15, 15): dist from center (20, 20) = sqrt(25+25) = 7.07 << 20
|
||||
expect(roundedRectAlpha(15, 15, 100, 100, uniform20)).toBe(1);
|
||||
});
|
||||
|
||||
it("returns fractional alpha at corner edge (anti-aliasing)", () => {
|
||||
// Find a point near the circle edge. radius = 20, center at (20, 20).
|
||||
// Point on the circle: (20 - 20*cos(45°), 20 - 20*sin(45°)) ≈ (5.86, 5.86)
|
||||
// Shift slightly inward for fractional alpha
|
||||
const edgePx = 20 - 20 * Math.cos(Math.PI / 4); // ~5.86
|
||||
const alpha = roundedRectAlpha(edgePx, edgePx, 100, 100, uniform20);
|
||||
expect(alpha).toBeGreaterThan(0);
|
||||
expect(alpha).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it("handles all four corners symmetrically", () => {
|
||||
// Test top-right corner (x near w, y near 0)
|
||||
expect(roundedRectAlpha(100, 0, 100, 100, uniform20)).toBe(0);
|
||||
// Test bottom-right corner
|
||||
expect(roundedRectAlpha(100, 100, 100, 100, uniform20)).toBe(0);
|
||||
// Test bottom-left corner
|
||||
expect(roundedRectAlpha(0, 100, 100, 100, uniform20)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 1 everywhere for zero radii", () => {
|
||||
const zero: [number, number, number, number] = [0, 0, 0, 0];
|
||||
expect(roundedRectAlpha(0, 0, 100, 100, zero)).toBe(1);
|
||||
expect(roundedRectAlpha(99, 0, 100, 100, zero)).toBe(1);
|
||||
expect(roundedRectAlpha(0, 99, 100, 100, zero)).toBe(1);
|
||||
expect(roundedRectAlpha(99, 99, 100, 100, zero)).toBe(1);
|
||||
});
|
||||
|
||||
it("supports per-corner radii", () => {
|
||||
const mixed: [number, number, number, number] = [20, 0, 10, 0];
|
||||
// Top-left has radius 20 — corner pixel outside
|
||||
expect(roundedRectAlpha(0, 0, 100, 100, mixed)).toBe(0);
|
||||
// Top-right has radius 0 — corner pixel inside
|
||||
expect(roundedRectAlpha(99, 0, 100, 100, mixed)).toBe(1);
|
||||
// Bottom-right has radius 10 — extreme corner outside
|
||||
expect(roundedRectAlpha(100, 100, 100, 100, mixed)).toBe(0);
|
||||
// Bottom-left has radius 0 — corner pixel inside
|
||||
expect(roundedRectAlpha(0, 99, 100, 100, mixed)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── blitRgb48leRegion with borderRadius ─────────────────────────────────────
|
||||
|
||||
describe("blitRgb48leRegion with borderRadius", () => {
|
||||
it("clips corner pixels when borderRadius is set", () => {
|
||||
// 10x10 source placed at origin on a 10x10 canvas, radius 5
|
||||
const canvas = Buffer.alloc(10 * 10 * 6);
|
||||
const source = makeHdrFrame(10, 10, 40000, 30000, 20000);
|
||||
const br: [number, number, number, number] = [5, 5, 5, 5];
|
||||
blitRgb48leRegion(canvas, source, 0, 0, 10, 10, 10, 10, undefined, br);
|
||||
|
||||
// Center pixel should be written
|
||||
const centerOff = (5 * 10 + 5) * 6;
|
||||
expect(canvas.readUInt16LE(centerOff)).toBe(40000);
|
||||
|
||||
// Corner pixel (0,0) should be clipped (remain 0)
|
||||
expect(canvas.readUInt16LE(0)).toBe(0);
|
||||
});
|
||||
|
||||
it("no effect when borderRadius is all zeros", () => {
|
||||
const canvas1 = Buffer.alloc(4 * 4 * 6);
|
||||
const canvas2 = Buffer.alloc(4 * 4 * 6);
|
||||
const source = makeHdrFrame(4, 4, 40000, 30000, 20000);
|
||||
|
||||
blitRgb48leRegion(canvas1, source, 0, 0, 4, 4, 4, 4);
|
||||
blitRgb48leRegion(canvas2, source, 0, 0, 4, 4, 4, 4, undefined, [0, 0, 0, 0]);
|
||||
|
||||
expect(Buffer.compare(canvas1, canvas2)).toBe(0);
|
||||
});
|
||||
|
||||
it("combines opacity and borderRadius", () => {
|
||||
// Canvas with known background, source with known values
|
||||
const canvas = makeHdrFrame(10, 10, 20000, 20000, 20000);
|
||||
const source = makeHdrFrame(10, 10, 60000, 60000, 60000);
|
||||
const br: [number, number, number, number] = [3, 3, 3, 3];
|
||||
|
||||
blitRgb48leRegion(canvas, source, 0, 0, 10, 10, 10, 10, 0.5, br);
|
||||
|
||||
// Center pixel: opacity 0.5, mask 1.0 → effective 0.5
|
||||
// Result: 60000 * 0.5 + 20000 * 0.5 = 40000
|
||||
const centerOff = (5 * 10 + 5) * 6;
|
||||
expect(canvas.readUInt16LE(centerOff)).toBe(40000);
|
||||
|
||||
// Corner pixel (0,0): mask 0.0 → skipped, canvas unchanged
|
||||
expect(canvas.readUInt16LE(0)).toBe(20000);
|
||||
});
|
||||
});
|
||||
|
||||
// ── blitRgb48leAffine with borderRadius ─────────────────────────────────────
|
||||
|
||||
describe("blitRgb48leAffine with borderRadius", () => {
|
||||
it("clips corner pixels with identity transform", () => {
|
||||
const canvas = Buffer.alloc(10 * 10 * 6);
|
||||
const source = makeHdrFrame(10, 10, 40000, 30000, 20000);
|
||||
const identity = [1, 0, 0, 1, 0, 0];
|
||||
const br: [number, number, number, number] = [5, 5, 5, 5];
|
||||
|
||||
blitRgb48leAffine(canvas, source, identity, 10, 10, 10, 10, undefined, br);
|
||||
|
||||
// Center pixel should be written
|
||||
const centerOff = (5 * 10 + 5) * 6;
|
||||
expect(canvas.readUInt16LE(centerOff)).toBe(40000);
|
||||
|
||||
// Corner pixel (0,0) should be clipped
|
||||
expect(canvas.readUInt16LE(0)).toBe(0);
|
||||
});
|
||||
|
||||
it("mask follows transform (scaled output has rounded corners)", () => {
|
||||
// 4x4 source scaled up 2× on an 8×8 canvas, radius 2 in source space
|
||||
const canvas = Buffer.alloc(8 * 8 * 6);
|
||||
const source = makeHdrFrame(4, 4, 50000, 40000, 30000);
|
||||
const scale2x = [2, 0, 0, 2, 0, 0];
|
||||
const br: [number, number, number, number] = [2, 2, 2, 2];
|
||||
|
||||
blitRgb48leAffine(canvas, source, scale2x, 4, 4, 8, 8, undefined, br);
|
||||
|
||||
// Canvas center (4,4) maps to source (2,2) — inside, should be written
|
||||
const centerOff = (4 * 8 + 4) * 6;
|
||||
expect(canvas.readUInt16LE(centerOff)).toBeGreaterThan(0);
|
||||
|
||||
// Canvas corner (0,0) maps to source (0,0) — outside radius, should be clipped
|
||||
expect(canvas.readUInt16LE(0)).toBe(0);
|
||||
});
|
||||
|
||||
it("no effect when borderRadius is undefined", () => {
|
||||
const canvas1 = Buffer.alloc(4 * 4 * 6);
|
||||
const canvas2 = Buffer.alloc(4 * 4 * 6);
|
||||
const source = makeHdrFrame(4, 4, 40000, 30000, 20000);
|
||||
const identity = [1, 0, 0, 1, 0, 0];
|
||||
|
||||
blitRgb48leAffine(canvas1, source, identity, 4, 4, 4, 4);
|
||||
blitRgb48leAffine(canvas2, source, identity, 4, 4, 4, 4, undefined, undefined);
|
||||
|
||||
expect(Buffer.compare(canvas1, canvas2)).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user