@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 +41 -1
- package/dist/describeLint.js +1 -1
- package/dist/parity.js +2 -1
- package/dist/semanticParity.js +648 -0
- package/package.json +10 -10
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}`);
|
package/dist/describeLint.js
CHANGED
|
@@ -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.
|
|
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.
|
|
32
|
-
"@glissade/core": "0.
|
|
33
|
-
"@glissade/interact": "0.
|
|
34
|
-
"@glissade/lottie": "0.
|
|
35
|
-
"@glissade/narrate": "0.
|
|
36
|
-
"@glissade/player": "0.
|
|
37
|
-
"@glissade/scene": "0.
|
|
38
|
-
"@glissade/sfx": "0.
|
|
39
|
-
"@glissade/svg": "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",
|