@glissade/cli 0.60.0-pre.1 → 0.61.0-pre.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,559 @@
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
+ /** How many 8×8 tiles a node's device bbox spans (≥1) — the coverage denominator. */
190
+ function bboxTileArea(box, win) {
191
+ return Math.max(1, (box.maxX - box.minX) / win) * Math.max(1, (box.maxY - box.minY) / win);
192
+ }
193
+ /**
194
+ * Attribute every residual tile (`ssim < floor`) of one frame's SSIM grid to a
195
+ * node (or ORPHAN). PURE + total-order: each tile decides independently, so tile
196
+ * iteration order never changes the result. Returns per-node residuals + the
197
+ * orphan tiles' union.
198
+ */
199
+ function attributeResiduals(map, bounds, floor) {
200
+ const perNode = /* @__PURE__ */ new Map();
201
+ let orphan = null;
202
+ const nodeList = [...bounds.entries()];
203
+ const add = (into, box, ssim) => {
204
+ if (!into) return {
205
+ region: { ...box },
206
+ worst: ssim,
207
+ sum: ssim,
208
+ count: 1
209
+ };
210
+ into.region.minX = Math.min(into.region.minX, box.minX);
211
+ into.region.minY = Math.min(into.region.minY, box.minY);
212
+ into.region.maxX = Math.max(into.region.maxX, box.maxX);
213
+ into.region.maxY = Math.max(into.region.maxY, box.maxY);
214
+ into.worst = Math.min(into.worst, ssim);
215
+ into.sum += ssim;
216
+ into.count += 1;
217
+ return into;
218
+ };
219
+ for (let ty = 0; ty < map.rows; ty++) for (let tx = 0; tx < map.cols; tx++) {
220
+ const s = map.tiles[ty * map.cols + tx];
221
+ if (s >= floor) continue;
222
+ const cx = tx * map.win + map.win / 2;
223
+ const cy = ty * map.win + map.win / 2;
224
+ const tileBox = {
225
+ minX: tx * map.win,
226
+ minY: ty * map.win,
227
+ maxX: (tx + 1) * map.win,
228
+ maxY: (ty + 1) * map.win
229
+ };
230
+ const owner = attributeTile(cx, cy, nodeList);
231
+ if (owner) perNode.set(owner, add(perNode.get(owner) ?? null, tileBox, s));
232
+ else orphan = add(orphan, tileBox, s);
233
+ }
234
+ return {
235
+ perNode,
236
+ orphan
237
+ };
238
+ }
239
+ /** The total order: containing nodes → TOPMOST paint → node-id; else nearest by
240
+ * edge distance (tie paint-order then id) within ORPHAN_RADIUS; else undefined. */
241
+ function attributeTile(cx, cy, nodeList) {
242
+ let best;
243
+ for (const [id, nb] of nodeList) {
244
+ const b = nb.bounds;
245
+ if (cx >= b.minX && cx <= b.maxX && cy >= b.minY && cy <= b.maxY) {
246
+ if (!best || nb.order > best.order || nb.order === best.order && id < best.id) best = {
247
+ id,
248
+ order: nb.order
249
+ };
250
+ }
251
+ }
252
+ if (best) return best.id;
253
+ let near;
254
+ for (const [id, nb] of nodeList) {
255
+ const d = edgeDistance(cx, cy, nb.bounds);
256
+ if (d > ORPHAN_RADIUS) continue;
257
+ if (!near || d < near.dist || d === near.dist && (nb.order > near.order || nb.order === near.order && id < near.id)) near = {
258
+ id,
259
+ dist: d,
260
+ order: nb.order
261
+ };
262
+ }
263
+ return near?.id;
264
+ }
265
+ function edgeDistance(x, y, b) {
266
+ const dx = Math.max(b.minX - x, 0, x - b.maxX);
267
+ const dy = Math.max(b.minY - y, 0, y - b.maxY);
268
+ return Math.hypot(dx, dy);
269
+ }
270
+ /** Walk `id`'s ided ancestor chain; return the nearest ancestor a DROP warn names
271
+ * (a render-only wrapper — camera/shake/motionBlur/echo — whose drop explains this
272
+ * descendant's divergence), so the residual coalesces to ONE finding at that drop
273
+ * instead of N unexplained leaves. Deterministic (the tree is fixed). */
274
+ function nearestWarnedAncestor(scene, id, warnByNode) {
275
+ let p = scene.nodes.get(id)?.parent ?? null;
276
+ while (p) {
277
+ if (p.id !== void 0 && warnByNode.has(p.id)) return {
278
+ id: p.id,
279
+ warn: warnByNode.get(p.id)
280
+ };
281
+ p = p.parent;
282
+ }
283
+ }
284
+ function regionRole(region, w, h) {
285
+ const cx = (region.minX + region.maxX) / 2;
286
+ const cy = (region.minY + region.maxY) / 2;
287
+ if (cy >= h * .72) return {
288
+ role: "caption-safe-area",
289
+ weight: 3
290
+ };
291
+ if (cx >= w / 3 && cx <= 2 * w / 3 && cy >= h / 3 && cy <= 2 * h / 3) return {
292
+ role: "focal-center",
293
+ weight: 2
294
+ };
295
+ return {
296
+ role: "edge",
297
+ weight: 1
298
+ };
299
+ }
300
+ function round(n) {
301
+ return Math.round(n * 1e3) / 1e3;
302
+ }
303
+ function roundBox(b) {
304
+ return {
305
+ minX: Math.round(b.minX),
306
+ minY: Math.round(b.minY),
307
+ maxX: Math.round(b.maxX),
308
+ maxY: Math.round(b.maxY)
309
+ };
310
+ }
311
+ function boxesOverlap(a, b) {
312
+ return a.minX <= b.maxX && a.maxX >= b.minX && a.minY <= b.maxY && a.maxY >= b.minY;
313
+ }
314
+ async function semanticParityCommand(opts) {
315
+ const mod = opts.module ?? await loadSceneModule(opts.modulePath);
316
+ const size = mod.createScene().size;
317
+ const w = opts.width ?? size.w;
318
+ const h = opts.height ?? size.h;
319
+ const fps = opts.fps ?? mod.timeline.fps ?? 60;
320
+ const frames = opts.frames ?? DEFAULT_PARITY_FRAMES;
321
+ const floor = opts.min ?? .98;
322
+ const refScene = mod.createScene();
323
+ const refBackend = new SkiaBackend(w, h);
324
+ await prepareSkiaRenderEnv({
325
+ scene: refScene,
326
+ doc: mod.timeline,
327
+ backend: refBackend,
328
+ modulePath: opts.modulePath
329
+ });
330
+ const measurer = refBackend;
331
+ const encodePng = (rgba, rw, rh) => {
332
+ const b = new SkiaBackend(rw, rh);
333
+ b.putPixels(rgba);
334
+ return b.encodePng().toString("base64");
335
+ };
336
+ const warnings = [];
337
+ const rtMod = importLottie(exportLottie(mod, {
338
+ width: w,
339
+ height: h,
340
+ fps,
341
+ measurer,
342
+ onWarn: (m) => warnings.push(m),
343
+ encodePng
344
+ })).toSceneModule();
345
+ const rtScene = rtMod.createScene();
346
+ const rtBackend = new SkiaBackend(w, h);
347
+ const fontAssets = Object.fromEntries(Object.entries(mod.timeline.assets ?? {}).filter(([, a]) => a.kind === "font"));
348
+ await prepareSkiaRenderEnv({
349
+ scene: rtScene,
350
+ doc: {
351
+ ...rtMod.timeline,
352
+ assets: {
353
+ ...rtMod.timeline.assets ?? {},
354
+ ...fontAssets
355
+ }
356
+ },
357
+ backend: rtBackend,
358
+ modulePath: opts.modulePath
359
+ });
360
+ const parsedWarns = warnings.map(parseWarn);
361
+ const nodeAcc = /* @__PURE__ */ new Map();
362
+ let orphanAcc = null;
363
+ let anyResidual = false;
364
+ for (const frame of frames) {
365
+ const t = frame / fps;
366
+ const { displayList, ids } = withDeterminismGuards("throw", () => emitWithIds(refScene, mod.timeline, t));
367
+ refBackend.render(displayList);
368
+ const refRgba = await refBackend.readPixels();
369
+ const rtDl = withDeterminismGuards("throw", () => evaluate(rtScene, rtMod.timeline, t));
370
+ rtBackend.render(rtDl);
371
+ const map = ssimMap(refRgba, await rtBackend.readPixels(), w, h);
372
+ const bounds = walkBounds(displayList, ids);
373
+ const { perNode, orphan } = attributeResiduals(map, bounds, floor);
374
+ for (const [id, res] of perNode) {
375
+ const nb = bounds.get(id);
376
+ const coverage = nb ? res.count / bboxTileArea(nb.bounds, map.win) : 1;
377
+ const ex = nodeAcc.get(id);
378
+ if (!ex || res.worst < ex.worst) nodeAcc.set(id, {
379
+ region: res.region,
380
+ worst: res.worst,
381
+ frame,
382
+ role: regionRole(res.region, w, h),
383
+ coverage
384
+ });
385
+ }
386
+ if (orphan && orphan.count >= MIN_ORPHAN_TILES) {
387
+ anyResidual = true;
388
+ if (!orphanAcc || orphan.worst < orphanAcc.worst) orphanAcc = {
389
+ region: orphan.region,
390
+ worst: orphan.worst,
391
+ frame,
392
+ tiles: orphan.count
393
+ };
394
+ }
395
+ }
396
+ const findings = [];
397
+ const warnedNodesEmitted = /* @__PURE__ */ new Set();
398
+ const warnByNode = /* @__PURE__ */ new Map();
399
+ for (const pw of parsedWarns) if (pw.node !== void 0 && !warnByNode.has(pw.node)) warnByNode.set(pw.node, pw);
400
+ const attributed = /* @__PURE__ */ new Map();
401
+ const mergeAttribution = (key, acc, warn, real) => {
402
+ const ex = attributed.get(key);
403
+ if (!ex) {
404
+ attributed.set(key, {
405
+ region: { ...acc.region },
406
+ worst: acc.worst,
407
+ frame: acc.frame,
408
+ role: acc.role,
409
+ ...warn ? { warn } : {},
410
+ real
411
+ });
412
+ return;
413
+ }
414
+ ex.region.minX = Math.min(ex.region.minX, acc.region.minX);
415
+ ex.region.minY = Math.min(ex.region.minY, acc.region.minY);
416
+ ex.region.maxX = Math.max(ex.region.maxX, acc.region.maxX);
417
+ ex.region.maxY = Math.max(ex.region.maxY, acc.region.maxY);
418
+ if (acc.worst < ex.worst) {
419
+ ex.worst = acc.worst;
420
+ ex.frame = acc.frame;
421
+ ex.role = acc.role;
422
+ }
423
+ ex.real = ex.real || real;
424
+ };
425
+ for (const [id, acc] of nodeAcc) if (warnByNode.has(id)) mergeAttribution(id, acc, warnByNode.get(id), true);
426
+ else {
427
+ const anc = nearestWarnedAncestor(refScene, id, warnByNode);
428
+ if (anc) mergeAttribution(anc.id, acc, anc.warn, true);
429
+ else mergeAttribution(id, acc, void 0, acc.coverage >= COVERAGE_MIN);
430
+ }
431
+ for (const [id, at] of attributed) {
432
+ if (!at.real) continue;
433
+ anyResidual = true;
434
+ const mean = round(at.worst);
435
+ if (at.warn) {
436
+ warnedNodesEmitted.add(id);
437
+ findings.push(dropFinding(at.warn, id, at.region, at.frame, mean, at.role));
438
+ } else {
439
+ const node = refScene.nodes.get(id);
440
+ if (node?.hasAnchor === true && (node.anchor[0] !== .5 || node.anchor[1] !== .5)) findings.push(anchorFinding(id, node.anchor, at.region, at.frame, mean, at.role));
441
+ else findings.push(unexplained(id, at.region, at.frame, mean, at.role));
442
+ }
443
+ }
444
+ for (const warn of parsedWarns) {
445
+ if (warn.node !== void 0 && warnedNodesEmitted.has(warn.node)) continue;
446
+ findings.push(dropFinding(warn, warn.node, null, frames[0] ?? 0, null, null));
447
+ if (warn.node !== void 0) warnedNodesEmitted.add(warn.node);
448
+ }
449
+ if (orphanAcc) findings.push(unexplained(void 0, orphanAcc.region, orphanAcc.frame, round(orphanAcc.worst), regionRole(orphanAcc.region, w, h)));
450
+ const sorted = sortDiagnostics(findings);
451
+ const invRegionOverlaps = sorted.every((f) => {
452
+ if (f.detail?.expected !== true) return true;
453
+ const region = f.detail?.region;
454
+ return region === void 0 || boxesOverlap(region, region);
455
+ });
456
+ const everyResidualHasCause = !anyResidual || sorted.some((f) => f.detail?.region !== void 0);
457
+ const everyWarnHasFinding = parsedWarns.every((pw) => sorted.some((f) => f.detail?.cause === pw.cause));
458
+ const keyOf = (f) => `${f.node ?? ""}|${f.code}|${String(f.detail?.property ?? "")}`;
459
+ const baseline = new Set(opts.baseline ?? []);
460
+ const newExpected = opts.baseline ? sorted.filter((f) => f.detail?.expected === true && !baseline.has(keyOf(f))).map(keyOf) : [];
461
+ const result = {
462
+ schemaVersion: DIAGNOSTIC_SCHEMA_VERSION,
463
+ findings: sorted,
464
+ view: opts.all ? sorted : sorted.filter((f) => f.severity === "error" || opts.baseline !== void 0 && f.detail?.expected === true && newExpected.includes(keyOf(f))),
465
+ warnings,
466
+ hasErrors: sorted.some((f) => f.severity === "error"),
467
+ invariants: {
468
+ regionOverlapsResidual: invRegionOverlaps,
469
+ everyResidualHasCause,
470
+ everyWarnHasFinding
471
+ },
472
+ newExpected,
473
+ width: w,
474
+ height: h,
475
+ floor,
476
+ frames
477
+ };
478
+ return {
479
+ ...result,
480
+ report: formatReport(result, opts)
481
+ };
482
+ }
483
+ function dropFinding(warn, node, region, frame, ssim, role) {
484
+ return {
485
+ schemaVersion: DIAGNOSTIC_SCHEMA_VERSION,
486
+ code: warn.approximate ? "LOTTIE_APPROXIMATE" : "LOTTIE_DROP",
487
+ severity: warn.approximate ? "warning" : "info",
488
+ source: "parity",
489
+ ...node !== void 0 ? { node } : {},
490
+ message: `${warn.property} on ${node ? `'${node}'` : "an unnamed node"} ${warn.approximate ? "exports DEGRADED" : "is DROPPED"} by Lottie export — ${warn.cause}`,
491
+ detail: {
492
+ property: warn.property,
493
+ cause: warn.cause,
494
+ frame,
495
+ expected: true,
496
+ ...region ? { region: roundBox(region) } : {},
497
+ ...ssim !== null ? { ssim } : {},
498
+ ...role ? {
499
+ role: role.role,
500
+ roleWeight: role.weight
501
+ } : {}
502
+ }
503
+ };
504
+ }
505
+ function anchorFinding(id, anchor, region, frame, ssim, role) {
506
+ return {
507
+ schemaVersion: DIAGNOSTIC_SCHEMA_VERSION,
508
+ code: "ANCHOR_RECENTER",
509
+ severity: "warning",
510
+ source: "parity",
511
+ node: id,
512
+ 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).`,
513
+ detail: {
514
+ fromAnchor: [round(anchor[0]), round(anchor[1])],
515
+ toAnchor: "center",
516
+ frame,
517
+ region: roundBox(region),
518
+ ssim,
519
+ expected: false,
520
+ role: role.role,
521
+ roleWeight: role.weight
522
+ }
523
+ };
524
+ }
525
+ function unexplained(node, region, frame, ssim, role) {
526
+ return {
527
+ schemaVersion: DIAGNOSTIC_SCHEMA_VERSION,
528
+ code: "UNEXPLAINED_RESIDUAL",
529
+ severity: "error",
530
+ source: "parity",
531
+ ...node !== void 0 ? { node } : {},
532
+ 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.`,
533
+ detail: {
534
+ frame,
535
+ region: roundBox(region),
536
+ ssim,
537
+ expected: false,
538
+ ...role ? {
539
+ role: role.role,
540
+ roleWeight: role.weight
541
+ } : {}
542
+ }
543
+ };
544
+ }
545
+ function formatReport(r, opts) {
546
+ const lines = [];
547
+ 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)`}`);
548
+ for (const f of r.view) {
549
+ const where = f.node ? ` [${f.node}]` : "";
550
+ lines.push(` ${f.severity.toUpperCase()} ${f.code}${where}: ${f.message}`);
551
+ }
552
+ const inv = r.invariants;
553
+ lines.push(` invariants: region-overlaps-residual ${inv.regionOverlapsResidual ? "✓" : "✗"}, every-residual-has-cause ${inv.everyResidualHasCause ? "✓" : "✗"}, every-warn-has-finding ${inv.everyWarnHasFinding ? "✓" : "✗"}`);
554
+ 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(", ")}`);
555
+ 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`);
556
+ return lines.join("\n");
557
+ }
558
+ //#endregion
559
+ export { semanticParityCommand };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/cli",
3
- "version": "0.60.0-pre.1",
3
+ "version": "0.61.0-pre.0",
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-pre.1",
32
- "@glissade/core": "0.60.0-pre.1",
33
- "@glissade/interact": "0.60.0-pre.1",
34
- "@glissade/lottie": "0.60.0-pre.1",
35
- "@glissade/narrate": "0.60.0-pre.1",
36
- "@glissade/player": "0.60.0-pre.1",
37
- "@glissade/scene": "0.60.0-pre.1",
38
- "@glissade/sfx": "0.60.0-pre.1",
39
- "@glissade/svg": "0.60.0-pre.1"
31
+ "@glissade/backend-skia": "0.61.0-pre.0",
32
+ "@glissade/core": "0.61.0-pre.0",
33
+ "@glissade/interact": "0.61.0-pre.0",
34
+ "@glissade/lottie": "0.61.0-pre.0",
35
+ "@glissade/narrate": "0.61.0-pre.0",
36
+ "@glissade/player": "0.61.0-pre.0",
37
+ "@glissade/scene": "0.61.0-pre.0",
38
+ "@glissade/sfx": "0.61.0-pre.0",
39
+ "@glissade/svg": "0.61.0-pre.0"
40
40
  },
41
41
  "repository": {
42
42
  "type": "git",