@idle-screens/schema 3.9.1 → 3.11.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,21 @@ 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
88
+ maxGroups: 16,
74
89
  maxEmitEvery: 6e5,
75
90
  // ms — ten minutes; longer silences than that are `life.enter`
76
91
  minEmitLife: 500,
@@ -96,7 +111,7 @@ var LIMITS = {
96
111
 
97
112
  // src/steer.ts
98
113
  var UNSAFE_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
99
- var STEERABLE_ROOT_KEYS = /* @__PURE__ */ new Set(["ghosting", "referenceViewport", "finish"]);
114
+ var STEERABLE_ROOT_KEYS = /* @__PURE__ */ new Set(["ghosting", "referenceViewport", "finish", "groups"]);
100
115
  function resolveSpecPath(spec, path) {
101
116
  if (!spec || typeof spec !== "object" || !path) return null;
102
117
  const parts = path.split(".");
@@ -110,6 +125,8 @@ function resolveSpecPath(spec, path) {
110
125
  return null;
111
126
  }
112
127
  }
128
+ if (parts[0] === "layers" && parts.length === 3 && parts[2] === "group") return null;
129
+ if (parts.length >= 2 && parts[parts.length - 1] === "origin" && parts[parts.length - 2] === "transform") return null;
113
130
  let node = spec;
114
131
  for (let i = 0; i < parts.length - 1; i++) {
115
132
  if (node === null || typeof node !== "object") return null;
@@ -121,6 +138,115 @@ function resolveSpecPath(spec, path) {
121
138
  const exists = Array.isArray(node) ? Number.isInteger(key) && key >= 0 && key < node.length : last in node;
122
139
  return exists ? { parent: node, key } : null;
123
140
  }
141
+ function canonicalSpecPath(spec, path) {
142
+ if (!resolveSpecPath(spec, path)) return null;
143
+ const parts = path.split(".");
144
+ const s = spec;
145
+ if (parts[0] !== "layers" && parts[0] !== "background" && Array.isArray(s.layers)) {
146
+ const idx = s.layers.findIndex((l) => l && l.key === parts[0]);
147
+ if (idx !== -1) parts.splice(0, 1, "layers", String(idx));
148
+ }
149
+ return parts.join(".");
150
+ }
151
+ function readSpecPath(spec, path) {
152
+ const loc = resolveSpecPath(spec, path);
153
+ return loc ? loc.parent[loc.key] : void 0;
154
+ }
155
+ var TRANSFORM_IDENTITY = { x: 0, y: 0, scale: 1, scaleX: 1, rotate: 0 };
156
+ function alignTransform(a, b) {
157
+ const isObj2 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
158
+ if (!isObj2(a) || !isObj2(b)) return [a, b];
159
+ let fa = null;
160
+ let fb = null;
161
+ for (const key of /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)])) {
162
+ const id = TRANSFORM_IDENTITY[key];
163
+ if (id === void 0) continue;
164
+ if (a[key] === void 0) (fa ??= { ...a })[key] = id;
165
+ if (b[key] === void 0) (fb ??= { ...b })[key] = id;
166
+ }
167
+ return [fa ?? a, fb ?? b];
168
+ }
169
+ function alignPaintFields(a, b) {
170
+ const la = a?.layers;
171
+ const lb = b?.layers;
172
+ if (!Array.isArray(la) || !Array.isArray(lb)) return [a, b];
173
+ let outA = null;
174
+ let outB = null;
175
+ const n = Math.min(la.length, lb.length);
176
+ for (let i = 0; i < n; i++) {
177
+ const x = la[i];
178
+ const y = lb[i];
179
+ if (!x || !y || typeof x !== "object" || typeof y !== "object") continue;
180
+ let nx = x;
181
+ let ny = y;
182
+ if (x.group === void 0 !== (y.group === void 0)) {
183
+ if (x.group === void 0) nx = { ...nx, group: y.group };
184
+ else ny = { ...ny, group: x.group };
185
+ }
186
+ if (x.opacity === void 0 !== (y.opacity === void 0)) {
187
+ if (x.opacity === void 0) nx = { ...nx, opacity: 1 };
188
+ else ny = { ...ny, opacity: 1 };
189
+ }
190
+ const tx = x.transform;
191
+ const ty = y.transform;
192
+ if (tx || ty) {
193
+ const fx = { ...tx ?? {} };
194
+ const fy = { ...ty ?? {} };
195
+ let changed = !tx || !ty;
196
+ for (const key of /* @__PURE__ */ new Set([...Object.keys(fx), ...Object.keys(fy)])) {
197
+ const id = TRANSFORM_IDENTITY[key];
198
+ if (id === void 0) continue;
199
+ if (fx[key] === void 0) {
200
+ fx[key] = id;
201
+ changed = true;
202
+ }
203
+ if (fy[key] === void 0) {
204
+ fy[key] = id;
205
+ changed = true;
206
+ }
207
+ }
208
+ if (changed) {
209
+ nx = { ...nx, transform: fx };
210
+ ny = { ...ny, transform: fy };
211
+ }
212
+ }
213
+ if (nx !== x) (outA ??= la.slice())[i] = nx;
214
+ if (ny !== y) (outB ??= lb.slice())[i] = ny;
215
+ }
216
+ let ra = outA ? { ...a, layers: outA } : a;
217
+ let rb = outB ? { ...b, layers: outB } : b;
218
+ const ga = a.groups;
219
+ const gb = b.groups;
220
+ if (ga || gb) {
221
+ const na = { ...ga ?? {} };
222
+ const nb = { ...gb ?? {} };
223
+ let changed = !ga || !gb;
224
+ for (const name of /* @__PURE__ */ new Set([...Object.keys(na), ...Object.keys(nb)])) {
225
+ const x = na[name] ?? {};
226
+ const y = nb[name] ?? {};
227
+ if (!na[name] || !nb[name]) changed = true;
228
+ let nx = x;
229
+ let ny = y;
230
+ if (x.opacity === void 0 !== (y.opacity === void 0)) {
231
+ if (x.opacity === void 0) nx = { ...nx, opacity: 1 };
232
+ else ny = { ...ny, opacity: 1 };
233
+ changed = true;
234
+ }
235
+ if (!x.transform !== !y.transform) {
236
+ if (!x.transform) nx = { ...nx, transform: {} };
237
+ else ny = { ...ny, transform: {} };
238
+ changed = true;
239
+ }
240
+ na[name] = nx;
241
+ nb[name] = ny;
242
+ }
243
+ if (changed) {
244
+ ra = { ...ra, groups: na };
245
+ rb = { ...rb, groups: nb };
246
+ }
247
+ }
248
+ return [ra, rb];
249
+ }
124
250
  function applyDeltasToSpec(spec, deltas) {
125
251
  const copy = JSON.parse(JSON.stringify(spec));
126
252
  for (const d of deltas) {
@@ -143,7 +269,14 @@ function lerpHex(a, b, k) {
143
269
  }
144
270
  function lerpSpec(from, to, k) {
145
271
  const kk = Math.max(0, Math.min(1, k));
146
- const walk = (a, b, key) => {
272
+ const out = lerpValue(from, to, kk, true);
273
+ return out;
274
+ }
275
+ function lerpValue(from, to, k, specRoot = false, rootKey) {
276
+ if (specRoot) [from, to] = alignPaintFields(from, to);
277
+ if (rootKey === "transform") [from, to] = alignTransform(from, to);
278
+ const kk = Math.max(0, Math.min(1, k));
279
+ const walk = (a, b, key, root = false) => {
147
280
  if (typeof a === "number" && typeof b === "number") {
148
281
  const v = a + (b - a) * kk;
149
282
  return key === "count" ? Math.max(1, Math.round(v)) : v;
@@ -157,13 +290,15 @@ function lerpSpec(from, to, k) {
157
290
  if (a && b && typeof a === "object" && typeof b === "object" && !Array.isArray(a) && !Array.isArray(b)) {
158
291
  const out = {};
159
292
  for (const kName of Object.keys(b)) {
160
- out[kName] = walk(a[kName], b[kName], kName);
293
+ const av = a[kName];
294
+ const bv = b[kName];
295
+ out[kName] = root && kName === "timeline" ? kk > 0 ? bv : av : kName === "transform" ? walk(...alignTransform(av, bv), kName) : walk(av, bv, kName);
161
296
  }
162
297
  return out;
163
298
  }
164
299
  return kk > 0 ? b : a;
165
300
  };
166
- return walk(from, to);
301
+ return walk(from, to, rootKey, specRoot);
167
302
  }
168
303
  function textStringsOf(sprite) {
169
304
  if (sprite.kind === "text") return sprite.strings;
@@ -219,7 +354,8 @@ function morphNothingMorphable(a, b, opts = {}) {
219
354
  }
220
355
  function steerablePaths(spec) {
221
356
  if (!spec || typeof spec !== "object") return [];
222
- const SKIP = /* @__PURE__ */ new Set(["kind", "type", "key", "schemaVersion", "id", "label", "seed", "units", "motionIntensity", "mode", "curve", "layer"]);
357
+ const SKIP = /* @__PURE__ */ new Set(["kind", "type", "key", "schemaVersion", "id", "label", "seed", "units", "motionIntensity", "mode", "curve", "layer", "group", "origin"]);
358
+ const SKIP_ROOT = /* @__PURE__ */ new Set(["timeline"]);
223
359
  const INDEXED = /* @__PURE__ */ new Set(["layers", "stops", "bands"]);
224
360
  const layers = spec.layers;
225
361
  const shadowedRootKeys = new Set(
@@ -237,8 +373,8 @@ function steerablePaths(spec) {
237
373
  }
238
374
  if (node && typeof node === "object") {
239
375
  for (const [k, v] of Object.entries(node)) {
240
- if (SKIP.has(k)) continue;
241
- if (!prefix && shadowedRootKeys.has(k)) continue;
376
+ if (SKIP.has(k) && prefix !== "groups") continue;
377
+ if (!prefix && (shadowedRootKeys.has(k) || SKIP_ROOT.has(k))) continue;
242
378
  walk(v, prefix ? `${prefix}.${k}` : k, k);
243
379
  }
244
380
  return;
@@ -301,13 +437,244 @@ function structuralSignature(spec) {
301
437
  ]);
302
438
  }
303
439
 
440
+ // src/sequence.ts
441
+ function segmentStart(seq, index) {
442
+ let t = 0;
443
+ const n = Math.max(0, Math.min(index, seq.segments.length - 1));
444
+ for (let i = 0; i < n; i++) t += seq.segments[i].duration ?? 0;
445
+ return t;
446
+ }
447
+ function resolveSegment(seq, T, opts = {}) {
448
+ const { segments, loop } = seq;
449
+ if (segments.length === 0) return { index: 0, localT: 0, startT: 0 };
450
+ let totalTimed = 0;
451
+ for (const seg of segments) {
452
+ if (seg.duration != null) totalTimed += seg.duration;
453
+ else break;
454
+ }
455
+ const t0 = Math.max(0, T);
456
+ const walk = (t, releasedBelow) => {
457
+ let cumulative = 0;
458
+ for (let i = 0; i < segments.length; i++) {
459
+ const seg = segments[i];
460
+ if (seg.duration == null) {
461
+ return loop ? null : { index: i, localT: t - cumulative, startT: cumulative };
462
+ }
463
+ if (t < cumulative + seg.duration) {
464
+ return { index: i, localT: t - cumulative, startT: cumulative };
465
+ }
466
+ if (seg.advance === "input" && i >= releasedBelow) {
467
+ return { index: i, localT: t - cumulative, startT: cumulative, held: true };
468
+ }
469
+ cumulative += seg.duration;
470
+ }
471
+ return null;
472
+ };
473
+ const first = walk(t0, opts.releasedBelow ?? 0);
474
+ if (first) return first;
475
+ if (loop && totalTimed > 0) {
476
+ const wrapped = walk(t0 % totalTimed, 0);
477
+ if (wrapped) return wrapped;
478
+ }
479
+ const last = segments.length - 1;
480
+ const lastStart = totalTimed - (segments[last].duration ?? 0);
481
+ return { index: last, localT: t0 - lastStart, startT: lastStart };
482
+ }
483
+ function canMorph(seq, from) {
484
+ const seg = seq.segments[from];
485
+ if (!seg || seg.transition?.type !== "morph") return false;
486
+ const next = seq.segments[from + 1];
487
+ if (!next) return false;
488
+ return structuralSignature(seg.scene) === structuralSignature(next.scene);
489
+ }
490
+ function morphChainRoot(seq, index) {
491
+ let i = index;
492
+ while (i > 0 && canMorph(seq, i - 1)) i--;
493
+ return i;
494
+ }
495
+ function canWrapMorph(seq) {
496
+ if (seq.wrapMorph !== true || seq.loop !== true) return false;
497
+ const last = seq.segments.length - 1;
498
+ if (last < 1) return false;
499
+ const seg = seq.segments[last];
500
+ if (seg.transition?.type !== "morph" || seg.duration == null) return false;
501
+ if (morphChainRoot(seq, last) !== 0) return false;
502
+ return structuralSignature(seg.scene) === structuralSignature(seq.segments[0].scene);
503
+ }
504
+ function normalizeSeed(seed) {
505
+ return seed >>> 0 || 1;
506
+ }
507
+ function segmentRenderSeed(seq, index) {
508
+ const root = morphChainRoot(seq, index);
509
+ const scene = seq.segments[root]?.scene;
510
+ if (!scene) return void 0;
511
+ const raw = scene.seed ?? (seq.seed !== void 0 ? seq.seed + root : void 0);
512
+ return raw === void 0 ? void 0 : normalizeSeed(raw);
513
+ }
514
+ function bedRenderSeed(seq) {
515
+ if (!seq.bed) return void 0;
516
+ const raw = seq.bed.seed ?? (seq.seed !== void 0 ? seq.seed + LIMITS.maxSegments : void 0);
517
+ return raw === void 0 ? void 0 : normalizeSeed(raw);
518
+ }
519
+ function sequenceWantsFinish(seq) {
520
+ return seq.finish !== void 0 || seq.segments.some((s) => s.scene.finish !== void 0);
521
+ }
522
+ function sequenceSwapCompatible(prev, next) {
523
+ if (prev.segments.length !== next.segments.length) return false;
524
+ if (prev.finish !== void 0 || next.finish !== void 0) {
525
+ if (prev.seed === void 0 !== (next.seed === void 0)) return false;
526
+ if (prev.seed !== void 0 && next.seed !== void 0 && normalizeSeed(prev.seed) !== normalizeSeed(next.seed)) return false;
527
+ }
528
+ if (sequenceWantsFinish(prev) !== sequenceWantsFinish(next)) return false;
529
+ if (prev.loop !== next.loop) return false;
530
+ if ((prev.sync ?? "mount") !== (next.sync ?? "mount")) return false;
531
+ if (!!prev.bed !== !!next.bed) return false;
532
+ if (prev.bed && next.bed) {
533
+ if (structuralSignature(prev.bed) !== structuralSignature(next.bed)) return false;
534
+ if (bedRenderSeed(prev) !== bedRenderSeed(next)) return false;
535
+ }
536
+ for (let i = 0; i < prev.segments.length; i++) {
537
+ const a = prev.segments[i];
538
+ const b = next.segments[i];
539
+ if (a.duration !== b.duration) return false;
540
+ if ((a.advance ?? "auto") !== (b.advance ?? "auto")) return false;
541
+ if (transitionKey(a.transition) !== transitionKey(b.transition)) return false;
542
+ if (structuralSignature(a.scene) !== structuralSignature(b.scene)) return false;
543
+ if (segmentRenderSeed(prev, i) !== segmentRenderSeed(next, i)) return false;
544
+ }
545
+ return true;
546
+ }
547
+ function transitionKey(tr) {
548
+ if (!tr || tr.type === "cut") return "cut";
549
+ if (tr.type === "morph") return `morph:${tr.dur}:${tr.text ?? "step"}`;
550
+ return `fade:${tr.dur}`;
551
+ }
552
+
553
+ // src/timeline.ts
554
+ var DEFAULT_KEY_DUR = 1e3;
555
+ var tracksCache = /* @__PURE__ */ new WeakMap();
556
+ function withoutTimeline(spec) {
557
+ if (!spec.timeline) return spec;
558
+ const rest = { ...spec };
559
+ delete rest.timeline;
560
+ return rest;
561
+ }
562
+ function timelineTracks(spec) {
563
+ const tl = spec.timeline;
564
+ if (!tl) return /* @__PURE__ */ new Map();
565
+ const hit = tracksCache.get(tl);
566
+ if (hit && hit.spec === spec) return hit.tracks;
567
+ const base = withoutTimeline(spec);
568
+ const tracks = /* @__PURE__ */ new Map();
569
+ for (const k of tl.keys ?? []) {
570
+ const cp = canonicalSpecPath(base, k.path);
571
+ if (!cp) continue;
572
+ const list = tracks.get(cp) ?? [];
573
+ list.push(k);
574
+ tracks.set(cp, list);
575
+ }
576
+ for (const list of tracks.values()) list.sort((a, b) => a.t - b.t);
577
+ tracksCache.set(tl, { spec, tracks });
578
+ return tracks;
579
+ }
580
+ function timelineLocalTime(tl, t) {
581
+ const d = tl.duration ?? 0;
582
+ if (tl.loop && d > 0) return (t % d + d) % d;
583
+ return t;
584
+ }
585
+ function ease(k, x) {
586
+ const c = x <= 0 ? 0 : x >= 1 ? 1 : x;
587
+ if (k.ease === "step") return c >= 1 ? 1 : 0;
588
+ if (k.ease === "linear") return c;
589
+ return easeSmooth(c);
590
+ }
591
+ function evalTrack(keys, start, tt, leaf) {
592
+ let from = start;
593
+ let key = null;
594
+ const at = (tau) => {
595
+ if (!key) return from;
596
+ const dur = key.dur ?? DEFAULT_KEY_DUR;
597
+ if (dur <= 0 || tau >= key.t + dur) return key.value;
598
+ return lerpValue(from, key.value, ease(key, (tau - key.t) / dur), false, leaf);
599
+ };
600
+ for (const k of keys) {
601
+ if (k.t > tt) break;
602
+ from = at(k.t);
603
+ key = k;
604
+ }
605
+ return at(tt);
606
+ }
607
+ function resolveTimelineAt(spec, t) {
608
+ const tl = spec.timeline;
609
+ if (!tl) return spec;
610
+ const base = withoutTimeline(spec);
611
+ const tracks = timelineTracks(spec);
612
+ if (tracks.size === 0) return base;
613
+ const tt = timelineLocalTime(tl, t);
614
+ const deltas = [];
615
+ for (const [path, keys] of tracks) {
616
+ const start = tl.loop ? keys[keys.length - 1].value : readSpecPath(base, path);
617
+ deltas.push({ t: 0, path, value: evalTrack(keys, start, tt, path.slice(path.lastIndexOf(".") + 1)) });
618
+ }
619
+ return applyDeltasToSpec(base, deltas);
620
+ }
621
+ function nextKeyAfter(spec, path, t) {
622
+ const tl = spec.timeline;
623
+ if (!tl) return null;
624
+ const cp = canonicalSpecPath(withoutTimeline(spec), path);
625
+ const keys = cp ? timelineTracks(spec).get(cp) : void 0;
626
+ if (!keys || keys.length === 0) return null;
627
+ const tt = timelineLocalTime(tl, t);
628
+ const next = keys.find((k) => k.t > tt);
629
+ if (next) return { at: t + (next.t - tt), dur: next.dur ?? DEFAULT_KEY_DUR };
630
+ if (tl.loop && (tl.duration ?? 0) > 0) {
631
+ const first = keys[0];
632
+ return { at: t + (tl.duration - tt) + first.t, dur: first.dur ?? DEFAULT_KEY_DUR };
633
+ }
634
+ return null;
635
+ }
636
+ function timelineSampleTimes(spec, dense = true, cap = 120, only) {
637
+ const tl = spec.timeline;
638
+ if (!tl) return [];
639
+ const d = tl.duration ?? 0;
640
+ const fold = (t) => tl.loop && d > 0 ? (t % d + d) % d : t;
641
+ const bounds = /* @__PURE__ */ new Set([0]);
642
+ const extras = /* @__PURE__ */ new Set();
643
+ const keys = [];
644
+ for (const [path, list] of timelineTracks(spec)) if (!only || only(path)) keys.push(...list);
645
+ for (const k of keys) {
646
+ if (!Number.isFinite(k.t)) continue;
647
+ const dur = k.dur ?? DEFAULT_KEY_DUR;
648
+ bounds.add(fold(k.t));
649
+ bounds.add(fold(k.t + dur));
650
+ if (dense) for (const f of [0.25, 0.5, 0.75]) extras.add(fold(k.t + dur * f));
651
+ }
652
+ let extra = [...extras].filter((t) => !bounds.has(t)).sort((a, b) => a - b);
653
+ if (extra.length > cap) {
654
+ const step = extra.length / cap;
655
+ extra = Array.from({ length: cap }, (_, i) => extra[Math.floor(i * step)]);
656
+ }
657
+ return [.../* @__PURE__ */ new Set([...bounds, ...extra])].filter(Number.isFinite).sort((a, b) => a - b);
658
+ }
659
+ function timelineSampleTimesAfter(spec, now, dense = false, only) {
660
+ const tl = spec.timeline;
661
+ if (!tl) return [];
662
+ const local = timelineSampleTimes(spec, dense, 120, only);
663
+ const d = tl.duration ?? 0;
664
+ if (tl.loop && d > 0) {
665
+ const phase = (now % d + d) % d;
666
+ return local.map((s) => now + ((s - phase) % d + d) % d).sort((a, b) => a - b);
667
+ }
668
+ return local.filter((s) => s > now);
669
+ }
670
+
304
671
  // src/validate.ts
305
672
  var HEX2 = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
306
673
  var isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
307
674
  var isStr = (v) => typeof v === "string";
308
675
  var isNum = (v) => typeof v === "number" && Number.isFinite(v);
309
676
  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"]);
677
+ var KNOWN_TOP = /* @__PURE__ */ new Set(["schemaVersion", "id", "label", "seed", "motionIntensity", "density", "units", "referenceViewport", "background", "layers", "ghosting", "finish", "timeline", "groups"]);
311
678
  var KNOWN_FINISH = /* @__PURE__ */ new Set(["grain", "dither", "animate"]);
312
679
  var KNOWN_LAYER = /* @__PURE__ */ new Set([
313
680
  "count",
@@ -330,8 +697,17 @@ var KNOWN_LAYER = /* @__PURE__ */ new Set([
330
697
  "life",
331
698
  "emit",
332
699
  "clock",
333
- "rotate"
700
+ "rotate",
701
+ "opacity",
702
+ "transform",
703
+ "group"
334
704
  ]);
705
+ var KNOWN_TRANSFORM = /* @__PURE__ */ new Set(["x", "y", "scale", "scaleX", "rotate", "origin"]);
706
+ var KNOWN_GROUP = /* @__PURE__ */ new Set(["transform", "opacity"]);
707
+ var GROUP_NAME = /^[A-Za-z][A-Za-z0-9_-]{0,31}$/;
708
+ var KNOWN_POSITION = /* @__PURE__ */ new Set(["x", "y", "dx", "dy"]);
709
+ var KNOWN_TIMELINE = /* @__PURE__ */ new Set(["loop", "duration", "keys"]);
710
+ var KNOWN_TIMELINE_KEY = /* @__PURE__ */ new Set(["t", "path", "value", "ease", "dur"]);
335
711
  var KNOWN_CIRCLE = /* @__PURE__ */ new Set(["kind", "radius", "color", "soft", "colors", "colorWeights"]);
336
712
  var KNOWN_RING = /* @__PURE__ */ new Set(["kind", "radius", "color", "width", "colors", "colorWeights"]);
337
713
  var KNOWN_STREAK = /* @__PURE__ */ new Set(["kind", "length", "color", "width", "colors", "colorWeights"]);
@@ -366,6 +742,9 @@ function unknownKeys(obj, known) {
366
742
  function dimUnit(spec) {
367
743
  return isObj(spec) && spec.units === "px" ? "px" : "fractions of min(width, height)";
368
744
  }
745
+ function offsetBound(spec) {
746
+ return isObj(spec) && spec.units === "px" ? [LIMITS.maxLayerTransformOffsetPx, "px"] : [LIMITS.maxLayerTransformOffset, "min(w, h) units"];
747
+ }
369
748
  function rangeMsg(spec, note) {
370
749
  return `must be a [min,max] range of positive ${dimUnit(spec)}${note ? ` (${note})` : ""}`;
371
750
  }
@@ -393,6 +772,42 @@ function ignoredPropertyPaths(spec) {
393
772
  if (!isObj(spec)) return [];
394
773
  return (validateSpecCore(spec).warnings ?? []).filter((w) => w.code === "unknown-property" || w.code === "misplaced-property").map((w) => w.path.replace(/\[(\d+)\]/g, ".$1"));
395
774
  }
775
+ function validateSpecPaths(spec, paths) {
776
+ const layers = /* @__PURE__ */ new Set();
777
+ let background = false;
778
+ let finish = false;
779
+ let groups = false;
780
+ for (const p of paths) {
781
+ if (/^layers\.\d+(\.(key|count|motion)(\.|$)|$)/.test(p)) return validateSpec(spec).valid;
782
+ if (p === "groups") return validateSpec(spec).valid;
783
+ const [head, idx] = p.split(".");
784
+ if (head === "layers" && idx !== void 0 && /^\d+$/.test(idx)) layers.add(Number(idx));
785
+ else if (head === "background") background = true;
786
+ else if (head === "finish") finish = true;
787
+ else if (head === "groups") groups = true;
788
+ else return validateSpec(spec).valid;
789
+ }
790
+ let ok = true;
791
+ const err = () => {
792
+ ok = false;
793
+ };
794
+ const warn = () => void 0;
795
+ if (!Array.isArray(spec.layers)) return false;
796
+ for (const i of layers) {
797
+ const layer = spec.layers[i];
798
+ if (layer === void 0) return false;
799
+ validateLayer(layer, `layers[${i}]`, err, warn, spec);
800
+ if (spec.units === "px" && isObj(layer) && isObj(layer.sprite) && layer.sprite.kind === "textBlock") ok = false;
801
+ }
802
+ if (background && spec.background !== void 0) validateBackground(spec.background, err, warn);
803
+ if (finish && spec.finish !== void 0) validateFinish(spec.finish, "finish", err, warn);
804
+ if (groups && spec.groups !== void 0) validateGroups(spec.groups, err, warn, spec);
805
+ if (layers.size > 0) {
806
+ const total = spec.layers.reduce((n, l) => n + (isObj(l) && isNum(l.count) ? l.count : 0), 0);
807
+ if (total > LIMITS.maxTotal) ok = false;
808
+ }
809
+ return ok;
810
+ }
396
811
  function validateSpecCore(spec) {
397
812
  const errors = [];
398
813
  const warnings = [];
@@ -433,6 +848,7 @@ function validateSpecCore(spec) {
433
848
  }
434
849
  if (spec.background !== void 0) validateBackground(spec.background, err, warn);
435
850
  if (spec.finish !== void 0) validateFinish(spec.finish, "finish", err, warn);
851
+ if (spec.groups !== void 0) validateGroups(spec.groups, err, warn, spec);
436
852
  if (!Array.isArray(spec.layers) || spec.layers.length === 0) {
437
853
  err("layers", "must be a non-empty array");
438
854
  } else {
@@ -444,8 +860,151 @@ function validateSpecCore(spec) {
444
860
  });
445
861
  if (total > LIMITS.maxTotal) err("layers", `total entities ${total} exceeds cap ${LIMITS.maxTotal}`);
446
862
  }
863
+ if (spec.timeline !== void 0 && errors.length === 0) validateTimeline(spec, err, warn);
447
864
  return { valid: errors.length === 0, errors, warnings };
448
865
  }
866
+ function validateTransform(tf, path, err, warn, spec, anchorOk) {
867
+ if (!isObj(tf)) return err(path, "must be an object {x?, y?, scale?, scaleX?, rotate?, origin?}");
868
+ for (const k of unknownKeys(tf, KNOWN_TRANSFORM)) warn(`${path}.${k}`, "unknown-property", `unknown transform property '${k}' \u2014 will be ignored`);
869
+ const [off, unit] = offsetBound(spec);
870
+ const sc = LIMITS.maxLayerTransformScale;
871
+ for (const axis of ["x", "y"]) {
872
+ if (tf[axis] !== void 0 && (!isNum(tf[axis]) || Math.abs(tf[axis]) > off)) err(`${path}.${axis}`, `must be a number within \xB1${off} (${unit})`);
873
+ }
874
+ if (tf.scale !== void 0 && (!isNum(tf.scale) || tf.scale < 0 || tf.scale > sc)) err(`${path}.scale`, `must be a number 0..${sc}`);
875
+ if (tf.scaleX !== void 0 && (!isNum(tf.scaleX) || Math.abs(tf.scaleX) > sc)) err(`${path}.scaleX`, `must be a number within \xB1${sc} (negative mirrors)`);
876
+ if (tf.rotate !== void 0 && (!isNum(tf.rotate) || Math.abs(tf.rotate) > LIMITS.maxLayerTransformRotate)) {
877
+ err(`${path}.rotate`, `must be a number of degrees within \xB1${LIMITS.maxLayerTransformRotate}`);
878
+ }
879
+ if (tf.origin !== void 0) {
880
+ if (tf.origin !== "viewport" && tf.origin !== "anchor") err(`${path}.origin`, "must be 'viewport' | 'anchor'");
881
+ else if (tf.origin === "anchor" && !anchorOk) err(`${path}.origin`, "a group transform always turns about the viewport centre \u2014 'anchor' is for a layer's own transform");
882
+ }
883
+ }
884
+ function validateGroups(groups, err, warn, spec) {
885
+ if (!isObj(groups)) return err("groups", "must be an object of named groups {name: {transform?, opacity?}}");
886
+ const names = Object.keys(groups);
887
+ if (names.length > LIMITS.maxGroups) err("groups", `at most ${LIMITS.maxGroups} groups`);
888
+ for (const name of names) {
889
+ const p = `groups.${name}`;
890
+ if (!GROUP_NAME.test(name)) err(p, "group names are 1\u201332 letters, digits, - or _, starting with a letter");
891
+ const g = groups[name];
892
+ if (!isObj(g)) {
893
+ err(p, "must be an object {transform?, opacity?}");
894
+ continue;
895
+ }
896
+ for (const k of unknownKeys(g, KNOWN_GROUP)) warn(`${p}.${k}`, "unknown-property", `unknown group property '${k}' \u2014 will be ignored`);
897
+ if (g.opacity !== void 0 && (!isNum(g.opacity) || g.opacity < 0 || g.opacity > 1)) err(`${p}.opacity`, "must be a number 0..1");
898
+ if (g.transform !== void 0) validateTransform(g.transform, `${p}.transform`, err, warn, spec, false);
899
+ }
900
+ }
901
+ function validateTimeline(spec, errOuter, warn) {
902
+ let failed = false;
903
+ const err = (p, m) => {
904
+ failed = true;
905
+ errOuter(p, m);
906
+ };
907
+ const tl = spec.timeline;
908
+ if (!isObj(tl)) return err("timeline", "must be an object {loop?, duration?, keys}");
909
+ for (const k of unknownKeys(tl, KNOWN_TIMELINE)) warn(`timeline.${k}`, "unknown-property", `unknown timeline property '${k}' \u2014 will be ignored`);
910
+ if (tl.loop !== void 0 && typeof tl.loop !== "boolean") err("timeline.loop", "must be a boolean");
911
+ if (tl.duration !== void 0 && (!isNum(tl.duration) || tl.duration <= 0 || tl.duration > LIMITS.maxTimelineDuration)) {
912
+ err("timeline.duration", `must be a number of ms, 0 < duration <= ${LIMITS.maxTimelineDuration}`);
913
+ }
914
+ if (tl.loop === true && tl.duration === void 0) err("timeline.duration", "loop: true needs a duration (the lap length in ms)");
915
+ if (!Array.isArray(tl.keys) || tl.keys.length === 0) return err("timeline.keys", "must be a non-empty array");
916
+ if (tl.keys.length > LIMITS.maxTimelineKeys) return err("timeline.keys", `at most ${LIMITS.maxTimelineKeys} keys`);
917
+ const base = withoutTimeline(spec);
918
+ const baseSig = structuralSignature(base);
919
+ const times = /* @__PURE__ */ new Map();
920
+ let latest = 0;
921
+ tl.keys.forEach((key, i) => {
922
+ const p = `timeline.keys[${i}]`;
923
+ if (!isObj(key)) return err(p, "must be an object {t, path, value, ease?, dur?}");
924
+ for (const k of unknownKeys(key, KNOWN_TIMELINE_KEY)) warn(`${p}.${k}`, "unknown-property", `unknown key property '${k}' \u2014 will be ignored`);
925
+ let ok = true;
926
+ if (!isNum(key.t) || key.t < 0) {
927
+ err(`${p}.t`, "must be a number of ms >= 0");
928
+ ok = false;
929
+ }
930
+ if (key.ease !== void 0 && key.ease !== "step" && key.ease !== "linear" && key.ease !== "smooth") err(`${p}.ease`, "must be 'step' | 'linear' | 'smooth'");
931
+ if (key.dur !== void 0 && (!isNum(key.dur) || key.dur < 0 || key.dur > LIMITS.maxTimelineKeyDur)) {
932
+ err(`${p}.dur`, `must be a number of ms, 0..${LIMITS.maxTimelineKeyDur}`);
933
+ }
934
+ if (!("value" in key)) {
935
+ err(`${p}.value`, "is required");
936
+ ok = false;
937
+ }
938
+ const cp = isStr(key.path) ? canonicalSpecPath(base, key.path) : null;
939
+ if (!isStr(key.path) || key.path.trim() === "") {
940
+ err(`${p}.path`, "must be a non-empty dot-path");
941
+ ok = false;
942
+ } else if (/\.group$/.test(key.path) || /\.transform\.origin$/.test(key.path)) {
943
+ err(`${p}.path`, `'${key.path}' is an enum that can only step \u2014 group membership and a transform's origin are fixed per scene`);
944
+ ok = false;
945
+ } else if (!cp) {
946
+ err(`${p}.path`, `'${key.path}' does not resolve on the spec \u2014 a key can only animate a field the spec already has`);
947
+ ok = false;
948
+ } else if (cp === "timeline" || cp.startsWith("timeline.") || cp === "schemaVersion" || cp === "id" || cp === "label" || cp === "seed") {
949
+ err(`${p}.path`, `'${key.path}' is not animatable`);
950
+ ok = false;
951
+ }
952
+ if (!ok || !cp) return;
953
+ const t = key.t;
954
+ latest = Math.max(latest, t);
955
+ const applied = applyDeltasToSpec(base, [{ t: 0, path: cp, value: key.value }]);
956
+ const r = validateSpecPaths(applied, [cp]) ? { valid: true, errors: [] } : validateSpec(applied);
957
+ if (!r.valid) {
958
+ for (const e of r.errors) err(`${p}.value`, `applied at '${key.path}' \u2192 ${e.path || "<root>"}: ${e.message}`);
959
+ return;
960
+ }
961
+ if (isObj(key.value) && cp.endsWith("transform")) {
962
+ const before = readSpecPath(base, cp);
963
+ const o0 = isObj(before) ? before.origin ?? "viewport" : "viewport";
964
+ const o1 = key.value.origin ?? "viewport";
965
+ if (o0 !== o1) warn(`${p}.value`, "timeline-origin-steps", `this key changes the transform's origin (${String(o0)} \u2192 ${String(o1)}): the pivot switches on the key's first frame, so the layer jumps \u2014 keep one origin per layer`);
966
+ }
967
+ if (structuralSignature(applied) !== baseSig) {
968
+ 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`);
969
+ }
970
+ const list = times.get(cp) ?? [];
971
+ list.push(t);
972
+ times.set(cp, list);
973
+ const dur = isNum(key.dur) ? key.dur : DEFAULT_KEY_DUR;
974
+ if (tl.loop === true && isNum(tl.duration) && t + dur > tl.duration) {
975
+ 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`);
976
+ }
977
+ });
978
+ if (tl.loop === true && isNum(tl.duration) && latest >= tl.duration) {
979
+ err("timeline.duration", `must be greater than the latest key's t (${latest} ms) under loop`);
980
+ }
981
+ const minGap = LIMITS.minTimelineKeyInterval;
982
+ for (const [path, list] of times) {
983
+ const distinct = [...new Set(list)].sort((a, b) => a - b);
984
+ for (let i = 1; i < distinct.length; i++) {
985
+ if (distinct[i] - distinct[i - 1] < minGap) {
986
+ err("timeline.keys", `keys on '${path}' at ${distinct[i - 1]} and ${distinct[i]} ms are closer than ${minGap} ms (flash safety)`);
987
+ }
988
+ }
989
+ if (tl.loop === true && isNum(tl.duration) && distinct.length > 1) {
990
+ const wrapGap = tl.duration - distinct[distinct.length - 1] + distinct[0];
991
+ 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)`);
992
+ }
993
+ }
994
+ if (failed) return;
995
+ const keyed = [...timelineTracks(spec).keys()];
996
+ const seen = /* @__PURE__ */ new Set();
997
+ for (const t of timelineSampleTimes(spec, true, 120)) {
998
+ const r = resolveTimelineAt(spec, t);
999
+ const state = JSON.stringify(keyed.map((p) => readSpecPath(r, p)));
1000
+ if (seen.has(state)) continue;
1001
+ seen.add(state);
1002
+ if (validateSpecPaths(r, keyed)) continue;
1003
+ const e = validateSpec(r).errors[0];
1004
+ err("timeline", `at t = ${Math.round(t)} ms the keys combine into an invalid scene \u2014 ${e ? `${e.path || "<root>"}: ${e.message}` : "invalid"}`);
1005
+ return;
1006
+ }
1007
+ }
449
1008
  function color(v, path, err) {
450
1009
  if (!isStr(v) || !HEX2.test(v)) err(path, "must be a hex colour like #0a3a52");
451
1010
  }
@@ -569,12 +1128,29 @@ function validateLayer(layer, path, err, warn, spec) {
569
1128
  if (layer.key !== void 0 && (!isStr(layer.key) || layer.key.trim() === "")) {
570
1129
  err(`${path}.key`, "must be a non-empty string");
571
1130
  }
1131
+ if (layer.opacity !== void 0 && (!isNum(layer.opacity) || layer.opacity < 0 || layer.opacity > 1)) {
1132
+ err(`${path}.opacity`, "must be a number 0..1");
1133
+ }
1134
+ if (layer.transform !== void 0) validateTransform(layer.transform, `${path}.transform`, err, warn, spec, true);
1135
+ if (layer.group !== void 0) {
1136
+ const groups = isObj(spec) && isObj(spec.groups) ? spec.groups : void 0;
1137
+ if (!isStr(layer.group)) err(`${path}.group`, "must be the name of a `groups` entry");
1138
+ else if (!groups || !Object.prototype.hasOwnProperty.call(groups, layer.group) || !isObj(groups[layer.group])) err(`${path}.group`, `'${layer.group}' is not in \`groups\` \u2014 declare it there first`);
1139
+ }
572
1140
  if (layer.position !== void 0) {
573
1141
  if (!isObj(layer.position) || !isNum(layer.position.x) || !isNum(layer.position.y)) {
574
1142
  err(`${path}.position`, "must be {x, y} with numbers 0..1");
575
1143
  } else {
576
1144
  if (layer.position.x < 0 || layer.position.x > 1) err(`${path}.position.x`, "must be 0..1");
577
1145
  if (layer.position.y < 0 || layer.position.y > 1) err(`${path}.position.y`, "must be 0..1");
1146
+ const [off, unit] = offsetBound(spec);
1147
+ for (const axis of ["dx", "dy"]) {
1148
+ const v = layer.position[axis];
1149
+ if (v !== void 0 && (!isNum(v) || Math.abs(v) > off)) {
1150
+ err(`${path}.position.${axis}`, `must be a number within \xB1${off} (${unit})`);
1151
+ }
1152
+ }
1153
+ for (const k of unknownKeys(layer.position, KNOWN_POSITION)) warn(`${path}.position.${k}`, "unknown-property", `unknown position property '${k}' \u2014 will be ignored`);
578
1154
  const dataLayout = isObj(layer.layout) && (layer.layout.type === "list" || layer.layout.type === "table");
579
1155
  if (isNum(layer.count) && layer.count !== 1 && !dataLayout) {
580
1156
  err(`${path}.position`, "position requires count: 1 \u2014 or a layout of type 'list' / 'table', where it anchors the whole block");
@@ -1161,14 +1737,14 @@ function validateShapePoints(points, path, min, err) {
1161
1737
  }
1162
1738
  }
1163
1739
  }
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) {
1740
+ function validateEase(ease2, path, err, warn) {
1741
+ if (ease2 === void 0) return;
1742
+ if (!isObj(ease2)) return err(path, "must be an object { type: 'settle' | 'buoyant', tau }");
1743
+ if (ease2.type !== "settle" && ease2.type !== "buoyant") err(`${path}.type`, "must be 'settle' | 'buoyant'");
1744
+ if (!isNum(ease2.tau) || ease2.tau < LIMITS.minEaseTau || ease2.tau > LIMITS.maxEaseTau) {
1169
1745
  err(`${path}.tau`, `must be ${LIMITS.minEaseTau}..${LIMITS.maxEaseTau} ms`);
1170
1746
  }
1171
- for (const k of unknownKeys(ease, /* @__PURE__ */ new Set(["type", "tau"]))) {
1747
+ for (const k of unknownKeys(ease2, /* @__PURE__ */ new Set(["type", "tau"]))) {
1172
1748
  warn(`${path}.${k}`, "unknown-property", `unknown ease property '${k}' \u2014 will be ignored`);
1173
1749
  }
1174
1750
  }
@@ -1200,6 +1776,7 @@ function validateSequence(seq) {
1200
1776
  if (seq.seed !== void 0 && !isNum(seq.seed)) err("seed", "must be a number");
1201
1777
  if (typeof seq.loop !== "boolean") err("loop", "must be a boolean");
1202
1778
  if (seq.sync !== void 0 && seq.sync !== "mount" && seq.sync !== "epoch") err("sync", "must be 'mount' | 'epoch'");
1779
+ if (seq.wrapMorph !== void 0 && typeof seq.wrapMorph !== "boolean") err("wrapMorph", "must be a boolean");
1203
1780
  if (seq.finish !== void 0) validateFinish(seq.finish, "finish", err, (p, code, message) => void warnings.push({ path: p, code, message }));
1204
1781
  if (!Array.isArray(seq.segments) || seq.segments.length === 0) {
1205
1782
  err("segments", "must be a non-empty array");
@@ -1253,8 +1830,8 @@ function validateSequence(seq) {
1253
1830
  if (isObj(s.transition) && s.transition.text !== void 0) {
1254
1831
  if (s.transition.type !== "morph") {
1255
1832
  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'");
1833
+ } else if (s.transition.text !== "step" && s.transition.text !== "crossfade" && s.transition.text !== "dip") {
1834
+ err(`${p}.transition.text`, "must be 'step' | 'crossfade' | 'dip'");
1258
1835
  }
1259
1836
  }
1260
1837
  }
@@ -1311,7 +1888,7 @@ function validateSequence(seq) {
1311
1888
  code: "morph-structural-mismatch",
1312
1889
  message: `segments ${i}\u2192${i + 1} differ structurally: morph will fall back to cut`
1313
1890
  });
1314
- } else if (morphNothingMorphable(s.scene, next.scene, { textCrossfade: s.transition.text === "crossfade" })) {
1891
+ } 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
1892
  warnings.push({
1316
1893
  path: `segments[${i}].transition`,
1317
1894
  code: "morph-nothing-morphable",
@@ -1320,6 +1897,20 @@ function validateSequence(seq) {
1320
1897
  }
1321
1898
  }
1322
1899
  }
1900
+ if (errors.length === 0 && seq.wrapMorph === true) {
1901
+ const sq = seq;
1902
+ const last = sq.segments.length - 1;
1903
+ const lastTr = sq.segments[last]?.transition;
1904
+ let why = null;
1905
+ if (sq.loop !== true) why = "the sequence does not loop";
1906
+ else if (last < 1) why = "there is only one segment";
1907
+ else if (lastTr?.type !== "morph") why = "the last segment declares no morph transition";
1908
+ else if (structuralSignature(sq.segments[last].scene) !== structuralSignature(sq.segments[0].scene)) why = `segment ${last} and segment 0 differ structurally`;
1909
+ 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`;
1910
+ if (why || !canWrapMorph(sq)) {
1911
+ warnings.push({ path: "wrapMorph", code: "wrap-morph-inactive", message: `wrapMorph has no effect: ${why ?? "the wrap cannot morph"} \u2014 the wrap stays a cut` });
1912
+ }
1913
+ }
1323
1914
  return { valid: errors.length === 0, errors, warnings };
1324
1915
  }
1325
1916
  function assertValidSequence(seq) {
@@ -1511,7 +2102,7 @@ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
1511
2102
  if (mSpec.type === "path") {
1512
2103
  pathPts = mSpec.points.map((p) => ({ x: p.x * w, y: p.y * h }));
1513
2104
  }
1514
- const ease = mSpec.type === "drift" || mSpec.type === "rise" || mSpec.type === "wander" ? mSpec.ease : void 0;
2105
+ const ease2 = mSpec.type === "drift" || mSpec.type === "rise" || mSpec.type === "wander" ? mSpec.ease : void 0;
1515
2106
  const clockPhase = layer.clock ? (layer.clock.phase ?? 0) % 1 * Math.PI * 2 : 0;
1516
2107
  const layout = layer.layout;
1517
2108
  const ordered = layout?.type === "list" || layout?.type === "table";
@@ -1530,6 +2121,8 @@ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
1530
2121
  if (layer.position) {
1531
2122
  listX0 = layer.position.x * w;
1532
2123
  listY0 = layer.position.y * h;
2124
+ if (layer.position.dx) listX0 += layer.position.dx * scale;
2125
+ if (layer.position.dy) listY0 += layer.position.dy * scale;
1533
2126
  } else {
1534
2127
  const [rx0, rx1] = layer.region?.x ?? [0, 1];
1535
2128
  const [ry0, ry1] = layer.region?.y ?? [0, 1];
@@ -1624,6 +2217,8 @@ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
1624
2217
  if (layer.position && layer.count === 1) {
1625
2218
  x0 = layer.position.x * w;
1626
2219
  y0 = layer.position.y * h;
2220
+ if (layer.position.dx) x0 += layer.position.dx * scale;
2221
+ if (layer.position.dy) y0 += layer.position.dy * scale;
1627
2222
  } else if (layout?.type === "list" || layout?.type === "table") {
1628
2223
  rng.next();
1629
2224
  rng.next();
@@ -1705,7 +2300,7 @@ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
1705
2300
  } : {},
1706
2301
  // Time-structure fields (2026-09): ease draws nothing; emit's one draw
1707
2302
  // per entity happens in a pass AFTER this loop (see below).
1708
- ...ease ? { ease: { type: ease.type, tau: ease.tau } } : {},
2303
+ ...ease2 ? { ease: { type: ease2.type, tau: ease2.tau } } : {},
1709
2304
  // Static rotation (2026-09-10): a scalar draws nothing; the range form
1710
2305
  // draws one value here, last, only when declared.
1711
2306
  ...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 } : {}
@@ -2174,6 +2769,67 @@ import {
2174
2769
  createRng
2175
2770
  } from "@idle-screens/core";
2176
2771
 
2772
+ // src/paint.ts
2773
+ function layerAnchor(layer, w, h, unit) {
2774
+ const p = layer.position;
2775
+ if (p) return { x: p.x * w + (p.dx ?? 0) * unit, y: p.y * h + (p.dy ?? 0) * unit };
2776
+ const rx = layer.region?.x ?? [0, 1];
2777
+ const ry = layer.region?.y ?? [0, 1];
2778
+ return { x: (rx[0] + rx[1]) / 2 * w, y: (ry[0] + ry[1]) / 2 * h };
2779
+ }
2780
+ function transformOrigin(tf, layer, w, h, unit) {
2781
+ return tf.origin === "anchor" && layer ? layerAnchor(layer, w, h, unit) : { x: w / 2, y: h / 2 };
2782
+ }
2783
+ function transformPoint(tf, p, w, h, unit, origin = { x: w / 2, y: h / 2 }) {
2784
+ const s = tf.scale ?? 1;
2785
+ const sx = s * (tf.scaleX ?? 1);
2786
+ let x = (p.x - origin.x) * sx;
2787
+ let y = (p.y - origin.y) * s;
2788
+ if (tf.rotate) {
2789
+ const r = tf.rotate * Math.PI / 180;
2790
+ const c = Math.cos(r);
2791
+ const n = Math.sin(r);
2792
+ [x, y] = [x * c - y * n, x * n + y * c];
2793
+ }
2794
+ return { x: x + origin.x + (tf.x ?? 0) * unit, y: y + origin.y + (tf.y ?? 0) * unit };
2795
+ }
2796
+ function transformSize(tf) {
2797
+ if (!tf) return 1;
2798
+ const s = tf.scale ?? 1;
2799
+ return s * Math.sqrt(Math.abs(tf.scaleX ?? 1));
2800
+ }
2801
+ function groupOf(spec, layer) {
2802
+ return layer.group === void 0 ? void 0 : spec.groups?.[layer.group];
2803
+ }
2804
+ var clamp01 = (v) => v <= 0 ? 0 : v >= 1 ? 1 : v;
2805
+ function paintOpacity(spec, layer) {
2806
+ const own = layer.opacity === void 0 ? 1 : clamp01(layer.opacity);
2807
+ const g = groupOf(spec, layer);
2808
+ return g?.opacity === void 0 ? own : own * clamp01(g.opacity);
2809
+ }
2810
+ function paintSize(spec, layer) {
2811
+ return transformSize(layer.transform) * transformSize(groupOf(spec, layer)?.transform);
2812
+ }
2813
+ function paintMap(spec, layer, w, h, unit) {
2814
+ const tf = layer.transform;
2815
+ const gtf = groupOf(spec, layer)?.transform;
2816
+ if (!tf && !gtf) return null;
2817
+ const o = tf ? transformOrigin(tf, layer, w, h, unit) : null;
2818
+ return (p) => {
2819
+ const q = tf ? transformPoint(tf, p, w, h, unit, o) : p;
2820
+ return gtf ? transformPoint(gtf, q, w, h, unit) : q;
2821
+ };
2822
+ }
2823
+ function mapBox(map, box) {
2824
+ const pts = [[box.x0, box.y0], [box.x1, box.y0], [box.x0, box.y1], [box.x1, box.y1]].map(([x, y]) => map({ x, y }));
2825
+ return {
2826
+ x0: Math.min(...pts.map((p) => p.x)),
2827
+ y0: Math.min(...pts.map((p) => p.y)),
2828
+ x1: Math.max(...pts.map((p) => p.x)),
2829
+ y1: Math.max(...pts.map((p) => p.y))
2830
+ };
2831
+ }
2832
+
2177
2833
  // src/field.ts
2178
2834
  var TWO_PI = Math.PI * 2;
2179
2835
  var FIELD_BUCKET_MS = 100;
@@ -2183,7 +2839,7 @@ var WARP_GAIN = 2;
2183
2839
  var DRIFT_GAIN = 1;
2184
2840
  var FIELD_DRIFT_DEFAULT_AMOUNT = 0.3;
2185
2841
  var CONTRAST = [1, 1.45, 1.8, 1.95, 2];
2186
- var clamp01 = (v) => v < 0 ? 0 : v > 1 ? 1 : v;
2842
+ var clamp012 = (v) => v < 0 ? 0 : v > 1 ? 1 : v;
2187
2843
  function latticeHash(ix, iy, seed) {
2188
2844
  let h = Math.imul(ix | 0, 668265261) ^ Math.imul(iy | 0, 374761393) ^ Math.imul(seed | 0, 2654435761);
2189
2845
  h = Math.imul(h ^ h >>> 15, 2246822507);
@@ -2246,7 +2902,7 @@ function fieldAt(u, v, t, cfg, seed) {
2246
2902
  y += warp * WARP_GAIN * qy;
2247
2903
  }
2248
2904
  const raw = fbm(x, y, s, octaves);
2249
- return clamp01(0.5 + (raw - 0.5) * CONTRAST[octaves]);
2905
+ return clamp012(0.5 + (raw - 0.5) * CONTRAST[octaves]);
2250
2906
  }
2251
2907
  function hexToRgb255(hex) {
2252
2908
  const h = hex.length === 4 ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}` : hex;
@@ -2262,10 +2918,10 @@ function fieldRgb(value, bands, quantize) {
2262
2918
  const q = Math.round(quantize ?? n);
2263
2919
  let f;
2264
2920
  if (q >= 2) {
2265
- const level = Math.min(q - 1, Math.floor(clamp01(value) * q));
2921
+ const level = Math.min(q - 1, Math.floor(clamp012(value) * q));
2266
2922
  f = level * (n - 1) / (q - 1);
2267
2923
  } else {
2268
- f = clamp01(value) * (n - 1);
2924
+ f = clamp012(value) * (n - 1);
2269
2925
  }
2270
2926
  const i = Math.min(n - 2, Math.floor(f));
2271
2927
  const k = f - i;
@@ -2288,9 +2944,9 @@ var DITHER_TILE = 8;
2288
2944
  var GRAIN_ALPHA = 0.35;
2289
2945
  var DITHER_ALPHA = 0.25;
2290
2946
  var FINISH_ANIMATE_HZ = 12;
2291
- var clamp012 = (v) => v < 0 ? 0 : v > 1 ? 1 : v;
2947
+ var clamp013 = (v) => v < 0 ? 0 : v > 1 ? 1 : v;
2292
2948
  function finishStrength(f) {
2293
- return { grain: clamp012(f?.grain ?? 0), dither: clamp012(f?.dither ?? 0) };
2949
+ return { grain: clamp013(f?.grain ?? 0), dither: clamp013(f?.dither ?? 0) };
2294
2950
  }
2295
2951
  function grainTilePixels(seed) {
2296
2952
  const px = new Uint8ClampedArray(GRAIN_TILE * GRAIN_TILE * 4);
@@ -2422,110 +3078,6 @@ function screen(fp, pat, alpha, off, w, h) {
2422
3078
  p.restore();
2423
3079
  }
2424
3080
 
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
3081
  // src/compile.ts
2530
3082
  var DEFAULT_STEER_DUR = 1e3;
2531
3083
  function applyRetainedDeltas(spec, deltas) {
@@ -2573,10 +3125,62 @@ function manifestFor(spec) {
2573
3125
  workerReady: true
2574
3126
  };
2575
3127
  }
3128
+ var clamp014 = (v) => v <= 0 ? 0 : v >= 1 ? 1 : v;
3129
+ var EXPIRED = /* @__PURE__ */ Symbol("expired");
3130
+ function overrideHeldAt(o, t) {
3131
+ const k = o.dur > 0 ? easeSmooth(clamp014((t - o.t0) / o.dur)) : 1;
3132
+ return k >= 1 ? o.to : lerpValue(o.from, o.to, k, false, o.leaf);
3133
+ }
3134
+ function overrideValueAt(o, t, timelineValue) {
3135
+ if (t < o.until) return overrideHeldAt(o, t);
3136
+ 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);
3137
+ return EXPIRED;
3138
+ }
3139
+ var steerId = (d) => `${d.t}|${JSON.stringify(d.value)}`;
3140
+ function armOverride(authored, path, from, to, now, glide) {
3141
+ const next = nextKeyAfter(authored, path, now);
3142
+ 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 };
3143
+ }
3144
+ function applyOverridesAt(resolved, overrides, t, expired) {
3145
+ if (overrides.size === 0) return resolved;
3146
+ const deltas = [];
3147
+ for (const [path, o] of overrides) {
3148
+ const v = overrideValueAt(o, t, () => readSpecPath(resolved, path));
3149
+ if (v === EXPIRED) {
3150
+ expired?.push(path);
3151
+ continue;
3152
+ }
3153
+ deltas.push({ t: 0, path, value: v });
3154
+ }
3155
+ return deltas.length > 0 ? applyDeltasToSpec(resolved, deltas) : resolved;
3156
+ }
3157
+ function overridesStayValid(authored, overrides, now) {
3158
+ const paths = [...overrides.keys()];
3159
+ if (paths.length === 0) return true;
3160
+ const groupOf2 = (p) => p.split(".").slice(0, p.startsWith("layers.") ? 2 : 1).join(".");
3161
+ const groups = [...new Set(paths.map(groupOf2))];
3162
+ const full = paths.some((p) => /^layers\.\d+(\.(key|count|motion)(\.|$)|$)/.test(p) || p === "groups" || !/^(layers|background|finish|groups)(\.|$)/.test(p));
3163
+ const only = full ? void 0 : (p) => groups.includes(groupOf2(p));
3164
+ const seen = /* @__PURE__ */ new Set();
3165
+ for (const t of [now, ...timelineSampleTimesAfter(authored, now, false, only)]) {
3166
+ const held = /* @__PURE__ */ new Map();
3167
+ for (const [p, o] of overrides) if (t < o.until) held.set(p, { ...o, t0: Number.NEGATIVE_INFINITY });
3168
+ const r = applyOverridesAt(resolveTimelineAt(authored, t), held, t);
3169
+ const state = JSON.stringify(groups.map((g) => readSpecPath(r, g))) + "|" + [...held.keys()].join(",");
3170
+ if (seen.has(state)) continue;
3171
+ seen.add(state);
3172
+ if (!validateSpecPaths(r, paths)) return false;
3173
+ }
3174
+ return true;
3175
+ }
2576
3176
  var SpecInstance = class {
2577
3177
  constructor(spec, ctx, opts = {}) {
2578
3178
  this.spec = spec;
2579
3179
  this.effSpec = spec;
3180
+ if (spec.timeline) {
3181
+ this.authored = spec;
3182
+ this.effSpec = resolveTimelineAt(spec, 0);
3183
+ }
2580
3184
  this.saverCtx = ctx;
2581
3185
  this.transparent = opts.transparent === true;
2582
3186
  const tier = opts.capabilityTier;
@@ -2642,6 +3246,31 @@ var SpecInstance = class {
2642
3246
  lastStructural = "";
2643
3247
  /** See `LayerPaintOverride`; null (the default, and every plain mount) draws every layer once. */
2644
3248
  paintOverrides = null;
3249
+ /**
3250
+ * The authored spec when it carries a `timeline` (null for every other
3251
+ * scene — which then never enters the timeline path). Each painted frame
3252
+ * resolves it at that frame's own time, so ghosting's warm-up replay and
3253
+ * any seek see the timeline exactly where it was.
3254
+ */
3255
+ authored = null;
3256
+ /**
3257
+ * Live steers on a timeline scene, by index-form path. A steer on a path no
3258
+ * key animates holds forever (sticky, exactly as on a scene without a
3259
+ * timeline); on an animated path it holds `until` that path's next key,
3260
+ * then glides back to the timeline over `outDur`.
3261
+ */
3262
+ overrides = /* @__PURE__ */ new Map();
3263
+ /**
3264
+ * The last registered steer per path, as `t|value`. A host re-sends the
3265
+ * whole channel track on every steer (idle-server broadcasts the full track
3266
+ * so viewers converge), and a steer's `t` is the server's stamp — stable
3267
+ * across re-sends. Without this, an unrelated later steer would re-arm a
3268
+ * hold the timeline had already taken back. Keyed by path (not by the full
3269
+ * `path|t|value` history) so a long-running instance re-steered thousands
3270
+ * of times doesn't accumulate an unbounded set — only the most recent id
3271
+ * per path can ever recur.
3272
+ */
3273
+ seenSteers = /* @__PURE__ */ new Map();
2645
3274
  /** Raster short side for a `field` background — halved on the low tiers. */
2646
3275
  fieldShortSide;
2647
3276
  /** The `field` background's cached raster; null until a field is first painted. */
@@ -2666,12 +3295,14 @@ var SpecInstance = class {
2666
3295
  }
2667
3296
  sizeCanvas() {
2668
3297
  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));
3298
+ const cw = Math.max(1, Math.round(this.w * dpr));
3299
+ const ch = Math.max(1, Math.round(this.h * dpr));
3300
+ if (this.canvas.width !== cw) this.canvas.width = cw;
3301
+ if (this.canvas.height !== ch) this.canvas.height = ch;
2671
3302
  this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
2672
3303
  if (this.finishPass) {
2673
- this.finishPass.canvas.width = this.canvas.width;
2674
- this.finishPass.canvas.height = this.canvas.height;
3304
+ if (this.finishPass.canvas.width !== cw) this.finishPass.canvas.width = cw;
3305
+ if (this.finishPass.canvas.height !== ch) this.finishPass.canvas.height = ch;
2675
3306
  this.finishPass.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
2676
3307
  }
2677
3308
  this.lastRenderT = Number.NEGATIVE_INFINITY;
@@ -3124,6 +3755,12 @@ var SpecInstance = class {
3124
3755
  */
3125
3756
  applyTrack(track) {
3126
3757
  const deltas = track?.deltas ?? [];
3758
+ if (this.authored) {
3759
+ const glide = deltas.length ? deltas.reduce((m, d) => Math.max(m, d.dur ?? DEFAULT_STEER_DUR), 0) : DEFAULT_STEER_DUR;
3760
+ if (!this.overrideAuthored(deltas, this.paused ? 0 : glide)) return;
3761
+ if (this.paused) this.renderFrame(this.sceneNow(), this.seed);
3762
+ return;
3763
+ }
3127
3764
  const target = applyDeltasToSpec(this.effSpec, deltas);
3128
3765
  if (!validateSpec(target).valid) return;
3129
3766
  const dur = deltas.length ? deltas.reduce((m, d) => Math.max(m, d.dur ?? DEFAULT_STEER_DUR), 0) : DEFAULT_STEER_DUR;
@@ -3160,6 +3797,13 @@ var SpecInstance = class {
3160
3797
  * showing a ghost of the un-steered scene.
3161
3798
  */
3162
3799
  applyDeltasNow(deltas) {
3800
+ if (this.authored) {
3801
+ const all = [...deltas];
3802
+ if (!this.overrideAuthored(all, 0)) for (const d of all) this.overrideAuthored([d], 0);
3803
+ this.transition = null;
3804
+ this.lastRenderT = Number.NEGATIVE_INFINITY;
3805
+ return;
3806
+ }
3163
3807
  const next = applyRetainedDeltas(this.effSpec, deltas);
3164
3808
  if (next === this.effSpec) return;
3165
3809
  this.transition = null;
@@ -3215,6 +3859,7 @@ var SpecInstance = class {
3215
3859
  * showing through — the ghosting smear. */
3216
3860
  paintFrame(t, bgAlpha) {
3217
3861
  const { ctx } = this;
3862
+ if (this.authored) this.resolveAuthored(t);
3218
3863
  if (!this.transparent) {
3219
3864
  ctx.globalAlpha = bgAlpha;
3220
3865
  ctx.globalCompositeOperation = "source-over";
@@ -3223,9 +3868,19 @@ var SpecInstance = class {
3223
3868
  ctx.globalAlpha = 1;
3224
3869
  for (let li = 0; li < this.layers.length; li++) {
3225
3870
  const built = this.layers[li];
3226
- const lifeA = lifeAlphaAt(built.layer.life, t);
3871
+ const opacity = built.layer.opacity;
3872
+ const group = built.layer.group === void 0 ? void 0 : this.effSpec.groups?.[built.layer.group];
3873
+ let lifeA = opacity === void 0 ? lifeAlphaAt(built.layer.life, t) : lifeAlphaAt(built.layer.life, t) * clamp014(opacity);
3874
+ if (group?.opacity !== void 0) lifeA *= clamp014(group.opacity);
3227
3875
  if (lifeA <= 0) continue;
3228
3876
  const parentE = this.parentEntityFor(built);
3877
+ const tf = built.layer.transform;
3878
+ const gtf = group?.transform;
3879
+ if (tf || gtf) {
3880
+ ctx.save();
3881
+ if (gtf) this.applyLayerTransform(gtf, null);
3882
+ if (tf) this.applyLayerTransform(tf, built.layer);
3883
+ }
3229
3884
  ctx.globalCompositeOperation = built.layer.blend ?? "source-over";
3230
3885
  this.drawLinks(built, t, lifeA, parentE);
3231
3886
  const override = this.paintOverrides?.get(li) ?? null;
@@ -3235,13 +3890,83 @@ var SpecInstance = class {
3235
3890
  this.drawEntity(built, e, t, lifeA, parentE);
3236
3891
  continue;
3237
3892
  }
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 });
3893
+ 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];
3894
+ if (aOut > 0) this.drawEntity(built, e, t, lifeA, parentE, { sprite: override.outgoing, alpha: aOut });
3895
+ if (aIn > 0) this.drawEntity(built, e, t, lifeA, parentE, { alpha: aIn });
3240
3896
  }
3897
+ if (tf || gtf) ctx.restore();
3241
3898
  }
3242
3899
  ctx.globalAlpha = 1;
3243
3900
  ctx.globalCompositeOperation = "source-over";
3244
3901
  }
3902
+ /**
3903
+ * A `transform`, in min(w,h) units (px under `units: 'px'`), about the
3904
+ * viewport centre — or, with `origin: 'anchor'`, about `layer`'s own anchor
3905
+ * (a group's transform passes no layer: always the viewport centre).
3906
+ */
3907
+ applyLayerTransform(tf, layer) {
3908
+ const { ctx, w, h } = this;
3909
+ const unit = this.effSpec.units === "px" ? 1 : Math.min(w, h);
3910
+ const { x: cx, y: cy } = transformOrigin(tf, layer, w, h, unit);
3911
+ ctx.translate(cx + (tf.x ?? 0) * unit, cy + (tf.y ?? 0) * unit);
3912
+ if (tf.rotate) ctx.rotate(tf.rotate * Math.PI / 180);
3913
+ const s = tf.scale ?? 1;
3914
+ ctx.scale(s * (tf.scaleX ?? 1), s);
3915
+ ctx.translate(-cx, -cy);
3916
+ }
3917
+ /** Scene time for a timeline decision: the last painted frame's, else the loop clock's. */
3918
+ sceneNow() {
3919
+ return this.lastRenderT !== Number.NEGATIVE_INFINITY ? this.lastRenderT : this.lastT;
3920
+ }
3921
+ /** Resolve the authored timeline at `t`, layer the live overrides on top, and adopt the result. */
3922
+ resolveAuthored(t) {
3923
+ const expired = [];
3924
+ const spec = applyOverridesAt(resolveTimelineAt(this.authored, t), this.overrides, t, expired);
3925
+ for (const p of expired) this.overrides.delete(p);
3926
+ this.effSpec = spec;
3927
+ const sig = structuralSignature(spec);
3928
+ if (sig !== this.lastStructural) this.rebuild();
3929
+ else this.layers.forEach((b, i) => {
3930
+ b.layer = spec.layers[i] ?? b.layer;
3931
+ });
3932
+ }
3933
+ /** Register live steers on a timeline scene under the steering rule (see `overrides`). */
3934
+ overrideAuthored(deltas, glide) {
3935
+ const now = this.sceneNow();
3936
+ const shown = this.effSpec;
3937
+ const latest = /* @__PURE__ */ new Map();
3938
+ for (const d of deltas) {
3939
+ const path = canonicalSpecPath(shown, d.path);
3940
+ if (!path) continue;
3941
+ latest.delete(path);
3942
+ latest.set(path, d);
3943
+ }
3944
+ const next = new Map(this.overrides);
3945
+ const armed = [];
3946
+ for (const [path, d] of latest) {
3947
+ const id = steerId(d);
3948
+ if (this.seenSteers.get(path) === id) continue;
3949
+ armed.push([path, id]);
3950
+ next.set(path, armOverride(this.authored, path, glide > 0 ? readSpecPath(shown, path) : d.value, d.value, now, glide));
3951
+ }
3952
+ if (armed.length === 0) return true;
3953
+ if (!overridesStayValid(this.authored, next, now)) return false;
3954
+ for (const [p, id] of armed) this.seenSteers.set(p, id);
3955
+ this.overrides = next;
3956
+ return true;
3957
+ }
3958
+ /** A copy of the live overrides — a sequence carries them into a morph's outgoing endpoint. */
3959
+ timelineOverrides() {
3960
+ return this.authored ? new Map(this.overrides) : null;
3961
+ }
3962
+ /** A copy of which server steers this child already applied, per field (see `seenSteers`). */
3963
+ timelineSeen() {
3964
+ return new Map(this.seenSteers);
3965
+ }
3966
+ /** The spec painted by the last frame (timeline and overrides resolved). */
3967
+ presentedSpec() {
3968
+ return this.effSpec;
3969
+ }
3245
3970
  /**
3246
3971
  * Deterministic, frame-addressable render (shared by the rAF loop).
3247
3972
  *
@@ -3294,6 +4019,11 @@ var SpecInstance = class {
3294
4019
  * already knows the signature is unchanged.
3295
4020
  */
3296
4021
  hotSwapSpec(spec) {
4022
+ this.adoptAuthored(spec);
4023
+ if (this.authored) {
4024
+ this.resolveAuthored(this.sceneNow());
4025
+ return;
4026
+ }
3297
4027
  this.effSpec = spec;
3298
4028
  const sig = structuralSignature(this.effSpec);
3299
4029
  if (sig !== this.lastStructural) this.rebuild();
@@ -3301,8 +4031,68 @@ var SpecInstance = class {
3301
4031
  b.layer = this.effSpec.layers[i] ?? b.layer;
3302
4032
  });
3303
4033
  }
4034
+ /**
4035
+ * Track whether `spec` carries a timeline. Live overrides belong to the
4036
+ * scene they were made on: a different spec (a new publish, a sequence's
4037
+ * re-steered segment, a morph frame) drops them, as a plain hot-swap
4038
+ * replaces `effSpec` wholesale; the host re-sends the live track.
4039
+ */
4040
+ adoptAuthored(spec, keepSteers = false) {
4041
+ const next = spec.timeline ? spec : null;
4042
+ if (next !== this.authored && !(keepSteers && next && this.authored)) {
4043
+ this.overrides.clear();
4044
+ this.seenSteers.clear();
4045
+ }
4046
+ this.authored = next;
4047
+ }
4048
+ /**
4049
+ * A sequence's hot swap of a timeline segment: the new scene replaces the
4050
+ * old, but the live steers (and which server steers were already applied)
4051
+ * carry over — a steer the timeline already took back stays taken back —
4052
+ * and only a steer this child has never seen is armed, now. Frame
4053
+ * continuity is kept, so ghosting trails do not restart.
4054
+ */
4055
+ hotSwapKeepingSteers(spec, retained) {
4056
+ if (!spec.timeline) {
4057
+ this.hotSwapSpec(spec);
4058
+ return;
4059
+ }
4060
+ if (!this.authored) {
4061
+ this.hotSwapSpec(spec);
4062
+ this.applyDeltasNow(retained);
4063
+ return;
4064
+ }
4065
+ this.adoptAuthored(spec, true);
4066
+ for (const d of retained) this.overrideAuthored([d], 0);
4067
+ this.resolveAuthored(this.sceneNow());
4068
+ }
4069
+ /** Arm a steer made while a sequence was mid-morph, on the outgoing end's override set, at that end's scene time. */
4070
+ static armInto(authored, overrides, seen, deltas, now) {
4071
+ const at = resolveTimelineAt(authored, now);
4072
+ const latest = /* @__PURE__ */ new Map();
4073
+ for (const d of deltas) {
4074
+ const path = canonicalSpecPath(at, d.path);
4075
+ if (!path) continue;
4076
+ latest.delete(path);
4077
+ latest.set(path, d);
4078
+ }
4079
+ for (const [path, d] of latest) {
4080
+ const id = steerId(d);
4081
+ if (seen.get(path) === id) continue;
4082
+ const trial = new Map(overrides).set(path, armOverride(authored, path, d.value, d.value, now, 0));
4083
+ if (overridesStayValid(authored, trial, now)) {
4084
+ overrides.set(path, trial.get(path));
4085
+ seen.set(path, id);
4086
+ }
4087
+ }
4088
+ }
3304
4089
  /** Paint-only hot-swap: skips structuralSignature (caller guarantees match). */
3305
4090
  hotSwapPaint(spec) {
4091
+ this.adoptAuthored(spec);
4092
+ if (this.authored) {
4093
+ this.resolveAuthored(this.sceneNow());
4094
+ return;
4095
+ }
3306
4096
  this.effSpec = spec;
3307
4097
  this.layers.forEach((b, i) => {
3308
4098
  b.layer = this.effSpec.layers[i] ?? b.layer;
@@ -3378,6 +4168,8 @@ var SequenceInstance = class {
3378
4168
  * window (if any) has passed.
3379
4169
  */
3380
4170
  pendingWrapFade = false;
4171
+ /** The morph in progress: its ends, and the override sets each end resolves with (see `morphEndpoint`). */
4172
+ morphEnds = null;
3381
4173
  /**
3382
4174
  * Every non-`sequence.segment` delta this instance has been handed, last
3383
4175
  * wins per path, merged across `applyTrack` calls. Children are created
@@ -3502,10 +4294,15 @@ var SequenceInstance = class {
3502
4294
  const tr = this.seq.segments[from]?.transition;
3503
4295
  return tr?.type === "morph" ? tr.dur : 0;
3504
4296
  }
3505
- /** Whether the morph out of `from` cross-fades its text (default `step`: strings switch at k > 0). */
4297
+ /** Whether the morph out of `from` paints its text in two passes — `crossfade` or `dip` (default `step`: strings switch at k > 0). */
3506
4298
  morphTextCrossfades(from) {
3507
4299
  const tr = this.seq.segments[from]?.transition;
3508
- return tr?.type === "morph" && tr.text === "crossfade";
4300
+ return tr?.type === "morph" && (tr.text === "crossfade" || tr.text === "dip");
4301
+ }
4302
+ /** The morph out of `from`'s two-pass text mode, for `crossfadeOverrides`. */
4303
+ morphTextMode(from) {
4304
+ const tr = this.seq.segments[from]?.transition;
4305
+ return tr?.type === "morph" && tr.text === "dip" ? "dip" : "crossfade";
3509
4306
  }
3510
4307
  /**
3511
4308
  * The paint overrides for one crossfade frame: every text layer whose
@@ -3514,7 +4311,7 @@ var SequenceInstance = class {
3514
4311
  * usual) at `1 − k` under the incoming at `k`. Null when nothing differs,
3515
4312
  * so a crossfade between same-worded twins is a plain morph frame.
3516
4313
  */
3517
- crossfadeOverrides(specA, specB, lerped, k) {
4314
+ crossfadeOverrides(specA, specB, lerped, k, mode = "crossfade") {
3518
4315
  let map = null;
3519
4316
  for (let i = 0; i < lerped.layers.length; i++) {
3520
4317
  if (!textStringsDiffer(specA, specB, i)) continue;
@@ -3522,7 +4319,7 @@ var SequenceInstance = class {
3522
4319
  const now = lerped.layers[i].sprite;
3523
4320
  const outgoing = from.kind === "text" && now.kind === "text" ? { ...now, strings: from.strings } : from.kind === "textBlock" && now.kind === "textBlock" ? { ...now, text: from.text } : now;
3524
4321
  if (outgoing === now) continue;
3525
- (map ??= /* @__PURE__ */ new Map()).set(i, { outgoing, k });
4322
+ (map ??= /* @__PURE__ */ new Map()).set(i, mode === "dip" ? { outgoing, k, mode } : { outgoing, k });
3526
4323
  }
3527
4324
  return map;
3528
4325
  }
@@ -3595,6 +4392,46 @@ var SequenceInstance = class {
3595
4392
  this.renderOffscreen(this.fading, outDur + localT, this.childSeed(this.morphChainRoot(from), seed));
3596
4393
  return this.fading;
3597
4394
  }
4395
+ /**
4396
+ * One end of a morph at segment time `t`. A scene without a timeline is its
4397
+ * steered scene, as always. With a timeline, the retained track follows the
4398
+ * same rule a child applies (held until the path's next key, sticky
4399
+ * otherwise): the outgoing end uses its child's live overrides when the
4400
+ * morph began from one, and otherwise — like the incoming end, whose child
4401
+ * does not exist yet — arms the retained steers at segment time 0, as a
4402
+ * freshly mounted child would.
4403
+ */
4404
+ morphEndpoint(index, t, side) {
4405
+ const scene = this.childScene(index);
4406
+ if (!scene.timeline) return this.steeredScene(scene);
4407
+ const ends = this.morphEnds;
4408
+ if (ends && side === "out") ends.outT = t;
4409
+ let ov = side === "out" ? ends?.out ?? null : ends?.in ?? null;
4410
+ if (!ov) {
4411
+ ov = this.armRetained(scene, side === "out" ? ends?.seenOut : void 0);
4412
+ if (ends) {
4413
+ if (side === "out") ends.out = ov;
4414
+ else ends.in = ov;
4415
+ }
4416
+ }
4417
+ return applyOverridesAt(resolveTimelineAt(scene, t), ov, t);
4418
+ }
4419
+ /** The retained steers that resolve (and validate) on a timeline scene, armed at segment time 0. */
4420
+ armRetained(scene, seen) {
4421
+ const out = /* @__PURE__ */ new Map();
4422
+ if (this.retainedDeltas.size === 0) return out;
4423
+ const at0 = resolveTimelineAt(scene, 0);
4424
+ for (const d of this.retainedDeltas.values()) {
4425
+ const path = canonicalSpecPath(at0, d.path);
4426
+ if (!path) continue;
4427
+ const trial = new Map(out).set(path, armOverride(scene, path, d.value, d.value, 0, 0));
4428
+ if (overridesStayValid(scene, trial, 0)) {
4429
+ out.set(path, trial.get(path));
4430
+ seen?.set(path, steerId(d));
4431
+ }
4432
+ }
4433
+ return out;
4434
+ }
3598
4435
  /** A segment's scene with the retained track applied — `scene` itself when nothing resolves on it. */
3599
4436
  steeredScene(scene) {
3600
4437
  return this.retainedDeltas.size === 0 ? scene : applyRetainedDeltas(scene, this.retainedDeltas.values());
@@ -3613,7 +4450,7 @@ var SequenceInstance = class {
3613
4450
  if (index < 0 || index >= this.seq.segments.length) index = 0;
3614
4451
  let child = this.children[index];
3615
4452
  if (!child) {
3616
- child = new SpecInstance(rootScene ?? this.childScene(index), this.childCtx, { transparent: this.bed !== null, skipInitialPaint, capabilityTier: this.capabilityTier, hostPresents: true });
4453
+ child = new SpecInstance(rootScene ?? this.childScene(index), this.childCtx, { transparent: this.bed !== null, skipInitialPaint: skipInitialPaint || this.bed !== null, capabilityTier: this.capabilityTier, hostPresents: true });
3617
4454
  this.children[index] = child;
3618
4455
  child.setPaused(true);
3619
4456
  if (rootScene) child.hotSwapSpec(this.childScene(index));
@@ -3668,7 +4505,12 @@ var SequenceInstance = class {
3668
4505
  frameFinish() {
3669
4506
  if (this.seq.finish !== void 0) return this.seq.finish;
3670
4507
  if (this.activeIndex < 0) return void 0;
3671
- return this.steeredScene(this.childScene(this.activeIndex)).finish;
4508
+ const scene = this.childScene(this.activeIndex);
4509
+ if (scene.timeline) {
4510
+ const live = (this.fadingIn?.index === this.activeIndex ? this.fadingIn.child : null) ?? this.children[this.activeIndex] ?? this.children[this.morphChainRoot(this.activeIndex)];
4511
+ if (live) return live.presentedSpec().finish;
4512
+ }
4513
+ return this.steeredScene(scene).finish;
3672
4514
  }
3673
4515
  /**
3674
4516
  * The seed `frameFinish()`'s grain resolves against — the sequence's own
@@ -3693,29 +4535,36 @@ var SequenceInstance = class {
3693
4535
  const resolved = resolveSegment(this.seq, T + this.clockOffset, { releasedBelow: this.releasedBelow });
3694
4536
  const { index, localT } = resolved;
3695
4537
  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
4538
  const lastIdx = this.seq.segments.length - 1;
3699
4539
  const wrapped = this.seq.loop && index === 0 && lastIdx > 0 && (T + this.clockOffset >= this.timedTotal() || this.pendingWrapFade);
4540
+ const naturalWrap = this.seq.loop && index === 0 && lastIdx > 0 && T + this.clockOffset >= this.timedTotal();
4541
+ const prevIdx = index > 0 ? index - 1 : -1;
4542
+ const morphFrom = prevIdx >= 0 ? prevIdx : naturalWrap && canWrapMorph(this.seq) ? lastIdx : -1;
4543
+ const morphActive = morphFrom >= 0 && (morphFrom === prevIdx ? this.canMorph(prevIdx) : true) && localT < this.morphDur(morphFrom);
3700
4544
  const fadeFrom = prevIdx >= 0 ? prevIdx : wrapped ? lastIdx : -1;
3701
4545
  const fadeActive = fadeFrom >= 0 && localT < this.fadeDur(fadeFrom);
3702
4546
  if (!fadeActive && this.fading) this.releaseFading();
3703
4547
  if (this.pendingWrapFade && (index !== 0 || !fadeActive)) this.pendingWrapFade = false;
3704
4548
  if (morphActive) {
3705
4549
  const chainRoot = this.morphChainRoot(index);
3706
- this.morphFromIndex = prevIdx;
4550
+ if (this.morphFromIndex !== morphFrom || this.morphEnds?.to !== index) {
4551
+ const outChild = this.children[morphFrom];
4552
+ this.morphEnds = { from: morphFrom, to: index, outT: 0, out: outChild?.timelineOverrides() ?? null, seenOut: outChild?.timelineSeen() ?? /* @__PURE__ */ new Map(), in: null };
4553
+ }
4554
+ this.morphFromIndex = morphFrom;
3707
4555
  this.activeIndex = index;
3708
4556
  for (let i = 0; i < this.children.length; i++) {
3709
4557
  if (i !== chainRoot) this.releaseChild(i);
3710
4558
  }
3711
- const child = this.ensureChild(chainRoot, void 0, this.morphTextCrossfades(prevIdx));
3712
- const dur = this.morphDur(prevIdx);
4559
+ const child = this.ensureChild(chainRoot, void 0, this.morphTextCrossfades(morphFrom));
4560
+ const dur = this.morphDur(morphFrom);
3713
4561
  const k = easeSmooth(localT / dur);
3714
- const specA = this.steeredScene(this.childScene(prevIdx));
3715
- const specB = this.steeredScene(this.childScene(index));
4562
+ const outDur = this.seq.segments[morphFrom].duration ?? 0;
4563
+ const specA = this.morphEndpoint(morphFrom, outDur + localT, "out");
4564
+ const specB = this.morphEndpoint(index, localT, "in");
3716
4565
  const lerped = lerpSpec(specA, specB, k);
3717
4566
  child.hotSwapSpec(lerped);
3718
- child.setLayerPaintOverrides(this.morphTextCrossfades(prevIdx) ? this.crossfadeOverrides(specA, specB, lerped, k) : null);
4567
+ child.setLayerPaintOverrides(this.morphTextCrossfades(morphFrom) ? this.crossfadeOverrides(specA, specB, lerped, k, this.morphTextMode(morphFrom)) : null);
3719
4568
  child.renderFrame(localT, this.childSeed(chainRoot, seed));
3720
4569
  } else {
3721
4570
  if (this.morphFromIndex >= 0) {
@@ -3723,6 +4572,7 @@ var SequenceInstance = class {
3723
4572
  this.releaseChild(oldRoot);
3724
4573
  if (oldRoot !== this.morphFromIndex) this.releaseChild(this.morphFromIndex);
3725
4574
  this.morphFromIndex = -1;
4575
+ this.morphEnds = null;
3726
4576
  }
3727
4577
  if (index !== this.activeIndex) {
3728
4578
  for (let i = 0; i < this.children.length; i++) {
@@ -3802,6 +4652,12 @@ var SequenceInstance = class {
3802
4652
  const childDeltas = deltas.filter((d) => d.path !== "sequence.segment");
3803
4653
  if (childDeltas.length > 0) {
3804
4654
  if (this.morphFromIndex >= 0) {
4655
+ const ends = this.morphEnds;
4656
+ if (ends) {
4657
+ const outScene = this.childScene(ends.from);
4658
+ if (ends.out && outScene.timeline) SpecInstance.armInto(outScene, ends.out, ends.seenOut, childDeltas, ends.outT);
4659
+ ends.in = null;
4660
+ }
3805
4661
  if (!switchedSegment) this.renderFrame(this.renderedT, this.seed);
3806
4662
  } else if (this.activeIndex >= 0) {
3807
4663
  const child = this.children[this.activeIndex];
@@ -3831,11 +4687,19 @@ var SequenceInstance = class {
3831
4687
  if (!sequenceSwapCompatible(this.seq, next)) return false;
3832
4688
  this.seq = next;
3833
4689
  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)));
4690
+ const swap = (child, index) => {
4691
+ if (!child) return;
4692
+ const scene = this.childScene(index);
4693
+ if (!scene.timeline) {
4694
+ child.hotSwapSpec(this.steeredScene(scene));
4695
+ return;
4696
+ }
4697
+ child.hotSwapKeepingSteers(scene, this.retainedDeltas.values());
4698
+ };
4699
+ for (let i = 0; i < this.children.length; i++) swap(this.children[i], i);
4700
+ if (this.fading) swap(this.fading.child, this.fading.index);
4701
+ if (this.fadingIn) swap(this.fadingIn.child, this.fadingIn.index);
4702
+ this.morphEnds = null;
3839
4703
  if (this.paused) this.renderFrame(this.renderedT, this.seed);
3840
4704
  return true;
3841
4705
  }
@@ -4234,6 +5098,8 @@ var SPARSE_DECLARED_MAX_COVERAGE = 0.02;
4234
5098
  var READ_LEGIBILITY_FLOOR = 4.5;
4235
5099
  var READ_SAFE_AREA = 0.05;
4236
5100
  function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
5101
+ const hidden = hiddenLayers(spec);
5102
+ spec = resolveTimelineAt(spec, opts.t ?? COHESION_T);
4237
5103
  const warnings = [];
4238
5104
  const w = viewport.width;
4239
5105
  const h = viewport.height;
@@ -4258,6 +5124,7 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
4258
5124
  let textLayerCount = 0;
4259
5125
  let motionLayerCount = 0;
4260
5126
  for (let li = 0; li < spec.layers.length; li++) {
5127
+ if (hidden?.[li]) continue;
4261
5128
  const layer = spec.layers[li];
4262
5129
  const entities = allEntities[li];
4263
5130
  totalEntities += entities.length;
@@ -4324,13 +5191,17 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
4324
5191
  }
4325
5192
  let totalCoverage = 0;
4326
5193
  for (let li = 0; li < spec.layers.length; li++) {
5194
+ if (hidden?.[li]) continue;
4327
5195
  const layer = spec.layers[li];
4328
5196
  const entities = allEntities[li];
5197
+ const paintA = layer.opacity === void 0 && layer.group === void 0 ? 1 : paintOpacity(spec, layer);
4329
5198
  for (const e of entities) {
4330
5199
  const r = e.size / 2;
4331
5200
  let pixArea;
4332
5201
  if (layer.sprite.kind === "circle") {
4333
5202
  pixArea = Math.PI * r * r;
5203
+ } else if (layer.sprite.kind === "polygon") {
5204
+ pixArea = polygonArea(polygonPoints(layer.sprite, r));
4334
5205
  } else if (layer.sprite.kind === "bar") {
4335
5206
  pixArea = e.size * barFraction(layer.sprite, e.barIndex ?? 0) * (e.size2 ?? e.size * 0.2);
4336
5207
  } else if (layer.sprite.kind === "textBlock") {
@@ -4348,7 +5219,7 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
4348
5219
  const g2 = (ga * ga + ga * gb + gb * gb) / 3;
4349
5220
  duty = Math.min(layer.emit.life, layer.emit.every) / layer.emit.every * 0.5 * g2;
4350
5221
  }
4351
- totalCoverage += pixArea * e.alpha * duty / (w * h);
5222
+ totalCoverage += paintA === 1 ? pixArea * e.alpha * duty / (w * h) : pixArea * e.alpha * duty * paintA / (w * h);
4352
5223
  }
4353
5224
  if (layer.links) {
4354
5225
  const positions = entities.map((e) => positionAt(e, 0, w, h));
@@ -4357,7 +5228,7 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
4357
5228
  const defaultWidth = scale === 1 ? 1 : 1 / LIMITS.referenceViewport;
4358
5229
  const lwPx = (layer.links.width ?? defaultWidth) * scale;
4359
5230
  const la = layer.links.alpha ?? 1;
4360
- for (const edge of edges) totalCoverage += edge.dist * lwPx * la / (w * h);
5231
+ for (const edge of edges) totalCoverage += paintA === 1 ? edge.dist * lwPx * la / (w * h) : edge.dist * lwPx * la * paintA / (w * h);
4361
5232
  }
4362
5233
  }
4363
5234
  const density = spec.density ?? "normal";
@@ -4400,6 +5271,7 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
4400
5271
  }
4401
5272
  }
4402
5273
  for (let li = 0; li < spec.layers.length; li++) {
5274
+ if (hidden?.[li]) continue;
4403
5275
  const layer = spec.layers[li];
4404
5276
  if (!layer.links || layer.links.mode === "chain") continue;
4405
5277
  const entities = allEntities[li];
@@ -4416,6 +5288,7 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
4416
5288
  }
4417
5289
  }
4418
5290
  for (let li = 0; li < spec.layers.length; li++) {
5291
+ if (hidden?.[li]) continue;
4419
5292
  const layer = spec.layers[li];
4420
5293
  if (layer.motion.type === "static") continue;
4421
5294
  const entities = allEntities[li];
@@ -4432,7 +5305,7 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
4432
5305
  });
4433
5306
  }
4434
5307
  }
4435
- const composed = spec.layers.map((l, li) => ({ l, ents: allEntities[li] })).filter(({ l }) => !["bounce", "warp", "path"].includes(l.motion.type)).filter(({ l }) => !(l.motion.type === "orbit" && l.motion.center && "layer" in l.motion.center));
5308
+ const composed = spec.layers.map((l, li) => ({ l, li, ents: allEntities[li] })).filter(({ li }) => !hidden?.[li]).filter(({ l }) => !["bounce", "warp", "path"].includes(l.motion.type)).filter(({ l }) => !(l.motion.type === "orbit" && l.motion.center && "layer" in l.motion.center));
4436
5309
  const composedCount = composed.reduce((s, c) => s + c.ents.length, 0);
4437
5310
  if (composedCount >= 8) {
4438
5311
  const positions = composed.flatMap(({ ents }) => ents.map((e) => positionAt(e, 0, w, h)));
@@ -4450,6 +5323,7 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
4450
5323
  }
4451
5324
  const textBoxes = [];
4452
5325
  for (let li = 0; li < spec.layers.length; li++) {
5326
+ if (hidden?.[li]) continue;
4453
5327
  const layer = spec.layers[li];
4454
5328
  const s = layer.sprite;
4455
5329
  if (layer.motion.type !== "static") continue;
@@ -4457,8 +5331,11 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
4457
5331
  const label = layer.key ? `\`${layer.key}\`` : `layers[${li}]`;
4458
5332
  for (const e of allEntities[li]) {
4459
5333
  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);
5334
+ const raw = s.kind === "textBlock" ? textBlockBoxAt(s, p, w, h) : textBoxAt(s, e, p, spec, w, h);
5335
+ const map = paintMap(spec, layer, w, h, scale);
5336
+ const box = map ? { ...raw, ...mapBox(map, raw) } : raw;
5337
+ const baseAlpha = Math.max(0, Math.min(1, e.alpha - e.pulseAmp)) * (s.kind === "textBlock" ? s.opacity ?? 1 : 1);
5338
+ const alpha = layer.opacity === void 0 && layer.group === void 0 ? baseAlpha : baseAlpha * paintOpacity(spec, layer);
4462
5339
  textBoxes.push({ li, label, alpha, ...box });
4463
5340
  }
4464
5341
  }
@@ -4581,14 +5458,15 @@ function brightestAdditivePlate(spec, allEntities, ground, box, w, h) {
4581
5458
  if (blend !== "lighter" && blend !== "screen") continue;
4582
5459
  if (lj === box.li) continue;
4583
5460
  const entities = allEntities[lj];
5461
+ const moved = layer.transform !== void 0 || groupOf(spec, layer)?.transform !== void 0;
4584
5462
  for (const e of entities) {
4585
5463
  const hex = spriteHex(layer, e);
4586
5464
  if (hex === null) continue;
4587
5465
  const growScale = (e.motion === "warp" ? WARP_MAX_SCALE : 1) * (e.emit ? Math.max(e.emit.growFrom, e.emit.growTo) : 1);
4588
5466
  const maxDim = Math.max(e.size, e.size2 ?? 0) * (1 + e.growAmp) * growScale;
4589
5467
  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);
5468
+ if (!moved && !entityReachesBox(e, maxDim / 2, box, w, h)) continue;
5469
+ const a = layer.opacity === void 0 && layer.group === void 0 ? Math.min(1, e.alpha + e.pulseAmp) : Math.min(1, e.alpha + e.pulseAmp) * paintOpacity(spec, layer);
4592
5470
  if (a <= 0) continue;
4593
5471
  const plate = additivePlate(ground, hexRgb(hex), a, blend);
4594
5472
  const lum = relativeLuminance(plate);
@@ -4636,6 +5514,26 @@ function textBlockBoxAt(s, p, w, h) {
4636
5514
  const { dx, dy } = textBlockAnchorOffset(s, maxWPx, maxLineW, totalH);
4637
5515
  return { x0: x0 + dx, y0: p.y + dy, x1: x0 + dx + maxLineW, y1: p.y + dy + totalH, fs: fsPx };
4638
5516
  }
5517
+ function hiddenLayers(spec) {
5518
+ if (!spec.groups && !spec.layers.some((l) => l.opacity !== void 0)) return null;
5519
+ const tracks = spec.timeline ? timelineTracks(spec) : null;
5520
+ const pinnedZero = (path, base, ancestors) => {
5521
+ if (base !== 0) return false;
5522
+ if (!tracks) return true;
5523
+ for (const [p, keys] of tracks) {
5524
+ if (p === path && keys.some((k) => k.value !== 0)) return false;
5525
+ if (ancestors.includes(p)) return false;
5526
+ }
5527
+ return true;
5528
+ };
5529
+ const out = spec.layers.map((l, i) => {
5530
+ const lp = `layers.${i}`;
5531
+ if (pinnedZero(`${lp}.opacity`, l.opacity, ["layers", lp])) return true;
5532
+ const g = l.group !== void 0 && spec.groups && Object.prototype.hasOwnProperty.call(spec.groups, l.group) ? spec.groups[l.group] : void 0;
5533
+ return !!g && pinnedZero(`groups.${l.group}.opacity`, g.opacity, ["groups", `groups.${l.group}`]);
5534
+ });
5535
+ return out.some(Boolean) ? out : null;
5536
+ }
4639
5537
  function adviseSequence(seq, viewport = { width: 1920, height: 1080 }) {
4640
5538
  const warnings = [];
4641
5539
  for (let i = 0; i < seq.segments.length; i++) {
@@ -4655,8 +5553,8 @@ function adviseSequence(seq, viewport = { width: 1920, height: 1080 }) {
4655
5553
  });
4656
5554
  }
4657
5555
  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);
5556
+ const lumaA = backgroundLuma(resolveTimelineAt(seq.segments[i].scene, seq.segments[i].duration ?? 0));
5557
+ const lumaB = backgroundLuma(resolveTimelineAt(seq.segments[i + 1].scene, 0));
4660
5558
  const delta = Math.abs(lumaA - lumaB);
4661
5559
  if (delta > 0.5) {
4662
5560
  warnings.push({
@@ -4675,7 +5573,7 @@ function adviseSequence(seq, viewport = { width: 1920, height: 1080 }) {
4675
5573
  code: "morph-structural-mismatch",
4676
5574
  message: `segments ${i}\u2192${i + 1} differ structurally: morph will fall back to cut`
4677
5575
  });
4678
- } else if (morphNothingMorphable(seq.segments[i].scene, seq.segments[i + 1].scene, { textCrossfade: tr.text === "crossfade" })) {
5576
+ } 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
5577
  warnings.push({
4680
5578
  path: `segments[${i}].transition`,
4681
5579
  code: "morph-nothing-morphable",
@@ -4709,7 +5607,7 @@ function describeScene(spec, options = {}) {
4709
5607
  let pixArea = 0;
4710
5608
  for (const e of entities) {
4711
5609
  const r = e.size / 2;
4712
- pixArea += layer.sprite.kind === "circle" ? Math.PI * r * r : e.size * e.size;
5610
+ pixArea += layer.sprite.kind === "circle" ? Math.PI * r * r : layer.sprite.kind === "polygon" ? polygonArea(polygonPoints(layer.sprite, r)) : e.size * e.size;
4713
5611
  }
4714
5612
  const coverage = pixArea / (w * h);
4715
5613
  const meanAlpha = entities.length > 0 ? entities.reduce((s, e) => s + e.alpha, 0) / entities.length : 0;
@@ -4785,6 +5683,8 @@ import { createRng as createRng4 } from "@idle-screens/core";
4785
5683
  var GLOW_SPREAD = 2.4;
4786
5684
  var LINE_SALIENCE = 3.5;
4787
5685
  var GHOST_TAPS = 12;
5686
+ var POLYGON_OUTLINE_CELLS = 3;
5687
+ var POLYGON_SCANLINES = 4;
4788
5688
  function buildScene(spec, opts) {
4789
5689
  const { width: w, height: h } = opts.viewport ?? { width: 1920, height: 1080 };
4790
5690
  const seed = normalizeSeed(opts.seed ?? spec.seed ?? 42);
@@ -4799,7 +5699,14 @@ function buildScene(spec, opts) {
4799
5699
  const layers = spec.layers.map((layer) => ({ layer, entities: buildEntities(layer, rng, w, h, scale, countScale) }));
4800
5700
  const byKey = /* @__PURE__ */ new Map();
4801
5701
  for (const { layer, entities } of layers) if (layer.key) byKey.set(layer.key, entities);
4802
- return { w, h, scale, seed, layers, byKey };
5702
+ let transforms = null;
5703
+ for (const { layer, entities } of layers) {
5704
+ const map = paintMap(spec, layer, w, h, scale);
5705
+ if (!map) continue;
5706
+ transforms ??= /* @__PURE__ */ new Map();
5707
+ for (const e of entities) transforms.set(e, map);
5708
+ }
5709
+ return { w, h, scale, seed, layers, byKey, transforms };
4803
5710
  }
4804
5711
  function posOf(scene, e, t) {
4805
5712
  const p = positionAt(e, t, scene.w, scene.h);
@@ -4811,7 +5718,8 @@ function posOf(scene, e, t) {
4811
5718
  p.y += pp.y;
4812
5719
  }
4813
5720
  }
4814
- return p;
5721
+ const map = scene.transforms?.get(e);
5722
+ return map ? map(p) : p;
4815
5723
  }
4816
5724
  function textBox(s, e, p) {
4817
5725
  const m = s.font ? /(\d+(?:\.\d+)?)px/.exec(s.font) : null;
@@ -4863,7 +5771,66 @@ function textBlockRevealFraction(s, w, h, t) {
4863
5771
  }
4864
5772
  return rs.progress;
4865
5773
  }
5774
+ function polygonCellCoverage(pts, cellW, cellH, c0, c1, r0, r1) {
5775
+ const nc = c1 - c0 + 1;
5776
+ const cov = new Float64Array(nc * (r1 - r0 + 1));
5777
+ const xMin = c0 * cellW;
5778
+ const xMax = (c1 + 1) * cellW;
5779
+ const share = 1 / POLYGON_SCANLINES;
5780
+ const hits = [];
5781
+ const addSpan = (row, xa, xb) => {
5782
+ const a = Math.max(xMin, xa);
5783
+ const b = Math.min(xMax, xb);
5784
+ if (!(b > a)) return;
5785
+ const first = Math.max(c0, Math.floor(a / cellW));
5786
+ const last = Math.min(c1, Math.floor(b / cellW));
5787
+ for (let c = first; c <= last; c++) {
5788
+ const len = Math.min(b, (c + 1) * cellW) - Math.max(a, c * cellW);
5789
+ if (len > 0) cov[row * nc + (c - c0)] += len / cellW * share;
5790
+ }
5791
+ };
5792
+ for (let r = r0; r <= r1; r++) {
5793
+ for (let k = 0; k < POLYGON_SCANLINES; k++) {
5794
+ const y = (r + (k + 0.5) / POLYGON_SCANLINES) * cellH;
5795
+ hits.length = 0;
5796
+ for (let i = 0; i < pts.length; i++) {
5797
+ const p = pts[i];
5798
+ const q = pts[(i + 1) % pts.length];
5799
+ const down = p.y <= y && q.y > y;
5800
+ if (!down && !(q.y <= y && p.y > y)) continue;
5801
+ hits.push({ x: p.x + (y - p.y) / (q.y - p.y) * (q.x - p.x), dir: down ? 1 : -1 });
5802
+ }
5803
+ if (hits.length < 2) continue;
5804
+ hits.sort((m, n) => m.x - n.x);
5805
+ let wind = 0;
5806
+ let start = 0;
5807
+ for (const hit of hits) {
5808
+ const before = wind;
5809
+ wind += hit.dir;
5810
+ if (before === 0 && wind !== 0) start = hit.x;
5811
+ else if (before !== 0 && wind === 0) addSpan(r - r0, start, hit.x);
5812
+ }
5813
+ }
5814
+ }
5815
+ return cov;
5816
+ }
5817
+ function distanceToOutline(pts, x, y) {
5818
+ let best = Infinity;
5819
+ for (let i = 0; i < pts.length; i++) {
5820
+ const p = pts[i];
5821
+ const q = pts[(i + 1) % pts.length];
5822
+ const ex = q.x - p.x;
5823
+ const ey = q.y - p.y;
5824
+ const len2 = ex * ex + ey * ey;
5825
+ const u = len2 > 0 ? Math.max(0, Math.min(1, ((x - p.x) * ex + (y - p.y) * ey) / len2)) : 0;
5826
+ const dx = x - (p.x + ex * u);
5827
+ const dy = y - (p.y + ey * u);
5828
+ best = Math.min(best, dx * dx + dy * dy);
5829
+ }
5830
+ return Math.sqrt(best);
5831
+ }
4866
5832
  function luminanceGrid(spec, opts = {}) {
5833
+ spec = resolveTimelineAt(spec, opts.t ?? 5e3);
4867
5834
  const scene = buildScene(spec, opts);
4868
5835
  const t = opts.t ?? 5e3;
4869
5836
  const cols = Math.max(8, Math.min(200, opts.cols ?? 80));
@@ -4962,9 +5929,66 @@ function luminanceGrid(spec, opts = {}) {
4962
5929
  }
4963
5930
  }
4964
5931
  };
5932
+ const splatPolygonOutline = (layer, s, e, p, sz, a, lum, tPass) => {
5933
+ const radius = sz / 2;
5934
+ const rot = rotationAt(e, tPass);
5935
+ const cr = Math.cos(rot);
5936
+ const sr = Math.sin(rot);
5937
+ 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 }));
5938
+ const additive = layer.blend === "lighter" || layer.blend === "screen";
5939
+ const glow = !!s.soft && additive;
5940
+ let band = 0;
5941
+ if (glow) {
5942
+ const perimeter = pts.reduce((acc, q, i) => {
5943
+ const n = pts[(i + 1) % pts.length];
5944
+ return acc + Math.hypot(n.x - q.x, n.y - q.y);
5945
+ }, 0);
5946
+ band = perimeter > 0 ? (GLOW_SPREAD - 1) * (2 * polygonArea(pts) / perimeter) : 0;
5947
+ }
5948
+ let minX = Infinity;
5949
+ let maxX = -Infinity;
5950
+ let minY = Infinity;
5951
+ let maxY = -Infinity;
5952
+ for (const q of pts) {
5953
+ minX = Math.min(minX, q.x);
5954
+ maxX = Math.max(maxX, q.x);
5955
+ minY = Math.min(minY, q.y);
5956
+ maxY = Math.max(maxY, q.y);
5957
+ }
5958
+ const c0 = Math.max(0, Math.floor((minX - band) / cellW));
5959
+ const c1 = Math.min(cols - 1, Math.floor((maxX + band) / cellW));
5960
+ const r0 = Math.max(0, Math.floor((minY - band) / cellH));
5961
+ const r1 = Math.min(rows - 1, Math.floor((maxY + band) / cellH));
5962
+ if (c0 > c1 || r0 > r1) return;
5963
+ const cov = polygonCellCoverage(pts, cellW, cellH, c0, c1, r0, r1);
5964
+ const nc = c1 - c0 + 1;
5965
+ for (let r = r0; r <= r1; r++) {
5966
+ for (let c = c0; c <= c1; c++) {
5967
+ const f = cov[(r - r0) * nc + (c - c0)];
5968
+ const x = (c + 0.5) * cellW;
5969
+ const y = (r + 0.5) * cellH;
5970
+ let wgt;
5971
+ if (glow) {
5972
+ const outX = Math.max(0, minX - x, x - maxX);
5973
+ const outY = Math.max(0, minY - y, y - maxY);
5974
+ if (f >= 1) wgt = 1;
5975
+ else if (outX * outX + outY * outY >= band * band) wgt = f;
5976
+ else {
5977
+ const k = band > 0 ? Math.max(0, 1 - distanceToOutline(pts, x, y) / band) : 0;
5978
+ wgt = f + (1 - f) * k * k;
5979
+ }
5980
+ } else if (s.soft) {
5981
+ wgt = f * Math.max(0.1, 1 - Math.hypot(x - p.x, y - p.y) / Math.max(radius, 1e-6));
5982
+ } else {
5983
+ wgt = f;
5984
+ }
5985
+ if (wgt > 0) compose(r * cols + c, lum, a * Math.min(1, wgt), layer.blend);
5986
+ }
5987
+ }
5988
+ };
4965
5989
  const splatPass = (tPass, alphaScale) => {
4966
5990
  for (const { layer, entities } of scene.layers) {
4967
- const lifeA = lifeAlphaAt(layer.life, tPass);
5991
+ const lifeA = layer.opacity === void 0 && layer.group === void 0 ? lifeAlphaAt(layer.life, tPass) : lifeAlphaAt(layer.life, tPass) * paintOpacity(spec, layer);
4968
5992
  if (lifeA <= 0) continue;
4969
5993
  for (const e of entities) {
4970
5994
  splatTrail(layer, e, tPass, lifeA, alphaScale);
@@ -4972,8 +5996,12 @@ function luminanceGrid(spec, opts = {}) {
4972
5996
  if (a <= 4e-3) continue;
4973
5997
  const lum = spriteLuma(layer, e);
4974
5998
  const p = posOf(scene, e, tPass);
4975
- const sz = sizeAt(e, tPass);
5999
+ const sz = layer.transform || layer.group !== void 0 ? sizeAt(e, tPass) * paintSize(spec, layer) : sizeAt(e, tPass);
4976
6000
  const s = layer.sprite;
6001
+ if (s.kind === "polygon" && sz >= POLYGON_OUTLINE_CELLS * Math.min(cellW, cellH)) {
6002
+ splatPolygonOutline(layer, s, e, p, sz, a, lum, tPass);
6003
+ continue;
6004
+ }
4977
6005
  if (s.kind === "stroke") {
4978
6006
  const pts = strokeSamples(s, sz / 2);
4979
6007
  let angle = rotationAt(e, tPass);
@@ -5219,6 +6247,7 @@ function renderDensityMap(grid) {
5219
6247
  return lines.join("\n");
5220
6248
  }
5221
6249
  function textSprites(spec, opts = {}) {
6250
+ spec = resolveTimelineAt(spec, opts.t ?? 5e3);
5222
6251
  const scene = buildScene(spec, opts);
5223
6252
  const t = opts.t ?? 5e3;
5224
6253
  const out = [];
@@ -5256,6 +6285,7 @@ function rankDominance(raw) {
5256
6285
  }));
5257
6286
  }
5258
6287
  function rawDominance(spec, opts = {}) {
6288
+ spec = resolveTimelineAt(spec, opts.t ?? 5e3);
5259
6289
  const scene = buildScene(spec, opts);
5260
6290
  const t = opts.t ?? 5e3;
5261
6291
  const { w, h, scale } = scene;
@@ -5280,7 +6310,9 @@ function rawDominance(spec, opts = {}) {
5280
6310
  return Math.sqrt(dx * dx + dy * dy);
5281
6311
  };
5282
6312
  const raw = scene.layers.map(({ layer, entities }, layerIndex) => {
5283
- const lifeA = lifeAlphaAt(layer.life, t);
6313
+ const lifeA = layer.opacity === void 0 && layer.group === void 0 ? lifeAlphaAt(layer.life, t) : lifeAlphaAt(layer.life, t) * paintOpacity(spec, layer);
6314
+ const painted = layer.transform !== void 0 || layer.group !== void 0;
6315
+ const areaScale = painted ? paintSize(spec, layer) ** 2 : 1;
5284
6316
  let area = 0;
5285
6317
  let lumAcc = 0;
5286
6318
  for (const e of entities) {
@@ -5309,7 +6341,7 @@ function rawDominance(spec, opts = {}) {
5309
6341
  const box = textBlockBox(s, e, { x: 0, y: 0 }, w, h);
5310
6342
  entArea = box.halfX * 2 * box.halfY * 2 * 0.55 * textBlockRevealFraction(s, w, h, t) * (s.opacity ?? 1);
5311
6343
  } else entArea = sz * sz * 0.55;
5312
- area += entArea * a;
6344
+ area += painted ? entArea * areaScale * a : entArea * a;
5313
6345
  if (layer.trail) {
5314
6346
  const fade = layer.trail.fade ?? 1;
5315
6347
  const span = sweptDistance(e, layer.trail.length);
@@ -5339,29 +6371,32 @@ function rawDominance(spec, opts = {}) {
5339
6371
  return raw;
5340
6372
  }
5341
6373
  function layerCohesion(spec, opts = {}) {
6374
+ spec = resolveTimelineAt(spec, opts.t ?? 5e3);
5342
6375
  const scene = buildScene(spec, opts);
5343
6376
  return cohesionOf(scene.layers, opts.t ?? 5e3, scene.w, scene.h);
5344
6377
  }
5345
6378
  function motionStats(spec, opts = {}) {
5346
- const scene = buildScene(spec, opts);
5347
6379
  const t = opts.t ?? 5e3;
5348
6380
  const dt = opts.dt ?? 500;
5349
- const { w, h } = scene;
5350
- return scene.layers.map(({ layer, entities }, layerIndex) => {
6381
+ const scene0 = buildScene(resolveTimelineAt(spec, t), opts);
6382
+ const scene1 = spec.timeline ? buildScene(resolveTimelineAt(spec, t + dt), opts) : scene0;
6383
+ const { w, h } = scene0;
6384
+ return scene0.layers.map(({ layer, entities }, layerIndex) => {
6385
+ const entities1 = scene1.layers[layerIndex]?.entities ?? entities;
5351
6386
  let acc = 0;
5352
6387
  let max = 0;
5353
6388
  let n = 0;
5354
- for (const e of entities) {
5355
- const p0 = posOf(scene, e, t);
5356
- const p1 = posOf(scene, e, t + dt);
6389
+ entities.forEach((e, i) => {
6390
+ const p0 = posOf(scene0, e, t);
6391
+ const p1 = posOf(scene1, entities1[i] ?? e, t + dt);
5357
6392
  const dx = p1.x - p0.x;
5358
6393
  const dy = p1.y - p0.y;
5359
- if (Math.abs(dx) > w / 2 || Math.abs(dy) > h / 2) continue;
6394
+ if (Math.abs(dx) > w / 2 || Math.abs(dy) > h / 2) return;
5360
6395
  const speed = Math.sqrt(dx * dx + dy * dy) / dt * 1e3;
5361
6396
  acc += speed;
5362
6397
  max = Math.max(max, speed);
5363
6398
  n++;
5364
- }
6399
+ });
5365
6400
  const meanSpeed = n ? acc / n : 0;
5366
6401
  return { layerIndex, key: layer.key, meanSpeed, maxSpeed: max, moving: meanSpeed > 0.5 };
5367
6402
  });
@@ -5427,6 +6462,7 @@ function diffScenes(a, b, opts = {}) {
5427
6462
  };
5428
6463
  }
5429
6464
  function perceiveScene(spec, opts = {}) {
6465
+ spec = resolveTimelineAt(spec, opts.t ?? 5e3);
5430
6466
  const grid = luminanceGrid(spec, opts);
5431
6467
  return {
5432
6468
  t: opts.t ?? 5e3,
@@ -7922,6 +8958,7 @@ export {
7922
8958
  COMETS_SPEC,
7923
8959
  CONSTELLATION_SPEC,
7924
8960
  DASHBOARD_SPEC,
8961
+ DEFAULT_KEY_DUR,
7925
8962
  DITHER_ALPHA,
7926
8963
  DITHER_TILE,
7927
8964
  EXAMPLE_BY_ID,
@@ -7975,6 +9012,8 @@ export {
7975
9012
  breakTextBlock,
7976
9013
  buildEntities,
7977
9014
  canMorph,
9015
+ canWrapMorph,
9016
+ canonicalSpecPath,
7978
9017
  cohesionOf,
7979
9018
  compileSaver,
7980
9019
  compileSequence,
@@ -8002,6 +9041,7 @@ export {
8002
9041
  latticeHash,
8003
9042
  layerCohesion,
8004
9043
  lerpSpec,
9044
+ lerpValue,
8005
9045
  lifeAlphaAt,
8006
9046
  linkEdges,
8007
9047
  linkPairs,
@@ -8010,16 +9050,19 @@ export {
8010
9050
  morphChainRoot,
8011
9051
  morphNothingMorphable,
8012
9052
  motionStats,
9053
+ nextKeyAfter,
8013
9054
  normalizeSeed,
8014
9055
  perceiveScene,
8015
9056
  perceiveSequenceFrame,
8016
9057
  pickScreenOp,
8017
9058
  positionAt,
8018
9059
  presentWithFinish,
9060
+ readSpecPath,
8019
9061
  renderBrailleMap,
8020
9062
  renderDensityMap,
8021
9063
  resolveSegment,
8022
9064
  resolveSpecPath,
9065
+ resolveTimelineAt,
8023
9066
  revealState,
8024
9067
  rgb255Luma,
8025
9068
  rotationAt,
@@ -8041,7 +9084,13 @@ export {
8041
9084
  textStringsDiffer,
8042
9085
  textStringsOf,
8043
9086
  textWidthEm,
9087
+ timelineLocalTime,
9088
+ timelineSampleTimes,
9089
+ timelineSampleTimesAfter,
9090
+ timelineTracks,
8044
9091
  validateSequence,
8045
- validateSpec
9092
+ validateSpec,
9093
+ validateSpecPaths,
9094
+ withoutTimeline
8046
9095
  };
8047
9096
  //# sourceMappingURL=index.js.map