@idle-screens/schema 3.6.1 → 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,
@@ -149,6 +150,58 @@ function lerpSpec(from, to, k) {
149
150
  };
150
151
  return walk(from, to);
151
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
+ }
152
205
  function steerablePaths(spec) {
153
206
  if (!spec || typeof spec !== "object") return [];
154
207
  const SKIP = /* @__PURE__ */ new Set(["kind", "type", "key", "schemaVersion", "id", "label", "seed", "units", "motionIntensity", "mode", "curve", "layer"]);
@@ -175,6 +228,12 @@ function steerablePaths(spec) {
175
228
  walk(spec, "", "");
176
229
  return out;
177
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
+ }
178
237
  function easeSmooth(k) {
179
238
  const c = Math.max(0, Math.min(1, k));
180
239
  return c * c * (3 - 2 * c);
@@ -208,7 +267,7 @@ function structuralSignature(spec) {
208
267
  l.sprite.kind === "rect" ? [s.width, s.aspect] : l.sprite.kind === "bar" ? [s.length, s.thickness] : void 0,
209
268
  Array.isArray(s.colors) ? s.colors.length : void 0,
210
269
  s.colorWeights,
211
- 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
212
271
  ];
213
272
  })
214
273
  ]);
@@ -251,8 +310,11 @@ var KNOWN_BAR = /* @__PURE__ */ new Set(["kind", "values", "length", "thickness"
251
310
  var KNOWN_POLYGON = /* @__PURE__ */ new Set(["kind", "radius", "color", "sides", "points", "soft", "colors", "colorWeights"]);
252
311
  var KNOWN_STROKE = /* @__PURE__ */ new Set(["kind", "length", "points", "color", "width", "curve", "taper", "orient", "colors", "colorWeights"]);
253
312
  var KNOWN_EMOJI = /* @__PURE__ */ new Set(["kind", "glyphs", "cycle"]);
254
- var KNOWN_TEXT = /* @__PURE__ */ new Set(["kind", "strings", "color", "font", "align", "baseline", "maxWidth", "cycle"]);
255
- 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;
256
318
  var KNOWN_REVEAL = /* @__PURE__ */ new Set(["progress", "mode", "speed", "caret", "fade"]);
257
319
  var KNOWN_REVEAL_CARET = /* @__PURE__ */ new Set(["blink", "color"]);
258
320
  var KNOWN_DRIFT = /* @__PURE__ */ new Set(["type", "speed", "angle", "bidirectional", "bob", "ease"]);
@@ -674,6 +736,7 @@ function validateSprite(sprite, path, err, warn) {
674
736
  if (sprite.maxWidth !== void 0 && (!isNum(sprite.maxWidth) || sprite.maxWidth <= 0)) {
675
737
  err(`${path}.maxWidth`, "must be a positive number");
676
738
  }
739
+ validateTextRole(sprite, path, err);
677
740
  validateCycle(sprite, path, err);
678
741
  } else if (sprite.kind === "circle") {
679
742
  knownSet = KNOWN_CIRCLE;
@@ -760,6 +823,20 @@ function validateSprite(sprite, path, err, warn) {
760
823
  err(`${path}.align`, "must be 'left' | 'center' | 'right'");
761
824
  }
762
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);
763
840
  if (sprite.reveal !== void 0) {
764
841
  const rv = sprite.reveal;
765
842
  if (typeof rv !== "object" || rv === null || Array.isArray(rv)) {
@@ -826,6 +903,11 @@ function validatePalette(sprite, path, err) {
826
903
  }
827
904
  }
828
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
+ }
829
911
  function validateCycle(sprite, path, err) {
830
912
  if (sprite.cycle === void 0) return;
831
913
  if (!isObj(sprite.cycle)) return err(`${path}.cycle`, "must be an object");
@@ -998,6 +1080,7 @@ function validateSequence(seq) {
998
1080
  }
999
1081
  }
1000
1082
  }
1083
+ if (isObj(seq) && isObj(seq.bed)) normalizeColors(seq.bed);
1001
1084
  const errors = [];
1002
1085
  const warnings = [];
1003
1086
  const err = (path, message) => void errors.push({ path, message });
@@ -1008,6 +1091,7 @@ function validateSequence(seq) {
1008
1091
  if (!isStr(seq.label) || seq.label.trim() === "") err("label", "must be a non-empty string");
1009
1092
  if (seq.seed !== void 0 && !isNum(seq.seed)) err("seed", "must be a number");
1010
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'");
1011
1095
  if (!Array.isArray(seq.segments) || seq.segments.length === 0) {
1012
1096
  err("segments", "must be a non-empty array");
1013
1097
  return { valid: false, errors, warnings };
@@ -1050,12 +1134,19 @@ function validateSequence(seq) {
1050
1134
  if (s.transition !== void 0) {
1051
1135
  if (!isObj(s.transition)) {
1052
1136
  err(`${p}.transition`, "must be an object");
1053
- } else if (s.transition.type === "morph") {
1137
+ } else if (s.transition.type === "morph" || s.transition.type === "fade") {
1054
1138
  if (!isNum(s.transition.dur) || s.transition.dur < LIMITS.minTransitionDur || s.transition.dur > LIMITS.maxTransitionDur) {
1055
1139
  err(`${p}.transition.dur`, `must be a number between ${LIMITS.minTransitionDur} and ${LIMITS.maxTransitionDur}`);
1056
1140
  }
1057
1141
  } else if (s.transition.type !== "cut") {
1058
- 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
+ }
1059
1150
  }
1060
1151
  }
1061
1152
  if (isObj(s.scene)) {
@@ -1071,6 +1162,32 @@ function validateSequence(seq) {
1071
1162
  if (seq.loop === true && hasDurationless) {
1072
1163
  err("loop", "loop: true requires all segments to have a duration");
1073
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
+ }
1074
1191
  if (errors.length === 0 && Array.isArray(seq.segments)) {
1075
1192
  for (let i = 0; i < seq.segments.length; i++) {
1076
1193
  const s = seq.segments[i];
@@ -1085,6 +1202,12 @@ function validateSequence(seq) {
1085
1202
  code: "morph-structural-mismatch",
1086
1203
  message: `segments ${i}\u2192${i + 1} differ structurally: morph will fall back to cut`
1087
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
+ });
1088
1211
  }
1089
1212
  }
1090
1213
  }
@@ -1777,18 +1900,25 @@ function linkPairs(positions, k, maxDist, doWrap, w, h) {
1777
1900
  }
1778
1901
  var CHAR_NARROW = new Set(`iIljtf1!|.,;:'"()[]{}`);
1779
1902
  var CHAR_WIDE = new Set("mwMWGOQD@%");
1780
- 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;
1781
1910
  if (ch === " " || ch === " ") return 0.3;
1782
1911
  if (CHAR_NARROW.has(ch)) return 0.35;
1783
1912
  if (CHAR_WIDE.has(ch)) return 0.72;
1784
1913
  return 0.55;
1785
1914
  }
1786
- function textWidthEm(str) {
1915
+ function textWidthEm(str, metrics = "proportional") {
1916
+ if (metrics === "mono") return graphemeClusters(str).length * MONO_ADVANCE_EM;
1787
1917
  let w = 0;
1788
- 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);
1789
1919
  return w;
1790
1920
  }
1791
- function breakTextBlock(text, maxWidthEm) {
1921
+ function breakTextBlock(text, maxWidthEm, metrics = "proportional") {
1792
1922
  const paragraphs = text.split("\n");
1793
1923
  const lines = [];
1794
1924
  for (const para of paragraphs) {
@@ -1802,11 +1932,11 @@ function breakTextBlock(text, maxWidthEm) {
1802
1932
  continue;
1803
1933
  }
1804
1934
  let lineText = words[0];
1805
- let lineW = textWidthEm(lineText);
1806
- const spaceW = charWidthEm(" ");
1935
+ let lineW = textWidthEm(lineText, metrics);
1936
+ const spaceW = charWidthEm(" ", metrics);
1807
1937
  for (let i = 1; i < words.length; i++) {
1808
1938
  const word = words[i];
1809
- const wordW = textWidthEm(word);
1939
+ const wordW = textWidthEm(word, metrics);
1810
1940
  if (lineW + spaceW + wordW <= maxWidthEm) {
1811
1941
  lineText += " " + word;
1812
1942
  lineW += spaceW + wordW;
@@ -1820,6 +1950,13 @@ function breakTextBlock(text, maxWidthEm) {
1820
1950
  }
1821
1951
  return lines;
1822
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
+ }
1823
1960
  function isClusterExtend(cp) {
1824
1961
  return cp >= 768 && cp <= 879 || // combining diacritics
1825
1962
  cp >= 6832 && cp <= 6911 || cp >= 8400 && cp <= 8447 || cp >= 65024 && cp <= 65039 || // variation selectors
@@ -1967,9 +2104,45 @@ function resolveSegment(seq, T, opts = {}) {
1967
2104
  const lastStart = totalTimed - (segments[last].duration ?? 0);
1968
2105
  return { index: last, localT: t0 - lastStart, startT: lastStart };
1969
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
+ }
1970
2134
 
1971
2135
  // src/compile.ts
1972
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
+ }
1973
2146
  function hexToRgba(hex, alpha) {
1974
2147
  const h = hex.length === 4 ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}` : hex;
1975
2148
  const n = parseInt(h.slice(1), 16);
@@ -2007,11 +2180,12 @@ function manifestFor(spec) {
2007
2180
  };
2008
2181
  }
2009
2182
  var SpecInstance = class {
2010
- constructor(spec, ctx) {
2183
+ constructor(spec, ctx, opts = {}) {
2011
2184
  this.spec = spec;
2012
2185
  this.effSpec = spec;
2013
2186
  this.saverCtx = ctx;
2014
- this.seed = (spec.seed ?? ctx.seed) >>> 0 || 1;
2187
+ this.transparent = opts.transparent === true;
2188
+ this.seed = normalizeSeed(spec.seed ?? ctx.seed);
2015
2189
  let canvas;
2016
2190
  if (ctx.surface) {
2017
2191
  canvas = ctx.surface;
@@ -2025,7 +2199,7 @@ var SpecInstance = class {
2025
2199
  this.ownsCanvas = true;
2026
2200
  }
2027
2201
  this.canvas = canvas;
2028
- const c2d = canvas.getContext("2d", { alpha: false });
2202
+ const c2d = canvas.getContext("2d", { alpha: this.transparent });
2029
2203
  if (!c2d) throw new Error("schema saver: no 2d context");
2030
2204
  this.ctx = c2d;
2031
2205
  this.w = ctx.width;
@@ -2033,12 +2207,16 @@ var SpecInstance = class {
2033
2207
  this.sizeCanvas();
2034
2208
  this.rebuild();
2035
2209
  this.paused = ctx.reducedMotion;
2036
- if (this.paused) this.renderFrame(0, this.seed);
2037
- else this.start();
2210
+ if (this.paused) {
2211
+ if (!opts.skipInitialPaint) this.renderFrame(0, this.seed);
2212
+ } else {
2213
+ this.start();
2214
+ }
2038
2215
  }
2039
2216
  spec;
2040
2217
  canvas;
2041
2218
  ownsCanvas;
2219
+ transparent;
2042
2220
  ctx;
2043
2221
  saverCtx;
2044
2222
  seed;
@@ -2058,6 +2236,8 @@ var SpecInstance = class {
2058
2236
  /** Active glide between two resolved specs (live setParam/applyTrack). */
2059
2237
  transition = null;
2060
2238
  lastStructural = "";
2239
+ /** See `LayerPaintOverride`; null (the default, and every plain mount) draws every layer once. */
2240
+ paintOverrides = null;
2061
2241
  /** Viewport factor for absolute px sizes — 1 for `units: 'px'` specs. */
2062
2242
  fontScale() {
2063
2243
  if (this.effSpec.units === "px") return 1;
@@ -2194,14 +2374,14 @@ var SpecInstance = class {
2194
2374
  ctx.fill();
2195
2375
  }
2196
2376
  }
2197
- drawEntity(built, e, t, lifeA, parentE) {
2377
+ drawEntity(built, e, t, lifeA, parentE, paint) {
2198
2378
  const { ctx } = this;
2199
2379
  const p = this.entityPos(e, t, parentE);
2200
- const sprite = built.layer.sprite;
2380
+ const sprite = paint?.sprite ?? built.layer.sprite;
2201
2381
  const sz = sizeAt(e, t);
2202
2382
  const rot = rotationAt(e, t);
2203
2383
  const unitScale = this.effSpec.units === "px" ? 1 : Math.min(this.w, this.h);
2204
- ctx.globalAlpha = alphaAt(e, t) * lifeA;
2384
+ ctx.globalAlpha = alphaAt(e, t) * lifeA * (paint?.alpha ?? 1);
2205
2385
  if (sprite.kind === "ring") {
2206
2386
  const r = sz / 2;
2207
2387
  const resolvedColor = sprite.colors?.[e.colorIndex] ?? sprite.color;
@@ -2353,7 +2533,7 @@ var SpecInstance = class {
2353
2533
  const lh = (sprite.lineHeight ?? 1.4) * fsPx;
2354
2534
  const maxWPx = sprite.maxWidth * unitScale2;
2355
2535
  const maxWEm = maxWPx / fsPx;
2356
- const lines = breakTextBlock(sprite.text, maxWEm);
2536
+ const lines = breakTextBlock(sprite.text, maxWEm, textMetricsClassFor(sprite.font));
2357
2537
  const align = sprite.align ?? "left";
2358
2538
  const reveal = sprite.reveal;
2359
2539
  const rs = reveal ? revealState(lines, reveal, t) : null;
@@ -2361,7 +2541,13 @@ var SpecInstance = class {
2361
2541
  ctx.save();
2362
2542
  ctx.translate(p.x, p.y);
2363
2543
  if (rot) ctx.rotate(rot);
2364
- 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`;
2365
2551
  ctx.fillStyle = sprite.color ?? "#e6e8ef";
2366
2552
  ctx.textBaseline = "top";
2367
2553
  ctx.textAlign = align;
@@ -2475,6 +2661,33 @@ var SpecInstance = class {
2475
2661
  this.renderFrame(this.lastT, this.seed);
2476
2662
  }
2477
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
+ }
2478
2691
  drawLinks(built, t, lifeA, parentE) {
2479
2692
  const { links } = built.layer;
2480
2693
  if (!links) return;
@@ -2520,19 +2733,28 @@ var SpecInstance = class {
2520
2733
  * showing through — the ghosting smear. */
2521
2734
  paintFrame(t, bgAlpha) {
2522
2735
  const { ctx } = this;
2523
- ctx.globalAlpha = bgAlpha;
2524
- ctx.globalCompositeOperation = "source-over";
2525
- this.drawBackground(t);
2736
+ if (!this.transparent) {
2737
+ ctx.globalAlpha = bgAlpha;
2738
+ ctx.globalCompositeOperation = "source-over";
2739
+ this.drawBackground(t);
2740
+ }
2526
2741
  ctx.globalAlpha = 1;
2527
- for (const built of this.layers) {
2742
+ for (let li = 0; li < this.layers.length; li++) {
2743
+ const built = this.layers[li];
2528
2744
  const lifeA = lifeAlphaAt(built.layer.life, t);
2529
2745
  if (lifeA <= 0) continue;
2530
2746
  const parentE = this.parentEntityFor(built);
2531
2747
  ctx.globalCompositeOperation = built.layer.blend ?? "source-over";
2532
2748
  this.drawLinks(built, t, lifeA, parentE);
2749
+ const override = this.paintOverrides?.get(li) ?? null;
2533
2750
  for (const e of built.entities) {
2534
2751
  this.drawTrail(built, e, t, lifeA, parentE);
2535
- 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 });
2536
2758
  }
2537
2759
  }
2538
2760
  ctx.globalAlpha = 1;
@@ -2549,7 +2771,7 @@ var SpecInstance = class {
2549
2771
  */
2550
2772
  renderFrame(t, _seed) {
2551
2773
  this.stepTransition(t);
2552
- const g = this.effSpec.ghosting ?? 0;
2774
+ const g = this.transparent ? 0 : this.effSpec.ghosting ?? 0;
2553
2775
  if (g > 0) {
2554
2776
  const dt = 1e3 / 60;
2555
2777
  const contiguous = this.lastRenderT !== Number.NEGATIVE_INFINITY && t > this.lastRenderT && t - this.lastRenderT <= 250;
@@ -2585,8 +2807,8 @@ var SpecInstance = class {
2585
2807
  }
2586
2808
  /**
2587
2809
  * Replace the rendered spec without triggering a transition glide.
2588
- * Checks structural signature — use `hotSwapPaint` when the caller
2589
- * 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.
2590
2812
  */
2591
2813
  hotSwapSpec(spec) {
2592
2814
  this.effSpec = spec;
@@ -2603,6 +2825,15 @@ var SpecInstance = class {
2603
2825
  b.layer = this.effSpec.layers[i] ?? b.layer;
2604
2826
  });
2605
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
+ }
2606
2837
  dispose() {
2607
2838
  this.stop();
2608
2839
  if (this.ownsCanvas && typeof HTMLCanvasElement !== "undefined" && this.canvas instanceof HTMLCanvasElement) this.canvas.remove();
@@ -2616,6 +2847,7 @@ function compileSaver(spec) {
2616
2847
  spec: valid
2617
2848
  };
2618
2849
  }
2850
+ var BED_PREFIX = "bed.";
2619
2851
  var SequenceInstance = class {
2620
2852
  seq;
2621
2853
  childCtx;
@@ -2646,8 +2878,55 @@ var SequenceInstance = class {
2646
2878
  */
2647
2879
  clockOffset = 0;
2648
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;
2649
2921
  constructor(seq, ctx) {
2650
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
+ }
2651
2930
  let surface = ctx.surface ?? null;
2652
2931
  let canvas = null;
2653
2932
  if (!surface && typeof document !== "undefined") {
@@ -2659,15 +2938,32 @@ var SequenceInstance = class {
2659
2938
  }
2660
2939
  this.canvas = canvas;
2661
2940
  this.seed = (seq.seed ?? ctx.seed ?? 0) >>> 0 || 1;
2662
- this.childCtx = { ...ctx, surface, reducedMotion: true };
2941
+ this.childCtx = { ...plainCtx, surface, reducedMotion: true };
2663
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
+ }
2664
2949
  this.paused = ctx.reducedMotion;
2665
- if (this.paused) this.renderFrame(0, this.seed);
2950
+ if (this.paused) this.renderFrame(this.baseT, this.seed);
2666
2951
  else this.start();
2667
2952
  }
2668
2953
  childSeed(index, fallback) {
2669
2954
  return this.seq.segments[index].scene.seed ?? this.seq.seed ?? fallback;
2670
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
+ }
2671
2967
  childScene(index) {
2672
2968
  const seg = this.seq.segments[index];
2673
2969
  if (seg.scene.seed != null) return seg.scene;
@@ -2676,29 +2972,132 @@ var SequenceInstance = class {
2676
2972
  }
2677
2973
  /** Whether the boundary from `from` to `from+1` should morph. */
2678
2974
  canMorph(from) {
2679
- const seg = this.seq.segments[from];
2680
- if (!seg || seg.transition?.type !== "morph") return false;
2681
- const next = this.seq.segments[from + 1];
2682
- if (!next) return false;
2683
- return structuralSignature(seg.scene) === structuralSignature(next.scene);
2975
+ return canMorph(this.seq, from);
2684
2976
  }
2685
2977
  /** Walk back through consecutive morph boundaries to find the chain origin. */
2686
2978
  morphChainRoot(index) {
2687
- let i = index;
2688
- while (i > 0 && this.canMorph(i - 1)) i--;
2689
- return i;
2979
+ return morphChainRoot(this.seq, index);
2690
2980
  }
2691
2981
  morphDur(from) {
2692
2982
  const tr = this.seq.segments[from]?.transition;
2693
2983
  return tr?.type === "morph" ? tr.dur : 0;
2694
2984
  }
2695
- 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) {
2696
3093
  if (index < 0 || index >= this.seq.segments.length) index = 0;
2697
3094
  let child = this.children[index];
2698
3095
  if (!child) {
2699
- child = new SpecInstance(this.childScene(index), this.childCtx);
3096
+ child = new SpecInstance(rootScene ?? this.childScene(index), this.childCtx, { transparent: this.bed !== null, skipInitialPaint });
2700
3097
  this.children[index] = child;
2701
3098
  child.setPaused(true);
3099
+ if (rootScene) child.hotSwapSpec(this.childScene(index));
3100
+ if (this.retainedDeltas.size > 0) child.applyDeltasNow(this.retainedDeltas.values());
2702
3101
  }
2703
3102
  return child;
2704
3103
  }
@@ -2731,8 +3130,15 @@ var SequenceInstance = class {
2731
3130
  this.renderedT = T;
2732
3131
  const resolved = resolveSegment(this.seq, T + this.clockOffset, { releasedBelow: this.releasedBelow });
2733
3132
  const { index, localT } = resolved;
3133
+ this.bed?.renderFrame(T, this.seq.bed?.seed ?? this.seq.seed ?? seed);
2734
3134
  const prevIdx = index > 0 ? index - 1 : -1;
2735
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;
2736
3142
  if (morphActive) {
2737
3143
  const chainRoot = this.morphChainRoot(index);
2738
3144
  this.morphFromIndex = prevIdx;
@@ -2740,12 +3146,14 @@ var SequenceInstance = class {
2740
3146
  for (let i = 0; i < this.children.length; i++) {
2741
3147
  if (i !== chainRoot) this.releaseChild(i);
2742
3148
  }
2743
- const child = this.ensureChild(chainRoot);
3149
+ const child = this.ensureChild(chainRoot, void 0, this.morphTextCrossfades(prevIdx));
2744
3150
  const dur = this.morphDur(prevIdx);
2745
3151
  const k = easeSmooth(localT / dur);
2746
- const specA = this.childScene(prevIdx);
2747
- const specB = this.childScene(index);
2748
- 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);
2749
3157
  child.renderFrame(localT, this.childSeed(chainRoot, seed));
2750
3158
  } else {
2751
3159
  if (this.morphFromIndex >= 0) {
@@ -2763,17 +3171,25 @@ var SequenceInstance = class {
2763
3171
  const chainRoot = this.morphChainRoot(index);
2764
3172
  const useMorphSeed = chainRoot < index;
2765
3173
  const effSeed = useMorphSeed ? this.childSeed(chainRoot, seed) : this.childSeed(index, seed);
2766
- let child;
2767
- if (useMorphSeed && !this.children[index]) {
2768
- const rootScene = this.childScene(chainRoot);
2769
- child = new SpecInstance(rootScene, this.childCtx);
2770
- this.children[index] = child;
2771
- child.setPaused(true);
2772
- child.hotSwapSpec(this.childScene(index));
2773
- } else {
2774
- child = this.ensureChild(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;
2775
3186
  }
3187
+ const child = useMorphSeed ? this.ensureChild(index, this.childScene(chainRoot)) : this.ensureChild(index);
2776
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
+ }
2777
3193
  }
2778
3194
  }
2779
3195
  setPaused(paused) {
@@ -2783,6 +3199,9 @@ var SequenceInstance = class {
2783
3199
  for (const child of this.children) {
2784
3200
  child?.setPaused(true);
2785
3201
  }
3202
+ this.fading?.child.setPaused(true);
3203
+ this.fadingIn?.child.setPaused(true);
3204
+ this.bed?.setPaused(true);
2786
3205
  }
2787
3206
  resize(width, height, dpr) {
2788
3207
  this.childCtx.width = width;
@@ -2791,35 +3210,58 @@ var SequenceInstance = class {
2791
3210
  for (const child of this.children) {
2792
3211
  child?.resize(width, height, dpr);
2793
3212
  }
3213
+ this.fading?.child.resize(width, height, dpr);
3214
+ this.fadingIn?.child.resize(width, height, dpr);
3215
+ this.bed?.resize(width, height, dpr);
2794
3216
  }
2795
3217
  applyTrack(track) {
2796
- 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
+ }
2797
3226
  const segDelta = deltas.find((d) => d.path === "sequence.segment");
3227
+ let switchedSegment = false;
2798
3228
  if (segDelta !== void 0 && typeof segDelta.value === "number") {
2799
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;
2800
3231
  this.clockOffset = segmentStart(this.seq, idx) - this.renderedT;
2801
3232
  this.releasedBelow = idx;
2802
3233
  this.renderFrame(this.renderedT, this.seed);
3234
+ switchedSegment = true;
2803
3235
  }
2804
3236
  const childDeltas = deltas.filter((d) => d.path !== "sequence.segment");
2805
- if (childDeltas.length > 0 && this.activeIndex >= 0) {
2806
- const child = this.children[this.activeIndex];
2807
- 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 });
2808
3246
  }
2809
3247
  }
2810
3248
  dispose() {
2811
3249
  this.stop();
3250
+ this.releaseFading();
2812
3251
  for (let i = 0; i < this.children.length; i++) {
2813
3252
  this.releaseChild(i);
2814
3253
  }
3254
+ this.bed?.dispose();
2815
3255
  if (this.canvas) this.canvas.remove();
2816
3256
  }
2817
3257
  };
2818
3258
  function sequenceManifest(seq) {
2819
- const maxTotal = seq.segments.reduce((max, s) => {
3259
+ const maxSegment = seq.segments.reduce((max, s) => {
2820
3260
  const t = s.scene.layers.reduce((n, l) => n + l.count, 0);
2821
3261
  return Math.max(max, t);
2822
3262
  }, 0);
3263
+ const bedTotal = seq.bed ? seq.bed.layers.reduce((n, l) => n + l.count, 0) : 0;
3264
+ const maxTotal = bedTotal + maxSegment;
2823
3265
  const costTier = maxTotal < 30 ? "idle" : maxTotal < 150 ? "low" : maxTotal < 400 ? "medium" : "high";
2824
3266
  return {
2825
3267
  id: seq.id,
@@ -2903,6 +3345,58 @@ function colourSeparation(layer, sprite, bg) {
2903
3345
  const db = sprite.rgb.b - bg.rgb.b;
2904
3346
  return Math.sqrt((dr * dr + dg * dg + db * db) / 3);
2905
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
+ }
2906
3400
 
2907
3401
  // src/cohesion.ts
2908
3402
  var MERGE_FLOOR = 0.35;
@@ -3114,6 +3608,8 @@ function seamsWorthWarning(layer, c, entityCount) {
3114
3608
  // src/advise.ts
3115
3609
  var LOW_CONTRAST_FLOOR = 0.05;
3116
3610
  var SPARSE_DECLARED_MAX_COVERAGE = 0.02;
3611
+ var READ_LEGIBILITY_FLOOR = 4.5;
3612
+ var READ_SAFE_AREA = 0.05;
3117
3613
  function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
3118
3614
  const warnings = [];
3119
3615
  const w = viewport.width;
@@ -3218,8 +3714,8 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
3218
3714
  const fsPx = layer.sprite.fontSize * scale;
3219
3715
  const lh = (layer.sprite.lineHeight ?? 1.4) * fsPx;
3220
3716
  const maxWPx = layer.sprite.maxWidth * scale;
3221
- const lines = breakTextBlock(layer.sprite.text, maxWPx / fsPx);
3222
- 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);
3223
3719
  } else {
3224
3720
  pixArea = e.size * e.size;
3225
3721
  }
@@ -3339,9 +3835,16 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
3339
3835
  for (const e of allEntities[li]) {
3340
3836
  const p = positionAt(e, 0, w, h);
3341
3837
  const box = s.kind === "textBlock" ? textBlockBoxAt(s, p, w, h) : textBoxAt(s, e, p, spec, w, h);
3342
- 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 });
3343
3840
  }
3344
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
+ });
3345
3848
  const tolX = w * 0.01;
3346
3849
  const tolY = h * 0.01;
3347
3850
  const offFlagged = /* @__PURE__ */ new Set();
@@ -3380,12 +3883,109 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
3380
3883
  warnings.push({
3381
3884
  path: `layers[${first.li}]`,
3382
3885
  code: "text-overlap",
3383
- 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 })]
3384
3947
  });
3385
3948
  }
3386
3949
  }
3387
3950
  return warnings;
3388
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
+ }
3389
3989
  function textBoxAt(s, e, p, spec, w, h) {
3390
3990
  const m = s.font ? /(\d{1,5}(?:\.\d{1,4})?|\.\d{1,4})px/.exec(s.font) : null;
3391
3991
  const fontScale = spec.units === "px" ? 1 : Math.min(w, h) / (spec.referenceViewport ?? LIMITS.referenceViewport);
@@ -3397,28 +3997,39 @@ function textBoxAt(s, e, p, spec, w, h) {
3397
3997
  const baseline = s.baseline ?? "middle";
3398
3998
  const x0 = align === "left" ? p.x : align === "right" ? p.x - fw : p.x - fw / 2;
3399
3999
  const y0 = baseline === "top" ? p.y : baseline === "bottom" ? p.y - fh : p.y - fh / 2;
3400
- return { x0, y0, x1: x0 + fw, y1: y0 + fh };
4000
+ return { x0, y0, x1: x0 + fw, y1: y0 + fh, fs: fh };
3401
4001
  }
3402
4002
  function textBlockBoxAt(s, p, w, h) {
3403
4003
  const unit = Math.min(w, h);
3404
4004
  const fsPx = s.fontSize * unit;
3405
4005
  const lh = (s.lineHeight ?? 1.4) * fsPx;
3406
4006
  const maxWPx = s.maxWidth * unit;
3407
- const lines = breakTextBlock(s.text, maxWPx / fsPx);
4007
+ const lines = breakTextBlock(s.text, maxWPx / fsPx, textMetricsClassFor(s.font));
3408
4008
  const maxLineW = lines.reduce((mx, l) => Math.max(mx, l.widthEm), 0) * fsPx;
3409
4009
  const totalH = lines.length * lh;
3410
4010
  const align = s.align ?? "left";
3411
4011
  const x0 = align === "center" ? p.x + (maxWPx - maxLineW) / 2 : align === "right" ? p.x + maxWPx - maxLineW : p.x;
3412
- 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 };
3413
4014
  }
3414
4015
  function adviseSequence(seq, viewport = { width: 1920, height: 1080 }) {
3415
4016
  const warnings = [];
3416
4017
  for (let i = 0; i < seq.segments.length; i++) {
3417
4018
  const segWarnings = adviseSpec(seq.segments[i].scene, viewport);
3418
4019
  for (const w of segWarnings) {
3419
- 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 });
3420
4021
  }
3421
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
+ }
3422
4033
  for (let i = 0; i < seq.segments.length - 1; i++) {
3423
4034
  const lumaA = backgroundLuma(seq.segments[i].scene);
3424
4035
  const lumaB = backgroundLuma(seq.segments[i + 1].scene);
@@ -3440,6 +4051,12 @@ function adviseSequence(seq, viewport = { width: 1920, height: 1080 }) {
3440
4051
  code: "morph-structural-mismatch",
3441
4052
  message: `segments ${i}\u2192${i + 1} differ structurally: morph will fall back to cut`
3442
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
+ });
3443
4060
  }
3444
4061
  }
3445
4062
  }
@@ -3592,14 +4209,15 @@ function textBlockBox(s, _e, p, w, h) {
3592
4209
  const lh = (s.lineHeight ?? 1.4) * fsPx;
3593
4210
  const maxWPx = s.maxWidth * unitScale;
3594
4211
  const maxWEm = maxWPx / fsPx;
3595
- const lines = breakTextBlock(s.text, maxWEm);
4212
+ const lines = breakTextBlock(s.text, maxWEm, textMetricsClassFor(s.font));
3596
4213
  const totalH = lines.length * lh;
3597
4214
  const maxLineW = lines.reduce((m, l) => Math.max(m, l.widthEm), 0) * fsPx;
3598
4215
  const align = s.align ?? "left";
3599
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);
3600
4218
  return {
3601
- cx,
3602
- cy: p.y + totalH / 2,
4219
+ cx: cx + dx,
4220
+ cy: p.y + totalH / 2 + dy,
3603
4221
  halfX: maxLineW / 2,
3604
4222
  halfY: totalH / 2
3605
4223
  };
@@ -3608,7 +4226,7 @@ function textBlockRevealFraction(s, w, h, t) {
3608
4226
  if (!s.reveal) return 1;
3609
4227
  const unitScale = Math.min(w, h);
3610
4228
  const maxWEm = s.maxWidth * unitScale / (s.fontSize * unitScale);
3611
- const lines = breakTextBlock(s.text, maxWEm);
4229
+ const lines = breakTextBlock(s.text, maxWEm, textMetricsClassFor(s.font));
3612
4230
  const rs = revealState(lines, s.reveal, t);
3613
4231
  if (rs.glyphAlphas) {
3614
4232
  let sum = 0;
@@ -3802,7 +4420,7 @@ function luminanceGrid(spec, opts = {}) {
3802
4420
  const c1 = Math.min(cols - 1, Math.floor((centerX + reachX) / cellW));
3803
4421
  const r0 = Math.max(0, Math.floor((centerY - reachY) / cellH));
3804
4422
  const r1 = Math.min(rows - 1, Math.floor((centerY + reachY) / cellH));
3805
- 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);
3806
4424
  for (let r = r0; r <= r1; r++) {
3807
4425
  for (let c = c0; c <= c1; c++) {
3808
4426
  const dx = (c + 0.5) * cellW - centerX;
@@ -3871,6 +4489,9 @@ function luminanceGrid(spec, opts = {}) {
3871
4489
  }
3872
4490
  }
3873
4491
  splatPass(t, 1);
4492
+ return finishGrid(cols, rows, cells, bgRow);
4493
+ }
4494
+ function finishGrid(cols, rows, cells, bgRow) {
3874
4495
  let sum = 0;
3875
4496
  let covered = 0;
3876
4497
  let devSum = 0;
@@ -3980,6 +4601,21 @@ function textSprites(spec, opts = {}) {
3980
4601
  return out;
3981
4602
  }
3982
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 = {}) {
3983
4619
  const scene = buildScene(spec, opts);
3984
4620
  const t = opts.t ?? 5e3;
3985
4621
  const { w, h, scale } = scene;
@@ -4031,7 +4667,7 @@ function dominanceRanking(spec, opts = {}) {
4031
4667
  entArea = box.halfX * 2 * box.halfY * 2 * 0.55;
4032
4668
  } else if (s.kind === "textBlock") {
4033
4669
  const box = textBlockBox(s, e, { x: 0, y: 0 }, w, h);
4034
- 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);
4035
4671
  } else entArea = sz * sz * 0.55;
4036
4672
  area += entArea * a;
4037
4673
  if (layer.trail) {
@@ -4060,16 +4696,7 @@ function dominanceRanking(spec, opts = {}) {
4060
4696
  const weight = area / (w * h) * contrast * blendBoost * motionBoost;
4061
4697
  return { layerIndex, key: layer.key, weight, entityCount: entities.length, meanLuma, factors: { area: area / (w * h), contrast, blendBoost, motionBoost } };
4062
4698
  });
4063
- const total = raw.reduce((s, r) => s + r.weight, 0) || 1;
4064
- return raw.slice().sort((a, b) => b.weight - a.weight).map((r, i) => ({
4065
- rank: i + 1,
4066
- layerIndex: r.layerIndex,
4067
- key: r.key,
4068
- share: r.weight / total,
4069
- entityCount: r.entityCount,
4070
- meanLuma: r.meanLuma,
4071
- factors: r.factors
4072
- }));
4699
+ return raw;
4073
4700
  }
4074
4701
  function layerCohesion(spec, opts = {}) {
4075
4702
  const scene = buildScene(spec, opts);
@@ -4176,6 +4803,51 @@ function perceiveScene(spec, opts = {}) {
4176
4803
  advisories: adviseSpec(spec, opts.viewport ?? { width: 1920, height: 1080 }, { t: opts.t, seed: opts.seed })
4177
4804
  };
4178
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
+ }
4179
4851
 
4180
4852
  // src/examples/aquarium.ts
4181
4853
  var AQUARIUM_SPEC = {
@@ -5065,6 +5737,85 @@ var LANTERNS_SPEC = {
5065
5737
  ]
5066
5738
  };
5067
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
+
5068
5819
  // src/examples/matrix-rain.ts
5069
5820
  var MATRIX_RAIN_SPEC = {
5070
5821
  schemaVersion: 1,
@@ -5789,7 +6540,8 @@ var SCHEMA_EXAMPLES = [
5789
6540
  { id: "haiku", label: "Haiku", spec: HAIKU_SPEC },
5790
6541
  { id: "pings", label: "Pings", spec: PINGS_SPEC },
5791
6542
  { id: "facets", label: "Facets", spec: FACETS_SPEC },
5792
- { 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 }
5793
6545
  ];
5794
6546
  var EXAMPLE_SPECS = SCHEMA_EXAMPLES.map((e) => e.spec);
5795
6547
  var EXAMPLE_BY_ID = Object.fromEntries(
@@ -5808,6 +6560,7 @@ export {
5808
6560
  HAIKU_SPEC,
5809
6561
  LANTERNS_SPEC,
5810
6562
  LIMITS,
6563
+ LOBBY_TALK_SPEC,
5811
6564
  MATRIX_RAIN_SPEC,
5812
6565
  NOSTALGHIA_CANDLE_SPEC,
5813
6566
  ORRERY_SPEC,
@@ -5820,6 +6573,7 @@ export {
5820
6573
  SCHEMA_EXAMPLES,
5821
6574
  SCHEMA_VERSION,
5822
6575
  SNOWFALL_SPEC,
6576
+ WARP_MAX_SCALE,
5823
6577
  WARP_NEAR,
5824
6578
  WARP_TUNNEL_SPEC,
5825
6579
  adviseSequence,
@@ -5828,8 +6582,10 @@ export {
5828
6582
  applyDeltasToSpec,
5829
6583
  assertValidSequence,
5830
6584
  assertValidSpec,
6585
+ bedRenderSeed,
5831
6586
  breakTextBlock,
5832
6587
  buildEntities,
6588
+ canMorph,
5833
6589
  cohesionOf,
5834
6590
  compileSaver,
5835
6591
  compileSequence,
@@ -5848,8 +6604,12 @@ export {
5848
6604
  linkPairs,
5849
6605
  luminanceGrid,
5850
6606
  manifestFor,
6607
+ morphChainRoot,
6608
+ morphNothingMorphable,
5851
6609
  motionStats,
6610
+ normalizeSeed,
5852
6611
  perceiveScene,
6612
+ perceiveSequenceFrame,
5853
6613
  positionAt,
5854
6614
  renderBrailleMap,
5855
6615
  renderDensityMap,
@@ -5859,13 +6619,19 @@ export {
5859
6619
  rotationAt,
5860
6620
  seamCauseOf,
5861
6621
  seamsWorthWarning,
6622
+ segmentRenderSeed,
5862
6623
  segmentStart,
6624
+ sequenceSteerablePaths,
5863
6625
  sizeAt,
5864
6626
  spriteIndexAt,
5865
6627
  spriteVariants,
5866
6628
  steerablePaths,
5867
6629
  structuralSignature,
6630
+ textBlockAnchorOffset,
6631
+ textMetricsClassFor,
5868
6632
  textSprites,
6633
+ textStringsDiffer,
6634
+ textStringsOf,
5869
6635
  textWidthEm,
5870
6636
  validateSequence,
5871
6637
  validateSpec