@idle-screens/schema 3.11.1 → 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,14 +2025,87 @@ 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
2099
+ function entityScales(spec, w, h) {
2100
+ const scale = spec.units === "px" ? 1 : Math.min(w, h);
2101
+ const refVp = spec.referenceViewport ?? LIMITS.referenceViewport;
2102
+ let countScale = scale > 1 ? Math.min(w, h) / refVp : 1;
2103
+ if (countScale > 1) {
2104
+ const rawTotal = spec.layers.reduce((s, l) => s + Math.round(l.count * countScale), 0);
2105
+ if (rawTotal > LIMITS.maxTotal) countScale *= LIMITS.maxTotal / rawTotal;
2106
+ }
2107
+ return { scale, countScale };
2108
+ }
2030
2109
  var WARP_NEAR = 0.08;
2031
2110
  var WARP_MAX_SCALE = 8;
2032
2111
  function spriteVariants(sprite) {
@@ -3078,6 +3157,79 @@ function screen(fp, pat, alpha, off, w, h) {
3078
3157
  p.restore();
3079
3158
  }
3080
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
+
3081
3233
  // src/compile.ts
3082
3234
  var DEFAULT_STEER_DUR = 1e3;
3083
3235
  function applyRetainedDeltas(spec, deltas) {
@@ -3275,6 +3427,15 @@ var SpecInstance = class {
3275
3427
  fieldShortSide;
3276
3428
  /** The `field` background's cached raster; null until a field is first painted. */
3277
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();
3278
3439
  /**
3279
3440
  * The presentation pass when this instance applies a `finish`: the
3280
3441
  * visible canvas plus the cached screen tiles. When set, `this.canvas` is
@@ -3310,13 +3471,7 @@ var SpecInstance = class {
3310
3471
  /** (Re)seed and place all entities — deterministic for the seed + viewport. */
3311
3472
  rebuild() {
3312
3473
  const rng = createRng(this.seed);
3313
- const scale = this.effSpec.units === "px" ? 1 : Math.min(this.w, this.h);
3314
- const refVp = this.effSpec.referenceViewport ?? LIMITS.referenceViewport;
3315
- let countScale = scale > 1 ? Math.min(this.w, this.h) / refVp : 1;
3316
- if (countScale > 1) {
3317
- const rawTotal = this.effSpec.layers.reduce((s, l) => s + Math.round(l.count * countScale), 0);
3318
- if (rawTotal > LIMITS.maxTotal) countScale *= LIMITS.maxTotal / rawTotal;
3319
- }
3474
+ const { scale, countScale } = entityScales(this.effSpec, this.w, this.h);
3320
3475
  this.layers = this.effSpec.layers.map((layer) => ({ layer, entities: buildEntities(layer, rng, this.w, this.h, scale, countScale) }));
3321
3476
  this.lastStructural = structuralSignature(this.effSpec);
3322
3477
  }
@@ -3445,36 +3600,81 @@ var SpecInstance = class {
3445
3600
  const trail = built.layer.trail;
3446
3601
  if (!trail) return;
3447
3602
  const { ctx, w, h } = this;
3448
- const fade = trail.fade ?? 1;
3449
- const n = Math.min(Math.ceil(trail.length / 50), LIMITS.maxTrailSamples);
3450
3603
  const headAlpha = alphaAt(e, t) * lifeA;
3451
3604
  const headSize = sizeAt(e, t);
3452
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
+ }
3453
3629
  const resolvedColor = isShapedSprite(sprite) ? sprite.colors?.[e.colorIndex] ?? sprite.color : sprite.kind === "text" || sprite.kind === "textBlock" ? sprite.color ?? "#e6e8ef" : "#e6e8ef";
3454
- const isSoft = sprite.kind === "circle" && sprite.soft;
3455
- const wrap2 = built.layer.wrap !== false;
3456
- const head = this.entityPos(e, t, parentE);
3457
- let prevX = head.x;
3458
- let prevY = head.y;
3459
- for (let s = 1; s <= n; s++) {
3460
- const k = s / n;
3461
- const pastT = t - k * trail.length;
3462
- if (pastT < 0) break;
3463
- const pos = this.entityPos(e, pastT, parentE);
3464
- if (wrap2) {
3465
- const dx = pos.x - prevX;
3466
- const dy = pos.y - prevY;
3467
- if (Math.abs(dx) > w / 2 || Math.abs(dy) > h / 2) break;
3468
- }
3469
- prevX = pos.x;
3470
- prevY = pos.y;
3471
- const a = headAlpha * (1 - k * fade);
3472
- if (a <= 0) break;
3473
- const r = headSize / 2 * (1 - k * 0.7);
3474
- if (r < 0.2) break;
3475
- 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;
3476
3676
  if (isSoft) {
3477
- 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);
3478
3678
  g.addColorStop(0, resolvedColor);
3479
3679
  g.addColorStop(0.35, hexToRgba(resolvedColor, 0.75));
3480
3680
  g.addColorStop(1, hexToRgba(resolvedColor, 0));
@@ -3483,18 +3683,202 @@ var SpecInstance = class {
3483
3683
  ctx.fillStyle = resolvedColor;
3484
3684
  }
3485
3685
  ctx.beginPath();
3486
- ctx.arc(pos.x, pos.y, r, 0, Math.PI * 2);
3686
+ ctx.arc(ec.x, ec.y, ec.r, 0, Math.PI * 2);
3487
3687
  ctx.fill();
3488
3688
  }
3489
3689
  }
3490
- 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) {
3491
3875
  const { ctx } = this;
3492
- const p = this.entityPos(e, t, parentE);
3876
+ const p = echo ? { x: echo.x, y: echo.y, flip: false } : this.entityPos(e, t, parentE);
3493
3877
  const sprite = paint?.sprite ?? built.layer.sprite;
3494
- const sz = sizeAt(e, t);
3878
+ const sz = echo ? echo.size : sizeAt(e, t);
3495
3879
  const rot = rotationAt(e, t);
3496
3880
  const unitScale = this.effSpec.units === "px" ? 1 : Math.min(this.w, this.h);
3497
- ctx.globalAlpha = alphaAt(e, t) * lifeA * (paint?.alpha ?? 1);
3881
+ ctx.globalAlpha = echo ? echo.alpha : alphaAt(e, t) * lifeA * (paint?.alpha ?? 1);
3498
3882
  if (sprite.kind === "ring") {
3499
3883
  const r = sz / 2;
3500
3884
  const resolvedColor = sprite.colors?.[e.colorIndex] ?? sprite.color;
@@ -3525,23 +3909,18 @@ var SpecInstance = class {
3525
3909
  if (sprite.kind === "rect") {
3526
3910
  const resolvedColor = sprite.colors?.[e.colorIndex] ?? sprite.color;
3527
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;
3528
3915
  ctx.save();
3529
3916
  ctx.translate(p.x, p.y);
3530
3917
  if (rot) ctx.rotate(rot);
3531
3918
  ctx.fillStyle = resolvedColor;
3532
- const feather = sprite.feather ?? 0;
3533
- if (feather > 0) {
3534
- const base = ctx.globalAlpha;
3535
- const additive = built.layer.blend === "lighter";
3536
- const alphas = featherAlphas(FEATHER_STEPS, additive);
3537
- for (let j = 1; j <= FEATHER_STEPS; j++) {
3538
- const f = 1 - feather * (j - 1) / FEATHER_STEPS;
3539
- const fw = sz * f;
3540
- const fh = rh * f;
3541
- ctx.globalAlpha = base * alphas[j - 1];
3542
- ctx.fillRect(-fw / 2, -fh / 2, fw, fh);
3543
- }
3544
- 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;
3545
3924
  } else {
3546
3925
  ctx.fillRect(-sz / 2, -rh / 2, sz, rh);
3547
3926
  }
@@ -3599,13 +3978,13 @@ var SpecInstance = class {
3599
3978
  ctx.lineCap = "round";
3600
3979
  ctx.lineJoin = "round";
3601
3980
  if (sprite.taper) {
3602
- for (let i = 1; i < pts.length; i++) {
3603
- ctx.lineWidth = Math.max(0.5, lw * strokeTaper((i - 0.5) / (pts.length - 1)));
3604
- ctx.beginPath();
3605
- ctx.moveTo(pts[i - 1].x, pts[i - 1].y);
3606
- ctx.lineTo(pts[i].x, pts[i].y);
3607
- ctx.stroke();
3608
- }
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();
3609
3988
  } else {
3610
3989
  ctx.lineWidth = lw;
3611
3990
  ctx.beginPath();
@@ -3859,6 +4238,7 @@ var SpecInstance = class {
3859
4238
  * showing through — the ghosting smear. */
3860
4239
  paintFrame(t, bgAlpha) {
3861
4240
  const { ctx } = this;
4241
+ this.paintSeq++;
3862
4242
  if (this.authored) this.resolveAuthored(t);
3863
4243
  if (!this.transparent) {
3864
4244
  ctx.globalAlpha = bgAlpha;
@@ -4748,7 +5128,7 @@ function compileSequence(spec) {
4748
5128
  }
4749
5129
 
4750
5130
  // src/advise.ts
4751
- import { createRng as createRng2 } from "@idle-screens/core";
5131
+ import { createRng as createRng3 } from "@idle-screens/core";
4752
5132
 
4753
5133
  // src/luma.ts
4754
5134
  function fieldRenderedRgbs(bg) {
@@ -5092,1504 +5472,1706 @@ function seamsWorthWarning(layer, c, entityCount) {
5092
5472
  return layer.blend !== "lighter" && layer.blend !== "screen";
5093
5473
  }
5094
5474
 
5095
- // src/advise.ts
5096
- var LOW_CONTRAST_FLOOR = 0.05;
5097
- var SPARSE_DECLARED_MAX_COVERAGE = 0.02;
5098
- var READ_LEGIBILITY_FLOOR = 4.5;
5099
- var READ_SAFE_AREA = 0.05;
5100
- function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
5101
- const hidden = hiddenLayers(spec);
5102
- spec = resolveTimelineAt(spec, opts.t ?? COHESION_T);
5103
- const warnings = [];
5104
- const w = viewport.width;
5105
- const h = viewport.height;
5106
- const scale = spec.units === "px" ? 1 : Math.min(w, h);
5107
- const refVp = spec.referenceViewport ?? LIMITS.referenceViewport;
5108
- let countScale = scale > 1 ? Math.min(w, h) / refVp : 1;
5109
- if (countScale > 1) {
5110
- const rawTotal = spec.layers.reduce((sum, l) => sum + Math.round(l.count * countScale), 0);
5111
- if (rawTotal > LIMITS.maxTotal) countScale *= LIMITS.maxTotal / rawTotal;
5475
+ // src/perceive.ts
5476
+ import { createRng as createRng2 } from "@idle-screens/core";
5477
+ var GLOW_SPREAD = 2.4;
5478
+ var LINE_SALIENCE = 3.5;
5479
+ var GHOST_TAPS = 12;
5480
+ var POLYGON_OUTLINE_CELLS = 3;
5481
+ var POLYGON_SCANLINES = 4;
5482
+ function buildScene(spec, opts) {
5483
+ const { width: w, height: h } = opts.viewport ?? { width: 1920, height: 1080 };
5484
+ const seed = normalizeSeed(opts.seed ?? spec.seed ?? 42);
5485
+ const { scale, countScale } = entityScales(spec, w, h);
5486
+ const rng = createRng2(seed);
5487
+ const layers = spec.layers.map((layer) => ({ layer, entities: buildEntities(layer, rng, w, h, scale, countScale) }));
5488
+ const byKey = /* @__PURE__ */ new Map();
5489
+ for (const { layer, entities } of layers) if (layer.key) byKey.set(layer.key, entities);
5490
+ let transforms = null;
5491
+ for (const { layer, entities } of layers) {
5492
+ const map = paintMap(spec, layer, w, h, scale);
5493
+ if (!map) continue;
5494
+ transforms ??= /* @__PURE__ */ new Map();
5495
+ for (const e of entities) transforms.set(e, map);
5112
5496
  }
5113
- const rng = createRng2(opts.seed ?? spec.seed ?? 42);
5114
- const allEntities = spec.layers.map((l) => buildEntities(l, rng, w, h, scale, countScale));
5115
- const bgLuma = backgroundLuma(spec);
5116
- const bgRgb = backgroundRgb(spec);
5117
- const cohesion = cohesionOf(
5118
- spec.layers.map((layer, i) => ({ layer, entities: allEntities[i] })),
5119
- opts.t ?? COHESION_T,
5120
- w,
5121
- h
5122
- );
5123
- let totalEntities = 0;
5124
- let textLayerCount = 0;
5125
- let motionLayerCount = 0;
5126
- for (let li = 0; li < spec.layers.length; li++) {
5127
- if (hidden?.[li]) continue;
5128
- const layer = spec.layers[li];
5129
- const entities = allEntities[li];
5130
- totalEntities += entities.length;
5131
- const isText = layer.sprite.kind === "text" || layer.sprite.kind === "textBlock";
5132
- const isStaticText = isText && layer.motion.type === "static";
5133
- if (isStaticText) textLayerCount++;
5134
- if (layer.motion.type !== "static") motionLayerCount++;
5135
- const coh = cohesion[li];
5136
- if (seamsWorthWarning(layer, coh, entities.length)) {
5137
- warnings.push({
5138
- path: `layers[${li}]`,
5139
- code: "overlap-seams",
5140
- message: `entities bury ${Math.round((coh.overlap ?? 0) * 100)}% of each other's outlines, so this layer is drawn as one shape \u2014 but ${coh.seamCause}, so every overlap paints a visible edge and it will read as a pile of sprites instead. A layer merges into a single silhouette only when it is one flat colour at alpha 1, hard-edged, with no blend and no pulse.`
5141
- });
5142
- }
5143
- if (layer.trail && layer.motion.type === "static") {
5144
- warnings.push({
5145
- path: `layers[${li}].trail`,
5146
- code: "trail-on-static",
5147
- message: "trail has no effect on static entities \u2014 they have no past positions to draw"
5148
- });
5497
+ return { w, h, scale, seed, layers, byKey, transforms };
5498
+ }
5499
+ function posOf(scene, e, t) {
5500
+ const p = positionAt(e, t, scene.w, scene.h);
5501
+ if (e.orbitParent) {
5502
+ const parent = scene.byKey.get(e.orbitParent)?.[0];
5503
+ if (parent) {
5504
+ const pp = positionAt(parent, t, scene.w, scene.h);
5505
+ p.x += pp.x;
5506
+ p.y += pp.y;
5149
5507
  }
5150
- if ((layer.sprite.kind === "streak" || layer.sprite.kind === "stroke" && layer.sprite.orient) && layer.motion.type === "static") {
5151
- warnings.push({
5152
- path: `layers[${li}].sprite`,
5153
- code: "streak-on-static",
5154
- message: `${layer.sprite.kind === "streak" ? "streak sprites orient" : "an oriented stroke turns"} along the motion heading \u2014 static entities have none, so the heading contributes nothing${layer.spin ? " (spin still rotates the mark)" : " and it renders at angle 0"}`
5155
- });
5508
+ }
5509
+ const map = scene.transforms?.get(e);
5510
+ return map ? map(p) : p;
5511
+ }
5512
+ function textBox(s, e, p) {
5513
+ const m = s.font ? /(\d+(?:\.\d+)?)px/.exec(s.font) : null;
5514
+ const fh = m ? Number(m[1]) : e.size;
5515
+ const str = s.strings[e.spriteIndex] ?? s.strings[0] ?? "";
5516
+ const fw = Math.max(fh, 0.62 * fh * Math.min(str.length, 48));
5517
+ const align = s.align ?? "center";
5518
+ const baseline = s.baseline ?? "middle";
5519
+ return {
5520
+ cx: align === "left" ? p.x + fw / 2 : align === "right" ? p.x - fw / 2 : p.x,
5521
+ cy: baseline === "top" ? p.y + fh * 0.6 : baseline === "bottom" ? p.y - fh * 0.6 : p.y,
5522
+ halfX: fw / 2,
5523
+ halfY: fh * 0.6
5524
+ };
5525
+ }
5526
+ function textBlockBox(s, _e, p, w, h) {
5527
+ const unitScale = Math.min(w, h);
5528
+ const fsPx = s.fontSize * unitScale;
5529
+ const lh = (s.lineHeight ?? 1.4) * fsPx;
5530
+ const maxWPx = s.maxWidth * unitScale;
5531
+ const maxWEm = maxWPx / fsPx;
5532
+ const lines = breakTextBlock(s.text, maxWEm, textMetricsClassFor(s.font));
5533
+ const totalH = lines.length * lh;
5534
+ const maxLineW = lines.reduce((m, l) => Math.max(m, l.widthEm), 0) * fsPx;
5535
+ const align = s.align ?? "left";
5536
+ const cx = align === "center" ? p.x + maxWPx / 2 : align === "right" ? p.x + maxWPx - maxLineW / 2 : p.x + maxLineW / 2;
5537
+ const { dx, dy } = textBlockAnchorOffset(s, maxWPx, maxLineW, totalH);
5538
+ return {
5539
+ cx: cx + dx,
5540
+ cy: p.y + totalH / 2 + dy,
5541
+ halfX: maxLineW / 2,
5542
+ halfY: totalH / 2
5543
+ };
5544
+ }
5545
+ function textBlockRevealFraction(s, w, h, t) {
5546
+ if (!s.reveal) return 1;
5547
+ const unitScale = Math.min(w, h);
5548
+ const maxWEm = s.maxWidth * unitScale / (s.fontSize * unitScale);
5549
+ const lines = breakTextBlock(s.text, maxWEm, textMetricsClassFor(s.font));
5550
+ const rs = revealState(lines, s.reveal, t);
5551
+ if (rs.glyphAlphas) {
5552
+ let sum = 0;
5553
+ let n = 0;
5554
+ for (const line of rs.glyphAlphas) for (const a of line) {
5555
+ sum += a;
5556
+ n++;
5156
5557
  }
5157
- if (layer.motion.type === "wander" && layer.motion.coherence === 1 && entities.length >= 20) {
5158
- warnings.push({
5159
- path: `layers[${li}].motion.coherence`,
5160
- code: "full-coherence",
5161
- message: "coherence 1 makes every entity share identical harmonics \u2014 the layer moves as a rigid sheet. 0.5\u20130.8 reads as schooling"
5162
- });
5558
+ return n > 0 ? sum / n : 1;
5559
+ }
5560
+ return rs.progress;
5561
+ }
5562
+ function polygonCellCoverage(pts, cellW, cellH, c0, c1, r0, r1) {
5563
+ const nc = c1 - c0 + 1;
5564
+ const cov = new Float64Array(nc * (r1 - r0 + 1));
5565
+ const xMin = c0 * cellW;
5566
+ const xMax = (c1 + 1) * cellW;
5567
+ const share = 1 / POLYGON_SCANLINES;
5568
+ const hits = [];
5569
+ const addSpan = (row, xa, xb) => {
5570
+ const a = Math.max(xMin, xa);
5571
+ const b = Math.min(xMax, xb);
5572
+ if (!(b > a)) return;
5573
+ const first = Math.max(c0, Math.floor(a / cellW));
5574
+ const last = Math.min(c1, Math.floor(b / cellW));
5575
+ for (let c = first; c <= last; c++) {
5576
+ const len = Math.min(b, (c + 1) * cellW) - Math.max(a, c * cellW);
5577
+ if (len > 0) cov[row * nc + (c - c0)] += len / cellW * share;
5163
5578
  }
5164
- if (layer.sprite.kind === "circle") {
5165
- const maxR = entities.reduce((m, e) => Math.max(m, e.size / 2), 0);
5166
- const maxAlpha = entities.reduce((m, e) => Math.max(m, e.alpha), 0);
5167
- if (maxR * maxAlpha < 0.05) {
5168
- warnings.push({
5169
- path: `layers[${li}]`,
5170
- code: "invisible-layer",
5171
- message: `layer's max visible radius (${maxR.toFixed(1)}px \xD7 ${maxAlpha.toFixed(2)} alpha) is below perceptual floor`
5172
- });
5579
+ };
5580
+ for (let r = r0; r <= r1; r++) {
5581
+ for (let k = 0; k < POLYGON_SCANLINES; k++) {
5582
+ const y = (r + (k + 0.5) / POLYGON_SCANLINES) * cellH;
5583
+ hits.length = 0;
5584
+ for (let i = 0; i < pts.length; i++) {
5585
+ const p = pts[i];
5586
+ const q = pts[(i + 1) % pts.length];
5587
+ const down = p.y <= y && q.y > y;
5588
+ if (!down && !(q.y <= y && p.y > y)) continue;
5589
+ hits.push({ x: p.x + (y - p.y) / (q.y - p.y) * (q.x - p.x), dir: down ? 1 : -1 });
5173
5590
  }
5174
- }
5175
- if (entities.length > 0 && layer.sprite.kind !== "emoji") {
5176
- const seps = entities.map((e) => {
5177
- const hex = spriteHex(layer, e);
5178
- if (hex === null) return Infinity;
5179
- return colourSeparation(layer, { rgb: hexRgb(hex), luma: hexLuma(hex) }, { rgb: bgRgb, luma: bgLuma });
5180
- });
5181
- const best = Math.max(...seps);
5182
- if (best < LOW_CONTRAST_FLOOR) {
5183
- const additive = layer.blend === "lighter" || layer.blend === "screen";
5184
- warnings.push({
5185
- path: `layers[${li}].sprite`,
5186
- code: "low-contrast-layer",
5187
- message: additive ? `additive layer adds almost no light (luma ${best.toFixed(3)}) \u2014 a "lighter"/"screen" layer is only as visible as it is bright` : `layer colour is within ${best.toFixed(3)} of the background (floor ${LOW_CONTRAST_FLOOR}) \u2014 it will read as background, not as content`
5188
- });
5591
+ if (hits.length < 2) continue;
5592
+ hits.sort((m, n) => m.x - n.x);
5593
+ let wind = 0;
5594
+ let start = 0;
5595
+ for (const hit of hits) {
5596
+ const before = wind;
5597
+ wind += hit.dir;
5598
+ if (before === 0 && wind !== 0) start = hit.x;
5599
+ else if (before !== 0 && wind === 0) addSpan(r - r0, start, hit.x);
5189
5600
  }
5190
5601
  }
5191
5602
  }
5192
- let totalCoverage = 0;
5193
- for (let li = 0; li < spec.layers.length; li++) {
5194
- if (hidden?.[li]) continue;
5195
- const layer = spec.layers[li];
5196
- const entities = allEntities[li];
5197
- const paintA = layer.opacity === void 0 && layer.group === void 0 ? 1 : paintOpacity(spec, layer);
5198
- for (const e of entities) {
5199
- const r = e.size / 2;
5200
- let pixArea;
5201
- if (layer.sprite.kind === "circle") {
5202
- pixArea = Math.PI * r * r;
5203
- } else if (layer.sprite.kind === "polygon") {
5204
- pixArea = polygonArea(polygonPoints(layer.sprite, r));
5205
- } else if (layer.sprite.kind === "bar") {
5206
- pixArea = e.size * barFraction(layer.sprite, e.barIndex ?? 0) * (e.size2 ?? e.size * 0.2);
5207
- } else if (layer.sprite.kind === "textBlock") {
5208
- const fsPx = layer.sprite.fontSize * scale;
5209
- const lh = (layer.sprite.lineHeight ?? 1.4) * fsPx;
5210
- const maxWPx = layer.sprite.maxWidth * scale;
5211
- const lines = breakTextBlock(layer.sprite.text, maxWPx / fsPx, textMetricsClassFor(layer.sprite.font));
5212
- pixArea = maxWPx * lines.length * lh * 0.55 * (layer.sprite.opacity ?? 1);
5213
- } else {
5214
- pixArea = e.size * e.size;
5215
- }
5216
- let duty = 1;
5217
- if (layer.emit) {
5218
- const [ga, gb] = layer.emit.grow ?? [1, 1];
5219
- const g2 = (ga * ga + ga * gb + gb * gb) / 3;
5220
- duty = Math.min(layer.emit.life, layer.emit.every) / layer.emit.every * 0.5 * g2;
5603
+ return cov;
5604
+ }
5605
+ function distanceToOutline(pts, x, y) {
5606
+ let best = Infinity;
5607
+ for (let i = 0; i < pts.length; i++) {
5608
+ const p = pts[i];
5609
+ const q = pts[(i + 1) % pts.length];
5610
+ const ex = q.x - p.x;
5611
+ const ey = q.y - p.y;
5612
+ const len2 = ex * ex + ey * ey;
5613
+ const u = len2 > 0 ? Math.max(0, Math.min(1, ((x - p.x) * ex + (y - p.y) * ey) / len2)) : 0;
5614
+ const dx = x - (p.x + ex * u);
5615
+ const dy = y - (p.y + ey * u);
5616
+ best = Math.min(best, dx * dx + dy * dy);
5617
+ }
5618
+ return Math.sqrt(best);
5619
+ }
5620
+ function luminanceGrid(spec, opts = {}) {
5621
+ spec = resolveTimelineAt(spec, opts.t ?? 5e3);
5622
+ const scene = buildScene(spec, opts);
5623
+ const t = opts.t ?? 5e3;
5624
+ const cols = Math.max(8, Math.min(200, opts.cols ?? 80));
5625
+ const rows = Math.max(8, Math.min(200, opts.rows ?? 48));
5626
+ const { w, h, scale } = scene;
5627
+ const cellW = w / cols;
5628
+ const cellH = h / rows;
5629
+ const bg = spec.background;
5630
+ const bgRow = new Array(rows).fill(0);
5631
+ let bgCells;
5632
+ if (!bg || bg.type === "solid") {
5633
+ bgRow.fill(hexLuma(bg?.color ?? "#05050a"));
5634
+ } else if (bg.type === "field") {
5635
+ bgCells = new Array(cols * rows);
5636
+ const short = Math.max(1, Math.min(w, h));
5637
+ const seed = scene.seed;
5638
+ const ft = fieldSampleTime(bg, t);
5639
+ for (let r = 0; r < rows; r++) {
5640
+ let rowSum = 0;
5641
+ for (let c = 0; c < cols; c++) {
5642
+ const lum = rgb255Luma(fieldRgbAt((c + 0.5) * cellW / short, (r + 0.5) * cellH / short, ft, bg, seed));
5643
+ bgCells[r * cols + c] = lum;
5644
+ rowSum += lum;
5221
5645
  }
5222
- totalCoverage += paintA === 1 ? pixArea * e.alpha * duty / (w * h) : pixArea * e.alpha * duty * paintA / (w * h);
5646
+ bgRow[r] = rowSum / cols;
5223
5647
  }
5224
- if (layer.links) {
5225
- const positions = entities.map((e) => positionAt(e, 0, w, h));
5226
- const maxDistPx = layer.links.maxDist * scale;
5227
- const edges = linkEdges(layer.links, positions, maxDistPx, layer.wrap !== false, w, h);
5228
- const defaultWidth = scale === 1 ? 1 : 1 / LIMITS.referenceViewport;
5229
- const lwPx = (layer.links.width ?? defaultWidth) * scale;
5230
- const la = layer.links.alpha ?? 1;
5231
- for (const edge of edges) totalCoverage += paintA === 1 ? edge.dist * lwPx * la / (w * h) : edge.dist * lwPx * la * paintA / (w * h);
5648
+ } else {
5649
+ const stops = [...bg.stops].sort((a, b) => a.at - b.at);
5650
+ for (let r = 0; r < rows; r++) {
5651
+ const y = (r + 0.5) / rows;
5652
+ let lum;
5653
+ if (y <= stops[0].at) lum = hexLuma(stops[0].color);
5654
+ else if (y >= stops[stops.length - 1].at) lum = hexLuma(stops[stops.length - 1].color);
5655
+ else {
5656
+ let i = 0;
5657
+ while (i < stops.length - 1 && stops[i + 1].at < y) i++;
5658
+ const a = stops[i];
5659
+ const b = stops[i + 1];
5660
+ const k = b.at === a.at ? 0 : (y - a.at) / (b.at - a.at);
5661
+ lum = hexLuma(a.color) + (hexLuma(b.color) - hexLuma(a.color)) * k;
5662
+ }
5663
+ bgRow[r] = lum;
5232
5664
  }
5233
- }
5234
- const density = spec.density ?? "normal";
5235
- const wouldBeSparse = totalCoverage < 5e-4 && spec.layers.length > 0;
5236
- if (wouldBeSparse && density !== "sparse") {
5237
- warnings.push({
5238
- path: "layers",
5239
- code: "sparse-scene",
5240
- message: `alpha-weighted coverage is ${(totalCoverage * 100).toFixed(4)}% \u2014 scene will look empty on most displays`
5241
- });
5242
- }
5243
- if (totalEntities > 500 && density !== "dense") {
5244
- warnings.push({
5245
- path: "layers",
5246
- code: "dense-scene",
5247
- message: `${totalEntities} entities \u2014 scene may feel crowded and hurt performance on low-end devices`
5248
- });
5249
- }
5250
- if (density === "sparse" && totalCoverage > SPARSE_DECLARED_MAX_COVERAGE) {
5251
- warnings.push({
5252
- path: "density",
5253
- code: "density-mismatch",
5254
- message: `declared sparse but alpha-weighted coverage is ${(totalCoverage * 100).toFixed(2)}% \u2014 either the declaration or the scene is wrong`
5255
- });
5256
- } else if (density === "dense" && wouldBeSparse) {
5257
- warnings.push({
5258
- path: "density",
5259
- code: "density-mismatch",
5260
- message: `declared dense but alpha-weighted coverage is ${(totalCoverage * 100).toFixed(4)}% \u2014 the scene will look empty`
5261
- });
5262
- }
5263
- if (spec.layers.length > 0 && textLayerCount > 0 && motionLayerCount === 0) {
5264
- const textRatio = textLayerCount / spec.layers.length;
5265
- if (textRatio >= 0.8) {
5266
- warnings.push({
5267
- path: "layers",
5268
- code: "text-heavy",
5269
- message: "most layers are static text \u2014 reads as a document, not a screensaver"
5270
- });
5665
+ if (bg.band) {
5666
+ const bandPx = bg.band.height * (spec.units === "px" ? 1 : Math.min(w, h));
5667
+ const fromRow = Math.max(0, Math.floor((h - bandPx) / cellH));
5668
+ for (let r = fromRow; r < rows; r++) bgRow[r] = hexLuma(bg.band.color);
5271
5669
  }
5272
5670
  }
5273
- for (let li = 0; li < spec.layers.length; li++) {
5274
- if (hidden?.[li]) continue;
5275
- const layer = spec.layers[li];
5276
- if (!layer.links || layer.links.mode === "chain") continue;
5277
- const entities = allEntities[li];
5278
- const positions = entities.map((e) => positionAt(e, 0, w, h));
5279
- const maxDist = layer.links.maxDist * scale;
5280
- const pairs = linkPairs(positions, layer.links.k, maxDist, layer.wrap !== false, w, h);
5281
- const maxPossible = Math.min(entities.length * layer.links.k, entities.length * (entities.length - 1) / 2);
5282
- if (maxPossible > 0 && pairs.length / maxPossible < 0.1) {
5283
- warnings.push({
5284
- path: `layers[${li}].links`,
5285
- code: "link-starvation",
5286
- message: `only ${pairs.length}/${maxPossible} possible edges formed \u2014 raise count or widen links.maxDist`
5287
- });
5671
+ const cells = new Array(cols * rows);
5672
+ for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) cells[r * cols + c] = bgCells ? bgCells[r * cols + c] : bgRow[r];
5673
+ const compose = (idx, lum, a, blend) => {
5674
+ const cur = cells[idx];
5675
+ if (blend === "lighter" || blend === "screen") cells[idx] = Math.min(1, cur + lum * a);
5676
+ else if (blend === "multiply") cells[idx] = cur * (1 - a * (1 - lum));
5677
+ else cells[idx] = cur * (1 - a) + lum * a;
5678
+ };
5679
+ const splatTrail = (layer, e, tPass, lifeA, alphaScale, layerCount) => {
5680
+ const trail = layer.trail;
5681
+ if (!trail) return;
5682
+ const headAlpha = alphaAt(e, tPass) * lifeA * alphaScale;
5683
+ const headSize = sizeAt(e, tPass);
5684
+ const lum = spriteLuma(layer, e);
5685
+ const soft = layer.sprite.kind === "circle" && !!layer.sprite.soft;
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;
5709
+ const c0 = Math.max(0, Math.floor((pos.x - rad) / cellW));
5710
+ const c1 = Math.min(cols - 1, Math.floor((pos.x + rad) / cellW));
5711
+ const r0 = Math.max(0, Math.floor((pos.y - rad) / cellH));
5712
+ const r1 = Math.min(rows - 1, Math.floor((pos.y + rad) / cellH));
5713
+ for (let r = r0; r <= r1; r++) {
5714
+ for (let c = c0; c <= c1; c++) {
5715
+ const dx = (c + 0.5) * cellW - pos.x;
5716
+ const dy = (r + 0.5) * cellH - pos.y;
5717
+ const d = Math.sqrt(dx * dx + dy * dy);
5718
+ if (d > rad + Math.min(cellW, cellH) / 2) continue;
5719
+ const wgt = soft ? Math.max(0.1, 1 - d / Math.max(rad, 1e-6)) : 1;
5720
+ compose(r * cols + c, lum, Math.min(1, a * wgt), layer.blend);
5721
+ }
5722
+ }
5288
5723
  }
5289
- }
5290
- for (let li = 0; li < spec.layers.length; li++) {
5291
- if (hidden?.[li]) continue;
5292
- const layer = spec.layers[li];
5293
- if (layer.motion.type === "static") continue;
5294
- const entities = allEntities[li];
5295
- if (entities.length < 3) continue;
5296
- const speeds = entities.map((e) => Math.sqrt(e.vx * e.vx + e.vy * e.vy));
5297
- const mean = speeds.reduce((a, b) => a + b, 0) / speeds.length;
5298
- if (mean === 0) continue;
5299
- const maxDev = Math.max(...speeds.map((s) => Math.abs(s - mean)));
5300
- if (maxDev / mean < 0.05) {
5301
- warnings.push({
5302
- path: `layers[${li}].motion`,
5303
- code: "uniform-motion",
5304
- message: "all entities move at nearly identical speed \u2014 widen the speed range for visual variety"
5305
- });
5724
+ };
5725
+ const splatPolygonOutline = (layer, s, e, p, sz, a, lum, tPass) => {
5726
+ const radius = sz / 2;
5727
+ const rot = rotationAt(e, tPass);
5728
+ const cr = Math.cos(rot);
5729
+ const sr = Math.sin(rot);
5730
+ const pts = polygonPoints(s, radius).map((q) => ({ x: p.x + q.x * cr - q.y * sr, y: p.y + q.x * sr + q.y * cr }));
5731
+ const additive = layer.blend === "lighter" || layer.blend === "screen";
5732
+ const glow = !!s.soft && additive;
5733
+ let band = 0;
5734
+ if (glow) {
5735
+ const perimeter = pts.reduce((acc, q, i) => {
5736
+ const n = pts[(i + 1) % pts.length];
5737
+ return acc + Math.hypot(n.x - q.x, n.y - q.y);
5738
+ }, 0);
5739
+ band = perimeter > 0 ? (GLOW_SPREAD - 1) * (2 * polygonArea(pts) / perimeter) : 0;
5306
5740
  }
5307
- }
5308
- const composed = spec.layers.map((l, li) => ({ l, li, ents: allEntities[li] })).filter(({ li }) => !hidden?.[li]).filter(({ l }) => !["bounce", "warp", "path"].includes(l.motion.type)).filter(({ l }) => !(l.motion.type === "orbit" && l.motion.center && "layer" in l.motion.center));
5309
- const composedCount = composed.reduce((s, c) => s + c.ents.length, 0);
5310
- if (composedCount >= 8) {
5311
- const positions = composed.flatMap(({ ents }) => ents.map((e) => positionAt(e, 0, w, h)));
5312
- const cx = positions.reduce((s, p) => s + p.x, 0) / positions.length;
5313
- const cy = positions.reduce((s, p) => s + p.y, 0) / positions.length;
5314
- const offX = Math.abs(cx / w - 0.5);
5315
- const offY = Math.abs(cy / h - 0.5);
5316
- if (offX > 0.2 || offY > 0.2) {
5317
- warnings.push({
5318
- path: "layers",
5319
- code: "off-center",
5320
- message: `centroid at (${(cx / w).toFixed(2)}, ${(cy / h).toFixed(2)}) \u2014 composition is heavily offset from center`
5321
- });
5741
+ let minX = Infinity;
5742
+ let maxX = -Infinity;
5743
+ let minY = Infinity;
5744
+ let maxY = -Infinity;
5745
+ for (const q of pts) {
5746
+ minX = Math.min(minX, q.x);
5747
+ maxX = Math.max(maxX, q.x);
5748
+ minY = Math.min(minY, q.y);
5749
+ maxY = Math.max(maxY, q.y);
5322
5750
  }
5323
- }
5324
- const textBoxes = [];
5325
- for (let li = 0; li < spec.layers.length; li++) {
5326
- if (hidden?.[li]) continue;
5327
- const layer = spec.layers[li];
5328
- const s = layer.sprite;
5329
- if (layer.motion.type !== "static") continue;
5330
- if (s.kind !== "text" && s.kind !== "textBlock") continue;
5331
- const label = layer.key ? `\`${layer.key}\`` : `layers[${li}]`;
5332
- for (const e of allEntities[li]) {
5333
- const p = positionAt(e, 0, w, h);
5334
- const raw = s.kind === "textBlock" ? textBlockBoxAt(s, p, w, h) : textBoxAt(s, e, p, spec, w, h);
5335
- const map = paintMap(spec, layer, w, h, scale);
5336
- const box = map ? { ...raw, ...mapBox(map, raw) } : raw;
5337
- const baseAlpha = Math.max(0, Math.min(1, e.alpha - e.pulseAmp)) * (s.kind === "textBlock" ? s.opacity ?? 1 : 1);
5338
- const alpha = layer.opacity === void 0 && layer.group === void 0 ? baseAlpha : baseAlpha * paintOpacity(spec, layer);
5339
- textBoxes.push({ li, label, alpha, ...box });
5340
- }
5341
- }
5342
- const asFraction = (b) => ({
5343
- x: +(b.x0 / w).toFixed(4),
5344
- y: +(b.y0 / h).toFixed(4),
5345
- w: +((b.x1 - b.x0) / w).toFixed(4),
5346
- h: +((b.y1 - b.y0) / h).toFixed(4)
5347
- });
5348
- const tolX = w * 0.01;
5349
- const tolY = h * 0.01;
5350
- const offFlagged = /* @__PURE__ */ new Set();
5351
- for (const b of textBoxes) {
5352
- if (offFlagged.has(b.li)) continue;
5353
- const edges = [];
5354
- if (b.x0 < -tolX) edges.push(`left edge by ~${Math.round(-b.x0)}px`);
5355
- if (b.x1 > w + tolX) edges.push(`right edge by ~${Math.round(b.x1 - w)}px`);
5356
- if (b.y0 < -tolY) edges.push(`top edge by ~${Math.round(-b.y0)}px`);
5357
- if (b.y1 > h + tolY) edges.push(`bottom edge by ~${Math.round(b.y1 - h)}px`);
5358
- if (edges.length === 0) continue;
5359
- offFlagged.add(b.li);
5360
- warnings.push({
5361
- path: `layers[${b.li}]`,
5362
- code: "text-off-screen",
5363
- message: `${b.label} text runs off the ${edges.join(" and the ")} at ${w}\xD7${h} \u2014 move position, or shrink fontSize / maxWidth`
5364
- });
5365
- }
5366
- const pairFlagged = /* @__PURE__ */ new Set();
5367
- for (let i = 0; i < textBoxes.length; i++) {
5368
- for (let j = i + 1; j < textBoxes.length; j++) {
5369
- const a = textBoxes[i];
5370
- const b = textBoxes[j];
5371
- if (a.li === b.li) continue;
5372
- const pair = `${Math.min(a.li, b.li)}:${Math.max(a.li, b.li)}`;
5373
- if (pairFlagged.has(pair)) continue;
5374
- const ix = Math.min(a.x1, b.x1) - Math.max(a.x0, b.x0);
5375
- const iy = Math.min(a.y1, b.y1) - Math.max(a.y0, b.y0);
5376
- if (ix <= 0 || iy <= 0) continue;
5377
- const smaller = Math.min((a.x1 - a.x0) * (a.y1 - a.y0), (b.x1 - b.x0) * (b.y1 - b.y0));
5378
- if (smaller <= 0) continue;
5379
- const share = ix * iy / smaller;
5380
- if (share <= 0.1) continue;
5381
- pairFlagged.add(pair);
5382
- const [first, second] = a.li < b.li ? [a, b] : [b, a];
5383
- warnings.push({
5384
- path: `layers[${first.li}]`,
5385
- code: "text-overlap",
5386
- message: `${first.label} and ${second.label} text boxes overlap (~${Math.round(share * 100)}% of the smaller one) \u2014 they will paint over each other; move one or shrink it`,
5387
- // The two boxes, so an agent can move one without re-deriving the
5388
- // layout. Additive: the message is unchanged.
5389
- boxes: [asFraction(first), asFraction(second)]
5390
- });
5391
- }
5392
- }
5393
- const readFlagged = /* @__PURE__ */ new Set();
5394
- for (const b of textBoxes) {
5395
- if (readFlagged.has(b.li)) continue;
5396
- const layer = spec.layers[b.li];
5397
- const s = layer.sprite;
5398
- if (s.kind !== "text" && s.kind !== "textBlock" || s.role !== "read") continue;
5399
- const boxes = textBoxes.filter((x) => x.li === b.li);
5400
- readFlagged.add(b.li);
5401
- const ink = hexRgb(s.color ?? "#e6e8ef");
5402
- const textPlate = (base, alpha) => {
5403
- if (layer.blend === "lighter" || layer.blend === "screen") return additivePlate(base, ink, alpha, layer.blend);
5404
- if (layer.blend === "multiply") {
5405
- return {
5406
- r: base.r * (1 - alpha + ink.r * alpha),
5407
- g: base.g * (1 - alpha + ink.g * alpha),
5408
- b: base.b * (1 - alpha + ink.b * alpha)
5409
- };
5410
- }
5411
- return sourceOverPlate(base, ink, alpha);
5412
- };
5413
- let worst = null;
5414
- for (const box of boxes) {
5415
- const cy = (box.y0 + box.y1) / 2;
5416
- const cx = (box.x0 + box.x1) / 2;
5417
- const groundRgb = backgroundRgbAt(spec, cy, h, scale, cx, w, opts.backgroundSeed ?? opts.seed, opts.t ?? COHESION_T);
5418
- const ground = legibilityRatio(textPlate(groundRgb, box.alpha), groundRgb);
5419
- const lit = brightestAdditivePlate(spec, allEntities, groundRgb, box, w, h);
5420
- const plate = lit ? legibilityRatio(textPlate(lit.rgb, box.alpha), lit.rgb) : Infinity;
5421
- if (!worst || Math.min(ground, plate) < Math.min(worst.ground, worst.plate)) {
5422
- worst = { ground, plate, plateLabel: lit?.label ?? null };
5423
- }
5424
- }
5425
- if (worst && Math.min(worst.ground, worst.plate) < READ_LEGIBILITY_FLOOR) {
5426
- const under = worst.plateLabel ? ` and ${worst.plate.toFixed(1)}:1 under ${worst.plateLabel} at its brightest` : "";
5427
- warnings.push({
5428
- path: `layers[${b.li}].sprite`,
5429
- code: "text-legibility",
5430
- message: `${b.label} is declared role: 'read' but its colour reads at ${worst.ground.toFixed(1)}:1 against the background at the box centre${under} \u2014 below ${READ_LEGIBILITY_FLOOR}:1; brighten the text, darken the ground under it, or declare role: 'atmosphere' if it is texture`
5431
- });
5432
- }
5433
- const edges = [];
5434
- const x0 = Math.min(...boxes.map((x) => x.x0));
5435
- const x1 = Math.max(...boxes.map((x) => x.x1));
5436
- const y0 = Math.min(...boxes.map((x) => x.y0));
5437
- const y1 = Math.max(...boxes.map((x) => x.y1));
5438
- if (x0 < w * READ_SAFE_AREA) edges.push("left");
5439
- if (x1 > w * (1 - READ_SAFE_AREA)) edges.push("right");
5440
- if (y0 < h * READ_SAFE_AREA) edges.push("top");
5441
- if (y1 > h * (1 - READ_SAFE_AREA)) edges.push("bottom");
5442
- if (edges.length > 0) {
5443
- warnings.push({
5444
- path: `layers[${b.li}]`,
5445
- code: "text-safe-area",
5446
- message: `${b.label} is declared role: 'read' but its box is within ${READ_SAFE_AREA * 100}% of the ${edges.join(" and ")} edge${edges.length === 1 ? "" : "s"} at ${w}\xD7${h} \u2014 displays crop and bezels hide that zone; move it inward`,
5447
- boxes: [asFraction({ x0, y0, x1, y1 })]
5448
- });
5449
- }
5450
- }
5451
- return warnings;
5452
- }
5453
- function brightestAdditivePlate(spec, allEntities, ground, box, w, h) {
5454
- let best = null;
5455
- for (let lj = 0; lj < spec.layers.length; lj++) {
5456
- const layer = spec.layers[lj];
5457
- const blend = layer.blend;
5458
- if (blend !== "lighter" && blend !== "screen") continue;
5459
- if (lj === box.li) continue;
5460
- const entities = allEntities[lj];
5461
- const moved = layer.transform !== void 0 || groupOf(spec, layer)?.transform !== void 0;
5462
- for (const e of entities) {
5463
- const hex = spriteHex(layer, e);
5464
- if (hex === null) continue;
5465
- const growScale = (e.motion === "warp" ? WARP_MAX_SCALE : 1) * (e.emit ? Math.max(e.emit.growFrom, e.emit.growTo) : 1);
5466
- const maxDim = Math.max(e.size, e.size2 ?? 0) * (1 + e.growAmp) * growScale;
5467
- if (maxDim < box.fs) continue;
5468
- if (!moved && !entityReachesBox(e, maxDim / 2, box, w, h)) continue;
5469
- const a = layer.opacity === void 0 && layer.group === void 0 ? Math.min(1, e.alpha + e.pulseAmp) : Math.min(1, e.alpha + e.pulseAmp) * paintOpacity(spec, layer);
5470
- if (a <= 0) continue;
5471
- const plate = additivePlate(ground, hexRgb(hex), a, blend);
5472
- const lum = relativeLuminance(plate);
5473
- if (!best || lum > best.lum) {
5474
- best = { rgb: plate, label: layer.key ? `\`${layer.key}\`` : `layers[${lj}]`, lum };
5751
+ const c0 = Math.max(0, Math.floor((minX - band) / cellW));
5752
+ const c1 = Math.min(cols - 1, Math.floor((maxX + band) / cellW));
5753
+ const r0 = Math.max(0, Math.floor((minY - band) / cellH));
5754
+ const r1 = Math.min(rows - 1, Math.floor((maxY + band) / cellH));
5755
+ if (c0 > c1 || r0 > r1) return;
5756
+ const cov = polygonCellCoverage(pts, cellW, cellH, c0, c1, r0, r1);
5757
+ const nc = c1 - c0 + 1;
5758
+ for (let r = r0; r <= r1; r++) {
5759
+ for (let c = c0; c <= c1; c++) {
5760
+ const f = cov[(r - r0) * nc + (c - c0)];
5761
+ const x = (c + 0.5) * cellW;
5762
+ const y = (r + 0.5) * cellH;
5763
+ let wgt;
5764
+ if (glow) {
5765
+ const outX = Math.max(0, minX - x, x - maxX);
5766
+ const outY = Math.max(0, minY - y, y - maxY);
5767
+ if (f >= 1) wgt = 1;
5768
+ else if (outX * outX + outY * outY >= band * band) wgt = f;
5769
+ else {
5770
+ const k = band > 0 ? Math.max(0, 1 - distanceToOutline(pts, x, y) / band) : 0;
5771
+ wgt = f + (1 - f) * k * k;
5772
+ }
5773
+ } else if (s.soft) {
5774
+ wgt = f * Math.max(0.1, 1 - Math.hypot(x - p.x, y - p.y) / Math.max(radius, 1e-6));
5775
+ } else {
5776
+ wgt = f;
5777
+ }
5778
+ if (wgt > 0) compose(r * cols + c, lum, a * Math.min(1, wgt), layer.blend);
5475
5779
  }
5476
5780
  }
5477
- }
5478
- return best ? { rgb: best.rgb, label: best.label } : null;
5479
- }
5480
- function entityReachesBox(e, r, box, w, h) {
5481
- if (e.motion === "static") {
5482
- const p = positionAt(e, 0, w, h);
5483
- return p.x + r > box.x0 && p.x - r < box.x1 && p.y + r > box.y0 && p.y - r < box.y1;
5484
- }
5485
- if (e.motion === "orbit" && !e.orbitParent) {
5486
- const span = e.orbitR + r;
5487
- return e.orbitCx + span > box.x0 && e.orbitCx - span < box.x1 && e.orbitCy + span > box.y0 && e.orbitCy - span < box.y1;
5488
- }
5489
- return true;
5490
- }
5491
- function textBoxAt(s, e, p, spec, w, h) {
5492
- const m = s.font ? /(\d{1,5}(?:\.\d{1,4})?|\.\d{1,4})px/.exec(s.font) : null;
5493
- const fontScale = spec.units === "px" ? 1 : Math.min(w, h) / (spec.referenceViewport ?? LIMITS.referenceViewport);
5494
- const fh = m ? Number(m[1]) * fontScale : e.size;
5495
- const str = s.strings[e.spriteIndex] ?? s.strings[0] ?? "";
5496
- let fw = textWidthEm(str) * fh;
5497
- if (s.maxWidth) fw = Math.min(fw, s.maxWidth * (spec.units === "px" ? 1 : Math.min(w, h)));
5498
- const align = s.align ?? "center";
5499
- const baseline = s.baseline ?? "middle";
5500
- const x0 = align === "left" ? p.x : align === "right" ? p.x - fw : p.x - fw / 2;
5501
- const y0 = baseline === "top" ? p.y : baseline === "bottom" ? p.y - fh : p.y - fh / 2;
5502
- return { x0, y0, x1: x0 + fw, y1: y0 + fh, fs: fh };
5503
- }
5504
- function textBlockBoxAt(s, p, w, h) {
5505
- const unit = Math.min(w, h);
5506
- const fsPx = s.fontSize * unit;
5507
- const lh = (s.lineHeight ?? 1.4) * fsPx;
5508
- const maxWPx = s.maxWidth * unit;
5509
- const lines = breakTextBlock(s.text, maxWPx / fsPx, textMetricsClassFor(s.font));
5510
- const maxLineW = lines.reduce((mx, l) => Math.max(mx, l.widthEm), 0) * fsPx;
5511
- const totalH = lines.length * lh;
5512
- const align = s.align ?? "left";
5513
- const x0 = align === "center" ? p.x + (maxWPx - maxLineW) / 2 : align === "right" ? p.x + maxWPx - maxLineW : p.x;
5514
- const { dx, dy } = textBlockAnchorOffset(s, maxWPx, maxLineW, totalH);
5515
- return { x0: x0 + dx, y0: p.y + dy, x1: x0 + dx + maxLineW, y1: p.y + dy + totalH, fs: fsPx };
5516
- }
5517
- function hiddenLayers(spec) {
5518
- if (!spec.groups && !spec.layers.some((l) => l.opacity !== void 0)) return null;
5519
- const tracks = spec.timeline ? timelineTracks(spec) : null;
5520
- const pinnedZero = (path, base, ancestors) => {
5521
- if (base !== 0) return false;
5522
- if (!tracks) return true;
5523
- for (const [p, keys] of tracks) {
5524
- if (p === path && keys.some((k) => k.value !== 0)) return false;
5525
- if (ancestors.includes(p)) return false;
5526
- }
5527
- return true;
5528
5781
  };
5529
- const out = spec.layers.map((l, i) => {
5530
- const lp = `layers.${i}`;
5531
- if (pinnedZero(`${lp}.opacity`, l.opacity, ["layers", lp])) return true;
5532
- const g = l.group !== void 0 && spec.groups && Object.prototype.hasOwnProperty.call(spec.groups, l.group) ? spec.groups[l.group] : void 0;
5533
- return !!g && pinnedZero(`groups.${l.group}.opacity`, g.opacity, ["groups", `groups.${l.group}`]);
5534
- });
5535
- return out.some(Boolean) ? out : null;
5536
- }
5537
- function adviseSequence(seq, viewport = { width: 1920, height: 1080 }) {
5538
- const warnings = [];
5539
- for (let i = 0; i < seq.segments.length; i++) {
5540
- const segWarnings = adviseSpec(seq.segments[i].scene, viewport);
5541
- for (const w of segWarnings) {
5542
- warnings.push({ path: `segments[${i}].scene.${w.path}`, code: w.code, message: w.message, boxes: w.boxes });
5543
- }
5544
- }
5545
- const fadeBound = seq.loop ? seq.segments.length : seq.segments.length - 1;
5546
- const fades = seq.segments.slice(0, fadeBound).filter((s) => s.transition?.type === "fade").length;
5547
- if (fades > 0) {
5548
- const first = seq.segments.slice(0, fadeBound).findIndex((s) => s.transition?.type === "fade");
5549
- warnings.push({
5550
- path: `segments[${first}].transition`,
5551
- code: "fade-degrades-on-low-tier",
5552
- message: `informational: ${fades} fade transition${fades === 1 ? "" : "s"} declared \u2014 viewers on the 'basic' or 'minimal' capability tier render fade as cut (two live segments for dur is over that budget); the show still plays`
5553
- });
5554
- }
5555
- for (let i = 0; i < seq.segments.length - 1; i++) {
5556
- const tr = seq.segments[i].transition;
5557
- const lumaA = backgroundLuma(resolveTimelineAt(seq.segments[i].scene, seq.segments[i].duration ?? 0));
5558
- const lumaB = backgroundLuma(resolveTimelineAt(seq.segments[i + 1].scene, 0));
5559
- const delta = Math.abs(lumaA - lumaB);
5560
- if (delta > 0.5 && tr?.type !== "fade") {
5561
- warnings.push({
5562
- path: `segments[${i}]`,
5563
- code: "boundary-luminance-jump",
5564
- message: `background luminance jumps ${delta.toFixed(2)} at boundary ${i}\u2192${i + 1} \u2014 may flash on cut${tr === void 0 || tr.type === "cut" ? `; declare transition: { type: 'fade', dur } on segments[${i}] to ramp it` : ""}`
5565
- });
5566
- }
5567
- if (tr?.type === "morph") {
5568
- const sigA = structuralSignature(seq.segments[i].scene);
5569
- const sigB = structuralSignature(seq.segments[i + 1].scene);
5570
- if (sigA !== sigB) {
5571
- warnings.push({
5572
- path: `segments[${i}].transition`,
5573
- code: "morph-structural-mismatch",
5574
- message: `segments ${i}\u2192${i + 1} differ structurally: morph will fall back to cut`
5575
- });
5576
- } else if (!seq.segments[i].scene.timeline && !seq.segments[i + 1].scene.timeline && morphNothingMorphable(seq.segments[i].scene, seq.segments[i + 1].scene, { textCrossfade: tr.text === "crossfade" || tr.text === "dip" })) {
5577
- warnings.push({
5578
- path: `segments[${i}].transition`,
5579
- code: "morph-nothing-morphable",
5580
- message: `segments ${i}\u2192${i + 1} differ only in values morph cannot interpolate (strings such as textBlock.text step on the first frame): the morph will look like a cut \u2014 fade text via colour or reveal.progress`
5581
- });
5582
- }
5583
- }
5584
- }
5585
- return warnings;
5586
- }
5587
-
5588
- // src/describe.ts
5589
- import { createRng as createRng3 } from "@idle-screens/core";
5590
- function describeScene(spec, options = {}) {
5591
- const { width: w, height: h } = options.viewport ?? { width: 1920, height: 1080 };
5592
- const seed = options.seed ?? spec.seed ?? 42;
5593
- const times = options.times ?? [0, 5e3, 15e3];
5594
- const scale = spec.units === "viewport" ? Math.min(w, h) : 1;
5595
- const refVp = spec.referenceViewport ?? LIMITS.referenceViewport;
5596
- const countScale = scale > 1 ? Math.min(w, h) / refVp : 1;
5597
- const rng = createRng3(seed);
5598
- const builtLayers = spec.layers.map((layer) => ({
5599
- layer,
5600
- entities: buildEntities(layer, rng, w, h, scale, countScale)
5601
- }));
5602
- const snapshots = times.map((t) => {
5603
- const layerSnapshots = builtLayers.map(({ layer, entities }) => {
5604
- const positions = entities.map((e) => positionAt(e, t, w, h));
5605
- const cx = positions.length > 0 ? positions.reduce((s, p) => s + p.x, 0) / positions.length : 0;
5606
- const cy = positions.length > 0 ? positions.reduce((s, p) => s + p.y, 0) / positions.length : 0;
5607
- let pixArea = 0;
5782
+ const splatPass = (tPass, alphaScale) => {
5783
+ for (const { layer, entities } of scene.layers) {
5784
+ const lifeA = layer.opacity === void 0 && layer.group === void 0 ? lifeAlphaAt(layer.life, tPass) : lifeAlphaAt(layer.life, tPass) * paintOpacity(spec, layer);
5785
+ if (lifeA <= 0) continue;
5608
5786
  for (const e of entities) {
5609
- const r = e.size / 2;
5610
- pixArea += layer.sprite.kind === "circle" ? Math.PI * r * r : layer.sprite.kind === "polygon" ? polygonArea(polygonPoints(layer.sprite, r)) : e.size * e.size;
5611
- }
5612
- const coverage = pixArea / (w * h);
5613
- const meanAlpha = entities.length > 0 ? entities.reduce((s, e) => s + e.alpha, 0) / entities.length : 0;
5614
- let linksDrawn = null;
5615
- let linksExpected = null;
5616
- let connectedComponents = null;
5617
- let isolatedNodes = null;
5618
- if (layer.links) {
5619
- const maxDist = layer.links.maxDist * scale;
5620
- const pairs = linkPairs(positions, layer.links.k, maxDist, layer.wrap !== false, w, h);
5621
- linksDrawn = pairs.length;
5622
- linksExpected = Math.min(entities.length * layer.links.k, entities.length * (entities.length - 1) / 2);
5623
- const parent = Array.from({ length: entities.length }, (_, i) => i);
5624
- const find = (x) => {
5625
- while (parent[x] !== x) {
5626
- parent[x] = parent[parent[x]];
5627
- x = parent[x];
5787
+ splatTrail(layer, e, tPass, lifeA, alphaScale, entities.length);
5788
+ const a = Math.min(1, alphaAt(e, tPass) * lifeA) * alphaScale;
5789
+ if (a <= 4e-3) continue;
5790
+ const lum = spriteLuma(layer, e);
5791
+ const p = posOf(scene, e, tPass);
5792
+ const sz = layer.transform || layer.group !== void 0 ? sizeAt(e, tPass) * paintSize(spec, layer) : sizeAt(e, tPass);
5793
+ const s = layer.sprite;
5794
+ if (s.kind === "polygon" && sz >= POLYGON_OUTLINE_CELLS * Math.min(cellW, cellH)) {
5795
+ splatPolygonOutline(layer, s, e, p, sz, a, lum, tPass);
5796
+ continue;
5797
+ }
5798
+ if (s.kind === "stroke") {
5799
+ const pts = strokeSamples(s, sz / 2);
5800
+ let angle = rotationAt(e, tPass);
5801
+ if (s.orient) angle += headingAt(e, tPass, w, h) ?? 0;
5802
+ const ca = Math.cos(angle);
5803
+ const sa = Math.sin(angle);
5804
+ const lw = strokeWidthPx(s, scale);
5805
+ const stampAt = (q, u) => {
5806
+ const x = p.x + q.x * ca - q.y * sa;
5807
+ const y = p.y + q.x * sa + q.y * ca;
5808
+ const c = Math.floor(x / cellW);
5809
+ const r = Math.floor(y / cellH);
5810
+ if (c < 0 || c >= cols || r < 0 || r >= rows) return;
5811
+ const taper = s.taper ? strokeTaper(u) : 1;
5812
+ compose(r * cols + c, lum, a * Math.min(1, lw * taper / cellH), layer.blend);
5813
+ };
5814
+ const step = Math.min(cellW, cellH) / 2;
5815
+ stampAt(pts[0], 0);
5816
+ for (let i = 1; i < pts.length; i++) {
5817
+ const q0 = pts[i - 1];
5818
+ const q1 = pts[i];
5819
+ const segLen = Math.hypot(q1.x - q0.x, q1.y - q0.y);
5820
+ const sub = Math.max(1, Math.ceil(segLen / step));
5821
+ for (let k = 1; k <= sub; k++) {
5822
+ const f = k / sub;
5823
+ stampAt({ x: q0.x + (q1.x - q0.x) * f, y: q0.y + (q1.y - q0.y) * f }, (i - 1 + f) / (pts.length - 1));
5824
+ }
5825
+ }
5826
+ continue;
5827
+ }
5828
+ if (s.kind === "streak") {
5829
+ const heading = (headingAt(e, tPass, w, h) ?? 0) + rotationAt(e, tPass);
5830
+ const steps = Math.max(2, Math.ceil(sz / Math.min(cellW, cellH)));
5831
+ const wgt = Math.min(1, (s.width ?? (scale === 1 ? 2 : 2e-3)) * scale / cellH);
5832
+ for (let i = 0; i <= steps; i++) {
5833
+ const k = i / steps;
5834
+ const x = p.x - Math.cos(heading) * sz * (1 - k);
5835
+ const y = p.y - Math.sin(heading) * sz * (1 - k);
5836
+ const c = Math.floor(x / cellW);
5837
+ const r = Math.floor(y / cellH);
5838
+ if (c < 0 || c >= cols || r < 0 || r >= rows) continue;
5839
+ compose(r * cols + c, lum, a * k * wgt, layer.blend);
5840
+ }
5841
+ continue;
5842
+ }
5843
+ let centerX = p.x;
5844
+ let centerY = p.y;
5845
+ let halfX = sz / 2;
5846
+ let halfY = s.kind === "rect" ? (e.size2 ?? sz) * (e.size > 0 ? sz / e.size : 1) / 2 : sz / 2;
5847
+ if (s.kind === "text") {
5848
+ const box = textBox(s, e, p);
5849
+ centerX = box.cx;
5850
+ centerY = box.cy;
5851
+ halfX = box.halfX;
5852
+ halfY = box.halfY;
5853
+ } else if (s.kind === "textBlock") {
5854
+ const box = textBlockBox(s, e, p, w, h);
5855
+ centerX = box.cx;
5856
+ centerY = box.cy;
5857
+ halfX = box.halfX;
5858
+ halfY = box.halfY;
5859
+ } else if (s.kind === "bar") {
5860
+ const len = sz * barFraction(s, e.barIndex ?? 0);
5861
+ if (len <= 0) continue;
5862
+ const th = e.size2 !== void 0 ? e.size2 * (e.size > 0 ? sz / e.size : 1) : sz * 0.2;
5863
+ const box = barBox(s.direction ?? "right", len, th);
5864
+ const rot = rotationAt(e, tPass);
5865
+ const cr = Math.cos(rot);
5866
+ const sr = Math.sin(rot);
5867
+ centerX = p.x + box.cx * cr - box.cy * sr;
5868
+ centerY = p.y + box.cx * sr + box.cy * cr;
5869
+ halfX = Math.abs(box.halfX * cr) + Math.abs(box.halfY * sr);
5870
+ halfY = Math.abs(box.halfX * sr) + Math.abs(box.halfY * cr);
5871
+ }
5872
+ const circular = s.kind === "circle" || s.kind === "ring" || s.kind === "polygon";
5873
+ const soft = (s.kind === "circle" || s.kind === "polygon") && !!s.soft;
5874
+ const shapeWeight = s.kind === "polygon" ? polygonFill(s, sz / 2) : s.kind === "rect" && s.feather ? (1 - s.feather / 2) * (1 - s.feather / 2) : 1;
5875
+ const additive = layer.blend === "lighter" || layer.blend === "screen";
5876
+ const glow = soft && additive;
5877
+ const reachX = glow ? halfX * GLOW_SPREAD : halfX;
5878
+ const reachY = glow ? halfY * GLOW_SPREAD : halfY;
5879
+ const c0 = Math.max(0, Math.floor((centerX - reachX) / cellW));
5880
+ const c1 = Math.min(cols - 1, Math.floor((centerX + reachX) / cellW));
5881
+ const r0 = Math.max(0, Math.floor((centerY - reachY) / cellH));
5882
+ const r1 = Math.min(rows - 1, Math.floor((centerY + reachY) / cellH));
5883
+ const inkWeight = (s.kind === "text" || s.kind === "emoji" || s.kind === "textBlock" ? 0.55 : 1) * (s.kind === "textBlock" ? textBlockRevealFraction(s, w, h, t) * (s.opacity ?? 1) : 1);
5884
+ const byArea = s.kind === "rect" || s.kind === "bar";
5885
+ for (let r = r0; r <= r1; r++) {
5886
+ const fy = byArea ? Math.max(0, Math.min((r + 1) * cellH, centerY + halfY) - Math.max(r * cellH, centerY - halfY)) / cellH : 1;
5887
+ if (fy <= 0) continue;
5888
+ for (let c = c0; c <= c1; c++) {
5889
+ const dx = (c + 0.5) * cellW - centerX;
5890
+ const dy = (r + 0.5) * cellH - centerY;
5891
+ if (circular) {
5892
+ const d = Math.sqrt(dx * dx + dy * dy);
5893
+ const reach = glow ? reachX : halfX;
5894
+ if (d > reach + Math.min(cellW, cellH) / 2) continue;
5895
+ if (s.kind === "ring") {
5896
+ const strokeW = (s.width ?? (scale === 1 ? 2 : 2e-3)) * scale;
5897
+ if (d < halfX - strokeW) continue;
5898
+ }
5899
+ let wgt;
5900
+ if (glow) {
5901
+ const core = halfX;
5902
+ if (d <= core) wgt = 1;
5903
+ else {
5904
+ const f = Math.max(0, 1 - (d - core) / Math.max(reachX - core, 1e-6));
5905
+ wgt = f * f;
5906
+ }
5907
+ } else {
5908
+ wgt = soft ? Math.max(0.1, 1 - d / Math.max(halfX, 1e-6)) : 1;
5909
+ }
5910
+ compose(r * cols + c, lum, a * wgt * shapeWeight, layer.blend);
5911
+ } else if (byArea) {
5912
+ const fx = Math.max(0, Math.min((c + 1) * cellW, centerX + halfX) - Math.max(c * cellW, centerX - halfX)) / cellW;
5913
+ if (fx > 0) compose(r * cols + c, lum, a * shapeWeight * fx * fy, layer.blend);
5914
+ } else {
5915
+ compose(r * cols + c, lum, a * inkWeight * shapeWeight, layer.blend);
5916
+ }
5628
5917
  }
5629
- return x;
5630
- };
5631
- for (const [a, b] of pairs) {
5632
- const ra = find(a);
5633
- const rb = find(b);
5634
- if (ra !== rb) parent[ra] = rb;
5635
- }
5636
- const roots = new Set(entities.map((_, i) => find(i)));
5637
- connectedComponents = roots.size;
5638
- const degree = new Uint16Array(entities.length);
5639
- for (const [a, b] of pairs) {
5640
- degree[a]++;
5641
- degree[b]++;
5642
5918
  }
5643
- isolatedNodes = degree.filter((d) => d === 0).length;
5644
- }
5645
- return {
5646
- key: layer.key,
5647
- count: entities.length,
5648
- centroid: { x: +(cx / w).toFixed(3), y: +(cy / h).toFixed(3) },
5649
- coverage: +coverage.toFixed(4),
5650
- meanAlpha: +meanAlpha.toFixed(3),
5651
- linksDrawn,
5652
- linksExpected,
5653
- connectedComponents,
5654
- isolatedNodes
5655
- };
5656
- });
5657
- const warnings = [];
5658
- for (const ls of layerSnapshots) {
5659
- const label = ls.key ?? `layer`;
5660
- if (ls.coverage * ls.meanAlpha < 5e-4) {
5661
- warnings.push(`${label}: effectively invisible (coverage\xD7alpha=${(ls.coverage * ls.meanAlpha).toFixed(5)})`);
5662
5919
  }
5663
- if (ls.linksDrawn !== null && ls.linksExpected !== null && ls.linksExpected > 0) {
5664
- const ratio = ls.linksDrawn / ls.linksExpected;
5665
- if (ratio < 0.1) {
5666
- warnings.push(`${label}: link starvation \u2014 ${ls.linksDrawn}/${ls.linksExpected} edges formed`);
5920
+ if (layer.links) {
5921
+ const positions = entities.map((e) => posOf(scene, e, tPass));
5922
+ const maxDistPx = layer.links.maxDist * scale;
5923
+ const motionWraps = ["drift", "rise", "wander"].includes(layer.motion.type);
5924
+ const edges = linkEdges(layer.links, positions, maxDistPx, layer.wrap !== false && motionWraps, w, h);
5925
+ const la = Math.min(1, (layer.links.alpha ?? 0.6) * lifeA);
5926
+ const lum = layer.links.color ? hexLuma(layer.links.color) : 0.7;
5927
+ const defaultWidth = scale === 1 ? 1 : 1 / LIMITS.referenceViewport;
5928
+ const wgt = Math.min(1, (layer.links.width ?? defaultWidth) * scale / cellH) * la * alphaScale;
5929
+ for (const edge of edges) {
5930
+ const pi = positions[edge.i];
5931
+ const pj = positions[edge.j];
5932
+ const steps = Math.max(1, Math.ceil(edge.dist / Math.min(cellW, cellH)));
5933
+ for (let i = 0; i <= steps; i++) {
5934
+ const k = i / steps;
5935
+ const c = Math.floor((pi.x + (pj.x - pi.x) * k) / cellW);
5936
+ const r = Math.floor((pi.y + (pj.y - pi.y) * k) / cellH);
5937
+ if (c < 0 || c >= cols || r < 0 || r >= rows) continue;
5938
+ compose(r * cols + c, lum, wgt, layer.blend);
5939
+ }
5667
5940
  }
5668
5941
  }
5669
- if (ls.isolatedNodes !== null && ls.count > 0 && ls.isolatedNodes / ls.count > 0.5) {
5670
- warnings.push(`${label}: ${ls.isolatedNodes}/${ls.count} nodes isolated \u2014 graph is disconnected`);
5671
- }
5672
5942
  }
5673
- return { t, viewport: { w, h }, layers: layerSnapshots, warnings };
5674
- });
5675
- return {
5676
- spec: { id: spec.id, label: spec.label, units: spec.units ?? "px", ...spec.finish ? { finish: spec.finish } : {} },
5677
- snapshots
5678
5943
  };
5679
- }
5680
-
5681
- // src/perceive.ts
5682
- import { createRng as createRng4 } from "@idle-screens/core";
5683
- var GLOW_SPREAD = 2.4;
5684
- var LINE_SALIENCE = 3.5;
5685
- var GHOST_TAPS = 12;
5686
- var POLYGON_OUTLINE_CELLS = 3;
5687
- var POLYGON_SCANLINES = 4;
5688
- function buildScene(spec, opts) {
5689
- const { width: w, height: h } = opts.viewport ?? { width: 1920, height: 1080 };
5690
- const seed = normalizeSeed(opts.seed ?? spec.seed ?? 42);
5691
- const scale = spec.units === "px" ? 1 : Math.min(w, h);
5692
- const refVp = spec.referenceViewport ?? LIMITS.referenceViewport;
5693
- let countScale = scale > 1 ? Math.min(w, h) / refVp : 1;
5694
- if (countScale > 1) {
5695
- const rawTotal = spec.layers.reduce((s, l) => s + Math.round(l.count * countScale), 0);
5696
- if (rawTotal > LIMITS.maxTotal) countScale *= LIMITS.maxTotal / rawTotal;
5697
- }
5698
- const rng = createRng4(seed);
5699
- const layers = spec.layers.map((layer) => ({ layer, entities: buildEntities(layer, rng, w, h, scale, countScale) }));
5700
- const byKey = /* @__PURE__ */ new Map();
5701
- for (const { layer, entities } of layers) if (layer.key) byKey.set(layer.key, entities);
5702
- let transforms = null;
5703
- for (const { layer, entities } of layers) {
5704
- const map = paintMap(spec, layer, w, h, scale);
5705
- if (!map) continue;
5706
- transforms ??= /* @__PURE__ */ new Map();
5707
- for (const e of entities) transforms.set(e, map);
5944
+ const ghost = spec.ghosting ?? 0;
5945
+ if (ghost > 0) {
5946
+ const dt = 1e3 / 60;
5947
+ const windowFrames = Math.min(Math.ceil(Math.log(1 / 255) / Math.log(ghost)), LIMITS.maxGhostReplayFrames);
5948
+ const taps = Math.min(GHOST_TAPS, windowFrames);
5949
+ const spacing = windowFrames / taps;
5950
+ for (let i = taps; i >= 1; i--) {
5951
+ const tPass = t - i * spacing * dt;
5952
+ if (tPass < 0) continue;
5953
+ const bracketWeight = Math.pow(ghost, (i - 1) * spacing + 1) * (1 - Math.pow(ghost, spacing)) / (1 - ghost);
5954
+ splatPass(tPass, Math.min(1, bracketWeight));
5955
+ }
5708
5956
  }
5709
- return { w, h, scale, seed, layers, byKey, transforms };
5957
+ splatPass(t, 1);
5958
+ return finishGrid(cols, rows, cells, bgRow, bgCells);
5710
5959
  }
5711
- function posOf(scene, e, t) {
5712
- const p = positionAt(e, t, scene.w, scene.h);
5713
- if (e.orbitParent) {
5714
- const parent = scene.byKey.get(e.orbitParent)?.[0];
5715
- if (parent) {
5716
- const pp = positionAt(parent, t, scene.w, scene.h);
5717
- p.x += pp.x;
5718
- p.y += pp.y;
5960
+ function finishGrid(cols, rows, cells, bgRow, bgCells) {
5961
+ let sum = 0;
5962
+ let covered = 0;
5963
+ let devSum = 0;
5964
+ let cxAcc = 0;
5965
+ let cyAcc = 0;
5966
+ const rowProfile = new Array(rows).fill(0);
5967
+ const colProfile = new Array(cols).fill(0);
5968
+ for (let r = 0; r < rows; r++) {
5969
+ for (let c = 0; c < cols; c++) {
5970
+ const v = cells[r * cols + c];
5971
+ sum += v;
5972
+ const dev = Math.abs(v - (bgCells ? bgCells[r * cols + c] : bgRow[r]));
5973
+ if (dev > 0.03) covered++;
5974
+ devSum += dev;
5975
+ cxAcc += dev * (c + 0.5);
5976
+ cyAcc += dev * (r + 0.5);
5977
+ rowProfile[r] += dev;
5978
+ colProfile[c] += dev;
5719
5979
  }
5720
5980
  }
5721
- const map = scene.transforms?.get(e);
5722
- return map ? map(p) : p;
5723
- }
5724
- function textBox(s, e, p) {
5725
- const m = s.font ? /(\d+(?:\.\d+)?)px/.exec(s.font) : null;
5726
- const fh = m ? Number(m[1]) : e.size;
5727
- const str = s.strings[e.spriteIndex] ?? s.strings[0] ?? "";
5728
- const fw = Math.max(fh, 0.62 * fh * Math.min(str.length, 48));
5729
- const align = s.align ?? "center";
5730
- const baseline = s.baseline ?? "middle";
5731
- return {
5732
- cx: align === "left" ? p.x + fw / 2 : align === "right" ? p.x - fw / 2 : p.x,
5733
- cy: baseline === "top" ? p.y + fh * 0.6 : baseline === "bottom" ? p.y - fh * 0.6 : p.y,
5734
- halfX: fw / 2,
5735
- halfY: fh * 0.6
5736
- };
5737
- }
5738
- function textBlockBox(s, _e, p, w, h) {
5739
- const unitScale = Math.min(w, h);
5740
- const fsPx = s.fontSize * unitScale;
5741
- const lh = (s.lineHeight ?? 1.4) * fsPx;
5742
- const maxWPx = s.maxWidth * unitScale;
5743
- const maxWEm = maxWPx / fsPx;
5744
- const lines = breakTextBlock(s.text, maxWEm, textMetricsClassFor(s.font));
5745
- const totalH = lines.length * lh;
5746
- const maxLineW = lines.reduce((m, l) => Math.max(m, l.widthEm), 0) * fsPx;
5747
- const align = s.align ?? "left";
5748
- const cx = align === "center" ? p.x + maxWPx / 2 : align === "right" ? p.x + maxWPx - maxLineW / 2 : p.x + maxLineW / 2;
5749
- const { dx, dy } = textBlockAnchorOffset(s, maxWPx, maxLineW, totalH);
5981
+ for (let r = 0; r < rows; r++) rowProfile[r] = rowProfile[r] / cols;
5982
+ for (let c = 0; c < cols; c++) colProfile[c] = colProfile[c] / rows;
5750
5983
  return {
5751
- cx: cx + dx,
5752
- cy: p.y + totalH / 2 + dy,
5753
- halfX: maxLineW / 2,
5754
- halfY: totalH / 2
5984
+ cols,
5985
+ rows,
5986
+ cells,
5987
+ background: bgRow,
5988
+ ...bgCells ? { backgroundCells: bgCells } : {},
5989
+ meanLuminance: sum / (cols * rows),
5990
+ coverage: covered / (cols * rows),
5991
+ centroid: devSum > 1e-6 ? { x: cxAcc / devSum / cols, y: cyAcc / devSum / rows } : null,
5992
+ rowProfile,
5993
+ colProfile
5755
5994
  };
5756
5995
  }
5757
- function textBlockRevealFraction(s, w, h, t) {
5758
- if (!s.reveal) return 1;
5759
- const unitScale = Math.min(w, h);
5760
- const maxWEm = s.maxWidth * unitScale / (s.fontSize * unitScale);
5761
- const lines = breakTextBlock(s.text, maxWEm, textMetricsClassFor(s.font));
5762
- const rs = revealState(lines, s.reveal, t);
5763
- if (rs.glyphAlphas) {
5764
- let sum = 0;
5765
- let n = 0;
5766
- for (const line of rs.glyphAlphas) for (const a of line) {
5767
- sum += a;
5768
- n++;
5769
- }
5770
- return n > 0 ? sum / n : 1;
5771
- }
5772
- return rs.progress;
5773
- }
5774
- function polygonCellCoverage(pts, cellW, cellH, c0, c1, r0, r1) {
5775
- const nc = c1 - c0 + 1;
5776
- const cov = new Float64Array(nc * (r1 - r0 + 1));
5777
- const xMin = c0 * cellW;
5778
- const xMax = (c1 + 1) * cellW;
5779
- const share = 1 / POLYGON_SCANLINES;
5780
- const hits = [];
5781
- const addSpan = (row, xa, xb) => {
5782
- const a = Math.max(xMin, xa);
5783
- const b = Math.min(xMax, xb);
5784
- if (!(b > a)) return;
5785
- const first = Math.max(c0, Math.floor(a / cellW));
5786
- const last = Math.min(c1, Math.floor(b / cellW));
5787
- for (let c = first; c <= last; c++) {
5788
- const len = Math.min(b, (c + 1) * cellW) - Math.max(a, c * cellW);
5789
- if (len > 0) cov[row * nc + (c - c0)] += len / cellW * share;
5790
- }
5791
- };
5792
- for (let r = r0; r <= r1; r++) {
5793
- for (let k = 0; k < POLYGON_SCANLINES; k++) {
5794
- const y = (r + (k + 0.5) / POLYGON_SCANLINES) * cellH;
5795
- hits.length = 0;
5796
- for (let i = 0; i < pts.length; i++) {
5797
- const p = pts[i];
5798
- const q = pts[(i + 1) % pts.length];
5799
- const down = p.y <= y && q.y > y;
5800
- if (!down && !(q.y <= y && p.y > y)) continue;
5801
- hits.push({ x: p.x + (y - p.y) / (q.y - p.y) * (q.x - p.x), dir: down ? 1 : -1 });
5802
- }
5803
- if (hits.length < 2) continue;
5804
- hits.sort((m, n) => m.x - n.x);
5805
- let wind = 0;
5806
- let start = 0;
5807
- for (const hit of hits) {
5808
- const before = wind;
5809
- wind += hit.dir;
5810
- if (before === 0 && wind !== 0) start = hit.x;
5811
- else if (before !== 0 && wind === 0) addSpan(r - r0, start, hit.x);
5996
+ var DITHER = [
5997
+ [0.5 / 8, 4.5 / 8],
5998
+ [2.5 / 8, 6.5 / 8],
5999
+ [1.5 / 8, 5.5 / 8],
6000
+ [3.5 / 8, 7.5 / 8]
6001
+ ];
6002
+ var DOT_BIT = [
6003
+ [1, 8],
6004
+ [2, 16],
6005
+ [4, 32],
6006
+ [64, 128]
6007
+ ];
6008
+ function autoExpose(grid) {
6009
+ const sorted = [...grid.cells].sort((a, b) => a - b);
6010
+ const lo = sorted[0];
6011
+ const hi = Math.max(sorted[Math.floor(sorted.length * 0.98)], lo + 0.08);
6012
+ return (v) => Math.pow(Math.max(0, Math.min(1, (v - lo) / (hi - lo))), 0.8);
6013
+ }
6014
+ function renderBrailleMap(grid) {
6015
+ const expose = autoExpose(grid);
6016
+ const outRows = [];
6017
+ for (let br = 0; br < Math.floor(grid.rows / 4); br++) {
6018
+ let line = "";
6019
+ for (let bc = 0; bc < Math.floor(grid.cols / 2); bc++) {
6020
+ let bits = 0;
6021
+ for (let dr = 0; dr < 4; dr++) {
6022
+ for (let dc = 0; dc < 2; dc++) {
6023
+ const v = expose(grid.cells[(br * 4 + dr) * grid.cols + (bc * 2 + dc)]);
6024
+ if (v > DITHER[dr][dc]) bits |= DOT_BIT[dr][dc];
6025
+ }
5812
6026
  }
6027
+ line += String.fromCharCode(10240 + bits);
5813
6028
  }
6029
+ outRows.push(line);
5814
6030
  }
5815
- return cov;
6031
+ return outRows.join("\n");
5816
6032
  }
5817
- function distanceToOutline(pts, x, y) {
5818
- let best = Infinity;
5819
- for (let i = 0; i < pts.length; i++) {
5820
- const p = pts[i];
5821
- const q = pts[(i + 1) % pts.length];
5822
- const ex = q.x - p.x;
5823
- const ey = q.y - p.y;
5824
- const len2 = ex * ex + ey * ey;
5825
- const u = len2 > 0 ? Math.max(0, Math.min(1, ((x - p.x) * ex + (y - p.y) * ey) / len2)) : 0;
5826
- const dx = x - (p.x + ex * u);
5827
- const dy = y - (p.y + ey * u);
5828
- best = Math.min(best, dx * dx + dy * dy);
6033
+ var DENSITY_RAMP = " .:-=+*#%@";
6034
+ function renderDensityMap(grid) {
6035
+ const expose = autoExpose(grid);
6036
+ const ramp = DENSITY_RAMP;
6037
+ const lines = [];
6038
+ for (let r = 0; r < grid.rows; r++) {
6039
+ let line = "";
6040
+ for (let c = 0; c < grid.cols; c++) {
6041
+ const v = expose(grid.cells[r * grid.cols + c]);
6042
+ line += ramp[Math.min(ramp.length - 1, Math.floor(v * ramp.length))];
6043
+ }
6044
+ lines.push(line);
5829
6045
  }
5830
- return Math.sqrt(best);
6046
+ return lines.join("\n");
5831
6047
  }
5832
- function rotatedBoxCoverage(cx, cy, hx, hy, rot, x0, y0, x1, y1) {
5833
- const cr = Math.cos(rot), sr = Math.sin(rot);
5834
- let poly = [[-hx, -hy], [hx, -hy], [hx, hy], [-hx, hy]].map(([x, y]) => [cx + x * cr - y * sr, cy + x * sr + y * cr]);
5835
- const clip = (inside, cut) => {
5836
- const out = [];
5837
- for (let i = 0; i < poly.length; i++) {
5838
- const a = poly[i], b = poly[(i + 1) % poly.length];
5839
- if (inside(a)) {
5840
- out.push(a);
5841
- if (!inside(b)) out.push(cut(a, b));
5842
- } else if (inside(b)) out.push(cut(a, b));
5843
- }
5844
- poly = out;
5845
- };
5846
- const atX = (x) => (a, b) => [x, a[1] + (x - a[0]) / (b[0] - a[0]) * (b[1] - a[1])];
5847
- const atY = (y) => (a, b) => [a[0] + (y - a[1]) / (b[1] - a[1]) * (b[0] - a[0]), y];
5848
- clip((p) => p[0] >= x0, atX(x0));
5849
- if (!poly.length) return 0;
5850
- clip((p) => p[0] <= x1, atX(x1));
5851
- if (!poly.length) return 0;
5852
- clip((p) => p[1] >= y0, atY(y0));
5853
- if (!poly.length) return 0;
5854
- clip((p) => p[1] <= y1, atY(y1));
5855
- if (!poly.length) return 0;
5856
- let area = 0;
5857
- for (let i = 0; i < poly.length; i++) {
5858
- const a = poly[i], b = poly[(i + 1) % poly.length];
5859
- area += a[0] * b[1] - b[0] * a[1];
5860
- }
5861
- return Math.abs(area) / 2 / ((x1 - x0) * (y1 - y0));
6048
+ function textSprites(spec, opts = {}) {
6049
+ spec = resolveTimelineAt(spec, opts.t ?? 5e3);
6050
+ const scene = buildScene(spec, opts);
6051
+ const t = opts.t ?? 5e3;
6052
+ const out = [];
6053
+ scene.layers.forEach(({ layer, entities }, layerIndex) => {
6054
+ if (layer.sprite.kind === "textBlock") {
6055
+ const s2 = layer.sprite;
6056
+ const unitScale = Math.min(scene.w, scene.h);
6057
+ const sizePx2 = s2.fontSize * unitScale;
6058
+ const info = { layerIndex, key: layer.key, strings: [s2.text], count: entities.length, sizePx: Math.round(sizePx2) };
6059
+ if (s2.reveal) info.revealed = Math.round(textBlockRevealFraction(s2, scene.w, scene.h, t) * 100) / 100;
6060
+ out.push(info);
6061
+ return;
6062
+ }
6063
+ if (layer.sprite.kind !== "text") return;
6064
+ const s = layer.sprite;
6065
+ const m = s.font ? /(\d+(?:\.\d+)?)px/.exec(s.font) : null;
6066
+ const sizePx = m ? Number(m[1]) : entities[0] ? sizeAt(entities[0], t) : 0;
6067
+ out.push({ layerIndex, key: layer.key, strings: [...s.strings], count: entities.length, sizePx: Math.round(sizePx) });
6068
+ });
6069
+ return out;
5862
6070
  }
5863
- function luminanceGrid(spec, opts = {}) {
6071
+ function dominanceRanking(spec, opts = {}) {
6072
+ return rankDominance(rawDominance(spec, opts));
6073
+ }
6074
+ function rankDominance(raw) {
6075
+ const total = raw.reduce((s, r) => s + r.weight, 0) || 1;
6076
+ return raw.slice().sort((a, b) => b.weight - a.weight).map((r, i) => ({
6077
+ rank: i + 1,
6078
+ layerIndex: r.layerIndex,
6079
+ key: r.key,
6080
+ share: r.weight / total,
6081
+ entityCount: r.entityCount,
6082
+ meanLuma: r.meanLuma,
6083
+ factors: r.factors
6084
+ }));
6085
+ }
6086
+ function rawDominance(spec, opts = {}) {
5864
6087
  spec = resolveTimelineAt(spec, opts.t ?? 5e3);
5865
6088
  const scene = buildScene(spec, opts);
5866
6089
  const t = opts.t ?? 5e3;
5867
- const cols = Math.max(8, Math.min(200, opts.cols ?? 80));
5868
- const rows = Math.max(8, Math.min(200, opts.rows ?? 48));
5869
6090
  const { w, h, scale } = scene;
5870
- const cellW = w / cols;
5871
- const cellH = h / rows;
5872
- const bg = spec.background;
5873
- const bgRow = new Array(rows).fill(0);
5874
- let bgCells;
5875
- if (!bg || bg.type === "solid") {
5876
- bgRow.fill(hexLuma(bg?.color ?? "#05050a"));
5877
- } else if (bg.type === "field") {
5878
- bgCells = new Array(cols * rows);
5879
- const short = Math.max(1, Math.min(w, h));
5880
- const seed = scene.seed;
5881
- const ft = fieldSampleTime(bg, t);
5882
- for (let r = 0; r < rows; r++) {
5883
- let rowSum = 0;
5884
- for (let c = 0; c < cols; c++) {
5885
- const lum = rgb255Luma(fieldRgbAt((c + 0.5) * cellW / short, (r + 0.5) * cellH / short, ft, bg, seed));
5886
- bgCells[r * cols + c] = lum;
5887
- rowSum += lum;
6091
+ const bgMean = backgroundLuma(spec);
6092
+ const ghost = spec.ghosting ?? 0;
6093
+ let ghostWindowMs = 0;
6094
+ let ghostMeanW = 0;
6095
+ if (ghost > 0) {
6096
+ const dt = 1e3 / 60;
6097
+ const frames = Math.min(Math.ceil(Math.log(1 / 255) / Math.log(ghost)), LIMITS.maxGhostReplayFrames);
6098
+ ghostWindowMs = frames * dt;
6099
+ ghostMeanW = ghost * (1 - Math.pow(ghost, frames)) / (1 - ghost) / frames;
6100
+ }
6101
+ const sweptDistance = (e, spanMs) => {
6102
+ const span = Math.min(spanMs, t);
6103
+ if (span <= 0) return 0;
6104
+ const p0 = posOf(scene, e, t - span);
6105
+ const p1 = posOf(scene, e, t);
6106
+ const dx = p1.x - p0.x;
6107
+ const dy = p1.y - p0.y;
6108
+ if (Math.abs(dx) > w / 2 || Math.abs(dy) > h / 2) return 0;
6109
+ return Math.sqrt(dx * dx + dy * dy);
6110
+ };
6111
+ const raw = scene.layers.map(({ layer, entities }, layerIndex) => {
6112
+ const lifeA = layer.opacity === void 0 && layer.group === void 0 ? lifeAlphaAt(layer.life, t) : lifeAlphaAt(layer.life, t) * paintOpacity(spec, layer);
6113
+ const painted = layer.transform !== void 0 || layer.group !== void 0;
6114
+ const areaScale = painted ? paintSize(spec, layer) ** 2 : 1;
6115
+ let area = 0;
6116
+ let lumAcc = 0;
6117
+ for (const e of entities) {
6118
+ const a = alphaAt(e, t) * lifeA;
6119
+ const sz = sizeAt(e, t);
6120
+ const s = layer.sprite;
6121
+ let entArea;
6122
+ if (s.kind === "circle") entArea = Math.PI * (sz / 2) * (sz / 2);
6123
+ else if (s.kind === "ring") entArea = Math.PI * sz * ((s.width ?? (scale === 1 ? 2 : 2e-3)) * scale) * LINE_SALIENCE;
6124
+ else if (s.kind === "streak") entArea = sz * ((s.width ?? (scale === 1 ? 2 : 2e-3)) * scale) * LINE_SALIENCE;
6125
+ else if (s.kind === "rect") {
6126
+ const h2 = e.size2 !== void 0 ? e.size2 * (e.size > 0 ? sz / e.size : 1) : sz;
6127
+ const f = s.feather ? (1 - s.feather / 2) * (1 - s.feather / 2) : 1;
6128
+ entArea = sz * h2 * f;
6129
+ } else if (s.kind === "polygon") entArea = polygonArea(polygonPoints(s, sz / 2));
6130
+ else if (s.kind === "bar") {
6131
+ const th = e.size2 !== void 0 ? e.size2 * (e.size > 0 ? sz / e.size : 1) : sz * 0.2;
6132
+ entArea = sz * barFraction(s, e.barIndex ?? 0) * th;
6133
+ } else if (s.kind === "stroke") {
6134
+ const meanTaper = s.taper ? 0.64 : 1;
6135
+ entArea = pathLength(strokeSamples(s, sz / 2)) * strokeWidthPx(s, scale) * meanTaper * LINE_SALIENCE;
6136
+ } else if (s.kind === "text") {
6137
+ const box = textBox(s, e, { x: 0, y: 0 });
6138
+ entArea = box.halfX * 2 * box.halfY * 2 * 0.55;
6139
+ } else if (s.kind === "textBlock") {
6140
+ const box = textBlockBox(s, e, { x: 0, y: 0 }, w, h);
6141
+ entArea = box.halfX * 2 * box.halfY * 2 * 0.55 * textBlockRevealFraction(s, w, h, t) * (s.opacity ?? 1);
6142
+ } else entArea = sz * sz * 0.55;
6143
+ area += painted ? entArea * areaScale * a : entArea * a;
6144
+ if (layer.trail) {
6145
+ const fade = layer.trail.fade ?? 1;
6146
+ const span = sweptDistance(e, layer.trail.length);
6147
+ area += span * sz * 0.65 * Math.max(0, 1 - fade / 2) * a;
5888
6148
  }
5889
- bgRow[r] = rowSum / cols;
6149
+ if (ghost > 0) {
6150
+ area += sweptDistance(e, ghostWindowMs) * sz * ghostMeanW * a;
6151
+ }
6152
+ lumAcc += spriteLuma(layer, e);
5890
6153
  }
5891
- } else {
5892
- const stops = [...bg.stops].sort((a, b) => a.at - b.at);
5893
- for (let r = 0; r < rows; r++) {
5894
- const y = (r + 0.5) / rows;
5895
- let lum;
5896
- if (y <= stops[0].at) lum = hexLuma(stops[0].color);
5897
- else if (y >= stops[stops.length - 1].at) lum = hexLuma(stops[stops.length - 1].color);
5898
- else {
5899
- let i = 0;
5900
- while (i < stops.length - 1 && stops[i + 1].at < y) i++;
5901
- const a = stops[i];
5902
- const b = stops[i + 1];
5903
- const k = b.at === a.at ? 0 : (y - a.at) / (b.at - a.at);
5904
- lum = hexLuma(a.color) + (hexLuma(b.color) - hexLuma(a.color)) * k;
6154
+ if (layer.links && entities.length > 1) {
6155
+ const positions = entities.map((e) => posOf(scene, e, t));
6156
+ const motionWraps = ["drift", "rise", "wander"].includes(layer.motion.type);
6157
+ const edges = linkEdges(layer.links, positions, layer.links.maxDist * scale, layer.wrap !== false && motionWraps, w, h);
6158
+ const la = (layer.links.alpha ?? 0.6) * lifeA;
6159
+ const defaultWidth = scale === 1 ? 1 : 1 / LIMITS.referenceViewport;
6160
+ const lw = (layer.links.width ?? defaultWidth) * scale;
6161
+ for (const edge of edges) area += edge.dist * lw * la * LINE_SALIENCE;
6162
+ }
6163
+ const meanLuma = entities.length ? lumAcc / entities.length : 0;
6164
+ const contrast = Math.abs(meanLuma - bgMean) + 0.1;
6165
+ const blendBoost = layer.blend === "lighter" || layer.blend === "screen" ? 1.3 : layer.blend === "multiply" ? 1.1 : 1;
6166
+ const motionBoost = layer.motion.type === "static" ? 1 : 1.2;
6167
+ const weight = area / (w * h) * contrast * blendBoost * motionBoost;
6168
+ return { layerIndex, key: layer.key, weight, entityCount: entities.length, meanLuma, factors: { area: area / (w * h), contrast, blendBoost, motionBoost } };
6169
+ });
6170
+ return raw;
6171
+ }
6172
+ function layerCohesion(spec, opts = {}) {
6173
+ spec = resolveTimelineAt(spec, opts.t ?? 5e3);
6174
+ const scene = buildScene(spec, opts);
6175
+ return cohesionOf(scene.layers, opts.t ?? 5e3, scene.w, scene.h);
6176
+ }
6177
+ var MOVING_FLOOR = 5e-4;
6178
+ function motionStats(spec, opts = {}) {
6179
+ const t = opts.t ?? 5e3;
6180
+ const dt = opts.dt ?? 500;
6181
+ const scene0 = buildScene(resolveTimelineAt(spec, t), opts);
6182
+ const scene1 = spec.timeline ? buildScene(resolveTimelineAt(spec, t + dt), opts) : scene0;
6183
+ const { w, h } = scene0;
6184
+ const unit = Math.min(w, h);
6185
+ return scene0.layers.map(({ layer, entities }, layerIndex) => {
6186
+ const entities1 = scene1.layers[layerIndex]?.entities ?? entities;
6187
+ let acc = 0;
6188
+ let max = 0;
6189
+ let n = 0;
6190
+ let ux = 0;
6191
+ let uy = 0;
6192
+ let moversN = 0;
6193
+ entities.forEach((e, i) => {
6194
+ const p0 = posOf(scene0, e, t);
6195
+ const p1 = posOf(scene1, entities1[i] ?? e, t + dt);
6196
+ const dx = p1.x - p0.x;
6197
+ const dy = p1.y - p0.y;
6198
+ if (Math.abs(dx) > w / 2 || Math.abs(dy) > h / 2) return;
6199
+ const dist = Math.sqrt(dx * dx + dy * dy);
6200
+ const speed = dist / dt * 1e3;
6201
+ acc += speed;
6202
+ max = Math.max(max, speed);
6203
+ n++;
6204
+ if (dist > 0) {
6205
+ ux += dx / dist;
6206
+ uy += dy / dist;
6207
+ moversN++;
5905
6208
  }
5906
- bgRow[r] = lum;
6209
+ });
6210
+ const meanSpeed = n ? acc / n : 0;
6211
+ const meanSpeedVp = meanSpeed / unit;
6212
+ const maxSpeedVp = max / unit;
6213
+ const moving = meanSpeedVp > MOVING_FLOOR;
6214
+ let heading = null;
6215
+ let headingSpread = null;
6216
+ if (moving && moversN > 0) {
6217
+ const mx = ux / moversN;
6218
+ const my = uy / moversN;
6219
+ const mag = Math.sqrt(mx * mx + my * my);
6220
+ headingSpread = 1 - mag;
6221
+ heading = mag > 0.05 ? (Math.atan2(my, mx) * 180 / Math.PI + 360) % 360 : null;
6222
+ }
6223
+ return { layerIndex, key: layer.key, meanSpeed, maxSpeed: max, meanSpeedVp, maxSpeedVp, moving, heading, headingSpread };
6224
+ });
6225
+ }
6226
+ function rgbToHsl(c) {
6227
+ const max = Math.max(c.r, c.g, c.b);
6228
+ const min = Math.min(c.r, c.g, c.b);
6229
+ const l = (max + min) / 2;
6230
+ const d = max - min;
6231
+ if (d < 1e-6) return { h: null, s: 0, l };
6232
+ const sat = l > 0.5 ? d / (2 - max - min) : d / (max + min);
6233
+ let h;
6234
+ if (max === c.r) h = ((c.g - c.b) / d + (c.g < c.b ? 6 : 0)) * 60;
6235
+ else if (max === c.g) h = ((c.b - c.r) / d + 2) * 60;
6236
+ else h = ((c.r - c.g) / d + 4) * 60;
6237
+ return { h: sat < 0.05 ? null : h, s: sat, l };
6238
+ }
6239
+ function toHex(c) {
6240
+ const q = (v) => Math.round(Math.max(0, Math.min(1, v)) * 255).toString(16).padStart(2, "0");
6241
+ return `#${q(c.r)}${q(c.g)}${q(c.b)}`;
6242
+ }
6243
+ function describeColour(c) {
6244
+ const hsl = rgbToHsl(c);
6245
+ return { hue: hsl.h === null ? null : Math.round(hsl.h), saturation: +hsl.s.toFixed(3), warmth: +(c.r - c.b).toFixed(3), hex: toHex(c) };
6246
+ }
6247
+ function sceneColour(spec, opts = {}) {
6248
+ const t = opts.t ?? 5e3;
6249
+ spec = resolveTimelineAt(spec, t);
6250
+ const scene = buildScene(spec, opts);
6251
+ let wr = 0;
6252
+ let wg = 0;
6253
+ let wb = 0;
6254
+ let wsum = 0;
6255
+ for (const { layer, entities } of scene.layers) {
6256
+ const lifeA = lifeAlphaAt(layer.life, t) * (layer.opacity === void 0 && layer.group === void 0 ? 1 : paintOpacity(spec, layer));
6257
+ for (const e of entities) {
6258
+ const hex = spriteHex(layer, e);
6259
+ if (hex === null) continue;
6260
+ const sz = sizeAt(e, t);
6261
+ const weight = alphaAt(e, t) * lifeA * (layer.sprite.kind === "circle" ? Math.PI * (sz / 2) * (sz / 2) : sz * sz);
6262
+ if (weight <= 0) continue;
6263
+ const c = hexRgb(hex);
6264
+ wr += c.r * weight;
6265
+ wg += c.g * weight;
6266
+ wb += c.b * weight;
6267
+ wsum += weight;
5907
6268
  }
5908
- if (bg.band) {
5909
- const bandPx = bg.band.height * (spec.units === "px" ? 1 : Math.min(w, h));
5910
- const fromRow = Math.max(0, Math.floor((h - bandPx) / cellH));
5911
- for (let r = fromRow; r < rows; r++) bgRow[r] = hexLuma(bg.band.color);
6269
+ }
6270
+ const bg = backgroundRgb(spec);
6271
+ const sprites = wsum > 0 ? { r: wr / wsum, g: wg / wsum, b: wb / wsum } : bg;
6272
+ return { ...describeColour(sprites), background: describeColour(bg) };
6273
+ }
6274
+ function hueShift(a, b) {
6275
+ if (a === null || b === null) return null;
6276
+ let d = b - a;
6277
+ while (d > 180) d -= 360;
6278
+ while (d < -180) d += 360;
6279
+ return Math.round(d);
6280
+ }
6281
+ var REGION_NAMES = [
6282
+ "top-left",
6283
+ "top-center",
6284
+ "top-right",
6285
+ "mid-left",
6286
+ "center",
6287
+ "mid-right",
6288
+ "bottom-left",
6289
+ "bottom-center",
6290
+ "bottom-right"
6291
+ ];
6292
+ function regionMeans(grid) {
6293
+ const out = new Array(9).fill(0);
6294
+ const cnt = new Array(9).fill(0);
6295
+ for (let r = 0; r < grid.rows; r++) {
6296
+ for (let c = 0; c < grid.cols; c++) {
6297
+ const idx = r * grid.cols + c;
6298
+ const region = Math.min(2, Math.floor(r / grid.rows * 3)) * 3 + Math.min(2, Math.floor(c / grid.cols * 3));
6299
+ out[region] += Math.abs(grid.cells[idx] - (grid.backgroundCells ? grid.backgroundCells[idx] : grid.background[r]));
6300
+ cnt[region]++;
5912
6301
  }
5913
6302
  }
5914
- const cells = new Array(cols * rows);
5915
- for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) cells[r * cols + c] = bgCells ? bgCells[r * cols + c] : bgRow[r];
5916
- const compose = (idx, lum, a, blend) => {
5917
- const cur = cells[idx];
5918
- if (blend === "lighter" || blend === "screen") cells[idx] = Math.min(1, cur + lum * a);
5919
- else if (blend === "multiply") cells[idx] = cur * (1 - a * (1 - lum));
5920
- else cells[idx] = cur * (1 - a) + lum * a;
6303
+ return out.map((v, i) => cnt[i] ? v / cnt[i] : 0);
6304
+ }
6305
+ function diffScenes(a, b, opts = {}) {
6306
+ const t = opts.t ?? 5e3;
6307
+ const gridA = luminanceGrid(a, opts);
6308
+ const gridB = luminanceGrid(b, opts);
6309
+ const regsA = regionMeans(gridA);
6310
+ const regsB = regionMeans(gridB);
6311
+ const domA = dominanceRanking(a, opts);
6312
+ const domB = dominanceRanking(b, opts);
6313
+ const nameOf = (d) => d.key ?? `layer-${d.layerIndex}`;
6314
+ const names = /* @__PURE__ */ new Set([...domA.map(nameOf), ...domB.map(nameOf)]);
6315
+ const dominance = [...names].map((key) => {
6316
+ const inA = domA.find((d) => nameOf(d) === key);
6317
+ const inB = domB.find((d) => nameOf(d) === key);
6318
+ return {
6319
+ key,
6320
+ rankA: inA?.rank ?? null,
6321
+ rankB: inB?.rank ?? null,
6322
+ shareA: inA?.share ?? 0,
6323
+ shareB: inB?.share ?? 0
6324
+ };
6325
+ }).sort((x, y) => Math.abs(y.shareB - y.shareA) - Math.abs(x.shareB - x.shareA));
6326
+ const vp = opts.viewport ?? { width: 1920, height: 1080 };
6327
+ const codesA = new Set(adviseSpec(a, vp).map((w) => w.code));
6328
+ const codesB = new Set(adviseSpec(b, vp).map((w) => w.code));
6329
+ const colA = sceneColour(a, opts);
6330
+ const colB = sceneColour(b, opts);
6331
+ const motA = motionStats(a, opts);
6332
+ const motB = motionStats(b, opts);
6333
+ const weighted = (stats, dom) => {
6334
+ let acc = 0;
6335
+ let n = 0;
6336
+ for (const m of stats) {
6337
+ const c = dom.find((d) => d.layerIndex === m.layerIndex)?.entityCount ?? 1;
6338
+ acc += m.meanSpeedVp * c;
6339
+ n += c;
6340
+ }
6341
+ return n ? acc / n : 0;
5921
6342
  };
5922
- const splatTrail = (layer, e, tPass, lifeA, alphaScale) => {
5923
- const trail = layer.trail;
5924
- if (!trail) return;
5925
- const fade = trail.fade ?? 1;
5926
- const n = Math.min(Math.ceil(trail.length / 50), LIMITS.maxTrailSamples);
5927
- const headAlpha = alphaAt(e, tPass) * lifeA;
5928
- const headSize = sizeAt(e, tPass);
5929
- const lum = spriteLuma(layer, e);
5930
- const soft = layer.sprite.kind === "circle" && !!layer.sprite.soft;
5931
- const wrapOn = layer.wrap !== false;
5932
- const head = posOf(scene, e, tPass);
5933
- let prevX = head.x;
5934
- let prevY = head.y;
5935
- for (let s = 1; s <= n; s++) {
5936
- const k = s / n;
5937
- const pastT = tPass - k * trail.length;
5938
- if (pastT < 0) break;
5939
- const pos = posOf(scene, e, pastT);
5940
- if (wrapOn && (Math.abs(pos.x - prevX) > w / 2 || Math.abs(pos.y - prevY) > h / 2)) break;
5941
- prevX = pos.x;
5942
- prevY = pos.y;
5943
- const a = headAlpha * (1 - k * fade) * alphaScale;
5944
- if (a <= 4e-3) break;
5945
- const rad = headSize / 2 * (1 - k * 0.7);
5946
- if (rad < 0.2) break;
5947
- const c0 = Math.max(0, Math.floor((pos.x - rad) / cellW));
5948
- const c1 = Math.min(cols - 1, Math.floor((pos.x + rad) / cellW));
5949
- const r0 = Math.max(0, Math.floor((pos.y - rad) / cellH));
5950
- const r1 = Math.min(rows - 1, Math.floor((pos.y + rad) / cellH));
5951
- for (let r = r0; r <= r1; r++) {
5952
- for (let c = c0; c <= c1; c++) {
5953
- const dx = (c + 0.5) * cellW - pos.x;
5954
- const dy = (r + 0.5) * cellH - pos.y;
5955
- const d = Math.sqrt(dx * dx + dy * dy);
5956
- if (d > rad + Math.min(cellW, cellH) / 2) continue;
5957
- const wgt = soft ? Math.max(0.1, 1 - d / Math.max(rad, 1e-6)) : 1;
5958
- compose(r * cols + c, lum, Math.min(1, a * wgt), layer.blend);
5959
- }
6343
+ const motNameOf = (m) => m.key ?? `layer-${m.layerIndex}`;
6344
+ const motionLayers = [.../* @__PURE__ */ new Set([...motA.map(motNameOf), ...motB.map(motNameOf)])].map((key) => {
6345
+ const ma = motA.find((m) => motNameOf(m) === key);
6346
+ const mb = motB.find((m) => motNameOf(m) === key);
6347
+ const va = ma?.meanSpeedVp ?? 0;
6348
+ const vb = mb?.meanSpeedVp ?? 0;
6349
+ return { key, a: va, b: vb, delta: vb - va, headingA: ma?.heading ?? null, headingB: mb?.heading ?? null };
6350
+ }).filter((m) => Math.abs(m.delta) > 1e-9 || m.headingA !== m.headingB).sort((x, y) => Math.abs(y.delta) - Math.abs(x.delta));
6351
+ const overallA = weighted(motA, domA);
6352
+ const overallB = weighted(motB, domB);
6353
+ return {
6354
+ t,
6355
+ coverage: { a: gridA.coverage, b: gridB.coverage, delta: gridB.coverage - gridA.coverage },
6356
+ meanLuminance: { a: gridA.meanLuminance, b: gridB.meanLuminance, delta: gridB.meanLuminance - gridA.meanLuminance },
6357
+ balanceShift: gridA.centroid && gridB.centroid ? { dx: gridB.centroid.x - gridA.centroid.x, dy: gridB.centroid.y - gridA.centroid.y } : null,
6358
+ regions: REGION_NAMES.map((region, i) => ({ region, a: regsA[i], b: regsB[i], delta: regsB[i] - regsA[i] })),
6359
+ dominance,
6360
+ colour: {
6361
+ a: colA,
6362
+ b: colB,
6363
+ hueShift: hueShift(colA.hue, colB.hue),
6364
+ saturationDelta: +(colB.saturation - colA.saturation).toFixed(3),
6365
+ warmthDelta: +(colB.warmth - colA.warmth).toFixed(3),
6366
+ background: {
6367
+ hueShift: hueShift(colA.background.hue, colB.background.hue),
6368
+ warmthDelta: +(colB.background.warmth - colA.background.warmth).toFixed(3)
5960
6369
  }
6370
+ },
6371
+ motion: {
6372
+ overall: { a: overallA, b: overallB, delta: overallB - overallA },
6373
+ layers: motionLayers
6374
+ },
6375
+ advisories: {
6376
+ added: [...codesB].filter((c) => !codesA.has(c)),
6377
+ removed: [...codesA].filter((c) => !codesB.has(c))
5961
6378
  }
5962
6379
  };
5963
- const splatPolygonOutline = (layer, s, e, p, sz, a, lum, tPass) => {
5964
- const radius = sz / 2;
5965
- const rot = rotationAt(e, tPass);
5966
- const cr = Math.cos(rot);
5967
- const sr = Math.sin(rot);
5968
- const pts = polygonPoints(s, radius).map((q) => ({ x: p.x + q.x * cr - q.y * sr, y: p.y + q.x * sr + q.y * cr }));
5969
- const additive = layer.blend === "lighter" || layer.blend === "screen";
5970
- const glow = !!s.soft && additive;
5971
- let band = 0;
5972
- if (glow) {
5973
- const perimeter = pts.reduce((acc, q, i) => {
5974
- const n = pts[(i + 1) % pts.length];
5975
- return acc + Math.hypot(n.x - q.x, n.y - q.y);
5976
- }, 0);
5977
- band = perimeter > 0 ? (GLOW_SPREAD - 1) * (2 * polygonArea(pts) / perimeter) : 0;
6380
+ }
6381
+ function perceiveScene(spec, opts = {}) {
6382
+ const timed = spec;
6383
+ spec = resolveTimelineAt(spec, opts.t ?? 5e3);
6384
+ const grid = luminanceGrid(spec, opts);
6385
+ return {
6386
+ t: opts.t ?? 5e3,
6387
+ braille: renderBrailleMap(grid),
6388
+ density: renderDensityMap(grid),
6389
+ coverage: grid.coverage,
6390
+ meanLuminance: grid.meanLuminance,
6391
+ centroid: grid.centroid,
6392
+ rowProfile: grid.rowProfile,
6393
+ colProfile: grid.colProfile,
6394
+ dominance: dominanceRanking(spec, opts),
6395
+ motion: motionStats(timed, opts),
6396
+ form: layerCohesion(spec, opts),
6397
+ colour: sceneColour(spec, opts),
6398
+ text: textSprites(spec, opts),
6399
+ advisories: adviseSpec(spec, opts.viewport ?? { width: 1920, height: 1080 }, { t: opts.t, seed: opts.seed })
6400
+ };
6401
+ }
6402
+ var bedKey = (key, layerIndex) => `bed:${key ?? layerIndex}`;
6403
+ function perceiveSequenceFrame(seq, T, opts = {}) {
6404
+ const { releasedBelow, ...gridOpts } = opts;
6405
+ const r = resolveSegment(seq, T, { releasedBelow });
6406
+ const seg = seq.segments[r.index];
6407
+ const segment = { index: r.index, key: seg.key, localT: r.localT, ...r.held ? { held: true } : {} };
6408
+ const segOpts = { ...gridOpts, t: r.localT, seed: gridOpts.seed ?? segmentRenderSeed(seq, r.index) };
6409
+ if (!seq.bed) return { ...perceiveScene(seg.scene, segOpts), segment, bed: false };
6410
+ const bedOpts = { ...gridOpts, t: T, seed: gridOpts.seed ?? bedRenderSeed(seq) };
6411
+ const viewport = gridOpts.viewport ?? { width: 1920, height: 1080 };
6412
+ const ink = { ...seg.scene, background: { type: "solid", color: "#000000" } };
6413
+ delete ink.ghosting;
6414
+ const inkOverBed = { ...ink, background: seq.bed.background ?? { type: "solid", color: "#05050a" } };
6415
+ const bedGrid = luminanceGrid(seq.bed, bedOpts);
6416
+ const inkGrid = luminanceGrid(ink, segOpts);
6417
+ const cells = bedGrid.cells.map((v, i) => Math.min(1, v + inkGrid.cells[i]));
6418
+ const grid = finishGrid(bedGrid.cols, bedGrid.rows, cells, bedGrid.background, bedGrid.backgroundCells);
6419
+ const bedLayers = seq.bed.layers.length;
6420
+ const dominance = rankDominance([
6421
+ ...rawDominance(seq.bed, bedOpts).map((d) => ({ ...d, key: bedKey(d.key, d.layerIndex) })),
6422
+ ...rawDominance(inkOverBed, segOpts).map((d) => ({ ...d, layerIndex: d.layerIndex + bedLayers }))
6423
+ ]);
6424
+ const shift = (x) => ({ ...x, layerIndex: x.layerIndex + bedLayers });
6425
+ const prefix = (x) => ({ ...x, key: bedKey(x.key, x.layerIndex) });
6426
+ return {
6427
+ t: r.localT,
6428
+ braille: renderBrailleMap(grid),
6429
+ density: renderDensityMap(grid),
6430
+ coverage: grid.coverage,
6431
+ meanLuminance: grid.meanLuminance,
6432
+ centroid: grid.centroid,
6433
+ rowProfile: grid.rowProfile,
6434
+ colProfile: grid.colProfile,
6435
+ dominance,
6436
+ motion: [...motionStats(seq.bed, bedOpts).map(prefix), ...motionStats(ink, segOpts).map(shift)],
6437
+ form: [...layerCohesion(seq.bed, bedOpts).map(prefix), ...layerCohesion(ink, segOpts).map(shift)],
6438
+ text: [...textSprites(seq.bed, bedOpts).map(prefix), ...textSprites(ink, segOpts).map(shift)],
6439
+ // The segment's sprite mass over the bed's plate — what the screen shows
6440
+ // in front of the bed. The bed's own sprites are a slow constant beneath.
6441
+ colour: sceneColour(inkOverBed, segOpts),
6442
+ advisories: [
6443
+ ...adviseSpec(seq.bed, viewport, { t: T, seed: bedOpts.seed }).map((w) => ({ ...w, path: `bed.${w.path}` })),
6444
+ // inkOverBed's layers are the segment's (seed: segOpts.seed) but its
6445
+ // background is the bed's (seq.bed.background) — a field there must be
6446
+ // sampled with the bed's own seed, or a `role: 'read'` legibility
6447
+ // advisory checks terrain that isn't what's actually behind the ink.
6448
+ // `?? 42`: inkOverBed.seed is the SEGMENT's raw seed field (it's `ink`
6449
+ // spread first), so adviseSpec's own `backgroundSeed ?? seed` fallback
6450
+ // would substitute the segment's seed, not the bed's, if bedOpts.seed
6451
+ // were left undefined — 42 is the same default `luminanceGrid` (and
6452
+ // every other field seed fallback) converges on.
6453
+ ...adviseSpec(inkOverBed, viewport, { t: r.localT, seed: segOpts.seed, backgroundSeed: bedOpts.seed ?? 42 })
6454
+ ],
6455
+ segment,
6456
+ bed: true
6457
+ };
6458
+ }
6459
+
6460
+ // src/advise.ts
6461
+ var LOW_CONTRAST_FLOOR = 0.05;
6462
+ var SPARSE_DECLARED_MAX_COVERAGE = 0.02;
6463
+ var READ_LEGIBILITY_FLOOR = 4.5;
6464
+ var READ_SAFE_AREA = 0.05;
6465
+ var ADDITIVE_INK_CEILING = 1;
6466
+ function edgeAnchored(range) {
6467
+ if (!range) return false;
6468
+ const [a, b] = range;
6469
+ return (a <= 0.02 || b >= 0.98) && b - a <= 0.6;
6470
+ }
6471
+ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
6472
+ const hidden = hiddenLayers(spec);
6473
+ spec = resolveTimelineAt(spec, opts.t ?? COHESION_T);
6474
+ const warnings = [];
6475
+ const w = viewport.width;
6476
+ const h = viewport.height;
6477
+ const { scale, countScale } = entityScales(spec, w, h);
6478
+ const rng = createRng3(opts.seed ?? spec.seed ?? 42);
6479
+ const allEntities = spec.layers.map((l) => buildEntities(l, rng, w, h, scale, countScale));
6480
+ const cohesion = cohesionOf(
6481
+ spec.layers.map((layer, i) => ({ layer, entities: allEntities[i] })),
6482
+ opts.t ?? COHESION_T,
6483
+ w,
6484
+ h
6485
+ );
6486
+ let totalEntities = 0;
6487
+ let textLayerCount = 0;
6488
+ let motionLayerCount = 0;
6489
+ for (let li = 0; li < spec.layers.length; li++) {
6490
+ if (hidden?.[li]) continue;
6491
+ const layer = spec.layers[li];
6492
+ const entities = allEntities[li];
6493
+ totalEntities += entities.length;
6494
+ const isText = layer.sprite.kind === "text" || layer.sprite.kind === "textBlock";
6495
+ const isStaticText = isText && layer.motion.type === "static";
6496
+ if (isStaticText) textLayerCount++;
6497
+ if (layer.motion.type !== "static") motionLayerCount++;
6498
+ const coh = cohesion[li];
6499
+ if (seamsWorthWarning(layer, coh, entities.length)) {
6500
+ const s = layer.sprite;
6501
+ const softEdged = (s.kind === "circle" || s.kind === "polygon") && s.soft || s.kind === "rect" && !!s.feather;
6502
+ warnings.push({
6503
+ path: `layers[${li}]`,
6504
+ code: "overlap-seams",
6505
+ message: softEdged ? `entities bury ${Math.round((coh.overlap ?? 0) * 100)}% of each other's outlines, but ${coh.seamCause}: overlaps pool into soft lumps rather than one solid silhouette (no hard edges \u2014 a cloud, smoke or haze reads well this way). If this layer is meant to be ONE solid shape, make it one flat colour at alpha 1, hard-edged, with no blend and no pulse.` : `entities bury ${Math.round((coh.overlap ?? 0) * 100)}% of each other's outlines, so this layer is drawn as one shape \u2014 but ${coh.seamCause}, so every overlap paints a visible edge and it will read as a pile of sprites instead. A layer merges into a single silhouette only when it is one flat colour at alpha 1, hard-edged, with no blend and no pulse.`
6506
+ });
5978
6507
  }
5979
- let minX = Infinity;
5980
- let maxX = -Infinity;
5981
- let minY = Infinity;
5982
- let maxY = -Infinity;
5983
- for (const q of pts) {
5984
- minX = Math.min(minX, q.x);
5985
- maxX = Math.max(maxX, q.x);
5986
- minY = Math.min(minY, q.y);
5987
- maxY = Math.max(maxY, q.y);
6508
+ if (layer.trail && layer.motion.type === "static") {
6509
+ warnings.push({
6510
+ path: `layers[${li}].trail`,
6511
+ code: "trail-on-static",
6512
+ message: "trail has no effect on static entities \u2014 they have no past positions to draw"
6513
+ });
5988
6514
  }
5989
- const c0 = Math.max(0, Math.floor((minX - band) / cellW));
5990
- const c1 = Math.min(cols - 1, Math.floor((maxX + band) / cellW));
5991
- const r0 = Math.max(0, Math.floor((minY - band) / cellH));
5992
- const r1 = Math.min(rows - 1, Math.floor((maxY + band) / cellH));
5993
- if (c0 > c1 || r0 > r1) return;
5994
- const cov = polygonCellCoverage(pts, cellW, cellH, c0, c1, r0, r1);
5995
- const nc = c1 - c0 + 1;
5996
- for (let r = r0; r <= r1; r++) {
5997
- for (let c = c0; c <= c1; c++) {
5998
- const f = cov[(r - r0) * nc + (c - c0)];
5999
- const x = (c + 0.5) * cellW;
6000
- const y = (r + 0.5) * cellH;
6001
- let wgt;
6002
- if (glow) {
6003
- const outX = Math.max(0, minX - x, x - maxX);
6004
- const outY = Math.max(0, minY - y, y - maxY);
6005
- if (f >= 1) wgt = 1;
6006
- else if (outX * outX + outY * outY >= band * band) wgt = f;
6007
- else {
6008
- const k = band > 0 ? Math.max(0, 1 - distanceToOutline(pts, x, y) / band) : 0;
6009
- wgt = f + (1 - f) * k * k;
6010
- }
6011
- } else if (s.soft) {
6012
- wgt = f * Math.max(0.1, 1 - Math.hypot(x - p.x, y - p.y) / Math.max(radius, 1e-6));
6013
- } else {
6014
- wgt = f;
6015
- }
6016
- if (wgt > 0) compose(r * cols + c, lum, a * Math.min(1, wgt), layer.blend);
6515
+ if ((layer.sprite.kind === "streak" || layer.sprite.kind === "stroke" && layer.sprite.orient) && layer.motion.type === "static" && !layer.spin) {
6516
+ warnings.push({
6517
+ path: `layers[${li}].sprite`,
6518
+ code: "streak-on-static",
6519
+ message: `${layer.sprite.kind === "streak" ? "streak sprites orient" : "an oriented stroke turns"} along the motion heading \u2014 static entities have none, so the heading contributes nothing and it renders at angle 0`
6520
+ });
6521
+ }
6522
+ if (layer.motion.type === "wander" && layer.motion.coherence === 1 && entities.length >= 20) {
6523
+ warnings.push({
6524
+ path: `layers[${li}].motion.coherence`,
6525
+ code: "full-coherence",
6526
+ message: "coherence 1 makes every entity share identical harmonics \u2014 the layer moves as a rigid sheet. 0.5\u20130.8 reads as schooling"
6527
+ });
6528
+ }
6529
+ if (layer.sprite.kind === "circle") {
6530
+ const maxR = entities.reduce((m, e) => Math.max(m, e.size / 2), 0);
6531
+ const maxAlpha = entities.reduce((m, e) => Math.max(m, e.alpha), 0);
6532
+ if (maxR * maxAlpha < 0.05) {
6533
+ warnings.push({
6534
+ path: `layers[${li}]`,
6535
+ code: "invisible-layer",
6536
+ message: `layer's max visible radius (${maxR.toFixed(1)}px \xD7 ${maxAlpha.toFixed(2)} alpha) is below perceptual floor`
6537
+ });
6017
6538
  }
6018
6539
  }
6019
- };
6020
- const splatPass = (tPass, alphaScale) => {
6021
- for (const { layer, entities } of scene.layers) {
6022
- const lifeA = layer.opacity === void 0 && layer.group === void 0 ? lifeAlphaAt(layer.life, tPass) : lifeAlphaAt(layer.life, tPass) * paintOpacity(spec, layer);
6023
- if (lifeA <= 0) continue;
6024
- for (const e of entities) {
6025
- splatTrail(layer, e, tPass, lifeA, alphaScale);
6026
- const a = Math.min(1, alphaAt(e, tPass) * lifeA) * alphaScale;
6027
- if (a <= 4e-3) continue;
6028
- const lum = spriteLuma(layer, e);
6029
- const p = posOf(scene, e, tPass);
6030
- const sz = layer.transform || layer.group !== void 0 ? sizeAt(e, tPass) * paintSize(spec, layer) : sizeAt(e, tPass);
6031
- const s = layer.sprite;
6032
- if (s.kind === "polygon" && sz >= POLYGON_OUTLINE_CELLS * Math.min(cellW, cellH)) {
6033
- splatPolygonOutline(layer, s, e, p, sz, a, lum, tPass);
6034
- continue;
6035
- }
6036
- if (s.kind === "stroke") {
6037
- const pts = strokeSamples(s, sz / 2);
6038
- let angle = rotationAt(e, tPass);
6039
- if (s.orient) angle += headingAt(e, tPass, w, h) ?? 0;
6040
- const ca = Math.cos(angle);
6041
- const sa = Math.sin(angle);
6042
- const lw = strokeWidthPx(s, scale);
6043
- const stampAt = (q, u) => {
6044
- const x = p.x + q.x * ca - q.y * sa;
6045
- const y = p.y + q.x * sa + q.y * ca;
6046
- const c = Math.floor(x / cellW);
6047
- const r = Math.floor(y / cellH);
6048
- if (c < 0 || c >= cols || r < 0 || r >= rows) return;
6049
- const taper = s.taper ? strokeTaper(u) : 1;
6050
- compose(r * cols + c, lum, a * Math.min(1, lw * taper / cellH), layer.blend);
6051
- };
6052
- const step = Math.min(cellW, cellH) / 2;
6053
- stampAt(pts[0], 0);
6054
- for (let i = 1; i < pts.length; i++) {
6055
- const q0 = pts[i - 1];
6056
- const q1 = pts[i];
6057
- const segLen = Math.hypot(q1.x - q0.x, q1.y - q0.y);
6058
- const sub = Math.max(1, Math.ceil(segLen / step));
6059
- for (let k = 1; k <= sub; k++) {
6060
- const f = k / sub;
6061
- stampAt({ x: q0.x + (q1.x - q0.x) * f, y: q0.y + (q1.y - q0.y) * f }, (i - 1 + f) / (pts.length - 1));
6062
- }
6063
- }
6064
- continue;
6065
- }
6066
- if (s.kind === "streak") {
6067
- const heading = (headingAt(e, tPass, w, h) ?? 0) + rotationAt(e, tPass);
6068
- const steps = Math.max(2, Math.ceil(sz / Math.min(cellW, cellH)));
6069
- const wgt = Math.min(1, (s.width ?? (scale === 1 ? 2 : 2e-3)) * scale / cellH);
6070
- for (let i = 0; i <= steps; i++) {
6071
- const k = i / steps;
6072
- const x = p.x - Math.cos(heading) * sz * (1 - k);
6073
- const y = p.y - Math.sin(heading) * sz * (1 - k);
6074
- const c = Math.floor(x / cellW);
6075
- const r = Math.floor(y / cellH);
6076
- if (c < 0 || c >= cols || r < 0 || r >= rows) continue;
6077
- compose(r * cols + c, lum, a * k * wgt, layer.blend);
6078
- }
6079
- continue;
6080
- }
6081
- let centerX = p.x;
6082
- let centerY = p.y;
6083
- let halfX = sz / 2;
6084
- let halfY = s.kind === "rect" ? (e.size2 ?? sz) * (e.size > 0 ? sz / e.size : 1) / 2 : sz / 2;
6085
- let boxRot = 0, boxHx = 0, boxHy = 0;
6086
- if (s.kind === "text") {
6087
- const box = textBox(s, e, p);
6088
- centerX = box.cx;
6089
- centerY = box.cy;
6090
- halfX = box.halfX;
6091
- halfY = box.halfY;
6092
- } else if (s.kind === "textBlock") {
6093
- const box = textBlockBox(s, e, p, w, h);
6094
- centerX = box.cx;
6095
- centerY = box.cy;
6096
- halfX = box.halfX;
6097
- halfY = box.halfY;
6098
- } else if (s.kind === "bar") {
6099
- const len = sz * barFraction(s, e.barIndex ?? 0);
6100
- if (len <= 0) continue;
6101
- const th = e.size2 !== void 0 ? e.size2 * (e.size > 0 ? sz / e.size : 1) : sz * 0.2;
6102
- const box = barBox(s.direction ?? "right", len, th);
6103
- const rot = rotationAt(e, tPass);
6104
- const cr = Math.cos(rot);
6105
- const sr = Math.sin(rot);
6106
- centerX = p.x + box.cx * cr - box.cy * sr;
6107
- centerY = p.y + box.cx * sr + box.cy * cr;
6108
- boxRot = rot;
6109
- boxHx = box.halfX;
6110
- boxHy = box.halfY;
6111
- halfX = Math.abs(box.halfX * cr) + Math.abs(box.halfY * sr);
6112
- halfY = Math.abs(box.halfX * sr) + Math.abs(box.halfY * cr);
6113
- } else if (s.kind === "rect") {
6114
- const rot = rotationAt(e, tPass);
6115
- if (rot) {
6116
- boxRot = rot;
6117
- boxHx = halfX;
6118
- boxHy = halfY;
6119
- const cr = Math.abs(Math.cos(rot));
6120
- const sr = Math.abs(Math.sin(rot));
6121
- const hx = halfX * cr + halfY * sr;
6122
- halfY = halfX * sr + halfY * cr;
6123
- halfX = hx;
6124
- }
6125
- }
6126
- const circular = s.kind === "circle" || s.kind === "ring" || s.kind === "polygon";
6127
- const soft = (s.kind === "circle" || s.kind === "polygon") && !!s.soft;
6128
- const shapeWeight = s.kind === "polygon" ? polygonFill(s, sz / 2) : s.kind === "rect" && s.feather ? (1 - s.feather / 2) * (1 - s.feather / 2) : 1;
6540
+ if (entities.length > 0 && layer.sprite.kind !== "emoji") {
6541
+ const [y0, y1] = layer.position ? [layer.position.y, layer.position.y] : layer.region?.y ?? [0, 1];
6542
+ const plates = [y0, (y0 + y1) / 2, y1].map((y) => {
6543
+ const rgb = backgroundRgbAt(spec, y * h, h, scale, void 0, void 0, opts.backgroundSeed ?? opts.seed, opts.t ?? COHESION_T);
6544
+ return { rgb, luma: 0.2126 * rgb.r + 0.7152 * rgb.g + 0.0722 * rgb.b };
6545
+ });
6546
+ const seps = entities.map((e) => {
6547
+ const hex = spriteHex(layer, e);
6548
+ if (hex === null) return Infinity;
6549
+ const ink = { rgb: hexRgb(hex), luma: hexLuma(hex) };
6550
+ const sep = Math.max(...plates.map((bg) => colourSeparation(layer, ink, bg)));
6551
+ if (sep >= LOW_CONTRAST_FLOOR || layer.blend === "lighter" || layer.blend === "screen") return sep;
6552
+ const under = groundUnder(spec, allEntities, li, hidden, positionAt(e, 0, w, h), w, h);
6553
+ return under ? colourSeparation(layer, ink, under) : sep;
6554
+ });
6555
+ const best = Math.max(...seps);
6556
+ if (best < LOW_CONTRAST_FLOOR) {
6129
6557
  const additive = layer.blend === "lighter" || layer.blend === "screen";
6130
- const glow = soft && additive;
6131
- const reachX = glow ? halfX * GLOW_SPREAD : halfX;
6132
- const reachY = glow ? halfY * GLOW_SPREAD : halfY;
6133
- const c0 = Math.max(0, Math.floor((centerX - reachX) / cellW));
6134
- const c1 = Math.min(cols - 1, Math.floor((centerX + reachX) / cellW));
6135
- const r0 = Math.max(0, Math.floor((centerY - reachY) / cellH));
6136
- const r1 = Math.min(rows - 1, Math.floor((centerY + reachY) / cellH));
6137
- const inkWeight = (s.kind === "text" || s.kind === "emoji" || s.kind === "textBlock" ? 0.55 : 1) * (s.kind === "textBlock" ? textBlockRevealFraction(s, w, h, t) * (s.opacity ?? 1) : 1);
6138
- const byArea = s.kind === "rect" || s.kind === "bar";
6139
- for (let r = r0; r <= r1; r++) {
6140
- const fy = byArea ? Math.max(0, Math.min((r + 1) * cellH, centerY + halfY) - Math.max(r * cellH, centerY - halfY)) / cellH : 1;
6141
- if (fy <= 0) continue;
6142
- for (let c = c0; c <= c1; c++) {
6143
- const dx = (c + 0.5) * cellW - centerX;
6144
- const dy = (r + 0.5) * cellH - centerY;
6145
- if (circular) {
6146
- const d = Math.sqrt(dx * dx + dy * dy);
6147
- const reach = glow ? reachX : halfX;
6148
- if (d > reach + Math.min(cellW, cellH) / 2) continue;
6149
- if (s.kind === "ring") {
6150
- const strokeW = (s.width ?? (scale === 1 ? 2 : 2e-3)) * scale;
6151
- if (d < halfX - strokeW) continue;
6152
- }
6153
- let wgt;
6154
- if (glow) {
6155
- const core = halfX;
6156
- if (d <= core) wgt = 1;
6157
- else {
6158
- const f = Math.max(0, 1 - (d - core) / Math.max(reachX - core, 1e-6));
6159
- wgt = f * f;
6160
- }
6161
- } else {
6162
- wgt = soft ? Math.max(0.1, 1 - d / Math.max(halfX, 1e-6)) : 1;
6163
- }
6164
- compose(r * cols + c, lum, a * wgt * shapeWeight, layer.blend);
6165
- } else if (byArea) {
6166
- if (boxRot) {
6167
- const cov = rotatedBoxCoverage(centerX, centerY, boxHx, boxHy, boxRot, c * cellW, r * cellH, (c + 1) * cellW, (r + 1) * cellH);
6168
- if (cov > 0) compose(r * cols + c, lum, a * shapeWeight * cov, layer.blend);
6169
- } else {
6170
- const fx = Math.max(0, Math.min((c + 1) * cellW, centerX + halfX) - Math.max(c * cellW, centerX - halfX)) / cellW;
6171
- if (fx > 0) compose(r * cols + c, lum, a * shapeWeight * fx * fy, layer.blend);
6172
- }
6173
- } else {
6174
- compose(r * cols + c, lum, a * inkWeight * shapeWeight, layer.blend);
6175
- }
6176
- }
6177
- }
6558
+ warnings.push({
6559
+ path: `layers[${li}].sprite`,
6560
+ code: "low-contrast-layer",
6561
+ message: additive ? `additive layer adds almost no light (luma ${best.toFixed(3)}) \u2014 a "lighter"/"screen" layer is only as visible as it is bright` : `layer colour is within ${best.toFixed(3)} of the background, or of the solid layer under it (floor ${LOW_CONTRAST_FLOOR}) \u2014 it will read as background, not as content`
6562
+ });
6178
6563
  }
6179
- if (layer.links) {
6180
- const positions = entities.map((e) => posOf(scene, e, tPass));
6181
- const maxDistPx = layer.links.maxDist * scale;
6182
- const motionWraps = ["drift", "rise", "wander"].includes(layer.motion.type);
6183
- const edges = linkEdges(layer.links, positions, maxDistPx, layer.wrap !== false && motionWraps, w, h);
6184
- const la = Math.min(1, (layer.links.alpha ?? 0.6) * lifeA);
6185
- const lum = layer.links.color ? hexLuma(layer.links.color) : 0.7;
6186
- const defaultWidth = scale === 1 ? 1 : 1 / LIMITS.referenceViewport;
6187
- const wgt = Math.min(1, (layer.links.width ?? defaultWidth) * scale / cellH) * la * alphaScale;
6188
- for (const edge of edges) {
6189
- const pi = positions[edge.i];
6190
- const pj = positions[edge.j];
6191
- const steps = Math.max(1, Math.ceil(edge.dist / Math.min(cellW, cellH)));
6192
- for (let i = 0; i <= steps; i++) {
6193
- const k = i / steps;
6194
- const c = Math.floor((pi.x + (pj.x - pi.x) * k) / cellW);
6195
- const r = Math.floor((pi.y + (pj.y - pi.y) * k) / cellH);
6196
- if (c < 0 || c >= cols || r < 0 || r >= rows) continue;
6197
- compose(r * cols + c, lum, wgt, layer.blend);
6198
- }
6199
- }
6564
+ }
6565
+ }
6566
+ let totalCoverage = 0;
6567
+ let additiveInk = 0;
6568
+ for (let li = 0; li < spec.layers.length; li++) {
6569
+ if (hidden?.[li]) continue;
6570
+ const layer = spec.layers[li];
6571
+ const entities = allEntities[li];
6572
+ const paintA = layer.opacity === void 0 && layer.group === void 0 ? 1 : paintOpacity(spec, layer);
6573
+ const map = paintMap(spec, layer, w, h, scale);
6574
+ const areaScale = map ? paintSize(spec, layer) ** 2 : 1;
6575
+ const additive = layer.blend === "lighter" || layer.blend === "screen";
6576
+ for (const e of entities) {
6577
+ const r = e.size / 2;
6578
+ let pixArea;
6579
+ if (layer.sprite.kind === "circle") {
6580
+ pixArea = Math.PI * r * r;
6581
+ } else if (layer.sprite.kind === "polygon") {
6582
+ pixArea = polygonArea(polygonPoints(layer.sprite, r));
6583
+ } else if (layer.sprite.kind === "bar") {
6584
+ pixArea = e.size * barFraction(layer.sprite, e.barIndex ?? 0) * (e.size2 ?? e.size * 0.2);
6585
+ } else if (layer.sprite.kind === "textBlock") {
6586
+ const fsPx = layer.sprite.fontSize * scale;
6587
+ const lh = (layer.sprite.lineHeight ?? 1.4) * fsPx;
6588
+ const maxWPx = layer.sprite.maxWidth * scale;
6589
+ const lines = breakTextBlock(layer.sprite.text, maxWPx / fsPx, textMetricsClassFor(layer.sprite.font));
6590
+ pixArea = maxWPx * lines.length * lh * 0.55 * (layer.sprite.opacity ?? 1);
6591
+ } else if (layer.sprite.kind === "text") {
6592
+ const b = textBoxAt(layer.sprite, e, positionAt(e, 0, w, h), spec, w, h);
6593
+ pixArea = (b.x1 - b.x0) * (b.y1 - b.y0) * 0.55;
6594
+ } else {
6595
+ pixArea = e.size * e.size;
6596
+ }
6597
+ if (areaScale !== 1) pixArea *= areaScale;
6598
+ if (pixArea > w * h) pixArea = w * h;
6599
+ let duty = 1;
6600
+ if (layer.emit) {
6601
+ const [ga, gb] = layer.emit.grow ?? [1, 1];
6602
+ const g2 = (ga * ga + ga * gb + gb * gb) / 3;
6603
+ duty = Math.min(layer.emit.life, layer.emit.every) / layer.emit.every * 0.5 * g2;
6604
+ }
6605
+ const ink = paintA === 1 ? pixArea * e.alpha * duty / (w * h) : pixArea * e.alpha * duty * paintA / (w * h);
6606
+ totalCoverage += ink;
6607
+ if (additive) additiveInk += ink;
6608
+ }
6609
+ if (layer.links) {
6610
+ const positions = entities.map((e) => positionAt(e, 0, w, h));
6611
+ const maxDistPx = layer.links.maxDist * scale;
6612
+ const edges = linkEdges(layer.links, positions, maxDistPx, layer.wrap !== false, w, h);
6613
+ const defaultWidth = scale === 1 ? 1 : 1 / LIMITS.referenceViewport;
6614
+ const lwPx = (layer.links.width ?? defaultWidth) * scale;
6615
+ const la = layer.links.alpha ?? 1;
6616
+ for (const edge of edges) totalCoverage += paintA === 1 ? edge.dist * lwPx * la / (w * h) : edge.dist * lwPx * la * paintA / (w * h);
6617
+ }
6618
+ }
6619
+ const density = spec.density ?? "normal";
6620
+ const wouldBeSparse = totalCoverage < 5e-4 && spec.layers.length > 0;
6621
+ if (wouldBeSparse && density !== "sparse") {
6622
+ warnings.push({
6623
+ path: "layers",
6624
+ code: "sparse-scene",
6625
+ message: `alpha-weighted coverage is ${(totalCoverage * 100).toFixed(4)}% \u2014 scene will look empty on most displays`
6626
+ });
6627
+ }
6628
+ if (additiveInk > ADDITIVE_INK_CEILING) {
6629
+ warnings.push({
6630
+ path: "layers",
6631
+ code: "additive-saturation",
6632
+ message: `"lighter"/"screen" layers stack ${additiveInk.toFixed(1)}\xD7 the frame's area in alpha-weighted light \u2014 overlapping additive sprites clip to white on a real canvas, and the analytic perception model sums them without a ceiling so its coverage/luminance/dominance numbers cannot see it. Thin the count, lower alpha (summed additive alpha at any point \u2272 0.5), or drop blend on the densest layer`
6633
+ });
6634
+ }
6635
+ if (totalEntities > 500 && density !== "dense") {
6636
+ warnings.push({
6637
+ path: "layers",
6638
+ code: "dense-scene",
6639
+ message: `${totalEntities} entities \u2014 scene may feel crowded and hurt performance on low-end devices`
6640
+ });
6641
+ }
6642
+ if (density === "sparse" && totalCoverage > SPARSE_DECLARED_MAX_COVERAGE) {
6643
+ warnings.push({
6644
+ path: "density",
6645
+ code: "density-mismatch",
6646
+ message: `declared sparse but alpha-weighted coverage is ${(totalCoverage * 100).toFixed(2)}% \u2014 either the declaration or the scene is wrong`
6647
+ });
6648
+ } else if (density === "dense" && wouldBeSparse) {
6649
+ warnings.push({
6650
+ path: "density",
6651
+ code: "density-mismatch",
6652
+ message: `declared dense but alpha-weighted coverage is ${(totalCoverage * 100).toFixed(4)}% \u2014 the scene will look empty`
6653
+ });
6654
+ }
6655
+ if (spec.layers.length > 0 && textLayerCount > 0 && motionLayerCount === 0) {
6656
+ const textRatio = textLayerCount / spec.layers.length;
6657
+ if (textRatio >= 0.8) {
6658
+ warnings.push({
6659
+ path: "layers",
6660
+ code: "text-heavy",
6661
+ message: "most layers are static text \u2014 reads as a document, not a screensaver"
6662
+ });
6663
+ }
6664
+ }
6665
+ for (let li = 0; li < spec.layers.length; li++) {
6666
+ if (hidden?.[li]) continue;
6667
+ const layer = spec.layers[li];
6668
+ if (!layer.links || layer.links.mode === "chain") continue;
6669
+ const entities = allEntities[li];
6670
+ const positions = entities.map((e) => positionAt(e, 0, w, h));
6671
+ const maxDist = layer.links.maxDist * scale;
6672
+ const pairs = linkPairs(positions, layer.links.k, maxDist, layer.wrap !== false, w, h);
6673
+ const maxPossible = Math.min(entities.length * layer.links.k, entities.length * (entities.length - 1) / 2);
6674
+ if (maxPossible > 0 && pairs.length / maxPossible < 0.1) {
6675
+ warnings.push({
6676
+ path: `layers[${li}].links`,
6677
+ code: "link-starvation",
6678
+ message: `only ${pairs.length}/${maxPossible} possible edges formed \u2014 raise count or widen links.maxDist`
6679
+ });
6680
+ }
6681
+ }
6682
+ for (let li = 0; li < spec.layers.length; li++) {
6683
+ if (hidden?.[li]) continue;
6684
+ const layer = spec.layers[li];
6685
+ if (layer.motion.type === "static") continue;
6686
+ const entities = allEntities[li];
6687
+ if (entities.length < 3) continue;
6688
+ const speeds = entities.map((e) => Math.sqrt(e.vx * e.vx + e.vy * e.vy));
6689
+ const mean = speeds.reduce((a, b) => a + b, 0) / speeds.length;
6690
+ if (mean === 0) continue;
6691
+ const maxDev = Math.max(...speeds.map((s) => Math.abs(s - mean)));
6692
+ if (maxDev / mean < 0.05) {
6693
+ warnings.push({
6694
+ path: `layers[${li}].motion`,
6695
+ code: "uniform-motion",
6696
+ message: "all entities move at nearly identical speed \u2014 widen the speed range for visual variety"
6697
+ });
6698
+ }
6699
+ }
6700
+ const composed = spec.layers.map((l, li) => ({ l, li, ents: allEntities[li] })).filter(({ li }) => !hidden?.[li]).filter(({ l }) => !["bounce", "warp", "path"].includes(l.motion.type)).filter(({ l }) => !(l.motion.type === "orbit" && l.motion.center && "layer" in l.motion.center));
6701
+ const composedCount = composed.reduce((s, c) => s + c.ents.length, 0);
6702
+ if (composedCount >= 8) {
6703
+ const positions = composed.flatMap(({ ents }) => ents.map((e) => positionAt(e, 0, w, h)));
6704
+ const px = positions.reduce((s, p) => s + p.x, 0) / positions.length / w;
6705
+ const py = positions.reduce((s, p) => s + p.y, 0) / positions.length / h;
6706
+ const offX = Math.abs(px - 0.5);
6707
+ const offY = Math.abs(py - 0.5);
6708
+ const anchoredOn = (axis) => composed.reduce((n, { l, ents }) => n + (edgeAnchored(l.region?.[axis]) ? ents.length : 0), 0) / composedCount;
6709
+ const intentional = offY > 0.2 && offX <= 0.2 && anchoredOn("y") >= 0.5 || offX > 0.2 && offY <= 0.2 && anchoredOn("x") >= 0.5;
6710
+ if ((offX > 0.2 || offY > 0.2) && !intentional) {
6711
+ const grid = luminanceGrid({ ...spec, layers: composed.map(({ l }) => l) }, { viewport: { width: w, height: h }, t: opts.t ?? COHESION_T, seed: opts.seed });
6712
+ const c = grid.centroid;
6713
+ if (c && (Math.abs(c.x - 0.5) > 0.2 || Math.abs(c.y - 0.5) > 0.2)) {
6714
+ warnings.push({
6715
+ path: "layers",
6716
+ code: "off-center",
6717
+ message: `centroid at (${c.x.toFixed(2)}, ${c.y.toFixed(2)}) \u2014 composition is heavily offset from center`
6718
+ });
6200
6719
  }
6201
6720
  }
6202
- };
6203
- const ghost = spec.ghosting ?? 0;
6204
- if (ghost > 0) {
6205
- const dt = 1e3 / 60;
6206
- const windowFrames = Math.min(Math.ceil(Math.log(1 / 255) / Math.log(ghost)), LIMITS.maxGhostReplayFrames);
6207
- const taps = Math.min(GHOST_TAPS, windowFrames);
6208
- const spacing = windowFrames / taps;
6209
- for (let i = taps; i >= 1; i--) {
6210
- const tPass = t - i * spacing * dt;
6211
- if (tPass < 0) continue;
6212
- const bracketWeight = Math.pow(ghost, (i - 1) * spacing + 1) * (1 - Math.pow(ghost, spacing)) / (1 - ghost);
6213
- splatPass(tPass, Math.min(1, bracketWeight));
6721
+ }
6722
+ const textBoxes = [];
6723
+ for (let li = 0; li < spec.layers.length; li++) {
6724
+ if (hidden?.[li]) continue;
6725
+ const layer = spec.layers[li];
6726
+ const s = layer.sprite;
6727
+ if (layer.motion.type !== "static") continue;
6728
+ if (s.kind !== "text" && s.kind !== "textBlock") continue;
6729
+ const label = layer.key ? `\`${layer.key}\`` : `layers[${li}]`;
6730
+ for (const e of allEntities[li]) {
6731
+ const p = positionAt(e, 0, w, h);
6732
+ const raw = s.kind === "textBlock" ? textBlockBoxAt(s, p, w, h) : textBoxAt(s, e, p, spec, w, h);
6733
+ const map = paintMap(spec, layer, w, h, scale);
6734
+ const box = map ? { ...raw, ...mapBox(map, raw) } : raw;
6735
+ const baseAlpha = Math.max(0, Math.min(1, e.alpha - e.pulseAmp)) * (s.kind === "textBlock" ? s.opacity ?? 1 : 1);
6736
+ const alpha = layer.opacity === void 0 && layer.group === void 0 ? baseAlpha : baseAlpha * paintOpacity(spec, layer);
6737
+ const str = s.kind === "textBlock" ? s.text : s.strings[e.spriteIndex] ?? s.strings[0] ?? "";
6738
+ textBoxes.push({ li, label, alpha, str, ...box, rawW: raw.x1 - raw.x0, rawH: raw.y1 - raw.y0 });
6214
6739
  }
6215
6740
  }
6216
- splatPass(t, 1);
6217
- return finishGrid(cols, rows, cells, bgRow, bgCells);
6218
- }
6219
- function finishGrid(cols, rows, cells, bgRow, bgCells) {
6220
- let sum = 0;
6221
- let covered = 0;
6222
- let devSum = 0;
6223
- let cxAcc = 0;
6224
- let cyAcc = 0;
6225
- const rowProfile = new Array(rows).fill(0);
6226
- const colProfile = new Array(cols).fill(0);
6227
- for (let r = 0; r < rows; r++) {
6228
- for (let c = 0; c < cols; c++) {
6229
- const v = cells[r * cols + c];
6230
- sum += v;
6231
- const dev = Math.abs(v - (bgCells ? bgCells[r * cols + c] : bgRow[r]));
6232
- if (dev > 0.03) covered++;
6233
- devSum += dev;
6234
- cxAcc += dev * (c + 0.5);
6235
- cyAcc += dev * (r + 0.5);
6236
- rowProfile[r] += dev;
6237
- colProfile[c] += dev;
6741
+ const asFraction = (b) => ({
6742
+ x: +(b.x0 / w).toFixed(4),
6743
+ y: +(b.y0 / h).toFixed(4),
6744
+ w: +((b.x1 - b.x0) / w).toFixed(4),
6745
+ h: +((b.y1 - b.y0) / h).toFixed(4)
6746
+ });
6747
+ const tolX = w * 0.01;
6748
+ const tolY = h * 0.01;
6749
+ const offFlagged = /* @__PURE__ */ new Set();
6750
+ for (const b of textBoxes) {
6751
+ if (offFlagged.has(b.li)) continue;
6752
+ const edges = [];
6753
+ if (b.x0 < -tolX) edges.push(`left edge by ~${Math.round(-b.x0)}px`);
6754
+ if (b.x1 > w + tolX) edges.push(`right edge by ~${Math.round(b.x1 - w)}px`);
6755
+ if (b.y0 < -tolY) edges.push(`top edge by ~${Math.round(-b.y0)}px`);
6756
+ if (b.y1 > h + tolY) edges.push(`bottom edge by ~${Math.round(b.y1 - h)}px`);
6757
+ if (edges.length === 0) continue;
6758
+ offFlagged.add(b.li);
6759
+ warnings.push({
6760
+ path: `layers[${b.li}]`,
6761
+ code: "text-off-screen",
6762
+ message: `${b.label} text runs off the ${edges.join(" and the ")} at ${w}\xD7${h} \u2014 move position, or shrink fontSize / maxWidth`
6763
+ });
6764
+ }
6765
+ const pairFlagged = /* @__PURE__ */ new Set();
6766
+ for (let i = 0; i < textBoxes.length; i++) {
6767
+ for (let j = i + 1; j < textBoxes.length; j++) {
6768
+ const a = textBoxes[i];
6769
+ const b = textBoxes[j];
6770
+ if (a.li === b.li) continue;
6771
+ const pair = `${Math.min(a.li, b.li)}:${Math.max(a.li, b.li)}`;
6772
+ if (pairFlagged.has(pair)) continue;
6773
+ const ix = Math.min(a.x1, b.x1) - Math.max(a.x0, b.x0);
6774
+ const iy = Math.min(a.y1, b.y1) - Math.max(a.y0, b.y0);
6775
+ if (ix <= 0 || iy <= 0) continue;
6776
+ const smaller = Math.min((a.x1 - a.x0) * (a.y1 - a.y0), (b.x1 - b.x0) * (b.y1 - b.y0));
6777
+ if (smaller <= 0) continue;
6778
+ const share = ix * iy / smaller;
6779
+ if (share <= 0.1) continue;
6780
+ if (isOutlineStack(a, b)) continue;
6781
+ pairFlagged.add(pair);
6782
+ const [first, second] = a.li < b.li ? [a, b] : [b, a];
6783
+ warnings.push({
6784
+ path: `layers[${first.li}]`,
6785
+ code: "text-overlap",
6786
+ message: `${first.label} and ${second.label} text boxes overlap (~${Math.round(share * 100)}% of the smaller one) \u2014 they will paint over each other; move one or shrink it`,
6787
+ // The two boxes, so an agent can move one without re-deriving the
6788
+ // layout. Additive: the message is unchanged.
6789
+ boxes: [asFraction(first), asFraction(second)]
6790
+ });
6238
6791
  }
6239
6792
  }
6240
- for (let r = 0; r < rows; r++) rowProfile[r] = rowProfile[r] / cols;
6241
- for (let c = 0; c < cols; c++) colProfile[c] = colProfile[c] / rows;
6242
- return {
6243
- cols,
6244
- rows,
6245
- cells,
6246
- background: bgRow,
6247
- ...bgCells ? { backgroundCells: bgCells } : {},
6248
- meanLuminance: sum / (cols * rows),
6249
- coverage: covered / (cols * rows),
6250
- centroid: devSum > 1e-6 ? { x: cxAcc / devSum / cols, y: cyAcc / devSum / rows } : null,
6251
- rowProfile,
6252
- colProfile
6253
- };
6254
- }
6255
- var DITHER = [
6256
- [0.5 / 8, 4.5 / 8],
6257
- [2.5 / 8, 6.5 / 8],
6258
- [1.5 / 8, 5.5 / 8],
6259
- [3.5 / 8, 7.5 / 8]
6260
- ];
6261
- var DOT_BIT = [
6262
- [1, 8],
6263
- [2, 16],
6264
- [4, 32],
6265
- [64, 128]
6266
- ];
6267
- function autoExpose(grid) {
6268
- const sorted = [...grid.cells].sort((a, b) => a - b);
6269
- const lo = sorted[0];
6270
- const hi = Math.max(sorted[Math.floor(sorted.length * 0.98)], lo + 0.08);
6271
- return (v) => Math.pow(Math.max(0, Math.min(1, (v - lo) / (hi - lo))), 0.8);
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
+ }
6832
+ const readFlagged = /* @__PURE__ */ new Set();
6833
+ for (const b of textBoxes) {
6834
+ if (readFlagged.has(b.li)) continue;
6835
+ const layer = spec.layers[b.li];
6836
+ const s = layer.sprite;
6837
+ if (s.kind !== "text" && s.kind !== "textBlock" || s.role !== "read") continue;
6838
+ const boxes = textBoxes.filter((x) => x.li === b.li);
6839
+ readFlagged.add(b.li);
6840
+ const ink = hexRgb(s.color ?? "#e6e8ef");
6841
+ const textPlate = (base, alpha) => {
6842
+ if (layer.blend === "lighter" || layer.blend === "screen") return additivePlate(base, ink, alpha, layer.blend);
6843
+ if (layer.blend === "multiply") {
6844
+ return {
6845
+ r: base.r * (1 - alpha + ink.r * alpha),
6846
+ g: base.g * (1 - alpha + ink.g * alpha),
6847
+ b: base.b * (1 - alpha + ink.b * alpha)
6848
+ };
6849
+ }
6850
+ return sourceOverPlate(base, ink, alpha);
6851
+ };
6852
+ let worst = null;
6853
+ for (const box of boxes) {
6854
+ const cy = (box.y0 + box.y1) / 2;
6855
+ const cx = (box.x0 + box.x1) / 2;
6856
+ const groundRgb = backgroundRgbAt(spec, cy, h, scale, cx, w, opts.backgroundSeed ?? opts.seed, opts.t ?? COHESION_T);
6857
+ const ground = legibilityRatio(textPlate(groundRgb, box.alpha), groundRgb);
6858
+ const lit = brightestAdditivePlate(spec, allEntities, groundRgb, box, w, h);
6859
+ const plate = lit ? legibilityRatio(textPlate(lit.rgb, box.alpha), lit.rgb) : Infinity;
6860
+ if (!worst || Math.min(ground, plate) < Math.min(worst.ground, worst.plate)) {
6861
+ worst = { ground, plate, plateLabel: lit?.label ?? null };
6862
+ }
6863
+ }
6864
+ if (worst && Math.min(worst.ground, worst.plate) < READ_LEGIBILITY_FLOOR) {
6865
+ const under = worst.plateLabel ? ` and ${worst.plate.toFixed(1)}:1 under ${worst.plateLabel} at its brightest` : "";
6866
+ warnings.push({
6867
+ path: `layers[${b.li}].sprite`,
6868
+ code: "text-legibility",
6869
+ message: `${b.label} is declared role: 'read' but its colour reads at ${worst.ground.toFixed(1)}:1 against the background at the box centre${under} \u2014 below ${READ_LEGIBILITY_FLOOR}:1; brighten the text, darken the ground under it, or declare role: 'atmosphere' if it is texture`
6870
+ });
6871
+ }
6872
+ const edges = [];
6873
+ const x0 = Math.min(...boxes.map((x) => x.x0));
6874
+ const x1 = Math.max(...boxes.map((x) => x.x1));
6875
+ const y0 = Math.min(...boxes.map((x) => x.y0));
6876
+ const y1 = Math.max(...boxes.map((x) => x.y1));
6877
+ if (x0 < w * READ_SAFE_AREA) edges.push("left");
6878
+ if (x1 > w * (1 - READ_SAFE_AREA)) edges.push("right");
6879
+ if (y0 < h * READ_SAFE_AREA) edges.push("top");
6880
+ if (y1 > h * (1 - READ_SAFE_AREA)) edges.push("bottom");
6881
+ if (edges.length > 0) {
6882
+ warnings.push({
6883
+ path: `layers[${b.li}]`,
6884
+ code: "text-safe-area",
6885
+ message: `${b.label} is declared role: 'read' but its box is within ${READ_SAFE_AREA * 100}% of the ${edges.join(" and ")} edge${edges.length === 1 ? "" : "s"} at ${w}\xD7${h} \u2014 displays crop and bezels hide that zone; move it inward`,
6886
+ boxes: [asFraction({ x0, y0, x1, y1 })]
6887
+ });
6888
+ }
6889
+ }
6890
+ return warnings;
6272
6891
  }
6273
- function renderBrailleMap(grid) {
6274
- const expose = autoExpose(grid);
6275
- const outRows = [];
6276
- for (let br = 0; br < Math.floor(grid.rows / 4); br++) {
6277
- let line = "";
6278
- for (let bc = 0; bc < Math.floor(grid.cols / 2); bc++) {
6279
- let bits = 0;
6280
- for (let dr = 0; dr < 4; dr++) {
6281
- for (let dc = 0; dc < 2; dc++) {
6282
- const v = expose(grid.cells[(br * 4 + dr) * grid.cols + (bc * 2 + dc)]);
6283
- if (v > DITHER[dr][dc]) bits |= DOT_BIT[dr][dc];
6284
- }
6892
+ function brightestAdditivePlate(spec, allEntities, ground, box, w, h) {
6893
+ let best = null;
6894
+ for (let lj = 0; lj < spec.layers.length; lj++) {
6895
+ const layer = spec.layers[lj];
6896
+ const blend = layer.blend;
6897
+ if (blend !== "lighter" && blend !== "screen") continue;
6898
+ if (lj === box.li) continue;
6899
+ const entities = allEntities[lj];
6900
+ const moved = layer.transform !== void 0 || groupOf(spec, layer)?.transform !== void 0;
6901
+ for (const e of entities) {
6902
+ const hex = spriteHex(layer, e);
6903
+ if (hex === null) continue;
6904
+ const growScale = (e.motion === "warp" ? WARP_MAX_SCALE : 1) * (e.emit ? Math.max(e.emit.growFrom, e.emit.growTo) : 1);
6905
+ const maxDim = Math.max(e.size, e.size2 ?? 0) * (1 + e.growAmp) * growScale;
6906
+ if (maxDim < box.fs) continue;
6907
+ if (!moved && !entityReachesBox(e, maxDim / 2, box, w, h)) continue;
6908
+ const a = layer.opacity === void 0 && layer.group === void 0 ? Math.min(1, e.alpha + e.pulseAmp) : Math.min(1, e.alpha + e.pulseAmp) * paintOpacity(spec, layer);
6909
+ if (a <= 0) continue;
6910
+ const plate = additivePlate(ground, hexRgb(hex), a, blend);
6911
+ const lum = relativeLuminance(plate);
6912
+ if (!best || lum > best.lum) {
6913
+ best = { rgb: plate, label: layer.key ? `\`${layer.key}\`` : `layers[${lj}]`, lum };
6285
6914
  }
6286
- line += String.fromCharCode(10240 + bits);
6287
6915
  }
6288
- outRows.push(line);
6289
6916
  }
6290
- return outRows.join("\n");
6917
+ return best ? { rgb: best.rgb, label: best.label } : null;
6291
6918
  }
6292
- var DENSITY_RAMP = " .:-=+*#%@";
6293
- function renderDensityMap(grid) {
6294
- const expose = autoExpose(grid);
6295
- const ramp = DENSITY_RAMP;
6296
- const lines = [];
6297
- for (let r = 0; r < grid.rows; r++) {
6298
- let line = "";
6299
- for (let c = 0; c < grid.cols; c++) {
6300
- const v = expose(grid.cells[r * grid.cols + c]);
6301
- line += ramp[Math.min(ramp.length - 1, Math.floor(v * ramp.length))];
6919
+ function groundUnder(spec, allEntities, li, hidden, pt, w, h) {
6920
+ let plate = backgroundRgb(spec);
6921
+ let covered = false;
6922
+ for (let lj = 0; lj < li; lj++) {
6923
+ if (hidden?.[lj]) continue;
6924
+ const layer = spec.layers[lj];
6925
+ const sp = layer.sprite;
6926
+ if (layer.motion.type !== "static" || layer.blend !== void 0 || layer.transform !== void 0 || groupOf(spec, layer)?.transform !== void 0) continue;
6927
+ if ((sp.kind === "circle" || sp.kind === "polygon") && sp.soft) continue;
6928
+ if (sp.kind !== "circle" && sp.kind !== "rect" && sp.kind !== "polygon") continue;
6929
+ const a = layer.opacity === void 0 && layer.group === void 0 ? 1 : paintOpacity(spec, layer);
6930
+ for (const e of allEntities[lj]) {
6931
+ if (e.spinSpeed !== 0 || e.alpha * a <= 0) continue;
6932
+ const hex = spriteHex(layer, e);
6933
+ if (hex === null) continue;
6934
+ const c = positionAt(e, 0, w, h);
6935
+ const dx = pt.x - c.x;
6936
+ const dy = pt.y - c.y;
6937
+ let inside = false;
6938
+ if (sp.kind === "circle") inside = Math.hypot(dx, dy) <= e.size / 2;
6939
+ else if (sp.kind === "rect") {
6940
+ const rot = ((e.rotate ?? 0) % Math.PI + Math.PI) % Math.PI;
6941
+ const quarter = Math.abs(rot - Math.PI / 2) < 0.01;
6942
+ if (rot > 0.01 && rot < Math.PI - 0.01 && !quarter) continue;
6943
+ const hx = (quarter ? e.size2 ?? e.size : e.size) / 2;
6944
+ const hy = (quarter ? e.size : e.size2 ?? e.size) / 2;
6945
+ inside = Math.abs(dx) <= hx && Math.abs(dy) <= hy;
6946
+ } else {
6947
+ if (e.rotate) continue;
6948
+ inside = pointInPolygon(dx, dy, polygonPoints(sp, e.size / 2));
6949
+ }
6950
+ if (!inside) continue;
6951
+ plate = sourceOverPlate(plate, hexRgb(hex), Math.min(1, e.alpha * a));
6952
+ covered = true;
6302
6953
  }
6303
- lines.push(line);
6304
6954
  }
6305
- return lines.join("\n");
6955
+ return covered ? { rgb: plate, luma: 0.2126 * plate.r + 0.7152 * plate.g + 0.0722 * plate.b } : null;
6306
6956
  }
6307
- function textSprites(spec, opts = {}) {
6308
- spec = resolveTimelineAt(spec, opts.t ?? 5e3);
6309
- const scene = buildScene(spec, opts);
6310
- const t = opts.t ?? 5e3;
6311
- const out = [];
6312
- scene.layers.forEach(({ layer, entities }, layerIndex) => {
6313
- if (layer.sprite.kind === "textBlock") {
6314
- const s2 = layer.sprite;
6315
- const unitScale = Math.min(scene.w, scene.h);
6316
- const sizePx2 = s2.fontSize * unitScale;
6317
- const info = { layerIndex, key: layer.key, strings: [s2.text], count: entities.length, sizePx: Math.round(sizePx2) };
6318
- if (s2.reveal) info.revealed = Math.round(textBlockRevealFraction(s2, scene.w, scene.h, t) * 100) / 100;
6319
- out.push(info);
6320
- return;
6321
- }
6322
- if (layer.sprite.kind !== "text") return;
6323
- const s = layer.sprite;
6324
- const m = s.font ? /(\d+(?:\.\d+)?)px/.exec(s.font) : null;
6325
- const sizePx = m ? Number(m[1]) : entities[0] ? sizeAt(entities[0], t) : 0;
6326
- out.push({ layerIndex, key: layer.key, strings: [...s.strings], count: entities.length, sizePx: Math.round(sizePx) });
6327
- });
6328
- return out;
6957
+ function pointInPolygon(x, y, pts) {
6958
+ let inside = false;
6959
+ for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
6960
+ const a = pts[i];
6961
+ const b = pts[j];
6962
+ if (a.y > y !== b.y > y && x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x) inside = !inside;
6963
+ }
6964
+ return inside;
6329
6965
  }
6330
- function dominanceRanking(spec, opts = {}) {
6331
- return rankDominance(rawDominance(spec, opts));
6966
+ function entityReachesBox(e, r, box, w, h) {
6967
+ if (e.motion === "static") {
6968
+ const p = positionAt(e, 0, w, h);
6969
+ return p.x + r > box.x0 && p.x - r < box.x1 && p.y + r > box.y0 && p.y - r < box.y1;
6970
+ }
6971
+ if (e.motion === "orbit" && !e.orbitParent) {
6972
+ const span = e.orbitR + r;
6973
+ return e.orbitCx + span > box.x0 && e.orbitCx - span < box.x1 && e.orbitCy + span > box.y0 && e.orbitCy - span < box.y1;
6974
+ }
6975
+ return true;
6332
6976
  }
6333
- function rankDominance(raw) {
6334
- const total = raw.reduce((s, r) => s + r.weight, 0) || 1;
6335
- return raw.slice().sort((a, b) => b.weight - a.weight).map((r, i) => ({
6336
- rank: i + 1,
6337
- layerIndex: r.layerIndex,
6338
- key: r.key,
6339
- share: r.weight / total,
6340
- entityCount: r.entityCount,
6341
- meanLuma: r.meanLuma,
6342
- factors: r.factors
6343
- }));
6977
+ var OUTLINE_STACK_OFFSET = 0.15;
6978
+ function isOutlineStack(a, b) {
6979
+ if (a.str !== b.str || a.str.trim() === "") return false;
6980
+ const fs = Math.min(a.fs, b.fs);
6981
+ if (fs <= 0 || Math.abs(a.fs - b.fs) > 0.1 * Math.max(a.fs, b.fs)) return false;
6982
+ const dx = (a.x0 + a.x1) / 2 - (b.x0 + b.x1) / 2;
6983
+ const dy = (a.y0 + a.y1) / 2 - (b.y0 + b.y1) / 2;
6984
+ return Math.hypot(dx, dy) <= OUTLINE_STACK_OFFSET * fs;
6344
6985
  }
6345
- function rawDominance(spec, opts = {}) {
6346
- spec = resolveTimelineAt(spec, opts.t ?? 5e3);
6347
- const scene = buildScene(spec, opts);
6348
- const t = opts.t ?? 5e3;
6349
- const { w, h, scale } = scene;
6350
- const bgMean = backgroundLuma(spec);
6351
- const ghost = spec.ghosting ?? 0;
6352
- let ghostWindowMs = 0;
6353
- let ghostMeanW = 0;
6354
- if (ghost > 0) {
6355
- const dt = 1e3 / 60;
6356
- const frames = Math.min(Math.ceil(Math.log(1 / 255) / Math.log(ghost)), LIMITS.maxGhostReplayFrames);
6357
- ghostWindowMs = frames * dt;
6358
- ghostMeanW = ghost * (1 - Math.pow(ghost, frames)) / (1 - ghost) / frames;
6359
- }
6360
- const sweptDistance = (e, spanMs) => {
6361
- const span = Math.min(spanMs, t);
6362
- if (span <= 0) return 0;
6363
- const p0 = posOf(scene, e, t - span);
6364
- const p1 = posOf(scene, e, t);
6365
- const dx = p1.x - p0.x;
6366
- const dy = p1.y - p0.y;
6367
- if (Math.abs(dx) > w / 2 || Math.abs(dy) > h / 2) return 0;
6368
- return Math.sqrt(dx * dx + dy * dy);
6369
- };
6370
- const raw = scene.layers.map(({ layer, entities }, layerIndex) => {
6371
- const lifeA = layer.opacity === void 0 && layer.group === void 0 ? lifeAlphaAt(layer.life, t) : lifeAlphaAt(layer.life, t) * paintOpacity(spec, layer);
6372
- const painted = layer.transform !== void 0 || layer.group !== void 0;
6373
- const areaScale = painted ? paintSize(spec, layer) ** 2 : 1;
6374
- let area = 0;
6375
- let lumAcc = 0;
6376
- for (const e of entities) {
6377
- const a = alphaAt(e, t) * lifeA;
6378
- const sz = sizeAt(e, t);
6379
- const s = layer.sprite;
6380
- let entArea;
6381
- if (s.kind === "circle") entArea = Math.PI * (sz / 2) * (sz / 2);
6382
- else if (s.kind === "ring") entArea = Math.PI * sz * ((s.width ?? (scale === 1 ? 2 : 2e-3)) * scale) * LINE_SALIENCE;
6383
- else if (s.kind === "streak") entArea = sz * ((s.width ?? (scale === 1 ? 2 : 2e-3)) * scale) * LINE_SALIENCE;
6384
- else if (s.kind === "rect") {
6385
- const h2 = e.size2 !== void 0 ? e.size2 * (e.size > 0 ? sz / e.size : 1) : sz;
6386
- const f = s.feather ? (1 - s.feather / 2) * (1 - s.feather / 2) : 1;
6387
- entArea = sz * h2 * f;
6388
- } else if (s.kind === "polygon") entArea = polygonArea(polygonPoints(s, sz / 2));
6389
- else if (s.kind === "bar") {
6390
- const th = e.size2 !== void 0 ? e.size2 * (e.size > 0 ? sz / e.size : 1) : sz * 0.2;
6391
- entArea = sz * barFraction(s, e.barIndex ?? 0) * th;
6392
- } else if (s.kind === "stroke") {
6393
- const meanTaper = s.taper ? 0.64 : 1;
6394
- entArea = pathLength(strokeSamples(s, sz / 2)) * strokeWidthPx(s, scale) * meanTaper * LINE_SALIENCE;
6395
- } else if (s.kind === "text") {
6396
- const box = textBox(s, e, { x: 0, y: 0 });
6397
- entArea = box.halfX * 2 * box.halfY * 2 * 0.55;
6398
- } else if (s.kind === "textBlock") {
6399
- const box = textBlockBox(s, e, { x: 0, y: 0 }, w, h);
6400
- entArea = box.halfX * 2 * box.halfY * 2 * 0.55 * textBlockRevealFraction(s, w, h, t) * (s.opacity ?? 1);
6401
- } else entArea = sz * sz * 0.55;
6402
- area += painted ? entArea * areaScale * a : entArea * a;
6403
- if (layer.trail) {
6404
- const fade = layer.trail.fade ?? 1;
6405
- const span = sweptDistance(e, layer.trail.length);
6406
- area += span * sz * 0.65 * Math.max(0, 1 - fade / 2) * a;
6407
- }
6408
- if (ghost > 0) {
6409
- area += sweptDistance(e, ghostWindowMs) * sz * ghostMeanW * a;
6410
- }
6411
- lumAcc += spriteLuma(layer, e);
6412
- }
6413
- if (layer.links && entities.length > 1) {
6414
- const positions = entities.map((e) => posOf(scene, e, t));
6415
- const motionWraps = ["drift", "rise", "wander"].includes(layer.motion.type);
6416
- const edges = linkEdges(layer.links, positions, layer.links.maxDist * scale, layer.wrap !== false && motionWraps, w, h);
6417
- const la = (layer.links.alpha ?? 0.6) * lifeA;
6418
- const defaultWidth = scale === 1 ? 1 : 1 / LIMITS.referenceViewport;
6419
- const lw = (layer.links.width ?? defaultWidth) * scale;
6420
- for (const edge of edges) area += edge.dist * lw * la * LINE_SALIENCE;
6421
- }
6422
- const meanLuma = entities.length ? lumAcc / entities.length : 0;
6423
- const contrast = Math.abs(meanLuma - bgMean) + 0.1;
6424
- const blendBoost = layer.blend === "lighter" || layer.blend === "screen" ? 1.3 : layer.blend === "multiply" ? 1.1 : 1;
6425
- const motionBoost = layer.motion.type === "static" ? 1 : 1.2;
6426
- const weight = area / (w * h) * contrast * blendBoost * motionBoost;
6427
- return { layerIndex, key: layer.key, weight, entityCount: entities.length, meanLuma, factors: { area: area / (w * h), contrast, blendBoost, motionBoost } };
6428
- });
6429
- return raw;
6986
+ function textBoxAt(s, e, p, spec, w, h) {
6987
+ const m = s.font ? /(\d{1,5}(?:\.\d{1,4})?|\.\d{1,4})px/.exec(s.font) : null;
6988
+ const fontScale = spec.units === "px" ? 1 : Math.min(w, h) / (spec.referenceViewport ?? LIMITS.referenceViewport);
6989
+ const fh = m ? Number(m[1]) * fontScale : e.size;
6990
+ const str = s.strings[e.spriteIndex] ?? s.strings[0] ?? "";
6991
+ let fw = textWidthEm(str) * fh;
6992
+ if (s.maxWidth) fw = Math.min(fw, s.maxWidth * (spec.units === "px" ? 1 : Math.min(w, h)));
6993
+ const align = s.align ?? "center";
6994
+ const baseline = s.baseline ?? "middle";
6995
+ const x0 = align === "left" ? p.x : align === "right" ? p.x - fw : p.x - fw / 2;
6996
+ const y0 = baseline === "top" ? p.y : baseline === "bottom" ? p.y - fh : p.y - fh / 2;
6997
+ return { x0, y0, x1: x0 + fw, y1: y0 + fh, fs: fh };
6430
6998
  }
6431
- function layerCohesion(spec, opts = {}) {
6432
- spec = resolveTimelineAt(spec, opts.t ?? 5e3);
6433
- const scene = buildScene(spec, opts);
6434
- return cohesionOf(scene.layers, opts.t ?? 5e3, scene.w, scene.h);
6999
+ function textBlockBoxAt(s, p, w, h) {
7000
+ const unit = Math.min(w, h);
7001
+ const fsPx = s.fontSize * unit;
7002
+ const lh = (s.lineHeight ?? 1.4) * fsPx;
7003
+ const maxWPx = s.maxWidth * unit;
7004
+ const lines = breakTextBlock(s.text, maxWPx / fsPx, textMetricsClassFor(s.font));
7005
+ const maxLineW = lines.reduce((mx, l) => Math.max(mx, l.widthEm), 0) * fsPx;
7006
+ const totalH = lines.length * lh;
7007
+ const align = s.align ?? "left";
7008
+ const x0 = align === "center" ? p.x + (maxWPx - maxLineW) / 2 : align === "right" ? p.x + maxWPx - maxLineW : p.x;
7009
+ const { dx, dy } = textBlockAnchorOffset(s, maxWPx, maxLineW, totalH);
7010
+ return { x0: x0 + dx, y0: p.y + dy, x1: x0 + dx + maxLineW, y1: p.y + dy + totalH, fs: fsPx };
6435
7011
  }
6436
- function motionStats(spec, opts = {}) {
6437
- const t = opts.t ?? 5e3;
6438
- const dt = opts.dt ?? 500;
6439
- const scene0 = buildScene(resolveTimelineAt(spec, t), opts);
6440
- const scene1 = spec.timeline ? buildScene(resolveTimelineAt(spec, t + dt), opts) : scene0;
6441
- const { w, h } = scene0;
6442
- return scene0.layers.map(({ layer, entities }, layerIndex) => {
6443
- const entities1 = scene1.layers[layerIndex]?.entities ?? entities;
6444
- let acc = 0;
6445
- let max = 0;
6446
- let n = 0;
6447
- entities.forEach((e, i) => {
6448
- const p0 = posOf(scene0, e, t);
6449
- const p1 = posOf(scene1, entities1[i] ?? e, t + dt);
6450
- const dx = p1.x - p0.x;
6451
- const dy = p1.y - p0.y;
6452
- if (Math.abs(dx) > w / 2 || Math.abs(dy) > h / 2) return;
6453
- const speed = Math.sqrt(dx * dx + dy * dy) / dt * 1e3;
6454
- acc += speed;
6455
- max = Math.max(max, speed);
6456
- n++;
6457
- });
6458
- const meanSpeed = n ? acc / n : 0;
6459
- return { layerIndex, key: layer.key, meanSpeed, maxSpeed: max, moving: meanSpeed > 0.5 };
7012
+ function hiddenLayers(spec) {
7013
+ if (!spec.groups && !spec.layers.some((l) => l.opacity !== void 0)) return null;
7014
+ const tracks = spec.timeline ? timelineTracks(spec) : null;
7015
+ const pinnedZero = (path, base, ancestors) => {
7016
+ if (base !== 0) return false;
7017
+ if (!tracks) return true;
7018
+ for (const [p, keys] of tracks) {
7019
+ if (p === path && keys.some((k) => k.value !== 0)) return false;
7020
+ if (ancestors.includes(p)) return false;
7021
+ }
7022
+ return true;
7023
+ };
7024
+ const out = spec.layers.map((l, i) => {
7025
+ const lp = `layers.${i}`;
7026
+ if (pinnedZero(`${lp}.opacity`, l.opacity, ["layers", lp])) return true;
7027
+ const g = l.group !== void 0 && spec.groups && Object.prototype.hasOwnProperty.call(spec.groups, l.group) ? spec.groups[l.group] : void 0;
7028
+ return !!g && pinnedZero(`groups.${l.group}.opacity`, g.opacity, ["groups", `groups.${l.group}`]);
6460
7029
  });
7030
+ return out.some(Boolean) ? out : null;
6461
7031
  }
6462
- var REGION_NAMES = [
6463
- "top-left",
6464
- "top-center",
6465
- "top-right",
6466
- "mid-left",
6467
- "center",
6468
- "mid-right",
6469
- "bottom-left",
6470
- "bottom-center",
6471
- "bottom-right"
6472
- ];
6473
- function regionMeans(grid) {
6474
- const out = new Array(9).fill(0);
6475
- const cnt = new Array(9).fill(0);
6476
- for (let r = 0; r < grid.rows; r++) {
6477
- for (let c = 0; c < grid.cols; c++) {
6478
- const idx = r * grid.cols + c;
6479
- const region = Math.min(2, Math.floor(r / grid.rows * 3)) * 3 + Math.min(2, Math.floor(c / grid.cols * 3));
6480
- out[region] += Math.abs(grid.cells[idx] - (grid.backgroundCells ? grid.backgroundCells[idx] : grid.background[r]));
6481
- cnt[region]++;
7032
+ function adviseSequence(seq, viewport = { width: 1920, height: 1080 }) {
7033
+ const warnings = [];
7034
+ for (let i = 0; i < seq.segments.length; i++) {
7035
+ const seg = seq.segments[i];
7036
+ const timed = !!seg.scene.timeline && seg.duration !== void 0;
7037
+ const tSample = timed ? Math.min(COHESION_T, seg.duration) : void 0;
7038
+ const segWarnings = adviseSpec(seg.scene, viewport, tSample === void 0 ? {} : { t: tSample });
7039
+ const at = tSample === void 0 ? "" : ` (judged ${(tSample / 1e3).toFixed(1)} s into this ${(seg.duration / 1e3).toFixed(1)} s segment)`;
7040
+ for (const w of segWarnings) {
7041
+ warnings.push({ path: `segments[${i}].scene.${w.path}`, code: w.code, message: w.message + at, boxes: w.boxes });
6482
7042
  }
6483
7043
  }
6484
- return out.map((v, i) => cnt[i] ? v / cnt[i] : 0);
6485
- }
6486
- function diffScenes(a, b, opts = {}) {
6487
- const t = opts.t ?? 5e3;
6488
- const gridA = luminanceGrid(a, opts);
6489
- const gridB = luminanceGrid(b, opts);
6490
- const regsA = regionMeans(gridA);
6491
- const regsB = regionMeans(gridB);
6492
- const domA = dominanceRanking(a, opts);
6493
- const domB = dominanceRanking(b, opts);
6494
- const nameOf = (d) => d.key ?? `layer-${d.layerIndex}`;
6495
- const names = /* @__PURE__ */ new Set([...domA.map(nameOf), ...domB.map(nameOf)]);
6496
- const dominance = [...names].map((key) => {
6497
- const inA = domA.find((d) => nameOf(d) === key);
6498
- const inB = domB.find((d) => nameOf(d) === key);
6499
- return {
6500
- key,
6501
- rankA: inA?.rank ?? null,
6502
- rankB: inB?.rank ?? null,
6503
- shareA: inA?.share ?? 0,
6504
- shareB: inB?.share ?? 0
6505
- };
6506
- }).sort((x, y) => Math.abs(y.shareB - y.shareA) - Math.abs(x.shareB - x.shareA));
6507
- const codesA = new Set(adviseSpec(a).map((w) => w.code));
6508
- const codesB = new Set(adviseSpec(b).map((w) => w.code));
6509
- return {
6510
- t,
6511
- coverage: { a: gridA.coverage, b: gridB.coverage, delta: gridB.coverage - gridA.coverage },
6512
- meanLuminance: { a: gridA.meanLuminance, b: gridB.meanLuminance, delta: gridB.meanLuminance - gridA.meanLuminance },
6513
- balanceShift: gridA.centroid && gridB.centroid ? { dx: gridB.centroid.x - gridA.centroid.x, dy: gridB.centroid.y - gridA.centroid.y } : null,
6514
- regions: REGION_NAMES.map((region, i) => ({ region, a: regsA[i], b: regsB[i], delta: regsB[i] - regsA[i] })),
6515
- dominance,
6516
- advisories: {
6517
- added: [...codesB].filter((c) => !codesA.has(c)),
6518
- removed: [...codesA].filter((c) => !codesB.has(c))
7044
+ const fadeBound = seq.loop ? seq.segments.length : seq.segments.length - 1;
7045
+ const fades = seq.segments.slice(0, fadeBound).filter((s) => s.transition?.type === "fade").length;
7046
+ if (fades > 0) {
7047
+ const first = seq.segments.slice(0, fadeBound).findIndex((s) => s.transition?.type === "fade");
7048
+ warnings.push({
7049
+ path: `segments[${first}].transition`,
7050
+ code: "fade-degrades-on-low-tier",
7051
+ message: `informational: ${fades} fade transition${fades === 1 ? "" : "s"} declared \u2014 viewers on the 'basic' or 'minimal' capability tier render fade as cut (two live segments for dur is over that budget); the show still plays`
7052
+ });
7053
+ }
7054
+ for (let i = 0; i < seq.segments.length - 1; i++) {
7055
+ const tr = seq.segments[i].transition;
7056
+ const lumaA = backgroundLuma(resolveTimelineAt(seq.segments[i].scene, seq.segments[i].duration ?? 0));
7057
+ const lumaB = backgroundLuma(resolveTimelineAt(seq.segments[i + 1].scene, 0));
7058
+ const delta = Math.abs(lumaA - lumaB);
7059
+ if (delta > 0.5 && tr?.type !== "fade") {
7060
+ warnings.push({
7061
+ path: `segments[${i}]`,
7062
+ code: "boundary-luminance-jump",
7063
+ message: `background luminance jumps ${delta.toFixed(2)} at boundary ${i}\u2192${i + 1} \u2014 may flash on cut; declare transition: { type: 'fade', dur } on segments[${i}] to ramp it`
7064
+ });
6519
7065
  }
6520
- };
6521
- }
6522
- function perceiveScene(spec, opts = {}) {
6523
- spec = resolveTimelineAt(spec, opts.t ?? 5e3);
6524
- const grid = luminanceGrid(spec, opts);
6525
- return {
6526
- t: opts.t ?? 5e3,
6527
- braille: renderBrailleMap(grid),
6528
- density: renderDensityMap(grid),
6529
- coverage: grid.coverage,
6530
- meanLuminance: grid.meanLuminance,
6531
- centroid: grid.centroid,
6532
- rowProfile: grid.rowProfile,
6533
- colProfile: grid.colProfile,
6534
- dominance: dominanceRanking(spec, opts),
6535
- motion: motionStats(spec, opts),
6536
- form: layerCohesion(spec, opts),
6537
- text: textSprites(spec, opts),
6538
- advisories: adviseSpec(spec, opts.viewport ?? { width: 1920, height: 1080 }, { t: opts.t, seed: opts.seed })
6539
- };
7066
+ if (tr?.type === "morph") {
7067
+ const sigA = structuralSignature(seq.segments[i].scene);
7068
+ const sigB = structuralSignature(seq.segments[i + 1].scene);
7069
+ if (sigA !== sigB) {
7070
+ warnings.push({
7071
+ path: `segments[${i}].transition`,
7072
+ code: "morph-structural-mismatch",
7073
+ message: `segments ${i}\u2192${i + 1} differ structurally: morph will fall back to cut`
7074
+ });
7075
+ } else if (!seq.segments[i].scene.timeline && !seq.segments[i + 1].scene.timeline && morphNothingMorphable(seq.segments[i].scene, seq.segments[i + 1].scene, { textCrossfade: tr.text === "crossfade" || tr.text === "dip" })) {
7076
+ warnings.push({
7077
+ path: `segments[${i}].transition`,
7078
+ code: "morph-nothing-morphable",
7079
+ message: `segments ${i}\u2192${i + 1} differ only in values morph cannot interpolate (strings such as textBlock.text step on the first frame): the morph will look like a cut \u2014 fade text via colour or reveal.progress`
7080
+ });
7081
+ }
7082
+ }
7083
+ }
7084
+ return warnings;
6540
7085
  }
6541
- var bedKey = (key, layerIndex) => `bed:${key ?? layerIndex}`;
6542
- function perceiveSequenceFrame(seq, T, opts = {}) {
6543
- const { releasedBelow, ...gridOpts } = opts;
6544
- const r = resolveSegment(seq, T, { releasedBelow });
6545
- const seg = seq.segments[r.index];
6546
- const segment = { index: r.index, key: seg.key, localT: r.localT, ...r.held ? { held: true } : {} };
6547
- const segOpts = { ...gridOpts, t: r.localT, seed: gridOpts.seed ?? segmentRenderSeed(seq, r.index) };
6548
- if (!seq.bed) return { ...perceiveScene(seg.scene, segOpts), segment, bed: false };
6549
- const bedOpts = { ...gridOpts, t: T, seed: gridOpts.seed ?? bedRenderSeed(seq) };
6550
- const viewport = gridOpts.viewport ?? { width: 1920, height: 1080 };
6551
- const ink = { ...seg.scene, background: { type: "solid", color: "#000000" } };
6552
- delete ink.ghosting;
6553
- const inkOverBed = { ...ink, background: seq.bed.background ?? { type: "solid", color: "#05050a" } };
6554
- const bedGrid = luminanceGrid(seq.bed, bedOpts);
6555
- const inkGrid = luminanceGrid(ink, segOpts);
6556
- const cells = bedGrid.cells.map((v, i) => Math.min(1, v + inkGrid.cells[i]));
6557
- const grid = finishGrid(bedGrid.cols, bedGrid.rows, cells, bedGrid.background, bedGrid.backgroundCells);
6558
- const bedLayers = seq.bed.layers.length;
6559
- const dominance = rankDominance([
6560
- ...rawDominance(seq.bed, bedOpts).map((d) => ({ ...d, key: bedKey(d.key, d.layerIndex) })),
6561
- ...rawDominance(inkOverBed, segOpts).map((d) => ({ ...d, layerIndex: d.layerIndex + bedLayers }))
6562
- ]);
6563
- const shift = (x) => ({ ...x, layerIndex: x.layerIndex + bedLayers });
6564
- const prefix = (x) => ({ ...x, key: bedKey(x.key, x.layerIndex) });
7086
+
7087
+ // src/describe.ts
7088
+ import { createRng as createRng4 } from "@idle-screens/core";
7089
+ function describeScene(spec, options = {}) {
7090
+ const { width: w, height: h } = options.viewport ?? { width: 1920, height: 1080 };
7091
+ const seed = options.seed ?? spec.seed ?? 42;
7092
+ const times = options.times ?? [0, 5e3, 15e3];
7093
+ const { scale, countScale } = entityScales(spec, w, h);
7094
+ const rng = createRng4(seed);
7095
+ const builtLayers = spec.layers.map((layer) => ({
7096
+ layer,
7097
+ entities: buildEntities(layer, rng, w, h, scale, countScale)
7098
+ }));
7099
+ const snapshots = times.map((t) => {
7100
+ const layerSnapshots = builtLayers.map(({ layer, entities }) => {
7101
+ const positions = entities.map((e) => positionAt(e, t, w, h));
7102
+ const cx = positions.length > 0 ? positions.reduce((s, p) => s + p.x, 0) / positions.length : 0;
7103
+ const cy = positions.length > 0 ? positions.reduce((s, p) => s + p.y, 0) / positions.length : 0;
7104
+ let pixArea = 0;
7105
+ for (const e of entities) {
7106
+ const r = e.size / 2;
7107
+ pixArea += layer.sprite.kind === "circle" ? Math.PI * r * r : layer.sprite.kind === "polygon" ? polygonArea(polygonPoints(layer.sprite, r)) : e.size * e.size;
7108
+ }
7109
+ const coverage = pixArea / (w * h);
7110
+ const meanAlpha = entities.length > 0 ? entities.reduce((s, e) => s + e.alpha, 0) / entities.length : 0;
7111
+ let linksDrawn = null;
7112
+ let linksExpected = null;
7113
+ let connectedComponents = null;
7114
+ let isolatedNodes = null;
7115
+ if (layer.links) {
7116
+ const maxDist = layer.links.maxDist * scale;
7117
+ const pairs = linkPairs(positions, layer.links.k, maxDist, layer.wrap !== false, w, h);
7118
+ linksDrawn = pairs.length;
7119
+ linksExpected = Math.min(entities.length * layer.links.k, entities.length * (entities.length - 1) / 2);
7120
+ const parent = Array.from({ length: entities.length }, (_, i) => i);
7121
+ const find = (x) => {
7122
+ while (parent[x] !== x) {
7123
+ parent[x] = parent[parent[x]];
7124
+ x = parent[x];
7125
+ }
7126
+ return x;
7127
+ };
7128
+ for (const [a, b] of pairs) {
7129
+ const ra = find(a);
7130
+ const rb = find(b);
7131
+ if (ra !== rb) parent[ra] = rb;
7132
+ }
7133
+ const roots = new Set(entities.map((_, i) => find(i)));
7134
+ connectedComponents = roots.size;
7135
+ const degree = new Uint16Array(entities.length);
7136
+ for (const [a, b] of pairs) {
7137
+ degree[a]++;
7138
+ degree[b]++;
7139
+ }
7140
+ isolatedNodes = degree.filter((d) => d === 0).length;
7141
+ }
7142
+ return {
7143
+ key: layer.key,
7144
+ count: entities.length,
7145
+ centroid: { x: +(cx / w).toFixed(3), y: +(cy / h).toFixed(3) },
7146
+ coverage: +coverage.toFixed(4),
7147
+ meanAlpha: +meanAlpha.toFixed(3),
7148
+ linksDrawn,
7149
+ linksExpected,
7150
+ connectedComponents,
7151
+ isolatedNodes
7152
+ };
7153
+ });
7154
+ const warnings = [];
7155
+ for (const ls of layerSnapshots) {
7156
+ const label = ls.key ?? `layer`;
7157
+ if (ls.coverage * ls.meanAlpha < 5e-4) {
7158
+ warnings.push(`${label}: effectively invisible (coverage\xD7alpha=${(ls.coverage * ls.meanAlpha).toFixed(5)})`);
7159
+ }
7160
+ if (ls.linksDrawn !== null && ls.linksExpected !== null && ls.linksExpected > 0) {
7161
+ const ratio = ls.linksDrawn / ls.linksExpected;
7162
+ if (ratio < 0.1) {
7163
+ warnings.push(`${label}: link starvation \u2014 ${ls.linksDrawn}/${ls.linksExpected} edges formed`);
7164
+ }
7165
+ }
7166
+ if (ls.isolatedNodes !== null && ls.count > 0 && ls.isolatedNodes / ls.count > 0.5) {
7167
+ warnings.push(`${label}: ${ls.isolatedNodes}/${ls.count} nodes isolated \u2014 graph is disconnected`);
7168
+ }
7169
+ }
7170
+ return { t, viewport: { w, h }, layers: layerSnapshots, warnings };
7171
+ });
6565
7172
  return {
6566
- t: r.localT,
6567
- braille: renderBrailleMap(grid),
6568
- density: renderDensityMap(grid),
6569
- coverage: grid.coverage,
6570
- meanLuminance: grid.meanLuminance,
6571
- centroid: grid.centroid,
6572
- rowProfile: grid.rowProfile,
6573
- colProfile: grid.colProfile,
6574
- dominance,
6575
- motion: [...motionStats(seq.bed, bedOpts).map(prefix), ...motionStats(ink, segOpts).map(shift)],
6576
- form: [...layerCohesion(seq.bed, bedOpts).map(prefix), ...layerCohesion(ink, segOpts).map(shift)],
6577
- text: [...textSprites(seq.bed, bedOpts).map(prefix), ...textSprites(ink, segOpts).map(shift)],
6578
- advisories: [
6579
- ...adviseSpec(seq.bed, viewport, { t: T, seed: bedOpts.seed }).map((w) => ({ ...w, path: `bed.${w.path}` })),
6580
- // inkOverBed's layers are the segment's (seed: segOpts.seed) but its
6581
- // background is the bed's (seq.bed.background) — a field there must be
6582
- // sampled with the bed's own seed, or a `role: 'read'` legibility
6583
- // advisory checks terrain that isn't what's actually behind the ink.
6584
- // `?? 42`: inkOverBed.seed is the SEGMENT's raw seed field (it's `ink`
6585
- // spread first), so adviseSpec's own `backgroundSeed ?? seed` fallback
6586
- // would substitute the segment's seed, not the bed's, if bedOpts.seed
6587
- // were left undefined — 42 is the same default `luminanceGrid` (and
6588
- // every other field seed fallback) converges on.
6589
- ...adviseSpec(inkOverBed, viewport, { t: r.localT, seed: segOpts.seed, backgroundSeed: bedOpts.seed ?? 42 })
6590
- ],
6591
- segment,
6592
- bed: true
7173
+ spec: { id: spec.id, label: spec.label, units: spec.units ?? "viewport", ...spec.finish ? { finish: spec.finish } : {} },
7174
+ snapshots
6593
7175
  };
6594
7176
  }
6595
7177
 
@@ -9083,6 +9665,7 @@ export {
9083
9665
  easeSmooth,
9084
9666
  emitEnvelope,
9085
9667
  emitWindow,
9668
+ entityScales,
9086
9669
  fieldAt,
9087
9670
  fieldOctaves,
9088
9671
  fieldRgb,
@@ -9124,6 +9707,7 @@ export {
9124
9707
  revealState,
9125
9708
  rgb255Luma,
9126
9709
  rotationAt,
9710
+ sceneColour,
9127
9711
  seamCauseOf,
9128
9712
  seamsWorthWarning,
9129
9713
  segmentRenderSeed,