@idle-screens/schema 3.6.1 → 3.8.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
@@ -12,6 +12,8 @@ var LIMITS = {
12
12
  // ms — caps pulse at 2 Hz (WCAG flash threshold is 3 Hz)
13
13
  maxSpin: 360,
14
14
  // degrees/sec — one full revolution per second
15
+ maxRotate: 360,
16
+ // degrees — static per-entity rotation, one turn either way
15
17
  maxGrowAmp: 0.8,
16
18
  // size breathing amplitude cap (fraction of base size)
17
19
  maxOrbitSpeed: 180,
@@ -27,9 +29,19 @@ var LIMITS = {
27
29
  maxTrailSamples: 24,
28
30
  // dots per trail
29
31
  minDriftPeriod: 1e4,
30
- // ms — background drift floor (10 s)
32
+ // ms — background drift floor (10 s), gradient and field alike
31
33
  maxDriftAmount: 0.3,
32
34
  // fraction of gradient stop shift
35
+ minFieldScale: 0.5,
36
+ // features per short side — below this the field is one flat blob
37
+ maxFieldScale: 8,
38
+ // above this a 96 px raster cell spans more than one feature
39
+ maxFieldOctaves: 4,
40
+ minFieldBands: 2,
41
+ maxFieldBands: 8,
42
+ maxFieldQuantize: 8,
43
+ maxFieldDriftAmount: 1,
44
+ // feature units of domain travel
33
45
  maxGhosting: 0.95,
34
46
  // frame persistence cap — bounds the seek warm-up replay
35
47
  maxGhostReplayFrames: 120,
@@ -46,7 +58,8 @@ var LIMITS = {
46
58
  maxTextBlockLength: 2e3,
47
59
  minTextBlockFontSize: 0.01,
48
60
  maxTextBlockFontSize: 0.2,
49
- maxTextBlockMaxWidth: 1,
61
+ maxTextBlockMaxWidth: 2,
62
+ // of min(w,h) — wider than the frame is legal; text-off-screen reports the overflow
50
63
  maxRevealSpeed: 120,
51
64
  // graphemes/sec — faster than any readable typing
52
65
  maxCaretBlinkHz: 3,
@@ -83,16 +96,19 @@ var LIMITS = {
83
96
 
84
97
  // src/steer.ts
85
98
  var UNSAFE_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
86
- var STEERABLE_ROOT_KEYS = /* @__PURE__ */ new Set(["ghosting", "referenceViewport"]);
99
+ var STEERABLE_ROOT_KEYS = /* @__PURE__ */ new Set(["ghosting", "referenceViewport", "finish"]);
87
100
  function resolveSpecPath(spec, path) {
88
101
  if (!spec || typeof spec !== "object" || !path) return null;
89
102
  const parts = path.split(".");
90
103
  if (parts.some((p) => UNSAFE_KEYS.has(p))) return null;
91
104
  const s = spec;
92
- if (parts[0] !== "layers" && parts[0] !== "background" && !STEERABLE_ROOT_KEYS.has(parts[0]) && Array.isArray(s.layers)) {
105
+ if (parts[0] !== "layers" && parts[0] !== "background" && Array.isArray(s.layers)) {
93
106
  const idx = s.layers.findIndex((l) => l && l.key === parts[0]);
94
- if (idx === -1) return null;
95
- parts.splice(0, 1, "layers", String(idx));
107
+ if (idx !== -1) {
108
+ parts.splice(0, 1, "layers", String(idx));
109
+ } else if (!STEERABLE_ROOT_KEYS.has(parts[0])) {
110
+ return null;
111
+ }
96
112
  }
97
113
  let node = spec;
98
114
  for (let i = 0; i < parts.length - 1; i++) {
@@ -149,10 +165,66 @@ function lerpSpec(from, to, k) {
149
165
  };
150
166
  return walk(from, to);
151
167
  }
168
+ function textStringsOf(sprite) {
169
+ if (sprite.kind === "text") return sprite.strings;
170
+ if (sprite.kind === "textBlock") return [sprite.text];
171
+ return null;
172
+ }
173
+ function textStringsDiffer(a, b, i) {
174
+ const sa = a.layers[i]?.sprite;
175
+ const sb = b.layers[i]?.sprite;
176
+ if (!sa || !sb) return false;
177
+ const ta = textStringsOf(sa);
178
+ const tb = textStringsOf(sb);
179
+ if (!ta || !tb) return false;
180
+ return ta.length !== tb.length || ta.some((s, j) => s !== tb[j]);
181
+ }
182
+ var NON_RENDERED_KEYS = /* @__PURE__ */ new Set(["id", "label", "schemaVersion", "key"]);
183
+ function morphNothingMorphable(a, b, opts = {}) {
184
+ if (opts.textCrossfade && a.layers.some((_, i) => textStringsDiffer(a, b, i))) return false;
185
+ let hasDiff = false;
186
+ let hasGlide = false;
187
+ const walk = (x, y, key) => {
188
+ if (key !== void 0 && NON_RENDERED_KEYS.has(key)) return;
189
+ if (x === y) return;
190
+ if (typeof x === "number" && typeof y === "number") {
191
+ hasDiff = true;
192
+ hasGlide = true;
193
+ return;
194
+ }
195
+ if (typeof x === "string" && typeof y === "string" && HEX.test(x) && HEX.test(y)) {
196
+ const ca = hexToRgb(x);
197
+ const cb = hexToRgb(y);
198
+ if (ca.some((v, i) => v !== cb[i])) {
199
+ hasDiff = true;
200
+ hasGlide = true;
201
+ }
202
+ return;
203
+ }
204
+ if (Array.isArray(x) && Array.isArray(y) && x.length === y.length) {
205
+ for (let i = 0; i < y.length; i++) walk(x[i], y[i]);
206
+ return;
207
+ }
208
+ if (x && y && typeof x === "object" && typeof y === "object" && !Array.isArray(x) && !Array.isArray(y)) {
209
+ const keys = /* @__PURE__ */ new Set([...Object.keys(x), ...Object.keys(y)]);
210
+ for (const k of keys) {
211
+ walk(x[k], y[k], k);
212
+ }
213
+ return;
214
+ }
215
+ hasDiff = true;
216
+ };
217
+ walk(a, b);
218
+ return hasDiff && !hasGlide;
219
+ }
152
220
  function steerablePaths(spec) {
153
221
  if (!spec || typeof spec !== "object") return [];
154
222
  const SKIP = /* @__PURE__ */ new Set(["kind", "type", "key", "schemaVersion", "id", "label", "seed", "units", "motionIntensity", "mode", "curve", "layer"]);
155
- const INDEXED = /* @__PURE__ */ new Set(["layers", "stops"]);
223
+ const INDEXED = /* @__PURE__ */ new Set(["layers", "stops", "bands"]);
224
+ const layers = spec.layers;
225
+ const shadowedRootKeys = new Set(
226
+ Array.isArray(layers) ? layers.map((l) => l && typeof l === "object" ? l.key : void 0).filter((k) => typeof k === "string" && STEERABLE_ROOT_KEYS.has(k)) : []
227
+ );
156
228
  const out = [];
157
229
  const walk = (node, prefix, key) => {
158
230
  if (Array.isArray(node) && INDEXED.has(key)) {
@@ -166,6 +238,7 @@ function steerablePaths(spec) {
166
238
  if (node && typeof node === "object") {
167
239
  for (const [k, v] of Object.entries(node)) {
168
240
  if (SKIP.has(k)) continue;
241
+ if (!prefix && shadowedRootKeys.has(k)) continue;
169
242
  walk(v, prefix ? `${prefix}.${k}` : k, k);
170
243
  }
171
244
  return;
@@ -175,6 +248,12 @@ function steerablePaths(spec) {
175
248
  walk(spec, "", "");
176
249
  return out;
177
250
  }
251
+ function sequenceSteerablePaths(seq) {
252
+ const out = /* @__PURE__ */ new Set(["sequence.segment"]);
253
+ if (seq.bed) for (const p of steerablePaths(seq.bed)) out.add(`bed.${p}`);
254
+ for (const seg of seq.segments) for (const p of steerablePaths(seg.scene)) out.add(p);
255
+ return [...out];
256
+ }
178
257
  function easeSmooth(k) {
179
258
  const c = Math.max(0, Math.min(1, k));
180
259
  return c * c * (3 - 2 * c);
@@ -182,7 +261,10 @@ function easeSmooth(k) {
182
261
  function structuralSignature(spec) {
183
262
  return JSON.stringify([
184
263
  spec.units,
185
- spec.referenceViewport,
264
+ // Normalized against the same default every renderer uses — an omitted
265
+ // referenceViewport and an explicit 1080 render identically, so they must
266
+ // hash identically or a same-sizing morph gets rejected as structural.
267
+ spec.referenceViewport ?? LIMITS.referenceViewport,
186
268
  spec.layers.map((l) => {
187
269
  const s = l.sprite;
188
270
  return [
@@ -208,7 +290,10 @@ function structuralSignature(spec) {
208
290
  l.sprite.kind === "rect" ? [s.width, s.aspect] : l.sprite.kind === "bar" ? [s.length, s.thickness] : void 0,
209
291
  Array.isArray(s.colors) ? s.colors.length : void 0,
210
292
  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
293
+ 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,
294
+ // Static rotation is baked into entities (a range draws a seeded
295
+ // angle). Appended only when set — same rule as `anchor` above.
296
+ ...l.rotate !== void 0 ? [l.rotate] : []
212
297
  ];
213
298
  })
214
299
  ]);
@@ -220,7 +305,8 @@ var isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
220
305
  var isStr = (v) => typeof v === "string";
221
306
  var isNum = (v) => typeof v === "number" && Number.isFinite(v);
222
307
  var isRange = (v) => Array.isArray(v) && v.length === 2 && isNum(v[0]) && isNum(v[1]) && v[0] <= v[1];
223
- var KNOWN_TOP = /* @__PURE__ */ new Set(["schemaVersion", "id", "label", "seed", "motionIntensity", "density", "units", "referenceViewport", "background", "layers", "ghosting"]);
308
+ var KNOWN_TOP = /* @__PURE__ */ new Set(["schemaVersion", "id", "label", "seed", "motionIntensity", "density", "units", "referenceViewport", "background", "layers", "ghosting", "finish"]);
309
+ var KNOWN_FINISH = /* @__PURE__ */ new Set(["grain", "dither", "animate"]);
224
310
  var KNOWN_LAYER = /* @__PURE__ */ new Set([
225
311
  "count",
226
312
  "sprite",
@@ -241,7 +327,8 @@ var KNOWN_LAYER = /* @__PURE__ */ new Set([
241
327
  "layout",
242
328
  "life",
243
329
  "emit",
244
- "clock"
330
+ "clock",
331
+ "rotate"
245
332
  ]);
246
333
  var KNOWN_CIRCLE = /* @__PURE__ */ new Set(["kind", "radius", "color", "soft", "colors", "colorWeights"]);
247
334
  var KNOWN_RING = /* @__PURE__ */ new Set(["kind", "radius", "color", "width", "colors", "colorWeights"]);
@@ -251,8 +338,11 @@ var KNOWN_BAR = /* @__PURE__ */ new Set(["kind", "values", "length", "thickness"
251
338
  var KNOWN_POLYGON = /* @__PURE__ */ new Set(["kind", "radius", "color", "sides", "points", "soft", "colors", "colorWeights"]);
252
339
  var KNOWN_STROKE = /* @__PURE__ */ new Set(["kind", "length", "points", "color", "width", "curve", "taper", "orient", "colors", "colorWeights"]);
253
340
  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"]);
341
+ var KNOWN_TEXT = /* @__PURE__ */ new Set(["kind", "strings", "color", "font", "align", "baseline", "maxWidth", "cycle", "role"]);
342
+ var KNOWN_TEXT_BLOCK = /* @__PURE__ */ new Set(["kind", "text", "maxWidth", "fontSize", "lineHeight", "align", "color", "reveal", "anchor", "font", "opacity", "role"]);
343
+ var TEXT_ROLES = /* @__PURE__ */ new Set(["read", "atmosphere"]);
344
+ var TEXT_BLOCK_ANCHORS = /* @__PURE__ */ new Set(["top-left", "top", "top-right", "left", "center", "right", "bottom-left", "bottom", "bottom-right"]);
345
+ var FONT_SIZE_RE = /\d(?:px|pt|pc|em|rem|ex|ch|vw|vh|vmin|vmax|%)/i;
256
346
  var KNOWN_REVEAL = /* @__PURE__ */ new Set(["progress", "mode", "speed", "caret", "fade"]);
257
347
  var KNOWN_REVEAL_CARET = /* @__PURE__ */ new Set(["blink", "color"]);
258
348
  var KNOWN_DRIFT = /* @__PURE__ */ new Set(["type", "speed", "angle", "bidirectional", "bob", "ease"]);
@@ -265,7 +355,9 @@ var KNOWN_WARP = /* @__PURE__ */ new Set(["type", "speed", "center"]);
265
355
  var KNOWN_PATH = /* @__PURE__ */ new Set(["type", "points", "duration", "curve", "closed", "scatter"]);
266
356
  var KNOWN_BG_SOLID = /* @__PURE__ */ new Set(["type", "color"]);
267
357
  var KNOWN_BG_GRADIENT = /* @__PURE__ */ new Set(["type", "stops", "band", "drift"]);
268
- var LAYER_PROPS_ON_SPRITE = /* @__PURE__ */ new Set(["blend", "trail", "alpha", "pulse", "spin", "grow", "region", "links", "flip", "wrap", "key", "emit", "clock", "life", "layout"]);
358
+ var KNOWN_BG_FIELD = /* @__PURE__ */ new Set(["type", "scale", "octaves", "warp", "quantize", "bands", "drift", "seed"]);
359
+ var KNOWN_FIELD_DRIFT = /* @__PURE__ */ new Set(["period", "amount"]);
360
+ var LAYER_PROPS_ON_SPRITE = /* @__PURE__ */ new Set(["blend", "trail", "alpha", "pulse", "spin", "grow", "region", "links", "flip", "wrap", "key", "emit", "clock", "life", "layout", "rotate"]);
269
361
  function unknownKeys(obj, known) {
270
362
  return Object.keys(obj).filter((k) => !known.has(k));
271
363
  }
@@ -321,6 +413,7 @@ function validateSpec(spec) {
321
413
  warn(k, "unknown-property", `unknown top-level property '${k}' \u2014 will be ignored`);
322
414
  }
323
415
  if (spec.background !== void 0) validateBackground(spec.background, err, warn);
416
+ if (spec.finish !== void 0) validateFinish(spec.finish, "finish", err, warn);
324
417
  if (!Array.isArray(spec.layers) || spec.layers.length === 0) {
325
418
  err("layers", "must be a non-empty array");
326
419
  } else {
@@ -337,6 +430,16 @@ function validateSpec(spec) {
337
430
  function color(v, path, err) {
338
431
  if (!isStr(v) || !HEX2.test(v)) err(path, "must be a hex colour like #0a3a52");
339
432
  }
433
+ function validateFinish(f, path, err, warn) {
434
+ if (!isObj(f)) return err(path, "must be an object");
435
+ for (const k of ["grain", "dither"]) {
436
+ if (f[k] !== void 0 && (!isNum(f[k]) || f[k] < 0 || f[k] > 1)) err(`${path}.${k}`, "must be a number 0..1");
437
+ }
438
+ if (f.animate !== void 0 && typeof f.animate !== "boolean") err(`${path}.animate`, "must be a boolean");
439
+ for (const k of unknownKeys(f, KNOWN_FINISH)) {
440
+ warn(`${path}.${k}`, "unknown-property", `unknown finish property '${k}' \u2014 will be ignored`);
441
+ }
442
+ }
340
443
  function validateBackground(bg, err, warn) {
341
444
  if (!isObj(bg)) return err("background", "must be an object");
342
445
  if (bg.type === "solid") {
@@ -374,8 +477,47 @@ function validateBackground(bg, err, warn) {
374
477
  for (const k of unknownKeys(bg, KNOWN_BG_GRADIENT)) {
375
478
  warn(`background.${k}`, "unknown-property", `unknown background property '${k}' \u2014 will be ignored`);
376
479
  }
480
+ } else if (bg.type === "field") {
481
+ validateFieldBackground(bg, err, warn);
482
+ } else {
483
+ err("background.type", "must be solid | gradient | field");
484
+ }
485
+ }
486
+ function validateFieldBackground(bg, err, warn) {
487
+ if (!isNum(bg.scale) || bg.scale < LIMITS.minFieldScale || bg.scale > LIMITS.maxFieldScale) {
488
+ err("background.scale", `must be a number ${LIMITS.minFieldScale}..${LIMITS.maxFieldScale} (features across the short side)`);
489
+ }
490
+ if (bg.octaves !== void 0 && (!isNum(bg.octaves) || !Number.isInteger(bg.octaves) || bg.octaves < 1 || bg.octaves > LIMITS.maxFieldOctaves)) {
491
+ err("background.octaves", `must be an integer 1..${LIMITS.maxFieldOctaves}`);
492
+ }
493
+ if (bg.warp !== void 0 && (!isNum(bg.warp) || bg.warp < 0 || bg.warp > 1)) {
494
+ err("background.warp", "must be a number 0..1");
495
+ }
496
+ if (bg.quantize !== void 0 && (!isNum(bg.quantize) || !Number.isInteger(bg.quantize) || bg.quantize < 0 || bg.quantize === 1 || bg.quantize > LIMITS.maxFieldQuantize)) {
497
+ err("background.quantize", `must be 0 (smooth) or an integer 2..${LIMITS.maxFieldQuantize}`);
498
+ }
499
+ if (!Array.isArray(bg.bands) || bg.bands.length < LIMITS.minFieldBands || bg.bands.length > LIMITS.maxFieldBands) {
500
+ err("background.bands", `must be ${LIMITS.minFieldBands}..${LIMITS.maxFieldBands} hex colours`);
377
501
  } else {
378
- err("background.type", "must be solid | gradient");
502
+ for (let i = 0; i < bg.bands.length; i++) color(bg.bands[i], `background.bands[${i}]`, err);
503
+ }
504
+ if (bg.drift !== void 0) {
505
+ if (!isObj(bg.drift)) err("background.drift", "must be an object");
506
+ else {
507
+ if (!isNum(bg.drift.period) || bg.drift.period < LIMITS.minDriftPeriod) {
508
+ err("background.drift.period", `must be >= ${LIMITS.minDriftPeriod} ms`);
509
+ }
510
+ if (bg.drift.amount !== void 0 && (!isNum(bg.drift.amount) || bg.drift.amount < 0 || bg.drift.amount > LIMITS.maxFieldDriftAmount)) {
511
+ err("background.drift.amount", `must be a number 0..${LIMITS.maxFieldDriftAmount}`);
512
+ }
513
+ for (const k of unknownKeys(bg.drift, KNOWN_FIELD_DRIFT)) {
514
+ warn(`background.drift.${k}`, "unknown-property", `unknown drift property '${k}' \u2014 will be ignored`);
515
+ }
516
+ }
517
+ }
518
+ if (bg.seed !== void 0 && !isNum(bg.seed)) err("background.seed", "must be a number");
519
+ for (const k of unknownKeys(bg, KNOWN_BG_FIELD)) {
520
+ warn(`background.${k}`, "unknown-property", `unknown background property '${k}' \u2014 will be ignored`);
379
521
  }
380
522
  }
381
523
  function validateLayer(layer, path, err, warn, spec) {
@@ -464,6 +606,17 @@ function validateLayer(layer, path, err, warn, spec) {
464
606
  err(`${path}.spin`, `must be within \xB1${LIMITS.maxSpin} deg/sec`);
465
607
  }
466
608
  }
609
+ if (layer.rotate !== void 0) {
610
+ if (isRange(layer.rotate)) {
611
+ if (Math.abs(layer.rotate[0]) > LIMITS.maxRotate || Math.abs(layer.rotate[1]) > LIMITS.maxRotate) {
612
+ err(`${path}.rotate`, `each end of the range must be within \xB1${LIMITS.maxRotate} degrees`);
613
+ }
614
+ } else if (!isNum(layer.rotate)) {
615
+ err(`${path}.rotate`, "must be a number or a [min,max] range (degrees)");
616
+ } else if (Math.abs(layer.rotate) > LIMITS.maxRotate) {
617
+ err(`${path}.rotate`, `must be within \xB1${LIMITS.maxRotate} degrees`);
618
+ }
619
+ }
467
620
  if (layer.grow !== void 0) {
468
621
  if (!isObj(layer.grow)) err(`${path}.grow`, "must be an object");
469
622
  else {
@@ -674,6 +827,7 @@ function validateSprite(sprite, path, err, warn) {
674
827
  if (sprite.maxWidth !== void 0 && (!isNum(sprite.maxWidth) || sprite.maxWidth <= 0)) {
675
828
  err(`${path}.maxWidth`, "must be a positive number");
676
829
  }
830
+ validateTextRole(sprite, path, err);
677
831
  validateCycle(sprite, path, err);
678
832
  } else if (sprite.kind === "circle") {
679
833
  knownSet = KNOWN_CIRCLE;
@@ -760,6 +914,20 @@ function validateSprite(sprite, path, err, warn) {
760
914
  err(`${path}.align`, "must be 'left' | 'center' | 'right'");
761
915
  }
762
916
  if (sprite.color !== void 0) color(sprite.color, `${path}.color`, err);
917
+ if (sprite.anchor !== void 0 && !TEXT_BLOCK_ANCHORS.has(sprite.anchor)) {
918
+ err(`${path}.anchor`, "must be 'top-left' | 'top' | 'top-right' | 'left' | 'center' | 'right' | 'bottom-left' | 'bottom' | 'bottom-right'");
919
+ }
920
+ if (sprite.font !== void 0) {
921
+ if (!isStr(sprite.font) || sprite.font.trim() === "") {
922
+ err(`${path}.font`, "must be a non-empty string (family and/or weight)");
923
+ } else if (FONT_SIZE_RE.test(sprite.font)) {
924
+ err(`${path}.font`, "must name a family and/or weight only \u2014 fontSize owns the size");
925
+ }
926
+ }
927
+ if (sprite.opacity !== void 0 && (!isNum(sprite.opacity) || sprite.opacity < 0 || sprite.opacity > 1)) {
928
+ err(`${path}.opacity`, "must be a number between 0 and 1");
929
+ }
930
+ validateTextRole(sprite, path, err);
763
931
  if (sprite.reveal !== void 0) {
764
932
  const rv = sprite.reveal;
765
933
  if (typeof rv !== "object" || rv === null || Array.isArray(rv)) {
@@ -826,6 +994,11 @@ function validatePalette(sprite, path, err) {
826
994
  }
827
995
  }
828
996
  }
997
+ function validateTextRole(sprite, path, err) {
998
+ if (sprite.role !== void 0 && !TEXT_ROLES.has(sprite.role)) {
999
+ err(`${path}.role`, "must be 'read' | 'atmosphere'");
1000
+ }
1001
+ }
829
1002
  function validateCycle(sprite, path, err) {
830
1003
  if (sprite.cycle === void 0) return;
831
1004
  if (!isObj(sprite.cycle)) return err(`${path}.cycle`, "must be an object");
@@ -998,6 +1171,7 @@ function validateSequence(seq) {
998
1171
  }
999
1172
  }
1000
1173
  }
1174
+ if (isObj(seq) && isObj(seq.bed)) normalizeColors(seq.bed);
1001
1175
  const errors = [];
1002
1176
  const warnings = [];
1003
1177
  const err = (path, message) => void errors.push({ path, message });
@@ -1008,6 +1182,8 @@ function validateSequence(seq) {
1008
1182
  if (!isStr(seq.label) || seq.label.trim() === "") err("label", "must be a non-empty string");
1009
1183
  if (seq.seed !== void 0 && !isNum(seq.seed)) err("seed", "must be a number");
1010
1184
  if (typeof seq.loop !== "boolean") err("loop", "must be a boolean");
1185
+ if (seq.sync !== void 0 && seq.sync !== "mount" && seq.sync !== "epoch") err("sync", "must be 'mount' | 'epoch'");
1186
+ if (seq.finish !== void 0) validateFinish(seq.finish, "finish", err, (p, code, message) => void warnings.push({ path: p, code, message }));
1011
1187
  if (!Array.isArray(seq.segments) || seq.segments.length === 0) {
1012
1188
  err("segments", "must be a non-empty array");
1013
1189
  return { valid: false, errors, warnings };
@@ -1050,12 +1226,19 @@ function validateSequence(seq) {
1050
1226
  if (s.transition !== void 0) {
1051
1227
  if (!isObj(s.transition)) {
1052
1228
  err(`${p}.transition`, "must be an object");
1053
- } else if (s.transition.type === "morph") {
1229
+ } else if (s.transition.type === "morph" || s.transition.type === "fade") {
1054
1230
  if (!isNum(s.transition.dur) || s.transition.dur < LIMITS.minTransitionDur || s.transition.dur > LIMITS.maxTransitionDur) {
1055
1231
  err(`${p}.transition.dur`, `must be a number between ${LIMITS.minTransitionDur} and ${LIMITS.maxTransitionDur}`);
1056
1232
  }
1057
1233
  } else if (s.transition.type !== "cut") {
1058
- err(`${p}.transition.type`, "must be 'cut' or 'morph'");
1234
+ err(`${p}.transition.type`, "must be 'cut', 'morph' or 'fade'");
1235
+ }
1236
+ if (isObj(s.transition) && s.transition.text !== void 0) {
1237
+ if (s.transition.type !== "morph") {
1238
+ err(`${p}.transition.text`, "is a morph option (fade already cross-fades whole frames; cut has no window)");
1239
+ } else if (s.transition.text !== "step" && s.transition.text !== "crossfade") {
1240
+ err(`${p}.transition.text`, "must be 'step' | 'crossfade'");
1241
+ }
1059
1242
  }
1060
1243
  }
1061
1244
  if (isObj(s.scene)) {
@@ -1071,6 +1254,32 @@ function validateSequence(seq) {
1071
1254
  if (seq.loop === true && hasDurationless) {
1072
1255
  err("loop", "loop: true requires all segments to have a duration");
1073
1256
  }
1257
+ if (seq.bed !== void 0) {
1258
+ if (!isObj(seq.bed)) {
1259
+ err("bed", "must be a SaverSpec object");
1260
+ } else {
1261
+ const bedResult = validateSpec(seq.bed);
1262
+ for (const e of bedResult.errors) errors.push({ path: `bed.${e.path}`, message: e.message });
1263
+ for (const w of bedResult.warnings ?? []) warnings.push({ path: `bed.${w.path}`, code: w.code, message: w.message });
1264
+ if (bedResult.valid) {
1265
+ 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;
1266
+ const bedTotal = entityTotal(seq.bed);
1267
+ const largest = seq.segments.reduce((m, s) => Math.max(m, isObj(s) ? entityTotal(s.scene) : 0), 0);
1268
+ if (bedTotal + largest > LIMITS.maxTotal) {
1269
+ err("bed", `bed entities ${bedTotal} + largest segment ${largest} = ${bedTotal + largest} exceeds cap ${LIMITS.maxTotal} (the two are live at once)`);
1270
+ }
1271
+ seq.segments.forEach((s, i) => {
1272
+ if (isObj(s) && isObj(s.scene) && s.scene.background !== void 0) {
1273
+ warnings.push({
1274
+ path: `segments[${i}].scene.background`,
1275
+ code: "bed-hides-segment-background",
1276
+ 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`
1277
+ });
1278
+ }
1279
+ });
1280
+ }
1281
+ }
1282
+ }
1074
1283
  if (errors.length === 0 && Array.isArray(seq.segments)) {
1075
1284
  for (let i = 0; i < seq.segments.length; i++) {
1076
1285
  const s = seq.segments[i];
@@ -1085,6 +1294,12 @@ function validateSequence(seq) {
1085
1294
  code: "morph-structural-mismatch",
1086
1295
  message: `segments ${i}\u2192${i + 1} differ structurally: morph will fall back to cut`
1087
1296
  });
1297
+ } else if (morphNothingMorphable(s.scene, next.scene, { textCrossfade: s.transition.text === "crossfade" })) {
1298
+ warnings.push({
1299
+ path: `segments[${i}].transition`,
1300
+ code: "morph-nothing-morphable",
1301
+ 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`
1302
+ });
1088
1303
  }
1089
1304
  }
1090
1305
  }
@@ -1473,7 +1688,10 @@ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
1473
1688
  } : {},
1474
1689
  // Time-structure fields (2026-09): ease draws nothing; emit's one draw
1475
1690
  // per entity happens in a pass AFTER this loop (see below).
1476
- ...ease ? { ease: { type: ease.type, tau: ease.tau } } : {}
1691
+ ...ease ? { ease: { type: ease.type, tau: ease.tau } } : {},
1692
+ // Static rotation (2026-09-10): a scalar draws nothing; the range form
1693
+ // draws one value here, last, only when declared.
1694
+ ...typeof layer.rotate === "number" ? { rotate: layer.rotate * Math.PI / 180 } : Array.isArray(layer.rotate) ? { rotate: rng.range(layer.rotate[0], layer.rotate[1]) * Math.PI / 180 } : {}
1477
1695
  });
1478
1696
  if (layer.clock) {
1479
1697
  const ent = out[out.length - 1];
@@ -1547,8 +1765,9 @@ function sizeAt(e, t) {
1547
1765
  return s > 0 ? s : 0.1;
1548
1766
  }
1549
1767
  function rotationAt(e, t) {
1550
- if (!e.spinSpeed) return 0;
1551
- return e.spinPhase + e.spinSpeed * (t / 1e3);
1768
+ const base = e.rotate ?? 0;
1769
+ if (!e.spinSpeed) return base;
1770
+ return base + e.spinPhase + e.spinSpeed * (t / 1e3);
1552
1771
  }
1553
1772
  function lifeAlphaAt(life, t) {
1554
1773
  if (!life) return 1;
@@ -1777,18 +1996,25 @@ function linkPairs(positions, k, maxDist, doWrap, w, h) {
1777
1996
  }
1778
1997
  var CHAR_NARROW = new Set(`iIljtf1!|.,;:'"()[]{}`);
1779
1998
  var CHAR_WIDE = new Set("mwMWGOQD@%");
1780
- function charWidthEm(ch) {
1999
+ 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;
2000
+ function textMetricsClassFor(font) {
2001
+ return font && MONO_FAMILY_RE.test(font) ? "mono" : "proportional";
2002
+ }
2003
+ var MONO_ADVANCE_EM = 0.6;
2004
+ function charWidthEm(ch, metrics = "proportional") {
2005
+ if (metrics === "mono") return MONO_ADVANCE_EM;
1781
2006
  if (ch === " " || ch === " ") return 0.3;
1782
2007
  if (CHAR_NARROW.has(ch)) return 0.35;
1783
2008
  if (CHAR_WIDE.has(ch)) return 0.72;
1784
2009
  return 0.55;
1785
2010
  }
1786
- function textWidthEm(str) {
2011
+ function textWidthEm(str, metrics = "proportional") {
2012
+ if (metrics === "mono") return graphemeClusters(str).length * MONO_ADVANCE_EM;
1787
2013
  let w = 0;
1788
- for (let i = 0; i < str.length; i++) w += charWidthEm(str[i]);
2014
+ for (let i = 0; i < str.length; i++) w += charWidthEm(str[i], metrics);
1789
2015
  return w;
1790
2016
  }
1791
- function breakTextBlock(text, maxWidthEm) {
2017
+ function breakTextBlock(text, maxWidthEm, metrics = "proportional") {
1792
2018
  const paragraphs = text.split("\n");
1793
2019
  const lines = [];
1794
2020
  for (const para of paragraphs) {
@@ -1802,11 +2028,11 @@ function breakTextBlock(text, maxWidthEm) {
1802
2028
  continue;
1803
2029
  }
1804
2030
  let lineText = words[0];
1805
- let lineW = textWidthEm(lineText);
1806
- const spaceW = charWidthEm(" ");
2031
+ let lineW = textWidthEm(lineText, metrics);
2032
+ const spaceW = charWidthEm(" ", metrics);
1807
2033
  for (let i = 1; i < words.length; i++) {
1808
2034
  const word = words[i];
1809
- const wordW = textWidthEm(word);
2035
+ const wordW = textWidthEm(word, metrics);
1810
2036
  if (lineW + spaceW + wordW <= maxWidthEm) {
1811
2037
  lineText += " " + word;
1812
2038
  lineW += spaceW + wordW;
@@ -1820,6 +2046,13 @@ function breakTextBlock(text, maxWidthEm) {
1820
2046
  }
1821
2047
  return lines;
1822
2048
  }
2049
+ function textBlockAnchorOffset(s, maxWPx, maxLineW, totalH) {
2050
+ if (!s.anchor) return { dx: 0, dy: 0 };
2051
+ const ax = s.anchor.endsWith("left") ? 0 : s.anchor.endsWith("right") ? 1 : 0.5;
2052
+ const ay = s.anchor.startsWith("top") ? 0 : s.anchor.startsWith("bottom") ? 1 : 0.5;
2053
+ const inkX0 = s.align === "center" ? (maxWPx - maxLineW) / 2 : s.align === "right" ? maxWPx - maxLineW : 0;
2054
+ return { dx: -(inkX0 + ax * maxLineW), dy: -(ay * totalH) };
2055
+ }
1823
2056
  function isClusterExtend(cp) {
1824
2057
  return cp >= 768 && cp <= 879 || // combining diacritics
1825
2058
  cp >= 6832 && cp <= 6911 || cp >= 8400 && cp <= 8447 || cp >= 65024 && cp <= 65039 || // variation selectors
@@ -1924,6 +2157,254 @@ import {
1924
2157
  createRng
1925
2158
  } from "@idle-screens/core";
1926
2159
 
2160
+ // src/field.ts
2161
+ var TWO_PI = Math.PI * 2;
2162
+ var FIELD_BUCKET_MS = 100;
2163
+ var FIELD_RASTER_SHORT_SIDE = 96;
2164
+ var FIELD_RASTER_SHORT_SIDE_LOW = 48;
2165
+ var WARP_GAIN = 2;
2166
+ var DRIFT_GAIN = 1;
2167
+ var FIELD_DRIFT_DEFAULT_AMOUNT = 0.3;
2168
+ var CONTRAST = [1, 1.45, 1.8, 1.95, 2];
2169
+ var clamp01 = (v) => v < 0 ? 0 : v > 1 ? 1 : v;
2170
+ function latticeHash(ix, iy, seed) {
2171
+ let h = Math.imul(ix | 0, 668265261) ^ Math.imul(iy | 0, 374761393) ^ Math.imul(seed | 0, 2654435761);
2172
+ h = Math.imul(h ^ h >>> 15, 2246822507);
2173
+ h = Math.imul(h ^ h >>> 13, 3266489909);
2174
+ h ^= h >>> 16;
2175
+ return (h >>> 0) / 4294967296;
2176
+ }
2177
+ var smoothstep2 = (k) => k * k * (3 - 2 * k);
2178
+ function valueNoise(x, y, seed) {
2179
+ const x0 = Math.floor(x);
2180
+ const y0 = Math.floor(y);
2181
+ const fx = smoothstep2(x - x0);
2182
+ const fy = smoothstep2(y - y0);
2183
+ const a = latticeHash(x0, y0, seed);
2184
+ const b = latticeHash(x0 + 1, y0, seed);
2185
+ const c = latticeHash(x0, y0 + 1, seed);
2186
+ const d = latticeHash(x0 + 1, y0 + 1, seed);
2187
+ const top = a + (b - a) * fx;
2188
+ const bottom = c + (d - c) * fx;
2189
+ return top + (bottom - top) * fy;
2190
+ }
2191
+ function fbm(x, y, seed, octaves) {
2192
+ let sum = 0;
2193
+ let amp = 1;
2194
+ let norm = 0;
2195
+ let f = 1;
2196
+ for (let i = 0; i < octaves; i++) {
2197
+ sum += amp * valueNoise(x * f + i * 17.3, y * f + i * 31.7, seed + i * 101);
2198
+ norm += amp;
2199
+ amp *= 0.5;
2200
+ f *= 2;
2201
+ }
2202
+ return sum / norm;
2203
+ }
2204
+ function fieldOctaves(cfg) {
2205
+ const o = Math.round(cfg.octaves ?? 2);
2206
+ return o < 1 ? 1 : o > 4 ? 4 : o;
2207
+ }
2208
+ function fieldSampleTime(cfg, t) {
2209
+ return cfg.drift && cfg.drift.amount !== 0 ? Math.floor(t / FIELD_BUCKET_MS) * FIELD_BUCKET_MS : 0;
2210
+ }
2211
+ function fieldAt(u, v, t, cfg, seed) {
2212
+ const scale = cfg.scale;
2213
+ const octaves = fieldOctaves(cfg);
2214
+ const s = (cfg.seed ?? seed) >>> 0;
2215
+ let x = u * scale;
2216
+ let y = v * scale;
2217
+ const drift = cfg.drift;
2218
+ if (drift) {
2219
+ const phase = TWO_PI * t / drift.period;
2220
+ const r = (drift.amount ?? FIELD_DRIFT_DEFAULT_AMOUNT) * DRIFT_GAIN;
2221
+ x += r * Math.sin(phase);
2222
+ y += r * Math.cos(phase);
2223
+ }
2224
+ const warp = cfg.warp ?? 0;
2225
+ if (warp > 0) {
2226
+ const qx = fbm(x + 5.2, y + 1.3, s + 7919, octaves) - 0.5;
2227
+ const qy = fbm(x + 1.7, y + 9.2, s + 104729, octaves) - 0.5;
2228
+ x += warp * WARP_GAIN * qx;
2229
+ y += warp * WARP_GAIN * qy;
2230
+ }
2231
+ const raw = fbm(x, y, s, octaves);
2232
+ return clamp01(0.5 + (raw - 0.5) * CONTRAST[octaves]);
2233
+ }
2234
+ function hexToRgb255(hex) {
2235
+ const h = hex.length === 4 ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}` : hex;
2236
+ const n = parseInt(h.slice(1), 16);
2237
+ if (Number.isNaN(n)) return [128, 128, 128];
2238
+ return [n >> 16 & 255, n >> 8 & 255, n & 255];
2239
+ }
2240
+ function rgb255Luma(rgb) {
2241
+ return (0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]) / 255;
2242
+ }
2243
+ function fieldRgb(value, bands, quantize) {
2244
+ const n = bands.length;
2245
+ const q = Math.round(quantize ?? n);
2246
+ let f;
2247
+ if (q >= 2) {
2248
+ const level = Math.min(q - 1, Math.floor(clamp01(value) * q));
2249
+ f = level * (n - 1) / (q - 1);
2250
+ } else {
2251
+ f = clamp01(value) * (n - 1);
2252
+ }
2253
+ const i = Math.min(n - 2, Math.floor(f));
2254
+ const k = f - i;
2255
+ const a = hexToRgb255(bands[i]);
2256
+ if (k === 0) return a;
2257
+ const b = hexToRgb255(bands[i + 1]);
2258
+ return [
2259
+ Math.round(a[0] + (b[0] - a[0]) * k),
2260
+ Math.round(a[1] + (b[1] - a[1]) * k),
2261
+ Math.round(a[2] + (b[2] - a[2]) * k)
2262
+ ];
2263
+ }
2264
+ function fieldRgbAt(u, v, t, cfg, seed) {
2265
+ return fieldRgb(fieldAt(u, v, t, cfg, seed), cfg.bands, cfg.quantize);
2266
+ }
2267
+
2268
+ // src/finish.ts
2269
+ var GRAIN_TILE = 256;
2270
+ var DITHER_TILE = 8;
2271
+ var GRAIN_ALPHA = 0.35;
2272
+ var DITHER_ALPHA = 0.25;
2273
+ var FINISH_ANIMATE_HZ = 12;
2274
+ var clamp012 = (v) => v < 0 ? 0 : v > 1 ? 1 : v;
2275
+ function finishStrength(f) {
2276
+ return { grain: clamp012(f?.grain ?? 0), dither: clamp012(f?.dither ?? 0) };
2277
+ }
2278
+ function grainTilePixels(seed) {
2279
+ const px = new Uint8ClampedArray(GRAIN_TILE * GRAIN_TILE * 4);
2280
+ const s = seed >>> 0;
2281
+ let i = 0;
2282
+ for (let y = 0; y < GRAIN_TILE; y++) {
2283
+ for (let x = 0; x < GRAIN_TILE; x++) {
2284
+ const v = (latticeHash(x, y, s) + latticeHash(x, y, s + 1540483477)) / 2;
2285
+ const g = Math.round(128 + (v - 0.5) * 255);
2286
+ px[i++] = g;
2287
+ px[i++] = g;
2288
+ px[i++] = g;
2289
+ px[i++] = 255;
2290
+ }
2291
+ }
2292
+ return px;
2293
+ }
2294
+ function bayerMatrix() {
2295
+ let m = [[0]];
2296
+ for (let n = 1; n < DITHER_TILE; n *= 2) {
2297
+ const next = [];
2298
+ for (let y = 0; y < n * 2; y++) {
2299
+ const row = [];
2300
+ for (let x = 0; x < n * 2; x++) {
2301
+ const base = m[y % n][x % n] * 4;
2302
+ const quadrant = (y >= n ? 2 : 0) + (x >= n ? 1 : 0);
2303
+ row.push(base + [0, 2, 3, 1][quadrant]);
2304
+ }
2305
+ next.push(row);
2306
+ }
2307
+ m = next;
2308
+ }
2309
+ return m;
2310
+ }
2311
+ function bayerTilePixels() {
2312
+ const m = bayerMatrix();
2313
+ const px = new Uint8ClampedArray(DITHER_TILE * DITHER_TILE * 4);
2314
+ let i = 0;
2315
+ for (let y = 0; y < DITHER_TILE; y++) {
2316
+ for (let x = 0; x < DITHER_TILE; x++) {
2317
+ const g = Math.round((m[y][x] + 0.5) / (DITHER_TILE * DITHER_TILE) * 255);
2318
+ px[i++] = g;
2319
+ px[i++] = g;
2320
+ px[i++] = g;
2321
+ px[i++] = 255;
2322
+ }
2323
+ }
2324
+ return px;
2325
+ }
2326
+ function grainOffset(t, seed, animate) {
2327
+ if (!animate) return { x: 0, y: 0 };
2328
+ const bucket = Math.floor(t * FINISH_ANIMATE_HZ / 1e3);
2329
+ const s = seed >>> 0;
2330
+ return {
2331
+ x: Math.floor(latticeHash(bucket, 1, s) * GRAIN_TILE),
2332
+ y: Math.floor(latticeHash(bucket, 2, s) * GRAIN_TILE)
2333
+ };
2334
+ }
2335
+ function pickScreenOp(ctx) {
2336
+ const prev = ctx.globalCompositeOperation;
2337
+ let op = "multiply";
2338
+ for (const candidate of ["overlay", "soft-light"]) {
2339
+ ctx.globalCompositeOperation = candidate;
2340
+ if (ctx.globalCompositeOperation === candidate) {
2341
+ op = candidate;
2342
+ break;
2343
+ }
2344
+ }
2345
+ ctx.globalCompositeOperation = prev;
2346
+ return op;
2347
+ }
2348
+ function tileCanvas(size, pixels) {
2349
+ const canvas = typeof document !== "undefined" ? document.createElement("canvas") : new OffscreenCanvas(size, size);
2350
+ canvas.width = size;
2351
+ canvas.height = size;
2352
+ const ctx = canvas.getContext("2d");
2353
+ if (!ctx || typeof ctx.createImageData !== "function") return null;
2354
+ const img = ctx.createImageData(size, size);
2355
+ img.data.set(pixels);
2356
+ ctx.putImageData(img, 0, 0);
2357
+ return canvas;
2358
+ }
2359
+ function createFinishPass(visible, seed) {
2360
+ const ctx = visible.getContext("2d", { alpha: false });
2361
+ if (!ctx) return null;
2362
+ return { canvas: visible, ctx, op: pickScreenOp(ctx), grain: null, dither: null, seed };
2363
+ }
2364
+ function createSceneCanvas(width, height) {
2365
+ if (typeof document === "undefined") return new OffscreenCanvas(Math.max(1, width), Math.max(1, height));
2366
+ const canvas = document.createElement("canvas");
2367
+ canvas.width = Math.max(1, width);
2368
+ canvas.height = Math.max(1, height);
2369
+ return canvas;
2370
+ }
2371
+ function pattern(fp, size, pixels) {
2372
+ const tile = tileCanvas(size, pixels);
2373
+ if (!tile || typeof fp.ctx.createPattern !== "function") return null;
2374
+ return fp.ctx.createPattern(tile, "repeat");
2375
+ }
2376
+ function presentWithFinish(fp, scene, w, h, t, finish, animateAllowed, seed = fp.seed) {
2377
+ const p = fp.ctx;
2378
+ p.globalAlpha = 1;
2379
+ p.globalCompositeOperation = "source-over";
2380
+ p.drawImage(scene, 0, 0, w, h);
2381
+ const normalizedSeed = seed >>> 0 || 1;
2382
+ if (normalizedSeed !== fp.seed) {
2383
+ fp.seed = normalizedSeed;
2384
+ fp.grain = null;
2385
+ }
2386
+ const { grain, dither } = finishStrength(finish);
2387
+ if (grain > 0) {
2388
+ fp.grain ??= pattern(fp, GRAIN_TILE, grainTilePixels(fp.seed));
2389
+ if (fp.grain) screen(fp, fp.grain, grain * GRAIN_ALPHA, grainOffset(t, fp.seed, animateAllowed && finish?.animate === true), w, h);
2390
+ }
2391
+ if (dither > 0) {
2392
+ fp.dither ??= pattern(fp, DITHER_TILE, bayerTilePixels());
2393
+ if (fp.dither) screen(fp, fp.dither, dither * DITHER_ALPHA, { x: 0, y: 0 }, w, h);
2394
+ }
2395
+ }
2396
+ function screen(fp, pat, alpha, off, w, h) {
2397
+ const p = fp.ctx;
2398
+ p.save();
2399
+ p.globalCompositeOperation = fp.op;
2400
+ p.globalAlpha = alpha;
2401
+ p.imageSmoothingEnabled = false;
2402
+ p.fillStyle = pat;
2403
+ p.translate(-off.x, -off.y);
2404
+ p.fillRect(off.x, off.y, w, h);
2405
+ p.restore();
2406
+ }
2407
+
1927
2408
  // src/sequence.ts
1928
2409
  function segmentStart(seq, index) {
1929
2410
  let t = 0;
@@ -1967,9 +2448,70 @@ function resolveSegment(seq, T, opts = {}) {
1967
2448
  const lastStart = totalTimed - (segments[last].duration ?? 0);
1968
2449
  return { index: last, localT: t0 - lastStart, startT: lastStart };
1969
2450
  }
2451
+ function canMorph(seq, from) {
2452
+ const seg = seq.segments[from];
2453
+ if (!seg || seg.transition?.type !== "morph") return false;
2454
+ const next = seq.segments[from + 1];
2455
+ if (!next) return false;
2456
+ return structuralSignature(seg.scene) === structuralSignature(next.scene);
2457
+ }
2458
+ function morphChainRoot(seq, index) {
2459
+ let i = index;
2460
+ while (i > 0 && canMorph(seq, i - 1)) i--;
2461
+ return i;
2462
+ }
2463
+ function normalizeSeed(seed) {
2464
+ return seed >>> 0 || 1;
2465
+ }
2466
+ function segmentRenderSeed(seq, index) {
2467
+ const root = morphChainRoot(seq, index);
2468
+ const scene = seq.segments[root]?.scene;
2469
+ if (!scene) return void 0;
2470
+ const raw = scene.seed ?? (seq.seed !== void 0 ? seq.seed + root : void 0);
2471
+ return raw === void 0 ? void 0 : normalizeSeed(raw);
2472
+ }
2473
+ function bedRenderSeed(seq) {
2474
+ if (!seq.bed) return void 0;
2475
+ const raw = seq.bed.seed ?? (seq.seed !== void 0 ? seq.seed + LIMITS.maxSegments : void 0);
2476
+ return raw === void 0 ? void 0 : normalizeSeed(raw);
2477
+ }
2478
+ function sequenceSwapCompatible(prev, next) {
2479
+ if (prev.segments.length !== next.segments.length) return false;
2480
+ if (prev.loop !== next.loop) return false;
2481
+ if ((prev.sync ?? "mount") !== (next.sync ?? "mount")) return false;
2482
+ if (!!prev.bed !== !!next.bed) return false;
2483
+ if (prev.bed && next.bed) {
2484
+ if (structuralSignature(prev.bed) !== structuralSignature(next.bed)) return false;
2485
+ if (bedRenderSeed(prev) !== bedRenderSeed(next)) return false;
2486
+ }
2487
+ for (let i = 0; i < prev.segments.length; i++) {
2488
+ const a = prev.segments[i];
2489
+ const b = next.segments[i];
2490
+ if (a.duration !== b.duration) return false;
2491
+ if ((a.advance ?? "auto") !== (b.advance ?? "auto")) return false;
2492
+ if (transitionKey(a.transition) !== transitionKey(b.transition)) return false;
2493
+ if (structuralSignature(a.scene) !== structuralSignature(b.scene)) return false;
2494
+ if (segmentRenderSeed(prev, i) !== segmentRenderSeed(next, i)) return false;
2495
+ }
2496
+ return true;
2497
+ }
2498
+ function transitionKey(tr) {
2499
+ if (!tr || tr.type === "cut") return "cut";
2500
+ if (tr.type === "morph") return `morph:${tr.dur}:${tr.text ?? "step"}`;
2501
+ return `fade:${tr.dur}`;
2502
+ }
1970
2503
 
1971
2504
  // src/compile.ts
1972
2505
  var DEFAULT_STEER_DUR = 1e3;
2506
+ function applyRetainedDeltas(spec, deltas) {
2507
+ let out = spec;
2508
+ for (const d of deltas) {
2509
+ if (!resolveSpecPath(out, d.path)) continue;
2510
+ const next = applyDeltasToSpec(out, [d]);
2511
+ if (validateSpec(next).valid) out = next;
2512
+ }
2513
+ return out;
2514
+ }
1973
2515
  function hexToRgba(hex, alpha) {
1974
2516
  const h = hex.length === 4 ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}` : hex;
1975
2517
  const n = parseInt(h.slice(1), 16);
@@ -2007,11 +2549,15 @@ function manifestFor(spec) {
2007
2549
  };
2008
2550
  }
2009
2551
  var SpecInstance = class {
2010
- constructor(spec, ctx) {
2552
+ constructor(spec, ctx, opts = {}) {
2011
2553
  this.spec = spec;
2012
2554
  this.effSpec = spec;
2013
2555
  this.saverCtx = ctx;
2014
- this.seed = (spec.seed ?? ctx.seed) >>> 0 || 1;
2556
+ this.transparent = opts.transparent === true;
2557
+ const tier = opts.capabilityTier;
2558
+ this.fieldShortSide = tier === "basic" || tier === "minimal" ? FIELD_RASTER_SHORT_SIDE_LOW : FIELD_RASTER_SHORT_SIDE;
2559
+ this.animateFinish = tier !== "basic" && tier !== "minimal";
2560
+ this.seed = normalizeSeed(spec.seed ?? ctx.seed);
2015
2561
  let canvas;
2016
2562
  if (ctx.surface) {
2017
2563
  canvas = ctx.surface;
@@ -2024,8 +2570,15 @@ var SpecInstance = class {
2024
2570
  canvas = el;
2025
2571
  this.ownsCanvas = true;
2026
2572
  }
2573
+ if (spec.finish !== void 0 && !this.transparent && !opts.hostPresents) {
2574
+ const pass = createFinishPass(canvas, this.seed);
2575
+ if (pass) {
2576
+ this.finishPass = pass;
2577
+ canvas = createSceneCanvas(ctx.width, ctx.height);
2578
+ }
2579
+ }
2027
2580
  this.canvas = canvas;
2028
- const c2d = canvas.getContext("2d", { alpha: false });
2581
+ const c2d = canvas.getContext("2d", { alpha: this.transparent });
2029
2582
  if (!c2d) throw new Error("schema saver: no 2d context");
2030
2583
  this.ctx = c2d;
2031
2584
  this.w = ctx.width;
@@ -2033,12 +2586,16 @@ var SpecInstance = class {
2033
2586
  this.sizeCanvas();
2034
2587
  this.rebuild();
2035
2588
  this.paused = ctx.reducedMotion;
2036
- if (this.paused) this.renderFrame(0, this.seed);
2037
- else this.start();
2589
+ if (this.paused) {
2590
+ if (!opts.skipInitialPaint) this.renderFrame(0, this.seed);
2591
+ } else {
2592
+ this.start();
2593
+ }
2038
2594
  }
2039
2595
  spec;
2040
2596
  canvas;
2041
2597
  ownsCanvas;
2598
+ transparent;
2042
2599
  ctx;
2043
2600
  saverCtx;
2044
2601
  seed;
@@ -2058,6 +2615,25 @@ var SpecInstance = class {
2058
2615
  /** Active glide between two resolved specs (live setParam/applyTrack). */
2059
2616
  transition = null;
2060
2617
  lastStructural = "";
2618
+ /** See `LayerPaintOverride`; null (the default, and every plain mount) draws every layer once. */
2619
+ paintOverrides = null;
2620
+ /** Raster short side for a `field` background — halved on the low tiers. */
2621
+ fieldShortSide;
2622
+ /** The `field` background's cached raster; null until a field is first painted. */
2623
+ field = null;
2624
+ /**
2625
+ * The presentation pass when this instance applies a `finish`: the
2626
+ * visible canvas plus the cached screen tiles. When set, `this.canvas` is
2627
+ * an offscreen SCENE canvas — the surface every layer (and `ghosting`'s
2628
+ * persistence) draws into — and each frame ends by copying it to the
2629
+ * visible canvas and screening the finish over the copy. The finish thus
2630
+ * never enters the persistence loop. Null (every plain mount without a
2631
+ * finish, every sequence child) draws straight onto the visible canvas,
2632
+ * exactly as before the field existed.
2633
+ */
2634
+ finishPass = null;
2635
+ /** False on the `basic`/`minimal` tiers — `finish.animate` renders as a static tile. */
2636
+ animateFinish;
2061
2637
  /** Viewport factor for absolute px sizes — 1 for `units: 'px'` specs. */
2062
2638
  fontScale() {
2063
2639
  if (this.effSpec.units === "px") return 1;
@@ -2068,6 +2644,11 @@ var SpecInstance = class {
2068
2644
  this.canvas.width = Math.max(1, Math.round(this.w * dpr));
2069
2645
  this.canvas.height = Math.max(1, Math.round(this.h * dpr));
2070
2646
  this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
2647
+ if (this.finishPass) {
2648
+ this.finishPass.canvas.width = this.canvas.width;
2649
+ this.finishPass.canvas.height = this.canvas.height;
2650
+ this.finishPass.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
2651
+ }
2071
2652
  this.lastRenderT = Number.NEGATIVE_INFINITY;
2072
2653
  }
2073
2654
  /** (Re)seed and place all entities — deterministic for the seed + viewport. */
@@ -2109,6 +2690,10 @@ var SpecInstance = class {
2109
2690
  ctx.fillRect(0, 0, w, h);
2110
2691
  return;
2111
2692
  }
2693
+ if (bg.type === "field") {
2694
+ this.drawField(bg, t);
2695
+ return;
2696
+ }
2112
2697
  const g = ctx.createLinearGradient(0, 0, 0, h);
2113
2698
  const drift = bg.drift;
2114
2699
  for (let i = 0; i < bg.stops.length; i++) {
@@ -2129,6 +2714,58 @@ var SpecInstance = class {
2129
2714
  ctx.fillRect(0, h - bh, w, bh);
2130
2715
  }
2131
2716
  }
2717
+ /**
2718
+ * Paint a `field` background: sample the field into a low-res raster (short
2719
+ * side `fieldShortSide`, the long side by aspect) and draw it scaled to the
2720
+ * canvas — nearest-neighbour for quantised bands so contours stay crisp,
2721
+ * smoothed for `quantize: 0`. The raster is recomputed only when its key
2722
+ * changes: the config (steering glides it), the canvas size, or — while
2723
+ * drifting — the 100 ms bucket `t` falls in (`fieldSampleTime`); a static
2724
+ * field is sampled exactly once per mount. `luminanceGrid` samples the same
2725
+ * function at the same bucketed time, so perception and paint agree.
2726
+ */
2727
+ drawField(bg, t) {
2728
+ const { ctx, w, h } = this;
2729
+ const short = Math.max(1, Math.min(w, h));
2730
+ const cols = Math.max(1, Math.round(this.fieldShortSide * w / short));
2731
+ const rows = Math.max(1, Math.round(this.fieldShortSide * h / short));
2732
+ const ft = fieldSampleTime(bg, t);
2733
+ const key = `${JSON.stringify(bg)}|${w}x${h}|${cols}x${rows}|${ft}`;
2734
+ let raster = this.field;
2735
+ if (!raster || raster.key !== key) {
2736
+ if (!raster || raster.cols !== cols || raster.rows !== rows) {
2737
+ const canvas = typeof document !== "undefined" ? document.createElement("canvas") : new OffscreenCanvas(cols, rows);
2738
+ canvas.width = cols;
2739
+ canvas.height = rows;
2740
+ const rctx = canvas.getContext("2d");
2741
+ if (!rctx) return;
2742
+ raster = { canvas, ctx: rctx, cols, rows, key: "" };
2743
+ }
2744
+ const img = raster.ctx.createImageData(cols, rows);
2745
+ const data = img.data;
2746
+ const seed = this.seed;
2747
+ let i = 0;
2748
+ for (let r = 0; r < rows; r++) {
2749
+ const v = (r + 0.5) * h / rows / short;
2750
+ for (let c = 0; c < cols; c++) {
2751
+ const u = (c + 0.5) * w / cols / short;
2752
+ const rgb = fieldRgbAt(u, v, ft, bg, seed);
2753
+ data[i++] = rgb[0];
2754
+ data[i++] = rgb[1];
2755
+ data[i++] = rgb[2];
2756
+ data[i++] = 255;
2757
+ }
2758
+ }
2759
+ raster.ctx.putImageData(img, 0, 0);
2760
+ raster.key = key;
2761
+ this.field = raster;
2762
+ }
2763
+ const smooth = Math.round(bg.quantize ?? bg.bands.length) < 2;
2764
+ const prev = ctx.imageSmoothingEnabled;
2765
+ ctx.imageSmoothingEnabled = smooth;
2766
+ ctx.drawImage(raster.canvas, 0, 0, w, h);
2767
+ ctx.imageSmoothingEnabled = prev;
2768
+ }
2132
2769
  /** Position with parent-orbit resolution: a layer-parented orbit entity's
2133
2770
  * positionAt is an offset around (0,0); add the parent's own analytic position. */
2134
2771
  entityPos(e, t, parentE) {
@@ -2194,14 +2831,14 @@ var SpecInstance = class {
2194
2831
  ctx.fill();
2195
2832
  }
2196
2833
  }
2197
- drawEntity(built, e, t, lifeA, parentE) {
2834
+ drawEntity(built, e, t, lifeA, parentE, paint) {
2198
2835
  const { ctx } = this;
2199
2836
  const p = this.entityPos(e, t, parentE);
2200
- const sprite = built.layer.sprite;
2837
+ const sprite = paint?.sprite ?? built.layer.sprite;
2201
2838
  const sz = sizeAt(e, t);
2202
2839
  const rot = rotationAt(e, t);
2203
2840
  const unitScale = this.effSpec.units === "px" ? 1 : Math.min(this.w, this.h);
2204
- ctx.globalAlpha = alphaAt(e, t) * lifeA;
2841
+ ctx.globalAlpha = alphaAt(e, t) * lifeA * (paint?.alpha ?? 1);
2205
2842
  if (sprite.kind === "ring") {
2206
2843
  const r = sz / 2;
2207
2844
  const resolvedColor = sprite.colors?.[e.colorIndex] ?? sprite.color;
@@ -2353,7 +2990,7 @@ var SpecInstance = class {
2353
2990
  const lh = (sprite.lineHeight ?? 1.4) * fsPx;
2354
2991
  const maxWPx = sprite.maxWidth * unitScale2;
2355
2992
  const maxWEm = maxWPx / fsPx;
2356
- const lines = breakTextBlock(sprite.text, maxWEm);
2993
+ const lines = breakTextBlock(sprite.text, maxWEm, textMetricsClassFor(sprite.font));
2357
2994
  const align = sprite.align ?? "left";
2358
2995
  const reveal = sprite.reveal;
2359
2996
  const rs = reveal ? revealState(lines, reveal, t) : null;
@@ -2361,7 +2998,13 @@ var SpecInstance = class {
2361
2998
  ctx.save();
2362
2999
  ctx.translate(p.x, p.y);
2363
3000
  if (rot) ctx.rotate(rot);
2364
- ctx.font = `${fsPx}px system-ui, sans-serif`;
3001
+ if (sprite.anchor) {
3002
+ const maxLineW = lines.reduce((m, l) => Math.max(m, l.widthEm), 0) * fsPx;
3003
+ const { dx, dy } = textBlockAnchorOffset(sprite, maxWPx, maxLineW, lines.length * lh);
3004
+ if (dx !== 0 || dy !== 0) ctx.translate(dx, dy);
3005
+ }
3006
+ if (sprite.opacity !== void 0) ctx.globalAlpha *= sprite.opacity;
3007
+ ctx.font = sprite.font ? composeFontShorthand(fsPx, sprite.font) : `${fsPx}px system-ui, sans-serif`;
2365
3008
  ctx.fillStyle = sprite.color ?? "#e6e8ef";
2366
3009
  ctx.textBaseline = "top";
2367
3010
  ctx.textAlign = align;
@@ -2475,6 +3118,33 @@ var SpecInstance = class {
2475
3118
  this.renderFrame(this.lastT, this.seed);
2476
3119
  }
2477
3120
  }
3121
+ /**
3122
+ * Apply steering deltas immediately: no glide, no frame painted. Each delta
3123
+ * is routed by `applyRetainedDeltas` (unresolvable or invalid ones are
3124
+ * skipped on this spec alone). `SequenceInstance` uses this to hand a
3125
+ * freshly created child the track the sequence has retained, so a steer
3126
+ * made while another segment was active lands on the segment that owns the
3127
+ * path. Not painting matters: a child is created inside the parent's
3128
+ * renderFrame, and a stray t=0 paint here would make the real frame that
3129
+ * follows look "contiguous" to the ghosting warm-up — but the constructor
3130
+ * already painted ONE stray t=0 frame of the pre-steer spec (SpecInstance's
3131
+ * own mount does an immediate render when paused, which every sequence
3132
+ * child is), so `lastRenderT` must be reset too: otherwise, with `ghosting`
3133
+ * on, the real frame that follows still finds itself "contiguous" with
3134
+ * that stray paint and composites over it at partial alpha, briefly
3135
+ * showing a ghost of the un-steered scene.
3136
+ */
3137
+ applyDeltasNow(deltas) {
3138
+ const next = applyRetainedDeltas(this.effSpec, deltas);
3139
+ if (next === this.effSpec) return;
3140
+ this.transition = null;
3141
+ this.lastRenderT = Number.NEGATIVE_INFINITY;
3142
+ this.effSpec = next;
3143
+ if (structuralSignature(this.effSpec) !== this.lastStructural) this.rebuild();
3144
+ else this.layers.forEach((b, i) => {
3145
+ b.layer = this.effSpec.layers[i] ?? b.layer;
3146
+ });
3147
+ }
2478
3148
  drawLinks(built, t, lifeA, parentE) {
2479
3149
  const { links } = built.layer;
2480
3150
  if (!links) return;
@@ -2520,19 +3190,28 @@ var SpecInstance = class {
2520
3190
  * showing through — the ghosting smear. */
2521
3191
  paintFrame(t, bgAlpha) {
2522
3192
  const { ctx } = this;
2523
- ctx.globalAlpha = bgAlpha;
2524
- ctx.globalCompositeOperation = "source-over";
2525
- this.drawBackground(t);
3193
+ if (!this.transparent) {
3194
+ ctx.globalAlpha = bgAlpha;
3195
+ ctx.globalCompositeOperation = "source-over";
3196
+ this.drawBackground(t);
3197
+ }
2526
3198
  ctx.globalAlpha = 1;
2527
- for (const built of this.layers) {
3199
+ for (let li = 0; li < this.layers.length; li++) {
3200
+ const built = this.layers[li];
2528
3201
  const lifeA = lifeAlphaAt(built.layer.life, t);
2529
3202
  if (lifeA <= 0) continue;
2530
3203
  const parentE = this.parentEntityFor(built);
2531
3204
  ctx.globalCompositeOperation = built.layer.blend ?? "source-over";
2532
3205
  this.drawLinks(built, t, lifeA, parentE);
3206
+ const override = this.paintOverrides?.get(li) ?? null;
2533
3207
  for (const e of built.entities) {
2534
3208
  this.drawTrail(built, e, t, lifeA, parentE);
2535
- this.drawEntity(built, e, t, lifeA, parentE);
3209
+ if (override === null) {
3210
+ this.drawEntity(built, e, t, lifeA, parentE);
3211
+ continue;
3212
+ }
3213
+ if (override.k < 1) this.drawEntity(built, e, t, lifeA, parentE, { sprite: override.outgoing, alpha: 1 - override.k });
3214
+ if (override.k > 0) this.drawEntity(built, e, t, lifeA, parentE, { alpha: override.k });
2536
3215
  }
2537
3216
  }
2538
3217
  ctx.globalAlpha = 1;
@@ -2549,7 +3228,7 @@ var SpecInstance = class {
2549
3228
  */
2550
3229
  renderFrame(t, _seed) {
2551
3230
  this.stepTransition(t);
2552
- const g = this.effSpec.ghosting ?? 0;
3231
+ const g = this.transparent ? 0 : this.effSpec.ghosting ?? 0;
2553
3232
  if (g > 0) {
2554
3233
  const dt = 1e3 / 60;
2555
3234
  const contiguous = this.lastRenderT !== Number.NEGATIVE_INFINITY && t > this.lastRenderT && t - this.lastRenderT <= 250;
@@ -2566,6 +3245,7 @@ var SpecInstance = class {
2566
3245
  this.paintFrame(t, 1);
2567
3246
  }
2568
3247
  this.lastRenderT = t;
3248
+ if (this.finishPass) presentWithFinish(this.finishPass, this.canvas, this.w, this.h, t, this.effSpec.finish, this.animateFinish);
2569
3249
  }
2570
3250
  setPaused(paused) {
2571
3251
  this.paused = paused;
@@ -2585,8 +3265,8 @@ var SpecInstance = class {
2585
3265
  }
2586
3266
  /**
2587
3267
  * 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).
3268
+ * Checks structural signature — use `hotSwapPaint` only when the caller
3269
+ * already knows the signature is unchanged.
2590
3270
  */
2591
3271
  hotSwapSpec(spec) {
2592
3272
  this.effSpec = spec;
@@ -2603,20 +3283,37 @@ var SpecInstance = class {
2603
3283
  b.layer = this.effSpec.layers[i] ?? b.layer;
2604
3284
  });
2605
3285
  }
3286
+ /**
3287
+ * Internal (see `LayerPaintOverride`): draw the listed layers twice —
3288
+ * outgoing sprite at `1 − k`, own sprite at `k` — until cleared with
3289
+ * `null`. Keyed by layer index. A `SequenceInstance` sets it per morph
3290
+ * frame under `text: 'crossfade'`; nothing else does.
3291
+ */
3292
+ setLayerPaintOverrides(overrides) {
3293
+ this.paintOverrides = overrides && overrides.size > 0 ? overrides : null;
3294
+ }
2606
3295
  dispose() {
2607
3296
  this.stop();
2608
- if (this.ownsCanvas && typeof HTMLCanvasElement !== "undefined" && this.canvas instanceof HTMLCanvasElement) this.canvas.remove();
3297
+ this.field = null;
3298
+ const visible = this.finishPass?.canvas ?? this.canvas;
3299
+ this.finishPass = null;
3300
+ if (this.ownsCanvas && typeof HTMLCanvasElement !== "undefined" && visible instanceof HTMLCanvasElement) visible.remove();
2609
3301
  }
2610
3302
  };
2611
3303
  function compileSaver(spec) {
2612
3304
  const valid = assertValidSpec(spec);
2613
3305
  return {
2614
3306
  manifest: manifestFor(valid),
2615
- mount: (ctx) => new SpecInstance(valid, ctx),
3307
+ mount: (ctx) => new SpecInstance(valid, ctx, { capabilityTier: ctx.capabilityTier }),
2616
3308
  spec: valid
2617
3309
  };
2618
3310
  }
3311
+ var BED_PREFIX = "bed.";
3312
+ function hasHotSwapSequence(inst) {
3313
+ return typeof inst.hotSwapSequence === "function";
3314
+ }
2619
3315
  var SequenceInstance = class {
3316
+ /** Replaced wholesale by `hotSwapSequence`; every per-segment lookup (`segmentStart`, `canMorph`, `childScene`) reads it live, so there are no derived tables to refresh. */
2620
3317
  seq;
2621
3318
  childCtx;
2622
3319
  canvas;
@@ -2646,8 +3343,76 @@ var SequenceInstance = class {
2646
3343
  */
2647
3344
  clockOffset = 0;
2648
3345
  releasedBelow = 0;
3346
+ /**
3347
+ * Set by a `sequence.segment` steer that targets segment 0 under `loop:
3348
+ * true`: the steer resets the clock to segment 0's own start (localT 0),
3349
+ * so `renderFrame`'s wrap check (which compares the raw, ever-increasing
3350
+ * clock against `timedTotal()`) can no longer recognize the jump as a
3351
+ * wrap. This flag lets the clicker's jump to 0 still count as one, so the
3352
+ * last segment's fade plays instead of a hard cut. Cleared once the wrap
3353
+ * window (if any) has passed.
3354
+ */
3355
+ pendingWrapFade = false;
3356
+ /**
3357
+ * Every non-`sequence.segment` delta this instance has been handed, last
3358
+ * wins per path, merged across `applyTrack` calls. Children are created
3359
+ * lazily and disposed on every segment switch, so a steer forwarded only
3360
+ * to the active child would evaporate at the next boundary — and a steer
3361
+ * to a path only a *later* segment owns (`bars.sprite.values` while the title
3362
+ * slide is up) would land nowhere. Re-applied to every child on creation
3363
+ * and folded into the morph endpoints; each child keeps the deltas that
3364
+ * resolve on it (see `applyRetainedDeltas`).
3365
+ */
3366
+ retainedDeltas = /* @__PURE__ */ new Map();
3367
+ /**
3368
+ * Every `bed.<path>` delta (prefix stripped, last wins per path). The bed
3369
+ * is never re-created, so these are applied to it once on arrival — the
3370
+ * map exists only so `hotSwapSequence` can put a steered bed colour back
3371
+ * after the bed takes its republished scene. Absent a swap it is never read.
3372
+ */
3373
+ retainedBedDeltas = /* @__PURE__ */ new Map();
3374
+ /** The outgoing segment while a `fade` runs; null otherwise. */
3375
+ fading = null;
3376
+ /**
3377
+ * The incoming segment while a `fade` runs over a `bed`. Children over a
3378
+ * bed are transparent, so a cross-fade needs both of them on canvases of
3379
+ * their own — the incoming composited at k, the outgoing at 1 − k. Without
3380
+ * a bed the incoming paints its own opaque ground on the shared surface and
3381
+ * this stays null.
3382
+ */
3383
+ fadingIn = null;
3384
+ /** False on the `basic`/`minimal` capability tiers — fade renders as cut. */
3385
+ fadeEnabled;
3386
+ /** The mount's tier, handed to every child and the bed. */
3387
+ capabilityTier;
3388
+ /**
3389
+ * The sequence's presentation pass when it — or any segment — declares a
3390
+ * `finish`: children and the bed draw into an offscreen scene canvas
3391
+ * (`childCtx.surface`), and every frame ends by copying it to the visible
3392
+ * surface with the finish screened over the copy — once per composed
3393
+ * frame, bed + segment + fade together. Null when no finish is declared
3394
+ * anywhere: children draw straight onto the visible surface, as before.
3395
+ */
3396
+ finishPass = null;
3397
+ /** False on the `basic`/`minimal` tiers — `finish.animate` renders as a static tile. */
3398
+ animateFinish;
3399
+ /**
3400
+ * The sequence's `bed`: one scene on the shared surface, drawn first every
3401
+ * frame at the sequence clock — WITHOUT `clockOffset`, so a
3402
+ * `sequence.segment` steer rewinds the segment and never the bed — and never
3403
+ * re-created. Segments render over it transparently. Null when absent.
3404
+ */
3405
+ bed;
2649
3406
  constructor(seq, ctx) {
2650
3407
  this.seq = seq;
3408
+ const { sequenceBaseT, capabilityTier, ...plainCtx } = ctx;
3409
+ this.capabilityTier = capabilityTier;
3410
+ this.fadeEnabled = capabilityTier !== "basic" && capabilityTier !== "minimal";
3411
+ if (seq.sync === "epoch" && typeof sequenceBaseT === "number" && Number.isFinite(sequenceBaseT)) {
3412
+ this.baseT = Math.max(0, sequenceBaseT);
3413
+ this.lastT = this.baseT;
3414
+ this.renderedT = this.baseT;
3415
+ }
2651
3416
  let surface = ctx.surface ?? null;
2652
3417
  let canvas = null;
2653
3418
  if (!surface && typeof document !== "undefined") {
@@ -2659,15 +3424,42 @@ var SequenceInstance = class {
2659
3424
  }
2660
3425
  this.canvas = canvas;
2661
3426
  this.seed = (seq.seed ?? ctx.seed ?? 0) >>> 0 || 1;
2662
- this.childCtx = { ...ctx, surface, reducedMotion: true };
3427
+ this.animateFinish = capabilityTier !== "basic" && capabilityTier !== "minimal";
3428
+ const wantsFinish = seq.finish !== void 0 || seq.segments.some((s) => s.scene.finish !== void 0);
3429
+ if (wantsFinish && surface) {
3430
+ const pass = createFinishPass(surface, this.seed);
3431
+ if (pass) {
3432
+ this.finishPass = pass;
3433
+ surface = createSceneCanvas(ctx.width, ctx.height);
3434
+ }
3435
+ }
3436
+ this.childCtx = { ...plainCtx, surface, reducedMotion: true };
3437
+ this.sizeVisible();
2663
3438
  this.children = new Array(seq.segments.length).fill(null);
3439
+ if (seq.bed) {
3440
+ this.bed = new SpecInstance(this.bedScene(seq.bed), this.childCtx, { capabilityTier, hostPresents: true });
3441
+ this.bed.setPaused(true);
3442
+ } else {
3443
+ this.bed = null;
3444
+ }
2664
3445
  this.paused = ctx.reducedMotion;
2665
- if (this.paused) this.renderFrame(0, this.seed);
3446
+ if (this.paused) this.renderFrame(this.baseT, this.seed);
2666
3447
  else this.start();
2667
3448
  }
2668
3449
  childSeed(index, fallback) {
2669
3450
  return this.seq.segments[index].scene.seed ?? this.seq.seed ?? fallback;
2670
3451
  }
3452
+ /**
3453
+ * The bed's seed follows the segment rule (own seed, else the sequence seed)
3454
+ * but offset by `maxSegments`, past every segment's `seed + index`, so a bed
3455
+ * and segment 0 never share an entity stream and adding a segment does not
3456
+ * re-seat the bed.
3457
+ */
3458
+ bedScene(bed) {
3459
+ if (bed.seed != null) return bed;
3460
+ if (this.seq.seed != null) return { ...bed, seed: this.seq.seed + LIMITS.maxSegments };
3461
+ return bed;
3462
+ }
2671
3463
  childScene(index) {
2672
3464
  const seg = this.seq.segments[index];
2673
3465
  if (seg.scene.seed != null) return seg.scene;
@@ -2676,29 +3468,132 @@ var SequenceInstance = class {
2676
3468
  }
2677
3469
  /** Whether the boundary from `from` to `from+1` should morph. */
2678
3470
  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);
3471
+ return canMorph(this.seq, from);
2684
3472
  }
2685
3473
  /** Walk back through consecutive morph boundaries to find the chain origin. */
2686
3474
  morphChainRoot(index) {
2687
- let i = index;
2688
- while (i > 0 && this.canMorph(i - 1)) i--;
2689
- return i;
3475
+ return morphChainRoot(this.seq, index);
2690
3476
  }
2691
3477
  morphDur(from) {
2692
3478
  const tr = this.seq.segments[from]?.transition;
2693
3479
  return tr?.type === "morph" ? tr.dur : 0;
2694
3480
  }
2695
- ensureChild(index) {
3481
+ /** Whether the morph out of `from` cross-fades its text (default `step`: strings switch at k > 0). */
3482
+ morphTextCrossfades(from) {
3483
+ const tr = this.seq.segments[from]?.transition;
3484
+ return tr?.type === "morph" && tr.text === "crossfade";
3485
+ }
3486
+ /**
3487
+ * The paint overrides for one crossfade frame: every text layer whose
3488
+ * words differ between the outgoing and incoming specs, painting the
3489
+ * outgoing words (in the lerped frame's paint — colour and alpha glide as
3490
+ * usual) at `1 − k` under the incoming at `k`. Null when nothing differs,
3491
+ * so a crossfade between same-worded twins is a plain morph frame.
3492
+ */
3493
+ crossfadeOverrides(specA, specB, lerped, k) {
3494
+ let map = null;
3495
+ for (let i = 0; i < lerped.layers.length; i++) {
3496
+ if (!textStringsDiffer(specA, specB, i)) continue;
3497
+ const from = specA.layers[i].sprite;
3498
+ const now = lerped.layers[i].sprite;
3499
+ const outgoing = from.kind === "text" && now.kind === "text" ? { ...now, strings: from.strings } : from.kind === "textBlock" && now.kind === "textBlock" ? { ...now, text: from.text } : now;
3500
+ if (outgoing === now) continue;
3501
+ (map ??= /* @__PURE__ */ new Map()).set(i, { outgoing, k });
3502
+ }
3503
+ return map;
3504
+ }
3505
+ /** Length of the fade out of segment `from`, or 0 (no fade, or a tier that plays it as cut). */
3506
+ fadeDur(from) {
3507
+ const tr = this.seq.segments[from]?.transition;
3508
+ return this.fadeEnabled && tr?.type === "fade" ? tr.dur : 0;
3509
+ }
3510
+ /** Sum of every timed segment — the lap length under `loop: true`. */
3511
+ timedTotal() {
3512
+ const last = this.seq.segments.length - 1;
3513
+ return segmentStart(this.seq, last) + (this.seq.segments[last].duration ?? 0);
3514
+ }
3515
+ releaseFading() {
3516
+ if (this.fading) {
3517
+ this.fading.child.dispose();
3518
+ this.fading = null;
3519
+ }
3520
+ if (this.fadingIn) {
3521
+ this.fadingIn.child.dispose();
3522
+ this.fadingIn = null;
3523
+ }
3524
+ }
3525
+ /**
3526
+ * A segment child on a canvas of its own. Like a morph it mounts on its
3527
+ * chain root's scene and seed (so its entities are the ones the viewer was
3528
+ * watching) and hot-swaps to the segment's spec; the retained track is
3529
+ * applied last. Transparent over a bed, opaque otherwise.
3530
+ */
3531
+ offscreenChild(index, root) {
3532
+ const canvas = typeof document !== "undefined" ? document.createElement("canvas") : new OffscreenCanvas(Math.max(1, this.childCtx.width), Math.max(1, this.childCtx.height));
3533
+ const child = new SpecInstance(this.childScene(root), { ...this.childCtx, surface: canvas }, { transparent: this.bed !== null, capabilityTier: this.capabilityTier, hostPresents: true });
3534
+ child.setPaused(true);
3535
+ if (root !== index) child.hotSwapSpec(this.childScene(index));
3536
+ if (this.retainedDeltas.size > 0) child.applyDeltasNow(this.retainedDeltas.values());
3537
+ return { index, child, canvas };
3538
+ }
3539
+ /** Render an offscreen child; a transparent one never clears, so its owner does here. */
3540
+ renderOffscreen(oc, t, seed) {
3541
+ if (this.bed) {
3542
+ const c = oc.canvas.getContext("2d");
3543
+ c?.clearRect(0, 0, this.childCtx.width, this.childCtx.height);
3544
+ }
3545
+ oc.child.renderFrame(t, seed);
3546
+ }
3547
+ /** Draw an offscreen canvas onto the shared surface at `alpha`. */
3548
+ composite(canvas, alpha) {
3549
+ const main = this.childCtx.surface?.getContext("2d");
3550
+ if (!main) return;
3551
+ main.save();
3552
+ main.globalAlpha = alpha;
3553
+ main.globalCompositeOperation = "source-over";
3554
+ main.drawImage(canvas, 0, 0, this.childCtx.width, this.childCtx.height);
3555
+ main.restore();
3556
+ }
3557
+ /**
3558
+ * Render the outgoing segment `from` of a live fade onto its own canvas. It
3559
+ * keeps animating at `duration(from) + localT` (it does not freeze — and if
3560
+ * it was an `advance: 'input'` hold it resumes from its duration, not from
3561
+ * wherever the hold had reached). Two live segments for `dur` only; the
3562
+ * outgoing one is disposed when the fade ends.
3563
+ */
3564
+ renderOutgoing(from, localT, seed) {
3565
+ if (this.fading && this.fading.index !== from) {
3566
+ this.fading.child.dispose();
3567
+ this.fading = null;
3568
+ }
3569
+ if (!this.fading) this.fading = this.offscreenChild(from, this.morphChainRoot(from));
3570
+ const outDur = this.seq.segments[from].duration ?? 0;
3571
+ this.renderOffscreen(this.fading, outDur + localT, this.childSeed(this.morphChainRoot(from), seed));
3572
+ return this.fading;
3573
+ }
3574
+ /** A segment's scene with the retained track applied — `scene` itself when nothing resolves on it. */
3575
+ steeredScene(scene) {
3576
+ return this.retainedDeltas.size === 0 ? scene : applyRetainedDeltas(scene, this.retainedDeltas.values());
3577
+ }
3578
+ /**
3579
+ * The child for segment `index`, created paused on the shared surface if
3580
+ * the slot is empty. `rootScene` (a morph chain's root) mounts the child
3581
+ * with the root's scene so its seed and entity placement are continuous
3582
+ * with the chain, then hot-swaps to the segment's own spec. Either way the
3583
+ * retained track is applied last, so a steer made while another segment
3584
+ * was up is already in effect on this child's first frame. `skipInitialPaint`
3585
+ * is for a caller (the morph branch) that is about to render a real frame,
3586
+ * paint overrides included, in this same call — see `SpecInstanceOptions`.
3587
+ */
3588
+ ensureChild(index, rootScene, skipInitialPaint) {
2696
3589
  if (index < 0 || index >= this.seq.segments.length) index = 0;
2697
3590
  let child = this.children[index];
2698
3591
  if (!child) {
2699
- child = new SpecInstance(this.childScene(index), this.childCtx);
3592
+ child = new SpecInstance(rootScene ?? this.childScene(index), this.childCtx, { transparent: this.bed !== null, skipInitialPaint, capabilityTier: this.capabilityTier, hostPresents: true });
2700
3593
  this.children[index] = child;
2701
3594
  child.setPaused(true);
3595
+ if (rootScene) child.hotSwapSpec(this.childScene(index));
3596
+ if (this.retainedDeltas.size > 0) child.applyDeltasNow(this.retainedDeltas.values());
2702
3597
  }
2703
3598
  return child;
2704
3599
  }
@@ -2727,12 +3622,61 @@ var SequenceInstance = class {
2727
3622
  this.lastT = now - this.startT + this.baseT;
2728
3623
  this.renderFrame(this.lastT, this.seed);
2729
3624
  }
3625
+ /**
3626
+ * With a presentation pass, size the visible surface like a child sizes
3627
+ * the scene canvas (children never touch the visible surface then).
3628
+ */
3629
+ sizeVisible() {
3630
+ const fp = this.finishPass;
3631
+ if (!fp) return;
3632
+ const dpr = Math.min(this.childCtx.dpr, 2);
3633
+ fp.canvas.width = Math.max(1, Math.round(this.childCtx.width * dpr));
3634
+ fp.canvas.height = Math.max(1, Math.round(this.childCtx.height * dpr));
3635
+ fp.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
3636
+ }
3637
+ /**
3638
+ * The finish this frame applies: the sequence's own overrides; else the
3639
+ * active segment's (the incoming one during a fade) — read through
3640
+ * `steeredScene` so a retained steer to `finish.grain`/`finish.dither`
3641
+ * takes effect immediately instead of the authored value; a bed's is
3642
+ * ignored.
3643
+ */
3644
+ frameFinish() {
3645
+ if (this.seq.finish !== void 0) return this.seq.finish;
3646
+ if (this.activeIndex < 0) return void 0;
3647
+ return this.steeredScene(this.childScene(this.activeIndex)).finish;
3648
+ }
3649
+ /**
3650
+ * The seed `frameFinish()`'s grain resolves against — the sequence's own
3651
+ * for a sequence-level finish, else the active segment's actual render
3652
+ * seed (`segmentRenderSeed`: chain-root-aware and normalized exactly like
3653
+ * `SpecInstance`, so it agrees with what that segment's entities render
3654
+ * with, morph or not).
3655
+ */
3656
+ frameFinishSeed() {
3657
+ if (this.seq.finish !== void 0) return this.seed;
3658
+ if (this.activeIndex < 0) return this.seed;
3659
+ return segmentRenderSeed(this.seq, this.activeIndex) ?? this.seed;
3660
+ }
2730
3661
  renderFrame(T, seed) {
3662
+ this.renderScene(T, seed);
3663
+ if (this.finishPass) {
3664
+ presentWithFinish(this.finishPass, this.childCtx.surface, this.childCtx.width, this.childCtx.height, T, this.frameFinish(), this.animateFinish, this.frameFinishSeed());
3665
+ }
3666
+ }
3667
+ renderScene(T, seed) {
2731
3668
  this.renderedT = T;
2732
3669
  const resolved = resolveSegment(this.seq, T + this.clockOffset, { releasedBelow: this.releasedBelow });
2733
3670
  const { index, localT } = resolved;
3671
+ this.bed?.renderFrame(T, this.seq.bed?.seed ?? this.seq.seed ?? seed);
2734
3672
  const prevIdx = index > 0 ? index - 1 : -1;
2735
3673
  const morphActive = prevIdx >= 0 && this.canMorph(prevIdx) && localT < this.morphDur(prevIdx);
3674
+ const lastIdx = this.seq.segments.length - 1;
3675
+ const wrapped = this.seq.loop && index === 0 && lastIdx > 0 && (T + this.clockOffset >= this.timedTotal() || this.pendingWrapFade);
3676
+ const fadeFrom = prevIdx >= 0 ? prevIdx : wrapped ? lastIdx : -1;
3677
+ const fadeActive = fadeFrom >= 0 && localT < this.fadeDur(fadeFrom);
3678
+ if (!fadeActive && this.fading) this.releaseFading();
3679
+ if (this.pendingWrapFade && (index !== 0 || !fadeActive)) this.pendingWrapFade = false;
2736
3680
  if (morphActive) {
2737
3681
  const chainRoot = this.morphChainRoot(index);
2738
3682
  this.morphFromIndex = prevIdx;
@@ -2740,12 +3684,14 @@ var SequenceInstance = class {
2740
3684
  for (let i = 0; i < this.children.length; i++) {
2741
3685
  if (i !== chainRoot) this.releaseChild(i);
2742
3686
  }
2743
- const child = this.ensureChild(chainRoot);
3687
+ const child = this.ensureChild(chainRoot, void 0, this.morphTextCrossfades(prevIdx));
2744
3688
  const dur = this.morphDur(prevIdx);
2745
3689
  const k = easeSmooth(localT / dur);
2746
- const specA = this.childScene(prevIdx);
2747
- const specB = this.childScene(index);
2748
- child.hotSwapPaint(lerpSpec(specA, specB, k));
3690
+ const specA = this.steeredScene(this.childScene(prevIdx));
3691
+ const specB = this.steeredScene(this.childScene(index));
3692
+ const lerped = lerpSpec(specA, specB, k);
3693
+ child.hotSwapSpec(lerped);
3694
+ child.setLayerPaintOverrides(this.morphTextCrossfades(prevIdx) ? this.crossfadeOverrides(specA, specB, lerped, k) : null);
2749
3695
  child.renderFrame(localT, this.childSeed(chainRoot, seed));
2750
3696
  } else {
2751
3697
  if (this.morphFromIndex >= 0) {
@@ -2763,17 +3709,25 @@ var SequenceInstance = class {
2763
3709
  const chainRoot = this.morphChainRoot(index);
2764
3710
  const useMorphSeed = chainRoot < index;
2765
3711
  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);
3712
+ if (fadeActive && this.bed) {
3713
+ const k = easeSmooth(localT / this.fadeDur(fadeFrom));
3714
+ if (this.fadingIn && this.fadingIn.index !== index) {
3715
+ this.fadingIn.child.dispose();
3716
+ this.fadingIn = null;
3717
+ }
3718
+ if (!this.fadingIn) this.fadingIn = this.offscreenChild(index, useMorphSeed ? chainRoot : index);
3719
+ this.renderOffscreen(this.fadingIn, localT, effSeed);
3720
+ const out = this.renderOutgoing(fadeFrom, localT, seed);
3721
+ this.composite(this.fadingIn.canvas, k);
3722
+ this.composite(out.canvas, 1 - k);
3723
+ return;
2775
3724
  }
3725
+ const child = useMorphSeed ? this.ensureChild(index, this.childScene(chainRoot)) : this.ensureChild(index);
2776
3726
  child.renderFrame(localT, effSeed);
3727
+ if (fadeActive) {
3728
+ const out = this.renderOutgoing(fadeFrom, localT, seed);
3729
+ this.composite(out.canvas, 1 - easeSmooth(localT / this.fadeDur(fadeFrom)));
3730
+ }
2777
3731
  }
2778
3732
  }
2779
3733
  setPaused(paused) {
@@ -2783,43 +3737,105 @@ var SequenceInstance = class {
2783
3737
  for (const child of this.children) {
2784
3738
  child?.setPaused(true);
2785
3739
  }
3740
+ this.fading?.child.setPaused(true);
3741
+ this.fadingIn?.child.setPaused(true);
3742
+ this.bed?.setPaused(true);
2786
3743
  }
2787
3744
  resize(width, height, dpr) {
2788
3745
  this.childCtx.width = width;
2789
3746
  this.childCtx.height = height;
2790
3747
  if (dpr !== void 0) this.childCtx.dpr = dpr;
3748
+ this.sizeVisible();
2791
3749
  for (const child of this.children) {
2792
3750
  child?.resize(width, height, dpr);
2793
3751
  }
3752
+ this.fading?.child.resize(width, height, dpr);
3753
+ this.fadingIn?.child.resize(width, height, dpr);
3754
+ this.bed?.resize(width, height, dpr);
2794
3755
  }
2795
3756
  applyTrack(track) {
2796
- const deltas = track?.deltas ?? [];
3757
+ const all = track?.deltas ?? [];
3758
+ const isBedPath = (d) => this.bed !== null && typeof d.path === "string" && d.path.startsWith(BED_PREFIX);
3759
+ const bedDeltas = all.filter(isBedPath).map((d) => ({ ...d, path: d.path.slice(BED_PREFIX.length) }));
3760
+ if (bedDeltas.length > 0) {
3761
+ for (const d of bedDeltas) this.retainedBedDeltas.set(d.path, d);
3762
+ this.bed.applyTrack({ ...track, deltas: bedDeltas });
3763
+ }
3764
+ const deltas = all.filter((d) => !isBedPath(d));
3765
+ for (const d of deltas) {
3766
+ if (d.path !== "sequence.segment" && typeof d.path === "string") this.retainedDeltas.set(d.path, d);
3767
+ }
2797
3768
  const segDelta = deltas.find((d) => d.path === "sequence.segment");
3769
+ let switchedSegment = false;
2798
3770
  if (segDelta !== void 0 && typeof segDelta.value === "number") {
2799
3771
  const idx = Math.max(0, Math.min(this.seq.segments.length - 1, Math.round(segDelta.value)));
3772
+ this.pendingWrapFade = this.seq.loop && idx === 0 && this.seq.segments.length > 1;
2800
3773
  this.clockOffset = segmentStart(this.seq, idx) - this.renderedT;
2801
3774
  this.releasedBelow = idx;
2802
3775
  this.renderFrame(this.renderedT, this.seed);
3776
+ switchedSegment = true;
2803
3777
  }
2804
3778
  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 });
3779
+ if (childDeltas.length > 0) {
3780
+ if (this.morphFromIndex >= 0) {
3781
+ if (!switchedSegment) this.renderFrame(this.renderedT, this.seed);
3782
+ } else if (this.activeIndex >= 0) {
3783
+ const child = this.children[this.activeIndex];
3784
+ child?.applyTrack({ ...track, deltas: childDeltas });
3785
+ }
3786
+ this.fading?.child.applyTrack({ ...track, deltas: childDeltas });
3787
+ this.fadingIn?.child.applyTrack({ ...track, deltas: childDeltas });
2808
3788
  }
2809
3789
  }
3790
+ /**
3791
+ * See `SequenceSaverInstance.hotSwapSequence`. Nothing that positions the
3792
+ * show is touched: `clockOffset`, `releasedBelow`, `activeIndex`,
3793
+ * `morphFromIndex`, `pendingWrapFade`, `renderedT` and the retained deltas
3794
+ * all keep their values, and the timing terms `sequenceSwapCompatible`
3795
+ * pins (durations, advance, transitions, loop) are the same on both sides,
3796
+ * so `resolveSegment` at the next frame lands where it would have anyway.
3797
+ * Each live child takes its new scene with the retained track re-applied
3798
+ * (`steeredScene` — the same routing a fresh child gets), so steered paint
3799
+ * survives the swap; `hotSwapSpec` keeps the entities (the signatures
3800
+ * match) and does not reset ghosting contiguity, so a drifting entity is
3801
+ * where it was 1 ms ago. Children are always paused, so none of them has a
3802
+ * glide in flight for the swap to interrupt (`SpecInstance.applyTrack`
3803
+ * snaps when paused). A morph in progress is untouched here: its chain-root
3804
+ * child is re-lerped from the new endpoints on the very next frame.
3805
+ */
3806
+ hotSwapSequence(next) {
3807
+ if (!sequenceSwapCompatible(this.seq, next)) return false;
3808
+ this.seq = next;
3809
+ if (this.bed && next.bed) this.bed.hotSwapSpec(applyRetainedDeltas(this.bedScene(next.bed), this.retainedBedDeltas.values()));
3810
+ for (let i = 0; i < this.children.length; i++) {
3811
+ this.children[i]?.hotSwapSpec(this.steeredScene(this.childScene(i)));
3812
+ }
3813
+ if (this.fading) this.fading.child.hotSwapSpec(this.steeredScene(this.childScene(this.fading.index)));
3814
+ if (this.fadingIn) this.fadingIn.child.hotSwapSpec(this.steeredScene(this.childScene(this.fadingIn.index)));
3815
+ if (this.paused) this.renderFrame(this.renderedT, this.seed);
3816
+ return true;
3817
+ }
2810
3818
  dispose() {
2811
3819
  this.stop();
3820
+ this.releaseFading();
2812
3821
  for (let i = 0; i < this.children.length; i++) {
2813
3822
  this.releaseChild(i);
2814
3823
  }
3824
+ this.bed?.dispose();
3825
+ if (this.finishPass) {
3826
+ this.finishPass.grain = null;
3827
+ this.finishPass.dither = null;
3828
+ }
2815
3829
  if (this.canvas) this.canvas.remove();
2816
3830
  }
2817
3831
  };
2818
3832
  function sequenceManifest(seq) {
2819
- const maxTotal = seq.segments.reduce((max, s) => {
3833
+ const maxSegment = seq.segments.reduce((max, s) => {
2820
3834
  const t = s.scene.layers.reduce((n, l) => n + l.count, 0);
2821
3835
  return Math.max(max, t);
2822
3836
  }, 0);
3837
+ const bedTotal = seq.bed ? seq.bed.layers.reduce((n, l) => n + l.count, 0) : 0;
3838
+ const maxTotal = bedTotal + maxSegment;
2823
3839
  const costTier = maxTotal < 30 ? "idle" : maxTotal < 150 ? "low" : maxTotal < 400 ? "medium" : "high";
2824
3840
  return {
2825
3841
  id: seq.id,
@@ -2847,6 +3863,14 @@ function compileSequence(spec) {
2847
3863
  import { createRng as createRng2 } from "@idle-screens/core";
2848
3864
 
2849
3865
  // src/luma.ts
3866
+ function fieldRenderedRgbs(bg) {
3867
+ const n = bg.bands.length;
3868
+ const q = Math.round(bg.quantize ?? n);
3869
+ if (q < 2) return bg.bands.map((hex) => hexToRgb255(hex));
3870
+ const out = [];
3871
+ for (let level = 0; level < q; level++) out.push(fieldRgb((level + 0.5) / q, bg.bands, q));
3872
+ return out;
3873
+ }
2850
3874
  function hexLuma(hex) {
2851
3875
  const h = hex.length === 4 ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}` : hex;
2852
3876
  const n = parseInt(h.slice(1), 16);
@@ -2875,6 +3899,10 @@ function spriteHex(layer, e) {
2875
3899
  function backgroundLuma(spec) {
2876
3900
  const bg = spec.background;
2877
3901
  if (!bg || bg.type === "solid") return hexLuma(bg?.color ?? "#05050a");
3902
+ if (bg.type === "field") {
3903
+ const rgbs = fieldRenderedRgbs(bg);
3904
+ return rgbs.reduce((s, c) => s + rgb255Luma(c), 0) / rgbs.length;
3905
+ }
2878
3906
  return bg.stops.reduce((s, st) => s + hexLuma(st.color), 0) / bg.stops.length;
2879
3907
  }
2880
3908
  function hexRgb(hex) {
@@ -2886,14 +3914,20 @@ function hexRgb(hex) {
2886
3914
  function backgroundRgb(spec) {
2887
3915
  const bg = spec.background;
2888
3916
  if (!bg || bg.type === "solid") return hexRgb(bg?.color ?? "#05050a");
2889
- const acc = bg.stops.reduce(
2890
- (s, st) => {
2891
- const c = hexRgb(st.color);
3917
+ if (bg.type === "field") {
3918
+ const rgbs = fieldRenderedRgbs(bg);
3919
+ const acc2 = rgbs.reduce((s, c) => ({ r: s.r + c[0], g: s.g + c[1], b: s.b + c[2] }), { r: 0, g: 0, b: 0 });
3920
+ return { r: acc2.r / rgbs.length / 255, g: acc2.g / rgbs.length / 255, b: acc2.b / rgbs.length / 255 };
3921
+ }
3922
+ const colours = bg.stops.map((st) => st.color);
3923
+ const acc = colours.reduce(
3924
+ (s, hex) => {
3925
+ const c = hexRgb(hex);
2892
3926
  return { r: s.r + c.r, g: s.g + c.g, b: s.b + c.b };
2893
3927
  },
2894
3928
  { r: 0, g: 0, b: 0 }
2895
3929
  );
2896
- const n = bg.stops.length || 1;
3930
+ const n = colours.length || 1;
2897
3931
  return { r: acc.r / n, g: acc.g / n, b: acc.b / n };
2898
3932
  }
2899
3933
  function colourSeparation(layer, sprite, bg) {
@@ -2903,6 +3937,64 @@ function colourSeparation(layer, sprite, bg) {
2903
3937
  const db = sprite.rgb.b - bg.rgb.b;
2904
3938
  return Math.sqrt((dr * dr + dg * dg + db * db) / 3);
2905
3939
  }
3940
+ function srgbToLinear(c) {
3941
+ return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
3942
+ }
3943
+ function relativeLuminance(rgb) {
3944
+ return 0.2126 * srgbToLinear(rgb.r) + 0.7152 * srgbToLinear(rgb.g) + 0.0722 * srgbToLinear(rgb.b);
3945
+ }
3946
+ function legibilityRatio(a, b) {
3947
+ const la = relativeLuminance(a);
3948
+ const lb = relativeLuminance(b);
3949
+ const hi = Math.max(la, lb);
3950
+ const lo = Math.min(la, lb);
3951
+ return (hi + 0.05) / (lo + 0.05);
3952
+ }
3953
+ function backgroundRgbAt(spec, yPx, h, unit, xPx, w, seed) {
3954
+ const bg = spec.background;
3955
+ if (!bg || bg.type === "solid") return hexRgb(bg?.color ?? "#05050a");
3956
+ if (bg.type === "field") {
3957
+ if (xPx === void 0 || w === void 0) return backgroundRgb(spec);
3958
+ const short = Math.max(1, Math.min(w, h));
3959
+ const c = fieldRgbAt(xPx / short, yPx / short, 0, bg, seed ?? spec.seed ?? 42);
3960
+ return { r: c[0] / 255, g: c[1] / 255, b: c[2] / 255 };
3961
+ }
3962
+ if (bg.band) {
3963
+ const bh = bg.band.height * unit;
3964
+ if (yPx >= h - bh) return hexRgb(bg.band.color);
3965
+ }
3966
+ const stops = [...bg.stops].sort((a, b) => a.at - b.at);
3967
+ const y = h > 0 ? Math.max(0, Math.min(1, yPx / h)) : 0;
3968
+ const first = stops[0];
3969
+ const last = stops[stops.length - 1];
3970
+ if (y <= first.at) return hexRgb(first.color);
3971
+ if (y >= last.at) return hexRgb(last.color);
3972
+ for (let i = 1; i < stops.length; i++) {
3973
+ const a = stops[i - 1];
3974
+ const b = stops[i];
3975
+ if (y > b.at) continue;
3976
+ const span = b.at - a.at;
3977
+ const k = span > 0 ? (y - a.at) / span : 1;
3978
+ const ca = hexRgb(a.color);
3979
+ const cb = hexRgb(b.color);
3980
+ 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 };
3981
+ }
3982
+ return hexRgb(last.color);
3983
+ }
3984
+ function sourceOverPlate(ground, colour, alpha) {
3985
+ return {
3986
+ r: ground.r + (colour.r - ground.r) * alpha,
3987
+ g: ground.g + (colour.g - ground.g) * alpha,
3988
+ b: ground.b + (colour.b - ground.b) * alpha
3989
+ };
3990
+ }
3991
+ function additivePlate(ground, colour, alpha, blend) {
3992
+ const mix = (g, c) => {
3993
+ const v = c * alpha;
3994
+ return blend === "lighter" ? Math.min(1, g + v) : 1 - (1 - g) * (1 - v);
3995
+ };
3996
+ return { r: mix(ground.r, colour.r), g: mix(ground.g, colour.g), b: mix(ground.b, colour.b) };
3997
+ }
2906
3998
 
2907
3999
  // src/cohesion.ts
2908
4000
  var MERGE_FLOOR = 0.35;
@@ -3114,6 +4206,8 @@ function seamsWorthWarning(layer, c, entityCount) {
3114
4206
  // src/advise.ts
3115
4207
  var LOW_CONTRAST_FLOOR = 0.05;
3116
4208
  var SPARSE_DECLARED_MAX_COVERAGE = 0.02;
4209
+ var READ_LEGIBILITY_FLOOR = 4.5;
4210
+ var READ_SAFE_AREA = 0.05;
3117
4211
  function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
3118
4212
  const warnings = [];
3119
4213
  const w = viewport.width;
@@ -3218,8 +4312,8 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
3218
4312
  const fsPx = layer.sprite.fontSize * scale;
3219
4313
  const lh = (layer.sprite.lineHeight ?? 1.4) * fsPx;
3220
4314
  const maxWPx = layer.sprite.maxWidth * scale;
3221
- const lines = breakTextBlock(layer.sprite.text, maxWPx / fsPx);
3222
- pixArea = maxWPx * lines.length * lh * 0.55;
4315
+ const lines = breakTextBlock(layer.sprite.text, maxWPx / fsPx, textMetricsClassFor(layer.sprite.font));
4316
+ pixArea = maxWPx * lines.length * lh * 0.55 * (layer.sprite.opacity ?? 1);
3223
4317
  } else {
3224
4318
  pixArea = e.size * e.size;
3225
4319
  }
@@ -3339,9 +4433,16 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
3339
4433
  for (const e of allEntities[li]) {
3340
4434
  const p = positionAt(e, 0, w, h);
3341
4435
  const box = s.kind === "textBlock" ? textBlockBoxAt(s, p, w, h) : textBoxAt(s, e, p, spec, w, h);
3342
- textBoxes.push({ li, label, ...box });
4436
+ const alpha = Math.max(0, Math.min(1, e.alpha - e.pulseAmp)) * (s.kind === "textBlock" ? s.opacity ?? 1 : 1);
4437
+ textBoxes.push({ li, label, alpha, ...box });
3343
4438
  }
3344
4439
  }
4440
+ const asFraction = (b) => ({
4441
+ x: +(b.x0 / w).toFixed(4),
4442
+ y: +(b.y0 / h).toFixed(4),
4443
+ w: +((b.x1 - b.x0) / w).toFixed(4),
4444
+ h: +((b.y1 - b.y0) / h).toFixed(4)
4445
+ });
3345
4446
  const tolX = w * 0.01;
3346
4447
  const tolY = h * 0.01;
3347
4448
  const offFlagged = /* @__PURE__ */ new Set();
@@ -3380,12 +4481,110 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
3380
4481
  warnings.push({
3381
4482
  path: `layers[${first.li}]`,
3382
4483
  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`
4484
+ 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`,
4485
+ // The two boxes, so an agent can move one without re-deriving the
4486
+ // layout. Additive: the message is unchanged.
4487
+ boxes: [asFraction(first), asFraction(second)]
4488
+ });
4489
+ }
4490
+ }
4491
+ const readFlagged = /* @__PURE__ */ new Set();
4492
+ for (const b of textBoxes) {
4493
+ if (readFlagged.has(b.li)) continue;
4494
+ const layer = spec.layers[b.li];
4495
+ const s = layer.sprite;
4496
+ if (s.kind !== "text" && s.kind !== "textBlock" || s.role !== "read") continue;
4497
+ const boxes = textBoxes.filter((x) => x.li === b.li);
4498
+ readFlagged.add(b.li);
4499
+ const ink = hexRgb(s.color ?? "#e6e8ef");
4500
+ const textPlate = (base, alpha) => {
4501
+ if (layer.blend === "lighter" || layer.blend === "screen") return additivePlate(base, ink, alpha, layer.blend);
4502
+ if (layer.blend === "multiply") {
4503
+ return {
4504
+ r: base.r * (1 - alpha + ink.r * alpha),
4505
+ g: base.g * (1 - alpha + ink.g * alpha),
4506
+ b: base.b * (1 - alpha + ink.b * alpha)
4507
+ };
4508
+ }
4509
+ return sourceOverPlate(base, ink, alpha);
4510
+ };
4511
+ let worst = null;
4512
+ for (const box of boxes) {
4513
+ const cy = (box.y0 + box.y1) / 2;
4514
+ const cx = (box.x0 + box.x1) / 2;
4515
+ const groundRgb = backgroundRgbAt(spec, cy, h, scale, cx, w, opts.backgroundSeed ?? opts.seed);
4516
+ const ground = legibilityRatio(textPlate(groundRgb, box.alpha), groundRgb);
4517
+ const lit = brightestAdditivePlate(spec, allEntities, groundRgb, box, w, h);
4518
+ const plate = lit ? legibilityRatio(textPlate(lit.rgb, box.alpha), lit.rgb) : Infinity;
4519
+ if (!worst || Math.min(ground, plate) < Math.min(worst.ground, worst.plate)) {
4520
+ worst = { ground, plate, plateLabel: lit?.label ?? null };
4521
+ }
4522
+ }
4523
+ if (worst && Math.min(worst.ground, worst.plate) < READ_LEGIBILITY_FLOOR) {
4524
+ const under = worst.plateLabel ? ` and ${worst.plate.toFixed(1)}:1 under ${worst.plateLabel} at its brightest` : "";
4525
+ warnings.push({
4526
+ path: `layers[${b.li}].sprite`,
4527
+ code: "text-legibility",
4528
+ 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`
4529
+ });
4530
+ }
4531
+ const edges = [];
4532
+ const x0 = Math.min(...boxes.map((x) => x.x0));
4533
+ const x1 = Math.max(...boxes.map((x) => x.x1));
4534
+ const y0 = Math.min(...boxes.map((x) => x.y0));
4535
+ const y1 = Math.max(...boxes.map((x) => x.y1));
4536
+ if (x0 < w * READ_SAFE_AREA) edges.push("left");
4537
+ if (x1 > w * (1 - READ_SAFE_AREA)) edges.push("right");
4538
+ if (y0 < h * READ_SAFE_AREA) edges.push("top");
4539
+ if (y1 > h * (1 - READ_SAFE_AREA)) edges.push("bottom");
4540
+ if (edges.length > 0) {
4541
+ warnings.push({
4542
+ path: `layers[${b.li}]`,
4543
+ code: "text-safe-area",
4544
+ 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`,
4545
+ boxes: [asFraction({ x0, y0, x1, y1 })]
3384
4546
  });
3385
4547
  }
3386
4548
  }
3387
4549
  return warnings;
3388
4550
  }
4551
+ function brightestAdditivePlate(spec, allEntities, ground, box, w, h) {
4552
+ let best = null;
4553
+ for (let lj = 0; lj < spec.layers.length; lj++) {
4554
+ const layer = spec.layers[lj];
4555
+ const blend = layer.blend;
4556
+ if (blend !== "lighter" && blend !== "screen") continue;
4557
+ if (lj === box.li) continue;
4558
+ const entities = allEntities[lj];
4559
+ for (const e of entities) {
4560
+ const hex = spriteHex(layer, e);
4561
+ if (hex === null) continue;
4562
+ const growScale = (e.motion === "warp" ? WARP_MAX_SCALE : 1) * (e.emit ? Math.max(e.emit.growFrom, e.emit.growTo) : 1);
4563
+ const maxDim = Math.max(e.size, e.size2 ?? 0) * (1 + e.growAmp) * growScale;
4564
+ if (maxDim < box.fs) continue;
4565
+ if (!entityReachesBox(e, maxDim / 2, box, w, h)) continue;
4566
+ const a = Math.min(1, e.alpha + e.pulseAmp);
4567
+ if (a <= 0) continue;
4568
+ const plate = additivePlate(ground, hexRgb(hex), a, blend);
4569
+ const lum = relativeLuminance(plate);
4570
+ if (!best || lum > best.lum) {
4571
+ best = { rgb: plate, label: layer.key ? `\`${layer.key}\`` : `layers[${lj}]`, lum };
4572
+ }
4573
+ }
4574
+ }
4575
+ return best ? { rgb: best.rgb, label: best.label } : null;
4576
+ }
4577
+ function entityReachesBox(e, r, box, w, h) {
4578
+ if (e.motion === "static") {
4579
+ const p = positionAt(e, 0, w, h);
4580
+ return p.x + r > box.x0 && p.x - r < box.x1 && p.y + r > box.y0 && p.y - r < box.y1;
4581
+ }
4582
+ if (e.motion === "orbit" && !e.orbitParent) {
4583
+ const span = e.orbitR + r;
4584
+ return e.orbitCx + span > box.x0 && e.orbitCx - span < box.x1 && e.orbitCy + span > box.y0 && e.orbitCy - span < box.y1;
4585
+ }
4586
+ return true;
4587
+ }
3389
4588
  function textBoxAt(s, e, p, spec, w, h) {
3390
4589
  const m = s.font ? /(\d{1,5}(?:\.\d{1,4})?|\.\d{1,4})px/.exec(s.font) : null;
3391
4590
  const fontScale = spec.units === "px" ? 1 : Math.min(w, h) / (spec.referenceViewport ?? LIMITS.referenceViewport);
@@ -3397,28 +4596,39 @@ function textBoxAt(s, e, p, spec, w, h) {
3397
4596
  const baseline = s.baseline ?? "middle";
3398
4597
  const x0 = align === "left" ? p.x : align === "right" ? p.x - fw : p.x - fw / 2;
3399
4598
  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 };
4599
+ return { x0, y0, x1: x0 + fw, y1: y0 + fh, fs: fh };
3401
4600
  }
3402
4601
  function textBlockBoxAt(s, p, w, h) {
3403
4602
  const unit = Math.min(w, h);
3404
4603
  const fsPx = s.fontSize * unit;
3405
4604
  const lh = (s.lineHeight ?? 1.4) * fsPx;
3406
4605
  const maxWPx = s.maxWidth * unit;
3407
- const lines = breakTextBlock(s.text, maxWPx / fsPx);
4606
+ const lines = breakTextBlock(s.text, maxWPx / fsPx, textMetricsClassFor(s.font));
3408
4607
  const maxLineW = lines.reduce((mx, l) => Math.max(mx, l.widthEm), 0) * fsPx;
3409
4608
  const totalH = lines.length * lh;
3410
4609
  const align = s.align ?? "left";
3411
4610
  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 };
4611
+ const { dx, dy } = textBlockAnchorOffset(s, maxWPx, maxLineW, totalH);
4612
+ return { x0: x0 + dx, y0: p.y + dy, x1: x0 + dx + maxLineW, y1: p.y + dy + totalH, fs: fsPx };
3413
4613
  }
3414
4614
  function adviseSequence(seq, viewport = { width: 1920, height: 1080 }) {
3415
4615
  const warnings = [];
3416
4616
  for (let i = 0; i < seq.segments.length; i++) {
3417
4617
  const segWarnings = adviseSpec(seq.segments[i].scene, viewport);
3418
4618
  for (const w of segWarnings) {
3419
- warnings.push({ path: `segments[${i}].scene.${w.path}`, code: w.code, message: w.message });
4619
+ warnings.push({ path: `segments[${i}].scene.${w.path}`, code: w.code, message: w.message, boxes: w.boxes });
3420
4620
  }
3421
4621
  }
4622
+ const fadeBound = seq.loop ? seq.segments.length : seq.segments.length - 1;
4623
+ const fades = seq.segments.slice(0, fadeBound).filter((s) => s.transition?.type === "fade").length;
4624
+ if (fades > 0) {
4625
+ const first = seq.segments.slice(0, fadeBound).findIndex((s) => s.transition?.type === "fade");
4626
+ warnings.push({
4627
+ path: `segments[${first}].transition`,
4628
+ code: "fade-degrades-on-low-tier",
4629
+ 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`
4630
+ });
4631
+ }
3422
4632
  for (let i = 0; i < seq.segments.length - 1; i++) {
3423
4633
  const lumaA = backgroundLuma(seq.segments[i].scene);
3424
4634
  const lumaB = backgroundLuma(seq.segments[i + 1].scene);
@@ -3440,6 +4650,12 @@ function adviseSequence(seq, viewport = { width: 1920, height: 1080 }) {
3440
4650
  code: "morph-structural-mismatch",
3441
4651
  message: `segments ${i}\u2192${i + 1} differ structurally: morph will fall back to cut`
3442
4652
  });
4653
+ } else if (morphNothingMorphable(seq.segments[i].scene, seq.segments[i + 1].scene, { textCrossfade: tr.text === "crossfade" })) {
4654
+ warnings.push({
4655
+ path: `segments[${i}].transition`,
4656
+ code: "morph-nothing-morphable",
4657
+ 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`
4658
+ });
3443
4659
  }
3444
4660
  }
3445
4661
  }
@@ -3534,7 +4750,7 @@ function describeScene(spec, options = {}) {
3534
4750
  return { t, viewport: { w, h }, layers: layerSnapshots, warnings };
3535
4751
  });
3536
4752
  return {
3537
- spec: { id: spec.id, label: spec.label, units: spec.units ?? "px" },
4753
+ spec: { id: spec.id, label: spec.label, units: spec.units ?? "px", ...spec.finish ? { finish: spec.finish } : {} },
3538
4754
  snapshots
3539
4755
  };
3540
4756
  }
@@ -3592,14 +4808,15 @@ function textBlockBox(s, _e, p, w, h) {
3592
4808
  const lh = (s.lineHeight ?? 1.4) * fsPx;
3593
4809
  const maxWPx = s.maxWidth * unitScale;
3594
4810
  const maxWEm = maxWPx / fsPx;
3595
- const lines = breakTextBlock(s.text, maxWEm);
4811
+ const lines = breakTextBlock(s.text, maxWEm, textMetricsClassFor(s.font));
3596
4812
  const totalH = lines.length * lh;
3597
4813
  const maxLineW = lines.reduce((m, l) => Math.max(m, l.widthEm), 0) * fsPx;
3598
4814
  const align = s.align ?? "left";
3599
4815
  const cx = align === "center" ? p.x + maxWPx / 2 : align === "right" ? p.x + maxWPx - maxLineW / 2 : p.x + maxLineW / 2;
4816
+ const { dx, dy } = textBlockAnchorOffset(s, maxWPx, maxLineW, totalH);
3600
4817
  return {
3601
- cx,
3602
- cy: p.y + totalH / 2,
4818
+ cx: cx + dx,
4819
+ cy: p.y + totalH / 2 + dy,
3603
4820
  halfX: maxLineW / 2,
3604
4821
  halfY: totalH / 2
3605
4822
  };
@@ -3608,7 +4825,7 @@ function textBlockRevealFraction(s, w, h, t) {
3608
4825
  if (!s.reveal) return 1;
3609
4826
  const unitScale = Math.min(w, h);
3610
4827
  const maxWEm = s.maxWidth * unitScale / (s.fontSize * unitScale);
3611
- const lines = breakTextBlock(s.text, maxWEm);
4828
+ const lines = breakTextBlock(s.text, maxWEm, textMetricsClassFor(s.font));
3612
4829
  const rs = revealState(lines, s.reveal, t);
3613
4830
  if (rs.glyphAlphas) {
3614
4831
  let sum = 0;
@@ -3631,8 +4848,23 @@ function luminanceGrid(spec, opts = {}) {
3631
4848
  const cellH = h / rows;
3632
4849
  const bg = spec.background;
3633
4850
  const bgRow = new Array(rows).fill(0);
4851
+ let bgCells;
3634
4852
  if (!bg || bg.type === "solid") {
3635
4853
  bgRow.fill(hexLuma(bg?.color ?? "#05050a"));
4854
+ } else if (bg.type === "field") {
4855
+ bgCells = new Array(cols * rows);
4856
+ const short = Math.max(1, Math.min(w, h));
4857
+ const seed = opts.seed ?? spec.seed ?? 42;
4858
+ const ft = fieldSampleTime(bg, t);
4859
+ for (let r = 0; r < rows; r++) {
4860
+ let rowSum = 0;
4861
+ for (let c = 0; c < cols; c++) {
4862
+ const lum = rgb255Luma(fieldRgbAt((c + 0.5) * cellW / short, (r + 0.5) * cellH / short, ft, bg, seed));
4863
+ bgCells[r * cols + c] = lum;
4864
+ rowSum += lum;
4865
+ }
4866
+ bgRow[r] = rowSum / cols;
4867
+ }
3636
4868
  } else {
3637
4869
  const stops = [...bg.stops].sort((a, b) => a.at - b.at);
3638
4870
  for (let r = 0; r < rows; r++) {
@@ -3657,7 +4889,7 @@ function luminanceGrid(spec, opts = {}) {
3657
4889
  }
3658
4890
  }
3659
4891
  const cells = new Array(cols * rows);
3660
- for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) cells[r * cols + c] = bgRow[r];
4892
+ for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) cells[r * cols + c] = bgCells ? bgCells[r * cols + c] : bgRow[r];
3661
4893
  const compose = (idx, lum, a, blend) => {
3662
4894
  const cur = cells[idx];
3663
4895
  if (blend === "lighter" || blend === "screen") cells[idx] = Math.min(1, cur + lum * a);
@@ -3802,7 +5034,7 @@ function luminanceGrid(spec, opts = {}) {
3802
5034
  const c1 = Math.min(cols - 1, Math.floor((centerX + reachX) / cellW));
3803
5035
  const r0 = Math.max(0, Math.floor((centerY - reachY) / cellH));
3804
5036
  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);
5037
+ 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
5038
  for (let r = r0; r <= r1; r++) {
3807
5039
  for (let c = c0; c <= c1; c++) {
3808
5040
  const dx = (c + 0.5) * cellW - centerX;
@@ -3871,6 +5103,9 @@ function luminanceGrid(spec, opts = {}) {
3871
5103
  }
3872
5104
  }
3873
5105
  splatPass(t, 1);
5106
+ return finishGrid(cols, rows, cells, bgRow, bgCells);
5107
+ }
5108
+ function finishGrid(cols, rows, cells, bgRow, bgCells) {
3874
5109
  let sum = 0;
3875
5110
  let covered = 0;
3876
5111
  let devSum = 0;
@@ -3882,7 +5117,7 @@ function luminanceGrid(spec, opts = {}) {
3882
5117
  for (let c = 0; c < cols; c++) {
3883
5118
  const v = cells[r * cols + c];
3884
5119
  sum += v;
3885
- const dev = Math.abs(v - bgRow[r]);
5120
+ const dev = Math.abs(v - (bgCells ? bgCells[r * cols + c] : bgRow[r]));
3886
5121
  if (dev > 0.03) covered++;
3887
5122
  devSum += dev;
3888
5123
  cxAcc += dev * (c + 0.5);
@@ -3898,6 +5133,7 @@ function luminanceGrid(spec, opts = {}) {
3898
5133
  rows,
3899
5134
  cells,
3900
5135
  background: bgRow,
5136
+ ...bgCells ? { backgroundCells: bgCells } : {},
3901
5137
  meanLuminance: sum / (cols * rows),
3902
5138
  coverage: covered / (cols * rows),
3903
5139
  centroid: devSum > 1e-6 ? { x: cxAcc / devSum / cols, y: cyAcc / devSum / rows } : null,
@@ -3980,6 +5216,21 @@ function textSprites(spec, opts = {}) {
3980
5216
  return out;
3981
5217
  }
3982
5218
  function dominanceRanking(spec, opts = {}) {
5219
+ return rankDominance(rawDominance(spec, opts));
5220
+ }
5221
+ function rankDominance(raw) {
5222
+ const total = raw.reduce((s, r) => s + r.weight, 0) || 1;
5223
+ return raw.slice().sort((a, b) => b.weight - a.weight).map((r, i) => ({
5224
+ rank: i + 1,
5225
+ layerIndex: r.layerIndex,
5226
+ key: r.key,
5227
+ share: r.weight / total,
5228
+ entityCount: r.entityCount,
5229
+ meanLuma: r.meanLuma,
5230
+ factors: r.factors
5231
+ }));
5232
+ }
5233
+ function rawDominance(spec, opts = {}) {
3983
5234
  const scene = buildScene(spec, opts);
3984
5235
  const t = opts.t ?? 5e3;
3985
5236
  const { w, h, scale } = scene;
@@ -4031,7 +5282,7 @@ function dominanceRanking(spec, opts = {}) {
4031
5282
  entArea = box.halfX * 2 * box.halfY * 2 * 0.55;
4032
5283
  } else if (s.kind === "textBlock") {
4033
5284
  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);
5285
+ entArea = box.halfX * 2 * box.halfY * 2 * 0.55 * textBlockRevealFraction(s, w, h, t) * (s.opacity ?? 1);
4035
5286
  } else entArea = sz * sz * 0.55;
4036
5287
  area += entArea * a;
4037
5288
  if (layer.trail) {
@@ -4060,16 +5311,7 @@ function dominanceRanking(spec, opts = {}) {
4060
5311
  const weight = area / (w * h) * contrast * blendBoost * motionBoost;
4061
5312
  return { layerIndex, key: layer.key, weight, entityCount: entities.length, meanLuma, factors: { area: area / (w * h), contrast, blendBoost, motionBoost } };
4062
5313
  });
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
- }));
5314
+ return raw;
4073
5315
  }
4074
5316
  function layerCohesion(spec, opts = {}) {
4075
5317
  const scene = buildScene(spec, opts);
@@ -4115,8 +5357,9 @@ function regionMeans(grid) {
4115
5357
  const cnt = new Array(9).fill(0);
4116
5358
  for (let r = 0; r < grid.rows; r++) {
4117
5359
  for (let c = 0; c < grid.cols; c++) {
5360
+ const idx = r * grid.cols + c;
4118
5361
  const region = Math.min(2, Math.floor(r / grid.rows * 3)) * 3 + Math.min(2, Math.floor(c / grid.cols * 3));
4119
- out[region] += Math.abs(grid.cells[r * grid.cols + c] - grid.background[r]);
5362
+ out[region] += Math.abs(grid.cells[idx] - (grid.backgroundCells ? grid.backgroundCells[idx] : grid.background[r]));
4120
5363
  cnt[region]++;
4121
5364
  }
4122
5365
  }
@@ -4176,6 +5419,60 @@ function perceiveScene(spec, opts = {}) {
4176
5419
  advisories: adviseSpec(spec, opts.viewport ?? { width: 1920, height: 1080 }, { t: opts.t, seed: opts.seed })
4177
5420
  };
4178
5421
  }
5422
+ var bedKey = (key, layerIndex) => `bed:${key ?? layerIndex}`;
5423
+ function perceiveSequenceFrame(seq, T, opts = {}) {
5424
+ const { releasedBelow, ...gridOpts } = opts;
5425
+ const r = resolveSegment(seq, T, { releasedBelow });
5426
+ const seg = seq.segments[r.index];
5427
+ const segment = { index: r.index, key: seg.key, localT: r.localT, ...r.held ? { held: true } : {} };
5428
+ const segOpts = { ...gridOpts, t: r.localT, seed: gridOpts.seed ?? segmentRenderSeed(seq, r.index) };
5429
+ if (!seq.bed) return { ...perceiveScene(seg.scene, segOpts), segment, bed: false };
5430
+ const bedOpts = { ...gridOpts, t: T, seed: gridOpts.seed ?? bedRenderSeed(seq) };
5431
+ const viewport = gridOpts.viewport ?? { width: 1920, height: 1080 };
5432
+ const ink = { ...seg.scene, background: { type: "solid", color: "#000000" } };
5433
+ delete ink.ghosting;
5434
+ const inkOverBed = { ...ink, background: seq.bed.background ?? { type: "solid", color: "#05050a" } };
5435
+ const bedGrid = luminanceGrid(seq.bed, bedOpts);
5436
+ const inkGrid = luminanceGrid(ink, segOpts);
5437
+ const cells = bedGrid.cells.map((v, i) => Math.min(1, v + inkGrid.cells[i]));
5438
+ const grid = finishGrid(bedGrid.cols, bedGrid.rows, cells, bedGrid.background, bedGrid.backgroundCells);
5439
+ const bedLayers = seq.bed.layers.length;
5440
+ const dominance = rankDominance([
5441
+ ...rawDominance(seq.bed, bedOpts).map((d) => ({ ...d, key: bedKey(d.key, d.layerIndex) })),
5442
+ ...rawDominance(inkOverBed, segOpts).map((d) => ({ ...d, layerIndex: d.layerIndex + bedLayers }))
5443
+ ]);
5444
+ const shift = (x) => ({ ...x, layerIndex: x.layerIndex + bedLayers });
5445
+ const prefix = (x) => ({ ...x, key: bedKey(x.key, x.layerIndex) });
5446
+ return {
5447
+ t: r.localT,
5448
+ braille: renderBrailleMap(grid),
5449
+ density: renderDensityMap(grid),
5450
+ coverage: grid.coverage,
5451
+ meanLuminance: grid.meanLuminance,
5452
+ centroid: grid.centroid,
5453
+ rowProfile: grid.rowProfile,
5454
+ colProfile: grid.colProfile,
5455
+ dominance,
5456
+ motion: [...motionStats(seq.bed, bedOpts).map(prefix), ...motionStats(ink, segOpts).map(shift)],
5457
+ form: [...layerCohesion(seq.bed, bedOpts).map(prefix), ...layerCohesion(ink, segOpts).map(shift)],
5458
+ text: [...textSprites(seq.bed, bedOpts).map(prefix), ...textSprites(ink, segOpts).map(shift)],
5459
+ advisories: [
5460
+ ...adviseSpec(seq.bed, viewport, { t: T, seed: bedOpts.seed }).map((w) => ({ ...w, path: `bed.${w.path}` })),
5461
+ // inkOverBed's layers are the segment's (seed: segOpts.seed) but its
5462
+ // background is the bed's (seq.bed.background) — a field there must be
5463
+ // sampled with the bed's own seed, or a `role: 'read'` legibility
5464
+ // advisory checks terrain that isn't what's actually behind the ink.
5465
+ // `?? 42`: inkOverBed.seed is the SEGMENT's raw seed field (it's `ink`
5466
+ // spread first), so adviseSpec's own `backgroundSeed ?? seed` fallback
5467
+ // would substitute the segment's seed, not the bed's, if bedOpts.seed
5468
+ // were left undefined — 42 is the same default `luminanceGrid` (and
5469
+ // every other field seed fallback) converges on.
5470
+ ...adviseSpec(inkOverBed, viewport, { t: r.localT, seed: segOpts.seed, backgroundSeed: bedOpts.seed ?? 42 })
5471
+ ],
5472
+ segment,
5473
+ bed: true
5474
+ };
5475
+ }
4179
5476
 
4180
5477
  // src/examples/aquarium.ts
4181
5478
  var AQUARIUM_SPEC = {
@@ -5065,6 +6362,85 @@ var LANTERNS_SPEC = {
5065
6362
  ]
5066
6363
  };
5067
6364
 
6365
+ // src/examples/lobby-talk.ts
6366
+ var LOBBY_TALK_SPEC = {
6367
+ schemaVersion: 1,
6368
+ id: "lobby-talk",
6369
+ label: "Lobby Talk",
6370
+ seed: 1207,
6371
+ motionIntensity: "calm",
6372
+ background: {
6373
+ type: "gradient",
6374
+ stops: [
6375
+ { at: 0, color: "#060912" },
6376
+ { at: 1, color: "#0b1224" }
6377
+ ],
6378
+ drift: { period: 4e4, amount: 0.1 }
6379
+ },
6380
+ layers: [
6381
+ {
6382
+ key: "haze",
6383
+ count: 3,
6384
+ sprite: { kind: "circle", radius: [0.22, 0.3], soft: true, color: "#123a4a", colors: ["#123a4a", "#2a1e4a", "#0e2a3a"] },
6385
+ alpha: [0.35, 0.5],
6386
+ blend: "lighter",
6387
+ region: { x: [0.2, 0.8], y: [0.3, 0.7] },
6388
+ motion: { type: "wander", speed: [2e-3, 4e-3], meander: 0.05 },
6389
+ pulse: { amp: 0.15, period: 9e3 }
6390
+ },
6391
+ {
6392
+ key: "dust",
6393
+ count: 50,
6394
+ sprite: { kind: "circle", radius: [8e-4, 18e-4], soft: true, color: "#7fb8c9" },
6395
+ alpha: [0.2, 0.5],
6396
+ blend: "lighter",
6397
+ motion: { type: "wander", speed: [1e-3, 3e-3] }
6398
+ },
6399
+ {
6400
+ key: "title",
6401
+ count: 1,
6402
+ sprite: { kind: "textBlock", text: "Adoption by feature, % of accounts", maxWidth: 1, fontSize: 0.034, color: "#f2f4f8", role: "read" },
6403
+ motion: { type: "static" },
6404
+ position: { x: 0.12, y: 0.16 }
6405
+ },
6406
+ {
6407
+ key: "labels",
6408
+ count: 5,
6409
+ sprite: { kind: "text", strings: ["Onboarding", "Search", "Billing", "Exports", "Mobile"], font: "system-ui", align: "left", baseline: "middle", color: "#a9b7cc", role: "read" },
6410
+ size: [0.03, 0.03],
6411
+ motion: { type: "static" },
6412
+ position: { x: 0.12, y: 0.32 },
6413
+ layout: { type: "list", gap: 0.085 }
6414
+ },
6415
+ {
6416
+ key: "bars",
6417
+ count: 5,
6418
+ sprite: {
6419
+ kind: "bar",
6420
+ values: [82, 64, 91, 37, 58],
6421
+ max: 100,
6422
+ length: 0.42,
6423
+ thickness: 0.026,
6424
+ color: "#7fe0d2",
6425
+ colors: ["#7fe0d2", "#7fe0d2", "#ffcf7a", "#7fe0d2", "#7fe0d2"]
6426
+ },
6427
+ alpha: [0.9, 0.9],
6428
+ motion: { type: "static" },
6429
+ position: { x: 0.33, y: 0.32 },
6430
+ layout: { type: "list", gap: 0.085 }
6431
+ },
6432
+ {
6433
+ key: "readouts",
6434
+ count: 5,
6435
+ sprite: { kind: "text", strings: ["82%", "64%", "91%", "37%", "58%"], font: "bold system-ui", align: "left", baseline: "middle", color: "#f2f4f8", role: "read" },
6436
+ size: [0.03, 0.03],
6437
+ motion: { type: "static" },
6438
+ position: { x: 0.78, y: 0.32 },
6439
+ layout: { type: "list", gap: 0.085 }
6440
+ }
6441
+ ]
6442
+ };
6443
+
5068
6444
  // src/examples/matrix-rain.ts
5069
6445
  var MATRIX_RAIN_SPEC = {
5070
6446
  schemaVersion: 1,
@@ -5716,6 +7092,38 @@ var SNOWFALL_SPEC = {
5716
7092
  ]
5717
7093
  };
5718
7094
 
7095
+ // src/examples/thermal-field.ts
7096
+ var THERMAL_FIELD_SPEC = {
7097
+ schemaVersion: 1,
7098
+ id: "thermal-field",
7099
+ label: "Thermal Field",
7100
+ seed: 71,
7101
+ motionIntensity: "calm",
7102
+ // The riso print: grain at half strength, a light dither screen — the
7103
+ // finish that makes the flat inks read as paper rather than pixels.
7104
+ finish: { grain: 0.5, dither: 0.3 },
7105
+ background: {
7106
+ type: "field",
7107
+ scale: 2.2,
7108
+ octaves: 3,
7109
+ warp: 0.4,
7110
+ quantize: 6,
7111
+ bands: ["#1b1a3a", "#0078bf", "#00a99d", "#ffe800", "#ff6c2f", "#ff48b0"],
7112
+ drift: { period: 4e4, amount: 0.3 }
7113
+ },
7114
+ layers: [
7115
+ {
7116
+ key: "motes",
7117
+ count: 14,
7118
+ sprite: { kind: "circle", radius: [0.012, 0.028], color: "#fff6e0", soft: true },
7119
+ motion: { type: "wander", speed: [3e-3, 8e-3], meander: 0.04, coherence: 0.3 },
7120
+ alpha: [0.18, 0.4],
7121
+ blend: "screen",
7122
+ pulse: { amp: 0.15, period: 9e3 }
7123
+ }
7124
+ ]
7125
+ };
7126
+
5719
7127
  // src/examples/warp-tunnel.ts
5720
7128
  var WARP_TUNNEL_SPEC = {
5721
7129
  schemaVersion: 1,
@@ -5789,7 +7197,9 @@ var SCHEMA_EXAMPLES = [
5789
7197
  { id: "haiku", label: "Haiku", spec: HAIKU_SPEC },
5790
7198
  { id: "pings", label: "Pings", spec: PINGS_SPEC },
5791
7199
  { id: "facets", label: "Facets", spec: FACETS_SPEC },
5792
- { id: "relay-board", label: "Relay Board", spec: RELAY_BOARD_SPEC }
7200
+ { id: "relay-board", label: "Relay Board", spec: RELAY_BOARD_SPEC },
7201
+ { id: "lobby-talk", label: "Lobby Talk", spec: LOBBY_TALK_SPEC },
7202
+ { id: "thermal-field", label: "Thermal Field", spec: THERMAL_FIELD_SPEC }
5793
7203
  ];
5794
7204
  var EXAMPLE_SPECS = SCHEMA_EXAMPLES.map((e) => e.spec);
5795
7205
  var EXAMPLE_BY_ID = Object.fromEntries(
@@ -5802,12 +7212,22 @@ export {
5802
7212
  COMETS_SPEC,
5803
7213
  CONSTELLATION_SPEC,
5804
7214
  DASHBOARD_SPEC,
7215
+ DITHER_ALPHA,
7216
+ DITHER_TILE,
5805
7217
  EXAMPLE_BY_ID,
5806
7218
  EXAMPLE_SPECS,
5807
7219
  FACETS_SPEC,
7220
+ FIELD_BUCKET_MS,
7221
+ FIELD_DRIFT_DEFAULT_AMOUNT,
7222
+ FIELD_RASTER_SHORT_SIDE,
7223
+ FIELD_RASTER_SHORT_SIDE_LOW,
7224
+ FINISH_ANIMATE_HZ,
7225
+ GRAIN_ALPHA,
7226
+ GRAIN_TILE,
5808
7227
  HAIKU_SPEC,
5809
7228
  LANTERNS_SPEC,
5810
7229
  LIMITS,
7230
+ LOBBY_TALK_SPEC,
5811
7231
  MATRIX_RAIN_SPEC,
5812
7232
  NOSTALGHIA_CANDLE_SPEC,
5813
7233
  ORRERY_SPEC,
@@ -5820,6 +7240,8 @@ export {
5820
7240
  SCHEMA_EXAMPLES,
5821
7241
  SCHEMA_VERSION,
5822
7242
  SNOWFALL_SPEC,
7243
+ THERMAL_FIELD_SPEC,
7244
+ WARP_MAX_SCALE,
5823
7245
  WARP_NEAR,
5824
7246
  WARP_TUNNEL_SPEC,
5825
7247
  adviseSequence,
@@ -5828,19 +7250,36 @@ export {
5828
7250
  applyDeltasToSpec,
5829
7251
  assertValidSequence,
5830
7252
  assertValidSpec,
7253
+ bayerMatrix,
7254
+ bayerTilePixels,
7255
+ bedRenderSeed,
5831
7256
  breakTextBlock,
5832
7257
  buildEntities,
7258
+ canMorph,
5833
7259
  cohesionOf,
5834
7260
  compileSaver,
5835
7261
  compileSequence,
7262
+ createFinishPass,
7263
+ createSceneCanvas,
5836
7264
  describeScene,
5837
7265
  diffScenes,
5838
7266
  dominanceRanking,
5839
7267
  easeSmooth,
5840
7268
  emitEnvelope,
5841
7269
  emitWindow,
7270
+ fieldAt,
7271
+ fieldOctaves,
7272
+ fieldRgb,
7273
+ fieldRgbAt,
7274
+ fieldSampleTime,
7275
+ finishStrength,
7276
+ grainOffset,
7277
+ grainTilePixels,
5842
7278
  graphemeClusters,
7279
+ hasHotSwapSequence,
5843
7280
  headingAt,
7281
+ hexToRgb255,
7282
+ latticeHash,
5844
7283
  layerCohesion,
5845
7284
  lerpSpec,
5846
7285
  lifeAlphaAt,
@@ -5848,24 +7287,38 @@ export {
5848
7287
  linkPairs,
5849
7288
  luminanceGrid,
5850
7289
  manifestFor,
7290
+ morphChainRoot,
7291
+ morphNothingMorphable,
5851
7292
  motionStats,
7293
+ normalizeSeed,
5852
7294
  perceiveScene,
7295
+ perceiveSequenceFrame,
7296
+ pickScreenOp,
5853
7297
  positionAt,
7298
+ presentWithFinish,
5854
7299
  renderBrailleMap,
5855
7300
  renderDensityMap,
5856
7301
  resolveSegment,
5857
7302
  resolveSpecPath,
5858
7303
  revealState,
7304
+ rgb255Luma,
5859
7305
  rotationAt,
5860
7306
  seamCauseOf,
5861
7307
  seamsWorthWarning,
7308
+ segmentRenderSeed,
5862
7309
  segmentStart,
7310
+ sequenceSteerablePaths,
7311
+ sequenceSwapCompatible,
5863
7312
  sizeAt,
5864
7313
  spriteIndexAt,
5865
7314
  spriteVariants,
5866
7315
  steerablePaths,
5867
7316
  structuralSignature,
7317
+ textBlockAnchorOffset,
7318
+ textMetricsClassFor,
5868
7319
  textSprites,
7320
+ textStringsDiffer,
7321
+ textStringsOf,
5869
7322
  textWidthEm,
5870
7323
  validateSequence,
5871
7324
  validateSpec