@idle-screens/schema 3.6.0 → 3.7.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
@@ -46,7 +46,8 @@ var LIMITS = {
46
46
  maxTextBlockLength: 2e3,
47
47
  minTextBlockFontSize: 0.01,
48
48
  maxTextBlockFontSize: 0.2,
49
- maxTextBlockMaxWidth: 1,
49
+ maxTextBlockMaxWidth: 2,
50
+ // of min(w,h) — wider than the frame is legal; text-off-screen reports the overflow
50
51
  maxRevealSpeed: 120,
51
52
  // graphemes/sec — faster than any readable typing
52
53
  maxCaretBlinkHz: 3,
@@ -83,12 +84,13 @@ var LIMITS = {
83
84
 
84
85
  // src/steer.ts
85
86
  var UNSAFE_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
87
+ var STEERABLE_ROOT_KEYS = /* @__PURE__ */ new Set(["ghosting", "referenceViewport"]);
86
88
  function resolveSpecPath(spec, path) {
87
89
  if (!spec || typeof spec !== "object" || !path) return null;
88
90
  const parts = path.split(".");
89
91
  if (parts.some((p) => UNSAFE_KEYS.has(p))) return null;
90
92
  const s = spec;
91
- if (parts[0] !== "layers" && parts[0] !== "background" && Array.isArray(s.layers)) {
93
+ if (parts[0] !== "layers" && parts[0] !== "background" && !STEERABLE_ROOT_KEYS.has(parts[0]) && Array.isArray(s.layers)) {
92
94
  const idx = s.layers.findIndex((l) => l && l.key === parts[0]);
93
95
  if (idx === -1) return null;
94
96
  parts.splice(0, 1, "layers", String(idx));
@@ -148,9 +150,61 @@ function lerpSpec(from, to, k) {
148
150
  };
149
151
  return walk(from, to);
150
152
  }
153
+ function textStringsOf(sprite) {
154
+ if (sprite.kind === "text") return sprite.strings;
155
+ if (sprite.kind === "textBlock") return [sprite.text];
156
+ return null;
157
+ }
158
+ function textStringsDiffer(a, b, i) {
159
+ const sa = a.layers[i]?.sprite;
160
+ const sb = b.layers[i]?.sprite;
161
+ if (!sa || !sb) return false;
162
+ const ta = textStringsOf(sa);
163
+ const tb = textStringsOf(sb);
164
+ if (!ta || !tb) return false;
165
+ return ta.length !== tb.length || ta.some((s, j) => s !== tb[j]);
166
+ }
167
+ var NON_RENDERED_KEYS = /* @__PURE__ */ new Set(["id", "label", "schemaVersion", "key"]);
168
+ function morphNothingMorphable(a, b, opts = {}) {
169
+ if (opts.textCrossfade && a.layers.some((_, i) => textStringsDiffer(a, b, i))) return false;
170
+ let hasDiff = false;
171
+ let hasGlide = false;
172
+ const walk = (x, y, key) => {
173
+ if (key !== void 0 && NON_RENDERED_KEYS.has(key)) return;
174
+ if (x === y) return;
175
+ if (typeof x === "number" && typeof y === "number") {
176
+ hasDiff = true;
177
+ hasGlide = true;
178
+ return;
179
+ }
180
+ if (typeof x === "string" && typeof y === "string" && HEX.test(x) && HEX.test(y)) {
181
+ const ca = hexToRgb(x);
182
+ const cb = hexToRgb(y);
183
+ if (ca.some((v, i) => v !== cb[i])) {
184
+ hasDiff = true;
185
+ hasGlide = true;
186
+ }
187
+ return;
188
+ }
189
+ if (Array.isArray(x) && Array.isArray(y) && x.length === y.length) {
190
+ for (let i = 0; i < y.length; i++) walk(x[i], y[i]);
191
+ return;
192
+ }
193
+ if (x && y && typeof x === "object" && typeof y === "object" && !Array.isArray(x) && !Array.isArray(y)) {
194
+ const keys = /* @__PURE__ */ new Set([...Object.keys(x), ...Object.keys(y)]);
195
+ for (const k of keys) {
196
+ walk(x[k], y[k], k);
197
+ }
198
+ return;
199
+ }
200
+ hasDiff = true;
201
+ };
202
+ walk(a, b);
203
+ return hasDiff && !hasGlide;
204
+ }
151
205
  function steerablePaths(spec) {
152
206
  if (!spec || typeof spec !== "object") return [];
153
- const SKIP = /* @__PURE__ */ new Set(["kind", "type", "key", "schemaVersion", "id", "label", "seed", "units", "mode", "curve", "layer"]);
207
+ const SKIP = /* @__PURE__ */ new Set(["kind", "type", "key", "schemaVersion", "id", "label", "seed", "units", "motionIntensity", "mode", "curve", "layer"]);
154
208
  const INDEXED = /* @__PURE__ */ new Set(["layers", "stops"]);
155
209
  const out = [];
156
210
  const walk = (node, prefix, key) => {
@@ -174,6 +228,12 @@ function steerablePaths(spec) {
174
228
  walk(spec, "", "");
175
229
  return out;
176
230
  }
231
+ function sequenceSteerablePaths(seq) {
232
+ const out = /* @__PURE__ */ new Set(["sequence.segment"]);
233
+ if (seq.bed) for (const p of steerablePaths(seq.bed)) out.add(`bed.${p}`);
234
+ for (const seg of seq.segments) for (const p of steerablePaths(seg.scene)) out.add(p);
235
+ return [...out];
236
+ }
177
237
  function easeSmooth(k) {
178
238
  const c = Math.max(0, Math.min(1, k));
179
239
  return c * c * (3 - 2 * c);
@@ -181,6 +241,7 @@ function easeSmooth(k) {
181
241
  function structuralSignature(spec) {
182
242
  return JSON.stringify([
183
243
  spec.units,
244
+ spec.referenceViewport,
184
245
  spec.layers.map((l) => {
185
246
  const s = l.sprite;
186
247
  return [
@@ -206,7 +267,7 @@ function structuralSignature(spec) {
206
267
  l.sprite.kind === "rect" ? [s.width, s.aspect] : l.sprite.kind === "bar" ? [s.length, s.thickness] : void 0,
207
268
  Array.isArray(s.colors) ? s.colors.length : void 0,
208
269
  s.colorWeights,
209
- 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.fontSize] : void 0
270
+ 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
210
271
  ];
211
272
  })
212
273
  ]);
@@ -249,8 +310,11 @@ var KNOWN_BAR = /* @__PURE__ */ new Set(["kind", "values", "length", "thickness"
249
310
  var KNOWN_POLYGON = /* @__PURE__ */ new Set(["kind", "radius", "color", "sides", "points", "soft", "colors", "colorWeights"]);
250
311
  var KNOWN_STROKE = /* @__PURE__ */ new Set(["kind", "length", "points", "color", "width", "curve", "taper", "orient", "colors", "colorWeights"]);
251
312
  var KNOWN_EMOJI = /* @__PURE__ */ new Set(["kind", "glyphs", "cycle"]);
252
- var KNOWN_TEXT = /* @__PURE__ */ new Set(["kind", "strings", "color", "font", "align", "baseline", "maxWidth", "cycle"]);
253
- var KNOWN_TEXT_BLOCK = /* @__PURE__ */ new Set(["kind", "text", "maxWidth", "fontSize", "lineHeight", "align", "color", "reveal"]);
313
+ var KNOWN_TEXT = /* @__PURE__ */ new Set(["kind", "strings", "color", "font", "align", "baseline", "maxWidth", "cycle", "role"]);
314
+ var KNOWN_TEXT_BLOCK = /* @__PURE__ */ new Set(["kind", "text", "maxWidth", "fontSize", "lineHeight", "align", "color", "reveal", "anchor", "font", "opacity", "role"]);
315
+ var TEXT_ROLES = /* @__PURE__ */ new Set(["read", "atmosphere"]);
316
+ var TEXT_BLOCK_ANCHORS = /* @__PURE__ */ new Set(["top-left", "top", "top-right", "left", "center", "right", "bottom-left", "bottom", "bottom-right"]);
317
+ var FONT_SIZE_RE = /\d(?:px|pt|pc|em|rem|ex|ch|vw|vh|vmin|vmax|%)/i;
254
318
  var KNOWN_REVEAL = /* @__PURE__ */ new Set(["progress", "mode", "speed", "caret", "fade"]);
255
319
  var KNOWN_REVEAL_CARET = /* @__PURE__ */ new Set(["blink", "color"]);
256
320
  var KNOWN_DRIFT = /* @__PURE__ */ new Set(["type", "speed", "angle", "bidirectional", "bob", "ease"]);
@@ -660,6 +724,9 @@ function validateSprite(sprite, path, err, warn) {
660
724
  err(`${path}.strings`, "must be a non-empty array of strings");
661
725
  }
662
726
  if (sprite.color !== void 0) color(sprite.color, `${path}.color`, err);
727
+ if (sprite.font !== void 0 && !isStr(sprite.font)) {
728
+ err(`${path}.font`, "must be a string");
729
+ }
663
730
  if (sprite.align !== void 0 && !["left", "center", "right"].includes(sprite.align)) {
664
731
  err(`${path}.align`, "must be left | center | right");
665
732
  }
@@ -669,6 +736,7 @@ function validateSprite(sprite, path, err, warn) {
669
736
  if (sprite.maxWidth !== void 0 && (!isNum(sprite.maxWidth) || sprite.maxWidth <= 0)) {
670
737
  err(`${path}.maxWidth`, "must be a positive number");
671
738
  }
739
+ validateTextRole(sprite, path, err);
672
740
  validateCycle(sprite, path, err);
673
741
  } else if (sprite.kind === "circle") {
674
742
  knownSet = KNOWN_CIRCLE;
@@ -755,6 +823,20 @@ function validateSprite(sprite, path, err, warn) {
755
823
  err(`${path}.align`, "must be 'left' | 'center' | 'right'");
756
824
  }
757
825
  if (sprite.color !== void 0) color(sprite.color, `${path}.color`, err);
826
+ if (sprite.anchor !== void 0 && !TEXT_BLOCK_ANCHORS.has(sprite.anchor)) {
827
+ err(`${path}.anchor`, "must be 'top-left' | 'top' | 'top-right' | 'left' | 'center' | 'right' | 'bottom-left' | 'bottom' | 'bottom-right'");
828
+ }
829
+ if (sprite.font !== void 0) {
830
+ if (!isStr(sprite.font) || sprite.font.trim() === "") {
831
+ err(`${path}.font`, "must be a non-empty string (family and/or weight)");
832
+ } else if (FONT_SIZE_RE.test(sprite.font)) {
833
+ err(`${path}.font`, "must name a family and/or weight only \u2014 fontSize owns the size");
834
+ }
835
+ }
836
+ if (sprite.opacity !== void 0 && (!isNum(sprite.opacity) || sprite.opacity < 0 || sprite.opacity > 1)) {
837
+ err(`${path}.opacity`, "must be a number between 0 and 1");
838
+ }
839
+ validateTextRole(sprite, path, err);
758
840
  if (sprite.reveal !== void 0) {
759
841
  const rv = sprite.reveal;
760
842
  if (typeof rv !== "object" || rv === null || Array.isArray(rv)) {
@@ -821,6 +903,11 @@ function validatePalette(sprite, path, err) {
821
903
  }
822
904
  }
823
905
  }
906
+ function validateTextRole(sprite, path, err) {
907
+ if (sprite.role !== void 0 && !TEXT_ROLES.has(sprite.role)) {
908
+ err(`${path}.role`, "must be 'read' | 'atmosphere'");
909
+ }
910
+ }
824
911
  function validateCycle(sprite, path, err) {
825
912
  if (sprite.cycle === void 0) return;
826
913
  if (!isObj(sprite.cycle)) return err(`${path}.cycle`, "must be an object");
@@ -993,6 +1080,7 @@ function validateSequence(seq) {
993
1080
  }
994
1081
  }
995
1082
  }
1083
+ if (isObj(seq) && isObj(seq.bed)) normalizeColors(seq.bed);
996
1084
  const errors = [];
997
1085
  const warnings = [];
998
1086
  const err = (path, message) => void errors.push({ path, message });
@@ -1003,6 +1091,7 @@ function validateSequence(seq) {
1003
1091
  if (!isStr(seq.label) || seq.label.trim() === "") err("label", "must be a non-empty string");
1004
1092
  if (seq.seed !== void 0 && !isNum(seq.seed)) err("seed", "must be a number");
1005
1093
  if (typeof seq.loop !== "boolean") err("loop", "must be a boolean");
1094
+ if (seq.sync !== void 0 && seq.sync !== "mount" && seq.sync !== "epoch") err("sync", "must be 'mount' | 'epoch'");
1006
1095
  if (!Array.isArray(seq.segments) || seq.segments.length === 0) {
1007
1096
  err("segments", "must be a non-empty array");
1008
1097
  return { valid: false, errors, warnings };
@@ -1045,12 +1134,19 @@ function validateSequence(seq) {
1045
1134
  if (s.transition !== void 0) {
1046
1135
  if (!isObj(s.transition)) {
1047
1136
  err(`${p}.transition`, "must be an object");
1048
- } else if (s.transition.type === "morph") {
1137
+ } else if (s.transition.type === "morph" || s.transition.type === "fade") {
1049
1138
  if (!isNum(s.transition.dur) || s.transition.dur < LIMITS.minTransitionDur || s.transition.dur > LIMITS.maxTransitionDur) {
1050
1139
  err(`${p}.transition.dur`, `must be a number between ${LIMITS.minTransitionDur} and ${LIMITS.maxTransitionDur}`);
1051
1140
  }
1052
1141
  } else if (s.transition.type !== "cut") {
1053
- err(`${p}.transition.type`, "must be 'cut' or 'morph'");
1142
+ err(`${p}.transition.type`, "must be 'cut', 'morph' or 'fade'");
1143
+ }
1144
+ if (isObj(s.transition) && s.transition.text !== void 0) {
1145
+ if (s.transition.type !== "morph") {
1146
+ err(`${p}.transition.text`, "is a morph option (fade already cross-fades whole frames; cut has no window)");
1147
+ } else if (s.transition.text !== "step" && s.transition.text !== "crossfade") {
1148
+ err(`${p}.transition.text`, "must be 'step' | 'crossfade'");
1149
+ }
1054
1150
  }
1055
1151
  }
1056
1152
  if (isObj(s.scene)) {
@@ -1066,6 +1162,32 @@ function validateSequence(seq) {
1066
1162
  if (seq.loop === true && hasDurationless) {
1067
1163
  err("loop", "loop: true requires all segments to have a duration");
1068
1164
  }
1165
+ if (seq.bed !== void 0) {
1166
+ if (!isObj(seq.bed)) {
1167
+ err("bed", "must be a SaverSpec object");
1168
+ } else {
1169
+ const bedResult = validateSpec(seq.bed);
1170
+ for (const e of bedResult.errors) errors.push({ path: `bed.${e.path}`, message: e.message });
1171
+ for (const w of bedResult.warnings ?? []) warnings.push({ path: `bed.${w.path}`, code: w.code, message: w.message });
1172
+ if (bedResult.valid) {
1173
+ const entityTotal = (spec) => isObj(spec) && Array.isArray(spec.layers) ? spec.layers.reduce((n, l) => n + (isObj(l) && isNum(l.count) ? l.count : 0), 0) : 0;
1174
+ const bedTotal = entityTotal(seq.bed);
1175
+ const largest = seq.segments.reduce((m, s) => Math.max(m, isObj(s) ? entityTotal(s.scene) : 0), 0);
1176
+ if (bedTotal + largest > LIMITS.maxTotal) {
1177
+ err("bed", `bed entities ${bedTotal} + largest segment ${largest} = ${bedTotal + largest} exceeds cap ${LIMITS.maxTotal} (the two are live at once)`);
1178
+ }
1179
+ seq.segments.forEach((s, i) => {
1180
+ if (isObj(s) && isObj(s.scene) && s.scene.background !== void 0) {
1181
+ warnings.push({
1182
+ path: `segments[${i}].scene.background`,
1183
+ code: "bed-hides-segment-background",
1184
+ message: `segment ${i} declares a background but the sequence has a bed \u2014 segments render transparently over the bed, so this background is never painted`
1185
+ });
1186
+ }
1187
+ });
1188
+ }
1189
+ }
1190
+ }
1069
1191
  if (errors.length === 0 && Array.isArray(seq.segments)) {
1070
1192
  for (let i = 0; i < seq.segments.length; i++) {
1071
1193
  const s = seq.segments[i];
@@ -1080,6 +1202,12 @@ function validateSequence(seq) {
1080
1202
  code: "morph-structural-mismatch",
1081
1203
  message: `segments ${i}\u2192${i + 1} differ structurally: morph will fall back to cut`
1082
1204
  });
1205
+ } else if (morphNothingMorphable(s.scene, next.scene, { textCrossfade: s.transition.text === "crossfade" })) {
1206
+ warnings.push({
1207
+ path: `segments[${i}].transition`,
1208
+ code: "morph-nothing-morphable",
1209
+ message: `segments ${i}\u2192${i + 1} differ only in values morph cannot interpolate (strings such as textBlock.text step on the first frame): the morph will look like a cut \u2014 fade text via colour or reveal.progress`
1210
+ });
1083
1211
  }
1084
1212
  }
1085
1213
  }
@@ -1255,7 +1383,8 @@ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
1255
1383
  Math.round(layer.count * countScale),
1256
1384
  layer.links ? LIMITS.maxLinkLayerCount : LIMITS.maxPerLayer
1257
1385
  ));
1258
- const [smin, smax] = layer.size ?? [20, 40];
1386
+ const defaultSize = scale === 1 ? [20, 40] : [20 / LIMITS.referenceViewport, 40 / LIMITS.referenceViewport];
1387
+ const [smin, smax] = layer.size ?? defaultSize;
1259
1388
  const variants = spriteVariants(layer.sprite);
1260
1389
  const sprite = layer.sprite;
1261
1390
  const spriteColors = isShapedSprite(sprite) ? sprite.colors : void 0;
@@ -1444,6 +1573,7 @@ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
1444
1573
  ...sprite.kind === "bar" ? { barIndex: i } : {},
1445
1574
  ...ordered ? { ordered: true } : {},
1446
1575
  ...orbitParent ? { orbitParent } : {},
1576
+ ...layer.wrap === false ? { wrapDisabled: true } : {},
1447
1577
  ...m.type === "wander" ? {
1448
1578
  wander: {
1449
1579
  own: drawOsc(rng, meanderPx),
@@ -1635,9 +1765,10 @@ function positionAt(e, t, w, h) {
1635
1765
  };
1636
1766
  }
1637
1767
  if (e.motion === "rise") {
1768
+ const y2 = e.y0 + e.vy * dt;
1638
1769
  return {
1639
1770
  x: e.x0 + (e.bob ? e.bob * Math.sin(t / 700 + e.phase) : 0),
1640
- y: wrap(e.y0 + e.vy * dt, -m, h + m),
1771
+ y: e.wrapDisabled ? y2 : wrap(y2, -m, h + m),
1641
1772
  flip: false
1642
1773
  };
1643
1774
  }
@@ -1657,14 +1788,18 @@ function positionAt(e, t, w, h) {
1657
1788
  }
1658
1789
  }
1659
1790
  const margin = m + wp.margin;
1791
+ const x2 = e.x0 + e.vx * dt + hx;
1792
+ const y2 = e.y0 + e.vy * dt + hy;
1660
1793
  return {
1661
- x: wrap(e.x0 + e.vx * dt + hx, -margin, w + margin),
1662
- y: wrap(e.y0 + e.vy * dt + hy, -margin, h + margin),
1794
+ x: e.wrapDisabled ? x2 : wrap(x2, -margin, w + margin),
1795
+ y: e.wrapDisabled ? y2 : wrap(y2, -margin, h + margin),
1663
1796
  flip: e.headingLeft
1664
1797
  };
1665
1798
  }
1666
- const x = wrap(e.x0 + e.vx * dt, -m, w + m);
1667
- let y = e.vy !== 0 ? wrap(e.y0 + e.vy * dt, -m, h + m) : e.y0;
1799
+ const rawX = e.x0 + e.vx * dt;
1800
+ const x = e.wrapDisabled ? rawX : wrap(rawX, -m, w + m);
1801
+ const rawY = e.y0 + e.vy * dt;
1802
+ let y = e.vy !== 0 && !e.wrapDisabled ? wrap(rawY, -m, h + m) : rawY;
1668
1803
  if (e.bob) y += e.bob * Math.sin(t / 500 + e.phase);
1669
1804
  return { x, y, flip: e.headingLeft };
1670
1805
  }
@@ -1765,18 +1900,25 @@ function linkPairs(positions, k, maxDist, doWrap, w, h) {
1765
1900
  }
1766
1901
  var CHAR_NARROW = new Set(`iIljtf1!|.,;:'"()[]{}`);
1767
1902
  var CHAR_WIDE = new Set("mwMWGOQD@%");
1768
- function charWidthEm(ch) {
1903
+ var MONO_FAMILY_RE = /\b(?:ui-)?mono(?:space)?\b|courier|menlo|monaco|sfmono|consolas|inconsolata|fira\s*code|source\s*code|jetbrains|ibm\s*plex\s*mono|roboto\s*mono/i;
1904
+ function textMetricsClassFor(font) {
1905
+ return font && MONO_FAMILY_RE.test(font) ? "mono" : "proportional";
1906
+ }
1907
+ var MONO_ADVANCE_EM = 0.6;
1908
+ function charWidthEm(ch, metrics = "proportional") {
1909
+ if (metrics === "mono") return MONO_ADVANCE_EM;
1769
1910
  if (ch === " " || ch === " ") return 0.3;
1770
1911
  if (CHAR_NARROW.has(ch)) return 0.35;
1771
1912
  if (CHAR_WIDE.has(ch)) return 0.72;
1772
1913
  return 0.55;
1773
1914
  }
1774
- function textWidthEm(str) {
1915
+ function textWidthEm(str, metrics = "proportional") {
1916
+ if (metrics === "mono") return graphemeClusters(str).length * MONO_ADVANCE_EM;
1775
1917
  let w = 0;
1776
- for (let i = 0; i < str.length; i++) w += charWidthEm(str[i]);
1918
+ for (let i = 0; i < str.length; i++) w += charWidthEm(str[i], metrics);
1777
1919
  return w;
1778
1920
  }
1779
- function breakTextBlock(text, maxWidthEm) {
1921
+ function breakTextBlock(text, maxWidthEm, metrics = "proportional") {
1780
1922
  const paragraphs = text.split("\n");
1781
1923
  const lines = [];
1782
1924
  for (const para of paragraphs) {
@@ -1790,11 +1932,11 @@ function breakTextBlock(text, maxWidthEm) {
1790
1932
  continue;
1791
1933
  }
1792
1934
  let lineText = words[0];
1793
- let lineW = textWidthEm(lineText);
1794
- const spaceW = charWidthEm(" ");
1935
+ let lineW = textWidthEm(lineText, metrics);
1936
+ const spaceW = charWidthEm(" ", metrics);
1795
1937
  for (let i = 1; i < words.length; i++) {
1796
1938
  const word = words[i];
1797
- const wordW = textWidthEm(word);
1939
+ const wordW = textWidthEm(word, metrics);
1798
1940
  if (lineW + spaceW + wordW <= maxWidthEm) {
1799
1941
  lineText += " " + word;
1800
1942
  lineW += spaceW + wordW;
@@ -1808,6 +1950,13 @@ function breakTextBlock(text, maxWidthEm) {
1808
1950
  }
1809
1951
  return lines;
1810
1952
  }
1953
+ function textBlockAnchorOffset(s, maxWPx, maxLineW, totalH) {
1954
+ if (!s.anchor) return { dx: 0, dy: 0 };
1955
+ const ax = s.anchor.endsWith("left") ? 0 : s.anchor.endsWith("right") ? 1 : 0.5;
1956
+ const ay = s.anchor.startsWith("top") ? 0 : s.anchor.startsWith("bottom") ? 1 : 0.5;
1957
+ const inkX0 = s.align === "center" ? (maxWPx - maxLineW) / 2 : s.align === "right" ? maxWPx - maxLineW : 0;
1958
+ return { dx: -(inkX0 + ax * maxLineW), dy: -(ay * totalH) };
1959
+ }
1811
1960
  function isClusterExtend(cp) {
1812
1961
  return cp >= 768 && cp <= 879 || // combining diacritics
1813
1962
  cp >= 6832 && cp <= 6911 || cp >= 8400 && cp <= 8447 || cp >= 65024 && cp <= 65039 || // variation selectors
@@ -1955,9 +2104,45 @@ function resolveSegment(seq, T, opts = {}) {
1955
2104
  const lastStart = totalTimed - (segments[last].duration ?? 0);
1956
2105
  return { index: last, localT: t0 - lastStart, startT: lastStart };
1957
2106
  }
2107
+ function canMorph(seq, from) {
2108
+ const seg = seq.segments[from];
2109
+ if (!seg || seg.transition?.type !== "morph") return false;
2110
+ const next = seq.segments[from + 1];
2111
+ if (!next) return false;
2112
+ return structuralSignature(seg.scene) === structuralSignature(next.scene);
2113
+ }
2114
+ function morphChainRoot(seq, index) {
2115
+ let i = index;
2116
+ while (i > 0 && canMorph(seq, i - 1)) i--;
2117
+ return i;
2118
+ }
2119
+ function normalizeSeed(seed) {
2120
+ return seed >>> 0 || 1;
2121
+ }
2122
+ function segmentRenderSeed(seq, index) {
2123
+ const root = morphChainRoot(seq, index);
2124
+ const scene = seq.segments[root]?.scene;
2125
+ if (!scene) return void 0;
2126
+ const raw = scene.seed ?? (seq.seed !== void 0 ? seq.seed + root : void 0);
2127
+ return raw === void 0 ? void 0 : normalizeSeed(raw);
2128
+ }
2129
+ function bedRenderSeed(seq) {
2130
+ if (!seq.bed) return void 0;
2131
+ const raw = seq.bed.seed ?? (seq.seed !== void 0 ? seq.seed + LIMITS.maxSegments : void 0);
2132
+ return raw === void 0 ? void 0 : normalizeSeed(raw);
2133
+ }
1958
2134
 
1959
2135
  // src/compile.ts
1960
2136
  var DEFAULT_STEER_DUR = 1e3;
2137
+ function applyRetainedDeltas(spec, deltas) {
2138
+ let out = spec;
2139
+ for (const d of deltas) {
2140
+ if (!resolveSpecPath(out, d.path)) continue;
2141
+ const next = applyDeltasToSpec(out, [d]);
2142
+ if (validateSpec(next).valid) out = next;
2143
+ }
2144
+ return out;
2145
+ }
1961
2146
  function hexToRgba(hex, alpha) {
1962
2147
  const h = hex.length === 4 ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}` : hex;
1963
2148
  const n = parseInt(h.slice(1), 16);
@@ -1995,11 +2180,12 @@ function manifestFor(spec) {
1995
2180
  };
1996
2181
  }
1997
2182
  var SpecInstance = class {
1998
- constructor(spec, ctx) {
2183
+ constructor(spec, ctx, opts = {}) {
1999
2184
  this.spec = spec;
2000
2185
  this.effSpec = spec;
2001
2186
  this.saverCtx = ctx;
2002
- this.seed = (spec.seed ?? ctx.seed) >>> 0 || 1;
2187
+ this.transparent = opts.transparent === true;
2188
+ this.seed = normalizeSeed(spec.seed ?? ctx.seed);
2003
2189
  let canvas;
2004
2190
  if (ctx.surface) {
2005
2191
  canvas = ctx.surface;
@@ -2013,7 +2199,7 @@ var SpecInstance = class {
2013
2199
  this.ownsCanvas = true;
2014
2200
  }
2015
2201
  this.canvas = canvas;
2016
- const c2d = canvas.getContext("2d", { alpha: false });
2202
+ const c2d = canvas.getContext("2d", { alpha: this.transparent });
2017
2203
  if (!c2d) throw new Error("schema saver: no 2d context");
2018
2204
  this.ctx = c2d;
2019
2205
  this.w = ctx.width;
@@ -2021,12 +2207,16 @@ var SpecInstance = class {
2021
2207
  this.sizeCanvas();
2022
2208
  this.rebuild();
2023
2209
  this.paused = ctx.reducedMotion;
2024
- if (this.paused) this.renderFrame(0, this.seed);
2025
- else this.start();
2210
+ if (this.paused) {
2211
+ if (!opts.skipInitialPaint) this.renderFrame(0, this.seed);
2212
+ } else {
2213
+ this.start();
2214
+ }
2026
2215
  }
2027
2216
  spec;
2028
2217
  canvas;
2029
2218
  ownsCanvas;
2219
+ transparent;
2030
2220
  ctx;
2031
2221
  saverCtx;
2032
2222
  seed;
@@ -2046,6 +2236,8 @@ var SpecInstance = class {
2046
2236
  /** Active glide between two resolved specs (live setParam/applyTrack). */
2047
2237
  transition = null;
2048
2238
  lastStructural = "";
2239
+ /** See `LayerPaintOverride`; null (the default, and every plain mount) draws every layer once. */
2240
+ paintOverrides = null;
2049
2241
  /** Viewport factor for absolute px sizes — 1 for `units: 'px'` specs. */
2050
2242
  fontScale() {
2051
2243
  if (this.effSpec.units === "px") return 1;
@@ -2182,14 +2374,14 @@ var SpecInstance = class {
2182
2374
  ctx.fill();
2183
2375
  }
2184
2376
  }
2185
- drawEntity(built, e, t, lifeA, parentE) {
2377
+ drawEntity(built, e, t, lifeA, parentE, paint) {
2186
2378
  const { ctx } = this;
2187
2379
  const p = this.entityPos(e, t, parentE);
2188
- const sprite = built.layer.sprite;
2380
+ const sprite = paint?.sprite ?? built.layer.sprite;
2189
2381
  const sz = sizeAt(e, t);
2190
2382
  const rot = rotationAt(e, t);
2191
2383
  const unitScale = this.effSpec.units === "px" ? 1 : Math.min(this.w, this.h);
2192
- ctx.globalAlpha = alphaAt(e, t) * lifeA;
2384
+ ctx.globalAlpha = alphaAt(e, t) * lifeA * (paint?.alpha ?? 1);
2193
2385
  if (sprite.kind === "ring") {
2194
2386
  const r = sz / 2;
2195
2387
  const resolvedColor = sprite.colors?.[e.colorIndex] ?? sprite.color;
@@ -2341,7 +2533,7 @@ var SpecInstance = class {
2341
2533
  const lh = (sprite.lineHeight ?? 1.4) * fsPx;
2342
2534
  const maxWPx = sprite.maxWidth * unitScale2;
2343
2535
  const maxWEm = maxWPx / fsPx;
2344
- const lines = breakTextBlock(sprite.text, maxWEm);
2536
+ const lines = breakTextBlock(sprite.text, maxWEm, textMetricsClassFor(sprite.font));
2345
2537
  const align = sprite.align ?? "left";
2346
2538
  const reveal = sprite.reveal;
2347
2539
  const rs = reveal ? revealState(lines, reveal, t) : null;
@@ -2349,7 +2541,13 @@ var SpecInstance = class {
2349
2541
  ctx.save();
2350
2542
  ctx.translate(p.x, p.y);
2351
2543
  if (rot) ctx.rotate(rot);
2352
- ctx.font = `${fsPx}px system-ui, sans-serif`;
2544
+ if (sprite.anchor) {
2545
+ const maxLineW = lines.reduce((m, l) => Math.max(m, l.widthEm), 0) * fsPx;
2546
+ const { dx, dy } = textBlockAnchorOffset(sprite, maxWPx, maxLineW, lines.length * lh);
2547
+ if (dx !== 0 || dy !== 0) ctx.translate(dx, dy);
2548
+ }
2549
+ if (sprite.opacity !== void 0) ctx.globalAlpha *= sprite.opacity;
2550
+ ctx.font = sprite.font ? composeFontShorthand(fsPx, sprite.font) : `${fsPx}px system-ui, sans-serif`;
2353
2551
  ctx.fillStyle = sprite.color ?? "#e6e8ef";
2354
2552
  ctx.textBaseline = "top";
2355
2553
  ctx.textAlign = align;
@@ -2463,6 +2661,33 @@ var SpecInstance = class {
2463
2661
  this.renderFrame(this.lastT, this.seed);
2464
2662
  }
2465
2663
  }
2664
+ /**
2665
+ * Apply steering deltas immediately: no glide, no frame painted. Each delta
2666
+ * is routed by `applyRetainedDeltas` (unresolvable or invalid ones are
2667
+ * skipped on this spec alone). `SequenceInstance` uses this to hand a
2668
+ * freshly created child the track the sequence has retained, so a steer
2669
+ * made while another segment was active lands on the segment that owns the
2670
+ * path. Not painting matters: a child is created inside the parent's
2671
+ * renderFrame, and a stray t=0 paint here would make the real frame that
2672
+ * follows look "contiguous" to the ghosting warm-up — but the constructor
2673
+ * already painted ONE stray t=0 frame of the pre-steer spec (SpecInstance's
2674
+ * own mount does an immediate render when paused, which every sequence
2675
+ * child is), so `lastRenderT` must be reset too: otherwise, with `ghosting`
2676
+ * on, the real frame that follows still finds itself "contiguous" with
2677
+ * that stray paint and composites over it at partial alpha, briefly
2678
+ * showing a ghost of the un-steered scene.
2679
+ */
2680
+ applyDeltasNow(deltas) {
2681
+ const next = applyRetainedDeltas(this.effSpec, deltas);
2682
+ if (next === this.effSpec) return;
2683
+ this.transition = null;
2684
+ this.lastRenderT = Number.NEGATIVE_INFINITY;
2685
+ this.effSpec = next;
2686
+ if (structuralSignature(this.effSpec) !== this.lastStructural) this.rebuild();
2687
+ else this.layers.forEach((b, i) => {
2688
+ b.layer = this.effSpec.layers[i] ?? b.layer;
2689
+ });
2690
+ }
2466
2691
  drawLinks(built, t, lifeA, parentE) {
2467
2692
  const { links } = built.layer;
2468
2693
  if (!links) return;
@@ -2472,7 +2697,9 @@ var SpecInstance = class {
2472
2697
  const positions = built.entities.map((e) => this.entityPos(e, t, parentE));
2473
2698
  const maxDistPx = links.maxDist * (this.effSpec.units === "px" ? 1 : Math.min(this.w, this.h));
2474
2699
  const edges = linkEdges(links, positions, maxDistPx, wrap2, this.w, this.h);
2475
- const lw = (links.width ?? 1) * (this.effSpec.units === "px" ? 1 : Math.min(this.w, this.h));
2700
+ const unitScale = this.effSpec.units === "px" ? 1 : Math.min(this.w, this.h);
2701
+ const defaultWidth = unitScale === 1 ? 1 : 1 / LIMITS.referenceViewport;
2702
+ const lw = (links.width ?? defaultWidth) * unitScale;
2476
2703
  ctx.lineWidth = lw;
2477
2704
  ctx.lineCap = "butt";
2478
2705
  for (const { i, j, dist } of edges) {
@@ -2506,19 +2733,28 @@ var SpecInstance = class {
2506
2733
  * showing through — the ghosting smear. */
2507
2734
  paintFrame(t, bgAlpha) {
2508
2735
  const { ctx } = this;
2509
- ctx.globalAlpha = bgAlpha;
2510
- ctx.globalCompositeOperation = "source-over";
2511
- this.drawBackground(t);
2736
+ if (!this.transparent) {
2737
+ ctx.globalAlpha = bgAlpha;
2738
+ ctx.globalCompositeOperation = "source-over";
2739
+ this.drawBackground(t);
2740
+ }
2512
2741
  ctx.globalAlpha = 1;
2513
- for (const built of this.layers) {
2742
+ for (let li = 0; li < this.layers.length; li++) {
2743
+ const built = this.layers[li];
2514
2744
  const lifeA = lifeAlphaAt(built.layer.life, t);
2515
2745
  if (lifeA <= 0) continue;
2516
2746
  const parentE = this.parentEntityFor(built);
2517
2747
  ctx.globalCompositeOperation = built.layer.blend ?? "source-over";
2518
2748
  this.drawLinks(built, t, lifeA, parentE);
2749
+ const override = this.paintOverrides?.get(li) ?? null;
2519
2750
  for (const e of built.entities) {
2520
2751
  this.drawTrail(built, e, t, lifeA, parentE);
2521
- this.drawEntity(built, e, t, lifeA, parentE);
2752
+ if (override === null) {
2753
+ this.drawEntity(built, e, t, lifeA, parentE);
2754
+ continue;
2755
+ }
2756
+ if (override.k < 1) this.drawEntity(built, e, t, lifeA, parentE, { sprite: override.outgoing, alpha: 1 - override.k });
2757
+ if (override.k > 0) this.drawEntity(built, e, t, lifeA, parentE, { alpha: override.k });
2522
2758
  }
2523
2759
  }
2524
2760
  ctx.globalAlpha = 1;
@@ -2535,7 +2771,7 @@ var SpecInstance = class {
2535
2771
  */
2536
2772
  renderFrame(t, _seed) {
2537
2773
  this.stepTransition(t);
2538
- const g = this.effSpec.ghosting ?? 0;
2774
+ const g = this.transparent ? 0 : this.effSpec.ghosting ?? 0;
2539
2775
  if (g > 0) {
2540
2776
  const dt = 1e3 / 60;
2541
2777
  const contiguous = this.lastRenderT !== Number.NEGATIVE_INFINITY && t > this.lastRenderT && t - this.lastRenderT <= 250;
@@ -2571,8 +2807,8 @@ var SpecInstance = class {
2571
2807
  }
2572
2808
  /**
2573
2809
  * Replace the rendered spec without triggering a transition glide.
2574
- * Checks structural signature — use `hotSwapPaint` when the caller
2575
- * already knows the signature is unchanged (e.g. mid-morph lerp).
2810
+ * Checks structural signature — use `hotSwapPaint` only when the caller
2811
+ * already knows the signature is unchanged.
2576
2812
  */
2577
2813
  hotSwapSpec(spec) {
2578
2814
  this.effSpec = spec;
@@ -2589,6 +2825,15 @@ var SpecInstance = class {
2589
2825
  b.layer = this.effSpec.layers[i] ?? b.layer;
2590
2826
  });
2591
2827
  }
2828
+ /**
2829
+ * Internal (see `LayerPaintOverride`): draw the listed layers twice —
2830
+ * outgoing sprite at `1 − k`, own sprite at `k` — until cleared with
2831
+ * `null`. Keyed by layer index. A `SequenceInstance` sets it per morph
2832
+ * frame under `text: 'crossfade'`; nothing else does.
2833
+ */
2834
+ setLayerPaintOverrides(overrides) {
2835
+ this.paintOverrides = overrides && overrides.size > 0 ? overrides : null;
2836
+ }
2592
2837
  dispose() {
2593
2838
  this.stop();
2594
2839
  if (this.ownsCanvas && typeof HTMLCanvasElement !== "undefined" && this.canvas instanceof HTMLCanvasElement) this.canvas.remove();
@@ -2602,6 +2847,7 @@ function compileSaver(spec) {
2602
2847
  spec: valid
2603
2848
  };
2604
2849
  }
2850
+ var BED_PREFIX = "bed.";
2605
2851
  var SequenceInstance = class {
2606
2852
  seq;
2607
2853
  childCtx;
@@ -2632,8 +2878,55 @@ var SequenceInstance = class {
2632
2878
  */
2633
2879
  clockOffset = 0;
2634
2880
  releasedBelow = 0;
2881
+ /**
2882
+ * Set by a `sequence.segment` steer that targets segment 0 under `loop:
2883
+ * true`: the steer resets the clock to segment 0's own start (localT 0),
2884
+ * so `renderFrame`'s wrap check (which compares the raw, ever-increasing
2885
+ * clock against `timedTotal()`) can no longer recognize the jump as a
2886
+ * wrap. This flag lets the clicker's jump to 0 still count as one, so the
2887
+ * last segment's fade plays instead of a hard cut. Cleared once the wrap
2888
+ * window (if any) has passed.
2889
+ */
2890
+ pendingWrapFade = false;
2891
+ /**
2892
+ * Every non-`sequence.segment` delta this instance has been handed, last
2893
+ * wins per path, merged across `applyTrack` calls. Children are created
2894
+ * lazily and disposed on every segment switch, so a steer forwarded only
2895
+ * to the active child would evaporate at the next boundary — and a steer
2896
+ * to a path only a *later* segment owns (`bars.sprite.values` while the title
2897
+ * slide is up) would land nowhere. Re-applied to every child on creation
2898
+ * and folded into the morph endpoints; each child keeps the deltas that
2899
+ * resolve on it (see `applyRetainedDeltas`).
2900
+ */
2901
+ retainedDeltas = /* @__PURE__ */ new Map();
2902
+ /** The outgoing segment while a `fade` runs; null otherwise. */
2903
+ fading = null;
2904
+ /**
2905
+ * The incoming segment while a `fade` runs over a `bed`. Children over a
2906
+ * bed are transparent, so a cross-fade needs both of them on canvases of
2907
+ * their own — the incoming composited at k, the outgoing at 1 − k. Without
2908
+ * a bed the incoming paints its own opaque ground on the shared surface and
2909
+ * this stays null.
2910
+ */
2911
+ fadingIn = null;
2912
+ /** False on the `basic`/`minimal` capability tiers — fade renders as cut. */
2913
+ fadeEnabled;
2914
+ /**
2915
+ * The sequence's `bed`: one scene on the shared surface, drawn first every
2916
+ * frame at the sequence clock — WITHOUT `clockOffset`, so a
2917
+ * `sequence.segment` steer rewinds the segment and never the bed — and never
2918
+ * re-created. Segments render over it transparently. Null when absent.
2919
+ */
2920
+ bed;
2635
2921
  constructor(seq, ctx) {
2636
2922
  this.seq = seq;
2923
+ const { sequenceBaseT, capabilityTier, ...plainCtx } = ctx;
2924
+ this.fadeEnabled = capabilityTier !== "basic" && capabilityTier !== "minimal";
2925
+ if (seq.sync === "epoch" && typeof sequenceBaseT === "number" && Number.isFinite(sequenceBaseT)) {
2926
+ this.baseT = Math.max(0, sequenceBaseT);
2927
+ this.lastT = this.baseT;
2928
+ this.renderedT = this.baseT;
2929
+ }
2637
2930
  let surface = ctx.surface ?? null;
2638
2931
  let canvas = null;
2639
2932
  if (!surface && typeof document !== "undefined") {
@@ -2645,15 +2938,32 @@ var SequenceInstance = class {
2645
2938
  }
2646
2939
  this.canvas = canvas;
2647
2940
  this.seed = (seq.seed ?? ctx.seed ?? 0) >>> 0 || 1;
2648
- this.childCtx = { ...ctx, surface, reducedMotion: true };
2941
+ this.childCtx = { ...plainCtx, surface, reducedMotion: true };
2649
2942
  this.children = new Array(seq.segments.length).fill(null);
2943
+ if (seq.bed) {
2944
+ this.bed = new SpecInstance(this.bedScene(seq.bed), this.childCtx);
2945
+ this.bed.setPaused(true);
2946
+ } else {
2947
+ this.bed = null;
2948
+ }
2650
2949
  this.paused = ctx.reducedMotion;
2651
- if (this.paused) this.renderFrame(0, this.seed);
2950
+ if (this.paused) this.renderFrame(this.baseT, this.seed);
2652
2951
  else this.start();
2653
2952
  }
2654
2953
  childSeed(index, fallback) {
2655
2954
  return this.seq.segments[index].scene.seed ?? this.seq.seed ?? fallback;
2656
2955
  }
2956
+ /**
2957
+ * The bed's seed follows the segment rule (own seed, else the sequence seed)
2958
+ * but offset by `maxSegments`, past every segment's `seed + index`, so a bed
2959
+ * and segment 0 never share an entity stream and adding a segment does not
2960
+ * re-seat the bed.
2961
+ */
2962
+ bedScene(bed) {
2963
+ if (bed.seed != null) return bed;
2964
+ if (this.seq.seed != null) return { ...bed, seed: this.seq.seed + LIMITS.maxSegments };
2965
+ return bed;
2966
+ }
2657
2967
  childScene(index) {
2658
2968
  const seg = this.seq.segments[index];
2659
2969
  if (seg.scene.seed != null) return seg.scene;
@@ -2662,29 +2972,132 @@ var SequenceInstance = class {
2662
2972
  }
2663
2973
  /** Whether the boundary from `from` to `from+1` should morph. */
2664
2974
  canMorph(from) {
2665
- const seg = this.seq.segments[from];
2666
- if (!seg || seg.transition?.type !== "morph") return false;
2667
- const next = this.seq.segments[from + 1];
2668
- if (!next) return false;
2669
- return structuralSignature(seg.scene) === structuralSignature(next.scene);
2975
+ return canMorph(this.seq, from);
2670
2976
  }
2671
2977
  /** Walk back through consecutive morph boundaries to find the chain origin. */
2672
2978
  morphChainRoot(index) {
2673
- let i = index;
2674
- while (i > 0 && this.canMorph(i - 1)) i--;
2675
- return i;
2979
+ return morphChainRoot(this.seq, index);
2676
2980
  }
2677
2981
  morphDur(from) {
2678
2982
  const tr = this.seq.segments[from]?.transition;
2679
2983
  return tr?.type === "morph" ? tr.dur : 0;
2680
2984
  }
2681
- ensureChild(index) {
2985
+ /** Whether the morph out of `from` cross-fades its text (default `step`: strings switch at k > 0). */
2986
+ morphTextCrossfades(from) {
2987
+ const tr = this.seq.segments[from]?.transition;
2988
+ return tr?.type === "morph" && tr.text === "crossfade";
2989
+ }
2990
+ /**
2991
+ * The paint overrides for one crossfade frame: every text layer whose
2992
+ * words differ between the outgoing and incoming specs, painting the
2993
+ * outgoing words (in the lerped frame's paint — colour and alpha glide as
2994
+ * usual) at `1 − k` under the incoming at `k`. Null when nothing differs,
2995
+ * so a crossfade between same-worded twins is a plain morph frame.
2996
+ */
2997
+ crossfadeOverrides(specA, specB, lerped, k) {
2998
+ let map = null;
2999
+ for (let i = 0; i < lerped.layers.length; i++) {
3000
+ if (!textStringsDiffer(specA, specB, i)) continue;
3001
+ const from = specA.layers[i].sprite;
3002
+ const now = lerped.layers[i].sprite;
3003
+ const outgoing = from.kind === "text" && now.kind === "text" ? { ...now, strings: from.strings } : from.kind === "textBlock" && now.kind === "textBlock" ? { ...now, text: from.text } : now;
3004
+ if (outgoing === now) continue;
3005
+ (map ??= /* @__PURE__ */ new Map()).set(i, { outgoing, k });
3006
+ }
3007
+ return map;
3008
+ }
3009
+ /** Length of the fade out of segment `from`, or 0 (no fade, or a tier that plays it as cut). */
3010
+ fadeDur(from) {
3011
+ const tr = this.seq.segments[from]?.transition;
3012
+ return this.fadeEnabled && tr?.type === "fade" ? tr.dur : 0;
3013
+ }
3014
+ /** Sum of every timed segment — the lap length under `loop: true`. */
3015
+ timedTotal() {
3016
+ const last = this.seq.segments.length - 1;
3017
+ return segmentStart(this.seq, last) + (this.seq.segments[last].duration ?? 0);
3018
+ }
3019
+ releaseFading() {
3020
+ if (this.fading) {
3021
+ this.fading.child.dispose();
3022
+ this.fading = null;
3023
+ }
3024
+ if (this.fadingIn) {
3025
+ this.fadingIn.child.dispose();
3026
+ this.fadingIn = null;
3027
+ }
3028
+ }
3029
+ /**
3030
+ * A segment child on a canvas of its own. Like a morph it mounts on its
3031
+ * chain root's scene and seed (so its entities are the ones the viewer was
3032
+ * watching) and hot-swaps to the segment's spec; the retained track is
3033
+ * applied last. Transparent over a bed, opaque otherwise.
3034
+ */
3035
+ offscreenChild(index, root) {
3036
+ const canvas = typeof document !== "undefined" ? document.createElement("canvas") : new OffscreenCanvas(Math.max(1, this.childCtx.width), Math.max(1, this.childCtx.height));
3037
+ const child = new SpecInstance(this.childScene(root), { ...this.childCtx, surface: canvas }, { transparent: this.bed !== null });
3038
+ child.setPaused(true);
3039
+ if (root !== index) child.hotSwapSpec(this.childScene(index));
3040
+ if (this.retainedDeltas.size > 0) child.applyDeltasNow(this.retainedDeltas.values());
3041
+ return { index, child, canvas };
3042
+ }
3043
+ /** Render an offscreen child; a transparent one never clears, so its owner does here. */
3044
+ renderOffscreen(oc, t, seed) {
3045
+ if (this.bed) {
3046
+ const c = oc.canvas.getContext("2d");
3047
+ c?.clearRect(0, 0, this.childCtx.width, this.childCtx.height);
3048
+ }
3049
+ oc.child.renderFrame(t, seed);
3050
+ }
3051
+ /** Draw an offscreen canvas onto the shared surface at `alpha`. */
3052
+ composite(canvas, alpha) {
3053
+ const main = this.childCtx.surface?.getContext("2d");
3054
+ if (!main) return;
3055
+ main.save();
3056
+ main.globalAlpha = alpha;
3057
+ main.globalCompositeOperation = "source-over";
3058
+ main.drawImage(canvas, 0, 0, this.childCtx.width, this.childCtx.height);
3059
+ main.restore();
3060
+ }
3061
+ /**
3062
+ * Render the outgoing segment `from` of a live fade onto its own canvas. It
3063
+ * keeps animating at `duration(from) + localT` (it does not freeze — and if
3064
+ * it was an `advance: 'input'` hold it resumes from its duration, not from
3065
+ * wherever the hold had reached). Two live segments for `dur` only; the
3066
+ * outgoing one is disposed when the fade ends.
3067
+ */
3068
+ renderOutgoing(from, localT, seed) {
3069
+ if (this.fading && this.fading.index !== from) {
3070
+ this.fading.child.dispose();
3071
+ this.fading = null;
3072
+ }
3073
+ if (!this.fading) this.fading = this.offscreenChild(from, this.morphChainRoot(from));
3074
+ const outDur = this.seq.segments[from].duration ?? 0;
3075
+ this.renderOffscreen(this.fading, outDur + localT, this.childSeed(this.morphChainRoot(from), seed));
3076
+ return this.fading;
3077
+ }
3078
+ /** A segment's scene with the retained track applied — `scene` itself when nothing resolves on it. */
3079
+ steeredScene(scene) {
3080
+ return this.retainedDeltas.size === 0 ? scene : applyRetainedDeltas(scene, this.retainedDeltas.values());
3081
+ }
3082
+ /**
3083
+ * The child for segment `index`, created paused on the shared surface if
3084
+ * the slot is empty. `rootScene` (a morph chain's root) mounts the child
3085
+ * with the root's scene so its seed and entity placement are continuous
3086
+ * with the chain, then hot-swaps to the segment's own spec. Either way the
3087
+ * retained track is applied last, so a steer made while another segment
3088
+ * was up is already in effect on this child's first frame. `skipInitialPaint`
3089
+ * is for a caller (the morph branch) that is about to render a real frame,
3090
+ * paint overrides included, in this same call — see `SpecInstanceOptions`.
3091
+ */
3092
+ ensureChild(index, rootScene, skipInitialPaint) {
2682
3093
  if (index < 0 || index >= this.seq.segments.length) index = 0;
2683
3094
  let child = this.children[index];
2684
3095
  if (!child) {
2685
- child = new SpecInstance(this.childScene(index), this.childCtx);
3096
+ child = new SpecInstance(rootScene ?? this.childScene(index), this.childCtx, { transparent: this.bed !== null, skipInitialPaint });
2686
3097
  this.children[index] = child;
2687
3098
  child.setPaused(true);
3099
+ if (rootScene) child.hotSwapSpec(this.childScene(index));
3100
+ if (this.retainedDeltas.size > 0) child.applyDeltasNow(this.retainedDeltas.values());
2688
3101
  }
2689
3102
  return child;
2690
3103
  }
@@ -2717,8 +3130,15 @@ var SequenceInstance = class {
2717
3130
  this.renderedT = T;
2718
3131
  const resolved = resolveSegment(this.seq, T + this.clockOffset, { releasedBelow: this.releasedBelow });
2719
3132
  const { index, localT } = resolved;
3133
+ this.bed?.renderFrame(T, this.seq.bed?.seed ?? this.seq.seed ?? seed);
2720
3134
  const prevIdx = index > 0 ? index - 1 : -1;
2721
3135
  const morphActive = prevIdx >= 0 && this.canMorph(prevIdx) && localT < this.morphDur(prevIdx);
3136
+ const lastIdx = this.seq.segments.length - 1;
3137
+ const wrapped = this.seq.loop && index === 0 && lastIdx > 0 && (T + this.clockOffset >= this.timedTotal() || this.pendingWrapFade);
3138
+ const fadeFrom = prevIdx >= 0 ? prevIdx : wrapped ? lastIdx : -1;
3139
+ const fadeActive = fadeFrom >= 0 && localT < this.fadeDur(fadeFrom);
3140
+ if (!fadeActive && this.fading) this.releaseFading();
3141
+ if (this.pendingWrapFade && (index !== 0 || !fadeActive)) this.pendingWrapFade = false;
2722
3142
  if (morphActive) {
2723
3143
  const chainRoot = this.morphChainRoot(index);
2724
3144
  this.morphFromIndex = prevIdx;
@@ -2726,12 +3146,14 @@ var SequenceInstance = class {
2726
3146
  for (let i = 0; i < this.children.length; i++) {
2727
3147
  if (i !== chainRoot) this.releaseChild(i);
2728
3148
  }
2729
- const child = this.ensureChild(chainRoot);
3149
+ const child = this.ensureChild(chainRoot, void 0, this.morphTextCrossfades(prevIdx));
2730
3150
  const dur = this.morphDur(prevIdx);
2731
3151
  const k = easeSmooth(localT / dur);
2732
- const specA = this.childScene(prevIdx);
2733
- const specB = this.childScene(index);
2734
- child.hotSwapPaint(lerpSpec(specA, specB, k));
3152
+ const specA = this.steeredScene(this.childScene(prevIdx));
3153
+ const specB = this.steeredScene(this.childScene(index));
3154
+ const lerped = lerpSpec(specA, specB, k);
3155
+ child.hotSwapSpec(lerped);
3156
+ child.setLayerPaintOverrides(this.morphTextCrossfades(prevIdx) ? this.crossfadeOverrides(specA, specB, lerped, k) : null);
2735
3157
  child.renderFrame(localT, this.childSeed(chainRoot, seed));
2736
3158
  } else {
2737
3159
  if (this.morphFromIndex >= 0) {
@@ -2749,18 +3171,25 @@ var SequenceInstance = class {
2749
3171
  const chainRoot = this.morphChainRoot(index);
2750
3172
  const useMorphSeed = chainRoot < index;
2751
3173
  const effSeed = useMorphSeed ? this.childSeed(chainRoot, seed) : this.childSeed(index, seed);
2752
- let child;
2753
- if (useMorphSeed && !this.children[index]) {
2754
- const rootScene = this.childScene(chainRoot);
2755
- child = new SpecInstance(rootScene, this.childCtx);
2756
- this.children[index] = child;
2757
- child.setPaused(true);
2758
- child.hotSwapSpec(this.childScene(index));
2759
- } else {
2760
- child = this.ensureChild(index);
2761
- if (useMorphSeed) child.hotSwapSpec(this.childScene(index));
3174
+ if (fadeActive && this.bed) {
3175
+ const k = easeSmooth(localT / this.fadeDur(fadeFrom));
3176
+ if (this.fadingIn && this.fadingIn.index !== index) {
3177
+ this.fadingIn.child.dispose();
3178
+ this.fadingIn = null;
3179
+ }
3180
+ if (!this.fadingIn) this.fadingIn = this.offscreenChild(index, useMorphSeed ? chainRoot : index);
3181
+ this.renderOffscreen(this.fadingIn, localT, effSeed);
3182
+ const out = this.renderOutgoing(fadeFrom, localT, seed);
3183
+ this.composite(this.fadingIn.canvas, k);
3184
+ this.composite(out.canvas, 1 - k);
3185
+ return;
2762
3186
  }
3187
+ const child = useMorphSeed ? this.ensureChild(index, this.childScene(chainRoot)) : this.ensureChild(index);
2763
3188
  child.renderFrame(localT, effSeed);
3189
+ if (fadeActive) {
3190
+ const out = this.renderOutgoing(fadeFrom, localT, seed);
3191
+ this.composite(out.canvas, 1 - easeSmooth(localT / this.fadeDur(fadeFrom)));
3192
+ }
2764
3193
  }
2765
3194
  }
2766
3195
  setPaused(paused) {
@@ -2770,6 +3199,9 @@ var SequenceInstance = class {
2770
3199
  for (const child of this.children) {
2771
3200
  child?.setPaused(true);
2772
3201
  }
3202
+ this.fading?.child.setPaused(true);
3203
+ this.fadingIn?.child.setPaused(true);
3204
+ this.bed?.setPaused(true);
2773
3205
  }
2774
3206
  resize(width, height, dpr) {
2775
3207
  this.childCtx.width = width;
@@ -2778,35 +3210,58 @@ var SequenceInstance = class {
2778
3210
  for (const child of this.children) {
2779
3211
  child?.resize(width, height, dpr);
2780
3212
  }
3213
+ this.fading?.child.resize(width, height, dpr);
3214
+ this.fadingIn?.child.resize(width, height, dpr);
3215
+ this.bed?.resize(width, height, dpr);
2781
3216
  }
2782
3217
  applyTrack(track) {
2783
- const deltas = track?.deltas ?? [];
3218
+ const all = track?.deltas ?? [];
3219
+ const isBedPath = (d) => this.bed !== null && typeof d.path === "string" && d.path.startsWith(BED_PREFIX);
3220
+ const bedDeltas = all.filter(isBedPath).map((d) => ({ ...d, path: d.path.slice(BED_PREFIX.length) }));
3221
+ if (bedDeltas.length > 0) this.bed.applyTrack({ ...track, deltas: bedDeltas });
3222
+ const deltas = all.filter((d) => !isBedPath(d));
3223
+ for (const d of deltas) {
3224
+ if (d.path !== "sequence.segment" && typeof d.path === "string") this.retainedDeltas.set(d.path, d);
3225
+ }
2784
3226
  const segDelta = deltas.find((d) => d.path === "sequence.segment");
3227
+ let switchedSegment = false;
2785
3228
  if (segDelta !== void 0 && typeof segDelta.value === "number") {
2786
3229
  const idx = Math.max(0, Math.min(this.seq.segments.length - 1, Math.round(segDelta.value)));
3230
+ this.pendingWrapFade = this.seq.loop && idx === 0 && this.seq.segments.length > 1;
2787
3231
  this.clockOffset = segmentStart(this.seq, idx) - this.renderedT;
2788
3232
  this.releasedBelow = idx;
2789
3233
  this.renderFrame(this.renderedT, this.seed);
3234
+ switchedSegment = true;
2790
3235
  }
2791
3236
  const childDeltas = deltas.filter((d) => d.path !== "sequence.segment");
2792
- if (childDeltas.length > 0 && this.activeIndex >= 0) {
2793
- const child = this.children[this.activeIndex];
2794
- child?.applyTrack({ ...track, deltas: childDeltas });
3237
+ if (childDeltas.length > 0) {
3238
+ if (this.morphFromIndex >= 0) {
3239
+ if (!switchedSegment) this.renderFrame(this.renderedT, this.seed);
3240
+ } else if (this.activeIndex >= 0) {
3241
+ const child = this.children[this.activeIndex];
3242
+ child?.applyTrack({ ...track, deltas: childDeltas });
3243
+ }
3244
+ this.fading?.child.applyTrack({ ...track, deltas: childDeltas });
3245
+ this.fadingIn?.child.applyTrack({ ...track, deltas: childDeltas });
2795
3246
  }
2796
3247
  }
2797
3248
  dispose() {
2798
3249
  this.stop();
3250
+ this.releaseFading();
2799
3251
  for (let i = 0; i < this.children.length; i++) {
2800
3252
  this.releaseChild(i);
2801
3253
  }
3254
+ this.bed?.dispose();
2802
3255
  if (this.canvas) this.canvas.remove();
2803
3256
  }
2804
3257
  };
2805
3258
  function sequenceManifest(seq) {
2806
- const maxTotal = seq.segments.reduce((max, s) => {
3259
+ const maxSegment = seq.segments.reduce((max, s) => {
2807
3260
  const t = s.scene.layers.reduce((n, l) => n + l.count, 0);
2808
3261
  return Math.max(max, t);
2809
3262
  }, 0);
3263
+ const bedTotal = seq.bed ? seq.bed.layers.reduce((n, l) => n + l.count, 0) : 0;
3264
+ const maxTotal = bedTotal + maxSegment;
2810
3265
  const costTier = maxTotal < 30 ? "idle" : maxTotal < 150 ? "low" : maxTotal < 400 ? "medium" : "high";
2811
3266
  return {
2812
3267
  id: seq.id,
@@ -2890,6 +3345,58 @@ function colourSeparation(layer, sprite, bg) {
2890
3345
  const db = sprite.rgb.b - bg.rgb.b;
2891
3346
  return Math.sqrt((dr * dr + dg * dg + db * db) / 3);
2892
3347
  }
3348
+ function srgbToLinear(c) {
3349
+ return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
3350
+ }
3351
+ function relativeLuminance(rgb) {
3352
+ return 0.2126 * srgbToLinear(rgb.r) + 0.7152 * srgbToLinear(rgb.g) + 0.0722 * srgbToLinear(rgb.b);
3353
+ }
3354
+ function legibilityRatio(a, b) {
3355
+ const la = relativeLuminance(a);
3356
+ const lb = relativeLuminance(b);
3357
+ const hi = Math.max(la, lb);
3358
+ const lo = Math.min(la, lb);
3359
+ return (hi + 0.05) / (lo + 0.05);
3360
+ }
3361
+ function backgroundRgbAt(spec, yPx, h, unit) {
3362
+ const bg = spec.background;
3363
+ if (!bg || bg.type === "solid") return hexRgb(bg?.color ?? "#05050a");
3364
+ if (bg.band) {
3365
+ const bh = bg.band.height * unit;
3366
+ if (yPx >= h - bh) return hexRgb(bg.band.color);
3367
+ }
3368
+ const stops = [...bg.stops].sort((a, b) => a.at - b.at);
3369
+ const y = h > 0 ? Math.max(0, Math.min(1, yPx / h)) : 0;
3370
+ const first = stops[0];
3371
+ const last = stops[stops.length - 1];
3372
+ if (y <= first.at) return hexRgb(first.color);
3373
+ if (y >= last.at) return hexRgb(last.color);
3374
+ for (let i = 1; i < stops.length; i++) {
3375
+ const a = stops[i - 1];
3376
+ const b = stops[i];
3377
+ if (y > b.at) continue;
3378
+ const span = b.at - a.at;
3379
+ const k = span > 0 ? (y - a.at) / span : 1;
3380
+ const ca = hexRgb(a.color);
3381
+ const cb = hexRgb(b.color);
3382
+ return { r: ca.r + (cb.r - ca.r) * k, g: ca.g + (cb.g - ca.g) * k, b: ca.b + (cb.b - ca.b) * k };
3383
+ }
3384
+ return hexRgb(last.color);
3385
+ }
3386
+ function sourceOverPlate(ground, colour, alpha) {
3387
+ return {
3388
+ r: ground.r + (colour.r - ground.r) * alpha,
3389
+ g: ground.g + (colour.g - ground.g) * alpha,
3390
+ b: ground.b + (colour.b - ground.b) * alpha
3391
+ };
3392
+ }
3393
+ function additivePlate(ground, colour, alpha, blend) {
3394
+ const mix = (g, c) => {
3395
+ const v = c * alpha;
3396
+ return blend === "lighter" ? Math.min(1, g + v) : 1 - (1 - g) * (1 - v);
3397
+ };
3398
+ return { r: mix(ground.r, colour.r), g: mix(ground.g, colour.g), b: mix(ground.b, colour.b) };
3399
+ }
2893
3400
 
2894
3401
  // src/cohesion.ts
2895
3402
  var MERGE_FLOOR = 0.35;
@@ -3101,6 +3608,8 @@ function seamsWorthWarning(layer, c, entityCount) {
3101
3608
  // src/advise.ts
3102
3609
  var LOW_CONTRAST_FLOOR = 0.05;
3103
3610
  var SPARSE_DECLARED_MAX_COVERAGE = 0.02;
3611
+ var READ_LEGIBILITY_FLOOR = 4.5;
3612
+ var READ_SAFE_AREA = 0.05;
3104
3613
  function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
3105
3614
  const warnings = [];
3106
3615
  const w = viewport.width;
@@ -3205,8 +3714,8 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
3205
3714
  const fsPx = layer.sprite.fontSize * scale;
3206
3715
  const lh = (layer.sprite.lineHeight ?? 1.4) * fsPx;
3207
3716
  const maxWPx = layer.sprite.maxWidth * scale;
3208
- const lines = breakTextBlock(layer.sprite.text, maxWPx / fsPx);
3209
- pixArea = maxWPx * lines.length * lh * 0.55;
3717
+ const lines = breakTextBlock(layer.sprite.text, maxWPx / fsPx, textMetricsClassFor(layer.sprite.font));
3718
+ pixArea = maxWPx * lines.length * lh * 0.55 * (layer.sprite.opacity ?? 1);
3210
3719
  } else {
3211
3720
  pixArea = e.size * e.size;
3212
3721
  }
@@ -3222,7 +3731,8 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
3222
3731
  const positions = entities.map((e) => positionAt(e, 0, w, h));
3223
3732
  const maxDistPx = layer.links.maxDist * scale;
3224
3733
  const edges = linkEdges(layer.links, positions, maxDistPx, layer.wrap !== false, w, h);
3225
- const lwPx = (layer.links.width ?? 1) * scale;
3734
+ const defaultWidth = scale === 1 ? 1 : 1 / LIMITS.referenceViewport;
3735
+ const lwPx = (layer.links.width ?? defaultWidth) * scale;
3226
3736
  const la = layer.links.alpha ?? 1;
3227
3737
  for (const edge of edges) totalCoverage += edge.dist * lwPx * la / (w * h);
3228
3738
  }
@@ -3325,9 +3835,16 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
3325
3835
  for (const e of allEntities[li]) {
3326
3836
  const p = positionAt(e, 0, w, h);
3327
3837
  const box = s.kind === "textBlock" ? textBlockBoxAt(s, p, w, h) : textBoxAt(s, e, p, spec, w, h);
3328
- textBoxes.push({ li, label, ...box });
3838
+ const alpha = Math.max(0, Math.min(1, e.alpha - e.pulseAmp)) * (s.kind === "textBlock" ? s.opacity ?? 1 : 1);
3839
+ textBoxes.push({ li, label, alpha, ...box });
3329
3840
  }
3330
3841
  }
3842
+ const asFraction = (b) => ({
3843
+ x: +(b.x0 / w).toFixed(4),
3844
+ y: +(b.y0 / h).toFixed(4),
3845
+ w: +((b.x1 - b.x0) / w).toFixed(4),
3846
+ h: +((b.y1 - b.y0) / h).toFixed(4)
3847
+ });
3331
3848
  const tolX = w * 0.01;
3332
3849
  const tolY = h * 0.01;
3333
3850
  const offFlagged = /* @__PURE__ */ new Set();
@@ -3366,12 +3883,109 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
3366
3883
  warnings.push({
3367
3884
  path: `layers[${first.li}]`,
3368
3885
  code: "text-overlap",
3369
- 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`
3886
+ 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`,
3887
+ // The two boxes, so an agent can move one without re-deriving the
3888
+ // layout. Additive: the message is unchanged.
3889
+ boxes: [asFraction(first), asFraction(second)]
3890
+ });
3891
+ }
3892
+ }
3893
+ const readFlagged = /* @__PURE__ */ new Set();
3894
+ for (const b of textBoxes) {
3895
+ if (readFlagged.has(b.li)) continue;
3896
+ const layer = spec.layers[b.li];
3897
+ const s = layer.sprite;
3898
+ if (s.kind !== "text" && s.kind !== "textBlock" || s.role !== "read") continue;
3899
+ const boxes = textBoxes.filter((x) => x.li === b.li);
3900
+ readFlagged.add(b.li);
3901
+ const ink = hexRgb(s.color ?? "#e6e8ef");
3902
+ const textPlate = (base, alpha) => {
3903
+ if (layer.blend === "lighter" || layer.blend === "screen") return additivePlate(base, ink, alpha, layer.blend);
3904
+ if (layer.blend === "multiply") {
3905
+ return {
3906
+ r: base.r * (1 - alpha + ink.r * alpha),
3907
+ g: base.g * (1 - alpha + ink.g * alpha),
3908
+ b: base.b * (1 - alpha + ink.b * alpha)
3909
+ };
3910
+ }
3911
+ return sourceOverPlate(base, ink, alpha);
3912
+ };
3913
+ let worst = null;
3914
+ for (const box of boxes) {
3915
+ const cy = (box.y0 + box.y1) / 2;
3916
+ const groundRgb = backgroundRgbAt(spec, cy, h, scale);
3917
+ const ground = legibilityRatio(textPlate(groundRgb, box.alpha), groundRgb);
3918
+ const lit = brightestAdditivePlate(spec, allEntities, groundRgb, box, w, h);
3919
+ const plate = lit ? legibilityRatio(textPlate(lit.rgb, box.alpha), lit.rgb) : Infinity;
3920
+ if (!worst || Math.min(ground, plate) < Math.min(worst.ground, worst.plate)) {
3921
+ worst = { ground, plate, plateLabel: lit?.label ?? null };
3922
+ }
3923
+ }
3924
+ if (worst && Math.min(worst.ground, worst.plate) < READ_LEGIBILITY_FLOOR) {
3925
+ const under = worst.plateLabel ? ` and ${worst.plate.toFixed(1)}:1 under ${worst.plateLabel} at its brightest` : "";
3926
+ warnings.push({
3927
+ path: `layers[${b.li}].sprite`,
3928
+ code: "text-legibility",
3929
+ message: `${b.label} is declared role: 'read' but its colour reads at ${worst.ground.toFixed(1)}:1 against the background at the box centre${under} \u2014 below ${READ_LEGIBILITY_FLOOR}:1; brighten the text, darken the ground under it, or declare role: 'atmosphere' if it is texture`
3930
+ });
3931
+ }
3932
+ const edges = [];
3933
+ const x0 = Math.min(...boxes.map((x) => x.x0));
3934
+ const x1 = Math.max(...boxes.map((x) => x.x1));
3935
+ const y0 = Math.min(...boxes.map((x) => x.y0));
3936
+ const y1 = Math.max(...boxes.map((x) => x.y1));
3937
+ if (x0 < w * READ_SAFE_AREA) edges.push("left");
3938
+ if (x1 > w * (1 - READ_SAFE_AREA)) edges.push("right");
3939
+ if (y0 < h * READ_SAFE_AREA) edges.push("top");
3940
+ if (y1 > h * (1 - READ_SAFE_AREA)) edges.push("bottom");
3941
+ if (edges.length > 0) {
3942
+ warnings.push({
3943
+ path: `layers[${b.li}]`,
3944
+ code: "text-safe-area",
3945
+ message: `${b.label} is declared role: 'read' but its box is within ${READ_SAFE_AREA * 100}% of the ${edges.join(" and ")} edge${edges.length === 1 ? "" : "s"} at ${w}\xD7${h} \u2014 displays crop and bezels hide that zone; move it inward`,
3946
+ boxes: [asFraction({ x0, y0, x1, y1 })]
3370
3947
  });
3371
3948
  }
3372
3949
  }
3373
3950
  return warnings;
3374
3951
  }
3952
+ function brightestAdditivePlate(spec, allEntities, ground, box, w, h) {
3953
+ let best = null;
3954
+ for (let lj = 0; lj < spec.layers.length; lj++) {
3955
+ const layer = spec.layers[lj];
3956
+ const blend = layer.blend;
3957
+ if (blend !== "lighter" && blend !== "screen") continue;
3958
+ if (lj === box.li) continue;
3959
+ const entities = allEntities[lj];
3960
+ for (const e of entities) {
3961
+ const hex = spriteHex(layer, e);
3962
+ if (hex === null) continue;
3963
+ const growScale = (e.motion === "warp" ? WARP_MAX_SCALE : 1) * (e.emit ? Math.max(e.emit.growFrom, e.emit.growTo) : 1);
3964
+ const maxDim = Math.max(e.size, e.size2 ?? 0) * (1 + e.growAmp) * growScale;
3965
+ if (maxDim < box.fs) continue;
3966
+ if (!entityReachesBox(e, maxDim / 2, box, w, h)) continue;
3967
+ const a = Math.min(1, e.alpha + e.pulseAmp);
3968
+ if (a <= 0) continue;
3969
+ const plate = additivePlate(ground, hexRgb(hex), a, blend);
3970
+ const lum = relativeLuminance(plate);
3971
+ if (!best || lum > best.lum) {
3972
+ best = { rgb: plate, label: layer.key ? `\`${layer.key}\`` : `layers[${lj}]`, lum };
3973
+ }
3974
+ }
3975
+ }
3976
+ return best ? { rgb: best.rgb, label: best.label } : null;
3977
+ }
3978
+ function entityReachesBox(e, r, box, w, h) {
3979
+ if (e.motion === "static") {
3980
+ const p = positionAt(e, 0, w, h);
3981
+ return p.x + r > box.x0 && p.x - r < box.x1 && p.y + r > box.y0 && p.y - r < box.y1;
3982
+ }
3983
+ if (e.motion === "orbit" && !e.orbitParent) {
3984
+ const span = e.orbitR + r;
3985
+ return e.orbitCx + span > box.x0 && e.orbitCx - span < box.x1 && e.orbitCy + span > box.y0 && e.orbitCy - span < box.y1;
3986
+ }
3987
+ return true;
3988
+ }
3375
3989
  function textBoxAt(s, e, p, spec, w, h) {
3376
3990
  const m = s.font ? /(\d{1,5}(?:\.\d{1,4})?|\.\d{1,4})px/.exec(s.font) : null;
3377
3991
  const fontScale = spec.units === "px" ? 1 : Math.min(w, h) / (spec.referenceViewport ?? LIMITS.referenceViewport);
@@ -3383,28 +3997,39 @@ function textBoxAt(s, e, p, spec, w, h) {
3383
3997
  const baseline = s.baseline ?? "middle";
3384
3998
  const x0 = align === "left" ? p.x : align === "right" ? p.x - fw : p.x - fw / 2;
3385
3999
  const y0 = baseline === "top" ? p.y : baseline === "bottom" ? p.y - fh : p.y - fh / 2;
3386
- return { x0, y0, x1: x0 + fw, y1: y0 + fh };
4000
+ return { x0, y0, x1: x0 + fw, y1: y0 + fh, fs: fh };
3387
4001
  }
3388
4002
  function textBlockBoxAt(s, p, w, h) {
3389
4003
  const unit = Math.min(w, h);
3390
4004
  const fsPx = s.fontSize * unit;
3391
4005
  const lh = (s.lineHeight ?? 1.4) * fsPx;
3392
4006
  const maxWPx = s.maxWidth * unit;
3393
- const lines = breakTextBlock(s.text, maxWPx / fsPx);
4007
+ const lines = breakTextBlock(s.text, maxWPx / fsPx, textMetricsClassFor(s.font));
3394
4008
  const maxLineW = lines.reduce((mx, l) => Math.max(mx, l.widthEm), 0) * fsPx;
3395
4009
  const totalH = lines.length * lh;
3396
4010
  const align = s.align ?? "left";
3397
4011
  const x0 = align === "center" ? p.x + (maxWPx - maxLineW) / 2 : align === "right" ? p.x + maxWPx - maxLineW : p.x;
3398
- return { x0, y0: p.y, x1: x0 + maxLineW, y1: p.y + totalH };
4012
+ const { dx, dy } = textBlockAnchorOffset(s, maxWPx, maxLineW, totalH);
4013
+ return { x0: x0 + dx, y0: p.y + dy, x1: x0 + dx + maxLineW, y1: p.y + dy + totalH, fs: fsPx };
3399
4014
  }
3400
4015
  function adviseSequence(seq, viewport = { width: 1920, height: 1080 }) {
3401
4016
  const warnings = [];
3402
4017
  for (let i = 0; i < seq.segments.length; i++) {
3403
4018
  const segWarnings = adviseSpec(seq.segments[i].scene, viewport);
3404
4019
  for (const w of segWarnings) {
3405
- warnings.push({ path: `segments[${i}].scene.${w.path}`, code: w.code, message: w.message });
4020
+ warnings.push({ path: `segments[${i}].scene.${w.path}`, code: w.code, message: w.message, boxes: w.boxes });
3406
4021
  }
3407
4022
  }
4023
+ const fadeBound = seq.loop ? seq.segments.length : seq.segments.length - 1;
4024
+ const fades = seq.segments.slice(0, fadeBound).filter((s) => s.transition?.type === "fade").length;
4025
+ if (fades > 0) {
4026
+ const first = seq.segments.slice(0, fadeBound).findIndex((s) => s.transition?.type === "fade");
4027
+ warnings.push({
4028
+ path: `segments[${first}].transition`,
4029
+ code: "fade-degrades-on-low-tier",
4030
+ message: `informational: ${fades} fade transition${fades === 1 ? "" : "s"} declared \u2014 viewers on the 'basic' or 'minimal' capability tier render fade as cut (two live segments for dur is over that budget); the show still plays`
4031
+ });
4032
+ }
3408
4033
  for (let i = 0; i < seq.segments.length - 1; i++) {
3409
4034
  const lumaA = backgroundLuma(seq.segments[i].scene);
3410
4035
  const lumaB = backgroundLuma(seq.segments[i + 1].scene);
@@ -3426,6 +4051,12 @@ function adviseSequence(seq, viewport = { width: 1920, height: 1080 }) {
3426
4051
  code: "morph-structural-mismatch",
3427
4052
  message: `segments ${i}\u2192${i + 1} differ structurally: morph will fall back to cut`
3428
4053
  });
4054
+ } else if (morphNothingMorphable(seq.segments[i].scene, seq.segments[i + 1].scene, { textCrossfade: tr.text === "crossfade" })) {
4055
+ warnings.push({
4056
+ path: `segments[${i}].transition`,
4057
+ code: "morph-nothing-morphable",
4058
+ message: `segments ${i}\u2192${i + 1} differ only in values morph cannot interpolate (strings such as textBlock.text step on the first frame): the morph will look like a cut \u2014 fade text via colour or reveal.progress`
4059
+ });
3429
4060
  }
3430
4061
  }
3431
4062
  }
@@ -3578,14 +4209,15 @@ function textBlockBox(s, _e, p, w, h) {
3578
4209
  const lh = (s.lineHeight ?? 1.4) * fsPx;
3579
4210
  const maxWPx = s.maxWidth * unitScale;
3580
4211
  const maxWEm = maxWPx / fsPx;
3581
- const lines = breakTextBlock(s.text, maxWEm);
4212
+ const lines = breakTextBlock(s.text, maxWEm, textMetricsClassFor(s.font));
3582
4213
  const totalH = lines.length * lh;
3583
4214
  const maxLineW = lines.reduce((m, l) => Math.max(m, l.widthEm), 0) * fsPx;
3584
4215
  const align = s.align ?? "left";
3585
4216
  const cx = align === "center" ? p.x + maxWPx / 2 : align === "right" ? p.x + maxWPx - maxLineW / 2 : p.x + maxLineW / 2;
4217
+ const { dx, dy } = textBlockAnchorOffset(s, maxWPx, maxLineW, totalH);
3586
4218
  return {
3587
- cx,
3588
- cy: p.y + totalH / 2,
4219
+ cx: cx + dx,
4220
+ cy: p.y + totalH / 2 + dy,
3589
4221
  halfX: maxLineW / 2,
3590
4222
  halfY: totalH / 2
3591
4223
  };
@@ -3594,7 +4226,7 @@ function textBlockRevealFraction(s, w, h, t) {
3594
4226
  if (!s.reveal) return 1;
3595
4227
  const unitScale = Math.min(w, h);
3596
4228
  const maxWEm = s.maxWidth * unitScale / (s.fontSize * unitScale);
3597
- const lines = breakTextBlock(s.text, maxWEm);
4229
+ const lines = breakTextBlock(s.text, maxWEm, textMetricsClassFor(s.font));
3598
4230
  const rs = revealState(lines, s.reveal, t);
3599
4231
  if (rs.glyphAlphas) {
3600
4232
  let sum = 0;
@@ -3788,7 +4420,7 @@ function luminanceGrid(spec, opts = {}) {
3788
4420
  const c1 = Math.min(cols - 1, Math.floor((centerX + reachX) / cellW));
3789
4421
  const r0 = Math.max(0, Math.floor((centerY - reachY) / cellH));
3790
4422
  const r1 = Math.min(rows - 1, Math.floor((centerY + reachY) / cellH));
3791
- const inkWeight = (s.kind === "text" || s.kind === "emoji" || s.kind === "textBlock" ? 0.55 : 1) * (s.kind === "textBlock" ? textBlockRevealFraction(s, w, h, t) : 1);
4423
+ const inkWeight = (s.kind === "text" || s.kind === "emoji" || s.kind === "textBlock" ? 0.55 : 1) * (s.kind === "textBlock" ? textBlockRevealFraction(s, w, h, t) * (s.opacity ?? 1) : 1);
3792
4424
  for (let r = r0; r <= r1; r++) {
3793
4425
  for (let c = c0; c <= c1; c++) {
3794
4426
  const dx = (c + 0.5) * cellW - centerX;
@@ -3826,7 +4458,8 @@ function luminanceGrid(spec, opts = {}) {
3826
4458
  const edges = linkEdges(layer.links, positions, maxDistPx, layer.wrap !== false && motionWraps, w, h);
3827
4459
  const la = Math.min(1, (layer.links.alpha ?? 0.6) * lifeA);
3828
4460
  const lum = layer.links.color ? hexLuma(layer.links.color) : 0.7;
3829
- const wgt = Math.min(1, (layer.links.width ?? 1) * scale / cellH) * la * alphaScale;
4461
+ const defaultWidth = scale === 1 ? 1 : 1 / LIMITS.referenceViewport;
4462
+ const wgt = Math.min(1, (layer.links.width ?? defaultWidth) * scale / cellH) * la * alphaScale;
3830
4463
  for (const edge of edges) {
3831
4464
  const pi = positions[edge.i];
3832
4465
  const pj = positions[edge.j];
@@ -3856,6 +4489,9 @@ function luminanceGrid(spec, opts = {}) {
3856
4489
  }
3857
4490
  }
3858
4491
  splatPass(t, 1);
4492
+ return finishGrid(cols, rows, cells, bgRow);
4493
+ }
4494
+ function finishGrid(cols, rows, cells, bgRow) {
3859
4495
  let sum = 0;
3860
4496
  let covered = 0;
3861
4497
  let devSum = 0;
@@ -3965,6 +4601,21 @@ function textSprites(spec, opts = {}) {
3965
4601
  return out;
3966
4602
  }
3967
4603
  function dominanceRanking(spec, opts = {}) {
4604
+ return rankDominance(rawDominance(spec, opts));
4605
+ }
4606
+ function rankDominance(raw) {
4607
+ const total = raw.reduce((s, r) => s + r.weight, 0) || 1;
4608
+ return raw.slice().sort((a, b) => b.weight - a.weight).map((r, i) => ({
4609
+ rank: i + 1,
4610
+ layerIndex: r.layerIndex,
4611
+ key: r.key,
4612
+ share: r.weight / total,
4613
+ entityCount: r.entityCount,
4614
+ meanLuma: r.meanLuma,
4615
+ factors: r.factors
4616
+ }));
4617
+ }
4618
+ function rawDominance(spec, opts = {}) {
3968
4619
  const scene = buildScene(spec, opts);
3969
4620
  const t = opts.t ?? 5e3;
3970
4621
  const { w, h, scale } = scene;
@@ -4016,7 +4667,7 @@ function dominanceRanking(spec, opts = {}) {
4016
4667
  entArea = box.halfX * 2 * box.halfY * 2 * 0.55;
4017
4668
  } else if (s.kind === "textBlock") {
4018
4669
  const box = textBlockBox(s, e, { x: 0, y: 0 }, w, h);
4019
- entArea = box.halfX * 2 * box.halfY * 2 * 0.55 * textBlockRevealFraction(s, w, h, t);
4670
+ entArea = box.halfX * 2 * box.halfY * 2 * 0.55 * textBlockRevealFraction(s, w, h, t) * (s.opacity ?? 1);
4020
4671
  } else entArea = sz * sz * 0.55;
4021
4672
  area += entArea * a;
4022
4673
  if (layer.trail) {
@@ -4034,7 +4685,8 @@ function dominanceRanking(spec, opts = {}) {
4034
4685
  const motionWraps = ["drift", "rise", "wander"].includes(layer.motion.type);
4035
4686
  const edges = linkEdges(layer.links, positions, layer.links.maxDist * scale, layer.wrap !== false && motionWraps, w, h);
4036
4687
  const la = (layer.links.alpha ?? 0.6) * lifeA;
4037
- const lw = (layer.links.width ?? 1) * scale;
4688
+ const defaultWidth = scale === 1 ? 1 : 1 / LIMITS.referenceViewport;
4689
+ const lw = (layer.links.width ?? defaultWidth) * scale;
4038
4690
  for (const edge of edges) area += edge.dist * lw * la * LINE_SALIENCE;
4039
4691
  }
4040
4692
  const meanLuma = entities.length ? lumAcc / entities.length : 0;
@@ -4044,16 +4696,7 @@ function dominanceRanking(spec, opts = {}) {
4044
4696
  const weight = area / (w * h) * contrast * blendBoost * motionBoost;
4045
4697
  return { layerIndex, key: layer.key, weight, entityCount: entities.length, meanLuma, factors: { area: area / (w * h), contrast, blendBoost, motionBoost } };
4046
4698
  });
4047
- const total = raw.reduce((s, r) => s + r.weight, 0) || 1;
4048
- return raw.slice().sort((a, b) => b.weight - a.weight).map((r, i) => ({
4049
- rank: i + 1,
4050
- layerIndex: r.layerIndex,
4051
- key: r.key,
4052
- share: r.weight / total,
4053
- entityCount: r.entityCount,
4054
- meanLuma: r.meanLuma,
4055
- factors: r.factors
4056
- }));
4699
+ return raw;
4057
4700
  }
4058
4701
  function layerCohesion(spec, opts = {}) {
4059
4702
  const scene = buildScene(spec, opts);
@@ -4160,6 +4803,51 @@ function perceiveScene(spec, opts = {}) {
4160
4803
  advisories: adviseSpec(spec, opts.viewport ?? { width: 1920, height: 1080 }, { t: opts.t, seed: opts.seed })
4161
4804
  };
4162
4805
  }
4806
+ var bedKey = (key, layerIndex) => `bed:${key ?? layerIndex}`;
4807
+ function perceiveSequenceFrame(seq, T, opts = {}) {
4808
+ const { releasedBelow, ...gridOpts } = opts;
4809
+ const r = resolveSegment(seq, T, { releasedBelow });
4810
+ const seg = seq.segments[r.index];
4811
+ const segment = { index: r.index, key: seg.key, localT: r.localT, ...r.held ? { held: true } : {} };
4812
+ const segOpts = { ...gridOpts, t: r.localT, seed: gridOpts.seed ?? segmentRenderSeed(seq, r.index) };
4813
+ if (!seq.bed) return { ...perceiveScene(seg.scene, segOpts), segment, bed: false };
4814
+ const bedOpts = { ...gridOpts, t: T, seed: gridOpts.seed ?? bedRenderSeed(seq) };
4815
+ const viewport = gridOpts.viewport ?? { width: 1920, height: 1080 };
4816
+ const ink = { ...seg.scene, background: { type: "solid", color: "#000000" } };
4817
+ delete ink.ghosting;
4818
+ const inkOverBed = { ...ink, background: seq.bed.background ?? { type: "solid", color: "#05050a" } };
4819
+ const bedGrid = luminanceGrid(seq.bed, bedOpts);
4820
+ const inkGrid = luminanceGrid(ink, segOpts);
4821
+ const cells = bedGrid.cells.map((v, i) => Math.min(1, v + inkGrid.cells[i]));
4822
+ const grid = finishGrid(bedGrid.cols, bedGrid.rows, cells, bedGrid.background);
4823
+ const bedLayers = seq.bed.layers.length;
4824
+ const dominance = rankDominance([
4825
+ ...rawDominance(seq.bed, bedOpts).map((d) => ({ ...d, key: bedKey(d.key, d.layerIndex) })),
4826
+ ...rawDominance(inkOverBed, segOpts).map((d) => ({ ...d, layerIndex: d.layerIndex + bedLayers }))
4827
+ ]);
4828
+ const shift = (x) => ({ ...x, layerIndex: x.layerIndex + bedLayers });
4829
+ const prefix = (x) => ({ ...x, key: bedKey(x.key, x.layerIndex) });
4830
+ return {
4831
+ t: r.localT,
4832
+ braille: renderBrailleMap(grid),
4833
+ density: renderDensityMap(grid),
4834
+ coverage: grid.coverage,
4835
+ meanLuminance: grid.meanLuminance,
4836
+ centroid: grid.centroid,
4837
+ rowProfile: grid.rowProfile,
4838
+ colProfile: grid.colProfile,
4839
+ dominance,
4840
+ motion: [...motionStats(seq.bed, bedOpts).map(prefix), ...motionStats(ink, segOpts).map(shift)],
4841
+ form: [...layerCohesion(seq.bed, bedOpts).map(prefix), ...layerCohesion(ink, segOpts).map(shift)],
4842
+ text: [...textSprites(seq.bed, bedOpts).map(prefix), ...textSprites(ink, segOpts).map(shift)],
4843
+ advisories: [
4844
+ ...adviseSpec(seq.bed, viewport, { t: T, seed: bedOpts.seed }).map((w) => ({ ...w, path: `bed.${w.path}` })),
4845
+ ...adviseSpec(inkOverBed, viewport, { t: r.localT, seed: segOpts.seed })
4846
+ ],
4847
+ segment,
4848
+ bed: true
4849
+ };
4850
+ }
4163
4851
 
4164
4852
  // src/examples/aquarium.ts
4165
4853
  var AQUARIUM_SPEC = {
@@ -5049,6 +5737,85 @@ var LANTERNS_SPEC = {
5049
5737
  ]
5050
5738
  };
5051
5739
 
5740
+ // src/examples/lobby-talk.ts
5741
+ var LOBBY_TALK_SPEC = {
5742
+ schemaVersion: 1,
5743
+ id: "lobby-talk",
5744
+ label: "Lobby Talk",
5745
+ seed: 1207,
5746
+ motionIntensity: "calm",
5747
+ background: {
5748
+ type: "gradient",
5749
+ stops: [
5750
+ { at: 0, color: "#060912" },
5751
+ { at: 1, color: "#0b1224" }
5752
+ ],
5753
+ drift: { period: 4e4, amount: 0.1 }
5754
+ },
5755
+ layers: [
5756
+ {
5757
+ key: "haze",
5758
+ count: 3,
5759
+ sprite: { kind: "circle", radius: [0.22, 0.3], soft: true, color: "#123a4a", colors: ["#123a4a", "#2a1e4a", "#0e2a3a"] },
5760
+ alpha: [0.35, 0.5],
5761
+ blend: "lighter",
5762
+ region: { x: [0.2, 0.8], y: [0.3, 0.7] },
5763
+ motion: { type: "wander", speed: [2e-3, 4e-3], meander: 0.05 },
5764
+ pulse: { amp: 0.15, period: 9e3 }
5765
+ },
5766
+ {
5767
+ key: "dust",
5768
+ count: 50,
5769
+ sprite: { kind: "circle", radius: [8e-4, 18e-4], soft: true, color: "#7fb8c9" },
5770
+ alpha: [0.2, 0.5],
5771
+ blend: "lighter",
5772
+ motion: { type: "wander", speed: [1e-3, 3e-3] }
5773
+ },
5774
+ {
5775
+ key: "title",
5776
+ count: 1,
5777
+ sprite: { kind: "textBlock", text: "Adoption by feature, % of accounts", maxWidth: 1, fontSize: 0.034, color: "#f2f4f8", role: "read" },
5778
+ motion: { type: "static" },
5779
+ position: { x: 0.12, y: 0.16 }
5780
+ },
5781
+ {
5782
+ key: "labels",
5783
+ count: 5,
5784
+ sprite: { kind: "text", strings: ["Onboarding", "Search", "Billing", "Exports", "Mobile"], font: "system-ui", align: "left", baseline: "middle", color: "#a9b7cc", role: "read" },
5785
+ size: [0.03, 0.03],
5786
+ motion: { type: "static" },
5787
+ position: { x: 0.12, y: 0.32 },
5788
+ layout: { type: "list", gap: 0.085 }
5789
+ },
5790
+ {
5791
+ key: "bars",
5792
+ count: 5,
5793
+ sprite: {
5794
+ kind: "bar",
5795
+ values: [82, 64, 91, 37, 58],
5796
+ max: 100,
5797
+ length: 0.42,
5798
+ thickness: 0.026,
5799
+ color: "#7fe0d2",
5800
+ colors: ["#7fe0d2", "#7fe0d2", "#ffcf7a", "#7fe0d2", "#7fe0d2"]
5801
+ },
5802
+ alpha: [0.9, 0.9],
5803
+ motion: { type: "static" },
5804
+ position: { x: 0.33, y: 0.32 },
5805
+ layout: { type: "list", gap: 0.085 }
5806
+ },
5807
+ {
5808
+ key: "readouts",
5809
+ count: 5,
5810
+ sprite: { kind: "text", strings: ["82%", "64%", "91%", "37%", "58%"], font: "bold system-ui", align: "left", baseline: "middle", color: "#f2f4f8", role: "read" },
5811
+ size: [0.03, 0.03],
5812
+ motion: { type: "static" },
5813
+ position: { x: 0.78, y: 0.32 },
5814
+ layout: { type: "list", gap: 0.085 }
5815
+ }
5816
+ ]
5817
+ };
5818
+
5052
5819
  // src/examples/matrix-rain.ts
5053
5820
  var MATRIX_RAIN_SPEC = {
5054
5821
  schemaVersion: 1,
@@ -5773,7 +6540,8 @@ var SCHEMA_EXAMPLES = [
5773
6540
  { id: "haiku", label: "Haiku", spec: HAIKU_SPEC },
5774
6541
  { id: "pings", label: "Pings", spec: PINGS_SPEC },
5775
6542
  { id: "facets", label: "Facets", spec: FACETS_SPEC },
5776
- { id: "relay-board", label: "Relay Board", spec: RELAY_BOARD_SPEC }
6543
+ { id: "relay-board", label: "Relay Board", spec: RELAY_BOARD_SPEC },
6544
+ { id: "lobby-talk", label: "Lobby Talk", spec: LOBBY_TALK_SPEC }
5777
6545
  ];
5778
6546
  var EXAMPLE_SPECS = SCHEMA_EXAMPLES.map((e) => e.spec);
5779
6547
  var EXAMPLE_BY_ID = Object.fromEntries(
@@ -5792,6 +6560,7 @@ export {
5792
6560
  HAIKU_SPEC,
5793
6561
  LANTERNS_SPEC,
5794
6562
  LIMITS,
6563
+ LOBBY_TALK_SPEC,
5795
6564
  MATRIX_RAIN_SPEC,
5796
6565
  NOSTALGHIA_CANDLE_SPEC,
5797
6566
  ORRERY_SPEC,
@@ -5804,6 +6573,7 @@ export {
5804
6573
  SCHEMA_EXAMPLES,
5805
6574
  SCHEMA_VERSION,
5806
6575
  SNOWFALL_SPEC,
6576
+ WARP_MAX_SCALE,
5807
6577
  WARP_NEAR,
5808
6578
  WARP_TUNNEL_SPEC,
5809
6579
  adviseSequence,
@@ -5812,8 +6582,10 @@ export {
5812
6582
  applyDeltasToSpec,
5813
6583
  assertValidSequence,
5814
6584
  assertValidSpec,
6585
+ bedRenderSeed,
5815
6586
  breakTextBlock,
5816
6587
  buildEntities,
6588
+ canMorph,
5817
6589
  cohesionOf,
5818
6590
  compileSaver,
5819
6591
  compileSequence,
@@ -5832,8 +6604,12 @@ export {
5832
6604
  linkPairs,
5833
6605
  luminanceGrid,
5834
6606
  manifestFor,
6607
+ morphChainRoot,
6608
+ morphNothingMorphable,
5835
6609
  motionStats,
6610
+ normalizeSeed,
5836
6611
  perceiveScene,
6612
+ perceiveSequenceFrame,
5837
6613
  positionAt,
5838
6614
  renderBrailleMap,
5839
6615
  renderDensityMap,
@@ -5843,13 +6619,19 @@ export {
5843
6619
  rotationAt,
5844
6620
  seamCauseOf,
5845
6621
  seamsWorthWarning,
6622
+ segmentRenderSeed,
5846
6623
  segmentStart,
6624
+ sequenceSteerablePaths,
5847
6625
  sizeAt,
5848
6626
  spriteIndexAt,
5849
6627
  spriteVariants,
5850
6628
  steerablePaths,
5851
6629
  structuralSignature,
6630
+ textBlockAnchorOffset,
6631
+ textMetricsClassFor,
5852
6632
  textSprites,
6633
+ textStringsDiffer,
6634
+ textStringsOf,
5853
6635
  textWidthEm,
5854
6636
  validateSequence,
5855
6637
  validateSpec