@hyperframes/engine 0.6.119 → 0.6.121

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