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