@idle-screens/schema 3.16.0 → 3.17.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
@@ -500,46 +500,105 @@ function easeGlide(ease2, k) {
500
500
  }
501
501
  var STEER_EASES = /* @__PURE__ */ new Set(["in", "out", "spring"]);
502
502
  function structuralSignature(spec) {
503
- return JSON.stringify([
504
- spec.units,
505
- // Normalized against the same default every renderer uses — an omitted
506
- // referenceViewport and an explicit 1080 render identically, so they must
507
- // hash identically or a same-sizing morph gets rejected as structural.
508
- spec.referenceViewport ?? LIMITS.referenceViewport,
509
- spec.layers.map((l) => {
503
+ const f = structuralFields(spec);
504
+ return JSON.stringify([...f.root.map(([, v]) => v), f.layers.map((l) => l.map(([, v]) => v))]);
505
+ }
506
+ function structuralFields(spec) {
507
+ return {
508
+ root: [
509
+ ["units", spec.units],
510
+ // Normalized against the same default every renderer uses — an omitted
511
+ // referenceViewport and an explicit 1080 render identically, so they must
512
+ // hash identically or a same-sizing morph gets rejected as structural.
513
+ ["referenceViewport", spec.referenceViewport ?? LIMITS.referenceViewport]
514
+ ],
515
+ layers: spec.layers.map((l) => {
510
516
  const s = l.sprite;
511
517
  return [
512
- l.count,
513
- l.size,
514
- l.region,
515
- l.position,
516
- l.motion,
517
- l.wrap,
518
- l.flip,
519
- l.alpha,
520
- l.pulse,
521
- l.spin,
522
- l.grow,
523
- l.layout,
524
- l.emit,
525
- l.clock,
526
- l.sprite.kind,
518
+ ["count", l.count],
519
+ ["size", l.size],
520
+ ["region", l.region],
521
+ ["position", l.position],
522
+ ["motion", l.motion],
523
+ ["wrap", l.wrap],
524
+ ["flip", l.flip],
525
+ ["alpha", l.alpha],
526
+ ["pulse", l.pulse],
527
+ ["spin", l.spin],
528
+ ["grow", l.grow],
529
+ ["layout", l.layout],
530
+ ["emit", l.emit],
531
+ ["clock", l.clock],
532
+ ["sprite.kind", l.sprite.kind],
527
533
  // Dimensional draws baked into entities: radius (circle/ring), length
528
534
  // (streak), width+aspect (rect), palette pick (all shaped sprites).
529
- s.radius,
530
- s.length,
531
- l.sprite.kind === "rect" ? [s.width, s.aspect] : l.sprite.kind === "bar" ? [s.length, s.thickness] : void 0,
532
- Array.isArray(s.colors) ? s.colors.length : void 0,
533
- s.colorWeights,
534
- l.sprite.kind === "emoji" ? [l.sprite.glyphs.length, l.sprite.cycle?.period] : l.sprite.kind === "text" ? [l.sprite.strings.length, l.sprite.cycle?.period] : l.sprite.kind === "textBlock" ? l.sprite.anchor ? [l.sprite.fontSize, l.sprite.anchor] : [l.sprite.fontSize] : void 0,
535
+ ["sprite.radius", s.radius],
536
+ ["sprite.length", s.length],
537
+ l.sprite.kind === "rect" ? [["sprite.width", "sprite.aspect"], [s.width, s.aspect]] : l.sprite.kind === "bar" ? [["sprite.length", "sprite.thickness"], [s.length, s.thickness]] : ["sprite.dimensions", void 0],
538
+ ["sprite.colors.length", Array.isArray(s.colors) ? s.colors.length : void 0],
539
+ ["sprite.colorWeights", s.colorWeights],
540
+ l.sprite.kind === "emoji" ? [["sprite.glyphs.length", "sprite.cycle.period"], [l.sprite.glyphs.length, l.sprite.cycle?.period]] : l.sprite.kind === "text" ? [["sprite.strings.length", "sprite.cycle.period"], [l.sprite.strings.length, l.sprite.cycle?.period]] : l.sprite.kind === "textBlock" ? [["sprite.fontSize", "sprite.anchor"], l.sprite.anchor ? [l.sprite.fontSize, l.sprite.anchor] : [l.sprite.fontSize]] : ["sprite.variants", void 0],
535
541
  // Static rotation is baked into entities (a range draws a seeded
536
542
  // angle). Appended only when set — same rule as `anchor` above.
537
- ...l.rotate !== void 0 ? [l.rotate] : [],
543
+ ...l.rotate !== void 0 ? [["rotate", l.rotate]] : [],
538
544
  // `rock` is baked per entity like `pulse` (and draws a seeded phase).
539
- ...l.rock !== void 0 ? [{ rock: l.rock }] : []
545
+ ...l.rock !== void 0 ? [["rock", { rock: l.rock }]] : []
540
546
  ];
541
547
  })
542
- ]);
548
+ };
549
+ }
550
+ function showValue(v) {
551
+ if (v === void 0) return "(unset)";
552
+ if (typeof v === "string") return v;
553
+ const j = JSON.stringify(v);
554
+ return j.length > 40 ? `${j.slice(0, 37)}\u2026` : j;
555
+ }
556
+ function leafDifferences(path, a, b, out) {
557
+ if (JSON.stringify(a) === JSON.stringify(b)) return;
558
+ const isRec = (x) => !!x && typeof x === "object" && !Array.isArray(x);
559
+ if (isRec(a) && isRec(b)) {
560
+ const before = out.length;
561
+ for (const k of /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)])) leafDifferences(`${path}.${k}`, a[k], b[k], out);
562
+ if (out.length === before) out.push(`${path}: the same fields in a different key order`);
563
+ return;
564
+ }
565
+ out.push(`${path}: ${showValue(a)} \u2192 ${showValue(b)}`);
566
+ }
567
+ function namedFields(fields) {
568
+ const m = /* @__PURE__ */ new Map();
569
+ for (const [name, value] of fields) {
570
+ if (typeof name === "string") {
571
+ m.set(name, value);
572
+ continue;
573
+ }
574
+ const vs = Array.isArray(value) ? value : [];
575
+ name.forEach((n, i) => m.set(n, vs[i]));
576
+ }
577
+ return m;
578
+ }
579
+ function structuralDifferences(a, b) {
580
+ const fa = structuralFields(a);
581
+ const fb = structuralFields(b);
582
+ const out = [];
583
+ const ra = namedFields(fa.root);
584
+ const rb = namedFields(fb.root);
585
+ for (const k of /* @__PURE__ */ new Set([...ra.keys(), ...rb.keys()])) leafDifferences(k, ra.get(k), rb.get(k), out);
586
+ if (fa.layers.length !== fb.layers.length) out.push(`layers.length: ${fa.layers.length} \u2192 ${fb.layers.length}`);
587
+ for (let i = 0; i < Math.min(fa.layers.length, fb.layers.length); i++) {
588
+ const la = namedFields(fa.layers[i]);
589
+ const lb = namedFields(fb.layers[i]);
590
+ const kindChanged = la.get("sprite.kind") !== lb.get("sprite.kind");
591
+ for (const k of /* @__PURE__ */ new Set([...la.keys(), ...lb.keys()])) {
592
+ if (kindChanged && k.startsWith("sprite.") && k !== "sprite.kind") continue;
593
+ leafDifferences(`layers[${i}].${k}`, la.get(k), lb.get(k), out);
594
+ }
595
+ }
596
+ return out;
597
+ }
598
+ function describeStructuralDifferences(a, b, max = 3) {
599
+ const d = structuralDifferences(a, b);
600
+ if (d.length === 0) return "";
601
+ return d.length <= max ? d.join("; ") : `${d.slice(0, max).join("; ")}; +${d.length - max} more`;
543
602
  }
544
603
 
545
604
  // src/sequence.ts
@@ -1602,6 +1661,11 @@ function pathDrawCost(s) {
1602
1661
  return c;
1603
1662
  }
1604
1663
 
1664
+ // src/validate-hints.ts
1665
+ var SCALAR_ONLY = /not a \[min,max\] range/;
1666
+ var SCALAR_BOUND = /^must be (?:>|an? (?:positive |single |finite |whole )?(?:number|integer)\b|between -?\d|-?\d+(?:\.\d+)?\.\.-?\d+(?:\.\d+)?(?: \(|$))/;
1667
+ var scalarWanted = (m) => SCALAR_ONLY.test(m) || !m.includes("[") && SCALAR_BOUND.test(m);
1668
+
1605
1669
  // src/validate.ts
1606
1670
  var HEX2 = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
1607
1671
  var isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
@@ -1734,6 +1798,15 @@ var KNOWN_BG_SOLID = /* @__PURE__ */ new Set(["type", "color"]);
1734
1798
  var KNOWN_BG_GRADIENT = /* @__PURE__ */ new Set(["type", "stops", "band", "drift"]);
1735
1799
  var KNOWN_BG_FIELD = /* @__PURE__ */ new Set(["type", "scale", "octaves", "warp", "quantize", "bands", "drift", "seed"]);
1736
1800
  var KNOWN_FIELD_DRIFT = /* @__PURE__ */ new Set(["period", "amount"]);
1801
+ var KNOWN_BG_STOP = /* @__PURE__ */ new Set(["at", "color"]);
1802
+ var KNOWN_BAND = /* @__PURE__ */ new Set(["color", "height"]);
1803
+ var KNOWN_BG_DRIFT = /* @__PURE__ */ new Set(["period", "amount"]);
1804
+ var KNOWN_PATH_STROKE = /* @__PURE__ */ new Set(["color", "width", "opacity", "join", "cap", "draw"]);
1805
+ var KNOWN_PATH_GRADIENT = {
1806
+ linear: /* @__PURE__ */ new Set(["type", "from", "to", "stops", "units"]),
1807
+ radial: /* @__PURE__ */ new Set(["type", "center", "radius", "focus", "stops", "units"])
1808
+ };
1809
+ var KNOWN_PATH_STOP = /* @__PURE__ */ new Set(["at", "color", "opacity"]);
1737
1810
  var LAYER_PROPS_ON_SPRITE = /* @__PURE__ */ new Set(["blend", "trail", "alpha", "pulse", "spin", "grow", "region", "links", "flip", "wrap", "key", "emit", "clock", "life", "layout", "rotate", "rock"]);
1738
1811
  function unknownKeys(obj, known) {
1739
1812
  return Object.keys(obj).filter((k) => !known.has(k));
@@ -1751,6 +1824,50 @@ function positiveScalar(v, path, err, msg = "must be > 0") {
1751
1824
  if (Array.isArray(v)) err(path, "must be a single number > 0, not a [min,max] range");
1752
1825
  else if (!isNum(v) || v <= 0) err(path, msg);
1753
1826
  }
1827
+ function valueAtPath(root, path) {
1828
+ let v = root;
1829
+ for (const seg of path.match(/[^.[\]]+/g) ?? []) {
1830
+ if (Array.isArray(v) && /^\d+$/.test(seg)) v = v[Number(seg)];
1831
+ else if (isObj(v)) v = v[seg];
1832
+ else return void 0;
1833
+ }
1834
+ return v;
1835
+ }
1836
+ function echo(v) {
1837
+ if (isNum(v)) return String(v);
1838
+ if (Array.isArray(v) && v.length <= 4 && v.every(isNum)) return `[${v.join(", ")}]`;
1839
+ const s = JSON.stringify(v) ?? String(v);
1840
+ return s.length > 48 ? `${s.slice(0, 45)}\u2026` : s;
1841
+ }
1842
+ var RANGE_WANTED = /^must be a \[min,max\] range/;
1843
+ function shapeHint(spec, path, message) {
1844
+ if (message.includes(" \u2014 got ")) return "";
1845
+ const v = valueAtPath(spec, path);
1846
+ if (v === void 0) return "";
1847
+ if (RANGE_WANTED.test(message)) {
1848
+ if (isNum(v)) {
1849
+ const fits = message.includes("within 0..1") ? v >= 0 && v <= 1 : message.includes(">= 0") ? v >= 0 : !message.includes("positive") || v > 0;
1850
+ return fits ? ` \u2014 got ${v}; write [${v}, ${v}]` : ` \u2014 got ${v}; a range is two numbers, [min, max]`;
1851
+ }
1852
+ if (isObj(v) && ("min" in v || "max" in v)) {
1853
+ return ` \u2014 got {min, max}; write [${isNum(v.min) ? v.min : "min"}, ${isNum(v.max) ? v.max : "max"}]`;
1854
+ }
1855
+ if (Array.isArray(v) && v.length === 2 && isNum(v[0]) && isNum(v[1]) && v[0] > v[1]) {
1856
+ return ` \u2014 got ${echo(v)}; the smaller number comes first: write [${v[1]}, ${v[0]}]`;
1857
+ }
1858
+ return ` \u2014 got ${echo(v)}`;
1859
+ }
1860
+ if (Array.isArray(v) && scalarWanted(message)) {
1861
+ const segs = path.match(/[^.[\]]+/g) ?? [];
1862
+ const key = segs[segs.length - 1] ?? path;
1863
+ const parentKey = segs[segs.length - 2];
1864
+ const parent = valueAtPath(spec, path.slice(0, path.lastIndexOf(".")));
1865
+ const kind = isObj(parent) && isStr(parent.kind) ? parent.kind : void 0;
1866
+ const noun = kind ? `${/^[aeiou]/i.test(kind) ? "an" : "a"} ${kind}'s \`${key}\`` : parentKey && !/^\d+$/.test(parentKey) && parentKey !== "sprite" ? `\`${parentKey}.${key}\`` : `\`${key}\``;
1867
+ return ` \u2014 got ${echo(v)}; ${noun} is one number`;
1868
+ }
1869
+ return "";
1870
+ }
1754
1871
  function normalizeColors(spec) {
1755
1872
  if (!isObj(spec) || !Array.isArray(spec.layers)) return;
1756
1873
  for (const layer of spec.layers) {
@@ -1763,9 +1880,37 @@ function normalizeColors(spec) {
1763
1880
  }
1764
1881
  }
1765
1882
  }
1883
+ var LAYER_SIZED_SPRITES = /* @__PURE__ */ new Set(["emoji", "text"]);
1884
+ var SPRITE_SIZE_FIELD = {
1885
+ circle: "radius",
1886
+ ring: "radius",
1887
+ polygon: "radius",
1888
+ streak: "length",
1889
+ stroke: "length",
1890
+ rect: "width",
1891
+ bar: "length and thickness",
1892
+ textBlock: "fontSize"
1893
+ };
1894
+ var SPRITE_SIZE_MISPLACED = "'size' belongs on the layer, not inside sprite \u2014 move it up one level";
1895
+ function aliasSpriteSize(spec) {
1896
+ const out = [];
1897
+ if (!isObj(spec) || !Array.isArray(spec.layers)) return out;
1898
+ spec.layers.forEach((layer, i) => {
1899
+ if (!isObj(layer) || !isObj(layer.sprite)) return;
1900
+ const sp = layer.sprite;
1901
+ if (sp.size === void 0 || !LAYER_SIZED_SPRITES.has(sp.kind) || layer.size !== void 0) return;
1902
+ layer.size = sp.size;
1903
+ delete sp.size;
1904
+ out.push({ path: `layers[${i}].sprite.size`, code: "misplaced-property", message: `${SPRITE_SIZE_MISPLACED} (applied as layers[${i}].size this time)` });
1905
+ });
1906
+ return out;
1907
+ }
1766
1908
  function validateSpec(spec) {
1767
1909
  normalizeColors(spec);
1768
- return validateSpecCore(spec);
1910
+ const repaired = aliasSpriteSize(spec);
1911
+ const result = validateSpecCore(spec);
1912
+ if (repaired.length > 0) result.warnings = [...repaired, ...result.warnings ?? []];
1913
+ return result;
1769
1914
  }
1770
1915
  function ignoredPropertyPaths(spec) {
1771
1916
  if (!isObj(spec)) return [];
@@ -1819,7 +1964,7 @@ function specPathsValid(spec, paths) {
1819
1964
  function validateSpecCore(spec, checkApplied = true) {
1820
1965
  const errors = [];
1821
1966
  const warnings = [];
1822
- const err = (path, message) => void errors.push({ path, message });
1967
+ const err = (path, message) => void errors.push({ path, message: message + shapeHint(spec, path, message) });
1823
1968
  const warn = (path, code, message) => void warnings.push({ path, code, message });
1824
1969
  if (!isObj(spec)) return { valid: false, errors: [{ path: "", message: "spec must be an object" }], warnings: [] };
1825
1970
  if (spec.schemaVersion !== SCHEMA_VERSION) err("schemaVersion", `must be ${SCHEMA_VERSION}`);
@@ -2048,9 +2193,12 @@ function validateTimelineKeys(spec, errOuter, warn, checkApplied) {
2048
2193
  return;
2049
2194
  }
2050
2195
  }
2051
- function color(v, path, err) {
2052
- if (!isStr(v) || !HEX2.test(v)) err(path, "must be a hex colour like #0a3a52");
2196
+ function color(v, path, err, missing = "is required (a hex colour like #0a3a52)") {
2197
+ if (v === void 0) err(path, missing);
2198
+ else if (!isStr(v) || !HEX2.test(v)) err(path, "must be a hex colour like #0a3a52");
2053
2199
  }
2200
+ var SPRITE_COLOR_MISSING = "`color` is required (or a `colors` palette)";
2201
+ var POLYGON_COLOR_MISSING = `${SPRITE_COLOR_MISSING}; parts without their own colour inherit it, and a gradient or stroke does not stand in for it`;
2054
2202
  function validateFinish(f, path, err, warn) {
2055
2203
  if (!isObj(f)) return err(path, "must be an object");
2056
2204
  for (const k of ["grain", "dither"]) {
@@ -2075,6 +2223,7 @@ function validateBackground(bg, err, warn) {
2075
2223
  bg.stops.forEach((s, i) => {
2076
2224
  if (!isObj(s) || !isNum(s.at) || s.at < 0 || s.at > 1) err(`background.stops[${i}].at`, "must be 0..1");
2077
2225
  color(isObj(s) ? s.color : void 0, `background.stops[${i}].color`, err);
2226
+ if (isObj(s)) for (const k of unknownKeys(s, KNOWN_BG_STOP)) warn(`background.stops[${i}].${k}`, "unknown-property", `unknown stop property '${k}' \u2014 will be ignored`);
2078
2227
  });
2079
2228
  }
2080
2229
  if (bg.drift !== void 0) {
@@ -2086,6 +2235,7 @@ function validateBackground(bg, err, warn) {
2086
2235
  if (bg.drift.amount !== void 0 && (!isNum(bg.drift.amount) || bg.drift.amount <= 0 || bg.drift.amount > LIMITS.maxDriftAmount)) {
2087
2236
  err("background.drift.amount", `must be > 0 and <= ${LIMITS.maxDriftAmount}`);
2088
2237
  }
2238
+ for (const k of unknownKeys(bg.drift, KNOWN_BG_DRIFT)) warn(`background.drift.${k}`, "unknown-property", `unknown drift property '${k}' \u2014 will be ignored`);
2089
2239
  }
2090
2240
  }
2091
2241
  if (bg.band !== void 0) {
@@ -2093,6 +2243,7 @@ function validateBackground(bg, err, warn) {
2093
2243
  else {
2094
2244
  color(bg.band.color, "background.band.color", err);
2095
2245
  positiveScalar(bg.band.height, "background.band.height", err);
2246
+ for (const k of unknownKeys(bg.band, KNOWN_BAND)) warn(`background.band.${k}`, "unknown-property", `unknown band property '${k}' \u2014 will be ignored`);
2096
2247
  }
2097
2248
  }
2098
2249
  for (const k of unknownKeys(bg, KNOWN_BG_GRADIENT)) {
@@ -2160,6 +2311,15 @@ function validateLayer(layer, path, err, warn, spec) {
2160
2311
  if (layer.size !== void 0 && (!isRange(layer.size) || layer.size[0] <= 0)) {
2161
2312
  err(`${path}.size`, rangeMsg(spec));
2162
2313
  }
2314
+ if (isObj(layer.sprite) && layer.sprite.size !== void 0) {
2315
+ const kind = layer.sprite.kind;
2316
+ if (LAYER_SIZED_SPRITES.has(kind)) {
2317
+ warn(`${path}.sprite.size`, "misplaced-property", layer.size !== void 0 ? `${SPRITE_SIZE_MISPLACED} \u2014 layer.size is also set, so the layer's value wins and sprite.size is ignored` : SPRITE_SIZE_MISPLACED);
2318
+ } else {
2319
+ const field = SPRITE_SIZE_FIELD[kind];
2320
+ warn(`${path}.sprite.size`, "unknown-property", field ? `unknown sprite property 'size' \u2014 will be ignored; a ${kind} sprite is sized by its ${field}` : `unknown sprite property 'size' \u2014 will be ignored`);
2321
+ }
2322
+ }
2163
2323
  if (layer.wrap !== void 0 && typeof layer.wrap !== "boolean") err(`${path}.wrap`, "must be a boolean");
2164
2324
  if (layer.flip !== void 0 && typeof layer.flip !== "boolean") err(`${path}.flip`, "must be a boolean");
2165
2325
  if (layer.alpha !== void 0 && (!isRange(layer.alpha) || layer.alpha[0] < 0 || layer.alpha[1] > 1)) {
@@ -2331,7 +2491,8 @@ function validateLayer(layer, path, err, warn, spec) {
2331
2491
  const sp = layer.sprite;
2332
2492
  if (isObj(sp) && isNum(layer.count)) {
2333
2493
  const variants = sp.kind === "text" && Array.isArray(sp.strings) ? sp.strings.length : sp.kind === "emoji" && Array.isArray(sp.glyphs) ? sp.glyphs.length : sp.kind === "bar" && Array.isArray(sp.values) ? sp.values.length : null;
2334
- if (variants !== null && variants !== layer.count) {
2494
+ const atmosphere = sp.kind === "text" && sp.role === "atmosphere";
2495
+ if (variants !== null && variants !== layer.count && !atmosphere) {
2335
2496
  warn(`${path}.count`, "list-length-mismatch", `count is ${layer.count} but the sprite carries ${variants} ${sp.kind === "bar" ? "values" : "variants"} \u2014 a ${lay.type} layout reads them in order, so they cycle or go unused`);
2336
2497
  }
2337
2498
  }
@@ -2500,19 +2661,19 @@ function validateSprite(sprite, path, err, warn, spec) {
2500
2661
  } else if (sprite.kind === "circle") {
2501
2662
  knownSet = KNOWN_CIRCLE;
2502
2663
  if (!isRange(sprite.radius) || sprite.radius[0] <= 0) err(`${path}.radius`, rangeMsg(spec));
2503
- color(sprite.color, `${path}.color`, err);
2664
+ color(sprite.color, `${path}.color`, err, SPRITE_COLOR_MISSING);
2504
2665
  if (sprite.soft !== void 0 && typeof sprite.soft !== "boolean") err(`${path}.soft`, "must be a boolean");
2505
2666
  validatePalette(sprite, path, err);
2506
2667
  } else if (sprite.kind === "ring") {
2507
2668
  knownSet = KNOWN_RING;
2508
2669
  if (!isRange(sprite.radius) || sprite.radius[0] <= 0) err(`${path}.radius`, rangeMsg(spec));
2509
- color(sprite.color, `${path}.color`, err);
2670
+ color(sprite.color, `${path}.color`, err, SPRITE_COLOR_MISSING);
2510
2671
  if (sprite.width !== void 0) positiveScalar(sprite.width, `${path}.width`, err);
2511
2672
  validatePalette(sprite, path, err);
2512
2673
  } else if (sprite.kind === "streak") {
2513
2674
  knownSet = KNOWN_STREAK;
2514
2675
  if (!isRange(sprite.length) || sprite.length[0] <= 0) err(`${path}.length`, rangeMsg(spec));
2515
- color(sprite.color, `${path}.color`, err);
2676
+ color(sprite.color, `${path}.color`, err, SPRITE_COLOR_MISSING);
2516
2677
  if (sprite.width !== void 0) positiveScalar(sprite.width, `${path}.width`, err);
2517
2678
  validatePalette(sprite, path, err);
2518
2679
  } else if (sprite.kind === "rect") {
@@ -2524,7 +2685,7 @@ function validateSprite(sprite, path, err, warn, spec) {
2524
2685
  if (sprite.feather !== void 0 && (!isNum(sprite.feather) || sprite.feather < 0 || sprite.feather > 1)) {
2525
2686
  err(`${path}.feather`, "must be 0..1 (fraction of the half-size that fades out)");
2526
2687
  }
2527
- color(sprite.color, `${path}.color`, err);
2688
+ color(sprite.color, `${path}.color`, err, SPRITE_COLOR_MISSING);
2528
2689
  validatePalette(sprite, path, err);
2529
2690
  } else if (sprite.kind === "bar") {
2530
2691
  knownSet = KNOWN_BAR;
@@ -2537,7 +2698,7 @@ function validateSprite(sprite, path, err, warn, spec) {
2537
2698
  if (sprite.direction !== void 0 && !["right", "left", "up", "down"].includes(sprite.direction)) {
2538
2699
  err(`${path}.direction`, "must be 'right' | 'left' | 'up' | 'down'");
2539
2700
  }
2540
- color(sprite.color, `${path}.color`, err);
2701
+ color(sprite.color, `${path}.color`, err, SPRITE_COLOR_MISSING);
2541
2702
  validatePalette(sprite, path, err);
2542
2703
  } else if (sprite.kind === "polygon") {
2543
2704
  knownSet = KNOWN_POLYGON;
@@ -2581,7 +2742,7 @@ function validateSprite(sprite, path, err, warn, spec) {
2581
2742
  if (sprite.origin !== void 0 && !(Array.isArray(sprite.origin) && sprite.origin.length === 2 && sprite.origin.every((v) => isNum(v) && Math.abs(v) <= 1))) err(`${path}.origin`, "must be [x, y] in the unit box (-1..1): the point placed at the position");
2582
2743
  }
2583
2744
  if (sprite.soft !== void 0 && typeof sprite.soft !== "boolean") err(`${path}.soft`, "must be a boolean");
2584
- color(sprite.color, `${path}.color`, err);
2745
+ color(sprite.color, `${path}.color`, err, POLYGON_COLOR_MISSING);
2585
2746
  validatePalette(sprite, path, err);
2586
2747
  } else if (sprite.kind === "stroke") {
2587
2748
  knownSet = KNOWN_STROKE;
@@ -2592,7 +2753,7 @@ function validateSprite(sprite, path, err, warn, spec) {
2592
2753
  if (sprite.taper !== void 0 && typeof sprite.taper !== "boolean") err(`${path}.taper`, "must be a boolean");
2593
2754
  if (sprite.orient !== void 0 && typeof sprite.orient !== "boolean") err(`${path}.orient`, "must be a boolean");
2594
2755
  if (sprite.draw !== void 0) validateDraw(sprite.draw, `${path}.draw`, err, warn);
2595
- color(sprite.color, `${path}.color`, err);
2756
+ color(sprite.color, `${path}.color`, err, SPRITE_COLOR_MISSING);
2596
2757
  validatePalette(sprite, path, err);
2597
2758
  } else if (sprite.kind === "textBlock") {
2598
2759
  knownSet = KNOWN_TEXT_BLOCK;
@@ -2696,6 +2857,7 @@ function validateSprite(sprite, path, err, warn, spec) {
2696
2857
  return;
2697
2858
  }
2698
2859
  for (const k of unknownKeys(sprite, knownSet)) {
2860
+ if (k === "size") continue;
2699
2861
  if (LAYER_PROPS_ON_SPRITE.has(k)) {
2700
2862
  warn(`${path}.${k}`, "misplaced-property", `'${k}' belongs on the layer, not inside sprite \u2014 move it up one level`);
2701
2863
  } else {
@@ -2903,10 +3065,10 @@ function validatePathPiece(piece, path, err, warn) {
2903
3065
  function validatePiecePaint(piece, path, err, warn) {
2904
3066
  if (piece.fill !== void 0 && typeof piece.fill !== "boolean") err(`${path}.fill`, "must be a boolean");
2905
3067
  if (piece.fill === false && piece.stroke === void 0) err(`${path}.fill`, "fill: false draws nothing without a stroke");
2906
- if (piece.gradient !== void 0) validatePathGradient(piece.gradient, `${path}.gradient`, err);
3068
+ if (piece.gradient !== void 0) validatePathGradient(piece.gradient, `${path}.gradient`, err, warn);
2907
3069
  if (piece.stroke !== void 0) validatePathStroke(piece.stroke, `${path}.stroke`, err, warn);
2908
3070
  }
2909
- function validatePathGradient(g, path, err) {
3071
+ function validatePathGradient(g, path, err, warn) {
2910
3072
  if (!isObj(g)) return err(path, "must be { type: 'linear', from, to, stops } or { type: 'radial', center, radius, stops }");
2911
3073
  if (g.type === "linear") {
2912
3074
  if (!isPair(g.from)) err(`${path}.from`, "must be [x, y] in unit-box coordinates (-2..2)");
@@ -2918,6 +3080,7 @@ function validatePathGradient(g, path, err) {
2918
3080
  } else {
2919
3081
  return err(`${path}.type`, "must be 'linear' | 'radial'");
2920
3082
  }
3083
+ for (const k of unknownKeys(g, KNOWN_PATH_GRADIENT[g.type])) warn(`${path}.${k}`, "unknown-property", `unknown ${g.type} gradient property '${k}' \u2014 will be ignored`);
2921
3084
  if (g.units !== void 0 && g.units !== "box" && g.units !== "part") err(`${path}.units`, "must be 'box' (the drawing's unit box, default) | 'part' (the part's own box, -1..1)");
2922
3085
  if (!Array.isArray(g.stops) || g.stops.length < 2 || g.stops.length > 8) return err(`${path}.stops`, "must be 2..8 stops");
2923
3086
  let last = -Infinity;
@@ -2927,16 +3090,18 @@ function validatePathGradient(g, path, err) {
2927
3090
  else last = st.at;
2928
3091
  color(st.color, `${path}.stops[${i}].color`, err);
2929
3092
  if (st.opacity !== void 0 && (!isNum(st.opacity) || st.opacity < 0 || st.opacity > 1)) err(`${path}.stops[${i}].opacity`, "must be 0..1");
3093
+ for (const k of unknownKeys(st, KNOWN_PATH_STOP)) warn(`${path}.stops[${i}].${k}`, "unknown-property", `unknown stop property '${k}' \u2014 will be ignored`);
2930
3094
  });
2931
3095
  }
2932
3096
  function validatePathStroke(st, path, err, warn) {
2933
3097
  if (!isObj(st)) return err(path, "must be { color, width, opacity?, join?, cap?, draw? }");
2934
- color(st.color, `${path}.color`, err);
3098
+ color(st.color, `${path}.color`, err, "`color` is required on a stroke (a hex colour like #0a3a52); a stroke does not inherit the entity colour");
2935
3099
  if (!isNum(st.width) || st.width <= 0 || st.width > 0.5) err(`${path}.width`, "must be > 0 and <= 0.5 (unit-box units: 0.01 is 1% of the drawing's half-size)");
2936
3100
  if (st.opacity !== void 0 && (!isNum(st.opacity) || st.opacity < 0 || st.opacity > 1)) err(`${path}.opacity`, "must be 0..1");
2937
3101
  if (st.join !== void 0 && !["round", "miter", "bevel"].includes(st.join)) err(`${path}.join`, "must be 'round' | 'miter' | 'bevel'");
2938
3102
  if (st.cap !== void 0 && !["round", "butt", "square"].includes(st.cap)) err(`${path}.cap`, "must be 'round' | 'butt' | 'square'");
2939
3103
  if (st.draw !== void 0) validateDraw(st.draw, `${path}.draw`, err, warn);
3104
+ for (const k of unknownKeys(st, KNOWN_PATH_STROKE)) warn(`${path}.${k}`, "unknown-property", `unknown stroke property '${k}' \u2014 will be ignored`);
2940
3105
  }
2941
3106
  function validateWipe(tr, path, err, warn) {
2942
3107
  if (tr.shape !== void 0 && tr.shape !== "circle" && tr.shape !== "line") err(`${path}.shape`, "must be 'circle' | 'line'");
@@ -3124,7 +3289,7 @@ function validateSequence(seq) {
3124
3289
  warnings.push({
3125
3290
  path: `segments[${i}].transition`,
3126
3291
  code: "morph-structural-mismatch",
3127
- message: `segments ${i}\u2192${i + 1} differ structurally: morph will fall back to cut`
3292
+ message: `segments ${i}\u2192${i + 1} differ structurally (${describeStructuralDifferences(s.scene, next.scene)}): morph will fall back to cut`
3128
3293
  });
3129
3294
  } else if (s.scene.timeline === void 0 && next.scene.timeline === void 0 && morphNothingMorphable(s.scene, next.scene, { textCrossfade: s.transition.text === "crossfade" || s.transition.text === "dip" })) {
3130
3295
  warnings.push({
@@ -3143,7 +3308,7 @@ function validateSequence(seq) {
3143
3308
  if (sq.loop !== true) why = "the sequence does not loop";
3144
3309
  else if (last < 1) why = "there is only one segment";
3145
3310
  else if (lastTr?.type !== "morph") why = "the last segment declares no morph transition";
3146
- else if (structuralSignature(sq.segments[last].scene) !== structuralSignature(sq.segments[0].scene)) why = `segment ${last} and segment 0 differ structurally`;
3311
+ else if (structuralSignature(sq.segments[last].scene) !== structuralSignature(sq.segments[0].scene)) why = `segment ${last} and segment 0 differ structurally (${describeStructuralDifferences(sq.segments[last].scene, sq.segments[0].scene)})`;
3147
3312
  else if (morphChainRoot(sq, last) !== 0) why = `the lap is not one morph chain (segment ${morphChainRoot(sq, last)} starts a new chain), so the wrap would jump seeds`;
3148
3313
  if (why || !canWrapMorph(sq)) {
3149
3314
  warnings.push({ path: "wrapMorph", code: "wrap-morph-inactive", message: `wrapMorph has no effect: ${why ?? "the wrap cannot morph"} \u2014 the wrap stays a cut` });
@@ -3566,6 +3731,7 @@ function lifeStaggerAt(life, t, i) {
3566
3731
  }
3567
3732
  function wrap(v, min, max) {
3568
3733
  const range2 = max - min;
3734
+ if (range2 <= 0) return min;
3569
3735
  return ((v - min) % range2 + range2) % range2 + min;
3570
3736
  }
3571
3737
  function reflect(v, min, max) {
@@ -4824,17 +4990,17 @@ var SpecInstance = class {
4824
4990
  const rows = Math.max(1, Math.round(this.fieldShortSide * h / short));
4825
4991
  const ft = fieldSampleTime(bg, t);
4826
4992
  const key = `${JSON.stringify(bg)}|${w}x${h}|${cols}x${rows}|${ft}`;
4827
- let raster = this.field;
4828
- if (!raster || raster.key !== key) {
4829
- if (!raster || raster.cols !== cols || raster.rows !== rows) {
4993
+ let raster2 = this.field;
4994
+ if (!raster2 || raster2.key !== key) {
4995
+ if (!raster2 || raster2.cols !== cols || raster2.rows !== rows) {
4830
4996
  const canvas = typeof document !== "undefined" ? document.createElement("canvas") : new OffscreenCanvas(cols, rows);
4831
4997
  canvas.width = cols;
4832
4998
  canvas.height = rows;
4833
4999
  const rctx = canvas.getContext("2d");
4834
5000
  if (!rctx) return;
4835
- raster = { canvas, ctx: rctx, cols, rows, key: "" };
5001
+ raster2 = { canvas, ctx: rctx, cols, rows, key: "" };
4836
5002
  }
4837
- const img = raster.ctx.createImageData(cols, rows);
5003
+ const img = raster2.ctx.createImageData(cols, rows);
4838
5004
  const data = img.data;
4839
5005
  const seed = this.seed;
4840
5006
  let i = 0;
@@ -4849,14 +5015,14 @@ var SpecInstance = class {
4849
5015
  data[i++] = 255;
4850
5016
  }
4851
5017
  }
4852
- raster.ctx.putImageData(img, 0, 0);
4853
- raster.key = key;
4854
- this.field = raster;
5018
+ raster2.ctx.putImageData(img, 0, 0);
5019
+ raster2.key = key;
5020
+ this.field = raster2;
4855
5021
  }
4856
5022
  const smooth = Math.round(bg.quantize ?? bg.bands.length) < 2;
4857
5023
  const prev = ctx.imageSmoothingEnabled;
4858
5024
  ctx.imageSmoothingEnabled = smooth;
4859
- ctx.drawImage(raster.canvas, 0, 0, w, h);
5025
+ ctx.drawImage(raster2.canvas, 0, 0, w, h);
4860
5026
  ctx.imageSmoothingEnabled = prev;
4861
5027
  }
4862
5028
  /** Position with parent-orbit resolution: a layer-parented orbit entity's
@@ -5430,14 +5596,14 @@ var SpecInstance = class {
5430
5596
  * entity as it was at `t` (rotation, heading, values), but at the echo's
5431
5597
  * position, size and alpha.
5432
5598
  */
5433
- drawEntity(built, e, t, lifeA, parentE, paint, echo) {
5599
+ drawEntity(built, e, t, lifeA, parentE, paint, echo2) {
5434
5600
  const { ctx } = this;
5435
- const p = echo ? { x: echo.x, y: echo.y, flip: false } : this.entityPos(e, t, parentE);
5601
+ const p = echo2 ? { x: echo2.x, y: echo2.y, flip: false } : this.entityPos(e, t, parentE);
5436
5602
  const sprite = paint?.sprite ?? built.layer.sprite;
5437
- const sz = echo ? echo.size : sizeAt(e, t);
5603
+ const sz = echo2 ? echo2.size : sizeAt(e, t);
5438
5604
  const rot = rotationAt(e, t);
5439
5605
  const unitScale = this.effSpec.units === "px" ? 1 : Math.min(this.w, this.h);
5440
- ctx.globalAlpha = echo ? echo.alpha : alphaAt(e, t) * lifeA * (paint?.alpha ?? 1);
5606
+ ctx.globalAlpha = echo2 ? echo2.alpha : alphaAt(e, t) * lifeA * (paint?.alpha ?? 1);
5441
5607
  if (sprite.kind === "ring") {
5442
5608
  const r = sz / 2;
5443
5609
  const resolvedColor = sprite.colors?.[e.colorIndex] ?? sprite.color;
@@ -7551,6 +7717,7 @@ function polygonCellCoverage(pts, cellW, cellH, c0, c1, r0, r1, evenodd = false)
7551
7717
  }
7552
7718
  return cov;
7553
7719
  }
7720
+ var MAX_GRID_SIDE = 400;
7554
7721
  function softProfile(u) {
7555
7722
  if (u <= 0) return 1;
7556
7723
  if (u >= 1) return 0;
@@ -7586,12 +7753,13 @@ function discCellInk(cx, cy, rOuter, rInner, soft, x0, y0, cw, ch) {
7586
7753
  }
7587
7754
  return ink / (ni * nj) * sx * sy / (cw * ch);
7588
7755
  }
7589
- function luminanceGrid(spec, opts = {}) {
7756
+ function luminanceGrid(spec, opts = {}, maxSide = 200) {
7590
7757
  spec = resolveTimelineAt(spec, opts.t ?? 5e3);
7591
7758
  const scene = buildScene(spec, opts);
7592
7759
  const t = opts.t ?? 5e3;
7593
- const cols = Math.max(8, Math.min(200, opts.cols ?? 80));
7594
- const rows = Math.max(8, Math.min(200, opts.rows ?? 48));
7760
+ const cap = Math.max(8, Math.min(MAX_GRID_SIDE, maxSide));
7761
+ const cols = Math.max(8, Math.min(cap, opts.cols ?? 80));
7762
+ const rows = Math.max(8, Math.min(cap, opts.rows ?? 48));
7595
7763
  const { w, h, scale } = scene;
7596
7764
  const cellW = w / cols;
7597
7765
  const cellH = h / rows;
@@ -7605,10 +7773,22 @@ function luminanceGrid(spec, opts = {}) {
7605
7773
  const short = Math.max(1, Math.min(w, h));
7606
7774
  const seed = scene.seed;
7607
7775
  const ft = fieldSampleTime(bg, t);
7776
+ const fCols = Math.max(1, Math.round(FIELD_RASTER_SHORT_SIDE * w / short));
7777
+ const fRows = Math.max(1, Math.round(FIELD_RASTER_SHORT_SIDE * h / short));
7778
+ const viaRaster = cols > fCols || rows > fRows;
7779
+ const rasterLum = viaRaster ? new Float64Array(fCols * fRows).fill(-1) : null;
7780
+ const fieldLum = (c, r) => {
7781
+ if (!rasterLum) return rgb255Luma(fieldRgbAt((c + 0.5) * cellW / short, (r + 0.5) * cellH / short, ft, bg, seed));
7782
+ const fc = Math.min(fCols - 1, Math.floor((c + 0.5) / cols * fCols));
7783
+ const fr = Math.min(fRows - 1, Math.floor((r + 0.5) / rows * fRows));
7784
+ const k = fr * fCols + fc;
7785
+ if (rasterLum[k] < 0) rasterLum[k] = rgb255Luma(fieldRgbAt((fc + 0.5) * w / fCols / short, (fr + 0.5) * h / fRows / short, ft, bg, seed));
7786
+ return rasterLum[k];
7787
+ };
7608
7788
  for (let r = 0; r < rows; r++) {
7609
7789
  let rowSum = 0;
7610
7790
  for (let c = 0; c < cols; c++) {
7611
- const lum = rgb255Luma(fieldRgbAt((c + 0.5) * cellW / short, (r + 0.5) * cellH / short, ft, bg, seed));
7791
+ const lum = fieldLum(c, r);
7612
7792
  bgCells[r * cols + c] = lum;
7613
7793
  rowSum += lum;
7614
7794
  }
@@ -8841,6 +9021,7 @@ function flashSamplingForecast(spec) {
8841
9021
 
8842
9022
  // src/period.ts
8843
9023
  var MAX_SCENE_PERIOD = 36e5;
9024
+ var SEAMLESS_PERIOD_KINDS = /* @__PURE__ */ new Set(["spin", "rock", "pulse", "grow"]);
8844
9025
  var PERIOD_LEAF = /(^|\.)(period|rate|every|duration|spin|speed|blink|drift)$/;
8845
9026
  var gcd = (a, b) => {
8846
9027
  while (b) [a, b] = [b, a % b];
@@ -8858,27 +9039,27 @@ function toRatio(ms) {
8858
9039
  return null;
8859
9040
  }
8860
9041
  var range = (r) => r === void 0 ? null : typeof r === "number" ? r : r[0] === r[1] ? r[0] : NaN;
8861
- function scenePeriod(spec) {
9042
+ function scenePeriod(spec, skip) {
8862
9043
  const parts = [];
8863
9044
  const reasons = [];
8864
- const add = (label, ms) => {
8865
- if (!(ms > 0) || !Number.isFinite(ms)) return;
9045
+ const add = (label, ms, kind) => {
9046
+ if (!(ms > 0) || !Number.isFinite(ms) || skip?.has(kind)) return;
8866
9047
  const ratio = toRatio(ms);
8867
9048
  if (!ratio) reasons.push(`${label} repeats every ${Number(ms.toFixed(3))} ms, which has no exact ratio to combine`);
8868
- else parts.push([label, ratio[0], ratio[1]]);
9049
+ else parts.push([label, ratio[0], ratio[1], kind]);
8869
9050
  };
8870
9051
  const tl = spec.timeline;
8871
9052
  if (tl && tl.keys.length > 0) {
8872
9053
  const keyed = tl.keys.filter((k) => PERIOD_LEAF.test(String(k.path)));
8873
9054
  if (keyed.length > 0) reasons.push(`timeline keys animate ${[...new Set(keyed.map((k) => String(k.path)))].join(", ")}, which sets the scene's own period`);
8874
- if (tl.loop && (tl.duration ?? 0) > 0) add("timeline", tl.duration);
9055
+ if (tl.loop && (tl.duration ?? 0) > 0) add("timeline", tl.duration, "timeline");
8875
9056
  else reasons.push("timeline does not loop (it plays once, then holds)");
8876
9057
  }
8877
9058
  const bg = spec.background;
8878
9059
  if (bg && bg.type !== "solid" && bg.drift && !(bg.type === "field" && bg.drift.amount === 0)) {
8879
9060
  const { period } = bg.drift;
8880
9061
  const r = bg.type === "field" ? toRatio(period) : null;
8881
- add("background drift", r ? r[0] / gcd(r[0], FIELD_BUCKET_MS) * FIELD_BUCKET_MS : period);
9062
+ add("background drift", r ? r[0] / gcd(r[0], FIELD_BUCKET_MS) * FIELD_BUCKET_MS : period, "background");
8882
9063
  }
8883
9064
  if (spec.finish?.animate && ((spec.finish.grain ?? 0) > 0 || (spec.finish.dither ?? 0) > 0)) reasons.push("finish.animate steps the grain without repeating");
8884
9065
  spec.layers.forEach((layer, i) => {
@@ -8887,53 +9068,58 @@ function scenePeriod(spec) {
8887
9068
  const life = layer.life;
8888
9069
  const staggered = (life?.stagger ?? 0) > 0;
8889
9070
  if (life && (life.exit !== void 0 || life.enter !== void 0 && ((life.enter ?? 0) > 0 || (life.fade ?? 1e3) > 0 || staggered))) reasons.push(`${at} life builds in or out once`);
8890
- if (layer.pulse && layer.pulse.amp !== 0) add(`${at} pulse`, layer.pulse.period / rate);
8891
- if (layer.grow) add(`${at} grow`, layer.grow.period / rate);
8892
- if (layer.rock) add(`${at} rock`, layer.rock.period / rate);
8893
- if (layer.emit) add(`${at} emit`, layer.emit.every);
9071
+ if (layer.pulse && layer.pulse.amp !== 0) add(`${at} pulse`, layer.pulse.period / rate, "pulse");
9072
+ if (layer.grow) add(`${at} grow`, layer.grow.period / rate, "grow");
9073
+ if (layer.rock) add(`${at} rock`, layer.rock.period / rate, "rock");
9074
+ if (layer.emit) add(`${at} emit`, layer.emit.every, "emit");
8894
9075
  const spin = range(layer.spin);
8895
9076
  const fold = spin !== null && spin !== 0 ? spinSymmetry(layer.sprite) : 1;
8896
9077
  if (spin !== null && fold !== Infinity) {
8897
9078
  if (Number.isNaN(spin)) reasons.push(`${at} spin range gives each entity its own speed`);
8898
- else if (spin !== 0) add(`${at} spin${fold > 1 ? ` (${fold}-fold)` : ""}`, 36e4 / Math.abs(spin) / fold);
9079
+ else if (spin !== 0) add(`${at} spin${fold > 1 ? ` (${fold}-fold)` : ""}`, 36e4 / Math.abs(spin) / fold, "spin");
8899
9080
  }
8900
9081
  const sp = layer.sprite;
8901
- if ((sp.kind === "emoji" || sp.kind === "text") && sp.cycle && spriteVariants(sp) > 1) add(`${at} cycle`, sp.cycle.period * spriteVariants(sp) / rate);
9082
+ if ((sp.kind === "emoji" || sp.kind === "text") && sp.cycle && spriteVariants(sp) > 1) add(`${at} cycle`, sp.cycle.period * spriteVariants(sp) / rate, "cycle");
8902
9083
  if (sp.kind === "textBlock" && sp.reveal) {
8903
9084
  if ((sp.reveal.speed ?? 0) > 0) reasons.push(`${at} reveal types itself once`);
8904
9085
  if (sp.reveal.caret) {
8905
9086
  const hz = Math.min(3, sp.reveal.caret === true ? 1.2 : sp.reveal.caret.blink ?? 1.2);
8906
- if (hz > 0) add(`${at} caret`, 1e3 / hz);
9087
+ if (hz > 0) add(`${at} caret`, 1e3 / hz, "caret");
8907
9088
  }
8908
9089
  }
8909
9090
  const m = layer.motion;
8910
- if (m.type === "path") add(`${at} path`, m.duration);
9091
+ if (m.type === "path") add(`${at} path`, m.duration, "path");
8911
9092
  else if (m.type === "orbit") {
8912
9093
  const s = range(m.speed);
8913
9094
  if (s === null || Number.isNaN(s)) reasons.push(`${at} orbit speed range gives each entity its own lap`);
8914
- else if (s !== 0) add(`${at} orbit`, 36e4 / Math.abs(s));
9095
+ else if (s !== 0) add(`${at} orbit`, 36e4 / Math.abs(s), "orbit");
8915
9096
  } else if (m.type !== "static") {
8916
9097
  if (m.type === "drift" && m.ease?.type === "settle" && !layer.emit) reasons.push(`${at} drift settles once`);
8917
9098
  else reasons.push(`${at} ${m.type} motion travels at seeded per-entity speeds`);
8918
9099
  }
8919
9100
  });
8920
9101
  const labelled = parts.map(([l, n, d]) => `${l}: ${Number((n / d).toFixed(3))} ms`);
8921
- if (reasons.length > 0) return { period: null, parts: labelled, reasons };
8922
- if (parts.length === 0) return { period: 0, parts: labelled, reasons };
9102
+ const kinds = parts.map(([, , , k]) => k);
9103
+ if (reasons.length > 0) return { period: null, parts: labelled, kinds, reasons };
9104
+ const combined = combinePeriods(parts);
9105
+ return { ...combined, parts: labelled, kinds };
9106
+ }
9107
+ function combinePeriods(parts) {
9108
+ if (parts.length === 0) return { period: 0, reasons: [] };
8923
9109
  let num = 1;
8924
9110
  let den = 0;
8925
9111
  for (const [, n, d] of parts) {
8926
9112
  num = num / gcd(num, n) * n;
8927
9113
  den = gcd(den, d);
8928
9114
  if (num / den > MAX_SCENE_PERIOD) {
8929
- return { period: null, parts: labelled, reasons: [`its parts share no cycle shorter than ${MAX_SCENE_PERIOD / 6e4} min`] };
9115
+ return { period: null, reasons: [`its parts share no cycle shorter than ${MAX_SCENE_PERIOD / 6e4} min`] };
8930
9116
  }
8931
9117
  }
8932
- if (num % den !== 0) return { period: null, parts: labelled, reasons: [`its parts repeat together every ${Number((num / den).toFixed(3))} ms, not a whole number of milliseconds`] };
8933
- return { period: num / den, parts: labelled, reasons };
9118
+ if (num % den !== 0) return { period: null, reasons: [`its parts repeat together every ${Number((num / den).toFixed(3))} ms, not a whole number of milliseconds`] };
9119
+ return { period: num / den, reasons: [] };
8934
9120
  }
8935
- function periodWithoutTimeline(spec) {
8936
- return scenePeriod(withoutTimeline(spec));
9121
+ function periodWithoutTimeline(spec, skip) {
9122
+ return scenePeriod(withoutTimeline(spec), skip);
8937
9123
  }
8938
9124
  var MAX_SYMMETRY = 64;
8939
9125
  var MAX_SYMMETRY_POINTS = 8192;
@@ -9045,6 +9231,7 @@ function edgeAnchored(range2) {
9045
9231
  function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
9046
9232
  const hidden = hiddenLayers(spec);
9047
9233
  const authored = spec;
9234
+ const keyed = keyedLayers(authored);
9048
9235
  spec = resolveTimelineAt(spec, opts.t ?? COHESION_T);
9049
9236
  const warnings = [];
9050
9237
  const w = viewport.width;
@@ -9058,18 +9245,19 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
9058
9245
  w,
9059
9246
  h
9060
9247
  );
9061
- let totalEntities = 0;
9248
+ let declaredEntities = 0;
9062
9249
  let textLayerCount = 0;
9063
9250
  let motionLayerCount = 0;
9064
9251
  for (let li = 0; li < spec.layers.length; li++) {
9065
9252
  if (hidden?.[li]) continue;
9066
9253
  const layer = spec.layers[li];
9067
9254
  const entities = allEntities[li];
9068
- totalEntities += entities.length;
9069
- const isText = layer.sprite.kind === "text" || layer.sprite.kind === "textBlock";
9070
- const isStaticText = isText && layer.motion.type === "static";
9255
+ declaredEntities += layer.count;
9256
+ const isText2 = layer.sprite.kind === "text" || layer.sprite.kind === "textBlock";
9257
+ const isKeyed = !!keyed?.[li];
9258
+ const isStaticText = isText2 && layer.motion.type === "static" && !isKeyed;
9071
9259
  if (isStaticText) textLayerCount++;
9072
- if (layer.motion.type !== "static") motionLayerCount++;
9260
+ if (layer.motion.type !== "static" || isKeyed) motionLayerCount++;
9073
9261
  const coh = cohesion[li];
9074
9262
  if (seamsWorthWarning(layer, coh, entities.length, () => buriedAreaOf(layer, entities, opts.t ?? COHESION_T, w, h))) {
9075
9263
  const s = layer.sprite;
@@ -9094,13 +9282,26 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
9094
9282
  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`
9095
9283
  });
9096
9284
  }
9097
- if (layer.motion.type === "wander" && layer.motion.coherence === 1 && entities.length >= 20) {
9285
+ if (layer.motion.type === "wander" && layer.motion.coherence === 1 && layer.count >= 20) {
9098
9286
  warnings.push({
9099
9287
  path: `layers[${li}].motion.coherence`,
9100
9288
  code: "full-coherence",
9101
9289
  message: "coherence 1 makes every entity share identical harmonics \u2014 the layer moves as a rigid sheet. 0.5\u20130.8 reads as schooling"
9102
9290
  });
9103
9291
  }
9292
+ if (layer.sprite.kind === "emoji" || layer.sprite.kind === "text") {
9293
+ const vpMin = Math.min(w, h);
9294
+ const fontPx = layer.sprite.kind === "text" && layer.sprite.font ? /(\d{1,5}(?:\.\d{1,4})?|\.\d{1,4})px/.exec(layer.sprite.font) : null;
9295
+ const maxGlyph = fontPx ? Number(fontPx[1]) * (spec.units === "px" ? 1 : vpMin / (spec.referenceViewport ?? LIMITS.referenceViewport)) : entities.reduce((m, e) => Math.max(m, e.size), 0);
9296
+ if (maxGlyph > vpMin) {
9297
+ const unitHint = spec.units === "px" ? 'sizes are px under units: "px"' : `sizes are fractions of min(width, height) \u2014 ${fontPx ? "the font's px size is rescaled from the reference viewport" : "write e.g. [0.03, 0.06], not px"}`;
9298
+ warnings.push({
9299
+ path: `layers[${li}]`,
9300
+ code: "giant-entity",
9301
+ message: `layer ${layer.key ? `'${layer.key}'` : li} renders ${layer.sprite.kind} glyphs up to ${Math.round(maxGlyph)}px \u2014 taller than the ${vpMin}px viewport (its min dimension), so they blanket the frame as blurred blobs; ${unitHint}`
9302
+ });
9303
+ }
9304
+ }
9104
9305
  if (layer.sprite.kind === "circle") {
9105
9306
  const maxR = entities.reduce((m, e) => Math.max(m, e.size / 2), 0);
9106
9307
  const maxAlpha = entities.reduce((m, e) => Math.max(m, e.alpha), 0);
@@ -9213,11 +9414,11 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
9213
9414
  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`
9214
9415
  });
9215
9416
  }
9216
- if (totalEntities > 500 && density !== "dense") {
9417
+ if (declaredEntities > 500 && density !== "dense") {
9217
9418
  warnings.push({
9218
9419
  path: "layers",
9219
9420
  code: "dense-scene",
9220
- message: `${totalEntities} entities \u2014 scene may feel crowded and hurt performance on low-end devices`
9421
+ message: `${declaredEntities} entities \u2014 scene may feel crowded and hurt performance on low-end devices`
9221
9422
  });
9222
9423
  }
9223
9424
  if (density === "sparse" && totalCoverage > SPARSE_DECLARED_MAX_COVERAGE) {
@@ -9259,25 +9460,41 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
9259
9460
  });
9260
9461
  }
9261
9462
  }
9262
- const flicker = nearLimitFlash(authored);
9263
- if (flicker) {
9463
+ const flicker2 = nearLimitFlash(authored);
9464
+ if (flicker2) {
9264
9465
  warnings.push({
9265
9466
  path: "timeline",
9266
9467
  code: "flash-near-limit",
9267
- message: `the timeline flickers ${flicker.flashesPerSecond} times a second over ${Math.round(flicker.area * 100)}% of the frame around ${Math.round(flicker.at)} ms \u2014 at or near WCAG 2.3.1's limit of 3; space opposing luminance changes \u2265 500 ms apart, or lower the contrast between the two states`
9468
+ message: `the timeline flickers ${flicker2.flashesPerSecond} times a second over ${Math.round(flicker2.area * 100)}% of the frame around ${Math.round(flicker2.at)} ms \u2014 at or near WCAG 2.3.1's limit of 3; space opposing luminance changes \u2265 500 ms apart, or lower the contrast between the two states`
9268
9469
  });
9269
9470
  }
9270
9471
  if (authored.timeline?.loop && (authored.timeline.duration ?? 0) > 0) {
9271
9472
  const lap = Math.round(authored.timeline.duration);
9272
9473
  const rest = periodWithoutTimeline(authored);
9273
- if (rest.period !== null && rest.period > 0 && lap % rest.period !== 0) {
9474
+ const seam = rest.period !== null && rest.period > 0 && lap % rest.period !== 0 ? periodWithoutTimeline(authored, SEAMLESS_PERIOD_KINDS) : null;
9475
+ if (seam && seam.period !== null && seam.period > 0 && lap % seam.period !== 0) {
9274
9476
  let a = lap;
9275
- let b = rest.period;
9477
+ let b = seam.period;
9276
9478
  while (b) [a, b] = [b, a % b];
9277
9479
  warnings.push({
9278
9480
  path: "timeline.duration",
9279
9481
  code: "loop-seam",
9280
- message: `the timeline loops every ${lap} ms but the rest of the scene repeats every ${rest.period} ms (${rest.parts.join(", ")}), so the whole scene only repeats every ${lap / a * rest.period} ms \u2014 make the lap a multiple of ${rest.period} ms for one lap to loop`
9482
+ message: `the timeline loops every ${lap} ms but ${seam.parts.join(", ")} repeat${seam.parts.length === 1 ? "s" : ""} every ${seam.period} ms, so the scene only comes round to its first frame every ${lap / a * seam.period} ms \u2014 make the lap a multiple of ${seam.period} ms for one lap to loop`
9483
+ });
9484
+ }
9485
+ }
9486
+ if (authored.timeline?.loop && (authored.timeline.duration ?? 0) > 0) {
9487
+ const lap = Math.round(authored.timeline.duration);
9488
+ for (let li = 0; li < authored.layers.length; li++) {
9489
+ if (hidden?.[li]) continue;
9490
+ const layer = authored.layers[li];
9491
+ const life = layer.life;
9492
+ if (!life || life.enter === void 0 && life.exit === void 0 && !life.stagger) continue;
9493
+ const label = layer.key ? `\`${layer.key}\`` : `layers[${li}]`;
9494
+ warnings.push({
9495
+ path: `layers[${li}].life`,
9496
+ code: "life-runs-once",
9497
+ message: `informational: ${label}'s \`life\` runs once on scene time, from when the scene (or its sequence segment) starts \u2014 it does not replay when the timeline loops every ${lap} ms${life.exit !== void 0 ? ", so after its exit the layer stays gone for every later lap" : ""}; to build it on every lap, key its \`opacity\` in the timeline instead`
9281
9498
  });
9282
9499
  }
9283
9500
  }
@@ -9304,7 +9521,7 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
9304
9521
  if (layer.motion.type === "static") continue;
9305
9522
  const entities = allEntities[li];
9306
9523
  if (entities.length < 3) continue;
9307
- const speeds = entities.map((e) => Math.sqrt(e.vx * e.vx + e.vy * e.vy));
9524
+ const speeds = layer.motion.type === "orbit" ? entities.map((e) => Math.abs(e.vx) * (Math.PI / 180) * e.orbitR) : entities.map((e) => Math.sqrt(e.vx * e.vx + e.vy * e.vy));
9308
9525
  const mean = speeds.reduce((a, b) => a + b, 0) / speeds.length;
9309
9526
  if (mean === 0) continue;
9310
9527
  const maxDev = Math.max(...speeds.map((s) => Math.abs(s - mean)));
@@ -9338,29 +9555,7 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
9338
9555
  }
9339
9556
  }
9340
9557
  }
9341
- const textBoxes = [];
9342
- for (let li = 0; li < spec.layers.length; li++) {
9343
- if (hidden?.[li]) continue;
9344
- const layer = spec.layers[li];
9345
- const s = layer.sprite;
9346
- if (layer.motion.type !== "static") continue;
9347
- if (s.kind !== "text" && s.kind !== "textBlock") continue;
9348
- const label = layer.key ? `\`${layer.key}\`` : `layers[${li}]`;
9349
- for (const e of allEntities[li]) {
9350
- const p = positionAt(e, 0, w, h);
9351
- const unturned = s.kind === "textBlock" ? textBlockBoxAt(s, p, w, h) : textBoxAt(s, e, p, spec, w, h);
9352
- const rot = rotationAt(e, 0);
9353
- const turn = (q) => ({ x: p.x + (q.x - p.x) * Math.cos(rot) - (q.y - p.y) * Math.sin(rot), y: p.y + (q.x - p.x) * Math.sin(rot) + (q.y - p.y) * Math.cos(rot) });
9354
- const map = paintMap(spec, layer, w, h, scale);
9355
- const both = rot && map ? (q) => map(turn(q)) : rot ? turn : map;
9356
- const box = both ? { ...unturned, ...mapBox(both, unturned) } : unturned;
9357
- const raw = rot ? { ...unturned, ...mapBox(turn, unturned) } : unturned;
9358
- const baseAlpha = Math.max(0, Math.min(1, e.alpha - e.pulseAmp)) * (s.kind === "textBlock" ? s.opacity ?? 1 : 1);
9359
- const alpha = layer.opacity === void 0 && layer.group === void 0 ? baseAlpha : baseAlpha * paintOpacity(spec, layer);
9360
- const str = s.kind === "textBlock" ? textBlockText(s) : s.strings[e.spriteIndex] ?? s.strings[0] ?? "";
9361
- textBoxes.push({ li, label, alpha, str, ...box, rawW: raw.x1 - raw.x0, rawH: raw.y1 - raw.y0 });
9362
- }
9363
- }
9558
+ const textBoxes = staticTextBoxes(spec, allEntities, hidden, w, h, scale);
9364
9559
  const asFraction = (b) => ({
9365
9560
  x: +(b.x0 / w).toFixed(4),
9366
9561
  y: +(b.y0 / h).toFixed(4),
@@ -9385,34 +9580,46 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
9385
9580
  message: `${b.label} text runs off the ${edges.join(" and the ")} at ${w}\xD7${h} \u2014 move position, or shrink fontSize / maxWidth`
9386
9581
  });
9387
9582
  }
9388
- const pairFlagged = /* @__PURE__ */ new Set();
9389
- for (let i = 0; i < textBoxes.length; i++) {
9390
- for (let j = i + 1; j < textBoxes.length; j++) {
9391
- const a = textBoxes[i];
9392
- const b = textBoxes[j];
9393
- if (a.li === b.li) continue;
9394
- const pair = `${Math.min(a.li, b.li)}:${Math.max(a.li, b.li)}`;
9395
- if (pairFlagged.has(pair)) continue;
9396
- const ix = Math.min(a.x1, b.x1) - Math.max(a.x0, b.x0);
9397
- const iy = Math.min(a.y1, b.y1) - Math.max(a.y0, b.y0);
9398
- if (ix <= 0 || iy <= 0) continue;
9399
- const smaller = Math.min((a.x1 - a.x0) * (a.y1 - a.y0), (b.x1 - b.x0) * (b.y1 - b.y0));
9400
- if (smaller <= 0) continue;
9401
- const share = ix * iy / smaller;
9402
- if (share <= 0.1) continue;
9403
- if (isOutlineStack(a, b)) continue;
9404
- pairFlagged.add(pair);
9405
- const [first, second] = a.li < b.li ? [a, b] : [b, a];
9406
- warnings.push({
9407
- path: `layers[${first.li}]`,
9408
- code: "text-overlap",
9409
- 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`,
9410
- // The two boxes, so an agent can move one without re-deriving the
9411
- // layout. Additive: the message is unchanged.
9412
- boxes: [asFraction(first), asFraction(second)]
9583
+ const overlapSamples = textOverlapSamples(authored, spec, allEntities, keyed, hidden, opts.t ?? COHESION_T, opts.until, opts.seed ?? spec.seed ?? 42, w, h);
9584
+ const overlapAt = /* @__PURE__ */ new Map();
9585
+ if (overlapSamples) {
9586
+ const pairsIn = (boxes, each) => {
9587
+ for (let i = 0; i < boxes.length; i++) {
9588
+ for (let j = i + 1; j < boxes.length; j++) {
9589
+ const a = boxes[i];
9590
+ const b = boxes[j];
9591
+ if (a.li === b.li) continue;
9592
+ const share = overlapShare(a, b);
9593
+ if (share <= 0.1 || isOutlineStack(a, b)) continue;
9594
+ each(`${Math.min(a.li, b.li)}:${Math.max(a.li, b.li)}`, a, b, share);
9595
+ }
9596
+ }
9597
+ };
9598
+ const raised = /* @__PURE__ */ new Set();
9599
+ pairsIn(overlapSamples.now, (pair) => raised.add(pair));
9600
+ for (const sample of overlapSamples.visible) {
9601
+ pairsIn(sample.boxes, (pair, a, b, share) => {
9602
+ if (!raised.has(pair) || overlapAt.has(pair)) return;
9603
+ overlapAt.set(pair, { share, a, b, t: sample.t });
9413
9604
  });
9414
9605
  }
9415
9606
  }
9607
+ for (const pair of [...overlapAt.keys()].sort((x, y) => {
9608
+ const [x0, x1] = x.split(":").map(Number);
9609
+ const [y0, y1] = y.split(":").map(Number);
9610
+ return x0 - y0 || x1 - y1;
9611
+ })) {
9612
+ const { share, a, b, t } = overlapAt.get(pair);
9613
+ const [first, second] = a.li < b.li ? [a, b] : [b, a];
9614
+ warnings.push({
9615
+ path: `layers[${first.li}]`,
9616
+ code: "text-overlap",
9617
+ message: `${first.label} and ${second.label} text boxes overlap (~${Math.round(share * 100)}% of the smaller one)${t === null ? "" : `, both visible at ${Math.round(t)} ms`} \u2014 they will paint over each other; move one or shrink it`,
9618
+ // The two boxes, so an agent can move one without re-deriving the
9619
+ // layout. Additive: the message is unchanged.
9620
+ boxes: [asFraction(first), asFraction(second)]
9621
+ });
9622
+ }
9416
9623
  const cellFlagged = /* @__PURE__ */ new Set();
9417
9624
  for (let i = 0; i < textBoxes.length; i++) {
9418
9625
  const a = textBoxes[i];
@@ -9420,6 +9627,7 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
9420
9627
  const layer = spec.layers[a.li];
9421
9628
  const layout = layer.layout;
9422
9629
  if (layout?.type !== "list" && layout?.type !== "table") continue;
9630
+ if (isAtmosphere(layer)) continue;
9423
9631
  for (let j = i + 1; j < textBoxes.length; j++) {
9424
9632
  const b = textBoxes[j];
9425
9633
  if (b.li !== a.li) continue;
@@ -9526,6 +9734,120 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
9526
9734
  }
9527
9735
  return warnings;
9528
9736
  }
9737
+ function isAtmosphere(layer) {
9738
+ const s = layer.sprite;
9739
+ return (s.kind === "text" || s.kind === "textBlock") && s.role === "atmosphere";
9740
+ }
9741
+ function staticTextBoxes(spec, allEntities, hidden, w, h, scale, only) {
9742
+ const textBoxes = [];
9743
+ for (let li = 0; li < spec.layers.length; li++) {
9744
+ if (hidden?.[li]) continue;
9745
+ if (only && !only.has(li)) continue;
9746
+ const layer = spec.layers[li];
9747
+ const s = layer.sprite;
9748
+ if (layer.motion.type !== "static") continue;
9749
+ if (s.kind !== "text" && s.kind !== "textBlock") continue;
9750
+ const label = layer.key ? `\`${layer.key}\`` : `layers[${li}]`;
9751
+ for (const e of allEntities[li]) {
9752
+ const p = positionAt(e, 0, w, h);
9753
+ const unturned = s.kind === "textBlock" ? textBlockBoxAt(s, p, w, h) : textBoxAt(s, e, p, spec, w, h);
9754
+ const rot = rotationAt(e, 0);
9755
+ const turn = (q) => ({ x: p.x + (q.x - p.x) * Math.cos(rot) - (q.y - p.y) * Math.sin(rot), y: p.y + (q.x - p.x) * Math.sin(rot) + (q.y - p.y) * Math.cos(rot) });
9756
+ const map = paintMap(spec, layer, w, h, scale);
9757
+ const both = rot && map ? (q) => map(turn(q)) : rot ? turn : map;
9758
+ const box = both ? { ...unturned, ...mapBox(both, unturned) } : unturned;
9759
+ const raw = rot ? { ...unturned, ...mapBox(turn, unturned) } : unturned;
9760
+ const baseAlpha = Math.max(0, Math.min(1, e.alpha - e.pulseAmp)) * (s.kind === "textBlock" ? s.opacity ?? 1 : 1);
9761
+ const alpha = layer.opacity === void 0 && layer.group === void 0 ? baseAlpha : baseAlpha * paintOpacity(spec, layer);
9762
+ const str = s.kind === "textBlock" ? textBlockText(s) : s.strings[e.spriteIndex] ?? s.strings[0] ?? "";
9763
+ textBoxes.push({ li, label, alpha, peak: Math.min(1, e.alpha + e.pulseAmp), str, ...box, rawW: raw.x1 - raw.x0, rawH: raw.y1 - raw.y0 });
9764
+ }
9765
+ }
9766
+ return textBoxes;
9767
+ }
9768
+ function keyedLayers(spec) {
9769
+ if (!spec.timeline) return null;
9770
+ const paths = [...timelineTracks(spec).keys()];
9771
+ if (paths.length === 0) return null;
9772
+ return spec.layers.map((l, i) => paths.some((p) => keyTouchesLayer(p, i, l)));
9773
+ }
9774
+ function keyTouchesLayer(path, i, layer) {
9775
+ const lp = `layers.${i}`;
9776
+ if (path === "layers" || path === lp || path.startsWith(`${lp}.`)) return true;
9777
+ if (path === "referenceViewport" || path === "units") return true;
9778
+ if (layer.group === void 0) return false;
9779
+ const gp = `groups.${layer.group}`;
9780
+ return path === "groups" || path === gp || path.startsWith(`${gp}.`);
9781
+ }
9782
+ var TEXT_VISIBLE_FLOOR = 0.02;
9783
+ function paintAxisScale(spec, layer) {
9784
+ const axes = (tf) => {
9785
+ const s = Math.abs(tf?.scale ?? 1);
9786
+ return [s * Math.abs(tf?.scaleX ?? 1), s * Math.abs(tf?.scaleY ?? 1)];
9787
+ };
9788
+ const [lx, ly] = axes(layer.transform);
9789
+ const [gx, gy] = axes(groupOf(spec, layer)?.transform);
9790
+ return Math.min(lx * gx, ly * gy);
9791
+ }
9792
+ var TEXT_OVERLAP_MAX_SAMPLES = 48;
9793
+ function textOverlapSamples(authored, spec, allEntities, keyed, hidden, tNow, until, seed, w, h) {
9794
+ const candidates = /* @__PURE__ */ new Set();
9795
+ spec.layers.forEach((l, li) => {
9796
+ const k = l.sprite.kind;
9797
+ if (!hidden?.[li] && (k === "text" || k === "textBlock") && !isAtmosphere(l)) candidates.add(li);
9798
+ });
9799
+ if (candidates.size < 2) return null;
9800
+ const visibleAt = (t, at, entities, boxes) => {
9801
+ const { scale } = entityScales(at, w, h);
9802
+ return (boxes ?? staticTextBoxes(at, entities, hidden, w, h, scale, candidates)).filter((b) => textBoxVisible(at, at.layers[b.li], b, t, w, h, scale));
9803
+ };
9804
+ const now = staticTextBoxes(spec, allEntities, hidden, w, h, entityScales(spec, w, h).scale, candidates);
9805
+ const visible = [{ t: null, boxes: visibleAt(tNow, spec, allEntities, now) }];
9806
+ if (keyed && [...candidates].some((li) => keyed[li])) {
9807
+ const touches = (path) => [...candidates].some((li) => keyTouchesLayer(path, li, authored.layers[li]));
9808
+ let times = timelineSampleTimes(authored, false, 120, touches).filter((t) => t !== tNow && (until === void 0 || t <= until));
9809
+ if (times.length > TEXT_OVERLAP_MAX_SAMPLES) {
9810
+ const step = times.length / TEXT_OVERLAP_MAX_SAMPLES;
9811
+ times = Array.from({ length: TEXT_OVERLAP_MAX_SAMPLES }, (_, i) => times[Math.floor(i * step)]);
9812
+ }
9813
+ const built = /* @__PURE__ */ new Map([[structuralSignature(spec), allEntities]]);
9814
+ for (const t of times) {
9815
+ const at = resolveTimelineAt(authored, t, true);
9816
+ const sig = structuralSignature(at);
9817
+ let entities = built.get(sig);
9818
+ if (!entities) {
9819
+ const sc = entityScales(at, w, h);
9820
+ const rng = createRng3(seed);
9821
+ entities = at.layers.map((l) => buildEntities(l, rng, w, h, sc.scale, sc.countScale));
9822
+ built.set(sig, entities);
9823
+ }
9824
+ visible.push({ t, boxes: visibleAt(t, at, entities) });
9825
+ }
9826
+ }
9827
+ return { now, visible };
9828
+ }
9829
+ function textBoxVisible(spec, layer, b, t, w, h, scale) {
9830
+ if (b.x1 <= 0 || b.x0 >= w || b.y1 <= 0 || b.y0 >= h) return false;
9831
+ if (paintAxisScale(spec, layer) <= TEXT_VISIBLE_FLOOR) return false;
9832
+ const s = layer.sprite;
9833
+ let a = paintOpacity(spec, layer) * b.peak;
9834
+ if (s.kind === "textBlock") {
9835
+ a *= s.opacity ?? 1;
9836
+ if (s.reveal) {
9837
+ const fsPx = s.fontSize * scale;
9838
+ const lines = breakTextBlock(textBlockText(s), s.maxWidth * scale / fsPx, textMetricsClassFor(s.font));
9839
+ if (revealState(lines, s.reveal, t).progress <= 0) return false;
9840
+ }
9841
+ }
9842
+ return a > TEXT_VISIBLE_FLOOR;
9843
+ }
9844
+ function overlapShare(a, b) {
9845
+ const ix = Math.min(a.x1, b.x1) - Math.max(a.x0, b.x0);
9846
+ const iy = Math.min(a.y1, b.y1) - Math.max(a.y0, b.y0);
9847
+ if (ix <= 0 || iy <= 0) return 0;
9848
+ const smaller = Math.min((a.x1 - a.x0) * (a.y1 - a.y0), (b.x1 - b.x0) * (b.y1 - b.y0));
9849
+ return smaller > 0 ? ix * iy / smaller : 0;
9850
+ }
9529
9851
  function nearLimitFlash(spec) {
9530
9852
  if (!spec.timeline) return null;
9531
9853
  const { times, lap } = lumaKeyTimes(spec);
@@ -9607,7 +9929,7 @@ function groundUnder(spec, allEntities, li, hidden, pt, w, h, base = backgroundR
9607
9929
  if (layer.life !== void 0 || layer.emit !== void 0) continue;
9608
9930
  if ((sp.kind === "circle" || sp.kind === "polygon") && sp.soft) continue;
9609
9931
  if (sp.kind !== "circle" && sp.kind !== "rect" && sp.kind !== "polygon") continue;
9610
- if (sp.kind === "polygon" && isPathPolygon(sp)) continue;
9932
+ const drawing = sp.kind === "polygon" && isPathPolygon(sp);
9611
9933
  const a = layer.opacity === void 0 && layer.group === void 0 ? 1 : paintOpacity(spec, layer);
9612
9934
  for (const e of allEntities[lj]) {
9613
9935
  if (e.spinSpeed !== 0 || e.pulseAmp > 0 || e.growAmp > 0 || e.alpha * a <= 0) continue;
@@ -9616,6 +9938,14 @@ function groundUnder(spec, allEntities, li, hidden, pt, w, h, base = backgroundR
9616
9938
  const c = positionAt(e, 0, w, h);
9617
9939
  const dx = pt.x - c.x;
9618
9940
  const dy = pt.y - c.y;
9941
+ if (drawing) {
9942
+ if (e.rotate) continue;
9943
+ const ink = pathGroundAt(sp, e, dx, dy, w, h);
9944
+ if (!ink) continue;
9945
+ plate = sourceOverPlate(plate, ink.rgb, Math.min(1, e.alpha * a * ink.opacity));
9946
+ covered = true;
9947
+ continue;
9948
+ }
9619
9949
  let inside2 = false;
9620
9950
  if (sp.kind === "circle") inside2 = Math.hypot(dx, dy) <= e.size / 2;
9621
9951
  else if (sp.kind === "rect") {
@@ -9636,6 +9966,41 @@ function groundUnder(spec, allEntities, li, hidden, pt, w, h, base = backgroundR
9636
9966
  }
9637
9967
  return covered ? { rgb: plate, luma: 0.2126 * plate.r + 0.7152 * plate.g + 0.0722 * plate.b } : null;
9638
9968
  }
9969
+ function pathGroundAt(sp, e, dx, dy, w, h) {
9970
+ const base = sp.colors?.[e.colorIndex] ?? sp.color;
9971
+ const fr = pathFrame(sp, e.size / 2, w, h);
9972
+ const u = dx / fr.sx + fr.ox;
9973
+ const v = dy / fr.sy + fr.oy;
9974
+ let r = 0, g = 0, b = 0, wsum = 0, area = 0;
9975
+ for (const part of pathParts(sp)) {
9976
+ if (!part.fill || part.opacity <= 0 || part.flat.points.length < 3) continue;
9977
+ const box = part.box;
9978
+ if (Math.abs(u - box.cx) > box.hw || Math.abs(v - box.cy) > box.hh) continue;
9979
+ const ring = part.flat.points.map(([x, y]) => ({ x, y }));
9980
+ if (!pointInPolygon(u, v, ring)) continue;
9981
+ const pc = hexRgb(part.color ?? base);
9982
+ let rgb = pc;
9983
+ if (part.gradient) {
9984
+ const weights = gradientStopWeights(part.gradient.stops);
9985
+ rgb = { r: 0, g: 0, b: 0 };
9986
+ part.gradient.stops.forEach((st, i) => {
9987
+ const o = st.opacity ?? 1;
9988
+ const c = hexRgb(st.color);
9989
+ const k = weights[i];
9990
+ rgb = { r: rgb.r + (c.r * o + pc.r * (1 - o)) * k, g: rgb.g + (c.g * o + pc.g * (1 - o)) * k, b: rgb.b + (c.b * o + pc.b * (1 - o)) * k };
9991
+ });
9992
+ }
9993
+ const pa = Math.max(1e-6, polygonArea(ring));
9994
+ const wt = pa * part.opacity;
9995
+ r += rgb.r * wt;
9996
+ g += rgb.g * wt;
9997
+ b += rgb.b * wt;
9998
+ wsum += wt;
9999
+ area += pa;
10000
+ }
10001
+ if (wsum <= 0 || area <= 0) return null;
10002
+ return { rgb: { r: r / wsum, g: g / wsum, b: b / wsum }, opacity: Math.min(1, wsum / area) };
10003
+ }
9639
10004
  function pointInPolygon(x, y, pts) {
9640
10005
  let inside2 = false;
9641
10006
  for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
@@ -9717,7 +10082,7 @@ function adviseSequence(seq, viewport = { width: 1920, height: 1080 }) {
9717
10082
  const seg = seq.segments[i];
9718
10083
  const timed = !!seg.scene.timeline && seg.duration !== void 0;
9719
10084
  const tSample = timed ? Math.min(COHESION_T, seg.duration) : void 0;
9720
- const segWarnings = adviseSpec(seg.scene, viewport, tSample === void 0 ? {} : { t: tSample });
10085
+ const segWarnings = adviseSpec(seg.scene, viewport, tSample === void 0 ? {} : { t: tSample, until: seg.duration });
9721
10086
  const at = tSample === void 0 ? "" : ` (judged ${(tSample / 1e3).toFixed(1)} s into this ${(seg.duration / 1e3).toFixed(1)} s segment)`;
9722
10087
  for (const w of segWarnings) {
9723
10088
  warnings.push({ path: `segments[${i}].scene.${w.path}`, code: w.code, message: w.message + at, boxes: w.boxes });
@@ -9754,7 +10119,7 @@ function adviseSequence(seq, viewport = { width: 1920, height: 1080 }) {
9754
10119
  warnings.push({
9755
10120
  path: `segments[${i}].transition`,
9756
10121
  code: "morph-structural-mismatch",
9757
- message: `segments ${i}\u2192${i + 1} differ structurally: morph will fall back to cut`
10122
+ message: `segments ${i}\u2192${i + 1} differ structurally (${describeStructuralDifferences(seq.segments[i].scene, seq.segments[i + 1].scene)}): morph will fall back to cut`
9758
10123
  });
9759
10124
  } 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" })) {
9760
10125
  warnings.push({
@@ -9859,6 +10224,418 @@ function describeScene(spec, options = {}) {
9859
10224
  };
9860
10225
  }
9861
10226
 
10227
+ // src/pattern-hazard.ts
10228
+ import { FLASH_DARK_MAX, FLASH_MAX_PER_SECOND, FLASH_SWING_THRESHOLD, srgbToLinear as srgbToLinear4 } from "@idle-screens/validator";
10229
+ var PATTERN_GRID_SHORT_SIDE = 216;
10230
+ var PATTERN_GRID_MAX_SIDE = 400;
10231
+ var PATTERN_MIN_PAIRS = 6;
10232
+ var PATTERN_STATIONARY_AREA = 0.4;
10233
+ var PATTERN_MOVING_AREA = 0.25;
10234
+ var PATTERN_FLICKER_AREA = 0.25;
10235
+ var PATTERN_MAX_SAMPLE_TIMES = 6;
10236
+ var PERIOD_RATIO = 4 / 3;
10237
+ var PATTERN_MAX_PITCH = 0.15;
10238
+ var PATTERN_MIN_EXTENT = 2;
10239
+ var NEIGHBOUR_PITCH = 0.25;
10240
+ var BLOCK = 4;
10241
+ var DELTA_MS = 1e3 / 60;
10242
+ var FLICKER_PERIODS = 4;
10243
+ var BASE_T = 5e3;
10244
+ var isText = (l) => l.sprite.kind === "text" || l.sprite.kind === "textBlock";
10245
+ function prepared(spec, t, keep, flat) {
10246
+ const resolved = resolveTimelineAt(spec, t);
10247
+ const out = { ...resolved, layers: resolved.layers.map((l, i) => keep(l, i) && !isText(l) ? l : { ...l, opacity: 0 }) };
10248
+ delete out.ghosting;
10249
+ if (flat !== void 0) {
10250
+ const v = Math.round(Math.max(0, Math.min(1, flat)) * 255).toString(16).padStart(2, "0");
10251
+ out.background = { type: "solid", color: `#${v}${v}${v}` };
10252
+ }
10253
+ return out;
10254
+ }
10255
+ function gridSize(viewport) {
10256
+ const { width: w, height: h } = viewport;
10257
+ const short = Math.max(1, Math.min(w, h));
10258
+ const fit = (side) => Math.max(8, Math.min(PATTERN_GRID_MAX_SIDE, Math.round(side / short * PATTERN_GRID_SHORT_SIDE)));
10259
+ return { cols: fit(w), rows: fit(h) };
10260
+ }
10261
+ function raster(spec, t, opts) {
10262
+ const { cols, rows } = gridSize(opts.viewport);
10263
+ const g = luminanceGrid(spec, { viewport: opts.viewport, seed: opts.seed, t, cols, rows }, PATTERN_GRID_MAX_SIDE);
10264
+ const lum = new Float64Array(g.cells.length);
10265
+ for (let i = 0; i < lum.length; i++) lum[i] = srgbToLinear4(g.cells[i] * 255);
10266
+ const short = Math.min(opts.viewport.width, opts.viewport.height);
10267
+ const bg = g.backgroundCells ?? g.background;
10268
+ return {
10269
+ cols: g.cols,
10270
+ rows: g.rows,
10271
+ cellX: opts.viewport.width / g.cols / short,
10272
+ cellY: opts.viewport.height / g.rows / short,
10273
+ lum,
10274
+ meanGround: bg.reduce((a, b) => a + b, 0) / Math.max(1, bg.length)
10275
+ };
10276
+ }
10277
+ function stripeExtrema(line) {
10278
+ const n = line.length;
10279
+ const out = [];
10280
+ if (n < 3) return out;
10281
+ let lo = line[0], loAt = 0, hi = line[0], hiAt = 0;
10282
+ let dir = 0;
10283
+ let cand = 0, candFrom = 0, candTo = 0;
10284
+ for (let i = 1; i < n; i++) {
10285
+ const v = line[i];
10286
+ if (dir === 0) {
10287
+ if (v > hi) {
10288
+ hi = v;
10289
+ hiAt = i;
10290
+ }
10291
+ if (v < lo) {
10292
+ lo = v;
10293
+ loAt = i;
10294
+ }
10295
+ if (hi - lo >= FLASH_SWING_THRESHOLD && lo < FLASH_DARK_MAX) {
10296
+ if (loAt < hiAt) {
10297
+ out.push({ x: loAt, v: lo, light: false });
10298
+ dir = 1;
10299
+ cand = hi;
10300
+ candFrom = candTo = hiAt;
10301
+ } else {
10302
+ out.push({ x: hiAt, v: hi, light: true });
10303
+ dir = -1;
10304
+ cand = lo;
10305
+ candFrom = candTo = loAt;
10306
+ }
10307
+ }
10308
+ continue;
10309
+ }
10310
+ if (dir === 1 ? v > cand : v < cand) {
10311
+ cand = v;
10312
+ candFrom = candTo = i;
10313
+ continue;
10314
+ }
10315
+ if (v === cand) {
10316
+ if (candTo === i - 1) candTo = i;
10317
+ continue;
10318
+ }
10319
+ const swing = dir === 1 ? cand - v : v - cand;
10320
+ if (swing >= FLASH_SWING_THRESHOLD && Math.min(cand, v) < FLASH_DARK_MAX) {
10321
+ out.push({ x: (candFrom + candTo) / 2, v: cand, light: dir === 1 });
10322
+ dir = -dir;
10323
+ cand = v;
10324
+ candFrom = candTo = i;
10325
+ }
10326
+ }
10327
+ return out;
10328
+ }
10329
+ function similarSpacing(a, b, frac) {
10330
+ return Math.abs(a - b) <= 1 + frac * Math.min(a, b);
10331
+ }
10332
+ function stripeRuns(ext, line, axis) {
10333
+ const runs = [];
10334
+ for (const light of [true, false]) {
10335
+ const idx = [];
10336
+ ext.forEach((e, i) => {
10337
+ if (e.light === light) idx.push(i);
10338
+ });
10339
+ let start = 0;
10340
+ const close = (end) => {
10341
+ const count = end - start + 1;
10342
+ if (count >= PATTERN_MIN_PAIRS) {
10343
+ const xs = idx.slice(start, end + 1).map((i) => ext[i].x);
10344
+ const periods = xs.slice(1).map((x, k) => x - xs[k]).sort((a, b) => a - b);
10345
+ const first = idx[start], last = idx[end];
10346
+ let swing = 0, pairs = 0;
10347
+ for (let k = Math.max(0, first - 1); k < Math.min(ext.length - 1, last + 1); k++) {
10348
+ swing += Math.abs(ext[k + 1].v - ext[k].v);
10349
+ pairs++;
10350
+ }
10351
+ runs.push({ line, axis, from: xs[0], to: xs[xs.length - 1], period: periods[periods.length >> 1], count, light, contrast: pairs ? swing / pairs : 0 });
10352
+ }
10353
+ };
10354
+ for (let k = 1; k < idx.length; k++) {
10355
+ if (k >= 2) {
10356
+ const p0 = ext[idx[k - 1]].x - ext[idx[k - 2]].x;
10357
+ const p1 = ext[idx[k]].x - ext[idx[k - 1]].x;
10358
+ if (!similarSpacing(p0, p1, PERIOD_RATIO - 1)) {
10359
+ close(k - 1);
10360
+ start = k - 1;
10361
+ }
10362
+ }
10363
+ }
10364
+ if (idx.length) close(idx.length - 1);
10365
+ }
10366
+ return runs;
10367
+ }
10368
+ function scanRaster(r) {
10369
+ const rowLine = new Float64Array(r.cols);
10370
+ const colLine = new Float64Array(r.rows);
10371
+ const perLine = (axis, n) => {
10372
+ const lines = [];
10373
+ for (let k = 0; k < n; k++) {
10374
+ if (axis === "x") {
10375
+ for (let c = 0; c < r.cols; c++) rowLine[c] = r.lum[k * r.cols + c];
10376
+ } else {
10377
+ for (let q = 0; q < r.rows; q++) colLine[q] = r.lum[q * r.cols + k];
10378
+ }
10379
+ const line = axis === "x" ? rowLine : colLine;
10380
+ lines.push(stripeRuns(stripeExtrema(line), k, axis));
10381
+ }
10382
+ return lines;
10383
+ };
10384
+ const all = [perLine("x", r.rows), perLine("y", r.cols)];
10385
+ const kept = [];
10386
+ for (const lines of all) {
10387
+ const flat = lines.flat();
10388
+ const at = /* @__PURE__ */ new Map();
10389
+ flat.forEach((run, i) => at.set(run, i));
10390
+ const parent = flat.map((_, i) => i);
10391
+ const find = (i) => {
10392
+ while (parent[i] !== i) i = parent[i] = parent[parent[i]];
10393
+ return i;
10394
+ };
10395
+ const across = (run) => run.period * (run.axis === "x" ? r.cellX / r.cellY : r.cellY / r.cellX);
10396
+ for (const run of flat) {
10397
+ const reach = Math.max(1, Math.ceil(across(run)));
10398
+ for (let k = run.line + 1; k <= run.line + reach && k < lines.length; k++) {
10399
+ for (const q of lines[k]) {
10400
+ const overlap = Math.min(run.to, q.to) - Math.max(run.from, q.from);
10401
+ if (overlap >= 0.5 * Math.min(run.to - run.from, q.to - q.from) && similarSpacing(run.period, q.period, NEIGHBOUR_PITCH)) {
10402
+ parent[find(at.get(q))] = find(at.get(run));
10403
+ }
10404
+ }
10405
+ }
10406
+ }
10407
+ const span = /* @__PURE__ */ new Map();
10408
+ for (const run of flat) {
10409
+ const root = find(at.get(run));
10410
+ const s0 = span.get(root);
10411
+ const a = across(run);
10412
+ if (!s0) span.set(root, { lo: run.line, hi: run.line, across: a, periods: [run.period], slants: [] });
10413
+ else {
10414
+ s0.lo = Math.min(s0.lo, run.line);
10415
+ s0.hi = Math.max(s0.hi, run.line);
10416
+ s0.across = Math.max(s0.across, a);
10417
+ s0.periods.push(run.period);
10418
+ }
10419
+ }
10420
+ for (const run of flat) {
10421
+ for (const q of lines[run.line + 1] ?? []) {
10422
+ if (q.light !== run.light || find(at.get(q)) !== find(at.get(run))) continue;
10423
+ const d = q.from - run.from;
10424
+ span.get(find(at.get(run))).slants.push(Math.abs(d - run.period * Math.round(d / run.period)));
10425
+ }
10426
+ }
10427
+ const median = (xs) => xs.length ? [...xs].sort((a, b) => a - b)[xs.length >> 1] : 0;
10428
+ for (const run of flat) {
10429
+ const sp = span.get(find(at.get(run)));
10430
+ if (sp.hi - sp.lo + 1 < PATTERN_MIN_EXTENT * sp.across) continue;
10431
+ const [along, step] = run.axis === "x" ? [r.cellX, r.cellY] : [r.cellY, r.cellX];
10432
+ const slant = median(sp.slants) * along;
10433
+ run.pitch = median(sp.periods) * along * (step / Math.hypot(slant, step));
10434
+ if (run.pitch <= PATTERN_MAX_PITCH) kept.push(run);
10435
+ }
10436
+ }
10437
+ return kept;
10438
+ }
10439
+ function runBlocks(r, run, from = run.from, to = run.to) {
10440
+ const half = run.period / 2;
10441
+ const across = run.axis === "x" ? half * (r.cellX / r.cellY) : half * (r.cellY / r.cellX);
10442
+ const [x0, x1, y0, y1] = run.axis === "x" ? [from - half, to + half, run.line - across, run.line + across] : [run.line - across, run.line + across, from - half, to + half];
10443
+ const bw = Math.ceil(r.cols / BLOCK), bh = Math.ceil(r.rows / BLOCK);
10444
+ const clampB = (v, blocks) => Math.max(0, Math.min(blocks - 1, Math.floor(v / BLOCK)));
10445
+ return { bx0: clampB(x0, bw), bx1: clampB(x1, bw), by0: clampB(y0, bh), by1: clampB(y1, bh) };
10446
+ }
10447
+ function markedArea(r, marked) {
10448
+ const bw = Math.ceil(r.cols / BLOCK);
10449
+ let cells = 0;
10450
+ marked.forEach((m, i) => {
10451
+ if (!m) return;
10452
+ const bx = i % bw, by = Math.floor(i / bw);
10453
+ cells += (Math.min(r.cols, (bx + 1) * BLOCK) - bx * BLOCK) * (Math.min(r.rows, (by + 1) * BLOCK) - by * BLOCK);
10454
+ });
10455
+ return cells / (r.cols * r.rows);
10456
+ }
10457
+ function patternArea(r, runs) {
10458
+ const bw = Math.ceil(r.cols / BLOCK), bh = Math.ceil(r.rows / BLOCK);
10459
+ const marked = new Uint8Array(bw * bh);
10460
+ for (const run of runs) {
10461
+ const b = runBlocks(r, run);
10462
+ for (let y = b.by0; y <= b.by1; y++) for (let x = b.bx0; x <= b.bx1; x++) marked[y * bw + x] = 1;
10463
+ }
10464
+ return markedArea(r, marked);
10465
+ }
10466
+ function flicker(a, b, runs, dt) {
10467
+ const bw = Math.ceil(a.cols / BLOCK), bh = Math.ceil(a.rows / BLOCK);
10468
+ const marked = new Uint8Array(bw * bh);
10469
+ let worst = 0;
10470
+ for (const run of runs) {
10471
+ if (run.contrast <= 0) continue;
10472
+ const span = Math.max(8, FLICKER_PERIODS * run.period);
10473
+ for (let s = run.from; s < run.to; s += span) {
10474
+ const e = Math.min(run.to, s + span);
10475
+ const c0 = Math.max(0, Math.floor(s)), c1 = Math.min((run.axis === "x" ? a.cols : a.rows) - 1, Math.ceil(e));
10476
+ let sum = 0;
10477
+ for (let c = c0; c <= c1; c++) {
10478
+ const i = run.axis === "x" ? run.line * a.cols + c : c * a.cols + run.line;
10479
+ sum += Math.abs(b.lum[i] - a.lum[i]);
10480
+ }
10481
+ const hz = sum / (c1 - c0 + 1) / (dt / 1e3) / (2 * run.contrast);
10482
+ worst = Math.max(worst, hz);
10483
+ if (hz > FLASH_MAX_PER_SECOND) {
10484
+ const bl = runBlocks(a, run, s, e);
10485
+ for (let y = bl.by0; y <= bl.by1; y++) for (let x = bl.bx0; x <= bl.bx1; x++) marked[y * bw + x] = 1;
10486
+ }
10487
+ }
10488
+ }
10489
+ return { area: markedArea(a, marked), hz: worst };
10490
+ }
10491
+ var nonZero = (v) => v !== void 0 && (Array.isArray(v) ? v[0] !== 0 || v[1] !== 0 : v !== 0);
10492
+ function layerPatternMotion(spec, index) {
10493
+ const l = spec.layers[index];
10494
+ if (!l) return "static";
10495
+ const keyed = keyedLayers2(spec);
10496
+ if (keyed.has(index)) return "moving";
10497
+ const sprite = l.sprite;
10498
+ if (nonZero(l.spin) || l.rock || l.pulse || l.grow || l.emit || sprite.cycle) return "moving";
10499
+ const m = l.motion;
10500
+ const moves = !!m.speed && nonZero(m.speed);
10501
+ switch (m.type) {
10502
+ case "static":
10503
+ return "static";
10504
+ case "drift":
10505
+ if (!moves) return (m.bob ?? 0) !== 0 ? "moving" : "static";
10506
+ return (m.bob ?? 0) !== 0 || m.bidirectional ? "moving" : "flow";
10507
+ case "rise":
10508
+ if (!moves) return (m.sway ?? 0) !== 0 ? "moving" : "static";
10509
+ return (m.sway ?? 0) !== 0 ? "moving" : "flow";
10510
+ case "warp":
10511
+ return moves ? "flow" : "static";
10512
+ default:
10513
+ return "moving";
10514
+ }
10515
+ }
10516
+ function keyedLayers2(spec) {
10517
+ const out = /* @__PURE__ */ new Set();
10518
+ if (!spec.timeline) return out;
10519
+ for (const path of timelineTracks(spec).keys()) {
10520
+ const [root, id] = path.split(".");
10521
+ if (root === "layers" && /^\d+$/.test(id ?? "")) out.add(Number(id));
10522
+ else if (root === "groups" && id !== void 0) spec.layers.forEach((l, i) => {
10523
+ if (l.group === id) out.add(i);
10524
+ });
10525
+ else out.add(-1);
10526
+ }
10527
+ return out;
10528
+ }
10529
+ function backgroundMotion(spec) {
10530
+ if (keyedLayers2(spec).has(-1)) return "moving";
10531
+ const bg = spec.background;
10532
+ if (!bg) return "static";
10533
+ const drift = bg.drift;
10534
+ if (bg.type === "field") return drift && drift.amount !== 0 ? "flow" : "static";
10535
+ return drift ? "flow" : "static";
10536
+ }
10537
+ function patternSampleTimes(spec) {
10538
+ const tl = spec.timeline;
10539
+ if (!tl) return [BASE_T];
10540
+ const lap = tl.loop && tl.duration ? tl.duration : 0;
10541
+ const settle = /* @__PURE__ */ new Set();
10542
+ for (const keys of timelineTracks(spec).values()) {
10543
+ for (const k of keys) {
10544
+ const end = k.t + Math.max(0, k.dur ?? DEFAULT_KEY_DUR);
10545
+ settle.add(lap ? end % lap : end);
10546
+ }
10547
+ }
10548
+ settle.delete(BASE_T);
10549
+ const ts = [...settle].sort((a, b) => a - b);
10550
+ const room = PATTERN_MAX_SAMPLE_TIMES - 1;
10551
+ const picked = ts.length <= room ? ts : Array.from({ length: room }, (_, i) => ts[Math.round((i + 1) * (ts.length - 1) / room)]);
10552
+ return [BASE_T, ...new Set(picked)];
10553
+ }
10554
+ var pct = (f) => `${Math.round(f * 100)}%`;
10555
+ function describeRun(run) {
10556
+ const p = run.pitch ?? 0;
10557
+ return `${run.count} light-dark pairs of stripes at a pitch of ${p.toFixed(3)} of the short side (about ${Math.round(p * 1080)} px at 1080p), luminance swing ${run.contrast.toFixed(2)}`;
10558
+ }
10559
+ var REMEDY = "To pass: lower the stripes' contrast (keep light and dark within 0.1 relative luminance of each other), use five or fewer stripes, break the regular spacing, or confine the pattern to a smaller part of the frame.";
10560
+ function patternHazardReport(spec, opts = {}) {
10561
+ const viewport = opts.viewport ?? { width: 1920, height: 1080 };
10562
+ const ro = { viewport, seed: opts.seed };
10563
+ const motion = spec.layers.map((_, i) => layerPatternMotion(spec, i));
10564
+ const bgMotion = backgroundMotion(spec);
10565
+ const anyMotion = bgMotion !== "static" || motion.some((m, i) => m !== "static" && !isText(spec.layers[i]));
10566
+ const best = { fails: false, kind: null, at: null, area: 0, movingArea: 0, flickerArea: 0, flickerHz: 0, pairs: 0, pitch: null, contrast: 0, rasters: 0 };
10567
+ let rasters = 0;
10568
+ for (const t of opts.times ?? patternSampleTimes(spec)) {
10569
+ const full = raster(prepared(spec, t, () => true), t, ro);
10570
+ rasters++;
10571
+ const runs = scanRaster(full);
10572
+ const area = patternArea(full, runs);
10573
+ const top = runs.reduce((a, b) => !a || b.count > a.count ? b : a, null);
10574
+ const found = {
10575
+ fails: false,
10576
+ kind: null,
10577
+ at: runs.length ? t : null,
10578
+ area,
10579
+ movingArea: 0,
10580
+ flickerArea: 0,
10581
+ flickerHz: 0,
10582
+ pairs: top?.count ?? 0,
10583
+ pitch: top?.pitch ?? null,
10584
+ contrast: top?.contrast ?? 0,
10585
+ rasters: 0
10586
+ };
10587
+ const where = `at t = ${Math.round(t)} ms`;
10588
+ if (top && area > PATTERN_STATIONARY_AREA) {
10589
+ return {
10590
+ ...found,
10591
+ fails: true,
10592
+ kind: "pattern",
10593
+ rasters,
10594
+ message: `pattern hazard (photosensitivity): ${where} the frame shows a regular stripe pattern (${describeRun(top)}) over ${pct(area)} of the frame. ITU-R BT.1702 treats more than five light-dark pairs of such stripes as harmful above 40% of the screen when still, and above 25% when they move, turn or oscillate. ${REMEDY}`
10595
+ };
10596
+ }
10597
+ if (top && area > PATTERN_MOVING_AREA) {
10598
+ const movers = motion.map((m) => m === "moving");
10599
+ if (movers.some((m, i) => m && !isText(spec.layers[i])) || bgMotion === "moving") {
10600
+ const flat = bgMotion === "moving" ? void 0 : full.meanGround;
10601
+ const own = raster(prepared(spec, t, (_, i) => movers[i], flat), t, ro);
10602
+ rasters++;
10603
+ const ownRuns = scanRaster(own);
10604
+ found.movingArea = patternArea(own, ownRuns);
10605
+ if (found.movingArea > PATTERN_MOVING_AREA) {
10606
+ const ownTop = ownRuns.reduce((a, b) => !a || b.count > a.count ? b : a, null) ?? top;
10607
+ return {
10608
+ ...found,
10609
+ fails: true,
10610
+ kind: "moving-pattern",
10611
+ rasters,
10612
+ message: `pattern hazard (photosensitivity): ${where} a regular stripe pattern (${describeRun(ownTop)}) that moves, turns or oscillates covers ${pct(found.movingArea)} of the frame. ITU-R BT.1702 limits a moving pattern of more than five light-dark pairs to 25% of the screen (40% when still). ${REMEDY} Or hold it still.`
10613
+ };
10614
+ }
10615
+ }
10616
+ if (anyMotion) {
10617
+ const later = raster(prepared(spec, t + DELTA_MS, () => true), t + DELTA_MS, ro);
10618
+ rasters++;
10619
+ const f = flicker(full, later, runs, DELTA_MS);
10620
+ found.flickerArea = f.area;
10621
+ found.flickerHz = f.hz;
10622
+ if (f.area > PATTERN_FLICKER_AREA) {
10623
+ return {
10624
+ ...found,
10625
+ fails: true,
10626
+ kind: "flicker",
10627
+ rasters,
10628
+ message: `pattern hazard (photosensitivity): ${where} a regular stripe pattern (${describeRun(top)}) moves across its stripes fast enough that ${pct(f.area)} of the frame flickers more than ${FLASH_MAX_PER_SECOND} times a second (up to ${Math.round(f.hz)}): each point turns light and dark as the stripes pass, at speed \xF7 pitch. More than 3 a second over 25% of the frame is a flash hazard. Slow it (under 3 stripe pitches a second), widen the pitch, lower the contrast, or shrink the patterned area.`
10629
+ };
10630
+ }
10631
+ }
10632
+ }
10633
+ if (area > best.area || best.at === null && found.at !== null) Object.assign(best, found);
10634
+ }
10635
+ best.rasters = rasters;
10636
+ return best;
10637
+ }
10638
+
9862
10639
  // src/examples/aquarium.ts
9863
10640
  var AQUARIUM_SPEC = {
9864
10641
  schemaVersion: 1,
@@ -12376,6 +13153,14 @@ export {
12376
13153
  MURMUR_SPEC,
12377
13154
  NOSTALGHIA_CANDLE_SPEC,
12378
13155
  ORRERY_SPEC,
13156
+ PATTERN_FLICKER_AREA,
13157
+ PATTERN_GRID_SHORT_SIDE,
13158
+ PATTERN_MAX_PITCH,
13159
+ PATTERN_MAX_SAMPLE_TIMES,
13160
+ PATTERN_MIN_EXTENT,
13161
+ PATTERN_MIN_PAIRS,
13162
+ PATTERN_MOVING_AREA,
13163
+ PATTERN_STATIONARY_AREA,
12379
13164
  PHASE_DUET_SPEC,
12380
13165
  PINGS_SPEC,
12381
13166
  POLYGONS_SPEC,
@@ -12385,6 +13170,7 @@ export {
12385
13170
  SAKURA_SPEC,
12386
13171
  SCHEMA_EXAMPLES,
12387
13172
  SCHEMA_VERSION,
13173
+ SEAMLESS_PERIOD_KINDS,
12388
13174
  SHARD_FALL_SPEC,
12389
13175
  SIGNAL_BOARD_SPEC,
12390
13176
  SNOWFALL_SPEC,
@@ -12419,6 +13205,7 @@ export {
12419
13205
  createFinishPass,
12420
13206
  createSceneCanvas,
12421
13207
  describeScene,
13208
+ describeStructuralDifferences,
12422
13209
  diffScenes,
12423
13210
  dominanceRanking,
12424
13211
  drawSpan,
@@ -12448,6 +13235,7 @@ export {
12448
13235
  latticeHash,
12449
13236
  layerCellFootprints,
12450
13237
  layerCohesion,
13238
+ layerPatternMotion,
12451
13239
  lerpPathD,
12452
13240
  lerpSpec,
12453
13241
  lerpValue,
@@ -12465,6 +13253,8 @@ export {
12465
13253
  normalizeSeed,
12466
13254
  pathFrame,
12467
13255
  pathParts,
13256
+ patternHazardReport,
13257
+ patternSampleTimes,
12468
13258
  perceiveScene,
12469
13259
  perceiveSequenceFrame,
12470
13260
  periodWithoutTimeline,
@@ -12496,6 +13286,7 @@ export {
12496
13286
  steerablePaths,
12497
13287
  strokeDrawnSamples,
12498
13288
  strokeLines,
13289
+ structuralDifferences,
12499
13290
  structuralSignature,
12500
13291
  textBlockAnchorOffset,
12501
13292
  textBlockText,