@idle-screens/schema 3.0.1 → 3.1.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 +21 -8
- package/dist/index.d.ts +11 -4
- package/dist/index.js +257 -110
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/FORMAT.md
CHANGED
|
@@ -195,6 +195,14 @@ the format never changes the entity stream of an existing spec — a spec author
|
|
|
195
195
|
against an older runtime renders bit-identically on a newer one
|
|
196
196
|
(`determinism-baseline.test.ts` guards this with snapshot streams).
|
|
197
197
|
|
|
198
|
+
The same discipline applies to **meaning**, not just RNG draws: a new field may
|
|
199
|
+
introduce a default, but a default must never *reinterpret* values that already
|
|
200
|
+
exist in published specs. Ship the field opt-in first, flip the default only in
|
|
201
|
+
a major, and write the stored-spec migration in the same change. (Learned the
|
|
202
|
+
hard way when 2.3.0's `units: "viewport"` default invalidated live channels —
|
|
203
|
+
see idle-server's G5 spec stamping, which now heals reads but can't excuse a
|
|
204
|
+
repeat.)
|
|
205
|
+
|
|
198
206
|
Positions are **analytic**: `positionAt(entity, t)` is a pure function, so
|
|
199
207
|
`renderFrame(t, seed)` can seek anywhere instantly, trails sample the past for
|
|
200
208
|
free, and streak headings derive from a finite difference. `ghosting` is the
|
|
@@ -214,11 +222,15 @@ for `ControlTrack` and the idlescreens.com MCP `setParam` tool.
|
|
|
214
222
|
|
|
215
223
|
`src/perceive.ts` translates a spec into modalities a **non-vision agent** can
|
|
216
224
|
reason about, computed analytically from the entity model (no canvas, no
|
|
217
|
-
renderer, deterministic, Node-safe).
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
225
|
+
renderer, deterministic, Node-safe). Persistence is part of the picture:
|
|
226
|
+
`ghosting` is modeled as a decayed sum of past-frame splats (ink from m frames
|
|
227
|
+
ago survives at weight gᵐ, mirroring the renderer's warm-up replay) and `trail`
|
|
228
|
+
mirrors the renderer's past-position sampling — so the smear an audience sees
|
|
229
|
+
is measurable, not invisible, in every output below. Note: the analytical model
|
|
230
|
+
approximates but does not perfectly match canvas rendering — blend modes are
|
|
231
|
+
simplified (`screen` ≈ `lighter`), wrapped link segments use straight
|
|
232
|
+
interpolation, and background drift is sampled at rest. These are documented
|
|
233
|
+
trade-offs for a zero-dependency, renderer-free analysis tool.
|
|
222
234
|
|
|
223
235
|
- `perceiveScene(spec, {t?, viewport?, seed?})` — one-call bundle: everything below.
|
|
224
236
|
- `luminanceGrid(spec, opts)` — an 80×48 luminance image of the composed frame
|
|
@@ -234,9 +246,10 @@ zero-dependency, renderer-free analysis tool.
|
|
|
234
246
|
braille map smears — concentric rings, grids, text blocks. This is the sharper
|
|
235
247
|
read when composition detail matters.
|
|
236
248
|
- `dominanceRanking(spec, opts)` — layers ranked by estimated visual weight
|
|
237
|
-
(area × alpha × contrast × glow/motion boosts; links
|
|
238
|
-
|
|
239
|
-
|
|
249
|
+
(area × alpha × contrast × glow/motion boosts; links and persistence ink —
|
|
250
|
+
trail ribbons, ghosting smear — count) — *where the eye goes*, normalized to
|
|
251
|
+
shares. Thin-but-bright structures (rings, streaks, link lines) get a
|
|
252
|
+
line-salience boost so they aren't crushed by filled discs.
|
|
240
253
|
- `motionStats(spec, opts)` — per-layer mean/max on-screen speed from analytic
|
|
241
254
|
displacement — choreography as numbers.
|
|
242
255
|
- `textSprites(spec, opts)` — the literal strings and rendered sizes of every
|
package/dist/index.d.ts
CHANGED
|
@@ -698,9 +698,14 @@ interface LuminanceGridOptions extends PerceiveOptions {
|
|
|
698
698
|
}
|
|
699
699
|
/**
|
|
700
700
|
* Sample the spec into a coarse luminance image — analytically, no renderer.
|
|
701
|
-
*
|
|
702
|
-
*
|
|
703
|
-
*
|
|
701
|
+
* Persistence is modeled, not simulated: because positions are pure functions
|
|
702
|
+
* of (seed, t), `ghosting` is a decayed sum of past-frame splats (weight g^m
|
|
703
|
+
* for ink m frames old, mirroring the renderer's warm-up replay) and `trail`
|
|
704
|
+
* re-uses drawTrail's sampling — past positions with decaying alpha and
|
|
705
|
+
* shrinking radius. Remaining approximations (documented, deliberate): soft
|
|
706
|
+
* circles use a linear falloff, background drift is sampled at its rest
|
|
707
|
+
* position. Good enough to perceive composition, focus, balance — and now
|
|
708
|
+
* smear.
|
|
704
709
|
*/
|
|
705
710
|
declare function luminanceGrid(spec: SaverSpec, opts?: LuminanceGridOptions): LuminanceGrid;
|
|
706
711
|
/**
|
|
@@ -752,7 +757,9 @@ interface DominanceEntry {
|
|
|
752
757
|
/**
|
|
753
758
|
* Rank layers by estimated visual weight — where the eye goes. Weight is
|
|
754
759
|
* on-screen area × alpha, scaled by contrast against the background, an
|
|
755
|
-
* additive-glow boost, and a motion boost. Link lines count as area
|
|
760
|
+
* additive-glow boost, and a motion boost. Link lines count as area, and so
|
|
761
|
+
* does persistence ink: trail ribbons and ghosting smear are modeled as swept
|
|
762
|
+
* ribbons, so a comet layer is ranked by its comet, not just its head.
|
|
756
763
|
*/
|
|
757
764
|
declare function dominanceRanking(spec: SaverSpec, opts?: PerceiveOptions): DominanceEntry[];
|
|
758
765
|
interface LayerMotionStats {
|
package/dist/index.js
CHANGED
|
@@ -1203,6 +1203,9 @@ function manifestFor(spec) {
|
|
|
1203
1203
|
return {
|
|
1204
1204
|
id: spec.id,
|
|
1205
1205
|
label: spec.label,
|
|
1206
|
+
// Declarative specs are closed-form by construction: every entity is a
|
|
1207
|
+
// pure function of (seed, t), which is the whole point of the format.
|
|
1208
|
+
timeModel: "closed-form",
|
|
1206
1209
|
passthrough: false,
|
|
1207
1210
|
minBackend: "canvas2d",
|
|
1208
1211
|
costTier,
|
|
@@ -1652,6 +1655,67 @@ function compileSaver(spec) {
|
|
|
1652
1655
|
|
|
1653
1656
|
// src/advise.ts
|
|
1654
1657
|
import { createRng as createRng2 } from "@idle-screens/core";
|
|
1658
|
+
|
|
1659
|
+
// src/luma.ts
|
|
1660
|
+
function hexLuma(hex) {
|
|
1661
|
+
const h = hex.length === 4 ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}` : hex;
|
|
1662
|
+
const n = parseInt(h.slice(1), 16);
|
|
1663
|
+
if (Number.isNaN(n)) return 0.7;
|
|
1664
|
+
const r = (n >> 16 & 255) / 255;
|
|
1665
|
+
const g = (n >> 8 & 255) / 255;
|
|
1666
|
+
const b = (n & 255) / 255;
|
|
1667
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
1668
|
+
}
|
|
1669
|
+
function spriteLuma(layer, e) {
|
|
1670
|
+
const s = layer.sprite;
|
|
1671
|
+
if (s.kind === "circle" || s.kind === "ring" || s.kind === "streak" || s.kind === "rect") {
|
|
1672
|
+
return hexLuma(s.colors?.[e.colorIndex] ?? s.color);
|
|
1673
|
+
}
|
|
1674
|
+
if (s.kind === "text") return hexLuma(s.color ?? "#e6e8ef");
|
|
1675
|
+
return 0.75;
|
|
1676
|
+
}
|
|
1677
|
+
function spriteHex(layer, e) {
|
|
1678
|
+
const s = layer.sprite;
|
|
1679
|
+
if (s.kind === "circle" || s.kind === "ring" || s.kind === "streak" || s.kind === "rect") {
|
|
1680
|
+
return s.colors?.[e.colorIndex] ?? s.color;
|
|
1681
|
+
}
|
|
1682
|
+
if (s.kind === "text") return s.color ?? "#e6e8ef";
|
|
1683
|
+
return null;
|
|
1684
|
+
}
|
|
1685
|
+
function backgroundLuma(spec) {
|
|
1686
|
+
const bg = spec.background;
|
|
1687
|
+
if (!bg || bg.type === "solid") return hexLuma(bg?.color ?? "#05050a");
|
|
1688
|
+
return bg.stops.reduce((s, st) => s + hexLuma(st.color), 0) / bg.stops.length;
|
|
1689
|
+
}
|
|
1690
|
+
function hexRgb(hex) {
|
|
1691
|
+
const h = hex.length === 4 ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}` : hex;
|
|
1692
|
+
const n = parseInt(h.slice(1), 16);
|
|
1693
|
+
if (Number.isNaN(n)) return { r: 0.7, g: 0.7, b: 0.7 };
|
|
1694
|
+
return { r: (n >> 16 & 255) / 255, g: (n >> 8 & 255) / 255, b: (n & 255) / 255 };
|
|
1695
|
+
}
|
|
1696
|
+
function backgroundRgb(spec) {
|
|
1697
|
+
const bg = spec.background;
|
|
1698
|
+
if (!bg || bg.type === "solid") return hexRgb(bg?.color ?? "#05050a");
|
|
1699
|
+
const acc = bg.stops.reduce(
|
|
1700
|
+
(s, st) => {
|
|
1701
|
+
const c = hexRgb(st.color);
|
|
1702
|
+
return { r: s.r + c.r, g: s.g + c.g, b: s.b + c.b };
|
|
1703
|
+
},
|
|
1704
|
+
{ r: 0, g: 0, b: 0 }
|
|
1705
|
+
);
|
|
1706
|
+
const n = bg.stops.length || 1;
|
|
1707
|
+
return { r: acc.r / n, g: acc.g / n, b: acc.b / n };
|
|
1708
|
+
}
|
|
1709
|
+
function colourSeparation(layer, sprite, bg) {
|
|
1710
|
+
if (layer.blend === "lighter" || layer.blend === "screen") return sprite.luma;
|
|
1711
|
+
const dr = sprite.rgb.r - bg.rgb.r;
|
|
1712
|
+
const dg = sprite.rgb.g - bg.rgb.g;
|
|
1713
|
+
const db = sprite.rgb.b - bg.rgb.b;
|
|
1714
|
+
return Math.sqrt((dr * dr + dg * dg + db * db) / 3);
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
// src/advise.ts
|
|
1718
|
+
var LOW_CONTRAST_FLOOR = 0.05;
|
|
1655
1719
|
function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
|
|
1656
1720
|
const warnings = [];
|
|
1657
1721
|
const w = viewport.width;
|
|
@@ -1661,6 +1725,8 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
|
|
|
1661
1725
|
const countScale = scale > 1 ? Math.min(w, h) / refVp : 1;
|
|
1662
1726
|
const rng = createRng2(spec.seed ?? 42);
|
|
1663
1727
|
const allEntities = spec.layers.map((l) => buildEntities(l, rng, w, h, scale, countScale));
|
|
1728
|
+
const bgLuma = backgroundLuma(spec);
|
|
1729
|
+
const bgRgb = backgroundRgb(spec);
|
|
1664
1730
|
let totalEntities = 0;
|
|
1665
1731
|
let textLayerCount = 0;
|
|
1666
1732
|
let motionLayerCount = 0;
|
|
@@ -1704,6 +1770,22 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
|
|
|
1704
1770
|
});
|
|
1705
1771
|
}
|
|
1706
1772
|
}
|
|
1773
|
+
if (entities.length > 0 && layer.sprite.kind !== "emoji") {
|
|
1774
|
+
const seps = entities.map((e) => {
|
|
1775
|
+
const hex = spriteHex(layer, e);
|
|
1776
|
+
if (hex === null) return Infinity;
|
|
1777
|
+
return colourSeparation(layer, { rgb: hexRgb(hex), luma: hexLuma(hex) }, { rgb: bgRgb, luma: bgLuma });
|
|
1778
|
+
});
|
|
1779
|
+
const best = Math.max(...seps);
|
|
1780
|
+
if (best < LOW_CONTRAST_FLOOR) {
|
|
1781
|
+
const additive = layer.blend === "lighter" || layer.blend === "screen";
|
|
1782
|
+
warnings.push({
|
|
1783
|
+
path: `layers[${li}].sprite`,
|
|
1784
|
+
code: "low-contrast-layer",
|
|
1785
|
+
message: additive ? `additive layer adds almost no light (luma ${best.toFixed(3)}) \u2014 a "lighter"/"screen" layer is only as visible as it is bright` : `layer colour is within ${best.toFixed(3)} of the background (floor ${LOW_CONTRAST_FLOOR}) \u2014 it will read as background, not as content`
|
|
1786
|
+
});
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1707
1789
|
}
|
|
1708
1790
|
let totalCoverage = 0;
|
|
1709
1791
|
for (let li = 0; li < spec.layers.length; li++) {
|
|
@@ -1896,6 +1978,7 @@ function describeScene(spec, options = {}) {
|
|
|
1896
1978
|
import { createRng as createRng4 } from "@idle-screens/core";
|
|
1897
1979
|
var GLOW_SPREAD = 2.4;
|
|
1898
1980
|
var LINE_SALIENCE = 3.5;
|
|
1981
|
+
var GHOST_TAPS = 12;
|
|
1899
1982
|
function buildScene(spec, opts) {
|
|
1900
1983
|
const { width: w, height: h } = opts.viewport ?? { width: 1920, height: 1080 };
|
|
1901
1984
|
const seed = opts.seed ?? spec.seed ?? 42;
|
|
@@ -1924,23 +2007,6 @@ function posOf(scene, e, t) {
|
|
|
1924
2007
|
}
|
|
1925
2008
|
return p;
|
|
1926
2009
|
}
|
|
1927
|
-
function hexLuma(hex) {
|
|
1928
|
-
const h = hex.length === 4 ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}` : hex;
|
|
1929
|
-
const n = parseInt(h.slice(1), 16);
|
|
1930
|
-
if (Number.isNaN(n)) return 0.7;
|
|
1931
|
-
const r = (n >> 16 & 255) / 255;
|
|
1932
|
-
const g = (n >> 8 & 255) / 255;
|
|
1933
|
-
const b = (n & 255) / 255;
|
|
1934
|
-
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
1935
|
-
}
|
|
1936
|
-
function spriteLuma(layer, e) {
|
|
1937
|
-
const s = layer.sprite;
|
|
1938
|
-
if (s.kind === "circle" || s.kind === "ring" || s.kind === "streak" || s.kind === "rect") {
|
|
1939
|
-
return hexLuma(s.colors?.[e.colorIndex] ?? s.color);
|
|
1940
|
-
}
|
|
1941
|
-
if (s.kind === "text") return hexLuma(s.color ?? "#e6e8ef");
|
|
1942
|
-
return 0.75;
|
|
1943
|
-
}
|
|
1944
2010
|
function textBox(s, e, p) {
|
|
1945
2011
|
const m = s.font ? /(\d+(?:\.\d+)?)px/.exec(s.font) : null;
|
|
1946
2012
|
const fh = m ? Number(m[1]) : e.size;
|
|
@@ -1998,105 +2064,163 @@ function luminanceGrid(spec, opts = {}) {
|
|
|
1998
2064
|
else if (blend === "multiply") cells[idx] = cur * (1 - a * (1 - lum));
|
|
1999
2065
|
else cells[idx] = cur * (1 - a) + lum * a;
|
|
2000
2066
|
};
|
|
2001
|
-
|
|
2002
|
-
const
|
|
2003
|
-
if (
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
if (s.kind === "text") {
|
|
2031
|
-
const box = textBox(s, e, p);
|
|
2032
|
-
centerX = box.cx;
|
|
2033
|
-
centerY = box.cy;
|
|
2034
|
-
halfX = box.halfX;
|
|
2035
|
-
halfY = box.halfY;
|
|
2036
|
-
}
|
|
2037
|
-
const circular = s.kind === "circle" || s.kind === "ring";
|
|
2038
|
-
const soft = s.kind === "circle" && !!s.soft;
|
|
2039
|
-
const additive = layer.blend === "lighter" || layer.blend === "screen";
|
|
2040
|
-
const glow = soft && additive;
|
|
2041
|
-
const reachX = glow ? halfX * GLOW_SPREAD : halfX;
|
|
2042
|
-
const reachY = glow ? halfY * GLOW_SPREAD : halfY;
|
|
2043
|
-
const c0 = Math.max(0, Math.floor((centerX - reachX) / cellW));
|
|
2044
|
-
const c1 = Math.min(cols - 1, Math.floor((centerX + reachX) / cellW));
|
|
2045
|
-
const r0 = Math.max(0, Math.floor((centerY - reachY) / cellH));
|
|
2046
|
-
const r1 = Math.min(rows - 1, Math.floor((centerY + reachY) / cellH));
|
|
2047
|
-
const inkWeight = s.kind === "text" || s.kind === "emoji" ? 0.55 : 1;
|
|
2067
|
+
const splatTrail = (layer, e, tPass, lifeA, alphaScale) => {
|
|
2068
|
+
const trail = layer.trail;
|
|
2069
|
+
if (!trail) return;
|
|
2070
|
+
const fade = trail.fade ?? 1;
|
|
2071
|
+
const n = Math.min(Math.ceil(trail.length / 50), LIMITS.maxTrailSamples);
|
|
2072
|
+
const headAlpha = alphaAt(e, tPass) * lifeA;
|
|
2073
|
+
const headSize = sizeAt(e, tPass);
|
|
2074
|
+
const lum = spriteLuma(layer, e);
|
|
2075
|
+
const soft = layer.sprite.kind === "circle" && !!layer.sprite.soft;
|
|
2076
|
+
const wrapOn = layer.wrap !== false;
|
|
2077
|
+
const head = posOf(scene, e, tPass);
|
|
2078
|
+
let prevX = head.x;
|
|
2079
|
+
let prevY = head.y;
|
|
2080
|
+
for (let s = 1; s <= n; s++) {
|
|
2081
|
+
const k = s / n;
|
|
2082
|
+
const pastT = tPass - k * trail.length;
|
|
2083
|
+
if (pastT < 0) break;
|
|
2084
|
+
const pos = posOf(scene, e, pastT);
|
|
2085
|
+
if (wrapOn && (Math.abs(pos.x - prevX) > w / 2 || Math.abs(pos.y - prevY) > h / 2)) break;
|
|
2086
|
+
prevX = pos.x;
|
|
2087
|
+
prevY = pos.y;
|
|
2088
|
+
const a = headAlpha * (1 - k * fade) * alphaScale;
|
|
2089
|
+
if (a <= 4e-3) break;
|
|
2090
|
+
const rad = headSize / 2 * (1 - k * 0.7);
|
|
2091
|
+
if (rad < 0.2) break;
|
|
2092
|
+
const c0 = Math.max(0, Math.floor((pos.x - rad) / cellW));
|
|
2093
|
+
const c1 = Math.min(cols - 1, Math.floor((pos.x + rad) / cellW));
|
|
2094
|
+
const r0 = Math.max(0, Math.floor((pos.y - rad) / cellH));
|
|
2095
|
+
const r1 = Math.min(rows - 1, Math.floor((pos.y + rad) / cellH));
|
|
2048
2096
|
for (let r = r0; r <= r1; r++) {
|
|
2049
2097
|
for (let c = c0; c <= c1; c++) {
|
|
2050
|
-
const dx = (c + 0.5) * cellW -
|
|
2051
|
-
const dy = (r + 0.5) * cellH -
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2098
|
+
const dx = (c + 0.5) * cellW - pos.x;
|
|
2099
|
+
const dy = (r + 0.5) * cellH - pos.y;
|
|
2100
|
+
const d = Math.sqrt(dx * dx + dy * dy);
|
|
2101
|
+
if (d > rad + Math.min(cellW, cellH) / 2) continue;
|
|
2102
|
+
const wgt = soft ? Math.max(0.1, 1 - d / Math.max(rad, 1e-6)) : 1;
|
|
2103
|
+
compose(r * cols + c, lum, Math.min(1, a * wgt), layer.blend);
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
};
|
|
2108
|
+
const splatPass = (tPass, alphaScale) => {
|
|
2109
|
+
for (const { layer, entities } of scene.layers) {
|
|
2110
|
+
const lifeA = lifeAlphaAt(layer.life, tPass);
|
|
2111
|
+
if (lifeA <= 0) continue;
|
|
2112
|
+
for (const e of entities) {
|
|
2113
|
+
splatTrail(layer, e, tPass, lifeA, alphaScale);
|
|
2114
|
+
const a = Math.min(1, alphaAt(e, tPass) * lifeA) * alphaScale;
|
|
2115
|
+
if (a <= 4e-3) continue;
|
|
2116
|
+
const lum = spriteLuma(layer, e);
|
|
2117
|
+
const p = posOf(scene, e, tPass);
|
|
2118
|
+
const sz = sizeAt(e, tPass);
|
|
2119
|
+
const s = layer.sprite;
|
|
2120
|
+
if (s.kind === "streak") {
|
|
2121
|
+
const heading = headingAt(e, tPass, w, h) ?? 0;
|
|
2122
|
+
const steps = Math.max(2, Math.ceil(sz / Math.min(cellW, cellH)));
|
|
2123
|
+
const wgt = Math.min(1, (s.width ?? (scale === 1 ? 2 : 2e-3)) * scale / cellH);
|
|
2124
|
+
for (let i = 0; i <= steps; i++) {
|
|
2125
|
+
const k = i / steps;
|
|
2126
|
+
const x = p.x - Math.cos(heading) * sz * (1 - k);
|
|
2127
|
+
const y = p.y - Math.sin(heading) * sz * (1 - k);
|
|
2128
|
+
const c = Math.floor(x / cellW);
|
|
2129
|
+
const r = Math.floor(y / cellH);
|
|
2130
|
+
if (c < 0 || c >= cols || r < 0 || r >= rows) continue;
|
|
2131
|
+
compose(r * cols + c, lum, a * k * wgt, layer.blend);
|
|
2132
|
+
}
|
|
2133
|
+
continue;
|
|
2134
|
+
}
|
|
2135
|
+
let centerX = p.x;
|
|
2136
|
+
let centerY = p.y;
|
|
2137
|
+
let halfX = sz / 2;
|
|
2138
|
+
let halfY = s.kind === "rect" ? (e.size2 ?? sz) * (e.size > 0 ? sz / e.size : 1) / 2 : sz / 2;
|
|
2139
|
+
if (s.kind === "text") {
|
|
2140
|
+
const box = textBox(s, e, p);
|
|
2141
|
+
centerX = box.cx;
|
|
2142
|
+
centerY = box.cy;
|
|
2143
|
+
halfX = box.halfX;
|
|
2144
|
+
halfY = box.halfY;
|
|
2145
|
+
}
|
|
2146
|
+
const circular = s.kind === "circle" || s.kind === "ring";
|
|
2147
|
+
const soft = s.kind === "circle" && !!s.soft;
|
|
2148
|
+
const additive = layer.blend === "lighter" || layer.blend === "screen";
|
|
2149
|
+
const glow = soft && additive;
|
|
2150
|
+
const reachX = glow ? halfX * GLOW_SPREAD : halfX;
|
|
2151
|
+
const reachY = glow ? halfY * GLOW_SPREAD : halfY;
|
|
2152
|
+
const c0 = Math.max(0, Math.floor((centerX - reachX) / cellW));
|
|
2153
|
+
const c1 = Math.min(cols - 1, Math.floor((centerX + reachX) / cellW));
|
|
2154
|
+
const r0 = Math.max(0, Math.floor((centerY - reachY) / cellH));
|
|
2155
|
+
const r1 = Math.min(rows - 1, Math.floor((centerY + reachY) / cellH));
|
|
2156
|
+
const inkWeight = s.kind === "text" || s.kind === "emoji" ? 0.55 : 1;
|
|
2157
|
+
for (let r = r0; r <= r1; r++) {
|
|
2158
|
+
for (let c = c0; c <= c1; c++) {
|
|
2159
|
+
const dx = (c + 0.5) * cellW - centerX;
|
|
2160
|
+
const dy = (r + 0.5) * cellH - centerY;
|
|
2161
|
+
if (circular) {
|
|
2162
|
+
const d = Math.sqrt(dx * dx + dy * dy);
|
|
2163
|
+
const reach = glow ? reachX : halfX;
|
|
2164
|
+
if (d > reach + Math.min(cellW, cellH) / 2) continue;
|
|
2165
|
+
if (s.kind === "ring") {
|
|
2166
|
+
const strokeW = (s.width ?? (scale === 1 ? 2 : 2e-3)) * scale;
|
|
2167
|
+
if (d < halfX - strokeW) continue;
|
|
2067
2168
|
}
|
|
2169
|
+
let wgt;
|
|
2170
|
+
if (glow) {
|
|
2171
|
+
const core = halfX;
|
|
2172
|
+
if (d <= core) wgt = 1;
|
|
2173
|
+
else {
|
|
2174
|
+
const f = Math.max(0, 1 - (d - core) / Math.max(reachX - core, 1e-6));
|
|
2175
|
+
wgt = f * f;
|
|
2176
|
+
}
|
|
2177
|
+
} else {
|
|
2178
|
+
wgt = soft ? Math.max(0.1, 1 - d / Math.max(halfX, 1e-6)) : 1;
|
|
2179
|
+
}
|
|
2180
|
+
compose(r * cols + c, lum, a * wgt, layer.blend);
|
|
2068
2181
|
} else {
|
|
2069
|
-
|
|
2182
|
+
compose(r * cols + c, lum, a * inkWeight, layer.blend);
|
|
2070
2183
|
}
|
|
2071
|
-
compose(r * cols + c, lum, a * wgt, layer.blend);
|
|
2072
|
-
} else {
|
|
2073
|
-
compose(r * cols + c, lum, a * inkWeight, layer.blend);
|
|
2074
2184
|
}
|
|
2075
2185
|
}
|
|
2076
2186
|
}
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2187
|
+
if (layer.links) {
|
|
2188
|
+
const positions = entities.map((e) => posOf(scene, e, tPass));
|
|
2189
|
+
const maxDistPx = layer.links.maxDist * scale;
|
|
2190
|
+
const motionWraps = ["drift", "rise", "wander"].includes(layer.motion.type);
|
|
2191
|
+
const edges = linkEdges(layer.links, positions, maxDistPx, layer.wrap !== false && motionWraps, w, h);
|
|
2192
|
+
const la = Math.min(1, (layer.links.alpha ?? 0.6) * lifeA);
|
|
2193
|
+
const lum = layer.links.color ? hexLuma(layer.links.color) : 0.7;
|
|
2194
|
+
const wgt = Math.min(1, (layer.links.width ?? 1) * scale / cellH) * la * alphaScale;
|
|
2195
|
+
for (const edge of edges) {
|
|
2196
|
+
const pi = positions[edge.i];
|
|
2197
|
+
const pj = positions[edge.j];
|
|
2198
|
+
const steps = Math.max(1, Math.ceil(edge.dist / Math.min(cellW, cellH)));
|
|
2199
|
+
for (let i = 0; i <= steps; i++) {
|
|
2200
|
+
const k = i / steps;
|
|
2201
|
+
const c = Math.floor((pi.x + (pj.x - pi.x) * k) / cellW);
|
|
2202
|
+
const r = Math.floor((pi.y + (pj.y - pi.y) * k) / cellH);
|
|
2203
|
+
if (c < 0 || c >= cols || r < 0 || r >= rows) continue;
|
|
2204
|
+
compose(r * cols + c, lum, wgt, layer.blend);
|
|
2205
|
+
}
|
|
2096
2206
|
}
|
|
2097
2207
|
}
|
|
2098
2208
|
}
|
|
2099
|
-
}
|
|
2209
|
+
};
|
|
2210
|
+
const ghost = spec.ghosting ?? 0;
|
|
2211
|
+
if (ghost > 0) {
|
|
2212
|
+
const dt = 1e3 / 60;
|
|
2213
|
+
const windowFrames = Math.min(Math.ceil(Math.log(1 / 255) / Math.log(ghost)), LIMITS.maxGhostReplayFrames);
|
|
2214
|
+
const taps = Math.min(GHOST_TAPS, windowFrames);
|
|
2215
|
+
const spacing = windowFrames / taps;
|
|
2216
|
+
for (let i = taps; i >= 1; i--) {
|
|
2217
|
+
const tPass = t - i * spacing * dt;
|
|
2218
|
+
if (tPass < 0) continue;
|
|
2219
|
+
const bracketWeight = Math.pow(ghost, (i - 1) * spacing + 1) * (1 - Math.pow(ghost, spacing)) / (1 - ghost);
|
|
2220
|
+
splatPass(tPass, Math.min(1, bracketWeight));
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
splatPass(t, 1);
|
|
2100
2224
|
let sum = 0;
|
|
2101
2225
|
let covered = 0;
|
|
2102
2226
|
let devSum = 0;
|
|
@@ -2200,11 +2324,26 @@ function dominanceRanking(spec, opts = {}) {
|
|
|
2200
2324
|
const scene = buildScene(spec, opts);
|
|
2201
2325
|
const t = opts.t ?? 5e3;
|
|
2202
2326
|
const { w, h, scale } = scene;
|
|
2203
|
-
const bgMean = (
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2327
|
+
const bgMean = backgroundLuma(spec);
|
|
2328
|
+
const ghost = spec.ghosting ?? 0;
|
|
2329
|
+
let ghostWindowMs = 0;
|
|
2330
|
+
let ghostMeanW = 0;
|
|
2331
|
+
if (ghost > 0) {
|
|
2332
|
+
const dt = 1e3 / 60;
|
|
2333
|
+
const frames = Math.min(Math.ceil(Math.log(1 / 255) / Math.log(ghost)), LIMITS.maxGhostReplayFrames);
|
|
2334
|
+
ghostWindowMs = frames * dt;
|
|
2335
|
+
ghostMeanW = ghost * (1 - Math.pow(ghost, frames)) / (1 - ghost) / frames;
|
|
2336
|
+
}
|
|
2337
|
+
const sweptDistance = (e, spanMs) => {
|
|
2338
|
+
const span = Math.min(spanMs, t);
|
|
2339
|
+
if (span <= 0) return 0;
|
|
2340
|
+
const p0 = posOf(scene, e, t - span);
|
|
2341
|
+
const p1 = posOf(scene, e, t);
|
|
2342
|
+
const dx = p1.x - p0.x;
|
|
2343
|
+
const dy = p1.y - p0.y;
|
|
2344
|
+
if (Math.abs(dx) > w / 2 || Math.abs(dy) > h / 2) return 0;
|
|
2345
|
+
return Math.sqrt(dx * dx + dy * dy);
|
|
2346
|
+
};
|
|
2208
2347
|
const raw = scene.layers.map(({ layer, entities }, layerIndex) => {
|
|
2209
2348
|
const lifeA = lifeAlphaAt(layer.life, t);
|
|
2210
2349
|
let area = 0;
|
|
@@ -2225,6 +2364,14 @@ function dominanceRanking(spec, opts = {}) {
|
|
|
2225
2364
|
entArea = box.halfX * 2 * box.halfY * 2 * 0.55;
|
|
2226
2365
|
} else entArea = sz * sz * 0.55;
|
|
2227
2366
|
area += entArea * a;
|
|
2367
|
+
if (layer.trail) {
|
|
2368
|
+
const fade = layer.trail.fade ?? 1;
|
|
2369
|
+
const span = sweptDistance(e, layer.trail.length);
|
|
2370
|
+
area += span * sz * 0.65 * Math.max(0, 1 - fade / 2) * a;
|
|
2371
|
+
}
|
|
2372
|
+
if (ghost > 0) {
|
|
2373
|
+
area += sweptDistance(e, ghostWindowMs) * sz * ghostMeanW * a;
|
|
2374
|
+
}
|
|
2228
2375
|
lumAcc += spriteLuma(layer, e);
|
|
2229
2376
|
}
|
|
2230
2377
|
if (layer.links && entities.length > 1) {
|