@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,894 @@
1
+ /**
2
+ * Shader Transition Math Utilities
3
+ *
4
+ * Sampling helpers and math primitives for rgb48le shader transitions.
5
+ * Functions are ported from GLSL to operate on 16-bit little-endian pixel
6
+ * buffers (6 bytes per pixel: R, G, B each stored as UInt16LE).
7
+ */
8
+ // ── PQ linearization ─────────────────────────────────────────────────────────
9
+ // Shader transitions were ported from sRGB GLSL where pixel values distribute
10
+ // linearly across the visible range. In PQ space, dark content clusters near
11
+ // zero, causing UV-warping shaders to produce black artifacts. Converting to
12
+ // linear light before the shader and back to PQ after gives correct results.
13
+ const PQ_M1 = 0.1593017578125;
14
+ const PQ_M2 = 78.84375;
15
+ const PQ_C1 = 0.8359375;
16
+ const PQ_C2 = 18.8515625;
17
+ const PQ_C3 = 18.6875;
18
+ /** PQ EOTF: decode PQ signal (0-1) → linear light (0-1, normalized to 10000 nits). */
19
+ function pqEotf(signal) {
20
+ const sp = Math.pow(Math.max(0, signal), 1 / PQ_M2);
21
+ const num = Math.max(sp - PQ_C1, 0);
22
+ const den = PQ_C2 - PQ_C3 * sp;
23
+ return den > 0 ? Math.pow(num / den, 1 / PQ_M1) : 0;
24
+ }
25
+ /** PQ OETF: encode linear light (0-1) → PQ signal (0-1). */
26
+ function pqOetf(linear) {
27
+ const lp = Math.pow(Math.max(0, linear), PQ_M1);
28
+ return Math.pow((PQ_C1 + PQ_C2 * lp) / (1 + PQ_C3 * lp), PQ_M2);
29
+ }
30
+ /** HLG OETF inverse: decode HLG signal (0-1) → linear scene light (0-1). */
31
+ function hlgEotf(signal) {
32
+ const a = 0.17883277;
33
+ const b = 1 - 4 * a;
34
+ const c = 0.5 - a * Math.log(4 * a);
35
+ if (signal <= 0.5) {
36
+ return (signal * signal) / 3;
37
+ }
38
+ return (Math.exp((signal - c) / a) + b) / 12;
39
+ }
40
+ /** HLG OETF: encode linear scene light (0-1) → HLG signal (0-1). */
41
+ function hlgOetf(linear) {
42
+ const a = 0.17883277;
43
+ const b = 1 - 4 * a;
44
+ const c = 0.5 - a * Math.log(4 * a);
45
+ if (linear <= 1 / 12) {
46
+ return Math.sqrt(3 * linear);
47
+ }
48
+ return a * Math.log(12 * linear - b) + c;
49
+ }
50
+ // ── Precomputed LUTs for fast HDR↔linear conversion ─────────────────────────
51
+ // 65536-entry lookup tables eliminate per-pixel Math.pow calls. Built once on
52
+ // first use, then reused for all subsequent conversions. At 4K (8.3M pixels ×
53
+ // 3 channels × 3 buffers), this turns ~75M Math.pow calls per transition frame
54
+ // into 75M array lookups — ~100× faster.
55
+ function buildLut(fn) {
56
+ const lut = new Uint16Array(65536);
57
+ for (let i = 0; i < 65536; i++) {
58
+ lut[i] = Math.round(fn(i / 65535) * 65535);
59
+ }
60
+ return lut;
61
+ }
62
+ let pqToLinearLut = null;
63
+ let linearToPqLut = null;
64
+ let hlgToLinearLut = null;
65
+ let linearToHlgLut = null;
66
+ function getPqToLinearLut() {
67
+ if (!pqToLinearLut)
68
+ pqToLinearLut = buildLut(pqEotf);
69
+ return pqToLinearLut;
70
+ }
71
+ function getLinearToPqLut() {
72
+ if (!linearToPqLut)
73
+ linearToPqLut = buildLut(pqOetf);
74
+ return linearToPqLut;
75
+ }
76
+ function getHlgToLinearLut() {
77
+ if (!hlgToLinearLut)
78
+ hlgToLinearLut = buildLut(hlgEotf);
79
+ return hlgToLinearLut;
80
+ }
81
+ function getLinearToHlgLut() {
82
+ if (!linearToHlgLut)
83
+ linearToHlgLut = buildLut(hlgOetf);
84
+ return linearToHlgLut;
85
+ }
86
+ /**
87
+ * Convert an rgb48le buffer from HDR signal space to linear light, in-place.
88
+ * Uses precomputed 65536-entry LUT for O(1) per-sample conversion.
89
+ * @param transfer "pq" or "hlg"
90
+ */
91
+ export function hdrToLinear(buf, transfer) {
92
+ const lut = transfer === "pq" ? getPqToLinearLut() : getHlgToLinearLut();
93
+ const len = buf.length / 2;
94
+ for (let i = 0; i < len; i++) {
95
+ const off = i * 2;
96
+ buf.writeUInt16LE(lut[buf.readUInt16LE(off)] ?? 0, off);
97
+ }
98
+ }
99
+ /**
100
+ * Convert an rgb48le buffer from linear light back to HDR signal space, in-place.
101
+ * Uses precomputed 65536-entry LUT for O(1) per-sample conversion.
102
+ * @param transfer "pq" or "hlg"
103
+ */
104
+ export function linearToHdr(buf, transfer) {
105
+ const lut = transfer === "pq" ? getLinearToPqLut() : getLinearToHlgLut();
106
+ const len = buf.length / 2;
107
+ for (let i = 0; i < len; i++) {
108
+ const off = i * 2;
109
+ buf.writeUInt16LE(lut[buf.readUInt16LE(off)] ?? 0, off);
110
+ }
111
+ }
112
+ // ── Cross-transfer conversion (HLG↔PQ) ──────────────────────────────────────
113
+ // HLG is scene-referred, PQ is display-referred. Converting between them
114
+ // requires the OOTF (Optical-Optical Transfer Function) which maps scene
115
+ // light to display light. Per BT.2100, the HLG OOTF for a reference
116
+ // display at Lw nits is: Y_display = Lw * Y_scene^gamma, where
117
+ // gamma = 1.2 * 1.111^(log2(Lw/1000)). At 1000 nits: gamma = 1.2.
118
+ //
119
+ // The per-channel approximation (applying gamma per-channel rather than
120
+ // on luminance Y) introduces slight color shifts but avoids a full
121
+ // colorimetric conversion with BT.2020 luma coefficients.
122
+ const HLG_OOTF_LW = 1000; // reference display peak luminance (nits)
123
+ const HLG_OOTF_GAMMA = 1.2 * Math.pow(1.111, Math.log2(HLG_OOTF_LW / 1000));
124
+ /** HLG scene light → PQ display light (per-channel, normalized to 10000 nits) */
125
+ function hlgSceneToPqDisplay(sceneLinear) {
126
+ const displayNits = HLG_OOTF_LW * Math.pow(Math.max(0, sceneLinear), HLG_OOTF_GAMMA);
127
+ return displayNits / 10000; // PQ is normalized to 10000 nits
128
+ }
129
+ /** PQ display light → HLG scene light (inverse OOTF) */
130
+ function pqDisplayToHlgScene(displayNormalized) {
131
+ const displayNits = displayNormalized * 10000;
132
+ return Math.pow(Math.max(0, displayNits / HLG_OOTF_LW), 1 / HLG_OOTF_GAMMA);
133
+ }
134
+ let hlgToPqLut = null;
135
+ let pqToHlgLut = null;
136
+ function getHlgToPqLut() {
137
+ // HLG signal → scene linear (EOTF) → display linear (OOTF) → PQ signal (OETF)
138
+ if (!hlgToPqLut)
139
+ hlgToPqLut = buildLut((v) => pqOetf(hlgSceneToPqDisplay(hlgEotf(v))));
140
+ return hlgToPqLut;
141
+ }
142
+ function getPqToHlgLut() {
143
+ // PQ signal → display linear (EOTF) → scene linear (inverse OOTF) → HLG signal (OETF)
144
+ if (!pqToHlgLut)
145
+ pqToHlgLut = buildLut((v) => hlgOetf(pqDisplayToHlgScene(pqEotf(v))));
146
+ return pqToHlgLut;
147
+ }
148
+ /**
149
+ * Convert an rgb48le buffer between HDR transfer functions, in-place.
150
+ * Uses a composite 65536-entry LUT (source EOTF → linear → target OETF)
151
+ * for O(1) per-sample conversion. No-op if from === to.
152
+ */
153
+ export function convertTransfer(buf, from, to) {
154
+ if (from === to)
155
+ return;
156
+ const lut = from === "hlg" ? getHlgToPqLut() : getPqToHlgLut();
157
+ const len = buf.length / 2;
158
+ for (let i = 0; i < len; i++) {
159
+ const off = i * 2;
160
+ buf.writeUInt16LE(lut[buf.readUInt16LE(off)] ?? 0, off);
161
+ }
162
+ }
163
+ // ── Buffer sampling ───────────────────────────────────────────────────────────
164
+ /**
165
+ * Sample an rgb48le buffer at floating-point UV coordinates (0–1 range, clamped).
166
+ * Uses bilinear interpolation between the 4 nearest pixels, equivalent to
167
+ * GLSL `texture2D` with clamp-to-edge wrapping.
168
+ *
169
+ * @param buf rgb48le buffer — w * h * 6 bytes
170
+ * @param u Horizontal coordinate in [0, 1]
171
+ * @param v Vertical coordinate in [0, 1]
172
+ * @param w Image width in pixels
173
+ * @param h Image height in pixels
174
+ * @returns [r, g, b] as 16-bit values (0–65535)
175
+ */
176
+ export function sampleRgb48le(buf, u, v, w, h) {
177
+ // Clamp UV to [0, 1] then map to pixel coordinates
178
+ const uc = Math.max(0, Math.min(1, u));
179
+ const vc = Math.max(0, Math.min(1, v));
180
+ const sx = uc * (w - 1);
181
+ const sy = vc * (h - 1);
182
+ const x0 = Math.floor(sx);
183
+ const y0 = Math.floor(sy);
184
+ const x1 = Math.min(x0 + 1, w - 1);
185
+ const y1 = Math.min(y0 + 1, h - 1);
186
+ const fx = sx - x0;
187
+ const fy = sy - y0;
188
+ const w00 = (1 - fx) * (1 - fy);
189
+ const w10 = fx * (1 - fy);
190
+ const w01 = (1 - fx) * fy;
191
+ const w11 = fx * fy;
192
+ const off00 = (y0 * w + x0) * 6;
193
+ const off10 = (y0 * w + x1) * 6;
194
+ const off01 = (y1 * w + x0) * 6;
195
+ const off11 = (y1 * w + x1) * 6;
196
+ const r = Math.round(buf.readUInt16LE(off00) * w00 +
197
+ buf.readUInt16LE(off10) * w10 +
198
+ buf.readUInt16LE(off01) * w01 +
199
+ buf.readUInt16LE(off11) * w11);
200
+ const g = Math.round(buf.readUInt16LE(off00 + 2) * w00 +
201
+ buf.readUInt16LE(off10 + 2) * w10 +
202
+ buf.readUInt16LE(off01 + 2) * w01 +
203
+ buf.readUInt16LE(off11 + 2) * w11);
204
+ const b = Math.round(buf.readUInt16LE(off00 + 4) * w00 +
205
+ buf.readUInt16LE(off10 + 4) * w10 +
206
+ buf.readUInt16LE(off01 + 4) * w01 +
207
+ buf.readUInt16LE(off11 + 4) * w11);
208
+ return [r, g, b];
209
+ }
210
+ // ── 16-bit math primitives ────────────────────────────────────────────────────
211
+ /**
212
+ * Linear interpolate two 16-bit values. Equivalent to GLSL `mix(a, b, t)`.
213
+ */
214
+ export function mix16(a, b, t) {
215
+ return Math.round(a * (1 - t) + b * t);
216
+ }
217
+ /**
218
+ * Clamp a value to the 16-bit unsigned range [0, 65535].
219
+ */
220
+ export function clamp16(v) {
221
+ return Math.max(0, Math.min(65535, v));
222
+ }
223
+ // ── GLSL math ports ───────────────────────────────────────────────────────────
224
+ /**
225
+ * Hermite interpolation from GLSL `smoothstep(edge0, edge1, x)`.
226
+ * Returns 0 for x ≤ edge0, 1 for x ≥ edge1, and a smooth S-curve between.
227
+ */
228
+ export function smoothstep(edge0, edge1, x) {
229
+ const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));
230
+ return t * t * (3 - 2 * t);
231
+ }
232
+ /**
233
+ * Deterministic pseudo-random value in [0, 1).
234
+ * Port of the GLSL idiom: `fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453)`.
235
+ */
236
+ export function hash(x, y) {
237
+ return (((Math.sin(x * 127.1 + y * 311.7) * 43758.5453) % 1) + 1) % 1;
238
+ }
239
+ /**
240
+ * Value noise with C2-continuous quintic interpolation.
241
+ * Samples `hash()` at the 4 surrounding integer grid corners and blends
242
+ * using the quintic fade f = f³(f(6f − 15) + 10).
243
+ *
244
+ * Returns a value in [0, 1].
245
+ */
246
+ export function vnoise(px, py) {
247
+ const ix = Math.floor(px);
248
+ const iy = Math.floor(py);
249
+ // Fractional part
250
+ let fx = px - ix;
251
+ let fy = py - iy;
252
+ // Quintic C2 interpolation weights
253
+ fx = fx * fx * fx * (fx * (fx * 6 - 15) + 10);
254
+ fy = fy * fy * fy * (fy * (fy * 6 - 15) + 10);
255
+ const h00 = hash(ix, iy);
256
+ const h10 = hash(ix + 1, iy);
257
+ const h01 = hash(ix, iy + 1);
258
+ const h11 = hash(ix + 1, iy + 1);
259
+ // Bilinear blend
260
+ return h00 * (1 - fx) * (1 - fy) + h10 * fx * (1 - fy) + h01 * (1 - fx) * fy + h11 * fx * fy;
261
+ }
262
+ // Rotation matrix constants from GLSL: mat2(0.8, 0.6, -0.6, 0.8)
263
+ // Applies to [px, py]: px' = 0.8*px - 0.6*py, py' = 0.6*px + 0.8*py
264
+ const ROT_A = 0.8;
265
+ const ROT_B = 0.6;
266
+ /**
267
+ * Fractal Brownian motion — 5-octave accumulation of value noise.
268
+ *
269
+ * Each octave: accumulate `amplitude * vnoise(p)`, rotate p by 36.87°,
270
+ * scale by 2.02, halve the amplitude. Matching the GLSL convention of
271
+ * `mat2(0.8, 0.6, -0.6, 0.8)` for the rotation.
272
+ */
273
+ export function fbm(px, py) {
274
+ let value = 0;
275
+ let amplitude = 0.5;
276
+ let x = px;
277
+ let y = py;
278
+ for (let i = 0; i < 5; i++) {
279
+ value += amplitude * vnoise(x, y);
280
+ // Rotate by mat2(0.8, 0.6, -0.6, 0.8)
281
+ const nx = ROT_A * x - ROT_B * y;
282
+ const ny = ROT_B * x + ROT_A * y;
283
+ x = nx * 2.02;
284
+ y = ny * 2.02;
285
+ amplitude *= 0.5;
286
+ }
287
+ return value;
288
+ }
289
+ /** Registry of all available transitions by name. */
290
+ export const TRANSITIONS = {};
291
+ // ── crossfade ─────────────────────────────────────────────────────────────────
292
+ /**
293
+ * Simple linear blend between two frames. Equivalent to GLSL `mix(from, to, progress)`.
294
+ */
295
+ export const crossfade = (from, to, out, w, h, p) => {
296
+ const inv = 1 - p;
297
+ for (let i = 0; i < w * h; i++) {
298
+ const o = i * 6;
299
+ out.writeUInt16LE(Math.round(from.readUInt16LE(o) * inv + to.readUInt16LE(o) * p), o);
300
+ out.writeUInt16LE(Math.round(from.readUInt16LE(o + 2) * inv + to.readUInt16LE(o + 2) * p), o + 2);
301
+ out.writeUInt16LE(Math.round(from.readUInt16LE(o + 4) * inv + to.readUInt16LE(o + 4) * p), o + 4);
302
+ }
303
+ };
304
+ TRANSITIONS["crossfade"] = crossfade;
305
+ // ── flashThroughWhite ─────────────────────────────────────────────────────────
306
+ /**
307
+ * Flash-through-white transition: the outgoing scene brightens to white while
308
+ * the incoming scene emerges from white, creating a bright flash at the midpoint.
309
+ *
310
+ * Port of the GLSL flash-through-white shader.
311
+ */
312
+ export const flashThroughWhite = (from, to, out, w, h, p) => {
313
+ const toWhite = smoothstep(0, 0.45, p); // outgoing brightens toward white
314
+ const fromWhite = 1 - smoothstep(0.5, 1, p); // incoming starts from white
315
+ const blend = smoothstep(0.35, 0.65, p); // crossfade between the two
316
+ for (let i = 0; i < w * h; i++) {
317
+ const o = i * 6;
318
+ const fromR = mix16(from.readUInt16LE(o), 65535, toWhite);
319
+ const fromG = mix16(from.readUInt16LE(o + 2), 65535, toWhite);
320
+ const fromB = mix16(from.readUInt16LE(o + 4), 65535, toWhite);
321
+ const toR = mix16(to.readUInt16LE(o), 65535, fromWhite);
322
+ const toG = mix16(to.readUInt16LE(o + 2), 65535, fromWhite);
323
+ const toB = mix16(to.readUInt16LE(o + 4), 65535, fromWhite);
324
+ out.writeUInt16LE(mix16(fromR, toR, blend), o);
325
+ out.writeUInt16LE(mix16(fromG, toG, blend), o + 2);
326
+ out.writeUInt16LE(mix16(fromB, toB, blend), o + 4);
327
+ }
328
+ };
329
+ TRANSITIONS["flash-through-white"] = flashThroughWhite;
330
+ // ── chromatic-split ───────────────────────────────────────────────────────────
331
+ /**
332
+ * RGB channel offset transition. Each channel is sampled at a different UV
333
+ * offset, spreading apart as progress increases (outgoing) and converging
334
+ * as progress approaches 1 (incoming). Port of the GLSL chromatic-split shader.
335
+ */
336
+ export const chromaticSplit = (from, to, out, w, h, p) => {
337
+ for (let i = 0; i < w * h; i++) {
338
+ const ux = (i % w) / w;
339
+ const uy = Math.floor(i / w) / h;
340
+ const o = i * 6;
341
+ // Center-relative UV for offset direction
342
+ const cx = ux - 0.5;
343
+ const cy = uy - 0.5;
344
+ const fromShift = p * 0.06;
345
+ const fr = sampleRgb48le(from, ux + cx * fromShift, uy + cy * fromShift, w, h)[0];
346
+ const fg = sampleRgb48le(from, ux, uy, w, h)[1];
347
+ const fb = sampleRgb48le(from, ux - cx * fromShift, uy - cy * fromShift, w, h)[2];
348
+ const toShift = (1 - p) * 0.06;
349
+ const tr = sampleRgb48le(to, ux - cx * toShift, uy - cy * toShift, w, h)[0];
350
+ const tg = sampleRgb48le(to, ux, uy, w, h)[1];
351
+ const tb = sampleRgb48le(to, ux + cx * toShift, uy + cy * toShift, w, h)[2];
352
+ out.writeUInt16LE(clamp16(mix16(fr, tr, p)), o);
353
+ out.writeUInt16LE(clamp16(mix16(fg, tg, p)), o + 2);
354
+ out.writeUInt16LE(clamp16(mix16(fb, tb, p)), o + 4);
355
+ }
356
+ };
357
+ TRANSITIONS["chromatic-split"] = chromaticSplit;
358
+ // ── sdf-iris ──────────────────────────────────────────────────────────────────
359
+ /**
360
+ * Circular iris reveal. A sharp edge expands from the center while golden
361
+ * glow rings ripple outward at the boundary. Port of the GLSL sdf-iris shader.
362
+ */
363
+ export const sdfIris = (from, to, out, w, h, p) => {
364
+ // Accent colors for glow rings (16-bit scale)
365
+ const accentBright = [65535, 55000, 35000];
366
+ for (let i = 0; i < w * h; i++) {
367
+ const ux = (i % w) / w;
368
+ const uy = Math.floor(i / w) / h;
369
+ const o = i * 6;
370
+ // Aspect-corrected distance from center
371
+ const ax = (ux - 0.5) * (w / h);
372
+ const ay = uy - 0.5;
373
+ const d = Math.sqrt(ax * ax + ay * ay);
374
+ const radius = p * 1.2;
375
+ const fw = 0.003;
376
+ const edge = smoothstep(radius + fw, radius - fw, d);
377
+ // Three glow rings at different radii and falloff speeds
378
+ const ring1 = Math.exp(-Math.abs(d - radius) * 25);
379
+ const ring2 = Math.exp(-Math.abs(d - radius + 0.04) * 20) * 0.5;
380
+ const ring3 = Math.exp(-Math.abs(d - radius + 0.08) * 15) * 0.25;
381
+ const glow = (ring1 + ring2 + ring3) * p * (1 - p) * 4;
382
+ const [fromR, fromG, fromB] = sampleRgb48le(from, ux, uy, w, h);
383
+ const [toR, toG, toB] = sampleRgb48le(to, ux, uy, w, h);
384
+ out.writeUInt16LE(clamp16(mix16(fromR, toR, edge) + accentBright[0] * glow * 0.6), o);
385
+ out.writeUInt16LE(clamp16(mix16(fromG, toG, edge) + accentBright[1] * glow * 0.6), o + 2);
386
+ out.writeUInt16LE(clamp16(mix16(fromB, toB, edge) + accentBright[2] * glow * 0.6), o + 4);
387
+ }
388
+ };
389
+ TRANSITIONS["sdf-iris"] = sdfIris;
390
+ // ── glitch ────────────────────────────────────────────────────────────────────
391
+ /**
392
+ * Deterministic PRNG matching the GLSL `rand` in the glitch shader.
393
+ * Uses different constants than `hash` — do NOT substitute.
394
+ */
395
+ function glitchRand(x, y) {
396
+ return (((Math.sin(x * 12.9898 + y * 78.233) * 43758.5453) % 1) + 1) % 1;
397
+ }
398
+ /**
399
+ * Block displacement + scanlines + RGB channel split. Intensity peaks at the
400
+ * midpoint (p=0.5) and decays at both ends. Port of the GLSL glitch shader.
401
+ */
402
+ export const glitch = (from, to, out, w, h, p) => {
403
+ const intensity = p * (1 - p) * 4;
404
+ for (let i = 0; i < w * h; i++) {
405
+ const ux = (i % w) / w;
406
+ const uy = Math.floor(i / w) / h;
407
+ const o = i * 6;
408
+ // Horizontal line displacement
409
+ const lineY = Math.floor(uy * 60) / 60;
410
+ const lineDisp = (glitchRand(lineY, Math.floor(p * 17)) - 0.5) * 0.18 * intensity;
411
+ // Block displacement
412
+ const blockX = Math.floor(ux * 12);
413
+ const blockY = Math.floor(uy * 8);
414
+ const progressStep = Math.floor(p * 11);
415
+ const br = glitchRand(blockX + progressStep, blockY + progressStep);
416
+ const ba = (br >= 0.83 ? 1 : 0) * intensity;
417
+ const bdx = (glitchRand(blockX * 2.1, blockY * 2.1) - 0.5) * 0.35 * ba;
418
+ const bdy = (glitchRand(blockX * 3.7, blockY * 3.7) - 0.5) * 0.35 * ba;
419
+ const uvx = Math.max(0, Math.min(1, ux + lineDisp + bdx));
420
+ const uvy = Math.max(0, Math.min(1, uy + bdy));
421
+ // RGB channel split on displaced UV
422
+ const shift = intensity * 0.035;
423
+ const r = sampleRgb48le(from, uvx + shift, uvy, w, h)[0];
424
+ const g = sampleRgb48le(from, uvx, uvy, w, h)[1];
425
+ const b = sampleRgb48le(from, uvx - shift, uvy, w, h)[2];
426
+ // Normalize to 0-1 for scanline, flicker, and crush operations
427
+ let cr = r / 65535;
428
+ let cg = g / 65535;
429
+ let cb = b / 65535;
430
+ // Scanline darkening: darken rows where fract(uy * h * 0.5) > 0.5
431
+ const scanline = (((uy * h * 0.5) % 1) + 1) % 1 >= 0.5 ? 0.05 * intensity : 0;
432
+ cr -= scanline;
433
+ cg -= scanline;
434
+ cb -= scanline;
435
+ // Brightness flicker
436
+ const flicker = 1 + (glitchRand(Math.floor(p * 23), 0) - 0.5) * 0.3 * intensity;
437
+ cr *= flicker;
438
+ cg *= flicker;
439
+ cb *= flicker;
440
+ // Color crush (posterize)
441
+ const levels = 256 - (256 - 8) * (intensity * 0.5);
442
+ cr = Math.floor(cr * levels) / levels;
443
+ cg = Math.floor(cg * levels) / levels;
444
+ cb = Math.floor(cb * levels) / levels;
445
+ // Scale back to 16-bit and mix with `to` by progress
446
+ const [toR, toG, toB] = sampleRgb48le(to, ux, uy, w, h);
447
+ out.writeUInt16LE(clamp16(mix16(Math.round(cr * 65535), toR, p)), o);
448
+ out.writeUInt16LE(clamp16(mix16(Math.round(cg * 65535), toG, p)), o + 2);
449
+ out.writeUInt16LE(clamp16(mix16(Math.round(cb * 65535), toB, p)), o + 4);
450
+ }
451
+ };
452
+ TRANSITIONS["glitch"] = glitch;
453
+ // ── light-leak ────────────────────────────────────────────────────────────────
454
+ /**
455
+ * ACES filmic tonemap. Input and output in 0-1 normalized range.
456
+ * Formula: (x * (2.51x + 0.03)) / (x * (2.43x + 0.59) + 0.14)
457
+ */
458
+ function aces(x) {
459
+ return Math.max(0, Math.min(1, (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14)));
460
+ }
461
+ /**
462
+ * Warm lens-flare from the upper-right corner. The incoming scene burns through
463
+ * an overexposed flash, tonemapped with ACES and crossfaded with the outgoing
464
+ * scene. Port of the GLSL light-leak shader.
465
+ */
466
+ export const lightLeak = (from, to, out, w, h, p) => {
467
+ // Normalized accent colors (0-1 range for ACES pipeline)
468
+ const accent = [50000 / 65535, 25000 / 65535, 5000 / 65535];
469
+ const accentBright = [65535 / 65535, 55000 / 65535, 35000 / 65535];
470
+ // Light source position
471
+ const lpx = 1.3;
472
+ const lpy = -0.2;
473
+ for (let i = 0; i < w * h; i++) {
474
+ const ux = (i % w) / w;
475
+ const uy = Math.floor(i / w) / h;
476
+ const o = i * 6;
477
+ const dx = ux - lpx;
478
+ const dy = uy - lpy;
479
+ const dist = Math.sqrt(dx * dx + dy * dy);
480
+ const leak = Math.max(0, Math.min(1, Math.exp(-dist * 1.8) * p * 4));
481
+ // Warm color: mix accent with accent_bright based on distance
482
+ const warmR = accent[0] + (accentBright[0] - accent[0]) * dist * 0.7;
483
+ const warmG = accent[1] + (accentBright[1] - accent[1]) * dist * 0.7;
484
+ const warmB = accent[2] + (accentBright[2] - accent[2]) * dist * 0.7;
485
+ // Lens flare streak
486
+ const flare = Math.exp(-Math.abs(uy - (-0.2 + ux * 0.3)) * 15) * leak * 0.3;
487
+ const [fr, fg, fb] = sampleRgb48le(from, ux, uy, w, h);
488
+ const fromR = fr / 65535;
489
+ const fromG = fg / 65535;
490
+ const fromB = fb / 65535;
491
+ // Overexpose and tonemap
492
+ const overR = aces(fromR + warmR * leak * 3 + accentBright[0] * flare);
493
+ const overG = aces(fromG + warmG * leak * 3 + accentBright[1] * flare);
494
+ const overB = aces(fromB + warmB * leak * 3 + accentBright[2] * flare);
495
+ // Mix overexposed → to by smoothstepped progress
496
+ const [toR, toG, toB] = sampleRgb48le(to, ux, uy, w, h);
497
+ const blend = smoothstep(0.15, 0.85, p);
498
+ out.writeUInt16LE(clamp16(mix16(Math.round(overR * 65535), toR, blend)), o);
499
+ out.writeUInt16LE(clamp16(mix16(Math.round(overG * 65535), toG, blend)), o + 2);
500
+ out.writeUInt16LE(clamp16(mix16(Math.round(overB * 65535), toB, blend)), o + 4);
501
+ }
502
+ };
503
+ TRANSITIONS["light-leak"] = lightLeak;
504
+ // ── cross-warp-morph ──────────────────────────────────────────────────────────
505
+ /**
506
+ * FBM displacement warp. Both frames are warped in opposite directions by a
507
+ * fractal noise field, then blended by a noise-threshold mask that sweeps
508
+ * across the screen as progress advances. Port of the GLSL cross-warp-morph shader.
509
+ */
510
+ export const crossWarpMorph = (from, to, out, w, h, p) => {
511
+ for (let i = 0; i < w * h; i++) {
512
+ const ux = (i % w) / w;
513
+ const uy = Math.floor(i / w) / h;
514
+ const o = i * 6;
515
+ const dispX = fbm(ux * 3, uy * 3) - 0.5;
516
+ const dispY = fbm(ux * 3 + 7.3, uy * 3 + 3.7) - 0.5;
517
+ const fromUx = Math.max(0, Math.min(1, ux + dispX * p * 0.5));
518
+ const fromUy = Math.max(0, Math.min(1, uy + dispY * p * 0.5));
519
+ const toUx = Math.max(0, Math.min(1, ux - dispX * (1 - p) * 0.5));
520
+ const toUy = Math.max(0, Math.min(1, uy - dispY * (1 - p) * 0.5));
521
+ const [fromR, fromG, fromB] = sampleRgb48le(from, fromUx, fromUy, w, h);
522
+ const [toR, toG, toB] = sampleRgb48le(to, toUx, toUy, w, h);
523
+ const n = fbm(ux * 4 + 3.1, uy * 4 + 1.7);
524
+ const blend = smoothstep(0.4, 0.6, n + p * 1.2 - 0.6);
525
+ out.writeUInt16LE(clamp16(mix16(fromR, toR, blend)), o);
526
+ out.writeUInt16LE(clamp16(mix16(fromG, toG, blend)), o + 2);
527
+ out.writeUInt16LE(clamp16(mix16(fromB, toB, blend)), o + 4);
528
+ }
529
+ };
530
+ TRANSITIONS["cross-warp-morph"] = crossWarpMorph;
531
+ // ── whip-pan ──────────────────────────────────────────────────────────────────
532
+ /**
533
+ * Horizontal motion blur. The outgoing frame is sampled with offsets shifted
534
+ * right (by progress*1.5) and the incoming frame is sampled with offsets shifted
535
+ * left (by (1-progress)*1.5). Each direction uses 10 samples averaged together.
536
+ * Port of the GLSL whip-pan shader.
537
+ */
538
+ export const whipPan = (from, to, out, w, h, p) => {
539
+ const fromOff = p * 1.5;
540
+ const toOff = (1 - p) * 1.5;
541
+ for (let i = 0; i < w * h; i++) {
542
+ const ux = (i % w) / w;
543
+ const uy = Math.floor(i / w) / h;
544
+ const o = i * 6;
545
+ let fromR = 0, fromG = 0, fromB = 0;
546
+ for (let s = 0; s < 10; s++) {
547
+ const f = s / 10;
548
+ const fuv = Math.max(0, Math.min(1, ux + fromOff + p * 0.08 * f));
549
+ const [r, g, b] = sampleRgb48le(from, fuv, uy, w, h);
550
+ fromR += r;
551
+ fromG += g;
552
+ fromB += b;
553
+ }
554
+ fromR /= 10;
555
+ fromG /= 10;
556
+ fromB /= 10;
557
+ let toR = 0, toG = 0, toB = 0;
558
+ for (let s = 0; s < 10; s++) {
559
+ const f = s / 10;
560
+ const tuv = Math.max(0, Math.min(1, ux - toOff - (1 - p) * 0.08 * f));
561
+ const [r, g, b] = sampleRgb48le(to, tuv, uy, w, h);
562
+ toR += r;
563
+ toG += g;
564
+ toB += b;
565
+ }
566
+ toR /= 10;
567
+ toG /= 10;
568
+ toB /= 10;
569
+ out.writeUInt16LE(clamp16(mix16(Math.round(fromR), Math.round(toR), p)), o);
570
+ out.writeUInt16LE(clamp16(mix16(Math.round(fromG), Math.round(toG), p)), o + 2);
571
+ out.writeUInt16LE(clamp16(mix16(Math.round(fromB), Math.round(toB), p)), o + 4);
572
+ }
573
+ };
574
+ TRANSITIONS["whip-pan"] = whipPan;
575
+ // ── cinematic-zoom ────────────────────────────────────────────────────────────
576
+ /**
577
+ * Radial zoom blur with chromatic aberration. Both frames are blurred along a
578
+ * radial direction from center using 12 samples. R/G/B channels use slightly
579
+ * different zoom factors (1.06, 1.0, 0.94) for chromatic aberration. The
580
+ * outgoing frame zooms inward while the incoming zooms outward.
581
+ * Port of the GLSL cinematic-zoom shader.
582
+ */
583
+ export const cinematicZoom = (from, to, out, w, h, p) => {
584
+ const fromS = p * 0.08;
585
+ const toS = (1 - p) * 0.06;
586
+ for (let i = 0; i < w * h; i++) {
587
+ const ux = (i % w) / w;
588
+ const uy = Math.floor(i / w) / h;
589
+ const o = i * 6;
590
+ const dx = ux - 0.5;
591
+ const dy = uy - 0.5;
592
+ let fr = 0, fg = 0, fb = 0;
593
+ for (let s = 0; s < 12; s++) {
594
+ const f = s / 12;
595
+ const rr = sampleRgb48le(from, ux - dx * fromS * 1.06 * f, uy - dy * fromS * 1.06 * f, w, h)[0];
596
+ const gg = sampleRgb48le(from, ux - dx * fromS * f, uy - dy * fromS * f, w, h)[1];
597
+ const bb = sampleRgb48le(from, ux - dx * fromS * 0.94 * f, uy - dy * fromS * 0.94 * f, w, h)[2];
598
+ fr += rr;
599
+ fg += gg;
600
+ fb += bb;
601
+ }
602
+ fr /= 12;
603
+ fg /= 12;
604
+ fb /= 12;
605
+ let tr = 0, tg = 0, tb = 0;
606
+ for (let s = 0; s < 12; s++) {
607
+ const f = s / 12;
608
+ const rr = sampleRgb48le(to, ux + dx * toS * 1.06 * f, uy + dy * toS * 1.06 * f, w, h)[0];
609
+ const gg = sampleRgb48le(to, ux + dx * toS * f, uy + dy * toS * f, w, h)[1];
610
+ const bb = sampleRgb48le(to, ux + dx * toS * 0.94 * f, uy + dy * toS * 0.94 * f, w, h)[2];
611
+ tr += rr;
612
+ tg += gg;
613
+ tb += bb;
614
+ }
615
+ tr /= 12;
616
+ tg /= 12;
617
+ tb /= 12;
618
+ out.writeUInt16LE(clamp16(mix16(Math.round(fr), Math.round(tr), p)), o);
619
+ out.writeUInt16LE(clamp16(mix16(Math.round(fg), Math.round(tg), p)), o + 2);
620
+ out.writeUInt16LE(clamp16(mix16(Math.round(fb), Math.round(tb), p)), o + 4);
621
+ }
622
+ };
623
+ TRANSITIONS["cinematic-zoom"] = cinematicZoom;
624
+ // ── gravitational-lens ────────────────────────────────────────────────────────
625
+ /**
626
+ * Radial warp toward center simulating a gravitational lens effect. The
627
+ * outgoing frame is warped with chromatic separation, masked by a horizon
628
+ * that depends on distance from center. Mixed to the incoming frame using
629
+ * smoothstep(0.3, 0.9, progress). Port of the GLSL gravitational-lens shader.
630
+ */
631
+ export const gravitationalLens = (from, to, out, w, h, p) => {
632
+ for (let i = 0; i < w * h; i++) {
633
+ const ux = (i % w) / w;
634
+ const uy = Math.floor(i / w) / h;
635
+ const o = i * 6;
636
+ const uvx = ux - 0.5;
637
+ const uvy = uy - 0.5;
638
+ const dist = Math.sqrt(uvx * uvx + uvy * uvy);
639
+ const pull = p * 2;
640
+ const warpStr = (pull * 0.3) / (dist + 0.1);
641
+ const warpedX = Math.max(0, Math.min(1, ux - uvx * warpStr));
642
+ const warpedY = Math.max(0, Math.min(1, uy - uvy * warpStr));
643
+ const [, ag] = sampleRgb48le(from, warpedX, warpedY, w, h);
644
+ const horizon = smoothstep(0, 0.3, dist / (1 - p * 0.85 + 0.001));
645
+ const shift = (pull * 0.02) / (dist + 0.2);
646
+ const rSampX = Math.max(0, Math.min(1, ux - uvx * (warpStr + shift)));
647
+ const rSampY = Math.max(0, Math.min(1, uy - uvy * (warpStr + shift)));
648
+ const bSampX = Math.max(0, Math.min(1, ux - uvx * (warpStr - shift)));
649
+ const bSampY = Math.max(0, Math.min(1, uy - uvy * (warpStr - shift)));
650
+ const ar = sampleRgb48le(from, rSampX, rSampY, w, h)[0];
651
+ const ab = sampleRgb48le(from, bSampX, bSampY, w, h)[2];
652
+ const lensedR = Math.round(ar * horizon);
653
+ const lensedG = Math.round(ag * horizon);
654
+ const lensedB = Math.round(ab * horizon);
655
+ const [toR, toG, toB] = sampleRgb48le(to, ux, uy, w, h);
656
+ const blend = smoothstep(0.3, 0.9, p);
657
+ out.writeUInt16LE(clamp16(mix16(lensedR, toR, blend)), o);
658
+ out.writeUInt16LE(clamp16(mix16(lensedG, toG, blend)), o + 2);
659
+ out.writeUInt16LE(clamp16(mix16(lensedB, toB, blend)), o + 4);
660
+ }
661
+ };
662
+ TRANSITIONS["gravitational-lens"] = gravitationalLens;
663
+ // ── ripple-waves ──────────────────────────────────────────────────────────────
664
+ /**
665
+ * Concentric wave distortion. Exponential wave functions create rings radiating
666
+ * outward from center. Both frames are distorted — the outgoing with progress-
667
+ * scaled amplitude, the incoming with (1-progress)-scaled amplitude. A warm
668
+ * accent tint highlights wave peaks. Port of the GLSL ripple-waves shader.
669
+ */
670
+ export const rippleWaves = (from, to, out, w, h, p) => {
671
+ // Accent bright color (16-bit)
672
+ const accentBright = [65535, 55000, 35000];
673
+ for (let i = 0; i < w * h; i++) {
674
+ const ux = (i % w) / w;
675
+ const uy = Math.floor(i / w) / h;
676
+ const o = i * 6;
677
+ const uvx = ux - 0.5;
678
+ const uvy = uy - 0.5;
679
+ const dist = Math.sqrt(uvx * uvx + uvy * uvy);
680
+ // Normalize direction from center, offset by small amount to avoid div-by-zero
681
+ const nux = uvx + 0.001;
682
+ const nuy = uvy + 0.001;
683
+ const nlen = Math.sqrt(nux * nux + nuy * nuy);
684
+ const dirx = nux / nlen;
685
+ const diry = nuy / nlen;
686
+ // From frame: waves moving outward (positive phase)
687
+ const fromAmp = p * 0.04;
688
+ const fw1 = Math.exp(Math.sin(dist * 25 - p * 12) - 1);
689
+ const fw2 = Math.exp(Math.sin(dist * 50 - p * 18) - 1) * 0.5;
690
+ const fromUx = Math.max(0, Math.min(1, ux + dirx * (fw1 + fw2) * fromAmp));
691
+ const fromUy = Math.max(0, Math.min(1, uy + diry * (fw1 + fw2) * fromAmp));
692
+ // To frame: waves moving inward (reversed phase)
693
+ const toAmp = (1 - p) * 0.04;
694
+ const tw1 = Math.exp(Math.sin(dist * 25 + p * 12) - 1);
695
+ const tw2 = Math.exp(Math.sin(dist * 50 + p * 18) - 1) * 0.5;
696
+ const toUx = Math.max(0, Math.min(1, ux - dirx * (tw1 + tw2) * toAmp));
697
+ const toUy = Math.max(0, Math.min(1, uy - diry * (tw1 + tw2) * toAmp));
698
+ const [fromR, fromG, fromB] = sampleRgb48le(from, fromUx, fromUy, w, h);
699
+ const [toR, toG, toB] = sampleRgb48le(to, toUx, toUy, w, h);
700
+ const peak = fw1 * p;
701
+ const tintR = accentBright[0] * peak * 0.1;
702
+ const tintG = accentBright[1] * peak * 0.1;
703
+ const tintB = accentBright[2] * peak * 0.1;
704
+ out.writeUInt16LE(clamp16(mix16(Math.round(fromR + tintR), toR, p)), o);
705
+ out.writeUInt16LE(clamp16(mix16(Math.round(fromG + tintG), toG, p)), o + 2);
706
+ out.writeUInt16LE(clamp16(mix16(Math.round(fromB + tintB), toB, p)), o + 4);
707
+ }
708
+ };
709
+ TRANSITIONS["ripple-waves"] = rippleWaves;
710
+ // ── swirl-vortex ──────────────────────────────────────────────────────────────
711
+ /**
712
+ * Rotational UV warp. The outgoing frame is rotated clockwise and the incoming
713
+ * counter-clockwise. Rotation angle depends on distance from center and FBM
714
+ * warp noise. Port of the GLSL swirl-vortex shader.
715
+ */
716
+ export const swirlVortex = (from, to, out, w, h, p) => {
717
+ for (let i = 0; i < w * h; i++) {
718
+ const ux = (i % w) / w;
719
+ const uy = Math.floor(i / w) / h;
720
+ const o = i * 6;
721
+ const uvx = ux - 0.5;
722
+ const uvy = uy - 0.5;
723
+ const dist = Math.sqrt(uvx * uvx + uvy * uvy);
724
+ const warp = fbm(ux * 4, uy * 4) * 0.5;
725
+ const fromAng = p * (1 - dist) * 10 + warp * p * 3;
726
+ const fs = Math.sin(fromAng);
727
+ const fc = Math.cos(fromAng);
728
+ const fromUx = Math.max(0, Math.min(1, uvx * fc - uvy * fs + 0.5));
729
+ const fromUy = Math.max(0, Math.min(1, uvx * fs + uvy * fc + 0.5));
730
+ const toAng = -(1 - p) * (1 - dist) * 10 - warp * (1 - p) * 3;
731
+ const ts = Math.sin(toAng);
732
+ const tc = Math.cos(toAng);
733
+ const toUx = Math.max(0, Math.min(1, uvx * tc - uvy * ts + 0.5));
734
+ const toUy = Math.max(0, Math.min(1, uvx * ts + uvy * tc + 0.5));
735
+ const [fromR, fromG, fromB] = sampleRgb48le(from, fromUx, fromUy, w, h);
736
+ const [toR, toG, toB] = sampleRgb48le(to, toUx, toUy, w, h);
737
+ out.writeUInt16LE(clamp16(mix16(fromR, toR, p)), o);
738
+ out.writeUInt16LE(clamp16(mix16(fromG, toG, p)), o + 2);
739
+ out.writeUInt16LE(clamp16(mix16(fromB, toB, p)), o + 4);
740
+ }
741
+ };
742
+ TRANSITIONS["swirl-vortex"] = swirlVortex;
743
+ // ── thermal-distortion ────────────────────────────────────────────────────────
744
+ /**
745
+ * Heat shimmer effect. Horizontal displacement based on a sin wave modulated by
746
+ * FBM noise, fading toward the top of the screen. Both frames are displaced
747
+ * independently, and a warm haze overlay fades as progress advances.
748
+ * Port of the GLSL thermal-distortion shader.
749
+ */
750
+ export const thermalDistortion = (from, to, out, w, h, p) => {
751
+ // Accent bright color (16-bit)
752
+ const accentBright = [65535, 55000, 35000];
753
+ for (let i = 0; i < w * h; i++) {
754
+ const ux = (i % w) / w;
755
+ const uy = Math.floor(i / w) / h;
756
+ const o = i * 6;
757
+ const heat = p * 1.5;
758
+ const yFade = smoothstep(1, 0, uy);
759
+ // From frame shimmer: fbm(uv*6) modulates sin wave
760
+ const shimmer = Math.sin(uy * 40 + fbm(ux * 6, uy * 6) * 8) * fbm(ux * 3 + 0, uy * 3 + p * 2);
761
+ const dispX = shimmer * heat * 0.03 * yFade;
762
+ const fromUx = Math.max(0, Math.min(1, ux + dispX));
763
+ const [fromR, fromG, fromB] = sampleRgb48le(from, fromUx, uy, w, h);
764
+ // To frame shimmer: different FBM seed (offset by 3)
765
+ const invShimmer = Math.sin(uy * 40 + fbm(ux * 6 + 3, uy * 6 + 3) * 8) * fbm(ux * 3 + 3, uy * 3 + p * 2);
766
+ const dispX2 = invShimmer * (1 - p) * 0.03 * yFade;
767
+ const toUx = Math.max(0, Math.min(1, ux + dispX2));
768
+ const [toR, toG, toB] = sampleRgb48le(to, toUx, uy, w, h);
769
+ const haze = heat * yFade * 0.15 * (1 - p);
770
+ out.writeUInt16LE(clamp16(mix16(fromR, toR, p) + Math.round(accentBright[0] * haze)), o);
771
+ out.writeUInt16LE(clamp16(mix16(fromG, toG, p) + Math.round(accentBright[1] * haze)), o + 2);
772
+ out.writeUInt16LE(clamp16(mix16(fromB, toB, p) + Math.round(accentBright[2] * haze)), o + 4);
773
+ }
774
+ };
775
+ TRANSITIONS["thermal-distortion"] = thermalDistortion;
776
+ // ── domain-warp ───────────────────────────────────────────────────────────────
777
+ /**
778
+ * FBM-driven UV warp with edge glow. Computes two layers of FBM (q and r) to
779
+ * derive a warp direction. Both frames are displaced in opposite directions.
780
+ * An edge-detection glow appears at the transition boundary.
781
+ * Port of the GLSL domain-warp shader. Note: mix(B, A, e) ordering — e=1 shows A (from).
782
+ */
783
+ export const domainWarp = (from, to, out, w, h, p) => {
784
+ // Accent colors (16-bit)
785
+ const accentDark = [25000, 8000, 2000];
786
+ const accentBright = [65535, 55000, 35000];
787
+ for (let i = 0; i < w * h; i++) {
788
+ const ux = (i % w) / w;
789
+ const uy = Math.floor(i / w) / h;
790
+ const o = i * 6;
791
+ // Two-layer domain warp: q, then r
792
+ const qx = fbm(ux * 3, uy * 3);
793
+ const qy = fbm(ux * 3 + 5.2, uy * 3 + 1.3);
794
+ const rx = fbm(ux * 3 + qx * 4 + 1.7, uy * 3 + qy * 4 + 9.2);
795
+ const ry = fbm(ux * 3 + qx * 4 + 8.3, uy * 3 + qy * 4 + 2.8);
796
+ const n = fbm(ux * 3 + rx * 2, uy * 3 + ry * 2);
797
+ const warpDirX = (qx - 0.5) * 0.4;
798
+ const warpDirY = (qy - 0.5) * 0.4;
799
+ const aUx = Math.max(0, Math.min(1, ux + warpDirX * p));
800
+ const aUy = Math.max(0, Math.min(1, uy + warpDirY * p));
801
+ const bUx = Math.max(0, Math.min(1, ux - warpDirX * (1 - p)));
802
+ const bUy = Math.max(0, Math.min(1, uy - warpDirY * (1 - p)));
803
+ const [aR, aG, aB] = sampleRgb48le(from, aUx, aUy, w, h);
804
+ const [bR, bG, bB] = sampleRgb48le(to, bUx, bUy, w, h);
805
+ // e=1 → show A (from), e=0 → show B (to): mix(B, A, e)
806
+ const e = smoothstep(p - 0.08, p + 0.08, n);
807
+ const ed = Math.abs(n - p);
808
+ // step(1, p) = p >= 1 ? 1 : 0 → suppress glow at p=1
809
+ const pStep = p >= 1 ? 1 : 0;
810
+ const em = smoothstep(0.1, 0, ed) * (1 - pStep);
811
+ // Edge color: mix accent_dark → accent_bright based on edge proximity
812
+ const ecBlend = smoothstep(0, 0.1, ed);
813
+ const ecR = accentDark[0] + (accentBright[0] - accentDark[0]) * (1 - ecBlend);
814
+ const ecG = accentDark[1] + (accentBright[1] - accentDark[1]) * (1 - ecBlend);
815
+ const ecB = accentDark[2] + (accentBright[2] - accentDark[2]) * (1 - ecBlend);
816
+ // mix(B, A, e) + edge glow
817
+ out.writeUInt16LE(clamp16(mix16(bR, aR, e) + Math.round(ecR * em * 2)), o);
818
+ out.writeUInt16LE(clamp16(mix16(bG, aG, e) + Math.round(ecG * em * 2)), o + 2);
819
+ out.writeUInt16LE(clamp16(mix16(bB, aB, e) + Math.round(ecB * em * 2)), o + 4);
820
+ }
821
+ };
822
+ TRANSITIONS["domain-warp"] = domainWarp;
823
+ // ── ridged-burn ───────────────────────────────────────────────────────────────
824
+ /**
825
+ * Ridged noise threshold transition with heat glow and sparks. Uses a custom
826
+ * ridged noise function (5 octaves of abs(vnoise*2 - 1)) to create a
827
+ * burning-paper effect. Accent colors glow at the burn boundary. Sparks
828
+ * appear from high-frequency noise.
829
+ * Port of the GLSL ridged-burn shader. Note: mix(B, A, e) ordering — e=1 shows A (from).
830
+ */
831
+ /**
832
+ * Ridged noise: 5 octaves of abs(vnoise*2 - 1) with the same rotation and
833
+ * scaling as fbm. Returns values in [0, ~0.97].
834
+ */
835
+ function ridged(px, py) {
836
+ let value = 0;
837
+ let amplitude = 0.5;
838
+ let x = px;
839
+ let y = py;
840
+ for (let i = 0; i < 5; i++) {
841
+ value += amplitude * Math.abs(vnoise(x, y) * 2 - 1);
842
+ const nx = ROT_A * x - ROT_B * y;
843
+ const ny = ROT_B * x + ROT_A * y;
844
+ x = nx * 2.02;
845
+ y = ny * 2.02;
846
+ amplitude *= 0.5;
847
+ }
848
+ return value;
849
+ }
850
+ export const ridgedBurn = (from, to, out, w, h, p) => {
851
+ // Accent colors (16-bit)
852
+ const accent = [50000, 25000, 5000];
853
+ const accentDark = [25000, 8000, 2000];
854
+ const accentBright = [65535, 55000, 35000];
855
+ for (let i = 0; i < w * h; i++) {
856
+ const ux = (i % w) / w;
857
+ const uy = Math.floor(i / w) / h;
858
+ const o = i * 6;
859
+ const [aR, aG, aB] = sampleRgb48le(from, ux, uy, w, h);
860
+ const [bR, bG, bB] = sampleRgb48le(to, ux, uy, w, h);
861
+ const n = ridged(ux * 4, uy * 4);
862
+ // e=1 → show A (from), e=0 → show B (to): mix(B, A, e)
863
+ const e = smoothstep(p - 0.04, p + 0.04, n);
864
+ const heat = smoothstep(0.12, 0, Math.abs(n - p));
865
+ // step(1, p) = p >= 1 ? 1 : 0 → suppress glow at p=1
866
+ const pStep = p >= 1 ? 1 : 0;
867
+ const heatMasked = heat * (1 - pStep);
868
+ // Burn color gradient: dark → accent → accent_bright → white
869
+ let burnR = accentDark[0] + (accent[0] - accentDark[0]) * smoothstep(0, 0.25, heatMasked);
870
+ let burnG = accentDark[1] + (accent[1] - accentDark[1]) * smoothstep(0, 0.25, heatMasked);
871
+ let burnB = accentDark[2] + (accent[2] - accentDark[2]) * smoothstep(0, 0.25, heatMasked);
872
+ const blend2 = smoothstep(0.25, 0.5, heatMasked);
873
+ burnR = burnR + (accentBright[0] - burnR) * blend2;
874
+ burnG = burnG + (accentBright[1] - burnG) * blend2;
875
+ burnB = burnB + (accentBright[2] - burnB) * blend2;
876
+ const blend3 = smoothstep(0.5, 1, heatMasked);
877
+ burnR = burnR + (65535 - burnR) * blend3;
878
+ burnG = burnG + (65535 - burnG) * blend3;
879
+ burnB = burnB + (65535 - burnB) * blend3;
880
+ // Sparks: high-frequency noise above threshold
881
+ const sparks = (vnoise(ux * 80, uy * 80) >= 0.92 ? 1 : 0) * heatMasked * 3;
882
+ out.writeUInt16LE(clamp16(mix16(bR, aR, e) +
883
+ Math.round(burnR * heatMasked * 3.5) +
884
+ Math.round(accentBright[0] * sparks)), o);
885
+ out.writeUInt16LE(clamp16(mix16(bG, aG, e) +
886
+ Math.round(burnG * heatMasked * 3.5) +
887
+ Math.round(accentBright[1] * sparks)), o + 2);
888
+ out.writeUInt16LE(clamp16(mix16(bB, aB, e) +
889
+ Math.round(burnB * heatMasked * 3.5) +
890
+ Math.round(accentBright[2] * sparks)), o + 4);
891
+ }
892
+ };
893
+ TRANSITIONS["ridged-burn"] = ridgedBurn;
894
+ //# sourceMappingURL=shaderTransitions.js.map