@hyperframes/engine 0.4.6 → 0.4.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config.d.ts +6 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +9 -0
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +8 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/services/browserManager.d.ts.map +1 -1
- package/dist/services/browserManager.js +6 -3
- package/dist/services/browserManager.js.map +1 -1
- package/dist/services/chunkEncoder.d.ts +14 -7
- package/dist/services/chunkEncoder.d.ts.map +1 -1
- package/dist/services/chunkEncoder.js +25 -9
- package/dist/services/chunkEncoder.js.map +1 -1
- package/dist/services/chunkEncoder.types.d.ts +4 -0
- package/dist/services/chunkEncoder.types.d.ts.map +1 -1
- package/dist/services/hdrCapture.d.ts +62 -0
- package/dist/services/hdrCapture.d.ts.map +1 -0
- package/dist/services/hdrCapture.js +259 -0
- package/dist/services/hdrCapture.js.map +1 -0
- package/dist/services/screenshotService.d.ts +34 -0
- package/dist/services/screenshotService.d.ts.map +1 -1
- package/dist/services/screenshotService.js +97 -19
- package/dist/services/screenshotService.js.map +1 -1
- package/dist/services/streamingEncoder.d.ts +18 -3
- package/dist/services/streamingEncoder.d.ts.map +1 -1
- package/dist/services/streamingEncoder.js +104 -50
- package/dist/services/streamingEncoder.js.map +1 -1
- package/dist/services/videoFrameExtractor.d.ts.map +1 -1
- package/dist/services/videoFrameExtractor.js +109 -18
- package/dist/services/videoFrameExtractor.js.map +1 -1
- package/dist/services/videoFrameInjector.d.ts +54 -0
- package/dist/services/videoFrameInjector.d.ts.map +1 -1
- package/dist/services/videoFrameInjector.js +159 -0
- package/dist/services/videoFrameInjector.js.map +1 -1
- package/dist/utils/alphaBlit.d.ts +105 -0
- package/dist/utils/alphaBlit.d.ts.map +1 -0
- package/dist/utils/alphaBlit.js +550 -0
- package/dist/utils/alphaBlit.js.map +1 -0
- package/dist/utils/ffprobe.d.ts +10 -0
- package/dist/utils/ffprobe.d.ts.map +1 -1
- package/dist/utils/ffprobe.js +7 -0
- package/dist/utils/ffprobe.js.map +1 -1
- package/dist/utils/hdr.d.ts +82 -0
- package/dist/utils/hdr.d.ts.map +1 -0
- package/dist/utils/hdr.js +87 -0
- package/dist/utils/hdr.js.map +1 -0
- package/dist/utils/layerCompositor.d.ts +36 -0
- package/dist/utils/layerCompositor.d.ts.map +1 -0
- package/dist/utils/layerCompositor.js +49 -0
- package/dist/utils/layerCompositor.js.map +1 -0
- package/package.json +3 -2
- package/src/config.ts +16 -0
- package/src/index.ts +42 -0
- package/src/services/browserManager.ts +6 -3
- package/src/services/chunkEncoder.test.ts +88 -0
- package/src/services/chunkEncoder.ts +35 -9
- package/src/services/chunkEncoder.types.ts +3 -0
- package/src/services/hdrCapture.test.ts +159 -0
- package/src/services/hdrCapture.ts +354 -0
- package/src/services/screenshotService.ts +102 -17
- package/src/services/streamingEncoder.test.ts +228 -0
- package/src/services/streamingEncoder.ts +153 -63
- package/src/services/videoFrameExtractor.ts +135 -31
- package/src/services/videoFrameInjector.ts +200 -0
- package/src/utils/alphaBlit.test.ts +993 -0
- package/src/utils/alphaBlit.ts +643 -0
- package/src/utils/ffprobe.ts +22 -0
- package/src/utils/hdr.test.ts +191 -0
- package/src/utils/hdr.ts +137 -0
- package/src/utils/layerCompositor.test.ts +141 -0
- package/src/utils/layerCompositor.ts +58 -0
- package/tsconfig.json +2 -1
|
@@ -0,0 +1,993 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { deflateSync } from "zlib";
|
|
3
|
+
import {
|
|
4
|
+
decodePng,
|
|
5
|
+
decodePngToRgb48le,
|
|
6
|
+
blitRgba8OverRgb48le,
|
|
7
|
+
blitRgb48leRegion,
|
|
8
|
+
blitRgb48leAffine,
|
|
9
|
+
parseTransformMatrix,
|
|
10
|
+
roundedRectAlpha,
|
|
11
|
+
} from "./alphaBlit.js";
|
|
12
|
+
|
|
13
|
+
// ── PNG construction helpers ─────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
function uint32BE(n: number): Buffer {
|
|
16
|
+
const b = Buffer.allocUnsafe(4);
|
|
17
|
+
b.writeUInt32BE(n, 0);
|
|
18
|
+
return b;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function crc32(data: Buffer): number {
|
|
22
|
+
let crc = 0xffffffff;
|
|
23
|
+
const table = crc32Table();
|
|
24
|
+
for (let i = 0; i < data.length; i++) {
|
|
25
|
+
crc = (table[(crc ^ (data[i] ?? 0)) & 0xff] ?? 0) ^ (crc >>> 8);
|
|
26
|
+
}
|
|
27
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let _crcTable: Uint32Array | undefined;
|
|
31
|
+
function crc32Table(): Uint32Array {
|
|
32
|
+
if (_crcTable) return _crcTable;
|
|
33
|
+
const t = new Uint32Array(256);
|
|
34
|
+
for (let i = 0; i < 256; i++) {
|
|
35
|
+
let c = i;
|
|
36
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
37
|
+
t[i] = c;
|
|
38
|
+
}
|
|
39
|
+
_crcTable = t;
|
|
40
|
+
return t;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function makeChunk(type: string, data: Buffer): Buffer {
|
|
44
|
+
const typeBuffer = Buffer.from(type, "ascii");
|
|
45
|
+
const crcInput = Buffer.concat([typeBuffer, data]);
|
|
46
|
+
const crcBuf = uint32BE(crc32(crcInput));
|
|
47
|
+
return Buffer.concat([uint32BE(data.length), typeBuffer, data, crcBuf]);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const PNG_SIG = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build a minimal RGBA PNG for testing.
|
|
54
|
+
* pixels: flat RGBA array (row-major, 8-bit per channel)
|
|
55
|
+
*/
|
|
56
|
+
function makePng(width: number, height: number, pixels: number[]): Buffer {
|
|
57
|
+
// IHDR
|
|
58
|
+
const ihdr = Buffer.allocUnsafe(13);
|
|
59
|
+
ihdr.writeUInt32BE(width, 0);
|
|
60
|
+
ihdr.writeUInt32BE(height, 4);
|
|
61
|
+
ihdr[8] = 8; // bit depth
|
|
62
|
+
ihdr[9] = 6; // color type RGBA
|
|
63
|
+
ihdr[10] = 0; // compression
|
|
64
|
+
ihdr[11] = 0; // filter method
|
|
65
|
+
ihdr[12] = 0; // interlace none
|
|
66
|
+
|
|
67
|
+
// Raw scanlines with filter byte 0 (None)
|
|
68
|
+
const scanlines: number[] = [];
|
|
69
|
+
for (let y = 0; y < height; y++) {
|
|
70
|
+
scanlines.push(0); // filter type None
|
|
71
|
+
for (let x = 0; x < width; x++) {
|
|
72
|
+
const i = (y * width + x) * 4;
|
|
73
|
+
scanlines.push(pixels[i] ?? 0, pixels[i + 1] ?? 0, pixels[i + 2] ?? 0, pixels[i + 3] ?? 0);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const idatData = deflateSync(Buffer.from(scanlines));
|
|
78
|
+
|
|
79
|
+
return Buffer.concat([
|
|
80
|
+
PNG_SIG,
|
|
81
|
+
makeChunk("IHDR", ihdr),
|
|
82
|
+
makeChunk("IDAT", idatData),
|
|
83
|
+
makeChunk("IEND", Buffer.alloc(0)),
|
|
84
|
+
]);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── decodePng tests ──────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
describe("decodePng", () => {
|
|
90
|
+
it("decodes a 1x1 RGBA PNG correctly", () => {
|
|
91
|
+
// RGBA: red pixel, full opacity
|
|
92
|
+
const png = makePng(1, 1, [255, 0, 0, 255]);
|
|
93
|
+
const { width, height, data } = decodePng(png);
|
|
94
|
+
expect(width).toBe(1);
|
|
95
|
+
expect(height).toBe(1);
|
|
96
|
+
expect(data[0]).toBe(255); // R
|
|
97
|
+
expect(data[1]).toBe(0); // G
|
|
98
|
+
expect(data[2]).toBe(0); // B
|
|
99
|
+
expect(data[3]).toBe(255); // A
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("decodes a 2x2 RGBA PNG with multiple pixels", () => {
|
|
103
|
+
// TL=red, TR=green, BL=blue, BR=white (all full opacity)
|
|
104
|
+
const pixels = [
|
|
105
|
+
255,
|
|
106
|
+
0,
|
|
107
|
+
0,
|
|
108
|
+
255, // TL red
|
|
109
|
+
0,
|
|
110
|
+
255,
|
|
111
|
+
0,
|
|
112
|
+
255, // TR green
|
|
113
|
+
0,
|
|
114
|
+
0,
|
|
115
|
+
255,
|
|
116
|
+
255, // BL blue
|
|
117
|
+
255,
|
|
118
|
+
255,
|
|
119
|
+
255,
|
|
120
|
+
255, // BR white
|
|
121
|
+
];
|
|
122
|
+
const png = makePng(2, 2, pixels);
|
|
123
|
+
const { width, height, data } = decodePng(png);
|
|
124
|
+
expect(width).toBe(2);
|
|
125
|
+
expect(height).toBe(2);
|
|
126
|
+
|
|
127
|
+
// Top-left: red
|
|
128
|
+
expect(data[0]).toBe(255);
|
|
129
|
+
expect(data[1]).toBe(0);
|
|
130
|
+
expect(data[2]).toBe(0);
|
|
131
|
+
expect(data[3]).toBe(255);
|
|
132
|
+
|
|
133
|
+
// Bottom-right: white
|
|
134
|
+
expect(data[12]).toBe(255);
|
|
135
|
+
expect(data[13]).toBe(255);
|
|
136
|
+
expect(data[14]).toBe(255);
|
|
137
|
+
expect(data[15]).toBe(255);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("decodes a transparent pixel correctly", () => {
|
|
141
|
+
const png = makePng(1, 1, [128, 64, 32, 0]);
|
|
142
|
+
const { data } = decodePng(png);
|
|
143
|
+
expect(data[3]).toBe(0); // alpha = 0
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("decodes a semi-transparent pixel correctly", () => {
|
|
147
|
+
const png = makePng(1, 1, [100, 150, 200, 128]);
|
|
148
|
+
const { data } = decodePng(png);
|
|
149
|
+
expect(data[0]).toBe(100);
|
|
150
|
+
expect(data[1]).toBe(150);
|
|
151
|
+
expect(data[2]).toBe(200);
|
|
152
|
+
expect(data[3]).toBe(128);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("throws on invalid PNG signature", () => {
|
|
156
|
+
const buf = Buffer.from([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
|
|
157
|
+
expect(() => decodePng(buf)).toThrow("not a PNG file");
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ── PNG filter coverage ─────────────────────────────────────────────────────
|
|
162
|
+
//
|
|
163
|
+
// `makePng` only exercises filter type 0 (None). libpng (and Chrome) pick
|
|
164
|
+
// other filter types heuristically; these tests build raw IDAT bytes with each
|
|
165
|
+
// filter type so the defilter logic gets actual coverage.
|
|
166
|
+
|
|
167
|
+
const paethRef = (a: number, b: number, c: number): number => {
|
|
168
|
+
const p = a + b - c;
|
|
169
|
+
const pa = Math.abs(p - a);
|
|
170
|
+
const pb = Math.abs(p - b);
|
|
171
|
+
const pc = Math.abs(p - c);
|
|
172
|
+
if (pa <= pb && pa <= pc) return a;
|
|
173
|
+
if (pb <= pc) return b;
|
|
174
|
+
return c;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Build a PNG with a specific filter type applied to every row. Encodes a
|
|
179
|
+
* 3×2 RGBA image with unique per-channel values so any cross-channel mistake
|
|
180
|
+
* in the defilter loop shows up as an assertion failure.
|
|
181
|
+
*
|
|
182
|
+
* @param filterType 0=None, 1=Sub, 2=Up, 3=Average, 4=Paeth
|
|
183
|
+
*/
|
|
184
|
+
function makePngWithFilter(filterType: 0 | 1 | 2 | 3 | 4): {
|
|
185
|
+
png: Buffer;
|
|
186
|
+
expectedPixels: number[];
|
|
187
|
+
} {
|
|
188
|
+
const width = 3;
|
|
189
|
+
const height = 2;
|
|
190
|
+
const bpp = 4; // RGBA, 8-bit
|
|
191
|
+
const stride = width * bpp;
|
|
192
|
+
|
|
193
|
+
// Unique pixels so any defilter bug is observable
|
|
194
|
+
const expectedPixels = [
|
|
195
|
+
10, 20, 30, 255, 50, 60, 70, 255, 90, 100, 110, 255, 130, 140, 150, 255, 170, 180, 190, 255,
|
|
196
|
+
210, 220, 230, 255,
|
|
197
|
+
];
|
|
198
|
+
|
|
199
|
+
const filtered: number[] = [];
|
|
200
|
+
const prev = new Uint8Array(stride);
|
|
201
|
+
for (let y = 0; y < height; y++) {
|
|
202
|
+
filtered.push(filterType);
|
|
203
|
+
const rowStart = y * stride;
|
|
204
|
+
const curr = new Uint8Array(stride);
|
|
205
|
+
for (let x = 0; x < stride; x++) curr[x] = expectedPixels[rowStart + x] ?? 0;
|
|
206
|
+
|
|
207
|
+
const out = new Uint8Array(stride);
|
|
208
|
+
for (let x = 0; x < stride; x++) {
|
|
209
|
+
const a = x >= bpp ? (curr[x - bpp] ?? 0) : 0;
|
|
210
|
+
const b = prev[x] ?? 0;
|
|
211
|
+
const c = x >= bpp ? (prev[x - bpp] ?? 0) : 0;
|
|
212
|
+
const cv = curr[x] ?? 0;
|
|
213
|
+
switch (filterType) {
|
|
214
|
+
case 0:
|
|
215
|
+
out[x] = cv;
|
|
216
|
+
break;
|
|
217
|
+
case 1:
|
|
218
|
+
out[x] = (cv - a) & 0xff;
|
|
219
|
+
break;
|
|
220
|
+
case 2:
|
|
221
|
+
out[x] = (cv - b) & 0xff;
|
|
222
|
+
break;
|
|
223
|
+
case 3:
|
|
224
|
+
out[x] = (cv - Math.floor((a + b) / 2)) & 0xff;
|
|
225
|
+
break;
|
|
226
|
+
case 4:
|
|
227
|
+
out[x] = (cv - paethRef(a, b, c)) & 0xff;
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
for (let x = 0; x < stride; x++) filtered.push(out[x] ?? 0);
|
|
232
|
+
prev.set(curr);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const ihdr = Buffer.allocUnsafe(13);
|
|
236
|
+
ihdr.writeUInt32BE(width, 0);
|
|
237
|
+
ihdr.writeUInt32BE(height, 4);
|
|
238
|
+
ihdr[8] = 8;
|
|
239
|
+
ihdr[9] = 6;
|
|
240
|
+
ihdr[10] = 0;
|
|
241
|
+
ihdr[11] = 0;
|
|
242
|
+
ihdr[12] = 0;
|
|
243
|
+
|
|
244
|
+
const idat = deflateSync(Buffer.from(filtered));
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
png: Buffer.concat([
|
|
248
|
+
PNG_SIG,
|
|
249
|
+
makeChunk("IHDR", ihdr),
|
|
250
|
+
makeChunk("IDAT", idat),
|
|
251
|
+
makeChunk("IEND", Buffer.alloc(0)),
|
|
252
|
+
]),
|
|
253
|
+
expectedPixels,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
describe("decodePng filter coverage", () => {
|
|
258
|
+
it.each([
|
|
259
|
+
[0, "None"],
|
|
260
|
+
[1, "Sub"],
|
|
261
|
+
[2, "Up"],
|
|
262
|
+
[3, "Average"],
|
|
263
|
+
[4, "Paeth"],
|
|
264
|
+
] as const)("round-trips a 3×2 PNG with filter type %d (%s)", (filterType) => {
|
|
265
|
+
const { png, expectedPixels } = makePngWithFilter(filterType);
|
|
266
|
+
const { width, height, data } = decodePng(png);
|
|
267
|
+
expect(width).toBe(3);
|
|
268
|
+
expect(height).toBe(2);
|
|
269
|
+
for (let i = 0; i < expectedPixels.length; i++) {
|
|
270
|
+
expect(data[i]).toBe(expectedPixels[i]);
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("decodes a PNG split across multiple IDAT chunks", () => {
|
|
275
|
+
// Build a normal single-IDAT PNG, then split its IDAT payload in half.
|
|
276
|
+
// Chrome routinely emits multi-chunk IDATs (default ~8KB segment size).
|
|
277
|
+
const { png: singleIdatPng, expectedPixels } = makePngWithFilter(0);
|
|
278
|
+
|
|
279
|
+
// Walk chunks to find IDAT
|
|
280
|
+
let pos = 8;
|
|
281
|
+
let ihdrChunk: Buffer | null = null;
|
|
282
|
+
let idatPayload: Buffer | null = null;
|
|
283
|
+
while (pos + 12 <= singleIdatPng.length) {
|
|
284
|
+
const len = singleIdatPng.readUInt32BE(pos);
|
|
285
|
+
const type = singleIdatPng.toString("ascii", pos + 4, pos + 8);
|
|
286
|
+
const data = singleIdatPng.subarray(pos + 8, pos + 8 + len);
|
|
287
|
+
const fullChunk = singleIdatPng.subarray(pos, pos + 12 + len);
|
|
288
|
+
if (type === "IHDR") ihdrChunk = Buffer.from(fullChunk);
|
|
289
|
+
if (type === "IDAT") idatPayload = Buffer.from(data);
|
|
290
|
+
if (type === "IEND") break;
|
|
291
|
+
pos += 12 + len;
|
|
292
|
+
}
|
|
293
|
+
expect(ihdrChunk).not.toBeNull();
|
|
294
|
+
expect(idatPayload).not.toBeNull();
|
|
295
|
+
if (!ihdrChunk || !idatPayload) return;
|
|
296
|
+
|
|
297
|
+
// Split the IDAT payload roughly in half across two IDAT chunks
|
|
298
|
+
const split = Math.floor(idatPayload.length / 2);
|
|
299
|
+
const part1 = idatPayload.subarray(0, split);
|
|
300
|
+
const part2 = idatPayload.subarray(split);
|
|
301
|
+
|
|
302
|
+
const multiIdatPng = Buffer.concat([
|
|
303
|
+
PNG_SIG,
|
|
304
|
+
ihdrChunk,
|
|
305
|
+
makeChunk("IDAT", Buffer.from(part1)),
|
|
306
|
+
makeChunk("IDAT", Buffer.from(part2)),
|
|
307
|
+
makeChunk("IEND", Buffer.alloc(0)),
|
|
308
|
+
]);
|
|
309
|
+
|
|
310
|
+
const { data } = decodePng(multiIdatPng);
|
|
311
|
+
for (let i = 0; i < expectedPixels.length; i++) {
|
|
312
|
+
expect(data[i]).toBe(expectedPixels[i]);
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("throws on Adam7-interlaced PNGs", () => {
|
|
317
|
+
const ihdr = Buffer.allocUnsafe(13);
|
|
318
|
+
ihdr.writeUInt32BE(1, 0);
|
|
319
|
+
ihdr.writeUInt32BE(1, 4);
|
|
320
|
+
ihdr[8] = 8;
|
|
321
|
+
ihdr[9] = 6;
|
|
322
|
+
ihdr[10] = 0;
|
|
323
|
+
ihdr[11] = 0;
|
|
324
|
+
ihdr[12] = 1; // Adam7 interlace
|
|
325
|
+
const idat = deflateSync(Buffer.from([0, 0, 0, 0, 255]));
|
|
326
|
+
const png = Buffer.concat([
|
|
327
|
+
PNG_SIG,
|
|
328
|
+
makeChunk("IHDR", ihdr),
|
|
329
|
+
makeChunk("IDAT", idat),
|
|
330
|
+
makeChunk("IEND", Buffer.alloc(0)),
|
|
331
|
+
]);
|
|
332
|
+
expect(() => decodePng(png)).toThrow("interlace");
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("throws on PNGs missing the IHDR chunk", () => {
|
|
336
|
+
const idat = deflateSync(Buffer.from([0, 0, 0, 0, 255]));
|
|
337
|
+
const png = Buffer.concat([
|
|
338
|
+
PNG_SIG,
|
|
339
|
+
makeChunk("IDAT", idat),
|
|
340
|
+
makeChunk("IEND", Buffer.alloc(0)),
|
|
341
|
+
]);
|
|
342
|
+
expect(() => decodePng(png)).toThrow("IHDR");
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// ── decodePngToRgb48le tests ────────────────────────────────────────────────
|
|
347
|
+
//
|
|
348
|
+
// FFmpeg emits 16-bit RGB PNGs (big-endian on the wire). The decoder swaps to
|
|
349
|
+
// little-endian for the streaming HDR encoder. These tests cover the byte-order
|
|
350
|
+
// swap, precision preservation, and multi-pixel row-major layout that the
|
|
351
|
+
// 8-bit suite cannot exercise.
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Build a 16-bit RGB PNG (colorType 2, bitDepth 16). PNG stores each 16-bit
|
|
355
|
+
* sample as two big-endian bytes; the decoder must swap them to LE.
|
|
356
|
+
*
|
|
357
|
+
* @param pixels Flat array of [r16, g16, b16, r16, g16, b16, ...] values
|
|
358
|
+
* (one entry per channel sample, 0–65535).
|
|
359
|
+
*/
|
|
360
|
+
function makePng16(width: number, height: number, pixels: number[]): Buffer {
|
|
361
|
+
const ihdr = Buffer.allocUnsafe(13);
|
|
362
|
+
ihdr.writeUInt32BE(width, 0);
|
|
363
|
+
ihdr.writeUInt32BE(height, 4);
|
|
364
|
+
ihdr[8] = 16; // bit depth
|
|
365
|
+
ihdr[9] = 2; // color type RGB
|
|
366
|
+
ihdr[10] = 0;
|
|
367
|
+
ihdr[11] = 0;
|
|
368
|
+
ihdr[12] = 0;
|
|
369
|
+
|
|
370
|
+
const stride = width * 6; // 3 channels × 2 bytes
|
|
371
|
+
const filtered: number[] = [];
|
|
372
|
+
for (let y = 0; y < height; y++) {
|
|
373
|
+
filtered.push(0); // filter type None
|
|
374
|
+
for (let x = 0; x < width; x++) {
|
|
375
|
+
const baseSample = (y * width + x) * 3;
|
|
376
|
+
for (let ch = 0; ch < 3; ch++) {
|
|
377
|
+
const v = pixels[baseSample + ch] ?? 0;
|
|
378
|
+
filtered.push((v >> 8) & 0xff); // high byte (BE on wire)
|
|
379
|
+
filtered.push(v & 0xff); // low byte
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
void stride;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const idat = deflateSync(Buffer.from(filtered));
|
|
386
|
+
return Buffer.concat([
|
|
387
|
+
PNG_SIG,
|
|
388
|
+
makeChunk("IHDR", ihdr),
|
|
389
|
+
makeChunk("IDAT", idat),
|
|
390
|
+
makeChunk("IEND", Buffer.alloc(0)),
|
|
391
|
+
]);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
describe("decodePngToRgb48le", () => {
|
|
395
|
+
it("swaps PNG big-endian samples to little-endian rgb48le", () => {
|
|
396
|
+
// Pick a value where high and low bytes differ so a missed swap is observable
|
|
397
|
+
const v = 0x1234;
|
|
398
|
+
const png = makePng16(1, 1, [v, v, v]);
|
|
399
|
+
const { width, height, data } = decodePngToRgb48le(png);
|
|
400
|
+
expect(width).toBe(1);
|
|
401
|
+
expect(height).toBe(1);
|
|
402
|
+
expect(data.length).toBe(6);
|
|
403
|
+
expect(data.readUInt16LE(0)).toBe(v);
|
|
404
|
+
expect(data.readUInt16LE(2)).toBe(v);
|
|
405
|
+
expect(data.readUInt16LE(4)).toBe(v);
|
|
406
|
+
// Spot-check raw byte order: low byte first, then high
|
|
407
|
+
expect(data[0]).toBe(0x34);
|
|
408
|
+
expect(data[1]).toBe(0x12);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("preserves full 16-bit precision (no 8-bit truncation)", () => {
|
|
412
|
+
// A value whose low byte alone would be misleading — proves both bytes survive
|
|
413
|
+
const r = 0xabcd;
|
|
414
|
+
const g = 0xfedc;
|
|
415
|
+
const b = 0x0102;
|
|
416
|
+
const png = makePng16(1, 1, [r, g, b]);
|
|
417
|
+
const { data } = decodePngToRgb48le(png);
|
|
418
|
+
expect(data.readUInt16LE(0)).toBe(r);
|
|
419
|
+
expect(data.readUInt16LE(2)).toBe(g);
|
|
420
|
+
expect(data.readUInt16LE(4)).toBe(b);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("decodes a 2×2 image with row-major layout", () => {
|
|
424
|
+
const pixels = [
|
|
425
|
+
// row 0
|
|
426
|
+
1000, 2000, 3000, 4000, 5000, 6000,
|
|
427
|
+
// row 1
|
|
428
|
+
7000, 8000, 9000, 10000, 11000, 12000,
|
|
429
|
+
];
|
|
430
|
+
const png = makePng16(2, 2, pixels);
|
|
431
|
+
const { width, height, data } = decodePngToRgb48le(png);
|
|
432
|
+
expect(width).toBe(2);
|
|
433
|
+
expect(height).toBe(2);
|
|
434
|
+
expect(data.length).toBe(2 * 2 * 6);
|
|
435
|
+
|
|
436
|
+
for (let i = 0; i < 4; i++) {
|
|
437
|
+
expect(data.readUInt16LE(i * 6 + 0)).toBe(pixels[i * 3 + 0]);
|
|
438
|
+
expect(data.readUInt16LE(i * 6 + 2)).toBe(pixels[i * 3 + 1]);
|
|
439
|
+
expect(data.readUInt16LE(i * 6 + 4)).toBe(pixels[i * 3 + 2]);
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it("rejects 8-bit PNGs with a clear error", () => {
|
|
444
|
+
const png = makePng(1, 1, [255, 0, 0, 255]); // 8-bit RGBA
|
|
445
|
+
expect(() => decodePngToRgb48le(png)).toThrow(/bit depth/);
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
// ── blitRgba8OverRgb48le tests ───────────────────────────────────────────────
|
|
450
|
+
|
|
451
|
+
/** Build an rgb48le buffer with a single solid color (16-bit per channel) */
|
|
452
|
+
function makeHdrFrame(
|
|
453
|
+
width: number,
|
|
454
|
+
height: number,
|
|
455
|
+
r16: number,
|
|
456
|
+
g16: number,
|
|
457
|
+
b16: number,
|
|
458
|
+
): Buffer {
|
|
459
|
+
const buf = Buffer.allocUnsafe(width * height * 6);
|
|
460
|
+
for (let i = 0; i < width * height; i++) {
|
|
461
|
+
buf.writeUInt16LE(r16, i * 6);
|
|
462
|
+
buf.writeUInt16LE(g16, i * 6 + 2);
|
|
463
|
+
buf.writeUInt16LE(b16, i * 6 + 4);
|
|
464
|
+
}
|
|
465
|
+
return buf;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** Build a raw RGBA array (Uint8Array) with a single solid color */
|
|
469
|
+
function makeDomRgba(
|
|
470
|
+
width: number,
|
|
471
|
+
height: number,
|
|
472
|
+
r: number,
|
|
473
|
+
g: number,
|
|
474
|
+
b: number,
|
|
475
|
+
a: number,
|
|
476
|
+
): Uint8Array {
|
|
477
|
+
const arr = new Uint8Array(width * height * 4);
|
|
478
|
+
for (let i = 0; i < width * height; i++) {
|
|
479
|
+
arr[i * 4 + 0] = r;
|
|
480
|
+
arr[i * 4 + 1] = g;
|
|
481
|
+
arr[i * 4 + 2] = b;
|
|
482
|
+
arr[i * 4 + 3] = a;
|
|
483
|
+
}
|
|
484
|
+
return arr;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
describe("blitRgba8OverRgb48le", () => {
|
|
488
|
+
it("fully transparent DOM: canvas unchanged", () => {
|
|
489
|
+
const canvas = makeHdrFrame(1, 1, 32000, 40000, 50000);
|
|
490
|
+
const dom = makeDomRgba(1, 1, 255, 0, 0, 0); // red but alpha=0
|
|
491
|
+
blitRgba8OverRgb48le(dom, canvas, 1, 1);
|
|
492
|
+
|
|
493
|
+
expect(canvas.readUInt16LE(0)).toBe(32000);
|
|
494
|
+
expect(canvas.readUInt16LE(2)).toBe(40000);
|
|
495
|
+
expect(canvas.readUInt16LE(4)).toBe(50000);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it("fully opaque DOM: sRGB→HLG converted values overwrite canvas", () => {
|
|
499
|
+
const canvas = makeHdrFrame(1, 1, 10000, 20000, 30000);
|
|
500
|
+
const dom = makeDomRgba(1, 1, 255, 128, 0, 255); // R=255, G=128, B=0, full opaque
|
|
501
|
+
blitRgba8OverRgb48le(dom, canvas, 1, 1);
|
|
502
|
+
|
|
503
|
+
// sRGB 255 → HLG 65535 (white maps to white)
|
|
504
|
+
// sRGB 128 → HLG ~46484 (mid-gray maps higher due to HLG OETF)
|
|
505
|
+
// sRGB 0 → HLG 0
|
|
506
|
+
expect(canvas.readUInt16LE(0)).toBe(65535);
|
|
507
|
+
expect(canvas.readUInt16LE(2)).toBeGreaterThan(40000); // HLG mid-gray > sRGB mid-gray
|
|
508
|
+
expect(canvas.readUInt16LE(2)).toBeLessThan(50000);
|
|
509
|
+
expect(canvas.readUInt16LE(4)).toBe(0);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it("sRGB→HLG: black stays black, white stays white", () => {
|
|
513
|
+
const canvasBlack = makeHdrFrame(1, 1, 0, 0, 0);
|
|
514
|
+
const domBlack = makeDomRgba(1, 1, 0, 0, 0, 255);
|
|
515
|
+
blitRgba8OverRgb48le(domBlack, canvasBlack, 1, 1);
|
|
516
|
+
expect(canvasBlack.readUInt16LE(0)).toBe(0);
|
|
517
|
+
|
|
518
|
+
const canvasWhite = makeHdrFrame(1, 1, 0, 0, 0);
|
|
519
|
+
const domWhite = makeDomRgba(1, 1, 255, 255, 255, 255);
|
|
520
|
+
blitRgba8OverRgb48le(domWhite, canvasWhite, 1, 1);
|
|
521
|
+
expect(canvasWhite.readUInt16LE(0)).toBe(65535);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it("50% alpha: HLG-converted DOM blended with canvas", () => {
|
|
525
|
+
// DOM: white (255, 255, 255) at alpha=128 (~50%)
|
|
526
|
+
// Canvas: black (0, 0, 0)
|
|
527
|
+
const canvas = makeHdrFrame(1, 1, 0, 0, 0);
|
|
528
|
+
const dom = makeDomRgba(1, 1, 255, 255, 255, 128);
|
|
529
|
+
blitRgba8OverRgb48le(dom, canvas, 1, 1);
|
|
530
|
+
|
|
531
|
+
// sRGB 255 → HLG 65535, blended 50/50 with black
|
|
532
|
+
const alpha = 128 / 255;
|
|
533
|
+
const expectedR = Math.round(65535 * alpha);
|
|
534
|
+
expect(canvas.readUInt16LE(0)).toBeCloseTo(expectedR, -1);
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it("50% alpha blends with non-zero canvas", () => {
|
|
538
|
+
// DOM: 8-bit red=200, canvas: 16-bit red=32000, alpha=128
|
|
539
|
+
const canvas = makeHdrFrame(1, 1, 32000, 0, 0);
|
|
540
|
+
const dom = makeDomRgba(1, 1, 200, 0, 0, 128);
|
|
541
|
+
blitRgba8OverRgb48le(dom, canvas, 1, 1);
|
|
542
|
+
|
|
543
|
+
// sRGB 200 → HLG value, blended ~50/50 with canvas red=32000
|
|
544
|
+
// Result should be higher than 32000 (pulled up by the HLG-converted DOM value)
|
|
545
|
+
expect(canvas.readUInt16LE(0)).toBeGreaterThan(32000);
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it("α=254 still blends (no fast-path overwrite at the opaque boundary)", () => {
|
|
549
|
+
// Reviewer feedback: confirm the alpha branch is taken for any α < 255.
|
|
550
|
+
// α=254 should *almost* match α=255 but still leave a sliver of the canvas
|
|
551
|
+
// value visible — proving we didn't accidentally fast-path α >= 254.
|
|
552
|
+
const canvasOpaque = makeHdrFrame(1, 1, 0, 0, 0);
|
|
553
|
+
const domOpaque = makeDomRgba(1, 1, 255, 255, 255, 255);
|
|
554
|
+
blitRgba8OverRgb48le(domOpaque, canvasOpaque, 1, 1);
|
|
555
|
+
const opaqueR = canvasOpaque.readUInt16LE(0);
|
|
556
|
+
|
|
557
|
+
const canvasNear = makeHdrFrame(1, 1, 1000, 1000, 1000);
|
|
558
|
+
const domNear = makeDomRgba(1, 1, 255, 255, 255, 254);
|
|
559
|
+
blitRgba8OverRgb48le(domNear, canvasNear, 1, 1);
|
|
560
|
+
const nearR = canvasNear.readUInt16LE(0);
|
|
561
|
+
|
|
562
|
+
// α=255 over black gave us the pure HLG-of-white value
|
|
563
|
+
expect(opaqueR).toBe(65535);
|
|
564
|
+
// α=254 over (1000, 1000, 1000) must be *strictly less* than α=255 over black —
|
|
565
|
+
// if the implementation short-circuits at α >= 254 it would also return 65535.
|
|
566
|
+
expect(nearR).toBeLessThan(opaqueR);
|
|
567
|
+
// …but it should still be very close (within ~1% of full white)
|
|
568
|
+
expect(nearR).toBeGreaterThan(64000);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
it("handles a 2x2 frame correctly pixel-by-pixel", () => {
|
|
572
|
+
const canvas = makeHdrFrame(2, 2, 0, 0, 0);
|
|
573
|
+
// First pixel: fully opaque white. Others: fully transparent.
|
|
574
|
+
const dom = new Uint8Array(2 * 2 * 4);
|
|
575
|
+
dom[0] = 255;
|
|
576
|
+
dom[1] = 255;
|
|
577
|
+
dom[2] = 255;
|
|
578
|
+
dom[3] = 255; // pixel 0: opaque white
|
|
579
|
+
// pixels 1-3: alpha=0 (transparent)
|
|
580
|
+
|
|
581
|
+
blitRgba8OverRgb48le(dom, canvas, 2, 2);
|
|
582
|
+
|
|
583
|
+
// Pixel 0: sRGB white → HLG white (65535)
|
|
584
|
+
expect(canvas.readUInt16LE(0)).toBe(65535);
|
|
585
|
+
expect(canvas.readUInt16LE(2)).toBe(65535);
|
|
586
|
+
expect(canvas.readUInt16LE(4)).toBe(65535);
|
|
587
|
+
|
|
588
|
+
// Pixel 1: transparent DOM → canvas black (0, 0, 0) unchanged
|
|
589
|
+
expect(canvas.readUInt16LE(6)).toBe(0);
|
|
590
|
+
expect(canvas.readUInt16LE(8)).toBe(0);
|
|
591
|
+
expect(canvas.readUInt16LE(10)).toBe(0);
|
|
592
|
+
});
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
describe("blitRgba8OverRgb48le with PQ transfer", () => {
|
|
596
|
+
it("PQ: black stays black, white maps to PQ white", () => {
|
|
597
|
+
const canvasBlack = makeHdrFrame(1, 1, 0, 0, 0);
|
|
598
|
+
const domBlack = makeDomRgba(1, 1, 0, 0, 0, 255);
|
|
599
|
+
blitRgba8OverRgb48le(domBlack, canvasBlack, 1, 1, "pq");
|
|
600
|
+
expect(canvasBlack.readUInt16LE(0)).toBe(0);
|
|
601
|
+
|
|
602
|
+
const canvasWhite = makeHdrFrame(1, 1, 0, 0, 0);
|
|
603
|
+
const domWhite = makeDomRgba(1, 1, 255, 255, 255, 255);
|
|
604
|
+
blitRgba8OverRgb48le(domWhite, canvasWhite, 1, 1, "pq");
|
|
605
|
+
// PQ white at SDR 203 nits is NOT 65535 (that's 10000 nits)
|
|
606
|
+
// SDR white in PQ ≈ 58% signal → ~38000
|
|
607
|
+
const pqWhite = canvasWhite.readUInt16LE(0);
|
|
608
|
+
expect(pqWhite).toBeGreaterThan(30000);
|
|
609
|
+
expect(pqWhite).toBeLessThan(45000);
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
it("PQ mid-gray differs from HLG mid-gray", () => {
|
|
613
|
+
const canvasHlg = makeHdrFrame(1, 1, 0, 0, 0);
|
|
614
|
+
const canvasPq = makeHdrFrame(1, 1, 0, 0, 0);
|
|
615
|
+
const dom = makeDomRgba(1, 1, 128, 128, 128, 255);
|
|
616
|
+
|
|
617
|
+
blitRgba8OverRgb48le(dom, canvasHlg, 1, 1, "hlg");
|
|
618
|
+
blitRgba8OverRgb48le(dom, canvasPq, 1, 1, "pq");
|
|
619
|
+
|
|
620
|
+
const hlgVal = canvasHlg.readUInt16LE(0);
|
|
621
|
+
const pqVal = canvasPq.readUInt16LE(0);
|
|
622
|
+
// PQ and HLG encode mid-gray differently
|
|
623
|
+
expect(hlgVal).not.toBe(pqVal);
|
|
624
|
+
// Both should be non-zero
|
|
625
|
+
expect(hlgVal).toBeGreaterThan(0);
|
|
626
|
+
expect(pqVal).toBeGreaterThan(0);
|
|
627
|
+
});
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
// ── blitRgb48leRegion tests ──────────────────────────────────────────────────
|
|
631
|
+
|
|
632
|
+
describe("blitRgb48leRegion", () => {
|
|
633
|
+
it("copies a region at position (0,0) — full overlap", () => {
|
|
634
|
+
const canvas = Buffer.alloc(4 * 4 * 6); // 4x4 black
|
|
635
|
+
const source = makeHdrFrame(2, 2, 10000, 20000, 30000);
|
|
636
|
+
blitRgb48leRegion(canvas, source, 0, 0, 2, 2, 4, 4);
|
|
637
|
+
expect(canvas.readUInt16LE(0)).toBe(10000);
|
|
638
|
+
expect(canvas.readUInt16LE(2)).toBe(20000);
|
|
639
|
+
expect(canvas.readUInt16LE(4)).toBe(30000);
|
|
640
|
+
expect(canvas.readUInt16LE(2 * 6)).toBe(0);
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
it("copies a region at offset position", () => {
|
|
644
|
+
const canvas = Buffer.alloc(4 * 4 * 6);
|
|
645
|
+
const source = makeHdrFrame(2, 2, 50000, 40000, 30000);
|
|
646
|
+
blitRgb48leRegion(canvas, source, 1, 1, 2, 2, 4, 4);
|
|
647
|
+
expect(canvas.readUInt16LE(0)).toBe(0);
|
|
648
|
+
const off = (1 * 4 + 1) * 6;
|
|
649
|
+
expect(canvas.readUInt16LE(off)).toBe(50000);
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it("clips when region extends beyond canvas edge", () => {
|
|
653
|
+
const canvas = Buffer.alloc(4 * 4 * 6);
|
|
654
|
+
const source = makeHdrFrame(3, 3, 10000, 20000, 30000);
|
|
655
|
+
blitRgb48leRegion(canvas, source, 2, 2, 3, 3, 4, 4);
|
|
656
|
+
const off = (2 * 4 + 2) * 6;
|
|
657
|
+
expect(canvas.readUInt16LE(off)).toBe(10000);
|
|
658
|
+
const off2 = (3 * 4 + 3) * 6;
|
|
659
|
+
expect(canvas.readUInt16LE(off2)).toBe(10000);
|
|
660
|
+
expect(canvas.length).toBe(4 * 4 * 6);
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
it("applies opacity when provided", () => {
|
|
664
|
+
const canvas = Buffer.alloc(1 * 1 * 6);
|
|
665
|
+
const source = makeHdrFrame(1, 1, 40000, 40000, 40000);
|
|
666
|
+
blitRgb48leRegion(canvas, source, 0, 0, 1, 1, 1, 1, 0.5);
|
|
667
|
+
expect(canvas.readUInt16LE(0)).toBe(20000);
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
it("no-op for zero-size region", () => {
|
|
671
|
+
const canvas = Buffer.alloc(4 * 4 * 6);
|
|
672
|
+
const source = makeHdrFrame(2, 2, 10000, 20000, 30000);
|
|
673
|
+
blitRgb48leRegion(canvas, source, 0, 0, 0, 0, 4, 4);
|
|
674
|
+
expect(canvas.readUInt16LE(0)).toBe(0);
|
|
675
|
+
});
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
// ── parseTransformMatrix tests ───────────────────────────────────────────────
|
|
679
|
+
|
|
680
|
+
describe("parseTransformMatrix", () => {
|
|
681
|
+
it("returns null for 'none'", () => {
|
|
682
|
+
expect(parseTransformMatrix("none")).toBeNull();
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
it("parses identity matrix", () => {
|
|
686
|
+
const m = parseTransformMatrix("matrix(1, 0, 0, 1, 0, 0)");
|
|
687
|
+
expect(m).toEqual([1, 0, 0, 1, 0, 0]);
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
it("parses scale + translate", () => {
|
|
691
|
+
const m = parseTransformMatrix("matrix(0.85, 0, 0, 0.85, 100, 50)");
|
|
692
|
+
expect(m).toEqual([0.85, 0, 0, 0.85, 100, 50]);
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
it("parses rotation (45 degrees)", () => {
|
|
696
|
+
const cos = Math.cos(Math.PI / 4);
|
|
697
|
+
const sin = Math.sin(Math.PI / 4);
|
|
698
|
+
const m = parseTransformMatrix(`matrix(${cos}, ${sin}, ${-sin}, ${cos}, 0, 0)`);
|
|
699
|
+
expect(m).not.toBeNull();
|
|
700
|
+
if (!m) return;
|
|
701
|
+
expect(m[0]).toBeCloseTo(cos, 10);
|
|
702
|
+
expect(m[1]).toBeCloseTo(sin, 10);
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
it("parses negative values", () => {
|
|
706
|
+
const m = parseTransformMatrix("matrix(-1, 0, 0, -1, -50, -100)");
|
|
707
|
+
expect(m).toEqual([-1, 0, 0, -1, -50, -100]);
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
it("returns null for empty string", () => {
|
|
711
|
+
expect(parseTransformMatrix("")).toBeNull();
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
it("returns null for unsupported 3d matrix", () => {
|
|
715
|
+
expect(parseTransformMatrix("matrix3d(1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1)")).toBeNull();
|
|
716
|
+
});
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
// ── blitRgb48leAffine tests ─────────────────────────────────────────────────
|
|
720
|
+
|
|
721
|
+
describe("blitRgb48leAffine", () => {
|
|
722
|
+
it("identity matrix produces same result as blitRgb48leRegion", () => {
|
|
723
|
+
const canvas1 = Buffer.alloc(4 * 4 * 6);
|
|
724
|
+
const canvas2 = Buffer.alloc(4 * 4 * 6);
|
|
725
|
+
const source = makeHdrFrame(2, 2, 10000, 20000, 30000);
|
|
726
|
+
const identity = [1, 0, 0, 1, 0, 0];
|
|
727
|
+
|
|
728
|
+
blitRgb48leRegion(canvas1, source, 0, 0, 2, 2, 4, 4);
|
|
729
|
+
blitRgb48leAffine(canvas2, source, identity, 2, 2, 4, 4);
|
|
730
|
+
|
|
731
|
+
expect(Buffer.compare(canvas1, canvas2)).toBe(0);
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
it("translation moves pixels", () => {
|
|
735
|
+
const canvas = Buffer.alloc(4 * 4 * 6);
|
|
736
|
+
const source = makeHdrFrame(1, 1, 50000, 40000, 30000);
|
|
737
|
+
const translate = [1, 0, 0, 1, 2, 1];
|
|
738
|
+
blitRgb48leAffine(canvas, source, translate, 1, 1, 4, 4);
|
|
739
|
+
|
|
740
|
+
expect(canvas.readUInt16LE(0)).toBe(0);
|
|
741
|
+
const off = (1 * 4 + 2) * 6;
|
|
742
|
+
expect(canvas.readUInt16LE(off)).toBe(50000);
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
it("scale down by 0.5 shrinks the output", () => {
|
|
746
|
+
const canvas = Buffer.alloc(4 * 4 * 6);
|
|
747
|
+
const source = makeHdrFrame(4, 4, 40000, 30000, 20000);
|
|
748
|
+
const scale = [0.5, 0, 0, 0.5, 0, 0];
|
|
749
|
+
blitRgb48leAffine(canvas, source, scale, 4, 4, 4, 4);
|
|
750
|
+
|
|
751
|
+
expect(canvas.readUInt16LE(0)).toBeGreaterThan(0);
|
|
752
|
+
expect(canvas.readUInt16LE((1 * 4 + 1) * 6)).toBeGreaterThan(0);
|
|
753
|
+
expect(canvas.readUInt16LE(2 * 6)).toBe(0);
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
it("scale up by 2 enlarges the output", () => {
|
|
757
|
+
const canvas = Buffer.alloc(4 * 4 * 6);
|
|
758
|
+
const source = makeHdrFrame(2, 2, 40000, 30000, 20000);
|
|
759
|
+
const scale = [2, 0, 0, 2, 0, 0];
|
|
760
|
+
blitRgb48leAffine(canvas, source, scale, 2, 2, 4, 4);
|
|
761
|
+
|
|
762
|
+
for (let i = 0; i < 16; i++) {
|
|
763
|
+
expect(canvas.readUInt16LE(i * 6)).toBeGreaterThan(0);
|
|
764
|
+
}
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
it("opacity blends with canvas", () => {
|
|
768
|
+
const canvas = makeHdrFrame(1, 1, 20000, 20000, 20000);
|
|
769
|
+
const source = makeHdrFrame(1, 1, 60000, 60000, 60000);
|
|
770
|
+
const identity = [1, 0, 0, 1, 0, 0];
|
|
771
|
+
blitRgb48leAffine(canvas, source, identity, 1, 1, 1, 1, 0.5);
|
|
772
|
+
|
|
773
|
+
expect(canvas.readUInt16LE(0)).toBe(40000);
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
it("out-of-bounds source coordinates are clipped", () => {
|
|
777
|
+
const canvas = Buffer.alloc(2 * 2 * 6);
|
|
778
|
+
const source = makeHdrFrame(1, 1, 50000, 40000, 30000);
|
|
779
|
+
const translate = [1, 0, 0, 1, 10, 10];
|
|
780
|
+
blitRgb48leAffine(canvas, source, translate, 1, 1, 2, 2);
|
|
781
|
+
|
|
782
|
+
expect(canvas.readUInt16LE(0)).toBe(0);
|
|
783
|
+
expect(canvas.readUInt16LE(6)).toBe(0);
|
|
784
|
+
});
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
// ── Round-trip test: decodePng → blitRgba8OverRgb48le ────────────────────────
|
|
788
|
+
|
|
789
|
+
describe("decodePng + blitRgba8OverRgb48le integration", () => {
|
|
790
|
+
it("transparent PNG overlay leaves canvas untouched", () => {
|
|
791
|
+
const width = 2;
|
|
792
|
+
const height = 2;
|
|
793
|
+
|
|
794
|
+
// Build a fully transparent PNG
|
|
795
|
+
const pixels = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; // all alpha=0
|
|
796
|
+
const png = makePng(width, height, pixels);
|
|
797
|
+
const { data: domRgba } = decodePng(png);
|
|
798
|
+
|
|
799
|
+
// Canvas pre-filled with known HDR values
|
|
800
|
+
const canvas = makeHdrFrame(width, height, 10000, 20000, 30000);
|
|
801
|
+
blitRgba8OverRgb48le(domRgba, canvas, width, height);
|
|
802
|
+
|
|
803
|
+
// All pixels should be unchanged
|
|
804
|
+
for (let i = 0; i < width * height; i++) {
|
|
805
|
+
expect(canvas.readUInt16LE(i * 6 + 0)).toBe(10000);
|
|
806
|
+
expect(canvas.readUInt16LE(i * 6 + 2)).toBe(20000);
|
|
807
|
+
expect(canvas.readUInt16LE(i * 6 + 4)).toBe(30000);
|
|
808
|
+
}
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
it("fully opaque PNG overlay overwrites all canvas pixels (sRGB→HLG)", () => {
|
|
812
|
+
const width = 2;
|
|
813
|
+
const height = 2;
|
|
814
|
+
|
|
815
|
+
// Build a fully opaque blue PNG (sRGB blue = 0,0,255)
|
|
816
|
+
const pixels = Array(width * height)
|
|
817
|
+
.fill(null)
|
|
818
|
+
.flatMap(() => [0, 0, 255, 255]);
|
|
819
|
+
const png = makePng(width, height, pixels);
|
|
820
|
+
const { data: domRgba } = decodePng(png);
|
|
821
|
+
|
|
822
|
+
const canvas = makeHdrFrame(width, height, 50000, 40000, 30000);
|
|
823
|
+
blitRgba8OverRgb48le(domRgba, canvas, width, height);
|
|
824
|
+
|
|
825
|
+
// sRGB blue (0,0,255) → HLG (0, 0, 65535) — black/white map identically
|
|
826
|
+
for (let i = 0; i < width * height; i++) {
|
|
827
|
+
expect(canvas.readUInt16LE(i * 6 + 0)).toBe(0);
|
|
828
|
+
expect(canvas.readUInt16LE(i * 6 + 2)).toBe(0);
|
|
829
|
+
expect(canvas.readUInt16LE(i * 6 + 4)).toBe(65535);
|
|
830
|
+
}
|
|
831
|
+
});
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
// ── roundedRectAlpha tests ──────────────────────────────────────────────────
|
|
835
|
+
|
|
836
|
+
describe("roundedRectAlpha", () => {
|
|
837
|
+
const uniform20: [number, number, number, number] = [20, 20, 20, 20];
|
|
838
|
+
|
|
839
|
+
it("returns 1 for center pixel", () => {
|
|
840
|
+
expect(roundedRectAlpha(50, 50, 100, 100, uniform20)).toBe(1);
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
it("returns 1 for pixel well inside edge (not in corner zone)", () => {
|
|
844
|
+
// On top edge but past the corner zone (x >= radius)
|
|
845
|
+
expect(roundedRectAlpha(50, 5, 100, 100, uniform20)).toBe(1);
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
it("returns 0 for pixel at the extreme corner (outside rounded area)", () => {
|
|
849
|
+
// Top-left corner: (0, 0) is far from circle center at (20, 20)
|
|
850
|
+
// dist = sqrt(400 + 400) = 28.28, well beyond radius 20
|
|
851
|
+
expect(roundedRectAlpha(0, 0, 100, 100, uniform20)).toBe(0);
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
it("returns 1 for pixel well inside corner circle", () => {
|
|
855
|
+
// Pixel at (15, 15): dist from center (20, 20) = sqrt(25+25) = 7.07 << 20
|
|
856
|
+
expect(roundedRectAlpha(15, 15, 100, 100, uniform20)).toBe(1);
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
it("returns fractional alpha at corner edge (anti-aliasing)", () => {
|
|
860
|
+
// Find a point near the circle edge. radius = 20, center at (20, 20).
|
|
861
|
+
// Point on the circle: (20 - 20*cos(45°), 20 - 20*sin(45°)) ≈ (5.86, 5.86)
|
|
862
|
+
// Shift slightly inward for fractional alpha
|
|
863
|
+
const edgePx = 20 - 20 * Math.cos(Math.PI / 4); // ~5.86
|
|
864
|
+
const alpha = roundedRectAlpha(edgePx, edgePx, 100, 100, uniform20);
|
|
865
|
+
expect(alpha).toBeGreaterThan(0);
|
|
866
|
+
expect(alpha).toBeLessThan(1);
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
it("handles all four corners symmetrically", () => {
|
|
870
|
+
// Test top-right corner (x near w, y near 0)
|
|
871
|
+
expect(roundedRectAlpha(100, 0, 100, 100, uniform20)).toBe(0);
|
|
872
|
+
// Test bottom-right corner
|
|
873
|
+
expect(roundedRectAlpha(100, 100, 100, 100, uniform20)).toBe(0);
|
|
874
|
+
// Test bottom-left corner
|
|
875
|
+
expect(roundedRectAlpha(0, 100, 100, 100, uniform20)).toBe(0);
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
it("returns 1 everywhere for zero radii", () => {
|
|
879
|
+
const zero: [number, number, number, number] = [0, 0, 0, 0];
|
|
880
|
+
expect(roundedRectAlpha(0, 0, 100, 100, zero)).toBe(1);
|
|
881
|
+
expect(roundedRectAlpha(99, 0, 100, 100, zero)).toBe(1);
|
|
882
|
+
expect(roundedRectAlpha(0, 99, 100, 100, zero)).toBe(1);
|
|
883
|
+
expect(roundedRectAlpha(99, 99, 100, 100, zero)).toBe(1);
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
it("supports per-corner radii", () => {
|
|
887
|
+
const mixed: [number, number, number, number] = [20, 0, 10, 0];
|
|
888
|
+
// Top-left has radius 20 — corner pixel outside
|
|
889
|
+
expect(roundedRectAlpha(0, 0, 100, 100, mixed)).toBe(0);
|
|
890
|
+
// Top-right has radius 0 — corner pixel inside
|
|
891
|
+
expect(roundedRectAlpha(99, 0, 100, 100, mixed)).toBe(1);
|
|
892
|
+
// Bottom-right has radius 10 — extreme corner outside
|
|
893
|
+
expect(roundedRectAlpha(100, 100, 100, 100, mixed)).toBe(0);
|
|
894
|
+
// Bottom-left has radius 0 — corner pixel inside
|
|
895
|
+
expect(roundedRectAlpha(0, 99, 100, 100, mixed)).toBe(1);
|
|
896
|
+
});
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
// ── blitRgb48leRegion with borderRadius ─────────────────────────────────────
|
|
900
|
+
|
|
901
|
+
describe("blitRgb48leRegion with borderRadius", () => {
|
|
902
|
+
it("clips corner pixels when borderRadius is set", () => {
|
|
903
|
+
// 10x10 source placed at origin on a 10x10 canvas, radius 5
|
|
904
|
+
const canvas = Buffer.alloc(10 * 10 * 6);
|
|
905
|
+
const source = makeHdrFrame(10, 10, 40000, 30000, 20000);
|
|
906
|
+
const br: [number, number, number, number] = [5, 5, 5, 5];
|
|
907
|
+
blitRgb48leRegion(canvas, source, 0, 0, 10, 10, 10, 10, undefined, br);
|
|
908
|
+
|
|
909
|
+
// Center pixel should be written
|
|
910
|
+
const centerOff = (5 * 10 + 5) * 6;
|
|
911
|
+
expect(canvas.readUInt16LE(centerOff)).toBe(40000);
|
|
912
|
+
|
|
913
|
+
// Corner pixel (0,0) should be clipped (remain 0)
|
|
914
|
+
expect(canvas.readUInt16LE(0)).toBe(0);
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
it("no effect when borderRadius is all zeros", () => {
|
|
918
|
+
const canvas1 = Buffer.alloc(4 * 4 * 6);
|
|
919
|
+
const canvas2 = Buffer.alloc(4 * 4 * 6);
|
|
920
|
+
const source = makeHdrFrame(4, 4, 40000, 30000, 20000);
|
|
921
|
+
|
|
922
|
+
blitRgb48leRegion(canvas1, source, 0, 0, 4, 4, 4, 4);
|
|
923
|
+
blitRgb48leRegion(canvas2, source, 0, 0, 4, 4, 4, 4, undefined, [0, 0, 0, 0]);
|
|
924
|
+
|
|
925
|
+
expect(Buffer.compare(canvas1, canvas2)).toBe(0);
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
it("combines opacity and borderRadius", () => {
|
|
929
|
+
// Canvas with known background, source with known values
|
|
930
|
+
const canvas = makeHdrFrame(10, 10, 20000, 20000, 20000);
|
|
931
|
+
const source = makeHdrFrame(10, 10, 60000, 60000, 60000);
|
|
932
|
+
const br: [number, number, number, number] = [3, 3, 3, 3];
|
|
933
|
+
|
|
934
|
+
blitRgb48leRegion(canvas, source, 0, 0, 10, 10, 10, 10, 0.5, br);
|
|
935
|
+
|
|
936
|
+
// Center pixel: opacity 0.5, mask 1.0 → effective 0.5
|
|
937
|
+
// Result: 60000 * 0.5 + 20000 * 0.5 = 40000
|
|
938
|
+
const centerOff = (5 * 10 + 5) * 6;
|
|
939
|
+
expect(canvas.readUInt16LE(centerOff)).toBe(40000);
|
|
940
|
+
|
|
941
|
+
// Corner pixel (0,0): mask 0.0 → skipped, canvas unchanged
|
|
942
|
+
expect(canvas.readUInt16LE(0)).toBe(20000);
|
|
943
|
+
});
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
// ── blitRgb48leAffine with borderRadius ─────────────────────────────────────
|
|
947
|
+
|
|
948
|
+
describe("blitRgb48leAffine with borderRadius", () => {
|
|
949
|
+
it("clips corner pixels with identity transform", () => {
|
|
950
|
+
const canvas = Buffer.alloc(10 * 10 * 6);
|
|
951
|
+
const source = makeHdrFrame(10, 10, 40000, 30000, 20000);
|
|
952
|
+
const identity = [1, 0, 0, 1, 0, 0];
|
|
953
|
+
const br: [number, number, number, number] = [5, 5, 5, 5];
|
|
954
|
+
|
|
955
|
+
blitRgb48leAffine(canvas, source, identity, 10, 10, 10, 10, undefined, br);
|
|
956
|
+
|
|
957
|
+
// Center pixel should be written
|
|
958
|
+
const centerOff = (5 * 10 + 5) * 6;
|
|
959
|
+
expect(canvas.readUInt16LE(centerOff)).toBe(40000);
|
|
960
|
+
|
|
961
|
+
// Corner pixel (0,0) should be clipped
|
|
962
|
+
expect(canvas.readUInt16LE(0)).toBe(0);
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
it("mask follows transform (scaled output has rounded corners)", () => {
|
|
966
|
+
// 4x4 source scaled up 2× on an 8×8 canvas, radius 2 in source space
|
|
967
|
+
const canvas = Buffer.alloc(8 * 8 * 6);
|
|
968
|
+
const source = makeHdrFrame(4, 4, 50000, 40000, 30000);
|
|
969
|
+
const scale2x = [2, 0, 0, 2, 0, 0];
|
|
970
|
+
const br: [number, number, number, number] = [2, 2, 2, 2];
|
|
971
|
+
|
|
972
|
+
blitRgb48leAffine(canvas, source, scale2x, 4, 4, 8, 8, undefined, br);
|
|
973
|
+
|
|
974
|
+
// Canvas center (4,4) maps to source (2,2) — inside, should be written
|
|
975
|
+
const centerOff = (4 * 8 + 4) * 6;
|
|
976
|
+
expect(canvas.readUInt16LE(centerOff)).toBeGreaterThan(0);
|
|
977
|
+
|
|
978
|
+
// Canvas corner (0,0) maps to source (0,0) — outside radius, should be clipped
|
|
979
|
+
expect(canvas.readUInt16LE(0)).toBe(0);
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
it("no effect when borderRadius is undefined", () => {
|
|
983
|
+
const canvas1 = Buffer.alloc(4 * 4 * 6);
|
|
984
|
+
const canvas2 = Buffer.alloc(4 * 4 * 6);
|
|
985
|
+
const source = makeHdrFrame(4, 4, 40000, 30000, 20000);
|
|
986
|
+
const identity = [1, 0, 0, 1, 0, 0];
|
|
987
|
+
|
|
988
|
+
blitRgb48leAffine(canvas1, source, identity, 4, 4, 4, 4);
|
|
989
|
+
blitRgb48leAffine(canvas2, source, identity, 4, 4, 4, 4, undefined, undefined);
|
|
990
|
+
|
|
991
|
+
expect(Buffer.compare(canvas1, canvas2)).toBe(0);
|
|
992
|
+
});
|
|
993
|
+
});
|