@idle-screens/schema 3.7.0 → 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -12,6 +12,8 @@ var LIMITS = {
12
12
  // ms — caps pulse at 2 Hz (WCAG flash threshold is 3 Hz)
13
13
  maxSpin: 360,
14
14
  // degrees/sec — one full revolution per second
15
+ maxRotate: 360,
16
+ // degrees — static per-entity rotation, one turn either way
15
17
  maxGrowAmp: 0.8,
16
18
  // size breathing amplitude cap (fraction of base size)
17
19
  maxOrbitSpeed: 180,
@@ -27,9 +29,19 @@ var LIMITS = {
27
29
  maxTrailSamples: 24,
28
30
  // dots per trail
29
31
  minDriftPeriod: 1e4,
30
- // ms — background drift floor (10 s)
32
+ // ms — background drift floor (10 s), gradient and field alike
31
33
  maxDriftAmount: 0.3,
32
34
  // fraction of gradient stop shift
35
+ minFieldScale: 0.5,
36
+ // features per short side — below this the field is one flat blob
37
+ maxFieldScale: 8,
38
+ // above this a 96 px raster cell spans more than one feature
39
+ maxFieldOctaves: 4,
40
+ minFieldBands: 2,
41
+ maxFieldBands: 8,
42
+ maxFieldQuantize: 8,
43
+ maxFieldDriftAmount: 1,
44
+ // feature units of domain travel
33
45
  maxGhosting: 0.95,
34
46
  // frame persistence cap — bounds the seek warm-up replay
35
47
  maxGhostReplayFrames: 120,
@@ -84,16 +96,19 @@ var LIMITS = {
84
96
 
85
97
  // src/steer.ts
86
98
  var UNSAFE_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
87
- var STEERABLE_ROOT_KEYS = /* @__PURE__ */ new Set(["ghosting", "referenceViewport"]);
99
+ var STEERABLE_ROOT_KEYS = /* @__PURE__ */ new Set(["ghosting", "referenceViewport", "finish"]);
88
100
  function resolveSpecPath(spec, path) {
89
101
  if (!spec || typeof spec !== "object" || !path) return null;
90
102
  const parts = path.split(".");
91
103
  if (parts.some((p) => UNSAFE_KEYS.has(p))) return null;
92
104
  const s = spec;
93
- if (parts[0] !== "layers" && parts[0] !== "background" && !STEERABLE_ROOT_KEYS.has(parts[0]) && Array.isArray(s.layers)) {
105
+ if (parts[0] !== "layers" && parts[0] !== "background" && Array.isArray(s.layers)) {
94
106
  const idx = s.layers.findIndex((l) => l && l.key === parts[0]);
95
- if (idx === -1) return null;
96
- parts.splice(0, 1, "layers", String(idx));
107
+ if (idx !== -1) {
108
+ parts.splice(0, 1, "layers", String(idx));
109
+ } else if (!STEERABLE_ROOT_KEYS.has(parts[0])) {
110
+ return null;
111
+ }
97
112
  }
98
113
  let node = spec;
99
114
  for (let i = 0; i < parts.length - 1; i++) {
@@ -205,7 +220,11 @@ function morphNothingMorphable(a, b, opts = {}) {
205
220
  function steerablePaths(spec) {
206
221
  if (!spec || typeof spec !== "object") return [];
207
222
  const SKIP = /* @__PURE__ */ new Set(["kind", "type", "key", "schemaVersion", "id", "label", "seed", "units", "motionIntensity", "mode", "curve", "layer"]);
208
- const INDEXED = /* @__PURE__ */ new Set(["layers", "stops"]);
223
+ const INDEXED = /* @__PURE__ */ new Set(["layers", "stops", "bands"]);
224
+ const layers = spec.layers;
225
+ const shadowedRootKeys = new Set(
226
+ Array.isArray(layers) ? layers.map((l) => l && typeof l === "object" ? l.key : void 0).filter((k) => typeof k === "string" && STEERABLE_ROOT_KEYS.has(k)) : []
227
+ );
209
228
  const out = [];
210
229
  const walk = (node, prefix, key) => {
211
230
  if (Array.isArray(node) && INDEXED.has(key)) {
@@ -219,6 +238,7 @@ function steerablePaths(spec) {
219
238
  if (node && typeof node === "object") {
220
239
  for (const [k, v] of Object.entries(node)) {
221
240
  if (SKIP.has(k)) continue;
241
+ if (!prefix && shadowedRootKeys.has(k)) continue;
222
242
  walk(v, prefix ? `${prefix}.${k}` : k, k);
223
243
  }
224
244
  return;
@@ -241,7 +261,10 @@ function easeSmooth(k) {
241
261
  function structuralSignature(spec) {
242
262
  return JSON.stringify([
243
263
  spec.units,
244
- spec.referenceViewport,
264
+ // Normalized against the same default every renderer uses — an omitted
265
+ // referenceViewport and an explicit 1080 render identically, so they must
266
+ // hash identically or a same-sizing morph gets rejected as structural.
267
+ spec.referenceViewport ?? LIMITS.referenceViewport,
245
268
  spec.layers.map((l) => {
246
269
  const s = l.sprite;
247
270
  return [
@@ -267,7 +290,10 @@ function structuralSignature(spec) {
267
290
  l.sprite.kind === "rect" ? [s.width, s.aspect] : l.sprite.kind === "bar" ? [s.length, s.thickness] : void 0,
268
291
  Array.isArray(s.colors) ? s.colors.length : void 0,
269
292
  s.colorWeights,
270
- l.sprite.kind === "emoji" ? [l.sprite.glyphs.length, l.sprite.cycle?.period] : l.sprite.kind === "text" ? [l.sprite.strings.length, l.sprite.cycle?.period] : l.sprite.kind === "textBlock" ? l.sprite.anchor ? [l.sprite.fontSize, l.sprite.anchor] : [l.sprite.fontSize] : void 0
293
+ l.sprite.kind === "emoji" ? [l.sprite.glyphs.length, l.sprite.cycle?.period] : l.sprite.kind === "text" ? [l.sprite.strings.length, l.sprite.cycle?.period] : l.sprite.kind === "textBlock" ? l.sprite.anchor ? [l.sprite.fontSize, l.sprite.anchor] : [l.sprite.fontSize] : void 0,
294
+ // Static rotation is baked into entities (a range draws a seeded
295
+ // angle). Appended only when set — same rule as `anchor` above.
296
+ ...l.rotate !== void 0 ? [l.rotate] : []
271
297
  ];
272
298
  })
273
299
  ]);
@@ -279,7 +305,8 @@ var isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
279
305
  var isStr = (v) => typeof v === "string";
280
306
  var isNum = (v) => typeof v === "number" && Number.isFinite(v);
281
307
  var isRange = (v) => Array.isArray(v) && v.length === 2 && isNum(v[0]) && isNum(v[1]) && v[0] <= v[1];
282
- var KNOWN_TOP = /* @__PURE__ */ new Set(["schemaVersion", "id", "label", "seed", "motionIntensity", "density", "units", "referenceViewport", "background", "layers", "ghosting"]);
308
+ var KNOWN_TOP = /* @__PURE__ */ new Set(["schemaVersion", "id", "label", "seed", "motionIntensity", "density", "units", "referenceViewport", "background", "layers", "ghosting", "finish"]);
309
+ var KNOWN_FINISH = /* @__PURE__ */ new Set(["grain", "dither", "animate"]);
283
310
  var KNOWN_LAYER = /* @__PURE__ */ new Set([
284
311
  "count",
285
312
  "sprite",
@@ -300,7 +327,8 @@ var KNOWN_LAYER = /* @__PURE__ */ new Set([
300
327
  "layout",
301
328
  "life",
302
329
  "emit",
303
- "clock"
330
+ "clock",
331
+ "rotate"
304
332
  ]);
305
333
  var KNOWN_CIRCLE = /* @__PURE__ */ new Set(["kind", "radius", "color", "soft", "colors", "colorWeights"]);
306
334
  var KNOWN_RING = /* @__PURE__ */ new Set(["kind", "radius", "color", "width", "colors", "colorWeights"]);
@@ -327,7 +355,9 @@ var KNOWN_WARP = /* @__PURE__ */ new Set(["type", "speed", "center"]);
327
355
  var KNOWN_PATH = /* @__PURE__ */ new Set(["type", "points", "duration", "curve", "closed", "scatter"]);
328
356
  var KNOWN_BG_SOLID = /* @__PURE__ */ new Set(["type", "color"]);
329
357
  var KNOWN_BG_GRADIENT = /* @__PURE__ */ new Set(["type", "stops", "band", "drift"]);
330
- var LAYER_PROPS_ON_SPRITE = /* @__PURE__ */ new Set(["blend", "trail", "alpha", "pulse", "spin", "grow", "region", "links", "flip", "wrap", "key", "emit", "clock", "life", "layout"]);
358
+ var KNOWN_BG_FIELD = /* @__PURE__ */ new Set(["type", "scale", "octaves", "warp", "quantize", "bands", "drift", "seed"]);
359
+ var KNOWN_FIELD_DRIFT = /* @__PURE__ */ new Set(["period", "amount"]);
360
+ var LAYER_PROPS_ON_SPRITE = /* @__PURE__ */ new Set(["blend", "trail", "alpha", "pulse", "spin", "grow", "region", "links", "flip", "wrap", "key", "emit", "clock", "life", "layout", "rotate"]);
331
361
  function unknownKeys(obj, known) {
332
362
  return Object.keys(obj).filter((k) => !known.has(k));
333
363
  }
@@ -383,6 +413,7 @@ function validateSpec(spec) {
383
413
  warn(k, "unknown-property", `unknown top-level property '${k}' \u2014 will be ignored`);
384
414
  }
385
415
  if (spec.background !== void 0) validateBackground(spec.background, err, warn);
416
+ if (spec.finish !== void 0) validateFinish(spec.finish, "finish", err, warn);
386
417
  if (!Array.isArray(spec.layers) || spec.layers.length === 0) {
387
418
  err("layers", "must be a non-empty array");
388
419
  } else {
@@ -399,6 +430,16 @@ function validateSpec(spec) {
399
430
  function color(v, path, err) {
400
431
  if (!isStr(v) || !HEX2.test(v)) err(path, "must be a hex colour like #0a3a52");
401
432
  }
433
+ function validateFinish(f, path, err, warn) {
434
+ if (!isObj(f)) return err(path, "must be an object");
435
+ for (const k of ["grain", "dither"]) {
436
+ if (f[k] !== void 0 && (!isNum(f[k]) || f[k] < 0 || f[k] > 1)) err(`${path}.${k}`, "must be a number 0..1");
437
+ }
438
+ if (f.animate !== void 0 && typeof f.animate !== "boolean") err(`${path}.animate`, "must be a boolean");
439
+ for (const k of unknownKeys(f, KNOWN_FINISH)) {
440
+ warn(`${path}.${k}`, "unknown-property", `unknown finish property '${k}' \u2014 will be ignored`);
441
+ }
442
+ }
402
443
  function validateBackground(bg, err, warn) {
403
444
  if (!isObj(bg)) return err("background", "must be an object");
404
445
  if (bg.type === "solid") {
@@ -436,8 +477,47 @@ function validateBackground(bg, err, warn) {
436
477
  for (const k of unknownKeys(bg, KNOWN_BG_GRADIENT)) {
437
478
  warn(`background.${k}`, "unknown-property", `unknown background property '${k}' \u2014 will be ignored`);
438
479
  }
480
+ } else if (bg.type === "field") {
481
+ validateFieldBackground(bg, err, warn);
439
482
  } else {
440
- err("background.type", "must be solid | gradient");
483
+ err("background.type", "must be solid | gradient | field");
484
+ }
485
+ }
486
+ function validateFieldBackground(bg, err, warn) {
487
+ if (!isNum(bg.scale) || bg.scale < LIMITS.minFieldScale || bg.scale > LIMITS.maxFieldScale) {
488
+ err("background.scale", `must be a number ${LIMITS.minFieldScale}..${LIMITS.maxFieldScale} (features across the short side)`);
489
+ }
490
+ if (bg.octaves !== void 0 && (!isNum(bg.octaves) || !Number.isInteger(bg.octaves) || bg.octaves < 1 || bg.octaves > LIMITS.maxFieldOctaves)) {
491
+ err("background.octaves", `must be an integer 1..${LIMITS.maxFieldOctaves}`);
492
+ }
493
+ if (bg.warp !== void 0 && (!isNum(bg.warp) || bg.warp < 0 || bg.warp > 1)) {
494
+ err("background.warp", "must be a number 0..1");
495
+ }
496
+ if (bg.quantize !== void 0 && (!isNum(bg.quantize) || !Number.isInteger(bg.quantize) || bg.quantize < 0 || bg.quantize === 1 || bg.quantize > LIMITS.maxFieldQuantize)) {
497
+ err("background.quantize", `must be 0 (smooth) or an integer 2..${LIMITS.maxFieldQuantize}`);
498
+ }
499
+ if (!Array.isArray(bg.bands) || bg.bands.length < LIMITS.minFieldBands || bg.bands.length > LIMITS.maxFieldBands) {
500
+ err("background.bands", `must be ${LIMITS.minFieldBands}..${LIMITS.maxFieldBands} hex colours`);
501
+ } else {
502
+ for (let i = 0; i < bg.bands.length; i++) color(bg.bands[i], `background.bands[${i}]`, err);
503
+ }
504
+ if (bg.drift !== void 0) {
505
+ if (!isObj(bg.drift)) err("background.drift", "must be an object");
506
+ else {
507
+ if (!isNum(bg.drift.period) || bg.drift.period < LIMITS.minDriftPeriod) {
508
+ err("background.drift.period", `must be >= ${LIMITS.minDriftPeriod} ms`);
509
+ }
510
+ if (bg.drift.amount !== void 0 && (!isNum(bg.drift.amount) || bg.drift.amount < 0 || bg.drift.amount > LIMITS.maxFieldDriftAmount)) {
511
+ err("background.drift.amount", `must be a number 0..${LIMITS.maxFieldDriftAmount}`);
512
+ }
513
+ for (const k of unknownKeys(bg.drift, KNOWN_FIELD_DRIFT)) {
514
+ warn(`background.drift.${k}`, "unknown-property", `unknown drift property '${k}' \u2014 will be ignored`);
515
+ }
516
+ }
517
+ }
518
+ if (bg.seed !== void 0 && !isNum(bg.seed)) err("background.seed", "must be a number");
519
+ for (const k of unknownKeys(bg, KNOWN_BG_FIELD)) {
520
+ warn(`background.${k}`, "unknown-property", `unknown background property '${k}' \u2014 will be ignored`);
441
521
  }
442
522
  }
443
523
  function validateLayer(layer, path, err, warn, spec) {
@@ -526,6 +606,17 @@ function validateLayer(layer, path, err, warn, spec) {
526
606
  err(`${path}.spin`, `must be within \xB1${LIMITS.maxSpin} deg/sec`);
527
607
  }
528
608
  }
609
+ if (layer.rotate !== void 0) {
610
+ if (isRange(layer.rotate)) {
611
+ if (Math.abs(layer.rotate[0]) > LIMITS.maxRotate || Math.abs(layer.rotate[1]) > LIMITS.maxRotate) {
612
+ err(`${path}.rotate`, `each end of the range must be within \xB1${LIMITS.maxRotate} degrees`);
613
+ }
614
+ } else if (!isNum(layer.rotate)) {
615
+ err(`${path}.rotate`, "must be a number or a [min,max] range (degrees)");
616
+ } else if (Math.abs(layer.rotate) > LIMITS.maxRotate) {
617
+ err(`${path}.rotate`, `must be within \xB1${LIMITS.maxRotate} degrees`);
618
+ }
619
+ }
529
620
  if (layer.grow !== void 0) {
530
621
  if (!isObj(layer.grow)) err(`${path}.grow`, "must be an object");
531
622
  else {
@@ -1092,6 +1183,7 @@ function validateSequence(seq) {
1092
1183
  if (seq.seed !== void 0 && !isNum(seq.seed)) err("seed", "must be a number");
1093
1184
  if (typeof seq.loop !== "boolean") err("loop", "must be a boolean");
1094
1185
  if (seq.sync !== void 0 && seq.sync !== "mount" && seq.sync !== "epoch") err("sync", "must be 'mount' | 'epoch'");
1186
+ if (seq.finish !== void 0) validateFinish(seq.finish, "finish", err, (p, code, message) => void warnings.push({ path: p, code, message }));
1095
1187
  if (!Array.isArray(seq.segments) || seq.segments.length === 0) {
1096
1188
  err("segments", "must be a non-empty array");
1097
1189
  return { valid: false, errors, warnings };
@@ -1596,7 +1688,10 @@ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
1596
1688
  } : {},
1597
1689
  // Time-structure fields (2026-09): ease draws nothing; emit's one draw
1598
1690
  // per entity happens in a pass AFTER this loop (see below).
1599
- ...ease ? { ease: { type: ease.type, tau: ease.tau } } : {}
1691
+ ...ease ? { ease: { type: ease.type, tau: ease.tau } } : {},
1692
+ // Static rotation (2026-09-10): a scalar draws nothing; the range form
1693
+ // draws one value here, last, only when declared.
1694
+ ...typeof layer.rotate === "number" ? { rotate: layer.rotate * Math.PI / 180 } : Array.isArray(layer.rotate) ? { rotate: rng.range(layer.rotate[0], layer.rotate[1]) * Math.PI / 180 } : {}
1600
1695
  });
1601
1696
  if (layer.clock) {
1602
1697
  const ent = out[out.length - 1];
@@ -1670,8 +1765,9 @@ function sizeAt(e, t) {
1670
1765
  return s > 0 ? s : 0.1;
1671
1766
  }
1672
1767
  function rotationAt(e, t) {
1673
- if (!e.spinSpeed) return 0;
1674
- return e.spinPhase + e.spinSpeed * (t / 1e3);
1768
+ const base = e.rotate ?? 0;
1769
+ if (!e.spinSpeed) return base;
1770
+ return base + e.spinPhase + e.spinSpeed * (t / 1e3);
1675
1771
  }
1676
1772
  function lifeAlphaAt(life, t) {
1677
1773
  if (!life) return 1;
@@ -2061,6 +2157,254 @@ import {
2061
2157
  createRng
2062
2158
  } from "@idle-screens/core";
2063
2159
 
2160
+ // src/field.ts
2161
+ var TWO_PI = Math.PI * 2;
2162
+ var FIELD_BUCKET_MS = 100;
2163
+ var FIELD_RASTER_SHORT_SIDE = 96;
2164
+ var FIELD_RASTER_SHORT_SIDE_LOW = 48;
2165
+ var WARP_GAIN = 2;
2166
+ var DRIFT_GAIN = 1;
2167
+ var FIELD_DRIFT_DEFAULT_AMOUNT = 0.3;
2168
+ var CONTRAST = [1, 1.45, 1.8, 1.95, 2];
2169
+ var clamp01 = (v) => v < 0 ? 0 : v > 1 ? 1 : v;
2170
+ function latticeHash(ix, iy, seed) {
2171
+ let h = Math.imul(ix | 0, 668265261) ^ Math.imul(iy | 0, 374761393) ^ Math.imul(seed | 0, 2654435761);
2172
+ h = Math.imul(h ^ h >>> 15, 2246822507);
2173
+ h = Math.imul(h ^ h >>> 13, 3266489909);
2174
+ h ^= h >>> 16;
2175
+ return (h >>> 0) / 4294967296;
2176
+ }
2177
+ var smoothstep2 = (k) => k * k * (3 - 2 * k);
2178
+ function valueNoise(x, y, seed) {
2179
+ const x0 = Math.floor(x);
2180
+ const y0 = Math.floor(y);
2181
+ const fx = smoothstep2(x - x0);
2182
+ const fy = smoothstep2(y - y0);
2183
+ const a = latticeHash(x0, y0, seed);
2184
+ const b = latticeHash(x0 + 1, y0, seed);
2185
+ const c = latticeHash(x0, y0 + 1, seed);
2186
+ const d = latticeHash(x0 + 1, y0 + 1, seed);
2187
+ const top = a + (b - a) * fx;
2188
+ const bottom = c + (d - c) * fx;
2189
+ return top + (bottom - top) * fy;
2190
+ }
2191
+ function fbm(x, y, seed, octaves) {
2192
+ let sum = 0;
2193
+ let amp = 1;
2194
+ let norm = 0;
2195
+ let f = 1;
2196
+ for (let i = 0; i < octaves; i++) {
2197
+ sum += amp * valueNoise(x * f + i * 17.3, y * f + i * 31.7, seed + i * 101);
2198
+ norm += amp;
2199
+ amp *= 0.5;
2200
+ f *= 2;
2201
+ }
2202
+ return sum / norm;
2203
+ }
2204
+ function fieldOctaves(cfg) {
2205
+ const o = Math.round(cfg.octaves ?? 2);
2206
+ return o < 1 ? 1 : o > 4 ? 4 : o;
2207
+ }
2208
+ function fieldSampleTime(cfg, t) {
2209
+ return cfg.drift && cfg.drift.amount !== 0 ? Math.floor(t / FIELD_BUCKET_MS) * FIELD_BUCKET_MS : 0;
2210
+ }
2211
+ function fieldAt(u, v, t, cfg, seed) {
2212
+ const scale = cfg.scale;
2213
+ const octaves = fieldOctaves(cfg);
2214
+ const s = (cfg.seed ?? seed) >>> 0;
2215
+ let x = u * scale;
2216
+ let y = v * scale;
2217
+ const drift = cfg.drift;
2218
+ if (drift) {
2219
+ const phase = TWO_PI * t / drift.period;
2220
+ const r = (drift.amount ?? FIELD_DRIFT_DEFAULT_AMOUNT) * DRIFT_GAIN;
2221
+ x += r * Math.sin(phase);
2222
+ y += r * Math.cos(phase);
2223
+ }
2224
+ const warp = cfg.warp ?? 0;
2225
+ if (warp > 0) {
2226
+ const qx = fbm(x + 5.2, y + 1.3, s + 7919, octaves) - 0.5;
2227
+ const qy = fbm(x + 1.7, y + 9.2, s + 104729, octaves) - 0.5;
2228
+ x += warp * WARP_GAIN * qx;
2229
+ y += warp * WARP_GAIN * qy;
2230
+ }
2231
+ const raw = fbm(x, y, s, octaves);
2232
+ return clamp01(0.5 + (raw - 0.5) * CONTRAST[octaves]);
2233
+ }
2234
+ function hexToRgb255(hex) {
2235
+ const h = hex.length === 4 ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}` : hex;
2236
+ const n = parseInt(h.slice(1), 16);
2237
+ if (Number.isNaN(n)) return [128, 128, 128];
2238
+ return [n >> 16 & 255, n >> 8 & 255, n & 255];
2239
+ }
2240
+ function rgb255Luma(rgb) {
2241
+ return (0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]) / 255;
2242
+ }
2243
+ function fieldRgb(value, bands, quantize) {
2244
+ const n = bands.length;
2245
+ const q = Math.round(quantize ?? n);
2246
+ let f;
2247
+ if (q >= 2) {
2248
+ const level = Math.min(q - 1, Math.floor(clamp01(value) * q));
2249
+ f = level * (n - 1) / (q - 1);
2250
+ } else {
2251
+ f = clamp01(value) * (n - 1);
2252
+ }
2253
+ const i = Math.min(n - 2, Math.floor(f));
2254
+ const k = f - i;
2255
+ const a = hexToRgb255(bands[i]);
2256
+ if (k === 0) return a;
2257
+ const b = hexToRgb255(bands[i + 1]);
2258
+ return [
2259
+ Math.round(a[0] + (b[0] - a[0]) * k),
2260
+ Math.round(a[1] + (b[1] - a[1]) * k),
2261
+ Math.round(a[2] + (b[2] - a[2]) * k)
2262
+ ];
2263
+ }
2264
+ function fieldRgbAt(u, v, t, cfg, seed) {
2265
+ return fieldRgb(fieldAt(u, v, t, cfg, seed), cfg.bands, cfg.quantize);
2266
+ }
2267
+
2268
+ // src/finish.ts
2269
+ var GRAIN_TILE = 256;
2270
+ var DITHER_TILE = 8;
2271
+ var GRAIN_ALPHA = 0.35;
2272
+ var DITHER_ALPHA = 0.25;
2273
+ var FINISH_ANIMATE_HZ = 12;
2274
+ var clamp012 = (v) => v < 0 ? 0 : v > 1 ? 1 : v;
2275
+ function finishStrength(f) {
2276
+ return { grain: clamp012(f?.grain ?? 0), dither: clamp012(f?.dither ?? 0) };
2277
+ }
2278
+ function grainTilePixels(seed) {
2279
+ const px = new Uint8ClampedArray(GRAIN_TILE * GRAIN_TILE * 4);
2280
+ const s = seed >>> 0;
2281
+ let i = 0;
2282
+ for (let y = 0; y < GRAIN_TILE; y++) {
2283
+ for (let x = 0; x < GRAIN_TILE; x++) {
2284
+ const v = (latticeHash(x, y, s) + latticeHash(x, y, s + 1540483477)) / 2;
2285
+ const g = Math.round(128 + (v - 0.5) * 255);
2286
+ px[i++] = g;
2287
+ px[i++] = g;
2288
+ px[i++] = g;
2289
+ px[i++] = 255;
2290
+ }
2291
+ }
2292
+ return px;
2293
+ }
2294
+ function bayerMatrix() {
2295
+ let m = [[0]];
2296
+ for (let n = 1; n < DITHER_TILE; n *= 2) {
2297
+ const next = [];
2298
+ for (let y = 0; y < n * 2; y++) {
2299
+ const row = [];
2300
+ for (let x = 0; x < n * 2; x++) {
2301
+ const base = m[y % n][x % n] * 4;
2302
+ const quadrant = (y >= n ? 2 : 0) + (x >= n ? 1 : 0);
2303
+ row.push(base + [0, 2, 3, 1][quadrant]);
2304
+ }
2305
+ next.push(row);
2306
+ }
2307
+ m = next;
2308
+ }
2309
+ return m;
2310
+ }
2311
+ function bayerTilePixels() {
2312
+ const m = bayerMatrix();
2313
+ const px = new Uint8ClampedArray(DITHER_TILE * DITHER_TILE * 4);
2314
+ let i = 0;
2315
+ for (let y = 0; y < DITHER_TILE; y++) {
2316
+ for (let x = 0; x < DITHER_TILE; x++) {
2317
+ const g = Math.round((m[y][x] + 0.5) / (DITHER_TILE * DITHER_TILE) * 255);
2318
+ px[i++] = g;
2319
+ px[i++] = g;
2320
+ px[i++] = g;
2321
+ px[i++] = 255;
2322
+ }
2323
+ }
2324
+ return px;
2325
+ }
2326
+ function grainOffset(t, seed, animate) {
2327
+ if (!animate) return { x: 0, y: 0 };
2328
+ const bucket = Math.floor(t * FINISH_ANIMATE_HZ / 1e3);
2329
+ const s = seed >>> 0;
2330
+ return {
2331
+ x: Math.floor(latticeHash(bucket, 1, s) * GRAIN_TILE),
2332
+ y: Math.floor(latticeHash(bucket, 2, s) * GRAIN_TILE)
2333
+ };
2334
+ }
2335
+ function pickScreenOp(ctx) {
2336
+ const prev = ctx.globalCompositeOperation;
2337
+ let op = "multiply";
2338
+ for (const candidate of ["overlay", "soft-light"]) {
2339
+ ctx.globalCompositeOperation = candidate;
2340
+ if (ctx.globalCompositeOperation === candidate) {
2341
+ op = candidate;
2342
+ break;
2343
+ }
2344
+ }
2345
+ ctx.globalCompositeOperation = prev;
2346
+ return op;
2347
+ }
2348
+ function tileCanvas(size, pixels) {
2349
+ const canvas = typeof document !== "undefined" ? document.createElement("canvas") : new OffscreenCanvas(size, size);
2350
+ canvas.width = size;
2351
+ canvas.height = size;
2352
+ const ctx = canvas.getContext("2d");
2353
+ if (!ctx || typeof ctx.createImageData !== "function") return null;
2354
+ const img = ctx.createImageData(size, size);
2355
+ img.data.set(pixels);
2356
+ ctx.putImageData(img, 0, 0);
2357
+ return canvas;
2358
+ }
2359
+ function createFinishPass(visible, seed) {
2360
+ const ctx = visible.getContext("2d", { alpha: false });
2361
+ if (!ctx) return null;
2362
+ return { canvas: visible, ctx, op: pickScreenOp(ctx), grain: null, dither: null, seed };
2363
+ }
2364
+ function createSceneCanvas(width, height) {
2365
+ if (typeof document === "undefined") return new OffscreenCanvas(Math.max(1, width), Math.max(1, height));
2366
+ const canvas = document.createElement("canvas");
2367
+ canvas.width = Math.max(1, width);
2368
+ canvas.height = Math.max(1, height);
2369
+ return canvas;
2370
+ }
2371
+ function pattern(fp, size, pixels) {
2372
+ const tile = tileCanvas(size, pixels);
2373
+ if (!tile || typeof fp.ctx.createPattern !== "function") return null;
2374
+ return fp.ctx.createPattern(tile, "repeat");
2375
+ }
2376
+ function presentWithFinish(fp, scene, w, h, t, finish, animateAllowed, seed = fp.seed) {
2377
+ const p = fp.ctx;
2378
+ p.globalAlpha = 1;
2379
+ p.globalCompositeOperation = "source-over";
2380
+ p.drawImage(scene, 0, 0, w, h);
2381
+ const normalizedSeed = seed >>> 0 || 1;
2382
+ if (normalizedSeed !== fp.seed) {
2383
+ fp.seed = normalizedSeed;
2384
+ fp.grain = null;
2385
+ }
2386
+ const { grain, dither } = finishStrength(finish);
2387
+ if (grain > 0) {
2388
+ fp.grain ??= pattern(fp, GRAIN_TILE, grainTilePixels(fp.seed));
2389
+ if (fp.grain) screen(fp, fp.grain, grain * GRAIN_ALPHA, grainOffset(t, fp.seed, animateAllowed && finish?.animate === true), w, h);
2390
+ }
2391
+ if (dither > 0) {
2392
+ fp.dither ??= pattern(fp, DITHER_TILE, bayerTilePixels());
2393
+ if (fp.dither) screen(fp, fp.dither, dither * DITHER_ALPHA, { x: 0, y: 0 }, w, h);
2394
+ }
2395
+ }
2396
+ function screen(fp, pat, alpha, off, w, h) {
2397
+ const p = fp.ctx;
2398
+ p.save();
2399
+ p.globalCompositeOperation = fp.op;
2400
+ p.globalAlpha = alpha;
2401
+ p.imageSmoothingEnabled = false;
2402
+ p.fillStyle = pat;
2403
+ p.translate(-off.x, -off.y);
2404
+ p.fillRect(off.x, off.y, w, h);
2405
+ p.restore();
2406
+ }
2407
+
2064
2408
  // src/sequence.ts
2065
2409
  function segmentStart(seq, index) {
2066
2410
  let t = 0;
@@ -2131,6 +2475,31 @@ function bedRenderSeed(seq) {
2131
2475
  const raw = seq.bed.seed ?? (seq.seed !== void 0 ? seq.seed + LIMITS.maxSegments : void 0);
2132
2476
  return raw === void 0 ? void 0 : normalizeSeed(raw);
2133
2477
  }
2478
+ function sequenceSwapCompatible(prev, next) {
2479
+ if (prev.segments.length !== next.segments.length) return false;
2480
+ if (prev.loop !== next.loop) return false;
2481
+ if ((prev.sync ?? "mount") !== (next.sync ?? "mount")) return false;
2482
+ if (!!prev.bed !== !!next.bed) return false;
2483
+ if (prev.bed && next.bed) {
2484
+ if (structuralSignature(prev.bed) !== structuralSignature(next.bed)) return false;
2485
+ if (bedRenderSeed(prev) !== bedRenderSeed(next)) return false;
2486
+ }
2487
+ for (let i = 0; i < prev.segments.length; i++) {
2488
+ const a = prev.segments[i];
2489
+ const b = next.segments[i];
2490
+ if (a.duration !== b.duration) return false;
2491
+ if ((a.advance ?? "auto") !== (b.advance ?? "auto")) return false;
2492
+ if (transitionKey(a.transition) !== transitionKey(b.transition)) return false;
2493
+ if (structuralSignature(a.scene) !== structuralSignature(b.scene)) return false;
2494
+ if (segmentRenderSeed(prev, i) !== segmentRenderSeed(next, i)) return false;
2495
+ }
2496
+ return true;
2497
+ }
2498
+ function transitionKey(tr) {
2499
+ if (!tr || tr.type === "cut") return "cut";
2500
+ if (tr.type === "morph") return `morph:${tr.dur}:${tr.text ?? "step"}`;
2501
+ return `fade:${tr.dur}`;
2502
+ }
2134
2503
 
2135
2504
  // src/compile.ts
2136
2505
  var DEFAULT_STEER_DUR = 1e3;
@@ -2185,6 +2554,9 @@ var SpecInstance = class {
2185
2554
  this.effSpec = spec;
2186
2555
  this.saverCtx = ctx;
2187
2556
  this.transparent = opts.transparent === true;
2557
+ const tier = opts.capabilityTier;
2558
+ this.fieldShortSide = tier === "basic" || tier === "minimal" ? FIELD_RASTER_SHORT_SIDE_LOW : FIELD_RASTER_SHORT_SIDE;
2559
+ this.animateFinish = tier !== "basic" && tier !== "minimal";
2188
2560
  this.seed = normalizeSeed(spec.seed ?? ctx.seed);
2189
2561
  let canvas;
2190
2562
  if (ctx.surface) {
@@ -2198,6 +2570,13 @@ var SpecInstance = class {
2198
2570
  canvas = el;
2199
2571
  this.ownsCanvas = true;
2200
2572
  }
2573
+ if (spec.finish !== void 0 && !this.transparent && !opts.hostPresents) {
2574
+ const pass = createFinishPass(canvas, this.seed);
2575
+ if (pass) {
2576
+ this.finishPass = pass;
2577
+ canvas = createSceneCanvas(ctx.width, ctx.height);
2578
+ }
2579
+ }
2201
2580
  this.canvas = canvas;
2202
2581
  const c2d = canvas.getContext("2d", { alpha: this.transparent });
2203
2582
  if (!c2d) throw new Error("schema saver: no 2d context");
@@ -2238,6 +2617,23 @@ var SpecInstance = class {
2238
2617
  lastStructural = "";
2239
2618
  /** See `LayerPaintOverride`; null (the default, and every plain mount) draws every layer once. */
2240
2619
  paintOverrides = null;
2620
+ /** Raster short side for a `field` background — halved on the low tiers. */
2621
+ fieldShortSide;
2622
+ /** The `field` background's cached raster; null until a field is first painted. */
2623
+ field = null;
2624
+ /**
2625
+ * The presentation pass when this instance applies a `finish`: the
2626
+ * visible canvas plus the cached screen tiles. When set, `this.canvas` is
2627
+ * an offscreen SCENE canvas — the surface every layer (and `ghosting`'s
2628
+ * persistence) draws into — and each frame ends by copying it to the
2629
+ * visible canvas and screening the finish over the copy. The finish thus
2630
+ * never enters the persistence loop. Null (every plain mount without a
2631
+ * finish, every sequence child) draws straight onto the visible canvas,
2632
+ * exactly as before the field existed.
2633
+ */
2634
+ finishPass = null;
2635
+ /** False on the `basic`/`minimal` tiers — `finish.animate` renders as a static tile. */
2636
+ animateFinish;
2241
2637
  /** Viewport factor for absolute px sizes — 1 for `units: 'px'` specs. */
2242
2638
  fontScale() {
2243
2639
  if (this.effSpec.units === "px") return 1;
@@ -2248,6 +2644,11 @@ var SpecInstance = class {
2248
2644
  this.canvas.width = Math.max(1, Math.round(this.w * dpr));
2249
2645
  this.canvas.height = Math.max(1, Math.round(this.h * dpr));
2250
2646
  this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
2647
+ if (this.finishPass) {
2648
+ this.finishPass.canvas.width = this.canvas.width;
2649
+ this.finishPass.canvas.height = this.canvas.height;
2650
+ this.finishPass.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
2651
+ }
2251
2652
  this.lastRenderT = Number.NEGATIVE_INFINITY;
2252
2653
  }
2253
2654
  /** (Re)seed and place all entities — deterministic for the seed + viewport. */
@@ -2289,6 +2690,10 @@ var SpecInstance = class {
2289
2690
  ctx.fillRect(0, 0, w, h);
2290
2691
  return;
2291
2692
  }
2693
+ if (bg.type === "field") {
2694
+ this.drawField(bg, t);
2695
+ return;
2696
+ }
2292
2697
  const g = ctx.createLinearGradient(0, 0, 0, h);
2293
2698
  const drift = bg.drift;
2294
2699
  for (let i = 0; i < bg.stops.length; i++) {
@@ -2309,6 +2714,58 @@ var SpecInstance = class {
2309
2714
  ctx.fillRect(0, h - bh, w, bh);
2310
2715
  }
2311
2716
  }
2717
+ /**
2718
+ * Paint a `field` background: sample the field into a low-res raster (short
2719
+ * side `fieldShortSide`, the long side by aspect) and draw it scaled to the
2720
+ * canvas — nearest-neighbour for quantised bands so contours stay crisp,
2721
+ * smoothed for `quantize: 0`. The raster is recomputed only when its key
2722
+ * changes: the config (steering glides it), the canvas size, or — while
2723
+ * drifting — the 100 ms bucket `t` falls in (`fieldSampleTime`); a static
2724
+ * field is sampled exactly once per mount. `luminanceGrid` samples the same
2725
+ * function at the same bucketed time, so perception and paint agree.
2726
+ */
2727
+ drawField(bg, t) {
2728
+ const { ctx, w, h } = this;
2729
+ const short = Math.max(1, Math.min(w, h));
2730
+ const cols = Math.max(1, Math.round(this.fieldShortSide * w / short));
2731
+ const rows = Math.max(1, Math.round(this.fieldShortSide * h / short));
2732
+ const ft = fieldSampleTime(bg, t);
2733
+ const key = `${JSON.stringify(bg)}|${w}x${h}|${cols}x${rows}|${ft}`;
2734
+ let raster = this.field;
2735
+ if (!raster || raster.key !== key) {
2736
+ if (!raster || raster.cols !== cols || raster.rows !== rows) {
2737
+ const canvas = typeof document !== "undefined" ? document.createElement("canvas") : new OffscreenCanvas(cols, rows);
2738
+ canvas.width = cols;
2739
+ canvas.height = rows;
2740
+ const rctx = canvas.getContext("2d");
2741
+ if (!rctx) return;
2742
+ raster = { canvas, ctx: rctx, cols, rows, key: "" };
2743
+ }
2744
+ const img = raster.ctx.createImageData(cols, rows);
2745
+ const data = img.data;
2746
+ const seed = this.seed;
2747
+ let i = 0;
2748
+ for (let r = 0; r < rows; r++) {
2749
+ const v = (r + 0.5) * h / rows / short;
2750
+ for (let c = 0; c < cols; c++) {
2751
+ const u = (c + 0.5) * w / cols / short;
2752
+ const rgb = fieldRgbAt(u, v, ft, bg, seed);
2753
+ data[i++] = rgb[0];
2754
+ data[i++] = rgb[1];
2755
+ data[i++] = rgb[2];
2756
+ data[i++] = 255;
2757
+ }
2758
+ }
2759
+ raster.ctx.putImageData(img, 0, 0);
2760
+ raster.key = key;
2761
+ this.field = raster;
2762
+ }
2763
+ const smooth = Math.round(bg.quantize ?? bg.bands.length) < 2;
2764
+ const prev = ctx.imageSmoothingEnabled;
2765
+ ctx.imageSmoothingEnabled = smooth;
2766
+ ctx.drawImage(raster.canvas, 0, 0, w, h);
2767
+ ctx.imageSmoothingEnabled = prev;
2768
+ }
2312
2769
  /** Position with parent-orbit resolution: a layer-parented orbit entity's
2313
2770
  * positionAt is an offset around (0,0); add the parent's own analytic position. */
2314
2771
  entityPos(e, t, parentE) {
@@ -2788,6 +3245,7 @@ var SpecInstance = class {
2788
3245
  this.paintFrame(t, 1);
2789
3246
  }
2790
3247
  this.lastRenderT = t;
3248
+ if (this.finishPass) presentWithFinish(this.finishPass, this.canvas, this.w, this.h, t, this.effSpec.finish, this.animateFinish);
2791
3249
  }
2792
3250
  setPaused(paused) {
2793
3251
  this.paused = paused;
@@ -2836,19 +3294,26 @@ var SpecInstance = class {
2836
3294
  }
2837
3295
  dispose() {
2838
3296
  this.stop();
2839
- if (this.ownsCanvas && typeof HTMLCanvasElement !== "undefined" && this.canvas instanceof HTMLCanvasElement) this.canvas.remove();
3297
+ this.field = null;
3298
+ const visible = this.finishPass?.canvas ?? this.canvas;
3299
+ this.finishPass = null;
3300
+ if (this.ownsCanvas && typeof HTMLCanvasElement !== "undefined" && visible instanceof HTMLCanvasElement) visible.remove();
2840
3301
  }
2841
3302
  };
2842
3303
  function compileSaver(spec) {
2843
3304
  const valid = assertValidSpec(spec);
2844
3305
  return {
2845
3306
  manifest: manifestFor(valid),
2846
- mount: (ctx) => new SpecInstance(valid, ctx),
3307
+ mount: (ctx) => new SpecInstance(valid, ctx, { capabilityTier: ctx.capabilityTier }),
2847
3308
  spec: valid
2848
3309
  };
2849
3310
  }
2850
3311
  var BED_PREFIX = "bed.";
3312
+ function hasHotSwapSequence(inst) {
3313
+ return typeof inst.hotSwapSequence === "function";
3314
+ }
2851
3315
  var SequenceInstance = class {
3316
+ /** Replaced wholesale by `hotSwapSequence`; every per-segment lookup (`segmentStart`, `canMorph`, `childScene`) reads it live, so there are no derived tables to refresh. */
2852
3317
  seq;
2853
3318
  childCtx;
2854
3319
  canvas;
@@ -2899,6 +3364,13 @@ var SequenceInstance = class {
2899
3364
  * resolve on it (see `applyRetainedDeltas`).
2900
3365
  */
2901
3366
  retainedDeltas = /* @__PURE__ */ new Map();
3367
+ /**
3368
+ * Every `bed.<path>` delta (prefix stripped, last wins per path). The bed
3369
+ * is never re-created, so these are applied to it once on arrival — the
3370
+ * map exists only so `hotSwapSequence` can put a steered bed colour back
3371
+ * after the bed takes its republished scene. Absent a swap it is never read.
3372
+ */
3373
+ retainedBedDeltas = /* @__PURE__ */ new Map();
2902
3374
  /** The outgoing segment while a `fade` runs; null otherwise. */
2903
3375
  fading = null;
2904
3376
  /**
@@ -2911,6 +3383,19 @@ var SequenceInstance = class {
2911
3383
  fadingIn = null;
2912
3384
  /** False on the `basic`/`minimal` capability tiers — fade renders as cut. */
2913
3385
  fadeEnabled;
3386
+ /** The mount's tier, handed to every child and the bed. */
3387
+ capabilityTier;
3388
+ /**
3389
+ * The sequence's presentation pass when it — or any segment — declares a
3390
+ * `finish`: children and the bed draw into an offscreen scene canvas
3391
+ * (`childCtx.surface`), and every frame ends by copying it to the visible
3392
+ * surface with the finish screened over the copy — once per composed
3393
+ * frame, bed + segment + fade together. Null when no finish is declared
3394
+ * anywhere: children draw straight onto the visible surface, as before.
3395
+ */
3396
+ finishPass = null;
3397
+ /** False on the `basic`/`minimal` tiers — `finish.animate` renders as a static tile. */
3398
+ animateFinish;
2914
3399
  /**
2915
3400
  * The sequence's `bed`: one scene on the shared surface, drawn first every
2916
3401
  * frame at the sequence clock — WITHOUT `clockOffset`, so a
@@ -2921,6 +3406,7 @@ var SequenceInstance = class {
2921
3406
  constructor(seq, ctx) {
2922
3407
  this.seq = seq;
2923
3408
  const { sequenceBaseT, capabilityTier, ...plainCtx } = ctx;
3409
+ this.capabilityTier = capabilityTier;
2924
3410
  this.fadeEnabled = capabilityTier !== "basic" && capabilityTier !== "minimal";
2925
3411
  if (seq.sync === "epoch" && typeof sequenceBaseT === "number" && Number.isFinite(sequenceBaseT)) {
2926
3412
  this.baseT = Math.max(0, sequenceBaseT);
@@ -2938,10 +3424,20 @@ var SequenceInstance = class {
2938
3424
  }
2939
3425
  this.canvas = canvas;
2940
3426
  this.seed = (seq.seed ?? ctx.seed ?? 0) >>> 0 || 1;
3427
+ this.animateFinish = capabilityTier !== "basic" && capabilityTier !== "minimal";
3428
+ const wantsFinish = seq.finish !== void 0 || seq.segments.some((s) => s.scene.finish !== void 0);
3429
+ if (wantsFinish && surface) {
3430
+ const pass = createFinishPass(surface, this.seed);
3431
+ if (pass) {
3432
+ this.finishPass = pass;
3433
+ surface = createSceneCanvas(ctx.width, ctx.height);
3434
+ }
3435
+ }
2941
3436
  this.childCtx = { ...plainCtx, surface, reducedMotion: true };
3437
+ this.sizeVisible();
2942
3438
  this.children = new Array(seq.segments.length).fill(null);
2943
3439
  if (seq.bed) {
2944
- this.bed = new SpecInstance(this.bedScene(seq.bed), this.childCtx);
3440
+ this.bed = new SpecInstance(this.bedScene(seq.bed), this.childCtx, { capabilityTier, hostPresents: true });
2945
3441
  this.bed.setPaused(true);
2946
3442
  } else {
2947
3443
  this.bed = null;
@@ -3034,7 +3530,7 @@ var SequenceInstance = class {
3034
3530
  */
3035
3531
  offscreenChild(index, root) {
3036
3532
  const canvas = typeof document !== "undefined" ? document.createElement("canvas") : new OffscreenCanvas(Math.max(1, this.childCtx.width), Math.max(1, this.childCtx.height));
3037
- const child = new SpecInstance(this.childScene(root), { ...this.childCtx, surface: canvas }, { transparent: this.bed !== null });
3533
+ const child = new SpecInstance(this.childScene(root), { ...this.childCtx, surface: canvas }, { transparent: this.bed !== null, capabilityTier: this.capabilityTier, hostPresents: true });
3038
3534
  child.setPaused(true);
3039
3535
  if (root !== index) child.hotSwapSpec(this.childScene(index));
3040
3536
  if (this.retainedDeltas.size > 0) child.applyDeltasNow(this.retainedDeltas.values());
@@ -3093,7 +3589,7 @@ var SequenceInstance = class {
3093
3589
  if (index < 0 || index >= this.seq.segments.length) index = 0;
3094
3590
  let child = this.children[index];
3095
3591
  if (!child) {
3096
- child = new SpecInstance(rootScene ?? this.childScene(index), this.childCtx, { transparent: this.bed !== null, skipInitialPaint });
3592
+ child = new SpecInstance(rootScene ?? this.childScene(index), this.childCtx, { transparent: this.bed !== null, skipInitialPaint, capabilityTier: this.capabilityTier, hostPresents: true });
3097
3593
  this.children[index] = child;
3098
3594
  child.setPaused(true);
3099
3595
  if (rootScene) child.hotSwapSpec(this.childScene(index));
@@ -3126,7 +3622,49 @@ var SequenceInstance = class {
3126
3622
  this.lastT = now - this.startT + this.baseT;
3127
3623
  this.renderFrame(this.lastT, this.seed);
3128
3624
  }
3625
+ /**
3626
+ * With a presentation pass, size the visible surface like a child sizes
3627
+ * the scene canvas (children never touch the visible surface then).
3628
+ */
3629
+ sizeVisible() {
3630
+ const fp = this.finishPass;
3631
+ if (!fp) return;
3632
+ const dpr = Math.min(this.childCtx.dpr, 2);
3633
+ fp.canvas.width = Math.max(1, Math.round(this.childCtx.width * dpr));
3634
+ fp.canvas.height = Math.max(1, Math.round(this.childCtx.height * dpr));
3635
+ fp.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
3636
+ }
3637
+ /**
3638
+ * The finish this frame applies: the sequence's own overrides; else the
3639
+ * active segment's (the incoming one during a fade) — read through
3640
+ * `steeredScene` so a retained steer to `finish.grain`/`finish.dither`
3641
+ * takes effect immediately instead of the authored value; a bed's is
3642
+ * ignored.
3643
+ */
3644
+ frameFinish() {
3645
+ if (this.seq.finish !== void 0) return this.seq.finish;
3646
+ if (this.activeIndex < 0) return void 0;
3647
+ return this.steeredScene(this.childScene(this.activeIndex)).finish;
3648
+ }
3649
+ /**
3650
+ * The seed `frameFinish()`'s grain resolves against — the sequence's own
3651
+ * for a sequence-level finish, else the active segment's actual render
3652
+ * seed (`segmentRenderSeed`: chain-root-aware and normalized exactly like
3653
+ * `SpecInstance`, so it agrees with what that segment's entities render
3654
+ * with, morph or not).
3655
+ */
3656
+ frameFinishSeed() {
3657
+ if (this.seq.finish !== void 0) return this.seed;
3658
+ if (this.activeIndex < 0) return this.seed;
3659
+ return segmentRenderSeed(this.seq, this.activeIndex) ?? this.seed;
3660
+ }
3129
3661
  renderFrame(T, seed) {
3662
+ this.renderScene(T, seed);
3663
+ if (this.finishPass) {
3664
+ presentWithFinish(this.finishPass, this.childCtx.surface, this.childCtx.width, this.childCtx.height, T, this.frameFinish(), this.animateFinish, this.frameFinishSeed());
3665
+ }
3666
+ }
3667
+ renderScene(T, seed) {
3130
3668
  this.renderedT = T;
3131
3669
  const resolved = resolveSegment(this.seq, T + this.clockOffset, { releasedBelow: this.releasedBelow });
3132
3670
  const { index, localT } = resolved;
@@ -3207,6 +3745,7 @@ var SequenceInstance = class {
3207
3745
  this.childCtx.width = width;
3208
3746
  this.childCtx.height = height;
3209
3747
  if (dpr !== void 0) this.childCtx.dpr = dpr;
3748
+ this.sizeVisible();
3210
3749
  for (const child of this.children) {
3211
3750
  child?.resize(width, height, dpr);
3212
3751
  }
@@ -3218,7 +3757,10 @@ var SequenceInstance = class {
3218
3757
  const all = track?.deltas ?? [];
3219
3758
  const isBedPath = (d) => this.bed !== null && typeof d.path === "string" && d.path.startsWith(BED_PREFIX);
3220
3759
  const bedDeltas = all.filter(isBedPath).map((d) => ({ ...d, path: d.path.slice(BED_PREFIX.length) }));
3221
- if (bedDeltas.length > 0) this.bed.applyTrack({ ...track, deltas: bedDeltas });
3760
+ if (bedDeltas.length > 0) {
3761
+ for (const d of bedDeltas) this.retainedBedDeltas.set(d.path, d);
3762
+ this.bed.applyTrack({ ...track, deltas: bedDeltas });
3763
+ }
3222
3764
  const deltas = all.filter((d) => !isBedPath(d));
3223
3765
  for (const d of deltas) {
3224
3766
  if (d.path !== "sequence.segment" && typeof d.path === "string") this.retainedDeltas.set(d.path, d);
@@ -3245,6 +3787,34 @@ var SequenceInstance = class {
3245
3787
  this.fadingIn?.child.applyTrack({ ...track, deltas: childDeltas });
3246
3788
  }
3247
3789
  }
3790
+ /**
3791
+ * See `SequenceSaverInstance.hotSwapSequence`. Nothing that positions the
3792
+ * show is touched: `clockOffset`, `releasedBelow`, `activeIndex`,
3793
+ * `morphFromIndex`, `pendingWrapFade`, `renderedT` and the retained deltas
3794
+ * all keep their values, and the timing terms `sequenceSwapCompatible`
3795
+ * pins (durations, advance, transitions, loop) are the same on both sides,
3796
+ * so `resolveSegment` at the next frame lands where it would have anyway.
3797
+ * Each live child takes its new scene with the retained track re-applied
3798
+ * (`steeredScene` — the same routing a fresh child gets), so steered paint
3799
+ * survives the swap; `hotSwapSpec` keeps the entities (the signatures
3800
+ * match) and does not reset ghosting contiguity, so a drifting entity is
3801
+ * where it was 1 ms ago. Children are always paused, so none of them has a
3802
+ * glide in flight for the swap to interrupt (`SpecInstance.applyTrack`
3803
+ * snaps when paused). A morph in progress is untouched here: its chain-root
3804
+ * child is re-lerped from the new endpoints on the very next frame.
3805
+ */
3806
+ hotSwapSequence(next) {
3807
+ if (!sequenceSwapCompatible(this.seq, next)) return false;
3808
+ this.seq = next;
3809
+ if (this.bed && next.bed) this.bed.hotSwapSpec(applyRetainedDeltas(this.bedScene(next.bed), this.retainedBedDeltas.values()));
3810
+ for (let i = 0; i < this.children.length; i++) {
3811
+ this.children[i]?.hotSwapSpec(this.steeredScene(this.childScene(i)));
3812
+ }
3813
+ if (this.fading) this.fading.child.hotSwapSpec(this.steeredScene(this.childScene(this.fading.index)));
3814
+ if (this.fadingIn) this.fadingIn.child.hotSwapSpec(this.steeredScene(this.childScene(this.fadingIn.index)));
3815
+ if (this.paused) this.renderFrame(this.renderedT, this.seed);
3816
+ return true;
3817
+ }
3248
3818
  dispose() {
3249
3819
  this.stop();
3250
3820
  this.releaseFading();
@@ -3252,6 +3822,10 @@ var SequenceInstance = class {
3252
3822
  this.releaseChild(i);
3253
3823
  }
3254
3824
  this.bed?.dispose();
3825
+ if (this.finishPass) {
3826
+ this.finishPass.grain = null;
3827
+ this.finishPass.dither = null;
3828
+ }
3255
3829
  if (this.canvas) this.canvas.remove();
3256
3830
  }
3257
3831
  };
@@ -3289,6 +3863,14 @@ function compileSequence(spec) {
3289
3863
  import { createRng as createRng2 } from "@idle-screens/core";
3290
3864
 
3291
3865
  // src/luma.ts
3866
+ function fieldRenderedRgbs(bg) {
3867
+ const n = bg.bands.length;
3868
+ const q = Math.round(bg.quantize ?? n);
3869
+ if (q < 2) return bg.bands.map((hex) => hexToRgb255(hex));
3870
+ const out = [];
3871
+ for (let level = 0; level < q; level++) out.push(fieldRgb((level + 0.5) / q, bg.bands, q));
3872
+ return out;
3873
+ }
3292
3874
  function hexLuma(hex) {
3293
3875
  const h = hex.length === 4 ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}` : hex;
3294
3876
  const n = parseInt(h.slice(1), 16);
@@ -3317,6 +3899,10 @@ function spriteHex(layer, e) {
3317
3899
  function backgroundLuma(spec) {
3318
3900
  const bg = spec.background;
3319
3901
  if (!bg || bg.type === "solid") return hexLuma(bg?.color ?? "#05050a");
3902
+ if (bg.type === "field") {
3903
+ const rgbs = fieldRenderedRgbs(bg);
3904
+ return rgbs.reduce((s, c) => s + rgb255Luma(c), 0) / rgbs.length;
3905
+ }
3320
3906
  return bg.stops.reduce((s, st) => s + hexLuma(st.color), 0) / bg.stops.length;
3321
3907
  }
3322
3908
  function hexRgb(hex) {
@@ -3328,14 +3914,20 @@ function hexRgb(hex) {
3328
3914
  function backgroundRgb(spec) {
3329
3915
  const bg = spec.background;
3330
3916
  if (!bg || bg.type === "solid") return hexRgb(bg?.color ?? "#05050a");
3331
- const acc = bg.stops.reduce(
3332
- (s, st) => {
3333
- const c = hexRgb(st.color);
3917
+ if (bg.type === "field") {
3918
+ const rgbs = fieldRenderedRgbs(bg);
3919
+ const acc2 = rgbs.reduce((s, c) => ({ r: s.r + c[0], g: s.g + c[1], b: s.b + c[2] }), { r: 0, g: 0, b: 0 });
3920
+ return { r: acc2.r / rgbs.length / 255, g: acc2.g / rgbs.length / 255, b: acc2.b / rgbs.length / 255 };
3921
+ }
3922
+ const colours = bg.stops.map((st) => st.color);
3923
+ const acc = colours.reduce(
3924
+ (s, hex) => {
3925
+ const c = hexRgb(hex);
3334
3926
  return { r: s.r + c.r, g: s.g + c.g, b: s.b + c.b };
3335
3927
  },
3336
3928
  { r: 0, g: 0, b: 0 }
3337
3929
  );
3338
- const n = bg.stops.length || 1;
3930
+ const n = colours.length || 1;
3339
3931
  return { r: acc.r / n, g: acc.g / n, b: acc.b / n };
3340
3932
  }
3341
3933
  function colourSeparation(layer, sprite, bg) {
@@ -3358,9 +3950,15 @@ function legibilityRatio(a, b) {
3358
3950
  const lo = Math.min(la, lb);
3359
3951
  return (hi + 0.05) / (lo + 0.05);
3360
3952
  }
3361
- function backgroundRgbAt(spec, yPx, h, unit) {
3953
+ function backgroundRgbAt(spec, yPx, h, unit, xPx, w, seed) {
3362
3954
  const bg = spec.background;
3363
3955
  if (!bg || bg.type === "solid") return hexRgb(bg?.color ?? "#05050a");
3956
+ if (bg.type === "field") {
3957
+ if (xPx === void 0 || w === void 0) return backgroundRgb(spec);
3958
+ const short = Math.max(1, Math.min(w, h));
3959
+ const c = fieldRgbAt(xPx / short, yPx / short, 0, bg, seed ?? spec.seed ?? 42);
3960
+ return { r: c[0] / 255, g: c[1] / 255, b: c[2] / 255 };
3961
+ }
3364
3962
  if (bg.band) {
3365
3963
  const bh = bg.band.height * unit;
3366
3964
  if (yPx >= h - bh) return hexRgb(bg.band.color);
@@ -3913,7 +4511,8 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
3913
4511
  let worst = null;
3914
4512
  for (const box of boxes) {
3915
4513
  const cy = (box.y0 + box.y1) / 2;
3916
- const groundRgb = backgroundRgbAt(spec, cy, h, scale);
4514
+ const cx = (box.x0 + box.x1) / 2;
4515
+ const groundRgb = backgroundRgbAt(spec, cy, h, scale, cx, w, opts.backgroundSeed ?? opts.seed);
3917
4516
  const ground = legibilityRatio(textPlate(groundRgb, box.alpha), groundRgb);
3918
4517
  const lit = brightestAdditivePlate(spec, allEntities, groundRgb, box, w, h);
3919
4518
  const plate = lit ? legibilityRatio(textPlate(lit.rgb, box.alpha), lit.rgb) : Infinity;
@@ -4151,7 +4750,7 @@ function describeScene(spec, options = {}) {
4151
4750
  return { t, viewport: { w, h }, layers: layerSnapshots, warnings };
4152
4751
  });
4153
4752
  return {
4154
- spec: { id: spec.id, label: spec.label, units: spec.units ?? "px" },
4753
+ spec: { id: spec.id, label: spec.label, units: spec.units ?? "px", ...spec.finish ? { finish: spec.finish } : {} },
4155
4754
  snapshots
4156
4755
  };
4157
4756
  }
@@ -4249,8 +4848,23 @@ function luminanceGrid(spec, opts = {}) {
4249
4848
  const cellH = h / rows;
4250
4849
  const bg = spec.background;
4251
4850
  const bgRow = new Array(rows).fill(0);
4851
+ let bgCells;
4252
4852
  if (!bg || bg.type === "solid") {
4253
4853
  bgRow.fill(hexLuma(bg?.color ?? "#05050a"));
4854
+ } else if (bg.type === "field") {
4855
+ bgCells = new Array(cols * rows);
4856
+ const short = Math.max(1, Math.min(w, h));
4857
+ const seed = opts.seed ?? spec.seed ?? 42;
4858
+ const ft = fieldSampleTime(bg, t);
4859
+ for (let r = 0; r < rows; r++) {
4860
+ let rowSum = 0;
4861
+ for (let c = 0; c < cols; c++) {
4862
+ const lum = rgb255Luma(fieldRgbAt((c + 0.5) * cellW / short, (r + 0.5) * cellH / short, ft, bg, seed));
4863
+ bgCells[r * cols + c] = lum;
4864
+ rowSum += lum;
4865
+ }
4866
+ bgRow[r] = rowSum / cols;
4867
+ }
4254
4868
  } else {
4255
4869
  const stops = [...bg.stops].sort((a, b) => a.at - b.at);
4256
4870
  for (let r = 0; r < rows; r++) {
@@ -4275,7 +4889,7 @@ function luminanceGrid(spec, opts = {}) {
4275
4889
  }
4276
4890
  }
4277
4891
  const cells = new Array(cols * rows);
4278
- for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) cells[r * cols + c] = bgRow[r];
4892
+ for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) cells[r * cols + c] = bgCells ? bgCells[r * cols + c] : bgRow[r];
4279
4893
  const compose = (idx, lum, a, blend) => {
4280
4894
  const cur = cells[idx];
4281
4895
  if (blend === "lighter" || blend === "screen") cells[idx] = Math.min(1, cur + lum * a);
@@ -4489,9 +5103,9 @@ function luminanceGrid(spec, opts = {}) {
4489
5103
  }
4490
5104
  }
4491
5105
  splatPass(t, 1);
4492
- return finishGrid(cols, rows, cells, bgRow);
5106
+ return finishGrid(cols, rows, cells, bgRow, bgCells);
4493
5107
  }
4494
- function finishGrid(cols, rows, cells, bgRow) {
5108
+ function finishGrid(cols, rows, cells, bgRow, bgCells) {
4495
5109
  let sum = 0;
4496
5110
  let covered = 0;
4497
5111
  let devSum = 0;
@@ -4503,7 +5117,7 @@ function finishGrid(cols, rows, cells, bgRow) {
4503
5117
  for (let c = 0; c < cols; c++) {
4504
5118
  const v = cells[r * cols + c];
4505
5119
  sum += v;
4506
- const dev = Math.abs(v - bgRow[r]);
5120
+ const dev = Math.abs(v - (bgCells ? bgCells[r * cols + c] : bgRow[r]));
4507
5121
  if (dev > 0.03) covered++;
4508
5122
  devSum += dev;
4509
5123
  cxAcc += dev * (c + 0.5);
@@ -4519,6 +5133,7 @@ function finishGrid(cols, rows, cells, bgRow) {
4519
5133
  rows,
4520
5134
  cells,
4521
5135
  background: bgRow,
5136
+ ...bgCells ? { backgroundCells: bgCells } : {},
4522
5137
  meanLuminance: sum / (cols * rows),
4523
5138
  coverage: covered / (cols * rows),
4524
5139
  centroid: devSum > 1e-6 ? { x: cxAcc / devSum / cols, y: cyAcc / devSum / rows } : null,
@@ -4742,8 +5357,9 @@ function regionMeans(grid) {
4742
5357
  const cnt = new Array(9).fill(0);
4743
5358
  for (let r = 0; r < grid.rows; r++) {
4744
5359
  for (let c = 0; c < grid.cols; c++) {
5360
+ const idx = r * grid.cols + c;
4745
5361
  const region = Math.min(2, Math.floor(r / grid.rows * 3)) * 3 + Math.min(2, Math.floor(c / grid.cols * 3));
4746
- out[region] += Math.abs(grid.cells[r * grid.cols + c] - grid.background[r]);
5362
+ out[region] += Math.abs(grid.cells[idx] - (grid.backgroundCells ? grid.backgroundCells[idx] : grid.background[r]));
4747
5363
  cnt[region]++;
4748
5364
  }
4749
5365
  }
@@ -4819,7 +5435,7 @@ function perceiveSequenceFrame(seq, T, opts = {}) {
4819
5435
  const bedGrid = luminanceGrid(seq.bed, bedOpts);
4820
5436
  const inkGrid = luminanceGrid(ink, segOpts);
4821
5437
  const cells = bedGrid.cells.map((v, i) => Math.min(1, v + inkGrid.cells[i]));
4822
- const grid = finishGrid(bedGrid.cols, bedGrid.rows, cells, bedGrid.background);
5438
+ const grid = finishGrid(bedGrid.cols, bedGrid.rows, cells, bedGrid.background, bedGrid.backgroundCells);
4823
5439
  const bedLayers = seq.bed.layers.length;
4824
5440
  const dominance = rankDominance([
4825
5441
  ...rawDominance(seq.bed, bedOpts).map((d) => ({ ...d, key: bedKey(d.key, d.layerIndex) })),
@@ -4842,7 +5458,16 @@ function perceiveSequenceFrame(seq, T, opts = {}) {
4842
5458
  text: [...textSprites(seq.bed, bedOpts).map(prefix), ...textSprites(ink, segOpts).map(shift)],
4843
5459
  advisories: [
4844
5460
  ...adviseSpec(seq.bed, viewport, { t: T, seed: bedOpts.seed }).map((w) => ({ ...w, path: `bed.${w.path}` })),
4845
- ...adviseSpec(inkOverBed, viewport, { t: r.localT, seed: segOpts.seed })
5461
+ // inkOverBed's layers are the segment's (seed: segOpts.seed) but its
5462
+ // background is the bed's (seq.bed.background) — a field there must be
5463
+ // sampled with the bed's own seed, or a `role: 'read'` legibility
5464
+ // advisory checks terrain that isn't what's actually behind the ink.
5465
+ // `?? 42`: inkOverBed.seed is the SEGMENT's raw seed field (it's `ink`
5466
+ // spread first), so adviseSpec's own `backgroundSeed ?? seed` fallback
5467
+ // would substitute the segment's seed, not the bed's, if bedOpts.seed
5468
+ // were left undefined — 42 is the same default `luminanceGrid` (and
5469
+ // every other field seed fallback) converges on.
5470
+ ...adviseSpec(inkOverBed, viewport, { t: r.localT, seed: segOpts.seed, backgroundSeed: bedOpts.seed ?? 42 })
4846
5471
  ],
4847
5472
  segment,
4848
5473
  bed: true
@@ -6467,6 +7092,38 @@ var SNOWFALL_SPEC = {
6467
7092
  ]
6468
7093
  };
6469
7094
 
7095
+ // src/examples/thermal-field.ts
7096
+ var THERMAL_FIELD_SPEC = {
7097
+ schemaVersion: 1,
7098
+ id: "thermal-field",
7099
+ label: "Thermal Field",
7100
+ seed: 71,
7101
+ motionIntensity: "calm",
7102
+ // The riso print: grain at half strength, a light dither screen — the
7103
+ // finish that makes the flat inks read as paper rather than pixels.
7104
+ finish: { grain: 0.5, dither: 0.3 },
7105
+ background: {
7106
+ type: "field",
7107
+ scale: 2.2,
7108
+ octaves: 3,
7109
+ warp: 0.4,
7110
+ quantize: 6,
7111
+ bands: ["#1b1a3a", "#0078bf", "#00a99d", "#ffe800", "#ff6c2f", "#ff48b0"],
7112
+ drift: { period: 4e4, amount: 0.3 }
7113
+ },
7114
+ layers: [
7115
+ {
7116
+ key: "motes",
7117
+ count: 14,
7118
+ sprite: { kind: "circle", radius: [0.012, 0.028], color: "#fff6e0", soft: true },
7119
+ motion: { type: "wander", speed: [3e-3, 8e-3], meander: 0.04, coherence: 0.3 },
7120
+ alpha: [0.18, 0.4],
7121
+ blend: "screen",
7122
+ pulse: { amp: 0.15, period: 9e3 }
7123
+ }
7124
+ ]
7125
+ };
7126
+
6470
7127
  // src/examples/warp-tunnel.ts
6471
7128
  var WARP_TUNNEL_SPEC = {
6472
7129
  schemaVersion: 1,
@@ -6541,7 +7198,8 @@ var SCHEMA_EXAMPLES = [
6541
7198
  { id: "pings", label: "Pings", spec: PINGS_SPEC },
6542
7199
  { id: "facets", label: "Facets", spec: FACETS_SPEC },
6543
7200
  { id: "relay-board", label: "Relay Board", spec: RELAY_BOARD_SPEC },
6544
- { id: "lobby-talk", label: "Lobby Talk", spec: LOBBY_TALK_SPEC }
7201
+ { id: "lobby-talk", label: "Lobby Talk", spec: LOBBY_TALK_SPEC },
7202
+ { id: "thermal-field", label: "Thermal Field", spec: THERMAL_FIELD_SPEC }
6545
7203
  ];
6546
7204
  var EXAMPLE_SPECS = SCHEMA_EXAMPLES.map((e) => e.spec);
6547
7205
  var EXAMPLE_BY_ID = Object.fromEntries(
@@ -6554,9 +7212,18 @@ export {
6554
7212
  COMETS_SPEC,
6555
7213
  CONSTELLATION_SPEC,
6556
7214
  DASHBOARD_SPEC,
7215
+ DITHER_ALPHA,
7216
+ DITHER_TILE,
6557
7217
  EXAMPLE_BY_ID,
6558
7218
  EXAMPLE_SPECS,
6559
7219
  FACETS_SPEC,
7220
+ FIELD_BUCKET_MS,
7221
+ FIELD_DRIFT_DEFAULT_AMOUNT,
7222
+ FIELD_RASTER_SHORT_SIDE,
7223
+ FIELD_RASTER_SHORT_SIDE_LOW,
7224
+ FINISH_ANIMATE_HZ,
7225
+ GRAIN_ALPHA,
7226
+ GRAIN_TILE,
6560
7227
  HAIKU_SPEC,
6561
7228
  LANTERNS_SPEC,
6562
7229
  LIMITS,
@@ -6573,6 +7240,7 @@ export {
6573
7240
  SCHEMA_EXAMPLES,
6574
7241
  SCHEMA_VERSION,
6575
7242
  SNOWFALL_SPEC,
7243
+ THERMAL_FIELD_SPEC,
6576
7244
  WARP_MAX_SCALE,
6577
7245
  WARP_NEAR,
6578
7246
  WARP_TUNNEL_SPEC,
@@ -6582,6 +7250,8 @@ export {
6582
7250
  applyDeltasToSpec,
6583
7251
  assertValidSequence,
6584
7252
  assertValidSpec,
7253
+ bayerMatrix,
7254
+ bayerTilePixels,
6585
7255
  bedRenderSeed,
6586
7256
  breakTextBlock,
6587
7257
  buildEntities,
@@ -6589,14 +7259,27 @@ export {
6589
7259
  cohesionOf,
6590
7260
  compileSaver,
6591
7261
  compileSequence,
7262
+ createFinishPass,
7263
+ createSceneCanvas,
6592
7264
  describeScene,
6593
7265
  diffScenes,
6594
7266
  dominanceRanking,
6595
7267
  easeSmooth,
6596
7268
  emitEnvelope,
6597
7269
  emitWindow,
7270
+ fieldAt,
7271
+ fieldOctaves,
7272
+ fieldRgb,
7273
+ fieldRgbAt,
7274
+ fieldSampleTime,
7275
+ finishStrength,
7276
+ grainOffset,
7277
+ grainTilePixels,
6598
7278
  graphemeClusters,
7279
+ hasHotSwapSequence,
6599
7280
  headingAt,
7281
+ hexToRgb255,
7282
+ latticeHash,
6600
7283
  layerCohesion,
6601
7284
  lerpSpec,
6602
7285
  lifeAlphaAt,
@@ -6610,18 +7293,22 @@ export {
6610
7293
  normalizeSeed,
6611
7294
  perceiveScene,
6612
7295
  perceiveSequenceFrame,
7296
+ pickScreenOp,
6613
7297
  positionAt,
7298
+ presentWithFinish,
6614
7299
  renderBrailleMap,
6615
7300
  renderDensityMap,
6616
7301
  resolveSegment,
6617
7302
  resolveSpecPath,
6618
7303
  revealState,
7304
+ rgb255Luma,
6619
7305
  rotationAt,
6620
7306
  seamCauseOf,
6621
7307
  seamsWorthWarning,
6622
7308
  segmentRenderSeed,
6623
7309
  segmentStart,
6624
7310
  sequenceSteerablePaths,
7311
+ sequenceSwapCompatible,
6625
7312
  sizeAt,
6626
7313
  spriteIndexAt,
6627
7314
  spriteVariants,