@idle-screens/schema 3.11.1 → 3.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2027,6 +2027,16 @@ function featherAlphas(steps, additive) {
2027
2027
  var FEATHER_STEPS = 6;
2028
2028
 
2029
2029
  // src/simulate.ts
2030
+ function entityScales(spec, w, h) {
2031
+ const scale = spec.units === "px" ? 1 : Math.min(w, h);
2032
+ const refVp = spec.referenceViewport ?? LIMITS.referenceViewport;
2033
+ let countScale = scale > 1 ? Math.min(w, h) / refVp : 1;
2034
+ if (countScale > 1) {
2035
+ const rawTotal = spec.layers.reduce((s, l) => s + Math.round(l.count * countScale), 0);
2036
+ if (rawTotal > LIMITS.maxTotal) countScale *= LIMITS.maxTotal / rawTotal;
2037
+ }
2038
+ return { scale, countScale };
2039
+ }
2030
2040
  var WARP_NEAR = 0.08;
2031
2041
  var WARP_MAX_SCALE = 8;
2032
2042
  function spriteVariants(sprite) {
@@ -3310,13 +3320,7 @@ var SpecInstance = class {
3310
3320
  /** (Re)seed and place all entities — deterministic for the seed + viewport. */
3311
3321
  rebuild() {
3312
3322
  const rng = createRng(this.seed);
3313
- const scale = this.effSpec.units === "px" ? 1 : Math.min(this.w, this.h);
3314
- const refVp = this.effSpec.referenceViewport ?? LIMITS.referenceViewport;
3315
- let countScale = scale > 1 ? Math.min(this.w, this.h) / refVp : 1;
3316
- if (countScale > 1) {
3317
- const rawTotal = this.effSpec.layers.reduce((s, l) => s + Math.round(l.count * countScale), 0);
3318
- if (rawTotal > LIMITS.maxTotal) countScale *= LIMITS.maxTotal / rawTotal;
3319
- }
3323
+ const { scale, countScale } = entityScales(this.effSpec, this.w, this.h);
3320
3324
  this.layers = this.effSpec.layers.map((layer) => ({ layer, entities: buildEntities(layer, rng, this.w, this.h, scale, countScale) }));
3321
3325
  this.lastStructural = structuralSignature(this.effSpec);
3322
3326
  }
@@ -4748,7 +4752,7 @@ function compileSequence(spec) {
4748
4752
  }
4749
4753
 
4750
4754
  // src/advise.ts
4751
- import { createRng as createRng2 } from "@idle-screens/core";
4755
+ import { createRng as createRng3 } from "@idle-screens/core";
4752
4756
 
4753
4757
  // src/luma.ts
4754
4758
  function fieldRenderedRgbs(bg) {
@@ -5092,1504 +5096,1662 @@ function seamsWorthWarning(layer, c, entityCount) {
5092
5096
  return layer.blend !== "lighter" && layer.blend !== "screen";
5093
5097
  }
5094
5098
 
5095
- // src/advise.ts
5096
- var LOW_CONTRAST_FLOOR = 0.05;
5097
- var SPARSE_DECLARED_MAX_COVERAGE = 0.02;
5098
- var READ_LEGIBILITY_FLOOR = 4.5;
5099
- var READ_SAFE_AREA = 0.05;
5100
- function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
5101
- const hidden = hiddenLayers(spec);
5102
- spec = resolveTimelineAt(spec, opts.t ?? COHESION_T);
5103
- const warnings = [];
5104
- const w = viewport.width;
5105
- const h = viewport.height;
5106
- const scale = spec.units === "px" ? 1 : Math.min(w, h);
5107
- const refVp = spec.referenceViewport ?? LIMITS.referenceViewport;
5108
- let countScale = scale > 1 ? Math.min(w, h) / refVp : 1;
5109
- if (countScale > 1) {
5110
- const rawTotal = spec.layers.reduce((sum, l) => sum + Math.round(l.count * countScale), 0);
5111
- if (rawTotal > LIMITS.maxTotal) countScale *= LIMITS.maxTotal / rawTotal;
5099
+ // src/perceive.ts
5100
+ import { createRng as createRng2 } from "@idle-screens/core";
5101
+ var GLOW_SPREAD = 2.4;
5102
+ var LINE_SALIENCE = 3.5;
5103
+ var GHOST_TAPS = 12;
5104
+ var POLYGON_OUTLINE_CELLS = 3;
5105
+ var POLYGON_SCANLINES = 4;
5106
+ function buildScene(spec, opts) {
5107
+ const { width: w, height: h } = opts.viewport ?? { width: 1920, height: 1080 };
5108
+ const seed = normalizeSeed(opts.seed ?? spec.seed ?? 42);
5109
+ const { scale, countScale } = entityScales(spec, w, h);
5110
+ const rng = createRng2(seed);
5111
+ const layers = spec.layers.map((layer) => ({ layer, entities: buildEntities(layer, rng, w, h, scale, countScale) }));
5112
+ const byKey = /* @__PURE__ */ new Map();
5113
+ for (const { layer, entities } of layers) if (layer.key) byKey.set(layer.key, entities);
5114
+ let transforms = null;
5115
+ for (const { layer, entities } of layers) {
5116
+ const map = paintMap(spec, layer, w, h, scale);
5117
+ if (!map) continue;
5118
+ transforms ??= /* @__PURE__ */ new Map();
5119
+ for (const e of entities) transforms.set(e, map);
5112
5120
  }
5113
- const rng = createRng2(opts.seed ?? spec.seed ?? 42);
5114
- const allEntities = spec.layers.map((l) => buildEntities(l, rng, w, h, scale, countScale));
5115
- const bgLuma = backgroundLuma(spec);
5116
- const bgRgb = backgroundRgb(spec);
5117
- const cohesion = cohesionOf(
5118
- spec.layers.map((layer, i) => ({ layer, entities: allEntities[i] })),
5119
- opts.t ?? COHESION_T,
5120
- w,
5121
- h
5122
- );
5123
- let totalEntities = 0;
5124
- let textLayerCount = 0;
5125
- let motionLayerCount = 0;
5126
- for (let li = 0; li < spec.layers.length; li++) {
5127
- if (hidden?.[li]) continue;
5128
- const layer = spec.layers[li];
5129
- const entities = allEntities[li];
5130
- totalEntities += entities.length;
5131
- const isText = layer.sprite.kind === "text" || layer.sprite.kind === "textBlock";
5132
- const isStaticText = isText && layer.motion.type === "static";
5133
- if (isStaticText) textLayerCount++;
5134
- if (layer.motion.type !== "static") motionLayerCount++;
5135
- const coh = cohesion[li];
5136
- if (seamsWorthWarning(layer, coh, entities.length)) {
5137
- warnings.push({
5138
- path: `layers[${li}]`,
5139
- code: "overlap-seams",
5140
- message: `entities bury ${Math.round((coh.overlap ?? 0) * 100)}% of each other's outlines, so this layer is drawn as one shape \u2014 but ${coh.seamCause}, so every overlap paints a visible edge and it will read as a pile of sprites instead. A layer merges into a single silhouette only when it is one flat colour at alpha 1, hard-edged, with no blend and no pulse.`
5141
- });
5142
- }
5143
- if (layer.trail && layer.motion.type === "static") {
5144
- warnings.push({
5145
- path: `layers[${li}].trail`,
5146
- code: "trail-on-static",
5147
- message: "trail has no effect on static entities \u2014 they have no past positions to draw"
5148
- });
5149
- }
5150
- if ((layer.sprite.kind === "streak" || layer.sprite.kind === "stroke" && layer.sprite.orient) && layer.motion.type === "static") {
5151
- warnings.push({
5152
- path: `layers[${li}].sprite`,
5153
- code: "streak-on-static",
5154
- message: `${layer.sprite.kind === "streak" ? "streak sprites orient" : "an oriented stroke turns"} along the motion heading \u2014 static entities have none, so the heading contributes nothing${layer.spin ? " (spin still rotates the mark)" : " and it renders at angle 0"}`
5155
- });
5156
- }
5157
- if (layer.motion.type === "wander" && layer.motion.coherence === 1 && entities.length >= 20) {
5158
- warnings.push({
5159
- path: `layers[${li}].motion.coherence`,
5160
- code: "full-coherence",
5161
- message: "coherence 1 makes every entity share identical harmonics \u2014 the layer moves as a rigid sheet. 0.5\u20130.8 reads as schooling"
5162
- });
5163
- }
5164
- if (layer.sprite.kind === "circle") {
5165
- const maxR = entities.reduce((m, e) => Math.max(m, e.size / 2), 0);
5166
- const maxAlpha = entities.reduce((m, e) => Math.max(m, e.alpha), 0);
5167
- if (maxR * maxAlpha < 0.05) {
5168
- warnings.push({
5169
- path: `layers[${li}]`,
5170
- code: "invisible-layer",
5171
- message: `layer's max visible radius (${maxR.toFixed(1)}px \xD7 ${maxAlpha.toFixed(2)} alpha) is below perceptual floor`
5172
- });
5173
- }
5121
+ return { w, h, scale, seed, layers, byKey, transforms };
5122
+ }
5123
+ function posOf(scene, e, t) {
5124
+ const p = positionAt(e, t, scene.w, scene.h);
5125
+ if (e.orbitParent) {
5126
+ const parent = scene.byKey.get(e.orbitParent)?.[0];
5127
+ if (parent) {
5128
+ const pp = positionAt(parent, t, scene.w, scene.h);
5129
+ p.x += pp.x;
5130
+ p.y += pp.y;
5174
5131
  }
5175
- if (entities.length > 0 && layer.sprite.kind !== "emoji") {
5176
- const seps = entities.map((e) => {
5177
- const hex = spriteHex(layer, e);
5178
- if (hex === null) return Infinity;
5179
- return colourSeparation(layer, { rgb: hexRgb(hex), luma: hexLuma(hex) }, { rgb: bgRgb, luma: bgLuma });
5180
- });
5181
- const best = Math.max(...seps);
5182
- if (best < LOW_CONTRAST_FLOOR) {
5183
- const additive = layer.blend === "lighter" || layer.blend === "screen";
5184
- warnings.push({
5185
- path: `layers[${li}].sprite`,
5186
- code: "low-contrast-layer",
5187
- message: additive ? `additive layer adds almost no light (luma ${best.toFixed(3)}) \u2014 a "lighter"/"screen" layer is only as visible as it is bright` : `layer colour is within ${best.toFixed(3)} of the background (floor ${LOW_CONTRAST_FLOOR}) \u2014 it will read as background, not as content`
5188
- });
5189
- }
5132
+ }
5133
+ const map = scene.transforms?.get(e);
5134
+ return map ? map(p) : p;
5135
+ }
5136
+ function textBox(s, e, p) {
5137
+ const m = s.font ? /(\d+(?:\.\d+)?)px/.exec(s.font) : null;
5138
+ const fh = m ? Number(m[1]) : e.size;
5139
+ const str = s.strings[e.spriteIndex] ?? s.strings[0] ?? "";
5140
+ const fw = Math.max(fh, 0.62 * fh * Math.min(str.length, 48));
5141
+ const align = s.align ?? "center";
5142
+ const baseline = s.baseline ?? "middle";
5143
+ return {
5144
+ cx: align === "left" ? p.x + fw / 2 : align === "right" ? p.x - fw / 2 : p.x,
5145
+ cy: baseline === "top" ? p.y + fh * 0.6 : baseline === "bottom" ? p.y - fh * 0.6 : p.y,
5146
+ halfX: fw / 2,
5147
+ halfY: fh * 0.6
5148
+ };
5149
+ }
5150
+ function textBlockBox(s, _e, p, w, h) {
5151
+ const unitScale = Math.min(w, h);
5152
+ const fsPx = s.fontSize * unitScale;
5153
+ const lh = (s.lineHeight ?? 1.4) * fsPx;
5154
+ const maxWPx = s.maxWidth * unitScale;
5155
+ const maxWEm = maxWPx / fsPx;
5156
+ const lines = breakTextBlock(s.text, maxWEm, textMetricsClassFor(s.font));
5157
+ const totalH = lines.length * lh;
5158
+ const maxLineW = lines.reduce((m, l) => Math.max(m, l.widthEm), 0) * fsPx;
5159
+ const align = s.align ?? "left";
5160
+ const cx = align === "center" ? p.x + maxWPx / 2 : align === "right" ? p.x + maxWPx - maxLineW / 2 : p.x + maxLineW / 2;
5161
+ const { dx, dy } = textBlockAnchorOffset(s, maxWPx, maxLineW, totalH);
5162
+ return {
5163
+ cx: cx + dx,
5164
+ cy: p.y + totalH / 2 + dy,
5165
+ halfX: maxLineW / 2,
5166
+ halfY: totalH / 2
5167
+ };
5168
+ }
5169
+ function textBlockRevealFraction(s, w, h, t) {
5170
+ if (!s.reveal) return 1;
5171
+ const unitScale = Math.min(w, h);
5172
+ const maxWEm = s.maxWidth * unitScale / (s.fontSize * unitScale);
5173
+ const lines = breakTextBlock(s.text, maxWEm, textMetricsClassFor(s.font));
5174
+ const rs = revealState(lines, s.reveal, t);
5175
+ if (rs.glyphAlphas) {
5176
+ let sum = 0;
5177
+ let n = 0;
5178
+ for (const line of rs.glyphAlphas) for (const a of line) {
5179
+ sum += a;
5180
+ n++;
5190
5181
  }
5182
+ return n > 0 ? sum / n : 1;
5191
5183
  }
5192
- let totalCoverage = 0;
5193
- for (let li = 0; li < spec.layers.length; li++) {
5194
- if (hidden?.[li]) continue;
5195
- const layer = spec.layers[li];
5196
- const entities = allEntities[li];
5197
- const paintA = layer.opacity === void 0 && layer.group === void 0 ? 1 : paintOpacity(spec, layer);
5198
- for (const e of entities) {
5199
- const r = e.size / 2;
5200
- let pixArea;
5201
- if (layer.sprite.kind === "circle") {
5202
- pixArea = Math.PI * r * r;
5203
- } else if (layer.sprite.kind === "polygon") {
5204
- pixArea = polygonArea(polygonPoints(layer.sprite, r));
5205
- } else if (layer.sprite.kind === "bar") {
5206
- pixArea = e.size * barFraction(layer.sprite, e.barIndex ?? 0) * (e.size2 ?? e.size * 0.2);
5207
- } else if (layer.sprite.kind === "textBlock") {
5208
- const fsPx = layer.sprite.fontSize * scale;
5209
- const lh = (layer.sprite.lineHeight ?? 1.4) * fsPx;
5210
- const maxWPx = layer.sprite.maxWidth * scale;
5211
- const lines = breakTextBlock(layer.sprite.text, maxWPx / fsPx, textMetricsClassFor(layer.sprite.font));
5212
- pixArea = maxWPx * lines.length * lh * 0.55 * (layer.sprite.opacity ?? 1);
5213
- } else {
5214
- pixArea = e.size * e.size;
5184
+ return rs.progress;
5185
+ }
5186
+ function polygonCellCoverage(pts, cellW, cellH, c0, c1, r0, r1) {
5187
+ const nc = c1 - c0 + 1;
5188
+ const cov = new Float64Array(nc * (r1 - r0 + 1));
5189
+ const xMin = c0 * cellW;
5190
+ const xMax = (c1 + 1) * cellW;
5191
+ const share = 1 / POLYGON_SCANLINES;
5192
+ const hits = [];
5193
+ const addSpan = (row, xa, xb) => {
5194
+ const a = Math.max(xMin, xa);
5195
+ const b = Math.min(xMax, xb);
5196
+ if (!(b > a)) return;
5197
+ const first = Math.max(c0, Math.floor(a / cellW));
5198
+ const last = Math.min(c1, Math.floor(b / cellW));
5199
+ for (let c = first; c <= last; c++) {
5200
+ const len = Math.min(b, (c + 1) * cellW) - Math.max(a, c * cellW);
5201
+ if (len > 0) cov[row * nc + (c - c0)] += len / cellW * share;
5202
+ }
5203
+ };
5204
+ for (let r = r0; r <= r1; r++) {
5205
+ for (let k = 0; k < POLYGON_SCANLINES; k++) {
5206
+ const y = (r + (k + 0.5) / POLYGON_SCANLINES) * cellH;
5207
+ hits.length = 0;
5208
+ for (let i = 0; i < pts.length; i++) {
5209
+ const p = pts[i];
5210
+ const q = pts[(i + 1) % pts.length];
5211
+ const down = p.y <= y && q.y > y;
5212
+ if (!down && !(q.y <= y && p.y > y)) continue;
5213
+ hits.push({ x: p.x + (y - p.y) / (q.y - p.y) * (q.x - p.x), dir: down ? 1 : -1 });
5215
5214
  }
5216
- let duty = 1;
5217
- if (layer.emit) {
5218
- const [ga, gb] = layer.emit.grow ?? [1, 1];
5219
- const g2 = (ga * ga + ga * gb + gb * gb) / 3;
5220
- duty = Math.min(layer.emit.life, layer.emit.every) / layer.emit.every * 0.5 * g2;
5215
+ if (hits.length < 2) continue;
5216
+ hits.sort((m, n) => m.x - n.x);
5217
+ let wind = 0;
5218
+ let start = 0;
5219
+ for (const hit of hits) {
5220
+ const before = wind;
5221
+ wind += hit.dir;
5222
+ if (before === 0 && wind !== 0) start = hit.x;
5223
+ else if (before !== 0 && wind === 0) addSpan(r - r0, start, hit.x);
5221
5224
  }
5222
- totalCoverage += paintA === 1 ? pixArea * e.alpha * duty / (w * h) : pixArea * e.alpha * duty * paintA / (w * h);
5223
- }
5224
- if (layer.links) {
5225
- const positions = entities.map((e) => positionAt(e, 0, w, h));
5226
- const maxDistPx = layer.links.maxDist * scale;
5227
- const edges = linkEdges(layer.links, positions, maxDistPx, layer.wrap !== false, w, h);
5228
- const defaultWidth = scale === 1 ? 1 : 1 / LIMITS.referenceViewport;
5229
- const lwPx = (layer.links.width ?? defaultWidth) * scale;
5230
- const la = layer.links.alpha ?? 1;
5231
- for (const edge of edges) totalCoverage += paintA === 1 ? edge.dist * lwPx * la / (w * h) : edge.dist * lwPx * la * paintA / (w * h);
5232
5225
  }
5233
5226
  }
5234
- const density = spec.density ?? "normal";
5235
- const wouldBeSparse = totalCoverage < 5e-4 && spec.layers.length > 0;
5236
- if (wouldBeSparse && density !== "sparse") {
5237
- warnings.push({
5238
- path: "layers",
5239
- code: "sparse-scene",
5240
- message: `alpha-weighted coverage is ${(totalCoverage * 100).toFixed(4)}% \u2014 scene will look empty on most displays`
5241
- });
5242
- }
5243
- if (totalEntities > 500 && density !== "dense") {
5244
- warnings.push({
5245
- path: "layers",
5246
- code: "dense-scene",
5247
- message: `${totalEntities} entities \u2014 scene may feel crowded and hurt performance on low-end devices`
5248
- });
5249
- }
5250
- if (density === "sparse" && totalCoverage > SPARSE_DECLARED_MAX_COVERAGE) {
5251
- warnings.push({
5252
- path: "density",
5253
- code: "density-mismatch",
5254
- message: `declared sparse but alpha-weighted coverage is ${(totalCoverage * 100).toFixed(2)}% \u2014 either the declaration or the scene is wrong`
5255
- });
5256
- } else if (density === "dense" && wouldBeSparse) {
5257
- warnings.push({
5258
- path: "density",
5259
- code: "density-mismatch",
5260
- message: `declared dense but alpha-weighted coverage is ${(totalCoverage * 100).toFixed(4)}% \u2014 the scene will look empty`
5261
- });
5227
+ return cov;
5228
+ }
5229
+ function distanceToOutline(pts, x, y) {
5230
+ let best = Infinity;
5231
+ for (let i = 0; i < pts.length; i++) {
5232
+ const p = pts[i];
5233
+ const q = pts[(i + 1) % pts.length];
5234
+ const ex = q.x - p.x;
5235
+ const ey = q.y - p.y;
5236
+ const len2 = ex * ex + ey * ey;
5237
+ const u = len2 > 0 ? Math.max(0, Math.min(1, ((x - p.x) * ex + (y - p.y) * ey) / len2)) : 0;
5238
+ const dx = x - (p.x + ex * u);
5239
+ const dy = y - (p.y + ey * u);
5240
+ best = Math.min(best, dx * dx + dy * dy);
5262
5241
  }
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
- });
5242
+ return Math.sqrt(best);
5243
+ }
5244
+ function luminanceGrid(spec, opts = {}) {
5245
+ spec = resolveTimelineAt(spec, opts.t ?? 5e3);
5246
+ const scene = buildScene(spec, opts);
5247
+ const t = opts.t ?? 5e3;
5248
+ const cols = Math.max(8, Math.min(200, opts.cols ?? 80));
5249
+ const rows = Math.max(8, Math.min(200, opts.rows ?? 48));
5250
+ const { w, h, scale } = scene;
5251
+ const cellW = w / cols;
5252
+ const cellH = h / rows;
5253
+ const bg = spec.background;
5254
+ const bgRow = new Array(rows).fill(0);
5255
+ let bgCells;
5256
+ if (!bg || bg.type === "solid") {
5257
+ bgRow.fill(hexLuma(bg?.color ?? "#05050a"));
5258
+ } else if (bg.type === "field") {
5259
+ bgCells = new Array(cols * rows);
5260
+ const short = Math.max(1, Math.min(w, h));
5261
+ const seed = scene.seed;
5262
+ const ft = fieldSampleTime(bg, t);
5263
+ for (let r = 0; r < rows; r++) {
5264
+ let rowSum = 0;
5265
+ for (let c = 0; c < cols; c++) {
5266
+ const lum = rgb255Luma(fieldRgbAt((c + 0.5) * cellW / short, (r + 0.5) * cellH / short, ft, bg, seed));
5267
+ bgCells[r * cols + c] = lum;
5268
+ rowSum += lum;
5269
+ }
5270
+ bgRow[r] = rowSum / cols;
5271
5271
  }
5272
- }
5273
- for (let li = 0; li < spec.layers.length; li++) {
5274
- if (hidden?.[li]) continue;
5275
- const layer = spec.layers[li];
5276
- if (!layer.links || layer.links.mode === "chain") continue;
5277
- const entities = allEntities[li];
5278
- const positions = entities.map((e) => positionAt(e, 0, w, h));
5279
- const maxDist = layer.links.maxDist * scale;
5280
- const pairs = linkPairs(positions, layer.links.k, maxDist, layer.wrap !== false, w, h);
5281
- const maxPossible = Math.min(entities.length * layer.links.k, entities.length * (entities.length - 1) / 2);
5282
- if (maxPossible > 0 && pairs.length / maxPossible < 0.1) {
5283
- warnings.push({
5284
- path: `layers[${li}].links`,
5285
- code: "link-starvation",
5286
- message: `only ${pairs.length}/${maxPossible} possible edges formed \u2014 raise count or widen links.maxDist`
5287
- });
5272
+ } else {
5273
+ const stops = [...bg.stops].sort((a, b) => a.at - b.at);
5274
+ for (let r = 0; r < rows; r++) {
5275
+ const y = (r + 0.5) / rows;
5276
+ let lum;
5277
+ if (y <= stops[0].at) lum = hexLuma(stops[0].color);
5278
+ else if (y >= stops[stops.length - 1].at) lum = hexLuma(stops[stops.length - 1].color);
5279
+ else {
5280
+ let i = 0;
5281
+ while (i < stops.length - 1 && stops[i + 1].at < y) i++;
5282
+ const a = stops[i];
5283
+ const b = stops[i + 1];
5284
+ const k = b.at === a.at ? 0 : (y - a.at) / (b.at - a.at);
5285
+ lum = hexLuma(a.color) + (hexLuma(b.color) - hexLuma(a.color)) * k;
5286
+ }
5287
+ bgRow[r] = lum;
5288
5288
  }
5289
- }
5290
- for (let li = 0; li < spec.layers.length; li++) {
5291
- if (hidden?.[li]) continue;
5292
- const layer = spec.layers[li];
5293
- if (layer.motion.type === "static") continue;
5294
- const entities = allEntities[li];
5295
- if (entities.length < 3) continue;
5296
- const speeds = entities.map((e) => Math.sqrt(e.vx * e.vx + e.vy * e.vy));
5297
- const mean = speeds.reduce((a, b) => a + b, 0) / speeds.length;
5298
- if (mean === 0) continue;
5299
- const maxDev = Math.max(...speeds.map((s) => Math.abs(s - mean)));
5300
- if (maxDev / mean < 0.05) {
5301
- warnings.push({
5302
- path: `layers[${li}].motion`,
5303
- code: "uniform-motion",
5304
- message: "all entities move at nearly identical speed \u2014 widen the speed range for visual variety"
5305
- });
5289
+ if (bg.band) {
5290
+ const bandPx = bg.band.height * (spec.units === "px" ? 1 : Math.min(w, h));
5291
+ const fromRow = Math.max(0, Math.floor((h - bandPx) / cellH));
5292
+ for (let r = fromRow; r < rows; r++) bgRow[r] = hexLuma(bg.band.color);
5306
5293
  }
5307
5294
  }
5308
- const composed = spec.layers.map((l, li) => ({ l, li, ents: allEntities[li] })).filter(({ li }) => !hidden?.[li]).filter(({ l }) => !["bounce", "warp", "path"].includes(l.motion.type)).filter(({ l }) => !(l.motion.type === "orbit" && l.motion.center && "layer" in l.motion.center));
5309
- const composedCount = composed.reduce((s, c) => s + c.ents.length, 0);
5310
- if (composedCount >= 8) {
5311
- const positions = composed.flatMap(({ ents }) => ents.map((e) => positionAt(e, 0, w, h)));
5312
- const cx = positions.reduce((s, p) => s + p.x, 0) / positions.length;
5313
- const cy = positions.reduce((s, p) => s + p.y, 0) / positions.length;
5314
- const offX = Math.abs(cx / w - 0.5);
5315
- const offY = Math.abs(cy / h - 0.5);
5316
- if (offX > 0.2 || offY > 0.2) {
5317
- warnings.push({
5318
- path: "layers",
5319
- code: "off-center",
5320
- message: `centroid at (${(cx / w).toFixed(2)}, ${(cy / h).toFixed(2)}) \u2014 composition is heavily offset from center`
5321
- });
5295
+ const cells = new Array(cols * rows);
5296
+ 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];
5297
+ const compose = (idx, lum, a, blend) => {
5298
+ const cur = cells[idx];
5299
+ if (blend === "lighter" || blend === "screen") cells[idx] = Math.min(1, cur + lum * a);
5300
+ else if (blend === "multiply") cells[idx] = cur * (1 - a * (1 - lum));
5301
+ else cells[idx] = cur * (1 - a) + lum * a;
5302
+ };
5303
+ const splatTrail = (layer, e, tPass, lifeA, alphaScale) => {
5304
+ const trail = layer.trail;
5305
+ if (!trail) return;
5306
+ const fade = trail.fade ?? 1;
5307
+ const n = Math.min(Math.ceil(trail.length / 50), LIMITS.maxTrailSamples);
5308
+ const headAlpha = alphaAt(e, tPass) * lifeA;
5309
+ const headSize = sizeAt(e, tPass);
5310
+ const lum = spriteLuma(layer, e);
5311
+ const soft = layer.sprite.kind === "circle" && !!layer.sprite.soft;
5312
+ const wrapOn = layer.wrap !== false;
5313
+ const head = posOf(scene, e, tPass);
5314
+ let prevX = head.x;
5315
+ let prevY = head.y;
5316
+ for (let s = 1; s <= n; s++) {
5317
+ const k = s / n;
5318
+ const pastT = tPass - k * trail.length;
5319
+ if (pastT < 0) break;
5320
+ const pos = posOf(scene, e, pastT);
5321
+ if (wrapOn && (Math.abs(pos.x - prevX) > w / 2 || Math.abs(pos.y - prevY) > h / 2)) break;
5322
+ prevX = pos.x;
5323
+ prevY = pos.y;
5324
+ const a = headAlpha * (1 - k * fade) * alphaScale;
5325
+ if (a <= 4e-3) break;
5326
+ const rad = headSize / 2 * (1 - k * 0.7);
5327
+ if (rad < 0.2) break;
5328
+ const c0 = Math.max(0, Math.floor((pos.x - rad) / cellW));
5329
+ const c1 = Math.min(cols - 1, Math.floor((pos.x + rad) / cellW));
5330
+ const r0 = Math.max(0, Math.floor((pos.y - rad) / cellH));
5331
+ const r1 = Math.min(rows - 1, Math.floor((pos.y + rad) / cellH));
5332
+ for (let r = r0; r <= r1; r++) {
5333
+ for (let c = c0; c <= c1; c++) {
5334
+ const dx = (c + 0.5) * cellW - pos.x;
5335
+ const dy = (r + 0.5) * cellH - pos.y;
5336
+ const d = Math.sqrt(dx * dx + dy * dy);
5337
+ if (d > rad + Math.min(cellW, cellH) / 2) continue;
5338
+ const wgt = soft ? Math.max(0.1, 1 - d / Math.max(rad, 1e-6)) : 1;
5339
+ compose(r * cols + c, lum, Math.min(1, a * wgt), layer.blend);
5340
+ }
5341
+ }
5322
5342
  }
5323
- }
5324
- const textBoxes = [];
5325
- for (let li = 0; li < spec.layers.length; li++) {
5326
- if (hidden?.[li]) continue;
5327
- const layer = spec.layers[li];
5328
- const s = layer.sprite;
5329
- if (layer.motion.type !== "static") continue;
5330
- if (s.kind !== "text" && s.kind !== "textBlock") continue;
5331
- const label = layer.key ? `\`${layer.key}\`` : `layers[${li}]`;
5332
- for (const e of allEntities[li]) {
5333
- const p = positionAt(e, 0, w, h);
5334
- const raw = s.kind === "textBlock" ? textBlockBoxAt(s, p, w, h) : textBoxAt(s, e, p, spec, w, h);
5335
- const map = paintMap(spec, layer, w, h, scale);
5336
- const box = map ? { ...raw, ...mapBox(map, raw) } : raw;
5337
- const baseAlpha = Math.max(0, Math.min(1, e.alpha - e.pulseAmp)) * (s.kind === "textBlock" ? s.opacity ?? 1 : 1);
5338
- const alpha = layer.opacity === void 0 && layer.group === void 0 ? baseAlpha : baseAlpha * paintOpacity(spec, layer);
5339
- textBoxes.push({ li, label, alpha, ...box });
5343
+ };
5344
+ const splatPolygonOutline = (layer, s, e, p, sz, a, lum, tPass) => {
5345
+ const radius = sz / 2;
5346
+ const rot = rotationAt(e, tPass);
5347
+ const cr = Math.cos(rot);
5348
+ const sr = Math.sin(rot);
5349
+ 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 }));
5350
+ const additive = layer.blend === "lighter" || layer.blend === "screen";
5351
+ const glow = !!s.soft && additive;
5352
+ let band = 0;
5353
+ if (glow) {
5354
+ const perimeter = pts.reduce((acc, q, i) => {
5355
+ const n = pts[(i + 1) % pts.length];
5356
+ return acc + Math.hypot(n.x - q.x, n.y - q.y);
5357
+ }, 0);
5358
+ band = perimeter > 0 ? (GLOW_SPREAD - 1) * (2 * polygonArea(pts) / perimeter) : 0;
5340
5359
  }
5341
- }
5342
- const asFraction = (b) => ({
5343
- x: +(b.x0 / w).toFixed(4),
5344
- y: +(b.y0 / h).toFixed(4),
5345
- w: +((b.x1 - b.x0) / w).toFixed(4),
5346
- h: +((b.y1 - b.y0) / h).toFixed(4)
5347
- });
5348
- const tolX = w * 0.01;
5349
- const tolY = h * 0.01;
5350
- const offFlagged = /* @__PURE__ */ new Set();
5351
- for (const b of textBoxes) {
5352
- if (offFlagged.has(b.li)) continue;
5353
- const edges = [];
5354
- if (b.x0 < -tolX) edges.push(`left edge by ~${Math.round(-b.x0)}px`);
5355
- if (b.x1 > w + tolX) edges.push(`right edge by ~${Math.round(b.x1 - w)}px`);
5356
- if (b.y0 < -tolY) edges.push(`top edge by ~${Math.round(-b.y0)}px`);
5357
- if (b.y1 > h + tolY) edges.push(`bottom edge by ~${Math.round(b.y1 - h)}px`);
5358
- if (edges.length === 0) continue;
5359
- offFlagged.add(b.li);
5360
- warnings.push({
5361
- path: `layers[${b.li}]`,
5362
- code: "text-off-screen",
5363
- message: `${b.label} text runs off the ${edges.join(" and the ")} at ${w}\xD7${h} \u2014 move position, or shrink fontSize / maxWidth`
5364
- });
5365
- }
5366
- const pairFlagged = /* @__PURE__ */ new Set();
5367
- for (let i = 0; i < textBoxes.length; i++) {
5368
- for (let j = i + 1; j < textBoxes.length; j++) {
5369
- const a = textBoxes[i];
5370
- const b = textBoxes[j];
5371
- if (a.li === b.li) continue;
5372
- const pair = `${Math.min(a.li, b.li)}:${Math.max(a.li, b.li)}`;
5373
- if (pairFlagged.has(pair)) continue;
5374
- const ix = Math.min(a.x1, b.x1) - Math.max(a.x0, b.x0);
5375
- const iy = Math.min(a.y1, b.y1) - Math.max(a.y0, b.y0);
5376
- if (ix <= 0 || iy <= 0) continue;
5377
- const smaller = Math.min((a.x1 - a.x0) * (a.y1 - a.y0), (b.x1 - b.x0) * (b.y1 - b.y0));
5378
- if (smaller <= 0) continue;
5379
- const share = ix * iy / smaller;
5380
- if (share <= 0.1) continue;
5381
- pairFlagged.add(pair);
5382
- const [first, second] = a.li < b.li ? [a, b] : [b, a];
5383
- warnings.push({
5384
- path: `layers[${first.li}]`,
5385
- code: "text-overlap",
5386
- message: `${first.label} and ${second.label} text boxes overlap (~${Math.round(share * 100)}% of the smaller one) \u2014 they will paint over each other; move one or shrink it`,
5387
- // The two boxes, so an agent can move one without re-deriving the
5388
- // layout. Additive: the message is unchanged.
5389
- boxes: [asFraction(first), asFraction(second)]
5390
- });
5391
- }
5392
- }
5393
- const readFlagged = /* @__PURE__ */ new Set();
5394
- for (const b of textBoxes) {
5395
- if (readFlagged.has(b.li)) continue;
5396
- const layer = spec.layers[b.li];
5397
- const s = layer.sprite;
5398
- if (s.kind !== "text" && s.kind !== "textBlock" || s.role !== "read") continue;
5399
- const boxes = textBoxes.filter((x) => x.li === b.li);
5400
- readFlagged.add(b.li);
5401
- const ink = hexRgb(s.color ?? "#e6e8ef");
5402
- const textPlate = (base, alpha) => {
5403
- if (layer.blend === "lighter" || layer.blend === "screen") return additivePlate(base, ink, alpha, layer.blend);
5404
- if (layer.blend === "multiply") {
5405
- return {
5406
- r: base.r * (1 - alpha + ink.r * alpha),
5407
- g: base.g * (1 - alpha + ink.g * alpha),
5408
- b: base.b * (1 - alpha + ink.b * alpha)
5409
- };
5410
- }
5411
- return sourceOverPlate(base, ink, alpha);
5412
- };
5413
- let worst = null;
5414
- for (const box of boxes) {
5415
- const cy = (box.y0 + box.y1) / 2;
5416
- const cx = (box.x0 + box.x1) / 2;
5417
- const groundRgb = backgroundRgbAt(spec, cy, h, scale, cx, w, opts.backgroundSeed ?? opts.seed, opts.t ?? COHESION_T);
5418
- const ground = legibilityRatio(textPlate(groundRgb, box.alpha), groundRgb);
5419
- const lit = brightestAdditivePlate(spec, allEntities, groundRgb, box, w, h);
5420
- const plate = lit ? legibilityRatio(textPlate(lit.rgb, box.alpha), lit.rgb) : Infinity;
5421
- if (!worst || Math.min(ground, plate) < Math.min(worst.ground, worst.plate)) {
5422
- worst = { ground, plate, plateLabel: lit?.label ?? null };
5423
- }
5424
- }
5425
- if (worst && Math.min(worst.ground, worst.plate) < READ_LEGIBILITY_FLOOR) {
5426
- const under = worst.plateLabel ? ` and ${worst.plate.toFixed(1)}:1 under ${worst.plateLabel} at its brightest` : "";
5427
- warnings.push({
5428
- path: `layers[${b.li}].sprite`,
5429
- code: "text-legibility",
5430
- message: `${b.label} is declared role: 'read' but its colour reads at ${worst.ground.toFixed(1)}:1 against the background at the box centre${under} \u2014 below ${READ_LEGIBILITY_FLOOR}:1; brighten the text, darken the ground under it, or declare role: 'atmosphere' if it is texture`
5431
- });
5432
- }
5433
- const edges = [];
5434
- const x0 = Math.min(...boxes.map((x) => x.x0));
5435
- const x1 = Math.max(...boxes.map((x) => x.x1));
5436
- const y0 = Math.min(...boxes.map((x) => x.y0));
5437
- const y1 = Math.max(...boxes.map((x) => x.y1));
5438
- if (x0 < w * READ_SAFE_AREA) edges.push("left");
5439
- if (x1 > w * (1 - READ_SAFE_AREA)) edges.push("right");
5440
- if (y0 < h * READ_SAFE_AREA) edges.push("top");
5441
- if (y1 > h * (1 - READ_SAFE_AREA)) edges.push("bottom");
5442
- if (edges.length > 0) {
5443
- warnings.push({
5444
- path: `layers[${b.li}]`,
5445
- code: "text-safe-area",
5446
- message: `${b.label} is declared role: 'read' but its box is within ${READ_SAFE_AREA * 100}% of the ${edges.join(" and ")} edge${edges.length === 1 ? "" : "s"} at ${w}\xD7${h} \u2014 displays crop and bezels hide that zone; move it inward`,
5447
- boxes: [asFraction({ x0, y0, x1, y1 })]
5448
- });
5360
+ let minX = Infinity;
5361
+ let maxX = -Infinity;
5362
+ let minY = Infinity;
5363
+ let maxY = -Infinity;
5364
+ for (const q of pts) {
5365
+ minX = Math.min(minX, q.x);
5366
+ maxX = Math.max(maxX, q.x);
5367
+ minY = Math.min(minY, q.y);
5368
+ maxY = Math.max(maxY, q.y);
5449
5369
  }
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 };
5370
+ const c0 = Math.max(0, Math.floor((minX - band) / cellW));
5371
+ const c1 = Math.min(cols - 1, Math.floor((maxX + band) / cellW));
5372
+ const r0 = Math.max(0, Math.floor((minY - band) / cellH));
5373
+ const r1 = Math.min(rows - 1, Math.floor((maxY + band) / cellH));
5374
+ if (c0 > c1 || r0 > r1) return;
5375
+ const cov = polygonCellCoverage(pts, cellW, cellH, c0, c1, r0, r1);
5376
+ const nc = c1 - c0 + 1;
5377
+ for (let r = r0; r <= r1; r++) {
5378
+ for (let c = c0; c <= c1; c++) {
5379
+ const f = cov[(r - r0) * nc + (c - c0)];
5380
+ const x = (c + 0.5) * cellW;
5381
+ const y = (r + 0.5) * cellH;
5382
+ let wgt;
5383
+ if (glow) {
5384
+ const outX = Math.max(0, minX - x, x - maxX);
5385
+ const outY = Math.max(0, minY - y, y - maxY);
5386
+ if (f >= 1) wgt = 1;
5387
+ else if (outX * outX + outY * outY >= band * band) wgt = f;
5388
+ else {
5389
+ const k = band > 0 ? Math.max(0, 1 - distanceToOutline(pts, x, y) / band) : 0;
5390
+ wgt = f + (1 - f) * k * k;
5391
+ }
5392
+ } else if (s.soft) {
5393
+ wgt = f * Math.max(0.1, 1 - Math.hypot(x - p.x, y - p.y) / Math.max(radius, 1e-6));
5394
+ } else {
5395
+ wgt = f;
5396
+ }
5397
+ if (wgt > 0) compose(r * cols + c, lum, a * Math.min(1, wgt), layer.blend);
5475
5398
  }
5476
5399
  }
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
5400
  };
5529
- const out = spec.layers.map((l, i) => {
5530
- const lp = `layers.${i}`;
5531
- if (pinnedZero(`${lp}.opacity`, l.opacity, ["layers", lp])) return true;
5532
- const g = l.group !== void 0 && spec.groups && Object.prototype.hasOwnProperty.call(spec.groups, l.group) ? spec.groups[l.group] : void 0;
5533
- return !!g && pinnedZero(`groups.${l.group}.opacity`, g.opacity, ["groups", `groups.${l.group}`]);
5534
- });
5535
- return out.some(Boolean) ? out : null;
5536
- }
5537
- function adviseSequence(seq, viewport = { width: 1920, height: 1080 }) {
5538
- const warnings = [];
5539
- for (let i = 0; i < seq.segments.length; i++) {
5540
- const segWarnings = adviseSpec(seq.segments[i].scene, viewport);
5541
- for (const w of segWarnings) {
5542
- warnings.push({ path: `segments[${i}].scene.${w.path}`, code: w.code, message: w.message, boxes: w.boxes });
5543
- }
5544
- }
5545
- const fadeBound = seq.loop ? seq.segments.length : seq.segments.length - 1;
5546
- const fades = seq.segments.slice(0, fadeBound).filter((s) => s.transition?.type === "fade").length;
5547
- if (fades > 0) {
5548
- const first = seq.segments.slice(0, fadeBound).findIndex((s) => s.transition?.type === "fade");
5549
- warnings.push({
5550
- path: `segments[${first}].transition`,
5551
- code: "fade-degrades-on-low-tier",
5552
- message: `informational: ${fades} fade transition${fades === 1 ? "" : "s"} declared \u2014 viewers on the 'basic' or 'minimal' capability tier render fade as cut (two live segments for dur is over that budget); the show still plays`
5553
- });
5554
- }
5555
- for (let i = 0; i < seq.segments.length - 1; i++) {
5556
- const tr = seq.segments[i].transition;
5557
- const lumaA = backgroundLuma(resolveTimelineAt(seq.segments[i].scene, seq.segments[i].duration ?? 0));
5558
- const lumaB = backgroundLuma(resolveTimelineAt(seq.segments[i + 1].scene, 0));
5559
- const delta = Math.abs(lumaA - lumaB);
5560
- if (delta > 0.5 && tr?.type !== "fade") {
5561
- warnings.push({
5562
- path: `segments[${i}]`,
5563
- code: "boundary-luminance-jump",
5564
- message: `background luminance jumps ${delta.toFixed(2)} at boundary ${i}\u2192${i + 1} \u2014 may flash on cut${tr === void 0 || tr.type === "cut" ? `; declare transition: { type: 'fade', dur } on segments[${i}] to ramp it` : ""}`
5565
- });
5566
- }
5567
- if (tr?.type === "morph") {
5568
- const sigA = structuralSignature(seq.segments[i].scene);
5569
- const sigB = structuralSignature(seq.segments[i + 1].scene);
5570
- if (sigA !== sigB) {
5571
- warnings.push({
5572
- path: `segments[${i}].transition`,
5573
- code: "morph-structural-mismatch",
5574
- message: `segments ${i}\u2192${i + 1} differ structurally: morph will fall back to cut`
5575
- });
5576
- } else if (!seq.segments[i].scene.timeline && !seq.segments[i + 1].scene.timeline && morphNothingMorphable(seq.segments[i].scene, seq.segments[i + 1].scene, { textCrossfade: tr.text === "crossfade" || tr.text === "dip" })) {
5577
- warnings.push({
5578
- path: `segments[${i}].transition`,
5579
- code: "morph-nothing-morphable",
5580
- message: `segments ${i}\u2192${i + 1} differ only in values morph cannot interpolate (strings such as textBlock.text step on the first frame): the morph will look like a cut \u2014 fade text via colour or reveal.progress`
5581
- });
5582
- }
5583
- }
5584
- }
5585
- return warnings;
5586
- }
5587
-
5588
- // src/describe.ts
5589
- import { createRng as createRng3 } from "@idle-screens/core";
5590
- function describeScene(spec, options = {}) {
5591
- const { width: w, height: h } = options.viewport ?? { width: 1920, height: 1080 };
5592
- const seed = options.seed ?? spec.seed ?? 42;
5593
- const times = options.times ?? [0, 5e3, 15e3];
5594
- const scale = spec.units === "viewport" ? Math.min(w, h) : 1;
5595
- const refVp = spec.referenceViewport ?? LIMITS.referenceViewport;
5596
- const countScale = scale > 1 ? Math.min(w, h) / refVp : 1;
5597
- const rng = createRng3(seed);
5598
- const builtLayers = spec.layers.map((layer) => ({
5599
- layer,
5600
- entities: buildEntities(layer, rng, w, h, scale, countScale)
5601
- }));
5602
- const snapshots = times.map((t) => {
5603
- const layerSnapshots = builtLayers.map(({ layer, entities }) => {
5604
- const positions = entities.map((e) => positionAt(e, t, w, h));
5605
- const cx = positions.length > 0 ? positions.reduce((s, p) => s + p.x, 0) / positions.length : 0;
5606
- const cy = positions.length > 0 ? positions.reduce((s, p) => s + p.y, 0) / positions.length : 0;
5607
- let pixArea = 0;
5401
+ const splatPass = (tPass, alphaScale) => {
5402
+ for (const { layer, entities } of scene.layers) {
5403
+ const lifeA = layer.opacity === void 0 && layer.group === void 0 ? lifeAlphaAt(layer.life, tPass) : lifeAlphaAt(layer.life, tPass) * paintOpacity(spec, layer);
5404
+ if (lifeA <= 0) continue;
5608
5405
  for (const e of entities) {
5609
- const r = e.size / 2;
5610
- pixArea += layer.sprite.kind === "circle" ? Math.PI * r * r : layer.sprite.kind === "polygon" ? polygonArea(polygonPoints(layer.sprite, r)) : e.size * e.size;
5611
- }
5612
- const coverage = pixArea / (w * h);
5613
- const meanAlpha = entities.length > 0 ? entities.reduce((s, e) => s + e.alpha, 0) / entities.length : 0;
5614
- let linksDrawn = null;
5615
- let linksExpected = null;
5616
- let connectedComponents = null;
5617
- let isolatedNodes = null;
5618
- if (layer.links) {
5619
- const maxDist = layer.links.maxDist * scale;
5620
- const pairs = linkPairs(positions, layer.links.k, maxDist, layer.wrap !== false, w, h);
5621
- linksDrawn = pairs.length;
5622
- linksExpected = Math.min(entities.length * layer.links.k, entities.length * (entities.length - 1) / 2);
5623
- const parent = Array.from({ length: entities.length }, (_, i) => i);
5624
- const find = (x) => {
5625
- while (parent[x] !== x) {
5626
- parent[x] = parent[parent[x]];
5627
- x = parent[x];
5406
+ splatTrail(layer, e, tPass, lifeA, alphaScale);
5407
+ const a = Math.min(1, alphaAt(e, tPass) * lifeA) * alphaScale;
5408
+ if (a <= 4e-3) continue;
5409
+ const lum = spriteLuma(layer, e);
5410
+ const p = posOf(scene, e, tPass);
5411
+ const sz = layer.transform || layer.group !== void 0 ? sizeAt(e, tPass) * paintSize(spec, layer) : sizeAt(e, tPass);
5412
+ const s = layer.sprite;
5413
+ if (s.kind === "polygon" && sz >= POLYGON_OUTLINE_CELLS * Math.min(cellW, cellH)) {
5414
+ splatPolygonOutline(layer, s, e, p, sz, a, lum, tPass);
5415
+ continue;
5416
+ }
5417
+ if (s.kind === "stroke") {
5418
+ const pts = strokeSamples(s, sz / 2);
5419
+ let angle = rotationAt(e, tPass);
5420
+ if (s.orient) angle += headingAt(e, tPass, w, h) ?? 0;
5421
+ const ca = Math.cos(angle);
5422
+ const sa = Math.sin(angle);
5423
+ const lw = strokeWidthPx(s, scale);
5424
+ const stampAt = (q, u) => {
5425
+ const x = p.x + q.x * ca - q.y * sa;
5426
+ const y = p.y + q.x * sa + q.y * ca;
5427
+ const c = Math.floor(x / cellW);
5428
+ const r = Math.floor(y / cellH);
5429
+ if (c < 0 || c >= cols || r < 0 || r >= rows) return;
5430
+ const taper = s.taper ? strokeTaper(u) : 1;
5431
+ compose(r * cols + c, lum, a * Math.min(1, lw * taper / cellH), layer.blend);
5432
+ };
5433
+ const step = Math.min(cellW, cellH) / 2;
5434
+ stampAt(pts[0], 0);
5435
+ for (let i = 1; i < pts.length; i++) {
5436
+ const q0 = pts[i - 1];
5437
+ const q1 = pts[i];
5438
+ const segLen = Math.hypot(q1.x - q0.x, q1.y - q0.y);
5439
+ const sub = Math.max(1, Math.ceil(segLen / step));
5440
+ for (let k = 1; k <= sub; k++) {
5441
+ const f = k / sub;
5442
+ stampAt({ x: q0.x + (q1.x - q0.x) * f, y: q0.y + (q1.y - q0.y) * f }, (i - 1 + f) / (pts.length - 1));
5443
+ }
5628
5444
  }
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;
5445
+ continue;
5635
5446
  }
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]++;
5447
+ if (s.kind === "streak") {
5448
+ const heading = (headingAt(e, tPass, w, h) ?? 0) + rotationAt(e, tPass);
5449
+ const steps = Math.max(2, Math.ceil(sz / Math.min(cellW, cellH)));
5450
+ const wgt = Math.min(1, (s.width ?? (scale === 1 ? 2 : 2e-3)) * scale / cellH);
5451
+ for (let i = 0; i <= steps; i++) {
5452
+ const k = i / steps;
5453
+ const x = p.x - Math.cos(heading) * sz * (1 - k);
5454
+ const y = p.y - Math.sin(heading) * sz * (1 - k);
5455
+ const c = Math.floor(x / cellW);
5456
+ const r = Math.floor(y / cellH);
5457
+ if (c < 0 || c >= cols || r < 0 || r >= rows) continue;
5458
+ compose(r * cols + c, lum, a * k * wgt, layer.blend);
5459
+ }
5460
+ continue;
5642
5461
  }
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
- }
5663
- if (ls.linksDrawn !== null && ls.linksExpected !== null && ls.linksExpected > 0) {
5664
- const ratio = ls.linksDrawn / ls.linksExpected;
5665
- if (ratio < 0.1) {
5666
- warnings.push(`${label}: link starvation \u2014 ${ls.linksDrawn}/${ls.linksExpected} edges formed`);
5462
+ let centerX = p.x;
5463
+ let centerY = p.y;
5464
+ let halfX = sz / 2;
5465
+ let halfY = s.kind === "rect" ? (e.size2 ?? sz) * (e.size > 0 ? sz / e.size : 1) / 2 : sz / 2;
5466
+ if (s.kind === "text") {
5467
+ const box = textBox(s, e, p);
5468
+ centerX = box.cx;
5469
+ centerY = box.cy;
5470
+ halfX = box.halfX;
5471
+ halfY = box.halfY;
5472
+ } else if (s.kind === "textBlock") {
5473
+ const box = textBlockBox(s, e, p, w, h);
5474
+ centerX = box.cx;
5475
+ centerY = box.cy;
5476
+ halfX = box.halfX;
5477
+ halfY = box.halfY;
5478
+ } else if (s.kind === "bar") {
5479
+ const len = sz * barFraction(s, e.barIndex ?? 0);
5480
+ if (len <= 0) continue;
5481
+ const th = e.size2 !== void 0 ? e.size2 * (e.size > 0 ? sz / e.size : 1) : sz * 0.2;
5482
+ const box = barBox(s.direction ?? "right", len, th);
5483
+ const rot = rotationAt(e, tPass);
5484
+ const cr = Math.cos(rot);
5485
+ const sr = Math.sin(rot);
5486
+ centerX = p.x + box.cx * cr - box.cy * sr;
5487
+ centerY = p.y + box.cx * sr + box.cy * cr;
5488
+ halfX = Math.abs(box.halfX * cr) + Math.abs(box.halfY * sr);
5489
+ halfY = Math.abs(box.halfX * sr) + Math.abs(box.halfY * cr);
5490
+ }
5491
+ const circular = s.kind === "circle" || s.kind === "ring" || s.kind === "polygon";
5492
+ const soft = (s.kind === "circle" || s.kind === "polygon") && !!s.soft;
5493
+ const shapeWeight = s.kind === "polygon" ? polygonFill(s, sz / 2) : s.kind === "rect" && s.feather ? (1 - s.feather / 2) * (1 - s.feather / 2) : 1;
5494
+ const additive = layer.blend === "lighter" || layer.blend === "screen";
5495
+ const glow = soft && additive;
5496
+ const reachX = glow ? halfX * GLOW_SPREAD : halfX;
5497
+ const reachY = glow ? halfY * GLOW_SPREAD : halfY;
5498
+ const c0 = Math.max(0, Math.floor((centerX - reachX) / cellW));
5499
+ const c1 = Math.min(cols - 1, Math.floor((centerX + reachX) / cellW));
5500
+ const r0 = Math.max(0, Math.floor((centerY - reachY) / cellH));
5501
+ const r1 = Math.min(rows - 1, Math.floor((centerY + reachY) / cellH));
5502
+ 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);
5503
+ const byArea = s.kind === "rect" || s.kind === "bar";
5504
+ for (let r = r0; r <= r1; r++) {
5505
+ const fy = byArea ? Math.max(0, Math.min((r + 1) * cellH, centerY + halfY) - Math.max(r * cellH, centerY - halfY)) / cellH : 1;
5506
+ if (fy <= 0) continue;
5507
+ for (let c = c0; c <= c1; c++) {
5508
+ const dx = (c + 0.5) * cellW - centerX;
5509
+ const dy = (r + 0.5) * cellH - centerY;
5510
+ if (circular) {
5511
+ const d = Math.sqrt(dx * dx + dy * dy);
5512
+ const reach = glow ? reachX : halfX;
5513
+ if (d > reach + Math.min(cellW, cellH) / 2) continue;
5514
+ if (s.kind === "ring") {
5515
+ const strokeW = (s.width ?? (scale === 1 ? 2 : 2e-3)) * scale;
5516
+ if (d < halfX - strokeW) continue;
5517
+ }
5518
+ let wgt;
5519
+ if (glow) {
5520
+ const core = halfX;
5521
+ if (d <= core) wgt = 1;
5522
+ else {
5523
+ const f = Math.max(0, 1 - (d - core) / Math.max(reachX - core, 1e-6));
5524
+ wgt = f * f;
5525
+ }
5526
+ } else {
5527
+ wgt = soft ? Math.max(0.1, 1 - d / Math.max(halfX, 1e-6)) : 1;
5528
+ }
5529
+ compose(r * cols + c, lum, a * wgt * shapeWeight, layer.blend);
5530
+ } else if (byArea) {
5531
+ const fx = Math.max(0, Math.min((c + 1) * cellW, centerX + halfX) - Math.max(c * cellW, centerX - halfX)) / cellW;
5532
+ if (fx > 0) compose(r * cols + c, lum, a * shapeWeight * fx * fy, layer.blend);
5533
+ } else {
5534
+ compose(r * cols + c, lum, a * inkWeight * shapeWeight, layer.blend);
5535
+ }
5536
+ }
5667
5537
  }
5668
5538
  }
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`);
5539
+ if (layer.links) {
5540
+ const positions = entities.map((e) => posOf(scene, e, tPass));
5541
+ const maxDistPx = layer.links.maxDist * scale;
5542
+ const motionWraps = ["drift", "rise", "wander"].includes(layer.motion.type);
5543
+ const edges = linkEdges(layer.links, positions, maxDistPx, layer.wrap !== false && motionWraps, w, h);
5544
+ const la = Math.min(1, (layer.links.alpha ?? 0.6) * lifeA);
5545
+ const lum = layer.links.color ? hexLuma(layer.links.color) : 0.7;
5546
+ const defaultWidth = scale === 1 ? 1 : 1 / LIMITS.referenceViewport;
5547
+ const wgt = Math.min(1, (layer.links.width ?? defaultWidth) * scale / cellH) * la * alphaScale;
5548
+ for (const edge of edges) {
5549
+ const pi = positions[edge.i];
5550
+ const pj = positions[edge.j];
5551
+ const steps = Math.max(1, Math.ceil(edge.dist / Math.min(cellW, cellH)));
5552
+ for (let i = 0; i <= steps; i++) {
5553
+ const k = i / steps;
5554
+ const c = Math.floor((pi.x + (pj.x - pi.x) * k) / cellW);
5555
+ const r = Math.floor((pi.y + (pj.y - pi.y) * k) / cellH);
5556
+ if (c < 0 || c >= cols || r < 0 || r >= rows) continue;
5557
+ compose(r * cols + c, lum, wgt, layer.blend);
5558
+ }
5559
+ }
5671
5560
  }
5672
5561
  }
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
5562
  };
5679
- }
5680
-
5681
- // src/perceive.ts
5682
- import { createRng as createRng4 } from "@idle-screens/core";
5683
- var GLOW_SPREAD = 2.4;
5684
- var LINE_SALIENCE = 3.5;
5685
- var GHOST_TAPS = 12;
5686
- var POLYGON_OUTLINE_CELLS = 3;
5687
- var POLYGON_SCANLINES = 4;
5688
- function buildScene(spec, opts) {
5689
- const { width: w, height: h } = opts.viewport ?? { width: 1920, height: 1080 };
5690
- const seed = normalizeSeed(opts.seed ?? spec.seed ?? 42);
5691
- const scale = spec.units === "px" ? 1 : Math.min(w, h);
5692
- const refVp = spec.referenceViewport ?? LIMITS.referenceViewport;
5693
- let countScale = scale > 1 ? Math.min(w, h) / refVp : 1;
5694
- if (countScale > 1) {
5695
- const rawTotal = spec.layers.reduce((s, l) => s + Math.round(l.count * countScale), 0);
5696
- if (rawTotal > LIMITS.maxTotal) countScale *= LIMITS.maxTotal / rawTotal;
5697
- }
5698
- const rng = createRng4(seed);
5699
- const layers = spec.layers.map((layer) => ({ layer, entities: buildEntities(layer, rng, w, h, scale, countScale) }));
5700
- const byKey = /* @__PURE__ */ new Map();
5701
- for (const { layer, entities } of layers) if (layer.key) byKey.set(layer.key, entities);
5702
- let transforms = null;
5703
- for (const { layer, entities } of layers) {
5704
- const map = paintMap(spec, layer, w, h, scale);
5705
- if (!map) continue;
5706
- transforms ??= /* @__PURE__ */ new Map();
5707
- for (const e of entities) transforms.set(e, map);
5563
+ const ghost = spec.ghosting ?? 0;
5564
+ if (ghost > 0) {
5565
+ const dt = 1e3 / 60;
5566
+ const windowFrames = Math.min(Math.ceil(Math.log(1 / 255) / Math.log(ghost)), LIMITS.maxGhostReplayFrames);
5567
+ const taps = Math.min(GHOST_TAPS, windowFrames);
5568
+ const spacing = windowFrames / taps;
5569
+ for (let i = taps; i >= 1; i--) {
5570
+ const tPass = t - i * spacing * dt;
5571
+ if (tPass < 0) continue;
5572
+ const bracketWeight = Math.pow(ghost, (i - 1) * spacing + 1) * (1 - Math.pow(ghost, spacing)) / (1 - ghost);
5573
+ splatPass(tPass, Math.min(1, bracketWeight));
5574
+ }
5708
5575
  }
5709
- return { w, h, scale, seed, layers, byKey, transforms };
5576
+ splatPass(t, 1);
5577
+ return finishGrid(cols, rows, cells, bgRow, bgCells);
5710
5578
  }
5711
- function posOf(scene, e, t) {
5712
- const p = positionAt(e, t, scene.w, scene.h);
5713
- if (e.orbitParent) {
5714
- const parent = scene.byKey.get(e.orbitParent)?.[0];
5715
- if (parent) {
5716
- const pp = positionAt(parent, t, scene.w, scene.h);
5717
- p.x += pp.x;
5718
- p.y += pp.y;
5579
+ function finishGrid(cols, rows, cells, bgRow, bgCells) {
5580
+ let sum = 0;
5581
+ let covered = 0;
5582
+ let devSum = 0;
5583
+ let cxAcc = 0;
5584
+ let cyAcc = 0;
5585
+ const rowProfile = new Array(rows).fill(0);
5586
+ const colProfile = new Array(cols).fill(0);
5587
+ for (let r = 0; r < rows; r++) {
5588
+ for (let c = 0; c < cols; c++) {
5589
+ const v = cells[r * cols + c];
5590
+ sum += v;
5591
+ const dev = Math.abs(v - (bgCells ? bgCells[r * cols + c] : bgRow[r]));
5592
+ if (dev > 0.03) covered++;
5593
+ devSum += dev;
5594
+ cxAcc += dev * (c + 0.5);
5595
+ cyAcc += dev * (r + 0.5);
5596
+ rowProfile[r] += dev;
5597
+ colProfile[c] += dev;
5719
5598
  }
5720
5599
  }
5721
- const map = scene.transforms?.get(e);
5722
- return map ? map(p) : p;
5723
- }
5724
- function textBox(s, e, p) {
5725
- const m = s.font ? /(\d+(?:\.\d+)?)px/.exec(s.font) : null;
5726
- const fh = m ? Number(m[1]) : e.size;
5727
- const str = s.strings[e.spriteIndex] ?? s.strings[0] ?? "";
5728
- const fw = Math.max(fh, 0.62 * fh * Math.min(str.length, 48));
5729
- const align = s.align ?? "center";
5730
- const baseline = s.baseline ?? "middle";
5600
+ for (let r = 0; r < rows; r++) rowProfile[r] = rowProfile[r] / cols;
5601
+ for (let c = 0; c < cols; c++) colProfile[c] = colProfile[c] / rows;
5731
5602
  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
5603
+ cols,
5604
+ rows,
5605
+ cells,
5606
+ background: bgRow,
5607
+ ...bgCells ? { backgroundCells: bgCells } : {},
5608
+ meanLuminance: sum / (cols * rows),
5609
+ coverage: covered / (cols * rows),
5610
+ centroid: devSum > 1e-6 ? { x: cxAcc / devSum / cols, y: cyAcc / devSum / rows } : null,
5611
+ rowProfile,
5612
+ colProfile
5736
5613
  };
5737
5614
  }
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);
5750
- return {
5751
- cx: cx + dx,
5752
- cy: p.y + totalH / 2 + dy,
5753
- halfX: maxLineW / 2,
5754
- halfY: totalH / 2
5755
- };
5615
+ var DITHER = [
5616
+ [0.5 / 8, 4.5 / 8],
5617
+ [2.5 / 8, 6.5 / 8],
5618
+ [1.5 / 8, 5.5 / 8],
5619
+ [3.5 / 8, 7.5 / 8]
5620
+ ];
5621
+ var DOT_BIT = [
5622
+ [1, 8],
5623
+ [2, 16],
5624
+ [4, 32],
5625
+ [64, 128]
5626
+ ];
5627
+ function autoExpose(grid) {
5628
+ const sorted = [...grid.cells].sort((a, b) => a - b);
5629
+ const lo = sorted[0];
5630
+ const hi = Math.max(sorted[Math.floor(sorted.length * 0.98)], lo + 0.08);
5631
+ return (v) => Math.pow(Math.max(0, Math.min(1, (v - lo) / (hi - lo))), 0.8);
5756
5632
  }
5757
- function textBlockRevealFraction(s, w, h, t) {
5758
- if (!s.reveal) return 1;
5759
- const unitScale = Math.min(w, h);
5760
- const maxWEm = s.maxWidth * unitScale / (s.fontSize * unitScale);
5761
- const lines = breakTextBlock(s.text, maxWEm, textMetricsClassFor(s.font));
5762
- const rs = revealState(lines, s.reveal, t);
5763
- if (rs.glyphAlphas) {
5764
- let sum = 0;
5765
- let n = 0;
5766
- for (const line of rs.glyphAlphas) for (const a of line) {
5767
- sum += a;
5768
- n++;
5633
+ function renderBrailleMap(grid) {
5634
+ const expose = autoExpose(grid);
5635
+ const outRows = [];
5636
+ for (let br = 0; br < Math.floor(grid.rows / 4); br++) {
5637
+ let line = "";
5638
+ for (let bc = 0; bc < Math.floor(grid.cols / 2); bc++) {
5639
+ let bits = 0;
5640
+ for (let dr = 0; dr < 4; dr++) {
5641
+ for (let dc = 0; dc < 2; dc++) {
5642
+ const v = expose(grid.cells[(br * 4 + dr) * grid.cols + (bc * 2 + dc)]);
5643
+ if (v > DITHER[dr][dc]) bits |= DOT_BIT[dr][dc];
5644
+ }
5645
+ }
5646
+ line += String.fromCharCode(10240 + bits);
5769
5647
  }
5770
- return n > 0 ? sum / n : 1;
5648
+ outRows.push(line);
5771
5649
  }
5772
- return rs.progress;
5650
+ return outRows.join("\n");
5773
5651
  }
5774
- function polygonCellCoverage(pts, cellW, cellH, c0, c1, r0, r1) {
5775
- const nc = c1 - c0 + 1;
5776
- const cov = new Float64Array(nc * (r1 - r0 + 1));
5777
- const xMin = c0 * cellW;
5778
- const xMax = (c1 + 1) * cellW;
5779
- const share = 1 / POLYGON_SCANLINES;
5780
- const hits = [];
5781
- const addSpan = (row, xa, xb) => {
5782
- const a = Math.max(xMin, xa);
5783
- const b = Math.min(xMax, xb);
5784
- if (!(b > a)) return;
5785
- const first = Math.max(c0, Math.floor(a / cellW));
5786
- const last = Math.min(c1, Math.floor(b / cellW));
5787
- for (let c = first; c <= last; c++) {
5788
- const len = Math.min(b, (c + 1) * cellW) - Math.max(a, c * cellW);
5789
- if (len > 0) cov[row * nc + (c - c0)] += len / cellW * share;
5790
- }
5791
- };
5792
- for (let r = r0; r <= r1; r++) {
5793
- for (let k = 0; k < POLYGON_SCANLINES; k++) {
5794
- const y = (r + (k + 0.5) / POLYGON_SCANLINES) * cellH;
5795
- hits.length = 0;
5796
- for (let i = 0; i < pts.length; i++) {
5797
- const p = pts[i];
5798
- const q = pts[(i + 1) % pts.length];
5799
- const down = p.y <= y && q.y > y;
5800
- if (!down && !(q.y <= y && p.y > y)) continue;
5801
- hits.push({ x: p.x + (y - p.y) / (q.y - p.y) * (q.x - p.x), dir: down ? 1 : -1 });
5802
- }
5803
- if (hits.length < 2) continue;
5804
- hits.sort((m, n) => m.x - n.x);
5805
- let wind = 0;
5806
- let start = 0;
5807
- for (const hit of hits) {
5808
- const before = wind;
5809
- wind += hit.dir;
5810
- if (before === 0 && wind !== 0) start = hit.x;
5811
- else if (before !== 0 && wind === 0) addSpan(r - r0, start, hit.x);
5812
- }
5652
+ var DENSITY_RAMP = " .:-=+*#%@";
5653
+ function renderDensityMap(grid) {
5654
+ const expose = autoExpose(grid);
5655
+ const ramp = DENSITY_RAMP;
5656
+ const lines = [];
5657
+ for (let r = 0; r < grid.rows; r++) {
5658
+ let line = "";
5659
+ for (let c = 0; c < grid.cols; c++) {
5660
+ const v = expose(grid.cells[r * grid.cols + c]);
5661
+ line += ramp[Math.min(ramp.length - 1, Math.floor(v * ramp.length))];
5813
5662
  }
5663
+ lines.push(line);
5814
5664
  }
5815
- return cov;
5665
+ return lines.join("\n");
5816
5666
  }
5817
- function distanceToOutline(pts, x, y) {
5818
- let best = Infinity;
5819
- for (let i = 0; i < pts.length; i++) {
5820
- const p = pts[i];
5821
- const q = pts[(i + 1) % pts.length];
5822
- const ex = q.x - p.x;
5823
- const ey = q.y - p.y;
5824
- const len2 = ex * ex + ey * ey;
5825
- const u = len2 > 0 ? Math.max(0, Math.min(1, ((x - p.x) * ex + (y - p.y) * ey) / len2)) : 0;
5826
- const dx = x - (p.x + ex * u);
5827
- const dy = y - (p.y + ey * u);
5828
- best = Math.min(best, dx * dx + dy * dy);
5829
- }
5830
- return Math.sqrt(best);
5667
+ function textSprites(spec, opts = {}) {
5668
+ spec = resolveTimelineAt(spec, opts.t ?? 5e3);
5669
+ const scene = buildScene(spec, opts);
5670
+ const t = opts.t ?? 5e3;
5671
+ const out = [];
5672
+ scene.layers.forEach(({ layer, entities }, layerIndex) => {
5673
+ if (layer.sprite.kind === "textBlock") {
5674
+ const s2 = layer.sprite;
5675
+ const unitScale = Math.min(scene.w, scene.h);
5676
+ const sizePx2 = s2.fontSize * unitScale;
5677
+ const info = { layerIndex, key: layer.key, strings: [s2.text], count: entities.length, sizePx: Math.round(sizePx2) };
5678
+ if (s2.reveal) info.revealed = Math.round(textBlockRevealFraction(s2, scene.w, scene.h, t) * 100) / 100;
5679
+ out.push(info);
5680
+ return;
5681
+ }
5682
+ if (layer.sprite.kind !== "text") return;
5683
+ const s = layer.sprite;
5684
+ const m = s.font ? /(\d+(?:\.\d+)?)px/.exec(s.font) : null;
5685
+ const sizePx = m ? Number(m[1]) : entities[0] ? sizeAt(entities[0], t) : 0;
5686
+ out.push({ layerIndex, key: layer.key, strings: [...s.strings], count: entities.length, sizePx: Math.round(sizePx) });
5687
+ });
5688
+ return out;
5831
5689
  }
5832
- function rotatedBoxCoverage(cx, cy, hx, hy, rot, x0, y0, x1, y1) {
5833
- const cr = Math.cos(rot), sr = Math.sin(rot);
5834
- let poly = [[-hx, -hy], [hx, -hy], [hx, hy], [-hx, hy]].map(([x, y]) => [cx + x * cr - y * sr, cy + x * sr + y * cr]);
5835
- const clip = (inside, cut) => {
5836
- const out = [];
5837
- for (let i = 0; i < poly.length; i++) {
5838
- const a = poly[i], b = poly[(i + 1) % poly.length];
5839
- if (inside(a)) {
5840
- out.push(a);
5841
- if (!inside(b)) out.push(cut(a, b));
5842
- } else if (inside(b)) out.push(cut(a, b));
5843
- }
5844
- poly = out;
5845
- };
5846
- const atX = (x) => (a, b) => [x, a[1] + (x - a[0]) / (b[0] - a[0]) * (b[1] - a[1])];
5847
- const atY = (y) => (a, b) => [a[0] + (y - a[1]) / (b[1] - a[1]) * (b[0] - a[0]), y];
5848
- clip((p) => p[0] >= x0, atX(x0));
5849
- if (!poly.length) return 0;
5850
- clip((p) => p[0] <= x1, atX(x1));
5851
- if (!poly.length) return 0;
5852
- clip((p) => p[1] >= y0, atY(y0));
5853
- if (!poly.length) return 0;
5854
- clip((p) => p[1] <= y1, atY(y1));
5855
- if (!poly.length) return 0;
5856
- let area = 0;
5857
- for (let i = 0; i < poly.length; i++) {
5858
- const a = poly[i], b = poly[(i + 1) % poly.length];
5859
- area += a[0] * b[1] - b[0] * a[1];
5860
- }
5861
- return Math.abs(area) / 2 / ((x1 - x0) * (y1 - y0));
5690
+ function dominanceRanking(spec, opts = {}) {
5691
+ return rankDominance(rawDominance(spec, opts));
5862
5692
  }
5863
- function luminanceGrid(spec, opts = {}) {
5693
+ function rankDominance(raw) {
5694
+ const total = raw.reduce((s, r) => s + r.weight, 0) || 1;
5695
+ return raw.slice().sort((a, b) => b.weight - a.weight).map((r, i) => ({
5696
+ rank: i + 1,
5697
+ layerIndex: r.layerIndex,
5698
+ key: r.key,
5699
+ share: r.weight / total,
5700
+ entityCount: r.entityCount,
5701
+ meanLuma: r.meanLuma,
5702
+ factors: r.factors
5703
+ }));
5704
+ }
5705
+ function rawDominance(spec, opts = {}) {
5864
5706
  spec = resolveTimelineAt(spec, opts.t ?? 5e3);
5865
5707
  const scene = buildScene(spec, opts);
5866
5708
  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
5709
  const { w, h, scale } = scene;
5870
- const cellW = w / cols;
5871
- const cellH = h / rows;
5872
- const bg = spec.background;
5873
- const bgRow = new Array(rows).fill(0);
5874
- let bgCells;
5875
- if (!bg || bg.type === "solid") {
5876
- bgRow.fill(hexLuma(bg?.color ?? "#05050a"));
5877
- } else if (bg.type === "field") {
5878
- bgCells = new Array(cols * rows);
5879
- const short = Math.max(1, Math.min(w, h));
5880
- const seed = scene.seed;
5881
- const ft = fieldSampleTime(bg, t);
5882
- for (let r = 0; r < rows; r++) {
5883
- let rowSum = 0;
5884
- for (let c = 0; c < cols; c++) {
5885
- const lum = rgb255Luma(fieldRgbAt((c + 0.5) * cellW / short, (r + 0.5) * cellH / short, ft, bg, seed));
5886
- bgCells[r * cols + c] = lum;
5887
- rowSum += lum;
5888
- }
5889
- bgRow[r] = rowSum / cols;
5890
- }
5891
- } else {
5892
- const stops = [...bg.stops].sort((a, b) => a.at - b.at);
5893
- for (let r = 0; r < rows; r++) {
5894
- const y = (r + 0.5) / rows;
5895
- let lum;
5896
- if (y <= stops[0].at) lum = hexLuma(stops[0].color);
5897
- else if (y >= stops[stops.length - 1].at) lum = hexLuma(stops[stops.length - 1].color);
5898
- else {
5899
- let i = 0;
5900
- while (i < stops.length - 1 && stops[i + 1].at < y) i++;
5901
- const a = stops[i];
5902
- const b = stops[i + 1];
5903
- const k = b.at === a.at ? 0 : (y - a.at) / (b.at - a.at);
5904
- lum = hexLuma(a.color) + (hexLuma(b.color) - hexLuma(a.color)) * k;
5905
- }
5906
- bgRow[r] = lum;
5907
- }
5908
- if (bg.band) {
5909
- const bandPx = bg.band.height * (spec.units === "px" ? 1 : Math.min(w, h));
5910
- const fromRow = Math.max(0, Math.floor((h - bandPx) / cellH));
5911
- for (let r = fromRow; r < rows; r++) bgRow[r] = hexLuma(bg.band.color);
5912
- }
5710
+ const bgMean = backgroundLuma(spec);
5711
+ const ghost = spec.ghosting ?? 0;
5712
+ let ghostWindowMs = 0;
5713
+ let ghostMeanW = 0;
5714
+ if (ghost > 0) {
5715
+ const dt = 1e3 / 60;
5716
+ const frames = Math.min(Math.ceil(Math.log(1 / 255) / Math.log(ghost)), LIMITS.maxGhostReplayFrames);
5717
+ ghostWindowMs = frames * dt;
5718
+ ghostMeanW = ghost * (1 - Math.pow(ghost, frames)) / (1 - ghost) / frames;
5913
5719
  }
5914
- const cells = new Array(cols * rows);
5915
- for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) cells[r * cols + c] = bgCells ? bgCells[r * cols + c] : bgRow[r];
5916
- const compose = (idx, lum, a, blend) => {
5917
- const cur = cells[idx];
5918
- if (blend === "lighter" || blend === "screen") cells[idx] = Math.min(1, cur + lum * a);
5919
- else if (blend === "multiply") cells[idx] = cur * (1 - a * (1 - lum));
5920
- else cells[idx] = cur * (1 - a) + lum * a;
5720
+ const sweptDistance = (e, spanMs) => {
5721
+ const span = Math.min(spanMs, t);
5722
+ if (span <= 0) return 0;
5723
+ const p0 = posOf(scene, e, t - span);
5724
+ const p1 = posOf(scene, e, t);
5725
+ const dx = p1.x - p0.x;
5726
+ const dy = p1.y - p0.y;
5727
+ if (Math.abs(dx) > w / 2 || Math.abs(dy) > h / 2) return 0;
5728
+ return Math.sqrt(dx * dx + dy * dy);
5921
5729
  };
5922
- const splatTrail = (layer, e, tPass, lifeA, alphaScale) => {
5923
- const trail = layer.trail;
5924
- if (!trail) return;
5925
- const fade = trail.fade ?? 1;
5926
- const n = Math.min(Math.ceil(trail.length / 50), LIMITS.maxTrailSamples);
5927
- const headAlpha = alphaAt(e, tPass) * lifeA;
5928
- const headSize = sizeAt(e, tPass);
5929
- const lum = spriteLuma(layer, e);
5930
- const soft = layer.sprite.kind === "circle" && !!layer.sprite.soft;
5931
- const wrapOn = layer.wrap !== false;
5932
- const head = posOf(scene, e, tPass);
5933
- let prevX = head.x;
5934
- let prevY = head.y;
5935
- for (let s = 1; s <= n; s++) {
5936
- const k = s / n;
5937
- const pastT = tPass - k * trail.length;
5938
- if (pastT < 0) break;
5939
- const pos = posOf(scene, e, pastT);
5940
- if (wrapOn && (Math.abs(pos.x - prevX) > w / 2 || Math.abs(pos.y - prevY) > h / 2)) break;
5941
- prevX = pos.x;
5942
- prevY = pos.y;
5943
- const a = headAlpha * (1 - k * fade) * alphaScale;
5944
- if (a <= 4e-3) break;
5945
- const rad = headSize / 2 * (1 - k * 0.7);
5946
- if (rad < 0.2) break;
5947
- const c0 = Math.max(0, Math.floor((pos.x - rad) / cellW));
5948
- const c1 = Math.min(cols - 1, Math.floor((pos.x + rad) / cellW));
5949
- const r0 = Math.max(0, Math.floor((pos.y - rad) / cellH));
5950
- const r1 = Math.min(rows - 1, Math.floor((pos.y + rad) / cellH));
5951
- for (let r = r0; r <= r1; r++) {
5952
- for (let c = c0; c <= c1; c++) {
5953
- const dx = (c + 0.5) * cellW - pos.x;
5954
- const dy = (r + 0.5) * cellH - pos.y;
5955
- const d = Math.sqrt(dx * dx + dy * dy);
5956
- if (d > rad + Math.min(cellW, cellH) / 2) continue;
5957
- const wgt = soft ? Math.max(0.1, 1 - d / Math.max(rad, 1e-6)) : 1;
5958
- compose(r * cols + c, lum, Math.min(1, a * wgt), layer.blend);
5959
- }
5730
+ const raw = scene.layers.map(({ layer, entities }, layerIndex) => {
5731
+ const lifeA = layer.opacity === void 0 && layer.group === void 0 ? lifeAlphaAt(layer.life, t) : lifeAlphaAt(layer.life, t) * paintOpacity(spec, layer);
5732
+ const painted = layer.transform !== void 0 || layer.group !== void 0;
5733
+ const areaScale = painted ? paintSize(spec, layer) ** 2 : 1;
5734
+ let area = 0;
5735
+ let lumAcc = 0;
5736
+ for (const e of entities) {
5737
+ const a = alphaAt(e, t) * lifeA;
5738
+ const sz = sizeAt(e, t);
5739
+ const s = layer.sprite;
5740
+ let entArea;
5741
+ if (s.kind === "circle") entArea = Math.PI * (sz / 2) * (sz / 2);
5742
+ else if (s.kind === "ring") entArea = Math.PI * sz * ((s.width ?? (scale === 1 ? 2 : 2e-3)) * scale) * LINE_SALIENCE;
5743
+ else if (s.kind === "streak") entArea = sz * ((s.width ?? (scale === 1 ? 2 : 2e-3)) * scale) * LINE_SALIENCE;
5744
+ else if (s.kind === "rect") {
5745
+ const h2 = e.size2 !== void 0 ? e.size2 * (e.size > 0 ? sz / e.size : 1) : sz;
5746
+ const f = s.feather ? (1 - s.feather / 2) * (1 - s.feather / 2) : 1;
5747
+ entArea = sz * h2 * f;
5748
+ } else if (s.kind === "polygon") entArea = polygonArea(polygonPoints(s, sz / 2));
5749
+ else if (s.kind === "bar") {
5750
+ const th = e.size2 !== void 0 ? e.size2 * (e.size > 0 ? sz / e.size : 1) : sz * 0.2;
5751
+ entArea = sz * barFraction(s, e.barIndex ?? 0) * th;
5752
+ } else if (s.kind === "stroke") {
5753
+ const meanTaper = s.taper ? 0.64 : 1;
5754
+ entArea = pathLength(strokeSamples(s, sz / 2)) * strokeWidthPx(s, scale) * meanTaper * LINE_SALIENCE;
5755
+ } else if (s.kind === "text") {
5756
+ const box = textBox(s, e, { x: 0, y: 0 });
5757
+ entArea = box.halfX * 2 * box.halfY * 2 * 0.55;
5758
+ } else if (s.kind === "textBlock") {
5759
+ const box = textBlockBox(s, e, { x: 0, y: 0 }, w, h);
5760
+ entArea = box.halfX * 2 * box.halfY * 2 * 0.55 * textBlockRevealFraction(s, w, h, t) * (s.opacity ?? 1);
5761
+ } else entArea = sz * sz * 0.55;
5762
+ area += painted ? entArea * areaScale * a : entArea * a;
5763
+ if (layer.trail) {
5764
+ const fade = layer.trail.fade ?? 1;
5765
+ const span = sweptDistance(e, layer.trail.length);
5766
+ area += span * sz * 0.65 * Math.max(0, 1 - fade / 2) * a;
5960
5767
  }
5768
+ if (ghost > 0) {
5769
+ area += sweptDistance(e, ghostWindowMs) * sz * ghostMeanW * a;
5770
+ }
5771
+ lumAcc += spriteLuma(layer, e);
5961
5772
  }
5962
- };
5963
- const splatPolygonOutline = (layer, s, e, p, sz, a, lum, tPass) => {
5964
- const radius = sz / 2;
5965
- const rot = rotationAt(e, tPass);
5966
- const cr = Math.cos(rot);
5967
- const sr = Math.sin(rot);
5968
- const pts = polygonPoints(s, radius).map((q) => ({ x: p.x + q.x * cr - q.y * sr, y: p.y + q.x * sr + q.y * cr }));
5969
- const additive = layer.blend === "lighter" || layer.blend === "screen";
5970
- const glow = !!s.soft && additive;
5971
- let band = 0;
5972
- if (glow) {
5973
- const perimeter = pts.reduce((acc, q, i) => {
5974
- const n = pts[(i + 1) % pts.length];
5975
- return acc + Math.hypot(n.x - q.x, n.y - q.y);
5976
- }, 0);
5977
- band = perimeter > 0 ? (GLOW_SPREAD - 1) * (2 * polygonArea(pts) / perimeter) : 0;
5978
- }
5979
- let minX = Infinity;
5980
- let maxX = -Infinity;
5981
- let minY = Infinity;
5982
- let maxY = -Infinity;
5983
- for (const q of pts) {
5984
- minX = Math.min(minX, q.x);
5985
- maxX = Math.max(maxX, q.x);
5986
- minY = Math.min(minY, q.y);
5987
- maxY = Math.max(maxY, q.y);
5773
+ if (layer.links && entities.length > 1) {
5774
+ const positions = entities.map((e) => posOf(scene, e, t));
5775
+ const motionWraps = ["drift", "rise", "wander"].includes(layer.motion.type);
5776
+ const edges = linkEdges(layer.links, positions, layer.links.maxDist * scale, layer.wrap !== false && motionWraps, w, h);
5777
+ const la = (layer.links.alpha ?? 0.6) * lifeA;
5778
+ const defaultWidth = scale === 1 ? 1 : 1 / LIMITS.referenceViewport;
5779
+ const lw = (layer.links.width ?? defaultWidth) * scale;
5780
+ for (const edge of edges) area += edge.dist * lw * la * LINE_SALIENCE;
5988
5781
  }
5989
- const c0 = Math.max(0, Math.floor((minX - band) / cellW));
5990
- const c1 = Math.min(cols - 1, Math.floor((maxX + band) / cellW));
5991
- const r0 = Math.max(0, Math.floor((minY - band) / cellH));
5992
- const r1 = Math.min(rows - 1, Math.floor((maxY + band) / cellH));
5993
- if (c0 > c1 || r0 > r1) return;
5994
- const cov = polygonCellCoverage(pts, cellW, cellH, c0, c1, r0, r1);
5995
- const nc = c1 - c0 + 1;
5996
- for (let r = r0; r <= r1; r++) {
5997
- for (let c = c0; c <= c1; c++) {
5998
- const f = cov[(r - r0) * nc + (c - c0)];
5999
- const x = (c + 0.5) * cellW;
6000
- const y = (r + 0.5) * cellH;
6001
- let wgt;
6002
- if (glow) {
6003
- const outX = Math.max(0, minX - x, x - maxX);
6004
- const outY = Math.max(0, minY - y, y - maxY);
6005
- if (f >= 1) wgt = 1;
6006
- else if (outX * outX + outY * outY >= band * band) wgt = f;
6007
- else {
6008
- const k = band > 0 ? Math.max(0, 1 - distanceToOutline(pts, x, y) / band) : 0;
6009
- wgt = f + (1 - f) * k * k;
6010
- }
6011
- } else if (s.soft) {
6012
- wgt = f * Math.max(0.1, 1 - Math.hypot(x - p.x, y - p.y) / Math.max(radius, 1e-6));
6013
- } else {
6014
- wgt = f;
6015
- }
6016
- if (wgt > 0) compose(r * cols + c, lum, a * Math.min(1, wgt), layer.blend);
5782
+ const meanLuma = entities.length ? lumAcc / entities.length : 0;
5783
+ const contrast = Math.abs(meanLuma - bgMean) + 0.1;
5784
+ const blendBoost = layer.blend === "lighter" || layer.blend === "screen" ? 1.3 : layer.blend === "multiply" ? 1.1 : 1;
5785
+ const motionBoost = layer.motion.type === "static" ? 1 : 1.2;
5786
+ const weight = area / (w * h) * contrast * blendBoost * motionBoost;
5787
+ return { layerIndex, key: layer.key, weight, entityCount: entities.length, meanLuma, factors: { area: area / (w * h), contrast, blendBoost, motionBoost } };
5788
+ });
5789
+ return raw;
5790
+ }
5791
+ function layerCohesion(spec, opts = {}) {
5792
+ spec = resolveTimelineAt(spec, opts.t ?? 5e3);
5793
+ const scene = buildScene(spec, opts);
5794
+ return cohesionOf(scene.layers, opts.t ?? 5e3, scene.w, scene.h);
5795
+ }
5796
+ var MOVING_FLOOR = 5e-4;
5797
+ function motionStats(spec, opts = {}) {
5798
+ const t = opts.t ?? 5e3;
5799
+ const dt = opts.dt ?? 500;
5800
+ const scene0 = buildScene(resolveTimelineAt(spec, t), opts);
5801
+ const scene1 = spec.timeline ? buildScene(resolveTimelineAt(spec, t + dt), opts) : scene0;
5802
+ const { w, h } = scene0;
5803
+ const unit = Math.min(w, h);
5804
+ return scene0.layers.map(({ layer, entities }, layerIndex) => {
5805
+ const entities1 = scene1.layers[layerIndex]?.entities ?? entities;
5806
+ let acc = 0;
5807
+ let max = 0;
5808
+ let n = 0;
5809
+ let ux = 0;
5810
+ let uy = 0;
5811
+ let moversN = 0;
5812
+ entities.forEach((e, i) => {
5813
+ const p0 = posOf(scene0, e, t);
5814
+ const p1 = posOf(scene1, entities1[i] ?? e, t + dt);
5815
+ const dx = p1.x - p0.x;
5816
+ const dy = p1.y - p0.y;
5817
+ if (Math.abs(dx) > w / 2 || Math.abs(dy) > h / 2) return;
5818
+ const dist = Math.sqrt(dx * dx + dy * dy);
5819
+ const speed = dist / dt * 1e3;
5820
+ acc += speed;
5821
+ max = Math.max(max, speed);
5822
+ n++;
5823
+ if (dist > 0) {
5824
+ ux += dx / dist;
5825
+ uy += dy / dist;
5826
+ moversN++;
6017
5827
  }
5828
+ });
5829
+ const meanSpeed = n ? acc / n : 0;
5830
+ const meanSpeedVp = meanSpeed / unit;
5831
+ const maxSpeedVp = max / unit;
5832
+ const moving = meanSpeedVp > MOVING_FLOOR;
5833
+ let heading = null;
5834
+ let headingSpread = null;
5835
+ if (moving && moversN > 0) {
5836
+ const mx = ux / moversN;
5837
+ const my = uy / moversN;
5838
+ const mag = Math.sqrt(mx * mx + my * my);
5839
+ headingSpread = 1 - mag;
5840
+ heading = mag > 0.05 ? (Math.atan2(my, mx) * 180 / Math.PI + 360) % 360 : null;
5841
+ }
5842
+ return { layerIndex, key: layer.key, meanSpeed, maxSpeed: max, meanSpeedVp, maxSpeedVp, moving, heading, headingSpread };
5843
+ });
5844
+ }
5845
+ function rgbToHsl(c) {
5846
+ const max = Math.max(c.r, c.g, c.b);
5847
+ const min = Math.min(c.r, c.g, c.b);
5848
+ const l = (max + min) / 2;
5849
+ const d = max - min;
5850
+ if (d < 1e-6) return { h: null, s: 0, l };
5851
+ const sat = l > 0.5 ? d / (2 - max - min) : d / (max + min);
5852
+ let h;
5853
+ if (max === c.r) h = ((c.g - c.b) / d + (c.g < c.b ? 6 : 0)) * 60;
5854
+ else if (max === c.g) h = ((c.b - c.r) / d + 2) * 60;
5855
+ else h = ((c.r - c.g) / d + 4) * 60;
5856
+ return { h: sat < 0.05 ? null : h, s: sat, l };
5857
+ }
5858
+ function toHex(c) {
5859
+ const q = (v) => Math.round(Math.max(0, Math.min(1, v)) * 255).toString(16).padStart(2, "0");
5860
+ return `#${q(c.r)}${q(c.g)}${q(c.b)}`;
5861
+ }
5862
+ function describeColour(c) {
5863
+ const hsl = rgbToHsl(c);
5864
+ 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) };
5865
+ }
5866
+ function sceneColour(spec, opts = {}) {
5867
+ const t = opts.t ?? 5e3;
5868
+ spec = resolveTimelineAt(spec, t);
5869
+ const scene = buildScene(spec, opts);
5870
+ let wr = 0;
5871
+ let wg = 0;
5872
+ let wb = 0;
5873
+ let wsum = 0;
5874
+ for (const { layer, entities } of scene.layers) {
5875
+ const lifeA = lifeAlphaAt(layer.life, t) * (layer.opacity === void 0 && layer.group === void 0 ? 1 : paintOpacity(spec, layer));
5876
+ for (const e of entities) {
5877
+ const hex = spriteHex(layer, e);
5878
+ if (hex === null) continue;
5879
+ const sz = sizeAt(e, t);
5880
+ const weight = alphaAt(e, t) * lifeA * (layer.sprite.kind === "circle" ? Math.PI * (sz / 2) * (sz / 2) : sz * sz);
5881
+ if (weight <= 0) continue;
5882
+ const c = hexRgb(hex);
5883
+ wr += c.r * weight;
5884
+ wg += c.g * weight;
5885
+ wb += c.b * weight;
5886
+ wsum += weight;
6018
5887
  }
6019
- };
6020
- const splatPass = (tPass, alphaScale) => {
6021
- for (const { layer, entities } of scene.layers) {
6022
- const lifeA = layer.opacity === void 0 && layer.group === void 0 ? lifeAlphaAt(layer.life, tPass) : lifeAlphaAt(layer.life, tPass) * paintOpacity(spec, layer);
6023
- if (lifeA <= 0) continue;
6024
- for (const e of entities) {
6025
- splatTrail(layer, e, tPass, lifeA, alphaScale);
6026
- const a = Math.min(1, alphaAt(e, tPass) * lifeA) * alphaScale;
6027
- if (a <= 4e-3) continue;
6028
- const lum = spriteLuma(layer, e);
6029
- const p = posOf(scene, e, tPass);
6030
- const sz = layer.transform || layer.group !== void 0 ? sizeAt(e, tPass) * paintSize(spec, layer) : sizeAt(e, tPass);
6031
- const s = layer.sprite;
6032
- if (s.kind === "polygon" && sz >= POLYGON_OUTLINE_CELLS * Math.min(cellW, cellH)) {
6033
- splatPolygonOutline(layer, s, e, p, sz, a, lum, tPass);
6034
- continue;
6035
- }
6036
- if (s.kind === "stroke") {
6037
- const pts = strokeSamples(s, sz / 2);
6038
- let angle = rotationAt(e, tPass);
6039
- if (s.orient) angle += headingAt(e, tPass, w, h) ?? 0;
6040
- const ca = Math.cos(angle);
6041
- const sa = Math.sin(angle);
6042
- const lw = strokeWidthPx(s, scale);
6043
- const stampAt = (q, u) => {
6044
- const x = p.x + q.x * ca - q.y * sa;
6045
- const y = p.y + q.x * sa + q.y * ca;
6046
- const c = Math.floor(x / cellW);
6047
- const r = Math.floor(y / cellH);
6048
- if (c < 0 || c >= cols || r < 0 || r >= rows) return;
6049
- const taper = s.taper ? strokeTaper(u) : 1;
6050
- compose(r * cols + c, lum, a * Math.min(1, lw * taper / cellH), layer.blend);
6051
- };
6052
- const step = Math.min(cellW, cellH) / 2;
6053
- stampAt(pts[0], 0);
6054
- for (let i = 1; i < pts.length; i++) {
6055
- const q0 = pts[i - 1];
6056
- const q1 = pts[i];
6057
- const segLen = Math.hypot(q1.x - q0.x, q1.y - q0.y);
6058
- const sub = Math.max(1, Math.ceil(segLen / step));
6059
- for (let k = 1; k <= sub; k++) {
6060
- const f = k / sub;
6061
- stampAt({ x: q0.x + (q1.x - q0.x) * f, y: q0.y + (q1.y - q0.y) * f }, (i - 1 + f) / (pts.length - 1));
6062
- }
6063
- }
6064
- continue;
6065
- }
6066
- if (s.kind === "streak") {
6067
- const heading = (headingAt(e, tPass, w, h) ?? 0) + rotationAt(e, tPass);
6068
- const steps = Math.max(2, Math.ceil(sz / Math.min(cellW, cellH)));
6069
- const wgt = Math.min(1, (s.width ?? (scale === 1 ? 2 : 2e-3)) * scale / cellH);
6070
- for (let i = 0; i <= steps; i++) {
6071
- const k = i / steps;
6072
- const x = p.x - Math.cos(heading) * sz * (1 - k);
6073
- const y = p.y - Math.sin(heading) * sz * (1 - k);
6074
- const c = Math.floor(x / cellW);
6075
- const r = Math.floor(y / cellH);
6076
- if (c < 0 || c >= cols || r < 0 || r >= rows) continue;
6077
- compose(r * cols + c, lum, a * k * wgt, layer.blend);
6078
- }
6079
- continue;
6080
- }
6081
- let centerX = p.x;
6082
- let centerY = p.y;
6083
- let halfX = sz / 2;
6084
- let halfY = s.kind === "rect" ? (e.size2 ?? sz) * (e.size > 0 ? sz / e.size : 1) / 2 : sz / 2;
6085
- let boxRot = 0, boxHx = 0, boxHy = 0;
6086
- if (s.kind === "text") {
6087
- const box = textBox(s, e, p);
6088
- centerX = box.cx;
6089
- centerY = box.cy;
6090
- halfX = box.halfX;
6091
- halfY = box.halfY;
6092
- } else if (s.kind === "textBlock") {
6093
- const box = textBlockBox(s, e, p, w, h);
6094
- centerX = box.cx;
6095
- centerY = box.cy;
6096
- halfX = box.halfX;
6097
- halfY = box.halfY;
6098
- } else if (s.kind === "bar") {
6099
- const len = sz * barFraction(s, e.barIndex ?? 0);
6100
- if (len <= 0) continue;
6101
- const th = e.size2 !== void 0 ? e.size2 * (e.size > 0 ? sz / e.size : 1) : sz * 0.2;
6102
- const box = barBox(s.direction ?? "right", len, th);
6103
- const rot = rotationAt(e, tPass);
6104
- const cr = Math.cos(rot);
6105
- const sr = Math.sin(rot);
6106
- centerX = p.x + box.cx * cr - box.cy * sr;
6107
- centerY = p.y + box.cx * sr + box.cy * cr;
6108
- boxRot = rot;
6109
- boxHx = box.halfX;
6110
- boxHy = box.halfY;
6111
- halfX = Math.abs(box.halfX * cr) + Math.abs(box.halfY * sr);
6112
- halfY = Math.abs(box.halfX * sr) + Math.abs(box.halfY * cr);
6113
- } else if (s.kind === "rect") {
6114
- const rot = rotationAt(e, tPass);
6115
- if (rot) {
6116
- boxRot = rot;
6117
- boxHx = halfX;
6118
- boxHy = halfY;
6119
- const cr = Math.abs(Math.cos(rot));
6120
- const sr = Math.abs(Math.sin(rot));
6121
- const hx = halfX * cr + halfY * sr;
6122
- halfY = halfX * sr + halfY * cr;
6123
- halfX = hx;
6124
- }
6125
- }
6126
- const circular = s.kind === "circle" || s.kind === "ring" || s.kind === "polygon";
6127
- const soft = (s.kind === "circle" || s.kind === "polygon") && !!s.soft;
6128
- const shapeWeight = s.kind === "polygon" ? polygonFill(s, sz / 2) : s.kind === "rect" && s.feather ? (1 - s.feather / 2) * (1 - s.feather / 2) : 1;
5888
+ }
5889
+ const bg = backgroundRgb(spec);
5890
+ const sprites = wsum > 0 ? { r: wr / wsum, g: wg / wsum, b: wb / wsum } : bg;
5891
+ return { ...describeColour(sprites), background: describeColour(bg) };
5892
+ }
5893
+ function hueShift(a, b) {
5894
+ if (a === null || b === null) return null;
5895
+ let d = b - a;
5896
+ while (d > 180) d -= 360;
5897
+ while (d < -180) d += 360;
5898
+ return Math.round(d);
5899
+ }
5900
+ var REGION_NAMES = [
5901
+ "top-left",
5902
+ "top-center",
5903
+ "top-right",
5904
+ "mid-left",
5905
+ "center",
5906
+ "mid-right",
5907
+ "bottom-left",
5908
+ "bottom-center",
5909
+ "bottom-right"
5910
+ ];
5911
+ function regionMeans(grid) {
5912
+ const out = new Array(9).fill(0);
5913
+ const cnt = new Array(9).fill(0);
5914
+ for (let r = 0; r < grid.rows; r++) {
5915
+ for (let c = 0; c < grid.cols; c++) {
5916
+ const idx = r * grid.cols + c;
5917
+ const region = Math.min(2, Math.floor(r / grid.rows * 3)) * 3 + Math.min(2, Math.floor(c / grid.cols * 3));
5918
+ out[region] += Math.abs(grid.cells[idx] - (grid.backgroundCells ? grid.backgroundCells[idx] : grid.background[r]));
5919
+ cnt[region]++;
5920
+ }
5921
+ }
5922
+ return out.map((v, i) => cnt[i] ? v / cnt[i] : 0);
5923
+ }
5924
+ function diffScenes(a, b, opts = {}) {
5925
+ const t = opts.t ?? 5e3;
5926
+ const gridA = luminanceGrid(a, opts);
5927
+ const gridB = luminanceGrid(b, opts);
5928
+ const regsA = regionMeans(gridA);
5929
+ const regsB = regionMeans(gridB);
5930
+ const domA = dominanceRanking(a, opts);
5931
+ const domB = dominanceRanking(b, opts);
5932
+ const nameOf = (d) => d.key ?? `layer-${d.layerIndex}`;
5933
+ const names = /* @__PURE__ */ new Set([...domA.map(nameOf), ...domB.map(nameOf)]);
5934
+ const dominance = [...names].map((key) => {
5935
+ const inA = domA.find((d) => nameOf(d) === key);
5936
+ const inB = domB.find((d) => nameOf(d) === key);
5937
+ return {
5938
+ key,
5939
+ rankA: inA?.rank ?? null,
5940
+ rankB: inB?.rank ?? null,
5941
+ shareA: inA?.share ?? 0,
5942
+ shareB: inB?.share ?? 0
5943
+ };
5944
+ }).sort((x, y) => Math.abs(y.shareB - y.shareA) - Math.abs(x.shareB - x.shareA));
5945
+ const vp = opts.viewport ?? { width: 1920, height: 1080 };
5946
+ const codesA = new Set(adviseSpec(a, vp).map((w) => w.code));
5947
+ const codesB = new Set(adviseSpec(b, vp).map((w) => w.code));
5948
+ const colA = sceneColour(a, opts);
5949
+ const colB = sceneColour(b, opts);
5950
+ const motA = motionStats(a, opts);
5951
+ const motB = motionStats(b, opts);
5952
+ const weighted = (stats, dom) => {
5953
+ let acc = 0;
5954
+ let n = 0;
5955
+ for (const m of stats) {
5956
+ const c = dom.find((d) => d.layerIndex === m.layerIndex)?.entityCount ?? 1;
5957
+ acc += m.meanSpeedVp * c;
5958
+ n += c;
5959
+ }
5960
+ return n ? acc / n : 0;
5961
+ };
5962
+ const motNameOf = (m) => m.key ?? `layer-${m.layerIndex}`;
5963
+ const motionLayers = [.../* @__PURE__ */ new Set([...motA.map(motNameOf), ...motB.map(motNameOf)])].map((key) => {
5964
+ const ma = motA.find((m) => motNameOf(m) === key);
5965
+ const mb = motB.find((m) => motNameOf(m) === key);
5966
+ const va = ma?.meanSpeedVp ?? 0;
5967
+ const vb = mb?.meanSpeedVp ?? 0;
5968
+ return { key, a: va, b: vb, delta: vb - va, headingA: ma?.heading ?? null, headingB: mb?.heading ?? null };
5969
+ }).filter((m) => Math.abs(m.delta) > 1e-9 || m.headingA !== m.headingB).sort((x, y) => Math.abs(y.delta) - Math.abs(x.delta));
5970
+ const overallA = weighted(motA, domA);
5971
+ const overallB = weighted(motB, domB);
5972
+ return {
5973
+ t,
5974
+ coverage: { a: gridA.coverage, b: gridB.coverage, delta: gridB.coverage - gridA.coverage },
5975
+ meanLuminance: { a: gridA.meanLuminance, b: gridB.meanLuminance, delta: gridB.meanLuminance - gridA.meanLuminance },
5976
+ balanceShift: gridA.centroid && gridB.centroid ? { dx: gridB.centroid.x - gridA.centroid.x, dy: gridB.centroid.y - gridA.centroid.y } : null,
5977
+ regions: REGION_NAMES.map((region, i) => ({ region, a: regsA[i], b: regsB[i], delta: regsB[i] - regsA[i] })),
5978
+ dominance,
5979
+ colour: {
5980
+ a: colA,
5981
+ b: colB,
5982
+ hueShift: hueShift(colA.hue, colB.hue),
5983
+ saturationDelta: +(colB.saturation - colA.saturation).toFixed(3),
5984
+ warmthDelta: +(colB.warmth - colA.warmth).toFixed(3),
5985
+ background: {
5986
+ hueShift: hueShift(colA.background.hue, colB.background.hue),
5987
+ warmthDelta: +(colB.background.warmth - colA.background.warmth).toFixed(3)
5988
+ }
5989
+ },
5990
+ motion: {
5991
+ overall: { a: overallA, b: overallB, delta: overallB - overallA },
5992
+ layers: motionLayers
5993
+ },
5994
+ advisories: {
5995
+ added: [...codesB].filter((c) => !codesA.has(c)),
5996
+ removed: [...codesA].filter((c) => !codesB.has(c))
5997
+ }
5998
+ };
5999
+ }
6000
+ function perceiveScene(spec, opts = {}) {
6001
+ const timed = spec;
6002
+ spec = resolveTimelineAt(spec, opts.t ?? 5e3);
6003
+ const grid = luminanceGrid(spec, opts);
6004
+ return {
6005
+ t: opts.t ?? 5e3,
6006
+ braille: renderBrailleMap(grid),
6007
+ density: renderDensityMap(grid),
6008
+ coverage: grid.coverage,
6009
+ meanLuminance: grid.meanLuminance,
6010
+ centroid: grid.centroid,
6011
+ rowProfile: grid.rowProfile,
6012
+ colProfile: grid.colProfile,
6013
+ dominance: dominanceRanking(spec, opts),
6014
+ motion: motionStats(timed, opts),
6015
+ form: layerCohesion(spec, opts),
6016
+ colour: sceneColour(spec, opts),
6017
+ text: textSprites(spec, opts),
6018
+ advisories: adviseSpec(spec, opts.viewport ?? { width: 1920, height: 1080 }, { t: opts.t, seed: opts.seed })
6019
+ };
6020
+ }
6021
+ var bedKey = (key, layerIndex) => `bed:${key ?? layerIndex}`;
6022
+ function perceiveSequenceFrame(seq, T, opts = {}) {
6023
+ const { releasedBelow, ...gridOpts } = opts;
6024
+ const r = resolveSegment(seq, T, { releasedBelow });
6025
+ const seg = seq.segments[r.index];
6026
+ const segment = { index: r.index, key: seg.key, localT: r.localT, ...r.held ? { held: true } : {} };
6027
+ const segOpts = { ...gridOpts, t: r.localT, seed: gridOpts.seed ?? segmentRenderSeed(seq, r.index) };
6028
+ if (!seq.bed) return { ...perceiveScene(seg.scene, segOpts), segment, bed: false };
6029
+ const bedOpts = { ...gridOpts, t: T, seed: gridOpts.seed ?? bedRenderSeed(seq) };
6030
+ const viewport = gridOpts.viewport ?? { width: 1920, height: 1080 };
6031
+ const ink = { ...seg.scene, background: { type: "solid", color: "#000000" } };
6032
+ delete ink.ghosting;
6033
+ const inkOverBed = { ...ink, background: seq.bed.background ?? { type: "solid", color: "#05050a" } };
6034
+ const bedGrid = luminanceGrid(seq.bed, bedOpts);
6035
+ const inkGrid = luminanceGrid(ink, segOpts);
6036
+ const cells = bedGrid.cells.map((v, i) => Math.min(1, v + inkGrid.cells[i]));
6037
+ const grid = finishGrid(bedGrid.cols, bedGrid.rows, cells, bedGrid.background, bedGrid.backgroundCells);
6038
+ const bedLayers = seq.bed.layers.length;
6039
+ const dominance = rankDominance([
6040
+ ...rawDominance(seq.bed, bedOpts).map((d) => ({ ...d, key: bedKey(d.key, d.layerIndex) })),
6041
+ ...rawDominance(inkOverBed, segOpts).map((d) => ({ ...d, layerIndex: d.layerIndex + bedLayers }))
6042
+ ]);
6043
+ const shift = (x) => ({ ...x, layerIndex: x.layerIndex + bedLayers });
6044
+ const prefix = (x) => ({ ...x, key: bedKey(x.key, x.layerIndex) });
6045
+ return {
6046
+ t: r.localT,
6047
+ braille: renderBrailleMap(grid),
6048
+ density: renderDensityMap(grid),
6049
+ coverage: grid.coverage,
6050
+ meanLuminance: grid.meanLuminance,
6051
+ centroid: grid.centroid,
6052
+ rowProfile: grid.rowProfile,
6053
+ colProfile: grid.colProfile,
6054
+ dominance,
6055
+ motion: [...motionStats(seq.bed, bedOpts).map(prefix), ...motionStats(ink, segOpts).map(shift)],
6056
+ form: [...layerCohesion(seq.bed, bedOpts).map(prefix), ...layerCohesion(ink, segOpts).map(shift)],
6057
+ text: [...textSprites(seq.bed, bedOpts).map(prefix), ...textSprites(ink, segOpts).map(shift)],
6058
+ // The segment's sprite mass over the bed's plate — what the screen shows
6059
+ // in front of the bed. The bed's own sprites are a slow constant beneath.
6060
+ colour: sceneColour(inkOverBed, segOpts),
6061
+ advisories: [
6062
+ ...adviseSpec(seq.bed, viewport, { t: T, seed: bedOpts.seed }).map((w) => ({ ...w, path: `bed.${w.path}` })),
6063
+ // inkOverBed's layers are the segment's (seed: segOpts.seed) but its
6064
+ // background is the bed's (seq.bed.background) — a field there must be
6065
+ // sampled with the bed's own seed, or a `role: 'read'` legibility
6066
+ // advisory checks terrain that isn't what's actually behind the ink.
6067
+ // `?? 42`: inkOverBed.seed is the SEGMENT's raw seed field (it's `ink`
6068
+ // spread first), so adviseSpec's own `backgroundSeed ?? seed` fallback
6069
+ // would substitute the segment's seed, not the bed's, if bedOpts.seed
6070
+ // were left undefined — 42 is the same default `luminanceGrid` (and
6071
+ // every other field seed fallback) converges on.
6072
+ ...adviseSpec(inkOverBed, viewport, { t: r.localT, seed: segOpts.seed, backgroundSeed: bedOpts.seed ?? 42 })
6073
+ ],
6074
+ segment,
6075
+ bed: true
6076
+ };
6077
+ }
6078
+
6079
+ // src/advise.ts
6080
+ var LOW_CONTRAST_FLOOR = 0.05;
6081
+ var SPARSE_DECLARED_MAX_COVERAGE = 0.02;
6082
+ var READ_LEGIBILITY_FLOOR = 4.5;
6083
+ var READ_SAFE_AREA = 0.05;
6084
+ var ADDITIVE_INK_CEILING = 1;
6085
+ function edgeAnchored(range) {
6086
+ if (!range) return false;
6087
+ const [a, b] = range;
6088
+ return (a <= 0.02 || b >= 0.98) && b - a <= 0.6;
6089
+ }
6090
+ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
6091
+ const hidden = hiddenLayers(spec);
6092
+ spec = resolveTimelineAt(spec, opts.t ?? COHESION_T);
6093
+ const warnings = [];
6094
+ const w = viewport.width;
6095
+ const h = viewport.height;
6096
+ const { scale, countScale } = entityScales(spec, w, h);
6097
+ const rng = createRng3(opts.seed ?? spec.seed ?? 42);
6098
+ const allEntities = spec.layers.map((l) => buildEntities(l, rng, w, h, scale, countScale));
6099
+ const cohesion = cohesionOf(
6100
+ spec.layers.map((layer, i) => ({ layer, entities: allEntities[i] })),
6101
+ opts.t ?? COHESION_T,
6102
+ w,
6103
+ h
6104
+ );
6105
+ let totalEntities = 0;
6106
+ let textLayerCount = 0;
6107
+ let motionLayerCount = 0;
6108
+ for (let li = 0; li < spec.layers.length; li++) {
6109
+ if (hidden?.[li]) continue;
6110
+ const layer = spec.layers[li];
6111
+ const entities = allEntities[li];
6112
+ totalEntities += entities.length;
6113
+ const isText = layer.sprite.kind === "text" || layer.sprite.kind === "textBlock";
6114
+ const isStaticText = isText && layer.motion.type === "static";
6115
+ if (isStaticText) textLayerCount++;
6116
+ if (layer.motion.type !== "static") motionLayerCount++;
6117
+ const coh = cohesion[li];
6118
+ if (seamsWorthWarning(layer, coh, entities.length)) {
6119
+ const s = layer.sprite;
6120
+ const softEdged = (s.kind === "circle" || s.kind === "polygon") && s.soft || s.kind === "rect" && !!s.feather;
6121
+ warnings.push({
6122
+ path: `layers[${li}]`,
6123
+ code: "overlap-seams",
6124
+ 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.`
6125
+ });
6126
+ }
6127
+ if (layer.trail && layer.motion.type === "static") {
6128
+ warnings.push({
6129
+ path: `layers[${li}].trail`,
6130
+ code: "trail-on-static",
6131
+ message: "trail has no effect on static entities \u2014 they have no past positions to draw"
6132
+ });
6133
+ }
6134
+ if ((layer.sprite.kind === "streak" || layer.sprite.kind === "stroke" && layer.sprite.orient) && layer.motion.type === "static" && !layer.spin) {
6135
+ warnings.push({
6136
+ path: `layers[${li}].sprite`,
6137
+ code: "streak-on-static",
6138
+ 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`
6139
+ });
6140
+ }
6141
+ if (layer.motion.type === "wander" && layer.motion.coherence === 1 && entities.length >= 20) {
6142
+ warnings.push({
6143
+ path: `layers[${li}].motion.coherence`,
6144
+ code: "full-coherence",
6145
+ message: "coherence 1 makes every entity share identical harmonics \u2014 the layer moves as a rigid sheet. 0.5\u20130.8 reads as schooling"
6146
+ });
6147
+ }
6148
+ if (layer.sprite.kind === "circle") {
6149
+ const maxR = entities.reduce((m, e) => Math.max(m, e.size / 2), 0);
6150
+ const maxAlpha = entities.reduce((m, e) => Math.max(m, e.alpha), 0);
6151
+ if (maxR * maxAlpha < 0.05) {
6152
+ warnings.push({
6153
+ path: `layers[${li}]`,
6154
+ code: "invisible-layer",
6155
+ message: `layer's max visible radius (${maxR.toFixed(1)}px \xD7 ${maxAlpha.toFixed(2)} alpha) is below perceptual floor`
6156
+ });
6157
+ }
6158
+ }
6159
+ if (entities.length > 0 && layer.sprite.kind !== "emoji") {
6160
+ const [y0, y1] = layer.position ? [layer.position.y, layer.position.y] : layer.region?.y ?? [0, 1];
6161
+ const plates = [y0, (y0 + y1) / 2, y1].map((y) => {
6162
+ const rgb = backgroundRgbAt(spec, y * h, h, scale, void 0, void 0, opts.backgroundSeed ?? opts.seed, opts.t ?? COHESION_T);
6163
+ return { rgb, luma: 0.2126 * rgb.r + 0.7152 * rgb.g + 0.0722 * rgb.b };
6164
+ });
6165
+ const seps = entities.map((e) => {
6166
+ const hex = spriteHex(layer, e);
6167
+ if (hex === null) return Infinity;
6168
+ const ink = { rgb: hexRgb(hex), luma: hexLuma(hex) };
6169
+ const sep = Math.max(...plates.map((bg) => colourSeparation(layer, ink, bg)));
6170
+ if (sep >= LOW_CONTRAST_FLOOR || layer.blend === "lighter" || layer.blend === "screen") return sep;
6171
+ const under = groundUnder(spec, allEntities, li, hidden, positionAt(e, 0, w, h), w, h);
6172
+ return under ? colourSeparation(layer, ink, under) : sep;
6173
+ });
6174
+ const best = Math.max(...seps);
6175
+ if (best < LOW_CONTRAST_FLOOR) {
6129
6176
  const additive = layer.blend === "lighter" || layer.blend === "screen";
6130
- const glow = soft && additive;
6131
- const reachX = glow ? halfX * GLOW_SPREAD : halfX;
6132
- const reachY = glow ? halfY * GLOW_SPREAD : halfY;
6133
- const c0 = Math.max(0, Math.floor((centerX - reachX) / cellW));
6134
- const c1 = Math.min(cols - 1, Math.floor((centerX + reachX) / cellW));
6135
- const r0 = Math.max(0, Math.floor((centerY - reachY) / cellH));
6136
- const r1 = Math.min(rows - 1, Math.floor((centerY + reachY) / cellH));
6137
- const inkWeight = (s.kind === "text" || s.kind === "emoji" || s.kind === "textBlock" ? 0.55 : 1) * (s.kind === "textBlock" ? textBlockRevealFraction(s, w, h, t) * (s.opacity ?? 1) : 1);
6138
- const byArea = s.kind === "rect" || s.kind === "bar";
6139
- for (let r = r0; r <= r1; r++) {
6140
- const fy = byArea ? Math.max(0, Math.min((r + 1) * cellH, centerY + halfY) - Math.max(r * cellH, centerY - halfY)) / cellH : 1;
6141
- if (fy <= 0) continue;
6142
- for (let c = c0; c <= c1; c++) {
6143
- const dx = (c + 0.5) * cellW - centerX;
6144
- const dy = (r + 0.5) * cellH - centerY;
6145
- if (circular) {
6146
- const d = Math.sqrt(dx * dx + dy * dy);
6147
- const reach = glow ? reachX : halfX;
6148
- if (d > reach + Math.min(cellW, cellH) / 2) continue;
6149
- if (s.kind === "ring") {
6150
- const strokeW = (s.width ?? (scale === 1 ? 2 : 2e-3)) * scale;
6151
- if (d < halfX - strokeW) continue;
6152
- }
6153
- let wgt;
6154
- if (glow) {
6155
- const core = halfX;
6156
- if (d <= core) wgt = 1;
6157
- else {
6158
- const f = Math.max(0, 1 - (d - core) / Math.max(reachX - core, 1e-6));
6159
- wgt = f * f;
6160
- }
6161
- } else {
6162
- wgt = soft ? Math.max(0.1, 1 - d / Math.max(halfX, 1e-6)) : 1;
6163
- }
6164
- compose(r * cols + c, lum, a * wgt * shapeWeight, layer.blend);
6165
- } else if (byArea) {
6166
- if (boxRot) {
6167
- const cov = rotatedBoxCoverage(centerX, centerY, boxHx, boxHy, boxRot, c * cellW, r * cellH, (c + 1) * cellW, (r + 1) * cellH);
6168
- if (cov > 0) compose(r * cols + c, lum, a * shapeWeight * cov, layer.blend);
6169
- } else {
6170
- const fx = Math.max(0, Math.min((c + 1) * cellW, centerX + halfX) - Math.max(c * cellW, centerX - halfX)) / cellW;
6171
- if (fx > 0) compose(r * cols + c, lum, a * shapeWeight * fx * fy, layer.blend);
6172
- }
6173
- } else {
6174
- compose(r * cols + c, lum, a * inkWeight * shapeWeight, layer.blend);
6175
- }
6176
- }
6177
- }
6177
+ warnings.push({
6178
+ path: `layers[${li}].sprite`,
6179
+ code: "low-contrast-layer",
6180
+ 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`
6181
+ });
6178
6182
  }
6179
- if (layer.links) {
6180
- const positions = entities.map((e) => posOf(scene, e, tPass));
6181
- const maxDistPx = layer.links.maxDist * scale;
6182
- const motionWraps = ["drift", "rise", "wander"].includes(layer.motion.type);
6183
- const edges = linkEdges(layer.links, positions, maxDistPx, layer.wrap !== false && motionWraps, w, h);
6184
- const la = Math.min(1, (layer.links.alpha ?? 0.6) * lifeA);
6185
- const lum = layer.links.color ? hexLuma(layer.links.color) : 0.7;
6186
- const defaultWidth = scale === 1 ? 1 : 1 / LIMITS.referenceViewport;
6187
- const wgt = Math.min(1, (layer.links.width ?? defaultWidth) * scale / cellH) * la * alphaScale;
6188
- for (const edge of edges) {
6189
- const pi = positions[edge.i];
6190
- const pj = positions[edge.j];
6191
- const steps = Math.max(1, Math.ceil(edge.dist / Math.min(cellW, cellH)));
6192
- for (let i = 0; i <= steps; i++) {
6193
- const k = i / steps;
6194
- const c = Math.floor((pi.x + (pj.x - pi.x) * k) / cellW);
6195
- const r = Math.floor((pi.y + (pj.y - pi.y) * k) / cellH);
6196
- if (c < 0 || c >= cols || r < 0 || r >= rows) continue;
6197
- compose(r * cols + c, lum, wgt, layer.blend);
6198
- }
6199
- }
6183
+ }
6184
+ }
6185
+ let totalCoverage = 0;
6186
+ let additiveInk = 0;
6187
+ for (let li = 0; li < spec.layers.length; li++) {
6188
+ if (hidden?.[li]) continue;
6189
+ const layer = spec.layers[li];
6190
+ const entities = allEntities[li];
6191
+ const paintA = layer.opacity === void 0 && layer.group === void 0 ? 1 : paintOpacity(spec, layer);
6192
+ const map = paintMap(spec, layer, w, h, scale);
6193
+ const areaScale = map ? paintSize(spec, layer) ** 2 : 1;
6194
+ const additive = layer.blend === "lighter" || layer.blend === "screen";
6195
+ for (const e of entities) {
6196
+ const r = e.size / 2;
6197
+ let pixArea;
6198
+ if (layer.sprite.kind === "circle") {
6199
+ pixArea = Math.PI * r * r;
6200
+ } else if (layer.sprite.kind === "polygon") {
6201
+ pixArea = polygonArea(polygonPoints(layer.sprite, r));
6202
+ } else if (layer.sprite.kind === "bar") {
6203
+ pixArea = e.size * barFraction(layer.sprite, e.barIndex ?? 0) * (e.size2 ?? e.size * 0.2);
6204
+ } else if (layer.sprite.kind === "textBlock") {
6205
+ const fsPx = layer.sprite.fontSize * scale;
6206
+ const lh = (layer.sprite.lineHeight ?? 1.4) * fsPx;
6207
+ const maxWPx = layer.sprite.maxWidth * scale;
6208
+ const lines = breakTextBlock(layer.sprite.text, maxWPx / fsPx, textMetricsClassFor(layer.sprite.font));
6209
+ pixArea = maxWPx * lines.length * lh * 0.55 * (layer.sprite.opacity ?? 1);
6210
+ } else if (layer.sprite.kind === "text") {
6211
+ const b = textBoxAt(layer.sprite, e, positionAt(e, 0, w, h), spec, w, h);
6212
+ pixArea = (b.x1 - b.x0) * (b.y1 - b.y0) * 0.55;
6213
+ } else {
6214
+ pixArea = e.size * e.size;
6215
+ }
6216
+ if (areaScale !== 1) pixArea *= areaScale;
6217
+ if (pixArea > w * h) pixArea = w * h;
6218
+ let duty = 1;
6219
+ if (layer.emit) {
6220
+ const [ga, gb] = layer.emit.grow ?? [1, 1];
6221
+ const g2 = (ga * ga + ga * gb + gb * gb) / 3;
6222
+ duty = Math.min(layer.emit.life, layer.emit.every) / layer.emit.every * 0.5 * g2;
6223
+ }
6224
+ const ink = paintA === 1 ? pixArea * e.alpha * duty / (w * h) : pixArea * e.alpha * duty * paintA / (w * h);
6225
+ totalCoverage += ink;
6226
+ if (additive) additiveInk += ink;
6227
+ }
6228
+ if (layer.links) {
6229
+ const positions = entities.map((e) => positionAt(e, 0, w, h));
6230
+ const maxDistPx = layer.links.maxDist * scale;
6231
+ const edges = linkEdges(layer.links, positions, maxDistPx, layer.wrap !== false, w, h);
6232
+ const defaultWidth = scale === 1 ? 1 : 1 / LIMITS.referenceViewport;
6233
+ const lwPx = (layer.links.width ?? defaultWidth) * scale;
6234
+ const la = layer.links.alpha ?? 1;
6235
+ for (const edge of edges) totalCoverage += paintA === 1 ? edge.dist * lwPx * la / (w * h) : edge.dist * lwPx * la * paintA / (w * h);
6236
+ }
6237
+ }
6238
+ const density = spec.density ?? "normal";
6239
+ const wouldBeSparse = totalCoverage < 5e-4 && spec.layers.length > 0;
6240
+ if (wouldBeSparse && density !== "sparse") {
6241
+ warnings.push({
6242
+ path: "layers",
6243
+ code: "sparse-scene",
6244
+ message: `alpha-weighted coverage is ${(totalCoverage * 100).toFixed(4)}% \u2014 scene will look empty on most displays`
6245
+ });
6246
+ }
6247
+ if (additiveInk > ADDITIVE_INK_CEILING) {
6248
+ warnings.push({
6249
+ path: "layers",
6250
+ code: "additive-saturation",
6251
+ 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`
6252
+ });
6253
+ }
6254
+ if (totalEntities > 500 && density !== "dense") {
6255
+ warnings.push({
6256
+ path: "layers",
6257
+ code: "dense-scene",
6258
+ message: `${totalEntities} entities \u2014 scene may feel crowded and hurt performance on low-end devices`
6259
+ });
6260
+ }
6261
+ if (density === "sparse" && totalCoverage > SPARSE_DECLARED_MAX_COVERAGE) {
6262
+ warnings.push({
6263
+ path: "density",
6264
+ code: "density-mismatch",
6265
+ message: `declared sparse but alpha-weighted coverage is ${(totalCoverage * 100).toFixed(2)}% \u2014 either the declaration or the scene is wrong`
6266
+ });
6267
+ } else if (density === "dense" && wouldBeSparse) {
6268
+ warnings.push({
6269
+ path: "density",
6270
+ code: "density-mismatch",
6271
+ message: `declared dense but alpha-weighted coverage is ${(totalCoverage * 100).toFixed(4)}% \u2014 the scene will look empty`
6272
+ });
6273
+ }
6274
+ if (spec.layers.length > 0 && textLayerCount > 0 && motionLayerCount === 0) {
6275
+ const textRatio = textLayerCount / spec.layers.length;
6276
+ if (textRatio >= 0.8) {
6277
+ warnings.push({
6278
+ path: "layers",
6279
+ code: "text-heavy",
6280
+ message: "most layers are static text \u2014 reads as a document, not a screensaver"
6281
+ });
6282
+ }
6283
+ }
6284
+ for (let li = 0; li < spec.layers.length; li++) {
6285
+ if (hidden?.[li]) continue;
6286
+ const layer = spec.layers[li];
6287
+ if (!layer.links || layer.links.mode === "chain") continue;
6288
+ const entities = allEntities[li];
6289
+ const positions = entities.map((e) => positionAt(e, 0, w, h));
6290
+ const maxDist = layer.links.maxDist * scale;
6291
+ const pairs = linkPairs(positions, layer.links.k, maxDist, layer.wrap !== false, w, h);
6292
+ const maxPossible = Math.min(entities.length * layer.links.k, entities.length * (entities.length - 1) / 2);
6293
+ if (maxPossible > 0 && pairs.length / maxPossible < 0.1) {
6294
+ warnings.push({
6295
+ path: `layers[${li}].links`,
6296
+ code: "link-starvation",
6297
+ message: `only ${pairs.length}/${maxPossible} possible edges formed \u2014 raise count or widen links.maxDist`
6298
+ });
6299
+ }
6300
+ }
6301
+ for (let li = 0; li < spec.layers.length; li++) {
6302
+ if (hidden?.[li]) continue;
6303
+ const layer = spec.layers[li];
6304
+ if (layer.motion.type === "static") continue;
6305
+ const entities = allEntities[li];
6306
+ if (entities.length < 3) continue;
6307
+ const speeds = entities.map((e) => Math.sqrt(e.vx * e.vx + e.vy * e.vy));
6308
+ const mean = speeds.reduce((a, b) => a + b, 0) / speeds.length;
6309
+ if (mean === 0) continue;
6310
+ const maxDev = Math.max(...speeds.map((s) => Math.abs(s - mean)));
6311
+ if (maxDev / mean < 0.05) {
6312
+ warnings.push({
6313
+ path: `layers[${li}].motion`,
6314
+ code: "uniform-motion",
6315
+ message: "all entities move at nearly identical speed \u2014 widen the speed range for visual variety"
6316
+ });
6317
+ }
6318
+ }
6319
+ 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));
6320
+ const composedCount = composed.reduce((s, c) => s + c.ents.length, 0);
6321
+ if (composedCount >= 8) {
6322
+ const positions = composed.flatMap(({ ents }) => ents.map((e) => positionAt(e, 0, w, h)));
6323
+ const px = positions.reduce((s, p) => s + p.x, 0) / positions.length / w;
6324
+ const py = positions.reduce((s, p) => s + p.y, 0) / positions.length / h;
6325
+ const offX = Math.abs(px - 0.5);
6326
+ const offY = Math.abs(py - 0.5);
6327
+ const anchoredOn = (axis) => composed.reduce((n, { l, ents }) => n + (edgeAnchored(l.region?.[axis]) ? ents.length : 0), 0) / composedCount;
6328
+ const intentional = offY > 0.2 && offX <= 0.2 && anchoredOn("y") >= 0.5 || offX > 0.2 && offY <= 0.2 && anchoredOn("x") >= 0.5;
6329
+ if ((offX > 0.2 || offY > 0.2) && !intentional) {
6330
+ const grid = luminanceGrid({ ...spec, layers: composed.map(({ l }) => l) }, { viewport: { width: w, height: h }, t: opts.t ?? COHESION_T, seed: opts.seed });
6331
+ const c = grid.centroid;
6332
+ if (c && (Math.abs(c.x - 0.5) > 0.2 || Math.abs(c.y - 0.5) > 0.2)) {
6333
+ warnings.push({
6334
+ path: "layers",
6335
+ code: "off-center",
6336
+ message: `centroid at (${c.x.toFixed(2)}, ${c.y.toFixed(2)}) \u2014 composition is heavily offset from center`
6337
+ });
6200
6338
  }
6201
6339
  }
6202
- };
6203
- const ghost = spec.ghosting ?? 0;
6204
- if (ghost > 0) {
6205
- const dt = 1e3 / 60;
6206
- const windowFrames = Math.min(Math.ceil(Math.log(1 / 255) / Math.log(ghost)), LIMITS.maxGhostReplayFrames);
6207
- const taps = Math.min(GHOST_TAPS, windowFrames);
6208
- const spacing = windowFrames / taps;
6209
- for (let i = taps; i >= 1; i--) {
6210
- const tPass = t - i * spacing * dt;
6211
- if (tPass < 0) continue;
6212
- const bracketWeight = Math.pow(ghost, (i - 1) * spacing + 1) * (1 - Math.pow(ghost, spacing)) / (1 - ghost);
6213
- splatPass(tPass, Math.min(1, bracketWeight));
6340
+ }
6341
+ const textBoxes = [];
6342
+ for (let li = 0; li < spec.layers.length; li++) {
6343
+ if (hidden?.[li]) continue;
6344
+ const layer = spec.layers[li];
6345
+ const s = layer.sprite;
6346
+ if (layer.motion.type !== "static") continue;
6347
+ if (s.kind !== "text" && s.kind !== "textBlock") continue;
6348
+ const label = layer.key ? `\`${layer.key}\`` : `layers[${li}]`;
6349
+ for (const e of allEntities[li]) {
6350
+ const p = positionAt(e, 0, w, h);
6351
+ const raw = s.kind === "textBlock" ? textBlockBoxAt(s, p, w, h) : textBoxAt(s, e, p, spec, w, h);
6352
+ const map = paintMap(spec, layer, w, h, scale);
6353
+ const box = map ? { ...raw, ...mapBox(map, raw) } : raw;
6354
+ const baseAlpha = Math.max(0, Math.min(1, e.alpha - e.pulseAmp)) * (s.kind === "textBlock" ? s.opacity ?? 1 : 1);
6355
+ const alpha = layer.opacity === void 0 && layer.group === void 0 ? baseAlpha : baseAlpha * paintOpacity(spec, layer);
6356
+ const str = s.kind === "textBlock" ? s.text : s.strings[e.spriteIndex] ?? s.strings[0] ?? "";
6357
+ textBoxes.push({ li, label, alpha, str, ...box });
6214
6358
  }
6215
6359
  }
6216
- splatPass(t, 1);
6217
- return finishGrid(cols, rows, cells, bgRow, bgCells);
6218
- }
6219
- function finishGrid(cols, rows, cells, bgRow, bgCells) {
6220
- let sum = 0;
6221
- let covered = 0;
6222
- let devSum = 0;
6223
- let cxAcc = 0;
6224
- let cyAcc = 0;
6225
- const rowProfile = new Array(rows).fill(0);
6226
- const colProfile = new Array(cols).fill(0);
6227
- for (let r = 0; r < rows; r++) {
6228
- for (let c = 0; c < cols; c++) {
6229
- const v = cells[r * cols + c];
6230
- sum += v;
6231
- const dev = Math.abs(v - (bgCells ? bgCells[r * cols + c] : bgRow[r]));
6232
- if (dev > 0.03) covered++;
6233
- devSum += dev;
6234
- cxAcc += dev * (c + 0.5);
6235
- cyAcc += dev * (r + 0.5);
6236
- rowProfile[r] += dev;
6237
- colProfile[c] += dev;
6360
+ const asFraction = (b) => ({
6361
+ x: +(b.x0 / w).toFixed(4),
6362
+ y: +(b.y0 / h).toFixed(4),
6363
+ w: +((b.x1 - b.x0) / w).toFixed(4),
6364
+ h: +((b.y1 - b.y0) / h).toFixed(4)
6365
+ });
6366
+ const tolX = w * 0.01;
6367
+ const tolY = h * 0.01;
6368
+ const offFlagged = /* @__PURE__ */ new Set();
6369
+ for (const b of textBoxes) {
6370
+ if (offFlagged.has(b.li)) continue;
6371
+ const edges = [];
6372
+ if (b.x0 < -tolX) edges.push(`left edge by ~${Math.round(-b.x0)}px`);
6373
+ if (b.x1 > w + tolX) edges.push(`right edge by ~${Math.round(b.x1 - w)}px`);
6374
+ if (b.y0 < -tolY) edges.push(`top edge by ~${Math.round(-b.y0)}px`);
6375
+ if (b.y1 > h + tolY) edges.push(`bottom edge by ~${Math.round(b.y1 - h)}px`);
6376
+ if (edges.length === 0) continue;
6377
+ offFlagged.add(b.li);
6378
+ warnings.push({
6379
+ path: `layers[${b.li}]`,
6380
+ code: "text-off-screen",
6381
+ message: `${b.label} text runs off the ${edges.join(" and the ")} at ${w}\xD7${h} \u2014 move position, or shrink fontSize / maxWidth`
6382
+ });
6383
+ }
6384
+ const pairFlagged = /* @__PURE__ */ new Set();
6385
+ for (let i = 0; i < textBoxes.length; i++) {
6386
+ for (let j = i + 1; j < textBoxes.length; j++) {
6387
+ const a = textBoxes[i];
6388
+ const b = textBoxes[j];
6389
+ if (a.li === b.li) continue;
6390
+ const pair = `${Math.min(a.li, b.li)}:${Math.max(a.li, b.li)}`;
6391
+ if (pairFlagged.has(pair)) continue;
6392
+ const ix = Math.min(a.x1, b.x1) - Math.max(a.x0, b.x0);
6393
+ const iy = Math.min(a.y1, b.y1) - Math.max(a.y0, b.y0);
6394
+ if (ix <= 0 || iy <= 0) continue;
6395
+ const smaller = Math.min((a.x1 - a.x0) * (a.y1 - a.y0), (b.x1 - b.x0) * (b.y1 - b.y0));
6396
+ if (smaller <= 0) continue;
6397
+ const share = ix * iy / smaller;
6398
+ if (share <= 0.1) continue;
6399
+ if (isOutlineStack(a, b)) continue;
6400
+ pairFlagged.add(pair);
6401
+ const [first, second] = a.li < b.li ? [a, b] : [b, a];
6402
+ warnings.push({
6403
+ path: `layers[${first.li}]`,
6404
+ code: "text-overlap",
6405
+ 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`,
6406
+ // The two boxes, so an agent can move one without re-deriving the
6407
+ // layout. Additive: the message is unchanged.
6408
+ boxes: [asFraction(first), asFraction(second)]
6409
+ });
6238
6410
  }
6239
6411
  }
6240
- for (let r = 0; r < rows; r++) rowProfile[r] = rowProfile[r] / cols;
6241
- for (let c = 0; c < cols; c++) colProfile[c] = colProfile[c] / rows;
6242
- return {
6243
- cols,
6244
- rows,
6245
- cells,
6246
- background: bgRow,
6247
- ...bgCells ? { backgroundCells: bgCells } : {},
6248
- meanLuminance: sum / (cols * rows),
6249
- coverage: covered / (cols * rows),
6250
- centroid: devSum > 1e-6 ? { x: cxAcc / devSum / cols, y: cyAcc / devSum / rows } : null,
6251
- rowProfile,
6252
- colProfile
6253
- };
6254
- }
6255
- var DITHER = [
6256
- [0.5 / 8, 4.5 / 8],
6257
- [2.5 / 8, 6.5 / 8],
6258
- [1.5 / 8, 5.5 / 8],
6259
- [3.5 / 8, 7.5 / 8]
6260
- ];
6261
- var DOT_BIT = [
6262
- [1, 8],
6263
- [2, 16],
6264
- [4, 32],
6265
- [64, 128]
6266
- ];
6267
- function autoExpose(grid) {
6268
- const sorted = [...grid.cells].sort((a, b) => a - b);
6269
- const lo = sorted[0];
6270
- const hi = Math.max(sorted[Math.floor(sorted.length * 0.98)], lo + 0.08);
6271
- return (v) => Math.pow(Math.max(0, Math.min(1, (v - lo) / (hi - lo))), 0.8);
6412
+ const readFlagged = /* @__PURE__ */ new Set();
6413
+ for (const b of textBoxes) {
6414
+ if (readFlagged.has(b.li)) continue;
6415
+ const layer = spec.layers[b.li];
6416
+ const s = layer.sprite;
6417
+ if (s.kind !== "text" && s.kind !== "textBlock" || s.role !== "read") continue;
6418
+ const boxes = textBoxes.filter((x) => x.li === b.li);
6419
+ readFlagged.add(b.li);
6420
+ const ink = hexRgb(s.color ?? "#e6e8ef");
6421
+ const textPlate = (base, alpha) => {
6422
+ if (layer.blend === "lighter" || layer.blend === "screen") return additivePlate(base, ink, alpha, layer.blend);
6423
+ if (layer.blend === "multiply") {
6424
+ return {
6425
+ r: base.r * (1 - alpha + ink.r * alpha),
6426
+ g: base.g * (1 - alpha + ink.g * alpha),
6427
+ b: base.b * (1 - alpha + ink.b * alpha)
6428
+ };
6429
+ }
6430
+ return sourceOverPlate(base, ink, alpha);
6431
+ };
6432
+ let worst = null;
6433
+ for (const box of boxes) {
6434
+ const cy = (box.y0 + box.y1) / 2;
6435
+ const cx = (box.x0 + box.x1) / 2;
6436
+ const groundRgb = backgroundRgbAt(spec, cy, h, scale, cx, w, opts.backgroundSeed ?? opts.seed, opts.t ?? COHESION_T);
6437
+ const ground = legibilityRatio(textPlate(groundRgb, box.alpha), groundRgb);
6438
+ const lit = brightestAdditivePlate(spec, allEntities, groundRgb, box, w, h);
6439
+ const plate = lit ? legibilityRatio(textPlate(lit.rgb, box.alpha), lit.rgb) : Infinity;
6440
+ if (!worst || Math.min(ground, plate) < Math.min(worst.ground, worst.plate)) {
6441
+ worst = { ground, plate, plateLabel: lit?.label ?? null };
6442
+ }
6443
+ }
6444
+ if (worst && Math.min(worst.ground, worst.plate) < READ_LEGIBILITY_FLOOR) {
6445
+ const under = worst.plateLabel ? ` and ${worst.plate.toFixed(1)}:1 under ${worst.plateLabel} at its brightest` : "";
6446
+ warnings.push({
6447
+ path: `layers[${b.li}].sprite`,
6448
+ code: "text-legibility",
6449
+ 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`
6450
+ });
6451
+ }
6452
+ const edges = [];
6453
+ const x0 = Math.min(...boxes.map((x) => x.x0));
6454
+ const x1 = Math.max(...boxes.map((x) => x.x1));
6455
+ const y0 = Math.min(...boxes.map((x) => x.y0));
6456
+ const y1 = Math.max(...boxes.map((x) => x.y1));
6457
+ if (x0 < w * READ_SAFE_AREA) edges.push("left");
6458
+ if (x1 > w * (1 - READ_SAFE_AREA)) edges.push("right");
6459
+ if (y0 < h * READ_SAFE_AREA) edges.push("top");
6460
+ if (y1 > h * (1 - READ_SAFE_AREA)) edges.push("bottom");
6461
+ if (edges.length > 0) {
6462
+ warnings.push({
6463
+ path: `layers[${b.li}]`,
6464
+ code: "text-safe-area",
6465
+ 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`,
6466
+ boxes: [asFraction({ x0, y0, x1, y1 })]
6467
+ });
6468
+ }
6469
+ }
6470
+ return warnings;
6272
6471
  }
6273
- function renderBrailleMap(grid) {
6274
- const expose = autoExpose(grid);
6275
- const outRows = [];
6276
- for (let br = 0; br < Math.floor(grid.rows / 4); br++) {
6277
- let line = "";
6278
- for (let bc = 0; bc < Math.floor(grid.cols / 2); bc++) {
6279
- let bits = 0;
6280
- for (let dr = 0; dr < 4; dr++) {
6281
- for (let dc = 0; dc < 2; dc++) {
6282
- const v = expose(grid.cells[(br * 4 + dr) * grid.cols + (bc * 2 + dc)]);
6283
- if (v > DITHER[dr][dc]) bits |= DOT_BIT[dr][dc];
6284
- }
6472
+ function brightestAdditivePlate(spec, allEntities, ground, box, w, h) {
6473
+ let best = null;
6474
+ for (let lj = 0; lj < spec.layers.length; lj++) {
6475
+ const layer = spec.layers[lj];
6476
+ const blend = layer.blend;
6477
+ if (blend !== "lighter" && blend !== "screen") continue;
6478
+ if (lj === box.li) continue;
6479
+ const entities = allEntities[lj];
6480
+ const moved = layer.transform !== void 0 || groupOf(spec, layer)?.transform !== void 0;
6481
+ for (const e of entities) {
6482
+ const hex = spriteHex(layer, e);
6483
+ if (hex === null) continue;
6484
+ const growScale = (e.motion === "warp" ? WARP_MAX_SCALE : 1) * (e.emit ? Math.max(e.emit.growFrom, e.emit.growTo) : 1);
6485
+ const maxDim = Math.max(e.size, e.size2 ?? 0) * (1 + e.growAmp) * growScale;
6486
+ if (maxDim < box.fs) continue;
6487
+ if (!moved && !entityReachesBox(e, maxDim / 2, box, w, h)) continue;
6488
+ 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);
6489
+ if (a <= 0) continue;
6490
+ const plate = additivePlate(ground, hexRgb(hex), a, blend);
6491
+ const lum = relativeLuminance(plate);
6492
+ if (!best || lum > best.lum) {
6493
+ best = { rgb: plate, label: layer.key ? `\`${layer.key}\`` : `layers[${lj}]`, lum };
6285
6494
  }
6286
- line += String.fromCharCode(10240 + bits);
6287
6495
  }
6288
- outRows.push(line);
6289
6496
  }
6290
- return outRows.join("\n");
6497
+ return best ? { rgb: best.rgb, label: best.label } : null;
6291
6498
  }
6292
- var DENSITY_RAMP = " .:-=+*#%@";
6293
- function renderDensityMap(grid) {
6294
- const expose = autoExpose(grid);
6295
- const ramp = DENSITY_RAMP;
6296
- const lines = [];
6297
- for (let r = 0; r < grid.rows; r++) {
6298
- let line = "";
6299
- for (let c = 0; c < grid.cols; c++) {
6300
- const v = expose(grid.cells[r * grid.cols + c]);
6301
- line += ramp[Math.min(ramp.length - 1, Math.floor(v * ramp.length))];
6499
+ function groundUnder(spec, allEntities, li, hidden, pt, w, h) {
6500
+ let plate = backgroundRgb(spec);
6501
+ let covered = false;
6502
+ for (let lj = 0; lj < li; lj++) {
6503
+ if (hidden?.[lj]) continue;
6504
+ const layer = spec.layers[lj];
6505
+ const sp = layer.sprite;
6506
+ if (layer.motion.type !== "static" || layer.blend !== void 0 || layer.transform !== void 0 || groupOf(spec, layer)?.transform !== void 0) continue;
6507
+ if ((sp.kind === "circle" || sp.kind === "polygon") && sp.soft) continue;
6508
+ if (sp.kind !== "circle" && sp.kind !== "rect" && sp.kind !== "polygon") continue;
6509
+ const a = layer.opacity === void 0 && layer.group === void 0 ? 1 : paintOpacity(spec, layer);
6510
+ for (const e of allEntities[lj]) {
6511
+ if (e.spinSpeed !== 0 || e.alpha * a <= 0) continue;
6512
+ const hex = spriteHex(layer, e);
6513
+ if (hex === null) continue;
6514
+ const c = positionAt(e, 0, w, h);
6515
+ const dx = pt.x - c.x;
6516
+ const dy = pt.y - c.y;
6517
+ let inside = false;
6518
+ if (sp.kind === "circle") inside = Math.hypot(dx, dy) <= e.size / 2;
6519
+ else if (sp.kind === "rect") {
6520
+ const rot = ((e.rotate ?? 0) % Math.PI + Math.PI) % Math.PI;
6521
+ const quarter = Math.abs(rot - Math.PI / 2) < 0.01;
6522
+ if (rot > 0.01 && rot < Math.PI - 0.01 && !quarter) continue;
6523
+ const hx = (quarter ? e.size2 ?? e.size : e.size) / 2;
6524
+ const hy = (quarter ? e.size : e.size2 ?? e.size) / 2;
6525
+ inside = Math.abs(dx) <= hx && Math.abs(dy) <= hy;
6526
+ } else {
6527
+ if (e.rotate) continue;
6528
+ inside = pointInPolygon(dx, dy, polygonPoints(sp, e.size / 2));
6529
+ }
6530
+ if (!inside) continue;
6531
+ plate = sourceOverPlate(plate, hexRgb(hex), Math.min(1, e.alpha * a));
6532
+ covered = true;
6302
6533
  }
6303
- lines.push(line);
6304
6534
  }
6305
- return lines.join("\n");
6535
+ return covered ? { rgb: plate, luma: 0.2126 * plate.r + 0.7152 * plate.g + 0.0722 * plate.b } : null;
6306
6536
  }
6307
- function textSprites(spec, opts = {}) {
6308
- spec = resolveTimelineAt(spec, opts.t ?? 5e3);
6309
- const scene = buildScene(spec, opts);
6310
- const t = opts.t ?? 5e3;
6311
- const out = [];
6312
- scene.layers.forEach(({ layer, entities }, layerIndex) => {
6313
- if (layer.sprite.kind === "textBlock") {
6314
- const s2 = layer.sprite;
6315
- const unitScale = Math.min(scene.w, scene.h);
6316
- const sizePx2 = s2.fontSize * unitScale;
6317
- const info = { layerIndex, key: layer.key, strings: [s2.text], count: entities.length, sizePx: Math.round(sizePx2) };
6318
- if (s2.reveal) info.revealed = Math.round(textBlockRevealFraction(s2, scene.w, scene.h, t) * 100) / 100;
6319
- out.push(info);
6320
- return;
6321
- }
6322
- if (layer.sprite.kind !== "text") return;
6323
- const s = layer.sprite;
6324
- const m = s.font ? /(\d+(?:\.\d+)?)px/.exec(s.font) : null;
6325
- const sizePx = m ? Number(m[1]) : entities[0] ? sizeAt(entities[0], t) : 0;
6326
- out.push({ layerIndex, key: layer.key, strings: [...s.strings], count: entities.length, sizePx: Math.round(sizePx) });
6327
- });
6328
- return out;
6537
+ function pointInPolygon(x, y, pts) {
6538
+ let inside = false;
6539
+ for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
6540
+ const a = pts[i];
6541
+ const b = pts[j];
6542
+ if (a.y > y !== b.y > y && x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x) inside = !inside;
6543
+ }
6544
+ return inside;
6329
6545
  }
6330
- function dominanceRanking(spec, opts = {}) {
6331
- return rankDominance(rawDominance(spec, opts));
6546
+ function entityReachesBox(e, r, box, w, h) {
6547
+ if (e.motion === "static") {
6548
+ const p = positionAt(e, 0, w, h);
6549
+ return p.x + r > box.x0 && p.x - r < box.x1 && p.y + r > box.y0 && p.y - r < box.y1;
6550
+ }
6551
+ if (e.motion === "orbit" && !e.orbitParent) {
6552
+ const span = e.orbitR + r;
6553
+ return e.orbitCx + span > box.x0 && e.orbitCx - span < box.x1 && e.orbitCy + span > box.y0 && e.orbitCy - span < box.y1;
6554
+ }
6555
+ return true;
6332
6556
  }
6333
- function rankDominance(raw) {
6334
- const total = raw.reduce((s, r) => s + r.weight, 0) || 1;
6335
- return raw.slice().sort((a, b) => b.weight - a.weight).map((r, i) => ({
6336
- rank: i + 1,
6337
- layerIndex: r.layerIndex,
6338
- key: r.key,
6339
- share: r.weight / total,
6340
- entityCount: r.entityCount,
6341
- meanLuma: r.meanLuma,
6342
- factors: r.factors
6343
- }));
6557
+ var OUTLINE_STACK_OFFSET = 0.15;
6558
+ function isOutlineStack(a, b) {
6559
+ if (a.str !== b.str || a.str.trim() === "") return false;
6560
+ const fs = Math.min(a.fs, b.fs);
6561
+ if (fs <= 0 || Math.abs(a.fs - b.fs) > 0.1 * Math.max(a.fs, b.fs)) return false;
6562
+ const dx = (a.x0 + a.x1) / 2 - (b.x0 + b.x1) / 2;
6563
+ const dy = (a.y0 + a.y1) / 2 - (b.y0 + b.y1) / 2;
6564
+ return Math.hypot(dx, dy) <= OUTLINE_STACK_OFFSET * fs;
6344
6565
  }
6345
- function rawDominance(spec, opts = {}) {
6346
- spec = resolveTimelineAt(spec, opts.t ?? 5e3);
6347
- const scene = buildScene(spec, opts);
6348
- const t = opts.t ?? 5e3;
6349
- const { w, h, scale } = scene;
6350
- const bgMean = backgroundLuma(spec);
6351
- const ghost = spec.ghosting ?? 0;
6352
- let ghostWindowMs = 0;
6353
- let ghostMeanW = 0;
6354
- if (ghost > 0) {
6355
- const dt = 1e3 / 60;
6356
- const frames = Math.min(Math.ceil(Math.log(1 / 255) / Math.log(ghost)), LIMITS.maxGhostReplayFrames);
6357
- ghostWindowMs = frames * dt;
6358
- ghostMeanW = ghost * (1 - Math.pow(ghost, frames)) / (1 - ghost) / frames;
6359
- }
6360
- const sweptDistance = (e, spanMs) => {
6361
- const span = Math.min(spanMs, t);
6362
- if (span <= 0) return 0;
6363
- const p0 = posOf(scene, e, t - span);
6364
- const p1 = posOf(scene, e, t);
6365
- const dx = p1.x - p0.x;
6366
- const dy = p1.y - p0.y;
6367
- if (Math.abs(dx) > w / 2 || Math.abs(dy) > h / 2) return 0;
6368
- return Math.sqrt(dx * dx + dy * dy);
6369
- };
6370
- const raw = scene.layers.map(({ layer, entities }, layerIndex) => {
6371
- const lifeA = layer.opacity === void 0 && layer.group === void 0 ? lifeAlphaAt(layer.life, t) : lifeAlphaAt(layer.life, t) * paintOpacity(spec, layer);
6372
- const painted = layer.transform !== void 0 || layer.group !== void 0;
6373
- const areaScale = painted ? paintSize(spec, layer) ** 2 : 1;
6374
- let area = 0;
6375
- let lumAcc = 0;
6376
- for (const e of entities) {
6377
- const a = alphaAt(e, t) * lifeA;
6378
- const sz = sizeAt(e, t);
6379
- const s = layer.sprite;
6380
- let entArea;
6381
- if (s.kind === "circle") entArea = Math.PI * (sz / 2) * (sz / 2);
6382
- else if (s.kind === "ring") entArea = Math.PI * sz * ((s.width ?? (scale === 1 ? 2 : 2e-3)) * scale) * LINE_SALIENCE;
6383
- else if (s.kind === "streak") entArea = sz * ((s.width ?? (scale === 1 ? 2 : 2e-3)) * scale) * LINE_SALIENCE;
6384
- else if (s.kind === "rect") {
6385
- const h2 = e.size2 !== void 0 ? e.size2 * (e.size > 0 ? sz / e.size : 1) : sz;
6386
- const f = s.feather ? (1 - s.feather / 2) * (1 - s.feather / 2) : 1;
6387
- entArea = sz * h2 * f;
6388
- } else if (s.kind === "polygon") entArea = polygonArea(polygonPoints(s, sz / 2));
6389
- else if (s.kind === "bar") {
6390
- const th = e.size2 !== void 0 ? e.size2 * (e.size > 0 ? sz / e.size : 1) : sz * 0.2;
6391
- entArea = sz * barFraction(s, e.barIndex ?? 0) * th;
6392
- } else if (s.kind === "stroke") {
6393
- const meanTaper = s.taper ? 0.64 : 1;
6394
- entArea = pathLength(strokeSamples(s, sz / 2)) * strokeWidthPx(s, scale) * meanTaper * LINE_SALIENCE;
6395
- } else if (s.kind === "text") {
6396
- const box = textBox(s, e, { x: 0, y: 0 });
6397
- entArea = box.halfX * 2 * box.halfY * 2 * 0.55;
6398
- } else if (s.kind === "textBlock") {
6399
- const box = textBlockBox(s, e, { x: 0, y: 0 }, w, h);
6400
- entArea = box.halfX * 2 * box.halfY * 2 * 0.55 * textBlockRevealFraction(s, w, h, t) * (s.opacity ?? 1);
6401
- } else entArea = sz * sz * 0.55;
6402
- area += painted ? entArea * areaScale * a : entArea * a;
6403
- if (layer.trail) {
6404
- const fade = layer.trail.fade ?? 1;
6405
- const span = sweptDistance(e, layer.trail.length);
6406
- area += span * sz * 0.65 * Math.max(0, 1 - fade / 2) * a;
6407
- }
6408
- if (ghost > 0) {
6409
- area += sweptDistance(e, ghostWindowMs) * sz * ghostMeanW * a;
6410
- }
6411
- lumAcc += spriteLuma(layer, e);
6412
- }
6413
- if (layer.links && entities.length > 1) {
6414
- const positions = entities.map((e) => posOf(scene, e, t));
6415
- const motionWraps = ["drift", "rise", "wander"].includes(layer.motion.type);
6416
- const edges = linkEdges(layer.links, positions, layer.links.maxDist * scale, layer.wrap !== false && motionWraps, w, h);
6417
- const la = (layer.links.alpha ?? 0.6) * lifeA;
6418
- const defaultWidth = scale === 1 ? 1 : 1 / LIMITS.referenceViewport;
6419
- const lw = (layer.links.width ?? defaultWidth) * scale;
6420
- for (const edge of edges) area += edge.dist * lw * la * LINE_SALIENCE;
6421
- }
6422
- const meanLuma = entities.length ? lumAcc / entities.length : 0;
6423
- const contrast = Math.abs(meanLuma - bgMean) + 0.1;
6424
- const blendBoost = layer.blend === "lighter" || layer.blend === "screen" ? 1.3 : layer.blend === "multiply" ? 1.1 : 1;
6425
- const motionBoost = layer.motion.type === "static" ? 1 : 1.2;
6426
- const weight = area / (w * h) * contrast * blendBoost * motionBoost;
6427
- return { layerIndex, key: layer.key, weight, entityCount: entities.length, meanLuma, factors: { area: area / (w * h), contrast, blendBoost, motionBoost } };
6428
- });
6429
- return raw;
6566
+ function textBoxAt(s, e, p, spec, w, h) {
6567
+ const m = s.font ? /(\d{1,5}(?:\.\d{1,4})?|\.\d{1,4})px/.exec(s.font) : null;
6568
+ const fontScale = spec.units === "px" ? 1 : Math.min(w, h) / (spec.referenceViewport ?? LIMITS.referenceViewport);
6569
+ const fh = m ? Number(m[1]) * fontScale : e.size;
6570
+ const str = s.strings[e.spriteIndex] ?? s.strings[0] ?? "";
6571
+ let fw = textWidthEm(str) * fh;
6572
+ if (s.maxWidth) fw = Math.min(fw, s.maxWidth * (spec.units === "px" ? 1 : Math.min(w, h)));
6573
+ const align = s.align ?? "center";
6574
+ const baseline = s.baseline ?? "middle";
6575
+ const x0 = align === "left" ? p.x : align === "right" ? p.x - fw : p.x - fw / 2;
6576
+ const y0 = baseline === "top" ? p.y : baseline === "bottom" ? p.y - fh : p.y - fh / 2;
6577
+ return { x0, y0, x1: x0 + fw, y1: y0 + fh, fs: fh };
6430
6578
  }
6431
- function layerCohesion(spec, opts = {}) {
6432
- spec = resolveTimelineAt(spec, opts.t ?? 5e3);
6433
- const scene = buildScene(spec, opts);
6434
- return cohesionOf(scene.layers, opts.t ?? 5e3, scene.w, scene.h);
6579
+ function textBlockBoxAt(s, p, w, h) {
6580
+ const unit = Math.min(w, h);
6581
+ const fsPx = s.fontSize * unit;
6582
+ const lh = (s.lineHeight ?? 1.4) * fsPx;
6583
+ const maxWPx = s.maxWidth * unit;
6584
+ const lines = breakTextBlock(s.text, maxWPx / fsPx, textMetricsClassFor(s.font));
6585
+ const maxLineW = lines.reduce((mx, l) => Math.max(mx, l.widthEm), 0) * fsPx;
6586
+ const totalH = lines.length * lh;
6587
+ const align = s.align ?? "left";
6588
+ const x0 = align === "center" ? p.x + (maxWPx - maxLineW) / 2 : align === "right" ? p.x + maxWPx - maxLineW : p.x;
6589
+ const { dx, dy } = textBlockAnchorOffset(s, maxWPx, maxLineW, totalH);
6590
+ return { x0: x0 + dx, y0: p.y + dy, x1: x0 + dx + maxLineW, y1: p.y + dy + totalH, fs: fsPx };
6435
6591
  }
6436
- function motionStats(spec, opts = {}) {
6437
- const t = opts.t ?? 5e3;
6438
- const dt = opts.dt ?? 500;
6439
- const scene0 = buildScene(resolveTimelineAt(spec, t), opts);
6440
- const scene1 = spec.timeline ? buildScene(resolveTimelineAt(spec, t + dt), opts) : scene0;
6441
- const { w, h } = scene0;
6442
- return scene0.layers.map(({ layer, entities }, layerIndex) => {
6443
- const entities1 = scene1.layers[layerIndex]?.entities ?? entities;
6444
- let acc = 0;
6445
- let max = 0;
6446
- let n = 0;
6447
- entities.forEach((e, i) => {
6448
- const p0 = posOf(scene0, e, t);
6449
- const p1 = posOf(scene1, entities1[i] ?? e, t + dt);
6450
- const dx = p1.x - p0.x;
6451
- const dy = p1.y - p0.y;
6452
- if (Math.abs(dx) > w / 2 || Math.abs(dy) > h / 2) return;
6453
- const speed = Math.sqrt(dx * dx + dy * dy) / dt * 1e3;
6454
- acc += speed;
6455
- max = Math.max(max, speed);
6456
- n++;
6457
- });
6458
- const meanSpeed = n ? acc / n : 0;
6459
- return { layerIndex, key: layer.key, meanSpeed, maxSpeed: max, moving: meanSpeed > 0.5 };
6592
+ function hiddenLayers(spec) {
6593
+ if (!spec.groups && !spec.layers.some((l) => l.opacity !== void 0)) return null;
6594
+ const tracks = spec.timeline ? timelineTracks(spec) : null;
6595
+ const pinnedZero = (path, base, ancestors) => {
6596
+ if (base !== 0) return false;
6597
+ if (!tracks) return true;
6598
+ for (const [p, keys] of tracks) {
6599
+ if (p === path && keys.some((k) => k.value !== 0)) return false;
6600
+ if (ancestors.includes(p)) return false;
6601
+ }
6602
+ return true;
6603
+ };
6604
+ const out = spec.layers.map((l, i) => {
6605
+ const lp = `layers.${i}`;
6606
+ if (pinnedZero(`${lp}.opacity`, l.opacity, ["layers", lp])) return true;
6607
+ const g = l.group !== void 0 && spec.groups && Object.prototype.hasOwnProperty.call(spec.groups, l.group) ? spec.groups[l.group] : void 0;
6608
+ return !!g && pinnedZero(`groups.${l.group}.opacity`, g.opacity, ["groups", `groups.${l.group}`]);
6460
6609
  });
6610
+ return out.some(Boolean) ? out : null;
6461
6611
  }
6462
- var REGION_NAMES = [
6463
- "top-left",
6464
- "top-center",
6465
- "top-right",
6466
- "mid-left",
6467
- "center",
6468
- "mid-right",
6469
- "bottom-left",
6470
- "bottom-center",
6471
- "bottom-right"
6472
- ];
6473
- function regionMeans(grid) {
6474
- const out = new Array(9).fill(0);
6475
- const cnt = new Array(9).fill(0);
6476
- for (let r = 0; r < grid.rows; r++) {
6477
- for (let c = 0; c < grid.cols; c++) {
6478
- const idx = r * grid.cols + c;
6479
- const region = Math.min(2, Math.floor(r / grid.rows * 3)) * 3 + Math.min(2, Math.floor(c / grid.cols * 3));
6480
- out[region] += Math.abs(grid.cells[idx] - (grid.backgroundCells ? grid.backgroundCells[idx] : grid.background[r]));
6481
- cnt[region]++;
6612
+ function adviseSequence(seq, viewport = { width: 1920, height: 1080 }) {
6613
+ const warnings = [];
6614
+ for (let i = 0; i < seq.segments.length; i++) {
6615
+ const seg = seq.segments[i];
6616
+ const timed = !!seg.scene.timeline && seg.duration !== void 0;
6617
+ const tSample = timed ? Math.min(COHESION_T, seg.duration) : void 0;
6618
+ const segWarnings = adviseSpec(seg.scene, viewport, tSample === void 0 ? {} : { t: tSample });
6619
+ const at = tSample === void 0 ? "" : ` (judged ${(tSample / 1e3).toFixed(1)} s into this ${(seg.duration / 1e3).toFixed(1)} s segment)`;
6620
+ for (const w of segWarnings) {
6621
+ warnings.push({ path: `segments[${i}].scene.${w.path}`, code: w.code, message: w.message + at, boxes: w.boxes });
6482
6622
  }
6483
6623
  }
6484
- return out.map((v, i) => cnt[i] ? v / cnt[i] : 0);
6485
- }
6486
- function diffScenes(a, b, opts = {}) {
6487
- const t = opts.t ?? 5e3;
6488
- const gridA = luminanceGrid(a, opts);
6489
- const gridB = luminanceGrid(b, opts);
6490
- const regsA = regionMeans(gridA);
6491
- const regsB = regionMeans(gridB);
6492
- const domA = dominanceRanking(a, opts);
6493
- const domB = dominanceRanking(b, opts);
6494
- const nameOf = (d) => d.key ?? `layer-${d.layerIndex}`;
6495
- const names = /* @__PURE__ */ new Set([...domA.map(nameOf), ...domB.map(nameOf)]);
6496
- const dominance = [...names].map((key) => {
6497
- const inA = domA.find((d) => nameOf(d) === key);
6498
- const inB = domB.find((d) => nameOf(d) === key);
6499
- return {
6500
- key,
6501
- rankA: inA?.rank ?? null,
6502
- rankB: inB?.rank ?? null,
6503
- shareA: inA?.share ?? 0,
6504
- shareB: inB?.share ?? 0
6505
- };
6506
- }).sort((x, y) => Math.abs(y.shareB - y.shareA) - Math.abs(x.shareB - x.shareA));
6507
- const codesA = new Set(adviseSpec(a).map((w) => w.code));
6508
- const codesB = new Set(adviseSpec(b).map((w) => w.code));
6509
- return {
6510
- t,
6511
- coverage: { a: gridA.coverage, b: gridB.coverage, delta: gridB.coverage - gridA.coverage },
6512
- meanLuminance: { a: gridA.meanLuminance, b: gridB.meanLuminance, delta: gridB.meanLuminance - gridA.meanLuminance },
6513
- balanceShift: gridA.centroid && gridB.centroid ? { dx: gridB.centroid.x - gridA.centroid.x, dy: gridB.centroid.y - gridA.centroid.y } : null,
6514
- regions: REGION_NAMES.map((region, i) => ({ region, a: regsA[i], b: regsB[i], delta: regsB[i] - regsA[i] })),
6515
- dominance,
6516
- advisories: {
6517
- added: [...codesB].filter((c) => !codesA.has(c)),
6518
- removed: [...codesA].filter((c) => !codesB.has(c))
6624
+ const fadeBound = seq.loop ? seq.segments.length : seq.segments.length - 1;
6625
+ const fades = seq.segments.slice(0, fadeBound).filter((s) => s.transition?.type === "fade").length;
6626
+ if (fades > 0) {
6627
+ const first = seq.segments.slice(0, fadeBound).findIndex((s) => s.transition?.type === "fade");
6628
+ warnings.push({
6629
+ path: `segments[${first}].transition`,
6630
+ code: "fade-degrades-on-low-tier",
6631
+ 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`
6632
+ });
6633
+ }
6634
+ for (let i = 0; i < seq.segments.length - 1; i++) {
6635
+ const tr = seq.segments[i].transition;
6636
+ const lumaA = backgroundLuma(resolveTimelineAt(seq.segments[i].scene, seq.segments[i].duration ?? 0));
6637
+ const lumaB = backgroundLuma(resolveTimelineAt(seq.segments[i + 1].scene, 0));
6638
+ const delta = Math.abs(lumaA - lumaB);
6639
+ if (delta > 0.5 && tr?.type !== "fade") {
6640
+ warnings.push({
6641
+ path: `segments[${i}]`,
6642
+ code: "boundary-luminance-jump",
6643
+ 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`
6644
+ });
6519
6645
  }
6520
- };
6521
- }
6522
- function perceiveScene(spec, opts = {}) {
6523
- spec = resolveTimelineAt(spec, opts.t ?? 5e3);
6524
- const grid = luminanceGrid(spec, opts);
6525
- return {
6526
- t: opts.t ?? 5e3,
6527
- braille: renderBrailleMap(grid),
6528
- density: renderDensityMap(grid),
6529
- coverage: grid.coverage,
6530
- meanLuminance: grid.meanLuminance,
6531
- centroid: grid.centroid,
6532
- rowProfile: grid.rowProfile,
6533
- colProfile: grid.colProfile,
6534
- dominance: dominanceRanking(spec, opts),
6535
- motion: motionStats(spec, opts),
6536
- form: layerCohesion(spec, opts),
6537
- text: textSprites(spec, opts),
6538
- advisories: adviseSpec(spec, opts.viewport ?? { width: 1920, height: 1080 }, { t: opts.t, seed: opts.seed })
6539
- };
6646
+ if (tr?.type === "morph") {
6647
+ const sigA = structuralSignature(seq.segments[i].scene);
6648
+ const sigB = structuralSignature(seq.segments[i + 1].scene);
6649
+ if (sigA !== sigB) {
6650
+ warnings.push({
6651
+ path: `segments[${i}].transition`,
6652
+ code: "morph-structural-mismatch",
6653
+ message: `segments ${i}\u2192${i + 1} differ structurally: morph will fall back to cut`
6654
+ });
6655
+ } 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" })) {
6656
+ warnings.push({
6657
+ path: `segments[${i}].transition`,
6658
+ code: "morph-nothing-morphable",
6659
+ 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`
6660
+ });
6661
+ }
6662
+ }
6663
+ }
6664
+ return warnings;
6540
6665
  }
6541
- var bedKey = (key, layerIndex) => `bed:${key ?? layerIndex}`;
6542
- function perceiveSequenceFrame(seq, T, opts = {}) {
6543
- const { releasedBelow, ...gridOpts } = opts;
6544
- const r = resolveSegment(seq, T, { releasedBelow });
6545
- const seg = seq.segments[r.index];
6546
- const segment = { index: r.index, key: seg.key, localT: r.localT, ...r.held ? { held: true } : {} };
6547
- const segOpts = { ...gridOpts, t: r.localT, seed: gridOpts.seed ?? segmentRenderSeed(seq, r.index) };
6548
- if (!seq.bed) return { ...perceiveScene(seg.scene, segOpts), segment, bed: false };
6549
- const bedOpts = { ...gridOpts, t: T, seed: gridOpts.seed ?? bedRenderSeed(seq) };
6550
- const viewport = gridOpts.viewport ?? { width: 1920, height: 1080 };
6551
- const ink = { ...seg.scene, background: { type: "solid", color: "#000000" } };
6552
- delete ink.ghosting;
6553
- const inkOverBed = { ...ink, background: seq.bed.background ?? { type: "solid", color: "#05050a" } };
6554
- const bedGrid = luminanceGrid(seq.bed, bedOpts);
6555
- const inkGrid = luminanceGrid(ink, segOpts);
6556
- const cells = bedGrid.cells.map((v, i) => Math.min(1, v + inkGrid.cells[i]));
6557
- const grid = finishGrid(bedGrid.cols, bedGrid.rows, cells, bedGrid.background, bedGrid.backgroundCells);
6558
- const bedLayers = seq.bed.layers.length;
6559
- const dominance = rankDominance([
6560
- ...rawDominance(seq.bed, bedOpts).map((d) => ({ ...d, key: bedKey(d.key, d.layerIndex) })),
6561
- ...rawDominance(inkOverBed, segOpts).map((d) => ({ ...d, layerIndex: d.layerIndex + bedLayers }))
6562
- ]);
6563
- const shift = (x) => ({ ...x, layerIndex: x.layerIndex + bedLayers });
6564
- const prefix = (x) => ({ ...x, key: bedKey(x.key, x.layerIndex) });
6666
+
6667
+ // src/describe.ts
6668
+ import { createRng as createRng4 } from "@idle-screens/core";
6669
+ function describeScene(spec, options = {}) {
6670
+ const { width: w, height: h } = options.viewport ?? { width: 1920, height: 1080 };
6671
+ const seed = options.seed ?? spec.seed ?? 42;
6672
+ const times = options.times ?? [0, 5e3, 15e3];
6673
+ const { scale, countScale } = entityScales(spec, w, h);
6674
+ const rng = createRng4(seed);
6675
+ const builtLayers = spec.layers.map((layer) => ({
6676
+ layer,
6677
+ entities: buildEntities(layer, rng, w, h, scale, countScale)
6678
+ }));
6679
+ const snapshots = times.map((t) => {
6680
+ const layerSnapshots = builtLayers.map(({ layer, entities }) => {
6681
+ const positions = entities.map((e) => positionAt(e, t, w, h));
6682
+ const cx = positions.length > 0 ? positions.reduce((s, p) => s + p.x, 0) / positions.length : 0;
6683
+ const cy = positions.length > 0 ? positions.reduce((s, p) => s + p.y, 0) / positions.length : 0;
6684
+ let pixArea = 0;
6685
+ for (const e of entities) {
6686
+ const r = e.size / 2;
6687
+ pixArea += layer.sprite.kind === "circle" ? Math.PI * r * r : layer.sprite.kind === "polygon" ? polygonArea(polygonPoints(layer.sprite, r)) : e.size * e.size;
6688
+ }
6689
+ const coverage = pixArea / (w * h);
6690
+ const meanAlpha = entities.length > 0 ? entities.reduce((s, e) => s + e.alpha, 0) / entities.length : 0;
6691
+ let linksDrawn = null;
6692
+ let linksExpected = null;
6693
+ let connectedComponents = null;
6694
+ let isolatedNodes = null;
6695
+ if (layer.links) {
6696
+ const maxDist = layer.links.maxDist * scale;
6697
+ const pairs = linkPairs(positions, layer.links.k, maxDist, layer.wrap !== false, w, h);
6698
+ linksDrawn = pairs.length;
6699
+ linksExpected = Math.min(entities.length * layer.links.k, entities.length * (entities.length - 1) / 2);
6700
+ const parent = Array.from({ length: entities.length }, (_, i) => i);
6701
+ const find = (x) => {
6702
+ while (parent[x] !== x) {
6703
+ parent[x] = parent[parent[x]];
6704
+ x = parent[x];
6705
+ }
6706
+ return x;
6707
+ };
6708
+ for (const [a, b] of pairs) {
6709
+ const ra = find(a);
6710
+ const rb = find(b);
6711
+ if (ra !== rb) parent[ra] = rb;
6712
+ }
6713
+ const roots = new Set(entities.map((_, i) => find(i)));
6714
+ connectedComponents = roots.size;
6715
+ const degree = new Uint16Array(entities.length);
6716
+ for (const [a, b] of pairs) {
6717
+ degree[a]++;
6718
+ degree[b]++;
6719
+ }
6720
+ isolatedNodes = degree.filter((d) => d === 0).length;
6721
+ }
6722
+ return {
6723
+ key: layer.key,
6724
+ count: entities.length,
6725
+ centroid: { x: +(cx / w).toFixed(3), y: +(cy / h).toFixed(3) },
6726
+ coverage: +coverage.toFixed(4),
6727
+ meanAlpha: +meanAlpha.toFixed(3),
6728
+ linksDrawn,
6729
+ linksExpected,
6730
+ connectedComponents,
6731
+ isolatedNodes
6732
+ };
6733
+ });
6734
+ const warnings = [];
6735
+ for (const ls of layerSnapshots) {
6736
+ const label = ls.key ?? `layer`;
6737
+ if (ls.coverage * ls.meanAlpha < 5e-4) {
6738
+ warnings.push(`${label}: effectively invisible (coverage\xD7alpha=${(ls.coverage * ls.meanAlpha).toFixed(5)})`);
6739
+ }
6740
+ if (ls.linksDrawn !== null && ls.linksExpected !== null && ls.linksExpected > 0) {
6741
+ const ratio = ls.linksDrawn / ls.linksExpected;
6742
+ if (ratio < 0.1) {
6743
+ warnings.push(`${label}: link starvation \u2014 ${ls.linksDrawn}/${ls.linksExpected} edges formed`);
6744
+ }
6745
+ }
6746
+ if (ls.isolatedNodes !== null && ls.count > 0 && ls.isolatedNodes / ls.count > 0.5) {
6747
+ warnings.push(`${label}: ${ls.isolatedNodes}/${ls.count} nodes isolated \u2014 graph is disconnected`);
6748
+ }
6749
+ }
6750
+ return { t, viewport: { w, h }, layers: layerSnapshots, warnings };
6751
+ });
6565
6752
  return {
6566
- t: r.localT,
6567
- braille: renderBrailleMap(grid),
6568
- density: renderDensityMap(grid),
6569
- coverage: grid.coverage,
6570
- meanLuminance: grid.meanLuminance,
6571
- centroid: grid.centroid,
6572
- rowProfile: grid.rowProfile,
6573
- colProfile: grid.colProfile,
6574
- dominance,
6575
- motion: [...motionStats(seq.bed, bedOpts).map(prefix), ...motionStats(ink, segOpts).map(shift)],
6576
- form: [...layerCohesion(seq.bed, bedOpts).map(prefix), ...layerCohesion(ink, segOpts).map(shift)],
6577
- text: [...textSprites(seq.bed, bedOpts).map(prefix), ...textSprites(ink, segOpts).map(shift)],
6578
- advisories: [
6579
- ...adviseSpec(seq.bed, viewport, { t: T, seed: bedOpts.seed }).map((w) => ({ ...w, path: `bed.${w.path}` })),
6580
- // inkOverBed's layers are the segment's (seed: segOpts.seed) but its
6581
- // background is the bed's (seq.bed.background) — a field there must be
6582
- // sampled with the bed's own seed, or a `role: 'read'` legibility
6583
- // advisory checks terrain that isn't what's actually behind the ink.
6584
- // `?? 42`: inkOverBed.seed is the SEGMENT's raw seed field (it's `ink`
6585
- // spread first), so adviseSpec's own `backgroundSeed ?? seed` fallback
6586
- // would substitute the segment's seed, not the bed's, if bedOpts.seed
6587
- // were left undefined — 42 is the same default `luminanceGrid` (and
6588
- // every other field seed fallback) converges on.
6589
- ...adviseSpec(inkOverBed, viewport, { t: r.localT, seed: segOpts.seed, backgroundSeed: bedOpts.seed ?? 42 })
6590
- ],
6591
- segment,
6592
- bed: true
6753
+ spec: { id: spec.id, label: spec.label, units: spec.units ?? "viewport", ...spec.finish ? { finish: spec.finish } : {} },
6754
+ snapshots
6593
6755
  };
6594
6756
  }
6595
6757
 
@@ -9083,6 +9245,7 @@ export {
9083
9245
  easeSmooth,
9084
9246
  emitEnvelope,
9085
9247
  emitWindow,
9248
+ entityScales,
9086
9249
  fieldAt,
9087
9250
  fieldOctaves,
9088
9251
  fieldRgb,
@@ -9124,6 +9287,7 @@ export {
9124
9287
  revealState,
9125
9288
  rgb255Luma,
9126
9289
  rotationAt,
9290
+ sceneColour,
9127
9291
  seamCauseOf,
9128
9292
  seamsWorthWarning,
9129
9293
  segmentRenderSeed,