@idle-screens/schema 3.13.0 → 3.14.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
@@ -255,6 +255,35 @@ function applyDeltasToSpec(spec, deltas) {
255
255
  }
256
256
  return copy;
257
257
  }
258
+ function applyDeltasShared(spec, deltas) {
259
+ const out = { ...spec };
260
+ const copied = new WeakSet([out]);
261
+ for (const d of deltas) {
262
+ const cp = canonicalSpecPath(out, d.path);
263
+ if (!cp) continue;
264
+ const parts = cp.split(".");
265
+ let node = out;
266
+ for (let i = 0; i < parts.length - 1; i++) {
267
+ const k = parts[i];
268
+ const child = node[k];
269
+ if (!child || typeof child !== "object") {
270
+ node = null;
271
+ break;
272
+ }
273
+ if (!copied.has(child)) {
274
+ const clone = Array.isArray(child) ? child.slice() : { ...child };
275
+ copied.add(clone);
276
+ node[k] = clone;
277
+ }
278
+ node = node[k];
279
+ }
280
+ if (node) {
281
+ const last = parts[parts.length - 1];
282
+ node[Array.isArray(node) ? Number(last) : last] = d.value;
283
+ }
284
+ }
285
+ return out;
286
+ }
258
287
  var HEX = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
259
288
  function hexToRgb(hex) {
260
289
  const h = hex.length === 4 ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}` : hex;
@@ -641,7 +670,7 @@ function evalTrack(keys, start, tt, leaf) {
641
670
  }
642
671
  return at(tt);
643
672
  }
644
- function resolveTimelineAt(spec, t) {
673
+ function resolveTimelineAt(spec, t, shared = false) {
645
674
  const tl = spec.timeline;
646
675
  if (!tl) return spec;
647
676
  const base = withoutTimeline(spec);
@@ -653,7 +682,7 @@ function resolveTimelineAt(spec, t) {
653
682
  const start = tl.loop ? keys[keys.length - 1].value : readSpecPath(base, path);
654
683
  deltas.push({ t: 0, path, value: evalTrack(keys, start, tt, path.slice(path.lastIndexOf(".") + 1)) });
655
684
  }
656
- return applyDeltasToSpec(base, deltas);
685
+ return shared ? applyDeltasShared(base, deltas) : applyDeltasToSpec(base, deltas);
657
686
  }
658
687
  function nextKeyAfter(spec, path, t) {
659
688
  const tl = spec.timeline;
@@ -934,6 +963,41 @@ function pathParts(s) {
934
963
  spriteParts.set(s, { key, parts });
935
964
  return parts;
936
965
  }
966
+ function partInkArea(part, sx, sy, r) {
967
+ if (part.opacity <= 0) return { fill: 0, stroke: 0 };
968
+ const fill = part.fill ? polygonArea(part.flat.points.map(([x, y]) => ({ x: x * sx, y: y * sy }))) * part.opacity : 0;
969
+ let stroke = 0;
970
+ if (part.stroke && part.stroke.width > 0) {
971
+ for (const l of part.flat.lines) {
972
+ const pts = l.points.map(([x, y]) => ({ x: x * sx, y: y * sy }));
973
+ if (l.closed && pts.length > 1) pts.push(pts[0]);
974
+ stroke += pathLength(pts) * part.stroke.width * r;
975
+ }
976
+ stroke *= (part.stroke.opacity ?? 1) * part.opacity;
977
+ }
978
+ return { fill, stroke };
979
+ }
980
+ function gradientStopWeights(stops) {
981
+ const at = stops.map((st) => Math.min(1, Math.max(0, st.at)));
982
+ const order = at.map((_, i) => i).sort((a, b) => at[a] - at[b]);
983
+ const out = new Array(stops.length).fill(0);
984
+ order.forEach((i, rank) => {
985
+ const prev = rank > 0 ? at[order[rank - 1]] : null;
986
+ const next = rank < order.length - 1 ? at[order[rank + 1]] : null;
987
+ const a = at[i];
988
+ out[i] = (prev === null ? a : (a - prev) / 2) + (next === null ? 1 - a : (next - a) / 2);
989
+ });
990
+ const sum = out.reduce((x, y) => x + y, 0);
991
+ return sum > 0 ? out.map((x) => x / sum) : out.map(() => 1 / Math.max(1, stops.length));
992
+ }
993
+ function pathInkArea(s, sx, sy, r) {
994
+ let area = 0;
995
+ for (const part of pathParts(s)) {
996
+ const a = partInkArea(part, sx, sy, r);
997
+ area += a.fill + a.stroke;
998
+ }
999
+ return area;
1000
+ }
937
1001
  function pathFrame(s, r, w, h) {
938
1002
  const span = s.span ?? "box";
939
1003
  const sx = span === "width" || span === "screen" ? w / 2 : r;
@@ -1456,7 +1520,7 @@ function validateSpec(spec) {
1456
1520
  }
1457
1521
  function ignoredPropertyPaths(spec) {
1458
1522
  if (!isObj(spec)) return [];
1459
- return (validateSpecCore(spec).warnings ?? []).filter((w) => w.code === "unknown-property" || w.code === "misplaced-property").map((w) => w.path.replace(/\[(\d+)\]/g, ".$1"));
1523
+ return (validateSpecCore(spec, false).warnings ?? []).filter((w) => w.code === "unknown-property" || w.code === "misplaced-property").map((w) => w.path.replace(/\[(\d+)\]/g, ".$1"));
1460
1524
  }
1461
1525
  function validateSpecPaths(spec, paths) {
1462
1526
  const layers = /* @__PURE__ */ new Set();
@@ -1494,7 +1558,7 @@ function validateSpecPaths(spec, paths) {
1494
1558
  }
1495
1559
  return ok;
1496
1560
  }
1497
- function validateSpecCore(spec) {
1561
+ function validateSpecCore(spec, checkApplied = true) {
1498
1562
  const errors = [];
1499
1563
  const warnings = [];
1500
1564
  const err = (path, message) => void errors.push({ path, message });
@@ -1563,7 +1627,7 @@ function validateSpecCore(spec) {
1563
1627
  });
1564
1628
  if (total > LIMITS.maxTotal) err("layers", `total entities ${total} exceeds cap ${LIMITS.maxTotal}`);
1565
1629
  }
1566
- if (spec.timeline !== void 0 && errors.length === 0) validateTimeline(spec, err, warn);
1630
+ if (spec.timeline !== void 0 && errors.length === 0) validateTimeline(spec, err, warn, checkApplied);
1567
1631
  return { valid: errors.length === 0, errors, warnings };
1568
1632
  }
1569
1633
  function validateTransform(tf, path, err, warn, spec, anchorOk) {
@@ -1601,7 +1665,7 @@ function validateGroups(groups, err, warn, spec) {
1601
1665
  if (g.transform !== void 0) validateTransform(g.transform, `${p}.transform`, err, warn, spec, false);
1602
1666
  }
1603
1667
  }
1604
- function validateTimeline(spec, errOuter, warn) {
1668
+ function validateTimeline(spec, errOuter, warn, checkApplied = true) {
1605
1669
  let failed = false;
1606
1670
  const err = (p, m) => {
1607
1671
  failed = true;
@@ -1655,20 +1719,22 @@ function validateTimeline(spec, errOuter, warn) {
1655
1719
  if (!ok || !cp) return;
1656
1720
  const t = key.t;
1657
1721
  latest = Math.max(latest, t);
1658
- const applied = applyDeltasToSpec(base, [{ t: 0, path: cp, value: key.value }]);
1659
- const r = validateSpecPaths(applied, [cp]) ? { valid: true, errors: [] } : validateSpec(applied);
1660
- if (!r.valid) {
1661
- for (const e of r.errors) err(`${p}.value`, `applied at '${key.path}' \u2192 ${e.path || "<root>"}: ${e.message}`);
1662
- return;
1663
- }
1664
- if (isObj(key.value) && cp.endsWith("transform")) {
1665
- const before = readSpecPath(base, cp);
1666
- const o0 = isObj(before) ? before.origin ?? "viewport" : "viewport";
1667
- const o1 = key.value.origin ?? "viewport";
1668
- if (o0 !== o1) warn(`${p}.value`, "timeline-origin-steps", `this key changes the transform's origin (${String(o0)} \u2192 ${String(o1)}): the pivot switches on the key's first frame, so the layer jumps \u2014 keep one origin per layer`);
1669
- }
1670
- if (structuralSignature(applied) !== baseSig) {
1671
- warn(`${p}.path`, "timeline-structural-key", `'${key.path}' is structural (placement, count, motion, size\u2026): the scene rebuilds at this key and entities re-seed \u2014 a pop, not a glide. Animate paint (colour, opacity, transform, polygon points) instead`);
1722
+ if (checkApplied) {
1723
+ const applied = applyDeltasShared(base, [{ t: 0, path: cp, value: key.value }]);
1724
+ const r = validateSpecPaths(applied, [cp]) ? { valid: true, errors: [] } : validateSpec(structuredClone(applied));
1725
+ if (!r.valid) {
1726
+ for (const e of r.errors) err(`${p}.value`, `applied at '${key.path}' \u2192 ${e.path || "<root>"}: ${e.message}`);
1727
+ return;
1728
+ }
1729
+ if (isObj(key.value) && cp.endsWith("transform")) {
1730
+ const before = readSpecPath(base, cp);
1731
+ const o0 = isObj(before) ? before.origin ?? "viewport" : "viewport";
1732
+ const o1 = key.value.origin ?? "viewport";
1733
+ if (o0 !== o1) warn(`${p}.value`, "timeline-origin-steps", `this key changes the transform's origin (${String(o0)} \u2192 ${String(o1)}): the pivot switches on the key's first frame, so the layer jumps \u2014 keep one origin per layer`);
1734
+ }
1735
+ if (structuralSignature(applied) !== baseSig) {
1736
+ warn(`${p}.path`, "timeline-structural-key", `'${key.path}' is structural (placement, count, motion, size\u2026): the scene rebuilds at this key and entities re-seed \u2014 a pop, not a glide. Animate paint (colour, opacity, transform, polygon points) instead`);
1737
+ }
1672
1738
  }
1673
1739
  const list = times.get(cp) ?? [];
1674
1740
  list.push(t);
@@ -1694,16 +1760,16 @@ function validateTimeline(spec, errOuter, warn) {
1694
1760
  if (wrapGap < minGap) err("timeline.keys", `keys on '${path}' at ${distinct[distinct.length - 1]} ms and ${distinct[0]} ms (next lap) are closer than ${minGap} ms across the loop wrap (flash safety)`);
1695
1761
  }
1696
1762
  }
1697
- if (failed) return;
1763
+ if (failed || !checkApplied) return;
1698
1764
  const keyed = [...timelineTracks(spec).keys()];
1699
1765
  const seen = /* @__PURE__ */ new Set();
1700
1766
  for (const t of timelineSampleTimes(spec, true, 120)) {
1701
- const r = resolveTimelineAt(spec, t);
1767
+ const r = resolveTimelineAt(spec, t, true);
1702
1768
  const state = JSON.stringify(keyed.map((p) => readSpecPath(r, p)));
1703
1769
  if (seen.has(state)) continue;
1704
1770
  seen.add(state);
1705
1771
  if (validateSpecPaths(r, keyed)) continue;
1706
- const e = validateSpec(r).errors[0];
1772
+ const e = validateSpec(structuredClone(r)).errors[0];
1707
1773
  err("timeline", `at t = ${Math.round(t)} ms the keys combine into an invalid scene \u2014 ${e ? `${e.path || "<root>"}: ${e.message}` : "invalid"}`);
1708
1774
  return;
1709
1775
  }
@@ -2469,17 +2535,29 @@ function validateMotion(motion, path, err, warn, spec) {
2469
2535
  }
2470
2536
  var isPair = (v, lim = 2) => Array.isArray(v) && v.length === 2 && isNum(v[0]) && isNum(v[1]) && Math.abs(v[0]) <= lim && Math.abs(v[1]) <= lim;
2471
2537
  var MAX_PATH_PARTS = 256;
2538
+ var pathVerdicts = /* @__PURE__ */ new Map();
2539
+ var pathVerdictBytes = 0;
2540
+ function pathGeometryProblem(d, rule, filled) {
2541
+ const key = `${rule}|${filled ? 1 : 0}|${d}`;
2542
+ const hit = pathVerdicts.get(key);
2543
+ if (hit !== void 0) return hit;
2544
+ const flat = flattenPath(d, rule);
2545
+ const inBox = ([x, y]) => Math.abs(x) <= 1.0001 && Math.abs(y) <= 1.0001;
2546
+ const problem = flat.error ? `not a usable path: ${flat.error}` : filled && flat.points.length < 3 ? "must enclose an area (at least 3 points), or set fill: false and a stroke for line art" : !filled && flat.lines.length === 0 ? "must draw at least one line (2+ points)" : flat.points.length > MAX_PATH_POINTS ? `flattens to ${flat.points.length} points; at most ${MAX_PATH_POINTS}` : !flat.points.every(inBox) || !flat.lines.every((l) => l.points.every(inBox)) ? "must stay inside the -1..1 unit box (curves included)" : null;
2547
+ if (pathVerdicts.size >= 1024 || pathVerdictBytes + key.length > 2e6) {
2548
+ pathVerdicts.clear();
2549
+ pathVerdictBytes = 0;
2550
+ }
2551
+ pathVerdicts.set(key, problem);
2552
+ pathVerdictBytes += key.length;
2553
+ return problem;
2554
+ }
2472
2555
  function validatePathPiece(piece, path, err, warn) {
2473
2556
  if (typeof piece.path !== "string" || piece.path.length === 0 || piece.path.length > 65536) {
2474
2557
  err(`${path}.path`, "must be an SVG path d string (1..65536 chars) in the -1..1 unit box");
2475
2558
  } else {
2476
- const flat = flattenPath(piece.path, piece.fillRule ?? "auto");
2477
- const filled = piece.fill !== false;
2478
- if (flat.error) err(`${path}.path`, `not a usable path: ${flat.error}`);
2479
- else if (filled && flat.points.length < 3) err(`${path}.path`, "must enclose an area (at least 3 points), or set fill: false and a stroke for line art");
2480
- else if (!filled && flat.lines.length === 0) err(`${path}.path`, "must draw at least one line (2+ points)");
2481
- else if (flat.points.length > MAX_PATH_POINTS) err(`${path}.path`, `flattens to ${flat.points.length} points; at most ${MAX_PATH_POINTS}`);
2482
- else if ([...flat.points, ...flat.lines.flatMap((l) => l.points)].some(([x, y]) => Math.abs(x) > 1.0001 || Math.abs(y) > 1.0001)) err(`${path}.path`, "must stay inside the -1..1 unit box (curves included)");
2559
+ const problem = pathGeometryProblem(piece.path, piece.fillRule ?? "auto", piece.fill !== false);
2560
+ if (problem) err(`${path}.path`, problem);
2483
2561
  }
2484
2562
  if (piece.fill !== void 0 && typeof piece.fill !== "boolean") err(`${path}.fill`, "must be a boolean");
2485
2563
  if (piece.fill === false && piece.stroke === void 0) err(`${path}.fill`, "fill: false draws nothing without a stroke");
@@ -3510,6 +3588,10 @@ function paintOpacity(spec, layer) {
3510
3588
  const g = groupOf(spec, layer);
3511
3589
  return g?.opacity === void 0 ? own : own * clamp01(g.opacity);
3512
3590
  }
3591
+ function paintRotation(spec, layer) {
3592
+ const deg = (layer.transform?.rotate ?? 0) + (groupOf(spec, layer)?.transform?.rotate ?? 0);
3593
+ return deg * Math.PI / 180;
3594
+ }
3513
3595
  function paintSize(spec, layer) {
3514
3596
  return transformSize(layer.transform) * transformSize(groupOf(spec, layer)?.transform);
3515
3597
  }
@@ -6253,6 +6335,7 @@ function compileSequence(spec) {
6253
6335
 
6254
6336
  // src/advise.ts
6255
6337
  import { createRng as createRng3 } from "@idle-screens/core";
6338
+ import { analyzeFlashes as analyzeFlashes2, srgbToLinear as srgbToLinear3 } from "@idle-screens/validator";
6256
6339
 
6257
6340
  // src/luma.ts
6258
6341
  function fieldRenderedRgbs(bg) {
@@ -6301,6 +6384,26 @@ function spriteLuma(layer, e) {
6301
6384
  if (s.kind === "text" || s.kind === "textBlock") return hexLuma(s.color ?? "#e6e8ef");
6302
6385
  return 0.75;
6303
6386
  }
6387
+ function spriteInks(layer, e) {
6388
+ const s = layer.sprite;
6389
+ if (s.kind === "polygon" && isPathPolygon(s)) {
6390
+ const base = s.colors?.[e.colorIndex] ?? s.color;
6391
+ const inks = [];
6392
+ for (const part of pathParts(s)) {
6393
+ if (part.opacity <= 0) continue;
6394
+ if (part.fill) {
6395
+ if (part.gradient) for (const st of part.gradient.stops) {
6396
+ if ((st.opacity ?? 1) > 0) inks.push(st.color);
6397
+ }
6398
+ else inks.push(part.color ?? base);
6399
+ }
6400
+ if (part.stroke && part.stroke.width > 0 && (part.stroke.opacity ?? 1) > 0) inks.push(part.stroke.color);
6401
+ }
6402
+ return inks;
6403
+ }
6404
+ const hex = spriteHex(layer, e);
6405
+ return hex === null ? null : [hex];
6406
+ }
6304
6407
  function spriteHex(layer, e) {
6305
6408
  const s = layer.sprite;
6306
6409
  if (isShapedSprite(s)) {
@@ -6416,6 +6519,23 @@ var WASH_ALPHA_MAX = 0.45;
6416
6519
  var COHESION_T = 5e3;
6417
6520
  var OUTLINE_SAMPLES = 24;
6418
6521
  var MAX_TRACED = 240;
6522
+ function discSamples(cx, cy, r, n) {
6523
+ const golden = Math.PI * (3 - Math.sqrt(5));
6524
+ return Array.from({ length: n }, (_, i) => {
6525
+ const rr = r * Math.sqrt((i + 0.5) / n);
6526
+ return { x: cx + rr * Math.cos(i * golden), y: cy + rr * Math.sin(i * golden) };
6527
+ });
6528
+ }
6529
+ function gridSamples(uw, vh, n, map) {
6530
+ if (!(uw > 0) || !(vh > 0)) return [];
6531
+ const nv = Math.min(n, Math.max(8, Math.round(Math.sqrt(n * vh / uw))));
6532
+ const nu = Math.max(8, Math.round(n / nv));
6533
+ const out = [];
6534
+ for (let i = 0; i < nu; i++) {
6535
+ for (let j = 0; j < nv; j++) out.push(map(((i + 0.5) / nu - 0.5) * uw, ((j + 0.5) / nv - 0.5) * vh));
6536
+ }
6537
+ return out;
6538
+ }
6419
6539
  var EDGE_INSET = 1e-4;
6420
6540
  function circleShape(cx, cy, r) {
6421
6541
  return {
@@ -6424,6 +6544,7 @@ function circleShape(cx, cy, r) {
6424
6544
  br: r,
6425
6545
  area: Math.PI * r * r,
6426
6546
  contains: (x, y) => (x - cx) * (x - cx) + (y - cy) * (y - cy) < r * r,
6547
+ interior: (n) => discSamples(cx, cy, r, n),
6427
6548
  outline: (n) => Array.from({ length: n }, (_, i) => {
6428
6549
  const a = i / n * Math.PI * 2;
6429
6550
  const inner = r * (1 - EDGE_INSET);
@@ -6457,12 +6578,21 @@ function polyShape(cx, cy, raw) {
6457
6578
  const step = Math.min(br * EDGE_INSET, shortest * 0.25);
6458
6579
  const area = polygonArea(abs);
6459
6580
  if (!(area > 0)) return null;
6581
+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
6582
+ for (const p of abs) {
6583
+ x0 = Math.min(x0, p.x);
6584
+ y0 = Math.min(y0, p.y);
6585
+ x1 = Math.max(x1, p.x);
6586
+ y1 = Math.max(y1, p.y);
6587
+ }
6460
6588
  return {
6461
6589
  cx,
6462
6590
  cy,
6463
6591
  br,
6464
6592
  area,
6465
6593
  contains: inside2,
6594
+ // Its bounding box's grid, kept to the cells inside the outline.
6595
+ interior: (n) => gridSamples(x1 - x0, y1 - y0, n, (u, v) => ({ x: (x0 + x1) / 2 + u, y: (y0 + y1) / 2 + v })).filter((p) => inside2(p.x, p.y)),
6466
6596
  /**
6467
6597
  * `n` points spread along the perimeter by ARC LENGTH, not one share per
6468
6598
  * edge — a long skinny rectangle has two edges carrying almost all of its
@@ -6512,12 +6642,13 @@ function rectShape(cx, cy, halfW, halfH, rot) {
6512
6642
  const cos = Math.cos(rot);
6513
6643
  const sin = Math.sin(rot);
6514
6644
  const corner = (lx, ly) => ({ x: cx + lx * cos - ly * sin, y: cy + lx * sin + ly * cos });
6515
- return polyShape(cx, cy, [
6645
+ const shape = polyShape(cx, cy, [
6516
6646
  corner(-halfW, -halfH),
6517
6647
  corner(halfW, -halfH),
6518
6648
  corner(halfW, halfH),
6519
6649
  corner(-halfW, halfH)
6520
6650
  ]);
6651
+ return shape && { ...shape, interior: (n) => gridSamples(halfW * 2, halfH * 2, n, corner) };
6521
6652
  }
6522
6653
  function polygonShape(cx, cy, pts, rot) {
6523
6654
  const cos = Math.cos(rot);
@@ -6577,8 +6708,7 @@ function cohesionOf(layers, t, w, h) {
6577
6708
  seamCause
6578
6709
  };
6579
6710
  if (lifeAlphaAt(layer.life, t) <= 0) return { ...base, overlap: null, reads: null };
6580
- const shapes = entities.map((e) => shapeOf(layer, e, t, w, h));
6581
- const solid = shapes.filter((x) => x !== null && x.area > 0);
6711
+ const solid = solidShapes(layer, entities, t, w, h);
6582
6712
  if (solid.length < 2) return { ...base, overlap: null, reads: null };
6583
6713
  const stride = Math.max(1, Math.ceil(solid.length / MAX_TRACED));
6584
6714
  let covered = 0;
@@ -6610,11 +6740,54 @@ function cohesionOf(layers, t, w, h) {
6610
6740
  return { ...base, overlap, reads };
6611
6741
  });
6612
6742
  }
6613
- function seamsWorthWarning(layer, c, entityCount) {
6743
+ function solidShapes(layer, entities, t, w, h) {
6744
+ return entities.map((e) => shapeOf(layer, e, t, w, h)).filter((x) => x !== null && x.area > 0);
6745
+ }
6746
+ var AREA_SAMPLES = 128;
6747
+ var TILED_AREA_MAX = 0.15;
6748
+ function buriedAreaOf(layer, entities, t, w, h) {
6749
+ if (lifeAlphaAt(layer.life, t) <= 0) return null;
6750
+ const solid = solidShapes(layer, entities, t, w, h);
6751
+ if (solid.length < 2) return null;
6752
+ const stride = Math.max(1, Math.ceil(solid.length / MAX_TRACED));
6753
+ let covered = 0;
6754
+ let traced = 0;
6755
+ for (let i = 0; i < solid.length; i += stride) {
6756
+ const pts = solid[i].interior(AREA_SAMPLES);
6757
+ if (pts.length === 0) continue;
6758
+ let hit = 0;
6759
+ for (const p of pts) {
6760
+ for (let j = 0; j < solid.length; j++) {
6761
+ if (j === i) continue;
6762
+ const other = solid[j];
6763
+ const dx = p.x - other.cx;
6764
+ const dy = p.y - other.cy;
6765
+ if (dx * dx + dy * dy > other.br * other.br) continue;
6766
+ if (other.contains(p.x, p.y)) {
6767
+ hit++;
6768
+ break;
6769
+ }
6770
+ }
6771
+ }
6772
+ covered += hit / pts.length;
6773
+ traced++;
6774
+ }
6775
+ return traced === 0 ? null : Number((covered / traced).toFixed(4));
6776
+ }
6777
+ function seamsWorthWarning(layer, c, entityCount, buriedArea) {
6614
6778
  if (c.reads !== "seamed" || entityCount < 6) return false;
6615
6779
  const [lo, hi] = layer.alpha ?? [1, 1];
6616
6780
  if ((lo + hi) / 2 < WASH_ALPHA_MAX) return false;
6617
- return layer.blend !== "lighter" && layer.blend !== "screen";
6781
+ if (layer.blend === "lighter" || layer.blend === "screen") return false;
6782
+ if (buriedArea && paletteIsTheOnlySeam(layer)) {
6783
+ const a = buriedArea();
6784
+ if (a !== null && a < TILED_AREA_MAX) return false;
6785
+ }
6786
+ return true;
6787
+ }
6788
+ function paletteIsTheOnlySeam(layer) {
6789
+ if (seamCauseOf(layer) === null) return false;
6790
+ return seamCauseOf({ ...layer, sprite: { ...layer.sprite, colors: void 0 } }) === null;
6618
6791
  }
6619
6792
 
6620
6793
  // src/perceive.ts
@@ -6917,7 +7090,7 @@ function luminanceGrid(spec, opts = {}) {
6917
7090
  };
6918
7091
  const splatPolygonOutline = (layer, s, e, p, sz, a, lum, tPass) => {
6919
7092
  const radius = sz / 2;
6920
- const rot = rotationAt(e, tPass);
7093
+ const rot = rotationAt(e, tPass) + paintRotation(spec, layer);
6921
7094
  const cr = Math.cos(rot);
6922
7095
  const sr = Math.sin(rot);
6923
7096
  const fr = isPathPolygon(s) ? pathFrame(s, radius, w, h) : { sx: radius, sy: radius, ox: 0, oy: 0, uniform: true };
@@ -7048,7 +7221,7 @@ function luminanceGrid(spec, opts = {}) {
7048
7221
  }
7049
7222
  if (s.kind === "stroke") {
7050
7223
  const pts = strokeSamples(s, sz / 2);
7051
- let angle = rotationAt(e, tPass);
7224
+ let angle = rotationAt(e, tPass) + paintRotation(spec, layer);
7052
7225
  if (s.orient) angle += headingAt(e, tPass, w, h) ?? 0;
7053
7226
  const ca = Math.cos(angle);
7054
7227
  const sa = Math.sin(angle);
@@ -7077,7 +7250,7 @@ function luminanceGrid(spec, opts = {}) {
7077
7250
  continue;
7078
7251
  }
7079
7252
  if (s.kind === "streak") {
7080
- const heading = (headingAt(e, tPass, w, h) ?? 0) + rotationAt(e, tPass);
7253
+ const heading = (headingAt(e, tPass, w, h) ?? 0) + (rotationAt(e, tPass) + paintRotation(spec, layer));
7081
7254
  const steps = Math.max(2, Math.ceil(sz / Math.min(cellW, cellH)));
7082
7255
  const wgt = Math.min(1, (s.width ?? (scale === 1 ? 2 : 2e-3)) * scale / cellH);
7083
7256
  for (let i = 0; i <= steps; i++) {
@@ -7112,7 +7285,7 @@ function luminanceGrid(spec, opts = {}) {
7112
7285
  if (len <= 0) continue;
7113
7286
  const th = e.size2 !== void 0 ? e.size2 * (e.size > 0 ? sz / e.size : 1) : sz * 0.2;
7114
7287
  const box = barBox(s.direction ?? "right", len, th);
7115
- const rot = rotationAt(e, tPass);
7288
+ const rot = rotationAt(e, tPass) + paintRotation(spec, layer);
7116
7289
  const cr = Math.cos(rot);
7117
7290
  const sr = Math.sin(rot);
7118
7291
  centerX = p.x + box.cx * cr - box.cy * sr;
@@ -7311,6 +7484,42 @@ function rankDominance(raw) {
7311
7484
  factors: r.factors
7312
7485
  }));
7313
7486
  }
7487
+ function onScreenFraction(scene, s, e, sz, k, t, paintRot = 0) {
7488
+ if (s.kind === "text" || s.kind === "textBlock") return 1;
7489
+ const { w, h } = scene;
7490
+ const p = posOf(scene, e, t);
7491
+ let cx = p.x, cy = p.y, hx = sz / 2 * k, hy = hx;
7492
+ if (s.kind === "bar") {
7493
+ const len = sz * barFraction(s, e.barIndex ?? 0);
7494
+ const th = e.size2 !== void 0 ? e.size2 * (e.size > 0 ? sz / e.size : 1) : sz * 0.2;
7495
+ const b = barBox(s.direction ?? "right", len * k, th * k);
7496
+ if (!(b.halfX > 0) || !(b.halfY > 0)) return 1;
7497
+ const rot = rotationAt(e, t);
7498
+ const cr = Math.cos(rot), sr = Math.sin(rot);
7499
+ cx = p.x + b.cx * cr - b.cy * sr;
7500
+ cy = p.y + b.cx * sr + b.cy * cr;
7501
+ const ex = Math.abs(b.halfX * cr) + Math.abs(b.halfY * sr);
7502
+ const ey = Math.abs(b.halfX * sr) + Math.abs(b.halfY * cr);
7503
+ const fx2 = Math.max(0, Math.min(cx + ex, w) - Math.max(cx - ex, 0)) / (2 * ex);
7504
+ const fy2 = Math.max(0, Math.min(cy + ey, h) - Math.max(cy - ey, 0)) / (2 * ey);
7505
+ return fx2 * fy2;
7506
+ }
7507
+ if (s.kind === "polygon" && isPathPolygon(s)) {
7508
+ const fr = pathFrame(s, sz / 2, w, h);
7509
+ const rot = rotationAt(e, t) + paintRot;
7510
+ const cr = Math.cos(rot), sr = Math.sin(rot);
7511
+ const ox = -fr.ox * fr.sx * k, oy = -fr.oy * fr.sy * k;
7512
+ cx += ox * cr - oy * sr;
7513
+ cy += ox * sr + oy * cr;
7514
+ const qx = fr.sx * k, qy = fr.sy * k;
7515
+ hx = Math.abs(qx * cr) + Math.abs(qy * sr);
7516
+ hy = Math.abs(qx * sr) + Math.abs(qy * cr);
7517
+ }
7518
+ if (!(hx > 0) || !(hy > 0)) return 1;
7519
+ const fx = Math.max(0, Math.min(cx + hx, w) - Math.max(cx - hx, 0)) / (2 * hx);
7520
+ const fy = Math.max(0, Math.min(cy + hy, h) - Math.max(cy - hy, 0)) / (2 * hy);
7521
+ return fx * fy;
7522
+ }
7314
7523
  function rawDominance(spec, opts = {}) {
7315
7524
  spec = resolveTimelineAt(spec, opts.t ?? 5e3);
7316
7525
  const scene = buildScene(spec, opts);
@@ -7354,8 +7563,12 @@ function rawDominance(spec, opts = {}) {
7354
7563
  const h2 = e.size2 !== void 0 ? e.size2 * (e.size > 0 ? sz / e.size : 1) : sz;
7355
7564
  const f = s.feather ? (1 - s.feather / 2) * (1 - s.feather / 2) : 1;
7356
7565
  entArea = sz * h2 * f;
7357
- } else if (s.kind === "polygon") entArea = polygonArea(polygonPoints(s, sz / 2));
7358
- else if (s.kind === "bar") {
7566
+ } else if (s.kind === "polygon") {
7567
+ if (isPathPolygon(s)) {
7568
+ const fr = pathFrame(s, sz / 2, w, h);
7569
+ entArea = pathInkArea(s, fr.sx, fr.sy, sz / 2);
7570
+ } else entArea = polygonArea(polygonPoints(s, sz / 2));
7571
+ } else if (s.kind === "bar") {
7359
7572
  const th = e.size2 !== void 0 ? e.size2 * (e.size > 0 ? sz / e.size : 1) : sz * 0.2;
7360
7573
  entArea = sz * barFraction(s, e.barIndex ?? 0) * th;
7361
7574
  } else if (s.kind === "stroke") {
@@ -7368,6 +7581,7 @@ function rawDominance(spec, opts = {}) {
7368
7581
  const box = textBlockBox(s, e, { x: 0, y: 0 }, w, h);
7369
7582
  entArea = box.halfX * 2 * box.halfY * 2 * 0.55 * textBlockRevealFraction(s, w, h, t) * (s.opacity ?? 1);
7370
7583
  } else entArea = sz * sz * 0.55;
7584
+ entArea *= onScreenFraction(scene, s, e, sz, painted ? paintSize(spec, layer) : 1, t, painted ? paintRotation(spec, layer) : 0);
7371
7585
  area += painted ? entArea * areaScale * a : entArea * a;
7372
7586
  if (layer.trail) {
7373
7587
  const fade = layer.trail.fade ?? 1;
@@ -7412,6 +7626,7 @@ function motionStats(spec, opts = {}) {
7412
7626
  const unit = Math.min(w, h);
7413
7627
  return scene0.layers.map(({ layer, entities }, layerIndex) => {
7414
7628
  const entities1 = scene1.layers[layerIndex]?.entities ?? entities;
7629
+ const spinVisible = layer.sprite.kind !== "circle" && layer.sprite.kind !== "ring";
7415
7630
  let acc = 0;
7416
7631
  let max = 0;
7417
7632
  let n = 0;
@@ -7425,7 +7640,8 @@ function motionStats(spec, opts = {}) {
7425
7640
  const dy = p1.y - p0.y;
7426
7641
  if (Math.abs(dx) > w / 2 || Math.abs(dy) > h / 2) return;
7427
7642
  const dist = Math.sqrt(dx * dx + dy * dy);
7428
- const speed = dist / dt * 1e3;
7643
+ const turn = spinVisible ? Math.abs(rotationAt(entities1[i] ?? e, t + dt) - rotationAt(e, t)) * (sizeAt(e, t) / 3) * paintSize(spec, layer) : 0;
7644
+ const speed = Math.sqrt(dist * dist + turn * turn) / dt * 1e3;
7429
7645
  acc += speed;
7430
7646
  max = Math.max(max, speed);
7431
7647
  n++;
@@ -7482,17 +7698,36 @@ function sceneColour(spec, opts = {}) {
7482
7698
  let wsum = 0;
7483
7699
  for (const { layer, entities } of scene.layers) {
7484
7700
  const lifeA = lifeAlphaAt(layer.life, t) * (layer.opacity === void 0 && layer.group === void 0 ? 1 : paintOpacity(spec, layer));
7485
- for (const e of entities) {
7486
- const hex = spriteHex(layer, e);
7487
- if (hex === null) continue;
7488
- const sz = sizeAt(e, t);
7489
- const weight = alphaAt(e, t) * lifeA * (layer.sprite.kind === "circle" ? Math.PI * (sz / 2) * (sz / 2) : sz * sz);
7490
- if (weight <= 0) continue;
7701
+ const k = layer.transform !== void 0 || layer.group !== void 0 ? paintSize(spec, layer) : 1;
7702
+ const add = (hex, weight) => {
7703
+ if (weight <= 0) return;
7491
7704
  const c = hexRgb(hex);
7492
7705
  wr += c.r * weight;
7493
7706
  wg += c.g * weight;
7494
7707
  wb += c.b * weight;
7495
7708
  wsum += weight;
7709
+ };
7710
+ for (const e of entities) {
7711
+ const sz = sizeAt(e, t);
7712
+ const s = layer.sprite;
7713
+ const a = alphaAt(e, t) * lifeA * onScreenFraction(scene, s, e, sz, k, t, paintRotation(spec, layer));
7714
+ if (a <= 0) continue;
7715
+ if (s.kind === "polygon" && isPathPolygon(s)) {
7716
+ const fr = pathFrame(s, sz / 2, scene.w, scene.h);
7717
+ const base = s.colors?.[e.colorIndex] ?? s.color;
7718
+ for (const part of pathParts(s)) {
7719
+ const ink = partInkArea(part, fr.sx, fr.sy, sz / 2);
7720
+ if (part.gradient) {
7721
+ const gw = gradientStopWeights(part.gradient.stops);
7722
+ part.gradient.stops.forEach((st, i) => add(st.color, a * ink.fill * (st.opacity ?? 1) * gw[i]));
7723
+ } else add(part.color ?? base, a * ink.fill);
7724
+ if (part.stroke) add(part.stroke.color, a * ink.stroke);
7725
+ }
7726
+ continue;
7727
+ }
7728
+ const hex = spriteHex(layer, e);
7729
+ if (hex === null) continue;
7730
+ add(hex, a * (s.kind === "circle" ? Math.PI * (sz / 2) * (sz / 2) : sz * sz));
7496
7731
  }
7497
7732
  }
7498
7733
  const bg = backgroundRgb(spec);
@@ -7685,12 +7920,92 @@ function perceiveSequenceFrame(seq, T, opts = {}) {
7685
7920
  };
7686
7921
  }
7687
7922
 
7923
+ // src/flash-timeline.ts
7924
+ import { analyzeFlashes, srgbToLinear as srgbToLinear2 } from "@idle-screens/validator";
7925
+ var LUMA_PATH = /(^|\.)(opacity|alpha|color|colors|stops|bands|gradient|stroke|fill|background)(\.|$)|(^|\.)(sprite|parts?)$|^layers\.\d+$/;
7926
+ var MIN_BOUNDARIES = 7;
7927
+ var MAX_SAMPLES = 64;
7928
+ var MAX_TOTAL_SAMPLES = 512;
7929
+ var WINDOW = 1e3;
7930
+ function lumaKeyTimes(spec) {
7931
+ const tl = spec.timeline;
7932
+ if (!tl) return { times: [], lap: 0 };
7933
+ const lap = tl.loop && tl.duration ? tl.duration : 0;
7934
+ const bounds = [];
7935
+ for (const [path, keys] of timelineTracks(spec)) {
7936
+ if (!LUMA_PATH.test(path)) continue;
7937
+ for (const k of keys) {
7938
+ const dur = k.dur ?? DEFAULT_KEY_DUR;
7939
+ bounds.push(k.t);
7940
+ if (dur > 0) bounds.push(k.t + dur);
7941
+ }
7942
+ }
7943
+ return { times: [...new Set(lap ? [...bounds, ...bounds.map((b) => b + lap)] : bounds)].sort((a, b) => a - b), lap };
7944
+ }
7945
+ function flashSampleTimes(boundaries) {
7946
+ const ts = /* @__PURE__ */ new Set();
7947
+ boundaries.forEach((t, i) => {
7948
+ ts.add(t - 1);
7949
+ ts.add(t + 1);
7950
+ const next = boundaries[i + 1];
7951
+ if (next !== void 0 && next - t > 4) ts.add((t + next) / 2);
7952
+ });
7953
+ return [...ts].sort((a, b) => a - b);
7954
+ }
7955
+ function timelineFlashReport(spec) {
7956
+ const pass = { fails: false, flashesPerSecond: 0, flashingArea: 0, at: null };
7957
+ if (!spec.timeline) return pass;
7958
+ const { times, lap } = lumaKeyTimes(spec);
7959
+ const dense = [];
7960
+ for (let i = 0, e = 0; i < times.length; i++) {
7961
+ while (e < times.length && times[e] < times[i] + WINDOW) e++;
7962
+ if (e - i < MIN_BOUNDARIES) continue;
7963
+ const last = dense[dense.length - 1];
7964
+ if (last && times[i] <= last.to) {
7965
+ last.to = Math.max(last.to, times[e - 1]);
7966
+ last.n = Math.max(last.n, e - i);
7967
+ } else dense.push({ from: times[i], to: times[e - 1], n: e - i });
7968
+ }
7969
+ if (dense.length === 0) return pass;
7970
+ dense.sort((a, b) => b.n - a.n);
7971
+ let budget = MAX_TOTAL_SAMPLES;
7972
+ let worst = pass;
7973
+ for (const w of dense) {
7974
+ const sampleTimes = flashSampleTimes(times.filter((t) => t >= w.from && t <= w.to));
7975
+ for (let from = 0; from < sampleTimes.length - 1; ) {
7976
+ const chunk = sampleTimes.slice(from, from + MAX_SAMPLES);
7977
+ if (chunk.length < 2) break;
7978
+ if (chunk.length > budget) return { ...worst, fails: true, at: lap ? w.from % lap : w.from, incomplete: true };
7979
+ budget -= chunk.length;
7980
+ const samples = chunk.map((t) => ({ t, lum: luminanceGrid(spec, { t, cols: 16, rows: 9 }).cells.flat().map((v) => srgbToLinear2(v * 255)) }));
7981
+ const r = analyzeFlashes(samples);
7982
+ if (r.general.worstTileFlashesPerSecond > worst.flashesPerSecond || !worst.fails && r.general.fails) {
7983
+ worst = {
7984
+ fails: r.general.fails,
7985
+ flashesPerSecond: r.general.worstTileFlashesPerSecond,
7986
+ flashingArea: r.general.flashingAreaFraction,
7987
+ at: lap ? w.from % lap : w.from
7988
+ };
7989
+ }
7990
+ if (worst.fails) return worst;
7991
+ if (from + MAX_SAMPLES >= sampleTimes.length) break;
7992
+ const resume = chunk[chunk.length - 1] - WINDOW;
7993
+ let next = from + 1;
7994
+ while (sampleTimes[next] < resume) next++;
7995
+ from = next;
7996
+ }
7997
+ }
7998
+ return worst;
7999
+ }
8000
+
7688
8001
  // src/advise.ts
7689
8002
  var LOW_CONTRAST_FLOOR = 0.05;
7690
8003
  var SPARSE_DECLARED_MAX_COVERAGE = 0.02;
7691
8004
  var READ_LEGIBILITY_FLOOR = 4.5;
7692
8005
  var READ_SAFE_AREA = 0.05;
7693
8006
  var ADDITIVE_INK_CEILING = 1;
8007
+ var CALM_MAX_SPIN = 90;
8008
+ var NEAR_FLASH_MAX_BOUNDARIES = 10;
7694
8009
  function edgeAnchored(range) {
7695
8010
  if (!range) return false;
7696
8011
  const [a, b] = range;
@@ -7698,6 +8013,7 @@ function edgeAnchored(range) {
7698
8013
  }
7699
8014
  function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
7700
8015
  const hidden = hiddenLayers(spec);
8016
+ const authored = spec;
7701
8017
  spec = resolveTimelineAt(spec, opts.t ?? COHESION_T);
7702
8018
  const warnings = [];
7703
8019
  const w = viewport.width;
@@ -7724,7 +8040,7 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
7724
8040
  if (isStaticText) textLayerCount++;
7725
8041
  if (layer.motion.type !== "static") motionLayerCount++;
7726
8042
  const coh = cohesion[li];
7727
- if (seamsWorthWarning(layer, coh, entities.length)) {
8043
+ if (seamsWorthWarning(layer, coh, entities.length, () => buriedAreaOf(layer, entities, opts.t ?? COHESION_T, w, h))) {
7728
8044
  const s = layer.sprite;
7729
8045
  const softEdged = (s.kind === "circle" || s.kind === "polygon") && s.soft || s.kind === "rect" && !!s.feather;
7730
8046
  warnings.push({
@@ -7772,13 +8088,17 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
7772
8088
  return { rgb, luma: 0.2126 * rgb.r + 0.7152 * rgb.g + 0.0722 * rgb.b };
7773
8089
  });
7774
8090
  const seps = entities.map((e) => {
7775
- const hex = spriteHex(layer, e);
7776
- if (hex === null) return Infinity;
7777
- const ink = { rgb: hexRgb(hex), luma: hexLuma(hex) };
7778
- const sep = Math.max(...plates.map((bg) => colourSeparation(layer, ink, bg)));
7779
- if (sep >= LOW_CONTRAST_FLOOR || layer.blend === "lighter" || layer.blend === "screen") return sep;
7780
- const under = groundUnder(spec, allEntities, li, hidden, positionAt(e, 0, w, h), w, h);
7781
- return under ? colourSeparation(layer, ink, under) : sep;
8091
+ const inks = spriteInks(layer, e);
8092
+ if (inks === null || inks.length === 0) return Infinity;
8093
+ return Math.max(...inks.map((hex) => {
8094
+ const ink = { rgb: hexRgb(hex), luma: hexLuma(hex) };
8095
+ const sep = Math.max(...plates.map((bg) => colourSeparation(layer, ink, bg)));
8096
+ if (sep >= LOW_CONTRAST_FLOOR || layer.blend === "lighter" || layer.blend === "screen") return sep;
8097
+ const at = positionAt(e, 0, w, h);
8098
+ const under = groundUnder(spec, allEntities, li, hidden, at, w, h);
8099
+ const framed = holeFrame(spec, allEntities, li, hidden, at, w, h);
8100
+ return Math.max(under ? colourSeparation(layer, ink, under) : sep, framed ? colourSeparation(layer, ink, framed) : sep);
8101
+ }));
7782
8102
  });
7783
8103
  const best = Math.max(...seps);
7784
8104
  if (best < LOW_CONTRAST_FLOOR) {
@@ -7807,7 +8127,10 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
7807
8127
  if (layer.sprite.kind === "circle") {
7808
8128
  pixArea = Math.PI * r * r;
7809
8129
  } else if (layer.sprite.kind === "polygon") {
7810
- pixArea = polygonArea(polygonPoints(layer.sprite, r));
8130
+ if (isPathPolygon(layer.sprite)) {
8131
+ const fr = pathFrame(layer.sprite, r, w, h);
8132
+ pixArea = pathInkArea(layer.sprite, fr.sx, fr.sy, r);
8133
+ } else pixArea = polygonArea(polygonPoints(layer.sprite, r));
7811
8134
  } else if (layer.sprite.kind === "bar") {
7812
8135
  pixArea = e.size * barFraction(layer.sprite, e.barIndex ?? 0) * (e.size2 ?? e.size * 0.2);
7813
8136
  } else if (layer.sprite.kind === "textBlock") {
@@ -7889,6 +8212,30 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
7889
8212
  });
7890
8213
  }
7891
8214
  }
8215
+ if (spec.motionIntensity === "calm") {
8216
+ for (let li = 0; li < spec.layers.length; li++) {
8217
+ if (hidden?.[li]) continue;
8218
+ const layer = spec.layers[li];
8219
+ if (!layer.spin || layer.sprite.kind === "circle" || layer.sprite.kind === "ring") continue;
8220
+ const entities = allEntities[li];
8221
+ if (entities.length === 0) continue;
8222
+ const rate = entities.reduce((s, e) => s + Math.abs(e.spinSpeed), 0) / entities.length * (180 / Math.PI);
8223
+ if (rate <= CALM_MAX_SPIN) continue;
8224
+ warnings.push({
8225
+ path: `layers[${li}].spin`,
8226
+ code: "motion-intensity-mismatch",
8227
+ message: `declared motionIntensity: 'calm' but ${layer.key ? `\`${layer.key}\`` : `layers[${li}]`} spins at ~${Math.round(rate)}\xB0/s on average \u2014 that reads busy, not calm (calm spins sit at \u2264 ${CALM_MAX_SPIN}\xB0/s); slow the spin or declare 'moderate' / 'energetic'`
8228
+ });
8229
+ }
8230
+ }
8231
+ const flicker = nearLimitFlash(authored);
8232
+ if (flicker) {
8233
+ warnings.push({
8234
+ path: "timeline",
8235
+ code: "flash-near-limit",
8236
+ 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`
8237
+ });
8238
+ }
7892
8239
  for (let li = 0; li < spec.layers.length; li++) {
7893
8240
  if (hidden?.[li]) continue;
7894
8241
  const layer = spec.layers[li];
@@ -7956,9 +8303,13 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
7956
8303
  const label = layer.key ? `\`${layer.key}\`` : `layers[${li}]`;
7957
8304
  for (const e of allEntities[li]) {
7958
8305
  const p = positionAt(e, 0, w, h);
7959
- const raw = s.kind === "textBlock" ? textBlockBoxAt(s, p, w, h) : textBoxAt(s, e, p, spec, w, h);
8306
+ const unturned = s.kind === "textBlock" ? textBlockBoxAt(s, p, w, h) : textBoxAt(s, e, p, spec, w, h);
8307
+ const rot = rotationAt(e, 0);
8308
+ 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) });
7960
8309
  const map = paintMap(spec, layer, w, h, scale);
7961
- const box = map ? { ...raw, ...mapBox(map, raw) } : raw;
8310
+ const both = rot && map ? (q) => map(turn(q)) : rot ? turn : map;
8311
+ const box = both ? { ...unturned, ...mapBox(both, unturned) } : unturned;
8312
+ const raw = rot ? { ...unturned, ...mapBox(turn, unturned) } : unturned;
7962
8313
  const baseAlpha = Math.max(0, Math.min(1, e.alpha - e.pulseAmp)) * (s.kind === "textBlock" ? s.opacity ?? 1 : 1);
7963
8314
  const alpha = layer.opacity === void 0 && layer.group === void 0 ? baseAlpha : baseAlpha * paintOpacity(spec, layer);
7964
8315
  const str = s.kind === "textBlock" ? s.text : s.strings[e.spriteIndex] ?? s.strings[0] ?? "";
@@ -8080,20 +8431,34 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
8080
8431
  for (const box of boxes) {
8081
8432
  const cy = (box.y0 + box.y1) / 2;
8082
8433
  const cx = (box.x0 + box.x1) / 2;
8083
- const groundRgb = backgroundRgbAt(spec, cy, h, scale, cx, w, opts.backgroundSeed ?? opts.seed, opts.t ?? COHESION_T);
8084
- const ground = legibilityRatio(textPlate(groundRgb, box.alpha), groundRgb);
8085
- const lit = brightestAdditivePlate(spec, allEntities, groundRgb, box, w, h);
8086
- const plate = lit ? legibilityRatio(textPlate(lit.rgb, box.alpha), lit.rgb) : Infinity;
8087
- if (!worst || Math.min(ground, plate) < Math.min(worst.ground, worst.plate)) {
8088
- worst = { ground, plate, plateLabel: lit?.label ?? null };
8434
+ const bgAt = (x, y) => backgroundRgbAt(spec, y, h, scale, x, w, opts.backgroundSeed ?? opts.seed, opts.t ?? COHESION_T);
8435
+ const ix = (box.x1 - box.x0) * 0.02;
8436
+ const iy = (box.y1 - box.y0) * 0.02;
8437
+ const probes = [[cx, cy], [box.x0 + ix, box.y0 + iy], [box.x1 - ix, box.y0 + iy], [box.x0 + ix, box.y1 - iy], [box.x1 - ix, box.y1 - iy]];
8438
+ const grounds = [];
8439
+ let bare = false;
8440
+ for (const [x, y] of probes) {
8441
+ const under = groundUnder(spec, allEntities, box.li, hidden, { x, y }, w, h, bgAt(x, y));
8442
+ if (under) grounds.push({ rgb: under.rgb, onSolid: true });
8443
+ else bare = true;
8444
+ }
8445
+ if (bare) grounds.push({ rgb: bgAt(cx, cy), onSolid: false });
8446
+ for (const g of grounds) {
8447
+ const ground = legibilityRatio(textPlate(g.rgb, box.alpha), g.rgb);
8448
+ const lit = brightestAdditivePlate(spec, allEntities, g.rgb, box, w, h);
8449
+ const plate = lit ? legibilityRatio(textPlate(lit.rgb, box.alpha), lit.rgb) : Infinity;
8450
+ if (!worst || Math.min(ground, plate) < Math.min(worst.ground, worst.plate)) {
8451
+ worst = { ground, plate, plateLabel: lit?.label ?? null, onSolid: g.onSolid };
8452
+ }
8089
8453
  }
8090
8454
  }
8091
8455
  if (worst && Math.min(worst.ground, worst.plate) < READ_LEGIBILITY_FLOOR) {
8092
8456
  const under = worst.plateLabel ? ` and ${worst.plate.toFixed(1)}:1 under ${worst.plateLabel} at its brightest` : "";
8457
+ const against = worst.onSolid ? "against the solid layer painted under it" : "against the background at the box centre";
8093
8458
  warnings.push({
8094
8459
  path: `layers[${b.li}].sprite`,
8095
8460
  code: "text-legibility",
8096
- message: `${b.label} is declared role: 'read' but its colour reads at ${worst.ground.toFixed(1)}:1 against the background at the box centre${under} \u2014 below ${READ_LEGIBILITY_FLOOR}:1; brighten the text, darken the ground under it, or declare role: 'atmosphere' if it is texture`
8461
+ message: `${b.label} is declared role: 'read' but its colour reads at ${worst.ground.toFixed(1)}:1 ${against}${under} \u2014 below ${READ_LEGIBILITY_FLOOR}:1; brighten the text, darken the ground under it, or declare role: 'atmosphere' if it is texture`
8097
8462
  });
8098
8463
  }
8099
8464
  const edges = [];
@@ -8116,6 +8481,21 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
8116
8481
  }
8117
8482
  return warnings;
8118
8483
  }
8484
+ function nearLimitFlash(spec) {
8485
+ if (!spec.timeline) return null;
8486
+ const { times, lap } = lumaKeyTimes(spec);
8487
+ let best = null;
8488
+ for (let i = 0, e = 0; i < times.length; i++) {
8489
+ while (e < times.length && times[e] < times[i] + 1e3) e++;
8490
+ if (e - i >= 4 && (!best || e - i > best.n)) best = { from: i, n: e - i };
8491
+ }
8492
+ if (!best) return null;
8493
+ const window = times.slice(best.from, best.from + Math.min(best.n, NEAR_FLASH_MAX_BOUNDARIES));
8494
+ const samples = flashSampleTimes(window).map((t) => ({ t, lum: luminanceGrid(spec, { t, cols: 16, rows: 9 }).cells.flat().map((v) => srgbToLinear3(v * 255)) }));
8495
+ const r = analyzeFlashes2(samples, { maxFlashesPerSecond: 1 }).general;
8496
+ if (!r.fails) return null;
8497
+ return { flashesPerSecond: r.worstTileFlashesPerSecond, area: r.flashingAreaFraction, at: lap ? window[0] % lap : window[0] };
8498
+ }
8119
8499
  function brightestAdditivePlate(spec, allEntities, ground, box, w, h) {
8120
8500
  let best = null;
8121
8501
  for (let lj = 0; lj < spec.layers.length; lj++) {
@@ -8143,19 +8523,49 @@ function brightestAdditivePlate(spec, allEntities, ground, box, w, h) {
8143
8523
  }
8144
8524
  return best ? { rgb: best.rgb, label: best.label } : null;
8145
8525
  }
8146
- function groundUnder(spec, allEntities, li, hidden, pt, w, h) {
8147
- let plate = backgroundRgb(spec);
8526
+ function holeFrame(spec, allEntities, li, hidden, pt, w, h) {
8527
+ for (let lj = li + 1; lj < spec.layers.length; lj++) {
8528
+ if (hidden?.[lj]) continue;
8529
+ const layer = spec.layers[lj];
8530
+ const sp = layer.sprite;
8531
+ if (sp.kind !== "polygon" || !isPathPolygon(sp) || sp.soft) continue;
8532
+ if (layer.motion.type !== "static" || layer.blend !== void 0 || layer.transform !== void 0 || groupOf(spec, layer)?.transform !== void 0) continue;
8533
+ if (flattenSprite(sp).subpaths < 2) continue;
8534
+ const a = layer.opacity === void 0 && layer.group === void 0 ? 1 : paintOpacity(spec, layer);
8535
+ for (const e of allEntities[lj]) {
8536
+ if (e.spinSpeed !== 0 || e.rotate || e.alpha * a < 0.9) continue;
8537
+ const c = positionAt(e, 0, w, h);
8538
+ const fr = pathFrame(sp, e.size / 2, w, h);
8539
+ const pts = flattenSprite(sp).points.map(([u, v]) => ({ x: (u - fr.ox) * fr.sx, y: (v - fr.oy) * fr.sy }));
8540
+ if (pts.length < 3) continue;
8541
+ const dx = pt.x - c.x;
8542
+ const dy = pt.y - c.y;
8543
+ if (dx < Math.min(...pts.map((q) => q.x)) || dx > Math.max(...pts.map((q) => q.x))) continue;
8544
+ if (dy < Math.min(...pts.map((q) => q.y)) || dy > Math.max(...pts.map((q) => q.y))) continue;
8545
+ if (pointInPolygon(dx, dy, pts)) continue;
8546
+ const ink = spriteInks(layer, e)?.[0];
8547
+ if (ink === void 0) continue;
8548
+ const rgb = hexRgb(ink);
8549
+ return { rgb, luma: 0.2126 * rgb.r + 0.7152 * rgb.g + 0.0722 * rgb.b };
8550
+ }
8551
+ }
8552
+ return null;
8553
+ }
8554
+ function groundUnder(spec, allEntities, li, hidden, pt, w, h, base = backgroundRgb(spec)) {
8555
+ let plate = base;
8148
8556
  let covered = false;
8149
8557
  for (let lj = 0; lj < li; lj++) {
8150
8558
  if (hidden?.[lj]) continue;
8151
8559
  const layer = spec.layers[lj];
8152
8560
  const sp = layer.sprite;
8153
8561
  if (layer.motion.type !== "static" || layer.blend !== void 0 || layer.transform !== void 0 || groupOf(spec, layer)?.transform !== void 0) continue;
8562
+ if (layer.life !== void 0 || layer.emit !== void 0) continue;
8154
8563
  if ((sp.kind === "circle" || sp.kind === "polygon") && sp.soft) continue;
8155
8564
  if (sp.kind !== "circle" && sp.kind !== "rect" && sp.kind !== "polygon") continue;
8565
+ if (sp.kind === "polygon" && isPathPolygon(sp)) continue;
8156
8566
  const a = layer.opacity === void 0 && layer.group === void 0 ? 1 : paintOpacity(spec, layer);
8157
8567
  for (const e of allEntities[lj]) {
8158
- if (e.spinSpeed !== 0 || e.alpha * a <= 0) continue;
8568
+ if (e.spinSpeed !== 0 || e.pulseAmp > 0 || e.growAmp > 0 || e.alpha * a <= 0) continue;
8159
8569
  const hex = spriteHex(layer, e);
8160
8570
  if (hex === null) continue;
8161
8571
  const c = positionAt(e, 0, w, h);
@@ -8208,7 +8618,7 @@ function isOutlineStack(a, b) {
8208
8618
  if (fs <= 0 || Math.abs(a.fs - b.fs) > 0.1 * Math.max(a.fs, b.fs)) return false;
8209
8619
  const dx = (a.x0 + a.x1) / 2 - (b.x0 + b.x1) / 2;
8210
8620
  const dy = (a.y0 + a.y1) / 2 - (b.y0 + b.y1) / 2;
8211
- return Math.hypot(dx, dy) <= OUTLINE_STACK_OFFSET * fs;
8621
+ return Math.max(Math.abs(dx), Math.abs(dy)) <= OUTLINE_STACK_OFFSET * fs;
8212
8622
  }
8213
8623
  function textBoxAt(s, e, p, spec, w, h) {
8214
8624
  const m = s.font ? /(\d{1,5}(?:\.\d{1,4})?|\.\d{1,4})px/.exec(s.font) : null;
@@ -10871,6 +11281,7 @@ export {
10871
11281
  adviseSequence,
10872
11282
  adviseSpec,
10873
11283
  alphaAt,
11284
+ applyDeltasShared,
10874
11285
  applyDeltasToSpec,
10875
11286
  assertValidSequence,
10876
11287
  assertValidSpec,
@@ -10879,6 +11290,7 @@ export {
10879
11290
  bedRenderSeed,
10880
11291
  breakTextBlock,
10881
11292
  buildEntities,
11293
+ buriedAreaOf,
10882
11294
  canMorph,
10883
11295
  canWrapMorph,
10884
11296
  canonicalSpecPath,
@@ -10960,6 +11372,7 @@ export {
10960
11372
  textStringsDiffer,
10961
11373
  textStringsOf,
10962
11374
  textWidthEm,
11375
+ timelineFlashReport,
10963
11376
  timelineLocalTime,
10964
11377
  timelineSampleTimes,
10965
11378
  timelineSampleTimesAfter,