@idle-screens/schema 3.9.1 → 3.10.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
@@ -71,6 +71,20 @@ var LIMITS = {
71
71
  minTransitionDur: 200,
72
72
  minEmitEvery: 1e3,
73
73
  // ms — at most one event per second per entity (flash safety)
74
+ maxTimelineKeys: 256,
75
+ minTimelineKeyInterval: 200,
76
+ // ms — distinct key times on one path (flash safety; the timeline design's floor)
77
+ maxTimelineKeyDur: 3e4,
78
+ // ms — one key's glide
79
+ maxTimelineDuration: 864e5,
80
+ // ms — a looping timeline's lap (a day)
81
+ maxLayerTransformOffset: 2,
82
+ // min(w,h) units — transform x/y and position dx/dy
83
+ maxLayerTransformOffsetPx: 17280,
84
+ // px, under `units: 'px'` — twice the largest referenceViewport
85
+ maxLayerTransformScale: 8,
86
+ maxLayerTransformRotate: 3600,
87
+ // degrees
74
88
  maxEmitEvery: 6e5,
75
89
  // ms — ten minutes; longer silences than that are `life.enter`
76
90
  minEmitLife: 500,
@@ -121,6 +135,79 @@ function resolveSpecPath(spec, path) {
121
135
  const exists = Array.isArray(node) ? Number.isInteger(key) && key >= 0 && key < node.length : last in node;
122
136
  return exists ? { parent: node, key } : null;
123
137
  }
138
+ function canonicalSpecPath(spec, path) {
139
+ if (!resolveSpecPath(spec, path)) return null;
140
+ const parts = path.split(".");
141
+ const s = spec;
142
+ if (parts[0] !== "layers" && parts[0] !== "background" && Array.isArray(s.layers)) {
143
+ const idx = s.layers.findIndex((l) => l && l.key === parts[0]);
144
+ if (idx !== -1) parts.splice(0, 1, "layers", String(idx));
145
+ }
146
+ return parts.join(".");
147
+ }
148
+ function readSpecPath(spec, path) {
149
+ const loc = resolveSpecPath(spec, path);
150
+ return loc ? loc.parent[loc.key] : void 0;
151
+ }
152
+ var TRANSFORM_IDENTITY = { x: 0, y: 0, scale: 1, scaleX: 1, rotate: 0 };
153
+ function alignTransform(a, b) {
154
+ const isObj2 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
155
+ if (!isObj2(a) || !isObj2(b)) return [a, b];
156
+ let fa = null;
157
+ let fb = null;
158
+ for (const key of /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)])) {
159
+ const id = TRANSFORM_IDENTITY[key];
160
+ if (id === void 0) continue;
161
+ if (a[key] === void 0) (fa ??= { ...a })[key] = id;
162
+ if (b[key] === void 0) (fb ??= { ...b })[key] = id;
163
+ }
164
+ return [fa ?? a, fb ?? b];
165
+ }
166
+ function alignPaintFields(a, b) {
167
+ const la = a?.layers;
168
+ const lb = b?.layers;
169
+ if (!Array.isArray(la) || !Array.isArray(lb)) return [a, b];
170
+ let outA = null;
171
+ let outB = null;
172
+ const n = Math.min(la.length, lb.length);
173
+ for (let i = 0; i < n; i++) {
174
+ const x = la[i];
175
+ const y = lb[i];
176
+ if (!x || !y || typeof x !== "object" || typeof y !== "object") continue;
177
+ let nx = x;
178
+ let ny = y;
179
+ if (x.opacity === void 0 !== (y.opacity === void 0)) {
180
+ if (x.opacity === void 0) nx = { ...nx, opacity: 1 };
181
+ else ny = { ...ny, opacity: 1 };
182
+ }
183
+ const tx = x.transform;
184
+ const ty = y.transform;
185
+ if (tx || ty) {
186
+ const fx = { ...tx ?? {} };
187
+ const fy = { ...ty ?? {} };
188
+ let changed = !tx || !ty;
189
+ for (const key of /* @__PURE__ */ new Set([...Object.keys(fx), ...Object.keys(fy)])) {
190
+ const id = TRANSFORM_IDENTITY[key];
191
+ if (id === void 0) continue;
192
+ if (fx[key] === void 0) {
193
+ fx[key] = id;
194
+ changed = true;
195
+ }
196
+ if (fy[key] === void 0) {
197
+ fy[key] = id;
198
+ changed = true;
199
+ }
200
+ }
201
+ if (changed) {
202
+ nx = { ...nx, transform: fx };
203
+ ny = { ...ny, transform: fy };
204
+ }
205
+ }
206
+ if (nx !== x) (outA ??= la.slice())[i] = nx;
207
+ if (ny !== y) (outB ??= lb.slice())[i] = ny;
208
+ }
209
+ return [outA ? { ...a, layers: outA } : a, outB ? { ...b, layers: outB } : b];
210
+ }
124
211
  function applyDeltasToSpec(spec, deltas) {
125
212
  const copy = JSON.parse(JSON.stringify(spec));
126
213
  for (const d of deltas) {
@@ -143,7 +230,14 @@ function lerpHex(a, b, k) {
143
230
  }
144
231
  function lerpSpec(from, to, k) {
145
232
  const kk = Math.max(0, Math.min(1, k));
146
- const walk = (a, b, key) => {
233
+ const out = lerpValue(from, to, kk, true);
234
+ return out;
235
+ }
236
+ function lerpValue(from, to, k, specRoot = false, rootKey) {
237
+ if (specRoot) [from, to] = alignPaintFields(from, to);
238
+ if (rootKey === "transform") [from, to] = alignTransform(from, to);
239
+ const kk = Math.max(0, Math.min(1, k));
240
+ const walk = (a, b, key, root = false) => {
147
241
  if (typeof a === "number" && typeof b === "number") {
148
242
  const v = a + (b - a) * kk;
149
243
  return key === "count" ? Math.max(1, Math.round(v)) : v;
@@ -157,13 +251,15 @@ function lerpSpec(from, to, k) {
157
251
  if (a && b && typeof a === "object" && typeof b === "object" && !Array.isArray(a) && !Array.isArray(b)) {
158
252
  const out = {};
159
253
  for (const kName of Object.keys(b)) {
160
- out[kName] = walk(a[kName], b[kName], kName);
254
+ const av = a[kName];
255
+ const bv = b[kName];
256
+ out[kName] = root && kName === "timeline" ? kk > 0 ? bv : av : kName === "transform" ? walk(...alignTransform(av, bv), kName) : walk(av, bv, kName);
161
257
  }
162
258
  return out;
163
259
  }
164
260
  return kk > 0 ? b : a;
165
261
  };
166
- return walk(from, to);
262
+ return walk(from, to, rootKey, specRoot);
167
263
  }
168
264
  function textStringsOf(sprite) {
169
265
  if (sprite.kind === "text") return sprite.strings;
@@ -301,13 +397,244 @@ function structuralSignature(spec) {
301
397
  ]);
302
398
  }
303
399
 
400
+ // src/sequence.ts
401
+ function segmentStart(seq, index) {
402
+ let t = 0;
403
+ const n = Math.max(0, Math.min(index, seq.segments.length - 1));
404
+ for (let i = 0; i < n; i++) t += seq.segments[i].duration ?? 0;
405
+ return t;
406
+ }
407
+ function resolveSegment(seq, T, opts = {}) {
408
+ const { segments, loop } = seq;
409
+ if (segments.length === 0) return { index: 0, localT: 0, startT: 0 };
410
+ let totalTimed = 0;
411
+ for (const seg of segments) {
412
+ if (seg.duration != null) totalTimed += seg.duration;
413
+ else break;
414
+ }
415
+ const t0 = Math.max(0, T);
416
+ const walk = (t, releasedBelow) => {
417
+ let cumulative = 0;
418
+ for (let i = 0; i < segments.length; i++) {
419
+ const seg = segments[i];
420
+ if (seg.duration == null) {
421
+ return loop ? null : { index: i, localT: t - cumulative, startT: cumulative };
422
+ }
423
+ if (t < cumulative + seg.duration) {
424
+ return { index: i, localT: t - cumulative, startT: cumulative };
425
+ }
426
+ if (seg.advance === "input" && i >= releasedBelow) {
427
+ return { index: i, localT: t - cumulative, startT: cumulative, held: true };
428
+ }
429
+ cumulative += seg.duration;
430
+ }
431
+ return null;
432
+ };
433
+ const first = walk(t0, opts.releasedBelow ?? 0);
434
+ if (first) return first;
435
+ if (loop && totalTimed > 0) {
436
+ const wrapped = walk(t0 % totalTimed, 0);
437
+ if (wrapped) return wrapped;
438
+ }
439
+ const last = segments.length - 1;
440
+ const lastStart = totalTimed - (segments[last].duration ?? 0);
441
+ return { index: last, localT: t0 - lastStart, startT: lastStart };
442
+ }
443
+ function canMorph(seq, from) {
444
+ const seg = seq.segments[from];
445
+ if (!seg || seg.transition?.type !== "morph") return false;
446
+ const next = seq.segments[from + 1];
447
+ if (!next) return false;
448
+ return structuralSignature(seg.scene) === structuralSignature(next.scene);
449
+ }
450
+ function morphChainRoot(seq, index) {
451
+ let i = index;
452
+ while (i > 0 && canMorph(seq, i - 1)) i--;
453
+ return i;
454
+ }
455
+ function canWrapMorph(seq) {
456
+ if (seq.wrapMorph !== true || seq.loop !== true) return false;
457
+ const last = seq.segments.length - 1;
458
+ if (last < 1) return false;
459
+ const seg = seq.segments[last];
460
+ if (seg.transition?.type !== "morph" || seg.duration == null) return false;
461
+ if (morphChainRoot(seq, last) !== 0) return false;
462
+ return structuralSignature(seg.scene) === structuralSignature(seq.segments[0].scene);
463
+ }
464
+ function normalizeSeed(seed) {
465
+ return seed >>> 0 || 1;
466
+ }
467
+ function segmentRenderSeed(seq, index) {
468
+ const root = morphChainRoot(seq, index);
469
+ const scene = seq.segments[root]?.scene;
470
+ if (!scene) return void 0;
471
+ const raw = scene.seed ?? (seq.seed !== void 0 ? seq.seed + root : void 0);
472
+ return raw === void 0 ? void 0 : normalizeSeed(raw);
473
+ }
474
+ function bedRenderSeed(seq) {
475
+ if (!seq.bed) return void 0;
476
+ const raw = seq.bed.seed ?? (seq.seed !== void 0 ? seq.seed + LIMITS.maxSegments : void 0);
477
+ return raw === void 0 ? void 0 : normalizeSeed(raw);
478
+ }
479
+ function sequenceWantsFinish(seq) {
480
+ return seq.finish !== void 0 || seq.segments.some((s) => s.scene.finish !== void 0);
481
+ }
482
+ function sequenceSwapCompatible(prev, next) {
483
+ if (prev.segments.length !== next.segments.length) return false;
484
+ if (prev.finish !== void 0 || next.finish !== void 0) {
485
+ if (prev.seed === void 0 !== (next.seed === void 0)) return false;
486
+ if (prev.seed !== void 0 && next.seed !== void 0 && normalizeSeed(prev.seed) !== normalizeSeed(next.seed)) return false;
487
+ }
488
+ if (sequenceWantsFinish(prev) !== sequenceWantsFinish(next)) return false;
489
+ if (prev.loop !== next.loop) return false;
490
+ if ((prev.sync ?? "mount") !== (next.sync ?? "mount")) return false;
491
+ if (!!prev.bed !== !!next.bed) return false;
492
+ if (prev.bed && next.bed) {
493
+ if (structuralSignature(prev.bed) !== structuralSignature(next.bed)) return false;
494
+ if (bedRenderSeed(prev) !== bedRenderSeed(next)) return false;
495
+ }
496
+ for (let i = 0; i < prev.segments.length; i++) {
497
+ const a = prev.segments[i];
498
+ const b = next.segments[i];
499
+ if (a.duration !== b.duration) return false;
500
+ if ((a.advance ?? "auto") !== (b.advance ?? "auto")) return false;
501
+ if (transitionKey(a.transition) !== transitionKey(b.transition)) return false;
502
+ if (structuralSignature(a.scene) !== structuralSignature(b.scene)) return false;
503
+ if (segmentRenderSeed(prev, i) !== segmentRenderSeed(next, i)) return false;
504
+ }
505
+ return true;
506
+ }
507
+ function transitionKey(tr) {
508
+ if (!tr || tr.type === "cut") return "cut";
509
+ if (tr.type === "morph") return `morph:${tr.dur}:${tr.text ?? "step"}`;
510
+ return `fade:${tr.dur}`;
511
+ }
512
+
513
+ // src/timeline.ts
514
+ var DEFAULT_KEY_DUR = 1e3;
515
+ var tracksCache = /* @__PURE__ */ new WeakMap();
516
+ function withoutTimeline(spec) {
517
+ if (!spec.timeline) return spec;
518
+ const rest = { ...spec };
519
+ delete rest.timeline;
520
+ return rest;
521
+ }
522
+ function timelineTracks(spec) {
523
+ const tl = spec.timeline;
524
+ if (!tl) return /* @__PURE__ */ new Map();
525
+ const hit = tracksCache.get(tl);
526
+ if (hit && hit.spec === spec) return hit.tracks;
527
+ const base = withoutTimeline(spec);
528
+ const tracks = /* @__PURE__ */ new Map();
529
+ for (const k of tl.keys ?? []) {
530
+ const cp = canonicalSpecPath(base, k.path);
531
+ if (!cp) continue;
532
+ const list = tracks.get(cp) ?? [];
533
+ list.push(k);
534
+ tracks.set(cp, list);
535
+ }
536
+ for (const list of tracks.values()) list.sort((a, b) => a.t - b.t);
537
+ tracksCache.set(tl, { spec, tracks });
538
+ return tracks;
539
+ }
540
+ function timelineLocalTime(tl, t) {
541
+ const d = tl.duration ?? 0;
542
+ if (tl.loop && d > 0) return (t % d + d) % d;
543
+ return t;
544
+ }
545
+ function ease(k, x) {
546
+ const c = x <= 0 ? 0 : x >= 1 ? 1 : x;
547
+ if (k.ease === "step") return c >= 1 ? 1 : 0;
548
+ if (k.ease === "linear") return c;
549
+ return easeSmooth(c);
550
+ }
551
+ function evalTrack(keys, start, tt, leaf) {
552
+ let from = start;
553
+ let key = null;
554
+ const at = (tau) => {
555
+ if (!key) return from;
556
+ const dur = key.dur ?? DEFAULT_KEY_DUR;
557
+ if (dur <= 0 || tau >= key.t + dur) return key.value;
558
+ return lerpValue(from, key.value, ease(key, (tau - key.t) / dur), false, leaf);
559
+ };
560
+ for (const k of keys) {
561
+ if (k.t > tt) break;
562
+ from = at(k.t);
563
+ key = k;
564
+ }
565
+ return at(tt);
566
+ }
567
+ function resolveTimelineAt(spec, t) {
568
+ const tl = spec.timeline;
569
+ if (!tl) return spec;
570
+ const base = withoutTimeline(spec);
571
+ const tracks = timelineTracks(spec);
572
+ if (tracks.size === 0) return base;
573
+ const tt = timelineLocalTime(tl, t);
574
+ const deltas = [];
575
+ for (const [path, keys] of tracks) {
576
+ const start = tl.loop ? keys[keys.length - 1].value : readSpecPath(base, path);
577
+ deltas.push({ t: 0, path, value: evalTrack(keys, start, tt, path.slice(path.lastIndexOf(".") + 1)) });
578
+ }
579
+ return applyDeltasToSpec(base, deltas);
580
+ }
581
+ function nextKeyAfter(spec, path, t) {
582
+ const tl = spec.timeline;
583
+ if (!tl) return null;
584
+ const cp = canonicalSpecPath(withoutTimeline(spec), path);
585
+ const keys = cp ? timelineTracks(spec).get(cp) : void 0;
586
+ if (!keys || keys.length === 0) return null;
587
+ const tt = timelineLocalTime(tl, t);
588
+ const next = keys.find((k) => k.t > tt);
589
+ if (next) return { at: t + (next.t - tt), dur: next.dur ?? DEFAULT_KEY_DUR };
590
+ if (tl.loop && (tl.duration ?? 0) > 0) {
591
+ const first = keys[0];
592
+ return { at: t + (tl.duration - tt) + first.t, dur: first.dur ?? DEFAULT_KEY_DUR };
593
+ }
594
+ return null;
595
+ }
596
+ function timelineSampleTimes(spec, dense = true, cap = 120, only) {
597
+ const tl = spec.timeline;
598
+ if (!tl) return [];
599
+ const d = tl.duration ?? 0;
600
+ const fold = (t) => tl.loop && d > 0 ? (t % d + d) % d : t;
601
+ const bounds = /* @__PURE__ */ new Set([0]);
602
+ const extras = /* @__PURE__ */ new Set();
603
+ const keys = [];
604
+ for (const [path, list] of timelineTracks(spec)) if (!only || only(path)) keys.push(...list);
605
+ for (const k of keys) {
606
+ if (!Number.isFinite(k.t)) continue;
607
+ const dur = k.dur ?? DEFAULT_KEY_DUR;
608
+ bounds.add(fold(k.t));
609
+ bounds.add(fold(k.t + dur));
610
+ if (dense) for (const f of [0.25, 0.5, 0.75]) extras.add(fold(k.t + dur * f));
611
+ }
612
+ let extra = [...extras].filter((t) => !bounds.has(t)).sort((a, b) => a - b);
613
+ if (extra.length > cap) {
614
+ const step = extra.length / cap;
615
+ extra = Array.from({ length: cap }, (_, i) => extra[Math.floor(i * step)]);
616
+ }
617
+ return [.../* @__PURE__ */ new Set([...bounds, ...extra])].filter(Number.isFinite).sort((a, b) => a - b);
618
+ }
619
+ function timelineSampleTimesAfter(spec, now, dense = false, only) {
620
+ const tl = spec.timeline;
621
+ if (!tl) return [];
622
+ const local = timelineSampleTimes(spec, dense, 120, only);
623
+ const d = tl.duration ?? 0;
624
+ if (tl.loop && d > 0) {
625
+ const phase = (now % d + d) % d;
626
+ return local.map((s) => now + ((s - phase) % d + d) % d).sort((a, b) => a - b);
627
+ }
628
+ return local.filter((s) => s > now);
629
+ }
630
+
304
631
  // src/validate.ts
305
632
  var HEX2 = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
306
633
  var isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
307
634
  var isStr = (v) => typeof v === "string";
308
635
  var isNum = (v) => typeof v === "number" && Number.isFinite(v);
309
636
  var isRange = (v) => Array.isArray(v) && v.length === 2 && isNum(v[0]) && isNum(v[1]) && v[0] <= v[1];
310
- var KNOWN_TOP = /* @__PURE__ */ new Set(["schemaVersion", "id", "label", "seed", "motionIntensity", "density", "units", "referenceViewport", "background", "layers", "ghosting", "finish"]);
637
+ var KNOWN_TOP = /* @__PURE__ */ new Set(["schemaVersion", "id", "label", "seed", "motionIntensity", "density", "units", "referenceViewport", "background", "layers", "ghosting", "finish", "timeline"]);
311
638
  var KNOWN_FINISH = /* @__PURE__ */ new Set(["grain", "dither", "animate"]);
312
639
  var KNOWN_LAYER = /* @__PURE__ */ new Set([
313
640
  "count",
@@ -330,8 +657,14 @@ var KNOWN_LAYER = /* @__PURE__ */ new Set([
330
657
  "life",
331
658
  "emit",
332
659
  "clock",
333
- "rotate"
660
+ "rotate",
661
+ "opacity",
662
+ "transform"
334
663
  ]);
664
+ var KNOWN_TRANSFORM = /* @__PURE__ */ new Set(["x", "y", "scale", "scaleX", "rotate"]);
665
+ var KNOWN_POSITION = /* @__PURE__ */ new Set(["x", "y", "dx", "dy"]);
666
+ var KNOWN_TIMELINE = /* @__PURE__ */ new Set(["loop", "duration", "keys"]);
667
+ var KNOWN_TIMELINE_KEY = /* @__PURE__ */ new Set(["t", "path", "value", "ease", "dur"]);
335
668
  var KNOWN_CIRCLE = /* @__PURE__ */ new Set(["kind", "radius", "color", "soft", "colors", "colorWeights"]);
336
669
  var KNOWN_RING = /* @__PURE__ */ new Set(["kind", "radius", "color", "width", "colors", "colorWeights"]);
337
670
  var KNOWN_STREAK = /* @__PURE__ */ new Set(["kind", "length", "color", "width", "colors", "colorWeights"]);
@@ -366,6 +699,9 @@ function unknownKeys(obj, known) {
366
699
  function dimUnit(spec) {
367
700
  return isObj(spec) && spec.units === "px" ? "px" : "fractions of min(width, height)";
368
701
  }
702
+ function offsetBound(spec) {
703
+ return isObj(spec) && spec.units === "px" ? [LIMITS.maxLayerTransformOffsetPx, "px"] : [LIMITS.maxLayerTransformOffset, "min(w, h) units"];
704
+ }
369
705
  function rangeMsg(spec, note) {
370
706
  return `must be a [min,max] range of positive ${dimUnit(spec)}${note ? ` (${note})` : ""}`;
371
707
  }
@@ -393,6 +729,38 @@ function ignoredPropertyPaths(spec) {
393
729
  if (!isObj(spec)) return [];
394
730
  return (validateSpecCore(spec).warnings ?? []).filter((w) => w.code === "unknown-property" || w.code === "misplaced-property").map((w) => w.path.replace(/\[(\d+)\]/g, ".$1"));
395
731
  }
732
+ function validateSpecPaths(spec, paths) {
733
+ const layers = /* @__PURE__ */ new Set();
734
+ let background = false;
735
+ let finish = false;
736
+ for (const p of paths) {
737
+ if (/^layers\.\d+(\.(key|count|motion)(\.|$)|$)/.test(p)) return validateSpec(spec).valid;
738
+ const [head, idx] = p.split(".");
739
+ if (head === "layers" && idx !== void 0 && /^\d+$/.test(idx)) layers.add(Number(idx));
740
+ else if (head === "background") background = true;
741
+ else if (head === "finish") finish = true;
742
+ else return validateSpec(spec).valid;
743
+ }
744
+ let ok = true;
745
+ const err = () => {
746
+ ok = false;
747
+ };
748
+ const warn = () => void 0;
749
+ if (!Array.isArray(spec.layers)) return false;
750
+ for (const i of layers) {
751
+ const layer = spec.layers[i];
752
+ if (layer === void 0) return false;
753
+ validateLayer(layer, `layers[${i}]`, err, warn, spec);
754
+ if (spec.units === "px" && isObj(layer) && isObj(layer.sprite) && layer.sprite.kind === "textBlock") ok = false;
755
+ }
756
+ if (background && spec.background !== void 0) validateBackground(spec.background, err, warn);
757
+ if (finish && spec.finish !== void 0) validateFinish(spec.finish, "finish", err, warn);
758
+ if (layers.size > 0) {
759
+ const total = spec.layers.reduce((n, l) => n + (isObj(l) && isNum(l.count) ? l.count : 0), 0);
760
+ if (total > LIMITS.maxTotal) ok = false;
761
+ }
762
+ return ok;
763
+ }
396
764
  function validateSpecCore(spec) {
397
765
  const errors = [];
398
766
  const warnings = [];
@@ -444,8 +812,107 @@ function validateSpecCore(spec) {
444
812
  });
445
813
  if (total > LIMITS.maxTotal) err("layers", `total entities ${total} exceeds cap ${LIMITS.maxTotal}`);
446
814
  }
815
+ if (spec.timeline !== void 0 && errors.length === 0) validateTimeline(spec, err, warn);
447
816
  return { valid: errors.length === 0, errors, warnings };
448
817
  }
818
+ function validateTimeline(spec, errOuter, warn) {
819
+ let failed = false;
820
+ const err = (p, m) => {
821
+ failed = true;
822
+ errOuter(p, m);
823
+ };
824
+ const tl = spec.timeline;
825
+ if (!isObj(tl)) return err("timeline", "must be an object {loop?, duration?, keys}");
826
+ for (const k of unknownKeys(tl, KNOWN_TIMELINE)) warn(`timeline.${k}`, "unknown-property", `unknown timeline property '${k}' \u2014 will be ignored`);
827
+ if (tl.loop !== void 0 && typeof tl.loop !== "boolean") err("timeline.loop", "must be a boolean");
828
+ if (tl.duration !== void 0 && (!isNum(tl.duration) || tl.duration <= 0 || tl.duration > LIMITS.maxTimelineDuration)) {
829
+ err("timeline.duration", `must be a number of ms, 0 < duration <= ${LIMITS.maxTimelineDuration}`);
830
+ }
831
+ if (tl.loop === true && tl.duration === void 0) err("timeline.duration", "loop: true needs a duration (the lap length in ms)");
832
+ if (!Array.isArray(tl.keys) || tl.keys.length === 0) return err("timeline.keys", "must be a non-empty array");
833
+ if (tl.keys.length > LIMITS.maxTimelineKeys) return err("timeline.keys", `at most ${LIMITS.maxTimelineKeys} keys`);
834
+ const base = withoutTimeline(spec);
835
+ const baseSig = structuralSignature(base);
836
+ const times = /* @__PURE__ */ new Map();
837
+ let latest = 0;
838
+ tl.keys.forEach((key, i) => {
839
+ const p = `timeline.keys[${i}]`;
840
+ if (!isObj(key)) return err(p, "must be an object {t, path, value, ease?, dur?}");
841
+ for (const k of unknownKeys(key, KNOWN_TIMELINE_KEY)) warn(`${p}.${k}`, "unknown-property", `unknown key property '${k}' \u2014 will be ignored`);
842
+ let ok = true;
843
+ if (!isNum(key.t) || key.t < 0) {
844
+ err(`${p}.t`, "must be a number of ms >= 0");
845
+ ok = false;
846
+ }
847
+ if (key.ease !== void 0 && key.ease !== "step" && key.ease !== "linear" && key.ease !== "smooth") err(`${p}.ease`, "must be 'step' | 'linear' | 'smooth'");
848
+ if (key.dur !== void 0 && (!isNum(key.dur) || key.dur < 0 || key.dur > LIMITS.maxTimelineKeyDur)) {
849
+ err(`${p}.dur`, `must be a number of ms, 0..${LIMITS.maxTimelineKeyDur}`);
850
+ }
851
+ if (!("value" in key)) {
852
+ err(`${p}.value`, "is required");
853
+ ok = false;
854
+ }
855
+ const cp = isStr(key.path) ? canonicalSpecPath(base, key.path) : null;
856
+ if (!isStr(key.path) || key.path.trim() === "") {
857
+ err(`${p}.path`, "must be a non-empty dot-path");
858
+ ok = false;
859
+ } else if (!cp) {
860
+ err(`${p}.path`, `'${key.path}' does not resolve on the spec \u2014 a key can only animate a field the spec already has`);
861
+ ok = false;
862
+ } else if (cp.split(".").includes("timeline") || cp === "schemaVersion" || cp === "id" || cp === "label" || cp === "seed") {
863
+ err(`${p}.path`, `'${key.path}' is not animatable`);
864
+ ok = false;
865
+ }
866
+ if (!ok || !cp) return;
867
+ const t = key.t;
868
+ latest = Math.max(latest, t);
869
+ const applied = applyDeltasToSpec(base, [{ t: 0, path: cp, value: key.value }]);
870
+ const r = validateSpecPaths(applied, [cp]) ? { valid: true, errors: [] } : validateSpec(applied);
871
+ if (!r.valid) {
872
+ for (const e of r.errors) err(`${p}.value`, `applied at '${key.path}' \u2192 ${e.path || "<root>"}: ${e.message}`);
873
+ return;
874
+ }
875
+ if (structuralSignature(applied) !== baseSig) {
876
+ warn(`${p}.path`, "timeline-structural-key", `'${key.path}' is structural (placement, count, motion, size\u2026): the scene rebuilds at this key and entities re-seed \u2014 a pop, not a glide. Animate paint (colour, opacity, transform, polygon points) instead`);
877
+ }
878
+ const list = times.get(cp) ?? [];
879
+ list.push(t);
880
+ times.set(cp, list);
881
+ const dur = isNum(key.dur) ? key.dur : DEFAULT_KEY_DUR;
882
+ if (tl.loop === true && isNum(tl.duration) && t + dur > tl.duration) {
883
+ warn(`${p}.dur`, "timeline-glide-overruns-lap", `this key's glide ends at ${t + dur} ms, after the ${tl.duration} ms lap \u2014 the wrap cuts it short`);
884
+ }
885
+ });
886
+ if (tl.loop === true && isNum(tl.duration) && latest >= tl.duration) {
887
+ err("timeline.duration", `must be greater than the latest key's t (${latest} ms) under loop`);
888
+ }
889
+ const minGap = LIMITS.minTimelineKeyInterval;
890
+ for (const [path, list] of times) {
891
+ const distinct = [...new Set(list)].sort((a, b) => a - b);
892
+ for (let i = 1; i < distinct.length; i++) {
893
+ if (distinct[i] - distinct[i - 1] < minGap) {
894
+ err("timeline.keys", `keys on '${path}' at ${distinct[i - 1]} and ${distinct[i]} ms are closer than ${minGap} ms (flash safety)`);
895
+ }
896
+ }
897
+ if (tl.loop === true && isNum(tl.duration) && distinct.length > 1) {
898
+ const wrapGap = tl.duration - distinct[distinct.length - 1] + distinct[0];
899
+ if (wrapGap < minGap) err("timeline.keys", `keys on '${path}' at ${distinct[distinct.length - 1]} ms and ${distinct[0]} ms (next lap) are closer than ${minGap} ms across the loop wrap (flash safety)`);
900
+ }
901
+ }
902
+ if (failed) return;
903
+ const keyed = [...timelineTracks(spec).keys()];
904
+ const seen = /* @__PURE__ */ new Set();
905
+ for (const t of timelineSampleTimes(spec, true, 120)) {
906
+ const r = resolveTimelineAt(spec, t);
907
+ const state = JSON.stringify(keyed.map((p) => readSpecPath(r, p)));
908
+ if (seen.has(state)) continue;
909
+ seen.add(state);
910
+ if (validateSpecPaths(r, keyed)) continue;
911
+ const e = validateSpec(r).errors[0];
912
+ err("timeline", `at t = ${Math.round(t)} ms the keys combine into an invalid scene \u2014 ${e ? `${e.path || "<root>"}: ${e.message}` : "invalid"}`);
913
+ return;
914
+ }
915
+ }
449
916
  function color(v, path, err) {
450
917
  if (!isStr(v) || !HEX2.test(v)) err(path, "must be a hex colour like #0a3a52");
451
918
  }
@@ -569,12 +1036,40 @@ function validateLayer(layer, path, err, warn, spec) {
569
1036
  if (layer.key !== void 0 && (!isStr(layer.key) || layer.key.trim() === "")) {
570
1037
  err(`${path}.key`, "must be a non-empty string");
571
1038
  }
1039
+ if (layer.opacity !== void 0 && (!isNum(layer.opacity) || layer.opacity < 0 || layer.opacity > 1)) {
1040
+ err(`${path}.opacity`, "must be a number 0..1");
1041
+ }
1042
+ if (layer.transform !== void 0) {
1043
+ const tf = layer.transform;
1044
+ if (!isObj(tf)) err(`${path}.transform`, "must be an object {x?, y?, scale?, scaleX?, rotate?}");
1045
+ else {
1046
+ for (const k of unknownKeys(tf, KNOWN_TRANSFORM)) warn(`${path}.transform.${k}`, "unknown-property", `unknown transform property '${k}' \u2014 will be ignored`);
1047
+ const [off, unit] = offsetBound(spec);
1048
+ const sc = LIMITS.maxLayerTransformScale;
1049
+ for (const axis of ["x", "y"]) {
1050
+ if (tf[axis] !== void 0 && (!isNum(tf[axis]) || Math.abs(tf[axis]) > off)) err(`${path}.transform.${axis}`, `must be a number within \xB1${off} (${unit})`);
1051
+ }
1052
+ if (tf.scale !== void 0 && (!isNum(tf.scale) || tf.scale < 0 || tf.scale > sc)) err(`${path}.transform.scale`, `must be a number 0..${sc}`);
1053
+ if (tf.scaleX !== void 0 && (!isNum(tf.scaleX) || Math.abs(tf.scaleX) > sc)) err(`${path}.transform.scaleX`, `must be a number within \xB1${sc} (negative mirrors)`);
1054
+ if (tf.rotate !== void 0 && (!isNum(tf.rotate) || Math.abs(tf.rotate) > LIMITS.maxLayerTransformRotate)) {
1055
+ err(`${path}.transform.rotate`, `must be a number of degrees within \xB1${LIMITS.maxLayerTransformRotate}`);
1056
+ }
1057
+ }
1058
+ }
572
1059
  if (layer.position !== void 0) {
573
1060
  if (!isObj(layer.position) || !isNum(layer.position.x) || !isNum(layer.position.y)) {
574
1061
  err(`${path}.position`, "must be {x, y} with numbers 0..1");
575
1062
  } else {
576
1063
  if (layer.position.x < 0 || layer.position.x > 1) err(`${path}.position.x`, "must be 0..1");
577
1064
  if (layer.position.y < 0 || layer.position.y > 1) err(`${path}.position.y`, "must be 0..1");
1065
+ const [off, unit] = offsetBound(spec);
1066
+ for (const axis of ["dx", "dy"]) {
1067
+ const v = layer.position[axis];
1068
+ if (v !== void 0 && (!isNum(v) || Math.abs(v) > off)) {
1069
+ err(`${path}.position.${axis}`, `must be a number within \xB1${off} (${unit})`);
1070
+ }
1071
+ }
1072
+ for (const k of unknownKeys(layer.position, KNOWN_POSITION)) warn(`${path}.position.${k}`, "unknown-property", `unknown position property '${k}' \u2014 will be ignored`);
578
1073
  const dataLayout = isObj(layer.layout) && (layer.layout.type === "list" || layer.layout.type === "table");
579
1074
  if (isNum(layer.count) && layer.count !== 1 && !dataLayout) {
580
1075
  err(`${path}.position`, "position requires count: 1 \u2014 or a layout of type 'list' / 'table', where it anchors the whole block");
@@ -1161,14 +1656,14 @@ function validateShapePoints(points, path, min, err) {
1161
1656
  }
1162
1657
  }
1163
1658
  }
1164
- function validateEase(ease, path, err, warn) {
1165
- if (ease === void 0) return;
1166
- if (!isObj(ease)) return err(path, "must be an object { type: 'settle' | 'buoyant', tau }");
1167
- if (ease.type !== "settle" && ease.type !== "buoyant") err(`${path}.type`, "must be 'settle' | 'buoyant'");
1168
- if (!isNum(ease.tau) || ease.tau < LIMITS.minEaseTau || ease.tau > LIMITS.maxEaseTau) {
1659
+ function validateEase(ease2, path, err, warn) {
1660
+ if (ease2 === void 0) return;
1661
+ if (!isObj(ease2)) return err(path, "must be an object { type: 'settle' | 'buoyant', tau }");
1662
+ if (ease2.type !== "settle" && ease2.type !== "buoyant") err(`${path}.type`, "must be 'settle' | 'buoyant'");
1663
+ if (!isNum(ease2.tau) || ease2.tau < LIMITS.minEaseTau || ease2.tau > LIMITS.maxEaseTau) {
1169
1664
  err(`${path}.tau`, `must be ${LIMITS.minEaseTau}..${LIMITS.maxEaseTau} ms`);
1170
1665
  }
1171
- for (const k of unknownKeys(ease, /* @__PURE__ */ new Set(["type", "tau"]))) {
1666
+ for (const k of unknownKeys(ease2, /* @__PURE__ */ new Set(["type", "tau"]))) {
1172
1667
  warn(`${path}.${k}`, "unknown-property", `unknown ease property '${k}' \u2014 will be ignored`);
1173
1668
  }
1174
1669
  }
@@ -1200,6 +1695,7 @@ function validateSequence(seq) {
1200
1695
  if (seq.seed !== void 0 && !isNum(seq.seed)) err("seed", "must be a number");
1201
1696
  if (typeof seq.loop !== "boolean") err("loop", "must be a boolean");
1202
1697
  if (seq.sync !== void 0 && seq.sync !== "mount" && seq.sync !== "epoch") err("sync", "must be 'mount' | 'epoch'");
1698
+ if (seq.wrapMorph !== void 0 && typeof seq.wrapMorph !== "boolean") err("wrapMorph", "must be a boolean");
1203
1699
  if (seq.finish !== void 0) validateFinish(seq.finish, "finish", err, (p, code, message) => void warnings.push({ path: p, code, message }));
1204
1700
  if (!Array.isArray(seq.segments) || seq.segments.length === 0) {
1205
1701
  err("segments", "must be a non-empty array");
@@ -1253,8 +1749,8 @@ function validateSequence(seq) {
1253
1749
  if (isObj(s.transition) && s.transition.text !== void 0) {
1254
1750
  if (s.transition.type !== "morph") {
1255
1751
  err(`${p}.transition.text`, "is a morph option (fade already cross-fades whole frames; cut has no window)");
1256
- } else if (s.transition.text !== "step" && s.transition.text !== "crossfade") {
1257
- err(`${p}.transition.text`, "must be 'step' | 'crossfade'");
1752
+ } else if (s.transition.text !== "step" && s.transition.text !== "crossfade" && s.transition.text !== "dip") {
1753
+ err(`${p}.transition.text`, "must be 'step' | 'crossfade' | 'dip'");
1258
1754
  }
1259
1755
  }
1260
1756
  }
@@ -1311,7 +1807,7 @@ function validateSequence(seq) {
1311
1807
  code: "morph-structural-mismatch",
1312
1808
  message: `segments ${i}\u2192${i + 1} differ structurally: morph will fall back to cut`
1313
1809
  });
1314
- } else if (morphNothingMorphable(s.scene, next.scene, { textCrossfade: s.transition.text === "crossfade" })) {
1810
+ } else if (s.scene.timeline === void 0 && next.scene.timeline === void 0 && morphNothingMorphable(s.scene, next.scene, { textCrossfade: s.transition.text === "crossfade" || s.transition.text === "dip" })) {
1315
1811
  warnings.push({
1316
1812
  path: `segments[${i}].transition`,
1317
1813
  code: "morph-nothing-morphable",
@@ -1320,6 +1816,20 @@ function validateSequence(seq) {
1320
1816
  }
1321
1817
  }
1322
1818
  }
1819
+ if (errors.length === 0 && seq.wrapMorph === true) {
1820
+ const sq = seq;
1821
+ const last = sq.segments.length - 1;
1822
+ const lastTr = sq.segments[last]?.transition;
1823
+ let why = null;
1824
+ if (sq.loop !== true) why = "the sequence does not loop";
1825
+ else if (last < 1) why = "there is only one segment";
1826
+ else if (lastTr?.type !== "morph") why = "the last segment declares no morph transition";
1827
+ else if (structuralSignature(sq.segments[last].scene) !== structuralSignature(sq.segments[0].scene)) why = `segment ${last} and segment 0 differ structurally`;
1828
+ else if (morphChainRoot(sq, last) !== 0) why = `the lap is not one morph chain (segment ${morphChainRoot(sq, last)} starts a new chain), so the wrap would jump seeds`;
1829
+ if (why || !canWrapMorph(sq)) {
1830
+ warnings.push({ path: "wrapMorph", code: "wrap-morph-inactive", message: `wrapMorph has no effect: ${why ?? "the wrap cannot morph"} \u2014 the wrap stays a cut` });
1831
+ }
1832
+ }
1323
1833
  return { valid: errors.length === 0, errors, warnings };
1324
1834
  }
1325
1835
  function assertValidSequence(seq) {
@@ -1511,7 +2021,7 @@ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
1511
2021
  if (mSpec.type === "path") {
1512
2022
  pathPts = mSpec.points.map((p) => ({ x: p.x * w, y: p.y * h }));
1513
2023
  }
1514
- const ease = mSpec.type === "drift" || mSpec.type === "rise" || mSpec.type === "wander" ? mSpec.ease : void 0;
2024
+ const ease2 = mSpec.type === "drift" || mSpec.type === "rise" || mSpec.type === "wander" ? mSpec.ease : void 0;
1515
2025
  const clockPhase = layer.clock ? (layer.clock.phase ?? 0) % 1 * Math.PI * 2 : 0;
1516
2026
  const layout = layer.layout;
1517
2027
  const ordered = layout?.type === "list" || layout?.type === "table";
@@ -1530,6 +2040,8 @@ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
1530
2040
  if (layer.position) {
1531
2041
  listX0 = layer.position.x * w;
1532
2042
  listY0 = layer.position.y * h;
2043
+ if (layer.position.dx) listX0 += layer.position.dx * scale;
2044
+ if (layer.position.dy) listY0 += layer.position.dy * scale;
1533
2045
  } else {
1534
2046
  const [rx0, rx1] = layer.region?.x ?? [0, 1];
1535
2047
  const [ry0, ry1] = layer.region?.y ?? [0, 1];
@@ -1624,6 +2136,8 @@ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
1624
2136
  if (layer.position && layer.count === 1) {
1625
2137
  x0 = layer.position.x * w;
1626
2138
  y0 = layer.position.y * h;
2139
+ if (layer.position.dx) x0 += layer.position.dx * scale;
2140
+ if (layer.position.dy) y0 += layer.position.dy * scale;
1627
2141
  } else if (layout?.type === "list" || layout?.type === "table") {
1628
2142
  rng.next();
1629
2143
  rng.next();
@@ -1705,7 +2219,7 @@ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
1705
2219
  } : {},
1706
2220
  // Time-structure fields (2026-09): ease draws nothing; emit's one draw
1707
2221
  // per entity happens in a pass AFTER this loop (see below).
1708
- ...ease ? { ease: { type: ease.type, tau: ease.tau } } : {},
2222
+ ...ease2 ? { ease: { type: ease2.type, tau: ease2.tau } } : {},
1709
2223
  // Static rotation (2026-09-10): a scalar draws nothing; the range form
1710
2224
  // draws one value here, last, only when declared.
1711
2225
  ...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 } : {}
@@ -2422,110 +2936,6 @@ function screen(fp, pat, alpha, off, w, h) {
2422
2936
  p.restore();
2423
2937
  }
2424
2938
 
2425
- // src/sequence.ts
2426
- function segmentStart(seq, index) {
2427
- let t = 0;
2428
- const n = Math.max(0, Math.min(index, seq.segments.length - 1));
2429
- for (let i = 0; i < n; i++) t += seq.segments[i].duration ?? 0;
2430
- return t;
2431
- }
2432
- function resolveSegment(seq, T, opts = {}) {
2433
- const { segments, loop } = seq;
2434
- if (segments.length === 0) return { index: 0, localT: 0, startT: 0 };
2435
- let totalTimed = 0;
2436
- for (const seg of segments) {
2437
- if (seg.duration != null) totalTimed += seg.duration;
2438
- else break;
2439
- }
2440
- const t0 = Math.max(0, T);
2441
- const walk = (t, releasedBelow) => {
2442
- let cumulative = 0;
2443
- for (let i = 0; i < segments.length; i++) {
2444
- const seg = segments[i];
2445
- if (seg.duration == null) {
2446
- return loop ? null : { index: i, localT: t - cumulative, startT: cumulative };
2447
- }
2448
- if (t < cumulative + seg.duration) {
2449
- return { index: i, localT: t - cumulative, startT: cumulative };
2450
- }
2451
- if (seg.advance === "input" && i >= releasedBelow) {
2452
- return { index: i, localT: t - cumulative, startT: cumulative, held: true };
2453
- }
2454
- cumulative += seg.duration;
2455
- }
2456
- return null;
2457
- };
2458
- const first = walk(t0, opts.releasedBelow ?? 0);
2459
- if (first) return first;
2460
- if (loop && totalTimed > 0) {
2461
- const wrapped = walk(t0 % totalTimed, 0);
2462
- if (wrapped) return wrapped;
2463
- }
2464
- const last = segments.length - 1;
2465
- const lastStart = totalTimed - (segments[last].duration ?? 0);
2466
- return { index: last, localT: t0 - lastStart, startT: lastStart };
2467
- }
2468
- function canMorph(seq, from) {
2469
- const seg = seq.segments[from];
2470
- if (!seg || seg.transition?.type !== "morph") return false;
2471
- const next = seq.segments[from + 1];
2472
- if (!next) return false;
2473
- return structuralSignature(seg.scene) === structuralSignature(next.scene);
2474
- }
2475
- function morphChainRoot(seq, index) {
2476
- let i = index;
2477
- while (i > 0 && canMorph(seq, i - 1)) i--;
2478
- return i;
2479
- }
2480
- function normalizeSeed(seed) {
2481
- return seed >>> 0 || 1;
2482
- }
2483
- function segmentRenderSeed(seq, index) {
2484
- const root = morphChainRoot(seq, index);
2485
- const scene = seq.segments[root]?.scene;
2486
- if (!scene) return void 0;
2487
- const raw = scene.seed ?? (seq.seed !== void 0 ? seq.seed + root : void 0);
2488
- return raw === void 0 ? void 0 : normalizeSeed(raw);
2489
- }
2490
- function bedRenderSeed(seq) {
2491
- if (!seq.bed) return void 0;
2492
- const raw = seq.bed.seed ?? (seq.seed !== void 0 ? seq.seed + LIMITS.maxSegments : void 0);
2493
- return raw === void 0 ? void 0 : normalizeSeed(raw);
2494
- }
2495
- function sequenceWantsFinish(seq) {
2496
- return seq.finish !== void 0 || seq.segments.some((s) => s.scene.finish !== void 0);
2497
- }
2498
- function sequenceSwapCompatible(prev, next) {
2499
- if (prev.segments.length !== next.segments.length) return false;
2500
- if (prev.finish !== void 0 || next.finish !== void 0) {
2501
- if (prev.seed === void 0 !== (next.seed === void 0)) return false;
2502
- if (prev.seed !== void 0 && next.seed !== void 0 && normalizeSeed(prev.seed) !== normalizeSeed(next.seed)) return false;
2503
- }
2504
- if (sequenceWantsFinish(prev) !== sequenceWantsFinish(next)) return false;
2505
- if (prev.loop !== next.loop) return false;
2506
- if ((prev.sync ?? "mount") !== (next.sync ?? "mount")) return false;
2507
- if (!!prev.bed !== !!next.bed) return false;
2508
- if (prev.bed && next.bed) {
2509
- if (structuralSignature(prev.bed) !== structuralSignature(next.bed)) return false;
2510
- if (bedRenderSeed(prev) !== bedRenderSeed(next)) return false;
2511
- }
2512
- for (let i = 0; i < prev.segments.length; i++) {
2513
- const a = prev.segments[i];
2514
- const b = next.segments[i];
2515
- if (a.duration !== b.duration) return false;
2516
- if ((a.advance ?? "auto") !== (b.advance ?? "auto")) return false;
2517
- if (transitionKey(a.transition) !== transitionKey(b.transition)) return false;
2518
- if (structuralSignature(a.scene) !== structuralSignature(b.scene)) return false;
2519
- if (segmentRenderSeed(prev, i) !== segmentRenderSeed(next, i)) return false;
2520
- }
2521
- return true;
2522
- }
2523
- function transitionKey(tr) {
2524
- if (!tr || tr.type === "cut") return "cut";
2525
- if (tr.type === "morph") return `morph:${tr.dur}:${tr.text ?? "step"}`;
2526
- return `fade:${tr.dur}`;
2527
- }
2528
-
2529
2939
  // src/compile.ts
2530
2940
  var DEFAULT_STEER_DUR = 1e3;
2531
2941
  function applyRetainedDeltas(spec, deltas) {
@@ -2573,10 +2983,62 @@ function manifestFor(spec) {
2573
2983
  workerReady: true
2574
2984
  };
2575
2985
  }
2986
+ var clamp013 = (v) => v <= 0 ? 0 : v >= 1 ? 1 : v;
2987
+ var EXPIRED = /* @__PURE__ */ Symbol("expired");
2988
+ function overrideHeldAt(o, t) {
2989
+ const k = o.dur > 0 ? easeSmooth(clamp013((t - o.t0) / o.dur)) : 1;
2990
+ return k >= 1 ? o.to : lerpValue(o.from, o.to, k, false, o.leaf);
2991
+ }
2992
+ function overrideValueAt(o, t, timelineValue) {
2993
+ if (t < o.until) return overrideHeldAt(o, t);
2994
+ if (o.outDur > 0 && t < o.until + o.outDur) return lerpValue(overrideHeldAt(o, o.until), timelineValue(), easeSmooth((t - o.until) / o.outDur), false, o.leaf);
2995
+ return EXPIRED;
2996
+ }
2997
+ var steerId = (d) => `${d.t}|${JSON.stringify(d.value)}`;
2998
+ function armOverride(authored, path, from, to, now, glide) {
2999
+ const next = nextKeyAfter(authored, path, now);
3000
+ return { leaf: path.slice(path.lastIndexOf(".") + 1), from, to, t0: now, dur: glide, until: next ? next.at : Number.POSITIVE_INFINITY, outDur: next ? next.dur : 0 };
3001
+ }
3002
+ function applyOverridesAt(resolved, overrides, t, expired) {
3003
+ if (overrides.size === 0) return resolved;
3004
+ const deltas = [];
3005
+ for (const [path, o] of overrides) {
3006
+ const v = overrideValueAt(o, t, () => readSpecPath(resolved, path));
3007
+ if (v === EXPIRED) {
3008
+ expired?.push(path);
3009
+ continue;
3010
+ }
3011
+ deltas.push({ t: 0, path, value: v });
3012
+ }
3013
+ return deltas.length > 0 ? applyDeltasToSpec(resolved, deltas) : resolved;
3014
+ }
3015
+ function overridesStayValid(authored, overrides, now) {
3016
+ const paths = [...overrides.keys()];
3017
+ if (paths.length === 0) return true;
3018
+ const groupOf = (p) => p.split(".").slice(0, p.startsWith("layers.") ? 2 : 1).join(".");
3019
+ const groups = [...new Set(paths.map(groupOf))];
3020
+ const full = paths.some((p) => /^layers\.\d+(\.(key|count|motion)(\.|$)|$)/.test(p) || !/^(layers|background|finish)(\.|$)/.test(p));
3021
+ const only = full ? void 0 : (p) => groups.includes(groupOf(p));
3022
+ const seen = /* @__PURE__ */ new Set();
3023
+ for (const t of [now, ...timelineSampleTimesAfter(authored, now, false, only)]) {
3024
+ const held = /* @__PURE__ */ new Map();
3025
+ for (const [p, o] of overrides) if (t < o.until) held.set(p, { ...o, t0: Number.NEGATIVE_INFINITY });
3026
+ const r = applyOverridesAt(resolveTimelineAt(authored, t), held, t);
3027
+ const state = JSON.stringify(groups.map((g) => readSpecPath(r, g))) + "|" + [...held.keys()].join(",");
3028
+ if (seen.has(state)) continue;
3029
+ seen.add(state);
3030
+ if (!validateSpecPaths(r, paths)) return false;
3031
+ }
3032
+ return true;
3033
+ }
2576
3034
  var SpecInstance = class {
2577
3035
  constructor(spec, ctx, opts = {}) {
2578
3036
  this.spec = spec;
2579
3037
  this.effSpec = spec;
3038
+ if (spec.timeline) {
3039
+ this.authored = spec;
3040
+ this.effSpec = resolveTimelineAt(spec, 0);
3041
+ }
2580
3042
  this.saverCtx = ctx;
2581
3043
  this.transparent = opts.transparent === true;
2582
3044
  const tier = opts.capabilityTier;
@@ -2642,6 +3104,31 @@ var SpecInstance = class {
2642
3104
  lastStructural = "";
2643
3105
  /** See `LayerPaintOverride`; null (the default, and every plain mount) draws every layer once. */
2644
3106
  paintOverrides = null;
3107
+ /**
3108
+ * The authored spec when it carries a `timeline` (null for every other
3109
+ * scene — which then never enters the timeline path). Each painted frame
3110
+ * resolves it at that frame's own time, so ghosting's warm-up replay and
3111
+ * any seek see the timeline exactly where it was.
3112
+ */
3113
+ authored = null;
3114
+ /**
3115
+ * Live steers on a timeline scene, by index-form path. A steer on a path no
3116
+ * key animates holds forever (sticky, exactly as on a scene without a
3117
+ * timeline); on an animated path it holds `until` that path's next key,
3118
+ * then glides back to the timeline over `outDur`.
3119
+ */
3120
+ overrides = /* @__PURE__ */ new Map();
3121
+ /**
3122
+ * The last registered steer per path, as `t|value`. A host re-sends the
3123
+ * whole channel track on every steer (idle-server broadcasts the full track
3124
+ * so viewers converge), and a steer's `t` is the server's stamp — stable
3125
+ * across re-sends. Without this, an unrelated later steer would re-arm a
3126
+ * hold the timeline had already taken back. Keyed by path (not by the full
3127
+ * `path|t|value` history) so a long-running instance re-steered thousands
3128
+ * of times doesn't accumulate an unbounded set — only the most recent id
3129
+ * per path can ever recur.
3130
+ */
3131
+ seenSteers = /* @__PURE__ */ new Map();
2645
3132
  /** Raster short side for a `field` background — halved on the low tiers. */
2646
3133
  fieldShortSide;
2647
3134
  /** The `field` background's cached raster; null until a field is first painted. */
@@ -2666,12 +3153,14 @@ var SpecInstance = class {
2666
3153
  }
2667
3154
  sizeCanvas() {
2668
3155
  const dpr = Math.min(this.saverCtx.dpr, 2);
2669
- this.canvas.width = Math.max(1, Math.round(this.w * dpr));
2670
- this.canvas.height = Math.max(1, Math.round(this.h * dpr));
3156
+ const cw = Math.max(1, Math.round(this.w * dpr));
3157
+ const ch = Math.max(1, Math.round(this.h * dpr));
3158
+ if (this.canvas.width !== cw) this.canvas.width = cw;
3159
+ if (this.canvas.height !== ch) this.canvas.height = ch;
2671
3160
  this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
2672
3161
  if (this.finishPass) {
2673
- this.finishPass.canvas.width = this.canvas.width;
2674
- this.finishPass.canvas.height = this.canvas.height;
3162
+ if (this.finishPass.canvas.width !== cw) this.finishPass.canvas.width = cw;
3163
+ if (this.finishPass.canvas.height !== ch) this.finishPass.canvas.height = ch;
2675
3164
  this.finishPass.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
2676
3165
  }
2677
3166
  this.lastRenderT = Number.NEGATIVE_INFINITY;
@@ -3124,6 +3613,12 @@ var SpecInstance = class {
3124
3613
  */
3125
3614
  applyTrack(track) {
3126
3615
  const deltas = track?.deltas ?? [];
3616
+ if (this.authored) {
3617
+ const glide = deltas.length ? deltas.reduce((m, d) => Math.max(m, d.dur ?? DEFAULT_STEER_DUR), 0) : DEFAULT_STEER_DUR;
3618
+ if (!this.overrideAuthored(deltas, this.paused ? 0 : glide)) return;
3619
+ if (this.paused) this.renderFrame(this.sceneNow(), this.seed);
3620
+ return;
3621
+ }
3127
3622
  const target = applyDeltasToSpec(this.effSpec, deltas);
3128
3623
  if (!validateSpec(target).valid) return;
3129
3624
  const dur = deltas.length ? deltas.reduce((m, d) => Math.max(m, d.dur ?? DEFAULT_STEER_DUR), 0) : DEFAULT_STEER_DUR;
@@ -3160,6 +3655,13 @@ var SpecInstance = class {
3160
3655
  * showing a ghost of the un-steered scene.
3161
3656
  */
3162
3657
  applyDeltasNow(deltas) {
3658
+ if (this.authored) {
3659
+ const all = [...deltas];
3660
+ if (!this.overrideAuthored(all, 0)) for (const d of all) this.overrideAuthored([d], 0);
3661
+ this.transition = null;
3662
+ this.lastRenderT = Number.NEGATIVE_INFINITY;
3663
+ return;
3664
+ }
3163
3665
  const next = applyRetainedDeltas(this.effSpec, deltas);
3164
3666
  if (next === this.effSpec) return;
3165
3667
  this.transition = null;
@@ -3215,6 +3717,7 @@ var SpecInstance = class {
3215
3717
  * showing through — the ghosting smear. */
3216
3718
  paintFrame(t, bgAlpha) {
3217
3719
  const { ctx } = this;
3720
+ if (this.authored) this.resolveAuthored(t);
3218
3721
  if (!this.transparent) {
3219
3722
  ctx.globalAlpha = bgAlpha;
3220
3723
  ctx.globalCompositeOperation = "source-over";
@@ -3223,9 +3726,15 @@ var SpecInstance = class {
3223
3726
  ctx.globalAlpha = 1;
3224
3727
  for (let li = 0; li < this.layers.length; li++) {
3225
3728
  const built = this.layers[li];
3226
- const lifeA = lifeAlphaAt(built.layer.life, t);
3729
+ const opacity = built.layer.opacity;
3730
+ const lifeA = opacity === void 0 ? lifeAlphaAt(built.layer.life, t) : lifeAlphaAt(built.layer.life, t) * clamp013(opacity);
3227
3731
  if (lifeA <= 0) continue;
3228
3732
  const parentE = this.parentEntityFor(built);
3733
+ const tf = built.layer.transform;
3734
+ if (tf) {
3735
+ ctx.save();
3736
+ this.applyLayerTransform(tf);
3737
+ }
3229
3738
  ctx.globalCompositeOperation = built.layer.blend ?? "source-over";
3230
3739
  this.drawLinks(built, t, lifeA, parentE);
3231
3740
  const override = this.paintOverrides?.get(li) ?? null;
@@ -3235,13 +3744,80 @@ var SpecInstance = class {
3235
3744
  this.drawEntity(built, e, t, lifeA, parentE);
3236
3745
  continue;
3237
3746
  }
3238
- if (override.k < 1) this.drawEntity(built, e, t, lifeA, parentE, { sprite: override.outgoing, alpha: 1 - override.k });
3239
- if (override.k > 0) this.drawEntity(built, e, t, lifeA, parentE, { alpha: override.k });
3747
+ const [aOut, aIn] = override.mode === "dip" ? [Math.max(0, 1 - 2 * override.k), Math.max(0, 2 * override.k - 1)] : [1 - override.k, override.k];
3748
+ if (aOut > 0) this.drawEntity(built, e, t, lifeA, parentE, { sprite: override.outgoing, alpha: aOut });
3749
+ if (aIn > 0) this.drawEntity(built, e, t, lifeA, parentE, { alpha: aIn });
3240
3750
  }
3751
+ if (tf) ctx.restore();
3241
3752
  }
3242
3753
  ctx.globalAlpha = 1;
3243
3754
  ctx.globalCompositeOperation = "source-over";
3244
3755
  }
3756
+ /** A layer `transform`, about the viewport centre, in min(w,h) units (px under `units: 'px'`). */
3757
+ applyLayerTransform(tf) {
3758
+ const { ctx, w, h } = this;
3759
+ const unit = this.effSpec.units === "px" ? 1 : Math.min(w, h);
3760
+ const cx = w / 2;
3761
+ const cy = h / 2;
3762
+ ctx.translate(cx + (tf.x ?? 0) * unit, cy + (tf.y ?? 0) * unit);
3763
+ if (tf.rotate) ctx.rotate(tf.rotate * Math.PI / 180);
3764
+ const s = tf.scale ?? 1;
3765
+ ctx.scale(s * (tf.scaleX ?? 1), s);
3766
+ ctx.translate(-cx, -cy);
3767
+ }
3768
+ /** Scene time for a timeline decision: the last painted frame's, else the loop clock's. */
3769
+ sceneNow() {
3770
+ return this.lastRenderT !== Number.NEGATIVE_INFINITY ? this.lastRenderT : this.lastT;
3771
+ }
3772
+ /** Resolve the authored timeline at `t`, layer the live overrides on top, and adopt the result. */
3773
+ resolveAuthored(t) {
3774
+ const expired = [];
3775
+ const spec = applyOverridesAt(resolveTimelineAt(this.authored, t), this.overrides, t, expired);
3776
+ for (const p of expired) this.overrides.delete(p);
3777
+ this.effSpec = spec;
3778
+ const sig = structuralSignature(spec);
3779
+ if (sig !== this.lastStructural) this.rebuild();
3780
+ else this.layers.forEach((b, i) => {
3781
+ b.layer = spec.layers[i] ?? b.layer;
3782
+ });
3783
+ }
3784
+ /** Register live steers on a timeline scene under the steering rule (see `overrides`). */
3785
+ overrideAuthored(deltas, glide) {
3786
+ const now = this.sceneNow();
3787
+ const shown = this.effSpec;
3788
+ const latest = /* @__PURE__ */ new Map();
3789
+ for (const d of deltas) {
3790
+ const path = canonicalSpecPath(shown, d.path);
3791
+ if (!path) continue;
3792
+ latest.delete(path);
3793
+ latest.set(path, d);
3794
+ }
3795
+ const next = new Map(this.overrides);
3796
+ const armed = [];
3797
+ for (const [path, d] of latest) {
3798
+ const id = steerId(d);
3799
+ if (this.seenSteers.get(path) === id) continue;
3800
+ armed.push([path, id]);
3801
+ next.set(path, armOverride(this.authored, path, glide > 0 ? readSpecPath(shown, path) : d.value, d.value, now, glide));
3802
+ }
3803
+ if (armed.length === 0) return true;
3804
+ if (!overridesStayValid(this.authored, next, now)) return false;
3805
+ for (const [p, id] of armed) this.seenSteers.set(p, id);
3806
+ this.overrides = next;
3807
+ return true;
3808
+ }
3809
+ /** A copy of the live overrides — a sequence carries them into a morph's outgoing endpoint. */
3810
+ timelineOverrides() {
3811
+ return this.authored ? new Map(this.overrides) : null;
3812
+ }
3813
+ /** A copy of which server steers this child already applied, per field (see `seenSteers`). */
3814
+ timelineSeen() {
3815
+ return new Map(this.seenSteers);
3816
+ }
3817
+ /** The spec painted by the last frame (timeline and overrides resolved). */
3818
+ presentedSpec() {
3819
+ return this.effSpec;
3820
+ }
3245
3821
  /**
3246
3822
  * Deterministic, frame-addressable render (shared by the rAF loop).
3247
3823
  *
@@ -3294,6 +3870,11 @@ var SpecInstance = class {
3294
3870
  * already knows the signature is unchanged.
3295
3871
  */
3296
3872
  hotSwapSpec(spec) {
3873
+ this.adoptAuthored(spec);
3874
+ if (this.authored) {
3875
+ this.resolveAuthored(this.sceneNow());
3876
+ return;
3877
+ }
3297
3878
  this.effSpec = spec;
3298
3879
  const sig = structuralSignature(this.effSpec);
3299
3880
  if (sig !== this.lastStructural) this.rebuild();
@@ -3301,8 +3882,68 @@ var SpecInstance = class {
3301
3882
  b.layer = this.effSpec.layers[i] ?? b.layer;
3302
3883
  });
3303
3884
  }
3885
+ /**
3886
+ * Track whether `spec` carries a timeline. Live overrides belong to the
3887
+ * scene they were made on: a different spec (a new publish, a sequence's
3888
+ * re-steered segment, a morph frame) drops them, as a plain hot-swap
3889
+ * replaces `effSpec` wholesale; the host re-sends the live track.
3890
+ */
3891
+ adoptAuthored(spec, keepSteers = false) {
3892
+ const next = spec.timeline ? spec : null;
3893
+ if (next !== this.authored && !(keepSteers && next && this.authored)) {
3894
+ this.overrides.clear();
3895
+ this.seenSteers.clear();
3896
+ }
3897
+ this.authored = next;
3898
+ }
3899
+ /**
3900
+ * A sequence's hot swap of a timeline segment: the new scene replaces the
3901
+ * old, but the live steers (and which server steers were already applied)
3902
+ * carry over — a steer the timeline already took back stays taken back —
3903
+ * and only a steer this child has never seen is armed, now. Frame
3904
+ * continuity is kept, so ghosting trails do not restart.
3905
+ */
3906
+ hotSwapKeepingSteers(spec, retained) {
3907
+ if (!spec.timeline) {
3908
+ this.hotSwapSpec(spec);
3909
+ return;
3910
+ }
3911
+ if (!this.authored) {
3912
+ this.hotSwapSpec(spec);
3913
+ this.applyDeltasNow(retained);
3914
+ return;
3915
+ }
3916
+ this.adoptAuthored(spec, true);
3917
+ for (const d of retained) this.overrideAuthored([d], 0);
3918
+ this.resolveAuthored(this.sceneNow());
3919
+ }
3920
+ /** Arm a steer made while a sequence was mid-morph, on the outgoing end's override set, at that end's scene time. */
3921
+ static armInto(authored, overrides, seen, deltas, now) {
3922
+ const at = resolveTimelineAt(authored, now);
3923
+ const latest = /* @__PURE__ */ new Map();
3924
+ for (const d of deltas) {
3925
+ const path = canonicalSpecPath(at, d.path);
3926
+ if (!path) continue;
3927
+ latest.delete(path);
3928
+ latest.set(path, d);
3929
+ }
3930
+ for (const [path, d] of latest) {
3931
+ const id = steerId(d);
3932
+ if (seen.get(path) === id) continue;
3933
+ const trial = new Map(overrides).set(path, armOverride(authored, path, d.value, d.value, now, 0));
3934
+ if (overridesStayValid(authored, trial, now)) {
3935
+ overrides.set(path, trial.get(path));
3936
+ seen.set(path, id);
3937
+ }
3938
+ }
3939
+ }
3304
3940
  /** Paint-only hot-swap: skips structuralSignature (caller guarantees match). */
3305
3941
  hotSwapPaint(spec) {
3942
+ this.adoptAuthored(spec);
3943
+ if (this.authored) {
3944
+ this.resolveAuthored(this.sceneNow());
3945
+ return;
3946
+ }
3306
3947
  this.effSpec = spec;
3307
3948
  this.layers.forEach((b, i) => {
3308
3949
  b.layer = this.effSpec.layers[i] ?? b.layer;
@@ -3378,6 +4019,8 @@ var SequenceInstance = class {
3378
4019
  * window (if any) has passed.
3379
4020
  */
3380
4021
  pendingWrapFade = false;
4022
+ /** The morph in progress: its ends, and the override sets each end resolves with (see `morphEndpoint`). */
4023
+ morphEnds = null;
3381
4024
  /**
3382
4025
  * Every non-`sequence.segment` delta this instance has been handed, last
3383
4026
  * wins per path, merged across `applyTrack` calls. Children are created
@@ -3502,10 +4145,15 @@ var SequenceInstance = class {
3502
4145
  const tr = this.seq.segments[from]?.transition;
3503
4146
  return tr?.type === "morph" ? tr.dur : 0;
3504
4147
  }
3505
- /** Whether the morph out of `from` cross-fades its text (default `step`: strings switch at k > 0). */
4148
+ /** Whether the morph out of `from` paints its text in two passes — `crossfade` or `dip` (default `step`: strings switch at k > 0). */
3506
4149
  morphTextCrossfades(from) {
3507
4150
  const tr = this.seq.segments[from]?.transition;
3508
- return tr?.type === "morph" && tr.text === "crossfade";
4151
+ return tr?.type === "morph" && (tr.text === "crossfade" || tr.text === "dip");
4152
+ }
4153
+ /** The morph out of `from`'s two-pass text mode, for `crossfadeOverrides`. */
4154
+ morphTextMode(from) {
4155
+ const tr = this.seq.segments[from]?.transition;
4156
+ return tr?.type === "morph" && tr.text === "dip" ? "dip" : "crossfade";
3509
4157
  }
3510
4158
  /**
3511
4159
  * The paint overrides for one crossfade frame: every text layer whose
@@ -3514,7 +4162,7 @@ var SequenceInstance = class {
3514
4162
  * usual) at `1 − k` under the incoming at `k`. Null when nothing differs,
3515
4163
  * so a crossfade between same-worded twins is a plain morph frame.
3516
4164
  */
3517
- crossfadeOverrides(specA, specB, lerped, k) {
4165
+ crossfadeOverrides(specA, specB, lerped, k, mode = "crossfade") {
3518
4166
  let map = null;
3519
4167
  for (let i = 0; i < lerped.layers.length; i++) {
3520
4168
  if (!textStringsDiffer(specA, specB, i)) continue;
@@ -3522,7 +4170,7 @@ var SequenceInstance = class {
3522
4170
  const now = lerped.layers[i].sprite;
3523
4171
  const outgoing = from.kind === "text" && now.kind === "text" ? { ...now, strings: from.strings } : from.kind === "textBlock" && now.kind === "textBlock" ? { ...now, text: from.text } : now;
3524
4172
  if (outgoing === now) continue;
3525
- (map ??= /* @__PURE__ */ new Map()).set(i, { outgoing, k });
4173
+ (map ??= /* @__PURE__ */ new Map()).set(i, mode === "dip" ? { outgoing, k, mode } : { outgoing, k });
3526
4174
  }
3527
4175
  return map;
3528
4176
  }
@@ -3595,6 +4243,46 @@ var SequenceInstance = class {
3595
4243
  this.renderOffscreen(this.fading, outDur + localT, this.childSeed(this.morphChainRoot(from), seed));
3596
4244
  return this.fading;
3597
4245
  }
4246
+ /**
4247
+ * One end of a morph at segment time `t`. A scene without a timeline is its
4248
+ * steered scene, as always. With a timeline, the retained track follows the
4249
+ * same rule a child applies (held until the path's next key, sticky
4250
+ * otherwise): the outgoing end uses its child's live overrides when the
4251
+ * morph began from one, and otherwise — like the incoming end, whose child
4252
+ * does not exist yet — arms the retained steers at segment time 0, as a
4253
+ * freshly mounted child would.
4254
+ */
4255
+ morphEndpoint(index, t, side) {
4256
+ const scene = this.childScene(index);
4257
+ if (!scene.timeline) return this.steeredScene(scene);
4258
+ const ends = this.morphEnds;
4259
+ if (ends && side === "out") ends.outT = t;
4260
+ let ov = side === "out" ? ends?.out ?? null : ends?.in ?? null;
4261
+ if (!ov) {
4262
+ ov = this.armRetained(scene, side === "out" ? ends?.seenOut : void 0);
4263
+ if (ends) {
4264
+ if (side === "out") ends.out = ov;
4265
+ else ends.in = ov;
4266
+ }
4267
+ }
4268
+ return applyOverridesAt(resolveTimelineAt(scene, t), ov, t);
4269
+ }
4270
+ /** The retained steers that resolve (and validate) on a timeline scene, armed at segment time 0. */
4271
+ armRetained(scene, seen) {
4272
+ const out = /* @__PURE__ */ new Map();
4273
+ if (this.retainedDeltas.size === 0) return out;
4274
+ const at0 = resolveTimelineAt(scene, 0);
4275
+ for (const d of this.retainedDeltas.values()) {
4276
+ const path = canonicalSpecPath(at0, d.path);
4277
+ if (!path) continue;
4278
+ const trial = new Map(out).set(path, armOverride(scene, path, d.value, d.value, 0, 0));
4279
+ if (overridesStayValid(scene, trial, 0)) {
4280
+ out.set(path, trial.get(path));
4281
+ seen?.set(path, steerId(d));
4282
+ }
4283
+ }
4284
+ return out;
4285
+ }
3598
4286
  /** A segment's scene with the retained track applied — `scene` itself when nothing resolves on it. */
3599
4287
  steeredScene(scene) {
3600
4288
  return this.retainedDeltas.size === 0 ? scene : applyRetainedDeltas(scene, this.retainedDeltas.values());
@@ -3613,7 +4301,7 @@ var SequenceInstance = class {
3613
4301
  if (index < 0 || index >= this.seq.segments.length) index = 0;
3614
4302
  let child = this.children[index];
3615
4303
  if (!child) {
3616
- child = new SpecInstance(rootScene ?? this.childScene(index), this.childCtx, { transparent: this.bed !== null, skipInitialPaint, capabilityTier: this.capabilityTier, hostPresents: true });
4304
+ child = new SpecInstance(rootScene ?? this.childScene(index), this.childCtx, { transparent: this.bed !== null, skipInitialPaint: skipInitialPaint || this.bed !== null, capabilityTier: this.capabilityTier, hostPresents: true });
3617
4305
  this.children[index] = child;
3618
4306
  child.setPaused(true);
3619
4307
  if (rootScene) child.hotSwapSpec(this.childScene(index));
@@ -3668,7 +4356,12 @@ var SequenceInstance = class {
3668
4356
  frameFinish() {
3669
4357
  if (this.seq.finish !== void 0) return this.seq.finish;
3670
4358
  if (this.activeIndex < 0) return void 0;
3671
- return this.steeredScene(this.childScene(this.activeIndex)).finish;
4359
+ const scene = this.childScene(this.activeIndex);
4360
+ if (scene.timeline) {
4361
+ const live = (this.fadingIn?.index === this.activeIndex ? this.fadingIn.child : null) ?? this.children[this.activeIndex] ?? this.children[this.morphChainRoot(this.activeIndex)];
4362
+ if (live) return live.presentedSpec().finish;
4363
+ }
4364
+ return this.steeredScene(scene).finish;
3672
4365
  }
3673
4366
  /**
3674
4367
  * The seed `frameFinish()`'s grain resolves against — the sequence's own
@@ -3693,29 +4386,36 @@ var SequenceInstance = class {
3693
4386
  const resolved = resolveSegment(this.seq, T + this.clockOffset, { releasedBelow: this.releasedBelow });
3694
4387
  const { index, localT } = resolved;
3695
4388
  this.bed?.renderFrame(T, this.seq.bed?.seed ?? this.seq.seed ?? seed);
3696
- const prevIdx = index > 0 ? index - 1 : -1;
3697
- const morphActive = prevIdx >= 0 && this.canMorph(prevIdx) && localT < this.morphDur(prevIdx);
3698
4389
  const lastIdx = this.seq.segments.length - 1;
3699
4390
  const wrapped = this.seq.loop && index === 0 && lastIdx > 0 && (T + this.clockOffset >= this.timedTotal() || this.pendingWrapFade);
4391
+ const naturalWrap = this.seq.loop && index === 0 && lastIdx > 0 && T + this.clockOffset >= this.timedTotal();
4392
+ const prevIdx = index > 0 ? index - 1 : -1;
4393
+ const morphFrom = prevIdx >= 0 ? prevIdx : naturalWrap && canWrapMorph(this.seq) ? lastIdx : -1;
4394
+ const morphActive = morphFrom >= 0 && (morphFrom === prevIdx ? this.canMorph(prevIdx) : true) && localT < this.morphDur(morphFrom);
3700
4395
  const fadeFrom = prevIdx >= 0 ? prevIdx : wrapped ? lastIdx : -1;
3701
4396
  const fadeActive = fadeFrom >= 0 && localT < this.fadeDur(fadeFrom);
3702
4397
  if (!fadeActive && this.fading) this.releaseFading();
3703
4398
  if (this.pendingWrapFade && (index !== 0 || !fadeActive)) this.pendingWrapFade = false;
3704
4399
  if (morphActive) {
3705
4400
  const chainRoot = this.morphChainRoot(index);
3706
- this.morphFromIndex = prevIdx;
4401
+ if (this.morphFromIndex !== morphFrom || this.morphEnds?.to !== index) {
4402
+ const outChild = this.children[morphFrom];
4403
+ this.morphEnds = { from: morphFrom, to: index, outT: 0, out: outChild?.timelineOverrides() ?? null, seenOut: outChild?.timelineSeen() ?? /* @__PURE__ */ new Map(), in: null };
4404
+ }
4405
+ this.morphFromIndex = morphFrom;
3707
4406
  this.activeIndex = index;
3708
4407
  for (let i = 0; i < this.children.length; i++) {
3709
4408
  if (i !== chainRoot) this.releaseChild(i);
3710
4409
  }
3711
- const child = this.ensureChild(chainRoot, void 0, this.morphTextCrossfades(prevIdx));
3712
- const dur = this.morphDur(prevIdx);
4410
+ const child = this.ensureChild(chainRoot, void 0, this.morphTextCrossfades(morphFrom));
4411
+ const dur = this.morphDur(morphFrom);
3713
4412
  const k = easeSmooth(localT / dur);
3714
- const specA = this.steeredScene(this.childScene(prevIdx));
3715
- const specB = this.steeredScene(this.childScene(index));
4413
+ const outDur = this.seq.segments[morphFrom].duration ?? 0;
4414
+ const specA = this.morphEndpoint(morphFrom, outDur + localT, "out");
4415
+ const specB = this.morphEndpoint(index, localT, "in");
3716
4416
  const lerped = lerpSpec(specA, specB, k);
3717
4417
  child.hotSwapSpec(lerped);
3718
- child.setLayerPaintOverrides(this.morphTextCrossfades(prevIdx) ? this.crossfadeOverrides(specA, specB, lerped, k) : null);
4418
+ child.setLayerPaintOverrides(this.morphTextCrossfades(morphFrom) ? this.crossfadeOverrides(specA, specB, lerped, k, this.morphTextMode(morphFrom)) : null);
3719
4419
  child.renderFrame(localT, this.childSeed(chainRoot, seed));
3720
4420
  } else {
3721
4421
  if (this.morphFromIndex >= 0) {
@@ -3723,6 +4423,7 @@ var SequenceInstance = class {
3723
4423
  this.releaseChild(oldRoot);
3724
4424
  if (oldRoot !== this.morphFromIndex) this.releaseChild(this.morphFromIndex);
3725
4425
  this.morphFromIndex = -1;
4426
+ this.morphEnds = null;
3726
4427
  }
3727
4428
  if (index !== this.activeIndex) {
3728
4429
  for (let i = 0; i < this.children.length; i++) {
@@ -3802,6 +4503,12 @@ var SequenceInstance = class {
3802
4503
  const childDeltas = deltas.filter((d) => d.path !== "sequence.segment");
3803
4504
  if (childDeltas.length > 0) {
3804
4505
  if (this.morphFromIndex >= 0) {
4506
+ const ends = this.morphEnds;
4507
+ if (ends) {
4508
+ const outScene = this.childScene(ends.from);
4509
+ if (ends.out && outScene.timeline) SpecInstance.armInto(outScene, ends.out, ends.seenOut, childDeltas, ends.outT);
4510
+ ends.in = null;
4511
+ }
3805
4512
  if (!switchedSegment) this.renderFrame(this.renderedT, this.seed);
3806
4513
  } else if (this.activeIndex >= 0) {
3807
4514
  const child = this.children[this.activeIndex];
@@ -3831,11 +4538,19 @@ var SequenceInstance = class {
3831
4538
  if (!sequenceSwapCompatible(this.seq, next)) return false;
3832
4539
  this.seq = next;
3833
4540
  if (this.bed && next.bed) this.bed.hotSwapSpec(applyRetainedDeltas(this.bedScene(next.bed), this.retainedBedDeltas.values()));
3834
- for (let i = 0; i < this.children.length; i++) {
3835
- this.children[i]?.hotSwapSpec(this.steeredScene(this.childScene(i)));
3836
- }
3837
- if (this.fading) this.fading.child.hotSwapSpec(this.steeredScene(this.childScene(this.fading.index)));
3838
- if (this.fadingIn) this.fadingIn.child.hotSwapSpec(this.steeredScene(this.childScene(this.fadingIn.index)));
4541
+ const swap = (child, index) => {
4542
+ if (!child) return;
4543
+ const scene = this.childScene(index);
4544
+ if (!scene.timeline) {
4545
+ child.hotSwapSpec(this.steeredScene(scene));
4546
+ return;
4547
+ }
4548
+ child.hotSwapKeepingSteers(scene, this.retainedDeltas.values());
4549
+ };
4550
+ for (let i = 0; i < this.children.length; i++) swap(this.children[i], i);
4551
+ if (this.fading) swap(this.fading.child, this.fading.index);
4552
+ if (this.fadingIn) swap(this.fadingIn.child, this.fadingIn.index);
4553
+ this.morphEnds = null;
3839
4554
  if (this.paused) this.renderFrame(this.renderedT, this.seed);
3840
4555
  return true;
3841
4556
  }
@@ -4228,12 +4943,43 @@ function seamsWorthWarning(layer, c, entityCount) {
4228
4943
  return layer.blend !== "lighter" && layer.blend !== "screen";
4229
4944
  }
4230
4945
 
4946
+ // src/paint.ts
4947
+ function transformPoint(tf, p, w, h, unit) {
4948
+ const s = tf.scale ?? 1;
4949
+ const sx = s * (tf.scaleX ?? 1);
4950
+ let x = (p.x - w / 2) * sx;
4951
+ let y = (p.y - h / 2) * s;
4952
+ if (tf.rotate) {
4953
+ const r = tf.rotate * Math.PI / 180;
4954
+ const c = Math.cos(r);
4955
+ const n = Math.sin(r);
4956
+ [x, y] = [x * c - y * n, x * n + y * c];
4957
+ }
4958
+ return { x: x + w / 2 + (tf.x ?? 0) * unit, y: y + h / 2 + (tf.y ?? 0) * unit };
4959
+ }
4960
+ function transformSize(tf) {
4961
+ if (!tf) return 1;
4962
+ const s = tf.scale ?? 1;
4963
+ return s * Math.sqrt(Math.abs(tf.scaleX ?? 1));
4964
+ }
4965
+ var layerOpacity = (layer) => layer.opacity === void 0 ? 1 : Math.max(0, Math.min(1, layer.opacity));
4966
+ function transformBox(tf, box, w, h, unit) {
4967
+ const pts = [[box.x0, box.y0], [box.x1, box.y0], [box.x0, box.y1], [box.x1, box.y1]].map(([x, y]) => transformPoint(tf, { x, y }, w, h, unit));
4968
+ return {
4969
+ x0: Math.min(...pts.map((p) => p.x)),
4970
+ y0: Math.min(...pts.map((p) => p.y)),
4971
+ x1: Math.max(...pts.map((p) => p.x)),
4972
+ y1: Math.max(...pts.map((p) => p.y))
4973
+ };
4974
+ }
4975
+
4231
4976
  // src/advise.ts
4232
4977
  var LOW_CONTRAST_FLOOR = 0.05;
4233
4978
  var SPARSE_DECLARED_MAX_COVERAGE = 0.02;
4234
4979
  var READ_LEGIBILITY_FLOOR = 4.5;
4235
4980
  var READ_SAFE_AREA = 0.05;
4236
4981
  function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
4982
+ spec = resolveTimelineAt(spec, opts.t ?? COHESION_T);
4237
4983
  const warnings = [];
4238
4984
  const w = viewport.width;
4239
4985
  const h = viewport.height;
@@ -4331,6 +5077,8 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
4331
5077
  let pixArea;
4332
5078
  if (layer.sprite.kind === "circle") {
4333
5079
  pixArea = Math.PI * r * r;
5080
+ } else if (layer.sprite.kind === "polygon") {
5081
+ pixArea = polygonArea(polygonPoints(layer.sprite, r));
4334
5082
  } else if (layer.sprite.kind === "bar") {
4335
5083
  pixArea = e.size * barFraction(layer.sprite, e.barIndex ?? 0) * (e.size2 ?? e.size * 0.2);
4336
5084
  } else if (layer.sprite.kind === "textBlock") {
@@ -4457,8 +5205,10 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
4457
5205
  const label = layer.key ? `\`${layer.key}\`` : `layers[${li}]`;
4458
5206
  for (const e of allEntities[li]) {
4459
5207
  const p = positionAt(e, 0, w, h);
4460
- const box = s.kind === "textBlock" ? textBlockBoxAt(s, p, w, h) : textBoxAt(s, e, p, spec, w, h);
4461
- const alpha = Math.max(0, Math.min(1, e.alpha - e.pulseAmp)) * (s.kind === "textBlock" ? s.opacity ?? 1 : 1);
5208
+ const raw = s.kind === "textBlock" ? textBlockBoxAt(s, p, w, h) : textBoxAt(s, e, p, spec, w, h);
5209
+ const box = layer.transform ? { ...raw, ...transformBox(layer.transform, raw, w, h, scale) } : raw;
5210
+ const baseAlpha = Math.max(0, Math.min(1, e.alpha - e.pulseAmp)) * (s.kind === "textBlock" ? s.opacity ?? 1 : 1);
5211
+ const alpha = layer.opacity === void 0 ? baseAlpha : baseAlpha * layerOpacity(layer);
4462
5212
  textBoxes.push({ li, label, alpha, ...box });
4463
5213
  }
4464
5214
  }
@@ -4587,8 +5337,8 @@ function brightestAdditivePlate(spec, allEntities, ground, box, w, h) {
4587
5337
  const growScale = (e.motion === "warp" ? WARP_MAX_SCALE : 1) * (e.emit ? Math.max(e.emit.growFrom, e.emit.growTo) : 1);
4588
5338
  const maxDim = Math.max(e.size, e.size2 ?? 0) * (1 + e.growAmp) * growScale;
4589
5339
  if (maxDim < box.fs) continue;
4590
- if (!entityReachesBox(e, maxDim / 2, box, w, h)) continue;
4591
- const a = Math.min(1, e.alpha + e.pulseAmp);
5340
+ if (!layer.transform && !entityReachesBox(e, maxDim / 2, box, w, h)) continue;
5341
+ const a = layer.opacity === void 0 ? Math.min(1, e.alpha + e.pulseAmp) : Math.min(1, e.alpha + e.pulseAmp) * layerOpacity(layer);
4592
5342
  if (a <= 0) continue;
4593
5343
  const plate = additivePlate(ground, hexRgb(hex), a, blend);
4594
5344
  const lum = relativeLuminance(plate);
@@ -4655,8 +5405,8 @@ function adviseSequence(seq, viewport = { width: 1920, height: 1080 }) {
4655
5405
  });
4656
5406
  }
4657
5407
  for (let i = 0; i < seq.segments.length - 1; i++) {
4658
- const lumaA = backgroundLuma(seq.segments[i].scene);
4659
- const lumaB = backgroundLuma(seq.segments[i + 1].scene);
5408
+ const lumaA = backgroundLuma(resolveTimelineAt(seq.segments[i].scene, seq.segments[i].duration ?? 0));
5409
+ const lumaB = backgroundLuma(resolveTimelineAt(seq.segments[i + 1].scene, 0));
4660
5410
  const delta = Math.abs(lumaA - lumaB);
4661
5411
  if (delta > 0.5) {
4662
5412
  warnings.push({
@@ -4675,7 +5425,7 @@ function adviseSequence(seq, viewport = { width: 1920, height: 1080 }) {
4675
5425
  code: "morph-structural-mismatch",
4676
5426
  message: `segments ${i}\u2192${i + 1} differ structurally: morph will fall back to cut`
4677
5427
  });
4678
- } else if (morphNothingMorphable(seq.segments[i].scene, seq.segments[i + 1].scene, { textCrossfade: tr.text === "crossfade" })) {
5428
+ } else if (!seq.segments[i].scene.timeline && !seq.segments[i + 1].scene.timeline && morphNothingMorphable(seq.segments[i].scene, seq.segments[i + 1].scene, { textCrossfade: tr.text === "crossfade" || tr.text === "dip" })) {
4679
5429
  warnings.push({
4680
5430
  path: `segments[${i}].transition`,
4681
5431
  code: "morph-nothing-morphable",
@@ -4709,7 +5459,7 @@ function describeScene(spec, options = {}) {
4709
5459
  let pixArea = 0;
4710
5460
  for (const e of entities) {
4711
5461
  const r = e.size / 2;
4712
- pixArea += layer.sprite.kind === "circle" ? Math.PI * r * r : e.size * e.size;
5462
+ pixArea += layer.sprite.kind === "circle" ? Math.PI * r * r : layer.sprite.kind === "polygon" ? polygonArea(polygonPoints(layer.sprite, r)) : e.size * e.size;
4713
5463
  }
4714
5464
  const coverage = pixArea / (w * h);
4715
5465
  const meanAlpha = entities.length > 0 ? entities.reduce((s, e) => s + e.alpha, 0) / entities.length : 0;
@@ -4785,6 +5535,8 @@ import { createRng as createRng4 } from "@idle-screens/core";
4785
5535
  var GLOW_SPREAD = 2.4;
4786
5536
  var LINE_SALIENCE = 3.5;
4787
5537
  var GHOST_TAPS = 12;
5538
+ var POLYGON_OUTLINE_CELLS = 3;
5539
+ var POLYGON_SCANLINES = 4;
4788
5540
  function buildScene(spec, opts) {
4789
5541
  const { width: w, height: h } = opts.viewport ?? { width: 1920, height: 1080 };
4790
5542
  const seed = normalizeSeed(opts.seed ?? spec.seed ?? 42);
@@ -4799,7 +5551,13 @@ function buildScene(spec, opts) {
4799
5551
  const layers = spec.layers.map((layer) => ({ layer, entities: buildEntities(layer, rng, w, h, scale, countScale) }));
4800
5552
  const byKey = /* @__PURE__ */ new Map();
4801
5553
  for (const { layer, entities } of layers) if (layer.key) byKey.set(layer.key, entities);
4802
- return { w, h, scale, seed, layers, byKey };
5554
+ let transforms = null;
5555
+ for (const { layer, entities } of layers) {
5556
+ if (!layer.transform) continue;
5557
+ transforms ??= /* @__PURE__ */ new Map();
5558
+ for (const e of entities) transforms.set(e, layer.transform);
5559
+ }
5560
+ return { w, h, scale, seed, layers, byKey, transforms };
4803
5561
  }
4804
5562
  function posOf(scene, e, t) {
4805
5563
  const p = positionAt(e, t, scene.w, scene.h);
@@ -4811,7 +5569,8 @@ function posOf(scene, e, t) {
4811
5569
  p.y += pp.y;
4812
5570
  }
4813
5571
  }
4814
- return p;
5572
+ const tf = scene.transforms?.get(e);
5573
+ return tf ? transformPoint(tf, p, scene.w, scene.h, scene.scale) : p;
4815
5574
  }
4816
5575
  function textBox(s, e, p) {
4817
5576
  const m = s.font ? /(\d+(?:\.\d+)?)px/.exec(s.font) : null;
@@ -4863,7 +5622,66 @@ function textBlockRevealFraction(s, w, h, t) {
4863
5622
  }
4864
5623
  return rs.progress;
4865
5624
  }
5625
+ function polygonCellCoverage(pts, cellW, cellH, c0, c1, r0, r1) {
5626
+ const nc = c1 - c0 + 1;
5627
+ const cov = new Float64Array(nc * (r1 - r0 + 1));
5628
+ const xMin = c0 * cellW;
5629
+ const xMax = (c1 + 1) * cellW;
5630
+ const share = 1 / POLYGON_SCANLINES;
5631
+ const hits = [];
5632
+ const addSpan = (row, xa, xb) => {
5633
+ const a = Math.max(xMin, xa);
5634
+ const b = Math.min(xMax, xb);
5635
+ if (!(b > a)) return;
5636
+ const first = Math.max(c0, Math.floor(a / cellW));
5637
+ const last = Math.min(c1, Math.floor(b / cellW));
5638
+ for (let c = first; c <= last; c++) {
5639
+ const len = Math.min(b, (c + 1) * cellW) - Math.max(a, c * cellW);
5640
+ if (len > 0) cov[row * nc + (c - c0)] += len / cellW * share;
5641
+ }
5642
+ };
5643
+ for (let r = r0; r <= r1; r++) {
5644
+ for (let k = 0; k < POLYGON_SCANLINES; k++) {
5645
+ const y = (r + (k + 0.5) / POLYGON_SCANLINES) * cellH;
5646
+ hits.length = 0;
5647
+ for (let i = 0; i < pts.length; i++) {
5648
+ const p = pts[i];
5649
+ const q = pts[(i + 1) % pts.length];
5650
+ const down = p.y <= y && q.y > y;
5651
+ if (!down && !(q.y <= y && p.y > y)) continue;
5652
+ hits.push({ x: p.x + (y - p.y) / (q.y - p.y) * (q.x - p.x), dir: down ? 1 : -1 });
5653
+ }
5654
+ if (hits.length < 2) continue;
5655
+ hits.sort((m, n) => m.x - n.x);
5656
+ let wind = 0;
5657
+ let start = 0;
5658
+ for (const hit of hits) {
5659
+ const before = wind;
5660
+ wind += hit.dir;
5661
+ if (before === 0 && wind !== 0) start = hit.x;
5662
+ else if (before !== 0 && wind === 0) addSpan(r - r0, start, hit.x);
5663
+ }
5664
+ }
5665
+ }
5666
+ return cov;
5667
+ }
5668
+ function distanceToOutline(pts, x, y) {
5669
+ let best = Infinity;
5670
+ for (let i = 0; i < pts.length; i++) {
5671
+ const p = pts[i];
5672
+ const q = pts[(i + 1) % pts.length];
5673
+ const ex = q.x - p.x;
5674
+ const ey = q.y - p.y;
5675
+ const len2 = ex * ex + ey * ey;
5676
+ const u = len2 > 0 ? Math.max(0, Math.min(1, ((x - p.x) * ex + (y - p.y) * ey) / len2)) : 0;
5677
+ const dx = x - (p.x + ex * u);
5678
+ const dy = y - (p.y + ey * u);
5679
+ best = Math.min(best, dx * dx + dy * dy);
5680
+ }
5681
+ return Math.sqrt(best);
5682
+ }
4866
5683
  function luminanceGrid(spec, opts = {}) {
5684
+ spec = resolveTimelineAt(spec, opts.t ?? 5e3);
4867
5685
  const scene = buildScene(spec, opts);
4868
5686
  const t = opts.t ?? 5e3;
4869
5687
  const cols = Math.max(8, Math.min(200, opts.cols ?? 80));
@@ -4962,9 +5780,66 @@ function luminanceGrid(spec, opts = {}) {
4962
5780
  }
4963
5781
  }
4964
5782
  };
5783
+ const splatPolygonOutline = (layer, s, e, p, sz, a, lum, tPass) => {
5784
+ const radius = sz / 2;
5785
+ const rot = rotationAt(e, tPass);
5786
+ const cr = Math.cos(rot);
5787
+ const sr = Math.sin(rot);
5788
+ const pts = polygonPoints(s, radius).map((q) => ({ x: p.x + q.x * cr - q.y * sr, y: p.y + q.x * sr + q.y * cr }));
5789
+ const additive = layer.blend === "lighter" || layer.blend === "screen";
5790
+ const glow = !!s.soft && additive;
5791
+ let band = 0;
5792
+ if (glow) {
5793
+ const perimeter = pts.reduce((acc, q, i) => {
5794
+ const n = pts[(i + 1) % pts.length];
5795
+ return acc + Math.hypot(n.x - q.x, n.y - q.y);
5796
+ }, 0);
5797
+ band = perimeter > 0 ? (GLOW_SPREAD - 1) * (2 * polygonArea(pts) / perimeter) : 0;
5798
+ }
5799
+ let minX = Infinity;
5800
+ let maxX = -Infinity;
5801
+ let minY = Infinity;
5802
+ let maxY = -Infinity;
5803
+ for (const q of pts) {
5804
+ minX = Math.min(minX, q.x);
5805
+ maxX = Math.max(maxX, q.x);
5806
+ minY = Math.min(minY, q.y);
5807
+ maxY = Math.max(maxY, q.y);
5808
+ }
5809
+ const c0 = Math.max(0, Math.floor((minX - band) / cellW));
5810
+ const c1 = Math.min(cols - 1, Math.floor((maxX + band) / cellW));
5811
+ const r0 = Math.max(0, Math.floor((minY - band) / cellH));
5812
+ const r1 = Math.min(rows - 1, Math.floor((maxY + band) / cellH));
5813
+ if (c0 > c1 || r0 > r1) return;
5814
+ const cov = polygonCellCoverage(pts, cellW, cellH, c0, c1, r0, r1);
5815
+ const nc = c1 - c0 + 1;
5816
+ for (let r = r0; r <= r1; r++) {
5817
+ for (let c = c0; c <= c1; c++) {
5818
+ const f = cov[(r - r0) * nc + (c - c0)];
5819
+ const x = (c + 0.5) * cellW;
5820
+ const y = (r + 0.5) * cellH;
5821
+ let wgt;
5822
+ if (glow) {
5823
+ const outX = Math.max(0, minX - x, x - maxX);
5824
+ const outY = Math.max(0, minY - y, y - maxY);
5825
+ if (f >= 1) wgt = 1;
5826
+ else if (outX * outX + outY * outY >= band * band) wgt = f;
5827
+ else {
5828
+ const k = band > 0 ? Math.max(0, 1 - distanceToOutline(pts, x, y) / band) : 0;
5829
+ wgt = f + (1 - f) * k * k;
5830
+ }
5831
+ } else if (s.soft) {
5832
+ wgt = f * Math.max(0.1, 1 - Math.hypot(x - p.x, y - p.y) / Math.max(radius, 1e-6));
5833
+ } else {
5834
+ wgt = f;
5835
+ }
5836
+ if (wgt > 0) compose(r * cols + c, lum, a * Math.min(1, wgt), layer.blend);
5837
+ }
5838
+ }
5839
+ };
4965
5840
  const splatPass = (tPass, alphaScale) => {
4966
5841
  for (const { layer, entities } of scene.layers) {
4967
- const lifeA = lifeAlphaAt(layer.life, tPass);
5842
+ const lifeA = layer.opacity === void 0 ? lifeAlphaAt(layer.life, tPass) : lifeAlphaAt(layer.life, tPass) * layerOpacity(layer);
4968
5843
  if (lifeA <= 0) continue;
4969
5844
  for (const e of entities) {
4970
5845
  splatTrail(layer, e, tPass, lifeA, alphaScale);
@@ -4972,8 +5847,12 @@ function luminanceGrid(spec, opts = {}) {
4972
5847
  if (a <= 4e-3) continue;
4973
5848
  const lum = spriteLuma(layer, e);
4974
5849
  const p = posOf(scene, e, tPass);
4975
- const sz = sizeAt(e, tPass);
5850
+ const sz = layer.transform ? sizeAt(e, tPass) * transformSize(layer.transform) : sizeAt(e, tPass);
4976
5851
  const s = layer.sprite;
5852
+ if (s.kind === "polygon" && sz >= POLYGON_OUTLINE_CELLS * Math.min(cellW, cellH)) {
5853
+ splatPolygonOutline(layer, s, e, p, sz, a, lum, tPass);
5854
+ continue;
5855
+ }
4977
5856
  if (s.kind === "stroke") {
4978
5857
  const pts = strokeSamples(s, sz / 2);
4979
5858
  let angle = rotationAt(e, tPass);
@@ -5219,6 +6098,7 @@ function renderDensityMap(grid) {
5219
6098
  return lines.join("\n");
5220
6099
  }
5221
6100
  function textSprites(spec, opts = {}) {
6101
+ spec = resolveTimelineAt(spec, opts.t ?? 5e3);
5222
6102
  const scene = buildScene(spec, opts);
5223
6103
  const t = opts.t ?? 5e3;
5224
6104
  const out = [];
@@ -5256,6 +6136,7 @@ function rankDominance(raw) {
5256
6136
  }));
5257
6137
  }
5258
6138
  function rawDominance(spec, opts = {}) {
6139
+ spec = resolveTimelineAt(spec, opts.t ?? 5e3);
5259
6140
  const scene = buildScene(spec, opts);
5260
6141
  const t = opts.t ?? 5e3;
5261
6142
  const { w, h, scale } = scene;
@@ -5280,7 +6161,8 @@ function rawDominance(spec, opts = {}) {
5280
6161
  return Math.sqrt(dx * dx + dy * dy);
5281
6162
  };
5282
6163
  const raw = scene.layers.map(({ layer, entities }, layerIndex) => {
5283
- const lifeA = lifeAlphaAt(layer.life, t);
6164
+ const lifeA = layer.opacity === void 0 ? lifeAlphaAt(layer.life, t) : lifeAlphaAt(layer.life, t) * layerOpacity(layer);
6165
+ const areaScale = layer.transform ? transformSize(layer.transform) ** 2 : 1;
5284
6166
  let area = 0;
5285
6167
  let lumAcc = 0;
5286
6168
  for (const e of entities) {
@@ -5309,7 +6191,7 @@ function rawDominance(spec, opts = {}) {
5309
6191
  const box = textBlockBox(s, e, { x: 0, y: 0 }, w, h);
5310
6192
  entArea = box.halfX * 2 * box.halfY * 2 * 0.55 * textBlockRevealFraction(s, w, h, t) * (s.opacity ?? 1);
5311
6193
  } else entArea = sz * sz * 0.55;
5312
- area += entArea * a;
6194
+ area += layer.transform ? entArea * areaScale * a : entArea * a;
5313
6195
  if (layer.trail) {
5314
6196
  const fade = layer.trail.fade ?? 1;
5315
6197
  const span = sweptDistance(e, layer.trail.length);
@@ -5339,29 +6221,32 @@ function rawDominance(spec, opts = {}) {
5339
6221
  return raw;
5340
6222
  }
5341
6223
  function layerCohesion(spec, opts = {}) {
6224
+ spec = resolveTimelineAt(spec, opts.t ?? 5e3);
5342
6225
  const scene = buildScene(spec, opts);
5343
6226
  return cohesionOf(scene.layers, opts.t ?? 5e3, scene.w, scene.h);
5344
6227
  }
5345
6228
  function motionStats(spec, opts = {}) {
5346
- const scene = buildScene(spec, opts);
5347
6229
  const t = opts.t ?? 5e3;
5348
6230
  const dt = opts.dt ?? 500;
5349
- const { w, h } = scene;
5350
- return scene.layers.map(({ layer, entities }, layerIndex) => {
6231
+ const scene0 = buildScene(resolveTimelineAt(spec, t), opts);
6232
+ const scene1 = spec.timeline ? buildScene(resolveTimelineAt(spec, t + dt), opts) : scene0;
6233
+ const { w, h } = scene0;
6234
+ return scene0.layers.map(({ layer, entities }, layerIndex) => {
6235
+ const entities1 = scene1.layers[layerIndex]?.entities ?? entities;
5351
6236
  let acc = 0;
5352
6237
  let max = 0;
5353
6238
  let n = 0;
5354
- for (const e of entities) {
5355
- const p0 = posOf(scene, e, t);
5356
- const p1 = posOf(scene, e, t + dt);
6239
+ entities.forEach((e, i) => {
6240
+ const p0 = posOf(scene0, e, t);
6241
+ const p1 = posOf(scene1, entities1[i] ?? e, t + dt);
5357
6242
  const dx = p1.x - p0.x;
5358
6243
  const dy = p1.y - p0.y;
5359
- if (Math.abs(dx) > w / 2 || Math.abs(dy) > h / 2) continue;
6244
+ if (Math.abs(dx) > w / 2 || Math.abs(dy) > h / 2) return;
5360
6245
  const speed = Math.sqrt(dx * dx + dy * dy) / dt * 1e3;
5361
6246
  acc += speed;
5362
6247
  max = Math.max(max, speed);
5363
6248
  n++;
5364
- }
6249
+ });
5365
6250
  const meanSpeed = n ? acc / n : 0;
5366
6251
  return { layerIndex, key: layer.key, meanSpeed, maxSpeed: max, moving: meanSpeed > 0.5 };
5367
6252
  });
@@ -5427,6 +6312,7 @@ function diffScenes(a, b, opts = {}) {
5427
6312
  };
5428
6313
  }
5429
6314
  function perceiveScene(spec, opts = {}) {
6315
+ spec = resolveTimelineAt(spec, opts.t ?? 5e3);
5430
6316
  const grid = luminanceGrid(spec, opts);
5431
6317
  return {
5432
6318
  t: opts.t ?? 5e3,
@@ -7922,6 +8808,7 @@ export {
7922
8808
  COMETS_SPEC,
7923
8809
  CONSTELLATION_SPEC,
7924
8810
  DASHBOARD_SPEC,
8811
+ DEFAULT_KEY_DUR,
7925
8812
  DITHER_ALPHA,
7926
8813
  DITHER_TILE,
7927
8814
  EXAMPLE_BY_ID,
@@ -7975,6 +8862,8 @@ export {
7975
8862
  breakTextBlock,
7976
8863
  buildEntities,
7977
8864
  canMorph,
8865
+ canWrapMorph,
8866
+ canonicalSpecPath,
7978
8867
  cohesionOf,
7979
8868
  compileSaver,
7980
8869
  compileSequence,
@@ -8002,6 +8891,7 @@ export {
8002
8891
  latticeHash,
8003
8892
  layerCohesion,
8004
8893
  lerpSpec,
8894
+ lerpValue,
8005
8895
  lifeAlphaAt,
8006
8896
  linkEdges,
8007
8897
  linkPairs,
@@ -8010,16 +8900,19 @@ export {
8010
8900
  morphChainRoot,
8011
8901
  morphNothingMorphable,
8012
8902
  motionStats,
8903
+ nextKeyAfter,
8013
8904
  normalizeSeed,
8014
8905
  perceiveScene,
8015
8906
  perceiveSequenceFrame,
8016
8907
  pickScreenOp,
8017
8908
  positionAt,
8018
8909
  presentWithFinish,
8910
+ readSpecPath,
8019
8911
  renderBrailleMap,
8020
8912
  renderDensityMap,
8021
8913
  resolveSegment,
8022
8914
  resolveSpecPath,
8915
+ resolveTimelineAt,
8023
8916
  revealState,
8024
8917
  rgb255Luma,
8025
8918
  rotationAt,
@@ -8041,7 +8934,13 @@ export {
8041
8934
  textStringsDiffer,
8042
8935
  textStringsOf,
8043
8936
  textWidthEm,
8937
+ timelineLocalTime,
8938
+ timelineSampleTimes,
8939
+ timelineSampleTimesAfter,
8940
+ timelineTracks,
8044
8941
  validateSequence,
8045
- validateSpec
8942
+ validateSpec,
8943
+ validateSpecPaths,
8944
+ withoutTimeline
8046
8945
  };
8047
8946
  //# sourceMappingURL=index.js.map