@glissade/cli 0.11.0-pre.1 → 0.12.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/audioMix.js CHANGED
@@ -10,6 +10,7 @@ import { audioOffsetSamples } from "@glissade/core";
10
10
  */
11
11
  var audioMix_exports = /* @__PURE__ */ __exportAll({
12
12
  AudioMixError: () => AudioMixError,
13
+ applyMixGainDb: () => applyMixGainDb,
13
14
  atempoChain: () => atempoChain,
14
15
  gainExpression: () => gainExpression,
15
16
  planAudioMix: () => planAudioMix,
@@ -60,6 +61,20 @@ function atempoChain(rate) {
60
61
  if (Math.abs(r - 1) > 1e-9 || chain.length === 0) chain.push(`atempo=${r}`);
61
62
  return chain;
62
63
  }
64
+ /**
65
+ * Append a PURE scalar publish-loudness gain to a mix's `-filter_complex`: the
66
+ * graph's final `[aout]` label is renamed and a `volume=<gain>dB` node feeds the
67
+ * new `[aout]`. This is a single multiply on the FINAL mix node — NOT a second
68
+ * ffmpeg pass — and is bit-deterministic (verified) + golden-hashable. A gain of
69
+ * exactly 0 dB is a no-op (returned unchanged) so an at-target source preserves
70
+ * the prior, un-gained bytes.
71
+ */
72
+ function applyMixGainDb(filterComplex, gainDb) {
73
+ if (gainDb === 0) return filterComplex;
74
+ const at = filterComplex.lastIndexOf("[aout]");
75
+ if (at < 0) throw new AudioMixError("mix filter graph has no [aout] to apply the loudness gain to");
76
+ return `${filterComplex.slice(0, at) + "[apreg]" + filterComplex.slice(at + 6)};[apreg]volume=${gainDb}dB[aout]`;
77
+ }
63
78
  /** Build the FFmpeg mix plan for clips that intersect [0, duration]. */
64
79
  function planAudioMix(clips, modulePath, duration) {
65
80
  const active = clips.filter((c) => c.at < duration);
@@ -93,4 +108,4 @@ function planAudioMix(clips, modulePath, duration) {
93
108
  };
94
109
  }
95
110
  //#endregion
96
- export { planAudioMix as a, gainExpression as i, atempoChain as n, resolveAssetPath as o, audioMix_exports as r, AudioMixError as t };
111
+ export { gainExpression as a, audioMix_exports as i, applyMixGainDb as n, planAudioMix as o, atempoChain as r, resolveAssetPath as s, AudioMixError as t };
@@ -0,0 +1,133 @@
1
+ import { t as __exportAll } from "./rolldown-runtime.js";
2
+ import { a as loadSceneModule } from "./render.js";
3
+ import { i as capsId, n as FrameCache, o as frameCacheKey } from "./frameCache.js";
4
+ import { t as glissadeVersion } from "./version.js";
5
+ import { mkdtempSync, rmSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { evaluate, withDeterminismGuards } from "@glissade/scene";
9
+ import { SkiaBackend } from "@glissade/backend-skia";
10
+ import { createHash } from "node:crypto";
11
+ //#region src/cacheVerify.ts
12
+ /**
13
+ * gs cache verify — THE verify gate for the persistent `.gscache` (DESIGN.md §3.5,
14
+ * 0.12). The frame cache's single failure mode is SILENT corruption (a stale tile
15
+ * served from an incomplete key), so this gate is MANDATORY: it renders the scene
16
+ * through the cache (read-only — serving HITS) and again with the cache OFF, then
17
+ * asserts the per-frame `encodePng` bytes are EQUAL frame-for-frame. A mismatch
18
+ * means a hit is NOT byte-identical to a cold render — the cache is unsafe.
19
+ *
20
+ * This EXTENDS verify-determinism's committed-byte-hash machinery (`sha256` over
21
+ * the rasterized frame bytes — the same `node:crypto` precedent) across the DISK
22
+ * boundary: instead of linear-vs-shard, it is cache-hit-vs-cache-off.
23
+ *
24
+ * A sampled fraction is fine for speed, and the sample is LOGGED (no silent caps):
25
+ * the report names exactly which frames were compared.
26
+ *
27
+ * Companion NEGATIVE gate: see `cacheVerify.test.ts`, which injects a deliberately
28
+ * INCOMPLETE key (drops the version / a transform-bearing component) and asserts
29
+ * this comparison FAILS — proving the gate actually catches the only failure mode.
30
+ */
31
+ var cacheVerify_exports = /* @__PURE__ */ __exportAll({ cacheVerifyCommand: () => cacheVerifyCommand });
32
+ /** sha256 hex of the encoded PNG bytes — the authoritative cross-the-disk-boundary check. */
33
+ function sha256(data) {
34
+ return createHash("sha256").update(data).digest("hex");
35
+ }
36
+ /**
37
+ * Render one frame with a given backend, returning its encodePng bytes. When
38
+ * `cache` is provided, a HIT loads stored RGBA via `putPixels` and encodes through
39
+ * the IDENTICAL path; a MISS renders, encodes, and (in read-write) stores. The
40
+ * keyer/ctx are injectable for the NEGATIVE gate.
41
+ */
42
+ async function renderFrameBytes(backend, scene, doc, f, fps, cache, ctx, keyer) {
43
+ const dl = withDeterminismGuards("throw", () => evaluate(scene, doc, f / fps));
44
+ if (cache && cache.mode !== "off") {
45
+ const key = keyer(dl, ctx);
46
+ const hit = cache.get(key);
47
+ if (hit) {
48
+ backend.putPixels(hit);
49
+ return backend.encodePng();
50
+ }
51
+ backend.render(dl);
52
+ const png = backend.encodePng();
53
+ cache.put(key, scene.size.w, scene.size.h, await backend.readPixels());
54
+ return png;
55
+ }
56
+ backend.render(dl);
57
+ return backend.encodePng();
58
+ }
59
+ /**
60
+ * The verify gate. Renders the range through a read-WRITE cache once (to WARM it),
61
+ * then compares — for each SAMPLED frame — a read-ONLY render (serving the warmed
62
+ * hits) against a cache-OFF render, by encodePng sha256. Equal frame-for-frame is
63
+ * the contract; the first mismatch fails the gate.
64
+ */
65
+ async function cacheVerifyCommand(opts) {
66
+ const mod = await loadSceneModule(opts.modulePath);
67
+ const scene = mod.createScene();
68
+ const doc = mod.timeline;
69
+ const fps = opts.fps ?? doc.fps ?? 60;
70
+ const backend = new SkiaBackend(scene.size.w, scene.size.h);
71
+ scene.setTextMeasurer(backend);
72
+ const { compileTimeline } = await import("@glissade/core");
73
+ const duration = compileTimeline(doc).duration;
74
+ const [first, last] = opts.frameRange ?? [0, Math.max(0, Math.ceil(duration * fps) - 1)];
75
+ const totalFrames = last - first + 1;
76
+ const sample = Math.max(1, Math.floor(opts.sample ?? 1));
77
+ const ctx = opts.keyContextOverride ?? {
78
+ version: glissadeVersion(),
79
+ capsId: capsId(backend.caps)
80
+ };
81
+ const keyer = opts.keyerOverride ?? frameCacheKey;
82
+ const dir = mkdtempSync(join(tmpdir(), "gscache-verify-"));
83
+ try {
84
+ const warm = new FrameCache({
85
+ dir,
86
+ mode: "read-write"
87
+ });
88
+ for (let f = first; f <= last; f++) await renderFrameBytes(backend, scene, doc, f, fps, warm, ctx, keyer);
89
+ const readOnly = new FrameCache({
90
+ dir,
91
+ mode: "read-only"
92
+ });
93
+ const comparedFrames = [];
94
+ let mismatch;
95
+ for (let f = first; f <= last; f += sample) {
96
+ comparedFrames.push(f);
97
+ const cachedBytes = await renderFrameBytes(backend, scene, doc, f, fps, readOnly, ctx, keyer);
98
+ const coldBytes = await renderFrameBytes(backend, scene, doc, f, fps, void 0, ctx, keyer);
99
+ const a = sha256(cachedBytes);
100
+ const b = sha256(coldBytes);
101
+ if (a !== b) {
102
+ mismatch = {
103
+ frame: f,
104
+ cached: a,
105
+ cold: b
106
+ };
107
+ break;
108
+ }
109
+ }
110
+ backend.dispose();
111
+ const sampledNote = sample === 1 ? `all ${totalFrames} frames` : `${comparedFrames.length} of ${totalFrames} frames (1-of-${sample} sample): [${comparedFrames.join(", ")}]`;
112
+ if (mismatch) return {
113
+ ok: false,
114
+ comparedFrames,
115
+ totalFrames,
116
+ mismatch,
117
+ report: `CACHE VERIFY FAILED — a hit is NOT byte-identical to a cold render.\n frame ${mismatch.frame}: cached-hit PNG sha256 ${mismatch.cached.slice(0, 16)}… != cache-off ${mismatch.cold.slice(0, 16)}…\n the cache key is INCOMPLETE (it served a stale frame for changed content) — DO NOT SHIP.\n sampled: ${sampledNote}`
118
+ };
119
+ return {
120
+ ok: true,
121
+ comparedFrames,
122
+ totalFrames,
123
+ report: `cache verify OK: cache hits are byte-identical to cold renders.\n compared (encodePng sha256, hit vs cache-off): ${sampledNote}`
124
+ };
125
+ } finally {
126
+ rmSync(dir, {
127
+ recursive: true,
128
+ force: true
129
+ });
130
+ }
131
+ }
132
+ //#endregion
133
+ export { cacheVerify_exports as n, cacheVerifyCommand as t };
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { i as parseFrameRange, o as render } from "./render.js";
2
+ import { c as render, o as parseFrameRange } from "./render.js";
3
3
  import { n as parseCaptionsMode } from "./captions.js";
4
4
  //#region src/cli.ts
5
5
  /**
@@ -41,11 +41,17 @@ function parseArgs(argv) {
41
41
  }
42
42
  const USAGE = `usage:
43
43
  gs render <scene-module> [options]
44
+ gs diff <scene-module> --at <t> --against <baseline.dl.json|.png>
45
+ gs verify-determinism <scene-module> [--shards <n>] [--against <frames.manifest>] [--range a..b] [--bisect] [--emit <p>]
44
46
  gs dev <scene-module> [--record] [--port <n>]
45
47
  gs import <lottie.json|asset.svg> [--out <dir>] [--allow-degraded]
46
48
  gs narrate <scene-module|script.narration.json> [--provider <id>] [--align <id>] [--force]
49
+ gs narration-lint <scene-module|script.narration.timing.json> [--json] [--fix] [--max-cps <n>]
47
50
  gs sfx <scene-module|script.sfx.json> [--verbose]
48
51
  gs prepare <scene-module> [--provider <id>] [--align <id>] [--force]
52
+ gs measure-loudness <scene-module> [--profile <youtube|shorts|podcast|broadcast|ebu>]
53
+ gs fonts audit <scene-module> list registered families, formats, and missing-glyph runs (§3.6)
54
+ gs cache verify <scene-module> [--range a..b] [--sample <n>] assert cache hits == cold renders (§3.5)
49
55
 
50
56
  render options:
51
57
  --out <path> output directory for a PNG sequence, or .mp4/.webm (needs ffmpeg). default: ./out
@@ -60,6 +66,14 @@ render options:
60
66
  --lossless-intermediate render shards as FFV1 + one final encode — the guaranteed byte-correct join
61
67
  (auto-enabled when the encoder can't honor precise boundary keyframes, e.g. mpeg4/openh264)
62
68
  --allow-gpu-shards permit sharding a scene with GPU/shader nodes (output is not reproducible across shards; §3.7)
69
+ --cache [<dir>] persistent whole-frame raster cache in <dir> (default .gscache; §3.5). OFF by default — opting in
70
+ never changes output, only speed. A hit serves a stored frame byte-identical to a cold render.
71
+ WINS: repeated renders + the UNCHANGED-PREFIX of a single-segment edit. Does NOT win a re-narrate —
72
+ that shifts every frame's timing, so every DisplayList changes and every frame MISSES. Shards share
73
+ one .gscache. Verify safety with 'gs cache verify'.
74
+ --cache-mode <m> read-write (default with --cache) | read-only (serve hits, never write) | off (bypass = baseline)
75
+ --cache-max-size <bytes|2GB> LRU size cap; oldest entries evicted when exceeded (default 2GB). Whole-frame RGBA is
76
+ ~MBs/frame (tens of GB/episode) — the cap keeps .gscache from eating the disk
63
77
  --trace <file> replay an InputTrace and bake it (machine scenes, §A.6)
64
78
  --state <name> render one machine state's timeline linearly
65
79
  --force downgrade a trace hash mismatch to a warning
@@ -67,11 +81,27 @@ render options:
67
81
  --narration <m> auto (default): mix the voice from a sibling *.narration.timing.json | off
68
82
  --music <m> auto (default): mix a sibling *.music.timing.json bed, ducked under narration | off
69
83
  --sfx <m> auto (default): mix effect hits from a sibling *.sfx.timing.json | off
84
+ --loudness <m> auto (default): apply a committed *.loudness.json publish gain (pure scalar; gs measure-loudness)
85
+ | off. A stale mixHash (mix inputs changed since measure) HARD-THROWS — re-run gs measure-loudness
70
86
  --chapters <m> vtt: also write WebVTT chapters from cue markers (cues.json is always written when cues exist)
71
87
  (YouTube needs the 1st chapter at 0:00 — auto-anchored — and each chapter >= 10s; author cue ts accordingly)
72
88
  --chapters-kind <k[,k]> cue kinds that become VTT chapters (default: chapter); cues.json keeps all kinds
73
89
  --strict fail on an unregistered font family or an uncovered glyph (§3.6; default: warn)
74
90
 
91
+ diff options (DisplayList diff vs a committed baseline — exits non-zero on any divergence):
92
+ --at <t> time in SECONDS to evaluate the scene at (required)
93
+ --against <p> baseline to compare to: <name>.dl.json (command-level structural diff)
94
+ or <name>.png (raw encodePng byte-compare only — no pixel-diff)
95
+ --snapshot <p> instead of diffing, write the scene's .dl.json snapshot at --at to <p>
96
+
97
+ verify-determinism options (the cross-shard/backend byte-divergence LOCATOR — exits non-zero on any divergence):
98
+ --shards <n> diff a linear render vs an n-shard render of the same range (byte-identical is the contract)
99
+ --against <p> diff against a committed / other-machine frames.manifest (REJECTS a cross-backend byte-compare)
100
+ --range <a..b> integer FRAME indices to verify, inclusive (default: whole timeline)
101
+ --bisect drill the first divergence to the exact (frame, node, op) via the command-level diff
102
+ --emit <p> instead of comparing, write the linear frames.manifest baseline to <p>
103
+ (byte-equality is Skia↔Skia / cross-machine only — browser↔Skia is perceptual SSIM, not bytes)
104
+
75
105
  dev options:
76
106
  --record add a Record button; writes .trace.json sidecars next to the module
77
107
  --port <n> listen port (default: any free port)
@@ -80,18 +110,80 @@ import options (.json = Lottie; .svg = static SVG → a scene that defers to @gl
80
110
  --out <dir> output directory for the generated scene module (default: .)
81
111
  --allow-degraded (Lottie only) downgrade degradable rejections (expressions, merge-paths modes != 1) to warnings
82
112
 
113
+ measure-loudness options (the explicit publish-loudness measure step; commits *.loudness.json):
114
+ --profile <id> youtube (default) | shorts (both -14 LUFS) | podcast (-16) | broadcast/ebu (-23); all cap at -1 dBTP
115
+ measures the final mix (ebur128) and commits a deterministic peak-clamped gain; render applies it
116
+ as a pure scalar. Needs ffmpeg. Brickwall limiter deferred — peaky un-normalized profiles warn.
117
+
83
118
  narrate options (the explicit TTS prepare step; render itself stays offline):
84
119
  --provider <id> fake | espeak | piper | kokoro | openai (default: the script's provider, else espeak)
85
120
  (kokoro = Apache-2.0 offline neural voice; add 'kokoro-js' to your project; pnpm: allow its native build scripts)
86
121
  --align <id> heuristic (default) | vosk | none — word timings for providers that emit none
87
122
  --force ignore the cache and re-synthesize every segment
123
+
124
+ narration-lint options (lint the committed *.narration.timing.json + the real caption geometry; exits non-zero on a Tier-1 issue):
125
+ --max-cps <n> reading-speed ceiling in chars-per-second (default: 17)
126
+ --max-lines <n> caption maxLines for the fit rule (default: 2, captionNode's own default)
127
+ --json machine-readable diagnostics ({ hasErrors, diagnostics })
128
+ --no-warnings omit Tier-2 (warn-only) diagnostics
129
+ --fix print a git-apply-able budget-bump diff for the SCRIPT (never writes a committed artifact)
88
130
  `;
89
131
  async function main() {
90
132
  const [command, ...rest] = process.argv.slice(2);
91
- if (command !== "render" && command !== "dev" && command !== "import" && command !== "narrate" && command !== "sfx" && command !== "prepare") {
133
+ if (command !== "render" && command !== "diff" && command !== "verify-determinism" && command !== "dev" && command !== "import" && command !== "narrate" && command !== "narration-lint" && command !== "sfx" && command !== "prepare" && command !== "measure-loudness" && command !== "fonts" && command !== "cache") {
92
134
  console.error(USAGE);
93
135
  process.exit(command === void 0 || command === "help" || command === "--help" ? 0 : 1);
94
136
  }
137
+ if (command === "fonts") {
138
+ const { positional: fp } = parseArgs(rest);
139
+ const sub = fp[0];
140
+ const sceneModule = fp[1];
141
+ if (sub !== "audit") fail(`unknown 'fonts' subcommand '${sub ?? ""}' (expected: audit)\n${USAGE}`);
142
+ if (!sceneModule) fail(`fonts audit needs <scene-module>\n${USAGE}`);
143
+ const { fontsAuditCommand } = await import("./fonts.js");
144
+ const { resolveAssetPath } = await import("./audioMix.js").then((n) => n.i);
145
+ try {
146
+ const { text } = await fontsAuditCommand({
147
+ modulePath: sceneModule,
148
+ resolvePath: (url) => resolveAssetPath(url, sceneModule)
149
+ });
150
+ process.stdout.write(`${text}\n`);
151
+ } catch (err) {
152
+ fail(err instanceof Error ? err.message : String(err));
153
+ }
154
+ return;
155
+ }
156
+ if (command === "cache") {
157
+ const { positional: cp, flags: cf } = parseArgs(rest);
158
+ const sub = cp[0];
159
+ const sceneModule = cp[1];
160
+ if (sub !== "verify") fail(`unknown 'cache' subcommand '${sub ?? ""}' (expected: verify)\n${USAGE}`);
161
+ if (!sceneModule) fail(`cache verify needs <scene-module>\n${USAGE}`);
162
+ let cvRange;
163
+ const cvRangeFlag = cf.get("range");
164
+ if (cvRangeFlag) try {
165
+ cvRange = parseFrameRange(cvRangeFlag);
166
+ } catch (err) {
167
+ fail(err instanceof Error ? err.message : String(err));
168
+ }
169
+ const sampleFlag = cf.get("sample");
170
+ if (sampleFlag !== void 0 && (!/^\d+$/.test(sampleFlag) || Number(sampleFlag) < 1)) fail(`--sample must be a positive integer (1-of-N frame sampling), got '${sampleFlag}'`);
171
+ const cvFpsFlag = cf.get("fps");
172
+ const { cacheVerifyCommand } = await import("./cacheVerify.js").then((n) => n.n);
173
+ try {
174
+ const result = await cacheVerifyCommand({
175
+ modulePath: sceneModule,
176
+ ...cvRange ? { frameRange: cvRange } : {},
177
+ ...sampleFlag !== void 0 ? { sample: Number(sampleFlag) } : {},
178
+ ...cvFpsFlag ? { fps: parseInt(cvFpsFlag, 10) } : {}
179
+ });
180
+ process.stdout.write(`${result.report}\n`);
181
+ if (!result.ok) process.exit(1);
182
+ } catch (err) {
183
+ fail(err instanceof Error ? err.message : String(err));
184
+ }
185
+ return;
186
+ }
95
187
  const { positional, flags } = parseArgs(rest);
96
188
  const modulePath = positional[0];
97
189
  if (!modulePath) fail(`missing ${command === "import" ? "<lottie.json|asset.svg>" : "<scene-module>"}\n${USAGE}`);
@@ -115,6 +207,28 @@ async function main() {
115
207
  }
116
208
  return;
117
209
  }
210
+ if (command === "narration-lint") {
211
+ const { narrationLintCommand } = await import("./narrationLintCommand.js").then((n) => n.i);
212
+ const maxCpsFlag = flags.get("max-cps");
213
+ if (maxCpsFlag !== void 0 && (maxCpsFlag === "" || !Number.isFinite(Number(maxCpsFlag)))) fail(`--max-cps must be a number, got '${maxCpsFlag}'`);
214
+ const maxLinesFlag = flags.get("max-lines");
215
+ if (maxLinesFlag !== void 0 && !/^\d+$/.test(maxLinesFlag)) fail(`--max-lines must be a non-negative integer, got '${maxLinesFlag}'`);
216
+ try {
217
+ const result = await narrationLintCommand({
218
+ input: modulePath,
219
+ ...maxCpsFlag !== void 0 ? { maxCps: Number(maxCpsFlag) } : {},
220
+ ...maxLinesFlag !== void 0 ? { maxLines: Number(maxLinesFlag) } : {},
221
+ ...flags.has("json") ? { json: true } : {},
222
+ ...flags.has("fix") ? { fix: true } : {},
223
+ ...flags.has("no-warnings") ? { noWarnings: true } : {}
224
+ });
225
+ process.stdout.write(result.output);
226
+ if (result.hasErrors) process.exit(1);
227
+ } catch (err) {
228
+ fail(err instanceof Error ? err.message : String(err));
229
+ }
230
+ return;
231
+ }
118
232
  if (command === "prepare") {
119
233
  const { prepareCommand } = await import("./prepare.js");
120
234
  try {
@@ -144,6 +258,90 @@ async function main() {
144
258
  }
145
259
  return;
146
260
  }
261
+ if (command === "measure-loudness") {
262
+ const { measureLoudnessCommand } = await import("./loudness.js").then((n) => n.c);
263
+ try {
264
+ const result = await measureLoudnessCommand({
265
+ modulePath,
266
+ ...flags.has("profile") ? { profile: flags.get("profile") } : {},
267
+ ...flags.get("narration") === "off" ? { narration: "off" } : {},
268
+ ...flags.get("music") === "off" ? { music: "off" } : {},
269
+ ...flags.get("sfx") === "off" ? { sfx: "off" } : {}
270
+ });
271
+ const m = result.measurement;
272
+ process.stderr.write(`gs measure-loudness: profile '${m.profileId}' — in ${m.inputI} LUFS / ${m.inputTp} dBTP → gain ${m.gain >= 0 ? "+" : ""}${m.gain} dB (out ~${(m.inputI + m.gain).toFixed(2)} LUFS / ~${(m.inputTp + m.gain).toFixed(2)} dBTP) → ${result.loudnessPath}\n`);
273
+ if (result.warning) process.stderr.write(`gs measure-loudness: warning: ${result.warning}\n`);
274
+ } catch (err) {
275
+ fail(err instanceof Error ? err.message : String(err));
276
+ }
277
+ return;
278
+ }
279
+ if (command === "diff") {
280
+ const atRaw = flags.get("at");
281
+ if (atRaw === void 0 || atRaw === "") fail(`diff needs --at <seconds>\n${USAGE}`);
282
+ const at = Number(atRaw);
283
+ if (!Number.isFinite(at)) fail(`--at must be a number of seconds, got '${atRaw}'`);
284
+ const snapshotOut = flags.get("snapshot");
285
+ if (snapshotOut !== void 0 && snapshotOut !== "") {
286
+ const { snapshotAt } = await import("./diff.js").then((n) => n.n);
287
+ try {
288
+ const { writeFileSync } = await import("node:fs");
289
+ writeFileSync(snapshotOut, await snapshotAt(modulePath, at));
290
+ process.stderr.write(`gs diff: wrote snapshot @ ${at}s → ${snapshotOut}\n`);
291
+ } catch (err) {
292
+ fail(err instanceof Error ? err.message : String(err));
293
+ }
294
+ return;
295
+ }
296
+ const against = flags.get("against");
297
+ if (against === void 0 || against === "") fail(`diff needs --against <baseline.dl.json|.png>\n${USAGE}`);
298
+ const { diffCommand } = await import("./diff.js").then((n) => n.n);
299
+ try {
300
+ const result = await diffCommand({
301
+ modulePath,
302
+ at,
303
+ against
304
+ });
305
+ process.stdout.write(`${result.report}\n`);
306
+ if (!result.equal) process.exit(1);
307
+ } catch (err) {
308
+ fail(err instanceof Error ? err.message : String(err));
309
+ }
310
+ return;
311
+ }
312
+ if (command === "verify-determinism") {
313
+ let frameRange;
314
+ const rangeFlag = flags.get("range");
315
+ if (rangeFlag) try {
316
+ frameRange = parseFrameRange(rangeFlag);
317
+ } catch (err) {
318
+ fail(err instanceof Error ? err.message : String(err));
319
+ }
320
+ const shardsFlag = flags.get("shards");
321
+ let shards;
322
+ if (shardsFlag !== void 0) {
323
+ if (!/^\d+$/.test(shardsFlag) || Number(shardsFlag) < 1) fail(`--shards must be a positive integer, got '${shardsFlag}'`);
324
+ shards = Number(shardsFlag);
325
+ }
326
+ const fpsFlag = flags.get("fps");
327
+ const { verifyDeterminismCommand } = await import("./verifyDeterminism.js");
328
+ try {
329
+ const result = await verifyDeterminismCommand({
330
+ modulePath,
331
+ ...shards !== void 0 ? { shards } : {},
332
+ ...flags.has("against") ? { against: flags.get("against") } : {},
333
+ ...frameRange ? { frameRange } : {},
334
+ ...flags.has("bisect") ? { bisect: true } : {},
335
+ ...flags.has("emit") ? { emit: flags.get("emit") } : {},
336
+ ...fpsFlag ? { fps: parseInt(fpsFlag, 10) } : {}
337
+ });
338
+ process.stdout.write(`${result.report}\n`);
339
+ if (!result.ok) process.exit(1);
340
+ } catch (err) {
341
+ fail(err instanceof Error ? err.message : String(err));
342
+ }
343
+ return;
344
+ }
147
345
  if (command === "import") {
148
346
  const { importCommand } = await import("./import.js").then((n) => n.n);
149
347
  try {
@@ -191,6 +389,31 @@ async function main() {
191
389
  if (!/^\d+$/.test(workersFlag) || Number(workersFlag) < 1) fail(`--workers must be a positive integer, got '${workersFlag}'`);
192
390
  workers = Number(workersFlag);
193
391
  }
392
+ let cache;
393
+ if (flags.has("cache")) {
394
+ const dir = flags.get("cache") || ".gscache";
395
+ const modeFlag = flags.get("cache-mode");
396
+ let mode = "read-write";
397
+ if (modeFlag !== void 0) {
398
+ if (modeFlag !== "read-write" && modeFlag !== "read-only" && modeFlag !== "off") fail(`--cache-mode must be read-write|read-only|off, got '${modeFlag}'`);
399
+ mode = modeFlag;
400
+ }
401
+ let maxSize;
402
+ const maxSizeFlag = flags.get("cache-max-size");
403
+ if (maxSizeFlag !== void 0 && maxSizeFlag !== "") {
404
+ const { parseCacheMaxSize } = await import("./frameCache.js").then((n) => n.s);
405
+ try {
406
+ maxSize = parseCacheMaxSize(maxSizeFlag);
407
+ } catch (err) {
408
+ fail(err instanceof Error ? err.message : String(err));
409
+ }
410
+ }
411
+ cache = {
412
+ dir,
413
+ mode,
414
+ ...maxSize !== void 0 ? { maxSize } : {}
415
+ };
416
+ }
194
417
  if (flags.has("watch")) process.stderr.write("note: --watch is not yet implemented in this release; rendering once\n");
195
418
  const fpsFlag = flags.get("fps");
196
419
  const started = performance.now();
@@ -211,10 +434,12 @@ async function main() {
211
434
  ...workers !== void 0 ? { workers } : {},
212
435
  ...flags.has("lossless-intermediate") ? { losslessIntermediate: true } : {},
213
436
  ...flags.has("allow-gpu-shards") ? { allowGpuShards: true } : {},
437
+ ...cache !== void 0 ? { cache } : {},
214
438
  captions: parseCaptionsModeOrFail(flags.get("captions")),
215
439
  narration: flags.get("narration") === "off" ? "off" : "auto",
216
440
  music: flags.get("music") === "off" ? "off" : "auto",
217
441
  sfx: flags.get("sfx") === "off" ? "off" : "auto",
442
+ loudness: flags.get("loudness") === "off" ? "off" : "auto",
218
443
  onProgress: (n, total) => {
219
444
  if (process.stderr.isTTY) {
220
445
  if (n % 30 === 0 || n === total) process.stderr.write(`\rrendering ${n}/${total} frames`);
package/dist/diff.js ADDED
@@ -0,0 +1,66 @@
1
+ import { t as __exportAll } from "./rolldown-runtime.js";
2
+ import { a as loadSceneModule } from "./render.js";
3
+ import { readFileSync } from "node:fs";
4
+ import { diffDisplayLists, evaluate, formatDisplayDiff, parseDisplaySnapshot, serializeDisplayList } from "@glissade/scene";
5
+ //#region src/diff.ts
6
+ /**
7
+ * gs diff (DESIGN.md §3.3): turn an opaque golden-hash mismatch into a
8
+ * command-level explanation. Evaluate a scene at `--at <t>` to its pure
9
+ * DisplayList, then compare against a committed baseline:
10
+ *
11
+ * --against <baseline.dl.json> → structural, index-aligned command diff
12
+ * (parse → diffDisplayLists → command tree)
13
+ * --against <baseline.png> → raw encodePng byte-compare ONLY (no pixel
14
+ * diff algorithm, no new raster path)
15
+ *
16
+ * Operates entirely on the already-pure IR (no audio). Exits non-zero on any
17
+ * divergence so it slots into CI / a golden-update workflow.
18
+ */
19
+ var diff_exports = /* @__PURE__ */ __exportAll({
20
+ diffCommand: () => diffCommand,
21
+ evaluateAt: () => evaluateAt,
22
+ snapshotAt: () => snapshotAt
23
+ });
24
+ /** Evaluate the scene module at `t` to its DisplayList (single-frame, no audio). */
25
+ async function evaluateAt(modulePath, t) {
26
+ const mod = await loadSceneModule(modulePath);
27
+ return evaluate(mod.createScene(), mod.timeline, t);
28
+ }
29
+ async function diffCommand(opts) {
30
+ if (opts.against.endsWith(".png")) return diffAgainstPng(opts);
31
+ if (opts.against.endsWith(".dl.json")) return diffAgainstSnapshot(opts);
32
+ throw new Error(`--against must be a .dl.json or .png baseline, got '${opts.against}'`);
33
+ }
34
+ async function diffAgainstSnapshot(opts) {
35
+ const mod = await loadSceneModule(opts.modulePath);
36
+ const actual = evaluate(mod.createScene(), mod.timeline, opts.at);
37
+ const diff = diffDisplayLists(parseDisplaySnapshot(readFileSync(opts.against, "utf8")), actual);
38
+ return {
39
+ equal: diff.equal,
40
+ report: diff.equal ? `match: ${actual.commands.length} commands identical to ${opts.against}` : `${opts.against} (baseline) -> scene @ ${opts.at}s\n${formatDisplayDiff(diff)}`,
41
+ actual
42
+ };
43
+ }
44
+ async function diffAgainstPng(opts) {
45
+ const { SkiaBackend } = await import("@glissade/backend-skia");
46
+ const mod = await loadSceneModule(opts.modulePath);
47
+ const scene = mod.createScene();
48
+ const backend = new SkiaBackend(scene.size.w, scene.size.h);
49
+ scene.setTextMeasurer(backend);
50
+ const actual = evaluate(scene, mod.timeline, opts.at);
51
+ backend.render(actual);
52
+ const actualPng = backend.encodePng();
53
+ const baselinePng = readFileSync(opts.against);
54
+ const equal = actualPng.equals(baselinePng);
55
+ return {
56
+ equal,
57
+ report: equal ? `match: rendered PNG is byte-identical to ${opts.against}` : `PNG mismatch vs ${opts.against}: baseline ${baselinePng.length}B, rendered ${actualPng.length}B\n(byte-compare only; run with --against a .dl.json baseline for a command-level diff)`,
58
+ actual
59
+ };
60
+ }
61
+ /** Snapshot a scene's DisplayList at `t` to a `.dl.json` string (the baseline writer). */
62
+ async function snapshotAt(modulePath, t) {
63
+ return serializeDisplayList(await evaluateAt(modulePath, t));
64
+ }
65
+ //#endregion
66
+ export { snapshotAt as i, diff_exports as n, evaluateAt as r, diffCommand as t };
package/dist/fonts.js ADDED
@@ -0,0 +1,122 @@
1
+ import { a as loadSceneModule } from "./render.js";
2
+ import { readFile } from "node:fs/promises";
3
+ import { buildFontRegistry } from "@glissade/core";
4
+ import { collectTextUsages } from "@glissade/scene";
5
+ //#region src/fonts.ts
6
+ /**
7
+ * gs fonts audit (DESIGN.md §3.6) — the font front-door report. Loads a scene
8
+ * module, builds the FontRegistry from its timeline assets, ingests each face
9
+ * (sniff format → woff2 decode → static instance, via @glissade/core/font-ingest),
10
+ * and prints per family: the declared faces, the on-disk format, the cmap
11
+ * coverage size, and any missing-glyph RUNS for the text the scene actually
12
+ * renders ("héllo 👋 renders emoji in Chrome, tofu in Skia").
13
+ *
14
+ * Read-only and offline: no rasterization, no network. The same ingest path the
15
+ * render/prepare steps use, so the audit reflects exactly what would ship.
16
+ */
17
+ function codePointsOf(text) {
18
+ const out = [];
19
+ for (const ch of text) {
20
+ const cp = ch.codePointAt(0);
21
+ if (cp !== void 0) out.push(cp);
22
+ }
23
+ return out;
24
+ }
25
+ /** Collapse a sorted, de-duped code-point list into [start,end] inclusive runs. */
26
+ function toRuns(cps) {
27
+ const sorted = [...new Set(cps)].sort((a, b) => a - b);
28
+ const runs = [];
29
+ for (const cp of sorted) {
30
+ const last = runs[runs.length - 1];
31
+ if (last && cp === last.end + 1) last.end = cp;
32
+ else runs.push({
33
+ start: cp,
34
+ end: cp
35
+ });
36
+ }
37
+ return runs;
38
+ }
39
+ function hex(cp) {
40
+ return "U+" + cp.toString(16).toUpperCase().padStart(4, "0");
41
+ }
42
+ /**
43
+ * Build the audit report for a scene module. `resolvePath` maps an asset url to
44
+ * an absolute path to read (the CLI resolves relative to the scene module).
45
+ */
46
+ async function auditSceneFonts(modulePath, resolvePath) {
47
+ const mod = await loadSceneModule(modulePath);
48
+ const scene = mod.createScene();
49
+ const doc = mod.timeline;
50
+ const registry = buildFontRegistry(doc.assets);
51
+ const ingest = await import("@glissade/core/font-ingest");
52
+ const faceCoverage = /* @__PURE__ */ new Map();
53
+ const families = [];
54
+ for (const family of [...new Set(registry.faces().map((f) => f.family))].sort()) {
55
+ const faceReports = [];
56
+ for (const face of registry.faces().filter((f) => f.family === family)) {
57
+ let format = "missing";
58
+ let coverage = -1;
59
+ try {
60
+ const src = await readFile(resolvePath(face.url));
61
+ const result = await ingest.ingestFont({
62
+ family: face.family,
63
+ src
64
+ });
65
+ format = result.sourceFormat;
66
+ coverage = result.coverage.size;
67
+ faceCoverage.set(`${family}|${face.url}`, result.coverage);
68
+ } catch {}
69
+ faceReports.push({
70
+ family: face.family,
71
+ url: face.url,
72
+ weight: face.weight,
73
+ style: face.style,
74
+ format,
75
+ coverage
76
+ });
77
+ }
78
+ const missing = /* @__PURE__ */ new Set();
79
+ const usages = collectTextUsages(scene).filter((u) => u.family === family);
80
+ if (usages.length > 0) {
81
+ const covered = /* @__PURE__ */ new Set();
82
+ for (const face of registry.faces().filter((f) => f.family === family)) {
83
+ const cov = faceCoverage.get(`${family}|${face.url}`);
84
+ if (cov) for (const cp of cov) covered.add(cp);
85
+ }
86
+ for (const u of usages) for (const cp of codePointsOf(u.text)) if (!covered.has(cp)) missing.add(cp);
87
+ }
88
+ families.push({
89
+ family,
90
+ faces: faceReports,
91
+ missingRuns: toRuns([...missing])
92
+ });
93
+ }
94
+ return { families };
95
+ }
96
+ /** Render the audit report as the human-readable text `gs fonts audit` prints. */
97
+ function formatFontAudit(report) {
98
+ if (report.families.length === 0) return "no font families registered in this scene";
99
+ const lines = [];
100
+ for (const fam of report.families) {
101
+ lines.push(`${fam.family}`);
102
+ for (const f of fam.faces) {
103
+ const cov = f.coverage >= 0 ? `${f.coverage} glyphs` : "unreadable";
104
+ lines.push(` - ${f.weight}/${f.style} ${f.format.padEnd(10)} ${cov} (${f.url})`);
105
+ }
106
+ if (fam.missingRuns.length > 0) {
107
+ const runs = fam.missingRuns.map((r) => r.start === r.end ? hex(r.start) : `${hex(r.start)}–${hex(r.end)}`).join(", ");
108
+ lines.push(` ! missing glyphs for used text: ${runs}`);
109
+ }
110
+ }
111
+ return lines.join("\n");
112
+ }
113
+ /** The `gs fonts audit <scene-module>` entry point. Returns the report + text. */
114
+ async function fontsAuditCommand(args) {
115
+ const report = await auditSceneFonts(args.modulePath, args.resolvePath);
116
+ return {
117
+ report,
118
+ text: formatFontAudit(report)
119
+ };
120
+ }
121
+ //#endregion
122
+ export { fontsAuditCommand };