@glissade/scene 0.19.1 → 0.20.0-pre.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/collapseReplacer.d.ts +40 -0
- package/dist/constructionProps.js +81 -0
- package/dist/describe.d.ts +26 -1
- package/dist/describe.js +104 -23
- package/dist/diagnostics.d.ts +159 -0
- package/dist/diagnostics.js +384 -0
- package/dist/displayList.d.ts +27 -0
- package/dist/grid.d.ts +56 -0
- package/dist/grid.js +83 -0
- package/dist/identity.d.ts +33 -0
- package/dist/identity.js +67 -0
- package/dist/index.d.ts +14 -302
- package/dist/index.js +19 -523
- package/dist/layout.d.ts +3 -95
- package/dist/layout.js +11 -196
- package/dist/layoutCtors.d.ts +99 -0
- package/dist/layoutCtors.js +210 -0
- package/dist/motion.d.ts +60 -0
- package/dist/motion.js +171 -0
- package/dist/nodes.d.ts +32 -8
- package/dist/nodes.js +62 -187
- package/dist/scene.d.ts +71 -0
- package/dist/scene.js +120 -0
- package/package.json +23 -3
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import { H as IDENTITY, K as matEquals, L as createDisplayListBuilder, V as collapseReplacer, f as roundedRectSegs, p as Node, r as Group } from "./nodes.js";
|
|
2
|
+
import { a as evaluate } from "./scene.js";
|
|
3
|
+
import { signal, vec2Signal } from "@glissade/core";
|
|
4
|
+
//#region src/displayDiff.ts
|
|
5
|
+
/** A flat, stable JSON value for one command with its resource ids INLINED to content. */
|
|
6
|
+
function commandView(cmd, resources) {
|
|
7
|
+
const out = {};
|
|
8
|
+
for (const [k, v] of Object.entries(cmd)) out[k] = v;
|
|
9
|
+
if (cmd.op === "clip" || cmd.op === "fillPath" || cmd.op === "strokePath") out["path"] = resources[cmd.path];
|
|
10
|
+
else if (cmd.op === "drawImage") out["image"] = resources[cmd.image];
|
|
11
|
+
return out;
|
|
12
|
+
}
|
|
13
|
+
const stable = (v) => JSON.stringify(v, collapseReplacer);
|
|
14
|
+
/** Field-level diff of two same-op command views (shallow over top-level props). */
|
|
15
|
+
function diffFields(a, b) {
|
|
16
|
+
const changes = [];
|
|
17
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
18
|
+
for (const k of keys) {
|
|
19
|
+
if (k === "op") continue;
|
|
20
|
+
if (stable(a[k]) !== stable(b[k])) changes.push({
|
|
21
|
+
path: k,
|
|
22
|
+
from: a[k],
|
|
23
|
+
to: b[k]
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
return changes;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Index-aligned positional diff of two DisplayLists. Command i in `a` is
|
|
30
|
+
* compared to command i in `b`; trailing commands become `add`/`remove`. A
|
|
31
|
+
* single insert/remove cascades — this is the documented v1 cliff.
|
|
32
|
+
*/
|
|
33
|
+
function diffDisplayLists(a, b) {
|
|
34
|
+
const deltas = [];
|
|
35
|
+
const n = Math.max(a.commands.length, b.commands.length);
|
|
36
|
+
for (let i = 0; i < n; i++) {
|
|
37
|
+
const ca = a.commands[i];
|
|
38
|
+
const cb = b.commands[i];
|
|
39
|
+
if (ca !== void 0 && cb !== void 0) {
|
|
40
|
+
const va = commandView(ca, a.resources);
|
|
41
|
+
const vb = commandView(cb, b.resources);
|
|
42
|
+
if (stable(va) === stable(vb)) continue;
|
|
43
|
+
if (ca.op !== cb.op) deltas.push({
|
|
44
|
+
index: i,
|
|
45
|
+
kind: "change",
|
|
46
|
+
opA: ca.op,
|
|
47
|
+
opB: cb.op,
|
|
48
|
+
fields: [{
|
|
49
|
+
path: "op",
|
|
50
|
+
from: ca.op,
|
|
51
|
+
to: cb.op
|
|
52
|
+
}]
|
|
53
|
+
});
|
|
54
|
+
else deltas.push({
|
|
55
|
+
index: i,
|
|
56
|
+
kind: "change",
|
|
57
|
+
opA: ca.op,
|
|
58
|
+
opB: cb.op,
|
|
59
|
+
fields: diffFields(va, vb)
|
|
60
|
+
});
|
|
61
|
+
} else if (cb !== void 0) deltas.push({
|
|
62
|
+
index: i,
|
|
63
|
+
kind: "add",
|
|
64
|
+
opB: cb.op,
|
|
65
|
+
fields: []
|
|
66
|
+
});
|
|
67
|
+
else if (ca !== void 0) deltas.push({
|
|
68
|
+
index: i,
|
|
69
|
+
kind: "remove",
|
|
70
|
+
opA: ca.op,
|
|
71
|
+
fields: []
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
const sizeDiffers = a.size.w !== b.size.w || a.size.h !== b.size.h;
|
|
75
|
+
return {
|
|
76
|
+
equal: deltas.length === 0 && !sizeDiffers,
|
|
77
|
+
deltas,
|
|
78
|
+
...sizeDiffers ? { size: {
|
|
79
|
+
from: a.size,
|
|
80
|
+
to: b.size
|
|
81
|
+
} } : {}
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/** Human-readable, multi-line rendering of a DisplayDiff (CLI command-tree). */
|
|
85
|
+
function formatDisplayDiff(diff) {
|
|
86
|
+
if (diff.equal) return "DisplayLists are identical.";
|
|
87
|
+
const lines = [];
|
|
88
|
+
if (diff.size) lines.push(`size ${diff.size.from.w}x${diff.size.from.h} -> ${diff.size.to.w}x${diff.size.to.h}`);
|
|
89
|
+
for (const d of diff.deltas) if (d.kind === "add") lines.push(`+ [${d.index}] add ${d.opB ?? "?"}`);
|
|
90
|
+
else if (d.kind === "remove") lines.push(`- [${d.index}] remove ${d.opA ?? "?"}`);
|
|
91
|
+
else {
|
|
92
|
+
const op = d.opA === d.opB ? d.opA : `${d.opA ?? "?"} -> ${d.opB ?? "?"}`;
|
|
93
|
+
lines.push(`~ [${d.index}] ${op}`);
|
|
94
|
+
for (const f of d.fields) lines.push(` ${f.path}: ${stable(f.from)} -> ${stable(f.to)}`);
|
|
95
|
+
}
|
|
96
|
+
return lines.join("\n");
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The `.dl.json` snapshot interchange schema (DESIGN §7.4). Users commit
|
|
100
|
+
* `.dl.json` baselines, so this carries the SAME break-policy obligation as
|
|
101
|
+
* `Timeline.version` and `SidecarDoc.sidecarVersion`: bump on a breaking shape
|
|
102
|
+
* change. Independent of the API version.
|
|
103
|
+
*/
|
|
104
|
+
const DL_SNAPSHOT_VERSION = 1;
|
|
105
|
+
/**
|
|
106
|
+
* Serialize a DisplayList to a stable `.dl.json` document string (reusing the
|
|
107
|
+
* byte-preserving collapse-replacer). Round-trips through `parseDisplaySnapshot`.
|
|
108
|
+
*/
|
|
109
|
+
function serializeDisplayList(dl) {
|
|
110
|
+
const snapshot = {
|
|
111
|
+
dlSnapshotVersion: 1,
|
|
112
|
+
size: dl.size,
|
|
113
|
+
commands: dl.commands,
|
|
114
|
+
resources: dl.resources
|
|
115
|
+
};
|
|
116
|
+
return JSON.stringify(snapshot, collapseReplacer, 2);
|
|
117
|
+
}
|
|
118
|
+
var DlSnapshotError = class extends Error {
|
|
119
|
+
constructor(message) {
|
|
120
|
+
super(message);
|
|
121
|
+
this.name = "DlSnapshotError";
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
/** Parse a `.dl.json` snapshot back into a DisplayList (validates the version). */
|
|
125
|
+
function parseDisplaySnapshot(json) {
|
|
126
|
+
const doc = JSON.parse(json);
|
|
127
|
+
if (doc.dlSnapshotVersion !== 1) throw new DlSnapshotError(`unsupported dlSnapshotVersion ${String(doc.dlSnapshotVersion)}; this build reads 1`);
|
|
128
|
+
if (!doc.size || !Array.isArray(doc.commands) || !Array.isArray(doc.resources)) throw new DlSnapshotError("malformed .dl.json snapshot (need size, commands[], resources[])");
|
|
129
|
+
return {
|
|
130
|
+
size: doc.size,
|
|
131
|
+
commands: doc.commands,
|
|
132
|
+
resources: doc.resources
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
//#endregion
|
|
136
|
+
//#region src/cacheColdAudit.ts
|
|
137
|
+
/** Stable string of a DisplayList for comparison (opaque resources collapse to a marker). */
|
|
138
|
+
function hashDisplayList(dl) {
|
|
139
|
+
return JSON.stringify(dl, collapseReplacer);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Evaluate two fresh scenes from `createScene` at `t` and confirm the
|
|
143
|
+
* DisplayLists are byte-identical. Returns `{ ok: true }` for a pure scene, or
|
|
144
|
+
* `{ ok: false, node }` naming the first divergent node. DEV-only — never on
|
|
145
|
+
* the render hot path.
|
|
146
|
+
*/
|
|
147
|
+
function auditCacheCold(createScene, doc, t) {
|
|
148
|
+
const warm = createScene();
|
|
149
|
+
const cold = createScene();
|
|
150
|
+
const a = evaluate(warm, doc, t);
|
|
151
|
+
const b = evaluate(cold, doc, t);
|
|
152
|
+
if (hashDisplayList(a) === hashDisplayList(b)) return { ok: true };
|
|
153
|
+
const frame = doc.fps !== void 0 ? Math.round(t * doc.fps) : -1;
|
|
154
|
+
const ctxA = {
|
|
155
|
+
time: t,
|
|
156
|
+
frame,
|
|
157
|
+
measurer: warm.textMeasurer
|
|
158
|
+
};
|
|
159
|
+
const ctxB = {
|
|
160
|
+
time: t,
|
|
161
|
+
frame,
|
|
162
|
+
measurer: cold.textMeasurer
|
|
163
|
+
};
|
|
164
|
+
let groupFallback;
|
|
165
|
+
for (const [id, nodeA] of warm.nodes) {
|
|
166
|
+
const nodeB = cold.nodes.get(id);
|
|
167
|
+
if (!nodeB) return {
|
|
168
|
+
ok: false,
|
|
169
|
+
node: id
|
|
170
|
+
};
|
|
171
|
+
const ea = createDisplayListBuilder(warm.size);
|
|
172
|
+
nodeA.emit(ea, ctxA);
|
|
173
|
+
const eb = createDisplayListBuilder(cold.size);
|
|
174
|
+
nodeB.emit(eb, ctxB);
|
|
175
|
+
const dlA = ea.finish();
|
|
176
|
+
const dlB = eb.finish();
|
|
177
|
+
if (hashDisplayList(dlA) === hashDisplayList(dlB)) continue;
|
|
178
|
+
if (nodeA instanceof Group) {
|
|
179
|
+
groupFallback ??= id;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const first = diffDisplayLists(dlA, dlB).deltas[0];
|
|
183
|
+
return {
|
|
184
|
+
ok: false,
|
|
185
|
+
node: id,
|
|
186
|
+
...first !== void 0 ? { delta: first } : {}
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
ok: false,
|
|
191
|
+
...groupFallback !== void 0 ? { node: groupFallback } : {}
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
//#endregion
|
|
195
|
+
//#region src/tokenHighlight.ts
|
|
196
|
+
/**
|
|
197
|
+
* Multi-range token highlight: sub-line ranges over a Text node's wordBoxes,
|
|
198
|
+
* each with its OWN animatable fill/opacity/progress/scale — four-color
|
|
199
|
+
* category passes, per-token flips, karaoke-with-color. Design answers from
|
|
200
|
+
* downstream production (the NNDL verification ritual):
|
|
201
|
+
* - ranges VALIDATE at construction and THROW on copy drift at draw — the
|
|
202
|
+
* throw is load-bearing (it catches edited copy that no longer matches);
|
|
203
|
+
* `rematch: true` opts animated text into per-frame re-resolution.
|
|
204
|
+
* - a range spanning a wrap produces one rect per line segment.
|
|
205
|
+
* - string matches are whitespace-stripped consecutive box runs and must end
|
|
206
|
+
* boundary-exact (mid-segment end = error listing the actual segments);
|
|
207
|
+
* `[wordIndex, wordIndex]` ranges sidestep matching entirely.
|
|
208
|
+
*/
|
|
209
|
+
var TokenMatchError = class extends Error {
|
|
210
|
+
constructor(message) {
|
|
211
|
+
super(message);
|
|
212
|
+
this.name = "TokenMatchError";
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
const stripWs = (s) => s.replace(/\s+/g, "");
|
|
216
|
+
/**
|
|
217
|
+
* Find the Nth whitespace-stripped consecutive run of boxes equal to token.
|
|
218
|
+
* Boundary-exact: a run that diverges mid-segment is not a match; ending
|
|
219
|
+
* mid-segment throws (with the real segment list) rather than half-boxing.
|
|
220
|
+
*/
|
|
221
|
+
function matchTokenRun(boxes, token, occurrence = 1) {
|
|
222
|
+
const want = stripWs(token);
|
|
223
|
+
if (want.length === 0) throw new TokenMatchError("empty token");
|
|
224
|
+
let seen = 0;
|
|
225
|
+
for (let i = 0; i < boxes.length; i++) {
|
|
226
|
+
let acc = "";
|
|
227
|
+
for (let j = i; j < boxes.length; j++) {
|
|
228
|
+
acc += stripWs(boxes[j].text);
|
|
229
|
+
if (acc === want) {
|
|
230
|
+
seen++;
|
|
231
|
+
if (seen === occurrence) return [i, j];
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
if (!want.startsWith(acc)) break;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
throw new TokenMatchError(`no match for token '${token}'${occurrence > 1 ? ` (occurrence ${occurrence})` : ""} — segments are [${boxes.map((b) => `'${b.text}'`).join(", ")}]`);
|
|
238
|
+
}
|
|
239
|
+
var TokenHighlight = class extends Node {
|
|
240
|
+
target;
|
|
241
|
+
padding;
|
|
242
|
+
cornerRadius;
|
|
243
|
+
rematch;
|
|
244
|
+
ranges;
|
|
245
|
+
constructor(props) {
|
|
246
|
+
super(props);
|
|
247
|
+
this.target = props.text;
|
|
248
|
+
this.padding = props.padding ?? [4, 2];
|
|
249
|
+
this.cornerRadius = props.cornerRadius ?? 4;
|
|
250
|
+
this.rematch = props.rematch ?? false;
|
|
251
|
+
const boxes = this.target.wordBoxes();
|
|
252
|
+
this.ranges = props.ranges.map((spec, index) => {
|
|
253
|
+
const run = this.resolveRun(boxes, spec);
|
|
254
|
+
const id = spec.id ?? `r${index}`;
|
|
255
|
+
const r = {
|
|
256
|
+
spec,
|
|
257
|
+
fill: init(signal("#ffe066"), spec.fill),
|
|
258
|
+
opacity: init(signal(1), spec.opacity),
|
|
259
|
+
progress: init(signal(1), spec.progress),
|
|
260
|
+
scale: init(signal(1), spec.scale),
|
|
261
|
+
offset: initVec(vec2Signal([0, 0]), spec.offset),
|
|
262
|
+
run,
|
|
263
|
+
bound: runText(boxes, run)
|
|
264
|
+
};
|
|
265
|
+
this.registerTarget(`${id}/fill`, r.fill, "color");
|
|
266
|
+
this.registerTarget(`${id}/opacity`, r.opacity, "number");
|
|
267
|
+
this.registerTarget(`${id}/progress`, r.progress, "number");
|
|
268
|
+
this.registerTarget(`${id}/scale`, r.scale, "number");
|
|
269
|
+
this.registerTarget(`${id}/offset`, r.offset, "vec2");
|
|
270
|
+
this.registerTarget(`${id}/offset.x`, r.offset.x, "number");
|
|
271
|
+
this.registerTarget(`${id}/offset.y`, r.offset.y, "number");
|
|
272
|
+
return r;
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
resolveRun(boxes, spec) {
|
|
276
|
+
if (typeof spec.match !== "string") {
|
|
277
|
+
const [from, to] = spec.match;
|
|
278
|
+
if (from < 0 || to >= boxes.length || from > to) throw new TokenMatchError(`word index range [${from}, ${to}] out of bounds (${boxes.length} boxes)`);
|
|
279
|
+
return [from, to];
|
|
280
|
+
}
|
|
281
|
+
return matchTokenRun(boxes, spec.match, spec.occurrence ?? 1);
|
|
282
|
+
}
|
|
283
|
+
draw(out, ctx) {
|
|
284
|
+
const boxes = this.target.wordBoxes(ctx.measurer);
|
|
285
|
+
if (boxes.length === 0) return;
|
|
286
|
+
const tm = this.target.localMatrix();
|
|
287
|
+
const [px, py] = this.padding;
|
|
288
|
+
let pushedTransform = false;
|
|
289
|
+
for (const r of this.ranges) {
|
|
290
|
+
const opacity = r.opacity();
|
|
291
|
+
if (opacity <= 0) continue;
|
|
292
|
+
const progress = Math.min(1, Math.max(0, r.progress()));
|
|
293
|
+
if (progress <= 0) continue;
|
|
294
|
+
let run = r.run;
|
|
295
|
+
if (this.rematch && typeof r.spec.match === "string") run = matchTokenRun(boxes, r.spec.match, r.spec.occurrence ?? 1);
|
|
296
|
+
else if (runText(boxes, run) !== r.bound) throw new TokenMatchError(`bound token '${r.bound}' no longer matches boxes [${run[0]}, ${run[1]}] — the text changed (segments now [${boxes.slice(run[0], run[1] + 1).map((b) => `'${b.text}'`).join(", ")}]); pass rematch: true for animated text`);
|
|
297
|
+
const lineRects = [];
|
|
298
|
+
for (let i = run[0]; i <= run[1]; i++) {
|
|
299
|
+
const b = boxes[i];
|
|
300
|
+
const last = lineRects[lineRects.length - 1];
|
|
301
|
+
const cur = i > run[0] && boxes[i - 1].line === b.line ? last : void 0;
|
|
302
|
+
if (cur) {
|
|
303
|
+
cur.w = b.x + b.w + px - cur.x;
|
|
304
|
+
cur.y = Math.min(cur.y, b.y - py);
|
|
305
|
+
cur.h = Math.max(cur.h, b.y + b.h + py - cur.y);
|
|
306
|
+
} else lineRects.push({
|
|
307
|
+
x: b.x - px,
|
|
308
|
+
y: b.y - py,
|
|
309
|
+
w: b.w + 2 * px,
|
|
310
|
+
h: b.h + 2 * py
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
if (!pushedTransform && !matEquals(tm, IDENTITY)) {
|
|
314
|
+
out.push({
|
|
315
|
+
op: "transform",
|
|
316
|
+
m: tm
|
|
317
|
+
});
|
|
318
|
+
pushedTransform = true;
|
|
319
|
+
}
|
|
320
|
+
const grouped = opacity < 1;
|
|
321
|
+
if (grouped) out.push({
|
|
322
|
+
op: "pushGroup",
|
|
323
|
+
opacity,
|
|
324
|
+
blend: "source-over",
|
|
325
|
+
filters: []
|
|
326
|
+
});
|
|
327
|
+
const fill = r.fill();
|
|
328
|
+
const scale = r.scale();
|
|
329
|
+
const [ox, oy] = r.offset();
|
|
330
|
+
let remaining = progress * lineRects.reduce((sum, q) => sum + q.w, 0);
|
|
331
|
+
for (const q of lineRects) {
|
|
332
|
+
const fillW = Math.min(q.w, remaining);
|
|
333
|
+
remaining -= fillW;
|
|
334
|
+
if (fillW <= 0) break;
|
|
335
|
+
const cx = q.x + q.w / 2 + ox;
|
|
336
|
+
const cy = q.y + q.h / 2 + oy;
|
|
337
|
+
const w = fillW * scale;
|
|
338
|
+
const h = q.h * scale;
|
|
339
|
+
const x = cx - q.w / 2 * scale;
|
|
340
|
+
const y = cy - h / 2;
|
|
341
|
+
const rad = Math.min(this.cornerRadius, w / 2, h / 2);
|
|
342
|
+
const path = out.resource({
|
|
343
|
+
kind: "path",
|
|
344
|
+
segs: roundedRectSegs(x, y, w, h, rad)
|
|
345
|
+
});
|
|
346
|
+
out.push({
|
|
347
|
+
op: "fillPath",
|
|
348
|
+
path,
|
|
349
|
+
paint: {
|
|
350
|
+
kind: "color",
|
|
351
|
+
color: fill
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
if (remaining <= 0) break;
|
|
355
|
+
}
|
|
356
|
+
if (grouped) out.push({ op: "popGroup" });
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
function runText(boxes, run) {
|
|
361
|
+
return boxes.slice(run[0], run[1] + 1).map((b) => stripWs(b.text)).join("");
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* `children: [tokenHighlight(para, { ranges: [{ match: '$48,200', fill: cat.money }] }), para]`
|
|
365
|
+
* — each range animates independently via '<id>/<rangeId>/fill|opacity|progress|scale'.
|
|
366
|
+
*/
|
|
367
|
+
function tokenHighlight(text, props) {
|
|
368
|
+
return new TokenHighlight({
|
|
369
|
+
...props,
|
|
370
|
+
text
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
function init(sig, v) {
|
|
374
|
+
if (typeof v === "function") sig.bindSource(v);
|
|
375
|
+
else if (v !== void 0) sig.set(v);
|
|
376
|
+
return sig;
|
|
377
|
+
}
|
|
378
|
+
function initVec(sig, v) {
|
|
379
|
+
if (typeof v === "function") sig.bindSource(v);
|
|
380
|
+
else if (v !== void 0) sig.set(v);
|
|
381
|
+
return sig;
|
|
382
|
+
}
|
|
383
|
+
//#endregion
|
|
384
|
+
export { DL_SNAPSHOT_VERSION, DlSnapshotError, TokenHighlight, TokenMatchError, auditCacheCold, collapseReplacer, diffDisplayLists, formatDisplayDiff, matchTokenRun, parseDisplaySnapshot, serializeDisplayList, tokenHighlight };
|
package/dist/displayList.d.ts
CHANGED
|
@@ -46,6 +46,18 @@ interface FontSpec {
|
|
|
46
46
|
size: number;
|
|
47
47
|
weight?: number;
|
|
48
48
|
style?: 'normal' | 'italic';
|
|
49
|
+
/**
|
|
50
|
+
* Variable-font axis settings in CSS `font-variation-settings` form
|
|
51
|
+
* (e.g. `'"wght" 700, "opsz" 14'`). 0.20 STATIC passthrough: applied on the
|
|
52
|
+
* Skia/export path (`@napi-rs/canvas` exposes `ctx.fontVariationSettings`),
|
|
53
|
+
* best-effort in the browser (the DOM 2D context has no such property — a
|
|
54
|
+
* guarded no-op there). OMITTED for default Text, so a node without axes
|
|
55
|
+
* emits a byte-identical FontSpec (the golden corpus depends on this).
|
|
56
|
+
* Animatable axes (a `wght` track) are deferred to 1.0 — the string isn't
|
|
57
|
+
* lerp-able, and a track targeting `<id>/fontVariationSettings` already
|
|
58
|
+
* hard-throws `UnboundTargetError` (no signal resolves to it).
|
|
59
|
+
*/
|
|
60
|
+
fontVariationSettings?: string;
|
|
49
61
|
}
|
|
50
62
|
/**
|
|
51
63
|
* Group filters (§3.4): a CLOSED union — validated data, never a CSS
|
|
@@ -183,6 +195,21 @@ interface DisplayListBuilder {
|
|
|
183
195
|
cacheKey?(start: number, end: number): string | undefined;
|
|
184
196
|
/** Stamp a cacheKey onto the pushGroup already emitted at index `i`. */
|
|
185
197
|
patchCacheKey?(i: number, key: string): void;
|
|
198
|
+
/**
|
|
199
|
+
* OUT-OF-BAND node-identity seam (S1, the DOM-backend readiness prerequisite —
|
|
200
|
+
* see docs/design/dom-backend.md "Seam 1"). `Node.emit` announces the node it
|
|
201
|
+
* is about to emit (`enterNode(this.id)`) and announces completion
|
|
202
|
+
* (`exitNode()`) — a strictly LIFO pair around the whole save…restore slice,
|
|
203
|
+
* so the instrumented builder can attribute each `push` to the emitting node
|
|
204
|
+
* and produce a positional `NodeIdStream` ALONGSIDE the DisplayList, never
|
|
205
|
+
* inside it. Both are OPTIONAL: the default `createDisplayListBuilder` does NOT
|
|
206
|
+
* implement them, so `Node.emit`'s guarded `out.enterNode?.()` /
|
|
207
|
+
* `out.exitNode?.()` calls are no-ops on the normal evaluate/render path and
|
|
208
|
+
* every DrawCommand stays byte-identical (the 262-golden contract). Only the
|
|
209
|
+
* opt-in `emitWithIds` builder (`@glissade/scene/identity`) supplies them.
|
|
210
|
+
*/
|
|
211
|
+
enterNode?(id: string | undefined): void;
|
|
212
|
+
exitNode?(): void;
|
|
186
213
|
}
|
|
187
214
|
declare function createDisplayListBuilder(size: {
|
|
188
215
|
w: number;
|
package/dist/grid.d.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { B as NodeProps, i as Group, z as Node } from "./nodes.js";
|
|
2
|
+
|
|
3
|
+
//#region src/grid.d.ts
|
|
4
|
+
|
|
5
|
+
declare class GridError extends Error {
|
|
6
|
+
constructor(message: string);
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* A single column track. A bare number is a FIXED px width; `{ fr }` is a
|
|
10
|
+
* flexible track that shares the leftover width (after fixed tracks + gaps)
|
|
11
|
+
* proportionally — the CSS `fr` unit. The array form `columns: [1, { fr: 1 }, …]`
|
|
12
|
+
* mixes them; the scalar form `columns: 3` is sugar for three equal `{ fr: 1 }`.
|
|
13
|
+
*/
|
|
14
|
+
type GridTrack = number | {
|
|
15
|
+
fr: number;
|
|
16
|
+
};
|
|
17
|
+
interface GridProps extends NodeProps {
|
|
18
|
+
/**
|
|
19
|
+
* Column tracks. A number N is sugar for N equal `fr` columns; an array spells
|
|
20
|
+
* each track (`5` = 5px fixed, `{ fr: 2 }` = a 2-fraction flexible track).
|
|
21
|
+
*/
|
|
22
|
+
columns: number | readonly GridTrack[];
|
|
23
|
+
/** The nodes to place, row-major (child[i] → row floor(i/cols), col i%cols). */
|
|
24
|
+
children?: Node[];
|
|
25
|
+
/** Gap between columns AND rows (px). Overridden per-axis by columnGap/rowGap. */
|
|
26
|
+
gap?: number;
|
|
27
|
+
/** Horizontal gap between columns (px). Defaults to `gap`. */
|
|
28
|
+
columnGap?: number;
|
|
29
|
+
/** Vertical gap between rows (px). Defaults to `gap`. */
|
|
30
|
+
rowGap?: number;
|
|
31
|
+
/**
|
|
32
|
+
* Total content width the tracks divide (px). REQUIRED when any `fr` track is
|
|
33
|
+
* present (an `fr` needs a total to resolve against). Fixed-only track lists
|
|
34
|
+
* may omit it — the width is then the sum of the fixed tracks + gaps.
|
|
35
|
+
*/
|
|
36
|
+
width?: number;
|
|
37
|
+
/**
|
|
38
|
+
* Row pitch (px) — the center-to-center distance between rows is
|
|
39
|
+
* `cellHeight + rowGap`. REQUIRED when the children span more than one row.
|
|
40
|
+
* (v1 is position-only, so the grid does not measure child heights.)
|
|
41
|
+
*/
|
|
42
|
+
cellHeight?: number;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Lay `children` out into a column grid and return a `Group` holding them, each
|
|
46
|
+
* repositioned to its cell center. Center-anchored: the grid's own origin is the
|
|
47
|
+
* center of its content box, so child positions are symmetric about [0, 0]
|
|
48
|
+
* (matching every other glissade node's center-anchor convention).
|
|
49
|
+
*
|
|
50
|
+
* The children are NOT cloned — their `position` signal is SET to the resolved
|
|
51
|
+
* cell center (a plain `signal.set`, so a later bind still wins if the author
|
|
52
|
+
* rebinds it). Pass freshly constructed nodes for a clean, deterministic layout.
|
|
53
|
+
*/
|
|
54
|
+
declare function Grid(props: GridProps): Group;
|
|
55
|
+
//#endregion
|
|
56
|
+
export { Grid, GridError, GridProps, GridTrack };
|
package/dist/grid.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { r as Group } from "./nodes.js";
|
|
2
|
+
//#region src/grid.ts
|
|
3
|
+
var GridError = class extends Error {
|
|
4
|
+
constructor(message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "GridError";
|
|
7
|
+
}
|
|
8
|
+
};
|
|
9
|
+
/** Resolve the column spec into per-column [centerX] offsets from the grid's left edge. */
|
|
10
|
+
function resolveColumns(spec, columnGap, totalWidth) {
|
|
11
|
+
const tracks = typeof spec === "number" ? (() => {
|
|
12
|
+
if (!Number.isInteger(spec) || spec < 1) throw new GridError(`Grid columns must be a positive integer (got ${spec})`);
|
|
13
|
+
return Array.from({ length: spec }, () => ({ fr: 1 }));
|
|
14
|
+
})() : [...spec];
|
|
15
|
+
if (tracks.length === 0) throw new GridError("Grid needs at least one column");
|
|
16
|
+
const cols = tracks.length;
|
|
17
|
+
const gapTotal = columnGap * (cols - 1);
|
|
18
|
+
const fixedTotal = tracks.reduce((s, t) => s + (typeof t === "number" ? t : 0), 0);
|
|
19
|
+
const frTotal = tracks.reduce((s, t) => s + (typeof t === "number" ? 0 : t.fr), 0);
|
|
20
|
+
let widths;
|
|
21
|
+
let width;
|
|
22
|
+
if (frTotal > 0) {
|
|
23
|
+
if (totalWidth === void 0) throw new GridError("Grid with fr columns needs an explicit `width` to resolve fractions against");
|
|
24
|
+
const free = Math.max(0, totalWidth - fixedTotal - gapTotal);
|
|
25
|
+
widths = tracks.map((t) => typeof t === "number" ? t : free * t.fr / frTotal);
|
|
26
|
+
width = totalWidth;
|
|
27
|
+
} else {
|
|
28
|
+
widths = tracks.map((t) => t);
|
|
29
|
+
width = totalWidth ?? fixedTotal + gapTotal;
|
|
30
|
+
}
|
|
31
|
+
const centers = [];
|
|
32
|
+
let x = 0;
|
|
33
|
+
for (let c = 0; c < cols; c++) {
|
|
34
|
+
centers.push(x + widths[c] / 2);
|
|
35
|
+
x += widths[c] + columnGap;
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
centers,
|
|
39
|
+
width
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Lay `children` out into a column grid and return a `Group` holding them, each
|
|
44
|
+
* repositioned to its cell center. Center-anchored: the grid's own origin is the
|
|
45
|
+
* center of its content box, so child positions are symmetric about [0, 0]
|
|
46
|
+
* (matching every other glissade node's center-anchor convention).
|
|
47
|
+
*
|
|
48
|
+
* The children are NOT cloned — their `position` signal is SET to the resolved
|
|
49
|
+
* cell center (a plain `signal.set`, so a later bind still wins if the author
|
|
50
|
+
* rebinds it). Pass freshly constructed nodes for a clean, deterministic layout.
|
|
51
|
+
*/
|
|
52
|
+
function Grid(props) {
|
|
53
|
+
const { columns, gap = 0, columnGap, rowGap, width, cellHeight } = props;
|
|
54
|
+
const children = props.children ?? [];
|
|
55
|
+
const colGap = columnGap ?? gap;
|
|
56
|
+
const rowGapPx = rowGap ?? gap;
|
|
57
|
+
const { centers, width: gridWidth } = resolveColumns(columns, colGap, width);
|
|
58
|
+
const cols = centers.length;
|
|
59
|
+
const rows = Math.ceil(children.length / cols);
|
|
60
|
+
if (rows > 1 && cellHeight === void 0) throw new GridError("Grid spanning more than one row needs `cellHeight` (the row pitch) — v1 is position-only and does not measure child heights");
|
|
61
|
+
const rowPitch = (cellHeight ?? 0) + rowGapPx;
|
|
62
|
+
const gridHeight = rows > 0 ? rows * (cellHeight ?? 0) + (rows - 1) * rowGapPx : 0;
|
|
63
|
+
const ox = -gridWidth / 2;
|
|
64
|
+
const oy = -gridHeight / 2;
|
|
65
|
+
children.forEach((child, i) => {
|
|
66
|
+
const col = i % cols;
|
|
67
|
+
const row = Math.floor(i / cols);
|
|
68
|
+
const cx = ox + centers[col];
|
|
69
|
+
const cy = oy + row * rowPitch + (cellHeight ?? 0) / 2;
|
|
70
|
+
child.position.set([cx, cy]);
|
|
71
|
+
});
|
|
72
|
+
return new Group({
|
|
73
|
+
children,
|
|
74
|
+
...stripGridOnly(props)
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
/** Strip Grid's own props so the rest (id/position/opacity/…) pass to the Group. */
|
|
78
|
+
function stripGridOnly(props) {
|
|
79
|
+
const { columns, children, gap, columnGap, rowGap, width, cellHeight, ...nodeProps } = props;
|
|
80
|
+
return nodeProps;
|
|
81
|
+
}
|
|
82
|
+
//#endregion
|
|
83
|
+
export { Grid, GridError };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { n as DisplayList } from "./displayList.js";
|
|
2
|
+
import { r as Scene } from "./scene.js";
|
|
3
|
+
import { Timeline } from "@glissade/core";
|
|
4
|
+
|
|
5
|
+
//#region src/identity.d.ts
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The out-of-band identity stream: one optional stable id per DisplayList
|
|
9
|
+
* command INDEX. Positional, aligned 1:1 with `displayList.commands` — command
|
|
10
|
+
* *i* was emitted by the node whose explicit id is `ids[i]` (or `undefined` for
|
|
11
|
+
* a node without one). Because the emit walk is stable + deterministic (the
|
|
12
|
+
* §3.3 positional discipline `diffDisplayLists` relies on), the stream is stable
|
|
13
|
+
* across re-emits of an unchanged graph at a given `t`.
|
|
14
|
+
*/
|
|
15
|
+
type NodeIdStream = readonly (string | undefined)[];
|
|
16
|
+
interface EmitWithIdsResult {
|
|
17
|
+
/** Byte/deep-equal to the normal `evaluate(scene, timeline, t)` DisplayList. */
|
|
18
|
+
readonly displayList: DisplayList;
|
|
19
|
+
/** Positional id stream, `ids.length === displayList.commands.length`. */
|
|
20
|
+
readonly ids: NodeIdStream;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Opt-in instrumented emit: the SAME pure evaluation as `evaluate(scene, doc, t)`,
|
|
24
|
+
* but additionally producing the out-of-band `NodeIdStream`. Returns
|
|
25
|
+
* `{ displayList, ids }`. The `displayList` is byte/deep-equal to `evaluate()`'s
|
|
26
|
+
* (the producer never alters geometry); `ids` is positional by command index.
|
|
27
|
+
*
|
|
28
|
+
* Off the hot path: this is a tree-shakeable subpath; the normal evaluate/render
|
|
29
|
+
* never touches it, so canvas/export pay nothing.
|
|
30
|
+
*/
|
|
31
|
+
declare function emitWithIds(scene: Scene, doc: Timeline, t: number): EmitWithIdsResult;
|
|
32
|
+
//#endregion
|
|
33
|
+
export { EmitWithIdsResult, NodeIdStream, emitWithIds };
|
package/dist/identity.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { L as createDisplayListBuilder } from "./nodes.js";
|
|
2
|
+
import { r as bindScene } from "./scene.js";
|
|
3
|
+
import { evaluateAt } from "@glissade/core";
|
|
4
|
+
//#region src/identity.ts
|
|
5
|
+
/**
|
|
6
|
+
* Wrap a real `DisplayListBuilder` so it records the emitting node's id per
|
|
7
|
+
* command index. It delegates every command/resource to the inner builder
|
|
8
|
+
* UNCHANGED (so the produced DisplayList is byte-identical), and adds only the
|
|
9
|
+
* `enterNode`/`exitNode` seam + an `ids` side array. The inner builder's
|
|
10
|
+
* §3.5 cacheKey methods (mark/cacheKey/patchCacheKey) are forwarded so a
|
|
11
|
+
* `cache:true` node behaves exactly as on the normal path.
|
|
12
|
+
*/
|
|
13
|
+
function instrument(inner) {
|
|
14
|
+
const ids = [];
|
|
15
|
+
const stack = [];
|
|
16
|
+
const builder = {
|
|
17
|
+
push(cmd) {
|
|
18
|
+
ids.push(stack.length > 0 ? stack[stack.length - 1] : void 0);
|
|
19
|
+
inner.push(cmd);
|
|
20
|
+
},
|
|
21
|
+
resource(res) {
|
|
22
|
+
return inner.resource(res);
|
|
23
|
+
},
|
|
24
|
+
enterNode(id) {
|
|
25
|
+
stack.push(id);
|
|
26
|
+
},
|
|
27
|
+
exitNode() {
|
|
28
|
+
stack.pop();
|
|
29
|
+
},
|
|
30
|
+
finish() {
|
|
31
|
+
return inner.finish();
|
|
32
|
+
},
|
|
33
|
+
ids
|
|
34
|
+
};
|
|
35
|
+
if (inner.mark) builder.mark = () => inner.mark();
|
|
36
|
+
if (inner.cacheKey) builder.cacheKey = (s, e) => inner.cacheKey(s, e);
|
|
37
|
+
if (inner.patchCacheKey) builder.patchCacheKey = (i, k) => inner.patchCacheKey(i, k);
|
|
38
|
+
return builder;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Opt-in instrumented emit: the SAME pure evaluation as `evaluate(scene, doc, t)`,
|
|
42
|
+
* but additionally producing the out-of-band `NodeIdStream`. Returns
|
|
43
|
+
* `{ displayList, ids }`. The `displayList` is byte/deep-equal to `evaluate()`'s
|
|
44
|
+
* (the producer never alters geometry); `ids` is positional by command index.
|
|
45
|
+
*
|
|
46
|
+
* Off the hot path: this is a tree-shakeable subpath; the normal evaluate/render
|
|
47
|
+
* never touches it, so canvas/export pay nothing.
|
|
48
|
+
*/
|
|
49
|
+
function emitWithIds(scene, doc, t) {
|
|
50
|
+
bindScene(scene, doc);
|
|
51
|
+
const fps = doc.fps;
|
|
52
|
+
const ctx = {
|
|
53
|
+
time: t,
|
|
54
|
+
frame: fps !== void 0 ? Math.round(t * fps) : -1,
|
|
55
|
+
measurer: scene.textMeasurer
|
|
56
|
+
};
|
|
57
|
+
return evaluateAt(scene.playhead, t, () => {
|
|
58
|
+
const builder = instrument(createDisplayListBuilder(scene.size));
|
|
59
|
+
scene.root.emit(builder, ctx);
|
|
60
|
+
return {
|
|
61
|
+
displayList: builder.finish(),
|
|
62
|
+
ids: builder.ids
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
//#endregion
|
|
67
|
+
export { emitWithIds };
|