PageSourceSearch

https://harperz9.github.io/system/glitch-ops.js

js harperz9.github.io collected 2026-10-03 09:17:02 UTC 41,712 bytes, 729 lines download raw bytes

1import { OPS2 as OPS_CHEM, OP_META2 as META_CHEM } from "./glitch-ops-wave2.js";
2import { OPS2 as OPS_OPT, OP_META2 as META_OPT } from "./glitch-ops-wave2-optical.js";
3import { OPS2 as OPS_TIME, OP_META2 as META_TIME } from "./glitch-ops-wave2-temporal.js";
4/* glitch-ops.js — a stackable rack of manipulation + transformation effects.
5
6   Each op takes a canvas and mutates it in place, so they compose in any order
7   (the effects rack applies them in sequence). Anything random is driven by a
8   seeded PRNG, so a given seed reproduces the same glitch exactly — chaos you
9   can keep, in the site's reproducible-by-seed idiom. Zero dependencies. */
10
11function mulberry32(a) {
12  return function () {
13    a |= 0; a = (a + 0x6D2B79F5) | 0;
14    let t = Math.imul(a ^ (a >>> 15), 1 | a);
15    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
16    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
17  };
18}
19export function rngFrom(seed) {
20  if (typeof seed === "function") return seed;
21  let h = 2166136261; const s = String(seed == null ? 1 : seed);
22  for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619);
23  return mulberry32(h >>> 0);
24}
25
26const scratch = (w, h) => { const c = document.createElement("canvas"); c.width = w; c.height = h; return c; };
27const luma = (d, i) => (d[i] * 0.299 + d[i + 1] * 0.587 + d[i + 2] * 0.114) / 255;
28
29// Pixel sort (Asendorf): sort contiguous runs whose luma sits in [low, high].
30export function pixelSort(canvas, p = {}) {
31  const low = p.low ?? 0.25, high = p.high ?? 0.8, vertical = p.axis === "col";
32  const ctx = canvas.getContext("2d"), w = canvas.width, h = canvas.height;
33  const img = ctx.getImageData(0, 0, w, h), d = img.data;
34  const idx = vertical ? (k, line) => (k * w + line) * 4 : (k, line) => (line * w + k) * 4;
35  const lines = vertical ? w : h, len = vertical ? h : w;
36  for (let line = 0; line < lines; line++) {
37    let start = -1;
38    for (let k = 0; k <= len; k++) {
39      const l = k < len ? luma(d, idx(k, line)) : -1;
40      const inBand = k < len && l >= low && l <= high;
41      if (inBand && start < 0) start = k;
42      else if (!inBand && start >= 0) {
43        const run = [];
44        for (let j = start; j < k; j++) { const i = idx(j, line); run.push([d[i], d[i + 1], d[i + 2], d[i + 3], luma(d, i)]); }
45        run.sort((a, b) => a[4] - b[4]);
46        for (let j = start; j < k; j++) { const i = idx(j, line), c = run[j - start]; d[i] = c[0]; d[i + 1] = c[1]; d[i + 2] = c[2]; d[i + 3] = c[3]; }
47        start = -1;
48      }
49    }
50  }
51  ctx.putImageData(img, 0, 0);
52}
53
54// RGB shift / chromatic aberration.
55export function rgbShift(canvas, p = {}) {
56  const dx = Math.round(p.dx ?? 6), dy = Math.round(p.dy ?? 0);
57  const ctx = canvas.getContext("2d"), w = canvas.width, h = canvas.height;
58  const s = ctx.getImageData(0, 0, w, h).data, out = ctx.createImageData(w, h), o = out.data;
59  const cl = (v, m) => (v < 0 ? 0 : v > m ? m : v);
60  for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
61    const i = (y * w + x) * 4;
62    const ri = (cl(y + dy, h - 1) * w + cl(x + dx, w - 1)) * 4;
63    const bi = (cl(y - dy, h - 1) * w + cl(x - dx, w - 1)) * 4;
64    o[i] = s[ri]; o[i + 1] = s[i + 1]; o[i + 2] = s[bi + 2]; o[i + 3] = 255;
65  }
66  ctx.putImageData(out, 0, 0);
67}
68
69// Databend: shift the raw RGB byte stream (a non-multiple of 3 tears channels
70// diagonally), with an optional XOR mask — the classic corrupt-the-bytes look.
71export function databend(canvas, p = {}) {
72  const shift = Math.max(1, Math.round(p.shift ?? 13)), xor = (p.xor | 0) & 255;
73  const ctx = canvas.getContext("2d"), w = canvas.width, h = canvas.height, n = w * h;
74  const img = ctx.getImageData(0, 0, w, h), d = img.data;
75  const rgb = new Uint8Array(n * 3);
76  for (let i = 0, j = 0; i < n; i++) { const k = i * 4; rgb[j++] = d[k]; rgb[j++] = d[k + 1]; rgb[j++] = d[k + 2]; }
77  const len = rgb.length, out = new Uint8Array(len);
78  for (let i = 0; i < len; i++) { let v = rgb[(i + shift) % len]; if (xor) v ^= xor; out[i] = v; }
79  for (let i = 0, j = 0; i < n; i++) { const k = i * 4;
79 d[k] = out[j++]; d[k + 1] = out[j++]; d[k + 2] = out[j++]; }
80  ctx.putImageData(img, 0, 0);
81}
82
83// Slice glitch: displace random horizontal bands.
84export function slice(canvas, p = {}) {
85  const count = Math.max(1, Math.round(p.count ?? 9)), maxShift = p.maxShift ?? 0.14, rng = rngFrom(p.seed);
86  const ctx = canvas.getContext("2d"), w = canvas.width, h = canvas.height;
87  const tmp = scratch(w, h); tmp.getContext("2d").drawImage(canvas, 0, 0);
88  ctx.clearRect(0, 0, w, h);
89  let y = 0;
90  for (let nb = 0; nb < count && y < h; nb++) {
91    const bh = Math.max(2, Math.round((h / count) * (0.4 + rng() * 1.2)));
92    const sx = Math.round((rng() * 2 - 1) * maxShift * w);
93    ctx.drawImage(tmp, 0, y, w, bh, sx, y, w, bh);
94    if (sx > 0) ctx.drawImage(tmp, w - sx, y, sx, bh, 0, y, sx, bh);
95    else if (sx < 0) ctx.drawImage(tmp, 0, y, -sx, bh, w + sx, y, -sx, bh);
96    y += bh;
97  }
98  if (y < h) ctx.drawImage(tmp, 0, y, w, h - y, 0, y, w, h - y);
99}
100
101// Wave / ripple displacement.
102export function wave(canvas, p = {}) {
103  const amp = p.amp ?? 0.03, freq = p.freq ?? 6, phase = p.phase ?? 0, vertical = p.axis === "col";
104  const ctx = canvas.getContext("2d"), w = canvas.width, h = canvas.height;
105  const tmp = scratch(w, h); tmp.getContext("2d").drawImage(canvas, 0, 0);
106  ctx.clearRect(0, 0, w, h);
107  if (!vertical) for (let y = 0; y < h; y++) { const dx = Math.sin((y / h) * freq * 6.2832 + phase) * amp * w; ctx.drawImage(tmp, 0, y, w, 1, dx, y, w, 1); }
108  else for (let x = 0; x < w; x++) { const dy = Math.sin((x / w) * freq * 6.2832 + phase) * amp * h; ctx.drawImage(tmp, x, 0, 1, h, x, dy, 1, h); }
109}
110
111// Mirror / kaleidoscope symmetry.
112export function mirror(canvas, p = {}) {
113  const mode = p.mode || "quad", ctx = canvas.getContext("2d"), w = canvas.width, h = canvas.height;
114  const tmp = scratch(w, h); tmp.getContext("2d").drawImage(canvas, 0, 0);
115  ctx.clearRect(0, 0, w, h);
116  const q = (sx, sy) => { ctx.save(); ctx.translate(sx < 0 ? w : 0, sy < 0 ? h : 0); ctx.scale(sx, sy); ctx.drawImage(tmp, 0, 0, w / 2, h / 2, 0, 0, w / 2, h / 2); ctx.restore(); };
117  if (mode === "x") { ctx.drawImage(tmp, 0, 0, w / 2, h, 0, 0, w / 2, h); ctx.save(); ctx.translate(w, 0); ctx.scale(-1, 1); ctx.drawImage(tmp, 0, 0, w / 2, h, 0, 0, w / 2, h); ctx.restore(); }
118  else if (mode === "y") { ctx.drawImage(tmp, 0, 0, w, h / 2, 0, 0, w, h / 2); ctx.save(); ctx.translate(0, h); ctx.scale(1, -1); ctx.drawImage(tmp, 0, 0, w, h / 2, 0, 0, w, h / 2); ctx.restore(); }
119  else { q(1, 1); q(-1, 1); q(1, -1); q(-1, -1); }
120}
121
122// Echo / ghost feedback.
123export function echo(canvas, p = {}) {
124  const dx = p.dx ?? 14, dy = p.dy ?? 8, alpha = p.alpha ?? 0.5;
125  const ctx = canvas.getContext("2d");
126  const tmp = scratch(canvas.width, canvas.height); tmp.getContext("2d").drawImage(canvas, 0, 0);
127  ctx.save(); ctx.globalAlpha = Math.max(0, Math.min(1, alpha)); ctx.globalCompositeOperation = "lighter";
128  ctx.drawImage(tmp, dx, dy); ctx.restore();
129}
130
131export function invert(canvas) {
132  const ctx = canvas.getContext("2d"), img = ctx.getImageData(0, 0, canvas.width, canvas.height), d = img.data;
133  for (let i = 0; i < d.length; i += 4) { d[i] = 255 - d[i]; d[i + 1] = 255 - d[i + 1]; d[i + 2] = 255 - d[i + 2]; }
134  ctx.putImageData(img, 0, 0);
135}
136
137export function posterize(canvas, p = {}) {
138  const levels = Math.max(2, Math.round(p.levels ?? 4)), step = 255 / (levels - 1);
139  const ctx = canvas.getContext("2d"), img = ctx.getImageData(0, 0, canvas.width, canvas.height), d = img.data;
140  for (let i = 0; i < d.length; i += 4) { d[i] = Math.round(d[i] / step) * step; d[i + 1] = Math.round(d[i + 1] / step) * step; d[i + 2] = Math.round(d[i + 2] / step) * step; }
141  ctx.putImageData(img, 0, 0);
142}
143
144// 4x4 ordered Bayer matrix (normalised thresholds), shared by dither.
145const BAYER4 = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5];
146
147// Ordered (Bayer) dither to N levels per channel — the classic 8-bit / PS1
148// gradient break-up, applied AFTER the engine so it stacks with anything.
149export function dither(canvas, p = {}) {
150  const L = Math.max(2, Math.round(p.levels ?? 4));
151  const ctx = canvas.getContext("2d"), w = canvas.width, h = canvas.height;
152  const img = ctx.getImageData(0, 0, w, h), d = img.data;
153  for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
154    const m = (BAYER4[(x & 3) + (y & 3) * 4] + 0.5) / 16, i = (y * w + x) * 4;
155    for (let c = 0; c < 3; c++) {
156      const scaled = (d[i + c] / 255) * (L - 1), lower = Math.floor(scaled);
157      d[i + c] = ((scaled - lower > m ? lower + 1 : lower) / (L - 1)) * 255;
158    }
159  }
160  ctx.putImageData(img, 0, 0);
161}
162
163// Halftone dot-screen: bright cells become bigger dots on ink — a print /
164// op-art register (Zain's textile + plotter-density DNA).
165export function halftone(canvas, p = {}) {
166  const cell = Math.max(3, Math.round(p.cell ?? 6)), mono = p.color === "mono";
167  const ctx = canvas.getContext("2d"), w = canvas.width, h = canvas.height;
168  const tmp = scratch(w, h), tctx = tmp.getContext("2d"); tctx.drawImage(canvas, 0, 0);
169  const s = tctx.getImageData(0, 0, w, h).data;
170  ctx.fillStyle = "#07070c"; ctx.fillRect(0, 0, w, h);
171  for (let cy = 0; cy < h; cy += cell) for (let cx = 0; cx < w; cx += cell) {
172    const mx = Math.min(w - 1, cx + (cell >> 1)), my = Math.min(h - 1, cy + (cell >> 1)), i = (my * w + mx) * 4;
173    const l = luma(s, i), r = l * (cell * 0.62);
174    if (r < 0.35) continue;
175    ctx.fillStyle = mono ? "#e9e6df" : `rgb(${s[i]},${s[i + 1]},${s[i + 2]})`;
176    ctx.beginPath(); ctx.arc(cx + cell / 2, cy + cell / 2, r, 0, 6.2832); ctx.fill();
177  }
178}
179
180// ASCII / text-mode: each cell becomes a glyph chosen by luminance. Colour
181// modes cover terminal phosphor (green/amber), ink, or the source tint.
182const ASCII_RAMP = " .:-=+*oz#%@";
183export function ascii(canvas, p = {}) {
184  const cell = Math.max(5, Math.round(p.cell ?? 10)), mode = p.color || "source";
185  const ctx = canvas.getContext("2d"), w = canvas.width, h = canvas.height;
186  const tmp = scratch(w, h), tctx = tmp.getContext("2d"); tctx.drawImage(canvas, 0, 0);
187  const s = tctx.getImageData(0, 0, w, h).data;
188  ctx.fillStyle = "#05050a"; ctx.fillRect(0, 0, w, h);
189  ctx.font = "700 " + cell + "px " + "ui-monospace, monospace";
190  ctx.textBaseline = "top";
191  const tint = mode === "green" ? [120, 255, 150] : mode === "amber" ? [255, 176, 64] : mode === "ink" ? [233, 230, 223] : null;
192  for (let cy = 0; cy < h; cy += cell) for (let cx = 0; cx < w; cx += cell) {
193    let lr = 0, lg = 0, lb = 0, ll = 0, n = 0;
194    for (let y = cy; y < cy + cell && y < h; y += 2) for (let x = cx; x < cx + cell && x < w; x += 2) {
195      const i = (y * w + x) * 4; lr += s[i]; lg += s[i + 1]; lb += s[i + 2]; ll += luma(s, i); n++;
196    }
197    if (!n) continue;
198    const l = ll / n, ch = ASCII_RAMP[Math.min(ASCII_RAMP.length - 1, Math.floor(l * ASCII_RAMP.length))];
199    if (ch === " ") continue;
200    if (tint) { const g = 0.5 + 0.5 * l; ctx.fillStyle = `rgb(${tint[0] * g | 0},${tint[1] * g | 0},${tint[2] * g | 0})`; }
201    else ctx.fillStyle = `rgb(${(lr / n) | 0},${(lg / n) | 0},${(lb / n) | 0})`;
202    ctx.fillText(ch, cx, cy);
203  }
204}
205
206// CRT scanlines as a stackable pass (the engine has its own; this lets you
207// stack a heavier line over any effect chain).
208export function scanlines(canvas, p = {}) {
209  const strength = Math.max(0, Math.min(0.9, p.strength ?? 0.4)), gap = Math.max(2, Math.round(p.gap ?? 2));
210  const ctx = canvas.getContext("2d"), w = canvas.width, h = canvas.height;
211  const img = ctx.getImageData(0, 0, w, h), d = img.data;
212  for (let y = 0; y < h; y++) {
213    const f = (y % gap === 0) ? (1 - strength) : (1 + strength * 0.12);
214    for (let x = 0; x < w; x++) { const i = (y * w + x) * 4; d[i] *= f; d[i + 1] *= f; d[i + 2] *= f; }
215  }
216  ctx.putImageData(img, 0, 0);
217}
218
219// NTSC / composite chroma bleed: smear R and B horizontally, keep luma sharp —
220// the color-fringe of a VHS pause or a PS2 over composite.
221export function bleed(canvas, p = {}) {
222  const rad = Math.max(1, Math.round(p.radius ?? 6));
223  const ctx = canvas.getContext("2d"), w = canvas.width, h = canvas.height;
224  const img = ctx.getImageData(0, 0, w, h), d = img.data, win = rad * 2 + 1;
225  for (let y = 0; y < h; y++) {
226    let sr = 0, sb = 0; const base = y * w;
227    for (let x = 0; x < Math.min(rad, w); x++) { const i = (base + x) * 4; sr += d[i]; sb += d[i + 2]; }
228    for (let x = 0; x < w; x++) {
229      const add = x + rad, sub = x - rad - 1;
230      if (add < w) { const ia = (base + add) * 4; sr += d[ia]; sb += d[ia + 2]; }
231      if (sub >= 0) { const is = (base + sub) * 4; sr -= d[is]; sb -= d[is + 2]; }
232      const lo = Math.max(0, x - rad), hi = Math.min(w - 1, x + rad), cnt = hi - lo + 1, i = (base + x) * 4;
233      d[i] = sr / cnt; d[i + 2] = sb / cnt;
234    }
235  }
236  ctx.putImageData(img, 0, 0);
237}
238
239// ---------------------------------------------------------------------------
240// "Feel" modifiers — a soft/hard/angular/wavy/goopy/bubbly/sparkly/starry rack,
241// each an original canvas-2D op built from a real, sourced technique (Rec.709
242// bloom, unsharp mask, Voronoi crystallize, orthogonal multi-octave sine,
243// iq domain warp, spherical refraction, golden-angle cross-glints, hashed star
244// field) and scaled by a single `amount` in [0,1] so a whisper and a scream are
245// the same control. Sources in project-docs; the transform into this idiom is ours.
246// ---------------------------------------------------------------------------
247const clamp01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x);
248function blurCanvas(src, px) {
249  const c = scratch(src.width, src.height), x = c.getContext("2d");
250  x.filter = "blur(" + px + "px)"; x.drawImage(src, 0, 0); x.filter = "none"; return c;
251}
252// cheap value-noise fBm for domain warping
253function _h2(x, y) { const n = Math.sin(x * 127.1 + y * 311.7) * 43758.5453; return n - Math.floor(n); }
254function _vnoise(x, y) {
255  const xi = Math.floor(x), yi = Math.floor(y), xf = x - xi, yf = y - yi;
256  const u = xf * xf * (3 - 2 * xf), v = yf * yf * (3 - 2 * yf);
257  const a = _h2(xi, yi), b = _h2(xi + 1, yi), c = _h2(xi, yi + 1), d = _h2(xi + 1, yi + 1);
258  return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
259}
260function _fbm(x, y, oct) { let s = 0, amp = 0.5, f = 1; for (let i = 0; i < oct; i++) { s += amp * _vnoise(x * f, y * f); f *= 2; amp *= 0.5; } return s; }
261
262// soft — Orton dreamy glow: screen-blend a gaussian-blurred copy over the base.
263export function soften(canvas, p = {}) {
264  const a = clamp01(p.amount ?? 0.5); if (a <= 0.002) return;
265  const b = blurCanvas(canvas, 2 + a * 20), ctx = canvas.getContext("2d");
266  ctx.save(); ctx.globalAlpha = Math.min(1, 0.35 + a * 0.5); ctx.globalCompositeOperation = "screen";
267  ctx.drawImage(b, 0, 0); ctx.restore();
268}
269
270// hard — unsharp mask: out = src + amount*(src - blur), per channel.
271export function sharpen(canvas, p = {}) {
272  const a = clamp01(p.amount ?? 0.5); if (a <= 0.002) return;
273  const strength = a * 2.4, w = canvas.width, h = canvas.height, ctx = canvas.getContext("2d");
274  const b = blurCanvas(canvas, 1.0 + a * 2.0).getContext("2d").getImageData(0, 0, w, h).data;
275  const img = ctx.getImageData(0, 0, w, h), d = img.data;
276  for (let i = 0; i < d.length; i += 4) for (let c = 0; c < 3; c++) {
277    const v = d[i + c] + strength * (d[i + c] - b[i + c]); d[i + c] = v < 0 ? 0 : v > 255 ? 255 : v;
278  }
279  ctx.putImageData(img, 0, 0);
280}
281
282// angular — Voronoi crystallize: nearest jittered site (argmin sq-dist), flat-fill by region average.
283export function crystallize(canvas, p = {}) {
284  const a = clamp01(p.amount ?? 0.5), rng = rngFrom(p.seed);
285  const w = canvas.width, h = canvas.height, ctx = canvas.getContext("2d");
286  const cell = Math.max(6, Math.round(8 + a * 46));
287  const gx = Math.ceil(w / cell) + 1, gy = Math.ceil(h / cell) + 1, gn = gx * gy;
288  const sx = new Float32Array(gn), sy = new Float32Array(gn);
289  for (let j = 0; j < gy; j++) for (let i = 0; i < gx; i++) { const k = j * gx + i; sx[k] = (i + rng()) * cell; sy[k] = (j + rng()) * cell; }
290  const img = ctx.getImageData(0, 0, w, h), d = img.data;
291  const sr = new Float32Array(gn), sg = new Float32Array(gn), sb = new Float32Array(gn), cn = new Float32Array(gn), idx = new Int32Array(w * h);
292  for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
293    const ci = Math.min(gx - 1, (x / cell) | 0), cj = Math.min(gy - 1, (y / cell) | 0);
294    let best = -1, bd = 1e18;
295    for (let dj = -1; dj <= 1; dj++) for (let di = -1; di <= 1; di++) {
296      const ii = ci + di, jj = cj + dj; if (ii < 0 || jj < 0 || ii >= gx || jj >= gy) continue;
297      const k = jj * gx + ii, ddx = x - sx[k], ddy = y - sy[k], dd = ddx * ddx + ddy * ddy;
298      if (dd < bd) { bd = dd; best = k; }
299    }
300    const pi = (y * w + x) * 4; idx[y * w + x] = best; sr[best] += d[pi]; sg[best] += d[pi + 1]; sb[best] += d[pi + 2]; cn[best]++;
301  }
302  for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) { const k = idx[y * w + x], c = cn[k] || 1, pi = (y * w + x) * 4; d[pi] = sr[k] / c; d[pi + 1] = sg[k] / c; d[pi + 2] = sb[k] / c; }
303  ctx.putImageData(img, 0, 0);
304}
305
306// wavy — orthogonal multi-octave sine displacement (dx from y, dy from x).
307export function wavy(canvas, p = {}) {
308  const a = clamp01(p.amount ?? 0.5); if (a <= 0.002) return;
309  const t = p.phase ?? 0, w = canvas.width, h = canvas.height, ctx = canvas.getContext("2d");
310  const A0 = a * h * 0.06, f0 = p.freq ?? 3, TAU = 6.2831853;
311  const dxY = new Float32Array(h), dyX = new Float32Array(w);
312  for (let y = 0; y < h; y++) { let s = 0, A = A0, f = f0; for (let i = 0; i < 3; i++) { s += A * Math.sin(TAU * f * (y / h) + t * (0.6 + 0.2 * i)); A *= 0.5; f *= 2; } dxY[y] = s; }
313  for (let x = 0; x < w; x++) { let s = 0, A = A0, f = f0; for (let i = 0; i < 3; i++) { s += A * Math.sin(TAU * f * (x / w) + t * (0.7 + 0.2 * i)); A *= 0.5; f *= 2; } dyX[x] = s; }
314  const tmp = scratch(w, h); tmp.getContext("2d").drawImage(canvas, 0, 0);
315  const src = tmp.getContext("2d").getImageData(0, 0, w, h).data, out = ctx.createImageData(w, h), o = out.data;
316  for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
317    let sxp = (x + dxY[y]) | 0, syp = (y + dyX[x]) | 0;
318    sxp = sxp < 0 ? 0 : sxp >= w ? w - 1 : sxp; syp = syp < 0 ? 0 : syp >= h ? h - 1 : syp;
319    const si = (syp * w + sxp) * 4, di = (y * w + x) * 4;
320    o[di] = src[si]; o[di + 1] = src[si + 1]; o[di + 2] = src[si + 2]; o[di + 3] = 255;
321  }
322  ctx.putImageData(out, 0, 0);
323}
324
325// goopy — iq domain warp: sample src at uv + fbm-driven offset (coarse field, bilinear up).
326export function goop(canvas, p = {}) {
327  const a = clamp01(p.amount ?? 0.5); if (a <= 0.002) return;
328  const t = p.phase ?? 0, w = canvas.width, h = canvas.height, ctx = canvas.getContext("2d");
329  const amp = a * Math.min(w, h) * 0.16, scale = p.scale ?? 0.006, step = 4;
330  const gw = Math.ceil(w / step) + 2, gh = Math.ceil(h / step) + 2;
331  const DX = new Float32Array(gw * gh), DY = new Float32Array(gw * gh);
332  for (let j = 0; j < gh; j++) for (let i = 0;
332 i < gw; i++) {
333    const x = i * step * scale, y = j * step * scale, k = j * gw + i;
334    DX[k] = amp * (_fbm(x + 1.7, y + 9.2 + t * 0.08, 4) - 0.5);
335    DY[k] = amp * (_fbm(x + 8.3, y + 2.8 - t * 0.08, 4) - 0.5);
336  }
337  const tmp = scratch(w, h); tmp.getContext("2d").drawImage(canvas, 0, 0);
338  const src = tmp.getContext("2d").getImageData(0, 0, w, h).data, out = ctx.createImageData(w, h), o = out.data;
339  for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
340    const gi = x / step, gj = y / step, i0 = gi | 0, j0 = gj | 0, fx = gi - i0, fy = gj - j0, k = j0 * gw + i0;
341    const dx = DX[k] + (DX[k + 1] - DX[k]) * fx + (DX[k + gw] - DX[k]) * fy;
342    const dy = DY[k] + (DY[k + 1] - DY[k]) * fx + (DY[k + gw] - DY[k]) * fy;
343    let sxp = (x + dx) | 0, syp = (y + dy) | 0;
344    sxp = sxp < 0 ? 0 : sxp >= w ? w - 1 : sxp; syp = syp < 0 ? 0 : syp >= h ? h - 1 : syp;
345    const si = (syp * w + sxp) * 4, di = (y * w + x) * 4;
346    o[di] = src[si]; o[di + 1] = src[si + 1]; o[di + 2] = src[si + 2]; o[di + 3] = 255;
347  }
348  ctx.putImageData(out, 0, 0);
349}
350
351// bubbly — drifting metaball bubbles, each a spherical refraction lens with a Fresnel rim.
352export function bubbly(canvas, p = {}) {
353  const a = clamp01(p.amount ?? 0.5), rng = rngFrom(p.seed), t = p.phase ?? 0;
354  const w = canvas.width, h = canvas.height, ctx = canvas.getContext("2d"), m = Math.min(w, h), N = 4 + Math.round(a * 9), ior = 1.33;
355  const bx = [], by = [], br = [];
356  for (let i = 0; i < N; i++) {
357    const ph = rng() * 6.283, sp = 0.2 + rng() * 0.5, ax = 0.2 + rng() * 0.6, ay = 0.2 + rng() * 0.6;
358    bx.push((0.5 + 0.4 * Math.sin(t * sp + ph)) * w * 0.0 + ax * w);
359    by.push(ay * h + Math.sin(t * sp * 0.8 + ph) * 0.05 * h);
360    bx[i] = ax * w + Math.cos(t * sp + ph) * 0.06 * w;
361    br.push((0.07 + 0.13 * rng()) * m * (0.7 + a));
362  }
363  const tmp = scratch(w, h); tmp.getContext("2d").drawImage(canvas, 0, 0);
364  const src = tmp.getContext("2d").getImageData(0, 0, w, h).data, img = ctx.getImageData(0, 0, w, h), d = img.data;
365  for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
366    let best = -1, bnd = 2;
367    for (let i = 0; i < N; i++) { const nd = Math.hypot(x - bx[i], y - by[i]) / br[i]; if (nd < 1 && nd < bnd) { bnd = nd; best = i; } }
368    const di = (y * w + x) * 4;
369    if (best < 0) continue;
370    const dx = x - bx[best], dy = y - by[best], nd = bnd, z = Math.sqrt(1 - nd * nd);
371    const mag = 1 + (1 / ior - 1) * z;
372    let sxp = (bx[best] + dx * mag) | 0, syp = (by[best] + dy * mag) | 0;
373    sxp = sxp < 0 ? 0 : sxp >= w ? w - 1 : sxp; syp = syp < 0 ? 0 : syp >= h ? h - 1 : syp;
374    const si = (syp * w + sxp) * 4, rim = Math.pow(nd, 3.5) * 200 * (0.4 + a);
375    d[di] = Math.min(255, src[si] + rim * 0.7); d[di + 1] = Math.min(255, src[si + 1] + rim * 0.9); d[di + 2] = Math.min(255, src[si + 2] + rim);
376  }
377  ctx.putImageData(img, 0, 0);
378}
379
380// sparkly — cross-glints seeded on the brightest pixel of each grid cell (golde
380n rays, additive).
381export function sparkle(canvas, p = {}) {
382  const a = clamp01(p.amount ?? 0.5), t = p.phase ?? 0, w = canvas.width, h = canvas.height, ctx = canvas.getContext("2d");
383  const grid = Math.max(10, Math.round(30 - a * 16)), T = 0.6, pts = a < 0.5 ? 4 : 6;
384  const data = ctx.getImageData(0, 0, w, h).data;
385  ctx.save(); ctx.globalCompositeOperation = "lighter"; ctx.lineCap = "round";
386  for (let cy = 0; cy < h; cy += grid) for (let cx = 0; cx < w; cx += grid) {
387    let bl = 0, bx = -1, by = -1;
388    for (let y = cy; y < cy + grid && y < h; y += 2) for (let x = cx; x < cx + grid && x < w; x += 2) {
389      const i = (y * w + x) * 4, l = (data[i] * 0.2126 + data[i + 1] * 0.7152 + data[i + 2] * 0.0722) / 255;
390      if (l > bl) { bl = l; bx = x; by = y; }
391    }
392    if (bl < T || bx < 0) continue;
393    const tw = 0.55 + 0.45 * Math.sin(t * 3 + (bx + by) * 0.05), len = grid * (0.7 + a) * (0.5 + bl - T) * (0.6 + 0.4 * tw);
394    if (len < 2) continue;
395    for (let k = 0; k < pts; k++) {
396      const ang = t * 0.3 + 6.2831853 * k / pts, ex = bx + Math.cos(ang) * len, ey = by + Math.sin(ang) * len;
397      const g = ctx.createLinearGradient(bx, by, ex, ey);
398      g.addColorStop(0, "rgba(255,250,235," + (0.5 * tw).toFixed(3) + ")"); g.addColorStop(1, "rgba(255,250,235,0)");
399      ctx.strokeStyle = g; ctx.lineWidth = 1.4; ctx.beginPath(); ctx.moveTo(bx, by); ctx.lineTo(ex, ey); ctx.stroke();
400    }
401  }
402  ctx.restore();
403}
404
405// starry — additive procedural star field (hashed grid + radial falloff + twinkle) over the canvas.
406export function starry(canvas, p = {}) {
407  const a = clamp01(p.amount ?? 0.5), t = p.phase ?? 0, w = canvas.width, h = canvas.height, ctx = canvas.getContext("2d");
408  const density = 8 + Math.round(a * 30), th = 1 - (0.28 + a * 0.5), cw = w / density, ch = h / density;
409  const img = ctx.getImageData(0, 0, w, h), d = img.data, rad = Math.max(cw, ch) * 0.42;
410  for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
411    const ci = (x / cw) | 0, cj = (y / ch) | 0, b = _h2(ci, cj + 19.1);
412    if (b < th) continue;
413    const spx = (ci + _h2(ci, cj)) * cw, spy = (cj + _h2(ci + 7.3, cj)) * ch;
414    const dist = Math.hypot(x - spx, y - spy), g = Math.max(0, 1 - dist / rad);
415    if (g <= 0) continue;
416    const tw = 0.55 + 0.45 * Math.sin(t * 2 + b * 25), add = Math.pow(g, 4) * (b - th) / (1 - th) * tw * 255;
417    const i = (y * w + x) * 4, warm = 0.85 + 0.15 * _h2(ci + 3.1, cj + 5.7);
418    d[i] = Math.min(255, d[i] + add); d[i + 1] = Math.min(255, d[i + 1] + add * warm); d[i + 2] = Math.min(255, d[i + 2] + add * (1.1 - 0.2 * warm));
419  }
420  ctx.putImageData(img, 0, 0);
421}
422
423// melt — columns drip downward by luminance, the wet-paint slide.
424export function melt(canvas, p = {}) {
425  const a = clamp01(p.amount ?? 0.5); if (a <= 0.002) return;
426  const rng = rngFrom(p.seed), t = p.phase ?? 0;
427  const w = canvas.width, h = canvas.height, ctx = canvas.getContext("2d");
428  const tmp = scratch(w, h); tmp.getContext("2d").drawImage(canvas, 0, 0);
429  const src = tmp.getContext("2d").getImageData(0, 0, w, h).data;
430  const img = ctx.getImageData(0, 0, w, h), d = img.data;
431  const maxDrip = a * h * 0.42;
432  for (let x = 0; x < w; x++) {
433    // each column drips by its own brightness plus a seeded wobble
434    let acc = 0, n = 0;
435    for (let y = 0; y < h; y += 6) { acc += luma(src, (y * w + x) * 4); n++; }
436    const bright = acc / (n || 1);
437    const drip = Math.round(maxDrip * (0.25 + bright * 0.75) * (0.55 + 0.45 * Math.sin(x * 0.07 + rng() * 6.28 + t * 0.6)));
438    if (drip <= 0) continue;
439    for (let y = h - 1; y >= 0; y--) {
440      const sy = y - drip < 0 ? 0 : y - drip, si = (sy * w + x) * 4, di = (y * w + x) * 4;
441      d[di] = src[si]; d[di + 1] = src[si + 1]; d[di + 2] = src[si + 2];
442    }
443  }
444  ctx.putImageData(img, 0, 0);
445}
446
447// kaleido — true polar fold: N mirrored wedges around the centre.
448export function kaleido(canvas, p = {}) {
449  const a = clamp01(p.amount ?? 0.5);
450  const seg = Math.max(2, Math.round(2 + a * 10)), t = p.phase ?? 0;
451  const w = canvas.width, h = canvas.height, ctx = canvas.getContext("2d");
452  const tmp = scratch(w, h); tmp.getContext("2d").drawImage(canvas, 0, 0);
453  const src = tmp.getContext("2d").getImageData(0, 0, w, h).data;
454  const out = ctx.createImageData(w, h), o = out.data;
455  const cx = w / 2, cy = h / 2, wedge = Math.PI * 2 / seg, spin = t * 0.15;
456  for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
457    const dx = x - cx, dy = y - cy, r = Math.hypot(dx, dy);
458    let ang = Math.atan2(dy, dx) + spin;
459    ang = ((ang % wedge) + wedge) % wedge;          // fold into one wedge
460    if (ang > wedge / 2) ang = wedge - ang;          // mirror it
461    let sx = Math.round(cx + Math.cos(ang) * r), sy = Math.round(cy + Math.sin(ang) * r);
462    sx = sx < 0 ? 0 : sx >= w ? w - 1 : sx; sy = sy < 0 ? 0 : sy >= h ? h - 1 : sy;
463    const si = (sy * w + sx) * 4, di = (y * w + x) * 4;
464    o[di] = src[si]; o[di + 1] = src[si + 1]; o[di + 2] = src[si + 2]; o[di + 3] = 255;
465  }
466  ctx.putImageData(out, 0, 0);
467}
468
469// prism — radial chromatic dispersion: channels split along the radius.
470export function prism(canvas, p = {}) {
471  const a = clamp01(p.amount ?? 0.5); if (a <= 0.002) return;
472  const w = canvas.width, h = canvas.height, ctx = canvas.getContext("2d");
473  const img = ctx.getImageData(0, 0, w, h), s = img.data, out = ctx.createImageData(w, h), o = out.data;
474  const cx = w / 2, cy = h / 2, k = a * 0.09;
475  const px = (x, y, c) => { const xi = x < 0 ? 0 : x >= w ? w - 1 : x | 0, yi = y < 0 ? 0 : y >= h ? h - 1 : y | 0; return s[(yi * w + xi) * 4 + c]; };
476  for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
477    const dx = x - cx, dy = y - cy, di = (y * w + x) * 4;
478    o[di] = px(cx + dx * (1 + k), cy + dy * (1 + k), 0);
479    o[di + 1] = s[di + 1];
480    o[di + 2] = px(cx + dx * (1 - k), cy + dy * (1 - k), 2);
481    o[di + 3] = 255;
482  }
483  ctx.putImageData(out, 0, 0);
484}
485
486// neon — Sobel edges, colourised and added back as glowing wire.
487export function neon(canvas, p = {}) {
488  const a = clamp01(p.amount ?? 0.5); if (a <= 0.002) return;
489  const w = canvas.width, h = canvas.height, ctx = canvas.getContext("2d");
490  const img = ctx.getImageData(0, 0, w, h), d = img.data;
491  const L = new Float32Array(w * h);
492  for (let i = 0, j = 0; i < d.length; i += 4, j++) L[j] = luma(d, i);
493  const edge = new Float32Array(w * h);
494  let peak = 0.0001;
495  for (let y = 1; y < h - 1; y++) for (let x = 1; x < w - 1; x++) {
496    const i = y * w + x;
497    const gx = -L[i - w - 1] - 2 * L[i - 1] - L[i + w - 1] + L[i - w + 1] + 2 * L[i + 1] + L[i + w + 1];
498    const gy = -L[i - w - 1] - 2 * L[i - w] - L[i - w + 1] + L[i + w - 1] + 2 * L[i + w] + L[i + w + 1];
499    const g = Math.sqrt(gx * gx + gy * gy); edge[i] = g; if (g > peak) peak = g;
500  }
501  const gain = a * 2.1;
502  for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
503    const i = y * w + x, e = Math.pow(edge[i] / peak, 0.75) * gain, k = i * 4;
504    if (e <= 0.002) continue;
505    d[k] = Math.min(255, d[k] * (1 - a * 0.35) + e * 255 * 1.0);
506    d[k + 1] = Math.min(255, d[k + 1] * (1 - a * 0.35) + e * 255 * 0.35);
507    d[k + 2] = Math.min(255, d[k + 2] * (1 - a * 0.35) + e * 255 * 0.9);
508  }
509  ctx.putImageData(img, 0, 0);
510}
511
512// mosaic — set the picture in tesserae. Built the way a mosaicist works rather
513// than as a pixel grid: tiles are irregular and jittered, separated by real
514// grout, each tile carries one averaged colour with its own tone variation, and
515// each is bedded at its own slight angle so a raking light glints across the
516// field tile by tile (the Byzantine gold-ground trick). Modes: stone (matte
517// smalti), gold (gold-leaf ground with travelling glint), glass (translucent,
518// brighter grout), trencadis (Gaudi: bigger irregular shards, wide grout).
519export function mosaic(canvas, p = {}) {
520  const a = clamp01(p.amount ?? 0.5);
521  const mode = p.mode || "stone";
522  const rng = rngFrom(p.seed), t = p.phase ?? 0;
523  const w = canvas.width, h = canvas.height, ctx = canvas.getContext("2d");
524  const shard = mode === "trencadis";
525  // tile pitch: bigger amount = bigger, bolder tesserae
526  const cell = Math.max(5, Math.round((shard ? 14 : 7) + a * (shard ? 54 : 34)));
527  const jit = shard ? 0.46 : 0.24;                 // how far a seed strays from its grid slot
528  const grout = (p.grout ?? (shard ? 0.34 : 0.20)) * (0.6 + a * 0.7);
529
530  const gx = Math.ceil(w / cell) + 2, gy = Math.ceil(h / cell) + 2, gn = gx * gy;
531  const sx = new Float32Array(gn), sy = new Float32Array(gn), ang = new Float32Array(gn), ton = new Float32Array(gn);
532  for (let j = 0; j < gy; j++) for (let i = 0; i < gx; i++) {
533    const k = j * gx + i;
534    sx[k] = (i - 0.5 + 0.5 + (rng() - 0.5) * 2 * jit) * cell;
535    sy[k] = (j - 0.5 + 0.5 + (rng() - 0.5) * 2 * jit) * cell;
536    ang[k] = rng() * 6.2831853;                    // the angle this tile is bedded at
537    ton[k] = 0.82 + rng() * 0.36;                  // tile-to-tile tone variation
538  }
539
540  const img = ctx.getImageData(0, 0, w, h), d = img.data;
541  const sr = new Float32Array(gn), sg = new Float32Array(gn), sb = new Float32Array(gn), cn = new Float32Array(gn);
542  const idx = new Int32Array(w * h), edge = new Float32Array(w * h);
543  for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
544    const ci = Math.min(gx - 1, ((x / cell) | 0) + 1), cj = Math.min(gy - 1, ((y / cell) | 0) + 1);
545    let b1 = -1, d1 = 1e18, d2 = 1e18;
546    for (let dj = -1; dj <= 1; dj++) for (let di = -1; di <= 1; di++) {
547      const ii = ci + di, jj = cj + dj; if (ii < 0 || jj < 0 || ii >= gx || jj >= gy) continue;
548      const k = jj * gx + ii, ddx = x - sx[k], ddy = y - sy[k], dd = ddx * ddx + ddy * ddy;
549      if (dd < d1) { d2 = d1; d1 = dd; b1 = k; } else if (dd < d2) { d2 = dd; }
550    }
551    if (b1 < 0) continue;
552    const q = y * w + x, pi = q * 4;
553    idx[q] = b1;
554    // distance to the tile boundary: the classic (d2 - d1) Voronoi edge measure
555    edge[q] = (Math.sqrt(d2) - Math.sqrt(d1)) * 0.5;
556    sr[b1] += d[pi]; sg[b1] += d[pi + 1]; sb[b1] += d[pi + 2]; cn[b1]++;
557  }
558
559  const lx = p.lightX == null ? 0.5 : p.lightX, ly = p.lightY == null ? 0.35 : p.lightY;
560  const lightAng = Math.atan2(ly - 0.5, lx - 0.5) + t * 0.25;
561  const sharp = 2.0 + (p.glint ?? 0.5) * 10;
562  const groutPx = Math.max(0.6, grout * cell * 0.5);
563  const gr = shard ? 22 : 12, gg = shard ? 20 : 11, gb = shard ? 24 : 13;   // grout colour
564
565  for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
566    const q = y * w + x, k = idx[q], c = cn[k] || 1, pi = q * 4;
567    let r = sr[k] / c, g = sg[k] / c, b = sb[k] / c;
568    r *= ton[k]; g *= ton[k]; b *= ton[k];
569    if (mode === "gold") {
570      // gold leaf: push the tile toward gold, then let its bedding angle catch the light
571      const lum = (r * 0.299 + g * 0.587 + b * 0.114) / 255;
572      r = 255 * (0.20 + lum * 0.86); g = 255 * (0.13 + lum * 0.66); b = 255 * (0.03 + lum * 0.22);
573      const glint = Math.pow(Math.max(0, 0.5 + 0.5 * Math.cos(ang[k] - lightAng)), sharp);
574      r = Math.min(255, r + glint * 205); g = Math.min(255, g + glint * 180); b = Math.min(255, b + glint * 110);
575    } else if (mode === "glass") {
576      const glint = Math.pow(Math.max(0, 0.5 + 0.5 * Math.cos(ang[k] - lightAng)), sharp * 1.4);
577      r = Math.min(255, r * 1.06 + glint * 120); g = Math.min(255, g * 1.06 + glint * 130); b = Math.min(255, b * 1.06 + glint * 150);
578    }
579    // grout: fade the tile out toward its boundary onto the bedding mortar
580    const e = edge[q] < groutPx ? edge[q] / groutPx : 1;
581    const m = e * e * (3 - 2 * e);                    // smoothstep
582    d[pi] = gr + (r - gr) * m; d[pi + 1] = gg + (g - gg) * m; d[pi + 2] = gb + (b - gb) * m;
583  }
584  ctx.putImageData(img, 0, 0);
585}
586
587// Datamosh, after entro_play's WebGL datamoshing and FFglitch practice: the
588// previous frame smeared along a procedural block motion field, refreshed in
589// periodic bands like sparse I-frames. The caller maintains step.prev as a
590// snapshot of the LAST composed frame, so the smear feeds on its own output.
591function datamosh(canvas, step) {
592  const prev = step.prev;
593  if (!prev || !prev.width) return;
594  const g = canvas.getContext("2d");
595  const W = canvas.width, H = canvas.height;
596  const amt = step.amount === undefined ? 0.6 : step.amount;
597  const rng = rngFrom((step.seed || "mosh") + "|field");
598  const bs = Math.max(12, Math.round(Math.min(W, H) / 22));
599  const t = step.phase || 0;
600  // refresh band: a strip of honest current frame sweeps through, everything
601  // else re-projects the stale frame with per-block drift
602  const bandY = ((t * 0.13) % 1) * H;
603  const bandH = H * (0.10 + 0.25 * (1 - amt));
604  for (let y = 0; y < H; y += bs) {
605    const inBand = y + bs > bandY && y < bandY + bandH;
606    if (inBand) continue;
607    for (let x = 0; x < W; x += bs) {
608      const n1 = rng(), n2 = rng();
609      const swirl = Math.sin((x / W) * 5.1 + t * 0.7 + n1 * 6.28) + Math.
609cos((y / H) * 4.3 - t * 0.5 + n2 * 6.28);
610      const mag = amt * (4 + 22 * Math.abs(swirl) * 0.5);
611      const vx = Math.round(Math.cos(n1 * 6.28 + swirl) * mag);
612      const vy = Math.round(Math.sin(n2 * 6.28 - swirl) * mag);
613      const sx = Math.max(0, Math.min(prev.width - bs, x + vx));
614      const sy = Math.max(0, Math.min(prev.height - bs, y + vy));
615      g.drawImage(prev, sx, sy, bs, bs, x, y, bs, bs);
616    }
617  }
618}
619
620// Slit scan, after the Kinect slit-scan lineage: each horizontal strip of the
621// output samples a different moment from a short ring of past frames, so
622// motion shears into time. The caller maintains the ring at half resolution.
623function slitscan(canvas, step) {
624  const ring = step.ring, len = step.len || 0, head = step.head || 0;
625  if (!ring || !len) return;
626  const g = canvas.getContext("2d");
627  const W = canvas.width, H = canvas.height;
628  const amt = step.amount === undefined ? 0.6 : step.amount;
629  const strips = 48;
630  const sh = Math.ceil(H / strips);
631  const col = step.axis === "col";
632  const n = col ? Math.ceil(W / sh) : strips;
633  for (let i = 0; i < n; i++) {
634    const frac = i / n;
635    const wobble = 0.5 + 0.5 * Math.sin(frac * 6.28 * 2 + (step.phase || 0) * 0.6);
636    const age = Math.min(len - 1, Math.floor(frac * wobble * amt * (len - 1)));
637    const src = ring[(head - age + len * 4) % len];
638    if (!src || !src.width) continue;
639    if (col) {
640      const x = i * sh, sxs = Math.floor((x / W) * src.width), sws = Math.max(1, Math.floor((sh / W) * src.width));
641      g.drawImage(src, sxs, 0, sws, src.height, x, 0, sh, H);
642    } else {
643      const y = i * sh, sys = Math.floor((y / H) * src.height), shs = Math.max(1, Math.floor((sh / H) * src.height));
644      g.drawImage(src, 0, sys, src.width, shs, 0, y, W, sh);
645    }
646  }
647}
648
649export const OPS = {
650  ...OPS_CHEM, ...OPS_OPT, ...OPS_TIME,
651  soften, sharpen, crystallize, wavy, goop, bubbly, sparkle, starry, melt, kaleido, prism, neon, mosaic,
652  dither, halftone, posterize, pixelSort, databend, rgbShift, slice, wave, echo, mirror, ascii, bleed, scanlines, invert,
653  datamosh, slitscan,
654};
655
656// Metadata for the effects rack UI. Array order IS the apply pipeline
657// (restructure -> warp -> tone -> glitch -> screen -> light overlays); the UI
658// groups the chips into shelves by `cat`, independent of this order.
659export const OP_META = [
660  { op: "mosaic", label: "Mosaic", cat: "feel", desc: "square tiles averaged from the picture underneath" },
661  { op: "crystallize", label: "Angular", cat: "feel", desc: "shatters the frame into angular facets" },
662  { op: "kaleido", label: "Kaleido", cat: "feel", desc: "mirrors a wedge around the centre into radial symmetry" },
663  { op: "wavy", label: "Wavy", cat: "feel", desc: "rolls the rows on a sine, like heat off tarmac" },
664  { op: "goop", label: "Goopy", cat: "feel", desc: "drags the pixels into slow viscous runs" },
665  { op: "melt", label: "Melty", cat: "feel", desc: "lets the picture sag downward from its bright edges" },
666  { op: "bubbly", label: "Bubbly", cat: "feel", desc: "lifts rounded blisters out of the surface" },
667  { op: "soften", label: "Soft", cat: "feel", desc: "a diffusion bloom, the picture seen through breath" },
668  { op: "sharpen", label: "Hard", cat: "feel", desc: "hardens edges by subtracting its own blur" },
669  { op: "neon", label: "Neon", cat: "feel", desc: "keeps the edges and lights them, drops the fill" },
670  { op: "prism", label: "Prism", cat: "feel", desc: "splits the frame through a glass wedge" },
671  { op: "dither", label: "Dither", cat: "tone", desc: "ordered Bayer dither, tone carried by a dot pattern" },
672  { op: "halftone", label: "Halftone", cat: "tone", desc: "a print screen: tone becomes dot size on a rotated grid" },
673  { op: "posterize", label: "Posterize", cat: "tone", desc: "collapses the tones to a few flat steps" },
674  { op: "invert", label: "Invert", cat: "tone", desc: "swaps light for dark; at part strength it greys, so use it near full" },
675  { op: "pixelSort", label: "Pixel sort", cat: "glitch", desc: "sorts runs of pixels by brightness into smears" },
676  { op: "databend", label: "Databend", cat: "glitch", desc: "corrupts the bytes as if the file were damaged" },
677  { op: "rgbShift", label: "RGB shift", cat: "glitch", desc: "pulls the three colour channels apart" },
678  { op: "slice", label: "Slice", cat: "glitch", desc: "cuts the frame into bands and slides them" },
679  { op: "wave", label: "Wave", cat: "glitch", desc: "a standing wave displaces each row" },
680  { op: "datamosh", label: "Datamosh", cat: "glitch", desc: "keeps the motion but loses the frame it belonged to" },
681  { op: "slitscan", label: "Slit scan", cat: "glitch", desc: "each column samples a different moment" },
682  { op: "echo", label: "Echo", cat: "glitch", desc: "ghosts a displaced copy at falling opacity" },
683  { op: "mirror", label: "Mirror", cat: "glitch", desc: "folds one half of the frame onto the other" },
684  { op: "bleed", label: "Bleed", cat: "tone", desc: "lets the ink run past its own edges" },
685  { op: "scanlines", label: "Scanlines", cat: "tone", desc: "the dark line between the lit rows of a tube" },
686  { op: "sparkle", label: "Sparkly", cat: "feel", desc: "scatters specular points across the highlights" },
687  { op: "starry", label: "Starry", cat: "feel", desc: "seeds a field of small stars over the dark areas" },
688  ...META_CHEM, ...META_OPT, ...META_TIME,
689];
690
691// Apply an ordered list of { op, ...params } steps to a canvas, in place.
692/* applyOpsWet(canvas, list, mix)
693
694   A real strength control for the whole rack. Most ops here take their own
695   named parameters (posterize takes `levels`, pixelSort takes `low`/`high`,
696   databend takes `shift`/`xor`) and never read `amount` at all, because the
697   Retro Engine gives each one its own dial. A surface that offers a single
698   strength slider and passes `amount` therefore moves nothing for roughly a
699   third of the rack.
700
701   So strength is applied where it always works: the ops run at full force on a
702   copy, and that copy is composited back over the original at `mix`. Wet over
703   dry. Every op gains a meaningful strength, including the binary ones, and the
704   result is predictable across the whole rack. */
705export function applyOpsWet(canvas, list, mix = 1) {
706  const m = Math.max(0, Math.min(1, Number(mix)));
707  if (!list || !list.length || m <= 0) return;
708  if (m >= 0.999) { applyOps(canvas, list); return; }
709  const w = canvas.width, h = canvas.height;
710  if (!w || !h) return;
711  const wet = document.createElement("canvas");
712  wet.width = w; wet.height = h;
713  const wctx = wet.getContext("2d");
714  if (!wctx) return;
715  wctx.drawImage(canvas, 0, 0);
716  applyOps(wet, list);
717  const ctx = canvas.getContext("2d");
718  if (!ctx) return;
719  ctx.save();
720  ctx.setTransform(1, 0, 0, 1, 0, 0);   // callers may be mid-transform
721  ctx.globalAlpha = m;
722  ctx.globalCompositeOperation = "source-over";
723  ctx.drawImage(wet, 0, 0);
724  ctx.restore();
725}
726
727export function applyOps(canvas, list) {
728  for (const step of list || []) { const fn = OPS[step.op]; if (fn) { try { fn(canvas, step); } catch (_) {} } }
729}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.