@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.
Files changed (74) hide show
  1. package/dist/config.d.ts +6 -0
  2. package/dist/config.d.ts.map +1 -1
  3. package/dist/config.js +9 -0
  4. package/dist/config.js.map +1 -1
  5. package/dist/index.d.ts +8 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +7 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/services/browserManager.d.ts.map +1 -1
  10. package/dist/services/browserManager.js +6 -3
  11. package/dist/services/browserManager.js.map +1 -1
  12. package/dist/services/chunkEncoder.d.ts +14 -7
  13. package/dist/services/chunkEncoder.d.ts.map +1 -1
  14. package/dist/services/chunkEncoder.js +25 -9
  15. package/dist/services/chunkEncoder.js.map +1 -1
  16. package/dist/services/chunkEncoder.types.d.ts +4 -0
  17. package/dist/services/chunkEncoder.types.d.ts.map +1 -1
  18. package/dist/services/hdrCapture.d.ts +62 -0
  19. package/dist/services/hdrCapture.d.ts.map +1 -0
  20. package/dist/services/hdrCapture.js +259 -0
  21. package/dist/services/hdrCapture.js.map +1 -0
  22. package/dist/services/screenshotService.d.ts +34 -0
  23. package/dist/services/screenshotService.d.ts.map +1 -1
  24. package/dist/services/screenshotService.js +97 -19
  25. package/dist/services/screenshotService.js.map +1 -1
  26. package/dist/services/streamingEncoder.d.ts +18 -3
  27. package/dist/services/streamingEncoder.d.ts.map +1 -1
  28. package/dist/services/streamingEncoder.js +104 -50
  29. package/dist/services/streamingEncoder.js.map +1 -1
  30. package/dist/services/videoFrameExtractor.d.ts.map +1 -1
  31. package/dist/services/videoFrameExtractor.js +109 -18
  32. package/dist/services/videoFrameExtractor.js.map +1 -1
  33. package/dist/services/videoFrameInjector.d.ts +54 -0
  34. package/dist/services/videoFrameInjector.d.ts.map +1 -1
  35. package/dist/services/videoFrameInjector.js +159 -0
  36. package/dist/services/videoFrameInjector.js.map +1 -1
  37. package/dist/utils/alphaBlit.d.ts +105 -0
  38. package/dist/utils/alphaBlit.d.ts.map +1 -0
  39. package/dist/utils/alphaBlit.js +550 -0
  40. package/dist/utils/alphaBlit.js.map +1 -0
  41. package/dist/utils/ffprobe.d.ts +10 -0
  42. package/dist/utils/ffprobe.d.ts.map +1 -1
  43. package/dist/utils/ffprobe.js +7 -0
  44. package/dist/utils/ffprobe.js.map +1 -1
  45. package/dist/utils/hdr.d.ts +82 -0
  46. package/dist/utils/hdr.d.ts.map +1 -0
  47. package/dist/utils/hdr.js +87 -0
  48. package/dist/utils/hdr.js.map +1 -0
  49. package/dist/utils/layerCompositor.d.ts +36 -0
  50. package/dist/utils/layerCompositor.d.ts.map +1 -0
  51. package/dist/utils/layerCompositor.js +49 -0
  52. package/dist/utils/layerCompositor.js.map +1 -0
  53. package/package.json +3 -2
  54. package/src/config.ts +16 -0
  55. package/src/index.ts +42 -0
  56. package/src/services/browserManager.ts +6 -3
  57. package/src/services/chunkEncoder.test.ts +88 -0
  58. package/src/services/chunkEncoder.ts +35 -9
  59. package/src/services/chunkEncoder.types.ts +3 -0
  60. package/src/services/hdrCapture.test.ts +159 -0
  61. package/src/services/hdrCapture.ts +354 -0
  62. package/src/services/screenshotService.ts +102 -17
  63. package/src/services/streamingEncoder.test.ts +228 -0
  64. package/src/services/streamingEncoder.ts +153 -63
  65. package/src/services/videoFrameExtractor.ts +135 -31
  66. package/src/services/videoFrameInjector.ts +200 -0
  67. package/src/utils/alphaBlit.test.ts +993 -0
  68. package/src/utils/alphaBlit.ts +643 -0
  69. package/src/utils/ffprobe.ts +22 -0
  70. package/src/utils/hdr.test.ts +191 -0
  71. package/src/utils/hdr.ts +137 -0
  72. package/src/utils/layerCompositor.test.ts +141 -0
  73. package/src/utils/layerCompositor.ts +58 -0
  74. 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
+ });