@pixerate/flair 0.7.0 → 0.8.0

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.
@@ -300,6 +300,30 @@ var SAMPLE_IMAGES = {
300
300
  city: svgDataUrl(CITY),
301
301
  portrait: svgDataUrl(PORTRAIT)
302
302
  };
303
+ function charWidth(c) {
304
+ if (/[iIjl.,:;'!|`]/.test(c)) return 0.3;
305
+ if (/[\sfrt()[\]\-]/.test(c)) return 0.4;
306
+ if (/[MW@%]/.test(c)) return 0.94;
307
+ if (/[mw]/.test(c)) return 0.88;
308
+ if (/[A-Z&#]/.test(c)) return 0.74;
309
+ if (/[0-9]/.test(c)) return 0.58;
310
+ if (/[a-z]/.test(c)) return 0.6;
311
+ return /[ᄀ-ᅟ⺀-꓏가-힣豈-﫿︰-﹏＀-⦆]|\p{Extended_Pictographic}/u.test(c) ? 1 : 0.64;
312
+ }
313
+ function textWidthEm(text) {
314
+ return Array.from(text).reduce((sum, c) => sum + charWidth(c), 0);
315
+ }
316
+ var escapeXml = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
317
+ function textImage(text, options = {}) {
318
+ const width = options.width ?? 1024;
319
+ const height = options.height ?? 256;
320
+ const ems = Math.max(0.5, textWidthEm(text));
321
+ const size = options.size ?? Math.min(height * 0.78, width * 0.9 / ems);
322
+ const length = Math.min(width * 0.96, ems * size);
323
+ const font = options.font ?? "'Arial Black', 'Helvetica Neue', Helvetica, Arial, system-ui, sans-serif";
324
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}"><text x="${width / 2}" y="${height / 2}" dy="0.35em" text-anchor="middle" textLength="${+length.toFixed(1)}" lengthAdjust="spacingAndGlyphs" font-family="${escapeXml(font)}" font-weight="${escapeXml(String(options.weight ?? 900))}" font-size="${+size.toFixed(1)}" fill="#fff">${escapeXml(text)}</text></svg>`;
325
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg).replace(/['()*!]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)}`;
326
+ }
303
327
 
304
328
  // src/catalog/shaders/image.ts
305
329
  var PICTURE = `
@@ -1414,6 +1438,483 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
1414
1438
  ]
1415
1439
  };
1416
1440
 
1441
+ // src/catalog/shaders/overlay.ts
1442
+ var GLOW = `
1443
+ vec4 glow(vec3 c) {
1444
+ c = 1.0 - exp(-max(c, 0.0));
1445
+ float a = max(c.r, max(c.g, c.b));
1446
+ return vec4(c / max(a, 1e-4), a);
1447
+ }`.trim();
1448
+ var OVER = `
1449
+ vec4 over(vec4 acc, vec3 c, float a) {
1450
+ return vec4(acc.rgb * (1.0 - a) + c * a, acc.a * (1.0 - a) + a);
1451
+ }
1452
+ vec4 unpremultiply(vec4 acc) {
1453
+ return vec4(acc.rgb / max(acc.a, 1e-4), acc.a);
1454
+ }`.trim();
1455
+ var rainOnGlass = {
1456
+ kind: "shader",
1457
+ id: "rain-on-glass",
1458
+ name: "Rain on glass",
1459
+ description: "Raindrops sliding down a window in fits and starts, leaving beaded trails behind them.",
1460
+ category: "overlay",
1461
+ tags: ["overlay", "rain", "weather", "moody", "window"],
1462
+ transparent: true,
1463
+ source: glsl(
1464
+ `// Rain on glass: drops sliding down a pane, leaving trails of beads.
1465
+ uniform float uDensity; // @control min=0 max=1 default=0.7 label="Density"
1466
+ uniform float uSpeed; // @control min=0 max=3 default=1 label="Speed"
1467
+ uniform float uSize; // @control min=0.5 max=2 default=1 label="Drop size"
1468
+ uniform vec3 uTint; // @control color default=#CFE3F0 label="Tint"`,
1469
+ HASH,
1470
+ OVER,
1471
+ `// One drop of radius r at offset d: a faint tinted body, a darker rim and a bright highlight.
1472
+ vec4 paintDrop(vec4 acc, vec2 d, float r, float px) {
1473
+ float dist = length(d);
1474
+ if (dist > r + px) return acc;
1475
+ float m = (1.0 - smoothstep(r - 1.5 * px, r, dist)) * smoothstep(0.0, 2.0 * px, r);
1476
+ vec2 n = d / max(r, 1e-5);
1477
+ float l = length(n);
1478
+ float rim = smoothstep(0.45, 1.0, l) * m;
1479
+ float spec = (1.0 - smoothstep(0.0, 0.3, length(n - vec2(-0.3, 0.38)))) * m;
1480
+ float caustic = (1.0 - smoothstep(0.0, 0.5, length(n - vec2(0.15, -0.5)))) * m;
1481
+ acc = over(acc, uTint, m * 0.2);
1482
+ acc = over(acc, vec3(0.0, 0.02, 0.05), rim * 0.5);
1483
+ acc = over(acc, mix(uTint, vec3(1.0), 0.6), caustic * 0.3);
1484
+ return over(acc, vec3(1.0), spec * 0.9);
1485
+ }
1486
+
1487
+ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
1488
+ vec2 p = fragCoord / iResolution.y;
1489
+ float px = 1.0 / iResolution.y;
1490
+ float t = iTime * uSpeed;
1491
+ vec4 acc = vec4(0.0);
1492
+
1493
+ // Condensation: still beads slowly forming and drying up.
1494
+ float beads = 30.0 / uSize;
1495
+ vec2 bq = p * beads;
1496
+ vec2 bid = floor(bq);
1497
+ vec2 bh = hash22(bid);
1498
+ float life = fract(iTime * 0.04 + bh.x * 7.0);
1499
+ float br = (0.12 + 0.18 * bh.y) / beads * sin(3.14159 * life);
1500
+ if (hash12(bid + 5.0) < uDensity) acc = paintDrop(acc, (fract(bq) - 0.5 - (bh - 0.5) * 0.4) / beads, br, px);
1501
+
1502
+ // Running drops: one per cell at most, sliding down a wavy path with a trail above.
1503
+ for (int i = 0; i < 2; i++) {
1504
+ float fi = float(i);
1505
+ vec2 cell = vec2(0.12, 0.3) * uSize * (1.0 - 0.3 * fi);
1506
+ vec2 q = p / cell + vec2(fi * 3.7, 0.0);
1507
+ float column = floor(q.x);
1508
+ q.y += t * 0.15 + hash12(vec2(column, fi)) * 9.0;
1509
+ vec2 id = floor(q);
1510
+ vec2 f = fract(q) - 0.5;
1511
+ vec2 h = hash22(id + fi * 17.0);
1512
+ if (h.x > uDensity) continue;
1513
+ float tt = t * 1.2 + h.y * 6.28318;
1514
+ float y = -0.38 * sin(tt + sin(tt + sin(tt) * 0.5));
1515
+ float base = (h.y - 0.5) * 0.3;
1516
+ float wobble = h.x * 6.28318;
1517
+ float x = base + 0.03 * sin(y * 9.0 + wobble);
1518
+ float aspect = cell.x / cell.y;
1519
+ float r = cell.y * (0.05 + 0.04 * fract(h.x * 13.7));
1520
+ vec2 d = vec2((f.x - x) * aspect, f.y - y) * cell.y;
1521
+ d.y *= d.y > 0.0 ? 0.8 : 1.0;
1522
+ acc = paintDrop(acc, d, r, px);
1523
+ if (f.y > y) {
1524
+ float rows = 10.0;
1525
+ float row = floor(f.y * rows);
1526
+ float tx = base + 0.03 * sin((row + 0.5) / rows * 9.0 + wobble);
1527
+ vec2 bd = vec2((f.x - tx) * aspect, (fract(f.y * rows) - 0.5) / rows) * cell.y;
1528
+ float fade = 1.0 - clamp((f.y - y) / (0.5 - y), 0.0, 1.0);
1529
+ float keep = step(0.35, hash12(id * 3.1 + vec2(row, fi)));
1530
+ acc = paintDrop(acc, bd, r * 0.35 * fade * keep, px);
1531
+ }
1532
+ }
1533
+ fragColor = unpremultiply(acc);
1534
+ }`
1535
+ ),
1536
+ presets: [
1537
+ { name: "Storm", values: { uDensity: 1, uSpeed: 1.8, uSize: 1.3 } },
1538
+ { name: "Drizzle", values: { uDensity: 0.4, uSpeed: 0.6, uSize: 0.7 } }
1539
+ ]
1540
+ };
1541
+ var snowfall = {
1542
+ kind: "shader",
1543
+ id: "snowfall",
1544
+ name: "Snowfall",
1545
+ description: "Layers of snowflakes drifting down on the wind, the far ones smaller and softer.",
1546
+ category: "overlay",
1547
+ tags: ["overlay", "snow", "winter", "festive", "particles"],
1548
+ transparent: true,
1549
+ source: glsl(
1550
+ `// Snowfall: layered flakes drifting with the wind, far layers smaller and blurrier.
1551
+ uniform vec3 uColor; // @control color default=#FFFFFF label="Colour"
1552
+ uniform float uDensity; // @control min=0 max=1 default=0.6 label="Density"
1553
+ uniform float uWind; // @control min=-2 max=2 default=0.3 label="Wind"
1554
+ uniform float uSpeed; // @control min=0 max=3 default=1 label="Fall speed"
1555
+ uniform float uSize; // @control min=0.2 max=2 default=1 label="Size"
1556
+ uniform int uLayers; // @control min=1 max=6 default=4 label="Layers"`,
1557
+ HASH,
1558
+ `void mainImage(out vec4 fragColor, in vec2 fragCoord) {
1559
+ vec2 p = fragCoord / iResolution.y;
1560
+ float px = 1.0 / iResolution.y;
1561
+ float a = 0.0;
1562
+ for (int i = 0; i < 6; i++) {
1563
+ if (i >= uLayers) break;
1564
+ float fi = float(i);
1565
+ // Layer 0 is the farthest: smaller, slower, softer and fainter.
1566
+ float depth = (fi + 1.0) / float(uLayers);
1567
+ float scale = mix(22.0, 6.0, depth);
1568
+ vec2 vel = vec2(uWind * 0.12 * (0.4 + depth), -uSpeed * mix(0.06, 0.25, depth));
1569
+ vec2 q = (p - vel * iTime) * scale + fi * 11.3;
1570
+ vec2 id = floor(q);
1571
+ vec2 h = hash22(id);
1572
+ if (hash12(id + 3.7) > uDensity) continue;
1573
+ float phase = iTime * (0.4 + 0.6 * h.x) + h.y * 6.28318;
1574
+ vec2 centre = (h - 0.5) * 0.3 + vec2(sin(phase) * 0.12, cos(phase * 0.7) * 0.05);
1575
+ float d = length(fract(q) - 0.5 - centre);
1576
+ float r = 0.06 * uSize * (0.6 + 0.8 * hash12(id + 9.1));
1577
+ float soft = max(px * scale * 1.5, r * mix(0.9, 0.3, depth));
1578
+ float flake = 1.0 - smoothstep(r - soft, r, d);
1579
+ a = max(a, flake * mix(0.45, 1.0, depth));
1580
+ }
1581
+ fragColor = vec4(uColor, a);
1582
+ }`
1583
+ ),
1584
+ presets: [
1585
+ { name: "Dust motes", values: { uColor: [1, 0.85, 0.63], uDensity: 0.25, uWind: 0.05, uSpeed: 0.08, uSize: 0.6, uLayers: 3 } },
1586
+ { name: "Blizzard", values: { uDensity: 0.9, uWind: 1.6, uSpeed: 2, uLayers: 6 } }
1587
+ ]
1588
+ };
1589
+ var lensFlare = {
1590
+ kind: "shader",
1591
+ id: "lens-flare",
1592
+ name: "Lens flare",
1593
+ description: "An anamorphic lens flare: a long horizontal streak and a chain of ghosts through the centre, following the pointer.",
1594
+ category: "overlay",
1595
+ tags: ["overlay", "cinematic", "light", "interactive", "sci-fi"],
1596
+ transparent: true,
1597
+ source: glsl(
1598
+ `// Lens flare: an anamorphic streak and ghosts along the line through the centre.
1599
+ uniform vec2 uSource; // @control min=-1 max=1 default=-0.45,0.4 label="Source"
1600
+ uniform vec3 uColor; // @control color default=#FFC98A label="Colour"
1601
+ uniform float uStreak; // @control min=0 max=2 default=1 label="Streak length"
1602
+ uniform float uIntensity; // @control min=0 max=2 default=1 label="Intensity"`,
1603
+ HASH,
1604
+ GLOW,
1605
+ `void mainImage(out vec4 fragColor, in vec2 fragCoord) {
1606
+ vec2 p = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;
1607
+ float aspect = iResolution.x / iResolution.y;
1608
+ vec2 src = iMouse.z > 0.0
1609
+ ? (iMouse.xy - 0.5 * iResolution.xy) / iResolution.y
1610
+ : uSource * vec2(aspect, 1.0) * 0.5;
1611
+ vec2 d = p - src;
1612
+ float r = length(d);
1613
+ vec3 col = uColor * (exp(-r * 20.0) * 1.5 + 0.015 / (r + 0.015) * 0.3);
1614
+ // The anamorphic streak: a bright thin line and a wider, fainter one, blue-shifted.
1615
+ vec3 streakColor = mix(uColor, vec3(0.45, 0.65, 1.0), 0.6);
1616
+ float len = uStreak + 1e-3;
1617
+ col += streakColor * exp(-abs(d.y) * 220.0) * exp(-abs(d.x) / (0.5 * len));
1618
+ col += streakColor * 0.4 * exp(-abs(d.y) * 60.0) * exp(-abs(d.x) / (0.25 * len));
1619
+ // Ghosts sit on the line from the source through the centre, mostly on the far side.
1620
+ for (int i = 0; i < 7; i++) {
1621
+ float fi = float(i);
1622
+ float along = mix(-1.6, 0.6, hash12(vec2(fi, 1.7)));
1623
+ float gr = mix(0.015, 0.11, hash12(vec2(fi, 4.1)));
1624
+ float gd = length(p - src * along);
1625
+ vec3 tint = mix(uColor, vec3(0.4, 0.85, 1.0), hash12(vec2(fi, 8.3)));
1626
+ float disc = (1.0 - smoothstep(gr * 0.6, gr, gd)) * 0.12;
1627
+ float ring = exp(-abs(gd - gr) * 90.0) * 0.18;
1628
+ col += tint * (hash12(vec2(fi, 2.9)) > 0.6 ? ring : disc);
1629
+ }
1630
+ fragColor = glow(col * uIntensity);
1631
+ }`
1632
+ ),
1633
+ presets: [
1634
+ { name: "Sci-fi blue", values: { uColor: [0.5, 0.7, 1], uStreak: 1.8 } },
1635
+ { name: "Golden hour", values: { uColor: [1, 0.7, 0.28], uStreak: 0.6, uIntensity: 1.3 } }
1636
+ ]
1637
+ };
1638
+ var vignetteGrain = {
1639
+ kind: "shader",
1640
+ id: "vignette-grain",
1641
+ name: "Vignette & grain",
1642
+ description: "Darkened corners and fine animated film grain to lay over any picture.",
1643
+ category: "overlay",
1644
+ tags: ["overlay", "cinematic", "film", "grain", "vintage"],
1645
+ transparent: true,
1646
+ source: glsl(
1647
+ `// Vignette & grain: darkened corners and fine grain, nothing else.
1648
+ uniform float uStrength; // @control min=0 max=1 default=0.6 label="Strength"
1649
+ uniform float uRoundness; // @control min=0 max=1 default=0.5 label="Roundness"
1650
+ uniform float uSoftness; // @control min=0.05 max=1 default=0.5 label="Softness"
1651
+ uniform float uGrain; // @control min=0 max=0.3 default=0.08 label="Grain"
1652
+ uniform float uGrainSize; // @control min=1 max=4 default=1.5 label="Grain size (px)"`,
1653
+ HASH,
1654
+ OVER,
1655
+ `void mainImage(out vec4 fragColor, in vec2 fragCoord) {
1656
+ float aspect = iResolution.x / iResolution.y;
1657
+ // 0 at the centre, 1 at the edges; rounder shapes measure across the frame's aspect.
1658
+ vec2 q = abs(fragCoord / iResolution.xy - 0.5) * 2.0;
1659
+ q.x *= mix(1.0, aspect, uRoundness * 0.5);
1660
+ float n = mix(8.0, 2.0, uRoundness);
1661
+ float dist = pow(pow(q.x, n) + pow(q.y, n), 1.0 / n);
1662
+ float dark = smoothstep(1.25 - uSoftness * 1.2, 1.3, dist) * uStrength;
1663
+ vec4 acc = over(vec4(0.0), vec3(0.0), dark);
1664
+ float frame = mod(floor(iTime * 24.0), 64.0);
1665
+ float g = hash12(floor(fragCoord / uGrainSize) + frame * vec2(17.31, 31.73)) - 0.5;
1666
+ acc = over(acc, vec3(step(0.0, g)), abs(g) * uGrain * 2.0);
1667
+ fragColor = unpremultiply(acc);
1668
+ }`
1669
+ ),
1670
+ presets: [
1671
+ { name: "Subtle", values: { uStrength: 0.35, uGrain: 0.04 } },
1672
+ { name: "Gritty", values: { uStrength: 0.8, uSoftness: 0.3, uGrain: 0.18, uGrainSize: 2 } }
1673
+ ]
1674
+ };
1675
+ var scanlineSweep = {
1676
+ kind: "shader",
1677
+ id: "scanline-sweep",
1678
+ name: "Scanline sweep",
1679
+ description: "A bright scanning line with a glow and fading trail passing across, like a scanner or hologram.",
1680
+ category: "overlay",
1681
+ tags: ["overlay", "scan", "hologram", "sci-fi", "tech"],
1682
+ transparent: true,
1683
+ source: glsl(
1684
+ `// Scanline sweep: a glowing line with a faint trail crossing the frame at intervals.
1685
+ uniform float uAngle; // @control min=0 max=360 default=270 label="Direction (\xB0)"
1686
+ uniform float uSpeed; // @control min=0.1 max=3 default=1 label="Speed"
1687
+ uniform float uWidth; // @control min=0.001 max=0.05 default=0.006 label="Width"
1688
+ uniform vec3 uColor; // @control color default=#4FF0FF label="Colour"
1689
+ uniform float uInterval; // @control min=0 max=5 default=1 label="Pause (s)"
1690
+ uniform float uTrail; // @control min=0 max=1 default=0.5 label="Trail"`,
1691
+ GLOW,
1692
+ `void mainImage(out vec4 fragColor, in vec2 fragCoord) {
1693
+ vec2 p = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;
1694
+ float aspect = iResolution.x / iResolution.y;
1695
+ float px = 1.0 / iResolution.y;
1696
+ float a = radians(uAngle);
1697
+ vec2 dir = vec2(cos(a), sin(a));
1698
+ float s = dot(p, dir);
1699
+ // Start and end far enough past the frame that the trail has gone before the next pass.
1700
+ float trailLength = 0.02 + 0.2 * uTrail;
1701
+ float reach = 0.5 * (abs(dir.x) * aspect + abs(dir.y)) + uWidth * 6.0 + trailLength * 4.0;
1702
+ float sweep = 1.5 / uSpeed;
1703
+ float k = mod(iTime, sweep + uInterval) / sweep;
1704
+ float d = s - mix(-reach, reach, k);
1705
+ float core = 1.0 - smoothstep(uWidth * 0.5, uWidth * 0.5 + px, abs(d));
1706
+ float halo = exp(-abs(d) / (uWidth * 3.0)) * 0.6;
1707
+ float trail = d < 0.0 ? exp(d / trailLength) * 0.35 * uTrail : 0.0;
1708
+ trail *= 0.75 + 0.25 * sin(fragCoord.y * 2.0);
1709
+ fragColor = glow(uColor * (core * 1.5 + halo + trail));
1710
+ }`
1711
+ ),
1712
+ presets: [
1713
+ { name: "Hologram", values: { uAngle: 90, uColor: [0.3, 1, 0.6], uSpeed: 0.6, uInterval: 0, uTrail: 0.9 } },
1714
+ { name: "Barcode scanner", values: { uAngle: 0, uColor: [1, 0.15, 0.15], uSpeed: 2, uInterval: 0.2, uWidth: 3e-3 } }
1715
+ ]
1716
+ };
1717
+ var spotlight = {
1718
+ kind: "shader",
1719
+ id: "spotlight",
1720
+ name: "Spotlight",
1721
+ description: "Darkness everywhere but a soft circle of light that follows the pointer or wanders on its own.",
1722
+ category: "overlay",
1723
+ tags: ["overlay", "interactive", "reveal", "focus", "dramatic"],
1724
+ transparent: true,
1725
+ source: glsl(
1726
+ `// Spotlight: dark everywhere but a soft circle, at the pointer or drifting.
1727
+ uniform float uRadius; // @control min=0.05 max=0.8 default=0.25 label="Radius"
1728
+ uniform float uSoftness; // @control min=0 max=1 default=0.4 label="Softness"
1729
+ uniform float uDarkness; // @control min=0 max=1 default=0.85 label="Darkness"
1730
+ uniform float uSpeed; // @control min=0 max=2 default=0.3 label="Drift speed"
1731
+ uniform vec3 uShade; // @control color default=#05050A label="Shade"`,
1732
+ `void mainImage(out vec4 fragColor, in vec2 fragCoord) {
1733
+ vec2 p = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;
1734
+ float aspect = iResolution.x / iResolution.y;
1735
+ float px = 1.0 / iResolution.y;
1736
+ float t = iTime * uSpeed;
1737
+ vec2 centre = iMouse.z > 0.0
1738
+ ? (iMouse.xy - 0.5 * iResolution.xy) / iResolution.y
1739
+ : vec2(sin(t * 2.0) * 0.32 * aspect, sin(t * 3.0 + 0.5) * 0.28);
1740
+ float r = length(p - centre);
1741
+ float dark = smoothstep(uRadius * (1.0 - uSoftness) - px, uRadius + px, r);
1742
+ fragColor = vec4(uShade, dark * uDarkness);
1743
+ }`
1744
+ ),
1745
+ presets: [
1746
+ { name: "Stage", values: { uRadius: 0.35, uSoftness: 0.15, uDarkness: 0.95, uSpeed: 0.15 } },
1747
+ { name: "Torch", values: { uRadius: 0.18, uSoftness: 0.7, uDarkness: 1, uShade: [0.02, 0.01, 0] } }
1748
+ ]
1749
+ };
1750
+ var embers = {
1751
+ kind: "shader",
1752
+ id: "embers",
1753
+ name: "Embers",
1754
+ description: "Glowing sparks rising from below, swaying and flickering out as they cool.",
1755
+ category: "overlay",
1756
+ tags: ["overlay", "fire", "sparks", "particles", "warm"],
1757
+ transparent: true,
1758
+ source: glsl(
1759
+ `// Embers: sparks rising, swaying and flickering out as they cool.
1760
+ uniform int uCount; // @control min=5 max=80 default=40 label="Sparks"
1761
+ uniform vec3 uHeat; // @control color default=#FF7A1A label="Heat"
1762
+ uniform float uSpeed; // @control min=0 max=3 default=1 label="Rise speed"
1763
+ uniform float uSize; // @control min=0.3 max=3 default=1 label="Size"`,
1764
+ HASH,
1765
+ GLOW,
1766
+ `void mainImage(out vec4 fragColor, in vec2 fragCoord) {
1767
+ vec2 p = fragCoord / iResolution.y;
1768
+ float aspect = iResolution.x / iResolution.y;
1769
+ vec3 col = vec3(0.0);
1770
+ for (int i = 0; i < 80; i++) {
1771
+ if (i >= uCount) break;
1772
+ float fi = float(i);
1773
+ vec2 h = hash22(vec2(fi, 1.3));
1774
+ float clock = iTime * uSpeed * 0.18 * (0.6 + 0.8 * h.y) + h.x;
1775
+ float life = fract(clock);
1776
+ // Each life starts somewhere new along the bottom.
1777
+ vec2 seed = vec2(fi, floor(clock));
1778
+ float x = hash12(seed) * aspect + 0.04 * sin(life * 8.0 + fi) + life * 0.15 * (hash12(seed + 2.0) - 0.5);
1779
+ float y = life * (0.5 + 0.7 * hash12(seed + 4.0)) - 0.02;
1780
+ float r = 0.005 * uSize * (0.5 + hash12(seed + 6.0));
1781
+ vec2 d = p - vec2(x, y);
1782
+ d.y *= 0.6;
1783
+ float dist = length(d);
1784
+ if (dist > r * 14.0) continue;
1785
+ float fade = smoothstep(0.0, 0.08, life) * (1.0 - smoothstep(0.45, 1.0, life));
1786
+ float flicker = 0.6 + 0.4 * sin(iTime * 17.0 + fi * 3.7) * sin(iTime * 7.3 + fi);
1787
+ vec3 heat = mix(vec3(1.0, 0.95, 0.75), uHeat, smoothstep(0.0, 0.5, life));
1788
+ heat = mix(heat, uHeat * vec3(0.8, 0.25, 0.1), smoothstep(0.5, 1.0, life));
1789
+ float core = 1.0 - smoothstep(r * 0.4, r, dist);
1790
+ float halo = exp(-dist / (r * 2.5)) * 0.35;
1791
+ col += heat * (core * 1.5 + halo) * fade * flicker;
1792
+ }
1793
+ fragColor = glow(col);
1794
+ }`
1795
+ ),
1796
+ presets: [
1797
+ { name: "Bonfire", values: { uCount: 80, uSpeed: 1.6, uSize: 1.3 } },
1798
+ { name: "Magic sparks", values: { uHeat: [0.55, 0.4, 1], uCount: 30, uSpeed: 0.6 } }
1799
+ ]
1800
+ };
1801
+ var shineSweep = {
1802
+ kind: "shader",
1803
+ id: "shine-sweep",
1804
+ name: "Shine sweep",
1805
+ description: "A soft diagonal gloss band gliding across now and then, for cards and buttons.",
1806
+ category: "overlay",
1807
+ tags: ["overlay", "ui", "gloss", "premium", "button"],
1808
+ transparent: true,
1809
+ source: glsl(
1810
+ `// Shine sweep: a soft gloss band crossing the frame at intervals.
1811
+ uniform float uAngle; // @control min=-60 max=60 default=25 label="Tilt (\xB0)"
1812
+ uniform float uWidth; // @control min=0.02 max=0.5 default=0.15 label="Width"
1813
+ uniform float uIntensity; // @control min=0 max=1 default=0.6 label="Intensity"
1814
+ uniform float uInterval; // @control min=1 max=8 default=3 label="Every (s)"
1815
+ uniform vec3 uColor; // @control color default=#FFFFFF label="Colour"`,
1816
+ `void mainImage(out vec4 fragColor, in vec2 fragCoord) {
1817
+ vec2 p = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;
1818
+ float aspect = iResolution.x / iResolution.y;
1819
+ float slope = tan(radians(uAngle));
1820
+ float s = p.x + p.y * slope;
1821
+ float reach = 0.5 * aspect + 0.5 * abs(slope) + uWidth;
1822
+ // Each sweep takes 0.9 s, then waits for the next.
1823
+ float k = clamp(mod(iTime, uInterval) / 0.9, 0.0, 1.0);
1824
+ k = k * k * (3.0 - 2.0 * k);
1825
+ float d = (s - mix(-reach, reach, k)) / uWidth;
1826
+ float band = exp(-d * d * 4.0) * 0.7 + exp(-d * d * 60.0) * 0.5;
1827
+ fragColor = vec4(uColor, clamp(band * uIntensity, 0.0, 1.0));
1828
+ }`
1829
+ ),
1830
+ presets: [
1831
+ { name: "Gold foil", values: { uColor: [1, 0.9, 0.6], uWidth: 0.25, uIntensity: 0.5 } },
1832
+ { name: "Quick glint", values: { uWidth: 0.06, uIntensity: 0.9, uInterval: 1.5, uAngle: 35 } }
1833
+ ]
1834
+ };
1835
+ var frostCreep = {
1836
+ kind: "shader",
1837
+ id: "frost-creep",
1838
+ name: "Frost creep",
1839
+ description: "Ice crystals creeping in from the edges of the frame and melting back.",
1840
+ category: "overlay",
1841
+ tags: ["overlay", "ice", "winter", "frozen", "reveal"],
1842
+ transparent: true,
1843
+ loop: 8,
1844
+ source: glsl(
1845
+ `// Frost creep: crystalline frost growing in from the edges.
1846
+ uniform bool uAnimate; // @control default=true label="Animate"
1847
+ uniform float uCoverage; // @control min=0 max=1 default=0.6 label="Coverage (when still)"
1848
+ uniform float uScale; // @control min=1 max=10 default=4 label="Crystal size"
1849
+ uniform vec3 uColor; // @control color default=#DDF2FF label="Frost"`,
1850
+ HASH,
1851
+ NOISE,
1852
+ FBM,
1853
+ `// Voronoi: x = distance to the nearest point, y = to the second, zw = the nearest cell.
1854
+ vec4 voronoi(vec2 q) {
1855
+ vec2 id = floor(q);
1856
+ vec2 f = fract(q);
1857
+ float f1 = 8.0;
1858
+ float f2 = 8.0;
1859
+ vec2 nearest = id;
1860
+ for (int j = -1; j <= 1; j++) {
1861
+ for (int i = -1; i <= 1; i++) {
1862
+ vec2 o = vec2(float(i), float(j));
1863
+ float d = length(o + hash22(id + o) - f);
1864
+ if (d < f1) {
1865
+ f2 = f1;
1866
+ f1 = d;
1867
+ nearest = id + o;
1868
+ } else if (d < f2) {
1869
+ f2 = d;
1870
+ }
1871
+ }
1872
+ }
1873
+ return vec4(f1, f2, nearest);
1874
+ }
1875
+
1876
+ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
1877
+ vec2 p = fragCoord / iResolution.y;
1878
+ float coverage = uAnimate ? 0.5 - 0.5 * cos(iTime * 6.28318 / 8.0) : uCoverage;
1879
+ // Distance in from the nearest edge, roughened by noise and stepped per crystal.
1880
+ float edge = min(min(fragCoord.x, iResolution.x - fragCoord.x), min(fragCoord.y, iResolution.y - fragCoord.y)) / iResolution.y;
1881
+ vec4 v = voronoi(p * uScale * 3.0);
1882
+ float n = fbm(p * uScale * 0.8, 5);
1883
+ float g = edge + (n - 0.5) * 0.3 + (hash12(v.zw) - 0.5) * 0.04;
1884
+ float front = mix(-0.25, 0.75, coverage);
1885
+ float frost = 1.0 - smoothstep(front - 0.06, front, g);
1886
+ if (frost <= 0.0) {
1887
+ fragColor = vec4(0.0);
1888
+ return;
1889
+ }
1890
+ // Facets catch the light differently; their edges and fine needles glint.
1891
+ float facet = hash12(v.zw + 3.1);
1892
+ float ridges = 1.0 - smoothstep(0.0, 0.06, v.y - v.x);
1893
+ float needles = smoothstep(0.55, 0.8, fbm(p * uScale * vec2(6.0, 1.5) + v.zw, 3));
1894
+ float fresh = 1.0 - smoothstep(0.0, 0.35, front - g);
1895
+ float rim = smoothstep(front - 0.06, front - 0.015, g);
1896
+ vec3 col = mix(uColor * vec3(0.8, 0.9, 1.0), vec3(1.0), facet * 0.4 + ridges * 0.6 + rim * 0.5 + needles * 0.4);
1897
+ float alpha = frost * clamp(0.35 + 0.25 * (1.0 - fresh) + 0.2 * facet + 0.35 * ridges + 0.25 * needles + 0.3 * rim, 0.0, 1.0);
1898
+ fragColor = vec4(min(col, 1.0), alpha);
1899
+ }`
1900
+ ),
1901
+ presets: [
1902
+ { name: "Hoarfrost", values: { uAnimate: false, uCoverage: 0.85, uScale: 6 } },
1903
+ { name: "Icy blue", values: { uColor: [0.62, 0.83, 1], uScale: 2.5 } }
1904
+ ]
1905
+ };
1906
+ var overlayEffects = [
1907
+ rainOnGlass,
1908
+ snowfall,
1909
+ lensFlare,
1910
+ vignetteGrain,
1911
+ scanlineSweep,
1912
+ spotlight,
1913
+ embers,
1914
+ shineSweep,
1915
+ frostCreep
1916
+ ];
1917
+
1417
1918
  // src/catalog/shaders/retro.ts
1418
1919
  var synthwaveGrid = {
1419
1920
  kind: "shader",
@@ -1558,122 +2059,1395 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
1558
2059
  ]
1559
2060
  };
1560
2061
 
1561
- // src/catalog/types.ts
1562
- var EFFECT_CATEGORIES = [
1563
- "gradient",
1564
- "organic",
1565
- "pattern",
1566
- "light",
1567
- "space",
1568
- "retro",
1569
- "image",
1570
- "transition",
1571
- "loader",
1572
- "data",
1573
- "icon"
1574
- ];
1575
- var VECTOR_CONTROL_TYPES = ["float", "int", "bool", "color", "text"];
1576
- var PLACEHOLDER = /\{\{\s*([A-Za-z_]\w*)\s*(?::\s*([A-Za-z0-9]*)([^}]*))?\}\}/g;
1577
- function motionStrings(motion) {
1578
- const out = [];
1579
- const walk = (v) => {
1580
- if (typeof v === "string") out.push(v);
1581
- else if (Array.isArray(v)) v.forEach(walk);
1582
- else if (v && typeof v === "object") Object.values(v).forEach(walk);
1583
- };
1584
- walk(motion);
1585
- return out;
1586
- }
1587
- function vectorControls(motion) {
1588
- const controls = [];
1589
- const seen = /* @__PURE__ */ new Set();
1590
- for (const text of motionStrings(motion)) {
1591
- for (const [, name, type, attributes] of text.matchAll(PLACEHOLDER)) {
1592
- if (type === void 0 || seen.has(name) || !VECTOR_CONTROL_TYPES.includes(type)) continue;
1593
- seen.add(name);
1594
- const colour = type === "color";
1595
- controls.push(controlFromDeclaration(name, colour ? "vec3" : type, `${attributes}${colour ? " color" : ""}`, controls.length + 1));
1596
- }
1597
- }
1598
- return controls;
1599
- }
1600
- function vectorEffectControls(effect) {
1601
- const declared = vectorControls(effect.motion);
1602
- const names = new Set(declared.map((c) => c.name));
1603
- const listed = (effect.controls ?? []).filter((c) => !names.has(c.name));
1604
- return [...declared, ...listed.map((c, i) => vectorControlDef(c, declared.length + i + 1))];
1605
- }
1606
- function vectorControlIssues(motion) {
1607
- const issues = [];
1608
- const declarations = /* @__PURE__ */ new Map();
1609
- for (const text of motionStrings(motion)) {
1610
- for (const [whole, name, type] of text.matchAll(PLACEHOLDER)) {
1611
- if (type === void 0) continue;
1612
- if (!VECTOR_CONTROL_TYPES.includes(type))
1613
- issues.push(`${whole}: a control's type is one of ${VECTOR_CONTROL_TYPES.join(", ")}`);
1614
- else if (declarations.has(name) && declarations.get(name) !== whole)
1615
- issues.push(`"${name}" is declared twice; declare it once and write {{${name}}} everywhere else`);
1616
- else declarations.set(name, whole);
1617
- }
1618
- }
1619
- return issues;
2062
+ // src/catalog/shaders/text.ts
2063
+ var shaderText = {
2064
+ kind: "shader",
2065
+ id: "shader-text",
2066
+ name: "Shader text",
2067
+ description: "A title filled with a living texture (plasma, fire, caustics or liquid chrome), with a soft glow and outline.",
2068
+ category: "text",
2069
+ tags: ["text", "title", "plasma", "fire", "chrome", "glow"],
2070
+ source: glsl(
2071
+ `// Shader text: the letters of a text image filled with an animated pattern.
2072
+ uniform sampler2D uText; // @control filter=linear wrap=clamp default=${textImage("FLAIR")} label="Text"
2073
+ uniform int uFill; // @control min=0 max=3 default=0 label="Fill (0 plasma, 1 fire, 2 caustics, 3 chrome)"
2074
+ uniform vec3 uBackground; // @control color default=#0A0A12 label="Background"
2075
+ uniform vec3 uTint; // @control color default=#FFFFFF label="Tint"
2076
+ uniform float uSize; // @control min=0.3 max=1 default=0.9 label="Text size"
2077
+ uniform float uSpeed; // @control min=0 max=3 default=1 label="Speed"
2078
+ uniform float uGlow; // @control min=0 max=2 default=0.6 label="Glow"
2079
+ uniform float uOutline; // @control min=0 max=0.02 default=0.004 label="Outline width"
2080
+ uniform vec3 uOutlineColor; // @control color default=#FFFFFF label="Outline"`,
2081
+ HASH,
2082
+ NOISE,
2083
+ FBM,
2084
+ PALETTE,
2085
+ `// The text image's alpha at frame point p (height 1, centred), the image contained in uSize of the frame.
2086
+ float letters(vec2 p) {
2087
+ vec2 size = vec2(textureSize(uText, 0));
2088
+ float aspect = size.x / max(size.y, 1.0);
2089
+ float frame = iResolution.x / iResolution.y;
2090
+ float h = min(uSize, frame * uSize / aspect);
2091
+ vec2 uv = p / vec2(h * aspect, h) + 0.5;
2092
+ if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) return 0.0;
2093
+ return texture(uText, uv).a;
1620
2094
  }
1621
- function written(control, value) {
1622
- if (control.color && Array.isArray(value)) return rgbToHex(value);
1623
- if (Array.isArray(value)) return value.join(" ");
1624
- return value;
2095
+
2096
+ vec3 plasmaFill(vec2 p, float t) {
2097
+ float v = sin(p.x * 6.0 + t) + sin(p.y * 7.0 - t * 1.3) + sin((p.x + p.y) * 5.0 + t * 0.7) + sin(length(p * 8.0) - t * 2.0);
2098
+ return palette(v * 0.125 + 0.5 + t * 0.05, vec3(0.5), vec3(0.5), vec3(1.0), vec3(0.0, 0.33, 0.67));
1625
2099
  }
1626
- var escapeMarkup = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
1627
- function vectorControlDef(control, line = 0) {
1628
- const min = control.min ?? 0;
1629
- return {
1630
- name: control.name,
1631
- type: control.type,
1632
- label: control.label ?? control.name,
1633
- color: control.color ?? false,
1634
- min,
1635
- max: control.max ?? Math.max(min + 1, 1),
1636
- step: control.step ?? (control.type === "int" ? 1 : 0.01),
1637
- default: control.default,
1638
- line
1639
- };
2100
+
2101
+ vec3 fireFill(vec2 p, float t) {
2102
+ float n = fbm(vec2(p.x * 5.0, p.y * 3.0 - t * 1.8), 5);
2103
+ float heat = clamp(1.1 - (p.y + 0.3) * 1.6 + (n - 0.5) * 1.2, 0.0, 1.0);
2104
+ return vec3(1.0, 0.35, 0.05) * heat * 1.4 + vec3(1.0, 0.85, 0.3) * pow(heat, 3.0) + vec3(0.25, 0.0, 0.0);
1640
2105
  }
1641
- function renderVectorEffect(effect, values = {}) {
1642
- const filled = {};
1643
- const placeholders = /* @__PURE__ */ new Map();
1644
- for (const control of vectorEffectControls(effect)) {
1645
- const given = values[control.name];
1646
- const check = given === void 0 ? void 0 : checkControlValue(control, given);
1647
- filled[control.name] = check?.ok ? check.value : control.default;
1648
- placeholders.set(control.name, written(control, filled[control.name]));
2106
+
2107
+ vec3 causticsFill(vec2 p, float t) {
2108
+ vec2 q = p * 6.0;
2109
+ vec2 i = q;
2110
+ float c = 1.0;
2111
+ for (int n = 0; n < 5; n++) {
2112
+ float tn = t * 0.5 * (1.0 - 3.5 / float(n + 1));
2113
+ i = q + vec2(cos(tn - i.x) + sin(tn + i.y), sin(tn - i.y) + cos(tn + i.x));
2114
+ c += 1.0 / length(vec2(q.x / (sin(i.x + tn) / 0.005), q.y / (cos(i.y + tn) / 0.005)));
1649
2115
  }
1650
- for (const [name, value] of Object.entries(effect.derived?.(filled) ?? {})) placeholders.set(name, value);
1651
- const fill = (text, markup) => {
1652
- const whole = /^\{\{\s*([A-Za-z_]\w*)\s*(?::[^}]*)?\}\}$/.exec(text);
1653
- if (!markup && whole && placeholders.has(whole[1])) return placeholders.get(whole[1]);
1654
- return text.replace(PLACEHOLDER, (match, name) => {
1655
- if (!placeholders.has(name)) return match;
1656
- const value = String(placeholders.get(name));
1657
- return markup ? escapeMarkup(value) : value;
1658
- });
1659
- };
1660
- const walk = (v) => {
1661
- if (typeof v === "string") return fill(v, false);
1662
- if (Array.isArray(v)) return v.map(walk);
1663
- if (v && typeof v === "object") return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)]));
1664
- return v;
1665
- };
1666
- const { svg, ...rest } = effect.motion;
1667
- return { svg: fill(svg.replace(/<!--[\s\S]*?-->/g, ""), true), ...walk(rest) };
1668
- }
1669
- function colorControl(name, hex, label) {
1670
- return { name, type: "vec3", color: true, default: hexToRgb(hex) ?? [1, 1, 1], label: label ?? name };
2116
+ c = 1.17 - pow(c / 5.0, 1.4);
2117
+ float light = pow(abs(c), 8.0);
2118
+ return clamp(vec3(0.0, 0.3, 0.5) + light * vec3(0.7, 1.0, 1.0), 0.0, 1.0);
1671
2119
  }
1672
- function numberControl(name, value, min, max, label, step) {
1673
- return { name, type: "float", default: value, min, max, label: label ?? name, step };
2120
+
2121
+ // Liquid chrome: a horizon reflection bent by the letters' bevel and a slow warp.
2122
+ vec3 chromeFill(vec2 p, float t, vec2 normal) {
2123
+ float warp = fbm(p * 3.0 + vec2(t * 0.3, -t * 0.2), 4) - 0.5;
2124
+ float y = p.y * 2.5 + normal.y * 1.5 + warp * 1.2 + 0.15 * sin(p.x * 4.0 + t);
2125
+ vec3 sky = mix(vec3(0.85, 0.9, 1.0), vec3(0.3, 0.4, 0.6), clamp(y, 0.0, 1.0));
2126
+ vec3 ground = mix(vec3(0.25, 0.2, 0.2), vec3(0.05), clamp(-y, 0.0, 1.0));
2127
+ vec3 col = y > 0.0 ? sky : ground;
2128
+ col += smoothstep(0.06, 0.0, abs(y)) * 0.9;
2129
+ return col + pow(max(0.0, normal.x * 0.7 + normal.y * 0.7), 6.0) * 0.8;
1674
2130
  }
1675
2131
 
1676
- // src/catalog/vector.ts
2132
+ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
2133
+ vec2 p = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;
2134
+ float t = iTime * uSpeed;
2135
+ float mask = letters(p);
2136
+ // A soft bevel from the mask's slope, for chrome's reflections.
2137
+ float e = 0.004;
2138
+ vec2 normal = vec2(letters(p + vec2(e, 0.0)) - letters(p - vec2(e, 0.0)), letters(p + vec2(0.0, e)) - letters(p - vec2(0.0, e)));
2139
+ // Glow and outline: the mask's average and maximum on rings round the point.
2140
+ float glow = 0.0;
2141
+ float ring = 0.0;
2142
+ for (int i = 0; i < 12; i++) {
2143
+ float a = float(i) * 0.5236;
2144
+ vec2 dir = vec2(cos(a), sin(a));
2145
+ glow += letters(p + dir * 0.02) + letters(p + dir * 0.05) * 0.6;
2146
+ ring = max(ring, letters(p + dir * uOutline));
2147
+ }
2148
+ glow /= 19.2;
2149
+ vec3 fill = uFill == 0 ? plasmaFill(p, t) : uFill == 1 ? fireFill(p, t) : uFill == 2 ? causticsFill(p, t) : chromeFill(p, t, normal * 2.0);
2150
+ fill *= uTint;
2151
+ vec3 col = uBackground + fill * glow * uGlow;
2152
+ col = mix(col, uOutlineColor, clamp(ring - mask, 0.0, 1.0) * step(0.0001, uOutline));
2153
+ col = mix(col, fill, mask);
2154
+ fragColor = vec4(col, 1.0);
2155
+ }`
2156
+ ),
2157
+ presets: [
2158
+ { name: "Inferno", values: { uFill: 1, uBackground: [0.05, 0.01, 0], uGlow: 1.2, uOutline: 0 } },
2159
+ { name: "Pool", values: { uFill: 2, uBackground: [0, 0.08, 0.14], uOutlineColor: [0.7, 0.95, 1] } },
2160
+ { name: "Chrome", values: { uFill: 3, uBackground: [0.06, 0.06, 0.08], uGlow: 0.3, uOutline: 2e-3 } }
2161
+ ]
2162
+ };
2163
+ var textShaders = [shaderText];
2164
+
2165
+ // src/catalog/shaders/treatments.ts
2166
+ var imageKenBurns = {
2167
+ kind: "shader",
2168
+ id: "image-ken-burns",
2169
+ name: "Ken Burns",
2170
+ description: "A slow documentary-style pan and zoom across an image, easing in and out and back again.",
2171
+ category: "image",
2172
+ tags: ["image", "camera", "documentary", "slideshow"],
2173
+ loop: 12,
2174
+ source: glsl(
2175
+ `// Ken Burns: a slow, eased pan and zoom across a cover-fitted image, there and back.
2176
+ uniform sampler2D uImage; // @control filter=linear wrap=clamp default=${SAMPLE_IMAGES.landscape} label="Image"
2177
+ uniform float uZoomStart; // @control min=1 max=3 default=1.05 label="Start zoom"
2178
+ uniform float uZoomEnd; // @control min=1 max=3 default=1.4 label="End zoom"
2179
+ uniform float uAngle; // @control min=0 max=360 default=20 label="Pan direction (\xB0)"
2180
+ uniform float uDuration; // @control min=2 max=30 default=6 label="Duration (s)"`,
2181
+ PICTURE,
2182
+ `void mainImage(out vec4 fragColor, in vec2 fragCoord) {
2183
+ vec2 uv = fragCoord / iResolution.xy;
2184
+ // Cover-fit: the share of the image visible across and up (the test card fits the frame).
2185
+ vec2 size = vec2(textureSize(uImage, 0));
2186
+ float frameAspect = iResolution.x / iResolution.y;
2187
+ float imageAspect = size.x > 1.0 ? size.x / size.y : frameAspect;
2188
+ vec2 cover = frameAspect > imageAspect ? vec2(1.0, imageAspect / frameAspect) : vec2(frameAspect / imageAspect, 1.0);
2189
+ // 0 \u2192 1 over one duration, back over the next, eased at both ends.
2190
+ float k = 0.5 - 0.5 * cos(3.14159265 * iTime / max(uDuration, 0.1));
2191
+ float zoom = max(mix(uZoomStart, uZoomEnd, k), 1.0);
2192
+ vec2 window = cover / zoom;
2193
+ // Pan as far as the window allows in the chosen direction, from one side to the other.
2194
+ float a = radians(uAngle);
2195
+ vec2 pan = vec2(cos(a), sin(a)) * (1.0 - window) * 0.5 * (2.0 * k - 1.0);
2196
+ vec3 col = picture(uImage, 0.5 + (uv - 0.5) * window + pan);
2197
+ fragColor = vec4(col, 1.0);
2198
+ }`
2199
+ ),
2200
+ presets: [
2201
+ { name: "Push in", values: { uZoomStart: 1, uZoomEnd: 1.5, uAngle: 0 } },
2202
+ { name: "Pull out", values: { uZoomStart: 1.6, uZoomEnd: 1.05, uAngle: 200 } },
2203
+ { name: "Slow drift", values: { uZoomStart: 1.2, uZoomEnd: 1.25, uAngle: 0, uDuration: 12 } }
2204
+ ]
2205
+ };
2206
+ var imageAscii = {
2207
+ kind: "shader",
2208
+ id: "image-ascii",
2209
+ name: "ASCII art",
2210
+ description: "An image drawn as text characters picked by brightness, in one ink or the picture's own colours.",
2211
+ category: "image",
2212
+ tags: ["image", "ascii", "text", "terminal", "retro"],
2213
+ source: glsl(
2214
+ `// ASCII art: each cell of the image becomes a character, denser for brighter cells.
2215
+ uniform sampler2D uImage; // @control filter=linear wrap=clamp default=${SAMPLE_IMAGES.landscape} label="Image"
2216
+ uniform float uCell; // @control min=4 max=32 default=9 label="Cell size (px)"
2217
+ uniform int uColourMode; // @control min=0 max=1 default=0 label="Colour (0 ink, 1 original)"
2218
+ uniform vec3 uInk; // @control color default=#A3E635 label="Ink"
2219
+ uniform vec3 uBackground; // @control color default=#0A0F0A label="Background"`,
2220
+ PICTURE,
2221
+ `// 5 \xD7 7 glyphs, lightest to densest: bit (x + 5y) of .x holds rows 0\u20133 (top first), of .y rows 4\u20136.
2222
+ const ivec2 GLYPHS[10] = ivec2[10](
2223
+ ivec2(0, 0), // space
2224
+ ivec2(0, 6336), // .
2225
+ ivec2(1015808, 0), // -
2226
+ ivec2(6336, 198), // :
2227
+ ivec2(1020032, 132), // +
2228
+ ivec2(31744, 31), // =
2229
+ ivec2(1030816, 686), // *
2230
+ ivec2(139875, 25378), // %
2231
+ ivec2(359754, 10591), // #
2232
+ ivec2(718382, 30781) // @
2233
+ );
2234
+
2235
+ // Whether glyph g has ink at column x (0\u20134), row y (0\u20136, top first).
2236
+ float glyphBit(ivec2 g, int x, int y) {
2237
+ if (x < 0 || x > 4 || y < 0 || y > 6) return 0.0;
2238
+ int bits = y < 4 ? (g.x >> (x + 5 * y)) : (g.y >> (x + 5 * (y - 4)));
2239
+ return float(bits & 1);
2240
+ }
2241
+
2242
+ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
2243
+ // Cells are 7 \xD7 9 units: the 5 \xD7 7 glyph and a unit of margin around it.
2244
+ vec2 cell = vec2(uCell, uCell * 9.0 / 7.0);
2245
+ vec2 id = floor(fragCoord / cell);
2246
+ vec2 local = fract(fragCoord / cell);
2247
+ vec3 src = picture(uImage, (id + 0.5) * cell / iResolution.xy);
2248
+ float l = pow(clamp(dot(src, vec3(0.2126, 0.7152, 0.0722)), 0.0, 1.0), 0.8);
2249
+ int level = int(min(l * 10.0, 9.0));
2250
+ int gx = int(floor(local.x * 7.0)) - 1;
2251
+ int gy = int(floor((1.0 - local.y) * 9.0)) - 1;
2252
+ float ink = glyphBit(GLYPHS[level], gx, gy);
2253
+ vec3 tint = uColourMode == 1 ? src * 1.25 : uInk;
2254
+ fragColor = vec4(mix(uBackground, tint, ink), 1.0);
2255
+ }`
2256
+ ),
2257
+ presets: [
2258
+ { name: "Terminal", values: { uColourMode: 0, uInk: [0.64, 0.9, 0.21], uBackground: [0.04, 0.06, 0.04] } },
2259
+ { name: "Colour", values: { uColourMode: 1, uBackground: [0.02, 0.02, 0.03] } },
2260
+ { name: "Typewriter", values: { uColourMode: 0, uInk: [0.1, 0.1, 0.12], uBackground: [0.96, 0.94, 0.88], uCell: 7 } }
2261
+ ]
2262
+ };
2263
+ var imageShatter = {
2264
+ kind: "shader",
2265
+ id: "image-shatter",
2266
+ name: "Shatter",
2267
+ description: "An image breaking into glass-like shards that fly apart, spin and reassemble in a loop.",
2268
+ category: "image",
2269
+ tags: ["image", "shatter", "transition", "voronoi", "explode"],
2270
+ loop: 5,
2271
+ source: glsl(
2272
+ `// Shatter: Voronoi shards of the image fly apart, spin and come back together.
2273
+ uniform sampler2D uImage; // @control filter=linear wrap=clamp default=${SAMPLE_IMAGES.landscape} label="Image"
2274
+ uniform bool uAuto; // @control default=true label="Animate"
2275
+ uniform float uProgress; // @control min=0 max=1 default=0.5 label="Progress (when still)"
2276
+ uniform float uCount; // @control min=3 max=30 default=8 label="Shards (across height)"
2277
+ uniform float uSpread; // @control min=0 max=2 default=0.6 label="Spread"
2278
+ uniform float uSpin; // @control min=0 max=6.28 default=1.5 label="Spin"
2279
+ uniform vec3 uBackground; // @control color default=#0B0B12 label="Background"`,
2280
+ HASH,
2281
+ ROTATE,
2282
+ PICTURE,
2283
+ `vec2 shardSite(vec2 cell) {
2284
+ return cell + 0.15 + 0.7 * hash22(cell);
2285
+ }
2286
+
2287
+ // The shard (cell) under grid point q, and how far q is from its edge (xy: cell, z: edge gap).
2288
+ vec3 shardAt(vec2 q) {
2289
+ vec2 base = floor(q);
2290
+ float d1 = 1e9;
2291
+ float d2 = 1e9;
2292
+ vec2 id = base;
2293
+ for (int j = -1; j <= 1; j++) {
2294
+ for (int i = -1; i <= 1; i++) {
2295
+ vec2 c = base + vec2(float(i), float(j));
2296
+ float d = distance(q, shardSite(c));
2297
+ if (d < d1) {
2298
+ d2 = d1;
2299
+ d1 = d;
2300
+ id = c;
2301
+ } else if (d < d2) {
2302
+ d2 = d;
2303
+ }
2304
+ }
2305
+ }
2306
+ return vec3(id, d2 - d1);
2307
+ }
2308
+
2309
+ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
2310
+ float k = uAuto ? smoothstep(0.2, 0.8, 0.5 - 0.5 * cos(iTime * 6.28318 / 5.0)) : uProgress;
2311
+ vec2 p = (fragCoord - 0.5 * iResolution.xy) / iResolution.y * uCount;
2312
+ // Shard centres move out from the middle; shards keep their size, so gaps open between them.
2313
+ float grow = 1.0 + uSpread * k;
2314
+ vec2 base = floor(p / grow);
2315
+ vec3 col = uBackground;
2316
+ float top = -1.0;
2317
+ for (int j = -2; j <= 2; j++) {
2318
+ for (int i = -2; i <= 2; i++) {
2319
+ vec2 c = base + vec2(float(i), float(j));
2320
+ vec2 site = shardSite(c);
2321
+ vec2 h = hash22(c + 41.7);
2322
+ vec2 centre = site * grow + (h - 0.5) * 1.2 * k;
2323
+ float a = (h.x - 0.5) * 2.0 * uSpin * k;
2324
+ // Back to where this pixel would sit in the unbroken image, if it is on this shard.
2325
+ vec2 q = rot(-a) * (p - centre) + site;
2326
+ vec3 s = shardAt(q);
2327
+ if (s.x == c.x && s.y == c.y && h.y > top) {
2328
+ top = h.y;
2329
+ vec2 uv = q / uCount * iResolution.y / iResolution.xy + 0.5;
2330
+ vec3 shard = picture(uImage, uv);
2331
+ shard *= 1.0 - 0.35 * k * abs(sin(a));
2332
+ shard += k * 0.35 * smoothstep(0.06, 0.0, s.z);
2333
+ col = shard;
2334
+ }
2335
+ }
2336
+ }
2337
+ fragColor = vec4(col, 1.0);
2338
+ }`
2339
+ ),
2340
+ presets: [
2341
+ { name: "Burst", values: { uCount: 6, uSpread: 1.4, uSpin: 3 } },
2342
+ { name: "Crackle", values: { uCount: 18, uSpread: 0.15, uSpin: 0.4 } }
2343
+ ]
2344
+ };
2345
+ var imageMelt = {
2346
+ kind: "shader",
2347
+ id: "image-melt",
2348
+ name: "Melt",
2349
+ description: "An image melting and dripping downwards in slow, noisy runs.",
2350
+ category: "image",
2351
+ tags: ["image", "melt", "drip", "psychedelic", "distortion"],
2352
+ source: glsl(
2353
+ `// Melt: noise-driven runs drag the image downwards.
2354
+ uniform sampler2D uImage; // @control filter=linear wrap=clamp default=${SAMPLE_IMAGES.landscape} label="Image"
2355
+ uniform float uAmount; // @control min=0 max=0.6 default=0.18 label="Amount"
2356
+ uniform float uSpeed; // @control min=0 max=2 default=0.35 label="Flow speed"
2357
+ uniform float uScale; // @control min=1 max=20 default=6 label="Scale"`,
2358
+ HASH,
2359
+ NOISE,
2360
+ FBM,
2361
+ PICTURE,
2362
+ `void mainImage(out vec4 fragColor, in vec2 fragCoord) {
2363
+ vec2 uv = fragCoord / iResolution.xy;
2364
+ float t = iTime * uSpeed;
2365
+ // Columns run at different lengths; thin drips ride on top; runs grow towards the bottom.
2366
+ float runs = fbm(vec2(uv.x * uScale, t * 0.5), 4);
2367
+ float drips = pow(vnoise(vec2(uv.x * uScale * 5.0, t * 0.3 + 7.0)), 3.0);
2368
+ float drop = uAmount * (0.7 * runs + 0.6 * drips) * (0.35 + 0.65 * (1.0 - uv.y));
2369
+ float wobble = (vnoise(vec2(uv.y * uScale * 2.0 - t, uv.x * uScale)) - 0.5) * uAmount * 0.12;
2370
+ vec2 st = clamp(uv + vec2(wobble, drop), 0.0, 1.0);
2371
+ vec3 col = picture(uImage, st);
2372
+ // A little sheen along the leading edges of the runs.
2373
+ col += 0.12 * smoothstep(0.55, 0.9, drips) * uAmount * 4.0;
2374
+ fragColor = vec4(col, 1.0);
2375
+ }`
2376
+ ),
2377
+ presets: [
2378
+ { name: "Slow ooze", values: { uAmount: 0.12, uSpeed: 0.15, uScale: 4 } },
2379
+ { name: "Meltdown", values: { uAmount: 0.45, uSpeed: 0.8, uScale: 9 } }
2380
+ ]
2381
+ };
2382
+ var imageRisograph = {
2383
+ kind: "shader",
2384
+ id: "image-risograph",
2385
+ name: "Risograph",
2386
+ description: "An image printed in two posterized, slightly misregistered inks on grainy paper.",
2387
+ category: "image",
2388
+ tags: ["image", "print", "riso", "editorial", "poster"],
2389
+ source: glsl(
2390
+ `// Risograph: two posterized ink layers, out of register, on grainy paper.
2391
+ uniform sampler2D uImage; // @control filter=linear wrap=clamp default=${SAMPLE_IMAGES.landscape} label="Image"
2392
+ uniform int uLevels; // @control min=2 max=8 default=4 label="Levels"
2393
+ uniform vec3 uInkA; // @control color default=#FF48B0 label="Ink A"
2394
+ uniform vec3 uInkB; // @control color default=#0078BF label="Ink B"
2395
+ uniform float uOffset; // @control min=0 max=20 default=4 label="Misregistration (px)"
2396
+ uniform float uGrain; // @control min=0 max=1 default=0.4 label="Grain"
2397
+ uniform vec3 uPaper; // @control color default=#F5EFE0 label="Paper"`,
2398
+ HASH,
2399
+ NOISE,
2400
+ PICTURE,
2401
+ `// Ink density stepped to a few levels, with grain dithering the steps.
2402
+ float posterize(float d, float levels, float grain) {
2403
+ return clamp(floor(d * (levels - 1.0) + 0.5 + grain) / (levels - 1.0), 0.0, 1.0);
2404
+ }
2405
+
2406
+ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
2407
+ vec2 uv = fragCoord / iResolution.xy;
2408
+ vec2 shift = vec2(uOffset, uOffset * 0.6) / iResolution.xy;
2409
+ float levels = float(uLevels);
2410
+ float fibre = hash12(fragCoord);
2411
+ float mottle = vnoise(fragCoord / 6.0);
2412
+ // Two separations: ink A where the picture lacks green, ink B where it lacks red.
2413
+ vec3 a = picture(uImage, uv + shift);
2414
+ vec3 b = picture(uImage, uv - shift);
2415
+ float dA = posterize(1.0 - a.g, levels, (hash12(fragCoord + 17.0) - 0.5) * uGrain * 0.6);
2416
+ float dB = posterize(1.0 - b.r, levels, (hash12(fragCoord + 53.0) - 0.5) * uGrain * 0.6);
2417
+ // Ink never lays down evenly: mottled, with paper showing through.
2418
+ float cover = 0.9 * (1.0 - uGrain * 0.35 * mottle);
2419
+ vec3 col = uPaper * (1.0 - uGrain * 0.08 * fibre);
2420
+ col *= mix(vec3(1.0), uInkA, dA * cover);
2421
+ col *= mix(vec3(1.0), uInkB, dB * cover);
2422
+ fragColor = vec4(col, 1.0);
2423
+ }`
2424
+ ),
2425
+ presets: [
2426
+ { name: "Pink & blue", values: { uInkA: [1, 0.28, 0.69], uInkB: [0, 0.47, 0.75] } },
2427
+ { name: "Sunflower & teal", values: { uInkA: [1, 0.71, 0], uInkB: [0, 0.51, 0.54], uLevels: 3 } },
2428
+ { name: "Red & black", values: { uInkA: [1, 0.25, 0.25], uInkB: [0.15, 0.15, 0.17], uOffset: 2, uGrain: 0.6 } }
2429
+ ]
2430
+ };
2431
+ var imageThermal = {
2432
+ kind: "shader",
2433
+ id: "image-thermal",
2434
+ name: "Thermal",
2435
+ description: "An image seen through a heat camera or night-vision goggles.",
2436
+ category: "image",
2437
+ tags: ["image", "thermal", "night-vision", "surveillance", "colour"],
2438
+ source: glsl(
2439
+ `// Thermal: brightness mapped to a heat palette, or to green night-vision phosphor.
2440
+ uniform sampler2D uImage; // @control filter=linear wrap=clamp default=${SAMPLE_IMAGES.landscape} label="Image"
2441
+ uniform int uMode; // @control min=0 max=1 default=0 label="Palette (0 thermal, 1 night vision)"
2442
+ uniform float uNoise; // @control min=0 max=0.5 default=0.04 label="Noise"
2443
+ uniform float uContrast; // @control min=0.5 max=3 default=1.3 label="Contrast"`,
2444
+ HASH,
2445
+ PICTURE,
2446
+ `vec3 heat(float t) {
2447
+ vec3 c = mix(vec3(0.0, 0.0, 0.08), vec3(0.15, 0.0, 0.55), smoothstep(0.0, 0.2, t));
2448
+ c = mix(c, vec3(0.75, 0.0, 0.55), smoothstep(0.2, 0.4, t));
2449
+ c = mix(c, vec3(1.0, 0.25, 0.0), smoothstep(0.4, 0.6, t));
2450
+ c = mix(c, vec3(1.0, 0.8, 0.0), smoothstep(0.6, 0.8, t));
2451
+ return mix(c, vec3(1.0, 1.0, 0.9), smoothstep(0.8, 1.0, t));
2452
+ }
2453
+
2454
+ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
2455
+ vec2 uv = fragCoord / iResolution.xy;
2456
+ // Heat spreads, so take a soft average around the pixel.
2457
+ vec2 px = 2.0 / iResolution.xy;
2458
+ vec3 src = 0.4 * picture(uImage, uv) + 0.15 * (picture(uImage, uv + vec2(px.x, 0.0)) + picture(uImage, uv - vec2(px.x, 0.0)) +
2459
+ picture(uImage, uv + vec2(0.0, px.y)) + picture(uImage, uv - vec2(0.0, px.y)));
2460
+ float l = dot(src, vec3(0.2126, 0.7152, 0.0722));
2461
+ l = clamp((l - 0.5) * uContrast + 0.5, 0.0, 1.0);
2462
+ float frame = floor(iTime * 24.0);
2463
+ float grain = hash12(fragCoord + frame * 37.1) - 0.5;
2464
+ vec3 col;
2465
+ if (uMode == 0) {
2466
+ col = heat(clamp(l + grain * uNoise, 0.0, 1.0));
2467
+ } else {
2468
+ float g = clamp(l * 1.2 + grain * uNoise * 2.0, 0.0, 1.0);
2469
+ col = vec3(0.12, 1.0, 0.25) * g + vec3(0.02, 0.06, 0.02);
2470
+ col *= 0.94 + 0.06 * sin(fragCoord.y * 2.0);
2471
+ col *= 0.97 + 0.03 * hash12(vec2(frame, 3.0));
2472
+ vec2 d = (uv - 0.5) * vec2(iResolution.x / iResolution.y, 1.0);
2473
+ col *= smoothstep(0.75, 0.3, length(d));
2474
+ }
2475
+ fragColor = vec4(col, 1.0);
2476
+ }`
2477
+ ),
2478
+ presets: [
2479
+ { name: "Thermal", values: { uMode: 0, uNoise: 0.04, uContrast: 1.3 } },
2480
+ { name: "Night vision", values: { uMode: 1, uNoise: 0.2, uContrast: 1.8 } }
2481
+ ]
2482
+ };
2483
+ var imageEdgeGlow = {
2484
+ kind: "shader",
2485
+ id: "image-edge-glow",
2486
+ name: "Edge glow",
2487
+ description: "An image's edges traced as glowing neon lines over a dimmed picture, with a travelling pulse.",
2488
+ category: "image",
2489
+ tags: ["image", "neon", "edges", "outline", "cyber"],
2490
+ loop: 2,
2491
+ source: glsl(
2492
+ `// Edge glow: Sobel edges as neon lines and a soft halo over the dimmed image.
2493
+ uniform sampler2D uImage; // @control filter=linear wrap=clamp default=${SAMPLE_IMAGES.landscape} label="Image"
2494
+ uniform float uThreshold; // @control min=0 max=1 default=0.15 label="Threshold"
2495
+ uniform vec3 uGlow; // @control color default=#22D3EE label="Glow colour"
2496
+ uniform float uWidth; // @control min=0.5 max=4 default=1.5 label="Glow width (px)"
2497
+ uniform float uDim; // @control min=0 max=1 default=0.75 label="Dim image"
2498
+ uniform float uPulse; // @control min=0 max=1 default=0.4 label="Pulse"`,
2499
+ PICTURE,
2500
+ `float luma(vec2 uv) {
2501
+ return dot(picture(uImage, uv), vec3(0.2126, 0.7152, 0.0722));
2502
+ }
2503
+
2504
+ // Sobel edge strength, sampling d apart (in uv).
2505
+ float sobel(vec2 uv, vec2 d) {
2506
+ float tl = luma(uv + vec2(-d.x, d.y));
2507
+ float t = luma(uv + vec2(0.0, d.y));
2508
+ float tr = luma(uv + d);
2509
+ float l = luma(uv - vec2(d.x, 0.0));
2510
+ float r = luma(uv + vec2(d.x, 0.0));
2511
+ float bl = luma(uv - d);
2512
+ float b = luma(uv - vec2(0.0, d.y));
2513
+ float br = luma(uv + vec2(d.x, -d.y));
2514
+ float gx = tr + 2.0 * r + br - tl - 2.0 * l - bl;
2515
+ float gy = tl + 2.0 * t + tr - bl - 2.0 * b - br;
2516
+ return length(vec2(gx, gy));
2517
+ }
2518
+
2519
+ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
2520
+ vec2 uv = fragCoord / iResolution.xy;
2521
+ vec2 d = uWidth / iResolution.xy;
2522
+ float line = smoothstep(uThreshold, uThreshold + 0.15, sobel(uv, d));
2523
+ float halo = smoothstep(uThreshold, uThreshold + 0.5, sobel(uv, d * 3.0));
2524
+ vec2 c = (uv - 0.5) * vec2(iResolution.x / iResolution.y, 1.0);
2525
+ float pulse = 1.0 - uPulse * (0.5 + 0.5 * sin(iTime * 3.14159265 - length(c) * 12.0));
2526
+ vec3 col = picture(uImage, uv) * (1.0 - uDim);
2527
+ col += uGlow * (line + 0.45 * halo) * pulse;
2528
+ col += vec3(1.0) * line * line * 0.35 * pulse;
2529
+ fragColor = vec4(col, 1.0);
2530
+ }`
2531
+ ),
2532
+ presets: [
2533
+ { name: "Cyan", values: { uGlow: [0.13, 0.83, 0.93] } },
2534
+ { name: "Magenta blackout", values: { uGlow: [0.93, 0.28, 0.6], uDim: 1, uThreshold: 0.1 } }
2535
+ ]
2536
+ };
2537
+ var imageTiltShift = {
2538
+ kind: "shader",
2539
+ id: "image-tilt-shift",
2540
+ name: "Tilt-shift",
2541
+ description: "An image blurred outside a sharp horizontal band, with punchy colour, so it looks like a miniature.",
2542
+ category: "image",
2543
+ tags: ["image", "blur", "miniature", "photography", "depth"],
2544
+ source: glsl(
2545
+ `// Tilt-shift: blur away from a focus band, plus saturation, for the miniature look.
2546
+ uniform sampler2D uImage; // @control filter=linear wrap=clamp default=${SAMPLE_IMAGES.city} label="Image"
2547
+ uniform float uFocus; // @control min=0 max=1 default=0.45 label="Focus position"
2548
+ uniform float uBand; // @control min=0 max=0.6 default=0.12 label="Focus band width"
2549
+ uniform float uBlur; // @control min=0 max=30 default=12 label="Blur (px)"
2550
+ uniform float uSaturation; // @control min=0 max=2 default=1.4 label="Saturation"`,
2551
+ PICTURE,
2552
+ `void mainImage(out vec4 fragColor, in vec2 fragCoord) {
2553
+ vec2 uv = fragCoord / iResolution.xy;
2554
+ float away = smoothstep(uBand * 0.5, uBand * 0.5 + 0.3, abs(uv.y - uFocus));
2555
+ float radius = away * uBlur;
2556
+ // A 24-tap golden-angle disc.
2557
+ vec3 sum = vec3(0.0);
2558
+ for (int i = 0; i < 24; i++) {
2559
+ float fi = float(i);
2560
+ float r = sqrt((fi + 0.5) / 24.0) * radius;
2561
+ float a = fi * 2.39996;
2562
+ sum += picture(uImage, uv + r * vec2(cos(a), sin(a)) / iResolution.xy);
2563
+ }
2564
+ vec3 col = sum / 24.0;
2565
+ float l = dot(col, vec3(0.2126, 0.7152, 0.0722));
2566
+ col = mix(vec3(l), col, uSaturation);
2567
+ col = clamp((col - 0.5) * 1.08 + 0.5, 0.0, 1.0);
2568
+ fragColor = vec4(col, 1.0);
2569
+ }`
2570
+ ),
2571
+ presets: [
2572
+ { name: "Model village", values: { uFocus: 0.4, uBand: 0.1, uBlur: 16, uSaturation: 1.6 } },
2573
+ { name: "Subtle", values: { uBand: 0.25, uBlur: 6, uSaturation: 1.15 } }
2574
+ ]
2575
+ };
2576
+ var imageSlitScan = {
2577
+ kind: "shader",
2578
+ id: "image-slit-scan",
2579
+ name: "Slit-scan",
2580
+ description: "An image smeared into time-stretched streaks, like a slit-scan photograph.",
2581
+ category: "image",
2582
+ tags: ["image", "slit-scan", "smear", "experimental", "distortion"],
2583
+ source: glsl(
2584
+ `// Slit-scan: rows (or columns) squeeze towards a moving slit and smear into streaks.
2585
+ uniform sampler2D uImage; // @control filter=linear wrap=mirror default=${SAMPLE_IMAGES.landscape} label="Image"
2586
+ uniform int uDirection; // @control min=0 max=1 default=0 label="Direction (0 horizontal, 1 vertical)"
2587
+ uniform float uStretch; // @control min=0 max=1 default=0.45 label="Stretch"
2588
+ uniform float uSpeed; // @control min=0 max=3 default=0.6 label="Speed"`,
2589
+ HASH,
2590
+ NOISE,
2591
+ PICTURE,
2592
+ `void mainImage(out vec4 fragColor, in vec2 fragCoord) {
2593
+ vec2 uv = fragCoord / iResolution.xy;
2594
+ // along: the axis the streaks run along; across: the axis the delay varies over.
2595
+ vec2 a = uDirection == 0 ? uv : uv.yx;
2596
+ float t = iTime * uSpeed;
2597
+ float delay = vnoise(vec2(a.y * 7.0, t)) * 0.7 + vnoise(vec2(a.y * 31.0, t * 1.7)) * 0.3;
2598
+ float slit = 0.5 + 0.35 * sin(t * 0.7 + delay * 2.0);
2599
+ // Squeeze towards the slit (stretching what is there) and drift with the delay.
2600
+ float along = slit + (a.x - slit) * (1.0 - uStretch * delay);
2601
+ along += uStretch * 0.15 * (delay - 0.5);
2602
+ // Smear: average a few taps along the streak.
2603
+ vec3 col = vec3(0.0);
2604
+ for (int i = 0; i < 8; i++) {
2605
+ float s = along - float(i) * uStretch * 0.006;
2606
+ vec2 st = uDirection == 0 ? vec2(s, a.y) : vec2(a.y, s);
2607
+ col += picture(uImage, st);
2608
+ }
2609
+ fragColor = vec4(col / 8.0, 1.0);
2610
+ }`
2611
+ ),
2612
+ presets: [
2613
+ { name: "Drift", values: { uDirection: 0, uStretch: 0.3, uSpeed: 0.3 } },
2614
+ { name: "Waterfall", values: { uDirection: 1, uStretch: 0.7, uSpeed: 1.2 } }
2615
+ ]
2616
+ };
2617
+ var imageMosaic = {
2618
+ kind: "shader",
2619
+ id: "image-mosaic",
2620
+ name: "Mosaic flip",
2621
+ description: "Hexagonal or triangular tiles flipping over from the left to reveal an image, then back.",
2622
+ category: "image",
2623
+ tags: ["image", "reveal", "transition", "tiles", "hexagon"],
2624
+ loop: 6,
2625
+ source: glsl(
2626
+ `// Mosaic flip: tiles turn over in a wave from the left, background on one face, image on the other.
2627
+ uniform sampler2D uImage; // @control filter=linear wrap=clamp default=${SAMPLE_IMAGES.landscape} label="Image"
2628
+ uniform int uShape; // @control min=0 max=1 default=0 label="Tile shape (0 hexagon, 1 triangle)"
2629
+ uniform float uSize; // @control min=10 max=160 default=48 label="Tile size (px)"
2630
+ uniform bool uAuto; // @control default=true label="Animate"
2631
+ uniform float uProgress; // @control min=0 max=1 default=1 label="Progress (when still)"
2632
+ uniform vec3 uBackground; // @control color default=#0F172A label="Background"`,
2633
+ HASH,
2634
+ PICTURE,
2635
+ `// Hexagon tiles 1 unit across the flats: h is the offset from the tile's centre.
2636
+ void hexTile(vec2 p, out vec2 h, out vec2 centre) {
2637
+ vec2 s = vec2(1.0, 1.7320508);
2638
+ vec2 a = (floor(p / s) + 0.5) * s;
2639
+ vec2 b = (floor((p - 0.5 * s) / s) + 1.0) * s;
2640
+ centre = dot(p - a, p - a) < dot(p - b, p - b) ? a : b;
2641
+ h = p - centre;
2642
+ }
2643
+
2644
+ // 0 at a hexagon's centre, 1 on its edge.
2645
+ float hexEdge(vec2 h) {
2646
+ h = abs(h);
2647
+ return max(h.x, dot(h, vec2(0.5, 0.8660254))) * 2.0;
2648
+ }
2649
+
2650
+ // Triangle tiles with sides of 1 unit; up is 1 for upward-pointing tiles, -1 for downward.
2651
+ void triTile(vec2 p, out vec2 h, out vec2 centre, out float up) {
2652
+ vec2 q = vec2(p.x - p.y * 0.5773503, p.y * 1.1547005);
2653
+ vec2 cell = floor(q);
2654
+ vec2 f = q - cell;
2655
+ up = f.x + f.y < 1.0 ? 1.0 : -1.0;
2656
+ vec2 cq = cell + (up > 0.0 ? vec2(1.0 / 3.0) : vec2(2.0 / 3.0));
2657
+ centre = vec2(cq.x + cq.y * 0.5, cq.y * 0.8660254);
2658
+ h = p - centre;
2659
+ }
2660
+
2661
+ // 0 at a triangle's centre, 1 on its edge.
2662
+ float triEdge(vec2 h, float up) {
2663
+ h *= up;
2664
+ float e = max(-h.y, max(dot(h, vec2(0.8660254, 0.5)), dot(h, vec2(-0.8660254, 0.5))));
2665
+ return e / 0.2886751;
2666
+ }
2667
+
2668
+ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
2669
+ float k = uAuto ? smoothstep(0.05, 0.95, 0.5 - 0.5 * cos(iTime * 6.28318 / 6.0)) : uProgress;
2670
+ vec2 p = fragCoord / uSize;
2671
+ vec2 h;
2672
+ vec2 centre;
2673
+ float up = 1.0;
2674
+ if (uShape == 0) {
2675
+ hexTile(p, h, centre);
2676
+ } else {
2677
+ triTile(p, h, centre, up);
2678
+ }
2679
+ // Each tile turns over in its own moment, sweeping left to right with a little jitter.
2680
+ float start = centre.x * uSize / iResolution.x * 0.75 + hash12(centre * 7.31) * 0.15;
2681
+ float flip = smoothstep(0.0, 1.0, clamp((k * 1.35 - start) / 0.35, 0.0, 1.0));
2682
+ float squash = abs(cos(flip * 3.14159265));
2683
+ vec2 hf = vec2(h.x / max(squash, 0.001), h.y);
2684
+ float e = uShape == 0 ? hexEdge(hf) : triEdge(hf, up);
2685
+ float gap = 0.1 * (1.0 - flip);
2686
+ vec3 col = uBackground * 0.6;
2687
+ if (e < 1.0 - gap + 0.001) {
2688
+ vec3 face = flip < 0.5
2689
+ ? uBackground * (0.9 + 0.2 * hash12(centre * 3.17)) + 0.04
2690
+ : picture(uImage, (centre + hf) * uSize / iResolution.xy);
2691
+ col = face * (0.5 + 0.5 * squash);
2692
+ }
2693
+ fragColor = vec4(col, 1.0);
2694
+ }`
2695
+ ),
2696
+ presets: [
2697
+ { name: "Hexagons", values: { uShape: 0, uSize: 48 } },
2698
+ { name: "Triangles", values: { uShape: 1, uSize: 64 } }
2699
+ ]
2700
+ };
2701
+ var imageChromaticLens = {
2702
+ kind: "shader",
2703
+ id: "image-chromatic-lens",
2704
+ name: "Chromatic lens",
2705
+ description: "An image through a cheap wide lens: barrel distortion and colour fringes growing towards the edges.",
2706
+ category: "image",
2707
+ tags: ["image", "lens", "aberration", "distortion", "vintage"],
2708
+ source: glsl(
2709
+ `// Chromatic lens: barrel distortion with red, green and blue bent by different amounts.
2710
+ uniform sampler2D uImage; // @control filter=linear wrap=clamp default=${SAMPLE_IMAGES.landscape} label="Image"
2711
+ uniform float uDistortion; // @control min=-0.5 max=1 default=0.25 label="Distortion"
2712
+ uniform float uAberration; // @control min=0 max=0.1 default=0.025 label="Aberration"
2713
+ uniform float uVignette; // @control min=0 max=1 default=0.4 label="Vignette"`,
2714
+ PICTURE,
2715
+ `// Where a lens with barrel strength k shows the point c (centred, aspect-corrected) from.
2716
+ vec2 lens(vec2 c, float k, vec2 aspect) {
2717
+ float r2 = dot(c, c);
2718
+ // Zoom in to keep the corners filled when barrelling.
2719
+ float fill = 1.0 + max(k, 0.0) * 0.3;
2720
+ return 0.5 + c * (1.0 + k * r2) / fill / aspect;
2721
+ }
2722
+
2723
+ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
2724
+ vec2 uv = fragCoord / iResolution.xy;
2725
+ vec2 aspect = vec2(iResolution.x / iResolution.y, 1.0);
2726
+ vec2 c = (uv - 0.5) * aspect;
2727
+ vec3 col = vec3(
2728
+ picture(uImage, lens(c, uDistortion + uAberration, aspect)).r,
2729
+ picture(uImage, lens(c, uDistortion, aspect)).g,
2730
+ picture(uImage, lens(c, uDistortion - uAberration, aspect)).b
2731
+ );
2732
+ col *= 1.0 - uVignette * smoothstep(0.25, 0.95, length(c));
2733
+ fragColor = vec4(col, 1.0);
2734
+ }`
2735
+ ),
2736
+ presets: [
2737
+ { name: "Fisheye", values: { uDistortion: 0.7, uAberration: 0.04, uVignette: 0.6 } },
2738
+ { name: "Fringe only", values: { uDistortion: 0, uAberration: 0.05, uVignette: 0.2 } }
2739
+ ]
2740
+ };
2741
+ var imageOilPaint = {
2742
+ kind: "shader",
2743
+ id: "image-oil-paint",
2744
+ name: "Oil paint",
2745
+ description: "An image repainted in flat, edge-keeping brush dabs, like an oil painting.",
2746
+ category: "image",
2747
+ tags: ["image", "painterly", "artistic", "kuwahara", "filter"],
2748
+ source: glsl(
2749
+ `// Oil paint: a four-sector Kuwahara filter, each pixel takes the calmest neighbourhood's colour.
2750
+ uniform sampler2D uImage; // @control filter=linear wrap=clamp default=${SAMPLE_IMAGES.portrait} label="Image"
2751
+ uniform int uRadius; // @control min=1 max=8 default=4 label="Brush radius (px)"
2752
+ uniform float uSharpness; // @control min=0.5 max=8 default=4 label="Sharpness"
2753
+ uniform float uSaturation; // @control min=0 max=2 default=1.15 label="Saturation"`,
2754
+ PICTURE,
2755
+ `void mainImage(out vec4 fragColor, in vec2 fragCoord) {
2756
+ vec2 uv = fragCoord / iResolution.xy;
2757
+ vec2 px = 1.0 / iResolution.xy;
2758
+ // Sums of colour and colour\xB2 over four overlapping squares: down-left, down-right, up-left, up-right.
2759
+ vec3 m0 = vec3(0.0), m1 = vec3(0.0), m2 = vec3(0.0), m3 = vec3(0.0);
2760
+ vec3 s0 = vec3(0.0), s1 = vec3(0.0), s2 = vec3(0.0), s3 = vec3(0.0);
2761
+ for (int j = 0; j <= 8; j++) {
2762
+ if (j > uRadius) break;
2763
+ for (int i = 0; i <= 8; i++) {
2764
+ if (i > uRadius) break;
2765
+ vec2 o = vec2(float(i), float(j)) * px;
2766
+ vec3 c = picture(uImage, uv + vec2(-o.x, -o.y));
2767
+ m0 += c;
2768
+ s0 += c * c;
2769
+ c = picture(uImage, uv + vec2(o.x, -o.y));
2770
+ m1 += c;
2771
+ s1 += c * c;
2772
+ c = picture(uImage, uv + vec2(-o.x, o.y));
2773
+ m2 += c;
2774
+ s2 += c * c;
2775
+ c = picture(uImage, uv + o);
2776
+ m3 += c;
2777
+ s3 += c * c;
2778
+ }
2779
+ }
2780
+ float n = float((uRadius + 1) * (uRadius + 1));
2781
+ m0 /= n; m1 /= n; m2 /= n; m3 /= n;
2782
+ // Weight each square by how calm (low-variance) it is; sharpness sets how strongly the calmest wins.
2783
+ vec4 v = vec4(
2784
+ dot(abs(s0 / n - m0 * m0), vec3(1.0)),
2785
+ dot(abs(s1 / n - m1 * m1), vec3(1.0)),
2786
+ dot(abs(s2 / n - m2 * m2), vec3(1.0)),
2787
+ dot(abs(s3 / n - m3 * m3), vec3(1.0))
2788
+ );
2789
+ vec4 w = pow(v * 100.0 + 1e-3, vec4(-uSharpness));
2790
+ vec3 col = (m0 * w.x + m1 * w.y + m2 * w.z + m3 * w.w) / (w.x + w.y + w.z + w.w);
2791
+ float l = dot(col, vec3(0.2126, 0.7152, 0.0722));
2792
+ col = mix(vec3(l), col, uSaturation);
2793
+ fragColor = vec4(col, 1.0);
2794
+ }`
2795
+ ),
2796
+ presets: [
2797
+ { name: "Broad strokes", values: { uRadius: 7, uSharpness: 6, uSaturation: 1.3 } },
2798
+ { name: "Watercolour", values: { uRadius: 3, uSharpness: 1.5, uSaturation: 0.9 } }
2799
+ ]
2800
+ };
2801
+ var imageTreatments = [
2802
+ imageKenBurns,
2803
+ imageAscii,
2804
+ imageShatter,
2805
+ imageMelt,
2806
+ imageRisograph,
2807
+ imageThermal,
2808
+ imageEdgeGlow,
2809
+ imageTiltShift,
2810
+ imageSlitScan,
2811
+ imageMosaic,
2812
+ imageChromaticLens,
2813
+ imageOilPaint
2814
+ ];
2815
+
2816
+ // src/catalog/text.ts
2817
+ var r1 = (n2) => Math.round(n2 * 10) / 10;
2818
+ function fit(text, width, max, ems = textWidthEm(text)) {
2819
+ const e = Math.max(0.6, ems);
2820
+ const size = Math.min(max, width / e);
2821
+ return { size: r1(size), length: r1(e * size), left: r1(200 - e * size / 2) };
2822
+ }
2823
+ var lettersOf = (text) => Array.from(String(text));
2824
+ var mixHex = (a, b, t) => {
2825
+ const x = Array.isArray(a) ? a : [1, 1, 1];
2826
+ const y = Array.isArray(b) ? b : [1, 1, 1];
2827
+ return rgbToHex([0, 1, 2].map((i) => x[i] + (y[i] - x[i]) * t));
2828
+ };
2829
+ var holds = (id, attr, keys) => keys.map(([delay, value]) => ({ id, attrs: { [attr]: [value, value] }, delay, ms: 0 }));
2830
+ var n = (name) => `{{${name}}}`;
2831
+ var MONO = `ui-monospace, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace`;
2832
+ var MONO_EM = 0.6;
2833
+ var HEAVY = `'Arial Black', 'Helvetica Neue', Impact, system-ui, sans-serif`;
2834
+ var SANS = `system-ui, 'Helvetica Neue', Arial, sans-serif`;
2835
+ var BLINKS = 7;
2836
+ var typewriter = {
2837
+ kind: "vector",
2838
+ id: "typewriter",
2839
+ name: "Typewriter",
2840
+ description: "Letters typed out one at a time behind a block caret, which then blinks.",
2841
+ category: "text",
2842
+ tags: ["text", "title", "typing", "terminal", "caption"],
2843
+ hold: 300,
2844
+ presets: [
2845
+ { name: "Terminal", values: { paper: [0.03, 0.05, 0.04], ink: [0.3, 1, 0.45] } },
2846
+ { name: "Paper", values: { paper: [0.98, 0.95, 0.88], ink: [0.15, 0.13, 0.12], pace: 120 } }
2847
+ ],
2848
+ // Monospace, so the caret lands after the last letter however long the text is.
2849
+ derived: (v) => {
2850
+ const count = Math.max(1, lettersOf(v.text).length);
2851
+ const size = r1(Math.min(44, 340 / ((count + 1) * MONO_EM)));
2852
+ const left = r1(200 - (count + 1) * MONO_EM * size / 2);
2853
+ const typing = Math.round(count * Number(v.pace));
2854
+ return {
2855
+ size,
2856
+ left,
2857
+ base: r1(100 + size * 0.35),
2858
+ end: r1(left + count * MONO_EM * size),
2859
+ typing,
2860
+ ...Object.fromEntries(Array.from({ length: BLINKS }, (_, k) => [`blink${k}`, typing + k * 500]))
2861
+ };
2862
+ },
2863
+ motion: {
2864
+ svg: `<svg viewBox="0 0 400 200"><!-- {{text: text default='Hello, world.' label='Text'}} {{paper: color default=#111827 label='Background'}} {{ink: color default=#F9FAFB label='Text colour'}} {{pace: float min=30 max=300 default=90 step=5 label='Ms per letter'}} --><rect width="400" height="200" fill="{{paper}}"/><text id="typed" x="{{left}}" y="{{base}}" font-family="${MONO}" font-size="{{size}}" fill="{{ink}}" xml:space="preserve">{{text}}</text><text id="caret" x="{{end}}" y="{{base}}" font-family="${MONO}" font-size="{{size}}" fill="{{ink}}" opacity="0">\u258C</text></svg>`,
2865
+ animate: [
2866
+ { id: "typed", write: "{{text}}", caret: "\u258C", ms: n("typing"), ease: "linear" },
2867
+ // Hidden while the writer shows its own caret, then blinking where it stopped.
2868
+ ...holds("caret", "opacity", [[0, 0], ...Array.from({ length: BLINKS }, (_, k) => [n(`blink${k}`), k % 2 ? 0 : 1])])
2869
+ ]
2870
+ }
2871
+ };
2872
+ var scrambleText = {
2873
+ kind: "vector",
2874
+ id: "scramble-text",
2875
+ name: "Scramble text",
2876
+ description: "Random glyphs flickering, then settling into the real word letter by letter, left to right.",
2877
+ category: "text",
2878
+ tags: ["text", "title", "decode", "hacker", "cyber", "reveal"],
2879
+ hold: 1200,
2880
+ presets: [
2881
+ { name: "Matrix", values: { background: [0, 0.03, 0.01], ink: [0.4, 1, 0.5], accent: [0.1, 0.7, 0.25], glyphs: "01" } },
2882
+ { name: "Amber", values: { background: [0.08, 0.04, 0], ink: [1, 0.72, 0.2], accent: [0.9, 0.4, 0.05] } }
2883
+ ],
2884
+ derived: (v) => {
2885
+ const count = Math.max(1, lettersOf(v.text).length);
2886
+ const size = r1(Math.min(60, 360 / (count * MONO_EM)));
2887
+ const width = r1(count * MONO_EM * size);
2888
+ const base = r1(100 + size * 0.35);
2889
+ return { size, base, barX: r1(200 - width / 2), barY: r1(base + size * 0.3), barW: width, decode: Math.round(count * Number(v.pace)), glowWide: Number(v.glow) * 2 };
2890
+ },
2891
+ motion: {
2892
+ svg: `<svg viewBox="0 0 400 200"><!-- {{text: text default='ACCESS GRANTED' label='Text'}} {{glyphs: text default='ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#%&@$*+' label='Scramble glyphs'}} {{background: color default=#05070D label='Background'}} {{ink: color default=#E2E8F0 label='Text colour'}} {{accent: color default=#22D3EE label='Accent'}} {{pace: float min=20 max=250 default=70 step=5 label='Ms per letter'}} {{glow: float min=0 max=6 default=2 step=0.5 label='Glow'}} --><defs><filter id="glow" x="-20%" y="-50%" width="140%" height="200%"><feGaussianBlur stdDeviation="{{glowWide}}" result="blur"/><feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs><rect width="400" height="200" fill="{{background}}"/><text id="word" x="200" y="{{base}}" text-anchor="middle" font-family="${MONO}" font-size="{{size}}" font-weight="700" fill="{{ink}}" filter="url(#glow)" xml:space="preserve">{{text}}</text><rect id="bar" x="{{barX}}" y="{{barY}}" width="0" height="3" fill="{{accent}}"/></svg>`,
2893
+ animate: [
2894
+ // Pure noise for a moment (the scramble runs before the change starts), then it decodes.
2895
+ { id: "word", write: "{{text}}", scramble: "{{glyphs}}", delay: 500, ms: n("decode"), ease: "linear" },
2896
+ { id: "bar", attrs: { width: [0, n("barW")] }, delay: 500, ms: n("decode"), ease: "linear" }
2897
+ ]
2898
+ }
2899
+ };
2900
+ var SLOTS = 20;
2901
+ var kineticStagger = {
2902
+ kind: "vector",
2903
+ id: "kinetic-stagger",
2904
+ name: "Kinetic stagger",
2905
+ description: "Each letter springs up into place in turn, rising, turning and overshooting, then an underline sweeps beneath.",
2906
+ category: "text",
2907
+ tags: ["text", "title", "kinetic", "typography", "headline", "intro"],
2908
+ hold: 1200,
2909
+ presets: [
2910
+ { name: "Poster", values: { background: [0.98, 0.87, 0.2], ink: [0.07, 0.07, 0.07], accent: [0.9, 0.15, 0.2] } },
2911
+ { name: "Midnight", values: { background: [0.04, 0.05, 0.12], ink: [0.95, 0.96, 1], accent: [0.55, 0.45, 1], stagger: 80 } }
2912
+ ],
2913
+ // Up to SLOTS letters, each centred in its estimated advance; unused slots stay empty.
2914
+ derived: (v) => {
2915
+ const letters = lettersOf(v.text).slice(0, SLOTS);
2916
+ const widths = letters.map((c) => textWidthEm(c));
2917
+ const ems = Math.max(0.6, widths.reduce((a, b) => a + b, 0));
2918
+ const size = Math.min(92, 370 / ems);
2919
+ const base = 100 + size * 0.35;
2920
+ const stagger = Number(v.stagger);
2921
+ const out = {
2922
+ size: r1(size),
2923
+ base: r1(base),
2924
+ rise: r1(base - size * 0.1),
2925
+ drop: r1(base + size * 0.6),
2926
+ lineX: r1(200 - ems * size / 2),
2927
+ lineY: r1(base + size * 0.2),
2928
+ lineW: r1(ems * size),
2929
+ lineAt: Math.round(letters.length * stagger + 400)
2930
+ };
2931
+ let x = 200 - ems * size / 2;
2932
+ for (let i = 0; i < SLOTS; i++) {
2933
+ const w = (widths[i] ?? 0) * size;
2934
+ out[`c${i}`] = letters[i]?.trim() ?? "";
2935
+ out[`x${i}`] = r1(x + w / 2);
2936
+ out[`d${i}`] = Math.round(i * stagger);
2937
+ out[`e${i}`] = Math.round(i * stagger + 380);
2938
+ x += w;
2939
+ }
2940
+ return out;
2941
+ },
2942
+ motion: {
2943
+ svg: `<svg viewBox="0 0 400 200"><!-- {{text: text default='Kinetic' label='Text'}} {{background: color default=#F8FAFC label='Background'}} {{ink: color default=#0F172A label='Text colour'}} {{accent: color default=#F43F5E label='Underline'}} {{stagger: float min=20 max=200 default=55 step=5 label='Ms between letters'}} --><rect width="400" height="200" fill="{{background}}"/>${Array.from(
2944
+ { length: SLOTS },
2945
+ (_, i) => `<text id="l${i}" text-anchor="middle" transform="translate({{x${i}}} {{drop}}) rotate(-28) scale(0.3)" opacity="0" font-family="${HEAVY}" font-size="{{size}}" font-weight="900" fill="{{ink}}">{{c${i}}}</text>`
2946
+ ).join("")}<rect id="line" x="{{lineX}}" y="{{lineY}}" width="0" height="6" rx="3" fill="{{accent}}"/></svg>`,
2947
+ animate: [
2948
+ ...Array.from({ length: SLOTS }, (_, i) => [
2949
+ {
2950
+ id: `l${i}`,
2951
+ attrs: { transform: [`translate({{x${i}}} {{drop}}) rotate(-28) scale(0.3)`, `translate({{x${i}}} {{rise}}) rotate(5) scale(1.12)`], opacity: [0, 1] },
2952
+ delay: n(`d${i}`),
2953
+ ms: 380,
2954
+ ease: "out"
2955
+ },
2956
+ { id: `l${i}`, attrs: { transform: [`translate({{x${i}}} {{rise}}) rotate(5) scale(1.12)`, `translate({{x${i}}} {{base}}) rotate(0) scale(1)`] }, delay: n(`e${i}`), ms: 260, ease: "in-out" }
2957
+ ]).flat(),
2958
+ { id: "line", attrs: { width: [0, n("lineW")] }, delay: n("lineAt"), ms: 500, ease: "in-out" }
2959
+ ]
2960
+ }
2961
+ };
2962
+ var neonSign = {
2963
+ kind: "vector",
2964
+ id: "neon-sign",
2965
+ name: "Neon sign",
2966
+ description: "Glowing neon tube lettering that stutters on, then keeps one faulty letter buzzing.",
2967
+ category: "text",
2968
+ tags: ["text", "neon", "glow", "sign", "night", "retro"],
2969
+ hold: 400,
2970
+ presets: [
2971
+ { name: "Hot pink", values: { tube: [1, 0.2, 0.65], wall: [0.06, 0.02, 0.07] } },
2972
+ { name: "Ice", values: { tube: [0.35, 0.85, 1], wall: [0.02, 0.04, 0.08], glow: 5 } },
2973
+ { name: "Lime", values: { tube: [0.6, 1, 0.2], wall: [0.03, 0.05, 0.02] } }
2974
+ ],
2975
+ // The word in three runs, so the buzzing letter can flicker on its own.
2976
+ derived: (v) => {
2977
+ const letters = lettersOf(v.text);
2978
+ const k = Math.min(Math.max(0, Math.round(Number(v.faulty)) - 1), Math.max(0, letters.length - 1));
2979
+ const { size, length } = fit(String(v.text), 340, 84);
2980
+ return {
2981
+ head: letters.slice(0, k).join(""),
2982
+ mid: letters[k] ?? "",
2983
+ tail: letters.slice(k + 1).join(""),
2984
+ size,
2985
+ length,
2986
+ base: r1(100 + size * 0.3),
2987
+ far: Number(v.glow) * 3,
2988
+ core: mixHex(v.tube, [1, 1, 1], 0.6)
2989
+ };
2990
+ },
2991
+ motion: (() => {
2992
+ const word = `x="200" y="{{base}}" text-anchor="middle" textLength="{{length}}" lengthAdjust="spacingAndGlyphs" font-family="'Pacifico', 'Brush Script MT', 'Segoe Script', 'Snell Roundhand', cursive" font-size="{{size}}"`;
2993
+ const runs = (id) => `<tspan>{{head}}</tspan><tspan id="${id}">{{mid}}</tspan><tspan>{{tail}}</tspan>`;
2994
+ const startUp = [[0, 0], [150, 0.85], [210, 0], [330, 1], [390, 0.15], [450, 1], [620, 0.35], [680, 1]];
2995
+ const buzz = [[0, 1], [1300, 0.15], [1350, 1], [1420, 0.2], [1520, 1], [2100, 0.1], [2160, 1], [2230, 0.25], [2280, 1]];
2996
+ return {
2997
+ svg: `<svg viewBox="0 0 400 200"><!-- {{text: text default='Open late' label='Text'}} {{tube: color default=#FF3DA5 label='Tube'}} {{wall: color default=#120612 label='Wall'}} {{glow: float min=0.5 max=8 default=3.5 step=0.5 label='Glow'}} {{faulty: int min=1 max=24 default=6 label='Buzzing letter'}} --><defs><filter id="neon" x="-30%" y="-80%" width="160%" height="260%"><feGaussianBlur in="SourceGraphic" stdDeviation="{{glow}}" result="near"/><feGaussianBlur in="SourceGraphic" stdDeviation="{{far}}" result="far"/><feMerge><feMergeNode in="far"/><feMergeNode in="far"/><feMergeNode in="near"/><feMergeNode in="SourceGraphic"/></feMerge></filter><filter id="haze" x="-50%" y="-100%" width="200%" height="300%"><feGaussianBlur stdDeviation="30"/></filter></defs><rect width="400" height="200" fill="{{wall}}"/><g id="sign" opacity="0"><ellipse cx="200" cy="100" rx="170" ry="55" fill="{{tube}}" opacity="0.18" filter="url(#haze)"/><text ${word} fill="{{tube}}" stroke="{{tube}}" stroke-width="1.5" filter="url(#neon)">${runs("buzzGlow")}</text><text ${word} fill="{{core}}">${runs("buzzCore")}</text></g></svg>`,
2998
+ animate: [
2999
+ ...holds("sign", "opacity", startUp),
3000
+ // fill-opacity rather than opacity: browsers ignore opacity on a <tspan>.
3001
+ ...["buzzGlow", "buzzCore"].flatMap((id) => [...holds(id, "fill-opacity", buzz), ...holds(id, "stroke-opacity", buzz)])
3002
+ ]
3003
+ };
3004
+ })()
3005
+ };
3006
+ var strokeDrawText = {
3007
+ kind: "vector",
3008
+ id: "stroke-draw-text",
3009
+ name: "Stroke draw text",
3010
+ description: "A word's outline traced on along every letter, then its fill fading in.",
3011
+ category: "text",
3012
+ tags: ["text", "title", "draw-on", "outline", "reveal", "handwriting"],
3013
+ hold: 1200,
3014
+ presets: [
3015
+ { name: "Blueprint", values: { background: [0.05, 0.2, 0.45], outline: [0.85, 0.93, 1], fill: [0.85, 0.93, 1], fillOpacity: 0.15 } },
3016
+ { name: "Gold", values: { background: [0.07, 0.06, 0.05], outline: [1, 0.8, 0.35], fill: [0.85, 0.6, 0.15] } }
3017
+ ],
3018
+ // <text> takes no pathLength, so `draw` cannot measure it: the dash grows instead, longer than any letter's outline.
3019
+ derived: (v) => {
3020
+ const { size, length } = fit(String(v.text), 360, 100);
3021
+ return { size, length, base: r1(100 + size * 0.35), dash: Math.round(size * 6) };
3022
+ },
3023
+ motion: {
3024
+ svg: `<svg viewBox="0 0 400 200"><!-- {{text: text default='Draw' label='Text'}} {{background: color default=#FFFBF5 label='Background'}} {{outline: color default=#1E293B label='Outline'}} {{fill: color default=#F97316 label='Fill'}} {{width: float min=0.5 max=6 default=2 step=0.5 label='Outline width'}} {{fillOpacity: float min=0 max=1 default=1 label='Fill opacity'}} --><rect width="400" height="200" fill="{{background}}"/><text id="word" x="200" y="{{base}}" text-anchor="middle" textLength="{{length}}" lengthAdjust="spacingAndGlyphs" font-family="${HEAVY}" font-size="{{size}}" font-weight="900" fill="{{fill}}" fill-opacity="0" stroke="{{outline}}" stroke-width="{{width}}" stroke-linejoin="round" stroke-dasharray="0 {{dash}}">{{text}}</text></svg>`,
3025
+ animate: [
3026
+ { id: "word", attrs: { "stroke-dasharray": ["0 {{dash}}", "{{dash}} 0"] }, ms: 2e3, ease: "in-out" },
3027
+ { id: "word", attrs: { "fill-opacity": [0, n("fillOpacity")] }, delay: 1500, ms: 700, ease: "out" }
3028
+ ]
3029
+ }
3030
+ };
3031
+ var splitReveal = {
3032
+ kind: "vector",
3033
+ id: "split-reveal",
3034
+ name: "Split reveal",
3035
+ description: "A line draws across, then the title's top half rises out from behind it while the bottom half drops down.",
3036
+ category: "text",
3037
+ tags: ["text", "title", "reveal", "mask", "editorial", "intro"],
3038
+ hold: 1400,
3039
+ presets: [
3040
+ { name: "Light", values: { background: [0.97, 0.96, 0.94], ink: [0.08, 0.08, 0.1], line: [0.85, 0.2, 0.15] } },
3041
+ { name: "Gold on black", values: { background: [0.02, 0.02, 0.02], ink: [0.96, 0.84, 0.55], line: [0.96, 0.84, 0.55] } }
3042
+ ],
3043
+ derived: (v) => {
3044
+ const { size, length, left } = fit(String(v.text), 360, 88);
3045
+ return {
3046
+ size,
3047
+ length,
3048
+ base: r1(100 + size * 0.36),
3049
+ shift: r1(size * 0.8),
3050
+ lineX1: r1(left - 10),
3051
+ lineX2: r1(400 - left + 10),
3052
+ captionY: r1(100 + size * 0.36 + 30)
3053
+ };
3054
+ },
3055
+ motion: (() => {
3056
+ const word = `<text x="200" y="{{base}}" text-anchor="middle" textLength="{{length}}" lengthAdjust="spacingAndGlyphs" font-family="${SANS}" font-size="{{size}}" font-weight="800" fill="{{ink}}">{{text}}</text>`;
3057
+ return {
3058
+ svg: `<svg viewBox="0 0 400 200"><!-- {{text: text default='Chapter One' label='Text'}} {{caption: text default='The beginning' label='Caption'}} {{background: color default=#0F172A label='Background'}} {{ink: color default=#F8FAFC label='Text colour'}} {{line: color default=#38BDF8 label='Line'}} --><defs><clipPath id="above"><rect width="400" height="100"/></clipPath><clipPath id="below"><rect y="100" width="400" height="100"/></clipPath></defs><rect width="400" height="200" fill="{{background}}"/><g clip-path="url(#above)"><g id="top" transform="translate(0 {{shift}})">${word}</g></g><g clip-path="url(#below)"><g id="bottom" transform="translate(0 -{{shift}})">${word}</g></g><path id="line" d="M{{lineX1}} 100 H{{lineX2}}" stroke="{{line}}" stroke-width="2.5" stroke-linecap="round"/><text id="caption" x="200" y="{{captionY}}" text-anchor="middle" font-family="${SANS}" font-size="13" letter-spacing="3" fill="{{ink}}" opacity="0">{{caption}}</text></svg>`,
3059
+ animate: [
3060
+ { id: "line", draw: true, ms: 600, ease: "in-out" },
3061
+ { id: "top", attrs: { transform: ["translate(0 {{shift}})", "translate(0 0)"] }, delay: 450, ms: 800, ease: "out" },
3062
+ { id: "bottom", attrs: { transform: ["translate(0 -{{shift}})", "translate(0 0)"] }, delay: 450, ms: 800, ease: "out" },
3063
+ { id: "line", attrs: { opacity: [1, 0] }, delay: 1100, ms: 400 },
3064
+ { id: "caption", attrs: { opacity: [0, 0.75] }, delay: 1100, ms: 500, ease: "out" }
3065
+ ]
3066
+ };
3067
+ })()
3068
+ };
3069
+ var highlighter = {
3070
+ kind: "vector",
3071
+ id: "highlighter",
3072
+ name: "Highlighter",
3073
+ description: "A marker swipe drawn left to right behind a phrase, rough-edged like a real highlighter pen.",
3074
+ category: "text",
3075
+ tags: ["text", "highlight", "marker", "emphasis", "explainer", "callout"],
3076
+ hold: 1400,
3077
+ presets: [
3078
+ { name: "Pink", values: { marker: [1, 0.55, 0.78] } },
3079
+ { name: "Mint", values: { marker: [0.55, 0.95, 0.7] } },
3080
+ { name: "Dark", values: { background: [0.07, 0.08, 0.1], ink: [0.97, 0.97, 0.98], marker: [0.55, 0.2, 0.95] } }
3081
+ ],
3082
+ // The swipe runs a little past the phrase at both ends, across its lower half, with a ragged second pass.
3083
+ derived: (v) => {
3084
+ const { size, length, left } = fit(String(v.text), 330, 56);
3085
+ const base = 100 + size * 0.35;
3086
+ const x0 = left - size * 0.25;
3087
+ const x1 = 400 - left + size * 0.2;
3088
+ const y = base - size * 0.3;
3089
+ return {
3090
+ size,
3091
+ length,
3092
+ base: r1(base),
3093
+ swipe: `M${r1(x0)} ${r1(y + size * 0.05)} Q${r1((x0 + x1) / 2)} ${r1(y - size * 0.06)} ${r1(x1)} ${r1(y - size * 0.02)}`,
3094
+ ragged: `M${r1(x0 + size * 0.15)} ${r1(y + size * 0.3)} L${r1(x1 - size * 0.1)} ${r1(y + size * 0.26)}`,
3095
+ thick: r1(size * 0.72),
3096
+ thin: r1(size * 0.16)
3097
+ };
3098
+ },
3099
+ motion: {
3100
+ svg: `<svg viewBox="0 0 400 200"><!-- {{text: text default='Ship it faster' label='Phrase'}} {{background: color default=#FFFFFF label='Background'}} {{ink: color default=#111827 label='Text colour'}} {{marker: color default=#FDE047 label='Marker'}} --><rect width="400" height="200" fill="{{background}}"/><g transform="rotate(-1.2 200 100)" opacity="0.9"><path id="swipe" d="{{swipe}}" fill="none" stroke="{{marker}}" stroke-width="{{thick}}"/><path id="ragged" d="{{ragged}}" fill="none" stroke="{{marker}}" stroke-width="{{thin}}" stroke-linecap="round"/></g><text id="phrase" x="200" y="{{base}}" text-anchor="middle" textLength="{{length}}" lengthAdjust="spacingAndGlyphs" font-family="${SANS}" font-size="{{size}}" font-weight="700" fill="{{ink}}" opacity="0">{{text}}</text></svg>`,
3101
+ animate: [
3102
+ { id: "phrase", attrs: { opacity: [0, 1] }, ms: 300, ease: "out" },
3103
+ { id: "swipe", draw: true, delay: 350, ms: 650, ease: "in-out" },
3104
+ { id: "ragged", draw: true, delay: 420, ms: 620, ease: "in-out" }
3105
+ ]
3106
+ }
3107
+ };
3108
+ var SHOW_MS = 1e3;
3109
+ var ROLL_MS = 450;
3110
+ var CYCLE_WORDS = 4;
3111
+ var wordCycle = {
3112
+ kind: "vector",
3113
+ id: "word-cycle",
3114
+ name: "Word cycle",
3115
+ description: "A fixed lead-in with the last word rolling through alternatives like a slot machine, an underline resizing to each.",
3116
+ category: "text",
3117
+ tags: ["text", "headline", "rotating", "hero", "marketing", "slot"],
3118
+ hold: 0,
3119
+ presets: [
3120
+ { name: "Dark", values: { background: [0.05, 0.06, 0.1], ink: [0.95, 0.96, 1], accent: [0.4, 0.85, 0.65] } },
3121
+ { name: "Warm", values: { background: [1, 0.96, 0.9], ink: [0.2, 0.12, 0.08], accent: [0.93, 0.35, 0.15] } }
3122
+ ],
3123
+ // The lead-in and the longest word share the line; the reel ends with word 1 again, so the loop is seamless.
3124
+ derived: (v) => {
3125
+ const words = Array.from({ length: CYCLE_WORDS }, (_, i) => String(v[`word${i + 1}`]));
3126
+ const lead = textWidthEm(String(v.prefix));
3127
+ const ems = words.map((w) => Math.max(0.6, textWidthEm(w)));
3128
+ const total = lead + 0.3 + Math.max(...ems);
3129
+ const size = Math.min(52, 370 / total);
3130
+ const left = 200 - total * size / 2;
3131
+ const base = 100 + size * 0.35;
3132
+ const step = size * 1.3;
3133
+ const wx = left + (lead + 0.3) * size;
3134
+ return {
3135
+ size: r1(size),
3136
+ base: r1(base),
3137
+ px: r1(left),
3138
+ plen: r1(Math.max(0.6, lead) * size),
3139
+ wx: r1(wx),
3140
+ clipX: r1(wx - 4),
3141
+ clipY: r1(base - size * 1),
3142
+ clipW: r1(Math.max(...ems) * size + 8),
3143
+ clipH: r1(size * 1.32),
3144
+ underY: r1(base + size * 0.18),
3145
+ ...Object.fromEntries(
3146
+ [...words, words[0]].flatMap((_, k) => [
3147
+ [`y${k}`, r1(base + k * step)],
3148
+ [`o${k}`, r1(k * step)],
3149
+ [`len${k}`, r1(ems[k % CYCLE_WORDS] * size)]
3150
+ ])
3151
+ ),
3152
+ ...Object.fromEntries(words.map((w, k) => [`w${k}`, w]))
3153
+ };
3154
+ },
3155
+ motion: {
3156
+ svg: `<svg viewBox="0 0 400 200"><!-- {{prefix: text default='We build' label='Lead-in'}} {{word1: text default=apps label='Word 1'}} {{word2: text default=brands label='Word 2'}} {{word3: text default=websites label='Word 3'}} {{word4: text default=games label='Word 4'}} {{background: color default=#FFFFFF label='Background'}} {{ink: color default=#0F172A label='Text colour'}} {{accent: color default=#6366F1 label='Accent'}} --><defs><clipPath id="window"><rect x="{{clipX}}" y="{{clipY}}" width="{{clipW}}" height="{{clipH}}"/></clipPath></defs><rect width="400" height="200" fill="{{background}}"/><text x="{{px}}" y="{{base}}" textLength="{{plen}}" lengthAdjust="spacingAndGlyphs" font-family="${SANS}" font-size="{{size}}" font-weight="700" fill="{{ink}}">{{prefix}}</text><g clip-path="url(#window)"><g id="reel" transform="translate(0 -0)">${Array.from(
3157
+ { length: CYCLE_WORDS + 1 },
3158
+ (_, k) => `<text x="{{wx}}" y="{{y${k}}}" textLength="{{len${k}}}" lengthAdjust="spacingAndGlyphs" font-family="${SANS}" font-size="{{size}}" font-weight="800" fill="{{accent}}">{{w${k % CYCLE_WORDS}}}</text>`
3159
+ ).join("")}</g></g><rect id="under" x="{{wx}}" y="{{underY}}" width="{{len0}}" height="4" rx="2" fill="{{accent}}"/></svg>`,
3160
+ animate: Array.from({ length: CYCLE_WORDS }, (_, i) => {
3161
+ const at = { delay: (i + 1) * SHOW_MS + i * ROLL_MS, ms: ROLL_MS, ease: "in-out" };
3162
+ return [
3163
+ { id: "reel", attrs: { transform: [`translate(0 -{{o${i}}})`, `translate(0 -{{o${i + 1}}})`] }, ...at },
3164
+ { id: "under", attrs: { width: [n(`len${i}`), n(`len${i + 1}`)] }, ...at }
3165
+ ];
3166
+ }).flat()
3167
+ }
3168
+ };
3169
+ var WAVE = `M-200 0${" q25 -8 50 0 t50 0".repeat(8)} V320 H-200Z`;
3170
+ var liquidFill = {
3171
+ kind: "vector",
3172
+ id: "liquid-fill",
3173
+ name: "Liquid fill",
3174
+ description: "A title filling up with liquid, its wavy surface rising through the letters.",
3175
+ category: "text",
3176
+ tags: ["text", "title", "liquid", "water", "progress", "fill"],
3177
+ hold: 900,
3178
+ presets: [
3179
+ { name: "Gradient sweep", values: { liquid: [0.98, 0.25, 0.55], liquid2: [0.35, 0.4, 1], empty: [0.93, 0.93, 0.96] } },
3180
+ { name: "Lava", values: { liquid: [1, 0.35, 0.05], liquid2: [1, 0.8, 0.1], empty: [0.15, 0.08, 0.06], background: [0.06, 0.03, 0.02] } },
3181
+ { name: "Juice", values: { liquid: [1, 0.6, 0.1], liquid2: [1, 0.75, 0.25] } }
3182
+ ],
3183
+ // The surface starts under the letters and ends above their caps.
3184
+ derived: (v) => {
3185
+ const { size, length } = fit(String(v.text), 360, 110);
3186
+ const base = 100 + size * 0.36;
3187
+ return { size, length, base: r1(base), low: r1(base + 12), high: r1(base - size * 0.82 - 12) };
3188
+ },
3189
+ motion: (() => {
3190
+ const word = `x="200" y="{{base}}" text-anchor="middle" textLength="{{length}}" lengthAdjust="spacingAndGlyphs" font-family="${HEAVY}" font-size="{{size}}" font-weight="900"`;
3191
+ return {
3192
+ svg: `<svg viewBox="0 0 400 200"><!-- {{text: text default='FILL' label='Text'}} {{background: color default=#FFFFFF label='Background'}} {{empty: color default=#E0F2FE label='Empty'}} {{liquid: color default=#0284C7 label='Liquid'}} {{liquid2: color default=#38BDF8 label='Liquid sweep'}} --><defs><clipPath id="letters"><text ${word}>{{text}}</text></clipPath><linearGradient id="liq" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="400" y2="0" spreadMethod="repeat"><stop offset="0" stop-color="{{liquid}}"/><stop offset="0.5" stop-color="{{liquid2}}"/><stop offset="1" stop-color="{{liquid}}"/></linearGradient></defs><rect width="400" height="200" fill="{{background}}"/><text ${word} fill="{{empty}}" stroke="{{liquid}}" stroke-width="1.5" stroke-opacity="0.5">{{text}}</text><g clip-path="url(#letters)"><g id="level" transform="translate(0 {{low}})"><path id="back" d="${WAVE}" fill="{{liquid}}" opacity="0.45" transform="translate(-50 -5)"/><path id="wave" d="${WAVE}" fill="url(#liq)" transform="translate(0 0)"/></g></g></svg>`,
3193
+ animate: [
3194
+ { id: "level", attrs: { transform: ["translate(0 {{low}})", "translate(0 {{high}})"] }, ms: 2800, ease: "out" },
3195
+ { id: "wave", attrs: { transform: ["translate(0 0)", "translate(-200 0)"] }, ms: 3e3, ease: "linear" },
3196
+ { id: "back", attrs: { transform: ["translate(-50 -5)", "translate(150 -5)"] }, ms: 3e3, ease: "linear" },
3197
+ // The sweep: the repeating gradient slides along, visible when the two liquid colours differ.
3198
+ { id: "liq", attrs: { x1: [0, 400], x2: [400, 800] }, ms: 3e3, ease: "linear" }
3199
+ ]
3200
+ };
3201
+ })()
3202
+ };
3203
+ var BANDS = [0.3, 0.55, 0.8];
3204
+ var GLITCH = (() => {
3205
+ let seed = 7;
3206
+ const rand = () => (seed = seed * 16807 % 2147483647) / 2147483647 * 2 - 1;
3207
+ const rest = { red: [-0.02, 0], cyan: [0.02, 0], bands: [0, 0, 0, 0] };
3208
+ const keys = [{ at: 0, ...rest }];
3209
+ for (const [start, count] of [[250, 6], [1050, 3], [1650, 8]]) {
3210
+ for (let k = 0; k < count; k++)
3211
+ keys.push({
3212
+ at: start + k * 55,
3213
+ red: [rand() * 0.12, rand() * 0.04],
3214
+ cyan: [rand() * 0.12, rand() * 0.04],
3215
+ bands: [0, 1, 2, 3].map(() => rand() > 0.1 ? rand() * 0.25 : 0)
3216
+ });
3217
+ keys.push({ at: start + count * 55, ...rest });
3218
+ }
3219
+ return keys;
3220
+ })();
3221
+ var glitchText = {
3222
+ kind: "vector",
3223
+ id: "glitch-text",
3224
+ name: "Glitch text",
3225
+ description: "A title breaking up in bursts: red and cyan copies split apart and slices of the letters jump sideways.",
3226
+ category: "text",
3227
+ tags: ["text", "title", "glitch", "cyber", "rgb-split", "error"],
3228
+ hold: 400,
3229
+ presets: [
3230
+ { name: "Classic", values: { left: [1, 0, 0.2], right: [0, 1, 1] } },
3231
+ { name: "Vapor", values: { left: [1, 0.3, 0.85], right: [0.4, 0.6, 1], background: [0.08, 0.02, 0.12], amount: 1.4 } }
3232
+ ],
3233
+ derived: (v) => {
3234
+ const { size, length } = fit(String(v.text), 340, 100);
3235
+ const base = 100 + size * 0.36;
3236
+ const top = base - size * 0.78;
3237
+ const cuts = [0, ...BANDS.map((f) => r1(top + f * (base - top))), 200];
3238
+ const unit = size * Number(v.amount);
3239
+ const shift = ([x, y]) => `translate(${r1(x * unit)} ${r1(y * unit)})`;
3240
+ return {
3241
+ size,
3242
+ length,
3243
+ base: r1(base),
3244
+ ...Object.fromEntries([0, 1, 2, 3].flatMap((b) => [[`cut${b}`, cuts[b]], [`band${b}`, r1(cuts[b + 1] - cuts[b])]])),
3245
+ ...Object.fromEntries(
3246
+ GLITCH.flatMap((key, k) => [
3247
+ [`red${k}`, shift(key.red)],
3248
+ [`cyan${k}`, shift(key.cyan)],
3249
+ ...key.bands.map((x, b) => [`b${b}_${k}`, shift([x, 0])])
3250
+ ])
3251
+ )
3252
+ };
3253
+ },
3254
+ motion: (() => {
3255
+ const word = (paint) => `<text x="200" y="{{base}}" text-anchor="middle" textLength="{{length}}" lengthAdjust="spacingAndGlyphs" font-family="${HEAVY}" font-size="{{size}}" font-weight="900" ${paint}>{{text}}</text>`;
3256
+ const jumps = (id, value) => GLITCH.map((key, k) => ({ id, attrs: { transform: [value(k), value(k)] }, delay: key.at, ms: 0 }));
3257
+ return {
3258
+ svg: `<svg viewBox="0 0 400 200"><!-- {{text: text default='GLITCH' label='Text'}} {{background: color default=#07070B label='Background'}} {{ink: color default=#F5F5F5 label='Text colour'}} {{left: color default=#FF0040 label='Red copy'}} {{right: color default=#00F0FF label='Cyan copy'}} {{amount: float min=0 max=3 default=1 step=0.1 label='Intensity'}} --><defs>${[0, 1, 2, 3].map((b) => `<clipPath id="slice${b}"><rect x="-100" y="{{cut${b}}}" width="600" height="{{band${b}}}"/></clipPath>`).join("")}</defs><rect width="400" height="200" fill="{{background}}"/><g id="red" style="mix-blend-mode:screen">${word(`fill="{{left}}"`)}</g><g id="cyan" style="mix-blend-mode:screen">${word(`fill="{{right}}"`)}</g>${[0, 1, 2, 3].map((b) => `<g clip-path="url(#slice${b})"><g id="s${b}">${word(`fill="{{ink}}"`)}</g></g>`).join("")}</svg>`,
3259
+ animate: [
3260
+ ...jumps("red", (k) => `{{red${k}}}`),
3261
+ ...jumps("cyan", (k) => `{{cyan${k}}}`),
3262
+ ...[0, 1, 2, 3].flatMap((b) => jumps(`s${b}`, (k) => `{{b${b}_${k}}}`))
3263
+ ]
3264
+ };
3265
+ })()
3266
+ };
3267
+ var LAYERS = 12;
3268
+ var EXTRUDE_FROM = 40;
3269
+ var EXTRUDE_TO = 140;
3270
+ var extrudedText = {
3271
+ kind: "vector",
3272
+ id: "extruded-text",
3273
+ name: "Extruded text",
3274
+ description: "Retro 3D block lettering: stacked shaded layers extrude out of the title and swing as the light moves.",
3275
+ category: "text",
3276
+ tags: ["text", "title", "3d", "retro", "extrude", "80s"],
3277
+ hold: 600,
3278
+ presets: [
3279
+ { name: "Arcade", values: { face: [0.2, 0.95, 1], side: [0.85, 0.2, 0.75], shade: [0.2, 0.05, 0.3], background: [0.05, 0.02, 0.12] } },
3280
+ { name: "Diner", values: { face: [1, 1, 1], side: [0.9, 0.15, 0.2], shade: [0.35, 0.03, 0.06], background: [0.98, 0.9, 0.75], outline: [0.1, 0.1, 0.12] } }
3281
+ ],
3282
+ // Each layer sits a step further back along the extrude direction, shaded from the side colour to the shadow.
3283
+ derived: (v) => {
3284
+ const { size, length } = fit(String(v.text), 320, 96);
3285
+ const step = size * 0.03 * Number(v.depth);
3286
+ const at = (deg, i) => {
3287
+ const a = deg * Math.PI / 180;
3288
+ return `translate(${r1(Math.cos(a) * step * i)} ${r1(Math.sin(a) * step * i)})`;
3289
+ };
3290
+ return {
3291
+ size,
3292
+ length,
3293
+ base: r1(92 + size * 0.35),
3294
+ ...Object.fromEntries(
3295
+ Array.from({ length: LAYERS }, (_, k) => {
3296
+ const i = LAYERS - k;
3297
+ return [
3298
+ [`fill${i}`, mixHex(v.side, v.shade, i / LAYERS)],
3299
+ [`a${i}`, at(EXTRUDE_FROM, i)],
3300
+ [`b${i}`, at(EXTRUDE_TO, i)]
3301
+ ];
3302
+ }).flat()
3303
+ )
3304
+ };
3305
+ },
3306
+ motion: (() => {
3307
+ const word = (paint) => `x="200" y="{{base}}" text-anchor="middle" textLength="{{length}}" lengthAdjust="spacingAndGlyphs" font-family="${HEAVY}" font-size="{{size}}" font-weight="900" stroke-linejoin="round" ${paint}`;
3308
+ const layers = Array.from({ length: LAYERS }, (_, k) => LAYERS - k);
3309
+ return {
3310
+ svg: `<svg viewBox="0 0 400 200"><!-- {{text: text default='RETRO' label='Text'}} {{background: color default=#FFE8C2 label='Background'}} {{face: color default=#FFD23F label='Face'}} {{outline: color default=#1A1423 label='Outline'}} {{side: color default=#EE4266 label='Extrude'}} {{shade: color default=#3B1F2B label='Shadow'}} {{depth: float min=0.2 max=2 default=1 step=0.1 label='Depth'}} --><rect width="400" height="200" fill="{{background}}"/>${layers.map((i) => `<text id="e${i}" transform="translate(0 0)" ${word(`fill="{{fill${i}}}" stroke="{{fill${i}}}" stroke-width="2"`)}>{{text}}</text>`).join("")}<text ${word(`fill="{{face}}" stroke="{{outline}}" stroke-width="2.5" paint-order="stroke"`)}>{{text}}</text></svg>`,
3311
+ animate: layers.flatMap((i) => [
3312
+ { id: `e${i}`, attrs: { transform: ["translate(0 0)", `{{a${i}}}`] }, ms: 700, ease: "out" },
3313
+ { id: `e${i}`, attrs: { transform: [`{{a${i}}}`, `{{b${i}}}`] }, delay: 1200, ms: 1500, ease: "in-out" },
3314
+ { id: `e${i}`, attrs: { transform: [`{{b${i}}}`, `{{a${i}}}`] }, delay: 3200, ms: 1500, ease: "in-out" }
3315
+ ])
3316
+ };
3317
+ })()
3318
+ };
3319
+ var textEffects = [
3320
+ typewriter,
3321
+ scrambleText,
3322
+ kineticStagger,
3323
+ neonSign,
3324
+ strokeDrawText,
3325
+ splitReveal,
3326
+ highlighter,
3327
+ wordCycle,
3328
+ liquidFill,
3329
+ glitchText,
3330
+ extrudedText
3331
+ ];
3332
+
3333
+ // src/catalog/types.ts
3334
+ var EFFECT_CATEGORIES = [
3335
+ "gradient",
3336
+ "organic",
3337
+ "pattern",
3338
+ "light",
3339
+ "space",
3340
+ "retro",
3341
+ "image",
3342
+ "overlay",
3343
+ "text",
3344
+ "transition",
3345
+ "loader",
3346
+ "data",
3347
+ "icon"
3348
+ ];
3349
+ var VECTOR_CONTROL_TYPES = ["float", "int", "bool", "color", "text"];
3350
+ var PLACEHOLDER = /\{\{\s*([A-Za-z_]\w*)\s*(?::\s*([A-Za-z0-9]*)([^}]*))?\}\}/g;
3351
+ function motionStrings(motion) {
3352
+ const out = [];
3353
+ const walk = (v) => {
3354
+ if (typeof v === "string") out.push(v);
3355
+ else if (Array.isArray(v)) v.forEach(walk);
3356
+ else if (v && typeof v === "object") Object.values(v).forEach(walk);
3357
+ };
3358
+ walk(motion);
3359
+ return out;
3360
+ }
3361
+ function vectorControls(motion) {
3362
+ const controls = [];
3363
+ const seen = /* @__PURE__ */ new Set();
3364
+ for (const text of motionStrings(motion)) {
3365
+ for (const [, name, type, attributes] of text.matchAll(PLACEHOLDER)) {
3366
+ if (type === void 0 || seen.has(name) || !VECTOR_CONTROL_TYPES.includes(type)) continue;
3367
+ seen.add(name);
3368
+ const colour = type === "color";
3369
+ controls.push(controlFromDeclaration(name, colour ? "vec3" : type, `${attributes}${colour ? " color" : ""}`, controls.length + 1));
3370
+ }
3371
+ }
3372
+ return controls;
3373
+ }
3374
+ function vectorEffectControls(effect) {
3375
+ const declared = vectorControls(effect.motion);
3376
+ const names = new Set(declared.map((c) => c.name));
3377
+ const listed = (effect.controls ?? []).filter((c) => !names.has(c.name));
3378
+ return [...declared, ...listed.map((c, i) => vectorControlDef(c, declared.length + i + 1))];
3379
+ }
3380
+ function vectorControlIssues(motion) {
3381
+ const issues = [];
3382
+ const declarations = /* @__PURE__ */ new Map();
3383
+ for (const text of motionStrings(motion)) {
3384
+ for (const [whole, name, type] of text.matchAll(PLACEHOLDER)) {
3385
+ if (type === void 0) continue;
3386
+ if (!VECTOR_CONTROL_TYPES.includes(type))
3387
+ issues.push(`${whole}: a control's type is one of ${VECTOR_CONTROL_TYPES.join(", ")}`);
3388
+ else if (declarations.has(name) && declarations.get(name) !== whole)
3389
+ issues.push(`"${name}" is declared twice; declare it once and write {{${name}}} everywhere else`);
3390
+ else declarations.set(name, whole);
3391
+ }
3392
+ }
3393
+ return issues;
3394
+ }
3395
+ function written(control, value) {
3396
+ if (control.color && Array.isArray(value)) return rgbToHex(value);
3397
+ if (Array.isArray(value)) return value.join(" ");
3398
+ return value;
3399
+ }
3400
+ var escapeMarkup = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
3401
+ function vectorControlDef(control, line = 0) {
3402
+ const min = control.min ?? 0;
3403
+ return {
3404
+ name: control.name,
3405
+ type: control.type,
3406
+ label: control.label ?? control.name,
3407
+ color: control.color ?? false,
3408
+ min,
3409
+ max: control.max ?? Math.max(min + 1, 1),
3410
+ step: control.step ?? (control.type === "int" ? 1 : 0.01),
3411
+ default: control.default,
3412
+ line
3413
+ };
3414
+ }
3415
+ function renderVectorEffect(effect, values = {}) {
3416
+ const filled = {};
3417
+ const placeholders = /* @__PURE__ */ new Map();
3418
+ for (const control of vectorEffectControls(effect)) {
3419
+ const given = values[control.name];
3420
+ const check = given === void 0 ? void 0 : checkControlValue(control, given);
3421
+ filled[control.name] = check?.ok ? check.value : control.default;
3422
+ placeholders.set(control.name, written(control, filled[control.name]));
3423
+ }
3424
+ for (const [name, value] of Object.entries(effect.derived?.(filled) ?? {})) placeholders.set(name, value);
3425
+ const fill = (text, markup) => {
3426
+ const whole = /^\{\{\s*([A-Za-z_]\w*)\s*(?::[^}]*)?\}\}$/.exec(text);
3427
+ if (!markup && whole && placeholders.has(whole[1])) return placeholders.get(whole[1]);
3428
+ return text.replace(PLACEHOLDER, (match, name) => {
3429
+ if (!placeholders.has(name)) return match;
3430
+ const value = String(placeholders.get(name));
3431
+ return markup ? escapeMarkup(value) : value;
3432
+ });
3433
+ };
3434
+ const walk = (v) => {
3435
+ if (typeof v === "string") return fill(v, false);
3436
+ if (Array.isArray(v)) return v.map(walk);
3437
+ if (v && typeof v === "object") return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)]));
3438
+ return v;
3439
+ };
3440
+ const { svg, ...rest } = effect.motion;
3441
+ return { svg: fill(svg.replace(/<!--[\s\S]*?-->/g, ""), true), ...walk(rest) };
3442
+ }
3443
+ function colorControl(name, hex, label) {
3444
+ return { name, type: "vec3", color: true, default: hexToRgb(hex) ?? [1, 1, 1], label: label ?? name };
3445
+ }
3446
+ function numberControl(name, value, min, max, label, step) {
3447
+ return { name, type: "float", default: value, min, max, label: label ?? name, step };
3448
+ }
3449
+
3450
+ // src/catalog/vector.ts
1677
3451
  var spinner = {
1678
3452
  kind: "vector",
1679
3453
  id: "spinner",
@@ -2099,7 +3873,7 @@ var dripText = {
2099
3873
  id: "drip-text",
2100
3874
  name: "Drip text",
2101
3875
  description: "A chunky outlined title popping in, then oozing slime drips from its letters.",
2102
- category: "icon",
3876
+ category: "text",
2103
3877
  tags: ["title", "text", "drip", "slime", "y2k", "headline"],
2104
3878
  hold: 1200,
2105
3879
  presets: [
@@ -2157,6 +3931,32 @@ var dripText = {
2157
3931
  };
2158
3932
  })()
2159
3933
  };
3934
+ var VIEWFINDER_SECONDS = 10;
3935
+ var viewfinder = {
3936
+ kind: "vector",
3937
+ id: "viewfinder",
3938
+ name: "Viewfinder",
3939
+ description: "A camera viewfinder over the picture: corner brackets, a blinking REC light and a running timecode.",
3940
+ category: "overlay",
3941
+ tags: ["overlay", "camera", "recording", "vlog", "frame"],
3942
+ hold: 0,
3943
+ presets: [
3944
+ { name: "Night vision", values: { color: [0.45, 1, 0.45], rec: [0.45, 1, 0.45] } },
3945
+ { name: "Live", values: { label: "LIVE", timecode: false } }
3946
+ ],
3947
+ derived: (v) => ({ timecodeOpacity: v.timecode ? 1 : 0 }),
3948
+ motion: {
3949
+ svg: `<svg viewBox="0 0 320 180" font-family="ui-monospace, Menlo, Consolas, monospace"><!-- {{color: color default=#FFFFFF label='Frame'}} {{rec: color default=#EF4444 label='Record light'}} {{timecode: bool default=true label='Timecode'}} --><g fill="none" stroke="{{color}}" stroke-width="2.5" stroke-linecap="square"><path d="M16 42 V16 H42"/><path d="M278 16 H304 V42"/><path d="M304 138 V164 H278"/><path d="M42 164 H16 V138"/><path d="M154 90 H166 M160 84 V96" stroke-width="1.5" opacity="0.8"/></g><circle id="dot" cx="34" cy="33" r="4.5" fill="{{rec}}"/><text x="44" y="37.5" font-size="12" font-weight="700" fill="{{color}}">{{label: text default=REC label='Label'}}</text><text x="296" y="37.5" text-anchor="end" font-size="12" fill="{{color}}" opacity="{{timecodeOpacity}}">00:00:<tspan id="sec">00</tspan>:<tspan id="ff">00</tspan></text></svg>`,
3950
+ animate: Array.from({ length: VIEWFINDER_SECONDS }, (_, s) => [
3951
+ { id: "sec", count: [s, s], format: s < 10 ? "0{n}" : "{n}", delay: s * 1e3, ms: 1 },
3952
+ // Frames 00–24: the single digits padded with a zero, then the rest.
3953
+ { id: "ff", count: [0, 9.49], format: "0{n}", delay: s * 1e3, ms: 400, ease: "linear" },
3954
+ { id: "ff", count: [10, 24.49], format: "{n}", delay: s * 1e3 + 400, ms: 600, ease: "linear" },
3955
+ { id: "dot", attrs: { opacity: [0, 1] }, delay: s * 1e3, ms: 1 },
3956
+ { id: "dot", attrs: { opacity: [1, 0] }, delay: s * 1e3 + 600, ms: 1 }
3957
+ ]).flat()
3958
+ }
3959
+ };
2160
3960
  var vectorEffects = [
2161
3961
  spinner,
2162
3962
  bouncingDots,
@@ -2175,7 +3975,8 @@ var vectorEffects = [
2175
3975
  confetti,
2176
3976
  blobMorph,
2177
3977
  dripText,
2178
- flowNetwork
3978
+ flowNetwork,
3979
+ viewfinder
2179
3980
  ];
2180
3981
 
2181
3982
  // src/catalog/index.ts
@@ -2216,11 +4017,14 @@ var SHADER_EFFECTS = [
2216
4017
  imageDuotone,
2217
4018
  imageHalftone,
2218
4019
  imagePixelate,
4020
+ ...imageTreatments,
2219
4021
  crt,
2220
4022
  filmGrain,
4023
+ ...overlayEffects,
4024
+ ...textShaders,
2221
4025
  ...transitions
2222
4026
  ];
2223
- var EFFECTS = [...SHADER_EFFECTS, ...vectorEffects].sort(
4027
+ var EFFECTS = [...SHADER_EFFECTS, ...vectorEffects, ...textEffects].sort(
2224
4028
  (a, b) => EFFECT_CATEGORIES.indexOf(a.category) - EFFECT_CATEGORIES.indexOf(b.category)
2225
4029
  );
2226
4030
  var byId = new Map(EFFECTS.map((e) => [e.id, e]));
@@ -2256,6 +4060,7 @@ export {
2256
4060
  glsl,
2257
4061
  svgDataUrl,
2258
4062
  SAMPLE_IMAGES,
4063
+ textImage,
2259
4064
  EFFECT_CATEGORIES,
2260
4065
  VECTOR_CONTROL_TYPES,
2261
4066
  vectorControls,
@@ -2272,4 +4077,4 @@ export {
2272
4077
  effectControls,
2273
4078
  effectDefaults
2274
4079
  };
2275
- //# sourceMappingURL=chunk-J2WIWT4J.js.map
4080
+ //# sourceMappingURL=chunk-FHXOMIQY.js.map