@glissade/cli 0.48.0 → 0.49.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/build.js CHANGED
Binary file
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { c as parseFrameRange, d as render, f as renderLocales, l as parseLocalesList, t as LocaleArgsError } from "./render.js";
2
+ import { c as parseFrameRange, f as render, l as parseLocalesList, p as renderLocales, t as LocaleArgsError } from "./render.js";
3
3
  import { n as parseCaptionsMode } from "./captions.js";
4
4
  //#region src/args.ts
5
5
  /**
@@ -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/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { A as peakClampBinds, C as PublishProfile, D as measureFile, E as loudnessPathFor, M as resolveProfile, O as measureLoudnessCommand, S as PUBLISH_PROFILES, T as computeMixHash, _ as LOUDNESS_SCHEMA_VERSION, a as MasterMemberResult, b as MeasureLoudnessOptions, d as masterAfChain, f as masterCommand, g as DEFAULT_PROFILE_ID, h as CommittedLimiter, i as MasterLimiter, j as readLoudness, k as parseLoudnormJson, l as MemberMeasure, m as planMaster, n as MasterConfig, o as MasterPlan, p as normalizeMasterConfig, r as MasterError, s as MasterResult, t as DEFAULT_MAX_GR_DB, u as MemberPlan, v as LoudnessError, w as computeGainDb, x as MeasureLoudnessResult, y as LoudnessMeasurement } from "./master.js";
2
2
  import { AudioClip, CompiledTimeline, Key, Timeline } from "@glissade/core";
3
3
  import { DisplayList, Scene, SceneModule, VideoFrameSource } from "@glissade/scene";
4
+ import { SkiaBackend } from "@glissade/backend-skia";
4
5
  import { NarrationTiming } from "@glissade/narrate";
5
6
  import { Image } from "@napi-rs/canvas";
6
7
  import * as _glissade_core_i18n0 from "@glissade/core/i18n";
@@ -130,6 +131,37 @@ declare function probeEntryHeader(file: string): {
130
131
  /** Remove the whole `.gscache` dir (a `gs cache clear` convenience / test cleanup). */
131
132
  declare function clearFrameCache(dir: string): void;
132
133
  //#endregion
134
+ //#region src/videoSource.d.ts
135
+ declare class VideoProbeError extends Error {
136
+ constructor(path: string, detail: string);
137
+ }
138
+ interface VideoInfo {
139
+ fps: number;
140
+ duration: number;
141
+ width: number;
142
+ height: number;
143
+ }
144
+ declare function probeVideo(path: string): VideoInfo;
145
+ /**
146
+ * v1 strategy: warm() extracts the full requested media range once at the
147
+ * source's own fps; getFrameSync indexes the extracted sequence. Disk-bound,
148
+ * trivially correct; the streaming-pipe variant is a later optimization.
149
+ */
150
+ declare class FfmpegVideoFrameSource implements VideoFrameSource {
151
+ readonly fps: number;
152
+ readonly duration: number;
153
+ private readonly path;
154
+ private framesDir;
155
+ private warmedFrom;
156
+ private warmedTo;
157
+ private readonly cache;
158
+ constructor(path: string, info?: VideoInfo);
159
+ private frameIndex;
160
+ warm(fromT: number, toT: number): Promise<void>;
161
+ getFrameSync(mediaT: number): Image;
162
+ close(): void;
163
+ }
164
+ //#endregion
133
165
  //#region src/render.d.ts
134
166
  interface RenderOptions {
135
167
  modulePath: string;
@@ -307,6 +339,10 @@ declare class SceneModuleError extends Error {
307
339
 
308
340
  declare function loadSceneModule(modulePath: string, locale?: string, messageTableOverride?: _glissade_core_i18n0.MessageTable): Promise<SceneModule>;
309
341
  declare function ffmpegAvailable(): boolean;
342
+ /** What `prepareSkiaRenderEnv` needs to make a Skia render FAITHFUL (fonts+axes,
343
+ * Yoga, assets) — the setup that `gs render` and any parity/preview render must
344
+ * share so a headless render is byte-faithful to `gs render` by construction. */
345
+
310
346
  declare function render(opts: RenderOptions): Promise<{
311
347
  frames: number;
312
348
  out: string;
@@ -424,37 +460,6 @@ declare function renderSharded(a: RenderShardedArgs): Promise<{
424
460
  }>;
425
461
  /** The FFV1 lossless intermediate retained beside a `--incremental` output for the next splice. */
426
462
  //#endregion
427
- //#region src/videoSource.d.ts
428
- declare class VideoProbeError extends Error {
429
- constructor(path: string, detail: string);
430
- }
431
- interface VideoInfo {
432
- fps: number;
433
- duration: number;
434
- width: number;
435
- height: number;
436
- }
437
- declare function probeVideo(path: string): VideoInfo;
438
- /**
439
- * v1 strategy: warm() extracts the full requested media range once at the
440
- * source's own fps; getFrameSync indexes the extracted sequence. Disk-bound,
441
- * trivially correct; the streaming-pipe variant is a later optimization.
442
- */
443
- declare class FfmpegVideoFrameSource implements VideoFrameSource {
444
- readonly fps: number;
445
- readonly duration: number;
446
- private readonly path;
447
- private framesDir;
448
- private warmedFrom;
449
- private warmedTo;
450
- private readonly cache;
451
- constructor(path: string, info?: VideoInfo);
452
- private frameIndex;
453
- warm(fromT: number, toT: number): Promise<void>;
454
- getFrameSync(mediaT: number): Image;
455
- close(): void;
456
- }
457
- //#endregion
458
463
  //#region src/audioMix.d.ts
459
464
  declare class AudioMixError extends Error {
460
465
  constructor(detail: string);
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { t as glissadeVersion } from "./version.js";
2
- import { a as ffmpegAvailable, d as render, f as renderLocales, i as collectAudioClips, l as parseLocalesList, m as resolveLoudnessGainDb, n as SceneModuleError, o as loadSceneModule, r as buildMixWav, s as localeOutPath, t as LocaleArgsError, u as planFinalAudio } from "./render.js";
2
+ import { a as ffmpegAvailable, f as render, h as resolveLoudnessGainDb, i as collectAudioClips, l as parseLocalesList, n as SceneModuleError, o as loadSceneModule, p as renderLocales, r as buildMixWav, s as localeOutPath, t as LocaleArgsError, u as planFinalAudio } from "./render.js";
3
3
  import { a as computeGainDb, d as parseLoudnormJson, f as peakClampBinds, i as PUBLISH_PROFILES, l as measureFile, m as resolveProfile, n as LOUDNESS_SCHEMA_VERSION, o as computeMixHash, p as readLoudness, r as LoudnessError, s as loudnessPathFor, t as DEFAULT_PROFILE_ID, u as measureLoudnessCommand } from "./loudness.js";
4
4
  import { i as masterCommand, n as MasterError, o as normalizeMasterConfig, r as masterAfChain, s as planMaster, t as DEFAULT_MAX_GR_DB } from "./master.js";
5
5
  import { a as pickEncoder, i as parseEncoderList, n as availableEncoders, t as NoEncoderError } from "./encoders.js";
package/dist/localize.js CHANGED
@@ -156,7 +156,7 @@ async function harvestMessageIds(modulePath) {
156
156
  get: (_t, prop) => typeof prop === "string" ? prop : void 0
157
157
  });
158
158
  const { getConsumedMessageIds } = await import("@glissade/core/i18n");
159
- const { loadSceneModule } = await import("./render.js").then((n) => n.p);
159
+ const { loadSceneModule } = await import("./render.js").then((n) => n.m);
160
160
  const mod = await loadSceneModule(modulePath, void 0, recording);
161
161
  mod.createScene();
162
162
  const tIds = new Set(getConsumedMessageIds());
package/dist/loudness.js CHANGED
@@ -225,7 +225,7 @@ function measureFile(audioPath) {
225
225
  */
226
226
  async function measureLoudnessCommand(opts) {
227
227
  const profile = resolveProfile(opts.profile ?? "youtube");
228
- const { buildMixWav } = await import("./render.js").then((n) => n.p);
228
+ const { buildMixWav } = await import("./render.js").then((n) => n.m);
229
229
  const tmp = mkdtempSync(join(tmpdir(), "glissade-loudness-"));
230
230
  try {
231
231
  const wavPath = join(tmp, "mix.wav");
@@ -239,7 +239,7 @@ async function measureLoudnessCommand(opts) {
239
239
  const { inputI, inputTp, inputLra } = measureFile(wavPath);
240
240
  const gain = computeGainDb(profile, inputI, inputTp);
241
241
  const clampBound = peakClampBinds(profile, inputI, inputTp);
242
- const { collectMixAudioInputs } = await import("./render.js").then((n) => n.p);
242
+ const { collectMixAudioInputs } = await import("./render.js").then((n) => n.m);
243
243
  const extraInputs = await collectMixAudioInputs({
244
244
  modulePath: opts.modulePath,
245
245
  narration: opts.narration ?? "auto",
package/dist/master.js CHANGED
@@ -132,7 +132,7 @@ async function runMaster(members, opts = {}, onLog) {
132
132
  const consistency = opts.consistency ?? "shared-target";
133
133
  const limiter = opts.limiter || null;
134
134
  const log = onLog ?? (() => {});
135
- const { buildMixWav, collectMixAudioInputs } = await import("./render.js").then((n) => n.p);
135
+ const { buildMixWav, collectMixAudioInputs } = await import("./render.js").then((n) => n.m);
136
136
  if (members.length === 0) throw new MasterError("master: no members to master");
137
137
  const ceilingDb = limiter?.ceilingDb ?? profile.truePeakDb;
138
138
  const committedLimiter = limiter ? {
package/dist/parity.js ADDED
@@ -0,0 +1,197 @@
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";
4
+ import { evaluate, withDeterminismGuards } 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
+ /**
61
+ * skia = the direct headless render — the reference every leg is diffed against.
62
+ * CRITICAL: it renders through the SAME environment as `gs render`
63
+ * (`prepareSkiaRenderEnv`: font faces incl. variable-font axes registered, Yoga
64
+ * for flexbox, assets decoded) and wraps `evaluate` in the determinism guard. Without
65
+ * this the reference would render a variable-font scene at DEFAULT weight (the face
66
+ * never registered) and match a fontAxes-dropping Lottie leg at a FALSE SSIM 1.0 —
67
+ * a fidelity gate silently reporting perfect on a real interchange loss.
68
+ *
69
+ * The scene + backend are built ONCE and reused across frames (evaluate is pure of
70
+ * time — the ONE contract — exactly as `gs render`'s frame loop reuses them).
71
+ */
72
+ async function skiaSource(mod, w, h, modulePath) {
73
+ const scene = mod.createScene();
74
+ const backend = new SkiaBackend(w, h);
75
+ await prepareSkiaRenderEnv({
76
+ scene,
77
+ doc: mod.timeline,
78
+ backend,
79
+ modulePath
80
+ });
81
+ return async (t) => {
82
+ const dl = withDeterminismGuards("throw", () => evaluate(scene, mod.timeline, t));
83
+ backend.render(dl);
84
+ return backend.readPixels();
85
+ };
86
+ }
87
+ /**
88
+ * lottie = the export↔import round-trip rendered on Skia (roundtrip.test's renderPixels
89
+ * shape). The document is exported/imported ONCE (it's time-independent); the re-imported
90
+ * module renders through the SAME faithful environment as the reference (so its render is
91
+ * honest too). Asset URLs resolve against the ORIGINAL module path. This leg measures the
92
+ * Lottie bijection per frame — e.g. the dropped `fontAxes` surfaces as a real SSIM drop.
93
+ */
94
+ async function lottieSource(mod, w, h, fps, modulePath) {
95
+ return skiaSource(importLottie(exportLottie(mod, {
96
+ width: w,
97
+ height: h,
98
+ fps
99
+ })).toSceneModule(), w, h, modulePath);
100
+ }
101
+ function makeSource(backend, mod, w, h, fps, modulePath) {
102
+ switch (backend) {
103
+ case "skia": return skiaSource(mod, w, h, modulePath);
104
+ case "lottie": return lottieSource(mod, w, h, fps, modulePath);
105
+ default: throw new ParityBackendError(`unknown parity backend '${backend}'`);
106
+ }
107
+ }
108
+ async function parityCommand(opts) {
109
+ const compared = (opts.backends ?? ["skia", "lottie"]).filter((b) => b !== REFERENCE_BACKEND);
110
+ 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)`);
111
+ const mod = opts.module ?? await loadSceneModule(opts.modulePath);
112
+ const size = mod.createScene().size;
113
+ const w = opts.width ?? size.w;
114
+ const h = opts.height ?? size.h;
115
+ const fps = opts.fps ?? mod.timeline.fps ?? 60;
116
+ const frames = opts.frames ?? DEFAULT_PARITY_FRAMES;
117
+ const floor = opts.min ?? .98;
118
+ const name = opts.name ?? basename(opts.modulePath).replace(/\.[jt]sx?$/, "");
119
+ const reference = await skiaSource(mod, w, h, opts.modulePath);
120
+ const sources = /* @__PURE__ */ new Map();
121
+ for (const b of compared) sources.set(b, await makeSource(b, mod, w, h, fps, opts.modulePath));
122
+ if (opts.heatmapDir && !existsSync(opts.heatmapDir)) mkdirSync(opts.heatmapDir, { recursive: true });
123
+ const results = [];
124
+ let belowFloor = 0;
125
+ let worstMean = Infinity;
126
+ let worstAt = null;
127
+ for (const frame of frames) {
128
+ const t = frame / fps;
129
+ const refRgba = await reference(t);
130
+ const pairs = [];
131
+ for (const backend of compared) {
132
+ const map = ssimMap(refRgba, await sources.get(backend)(t), w, h);
133
+ const below = map.mean < floor;
134
+ if (below) belowFloor++;
135
+ if (map.mean < worstMean) {
136
+ worstMean = map.mean;
137
+ worstAt = {
138
+ frame,
139
+ backend
140
+ };
141
+ }
142
+ const pair = {
143
+ backend,
144
+ mean: map.mean,
145
+ min: map.min,
146
+ minTile: map.minTile,
147
+ belowFloor: below
148
+ };
149
+ if (opts.heatmapDir) {
150
+ const hb = new SkiaBackend(w, h);
151
+ hb.putPixels(heatmapRgba(map, w, h));
152
+ const hp = join(opts.heatmapDir, `${name}-${backend}-f${String(frame).padStart(4, "0")}.heat.png`);
153
+ writeFileSync(hp, hb.encodePng());
154
+ pair.heatmap = hp;
155
+ }
156
+ pairs.push(pair);
157
+ }
158
+ results.push({
159
+ frame,
160
+ t,
161
+ pairs
162
+ });
163
+ }
164
+ const result = {
165
+ frames: results,
166
+ backends: compared,
167
+ width: w,
168
+ height: h,
169
+ floor,
170
+ worstMean,
171
+ worstAt,
172
+ belowFloor,
173
+ ok: belowFloor === 0
174
+ };
175
+ return {
176
+ ...result,
177
+ report: formatReport(name, result)
178
+ };
179
+ }
180
+ function formatReport(name, r) {
181
+ const lines = [];
182
+ 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
+ for (const f of r.frames) {
184
+ const fno = `f${String(f.frame).padStart(4, "0")}`;
185
+ for (const p of f.pairs) {
186
+ const tile = `@ tile ${p.minTile.tx},${p.minTile.ty}`;
187
+ const mark = p.belowFloor ? " ⚠ BELOW FLOOR" : "";
188
+ 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}`);
190
+ }
191
+ }
192
+ if (r.worstAt) lines.push(` worst: f${String(r.worstAt.frame).padStart(4, "0")} ${r.worstAt.backend} ssim ${r.worstMean.toFixed(4)}`);
193
+ lines.push(r.ok ? ` PASS — every frame ≥ floor ${r.floor}` : ` FAIL — ${r.belowFloor} comparison${r.belowFloor === 1 ? "" : "s"} below floor ${r.floor}`);
194
+ return lines.join("\n");
195
+ }
196
+ //#endregion
197
+ export { ParityBackendError, parityCommand, parseBackends };
package/dist/render.js CHANGED
@@ -166,6 +166,7 @@ var render_exports = /* @__PURE__ */ __exportAll({
166
166
  parseFrameRange: () => parseFrameRange,
167
167
  parseLocalesList: () => parseLocalesList,
168
168
  planFinalAudio: () => planFinalAudio,
169
+ prepareSkiaRenderEnv: () => prepareSkiaRenderEnv,
169
170
  render: () => render,
170
171
  renderLocales: () => renderLocales,
171
172
  resolveLoudnessGainDb: () => resolveLoudnessGainDb
@@ -358,6 +359,95 @@ async function loadSceneModule(modulePath, locale, messageTableOverride) {
358
359
  function ffmpegAvailable() {
359
360
  return spawnSync("ffmpeg", ["-version"], { stdio: "ignore" }).status === 0;
360
361
  }
362
+ /**
363
+ * Prepare the Skia render environment so `evaluate(scene, doc, t)` → `backend.render`
364
+ * is FAITHFUL: line-break measurer, Yoga (flexbox scenes), font faces registered
365
+ * under their families (variable-font axes included, so `fontAxes` reaches the
366
+ * glyphs), font validation, and image/video asset decode bound to the backend.
367
+ *
368
+ * This is the SINGLE SOURCE OF TRUTH for render setup — `gs render` AND `gs parity`
369
+ * both call it, so a parity render cannot silently diverge from a real render (e.g.
370
+ * report a false-perfect SSIM on a variable-font scene by rendering BOTH legs at the
371
+ * default weight because neither registered the face). Extracted verbatim from the
372
+ * `render()` body; the ordering (yoga → asset-validate → font-register → validate →
373
+ * asset-decode) is preserved so `gs render` stays byte-identical.
374
+ */
375
+ async function prepareSkiaRenderEnv(o) {
376
+ const { scene, doc, backend, modulePath } = o;
377
+ scene.setTextMeasurer(backend);
378
+ if ([...scene.nodes.values()].some((n) => n.constructor.isLayoutNode === true)) {
379
+ const { loadYogaLayoutEngine } = await import("@glissade/scene/layout");
380
+ await loadYogaLayoutEngine();
381
+ }
382
+ validateAssetReferences(collectAssetReferences(scene.root), Object.keys(doc.assets ?? {}));
383
+ const videoSources = [];
384
+ const { resolveAssetPath: resolveAsset } = await import("./audioMix.js").then((n) => n.i);
385
+ const { createHash } = await import("node:crypto");
386
+ const assetDigests = /* @__PURE__ */ new Map();
387
+ const digestBytes = (bytes) => createHash("sha256").update(bytes).digest("hex");
388
+ const registeredFamilies = /* @__PURE__ */ new Set();
389
+ const fontRegistry = buildFontRegistry(doc.assets);
390
+ if (fontRegistry.faces().length > 0) {
391
+ const { GlobalFonts } = await import("@napi-rs/canvas");
392
+ let ingest;
393
+ for (const face of fontRegistry.faces()) {
394
+ registeredFamilies.add(face.family.toLowerCase());
395
+ const path = resolveAsset(face.url, modulePath);
396
+ if (/\.woff2?$/i.test(face.url)) {
397
+ ingest ??= await import("@glissade/core/font-ingest");
398
+ const src = await readFile(path);
399
+ assetDigests.set(`font:${face.family}:${face.url}`, digestBytes(src));
400
+ const result = await ingest.ingestFont({
401
+ family: face.family,
402
+ src
403
+ });
404
+ GlobalFonts.register(Buffer.from(result.bytes), face.family);
405
+ } else {
406
+ assetDigests.set(`font:${face.family}:${face.url}`, digestBytes(await readFile(path)));
407
+ GlobalFonts.registerFromPath(path, face.family);
408
+ }
409
+ }
410
+ }
411
+ const osCatalog = !!o.allowSystemFonts && !o.strictFonts ? new Set((await import("@napi-rs/canvas")).GlobalFonts.families.map((f) => f.family.toLowerCase())) : /* @__PURE__ */ new Set();
412
+ const osFamilies = buildFontExemptSet(registeredFamilies, {
413
+ allowSystemFonts: !!o.allowSystemFonts,
414
+ strict: !!o.strictFonts,
415
+ osCatalog
416
+ });
417
+ const localizedUsages = o.locale !== void 0 && o.locale !== "" ? collectLocalizedTextUsages(scene, doc) : [];
418
+ await validateSceneFonts(scene, doc, async (url) => {
419
+ try {
420
+ const buf = await readFile(resolveAsset(url, modulePath));
421
+ return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
422
+ } catch {
423
+ return;
424
+ }
425
+ }, {
426
+ mode: o.strictFonts ? "strict" : "dev",
427
+ osFamilies,
428
+ extraUsages: localizedUsages
429
+ });
430
+ for (const [assetId, ref] of Object.entries(doc.assets ?? {})) if (ref.kind === "font") {} else if (ref.kind === "image") {
431
+ const { loadImage } = await import("@napi-rs/canvas");
432
+ const imgPath = resolveAsset(ref.url, modulePath);
433
+ assetDigests.set(`image:${assetId}`, digestBytes(await readFile(imgPath)));
434
+ backend.setImageAsset(assetId, await loadImage(imgPath));
435
+ } else if (ref.kind === "video") {
436
+ if (!ffmpegAvailable()) throw new Error(`video asset '${assetId}' needs FFmpeg on PATH for frame extraction (§5.4)`);
437
+ const { FfmpegVideoFrameSource } = await import("./videoSource.js").then((n) => n.i);
438
+ const videoPath = resolveAsset(ref.url, modulePath);
439
+ assetDigests.set(`video:${assetId}`, digestBytes(await readFile(videoPath)));
440
+ const source = new FfmpegVideoFrameSource(videoPath);
441
+ await source.warm(0, source.duration);
442
+ backend.setVideoAsset(assetId, source);
443
+ videoSources.push(source);
444
+ }
445
+ return {
446
+ assetDigests,
447
+ videoSources,
448
+ registeredFamilies
449
+ };
450
+ }
361
451
  async function render(opts) {
362
452
  const mod = await loadSceneModule(opts.modulePath, opts.locale);
363
453
  const scene = mod.createScene();
@@ -427,74 +517,15 @@ async function render(opts) {
427
517
  const framesDir = isVideo ? mkdtempSync(join(tmpdir(), "glissade-frames-")) : singleFile ? dirname(resolve(opts.out)) : resolve(opts.out);
428
518
  mkdirSync(framesDir, { recursive: true });
429
519
  const backend = new SkiaBackend(scene.size.w, scene.size.h);
430
- scene.setTextMeasurer(backend);
431
- if ([...scene.nodes.values()].some((n) => n.constructor.isLayoutNode === true)) {
432
- const { loadYogaLayoutEngine } = await import("@glissade/scene/layout");
433
- await loadYogaLayoutEngine();
434
- }
435
- validateAssetReferences(collectAssetReferences(scene.root), Object.keys(doc.assets ?? {}));
436
- const videoSources = [];
437
- const { resolveAssetPath: resolveAsset } = await import("./audioMix.js").then((n) => n.i);
438
- const { createHash } = await import("node:crypto");
439
- const assetDigests = /* @__PURE__ */ new Map();
440
- const digestBytes = (bytes) => createHash("sha256").update(bytes).digest("hex");
441
- const registeredFamilies = /* @__PURE__ */ new Set();
442
- const fontRegistry = buildFontRegistry(doc.assets);
443
- if (fontRegistry.faces().length > 0) {
444
- const { GlobalFonts } = await import("@napi-rs/canvas");
445
- let ingest;
446
- for (const face of fontRegistry.faces()) {
447
- registeredFamilies.add(face.family.toLowerCase());
448
- const path = resolveAsset(face.url, opts.modulePath);
449
- if (/\.woff2?$/i.test(face.url)) {
450
- ingest ??= await import("@glissade/core/font-ingest");
451
- const src = await readFile(path);
452
- assetDigests.set(`font:${face.family}:${face.url}`, digestBytes(src));
453
- const result = await ingest.ingestFont({
454
- family: face.family,
455
- src
456
- });
457
- GlobalFonts.register(Buffer.from(result.bytes), face.family);
458
- } else {
459
- assetDigests.set(`font:${face.family}:${face.url}`, digestBytes(await readFile(path)));
460
- GlobalFonts.registerFromPath(path, face.family);
461
- }
462
- }
463
- }
464
- const osCatalog = !!opts.allowSystemFonts && !opts.strictFonts ? new Set((await import("@napi-rs/canvas")).GlobalFonts.families.map((f) => f.family.toLowerCase())) : /* @__PURE__ */ new Set();
465
- const osFamilies = buildFontExemptSet(registeredFamilies, {
466
- allowSystemFonts: !!opts.allowSystemFonts,
467
- strict: !!opts.strictFonts,
468
- osCatalog
469
- });
470
- const localizedUsages = opts.locale !== void 0 && opts.locale !== "" ? collectLocalizedTextUsages(scene, doc) : [];
471
- await validateSceneFonts(scene, doc, async (url) => {
472
- try {
473
- const buf = await readFile(resolveAsset(url, opts.modulePath));
474
- return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
475
- } catch {
476
- return;
477
- }
478
- }, {
479
- mode: opts.strictFonts ? "strict" : "dev",
480
- osFamilies,
481
- extraUsages: localizedUsages
520
+ const { assetDigests, videoSources } = await prepareSkiaRenderEnv({
521
+ scene,
522
+ doc,
523
+ backend,
524
+ modulePath: opts.modulePath,
525
+ ...opts.strictFonts !== void 0 ? { strictFonts: opts.strictFonts } : {},
526
+ ...opts.allowSystemFonts !== void 0 ? { allowSystemFonts: opts.allowSystemFonts } : {},
527
+ ...opts.locale !== void 0 ? { locale: opts.locale } : {}
482
528
  });
483
- for (const [assetId, ref] of Object.entries(doc.assets ?? {})) if (ref.kind === "font") {} else if (ref.kind === "image") {
484
- const { loadImage } = await import("@napi-rs/canvas");
485
- const imgPath = resolveAsset(ref.url, opts.modulePath);
486
- assetDigests.set(`image:${assetId}`, digestBytes(await readFile(imgPath)));
487
- backend.setImageAsset(assetId, await loadImage(imgPath));
488
- } else if (ref.kind === "video") {
489
- if (!ffmpegAvailable()) throw new Error(`video asset '${assetId}' needs FFmpeg on PATH for frame extraction (§5.4)`);
490
- const { FfmpegVideoFrameSource } = await import("./videoSource.js").then((n) => n.i);
491
- const videoPath = resolveAsset(ref.url, opts.modulePath);
492
- assetDigests.set(`video:${assetId}`, digestBytes(await readFile(videoPath)));
493
- const source = new FfmpegVideoFrameSource(videoPath);
494
- await source.warm(0, source.duration);
495
- backend.setVideoAsset(assetId, source);
496
- videoSources.push(source);
497
- }
498
529
  let frameCache;
499
530
  let keyCtx;
500
531
  const cacheOn = !!(opts.cache && opts.cache.mode !== "off");
@@ -995,4 +1026,4 @@ async function buildMixWav(opts, wavOut) {
995
1026
  });
996
1027
  }
997
1028
  //#endregion
998
- export { readRenderManifest as _, ffmpegAvailable as a, parseFrameRange as c, render as d, renderLocales as f, frameKeyDigest as g, changedFrameRanges as h, collectAudioClips as i, parseLocalesList as l, resolveLoudnessGainDb as m, SceneModuleError as n, loadSceneModule as o, render_exports as p, buildMixWav as r, localeOutPath as s, LocaleArgsError as t, planFinalAudio as u, writeRenderManifest as v };
1029
+ export { frameKeyDigest as _, ffmpegAvailable as a, parseFrameRange as c, prepareSkiaRenderEnv as d, render as f, changedFrameRanges as g, resolveLoudnessGainDb as h, collectAudioClips as i, parseLocalesList as l, render_exports as m, SceneModuleError as n, loadSceneModule as o, renderLocales as p, buildMixWav as r, localeOutPath as s, LocaleArgsError as t, planFinalAudio as u, readRenderManifest as v, writeRenderManifest as y };
package/dist/shards.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime.js";
2
- import { _ as readRenderManifest, g as frameKeyDigest, h as changedFrameRanges, u as planFinalAudio, v as writeRenderManifest } from "./render.js";
2
+ import { _ as frameKeyDigest, g as changedFrameRanges, u as planFinalAudio, v as readRenderManifest, y as writeRenderManifest } from "./render.js";
3
3
  import { a as pickEncoder } from "./encoders.js";
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/cli",
3
- "version": "0.48.0",
3
+ "version": "0.49.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.48.0",
32
- "@glissade/core": "0.48.0",
33
- "@glissade/interact": "0.48.0",
34
- "@glissade/lottie": "0.48.0",
35
- "@glissade/narrate": "0.48.0",
36
- "@glissade/player": "0.48.0",
37
- "@glissade/scene": "0.48.0",
38
- "@glissade/sfx": "0.48.0",
39
- "@glissade/svg": "0.48.0"
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"
40
40
  },
41
41
  "repository": {
42
42
  "type": "git",