@hyperframes/engine 0.4.6 → 0.4.8

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 (84) 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 +11 -2
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +10 -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 +115 -0
  23. package/dist/services/screenshotService.d.ts.map +1 -1
  24. package/dist/services/screenshotService.js +252 -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 +59 -0
  34. package/dist/services/videoFrameInjector.d.ts.map +1 -1
  35. package/dist/services/videoFrameInjector.js +290 -0
  36. package/dist/services/videoFrameInjector.js.map +1 -1
  37. package/dist/types.d.ts +32 -0
  38. package/dist/types.d.ts.map +1 -1
  39. package/dist/utils/alphaBlit.d.ts +105 -0
  40. package/dist/utils/alphaBlit.d.ts.map +1 -0
  41. package/dist/utils/alphaBlit.js +550 -0
  42. package/dist/utils/alphaBlit.js.map +1 -0
  43. package/dist/utils/ffprobe.d.ts +10 -0
  44. package/dist/utils/ffprobe.d.ts.map +1 -1
  45. package/dist/utils/ffprobe.js +7 -0
  46. package/dist/utils/ffprobe.js.map +1 -1
  47. package/dist/utils/hdr.d.ts +82 -0
  48. package/dist/utils/hdr.d.ts.map +1 -0
  49. package/dist/utils/hdr.js +87 -0
  50. package/dist/utils/hdr.js.map +1 -0
  51. package/dist/utils/layerCompositor.d.ts +36 -0
  52. package/dist/utils/layerCompositor.d.ts.map +1 -0
  53. package/dist/utils/layerCompositor.js +49 -0
  54. package/dist/utils/layerCompositor.js.map +1 -0
  55. package/dist/utils/shaderTransitions.d.ts +166 -0
  56. package/dist/utils/shaderTransitions.d.ts.map +1 -0
  57. package/dist/utils/shaderTransitions.js +894 -0
  58. package/dist/utils/shaderTransitions.js.map +1 -0
  59. package/package.json +3 -2
  60. package/src/config.ts +16 -0
  61. package/src/index.ts +61 -0
  62. package/src/services/browserManager.ts +6 -3
  63. package/src/services/chunkEncoder.test.ts +88 -0
  64. package/src/services/chunkEncoder.ts +35 -9
  65. package/src/services/chunkEncoder.types.ts +3 -0
  66. package/src/services/hdrCapture.test.ts +159 -0
  67. package/src/services/hdrCapture.ts +354 -0
  68. package/src/services/screenshotService.ts +271 -17
  69. package/src/services/streamingEncoder.test.ts +228 -0
  70. package/src/services/streamingEncoder.ts +153 -63
  71. package/src/services/videoFrameExtractor.ts +135 -31
  72. package/src/services/videoFrameInjector.ts +342 -0
  73. package/src/types.ts +34 -0
  74. package/src/utils/alphaBlit.test.ts +993 -0
  75. package/src/utils/alphaBlit.ts +643 -0
  76. package/src/utils/ffprobe.ts +22 -0
  77. package/src/utils/hdr.test.ts +191 -0
  78. package/src/utils/hdr.ts +137 -0
  79. package/src/utils/layerCompositor.test.ts +141 -0
  80. package/src/utils/layerCompositor.ts +58 -0
  81. package/src/utils/shaderTransitions.test.ts +674 -0
  82. package/src/utils/shaderTransitions.ts +1130 -0
  83. package/src/utils/uint16-alignment-audit.test.ts +125 -0
  84. package/tsconfig.json +2 -1
@@ -0,0 +1,674 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ sampleRgb48le,
4
+ mix16,
5
+ clamp16,
6
+ smoothstep,
7
+ hash,
8
+ vnoise,
9
+ fbm,
10
+ crossfade,
11
+ flashThroughWhite,
12
+ hdrToLinear,
13
+ linearToHdr,
14
+ convertTransfer,
15
+ TRANSITIONS,
16
+ type TransitionFn,
17
+ } from "./shaderTransitions.js";
18
+
19
+ // ── sampleRgb48le ─────────────────────────────────────────────────────────────
20
+
21
+ describe("sampleRgb48le", () => {
22
+ it("samples center pixel of a uniform 1x1 buffer", () => {
23
+ const buf = Buffer.alloc(6);
24
+ buf.writeUInt16LE(10000, 0); // R
25
+ buf.writeUInt16LE(20000, 2); // G
26
+ buf.writeUInt16LE(30000, 4); // B
27
+ const [r, g, b] = sampleRgb48le(buf, 0.5, 0.5, 1, 1);
28
+ expect(r).toBe(10000);
29
+ expect(g).toBe(20000);
30
+ expect(b).toBe(30000);
31
+ });
32
+
33
+ it("clamps out-of-bounds UV below 0 to first pixel", () => {
34
+ const buf = Buffer.alloc(6);
35
+ buf.writeUInt16LE(5000, 0);
36
+ buf.writeUInt16LE(6000, 2);
37
+ buf.writeUInt16LE(7000, 4);
38
+ const [r, g, b] = sampleRgb48le(buf, -0.5, -0.5, 1, 1);
39
+ expect(r).toBe(5000);
40
+ expect(g).toBe(6000);
41
+ expect(b).toBe(7000);
42
+ });
43
+
44
+ it("clamps out-of-bounds UV above 1 to last pixel", () => {
45
+ const buf = Buffer.alloc(6);
46
+ buf.writeUInt16LE(5000, 0);
47
+ buf.writeUInt16LE(6000, 2);
48
+ buf.writeUInt16LE(7000, 4);
49
+ const [r, g, b] = sampleRgb48le(buf, 1.5, 1.5, 1, 1);
50
+ expect(r).toBe(5000);
51
+ expect(g).toBe(6000);
52
+ expect(b).toBe(7000);
53
+ });
54
+
55
+ it("bilinearly interpolates between two horizontally adjacent pixels", () => {
56
+ // 2x1 buffer: pixel 0 = (0,0,0), pixel 1 = (65534,65534,65534)
57
+ const buf = Buffer.alloc(12);
58
+ buf.writeUInt16LE(0, 0);
59
+ buf.writeUInt16LE(0, 2);
60
+ buf.writeUInt16LE(0, 4);
61
+ buf.writeUInt16LE(65534, 6);
62
+ buf.writeUInt16LE(65534, 8);
63
+ buf.writeUInt16LE(65534, 10);
64
+ // u=0.5, w=2 → x = 0.5*(2-1) = 0.5 → equal blend of pixels 0 and 1
65
+ const [r, g, b] = sampleRgb48le(buf, 0.5, 0, 2, 1);
66
+ expect(r).toBe(32767);
67
+ expect(g).toBe(32767);
68
+ expect(b).toBe(32767);
69
+ });
70
+
71
+ it("samples from exact pixel 0 at u=0", () => {
72
+ const buf = Buffer.alloc(12);
73
+ buf.writeUInt16LE(1000, 0);
74
+ buf.writeUInt16LE(2000, 2);
75
+ buf.writeUInt16LE(3000, 4);
76
+ buf.writeUInt16LE(60000, 6);
77
+ buf.writeUInt16LE(60000, 8);
78
+ buf.writeUInt16LE(60000, 10);
79
+ const [r, g, b] = sampleRgb48le(buf, 0, 0, 2, 1);
80
+ expect(r).toBe(1000);
81
+ expect(g).toBe(2000);
82
+ expect(b).toBe(3000);
83
+ });
84
+
85
+ // ── Wider coverage for the perf-migration follow-up ────────────────────────
86
+ // These pin down sub-pixel sampling semantics so a future Uint16Array
87
+ // implementation can swap in and verify byte-equivalent output.
88
+
89
+ it("bilinearly interpolates between two vertically adjacent pixels", () => {
90
+ // 1x2 buffer: row 0 = (0,0,0), row 1 = (40000,40000,40000)
91
+ const buf = Buffer.alloc(12);
92
+ buf.writeUInt16LE(0, 0);
93
+ buf.writeUInt16LE(0, 2);
94
+ buf.writeUInt16LE(0, 4);
95
+ buf.writeUInt16LE(40000, 6);
96
+ buf.writeUInt16LE(40000, 8);
97
+ buf.writeUInt16LE(40000, 10);
98
+ const [r, g, b] = sampleRgb48le(buf, 0, 0.5, 1, 2);
99
+ expect(r).toBe(20000);
100
+ expect(g).toBe(20000);
101
+ expect(b).toBe(20000);
102
+ });
103
+
104
+ it("bilinearly interpolates the centroid of a 2x2 block", () => {
105
+ // Layout (R channel only, others mirror):
106
+ // (1000) (5000)
107
+ // (3000) (7000)
108
+ // Centroid (u=v=0.5) → average of all four = 4000
109
+ const buf = Buffer.alloc(24);
110
+ const corners = [1000, 5000, 3000, 7000];
111
+ for (let i = 0; i < 4; i++) {
112
+ const off = i * 6;
113
+ buf.writeUInt16LE(corners[i] ?? 0, off);
114
+ buf.writeUInt16LE(corners[i] ?? 0, off + 2);
115
+ buf.writeUInt16LE(corners[i] ?? 0, off + 4);
116
+ }
117
+ const [r, g, b] = sampleRgb48le(buf, 0.5, 0.5, 2, 2);
118
+ expect(r).toBe(4000);
119
+ expect(g).toBe(4000);
120
+ expect(b).toBe(4000);
121
+ });
122
+
123
+ it("does not bleed channels — R, G, B sampled independently", () => {
124
+ // 2x1 buffer with distinct per-channel gradients.
125
+ // pixel 0: R=1000 G=20000 B=50000
126
+ // pixel 1: R=9000 G=30000 B=60000
127
+ const buf = Buffer.alloc(12);
128
+ buf.writeUInt16LE(1000, 0);
129
+ buf.writeUInt16LE(20000, 2);
130
+ buf.writeUInt16LE(50000, 4);
131
+ buf.writeUInt16LE(9000, 6);
132
+ buf.writeUInt16LE(30000, 8);
133
+ buf.writeUInt16LE(60000, 10);
134
+ const [r, g, b] = sampleRgb48le(buf, 0.5, 0, 2, 1);
135
+ expect(r).toBe(5000);
136
+ expect(g).toBe(25000);
137
+ expect(b).toBe(55000);
138
+ });
139
+
140
+ it("samples last pixel exactly when u=v=1 (no overflow past the edge)", () => {
141
+ // 2x2 buffer where (1,1) corner has a unique value the first three pixels
142
+ // do not. If sampleRgb48le tried to read off-edge, the result would mix in
143
+ // out-of-bounds garbage.
144
+ const buf = Buffer.alloc(24);
145
+ const fill = [10, 20, 30, 65000];
146
+ for (let i = 0; i < 4; i++) {
147
+ const off = i * 6;
148
+ buf.writeUInt16LE(fill[i] ?? 0, off);
149
+ buf.writeUInt16LE(fill[i] ?? 0, off + 2);
150
+ buf.writeUInt16LE(fill[i] ?? 0, off + 4);
151
+ }
152
+ const [r, g, b] = sampleRgb48le(buf, 1, 1, 2, 2);
153
+ expect(r).toBe(65000);
154
+ expect(g).toBe(65000);
155
+ expect(b).toBe(65000);
156
+ });
157
+
158
+ it("respects asymmetric off-center UV weights", () => {
159
+ // 2x1 buffer, R-only differentiation: pixel 0 = 0, pixel 1 = 10000
160
+ // u=0.25 → x = 0.25 * (2 - 1) = 0.25
161
+ // weight on pixel 0 = 0.75, weight on pixel 1 = 0.25
162
+ // expected R = round(0 * 0.75 + 10000 * 0.25) = 2500
163
+ const buf = Buffer.alloc(12);
164
+ buf.writeUInt16LE(0, 0);
165
+ buf.writeUInt16LE(0, 2);
166
+ buf.writeUInt16LE(0, 4);
167
+ buf.writeUInt16LE(10000, 6);
168
+ buf.writeUInt16LE(10000, 8);
169
+ buf.writeUInt16LE(10000, 10);
170
+ const [r, g, b] = sampleRgb48le(buf, 0.25, 0, 2, 1);
171
+ expect(r).toBe(2500);
172
+ expect(g).toBe(2500);
173
+ expect(b).toBe(2500);
174
+ });
175
+
176
+ it("preserves max 16-bit values without clipping or rollover", () => {
177
+ // Verify the 65535 ceiling round-trips through bilinear weights without
178
+ // losing precision. A naïve 32-bit accumulator would still be fine here,
179
+ // but a future packed-Uint16 implementation must be checked for overflow
180
+ // in intermediate sums.
181
+ const buf = Buffer.alloc(24);
182
+ for (let i = 0; i < 4; i++) {
183
+ const off = i * 6;
184
+ buf.writeUInt16LE(65535, off);
185
+ buf.writeUInt16LE(65535, off + 2);
186
+ buf.writeUInt16LE(65535, off + 4);
187
+ }
188
+ const [r, g, b] = sampleRgb48le(buf, 0.5, 0.5, 2, 2);
189
+ expect(r).toBe(65535);
190
+ expect(g).toBe(65535);
191
+ expect(b).toBe(65535);
192
+ });
193
+
194
+ it("works on a large 256x256 canvas with sub-pixel UV", () => {
195
+ // Sanity check that buffer offset math scales — exercises the (y * w + x) * 6
196
+ // indexing on a non-trivial stride.
197
+ const w = 256;
198
+ const h = 256;
199
+ const buf = Buffer.alloc(w * h * 6);
200
+ // Diagonal gradient in R: pixel (x, y).R = x
201
+ for (let y = 0; y < h; y++) {
202
+ for (let x = 0; x < w; x++) {
203
+ const off = (y * w + x) * 6;
204
+ buf.writeUInt16LE(x, off);
205
+ }
206
+ }
207
+ // u = 0.5 → sx = 0.5 * (w - 1) = 127.5 → R should be ≈ 127.5 → rounds to 128
208
+ const [r] = sampleRgb48le(buf, 0.5, 0.5, w, h);
209
+ expect(r).toBe(128);
210
+ });
211
+
212
+ it("handles 1x1 source with arbitrary UV (no x1=x0 division by zero)", () => {
213
+ // x0+1 gets clamped back to w-1=0, so x0 == x1. The weights still sum to 1
214
+ // and the result must equal the single pixel value.
215
+ const buf = Buffer.alloc(6);
216
+ buf.writeUInt16LE(12345, 0);
217
+ buf.writeUInt16LE(23456, 2);
218
+ buf.writeUInt16LE(34567, 4);
219
+ for (const [u, v] of [
220
+ [0, 0],
221
+ [0.5, 0.5],
222
+ [1, 1],
223
+ [0.7, 0.3],
224
+ ] as const) {
225
+ const [r, g, b] = sampleRgb48le(buf, u, v, 1, 1);
226
+ expect(r).toBe(12345);
227
+ expect(g).toBe(23456);
228
+ expect(b).toBe(34567);
229
+ }
230
+ });
231
+ });
232
+
233
+ // ── mix16 ─────────────────────────────────────────────────────────────────────
234
+
235
+ describe("mix16", () => {
236
+ it("returns a at t=0", () => {
237
+ expect(mix16(1000, 60000, 0)).toBe(1000);
238
+ });
239
+
240
+ it("returns b at t=1", () => {
241
+ expect(mix16(1000, 60000, 1)).toBe(60000);
242
+ });
243
+
244
+ it("returns midpoint at t=0.5", () => {
245
+ expect(mix16(0, 60000, 0.5)).toBe(30000);
246
+ });
247
+
248
+ it("returns rounded result for non-integer midpoints", () => {
249
+ // 0 * 0.5 + 1 * 0.5 = 0.5 → rounds to 1
250
+ expect(mix16(0, 1, 0.5)).toBe(1);
251
+ });
252
+ });
253
+
254
+ // ── clamp16 ───────────────────────────────────────────────────────────────────
255
+
256
+ describe("clamp16", () => {
257
+ it("clamps negative to 0", () => {
258
+ expect(clamp16(-100)).toBe(0);
259
+ });
260
+
261
+ it("clamps overflow to 65535", () => {
262
+ expect(clamp16(70000)).toBe(65535);
263
+ });
264
+
265
+ it("passes normal values through", () => {
266
+ expect(clamp16(32768)).toBe(32768);
267
+ });
268
+
269
+ it("passes boundary values through", () => {
270
+ expect(clamp16(0)).toBe(0);
271
+ expect(clamp16(65535)).toBe(65535);
272
+ });
273
+ });
274
+
275
+ // ── smoothstep ────────────────────────────────────────────────────────────────
276
+
277
+ describe("smoothstep", () => {
278
+ it("returns 0 when x <= edge0", () => {
279
+ expect(smoothstep(0.2, 0.8, 0.1)).toBe(0);
280
+ expect(smoothstep(0.2, 0.8, 0.2)).toBe(0);
281
+ });
282
+
283
+ it("returns 1 when x >= edge1", () => {
284
+ expect(smoothstep(0.2, 0.8, 0.9)).toBe(1);
285
+ expect(smoothstep(0.2, 0.8, 0.8)).toBe(1);
286
+ });
287
+
288
+ it("returns ~0.5 at midpoint between edge0 and edge1", () => {
289
+ // t = (0.5 - 0.2) / (0.8 - 0.2) = 0.5; hermite(0.5) = 0.5*0.5*(3-2*0.5) = 0.5
290
+ expect(smoothstep(0.2, 0.8, 0.5)).toBeCloseTo(0.5, 10);
291
+ });
292
+
293
+ it("is monotonically increasing", () => {
294
+ const vals = [0.3, 0.4, 0.5, 0.6, 0.7].map((x) => smoothstep(0.2, 0.8, x));
295
+ for (let i = 1; i < vals.length; i++) {
296
+ expect(vals[i]).toBeGreaterThan(vals[i - 1] ?? 0);
297
+ }
298
+ });
299
+ });
300
+
301
+ // ── hash ──────────────────────────────────────────────────────────────────────
302
+
303
+ describe("hash", () => {
304
+ it("returns a value in [0, 1)", () => {
305
+ const h = hash(1.5, 2.7);
306
+ expect(h).toBeGreaterThanOrEqual(0);
307
+ expect(h).toBeLessThan(1);
308
+ });
309
+
310
+ it("is deterministic for the same inputs", () => {
311
+ expect(hash(3.14, 2.71)).toBe(hash(3.14, 2.71));
312
+ });
313
+
314
+ it("returns different values for different inputs", () => {
315
+ expect(hash(0, 0)).not.toBe(hash(1, 0));
316
+ expect(hash(0, 0)).not.toBe(hash(0, 1));
317
+ });
318
+
319
+ it("returns values in [0,1) for integer grid points", () => {
320
+ for (let i = 0; i < 5; i++) {
321
+ for (let j = 0; j < 5; j++) {
322
+ const h = hash(i, j);
323
+ expect(h).toBeGreaterThanOrEqual(0);
324
+ expect(h).toBeLessThan(1);
325
+ }
326
+ }
327
+ });
328
+ });
329
+
330
+ // ── vnoise ────────────────────────────────────────────────────────────────────
331
+
332
+ describe("vnoise", () => {
333
+ it("returns a value in [0, 1]", () => {
334
+ const v = vnoise(1.5, 2.3);
335
+ expect(v).toBeGreaterThanOrEqual(0);
336
+ expect(v).toBeLessThanOrEqual(1);
337
+ });
338
+
339
+ it("is deterministic for the same inputs", () => {
340
+ expect(vnoise(3.14, 2.71)).toBe(vnoise(3.14, 2.71));
341
+ });
342
+
343
+ it("returns [0,1] range over a grid", () => {
344
+ for (let i = 0; i < 4; i++) {
345
+ for (let j = 0; j < 4; j++) {
346
+ const v = vnoise(i * 0.7, j * 0.7);
347
+ expect(v).toBeGreaterThanOrEqual(0);
348
+ expect(v).toBeLessThanOrEqual(1);
349
+ }
350
+ }
351
+ });
352
+
353
+ it("produces variation across the domain (not constant)", () => {
354
+ const values = new Set([
355
+ vnoise(0, 0),
356
+ vnoise(1, 0),
357
+ vnoise(0, 1),
358
+ vnoise(1, 1),
359
+ vnoise(0.5, 0.5),
360
+ ]);
361
+ // At least 2 distinct values among 5 samples
362
+ expect(values.size).toBeGreaterThan(1);
363
+ });
364
+ });
365
+
366
+ // ── fbm ───────────────────────────────────────────────────────────────────────
367
+
368
+ describe("fbm", () => {
369
+ it("is deterministic for the same inputs", () => {
370
+ expect(fbm(1.5, 2.3)).toBe(fbm(1.5, 2.3));
371
+ });
372
+
373
+ it("returns consistent known values", () => {
374
+ const v0 = fbm(0, 0);
375
+ const v1 = fbm(1, 0);
376
+ const v2 = fbm(0, 1);
377
+ // All should be finite numbers (not NaN/Infinity)
378
+ expect(Number.isFinite(v0)).toBe(true);
379
+ expect(Number.isFinite(v1)).toBe(true);
380
+ expect(Number.isFinite(v2)).toBe(true);
381
+ // Should produce different values for different inputs
382
+ expect(v0).not.toBe(v1);
383
+ expect(v0).not.toBe(v2);
384
+ });
385
+
386
+ it("produces values in a reasonable range", () => {
387
+ // fbm sums 5 octaves of vnoise (0–1) with amplitudes 0.5,0.25,0.125,0.0625,0.03125
388
+ // max possible ≈ 0.96875; values should be positive
389
+ const v = fbm(2.5, 3.7);
390
+ expect(v).toBeGreaterThan(0);
391
+ expect(v).toBeLessThan(1.1);
392
+ });
393
+ });
394
+
395
+ // ── transition helpers ────────────────────────────────────────────────────────
396
+
397
+ function makeBuffer(w: number, h: number, r: number, g: number, b: number): Buffer {
398
+ const buf = Buffer.alloc(w * h * 6);
399
+ for (let i = 0; i < w * h; i++) {
400
+ buf.writeUInt16LE(r, i * 6);
401
+ buf.writeUInt16LE(g, i * 6 + 2);
402
+ buf.writeUInt16LE(b, i * 6 + 4);
403
+ }
404
+ return buf;
405
+ }
406
+
407
+ function runTransition(
408
+ fn: TransitionFn,
409
+ w: number,
410
+ h: number,
411
+ fR: number,
412
+ fG: number,
413
+ fB: number,
414
+ tR: number,
415
+ tG: number,
416
+ tB: number,
417
+ progress: number,
418
+ ): Buffer {
419
+ const from = makeBuffer(w, h, fR, fG, fB);
420
+ const to = makeBuffer(w, h, tR, tG, tB);
421
+ const out = Buffer.alloc(w * h * 6);
422
+ fn(from, to, out, w, h, progress);
423
+ return out;
424
+ }
425
+
426
+ // ── crossfade ─────────────────────────────────────────────────────────────────
427
+
428
+ describe("crossfade", () => {
429
+ it("at progress=0 output equals from", () => {
430
+ const out = runTransition(crossfade, 2, 2, 10000, 20000, 30000, 50000, 55000, 60000, 0);
431
+ for (let i = 0; i < 4; i++) {
432
+ expect(out.readUInt16LE(i * 6)).toBe(10000);
433
+ expect(out.readUInt16LE(i * 6 + 2)).toBe(20000);
434
+ expect(out.readUInt16LE(i * 6 + 4)).toBe(30000);
435
+ }
436
+ });
437
+
438
+ it("at progress=1 output equals to", () => {
439
+ const out = runTransition(crossfade, 2, 2, 10000, 20000, 30000, 50000, 55000, 60000, 1);
440
+ for (let i = 0; i < 4; i++) {
441
+ expect(out.readUInt16LE(i * 6)).toBe(50000);
442
+ expect(out.readUInt16LE(i * 6 + 2)).toBe(55000);
443
+ expect(out.readUInt16LE(i * 6 + 4)).toBe(60000);
444
+ }
445
+ });
446
+
447
+ it("at progress=0.5 output is midpoint of from and to", () => {
448
+ const out = runTransition(crossfade, 1, 1, 0, 0, 0, 60000, 60000, 60000, 0.5);
449
+ expect(out.readUInt16LE(0)).toBe(30000);
450
+ expect(out.readUInt16LE(2)).toBe(30000);
451
+ expect(out.readUInt16LE(4)).toBe(30000);
452
+ });
453
+
454
+ it("is registered in TRANSITIONS", () => {
455
+ expect(TRANSITIONS["crossfade"]).toBe(crossfade);
456
+ });
457
+ });
458
+
459
+ // ── flashThroughWhite ─────────────────────────────────────────────────────────
460
+
461
+ describe("flashThroughWhite", () => {
462
+ it("at progress=0 output approximates from", () => {
463
+ // toWhite = smoothstep(0,0.45,0) = 0, fromWhite = 1-smoothstep(0.5,1,0) = 1,
464
+ // blend = smoothstep(0.35,0.65,0) = 0 → output = fromC = from (untouched)
465
+ const out = runTransition(flashThroughWhite, 1, 1, 10000, 20000, 30000, 50000, 55000, 60000, 0);
466
+ expect(out.readUInt16LE(0)).toBe(10000);
467
+ expect(out.readUInt16LE(2)).toBe(20000);
468
+ expect(out.readUInt16LE(4)).toBe(30000);
469
+ });
470
+
471
+ it("at progress≈0.45 all channels are near white (>50000)", () => {
472
+ // toWhite = smoothstep(0,0.45,0.45) = 1 → fromC = white
473
+ // fromWhite = 1-smoothstep(0.5,1,0.45) = 1 → toC = white
474
+ // both inputs to blend are white → output is white
475
+ const out = runTransition(
476
+ flashThroughWhite,
477
+ 1,
478
+ 1,
479
+ 10000,
480
+ 20000,
481
+ 30000,
482
+ 50000,
483
+ 55000,
484
+ 60000,
485
+ 0.45,
486
+ );
487
+ expect(out.readUInt16LE(0)).toBeGreaterThan(50000);
488
+ expect(out.readUInt16LE(2)).toBeGreaterThan(50000);
489
+ expect(out.readUInt16LE(4)).toBeGreaterThan(50000);
490
+ });
491
+
492
+ it("at progress=1 output approximates to", () => {
493
+ // toWhite = smoothstep(0,0.45,1) = 1, fromWhite = 1-smoothstep(0.5,1,1) = 0,
494
+ // blend = smoothstep(0.35,0.65,1) = 1 → output = toC = to (untouched)
495
+ const out = runTransition(flashThroughWhite, 1, 1, 10000, 20000, 30000, 50000, 55000, 60000, 1);
496
+ expect(out.readUInt16LE(0)).toBe(50000);
497
+ expect(out.readUInt16LE(2)).toBe(55000);
498
+ expect(out.readUInt16LE(4)).toBe(60000);
499
+ });
500
+
501
+ it("is registered in TRANSITIONS", () => {
502
+ expect(TRANSITIONS["flash-through-white"]).toBe(flashThroughWhite);
503
+ });
504
+ });
505
+
506
+ // ── all transitions smoke test ────────────────────────────────────────────────
507
+
508
+ const ALL_SHADERS = [
509
+ "crossfade",
510
+ "flash-through-white",
511
+ "chromatic-split",
512
+ "sdf-iris",
513
+ "whip-pan",
514
+ "cinematic-zoom",
515
+ "gravitational-lens",
516
+ "glitch",
517
+ "ripple-waves",
518
+ "swirl-vortex",
519
+ "thermal-distortion",
520
+ "domain-warp",
521
+ "ridged-burn",
522
+ "cross-warp-morph",
523
+ "light-leak",
524
+ ];
525
+
526
+ // Pixel offset selector for the "at progress=0, center pixel ≈ from" test.
527
+ // Most transitions use the center pixel (4*8+4). Two shaders require a
528
+ // different test pixel because their design does not produce `from` at the
529
+ // center when p=0:
530
+ // sdf-iris: the iris reveal shows `to` inside and `from` outside.
531
+ // At any p>0 the center is inside → shows `to`. We use
532
+ // a corner pixel (row 0, col 0) that stays outside the
533
+ // iris until p is large.
534
+ // gravitational-lens: at p=0 the horizon mask is 0 at center (dist=0),
535
+ // producing black. A corner pixel (dist≈0.7) has
536
+ // horizon > 0 and shows a lensed version of `from`.
537
+ const P0_PIXEL: Record<string, number> = {
538
+ "sdf-iris": 0 * 6, // top-left corner: always outside iris at p=0
539
+ "gravitational-lens": (0 * 8 + 0) * 6, // top-left corner: non-zero dist
540
+ };
541
+
542
+ describe("all transitions smoke test", () => {
543
+ for (const name of ALL_SHADERS) {
544
+ describe(name, () => {
545
+ it("exists in registry", () => {
546
+ expect(TRANSITIONS[name]).toBeDefined();
547
+ });
548
+ it("at progress=0, center pixel ≈ from", () => {
549
+ const from = makeBuffer(8, 8, 40000, 30000, 20000);
550
+ const to = makeBuffer(8, 8, 10000, 10000, 10000);
551
+ const out = Buffer.alloc(8 * 8 * 6);
552
+ const fn = TRANSITIONS[name];
553
+ expect(fn).toBeDefined();
554
+ fn?.(from, to, out, 8, 8, 0);
555
+ const o = P0_PIXEL[name] ?? (4 * 8 + 4) * 6;
556
+ // At progress=0 the result should be the `from` pixel (R=40000).
557
+ // The midpoint between from (40000) and to (10000) is 25000, so a
558
+ // tighter threshold catches transitions that are halfway-blended
559
+ // when they should be fully on the `from` side.
560
+ expect(out.readUInt16LE(o)).toBeGreaterThan(35000);
561
+ });
562
+ it("at progress=1, center pixel ≈ to", () => {
563
+ const from = makeBuffer(8, 8, 40000, 30000, 20000);
564
+ const to = makeBuffer(8, 8, 10000, 10000, 10000);
565
+ const out = Buffer.alloc(8 * 8 * 6);
566
+ const fn = TRANSITIONS[name];
567
+ expect(fn).toBeDefined();
568
+ fn?.(from, to, out, 8, 8, 1);
569
+ const o = (4 * 8 + 4) * 6;
570
+ // At progress=1 the result should be the `to` pixel (R=10000).
571
+ // Tighter than the previous halfway midpoint (25000) so that any
572
+ // transition that is still half-blended will fail.
573
+ expect(out.readUInt16LE(o)).toBeLessThan(15000);
574
+ });
575
+ });
576
+ }
577
+ });
578
+
579
+ // ── hdrToLinear / linearToHdr roundtrip ────────────────────────────────────
580
+
581
+ describe("hdrToLinear / linearToHdr", () => {
582
+ for (const transfer of ["pq", "hlg"] as const) {
583
+ describe(transfer, () => {
584
+ it("roundtrip preserves mid-to-high values", () => {
585
+ // PQ concentrates precision in the dark range — linearizing then
586
+ // re-quantizing at 16-bit loses bits for values below ~16000.
587
+ // HLG squares small inputs, similar effect. Test the mid-to-high
588
+ // range where roundtrip error is bounded.
589
+ const values = [16384, 32768, 50000, 65535];
590
+ const buf = Buffer.alloc(values.length * 2);
591
+ for (let i = 0; i < values.length; i++) {
592
+ buf.writeUInt16LE(values[i] ?? 0, i * 2);
593
+ }
594
+ const original = Buffer.from(buf);
595
+ hdrToLinear(buf, transfer);
596
+ linearToHdr(buf, transfer);
597
+ for (let i = 0; i < values.length; i++) {
598
+ const got = buf.readUInt16LE(i * 2);
599
+ const want = original.readUInt16LE(i * 2);
600
+ expect(Math.abs(got - want)).toBeLessThanOrEqual(30);
601
+ }
602
+ });
603
+
604
+ it("zero maps to zero", () => {
605
+ const buf = Buffer.alloc(2);
606
+ buf.writeUInt16LE(0, 0);
607
+ hdrToLinear(buf, transfer);
608
+ expect(buf.readUInt16LE(0)).toBe(0);
609
+ });
610
+
611
+ it("65535 maps to 65535", () => {
612
+ const buf = Buffer.alloc(2);
613
+ buf.writeUInt16LE(65535, 0);
614
+ hdrToLinear(buf, transfer);
615
+ linearToHdr(buf, transfer);
616
+ expect(buf.readUInt16LE(0)).toBe(65535);
617
+ });
618
+
619
+ it("hdrToLinear produces monotonically increasing output", () => {
620
+ const steps = [0, 1000, 5000, 10000, 20000, 40000, 65535];
621
+ const buf = Buffer.alloc(steps.length * 2);
622
+ for (let i = 0; i < steps.length; i++) {
623
+ buf.writeUInt16LE(steps[i] ?? 0, i * 2);
624
+ }
625
+ hdrToLinear(buf, transfer);
626
+ let prev = 0;
627
+ for (let i = 0; i < steps.length; i++) {
628
+ const val = buf.readUInt16LE(i * 2);
629
+ expect(val).toBeGreaterThanOrEqual(prev);
630
+ prev = val;
631
+ }
632
+ });
633
+ });
634
+ }
635
+ });
636
+
637
+ // ── convertTransfer (HLG↔PQ) ─────────────────────────────────────────────
638
+
639
+ describe("convertTransfer", () => {
640
+ it("no-op when from === to", () => {
641
+ const buf = Buffer.alloc(6);
642
+ buf.writeUInt16LE(32768, 0);
643
+ buf.writeUInt16LE(16384, 2);
644
+ buf.writeUInt16LE(8192, 4);
645
+ const original = Buffer.from(buf);
646
+ convertTransfer(buf, "pq", "pq");
647
+ expect(buf.equals(original)).toBe(true);
648
+ });
649
+
650
+ it("hlg→pq→hlg roundtrip preserves mid-high values", () => {
651
+ const values = [16384, 32768, 50000, 65535];
652
+ const buf = Buffer.alloc(values.length * 2);
653
+ for (let i = 0; i < values.length; i++) {
654
+ buf.writeUInt16LE(values[i] ?? 0, i * 2);
655
+ }
656
+ const original = Buffer.from(buf);
657
+ convertTransfer(buf, "hlg", "pq");
658
+ expect(buf.equals(original)).toBe(false);
659
+ convertTransfer(buf, "pq", "hlg");
660
+ for (let i = 0; i < values.length; i++) {
661
+ const got = buf.readUInt16LE(i * 2);
662
+ const want = original.readUInt16LE(i * 2);
663
+ expect(Math.abs(got - want)).toBeLessThanOrEqual(30);
664
+ }
665
+ });
666
+
667
+ it("hlg→pq produces different values", () => {
668
+ const buf = Buffer.alloc(2);
669
+ buf.writeUInt16LE(32768, 0);
670
+ const before = buf.readUInt16LE(0);
671
+ convertTransfer(buf, "hlg", "pq");
672
+ expect(buf.readUInt16LE(0)).not.toBe(before);
673
+ });
674
+ });