@idle-screens/schema 3.2.0 → 3.3.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/FORMAT.md CHANGED
@@ -313,8 +313,14 @@ the final segment may omit duration (holds indefinitely).
313
313
  **Advance mode:** `auto` (default), `input`, or `either`. Validated but not
314
314
  wired to runtime behavior — timer/input drivers are planned for a follow-up.
315
315
 
316
- **Transitions:** `{ type: 'cut' }` is the only supported transition. `fade` is
317
- defined as a type but rejected at validation.
316
+ **Transitions:** `{ type: 'cut' }` (default) performs a hard switch.
317
+ `{ type: 'morph', dur: number }` smoothly interpolates paint properties
318
+ (colors, opacity values) over `dur` ms when crossing into the next segment.
319
+ Morph requires structurally identical adjacent segments (same
320
+ `structuralSignature`); if they differ, the engine falls back to cut and the
321
+ validator emits a `morph-structural-mismatch` warning. During a morph, entity
322
+ placement inherits the outgoing segment's seed — the incoming segment's own
323
+ seed is unused. `dur` must be between 200 and 5000 ms.
318
324
 
319
325
  **Time mapping:** global clock `T` maps to `(segmentIndex, localT)` via prefix
320
326
  sums of durations. Half-open segments: `[start, start+duration)`. With
package/dist/index.d.ts CHANGED
@@ -431,9 +431,12 @@ declare const LIMITS: {
431
431
  readonly maxTransitionDur: 5000;
432
432
  readonly minTransitionDur: 200;
433
433
  };
434
- interface SequenceTransition {
434
+ type SequenceTransition = {
435
435
  type: 'cut';
436
- }
436
+ } | {
437
+ type: 'morph';
438
+ dur: number;
439
+ };
437
440
  interface SequenceSegment {
438
441
  key: string;
439
442
  scene: SaverSpec;
@@ -681,6 +684,14 @@ declare function adviseSpec(spec: SaverSpec, viewport?: {
681
684
  width: number;
682
685
  height: number;
683
686
  }): SpecWarning[];
687
+ /**
688
+ * Non-blocking advisory warnings for a validated sequence envelope.
689
+ * Checks cross-segment luminance jumps at boundaries and per-segment advisories.
690
+ */
691
+ declare function adviseSequence(seq: IdleSequence, viewport?: {
692
+ width: number;
693
+ height: number;
694
+ }): SpecWarning[];
684
695
 
685
696
  interface LayerSnapshot {
686
697
  key: string | undefined;
@@ -1075,4 +1086,4 @@ declare const EXAMPLE_SPECS: SaverSpec[];
1075
1086
  /** Lookup by saver id. */
1076
1087
  declare const EXAMPLE_BY_ID: Readonly<Record<string, SaverSpec>>;
1077
1088
 
1078
- export { AQUARIUM_SPEC, AURORA_SPEC, type BackgroundDrift, type BackgroundSpec, type BandSpec, COMETS_SPEC, CONSTELLATION_SPEC, type CycleSpec, DASHBOARD_SPEC, type DominanceEntry, EXAMPLE_BY_ID, EXAMPLE_SPECS, type Entity, type GradientStop, HAIKU_SPEC, type IdleSequence, LANTERNS_SPEC, LIMITS, type LayerMotionStats, type LayerSnapshot, type LayerSpec, type LinkEdge, type LuminanceGrid, type LuminanceGridOptions, MATRIX_RAIN_SPEC, type MotionSpec, NOSTALGHIA_CANDLE_SPEC, ORRERY_SPEC, POLYGONS_SPEC, PROCESSION_SPEC, type PerceiveOptions, type Placed, RAIN_SPEC, type ResolvedSegment, SAKURA_SPEC, SCHEMA_EXAMPLES, SCHEMA_VERSION, SNOWFALL_SPEC, type SaverSpec, type SceneDescription, type SceneDiff, type ScenePerception, type SceneSnapshot, type SchemaExample, type SequenceSegment, type SequenceTransition, type SpecError, type SpecWarning, type SpriteSpec, type SteerDelta, type TextBlockLine, type TextSpriteInfo, type ValidationResult, WARP_NEAR, WARP_TUNNEL_SPEC, type WanderOsc, adviseSpec, alphaAt, applyDeltasToSpec, assertValidSequence, assertValidSpec, breakTextBlock, buildEntities, compileSaver, compileSequence, describeScene, diffScenes, dominanceRanking, easeSmooth, headingAt, lerpSpec, lifeAlphaAt, linkEdges, linkPairs, luminanceGrid, manifestFor, motionStats, perceiveScene, positionAt, renderBrailleMap, renderDensityMap, resolveSegment, resolveSpecPath, rotationAt, sizeAt, spriteIndexAt, spriteVariants, steerablePaths, structuralSignature, textSprites, validateSequence, validateSpec };
1089
+ export { AQUARIUM_SPEC, AURORA_SPEC, type BackgroundDrift, type BackgroundSpec, type BandSpec, COMETS_SPEC, CONSTELLATION_SPEC, type CycleSpec, DASHBOARD_SPEC, type DominanceEntry, EXAMPLE_BY_ID, EXAMPLE_SPECS, type Entity, type GradientStop, HAIKU_SPEC, type IdleSequence, LANTERNS_SPEC, LIMITS, type LayerMotionStats, type LayerSnapshot, type LayerSpec, type LinkEdge, type LuminanceGrid, type LuminanceGridOptions, MATRIX_RAIN_SPEC, type MotionSpec, NOSTALGHIA_CANDLE_SPEC, ORRERY_SPEC, POLYGONS_SPEC, PROCESSION_SPEC, type PerceiveOptions, type Placed, RAIN_SPEC, type ResolvedSegment, SAKURA_SPEC, SCHEMA_EXAMPLES, SCHEMA_VERSION, SNOWFALL_SPEC, type SaverSpec, type SceneDescription, type SceneDiff, type ScenePerception, type SceneSnapshot, type SchemaExample, type SequenceSegment, type SequenceTransition, type SpecError, type SpecWarning, type SpriteSpec, type SteerDelta, type TextBlockLine, type TextSpriteInfo, type ValidationResult, WARP_NEAR, WARP_TUNNEL_SPEC, type WanderOsc, adviseSequence, adviseSpec, alphaAt, applyDeltasToSpec, assertValidSequence, assertValidSpec, breakTextBlock, buildEntities, compileSaver, compileSequence, describeScene, diffScenes, dominanceRanking, easeSmooth, headingAt, lerpSpec, lifeAlphaAt, linkEdges, linkPairs, luminanceGrid, manifestFor, motionStats, perceiveScene, positionAt, renderBrailleMap, renderDensityMap, resolveSegment, resolveSpecPath, rotationAt, sizeAt, spriteIndexAt, spriteVariants, steerablePaths, structuralSignature, textSprites, validateSequence, validateSpec };
package/dist/index.js CHANGED
@@ -54,8 +54,137 @@ var LIMITS = {
54
54
  minTransitionDur: 200
55
55
  };
56
56
 
57
+ // src/steer.ts
58
+ var UNSAFE_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
59
+ function resolveSpecPath(spec, path) {
60
+ if (!spec || typeof spec !== "object" || !path) return null;
61
+ const parts = path.split(".");
62
+ if (parts.some((p) => UNSAFE_KEYS.has(p))) return null;
63
+ const s = spec;
64
+ if (parts[0] !== "layers" && parts[0] !== "background" && Array.isArray(s.layers)) {
65
+ const idx = s.layers.findIndex((l) => l && l.key === parts[0]);
66
+ if (idx === -1) return null;
67
+ parts.splice(0, 1, "layers", String(idx));
68
+ }
69
+ let node = spec;
70
+ for (let i = 0; i < parts.length - 1; i++) {
71
+ if (node === null || typeof node !== "object") return null;
72
+ node = node[parts[i]];
73
+ }
74
+ if (node === null || typeof node !== "object") return null;
75
+ const last = parts[parts.length - 1];
76
+ const key = Array.isArray(node) ? Number(last) : last;
77
+ const exists = Array.isArray(node) ? Number.isInteger(key) && key >= 0 && key < node.length : last in node;
78
+ return exists ? { parent: node, key } : null;
79
+ }
80
+ function applyDeltasToSpec(spec, deltas) {
81
+ const copy = JSON.parse(JSON.stringify(spec));
82
+ for (const d of deltas) {
83
+ const loc = resolveSpecPath(copy, d.path);
84
+ if (loc) loc.parent[loc.key] = d.value;
85
+ }
86
+ return copy;
87
+ }
88
+ var HEX = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
89
+ function hexToRgb(hex) {
90
+ const h = hex.length === 4 ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}` : hex;
91
+ const n = parseInt(h.slice(1), 16);
92
+ return [n >> 16 & 255, n >> 8 & 255, n & 255];
93
+ }
94
+ function lerpHex(a, b, k) {
95
+ const ca = hexToRgb(a);
96
+ const cb = hexToRgb(b);
97
+ const mix = ca.map((v, i) => Math.round(v + (cb[i] - v) * k));
98
+ return `#${mix.map((v) => v.toString(16).padStart(2, "0")).join("")}`;
99
+ }
100
+ function lerpSpec(from, to, k) {
101
+ const kk = Math.max(0, Math.min(1, k));
102
+ const walk = (a, b, key) => {
103
+ if (typeof a === "number" && typeof b === "number") {
104
+ const v = a + (b - a) * kk;
105
+ return key === "count" ? Math.max(1, Math.round(v)) : v;
106
+ }
107
+ if (typeof a === "string" && typeof b === "string" && HEX.test(a) && HEX.test(b)) {
108
+ return lerpHex(a, b, kk);
109
+ }
110
+ if (Array.isArray(a) && Array.isArray(b) && a.length === b.length) {
111
+ return b.map((bv, i) => walk(a[i], bv, i));
112
+ }
113
+ if (a && b && typeof a === "object" && typeof b === "object" && !Array.isArray(a) && !Array.isArray(b)) {
114
+ const out = {};
115
+ for (const kName of Object.keys(b)) {
116
+ out[kName] = walk(a[kName], b[kName], kName);
117
+ }
118
+ return out;
119
+ }
120
+ return kk > 0 ? b : a;
121
+ };
122
+ return walk(from, to);
123
+ }
124
+ function steerablePaths(spec) {
125
+ if (!spec || typeof spec !== "object") return [];
126
+ const SKIP = /* @__PURE__ */ new Set(["kind", "type", "key", "schemaVersion", "id", "label", "seed", "units", "mode", "curve", "layer"]);
127
+ const INDEXED = /* @__PURE__ */ new Set(["layers", "stops"]);
128
+ const out = [];
129
+ const walk = (node, prefix, key) => {
130
+ if (Array.isArray(node) && INDEXED.has(key)) {
131
+ node.forEach((child, i) => walk(child, `${prefix}.${i}`, ""));
132
+ return;
133
+ }
134
+ if (Array.isArray(node)) {
135
+ if (prefix) out.push(prefix);
136
+ return;
137
+ }
138
+ if (node && typeof node === "object") {
139
+ for (const [k, v] of Object.entries(node)) {
140
+ if (SKIP.has(k)) continue;
141
+ walk(v, prefix ? `${prefix}.${k}` : k, k);
142
+ }
143
+ return;
144
+ }
145
+ if (prefix) out.push(prefix);
146
+ };
147
+ walk(spec, "", "");
148
+ return out;
149
+ }
150
+ function easeSmooth(k) {
151
+ const c = Math.max(0, Math.min(1, k));
152
+ return c * c * (3 - 2 * c);
153
+ }
154
+ function structuralSignature(spec) {
155
+ return JSON.stringify([
156
+ spec.units,
157
+ spec.layers.map((l) => {
158
+ const s = l.sprite;
159
+ return [
160
+ l.count,
161
+ l.size,
162
+ l.region,
163
+ l.position,
164
+ l.motion,
165
+ l.wrap,
166
+ l.flip,
167
+ l.alpha,
168
+ l.pulse,
169
+ l.spin,
170
+ l.grow,
171
+ l.layout,
172
+ l.sprite.kind,
173
+ // Dimensional draws baked into entities: radius (circle/ring), length
174
+ // (streak), width+aspect (rect), palette pick (all shaped sprites).
175
+ s.radius,
176
+ s.length,
177
+ l.sprite.kind === "rect" ? [s.width, s.aspect] : void 0,
178
+ Array.isArray(s.colors) ? s.colors.length : void 0,
179
+ s.colorWeights,
180
+ l.sprite.kind === "emoji" ? [l.sprite.glyphs.length, l.sprite.cycle?.period] : l.sprite.kind === "text" ? [l.sprite.strings.length, l.sprite.cycle?.period] : l.sprite.kind === "textBlock" ? [l.sprite.fontSize] : void 0
181
+ ];
182
+ })
183
+ ]);
184
+ }
185
+
57
186
  // src/validate.ts
58
- var HEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
187
+ var HEX2 = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
59
188
  var isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
60
189
  var isStr = (v) => typeof v === "string";
61
190
  var isNum = (v) => typeof v === "number" && Number.isFinite(v);
@@ -152,7 +281,7 @@ function validateSpec(spec) {
152
281
  return { valid: errors.length === 0, errors, warnings };
153
282
  }
154
283
  function color(v, path, err) {
155
- if (!isStr(v) || !HEX.test(v)) err(path, "must be a hex colour like #0a3a52");
284
+ if (!isStr(v) || !HEX2.test(v)) err(path, "must be a hex colour like #0a3a52");
156
285
  }
157
286
  function validateBackground(bg, err, warn) {
158
287
  if (!isObj(bg)) return err("background", "must be an object");
@@ -667,8 +796,12 @@ function validateSequence(seq) {
667
796
  if (s.transition !== void 0) {
668
797
  if (!isObj(s.transition)) {
669
798
  err(`${p}.transition`, "must be an object");
799
+ } else if (s.transition.type === "morph") {
800
+ if (!isNum(s.transition.dur) || s.transition.dur < LIMITS.minTransitionDur || s.transition.dur > LIMITS.maxTransitionDur) {
801
+ err(`${p}.transition.dur`, `must be a number between ${LIMITS.minTransitionDur} and ${LIMITS.maxTransitionDur}`);
802
+ }
670
803
  } else if (s.transition.type !== "cut") {
671
- err(`${p}.transition.type`, "must be 'cut' (fade not yet supported)");
804
+ err(`${p}.transition.type`, "must be 'cut' or 'morph'");
672
805
  }
673
806
  }
674
807
  if (isObj(s.scene)) {
@@ -684,6 +817,23 @@ function validateSequence(seq) {
684
817
  if (seq.loop === true && hasDurationless) {
685
818
  err("loop", "loop: true requires all segments to have a duration");
686
819
  }
820
+ if (errors.length === 0 && Array.isArray(seq.segments)) {
821
+ for (let i = 0; i < seq.segments.length; i++) {
822
+ const s = seq.segments[i];
823
+ if (!isObj(s) || !isObj(s.transition) || s.transition.type !== "morph") continue;
824
+ const next = seq.segments[i + 1];
825
+ if (!next || !isObj(next) || !isObj(s.scene) || !isObj(next.scene)) continue;
826
+ const sigA = structuralSignature(s.scene);
827
+ const sigB = structuralSignature(next.scene);
828
+ if (sigA !== sigB) {
829
+ warnings.push({
830
+ path: `segments[${i}].transition`,
831
+ code: "morph-structural-mismatch",
832
+ message: `segments ${i}\u2192${i + 1} differ structurally: morph will fall back to cut`
833
+ });
834
+ }
835
+ }
836
+ }
687
837
  return { valid: errors.length === 0, errors, warnings };
688
838
  }
689
839
  function assertValidSequence(seq) {
@@ -1213,135 +1363,6 @@ import {
1213
1363
  createRng
1214
1364
  } from "@idle-screens/core";
1215
1365
 
1216
- // src/steer.ts
1217
- var UNSAFE_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
1218
- function resolveSpecPath(spec, path) {
1219
- if (!spec || typeof spec !== "object" || !path) return null;
1220
- const parts = path.split(".");
1221
- if (parts.some((p) => UNSAFE_KEYS.has(p))) return null;
1222
- const s = spec;
1223
- if (parts[0] !== "layers" && parts[0] !== "background" && Array.isArray(s.layers)) {
1224
- const idx = s.layers.findIndex((l) => l && l.key === parts[0]);
1225
- if (idx === -1) return null;
1226
- parts.splice(0, 1, "layers", String(idx));
1227
- }
1228
- let node = spec;
1229
- for (let i = 0; i < parts.length - 1; i++) {
1230
- if (node === null || typeof node !== "object") return null;
1231
- node = node[parts[i]];
1232
- }
1233
- if (node === null || typeof node !== "object") return null;
1234
- const last = parts[parts.length - 1];
1235
- const key = Array.isArray(node) ? Number(last) : last;
1236
- const exists = Array.isArray(node) ? Number.isInteger(key) && key >= 0 && key < node.length : last in node;
1237
- return exists ? { parent: node, key } : null;
1238
- }
1239
- function applyDeltasToSpec(spec, deltas) {
1240
- const copy = JSON.parse(JSON.stringify(spec));
1241
- for (const d of deltas) {
1242
- const loc = resolveSpecPath(copy, d.path);
1243
- if (loc) loc.parent[loc.key] = d.value;
1244
- }
1245
- return copy;
1246
- }
1247
- var HEX2 = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
1248
- function hexToRgb(hex) {
1249
- const h = hex.length === 4 ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}` : hex;
1250
- const n = parseInt(h.slice(1), 16);
1251
- return [n >> 16 & 255, n >> 8 & 255, n & 255];
1252
- }
1253
- function lerpHex(a, b, k) {
1254
- const ca = hexToRgb(a);
1255
- const cb = hexToRgb(b);
1256
- const mix = ca.map((v, i) => Math.round(v + (cb[i] - v) * k));
1257
- return `#${mix.map((v) => v.toString(16).padStart(2, "0")).join("")}`;
1258
- }
1259
- function lerpSpec(from, to, k) {
1260
- const kk = Math.max(0, Math.min(1, k));
1261
- const walk = (a, b, key) => {
1262
- if (typeof a === "number" && typeof b === "number") {
1263
- const v = a + (b - a) * kk;
1264
- return key === "count" ? Math.max(1, Math.round(v)) : v;
1265
- }
1266
- if (typeof a === "string" && typeof b === "string" && HEX2.test(a) && HEX2.test(b)) {
1267
- return lerpHex(a, b, kk);
1268
- }
1269
- if (Array.isArray(a) && Array.isArray(b) && a.length === b.length) {
1270
- return b.map((bv, i) => walk(a[i], bv, i));
1271
- }
1272
- if (a && b && typeof a === "object" && typeof b === "object" && !Array.isArray(a) && !Array.isArray(b)) {
1273
- const out = {};
1274
- for (const kName of Object.keys(b)) {
1275
- out[kName] = walk(a[kName], b[kName], kName);
1276
- }
1277
- return out;
1278
- }
1279
- return kk > 0 ? b : a;
1280
- };
1281
- return walk(from, to);
1282
- }
1283
- function steerablePaths(spec) {
1284
- if (!spec || typeof spec !== "object") return [];
1285
- const SKIP = /* @__PURE__ */ new Set(["kind", "type", "key", "schemaVersion", "id", "label", "seed", "units", "mode", "curve", "layer"]);
1286
- const INDEXED = /* @__PURE__ */ new Set(["layers", "stops"]);
1287
- const out = [];
1288
- const walk = (node, prefix, key) => {
1289
- if (Array.isArray(node) && INDEXED.has(key)) {
1290
- node.forEach((child, i) => walk(child, `${prefix}.${i}`, ""));
1291
- return;
1292
- }
1293
- if (Array.isArray(node)) {
1294
- if (prefix) out.push(prefix);
1295
- return;
1296
- }
1297
- if (node && typeof node === "object") {
1298
- for (const [k, v] of Object.entries(node)) {
1299
- if (SKIP.has(k)) continue;
1300
- walk(v, prefix ? `${prefix}.${k}` : k, k);
1301
- }
1302
- return;
1303
- }
1304
- if (prefix) out.push(prefix);
1305
- };
1306
- walk(spec, "", "");
1307
- return out;
1308
- }
1309
- function easeSmooth(k) {
1310
- const c = Math.max(0, Math.min(1, k));
1311
- return c * c * (3 - 2 * c);
1312
- }
1313
- function structuralSignature(spec) {
1314
- return JSON.stringify([
1315
- spec.units,
1316
- spec.layers.map((l) => {
1317
- const s = l.sprite;
1318
- return [
1319
- l.count,
1320
- l.size,
1321
- l.region,
1322
- l.position,
1323
- l.motion,
1324
- l.wrap,
1325
- l.flip,
1326
- l.alpha,
1327
- l.pulse,
1328
- l.spin,
1329
- l.grow,
1330
- l.layout,
1331
- l.sprite.kind,
1332
- // Dimensional draws baked into entities: radius (circle/ring), length
1333
- // (streak), width+aspect (rect), palette pick (all shaped sprites).
1334
- s.radius,
1335
- s.length,
1336
- l.sprite.kind === "rect" ? [s.width, s.aspect] : void 0,
1337
- Array.isArray(s.colors) ? s.colors.length : void 0,
1338
- s.colorWeights,
1339
- l.sprite.kind === "emoji" ? [l.sprite.glyphs.length, l.sprite.cycle?.period] : l.sprite.kind === "text" ? [l.sprite.strings.length, l.sprite.cycle?.period] : l.sprite.kind === "textBlock" ? [l.sprite.fontSize] : void 0
1340
- ];
1341
- })
1342
- ]);
1343
- }
1344
-
1345
1366
  // src/sequence.ts
1346
1367
  function resolveSegment(seq, T) {
1347
1368
  const { segments, loop } = seq;
@@ -1856,6 +1877,26 @@ var SpecInstance = class {
1856
1877
  this.rebuild();
1857
1878
  if (this.paused) this.renderFrame(0, this.seed);
1858
1879
  }
1880
+ /**
1881
+ * Replace the rendered spec without triggering a transition glide.
1882
+ * Checks structural signature — use `hotSwapPaint` when the caller
1883
+ * already knows the signature is unchanged (e.g. mid-morph lerp).
1884
+ */
1885
+ hotSwapSpec(spec) {
1886
+ this.effSpec = spec;
1887
+ const sig = structuralSignature(this.effSpec);
1888
+ if (sig !== this.lastStructural) this.rebuild();
1889
+ else this.layers.forEach((b, i) => {
1890
+ b.layer = this.effSpec.layers[i] ?? b.layer;
1891
+ });
1892
+ }
1893
+ /** Paint-only hot-swap: skips structuralSignature (caller guarantees match). */
1894
+ hotSwapPaint(spec) {
1895
+ this.effSpec = spec;
1896
+ this.layers.forEach((b, i) => {
1897
+ b.layer = this.effSpec.layers[i] ?? b.layer;
1898
+ });
1899
+ }
1859
1900
  dispose() {
1860
1901
  this.stop();
1861
1902
  if (this.ownsCanvas && typeof HTMLCanvasElement !== "undefined" && this.canvas instanceof HTMLCanvasElement) this.canvas.remove();
@@ -1876,6 +1917,12 @@ var SequenceInstance = class {
1876
1917
  children;
1877
1918
  activeIndex = -1;
1878
1919
  paused = false;
1920
+ /**
1921
+ * When a morph is active, the child at `morphFromIndex` renders with a
1922
+ * lerped spec. The child stays keyed to the *outgoing* segment's slot so
1923
+ * its seed (and entity placement) is continuous.
1924
+ */
1925
+ morphFromIndex = -1;
1879
1926
  constructor(seq, ctx) {
1880
1927
  this.seq = seq;
1881
1928
  let surface = ctx.surface ?? null;
@@ -1891,12 +1938,33 @@ var SequenceInstance = class {
1891
1938
  this.childCtx = { ...ctx, surface, reducedMotion: true };
1892
1939
  this.children = new Array(seq.segments.length).fill(null);
1893
1940
  }
1941
+ childSeed(index, fallback) {
1942
+ return this.seq.segments[index].scene.seed ?? this.seq.seed ?? fallback;
1943
+ }
1894
1944
  childScene(index) {
1895
1945
  const seg = this.seq.segments[index];
1896
1946
  if (seg.scene.seed != null) return seg.scene;
1897
1947
  if (this.seq.seed != null) return { ...seg.scene, seed: this.seq.seed + index };
1898
1948
  return seg.scene;
1899
1949
  }
1950
+ /** Whether the boundary from `from` to `from+1` should morph. */
1951
+ canMorph(from) {
1952
+ const seg = this.seq.segments[from];
1953
+ if (!seg || seg.transition?.type !== "morph") return false;
1954
+ const next = this.seq.segments[from + 1];
1955
+ if (!next) return false;
1956
+ return structuralSignature(seg.scene) === structuralSignature(next.scene);
1957
+ }
1958
+ /** Walk back through consecutive morph boundaries to find the chain origin. */
1959
+ morphChainRoot(index) {
1960
+ let i = index;
1961
+ while (i > 0 && this.canMorph(i - 1)) i--;
1962
+ return i;
1963
+ }
1964
+ morphDur(from) {
1965
+ const tr = this.seq.segments[from]?.transition;
1966
+ return tr?.type === "morph" ? tr.dur : 0;
1967
+ }
1900
1968
  ensureChild(index) {
1901
1969
  if (index < 0 || index >= this.seq.segments.length) index = 0;
1902
1970
  let child = this.children[index];
@@ -1917,15 +1985,51 @@ var SequenceInstance = class {
1917
1985
  renderFrame(T, seed) {
1918
1986
  const resolved = resolveSegment(this.seq, T);
1919
1987
  const { index, localT } = resolved;
1920
- if (index !== this.activeIndex) {
1988
+ const prevIdx = index > 0 ? index - 1 : -1;
1989
+ const morphActive = prevIdx >= 0 && this.canMorph(prevIdx) && localT < this.morphDur(prevIdx);
1990
+ if (morphActive) {
1991
+ const chainRoot = this.morphChainRoot(index);
1992
+ this.morphFromIndex = prevIdx;
1993
+ this.activeIndex = index;
1921
1994
  for (let i = 0; i < this.children.length; i++) {
1922
- if (i !== index) this.releaseChild(i);
1995
+ if (i !== chainRoot) this.releaseChild(i);
1923
1996
  }
1924
- this.activeIndex = index;
1997
+ const child = this.ensureChild(chainRoot);
1998
+ const dur = this.morphDur(prevIdx);
1999
+ const k = easeSmooth(localT / dur);
2000
+ const specA = this.childScene(prevIdx);
2001
+ const specB = this.childScene(index);
2002
+ child.hotSwapPaint(lerpSpec(specA, specB, k));
2003
+ child.renderFrame(localT, this.childSeed(chainRoot, seed));
2004
+ } else {
2005
+ if (this.morphFromIndex >= 0) {
2006
+ const oldRoot = this.morphChainRoot(this.morphFromIndex);
2007
+ this.releaseChild(oldRoot);
2008
+ if (oldRoot !== this.morphFromIndex) this.releaseChild(this.morphFromIndex);
2009
+ this.morphFromIndex = -1;
2010
+ }
2011
+ if (index !== this.activeIndex) {
2012
+ for (let i = 0; i < this.children.length; i++) {
2013
+ if (i !== index) this.releaseChild(i);
2014
+ }
2015
+ this.activeIndex = index;
2016
+ }
2017
+ const chainRoot = this.morphChainRoot(index);
2018
+ const useMorphSeed = chainRoot < index;
2019
+ const effSeed = useMorphSeed ? this.childSeed(chainRoot, seed) : this.childSeed(index, seed);
2020
+ let child;
2021
+ if (useMorphSeed && !this.children[index]) {
2022
+ const rootScene = this.childScene(chainRoot);
2023
+ child = new SpecInstance(rootScene, this.childCtx);
2024
+ this.children[index] = child;
2025
+ if (this.paused) child.setPaused(true);
2026
+ child.hotSwapSpec(this.childScene(index));
2027
+ } else {
2028
+ child = this.ensureChild(index);
2029
+ if (useMorphSeed) child.hotSwapSpec(this.childScene(index));
2030
+ }
2031
+ child.renderFrame(localT, effSeed);
1925
2032
  }
1926
- const child = this.ensureChild(index);
1927
- const childSeed = this.seq.segments[index].scene.seed ?? this.seq.seed ?? seed;
1928
- child.renderFrame(localT, childSeed);
1929
2033
  }
1930
2034
  setPaused(paused) {
1931
2035
  this.paused = paused;
@@ -2231,6 +2335,40 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
2231
2335
  }
2232
2336
  return warnings;
2233
2337
  }
2338
+ function adviseSequence(seq, viewport = { width: 1920, height: 1080 }) {
2339
+ const warnings = [];
2340
+ for (let i = 0; i < seq.segments.length; i++) {
2341
+ const segWarnings = adviseSpec(seq.segments[i].scene, viewport);
2342
+ for (const w of segWarnings) {
2343
+ warnings.push({ path: `segments[${i}].scene.${w.path}`, code: w.code, message: w.message });
2344
+ }
2345
+ }
2346
+ for (let i = 0; i < seq.segments.length - 1; i++) {
2347
+ const lumaA = backgroundLuma(seq.segments[i].scene);
2348
+ const lumaB = backgroundLuma(seq.segments[i + 1].scene);
2349
+ const delta = Math.abs(lumaA - lumaB);
2350
+ if (delta > 0.5) {
2351
+ warnings.push({
2352
+ path: `segments[${i}]`,
2353
+ code: "boundary-luminance-jump",
2354
+ message: `background luminance jumps ${delta.toFixed(2)} at boundary ${i}\u2192${i + 1} \u2014 may flash on cut`
2355
+ });
2356
+ }
2357
+ const tr = seq.segments[i].transition;
2358
+ if (tr?.type === "morph") {
2359
+ const sigA = structuralSignature(seq.segments[i].scene);
2360
+ const sigB = structuralSignature(seq.segments[i + 1].scene);
2361
+ if (sigA !== sigB) {
2362
+ warnings.push({
2363
+ path: `segments[${i}].transition`,
2364
+ code: "morph-structural-mismatch",
2365
+ message: `segments ${i}\u2192${i + 1} differ structurally: morph will fall back to cut`
2366
+ });
2367
+ }
2368
+ }
2369
+ }
2370
+ return warnings;
2371
+ }
2234
2372
 
2235
2373
  // src/describe.ts
2236
2374
  import { createRng as createRng3 } from "@idle-screens/core";
@@ -4351,6 +4489,7 @@ export {
4351
4489
  SNOWFALL_SPEC,
4352
4490
  WARP_NEAR,
4353
4491
  WARP_TUNNEL_SPEC,
4492
+ adviseSequence,
4354
4493
  adviseSpec,
4355
4494
  alphaAt,
4356
4495
  applyDeltasToSpec,