@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/FORMAT.md +77 -22
- package/dist/index.d.ts +113 -3
- package/dist/index.js +2059 -1475
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
|
2023
|
-
const
|
|
2024
|
-
|
|
2025
|
-
return
|
|
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
|
|
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
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
|
|
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(
|
|
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(
|
|
3686
|
+
ctx.arc(ec.x, ec.y, ec.r, 0, Math.PI * 2);
|
|
3487
3687
|
ctx.fill();
|
|
3488
3688
|
}
|
|
3489
3689
|
}
|
|
3490
|
-
|
|
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
|
-
|
|
3533
|
-
|
|
3534
|
-
|
|
3535
|
-
|
|
3536
|
-
|
|
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
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
|
|
3606
|
-
|
|
3607
|
-
|
|
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
|
|
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/
|
|
5096
|
-
|
|
5097
|
-
var
|
|
5098
|
-
var
|
|
5099
|
-
var
|
|
5100
|
-
|
|
5101
|
-
|
|
5102
|
-
|
|
5103
|
-
const
|
|
5104
|
-
const
|
|
5105
|
-
const
|
|
5106
|
-
const
|
|
5107
|
-
const
|
|
5108
|
-
|
|
5109
|
-
if (
|
|
5110
|
-
|
|
5111
|
-
|
|
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
|
-
|
|
5114
|
-
|
|
5115
|
-
|
|
5116
|
-
const
|
|
5117
|
-
|
|
5118
|
-
|
|
5119
|
-
|
|
5120
|
-
|
|
5121
|
-
|
|
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
|
-
|
|
5151
|
-
|
|
5152
|
-
|
|
5153
|
-
|
|
5154
|
-
|
|
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
|
-
|
|
5158
|
-
|
|
5159
|
-
|
|
5160
|
-
|
|
5161
|
-
|
|
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
|
-
|
|
5165
|
-
|
|
5166
|
-
|
|
5167
|
-
|
|
5168
|
-
|
|
5169
|
-
|
|
5170
|
-
|
|
5171
|
-
|
|
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
|
-
|
|
5176
|
-
|
|
5177
|
-
|
|
5178
|
-
|
|
5179
|
-
|
|
5180
|
-
|
|
5181
|
-
|
|
5182
|
-
|
|
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
|
-
|
|
5193
|
-
|
|
5194
|
-
|
|
5195
|
-
|
|
5196
|
-
|
|
5197
|
-
const
|
|
5198
|
-
|
|
5199
|
-
|
|
5200
|
-
|
|
5201
|
-
|
|
5202
|
-
|
|
5203
|
-
|
|
5204
|
-
|
|
5205
|
-
|
|
5206
|
-
|
|
5207
|
-
|
|
5208
|
-
|
|
5209
|
-
|
|
5210
|
-
|
|
5211
|
-
|
|
5212
|
-
|
|
5213
|
-
|
|
5214
|
-
|
|
5215
|
-
|
|
5216
|
-
|
|
5217
|
-
|
|
5218
|
-
|
|
5219
|
-
|
|
5220
|
-
|
|
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
|
-
|
|
5646
|
+
bgRow[r] = rowSum / cols;
|
|
5223
5647
|
}
|
|
5224
|
-
|
|
5225
|
-
|
|
5226
|
-
|
|
5227
|
-
const
|
|
5228
|
-
|
|
5229
|
-
|
|
5230
|
-
|
|
5231
|
-
|
|
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
|
-
|
|
5235
|
-
|
|
5236
|
-
|
|
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
|
-
|
|
5274
|
-
|
|
5275
|
-
|
|
5276
|
-
|
|
5277
|
-
|
|
5278
|
-
|
|
5279
|
-
|
|
5280
|
-
|
|
5281
|
-
|
|
5282
|
-
|
|
5283
|
-
|
|
5284
|
-
|
|
5285
|
-
|
|
5286
|
-
|
|
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
|
-
|
|
5291
|
-
|
|
5292
|
-
const
|
|
5293
|
-
|
|
5294
|
-
const
|
|
5295
|
-
|
|
5296
|
-
const
|
|
5297
|
-
const
|
|
5298
|
-
|
|
5299
|
-
|
|
5300
|
-
|
|
5301
|
-
|
|
5302
|
-
|
|
5303
|
-
|
|
5304
|
-
|
|
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
|
-
|
|
5309
|
-
|
|
5310
|
-
|
|
5311
|
-
|
|
5312
|
-
|
|
5313
|
-
|
|
5314
|
-
|
|
5315
|
-
|
|
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
|
-
|
|
5325
|
-
|
|
5326
|
-
|
|
5327
|
-
|
|
5328
|
-
const
|
|
5329
|
-
|
|
5330
|
-
|
|
5331
|
-
|
|
5332
|
-
|
|
5333
|
-
|
|
5334
|
-
|
|
5335
|
-
|
|
5336
|
-
|
|
5337
|
-
|
|
5338
|
-
|
|
5339
|
-
|
|
5340
|
-
|
|
5341
|
-
|
|
5342
|
-
|
|
5343
|
-
|
|
5344
|
-
|
|
5345
|
-
|
|
5346
|
-
|
|
5347
|
-
|
|
5348
|
-
|
|
5349
|
-
|
|
5350
|
-
|
|
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
|
|
5530
|
-
const
|
|
5531
|
-
|
|
5532
|
-
|
|
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
|
-
|
|
5610
|
-
|
|
5611
|
-
|
|
5612
|
-
|
|
5613
|
-
|
|
5614
|
-
|
|
5615
|
-
|
|
5616
|
-
|
|
5617
|
-
|
|
5618
|
-
|
|
5619
|
-
|
|
5620
|
-
|
|
5621
|
-
|
|
5622
|
-
|
|
5623
|
-
|
|
5624
|
-
|
|
5625
|
-
|
|
5626
|
-
|
|
5627
|
-
|
|
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 (
|
|
5664
|
-
const
|
|
5665
|
-
|
|
5666
|
-
|
|
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
|
-
|
|
5682
|
-
|
|
5683
|
-
|
|
5684
|
-
|
|
5685
|
-
|
|
5686
|
-
|
|
5687
|
-
|
|
5688
|
-
|
|
5689
|
-
|
|
5690
|
-
|
|
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
|
-
|
|
5957
|
+
splatPass(t, 1);
|
|
5958
|
+
return finishGrid(cols, rows, cells, bgRow, bgCells);
|
|
5710
5959
|
}
|
|
5711
|
-
function
|
|
5712
|
-
|
|
5713
|
-
|
|
5714
|
-
|
|
5715
|
-
|
|
5716
|
-
|
|
5717
|
-
|
|
5718
|
-
|
|
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
|
-
|
|
5722
|
-
|
|
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
|
-
|
|
5752
|
-
|
|
5753
|
-
|
|
5754
|
-
|
|
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
|
-
|
|
5758
|
-
|
|
5759
|
-
|
|
5760
|
-
|
|
5761
|
-
|
|
5762
|
-
|
|
5763
|
-
|
|
5764
|
-
|
|
5765
|
-
|
|
5766
|
-
|
|
5767
|
-
|
|
5768
|
-
|
|
5769
|
-
|
|
5770
|
-
|
|
5771
|
-
|
|
5772
|
-
|
|
5773
|
-
|
|
5774
|
-
|
|
5775
|
-
|
|
5776
|
-
const
|
|
5777
|
-
const
|
|
5778
|
-
|
|
5779
|
-
|
|
5780
|
-
|
|
5781
|
-
|
|
5782
|
-
|
|
5783
|
-
|
|
5784
|
-
|
|
5785
|
-
|
|
5786
|
-
|
|
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
|
|
6031
|
+
return outRows.join("\n");
|
|
5816
6032
|
}
|
|
5817
|
-
|
|
5818
|
-
|
|
5819
|
-
|
|
5820
|
-
|
|
5821
|
-
|
|
5822
|
-
|
|
5823
|
-
|
|
5824
|
-
|
|
5825
|
-
|
|
5826
|
-
|
|
5827
|
-
|
|
5828
|
-
|
|
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
|
|
6046
|
+
return lines.join("\n");
|
|
5831
6047
|
}
|
|
5832
|
-
function
|
|
5833
|
-
|
|
5834
|
-
|
|
5835
|
-
const
|
|
5836
|
-
|
|
5837
|
-
|
|
5838
|
-
|
|
5839
|
-
|
|
5840
|
-
|
|
5841
|
-
|
|
5842
|
-
|
|
5843
|
-
|
|
5844
|
-
|
|
5845
|
-
|
|
5846
|
-
|
|
5847
|
-
|
|
5848
|
-
|
|
5849
|
-
|
|
5850
|
-
|
|
5851
|
-
|
|
5852
|
-
|
|
5853
|
-
|
|
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
|
|
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
|
|
5871
|
-
const
|
|
5872
|
-
|
|
5873
|
-
|
|
5874
|
-
|
|
5875
|
-
|
|
5876
|
-
|
|
5877
|
-
|
|
5878
|
-
|
|
5879
|
-
|
|
5880
|
-
|
|
5881
|
-
const
|
|
5882
|
-
|
|
5883
|
-
|
|
5884
|
-
|
|
5885
|
-
|
|
5886
|
-
|
|
5887
|
-
|
|
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
|
-
|
|
6149
|
+
if (ghost > 0) {
|
|
6150
|
+
area += sweptDistance(e, ghostWindowMs) * sz * ghostMeanW * a;
|
|
6151
|
+
}
|
|
6152
|
+
lumAcc += spriteLuma(layer, e);
|
|
5890
6153
|
}
|
|
5891
|
-
|
|
5892
|
-
|
|
5893
|
-
|
|
5894
|
-
const
|
|
5895
|
-
|
|
5896
|
-
|
|
5897
|
-
|
|
5898
|
-
|
|
5899
|
-
|
|
5900
|
-
|
|
5901
|
-
|
|
5902
|
-
|
|
5903
|
-
|
|
5904
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5909
|
-
|
|
5910
|
-
|
|
5911
|
-
|
|
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
|
-
|
|
5915
|
-
|
|
5916
|
-
|
|
5917
|
-
|
|
5918
|
-
|
|
5919
|
-
|
|
5920
|
-
|
|
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
|
|
5923
|
-
|
|
5924
|
-
|
|
5925
|
-
const
|
|
5926
|
-
const
|
|
5927
|
-
const
|
|
5928
|
-
|
|
5929
|
-
|
|
5930
|
-
|
|
5931
|
-
|
|
5932
|
-
|
|
5933
|
-
|
|
5934
|
-
|
|
5935
|
-
|
|
5936
|
-
|
|
5937
|
-
|
|
5938
|
-
|
|
5939
|
-
|
|
5940
|
-
|
|
5941
|
-
|
|
5942
|
-
|
|
5943
|
-
|
|
5944
|
-
|
|
5945
|
-
|
|
5946
|
-
|
|
5947
|
-
|
|
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
|
-
|
|
5964
|
-
|
|
5965
|
-
|
|
5966
|
-
|
|
5967
|
-
|
|
5968
|
-
|
|
5969
|
-
|
|
5970
|
-
|
|
5971
|
-
|
|
5972
|
-
|
|
5973
|
-
|
|
5974
|
-
|
|
5975
|
-
|
|
5976
|
-
|
|
5977
|
-
|
|
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
|
-
|
|
5980
|
-
|
|
5981
|
-
|
|
5982
|
-
|
|
5983
|
-
|
|
5984
|
-
|
|
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
|
-
|
|
5990
|
-
|
|
5991
|
-
|
|
5992
|
-
|
|
5993
|
-
|
|
5994
|
-
|
|
5995
|
-
|
|
5996
|
-
|
|
5997
|
-
|
|
5998
|
-
|
|
5999
|
-
|
|
6000
|
-
|
|
6001
|
-
|
|
6002
|
-
|
|
6003
|
-
|
|
6004
|
-
|
|
6005
|
-
|
|
6006
|
-
|
|
6007
|
-
|
|
6008
|
-
|
|
6009
|
-
|
|
6010
|
-
}
|
|
6011
|
-
}
|
|
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
|
-
|
|
6021
|
-
|
|
6022
|
-
|
|
6023
|
-
|
|
6024
|
-
|
|
6025
|
-
|
|
6026
|
-
const
|
|
6027
|
-
if (
|
|
6028
|
-
const
|
|
6029
|
-
const
|
|
6030
|
-
|
|
6031
|
-
const
|
|
6032
|
-
|
|
6033
|
-
|
|
6034
|
-
|
|
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
|
-
|
|
6131
|
-
|
|
6132
|
-
|
|
6133
|
-
|
|
6134
|
-
|
|
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
|
-
|
|
6180
|
-
|
|
6181
|
-
|
|
6182
|
-
|
|
6183
|
-
|
|
6184
|
-
|
|
6185
|
-
|
|
6186
|
-
|
|
6187
|
-
|
|
6188
|
-
|
|
6189
|
-
|
|
6190
|
-
|
|
6191
|
-
|
|
6192
|
-
|
|
6193
|
-
|
|
6194
|
-
|
|
6195
|
-
|
|
6196
|
-
|
|
6197
|
-
|
|
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
|
|
6204
|
-
|
|
6205
|
-
|
|
6206
|
-
const
|
|
6207
|
-
const
|
|
6208
|
-
|
|
6209
|
-
|
|
6210
|
-
|
|
6211
|
-
|
|
6212
|
-
const
|
|
6213
|
-
|
|
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
|
-
|
|
6217
|
-
|
|
6218
|
-
|
|
6219
|
-
|
|
6220
|
-
|
|
6221
|
-
|
|
6222
|
-
|
|
6223
|
-
|
|
6224
|
-
|
|
6225
|
-
const
|
|
6226
|
-
|
|
6227
|
-
|
|
6228
|
-
|
|
6229
|
-
|
|
6230
|
-
|
|
6231
|
-
|
|
6232
|
-
|
|
6233
|
-
|
|
6234
|
-
|
|
6235
|
-
|
|
6236
|
-
|
|
6237
|
-
|
|
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
|
-
|
|
6241
|
-
for (let
|
|
6242
|
-
|
|
6243
|
-
|
|
6244
|
-
|
|
6245
|
-
|
|
6246
|
-
|
|
6247
|
-
|
|
6248
|
-
|
|
6249
|
-
|
|
6250
|
-
|
|
6251
|
-
|
|
6252
|
-
|
|
6253
|
-
|
|
6254
|
-
|
|
6255
|
-
|
|
6256
|
-
|
|
6257
|
-
|
|
6258
|
-
|
|
6259
|
-
|
|
6260
|
-
|
|
6261
|
-
|
|
6262
|
-
|
|
6263
|
-
|
|
6264
|
-
|
|
6265
|
-
|
|
6266
|
-
];
|
|
6267
|
-
|
|
6268
|
-
|
|
6269
|
-
|
|
6270
|
-
|
|
6271
|
-
|
|
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
|
|
6274
|
-
|
|
6275
|
-
|
|
6276
|
-
|
|
6277
|
-
|
|
6278
|
-
|
|
6279
|
-
|
|
6280
|
-
|
|
6281
|
-
|
|
6282
|
-
|
|
6283
|
-
|
|
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
|
|
6917
|
+
return best ? { rgb: best.rgb, label: best.label } : null;
|
|
6291
6918
|
}
|
|
6292
|
-
|
|
6293
|
-
|
|
6294
|
-
|
|
6295
|
-
|
|
6296
|
-
|
|
6297
|
-
|
|
6298
|
-
|
|
6299
|
-
|
|
6300
|
-
|
|
6301
|
-
|
|
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
|
|
6955
|
+
return covered ? { rgb: plate, luma: 0.2126 * plate.r + 0.7152 * plate.g + 0.0722 * plate.b } : null;
|
|
6306
6956
|
}
|
|
6307
|
-
function
|
|
6308
|
-
|
|
6309
|
-
|
|
6310
|
-
|
|
6311
|
-
|
|
6312
|
-
|
|
6313
|
-
|
|
6314
|
-
|
|
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
|
|
6331
|
-
|
|
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
|
-
|
|
6334
|
-
|
|
6335
|
-
|
|
6336
|
-
|
|
6337
|
-
|
|
6338
|
-
|
|
6339
|
-
|
|
6340
|
-
|
|
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
|
|
6346
|
-
|
|
6347
|
-
const
|
|
6348
|
-
const
|
|
6349
|
-
const
|
|
6350
|
-
|
|
6351
|
-
|
|
6352
|
-
|
|
6353
|
-
|
|
6354
|
-
|
|
6355
|
-
|
|
6356
|
-
|
|
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
|
|
6432
|
-
|
|
6433
|
-
const
|
|
6434
|
-
|
|
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
|
|
6437
|
-
|
|
6438
|
-
const
|
|
6439
|
-
const
|
|
6440
|
-
|
|
6441
|
-
|
|
6442
|
-
|
|
6443
|
-
|
|
6444
|
-
|
|
6445
|
-
|
|
6446
|
-
|
|
6447
|
-
|
|
6448
|
-
|
|
6449
|
-
|
|
6450
|
-
|
|
6451
|
-
|
|
6452
|
-
|
|
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
|
-
|
|
6463
|
-
|
|
6464
|
-
|
|
6465
|
-
|
|
6466
|
-
|
|
6467
|
-
|
|
6468
|
-
|
|
6469
|
-
|
|
6470
|
-
|
|
6471
|
-
|
|
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
|
-
|
|
6485
|
-
|
|
6486
|
-
|
|
6487
|
-
|
|
6488
|
-
|
|
6489
|
-
|
|
6490
|
-
|
|
6491
|
-
|
|
6492
|
-
|
|
6493
|
-
|
|
6494
|
-
|
|
6495
|
-
|
|
6496
|
-
|
|
6497
|
-
const
|
|
6498
|
-
const
|
|
6499
|
-
|
|
6500
|
-
|
|
6501
|
-
|
|
6502
|
-
|
|
6503
|
-
|
|
6504
|
-
|
|
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
|
-
|
|
6523
|
-
|
|
6524
|
-
|
|
6525
|
-
|
|
6526
|
-
|
|
6527
|
-
|
|
6528
|
-
|
|
6529
|
-
|
|
6530
|
-
|
|
6531
|
-
|
|
6532
|
-
|
|
6533
|
-
|
|
6534
|
-
|
|
6535
|
-
|
|
6536
|
-
|
|
6537
|
-
|
|
6538
|
-
|
|
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
|
-
|
|
6542
|
-
|
|
6543
|
-
|
|
6544
|
-
|
|
6545
|
-
const
|
|
6546
|
-
const
|
|
6547
|
-
const
|
|
6548
|
-
|
|
6549
|
-
const
|
|
6550
|
-
const
|
|
6551
|
-
|
|
6552
|
-
|
|
6553
|
-
|
|
6554
|
-
const
|
|
6555
|
-
|
|
6556
|
-
|
|
6557
|
-
|
|
6558
|
-
|
|
6559
|
-
|
|
6560
|
-
|
|
6561
|
-
|
|
6562
|
-
|
|
6563
|
-
|
|
6564
|
-
|
|
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
|
-
|
|
6567
|
-
|
|
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,
|