mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
refactor: consolidate PNG decoder, fix assertions in alphaBlit
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
7d8c5d20ef
commit
54f6163897
-28
@@ -14,12 +14,6 @@ dist/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Docs media — served from CDN, not committed.
|
||||
# Regenerate with scripts/generate-{catalog,template}-previews.ts then upload
|
||||
# with `bun run upload:docs-images`. Add explicit negations below for any
|
||||
# non-generated assets (logos, svgs) that should stay in the repo.
|
||||
docs/images/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
@@ -59,28 +53,6 @@ packages/producer/src/services/fontData.generated.ts
|
||||
|
||||
# Test artifacts
|
||||
my-video/
|
||||
examples/
|
||||
packages/studio/data/
|
||||
.desloppify/
|
||||
.worktrees/
|
||||
|
||||
# Playwright MCP browser cache
|
||||
.playwright-mcp/
|
||||
|
||||
# Installed skills (user-specific)
|
||||
.agents/
|
||||
.claude/skills/
|
||||
skills-lock.json
|
||||
|
||||
# Skills from other PRs (not managed here)
|
||||
skills/hyperframes-animation-map/
|
||||
skills/hyperframes-contrast/
|
||||
|
||||
# Capture outputs
|
||||
captures/
|
||||
# Legacy test captures at repo root (use captures/ instead)
|
||||
*-capture/
|
||||
*-demo/
|
||||
*-ad/
|
||||
*-tour/
|
||||
*-brand/
|
||||
|
||||
@@ -232,16 +232,11 @@ export async function injectVideoFramesBatch(
|
||||
}
|
||||
if (!img) continue;
|
||||
|
||||
if (!sourceIsStatic) {
|
||||
img.style.position = computedStyle.position;
|
||||
img.style.width = computedStyle.width;
|
||||
img.style.height = computedStyle.height;
|
||||
img.style.top = computedStyle.top;
|
||||
img.style.left = computedStyle.left;
|
||||
img.style.right = computedStyle.right;
|
||||
img.style.bottom = computedStyle.bottom;
|
||||
img.style.inset = computedStyle.inset;
|
||||
} else {
|
||||
// Always use absolute positioning so the <img> overlays the <video>
|
||||
// instead of flowing below it. With position:relative, both elements
|
||||
// stack vertically — the <img> lands below the video and gets clipped
|
||||
// by any overflow:hidden ancestor (e.g., border-radius wrappers).
|
||||
{
|
||||
const videoRect = video.getBoundingClientRect();
|
||||
const offsetLeft = Number.isFinite(video.offsetLeft) ? video.offsetLeft : 0;
|
||||
const offsetTop = Number.isFinite(video.offsetTop) ? video.offsetTop : 0;
|
||||
@@ -307,14 +302,25 @@ export async function syncVideoFrameVisibility(
|
||||
const active = new Set(ids);
|
||||
const videos = Array.from(document.querySelectorAll("video[data-start]")) as HTMLVideoElement[];
|
||||
for (const video of videos) {
|
||||
if (active.has(video.id)) continue;
|
||||
video.style.removeProperty("display");
|
||||
video.style.setProperty("visibility", "hidden", "important");
|
||||
video.style.setProperty("opacity", "0", "important");
|
||||
video.style.setProperty("pointer-events", "none", "important");
|
||||
const img = video.nextElementSibling as HTMLElement | null;
|
||||
if (img && img.classList.contains("__render_frame__")) {
|
||||
img.style.visibility = "hidden";
|
||||
const hasImg = img && img.classList.contains("__render_frame__");
|
||||
if (active.has(video.id)) {
|
||||
// Active video: show injected <img>, hide native <video>
|
||||
video.style.setProperty("visibility", "hidden", "important");
|
||||
video.style.setProperty("opacity", "0", "important");
|
||||
video.style.setProperty("pointer-events", "none", "important");
|
||||
if (hasImg) {
|
||||
img.style.visibility = "visible";
|
||||
}
|
||||
} else {
|
||||
// Inactive video: hide both
|
||||
video.style.removeProperty("display");
|
||||
video.style.setProperty("visibility", "hidden", "important");
|
||||
video.style.setProperty("opacity", "0", "important");
|
||||
video.style.setProperty("pointer-events", "none", "important");
|
||||
if (hasImg) {
|
||||
img.style.visibility = "hidden";
|
||||
}
|
||||
}
|
||||
}
|
||||
}, activeVideoIds);
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { deflateSync } from "zlib";
|
||||
import { decodePng, blitRgba8OverRgb48le } from "./alphaBlit.js";
|
||||
import {
|
||||
decodePng,
|
||||
blitRgba8OverRgb48le,
|
||||
blitRgb48leRegion,
|
||||
blitRgb48leAffine,
|
||||
parseTransformMatrix,
|
||||
roundedRectAlpha,
|
||||
} from "./alphaBlit.js";
|
||||
|
||||
// ── PNG construction helpers ─────────────────────────────────────────────────
|
||||
|
||||
@@ -14,7 +21,7 @@ function crc32(data: Buffer): number {
|
||||
let crc = 0xffffffff;
|
||||
const table = crc32Table();
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
crc = table[((crc ^ data[i]!) & 0xff)!]! ^ (crc >>> 8);
|
||||
crc = (table[(crc ^ (data[i] ?? 0)) & 0xff] ?? 0) ^ (crc >>> 8);
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
@@ -62,7 +69,7 @@ function makePng(width: number, height: number, pixels: number[]): Buffer {
|
||||
scanlines.push(0); // filter type None
|
||||
for (let x = 0; x < width; x++) {
|
||||
const i = (y * width + x) * 4;
|
||||
scanlines.push(pixels[i]!, pixels[i + 1]!, pixels[i + 2]!, pixels[i + 3]!);
|
||||
scanlines.push(pixels[i] ?? 0, pixels[i + 1] ?? 0, pixels[i + 2] ?? 0, pixels[i + 3] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,67 +196,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;
|
||||
@@ -258,31 +266,216 @@ 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);
|
||||
expect(canvas.readUInt16LE(0)).toBe(65535);
|
||||
expect(canvas.readUInt16LE(2)).toBe(65535);
|
||||
expect(canvas.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);
|
||||
// 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("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);
|
||||
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);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 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);
|
||||
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 HDR frame untouched", () => {
|
||||
it("transparent PNG overlay leaves canvas untouched", () => {
|
||||
const width = 2;
|
||||
const height = 2;
|
||||
|
||||
@@ -291,20 +484,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);
|
||||
// Canvas pre-filled with known HDR values
|
||||
const canvas = makeHdrFrame(width, height, 10000, 20000, 30000);
|
||||
blitRgba8OverRgb48le(domRgba, canvas, width, height);
|
||||
|
||||
const out = blitRgba8OverRgb48le(domRgba, hdr, 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;
|
||||
|
||||
@@ -315,14 +507,175 @@ 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── 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, 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);
|
||||
blitRgb48leRegion(canvas2, source, 0, 0, 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, 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,14 +20,18 @@ function paeth(a: number, b: number, c: number): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a PNG buffer to raw RGBA pixel data (8-bit per channel).
|
||||
* Shared PNG chunk parsing + filter reconstruction.
|
||||
*
|
||||
* Supports color type 6 (RGBA) and color type 2 (RGB) at 8-bit depth,
|
||||
* non-interlaced. Chrome's Page.captureScreenshot always emits this format.
|
||||
* Verifies the PNG signature, iterates chunks to collect IHDR metadata and IDAT
|
||||
* payloads, decompresses with zlib, and reconstructs all 5 PNG filter types.
|
||||
*
|
||||
* Returns a Uint8Array of width*height*4 bytes in RGBA order.
|
||||
* Returns the defiltered pixel bytes (no filter-type prefix bytes) along with
|
||||
* IHDR fields so callers can convert to their target pixel format.
|
||||
*/
|
||||
export function decodePng(buf: Buffer): { width: number; height: number; data: Uint8Array } {
|
||||
function decodePngRaw(
|
||||
buf: Buffer,
|
||||
caller: string,
|
||||
): { width: number; height: number; bitDepth: number; colorType: number; rawPixels: Buffer } {
|
||||
// Verify PNG signature
|
||||
if (
|
||||
buf[0] !== 137 ||
|
||||
@@ -39,7 +43,7 @@ export function decodePng(buf: Buffer): { width: number; height: number; data: U
|
||||
buf[6] !== 26 ||
|
||||
buf[7] !== 10
|
||||
) {
|
||||
throw new Error("decodePng: not a PNG file");
|
||||
throw new Error(`${caller}: not a PNG file`);
|
||||
}
|
||||
|
||||
let pos = 8;
|
||||
@@ -68,22 +72,20 @@ export function decodePng(buf: Buffer): { width: number; height: number; data: U
|
||||
pos += 12 + chunkLen; // length(4) + type(4) + data(chunkLen) + crc(4)
|
||||
}
|
||||
|
||||
if (bitDepth !== 8) {
|
||||
throw new Error(`decodePng: unsupported bit depth ${bitDepth} (expected 8)`);
|
||||
}
|
||||
// colorType 6 = RGBA, colorType 2 = RGB
|
||||
if (colorType !== 6 && colorType !== 2) {
|
||||
throw new Error(`decodePng: unsupported color type ${colorType} (expected 2=RGB or 6=RGBA)`);
|
||||
if (colorType !== 2 && colorType !== 6) {
|
||||
throw new Error(`${caller}: unsupported color type ${colorType} (expected 2=RGB or 6=RGBA)`);
|
||||
}
|
||||
|
||||
const bpp = colorType === 6 ? 4 : 3; // bytes per pixel in the PNG stream
|
||||
// Bytes per pixel: channels x bytes-per-channel
|
||||
const channels = colorType === 6 ? 4 : 3;
|
||||
const bpp = channels * (bitDepth / 8);
|
||||
const stride = width * bpp;
|
||||
|
||||
const compressed = Buffer.concat(idatChunks);
|
||||
const decompressed = inflateSync(compressed);
|
||||
|
||||
// Reconstruct filtered rows → output RGBA
|
||||
const output = new Uint8Array(width * height * 4);
|
||||
// Reconstruct filtered rows into a flat pixel buffer (no filter bytes)
|
||||
const rawPixels = Buffer.allocUnsafe(height * stride);
|
||||
const prevRow = new Uint8Array(stride);
|
||||
const currRow = new Uint8Array(stride);
|
||||
|
||||
@@ -94,29 +96,28 @@ export function decodePng(buf: Buffer): { width: number; height: number; data: U
|
||||
const rawRow = decompressed.subarray(srcPos, srcPos + stride);
|
||||
srcPos += stride;
|
||||
|
||||
// Apply PNG filter to reconstruct scanline
|
||||
switch (filterType) {
|
||||
case 0: // None
|
||||
currRow.set(rawRow);
|
||||
break;
|
||||
case 1: // Sub — difference from left pixel
|
||||
case 1: // Sub
|
||||
for (let x = 0; x < stride; x++) {
|
||||
currRow[x] = ((rawRow[x] ?? 0) + (x >= bpp ? (currRow[x - bpp] ?? 0) : 0)) & 0xff;
|
||||
}
|
||||
break;
|
||||
case 2: // Up — difference from above pixel
|
||||
case 2: // Up
|
||||
for (let x = 0; x < stride; x++) {
|
||||
currRow[x] = ((rawRow[x] ?? 0) + (prevRow[x] ?? 0)) & 0xff;
|
||||
}
|
||||
break;
|
||||
case 3: // Average — difference from floor((left + above) / 2)
|
||||
case 3: // Average
|
||||
for (let x = 0; x < stride; x++) {
|
||||
const left = x >= bpp ? (currRow[x - bpp] ?? 0) : 0;
|
||||
const up = prevRow[x] ?? 0;
|
||||
currRow[x] = ((rawRow[x] ?? 0) + Math.floor((left + up) / 2)) & 0xff;
|
||||
}
|
||||
break;
|
||||
case 4: // Paeth predictor
|
||||
case 4: // Paeth
|
||||
for (let x = 0; x < stride; x++) {
|
||||
const left = x >= bpp ? (currRow[x - bpp] ?? 0) : 0;
|
||||
const up = prevRow[x] ?? 0;
|
||||
@@ -125,26 +126,46 @@ export function decodePng(buf: Buffer): { width: number; height: number; data: U
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new Error(`decodePng: unknown filter type ${filterType} at row ${y}`);
|
||||
}
|
||||
|
||||
// Write to output as RGBA (expand RGB→RGBA if colorType=2)
|
||||
const dstBase = y * width * 4;
|
||||
if (colorType === 6) {
|
||||
output.set(currRow, dstBase);
|
||||
} else {
|
||||
// RGB → RGBA: set alpha to 255
|
||||
for (let x = 0; x < width; x++) {
|
||||
output[dstBase + x * 4 + 0] = currRow[x * 3 + 0] ?? 0;
|
||||
output[dstBase + x * 4 + 1] = currRow[x * 3 + 1] ?? 0;
|
||||
output[dstBase + x * 4 + 2] = currRow[x * 3 + 2] ?? 0;
|
||||
output[dstBase + x * 4 + 3] = 255;
|
||||
}
|
||||
throw new Error(`${caller}: unknown filter type ${filterType} at row ${y}`);
|
||||
}
|
||||
|
||||
rawPixels.set(currRow, y * stride);
|
||||
prevRow.set(currRow);
|
||||
}
|
||||
|
||||
return { width, height, bitDepth, colorType, rawPixels };
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a PNG buffer to raw RGBA pixel data (8-bit per channel).
|
||||
*
|
||||
* Supports color type 6 (RGBA) and color type 2 (RGB) at 8-bit depth,
|
||||
* non-interlaced. Chrome's Page.captureScreenshot always emits this format.
|
||||
*
|
||||
* Returns a Uint8Array of width*height*4 bytes in RGBA order.
|
||||
*/
|
||||
export function decodePng(buf: Buffer): { width: number; height: number; data: Uint8Array } {
|
||||
const { width, height, bitDepth, colorType, rawPixels } = decodePngRaw(buf, "decodePng");
|
||||
|
||||
if (bitDepth !== 8) {
|
||||
throw new Error(`decodePng: unsupported bit depth ${bitDepth} (expected 8)`);
|
||||
}
|
||||
|
||||
const output = new Uint8Array(width * height * 4);
|
||||
|
||||
if (colorType === 6) {
|
||||
// RGBA — copy directly
|
||||
output.set(rawPixels);
|
||||
} else {
|
||||
// RGB → RGBA: set alpha to 255
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
output[i * 4 + 0] = rawPixels[i * 3 + 0] ?? 0;
|
||||
output[i * 4 + 1] = rawPixels[i * 3 + 1] ?? 0;
|
||||
output[i * 4 + 2] = rawPixels[i * 3 + 2] ?? 0;
|
||||
output[i * 4 + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
return { width, height, data: output };
|
||||
}
|
||||
|
||||
@@ -157,238 +178,435 @@ export function decodePng(buf: Buffer): { width: number; height: number; data: U
|
||||
* PNG stores 16-bit values in big-endian; this function swaps to little-endian
|
||||
* for the streaming encoder's rgb48le input format.
|
||||
*
|
||||
* Supports colorType 2 (RGB) at 16-bit depth, non-interlaced.
|
||||
* Supports colorType 2 (RGB) and 6 (RGBA) at 16-bit depth, non-interlaced.
|
||||
*/
|
||||
export function decodePngToRgb48le(buf: Buffer): { width: number; height: number; data: Buffer } {
|
||||
// Verify PNG signature
|
||||
if (
|
||||
buf[0] !== 137 ||
|
||||
buf[1] !== 80 ||
|
||||
buf[2] !== 78 ||
|
||||
buf[3] !== 71 ||
|
||||
buf[4] !== 13 ||
|
||||
buf[5] !== 10 ||
|
||||
buf[6] !== 26 ||
|
||||
buf[7] !== 10
|
||||
) {
|
||||
throw new Error("decodePngToRgb48le: not a PNG file");
|
||||
}
|
||||
|
||||
let pos = 8;
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
let bitDepth = 0;
|
||||
let colorType = 0;
|
||||
const idatChunks: Buffer[] = [];
|
||||
|
||||
while (pos + 12 <= buf.length) {
|
||||
const chunkLen = buf.readUInt32BE(pos);
|
||||
const chunkType = buf.toString("ascii", pos + 4, pos + 8);
|
||||
const chunkData = buf.subarray(pos + 8, pos + 8 + chunkLen);
|
||||
|
||||
if (chunkType === "IHDR") {
|
||||
width = chunkData.readUInt32BE(0);
|
||||
height = chunkData.readUInt32BE(4);
|
||||
bitDepth = chunkData[8] ?? 0;
|
||||
colorType = chunkData[9] ?? 0;
|
||||
} else if (chunkType === "IDAT") {
|
||||
idatChunks.push(Buffer.from(chunkData));
|
||||
} else if (chunkType === "IEND") {
|
||||
break;
|
||||
}
|
||||
|
||||
pos += 12 + chunkLen;
|
||||
}
|
||||
const { width, height, bitDepth, colorType, rawPixels } = decodePngRaw(buf, "decodePngToRgb48le");
|
||||
|
||||
if (bitDepth !== 16) {
|
||||
throw new Error(`decodePngToRgb48le: unsupported bit depth ${bitDepth} (expected 16)`);
|
||||
}
|
||||
if (colorType !== 2 && colorType !== 6) {
|
||||
throw new Error(
|
||||
`decodePngToRgb48le: unsupported color type ${colorType} (expected 2=RGB or 6=RGBA)`,
|
||||
);
|
||||
}
|
||||
|
||||
// 16-bit: 2 bytes per channel. RGB=6 bytes/pixel, RGBA=8 bytes/pixel
|
||||
const bpp = colorType === 6 ? 8 : 6;
|
||||
const stride = width * bpp;
|
||||
|
||||
const compressed = Buffer.concat(idatChunks);
|
||||
const decompressed = inflateSync(compressed);
|
||||
|
||||
// Reconstruct filtered rows (filter operates on individual bytes)
|
||||
const currRow = new Uint8Array(stride);
|
||||
const prevRow = new Uint8Array(stride);
|
||||
|
||||
// Output: rgb48le = 3 channels × 2 bytes (LE) = 6 bytes/pixel
|
||||
// Output: rgb48le = 3 channels x 2 bytes (LE) = 6 bytes/pixel
|
||||
const output = Buffer.allocUnsafe(width * height * 6);
|
||||
|
||||
let srcPos = 0;
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
const filterType = decompressed[srcPos++] ?? 0;
|
||||
const rawRow = decompressed.subarray(srcPos, srcPos + stride);
|
||||
srcPos += stride;
|
||||
|
||||
switch (filterType) {
|
||||
case 0:
|
||||
currRow.set(rawRow);
|
||||
break;
|
||||
case 1:
|
||||
for (let x = 0; x < stride; x++) {
|
||||
currRow[x] = ((rawRow[x] ?? 0) + (x >= bpp ? (currRow[x - bpp] ?? 0) : 0)) & 0xff;
|
||||
}
|
||||
break;
|
||||
case 2:
|
||||
for (let x = 0; x < stride; x++) {
|
||||
currRow[x] = ((rawRow[x] ?? 0) + (prevRow[x] ?? 0)) & 0xff;
|
||||
}
|
||||
break;
|
||||
case 3:
|
||||
for (let x = 0; x < stride; x++) {
|
||||
const left = x >= bpp ? (currRow[x - bpp] ?? 0) : 0;
|
||||
const up = prevRow[x] ?? 0;
|
||||
currRow[x] = ((rawRow[x] ?? 0) + Math.floor((left + up) / 2)) & 0xff;
|
||||
}
|
||||
break;
|
||||
case 4:
|
||||
for (let x = 0; x < stride; x++) {
|
||||
const left = x >= bpp ? (currRow[x - bpp] ?? 0) : 0;
|
||||
const up = prevRow[x] ?? 0;
|
||||
const upLeft = x >= bpp ? (prevRow[x - bpp] ?? 0) : 0;
|
||||
currRow[x] = ((rawRow[x] ?? 0) + paeth(left, up, upLeft)) & 0xff;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new Error(`decodePngToRgb48le: unknown filter type ${filterType} at row ${y}`);
|
||||
}
|
||||
|
||||
// Convert big-endian 16-bit RGB(A) → little-endian rgb48le (drop alpha if RGBA)
|
||||
const dstBase = y * width * 6;
|
||||
const srcRowBase = y * width * bpp;
|
||||
for (let x = 0; x < width; x++) {
|
||||
const srcBase = x * bpp;
|
||||
const srcBase = srcRowBase + x * bpp;
|
||||
// PNG stores 16-bit as big-endian: [high, low]. Swap to little-endian: [low, high].
|
||||
output[dstBase + x * 6 + 0] = currRow[srcBase + 1] ?? 0; // R low
|
||||
output[dstBase + x * 6 + 1] = currRow[srcBase + 0] ?? 0; // R high
|
||||
output[dstBase + x * 6 + 2] = currRow[srcBase + 3] ?? 0; // G low
|
||||
output[dstBase + x * 6 + 3] = currRow[srcBase + 2] ?? 0; // G high
|
||||
output[dstBase + x * 6 + 4] = currRow[srcBase + 5] ?? 0; // B low
|
||||
output[dstBase + x * 6 + 5] = currRow[srcBase + 4] ?? 0; // B high
|
||||
output[dstBase + x * 6 + 0] = rawPixels[srcBase + 1] ?? 0; // R low
|
||||
output[dstBase + x * 6 + 1] = rawPixels[srcBase + 0] ?? 0; // R high
|
||||
output[dstBase + x * 6 + 2] = rawPixels[srcBase + 3] ?? 0; // G low
|
||||
output[dstBase + x * 6 + 3] = rawPixels[srcBase + 2] ?? 0; // G high
|
||||
output[dstBase + x * 6 + 4] = rawPixels[srcBase + 5] ?? 0; // B low
|
||||
output[dstBase + x * 6 + 5] = rawPixels[srcBase + 4] ?? 0; // B high
|
||||
}
|
||||
|
||||
prevRow.set(currRow);
|
||||
}
|
||||
|
||||
return { width, height, data: output };
|
||||
}
|
||||
|
||||
// ── sRGB → HLG color conversion ───────────────────────────────────────────────
|
||||
// ── sRGB → HDR color conversion ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 256-entry LUT: sRGB 8-bit value → HLG 16-bit signal value.
|
||||
* Build a 256-entry LUT: sRGB 8-bit value → HDR 16-bit signal value.
|
||||
*
|
||||
* Converts DOM overlay pixels (Chrome sRGB) to HLG signal space so they
|
||||
* composite correctly into the HLG/BT.2020 output without color shift.
|
||||
* Pipeline per channel: sRGB EOTF (decode gamma) → linear → HDR OETF → 16-bit.
|
||||
*
|
||||
* Pipeline per channel: sRGB EOTF (decode gamma) → linear → HLG OETF → 16-bit.
|
||||
*
|
||||
* Note: this converts the transfer function (gamma) but not the color primaries
|
||||
* (bt709 → bt2020). For neutral/near-neutral content (text, UI elements) the
|
||||
* gamut difference is negligible. Saturated sRGB colors may shift slightly.
|
||||
* Note: converts the transfer function but not the color primaries (bt709 → bt2020).
|
||||
* For neutral/near-neutral content (text, UI) the gamut difference is negligible.
|
||||
*/
|
||||
function buildSrgbToHlgLut(): Uint16Array {
|
||||
function buildSrgbToHdrLut(transfer: "hlg" | "pq"): Uint16Array {
|
||||
const lut = new Uint16Array(256);
|
||||
|
||||
// HLG OETF constants (Rec. 2100)
|
||||
const a = 0.17883277;
|
||||
const b = 1 - 4 * a;
|
||||
const c = 0.5 - a * Math.log(4 * a);
|
||||
const hlgA = 0.17883277;
|
||||
const hlgB = 1 - 4 * hlgA;
|
||||
const hlgC = 0.5 - hlgA * Math.log(4 * hlgA);
|
||||
|
||||
// PQ (SMPTE 2084) OETF constants
|
||||
const pqM1 = 0.1593017578125;
|
||||
const pqM2 = 78.84375;
|
||||
const pqC1 = 0.8359375;
|
||||
const pqC2 = 18.8515625;
|
||||
const pqC3 = 18.6875;
|
||||
const pqMaxNits = 10000.0;
|
||||
const sdrNits = 203.0;
|
||||
|
||||
for (let i = 0; i < 256; i++) {
|
||||
// sRGB EOTF: signal → linear
|
||||
// sRGB EOTF: signal → linear (range 0–1, relative to SDR white)
|
||||
const v = i / 255;
|
||||
const linear = v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
||||
|
||||
// HLG OETF: linear → HLG signal
|
||||
const hlg = linear <= 1 / 12 ? Math.sqrt(3 * linear) : a * Math.log(12 * linear - b) + c;
|
||||
let signal: number;
|
||||
if (transfer === "hlg") {
|
||||
signal =
|
||||
linear <= 1 / 12 ? Math.sqrt(3 * linear) : hlgA * Math.log(12 * linear - hlgB) + hlgC;
|
||||
} else {
|
||||
// PQ OETF: linear light (in SDR nits) → PQ signal
|
||||
const Lp = Math.max(0, (linear * sdrNits) / pqMaxNits);
|
||||
const Lm1 = Math.pow(Lp, pqM1);
|
||||
signal = Math.pow((pqC1 + pqC2 * Lm1) / (1.0 + pqC3 * Lm1), pqM2);
|
||||
}
|
||||
|
||||
lut[i] = Math.min(65535, Math.round(hlg * 65535));
|
||||
lut[i] = Math.min(65535, Math.round(signal * 65535));
|
||||
}
|
||||
|
||||
return lut;
|
||||
}
|
||||
|
||||
const SRGB_TO_HLG = buildSrgbToHlgLut();
|
||||
const SRGB_TO_HLG = buildSrgbToHdrLut("hlg");
|
||||
const SRGB_TO_PQ = buildSrgbToHdrLut("pq");
|
||||
|
||||
/** Select the correct sRGB→HDR LUT for the given transfer function. */
|
||||
export function getSrgbToHdrLut(transfer: "hlg" | "pq"): Uint16Array {
|
||||
return transfer === "pq" ? SRGB_TO_PQ : SRGB_TO_HLG;
|
||||
}
|
||||
|
||||
// ── Alpha compositing ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Alpha-composite a DOM RGBA overlay (8-bit sRGB) onto an HDR frame
|
||||
* (rgb48le, HLG-encoded) in memory.
|
||||
* Alpha-composite a DOM RGBA overlay (8-bit sRGB) onto an HDR canvas
|
||||
* (rgb48le) in-place.
|
||||
*
|
||||
* DOM pixels are converted from sRGB to HLG signal space before blending
|
||||
* so the composited output is uniformly HLG-encoded. Without this conversion,
|
||||
* sRGB content (text, SDR video rendered by Chrome) would have incorrect
|
||||
* gamma and appear orange/washed in HDR playback.
|
||||
* DOM pixels are converted from sRGB to the target HDR signal space (HLG or PQ)
|
||||
* before blending so the composited output is uniformly encoded. Without this
|
||||
* conversion, sRGB content appears orange/washed in HDR playback.
|
||||
*
|
||||
* For each pixel:
|
||||
* - If DOM alpha == 0 → copy HDR pixel unchanged
|
||||
* - If DOM alpha == 255 → use DOM pixel (sRGB→HLG converted)
|
||||
* - Otherwise → blend converted DOM with HDR in HLG signal domain
|
||||
*
|
||||
* @param domRgba Raw RGBA pixel data from decodePng() — width*height*4 bytes
|
||||
* @param hdrRgb48 HDR frame in rgb48le format — width*height*6 bytes
|
||||
* @returns New rgb48le buffer with DOM composited on top (HLG-encoded)
|
||||
* @param domRgba Raw RGBA pixel data from decodePng() — width*height*4 bytes
|
||||
* @param canvas HDR canvas in rgb48le format — width*height*6 bytes, mutated in-place
|
||||
* @param width Canvas width in pixels
|
||||
* @param height Canvas height in pixels
|
||||
* @param transfer HDR transfer function — selects the correct sRGB→HDR LUT
|
||||
*/
|
||||
export function blitRgba8OverRgb48le(
|
||||
domRgba: Uint8Array,
|
||||
hdrRgb48: Buffer,
|
||||
canvas: Buffer,
|
||||
width: number,
|
||||
height: number,
|
||||
): Buffer {
|
||||
transfer: "hlg" | "pq" = "hlg",
|
||||
): void {
|
||||
const pixelCount = width * height;
|
||||
const out = Buffer.allocUnsafe(pixelCount * 6);
|
||||
const lut = SRGB_TO_HLG;
|
||||
const lut = getSrgbToHdrLut(transfer);
|
||||
|
||||
for (let i = 0; i < pixelCount; i++) {
|
||||
const da = domRgba[i * 4 + 3] ?? 0;
|
||||
|
||||
if (da === 0) {
|
||||
// Fully transparent DOM pixel — copy HDR unchanged
|
||||
out[i * 6 + 0] = hdrRgb48[i * 6 + 0] ?? 0;
|
||||
out[i * 6 + 1] = hdrRgb48[i * 6 + 1] ?? 0;
|
||||
out[i * 6 + 2] = hdrRgb48[i * 6 + 2] ?? 0;
|
||||
out[i * 6 + 3] = hdrRgb48[i * 6 + 3] ?? 0;
|
||||
out[i * 6 + 4] = hdrRgb48[i * 6 + 4] ?? 0;
|
||||
out[i * 6 + 5] = hdrRgb48[i * 6 + 5] ?? 0;
|
||||
continue;
|
||||
} else if (da === 255) {
|
||||
// Fully opaque DOM pixel — convert sRGB → HLG
|
||||
const r16 = lut[domRgba[i * 4 + 0] ?? 0] ?? 0;
|
||||
const g16 = lut[domRgba[i * 4 + 1] ?? 0] ?? 0;
|
||||
const b16 = lut[domRgba[i * 4 + 2] ?? 0] ?? 0;
|
||||
out.writeUInt16LE(r16, i * 6);
|
||||
out.writeUInt16LE(g16, i * 6 + 2);
|
||||
out.writeUInt16LE(b16, i * 6 + 4);
|
||||
canvas.writeUInt16LE(r16, i * 6);
|
||||
canvas.writeUInt16LE(g16, i * 6 + 2);
|
||||
canvas.writeUInt16LE(b16, i * 6 + 4);
|
||||
} else {
|
||||
// Partial alpha — convert sRGB→HLG then blend in HLG signal domain
|
||||
const alpha = da / 255;
|
||||
const invAlpha = 1 - alpha;
|
||||
|
||||
// Read HDR pixel (little-endian uint16, already HLG-encoded)
|
||||
const hdrR = (hdrRgb48[i * 6 + 0] ?? 0) | ((hdrRgb48[i * 6 + 1] ?? 0) << 8);
|
||||
const hdrG = (hdrRgb48[i * 6 + 2] ?? 0) | ((hdrRgb48[i * 6 + 3] ?? 0) << 8);
|
||||
const hdrB = (hdrRgb48[i * 6 + 4] ?? 0) | ((hdrRgb48[i * 6 + 5] ?? 0) << 8);
|
||||
const hdrR = (canvas[i * 6 + 0] ?? 0) | ((canvas[i * 6 + 1] ?? 0) << 8);
|
||||
const hdrG = (canvas[i * 6 + 2] ?? 0) | ((canvas[i * 6 + 3] ?? 0) << 8);
|
||||
const hdrB = (canvas[i * 6 + 4] ?? 0) | ((canvas[i * 6 + 5] ?? 0) << 8);
|
||||
|
||||
// Convert DOM sRGB → HLG signal
|
||||
const domR = lut[domRgba[i * 4 + 0] ?? 0] ?? 0;
|
||||
const domG = lut[domRgba[i * 4 + 1] ?? 0] ?? 0;
|
||||
const domB = lut[domRgba[i * 4 + 2] ?? 0] ?? 0;
|
||||
|
||||
out.writeUInt16LE(Math.round(domR * alpha + hdrR * invAlpha), i * 6);
|
||||
out.writeUInt16LE(Math.round(domG * alpha + hdrG * invAlpha), i * 6 + 2);
|
||||
out.writeUInt16LE(Math.round(domB * alpha + hdrB * invAlpha), i * 6 + 4);
|
||||
canvas.writeUInt16LE(Math.round(domR * alpha + hdrR * invAlpha), i * 6);
|
||||
canvas.writeUInt16LE(Math.round(domG * alpha + hdrG * invAlpha), i * 6 + 2);
|
||||
canvas.writeUInt16LE(Math.round(domB * alpha + hdrB * invAlpha), i * 6 + 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
// ── Rounded-rectangle mask ───────────────────────────────────────────────────
|
||||
|
||||
/** Anti-aliased alpha for a point at distance `dist` from a corner circle of radius `r`. */
|
||||
function cornerAlpha(px: number, py: number, cx: number, cy: number, r: number): number {
|
||||
const dx = px - cx;
|
||||
const dy = py - cy;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist > r + 0.5) return 0;
|
||||
if (dist > r - 0.5) return r + 0.5 - dist;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the alpha (0.0–1.0) for a point inside a rounded rectangle.
|
||||
* Returns 1.0 for interior pixels, 0.0 for exterior, and a smooth
|
||||
* transition at the corner edges (1px anti-aliasing).
|
||||
*
|
||||
* @param px X coordinate (continuous, e.g. pixel center or subpixel)
|
||||
* @param py Y coordinate
|
||||
* @param w Rectangle width
|
||||
* @param h Rectangle height
|
||||
* @param radii Corner radii [topLeft, topRight, bottomRight, bottomLeft]
|
||||
*/
|
||||
export function roundedRectAlpha(
|
||||
px: number,
|
||||
py: number,
|
||||
w: number,
|
||||
h: number,
|
||||
radii: [number, number, number, number],
|
||||
): number {
|
||||
const [tl, tr, br, bl] = radii;
|
||||
if (px < tl && py < tl) return cornerAlpha(px, py, tl, tl, tl);
|
||||
if (px >= w - tr && py < tr) return cornerAlpha(px, py, w - tr, tr, tr);
|
||||
if (px >= w - br && py >= h - br) return cornerAlpha(px, py, w - br, h - br, br);
|
||||
if (px < bl && py >= h - bl) return cornerAlpha(px, py, bl, h - bl, bl);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// ── Positioned HDR region copy ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Copy a rectangular region of an rgb48le source onto an rgb48le canvas
|
||||
* at position (dx, dy). Clips to canvas bounds. Optional opacity blending
|
||||
* (0.0–1.0) over existing canvas content.
|
||||
*
|
||||
* @param canvas Destination rgb48le buffer (canvasWidth * canvasHeight * 6 bytes)
|
||||
* @param source Source rgb48le buffer (sw * sh * 6 bytes)
|
||||
* @param dx Destination X offset on canvas
|
||||
* @param dy Destination Y offset on canvas
|
||||
* @param sw Source width in pixels
|
||||
* @param sh Source height in pixels
|
||||
* @param canvasWidth Canvas width in pixels (needed for stride calculation)
|
||||
* @param opacity Optional opacity 0.0–1.0 (default 1.0 = fully opaque copy)
|
||||
*/
|
||||
export function blitRgb48leRegion(
|
||||
canvas: Buffer,
|
||||
source: Buffer,
|
||||
dx: number,
|
||||
dy: number,
|
||||
sw: number,
|
||||
sh: number,
|
||||
canvasWidth: number,
|
||||
opacity?: number,
|
||||
borderRadius?: [number, number, number, number],
|
||||
): void {
|
||||
if (sw <= 0 || sh <= 0) return;
|
||||
|
||||
const op = opacity ?? 1.0;
|
||||
const canvasHeight = canvas.length / (canvasWidth * 6);
|
||||
|
||||
const x0 = Math.max(0, dx);
|
||||
const y0 = Math.max(0, dy);
|
||||
const x1 = Math.min(canvasWidth, dx + sw);
|
||||
const y1 = Math.min(canvasHeight, dy + sh);
|
||||
if (x0 >= x1 || y0 >= y1) return;
|
||||
|
||||
const clippedW = x1 - x0;
|
||||
const srcOffsetX = x0 - dx;
|
||||
const srcOffsetY = y0 - dy;
|
||||
|
||||
const hasMask = borderRadius !== undefined;
|
||||
|
||||
if (op >= 0.999 && !hasMask) {
|
||||
for (let y = 0; y < y1 - y0; y++) {
|
||||
const srcRowOff = ((srcOffsetY + y) * sw + srcOffsetX) * 6;
|
||||
const dstRowOff = ((y0 + y) * canvasWidth + x0) * 6;
|
||||
source.copy(canvas, dstRowOff, srcRowOff, srcRowOff + clippedW * 6);
|
||||
}
|
||||
} else {
|
||||
for (let y = 0; y < y1 - y0; y++) {
|
||||
for (let x = 0; x < clippedW; x++) {
|
||||
let effectiveOp = op;
|
||||
if (hasMask) {
|
||||
const ma = roundedRectAlpha(srcOffsetX + x, srcOffsetY + y, sw, sh, borderRadius);
|
||||
if (ma <= 0) continue;
|
||||
effectiveOp *= ma;
|
||||
}
|
||||
|
||||
const srcOff = ((srcOffsetY + y) * sw + srcOffsetX + x) * 6;
|
||||
const dstOff = ((y0 + y) * canvasWidth + x0 + x) * 6;
|
||||
|
||||
if (effectiveOp >= 0.999) {
|
||||
source.copy(canvas, dstOff, srcOff, srcOff + 6);
|
||||
} else {
|
||||
const invEff = 1 - effectiveOp;
|
||||
const sr = source.readUInt16LE(srcOff);
|
||||
const sg = source.readUInt16LE(srcOff + 2);
|
||||
const sb = source.readUInt16LE(srcOff + 4);
|
||||
const dr = canvas.readUInt16LE(dstOff);
|
||||
const dg = canvas.readUInt16LE(dstOff + 2);
|
||||
const db = canvas.readUInt16LE(dstOff + 4);
|
||||
canvas.writeUInt16LE(Math.round(sr * effectiveOp + dr * invEff), dstOff);
|
||||
canvas.writeUInt16LE(Math.round(sg * effectiveOp + dg * invEff), dstOff + 2);
|
||||
canvas.writeUInt16LE(Math.round(sb * effectiveOp + db * invEff), dstOff + 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a 2D affine transform to an rgb48le source and composite onto a canvas.
|
||||
*
|
||||
* For each destination pixel, the inverse transform maps back to source coordinates.
|
||||
* Bilinear interpolation samples the 4 nearest source pixels for smooth scaling/rotation.
|
||||
*
|
||||
* @param canvas Destination rgb48le buffer, mutated in-place
|
||||
* @param source Source rgb48le buffer (srcW * srcH * 6 bytes)
|
||||
* @param matrix CSS transform matrix [a, b, c, d, tx, ty]
|
||||
* @param srcW Source width in pixels
|
||||
* @param srcH Source height in pixels
|
||||
* @param canvasW Canvas width in pixels
|
||||
* @param canvasH Canvas height in pixels
|
||||
* @param opacity Optional opacity 0.0–1.0 (default 1.0)
|
||||
*/
|
||||
export function blitRgb48leAffine(
|
||||
canvas: Buffer,
|
||||
source: Buffer,
|
||||
matrix: number[],
|
||||
srcW: number,
|
||||
srcH: number,
|
||||
canvasW: number,
|
||||
canvasH: number,
|
||||
opacity?: number,
|
||||
borderRadius?: [number, number, number, number],
|
||||
): void {
|
||||
const a = matrix[0];
|
||||
const b = matrix[1];
|
||||
const c = matrix[2];
|
||||
const d = matrix[3];
|
||||
const tx = matrix[4];
|
||||
const ty = matrix[5];
|
||||
if (
|
||||
a === undefined ||
|
||||
b === undefined ||
|
||||
c === undefined ||
|
||||
d === undefined ||
|
||||
tx === undefined ||
|
||||
ty === undefined
|
||||
)
|
||||
return;
|
||||
|
||||
// Invert the 2x2 part of the affine matrix
|
||||
const det = a * d - b * c;
|
||||
if (Math.abs(det) < 1e-10) return; // degenerate matrix
|
||||
|
||||
const invA = d / det;
|
||||
const invB = -b / det;
|
||||
const invC = -c / det;
|
||||
const invD = a / det;
|
||||
const invTx = -(invA * tx + invC * ty);
|
||||
const invTy = -(invB * tx + invD * ty);
|
||||
|
||||
const op = opacity ?? 1.0;
|
||||
|
||||
const hasMask = borderRadius !== undefined;
|
||||
|
||||
// Compute bounding box of transformed source on canvas
|
||||
const corners = [
|
||||
[tx, ty],
|
||||
[a * srcW + tx, b * srcW + ty],
|
||||
[c * srcH + tx, d * srcH + ty],
|
||||
[a * srcW + c * srcH + tx, b * srcW + d * srcH + ty],
|
||||
];
|
||||
let minX = canvasW,
|
||||
maxX = 0,
|
||||
minY = canvasH,
|
||||
maxY = 0;
|
||||
for (const corner of corners) {
|
||||
const cx = corner[0] ?? 0;
|
||||
const cy = corner[1] ?? 0;
|
||||
if (cx < minX) minX = cx;
|
||||
if (cx > maxX) maxX = cx;
|
||||
if (cy < minY) minY = cy;
|
||||
if (cy > maxY) maxY = cy;
|
||||
}
|
||||
const startX = Math.max(0, Math.floor(minX));
|
||||
const endX = Math.min(canvasW, Math.ceil(maxX));
|
||||
const startY = Math.max(0, Math.floor(minY));
|
||||
const endY = Math.min(canvasH, Math.ceil(maxY));
|
||||
|
||||
for (let dy = startY; dy < endY; dy++) {
|
||||
for (let dx = startX; dx < endX; dx++) {
|
||||
const sx = invA * dx + invC * dy + invTx;
|
||||
const sy = invB * dx + invD * dy + invTy;
|
||||
|
||||
if (sx < 0 || sy < 0 || sx >= srcW || sy >= srcH) continue;
|
||||
|
||||
// Apply rounded-rect mask in source coordinates
|
||||
let effectiveOp = op;
|
||||
if (hasMask) {
|
||||
const ma = roundedRectAlpha(sx, sy, srcW, srcH, borderRadius);
|
||||
if (ma <= 0) continue;
|
||||
effectiveOp *= ma;
|
||||
}
|
||||
|
||||
const x0 = Math.floor(sx);
|
||||
const y0 = Math.floor(sy);
|
||||
const fx = sx - x0;
|
||||
const fy = sy - y0;
|
||||
const x1 = Math.min(x0 + 1, srcW - 1);
|
||||
const y1 = Math.min(y0 + 1, srcH - 1);
|
||||
|
||||
const off00 = (y0 * srcW + x0) * 6;
|
||||
const off10 = (y0 * srcW + x1) * 6;
|
||||
const off01 = (y1 * srcW + x0) * 6;
|
||||
const off11 = (y1 * srcW + x1) * 6;
|
||||
|
||||
const w00 = (1 - fx) * (1 - fy);
|
||||
const w10 = fx * (1 - fy);
|
||||
const w01 = (1 - fx) * fy;
|
||||
const w11 = fx * fy;
|
||||
|
||||
const sr =
|
||||
source.readUInt16LE(off00) * w00 +
|
||||
source.readUInt16LE(off10) * w10 +
|
||||
source.readUInt16LE(off01) * w01 +
|
||||
source.readUInt16LE(off11) * w11;
|
||||
const sg =
|
||||
source.readUInt16LE(off00 + 2) * w00 +
|
||||
source.readUInt16LE(off10 + 2) * w10 +
|
||||
source.readUInt16LE(off01 + 2) * w01 +
|
||||
source.readUInt16LE(off11 + 2) * w11;
|
||||
const sb =
|
||||
source.readUInt16LE(off00 + 4) * w00 +
|
||||
source.readUInt16LE(off10 + 4) * w10 +
|
||||
source.readUInt16LE(off01 + 4) * w01 +
|
||||
source.readUInt16LE(off11 + 4) * w11;
|
||||
|
||||
const dstOff = (dy * canvasW + dx) * 6;
|
||||
|
||||
if (effectiveOp >= 0.999) {
|
||||
canvas.writeUInt16LE(Math.round(sr), dstOff);
|
||||
canvas.writeUInt16LE(Math.round(sg), dstOff + 2);
|
||||
canvas.writeUInt16LE(Math.round(sb), dstOff + 4);
|
||||
} else {
|
||||
const invEff = 1 - effectiveOp;
|
||||
const dr = canvas.readUInt16LE(dstOff);
|
||||
const dg = canvas.readUInt16LE(dstOff + 2);
|
||||
const db = canvas.readUInt16LE(dstOff + 4);
|
||||
canvas.writeUInt16LE(Math.round(sr * effectiveOp + dr * invEff), dstOff);
|
||||
canvas.writeUInt16LE(Math.round(sg * effectiveOp + dg * invEff), dstOff + 2);
|
||||
canvas.writeUInt16LE(Math.round(sb * effectiveOp + db * invEff), dstOff + 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a CSS `matrix(a,b,c,d,e,f)` string into a 6-element array.
|
||||
* Returns null for "none", empty, or unsupported formats (matrix3d).
|
||||
*
|
||||
* The array maps to the CSS matrix: [a, b, c, d, tx, ty] where:
|
||||
* | a c tx | (a=scaleX, b=skewY, c=skewX, d=scaleY, tx/ty=translate)
|
||||
* | b d ty |
|
||||
* | 0 0 1 |
|
||||
*/
|
||||
export function parseTransformMatrix(css: string): number[] | null {
|
||||
if (!css || css === "none") return null;
|
||||
const match = css.match(
|
||||
/^matrix\(\s*([^,]+),\s*([^,]+),\s*([^,]+),\s*([^,]+),\s*([^,]+),\s*([^,)]+)\s*\)$/,
|
||||
);
|
||||
if (!match) return null;
|
||||
return match.slice(1, 7).map(Number);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user