@glissade/scene 0.60.0 → 0.61.0-pre.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.
@@ -157,12 +157,16 @@ interface SurfaceEntry {
157
157
  * `'value'` = a runtime binding on the bundle (a class / function / object);
158
158
  * `'type'` = a TS type-only name that erases at runtime (opaque, referenced by
159
159
  * signatures); `'diagnostic'` = a runtime AUTHORING-DIAGNOSTIC function
160
- * (`critique`/`validateScene`/`resolveAt`/`instanceProps`, 0.60) — a real
161
- * `window.glissade.<name>` callable that is PERCEPTION/self-check tooling, not
162
- * scene-building surface. An agent BUILDING a scene filters `kind !== 'diagnostic'`;
163
- * an agent CRITIQUING a rendered scene filters `kind === 'diagnostic'`.
160
+ * (`critique`/`validateScene`/`resolveAt`/`instanceProps`/`exportFidelity`) — a
161
+ * real `window.glissade.<name>` callable that reports PROBLEMS (self-check tooling),
162
+ * not scene-building surface; `'tool'` (0.61) = a runtime OPERATION function
163
+ * (`diff`) that transforms/compares scenes and returns a RESULT (a ChangeSet), not
164
+ * a problem list — distinct from a diagnostic so a consumer never misuses its
165
+ * output as a defect report. A scalable CATEGORY (diagnostics=problems /
166
+ * tools=operations / values=authoring surface): an agent BUILDING a scene filters
167
+ * `kind === 'value'`; a self-check agent filters `kind === 'diagnostic'`.
164
168
  */
165
- kind: 'value' | 'type' | 'diagnostic';
169
+ kind: 'value' | 'type' | 'diagnostic' | 'tool';
166
170
  /** `true` when it is reachable as `window.glissade.<name>` on the single-file IIFE bundle. */
167
171
  iife: boolean;
168
172
  /** How to consume it: `'constructor'` needs `new`, `'function'` is a plain call, `'object'` is a value namespace (e.g. `easings`), `'type'` is type-only. */
package/dist/describe.js CHANGED
@@ -23,7 +23,7 @@ import { easings, listValueTypes } from "@glissade/core";
23
23
  * never pulled onto the base embed path — a scene that never calls `describe()`
24
24
  * pays zero bytes for it.
25
25
  */
26
- const RAW_VERSION = "0.60.0";
26
+ const RAW_VERSION = "0.61.0-pre.1";
27
27
  const PACKAGE_VERSION = RAW_VERSION.includes("GLISSADE_".concat("VERSION")) ? "0.0.0-dev" : RAW_VERSION;
28
28
  /**
29
29
  * Parse the documented positional-arg count from a helper `usage` string — the
@@ -207,9 +207,24 @@ const SURFACE_DIAGNOSTICS = [
207
207
  {
208
208
  name: "instanceProps",
209
209
  arity: 1
210
+ },
211
+ {
212
+ name: "exportFidelity",
213
+ arity: 1
210
214
  }
211
215
  ];
212
216
  /**
217
+ * 0.61 machine-readable TOOL functions on `window.glissade` — runtime OPERATIONS
218
+ * that transform/compare scenes and return a RESULT, not a problem list. `diff(a,b)`
219
+ * returns a ChangeSet (the blast-radius of an edit). Marked `kind:'tool'` so a
220
+ * consumer never treats its output as a diagnostic (no severity/defect semantics).
221
+ * `arity` = the documented required positional-arg count.
222
+ */
223
+ const SURFACE_TOOLS = [{
224
+ name: "diff",
225
+ arity: 2
226
+ }];
227
+ /**
213
228
  * The opaque, type-ONLY names the API surface references (they erase at runtime —
214
229
  * `window.glissade.Paint` is `undefined`). `gs types --global` emits a best-effort
215
230
  * alias per name; `gs describe --lint` guards they stay type-only (a type surfaced
@@ -270,6 +285,13 @@ function buildSurface() {
270
285
  form: "function",
271
286
  arity: d.arity
272
287
  });
288
+ for (const t of SURFACE_TOOLS) out.push({
289
+ name: t.name,
290
+ kind: "tool",
291
+ iife: true,
292
+ form: "function",
293
+ arity: t.arity
294
+ });
273
295
  for (const name of SURFACE_TYPE_ONLY) out.push({
274
296
  name,
275
297
  kind: "type",
@@ -178,7 +178,7 @@ type DiagnosticSeverity = 'error' | 'warning' | 'info';
178
178
  * a built Scene structurally cannot contain a duplicate id, so validateScene
179
179
  * never reaches this case. Kept for the shared contract / `gs parity` surface.
180
180
  */
181
- type DiagnosticCode = 'UNKNOWN_TARGET' | 'ID_COLLISION' | 'OFF_CANVAS' | 'YOGA_CHILD_POSITION' | 'MEASURER_FALLBACK' | 'TEXT_OVERFLOW' | 'OCCLUSION';
181
+ type DiagnosticCode = 'UNKNOWN_TARGET' | 'ID_COLLISION' | 'OFF_CANVAS' | 'YOGA_CHILD_POSITION' | 'MEASURER_FALLBACK' | 'TEXT_OVERFLOW' | 'OCCLUSION' | 'RENDER_ONLY_EXPORT' | 'LOTTIE_DROP' | 'LOTTIE_APPROXIMATE' | 'ANCHOR_RECENTER' | 'UNEXPLAINED_RESIDUAL' | 'BACKEND_DIVERGE';
182
182
  /**
183
183
  * 0.60: which enforcement SURFACE produced a diagnostic — distinguishes a CERTAIN
184
184
  * static fact (`validateScene`) from a HEURISTIC rendered judgment (`critique`,
@@ -327,4 +327,100 @@ declare function critique(scene: Scene, timeline: Timeline, opts?: CritiqueOptio
327
327
  */
328
328
  declare function sortDiagnostics(diags: SceneDiagnostic[]): SceneDiagnostic[];
329
329
  //#endregion
330
- export { type CacheColdResult, type CommandDelta, type CritiqueOptions, type CritiqueResult, DIAGNOSTIC_SCHEMA_VERSION, DL_SNAPSHOT_VERSION, type DiagnosticCode, type DiagnosticSeverity, type DiagnosticSource, type DisplayDiff, type DlSnapshot, DlSnapshotError, type FieldChange, type FontByteLoader, type InstancePropState, type SceneDiagnostic, type ValidateSceneFontsOptions, type ValidateSceneResult, auditCacheCold, collapseReplacer, collectLocalizedTextUsages, collectTextUsages, critique, diffDisplayLists, formatDisplayDiff, instanceProps, levenshtein, nearestId, parseDisplaySnapshot, resolveAt, serializeDisplayList, sortDiagnostics, validateScene, validateSceneFonts };
330
+ //#region src/diff.d.ts
331
+ /** A node reference in the added/removed lists — its id + node type. */
332
+ interface NodeRef {
333
+ /** the node id (only id-bearing nodes are diffed by identity). */
334
+ node: string;
335
+ /** the node's `describeType` (e.g. `'Rect'`, `'Text'`). */
336
+ type: string;
337
+ }
338
+ /** A composed-world bbox (rendered layer), same shape as critique's geometry. */
339
+ interface Region {
340
+ minX: number;
341
+ minY: number;
342
+ maxX: number;
343
+ maxY: number;
344
+ }
345
+ /**
346
+ * One change. The `op` tags WHAT KIND of change it is:
347
+ * - `'moved'` — a node was RE-PARENTED (structural) or its rendered box shifted
348
+ * (rendered layer). `from`/`to` are the parent ids (structural) or bboxes.
349
+ * - `'retargeted'` — a track now points at a different target (its keys are
350
+ * unchanged; only `target` moved). `from`/`to` are the old/new target strings.
351
+ * - `'changed'` — a node prop value, a track's keys, a track's presence, or a
352
+ * rendered command field changed. `from`/`to` are the old/new values.
353
+ */
354
+ type ChangeOp = 'moved' | 'retargeted' | 'changed';
355
+ interface Change {
356
+ op: ChangeOp;
357
+ /** the node id this change concerns (node-level changes). */
358
+ node?: string;
359
+ /** the track/prop target this change concerns (track-level + prop changes). */
360
+ target?: string;
361
+ /** the specific property that changed (e.g. `'position'`, `'keys'`, `'type'`). */
362
+ property?: string;
363
+ /** the value BEFORE (in `a`). */
364
+ from: unknown;
365
+ /** the value AFTER (in `b`). */
366
+ to: unknown;
367
+ /** rendered layer: the composed-world region the change occupies. */
368
+ region?: Region;
369
+ }
370
+ /**
371
+ * The typed change-set `diff(a, b)` returns. Programmatically checkable — an agent
372
+ * asserts `diff == exactly my intended change-set`, never parses a text blob.
373
+ * `empty` is a convenience (`added=removed=changed=[]`), so `diff(a,a).empty` is
374
+ * the one-liner clean check.
375
+ */
376
+ interface ChangeSet {
377
+ /** nodes present in `b` but not `a` (by id). */
378
+ added: NodeRef[];
379
+ /** nodes present in `a` but not `b` (by id). */
380
+ removed: NodeRef[];
381
+ /** every moved / retargeted / changed entry, canonically sorted. */
382
+ changed: Change[];
383
+ /** true iff `added`, `removed`, and `changed` are all empty. */
384
+ empty: boolean;
385
+ }
386
+ /** The two sides of a diff — a bound-or-buildable scene + its optional document. */
387
+ interface DiffInput {
388
+ scene: Scene;
389
+ timeline?: Timeline;
390
+ }
391
+ interface DiffOptions {
392
+ /**
393
+ * ALSO run the rendered (DisplayList) layer at `at` — surfaces VISUAL deltas the
394
+ * semantic structural layer ignores (a paint that moved, a color that changed
395
+ * mid-flight). Default false (structural only).
396
+ */
397
+ rendered?: boolean;
398
+ /** the time (seconds) the rendered layer samples. Default 0. */
399
+ at?: number;
400
+ }
401
+ /**
402
+ * Diff two scenes (+ optional timelines) into a typed {@link ChangeSet}. Default
403
+ * is the SEMANTIC structural layer (node tree + timeline); pass `{ rendered: true }`
404
+ * to also diff the DisplayList at `at`. Pure read; `diff(a, a)` is EMPTY.
405
+ */
406
+ declare function diff(a: DiffInput, b: DiffInput, opts?: DiffOptions): ChangeSet;
407
+ //#endregion
408
+ //#region src/fidelity.d.ts
409
+ /** exportFidelity result — the CLI-lint `{ hasErrors, diagnostics }` shape. */
410
+ interface ExportFidelityResult {
411
+ schemaVersion: typeof DIAGNOSTIC_SCHEMA_VERSION;
412
+ /** true iff any diagnostic is severity `error` — always false here (render-only
413
+ * use is a warning, never a build error), kept for shape-parity with the family. */
414
+ hasErrors: boolean;
415
+ /** every RENDER_ONLY_EXPORT finding, canonically sorted. */
416
+ diagnostics: SceneDiagnostic[];
417
+ }
418
+ /**
419
+ * Statically scan `scene` (+ optional `timeline`, for reveal-track detection) for
420
+ * render-only features that won't survive Lottie export, aggregating one
421
+ * RENDER_ONLY_EXPORT warning per affected node. Pure read; a scene with no
422
+ * render-only feature returns an EMPTY diagnostics list.
423
+ */
424
+ declare function exportFidelity(scene: Scene, timeline?: Timeline): ExportFidelityResult;
425
+ //#endregion
426
+ export { type CacheColdResult, type Change, type ChangeOp, type ChangeSet, type CommandDelta, type CritiqueOptions, type CritiqueResult, DIAGNOSTIC_SCHEMA_VERSION, DL_SNAPSHOT_VERSION, type DiagnosticCode, type DiagnosticSeverity, type DiagnosticSource, type DiffInput, type DiffOptions, type DisplayDiff, type DlSnapshot, DlSnapshotError, type ExportFidelityResult, type FieldChange, type FontByteLoader, type InstancePropState, type NodeRef, type Region, type SceneDiagnostic, type ValidateSceneFontsOptions, type ValidateSceneResult, auditCacheCold, collapseReplacer, collectLocalizedTextUsages, collectTextUsages, critique, diff, diffDisplayLists, exportFidelity, formatDisplayDiff, instanceProps, levenshtein, nearestId, parseDisplaySnapshot, resolveAt, serializeDisplayList, sortDiagnostics, validateScene, validateSceneFonts };
@@ -1,6 +1,7 @@
1
1
  import { I as isEstimatingMeasurer, J as collapseReplacer, P as estimatingMeasurer, R as quantize, W as createDisplayListBuilder, Y as IDENTITY, et as multiply, r as Group, s as Text } from "./nodes.js";
2
2
  import { a as evaluate, r as bindScene } from "./scene.js";
3
3
  import { emitWithIds } from "./identity.js";
4
+ import { i as shakenSpec } from "./shake.js";
4
5
  import { buildFontRegistry, compileTimeline, evaluateAt, parseCmap, parseColor, untracked, validateFonts } from "@glissade/core";
5
6
  //#region src/displayDiff.ts
6
7
  /** A flat, stable JSON value for one command with its resource ids INLINED to content. */
@@ -921,4 +922,401 @@ function sortDiagnostics(diags) {
921
922
  }).map(([d]) => d);
922
923
  }
923
924
  //#endregion
924
- export { DIAGNOSTIC_SCHEMA_VERSION, DL_SNAPSHOT_VERSION, DlSnapshotError, auditCacheCold, collapseReplacer, collectLocalizedTextUsages, collectTextUsages, critique, diffDisplayLists, formatDisplayDiff, instanceProps, levenshtein, nearestId, parseDisplaySnapshot, resolveAt, serializeDisplayList, sortDiagnostics, validateScene, validateSceneFonts };
925
+ //#region src/diff.ts
926
+ /**
927
+ * `diff` takes two `{ scene, timeline }` STATE PAIRS, not two `(scene, timeline)`
928
+ * args like its sibling diagnostics (critique/exportFidelity) — a plausible misuse.
929
+ * Passing a raw `Scene` would fall through to `evaluate` and throw the cryptic
930
+ * "Invalid value used as weak map key"; catch it here with an actionable message.
931
+ */
932
+ function assertDiffInput(x, which) {
933
+ if (x !== null && typeof x === "object" && typeof x.scene === "object" && x.scene !== null) return;
934
+ const looksLikeScene = x !== null && typeof x === "object" && "nodes" in x && "root" in x && typeof x.resolveTarget === "function";
935
+ throw new TypeError(looksLikeScene ? `diff expects { scene, timeline } state objects (got a Scene) — did you mean diff({ scene, timeline }, { scene, timeline })? (unlike critique/exportFidelity, diff takes two STATE PAIRS, not scene+timeline args)` : `diff's ${which} argument must be a { scene, timeline } state object`);
936
+ }
937
+ /** Stable JSON of any value — object keys sorted so key ORDER never reads as a
938
+ * change. NaN/Infinity/undefined are normalized to stable sentinels. */
939
+ function canonical(v) {
940
+ return JSON.stringify(normalize(v));
941
+ }
942
+ function normalize(v) {
943
+ if (v === void 0) return "\0undef";
944
+ if (typeof v === "number") return Number.isFinite(v) ? v : `num:${String(v)}`;
945
+ if (Array.isArray(v)) return v.map(normalize);
946
+ if (v && typeof v === "object") {
947
+ const out = {};
948
+ for (const k of Object.keys(v).sort()) out[k] = normalize(v[k]);
949
+ return out;
950
+ }
951
+ return v;
952
+ }
953
+ function equalValue(a, b) {
954
+ return canonical(a) === canonical(b);
955
+ }
956
+ /** Collect every id-bearing node's structural facts, keyed by id. */
957
+ function nodeFacts(scene) {
958
+ const out = /* @__PURE__ */ new Map();
959
+ for (const [id, node] of scene.nodes) out.set(id, {
960
+ type: node.describeType,
961
+ parent: nearestIdedParent(node)
962
+ });
963
+ return out;
964
+ }
965
+ /** The id of the nearest id-bearing ancestor (skipping id-less wrappers like a
966
+ * bare `motionBlur(...)`), or '' at the root. */
967
+ function nearestIdedParent(node) {
968
+ let p = node.parent;
969
+ while (p) {
970
+ if (p.id !== void 0 && p.id !== "__root") return p.id;
971
+ p = p.parent;
972
+ }
973
+ return "";
974
+ }
975
+ /** Read a node's registered animatable targets → the static (or t=0) resolved
976
+ * value per path. Guarded: a computed prop that throws when read outside a full
977
+ * render is simply omitted (favor no-spurious over a crash). */
978
+ function propValues(scene, node) {
979
+ const out = /* @__PURE__ */ new Map();
980
+ untracked(() => {
981
+ for (const { path } of node.listTargets()) {
982
+ const sig = node.resolveTarget(path);
983
+ if (typeof sig !== "function") continue;
984
+ try {
985
+ out.set(path, sig());
986
+ } catch {}
987
+ }
988
+ });
989
+ return out;
990
+ }
991
+ /** A track's identity-independent value signature (target excluded, so a retarget
992
+ * is detectable as "same keys, new target"). */
993
+ function trackSignature(track) {
994
+ return canonical({
995
+ keys: track.keys,
996
+ type: track.type,
997
+ expr: track.expr ?? null
998
+ });
999
+ }
1000
+ /**
1001
+ * Diff two scenes (+ optional timelines) into a typed {@link ChangeSet}. Default
1002
+ * is the SEMANTIC structural layer (node tree + timeline); pass `{ rendered: true }`
1003
+ * to also diff the DisplayList at `at`. Pure read; `diff(a, a)` is EMPTY.
1004
+ */
1005
+ function diff(a, b, opts = {}) {
1006
+ assertDiffInput(a, "first");
1007
+ assertDiffInput(b, "second");
1008
+ const added = [];
1009
+ const removed = [];
1010
+ const changed = [];
1011
+ const emptyDoc = {
1012
+ version: 1,
1013
+ tracks: []
1014
+ };
1015
+ const docA = a.timeline ?? emptyDoc;
1016
+ const docB = b.timeline ?? emptyDoc;
1017
+ evaluate(a.scene, docA, 0);
1018
+ evaluate(b.scene, docB, 0);
1019
+ const factsA = nodeFacts(a.scene);
1020
+ const factsB = nodeFacts(b.scene);
1021
+ for (const [id, f] of factsA) if (!factsB.has(id)) removed.push({
1022
+ node: id,
1023
+ type: f.type
1024
+ });
1025
+ for (const [id, f] of factsB) if (!factsA.has(id)) added.push({
1026
+ node: id,
1027
+ type: f.type
1028
+ });
1029
+ const animated = /* @__PURE__ */ new Set();
1030
+ for (const tr of docA.tracks) animated.add(tr.target);
1031
+ for (const tr of docB.tracks) animated.add(tr.target);
1032
+ for (const [id, fa] of factsA) {
1033
+ const fb = factsB.get(id);
1034
+ if (!fb) continue;
1035
+ if (fa.type !== fb.type) {
1036
+ changed.push({
1037
+ op: "changed",
1038
+ node: id,
1039
+ property: "type",
1040
+ from: fa.type,
1041
+ to: fb.type
1042
+ });
1043
+ continue;
1044
+ }
1045
+ if (fa.parent !== fb.parent) changed.push({
1046
+ op: "moved",
1047
+ node: id,
1048
+ from: fa.parent || null,
1049
+ to: fb.parent || null
1050
+ });
1051
+ const nodeA = a.scene.nodes.get(id);
1052
+ const nodeB = b.scene.nodes.get(id);
1053
+ const pvA = propValues(a.scene, nodeA);
1054
+ const pvB = propValues(b.scene, nodeB);
1055
+ for (const [path, va] of pvA) {
1056
+ const target = `${id}/${path}`;
1057
+ if (animated.has(target)) continue;
1058
+ if (!pvB.has(path)) continue;
1059
+ const vb = pvB.get(path);
1060
+ if (!equalValue(va, vb)) changed.push({
1061
+ op: "changed",
1062
+ node: id,
1063
+ property: path,
1064
+ from: va,
1065
+ to: vb
1066
+ });
1067
+ }
1068
+ }
1069
+ diffTracks(docA.tracks, docB.tracks, changed);
1070
+ if (opts.rendered) diffRendered(a.scene, docA, b.scene, docB, opts.at ?? 0, changed);
1071
+ sortChanges(changed);
1072
+ sortRefs(added);
1073
+ sortRefs(removed);
1074
+ return {
1075
+ added,
1076
+ removed,
1077
+ changed,
1078
+ empty: added.length === 0 && removed.length === 0 && changed.length === 0
1079
+ };
1080
+ }
1081
+ /** Track diff: match by target for keys-change + add/remove, then pair an unmatched
1082
+ * removed target with an unmatched added target of identical signature as a RETARGET. */
1083
+ function diffTracks(tracksA, tracksB, changed) {
1084
+ const byTargetA = /* @__PURE__ */ new Map();
1085
+ const byTargetB = /* @__PURE__ */ new Map();
1086
+ for (const t of tracksA) byTargetA.set(t.target, t);
1087
+ for (const t of tracksB) byTargetB.set(t.target, t);
1088
+ const removedTargets = [];
1089
+ const addedTargets = [];
1090
+ for (const [target, ta] of byTargetA) {
1091
+ const tb = byTargetB.get(target);
1092
+ if (!tb) {
1093
+ removedTargets.push(ta);
1094
+ continue;
1095
+ }
1096
+ if (trackSignature(ta) !== trackSignature(tb)) changed.push({
1097
+ op: "changed",
1098
+ target,
1099
+ property: "keys",
1100
+ from: ta.keys,
1101
+ to: tb.keys
1102
+ });
1103
+ }
1104
+ for (const [target, tb] of byTargetB) if (!byTargetA.has(target)) addedTargets.push(tb);
1105
+ removedTargets.sort((x, y) => x.target < y.target ? -1 : x.target > y.target ? 1 : 0);
1106
+ addedTargets.sort((x, y) => x.target < y.target ? -1 : x.target > y.target ? 1 : 0);
1107
+ const usedAdded = /* @__PURE__ */ new Set();
1108
+ for (const rem of removedTargets) {
1109
+ const remSig = trackSignature(rem);
1110
+ let matched = -1;
1111
+ for (let i = 0; i < addedTargets.length; i++) {
1112
+ if (usedAdded.has(i)) continue;
1113
+ if (trackSignature(addedTargets[i]) === remSig) {
1114
+ matched = i;
1115
+ break;
1116
+ }
1117
+ }
1118
+ if (matched >= 0) {
1119
+ usedAdded.add(matched);
1120
+ changed.push({
1121
+ op: "retargeted",
1122
+ target: rem.target,
1123
+ from: rem.target,
1124
+ to: addedTargets[matched].target
1125
+ });
1126
+ } else changed.push({
1127
+ op: "changed",
1128
+ target: rem.target,
1129
+ property: "track",
1130
+ from: "present",
1131
+ to: "absent"
1132
+ });
1133
+ }
1134
+ for (let i = 0; i < addedTargets.length; i++) {
1135
+ if (usedAdded.has(i)) continue;
1136
+ changed.push({
1137
+ op: "changed",
1138
+ target: addedTargets[i].target,
1139
+ property: "track",
1140
+ from: "absent",
1141
+ to: "present"
1142
+ });
1143
+ }
1144
+ }
1145
+ /** Rendered layer: diff the two DisplayLists at `t` via the shipped
1146
+ * `diffDisplayLists`; translate each field delta into a `changed` entry. */
1147
+ function diffRendered(sceneA, docA, sceneB, docB, t, changed) {
1148
+ const d = diffDisplayLists(evaluate(sceneA, docA, t), evaluate(sceneB, docB, t));
1149
+ if (d.equal) return;
1150
+ for (const delta of d.deltas) if (delta.kind === "add") changed.push({
1151
+ op: "changed",
1152
+ property: `render:command[${delta.index}]`,
1153
+ from: "absent",
1154
+ to: delta.opB ?? "present"
1155
+ });
1156
+ else if (delta.kind === "remove") changed.push({
1157
+ op: "changed",
1158
+ property: `render:command[${delta.index}]`,
1159
+ from: delta.opA ?? "present",
1160
+ to: "absent"
1161
+ });
1162
+ else for (const fc of delta.fields) changed.push({
1163
+ op: "changed",
1164
+ property: `render:command[${delta.index}].${fc.path}`,
1165
+ from: fc.from,
1166
+ to: fc.to
1167
+ });
1168
+ }
1169
+ function sortRefs(refs) {
1170
+ refs.sort((a, b) => a.node < b.node ? -1 : a.node > b.node ? 1 : 0);
1171
+ }
1172
+ /** Canonical change order: by (target||node) key, then op, then property, then a
1173
+ * value tiebreak — so a shuffled emission order sorts to the same sequence
1174
+ * (assert sort-invariance). Sorts `changed` IN PLACE. */
1175
+ function sortChanges(changed) {
1176
+ const keyOf = (c) => c.target ?? c.node ?? "";
1177
+ const paired = changed.map((c, i) => [c, i]);
1178
+ paired.sort((A, B) => {
1179
+ const [a, ai] = A;
1180
+ const [b, bi] = B;
1181
+ const ka = keyOf(a);
1182
+ const kb = keyOf(b);
1183
+ if (ka !== kb) return ka < kb ? -1 : 1;
1184
+ if (a.op !== b.op) return a.op < b.op ? -1 : 1;
1185
+ const pa = a.property ?? "";
1186
+ const pb = b.property ?? "";
1187
+ if (pa !== pb) return pa < pb ? -1 : 1;
1188
+ const va = canonical(a.to);
1189
+ const vb = canonical(b.to);
1190
+ if (va !== vb) return va < vb ? -1 : 1;
1191
+ return ai - bi;
1192
+ });
1193
+ const sorted = paired.map(([c]) => c);
1194
+ for (let i = 0; i < sorted.length; i++) changed[i] = sorted[i];
1195
+ }
1196
+ //#endregion
1197
+ //#region src/fidelity.ts
1198
+ /** Per-feature actionable hint tail (after the "won't survive Lottie export" clause). */
1199
+ const HINT = {
1200
+ "motion-blur": "the round-trip shows the un-blurred shape; if export-bound, pre-bake the blur into keyframed copies or accept the loss",
1201
+ "echo-trails": "only the base shape exports (no ghost copies); if export-bound, bake the trail into real staggered layers or accept the loss",
1202
+ shake: "the closed-form jitter is not a keyframe track; if export-bound, bake() the shake into position/rotation tracks or accept the loss",
1203
+ "camera-shake": "whole-frame camera shake is not exported; if export-bound, bake it into the camera pose tracks or accept the loss",
1204
+ "text-cursor": "the caret sibling is not exportable and is dropped; if export-bound, drop the cursor or accept the loss",
1205
+ reveal: "Lottie has no range selector, so the FULL text shows on the round-trip; if export-bound, use per-word/line reveal tracks (revealWords/revealLines) or accept the loss",
1206
+ "mesh-fill": "a mesh fill has no Lottie gradient ramp; if export-bound, thread a PNG encoder to rasterize it (gs render does), use a solid/linear/radial fill, or accept the loss"
1207
+ };
1208
+ function label(feature) {
1209
+ switch (feature) {
1210
+ case "motion-blur": return "motionBlur";
1211
+ case "echo-trails": return "echo trails";
1212
+ case "shake": return "shake() jitter";
1213
+ case "camera-shake": return "camera shake";
1214
+ case "text-cursor": return "a text cursor";
1215
+ case "reveal": return "a typewriter reveal mask";
1216
+ case "mesh-fill": return "a mesh fill";
1217
+ }
1218
+ }
1219
+ /**
1220
+ * Statically scan `scene` (+ optional `timeline`, for reveal-track detection) for
1221
+ * render-only features that won't survive Lottie export, aggregating one
1222
+ * RENDER_ONLY_EXPORT warning per affected node. Pure read; a scene with no
1223
+ * render-only feature returns an EMPTY diagnostics list.
1224
+ */
1225
+ function exportFidelity(scene, timeline) {
1226
+ const diagnostics = [];
1227
+ const revealTargets = /* @__PURE__ */ new Set();
1228
+ if (timeline) {
1229
+ for (const tr of timeline.tracks) if (/\/reveal(Fraction)?$/.test(tr.target)) revealTargets.add(tr.target);
1230
+ }
1231
+ const push = (node, feature) => {
1232
+ const id = displayId(node);
1233
+ diagnostics.push({
1234
+ schemaVersion: 1,
1235
+ code: "RENDER_ONLY_EXPORT",
1236
+ severity: "warning",
1237
+ source: "parity",
1238
+ ...node.id !== void 0 ? { node: node.id } : {},
1239
+ message: `${label(feature)} on '${id}' is render-only — won't survive Lottie export (${HINT[feature]}).`,
1240
+ detail: {
1241
+ feature,
1242
+ node: id,
1243
+ ...node.id !== void 0 ? {} : { unnamed: true }
1244
+ }
1245
+ });
1246
+ };
1247
+ untracked(() => {
1248
+ const visit = (node) => {
1249
+ const type = node.describeType;
1250
+ if (type === "MotionBlur") push(node, "motion-blur");
1251
+ else if (type === "Echo") push(node, "echo-trails");
1252
+ else if (type === "TextCursor") push(node, "text-cursor");
1253
+ else if (type === "Camera") {
1254
+ if (node.shakeSpec !== void 0) push(node, "camera-shake");
1255
+ } else if (shakenSpec(node) !== void 0) push(node, "shake");
1256
+ if (type === "Text") {
1257
+ const id = node.id;
1258
+ const hasRevealTrack = id !== void 0 && (revealTargets.has(`${id}/reveal`) || revealTargets.has(`${id}/revealFraction`));
1259
+ const rf = readNumber(node, "revealFraction");
1260
+ const rv = readNumber(node, "reveal");
1261
+ if (hasRevealTrack || rf !== void 0 && !Number.isNaN(rf) || rv !== void 0 && Number.isFinite(rv)) push(node, "reveal");
1262
+ }
1263
+ const fill = node.fill;
1264
+ if (typeof fill === "function") try {
1265
+ const v = fill();
1266
+ if (v && typeof v === "object" && v.kind === "mesh") push(node, "mesh-fill");
1267
+ } catch {}
1268
+ if (node instanceof Group) for (const c of node.children) visit(c);
1269
+ else {
1270
+ const children = node.children;
1271
+ if (Array.isArray(children)) for (const c of children) visit(c);
1272
+ }
1273
+ };
1274
+ visit(scene.root);
1275
+ });
1276
+ return {
1277
+ schemaVersion: 1,
1278
+ hasErrors: false,
1279
+ diagnostics: sortFidelity(diagnostics)
1280
+ };
1281
+ }
1282
+ /** Canonical order — by node id, then feature (detail.feature). Shuffle-stable. */
1283
+ function sortFidelity(diags) {
1284
+ const featureOf = (d) => String(d.detail?.feature ?? "");
1285
+ return diags.map((d, i) => [d, i]).sort((A, B) => {
1286
+ const [a, ai] = A;
1287
+ const [b, bi] = B;
1288
+ const na = String(a.detail?.node ?? a.node ?? "");
1289
+ const nb = String(b.detail?.node ?? b.node ?? "");
1290
+ if (na !== nb) return na < nb ? -1 : 1;
1291
+ const fa = featureOf(a);
1292
+ const fb = featureOf(b);
1293
+ if (fa !== fb) return fa < fb ? -1 : 1;
1294
+ return ai - bi;
1295
+ }).map(([d]) => d);
1296
+ }
1297
+ /** A readable id for a node: its own id, else the first id-bearing descendant
1298
+ * (a wrapper like `motionBlur(rect)` reports the wrapped child), else its type. */
1299
+ function displayId(node) {
1300
+ if (node.id !== void 0) return node.id;
1301
+ const stack = [node];
1302
+ while (stack.length) {
1303
+ const n = stack.shift();
1304
+ if (n !== node && n.id !== void 0) return n.id;
1305
+ const children = n.children;
1306
+ if (Array.isArray(children)) stack.push(...children);
1307
+ }
1308
+ return `<${node.describeType}>`;
1309
+ }
1310
+ /** Read a node's numeric signal by prop name under untracked, or undefined. */
1311
+ function readNumber(node, prop) {
1312
+ const sig = node[prop];
1313
+ if (typeof sig !== "function") return void 0;
1314
+ try {
1315
+ const v = sig();
1316
+ return typeof v === "number" ? v : void 0;
1317
+ } catch {
1318
+ return;
1319
+ }
1320
+ }
1321
+ //#endregion
1322
+ export { DIAGNOSTIC_SCHEMA_VERSION, DL_SNAPSHOT_VERSION, DlSnapshotError, auditCacheCold, collapseReplacer, collectLocalizedTextUsages, collectTextUsages, critique, diff, diffDisplayLists, exportFidelity, formatDisplayDiff, instanceProps, levenshtein, nearestId, parseDisplaySnapshot, resolveAt, serializeDisplayList, sortDiagnostics, validateScene, validateSceneFonts };
package/dist/motion.js CHANGED
@@ -1,127 +1,8 @@
1
1
  import { C as Node, Z as fromTRS, _ as hashStr, et as multiply, r as Group, s as Text, t as Circle } from "./nodes.js";
2
+ import { i as shakenSpec, n as shakeMatrix, r as shakeOffset, t as shake } from "./shake.js";
2
3
  import { a as FollowPath, c as pathLength, i as orientToPath, l as pointAtLength, n as OrientToPath, o as followPath, r as lookAt, s as motionPath, t as LookAt } from "./orient.js";
3
4
  import { n as each } from "./each.js";
4
- import { bake, signal, valueNoise, vec2Signal } from "@glissade/core";
5
- //#region src/shake.ts
6
- /**
7
- * `shake` (0.55) — a standalone jitter driver that wobbles ANY node's pose with
8
- * deterministic value noise. It subsumes the hand-rolled per-element jitters
9
- * (desk-cursor jitterX/Y, glitch shakeAmp, typewriter jitterRate) behind one
10
- * primitive with SEPARATE translate / rotate / frequency amplitudes.
11
- *
12
- * The jitter is realized AT EMIT (the Echo/MotionBlur idiom): `shake` overrides
13
- * the node's `emit` to wrap it in a save → shake-transform → emit → restore, where
14
- * the transform is a pure function of `ctx.time` via `valueNoise` — so it composes
15
- * on top of WHATEVER already drives the node (keyframes, layout, followPath) as a
16
- * parent-space offset, and stays byte-identical across two `evaluate()` passes (no
17
- * cross-frame state, no `Date.now`/`Math.random`). The camera whole-frame shake
18
- * reuses {@link shakeOffset} directly on its pose.
19
- *
20
- * Lives on `@glissade/scene/motion` (off the base embed). Note (like Echo/
21
- * MotionBlur, both emit-time re-eval): the offset is not a Timeline TRACK, so it
22
- * is a runtime/render effect — it is NOT emitted as animated Lottie keyframes.
23
- */
24
- /** Default temporal frequency (noise cycles per second) when `frequency` is unset. */
25
- const DEFAULT_FREQUENCY = 8;
26
- const K_Y = 101;
27
- const K_ROT = 211;
28
- /** Signed value noise in [-1, 1) — the bipolar form a shake offset needs. */
29
- function snoise(seed, t) {
30
- return valueNoise(seed, t) * 2 - 1;
31
- }
32
- /**
33
- * Render-INVISIBLE marker: which nodes a {@link shake} driver is applied to, and
34
- * with what spec. The render path NEVER reads this (shake works purely by wrapping
35
- * `emit`), so it is byte-neutral for goldens — it exists ONLY so an EXPORTER
36
- * (which reads signals, not `emit`) can detect the render-only jitter and warn
37
- * honestly instead of silently dropping it. A WeakMap keeps it off the Node type.
38
- */
39
- const SHAKEN = /* @__PURE__ */ new WeakMap();
40
- /** The shake spec applied to `node` via {@link shake}, or undefined — the seam an
41
- * exporter uses to emit an honest "shake is render-only" warn (never a silent drop). */
42
- function shakenSpec(node) {
43
- return SHAKEN.get(node);
44
- }
45
- /**
46
- * The pure per-time shake offset for a spec: `{ dx, dy }` px + `dr` degrees, each
47
- * a deterministic function of `(seed, t)`. Both the {@link shake} node driver and
48
- * the Camera whole-frame shake fold this in.
49
- */
50
- function shakeOffset(spec, t) {
51
- const tr = spec.translate ?? 0;
52
- const rot = spec.rotate ?? 0;
53
- const tf = t * (spec.frequency ?? DEFAULT_FREQUENCY);
54
- return {
55
- dx: tr === 0 ? 0 : tr * snoise(spec.seed, tf),
56
- dy: tr === 0 ? 0 : tr * snoise(spec.seed + K_Y, tf),
57
- dr: rot === 0 ? 0 : rot * snoise(spec.seed + K_ROT, tf)
58
- };
59
- }
60
- /**
61
- * A shake transform about the point `p` (parent space): translate by `(dx, dy)`
62
- * then rotate `dr` degrees about `p`, so a rotational jitter spins the node around
63
- * its own origin rather than the parent's.
64
- */
65
- function shakeMatrix(p, dx, dy, dr) {
66
- const translate = [
67
- 1,
68
- 0,
69
- 0,
70
- 1,
71
- dx,
72
- dy
73
- ];
74
- if (dr === 0) return translate;
75
- const toP = [
76
- 1,
77
- 0,
78
- 0,
79
- 1,
80
- p[0],
81
- p[1]
82
- ];
83
- const fromP = [
84
- 1,
85
- 0,
86
- 0,
87
- 1,
88
- -p[0],
89
- -p[1]
90
- ];
91
- return multiply(translate, multiply(toP, multiply(fromTRS([0, 0], dr, [1, 1]), fromP)));
92
- }
93
- /**
94
- * Jitter `node`'s pose with deterministic value noise, then return it (mutate-and-
95
- * return, like Grid/orientToPath). SEPARATE `translate` (px) / `rotate` (deg) /
96
- * `frequency` (Hz) amplitudes; pass at least one nonzero amplitude. The jitter is
97
- * a parent-space offset applied at emit, so it composes with any existing driver.
98
- *
99
- * `children: [shake(cursor, { seed: 7, translate: 3 })]` — the cursor wobbles ±3px
100
- * around wherever else it is (its position track, a followPath, …).
101
- */
102
- function shake(node, spec) {
103
- const tr = spec.translate ?? 0;
104
- const rot = spec.rotate ?? 0;
105
- if (tr === 0 && rot === 0) throw new Error("shake(): pass a nonzero `translate` (px) or `rotate` (deg) amplitude — both are 0/omitted, so nothing would move.");
106
- SHAKEN.set(node, spec);
107
- const origEmit = node.emit.bind(node);
108
- node.emit = (out, ctx) => {
109
- const { dx, dy, dr } = shakeOffset(spec, ctx.time);
110
- if (dx === 0 && dy === 0 && dr === 0) {
111
- origEmit(out, ctx);
112
- return;
113
- }
114
- out.push({ op: "save" });
115
- out.push({
116
- op: "transform",
117
- m: shakeMatrix(node.position(), dx, dy, dr)
118
- });
119
- origEmit(out, ctx);
120
- out.push({ op: "restore" });
121
- };
122
- return node;
123
- }
124
- //#endregion
5
+ import { bake, signal, vec2Signal } from "@glissade/core";
125
6
  //#region src/camera.ts
126
7
  /**
127
8
  * `Camera` (0.55) — a cinematic camera rig for cuts, push-ins, pans, rolls, and
package/dist/shake.js ADDED
@@ -0,0 +1,123 @@
1
+ import { Z as fromTRS, et as multiply } from "./nodes.js";
2
+ import { valueNoise } from "@glissade/core";
3
+ //#region src/shake.ts
4
+ /**
5
+ * `shake` (0.55) — a standalone jitter driver that wobbles ANY node's pose with
6
+ * deterministic value noise. It subsumes the hand-rolled per-element jitters
7
+ * (desk-cursor jitterX/Y, glitch shakeAmp, typewriter jitterRate) behind one
8
+ * primitive with SEPARATE translate / rotate / frequency amplitudes.
9
+ *
10
+ * The jitter is realized AT EMIT (the Echo/MotionBlur idiom): `shake` overrides
11
+ * the node's `emit` to wrap it in a save → shake-transform → emit → restore, where
12
+ * the transform is a pure function of `ctx.time` via `valueNoise` — so it composes
13
+ * on top of WHATEVER already drives the node (keyframes, layout, followPath) as a
14
+ * parent-space offset, and stays byte-identical across two `evaluate()` passes (no
15
+ * cross-frame state, no `Date.now`/`Math.random`). The camera whole-frame shake
16
+ * reuses {@link shakeOffset} directly on its pose.
17
+ *
18
+ * Lives on `@glissade/scene/motion` (off the base embed). Note (like Echo/
19
+ * MotionBlur, both emit-time re-eval): the offset is not a Timeline TRACK, so it
20
+ * is a runtime/render effect — it is NOT emitted as animated Lottie keyframes.
21
+ */
22
+ /** Default temporal frequency (noise cycles per second) when `frequency` is unset. */
23
+ const DEFAULT_FREQUENCY = 8;
24
+ const K_Y = 101;
25
+ const K_ROT = 211;
26
+ /** Signed value noise in [-1, 1) — the bipolar form a shake offset needs. */
27
+ function snoise(seed, t) {
28
+ return valueNoise(seed, t) * 2 - 1;
29
+ }
30
+ /**
31
+ * Render-INVISIBLE marker: which nodes a {@link shake} driver is applied to, and
32
+ * with what spec. The render path NEVER reads this (shake works purely by wrapping
33
+ * `emit`), so it is byte-neutral for goldens — it exists ONLY so an EXPORTER
34
+ * (which reads signals, not `emit`) can detect the render-only jitter and warn
35
+ * honestly instead of silently dropping it. A WeakMap keeps it off the Node type.
36
+ */
37
+ const SHAKEN = /* @__PURE__ */ new WeakMap();
38
+ /** The shake spec applied to `node` via {@link shake}, or undefined — the seam an
39
+ * exporter uses to emit an honest "shake is render-only" warn (never a silent drop). */
40
+ function shakenSpec(node) {
41
+ return SHAKEN.get(node);
42
+ }
43
+ /**
44
+ * The pure per-time shake offset for a spec: `{ dx, dy }` px + `dr` degrees, each
45
+ * a deterministic function of `(seed, t)`. Both the {@link shake} node driver and
46
+ * the Camera whole-frame shake fold this in.
47
+ */
48
+ function shakeOffset(spec, t) {
49
+ const tr = spec.translate ?? 0;
50
+ const rot = spec.rotate ?? 0;
51
+ const tf = t * (spec.frequency ?? DEFAULT_FREQUENCY);
52
+ return {
53
+ dx: tr === 0 ? 0 : tr * snoise(spec.seed, tf),
54
+ dy: tr === 0 ? 0 : tr * snoise(spec.seed + K_Y, tf),
55
+ dr: rot === 0 ? 0 : rot * snoise(spec.seed + K_ROT, tf)
56
+ };
57
+ }
58
+ /**
59
+ * A shake transform about the point `p` (parent space): translate by `(dx, dy)`
60
+ * then rotate `dr` degrees about `p`, so a rotational jitter spins the node around
61
+ * its own origin rather than the parent's.
62
+ */
63
+ function shakeMatrix(p, dx, dy, dr) {
64
+ const translate = [
65
+ 1,
66
+ 0,
67
+ 0,
68
+ 1,
69
+ dx,
70
+ dy
71
+ ];
72
+ if (dr === 0) return translate;
73
+ const toP = [
74
+ 1,
75
+ 0,
76
+ 0,
77
+ 1,
78
+ p[0],
79
+ p[1]
80
+ ];
81
+ const fromP = [
82
+ 1,
83
+ 0,
84
+ 0,
85
+ 1,
86
+ -p[0],
87
+ -p[1]
88
+ ];
89
+ return multiply(translate, multiply(toP, multiply(fromTRS([0, 0], dr, [1, 1]), fromP)));
90
+ }
91
+ /**
92
+ * Jitter `node`'s pose with deterministic value noise, then return it (mutate-and-
93
+ * return, like Grid/orientToPath). SEPARATE `translate` (px) / `rotate` (deg) /
94
+ * `frequency` (Hz) amplitudes; pass at least one nonzero amplitude. The jitter is
95
+ * a parent-space offset applied at emit, so it composes with any existing driver.
96
+ *
97
+ * `children: [shake(cursor, { seed: 7, translate: 3 })]` — the cursor wobbles ±3px
98
+ * around wherever else it is (its position track, a followPath, …).
99
+ */
100
+ function shake(node, spec) {
101
+ const tr = spec.translate ?? 0;
102
+ const rot = spec.rotate ?? 0;
103
+ if (tr === 0 && rot === 0) throw new Error("shake(): pass a nonzero `translate` (px) or `rotate` (deg) amplitude — both are 0/omitted, so nothing would move.");
104
+ SHAKEN.set(node, spec);
105
+ const origEmit = node.emit.bind(node);
106
+ node.emit = (out, ctx) => {
107
+ const { dx, dy, dr } = shakeOffset(spec, ctx.time);
108
+ if (dx === 0 && dy === 0 && dr === 0) {
109
+ origEmit(out, ctx);
110
+ return;
111
+ }
112
+ out.push({ op: "save" });
113
+ out.push({
114
+ op: "transform",
115
+ m: shakeMatrix(node.position(), dx, dy, dr)
116
+ });
117
+ origEmit(out, ctx);
118
+ out.push({ op: "restore" });
119
+ };
120
+ return node;
121
+ }
122
+ //#endregion
123
+ export { shakenSpec as i, shakeMatrix as n, shakeOffset as r, shake as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/scene",
3
- "version": "0.60.0",
3
+ "version": "0.61.0-pre.1",
4
4
  "description": "glissade scene graph: nodes, transforms, DisplayList emission. Renderer-agnostic; zero DOM/Node dependencies.",
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
@@ -81,7 +81,7 @@
81
81
  ],
82
82
  "dependencies": {
83
83
  "yoga-layout": "^3.2.1",
84
- "@glissade/core": "0.60.0"
84
+ "@glissade/core": "0.61.0-pre.1"
85
85
  },
86
86
  "repository": {
87
87
  "type": "git",