@glissade/scene 0.17.0 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,2020 +1,3 @@
1
- import { TARGET_PATH, computed, emitDevWarning, random, signal, vec2Signal } from "@glissade/core";
2
- //#region src/matrix.ts
3
- const IDENTITY = [
4
- 1,
5
- 0,
6
- 0,
7
- 1,
8
- 0,
9
- 0
10
- ];
11
- const z = (v) => v === 0 ? 0 : v;
12
- function multiply(m1, m2) {
13
- const [a1, b1, c1, d1, e1, f1] = m1;
14
- const [a2, b2, c2, d2, e2, f2] = m2;
15
- return [
16
- z(a1 * a2 + c1 * b2),
17
- z(b1 * a2 + d1 * b2),
18
- z(a1 * c2 + c1 * d2),
19
- z(b1 * c2 + d1 * d2),
20
- z(a1 * e2 + c1 * f2 + e1),
21
- z(b1 * e2 + d1 * f2 + f1)
22
- ];
23
- }
24
- /** Compose translate × rotate × scale (rotation in degrees). */
25
- function fromTRS(position, rotationDeg, scale) {
26
- const r = rotationDeg * Math.PI / 180;
27
- const cos = Math.cos(r);
28
- const sin = Math.sin(r);
29
- const [sx, sy] = scale;
30
- return [
31
- z(cos * sx),
32
- z(sin * sx),
33
- z(-sin * sy),
34
- z(cos * sy),
35
- z(position[0]),
36
- z(position[1])
37
- ];
38
- }
39
- /** Inverse affine: [A | t]⁻¹ = [A⁻¹ | −A⁻¹t]; null when degenerate (det 0). */
40
- function invert(m) {
41
- const [a, b, c, d, e, f] = m;
42
- const det = a * d - b * c;
43
- if (det === 0) return null;
44
- const ia = d / det;
45
- const ib = -b / det;
46
- const ic = -c / det;
47
- const id = a / det;
48
- return [
49
- z(ia),
50
- z(ib),
51
- z(ic),
52
- z(id),
53
- z(-(ia * e + ic * f)),
54
- z(-(ib * e + id * f))
55
- ];
56
- }
57
- function applyToPoint(m, p) {
58
- return [m[0] * p[0] + m[2] * p[1] + m[4], m[1] * p[0] + m[3] * p[1] + m[5]];
59
- }
60
- function matEquals(a, b) {
61
- return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3] && a[4] === b[4] && a[5] === b[5];
62
- }
63
- //#endregion
64
- //#region src/displayDiff.ts
65
- /**
66
- * The collapse-replacer shared by the cacheKey serializer
67
- * (`createDisplayListBuilder().cacheKey`), `cacheColdAudit.hashDisplayList`,
68
- * and `serializeDisplayList` here. CRITICAL: this is BYTE-PRESERVING — the
69
- * cacheKey it backs stamps into pushGroup and keys the §3.5 raster cache, so
70
- * its output must not move. The three call sites previously DUPLICATED this
71
- * byte-for-byte; it lives here once.
72
- *
73
- * - ArrayBuffer / typed-array views collapse to a `ab:<len>` / `view:<len>`
74
- * length marker (opaque binary never belongs in a structural key).
75
- * - Functions drop (JSON would already drop them; explicit for parity).
76
- * - Non-finite numbers (`NaN`/`Infinity`/`-Infinity`) collapse to DISTINCT
77
- * string sentinels. `JSON.stringify` natively serializes all three to the
78
- * SAME token (`null`), which would collide the cacheKey of two DisplayLists
79
- * that differ only in WHICH non-finite value reaches a draw field — a stale
80
- * raster + an `auditCacheCold` false-OK. The distinct sentinels keep them
81
- * apart. This does NOT touch FINITE numbers (the common path): only the
82
- * three non-finite inputs change, so the §3.5 cacheKey bytes for every real
83
- * (finite) list are byte-identical — pinned by the regression guard.
84
- *
85
- * NOTE: `-0` is intentionally NOT normalized here. The matrix layer
86
- * (`matrix.ts`) already normalizes `-0 → 0` at the source, and adding a `-0`
87
- * pass to THIS replacer would change the cacheKey bytes for any list that ever
88
- * carried a raw `-0` — silently invalidating the cache cluster-wide. (`-0` is
89
- * finite, so the non-finite branch never touches it.) Byte preservation wins;
90
- * the regression guard pins the exact key.
91
- */
92
- function collapseReplacer(_key, value) {
93
- if (value instanceof ArrayBuffer) return `ab:${value.byteLength}`;
94
- if (ArrayBuffer.isView(value)) return `view:${value.byteLength}`;
95
- if (typeof value === "function") return void 0;
96
- if (typeof value === "number" && !Number.isFinite(value)) {
97
- if (Number.isNaN(value)) return "NaN";
98
- return value > 0 ? "Infinity" : "-Infinity";
99
- }
100
- return value;
101
- }
102
- /** A flat, stable JSON value for one command with its resource ids INLINED to content. */
103
- function commandView(cmd, resources) {
104
- const out = {};
105
- for (const [k, v] of Object.entries(cmd)) out[k] = v;
106
- if (cmd.op === "clip" || cmd.op === "fillPath" || cmd.op === "strokePath") out["path"] = resources[cmd.path];
107
- else if (cmd.op === "drawImage") out["image"] = resources[cmd.image];
108
- return out;
109
- }
110
- const stable = (v) => JSON.stringify(v, collapseReplacer);
111
- /** Field-level diff of two same-op command views (shallow over top-level props). */
112
- function diffFields(a, b) {
113
- const changes = [];
114
- const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
115
- for (const k of keys) {
116
- if (k === "op") continue;
117
- if (stable(a[k]) !== stable(b[k])) changes.push({
118
- path: k,
119
- from: a[k],
120
- to: b[k]
121
- });
122
- }
123
- return changes;
124
- }
125
- /**
126
- * Index-aligned positional diff of two DisplayLists. Command i in `a` is
127
- * compared to command i in `b`; trailing commands become `add`/`remove`. A
128
- * single insert/remove cascades — this is the documented v1 cliff.
129
- */
130
- function diffDisplayLists(a, b) {
131
- const deltas = [];
132
- const n = Math.max(a.commands.length, b.commands.length);
133
- for (let i = 0; i < n; i++) {
134
- const ca = a.commands[i];
135
- const cb = b.commands[i];
136
- if (ca !== void 0 && cb !== void 0) {
137
- const va = commandView(ca, a.resources);
138
- const vb = commandView(cb, b.resources);
139
- if (stable(va) === stable(vb)) continue;
140
- if (ca.op !== cb.op) deltas.push({
141
- index: i,
142
- kind: "change",
143
- opA: ca.op,
144
- opB: cb.op,
145
- fields: [{
146
- path: "op",
147
- from: ca.op,
148
- to: cb.op
149
- }]
150
- });
151
- else deltas.push({
152
- index: i,
153
- kind: "change",
154
- opA: ca.op,
155
- opB: cb.op,
156
- fields: diffFields(va, vb)
157
- });
158
- } else if (cb !== void 0) deltas.push({
159
- index: i,
160
- kind: "add",
161
- opB: cb.op,
162
- fields: []
163
- });
164
- else if (ca !== void 0) deltas.push({
165
- index: i,
166
- kind: "remove",
167
- opA: ca.op,
168
- fields: []
169
- });
170
- }
171
- const sizeDiffers = a.size.w !== b.size.w || a.size.h !== b.size.h;
172
- return {
173
- equal: deltas.length === 0 && !sizeDiffers,
174
- deltas,
175
- ...sizeDiffers ? { size: {
176
- from: a.size,
177
- to: b.size
178
- } } : {}
179
- };
180
- }
181
- /** Human-readable, multi-line rendering of a DisplayDiff (CLI command-tree). */
182
- function formatDisplayDiff(diff) {
183
- if (diff.equal) return "DisplayLists are identical.";
184
- const lines = [];
185
- if (diff.size) lines.push(`size ${diff.size.from.w}x${diff.size.from.h} -> ${diff.size.to.w}x${diff.size.to.h}`);
186
- for (const d of diff.deltas) if (d.kind === "add") lines.push(`+ [${d.index}] add ${d.opB ?? "?"}`);
187
- else if (d.kind === "remove") lines.push(`- [${d.index}] remove ${d.opA ?? "?"}`);
188
- else {
189
- const op = d.opA === d.opB ? d.opA : `${d.opA ?? "?"} -> ${d.opB ?? "?"}`;
190
- lines.push(`~ [${d.index}] ${op}`);
191
- for (const f of d.fields) lines.push(` ${f.path}: ${stable(f.from)} -> ${stable(f.to)}`);
192
- }
193
- return lines.join("\n");
194
- }
195
- /**
196
- * The `.dl.json` snapshot interchange schema (DESIGN §7.4). Users commit
197
- * `.dl.json` baselines, so this carries the SAME break-policy obligation as
198
- * `Timeline.version` and `SidecarDoc.sidecarVersion`: bump on a breaking shape
199
- * change. Independent of the API version.
200
- */
201
- const DL_SNAPSHOT_VERSION = 1;
202
- /**
203
- * Serialize a DisplayList to a stable `.dl.json` document string (reusing the
204
- * byte-preserving collapse-replacer). Round-trips through `parseDisplaySnapshot`.
205
- */
206
- function serializeDisplayList(dl) {
207
- const snapshot = {
208
- dlSnapshotVersion: 1,
209
- size: dl.size,
210
- commands: dl.commands,
211
- resources: dl.resources
212
- };
213
- return JSON.stringify(snapshot, collapseReplacer, 2);
214
- }
215
- var DlSnapshotError = class extends Error {
216
- constructor(message) {
217
- super(message);
218
- this.name = "DlSnapshotError";
219
- }
220
- };
221
- /** Parse a `.dl.json` snapshot back into a DisplayList (validates the version). */
222
- function parseDisplaySnapshot(json) {
223
- const doc = JSON.parse(json);
224
- if (doc.dlSnapshotVersion !== 1) throw new DlSnapshotError(`unsupported dlSnapshotVersion ${String(doc.dlSnapshotVersion)}; this build reads 1`);
225
- if (!doc.size || !Array.isArray(doc.commands) || !Array.isArray(doc.resources)) throw new DlSnapshotError("malformed .dl.json snapshot (need size, commands[], resources[])");
226
- return {
227
- size: doc.size,
228
- commands: doc.commands,
229
- resources: doc.resources
230
- };
231
- }
232
- //#endregion
233
- //#region src/displayList.ts
234
- var FilterValidationError = class extends Error {
235
- constructor(message) {
236
- super(message);
237
- this.name = "FilterValidationError";
238
- }
239
- };
240
- const FILTER_KINDS = new Set([
241
- "blur",
242
- "drop-shadow",
243
- "brightness",
244
- "contrast",
245
- "saturate"
246
- ]);
247
- /** Document-layer validation: reject unknown kinds and out-of-range params loudly. */
248
- function validateFilters(filters) {
249
- for (const f of filters) {
250
- if (!FILTER_KINDS.has(f.kind)) throw new FilterValidationError(`unknown filter kind '${String(f.kind)}' (have: ${[...FILTER_KINDS].join(", ")})`);
251
- if (f.kind === "blur" && !(Number.isFinite(f.radius) && f.radius >= 0)) throw new FilterValidationError(`blur radius must be ≥ 0, got ${String(f.radius)}`);
252
- if (f.kind === "drop-shadow") {
253
- if (![
254
- f.dx,
255
- f.dy,
256
- f.blur
257
- ].every(Number.isFinite) || f.blur < 0) throw new FilterValidationError("drop-shadow needs finite dx/dy and blur ≥ 0");
258
- if (typeof f.color !== "string" || f.color.length === 0) throw new FilterValidationError("drop-shadow needs a color string");
259
- }
260
- if ((f.kind === "brightness" || f.kind === "contrast" || f.kind === "saturate") && !(Number.isFinite(f.amount) && f.amount >= 0)) throw new FilterValidationError(`${f.kind} amount must be ≥ 0, got ${String(f.amount)}`);
261
- }
262
- }
263
- /**
264
- * Compile the validated union to the canvas 2D `ctx.filter` syntax — both
265
- * backends speak it (browser canvas and @napi-rs/canvas/Skia). This is the
266
- * ONLY place the CSS-like syntax appears; documents never carry it.
267
- */
268
- function filtersToCanvasFilter(filters) {
269
- if (filters.length === 0) return "none";
270
- return filters.map((f) => {
271
- switch (f.kind) {
272
- case "blur": return `blur(${f.radius}px)`;
273
- case "drop-shadow": return `drop-shadow(${f.dx}px ${f.dy}px ${f.blur}px ${f.color})`;
274
- default: return `${f.kind}(${f.amount})`;
275
- }
276
- }).join(" ");
277
- }
278
- /**
279
- * Outer glow as stacked zero-offset drop-shadows — the classic recipe, fully
280
- * deterministic on both backends (it is just filters). intensity stacks more
281
- * layers; pair with a signal binding to follow an animated fill.
282
- */
283
- function glow(color, radius = 16, intensity = 2) {
284
- const layers = [];
285
- for (let i = 0; i < Math.max(1, intensity); i++) layers.push({
286
- kind: "drop-shadow",
287
- dx: 0,
288
- dy: 0,
289
- blur: radius * (1 + i * 1.5),
290
- color
291
- });
292
- return layers;
293
- }
294
- /**
295
- * Resource ids that appear in a draw command — only path/image/video draws
296
- * carry one. Keeps the cacheKey serializer in lockstep with DrawCommand.
297
- */
298
- function commandResourceIds(cmd) {
299
- switch (cmd.op) {
300
- case "clip":
301
- case "fillPath":
302
- case "strokePath": return [cmd.path];
303
- case "drawImage": return [cmd.image];
304
- default: return [];
305
- }
306
- }
307
- /** FNV-1a over a string → an 8-hex-digit stable token (no crypto dep, ESM-safe). */
308
- function fnv1a(s) {
309
- let h = 2166136261;
310
- for (let i = 0; i < s.length; i++) {
311
- h ^= s.charCodeAt(i);
312
- h = Math.imul(h, 16777619);
313
- }
314
- return (h >>> 0).toString(16).padStart(8, "0");
315
- }
316
- function createDisplayListBuilder(size) {
317
- const commands = [];
318
- const resources = [];
319
- const interned = /* @__PURE__ */ new Map();
320
- return {
321
- push: (cmd) => {
322
- if (cmd.op === "pushGroup" && cmd.filters.length > 0) validateFilters(cmd.filters);
323
- commands.push(cmd);
324
- },
325
- resource: (res) => {
326
- const k = JSON.stringify(res);
327
- const hit = interned.get(k);
328
- if (hit !== void 0) return hit;
329
- const id = resources.length;
330
- resources.push(res);
331
- interned.set(k, id);
332
- return id;
333
- },
334
- mark: () => commands.length,
335
- cacheKey: (start, end) => {
336
- if (end <= start) return void 0;
337
- const localIds = /* @__PURE__ */ new Map();
338
- const usedResources = [];
339
- const remap = (id) => {
340
- let local = localIds.get(id);
341
- if (local === void 0) {
342
- local = localIds.size;
343
- localIds.set(id, local);
344
- usedResources.push(resources[id]);
345
- }
346
- return local;
347
- };
348
- const sliceCmds = commands.slice(start, end).map((cmd) => {
349
- if (commandResourceIds(cmd).length === 0) return cmd;
350
- if (cmd.op === "drawImage") return {
351
- ...cmd,
352
- image: remap(cmd.image)
353
- };
354
- return {
355
- ...cmd,
356
- path: remap(cmd.path)
357
- };
358
- });
359
- return fnv1a(JSON.stringify({
360
- c: sliceCmds,
361
- r: usedResources
362
- }, collapseReplacer));
363
- },
364
- patchCacheKey: (i, key) => {
365
- const cmd = commands[i];
366
- if (cmd && cmd.op === "pushGroup") cmd.cacheKey = key;
367
- },
368
- finish: () => ({
369
- commands,
370
- resources,
371
- size
372
- })
373
- };
374
- }
375
- //#endregion
376
- //#region src/text.ts
377
- /**
378
- * §3.6 measurement quantum (px). Scene-owned pre-measure quantizes every
379
- * layout-feeding advance to this grid ONCE, then hands Yoga frozen integers —
380
- * so sub-pixel measureText drift between Skia/HarfBuzz versions cannot move a
381
- * whole layout. The single source of truth for the grid; `quantize` rounds to
382
- * it. (Yoga's `setMeasureFunc` was considered and rejected — see DESIGN.md §3.6.)
383
- */
384
- const MEASURE_QUANTUM_PX = .5;
385
- /** §3.6 measurement quantum — round to the MEASURE_QUANTUM_PX grid. */
386
- function quantize(v) {
387
- return Math.round(v / MEASURE_QUANTUM_PX) * MEASURE_QUANTUM_PX;
388
- }
389
- /**
390
- * Estimating fallback measurer — used only when no backend has been injected
391
- * (e.g. evaluating for IR-level tests). Deterministic but not metrically
392
- * faithful; mount(), the CLI, and exporters always inject the real one.
393
- */
394
- let defaultMeasurer = null;
395
- /**
396
- * Process-wide fallback measurer for FACTORY-TIME measurement — component
397
- * factories run before any scene exists, so Text pulls (measuredSize,
398
- * lineBoxes, wordBoxes) and createScene fall back here before the estimator.
399
- * Node consumers: `setDefaultMeasurer(createMeasurer({ fonts }))` from
400
- * @glissade/backend-skia gives factory code the rasterizer's real metrics.
401
- * Scene-injected measurers (mount/CLI/golden harness) always win.
402
- */
403
- function setDefaultMeasurer(m) {
404
- defaultMeasurer = m;
405
- }
406
- /** The default-or-estimating chain end; internal fallback for measurer pulls. */
407
- function fallbackMeasurer() {
408
- return defaultMeasurer ?? estimatingMeasurer;
409
- }
410
- const estimatingMeasurer = { measureText(text, font) {
411
- return {
412
- width: text.length * font.size * .52,
413
- ascent: font.size * .8,
414
- descent: font.size * .2
415
- };
416
- } };
417
- let wordSegmenter;
418
- /**
419
- * The draw-path word segmentation (Intl.Segmenter boundaries, punctuation
420
- * glued to its predecessor) — exported so Text.wordBoxes() boxes EXACTLY the
421
- * units the breaker flows.
422
- */
423
- function segmentWords(text) {
424
- if (wordSegmenter === void 0) wordSegmenter = typeof Intl !== "undefined" && "Segmenter" in Intl ? new Intl.Segmenter(void 0, { granularity: "word" }) : null;
425
- if (wordSegmenter) {
426
- const raw = [...wordSegmenter.segment(text)].map((s) => s.segment);
427
- const glued = [];
428
- for (const seg of raw) if (glued.length > 0 && /^[^\p{L}\p{N}\s]+$/u.test(seg)) glued[glued.length - 1] += seg;
429
- else glued.push(seg);
430
- return glued;
431
- }
432
- return text.split(/(\s+)/).filter((w) => w.length > 0);
433
- }
434
- let graphemeSegmenter;
435
- /**
436
- * Split text into graphemes (user-perceived characters). Exported so Text.draw
437
- * (reveal masking), Text.graphemes() (authoring), and revealSchedule() (the SFX
438
- * keystroke contract) all count the SAME units.
439
- */
440
- function segmentGraphemes(text) {
441
- if (graphemeSegmenter === void 0) graphemeSegmenter = typeof Intl !== "undefined" && "Segmenter" in Intl ? new Intl.Segmenter(void 0, { granularity: "grapheme" }) : null;
442
- if (graphemeSegmenter) return [...graphemeSegmenter.segment(text)].map((s) => s.segment);
443
- return Array.from(text);
444
- }
445
- /**
446
- * Greedy line breaking: explicit '\n' always breaks; otherwise word segments
447
- * flow until maxWidth is exceeded (Intl.Segmenter boundaries, so CJK wraps
448
- * without spaces). A segment wider than maxWidth gets its own line (no
449
- * intra-word breaking in v1).
450
- */
451
- function breakLines(text, font, maxWidth, measurer) {
452
- const paragraphs = text.split("\n");
453
- if (maxWidth === void 0 || maxWidth <= 0) return paragraphs;
454
- const lines = [];
455
- for (const para of paragraphs) {
456
- const words = segmentWords(para);
457
- let line = "";
458
- for (const word of words) {
459
- const candidate = line + word;
460
- if (line !== "" && quantize(measurer.measureText(candidate.trimEnd(), font).width) > maxWidth) {
461
- lines.push(line.trimEnd());
462
- line = word.trimStart() === "" ? "" : word;
463
- } else line = candidate;
464
- }
465
- lines.push(line.trimEnd());
466
- }
467
- return lines;
468
- }
469
- //#endregion
470
- //#region src/node.ts
471
- /**
472
- * Scene-graph node base (DESIGN.md §3.1): every animatable property is a
473
- * signal; transforms are computed matrix signals; emit() is pure — it reads
474
- * signals and ctx only, and produces IR commands, never canvas calls.
475
- */
476
- const ANCHOR_PRESETS = {
477
- "center": [.5, .5],
478
- "top-left": [0, 0],
479
- "top": [.5, 0],
480
- "top-right": [1, 0],
481
- "left": [0, .5],
482
- "right": [1, .5],
483
- "bottom-left": [0, 1],
484
- "bottom": [.5, 1],
485
- "bottom-right": [1, 1]
486
- };
487
- function resolveAnchor(spec) {
488
- if (typeof spec === "string") {
489
- const preset = ANCHOR_PRESETS[spec];
490
- if (!preset) throw new Error(`unknown anchor '${spec}' (use a preset like 'top-left' or a [ax, ay] pair)`);
491
- return preset;
492
- }
493
- return [spec[0], spec[1]];
494
- }
495
- function initScalar(sig, init) {
496
- if (typeof init === "function") sig.bindSource(init);
497
- else if (init !== void 0) sig.set(init);
498
- return sig;
499
- }
500
- function initVec2(sig, init) {
501
- if (typeof init === "function") sig.bindSource(init);
502
- else if (init !== void 0) sig.set(init);
503
- return sig;
504
- }
505
- var Node = class {
506
- id;
507
- position;
508
- rotation;
509
- scale;
510
- opacity;
511
- blend;
512
- zIndex;
513
- filters;
514
- /** Resolved anchor fraction over the intrinsic box; [0.5, 0.5] = center. */
515
- anchor;
516
- /** True only when the author SET an anchor — unset keeps the legacy origin. */
517
- hasAnchor;
518
- /** §3.5: opt-in cross-frame raster cache. Forces a group + a stamped cacheKey. */
519
- cache;
520
- parent = null;
521
- #warnedAnchor = false;
522
- /** v2 §C.3: participates in hit testing; set implicitly by attaching a listener. */
523
- interactive = false;
524
- /** v2 §C.3: false prunes this subtree from hit testing (PixiJS's flag). */
525
- interactiveChildren = true;
526
- /** v2 §C.3: explicit hit-shape override in node-local coordinates. */
527
- hitArea;
528
- /**
529
- * Injected by createScene: the scene's CURRENT TextMeasurer (§3.2), so
530
- * derived-size bindings (e.g. a background tracking Layout.computedSize)
531
- * measure with the same rasterizer the flow uses.
532
- */
533
- measurerSource = null;
534
- localMatrix;
535
- worldMatrix;
536
- /** Track-target paths → bindable signals; subclasses register their own props. */
537
- targets = /* @__PURE__ */ new Map();
538
- constructor(props = {}) {
539
- this.id = props.id;
540
- this.position = initVec2(vec2Signal([0, 0]), props.position);
541
- this.rotation = initScalar(signal(0), props.rotation);
542
- this.scale = initVec2(vec2Signal([1, 1]), props.scale);
543
- this.opacity = initScalar(signal(1), props.opacity);
544
- this.blend = initScalar(signal("source-over"), props.blend);
545
- this.zIndex = initScalar(signal(0), props.zIndex);
546
- this.filters = initScalar(signal([]), props.filters);
547
- this.hasAnchor = props.anchor !== void 0;
548
- this.anchor = resolveAnchor(props.anchor ?? "center");
549
- this.cache = props.cache === true;
550
- this.localMatrix = computed(() => {
551
- const trs = fromTRS(this.position(), this.rotation(), this.scale());
552
- const [sx, sy] = this.anchorShift();
553
- return sx === 0 && sy === 0 ? trs : multiply(trs, [
554
- 1,
555
- 0,
556
- 0,
557
- 1,
558
- sx,
559
- sy
560
- ]);
561
- }, { equals: matEquals });
562
- this.worldMatrix = computed(() => this.parent ? multiply(this.parent.worldMatrix(), this.localMatrix()) : this.localMatrix(), { equals: matEquals });
563
- this.registerTarget("position", this.position, "vec2");
564
- this.registerTarget("position.x", this.position.x, "number");
565
- this.registerTarget("position.y", this.position.y, "number");
566
- this.registerTarget("rotation", this.rotation, "number");
567
- this.registerTarget("scale", this.scale, "vec2");
568
- this.registerTarget("scale.x", this.scale.x, "number");
569
- this.registerTarget("scale.y", this.scale.y, "number");
570
- this.registerTarget("opacity", this.opacity, "number");
571
- this.registerTarget("zIndex", this.zIndex, "number");
572
- }
573
- /**
574
- * Register a track-target path → bindable signal, stamping the value type the
575
- * signal accepts (§2.2). The stamp is what bindTimeline's bind-time guard
576
- * reads to reject a mismatched track (a scalar on a vec2, a number on a paint
577
- * prop, …) instead of silently sampling to NaN/undefined.
578
- *
579
- * `expects` is OPTIONAL: omitting it (the 2-arg form) leaves the target
580
- * UNtagged — bindTimeline then skips the type guard for it, which is the
581
- * back-compat seam for external `Custom`/`Node` subclasses (DESIGN.md §329)
582
- * and prebuilt 0.13 nodes that called the 2-arg form (0.13 had no guard). A
583
- * built-in node opts INTO the guard by tagging.
584
- */
585
- registerTarget(path, sig, expects) {
586
- sig.expects = expects;
587
- this.targets.set(path, sig);
588
- if (this.id !== void 0) sig[TARGET_PATH] = `${this.id}/${path}`;
589
- }
590
- resolveTarget(path) {
591
- return this.targets.get(path);
592
- }
593
- /**
594
- * Natural size for flex flow (§3.2); null = not flowable (a Layout parent
595
- * emits such children absolutely, untouched).
596
- */
597
- intrinsicSize(measurer) {
598
- return null;
599
- }
600
- /**
601
- * Vector from the DRAW origin to the intrinsic box's top-left, in the
602
- * geometry space draw() emits into (anchor-independent — the anchor shift
603
- * lives in localMatrix). Hit testing boxes nodes with this. Default:
604
- * center-anchored geometry (every shape). Text overrides — it draws from a
605
- * left/center/right baseline origin; Path from author-positioned bounds.
606
- */
607
- drawOffset(measurer) {
608
- const m = measurer ?? this.measurerSource?.() ?? fallbackMeasurer();
609
- const size = this.intrinsicSize(m) ?? {
610
- w: 0,
611
- h: 0
612
- };
613
- return {
614
- x: -size.w / 2,
615
- y: -size.h / 2
616
- };
617
- }
618
- /**
619
- * Vector from the node ORIGIN (the point `position` places) to the box's
620
- * top-left, so Layout can place any node. With an anchor this is exactly
621
- * (−ax·w, −ay·h); the center default reproduces (−w/2, −h/2).
622
- */
623
- flowOffset(measurer) {
624
- const m = measurer ?? this.measurerSource?.() ?? fallbackMeasurer();
625
- const d = this.drawOffset(m);
626
- const [sx, sy] = this.anchorShift(m);
627
- return {
628
- x: d.x + sx,
629
- y: d.y + sy
630
- };
631
- }
632
- /**
633
- * Translation composed after TRS in localMatrix: moves the drawn box so the
634
- * anchor point lands on the origin. shift = −(drawOffset + anchor·size).
635
- * No anchor set → zero shift, the legacy origin (shape center / Text
636
- * baseline / Path author coords) — every pre-anchor scene is byte-stable.
637
- * An EXPLICIT anchor pins position to that fraction of the box, even
638
- * 'center' (which differs from the legacy origin only for Text and Path).
639
- * Nodes without an intrinsic box (Group) warn once and ignore it.
640
- */
641
- anchorShift(measurer) {
642
- if (!this.hasAnchor) return [0, 0];
643
- const [ax, ay] = this.anchor;
644
- const m = measurer ?? this.measurerSource?.() ?? fallbackMeasurer();
645
- const size = this.intrinsicSize(m);
646
- if (!size) {
647
- if (!this.#warnedAnchor) {
648
- this.#warnedAnchor = true;
649
- emitDevWarning(`anchor set on a node without an intrinsic box${this.id ? ` ('${this.id}')` : ""} — ignored (give it a sized node, or position children explicitly)`);
650
- }
651
- return [0, 0];
652
- }
653
- const d = this.drawOffset(m);
654
- const sx = -(d.x + ax * size.w);
655
- const sy = -(d.y + ay * size.h);
656
- return [sx === 0 ? 0 : sx, sy === 0 ? 0 : sy];
657
- }
658
- /** §3.5 predicate: composite-as-a-unit when opacity/blend/filters demand it. */
659
- requiresGroup() {
660
- return this.opacity() < 1 || this.blend() !== "source-over" || this.filters().length > 0;
661
- }
662
- /** §3.7: a subtree-level shader pass; ShaderEffect overrides. */
663
- groupShader() {}
664
- emit(out, ctx) {
665
- const opacity = this.opacity();
666
- if (opacity <= 0) return;
667
- const local = this.localMatrix();
668
- const isIdentity = matEquals(local, IDENTITY);
669
- const shader = this.groupShader();
670
- const group = this.requiresGroup() || shader !== void 0 || this.cache;
671
- const wantsKey = this.cache && out.mark !== void 0;
672
- out.push({ op: "save" });
673
- if (!isIdentity) out.push({
674
- op: "transform",
675
- m: local
676
- });
677
- let pushIdx = -1;
678
- if (group) {
679
- if (wantsKey) pushIdx = out.mark();
680
- out.push({
681
- op: "pushGroup",
682
- opacity,
683
- blend: this.blend(),
684
- filters: this.filters(),
685
- ...shader !== void 0 ? { shader } : {}
686
- });
687
- }
688
- const drawStart = wantsKey ? out.mark() : -1;
689
- this.draw(out, ctx);
690
- if (group) {
691
- if (wantsKey) {
692
- const key = out.cacheKey(drawStart, out.mark());
693
- if (key !== void 0) out.patchCacheKey(pushIdx, key);
694
- }
695
- out.push({ op: "popGroup" });
696
- }
697
- out.push({ op: "restore" });
698
- }
699
- };
700
- //#endregion
701
- //#region src/sketch.ts
702
- /**
703
- * Hand-drawn stroke styles via GEOMETRIC roughening — not raster textures. A
704
- * shape's outline is flattened to polylines, then each segment is redrawn as a
705
- * slightly jittered, bowed stroke, overlaid in a few passes. Because it's pure
706
- * path math seeded by a stable per-shape seed, the result is byte-identical on
707
- * both backends and re-evaluates deterministically (the seed is consumed fresh
708
- * each draw, never as a shared stateful stream).
709
- */
710
- const KINDS = [
711
- "marker",
712
- "crayon",
713
- "pencil",
714
- "ink",
715
- "chalk"
716
- ];
717
- var SketchValidationError = class extends Error {
718
- constructor(message) {
719
- super(message);
720
- this.name = "SketchValidationError";
721
- }
722
- };
723
- /** Reject unknown kinds / out-of-range params at construction (like validateFilters). */
724
- function validateSketch(s) {
725
- if (!KINDS.includes(s.kind)) throw new SketchValidationError(`unknown sketch kind '${String(s.kind)}' (have: ${KINDS.join(", ")})`);
726
- if (s.width !== void 0 && !(s.width > 0)) throw new SketchValidationError(`sketch width must be > 0, got ${String(s.width)}`);
727
- if (s.roughness !== void 0 && !(s.roughness >= 0)) throw new SketchValidationError(`sketch roughness must be ≥ 0, got ${String(s.roughness)}`);
728
- if ((s.kind === "crayon" || s.kind === "pencil") && s.passes !== void 0 && !(s.passes >= 1)) throw new SketchValidationError(`sketch passes must be ≥ 1, got ${String(s.passes)}`);
729
- if (s.kind === "chalk" && s.dash !== void 0 && (!Array.isArray(s.dash) || s.dash.some((d) => !(d >= 0)))) throw new SketchValidationError("sketch chalk dash must be an array of non-negative numbers");
730
- }
731
- /** Per-kind defaults — the character of each look. */
732
- function resolveSketch(s) {
733
- switch (s.kind) {
734
- case "marker": return {
735
- width: s.width ?? 8,
736
- roughness: s.roughness ?? 1.2,
737
- passes: 2
738
- };
739
- case "crayon": return {
740
- width: s.width ?? 4,
741
- roughness: s.roughness ?? 2.4,
742
- passes: s.passes ?? 3
743
- };
744
- case "pencil": return {
745
- width: s.width ?? 1.5,
746
- roughness: s.roughness ?? 1,
747
- passes: s.passes ?? 2
748
- };
749
- case "ink": return {
750
- width: s.width ?? 2.5,
751
- roughness: s.roughness ?? .8,
752
- passes: 1
753
- };
754
- case "chalk": return {
755
- width: s.width ?? 3,
756
- roughness: s.roughness ?? 1.6,
757
- passes: 1,
758
- dash: s.dash ?? [6, 5]
759
- };
760
- }
761
- }
762
- const cubic = (p0, c1, c2, p1, t) => {
763
- const mt = 1 - t;
764
- const a = mt * mt * mt;
765
- const b = 3 * mt * mt * t;
766
- const c = 3 * mt * t * t;
767
- const d = t * t * t;
768
- return [a * p0[0] + b * c1[0] + c * c2[0] + d * p1[0], a * p0[1] + b * c1[1] + c * c2[1] + d * p1[1]];
769
- };
770
- const quad = (p0, c, p1, t) => {
771
- const mt = 1 - t;
772
- return [mt * mt * p0[0] + 2 * mt * t * c[0] + t * t * p1[0], mt * mt * p0[1] + 2 * mt * t * c[1] + t * t * p1[1]];
773
- };
774
- const ellipse = (cx, cy, rx, ry, rot, ang) => {
775
- const ex = rx * Math.cos(ang);
776
- const ey = ry * Math.sin(ang);
777
- const cos = Math.cos(rot);
778
- const sin = Math.sin(rot);
779
- return [cx + ex * cos - ey * sin, cy + ex * sin + ey * cos];
780
- };
781
- /**
782
- * Flatten a path to polylines — de Casteljau for C/Q, arc sampling for E
783
- * (Circle and rounded-rect corners are 'E' segments, so this MUST handle them
784
- * or those shapes roughen wrong). `steps` is the samples per curved segment.
785
- */
786
- function flatten(segs, steps = 16) {
787
- const polys = [];
788
- let cur = null;
789
- let px = 0;
790
- let py = 0;
791
- let sx = 0;
792
- let sy = 0;
793
- const ensure = (x, y) => {
794
- if (!cur) {
795
- cur = {
796
- points: [[x, y]],
797
- closed: false
798
- };
799
- polys.push(cur);
800
- sx = x;
801
- sy = y;
802
- }
803
- return cur;
804
- };
805
- for (const s of segs) switch (s[0]) {
806
- case "M":
807
- cur = {
808
- points: [[s[1], s[2]]],
809
- closed: false
810
- };
811
- polys.push(cur);
812
- px = sx = s[1];
813
- py = sy = s[2];
814
- break;
815
- case "L":
816
- ensure(px, py).points.push([s[1], s[2]]);
817
- px = s[1];
818
- py = s[2];
819
- break;
820
- case "C": {
821
- const c = ensure(px, py);
822
- for (let k = 1; k <= steps; k++) c.points.push(cubic([px, py], [s[1], s[2]], [s[3], s[4]], [s[5], s[6]], k / steps));
823
- px = s[5];
824
- py = s[6];
825
- break;
826
- }
827
- case "Q": {
828
- const c = ensure(px, py);
829
- for (let k = 1; k <= steps; k++) c.points.push(quad([px, py], [s[1], s[2]], [s[3], s[4]], k / steps));
830
- px = s[3];
831
- py = s[4];
832
- break;
833
- }
834
- case "E": {
835
- const [, cx, cy, rx, ry, rot, a0, a1] = s;
836
- const begin = ellipse(cx, cy, rx, ry, rot, a0);
837
- const c = ensure(begin[0], begin[1]);
838
- for (let k = 1; k <= steps; k++) c.points.push(ellipse(cx, cy, rx, ry, rot, a0 + (a1 - a0) * (k / steps)));
839
- const end = ellipse(cx, cy, rx, ry, rot, a1);
840
- px = end[0];
841
- py = end[1];
842
- break;
843
- }
844
- case "Z":
845
- if (cur) {
846
- cur.points.push([sx, sy]);
847
- cur.closed = true;
848
- px = sx;
849
- py = sy;
850
- }
851
- break;
852
- }
853
- return polys.filter((p) => p.points.length > 0);
854
- }
855
- /** Total length of a flattened polyline (for draw-on dashing). */
856
- function arcLength(poly) {
857
- let len = 0;
858
- for (let i = 1; i < poly.points.length; i++) len += Math.hypot(poly.points[i][0] - poly.points[i - 1][0], poly.points[i][1] - poly.points[i - 1][1]);
859
- return len;
860
- }
861
- function validateHachure(h) {
862
- if (!(h.gap > 0)) throw new SketchValidationError(`hachure gap must be > 0, got ${String(h.gap)}`);
863
- if (h.roughness !== void 0 && !(h.roughness >= 0)) throw new SketchValidationError(`hachure roughness must be ≥ 0, got ${String(h.roughness)}`);
864
- }
865
- /**
866
- * Parallel hatch lines covering a path's bounding box at `angleRad`, spaced
867
- * `gap`, lightly jittered. Returned as `M/L` segments to be stroked INSIDE a
868
- * clip of the shape (the caller emits the clip). Pure; `rng` reseeded per draw.
869
- */
870
- function hachureLines(segs, spec, rng) {
871
- const polys = flatten(segs);
872
- let minX = Infinity;
873
- let minY = Infinity;
874
- let maxX = -Infinity;
875
- let maxY = -Infinity;
876
- for (const p of polys) for (const [x, y] of p.points) {
877
- if (x < minX) minX = x;
878
- if (y < minY) minY = y;
879
- if (x > maxX) maxX = x;
880
- if (y > maxY) maxY = y;
881
- }
882
- if (!Number.isFinite(minX)) return [];
883
- const cx = (minX + maxX) / 2;
884
- const cy = (minY + maxY) / 2;
885
- const ca = Math.cos(spec.angleRad);
886
- const sa = Math.sin(spec.angleRad);
887
- const toRot = (x, y) => [(x - cx) * ca + (y - cy) * sa, -(x - cx) * sa + (y - cy) * ca];
888
- const fromRot = (x, y) => [cx + x * ca - y * sa, cy + x * sa + y * ca];
889
- let rMinX = Infinity;
890
- let rMinY = Infinity;
891
- let rMaxX = -Infinity;
892
- let rMaxY = -Infinity;
893
- const corners = [
894
- [minX, minY],
895
- [maxX, minY],
896
- [maxX, maxY],
897
- [minX, maxY]
898
- ];
899
- for (const [x, y] of corners) {
900
- const [rx, ry] = toRot(x, y);
901
- if (rx < rMinX) rMinX = rx;
902
- if (ry < rMinY) rMinY = ry;
903
- if (rx > rMaxX) rMaxX = rx;
904
- if (ry > rMaxY) rMaxY = ry;
905
- }
906
- const rough = spec.roughness ?? 1;
907
- const jit = () => (rng() * 2 - 1) * rough;
908
- const out = [];
909
- for (let y = rMinY + spec.gap / 2; y < rMaxY; y += spec.gap) {
910
- const a = fromRot(rMinX, y + jit());
911
- const b = fromRot(rMaxX, y + jit());
912
- out.push([
913
- "M",
914
- a[0],
915
- a[1]
916
- ], [
917
- "L",
918
- b[0],
919
- b[1]
920
- ]);
921
- }
922
- return out;
923
- }
924
- /**
925
- * Roughen a path into hand-drawn stroke passes. Each segment becomes a bowed,
926
- * jittered quadratic; `passes` overlay slightly different jitters for the
927
- * built-up look. `rng` must be a freshly seeded generator (the caller reseeds
928
- * per draw from a stable seed, so evaluate() stays pure).
929
- */
930
- function roughen(segs, style, rng) {
931
- const resolved = resolveSketch(style);
932
- const polys = flatten(segs);
933
- const jit = () => (rng() * 2 - 1) * resolved.roughness;
934
- const strokes = [];
935
- for (let pass = 0; pass < resolved.passes; pass++) {
936
- const out = [];
937
- for (const poly of polys) {
938
- const pts = poly.points;
939
- if (pts.length < 2) continue;
940
- let ax = pts[0][0] + jit();
941
- let ay = pts[0][1] + jit();
942
- out.push([
943
- "M",
944
- ax,
945
- ay
946
- ]);
947
- for (let i = 1; i < pts.length; i++) {
948
- const bx = pts[i][0] + jit();
949
- const by = pts[i][1] + jit();
950
- const dx = bx - ax;
951
- const dy = by - ay;
952
- const len = Math.hypot(dx, dy) || 1;
953
- const bow = jit() * .5;
954
- const mx = (ax + bx) / 2 + -dy / len * bow;
955
- const my = (ay + by) / 2 + dx / len * bow;
956
- out.push([
957
- "Q",
958
- mx,
959
- my,
960
- bx,
961
- by
962
- ]);
963
- ax = bx;
964
- ay = by;
965
- }
966
- }
967
- strokes.push(out);
968
- }
969
- return {
970
- strokes,
971
- resolved
972
- };
973
- }
974
- /** FNV-1a 32-bit — a stable per-shape sketch seed from its id. */
975
- function hashStr(s) {
976
- let h = 2166136261;
977
- for (let i = 0; i < s.length; i++) {
978
- h ^= s.charCodeAt(i);
979
- h = Math.imul(h, 16777619);
980
- }
981
- return h >>> 0;
982
- }
983
- /** Convenience: the rough stroke passes for a path at a given seed. */
984
- function sketchStrokes(segs, style, seed) {
985
- return roughen(segs, style, random(seed >>> 0)).strokes;
986
- }
987
- //#endregion
988
- //#region src/nodes.ts
989
- /**
990
- * Built-in nodes for M1 (DESIGN.md §3.1): Group, Rect, Circle, Text.
991
- * Path/Image/Video/Layout arrive with their milestones.
992
- */
993
- /**
994
- * The NAMED extension point of the closed §3.1 taxonomy: the documented base
995
- * an author subclasses to emit IR commands (never canvas calls). It adds
996
- * nothing to `Node` — it exists so "custom-via-subclassing" is a real,
997
- * exported surface (the ninth taxonomy member) rather than an unnamed
998
- * convention. Subclasses implement the abstract `draw()` from `Node`.
999
- */
1000
- var Custom = class extends Node {};
1001
- /** Rounded-rect path segments — Rect's outline, shared with Highlight. */
1002
- function roundedRectSegs(x, y, w, h, r) {
1003
- if (r <= 0) return [
1004
- [
1005
- "M",
1006
- x,
1007
- y
1008
- ],
1009
- [
1010
- "L",
1011
- x + w,
1012
- y
1013
- ],
1014
- [
1015
- "L",
1016
- x + w,
1017
- y + h
1018
- ],
1019
- [
1020
- "L",
1021
- x,
1022
- y + h
1023
- ],
1024
- ["Z"]
1025
- ];
1026
- const HALF = Math.PI / 2;
1027
- return [
1028
- [
1029
- "M",
1030
- x + r,
1031
- y
1032
- ],
1033
- [
1034
- "L",
1035
- x + w - r,
1036
- y
1037
- ],
1038
- [
1039
- "E",
1040
- x + w - r,
1041
- y + r,
1042
- r,
1043
- r,
1044
- 0,
1045
- -HALF,
1046
- 0
1047
- ],
1048
- [
1049
- "L",
1050
- x + w,
1051
- y + h - r
1052
- ],
1053
- [
1054
- "E",
1055
- x + w - r,
1056
- y + h - r,
1057
- r,
1058
- r,
1059
- 0,
1060
- 0,
1061
- HALF
1062
- ],
1063
- [
1064
- "L",
1065
- x + r,
1066
- y + h
1067
- ],
1068
- [
1069
- "E",
1070
- x + r,
1071
- y + h - r,
1072
- r,
1073
- r,
1074
- 0,
1075
- HALF,
1076
- Math.PI
1077
- ],
1078
- [
1079
- "L",
1080
- x,
1081
- y + r
1082
- ],
1083
- [
1084
- "E",
1085
- x + r,
1086
- y + r,
1087
- r,
1088
- r,
1089
- 0,
1090
- Math.PI,
1091
- Math.PI + HALF
1092
- ],
1093
- ["Z"]
1094
- ];
1095
- }
1096
- var Group = class extends Node {
1097
- children;
1098
- /** Version bumped on structural child mutation, so a dependency-tracked memo
1099
- * (e.g. Layout's computed) re-runs when the child SET changes — not only when
1100
- * a participating prop signal does. */
1101
- #structure = signal(0);
1102
- constructor(props = {}) {
1103
- super(props);
1104
- this.children = props.children ?? [];
1105
- for (const child of this.children) child.parent = this;
1106
- }
1107
- /** Record the structural version as a dependency — call inside a computed
1108
- * that walks `children` so add()/remove() invalidate it. */
1109
- trackStructure() {
1110
- this.#structure();
1111
- }
1112
- add(child) {
1113
- child.parent = this;
1114
- this.children.push(child);
1115
- this.#structure.set(this.#structure.peek() + 1);
1116
- return this;
1117
- }
1118
- /** Remove a child (the reactive counterpart to add()); no-op if absent. */
1119
- remove(child) {
1120
- const i = this.children.indexOf(child);
1121
- if (i >= 0) {
1122
- this.children.splice(i, 1);
1123
- if (child.parent === this) child.parent = null;
1124
- this.#structure.set(this.#structure.peek() + 1);
1125
- }
1126
- return this;
1127
- }
1128
- draw(out, ctx) {
1129
- const sorted = this.children.map((node, i) => ({
1130
- node,
1131
- i
1132
- })).sort((a, b) => a.node.zIndex() - b.node.zIndex() || a.i - b.i).map((e) => e.node);
1133
- for (const child of sorted) child.emit(out, ctx);
1134
- }
1135
- };
1136
- /** A color string is sugar for a solid `color` Paint; a Paint passes through. */
1137
- function toPaint(fill) {
1138
- return typeof fill === "string" ? {
1139
- kind: "color",
1140
- color: fill
1141
- } : fill;
1142
- }
1143
- var Shape = class extends Node {
1144
- fill;
1145
- stroke;
1146
- strokeWidth;
1147
- sketch;
1148
- sketchFill;
1149
- sketchSeed;
1150
- reveal;
1151
- constructor(props = {}) {
1152
- super(props);
1153
- this.fill = initProp(signal(""), props.fill);
1154
- this.stroke = initProp(signal(""), props.stroke);
1155
- this.strokeWidth = initProp(signal(0), props.strokeWidth);
1156
- this.reveal = initProp(signal(1), props.reveal);
1157
- this.registerTarget("fill", this.fill, ["color", "paint"]);
1158
- this.registerTarget("stroke", this.stroke, "color");
1159
- this.registerTarget("strokeWidth", this.strokeWidth, "number");
1160
- this.registerTarget("reveal", this.reveal, "number");
1161
- if (props.sketch) validateSketch(props.sketch);
1162
- if (props.sketchFill) validateHachure(props.sketchFill);
1163
- if (props.sketchFill && !props.sketch) emitDevWarning(`${this.id !== void 0 ? `'${this.id}': ` : ""}sketchFill is ignored without sketch — hachure fill is drawn only by the sketch renderer. Set a sketch style (e.g. { kind: 'pencil' }) to see it.`);
1164
- this.sketch = props.sketch;
1165
- this.sketchFill = props.sketchFill;
1166
- this.sketchSeed = props.sketchSeed ?? (this.id !== void 0 ? hashStr(this.id) : 0);
1167
- }
1168
- draw(out) {
1169
- const segs = this.pathSegs();
1170
- if (this.sketch) return this.drawSketch(out, segs);
1171
- const path = out.resource({
1172
- kind: "path",
1173
- segs
1174
- });
1175
- const fill = this.fill();
1176
- if (fill) out.push({
1177
- op: "fillPath",
1178
- path,
1179
- paint: toPaint(fill)
1180
- });
1181
- const stroke = this.stroke();
1182
- const width = this.strokeWidth();
1183
- if (stroke && width > 0) {
1184
- const reveal = this.reveal();
1185
- if (reveal < 1) emitDrawOnStroke(out, segs, {
1186
- kind: "color",
1187
- color: stroke
1188
- }, { width }, reveal);
1189
- else out.push({
1190
- op: "strokePath",
1191
- path,
1192
- paint: {
1193
- kind: "color",
1194
- color: stroke
1195
- },
1196
- stroke: { width }
1197
- });
1198
- }
1199
- }
1200
- /** Hand-drawn render: solid fill (if any) under roughened, multi-pass strokes.
1201
- * The seed is consumed fresh each draw, so re-evaluation is byte-identical. */
1202
- drawSketch(out, segs) {
1203
- const rng = random(this.sketchSeed >>> 0);
1204
- const fill = this.fill();
1205
- if (fill) {
1206
- const path = out.resource({
1207
- kind: "path",
1208
- segs
1209
- });
1210
- out.push({
1211
- op: "fillPath",
1212
- path,
1213
- paint: toPaint(fill)
1214
- });
1215
- }
1216
- const { strokes, resolved } = roughen(segs, this.sketch, rng);
1217
- const ink = this.stroke() || (typeof fill === "string" ? fill : "") || "#000000";
1218
- if (this.sketchFill) {
1219
- const clipPath = out.resource({
1220
- kind: "path",
1221
- segs
1222
- });
1223
- out.push({ op: "save" });
1224
- out.push({
1225
- op: "clip",
1226
- path: clipPath,
1227
- rule: "nonzero"
1228
- });
1229
- const hatch = hachureLines(segs, this.sketchFill, rng);
1230
- if (hatch.length > 0) {
1231
- const hp = out.resource({
1232
- kind: "path",
1233
- segs: hatch
1234
- });
1235
- out.push({
1236
- op: "strokePath",
1237
- path: hp,
1238
- paint: {
1239
- kind: "color",
1240
- color: ink
1241
- },
1242
- stroke: {
1243
- width: Math.max(1, resolved.width * .5),
1244
- cap: "round"
1245
- }
1246
- });
1247
- }
1248
- out.push({ op: "restore" });
1249
- }
1250
- const reveal = this.reveal();
1251
- const drawOn = reveal < 1;
1252
- for (const passSegs of strokes) {
1253
- if (passSegs.length === 0) continue;
1254
- if (drawOn) {
1255
- emitDrawOnStroke(out, passSegs, {
1256
- kind: "color",
1257
- color: ink
1258
- }, {
1259
- width: resolved.width,
1260
- cap: "round",
1261
- join: "round"
1262
- }, reveal);
1263
- continue;
1264
- }
1265
- const path = out.resource({
1266
- kind: "path",
1267
- segs: passSegs
1268
- });
1269
- out.push({
1270
- op: "strokePath",
1271
- path,
1272
- paint: {
1273
- kind: "color",
1274
- color: ink
1275
- },
1276
- stroke: {
1277
- width: resolved.width,
1278
- cap: "round",
1279
- join: "round",
1280
- ...resolved.dash ? { dash: resolved.dash } : {}
1281
- }
1282
- });
1283
- }
1284
- }
1285
- };
1286
- function initProp(sig, init) {
1287
- if (typeof init === "function") sig.bindSource(init);
1288
- else if (init !== void 0) sig.set(init);
1289
- return sig;
1290
- }
1291
- /** Split a stroke path into its subpaths (each starting at an 'M'). */
1292
- function splitContours(segs) {
1293
- const out = [];
1294
- let cur = null;
1295
- for (const s of segs) if (s[0] === "M") {
1296
- cur = [s];
1297
- out.push(cur);
1298
- } else if (cur) cur.push(s);
1299
- return out;
1300
- }
1301
- /**
1302
- * Emit a stroke that "draws on" by arc length — a retreating dash PER CONTOUR
1303
- * (canvas restarts the dash phase at each subpath move, so each contour reveals
1304
- * from its own start). Shared by sketched and plain stroked shapes.
1305
- */
1306
- function emitDrawOnStroke(out, segs, paint, baseStroke, reveal) {
1307
- for (const contour of splitContours(segs)) {
1308
- const len = flatten(contour).reduce((s, p) => s + arcLength(p), 0);
1309
- const path = out.resource({
1310
- kind: "path",
1311
- segs: contour
1312
- });
1313
- out.push({
1314
- op: "strokePath",
1315
- path,
1316
- paint,
1317
- stroke: {
1318
- ...baseStroke,
1319
- dash: [len, len],
1320
- dashOffset: len * (1 - reveal)
1321
- }
1322
- });
1323
- }
1324
- }
1325
- /**
1326
- * `PathSeg[]` → `PathValue` (Lottie vertex contours) — the inverse of
1327
- * `Path.pathSegs`, so geometry from `roundedRectSegs` / `sketchStrokes` /
1328
- * `flatten` can be placed on a `Path` node (to morph, motion-path, or draw-on
1329
- * it). C/Q become an anchor + relative in/out tangents; L is a zero-tangent
1330
- * vertex; E samples to vertices; Z closes the contour, folding the closing
1331
- * tangent back onto the first vertex. Round-trips C-contours exactly.
1332
- */
1333
- function pathFromSegs(segs) {
1334
- const contours = [];
1335
- let c = null;
1336
- const push = (v, inT = [0, 0], outT = [0, 0]) => {
1337
- c.v.push(v);
1338
- c.in.push(inT);
1339
- c.out.push(outT);
1340
- };
1341
- const last = () => c.v[c.v.length - 1];
1342
- for (const s of segs) switch (s[0]) {
1343
- case "M":
1344
- c = {
1345
- closed: false,
1346
- v: [[s[1], s[2]]],
1347
- in: [[0, 0]],
1348
- out: [[0, 0]]
1349
- };
1350
- contours.push(c);
1351
- break;
1352
- case "L":
1353
- if (c) push([s[1], s[2]]);
1354
- break;
1355
- case "C":
1356
- if (c) {
1357
- const p0 = last();
1358
- c.out[c.out.length - 1] = [s[1] - p0[0], s[2] - p0[1]];
1359
- push([s[5], s[6]], [s[3] - s[5], s[4] - s[6]]);
1360
- }
1361
- break;
1362
- case "Q":
1363
- if (c) {
1364
- const p0 = last();
1365
- const qx = s[1];
1366
- const qy = s[2];
1367
- const px = s[3];
1368
- const py = s[4];
1369
- c.out[c.out.length - 1] = [2 / 3 * (qx - p0[0]), 2 / 3 * (qy - p0[1])];
1370
- push([px, py], [2 / 3 * (qx - px), 2 / 3 * (qy - py)]);
1371
- }
1372
- break;
1373
- case "E":
1374
- if (c) {
1375
- const [, cx, cy, rx, ry, rot, a0, a1] = s;
1376
- const cos = Math.cos(rot);
1377
- const sin = Math.sin(rot);
1378
- for (let k = 1; k <= 16; k++) {
1379
- const ang = a0 + (a1 - a0) * (k / 16);
1380
- const ex = rx * Math.cos(ang);
1381
- const ey = ry * Math.sin(ang);
1382
- push([cx + ex * cos - ey * sin, cy + ex * sin + ey * cos]);
1383
- }
1384
- }
1385
- break;
1386
- case "Z":
1387
- if (c) {
1388
- c.closed = true;
1389
- const f = c.v[0];
1390
- const l = last();
1391
- if (c.v.length > 1 && Math.abs(f[0] - l[0]) < 1e-9 && Math.abs(f[1] - l[1]) < 1e-9) {
1392
- c.in[0] = c.in[c.in.length - 1];
1393
- c.v.pop();
1394
- c.in.pop();
1395
- c.out.pop();
1396
- }
1397
- }
1398
- break;
1399
- }
1400
- return contours;
1401
- }
1402
- var Rect = class extends Shape {
1403
- width;
1404
- height;
1405
- /** Corner radius; clamped to half the smaller dimension. radius = h/2 makes a pill. */
1406
- cornerRadius;
1407
- constructor(props = {}) {
1408
- super(props);
1409
- this.width = initProp(signal(0), props.width);
1410
- this.height = initProp(signal(0), props.height);
1411
- this.cornerRadius = initProp(signal(0), props.cornerRadius);
1412
- this.registerTarget("width", this.width, "number");
1413
- this.registerTarget("height", this.height, "number");
1414
- this.registerTarget("cornerRadius", this.cornerRadius, "number");
1415
- }
1416
- intrinsicSize() {
1417
- return {
1418
- w: this.width(),
1419
- h: this.height()
1420
- };
1421
- }
1422
- pathSegs() {
1423
- const w = this.width();
1424
- const h = this.height();
1425
- const r = Math.min(Math.max(0, this.cornerRadius()), w / 2, h / 2);
1426
- return roundedRectSegs(-w / 2, -h / 2, w, h, r);
1427
- }
1428
- };
1429
- var Circle = class extends Shape {
1430
- radius;
1431
- constructor(props = {}) {
1432
- super(props);
1433
- this.radius = initProp(signal(0), props.radius);
1434
- this.registerTarget("radius", this.radius, "number");
1435
- }
1436
- intrinsicSize() {
1437
- const d = this.radius() * 2;
1438
- return {
1439
- w: d,
1440
- h: d
1441
- };
1442
- }
1443
- pathSegs() {
1444
- const r = this.radius();
1445
- return [[
1446
- "E",
1447
- 0,
1448
- 0,
1449
- r,
1450
- r,
1451
- 0,
1452
- 0,
1453
- Math.PI * 2
1454
- ], ["Z"]];
1455
- }
1456
- };
1457
- /**
1458
- * Arbitrary bezier geometry — the Lottie-import landing spot and the target
1459
- * of native path morphs. Coordinates are node-local (the node origin is
1460
- * wherever the author put 0,0); flow placement uses the control-point bounds.
1461
- */
1462
- var Path = class extends Shape {
1463
- data;
1464
- constructor(props = {}) {
1465
- super(props);
1466
- this.data = initProp(signal([]), props.data);
1467
- this.registerTarget("d", this.data, "path");
1468
- }
1469
- /** Control-point bounding box (conservative: contains the true curve). */
1470
- bounds() {
1471
- let minX = Infinity;
1472
- let minY = Infinity;
1473
- let maxX = -Infinity;
1474
- let maxY = -Infinity;
1475
- for (const c of this.data()) for (let i = 0; i < c.v.length; i++) {
1476
- const vx = c.v[i][0];
1477
- const vy = c.v[i][1];
1478
- const candidates = [
1479
- [vx, vy],
1480
- [vx + c.in[i][0], vy + c.in[i][1]],
1481
- [vx + c.out[i][0], vy + c.out[i][1]]
1482
- ];
1483
- for (const p of candidates) {
1484
- if (p[0] < minX) minX = p[0];
1485
- if (p[1] < minY) minY = p[1];
1486
- if (p[0] > maxX) maxX = p[0];
1487
- if (p[1] > maxY) maxY = p[1];
1488
- }
1489
- }
1490
- if (minX > maxX) return {
1491
- minX: 0,
1492
- minY: 0,
1493
- maxX: 0,
1494
- maxY: 0
1495
- };
1496
- return {
1497
- minX,
1498
- minY,
1499
- maxX,
1500
- maxY
1501
- };
1502
- }
1503
- intrinsicSize() {
1504
- const b = this.bounds();
1505
- return {
1506
- w: b.maxX - b.minX,
1507
- h: b.maxY - b.minY
1508
- };
1509
- }
1510
- /** Geometry is node-local, not center-anchored: offset to the box's actual top-left. */
1511
- drawOffset() {
1512
- const b = this.bounds();
1513
- return {
1514
- x: b.minX,
1515
- y: b.minY
1516
- };
1517
- }
1518
- pathSegs() {
1519
- const segs = [];
1520
- for (const c of this.data()) {
1521
- const n = c.v.length;
1522
- if (n === 0) continue;
1523
- segs.push([
1524
- "M",
1525
- c.v[0][0],
1526
- c.v[0][1]
1527
- ]);
1528
- for (let i = 0; i < n - 1; i++) segs.push([
1529
- "C",
1530
- c.v[i][0] + c.out[i][0],
1531
- c.v[i][1] + c.out[i][1],
1532
- c.v[i + 1][0] + c.in[i + 1][0],
1533
- c.v[i + 1][1] + c.in[i + 1][1],
1534
- c.v[i + 1][0],
1535
- c.v[i + 1][1]
1536
- ]);
1537
- if (c.closed && n > 1) {
1538
- segs.push([
1539
- "C",
1540
- c.v[n - 1][0] + c.out[n - 1][0],
1541
- c.v[n - 1][1] + c.out[n - 1][1],
1542
- c.v[0][0] + c.in[0][0],
1543
- c.v[0][1] + c.in[0][1],
1544
- c.v[0][0],
1545
- c.v[0][1]
1546
- ]);
1547
- segs.push(["Z"]);
1548
- }
1549
- }
1550
- return segs;
1551
- }
1552
- };
1553
- var ImageNode = class extends Node {
1554
- /** Marks this node as referencing a kind 'image' timeline asset (§2.3). */
1555
- static assetKind = "image";
1556
- assetId;
1557
- width;
1558
- height;
1559
- constructor(props) {
1560
- super(props);
1561
- this.assetId = props.assetId;
1562
- this.width = initProp(signal(0), props.width);
1563
- this.height = initProp(signal(0), props.height);
1564
- this.registerTarget("width", this.width, "number");
1565
- this.registerTarget("height", this.height, "number");
1566
- }
1567
- intrinsicSize() {
1568
- return {
1569
- w: this.width(),
1570
- h: this.height()
1571
- };
1572
- }
1573
- draw(out) {
1574
- const w = this.width();
1575
- const h = this.height();
1576
- if (w <= 0 || h <= 0) return;
1577
- const image = out.resource({
1578
- kind: "image",
1579
- assetId: this.assetId
1580
- });
1581
- out.push({
1582
- op: "drawImage",
1583
- image,
1584
- dst: {
1585
- x: -w / 2,
1586
- y: -h / 2,
1587
- w,
1588
- h
1589
- }
1590
- });
1591
- }
1592
- };
1593
- /**
1594
- * Pure given a warmed VideoFrameSource (§3.8): emit() does only the
1595
- * frame-indexed media-time arithmetic — mediaT = trimStart + (t - at) * rate —
1596
- * and references the exact source-grid frame; backends resolve it.
1597
- */
1598
- var Video = class extends Node {
1599
- /** Marks this node as referencing a kind 'video' timeline asset (§3.8). */
1600
- static assetKind = "video";
1601
- assetId;
1602
- at;
1603
- trimStart;
1604
- playbackRate;
1605
- clipDuration;
1606
- sourceFps;
1607
- width;
1608
- height;
1609
- constructor(props) {
1610
- super(props);
1611
- this.assetId = props.assetId;
1612
- this.at = props.at ?? 0;
1613
- this.trimStart = props.trimStart ?? 0;
1614
- this.playbackRate = props.playbackRate ?? 1;
1615
- this.clipDuration = props.clipDuration;
1616
- this.sourceFps = props.sourceFps;
1617
- this.width = initProp(signal(0), props.width);
1618
- this.height = initProp(signal(0), props.height);
1619
- this.registerTarget("width", this.width, "number");
1620
- this.registerTarget("height", this.height, "number");
1621
- }
1622
- /** Frame-indexed media time for timeline time t; null when outside the clip. */
1623
- mediaTime(t) {
1624
- const local = (t - this.at) * this.playbackRate;
1625
- if (local < 0) return null;
1626
- if (this.clipDuration !== void 0 && t - this.at >= this.clipDuration) return null;
1627
- const mediaT = this.trimStart + local;
1628
- if (this.sourceFps !== void 0) return Math.floor(mediaT * this.sourceFps + 1e-9) / this.sourceFps;
1629
- return mediaT;
1630
- }
1631
- draw(out, ctx) {
1632
- const mediaT = this.mediaTime(ctx.time);
1633
- if (mediaT === null) return;
1634
- const w = this.width();
1635
- const h = this.height();
1636
- if (w <= 0 || h <= 0) return;
1637
- const image = out.resource({
1638
- kind: "videoFrame",
1639
- assetId: this.assetId,
1640
- mediaT
1641
- });
1642
- out.push({
1643
- op: "drawImage",
1644
- image,
1645
- dst: {
1646
- x: -w / 2,
1647
- y: -h / 2,
1648
- w,
1649
- h
1650
- }
1651
- });
1652
- }
1653
- };
1654
- var Text = class extends Node {
1655
- text;
1656
- fill;
1657
- fontSize;
1658
- fontFamily;
1659
- fontWeight;
1660
- fontStyle;
1661
- align;
1662
- width;
1663
- lineHeight;
1664
- reveal;
1665
- constructor(props = {}) {
1666
- super(props);
1667
- this.text = initProp(signal(""), props.text);
1668
- this.fill = initProp(signal("#000000"), props.fill);
1669
- this.fontSize = initProp(signal(16), props.fontSize);
1670
- this.fontFamily = props.fontFamily ?? "sans-serif";
1671
- this.fontWeight = props.fontWeight ?? 400;
1672
- this.fontStyle = props.fontStyle ?? "normal";
1673
- this.align = props.align ?? "left";
1674
- this.width = initProp(signal(0), props.width);
1675
- this.lineHeight = props.lineHeight ?? 1.25;
1676
- this.reveal = initProp(signal(Number.POSITIVE_INFINITY), props.reveal);
1677
- this.registerTarget("width", this.width, "number");
1678
- this.registerTarget("text", this.text, "string");
1679
- this.registerTarget("fill", this.fill, "color");
1680
- this.registerTarget("fontSize", this.fontSize, "number");
1681
- this.registerTarget("reveal", this.reveal, "number");
1682
- }
1683
- intrinsicSize(measurer) {
1684
- const text = this.text();
1685
- if (!text) return {
1686
- w: 0,
1687
- h: 0
1688
- };
1689
- const font = {
1690
- family: this.fontFamily,
1691
- size: this.fontSize(),
1692
- weight: this.fontWeight,
1693
- ...this.fontStyle === "italic" ? { style: "italic" } : {}
1694
- };
1695
- const maxWidth = this.width();
1696
- const lines = breakLines(text, font, maxWidth > 0 ? maxWidth : void 0, measurer);
1697
- const widest = Math.max(...lines.map((l) => quantize(measurer.measureText(l, font).width)), 0);
1698
- return {
1699
- w: maxWidth > 0 ? maxWidth : widest,
1700
- h: quantize(font.size * this.lineHeight) * lines.length
1701
- };
1702
- }
1703
- /** Text draws from a baseline origin at its align edge, not a center (§3.6). */
1704
- drawOffset(measurer) {
1705
- const m = measurer ?? this.measurerSource?.() ?? fallbackMeasurer();
1706
- const size = this.intrinsicSize(m);
1707
- const font = {
1708
- family: this.fontFamily,
1709
- size: this.fontSize(),
1710
- weight: this.fontWeight,
1711
- ...this.fontStyle === "italic" ? { style: "italic" } : {}
1712
- };
1713
- const firstLine = breakLines(this.text(), font, this.width() > 0 ? this.width() : void 0, m)[0] ?? "";
1714
- const ascent = m.measureText(firstLine, font).ascent;
1715
- return {
1716
- x: this.align === "left" ? 0 : this.align === "center" ? -size.w / 2 : -size.w,
1717
- y: -ascent
1718
- };
1719
- }
1720
- /**
1721
- * The wrapped box {w, h}, measured with the scene's active measurer — the
1722
- * same numbers Layout flows with, public so bindings never hand-calculate
1723
- * text dimensions (e.g. underline width = () => title.measuredSize().w).
1724
- */
1725
- measuredSize(measurer) {
1726
- return this.intrinsicSize(measurer ?? this.measurerSource?.() ?? fallbackMeasurer());
1727
- }
1728
- /**
1729
- * Per-line ink boxes in this node's DRAW space (origin = first baseline at
1730
- * the align edge), from the same breakLines pass that draws. Pull-based:
1731
- * re-measures when text/font/width animate. Blank lines (from '\n\n')
1732
- * produce no box. The substrate for highlights, underlines, per-line
1733
- * reveals, selections.
1734
- */
1735
- lineBoxes(measurer) {
1736
- const m = measurer ?? this.measurerSource?.() ?? fallbackMeasurer();
1737
- const text = this.text();
1738
- if (!text) return [];
1739
- const font = {
1740
- family: this.fontFamily,
1741
- size: this.fontSize(),
1742
- weight: this.fontWeight,
1743
- ...this.fontStyle === "italic" ? { style: "italic" } : {}
1744
- };
1745
- const maxWidth = this.width();
1746
- const lines = breakLines(text, font, maxWidth > 0 ? maxWidth : void 0, m);
1747
- const step = quantize(font.size * this.lineHeight);
1748
- const boxes = [];
1749
- for (let i = 0; i < lines.length; i++) {
1750
- const line = lines[i];
1751
- if (!line) continue;
1752
- const met = m.measureText(line, font);
1753
- const w = quantize(met.width);
1754
- const x = this.align === "left" ? 0 : this.align === "center" ? -w / 2 : -w;
1755
- boxes.push({
1756
- text: line,
1757
- x,
1758
- y: i * step - met.ascent,
1759
- w,
1760
- h: met.ascent + met.descent
1761
- });
1762
- }
1763
- return boxes;
1764
- }
1765
- /**
1766
- * Per-word ink boxes within each laid-out line — the SAME segmentation the
1767
- * breaker flows (Intl.Segmenter boundaries, punctuation glued), positioned
1768
- * by cumulative prefix advances so cross-word kerning is exact and word
1769
- * widths sum to the line's width. Whitespace contributes advance but no
1770
- * box. Pair index-wise with a narration manifest's word timestamps for
1771
- * karaoke; draw your own rects for sub-line multi-color token work.
1772
- */
1773
- wordBoxes(measurer) {
1774
- const m = measurer ?? this.measurerSource?.() ?? fallbackMeasurer();
1775
- const text = this.text();
1776
- if (!text) return [];
1777
- const font = {
1778
- family: this.fontFamily,
1779
- size: this.fontSize(),
1780
- weight: this.fontWeight,
1781
- ...this.fontStyle === "italic" ? { style: "italic" } : {}
1782
- };
1783
- const maxWidth = this.width();
1784
- const lines = breakLines(text, font, maxWidth > 0 ? maxWidth : void 0, m);
1785
- const step = quantize(font.size * this.lineHeight);
1786
- const boxes = [];
1787
- for (let i = 0; i < lines.length; i++) {
1788
- const line = lines[i];
1789
- if (!line) continue;
1790
- const met = m.measureText(line, font);
1791
- const lineW = quantize(met.width);
1792
- const lineX = this.align === "left" ? 0 : this.align === "center" ? -lineW / 2 : -lineW;
1793
- const y = i * step - met.ascent;
1794
- const h = met.ascent + met.descent;
1795
- let prefix = "";
1796
- for (const seg of segmentWords(line)) {
1797
- const start = prefix;
1798
- prefix += seg;
1799
- const word = seg.trim();
1800
- if (word === "") continue;
1801
- const lead = seg.length - seg.trimStart().length;
1802
- const before = m.measureText(start + seg.slice(0, lead), font).width;
1803
- const after = m.measureText(start + seg.trimEnd(), font).width;
1804
- boxes.push({
1805
- text: word,
1806
- line: i,
1807
- x: lineX + before,
1808
- y,
1809
- w: after - before,
1810
- h
1811
- });
1812
- }
1813
- }
1814
- return boxes;
1815
- }
1816
- /**
1817
- * The laid-out grapheme stream the typewriter reveal advances over — every
1818
- * grapheme of every wrapped line, in reading order (soft-wrap whitespace is
1819
- * dropped by the breaker, exactly as drawn, so draw/revealHead/revealSchedule
1820
- * all agree). Pull-based; its length is the `reveal` count that shows
1821
- * everything. Author a per-keystroke staircase straight off it:
1822
- *
1823
- * const g = title.graphemes();
1824
- * track('title/reveal', 'number',
1825
- * g.map((_, i) => key(t0 + i * 0.05, i + 1, { interp: 'hold' })));
1826
- */
1827
- graphemes(measurer) {
1828
- const m = measurer ?? this.measurerSource?.() ?? fallbackMeasurer();
1829
- const text = this.text();
1830
- if (!text) return [];
1831
- const font = {
1832
- family: this.fontFamily,
1833
- size: this.fontSize(),
1834
- weight: this.fontWeight,
1835
- ...this.fontStyle === "italic" ? { style: "italic" } : {}
1836
- };
1837
- const maxWidth = this.width();
1838
- const lines = breakLines(text, font, maxWidth > 0 ? maxWidth : void 0, m);
1839
- const out = [];
1840
- for (const line of lines) for (const g of segmentGraphemes(line)) out.push(g);
1841
- return out;
1842
- }
1843
- /**
1844
- * Draw-space position of the reveal head — the caret point just after the
1845
- * last revealed grapheme, for the current `reveal` value. Drives TextCursor;
1846
- * honours align and wrap exactly like wordBoxes(). At reveal 0 it sits at the
1847
- * start of the first line; fully revealed, at the end of the last line.
1848
- */
1849
- revealHead(measurer) {
1850
- const m = measurer ?? this.measurerSource?.() ?? fallbackMeasurer();
1851
- const font = {
1852
- family: this.fontFamily,
1853
- size: this.fontSize(),
1854
- weight: this.fontWeight,
1855
- ...this.fontStyle === "italic" ? { style: "italic" } : {}
1856
- };
1857
- const maxWidth = this.width();
1858
- const lines = breakLines(this.text(), font, maxWidth > 0 ? maxWidth : void 0, m);
1859
- const step = quantize(font.size * this.lineHeight);
1860
- const total = lines.reduce((n, l) => n + segmentGraphemes(l).length, 0);
1861
- const revealRaw = this.reveal();
1862
- const shown = Math.max(0, Math.min(Number.isFinite(revealRaw) ? Math.floor(revealRaw) : total, total));
1863
- let remaining = shown;
1864
- let last = {
1865
- x: 0,
1866
- y: 0,
1867
- h: 0,
1868
- line: 0,
1869
- index: shown
1870
- };
1871
- for (let i = 0; i < lines.length; i++) {
1872
- const line = lines[i] ?? "";
1873
- const met = m.measureText(line, font);
1874
- const lineW = quantize(met.width);
1875
- const lineX = this.align === "left" ? 0 : this.align === "center" ? -lineW / 2 : -lineW;
1876
- const y = i * step - met.ascent;
1877
- const h = met.ascent + met.descent;
1878
- const gs = segmentGraphemes(line);
1879
- if (remaining <= gs.length) return {
1880
- x: lineX + (remaining <= 0 ? 0 : m.measureText(gs.slice(0, remaining).join(""), font).width),
1881
- y,
1882
- h,
1883
- line: i,
1884
- index: shown
1885
- };
1886
- remaining -= gs.length;
1887
- last = {
1888
- x: lineX + met.width,
1889
- y,
1890
- h,
1891
- line: i,
1892
- index: shown
1893
- };
1894
- }
1895
- return last;
1896
- }
1897
- draw(out, ctx) {
1898
- const text = this.text();
1899
- if (!text) return;
1900
- const font = {
1901
- family: this.fontFamily,
1902
- size: this.fontSize(),
1903
- weight: this.fontWeight,
1904
- ...this.fontStyle === "italic" ? { style: "italic" } : {}
1905
- };
1906
- const maxWidth = this.width();
1907
- const lines = breakLines(text, font, maxWidth > 0 ? maxWidth : void 0, ctx.measurer);
1908
- const step = quantize(font.size * this.lineHeight);
1909
- const revealRaw = this.reveal();
1910
- const masked = Number.isFinite(revealRaw);
1911
- let remaining = masked ? Math.max(0, Math.floor(revealRaw)) : 0;
1912
- for (let i = 0; i < lines.length; i++) {
1913
- const line = lines[i];
1914
- if (!line) continue;
1915
- if (!masked) {
1916
- out.push({
1917
- op: "fillText",
1918
- text: line,
1919
- font,
1920
- paint: {
1921
- kind: "color",
1922
- color: this.fill()
1923
- },
1924
- x: 0,
1925
- y: i * step,
1926
- ...this.align !== "left" ? { align: this.align } : {}
1927
- });
1928
- continue;
1929
- }
1930
- if (remaining <= 0) break;
1931
- const gs = segmentGraphemes(line);
1932
- const show = Math.min(remaining, gs.length);
1933
- remaining -= show;
1934
- if (show === gs.length) out.push({
1935
- op: "fillText",
1936
- text: line,
1937
- font,
1938
- paint: {
1939
- kind: "color",
1940
- color: this.fill()
1941
- },
1942
- x: 0,
1943
- y: i * step,
1944
- ...this.align !== "left" ? { align: this.align } : {}
1945
- });
1946
- else {
1947
- const lineW = quantize(ctx.measurer.measureText(line, font).width);
1948
- const lineX = this.align === "left" ? 0 : this.align === "center" ? -lineW / 2 : -lineW;
1949
- out.push({
1950
- op: "fillText",
1951
- text: gs.slice(0, show).join(""),
1952
- font,
1953
- paint: {
1954
- kind: "color",
1955
- color: this.fill()
1956
- },
1957
- x: lineX,
1958
- y: i * step
1959
- });
1960
- }
1961
- }
1962
- }
1963
- };
1964
- /**
1965
- * Pure per-grapheme schedule from a Text and its reveal track — geometry from
1966
- * the text, timing from the track. A grapheme's time is the first key whose
1967
- * value reveals it (value >= index + 1); graphemes the track never reaches are
1968
- * omitted. The single source SFX keystroke-sync consumes (keystrokeClips()):
1969
- * one click per mark at `at: mark.time`, char-class policy (skip space/newline,
1970
- * pick a sample) decided downstream from `mark.grapheme`.
1971
- */
1972
- function revealSchedule(text, reveal, measurer) {
1973
- const m = measurer ?? text.measurerSource?.() ?? fallbackMeasurer();
1974
- const src = text.text();
1975
- if (!src) return [];
1976
- const font = {
1977
- family: text.fontFamily,
1978
- size: text.fontSize(),
1979
- weight: text.fontWeight,
1980
- ...text.fontStyle === "italic" ? { style: "italic" } : {}
1981
- };
1982
- const maxWidth = text.width();
1983
- const lines = breakLines(src, font, maxWidth > 0 ? maxWidth : void 0, m);
1984
- const step = quantize(font.size * text.lineHeight);
1985
- const keys = reveal.keys;
1986
- const marks = [];
1987
- let k = 0;
1988
- for (let li = 0; li < lines.length; li++) {
1989
- const line = lines[li];
1990
- if (!line) continue;
1991
- const met = m.measureText(line, font);
1992
- const lineW = quantize(met.width);
1993
- const lineX = text.align === "left" ? 0 : text.align === "center" ? -lineW / 2 : -lineW;
1994
- const y = li * step - met.ascent;
1995
- let prefix = "";
1996
- for (const g of segmentGraphemes(line)) {
1997
- prefix += g;
1998
- const need = k + 1;
1999
- let time = Number.POSITIVE_INFINITY;
2000
- for (const key of keys) if (key.value >= need) {
2001
- time = key.t;
2002
- break;
2003
- }
2004
- if (Number.isFinite(time)) marks.push({
2005
- charIndex: k,
2006
- grapheme: g,
2007
- time,
2008
- x: lineX + m.measureText(prefix, font).width,
2009
- y,
2010
- line: li
2011
- });
2012
- k++;
2013
- }
2014
- }
2015
- return marks;
2016
- }
2017
- //#endregion
2018
1
  //#region src/layoutEngine.ts
2019
2
  var LayoutEngineMissingError = class extends Error {
2020
3
  constructor() {
@@ -2034,4 +17,4 @@ function requireLayoutEngine() {
2034
17
  return engine;
2035
18
  }
2036
19
  //#endregion
2037
- export { multiply as $, estimatingMeasurer as A, validateFilters as B, sketchStrokes as C, resolveAnchor as D, Node as E, setDefaultMeasurer as F, formatDisplayDiff as G, DlSnapshotError as H, FilterValidationError as I, IDENTITY as J, parseDisplaySnapshot as K, createDisplayListBuilder as L, quantize as M, segmentGraphemes as N, MEASURE_QUANTUM_PX as O, segmentWords as P, matEquals as Q, filtersToCanvasFilter as R, roughen as S, validateSketch as T, collapseReplacer as U, DL_SNAPSHOT_VERSION as V, diffDisplayLists as W, fromTRS as X, applyToPoint as Y, invert as Z, arcLength as _, Circle as a, hashStr as b, ImageNode as c, Text as d, Video as f, SketchValidationError as g, roundedRectSegs as h, setLayoutEngine as i, fallbackMeasurer as j, breakLines as k, Path as l, revealSchedule as m, getLayoutEngine as n, Custom as o, pathFromSegs as p, serializeDisplayList as q, requireLayoutEngine as r, Group as s, LayoutEngineMissingError as t, Rect as u, flatten as v, validateHachure as w, resolveSketch as x, hachureLines as y, glow as z };
20
+ export { setLayoutEngine as i, getLayoutEngine as n, requireLayoutEngine as r, LayoutEngineMissingError as t };