@glissade/cli 0.49.0-pre.1 → 0.50.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 +19 -4
  2. package/dist/parity.js +232 -4
  3. package/package.json +10 -10
package/dist/cli.js CHANGED
@@ -29,7 +29,8 @@ const KNOWN_BOOLEAN_FLAGS = new Set([
29
29
  "lottie",
30
30
  "lint",
31
31
  "global",
32
- "iife"
32
+ "iife",
33
+ "update-baseline"
33
34
  ]);
34
35
  function parseArgs(argv) {
35
36
  const positional = [];
@@ -99,7 +100,7 @@ const USAGE = `usage:
99
100
  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
101
  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
102
  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)
103
+ 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)
103
104
  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
104
105
  gs --version print the engine version
105
106
 
@@ -453,6 +454,15 @@ async function main() {
453
454
  min = Number(minRaw);
454
455
  if (!(min >= -1 && min <= 1)) fail(`parity: --min must be an SSIM floor in [-1, 1], got '${minRaw}'`);
455
456
  }
457
+ const baselinePath = pf.get("baseline");
458
+ const updateBaseline = pf.has("update-baseline");
459
+ if (updateBaseline && baselinePath === void 0) fail(`parity: --update-baseline needs --baseline <file> (the baseline path to write to)\n${USAGE}`);
460
+ const tolRaw = pf.get("tolerance");
461
+ let tolerance;
462
+ if (tolRaw !== void 0) {
463
+ tolerance = Number(tolRaw);
464
+ if (!(tolerance >= 0) || !Number.isFinite(tolerance)) fail(`parity: --tolerance must be a non-negative number, got '${tolRaw}'`);
465
+ }
456
466
  const { parityCommand, parseBackends, ParityBackendError } = await import("./parity.js");
457
467
  let backends;
458
468
  try {
@@ -471,10 +481,15 @@ async function main() {
471
481
  ...width !== void 0 ? { width } : {},
472
482
  ...height !== void 0 ? { height } : {},
473
483
  ...pf.get("heatmap") ? { heatmapDir: pf.get("heatmap") } : {},
474
- ...min !== void 0 ? { min } : {}
484
+ ...min !== void 0 ? { min } : {},
485
+ ...baselinePath !== void 0 ? { baselinePath } : {},
486
+ ...updateBaseline ? { updateBaseline: true } : {},
487
+ ...tolerance !== void 0 ? { tolerance } : {}
475
488
  });
476
489
  process.stdout.write(`${result.report}\n`);
477
- if (!result.ok) process.exit(1);
490
+ if (updateBaseline) {} else if (baselinePath !== void 0) {
491
+ if (result.gateOk !== true) process.exit(1);
492
+ } else if (!result.ok) process.exit(1);
478
493
  } catch (err) {
479
494
  fail(err instanceof ParityBackendError ? err.message : err instanceof Error ? err.message : String(err));
480
495
  }
package/dist/parity.js CHANGED
@@ -1,9 +1,124 @@
1
1
  import { d as prepareSkiaRenderEnv, o as loadSceneModule } from "./render.js";
2
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
3
- import { basename, join } from "node:path";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { basename, dirname, join } from "node:path";
4
4
  import { evaluate, withDeterminismGuards } from "@glissade/scene";
5
5
  import { SkiaBackend, heatmapRgba, ssimMap } from "@glissade/backend-skia";
6
6
  import { exportLottie, importLottie } from "@glissade/lottie";
7
+ //#region src/parityBaseline.ts
8
+ /**
9
+ * `gs parity` known-drop regression-gate — the committed per-scene baseline.
10
+ *
11
+ * `gs parity` (Phase A) is RED-BY-DESIGN: documented scope-outs (non-center-anchor
12
+ * transforms, gradient/mesh fills, variable-font axes, text-wrap, media) legitimately
13
+ * fall below the 0.98 SSIM floor and fail the strict `--min` path. That makes the
14
+ * command a fidelity READOUT, not a regression gate you can wire into CI.
15
+ *
16
+ * This module turns it into a real gate: a committed baseline pins each scope-out's
17
+ * EXPECTED SSIM drop per (frame, backend); the run compares actual vs expected and
18
+ * alerts only on DEVIATION — a new/worse drop is a REGRESSION (fail), while a
19
+ * documented drop that matches its pin PASSES even though it's below the floor.
20
+ *
21
+ * Mirrors `gs repin`'s golden/baseline model (write vs compare split): a default
22
+ * conventional path (`parityBaselinePathFor`, like `goldenPathFor`), a loud header
23
+ * validation (the baseline is pinned at a specific w×h/fps/reference), and a
24
+ * four-way classify (`compareToBaseline`, like repin's `RepinStatus`).
25
+ *
26
+ * Pure of the render pipeline — plain JSON I/O over `node:fs`. `parity.ts` owns the
27
+ * render; this owns the baseline. (Dependency runs one way: parity.ts → here.)
28
+ */
29
+ /** Raised for a malformed / mismatched baseline — the CLI turns it into a loud fail(). */
30
+ var ParityBaselineError = class extends Error {
31
+ constructor(message) {
32
+ super(message);
33
+ this.name = "ParityBaselineError";
34
+ }
35
+ };
36
+ function isRecord(v) {
37
+ return typeof v === "object" && v !== null && !Array.isArray(v);
38
+ }
39
+ function readExpectation(where, raw) {
40
+ if (!isRecord(raw)) throw new ParityBaselineError(`parity baseline: ${where} is not an object`);
41
+ if (typeof raw.mean !== "number" || !Number.isFinite(raw.mean)) throw new ParityBaselineError(`parity baseline: ${where} is missing a numeric 'mean'`);
42
+ const exp = { mean: raw.mean };
43
+ if (raw.min !== void 0) {
44
+ if (typeof raw.min !== "number" || !Number.isFinite(raw.min)) throw new ParityBaselineError(`parity baseline: ${where}.min is not a number`);
45
+ exp.min = raw.min;
46
+ }
47
+ if (raw.minTile !== void 0) {
48
+ const mt = raw.minTile;
49
+ if (!isRecord(mt) || typeof mt.tx !== "number" || typeof mt.ty !== "number") throw new ParityBaselineError(`parity baseline: ${where}.minTile is not {tx,ty}`);
50
+ exp.minTile = {
51
+ tx: mt.tx,
52
+ ty: mt.ty
53
+ };
54
+ }
55
+ return exp;
56
+ }
57
+ /** Read + structurally validate a baseline file. Any malformation fails LOUD. */
58
+ function loadParityBaseline(path) {
59
+ let parsed;
60
+ try {
61
+ parsed = JSON.parse(readFileSync(path, "utf8"));
62
+ } catch (err) {
63
+ throw new ParityBaselineError(`could not read parity baseline '${path}': ${err instanceof Error ? err.message : String(err)}`);
64
+ }
65
+ if (!isRecord(parsed)) throw new ParityBaselineError(`parity baseline '${path}' is not a JSON object`);
66
+ const { name, width, height, fps, reference, frames } = parsed;
67
+ if (typeof name !== "string") throw new ParityBaselineError(`parity baseline '${path}': missing string 'name'`);
68
+ if (typeof width !== "number" || width <= 0) throw new ParityBaselineError(`parity baseline '${path}': missing positive 'width'`);
69
+ if (typeof height !== "number" || height <= 0) throw new ParityBaselineError(`parity baseline '${path}': missing positive 'height'`);
70
+ if (typeof fps !== "number" || fps <= 0) throw new ParityBaselineError(`parity baseline '${path}': missing positive 'fps'`);
71
+ if (typeof reference !== "string") throw new ParityBaselineError(`parity baseline '${path}': missing string 'reference'`);
72
+ if (!isRecord(frames)) throw new ParityBaselineError(`parity baseline '${path}': missing 'frames' object`);
73
+ const outFrames = {};
74
+ for (const [frame, backends] of Object.entries(frames)) {
75
+ if (!isRecord(backends)) throw new ParityBaselineError(`parity baseline '${path}': frame '${frame}' is not an object`);
76
+ const perBackend = {};
77
+ for (const [backend, exp] of Object.entries(backends)) perBackend[backend] = readExpectation(`frame '${frame}' backend '${backend}'`, exp);
78
+ outFrames[frame] = perBackend;
79
+ }
80
+ return {
81
+ name,
82
+ width,
83
+ height,
84
+ fps,
85
+ reference,
86
+ frames: outFrames
87
+ };
88
+ }
89
+ /** Write a baseline (pretty JSON), creating the parent dir — mirrors repin's --write emit. */
90
+ function saveParityBaseline(path, baseline) {
91
+ const dir = dirname(path);
92
+ if (dir && !existsSync(dir)) mkdirSync(dir, { recursive: true });
93
+ writeFileSync(path, `${JSON.stringify(baseline, null, 2)}\n`);
94
+ }
95
+ /**
96
+ * Assert the baseline was pinned at the SAME config as the live run. A mismatch
97
+ * means the numbers aren't comparable (a 480×480 pin vs a 240×240 run) → LOUD fail
98
+ * rather than a meaningless gate verdict.
99
+ */
100
+ function assertBaselineHeader(b, live) {
101
+ const mm = [];
102
+ if (b.width !== live.width) mm.push(`width ${b.width} ≠ ${live.width}`);
103
+ if (b.height !== live.height) mm.push(`height ${b.height} ≠ ${live.height}`);
104
+ if (b.fps !== live.fps) mm.push(`fps ${b.fps} ≠ ${live.fps}`);
105
+ if (b.reference !== live.reference) mm.push(`reference '${b.reference}' ≠ '${live.reference}'`);
106
+ if (mm.length > 0) throw new ParityBaselineError(`parity baseline pinned at a different config: ${mm.join(", ")} — re-pin with --update-baseline`);
107
+ }
108
+ /**
109
+ * Classify one live comparison against its pin.
110
+ * • no pin (unpinned frame/backend) → 'new' → FAIL (must be accepted via --update-baseline)
111
+ * • mean ≥ expected − tol → 'ok' → PASS (even below the 0.98 floor — the point)
112
+ * • mean < expected − tol → 'regressed'→ FAIL (a new/worse drop)
113
+ * • mean > expected + tol → 'improved' → PASS but FLAG (re-pin tighter)
114
+ */
115
+ function compareToBaseline(actualMean, expected, tolerance) {
116
+ if (expected === void 0) return "new";
117
+ if (actualMean < expected.mean - tolerance) return "regressed";
118
+ if (actualMean > expected.mean + tolerance) return "improved";
119
+ return "ok";
120
+ }
121
+ //#endregion
7
122
  //#region src/parity.ts
8
123
  /**
9
124
  * `gs parity` (Phase A) — the cross-backend perceptual reviewer.
@@ -116,6 +231,18 @@ async function parityCommand(opts) {
116
231
  const frames = opts.frames ?? DEFAULT_PARITY_FRAMES;
117
232
  const floor = opts.min ?? .98;
118
233
  const name = opts.name ?? basename(opts.modulePath).replace(/\.[jt]sx?$/, "");
234
+ const tolerance = opts.tolerance ?? 1e-4;
235
+ const gating = opts.baselinePath !== void 0 && opts.updateBaseline !== true;
236
+ let baseline;
237
+ if (gating) {
238
+ baseline = loadParityBaseline(opts.baselinePath);
239
+ assertBaselineHeader(baseline, {
240
+ width: w,
241
+ height: h,
242
+ fps,
243
+ reference: REFERENCE_BACKEND
244
+ });
245
+ }
119
246
  const reference = await skiaSource(mod, w, h, opts.modulePath);
120
247
  const sources = /* @__PURE__ */ new Map();
121
248
  for (const b of compared) sources.set(b, await makeSource(b, mod, w, h, fps, opts.modulePath));
@@ -124,6 +251,9 @@ async function parityCommand(opts) {
124
251
  let belowFloor = 0;
125
252
  let worstMean = Infinity;
126
253
  let worstAt = null;
254
+ let regressed = 0;
255
+ let newComparisons = 0;
256
+ let improved = 0;
127
257
  for (const frame of frames) {
128
258
  const t = frame / fps;
129
259
  const refRgba = await reference(t);
@@ -153,6 +283,18 @@ async function parityCommand(opts) {
153
283
  writeFileSync(hp, hb.encodePng());
154
284
  pair.heatmap = hp;
155
285
  }
286
+ if (baseline) {
287
+ const expected = baseline.frames[String(frame)]?.[backend];
288
+ const status = compareToBaseline(map.mean, expected, tolerance);
289
+ pair.status = status;
290
+ if (expected !== void 0) {
291
+ pair.expected = expected.mean;
292
+ pair.delta = map.mean - expected.mean;
293
+ }
294
+ if (status === "regressed") regressed++;
295
+ else if (status === "new") newComparisons++;
296
+ else if (status === "improved") improved++;
297
+ }
156
298
  pairs.push(pair);
157
299
  }
158
300
  results.push({
@@ -172,12 +314,85 @@ async function parityCommand(opts) {
172
314
  belowFloor,
173
315
  ok: belowFloor === 0
174
316
  };
317
+ if (opts.updateBaseline === true) {
318
+ if (opts.baselinePath === void 0) throw new ParityBaselineError("gs parity --update-baseline needs --baseline <file> to write to");
319
+ const emitted = buildBaseline(name, w, h, fps, results);
320
+ let prior;
321
+ if (existsSync(opts.baselinePath)) try {
322
+ prior = loadParityBaseline(opts.baselinePath);
323
+ } catch {}
324
+ let added = 0;
325
+ let repinned = 0;
326
+ for (const [frame, backends] of Object.entries(emitted.frames)) for (const backend of Object.keys(backends)) {
327
+ const before = prior?.frames[frame]?.[backend];
328
+ if (before === void 0) added++;
329
+ else if (Math.abs(before.mean - backends[backend].mean) > tolerance) repinned++;
330
+ }
331
+ saveParityBaseline(opts.baselinePath, emitted);
332
+ const updated = {
333
+ ...result,
334
+ improved,
335
+ baselineWritten: opts.baselinePath,
336
+ baselineAdded: added
337
+ };
338
+ return {
339
+ ...updated,
340
+ regressed: repinned,
341
+ report: formatReport(name, updated, { repinned })
342
+ };
343
+ }
344
+ if (gating) {
345
+ const gated = {
346
+ ...result,
347
+ regressed,
348
+ newComparisons,
349
+ improved,
350
+ gateOk: regressed === 0 && newComparisons === 0
351
+ };
352
+ return {
353
+ ...gated,
354
+ report: formatReport(name, gated)
355
+ };
356
+ }
175
357
  return {
176
358
  ...result,
177
359
  report: formatReport(name, result)
178
360
  };
179
361
  }
180
- function formatReport(name, r) {
362
+ /** Build a baseline document from the live frames (used by --update-baseline). */
363
+ function buildBaseline(name, w, h, fps, frames) {
364
+ const out = {};
365
+ for (const f of frames) {
366
+ const perBackend = {};
367
+ for (const p of f.pairs) perBackend[p.backend] = {
368
+ mean: p.mean,
369
+ min: p.min,
370
+ minTile: {
371
+ tx: p.minTile.tx,
372
+ ty: p.minTile.ty
373
+ }
374
+ };
375
+ out[String(f.frame)] = perBackend;
376
+ }
377
+ return {
378
+ name,
379
+ width: w,
380
+ height: h,
381
+ fps,
382
+ reference: REFERENCE_BACKEND,
383
+ frames: out
384
+ };
385
+ }
386
+ /** gate/update status → the report mark appended to a pair line. */
387
+ const GATE_MARK = {
388
+ ok: " ✓ expected-drop",
389
+ regressed: " ⚠ REGRESSION",
390
+ new: " + NEW",
391
+ improved: " ▲ improved"
392
+ };
393
+ function formatReport(name, r, extra) {
394
+ const update = r.baselineWritten !== void 0;
395
+ const gate = !update && r.gateOk !== void 0;
181
396
  const lines = [];
182
397
  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}`);
183
398
  for (const f of r.frames) {
@@ -186,10 +401,23 @@ function formatReport(name, r) {
186
401
  const tile = `@ tile ${p.minTile.tx},${p.minTile.ty}`;
187
402
  const mark = p.belowFloor ? " ⚠ BELOW FLOOR" : "";
188
403
  const heat = p.heatmap ? ` → ${p.heatmap}` : "";
189
- lines.push(` ${fno} ${p.backend.padEnd(6)} ssim ${p.mean.toFixed(4)} (min ${p.min.toFixed(3)} ${tile})${mark}${heat}`);
404
+ let gateSuffix = "";
405
+ if (gate && p.status !== void 0) gateSuffix = `${p.expected !== void 0 ? ` exp ${p.expected.toFixed(4)} Δ${p.delta >= 0 ? "+" : ""}${p.delta.toFixed(4)}` : ""}${GATE_MARK[p.status]}`;
406
+ lines.push(` ${fno} ${p.backend.padEnd(6)} ssim ${p.mean.toFixed(4)} (min ${p.min.toFixed(3)} ${tile})${gateSuffix}${mark}${heat}`);
190
407
  }
191
408
  }
192
409
  if (r.worstAt) lines.push(` worst: f${String(r.worstAt.frame).padStart(4, "0")} ${r.worstAt.backend} ssim ${r.worstMean.toFixed(4)}`);
410
+ if (update) {
411
+ lines.push(` wrote baseline → ${r.baselineWritten}`);
412
+ lines.push(` ${r.baselineAdded ?? 0} added, ${extra?.repinned ?? 0} re-pinned (moved > tol), ${r.improved ?? 0} improved`);
413
+ return lines.join("\n");
414
+ }
415
+ if (gate) {
416
+ const parts = [`${r.regressed ?? 0} regressed`, `${r.newComparisons ?? 0} new`];
417
+ if ((r.improved ?? 0) > 0) parts.push(`${r.improved} improved`);
418
+ lines.push(r.gateOk ? ` PASS — every comparison matched its expected drop (${parts.join(", ")})` : ` FAIL — ${parts.join(", ")} vs the baseline`);
419
+ return lines.join("\n");
420
+ }
193
421
  lines.push(r.ok ? ` PASS — every frame ≥ floor ${r.floor}` : ` FAIL — ${r.belowFloor} comparison${r.belowFloor === 1 ? "" : "s"} below floor ${r.floor}`);
194
422
  return lines.join("\n");
195
423
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/cli",
3
- "version": "0.49.0-pre.1",
3
+ "version": "0.50.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.49.0-pre.1",
32
- "@glissade/core": "0.49.0-pre.1",
33
- "@glissade/interact": "0.49.0-pre.1",
34
- "@glissade/lottie": "0.49.0-pre.1",
35
- "@glissade/narrate": "0.49.0-pre.1",
36
- "@glissade/player": "0.49.0-pre.1",
37
- "@glissade/scene": "0.49.0-pre.1",
38
- "@glissade/sfx": "0.49.0-pre.1",
39
- "@glissade/svg": "0.49.0-pre.1"
31
+ "@glissade/backend-skia": "0.50.0-pre.0",
32
+ "@glissade/core": "0.50.0-pre.0",
33
+ "@glissade/interact": "0.50.0-pre.0",
34
+ "@glissade/lottie": "0.50.0-pre.0",
35
+ "@glissade/narrate": "0.50.0-pre.0",
36
+ "@glissade/player": "0.50.0-pre.0",
37
+ "@glissade/scene": "0.50.0-pre.0",
38
+ "@glissade/sfx": "0.50.0-pre.0",
39
+ "@glissade/svg": "0.50.0-pre.0"
40
40
  },
41
41
  "repository": {
42
42
  "type": "git",