@glissade/cli 0.48.0-pre.2 → 0.49.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 +51 -1
- package/dist/parity.js +177 -0
- package/package.json +10 -10
package/dist/cli.js
CHANGED
|
@@ -99,6 +99,7 @@ const USAGE = `usage:
|
|
|
99
99
|
gs types [--out <file.ts>] [--from <api.json>] [--check] [--global] codegen a type-checked track() SDK from the describe() manifest: only registered animatable paths + their value types compile, so a typo'd path or wrong value-type id is a COMPILE error (import track from the generated file). --check fails if --out is stale. Zero-runtime (types + a re-typed re-export of the real track). --global (alias --iife) instead emits a SELF-CONTAINED ambient window.glissade .d.ts for the no-build <script src> author (typed IIFE surface — a typo'd window.glissade member is a compile error)
|
|
100
100
|
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)
|
|
101
101
|
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)
|
|
102
|
+
gs parity <scene-module> [--backends skia,lottie] [--frames a,b,..] [--fps <n>] [--width <n>] [--height <n>] [--heatmap <dir>] [--min <ssim>] 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. (dom = Phase B, not yet shipped)
|
|
102
103
|
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
|
|
103
104
|
gs --version print the engine version
|
|
104
105
|
|
|
@@ -210,7 +211,7 @@ async function main() {
|
|
|
210
211
|
process.stdout.write(`${describe().version}\n`);
|
|
211
212
|
return;
|
|
212
213
|
}
|
|
213
|
-
if (command !== "render" && command !== "diff" && command !== "verify-determinism" && command !== "dev" && command !== "import" && command !== "export" && command !== "narrate" && command !== "narration-lint" && command !== "sfx" && command !== "prepare" && command !== "measure-loudness" && command !== "fonts" && command !== "cache" && command !== "mcp" && command !== "build" && command !== "describe" && command !== "migrate" && command !== "repin" && command !== "master" && command !== "localize" && command !== "types") {
|
|
214
|
+
if (command !== "render" && command !== "diff" && command !== "verify-determinism" && command !== "dev" && command !== "import" && command !== "export" && command !== "narrate" && command !== "narration-lint" && command !== "sfx" && command !== "prepare" && command !== "measure-loudness" && command !== "fonts" && command !== "cache" && command !== "mcp" && command !== "build" && command !== "describe" && command !== "migrate" && command !== "repin" && command !== "parity" && command !== "master" && command !== "localize" && command !== "types") {
|
|
214
215
|
console.error(USAGE);
|
|
215
216
|
process.exit(command === void 0 || command === "help" || command === "--help" ? 0 : 1);
|
|
216
217
|
}
|
|
@@ -430,6 +431,55 @@ async function main() {
|
|
|
430
431
|
}
|
|
431
432
|
return;
|
|
432
433
|
}
|
|
434
|
+
if (command === "parity") {
|
|
435
|
+
const { positional: pp, flags: pf } = parseArgs(rest);
|
|
436
|
+
const sceneModule = pp[0];
|
|
437
|
+
if (!sceneModule) fail(`gs parity needs <scene-module>\n${USAGE}`);
|
|
438
|
+
const nums = (raw) => raw === void 0 ? void 0 : raw.split(",").map((s) => {
|
|
439
|
+
const n = Number(s.trim());
|
|
440
|
+
if (!Number.isInteger(n) || n < 0) fail(`parity: '${s}' is not a frame number (expected comma-separated non-negative integers)`);
|
|
441
|
+
return n;
|
|
442
|
+
});
|
|
443
|
+
const dim = (flagName) => {
|
|
444
|
+
const raw = pf.get(flagName);
|
|
445
|
+
if (raw === void 0 || raw === "") return void 0;
|
|
446
|
+
const n = Number(raw);
|
|
447
|
+
if (!Number.isFinite(n) || n <= 0) fail(`parity: --${flagName} must be a positive number, got '${raw}'`);
|
|
448
|
+
return n;
|
|
449
|
+
};
|
|
450
|
+
const minRaw = pf.get("min");
|
|
451
|
+
let min;
|
|
452
|
+
if (minRaw !== void 0) {
|
|
453
|
+
min = Number(minRaw);
|
|
454
|
+
if (!(min >= -1 && min <= 1)) fail(`parity: --min must be an SSIM floor in [-1, 1], got '${minRaw}'`);
|
|
455
|
+
}
|
|
456
|
+
const { parityCommand, parseBackends, ParityBackendError } = await import("./parity.js");
|
|
457
|
+
let backends;
|
|
458
|
+
try {
|
|
459
|
+
backends = parseBackends(pf.get("backends"));
|
|
460
|
+
} catch (err) {
|
|
461
|
+
fail(err instanceof ParityBackendError ? err.message : err instanceof Error ? err.message : String(err));
|
|
462
|
+
}
|
|
463
|
+
const width = dim("width");
|
|
464
|
+
const height = dim("height");
|
|
465
|
+
try {
|
|
466
|
+
const result = await parityCommand({
|
|
467
|
+
modulePath: sceneModule,
|
|
468
|
+
backends,
|
|
469
|
+
...nums(pf.get("frames")) ? { frames: nums(pf.get("frames")) } : {},
|
|
470
|
+
...pf.get("fps") ? { fps: parseFpsOrFail(pf.get("fps")) } : {},
|
|
471
|
+
...width !== void 0 ? { width } : {},
|
|
472
|
+
...height !== void 0 ? { height } : {},
|
|
473
|
+
...pf.get("heatmap") ? { heatmapDir: pf.get("heatmap") } : {},
|
|
474
|
+
...min !== void 0 ? { min } : {}
|
|
475
|
+
});
|
|
476
|
+
process.stdout.write(`${result.report}\n`);
|
|
477
|
+
if (!result.ok) process.exit(1);
|
|
478
|
+
} catch (err) {
|
|
479
|
+
fail(err instanceof ParityBackendError ? err.message : err instanceof Error ? err.message : String(err));
|
|
480
|
+
}
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
433
483
|
if (command === "localize") {
|
|
434
484
|
const { positional: lp, flags: lf } = parseArgs(rest);
|
|
435
485
|
const sceneModule = lp[0];
|
package/dist/parity.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { o as loadSceneModule } from "./render.js";
|
|
2
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
import { evaluate } from "@glissade/scene";
|
|
5
|
+
import { SkiaBackend, heatmapRgba, ssimMap } from "@glissade/backend-skia";
|
|
6
|
+
import { exportLottie, importLottie } from "@glissade/lottie";
|
|
7
|
+
//#region src/parity.ts
|
|
8
|
+
/**
|
|
9
|
+
* `gs parity` (Phase A) — the cross-backend perceptual reviewer.
|
|
10
|
+
*
|
|
11
|
+
* Renders ONE scene across backends and reports, per frame, how far each backend
|
|
12
|
+
* strays from the Skia reference: mean SSIM, the worst 8×8 tile (WHERE it drifts),
|
|
13
|
+
* and an optional thermal heat-map PNG. It answers "does my Lottie export still
|
|
14
|
+
* look like the real render?" as a NUMBER, per frame, headlessly.
|
|
15
|
+
*
|
|
16
|
+
* Phase A ships two legs, both in-process, ZERO browser deps:
|
|
17
|
+
* • `skia` — the direct headless render (the reference every leg is diffed
|
|
18
|
+
* against): createScene → SkiaBackend.render(evaluate(...)) → readPixels.
|
|
19
|
+
* • `lottie` — the export↔import round-trip: exportLottie(mod) → importLottie(doc)
|
|
20
|
+
* .toSceneModule() → render the re-imported module on Skia → readPixels.
|
|
21
|
+
* This measures the Lottie bijection the same way the round-trip gate does.
|
|
22
|
+
*
|
|
23
|
+
* SSIM is the perceptual metric (`ssimMap` from the shipped @glissade/backend-skia),
|
|
24
|
+
* NOT byte-equality — browser/exporter parity is perceptual by contract (DESIGN §3.4).
|
|
25
|
+
* A `--min` floor (default 0.98) gates the run: any frame below it fails (non-zero exit).
|
|
26
|
+
*
|
|
27
|
+
* The `dom` leg (a DOM-backend rasterizer via Playwright) is Phase B — it is NOT
|
|
28
|
+
* accepted here and fails loud rather than silently skipping a requested backend.
|
|
29
|
+
*/
|
|
30
|
+
/** Default sampled frames + fps — matches the round-trip / golden suites' cadence. */
|
|
31
|
+
const DEFAULT_PARITY_FRAMES = [
|
|
32
|
+
0,
|
|
33
|
+
30,
|
|
34
|
+
60,
|
|
35
|
+
90,
|
|
36
|
+
119
|
|
37
|
+
];
|
|
38
|
+
/** The reference backend every other leg is diffed against. */
|
|
39
|
+
const REFERENCE_BACKEND = "skia";
|
|
40
|
+
/** Backends accepted in Phase A (skia = reference; lottie = export↔import round-trip). */
|
|
41
|
+
const PHASE_A_BACKENDS = new Set(["skia", "lottie"]);
|
|
42
|
+
/** Raised for an unknown or not-yet-shipped backend — the CLI turns it into a loud fail(). */
|
|
43
|
+
var ParityBackendError = class extends Error {
|
|
44
|
+
constructor(message) {
|
|
45
|
+
super(message);
|
|
46
|
+
this.name = "ParityBackendError";
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
/** Parse + validate a `--backends` list. dom fails loud (Phase B); unknown fails loud. */
|
|
50
|
+
function parseBackends(raw) {
|
|
51
|
+
if (raw === void 0 || raw.trim() === "") return ["skia", "lottie"];
|
|
52
|
+
const list = raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
53
|
+
if (list.length === 0) return ["skia", "lottie"];
|
|
54
|
+
for (const b of list) {
|
|
55
|
+
if (b === "dom") throw new ParityBackendError("the dom parity leg needs Playwright + chromium-headless-shell — Phase B, not yet shipped (Phase A backends: skia, lottie)");
|
|
56
|
+
if (!PHASE_A_BACKENDS.has(b)) throw new ParityBackendError(`unknown parity backend '${b}' (Phase A backends: skia, lottie)`);
|
|
57
|
+
}
|
|
58
|
+
return list;
|
|
59
|
+
}
|
|
60
|
+
/** skia = the direct headless render (repin's render→pixels shape) — the reference. */
|
|
61
|
+
function skiaSource(mod, w, h) {
|
|
62
|
+
return async (t) => {
|
|
63
|
+
const scene = mod.createScene();
|
|
64
|
+
const backend = new SkiaBackend(w, h);
|
|
65
|
+
scene.setTextMeasurer(backend);
|
|
66
|
+
backend.render(evaluate(scene, mod.timeline, t));
|
|
67
|
+
return backend.readPixels();
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* lottie = the export↔import round-trip rendered on Skia (roundtrip.test's renderPixels
|
|
72
|
+
* shape). The document is exported/imported ONCE (it's time-independent); each frame
|
|
73
|
+
* evaluates the re-imported module — so the leg measures the Lottie bijection per frame.
|
|
74
|
+
*/
|
|
75
|
+
function lottieSource(mod, w, h, fps) {
|
|
76
|
+
return skiaSource(importLottie(exportLottie(mod, {
|
|
77
|
+
width: w,
|
|
78
|
+
height: h,
|
|
79
|
+
fps
|
|
80
|
+
})).toSceneModule(), w, h);
|
|
81
|
+
}
|
|
82
|
+
function makeSource(backend, mod, w, h, fps) {
|
|
83
|
+
switch (backend) {
|
|
84
|
+
case "skia": return skiaSource(mod, w, h);
|
|
85
|
+
case "lottie": return lottieSource(mod, w, h, fps);
|
|
86
|
+
default: throw new ParityBackendError(`unknown parity backend '${backend}'`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async function parityCommand(opts) {
|
|
90
|
+
const compared = (opts.backends ?? ["skia", "lottie"]).filter((b) => b !== REFERENCE_BACKEND);
|
|
91
|
+
if (compared.length === 0) throw new ParityBackendError(`gs parity needs at least one non-reference backend to compare against skia (e.g. --backends skia,lottie)`);
|
|
92
|
+
const mod = opts.module ?? await loadSceneModule(opts.modulePath);
|
|
93
|
+
const size = mod.createScene().size;
|
|
94
|
+
const w = opts.width ?? size.w;
|
|
95
|
+
const h = opts.height ?? size.h;
|
|
96
|
+
const fps = opts.fps ?? mod.timeline.fps ?? 60;
|
|
97
|
+
const frames = opts.frames ?? DEFAULT_PARITY_FRAMES;
|
|
98
|
+
const floor = opts.min ?? .98;
|
|
99
|
+
const name = opts.name ?? basename(opts.modulePath).replace(/\.[jt]sx?$/, "");
|
|
100
|
+
const reference = skiaSource(mod, w, h);
|
|
101
|
+
const sources = new Map(compared.map((b) => [b, makeSource(b, mod, w, h, fps)]));
|
|
102
|
+
if (opts.heatmapDir && !existsSync(opts.heatmapDir)) mkdirSync(opts.heatmapDir, { recursive: true });
|
|
103
|
+
const results = [];
|
|
104
|
+
let belowFloor = 0;
|
|
105
|
+
let worstMean = Infinity;
|
|
106
|
+
let worstAt = null;
|
|
107
|
+
for (const frame of frames) {
|
|
108
|
+
const t = frame / fps;
|
|
109
|
+
const refRgba = await reference(t);
|
|
110
|
+
const pairs = [];
|
|
111
|
+
for (const backend of compared) {
|
|
112
|
+
const map = ssimMap(refRgba, await sources.get(backend)(t), w, h);
|
|
113
|
+
const below = map.mean < floor;
|
|
114
|
+
if (below) belowFloor++;
|
|
115
|
+
if (map.mean < worstMean) {
|
|
116
|
+
worstMean = map.mean;
|
|
117
|
+
worstAt = {
|
|
118
|
+
frame,
|
|
119
|
+
backend
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
const pair = {
|
|
123
|
+
backend,
|
|
124
|
+
mean: map.mean,
|
|
125
|
+
min: map.min,
|
|
126
|
+
minTile: map.minTile,
|
|
127
|
+
belowFloor: below
|
|
128
|
+
};
|
|
129
|
+
if (opts.heatmapDir) {
|
|
130
|
+
const hb = new SkiaBackend(w, h);
|
|
131
|
+
hb.putPixels(heatmapRgba(map, w, h));
|
|
132
|
+
const hp = join(opts.heatmapDir, `${name}-${backend}-f${String(frame).padStart(4, "0")}.heat.png`);
|
|
133
|
+
writeFileSync(hp, hb.encodePng());
|
|
134
|
+
pair.heatmap = hp;
|
|
135
|
+
}
|
|
136
|
+
pairs.push(pair);
|
|
137
|
+
}
|
|
138
|
+
results.push({
|
|
139
|
+
frame,
|
|
140
|
+
t,
|
|
141
|
+
pairs
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
const result = {
|
|
145
|
+
frames: results,
|
|
146
|
+
backends: compared,
|
|
147
|
+
width: w,
|
|
148
|
+
height: h,
|
|
149
|
+
floor,
|
|
150
|
+
worstMean,
|
|
151
|
+
worstAt,
|
|
152
|
+
belowFloor,
|
|
153
|
+
ok: belowFloor === 0
|
|
154
|
+
};
|
|
155
|
+
return {
|
|
156
|
+
...result,
|
|
157
|
+
report: formatReport(name, result)
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function formatReport(name, r) {
|
|
161
|
+
const lines = [];
|
|
162
|
+
lines.push(`gs parity '${name}' — ${REFERENCE_BACKEND} reference vs ${r.backends.join(", ")} — ${r.frames.length} frame${r.frames.length === 1 ? "" : "s"} @ ${r.width}×${r.height}, floor ${r.floor}`);
|
|
163
|
+
for (const f of r.frames) {
|
|
164
|
+
const fno = `f${String(f.frame).padStart(4, "0")}`;
|
|
165
|
+
for (const p of f.pairs) {
|
|
166
|
+
const tile = `@ tile ${p.minTile.tx},${p.minTile.ty}`;
|
|
167
|
+
const mark = p.belowFloor ? " ⚠ BELOW FLOOR" : "";
|
|
168
|
+
const heat = p.heatmap ? ` → ${p.heatmap}` : "";
|
|
169
|
+
lines.push(` ${fno} ${p.backend.padEnd(6)} ssim ${p.mean.toFixed(4)} (min ${p.min.toFixed(3)} ${tile})${mark}${heat}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (r.worstAt) lines.push(` worst: f${String(r.worstAt.frame).padStart(4, "0")} ${r.worstAt.backend} ssim ${r.worstMean.toFixed(4)}`);
|
|
173
|
+
lines.push(r.ok ? ` PASS — every frame ≥ floor ${r.floor}` : ` FAIL — ${r.belowFloor} comparison${r.belowFloor === 1 ? "" : "s"} below floor ${r.floor}`);
|
|
174
|
+
return lines.join("\n");
|
|
175
|
+
}
|
|
176
|
+
//#endregion
|
|
177
|
+
export { ParityBackendError, parityCommand, parseBackends };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glissade/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.49.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.
|
|
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.49.0-pre.0",
|
|
32
|
+
"@glissade/core": "0.49.0-pre.0",
|
|
33
|
+
"@glissade/interact": "0.49.0-pre.0",
|
|
34
|
+
"@glissade/lottie": "0.49.0-pre.0",
|
|
35
|
+
"@glissade/narrate": "0.49.0-pre.0",
|
|
36
|
+
"@glissade/player": "0.49.0-pre.0",
|
|
37
|
+
"@glissade/scene": "0.49.0-pre.0",
|
|
38
|
+
"@glissade/sfx": "0.49.0-pre.0",
|
|
39
|
+
"@glissade/svg": "0.49.0-pre.0"
|
|
40
40
|
},
|
|
41
41
|
"repository": {
|
|
42
42
|
"type": "git",
|