@idle-screens/schema 3.16.1 → 3.17.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
@@ -500,46 +500,105 @@ function easeGlide(ease2, k) {
500
500
  }
501
501
  var STEER_EASES = /* @__PURE__ */ new Set(["in", "out", "spring"]);
502
502
  function structuralSignature(spec) {
503
- return JSON.stringify([
504
- spec.units,
505
- // Normalized against the same default every renderer uses — an omitted
506
- // referenceViewport and an explicit 1080 render identically, so they must
507
- // hash identically or a same-sizing morph gets rejected as structural.
508
- spec.referenceViewport ?? LIMITS.referenceViewport,
509
- spec.layers.map((l) => {
503
+ const f = structuralFields(spec);
504
+ return JSON.stringify([...f.root.map(([, v]) => v), f.layers.map((l) => l.map(([, v]) => v))]);
505
+ }
506
+ function structuralFields(spec) {
507
+ return {
508
+ root: [
509
+ ["units", spec.units],
510
+ // Normalized against the same default every renderer uses — an omitted
511
+ // referenceViewport and an explicit 1080 render identically, so they must
512
+ // hash identically or a same-sizing morph gets rejected as structural.
513
+ ["referenceViewport", spec.referenceViewport ?? LIMITS.referenceViewport]
514
+ ],
515
+ layers: spec.layers.map((l) => {
510
516
  const s = l.sprite;
511
517
  return [
512
- l.count,
513
- l.size,
514
- l.region,
515
- l.position,
516
- l.motion,
517
- l.wrap,
518
- l.flip,
519
- l.alpha,
520
- l.pulse,
521
- l.spin,
522
- l.grow,
523
- l.layout,
524
- l.emit,
525
- l.clock,
526
- l.sprite.kind,
518
+ ["count", l.count],
519
+ ["size", l.size],
520
+ ["region", l.region],
521
+ ["position", l.position],
522
+ ["motion", l.motion],
523
+ ["wrap", l.wrap],
524
+ ["flip", l.flip],
525
+ ["alpha", l.alpha],
526
+ ["pulse", l.pulse],
527
+ ["spin", l.spin],
528
+ ["grow", l.grow],
529
+ ["layout", l.layout],
530
+ ["emit", l.emit],
531
+ ["clock", l.clock],
532
+ ["sprite.kind", l.sprite.kind],
527
533
  // Dimensional draws baked into entities: radius (circle/ring), length
528
534
  // (streak), width+aspect (rect), palette pick (all shaped sprites).
529
- s.radius,
530
- s.length,
531
- l.sprite.kind === "rect" ? [s.width, s.aspect] : l.sprite.kind === "bar" ? [s.length, s.thickness] : void 0,
532
- Array.isArray(s.colors) ? s.colors.length : void 0,
533
- s.colorWeights,
534
- 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,
535
+ ["sprite.radius", s.radius],
536
+ ["sprite.length", s.length],
537
+ l.sprite.kind === "rect" ? [["sprite.width", "sprite.aspect"], [s.width, s.aspect]] : l.sprite.kind === "bar" ? [["sprite.length", "sprite.thickness"], [s.length, s.thickness]] : ["sprite.dimensions", void 0],
538
+ ["sprite.colors.length", Array.isArray(s.colors) ? s.colors.length : void 0],
539
+ ["sprite.colorWeights", s.colorWeights],
540
+ l.sprite.kind === "emoji" ? [["sprite.glyphs.length", "sprite.cycle.period"], [l.sprite.glyphs.length, l.sprite.cycle?.period]] : l.sprite.kind === "text" ? [["sprite.strings.length", "sprite.cycle.period"], [l.sprite.strings.length, l.sprite.cycle?.period]] : l.sprite.kind === "textBlock" ? [["sprite.fontSize", "sprite.anchor"], l.sprite.anchor ? [l.sprite.fontSize, l.sprite.anchor] : [l.sprite.fontSize]] : ["sprite.variants", void 0],
535
541
  // Static rotation is baked into entities (a range draws a seeded
536
542
  // angle). Appended only when set — same rule as `anchor` above.
537
- ...l.rotate !== void 0 ? [l.rotate] : [],
543
+ ...l.rotate !== void 0 ? [["rotate", l.rotate]] : [],
538
544
  // `rock` is baked per entity like `pulse` (and draws a seeded phase).
539
- ...l.rock !== void 0 ? [{ rock: l.rock }] : []
545
+ ...l.rock !== void 0 ? [["rock", { rock: l.rock }]] : []
540
546
  ];
541
547
  })
542
- ]);
548
+ };
549
+ }
550
+ function showValue(v) {
551
+ if (v === void 0) return "(unset)";
552
+ if (typeof v === "string") return v;
553
+ const j = JSON.stringify(v);
554
+ return j.length > 40 ? `${j.slice(0, 37)}\u2026` : j;
555
+ }
556
+ function leafDifferences(path, a, b, out) {
557
+ if (JSON.stringify(a) === JSON.stringify(b)) return;
558
+ const isRec = (x) => !!x && typeof x === "object" && !Array.isArray(x);
559
+ if (isRec(a) && isRec(b)) {
560
+ const before = out.length;
561
+ for (const k of /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)])) leafDifferences(`${path}.${k}`, a[k], b[k], out);
562
+ if (out.length === before) out.push(`${path}: the same fields in a different key order`);
563
+ return;
564
+ }
565
+ out.push(`${path}: ${showValue(a)} \u2192 ${showValue(b)}`);
566
+ }
567
+ function namedFields(fields) {
568
+ const m = /* @__PURE__ */ new Map();
569
+ for (const [name, value] of fields) {
570
+ if (typeof name === "string") {
571
+ m.set(name, value);
572
+ continue;
573
+ }
574
+ const vs = Array.isArray(value) ? value : [];
575
+ name.forEach((n, i) => m.set(n, vs[i]));
576
+ }
577
+ return m;
578
+ }
579
+ function structuralDifferences(a, b) {
580
+ const fa = structuralFields(a);
581
+ const fb = structuralFields(b);
582
+ const out = [];
583
+ const ra = namedFields(fa.root);
584
+ const rb = namedFields(fb.root);
585
+ for (const k of /* @__PURE__ */ new Set([...ra.keys(), ...rb.keys()])) leafDifferences(k, ra.get(k), rb.get(k), out);
586
+ if (fa.layers.length !== fb.layers.length) out.push(`layers.length: ${fa.layers.length} \u2192 ${fb.layers.length}`);
587
+ for (let i = 0; i < Math.min(fa.layers.length, fb.layers.length); i++) {
588
+ const la = namedFields(fa.layers[i]);
589
+ const lb = namedFields(fb.layers[i]);
590
+ const kindChanged = la.get("sprite.kind") !== lb.get("sprite.kind");
591
+ for (const k of /* @__PURE__ */ new Set([...la.keys(), ...lb.keys()])) {
592
+ if (kindChanged && k.startsWith("sprite.") && k !== "sprite.kind") continue;
593
+ leafDifferences(`layers[${i}].${k}`, la.get(k), lb.get(k), out);
594
+ }
595
+ }
596
+ return out;
597
+ }
598
+ function describeStructuralDifferences(a, b, max = 3) {
599
+ const d = structuralDifferences(a, b);
600
+ if (d.length === 0) return "";
601
+ return d.length <= max ? d.join("; ") : `${d.slice(0, max).join("; ")}; +${d.length - max} more`;
543
602
  }
544
603
 
545
604
  // src/sequence.ts
@@ -1821,9 +1880,37 @@ function normalizeColors(spec) {
1821
1880
  }
1822
1881
  }
1823
1882
  }
1883
+ var LAYER_SIZED_SPRITES = /* @__PURE__ */ new Set(["emoji", "text"]);
1884
+ var SPRITE_SIZE_FIELD = {
1885
+ circle: "radius",
1886
+ ring: "radius",
1887
+ polygon: "radius",
1888
+ streak: "length",
1889
+ stroke: "length",
1890
+ rect: "width",
1891
+ bar: "length and thickness",
1892
+ textBlock: "fontSize"
1893
+ };
1894
+ var SPRITE_SIZE_MISPLACED = "'size' belongs on the layer, not inside sprite \u2014 move it up one level";
1895
+ function aliasSpriteSize(spec) {
1896
+ const out = [];
1897
+ if (!isObj(spec) || !Array.isArray(spec.layers)) return out;
1898
+ spec.layers.forEach((layer, i) => {
1899
+ if (!isObj(layer) || !isObj(layer.sprite)) return;
1900
+ const sp = layer.sprite;
1901
+ if (sp.size === void 0 || !LAYER_SIZED_SPRITES.has(sp.kind) || layer.size !== void 0) return;
1902
+ layer.size = sp.size;
1903
+ delete sp.size;
1904
+ out.push({ path: `layers[${i}].sprite.size`, code: "misplaced-property", message: `${SPRITE_SIZE_MISPLACED} (applied as layers[${i}].size this time)` });
1905
+ });
1906
+ return out;
1907
+ }
1824
1908
  function validateSpec(spec) {
1825
1909
  normalizeColors(spec);
1826
- return validateSpecCore(spec);
1910
+ const repaired = aliasSpriteSize(spec);
1911
+ const result = validateSpecCore(spec);
1912
+ if (repaired.length > 0) result.warnings = [...repaired, ...result.warnings ?? []];
1913
+ return result;
1827
1914
  }
1828
1915
  function ignoredPropertyPaths(spec) {
1829
1916
  if (!isObj(spec)) return [];
@@ -2224,6 +2311,15 @@ function validateLayer(layer, path, err, warn, spec) {
2224
2311
  if (layer.size !== void 0 && (!isRange(layer.size) || layer.size[0] <= 0)) {
2225
2312
  err(`${path}.size`, rangeMsg(spec));
2226
2313
  }
2314
+ if (isObj(layer.sprite) && layer.sprite.size !== void 0) {
2315
+ const kind = layer.sprite.kind;
2316
+ if (LAYER_SIZED_SPRITES.has(kind)) {
2317
+ warn(`${path}.sprite.size`, "misplaced-property", layer.size !== void 0 ? `${SPRITE_SIZE_MISPLACED} \u2014 layer.size is also set, so the layer's value wins and sprite.size is ignored` : SPRITE_SIZE_MISPLACED);
2318
+ } else {
2319
+ const field = SPRITE_SIZE_FIELD[kind];
2320
+ warn(`${path}.sprite.size`, "unknown-property", field ? `unknown sprite property 'size' \u2014 will be ignored; a ${kind} sprite is sized by its ${field}` : `unknown sprite property 'size' \u2014 will be ignored`);
2321
+ }
2322
+ }
2227
2323
  if (layer.wrap !== void 0 && typeof layer.wrap !== "boolean") err(`${path}.wrap`, "must be a boolean");
2228
2324
  if (layer.flip !== void 0 && typeof layer.flip !== "boolean") err(`${path}.flip`, "must be a boolean");
2229
2325
  if (layer.alpha !== void 0 && (!isRange(layer.alpha) || layer.alpha[0] < 0 || layer.alpha[1] > 1)) {
@@ -2395,7 +2491,8 @@ function validateLayer(layer, path, err, warn, spec) {
2395
2491
  const sp = layer.sprite;
2396
2492
  if (isObj(sp) && isNum(layer.count)) {
2397
2493
  const variants = sp.kind === "text" && Array.isArray(sp.strings) ? sp.strings.length : sp.kind === "emoji" && Array.isArray(sp.glyphs) ? sp.glyphs.length : sp.kind === "bar" && Array.isArray(sp.values) ? sp.values.length : null;
2398
- if (variants !== null && variants !== layer.count) {
2494
+ const atmosphere = sp.kind === "text" && sp.role === "atmosphere";
2495
+ if (variants !== null && variants !== layer.count && !atmosphere) {
2399
2496
  warn(`${path}.count`, "list-length-mismatch", `count is ${layer.count} but the sprite carries ${variants} ${sp.kind === "bar" ? "values" : "variants"} \u2014 a ${lay.type} layout reads them in order, so they cycle or go unused`);
2400
2497
  }
2401
2498
  }
@@ -2760,6 +2857,7 @@ function validateSprite(sprite, path, err, warn, spec) {
2760
2857
  return;
2761
2858
  }
2762
2859
  for (const k of unknownKeys(sprite, knownSet)) {
2860
+ if (k === "size") continue;
2763
2861
  if (LAYER_PROPS_ON_SPRITE.has(k)) {
2764
2862
  warn(`${path}.${k}`, "misplaced-property", `'${k}' belongs on the layer, not inside sprite \u2014 move it up one level`);
2765
2863
  } else {
@@ -3191,7 +3289,7 @@ function validateSequence(seq) {
3191
3289
  warnings.push({
3192
3290
  path: `segments[${i}].transition`,
3193
3291
  code: "morph-structural-mismatch",
3194
- message: `segments ${i}\u2192${i + 1} differ structurally: morph will fall back to cut`
3292
+ message: `segments ${i}\u2192${i + 1} differ structurally (${describeStructuralDifferences(s.scene, next.scene)}): morph will fall back to cut`
3195
3293
  });
3196
3294
  } 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" })) {
3197
3295
  warnings.push({
@@ -3210,7 +3308,7 @@ function validateSequence(seq) {
3210
3308
  if (sq.loop !== true) why = "the sequence does not loop";
3211
3309
  else if (last < 1) why = "there is only one segment";
3212
3310
  else if (lastTr?.type !== "morph") why = "the last segment declares no morph transition";
3213
- else if (structuralSignature(sq.segments[last].scene) !== structuralSignature(sq.segments[0].scene)) why = `segment ${last} and segment 0 differ structurally`;
3311
+ else if (structuralSignature(sq.segments[last].scene) !== structuralSignature(sq.segments[0].scene)) why = `segment ${last} and segment 0 differ structurally (${describeStructuralDifferences(sq.segments[last].scene, sq.segments[0].scene)})`;
3214
3312
  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`;
3215
3313
  if (why || !canWrapMorph(sq)) {
3216
3314
  warnings.push({ path: "wrapMorph", code: "wrap-morph-inactive", message: `wrapMorph has no effect: ${why ?? "the wrap cannot morph"} \u2014 the wrap stays a cut` });
@@ -4892,17 +4990,17 @@ var SpecInstance = class {
4892
4990
  const rows = Math.max(1, Math.round(this.fieldShortSide * h / short));
4893
4991
  const ft = fieldSampleTime(bg, t);
4894
4992
  const key = `${JSON.stringify(bg)}|${w}x${h}|${cols}x${rows}|${ft}`;
4895
- let raster = this.field;
4896
- if (!raster || raster.key !== key) {
4897
- if (!raster || raster.cols !== cols || raster.rows !== rows) {
4993
+ let raster2 = this.field;
4994
+ if (!raster2 || raster2.key !== key) {
4995
+ if (!raster2 || raster2.cols !== cols || raster2.rows !== rows) {
4898
4996
  const canvas = typeof document !== "undefined" ? document.createElement("canvas") : new OffscreenCanvas(cols, rows);
4899
4997
  canvas.width = cols;
4900
4998
  canvas.height = rows;
4901
4999
  const rctx = canvas.getContext("2d");
4902
5000
  if (!rctx) return;
4903
- raster = { canvas, ctx: rctx, cols, rows, key: "" };
5001
+ raster2 = { canvas, ctx: rctx, cols, rows, key: "" };
4904
5002
  }
4905
- const img = raster.ctx.createImageData(cols, rows);
5003
+ const img = raster2.ctx.createImageData(cols, rows);
4906
5004
  const data = img.data;
4907
5005
  const seed = this.seed;
4908
5006
  let i = 0;
@@ -4917,14 +5015,14 @@ var SpecInstance = class {
4917
5015
  data[i++] = 255;
4918
5016
  }
4919
5017
  }
4920
- raster.ctx.putImageData(img, 0, 0);
4921
- raster.key = key;
4922
- this.field = raster;
5018
+ raster2.ctx.putImageData(img, 0, 0);
5019
+ raster2.key = key;
5020
+ this.field = raster2;
4923
5021
  }
4924
5022
  const smooth = Math.round(bg.quantize ?? bg.bands.length) < 2;
4925
5023
  const prev = ctx.imageSmoothingEnabled;
4926
5024
  ctx.imageSmoothingEnabled = smooth;
4927
- ctx.drawImage(raster.canvas, 0, 0, w, h);
5025
+ ctx.drawImage(raster2.canvas, 0, 0, w, h);
4928
5026
  ctx.imageSmoothingEnabled = prev;
4929
5027
  }
4930
5028
  /** Position with parent-orbit resolution: a layer-parented orbit entity's
@@ -7619,6 +7717,7 @@ function polygonCellCoverage(pts, cellW, cellH, c0, c1, r0, r1, evenodd = false)
7619
7717
  }
7620
7718
  return cov;
7621
7719
  }
7720
+ var MAX_GRID_SIDE = 400;
7622
7721
  function softProfile(u) {
7623
7722
  if (u <= 0) return 1;
7624
7723
  if (u >= 1) return 0;
@@ -7654,12 +7753,13 @@ function discCellInk(cx, cy, rOuter, rInner, soft, x0, y0, cw, ch) {
7654
7753
  }
7655
7754
  return ink / (ni * nj) * sx * sy / (cw * ch);
7656
7755
  }
7657
- function luminanceGrid(spec, opts = {}) {
7756
+ function luminanceGrid(spec, opts = {}, maxSide = 200) {
7658
7757
  spec = resolveTimelineAt(spec, opts.t ?? 5e3);
7659
7758
  const scene = buildScene(spec, opts);
7660
7759
  const t = opts.t ?? 5e3;
7661
- const cols = Math.max(8, Math.min(200, opts.cols ?? 80));
7662
- const rows = Math.max(8, Math.min(200, opts.rows ?? 48));
7760
+ const cap = Math.max(8, Math.min(MAX_GRID_SIDE, maxSide));
7761
+ const cols = Math.max(8, Math.min(cap, opts.cols ?? 80));
7762
+ const rows = Math.max(8, Math.min(cap, opts.rows ?? 48));
7663
7763
  const { w, h, scale } = scene;
7664
7764
  const cellW = w / cols;
7665
7765
  const cellH = h / rows;
@@ -7673,10 +7773,22 @@ function luminanceGrid(spec, opts = {}) {
7673
7773
  const short = Math.max(1, Math.min(w, h));
7674
7774
  const seed = scene.seed;
7675
7775
  const ft = fieldSampleTime(bg, t);
7776
+ const fCols = Math.max(1, Math.round(FIELD_RASTER_SHORT_SIDE * w / short));
7777
+ const fRows = Math.max(1, Math.round(FIELD_RASTER_SHORT_SIDE * h / short));
7778
+ const viaRaster = cols > fCols || rows > fRows;
7779
+ const rasterLum = viaRaster ? new Float64Array(fCols * fRows).fill(-1) : null;
7780
+ const fieldLum = (c, r) => {
7781
+ if (!rasterLum) return rgb255Luma(fieldRgbAt((c + 0.5) * cellW / short, (r + 0.5) * cellH / short, ft, bg, seed));
7782
+ const fc = Math.min(fCols - 1, Math.floor((c + 0.5) / cols * fCols));
7783
+ const fr = Math.min(fRows - 1, Math.floor((r + 0.5) / rows * fRows));
7784
+ const k = fr * fCols + fc;
7785
+ if (rasterLum[k] < 0) rasterLum[k] = rgb255Luma(fieldRgbAt((fc + 0.5) * w / fCols / short, (fr + 0.5) * h / fRows / short, ft, bg, seed));
7786
+ return rasterLum[k];
7787
+ };
7676
7788
  for (let r = 0; r < rows; r++) {
7677
7789
  let rowSum = 0;
7678
7790
  for (let c = 0; c < cols; c++) {
7679
- const lum = rgb255Luma(fieldRgbAt((c + 0.5) * cellW / short, (r + 0.5) * cellH / short, ft, bg, seed));
7791
+ const lum = fieldLum(c, r);
7680
7792
  bgCells[r * cols + c] = lum;
7681
7793
  rowSum += lum;
7682
7794
  }
@@ -9119,6 +9231,7 @@ function edgeAnchored(range2) {
9119
9231
  function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
9120
9232
  const hidden = hiddenLayers(spec);
9121
9233
  const authored = spec;
9234
+ const keyed = keyedLayers(authored);
9122
9235
  spec = resolveTimelineAt(spec, opts.t ?? COHESION_T);
9123
9236
  const warnings = [];
9124
9237
  const w = viewport.width;
@@ -9132,18 +9245,19 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
9132
9245
  w,
9133
9246
  h
9134
9247
  );
9135
- let totalEntities = 0;
9248
+ let declaredEntities = 0;
9136
9249
  let textLayerCount = 0;
9137
9250
  let motionLayerCount = 0;
9138
9251
  for (let li = 0; li < spec.layers.length; li++) {
9139
9252
  if (hidden?.[li]) continue;
9140
9253
  const layer = spec.layers[li];
9141
9254
  const entities = allEntities[li];
9142
- totalEntities += entities.length;
9143
- const isText = layer.sprite.kind === "text" || layer.sprite.kind === "textBlock";
9144
- const isStaticText = isText && layer.motion.type === "static";
9255
+ declaredEntities += layer.count;
9256
+ const isText2 = layer.sprite.kind === "text" || layer.sprite.kind === "textBlock";
9257
+ const isKeyed = !!keyed?.[li];
9258
+ const isStaticText = isText2 && layer.motion.type === "static" && !isKeyed;
9145
9259
  if (isStaticText) textLayerCount++;
9146
- if (layer.motion.type !== "static") motionLayerCount++;
9260
+ if (layer.motion.type !== "static" || isKeyed) motionLayerCount++;
9147
9261
  const coh = cohesion[li];
9148
9262
  if (seamsWorthWarning(layer, coh, entities.length, () => buriedAreaOf(layer, entities, opts.t ?? COHESION_T, w, h))) {
9149
9263
  const s = layer.sprite;
@@ -9168,13 +9282,26 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
9168
9282
  message: `${layer.sprite.kind === "streak" ? "streak sprites orient" : "an oriented stroke turns"} along the motion heading \u2014 static entities have none, so the heading contributes nothing and it renders at angle 0`
9169
9283
  });
9170
9284
  }
9171
- if (layer.motion.type === "wander" && layer.motion.coherence === 1 && entities.length >= 20) {
9285
+ if (layer.motion.type === "wander" && layer.motion.coherence === 1 && layer.count >= 20) {
9172
9286
  warnings.push({
9173
9287
  path: `layers[${li}].motion.coherence`,
9174
9288
  code: "full-coherence",
9175
9289
  message: "coherence 1 makes every entity share identical harmonics \u2014 the layer moves as a rigid sheet. 0.5\u20130.8 reads as schooling"
9176
9290
  });
9177
9291
  }
9292
+ if (layer.sprite.kind === "emoji" || layer.sprite.kind === "text") {
9293
+ const vpMin = Math.min(w, h);
9294
+ const fontPx = layer.sprite.kind === "text" && layer.sprite.font ? /(\d{1,5}(?:\.\d{1,4})?|\.\d{1,4})px/.exec(layer.sprite.font) : null;
9295
+ const maxGlyph = fontPx ? Number(fontPx[1]) * (spec.units === "px" ? 1 : vpMin / (spec.referenceViewport ?? LIMITS.referenceViewport)) : entities.reduce((m, e) => Math.max(m, e.size), 0);
9296
+ if (maxGlyph > vpMin) {
9297
+ const unitHint = spec.units === "px" ? 'sizes are px under units: "px"' : `sizes are fractions of min(width, height) \u2014 ${fontPx ? "the font's px size is rescaled from the reference viewport" : "write e.g. [0.03, 0.06], not px"}`;
9298
+ warnings.push({
9299
+ path: `layers[${li}]`,
9300
+ code: "giant-entity",
9301
+ message: `layer ${layer.key ? `'${layer.key}'` : li} renders ${layer.sprite.kind} glyphs up to ${Math.round(maxGlyph)}px \u2014 taller than the ${vpMin}px viewport (its min dimension), so they blanket the frame as blurred blobs; ${unitHint}`
9302
+ });
9303
+ }
9304
+ }
9178
9305
  if (layer.sprite.kind === "circle") {
9179
9306
  const maxR = entities.reduce((m, e) => Math.max(m, e.size / 2), 0);
9180
9307
  const maxAlpha = entities.reduce((m, e) => Math.max(m, e.alpha), 0);
@@ -9287,11 +9414,11 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
9287
9414
  message: `"lighter"/"screen" layers stack ${additiveInk.toFixed(1)}\xD7 the frame's area in alpha-weighted light \u2014 overlapping additive sprites clip to white on a real canvas, and the analytic perception model sums them without a ceiling so its coverage/luminance/dominance numbers cannot see it. Thin the count, lower alpha (summed additive alpha at any point \u2272 0.5), or drop blend on the densest layer`
9288
9415
  });
9289
9416
  }
9290
- if (totalEntities > 500 && density !== "dense") {
9417
+ if (declaredEntities > 500 && density !== "dense") {
9291
9418
  warnings.push({
9292
9419
  path: "layers",
9293
9420
  code: "dense-scene",
9294
- message: `${totalEntities} entities \u2014 scene may feel crowded and hurt performance on low-end devices`
9421
+ message: `${declaredEntities} entities \u2014 scene may feel crowded and hurt performance on low-end devices`
9295
9422
  });
9296
9423
  }
9297
9424
  if (density === "sparse" && totalCoverage > SPARSE_DECLARED_MAX_COVERAGE) {
@@ -9333,12 +9460,12 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
9333
9460
  });
9334
9461
  }
9335
9462
  }
9336
- const flicker = nearLimitFlash(authored);
9337
- if (flicker) {
9463
+ const flicker2 = nearLimitFlash(authored);
9464
+ if (flicker2) {
9338
9465
  warnings.push({
9339
9466
  path: "timeline",
9340
9467
  code: "flash-near-limit",
9341
- message: `the timeline flickers ${flicker.flashesPerSecond} times a second over ${Math.round(flicker.area * 100)}% of the frame around ${Math.round(flicker.at)} ms \u2014 at or near WCAG 2.3.1's limit of 3; space opposing luminance changes \u2265 500 ms apart, or lower the contrast between the two states`
9468
+ message: `the timeline flickers ${flicker2.flashesPerSecond} times a second over ${Math.round(flicker2.area * 100)}% of the frame around ${Math.round(flicker2.at)} ms \u2014 at or near WCAG 2.3.1's limit of 3; space opposing luminance changes \u2265 500 ms apart, or lower the contrast between the two states`
9342
9469
  });
9343
9470
  }
9344
9471
  if (authored.timeline?.loop && (authored.timeline.duration ?? 0) > 0) {
@@ -9356,6 +9483,21 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
9356
9483
  });
9357
9484
  }
9358
9485
  }
9486
+ if (authored.timeline?.loop && (authored.timeline.duration ?? 0) > 0) {
9487
+ const lap = Math.round(authored.timeline.duration);
9488
+ for (let li = 0; li < authored.layers.length; li++) {
9489
+ if (hidden?.[li]) continue;
9490
+ const layer = authored.layers[li];
9491
+ const life = layer.life;
9492
+ if (!life || life.enter === void 0 && life.exit === void 0 && !life.stagger) continue;
9493
+ const label = layer.key ? `\`${layer.key}\`` : `layers[${li}]`;
9494
+ warnings.push({
9495
+ path: `layers[${li}].life`,
9496
+ code: "life-runs-once",
9497
+ message: `informational: ${label}'s \`life\` runs once on scene time, from when the scene (or its sequence segment) starts \u2014 it does not replay when the timeline loops every ${lap} ms${life.exit !== void 0 ? ", so after its exit the layer stays gone for every later lap" : ""}; to build it on every lap, key its \`opacity\` in the timeline instead`
9498
+ });
9499
+ }
9500
+ }
9359
9501
  for (let li = 0; li < spec.layers.length; li++) {
9360
9502
  if (hidden?.[li]) continue;
9361
9503
  const layer = spec.layers[li];
@@ -9379,7 +9521,7 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
9379
9521
  if (layer.motion.type === "static") continue;
9380
9522
  const entities = allEntities[li];
9381
9523
  if (entities.length < 3) continue;
9382
- const speeds = entities.map((e) => Math.sqrt(e.vx * e.vx + e.vy * e.vy));
9524
+ const speeds = layer.motion.type === "orbit" ? entities.map((e) => Math.abs(e.vx) * (Math.PI / 180) * e.orbitR) : entities.map((e) => Math.sqrt(e.vx * e.vx + e.vy * e.vy));
9383
9525
  const mean = speeds.reduce((a, b) => a + b, 0) / speeds.length;
9384
9526
  if (mean === 0) continue;
9385
9527
  const maxDev = Math.max(...speeds.map((s) => Math.abs(s - mean)));
@@ -9413,29 +9555,7 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
9413
9555
  }
9414
9556
  }
9415
9557
  }
9416
- const textBoxes = [];
9417
- for (let li = 0; li < spec.layers.length; li++) {
9418
- if (hidden?.[li]) continue;
9419
- const layer = spec.layers[li];
9420
- const s = layer.sprite;
9421
- if (layer.motion.type !== "static") continue;
9422
- if (s.kind !== "text" && s.kind !== "textBlock") continue;
9423
- const label = layer.key ? `\`${layer.key}\`` : `layers[${li}]`;
9424
- for (const e of allEntities[li]) {
9425
- const p = positionAt(e, 0, w, h);
9426
- const unturned = s.kind === "textBlock" ? textBlockBoxAt(s, p, w, h) : textBoxAt(s, e, p, spec, w, h);
9427
- const rot = rotationAt(e, 0);
9428
- const turn = (q) => ({ x: p.x + (q.x - p.x) * Math.cos(rot) - (q.y - p.y) * Math.sin(rot), y: p.y + (q.x - p.x) * Math.sin(rot) + (q.y - p.y) * Math.cos(rot) });
9429
- const map = paintMap(spec, layer, w, h, scale);
9430
- const both = rot && map ? (q) => map(turn(q)) : rot ? turn : map;
9431
- const box = both ? { ...unturned, ...mapBox(both, unturned) } : unturned;
9432
- const raw = rot ? { ...unturned, ...mapBox(turn, unturned) } : unturned;
9433
- const baseAlpha = Math.max(0, Math.min(1, e.alpha - e.pulseAmp)) * (s.kind === "textBlock" ? s.opacity ?? 1 : 1);
9434
- const alpha = layer.opacity === void 0 && layer.group === void 0 ? baseAlpha : baseAlpha * paintOpacity(spec, layer);
9435
- const str = s.kind === "textBlock" ? textBlockText(s) : s.strings[e.spriteIndex] ?? s.strings[0] ?? "";
9436
- textBoxes.push({ li, label, alpha, str, ...box, rawW: raw.x1 - raw.x0, rawH: raw.y1 - raw.y0 });
9437
- }
9438
- }
9558
+ const textBoxes = staticTextBoxes(spec, allEntities, hidden, w, h, scale);
9439
9559
  const asFraction = (b) => ({
9440
9560
  x: +(b.x0 / w).toFixed(4),
9441
9561
  y: +(b.y0 / h).toFixed(4),
@@ -9460,34 +9580,46 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
9460
9580
  message: `${b.label} text runs off the ${edges.join(" and the ")} at ${w}\xD7${h} \u2014 move position, or shrink fontSize / maxWidth`
9461
9581
  });
9462
9582
  }
9463
- const pairFlagged = /* @__PURE__ */ new Set();
9464
- for (let i = 0; i < textBoxes.length; i++) {
9465
- for (let j = i + 1; j < textBoxes.length; j++) {
9466
- const a = textBoxes[i];
9467
- const b = textBoxes[j];
9468
- if (a.li === b.li) continue;
9469
- const pair = `${Math.min(a.li, b.li)}:${Math.max(a.li, b.li)}`;
9470
- if (pairFlagged.has(pair)) continue;
9471
- const ix = Math.min(a.x1, b.x1) - Math.max(a.x0, b.x0);
9472
- const iy = Math.min(a.y1, b.y1) - Math.max(a.y0, b.y0);
9473
- if (ix <= 0 || iy <= 0) continue;
9474
- const smaller = Math.min((a.x1 - a.x0) * (a.y1 - a.y0), (b.x1 - b.x0) * (b.y1 - b.y0));
9475
- if (smaller <= 0) continue;
9476
- const share = ix * iy / smaller;
9477
- if (share <= 0.1) continue;
9478
- if (isOutlineStack(a, b)) continue;
9479
- pairFlagged.add(pair);
9480
- const [first, second] = a.li < b.li ? [a, b] : [b, a];
9481
- warnings.push({
9482
- path: `layers[${first.li}]`,
9483
- code: "text-overlap",
9484
- message: `${first.label} and ${second.label} text boxes overlap (~${Math.round(share * 100)}% of the smaller one) \u2014 they will paint over each other; move one or shrink it`,
9485
- // The two boxes, so an agent can move one without re-deriving the
9486
- // layout. Additive: the message is unchanged.
9487
- boxes: [asFraction(first), asFraction(second)]
9583
+ const overlapSamples = textOverlapSamples(authored, spec, allEntities, keyed, hidden, opts.t ?? COHESION_T, opts.until, opts.seed ?? spec.seed ?? 42, w, h);
9584
+ const overlapAt = /* @__PURE__ */ new Map();
9585
+ if (overlapSamples) {
9586
+ const pairsIn = (boxes, each) => {
9587
+ for (let i = 0; i < boxes.length; i++) {
9588
+ for (let j = i + 1; j < boxes.length; j++) {
9589
+ const a = boxes[i];
9590
+ const b = boxes[j];
9591
+ if (a.li === b.li) continue;
9592
+ const share = overlapShare(a, b);
9593
+ if (share <= 0.1 || isOutlineStack(a, b)) continue;
9594
+ each(`${Math.min(a.li, b.li)}:${Math.max(a.li, b.li)}`, a, b, share);
9595
+ }
9596
+ }
9597
+ };
9598
+ const raised = /* @__PURE__ */ new Set();
9599
+ pairsIn(overlapSamples.now, (pair) => raised.add(pair));
9600
+ for (const sample of overlapSamples.visible) {
9601
+ pairsIn(sample.boxes, (pair, a, b, share) => {
9602
+ if (!raised.has(pair) || overlapAt.has(pair)) return;
9603
+ overlapAt.set(pair, { share, a, b, t: sample.t });
9488
9604
  });
9489
9605
  }
9490
9606
  }
9607
+ for (const pair of [...overlapAt.keys()].sort((x, y) => {
9608
+ const [x0, x1] = x.split(":").map(Number);
9609
+ const [y0, y1] = y.split(":").map(Number);
9610
+ return x0 - y0 || x1 - y1;
9611
+ })) {
9612
+ const { share, a, b, t } = overlapAt.get(pair);
9613
+ const [first, second] = a.li < b.li ? [a, b] : [b, a];
9614
+ warnings.push({
9615
+ path: `layers[${first.li}]`,
9616
+ code: "text-overlap",
9617
+ message: `${first.label} and ${second.label} text boxes overlap (~${Math.round(share * 100)}% of the smaller one)${t === null ? "" : `, both visible at ${Math.round(t)} ms`} \u2014 they will paint over each other; move one or shrink it`,
9618
+ // The two boxes, so an agent can move one without re-deriving the
9619
+ // layout. Additive: the message is unchanged.
9620
+ boxes: [asFraction(first), asFraction(second)]
9621
+ });
9622
+ }
9491
9623
  const cellFlagged = /* @__PURE__ */ new Set();
9492
9624
  for (let i = 0; i < textBoxes.length; i++) {
9493
9625
  const a = textBoxes[i];
@@ -9495,6 +9627,7 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
9495
9627
  const layer = spec.layers[a.li];
9496
9628
  const layout = layer.layout;
9497
9629
  if (layout?.type !== "list" && layout?.type !== "table") continue;
9630
+ if (isAtmosphere(layer)) continue;
9498
9631
  for (let j = i + 1; j < textBoxes.length; j++) {
9499
9632
  const b = textBoxes[j];
9500
9633
  if (b.li !== a.li) continue;
@@ -9601,6 +9734,120 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
9601
9734
  }
9602
9735
  return warnings;
9603
9736
  }
9737
+ function isAtmosphere(layer) {
9738
+ const s = layer.sprite;
9739
+ return (s.kind === "text" || s.kind === "textBlock") && s.role === "atmosphere";
9740
+ }
9741
+ function staticTextBoxes(spec, allEntities, hidden, w, h, scale, only) {
9742
+ const textBoxes = [];
9743
+ for (let li = 0; li < spec.layers.length; li++) {
9744
+ if (hidden?.[li]) continue;
9745
+ if (only && !only.has(li)) continue;
9746
+ const layer = spec.layers[li];
9747
+ const s = layer.sprite;
9748
+ if (layer.motion.type !== "static") continue;
9749
+ if (s.kind !== "text" && s.kind !== "textBlock") continue;
9750
+ const label = layer.key ? `\`${layer.key}\`` : `layers[${li}]`;
9751
+ for (const e of allEntities[li]) {
9752
+ const p = positionAt(e, 0, w, h);
9753
+ const unturned = s.kind === "textBlock" ? textBlockBoxAt(s, p, w, h) : textBoxAt(s, e, p, spec, w, h);
9754
+ const rot = rotationAt(e, 0);
9755
+ const turn = (q) => ({ x: p.x + (q.x - p.x) * Math.cos(rot) - (q.y - p.y) * Math.sin(rot), y: p.y + (q.x - p.x) * Math.sin(rot) + (q.y - p.y) * Math.cos(rot) });
9756
+ const map = paintMap(spec, layer, w, h, scale);
9757
+ const both = rot && map ? (q) => map(turn(q)) : rot ? turn : map;
9758
+ const box = both ? { ...unturned, ...mapBox(both, unturned) } : unturned;
9759
+ const raw = rot ? { ...unturned, ...mapBox(turn, unturned) } : unturned;
9760
+ const baseAlpha = Math.max(0, Math.min(1, e.alpha - e.pulseAmp)) * (s.kind === "textBlock" ? s.opacity ?? 1 : 1);
9761
+ const alpha = layer.opacity === void 0 && layer.group === void 0 ? baseAlpha : baseAlpha * paintOpacity(spec, layer);
9762
+ const str = s.kind === "textBlock" ? textBlockText(s) : s.strings[e.spriteIndex] ?? s.strings[0] ?? "";
9763
+ textBoxes.push({ li, label, alpha, peak: Math.min(1, e.alpha + e.pulseAmp), str, ...box, rawW: raw.x1 - raw.x0, rawH: raw.y1 - raw.y0 });
9764
+ }
9765
+ }
9766
+ return textBoxes;
9767
+ }
9768
+ function keyedLayers(spec) {
9769
+ if (!spec.timeline) return null;
9770
+ const paths = [...timelineTracks(spec).keys()];
9771
+ if (paths.length === 0) return null;
9772
+ return spec.layers.map((l, i) => paths.some((p) => keyTouchesLayer(p, i, l)));
9773
+ }
9774
+ function keyTouchesLayer(path, i, layer) {
9775
+ const lp = `layers.${i}`;
9776
+ if (path === "layers" || path === lp || path.startsWith(`${lp}.`)) return true;
9777
+ if (path === "referenceViewport" || path === "units") return true;
9778
+ if (layer.group === void 0) return false;
9779
+ const gp = `groups.${layer.group}`;
9780
+ return path === "groups" || path === gp || path.startsWith(`${gp}.`);
9781
+ }
9782
+ var TEXT_VISIBLE_FLOOR = 0.02;
9783
+ function paintAxisScale(spec, layer) {
9784
+ const axes = (tf) => {
9785
+ const s = Math.abs(tf?.scale ?? 1);
9786
+ return [s * Math.abs(tf?.scaleX ?? 1), s * Math.abs(tf?.scaleY ?? 1)];
9787
+ };
9788
+ const [lx, ly] = axes(layer.transform);
9789
+ const [gx, gy] = axes(groupOf(spec, layer)?.transform);
9790
+ return Math.min(lx * gx, ly * gy);
9791
+ }
9792
+ var TEXT_OVERLAP_MAX_SAMPLES = 48;
9793
+ function textOverlapSamples(authored, spec, allEntities, keyed, hidden, tNow, until, seed, w, h) {
9794
+ const candidates = /* @__PURE__ */ new Set();
9795
+ spec.layers.forEach((l, li) => {
9796
+ const k = l.sprite.kind;
9797
+ if (!hidden?.[li] && (k === "text" || k === "textBlock") && !isAtmosphere(l)) candidates.add(li);
9798
+ });
9799
+ if (candidates.size < 2) return null;
9800
+ const visibleAt = (t, at, entities, boxes) => {
9801
+ const { scale } = entityScales(at, w, h);
9802
+ return (boxes ?? staticTextBoxes(at, entities, hidden, w, h, scale, candidates)).filter((b) => textBoxVisible(at, at.layers[b.li], b, t, w, h, scale));
9803
+ };
9804
+ const now = staticTextBoxes(spec, allEntities, hidden, w, h, entityScales(spec, w, h).scale, candidates);
9805
+ const visible = [{ t: null, boxes: visibleAt(tNow, spec, allEntities, now) }];
9806
+ if (keyed && [...candidates].some((li) => keyed[li])) {
9807
+ const touches = (path) => [...candidates].some((li) => keyTouchesLayer(path, li, authored.layers[li]));
9808
+ let times = timelineSampleTimes(authored, false, 120, touches).filter((t) => t !== tNow && (until === void 0 || t <= until));
9809
+ if (times.length > TEXT_OVERLAP_MAX_SAMPLES) {
9810
+ const step = times.length / TEXT_OVERLAP_MAX_SAMPLES;
9811
+ times = Array.from({ length: TEXT_OVERLAP_MAX_SAMPLES }, (_, i) => times[Math.floor(i * step)]);
9812
+ }
9813
+ const built = /* @__PURE__ */ new Map([[structuralSignature(spec), allEntities]]);
9814
+ for (const t of times) {
9815
+ const at = resolveTimelineAt(authored, t, true);
9816
+ const sig = structuralSignature(at);
9817
+ let entities = built.get(sig);
9818
+ if (!entities) {
9819
+ const sc = entityScales(at, w, h);
9820
+ const rng = createRng3(seed);
9821
+ entities = at.layers.map((l) => buildEntities(l, rng, w, h, sc.scale, sc.countScale));
9822
+ built.set(sig, entities);
9823
+ }
9824
+ visible.push({ t, boxes: visibleAt(t, at, entities) });
9825
+ }
9826
+ }
9827
+ return { now, visible };
9828
+ }
9829
+ function textBoxVisible(spec, layer, b, t, w, h, scale) {
9830
+ if (b.x1 <= 0 || b.x0 >= w || b.y1 <= 0 || b.y0 >= h) return false;
9831
+ if (paintAxisScale(spec, layer) <= TEXT_VISIBLE_FLOOR) return false;
9832
+ const s = layer.sprite;
9833
+ let a = paintOpacity(spec, layer) * b.peak;
9834
+ if (s.kind === "textBlock") {
9835
+ a *= s.opacity ?? 1;
9836
+ if (s.reveal) {
9837
+ const fsPx = s.fontSize * scale;
9838
+ const lines = breakTextBlock(textBlockText(s), s.maxWidth * scale / fsPx, textMetricsClassFor(s.font));
9839
+ if (revealState(lines, s.reveal, t).progress <= 0) return false;
9840
+ }
9841
+ }
9842
+ return a > TEXT_VISIBLE_FLOOR;
9843
+ }
9844
+ function overlapShare(a, b) {
9845
+ const ix = Math.min(a.x1, b.x1) - Math.max(a.x0, b.x0);
9846
+ const iy = Math.min(a.y1, b.y1) - Math.max(a.y0, b.y0);
9847
+ if (ix <= 0 || iy <= 0) return 0;
9848
+ const smaller = Math.min((a.x1 - a.x0) * (a.y1 - a.y0), (b.x1 - b.x0) * (b.y1 - b.y0));
9849
+ return smaller > 0 ? ix * iy / smaller : 0;
9850
+ }
9604
9851
  function nearLimitFlash(spec) {
9605
9852
  if (!spec.timeline) return null;
9606
9853
  const { times, lap } = lumaKeyTimes(spec);
@@ -9682,7 +9929,7 @@ function groundUnder(spec, allEntities, li, hidden, pt, w, h, base = backgroundR
9682
9929
  if (layer.life !== void 0 || layer.emit !== void 0) continue;
9683
9930
  if ((sp.kind === "circle" || sp.kind === "polygon") && sp.soft) continue;
9684
9931
  if (sp.kind !== "circle" && sp.kind !== "rect" && sp.kind !== "polygon") continue;
9685
- if (sp.kind === "polygon" && isPathPolygon(sp)) continue;
9932
+ const drawing = sp.kind === "polygon" && isPathPolygon(sp);
9686
9933
  const a = layer.opacity === void 0 && layer.group === void 0 ? 1 : paintOpacity(spec, layer);
9687
9934
  for (const e of allEntities[lj]) {
9688
9935
  if (e.spinSpeed !== 0 || e.pulseAmp > 0 || e.growAmp > 0 || e.alpha * a <= 0) continue;
@@ -9691,6 +9938,14 @@ function groundUnder(spec, allEntities, li, hidden, pt, w, h, base = backgroundR
9691
9938
  const c = positionAt(e, 0, w, h);
9692
9939
  const dx = pt.x - c.x;
9693
9940
  const dy = pt.y - c.y;
9941
+ if (drawing) {
9942
+ if (e.rotate) continue;
9943
+ const ink = pathGroundAt(sp, e, dx, dy, w, h);
9944
+ if (!ink) continue;
9945
+ plate = sourceOverPlate(plate, ink.rgb, Math.min(1, e.alpha * a * ink.opacity));
9946
+ covered = true;
9947
+ continue;
9948
+ }
9694
9949
  let inside2 = false;
9695
9950
  if (sp.kind === "circle") inside2 = Math.hypot(dx, dy) <= e.size / 2;
9696
9951
  else if (sp.kind === "rect") {
@@ -9711,6 +9966,41 @@ function groundUnder(spec, allEntities, li, hidden, pt, w, h, base = backgroundR
9711
9966
  }
9712
9967
  return covered ? { rgb: plate, luma: 0.2126 * plate.r + 0.7152 * plate.g + 0.0722 * plate.b } : null;
9713
9968
  }
9969
+ function pathGroundAt(sp, e, dx, dy, w, h) {
9970
+ const base = sp.colors?.[e.colorIndex] ?? sp.color;
9971
+ const fr = pathFrame(sp, e.size / 2, w, h);
9972
+ const u = dx / fr.sx + fr.ox;
9973
+ const v = dy / fr.sy + fr.oy;
9974
+ let r = 0, g = 0, b = 0, wsum = 0, area = 0;
9975
+ for (const part of pathParts(sp)) {
9976
+ if (!part.fill || part.opacity <= 0 || part.flat.points.length < 3) continue;
9977
+ const box = part.box;
9978
+ if (Math.abs(u - box.cx) > box.hw || Math.abs(v - box.cy) > box.hh) continue;
9979
+ const ring = part.flat.points.map(([x, y]) => ({ x, y }));
9980
+ if (!pointInPolygon(u, v, ring)) continue;
9981
+ const pc = hexRgb(part.color ?? base);
9982
+ let rgb = pc;
9983
+ if (part.gradient) {
9984
+ const weights = gradientStopWeights(part.gradient.stops);
9985
+ rgb = { r: 0, g: 0, b: 0 };
9986
+ part.gradient.stops.forEach((st, i) => {
9987
+ const o = st.opacity ?? 1;
9988
+ const c = hexRgb(st.color);
9989
+ const k = weights[i];
9990
+ rgb = { r: rgb.r + (c.r * o + pc.r * (1 - o)) * k, g: rgb.g + (c.g * o + pc.g * (1 - o)) * k, b: rgb.b + (c.b * o + pc.b * (1 - o)) * k };
9991
+ });
9992
+ }
9993
+ const pa = Math.max(1e-6, polygonArea(ring));
9994
+ const wt = pa * part.opacity;
9995
+ r += rgb.r * wt;
9996
+ g += rgb.g * wt;
9997
+ b += rgb.b * wt;
9998
+ wsum += wt;
9999
+ area += pa;
10000
+ }
10001
+ if (wsum <= 0 || area <= 0) return null;
10002
+ return { rgb: { r: r / wsum, g: g / wsum, b: b / wsum }, opacity: Math.min(1, wsum / area) };
10003
+ }
9714
10004
  function pointInPolygon(x, y, pts) {
9715
10005
  let inside2 = false;
9716
10006
  for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
@@ -9792,7 +10082,7 @@ function adviseSequence(seq, viewport = { width: 1920, height: 1080 }) {
9792
10082
  const seg = seq.segments[i];
9793
10083
  const timed = !!seg.scene.timeline && seg.duration !== void 0;
9794
10084
  const tSample = timed ? Math.min(COHESION_T, seg.duration) : void 0;
9795
- const segWarnings = adviseSpec(seg.scene, viewport, tSample === void 0 ? {} : { t: tSample });
10085
+ const segWarnings = adviseSpec(seg.scene, viewport, tSample === void 0 ? {} : { t: tSample, until: seg.duration });
9796
10086
  const at = tSample === void 0 ? "" : ` (judged ${(tSample / 1e3).toFixed(1)} s into this ${(seg.duration / 1e3).toFixed(1)} s segment)`;
9797
10087
  for (const w of segWarnings) {
9798
10088
  warnings.push({ path: `segments[${i}].scene.${w.path}`, code: w.code, message: w.message + at, boxes: w.boxes });
@@ -9829,7 +10119,7 @@ function adviseSequence(seq, viewport = { width: 1920, height: 1080 }) {
9829
10119
  warnings.push({
9830
10120
  path: `segments[${i}].transition`,
9831
10121
  code: "morph-structural-mismatch",
9832
- message: `segments ${i}\u2192${i + 1} differ structurally: morph will fall back to cut`
10122
+ message: `segments ${i}\u2192${i + 1} differ structurally (${describeStructuralDifferences(seq.segments[i].scene, seq.segments[i + 1].scene)}): morph will fall back to cut`
9833
10123
  });
9834
10124
  } 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" })) {
9835
10125
  warnings.push({
@@ -9934,6 +10224,418 @@ function describeScene(spec, options = {}) {
9934
10224
  };
9935
10225
  }
9936
10226
 
10227
+ // src/pattern-hazard.ts
10228
+ import { FLASH_DARK_MAX, FLASH_MAX_PER_SECOND, FLASH_SWING_THRESHOLD, srgbToLinear as srgbToLinear4 } from "@idle-screens/validator";
10229
+ var PATTERN_GRID_SHORT_SIDE = 216;
10230
+ var PATTERN_GRID_MAX_SIDE = 400;
10231
+ var PATTERN_MIN_PAIRS = 6;
10232
+ var PATTERN_STATIONARY_AREA = 0.4;
10233
+ var PATTERN_MOVING_AREA = 0.25;
10234
+ var PATTERN_FLICKER_AREA = 0.25;
10235
+ var PATTERN_MAX_SAMPLE_TIMES = 6;
10236
+ var PERIOD_RATIO = 4 / 3;
10237
+ var PATTERN_MAX_PITCH = 0.15;
10238
+ var PATTERN_MIN_EXTENT = 2;
10239
+ var NEIGHBOUR_PITCH = 0.25;
10240
+ var BLOCK = 4;
10241
+ var DELTA_MS = 1e3 / 60;
10242
+ var FLICKER_PERIODS = 4;
10243
+ var BASE_T = 5e3;
10244
+ var isText = (l) => l.sprite.kind === "text" || l.sprite.kind === "textBlock";
10245
+ function prepared(spec, t, keep, flat) {
10246
+ const resolved = resolveTimelineAt(spec, t);
10247
+ const out = { ...resolved, layers: resolved.layers.map((l, i) => keep(l, i) && !isText(l) ? l : { ...l, opacity: 0 }) };
10248
+ delete out.ghosting;
10249
+ if (flat !== void 0) {
10250
+ const v = Math.round(Math.max(0, Math.min(1, flat)) * 255).toString(16).padStart(2, "0");
10251
+ out.background = { type: "solid", color: `#${v}${v}${v}` };
10252
+ }
10253
+ return out;
10254
+ }
10255
+ function gridSize(viewport) {
10256
+ const { width: w, height: h } = viewport;
10257
+ const short = Math.max(1, Math.min(w, h));
10258
+ const fit = (side) => Math.max(8, Math.min(PATTERN_GRID_MAX_SIDE, Math.round(side / short * PATTERN_GRID_SHORT_SIDE)));
10259
+ return { cols: fit(w), rows: fit(h) };
10260
+ }
10261
+ function raster(spec, t, opts) {
10262
+ const { cols, rows } = gridSize(opts.viewport);
10263
+ const g = luminanceGrid(spec, { viewport: opts.viewport, seed: opts.seed, t, cols, rows }, PATTERN_GRID_MAX_SIDE);
10264
+ const lum = new Float64Array(g.cells.length);
10265
+ for (let i = 0; i < lum.length; i++) lum[i] = srgbToLinear4(g.cells[i] * 255);
10266
+ const short = Math.min(opts.viewport.width, opts.viewport.height);
10267
+ const bg = g.backgroundCells ?? g.background;
10268
+ return {
10269
+ cols: g.cols,
10270
+ rows: g.rows,
10271
+ cellX: opts.viewport.width / g.cols / short,
10272
+ cellY: opts.viewport.height / g.rows / short,
10273
+ lum,
10274
+ meanGround: bg.reduce((a, b) => a + b, 0) / Math.max(1, bg.length)
10275
+ };
10276
+ }
10277
+ function stripeExtrema(line) {
10278
+ const n = line.length;
10279
+ const out = [];
10280
+ if (n < 3) return out;
10281
+ let lo = line[0], loAt = 0, hi = line[0], hiAt = 0;
10282
+ let dir = 0;
10283
+ let cand = 0, candFrom = 0, candTo = 0;
10284
+ for (let i = 1; i < n; i++) {
10285
+ const v = line[i];
10286
+ if (dir === 0) {
10287
+ if (v > hi) {
10288
+ hi = v;
10289
+ hiAt = i;
10290
+ }
10291
+ if (v < lo) {
10292
+ lo = v;
10293
+ loAt = i;
10294
+ }
10295
+ if (hi - lo >= FLASH_SWING_THRESHOLD && lo < FLASH_DARK_MAX) {
10296
+ if (loAt < hiAt) {
10297
+ out.push({ x: loAt, v: lo, light: false });
10298
+ dir = 1;
10299
+ cand = hi;
10300
+ candFrom = candTo = hiAt;
10301
+ } else {
10302
+ out.push({ x: hiAt, v: hi, light: true });
10303
+ dir = -1;
10304
+ cand = lo;
10305
+ candFrom = candTo = loAt;
10306
+ }
10307
+ }
10308
+ continue;
10309
+ }
10310
+ if (dir === 1 ? v > cand : v < cand) {
10311
+ cand = v;
10312
+ candFrom = candTo = i;
10313
+ continue;
10314
+ }
10315
+ if (v === cand) {
10316
+ if (candTo === i - 1) candTo = i;
10317
+ continue;
10318
+ }
10319
+ const swing = dir === 1 ? cand - v : v - cand;
10320
+ if (swing >= FLASH_SWING_THRESHOLD && Math.min(cand, v) < FLASH_DARK_MAX) {
10321
+ out.push({ x: (candFrom + candTo) / 2, v: cand, light: dir === 1 });
10322
+ dir = -dir;
10323
+ cand = v;
10324
+ candFrom = candTo = i;
10325
+ }
10326
+ }
10327
+ return out;
10328
+ }
10329
+ function similarSpacing(a, b, frac) {
10330
+ return Math.abs(a - b) <= 1 + frac * Math.min(a, b);
10331
+ }
10332
+ function stripeRuns(ext, line, axis) {
10333
+ const runs = [];
10334
+ for (const light of [true, false]) {
10335
+ const idx = [];
10336
+ ext.forEach((e, i) => {
10337
+ if (e.light === light) idx.push(i);
10338
+ });
10339
+ let start = 0;
10340
+ const close = (end) => {
10341
+ const count = end - start + 1;
10342
+ if (count >= PATTERN_MIN_PAIRS) {
10343
+ const xs = idx.slice(start, end + 1).map((i) => ext[i].x);
10344
+ const periods = xs.slice(1).map((x, k) => x - xs[k]).sort((a, b) => a - b);
10345
+ const first = idx[start], last = idx[end];
10346
+ let swing = 0, pairs = 0;
10347
+ for (let k = Math.max(0, first - 1); k < Math.min(ext.length - 1, last + 1); k++) {
10348
+ swing += Math.abs(ext[k + 1].v - ext[k].v);
10349
+ pairs++;
10350
+ }
10351
+ runs.push({ line, axis, from: xs[0], to: xs[xs.length - 1], period: periods[periods.length >> 1], count, light, contrast: pairs ? swing / pairs : 0 });
10352
+ }
10353
+ };
10354
+ for (let k = 1; k < idx.length; k++) {
10355
+ if (k >= 2) {
10356
+ const p0 = ext[idx[k - 1]].x - ext[idx[k - 2]].x;
10357
+ const p1 = ext[idx[k]].x - ext[idx[k - 1]].x;
10358
+ if (!similarSpacing(p0, p1, PERIOD_RATIO - 1)) {
10359
+ close(k - 1);
10360
+ start = k - 1;
10361
+ }
10362
+ }
10363
+ }
10364
+ if (idx.length) close(idx.length - 1);
10365
+ }
10366
+ return runs;
10367
+ }
10368
+ function scanRaster(r) {
10369
+ const rowLine = new Float64Array(r.cols);
10370
+ const colLine = new Float64Array(r.rows);
10371
+ const perLine = (axis, n) => {
10372
+ const lines = [];
10373
+ for (let k = 0; k < n; k++) {
10374
+ if (axis === "x") {
10375
+ for (let c = 0; c < r.cols; c++) rowLine[c] = r.lum[k * r.cols + c];
10376
+ } else {
10377
+ for (let q = 0; q < r.rows; q++) colLine[q] = r.lum[q * r.cols + k];
10378
+ }
10379
+ const line = axis === "x" ? rowLine : colLine;
10380
+ lines.push(stripeRuns(stripeExtrema(line), k, axis));
10381
+ }
10382
+ return lines;
10383
+ };
10384
+ const all = [perLine("x", r.rows), perLine("y", r.cols)];
10385
+ const kept = [];
10386
+ for (const lines of all) {
10387
+ const flat = lines.flat();
10388
+ const at = /* @__PURE__ */ new Map();
10389
+ flat.forEach((run, i) => at.set(run, i));
10390
+ const parent = flat.map((_, i) => i);
10391
+ const find = (i) => {
10392
+ while (parent[i] !== i) i = parent[i] = parent[parent[i]];
10393
+ return i;
10394
+ };
10395
+ const across = (run) => run.period * (run.axis === "x" ? r.cellX / r.cellY : r.cellY / r.cellX);
10396
+ for (const run of flat) {
10397
+ const reach = Math.max(1, Math.ceil(across(run)));
10398
+ for (let k = run.line + 1; k <= run.line + reach && k < lines.length; k++) {
10399
+ for (const q of lines[k]) {
10400
+ const overlap = Math.min(run.to, q.to) - Math.max(run.from, q.from);
10401
+ if (overlap >= 0.5 * Math.min(run.to - run.from, q.to - q.from) && similarSpacing(run.period, q.period, NEIGHBOUR_PITCH)) {
10402
+ parent[find(at.get(q))] = find(at.get(run));
10403
+ }
10404
+ }
10405
+ }
10406
+ }
10407
+ const span = /* @__PURE__ */ new Map();
10408
+ for (const run of flat) {
10409
+ const root = find(at.get(run));
10410
+ const s0 = span.get(root);
10411
+ const a = across(run);
10412
+ if (!s0) span.set(root, { lo: run.line, hi: run.line, across: a, periods: [run.period], slants: [] });
10413
+ else {
10414
+ s0.lo = Math.min(s0.lo, run.line);
10415
+ s0.hi = Math.max(s0.hi, run.line);
10416
+ s0.across = Math.max(s0.across, a);
10417
+ s0.periods.push(run.period);
10418
+ }
10419
+ }
10420
+ for (const run of flat) {
10421
+ for (const q of lines[run.line + 1] ?? []) {
10422
+ if (q.light !== run.light || find(at.get(q)) !== find(at.get(run))) continue;
10423
+ const d = q.from - run.from;
10424
+ span.get(find(at.get(run))).slants.push(Math.abs(d - run.period * Math.round(d / run.period)));
10425
+ }
10426
+ }
10427
+ const median = (xs) => xs.length ? [...xs].sort((a, b) => a - b)[xs.length >> 1] : 0;
10428
+ for (const run of flat) {
10429
+ const sp = span.get(find(at.get(run)));
10430
+ if (sp.hi - sp.lo + 1 < PATTERN_MIN_EXTENT * sp.across) continue;
10431
+ const [along, step] = run.axis === "x" ? [r.cellX, r.cellY] : [r.cellY, r.cellX];
10432
+ const slant = median(sp.slants) * along;
10433
+ run.pitch = median(sp.periods) * along * (step / Math.hypot(slant, step));
10434
+ if (run.pitch <= PATTERN_MAX_PITCH) kept.push(run);
10435
+ }
10436
+ }
10437
+ return kept;
10438
+ }
10439
+ function runBlocks(r, run, from = run.from, to = run.to) {
10440
+ const half = run.period / 2;
10441
+ const across = run.axis === "x" ? half * (r.cellX / r.cellY) : half * (r.cellY / r.cellX);
10442
+ const [x0, x1, y0, y1] = run.axis === "x" ? [from - half, to + half, run.line - across, run.line + across] : [run.line - across, run.line + across, from - half, to + half];
10443
+ const bw = Math.ceil(r.cols / BLOCK), bh = Math.ceil(r.rows / BLOCK);
10444
+ const clampB = (v, blocks) => Math.max(0, Math.min(blocks - 1, Math.floor(v / BLOCK)));
10445
+ return { bx0: clampB(x0, bw), bx1: clampB(x1, bw), by0: clampB(y0, bh), by1: clampB(y1, bh) };
10446
+ }
10447
+ function markedArea(r, marked) {
10448
+ const bw = Math.ceil(r.cols / BLOCK);
10449
+ let cells = 0;
10450
+ marked.forEach((m, i) => {
10451
+ if (!m) return;
10452
+ const bx = i % bw, by = Math.floor(i / bw);
10453
+ cells += (Math.min(r.cols, (bx + 1) * BLOCK) - bx * BLOCK) * (Math.min(r.rows, (by + 1) * BLOCK) - by * BLOCK);
10454
+ });
10455
+ return cells / (r.cols * r.rows);
10456
+ }
10457
+ function patternArea(r, runs) {
10458
+ const bw = Math.ceil(r.cols / BLOCK), bh = Math.ceil(r.rows / BLOCK);
10459
+ const marked = new Uint8Array(bw * bh);
10460
+ for (const run of runs) {
10461
+ const b = runBlocks(r, run);
10462
+ for (let y = b.by0; y <= b.by1; y++) for (let x = b.bx0; x <= b.bx1; x++) marked[y * bw + x] = 1;
10463
+ }
10464
+ return markedArea(r, marked);
10465
+ }
10466
+ function flicker(a, b, runs, dt) {
10467
+ const bw = Math.ceil(a.cols / BLOCK), bh = Math.ceil(a.rows / BLOCK);
10468
+ const marked = new Uint8Array(bw * bh);
10469
+ let worst = 0;
10470
+ for (const run of runs) {
10471
+ if (run.contrast <= 0) continue;
10472
+ const span = Math.max(8, FLICKER_PERIODS * run.period);
10473
+ for (let s = run.from; s < run.to; s += span) {
10474
+ const e = Math.min(run.to, s + span);
10475
+ const c0 = Math.max(0, Math.floor(s)), c1 = Math.min((run.axis === "x" ? a.cols : a.rows) - 1, Math.ceil(e));
10476
+ let sum = 0;
10477
+ for (let c = c0; c <= c1; c++) {
10478
+ const i = run.axis === "x" ? run.line * a.cols + c : c * a.cols + run.line;
10479
+ sum += Math.abs(b.lum[i] - a.lum[i]);
10480
+ }
10481
+ const hz = sum / (c1 - c0 + 1) / (dt / 1e3) / (2 * run.contrast);
10482
+ worst = Math.max(worst, hz);
10483
+ if (hz > FLASH_MAX_PER_SECOND) {
10484
+ const bl = runBlocks(a, run, s, e);
10485
+ for (let y = bl.by0; y <= bl.by1; y++) for (let x = bl.bx0; x <= bl.bx1; x++) marked[y * bw + x] = 1;
10486
+ }
10487
+ }
10488
+ }
10489
+ return { area: markedArea(a, marked), hz: worst };
10490
+ }
10491
+ var nonZero = (v) => v !== void 0 && (Array.isArray(v) ? v[0] !== 0 || v[1] !== 0 : v !== 0);
10492
+ function layerPatternMotion(spec, index) {
10493
+ const l = spec.layers[index];
10494
+ if (!l) return "static";
10495
+ const keyed = keyedLayers2(spec);
10496
+ if (keyed.has(index)) return "moving";
10497
+ const sprite = l.sprite;
10498
+ if (nonZero(l.spin) || l.rock || l.pulse || l.grow || l.emit || sprite.cycle) return "moving";
10499
+ const m = l.motion;
10500
+ const moves = !!m.speed && nonZero(m.speed);
10501
+ switch (m.type) {
10502
+ case "static":
10503
+ return "static";
10504
+ case "drift":
10505
+ if (!moves) return (m.bob ?? 0) !== 0 ? "moving" : "static";
10506
+ return (m.bob ?? 0) !== 0 || m.bidirectional ? "moving" : "flow";
10507
+ case "rise":
10508
+ if (!moves) return (m.sway ?? 0) !== 0 ? "moving" : "static";
10509
+ return (m.sway ?? 0) !== 0 ? "moving" : "flow";
10510
+ case "warp":
10511
+ return moves ? "flow" : "static";
10512
+ default:
10513
+ return "moving";
10514
+ }
10515
+ }
10516
+ function keyedLayers2(spec) {
10517
+ const out = /* @__PURE__ */ new Set();
10518
+ if (!spec.timeline) return out;
10519
+ for (const path of timelineTracks(spec).keys()) {
10520
+ const [root, id] = path.split(".");
10521
+ if (root === "layers" && /^\d+$/.test(id ?? "")) out.add(Number(id));
10522
+ else if (root === "groups" && id !== void 0) spec.layers.forEach((l, i) => {
10523
+ if (l.group === id) out.add(i);
10524
+ });
10525
+ else out.add(-1);
10526
+ }
10527
+ return out;
10528
+ }
10529
+ function backgroundMotion(spec) {
10530
+ if (keyedLayers2(spec).has(-1)) return "moving";
10531
+ const bg = spec.background;
10532
+ if (!bg) return "static";
10533
+ const drift = bg.drift;
10534
+ if (bg.type === "field") return drift && drift.amount !== 0 ? "flow" : "static";
10535
+ return drift ? "flow" : "static";
10536
+ }
10537
+ function patternSampleTimes(spec) {
10538
+ const tl = spec.timeline;
10539
+ if (!tl) return [BASE_T];
10540
+ const lap = tl.loop && tl.duration ? tl.duration : 0;
10541
+ const settle = /* @__PURE__ */ new Set();
10542
+ for (const keys of timelineTracks(spec).values()) {
10543
+ for (const k of keys) {
10544
+ const end = k.t + Math.max(0, k.dur ?? DEFAULT_KEY_DUR);
10545
+ settle.add(lap ? end % lap : end);
10546
+ }
10547
+ }
10548
+ settle.delete(BASE_T);
10549
+ const ts = [...settle].sort((a, b) => a - b);
10550
+ const room = PATTERN_MAX_SAMPLE_TIMES - 1;
10551
+ const picked = ts.length <= room ? ts : Array.from({ length: room }, (_, i) => ts[Math.round((i + 1) * (ts.length - 1) / room)]);
10552
+ return [BASE_T, ...new Set(picked)];
10553
+ }
10554
+ var pct = (f) => `${Math.round(f * 100)}%`;
10555
+ function describeRun(run) {
10556
+ const p = run.pitch ?? 0;
10557
+ return `${run.count} light-dark pairs of stripes at a pitch of ${p.toFixed(3)} of the short side (about ${Math.round(p * 1080)} px at 1080p), luminance swing ${run.contrast.toFixed(2)}`;
10558
+ }
10559
+ var REMEDY = "To pass: lower the stripes' contrast (keep light and dark within 0.1 relative luminance of each other), use five or fewer stripes, break the regular spacing, or confine the pattern to a smaller part of the frame.";
10560
+ function patternHazardReport(spec, opts = {}) {
10561
+ const viewport = opts.viewport ?? { width: 1920, height: 1080 };
10562
+ const ro = { viewport, seed: opts.seed };
10563
+ const motion = spec.layers.map((_, i) => layerPatternMotion(spec, i));
10564
+ const bgMotion = backgroundMotion(spec);
10565
+ const anyMotion = bgMotion !== "static" || motion.some((m, i) => m !== "static" && !isText(spec.layers[i]));
10566
+ const best = { fails: false, kind: null, at: null, area: 0, movingArea: 0, flickerArea: 0, flickerHz: 0, pairs: 0, pitch: null, contrast: 0, rasters: 0 };
10567
+ let rasters = 0;
10568
+ for (const t of opts.times ?? patternSampleTimes(spec)) {
10569
+ const full = raster(prepared(spec, t, () => true), t, ro);
10570
+ rasters++;
10571
+ const runs = scanRaster(full);
10572
+ const area = patternArea(full, runs);
10573
+ const top = runs.reduce((a, b) => !a || b.count > a.count ? b : a, null);
10574
+ const found = {
10575
+ fails: false,
10576
+ kind: null,
10577
+ at: runs.length ? t : null,
10578
+ area,
10579
+ movingArea: 0,
10580
+ flickerArea: 0,
10581
+ flickerHz: 0,
10582
+ pairs: top?.count ?? 0,
10583
+ pitch: top?.pitch ?? null,
10584
+ contrast: top?.contrast ?? 0,
10585
+ rasters: 0
10586
+ };
10587
+ const where = `at t = ${Math.round(t)} ms`;
10588
+ if (top && area > PATTERN_STATIONARY_AREA) {
10589
+ return {
10590
+ ...found,
10591
+ fails: true,
10592
+ kind: "pattern",
10593
+ rasters,
10594
+ message: `pattern hazard (photosensitivity): ${where} the frame shows a regular stripe pattern (${describeRun(top)}) over ${pct(area)} of the frame. ITU-R BT.1702 treats more than five light-dark pairs of such stripes as harmful above 40% of the screen when still, and above 25% when they move, turn or oscillate. ${REMEDY}`
10595
+ };
10596
+ }
10597
+ if (top && area > PATTERN_MOVING_AREA) {
10598
+ const movers = motion.map((m) => m === "moving");
10599
+ if (movers.some((m, i) => m && !isText(spec.layers[i])) || bgMotion === "moving") {
10600
+ const flat = bgMotion === "moving" ? void 0 : full.meanGround;
10601
+ const own = raster(prepared(spec, t, (_, i) => movers[i], flat), t, ro);
10602
+ rasters++;
10603
+ const ownRuns = scanRaster(own);
10604
+ found.movingArea = patternArea(own, ownRuns);
10605
+ if (found.movingArea > PATTERN_MOVING_AREA) {
10606
+ const ownTop = ownRuns.reduce((a, b) => !a || b.count > a.count ? b : a, null) ?? top;
10607
+ return {
10608
+ ...found,
10609
+ fails: true,
10610
+ kind: "moving-pattern",
10611
+ rasters,
10612
+ message: `pattern hazard (photosensitivity): ${where} a regular stripe pattern (${describeRun(ownTop)}) that moves, turns or oscillates covers ${pct(found.movingArea)} of the frame. ITU-R BT.1702 limits a moving pattern of more than five light-dark pairs to 25% of the screen (40% when still). ${REMEDY} Or hold it still.`
10613
+ };
10614
+ }
10615
+ }
10616
+ if (anyMotion) {
10617
+ const later = raster(prepared(spec, t + DELTA_MS, () => true), t + DELTA_MS, ro);
10618
+ rasters++;
10619
+ const f = flicker(full, later, runs, DELTA_MS);
10620
+ found.flickerArea = f.area;
10621
+ found.flickerHz = f.hz;
10622
+ if (f.area > PATTERN_FLICKER_AREA) {
10623
+ return {
10624
+ ...found,
10625
+ fails: true,
10626
+ kind: "flicker",
10627
+ rasters,
10628
+ message: `pattern hazard (photosensitivity): ${where} a regular stripe pattern (${describeRun(top)}) moves across its stripes fast enough that ${pct(f.area)} of the frame flickers more than ${FLASH_MAX_PER_SECOND} times a second (up to ${Math.round(f.hz)}): each point turns light and dark as the stripes pass, at speed \xF7 pitch. More than 3 a second over 25% of the frame is a flash hazard. Slow it (under 3 stripe pitches a second), widen the pitch, lower the contrast, or shrink the patterned area.`
10629
+ };
10630
+ }
10631
+ }
10632
+ }
10633
+ if (area > best.area || best.at === null && found.at !== null) Object.assign(best, found);
10634
+ }
10635
+ best.rasters = rasters;
10636
+ return best;
10637
+ }
10638
+
9937
10639
  // src/examples/aquarium.ts
9938
10640
  var AQUARIUM_SPEC = {
9939
10641
  schemaVersion: 1,
@@ -12451,6 +13153,14 @@ export {
12451
13153
  MURMUR_SPEC,
12452
13154
  NOSTALGHIA_CANDLE_SPEC,
12453
13155
  ORRERY_SPEC,
13156
+ PATTERN_FLICKER_AREA,
13157
+ PATTERN_GRID_SHORT_SIDE,
13158
+ PATTERN_MAX_PITCH,
13159
+ PATTERN_MAX_SAMPLE_TIMES,
13160
+ PATTERN_MIN_EXTENT,
13161
+ PATTERN_MIN_PAIRS,
13162
+ PATTERN_MOVING_AREA,
13163
+ PATTERN_STATIONARY_AREA,
12454
13164
  PHASE_DUET_SPEC,
12455
13165
  PINGS_SPEC,
12456
13166
  POLYGONS_SPEC,
@@ -12495,6 +13205,7 @@ export {
12495
13205
  createFinishPass,
12496
13206
  createSceneCanvas,
12497
13207
  describeScene,
13208
+ describeStructuralDifferences,
12498
13209
  diffScenes,
12499
13210
  dominanceRanking,
12500
13211
  drawSpan,
@@ -12524,6 +13235,7 @@ export {
12524
13235
  latticeHash,
12525
13236
  layerCellFootprints,
12526
13237
  layerCohesion,
13238
+ layerPatternMotion,
12527
13239
  lerpPathD,
12528
13240
  lerpSpec,
12529
13241
  lerpValue,
@@ -12541,6 +13253,8 @@ export {
12541
13253
  normalizeSeed,
12542
13254
  pathFrame,
12543
13255
  pathParts,
13256
+ patternHazardReport,
13257
+ patternSampleTimes,
12544
13258
  perceiveScene,
12545
13259
  perceiveSequenceFrame,
12546
13260
  periodWithoutTimeline,
@@ -12572,6 +13286,7 @@ export {
12572
13286
  steerablePaths,
12573
13287
  strokeDrawnSamples,
12574
13288
  strokeLines,
13289
+ structuralDifferences,
12575
13290
  structuralSignature,
12576
13291
  textBlockAnchorOffset,
12577
13292
  textBlockText,