pyvisualfx 0.1.0b1__py3-none-any.whl

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.
@@ -0,0 +1,2421 @@
1
+ /**
2
+ * visualfx.js: effects for a scene. Draw some shapes, then turn one on.
3
+ *
4
+ * const { kaleidoscope, glow, trails } = await use("visualfx.js") (a pictures.js scene)
5
+ * import { kaleidoscope, glow, trails } from "./visualfx.js" (any page with one canvas)
6
+ *
7
+ * kaleidoscope(8) on, eight segments
8
+ * glow(0.8) bright things shine
9
+ * const trail = trails(0.5, { zoom: 1.5, spin: 20 })
10
+ * trail.spin = 40 a setting is a plain property: change it any time
11
+ * glowing.strength = () => 0.3 + bass() or give it a function, asked every frame
12
+ *
13
+ * EFFECTS APPLY IN THE ORDER THEY ARE TURNED ON, to everything the scene has drawn, 2D or 3D. Each has
14
+ * `off()`, `on()` and `remove()`, and `amount` (0 to 1: how much of it is mixed in). `clearEffects()`
15
+ * takes them all away. `look("dream")` is several at once.
16
+ *
17
+ * THE GRAPHICS CARD DOES THE WORK. The scene's canvas is handed to it as a picture once a frame, each
18
+ * effect is a small program run over every pixel, and the result is shown on a canvas of its own laid
19
+ * exactly over the scene's. A scene with no effect on has no such canvas and costs nothing.
20
+ *
21
+ * THE SAME FRAMES EVERY TIME. An effect sees only the picture, its own last frame (trails), its settings
22
+ * and the scene's clock: never the time of day. Grain and glitches come from a seed. So a scene stepped
23
+ * a frame at a time by a video recorder gives exactly the picture it would have given.
24
+ *
25
+ * MUSIC: `listenTo(soundOutput())` (or a synth, a song, the microphone: anything with `samples()`), then
26
+ * `bass()`, `mids()`, `highs()`, `loudness()` and `pulse()` are numbers from 0 to 1 to use anywhere.
27
+ * `react(glowing, "strength", "bass", { from: 0.3, to: 1.2 })` ties a setting to one in a line.
28
+ *
29
+ * YOUR OWN: `shader("vec4 effect(vec2 uv) { return texture(picture, uv).bgra; }")`. See the README.
30
+ *
31
+ * An effect is also something a dial can turn (dials.js): it has `set`, `get` and `describe`.
32
+ * The Python spellings (`zoom_blur`, `clear_effects`) are understood wherever a name is looked for.
33
+ *
34
+ * MIT licence.
35
+ */
36
+
37
+ const version = '0.0.0'
38
+
39
+ const LAYER = Symbol.for('effects.pipeline')
40
+ let spare = null // the last scene's context and programs, for the next
41
+ const clamp = (v, a, b) => (v < a ? a : v > b ? b : v)
42
+ const isPlain = (v) => v != null && typeof v === 'object' && Object.getPrototypeOf(v) === Object.prototype
43
+ const camel = (name) => String(name).replace(/_([a-z])/g, (_, c) => c.toUpperCase())
44
+ /** A spec's keys in one spelling: zoom_step is zoomStep, color is colour. */
45
+ const SPELLING = { color: 'colour', colors: 'colours' }
46
+ const spelled = (spec) => {
47
+ const out = {}
48
+ for (const key in spec) { const k = camel(key); out[SPELLING[k] || k] = spec[key] }
49
+ return out
50
+ }
51
+
52
+ // --- colours: anything the page understands ("gold", "#f80", "rgb(…)"), as three numbers from 0 to 1
53
+ const colours = new Map()
54
+ let swatch = null
55
+ function rgb(colour) {
56
+ if (Array.isArray(colour) || ArrayBuffer.isView(colour)) return colour.length >= 3 && colour[0] <= 1 && colour[1] <= 1 && colour[2] <= 1 ? [colour[0], colour[1], colour[2]] : [colour[0] / 255, colour[1] / 255, colour[2] / 255]
57
+ let found = colours.get(colour)
58
+ if (!found) {
59
+ swatch ||= Object.assign(document.createElement('canvas'), { width: 1, height: 1 }).getContext('2d', { willReadFrequently: true })
60
+ swatch.fillStyle = '#000'
61
+ swatch.fillStyle = String(colour)
62
+ const said = swatch.fillStyle
63
+ swatch.fillStyle = '#fff'
64
+ swatch.fillStyle = String(colour)
65
+ // A colour the page does not know leaves the last one in place: black one time and white the next.
66
+ if (said !== swatch.fillStyle) throw new Error(`"${colour}" is not a colour this page knows. Try a name ("gold", "deepskyblue") or a code ("#ff8800")`)
67
+ swatch.clearRect(0, 0, 1, 1)
68
+ swatch.fillRect(0, 0, 1, 1)
69
+ const [r, g, b] = swatch.getImageData(0, 0, 1, 1).data
70
+ found = [r / 255, g / 255, b / 255]
71
+ colours.set(colour, found)
72
+ }
73
+ return found
74
+ }
75
+
76
+ /** Palettes by name: a few colours a number runs through, round and round. A list of colours is one too. */
77
+ const PALETTES = {
78
+ rainbow: ['#ff2a2a', '#ffd92a', '#3cff5a', '#2ad4ff', '#5a3cff', '#ff2ad4'],
79
+ fire: ['#000000', '#5a0a00', '#d43a00', '#ff9a1a', '#ffe98a', '#5a0a00'],
80
+ ocean: ['#02102a', '#0a3a7a', '#1a8ad4', '#6ae0e0', '#e8fff8', '#0a3a7a'],
81
+ sunset: ['#1a0a3a', '#6a1a7a', '#e0407a', '#ff8a3a', '#ffd86a', '#6a1a7a'],
82
+ neon: ['#0a001a', '#ff1ad9', '#1affea', '#f6ff1a', '#7a1aff', '#1affea'],
83
+ candy: ['#ff8ad4', '#ffd0e8', '#8ad4ff', '#b8ffd0', '#fff0a0', '#d0a0ff'],
84
+ forest: ['#04140a', '#0e4a22', '#3a8a2a', '#a8d44a', '#f0f0a0', '#0e4a22'],
85
+ ice: ['#020818', '#103a6a', '#4a9ae0', '#b8e8ff', '#ffffff', '#4a9ae0'],
86
+ lava: ['#1a0000', '#8a0a00', '#ff3a00', '#ff9a00', '#ffe27a', '#ff5a00'],
87
+ mono: ['#000000', '#ffffff'],
88
+ thermal: ['#000000', '#20008c', '#cc0077', '#ff3000', '#ffd000', '#ffffff'],
89
+ aurora: ['#00ff9a', '#00b0ff', '#8a2be2', '#00ff9a'],
90
+ gameboy: ['#0f380f', '#306230', '#8bac0f', '#9bbc0f'],
91
+ c64: ['#000000', '#352879', '#6c5eb5', '#9a6759', '#b8c76f', '#ffffff'],
92
+ amiga: ['#000022', '#0055aa', '#ff8800', '#ffffff'],
93
+ pico: ['#1d2b53', '#7e2553', '#ff004d', '#ffa300', '#29adff', '#fff1e8'],
94
+ }
95
+ const palettes = new Map()
96
+ function paletteOf(value) {
97
+ const key = Array.isArray(value) ? value.join('|') : String(value)
98
+ let found = palettes.get(key)
99
+ if (!found) {
100
+ const list = Array.isArray(value) ? value : PALETTES[String(value).toLowerCase()]
101
+ if (!list || !list.length) throw new Error(`There is no palette called "${value}". There are ${Object.keys(PALETTES).join(', ')}, or give a list of colours: ["navy", "magenta", "gold"]`)
102
+ const data = new Float32Array(18)
103
+ const n = Math.min(6, list.length)
104
+ for (let i = 0; i < 6; i++) data.set(rgb(list[Math.min(i, n - 1)]), i * 3)
105
+ found = { data, count: n }
106
+ palettes.set(key, found)
107
+ }
108
+ return found
109
+ }
110
+
111
+ // --------------------------------------------------------------------------------------------
112
+ // The programs. Every effect is written as `vec4 effect(vec2 uv)`: the colour at one place of the
113
+ // picture, where uv runs from (0, 0) at the top left to (1, 1) at the bottom right. These are the
114
+ // names it can use, and they are the names a shader of your own is given (see `shader`).
115
+ const VERTEX = `#version 300 es
116
+ out vec2 uv;
117
+ uniform float flip;
118
+ void main() {
119
+ vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
120
+ uv = vec2(p.x, flip > 0.5 ? 1.0 - p.y : p.y);
121
+ gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
122
+ }`
123
+ const HEAD = `#version 300 es
124
+ precision highp float;
125
+ precision highp int;
126
+ in vec2 uv;
127
+ out vec4 colour_out;
128
+ uniform sampler2D picture; // what this effect is given: the scene, after the effects before it
129
+ uniform sampler2D previous; // what this effect made last frame (black on the first)
130
+ uniform sampler2D spectrum; // the sound, low notes at the left: texture(spectrum, vec2(x, 0.5)).r
131
+ uniform sampler2D waveform; // the sound's wave: texture(waveform, vec2(x, 0.5)).r, 0.5 is silence
132
+ uniform vec2 resolution; // the picture's size in real pixels
133
+ uniform vec2 size; // the picture's size in the scene's points (what width and height are)
134
+ uniform float time; // seconds on the scene's clock
135
+ uniform float dt; // seconds since the last frame
136
+ uniform float frame; // frames so far
137
+ uniform float amount; // this effect's amount, 0 to 1
138
+ uniform float seed;
139
+ uniform float bass, mids, highs, loudness, pulse; // the music, 0 to 1 (0 when nothing is listened to)
140
+ uniform vec3 pal[6];
141
+ uniform float palCount;
142
+ const float TAU = 6.283185307179586;
143
+ float luma(vec3 c) { return dot(c, vec3(0.299, 0.587, 0.114)); }
144
+ vec2 turn(vec2 p, float a) { float c = cos(a), s = sin(a); return vec2(c * p.x - s * p.y, s * p.x + c * p.y); }
145
+ // Whole-number hashing: the same on every graphics card, which a sine of a big number is not.
146
+ uint scramble(uint x) { x ^= x >> 16; x *= 0x7feb352dU; x ^= x >> 15; x *= 0x846ca68bU; x ^= x >> 16; return x; }
147
+ float hash(vec2 p) { ivec2 q = ivec2(floor(p)); return float(scramble(uint(q.x) ^ scramble(uint(q.y) + 0x9e3779b9U)) >> 8) / 16777216.0; }
148
+ float hash(vec3 p) { ivec3 q = ivec3(floor(p)); return float(scramble(uint(q.x) ^ scramble(uint(q.y) + 0x9e3779b9U ^ scramble(uint(q.z) + 0x85ebca6bU))) >> 8) / 16777216.0; }
149
+ float noise(vec2 p) {
150
+ vec2 i = floor(p), f = fract(p);
151
+ f = f * f * (3.0 - 2.0 * f);
152
+ return mix(mix(hash(i), hash(i + vec2(1, 0)), f.x), mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), f.x), f.y);
153
+ }
154
+ float fbm(vec2 p) { float v = 0.0, a = 0.5; for (int i = 0; i < 4; i++) { v += a * noise(p); p = p * 2.03 + 17.0; a *= 0.5; } return v; }
155
+ // A colour's hue turned by an angle: a turn about the line of greys.
156
+ vec3 hueTurn(vec3 c, float a) { const vec3 k = vec3(0.57735026919); float cs = cos(a), sn = sin(a); return c * cs + cross(k, c) * sn + k * dot(k, c) * (1.0 - cs); }
157
+ vec3 palette(float t) {
158
+ float x = fract(t) * palCount;
159
+ int i = int(floor(x));
160
+ float f = x - floor(x);
161
+ return mix(pal[i], pal[(i + 1) % int(palCount)], f * f * (3.0 - 2.0 * f));
162
+ }
163
+ // A palette from its first colour to its last, once, without coming round again.
164
+ vec3 ramp(float t) { return palette(clamp(t, 0.0, 0.999) * (palCount - 1.0) / palCount); }
165
+ // Cells, as in a honeycomb drawn by hand: xy is the nearest cell's own point, z how far this place is from the cell's edge.
166
+ vec3 cells(vec2 p) {
167
+ vec2 i = floor(p), best = i;
168
+ float d1 = 9.0, d2 = 9.0;
169
+ for (int y = -1; y <= 1; y++) for (int x = -1; x <= 1; x++) {
170
+ vec2 g = i + vec2(x, y), o = g + vec2(hash(g), hash(g + vec2(37.0, 91.0)));
171
+ float d = distance(p, o);
172
+ if (d < d1) { d2 = d1; d1 = d; best = o; } else if (d < d2) d2 = d;
173
+ }
174
+ return vec3(best, d2 - d1);
175
+ }
176
+ float most(vec3 c) { return max(c.r, max(c.g, c.b)); }
177
+ // Off the edge of the picture it is seen again, mirrored: no seams and no stretched borders.
178
+ vec2 mirrored(vec2 q) { return 1.0 - abs(mod(q, 2.0) - 1.0); }
179
+ vec4 look(vec2 q) { return texture(picture, mirrored(q)); }
180
+ `
181
+ const MAIN = `
182
+ void main() {
183
+ vec4 c = effect(uv);
184
+ if (amount < 1.0) c = mix(texture(picture, uv), c, amount);
185
+ colour_out = vec4(clamp(c.rgb, 0.0, 1.0), 1.0);
186
+ }`
187
+ // A backdrop is behind what the scene drew: it shows wherever the scene left the picture clear.
188
+ const MAIN_BACKDROP = `
189
+ void main() {
190
+ vec4 s = texture(picture, uv);
191
+ colour_out = vec4(s.rgb + clamp(backdrop(uv), 0.0, 1.0) * amount * (1.0 - s.a), 1.0);
192
+ }`
193
+
194
+ const COPY = `void main() { colour_out = vec4(texture(picture, uv).rgb, 1.0); }`
195
+ // Two pictures mixed, with a grain too fine to see that stops smooth shades showing as bands.
196
+ const BLEND = `uniform sampler2D other; uniform float add;
197
+ void main() {
198
+ vec3 a = texture(picture, uv).rgb, b = texture(other, uv).rgb;
199
+ vec3 c = add > 0.0 ? a + b * add * amount : mix(a, b, amount);
200
+ colour_out = vec4(c + (hash(uv * resolution) - 0.5) / 255.0, 1.0);
201
+ }`
202
+ const GAUSS = `uniform vec2 dir; uniform float sigma;
203
+ void main() {
204
+ vec3 sum = texture(picture, uv).rgb;
205
+ float total = 1.0, k = 0.5 / (sigma * sigma);
206
+ for (int i = 0; i < 5; i++) {
207
+ float a = float(2 * i + 1), b = a + 1.0;
208
+ float wa = exp(-a * a * k), wb = exp(-b * b * k), w = wa + wb;
209
+ if (w < 1e-5) break;
210
+ vec2 o = dir * ((a * wa + b * wb) / w);
211
+ sum += (texture(picture, uv + o).rgb + texture(picture, uv - o).rgb) * w;
212
+ total += 2.0 * w;
213
+ }
214
+ colour_out = vec4(sum / total, 1.0);
215
+ }`
216
+ // What is bright enough to glow, at half size. Each of the four pixels that become one is judged alone, so a
217
+ // small bright thing counts for what it is and does not flicker as it crosses pixels.
218
+ const BRIGHT = `uniform float threshold; uniform vec2 texel;
219
+ vec3 lit(vec2 q) { vec3 c = texture(picture, q).rgb; float l = max(c.r, max(c.g, c.b)); return c * smoothstep(threshold - 0.08, threshold + 0.12, l); }
220
+ void main() { colour_out = vec4((lit(uv + texel * vec2(-1, -1)) + lit(uv + texel * vec2(1, -1)) + lit(uv + texel * vec2(-1, 1)) + lit(uv + texel * vec2(1, 1))) * 0.25, 1.0); }`
221
+ const SHRINK = `uniform vec2 texel;
222
+ void main() {
223
+ vec3 c = texture(picture, uv).rgb * 4.0;
224
+ c += texture(picture, uv + texel * vec2(-1, -1)).rgb + texture(picture, uv + texel * vec2(1, -1)).rgb;
225
+ c += texture(picture, uv + texel * vec2(-1, 1)).rgb + texture(picture, uv + texel * vec2(1, 1)).rgb;
226
+ colour_out = vec4(c / 8.0, 1.0);
227
+ }`
228
+ const GROW = `uniform sampler2D other; uniform vec2 texel; uniform float weight;
229
+ void main() {
230
+ vec3 c = texture(picture, uv + texel * vec2(-2, 0)).rgb + texture(picture, uv + texel * vec2(2, 0)).rgb;
231
+ c += texture(picture, uv + texel * vec2(0, -2)).rgb + texture(picture, uv + texel * vec2(0, 2)).rgb;
232
+ c += 2.0 * (texture(picture, uv + texel * vec2(-1, -1)).rgb + texture(picture, uv + texel * vec2(1, -1)).rgb);
233
+ c += 2.0 * (texture(picture, uv + texel * vec2(-1, 1)).rgb + texture(picture, uv + texel * vec2(1, 1)).rgb);
234
+ colour_out = vec4(c / 12.0 * weight + texture(other, uv).rgb, 1.0);
235
+ }`
236
+ // Trails: last frame's picture, moved a little and a little fainter, with this frame's on top.
237
+ const TRAIL = `uniform float keep, grow, spun, hued, cut; uniform vec2 centre, moved;
238
+ void main() {
239
+ vec2 q = (turn(uv * size - centre, -spun) / grow + centre - moved) / size;
240
+ vec3 old = vec3(0.0);
241
+ if (q.x >= 0.0 && q.x <= 1.0 && q.y >= 0.0 && q.y <= 1.0) old = texture(previous, q).rgb;
242
+ if (hued != 0.0) old = hueTurn(old, hued);
243
+ old = max(old * keep - cut, 0.0);
244
+ colour_out = vec4(max(texture(picture, uv).rgb, old), 1.0);
245
+ }`
246
+
247
+ // --------------------------------------------------------------------------------------------
248
+ // The effects. `order` is what the plain arguments mean: kaleidoscope(8) is { segments: 8 }.
249
+ // A setting is [default, min, max] and what kind it is. Lengths are in the scene's points and angles in
250
+ // degrees, so a scene looks the same on any screen; x and y are a place in the scene (the middle if not given).
251
+ const DEFS = {}
252
+ const num = (value, min, max, more) => ({ kind: 'float', default: value, min, max, ...more })
253
+ const whole = (value, min, max) => ({ kind: 'int', default: value, min, max, step: 1 })
254
+ const angle = (value, min = -360, max = 360) => ({ kind: 'float', default: value, min, max, unit: 'degrees' })
255
+ const place = () => ({ kind: 'float', default: null, min: 0, max: 4000, place: true })
256
+ const flag = (value) => ({ kind: 'bool', default: value, min: 0, max: 1 })
257
+ const choice = (value, choices) => ({ kind: 'choice', default: value, choices })
258
+ const colour = (value) => ({ kind: 'colour', default: value })
259
+ const pal = (value) => ({ kind: 'palette', default: value })
260
+ const AMOUNT = num(1, 0, 1)
261
+ function define(name, order, settings, glsl, more) {
262
+ DEFS[name] = { name, order, settings: { ...settings, amount: AMOUNT }, glsl, ...more }
263
+ }
264
+ const PLACE = `vec2 c = vec2(x, y), p = uv * size - c;`
265
+
266
+ // --- shapes of the picture
267
+ define('kaleidoscope', ['segments'], { segments: whole(8, 2, 32), angle: angle(0), zoom: num(1, 0.2, 5), x: place(), y: place() }, `
268
+ vec4 effect(vec2 uv) {
269
+ ${PLACE}
270
+ float wedge = TAU / max(2.0, floor(segments + 0.5));
271
+ float a = mod(atan(p.y, p.x) - angle, wedge);
272
+ a = abs(a - wedge * 0.5) + angle;
273
+ return look((c + length(p) * vec2(cos(a), sin(a)) / zoom) / size);
274
+ }`)
275
+ define('mirror', ['side'], { side: choice('left', ['left', 'right', 'top', 'bottom', 'four']) }, `
276
+ vec4 effect(vec2 uv) {
277
+ vec2 q = uv;
278
+ if (side < 0.5) q.x = 0.5 - abs(q.x - 0.5);
279
+ else if (side < 1.5) q.x = 0.5 + abs(q.x - 0.5);
280
+ else if (side < 2.5) q.y = 0.5 - abs(q.y - 0.5);
281
+ else if (side < 3.5) q.y = 0.5 + abs(q.y - 0.5);
282
+ else q = 0.5 - abs(q - 0.5);
283
+ return texture(picture, q);
284
+ }`)
285
+ define('pixelate', ['size'], { size: num(10, 1, 100) }, `
286
+ vec4 effect(vec2 uv) {
287
+ float s = max(cell, size.x / resolution.x);
288
+ vec2 p = uv * size - size * 0.5;
289
+ return texture(picture, ((floor(p / s) + 0.5) * s + size * 0.5) / size);
290
+ }`, { rename: { size: 'cell' } })
291
+ define('waves', ['size'], { size: num(12, 0, 100), length: num(160, 10, 1000), speed: num(0.5, -5, 5) }, `
292
+ vec4 effect(vec2 uv) {
293
+ vec2 p = uv * size;
294
+ vec2 d = vec2(sin(TAU * (p.y / len + time * speed)), cos(TAU * (p.x / len + time * speed * 0.8)));
295
+ return look((p + d * big) / size);
296
+ }`, { rename: { size: 'big', length: 'len' } })
297
+ define('ripple', ['size'], { size: num(8, 0, 100), length: num(60, 10, 1000), speed: num(1, -5, 5), x: place(), y: place() }, `
298
+ vec4 effect(vec2 uv) {
299
+ ${PLACE}
300
+ float d = length(p);
301
+ return look((c + p + normalize(p + 1e-5) * big * sin(TAU * (d / len - time * speed))) / size);
302
+ }`, { rename: { size: 'big', length: 'len' } })
303
+ define('wobble', ['size'], { size: num(14, 0, 100), scale: num(120, 10, 1000), speed: num(0.4, -5, 5) }, `
304
+ vec4 effect(vec2 uv) {
305
+ vec2 p = uv * size, q = p / scale;
306
+ vec2 d = vec2(noise(q + vec2(time * speed, 3.1)), noise(q + vec2(17.3, -time * speed))) - 0.5;
307
+ return look((p + d * 2.0 * big) / size);
308
+ }`, { rename: { size: 'big' } })
309
+ define('swirl', ['angle'], { angle: angle(180, -1440, 1440), radius: num(0, 0, 2000), x: place(), y: place() }, `
310
+ vec4 effect(vec2 uv) {
311
+ ${PLACE}
312
+ float r = radius > 0.0 ? radius : min(size.x, size.y) * 0.5;
313
+ float f = max(0.0, 1.0 - length(p) / r);
314
+ return look((c + turn(p, angle * f * f)) / size);
315
+ }`)
316
+ define('fisheye', ['strength'], { strength: num(0.5, -1, 1), radius: num(0, 0, 2000), x: place(), y: place() }, `
317
+ vec4 effect(vec2 uv) {
318
+ ${PLACE}
319
+ float r = radius > 0.0 ? radius : length(size) * 0.5;
320
+ float d = length(p) / r;
321
+ if (d < 1.0) p *= 1.0 - strength * 0.75 * (1.0 - d * d);
322
+ return look((c + p) / size);
323
+ }`)
324
+ define('tunnel', ['speed'], { speed: num(0.4, -5, 5), repeat: whole(2, 1, 12), depth: num(0.5, 0.05, 4), x: place(), y: place() }, `
325
+ vec4 effect(vec2 uv) {
326
+ ${PLACE}
327
+ float m = min(size.x, size.y) * 0.5, d = max(length(p), 0.5);
328
+ vec2 st = vec2(atan(p.y, p.x) / TAU * repeat * 2.0, depth * m / d + time * speed);
329
+ return look(st) * smoothstep(0.0, 0.5, d / m);
330
+ }`)
331
+ define('tile', ['across'], { across: whole(3, 1, 40), down: whole(0, 0, 40), flip: flag(false) }, `
332
+ vec4 effect(vec2 uv) {
333
+ vec2 q = uv * vec2(across, down > 0.5 ? down : across);
334
+ return texture(picture, flipped > 0.5 ? mirrored(q) : fract(q));
335
+ }`, { rename: { flip: 'flipped' } })
336
+
337
+ // --- blurs that are one pass
338
+ define('zoomBlur', ['strength'], { strength: num(0.25, 0, 1), x: place(), y: place() }, `
339
+ vec4 effect(vec2 uv) {
340
+ ${PLACE}
341
+ float j = hash(uv * resolution);
342
+ vec3 sum = vec3(0.0);
343
+ for (int i = 0; i < 14; i++) sum += look((c + p * (1.0 - strength * (float(i) + j) / 14.0)) / size).rgb;
344
+ return vec4(sum / 14.0, 1.0);
345
+ }`)
346
+ define('spinBlur', ['angle'], { angle: angle(8, -90, 90), x: place(), y: place() }, `
347
+ vec4 effect(vec2 uv) {
348
+ ${PLACE}
349
+ float j = hash(uv * resolution);
350
+ vec3 sum = vec3(0.0);
351
+ for (int i = 0; i < 14; i++) sum += look((c + turn(p, angle * ((float(i) + j) / 14.0 - 0.5))) / size).rgb;
352
+ return vec4(sum / 14.0, 1.0);
353
+ }`)
354
+ define('rgbSplit', ['size'], { size: num(4, 0, 60), angle: angle(0), spread: flag(false) }, `
355
+ vec4 effect(vec2 uv) {
356
+ vec2 d = spread > 0.5 ? (uv - 0.5) * 2.0 * big : big * vec2(cos(angle), sin(angle));
357
+ d /= size;
358
+ return vec4(texture(picture, uv + d).r, texture(picture, uv).g, texture(picture, uv - d).b, 1.0);
359
+ }`, { rename: { size: 'big' } })
360
+
361
+ // --- colours
362
+ define('hue', ['shift'], { shift: angle(90), speed: num(0, -720, 720) }, `
363
+ vec4 effect(vec2 uv) { return vec4(hueTurn(texture(picture, uv).rgb, shift + radians(speed) * time), 1.0); }`)
364
+ define('saturate', ['strength'], { strength: num(1.6, 0, 4) }, `
365
+ vec4 effect(vec2 uv) { vec3 c = texture(picture, uv).rgb; return vec4(mix(vec3(luma(c)), c, strength), 1.0); }`)
366
+ define('brighten', ['strength'], { strength: num(1.3, 0, 4) }, `
367
+ vec4 effect(vec2 uv) { return vec4(texture(picture, uv).rgb * strength, 1.0); }`)
368
+ define('contrast', ['strength'], { strength: num(1.4, 0, 4) }, `
369
+ vec4 effect(vec2 uv) { return vec4((texture(picture, uv).rgb - 0.5) * strength + 0.5, 1.0); }`)
370
+ define('invert', [], {}, `
371
+ vec4 effect(vec2 uv) { return vec4(1.0 - texture(picture, uv).rgb, 1.0); }`)
372
+ define('posterize', ['levels'], { levels: whole(4, 2, 16) }, `
373
+ vec4 effect(vec2 uv) { float n = max(2.0, floor(levels + 0.5)); return vec4(min(floor(texture(picture, uv).rgb * n), n - 1.0) / (n - 1.0), 1.0); }`)
374
+ define('tint', ['colour'], { colour: colour('gold') }, `
375
+ vec4 effect(vec2 uv) { return vec4(texture(picture, uv).rgb * colour, 1.0); }`)
376
+ define('duotone', ['dark', 'light'], { dark: colour('midnightblue'), light: colour('gold') }, `
377
+ vec4 effect(vec2 uv) { return vec4(mix(dark, light, luma(texture(picture, uv).rgb)), 1.0); }`)
378
+ define('colourCycle', ['palette'], { palette: pal('rainbow'), speed: num(0.3, -5, 5), bands: num(1, 0.1, 8) }, `
379
+ vec4 effect(vec2 uv) {
380
+ float l = luma(texture(picture, uv).rgb);
381
+ return vec4(palette(l * bands + time * speed) * smoothstep(0.0, 0.12, l), 1.0);
382
+ }`)
383
+
384
+ // --- screens, film and print
385
+ define('scanlines', ['strength'], { strength: num(0.5, 0, 1), size: num(3, 1, 40) }, `
386
+ vec4 effect(vec2 uv) {
387
+ float s = max(gap, 2.0 * size.y / resolution.y);
388
+ return vec4(texture(picture, uv).rgb * (1.0 - strength * (0.5 + 0.5 * cos(TAU * uv.y * size.y / s))), 1.0);
389
+ }`, { rename: { size: 'gap' } })
390
+ define('crt', ['curve'], { curve: num(0.18, 0, 1), lines: num(0.45, 0, 1), size: num(3, 1, 40) }, `
391
+ vec4 effect(vec2 uv) {
392
+ vec2 q = uv * 2.0 - 1.0;
393
+ q *= 1.0 + curve * 0.5 * q.yx * q.yx;
394
+ vec2 st = q * 0.5 + 0.5;
395
+ // The glass ends in a soft rounded edge, and its three colours do not quite meet near it.
396
+ vec2 e = 1.0 - smoothstep(vec2(1.0) - 6.0 / size, vec2(1.0), abs(q));
397
+ float fringe = 0.0015 * (1.0 + 2.0 * dot(q, q));
398
+ vec3 c = vec3(texture(picture, st + vec2(fringe, 0.0)).r, texture(picture, st).g, texture(picture, st - vec2(fringe, 0.0)).b);
399
+ float s = max(gap, 2.0 * size.y / resolution.y);
400
+ c *= 1.0 - lines * (0.5 + 0.5 * cos(TAU * st.y * size.y / s));
401
+ // Thin stripes of red, green and blue, as a tube's dots are: a third of a line apart.
402
+ float stripe = fract(st.x * size.x / s);
403
+ c *= 1.0 + lines * 0.35 * (0.5 - smoothstep(vec3(0.0), vec3(0.34), abs(stripe - vec3(0.17, 0.5, 0.83))));
404
+ c *= (1.0 + 0.25 * lines) * (1.0 - 0.35 * curve * dot(q, q));
405
+ return vec4(c * e.x * e.y, 1.0);
406
+ }`, { rename: { size: 'gap' } })
407
+ define('glitch', ['strength'], { strength: num(0.5, 0, 1), speed: num(1, 0, 10), seed: whole(1, 0, 9999) }, `
408
+ vec4 effect(vec2 uv) {
409
+ float t = floor(time * 10.0 * speed) + seed * 131.0;
410
+ vec2 q = uv;
411
+ // Blocks that jump sideways, a few at a time and not on every beat of the clock.
412
+ float busy = step(1.0 - 0.6 * strength, hash(vec2(t, 5.0)));
413
+ vec2 cell = floor(uv * vec2(3.0, 9.0 + 6.0 * hash(vec2(t, 1.0))));
414
+ float r = hash(vec3(cell, t));
415
+ if (r > 1.0 - 0.3 * strength * busy) q.x += (hash(vec3(cell, t + 9.0)) - 0.5) * 0.3 * strength;
416
+ // One line where the picture tears.
417
+ float tear = hash(vec2(t, 2.0)), band = abs(uv.y - tear);
418
+ if (band < 0.012 * (1.0 + 3.0 * hash(vec2(t, 3.0))) && hash(vec2(t, 4.0)) < strength) q.x += (hash(vec2(t, 7.0)) - 0.5) * 0.25;
419
+ float split = (0.002 + 0.02 * busy * hash(vec2(t, 8.0))) * strength;
420
+ vec3 c = vec3(look(q + vec2(split, 0.0)).r, look(q).g, look(q - vec2(split, 0.0)).b);
421
+ // A band of snow that rolls down.
422
+ float roll = fract(uv.y - time * 0.23 * speed);
423
+ c += strength * 0.12 * (1.0 - smoothstep(0.0, 0.06, abs(roll - 0.5))) * hash(vec3(floor(uv * resolution / 2.0), t));
424
+ return vec4(c, 1.0);
425
+ }`)
426
+ define('grain', ['strength'], { strength: num(0.18, 0, 1), size: num(1.5, 0.5, 20), seed: whole(1, 0, 9999) }, `
427
+ vec4 effect(vec2 uv) {
428
+ vec3 c = texture(picture, uv).rgb;
429
+ float n = hash(vec3(floor(uv * size / max(dot_, size.x / resolution.x)), floor(time * 30.0) + seed * 57.0)) - 0.5;
430
+ return vec4(c + n * strength * (1.0 - 0.5 * luma(c)), 1.0);
431
+ }`, { rename: { size: 'dot_' } })
432
+ define('vignette', ['strength'], { strength: num(0.6, 0, 1), size: num(0.75, 0, 2), colour: colour('black') }, `
433
+ vec4 effect(vec2 uv) {
434
+ float d = length((uv - 0.5) * 2.0) / 1.41421;
435
+ return vec4(mix(texture(picture, uv).rgb, colour, strength * smoothstep(wide * 0.5, wide * 0.5 + 0.55, d)), 1.0);
436
+ }`, { rename: { size: 'wide' } })
437
+ define('edges', ['strength'], { strength: num(2, 0, 10), size: num(1.5, 0.5, 20), fill: num(0, 0, 1) }, `
438
+ vec4 effect(vec2 uv) {
439
+ vec2 o = max(thick, size.x / resolution.x) / size;
440
+ vec3 a = texture(picture, uv + o * vec2(-1, -1)).rgb, b = texture(picture, uv + o * vec2(0, -1)).rgb, c = texture(picture, uv + o * vec2(1, -1)).rgb;
441
+ vec3 d = texture(picture, uv + o * vec2(-1, 0)).rgb, f = texture(picture, uv + o * vec2(1, 0)).rgb;
442
+ vec3 g = texture(picture, uv + o * vec2(-1, 1)).rgb, h = texture(picture, uv + o * vec2(0, 1)).rgb, i = texture(picture, uv + o * vec2(1, 1)).rgb;
443
+ vec3 gx = c + 2.0 * f + i - a - 2.0 * d - g, gy = g + 2.0 * h + i - a - 2.0 * b - c;
444
+ float e = clamp(sqrt(dot(gx, gx) + dot(gy, gy)) * 0.25 * strength, 0.0, 1.0);
445
+ // The line takes the brightest colour beside it, at full strength.
446
+ vec3 near = max(max(max(a, c), max(g, i)), max(max(b, h), max(d, f)));
447
+ near /= max(max(near.r, max(near.g, near.b)), 0.001);
448
+ return vec4(mix(texture(picture, uv).rgb * fill, near, e), 1.0);
449
+ }`, { rename: { size: 'thick' } })
450
+ define('dots', ['size'], { size: num(9, 2, 100), angle: angle(25), paper: colour('black') }, `
451
+ vec4 effect(vec2 uv) {
452
+ vec2 p = turn(uv * size - size * 0.5, angle);
453
+ vec2 mid = (floor(p / big) + 0.5) * big;
454
+ vec3 s = texture(picture, (turn(mid, -angle) + size * 0.5) / size).rgb;
455
+ float most = max(s.r, max(s.g, s.b));
456
+ float r = sqrt(most) * big * 0.62, aa = size.x / resolution.x;
457
+ return vec4(mix(paper, s / max(most, 0.001), (1.0 - smoothstep(r - aa, r + aa, length(p - mid))) * step(0.004, most)), 1.0);
458
+ }`, { rename: { size: 'big' } })
459
+ // Letters: each square of the picture becomes the one of ten marks that has about its brightness.
460
+ define('letters', ['size'], { size: num(10, 4, 100), colour: colour('white'), own: flag(true) }, `
461
+ const int MARKS[10] = int[10](0, 4194304, 131200, 14336, 459200, 145536, 332096, 18157905, 11512810, 33412991);
462
+ vec4 effect(vec2 uv) {
463
+ vec2 p = uv * size;
464
+ vec2 cell = floor(p / big);
465
+ vec3 s = texture(picture, (cell + 0.5) * big / size).rgb;
466
+ float l = max(s.r, max(s.g, s.b));
467
+ int mark = MARKS[int(clamp(l * 10.0, 0.0, 9.0))];
468
+ ivec2 at = ivec2(floor(fract(p / big) * 6.0)) - 1;
469
+ float on = at.x >= 0 && at.x < 5 && at.y >= 0 && at.y < 5 ? float((mark >> (at.x + at.y * 5)) & 1) : 0.0;
470
+ return vec4((own > 0.5 ? s / max(l, 0.001) : colour) * on, 1.0);
471
+ }`, { rename: { size: 'big' } })
472
+
473
+ // --- backdrops: pictures that need no drawing. They show wherever the scene has left the canvas clear.
474
+ define('plasma', ['palette'], { palette: pal('sunset'), speed: num(0.3, -5, 5), scale: num(1, 0.1, 10) }, `
475
+ vec3 backdrop(vec2 uv) {
476
+ vec2 q = (uv * size - size * 0.5) / min(size.x, size.y) * scale * 2.0;
477
+ float t = time * speed * 2.0;
478
+ float v = sin(q.x * 3.1 + t) + sin(q.y * 4.3 - t * 1.3) + sin((q.x + q.y) * 2.3 + t * 0.7);
479
+ v += sin(length(q - vec2(sin(t * 0.37), cos(t * 0.53))) * 5.0 - t);
480
+ return palette(v * 0.125 + 0.5 + t * 0.03);
481
+ }`, { backdrop: true })
482
+ define('starfield', ['speed'], { speed: num(0.5, -5, 5), stars: num(1, 0, 4), colour: colour('white'), x: place(), y: place() }, `
483
+ vec3 backdrop(vec2 uv) {
484
+ ${PLACE}
485
+ p /= min(size.x, size.y);
486
+ float sum = 0.0;
487
+ for (int i = 0; i < 10; i++) {
488
+ // Ten sheets of stars, each coming nearer and starting again far away.
489
+ float z = fract(float(i) / 10.0 + time * speed * 0.12);
490
+ float s = mix(26.0, 0.7, z);
491
+ vec2 g = p * s + float(i) * 23.7, cell = floor(g);
492
+ if (hash(vec3(cell, float(i) + seed)) > 0.22 * stars) continue;
493
+ vec2 off = vec2(hash(vec3(cell, 71.0)), hash(vec3(cell, 113.0))) - 0.5;
494
+ float d = length(fract(g) - 0.5 - off * 0.7) / s;
495
+ float r = 0.0012 + 0.004 * z * z;
496
+ sum += (1.0 - smoothstep(r * 0.3, r, d)) * smoothstep(0.0, 0.25, z) * (1.0 - smoothstep(0.9, 1.0, z));
497
+ }
498
+ return colour * sum;
499
+ }`, { backdrop: true })
500
+ define('metaballs', ['palette'], { palette: pal('neon'), speed: num(0.5, -5, 5), count: whole(7, 1, 12), size: num(1, 0.2, 4) }, `
501
+ vec3 backdrop(vec2 uv) {
502
+ vec2 q = (uv * size - size * 0.5) / min(size.x, size.y);
503
+ vec2 half_ = size / min(size.x, size.y) * 0.5;
504
+ float t = time * speed, field = 0.0;
505
+ for (int i = 0; i < 12; i++) {
506
+ if (float(i) >= count) break;
507
+ float k = float(i);
508
+ vec2 at = half_ * 0.85 * vec2(sin(t * (0.5 + 0.13 * k) + k * 2.4), cos(t * (0.37 + 0.11 * k) + k * 4.1));
509
+ float r = big * (0.07 + 0.035 * fract(k * 0.618));
510
+ field += r * r / (dot(q - at, q - at) + 1e-4);
511
+ }
512
+ // The colour runs through the palette once from a blob's edge to its heart, and never round to the start again.
513
+ vec3 c = palette(0.12 + 0.62 * smoothstep(0.4, 3.5, field));
514
+ float inside = smoothstep(0.97, 1.03, field);
515
+ return c * mix(field * field * 0.35, 1.0, inside);
516
+ }`, { backdrop: true, rename: { size: 'big' } })
517
+ define('flow', ['palette'], { palette: pal('ocean'), speed: num(0.2, -5, 5), scale: num(1, 0.1, 10) }, `
518
+ vec3 backdrop(vec2 uv) {
519
+ vec2 q = (uv * size - size * 0.5) / min(size.x, size.y) * scale * 2.2;
520
+ float t = time * speed;
521
+ // Noise looked up at a place that noise has moved: it folds like ink in water.
522
+ vec2 w = vec2(fbm(q + vec2(t, -t * 0.7)), fbm(q + vec2(5.2 - t * 0.6, 1.3 + t)));
523
+ float v = fbm(q + 2.5 * w + vec2(0.0, t * 0.3));
524
+ return palette(v * 1.6 + length(w) * 0.5 + t * 0.1) * (0.35 + 1.1 * v);
525
+ }`, { backdrop: true })
526
+
527
+ // --------------------------------------------------------------------------------------------
528
+ // MORE OF THEM. The same words mean the same thing in every effect: `strength` how much, `size` how big (in
529
+ // the scene's points), `speed`, `angle`, `colour`, and x and y for a place. An effect that looks at many
530
+ // places for every pixel says so with `cost` ("some": a dozen or two; "heavy": more).
531
+ const SOBEL = `
532
+ vec2 o = max(thick, size.x / resolution.x) / size;
533
+ float a = luma(texture(picture, uv + o * vec2(-1, -1)).rgb), b = luma(texture(picture, uv + o * vec2(0, -1)).rgb), cc = luma(texture(picture, uv + o * vec2(1, -1)).rgb);
534
+ float d = luma(texture(picture, uv + o * vec2(-1, 0)).rgb), f = luma(texture(picture, uv + o * vec2(1, 0)).rgb);
535
+ float g = luma(texture(picture, uv + o * vec2(-1, 1)).rgb), h = luma(texture(picture, uv + o * vec2(0, 1)).rgb), i = luma(texture(picture, uv + o * vec2(1, 1)).rgb);
536
+ float gx = cc + 2.0 * f + i - a - 2.0 * d - g, gy = g + 2.0 * h + i - a - 2.0 * b - cc;
537
+ float edge = clamp(length(vec2(gx, gy)) * 0.5 * strength, 0.0, 1.0);`
538
+
539
+ // --- shapes of the picture
540
+ define('droste', ['zoom'], { zoom: num(3, 1.2, 10), twist: whole(1, 0, 4), speed: num(0.3, -5, 5), x: place(), y: place() }, `
541
+ vec4 effect(vec2 uv) {
542
+ ${PLACE}
543
+ float m = length(size) * 0.5, r = max(length(p), 0.001) / m, a = atan(p.y, p.x);
544
+ // The picture inside itself, smaller each time, for ever: with a twist it winds in as a spiral.
545
+ float k = pow(zoom, fract(log(r) / log(zoom) + a / TAU * twist - time * speed)) / zoom;
546
+ return look((c + k * m * vec2(cos(a), sin(a))) / size);
547
+ }`)
548
+ define('rotozoom', ['speed'], { speed: num(0.5, -5, 5), zoom: num(1.5, 0.2, 8) }, `
549
+ vec4 effect(vec2 uv) {
550
+ vec2 p = turn(uv * size - size * 0.5, time * speed) * (zoom + 0.6 * zoom * sin(time * speed * 0.7));
551
+ return texture(picture, fract(p / size + 0.5));
552
+ }`)
553
+ define('hexagons', ['size'], { size: num(14, 3, 100) }, `
554
+ vec4 effect(vec2 uv) {
555
+ const vec2 s = vec2(1.0, 1.7320508);
556
+ vec2 p = uv * size / big, a = mod(p, s) - s * 0.5, b = mod(p - s * 0.5, s) - s * 0.5;
557
+ return texture(picture, (p - (dot(a, a) < dot(b, b) ? a : b)) * big / size);
558
+ }`, { rename: { size: 'big' } })
559
+ define('glassTiles', ['size'], { size: num(40, 6, 300), strength: num(0.4, 0, 1) }, `
560
+ vec4 effect(vec2 uv) { vec2 p = uv * size; return look((p + (fract(p / big) - 0.5) * big * strength) / size); }`, { rename: { size: 'big' } })
561
+ define('frosted', ['size'], { size: num(6, 0, 60) }, `
562
+ vec4 effect(vec2 uv) {
563
+ vec2 o = vec2(hash(uv * resolution), hash(uv * resolution + vec2(71.0, 13.0))) - 0.5;
564
+ return look((uv * size + o * 2.0 * big) / size);
565
+ }`, { rename: { size: 'big' } })
566
+ define('heatHaze', ['size'], { size: num(5, 0, 60), scale: num(40, 5, 400), speed: num(1, -5, 5) }, `
567
+ vec4 effect(vec2 uv) {
568
+ vec2 p = uv * size;
569
+ p.x += big * 2.0 * (noise(vec2(p.x / scale, p.y / scale + time * speed * 3.0)) - 0.5);
570
+ p.y += big * (noise(vec2(p.x / scale + 9.0, p.y / scale + time * speed * 3.0)) - 0.5);
571
+ return look(p / size);
572
+ }`, { rename: { size: 'big' } })
573
+ define('caustics', ['strength'], { strength: num(0.5, 0, 2), scale: num(120, 20, 600), speed: num(0.5, -5, 5), colour: colour('#bfe8ff') }, `
574
+ vec4 effect(vec2 uv) {
575
+ vec2 q = uv * size / scale, w = q;
576
+ float t = time * speed;
577
+ for (int i = 0; i < 3; i++) w += 0.6 * vec2(sin(w.y * 1.7 + t * (1.0 + 0.3 * float(i))), cos(w.x * 1.9 - t * 1.1));
578
+ // Light gathered into a net of bright lines, as the sun is on the floor of a pool.
579
+ float v = pow(0.5 + 0.5 * sin(w.x * 2.0) * sin(w.y * 2.0), 6.0);
580
+ return vec4(look(uv + (w - q) * 0.004 * strength).rgb + colour * v * strength, 1.0);
581
+ }`)
582
+ define('shake', ['strength'], { strength: num(10, 0, 200), speed: num(25, 1, 100) }, `
583
+ vec4 effect(vec2 uv) {
584
+ vec2 o = (vec2(noise(vec2(time * speed, 1.0)), noise(vec2(7.0, time * speed))) - 0.5) * 2.0 * strength;
585
+ return look((uv * size + o) / size);
586
+ }`)
587
+ define('punch', ['strength'], { strength: num(0.15, -0.9, 4), x: place(), y: place() }, `
588
+ vec4 effect(vec2 uv) { ${PLACE} return look((c + p / (1.0 + strength)) / size); }`)
589
+
590
+ // --- blur and light
591
+ define('motionBlur', ['size'], { size: num(24, 0, 300), angle: angle(0) }, `
592
+ vec4 effect(vec2 uv) {
593
+ vec2 d = big * vec2(cos(angle), sin(angle)) / size;
594
+ float j = hash(uv * resolution);
595
+ vec3 sum = vec3(0.0);
596
+ for (int i = 0; i < 14; i++) sum += look(uv + d * ((float(i) + j) / 14.0 - 0.5)).rgb;
597
+ return vec4(sum / 14.0, 1.0);
598
+ }`, { rename: { size: 'big' }, cost: 'some' })
599
+ define('tiltShift', ['strength'], { strength: num(8, 0, 60), size: num(70, 5, 1000), y: place() }, `
600
+ vec4 effect(vec2 uv) {
601
+ float r = strength * smoothstep(band, band * 2.5, abs(uv.y * size.y - y));
602
+ if (r < 0.3) return texture(picture, uv);
603
+ // Sixteen places on a spiral out from this one: a small round blur that grows away from the band in focus.
604
+ vec3 sum = vec3(0.0);
605
+ float j = hash(uv * resolution) * TAU;
606
+ for (int i = 0; i < 16; i++) sum += texture(picture, uv + sqrt((float(i) + 0.5) / 16.0) * r * vec2(cos(float(i) * 2.39996 + j), sin(float(i) * 2.39996 + j)) / size).rgb;
607
+ return vec4(sum / 16.0, 1.0);
608
+ }`, { rename: { size: 'band' }, cost: 'some' })
609
+ define('lightRays', ['strength'], { strength: num(0.7, 0, 3), size: num(0.6, 0, 1), threshold: num(0.6, 0, 1), x: place(), y: place() }, `
610
+ vec4 effect(vec2 uv) {
611
+ vec2 d = (uv - vec2(x, y) / size) * reach / 22.0, q = uv - d * hash(uv * resolution);
612
+ vec3 sum = vec3(0.0);
613
+ float w = 1.0;
614
+ for (int i = 0; i < 22; i++) {
615
+ q -= d;
616
+ vec3 s = texture(picture, q).rgb;
617
+ sum += s * smoothstep(threshold - 0.1, threshold + 0.1, most(s)) * w;
618
+ w *= 0.93;
619
+ }
620
+ return vec4(texture(picture, uv).rgb + sum * strength * 0.11, 1.0);
621
+ }`, { rename: { size: 'reach' }, cost: 'some' })
622
+ define('streaks', ['strength'], { strength: num(0.6, 0, 3), size: num(120, 0, 800), threshold: num(0.6, 0, 1), colour: colour('#88aaff') }, `
623
+ vec3 lit(vec2 q) { vec3 s = texture(picture, q).rgb; return s * smoothstep(threshold - 0.1, threshold + 0.1, most(s)); }
624
+ vec4 effect(vec2 uv) {
625
+ vec3 sum = vec3(0.0);
626
+ float j = hash(uv * resolution);
627
+ for (int i = 0; i < 12; i++) {
628
+ float f = (float(i) + j) / 12.0;
629
+ sum += (lit(uv + vec2(f * big / size.x, 0.0)) + lit(uv - vec2(f * big / size.x, 0.0))) * (1.0 - f);
630
+ }
631
+ return vec4(texture(picture, uv).rgb + sum * colour * strength * 0.16, 1.0);
632
+ }`, { rename: { size: 'big' }, cost: 'some' })
633
+ define('softFocus', ['strength'], { strength: num(0.6, 0, 1), size: num(6, 0, 60) }, `
634
+ vec4 effect(vec2 uv) {
635
+ vec3 c = texture(picture, uv).rgb, b = vec3(0.0);
636
+ for (int i = 0; i < 8; i++) b += texture(picture, uv + big * vec2(cos(float(i) * 0.7854), sin(float(i) * 0.7854)) * (0.5 + 0.5 * float(i & 1)) / size).rgb;
637
+ return vec4(1.0 - (1.0 - c) * (1.0 - b / 8.0 * strength), 1.0);
638
+ }`, { rename: { size: 'big' } })
639
+
640
+ // --- what was drawn stays: these read their own last frame
641
+ define('smear', ['length'], { length: num(3, 0, 30), size: num(40, 0, 400), scale: num(150, 20, 1000) }, `
642
+ vec4 effect(vec2 uv) {
643
+ vec2 p = uv * size;
644
+ // The paint is pushed a little each frame along a slow current, and thins as it goes.
645
+ float a = noise(p / scale + time * 0.1) * TAU * 2.0;
646
+ vec3 old = texture(previous, (p - vec2(cos(a), sin(a)) * big * dt) / size).rgb * pow(0.1, dt / max(len, 0.01));
647
+ return vec4(max(texture(picture, uv).rgb, max(old - 0.0015, 0.0)), 1.0);
648
+ }`, { rename: { size: 'big', length: 'len' }, feedback: true })
649
+ define('stutter', ['speed'], { speed: num(8, 0.5, 60) }, `
650
+ vec4 effect(vec2 uv) {
651
+ // A new picture only so many times a second: between them the last one is held.
652
+ bool fresh = floor(time * speed + 0.001) != floor((time - dt) * speed + 0.001) || frame < 1.5;
653
+ return fresh ? texture(picture, uv) : texture(previous, uv);
654
+ }`, { feedback: true })
655
+
656
+ // --- colours
657
+ define('gradientMap', ['palette'], { palette: pal('sunset') }, `
658
+ vec4 effect(vec2 uv) { return vec4(ramp(luma(texture(picture, uv).rgb)), 1.0); }`)
659
+ define('thermal', [], { palette: pal('thermal') }, `
660
+ vec4 effect(vec2 uv) { return vec4(ramp(luma(texture(picture, uv).rgb)), 1.0); }`)
661
+ define('nightVision', ['strength'], { strength: num(1.6, 0, 6) }, `
662
+ vec4 effect(vec2 uv) {
663
+ float l = pow(luma(texture(picture, uv).rgb) * strength, 0.8);
664
+ l += (hash(vec3(floor(uv * resolution / 2.0), floor(time * 30.0 + 0.001))) - 0.5) * 0.14;
665
+ l *= 0.85 + 0.15 * cos(TAU * uv.y * size.y / 3.0);
666
+ return vec4(vec3(0.1, 1.0, 0.25) * l * (1.0 - smoothstep(0.35, 0.75, length(uv - 0.5))), 1.0);
667
+ }`)
668
+ define('sepia', ['strength'], { strength: num(1, 0, 1) }, `
669
+ vec4 effect(vec2 uv) {
670
+ vec3 c = texture(picture, uv).rgb;
671
+ return vec4(mix(c, vec3(dot(c, vec3(0.393, 0.769, 0.189)), dot(c, vec3(0.349, 0.686, 0.168)), dot(c, vec3(0.272, 0.534, 0.131))), strength), 1.0);
672
+ }`)
673
+ define('threshold', ['level'], { level: num(0.5, 0, 1), dark: colour('black'), light: colour('white') }, `
674
+ vec4 effect(vec2 uv) { return vec4(mix(dark, light, smoothstep(level - 0.01, level + 0.01, luma(texture(picture, uv).rgb))), 1.0); }`)
675
+ define('solarize', ['level'], { level: num(0.5, 0, 1) }, `
676
+ vec4 effect(vec2 uv) { vec3 c = texture(picture, uv).rgb; return vec4(mix(c, 1.0 - c, step(vec3(level), c)), 1.0); }`)
677
+ define('colourBleed', ['size'], { size: num(10, 0, 100) }, `
678
+ vec4 effect(vec2 uv) {
679
+ vec3 c = texture(picture, uv).rgb, s = vec3(0.0);
680
+ for (int i = 0; i < 6; i++) s += texture(picture, uv - vec2(float(i) * big / 6.0 / size.x, 0.0)).rgb;
681
+ s /= 6.0;
682
+ return vec4(luma(c) + (s - luma(s)) * 1.2, 1.0);
683
+ }`, { rename: { size: 'big' } })
684
+ define('rainbowEdges', ['strength'], { strength: num(2, 0, 10), size: num(1.5, 0.5, 20), speed: num(0.1, -5, 5) }, `
685
+ vec4 effect(vec2 uv) {${SOBEL}
686
+ vec3 tone = 0.5 + 0.5 * cos(TAU * (atan(gy, gx) / TAU + time * speed + vec3(0.0, 0.33, 0.67)));
687
+ return vec4(mix(texture(picture, uv).rgb, tone, edge), 1.0);
688
+ }`, { rename: { size: 'thick' } })
689
+
690
+ // --- old computers and screens
691
+ define('dither', ['palette'], { palette: pal('gameboy'), size: num(2, 1, 40), strength: num(0.35, 0, 1) }, `
692
+ const int BAYER[16] = int[16](0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5);
693
+ vec4 effect(vec2 uv) {
694
+ float s = max(big, size.x / resolution.x);
695
+ vec2 cell = floor(uv * size / s);
696
+ vec3 c = texture(picture, (cell + 0.5) * s / size).rgb;
697
+ // A pattern of sixteen thresholds nudges each pixel up or down, so a few colours pass for many.
698
+ c += ((float(BAYER[int(mod(cell.x, 4.0)) + int(mod(cell.y, 4.0)) * 4]) + 0.5) / 16.0 - 0.5) * strength;
699
+ vec3 best = pal[0];
700
+ float closest = 9.0;
701
+ for (int i = 0; i < 6; i++) {
702
+ if (float(i) >= palCount) break;
703
+ float d = distance(c, pal[i]);
704
+ if (d < closest) { closest = d; best = pal[i]; }
705
+ }
706
+ return vec4(best, 1.0);
707
+ }`, { rename: { size: 'big' } })
708
+ define('interlace', ['strength'], { strength: num(0.6, 0, 1), size: num(2, 1, 20) }, `
709
+ vec4 effect(vec2 uv) {
710
+ float row = floor(uv.y * size.y / max(gap, size.y / resolution.y)) + floor(time * 30.0 + 0.001);
711
+ return vec4(texture(picture, uv).rgb * (1.0 - strength * mod(row, 2.0)), 1.0);
712
+ }`, { rename: { size: 'gap' } })
713
+ define('led', ['size'], { size: num(8, 3, 80) }, `
714
+ vec4 effect(vec2 uv) {
715
+ vec2 p = uv * size, mid = (floor(p / big) + 0.5) * big;
716
+ float aa = size.x / resolution.x;
717
+ vec3 c = texture(picture, mid / size).rgb;
718
+ return vec4(c * 1.25 * (1.0 - smoothstep(big * 0.36 - aa, big * 0.36 + aa, length(p - mid))) + 0.025, 1.0);
719
+ }`, { rename: { size: 'big' } })
720
+
721
+ // --- drawn, printed and painted
722
+ define('emboss', ['strength'], { strength: num(2, 0, 10), angle: angle(135), size: num(1.5, 0.5, 20) }, `
723
+ vec4 effect(vec2 uv) {
724
+ vec2 o = max(thick, size.x / resolution.x) * vec2(cos(angle), sin(angle)) / size;
725
+ return vec4(vec3(0.5 + (luma(texture(picture, uv + o).rgb) - luma(texture(picture, uv - o).rgb)) * strength), 1.0);
726
+ }`, { rename: { size: 'thick' } })
727
+ define('sharpen', ['strength'], { strength: num(1, 0, 5), size: num(1, 0.5, 10) }, `
728
+ vec4 effect(vec2 uv) {
729
+ vec2 o = max(thick, size.x / resolution.x) / size;
730
+ vec3 c = texture(picture, uv).rgb;
731
+ vec3 round_ = texture(picture, uv + vec2(o.x, 0.0)).rgb + texture(picture, uv - vec2(o.x, 0.0)).rgb + texture(picture, uv + vec2(0.0, o.y)).rgb + texture(picture, uv - vec2(0.0, o.y)).rgb;
732
+ return vec4(c + (c - round_ * 0.25) * strength, 1.0);
733
+ }`, { rename: { size: 'thick' } })
734
+ define('sketch', ['strength'], { strength: num(3, 0, 10), size: num(1.2, 0.5, 20), paper: colour('#f4efe2'), colour: colour('#2a2a33') }, `
735
+ vec4 effect(vec2 uv) {${SOBEL}
736
+ vec2 p = uv * size;
737
+ // Shading is pencil strokes on a slant, closer together where the picture is darker.
738
+ float shade = 1.0 - luma(texture(picture, uv).rgb);
739
+ float strokes = step(fract((p.x + p.y) / 5.0 + 0.3 * noise(p / 7.0)), shade * 0.6) * step(0.35, shade);
740
+ return vec4(mix(paper, colour, clamp(edge + strokes * 0.45, 0.0, 1.0)), 1.0);
741
+ }`, { rename: { size: 'thick' } })
742
+ define('cartoon', ['levels'], { levels: whole(4, 2, 12), strength: num(2.5, 0, 10), size: num(1.5, 0.5, 20) }, `
743
+ vec4 effect(vec2 uv) {${SOBEL}
744
+ vec3 c = texture(picture, uv).rgb;
745
+ float l = max(most(c), 0.001), n = max(2.0, floor(levels + 0.5));
746
+ return vec4(c / l * (floor(l * n + 0.5) / n) * (1.0 - smoothstep(0.25, 0.6, edge)), 1.0);
747
+ }`, { rename: { size: 'thick' } })
748
+ define('stainedGlass', ['size'], { size: num(40, 8, 400), colour: colour('#101010') }, `
749
+ vec4 effect(vec2 uv) {
750
+ vec3 v = cells(uv * size / big);
751
+ return vec4(mix(colour, texture(picture, v.xy * big / size).rgb * 1.1, smoothstep(0.03, 0.09, v.z)), 1.0);
752
+ }`, { rename: { size: 'big' } })
753
+ define('crosshatch', ['size'], { size: num(6, 2, 60), colour: colour('#1a1a22'), paper: colour('#f6f2e8') }, `
754
+ vec4 effect(vec2 uv) {
755
+ vec2 p = uv * size;
756
+ float l = luma(texture(picture, uv).rgb), a = fract((p.x + p.y) / big), b = fract((p.x - p.y) / big), ink = 0.0;
757
+ // A darker place has more sets of lines across it.
758
+ if (l < 0.8) ink = max(ink, step(a, 0.22));
759
+ if (l < 0.55) ink = max(ink, step(b, 0.22));
760
+ if (l < 0.3) ink = max(ink, step(fract(a + 0.5), 0.22));
761
+ if (l < 0.12) ink = max(ink, step(fract(b + 0.5), 0.22));
762
+ return vec4(mix(paper, colour, ink), 1.0);
763
+ }`, { rename: { size: 'big' } })
764
+ define('oil', ['size'], { size: num(4, 1, 30) }, `
765
+ vec4 effect(vec2 uv) {
766
+ // Four patches round each place, and the colour of the calmest one: edges stay and everything else is smoothed like paint.
767
+ vec2 o = max(big, size.x / resolution.x) / 2.0 / size;
768
+ vec3 best = vec3(0.0);
769
+ float least = 1e9;
770
+ for (int k = 0; k < 4; k++) {
771
+ vec2 corner = vec2(k % 2 == 0 ? -1.0 : 1.0, k < 2 ? -1.0 : 1.0);
772
+ vec3 mean = vec3(0.0);
773
+ float sq = 0.0;
774
+ for (int j = 0; j < 3; j++) for (int i = 0; i < 3; i++) {
775
+ vec3 c = texture(picture, uv + corner * vec2(i, j) * o).rgb;
776
+ mean += c;
777
+ sq += dot(c, c);
778
+ }
779
+ mean /= 9.0;
780
+ float spread = sq / 9.0 - dot(mean, mean);
781
+ if (spread < least) { least = spread; best = mean; }
782
+ }
783
+ return vec4(best, 1.0);
784
+ }`, { rename: { size: 'big' }, cost: 'heavy' })
785
+
786
+ // --- things in front of the picture
787
+ define('dust', ['strength'], { strength: num(0.5, 0, 1), seed: whole(1, 0, 9999) }, `
788
+ vec4 effect(vec2 uv) {
789
+ vec3 c = texture(picture, uv).rgb;
790
+ float t = floor(time * 12.0 + 0.001) + seed * 17.0;
791
+ vec2 cell = floor(uv * vec2(8.0, 5.0));
792
+ if (hash(vec3(cell, t)) > 1.0 - 0.1 * strength) {
793
+ vec2 at = (cell + 0.2 + 0.6 * vec2(hash(vec3(cell, t + 3.0)), hash(vec3(cell, t + 5.0)))) / vec2(8.0, 5.0);
794
+ c *= smoothstep(0.0, 1.0, length((uv - at) * size) / (1.0 + 2.5 * hash(vec3(cell, t + 9.0))) - 0.2);
795
+ }
796
+ // A scratch or two down the film, never in the same place twice.
797
+ for (int k = 0; k < 2; k++) {
798
+ float at = hash(vec2(t, float(k)));
799
+ if (hash(vec2(t, float(k) + 5.0)) < 0.35 * strength) c += 0.3 * (1.0 - smoothstep(0.0, 1.2, abs(uv.x - at) * size.x)) * step(hash(vec2(floor(uv.y * 14.0), t)), 0.8);
800
+ }
801
+ return vec4(c, 1.0);
802
+ }`)
803
+ define('lightLeaks', ['strength'], { strength: num(0.6, 0, 2), speed: num(0.2, -5, 5), colour: colour('#ff7a2a') }, `
804
+ vec4 effect(vec2 uv) {
805
+ float t = time * speed;
806
+ float v = pow(noise(vec2(uv.y * 1.5 + t, t * 0.7)), 2.0) * (1.0 - smoothstep(0.0, 0.55, uv.x)) + pow(noise(vec2(uv.y * 1.2 - t * 0.8, 9.0 + t * 0.5)), 2.0) * smoothstep(0.55, 1.0, uv.x);
807
+ return vec4(1.0 - (1.0 - texture(picture, uv).rgb) * (1.0 - clamp(colour * v * strength * 2.2, 0.0, 1.0)), 1.0);
808
+ }`)
809
+ define('rain', ['strength'], { strength: num(0.6, 0, 2), speed: num(1, 0, 5), angle: angle(12, -80, 80) }, `
810
+ vec4 effect(vec2 uv) {
811
+ vec2 p = turn(uv * size, angle);
812
+ float sum = 0.0;
813
+ for (int k = 0; k < 3; k++) {
814
+ float f = 1.0 + float(k) * 0.6, w = 9.0 / f, h = 150.0 / f, col = floor(p.x / w);
815
+ if (hash(vec2(col, float(k))) > 0.35) continue;
816
+ float y = fract((p.y - time * speed * 900.0 / f) / h + hash(vec2(col, float(k) + 3.0)));
817
+ sum += smoothstep(0.75, 1.0, y) * (1.0 - smoothstep(0.1, 0.25, abs(fract(p.x / w) - 0.5))) / f;
818
+ }
819
+ return vec4(texture(picture, uv).rgb + vec3(0.7, 0.8, 1.0) * sum * strength * 0.5, 1.0);
820
+ }`)
821
+ define('snow', ['strength'], { strength: num(0.8, 0, 2), speed: num(1, 0, 5), size: num(3, 0.5, 20) }, `
822
+ vec4 effect(vec2 uv) {
823
+ vec2 p = uv * size;
824
+ float sum = 0.0;
825
+ for (int k = 0; k < 4; k++) {
826
+ float f = 1.0 + float(k) * 0.5, s = 70.0 / f;
827
+ vec2 q = (p + vec2(14.0 * sin(time * 0.7 + float(k) * 2.0), -time * speed * (60.0 - 9.0 * float(k)))) / s, cell = floor(q);
828
+ vec2 at = 0.2 + 0.6 * vec2(hash(vec3(cell, float(k))), hash(vec3(cell, float(k) + 9.0)));
829
+ float r = big * (0.5 + 0.5 * hash(vec3(cell, 4.0))) / f / s;
830
+ sum += (1.0 - smoothstep(r * 0.4, r, distance(fract(q), at))) / (0.6 + 0.4 * f);
831
+ }
832
+ return vec4(texture(picture, uv).rgb + sum * strength, 1.0);
833
+ }`, { rename: { size: 'big' } })
834
+ define('bokeh', ['strength'], { strength: num(0.5, 0, 2), size: num(30, 4, 200), speed: num(0.3, -5, 5) }, `
835
+ vec4 effect(vec2 uv) {
836
+ vec2 p = uv * size;
837
+ vec3 sum = vec3(0.0);
838
+ for (int k = 0; k < 2; k++) {
839
+ float s = big * (3.0 + float(k));
840
+ vec2 q = (p + vec2(float(k) * 37.0, time * speed * 40.0 * (1.0 + float(k)))) / s, cell = floor(q);
841
+ if (hash(vec3(cell, float(k))) > 0.6) continue;
842
+ vec2 at = 0.3 + 0.4 * vec2(hash(vec3(cell, 2.0)), hash(vec3(cell, 3.0)));
843
+ float r = (0.5 + 0.5 * hash(vec3(cell, 4.0))) * big / s, d = distance(fract(q), at);
844
+ // A disc of light with a brighter rim, as a lamp out of focus is.
845
+ sum += (0.5 + 0.5 * cos(TAU * (hash(vec3(cell, 5.0)) + vec3(0.0, 0.33, 0.67)))) * (1.0 - smoothstep(r * 0.9, r, d)) * (0.5 + 0.5 * smoothstep(r * 0.6, r * 0.9, d));
846
+ }
847
+ return vec4(texture(picture, uv).rgb + sum * strength * 0.5, 1.0);
848
+ }`, { rename: { size: 'big' } })
849
+ define('fog', ['strength'], { strength: num(0.6, 0, 1), speed: num(0.3, -5, 5), scale: num(220, 20, 2000), colour: colour('#dfe6ee') }, `
850
+ vec4 effect(vec2 uv) {
851
+ float v = fbm(uv * size / scale + vec2(time * speed, 0.0));
852
+ return vec4(mix(texture(picture, uv).rgb, colour, strength * smoothstep(0.2, 0.8, v) * (0.4 + 0.6 * uv.y)), 1.0);
853
+ }`)
854
+ define('lightning', ['strength'], { strength: num(1, 0, 3), speed: num(1, 0, 10), seed: whole(1, 0, 9999) }, `
855
+ vec4 effect(vec2 uv) {
856
+ float t = time * speed * 0.7 + seed * 13.0, f = fract(t);
857
+ float flash = step(hash(vec2(floor(t), 1.0)), 0.5) * exp(-f * 14.0) * (0.6 + 0.4 * sin(f * 90.0));
858
+ return vec4(texture(picture, uv).rgb * (1.0 + flash * strength) + vec3(0.75, 0.82, 1.0) * flash * strength * 0.6, 1.0);
859
+ }`)
860
+
861
+ // --- more backdrops
862
+ define('warp', ['palette'], { palette: pal('neon'), speed: num(1, -10, 10), x: place(), y: place() }, `
863
+ vec3 backdrop(vec2 uv) {
864
+ ${PLACE}
865
+ float r = length(p) / (min(size.x, size.y) * 0.5), a = atan(p.y, p.x) / TAU;
866
+ float z = 1.0 / max(r, 0.02) + time * speed * 2.0;
867
+ return palette(z * 0.05 + a) * (0.3 + 0.7 * mod(floor(a * 16.0) + floor(z * 2.0), 2.0)) * smoothstep(0.0, 0.6, r);
868
+ }`, { backdrop: true })
869
+ define('voronoi', ['palette'], { palette: pal('candy'), speed: num(0.3, -5, 5), scale: num(1, 0.1, 10) }, `
870
+ vec3 backdrop(vec2 uv) {
871
+ vec2 q = (uv * size - size * 0.5) / min(size.x, size.y) * 5.0 * scale;
872
+ float t = time * speed;
873
+ q += 0.35 * vec2(sin(t + q.y * 0.9), cos(t * 0.8 + q.x * 0.9));
874
+ vec3 v = cells(q);
875
+ return palette(hash(v.xy * 91.0) + t * 0.05) * smoothstep(0.0, 0.1, v.z) * (0.7 + 0.3 * smoothstep(0.1, 0.5, v.z));
876
+ }`, { backdrop: true })
877
+ define('moire', ['colour'], { colour: colour('white'), speed: num(0.3, -5, 5), scale: num(1, 0.1, 10) }, `
878
+ vec3 backdrop(vec2 uv) {
879
+ vec2 q = (uv * size - size * 0.5) / min(size.x, size.y);
880
+ float t = time * speed;
881
+ float v = sin(length(q - 0.3 * vec2(sin(t), cos(t * 1.3))) * 90.0 * scale) * sin(length(q + 0.3 * vec2(cos(t * 0.7), sin(t * 1.1))) * 90.0 * scale);
882
+ return colour * smoothstep(-0.15, 0.15, v);
883
+ }`, { backdrop: true })
884
+ define('interference', ['palette'], { palette: pal('ocean'), speed: num(0.5, -5, 5), scale: num(1, 0.1, 10) }, `
885
+ vec3 backdrop(vec2 uv) {
886
+ vec2 q = (uv * size - size * 0.5) / min(size.x, size.y);
887
+ float t = time * speed, v = 0.0;
888
+ for (int i = 0; i < 4; i++) {
889
+ float k = float(i);
890
+ v += sin(length(q - 0.45 * vec2(sin(t * (0.5 + 0.2 * k) + k * 2.0), cos(t * (0.4 + 0.15 * k) + k))) * 34.0 * scale - t * 4.0);
891
+ }
892
+ return palette(v * 0.125 + 0.5);
893
+ }`, { backdrop: true })
894
+ define('aurora', ['palette'], { palette: pal('aurora'), speed: num(0.3, -5, 5) }, `
895
+ vec3 backdrop(vec2 uv) {
896
+ float t = time * speed;
897
+ vec3 sum = vec3(0.004, 0.012, 0.04) * (1.0 + uv.y);
898
+ for (int i = 0; i < 3; i++) {
899
+ float k = float(i), y = 0.3 + 0.12 * k + 0.09 * sin(uv.x * 3.0 + t * (1.0 + 0.3 * k) + k * 2.0) + 0.04 * sin(uv.x * 9.0 - t * 1.7);
900
+ float d = uv.y - y;
901
+ // A curtain: sharp along its lower edge, fading upwards, with rays that shift.
902
+ float band = (d < 0.0 ? exp(d * 9.0) : exp(-d * 60.0)) * (0.45 + 0.55 * noise(vec2(uv.x * 40.0 + k * 7.0, t * 2.0)));
903
+ sum += palette(k / 3.0 + 0.1 * sin(t + uv.x * 2.0)) * band * 0.8;
904
+ }
905
+ return sum + step(0.9985, hash(floor(uv * resolution / 2.0))) * 0.6;
906
+ }`, { backdrop: true })
907
+ define('fire', ['palette'], { palette: pal('fire'), speed: num(1, -5, 5), scale: num(1, 0.1, 10) }, `
908
+ vec3 backdrop(vec2 uv) {
909
+ vec2 q = uv * size / min(size.x, size.y) * 3.0 * scale;
910
+ float t = time * speed;
911
+ float v = fbm(vec2(q.x, q.y + t * 2.2)) * 0.6 + fbm(vec2(q.x * 2.0 + 7.0, q.y * 2.0 + t * 3.5)) * 0.4;
912
+ return ramp(clamp(v * 2.0 - (1.0 - uv.y) * 1.5 + 0.15, 0.0, 1.0));
913
+ }`, { backdrop: true })
914
+ define('clouds', ['colour'], { colour: colour('#3a8fdc'), speed: num(0.1, -5, 5), scale: num(1, 0.1, 10) }, `
915
+ vec3 backdrop(vec2 uv) {
916
+ vec2 q = uv * size / min(size.x, size.y) * 2.5 * scale + vec2(time * speed, 0.0);
917
+ float v = fbm(q) * 0.65 + fbm(q * 2.3 + 11.0) * 0.35;
918
+ vec3 sky = mix(colour, colour * 0.45 + 0.5, uv.y);
919
+ return mix(sky, vec3(1.0) - 0.25 * smoothstep(0.6, 0.9, v), smoothstep(0.42, 0.7, v));
920
+ }`, { backdrop: true })
921
+ define('matrixRain', ['colour'], { colour: colour('#3dff7a'), speed: num(1, 0, 10), size: num(14, 6, 80) }, `
922
+ vec3 backdrop(vec2 uv) {
923
+ vec2 p = uv * size, cell = floor(p / big), sub = floor(fract(p / big) * 6.0);
924
+ float rows = size.y / big + 24.0;
925
+ float head = fract(time * speed * 0.12 * (0.5 + hash(vec2(cell.x, 3.0))) + hash(vec2(cell.x, 7.0))) * rows, behind = head - cell.y;
926
+ if (behind < 0.0 || behind > 20.0 || sub.x < 1.0 || sub.y < 1.0) return vec3(0.0);
927
+ // Each square is a mark made of dots that changes now and then.
928
+ float on = step(0.5, hash(vec3(sub + cell * 7.0, floor(time * 5.0 * speed + 0.001 + hash(cell) * 10.0))));
929
+ return mix(colour * (1.0 - behind / 20.0), vec3(0.85, 1.0, 0.9), step(behind, 1.0)) * on;
930
+ }`, { backdrop: true, rename: { size: 'big' } })
931
+ define('copperBars', ['palette'], { palette: pal('rainbow'), count: whole(5, 1, 8), speed: num(1, -5, 5), size: num(34, 4, 300) }, `
932
+ vec3 backdrop(vec2 uv) {
933
+ vec3 c = vec3(0.0);
934
+ for (int i = 0; i < 8; i++) {
935
+ if (float(i) >= count) break;
936
+ float d = abs(uv.y * size.y - size.y * (0.5 + 0.36 * sin(time * speed * 1.3 + float(i) * 0.55))) / big;
937
+ // A round bar of metal: bright along its middle. A later bar is in front of an earlier one.
938
+ if (d < 1.0) c = palette(float(i) / count) * (1.0 - d * d) + 0.5 * pow(1.0 - d, 6.0);
939
+ }
940
+ return c;
941
+ }`, { backdrop: true, rename: { size: 'big' } })
942
+ define('checkerFloor', ['colour'], { colour: colour('#ff2a6d'), dark: colour('#101a3a'), speed: num(1, -10, 10) }, `
943
+ vec3 backdrop(vec2 uv) {
944
+ float h = 0.42, d = uv.y - h;
945
+ if (d <= 0.0) return mix(vec3(0.01, 0.0, 0.05), colour * 0.55, pow(uv.y / h, 5.0));
946
+ // Below the horizon: the further down the screen, the nearer the floor.
947
+ float z = 1.0 / d;
948
+ float squares = mod(floor((uv.x - 0.5) * z * 3.0 * size.x / size.y) + floor(z * 1.2 + time * speed * 3.0), 2.0);
949
+ return mix(colour * 0.55, mix(dark, colour, squares), clamp(d * 5.0, 0.0, 1.0));
950
+ }`, { backdrop: true })
951
+ define('twister', ['palette'], { palette: pal('candy'), speed: num(1, -5, 5), size: num(0.22, 0.02, 0.5) }, `
952
+ vec3 backdrop(vec2 uv) {
953
+ float t = time * speed, a = t + sin(uv.y * 3.0 + t * 1.3) * 2.2, x = uv.x - 0.5 - 0.08 * sin(uv.y * 2.0 + t);
954
+ // A square bar seen from the side, twisted along its length: the faces that are towards you, each with its own colour.
955
+ for (int i = 0; i < 4; i++) {
956
+ float x0 = sin(a + float(i) * 1.5708) * wide, x1 = sin(a + float(i + 1) * 1.5708) * wide;
957
+ if (x0 < x1 && x > x0 && x < x1) return palette(float(i) / 4.0) * (0.35 + 0.65 * (x1 - x0) / (wide * 1.4143));
958
+ }
959
+ return vec3(0.0);
960
+ }`, { backdrop: true, rename: { size: 'wide' } })
961
+
962
+ // --- the music, drawn. With nothing to listen to they are still and dark.
963
+ define('spectrumBars', ['palette'], { palette: pal('rainbow'), count: whole(32, 4, 64), size: num(0.7, 0.05, 1) }, `
964
+ vec3 backdrop(vec2 uv) {
965
+ float n = floor(count + 0.5), i = floor(uv.x * n), v = texture(spectrum, vec2((i + 0.5) / n, 0.5)).r * tall;
966
+ float bar = step(1.0 - uv.y, v) * step(0.12, fract(uv.x * n)) * step(fract(uv.x * n), 0.88);
967
+ return palette(i / n * 0.85) * bar * (0.55 + 0.45 * (1.0 - uv.y) / max(v, 0.001));
968
+ }`, { backdrop: true, rename: { size: 'tall' } })
969
+ define('spectrumRing', ['palette'], { palette: pal('rainbow'), size: num(90, 10, 1000), x: place(), y: place() }, `
970
+ vec3 backdrop(vec2 uv) {
971
+ ${PLACE}
972
+ float s = abs(atan(p.y, p.x) / TAU * 2.0), bars = 48.0, i = floor(s * bars);
973
+ float v = texture(spectrum, vec2((i + 0.5) / bars * 0.9, 0.5)).r, r = length(p);
974
+ float bar = step(big, r) * step(r, big * (1.03 + 1.4 * v)) * step(0.15, fract(s * bars)) * step(fract(s * bars), 0.85);
975
+ return palette(i / bars * 0.85) * bar;
976
+ }`, { backdrop: true, rename: { size: 'big' } })
977
+ define('waveformRing', ['colour'], { colour: colour('white'), size: num(110, 10, 1000), strength: num(3, 0.5, 40), x: place(), y: place() }, `
978
+ vec3 backdrop(vec2 uv) {
979
+ ${PLACE}
980
+ float s = abs(atan(p.y, p.x) / TAU * 2.0);
981
+ float r = big * (1.0 + (texture(waveform, vec2(s, 0.5)).r - 0.5) * 1.2), aa = size.x / resolution.x;
982
+ return colour * (1.0 - smoothstep(strength * 0.5, strength * 0.5 + aa * 1.5, abs(length(p) - r)));
983
+ }`, { backdrop: true, rename: { size: 'big' } })
984
+ define('beatFlash', ['strength'], { strength: num(0.5, 0, 2), colour: colour('white') }, `
985
+ vec4 effect(vec2 uv) { return vec4(texture(picture, uv).rgb + colour * pulse * strength, 1.0); }`)
986
+ define('bassShake', ['strength'], { strength: num(14, 0, 200), speed: num(25, 1, 100) }, `
987
+ vec4 effect(vec2 uv) {
988
+ vec2 o = (vec2(noise(vec2(time * speed, 1.0)), noise(vec2(7.0, time * speed))) - 0.5) * 2.0 * strength * bass;
989
+ return look((uv * size + o) / size);
990
+ }`)
991
+
992
+ /**
993
+ * LOOKS: several effects at once, by name. Each entry is [effect, its settings].
994
+ */
995
+ const LOOKS = {
996
+ dream: [['glow', { strength: 1, threshold: 0.35, radius: 40 }], ['saturate', { strength: 1.3 }], ['vignette', { strength: 0.5 }]],
997
+ arcade: [['glow', { strength: 0.7, threshold: 0.4, radius: 20 }], ['crt', { curve: 0.2, lines: 0.5 }]],
998
+ vhs: [['saturate', { strength: 1.35 }], ['rgbSplit', { size: 3 }], ['glitch', { strength: 0.35 }], ['scanlines', { strength: 0.3, size: 4 }], ['grain', { strength: 0.2, size: 2 }], ['vignette', { strength: 0.45 }]],
999
+ neon: [['edges', { strength: 3, fill: 0.15 }], ['glow', { strength: 1.4, threshold: 0.2, radius: 28 }]],
1000
+ comic: [['posterize', { levels: 4 }], ['dots', { size: 7 }]],
1001
+ underwater: [['wobble', { size: 10, scale: 160 }], ['tint', { colour: '#7fd4ff' }], ['glow', { strength: 0.5, threshold: 0.5 }], ['vignette', { strength: 0.6, colour: '#001830' }]],
1002
+ oldfilm: [['duotone', { dark: '#1a1208', light: '#f2e2c0' }], ['grain', { strength: 0.3, size: 2 }], ['vignette', { strength: 0.75 }]],
1003
+ disco: [['kaleidoscope', { segments: 10 }], ['hue', { shift: 0, speed: 60 }], ['glow', { strength: 0.8 }]],
1004
+ gameboy: [['dither', { palette: 'gameboy', size: 3 }]],
1005
+ c64: [['dither', { palette: 'c64', size: 3 }], ['scanlines', { strength: 0.2, size: 3 }]],
1006
+ noir: [['saturate', { strength: 0 }], ['contrast', { strength: 1.6 }], ['grain', { strength: 0.25, size: 2 }], ['vignette', { strength: 0.8 }]],
1007
+ comicbook: [['cartoon', { levels: 4 }], ['dots', { size: 5, paper: '#101018' }]],
1008
+ storm: [['rain', { strength: 0.7 }], ['lightning', {}], ['fog', { strength: 0.35, colour: '#8a94a8' }], ['vignette', { strength: 0.6 }]],
1009
+ winter: [['tint', { colour: '#cfe4ff' }], ['snow', {}], ['softFocus', { strength: 0.4 }], ['vignette', { strength: 0.4, colour: '#0a1830' }]],
1010
+ cinema: [['streaks', { strength: 0.5 }], ['tiltShift', { strength: 5 }], ['grain', { strength: 0.1 }], ['vignette', { strength: 0.5 }]],
1011
+ terminal: [['letters', { size: 9, own: false, colour: '#3dff7a' }], ['glow', { strength: 0.6, threshold: 0.2, radius: 14 }], ['scanlines', { strength: 0.25 }]],
1012
+ }
1013
+
1014
+ // --------------------------------------------------------------------------------------------
1015
+ // LISTENING: the bass, the middle, the highs, how loud, and a pulse on each beat, from anything with
1016
+ // samples() (numbers from -1 to 1, the latest sound). All of it is smoothed on the scene's clock.
1017
+ class Listener {
1018
+ constructor() {
1019
+ this.target = null
1020
+ this.bass = this.mids = this.highs = this.loudness = this.pulse = 0
1021
+ this.spectrum = new Uint8Array(64)
1022
+ this.waveform = new Uint8Array(256).fill(128)
1023
+ this.peak = 0.05
1024
+ this.average = 0
1025
+ this.since = 1
1026
+ this.last = null
1027
+ this.stamp = 0 // goes up when the textures need sending again
1028
+ this.used = false // nothing is asked of the sound until something here wants to know (a synth is not made to report for nothing)
1029
+ this.re = this.im = this.window = null
1030
+ }
1031
+
1032
+ listen(target) {
1033
+ this.target = target || null
1034
+ this.last = null
1035
+ }
1036
+
1037
+ /** The sound as it is now, or null: a list of numbers, however the target gives it. */
1038
+ heard() {
1039
+ const t = this.target
1040
+ if (!t) return null
1041
+ try {
1042
+ let s = typeof t === 'function' ? t() : typeof t.samples === 'function' ? t.samples() : null
1043
+ // Left and right as two lists: the left will do.
1044
+ if (s && s.length && typeof s[0] !== 'number' && s[0] && s[0].length !== undefined) s = s[0]
1045
+ return s && s.length ? s : null
1046
+ } catch (e) { return null }
1047
+ }
1048
+
1049
+ update(dt) {
1050
+ if (!this.used) return
1051
+ const ease = (now, to, up, down) => now + (to - now) * (1 - Math.exp(-dt / (to > now ? up : down)))
1052
+ const s = this.heard()
1053
+ let bass = 0, mids = 0, highs = 0, loud = 0
1054
+ this.since += dt
1055
+ // The same list as last frame (a sound library posts about forty a second) says nothing new.
1056
+ const fresh = s && !(this.last && this.last.n === s.length && this.last.a === s[0] && this.last.b === s[s.length >> 1] && this.last.c === s[s.length - 1])
1057
+ if (s) {
1058
+ let n = 64
1059
+ while (n * 2 <= s.length && n < 1024) n *= 2
1060
+ if (!this.re || this.re.length !== n) {
1061
+ this.re = new Float32Array(n)
1062
+ this.im = new Float32Array(n)
1063
+ this.window = Float32Array.from({ length: n }, (_, i) => 0.5 - 0.5 * Math.cos(2 * Math.PI * i / (n - 1)))
1064
+ }
1065
+ const { re, im } = this, from = s.length - n
1066
+ let energy = 0
1067
+ for (let i = 0; i < s.length; i++) energy += s[i] * s[i]
1068
+ energy /= s.length
1069
+ for (let i = 0; i < n; i++) { re[i] = (s[from + i] || 0) * this.window[i]; im[i] = 0 }
1070
+ transform(re, im)
1071
+ const rate = (this.target && this.target.sampleRate) || 48000, hz = rate / n
1072
+ const band = (low, high) => {
1073
+ const a = Math.max(1, Math.round(low / hz)), b = Math.max(a + 1, Math.min(n / 2, Math.round(high / hz)))
1074
+ let sum = 0
1075
+ for (let k = a; k < b; k++) sum += re[k] * re[k] + im[k] * im[k]
1076
+ return Math.sqrt(sum) / (n / 4)
1077
+ }
1078
+ // High notes are quieter in most music than low ones: they are turned up to be seen.
1079
+ bass = band(20, 220); mids = band(220, 2200) * 1.6; highs = band(2200, 12000) * 3
1080
+ loud = Math.sqrt(energy) * 1.6
1081
+ // Everything is measured against the loudest it has been lately, so quiet music moves as much as loud.
1082
+ this.peak = Math.max(this.peak * Math.pow(0.5, dt / 4), bass, mids, highs, loud, 0.02)
1083
+ bass /= this.peak; mids /= this.peak; highs /= this.peak; loud /= this.peak
1084
+ if (fresh) {
1085
+ // A beat: suddenly more sound than there has been over the last half second.
1086
+ if (energy > this.average * 1.8 + 1e-5 && this.since > 0.12) { this.pulse = 1; this.since = 0 }
1087
+ this.average += (energy - this.average) * 0.25
1088
+ for (let i = 0; i < 64; i++) {
1089
+ // Sixty-four steps from 30 Hz to 12 kHz, each a little wider than the one before.
1090
+ const lo = 30 * Math.pow(400, i / 64), hi = 30 * Math.pow(400, (i + 1) / 64)
1091
+ this.spectrum[i] = clamp(band(lo, Math.max(hi, lo + hz)) * (1 + i / 16) / this.peak, 0, 1) * 255
1092
+ }
1093
+ for (let i = 0; i < 256; i++) this.waveform[i] = clamp(0.5 + 0.5 * (s[from + Math.floor(i * n / 256)] || 0), 0, 1) * 255
1094
+ this.stamp++
1095
+ this.last = { n: s.length, a: s[0], b: s[s.length >> 1], c: s[s.length - 1] }
1096
+ }
1097
+ } else if (this.last) {
1098
+ this.last = null
1099
+ this.spectrum.fill(0); this.waveform.fill(128); this.stamp++
1100
+ }
1101
+ this.bass = ease(this.bass, clamp(bass, 0, 1), 0.025, 0.22)
1102
+ this.mids = ease(this.mids, clamp(mids, 0, 1), 0.025, 0.22)
1103
+ this.highs = ease(this.highs, clamp(highs, 0, 1), 0.025, 0.18)
1104
+ this.loudness = ease(this.loudness, clamp(loud, 0, 1), 0.03, 0.3)
1105
+ if (this.since > 0) this.pulse *= Math.pow(0.5, dt / 0.11)
1106
+ if (this.pulse < 0.001) this.pulse = 0
1107
+ }
1108
+ }
1109
+ /** A fast Fourier transform in place: how much of each pitch is in a wave. The length is a power of two. */
1110
+ function transform(re, im) {
1111
+ const n = re.length
1112
+ for (let i = 1, j = 0; i < n; i++) {
1113
+ let bit = n >> 1
1114
+ for (; j & bit; bit >>= 1) j ^= bit
1115
+ j ^= bit
1116
+ if (i < j) { let t = re[i]; re[i] = re[j]; re[j] = t; t = im[i]; im[i] = im[j]; im[j] = t }
1117
+ }
1118
+ for (let len = 2; len <= n; len <<= 1) {
1119
+ const step = -2 * Math.PI / len, wr = Math.cos(step), wi = Math.sin(step)
1120
+ for (let i = 0; i < n; i += len) {
1121
+ let cr = 1, ci = 0
1122
+ for (let k = 0; k < len / 2; k++) {
1123
+ const a = i + k, b = a + len / 2
1124
+ const xr = re[b] * cr - im[b] * ci, xi = re[b] * ci + im[b] * cr
1125
+ re[b] = re[a] - xr; im[b] = im[a] - xi
1126
+ re[a] += xr; im[a] += xi
1127
+ const t = cr * wr - ci * wi
1128
+ ci = cr * wi + ci * wr
1129
+ cr = t
1130
+ }
1131
+ }
1132
+ }
1133
+ }
1134
+
1135
+ // --------------------------------------------------------------------------------------------
1136
+ // One effect that is on a scene. What the scene holds is a Proxy of this, so `effect.segments = 12`
1137
+ // sets a setting and a name that is not a setting is said plainly.
1138
+ const OWN = new Set(['on', 'off', 'remove', 'set', 'get', 'describe', 'name', 'active', 'settings', 'params'])
1139
+ class Effect {
1140
+ constructor(pipe, def, given) {
1141
+ this.pipe = pipe
1142
+ this.def = def
1143
+ this.name = def.name
1144
+ this.active = true
1145
+ this.values = {}
1146
+ this.history = null // two pictures it writes in turn, when it uses its own last frame
1147
+ this.flipped = 0
1148
+ for (const name in def.settings) this.values[name] = def.settings[name].default
1149
+ for (const name in given) this.set(name, given[name])
1150
+ }
1151
+
1152
+ setting(name) {
1153
+ const key = SPELLING[camel(name)] || camel(name)
1154
+ const s = this.def.settings[key]
1155
+ if (!s) throw new Error(`${this.name} has no setting called "${name}". Its settings are ${Object.keys(this.def.settings).join(', ')}`)
1156
+ return [key, s]
1157
+ }
1158
+
1159
+ set(name, value) {
1160
+ const [key, s] = this.setting(name)
1161
+ if (typeof value !== 'function') {
1162
+ if (s.kind === 'choice') {
1163
+ if (typeof value === 'number') value = s.choices[clamp(Math.round(value), 0, s.choices.length - 1)]
1164
+ if (!s.choices.includes(value)) throw new Error(`${this.name}'s ${key} is one of ${s.choices.map((c) => `"${c}"`).join(', ')}, not ${JSON.stringify(value)}`)
1165
+ } else if (s.kind === 'colour') rgb(value)
1166
+ else if (s.kind === 'palette') paletteOf(value)
1167
+ else if (s.kind === 'bool') value = !!value
1168
+ else if (value == null && s.place) value = null
1169
+ else if (typeof value !== 'number' || Number.isNaN(value)) throw new Error(`${this.name}'s ${key} is a number (or a function that gives one), not ${JSON.stringify(value)}`)
1170
+ }
1171
+ this.values[key] = value
1172
+ }
1173
+
1174
+ /** A setting as it is now: a function is asked. */
1175
+ get(name) {
1176
+ const [key, s] = this.setting(name)
1177
+ let v = this.values[key]
1178
+ if (typeof v === 'function') {
1179
+ v = v()
1180
+ if (s.kind === 'float' || s.kind === 'int') { v = Number(v); if (Number.isNaN(v)) v = s.default }
1181
+ }
1182
+ if (v == null && s.place) {
1183
+ // The middle of the picture, which before the first frame is asked of the canvas itself.
1184
+ const p = this.pipe, c = p.canvas
1185
+ v = (key === 'y' ? (p.w ? p.H : c.clientHeight || c.height) : (p.w ? p.W : c.clientWidth || c.width)) / 2
1186
+ }
1187
+ return v
1188
+ }
1189
+
1190
+ /** What a dial needs to know of a setting: its range, its kind, where it starts. */
1191
+ describe(name) {
1192
+ const [key, s] = this.setting(name)
1193
+ const d = { name: key, kind: s.kind === 'colour' || s.kind === 'palette' ? 'text' : s.kind, default: s.default }
1194
+ if (s.min !== undefined) { d.min = s.min; d.max = s.max }
1195
+ if (s.place) { const c = this.pipe.canvas; d.max = key === 'y' ? c.clientHeight || c.height : c.clientWidth || c.width; d.default = d.max / 2 }
1196
+ if (s.step) d.step = s.step
1197
+ if (s.unit) d.unit = s.unit === 'degrees' ? '°' : s.unit
1198
+ if (s.choices) d.choices = s.choices
1199
+ return d
1200
+ }
1201
+
1202
+ get settings() { return Object.keys(this.def.settings) }
1203
+ get params() { return Object.keys(this.def.settings).map((n) => this.describe(n)) }
1204
+ on() { this.active = true; this.pipe.wake(); return this.proxy }
1205
+ off() { this.active = false; return this.proxy }
1206
+ remove() { this.pipe.drop(this) }
1207
+ }
1208
+ const EFFECT = {
1209
+ get(fx, name) {
1210
+ if (typeof name === 'symbol' || name in fx || OWN.has(name)) { const v = fx[name]; return typeof v === 'function' ? v.bind(fx) : v }
1211
+ // Asking for something it does not have is how other code finds out what an effect is (a dial asks for
1212
+ // onChange, a promise is tested for with `then`): that is answered with nothing. Setting one is a mistake, and is said.
1213
+ const key = SPELLING[camel(name)] || camel(name)
1214
+ return key in fx.def.settings ? fx.get(key) : undefined
1215
+ },
1216
+ set(fx, name, value) {
1217
+ if (name === 'active') { if (value) fx.on(); else fx.off() } else fx.set(name, value)
1218
+ return true
1219
+ },
1220
+ has(fx, name) { return name in fx || camel(name) in fx.def.settings },
1221
+ }
1222
+
1223
+ // --------------------------------------------------------------------------------------------
1224
+ // The effects of one canvas: their order, the graphics card's pictures, and the frame.
1225
+ class Pipeline {
1226
+ constructor(canvas, options = {}) {
1227
+ this.canvas = canvas
1228
+ this.manual = !!options.manual
1229
+ this.sound = options.sound || null // where the music comes from when a scene has not said (a host's)
1230
+ // A host's function that gives a canvas laid over the scene's which is PART OF THE SCENE'S PICTURE (the old scene
1231
+ // going out during a transition), or nothing. The effects are given the two together, and are shown over both.
1232
+ this.under = options.under || null
1233
+ this.effects = []
1234
+ this.listener = new Listener()
1235
+ this.heard = false
1236
+ this.time = 0
1237
+ this.frames = 0
1238
+ this.dt = 1 / 60
1239
+ this.W = this.H = 1
1240
+ this.quality = 'auto'
1241
+ this.scale = 1
1242
+ this.sized = false
1243
+ this.least = 0.25
1244
+ this.handover = 0
1245
+ this.small = null // a 2D canvas the scene's picture is made smaller on, before it is handed over
1246
+ this.slow = 0
1247
+ this.best = Infinity
1248
+ this.pace = 0
1249
+ this.clock = 0
1250
+ this.gl = null
1251
+ this.layer = null
1252
+ this.failed = false
1253
+ this.lost = false
1254
+ this.closed = false
1255
+ this.raf = 0
1256
+ this.counts = { textures: 0, framebuffers: 0, programs: 0 }
1257
+ this.passes = 0
1258
+ this.told = null // a host's function for the music's numbers, called when it has answered the last (`ack`)
1259
+ this.armed = true
1260
+ this.toldLast = ''
1261
+ this.after = null // a host's function, called when a frame's picture is on the screen
1262
+ this.reset()
1263
+ }
1264
+
1265
+ /** What the graphics card holds: nothing, until there is a context to hold it. */
1266
+ reset() {
1267
+ this.programs = new Map()
1268
+ this.input = null
1269
+ this.main = []
1270
+ this.pool = []
1271
+ this.inUse = []
1272
+ this.float = false
1273
+ this.sounds = null
1274
+ this.soundStamp = -1
1275
+ this.w = this.h = 0
1276
+ }
1277
+
1278
+ // --- the canvas the result is shown on, and its context
1279
+ open() {
1280
+ if (this.gl || this.failed || this.closed) return !!this.gl
1281
+ const { canvas } = this
1282
+ // The context of the scene that ran before, when there is one: a page may hold only a few, making one
1283
+ // takes a moment on a tablet, and its compiled programs are as good for this scene as for the last.
1284
+ const kept = spare && !spare.gl.isContextLost() ? spare : null
1285
+ spare = null
1286
+ const layer = this.layer = kept ? kept.layer : document.createElement('canvas')
1287
+ layer.dataset.effectsLayer = ''
1288
+ layer.style.cssText = 'position:absolute;pointer-events:none;margin:0;padding:0;border:0;inset:auto;display:none'
1289
+ canvas.after(layer)
1290
+ const gl = kept ? kept.gl : layer.getContext('webgl2', { alpha: false, antialias: false, depth: false, stencil: false, powerPreference: 'high-performance' })
1291
+ if (kept) { this.programs = kept.programs; this.counts.programs = kept.programs.size }
1292
+ if (!gl) {
1293
+ this.failed = true
1294
+ layer.remove()
1295
+ this.layer = null
1296
+ console.warn('The effects need WebGL 2, which this browser or device does not have: the scene is shown without them.')
1297
+ return false
1298
+ }
1299
+ this.gl = gl
1300
+ this.onLost = (e) => { e.preventDefault(); this.lost = true; this.reset(); for (const fx of this.effects) fx.history = null }
1301
+ // The context came back: everything is made again as it is next needed. Trails start from black.
1302
+ this.onFound = () => { this.lost = false; this.counts = { textures: 0, framebuffers: 0, programs: 0 }; this.begin() }
1303
+ layer.addEventListener('webglcontextlost', this.onLost)
1304
+ layer.addEventListener('webglcontextrestored', this.onFound)
1305
+ this.begin()
1306
+ return true
1307
+ }
1308
+
1309
+ begin() {
1310
+ const gl = this.gl
1311
+ this.float = !!(gl.getExtension('EXT_color_buffer_float') || gl.getExtension('EXT_color_buffer_half_float'))
1312
+ gl.disable(gl.DEPTH_TEST)
1313
+ gl.disable(gl.BLEND)
1314
+ gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true)
1315
+ }
1316
+
1317
+ /** Frames by itself (a page that has no host to say when the scene has drawn): after the scene's own. */
1318
+ wake() {
1319
+ if (this.manual || this.raf || this.closed) return
1320
+ let last = 0
1321
+ const tick = (now) => {
1322
+ if (this.closed) return
1323
+ if (!this.canvas.isConnected || (this.layer && !this.layer.isConnected)) return this.close()
1324
+ this.raf = requestAnimationFrame(tick)
1325
+ const dt = last ? Math.min(0.1, (now - last) / 1000) : 1 / 60
1326
+ last = now
1327
+ this.frame(dt)
1328
+ }
1329
+ // Asked for from a timer, so it is behind the scene's own frame in the browser's list and stays there:
1330
+ // each frame the scene draws first, then this.
1331
+ this.raf = -1
1332
+ setTimeout(() => { if (!this.closed) this.raf = requestAnimationFrame(tick) }, 0)
1333
+ }
1334
+
1335
+ close() {
1336
+ if (this.closed) return
1337
+ this.closed = true
1338
+ if (this.raf > 0) cancelAnimationFrame(this.raf)
1339
+ const gl = this.gl
1340
+ if (gl) {
1341
+ this.free()
1342
+ this.layer.removeEventListener('webglcontextlost', this.onLost)
1343
+ this.layer.removeEventListener('webglcontextrestored', this.onFound)
1344
+ // A shader a scene wrote itself goes with the scene; the library's own programs are kept with the context.
1345
+ for (const [key, p] of this.programs) if (/^shader\d/.test(key)) { gl.deleteProgram(p.program); this.programs.delete(key) }
1346
+ if (!this.lost && !gl.isContextLost()) {
1347
+ if (spare) spare.gl.getExtension('WEBGL_lose_context')?.loseContext()
1348
+ spare = { layer: this.layer, gl, programs: this.programs }
1349
+ } else gl.getExtension('WEBGL_lose_context')?.loseContext()
1350
+ this.gl = null
1351
+ }
1352
+ this.layer?.remove()
1353
+ this.layer = null
1354
+ this.effects.length = 0
1355
+ if (this.canvas[LAYER] === this) delete this.canvas[LAYER]
1356
+ }
1357
+
1358
+ /** Every picture and frame buffer given back (a new size, or the end). */
1359
+ free() {
1360
+ const gl = this.gl
1361
+ const drop = (t) => {
1362
+ if (!t) return
1363
+ gl.deleteTexture(t.tex); this.counts.textures--
1364
+ if (t.fbo) { gl.deleteFramebuffer(t.fbo); this.counts.framebuffers-- }
1365
+ }
1366
+ drop(this.input)
1367
+ this.input = null
1368
+ for (const t of [...this.main, ...this.pool, ...this.inUse]) drop(t)
1369
+ this.main = []; this.pool = []; this.inUse = []
1370
+ for (const fx of this.effects) { if (fx.history) fx.history.forEach(drop); fx.history = null }
1371
+ if (this.sounds) { drop(this.sounds.spectrum); drop(this.sounds.waveform); this.sounds = null; this.soundStamp = -1 }
1372
+ }
1373
+
1374
+ texture(w, h, { fbo = true, float = false, data = null, format = null } = {}) {
1375
+ const gl = this.gl
1376
+ const tex = gl.createTexture()
1377
+ this.counts.textures++
1378
+ gl.bindTexture(gl.TEXTURE_2D, tex)
1379
+ if (format === 'r8') gl.texImage2D(gl.TEXTURE_2D, 0, gl.R8, w, h, 0, gl.RED, gl.UNSIGNED_BYTE, data)
1380
+ else if (w) gl.texStorage2D(gl.TEXTURE_2D, 1, float ? gl.RGBA16F : gl.RGBA8, w, h)
1381
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
1382
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
1383
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
1384
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
1385
+ const t = { tex, w, h, fbo: null }
1386
+ if (fbo) {
1387
+ t.fbo = gl.createFramebuffer()
1388
+ this.counts.framebuffers++
1389
+ gl.bindFramebuffer(gl.FRAMEBUFFER, t.fbo)
1390
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0)
1391
+ gl.clearColor(0, 0, 0, 1)
1392
+ gl.clear(gl.COLOR_BUFFER_BIT)
1393
+ }
1394
+ return t
1395
+ }
1396
+
1397
+ /** A picture to work in for this frame, of a size: taken from those kept, or made. */
1398
+ scratch(w, h) {
1399
+ w = Math.max(1, Math.round(w)); h = Math.max(1, Math.round(h))
1400
+ const i = this.pool.findIndex((t) => t.w === w && t.h === h)
1401
+ const t = i >= 0 ? this.pool.splice(i, 1)[0] : this.texture(w, h)
1402
+ this.inUse.push(t)
1403
+ return t
1404
+ }
1405
+
1406
+ /** A program by name, compiled the first time it is used. `said` is how a mistake in it is told. */
1407
+ program(key, body, said) {
1408
+ let p = this.programs.get(key)
1409
+ if (p) return p
1410
+ const gl = this.gl
1411
+ const compile = (type, source) => {
1412
+ const s = gl.createShader(type)
1413
+ gl.shaderSource(s, source)
1414
+ gl.compileShader(s)
1415
+ return s
1416
+ }
1417
+ const vs = compile(gl.VERTEX_SHADER, VERTEX), fs = compile(gl.FRAGMENT_SHADER, HEAD + body)
1418
+ const program = gl.createProgram()
1419
+ gl.attachShader(program, vs)
1420
+ gl.attachShader(program, fs)
1421
+ gl.linkProgram(program)
1422
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS) && !gl.isContextLost()) {
1423
+ const log = gl.getShaderInfoLog(fs) || gl.getProgramInfoLog(program) || ''
1424
+ gl.deleteShader(vs); gl.deleteShader(fs); gl.deleteProgram(program)
1425
+ throw new Error(said ? said(log, HEAD.split('\n').length - 1) : `visualfx.js: the ${key} effect's program did not compile on this device:\n${log}`)
1426
+ }
1427
+ gl.deleteShader(vs); gl.deleteShader(fs)
1428
+ this.counts.programs++
1429
+ const locs = {}
1430
+ const n = gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS) || 0
1431
+ for (let i = 0; i < n; i++) {
1432
+ const name = gl.getActiveUniform(program, i).name.replace(/\[0\]$/, '')
1433
+ locs[name] = gl.getUniformLocation(program, name)
1434
+ }
1435
+ p = { program, locs }
1436
+ this.programs.set(key, p)
1437
+ return p
1438
+ }
1439
+
1440
+ /**
1441
+ * Run a program over every pixel of `to` (a picture, or null for the screen), reading `from`.
1442
+ * `values` are its own numbers; `fx` gives the effect's amount and its last frame.
1443
+ */
1444
+ draw(p, from, to, values, fx, extra) {
1445
+ const gl = this.gl, L = p.locs
1446
+ gl.bindFramebuffer(gl.FRAMEBUFFER, to ? to.fbo : null)
1447
+ gl.viewport(0, 0, to ? to.w : this.w, to ? to.h : this.h)
1448
+ gl.useProgram(p.program)
1449
+ // The sound's pictures are made or brought up to date first: doing that leaves one of them where the scene's picture goes.
1450
+ const sounds = L.spectrum || L.waveform ? this.soundPictures() : null
1451
+ gl.activeTexture(gl.TEXTURE0)
1452
+ gl.bindTexture(gl.TEXTURE_2D, from.tex)
1453
+ if (L.picture) gl.uniform1i(L.picture, 0)
1454
+ if (L.flip) gl.uniform1f(L.flip, to ? 0 : 1)
1455
+ if (L.resolution) gl.uniform2f(L.resolution, to ? to.w : this.w, to ? to.h : this.h)
1456
+ if (L.size) gl.uniform2f(L.size, this.W, this.H)
1457
+ if (L.time) gl.uniform1f(L.time, this.time)
1458
+ if (L.dt) gl.uniform1f(L.dt, this.dt)
1459
+ if (L.frame) gl.uniform1f(L.frame, this.frames)
1460
+ if (L.amount) gl.uniform1f(L.amount, fx ? clamp(fx.get('amount'), 0, 1) : 1)
1461
+ if (L.seed && !(values && 'seed' in values)) gl.uniform1f(L.seed, 1)
1462
+ const l = this.listener
1463
+ if (L.bass || L.mids || L.highs || L.loudness || L.pulse || L.spectrum || L.waveform) l.used = true
1464
+ if (L.bass) gl.uniform1f(L.bass, l.bass)
1465
+ if (L.mids) gl.uniform1f(L.mids, l.mids)
1466
+ if (L.highs) gl.uniform1f(L.highs, l.highs)
1467
+ if (L.loudness) gl.uniform1f(L.loudness, l.loudness)
1468
+ if (L.pulse) gl.uniform1f(L.pulse, l.pulse)
1469
+ if (sounds) {
1470
+ const s = sounds
1471
+ gl.activeTexture(gl.TEXTURE2); gl.bindTexture(gl.TEXTURE_2D, s.spectrum.tex)
1472
+ gl.activeTexture(gl.TEXTURE3); gl.bindTexture(gl.TEXTURE_2D, s.waveform.tex)
1473
+ if (L.spectrum) gl.uniform1i(L.spectrum, 2)
1474
+ if (L.waveform) gl.uniform1i(L.waveform, 3)
1475
+ }
1476
+ if (L.previous) {
1477
+ gl.activeTexture(gl.TEXTURE1)
1478
+ gl.bindTexture(gl.TEXTURE_2D, extra && extra.previous ? extra.previous.tex : from.tex)
1479
+ gl.uniform1i(L.previous, 1)
1480
+ }
1481
+ if (L.other && extra && extra.other) {
1482
+ gl.activeTexture(gl.TEXTURE4)
1483
+ gl.bindTexture(gl.TEXTURE_2D, extra.other.tex)
1484
+ gl.uniform1i(L.other, 4)
1485
+ }
1486
+ for (const name in values) {
1487
+ const loc = L[name], v = values[name]
1488
+ if (!loc) continue
1489
+ if (typeof v === 'number') gl.uniform1f(loc, v)
1490
+ else if (v.length === 2) gl.uniform2f(loc, v[0], v[1])
1491
+ else if (v.length === 3) gl.uniform3f(loc, v[0], v[1], v[2])
1492
+ else gl.uniform3fv(loc, v)
1493
+ }
1494
+ gl.drawArrays(gl.TRIANGLES, 0, 3)
1495
+ this.passes++
1496
+ }
1497
+
1498
+ /** The sound as two thin pictures a program can read: the spectrum and the wave. */
1499
+ soundPictures() {
1500
+ const gl = this.gl, l = this.listener
1501
+ if (!this.sounds) {
1502
+ this.sounds = { spectrum: this.texture(64, 1, { fbo: false, format: 'r8', data: l.spectrum }), waveform: this.texture(256, 1, { fbo: false, format: 'r8', data: l.waveform }) }
1503
+ this.soundStamp = l.stamp
1504
+ } else if (this.soundStamp !== l.stamp) {
1505
+ this.soundStamp = l.stamp
1506
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1)
1507
+ gl.bindTexture(gl.TEXTURE_2D, this.sounds.spectrum.tex)
1508
+ gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, 64, 1, gl.RED, gl.UNSIGNED_BYTE, l.spectrum)
1509
+ gl.bindTexture(gl.TEXTURE_2D, this.sounds.waveform.tex)
1510
+ gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, 256, 1, gl.RED, gl.UNSIGNED_BYTE, l.waveform)
1511
+ }
1512
+ return this.sounds
1513
+ }
1514
+
1515
+ // --- the size: the scene's canvas, pixel for pixel, or a part of it when that is too much for the device
1516
+ fit() {
1517
+ const { canvas, layer, gl } = this
1518
+ const W = canvas.clientWidth || canvas.width, H = canvas.clientHeight || canvas.height
1519
+ this.W = W; this.H = H
1520
+ if (this.quality === 'auto' && !this.sized) {
1521
+ // A tablet's screen has more pixels than its graphics card can fill several times a frame: it starts
1522
+ // with about as many as a 1080p picture. From there `watch` takes it down if the device is not keeping up.
1523
+ this.sized = true
1524
+ let coarse = false
1525
+ try { coarse = matchMedia('(pointer: coarse)').matches } catch (e) { /* no such question here */ }
1526
+ this.scale = Math.min(1, Math.sqrt((coarse ? 2.1e6 : 9e6) / Math.max(1, canvas.width * canvas.height)))
1527
+ }
1528
+ // Never fewer than a pixel for each of the scene's points.
1529
+ this.least = Math.min(1, Math.max(0.25, W / Math.max(1, canvas.width)))
1530
+ const scale = typeof this.quality === 'number' ? clamp(this.quality, 0.1, 1) : Math.max(this.least, this.scale)
1531
+ const w = Math.max(1, Math.round(canvas.width * scale)), h = Math.max(1, Math.round(canvas.height * scale))
1532
+ if (layer.style.left !== canvas.offsetLeft + 'px') layer.style.left = canvas.offsetLeft + 'px'
1533
+ if (layer.style.top !== canvas.offsetTop + 'px') layer.style.top = canvas.offsetTop + 'px'
1534
+ if (layer.style.width !== W + 'px') layer.style.width = W + 'px'
1535
+ if (layer.style.height !== H + 'px') layer.style.height = H + 'px'
1536
+ // Straight after the scene's canvas, under anything else laid over it (a dial is not blurred).
1537
+ const below = this.over() || canvas
1538
+ if (below.nextSibling !== layer) below.after(layer)
1539
+ if (w === this.w && h === this.h) return
1540
+ if (this.w) this.free()
1541
+ this.w = w; this.h = h
1542
+ layer.width = w
1543
+ layer.height = h
1544
+ this.main = [this.texture(w, h), this.texture(w, h)]
1545
+ }
1546
+
1547
+ /** What is laid over the scene's canvas as part of its picture just now (see `under`), if anything is. */
1548
+ over() {
1549
+ if (!this.under) return null
1550
+ try { const c = this.under(); return c && c.isConnected && c.width && c.height ? c : null } catch (e) { return null }
1551
+ }
1552
+
1553
+ /**
1554
+ * The scene's canvas as a picture on the graphics card. Some browsers hand it over without touching the
1555
+ * pixels; Safari's engine reads every one back first, which at a tablet's full size is most of a frame.
1556
+ * So when the effects work on fewer pixels than the canvas has, it is made that small first (a 2D
1557
+ * canvas-to-canvas copy, which the card does), and only the small one is handed over.
1558
+ */
1559
+ upload() {
1560
+ const gl = this.gl, c = this.canvas
1561
+ const t0 = performance.now()
1562
+ let from = c
1563
+ const over = this.over()
1564
+ if (over || this.w < c.width * 0.95) {
1565
+ if (!this.small) this.small = document.createElement('canvas').getContext('2d')
1566
+ const s = this.small
1567
+ if (s.canvas.width !== this.w || s.canvas.height !== this.h) { s.canvas.width = this.w; s.canvas.height = this.h }
1568
+ s.globalCompositeOperation = 'copy'
1569
+ s.drawImage(c, 0, 0, this.w, this.h)
1570
+ if (over) { s.globalCompositeOperation = 'source-over'; s.drawImage(over, 0, 0, this.w, this.h) }
1571
+ from = s.canvas
1572
+ }
1573
+ if (!this.input) this.input = this.texture(0, 0, { fbo: false })
1574
+ this.input.w = from.width; this.input.h = from.height
1575
+ gl.bindTexture(gl.TEXTURE_2D, this.input.tex)
1576
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, from)
1577
+ // How long the hand-over held the page up (nothing, where the card does it by itself).
1578
+ const took = performance.now() - t0
1579
+ this.handover += (took - this.handover) * 0.2
1580
+ }
1581
+
1582
+ /**
1583
+ * Is the device keeping up? When the hand-over takes more than a few milliseconds, or frames have been
1584
+ * late for a while, the effects work on fewer pixels. Only the wall clock is used here, and only for
1585
+ * this: under a recorder's stepped clock nothing is ever late, so a recording keeps every pixel.
1586
+ */
1587
+ watch() {
1588
+ const now = performance.now()
1589
+ const gap = now - this.clock
1590
+ this.clock = now
1591
+ if (this.quality !== 'auto' || !(gap > 0) || gap > 250 || this.scale <= this.least) { this.slow = 0; return }
1592
+ if (this.handover > 4 && this.frames > 10) {
1593
+ this.scale = Math.max(this.least, this.scale * Math.sqrt(3 / this.handover))
1594
+ this.handover = 0
1595
+ return
1596
+ }
1597
+ this.pace = this.pace ? this.pace + (gap - this.pace) * 0.1 : gap
1598
+ if (this.frames > 30) this.best = Math.min(this.best, this.pace)
1599
+ this.slow = this.pace > this.best * 1.4 ? this.slow + 1 : 0
1600
+ if (this.slow > 45) {
1601
+ this.scale = Math.max(this.least, this.scale * 0.75)
1602
+ this.slow = 0
1603
+ }
1604
+ }
1605
+
1606
+ /**
1607
+ * ONE FRAME: the scene has drawn, `dt` seconds after the last. A host calls this after the scene's
1608
+ * draw (`manual`); otherwise it is called each frame the browser gives.
1609
+ */
1610
+ frame(dt = 1 / 60) {
1611
+ if (this.closed) return
1612
+ this.dt = dt
1613
+ this.time += dt
1614
+ this.frames++
1615
+ if (!this.heard && this.sound) { this.heard = true; try { this.listener.listen(typeof this.sound === 'function' ? this.sound() : this.sound) } catch (e) { /* no sound after all */ } }
1616
+ this.listener.update(dt)
1617
+ this.tell()
1618
+ const active = this.effects.filter((fx) => fx.active)
1619
+ if (!active.length || !this.canvas.width || !this.canvas.height) {
1620
+ if (this.layer && this.layer.style.display !== 'none') this.layer.style.display = 'none'
1621
+ return
1622
+ }
1623
+ if (!this.open() || this.lost) return
1624
+ this.watch()
1625
+ this.fit()
1626
+ if (this.layer.style.display !== 'block') this.layer.style.display = 'block'
1627
+ this.passes = 0
1628
+ this.upload()
1629
+ // Backdrops go behind the scene's drawing, so they are first whatever the order.
1630
+ active.sort((a, b) => (b.def.backdrop ? 1 : 0) - (a.def.backdrop ? 1 : 0))
1631
+ let from = this.input
1632
+ for (let i = 0; i < active.length; i++) {
1633
+ const fx = active[i], last = i === active.length - 1
1634
+ const to = last ? null : from === this.main[0] ? this.main[1] : this.main[0]
1635
+ from = (fx.def.run || runOne)(this, fx, from, to) || to
1636
+ }
1637
+ this.pool.push(...this.inUse.splice(0))
1638
+ if (this.after) this.after(this.api)
1639
+ }
1640
+
1641
+ /** The music's numbers for a host that asked to be told (Python), when they have changed. */
1642
+ tell() {
1643
+ const l = this.listener
1644
+ if (!this.told || !this.armed) return
1645
+ const now = [l.bass, l.mids, l.highs, l.loudness, l.pulse].map((v) => Math.round(v * 1000) / 1000)
1646
+ if (now.join() === this.toldLast) return
1647
+ this.toldLast = now.join()
1648
+ this.armed = false
1649
+ // Told as an object with one list in it: that is the shape a host's callback is given (Pyctures makes an event of it).
1650
+ this.told({ b: now })
1651
+ }
1652
+
1653
+ /** The picture on the screen as numbers, straight after a frame: { width, height, data } with four numbers a pixel, top row first. */
1654
+ pixels() {
1655
+ const gl = this.gl
1656
+ if (!gl || !this.w || this.layer.style.display === 'none') {
1657
+ // No effect is on: the picture is the scene's own.
1658
+ const c = this.canvas, copy = Object.assign(document.createElement('canvas'), { width: c.width, height: c.height }).getContext('2d')
1659
+ copy.drawImage(c, 0, 0)
1660
+ return { width: c.width, height: c.height, data: new Uint8Array(copy.getImageData(0, 0, c.width, c.height).data.buffer) }
1661
+ }
1662
+ const { w, h } = this, data = new Uint8Array(w * h * 4), row = w * 4
1663
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null)
1664
+ gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, data)
1665
+ // The graphics card counts rows from the bottom.
1666
+ const line = new Uint8Array(row)
1667
+ for (let y = 0; y < h >> 1; y++) {
1668
+ const a = y * row, b = (h - 1 - y) * row
1669
+ line.set(data.subarray(a, a + row)); data.copyWithin(a, b, b + row); data.set(line, b)
1670
+ }
1671
+ return { width: w, height: h, data }
1672
+ }
1673
+
1674
+ make(name, args) {
1675
+ const def = DEFS[name]
1676
+ const given = {}
1677
+ args = [...args]
1678
+ const last = args[args.length - 1]
1679
+ const options = isPlain(last) ? spelled(args.pop()) : {}
1680
+ if (args.length > def.order.length) throw new Error(`${name}() was given ${args.length} values and takes ${def.order.length ? `${def.order.length} (${def.order.join(', ')})` : 'none'}. Its other settings go in by name: ${name}(${def.order.length ? '…, ' : ''}{ ${Object.keys(def.settings).filter((n) => !def.order.includes(n)).slice(0, 2).map((n) => `${n}: …`).join(', ')} })`)
1681
+ args.forEach((v, i) => { if (v !== undefined) given[def.order[i]] = v })
1682
+ const fx = new Effect(this, def, { ...given, ...options })
1683
+ fx.proxy = new Proxy(fx, EFFECT)
1684
+ if (this.effects.length >= 16) console.warn('That is more than sixteen effects on one scene: each is a pass over every pixel, so a tablet will slow down.')
1685
+ this.effects.push(fx)
1686
+ this.wake()
1687
+ return fx.proxy
1688
+ }
1689
+
1690
+ drop(fx) {
1691
+ const i = this.effects.indexOf(fx)
1692
+ if (i < 0) return
1693
+ this.effects.splice(i, 1)
1694
+ fx.active = false
1695
+ if (fx.history && this.gl) {
1696
+ for (const t of fx.history) { this.gl.deleteTexture(t.tex); this.gl.deleteFramebuffer(t.fbo); this.counts.textures--; this.counts.framebuffers-- }
1697
+ fx.history = null
1698
+ }
1699
+ }
1700
+ }
1701
+
1702
+ // --- how an effect is run. Each is given the picture so far and where to put its own (null: the screen),
1703
+ // and gives back the picture it wrote when that is one of its own.
1704
+ const uniforms = {}
1705
+ function valuesOf(pipe, fx) {
1706
+ const out = uniforms, def = fx.def
1707
+ for (const k in out) delete out[k]
1708
+ for (const name in def.settings) {
1709
+ if (name === 'amount') continue
1710
+ const s = def.settings[name], as = (def.rename && def.rename[name]) || name
1711
+ const v = fx.get(name)
1712
+ if (s.kind === 'colour') out[as] = rgb(v)
1713
+ else if (s.kind === 'palette') { const p = paletteOf(v); out.pal = p.data; out.palCount = p.count }
1714
+ else if (s.kind === 'choice') out[as] = s.choices.indexOf(v)
1715
+ else if (s.kind === 'bool') out[as] = v ? 1 : 0
1716
+ else out[as] = s.unit === 'degrees' ? v * Math.PI / 180 : v
1717
+ }
1718
+ return out
1719
+ }
1720
+ const declared = (def) => Object.keys(def.settings).filter((n) => n !== 'amount' && n !== 'seed' && def.settings[n].kind !== 'palette')
1721
+ .map((n) => `uniform ${def.settings[n].kind === 'colour' ? 'vec3' : 'float'} ${(def.rename && def.rename[n]) || n};`).join('\n')
1722
+ /** Two pictures of its own that an effect writes in turn, so it can read the one it made last frame. */
1723
+ function historyOf(pipe, fx) {
1724
+ if (!fx.history || fx.history[0].w !== pipe.w || fx.history[0].h !== pipe.h) {
1725
+ if (fx.history) for (const t of fx.history) { pipe.gl.deleteTexture(t.tex); pipe.gl.deleteFramebuffer(t.fbo); pipe.counts.textures--; pipe.counts.framebuffers-- }
1726
+ fx.history = [pipe.texture(pipe.w, pipe.h, { float: pipe.float }), pipe.texture(pipe.w, pipe.h, { float: pipe.float })]
1727
+ fx.flipped = 0
1728
+ }
1729
+ fx.flipped ^= 1
1730
+ return [fx.history[fx.flipped], fx.history[fx.flipped ^ 1]]
1731
+ }
1732
+ function runOne(pipe, fx, from, to) {
1733
+ const def = fx.def
1734
+ const p = pipe.program(def.key || def.name, `${declared(def)}\n${def.pre || ''}\n${def.glsl}\n${def.post || ''}\n${def.backdrop ? MAIN_BACKDROP : MAIN}`, def.said)
1735
+ if (!def.feedback) { pipe.draw(p, from, to, valuesOf(pipe, fx), fx); return to }
1736
+ const [now, before] = historyOf(pipe, fx)
1737
+ pipe.draw(p, from, now, valuesOf(pipe, fx), fx, { previous: before })
1738
+ if (!to) pipe.draw(pipe.program('copy', COPY), now, null)
1739
+ return now
1740
+ }
1741
+
1742
+ DEFS.blur = {
1743
+ name: 'blur', order: ['radius'], settings: { radius: num(8, 0, 200), amount: AMOUNT },
1744
+ run(pipe, fx, from, to) {
1745
+ // The blur is as wide as asked in the scene's points. A wide one is done on a smaller picture, which is the
1746
+ // same blur for a fraction of the work: each halving is itself a little blur.
1747
+ const sigma = Math.max(0.001, fx.get('radius')) * 0.5 * pipe.w / pipe.W
1748
+ const copy = pipe.program('copy', COPY), gauss = pipe.program('gauss', GAUSS)
1749
+ let level = Math.max(0, Math.ceil(Math.log2(sigma / 2.5))), src = from, w = pipe.w, h = pipe.h
1750
+ for (let i = 0; i < level; i++) {
1751
+ w /= 2; h /= 2
1752
+ if (w < 4 || h < 4) { level = i; w *= 2; h *= 2; break }
1753
+ const small = pipe.scratch(w, h)
1754
+ pipe.draw(copy, src, small)
1755
+ src = small
1756
+ }
1757
+ const s = Math.max(0.05, sigma / 2 ** level)
1758
+ const a = pipe.scratch(w, h), b = pipe.scratch(w, h)
1759
+ pipe.draw(gauss, src, a, { dir: [1 / a.w, 0], sigma: s })
1760
+ pipe.draw(gauss, a, b, { dir: [0, 1 / a.h], sigma: s })
1761
+ pipe.draw(pipe.program('blend', BLEND), from, to, { add: 0 }, fx, { other: b })
1762
+ return to
1763
+ },
1764
+ }
1765
+ DEFS.glow = {
1766
+ name: 'glow', order: ['strength'], settings: { strength: num(0.8, 0, 4), threshold: num(0.5, 0, 1), radius: num(24, 2, 200), amount: AMOUNT },
1767
+ run(pipe, fx, from, to) {
1768
+ const reach = Math.max(2, fx.get('radius')) * pipe.w / pipe.W
1769
+ // Smaller and smaller pictures of what is bright, then back up, each added to the one above: a wide soft
1770
+ // glow for the price of a few small passes. The smallest fades in, so the radius can be turned smoothly.
1771
+ const depth = clamp(Math.log2(reach) - 1, 1, 7), levels = Math.ceil(depth), part = depth - (levels - 1)
1772
+ const shrink = pipe.program('shrink', SHRINK), grow = pipe.program('grow', GROW)
1773
+ const down = []
1774
+ let w = pipe.w / 2, h = pipe.h / 2, src = from
1775
+ const first = pipe.scratch(w, h)
1776
+ pipe.draw(pipe.program('bright', BRIGHT), from, first, { threshold: fx.get('threshold'), texel: [0.5 / from.w, 0.5 / from.h] })
1777
+ down.push(first)
1778
+ src = first
1779
+ for (let i = 1; i < levels && w >= 8 && h >= 8; i++) {
1780
+ w /= 2; h /= 2
1781
+ const t = pipe.scratch(w, h)
1782
+ pipe.draw(shrink, src, t, { texel: [1 / src.w, 1 / src.h] })
1783
+ down.push(t)
1784
+ src = t
1785
+ }
1786
+ let up = down[down.length - 1], weight = down.length === levels ? part : 1
1787
+ for (let i = down.length - 2; i >= 0; i--) {
1788
+ const t = pipe.scratch(down[i].w, down[i].h)
1789
+ pipe.draw(grow, up, t, { texel: [0.5 / up.w, 0.5 / up.h], weight }, null, { other: down[i] })
1790
+ up = t
1791
+ weight = 1
1792
+ }
1793
+ const total = down.length > 1 ? down.length - 1 + (down.length === levels ? part : 1) : 1
1794
+ pipe.draw(pipe.program('blend', BLEND), from, to, { add: fx.get('strength') * 2 / total }, fx, { other: up })
1795
+ return to
1796
+ },
1797
+ }
1798
+ DEFS.trails = {
1799
+ name: 'trails', order: ['length'],
1800
+ settings: { length: num(0.5, 0, 20, { unit: 's' }), zoom: num(1, 0.1, 10), spin: angle(0, -720, 720), hue: angle(0, -720, 720), dx: num(0, -2000, 2000), dy: num(0, -2000, 2000), x: place(), y: place(), amount: AMOUNT },
1801
+ run(pipe, fx, from, to) {
1802
+ const dt = pipe.dt, length = fx.get('length')
1803
+ const [now, before] = historyOf(pipe, fx)
1804
+ // `length` is the seconds a trail takes to fade to a tenth, whatever the frame rate. Zoom, spin and hue are
1805
+ // by the second too. Where the card cannot keep fine shades, a sliver is taken off so a trail does end.
1806
+ pipe.draw(pipe.program('trail', TRAIL), from, now, {
1807
+ keep: length > 0 ? Math.pow(0.1, dt / length) : 0, grow: Math.pow(Math.max(0.01, fx.get('zoom')), dt), spun: fx.get('spin') * Math.PI / 180 * dt, hued: fx.get('hue') * Math.PI / 180 * dt,
1808
+ cut: pipe.float ? 0 : 0.6 / 255, centre: [fx.get('x'), fx.get('y')], moved: [fx.get('dx') * dt, fx.get('dy') * dt],
1809
+ }, fx, { previous: before })
1810
+ const amount = fx.get('amount')
1811
+ if (to || amount < 1) {
1812
+ if (amount < 1) pipe.draw(pipe.program('blend', BLEND), from, to, { add: 0 }, fx, { other: now })
1813
+ else return now
1814
+ return to
1815
+ }
1816
+ pipe.draw(pipe.program('copy', COPY), now, null)
1817
+ return now
1818
+ },
1819
+ }
1820
+
1821
+ // --------------------------------------------------------------------------------------------
1822
+ // YOUR OWN EFFECT, from a program for the graphics card. See the README for what it is given.
1823
+ let shaders = 0
1824
+ function customDef(source, settings) {
1825
+ if (typeof source !== 'string' || !/\beffect\s*\(/.test(source)) throw new Error('shader() takes a program that has a function `vec4 effect(vec2 uv)`: the colour at one place of the picture. For example: shader("vec4 effect(vec2 uv) { return texture(picture, uv).bgra; }")')
1826
+ const defs = {}
1827
+ for (const name in settings) {
1828
+ const v = settings[name]
1829
+ if (/^(picture|previous|spectrum|waveform|resolution|size|time|dt|frame|amount|bass|mids|highs|loudness|pulse|pal|palCount)$/.test(name)) throw new Error(`A shader is already given something called ${name}: call your setting something else`)
1830
+ if (typeof v === 'number') defs[name] = num(v, Math.min(0, v * 4), Math.max(1, v * 4))
1831
+ else if (Array.isArray(v) && v.length === 3 && v.every((n) => typeof n === 'number')) defs[name] = num(v[0], v[1], v[2])
1832
+ else if (typeof v === 'boolean') defs[name] = flag(v)
1833
+ else if (typeof v === 'string') defs[name] = colour(v)
1834
+ else throw new Error(`A shader's setting is a number (speed: 2), a number with its range (speed: [2, 0, 10]), true or false, or a colour ("gold"). ${name} is ${JSON.stringify(v)}`)
1835
+ }
1836
+ const def = { name: 'shader', key: `shader${++shaders}`, order: [], settings: { ...defs, amount: AMOUNT }, glsl: `#line 1\n${source}`, feedback: /\bprevious\b/.test(source) }
1837
+ def.said = (log) => {
1838
+ // The card says "ERROR: 0:12: …" with the line of the shader as it was written.
1839
+ const lines = String(log).replace(/\0/g, '').split('\n').map((l) => l.trim()).filter(Boolean).map((l) => l.replace(/^ERROR:\s*\d+:(\d+):\s*/, 'line $1: '))
1840
+ return `Your shader has a mistake the graphics card will not accept:\n${lines.join('\n') || 'it gave no reason'}\nIt is given: picture, previous, resolution, size, time, dt, frame, amount, bass, mids, highs, loudness, pulse, spectrum, waveform${Object.keys(defs).length ? ', and your settings ' + Object.keys(defs).join(', ') : ''}. Numbers need a decimal point (2.0, not 2).`
1841
+ }
1842
+ return def
1843
+ }
1844
+
1845
+ // --------------------------------------------------------------------------------------------
1846
+ // What a scene is given for one canvas.
1847
+ const NAMES = [...Object.keys(DEFS)]
1848
+ function api(pipe) {
1849
+ const out = {}
1850
+ for (const name of NAMES) out[name] = (...args) => pipe.make(name, args)
1851
+ // The other spellings of the same things.
1852
+ out.bloom = out.glow
1853
+ out.colorCycle = out.colourCycle
1854
+ out.bulge = out.fisheye
1855
+ out.halftone = out.dots
1856
+ out.colorBleed = out.colourBleed
1857
+ out.godRays = out.lightRays
1858
+ const band = (name) => () => { pipe.listener.used = true; return pipe.listener[name] }
1859
+ const bands = { bass: band('bass'), mids: band('mids'), highs: band('highs'), loudness: band('loudness'), pulse: band('pulse') }
1860
+ const bandOf = (which) => {
1861
+ if (typeof which === 'function') return which
1862
+ const fn = bands[String(which).toLowerCase()] || (String(which).toLowerCase() === 'level' ? bands.loudness : null) || (String(which).toLowerCase() === 'beat' ? bands.pulse : null)
1863
+ if (!fn) throw new Error(`"${which}" is not something the music has. There are bass, mids, highs, loudness and pulse`)
1864
+ return fn
1865
+ }
1866
+ const made = new Map() // effects by a host's number (Python)
1867
+ Object.assign(out, bands, {
1868
+ /** Your own effect: shader("vec4 effect(vec2 uv) { … }", { speed: 2 }). It is compiled now, so a mistake is said here. */
1869
+ shader(source, settings = {}) {
1870
+ const def = customDef(source, settings)
1871
+ if (!pipe.open()) throw new Error('A shader needs WebGL 2, which this browser or device does not have')
1872
+ DEFS[def.key] = def
1873
+ try {
1874
+ pipe.program(def.key, `${declared(def)}\n${def.glsl}\n${MAIN}`, def.said)
1875
+ return pipe.make(def.key, [])
1876
+ } finally { delete DEFS[def.key] }
1877
+ },
1878
+ /** Several effects at once: look("dream"). It gives them back by name, with off(), on() and remove() for the lot. */
1879
+ look(name, amount = 1) {
1880
+ const key = String(name).toLowerCase().replace(/[\s_-]/g, '')
1881
+ const recipe = LOOKS[key]
1882
+ if (!recipe) throw new Error(`There is no look called "${name}". There are ${Object.keys(LOOKS).join(', ')}`)
1883
+ const parts = recipe.map(([effect, settings]) => pipe.make(effect, [{ ...settings, amount }]))
1884
+ const all = { name: key, effects: parts, on() { parts.forEach((p) => p.on()); return all }, off() { parts.forEach((p) => p.off()); return all }, remove() { parts.forEach((p) => p.remove()) } }
1885
+ recipe.forEach(([effect], i) => { all[effect] = parts[i] })
1886
+ Object.defineProperty(all, 'amount', { get: () => parts[0].amount, set: (v) => { for (const p of parts) p.amount = v } })
1887
+ return all
1888
+ },
1889
+ lookNames: () => Object.keys(LOOKS),
1890
+ effectNames: () => [...NAMES],
1891
+ paletteNames: () => Object.keys(PALETTES),
1892
+ clearEffects() { for (const fx of [...pipe.effects]) pipe.drop(fx); made.clear() },
1893
+ /** What the music is taken from: soundOutput(), a synth, a song, the microphone, or anything with samples(). */
1894
+ listenTo(target) {
1895
+ pipe.heard = true
1896
+ pipe.listener.used = true
1897
+ pipe.listener.listen(target)
1898
+ return { ...bands, level: bands.loudness, spectrum: () => pipe.listener.spectrum, waveform: () => pipe.listener.waveform, stop() { pipe.listener.listen(null) } }
1899
+ },
1900
+ /** Tie a setting to the music: react(glowing, "strength", "bass", { from: 0.3, to: 1.2 }). */
1901
+ react(effect, setting, which = 'loudness', range = {}) {
1902
+ const fx = typeof effect === 'number' ? made.get(effect) : effect
1903
+ if (!fx || typeof fx.describe !== 'function') throw new Error('react() is given an effect, the name of one of its settings and what to follow: react(glowing, "strength", "bass")')
1904
+ const d = fx.describe(setting), fn = bandOf(which)
1905
+ const now = fx.get(setting)
1906
+ const { from = typeof now === 'number' ? now : d.min ?? 0, to = d.max ?? 1 } = range || {}
1907
+ fx.set(setting, () => from + (to - from) * fn())
1908
+ return fx
1909
+ },
1910
+ /** How many of the canvas's pixels the effects work on: 1 is all, 0.5 is half each way, "auto" lets the device decide. */
1911
+ effectsQuality(value) {
1912
+ if (value === undefined) return pipe.quality === 'auto' ? pipe.scale : pipe.quality
1913
+ if (value !== 'auto' && !(typeof value === 'number' && value > 0)) throw new Error('effectsQuality() is given a number from 0.25 to 1, or "auto"')
1914
+ pipe.quality = value
1915
+ pipe.scale = 1
1916
+ pipe.sized = false
1917
+ pipe.best = Infinity
1918
+ },
1919
+ // --- for a host (pictures.js, Pyctures): the frame, the picture, and the end
1920
+ frame: (dt) => pipe.frame(dt),
1921
+ /** Called each time a frame's picture is on the screen, when `pixels()` can be read (a recorder's moment). */
1922
+ onFrame(fn) { pipe.after = fn },
1923
+ pixels: () => pipe.pixels(),
1924
+ stats: () => ({ ...pipe.counts, handover: pipe.handover, passes: pipe.passes, effects: pipe.effects.length, width: pipe.w, height: pipe.h, scale: pipe.w && pipe.canvas.width ? pipe.w / pipe.canvas.width : 1, float: pipe.float, context: !!pipe.gl, lost: pipe.lost, time: pipe.time, frames: pipe.frames }),
1925
+ close: () => pipe.close(),
1926
+ pipeline: pipe,
1927
+ // --- for a host that keeps the scene's code somewhere else (Python): effects by number, one call each
1928
+ make(kind, id, args) {
1929
+ const name = camel(kind)
1930
+ if (name === 'look') { const all = out.look(...(args || [])); made.set(id, all); return }
1931
+ if (name === 'shader') { made.set(id, out.shader(...(args || []))); return }
1932
+ if (!(name in DEFS || ['bloom', 'colorCycle', 'bulge', 'halftone', 'colorBleed', 'godRays'].includes(name))) throw new Error(`There is no effect called ${kind}`)
1933
+ made.set(id, out[name](...(args || [])))
1934
+ },
1935
+ set(id, name, value) { const fx = made.get(id); if (fx) fx[camel(name)] = value },
1936
+ call(id, name) { const fx = made.get(id); if (fx && typeof fx[name] === 'function') fx[name]() },
1937
+ listen: (target) => out.listenTo(target),
1938
+ /** The music's five numbers are told to `fn` as { b: [bass, mids, highs, loudness, pulse] } when they change, once it has answered the last with ack(). */
1939
+ report(fn) { pipe.told = fn; pipe.armed = true; pipe.listener.used = true },
1940
+ ack() { pipe.armed = true },
1941
+ })
1942
+ return out
1943
+ }
1944
+
1945
+ /** The effects of one canvas: `const { glow, trails } = effects(canvas)`. With no canvas, the page's only one. */
1946
+ function effects(canvas, options) {
1947
+ if (canvas && !(typeof canvas.getBoundingClientRect === 'function')) { options = canvas; canvas = null }
1948
+ canvas ||= onlyCanvas()
1949
+ let pipe = canvas[LAYER]
1950
+ // `fresh`: a new run of the scene on a canvas that was kept starts with no effects.
1951
+ if (pipe && options && options.fresh) { pipe.close(); pipe = null }
1952
+ if (!pipe) {
1953
+ pipe = canvas[LAYER] = new Pipeline(canvas, options || {})
1954
+ pipe.api = api(pipe)
1955
+ }
1956
+ return pipe.api
1957
+ }
1958
+ /**
1959
+ * WHAT EACH EFFECT IS, in a line, and the group it is shown in. The documentation (docs/effects.md, its pictures)
1960
+ * is written from this and from the settings above, so an effect added here is in the docs with no more to do.
1961
+ */
1962
+ const GROUPS = {
1963
+ 'Shapes of the picture': ['kaleidoscope', 'mirror', 'tile', 'pixelate', 'hexagons', 'waves', 'ripple', 'wobble', 'heatHaze', 'swirl', 'fisheye', 'tunnel', 'droste', 'rotozoom', 'glassTiles', 'frosted', 'caustics', 'shake', 'punch'],
1964
+ 'Blur and light': ['blur', 'zoomBlur', 'spinBlur', 'motionBlur', 'tiltShift', 'softFocus', 'glow', 'lightRays', 'streaks'],
1965
+ 'What was drawn stays': ['trails', 'smear', 'stutter'],
1966
+ 'Colours': ['hue', 'saturate', 'brighten', 'contrast', 'invert', 'posterize', 'tint', 'duotone', 'colourCycle', 'gradientMap', 'thermal', 'nightVision', 'sepia', 'threshold', 'solarize', 'rgbSplit', 'colourBleed', 'rainbowEdges'],
1967
+ 'Screens and old computers': ['scanlines', 'crt', 'interlace', 'glitch', 'dither', 'led', 'letters'],
1968
+ 'Drawn, printed and painted': ['edges', 'dots', 'emboss', 'sharpen', 'sketch', 'cartoon', 'stainedGlass', 'crosshatch', 'oil'],
1969
+ 'In front of the picture': ['vignette', 'grain', 'dust', 'lightLeaks', 'rain', 'snow', 'bokeh', 'fog', 'lightning'],
1970
+ 'Backdrops that need no drawing': ['plasma', 'flow', 'metaballs', 'starfield', 'warp', 'voronoi', 'moire', 'interference', 'aurora', 'fire', 'clouds', 'matrixRain', 'copperBars', 'checkerFloor', 'twister'],
1971
+ 'The music, drawn': ['spectrumBars', 'spectrumRing', 'waveformRing', 'beatFlash', 'bassShake'],
1972
+ }
1973
+ const ABOUT = {
1974
+ kaleidoscope: 'One slice of the picture, folded round and round. The slice starts at the middle and points right: turn it with angle.',
1975
+ mirror: 'One half of the picture shown on both sides, or one quarter in all four.',
1976
+ tile: 'The picture repeated in rows and columns.',
1977
+ pixelate: 'Big square pixels.',
1978
+ hexagons: 'Six-sided pixels, like a honeycomb.',
1979
+ waves: 'The picture ripples like a flag in the wind.',
1980
+ ripple: 'Rings spreading from a point, as from a stone dropped in water.',
1981
+ wobble: 'The picture wobbles like jelly, or something seen under water.',
1982
+ heatHaze: 'The shimmer of hot air rising.',
1983
+ swirl: 'The middle of the picture twisted round, like stirred paint.',
1984
+ fisheye: 'A bulge, as through a round lens. Below zero it pinches instead.',
1985
+ tunnel: 'The picture becomes the walls of a tunnel you are flying down.',
1986
+ droste: 'The picture inside itself, smaller each time, for ever. With a twist it winds in as a spiral.',
1987
+ rotozoom: 'The picture tiled, turning and zooming: an old demo-scene favourite.',
1988
+ glassTiles: 'Seen through a wall of glass blocks.',
1989
+ frosted: 'Seen through frosted glass.',
1990
+ caustics: 'The net of bright lines that sunlight makes on the floor of a pool.',
1991
+ shake: 'The whole picture shakes. Set strength when something hits, and let it fall away.',
1992
+ punch: 'A zoom in towards a point. Tie its strength to the beat.',
1993
+ blur: 'Out of focus.',
1994
+ zoomBlur: 'Rushing towards a point.',
1995
+ spinBlur: 'Spinning round a point.',
1996
+ motionBlur: 'Moving fast in one direction.',
1997
+ tiltShift: 'A band in focus and the rest blurred, which makes a scene look like a tiny model.',
1998
+ softFocus: 'A soft, dreamy haze over the bright parts.',
1999
+ glow: 'Bright things shine and spread light around them.',
2000
+ lightRays: 'Rays of light streaming out from a point, through whatever is bright.',
2001
+ streaks: 'Long sideways flares from bright lights, as a film camera makes.',
2002
+ trails: 'What was drawn stays and fades. With zoom, spin and hue it becomes the classic screensaver tunnel.',
2003
+ smear: 'What was drawn is carried away on a slow current, like wet paint.',
2004
+ stutter: 'Only so many new pictures a second: the last one is held between them.',
2005
+ hue: 'Every colour moved round the colour wheel. With speed, the colours keep turning.',
2006
+ saturate: 'More colour, or less: at 0 the picture is grey.',
2007
+ brighten: 'Brighter, or darker below 1.',
2008
+ contrast: 'Lights lighter and darks darker.',
2009
+ invert: 'A negative: every colour swapped for its opposite.',
2010
+ posterize: 'Only a few shades of each colour, like a poster.',
2011
+ tint: 'Seen through coloured glass.',
2012
+ duotone: 'Two colours only: one for the dark parts and one for the light.',
2013
+ colourCycle: 'Brightness becomes a colour from a palette, and the colours move round.',
2014
+ gradientMap: 'Brightness becomes a colour from a palette, dark to light.',
2015
+ thermal: 'A heat camera: cold is dark blue, hot is yellow and white.',
2016
+ nightVision: 'Green, grainy and bright in the dark, like a night-vision scope.',
2017
+ sepia: 'The warm brown of an old photograph.',
2018
+ threshold: 'Two colours and nothing between: dark or light.',
2019
+ solarize: 'The bright parts turned to their opposite, as in an over-exposed photograph.',
2020
+ rgbSplit: 'Red and blue pulled apart, like a cheap lens or a bad signal.',
2021
+ colourBleed: 'Colours smeared sideways, as on old videotape.',
2022
+ rainbowEdges: 'Every edge in the picture drawn in rainbow colours.',
2023
+ scanlines: 'Dark lines across the picture, as on an old screen.',
2024
+ crt: 'An old television: curved glass, lines, and thin stripes of red, green and blue.',
2025
+ interlace: 'Every other line dark, swapping each frame, like old video.',
2026
+ glitch: 'A broken signal: blocks that jump sideways, a line that tears, a band of snow.',
2027
+ dither: 'A few colours made to pass for many with a pattern of dots, as old computers did.',
2028
+ led: 'A wall of round lamps.',
2029
+ letters: 'The picture made of marks, as an old terminal would show it.',
2030
+ edges: 'Only the outlines, each in its own colour.',
2031
+ dots: 'Printed in dots, big where the picture is bright, as a comic or a newspaper is.',
2032
+ emboss: 'Pressed into grey metal: only the bumps and hollows show.',
2033
+ sharpen: 'Crisper edges.',
2034
+ sketch: 'A pencil drawing on paper.',
2035
+ cartoon: 'Flat colours and black outlines.',
2036
+ stainedGlass: 'Pieces of coloured glass with dark lead between them.',
2037
+ crosshatch: 'Shaded with crossing pen lines: more lines where it is darker.',
2038
+ oil: 'Smoothed into patches of colour, like oil paint. Heavy: it looks at 36 places for every pixel.',
2039
+ vignette: 'Dark corners, which draw the eye to the middle.',
2040
+ grain: 'The fine speckle of film.',
2041
+ dust: 'Specks and scratches, as on old film.',
2042
+ lightLeaks: 'Warm light creeping in from the sides, as in an old camera.',
2043
+ rain: 'Rain falling in front of the picture.',
2044
+ snow: 'Snow falling in front of the picture.',
2045
+ bokeh: 'Soft discs of coloured light drifting by: lamps out of focus.',
2046
+ fog: 'Drifting mist, thicker towards the bottom.',
2047
+ lightning: 'A flash, now and then.',
2048
+ plasma: 'Slow rolling colour.',
2049
+ flow: 'Colour that folds and curls like ink in water.',
2050
+ metaballs: 'Blobs that melt into each other when they meet: a lava lamp.',
2051
+ starfield: 'Flying through stars.',
2052
+ warp: 'A chequered tunnel rushing past.',
2053
+ voronoi: 'Soft coloured cells that slowly shift.',
2054
+ moire: 'Two sets of rings crossing, making patterns neither has alone.',
2055
+ interference: 'Waves from four moving points, crossing.',
2056
+ aurora: 'The northern lights over a night sky.',
2057
+ fire: 'Flames rising from the bottom of the picture.',
2058
+ clouds: 'A sky with drifting clouds.',
2059
+ matrixRain: 'Green marks falling in columns.',
2060
+ copperBars: 'Shining bars sliding up and down: a demo-scene classic.',
2061
+ checkerFloor: 'A chequered floor rushing towards you under a glowing horizon.',
2062
+ twister: 'A twisting four-sided bar, another demo-scene classic.',
2063
+ spectrumBars: 'The music as bars: low notes at the left, high at the right.',
2064
+ spectrumRing: 'The music as bars round a ring.',
2065
+ waveformRing: 'The sound\'s own wave, bent into a ring.',
2066
+ beatFlash: 'A flash of colour on each beat.',
2067
+ bassShake: 'The picture shakes with the bass.',
2068
+ }
2069
+ const LOOK_ABOUT = {
2070
+ dream: 'Soft, glowing and rich.', arcade: 'The curved glass of an old arcade machine.', vhs: 'A worn videotape.', neon: 'Glowing outlines in the dark.',
2071
+ comic: 'A few flat colours, printed in dots.', underwater: 'Blue, wobbling and dim at the edges.', oldfilm: 'Brown, grainy and dark at the corners.',
2072
+ disco: 'A kaleidoscope whose colours keep turning.', gameboy: 'Four shades of green.', c64: 'The colours of a 1980s home computer.',
2073
+ noir: 'Black and white, high contrast, grainy.', comicbook: 'Black outlines over printed dots.', storm: 'Rain, lightning and fog.',
2074
+ winter: 'Cold light and falling snow.', cinema: 'A film camera: flares, a narrow focus, grain.', terminal: 'Green marks on a dark screen.',
2075
+ }
2076
+ const groupOf = (name) => Object.keys(GROUPS).find((g) => GROUPS[g].includes(name)) || 'More'
2077
+
2078
+ /** Every effect and its settings, for a host that keeps a scene's code in another language. */
2079
+ effects.definitions = () => {
2080
+ const out = {}
2081
+ for (const name of NAMES) {
2082
+ const def = DEFS[name], settings = {}
2083
+ for (const key in def.settings) { const s = def.settings[key]; settings[key] = { kind: s.kind, default: s.default, ...(s.min !== undefined ? { min: s.min, max: s.max } : {}), ...(s.choices ? { choices: s.choices } : {}), ...(s.place ? { place: true } : {}), ...(s.unit ? { unit: s.unit } : {}) } }
2084
+ out[name] = { order: def.order, settings, backdrop: !!def.backdrop, cost: def.cost || (def.run ? 'some' : 'light'), feedback: !!def.feedback || name === 'trails', group: groupOf(name), about: ABOUT[name] || '' }
2085
+ }
2086
+ return { effects: out, groups: [...Object.keys(GROUPS), 'More'], looks: Object.keys(LOOKS), recipes: LOOKS, lookAbout: LOOK_ABOUT, palettes: Object.keys(PALETTES), paletteColours: PALETTES, aliases: { bloom: 'glow', colorCycle: 'colourCycle', bulge: 'fisheye', halftone: 'dots', colorBleed: 'colourBleed', godRays: 'lightRays' } }
2087
+ }
2088
+ function onlyCanvas() {
2089
+ // A canvas laid over the scene's by this library, by dials.js or for a change of scene is not the scene's.
2090
+ const found = [...document.querySelectorAll('canvas')].filter((c) => c.dataset.effectsLayer === undefined && c.dataset.dialsLayer === undefined && !Object.keys(c.dataset).some((k) => /Transition$/.test(k)))
2091
+ if (found.length === 1) return found[0]
2092
+ throw new Error(found.length
2093
+ ? 'There is more than one canvas on this page, so say which one the effects are for: const { glow, trails } = effects(canvas)'
2094
+ : 'There is no canvas on this page to put effects on')
2095
+ }
2096
+
2097
+ // --------------------------------------------------------------------------------------------
2098
+ // FILM: the scene stepped a frame at a time, each frame exactly 1/fps after the last however long it took
2099
+ // to draw, for a page that has no recorder of its own (Circuitry has one, and steps the page the same way).
2100
+ // SOUND is stepped with it when the scene's sound is a stepped player (synths.js: createPlayer({ stepped }),
2101
+ // which pictures.js makes for runScene(…, { stepped: { fps } })): before each frame is drawn the player is
2102
+ // told where the clock is and makes exactly the samples up to there, so the film's sound is with its
2103
+ // pictures to the sample, and what the effects hear (bass(), pulse()) is the frame they are drawn on.
2104
+ /** The stepped player in what was given: the player, a scene with one as `sound`, or a function that gives either. */
2105
+ function steppedOf(given) {
2106
+ try {
2107
+ let s = typeof given === 'function' ? given() : given
2108
+ if (s && !s.stepped && s.sound) s = s.sound
2109
+ if (s && !s.stepped && s.player) s = s.player
2110
+ return s && s.stepped && typeof s.stepped.advance === 'function' ? s.stepped : null
2111
+ } catch (e) { return null }
2112
+ }
2113
+
2114
+ /**
2115
+ * The next frames of the page, one at a time: `await renderFrames({ seconds: 3, fps: 60, onFrame })`.
2116
+ * `onFrame(canvas, n, time)` is given each finished picture (the scene, its effects and anything else
2117
+ * laid over it) on one canvas that is used again for the next, and may be awaited. With no `onFrame` it
2118
+ * gives back a list of pictures (ImageBitmap), which for a long film is a great deal of memory.
2119
+ * While it runs, the page's clock is this one: requestAnimationFrame and performance.now are stepped.
2120
+ * `sound` is the scene's stepped sound (a synths.js stepped player, a pictures.js scene that has one, or a
2121
+ * function that gives either once there is one): it is moved with the frames, and `onSound(left, right,
2122
+ * sampleRate, n)` is given the samples of each frame after it, exactly sampleRate / fps of them with the
2123
+ * remainder carried, so n frames are round(n × sampleRate / fps) samples. Without `sound`, or with a
2124
+ * scene that makes none, `onSound` is never called.
2125
+ */
2126
+ async function renderFrames({ seconds = 3, fps = 60, frames = Math.round(seconds * fps), onFrame = null, canvas = null, sound = null, onSound = null } = {}) {
2127
+ canvas ||= onlyCanvas()
2128
+ const real = { raf: window.requestAnimationFrame, caf: window.cancelAnimationFrame, now: performance.now }
2129
+ // The frame the browser has already promised is let through first, so everything waiting is waiting on this clock.
2130
+ await new Promise((done) => real.raf.call(window, () => setTimeout(done, 0)))
2131
+ let waiting = [], ids = 0, clock = 0
2132
+ window.requestAnimationFrame = (fn) => { waiting.push([++ids, fn]); return -ids }
2133
+ window.cancelAnimationFrame = (id) => { if (id < 0) waiting = waiting.filter(([n]) => n !== -id); else real.caf.call(window, id) }
2134
+ performance.now = () => clock || real.now.call(performance)
2135
+ const out = Object.assign(document.createElement('canvas'), { width: canvas.width, height: canvas.height })
2136
+ const g = out.getContext('2d')
2137
+ const kept = []
2138
+ // THE SOUND. `stepped` once the scene has a stepped player; `owed` the frames that went by before it had one,
2139
+ // which are silence. The player's clock is the page's, so it is told the clock, and makes the samples up to it.
2140
+ let stepped = null, owed = 0, first = 0, heardTo = 0, stop = null
2141
+ const hear = async (n) => {
2142
+ if (!sound) return
2143
+ if (!stepped) {
2144
+ stepped = steppedOf(sound)
2145
+ if (!stepped) { owed = n; return }
2146
+ // A player that has made nothing yet began with the film; one that has is joined where it is.
2147
+ if (stepped.samples) { stepped.startAt(clock / 1000); owed = n } else { stepped.startAt(first / 1000); owed = 0 }
2148
+ stepped.take()
2149
+ const lead = Math.round(owed * stepped.sampleRate / fps)
2150
+ if (lead && onSound) await onSound(new Float32Array(lead), new Float32Array(lead), stepped.sampleRate, -1)
2151
+ heardTo = lead
2152
+ }
2153
+ const made = []
2154
+ stop = stepped.onAdvance((l, r) => made.push([l, r]))
2155
+ try { await stepped.advance(clock / 1000) } finally { stop(); stop = null }
2156
+ stepped.take()
2157
+ for (const [l, r] of made) { heardTo += l.length; if (onSound) await onSound(l, r, stepped.sampleRate, n - 1) }
2158
+ }
2159
+ try {
2160
+ // One real frame lets what was already asked of the browser's own clock ask again of this one.
2161
+ // This clock starts at that frame's own time, so the first step is exactly 1/fps like every other.
2162
+ const zero = await new Promise((done) => real.raf.call(window, (now) => { clock = now; setTimeout(() => done(now), 0) }))
2163
+ first = zero + 1000 / fps
2164
+ for (let n = 0; n < frames; n++) {
2165
+ clock = zero + (n + 1) * 1000 / fps
2166
+ // The sound up to this frame is made before the frame is drawn: what the scene reads of it is this frame's.
2167
+ await hear(n)
2168
+ const due = waiting
2169
+ waiting = []
2170
+ for (const [, fn] of due) fn(clock)
2171
+ // Copied now, in the same task as the drawing: a graphics-card canvas keeps its picture no longer.
2172
+ g.fillStyle = '#000'
2173
+ g.fillRect(0, 0, out.width, out.height)
2174
+ const box = canvas.getBoundingClientRect()
2175
+ for (const c of canvas.parentElement ? canvas.parentElement.querySelectorAll('canvas') : [canvas]) {
2176
+ if (!c.width || !c.height || getComputedStyle(c).display === 'none' || getComputedStyle(c).visibility === 'hidden') continue
2177
+ const b = c.getBoundingClientRect(), k = out.width / (box.width || out.width)
2178
+ g.drawImage(c, (b.left - box.left) * k, (b.top - box.top) * k, b.width * k, b.height * k)
2179
+ }
2180
+ if (onFrame) await onFrame(out, n, (n + 1) / fps)
2181
+ else kept.push(await createImageBitmap(out))
2182
+ // What the scene awaits between frames (a promise, a timer that is due) is given its turn.
2183
+ await new Promise((done) => setTimeout(done, 0))
2184
+ }
2185
+ // The last frame lasts 1/fps too, and its sound is the film's.
2186
+ clock = zero + (frames + 1) * 1000 / fps
2187
+ await hear(frames)
2188
+ } finally {
2189
+ if (stop) stop()
2190
+ window.requestAnimationFrame = real.raf
2191
+ window.cancelAnimationFrame = real.caf
2192
+ performance.now = real.now
2193
+ for (const [, fn] of waiting) real.raf.call(window, fn)
2194
+ }
2195
+ return kept
2196
+ }
2197
+
2198
+ /**
2199
+ * A film of the scene as a WebM file, every frame exactly 1/fps apart: `const film = await recordVideo({ seconds: 5, fps: 60 })`
2200
+ * gives a Blob (`saveVideo(film)` downloads it). It needs a browser that can encode video (WebCodecs).
2201
+ * WITH SOUND: `recordVideo({ seconds, fps, sound: scene })`, where the scene's sound is stepped (see renderFrames).
2202
+ * The sound goes into the file as Opus, beginning on the first frame's first sample and ending with the last
2203
+ * frame (`film.audio` is "opus"). A browser that cannot encode Opus gives the film without sound and the sound
2204
+ * beside it as a WAV file of the same length, `film.sound` (`film.audio` is "wav"); saveVideo saves both.
2205
+ * With no `sound`, or a scene that made none, the film has no sound track (`film.audio` is null).
2206
+ */
2207
+ async function recordVideo({ seconds = 5, fps = 60, bitrate = 0, canvas = null, sound = null, audioBitrate = 160000 } = {}) {
2208
+ canvas ||= onlyCanvas()
2209
+ if (typeof VideoEncoder !== 'function' || typeof VideoFrame !== 'function') throw new Error('This browser cannot make a video file (it has no VideoEncoder). renderFrames() gives the frames one by one instead')
2210
+ // An encoder wants a picture with an even number of pixels each way.
2211
+ const w = canvas.width & ~1, h = canvas.height & ~1
2212
+ let codec = ''
2213
+ for (const c of ['vp09.00.40.08', 'vp8']) {
2214
+ try { if ((await VideoEncoder.isConfigSupported({ codec: c, width: w, height: h })).supported) { codec = c; break } } catch (e) { /* not this one */ }
2215
+ }
2216
+ if (!codec) throw new Error(`This browser cannot encode a WebM video of ${w} by ${h} pixels. renderFrames() gives the frames one by one instead`)
2217
+ const chunks = []
2218
+ let failed = null
2219
+ const encoder = new VideoEncoder({
2220
+ output: (chunk) => { const data = new Uint8Array(chunk.byteLength); chunk.copyTo(data); chunks.push({ data, key: chunk.type === 'key', time: Math.round(chunk.timestamp / 1000) }) },
2221
+ error: (e) => { failed = e },
2222
+ })
2223
+ encoder.configure({ codec, width: w, height: h, framerate: fps, bitrate: bitrate || Math.round(w * h * fps * 0.12) })
2224
+ const even = Object.assign(document.createElement('canvas'), { width: w, height: h }).getContext('2d')
2225
+ const track = soundTrack(audioBitrate)
2226
+ const total = Math.round(seconds * fps)
2227
+ await renderFrames({ seconds, fps, canvas, sound, onSound: (left, right, rate) => track.add(left, right, rate), onFrame: async (picture, n) => {
2228
+ even.drawImage(picture, 0, 0)
2229
+ const frame = new VideoFrame(even.canvas, { timestamp: Math.round(n * 1e6 / fps), duration: Math.round(1e6 / fps) })
2230
+ encoder.encode(frame, { keyFrame: n % Math.round(fps * 2) === 0 })
2231
+ frame.close()
2232
+ // The encoder is not given frames faster than it can take them.
2233
+ while (encoder.encodeQueueSize > 6) await new Promise((done) => setTimeout(done, 1))
2234
+ } })
2235
+ await encoder.flush()
2236
+ encoder.close()
2237
+ if (failed) throw new Error(`The video could not be encoded: ${failed.message}`)
2238
+ const heard = await track.end()
2239
+ const film = webm(chunks, { codec: codec === 'vp8' ? 'V_VP8' : 'V_VP9', width: w, height: h, fps, frames: total, audio: heard && heard.opus })
2240
+ film.codec = codec === 'vp8' ? 'vp8' : 'vp9'
2241
+ film.audio = heard ? (heard.opus ? 'opus' : 'wav') : null
2242
+ film.samples = heard ? heard.samples : 0
2243
+ film.sampleRate = heard ? heard.sampleRate : 0
2244
+ if (heard && heard.wav) film.sound = heard.wav
2245
+ return film
2246
+ }
2247
+
2248
+ /**
2249
+ * A film's sound as it is made: Opus where the browser can encode it (WebCodecs' AudioEncoder, at 48000 samples
2250
+ * a second), and otherwise kept as it is, to be handed over as a WAV file. `add(left, right, sampleRate)` takes
2251
+ * each stretch; `end()` gives null (there was none), { opus: { packets, head, preSkip, samples } } or { wav }.
2252
+ */
2253
+ function soundTrack(bitrate) {
2254
+ let encoder = null, failed = null, rate = 0, samples = 0, fed = 0, begun = false, head = null
2255
+ const packets = [], plain = []
2256
+ const feed = (left, right) => {
2257
+ const data = new Float32Array(left.length * 2)
2258
+ data.set(left, 0); data.set(right, left.length)
2259
+ const frame = new AudioData({ format: 'f32-planar', sampleRate: rate, numberOfFrames: left.length, numberOfChannels: 2, timestamp: Math.round(fed * 1e6 / rate), data })
2260
+ fed += left.length
2261
+ encoder.encode(frame)
2262
+ frame.close()
2263
+ }
2264
+ return {
2265
+ async add(left, right, sampleRate) {
2266
+ if (!left.length) return
2267
+ if (!begun) {
2268
+ begun = true
2269
+ rate = sampleRate
2270
+ let can = false
2271
+ try { can = rate === 48000 && typeof AudioEncoder === 'function' && typeof AudioData === 'function' && (await AudioEncoder.isConfigSupported({ codec: 'opus', sampleRate: rate, numberOfChannels: 2, bitrate })).supported } catch (e) { can = false }
2272
+ if (can) {
2273
+ encoder = new AudioEncoder({
2274
+ output: (chunk, meta) => {
2275
+ if (meta && meta.decoderConfig && meta.decoderConfig.description && !head) head = new Uint8Array(meta.decoderConfig.description.buffer || meta.decoderConfig.description).slice()
2276
+ const data = new Uint8Array(chunk.byteLength); chunk.copyTo(data)
2277
+ packets.push({ data, time: chunk.timestamp, duration: chunk.duration || 20000 })
2278
+ },
2279
+ error: (e) => { failed = e },
2280
+ })
2281
+ encoder.configure({ codec: 'opus', sampleRate: rate, numberOfChannels: 2, bitrate })
2282
+ }
2283
+ }
2284
+ samples += left.length
2285
+ // With no encoder the samples are kept as they are, for the WAV file.
2286
+ if (!encoder) plain.push([left, right])
2287
+ else if (!failed) {
2288
+ feed(left, right)
2289
+ while (encoder.encodeQueueSize > 24) await new Promise((done) => setTimeout(done, 1))
2290
+ }
2291
+ },
2292
+ async end() {
2293
+ if (!begun) return null
2294
+ if (encoder && !failed) {
2295
+ // A little silence after the end, so the encoder (which runs a few milliseconds behind) gives all of the sound.
2296
+ feed(new Float32Array(1920), new Float32Array(1920))
2297
+ try { await encoder.flush() } catch (e) { failed = failed || e }
2298
+ try { encoder.close() } catch (e) { /* already closed */ }
2299
+ }
2300
+ if (encoder && !failed && packets.length) {
2301
+ // The Opus header says how many samples at the start are the encoder's own run-in, which a player drops.
2302
+ const preSkip = head && head.length >= 12 ? head[10] | (head[11] << 8) : 312
2303
+ if (!head) { head = new Uint8Array([79, 112, 117, 115, 72, 101, 97, 100, 1, 2, preSkip & 255, preSkip >> 8, 0x80, 0xbb, 0, 0, 0, 0, 0]) }
2304
+ return { opus: { packets, head, preSkip, samples }, samples, sampleRate: rate }
2305
+ }
2306
+ if (failed) console.warn(`visualfx.js: the film's sound could not be encoded (${failed.message || failed}), so it is given beside the film as a WAV file: film.sound`)
2307
+ else console.info('visualfx.js: this browser cannot put sound into a WebM file (it has no Opus encoder), so the film\'s sound is given beside it as a WAV file: film.sound')
2308
+ // An encoder that failed part-way took the samples with it: the film is then silent, and says so above.
2309
+ if (encoder) return null
2310
+ return { wav: wav(plain, rate), samples, sampleRate: rate }
2311
+ },
2312
+ }
2313
+ }
2314
+
2315
+ /** A WAV file (16 bits, two channels) from stretches of samples. */
2316
+ function wav(parts, rate) {
2317
+ const n = parts.reduce((sum, [l]) => sum + l.length, 0)
2318
+ const out = new DataView(new ArrayBuffer(44 + n * 4))
2319
+ const text = (at, t) => { for (let i = 0; i < t.length; i++) out.setUint8(at + i, t.charCodeAt(i)) }
2320
+ text(0, 'RIFF'); out.setUint32(4, 36 + n * 4, true); text(8, 'WAVEfmt '); out.setUint32(16, 16, true); out.setUint16(20, 1, true); out.setUint16(22, 2, true)
2321
+ out.setUint32(24, rate, true); out.setUint32(28, rate * 4, true); out.setUint16(32, 4, true); out.setUint16(34, 16, true); text(36, 'data'); out.setUint32(40, n * 4, true)
2322
+ let at = 44
2323
+ for (const [l, r] of parts) for (let i = 0; i < l.length; i++) { out.setInt16(at, Math.round(clamp(l[i], -1, 1) * 32767), true); out.setInt16(at + 2, Math.round(clamp(r[i], -1, 1) * 32767), true); at += 4 }
2324
+ return new Blob([out.buffer], { type: 'audio/wav' })
2325
+ }
2326
+
2327
+ /**
2328
+ * A WebM file from encoded frames: the few parts a player needs, each a name, a length and what is in it.
2329
+ * `audio` is Opus packets to go with them ({ packets, head, preSkip, samples }): a second track, whose first
2330
+ * sample a player hears is the film's first (the encoder's run-in is declared, and dropped by the player) and
2331
+ * whose last is cut where the film ends.
2332
+ */
2333
+ function webm(chunks, { codec, width, height, fps, frames = 0, audio = null }) {
2334
+ const bytes = (n, size) => { const out = []; for (let i = size - 1; i >= 0; i--) out.push(Math.floor(n / 2 ** (8 * i)) & 255); return out }
2335
+ const whole = (n) => bytes(n, Math.max(1, Math.ceil(Math.log2(n + 1) / 8)))
2336
+ const float = (n) => [...new Uint8Array(new Float64Array([n]).buffer).reverse()]
2337
+ const text = (t) => [...new TextEncoder().encode(t)]
2338
+ // A part: its name, how long it is (eight bytes, the first marking that there are eight) and its content.
2339
+ const part = (id, ...content) => {
2340
+ const body = content.map((c) => (c instanceof Uint8Array ? c : new Uint8Array(c)))
2341
+ const size = body.reduce((n, b) => n + b.length, 0)
2342
+ const out = new Uint8Array(id.length + 8 + size)
2343
+ out.set(id, 0)
2344
+ out.set([1, ...bytes(size, 7)], id.length)
2345
+ let at = id.length + 8
2346
+ for (const b of body) { out.set(b, at); at += b.length }
2347
+ return out
2348
+ }
2349
+ const head = part([0x1a, 0x45, 0xdf, 0xa3], part([0x42, 0x86], [1]), part([0x42, 0xf7], [1]), part([0x42, 0xf2], [4]), part([0x42, 0xf3], [8]), part([0x42, 0x82], text('webm')), part([0x42, 0x87], [4]), part([0x42, 0x85], [2]))
2350
+ const last = chunks.length ? chunks[chunks.length - 1].time : 0
2351
+ const length = frames ? frames * 1000 / fps : last + 1000 / fps
2352
+ const info = part([0x15, 0x49, 0xa9, 0x66], part([0x2a, 0xd7, 0xb1], whole(1000000)), part([0x4d, 0x80], text('visualfx.js')), part([0x57, 0x41], text('visualfx.js')), part([0x44, 0x89], float(length)))
2353
+ const picture = part([0xae], part([0xd7], [1]), part([0x73, 0xc5], [1]), part([0x83], [1]), part([0x86], text(codec)), part([0x23, 0xe3, 0x83], whole(Math.round(1e9 / fps))),
2354
+ part([0xe0], part([0xb0], whole(width)), part([0xba], whole(height))))
2355
+ // The sound's track: Opus, with its header, how much of its start is run-in (in nanoseconds) and how far back a player must begin to seek cleanly.
2356
+ const sounds = audio ? [part([0xae], part([0xd7], [2]), part([0x73, 0xc5], [2]), part([0x83], [2]), part([0x86], text('A_OPUS')), part([0x56, 0xaa], whole(Math.round(audio.preSkip * 1e9 / 48000))), part([0x56, 0xbb], whole(80000000)),
2357
+ part([0x63, 0xa2], audio.head), part([0xe1], part([0xb5], float(48000)), part([0x9f], [2])))] : []
2358
+ const tracks = part([0x16, 0x54, 0xae, 0x6b], picture, ...sounds)
2359
+ // Everything in the order of its time. A sound packet's time is where its samples begin, in milliseconds.
2360
+ const blocks = chunks.map((c) => ({ track: 1, time: c.time, key: c.key, data: c.data }))
2361
+ if (audio) {
2362
+ // Packets past the film's end are left out, and the one that crosses it is cut there.
2363
+ const end = audio.preSkip + audio.samples
2364
+ let at = 0
2365
+ for (const p of audio.packets) {
2366
+ const size = Math.round(p.duration * 48 / 1000)
2367
+ if (at >= end) break
2368
+ blocks.push({ track: 2, time: Math.round(at / 48), key: true, data: p.data, cut: Math.max(0, at + size - end) })
2369
+ at += size
2370
+ }
2371
+ blocks.sort((a, b) => a.time - b.time || a.track - b.track)
2372
+ }
2373
+ // Frames are kept in groups that each begin with a whole picture: a player can start at any group.
2374
+ const clusters = []
2375
+ let group = null
2376
+ for (const c of blocks) {
2377
+ if (!group || (c.track === 1 && c.key) || c.time - group.time > 30000) { group = { time: c.time, blocks: [] }; clusters.push(group) }
2378
+ const block = new Uint8Array(4 + c.data.length)
2379
+ block.set([0x80 | c.track, ...bytes(c.time - group.time, 2), c.key && !c.cut ? 0x80 : 0], 0)
2380
+ block.set(c.data, 4)
2381
+ // A packet that is cut short says by how much (nanoseconds), which needs the longer form of a block.
2382
+ group.blocks.push(c.cut ? part([0xa0], part([0xa1], block), part([0x75, 0xa2], bytes(Math.round(c.cut * 1e9 / 48000), 4))) : part([0xa3], block))
2383
+ }
2384
+ const body = [info, tracks, ...clusters.map((g) => part([0x1f, 0x43, 0xb6, 0x75], part([0xe7], whole(g.time)), ...g.blocks))]
2385
+ return new Blob([head, part([0x18, 0x53, 0x80, 0x67], ...body)], { type: 'video/webm' })
2386
+ }
2387
+
2388
+ /** Save a film (or any Blob) as a file the browser downloads: saveVideo(film, "my-scene.webm"). A film whose sound is beside it (film.sound) has that saved too, as a WAV file of the same name. */
2389
+ function saveVideo(film, name = 'scene.webm') {
2390
+ const put = (blob, called) => {
2391
+ const link = Object.assign(document.createElement('a'), { href: URL.createObjectURL(blob), download: called })
2392
+ document.body.append(link)
2393
+ link.click()
2394
+ link.remove()
2395
+ setTimeout(() => URL.revokeObjectURL(link.href), 10000)
2396
+ }
2397
+ put(film, name)
2398
+ if (film && film.sound) put(film.sound, name.replace(/\.[^.]*$/, '') + '.wav')
2399
+ }
2400
+
2401
+ // The same functions with no canvas named: for a page (and a pictures.js scene) with one canvas.
2402
+ const on = (name) => (...args) => effects()[name](...args)
2403
+ const kaleidoscope = on('kaleidoscope'), mirror = on('mirror'), pixelate = on('pixelate'), waves = on('waves'), ripple = on('ripple'), wobble = on('wobble')
2404
+ const swirl = on('swirl'), fisheye = on('fisheye'), bulge = on('fisheye'), tunnel = on('tunnel'), tile = on('tile'), zoomBlur = on('zoomBlur'), spinBlur = on('spinBlur'), rgbSplit = on('rgbSplit')
2405
+ const hue = on('hue'), saturate = on('saturate'), brighten = on('brighten'), contrast = on('contrast'), invert = on('invert'), posterize = on('posterize'), tint = on('tint'), duotone = on('duotone')
2406
+ const colourCycle = on('colourCycle'), colorCycle = on('colourCycle'), scanlines = on('scanlines'), crt = on('crt'), glitch = on('glitch'), grain = on('grain'), vignette = on('vignette'), edges = on('edges')
2407
+ const dots = on('dots'), halftone = on('dots'), letters = on('letters'), plasma = on('plasma'), starfield = on('starfield'), metaballs = on('metaballs'), flow = on('flow')
2408
+ const blur = on('blur'), glow = on('glow'), bloom = on('glow'), trails = on('trails')
2409
+ const droste = on('droste'), rotozoom = on('rotozoom'), hexagons = on('hexagons'), glassTiles = on('glassTiles'), frosted = on('frosted'), heatHaze = on('heatHaze'), caustics = on('caustics'), shake = on('shake'), punch = on('punch'), motionBlur = on('motionBlur'), tiltShift = on('tiltShift'), lightRays = on('lightRays'), streaks = on('streaks'), softFocus = on('softFocus'), smear = on('smear'), stutter = on('stutter'), gradientMap = on('gradientMap'), thermal = on('thermal'), nightVision = on('nightVision'), sepia = on('sepia'), threshold = on('threshold'), solarize = on('solarize'), colourBleed = on('colourBleed'), rainbowEdges = on('rainbowEdges'), dither = on('dither'), interlace = on('interlace'), led = on('led'), emboss = on('emboss'), sharpen = on('sharpen'), sketch = on('sketch'), cartoon = on('cartoon'), stainedGlass = on('stainedGlass'), crosshatch = on('crosshatch'), oil = on('oil'), dust = on('dust'), lightLeaks = on('lightLeaks'), rain = on('rain'), snow = on('snow'), bokeh = on('bokeh'), fog = on('fog'), lightning = on('lightning'), warp = on('warp'), voronoi = on('voronoi'), moire = on('moire'), interference = on('interference'), aurora = on('aurora'), fire = on('fire'), clouds = on('clouds'), matrixRain = on('matrixRain'), copperBars = on('copperBars'), checkerFloor = on('checkerFloor'), twister = on('twister'), spectrumBars = on('spectrumBars'), spectrumRing = on('spectrumRing'), waveformRing = on('waveformRing'), beatFlash = on('beatFlash'), bassShake = on('bassShake'), colorBleed = on('colourBleed'), godRays = on('lightRays')
2410
+ const shader = on('shader'), look = on('look'), clearEffects = on('clearEffects'), listenTo = on('listenTo'), react = on('react'), effectsQuality = on('effectsQuality')
2411
+ const bass = on('bass'), mids = on('mids'), highs = on('highs'), loudness = on('loudness'), pulse = on('pulse')
2412
+ const lookNames = () => Object.keys(LOOKS), effectNames = () => [...NAMES], paletteNames = () => Object.keys(PALETTES)
2413
+
2414
+ export {
2415
+ effects, kaleidoscope, mirror, pixelate, waves, ripple, wobble, swirl, fisheye, bulge, tunnel, tile, zoomBlur, spinBlur, rgbSplit,
2416
+ hue, saturate, brighten, contrast, invert, posterize, tint, duotone, colourCycle, colorCycle, scanlines, crt, glitch, grain, vignette, edges,
2417
+ dots, halftone, letters, plasma, starfield, metaballs, flow, blur, glow, bloom, trails,
2418
+ droste, rotozoom, hexagons, glassTiles, frosted, heatHaze, caustics, shake, punch, motionBlur, tiltShift, lightRays, streaks, softFocus, smear, stutter, gradientMap, thermal, nightVision, sepia, threshold, solarize, colourBleed, rainbowEdges, dither, interlace, led, emboss, sharpen, sketch, cartoon, stainedGlass, crosshatch, oil, dust, lightLeaks, rain, snow, bokeh, fog, lightning, warp, voronoi, moire, interference, aurora, fire, clouds, matrixRain, copperBars, checkerFloor, twister, spectrumBars, spectrumRing, waveformRing, beatFlash, bassShake, colorBleed, godRays,
2419
+ shader, look, clearEffects, listenTo, react, effectsQuality, bass, mids, highs, loudness, pulse, lookNames, effectNames, paletteNames,
2420
+ renderFrames, recordVideo, saveVideo, version,
2421
+ }