@idle-screens/schema 3.15.0 → 3.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -106,6 +106,8 @@ var LIMITS = {
106
106
  maxShapePoints: 24,
107
107
  // polygon.points / stroke.points
108
108
  maxTextNumberDecimals: 6,
109
+ maxTextNumberMinDigits: 12,
110
+ // a counter's zero-padded integer digits
109
111
  maxTextAffix: 64,
110
112
  // characters of a counter's prefix / suffix
111
113
  maxRevealRise: 1,
@@ -165,7 +167,7 @@ function readSpecPath(spec, path) {
165
167
  const loc = resolveSpecPath(spec, path);
166
168
  return loc ? loc.parent[loc.key] : void 0;
167
169
  }
168
- var TRANSFORM_IDENTITY = { x: 0, y: 0, scale: 1, scaleX: 1, rotate: 0 };
170
+ var TRANSFORM_IDENTITY = { x: 0, y: 0, scale: 1, scaleX: 1, scaleY: 1, rotate: 0 };
169
171
  function alignTransform(a, b) {
170
172
  const isObj2 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
171
173
  if (!isObj2(a) || !isObj2(b)) return [a, b];
@@ -916,8 +918,9 @@ function trimPolyline(pts, a, b) {
916
918
  }
917
919
  return out.length >= 2 ? out : [];
918
920
  }
919
- function trimLines(lines, a, b) {
920
- const key = `${a}|${b}`;
921
+ function trimLines(lines, a, b, mode = "run") {
922
+ const each = mode === "each";
923
+ const key = `${a}|${b}|${each ? "each" : "run"}`;
921
924
  let byLines = trimCache.get(lines);
922
925
  const hit = byLines?.get(key);
923
926
  if (hit) return hit;
@@ -928,7 +931,19 @@ function trimLines(lines, a, b) {
928
931
  });
929
932
  const total = lens.reduce((m, v) => m + v, 0);
930
933
  const out = [];
931
- if (total > 0 && b > a) {
934
+ if (each && b > a) {
935
+ lines.forEach((l, i) => {
936
+ if (!(lens[i] > 0)) return;
937
+ if (a <= 0 && b >= 1) {
938
+ out.push(l);
939
+ return;
940
+ }
941
+ const pts = l.points.map(([x, y]) => ({ x, y }));
942
+ if (l.closed && pts.length > 1) pts.push(pts[0]);
943
+ const cut = trimPolyline(pts, a, b);
944
+ if (cut.length >= 2) out.push({ points: cut.map((q) => [q.x, q.y]), closed: false });
945
+ });
946
+ } else if (total > 0 && b > a) {
932
947
  const from = a * total;
933
948
  const to = b * total;
934
949
  let run = 0;
@@ -958,7 +973,7 @@ function trimLines(lines, a, b) {
958
973
  var trimCache = /* @__PURE__ */ new WeakMap();
959
974
  function strokeLines(part) {
960
975
  const span = drawSpan(part.stroke?.draw);
961
- return span ? trimLines(part.flat.lines, span[0], span[1]) : part.flat.lines;
976
+ return span ? trimLines(part.flat.lines, span[0], span[1], part.stroke?.draw?.mode) : part.flat.lines;
962
977
  }
963
978
  function strokeTaper(u) {
964
979
  return Math.max(0.15, Math.sin(Math.PI * Math.max(0, Math.min(1, u))));
@@ -1058,7 +1073,7 @@ function flattenPath(d, rule = "auto") {
1058
1073
  }
1059
1074
  var spriteFlat = /* @__PURE__ */ new WeakMap();
1060
1075
  function flattenSprite(s) {
1061
- const d = s.parts ? s.parts.filter((p) => p.fill !== false).map((p) => p.path).join(" ") : s.path ?? "";
1076
+ const d = s.parts ? s.parts.filter((p) => p.fill !== false).map((p) => p.path).join(" ") : spritePathD(s) ?? "";
1062
1077
  const rule = s.parts ? "auto" : s.fillRule ?? "auto";
1063
1078
  const hit = spriteFlat.get(s);
1064
1079
  if (hit && hit.d === d && hit.rule === rule) return hit.flat;
@@ -1070,14 +1085,30 @@ function polygonFillRule(s) {
1070
1085
  return typeof s.path === "string" && s.fillRule === "evenodd" ? "evenodd" : "nonzero";
1071
1086
  }
1072
1087
  function isPathPolygon(s) {
1073
- return s.kind === "polygon" && (typeof s.path === "string" || Array.isArray(s.parts));
1088
+ return s.kind === "polygon" && (typeof s.path === "string" || Array.isArray(s.parts) || isPointsPath(s));
1089
+ }
1090
+ function isPointsPath(s) {
1091
+ return s.kind === "polygon" && typeof s.path !== "string" && !Array.isArray(s.parts) && Array.isArray(s.points) && s.points.length >= 2 && (s.stroke !== void 0 || s.fill === false || s.gradient !== void 0);
1092
+ }
1093
+ var pointsDs = /* @__PURE__ */ new WeakMap();
1094
+ function pointsPathD(points) {
1095
+ const hit = pointsDs.get(points);
1096
+ if (hit !== void 0) return hit;
1097
+ const d = `${points.map(([x, y], i) => `${i === 0 ? "M" : "L"} ${x} ${y}`).join(" ")} Z`;
1098
+ pointsDs.set(points, d);
1099
+ return d;
1100
+ }
1101
+ function spritePathD(s) {
1102
+ if (typeof s.path === "string") return s.path;
1103
+ return isPointsPath(s) ? pointsPathD(s.points) : void 0;
1074
1104
  }
1075
1105
  var spriteParts = /* @__PURE__ */ new WeakMap();
1076
1106
  function pathParts(s) {
1077
- const key = s.parts ?? s.path;
1107
+ const key = s.parts ?? s.path ?? s.points;
1078
1108
  const hit = spriteParts.get(s);
1079
1109
  if (hit && hit.key === key) return hit.parts;
1080
- const src = s.parts ?? (typeof s.path === "string" ? [{ path: s.path, fillRule: s.fillRule, fill: s.fill, gradient: s.gradient, stroke: s.stroke }] : []);
1110
+ const d = s.parts ? void 0 : spritePathD(s);
1111
+ const src = s.parts ?? (d !== void 0 ? [{ path: d, fillRule: typeof s.path === "string" ? s.fillRule : void 0, fill: s.fill, gradient: s.gradient, stroke: s.stroke }] : []);
1081
1112
  const parts = src.map((p) => {
1082
1113
  const flat = flattenPath(p.path, p.fillRule ?? "auto");
1083
1114
  return {
@@ -1571,11 +1602,17 @@ function pathDrawCost(s) {
1571
1602
  return c;
1572
1603
  }
1573
1604
 
1605
+ // src/validate-hints.ts
1606
+ var SCALAR_ONLY = /not a \[min,max\] range/;
1607
+ var SCALAR_BOUND = /^must be (?:>|an? (?:positive |single |finite |whole )?(?:number|integer)\b|between -?\d|-?\d+(?:\.\d+)?\.\.-?\d+(?:\.\d+)?(?: \(|$))/;
1608
+ var scalarWanted = (m) => SCALAR_ONLY.test(m) || !m.includes("[") && SCALAR_BOUND.test(m);
1609
+
1574
1610
  // src/validate.ts
1575
1611
  var HEX2 = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
1576
1612
  var isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
1577
1613
  var isStr = (v) => typeof v === "string";
1578
1614
  var isNum = (v) => typeof v === "number" && Number.isFinite(v);
1615
+ var validPointPairs = (v) => Array.isArray(v) && v.length >= 2 && v.every((p) => Array.isArray(p) && p.length === 2 && isNum(p[0]) && isNum(p[1]));
1579
1616
  var isRange = (v) => Array.isArray(v) && v.length === 2 && isNum(v[0]) && isNum(v[1]) && v[0] <= v[1];
1580
1617
  var KNOWN_TOP = /* @__PURE__ */ new Set(["schemaVersion", "id", "label", "seed", "motionIntensity", "density", "units", "referenceViewport", "background", "layers", "ghosting", "finish", "timeline", "groups"]);
1581
1618
  var KNOWN_FINISH = /* @__PURE__ */ new Set(["grain", "dither", "animate"]);
@@ -1606,7 +1643,7 @@ var KNOWN_LAYER = /* @__PURE__ */ new Set([
1606
1643
  "transform",
1607
1644
  "group"
1608
1645
  ]);
1609
- var KNOWN_TRANSFORM = /* @__PURE__ */ new Set(["x", "y", "scale", "scaleX", "rotate", "origin"]);
1646
+ var KNOWN_TRANSFORM = /* @__PURE__ */ new Set(["x", "y", "scale", "scaleX", "scaleY", "rotate", "origin"]);
1610
1647
  var KNOWN_GROUP = /* @__PURE__ */ new Set(["transform", "opacity"]);
1611
1648
  var GROUP_NAME = /^[A-Za-z][A-Za-z0-9_-]{0,31}$/;
1612
1649
  var KNOWN_POSITION = /* @__PURE__ */ new Set(["x", "y", "dx", "dy"]);
@@ -1624,7 +1661,7 @@ var KNOWN_STROKE = /* @__PURE__ */ new Set(["kind", "length", "points", "color",
1624
1661
  var KNOWN_EMOJI = /* @__PURE__ */ new Set(["kind", "glyphs", "cycle"]);
1625
1662
  var KNOWN_TEXT = /* @__PURE__ */ new Set(["kind", "strings", "color", "font", "align", "baseline", "maxWidth", "cycle", "role"]);
1626
1663
  var KNOWN_TEXT_BLOCK = /* @__PURE__ */ new Set(["kind", "text", "maxWidth", "fontSize", "lineHeight", "align", "color", "reveal", "anchor", "font", "opacity", "role", "number"]);
1627
- var KNOWN_TEXT_NUMBER = /* @__PURE__ */ new Set(["value", "decimals", "prefix", "suffix", "grouping"]);
1664
+ var KNOWN_TEXT_NUMBER = /* @__PURE__ */ new Set(["value", "decimals", "prefix", "suffix", "grouping", "minDigits"]);
1628
1665
  var TEXT_ROLES = /* @__PURE__ */ new Set(["read", "atmosphere"]);
1629
1666
  var TEXT_BLOCK_ANCHORS = /* @__PURE__ */ new Set(["top-left", "top", "top-right", "left", "center", "right", "bottom-left", "bottom", "bottom-right"]);
1630
1667
  var FONT_SIZE_RE = /\d(?:px|pt|pc|em|rem|ex|ch|vw|vh|vmin|vmax|%)/i;
@@ -1702,6 +1739,15 @@ var KNOWN_BG_SOLID = /* @__PURE__ */ new Set(["type", "color"]);
1702
1739
  var KNOWN_BG_GRADIENT = /* @__PURE__ */ new Set(["type", "stops", "band", "drift"]);
1703
1740
  var KNOWN_BG_FIELD = /* @__PURE__ */ new Set(["type", "scale", "octaves", "warp", "quantize", "bands", "drift", "seed"]);
1704
1741
  var KNOWN_FIELD_DRIFT = /* @__PURE__ */ new Set(["period", "amount"]);
1742
+ var KNOWN_BG_STOP = /* @__PURE__ */ new Set(["at", "color"]);
1743
+ var KNOWN_BAND = /* @__PURE__ */ new Set(["color", "height"]);
1744
+ var KNOWN_BG_DRIFT = /* @__PURE__ */ new Set(["period", "amount"]);
1745
+ var KNOWN_PATH_STROKE = /* @__PURE__ */ new Set(["color", "width", "opacity", "join", "cap", "draw"]);
1746
+ var KNOWN_PATH_GRADIENT = {
1747
+ linear: /* @__PURE__ */ new Set(["type", "from", "to", "stops", "units"]),
1748
+ radial: /* @__PURE__ */ new Set(["type", "center", "radius", "focus", "stops", "units"])
1749
+ };
1750
+ var KNOWN_PATH_STOP = /* @__PURE__ */ new Set(["at", "color", "opacity"]);
1705
1751
  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"]);
1706
1752
  function unknownKeys(obj, known) {
1707
1753
  return Object.keys(obj).filter((k) => !known.has(k));
@@ -1719,6 +1765,50 @@ function positiveScalar(v, path, err, msg = "must be > 0") {
1719
1765
  if (Array.isArray(v)) err(path, "must be a single number > 0, not a [min,max] range");
1720
1766
  else if (!isNum(v) || v <= 0) err(path, msg);
1721
1767
  }
1768
+ function valueAtPath(root, path) {
1769
+ let v = root;
1770
+ for (const seg of path.match(/[^.[\]]+/g) ?? []) {
1771
+ if (Array.isArray(v) && /^\d+$/.test(seg)) v = v[Number(seg)];
1772
+ else if (isObj(v)) v = v[seg];
1773
+ else return void 0;
1774
+ }
1775
+ return v;
1776
+ }
1777
+ function echo(v) {
1778
+ if (isNum(v)) return String(v);
1779
+ if (Array.isArray(v) && v.length <= 4 && v.every(isNum)) return `[${v.join(", ")}]`;
1780
+ const s = JSON.stringify(v) ?? String(v);
1781
+ return s.length > 48 ? `${s.slice(0, 45)}\u2026` : s;
1782
+ }
1783
+ var RANGE_WANTED = /^must be a \[min,max\] range/;
1784
+ function shapeHint(spec, path, message) {
1785
+ if (message.includes(" \u2014 got ")) return "";
1786
+ const v = valueAtPath(spec, path);
1787
+ if (v === void 0) return "";
1788
+ if (RANGE_WANTED.test(message)) {
1789
+ if (isNum(v)) {
1790
+ const fits = message.includes("within 0..1") ? v >= 0 && v <= 1 : message.includes(">= 0") ? v >= 0 : !message.includes("positive") || v > 0;
1791
+ return fits ? ` \u2014 got ${v}; write [${v}, ${v}]` : ` \u2014 got ${v}; a range is two numbers, [min, max]`;
1792
+ }
1793
+ if (isObj(v) && ("min" in v || "max" in v)) {
1794
+ return ` \u2014 got {min, max}; write [${isNum(v.min) ? v.min : "min"}, ${isNum(v.max) ? v.max : "max"}]`;
1795
+ }
1796
+ if (Array.isArray(v) && v.length === 2 && isNum(v[0]) && isNum(v[1]) && v[0] > v[1]) {
1797
+ return ` \u2014 got ${echo(v)}; the smaller number comes first: write [${v[1]}, ${v[0]}]`;
1798
+ }
1799
+ return ` \u2014 got ${echo(v)}`;
1800
+ }
1801
+ if (Array.isArray(v) && scalarWanted(message)) {
1802
+ const segs = path.match(/[^.[\]]+/g) ?? [];
1803
+ const key = segs[segs.length - 1] ?? path;
1804
+ const parentKey = segs[segs.length - 2];
1805
+ const parent = valueAtPath(spec, path.slice(0, path.lastIndexOf(".")));
1806
+ const kind = isObj(parent) && isStr(parent.kind) ? parent.kind : void 0;
1807
+ const noun = kind ? `${/^[aeiou]/i.test(kind) ? "an" : "a"} ${kind}'s \`${key}\`` : parentKey && !/^\d+$/.test(parentKey) && parentKey !== "sprite" ? `\`${parentKey}.${key}\`` : `\`${key}\``;
1808
+ return ` \u2014 got ${echo(v)}; ${noun} is one number`;
1809
+ }
1810
+ return "";
1811
+ }
1722
1812
  function normalizeColors(spec) {
1723
1813
  if (!isObj(spec) || !Array.isArray(spec.layers)) return;
1724
1814
  for (const layer of spec.layers) {
@@ -1787,7 +1877,7 @@ function specPathsValid(spec, paths) {
1787
1877
  function validateSpecCore(spec, checkApplied = true) {
1788
1878
  const errors = [];
1789
1879
  const warnings = [];
1790
- const err = (path, message) => void errors.push({ path, message });
1880
+ const err = (path, message) => void errors.push({ path, message: message + shapeHint(spec, path, message) });
1791
1881
  const warn = (path, code, message) => void warnings.push({ path, code, message });
1792
1882
  if (!isObj(spec)) return { valid: false, errors: [{ path: "", message: "spec must be an object" }], warnings: [] };
1793
1883
  if (spec.schemaVersion !== SCHEMA_VERSION) err("schemaVersion", `must be ${SCHEMA_VERSION}`);
@@ -1834,7 +1924,7 @@ function validateSpecCore(spec, checkApplied = true) {
1834
1924
  for (const l of spec.layers) {
1835
1925
  const sp = isObj(l) ? l.sprite : void 0;
1836
1926
  if (!sp || sp.kind !== "polygon") continue;
1837
- const pieces = Array.isArray(sp.parts) ? sp.parts : typeof sp.path === "string" ? [sp] : [];
1927
+ const pieces = Array.isArray(sp.parts) ? sp.parts : typeof sp.path === "string" ? [sp] : isPointsPath(sp) && validPointPairs(sp.points) ? [{ ...sp, path: pointsPathD(sp.points), fillRule: void 0 }] : [];
1838
1928
  const n = typeof l.count === "number" && l.count > 0 ? l.count : 1;
1839
1929
  for (const pc of pieces) {
1840
1930
  if (!isObj(pc) || typeof pc.path !== "string") continue;
@@ -1857,7 +1947,7 @@ function validateSpecCore(spec, checkApplied = true) {
1857
1947
  return { valid: errors.length === 0, errors, warnings };
1858
1948
  }
1859
1949
  function validateTransform(tf, path, err, warn, spec, anchorOk) {
1860
- if (!isObj(tf)) return err(path, "must be an object {x?, y?, scale?, scaleX?, rotate?, origin?}");
1950
+ if (!isObj(tf)) return err(path, "must be an object {x?, y?, scale?, scaleX?, scaleY?, rotate?, origin?}");
1861
1951
  for (const k of unknownKeys(tf, KNOWN_TRANSFORM)) warn(`${path}.${k}`, "unknown-property", `unknown transform property '${k}' \u2014 will be ignored`);
1862
1952
  const [off, unit] = offsetBound(spec);
1863
1953
  const sc = LIMITS.maxLayerTransformScale;
@@ -1865,7 +1955,9 @@ function validateTransform(tf, path, err, warn, spec, anchorOk) {
1865
1955
  if (tf[axis] !== void 0 && (!isNum(tf[axis]) || Math.abs(tf[axis]) > off)) err(`${path}.${axis}`, `must be a number within \xB1${off} (${unit})`);
1866
1956
  }
1867
1957
  if (tf.scale !== void 0 && (!isNum(tf.scale) || tf.scale < 0 || tf.scale > sc)) err(`${path}.scale`, `must be a number 0..${sc}`);
1868
- if (tf.scaleX !== void 0 && (!isNum(tf.scaleX) || Math.abs(tf.scaleX) > sc)) err(`${path}.scaleX`, `must be a number within \xB1${sc} (negative mirrors)`);
1958
+ for (const axis of ["scaleX", "scaleY"]) {
1959
+ if (tf[axis] !== void 0 && (!isNum(tf[axis]) || Math.abs(tf[axis]) > sc)) err(`${path}.${axis}`, `must be a number within \xB1${sc} (negative mirrors)`);
1960
+ }
1869
1961
  if (tf.rotate !== void 0 && (!isNum(tf.rotate) || Math.abs(tf.rotate) > LIMITS.maxLayerTransformRotate)) {
1870
1962
  err(`${path}.rotate`, `must be a number of degrees within \xB1${LIMITS.maxLayerTransformRotate}`);
1871
1963
  }
@@ -2014,9 +2106,12 @@ function validateTimelineKeys(spec, errOuter, warn, checkApplied) {
2014
2106
  return;
2015
2107
  }
2016
2108
  }
2017
- function color(v, path, err) {
2018
- if (!isStr(v) || !HEX2.test(v)) err(path, "must be a hex colour like #0a3a52");
2109
+ function color(v, path, err, missing = "is required (a hex colour like #0a3a52)") {
2110
+ if (v === void 0) err(path, missing);
2111
+ else if (!isStr(v) || !HEX2.test(v)) err(path, "must be a hex colour like #0a3a52");
2019
2112
  }
2113
+ var SPRITE_COLOR_MISSING = "`color` is required (or a `colors` palette)";
2114
+ 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`;
2020
2115
  function validateFinish(f, path, err, warn) {
2021
2116
  if (!isObj(f)) return err(path, "must be an object");
2022
2117
  for (const k of ["grain", "dither"]) {
@@ -2041,6 +2136,7 @@ function validateBackground(bg, err, warn) {
2041
2136
  bg.stops.forEach((s, i) => {
2042
2137
  if (!isObj(s) || !isNum(s.at) || s.at < 0 || s.at > 1) err(`background.stops[${i}].at`, "must be 0..1");
2043
2138
  color(isObj(s) ? s.color : void 0, `background.stops[${i}].color`, err);
2139
+ 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`);
2044
2140
  });
2045
2141
  }
2046
2142
  if (bg.drift !== void 0) {
@@ -2052,6 +2148,7 @@ function validateBackground(bg, err, warn) {
2052
2148
  if (bg.drift.amount !== void 0 && (!isNum(bg.drift.amount) || bg.drift.amount <= 0 || bg.drift.amount > LIMITS.maxDriftAmount)) {
2053
2149
  err("background.drift.amount", `must be > 0 and <= ${LIMITS.maxDriftAmount}`);
2054
2150
  }
2151
+ for (const k of unknownKeys(bg.drift, KNOWN_BG_DRIFT)) warn(`background.drift.${k}`, "unknown-property", `unknown drift property '${k}' \u2014 will be ignored`);
2055
2152
  }
2056
2153
  }
2057
2154
  if (bg.band !== void 0) {
@@ -2059,6 +2156,7 @@ function validateBackground(bg, err, warn) {
2059
2156
  else {
2060
2157
  color(bg.band.color, "background.band.color", err);
2061
2158
  positiveScalar(bg.band.height, "background.band.height", err);
2159
+ for (const k of unknownKeys(bg.band, KNOWN_BAND)) warn(`background.band.${k}`, "unknown-property", `unknown band property '${k}' \u2014 will be ignored`);
2062
2160
  }
2063
2161
  }
2064
2162
  for (const k of unknownKeys(bg, KNOWN_BG_GRADIENT)) {
@@ -2180,8 +2278,8 @@ function validateLayer(layer, path, err, warn, spec) {
2180
2278
  if (layer.pulse !== void 0) {
2181
2279
  if (!isObj(layer.pulse)) err(`${path}.pulse`, "must be an object");
2182
2280
  else {
2183
- if (!isNum(layer.pulse.amp) || layer.pulse.amp <= 0 || layer.pulse.amp > LIMITS.maxPulseAmp) {
2184
- err(`${path}.pulse.amp`, `must be within 0..${LIMITS.maxPulseAmp}`);
2281
+ if (!isNum(layer.pulse.amp) || layer.pulse.amp < 0 || layer.pulse.amp > LIMITS.maxPulseAmp) {
2282
+ err(`${path}.pulse.amp`, `must be a number from 0 (no pulse) to ${LIMITS.maxPulseAmp}`);
2185
2283
  }
2186
2284
  if (!isNum(layer.pulse.period) || layer.pulse.period < LIMITS.minPulsePeriod) {
2187
2285
  err(`${path}.pulse.period`, `must be >= ${LIMITS.minPulsePeriod} ms (flash-safety floor)`);
@@ -2466,19 +2564,19 @@ function validateSprite(sprite, path, err, warn, spec) {
2466
2564
  } else if (sprite.kind === "circle") {
2467
2565
  knownSet = KNOWN_CIRCLE;
2468
2566
  if (!isRange(sprite.radius) || sprite.radius[0] <= 0) err(`${path}.radius`, rangeMsg(spec));
2469
- color(sprite.color, `${path}.color`, err);
2567
+ color(sprite.color, `${path}.color`, err, SPRITE_COLOR_MISSING);
2470
2568
  if (sprite.soft !== void 0 && typeof sprite.soft !== "boolean") err(`${path}.soft`, "must be a boolean");
2471
2569
  validatePalette(sprite, path, err);
2472
2570
  } else if (sprite.kind === "ring") {
2473
2571
  knownSet = KNOWN_RING;
2474
2572
  if (!isRange(sprite.radius) || sprite.radius[0] <= 0) err(`${path}.radius`, rangeMsg(spec));
2475
- color(sprite.color, `${path}.color`, err);
2573
+ color(sprite.color, `${path}.color`, err, SPRITE_COLOR_MISSING);
2476
2574
  if (sprite.width !== void 0) positiveScalar(sprite.width, `${path}.width`, err);
2477
2575
  validatePalette(sprite, path, err);
2478
2576
  } else if (sprite.kind === "streak") {
2479
2577
  knownSet = KNOWN_STREAK;
2480
2578
  if (!isRange(sprite.length) || sprite.length[0] <= 0) err(`${path}.length`, rangeMsg(spec));
2481
- color(sprite.color, `${path}.color`, err);
2579
+ color(sprite.color, `${path}.color`, err, SPRITE_COLOR_MISSING);
2482
2580
  if (sprite.width !== void 0) positiveScalar(sprite.width, `${path}.width`, err);
2483
2581
  validatePalette(sprite, path, err);
2484
2582
  } else if (sprite.kind === "rect") {
@@ -2490,7 +2588,7 @@ function validateSprite(sprite, path, err, warn, spec) {
2490
2588
  if (sprite.feather !== void 0 && (!isNum(sprite.feather) || sprite.feather < 0 || sprite.feather > 1)) {
2491
2589
  err(`${path}.feather`, "must be 0..1 (fraction of the half-size that fades out)");
2492
2590
  }
2493
- color(sprite.color, `${path}.color`, err);
2591
+ color(sprite.color, `${path}.color`, err, SPRITE_COLOR_MISSING);
2494
2592
  validatePalette(sprite, path, err);
2495
2593
  } else if (sprite.kind === "bar") {
2496
2594
  knownSet = KNOWN_BAR;
@@ -2503,7 +2601,7 @@ function validateSprite(sprite, path, err, warn, spec) {
2503
2601
  if (sprite.direction !== void 0 && !["right", "left", "up", "down"].includes(sprite.direction)) {
2504
2602
  err(`${path}.direction`, "must be 'right' | 'left' | 'up' | 'down'");
2505
2603
  }
2506
- color(sprite.color, `${path}.color`, err);
2604
+ color(sprite.color, `${path}.color`, err, SPRITE_COLOR_MISSING);
2507
2605
  validatePalette(sprite, path, err);
2508
2606
  } else if (sprite.kind === "polygon") {
2509
2607
  knownSet = KNOWN_POLYGON;
@@ -2534,8 +2632,11 @@ function validateSprite(sprite, path, err, warn, spec) {
2534
2632
  } else if (sprite.path !== void 0) {
2535
2633
  if (sprite.points !== void 0 || sprite.sides !== void 0) err(`${path}.path`, "use path, points or sides, not more than one");
2536
2634
  validatePathPiece(sprite, path, err, warn);
2635
+ } else if (sprite.points !== void 0) {
2636
+ validatePiecePaint(sprite, path, err, warn);
2637
+ if (sprite.fillRule !== void 0) warn(`${path}.fillRule`, "unknown-property", "fillRule applies only to a path \u2014 ignored");
2537
2638
  } else {
2538
- for (const k of ["gradient", "stroke", "fill"]) if (sprite[k] !== void 0) err(`${path}.${k}`, `${k} applies only to a polygon with a path (or parts)`);
2639
+ for (const k of ["gradient", "stroke", "fill"]) if (sprite[k] !== void 0) err(`${path}.${k}`, `${k} applies only to a polygon with a path, points or parts`);
2539
2640
  if (sprite.fillRule !== void 0) warn(`${path}.fillRule`, "unknown-property", "fillRule applies only to a path \u2014 ignored");
2540
2641
  }
2541
2642
  if (sprite.span !== void 0 || sprite.origin !== void 0) {
@@ -2544,7 +2645,7 @@ function validateSprite(sprite, path, err, warn, spec) {
2544
2645
  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");
2545
2646
  }
2546
2647
  if (sprite.soft !== void 0 && typeof sprite.soft !== "boolean") err(`${path}.soft`, "must be a boolean");
2547
- color(sprite.color, `${path}.color`, err);
2648
+ color(sprite.color, `${path}.color`, err, POLYGON_COLOR_MISSING);
2548
2649
  validatePalette(sprite, path, err);
2549
2650
  } else if (sprite.kind === "stroke") {
2550
2651
  knownSet = KNOWN_STROKE;
@@ -2555,7 +2656,7 @@ function validateSprite(sprite, path, err, warn, spec) {
2555
2656
  if (sprite.taper !== void 0 && typeof sprite.taper !== "boolean") err(`${path}.taper`, "must be a boolean");
2556
2657
  if (sprite.orient !== void 0 && typeof sprite.orient !== "boolean") err(`${path}.orient`, "must be a boolean");
2557
2658
  if (sprite.draw !== void 0) validateDraw(sprite.draw, `${path}.draw`, err, warn);
2558
- color(sprite.color, `${path}.color`, err);
2659
+ color(sprite.color, `${path}.color`, err, SPRITE_COLOR_MISSING);
2559
2660
  validatePalette(sprite, path, err);
2560
2661
  } else if (sprite.kind === "textBlock") {
2561
2662
  knownSet = KNOWN_TEXT_BLOCK;
@@ -2597,7 +2698,7 @@ function validateSprite(sprite, path, err, warn, spec) {
2597
2698
  validateTextRole(sprite, path, err);
2598
2699
  if (sprite.number !== void 0) {
2599
2700
  const n = sprite.number;
2600
- if (!isObj(n)) err(`${path}.number`, "must be { value, decimals?, prefix?, suffix?, grouping? }");
2701
+ if (!isObj(n)) err(`${path}.number`, "must be { value, decimals?, prefix?, suffix?, grouping?, minDigits? }");
2601
2702
  else {
2602
2703
  if (!isNum(n.value)) err(`${path}.number.value`, "must be a finite number");
2603
2704
  if (n.decimals !== void 0 && (!isNum(n.decimals) || !Number.isInteger(n.decimals) || n.decimals < 0 || n.decimals > LIMITS.maxTextNumberDecimals)) {
@@ -2608,6 +2709,9 @@ function validateSprite(sprite, path, err, warn, spec) {
2608
2709
  if (v !== void 0 && (!isStr(v) || v.length > LIMITS.maxTextAffix)) err(`${path}.number.${k}`, `must be a string of at most ${LIMITS.maxTextAffix} characters`);
2609
2710
  }
2610
2711
  if (n.grouping !== void 0 && typeof n.grouping !== "boolean") err(`${path}.number.grouping`, "must be a boolean");
2712
+ if (n.minDigits !== void 0 && (!isNum(n.minDigits) || !Number.isInteger(n.minDigits) || n.minDigits < 1 || n.minDigits > LIMITS.maxTextNumberMinDigits)) {
2713
+ err(`${path}.number.minDigits`, `must be a whole number 1..${LIMITS.maxTextNumberMinDigits} (integer digits, zero-padded)`);
2714
+ }
2611
2715
  for (const k of unknownKeys(n, KNOWN_TEXT_NUMBER)) warn(`${path}.number.${k}`, "unknown-property", `unknown number property '${k}' \u2014 will be ignored`);
2612
2716
  }
2613
2717
  }
@@ -2857,13 +2961,16 @@ function validatePathPiece(piece, path, err, warn) {
2857
2961
  const problem = pathGeometryProblem(piece.path, piece.fillRule ?? "auto", piece.fill !== false);
2858
2962
  if (problem && !(keyedPathDepth > 0 && (problem === NO_AREA || problem === NO_LINE))) err(`${path}.path`, problem);
2859
2963
  }
2964
+ validatePiecePaint(piece, path, err, warn);
2965
+ if (piece.fillRule !== void 0 && !["auto", "nonzero", "evenodd"].includes(piece.fillRule)) err(`${path}.fillRule`, "must be 'auto' | 'nonzero' | 'evenodd'");
2966
+ }
2967
+ function validatePiecePaint(piece, path, err, warn) {
2860
2968
  if (piece.fill !== void 0 && typeof piece.fill !== "boolean") err(`${path}.fill`, "must be a boolean");
2861
2969
  if (piece.fill === false && piece.stroke === void 0) err(`${path}.fill`, "fill: false draws nothing without a stroke");
2862
- if (piece.gradient !== void 0) validatePathGradient(piece.gradient, `${path}.gradient`, err);
2970
+ if (piece.gradient !== void 0) validatePathGradient(piece.gradient, `${path}.gradient`, err, warn);
2863
2971
  if (piece.stroke !== void 0) validatePathStroke(piece.stroke, `${path}.stroke`, err, warn);
2864
- if (piece.fillRule !== void 0 && !["auto", "nonzero", "evenodd"].includes(piece.fillRule)) err(`${path}.fillRule`, "must be 'auto' | 'nonzero' | 'evenodd'");
2865
2972
  }
2866
- function validatePathGradient(g, path, err) {
2973
+ function validatePathGradient(g, path, err, warn) {
2867
2974
  if (!isObj(g)) return err(path, "must be { type: 'linear', from, to, stops } or { type: 'radial', center, radius, stops }");
2868
2975
  if (g.type === "linear") {
2869
2976
  if (!isPair(g.from)) err(`${path}.from`, "must be [x, y] in unit-box coordinates (-2..2)");
@@ -2875,6 +2982,7 @@ function validatePathGradient(g, path, err) {
2875
2982
  } else {
2876
2983
  return err(`${path}.type`, "must be 'linear' | 'radial'");
2877
2984
  }
2985
+ 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`);
2878
2986
  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)");
2879
2987
  if (!Array.isArray(g.stops) || g.stops.length < 2 || g.stops.length > 8) return err(`${path}.stops`, "must be 2..8 stops");
2880
2988
  let last = -Infinity;
@@ -2884,16 +2992,18 @@ function validatePathGradient(g, path, err) {
2884
2992
  else last = st.at;
2885
2993
  color(st.color, `${path}.stops[${i}].color`, err);
2886
2994
  if (st.opacity !== void 0 && (!isNum(st.opacity) || st.opacity < 0 || st.opacity > 1)) err(`${path}.stops[${i}].opacity`, "must be 0..1");
2995
+ for (const k of unknownKeys(st, KNOWN_PATH_STOP)) warn(`${path}.stops[${i}].${k}`, "unknown-property", `unknown stop property '${k}' \u2014 will be ignored`);
2887
2996
  });
2888
2997
  }
2889
2998
  function validatePathStroke(st, path, err, warn) {
2890
2999
  if (!isObj(st)) return err(path, "must be { color, width, opacity?, join?, cap?, draw? }");
2891
- color(st.color, `${path}.color`, err);
3000
+ 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");
2892
3001
  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)");
2893
3002
  if (st.opacity !== void 0 && (!isNum(st.opacity) || st.opacity < 0 || st.opacity > 1)) err(`${path}.opacity`, "must be 0..1");
2894
3003
  if (st.join !== void 0 && !["round", "miter", "bevel"].includes(st.join)) err(`${path}.join`, "must be 'round' | 'miter' | 'bevel'");
2895
3004
  if (st.cap !== void 0 && !["round", "butt", "square"].includes(st.cap)) err(`${path}.cap`, "must be 'round' | 'butt' | 'square'");
2896
3005
  if (st.draw !== void 0) validateDraw(st.draw, `${path}.draw`, err, warn);
3006
+ for (const k of unknownKeys(st, KNOWN_PATH_STROKE)) warn(`${path}.${k}`, "unknown-property", `unknown stroke property '${k}' \u2014 will be ignored`);
2897
3007
  }
2898
3008
  function validateWipe(tr, path, err, warn) {
2899
3009
  if (tr.shape !== void 0 && tr.shape !== "circle" && tr.shape !== "line") err(`${path}.shape`, "must be 'circle' | 'line'");
@@ -2912,12 +3022,13 @@ function validateWipe(tr, path, err, warn) {
2912
3022
  }
2913
3023
  }
2914
3024
  function validateDraw(d, path, err, warn) {
2915
- if (!isObj(d)) return err(path, "must be { start?, end? } \u2014 fractions of the line, 0..1");
3025
+ if (!isObj(d)) return err(path, "must be { start?, end?, mode? } \u2014 fractions of the line, 0..1; mode 'run' | 'each'");
2916
3026
  for (const k of ["start", "end"]) {
2917
3027
  const v = d[k];
2918
3028
  if (v !== void 0 && (!isNum(v) || v < 0 || v > 1)) err(`${path}.${k}`, "must be 0..1 (a fraction of the line's length)");
2919
3029
  }
2920
- for (const k of Object.keys(d)) if (k !== "start" && k !== "end") warn(`${path}.${k}`, "unknown-property", `unknown draw property '${k}' \u2014 draw takes start and end; will be ignored`);
3030
+ if (d.mode !== void 0 && d.mode !== "run" && d.mode !== "each") err(`${path}.mode`, "must be 'run' (one pen run through every subpath, default) | 'each' (every subpath draws at once)");
3031
+ for (const k of Object.keys(d)) if (k !== "start" && k !== "end" && k !== "mode") warn(`${path}.${k}`, "unknown-property", `unknown draw property '${k}' \u2014 draw takes start, end and mode; will be ignored`);
2921
3032
  }
2922
3033
  function validateShapePoints(points, path, min, err) {
2923
3034
  if (!Array.isArray(points) || points.length < min || points.length > LIMITS.maxShapePoints) {
@@ -3436,7 +3547,9 @@ function textBlockText(s) {
3436
3547
  const d = Math.max(0, Math.min(LIMITS.maxTextNumberDecimals, Math.round(n.decimals ?? 0)));
3437
3548
  const v = Number.isFinite(n.value) ? n.value : 0;
3438
3549
  const fixed = Math.abs(v).toFixed(d);
3439
- const [whole, frac] = fixed.split(".");
3550
+ const [digits, frac] = fixed.split(".");
3551
+ const pad = Math.max(1, Math.min(LIMITS.maxTextNumberMinDigits, Math.round(n.minDigits ?? 1)));
3552
+ const whole = digits.padStart(pad, "0");
3440
3553
  const grouped = n.grouping === false ? whole : whole.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
3441
3554
  const neg = v < 0 && Number(fixed) !== 0;
3442
3555
  return `${n.prefix ?? ""}${neg ? "-" : ""}${grouped}${frac !== void 0 ? `.${frac}` : ""}${n.suffix ?? ""}`;
@@ -3520,6 +3633,7 @@ function lifeStaggerAt(life, t, i) {
3520
3633
  }
3521
3634
  function wrap(v, min, max) {
3522
3635
  const range2 = max - min;
3636
+ if (range2 <= 0) return min;
3523
3637
  return ((v - min) % range2 + range2) % range2 + min;
3524
3638
  }
3525
3639
  function reflect(v, min, max) {
@@ -3947,8 +4061,9 @@ function transformOrigin(tf, layer, w, h, unit) {
3947
4061
  function transformPoint(tf, p, w, h, unit, origin = { x: w / 2, y: h / 2 }) {
3948
4062
  const s = tf.scale ?? 1;
3949
4063
  const sx = s * (tf.scaleX ?? 1);
4064
+ const sy = s * (tf.scaleY ?? 1);
3950
4065
  let x = (p.x - origin.x) * sx;
3951
- let y = (p.y - origin.y) * s;
4066
+ let y = (p.y - origin.y) * sy;
3952
4067
  if (tf.rotate) {
3953
4068
  const r = tf.rotate * Math.PI / 180;
3954
4069
  const c = Math.cos(r);
@@ -3960,7 +4075,61 @@ function transformPoint(tf, p, w, h, unit, origin = { x: w / 2, y: h / 2 }) {
3960
4075
  function transformSize(tf) {
3961
4076
  if (!tf) return 1;
3962
4077
  const s = tf.scale ?? 1;
3963
- return s * Math.sqrt(Math.abs(tf.scaleX ?? 1));
4078
+ return s * Math.sqrt(Math.abs((tf.scaleX ?? 1) * (tf.scaleY ?? 1)));
4079
+ }
4080
+ var TRANSFORM_FIELDS = { x: 0, y: 0, scale: 1, scaleX: 1, scaleY: 1, rotate: 0 };
4081
+ function transformRangeOf(tf) {
4082
+ const r = {};
4083
+ for (const f of Object.keys(TRANSFORM_FIELDS)) {
4084
+ const v = tf?.[f] ?? TRANSFORM_FIELDS[f];
4085
+ r[f] = [v, v];
4086
+ }
4087
+ return r;
4088
+ }
4089
+ function maxAxisScale(r) {
4090
+ const s = Math.max(Math.abs(r.scale[0]), Math.abs(r.scale[1]));
4091
+ return s * Math.max(1, Math.abs(r.scaleX[0]), Math.abs(r.scaleX[1]), Math.abs(r.scaleY[0]), Math.abs(r.scaleY[1]));
4092
+ }
4093
+ function sweptBox(r, p, origin, unit) {
4094
+ const ux = p.x - origin.x;
4095
+ const uy = p.y - origin.y;
4096
+ const b = { x0: Infinity, y0: Infinity, x1: -Infinity, y1: -Infinity };
4097
+ const take = (x, y) => {
4098
+ b.x0 = Math.min(b.x0, x);
4099
+ b.x1 = Math.max(b.x1, x);
4100
+ b.y0 = Math.min(b.y0, y);
4101
+ b.y1 = Math.max(b.y1, y);
4102
+ };
4103
+ const r0 = r.rotate[0] * Math.PI / 180;
4104
+ const r1 = r.rotate[1] * Math.PI / 180;
4105
+ const full = r.rotate[1] - r.rotate[0] >= 360;
4106
+ const QUARTER = Math.PI / 2;
4107
+ for (const s of r.scale) {
4108
+ for (const sx of r.scaleX) for (const sy of r.scaleY) {
4109
+ const vx = ux * s * sx;
4110
+ const vy = uy * s * sy;
4111
+ const len = Math.hypot(vx, vy);
4112
+ if (len === 0) {
4113
+ take(0, 0);
4114
+ continue;
4115
+ }
4116
+ if (full) {
4117
+ take(-len, -len);
4118
+ take(len, len);
4119
+ continue;
4120
+ }
4121
+ const a = Math.atan2(vy, vx);
4122
+ take(len * Math.cos(a + r0), len * Math.sin(a + r0));
4123
+ take(len * Math.cos(a + r1), len * Math.sin(a + r1));
4124
+ for (let phi = Math.ceil((a + r0) / QUARTER) * QUARTER; phi <= a + r1; phi += QUARTER) take(len * Math.cos(phi), len * Math.sin(phi));
4125
+ }
4126
+ }
4127
+ return {
4128
+ x0: b.x0 + origin.x + r.x[0] * unit,
4129
+ x1: b.x1 + origin.x + r.x[1] * unit,
4130
+ y0: b.y0 + origin.y + r.y[0] * unit,
4131
+ y1: b.y1 + origin.y + r.y[1] * unit
4132
+ };
3964
4133
  }
3965
4134
  function groupOf(spec, layer) {
3966
4135
  return layer.group === void 0 ? void 0 : spec.groups?.[layer.group];
@@ -5329,14 +5498,14 @@ var SpecInstance = class {
5329
5498
  * entity as it was at `t` (rotation, heading, values), but at the echo's
5330
5499
  * position, size and alpha.
5331
5500
  */
5332
- drawEntity(built, e, t, lifeA, parentE, paint, echo) {
5501
+ drawEntity(built, e, t, lifeA, parentE, paint, echo2) {
5333
5502
  const { ctx } = this;
5334
- const p = echo ? { x: echo.x, y: echo.y, flip: false } : this.entityPos(e, t, parentE);
5503
+ const p = echo2 ? { x: echo2.x, y: echo2.y, flip: false } : this.entityPos(e, t, parentE);
5335
5504
  const sprite = paint?.sprite ?? built.layer.sprite;
5336
- const sz = echo ? echo.size : sizeAt(e, t);
5505
+ const sz = echo2 ? echo2.size : sizeAt(e, t);
5337
5506
  const rot = rotationAt(e, t);
5338
5507
  const unitScale = this.effSpec.units === "px" ? 1 : Math.min(this.w, this.h);
5339
- ctx.globalAlpha = echo ? echo.alpha : alphaAt(e, t) * lifeA * (paint?.alpha ?? 1);
5508
+ ctx.globalAlpha = echo2 ? echo2.alpha : alphaAt(e, t) * lifeA * (paint?.alpha ?? 1);
5340
5509
  if (sprite.kind === "ring") {
5341
5510
  const r = sz / 2;
5342
5511
  const resolvedColor = sprite.colors?.[e.colorIndex] ?? sprite.color;
@@ -5936,7 +6105,7 @@ var SpecInstance = class {
5936
6105
  ctx.translate(cx + (tf.x ?? 0) * unit, cy + (tf.y ?? 0) * unit);
5937
6106
  if (tf.rotate) ctx.rotate(tf.rotate * Math.PI / 180);
5938
6107
  const s = tf.scale ?? 1;
5939
- ctx.scale(s * (tf.scaleX ?? 1), s);
6108
+ ctx.scale(s * (tf.scaleX ?? 1), s * (tf.scaleY ?? 1));
5940
6109
  ctx.translate(-cx, -cy);
5941
6110
  }
5942
6111
  /** Scene time for a timeline decision: the last painted frame's, else the loop clock's. */
@@ -7906,7 +8075,7 @@ function finishGrid(cols, rows, cells, bgRow, bgCells) {
7906
8075
  colProfile
7907
8076
  };
7908
8077
  }
7909
- function layerCellFootprints(spec, opts = {}) {
8078
+ function layerCellFootprints(spec, opts = {}, sweep) {
7910
8079
  const scene = buildScene(spec, opts);
7911
8080
  const cols = Math.max(8, Math.min(200, opts.cols ?? 80));
7912
8081
  const rows = Math.max(8, Math.min(200, opts.rows ?? 48));
@@ -7914,14 +8083,33 @@ function layerCellFootprints(spec, opts = {}) {
7914
8083
  const cellW = w / cols;
7915
8084
  const cellH = h / rows;
7916
8085
  const fontScale = fontScaleFor(spec, w, h);
7917
- return scene.layers.map(({ layer, entities }) => {
8086
+ return scene.layers.map(({ layer, entities }, li) => {
7918
8087
  if (layer.motion.type !== "static" || layer.emit || layer.links || layer.trail) return null;
7919
8088
  const s = layer.sprite;
7920
- const k = layer.transform !== void 0 || layer.group !== void 0 ? paintSize(spec, layer) : 1;
8089
+ const sw = sweep?.get(li);
8090
+ const own = sw ? sw.layer ?? transformRangeOf(layer.transform) : null;
8091
+ const grp = sw ? sw.group ?? transformRangeOf(groupOf(spec, layer)?.transform) : null;
8092
+ const k = own && grp ? maxAxisScale(own) * maxAxisScale(grp) : layer.transform !== void 0 || layer.group !== void 0 ? maxAxisScale(transformRangeOf(layer.transform)) * maxAxisScale(transformRangeOf(groupOf(spec, layer)?.transform)) : 1;
8093
+ const pivot = own ? transformOrigin(layer.transform ?? {}, layer, w, h, scale) : null;
8094
+ const centre = { x: w / 2, y: h / 2 };
7921
8095
  const cells = /* @__PURE__ */ new Set();
7922
8096
  for (const e of entities) {
7923
8097
  if (e.orbitParent || e.cyclePeriod > 0) return null;
7924
- const p = posOf(scene, e, 0);
8098
+ let box;
8099
+ if (own && grp && pivot) {
8100
+ const lb = sweptBox(own, positionAt(e, 0, w, h), pivot, scale);
8101
+ box = { x0: Infinity, y0: Infinity, x1: -Infinity, y1: -Infinity };
8102
+ for (const x of [lb.x0, lb.x1]) {
8103
+ for (const y of [lb.y0, lb.y1]) {
8104
+ const gb = sweptBox(grp, { x, y }, centre, scale);
8105
+ box = { x0: Math.min(box.x0, gb.x0), y0: Math.min(box.y0, gb.y0), x1: Math.max(box.x1, gb.x1), y1: Math.max(box.y1, gb.y1) };
8106
+ }
8107
+ }
8108
+ } else {
8109
+ const q = posOf(scene, e, 0);
8110
+ box = { x0: q.x, y0: q.y, x1: q.x, y1: q.y };
8111
+ }
8112
+ const p = { x: (box.x0 + box.x1) / 2, y: (box.y0 + box.y1) / 2 };
7925
8113
  const grow = 1 + Math.abs(e.growAmp);
7926
8114
  const sz = e.size * grow * k;
7927
8115
  const per = e.size > 0 ? sz / e.size : 1;
@@ -7947,11 +8135,11 @@ function layerCellFootprints(spec, opts = {}) {
7947
8135
  const b = s.kind === "text" ? textBox(s, e, p, fontScale, scale) : textBlockBox(s, e, p, w, h);
7948
8136
  reach = Math.hypot(Math.abs(b.cx - p.x) + b.halfX, Math.abs(b.cy - p.y) + b.halfY) * grow * Math.max(1, k);
7949
8137
  } else reach = sz;
7950
- if (!Number.isFinite(reach) || !Number.isFinite(p.x) || !Number.isFinite(p.y)) return null;
7951
- const c0 = Math.max(0, Math.floor((p.x - reach - 1) / cellW));
7952
- const c1 = Math.min(cols - 1, Math.floor((p.x + reach + 1) / cellW));
7953
- const r0 = Math.max(0, Math.floor((p.y - reach - 1) / cellH));
7954
- const r1 = Math.min(rows - 1, Math.floor((p.y + reach + 1) / cellH));
8138
+ if (!Number.isFinite(reach) || ![box.x0, box.x1, box.y0, box.y1].every(Number.isFinite)) return null;
8139
+ const c0 = Math.max(0, Math.floor((box.x0 - reach - 1) / cellW));
8140
+ const c1 = Math.min(cols - 1, Math.floor((box.x1 + reach + 1) / cellW));
8141
+ const r0 = Math.max(0, Math.floor((box.y0 - reach - 1) / cellH));
8142
+ const r1 = Math.min(rows - 1, Math.floor((box.y1 + reach + 1) / cellH));
7955
8143
  for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) cells.add(r * cols + c);
7956
8144
  }
7957
8145
  return [...cells].sort((a, b) => a - b);
@@ -8360,7 +8548,7 @@ function diffScenes(a, b, opts = {}) {
8360
8548
  const colB = sceneColour(b, opts);
8361
8549
  const motA = motionStats(a, opts);
8362
8550
  const motB = motionStats(b, opts);
8363
- const weighted = (stats, dom) => {
8551
+ const weighted2 = (stats, dom) => {
8364
8552
  let acc = 0;
8365
8553
  let n = 0;
8366
8554
  for (const m of stats) {
@@ -8378,8 +8566,8 @@ function diffScenes(a, b, opts = {}) {
8378
8566
  const vb = mb?.meanSpeedVp ?? 0;
8379
8567
  return { key, a: va, b: vb, delta: vb - va, headingA: ma?.heading ?? null, headingB: mb?.heading ?? null };
8380
8568
  }).filter((m) => Math.abs(m.delta) > 1e-9 || m.headingA !== m.headingB).sort((x, y) => Math.abs(y.delta) - Math.abs(x.delta));
8381
- const overallA = weighted(motA, domA);
8382
- const overallB = weighted(motB, domB);
8569
+ const overallA = weighted2(motA, domA);
8570
+ const overallB = weighted2(motB, domB);
8383
8571
  return {
8384
8572
  t,
8385
8573
  coverage: { a: gridA.coverage, b: gridB.coverage, delta: gridB.coverage - gridA.coverage },
@@ -8502,29 +8690,78 @@ function lumaTracks(spec) {
8502
8690
  if (!tl) return { tracks: [], lap: 0 };
8503
8691
  const lap = tl.loop && tl.duration ? tl.duration : 0;
8504
8692
  const tracks = [];
8693
+ const laps = (ts) => lap ? [...ts, ...ts.map((b) => b + lap)] : ts;
8505
8694
  for (const [path, keys] of timelineTracks(spec)) {
8506
8695
  if (!LUMA_PATH.test(path)) continue;
8507
8696
  const bounds = [];
8508
- for (const k of keys) {
8697
+ const owners = /* @__PURE__ */ new Map();
8698
+ const jumpAt = [];
8699
+ keys.forEach((k, i) => {
8509
8700
  const dur = k.dur ?? DEFAULT_KEY_DUR;
8510
- bounds.push(k.t);
8511
- if (dur > 0) bounds.push(k.t + dur);
8512
- }
8513
- tracks.push({ path, times: lap ? [...bounds, ...bounds.map((b) => b + lap)] : bounds });
8701
+ const own = (t) => {
8702
+ bounds.push(t);
8703
+ owners.set(t, (owners.get(t) ?? /* @__PURE__ */ new Set()).add(i));
8704
+ };
8705
+ own(k.t);
8706
+ if (dur > 0) own(k.t + dur);
8707
+ if (dur <= 0) jumpAt.push([k.t, i]);
8708
+ else if (k.ease === "step") jumpAt.push([k.t + dur, i]);
8709
+ });
8710
+ const shared = (t, i) => [...owners.get(t) ?? [], ...lap && t === 0 ? owners.get(lap) ?? [] : []].some((j) => j !== i);
8711
+ const jumps = [...new Set(jumpAt.filter(([t, i]) => shared(t, i)).map(([t]) => t))];
8712
+ tracks.push({ path, times: laps(bounds), jumps: laps(jumps) });
8514
8713
  }
8515
8714
  return { tracks, lap };
8516
8715
  }
8716
+ function weighted(tracks) {
8717
+ const extra = new Set(tracks.flatMap((tr) => tr.jumps));
8718
+ return { times: [...new Set(tracks.flatMap((tr) => tr.times))].sort((a, b) => a - b), weight: (t) => extra.has(t) ? 2 : 1 };
8719
+ }
8517
8720
  function lumaKeyTimes(spec) {
8518
8721
  const { tracks, lap } = lumaTracks(spec);
8519
- return { times: [...new Set(tracks.flatMap((tr) => tr.times))].sort((a, b) => a - b), lap };
8722
+ return { times: weighted(tracks).times, lap };
8520
8723
  }
8521
8724
  var PAINT_LEAF = /(^|\.)(opacity|alpha|color|fill)(\.\d+)?$|(^|\.)gradient(\.|$)|(^|\.)draw\.(start|end)$/;
8725
+ var MONOTONE_EASES = /* @__PURE__ */ new Set([void 0, "step", "linear", "smooth", "in", "out", "spring"]);
8726
+ function widenTransform(r, base, field, keys) {
8727
+ const take = (f, v) => {
8728
+ if (typeof v !== "number" || !Number.isFinite(v)) return false;
8729
+ r[f] = [Math.min(r[f][0], v), Math.max(r[f][1], v)];
8730
+ return true;
8731
+ };
8732
+ for (const k of keys) {
8733
+ if (!MONOTONE_EASES.has(k.ease)) return false;
8734
+ if (field !== null) {
8735
+ if (!(field in TRANSFORM_FIELDS) || !take(field, k.value)) return false;
8736
+ continue;
8737
+ }
8738
+ const v = k.value;
8739
+ if (!v || typeof v !== "object" || Array.isArray(v)) return false;
8740
+ const o = v;
8741
+ for (const name of Object.keys(o)) if (name !== "origin" && !(name in TRANSFORM_FIELDS)) return false;
8742
+ if (o.origin !== base?.origin) return false;
8743
+ for (const f of Object.keys(TRANSFORM_FIELDS)) if (!take(f, o[f] ?? TRANSFORM_FIELDS[f])) return false;
8744
+ }
8745
+ return true;
8746
+ }
8522
8747
  function pathCells(spec) {
8523
8748
  const layers = spec.layers ?? [];
8524
8749
  const volatile = /* @__PURE__ */ new Set();
8525
8750
  const members = (g) => layers.flatMap((l, i) => l.group === g ? [i] : []);
8526
8751
  const reach = /* @__PURE__ */ new Map();
8527
- for (const path of timelineTracks(spec).keys()) {
8752
+ const layerRanges = /* @__PURE__ */ new Map();
8753
+ const groupRanges = /* @__PURE__ */ new Map();
8754
+ const bound = (ranges, id, base, rest, keys) => {
8755
+ if (rest.length > 2) return false;
8756
+ if (!ranges.has(id)) ranges.set(id, transformRangeOf(base));
8757
+ const r = ranges.get(id);
8758
+ if (!r || !widenTransform(r, base, rest[1] ?? null, keys)) {
8759
+ ranges.set(id, null);
8760
+ return false;
8761
+ }
8762
+ return true;
8763
+ };
8764
+ for (const [path, keys] of timelineTracks(spec)) {
8528
8765
  const [root, id, ...rest] = path.split(".");
8529
8766
  const leaf = rest.join(".");
8530
8767
  let to;
@@ -8532,18 +8769,27 @@ function pathCells(spec) {
8532
8769
  const n = Number(id);
8533
8770
  to = [n];
8534
8771
  if (!PAINT_LEAF.test(leaf)) {
8535
- if (rest[0] === "transform") volatile.add(n);
8536
- else for (let i = n; i < layers.length; i++) volatile.add(i);
8772
+ if (rest[0] === "transform") {
8773
+ if (!bound(layerRanges, n, layers[n]?.transform, rest, keys)) volatile.add(n);
8774
+ } else for (let i = n; i < layers.length; i++) volatile.add(i);
8537
8775
  }
8538
8776
  } else if (root === "groups" && id !== void 0) {
8539
8777
  to = members(id);
8540
- if (leaf !== "opacity") for (const i of to) volatile.add(i);
8778
+ if (leaf !== "opacity" && !(rest[0] === "transform" && bound(groupRanges, id, spec.groups?.[id]?.transform, rest, keys))) for (const i of to) volatile.add(i);
8541
8779
  } else if (root === "background") to = "all";
8542
8780
  else return null;
8543
8781
  if (LUMA_PATH.test(path)) reach.set(path, to);
8544
8782
  }
8545
8783
  if (reach.size === 0) return /* @__PURE__ */ new Map();
8546
- const footprints = layerCellFootprints(withoutTimeline(spec), { cols: COLS, rows: ROWS });
8784
+ for (const [n, r] of layerRanges) if (!r) volatile.add(n);
8785
+ for (const [g, r] of groupRanges) if (!r) for (const i of members(g)) volatile.add(i);
8786
+ const sweep = /* @__PURE__ */ new Map();
8787
+ layers.forEach((l, i) => {
8788
+ const own = layerRanges.get(i) ?? void 0;
8789
+ const grp = l.group !== void 0 && spec.groups?.[l.group] ? groupRanges.get(l.group) ?? void 0 : void 0;
8790
+ if (own || grp) sweep.set(i, { layer: own, group: grp });
8791
+ });
8792
+ const footprints = layerCellFootprints(withoutTimeline(spec), { cols: COLS, rows: ROWS }, sweep);
8547
8793
  const out = /* @__PURE__ */ new Map();
8548
8794
  for (const [path, to] of reach) {
8549
8795
  if (to === "all" || to.some((i) => volatile.has(i) || !footprints[i])) out.set(path, null);
@@ -8556,23 +8802,28 @@ function cellsThatCanFlash(tracks, cellsOf, from, to) {
8556
8802
  for (const tr of tracks) {
8557
8803
  const ts = tr.times.filter((t) => t >= from && t <= to);
8558
8804
  if (ts.length === 0) continue;
8805
+ const part = { times: ts, jumps: tr.jumps };
8559
8806
  const cells = cellsOf.get(tr.path);
8560
- if (cells === null || cells === void 0) for (const list of perCell) list.push(...ts);
8561
- else for (const c of cells) perCell[c].push(...ts);
8807
+ if (cells === null || cells === void 0) for (const list of perCell) list.push(part);
8808
+ else for (const c of cells) perCell[c].push(part);
8562
8809
  }
8563
8810
  let n = 0;
8564
- for (const list of perCell) {
8565
- if (list.length < MIN_BOUNDARIES) continue;
8566
- const ts = [...new Set(list)].sort((a, b) => a - b);
8567
- for (let i = 0; i + MIN_BOUNDARIES - 1 < ts.length; i++) {
8568
- if (ts[i + MIN_BOUNDARIES - 1] - ts[i] <= WINDOW + 2) {
8569
- n++;
8570
- break;
8571
- }
8572
- }
8811
+ for (const parts of perCell) {
8812
+ if (parts.length === 0) continue;
8813
+ const { times: ts, weight } = weighted(parts);
8814
+ if (denseRun(ts, weight, WINDOW + 2) !== null) n++;
8573
8815
  }
8574
8816
  return n;
8575
8817
  }
8818
+ function denseRun(ts, weight, span) {
8819
+ let sum = 0;
8820
+ for (let i = 0, e = 0; i < ts.length; i++) {
8821
+ while (e < ts.length && ts[e] - ts[i] <= span) sum += weight(ts[e++]);
8822
+ if (sum >= MIN_BOUNDARIES) return [i, e];
8823
+ sum -= weight(ts[i]);
8824
+ }
8825
+ return null;
8826
+ }
8576
8827
  function flashSampleTimes(boundaries) {
8577
8828
  const ts = /* @__PURE__ */ new Set();
8578
8829
  boundaries.forEach((t, i) => {
@@ -8583,45 +8834,37 @@ function flashSampleTimes(boundaries) {
8583
8834
  });
8584
8835
  return [...ts].sort((a, b) => a - b);
8585
8836
  }
8586
- function timelineFlashReport(spec) {
8587
- const pass = { fails: false, flashesPerSecond: 0, flashingArea: 0, at: null };
8588
- if (!spec.timeline) return pass;
8837
+ function* flashSampleChunks(spec) {
8838
+ if (!spec.timeline) return;
8589
8839
  const { tracks, lap } = lumaTracks(spec);
8590
- const times = [...new Set(tracks.flatMap((tr) => tr.times))].sort((a, b) => a - b);
8840
+ const { times, weight } = weighted(tracks);
8591
8841
  const dense = [];
8592
- for (let i = 0, e = 0; i < times.length; i++) {
8593
- while (e < times.length && times[e] < times[i] + WINDOW) e++;
8594
- if (e - i < MIN_BOUNDARIES) continue;
8595
- const last = dense[dense.length - 1];
8596
- if (last && times[i] <= last.to) {
8597
- last.to = Math.max(last.to, times[e - 1]);
8598
- last.n = Math.max(last.n, e - i);
8599
- } else dense.push({ from: times[i], to: times[e - 1], n: e - i });
8600
- }
8601
- if (dense.length === 0) return pass;
8842
+ for (let i = 0, e = 0, sum = 0; i < times.length; i++) {
8843
+ while (e < times.length && times[e] < times[i] + WINDOW) sum += weight(times[e++]);
8844
+ if (sum >= MIN_BOUNDARIES) {
8845
+ const last = dense[dense.length - 1];
8846
+ if (last && times[i] <= last.to) {
8847
+ last.to = Math.max(last.to, times[e - 1]);
8848
+ last.n = Math.max(last.n, sum);
8849
+ } else dense.push({ from: times[i], to: times[e - 1], n: sum });
8850
+ }
8851
+ sum -= weight(times[i]);
8852
+ }
8853
+ if (dense.length === 0) return;
8602
8854
  dense.sort((a, b) => b.n - a.n);
8603
8855
  let budget = MAX_TOTAL_SAMPLES;
8604
- let worst = pass;
8856
+ let over = false;
8605
8857
  const cellsOf = pathCells(spec);
8606
8858
  for (const w of dense) {
8607
8859
  if (cellsOf && cellsThatCanFlash(tracks, cellsOf, w.from, w.to) < Math.ceil(AREA_THRESHOLD * COLS * ROWS)) continue;
8860
+ const at = lap ? w.from % lap : w.from;
8608
8861
  const sampleTimes = flashSampleTimes(times.filter((t) => t >= w.from && t <= w.to));
8609
8862
  for (let from = 0; from < sampleTimes.length - 1; ) {
8610
8863
  const chunk = sampleTimes.slice(from, from + MAX_SAMPLES);
8611
8864
  if (chunk.length < 2) break;
8612
- if (chunk.length > budget) return { ...worst, fails: true, at: lap ? w.from % lap : w.from, incomplete: true, boundariesPerSecond: w.n };
8613
- budget -= chunk.length;
8614
- const samples = chunk.map((t) => ({ t, lum: luminanceGrid(spec, { t, cols: 16, rows: 9 }).cells.flat().map((v) => srgbToLinear2(v * 255)) }));
8615
- const r = analyzeFlashes(samples);
8616
- if (r.general.worstTileFlashesPerSecond > worst.flashesPerSecond || !worst.fails && r.general.fails) {
8617
- worst = {
8618
- fails: r.general.fails,
8619
- flashesPerSecond: r.general.worstTileFlashesPerSecond,
8620
- flashingArea: r.general.flashingAreaFraction,
8621
- at: lap ? w.from % lap : w.from
8622
- };
8623
- }
8624
- if (worst.fails) return worst;
8865
+ if (chunk.length > budget) over = true;
8866
+ if (!over) budget -= chunk.length;
8867
+ yield { times: chunk, at, n: w.n, over };
8625
8868
  if (from + MAX_SAMPLES >= sampleTimes.length) break;
8626
8869
  const resume = chunk[chunk.length - 1] - WINDOW;
8627
8870
  let next = from + 1;
@@ -8629,11 +8872,44 @@ function timelineFlashReport(spec) {
8629
8872
  from = next;
8630
8873
  }
8631
8874
  }
8875
+ }
8876
+ var PASS = { fails: false, flashesPerSecond: 0, flashingArea: 0, at: null };
8877
+ var failClosed = (worst, c) => ({ ...worst, fails: true, at: c.at, incomplete: true, boundariesPerSecond: c.n });
8878
+ function timelineFlashReport(spec) {
8879
+ let worst = { ...PASS };
8880
+ for (const c of flashSampleChunks(spec)) {
8881
+ if (c.over) return failClosed(worst, c);
8882
+ const samples = c.times.map((t) => ({ t, lum: luminanceGrid(spec, { t, cols: COLS, rows: ROWS }).cells.flat().map((v) => srgbToLinear2(v * 255)) }));
8883
+ const r = analyzeFlashes(samples);
8884
+ if (r.general.worstTileFlashesPerSecond > worst.flashesPerSecond || !worst.fails && r.general.fails) {
8885
+ worst = {
8886
+ fails: r.general.fails,
8887
+ flashesPerSecond: r.general.worstTileFlashesPerSecond,
8888
+ flashingArea: r.general.flashingAreaFraction,
8889
+ at: c.at
8890
+ };
8891
+ }
8892
+ if (worst.fails) return worst;
8893
+ }
8632
8894
  return worst;
8633
8895
  }
8896
+ function flashSamplingForecast(spec) {
8897
+ let samples = 0;
8898
+ let needed = 0;
8899
+ let first;
8900
+ for (const c of flashSampleChunks(spec)) {
8901
+ needed += c.times.length;
8902
+ if (!c.over) samples += c.times.length;
8903
+ else first ??= c;
8904
+ }
8905
+ const base = { samples, needed, budget: MAX_TOTAL_SAMPLES };
8906
+ if (!first) return { exhausted: false, at: null, ...base, report: { ...PASS } };
8907
+ return { exhausted: true, at: first.at, boundariesPerSecond: first.n, ...base, report: failClosed({ ...PASS }, first) };
8908
+ }
8634
8909
 
8635
8910
  // src/period.ts
8636
8911
  var MAX_SCENE_PERIOD = 36e5;
8912
+ var SEAMLESS_PERIOD_KINDS = /* @__PURE__ */ new Set(["spin", "rock", "pulse", "grow"]);
8637
8913
  var PERIOD_LEAF = /(^|\.)(period|rate|every|duration|spin|speed|blink|drift)$/;
8638
8914
  var gcd = (a, b) => {
8639
8915
  while (b) [a, b] = [b, a % b];
@@ -8651,81 +8927,180 @@ function toRatio(ms) {
8651
8927
  return null;
8652
8928
  }
8653
8929
  var range = (r) => r === void 0 ? null : typeof r === "number" ? r : r[0] === r[1] ? r[0] : NaN;
8654
- function scenePeriod(spec) {
8930
+ function scenePeriod(spec, skip) {
8655
8931
  const parts = [];
8656
8932
  const reasons = [];
8657
- const add = (label, ms) => {
8658
- if (!(ms > 0) || !Number.isFinite(ms)) return;
8933
+ const add = (label, ms, kind) => {
8934
+ if (!(ms > 0) || !Number.isFinite(ms) || skip?.has(kind)) return;
8659
8935
  const ratio = toRatio(ms);
8660
8936
  if (!ratio) reasons.push(`${label} repeats every ${Number(ms.toFixed(3))} ms, which has no exact ratio to combine`);
8661
- else parts.push([label, ratio[0], ratio[1]]);
8937
+ else parts.push([label, ratio[0], ratio[1], kind]);
8662
8938
  };
8663
8939
  const tl = spec.timeline;
8664
8940
  if (tl && tl.keys.length > 0) {
8665
8941
  const keyed = tl.keys.filter((k) => PERIOD_LEAF.test(String(k.path)));
8666
8942
  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`);
8667
- if (tl.loop && (tl.duration ?? 0) > 0) add("timeline", tl.duration);
8943
+ if (tl.loop && (tl.duration ?? 0) > 0) add("timeline", tl.duration, "timeline");
8668
8944
  else reasons.push("timeline does not loop (it plays once, then holds)");
8669
8945
  }
8670
8946
  const bg = spec.background;
8671
- if (bg && bg.type !== "solid" && bg.drift) {
8947
+ if (bg && bg.type !== "solid" && bg.drift && !(bg.type === "field" && bg.drift.amount === 0)) {
8672
8948
  const { period } = bg.drift;
8673
- const field = bg.type === "field" && bg.drift.amount !== 0;
8674
- const r = field ? toRatio(period) : null;
8675
- add("background drift", r ? r[0] / gcd(r[0], FIELD_BUCKET_MS) * FIELD_BUCKET_MS : period);
8949
+ const r = bg.type === "field" ? toRatio(period) : null;
8950
+ add("background drift", r ? r[0] / gcd(r[0], FIELD_BUCKET_MS) * FIELD_BUCKET_MS : period, "background");
8676
8951
  }
8677
8952
  if (spec.finish?.animate && ((spec.finish.grain ?? 0) > 0 || (spec.finish.dither ?? 0) > 0)) reasons.push("finish.animate steps the grain without repeating");
8678
8953
  spec.layers.forEach((layer, i) => {
8679
8954
  const at = `layers[${i}]${layer.key ? ` (${layer.key})` : ""}`;
8680
8955
  const rate = layer.clock?.rate ?? 1;
8681
8956
  const life = layer.life;
8682
- if (life && (life.exit !== void 0 || life.enter !== void 0 && ((life.enter ?? 0) > 0 || (life.fade ?? 1e3) > 0))) reasons.push(`${at} life builds in or out once`);
8683
- if (layer.pulse) add(`${at} pulse`, layer.pulse.period / rate);
8684
- if (layer.grow) add(`${at} grow`, layer.grow.period / rate);
8685
- if (layer.rock) add(`${at} rock`, layer.rock.period / rate);
8686
- if (layer.emit) add(`${at} emit`, layer.emit.every);
8957
+ const staggered = (life?.stagger ?? 0) > 0;
8958
+ 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`);
8959
+ if (layer.pulse && layer.pulse.amp !== 0) add(`${at} pulse`, layer.pulse.period / rate, "pulse");
8960
+ if (layer.grow) add(`${at} grow`, layer.grow.period / rate, "grow");
8961
+ if (layer.rock) add(`${at} rock`, layer.rock.period / rate, "rock");
8962
+ if (layer.emit) add(`${at} emit`, layer.emit.every, "emit");
8687
8963
  const spin = range(layer.spin);
8688
- if (spin !== null) {
8964
+ const fold = spin !== null && spin !== 0 ? spinSymmetry(layer.sprite) : 1;
8965
+ if (spin !== null && fold !== Infinity) {
8689
8966
  if (Number.isNaN(spin)) reasons.push(`${at} spin range gives each entity its own speed`);
8690
- else if (spin !== 0) add(`${at} spin`, 36e4 / Math.abs(spin));
8967
+ else if (spin !== 0) add(`${at} spin${fold > 1 ? ` (${fold}-fold)` : ""}`, 36e4 / Math.abs(spin) / fold, "spin");
8691
8968
  }
8692
8969
  const sp = layer.sprite;
8693
- if ((sp.kind === "emoji" || sp.kind === "text") && sp.cycle && spriteVariants(sp) > 1) add(`${at} cycle`, sp.cycle.period * spriteVariants(sp) / rate);
8970
+ if ((sp.kind === "emoji" || sp.kind === "text") && sp.cycle && spriteVariants(sp) > 1) add(`${at} cycle`, sp.cycle.period * spriteVariants(sp) / rate, "cycle");
8694
8971
  if (sp.kind === "textBlock" && sp.reveal) {
8695
8972
  if ((sp.reveal.speed ?? 0) > 0) reasons.push(`${at} reveal types itself once`);
8696
8973
  if (sp.reveal.caret) {
8697
8974
  const hz = Math.min(3, sp.reveal.caret === true ? 1.2 : sp.reveal.caret.blink ?? 1.2);
8698
- if (hz > 0) add(`${at} caret`, 1e3 / hz);
8975
+ if (hz > 0) add(`${at} caret`, 1e3 / hz, "caret");
8699
8976
  }
8700
8977
  }
8701
8978
  const m = layer.motion;
8702
- if (m.type === "path") add(`${at} path`, m.duration);
8979
+ if (m.type === "path") add(`${at} path`, m.duration, "path");
8703
8980
  else if (m.type === "orbit") {
8704
8981
  const s = range(m.speed);
8705
8982
  if (s === null || Number.isNaN(s)) reasons.push(`${at} orbit speed range gives each entity its own lap`);
8706
- else if (s !== 0) add(`${at} orbit`, 36e4 / Math.abs(s));
8983
+ else if (s !== 0) add(`${at} orbit`, 36e4 / Math.abs(s), "orbit");
8707
8984
  } else if (m.type !== "static") {
8708
8985
  if (m.type === "drift" && m.ease?.type === "settle" && !layer.emit) reasons.push(`${at} drift settles once`);
8709
8986
  else reasons.push(`${at} ${m.type} motion travels at seeded per-entity speeds`);
8710
8987
  }
8711
8988
  });
8712
8989
  const labelled = parts.map(([l, n, d]) => `${l}: ${Number((n / d).toFixed(3))} ms`);
8713
- if (reasons.length > 0) return { period: null, parts: labelled, reasons };
8714
- if (parts.length === 0) return { period: 0, parts: labelled, reasons };
8990
+ const kinds = parts.map(([, , , k]) => k);
8991
+ if (reasons.length > 0) return { period: null, parts: labelled, kinds, reasons };
8992
+ const combined = combinePeriods(parts);
8993
+ return { ...combined, parts: labelled, kinds };
8994
+ }
8995
+ function combinePeriods(parts) {
8996
+ if (parts.length === 0) return { period: 0, reasons: [] };
8715
8997
  let num = 1;
8716
8998
  let den = 0;
8717
8999
  for (const [, n, d] of parts) {
8718
9000
  num = num / gcd(num, n) * n;
8719
9001
  den = gcd(den, d);
8720
9002
  if (num / den > MAX_SCENE_PERIOD) {
8721
- return { period: null, parts: labelled, reasons: [`its parts share no cycle shorter than ${MAX_SCENE_PERIOD / 6e4} min`] };
9003
+ return { period: null, reasons: [`its parts share no cycle shorter than ${MAX_SCENE_PERIOD / 6e4} min`] };
9004
+ }
9005
+ }
9006
+ 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`] };
9007
+ return { period: num / den, reasons: [] };
9008
+ }
9009
+ function periodWithoutTimeline(spec, skip) {
9010
+ return scenePeriod(withoutTimeline(spec), skip);
9011
+ }
9012
+ var MAX_SYMMETRY = 64;
9013
+ var MAX_SYMMETRY_POINTS = 8192;
9014
+ function spinSymmetry(sp) {
9015
+ if (sp.kind === "circle" || sp.kind === "ring") return Infinity;
9016
+ if (sp.kind !== "polygon" || sp.gradient) return 1;
9017
+ if (!isPathPolygon(sp)) {
9018
+ if (sp.points && sp.points.length >= 3) return largestSymmetry(/* @__PURE__ */ new Map([["fill", closedSegs(sp.points)]]));
9019
+ return Math.max(3, Math.round(sp.sides ?? DEFAULT_POLYGON_SIDES));
9020
+ }
9021
+ if ((sp.span ?? "box") !== "box") return 1;
9022
+ const [ox, oy] = sp.origin ?? [0, 0];
9023
+ if (sp.soft && !sp.parts && (ox !== 0 || oy !== 0)) return 1;
9024
+ const groups = /* @__PURE__ */ new Map();
9025
+ const add = (key, segs) => {
9026
+ const list = groups.get(key);
9027
+ if (list) list.push(...segs);
9028
+ else groups.set(key, segs);
9029
+ };
9030
+ const shift = (pts) => pts.map(([x, y]) => [x - ox, y - oy]);
9031
+ for (const part of pathParts(sp)) {
9032
+ if (part.gradient || part.stroke && drawSpan(part.stroke.draw) !== null) return 1;
9033
+ const colour = part.color ?? "*";
9034
+ if (part.fill && part.flat.points.length >= 3) {
9035
+ for (const l of part.flat.lines) if (l.points.length >= 3) add(`F|${colour}|${part.rule}|${part.opacity}`, closedSegs(shift(l.points)));
9036
+ }
9037
+ const st = part.stroke;
9038
+ if (st) {
9039
+ const key = `S|${st.color}|${st.width}|${st.opacity ?? 1}|${st.join ?? ""}|${st.cap ?? ""}|${part.opacity}`;
9040
+ for (const l of part.flat.lines) {
9041
+ const pts = shift(l.points);
9042
+ add(key, l.closed ? closedSegs(pts) : openSegs(pts));
9043
+ }
8722
9044
  }
8723
9045
  }
8724
- 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`] };
8725
- return { period: num / den, parts: labelled, reasons };
9046
+ return largestSymmetry(groups);
9047
+ }
9048
+ function closedSegs(pts) {
9049
+ return pts.map(([x, y], i) => {
9050
+ const [x2, y2] = pts[(i + 1) % pts.length];
9051
+ return [x, y, x2, y2];
9052
+ });
8726
9053
  }
8727
- function periodWithoutTimeline(spec) {
8728
- return scenePeriod(withoutTimeline(spec));
9054
+ function openSegs(pts) {
9055
+ const out = [];
9056
+ for (let i = 0; i + 1 < pts.length; i++) out.push([pts[i][0], pts[i][1], pts[i + 1][0], pts[i + 1][1]]);
9057
+ return out;
9058
+ }
9059
+ function largestSymmetry(groups) {
9060
+ let radius = 0;
9061
+ let total = 0;
9062
+ for (const segs of groups.values()) {
9063
+ total += segs.length;
9064
+ for (const [ax, ay, bx, by] of segs) radius = Math.max(radius, Math.hypot(ax, ay), Math.hypot(bx, by));
9065
+ }
9066
+ if (radius === 0 || total === 0 || total > MAX_SYMMETRY_POINTS) return 1;
9067
+ const eps = Math.max(2e-3 * radius, 2e-3);
9068
+ const indexed = [...groups.values()].map((segs) => ({ grid: segGrid(segs, radius, eps), probes: segs.flatMap(([ax, ay, bx, by]) => [[ax, ay], [(ax + bx) / 2, (ay + by) / 2]]) }));
9069
+ for (let n = MAX_SYMMETRY; n >= 2; n--) {
9070
+ const a = 2 * Math.PI / n;
9071
+ if (indexed.every(({ grid, probes }) => [a, -a].every((t) => {
9072
+ const c = Math.cos(t);
9073
+ const s = Math.sin(t);
9074
+ return probes.every(([x, y]) => grid.near(x * c - y * s, x * s + y * c));
9075
+ }))) return n;
9076
+ }
9077
+ return 1;
9078
+ }
9079
+ function segGrid(segs, radius, eps) {
9080
+ const n = Math.max(1, Math.min(128, Math.ceil(Math.sqrt(segs.length) * 2)));
9081
+ const lo = -radius - eps;
9082
+ const size = 2 * (radius + eps) / n;
9083
+ const cellOf = (v) => Math.max(0, Math.min(n - 1, Math.floor((v - lo) / size)));
9084
+ const cells = Array.from({ length: n * n }, () => []);
9085
+ for (const sg of segs) {
9086
+ const [ax, ay, bx, by] = sg;
9087
+ for (let cy = cellOf(Math.min(ay, by) - eps); cy <= cellOf(Math.max(ay, by) + eps); cy++) {
9088
+ for (let cx = cellOf(Math.min(ax, bx) - eps); cx <= cellOf(Math.max(ax, bx) + eps); cx++) cells[cy * n + cx].push(sg);
9089
+ }
9090
+ }
9091
+ return {
9092
+ near: (x, y) => {
9093
+ if (x < lo || y < lo || x > -lo || y > -lo) return false;
9094
+ for (const [ax, ay, bx, by] of cells[cellOf(y) * n + cellOf(x)]) {
9095
+ const dx = bx - ax;
9096
+ const dy = by - ay;
9097
+ const len2 = dx * dx + dy * dy;
9098
+ const u = len2 > 0 ? Math.max(0, Math.min(1, ((x - ax) * dx + (y - ay) * dy) / len2)) : 0;
9099
+ if (Math.hypot(x - ax - u * dx, y - ay - u * dy) <= eps) return true;
9100
+ }
9101
+ return false;
9102
+ }
9103
+ };
8729
9104
  }
8730
9105
 
8731
9106
  // src/advise.ts
@@ -8969,14 +9344,15 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
8969
9344
  if (authored.timeline?.loop && (authored.timeline.duration ?? 0) > 0) {
8970
9345
  const lap = Math.round(authored.timeline.duration);
8971
9346
  const rest = periodWithoutTimeline(authored);
8972
- if (rest.period !== null && rest.period > 0 && lap % rest.period !== 0) {
9347
+ const seam = rest.period !== null && rest.period > 0 && lap % rest.period !== 0 ? periodWithoutTimeline(authored, SEAMLESS_PERIOD_KINDS) : null;
9348
+ if (seam && seam.period !== null && seam.period > 0 && lap % seam.period !== 0) {
8973
9349
  let a = lap;
8974
- let b = rest.period;
9350
+ let b = seam.period;
8975
9351
  while (b) [a, b] = [b, a % b];
8976
9352
  warnings.push({
8977
9353
  path: "timeline.duration",
8978
9354
  code: "loop-seam",
8979
- 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`
9355
+ 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`
8980
9356
  });
8981
9357
  }
8982
9358
  }
@@ -12084,6 +12460,7 @@ export {
12084
12460
  SAKURA_SPEC,
12085
12461
  SCHEMA_EXAMPLES,
12086
12462
  SCHEMA_VERSION,
12463
+ SEAMLESS_PERIOD_KINDS,
12087
12464
  SHARD_FALL_SPEC,
12088
12465
  SIGNAL_BOARD_SPEC,
12089
12466
  SNOWFALL_SPEC,
@@ -12132,6 +12509,7 @@ export {
12132
12509
  fieldRgbAt,
12133
12510
  fieldSampleTime,
12134
12511
  finishStrength,
12512
+ flashSamplingForecast,
12135
12513
  flattenPath,
12136
12514
  grainOffset,
12137
12515
  grainTilePixels,
@@ -12188,6 +12566,7 @@ export {
12188
12566
  sequenceSwapCompatible,
12189
12567
  sequenceWantsFinish,
12190
12568
  sizeAt,
12569
+ spinSymmetry,
12191
12570
  spriteIndexAt,
12192
12571
  spriteVariants,
12193
12572
  steerablePaths,