@pixerate/flair 0.6.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 = `
@@ -577,17 +601,19 @@ var bokeh = {
577
601
  tags: ["background", "festive", "dreamy", "night"],
578
602
  source: glsl(
579
603
  `// Bokeh: soft out-of-focus lights drifting.
580
- uniform vec3 uBackground; // @control color default=#0B1026 label="Background"
581
- uniform vec3 uWarm; // @control color default=#FFC56E label="Warm light"
582
- uniform vec3 uCool; // @control color default=#6EC8FF label="Cool light"
583
- uniform int uCount; // @control min=4 max=60 default=30 label="Lights"
584
- uniform float uSize; // @control min=0.02 max=0.25 default=0.08 label="Size"
585
- uniform float uSpeed; // @control min=0 max=1 default=0.15 label="Drift"`,
604
+ uniform vec3 uBackground; // @control color default=#0B1026 label="Background"
605
+ uniform float uBackgroundAlpha; // @control min=0 max=1 default=1 label="Background opacity"
606
+ uniform vec3 uWarm; // @control color default=#FFC56E label="Warm light"
607
+ uniform vec3 uCool; // @control color default=#6EC8FF label="Cool light"
608
+ uniform int uCount; // @control min=4 max=60 default=30 label="Lights"
609
+ uniform float uSize; // @control min=0.02 max=0.25 default=0.08 label="Size"
610
+ uniform float uSpeed; // @control min=0 max=1 default=0.15 label="Drift"`,
586
611
  HASH,
587
612
  `void mainImage(out vec4 fragColor, in vec2 fragCoord) {
588
613
  vec2 p = fragCoord / iResolution.y;
589
614
  float aspect = iResolution.x / iResolution.y;
590
- vec3 col = uBackground * (0.7 + 0.3 * fragCoord.y / iResolution.y);
615
+ vec3 col = uBackground * (0.7 + 0.3 * fragCoord.y / iResolution.y) * uBackgroundAlpha;
616
+ float lightAlpha = 0.0;
591
617
  for (int i = 0; i < 60; i++) {
592
618
  if (i >= uCount) break;
593
619
  float fi = float(i);
@@ -599,9 +625,12 @@ uniform float uSpeed; // @control min=0 max=1 default=0.15 label="Drift"`,
599
625
  float d = length(p - c);
600
626
  float disc = smoothstep(r, r * 0.85, d) * (0.6 + 0.4 * smoothstep(r * 0.4, r, d));
601
627
  vec3 light = mix(uWarm, uCool, step(0.5, hash12(vec2(fi, 1.3))));
602
- col += light * disc * (0.15 + 0.25 * (1.0 - depth));
628
+ float weight = disc * (0.15 + 0.25 * (1.0 - depth));
629
+ col += light * weight;
630
+ lightAlpha += weight;
603
631
  }
604
- fragColor = vec4(col, 1.0);
632
+ float a = clamp(uBackgroundAlpha + lightAlpha * (1.0 - uBackgroundAlpha), 0.0, 1.0);
633
+ fragColor = vec4(col, a);
605
634
  }`
606
635
  )
607
636
  };
@@ -645,18 +674,21 @@ var sparkles = {
645
674
  tags: ["overlay", "magic", "celebration", "premium"],
646
675
  source: glsl(
647
676
  `// Sparkles: four-pointed glints twinkling across the frame.
648
- uniform vec3 uBackground; // @control color default=#1E1B4B label="Background"
649
- uniform vec3 uSparkle; // @control color default=#FFFFFF label="Sparkle"
650
- uniform float uDensity; // @control min=2 max=30 default=10 label="Density"
651
- uniform float uSize; // @control min=0.05 max=0.5 default=0.35 label="Size"
652
- uniform float uSpeed; // @control min=0 max=4 default=1 label="Twinkle speed"`,
677
+ uniform vec3 uBackground; // @control color default=#1E1B4B label="Background"
678
+ uniform float uBackgroundAlpha; // @control min=0 max=1 default=1 label="Background opacity"
679
+ uniform vec3 uSparkle; // @control color default=#FFFFFF label="Sparkle"
680
+ uniform float uDensity; // @control min=2 max=30 default=10 label="Density"
681
+ uniform float uChance; // @control min=0.02 max=1 default=1 label="Sparsity"
682
+ uniform float uSize; // @control min=0.05 max=0.5 default=0.35 label="Size"
683
+ uniform float uSpeed; // @control min=0 max=4 default=1 label="Twinkle speed"`,
653
684
  HASH,
654
685
  `void mainImage(out vec4 fragColor, in vec2 fragCoord) {
655
686
  vec2 p = fragCoord / iResolution.y * uDensity;
656
- vec3 col = uBackground;
687
+ float glint = 0.0;
657
688
  for (int layer = 0; layer < 2; layer++) {
658
689
  vec2 q = p * (1.0 + float(layer) * 0.6) + float(layer) * 7.3;
659
690
  vec2 id = floor(q);
691
+ float keep = step(1.0 - uChance, hash12(id + 19.0 + float(layer) * 31.0));
660
692
  vec2 f = fract(q) - 0.5;
661
693
  vec2 o = hash22(id) - 0.5;
662
694
  vec2 d = f - o * 0.6;
@@ -665,10 +697,12 @@ uniform float uSpeed; // @control min=0 max=4 default=1 label="Twinkle speed
665
697
  float r = length(d);
666
698
  float rays = max(0.0, 1.0 - abs(d.x * d.y) * 3000.0);
667
699
  float core = 0.01 / (r + 0.01);
668
- float star = (rays * 0.8 + core) * smoothstep(uSize, 0.0, r) * tw;
669
- col += uSparkle * star;
700
+ float star = (rays * 0.8 + core) * smoothstep(uSize, 0.0, r) * tw * keep;
701
+ glint += star;
670
702
  }
671
- fragColor = vec4(col, 1.0);
703
+ vec3 col = uBackground * uBackgroundAlpha + uSparkle * glint;
704
+ float a = clamp(uBackgroundAlpha + glint * (1.0 - uBackgroundAlpha), 0.0, 1.0);
705
+ fragColor = vec4(col, a);
672
706
  }`
673
707
  )
674
708
  };
@@ -1404,6 +1438,483 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
1404
1438
  ]
1405
1439
  };
1406
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
+
1407
1918
  // src/catalog/shaders/retro.ts
1408
1919
  var synthwaveGrid = {
1409
1920
  kind: "shader",
@@ -1548,114 +2059,1387 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
1548
2059
  ]
1549
2060
  };
1550
2061
 
1551
- // src/catalog/types.ts
1552
- var EFFECT_CATEGORIES = [
1553
- "gradient",
1554
- "organic",
1555
- "pattern",
1556
- "light",
1557
- "space",
1558
- "retro",
1559
- "image",
1560
- "transition",
1561
- "loader",
1562
- "data",
1563
- "icon"
1564
- ];
1565
- var VECTOR_CONTROL_TYPES = ["float", "int", "bool", "color", "text"];
1566
- var PLACEHOLDER = /\{\{\s*([A-Za-z_]\w*)\s*(?::\s*([A-Za-z0-9]*)([^}]*))?\}\}/g;
1567
- function motionStrings(motion) {
1568
- const out = [];
1569
- const walk = (v) => {
1570
- if (typeof v === "string") out.push(v);
1571
- else if (Array.isArray(v)) v.forEach(walk);
1572
- else if (v && typeof v === "object") Object.values(v).forEach(walk);
1573
- };
1574
- walk(motion);
1575
- return out;
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;
1576
2094
  }
1577
- function vectorControls(motion) {
1578
- const controls = [];
1579
- const seen = /* @__PURE__ */ new Set();
1580
- for (const text of motionStrings(motion)) {
1581
- for (const [, name, type, attributes] of text.matchAll(PLACEHOLDER)) {
1582
- if (type === void 0 || seen.has(name) || !VECTOR_CONTROL_TYPES.includes(type)) continue;
1583
- seen.add(name);
1584
- const colour = type === "color";
1585
- controls.push(controlFromDeclaration(name, colour ? "vec3" : type, `${attributes}${colour ? " color" : ""}`, controls.length + 1));
1586
- }
1587
- }
1588
- return controls;
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));
1589
2099
  }
1590
- function vectorEffectControls(effect) {
1591
- const declared = vectorControls(effect.motion);
1592
- const names = new Set(declared.map((c) => c.name));
1593
- const listed = (effect.controls ?? []).filter((c) => !names.has(c.name));
1594
- return [...declared, ...listed.map((c, i) => vectorControlDef(c, declared.length + i + 1))];
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);
1595
2105
  }
1596
- function vectorControlIssues(motion) {
1597
- const issues = [];
1598
- const declarations = /* @__PURE__ */ new Map();
1599
- for (const text of motionStrings(motion)) {
1600
- for (const [whole, name, type] of text.matchAll(PLACEHOLDER)) {
1601
- if (type === void 0) continue;
1602
- if (!VECTOR_CONTROL_TYPES.includes(type))
1603
- issues.push(`${whole}: a control's type is one of ${VECTOR_CONTROL_TYPES.join(", ")}`);
1604
- else if (declarations.has(name) && declarations.get(name) !== whole)
1605
- issues.push(`"${name}" is declared twice; declare it once and write {{${name}}} everywhere else`);
1606
- else declarations.set(name, whole);
1607
- }
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)));
1608
2115
  }
1609
- return issues;
1610
- }
1611
- function written(control, value) {
1612
- if (control.color && Array.isArray(value)) return rgbToHex(value);
1613
- if (Array.isArray(value)) return value.join(" ");
1614
- return value;
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);
1615
2119
  }
1616
- var escapeMarkup = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
1617
- function vectorControlDef(control, line = 0) {
1618
- const min = control.min ?? 0;
1619
- return {
1620
- name: control.name,
1621
- type: control.type,
1622
- label: control.label ?? control.name,
1623
- color: control.color ?? false,
1624
- min,
1625
- max: control.max ?? Math.max(min + 1, 1),
1626
- step: control.step ?? (control.type === "int" ? 1 : 0.01),
1627
- default: control.default,
1628
- line
1629
- };
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;
1630
2130
  }
1631
- function renderVectorEffect(effect, values = {}) {
1632
- const filled = {};
1633
- const placeholders = /* @__PURE__ */ new Map();
1634
- for (const control of vectorEffectControls(effect)) {
1635
- const given = values[control.name];
1636
- const check = given === void 0 ? void 0 : checkControlValue(control, given);
1637
- filled[control.name] = check?.ok ? check.value : control.default;
1638
- placeholders.set(control.name, written(control, filled[control.name]));
2131
+
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));
1639
2147
  }
1640
- for (const [name, value] of Object.entries(effect.derived?.(filled) ?? {})) placeholders.set(name, value);
1641
- const fill = (text, markup) => {
1642
- const whole = /^\{\{\s*([A-Za-z_]\w*)\s*(?::[^}]*)?\}\}$/.exec(text);
1643
- if (!markup && whole && placeholders.has(whole[1])) return placeholders.get(whole[1]);
1644
- return text.replace(PLACEHOLDER, (match, name) => {
1645
- if (!placeholders.has(name)) return match;
1646
- const value = String(placeholders.get(name));
1647
- return markup ? escapeMarkup(value) : value;
1648
- });
1649
- };
1650
- const walk = (v) => {
1651
- if (typeof v === "string") return fill(v, false);
1652
- if (Array.isArray(v)) return v.map(walk);
1653
- if (v && typeof v === "object") return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)]));
1654
- return v;
1655
- };
1656
- const { svg, ...rest } = effect.motion;
1657
- return { svg: fill(svg.replace(/<!--[\s\S]*?-->/g, ""), true), ...walk(rest) };
1658
- }
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
+ }
1659
3443
  function colorControl(name, hex, label) {
1660
3444
  return { name, type: "vec3", color: true, default: hexToRgb(hex) ?? [1, 1, 1], label: label ?? name };
1661
3445
  }
@@ -2089,7 +3873,7 @@ var dripText = {
2089
3873
  id: "drip-text",
2090
3874
  name: "Drip text",
2091
3875
  description: "A chunky outlined title popping in, then oozing slime drips from its letters.",
2092
- category: "icon",
3876
+ category: "text",
2093
3877
  tags: ["title", "text", "drip", "slime", "y2k", "headline"],
2094
3878
  hold: 1200,
2095
3879
  presets: [
@@ -2147,6 +3931,32 @@ var dripText = {
2147
3931
  };
2148
3932
  })()
2149
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
+ };
2150
3960
  var vectorEffects = [
2151
3961
  spinner,
2152
3962
  bouncingDots,
@@ -2165,7 +3975,8 @@ var vectorEffects = [
2165
3975
  confetti,
2166
3976
  blobMorph,
2167
3977
  dripText,
2168
- flowNetwork
3978
+ flowNetwork,
3979
+ viewfinder
2169
3980
  ];
2170
3981
 
2171
3982
  // src/catalog/index.ts
@@ -2206,11 +4017,14 @@ var SHADER_EFFECTS = [
2206
4017
  imageDuotone,
2207
4018
  imageHalftone,
2208
4019
  imagePixelate,
4020
+ ...imageTreatments,
2209
4021
  crt,
2210
4022
  filmGrain,
4023
+ ...overlayEffects,
4024
+ ...textShaders,
2211
4025
  ...transitions
2212
4026
  ];
2213
- var EFFECTS = [...SHADER_EFFECTS, ...vectorEffects].sort(
4027
+ var EFFECTS = [...SHADER_EFFECTS, ...vectorEffects, ...textEffects].sort(
2214
4028
  (a, b) => EFFECT_CATEGORIES.indexOf(a.category) - EFFECT_CATEGORIES.indexOf(b.category)
2215
4029
  );
2216
4030
  var byId = new Map(EFFECTS.map((e) => [e.id, e]));
@@ -2246,6 +4060,7 @@ export {
2246
4060
  glsl,
2247
4061
  svgDataUrl,
2248
4062
  SAMPLE_IMAGES,
4063
+ textImage,
2249
4064
  EFFECT_CATEGORIES,
2250
4065
  VECTOR_CONTROL_TYPES,
2251
4066
  vectorControls,
@@ -2262,4 +4077,4 @@ export {
2262
4077
  effectControls,
2263
4078
  effectDefaults
2264
4079
  };
2265
- //# sourceMappingURL=chunk-WQYK64AA.js.map
4080
+ //# sourceMappingURL=chunk-FHXOMIQY.js.map