@idle-screens/schema 3.12.0 → 3.12.1

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.
package/dist/index.js CHANGED
@@ -279,7 +279,13 @@ function lerpValue(from, to, k, specRoot = false, rootKey) {
279
279
  const walk = (a, b, key, root = false) => {
280
280
  if (typeof a === "number" && typeof b === "number") {
281
281
  const v = a + (b - a) * kk;
282
- return key === "count" ? Math.max(1, Math.round(v)) : v;
282
+ if (key === "count") return Math.max(1, Math.round(v));
283
+ if (key === "octaves") return Math.round(v);
284
+ if (key === "quantize") {
285
+ const q = Math.round(v);
286
+ return q < 2 ? 0 : q;
287
+ }
288
+ return v;
283
289
  }
284
290
  if (typeof a === "string" && typeof b === "string" && HEX.test(a) && HEX.test(b)) {
285
291
  return lerpHex(a, b, kk);
@@ -2019,12 +2025,75 @@ function strokeTaper(u) {
2019
2025
  function strokeWidthPx(s, scale) {
2020
2026
  return Math.max(0.5, (s.width ?? (scale === 1 ? 2 : 2e-3)) * scale);
2021
2027
  }
2022
- function featherAlphas(steps, additive) {
2023
- const out = [];
2024
- for (let k = 1; k <= steps; k++) out.push(additive ? 1 / steps : 1 / (steps - k + 1));
2025
- return out;
2028
+ function featherRamp(u, feather) {
2029
+ const band = Math.max(0, Math.min(1, feather)) / 2;
2030
+ const d = Math.min(u, 1 - u);
2031
+ if (band <= 0) return d >= 0 ? 1 : 0;
2032
+ return Math.max(0, Math.min(1, d / band));
2033
+ }
2034
+ var FEATHER_MASK_SIZE = 128;
2035
+ function ribbonSides(pts, half) {
2036
+ const n = pts.length;
2037
+ const left = [];
2038
+ const right = [];
2039
+ if (n < 2) return { left, right };
2040
+ const dir = (a, b) => {
2041
+ const dx = b.x - a.x;
2042
+ const dy = b.y - a.y;
2043
+ const l = Math.hypot(dx, dy);
2044
+ return l > 1e-9 ? { x: dx / l, y: dy / l } : { x: 1, y: 0 };
2045
+ };
2046
+ for (let i = 0; i < n; i++) {
2047
+ const tIn = i > 0 ? dir(pts[i - 1], pts[i]) : dir(pts[0], pts[1]);
2048
+ const tOut = i < n - 1 ? dir(pts[i], pts[i + 1]) : tIn;
2049
+ let tx = tIn.x + tOut.x;
2050
+ let ty = tIn.y + tOut.y;
2051
+ const tl = Math.hypot(tx, ty);
2052
+ if (tl < 1e-9) {
2053
+ tx = tOut.x;
2054
+ ty = tOut.y;
2055
+ } else {
2056
+ tx /= tl;
2057
+ ty /= tl;
2058
+ }
2059
+ const hw = half[i] / Math.max(0.5, tx * tOut.x + ty * tOut.y);
2060
+ const p = pts[i];
2061
+ left.push({ x: p.x - ty * hw, y: p.y + tx * hw });
2062
+ right.push({ x: p.x + ty * hw, y: p.y - tx * hw });
2063
+ }
2064
+ return { left, right };
2065
+ }
2066
+ function strokeOutline(pts, lw, taper = true) {
2067
+ const n = pts.length;
2068
+ if (n < 2) return [];
2069
+ const half = (i) => Math.max(0.5, taper ? lw * strokeTaper(i / (n - 1)) : lw) / 2;
2070
+ const dir = (a, b) => {
2071
+ const dx = b.x - a.x;
2072
+ const dy = b.y - a.y;
2073
+ const l = Math.hypot(dx, dy);
2074
+ return l > 1e-9 ? { x: dx / l, y: dy / l } : { x: 1, y: 0 };
2075
+ };
2076
+ const { left, right } = ribbonSides(pts, Array.from({ length: n }, (_, i) => half(i)));
2077
+ const cap = (c, r, from) => {
2078
+ const out = [];
2079
+ const steps = 6;
2080
+ for (let k = 1; k < steps; k++) {
2081
+ const a = from - Math.PI * k / steps;
2082
+ out.push({ x: c.x + Math.cos(a) * r, y: c.y + Math.sin(a) * r });
2083
+ }
2084
+ return out;
2085
+ };
2086
+ const endT = dir(pts[n - 2], pts[n - 1]);
2087
+ const startT = dir(pts[0], pts[1]);
2088
+ const endA = Math.atan2(endT.y, endT.x) + Math.PI / 2;
2089
+ const startA = Math.atan2(startT.y, startT.x) - Math.PI / 2;
2090
+ return [
2091
+ ...left,
2092
+ ...cap(pts[n - 1], half(n - 1), endA),
2093
+ ...right.reverse(),
2094
+ ...cap(pts[0], half(0), startA)
2095
+ ];
2026
2096
  }
2027
- var FEATHER_STEPS = 6;
2028
2097
 
2029
2098
  // src/simulate.ts
2030
2099
  function entityScales(spec, w, h) {
@@ -3088,6 +3157,79 @@ function screen(fp, pat, alpha, off, w, h) {
3088
3157
  p.restore();
3089
3158
  }
3090
3159
 
3160
+ // src/trail.ts
3161
+ function trailFillBudget(layerCount) {
3162
+ return Math.max(4, Math.min(48, Math.floor(240 / Math.max(1, layerCount))));
3163
+ }
3164
+ function trailEchoes(trail, t, headAlpha, headSize, posAt, w, h, wrap2, fillBudget, minAlpha = 0, additive = false) {
3165
+ const fade = trail.fade ?? 1;
3166
+ const n = Math.min(Math.ceil(trail.length / 50), LIMITS.maxTrailSamples);
3167
+ const alphaAtK = (k) => headAlpha * (1 - k * fade);
3168
+ const radiusAtK = (k) => headSize / 2 * (1 - k * 0.7);
3169
+ const jumps = (a, b) => wrap2 && (Math.abs(b.x - a.x) > w / 2 || Math.abs(b.y - a.y) > h / 2);
3170
+ const head = posAt(t);
3171
+ const base = [];
3172
+ let prev = head;
3173
+ for (let s = 1; s <= n; s++) {
3174
+ const k = s / n;
3175
+ const pastT = t - k * trail.length;
3176
+ if (pastT < 0) break;
3177
+ const pos = posAt(pastT);
3178
+ if (jumps(prev, pos)) break;
3179
+ prev = pos;
3180
+ const a = alphaAtK(k);
3181
+ if (a <= minAlpha) break;
3182
+ const r = radiusAtK(k);
3183
+ if (r < 0.2) break;
3184
+ base.push({ t: pastT, x: pos.x, y: pos.y, k, alpha: a, r });
3185
+ }
3186
+ if (base.length === 0) return base;
3187
+ const need = [];
3188
+ let total = 0;
3189
+ for (let i = 0; i < base.length; i++) {
3190
+ const a = i === 0 ? head : base[i - 1];
3191
+ const b = base[i];
3192
+ const d = Math.hypot(b.x - a.x, b.y - a.y);
3193
+ const m = d > b.r ? Math.ceil(d / b.r) - 1 : 0;
3194
+ need.push(m);
3195
+ total += m;
3196
+ }
3197
+ if (total === 0) return base;
3198
+ const scale = total > fillBudget ? fillBudget / total : 1;
3199
+ const out = [];
3200
+ let last = head;
3201
+ for (let i = 0; i < base.length; i++) {
3202
+ const b = base[i];
3203
+ const m = Math.floor(need[i] * scale);
3204
+ if (m === 0) {
3205
+ out.push(b);
3206
+ last = b;
3207
+ continue;
3208
+ }
3209
+ const a = i === 0 ? head : base[i - 1];
3210
+ const spacing = Math.hypot(b.x - a.x, b.y - a.y) / (m + 1);
3211
+ const share = (alpha, r) => {
3212
+ const k = Math.min(1, spacing / (2 * r));
3213
+ return additive ? alpha * k : 1 - Math.pow(1 - Math.min(1, alpha), k);
3214
+ };
3215
+ const t0 = i === 0 ? t : base[i - 1].t;
3216
+ const k0 = i === 0 ? 0 : base[i - 1].k;
3217
+ for (let j = 1; j <= m; j++) {
3218
+ const f = j / (m + 1);
3219
+ const tt = t0 + (b.t - t0) * f;
3220
+ const k = k0 + (b.k - k0) * f;
3221
+ const pos = posAt(tt);
3222
+ if (jumps(last, pos)) continue;
3223
+ last = pos;
3224
+ const r = radiusAtK(k);
3225
+ out.push({ t: tt, x: pos.x, y: pos.y, k, alpha: share(alphaAtK(k), r), r });
3226
+ }
3227
+ out.push({ ...b, alpha: share(b.alpha, b.r) });
3228
+ last = b;
3229
+ }
3230
+ return out;
3231
+ }
3232
+
3091
3233
  // src/compile.ts
3092
3234
  var DEFAULT_STEER_DUR = 1e3;
3093
3235
  function applyRetainedDeltas(spec, deltas) {
@@ -3285,6 +3427,15 @@ var SpecInstance = class {
3285
3427
  fieldShortSide;
3286
3428
  /** The `field` background's cached raster; null until a field is first painted. */
3287
3429
  field = null;
3430
+ featherWhite = /* @__PURE__ */ new Map();
3431
+ /** Per-entity pre-rendered feathered rects (see `blitFeather`), and the device pixels they hold. */
3432
+ featherBlits = /* @__PURE__ */ new WeakMap();
3433
+ featherBlitPx = 0;
3434
+ featherBlitN = 0;
3435
+ paintSeq = 0;
3436
+ /** Per layer: ribbon area painted this frame (px²), and whether its trails ribbon (see ribbonAllowed). */
3437
+ ribbonBudget = /* @__PURE__ */ new WeakMap();
3438
+ featherTints = /* @__PURE__ */ new Map();
3288
3439
  /**
3289
3440
  * The presentation pass when this instance applies a `finish`: the
3290
3441
  * visible canvas plus the cached screen tiles. When set, `this.canvas` is
@@ -3449,36 +3600,81 @@ var SpecInstance = class {
3449
3600
  const trail = built.layer.trail;
3450
3601
  if (!trail) return;
3451
3602
  const { ctx, w, h } = this;
3452
- const fade = trail.fade ?? 1;
3453
- const n = Math.min(Math.ceil(trail.length / 50), LIMITS.maxTrailSamples);
3454
3603
  const headAlpha = alphaAt(e, t) * lifeA;
3455
3604
  const headSize = sizeAt(e, t);
3456
3605
  const sprite = built.layer.sprite;
3606
+ const shaped = isShapedSprite(sprite) && sprite.kind !== "circle";
3607
+ const isSoft = sprite.kind === "circle" && !!sprite.soft;
3608
+ const hardCircle = sprite.kind === "circle" && !sprite.soft;
3609
+ const ribbon = hardCircle && this.ribbonAllowed(built);
3610
+ const posAt = (pt) => this.entityPos(e, pt, parentE);
3611
+ const fill = shaped || hardCircle ? 0 : trailFillBudget(built.entities.length);
3612
+ const echoes = trailEchoes(trail, t, headAlpha, headSize, posAt, w, h, built.layer.wrap !== false, fill, 0, built.layer.blend === "lighter");
3613
+ if (echoes.length === 0) return;
3614
+ const head = posAt(t);
3615
+ if (hardCircle) {
3616
+ const rb = this.ribbonBudget.get(built);
3617
+ let prev = head;
3618
+ let r0 = headSize / 2;
3619
+ for (const ec of echoes) {
3620
+ rb.area += Math.hypot(ec.x - prev.x, ec.y - prev.y) * (r0 + ec.r);
3621
+ prev = ec;
3622
+ r0 = ec.r;
3623
+ }
3624
+ }
3625
+ if (shaped) {
3626
+ for (const ec of echoes) this.drawEntity(built, e, ec.t, 1, parentE, void 0, { x: ec.x, y: ec.y, size: ec.r * 2, alpha: ec.alpha });
3627
+ return;
3628
+ }
3457
3629
  const resolvedColor = isShapedSprite(sprite) ? sprite.colors?.[e.colorIndex] ?? sprite.color : sprite.kind === "text" || sprite.kind === "textBlock" ? sprite.color ?? "#e6e8ef" : "#e6e8ef";
3458
- const isSoft = sprite.kind === "circle" && sprite.soft;
3459
- const wrap2 = built.layer.wrap !== false;
3460
- const head = this.entityPos(e, t, parentE);
3461
- let prevX = head.x;
3462
- let prevY = head.y;
3463
- for (let s = 1; s <= n; s++) {
3464
- const k = s / n;
3465
- const pastT = t - k * trail.length;
3466
- if (pastT < 0) break;
3467
- const pos = this.entityPos(e, pastT, parentE);
3468
- if (wrap2) {
3469
- const dx = pos.x - prevX;
3470
- const dy = pos.y - prevY;
3471
- if (Math.abs(dx) > w / 2 || Math.abs(dy) > h / 2) break;
3472
- }
3473
- prevX = pos.x;
3474
- prevY = pos.y;
3475
- const a = headAlpha * (1 - k * fade);
3476
- if (a <= 0) break;
3477
- const r = headSize / 2 * (1 - k * 0.7);
3478
- if (r < 0.2) break;
3479
- ctx.globalAlpha = a;
3630
+ if (ribbon) {
3631
+ const pts = [head];
3632
+ const radii = [headSize / 2];
3633
+ const alphas = [headAlpha];
3634
+ let prevT = t;
3635
+ for (const ec of echoes) {
3636
+ const p0 = pts[pts.length - 1];
3637
+ const r0 = radii[radii.length - 1];
3638
+ const a0 = alphas[alphas.length - 1];
3639
+ const sub = Math.max(1, Math.min(8, Math.ceil(Math.hypot(ec.x - p0.x, ec.y - p0.y) / 12)));
3640
+ for (let j = 1; j < sub; j++) {
3641
+ const f = j / sub;
3642
+ pts.push(posAt(prevT + (ec.t - prevT) * f));
3643
+ radii.push(r0 + (ec.r - r0) * f);
3644
+ alphas.push(a0 + (ec.alpha - a0) * f);
3645
+ }
3646
+ pts.push(ec);
3647
+ radii.push(ec.r);
3648
+ alphas.push(ec.alpha);
3649
+ prevT = ec.t;
3650
+ }
3651
+ const { left, right } = ribbonSides(pts, radii);
3652
+ ctx.fillStyle = resolvedColor;
3653
+ const tol = Math.max(4e-3, headAlpha * 0.04);
3654
+ let from = 0;
3655
+ while (from < pts.length - 1) {
3656
+ const a0 = alphas[from + 1];
3657
+ let to = from + 1;
3658
+ let sum = a0;
3659
+ while (to < pts.length - 1 && Math.abs(alphas[to + 1] - a0) <= tol) {
3660
+ sum += alphas[to + 1];
3661
+ to++;
3662
+ }
3663
+ ctx.globalAlpha = sum / (to - from);
3664
+ ctx.beginPath();
3665
+ ctx.moveTo(left[from].x, left[from].y);
3666
+ for (let i = from + 1; i <= to; i++) ctx.lineTo(left[i].x, left[i].y);
3667
+ for (let i = to; i >= from; i--) ctx.lineTo(right[i].x, right[i].y);
3668
+ ctx.closePath();
3669
+ ctx.fill();
3670
+ from = to;
3671
+ }
3672
+ return;
3673
+ }
3674
+ for (const ec of echoes) {
3675
+ ctx.globalAlpha = ec.alpha;
3480
3676
  if (isSoft) {
3481
- const g = ctx.createRadialGradient(pos.x, pos.y, 0, pos.x, pos.y, r);
3677
+ const g = ctx.createRadialGradient(ec.x, ec.y, 0, ec.x, ec.y, ec.r);
3482
3678
  g.addColorStop(0, resolvedColor);
3483
3679
  g.addColorStop(0.35, hexToRgba(resolvedColor, 0.75));
3484
3680
  g.addColorStop(1, hexToRgba(resolvedColor, 0));
@@ -3487,18 +3683,202 @@ var SpecInstance = class {
3487
3683
  ctx.fillStyle = resolvedColor;
3488
3684
  }
3489
3685
  ctx.beginPath();
3490
- ctx.arc(pos.x, pos.y, r, 0, Math.PI * 2);
3686
+ ctx.arc(ec.x, ec.y, ec.r, 0, Math.PI * 2);
3491
3687
  ctx.fill();
3492
3688
  }
3493
3689
  }
3494
- drawEntity(built, e, t, lifeA, parentE, paint) {
3690
+ /**
3691
+ * Whether this layer's hard-circle trails draw as ribbons this frame. A
3692
+ * ribbon paints its trail's whole length, so a layer of many fast, long
3693
+ * trails can paint far more pixels than its dots did — under `lighter` on
3694
+ * a CPU rasteriser that was 2.4× the frame time (60 trails, 3 s, 0.4/s).
3695
+ * Past a tenth of the viewport's area in ribbon (measured on the
3696
+ * previous frame) the layer falls back to the time-sample discs it always
3697
+ * drew; it returns to ribbons below 70 % of that, so a layer near the line
3698
+ * does not flicker between the two.
3699
+ */
3700
+ ribbonAllowed(built) {
3701
+ let rb = this.ribbonBudget.get(built);
3702
+ if (!rb) {
3703
+ rb = { seq: this.paintSeq, area: 0, ribbon: true };
3704
+ this.ribbonBudget.set(built, rb);
3705
+ }
3706
+ if (rb.seq !== this.paintSeq) {
3707
+ const budget = 0.1 * this.w * this.h;
3708
+ if (rb.ribbon && rb.area > budget) rb.ribbon = false;
3709
+ else if (!rb.ribbon && rb.area < 0.7 * budget) rb.ribbon = true;
3710
+ rb.seq = this.paintSeq;
3711
+ rb.area = 0;
3712
+ }
3713
+ return rb.ribbon;
3714
+ }
3715
+ /** A fresh 2D canvas (DOM or Offscreen), or null where none can be made. */
3716
+ makeCanvas(n) {
3717
+ try {
3718
+ const canvas = typeof document !== "undefined" ? document.createElement("canvas") : new OffscreenCanvas(n, n);
3719
+ canvas.width = n;
3720
+ canvas.height = n;
3721
+ const c = canvas.getContext("2d");
3722
+ return c ? { canvas, ctx: c } : null;
3723
+ } catch {
3724
+ return null;
3725
+ }
3726
+ }
3727
+ /**
3728
+ * A feathered rect from a per-entity bitmap of it, pre-rendered at device
3729
+ * resolution with its rotation baked in, copied 1:1 at a whole-pixel
3730
+ * offset. A smooth falloff is a sampled image draw — on a CPU rasteriser
3731
+ * (a Raspberry Pi wall) ~3× the six flat fills it replaced, ~10× when
3732
+ * rotated and stretched — while a 1:1 integer copy costs about one flat
3733
+ * fill. The bitmap is rebuilt only when the rect's size, rotation or
3734
+ * feather changes (in device pixels, to a quarter pixel / 0.06°), and
3735
+ * re-tinted (one copy + one flat fill) only when its colour changes, so a
3736
+ * drifting block or a colour glide stays cheap. A rect whose shape
3737
+ * changes every frame (`grow`, `spin`), a canvas under a rotating or
3738
+ * mirroring transform, or a scene past the 8 Mpx cache budget draws the
3739
+ * stretched mask directly instead. Returns false when it did not draw.
3740
+ */
3741
+ blitFeather(e, p, sz, rh, rot, color2, feather) {
3742
+ const ctx = this.ctx;
3743
+ const m = typeof ctx.getTransform === "function" ? ctx.getTransform() : null;
3744
+ if (!m || m.b !== 0 || m.c !== 0 || m.a <= 0 || m.d <= 0) return false;
3745
+ const cos = Math.abs(Math.cos(rot));
3746
+ const sin = Math.abs(Math.sin(rot));
3747
+ const bw = Math.ceil((cos * sz + sin * rh) * m.a) + 2;
3748
+ const bh = Math.ceil((sin * sz + cos * rh) * m.d) + 2;
3749
+ const shapeKey = `${feather}|${Math.round(sz * m.a * 4)}|${Math.round(rh * m.d * 4)}|${Math.round(rot * 1e3)}|${bw}x${bh}`;
3750
+ let b = this.featherBlits.get(e);
3751
+ if (!b || b.shapeKey !== shapeKey) {
3752
+ if (b) {
3753
+ if (b.seenKey !== shapeKey) {
3754
+ b.seenKey = shapeKey;
3755
+ b.seenAt = this.paintSeq;
3756
+ }
3757
+ if (b.seenAt === this.paintSeq) return false;
3758
+ }
3759
+ const px = bw * bh;
3760
+ const reuse = b && b.w === bw && b.h === bh;
3761
+ if (!reuse && this.featherBlitPx + px - (b ? b.w * b.h : 0) > 8e6) return false;
3762
+ const white = this.featherWhite.get(feather);
3763
+ if (!white) return false;
3764
+ const wc = reuse ? b.white : this.makeCanvas(1);
3765
+ const tc = reuse ? b.tint : this.makeCanvas(1);
3766
+ if (!wc || !tc) return false;
3767
+ if (!reuse) {
3768
+ wc.canvas.width = bw;
3769
+ wc.canvas.height = bh;
3770
+ tc.canvas.width = bw;
3771
+ tc.canvas.height = bh;
3772
+ this.featherBlitPx += px - (b ? b.w * b.h : 0);
3773
+ }
3774
+ const w2 = wc.ctx;
3775
+ w2.setTransform(1, 0, 0, 1, 0, 0);
3776
+ w2.clearRect(0, 0, bw, bh);
3777
+ w2.translate(bw / 2, bh / 2);
3778
+ w2.scale(m.a, m.d);
3779
+ if (rot) w2.rotate(rot);
3780
+ w2.imageSmoothingEnabled = true;
3781
+ w2.drawImage(white, -sz / 2, -rh / 2, sz, rh);
3782
+ w2.setTransform(1, 0, 0, 1, 0, 0);
3783
+ b = { shapeKey, w: bw, h: bh, white: wc, tint: tc, tintKey: "", tintAt: 0, stagger: this.featherBlitN++ % 8, seenKey: shapeKey, seenAt: this.paintSeq };
3784
+ this.featherBlits.set(e, b);
3785
+ }
3786
+ if (b.tintKey !== color2 && (b.tintKey === "" || this.paintSeq - b.tintAt >= 8)) {
3787
+ const t2 = b.tint.ctx;
3788
+ t2.setTransform(1, 0, 0, 1, 0, 0);
3789
+ t2.globalCompositeOperation = "copy";
3790
+ t2.drawImage(b.white.canvas, 0, 0);
3791
+ t2.globalCompositeOperation = "source-in";
3792
+ t2.fillStyle = color2;
3793
+ t2.fillRect(0, 0, b.w, b.h);
3794
+ t2.globalCompositeOperation = "source-over";
3795
+ b.tintKey = color2;
3796
+ b.tintAt = this.paintSeq - (b.stagger ?? 0);
3797
+ b.stagger = 0;
3798
+ }
3799
+ const cx = m.a * p.x + m.e;
3800
+ const cy = m.d * p.y + m.f;
3801
+ ctx.save();
3802
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
3803
+ ctx.drawImage(b.tint.canvas, Math.round(cx - b.w / 2), Math.round(cy - b.h / 2));
3804
+ ctx.restore();
3805
+ return true;
3806
+ }
3807
+ /**
3808
+ * A feathered rect's mask in `color`: FEATHER_MASK_SIZE² texels of
3809
+ * `featherRamp(u) × featherRamp(v)`, drawn stretched to the rect with
3810
+ * smoothing on, so the falloff is continuous at any size (X383).
3811
+ *
3812
+ * The ramp itself is built ONCE per feather (a white alpha mask, the only
3813
+ * per-pixel work). A colour is a cheap tint of it — clear, draw the mask,
3814
+ * `source-in` fill — into a pooled canvas, cached per colour × feather.
3815
+ * A colour glide or a palette larger than the pool re-tints a pooled
3816
+ * canvas (three GPU draws) rather than rebuilding pixels in JS.
3817
+ */
3818
+ featherMask(color2, feather) {
3819
+ const key = `${color2}|${feather}`;
3820
+ const hit = this.featherTints.get(key);
3821
+ if (hit) {
3822
+ this.featherTints.delete(key);
3823
+ this.featherTints.set(key, hit);
3824
+ return hit.canvas;
3825
+ }
3826
+ const N = FEATHER_MASK_SIZE;
3827
+ let white = this.featherWhite.get(feather);
3828
+ if (!white) {
3829
+ const made = this.makeCanvas(N);
3830
+ if (!made) return null;
3831
+ const img = made.ctx.createImageData(N, N);
3832
+ const data = img.data;
3833
+ const ramp = [];
3834
+ for (let i = 0; i < N; i++) ramp.push(featherRamp((i + 0.5) / N, feather));
3835
+ let o = 0;
3836
+ for (let y = 0; y < N; y++) {
3837
+ const ry = ramp[y];
3838
+ for (let x = 0; x < N; x++) {
3839
+ data[o++] = 255;
3840
+ data[o++] = 255;
3841
+ data[o++] = 255;
3842
+ data[o++] = Math.round(255 * ry * ramp[x]);
3843
+ }
3844
+ }
3845
+ made.ctx.putImageData(img, 0, 0);
3846
+ if (this.featherWhite.size >= 8) this.featherWhite.delete(this.featherWhite.keys().next().value);
3847
+ white = made.canvas;
3848
+ this.featherWhite.set(feather, white);
3849
+ }
3850
+ let slot = null;
3851
+ if (this.featherTints.size >= 48) {
3852
+ const oldest = this.featherTints.keys().next().value;
3853
+ slot = this.featherTints.get(oldest);
3854
+ this.featherTints.delete(oldest);
3855
+ } else {
3856
+ slot = this.makeCanvas(N);
3857
+ if (!slot) return null;
3858
+ }
3859
+ const tctx = slot.ctx;
3860
+ tctx.globalCompositeOperation = "copy";
3861
+ tctx.drawImage(white, 0, 0);
3862
+ tctx.globalCompositeOperation = "source-in";
3863
+ tctx.fillStyle = color2;
3864
+ tctx.fillRect(0, 0, N, N);
3865
+ tctx.globalCompositeOperation = "source-over";
3866
+ this.featherTints.set(key, slot);
3867
+ return slot.canvas;
3868
+ }
3869
+ /**
3870
+ * Paint one entity at time `t`. `echo` paints a trail echo instead: the
3871
+ * entity as it was at `t` (rotation, heading, values), but at the echo's
3872
+ * position, size and alpha.
3873
+ */
3874
+ drawEntity(built, e, t, lifeA, parentE, paint, echo) {
3495
3875
  const { ctx } = this;
3496
- const p = this.entityPos(e, t, parentE);
3876
+ const p = echo ? { x: echo.x, y: echo.y, flip: false } : this.entityPos(e, t, parentE);
3497
3877
  const sprite = paint?.sprite ?? built.layer.sprite;
3498
- const sz = sizeAt(e, t);
3878
+ const sz = echo ? echo.size : sizeAt(e, t);
3499
3879
  const rot = rotationAt(e, t);
3500
3880
  const unitScale = this.effSpec.units === "px" ? 1 : Math.min(this.w, this.h);
3501
- ctx.globalAlpha = alphaAt(e, t) * lifeA * (paint?.alpha ?? 1);
3881
+ ctx.globalAlpha = echo ? echo.alpha : alphaAt(e, t) * lifeA * (paint?.alpha ?? 1);
3502
3882
  if (sprite.kind === "ring") {
3503
3883
  const r = sz / 2;
3504
3884
  const resolvedColor = sprite.colors?.[e.colorIndex] ?? sprite.color;
@@ -3529,23 +3909,18 @@ var SpecInstance = class {
3529
3909
  if (sprite.kind === "rect") {
3530
3910
  const resolvedColor = sprite.colors?.[e.colorIndex] ?? sprite.color;
3531
3911
  const rh = e.size2 !== void 0 ? e.size2 * (e.size > 0 ? sz / e.size : 1) : sz;
3912
+ const feather = sprite.feather ?? 0;
3913
+ const mask = feather > 0 ? this.featherMask(resolvedColor, feather) : null;
3914
+ if (mask && this.blitFeather(e, p, sz, rh, rot, resolvedColor, feather)) return;
3532
3915
  ctx.save();
3533
3916
  ctx.translate(p.x, p.y);
3534
3917
  if (rot) ctx.rotate(rot);
3535
3918
  ctx.fillStyle = resolvedColor;
3536
- const feather = sprite.feather ?? 0;
3537
- if (feather > 0) {
3538
- const base = ctx.globalAlpha;
3539
- const additive = built.layer.blend === "lighter";
3540
- const alphas = featherAlphas(FEATHER_STEPS, additive);
3541
- for (let j = 1; j <= FEATHER_STEPS; j++) {
3542
- const f = 1 - feather * (j - 1) / FEATHER_STEPS;
3543
- const fw = sz * f;
3544
- const fh = rh * f;
3545
- ctx.globalAlpha = base * alphas[j - 1];
3546
- ctx.fillRect(-fw / 2, -fh / 2, fw, fh);
3547
- }
3548
- ctx.globalAlpha = base;
3919
+ if (mask) {
3920
+ const prevSmooth = ctx.imageSmoothingEnabled;
3921
+ ctx.imageSmoothingEnabled = true;
3922
+ ctx.drawImage(mask, -sz / 2, -rh / 2, sz, rh);
3923
+ ctx.imageSmoothingEnabled = prevSmooth;
3549
3924
  } else {
3550
3925
  ctx.fillRect(-sz / 2, -rh / 2, sz, rh);
3551
3926
  }
@@ -3603,13 +3978,13 @@ var SpecInstance = class {
3603
3978
  ctx.lineCap = "round";
3604
3979
  ctx.lineJoin = "round";
3605
3980
  if (sprite.taper) {
3606
- for (let i = 1; i < pts.length; i++) {
3607
- ctx.lineWidth = Math.max(0.5, lw * strokeTaper((i - 0.5) / (pts.length - 1)));
3608
- ctx.beginPath();
3609
- ctx.moveTo(pts[i - 1].x, pts[i - 1].y);
3610
- ctx.lineTo(pts[i].x, pts[i].y);
3611
- ctx.stroke();
3612
- }
3981
+ const outline = strokeOutline(pts, lw);
3982
+ ctx.fillStyle = resolvedColor;
3983
+ ctx.beginPath();
3984
+ ctx.moveTo(outline[0].x, outline[0].y);
3985
+ for (let i = 1; i < outline.length; i++) ctx.lineTo(outline[i].x, outline[i].y);
3986
+ ctx.closePath();
3987
+ ctx.fill();
3613
3988
  } else {
3614
3989
  ctx.lineWidth = lw;
3615
3990
  ctx.beginPath();
@@ -3863,6 +4238,7 @@ var SpecInstance = class {
3863
4238
  * showing through — the ghosting smear. */
3864
4239
  paintFrame(t, bgAlpha) {
3865
4240
  const { ctx } = this;
4241
+ this.paintSeq++;
3866
4242
  if (this.authored) this.resolveAuthored(t);
3867
4243
  if (!this.transparent) {
3868
4244
  ctx.globalAlpha = bgAlpha;
@@ -5300,31 +5676,36 @@ function luminanceGrid(spec, opts = {}) {
5300
5676
  else if (blend === "multiply") cells[idx] = cur * (1 - a * (1 - lum));
5301
5677
  else cells[idx] = cur * (1 - a) + lum * a;
5302
5678
  };
5303
- const splatTrail = (layer, e, tPass, lifeA, alphaScale) => {
5679
+ const splatTrail = (layer, e, tPass, lifeA, alphaScale, layerCount) => {
5304
5680
  const trail = layer.trail;
5305
5681
  if (!trail) return;
5306
- const fade = trail.fade ?? 1;
5307
- const n = Math.min(Math.ceil(trail.length / 50), LIMITS.maxTrailSamples);
5308
- const headAlpha = alphaAt(e, tPass) * lifeA;
5682
+ const headAlpha = alphaAt(e, tPass) * lifeA * alphaScale;
5309
5683
  const headSize = sizeAt(e, tPass);
5310
5684
  const lum = spriteLuma(layer, e);
5311
5685
  const soft = layer.sprite.kind === "circle" && !!layer.sprite.soft;
5312
- const wrapOn = layer.wrap !== false;
5313
- const head = posOf(scene, e, tPass);
5314
- let prevX = head.x;
5315
- let prevY = head.y;
5316
- for (let s = 1; s <= n; s++) {
5317
- const k = s / n;
5318
- const pastT = tPass - k * trail.length;
5319
- if (pastT < 0) break;
5320
- const pos = posOf(scene, e, pastT);
5321
- if (wrapOn && (Math.abs(pos.x - prevX) > w / 2 || Math.abs(pos.y - prevY) > h / 2)) break;
5322
- prevX = pos.x;
5323
- prevY = pos.y;
5324
- const a = headAlpha * (1 - k * fade) * alphaScale;
5325
- if (a <= 4e-3) break;
5326
- const rad = headSize / 2 * (1 - k * 0.7);
5327
- if (rad < 0.2) break;
5686
+ const echoes = trailEchoes(
5687
+ trail,
5688
+ tPass,
5689
+ headAlpha,
5690
+ headSize,
5691
+ (pt) => posOf(scene, e, pt),
5692
+ w,
5693
+ h,
5694
+ layer.wrap !== false,
5695
+ // As the renderer: a shape echoes at its time samples only; circles fill
5696
+ // (a hard circle's ribbon reads, on the coarse grid, as filled discs).
5697
+ isShapedSprite(layer.sprite) && layer.sprite.kind !== "circle" ? 0 : trailFillBudget(layerCount),
5698
+ 4e-3,
5699
+ layer.blend === "lighter"
5700
+ );
5701
+ for (const ec of echoes) {
5702
+ const a = ec.alpha;
5703
+ const rad = ec.r;
5704
+ if (layer.sprite.kind === "polygon" && rad * 2 >= POLYGON_OUTLINE_CELLS * Math.min(cellW, cellH)) {
5705
+ splatPolygonOutline(layer, layer.sprite, e, ec, rad * 2, Math.min(1, a), lum, ec.t);
5706
+ continue;
5707
+ }
5708
+ const pos = ec;
5328
5709
  const c0 = Math.max(0, Math.floor((pos.x - rad) / cellW));
5329
5710
  const c1 = Math.min(cols - 1, Math.floor((pos.x + rad) / cellW));
5330
5711
  const r0 = Math.max(0, Math.floor((pos.y - rad) / cellH));
@@ -5403,7 +5784,7 @@ function luminanceGrid(spec, opts = {}) {
5403
5784
  const lifeA = layer.opacity === void 0 && layer.group === void 0 ? lifeAlphaAt(layer.life, tPass) : lifeAlphaAt(layer.life, tPass) * paintOpacity(spec, layer);
5404
5785
  if (lifeA <= 0) continue;
5405
5786
  for (const e of entities) {
5406
- splatTrail(layer, e, tPass, lifeA, alphaScale);
5787
+ splatTrail(layer, e, tPass, lifeA, alphaScale, entities.length);
5407
5788
  const a = Math.min(1, alphaAt(e, tPass) * lifeA) * alphaScale;
5408
5789
  if (a <= 4e-3) continue;
5409
5790
  const lum = spriteLuma(layer, e);
@@ -6354,7 +6735,7 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
6354
6735
  const baseAlpha = Math.max(0, Math.min(1, e.alpha - e.pulseAmp)) * (s.kind === "textBlock" ? s.opacity ?? 1 : 1);
6355
6736
  const alpha = layer.opacity === void 0 && layer.group === void 0 ? baseAlpha : baseAlpha * paintOpacity(spec, layer);
6356
6737
  const str = s.kind === "textBlock" ? s.text : s.strings[e.spriteIndex] ?? s.strings[0] ?? "";
6357
- textBoxes.push({ li, label, alpha, str, ...box });
6738
+ textBoxes.push({ li, label, alpha, str, ...box, rawW: raw.x1 - raw.x0, rawH: raw.y1 - raw.y0 });
6358
6739
  }
6359
6740
  }
6360
6741
  const asFraction = (b) => ({
@@ -6409,6 +6790,45 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
6409
6790
  });
6410
6791
  }
6411
6792
  }
6793
+ const cellFlagged = /* @__PURE__ */ new Set();
6794
+ for (let i = 0; i < textBoxes.length; i++) {
6795
+ const a = textBoxes[i];
6796
+ if (cellFlagged.has(a.li)) continue;
6797
+ const layer = spec.layers[a.li];
6798
+ const layout = layer.layout;
6799
+ if (layout?.type !== "list" && layout?.type !== "table") continue;
6800
+ for (let j = i + 1; j < textBoxes.length; j++) {
6801
+ const b = textBoxes[j];
6802
+ if (b.li !== a.li) continue;
6803
+ const ix = Math.min(a.x1, b.x1) - Math.max(a.x0, b.x0);
6804
+ const iy = Math.min(a.y1, b.y1) - Math.max(a.y0, b.y0);
6805
+ if (ix <= 0 || iy <= 0) continue;
6806
+ const smaller = Math.min((a.x1 - a.x0) * (a.y1 - a.y0), (b.x1 - b.x0) * (b.y1 - b.y0));
6807
+ if (smaller <= 0 || ix * iy / smaller <= 0.1) continue;
6808
+ cellFlagged.add(a.li);
6809
+ const cells = textBoxes.filter((x) => x.li === a.li);
6810
+ const dflt = scale === 1 ? LIMITS.defaultListGapPx : LIMITS.defaultListGap;
6811
+ const g = layout.gap ?? dflt;
6812
+ const gx = typeof g === "number" ? g : g.x ?? dflt;
6813
+ const gy = typeof g === "number" ? g : g.y ?? dflt;
6814
+ const cols = layout.type === "list" ? 1 : Math.max(1, Math.round(layout.columns));
6815
+ const unitPx = (v) => scale === 1 ? v : v * scale;
6816
+ const fromPx = (px) => scale === 1 ? Math.ceil(px) : Math.ceil(px / scale * 100) / 100;
6817
+ const maxW = cells.reduce((m, c) => Math.max(m, c.rawW), 0);
6818
+ const maxH = cells.reduce((m, c) => Math.max(m, c.rawH), 0);
6819
+ const fixes = [];
6820
+ const across = cols > 1 && maxW > unitPx(gx);
6821
+ if (across) fixes.push(`its labels are up to ~${Math.round(maxW)}px wide against a ${Math.round(unitPx(gx))}px column pitch (gap${typeof g === "number" ? "" : ".x"} ${gx}) \u2014 raise gap${typeof g === "number" ? "" : ".x"} to \u2265 ${fromPx(maxW * 1.1)}`);
6822
+ if (!across || maxH > unitPx(gy)) fixes.push(`rows are ~${Math.round(maxH)}px tall against a ${Math.round(unitPx(gy))}px row pitch (gap${typeof g === "number" ? "" : ".y"} ${gy}) \u2014 raise gap${typeof g === "number" ? "" : ".y"} to \u2265 ${fromPx(maxH * 1.1)}`);
6823
+ warnings.push({
6824
+ path: `layers[${a.li}].layout.gap`,
6825
+ code: "layout-text-overlap",
6826
+ message: `${a.label} cells overlap at ${w}\xD7${h}: a ${layout.type}'s \`gap\` is the pitch between cell anchors, not the space between cells; ${fixes.join("; and ")}, or shrink the text`,
6827
+ boxes: [asFraction(a), asFraction(b)]
6828
+ });
6829
+ break;
6830
+ }
6831
+ }
6412
6832
  const readFlagged = /* @__PURE__ */ new Set();
6413
6833
  for (const b of textBoxes) {
6414
6834
  if (readFlagged.has(b.li)) continue;