@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/FORMAT.md +27 -5
- package/dist/index.d.ts +42 -3
- package/dist/index.js +483 -70
- package/dist/index.js.map +1 -1
- package/package.json +4 -1
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
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
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
|
|
2477
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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")
|
|
7358
|
-
|
|
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
|
|
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
|
-
|
|
7486
|
-
|
|
7487
|
-
if (
|
|
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
|
|
7776
|
-
if (
|
|
7777
|
-
|
|
7778
|
-
|
|
7779
|
-
|
|
7780
|
-
|
|
7781
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
8084
|
-
const
|
|
8085
|
-
const
|
|
8086
|
-
const
|
|
8087
|
-
|
|
8088
|
-
|
|
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
|
|
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
|
|
8147
|
-
let
|
|
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.
|
|
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,
|