@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.
Files changed (3) hide show
  1. package/dist/cli.js +51 -1
  2. package/dist/parity.js +177 -0
  3. 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.48.0-pre.2",
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.48.0-pre.2",
32
- "@glissade/core": "0.48.0-pre.2",
33
- "@glissade/interact": "0.48.0-pre.2",
34
- "@glissade/lottie": "0.48.0-pre.2",
35
- "@glissade/narrate": "0.48.0-pre.2",
36
- "@glissade/player": "0.48.0-pre.2",
37
- "@glissade/scene": "0.48.0-pre.2",
38
- "@glissade/sfx": "0.48.0-pre.2",
39
- "@glissade/svg": "0.48.0-pre.2"
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",