@idle-screens/schema 3.6.0 → 3.7.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 +396 -41
- package/dist/index.d.ts +276 -8
- package/dist/index.js +881 -99
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/saver-spec.schema.json +126 -5
package/dist/index.js
CHANGED
|
@@ -46,7 +46,8 @@ var LIMITS = {
|
|
|
46
46
|
maxTextBlockLength: 2e3,
|
|
47
47
|
minTextBlockFontSize: 0.01,
|
|
48
48
|
maxTextBlockFontSize: 0.2,
|
|
49
|
-
maxTextBlockMaxWidth:
|
|
49
|
+
maxTextBlockMaxWidth: 2,
|
|
50
|
+
// of min(w,h) — wider than the frame is legal; text-off-screen reports the overflow
|
|
50
51
|
maxRevealSpeed: 120,
|
|
51
52
|
// graphemes/sec — faster than any readable typing
|
|
52
53
|
maxCaretBlinkHz: 3,
|
|
@@ -83,12 +84,13 @@ var LIMITS = {
|
|
|
83
84
|
|
|
84
85
|
// src/steer.ts
|
|
85
86
|
var UNSAFE_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
87
|
+
var STEERABLE_ROOT_KEYS = /* @__PURE__ */ new Set(["ghosting", "referenceViewport"]);
|
|
86
88
|
function resolveSpecPath(spec, path) {
|
|
87
89
|
if (!spec || typeof spec !== "object" || !path) return null;
|
|
88
90
|
const parts = path.split(".");
|
|
89
91
|
if (parts.some((p) => UNSAFE_KEYS.has(p))) return null;
|
|
90
92
|
const s = spec;
|
|
91
|
-
if (parts[0] !== "layers" && parts[0] !== "background" && Array.isArray(s.layers)) {
|
|
93
|
+
if (parts[0] !== "layers" && parts[0] !== "background" && !STEERABLE_ROOT_KEYS.has(parts[0]) && Array.isArray(s.layers)) {
|
|
92
94
|
const idx = s.layers.findIndex((l) => l && l.key === parts[0]);
|
|
93
95
|
if (idx === -1) return null;
|
|
94
96
|
parts.splice(0, 1, "layers", String(idx));
|
|
@@ -148,9 +150,61 @@ function lerpSpec(from, to, k) {
|
|
|
148
150
|
};
|
|
149
151
|
return walk(from, to);
|
|
150
152
|
}
|
|
153
|
+
function textStringsOf(sprite) {
|
|
154
|
+
if (sprite.kind === "text") return sprite.strings;
|
|
155
|
+
if (sprite.kind === "textBlock") return [sprite.text];
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
function textStringsDiffer(a, b, i) {
|
|
159
|
+
const sa = a.layers[i]?.sprite;
|
|
160
|
+
const sb = b.layers[i]?.sprite;
|
|
161
|
+
if (!sa || !sb) return false;
|
|
162
|
+
const ta = textStringsOf(sa);
|
|
163
|
+
const tb = textStringsOf(sb);
|
|
164
|
+
if (!ta || !tb) return false;
|
|
165
|
+
return ta.length !== tb.length || ta.some((s, j) => s !== tb[j]);
|
|
166
|
+
}
|
|
167
|
+
var NON_RENDERED_KEYS = /* @__PURE__ */ new Set(["id", "label", "schemaVersion", "key"]);
|
|
168
|
+
function morphNothingMorphable(a, b, opts = {}) {
|
|
169
|
+
if (opts.textCrossfade && a.layers.some((_, i) => textStringsDiffer(a, b, i))) return false;
|
|
170
|
+
let hasDiff = false;
|
|
171
|
+
let hasGlide = false;
|
|
172
|
+
const walk = (x, y, key) => {
|
|
173
|
+
if (key !== void 0 && NON_RENDERED_KEYS.has(key)) return;
|
|
174
|
+
if (x === y) return;
|
|
175
|
+
if (typeof x === "number" && typeof y === "number") {
|
|
176
|
+
hasDiff = true;
|
|
177
|
+
hasGlide = true;
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (typeof x === "string" && typeof y === "string" && HEX.test(x) && HEX.test(y)) {
|
|
181
|
+
const ca = hexToRgb(x);
|
|
182
|
+
const cb = hexToRgb(y);
|
|
183
|
+
if (ca.some((v, i) => v !== cb[i])) {
|
|
184
|
+
hasDiff = true;
|
|
185
|
+
hasGlide = true;
|
|
186
|
+
}
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (Array.isArray(x) && Array.isArray(y) && x.length === y.length) {
|
|
190
|
+
for (let i = 0; i < y.length; i++) walk(x[i], y[i]);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (x && y && typeof x === "object" && typeof y === "object" && !Array.isArray(x) && !Array.isArray(y)) {
|
|
194
|
+
const keys = /* @__PURE__ */ new Set([...Object.keys(x), ...Object.keys(y)]);
|
|
195
|
+
for (const k of keys) {
|
|
196
|
+
walk(x[k], y[k], k);
|
|
197
|
+
}
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
hasDiff = true;
|
|
201
|
+
};
|
|
202
|
+
walk(a, b);
|
|
203
|
+
return hasDiff && !hasGlide;
|
|
204
|
+
}
|
|
151
205
|
function steerablePaths(spec) {
|
|
152
206
|
if (!spec || typeof spec !== "object") return [];
|
|
153
|
-
const SKIP = /* @__PURE__ */ new Set(["kind", "type", "key", "schemaVersion", "id", "label", "seed", "units", "mode", "curve", "layer"]);
|
|
207
|
+
const SKIP = /* @__PURE__ */ new Set(["kind", "type", "key", "schemaVersion", "id", "label", "seed", "units", "motionIntensity", "mode", "curve", "layer"]);
|
|
154
208
|
const INDEXED = /* @__PURE__ */ new Set(["layers", "stops"]);
|
|
155
209
|
const out = [];
|
|
156
210
|
const walk = (node, prefix, key) => {
|
|
@@ -174,6 +228,12 @@ function steerablePaths(spec) {
|
|
|
174
228
|
walk(spec, "", "");
|
|
175
229
|
return out;
|
|
176
230
|
}
|
|
231
|
+
function sequenceSteerablePaths(seq) {
|
|
232
|
+
const out = /* @__PURE__ */ new Set(["sequence.segment"]);
|
|
233
|
+
if (seq.bed) for (const p of steerablePaths(seq.bed)) out.add(`bed.${p}`);
|
|
234
|
+
for (const seg of seq.segments) for (const p of steerablePaths(seg.scene)) out.add(p);
|
|
235
|
+
return [...out];
|
|
236
|
+
}
|
|
177
237
|
function easeSmooth(k) {
|
|
178
238
|
const c = Math.max(0, Math.min(1, k));
|
|
179
239
|
return c * c * (3 - 2 * c);
|
|
@@ -181,6 +241,7 @@ function easeSmooth(k) {
|
|
|
181
241
|
function structuralSignature(spec) {
|
|
182
242
|
return JSON.stringify([
|
|
183
243
|
spec.units,
|
|
244
|
+
spec.referenceViewport,
|
|
184
245
|
spec.layers.map((l) => {
|
|
185
246
|
const s = l.sprite;
|
|
186
247
|
return [
|
|
@@ -206,7 +267,7 @@ function structuralSignature(spec) {
|
|
|
206
267
|
l.sprite.kind === "rect" ? [s.width, s.aspect] : l.sprite.kind === "bar" ? [s.length, s.thickness] : void 0,
|
|
207
268
|
Array.isArray(s.colors) ? s.colors.length : void 0,
|
|
208
269
|
s.colorWeights,
|
|
209
|
-
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.fontSize] : void 0
|
|
270
|
+
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
|
|
210
271
|
];
|
|
211
272
|
})
|
|
212
273
|
]);
|
|
@@ -249,8 +310,11 @@ var KNOWN_BAR = /* @__PURE__ */ new Set(["kind", "values", "length", "thickness"
|
|
|
249
310
|
var KNOWN_POLYGON = /* @__PURE__ */ new Set(["kind", "radius", "color", "sides", "points", "soft", "colors", "colorWeights"]);
|
|
250
311
|
var KNOWN_STROKE = /* @__PURE__ */ new Set(["kind", "length", "points", "color", "width", "curve", "taper", "orient", "colors", "colorWeights"]);
|
|
251
312
|
var KNOWN_EMOJI = /* @__PURE__ */ new Set(["kind", "glyphs", "cycle"]);
|
|
252
|
-
var KNOWN_TEXT = /* @__PURE__ */ new Set(["kind", "strings", "color", "font", "align", "baseline", "maxWidth", "cycle"]);
|
|
253
|
-
var KNOWN_TEXT_BLOCK = /* @__PURE__ */ new Set(["kind", "text", "maxWidth", "fontSize", "lineHeight", "align", "color", "reveal"]);
|
|
313
|
+
var KNOWN_TEXT = /* @__PURE__ */ new Set(["kind", "strings", "color", "font", "align", "baseline", "maxWidth", "cycle", "role"]);
|
|
314
|
+
var KNOWN_TEXT_BLOCK = /* @__PURE__ */ new Set(["kind", "text", "maxWidth", "fontSize", "lineHeight", "align", "color", "reveal", "anchor", "font", "opacity", "role"]);
|
|
315
|
+
var TEXT_ROLES = /* @__PURE__ */ new Set(["read", "atmosphere"]);
|
|
316
|
+
var TEXT_BLOCK_ANCHORS = /* @__PURE__ */ new Set(["top-left", "top", "top-right", "left", "center", "right", "bottom-left", "bottom", "bottom-right"]);
|
|
317
|
+
var FONT_SIZE_RE = /\d(?:px|pt|pc|em|rem|ex|ch|vw|vh|vmin|vmax|%)/i;
|
|
254
318
|
var KNOWN_REVEAL = /* @__PURE__ */ new Set(["progress", "mode", "speed", "caret", "fade"]);
|
|
255
319
|
var KNOWN_REVEAL_CARET = /* @__PURE__ */ new Set(["blink", "color"]);
|
|
256
320
|
var KNOWN_DRIFT = /* @__PURE__ */ new Set(["type", "speed", "angle", "bidirectional", "bob", "ease"]);
|
|
@@ -660,6 +724,9 @@ function validateSprite(sprite, path, err, warn) {
|
|
|
660
724
|
err(`${path}.strings`, "must be a non-empty array of strings");
|
|
661
725
|
}
|
|
662
726
|
if (sprite.color !== void 0) color(sprite.color, `${path}.color`, err);
|
|
727
|
+
if (sprite.font !== void 0 && !isStr(sprite.font)) {
|
|
728
|
+
err(`${path}.font`, "must be a string");
|
|
729
|
+
}
|
|
663
730
|
if (sprite.align !== void 0 && !["left", "center", "right"].includes(sprite.align)) {
|
|
664
731
|
err(`${path}.align`, "must be left | center | right");
|
|
665
732
|
}
|
|
@@ -669,6 +736,7 @@ function validateSprite(sprite, path, err, warn) {
|
|
|
669
736
|
if (sprite.maxWidth !== void 0 && (!isNum(sprite.maxWidth) || sprite.maxWidth <= 0)) {
|
|
670
737
|
err(`${path}.maxWidth`, "must be a positive number");
|
|
671
738
|
}
|
|
739
|
+
validateTextRole(sprite, path, err);
|
|
672
740
|
validateCycle(sprite, path, err);
|
|
673
741
|
} else if (sprite.kind === "circle") {
|
|
674
742
|
knownSet = KNOWN_CIRCLE;
|
|
@@ -755,6 +823,20 @@ function validateSprite(sprite, path, err, warn) {
|
|
|
755
823
|
err(`${path}.align`, "must be 'left' | 'center' | 'right'");
|
|
756
824
|
}
|
|
757
825
|
if (sprite.color !== void 0) color(sprite.color, `${path}.color`, err);
|
|
826
|
+
if (sprite.anchor !== void 0 && !TEXT_BLOCK_ANCHORS.has(sprite.anchor)) {
|
|
827
|
+
err(`${path}.anchor`, "must be 'top-left' | 'top' | 'top-right' | 'left' | 'center' | 'right' | 'bottom-left' | 'bottom' | 'bottom-right'");
|
|
828
|
+
}
|
|
829
|
+
if (sprite.font !== void 0) {
|
|
830
|
+
if (!isStr(sprite.font) || sprite.font.trim() === "") {
|
|
831
|
+
err(`${path}.font`, "must be a non-empty string (family and/or weight)");
|
|
832
|
+
} else if (FONT_SIZE_RE.test(sprite.font)) {
|
|
833
|
+
err(`${path}.font`, "must name a family and/or weight only \u2014 fontSize owns the size");
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
if (sprite.opacity !== void 0 && (!isNum(sprite.opacity) || sprite.opacity < 0 || sprite.opacity > 1)) {
|
|
837
|
+
err(`${path}.opacity`, "must be a number between 0 and 1");
|
|
838
|
+
}
|
|
839
|
+
validateTextRole(sprite, path, err);
|
|
758
840
|
if (sprite.reveal !== void 0) {
|
|
759
841
|
const rv = sprite.reveal;
|
|
760
842
|
if (typeof rv !== "object" || rv === null || Array.isArray(rv)) {
|
|
@@ -821,6 +903,11 @@ function validatePalette(sprite, path, err) {
|
|
|
821
903
|
}
|
|
822
904
|
}
|
|
823
905
|
}
|
|
906
|
+
function validateTextRole(sprite, path, err) {
|
|
907
|
+
if (sprite.role !== void 0 && !TEXT_ROLES.has(sprite.role)) {
|
|
908
|
+
err(`${path}.role`, "must be 'read' | 'atmosphere'");
|
|
909
|
+
}
|
|
910
|
+
}
|
|
824
911
|
function validateCycle(sprite, path, err) {
|
|
825
912
|
if (sprite.cycle === void 0) return;
|
|
826
913
|
if (!isObj(sprite.cycle)) return err(`${path}.cycle`, "must be an object");
|
|
@@ -993,6 +1080,7 @@ function validateSequence(seq) {
|
|
|
993
1080
|
}
|
|
994
1081
|
}
|
|
995
1082
|
}
|
|
1083
|
+
if (isObj(seq) && isObj(seq.bed)) normalizeColors(seq.bed);
|
|
996
1084
|
const errors = [];
|
|
997
1085
|
const warnings = [];
|
|
998
1086
|
const err = (path, message) => void errors.push({ path, message });
|
|
@@ -1003,6 +1091,7 @@ function validateSequence(seq) {
|
|
|
1003
1091
|
if (!isStr(seq.label) || seq.label.trim() === "") err("label", "must be a non-empty string");
|
|
1004
1092
|
if (seq.seed !== void 0 && !isNum(seq.seed)) err("seed", "must be a number");
|
|
1005
1093
|
if (typeof seq.loop !== "boolean") err("loop", "must be a boolean");
|
|
1094
|
+
if (seq.sync !== void 0 && seq.sync !== "mount" && seq.sync !== "epoch") err("sync", "must be 'mount' | 'epoch'");
|
|
1006
1095
|
if (!Array.isArray(seq.segments) || seq.segments.length === 0) {
|
|
1007
1096
|
err("segments", "must be a non-empty array");
|
|
1008
1097
|
return { valid: false, errors, warnings };
|
|
@@ -1045,12 +1134,19 @@ function validateSequence(seq) {
|
|
|
1045
1134
|
if (s.transition !== void 0) {
|
|
1046
1135
|
if (!isObj(s.transition)) {
|
|
1047
1136
|
err(`${p}.transition`, "must be an object");
|
|
1048
|
-
} else if (s.transition.type === "morph") {
|
|
1137
|
+
} else if (s.transition.type === "morph" || s.transition.type === "fade") {
|
|
1049
1138
|
if (!isNum(s.transition.dur) || s.transition.dur < LIMITS.minTransitionDur || s.transition.dur > LIMITS.maxTransitionDur) {
|
|
1050
1139
|
err(`${p}.transition.dur`, `must be a number between ${LIMITS.minTransitionDur} and ${LIMITS.maxTransitionDur}`);
|
|
1051
1140
|
}
|
|
1052
1141
|
} else if (s.transition.type !== "cut") {
|
|
1053
|
-
err(`${p}.transition.type`, "must be 'cut' or '
|
|
1142
|
+
err(`${p}.transition.type`, "must be 'cut', 'morph' or 'fade'");
|
|
1143
|
+
}
|
|
1144
|
+
if (isObj(s.transition) && s.transition.text !== void 0) {
|
|
1145
|
+
if (s.transition.type !== "morph") {
|
|
1146
|
+
err(`${p}.transition.text`, "is a morph option (fade already cross-fades whole frames; cut has no window)");
|
|
1147
|
+
} else if (s.transition.text !== "step" && s.transition.text !== "crossfade") {
|
|
1148
|
+
err(`${p}.transition.text`, "must be 'step' | 'crossfade'");
|
|
1149
|
+
}
|
|
1054
1150
|
}
|
|
1055
1151
|
}
|
|
1056
1152
|
if (isObj(s.scene)) {
|
|
@@ -1066,6 +1162,32 @@ function validateSequence(seq) {
|
|
|
1066
1162
|
if (seq.loop === true && hasDurationless) {
|
|
1067
1163
|
err("loop", "loop: true requires all segments to have a duration");
|
|
1068
1164
|
}
|
|
1165
|
+
if (seq.bed !== void 0) {
|
|
1166
|
+
if (!isObj(seq.bed)) {
|
|
1167
|
+
err("bed", "must be a SaverSpec object");
|
|
1168
|
+
} else {
|
|
1169
|
+
const bedResult = validateSpec(seq.bed);
|
|
1170
|
+
for (const e of bedResult.errors) errors.push({ path: `bed.${e.path}`, message: e.message });
|
|
1171
|
+
for (const w of bedResult.warnings ?? []) warnings.push({ path: `bed.${w.path}`, code: w.code, message: w.message });
|
|
1172
|
+
if (bedResult.valid) {
|
|
1173
|
+
const entityTotal = (spec) => isObj(spec) && Array.isArray(spec.layers) ? spec.layers.reduce((n, l) => n + (isObj(l) && isNum(l.count) ? l.count : 0), 0) : 0;
|
|
1174
|
+
const bedTotal = entityTotal(seq.bed);
|
|
1175
|
+
const largest = seq.segments.reduce((m, s) => Math.max(m, isObj(s) ? entityTotal(s.scene) : 0), 0);
|
|
1176
|
+
if (bedTotal + largest > LIMITS.maxTotal) {
|
|
1177
|
+
err("bed", `bed entities ${bedTotal} + largest segment ${largest} = ${bedTotal + largest} exceeds cap ${LIMITS.maxTotal} (the two are live at once)`);
|
|
1178
|
+
}
|
|
1179
|
+
seq.segments.forEach((s, i) => {
|
|
1180
|
+
if (isObj(s) && isObj(s.scene) && s.scene.background !== void 0) {
|
|
1181
|
+
warnings.push({
|
|
1182
|
+
path: `segments[${i}].scene.background`,
|
|
1183
|
+
code: "bed-hides-segment-background",
|
|
1184
|
+
message: `segment ${i} declares a background but the sequence has a bed \u2014 segments render transparently over the bed, so this background is never painted`
|
|
1185
|
+
});
|
|
1186
|
+
}
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1069
1191
|
if (errors.length === 0 && Array.isArray(seq.segments)) {
|
|
1070
1192
|
for (let i = 0; i < seq.segments.length; i++) {
|
|
1071
1193
|
const s = seq.segments[i];
|
|
@@ -1080,6 +1202,12 @@ function validateSequence(seq) {
|
|
|
1080
1202
|
code: "morph-structural-mismatch",
|
|
1081
1203
|
message: `segments ${i}\u2192${i + 1} differ structurally: morph will fall back to cut`
|
|
1082
1204
|
});
|
|
1205
|
+
} else if (morphNothingMorphable(s.scene, next.scene, { textCrossfade: s.transition.text === "crossfade" })) {
|
|
1206
|
+
warnings.push({
|
|
1207
|
+
path: `segments[${i}].transition`,
|
|
1208
|
+
code: "morph-nothing-morphable",
|
|
1209
|
+
message: `segments ${i}\u2192${i + 1} differ only in values morph cannot interpolate (strings such as textBlock.text step on the first frame): the morph will look like a cut \u2014 fade text via colour or reveal.progress`
|
|
1210
|
+
});
|
|
1083
1211
|
}
|
|
1084
1212
|
}
|
|
1085
1213
|
}
|
|
@@ -1255,7 +1383,8 @@ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
|
|
|
1255
1383
|
Math.round(layer.count * countScale),
|
|
1256
1384
|
layer.links ? LIMITS.maxLinkLayerCount : LIMITS.maxPerLayer
|
|
1257
1385
|
));
|
|
1258
|
-
const [
|
|
1386
|
+
const defaultSize = scale === 1 ? [20, 40] : [20 / LIMITS.referenceViewport, 40 / LIMITS.referenceViewport];
|
|
1387
|
+
const [smin, smax] = layer.size ?? defaultSize;
|
|
1259
1388
|
const variants = spriteVariants(layer.sprite);
|
|
1260
1389
|
const sprite = layer.sprite;
|
|
1261
1390
|
const spriteColors = isShapedSprite(sprite) ? sprite.colors : void 0;
|
|
@@ -1444,6 +1573,7 @@ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
|
|
|
1444
1573
|
...sprite.kind === "bar" ? { barIndex: i } : {},
|
|
1445
1574
|
...ordered ? { ordered: true } : {},
|
|
1446
1575
|
...orbitParent ? { orbitParent } : {},
|
|
1576
|
+
...layer.wrap === false ? { wrapDisabled: true } : {},
|
|
1447
1577
|
...m.type === "wander" ? {
|
|
1448
1578
|
wander: {
|
|
1449
1579
|
own: drawOsc(rng, meanderPx),
|
|
@@ -1635,9 +1765,10 @@ function positionAt(e, t, w, h) {
|
|
|
1635
1765
|
};
|
|
1636
1766
|
}
|
|
1637
1767
|
if (e.motion === "rise") {
|
|
1768
|
+
const y2 = e.y0 + e.vy * dt;
|
|
1638
1769
|
return {
|
|
1639
1770
|
x: e.x0 + (e.bob ? e.bob * Math.sin(t / 700 + e.phase) : 0),
|
|
1640
|
-
y:
|
|
1771
|
+
y: e.wrapDisabled ? y2 : wrap(y2, -m, h + m),
|
|
1641
1772
|
flip: false
|
|
1642
1773
|
};
|
|
1643
1774
|
}
|
|
@@ -1657,14 +1788,18 @@ function positionAt(e, t, w, h) {
|
|
|
1657
1788
|
}
|
|
1658
1789
|
}
|
|
1659
1790
|
const margin = m + wp.margin;
|
|
1791
|
+
const x2 = e.x0 + e.vx * dt + hx;
|
|
1792
|
+
const y2 = e.y0 + e.vy * dt + hy;
|
|
1660
1793
|
return {
|
|
1661
|
-
x:
|
|
1662
|
-
y:
|
|
1794
|
+
x: e.wrapDisabled ? x2 : wrap(x2, -margin, w + margin),
|
|
1795
|
+
y: e.wrapDisabled ? y2 : wrap(y2, -margin, h + margin),
|
|
1663
1796
|
flip: e.headingLeft
|
|
1664
1797
|
};
|
|
1665
1798
|
}
|
|
1666
|
-
const
|
|
1667
|
-
|
|
1799
|
+
const rawX = e.x0 + e.vx * dt;
|
|
1800
|
+
const x = e.wrapDisabled ? rawX : wrap(rawX, -m, w + m);
|
|
1801
|
+
const rawY = e.y0 + e.vy * dt;
|
|
1802
|
+
let y = e.vy !== 0 && !e.wrapDisabled ? wrap(rawY, -m, h + m) : rawY;
|
|
1668
1803
|
if (e.bob) y += e.bob * Math.sin(t / 500 + e.phase);
|
|
1669
1804
|
return { x, y, flip: e.headingLeft };
|
|
1670
1805
|
}
|
|
@@ -1765,18 +1900,25 @@ function linkPairs(positions, k, maxDist, doWrap, w, h) {
|
|
|
1765
1900
|
}
|
|
1766
1901
|
var CHAR_NARROW = new Set(`iIljtf1!|.,;:'"()[]{}`);
|
|
1767
1902
|
var CHAR_WIDE = new Set("mwMWGOQD@%");
|
|
1768
|
-
|
|
1903
|
+
var MONO_FAMILY_RE = /\b(?:ui-)?mono(?:space)?\b|courier|menlo|monaco|sfmono|consolas|inconsolata|fira\s*code|source\s*code|jetbrains|ibm\s*plex\s*mono|roboto\s*mono/i;
|
|
1904
|
+
function textMetricsClassFor(font) {
|
|
1905
|
+
return font && MONO_FAMILY_RE.test(font) ? "mono" : "proportional";
|
|
1906
|
+
}
|
|
1907
|
+
var MONO_ADVANCE_EM = 0.6;
|
|
1908
|
+
function charWidthEm(ch, metrics = "proportional") {
|
|
1909
|
+
if (metrics === "mono") return MONO_ADVANCE_EM;
|
|
1769
1910
|
if (ch === " " || ch === " ") return 0.3;
|
|
1770
1911
|
if (CHAR_NARROW.has(ch)) return 0.35;
|
|
1771
1912
|
if (CHAR_WIDE.has(ch)) return 0.72;
|
|
1772
1913
|
return 0.55;
|
|
1773
1914
|
}
|
|
1774
|
-
function textWidthEm(str) {
|
|
1915
|
+
function textWidthEm(str, metrics = "proportional") {
|
|
1916
|
+
if (metrics === "mono") return graphemeClusters(str).length * MONO_ADVANCE_EM;
|
|
1775
1917
|
let w = 0;
|
|
1776
|
-
for (let i = 0; i < str.length; i++) w += charWidthEm(str[i]);
|
|
1918
|
+
for (let i = 0; i < str.length; i++) w += charWidthEm(str[i], metrics);
|
|
1777
1919
|
return w;
|
|
1778
1920
|
}
|
|
1779
|
-
function breakTextBlock(text, maxWidthEm) {
|
|
1921
|
+
function breakTextBlock(text, maxWidthEm, metrics = "proportional") {
|
|
1780
1922
|
const paragraphs = text.split("\n");
|
|
1781
1923
|
const lines = [];
|
|
1782
1924
|
for (const para of paragraphs) {
|
|
@@ -1790,11 +1932,11 @@ function breakTextBlock(text, maxWidthEm) {
|
|
|
1790
1932
|
continue;
|
|
1791
1933
|
}
|
|
1792
1934
|
let lineText = words[0];
|
|
1793
|
-
let lineW = textWidthEm(lineText);
|
|
1794
|
-
const spaceW = charWidthEm(" ");
|
|
1935
|
+
let lineW = textWidthEm(lineText, metrics);
|
|
1936
|
+
const spaceW = charWidthEm(" ", metrics);
|
|
1795
1937
|
for (let i = 1; i < words.length; i++) {
|
|
1796
1938
|
const word = words[i];
|
|
1797
|
-
const wordW = textWidthEm(word);
|
|
1939
|
+
const wordW = textWidthEm(word, metrics);
|
|
1798
1940
|
if (lineW + spaceW + wordW <= maxWidthEm) {
|
|
1799
1941
|
lineText += " " + word;
|
|
1800
1942
|
lineW += spaceW + wordW;
|
|
@@ -1808,6 +1950,13 @@ function breakTextBlock(text, maxWidthEm) {
|
|
|
1808
1950
|
}
|
|
1809
1951
|
return lines;
|
|
1810
1952
|
}
|
|
1953
|
+
function textBlockAnchorOffset(s, maxWPx, maxLineW, totalH) {
|
|
1954
|
+
if (!s.anchor) return { dx: 0, dy: 0 };
|
|
1955
|
+
const ax = s.anchor.endsWith("left") ? 0 : s.anchor.endsWith("right") ? 1 : 0.5;
|
|
1956
|
+
const ay = s.anchor.startsWith("top") ? 0 : s.anchor.startsWith("bottom") ? 1 : 0.5;
|
|
1957
|
+
const inkX0 = s.align === "center" ? (maxWPx - maxLineW) / 2 : s.align === "right" ? maxWPx - maxLineW : 0;
|
|
1958
|
+
return { dx: -(inkX0 + ax * maxLineW), dy: -(ay * totalH) };
|
|
1959
|
+
}
|
|
1811
1960
|
function isClusterExtend(cp) {
|
|
1812
1961
|
return cp >= 768 && cp <= 879 || // combining diacritics
|
|
1813
1962
|
cp >= 6832 && cp <= 6911 || cp >= 8400 && cp <= 8447 || cp >= 65024 && cp <= 65039 || // variation selectors
|
|
@@ -1955,9 +2104,45 @@ function resolveSegment(seq, T, opts = {}) {
|
|
|
1955
2104
|
const lastStart = totalTimed - (segments[last].duration ?? 0);
|
|
1956
2105
|
return { index: last, localT: t0 - lastStart, startT: lastStart };
|
|
1957
2106
|
}
|
|
2107
|
+
function canMorph(seq, from) {
|
|
2108
|
+
const seg = seq.segments[from];
|
|
2109
|
+
if (!seg || seg.transition?.type !== "morph") return false;
|
|
2110
|
+
const next = seq.segments[from + 1];
|
|
2111
|
+
if (!next) return false;
|
|
2112
|
+
return structuralSignature(seg.scene) === structuralSignature(next.scene);
|
|
2113
|
+
}
|
|
2114
|
+
function morphChainRoot(seq, index) {
|
|
2115
|
+
let i = index;
|
|
2116
|
+
while (i > 0 && canMorph(seq, i - 1)) i--;
|
|
2117
|
+
return i;
|
|
2118
|
+
}
|
|
2119
|
+
function normalizeSeed(seed) {
|
|
2120
|
+
return seed >>> 0 || 1;
|
|
2121
|
+
}
|
|
2122
|
+
function segmentRenderSeed(seq, index) {
|
|
2123
|
+
const root = morphChainRoot(seq, index);
|
|
2124
|
+
const scene = seq.segments[root]?.scene;
|
|
2125
|
+
if (!scene) return void 0;
|
|
2126
|
+
const raw = scene.seed ?? (seq.seed !== void 0 ? seq.seed + root : void 0);
|
|
2127
|
+
return raw === void 0 ? void 0 : normalizeSeed(raw);
|
|
2128
|
+
}
|
|
2129
|
+
function bedRenderSeed(seq) {
|
|
2130
|
+
if (!seq.bed) return void 0;
|
|
2131
|
+
const raw = seq.bed.seed ?? (seq.seed !== void 0 ? seq.seed + LIMITS.maxSegments : void 0);
|
|
2132
|
+
return raw === void 0 ? void 0 : normalizeSeed(raw);
|
|
2133
|
+
}
|
|
1958
2134
|
|
|
1959
2135
|
// src/compile.ts
|
|
1960
2136
|
var DEFAULT_STEER_DUR = 1e3;
|
|
2137
|
+
function applyRetainedDeltas(spec, deltas) {
|
|
2138
|
+
let out = spec;
|
|
2139
|
+
for (const d of deltas) {
|
|
2140
|
+
if (!resolveSpecPath(out, d.path)) continue;
|
|
2141
|
+
const next = applyDeltasToSpec(out, [d]);
|
|
2142
|
+
if (validateSpec(next).valid) out = next;
|
|
2143
|
+
}
|
|
2144
|
+
return out;
|
|
2145
|
+
}
|
|
1961
2146
|
function hexToRgba(hex, alpha) {
|
|
1962
2147
|
const h = hex.length === 4 ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}` : hex;
|
|
1963
2148
|
const n = parseInt(h.slice(1), 16);
|
|
@@ -1995,11 +2180,12 @@ function manifestFor(spec) {
|
|
|
1995
2180
|
};
|
|
1996
2181
|
}
|
|
1997
2182
|
var SpecInstance = class {
|
|
1998
|
-
constructor(spec, ctx) {
|
|
2183
|
+
constructor(spec, ctx, opts = {}) {
|
|
1999
2184
|
this.spec = spec;
|
|
2000
2185
|
this.effSpec = spec;
|
|
2001
2186
|
this.saverCtx = ctx;
|
|
2002
|
-
this.
|
|
2187
|
+
this.transparent = opts.transparent === true;
|
|
2188
|
+
this.seed = normalizeSeed(spec.seed ?? ctx.seed);
|
|
2003
2189
|
let canvas;
|
|
2004
2190
|
if (ctx.surface) {
|
|
2005
2191
|
canvas = ctx.surface;
|
|
@@ -2013,7 +2199,7 @@ var SpecInstance = class {
|
|
|
2013
2199
|
this.ownsCanvas = true;
|
|
2014
2200
|
}
|
|
2015
2201
|
this.canvas = canvas;
|
|
2016
|
-
const c2d = canvas.getContext("2d", { alpha:
|
|
2202
|
+
const c2d = canvas.getContext("2d", { alpha: this.transparent });
|
|
2017
2203
|
if (!c2d) throw new Error("schema saver: no 2d context");
|
|
2018
2204
|
this.ctx = c2d;
|
|
2019
2205
|
this.w = ctx.width;
|
|
@@ -2021,12 +2207,16 @@ var SpecInstance = class {
|
|
|
2021
2207
|
this.sizeCanvas();
|
|
2022
2208
|
this.rebuild();
|
|
2023
2209
|
this.paused = ctx.reducedMotion;
|
|
2024
|
-
if (this.paused)
|
|
2025
|
-
|
|
2210
|
+
if (this.paused) {
|
|
2211
|
+
if (!opts.skipInitialPaint) this.renderFrame(0, this.seed);
|
|
2212
|
+
} else {
|
|
2213
|
+
this.start();
|
|
2214
|
+
}
|
|
2026
2215
|
}
|
|
2027
2216
|
spec;
|
|
2028
2217
|
canvas;
|
|
2029
2218
|
ownsCanvas;
|
|
2219
|
+
transparent;
|
|
2030
2220
|
ctx;
|
|
2031
2221
|
saverCtx;
|
|
2032
2222
|
seed;
|
|
@@ -2046,6 +2236,8 @@ var SpecInstance = class {
|
|
|
2046
2236
|
/** Active glide between two resolved specs (live setParam/applyTrack). */
|
|
2047
2237
|
transition = null;
|
|
2048
2238
|
lastStructural = "";
|
|
2239
|
+
/** See `LayerPaintOverride`; null (the default, and every plain mount) draws every layer once. */
|
|
2240
|
+
paintOverrides = null;
|
|
2049
2241
|
/** Viewport factor for absolute px sizes — 1 for `units: 'px'` specs. */
|
|
2050
2242
|
fontScale() {
|
|
2051
2243
|
if (this.effSpec.units === "px") return 1;
|
|
@@ -2182,14 +2374,14 @@ var SpecInstance = class {
|
|
|
2182
2374
|
ctx.fill();
|
|
2183
2375
|
}
|
|
2184
2376
|
}
|
|
2185
|
-
drawEntity(built, e, t, lifeA, parentE) {
|
|
2377
|
+
drawEntity(built, e, t, lifeA, parentE, paint) {
|
|
2186
2378
|
const { ctx } = this;
|
|
2187
2379
|
const p = this.entityPos(e, t, parentE);
|
|
2188
|
-
const sprite = built.layer.sprite;
|
|
2380
|
+
const sprite = paint?.sprite ?? built.layer.sprite;
|
|
2189
2381
|
const sz = sizeAt(e, t);
|
|
2190
2382
|
const rot = rotationAt(e, t);
|
|
2191
2383
|
const unitScale = this.effSpec.units === "px" ? 1 : Math.min(this.w, this.h);
|
|
2192
|
-
ctx.globalAlpha = alphaAt(e, t) * lifeA;
|
|
2384
|
+
ctx.globalAlpha = alphaAt(e, t) * lifeA * (paint?.alpha ?? 1);
|
|
2193
2385
|
if (sprite.kind === "ring") {
|
|
2194
2386
|
const r = sz / 2;
|
|
2195
2387
|
const resolvedColor = sprite.colors?.[e.colorIndex] ?? sprite.color;
|
|
@@ -2341,7 +2533,7 @@ var SpecInstance = class {
|
|
|
2341
2533
|
const lh = (sprite.lineHeight ?? 1.4) * fsPx;
|
|
2342
2534
|
const maxWPx = sprite.maxWidth * unitScale2;
|
|
2343
2535
|
const maxWEm = maxWPx / fsPx;
|
|
2344
|
-
const lines = breakTextBlock(sprite.text, maxWEm);
|
|
2536
|
+
const lines = breakTextBlock(sprite.text, maxWEm, textMetricsClassFor(sprite.font));
|
|
2345
2537
|
const align = sprite.align ?? "left";
|
|
2346
2538
|
const reveal = sprite.reveal;
|
|
2347
2539
|
const rs = reveal ? revealState(lines, reveal, t) : null;
|
|
@@ -2349,7 +2541,13 @@ var SpecInstance = class {
|
|
|
2349
2541
|
ctx.save();
|
|
2350
2542
|
ctx.translate(p.x, p.y);
|
|
2351
2543
|
if (rot) ctx.rotate(rot);
|
|
2352
|
-
|
|
2544
|
+
if (sprite.anchor) {
|
|
2545
|
+
const maxLineW = lines.reduce((m, l) => Math.max(m, l.widthEm), 0) * fsPx;
|
|
2546
|
+
const { dx, dy } = textBlockAnchorOffset(sprite, maxWPx, maxLineW, lines.length * lh);
|
|
2547
|
+
if (dx !== 0 || dy !== 0) ctx.translate(dx, dy);
|
|
2548
|
+
}
|
|
2549
|
+
if (sprite.opacity !== void 0) ctx.globalAlpha *= sprite.opacity;
|
|
2550
|
+
ctx.font = sprite.font ? composeFontShorthand(fsPx, sprite.font) : `${fsPx}px system-ui, sans-serif`;
|
|
2353
2551
|
ctx.fillStyle = sprite.color ?? "#e6e8ef";
|
|
2354
2552
|
ctx.textBaseline = "top";
|
|
2355
2553
|
ctx.textAlign = align;
|
|
@@ -2463,6 +2661,33 @@ var SpecInstance = class {
|
|
|
2463
2661
|
this.renderFrame(this.lastT, this.seed);
|
|
2464
2662
|
}
|
|
2465
2663
|
}
|
|
2664
|
+
/**
|
|
2665
|
+
* Apply steering deltas immediately: no glide, no frame painted. Each delta
|
|
2666
|
+
* is routed by `applyRetainedDeltas` (unresolvable or invalid ones are
|
|
2667
|
+
* skipped on this spec alone). `SequenceInstance` uses this to hand a
|
|
2668
|
+
* freshly created child the track the sequence has retained, so a steer
|
|
2669
|
+
* made while another segment was active lands on the segment that owns the
|
|
2670
|
+
* path. Not painting matters: a child is created inside the parent's
|
|
2671
|
+
* renderFrame, and a stray t=0 paint here would make the real frame that
|
|
2672
|
+
* follows look "contiguous" to the ghosting warm-up — but the constructor
|
|
2673
|
+
* already painted ONE stray t=0 frame of the pre-steer spec (SpecInstance's
|
|
2674
|
+
* own mount does an immediate render when paused, which every sequence
|
|
2675
|
+
* child is), so `lastRenderT` must be reset too: otherwise, with `ghosting`
|
|
2676
|
+
* on, the real frame that follows still finds itself "contiguous" with
|
|
2677
|
+
* that stray paint and composites over it at partial alpha, briefly
|
|
2678
|
+
* showing a ghost of the un-steered scene.
|
|
2679
|
+
*/
|
|
2680
|
+
applyDeltasNow(deltas) {
|
|
2681
|
+
const next = applyRetainedDeltas(this.effSpec, deltas);
|
|
2682
|
+
if (next === this.effSpec) return;
|
|
2683
|
+
this.transition = null;
|
|
2684
|
+
this.lastRenderT = Number.NEGATIVE_INFINITY;
|
|
2685
|
+
this.effSpec = next;
|
|
2686
|
+
if (structuralSignature(this.effSpec) !== this.lastStructural) this.rebuild();
|
|
2687
|
+
else this.layers.forEach((b, i) => {
|
|
2688
|
+
b.layer = this.effSpec.layers[i] ?? b.layer;
|
|
2689
|
+
});
|
|
2690
|
+
}
|
|
2466
2691
|
drawLinks(built, t, lifeA, parentE) {
|
|
2467
2692
|
const { links } = built.layer;
|
|
2468
2693
|
if (!links) return;
|
|
@@ -2472,7 +2697,9 @@ var SpecInstance = class {
|
|
|
2472
2697
|
const positions = built.entities.map((e) => this.entityPos(e, t, parentE));
|
|
2473
2698
|
const maxDistPx = links.maxDist * (this.effSpec.units === "px" ? 1 : Math.min(this.w, this.h));
|
|
2474
2699
|
const edges = linkEdges(links, positions, maxDistPx, wrap2, this.w, this.h);
|
|
2475
|
-
const
|
|
2700
|
+
const unitScale = this.effSpec.units === "px" ? 1 : Math.min(this.w, this.h);
|
|
2701
|
+
const defaultWidth = unitScale === 1 ? 1 : 1 / LIMITS.referenceViewport;
|
|
2702
|
+
const lw = (links.width ?? defaultWidth) * unitScale;
|
|
2476
2703
|
ctx.lineWidth = lw;
|
|
2477
2704
|
ctx.lineCap = "butt";
|
|
2478
2705
|
for (const { i, j, dist } of edges) {
|
|
@@ -2506,19 +2733,28 @@ var SpecInstance = class {
|
|
|
2506
2733
|
* showing through — the ghosting smear. */
|
|
2507
2734
|
paintFrame(t, bgAlpha) {
|
|
2508
2735
|
const { ctx } = this;
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2736
|
+
if (!this.transparent) {
|
|
2737
|
+
ctx.globalAlpha = bgAlpha;
|
|
2738
|
+
ctx.globalCompositeOperation = "source-over";
|
|
2739
|
+
this.drawBackground(t);
|
|
2740
|
+
}
|
|
2512
2741
|
ctx.globalAlpha = 1;
|
|
2513
|
-
for (
|
|
2742
|
+
for (let li = 0; li < this.layers.length; li++) {
|
|
2743
|
+
const built = this.layers[li];
|
|
2514
2744
|
const lifeA = lifeAlphaAt(built.layer.life, t);
|
|
2515
2745
|
if (lifeA <= 0) continue;
|
|
2516
2746
|
const parentE = this.parentEntityFor(built);
|
|
2517
2747
|
ctx.globalCompositeOperation = built.layer.blend ?? "source-over";
|
|
2518
2748
|
this.drawLinks(built, t, lifeA, parentE);
|
|
2749
|
+
const override = this.paintOverrides?.get(li) ?? null;
|
|
2519
2750
|
for (const e of built.entities) {
|
|
2520
2751
|
this.drawTrail(built, e, t, lifeA, parentE);
|
|
2521
|
-
|
|
2752
|
+
if (override === null) {
|
|
2753
|
+
this.drawEntity(built, e, t, lifeA, parentE);
|
|
2754
|
+
continue;
|
|
2755
|
+
}
|
|
2756
|
+
if (override.k < 1) this.drawEntity(built, e, t, lifeA, parentE, { sprite: override.outgoing, alpha: 1 - override.k });
|
|
2757
|
+
if (override.k > 0) this.drawEntity(built, e, t, lifeA, parentE, { alpha: override.k });
|
|
2522
2758
|
}
|
|
2523
2759
|
}
|
|
2524
2760
|
ctx.globalAlpha = 1;
|
|
@@ -2535,7 +2771,7 @@ var SpecInstance = class {
|
|
|
2535
2771
|
*/
|
|
2536
2772
|
renderFrame(t, _seed) {
|
|
2537
2773
|
this.stepTransition(t);
|
|
2538
|
-
const g = this.effSpec.ghosting ?? 0;
|
|
2774
|
+
const g = this.transparent ? 0 : this.effSpec.ghosting ?? 0;
|
|
2539
2775
|
if (g > 0) {
|
|
2540
2776
|
const dt = 1e3 / 60;
|
|
2541
2777
|
const contiguous = this.lastRenderT !== Number.NEGATIVE_INFINITY && t > this.lastRenderT && t - this.lastRenderT <= 250;
|
|
@@ -2571,8 +2807,8 @@ var SpecInstance = class {
|
|
|
2571
2807
|
}
|
|
2572
2808
|
/**
|
|
2573
2809
|
* Replace the rendered spec without triggering a transition glide.
|
|
2574
|
-
* Checks structural signature — use `hotSwapPaint` when the caller
|
|
2575
|
-
* already knows the signature is unchanged
|
|
2810
|
+
* Checks structural signature — use `hotSwapPaint` only when the caller
|
|
2811
|
+
* already knows the signature is unchanged.
|
|
2576
2812
|
*/
|
|
2577
2813
|
hotSwapSpec(spec) {
|
|
2578
2814
|
this.effSpec = spec;
|
|
@@ -2589,6 +2825,15 @@ var SpecInstance = class {
|
|
|
2589
2825
|
b.layer = this.effSpec.layers[i] ?? b.layer;
|
|
2590
2826
|
});
|
|
2591
2827
|
}
|
|
2828
|
+
/**
|
|
2829
|
+
* Internal (see `LayerPaintOverride`): draw the listed layers twice —
|
|
2830
|
+
* outgoing sprite at `1 − k`, own sprite at `k` — until cleared with
|
|
2831
|
+
* `null`. Keyed by layer index. A `SequenceInstance` sets it per morph
|
|
2832
|
+
* frame under `text: 'crossfade'`; nothing else does.
|
|
2833
|
+
*/
|
|
2834
|
+
setLayerPaintOverrides(overrides) {
|
|
2835
|
+
this.paintOverrides = overrides && overrides.size > 0 ? overrides : null;
|
|
2836
|
+
}
|
|
2592
2837
|
dispose() {
|
|
2593
2838
|
this.stop();
|
|
2594
2839
|
if (this.ownsCanvas && typeof HTMLCanvasElement !== "undefined" && this.canvas instanceof HTMLCanvasElement) this.canvas.remove();
|
|
@@ -2602,6 +2847,7 @@ function compileSaver(spec) {
|
|
|
2602
2847
|
spec: valid
|
|
2603
2848
|
};
|
|
2604
2849
|
}
|
|
2850
|
+
var BED_PREFIX = "bed.";
|
|
2605
2851
|
var SequenceInstance = class {
|
|
2606
2852
|
seq;
|
|
2607
2853
|
childCtx;
|
|
@@ -2632,8 +2878,55 @@ var SequenceInstance = class {
|
|
|
2632
2878
|
*/
|
|
2633
2879
|
clockOffset = 0;
|
|
2634
2880
|
releasedBelow = 0;
|
|
2881
|
+
/**
|
|
2882
|
+
* Set by a `sequence.segment` steer that targets segment 0 under `loop:
|
|
2883
|
+
* true`: the steer resets the clock to segment 0's own start (localT 0),
|
|
2884
|
+
* so `renderFrame`'s wrap check (which compares the raw, ever-increasing
|
|
2885
|
+
* clock against `timedTotal()`) can no longer recognize the jump as a
|
|
2886
|
+
* wrap. This flag lets the clicker's jump to 0 still count as one, so the
|
|
2887
|
+
* last segment's fade plays instead of a hard cut. Cleared once the wrap
|
|
2888
|
+
* window (if any) has passed.
|
|
2889
|
+
*/
|
|
2890
|
+
pendingWrapFade = false;
|
|
2891
|
+
/**
|
|
2892
|
+
* Every non-`sequence.segment` delta this instance has been handed, last
|
|
2893
|
+
* wins per path, merged across `applyTrack` calls. Children are created
|
|
2894
|
+
* lazily and disposed on every segment switch, so a steer forwarded only
|
|
2895
|
+
* to the active child would evaporate at the next boundary — and a steer
|
|
2896
|
+
* to a path only a *later* segment owns (`bars.sprite.values` while the title
|
|
2897
|
+
* slide is up) would land nowhere. Re-applied to every child on creation
|
|
2898
|
+
* and folded into the morph endpoints; each child keeps the deltas that
|
|
2899
|
+
* resolve on it (see `applyRetainedDeltas`).
|
|
2900
|
+
*/
|
|
2901
|
+
retainedDeltas = /* @__PURE__ */ new Map();
|
|
2902
|
+
/** The outgoing segment while a `fade` runs; null otherwise. */
|
|
2903
|
+
fading = null;
|
|
2904
|
+
/**
|
|
2905
|
+
* The incoming segment while a `fade` runs over a `bed`. Children over a
|
|
2906
|
+
* bed are transparent, so a cross-fade needs both of them on canvases of
|
|
2907
|
+
* their own — the incoming composited at k, the outgoing at 1 − k. Without
|
|
2908
|
+
* a bed the incoming paints its own opaque ground on the shared surface and
|
|
2909
|
+
* this stays null.
|
|
2910
|
+
*/
|
|
2911
|
+
fadingIn = null;
|
|
2912
|
+
/** False on the `basic`/`minimal` capability tiers — fade renders as cut. */
|
|
2913
|
+
fadeEnabled;
|
|
2914
|
+
/**
|
|
2915
|
+
* The sequence's `bed`: one scene on the shared surface, drawn first every
|
|
2916
|
+
* frame at the sequence clock — WITHOUT `clockOffset`, so a
|
|
2917
|
+
* `sequence.segment` steer rewinds the segment and never the bed — and never
|
|
2918
|
+
* re-created. Segments render over it transparently. Null when absent.
|
|
2919
|
+
*/
|
|
2920
|
+
bed;
|
|
2635
2921
|
constructor(seq, ctx) {
|
|
2636
2922
|
this.seq = seq;
|
|
2923
|
+
const { sequenceBaseT, capabilityTier, ...plainCtx } = ctx;
|
|
2924
|
+
this.fadeEnabled = capabilityTier !== "basic" && capabilityTier !== "minimal";
|
|
2925
|
+
if (seq.sync === "epoch" && typeof sequenceBaseT === "number" && Number.isFinite(sequenceBaseT)) {
|
|
2926
|
+
this.baseT = Math.max(0, sequenceBaseT);
|
|
2927
|
+
this.lastT = this.baseT;
|
|
2928
|
+
this.renderedT = this.baseT;
|
|
2929
|
+
}
|
|
2637
2930
|
let surface = ctx.surface ?? null;
|
|
2638
2931
|
let canvas = null;
|
|
2639
2932
|
if (!surface && typeof document !== "undefined") {
|
|
@@ -2645,15 +2938,32 @@ var SequenceInstance = class {
|
|
|
2645
2938
|
}
|
|
2646
2939
|
this.canvas = canvas;
|
|
2647
2940
|
this.seed = (seq.seed ?? ctx.seed ?? 0) >>> 0 || 1;
|
|
2648
|
-
this.childCtx = { ...
|
|
2941
|
+
this.childCtx = { ...plainCtx, surface, reducedMotion: true };
|
|
2649
2942
|
this.children = new Array(seq.segments.length).fill(null);
|
|
2943
|
+
if (seq.bed) {
|
|
2944
|
+
this.bed = new SpecInstance(this.bedScene(seq.bed), this.childCtx);
|
|
2945
|
+
this.bed.setPaused(true);
|
|
2946
|
+
} else {
|
|
2947
|
+
this.bed = null;
|
|
2948
|
+
}
|
|
2650
2949
|
this.paused = ctx.reducedMotion;
|
|
2651
|
-
if (this.paused) this.renderFrame(
|
|
2950
|
+
if (this.paused) this.renderFrame(this.baseT, this.seed);
|
|
2652
2951
|
else this.start();
|
|
2653
2952
|
}
|
|
2654
2953
|
childSeed(index, fallback) {
|
|
2655
2954
|
return this.seq.segments[index].scene.seed ?? this.seq.seed ?? fallback;
|
|
2656
2955
|
}
|
|
2956
|
+
/**
|
|
2957
|
+
* The bed's seed follows the segment rule (own seed, else the sequence seed)
|
|
2958
|
+
* but offset by `maxSegments`, past every segment's `seed + index`, so a bed
|
|
2959
|
+
* and segment 0 never share an entity stream and adding a segment does not
|
|
2960
|
+
* re-seat the bed.
|
|
2961
|
+
*/
|
|
2962
|
+
bedScene(bed) {
|
|
2963
|
+
if (bed.seed != null) return bed;
|
|
2964
|
+
if (this.seq.seed != null) return { ...bed, seed: this.seq.seed + LIMITS.maxSegments };
|
|
2965
|
+
return bed;
|
|
2966
|
+
}
|
|
2657
2967
|
childScene(index) {
|
|
2658
2968
|
const seg = this.seq.segments[index];
|
|
2659
2969
|
if (seg.scene.seed != null) return seg.scene;
|
|
@@ -2662,29 +2972,132 @@ var SequenceInstance = class {
|
|
|
2662
2972
|
}
|
|
2663
2973
|
/** Whether the boundary from `from` to `from+1` should morph. */
|
|
2664
2974
|
canMorph(from) {
|
|
2665
|
-
|
|
2666
|
-
if (!seg || seg.transition?.type !== "morph") return false;
|
|
2667
|
-
const next = this.seq.segments[from + 1];
|
|
2668
|
-
if (!next) return false;
|
|
2669
|
-
return structuralSignature(seg.scene) === structuralSignature(next.scene);
|
|
2975
|
+
return canMorph(this.seq, from);
|
|
2670
2976
|
}
|
|
2671
2977
|
/** Walk back through consecutive morph boundaries to find the chain origin. */
|
|
2672
2978
|
morphChainRoot(index) {
|
|
2673
|
-
|
|
2674
|
-
while (i > 0 && this.canMorph(i - 1)) i--;
|
|
2675
|
-
return i;
|
|
2979
|
+
return morphChainRoot(this.seq, index);
|
|
2676
2980
|
}
|
|
2677
2981
|
morphDur(from) {
|
|
2678
2982
|
const tr = this.seq.segments[from]?.transition;
|
|
2679
2983
|
return tr?.type === "morph" ? tr.dur : 0;
|
|
2680
2984
|
}
|
|
2681
|
-
|
|
2985
|
+
/** Whether the morph out of `from` cross-fades its text (default `step`: strings switch at k > 0). */
|
|
2986
|
+
morphTextCrossfades(from) {
|
|
2987
|
+
const tr = this.seq.segments[from]?.transition;
|
|
2988
|
+
return tr?.type === "morph" && tr.text === "crossfade";
|
|
2989
|
+
}
|
|
2990
|
+
/**
|
|
2991
|
+
* The paint overrides for one crossfade frame: every text layer whose
|
|
2992
|
+
* words differ between the outgoing and incoming specs, painting the
|
|
2993
|
+
* outgoing words (in the lerped frame's paint — colour and alpha glide as
|
|
2994
|
+
* usual) at `1 − k` under the incoming at `k`. Null when nothing differs,
|
|
2995
|
+
* so a crossfade between same-worded twins is a plain morph frame.
|
|
2996
|
+
*/
|
|
2997
|
+
crossfadeOverrides(specA, specB, lerped, k) {
|
|
2998
|
+
let map = null;
|
|
2999
|
+
for (let i = 0; i < lerped.layers.length; i++) {
|
|
3000
|
+
if (!textStringsDiffer(specA, specB, i)) continue;
|
|
3001
|
+
const from = specA.layers[i].sprite;
|
|
3002
|
+
const now = lerped.layers[i].sprite;
|
|
3003
|
+
const outgoing = from.kind === "text" && now.kind === "text" ? { ...now, strings: from.strings } : from.kind === "textBlock" && now.kind === "textBlock" ? { ...now, text: from.text } : now;
|
|
3004
|
+
if (outgoing === now) continue;
|
|
3005
|
+
(map ??= /* @__PURE__ */ new Map()).set(i, { outgoing, k });
|
|
3006
|
+
}
|
|
3007
|
+
return map;
|
|
3008
|
+
}
|
|
3009
|
+
/** Length of the fade out of segment `from`, or 0 (no fade, or a tier that plays it as cut). */
|
|
3010
|
+
fadeDur(from) {
|
|
3011
|
+
const tr = this.seq.segments[from]?.transition;
|
|
3012
|
+
return this.fadeEnabled && tr?.type === "fade" ? tr.dur : 0;
|
|
3013
|
+
}
|
|
3014
|
+
/** Sum of every timed segment — the lap length under `loop: true`. */
|
|
3015
|
+
timedTotal() {
|
|
3016
|
+
const last = this.seq.segments.length - 1;
|
|
3017
|
+
return segmentStart(this.seq, last) + (this.seq.segments[last].duration ?? 0);
|
|
3018
|
+
}
|
|
3019
|
+
releaseFading() {
|
|
3020
|
+
if (this.fading) {
|
|
3021
|
+
this.fading.child.dispose();
|
|
3022
|
+
this.fading = null;
|
|
3023
|
+
}
|
|
3024
|
+
if (this.fadingIn) {
|
|
3025
|
+
this.fadingIn.child.dispose();
|
|
3026
|
+
this.fadingIn = null;
|
|
3027
|
+
}
|
|
3028
|
+
}
|
|
3029
|
+
/**
|
|
3030
|
+
* A segment child on a canvas of its own. Like a morph it mounts on its
|
|
3031
|
+
* chain root's scene and seed (so its entities are the ones the viewer was
|
|
3032
|
+
* watching) and hot-swaps to the segment's spec; the retained track is
|
|
3033
|
+
* applied last. Transparent over a bed, opaque otherwise.
|
|
3034
|
+
*/
|
|
3035
|
+
offscreenChild(index, root) {
|
|
3036
|
+
const canvas = typeof document !== "undefined" ? document.createElement("canvas") : new OffscreenCanvas(Math.max(1, this.childCtx.width), Math.max(1, this.childCtx.height));
|
|
3037
|
+
const child = new SpecInstance(this.childScene(root), { ...this.childCtx, surface: canvas }, { transparent: this.bed !== null });
|
|
3038
|
+
child.setPaused(true);
|
|
3039
|
+
if (root !== index) child.hotSwapSpec(this.childScene(index));
|
|
3040
|
+
if (this.retainedDeltas.size > 0) child.applyDeltasNow(this.retainedDeltas.values());
|
|
3041
|
+
return { index, child, canvas };
|
|
3042
|
+
}
|
|
3043
|
+
/** Render an offscreen child; a transparent one never clears, so its owner does here. */
|
|
3044
|
+
renderOffscreen(oc, t, seed) {
|
|
3045
|
+
if (this.bed) {
|
|
3046
|
+
const c = oc.canvas.getContext("2d");
|
|
3047
|
+
c?.clearRect(0, 0, this.childCtx.width, this.childCtx.height);
|
|
3048
|
+
}
|
|
3049
|
+
oc.child.renderFrame(t, seed);
|
|
3050
|
+
}
|
|
3051
|
+
/** Draw an offscreen canvas onto the shared surface at `alpha`. */
|
|
3052
|
+
composite(canvas, alpha) {
|
|
3053
|
+
const main = this.childCtx.surface?.getContext("2d");
|
|
3054
|
+
if (!main) return;
|
|
3055
|
+
main.save();
|
|
3056
|
+
main.globalAlpha = alpha;
|
|
3057
|
+
main.globalCompositeOperation = "source-over";
|
|
3058
|
+
main.drawImage(canvas, 0, 0, this.childCtx.width, this.childCtx.height);
|
|
3059
|
+
main.restore();
|
|
3060
|
+
}
|
|
3061
|
+
/**
|
|
3062
|
+
* Render the outgoing segment `from` of a live fade onto its own canvas. It
|
|
3063
|
+
* keeps animating at `duration(from) + localT` (it does not freeze — and if
|
|
3064
|
+
* it was an `advance: 'input'` hold it resumes from its duration, not from
|
|
3065
|
+
* wherever the hold had reached). Two live segments for `dur` only; the
|
|
3066
|
+
* outgoing one is disposed when the fade ends.
|
|
3067
|
+
*/
|
|
3068
|
+
renderOutgoing(from, localT, seed) {
|
|
3069
|
+
if (this.fading && this.fading.index !== from) {
|
|
3070
|
+
this.fading.child.dispose();
|
|
3071
|
+
this.fading = null;
|
|
3072
|
+
}
|
|
3073
|
+
if (!this.fading) this.fading = this.offscreenChild(from, this.morphChainRoot(from));
|
|
3074
|
+
const outDur = this.seq.segments[from].duration ?? 0;
|
|
3075
|
+
this.renderOffscreen(this.fading, outDur + localT, this.childSeed(this.morphChainRoot(from), seed));
|
|
3076
|
+
return this.fading;
|
|
3077
|
+
}
|
|
3078
|
+
/** A segment's scene with the retained track applied — `scene` itself when nothing resolves on it. */
|
|
3079
|
+
steeredScene(scene) {
|
|
3080
|
+
return this.retainedDeltas.size === 0 ? scene : applyRetainedDeltas(scene, this.retainedDeltas.values());
|
|
3081
|
+
}
|
|
3082
|
+
/**
|
|
3083
|
+
* The child for segment `index`, created paused on the shared surface if
|
|
3084
|
+
* the slot is empty. `rootScene` (a morph chain's root) mounts the child
|
|
3085
|
+
* with the root's scene so its seed and entity placement are continuous
|
|
3086
|
+
* with the chain, then hot-swaps to the segment's own spec. Either way the
|
|
3087
|
+
* retained track is applied last, so a steer made while another segment
|
|
3088
|
+
* was up is already in effect on this child's first frame. `skipInitialPaint`
|
|
3089
|
+
* is for a caller (the morph branch) that is about to render a real frame,
|
|
3090
|
+
* paint overrides included, in this same call — see `SpecInstanceOptions`.
|
|
3091
|
+
*/
|
|
3092
|
+
ensureChild(index, rootScene, skipInitialPaint) {
|
|
2682
3093
|
if (index < 0 || index >= this.seq.segments.length) index = 0;
|
|
2683
3094
|
let child = this.children[index];
|
|
2684
3095
|
if (!child) {
|
|
2685
|
-
child = new SpecInstance(this.childScene(index), this.childCtx);
|
|
3096
|
+
child = new SpecInstance(rootScene ?? this.childScene(index), this.childCtx, { transparent: this.bed !== null, skipInitialPaint });
|
|
2686
3097
|
this.children[index] = child;
|
|
2687
3098
|
child.setPaused(true);
|
|
3099
|
+
if (rootScene) child.hotSwapSpec(this.childScene(index));
|
|
3100
|
+
if (this.retainedDeltas.size > 0) child.applyDeltasNow(this.retainedDeltas.values());
|
|
2688
3101
|
}
|
|
2689
3102
|
return child;
|
|
2690
3103
|
}
|
|
@@ -2717,8 +3130,15 @@ var SequenceInstance = class {
|
|
|
2717
3130
|
this.renderedT = T;
|
|
2718
3131
|
const resolved = resolveSegment(this.seq, T + this.clockOffset, { releasedBelow: this.releasedBelow });
|
|
2719
3132
|
const { index, localT } = resolved;
|
|
3133
|
+
this.bed?.renderFrame(T, this.seq.bed?.seed ?? this.seq.seed ?? seed);
|
|
2720
3134
|
const prevIdx = index > 0 ? index - 1 : -1;
|
|
2721
3135
|
const morphActive = prevIdx >= 0 && this.canMorph(prevIdx) && localT < this.morphDur(prevIdx);
|
|
3136
|
+
const lastIdx = this.seq.segments.length - 1;
|
|
3137
|
+
const wrapped = this.seq.loop && index === 0 && lastIdx > 0 && (T + this.clockOffset >= this.timedTotal() || this.pendingWrapFade);
|
|
3138
|
+
const fadeFrom = prevIdx >= 0 ? prevIdx : wrapped ? lastIdx : -1;
|
|
3139
|
+
const fadeActive = fadeFrom >= 0 && localT < this.fadeDur(fadeFrom);
|
|
3140
|
+
if (!fadeActive && this.fading) this.releaseFading();
|
|
3141
|
+
if (this.pendingWrapFade && (index !== 0 || !fadeActive)) this.pendingWrapFade = false;
|
|
2722
3142
|
if (morphActive) {
|
|
2723
3143
|
const chainRoot = this.morphChainRoot(index);
|
|
2724
3144
|
this.morphFromIndex = prevIdx;
|
|
@@ -2726,12 +3146,14 @@ var SequenceInstance = class {
|
|
|
2726
3146
|
for (let i = 0; i < this.children.length; i++) {
|
|
2727
3147
|
if (i !== chainRoot) this.releaseChild(i);
|
|
2728
3148
|
}
|
|
2729
|
-
const child = this.ensureChild(chainRoot);
|
|
3149
|
+
const child = this.ensureChild(chainRoot, void 0, this.morphTextCrossfades(prevIdx));
|
|
2730
3150
|
const dur = this.morphDur(prevIdx);
|
|
2731
3151
|
const k = easeSmooth(localT / dur);
|
|
2732
|
-
const specA = this.childScene(prevIdx);
|
|
2733
|
-
const specB = this.childScene(index);
|
|
2734
|
-
|
|
3152
|
+
const specA = this.steeredScene(this.childScene(prevIdx));
|
|
3153
|
+
const specB = this.steeredScene(this.childScene(index));
|
|
3154
|
+
const lerped = lerpSpec(specA, specB, k);
|
|
3155
|
+
child.hotSwapSpec(lerped);
|
|
3156
|
+
child.setLayerPaintOverrides(this.morphTextCrossfades(prevIdx) ? this.crossfadeOverrides(specA, specB, lerped, k) : null);
|
|
2735
3157
|
child.renderFrame(localT, this.childSeed(chainRoot, seed));
|
|
2736
3158
|
} else {
|
|
2737
3159
|
if (this.morphFromIndex >= 0) {
|
|
@@ -2749,18 +3171,25 @@ var SequenceInstance = class {
|
|
|
2749
3171
|
const chainRoot = this.morphChainRoot(index);
|
|
2750
3172
|
const useMorphSeed = chainRoot < index;
|
|
2751
3173
|
const effSeed = useMorphSeed ? this.childSeed(chainRoot, seed) : this.childSeed(index, seed);
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
3174
|
+
if (fadeActive && this.bed) {
|
|
3175
|
+
const k = easeSmooth(localT / this.fadeDur(fadeFrom));
|
|
3176
|
+
if (this.fadingIn && this.fadingIn.index !== index) {
|
|
3177
|
+
this.fadingIn.child.dispose();
|
|
3178
|
+
this.fadingIn = null;
|
|
3179
|
+
}
|
|
3180
|
+
if (!this.fadingIn) this.fadingIn = this.offscreenChild(index, useMorphSeed ? chainRoot : index);
|
|
3181
|
+
this.renderOffscreen(this.fadingIn, localT, effSeed);
|
|
3182
|
+
const out = this.renderOutgoing(fadeFrom, localT, seed);
|
|
3183
|
+
this.composite(this.fadingIn.canvas, k);
|
|
3184
|
+
this.composite(out.canvas, 1 - k);
|
|
3185
|
+
return;
|
|
2762
3186
|
}
|
|
3187
|
+
const child = useMorphSeed ? this.ensureChild(index, this.childScene(chainRoot)) : this.ensureChild(index);
|
|
2763
3188
|
child.renderFrame(localT, effSeed);
|
|
3189
|
+
if (fadeActive) {
|
|
3190
|
+
const out = this.renderOutgoing(fadeFrom, localT, seed);
|
|
3191
|
+
this.composite(out.canvas, 1 - easeSmooth(localT / this.fadeDur(fadeFrom)));
|
|
3192
|
+
}
|
|
2764
3193
|
}
|
|
2765
3194
|
}
|
|
2766
3195
|
setPaused(paused) {
|
|
@@ -2770,6 +3199,9 @@ var SequenceInstance = class {
|
|
|
2770
3199
|
for (const child of this.children) {
|
|
2771
3200
|
child?.setPaused(true);
|
|
2772
3201
|
}
|
|
3202
|
+
this.fading?.child.setPaused(true);
|
|
3203
|
+
this.fadingIn?.child.setPaused(true);
|
|
3204
|
+
this.bed?.setPaused(true);
|
|
2773
3205
|
}
|
|
2774
3206
|
resize(width, height, dpr) {
|
|
2775
3207
|
this.childCtx.width = width;
|
|
@@ -2778,35 +3210,58 @@ var SequenceInstance = class {
|
|
|
2778
3210
|
for (const child of this.children) {
|
|
2779
3211
|
child?.resize(width, height, dpr);
|
|
2780
3212
|
}
|
|
3213
|
+
this.fading?.child.resize(width, height, dpr);
|
|
3214
|
+
this.fadingIn?.child.resize(width, height, dpr);
|
|
3215
|
+
this.bed?.resize(width, height, dpr);
|
|
2781
3216
|
}
|
|
2782
3217
|
applyTrack(track) {
|
|
2783
|
-
const
|
|
3218
|
+
const all = track?.deltas ?? [];
|
|
3219
|
+
const isBedPath = (d) => this.bed !== null && typeof d.path === "string" && d.path.startsWith(BED_PREFIX);
|
|
3220
|
+
const bedDeltas = all.filter(isBedPath).map((d) => ({ ...d, path: d.path.slice(BED_PREFIX.length) }));
|
|
3221
|
+
if (bedDeltas.length > 0) this.bed.applyTrack({ ...track, deltas: bedDeltas });
|
|
3222
|
+
const deltas = all.filter((d) => !isBedPath(d));
|
|
3223
|
+
for (const d of deltas) {
|
|
3224
|
+
if (d.path !== "sequence.segment" && typeof d.path === "string") this.retainedDeltas.set(d.path, d);
|
|
3225
|
+
}
|
|
2784
3226
|
const segDelta = deltas.find((d) => d.path === "sequence.segment");
|
|
3227
|
+
let switchedSegment = false;
|
|
2785
3228
|
if (segDelta !== void 0 && typeof segDelta.value === "number") {
|
|
2786
3229
|
const idx = Math.max(0, Math.min(this.seq.segments.length - 1, Math.round(segDelta.value)));
|
|
3230
|
+
this.pendingWrapFade = this.seq.loop && idx === 0 && this.seq.segments.length > 1;
|
|
2787
3231
|
this.clockOffset = segmentStart(this.seq, idx) - this.renderedT;
|
|
2788
3232
|
this.releasedBelow = idx;
|
|
2789
3233
|
this.renderFrame(this.renderedT, this.seed);
|
|
3234
|
+
switchedSegment = true;
|
|
2790
3235
|
}
|
|
2791
3236
|
const childDeltas = deltas.filter((d) => d.path !== "sequence.segment");
|
|
2792
|
-
if (childDeltas.length > 0
|
|
2793
|
-
|
|
2794
|
-
|
|
3237
|
+
if (childDeltas.length > 0) {
|
|
3238
|
+
if (this.morphFromIndex >= 0) {
|
|
3239
|
+
if (!switchedSegment) this.renderFrame(this.renderedT, this.seed);
|
|
3240
|
+
} else if (this.activeIndex >= 0) {
|
|
3241
|
+
const child = this.children[this.activeIndex];
|
|
3242
|
+
child?.applyTrack({ ...track, deltas: childDeltas });
|
|
3243
|
+
}
|
|
3244
|
+
this.fading?.child.applyTrack({ ...track, deltas: childDeltas });
|
|
3245
|
+
this.fadingIn?.child.applyTrack({ ...track, deltas: childDeltas });
|
|
2795
3246
|
}
|
|
2796
3247
|
}
|
|
2797
3248
|
dispose() {
|
|
2798
3249
|
this.stop();
|
|
3250
|
+
this.releaseFading();
|
|
2799
3251
|
for (let i = 0; i < this.children.length; i++) {
|
|
2800
3252
|
this.releaseChild(i);
|
|
2801
3253
|
}
|
|
3254
|
+
this.bed?.dispose();
|
|
2802
3255
|
if (this.canvas) this.canvas.remove();
|
|
2803
3256
|
}
|
|
2804
3257
|
};
|
|
2805
3258
|
function sequenceManifest(seq) {
|
|
2806
|
-
const
|
|
3259
|
+
const maxSegment = seq.segments.reduce((max, s) => {
|
|
2807
3260
|
const t = s.scene.layers.reduce((n, l) => n + l.count, 0);
|
|
2808
3261
|
return Math.max(max, t);
|
|
2809
3262
|
}, 0);
|
|
3263
|
+
const bedTotal = seq.bed ? seq.bed.layers.reduce((n, l) => n + l.count, 0) : 0;
|
|
3264
|
+
const maxTotal = bedTotal + maxSegment;
|
|
2810
3265
|
const costTier = maxTotal < 30 ? "idle" : maxTotal < 150 ? "low" : maxTotal < 400 ? "medium" : "high";
|
|
2811
3266
|
return {
|
|
2812
3267
|
id: seq.id,
|
|
@@ -2890,6 +3345,58 @@ function colourSeparation(layer, sprite, bg) {
|
|
|
2890
3345
|
const db = sprite.rgb.b - bg.rgb.b;
|
|
2891
3346
|
return Math.sqrt((dr * dr + dg * dg + db * db) / 3);
|
|
2892
3347
|
}
|
|
3348
|
+
function srgbToLinear(c) {
|
|
3349
|
+
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
|
3350
|
+
}
|
|
3351
|
+
function relativeLuminance(rgb) {
|
|
3352
|
+
return 0.2126 * srgbToLinear(rgb.r) + 0.7152 * srgbToLinear(rgb.g) + 0.0722 * srgbToLinear(rgb.b);
|
|
3353
|
+
}
|
|
3354
|
+
function legibilityRatio(a, b) {
|
|
3355
|
+
const la = relativeLuminance(a);
|
|
3356
|
+
const lb = relativeLuminance(b);
|
|
3357
|
+
const hi = Math.max(la, lb);
|
|
3358
|
+
const lo = Math.min(la, lb);
|
|
3359
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
3360
|
+
}
|
|
3361
|
+
function backgroundRgbAt(spec, yPx, h, unit) {
|
|
3362
|
+
const bg = spec.background;
|
|
3363
|
+
if (!bg || bg.type === "solid") return hexRgb(bg?.color ?? "#05050a");
|
|
3364
|
+
if (bg.band) {
|
|
3365
|
+
const bh = bg.band.height * unit;
|
|
3366
|
+
if (yPx >= h - bh) return hexRgb(bg.band.color);
|
|
3367
|
+
}
|
|
3368
|
+
const stops = [...bg.stops].sort((a, b) => a.at - b.at);
|
|
3369
|
+
const y = h > 0 ? Math.max(0, Math.min(1, yPx / h)) : 0;
|
|
3370
|
+
const first = stops[0];
|
|
3371
|
+
const last = stops[stops.length - 1];
|
|
3372
|
+
if (y <= first.at) return hexRgb(first.color);
|
|
3373
|
+
if (y >= last.at) return hexRgb(last.color);
|
|
3374
|
+
for (let i = 1; i < stops.length; i++) {
|
|
3375
|
+
const a = stops[i - 1];
|
|
3376
|
+
const b = stops[i];
|
|
3377
|
+
if (y > b.at) continue;
|
|
3378
|
+
const span = b.at - a.at;
|
|
3379
|
+
const k = span > 0 ? (y - a.at) / span : 1;
|
|
3380
|
+
const ca = hexRgb(a.color);
|
|
3381
|
+
const cb = hexRgb(b.color);
|
|
3382
|
+
return { r: ca.r + (cb.r - ca.r) * k, g: ca.g + (cb.g - ca.g) * k, b: ca.b + (cb.b - ca.b) * k };
|
|
3383
|
+
}
|
|
3384
|
+
return hexRgb(last.color);
|
|
3385
|
+
}
|
|
3386
|
+
function sourceOverPlate(ground, colour, alpha) {
|
|
3387
|
+
return {
|
|
3388
|
+
r: ground.r + (colour.r - ground.r) * alpha,
|
|
3389
|
+
g: ground.g + (colour.g - ground.g) * alpha,
|
|
3390
|
+
b: ground.b + (colour.b - ground.b) * alpha
|
|
3391
|
+
};
|
|
3392
|
+
}
|
|
3393
|
+
function additivePlate(ground, colour, alpha, blend) {
|
|
3394
|
+
const mix = (g, c) => {
|
|
3395
|
+
const v = c * alpha;
|
|
3396
|
+
return blend === "lighter" ? Math.min(1, g + v) : 1 - (1 - g) * (1 - v);
|
|
3397
|
+
};
|
|
3398
|
+
return { r: mix(ground.r, colour.r), g: mix(ground.g, colour.g), b: mix(ground.b, colour.b) };
|
|
3399
|
+
}
|
|
2893
3400
|
|
|
2894
3401
|
// src/cohesion.ts
|
|
2895
3402
|
var MERGE_FLOOR = 0.35;
|
|
@@ -3101,6 +3608,8 @@ function seamsWorthWarning(layer, c, entityCount) {
|
|
|
3101
3608
|
// src/advise.ts
|
|
3102
3609
|
var LOW_CONTRAST_FLOOR = 0.05;
|
|
3103
3610
|
var SPARSE_DECLARED_MAX_COVERAGE = 0.02;
|
|
3611
|
+
var READ_LEGIBILITY_FLOOR = 4.5;
|
|
3612
|
+
var READ_SAFE_AREA = 0.05;
|
|
3104
3613
|
function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
|
|
3105
3614
|
const warnings = [];
|
|
3106
3615
|
const w = viewport.width;
|
|
@@ -3205,8 +3714,8 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
|
|
|
3205
3714
|
const fsPx = layer.sprite.fontSize * scale;
|
|
3206
3715
|
const lh = (layer.sprite.lineHeight ?? 1.4) * fsPx;
|
|
3207
3716
|
const maxWPx = layer.sprite.maxWidth * scale;
|
|
3208
|
-
const lines = breakTextBlock(layer.sprite.text, maxWPx / fsPx);
|
|
3209
|
-
pixArea = maxWPx * lines.length * lh * 0.55;
|
|
3717
|
+
const lines = breakTextBlock(layer.sprite.text, maxWPx / fsPx, textMetricsClassFor(layer.sprite.font));
|
|
3718
|
+
pixArea = maxWPx * lines.length * lh * 0.55 * (layer.sprite.opacity ?? 1);
|
|
3210
3719
|
} else {
|
|
3211
3720
|
pixArea = e.size * e.size;
|
|
3212
3721
|
}
|
|
@@ -3222,7 +3731,8 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
|
|
|
3222
3731
|
const positions = entities.map((e) => positionAt(e, 0, w, h));
|
|
3223
3732
|
const maxDistPx = layer.links.maxDist * scale;
|
|
3224
3733
|
const edges = linkEdges(layer.links, positions, maxDistPx, layer.wrap !== false, w, h);
|
|
3225
|
-
const
|
|
3734
|
+
const defaultWidth = scale === 1 ? 1 : 1 / LIMITS.referenceViewport;
|
|
3735
|
+
const lwPx = (layer.links.width ?? defaultWidth) * scale;
|
|
3226
3736
|
const la = layer.links.alpha ?? 1;
|
|
3227
3737
|
for (const edge of edges) totalCoverage += edge.dist * lwPx * la / (w * h);
|
|
3228
3738
|
}
|
|
@@ -3325,9 +3835,16 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
|
|
|
3325
3835
|
for (const e of allEntities[li]) {
|
|
3326
3836
|
const p = positionAt(e, 0, w, h);
|
|
3327
3837
|
const box = s.kind === "textBlock" ? textBlockBoxAt(s, p, w, h) : textBoxAt(s, e, p, spec, w, h);
|
|
3328
|
-
|
|
3838
|
+
const alpha = Math.max(0, Math.min(1, e.alpha - e.pulseAmp)) * (s.kind === "textBlock" ? s.opacity ?? 1 : 1);
|
|
3839
|
+
textBoxes.push({ li, label, alpha, ...box });
|
|
3329
3840
|
}
|
|
3330
3841
|
}
|
|
3842
|
+
const asFraction = (b) => ({
|
|
3843
|
+
x: +(b.x0 / w).toFixed(4),
|
|
3844
|
+
y: +(b.y0 / h).toFixed(4),
|
|
3845
|
+
w: +((b.x1 - b.x0) / w).toFixed(4),
|
|
3846
|
+
h: +((b.y1 - b.y0) / h).toFixed(4)
|
|
3847
|
+
});
|
|
3331
3848
|
const tolX = w * 0.01;
|
|
3332
3849
|
const tolY = h * 0.01;
|
|
3333
3850
|
const offFlagged = /* @__PURE__ */ new Set();
|
|
@@ -3366,12 +3883,109 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
|
|
|
3366
3883
|
warnings.push({
|
|
3367
3884
|
path: `layers[${first.li}]`,
|
|
3368
3885
|
code: "text-overlap",
|
|
3369
|
-
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
|
|
3886
|
+
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`,
|
|
3887
|
+
// The two boxes, so an agent can move one without re-deriving the
|
|
3888
|
+
// layout. Additive: the message is unchanged.
|
|
3889
|
+
boxes: [asFraction(first), asFraction(second)]
|
|
3890
|
+
});
|
|
3891
|
+
}
|
|
3892
|
+
}
|
|
3893
|
+
const readFlagged = /* @__PURE__ */ new Set();
|
|
3894
|
+
for (const b of textBoxes) {
|
|
3895
|
+
if (readFlagged.has(b.li)) continue;
|
|
3896
|
+
const layer = spec.layers[b.li];
|
|
3897
|
+
const s = layer.sprite;
|
|
3898
|
+
if (s.kind !== "text" && s.kind !== "textBlock" || s.role !== "read") continue;
|
|
3899
|
+
const boxes = textBoxes.filter((x) => x.li === b.li);
|
|
3900
|
+
readFlagged.add(b.li);
|
|
3901
|
+
const ink = hexRgb(s.color ?? "#e6e8ef");
|
|
3902
|
+
const textPlate = (base, alpha) => {
|
|
3903
|
+
if (layer.blend === "lighter" || layer.blend === "screen") return additivePlate(base, ink, alpha, layer.blend);
|
|
3904
|
+
if (layer.blend === "multiply") {
|
|
3905
|
+
return {
|
|
3906
|
+
r: base.r * (1 - alpha + ink.r * alpha),
|
|
3907
|
+
g: base.g * (1 - alpha + ink.g * alpha),
|
|
3908
|
+
b: base.b * (1 - alpha + ink.b * alpha)
|
|
3909
|
+
};
|
|
3910
|
+
}
|
|
3911
|
+
return sourceOverPlate(base, ink, alpha);
|
|
3912
|
+
};
|
|
3913
|
+
let worst = null;
|
|
3914
|
+
for (const box of boxes) {
|
|
3915
|
+
const cy = (box.y0 + box.y1) / 2;
|
|
3916
|
+
const groundRgb = backgroundRgbAt(spec, cy, h, scale);
|
|
3917
|
+
const ground = legibilityRatio(textPlate(groundRgb, box.alpha), groundRgb);
|
|
3918
|
+
const lit = brightestAdditivePlate(spec, allEntities, groundRgb, box, w, h);
|
|
3919
|
+
const plate = lit ? legibilityRatio(textPlate(lit.rgb, box.alpha), lit.rgb) : Infinity;
|
|
3920
|
+
if (!worst || Math.min(ground, plate) < Math.min(worst.ground, worst.plate)) {
|
|
3921
|
+
worst = { ground, plate, plateLabel: lit?.label ?? null };
|
|
3922
|
+
}
|
|
3923
|
+
}
|
|
3924
|
+
if (worst && Math.min(worst.ground, worst.plate) < READ_LEGIBILITY_FLOOR) {
|
|
3925
|
+
const under = worst.plateLabel ? ` and ${worst.plate.toFixed(1)}:1 under ${worst.plateLabel} at its brightest` : "";
|
|
3926
|
+
warnings.push({
|
|
3927
|
+
path: `layers[${b.li}].sprite`,
|
|
3928
|
+
code: "text-legibility",
|
|
3929
|
+
message: `${b.label} is declared role: 'read' but its colour reads at ${worst.ground.toFixed(1)}:1 against the background at the box centre${under} \u2014 below ${READ_LEGIBILITY_FLOOR}:1; brighten the text, darken the ground under it, or declare role: 'atmosphere' if it is texture`
|
|
3930
|
+
});
|
|
3931
|
+
}
|
|
3932
|
+
const edges = [];
|
|
3933
|
+
const x0 = Math.min(...boxes.map((x) => x.x0));
|
|
3934
|
+
const x1 = Math.max(...boxes.map((x) => x.x1));
|
|
3935
|
+
const y0 = Math.min(...boxes.map((x) => x.y0));
|
|
3936
|
+
const y1 = Math.max(...boxes.map((x) => x.y1));
|
|
3937
|
+
if (x0 < w * READ_SAFE_AREA) edges.push("left");
|
|
3938
|
+
if (x1 > w * (1 - READ_SAFE_AREA)) edges.push("right");
|
|
3939
|
+
if (y0 < h * READ_SAFE_AREA) edges.push("top");
|
|
3940
|
+
if (y1 > h * (1 - READ_SAFE_AREA)) edges.push("bottom");
|
|
3941
|
+
if (edges.length > 0) {
|
|
3942
|
+
warnings.push({
|
|
3943
|
+
path: `layers[${b.li}]`,
|
|
3944
|
+
code: "text-safe-area",
|
|
3945
|
+
message: `${b.label} is declared role: 'read' but its box is within ${READ_SAFE_AREA * 100}% of the ${edges.join(" and ")} edge${edges.length === 1 ? "" : "s"} at ${w}\xD7${h} \u2014 displays crop and bezels hide that zone; move it inward`,
|
|
3946
|
+
boxes: [asFraction({ x0, y0, x1, y1 })]
|
|
3370
3947
|
});
|
|
3371
3948
|
}
|
|
3372
3949
|
}
|
|
3373
3950
|
return warnings;
|
|
3374
3951
|
}
|
|
3952
|
+
function brightestAdditivePlate(spec, allEntities, ground, box, w, h) {
|
|
3953
|
+
let best = null;
|
|
3954
|
+
for (let lj = 0; lj < spec.layers.length; lj++) {
|
|
3955
|
+
const layer = spec.layers[lj];
|
|
3956
|
+
const blend = layer.blend;
|
|
3957
|
+
if (blend !== "lighter" && blend !== "screen") continue;
|
|
3958
|
+
if (lj === box.li) continue;
|
|
3959
|
+
const entities = allEntities[lj];
|
|
3960
|
+
for (const e of entities) {
|
|
3961
|
+
const hex = spriteHex(layer, e);
|
|
3962
|
+
if (hex === null) continue;
|
|
3963
|
+
const growScale = (e.motion === "warp" ? WARP_MAX_SCALE : 1) * (e.emit ? Math.max(e.emit.growFrom, e.emit.growTo) : 1);
|
|
3964
|
+
const maxDim = Math.max(e.size, e.size2 ?? 0) * (1 + e.growAmp) * growScale;
|
|
3965
|
+
if (maxDim < box.fs) continue;
|
|
3966
|
+
if (!entityReachesBox(e, maxDim / 2, box, w, h)) continue;
|
|
3967
|
+
const a = Math.min(1, e.alpha + e.pulseAmp);
|
|
3968
|
+
if (a <= 0) continue;
|
|
3969
|
+
const plate = additivePlate(ground, hexRgb(hex), a, blend);
|
|
3970
|
+
const lum = relativeLuminance(plate);
|
|
3971
|
+
if (!best || lum > best.lum) {
|
|
3972
|
+
best = { rgb: plate, label: layer.key ? `\`${layer.key}\`` : `layers[${lj}]`, lum };
|
|
3973
|
+
}
|
|
3974
|
+
}
|
|
3975
|
+
}
|
|
3976
|
+
return best ? { rgb: best.rgb, label: best.label } : null;
|
|
3977
|
+
}
|
|
3978
|
+
function entityReachesBox(e, r, box, w, h) {
|
|
3979
|
+
if (e.motion === "static") {
|
|
3980
|
+
const p = positionAt(e, 0, w, h);
|
|
3981
|
+
return p.x + r > box.x0 && p.x - r < box.x1 && p.y + r > box.y0 && p.y - r < box.y1;
|
|
3982
|
+
}
|
|
3983
|
+
if (e.motion === "orbit" && !e.orbitParent) {
|
|
3984
|
+
const span = e.orbitR + r;
|
|
3985
|
+
return e.orbitCx + span > box.x0 && e.orbitCx - span < box.x1 && e.orbitCy + span > box.y0 && e.orbitCy - span < box.y1;
|
|
3986
|
+
}
|
|
3987
|
+
return true;
|
|
3988
|
+
}
|
|
3375
3989
|
function textBoxAt(s, e, p, spec, w, h) {
|
|
3376
3990
|
const m = s.font ? /(\d{1,5}(?:\.\d{1,4})?|\.\d{1,4})px/.exec(s.font) : null;
|
|
3377
3991
|
const fontScale = spec.units === "px" ? 1 : Math.min(w, h) / (spec.referenceViewport ?? LIMITS.referenceViewport);
|
|
@@ -3383,28 +3997,39 @@ function textBoxAt(s, e, p, spec, w, h) {
|
|
|
3383
3997
|
const baseline = s.baseline ?? "middle";
|
|
3384
3998
|
const x0 = align === "left" ? p.x : align === "right" ? p.x - fw : p.x - fw / 2;
|
|
3385
3999
|
const y0 = baseline === "top" ? p.y : baseline === "bottom" ? p.y - fh : p.y - fh / 2;
|
|
3386
|
-
return { x0, y0, x1: x0 + fw, y1: y0 + fh };
|
|
4000
|
+
return { x0, y0, x1: x0 + fw, y1: y0 + fh, fs: fh };
|
|
3387
4001
|
}
|
|
3388
4002
|
function textBlockBoxAt(s, p, w, h) {
|
|
3389
4003
|
const unit = Math.min(w, h);
|
|
3390
4004
|
const fsPx = s.fontSize * unit;
|
|
3391
4005
|
const lh = (s.lineHeight ?? 1.4) * fsPx;
|
|
3392
4006
|
const maxWPx = s.maxWidth * unit;
|
|
3393
|
-
const lines = breakTextBlock(s.text, maxWPx / fsPx);
|
|
4007
|
+
const lines = breakTextBlock(s.text, maxWPx / fsPx, textMetricsClassFor(s.font));
|
|
3394
4008
|
const maxLineW = lines.reduce((mx, l) => Math.max(mx, l.widthEm), 0) * fsPx;
|
|
3395
4009
|
const totalH = lines.length * lh;
|
|
3396
4010
|
const align = s.align ?? "left";
|
|
3397
4011
|
const x0 = align === "center" ? p.x + (maxWPx - maxLineW) / 2 : align === "right" ? p.x + maxWPx - maxLineW : p.x;
|
|
3398
|
-
|
|
4012
|
+
const { dx, dy } = textBlockAnchorOffset(s, maxWPx, maxLineW, totalH);
|
|
4013
|
+
return { x0: x0 + dx, y0: p.y + dy, x1: x0 + dx + maxLineW, y1: p.y + dy + totalH, fs: fsPx };
|
|
3399
4014
|
}
|
|
3400
4015
|
function adviseSequence(seq, viewport = { width: 1920, height: 1080 }) {
|
|
3401
4016
|
const warnings = [];
|
|
3402
4017
|
for (let i = 0; i < seq.segments.length; i++) {
|
|
3403
4018
|
const segWarnings = adviseSpec(seq.segments[i].scene, viewport);
|
|
3404
4019
|
for (const w of segWarnings) {
|
|
3405
|
-
warnings.push({ path: `segments[${i}].scene.${w.path}`, code: w.code, message: w.message });
|
|
4020
|
+
warnings.push({ path: `segments[${i}].scene.${w.path}`, code: w.code, message: w.message, boxes: w.boxes });
|
|
3406
4021
|
}
|
|
3407
4022
|
}
|
|
4023
|
+
const fadeBound = seq.loop ? seq.segments.length : seq.segments.length - 1;
|
|
4024
|
+
const fades = seq.segments.slice(0, fadeBound).filter((s) => s.transition?.type === "fade").length;
|
|
4025
|
+
if (fades > 0) {
|
|
4026
|
+
const first = seq.segments.slice(0, fadeBound).findIndex((s) => s.transition?.type === "fade");
|
|
4027
|
+
warnings.push({
|
|
4028
|
+
path: `segments[${first}].transition`,
|
|
4029
|
+
code: "fade-degrades-on-low-tier",
|
|
4030
|
+
message: `informational: ${fades} fade transition${fades === 1 ? "" : "s"} declared \u2014 viewers on the 'basic' or 'minimal' capability tier render fade as cut (two live segments for dur is over that budget); the show still plays`
|
|
4031
|
+
});
|
|
4032
|
+
}
|
|
3408
4033
|
for (let i = 0; i < seq.segments.length - 1; i++) {
|
|
3409
4034
|
const lumaA = backgroundLuma(seq.segments[i].scene);
|
|
3410
4035
|
const lumaB = backgroundLuma(seq.segments[i + 1].scene);
|
|
@@ -3426,6 +4051,12 @@ function adviseSequence(seq, viewport = { width: 1920, height: 1080 }) {
|
|
|
3426
4051
|
code: "morph-structural-mismatch",
|
|
3427
4052
|
message: `segments ${i}\u2192${i + 1} differ structurally: morph will fall back to cut`
|
|
3428
4053
|
});
|
|
4054
|
+
} else if (morphNothingMorphable(seq.segments[i].scene, seq.segments[i + 1].scene, { textCrossfade: tr.text === "crossfade" })) {
|
|
4055
|
+
warnings.push({
|
|
4056
|
+
path: `segments[${i}].transition`,
|
|
4057
|
+
code: "morph-nothing-morphable",
|
|
4058
|
+
message: `segments ${i}\u2192${i + 1} differ only in values morph cannot interpolate (strings such as textBlock.text step on the first frame): the morph will look like a cut \u2014 fade text via colour or reveal.progress`
|
|
4059
|
+
});
|
|
3429
4060
|
}
|
|
3430
4061
|
}
|
|
3431
4062
|
}
|
|
@@ -3578,14 +4209,15 @@ function textBlockBox(s, _e, p, w, h) {
|
|
|
3578
4209
|
const lh = (s.lineHeight ?? 1.4) * fsPx;
|
|
3579
4210
|
const maxWPx = s.maxWidth * unitScale;
|
|
3580
4211
|
const maxWEm = maxWPx / fsPx;
|
|
3581
|
-
const lines = breakTextBlock(s.text, maxWEm);
|
|
4212
|
+
const lines = breakTextBlock(s.text, maxWEm, textMetricsClassFor(s.font));
|
|
3582
4213
|
const totalH = lines.length * lh;
|
|
3583
4214
|
const maxLineW = lines.reduce((m, l) => Math.max(m, l.widthEm), 0) * fsPx;
|
|
3584
4215
|
const align = s.align ?? "left";
|
|
3585
4216
|
const cx = align === "center" ? p.x + maxWPx / 2 : align === "right" ? p.x + maxWPx - maxLineW / 2 : p.x + maxLineW / 2;
|
|
4217
|
+
const { dx, dy } = textBlockAnchorOffset(s, maxWPx, maxLineW, totalH);
|
|
3586
4218
|
return {
|
|
3587
|
-
cx,
|
|
3588
|
-
cy: p.y + totalH / 2,
|
|
4219
|
+
cx: cx + dx,
|
|
4220
|
+
cy: p.y + totalH / 2 + dy,
|
|
3589
4221
|
halfX: maxLineW / 2,
|
|
3590
4222
|
halfY: totalH / 2
|
|
3591
4223
|
};
|
|
@@ -3594,7 +4226,7 @@ function textBlockRevealFraction(s, w, h, t) {
|
|
|
3594
4226
|
if (!s.reveal) return 1;
|
|
3595
4227
|
const unitScale = Math.min(w, h);
|
|
3596
4228
|
const maxWEm = s.maxWidth * unitScale / (s.fontSize * unitScale);
|
|
3597
|
-
const lines = breakTextBlock(s.text, maxWEm);
|
|
4229
|
+
const lines = breakTextBlock(s.text, maxWEm, textMetricsClassFor(s.font));
|
|
3598
4230
|
const rs = revealState(lines, s.reveal, t);
|
|
3599
4231
|
if (rs.glyphAlphas) {
|
|
3600
4232
|
let sum = 0;
|
|
@@ -3788,7 +4420,7 @@ function luminanceGrid(spec, opts = {}) {
|
|
|
3788
4420
|
const c1 = Math.min(cols - 1, Math.floor((centerX + reachX) / cellW));
|
|
3789
4421
|
const r0 = Math.max(0, Math.floor((centerY - reachY) / cellH));
|
|
3790
4422
|
const r1 = Math.min(rows - 1, Math.floor((centerY + reachY) / cellH));
|
|
3791
|
-
const inkWeight = (s.kind === "text" || s.kind === "emoji" || s.kind === "textBlock" ? 0.55 : 1) * (s.kind === "textBlock" ? textBlockRevealFraction(s, w, h, t) : 1);
|
|
4423
|
+
const inkWeight = (s.kind === "text" || s.kind === "emoji" || s.kind === "textBlock" ? 0.55 : 1) * (s.kind === "textBlock" ? textBlockRevealFraction(s, w, h, t) * (s.opacity ?? 1) : 1);
|
|
3792
4424
|
for (let r = r0; r <= r1; r++) {
|
|
3793
4425
|
for (let c = c0; c <= c1; c++) {
|
|
3794
4426
|
const dx = (c + 0.5) * cellW - centerX;
|
|
@@ -3826,7 +4458,8 @@ function luminanceGrid(spec, opts = {}) {
|
|
|
3826
4458
|
const edges = linkEdges(layer.links, positions, maxDistPx, layer.wrap !== false && motionWraps, w, h);
|
|
3827
4459
|
const la = Math.min(1, (layer.links.alpha ?? 0.6) * lifeA);
|
|
3828
4460
|
const lum = layer.links.color ? hexLuma(layer.links.color) : 0.7;
|
|
3829
|
-
const
|
|
4461
|
+
const defaultWidth = scale === 1 ? 1 : 1 / LIMITS.referenceViewport;
|
|
4462
|
+
const wgt = Math.min(1, (layer.links.width ?? defaultWidth) * scale / cellH) * la * alphaScale;
|
|
3830
4463
|
for (const edge of edges) {
|
|
3831
4464
|
const pi = positions[edge.i];
|
|
3832
4465
|
const pj = positions[edge.j];
|
|
@@ -3856,6 +4489,9 @@ function luminanceGrid(spec, opts = {}) {
|
|
|
3856
4489
|
}
|
|
3857
4490
|
}
|
|
3858
4491
|
splatPass(t, 1);
|
|
4492
|
+
return finishGrid(cols, rows, cells, bgRow);
|
|
4493
|
+
}
|
|
4494
|
+
function finishGrid(cols, rows, cells, bgRow) {
|
|
3859
4495
|
let sum = 0;
|
|
3860
4496
|
let covered = 0;
|
|
3861
4497
|
let devSum = 0;
|
|
@@ -3965,6 +4601,21 @@ function textSprites(spec, opts = {}) {
|
|
|
3965
4601
|
return out;
|
|
3966
4602
|
}
|
|
3967
4603
|
function dominanceRanking(spec, opts = {}) {
|
|
4604
|
+
return rankDominance(rawDominance(spec, opts));
|
|
4605
|
+
}
|
|
4606
|
+
function rankDominance(raw) {
|
|
4607
|
+
const total = raw.reduce((s, r) => s + r.weight, 0) || 1;
|
|
4608
|
+
return raw.slice().sort((a, b) => b.weight - a.weight).map((r, i) => ({
|
|
4609
|
+
rank: i + 1,
|
|
4610
|
+
layerIndex: r.layerIndex,
|
|
4611
|
+
key: r.key,
|
|
4612
|
+
share: r.weight / total,
|
|
4613
|
+
entityCount: r.entityCount,
|
|
4614
|
+
meanLuma: r.meanLuma,
|
|
4615
|
+
factors: r.factors
|
|
4616
|
+
}));
|
|
4617
|
+
}
|
|
4618
|
+
function rawDominance(spec, opts = {}) {
|
|
3968
4619
|
const scene = buildScene(spec, opts);
|
|
3969
4620
|
const t = opts.t ?? 5e3;
|
|
3970
4621
|
const { w, h, scale } = scene;
|
|
@@ -4016,7 +4667,7 @@ function dominanceRanking(spec, opts = {}) {
|
|
|
4016
4667
|
entArea = box.halfX * 2 * box.halfY * 2 * 0.55;
|
|
4017
4668
|
} else if (s.kind === "textBlock") {
|
|
4018
4669
|
const box = textBlockBox(s, e, { x: 0, y: 0 }, w, h);
|
|
4019
|
-
entArea = box.halfX * 2 * box.halfY * 2 * 0.55 * textBlockRevealFraction(s, w, h, t);
|
|
4670
|
+
entArea = box.halfX * 2 * box.halfY * 2 * 0.55 * textBlockRevealFraction(s, w, h, t) * (s.opacity ?? 1);
|
|
4020
4671
|
} else entArea = sz * sz * 0.55;
|
|
4021
4672
|
area += entArea * a;
|
|
4022
4673
|
if (layer.trail) {
|
|
@@ -4034,7 +4685,8 @@ function dominanceRanking(spec, opts = {}) {
|
|
|
4034
4685
|
const motionWraps = ["drift", "rise", "wander"].includes(layer.motion.type);
|
|
4035
4686
|
const edges = linkEdges(layer.links, positions, layer.links.maxDist * scale, layer.wrap !== false && motionWraps, w, h);
|
|
4036
4687
|
const la = (layer.links.alpha ?? 0.6) * lifeA;
|
|
4037
|
-
const
|
|
4688
|
+
const defaultWidth = scale === 1 ? 1 : 1 / LIMITS.referenceViewport;
|
|
4689
|
+
const lw = (layer.links.width ?? defaultWidth) * scale;
|
|
4038
4690
|
for (const edge of edges) area += edge.dist * lw * la * LINE_SALIENCE;
|
|
4039
4691
|
}
|
|
4040
4692
|
const meanLuma = entities.length ? lumAcc / entities.length : 0;
|
|
@@ -4044,16 +4696,7 @@ function dominanceRanking(spec, opts = {}) {
|
|
|
4044
4696
|
const weight = area / (w * h) * contrast * blendBoost * motionBoost;
|
|
4045
4697
|
return { layerIndex, key: layer.key, weight, entityCount: entities.length, meanLuma, factors: { area: area / (w * h), contrast, blendBoost, motionBoost } };
|
|
4046
4698
|
});
|
|
4047
|
-
|
|
4048
|
-
return raw.slice().sort((a, b) => b.weight - a.weight).map((r, i) => ({
|
|
4049
|
-
rank: i + 1,
|
|
4050
|
-
layerIndex: r.layerIndex,
|
|
4051
|
-
key: r.key,
|
|
4052
|
-
share: r.weight / total,
|
|
4053
|
-
entityCount: r.entityCount,
|
|
4054
|
-
meanLuma: r.meanLuma,
|
|
4055
|
-
factors: r.factors
|
|
4056
|
-
}));
|
|
4699
|
+
return raw;
|
|
4057
4700
|
}
|
|
4058
4701
|
function layerCohesion(spec, opts = {}) {
|
|
4059
4702
|
const scene = buildScene(spec, opts);
|
|
@@ -4160,6 +4803,51 @@ function perceiveScene(spec, opts = {}) {
|
|
|
4160
4803
|
advisories: adviseSpec(spec, opts.viewport ?? { width: 1920, height: 1080 }, { t: opts.t, seed: opts.seed })
|
|
4161
4804
|
};
|
|
4162
4805
|
}
|
|
4806
|
+
var bedKey = (key, layerIndex) => `bed:${key ?? layerIndex}`;
|
|
4807
|
+
function perceiveSequenceFrame(seq, T, opts = {}) {
|
|
4808
|
+
const { releasedBelow, ...gridOpts } = opts;
|
|
4809
|
+
const r = resolveSegment(seq, T, { releasedBelow });
|
|
4810
|
+
const seg = seq.segments[r.index];
|
|
4811
|
+
const segment = { index: r.index, key: seg.key, localT: r.localT, ...r.held ? { held: true } : {} };
|
|
4812
|
+
const segOpts = { ...gridOpts, t: r.localT, seed: gridOpts.seed ?? segmentRenderSeed(seq, r.index) };
|
|
4813
|
+
if (!seq.bed) return { ...perceiveScene(seg.scene, segOpts), segment, bed: false };
|
|
4814
|
+
const bedOpts = { ...gridOpts, t: T, seed: gridOpts.seed ?? bedRenderSeed(seq) };
|
|
4815
|
+
const viewport = gridOpts.viewport ?? { width: 1920, height: 1080 };
|
|
4816
|
+
const ink = { ...seg.scene, background: { type: "solid", color: "#000000" } };
|
|
4817
|
+
delete ink.ghosting;
|
|
4818
|
+
const inkOverBed = { ...ink, background: seq.bed.background ?? { type: "solid", color: "#05050a" } };
|
|
4819
|
+
const bedGrid = luminanceGrid(seq.bed, bedOpts);
|
|
4820
|
+
const inkGrid = luminanceGrid(ink, segOpts);
|
|
4821
|
+
const cells = bedGrid.cells.map((v, i) => Math.min(1, v + inkGrid.cells[i]));
|
|
4822
|
+
const grid = finishGrid(bedGrid.cols, bedGrid.rows, cells, bedGrid.background);
|
|
4823
|
+
const bedLayers = seq.bed.layers.length;
|
|
4824
|
+
const dominance = rankDominance([
|
|
4825
|
+
...rawDominance(seq.bed, bedOpts).map((d) => ({ ...d, key: bedKey(d.key, d.layerIndex) })),
|
|
4826
|
+
...rawDominance(inkOverBed, segOpts).map((d) => ({ ...d, layerIndex: d.layerIndex + bedLayers }))
|
|
4827
|
+
]);
|
|
4828
|
+
const shift = (x) => ({ ...x, layerIndex: x.layerIndex + bedLayers });
|
|
4829
|
+
const prefix = (x) => ({ ...x, key: bedKey(x.key, x.layerIndex) });
|
|
4830
|
+
return {
|
|
4831
|
+
t: r.localT,
|
|
4832
|
+
braille: renderBrailleMap(grid),
|
|
4833
|
+
density: renderDensityMap(grid),
|
|
4834
|
+
coverage: grid.coverage,
|
|
4835
|
+
meanLuminance: grid.meanLuminance,
|
|
4836
|
+
centroid: grid.centroid,
|
|
4837
|
+
rowProfile: grid.rowProfile,
|
|
4838
|
+
colProfile: grid.colProfile,
|
|
4839
|
+
dominance,
|
|
4840
|
+
motion: [...motionStats(seq.bed, bedOpts).map(prefix), ...motionStats(ink, segOpts).map(shift)],
|
|
4841
|
+
form: [...layerCohesion(seq.bed, bedOpts).map(prefix), ...layerCohesion(ink, segOpts).map(shift)],
|
|
4842
|
+
text: [...textSprites(seq.bed, bedOpts).map(prefix), ...textSprites(ink, segOpts).map(shift)],
|
|
4843
|
+
advisories: [
|
|
4844
|
+
...adviseSpec(seq.bed, viewport, { t: T, seed: bedOpts.seed }).map((w) => ({ ...w, path: `bed.${w.path}` })),
|
|
4845
|
+
...adviseSpec(inkOverBed, viewport, { t: r.localT, seed: segOpts.seed })
|
|
4846
|
+
],
|
|
4847
|
+
segment,
|
|
4848
|
+
bed: true
|
|
4849
|
+
};
|
|
4850
|
+
}
|
|
4163
4851
|
|
|
4164
4852
|
// src/examples/aquarium.ts
|
|
4165
4853
|
var AQUARIUM_SPEC = {
|
|
@@ -5049,6 +5737,85 @@ var LANTERNS_SPEC = {
|
|
|
5049
5737
|
]
|
|
5050
5738
|
};
|
|
5051
5739
|
|
|
5740
|
+
// src/examples/lobby-talk.ts
|
|
5741
|
+
var LOBBY_TALK_SPEC = {
|
|
5742
|
+
schemaVersion: 1,
|
|
5743
|
+
id: "lobby-talk",
|
|
5744
|
+
label: "Lobby Talk",
|
|
5745
|
+
seed: 1207,
|
|
5746
|
+
motionIntensity: "calm",
|
|
5747
|
+
background: {
|
|
5748
|
+
type: "gradient",
|
|
5749
|
+
stops: [
|
|
5750
|
+
{ at: 0, color: "#060912" },
|
|
5751
|
+
{ at: 1, color: "#0b1224" }
|
|
5752
|
+
],
|
|
5753
|
+
drift: { period: 4e4, amount: 0.1 }
|
|
5754
|
+
},
|
|
5755
|
+
layers: [
|
|
5756
|
+
{
|
|
5757
|
+
key: "haze",
|
|
5758
|
+
count: 3,
|
|
5759
|
+
sprite: { kind: "circle", radius: [0.22, 0.3], soft: true, color: "#123a4a", colors: ["#123a4a", "#2a1e4a", "#0e2a3a"] },
|
|
5760
|
+
alpha: [0.35, 0.5],
|
|
5761
|
+
blend: "lighter",
|
|
5762
|
+
region: { x: [0.2, 0.8], y: [0.3, 0.7] },
|
|
5763
|
+
motion: { type: "wander", speed: [2e-3, 4e-3], meander: 0.05 },
|
|
5764
|
+
pulse: { amp: 0.15, period: 9e3 }
|
|
5765
|
+
},
|
|
5766
|
+
{
|
|
5767
|
+
key: "dust",
|
|
5768
|
+
count: 50,
|
|
5769
|
+
sprite: { kind: "circle", radius: [8e-4, 18e-4], soft: true, color: "#7fb8c9" },
|
|
5770
|
+
alpha: [0.2, 0.5],
|
|
5771
|
+
blend: "lighter",
|
|
5772
|
+
motion: { type: "wander", speed: [1e-3, 3e-3] }
|
|
5773
|
+
},
|
|
5774
|
+
{
|
|
5775
|
+
key: "title",
|
|
5776
|
+
count: 1,
|
|
5777
|
+
sprite: { kind: "textBlock", text: "Adoption by feature, % of accounts", maxWidth: 1, fontSize: 0.034, color: "#f2f4f8", role: "read" },
|
|
5778
|
+
motion: { type: "static" },
|
|
5779
|
+
position: { x: 0.12, y: 0.16 }
|
|
5780
|
+
},
|
|
5781
|
+
{
|
|
5782
|
+
key: "labels",
|
|
5783
|
+
count: 5,
|
|
5784
|
+
sprite: { kind: "text", strings: ["Onboarding", "Search", "Billing", "Exports", "Mobile"], font: "system-ui", align: "left", baseline: "middle", color: "#a9b7cc", role: "read" },
|
|
5785
|
+
size: [0.03, 0.03],
|
|
5786
|
+
motion: { type: "static" },
|
|
5787
|
+
position: { x: 0.12, y: 0.32 },
|
|
5788
|
+
layout: { type: "list", gap: 0.085 }
|
|
5789
|
+
},
|
|
5790
|
+
{
|
|
5791
|
+
key: "bars",
|
|
5792
|
+
count: 5,
|
|
5793
|
+
sprite: {
|
|
5794
|
+
kind: "bar",
|
|
5795
|
+
values: [82, 64, 91, 37, 58],
|
|
5796
|
+
max: 100,
|
|
5797
|
+
length: 0.42,
|
|
5798
|
+
thickness: 0.026,
|
|
5799
|
+
color: "#7fe0d2",
|
|
5800
|
+
colors: ["#7fe0d2", "#7fe0d2", "#ffcf7a", "#7fe0d2", "#7fe0d2"]
|
|
5801
|
+
},
|
|
5802
|
+
alpha: [0.9, 0.9],
|
|
5803
|
+
motion: { type: "static" },
|
|
5804
|
+
position: { x: 0.33, y: 0.32 },
|
|
5805
|
+
layout: { type: "list", gap: 0.085 }
|
|
5806
|
+
},
|
|
5807
|
+
{
|
|
5808
|
+
key: "readouts",
|
|
5809
|
+
count: 5,
|
|
5810
|
+
sprite: { kind: "text", strings: ["82%", "64%", "91%", "37%", "58%"], font: "bold system-ui", align: "left", baseline: "middle", color: "#f2f4f8", role: "read" },
|
|
5811
|
+
size: [0.03, 0.03],
|
|
5812
|
+
motion: { type: "static" },
|
|
5813
|
+
position: { x: 0.78, y: 0.32 },
|
|
5814
|
+
layout: { type: "list", gap: 0.085 }
|
|
5815
|
+
}
|
|
5816
|
+
]
|
|
5817
|
+
};
|
|
5818
|
+
|
|
5052
5819
|
// src/examples/matrix-rain.ts
|
|
5053
5820
|
var MATRIX_RAIN_SPEC = {
|
|
5054
5821
|
schemaVersion: 1,
|
|
@@ -5773,7 +6540,8 @@ var SCHEMA_EXAMPLES = [
|
|
|
5773
6540
|
{ id: "haiku", label: "Haiku", spec: HAIKU_SPEC },
|
|
5774
6541
|
{ id: "pings", label: "Pings", spec: PINGS_SPEC },
|
|
5775
6542
|
{ id: "facets", label: "Facets", spec: FACETS_SPEC },
|
|
5776
|
-
{ id: "relay-board", label: "Relay Board", spec: RELAY_BOARD_SPEC }
|
|
6543
|
+
{ id: "relay-board", label: "Relay Board", spec: RELAY_BOARD_SPEC },
|
|
6544
|
+
{ id: "lobby-talk", label: "Lobby Talk", spec: LOBBY_TALK_SPEC }
|
|
5777
6545
|
];
|
|
5778
6546
|
var EXAMPLE_SPECS = SCHEMA_EXAMPLES.map((e) => e.spec);
|
|
5779
6547
|
var EXAMPLE_BY_ID = Object.fromEntries(
|
|
@@ -5792,6 +6560,7 @@ export {
|
|
|
5792
6560
|
HAIKU_SPEC,
|
|
5793
6561
|
LANTERNS_SPEC,
|
|
5794
6562
|
LIMITS,
|
|
6563
|
+
LOBBY_TALK_SPEC,
|
|
5795
6564
|
MATRIX_RAIN_SPEC,
|
|
5796
6565
|
NOSTALGHIA_CANDLE_SPEC,
|
|
5797
6566
|
ORRERY_SPEC,
|
|
@@ -5804,6 +6573,7 @@ export {
|
|
|
5804
6573
|
SCHEMA_EXAMPLES,
|
|
5805
6574
|
SCHEMA_VERSION,
|
|
5806
6575
|
SNOWFALL_SPEC,
|
|
6576
|
+
WARP_MAX_SCALE,
|
|
5807
6577
|
WARP_NEAR,
|
|
5808
6578
|
WARP_TUNNEL_SPEC,
|
|
5809
6579
|
adviseSequence,
|
|
@@ -5812,8 +6582,10 @@ export {
|
|
|
5812
6582
|
applyDeltasToSpec,
|
|
5813
6583
|
assertValidSequence,
|
|
5814
6584
|
assertValidSpec,
|
|
6585
|
+
bedRenderSeed,
|
|
5815
6586
|
breakTextBlock,
|
|
5816
6587
|
buildEntities,
|
|
6588
|
+
canMorph,
|
|
5817
6589
|
cohesionOf,
|
|
5818
6590
|
compileSaver,
|
|
5819
6591
|
compileSequence,
|
|
@@ -5832,8 +6604,12 @@ export {
|
|
|
5832
6604
|
linkPairs,
|
|
5833
6605
|
luminanceGrid,
|
|
5834
6606
|
manifestFor,
|
|
6607
|
+
morphChainRoot,
|
|
6608
|
+
morphNothingMorphable,
|
|
5835
6609
|
motionStats,
|
|
6610
|
+
normalizeSeed,
|
|
5836
6611
|
perceiveScene,
|
|
6612
|
+
perceiveSequenceFrame,
|
|
5837
6613
|
positionAt,
|
|
5838
6614
|
renderBrailleMap,
|
|
5839
6615
|
renderDensityMap,
|
|
@@ -5843,13 +6619,19 @@ export {
|
|
|
5843
6619
|
rotationAt,
|
|
5844
6620
|
seamCauseOf,
|
|
5845
6621
|
seamsWorthWarning,
|
|
6622
|
+
segmentRenderSeed,
|
|
5846
6623
|
segmentStart,
|
|
6624
|
+
sequenceSteerablePaths,
|
|
5847
6625
|
sizeAt,
|
|
5848
6626
|
spriteIndexAt,
|
|
5849
6627
|
spriteVariants,
|
|
5850
6628
|
steerablePaths,
|
|
5851
6629
|
structuralSignature,
|
|
6630
|
+
textBlockAnchorOffset,
|
|
6631
|
+
textMetricsClassFor,
|
|
5852
6632
|
textSprites,
|
|
6633
|
+
textStringsDiffer,
|
|
6634
|
+
textStringsOf,
|
|
5853
6635
|
textWidthEm,
|
|
5854
6636
|
validateSequence,
|
|
5855
6637
|
validateSpec
|