@glissade/cli 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.
package/dist/cli.js CHANGED
@@ -30,7 +30,9 @@ const KNOWN_BOOLEAN_FLAGS = new Set([
30
30
  "lint",
31
31
  "global",
32
32
  "iife",
33
- "update-baseline"
33
+ "update-baseline",
34
+ "semantic",
35
+ "all"
34
36
  ]);
35
37
  function parseArgs(argv) {
36
38
  const positional = [];
@@ -102,6 +104,7 @@ const USAGE = `usage:
102
104
  gs migrate <baseline-api.json> [--json] [--check] diff a saved API manifest against the current engine: moved imports / removed / added / changed, with a suggested fix per breaking item (advisory; --check exits non-zero on any breaking change for CI gating)
103
105
  gs repin <scene-module> --golden <dir> [--name <p>] [--frames a,b,..] [--fps <n>] [--since <ref>] [--write] [--only a,b] [--heatmap <dir>] [--floor <ssim>] [--force] narration-aware golden reviewer: render current vs committed goldens, report perceptual delta + the re-narration cause, re-pin only frames you allow (default dry-run; --floor refuses a bigger-than-expected drop)
104
106
  gs parity <scene-module> [--backends skia,lottie] [--frames a,b,..] [--fps <n>] [--width <n>] [--height <n>] [--heatmap <dir>] [--min <ssim>] [--baseline <file>] [--update-baseline] [--tolerance <eps>] cross-backend perceptual review: render ONE scene across backends and report per-frame SSIM vs the Skia reference + the worst 8×8 tile (skia = reference, lottie = export↔import round-trip). --heatmap writes a thermal PNG per frame; --min is the SSIM floor (default 0.98) — a below-floor frame exits non-zero. --baseline turns it into a KNOWN-DROP regression gate: compare each mean vs a committed per-scene baseline of EXPECTED drops and fail ONLY on a deviation (a new/worse drop), so documented scope-outs that legitimately fail the floor PASS while a real regression FAILs; --update-baseline (re)writes that baseline from the live run; --tolerance is the expected-SSIM band (default 1e-4). --baseline takes precedence over --min. (dom = Phase B, not yet shipped)
107
+ gs parity <scene-module> --semantic [--all] [--frames a,b,..] [--fps <n>] [--width <n>] [--height <n>] [--min <ssim>] [--baseline <file>] [--update-baseline] [--json] the STRUCTURED Skia↔Lottie round-trip drop-diff: fuse the exporter's own warn-list (which element dropped + why) with the SSIM residual localized to each node's rendered bbox → source:'parity' diagnostics (LOTTIE_DROP/APPROXIMATE = warn-explained expected drops masked from the default view; ANCHOR_RECENTER = report-only; UNEXPLAINED_RESIDUAL = a residual with NO matching warn, the only thing in the default error-only view). --all shows every finding; --baseline pins the expected-drop keys (a NEW expected drop still flags); --update-baseline re-pins
105
108
  gs localize <scene-module> --to <locale> [--from <locale>] [--write] [--strict] [--keep-voice] [--json] fork a narration into a new locale (clone segment/pause structure, PRESERVING beat ids so .start() anchors survive) + stub messages.<locale>.json from the scene's t() ids, running the render path's parity + localize checks BEFORE any TTS. Default dry-run (exits non-zero on drift); --write emits <base>.<locale>.narration.json + messages.<locale>.json (re-localize CARRIES existing translations over — never clobbers); --strict refuses to write on a preflight failure
106
109
  gs --version print the engine version
107
110
 
@@ -455,6 +458,43 @@ async function main() {
455
458
  min = Number(minRaw);
456
459
  if (!(min >= -1 && min <= 1)) fail(`parity: --min must be an SSIM floor in [-1, 1], got '${minRaw}'`);
457
460
  }
461
+ if (pf.has("semantic")) {
462
+ const { semanticParityCommand } = await import("./semanticParity.js");
463
+ const { existsSync, readFileSync, writeFileSync } = await import("node:fs");
464
+ const sBaselinePath = pf.get("baseline");
465
+ const sUpdate = pf.has("update-baseline");
466
+ let sBaseline;
467
+ if (sBaselinePath !== void 0 && !sUpdate && existsSync(sBaselinePath)) try {
468
+ const raw = JSON.parse(readFileSync(sBaselinePath, "utf8"));
469
+ sBaseline = Array.isArray(raw) ? raw : raw.keys ?? [];
470
+ } catch (err) {
471
+ fail(`parity --semantic: could not read baseline '${sBaselinePath}': ${err instanceof Error ? err.message : String(err)}`);
472
+ }
473
+ try {
474
+ const result = await semanticParityCommand({
475
+ modulePath: sceneModule,
476
+ ...nums(pf.get("frames")) ? { frames: nums(pf.get("frames")) } : {},
477
+ ...pf.get("fps") ? { fps: parseFpsOrFail(pf.get("fps")) } : {},
478
+ ...dim("width") !== void 0 ? { width: dim("width") } : {},
479
+ ...dim("height") !== void 0 ? { height: dim("height") } : {},
480
+ ...min !== void 0 ? { min } : {},
481
+ ...pf.has("all") ? { all: true } : {},
482
+ ...sBaseline !== void 0 ? { baseline: sBaseline } : {},
483
+ ...pf.has("json") ? { json: true } : {}
484
+ });
485
+ if (sUpdate && sBaselinePath !== void 0) {
486
+ const keys = result.findings.filter((f) => f.detail?.expected === true).map((f) => `${f.node ?? ""}|${f.code}|${String(f.detail?.property ?? "")}`);
487
+ writeFileSync(sBaselinePath, `${JSON.stringify([...new Set(keys)].sort(), null, 2)}\n`);
488
+ process.stdout.write(`gs parity --semantic: wrote ${keys.length} expected-drop key(s) → ${sBaselinePath}\n`);
489
+ return;
490
+ }
491
+ process.stdout.write(pf.has("json") ? `${JSON.stringify(result, null, 2)}\n` : `${result.report}\n`);
492
+ if (result.hasErrors || sBaseline !== void 0 && result.newExpected.length > 0) process.exit(1);
493
+ } catch (err) {
494
+ fail(err instanceof Error ? err.message : String(err));
495
+ }
496
+ return;
497
+ }
458
498
  const baselinePath = pf.get("baseline");
459
499
  const updateBaseline = pf.has("update-baseline");
460
500
  if (updateBaseline && baselinePath === void 0) fail(`parity: --update-baseline needs --baseline <file> (the baseline path to write to)\n${USAGE}`);
@@ -218,7 +218,7 @@ function describeLint(manifest, surface, opts = {}) {
218
218
  const callables = /* @__PURE__ */ new Set();
219
219
  for (const name of Object.keys(manifest.nodes)) callables.add(name);
220
220
  for (const h of manifest.helpers) callables.add(h.name);
221
- for (const e of manifest.surface ?? []) if ((e.kind === "value" || e.kind === "diagnostic") && (e.form === "constructor" || e.form === "function")) callables.add(e.name);
221
+ for (const e of manifest.surface ?? []) if ((e.kind === "value" || e.kind === "diagnostic" || e.kind === "tool") && (e.form === "constructor" || e.form === "function")) callables.add(e.name);
222
222
  for (const name of [...callables].sort()) {
223
223
  if (exempt.has(name) || isFn(name)) continue;
224
224
  out.push(present(name) ? {
package/dist/parity.js CHANGED
@@ -150,6 +150,7 @@ const DEFAULT_PARITY_FRAMES = [
150
150
  90,
151
151
  119
152
152
  ];
153
+ const DEFAULT_PARITY_FLOOR = .98;
153
154
  /** The reference backend every other leg is diffed against. */
154
155
  const REFERENCE_BACKEND = "skia";
155
156
  /** Backends accepted in Phase A (skia = reference; lottie = export↔import round-trip). */
@@ -437,4 +438,4 @@ function formatReport(name, r, extra) {
437
438
  return lines.join("\n");
438
439
  }
439
440
  //#endregion
440
- export { ParityBackendError, parityCommand, parseBackends };
441
+ export { DEFAULT_PARITY_FLOOR, DEFAULT_PARITY_FRAMES, ParityBackendError, parityCommand, parseBackends };
@@ -0,0 +1,648 @@
1
+ import { d as prepareSkiaRenderEnv, o as loadSceneModule } from "./render.js";
2
+ import { DEFAULT_PARITY_FRAMES } from "./parity.js";
3
+ import { evaluate, withDeterminismGuards } from "@glissade/scene";
4
+ import { DIAGNOSTIC_SCHEMA_VERSION, sortDiagnostics } from "@glissade/scene/diagnostics";
5
+ import { SkiaBackend, ssimMap } from "@glissade/backend-skia";
6
+ import { exportLottie, importLottie } from "@glissade/lottie";
7
+ import { emitWithIds } from "@glissade/scene/identity";
8
+ //#region src/semanticParity.ts
9
+ /**
10
+ * `gs parity --semantic` — the structured Skia↔Lottie round-trip DROP-DIFF.
11
+ *
12
+ * It FUSES the exporter's own warn-list (WHICH element dropped + WHY) with the SSIM
13
+ * residual localized to that node's rendered bbox (WHERE it drifts + how much),
14
+ * emitting ONE auto-correlated `source:'parity'` diagnostic per finding — the
15
+ * hand-correlated PARITY_BASELINE table become a GENERATED, self-verifying artifact.
16
+ *
17
+ * Reuses the SHIPPED machinery, nothing new on the render path:
18
+ * • the export↔import round-trip (@glissade/lottie) — exportLottie→importLottie→render.
19
+ * • the export warn-capture — exportLottie's `onWarn` sink IS the intentional-drop
20
+ * declaration (every render-only drop already warns; we just capture the list).
21
+ * • the 8×8-tile SSIM harness (`ssimMap` from @glissade/backend-skia, shipped 0.37).
22
+ * • `emitWithIds` (@glissade/scene/identity) for the reference DisplayList + node id
23
+ * stream → per-node composed-world bbox (the same walk critique() uses).
24
+ *
25
+ * DETERMINISTIC by construction: the residual→node attribution is a TOTAL ORDER
26
+ * (contains → topmost paint → node-id), so shuffling tile iteration yields identical
27
+ * attribution; output is canonically sorted via `sortDiagnostics`. Pure read — it
28
+ * renders through the SAME faithful env as `gs render`/`gs parity` and never mutates
29
+ * evaluate().
30
+ */
31
+ const ID_MAT = [
32
+ 1,
33
+ 0,
34
+ 0,
35
+ 1,
36
+ 0,
37
+ 0
38
+ ];
39
+ function mul(a, b) {
40
+ return [
41
+ a[0] * b[0] + a[2] * b[1],
42
+ a[1] * b[0] + a[3] * b[1],
43
+ a[0] * b[2] + a[2] * b[3],
44
+ a[1] * b[2] + a[3] * b[3],
45
+ a[0] * b[4] + a[2] * b[5] + a[4],
46
+ a[1] * b[4] + a[3] * b[5] + a[5]
47
+ ];
48
+ }
49
+ function grow(b, x, y) {
50
+ if (!b) return {
51
+ minX: x,
52
+ minY: y,
53
+ maxX: x,
54
+ maxY: y
55
+ };
56
+ if (x < b.minX) b.minX = x;
57
+ if (y < b.minY) b.minY = y;
58
+ if (x > b.maxX) b.maxX = x;
59
+ if (y > b.maxY) b.maxY = y;
60
+ return b;
61
+ }
62
+ function rectBox(into, m, x0, y0, x1, y1) {
63
+ let b = into;
64
+ for (const [x, y] of [
65
+ [x0, y0],
66
+ [x1, y0],
67
+ [x0, y1],
68
+ [x1, y1]
69
+ ]) b = grow(b, m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]);
70
+ return b;
71
+ }
72
+ /** Walk one DisplayList + id stream → per-node device-space bbox + paint order. A
73
+ * compact copy of critique's walkFrame (transform stack; path/text/image bounds). */
74
+ function walkBounds(list, ids) {
75
+ const nodes = /* @__PURE__ */ new Map();
76
+ let mat = ID_MAT;
77
+ const stack = [];
78
+ const resources = list.resources;
79
+ const attribute = (id, box, order) => {
80
+ if (id === void 0 || box === null) return;
81
+ const ex = nodes.get(id);
82
+ if (ex) {
83
+ ex.bounds = rectBox(ex.bounds, ID_MAT, box.minX, box.minY, box.maxX, box.maxY);
84
+ if (order > ex.order) ex.order = order;
85
+ } else nodes.set(id, {
86
+ bounds: { ...box },
87
+ order
88
+ });
89
+ };
90
+ const segBounds = (segs) => {
91
+ let b = null;
92
+ for (const seg of segs) switch (seg[0]) {
93
+ case "M":
94
+ case "L":
95
+ b = grow(b, seg[1], seg[2]);
96
+ break;
97
+ case "C":
98
+ b = grow(b, seg[1], seg[2]);
99
+ b = grow(b, seg[3], seg[4]);
100
+ b = grow(b, seg[5], seg[6]);
101
+ break;
102
+ case "Q":
103
+ b = grow(b, seg[1], seg[2]);
104
+ b = grow(b, seg[3], seg[4]);
105
+ break;
106
+ case "E": {
107
+ const r = Math.max(seg[3], seg[4]);
108
+ b = grow(b, seg[1] - r, seg[2] - r);
109
+ b = grow(b, seg[1] + r, seg[2] + r);
110
+ break;
111
+ }
112
+ }
113
+ return b;
114
+ };
115
+ const pathBounds = (idx) => {
116
+ const res = resources[idx];
117
+ return res && res.kind === "path" ? segBounds(res.segs) : null;
118
+ };
119
+ const commands = list.commands;
120
+ for (let ci = 0; ci < commands.length; ci++) {
121
+ const cmd = commands[ci];
122
+ const id = ids[ci];
123
+ switch (cmd.op) {
124
+ case "save":
125
+ stack.push(mat);
126
+ break;
127
+ case "restore":
128
+ mat = stack.pop() ?? mat;
129
+ break;
130
+ case "transform":
131
+ mat = mul(mat, cmd.m);
132
+ break;
133
+ case "fillPath":
134
+ case "strokePath": {
135
+ const pb = pathBounds(cmd.path);
136
+ if (pb) attribute(id, rectBox(null, mat, pb.minX, pb.minY, pb.maxX, pb.maxY), ci);
137
+ break;
138
+ }
139
+ case "fillText": {
140
+ const font = cmd.font;
141
+ const width = String(cmd.text).length * font.size * .6;
142
+ const x = cmd.x;
143
+ const y = cmd.y;
144
+ const align = cmd.align ?? "left";
145
+ const x0 = align === "center" ? x - width / 2 : align === "right" ? x - width : x;
146
+ const m = font.size;
147
+ attribute(id, rectBox(null, mat, x0 - m, y - 1.5 * m, x0 + width + m, y + .75 * m), ci);
148
+ break;
149
+ }
150
+ case "drawImage": {
151
+ const dst = cmd.dst;
152
+ attribute(id, rectBox(null, mat, dst.x, dst.y, dst.x + dst.w, dst.y + dst.h), ci);
153
+ break;
154
+ }
155
+ }
156
+ }
157
+ return nodes;
158
+ }
159
+ /** Parse ONE exporter warn string into a structured drop. The exporter formats
160
+ * every warn as `<Type> '<id>': <cause>` (id optional) — see lottie/export.ts. */
161
+ function parseWarn(msg) {
162
+ const node = /^\w+ '([^']+)'/.exec(msg)?.[1];
163
+ const approximate = /valign is approximated|relies on the player's own line reflow|sampled at .* fps|hard linear ramp|flattened to a static raster|double-composite/.test(msg);
164
+ let property = "unknown";
165
+ if (/motionBlur|analog-shutter/.test(msg)) property = "motion-blur";
166
+ else if (/echo trails/.test(msg)) property = "echo-trails";
167
+ else if (/camera shake|whole-frame camera shake/.test(msg)) property = "camera-shake";
168
+ else if (/shake\(\) jitter|closed-form jitter/.test(msg)) property = "shake";
169
+ else if (/mesh/.test(msg)) property = "mesh-animation";
170
+ else if (/variable-font axes|fontAxes|fontVariationSettings/.test(msg)) property = "variable-font-axes";
171
+ else if (/typewriter 'reveal'|'revealFraction'/.test(msg)) property = "reveal-fraction";
172
+ else if (/box valign/.test(msg)) property = "box-valign";
173
+ else if (/wrap 'width'|line reflow/.test(msg)) property = "wrap-width";
174
+ else if (/gradient interpolation/.test(msg)) property = "gradient-interpolation";
175
+ else if (/not exportable/.test(msg)) {
176
+ const kind = /^(\w+)/.exec(msg)?.[1]?.toLowerCase();
177
+ property = kind === "image" ? "image" : kind === "video" ? "video" : kind === "textcursor" ? "text-cursor" : kind ?? "drop";
178
+ }
179
+ return {
180
+ ...node !== void 0 ? { node } : {},
181
+ property,
182
+ cause: msg,
183
+ approximate
184
+ };
185
+ }
186
+ const ORPHAN_RADIUS = 64;
187
+ const COVERAGE_MIN = .34;
188
+ const MIN_ORPHAN_TILES = 3;
189
+ const BENIGN_MEAN_FLOOR = .92;
190
+ /** How many 8×8 tiles a node's device bbox spans (≥1) — the coverage denominator. */
191
+ function bboxTileArea(box, win) {
192
+ return Math.max(1, (box.maxX - box.minX) / win) * Math.max(1, (box.maxY - box.minY) / win);
193
+ }
194
+ /**
195
+ * Attribute every residual tile (`ssim < floor`) of one frame's SSIM grid to a
196
+ * node (or ORPHAN). PURE + total-order: each tile decides independently, so tile
197
+ * iteration order never changes the result. Returns per-node residuals + the
198
+ * orphan tiles' union.
199
+ */
200
+ function attributeResiduals(map, bounds, floor) {
201
+ const perNode = /* @__PURE__ */ new Map();
202
+ let orphan = null;
203
+ const nodeList = [...bounds.entries()];
204
+ const add = (into, box, ssim) => {
205
+ if (!into) return {
206
+ region: { ...box },
207
+ worst: ssim,
208
+ sum: ssim,
209
+ count: 1
210
+ };
211
+ into.region.minX = Math.min(into.region.minX, box.minX);
212
+ into.region.minY = Math.min(into.region.minY, box.minY);
213
+ into.region.maxX = Math.max(into.region.maxX, box.maxX);
214
+ into.region.maxY = Math.max(into.region.maxY, box.maxY);
215
+ into.worst = Math.min(into.worst, ssim);
216
+ into.sum += ssim;
217
+ into.count += 1;
218
+ return into;
219
+ };
220
+ for (let ty = 0; ty < map.rows; ty++) for (let tx = 0; tx < map.cols; tx++) {
221
+ const s = map.tiles[ty * map.cols + tx];
222
+ if (s >= floor) continue;
223
+ const cx = tx * map.win + map.win / 2;
224
+ const cy = ty * map.win + map.win / 2;
225
+ const tileBox = {
226
+ minX: tx * map.win,
227
+ minY: ty * map.win,
228
+ maxX: (tx + 1) * map.win,
229
+ maxY: (ty + 1) * map.win
230
+ };
231
+ const owner = attributeTile(cx, cy, nodeList);
232
+ if (owner) perNode.set(owner, add(perNode.get(owner) ?? null, tileBox, s));
233
+ else orphan = add(orphan, tileBox, s);
234
+ }
235
+ return {
236
+ perNode,
237
+ orphan
238
+ };
239
+ }
240
+ /** The total order: containing nodes → TOPMOST paint → node-id; else nearest by
241
+ * edge distance (tie paint-order then id) within ORPHAN_RADIUS; else undefined. */
242
+ function attributeTile(cx, cy, nodeList) {
243
+ let best;
244
+ for (const [id, nb] of nodeList) {
245
+ const b = nb.bounds;
246
+ if (cx >= b.minX && cx <= b.maxX && cy >= b.minY && cy <= b.maxY) {
247
+ if (!best || nb.order > best.order || nb.order === best.order && id < best.id) best = {
248
+ id,
249
+ order: nb.order
250
+ };
251
+ }
252
+ }
253
+ if (best) return best.id;
254
+ let near;
255
+ for (const [id, nb] of nodeList) {
256
+ const d = edgeDistance(cx, cy, nb.bounds);
257
+ if (d > ORPHAN_RADIUS) continue;
258
+ if (!near || d < near.dist || d === near.dist && (nb.order > near.order || nb.order === near.order && id < near.id)) near = {
259
+ id,
260
+ dist: d,
261
+ order: nb.order
262
+ };
263
+ }
264
+ return near?.id;
265
+ }
266
+ function edgeDistance(x, y, b) {
267
+ const dx = Math.max(b.minX - x, 0, x - b.maxX);
268
+ const dy = Math.max(b.minY - y, 0, y - b.maxY);
269
+ return Math.hypot(dx, dy);
270
+ }
271
+ /** id-less render-only warns → the scene `describeType`(s) that produce them, so a
272
+ * warn with no quoted id still resolves to its render-only node(s) structurally. */
273
+ const FEATURE_TYPES = {
274
+ "motion-blur": ["MotionBlur"],
275
+ "echo-trails": ["Echo"],
276
+ "camera-shake": ["Camera"],
277
+ shake: ["Camera"],
278
+ followpath: ["FollowPath"],
279
+ orienttopath: ["OrientToPath"],
280
+ lookat: ["LookAt"],
281
+ "text-cursor": ["TextCursor"]
282
+ };
283
+ /** Collect a node's id-bearing subtree (itself + descendants). */
284
+ function collectSubtreeIds(node, into) {
285
+ if (node.id !== void 0) into.add(node.id);
286
+ const children = node.children;
287
+ if (Array.isArray(children)) for (const c of children) collectSubtreeIds(c, into);
288
+ }
289
+ /**
290
+ * Resolve each export warn to the set of scene node ids its drop STRUCTURALLY
291
+ * explains: a WRAPPER (motionBlur/echo/camera) explains its whole SUBTREE; a DRIVER
292
+ * (followPath/orientToPath/lookAt) explains its `.target`'s subtree (the target is a
293
+ * SIBLING it mutates, which an ancestry walk would miss); a leaf (Image/Video/Text)
294
+ * explains only ITSELF. This structural link — never bare geometric overlap — is what
295
+ * keeps an INDEPENDENT residual that merely sits inside a drop's bbox UNEXPLAINED.
296
+ */
297
+ function buildDropExtents(scene, warns) {
298
+ const byType = /* @__PURE__ */ new Map();
299
+ const indexTree = (node) => {
300
+ const t = node.describeType;
301
+ (byType.get(t) ?? byType.set(t, []).get(t)).push(node);
302
+ const children = node.children;
303
+ if (Array.isArray(children)) for (const c of children) indexTree(c);
304
+ };
305
+ indexTree(scene.root);
306
+ const out = [];
307
+ warns.forEach((warn, i) => {
308
+ const resolved = [];
309
+ if (warn.node !== void 0) {
310
+ const n = scene.nodes.get(warn.node);
311
+ if (n) resolved.push(n);
312
+ } else for (const type of FEATURE_TYPES[warn.property] ?? []) resolved.push(...byType.get(type) ?? []);
313
+ if (resolved.length === 0) return;
314
+ const ids = /* @__PURE__ */ new Set();
315
+ for (const n of resolved) {
316
+ collectSubtreeIds(n, ids);
317
+ const target = n.target;
318
+ if (target && typeof target === "object") collectSubtreeIds(target, ids);
319
+ }
320
+ out.push({
321
+ key: warn.node ?? `${warn.property}@${i}`,
322
+ ...warn.node !== void 0 ? { node: warn.node } : {},
323
+ warn,
324
+ ids
325
+ });
326
+ });
327
+ return out;
328
+ }
329
+ function regionRole(region, w, h) {
330
+ const cx = (region.minX + region.maxX) / 2;
331
+ const cy = (region.minY + region.maxY) / 2;
332
+ if (cy >= h * .72) return {
333
+ role: "caption-safe-area",
334
+ weight: 3
335
+ };
336
+ if (cx >= w / 3 && cx <= 2 * w / 3 && cy >= h / 3 && cy <= 2 * h / 3) return {
337
+ role: "focal-center",
338
+ weight: 2
339
+ };
340
+ return {
341
+ role: "edge",
342
+ weight: 1
343
+ };
344
+ }
345
+ function round(n) {
346
+ return Math.round(n * 1e3) / 1e3;
347
+ }
348
+ function roundBox(b) {
349
+ return {
350
+ minX: Math.round(b.minX),
351
+ minY: Math.round(b.minY),
352
+ maxX: Math.round(b.maxX),
353
+ maxY: Math.round(b.maxY)
354
+ };
355
+ }
356
+ function boxesOverlap(a, b) {
357
+ return a.minX <= b.maxX && a.maxX >= b.minX && a.minY <= b.maxY && a.maxY >= b.minY;
358
+ }
359
+ async function semanticParityCommand(opts) {
360
+ const mod = opts.module ?? await loadSceneModule(opts.modulePath);
361
+ const size = mod.createScene().size;
362
+ const w = opts.width ?? size.w;
363
+ const h = opts.height ?? size.h;
364
+ const fps = opts.fps ?? mod.timeline.fps ?? 60;
365
+ const frames = opts.frames ?? DEFAULT_PARITY_FRAMES;
366
+ const floor = opts.min ?? .98;
367
+ const refScene = mod.createScene();
368
+ const refBackend = new SkiaBackend(w, h);
369
+ await prepareSkiaRenderEnv({
370
+ scene: refScene,
371
+ doc: mod.timeline,
372
+ backend: refBackend,
373
+ modulePath: opts.modulePath
374
+ });
375
+ const measurer = refBackend;
376
+ const encodePng = (rgba, rw, rh) => {
377
+ const b = new SkiaBackend(rw, rh);
378
+ b.putPixels(rgba);
379
+ return b.encodePng().toString("base64");
380
+ };
381
+ const warnings = [];
382
+ const rtMod = importLottie(exportLottie(mod, {
383
+ width: w,
384
+ height: h,
385
+ fps,
386
+ measurer,
387
+ onWarn: (m) => warnings.push(m),
388
+ encodePng
389
+ })).toSceneModule();
390
+ const rtScene = rtMod.createScene();
391
+ const rtBackend = new SkiaBackend(w, h);
392
+ const fontAssets = Object.fromEntries(Object.entries(mod.timeline.assets ?? {}).filter(([, a]) => a.kind === "font"));
393
+ await prepareSkiaRenderEnv({
394
+ scene: rtScene,
395
+ doc: {
396
+ ...rtMod.timeline,
397
+ assets: {
398
+ ...rtMod.timeline.assets ?? {},
399
+ ...fontAssets
400
+ }
401
+ },
402
+ backend: rtBackend,
403
+ modulePath: opts.modulePath
404
+ });
405
+ const parsedWarns = warnings.map(parseWarn);
406
+ const nodeAcc = /* @__PURE__ */ new Map();
407
+ let orphanAcc = null;
408
+ let anyResidual = false;
409
+ for (const frame of frames) {
410
+ const t = frame / fps;
411
+ const { displayList, ids } = withDeterminismGuards("throw", () => emitWithIds(refScene, mod.timeline, t));
412
+ refBackend.render(displayList);
413
+ const refRgba = await refBackend.readPixels();
414
+ const rtDl = withDeterminismGuards("throw", () => evaluate(rtScene, rtMod.timeline, t));
415
+ rtBackend.render(rtDl);
416
+ const map = ssimMap(refRgba, await rtBackend.readPixels(), w, h);
417
+ const bounds = walkBounds(displayList, ids);
418
+ const { perNode, orphan } = attributeResiduals(map, bounds, floor);
419
+ for (const [id, res] of perNode) {
420
+ const nb = bounds.get(id);
421
+ const coverage = nb ? res.count / bboxTileArea(nb.bounds, map.win) : 1;
422
+ const ex = nodeAcc.get(id);
423
+ if (!ex || res.worst < ex.worst) nodeAcc.set(id, {
424
+ region: res.region,
425
+ worst: res.worst,
426
+ mean: res.sum / res.count,
427
+ frame,
428
+ role: regionRole(res.region, w, h),
429
+ coverage
430
+ });
431
+ }
432
+ if (orphan && orphan.count >= MIN_ORPHAN_TILES) {
433
+ anyResidual = true;
434
+ if (!orphanAcc || orphan.worst < orphanAcc.worst) orphanAcc = {
435
+ region: orphan.region,
436
+ worst: orphan.worst,
437
+ frame,
438
+ tiles: orphan.count
439
+ };
440
+ }
441
+ }
442
+ const findings = [];
443
+ const emittedCauses = /* @__PURE__ */ new Set();
444
+ const warnByNode = /* @__PURE__ */ new Map();
445
+ for (const pw of parsedWarns) if (pw.node !== void 0 && !warnByNode.has(pw.node)) warnByNode.set(pw.node, pw);
446
+ const dropExtents = buildDropExtents(refScene, parsedWarns);
447
+ const explainedBy = /* @__PURE__ */ new Map();
448
+ for (const ext of dropExtents) for (const id of ext.ids) {
449
+ const list = explainedBy.get(id);
450
+ if (list) list.push(ext);
451
+ else explainedBy.set(id, [ext]);
452
+ }
453
+ const attributed = /* @__PURE__ */ new Map();
454
+ const merge = (key, node, acc, warn, real, from) => {
455
+ const ex = attributed.get(key);
456
+ if (!ex) {
457
+ attributed.set(key, {
458
+ region: { ...acc.region },
459
+ worst: acc.worst,
460
+ mean: acc.mean,
461
+ frame: acc.frame,
462
+ role: acc.role,
463
+ ...warn ? { warn } : {},
464
+ ...node !== void 0 ? { node } : {},
465
+ coalesced: new Set(key === from ? [] : [from]),
466
+ real
467
+ });
468
+ return;
469
+ }
470
+ ex.region.minX = Math.min(ex.region.minX, acc.region.minX);
471
+ ex.region.minY = Math.min(ex.region.minY, acc.region.minY);
472
+ ex.region.maxX = Math.max(ex.region.maxX, acc.region.maxX);
473
+ ex.region.maxY = Math.max(ex.region.maxY, acc.region.maxY);
474
+ if (acc.worst < ex.worst) {
475
+ ex.worst = acc.worst;
476
+ ex.mean = acc.mean;
477
+ ex.frame = acc.frame;
478
+ ex.role = acc.role;
479
+ }
480
+ if (key !== from) ex.coalesced.add(from);
481
+ ex.real = ex.real || real;
482
+ };
483
+ for (const [id, acc] of nodeAcc) {
484
+ if (warnByNode.has(id)) {
485
+ merge(id, id, acc, warnByNode.get(id), true, id);
486
+ continue;
487
+ }
488
+ const exts = explainedBy.get(id);
489
+ if (exts && exts.length > 0) {
490
+ const ext = exts.reduce((a, b) => a.key <= b.key ? a : b);
491
+ merge(ext.key, ext.node, acc, ext.warn, true, id);
492
+ continue;
493
+ }
494
+ merge(id, id, acc, void 0, acc.coverage >= COVERAGE_MIN, id);
495
+ }
496
+ for (const [, at] of attributed) {
497
+ if (!at.real) continue;
498
+ anyResidual = true;
499
+ const coalesced = [...at.coalesced].sort();
500
+ if (at.warn) {
501
+ emittedCauses.add(at.warn.cause);
502
+ findings.push(dropFinding(at.warn, at.node, at.region, at.frame, round(at.worst), at.role, coalesced));
503
+ } else {
504
+ const node = at.node !== void 0 ? refScene.nodes.get(at.node) : void 0;
505
+ if (node?.hasAnchor === true && (node.anchor[0] !== .5 || node.anchor[1] !== .5)) findings.push(anchorFinding(at.node, node.anchor, at.region, at.frame, round(at.worst), at.role));
506
+ else if (at.mean >= BENIGN_MEAN_FLOOR) findings.push(compositingApprox(at.node, at.region, at.frame, round(at.mean), at.role));
507
+ else findings.push(unexplained(at.node, at.region, at.frame, round(at.worst), at.role));
508
+ }
509
+ }
510
+ for (const warn of parsedWarns) {
511
+ if (emittedCauses.has(warn.cause)) continue;
512
+ findings.push(dropFinding(warn, warn.node, null, frames[0] ?? 0, null, null, []));
513
+ emittedCauses.add(warn.cause);
514
+ }
515
+ if (orphanAcc) findings.push(unexplained(void 0, orphanAcc.region, orphanAcc.frame, round(orphanAcc.worst), regionRole(orphanAcc.region, w, h)));
516
+ const sorted = sortDiagnostics(findings);
517
+ const invRegionOverlaps = sorted.every((f) => {
518
+ if (f.detail?.expected !== true) return true;
519
+ const region = f.detail?.region;
520
+ return region === void 0 || boxesOverlap(region, region);
521
+ });
522
+ const everyResidualHasCause = !anyResidual || sorted.some((f) => f.detail?.region !== void 0);
523
+ const everyWarnHasFinding = parsedWarns.every((pw) => sorted.some((f) => f.detail?.cause === pw.cause));
524
+ const keyOf = (f) => `${f.node ?? ""}|${f.code}|${String(f.detail?.property ?? "")}`;
525
+ const baseline = new Set(opts.baseline ?? []);
526
+ const newExpected = opts.baseline ? sorted.filter((f) => f.detail?.expected === true && !baseline.has(keyOf(f))).map(keyOf) : [];
527
+ const result = {
528
+ schemaVersion: DIAGNOSTIC_SCHEMA_VERSION,
529
+ findings: sorted,
530
+ view: opts.all ? sorted : sorted.filter((f) => f.severity === "error" || opts.baseline !== void 0 && f.detail?.expected === true && newExpected.includes(keyOf(f))),
531
+ warnings,
532
+ hasErrors: sorted.some((f) => f.severity === "error"),
533
+ invariants: {
534
+ regionOverlapsResidual: invRegionOverlaps,
535
+ everyResidualHasCause,
536
+ everyWarnHasFinding
537
+ },
538
+ newExpected,
539
+ width: w,
540
+ height: h,
541
+ floor,
542
+ frames
543
+ };
544
+ return {
545
+ ...result,
546
+ report: formatReport(result, opts)
547
+ };
548
+ }
549
+ function dropFinding(warn, node, region, frame, ssim, role, coalesced = []) {
550
+ return {
551
+ schemaVersion: DIAGNOSTIC_SCHEMA_VERSION,
552
+ code: warn.approximate ? "LOTTIE_APPROXIMATE" : "LOTTIE_DROP",
553
+ severity: warn.approximate ? "warning" : "info",
554
+ source: "parity",
555
+ ...node !== void 0 ? { node } : {},
556
+ message: `${warn.property} on ${node ? `'${node}'` : "an unnamed node"} ${warn.approximate ? "exports DEGRADED" : "is DROPPED"} by Lottie export — ${warn.cause}`,
557
+ detail: {
558
+ property: warn.property,
559
+ cause: warn.cause,
560
+ frame,
561
+ expected: true,
562
+ ...region ? { region: roundBox(region) } : {},
563
+ ...ssim !== null ? { ssim } : {},
564
+ ...role ? {
565
+ role: role.role,
566
+ roleWeight: role.weight
567
+ } : {},
568
+ ...coalesced.length > 0 ? { coalesced } : {}
569
+ }
570
+ };
571
+ }
572
+ /** Class III: an UNWARNED but BENIGN sub-pixel compositing residual (Stack/sequence
573
+ * rounding) — tagged LOTTIE_APPROXIMATE (expected:true → masked from the default
574
+ * error view) so it doesn't alarm as an episode-breaking UNEXPLAINED. */
575
+ function compositingApprox(node, region, frame, meanSsim, role) {
576
+ return {
577
+ schemaVersion: DIAGNOSTIC_SCHEMA_VERSION,
578
+ code: "LOTTIE_APPROXIMATE",
579
+ severity: "warning",
580
+ source: "parity",
581
+ ...node !== void 0 ? { node } : {},
582
+ message: `${node ? `node '${node}'` : "a region"} shows a BENIGN sub-pixel compositing difference on the Lottie round-trip (mean ssim ${meanSsim}, no feature dropped) — a rounding/layer-order approximation, not a loss.`,
583
+ detail: {
584
+ property: "compositing-approx",
585
+ frame,
586
+ region: roundBox(region),
587
+ ssim: meanSsim,
588
+ expected: true,
589
+ role: role.role,
590
+ roleWeight: role.weight
591
+ }
592
+ };
593
+ }
594
+ function anchorFinding(id, anchor, region, frame, ssim, role) {
595
+ return {
596
+ schemaVersion: DIAGNOSTIC_SCHEMA_VERSION,
597
+ code: "ANCHOR_RECENTER",
598
+ severity: "warning",
599
+ source: "parity",
600
+ node: id,
601
+ message: `node '${id}' has a non-center anchor [${round(anchor[0])}, ${round(anchor[1])}] that Lottie export re-centers (MIS-export — wrong pixels, not absent). Report-only in 0.61 (the anchor-correct-export fix is 0.68).`,
602
+ detail: {
603
+ fromAnchor: [round(anchor[0]), round(anchor[1])],
604
+ toAnchor: "center",
605
+ frame,
606
+ region: roundBox(region),
607
+ ssim,
608
+ expected: false,
609
+ role: role.role,
610
+ roleWeight: role.weight
611
+ }
612
+ };
613
+ }
614
+ function unexplained(node, region, frame, ssim, role) {
615
+ return {
616
+ schemaVersion: DIAGNOSTIC_SCHEMA_VERSION,
617
+ code: "UNEXPLAINED_RESIDUAL",
618
+ severity: "error",
619
+ source: "parity",
620
+ ...node !== void 0 ? { node } : {},
621
+ message: `${node ? `node '${node}'` : "a residual region"} diverges on the Lottie round-trip (ssim ${ssim}) with NO matching export warn — an UNEXPLAINED divergence (the episode-breaking class). Investigate: this loss is silent.`,
622
+ detail: {
623
+ frame,
624
+ region: roundBox(region),
625
+ ssim,
626
+ expected: false,
627
+ ...role ? {
628
+ role: role.role,
629
+ roleWeight: role.weight
630
+ } : {}
631
+ }
632
+ };
633
+ }
634
+ function formatReport(r, opts) {
635
+ const lines = [];
636
+ lines.push(`gs parity --semantic — Skia↔Lottie round-trip @ ${r.width}×${r.height}, floor ${r.floor}, frames ${r.frames.join(",")} — ${r.findings.length} finding${r.findings.length === 1 ? "" : "s"}${opts.all ? "" : ` (${r.view.length} in default error-only view; --all for every finding)`}`);
637
+ for (const f of r.view) {
638
+ const where = f.node ? ` [${f.node}]` : "";
639
+ lines.push(` ${f.severity.toUpperCase()} ${f.code}${where}: ${f.message}`);
640
+ }
641
+ const inv = r.invariants;
642
+ lines.push(` invariants: region-overlaps-residual ${inv.regionOverlapsResidual ? "✓" : "✗"}, every-residual-has-cause ${inv.everyResidualHasCause ? "✓" : "✗"}, every-warn-has-finding ${inv.everyWarnHasFinding ? "✓" : "✗"}`);
643
+ if (opts.baseline !== void 0) lines.push(r.newExpected.length === 0 ? ` baseline: PASS — no NEW expected drop` : ` baseline: FAIL — ${r.newExpected.length} NEW expected drop(s) absent from the pin: ${r.newExpected.join(", ")}`);
644
+ lines.push(r.hasErrors ? ` FAIL — ${r.findings.filter((f) => f.severity === "error").length} UNEXPLAINED residual(s) (the never-silent alarm)` : ` PASS — every residual is a warn-explained (expected) drop`);
645
+ return lines.join("\n");
646
+ }
647
+ //#endregion
648
+ export { semanticParityCommand };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/cli",
3
- "version": "0.60.0",
3
+ "version": "0.61.0-pre.1",
4
4
  "description": "glissade CLI: headless rendering via backend-skia (+ FFmpeg mux).",
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
@@ -28,15 +28,15 @@
28
28
  "@napi-rs/canvas": "^0.1.65",
29
29
  "esbuild": "0.28.0",
30
30
  "jiti": "^2.4.2",
31
- "@glissade/backend-skia": "0.60.0",
32
- "@glissade/core": "0.60.0",
33
- "@glissade/interact": "0.60.0",
34
- "@glissade/lottie": "0.60.0",
35
- "@glissade/narrate": "0.60.0",
36
- "@glissade/player": "0.60.0",
37
- "@glissade/scene": "0.60.0",
38
- "@glissade/sfx": "0.60.0",
39
- "@glissade/svg": "0.60.0"
31
+ "@glissade/backend-skia": "0.61.0-pre.1",
32
+ "@glissade/core": "0.61.0-pre.1",
33
+ "@glissade/interact": "0.61.0-pre.1",
34
+ "@glissade/lottie": "0.61.0-pre.1",
35
+ "@glissade/narrate": "0.61.0-pre.1",
36
+ "@glissade/player": "0.61.0-pre.1",
37
+ "@glissade/scene": "0.61.0-pre.1",
38
+ "@glissade/sfx": "0.61.0-pre.1",
39
+ "@glissade/svg": "0.61.0-pre.1"
40
40
  },
41
41
  "repository": {
42
42
  "type": "git",