@glissade/cli 0.11.0 → 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/render.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { t as __exportAll } from "./rolldown-runtime.js";
1
2
  import { spawnSync } from "node:child_process";
2
3
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
4
  import { tmpdir } from "node:os";
@@ -14,6 +15,17 @@ import { SkiaBackend } from "@glissade/backend-skia";
14
15
  * rasterize on Skia, write a PNG sequence — and mux to mp4/webm via FFmpeg
15
16
  * when requested and available. No browser anywhere.
16
17
  */
18
+ var render_exports = /* @__PURE__ */ __exportAll({
19
+ SceneModuleError: () => SceneModuleError,
20
+ buildMixWav: () => buildMixWav,
21
+ collectAudioClips: () => collectAudioClips,
22
+ ffmpegAvailable: () => ffmpegAvailable,
23
+ loadSceneModule: () => loadSceneModule,
24
+ parseFrameRange: () => parseFrameRange,
25
+ planFinalAudio: () => planFinalAudio,
26
+ render: () => render,
27
+ resolveLoudnessGainDb: () => resolveLoudnessGainDb
28
+ });
17
29
  var SceneModuleError = class extends Error {
18
30
  constructor(modulePath, detail) {
19
31
  super(`${modulePath}: ${detail}\nA scene module default-exports { createScene(): Scene, timeline: Timeline } (SceneModule).`);
@@ -101,11 +113,23 @@ async function render(opts) {
101
113
  await loadYogaLayoutEngine();
102
114
  }
103
115
  const videoSources = [];
104
- const { resolveAssetPath: resolveAsset } = await import("./audioMix.js").then((n) => n.r);
116
+ const { resolveAssetPath: resolveAsset } = await import("./audioMix.js").then((n) => n.i);
105
117
  const fontRegistry = buildFontRegistry(doc.assets);
106
118
  if (fontRegistry.faces().length > 0) {
107
119
  const { GlobalFonts } = await import("@napi-rs/canvas");
108
- for (const face of fontRegistry.faces()) GlobalFonts.registerFromPath(resolveAsset(face.url, opts.modulePath), face.family);
120
+ let ingest;
121
+ for (const face of fontRegistry.faces()) {
122
+ const path = resolveAsset(face.url, opts.modulePath);
123
+ if (/\.woff2?$/i.test(face.url)) {
124
+ ingest ??= await import("@glissade/core/font-ingest");
125
+ const src = await readFile(path);
126
+ const result = await ingest.ingestFont({
127
+ family: face.family,
128
+ src
129
+ });
130
+ GlobalFonts.register(Buffer.from(result.bytes), face.family);
131
+ } else GlobalFonts.registerFromPath(path, face.family);
132
+ }
109
133
  }
110
134
  await validateSceneFonts(scene, doc, async (url) => {
111
135
  try {
@@ -126,12 +150,48 @@ async function render(opts) {
126
150
  backend.setVideoAsset(assetId, source);
127
151
  videoSources.push(source);
128
152
  }
153
+ let frameCache;
154
+ let keyCtx;
155
+ if (opts.cache && opts.cache.mode !== "off") {
156
+ const { FrameCache, capsId } = await import("./frameCache.js").then((n) => n.s);
157
+ const { glissadeVersion } = await import("./version.js").then((n) => n.n);
158
+ frameCache = new FrameCache({
159
+ dir: opts.cache.dir,
160
+ mode: opts.cache.mode,
161
+ ...opts.cache.maxSize !== void 0 ? { maxSize: opts.cache.maxSize } : {}
162
+ });
163
+ keyCtx = {
164
+ version: glissadeVersion(),
165
+ capsId: capsId(backend.caps)
166
+ };
167
+ }
129
168
  for (let f = firstFrame; f <= lastFrame; f++) {
130
- backend.render(withDeterminismGuards("throw", () => evaluate(scene, doc, f / fps)));
131
- writeFileSync(singleFile ? resolve(opts.out) : join(framesDir, `frame-${String(f).padStart(5, "0")}.png`), backend.encodePng());
169
+ const dl = withDeterminismGuards("throw", () => evaluate(scene, doc, f / fps));
170
+ let pngBytes;
171
+ if (frameCache && keyCtx) {
172
+ const { frameCacheKey } = await import("./frameCache.js").then((n) => n.s);
173
+ const key = frameCacheKey(dl, keyCtx);
174
+ const cached = frameCache.get(key);
175
+ if (cached) {
176
+ backend.putPixels(cached);
177
+ pngBytes = backend.encodePng();
178
+ } else {
179
+ backend.render(dl);
180
+ pngBytes = backend.encodePng();
181
+ frameCache.put(key, scene.size.w, scene.size.h, await backend.readPixels());
182
+ }
183
+ } else {
184
+ backend.render(dl);
185
+ pngBytes = backend.encodePng();
186
+ }
187
+ writeFileSync(singleFile ? resolve(opts.out) : join(framesDir, `frame-${String(f).padStart(5, "0")}.png`), pngBytes);
132
188
  opts.onProgress?.(f - firstFrame + 1, total);
133
189
  }
134
190
  backend.dispose();
191
+ if (frameCache) {
192
+ const s = frameCache.getStats();
193
+ process.stderr.write(`cache (${opts.cache.mode}): ${s.hits} hit${s.hits === 1 ? "" : "s"}, ${s.misses} miss${s.misses === 1 ? "" : "es"}` + (s.stored ? `, ${s.stored} stored` : "") + (s.evicted ? `, ${s.evicted} evicted (LRU cap ${frameCache.maxSize} B)` : "") + ` → ${opts.cache.dir}\n`);
194
+ }
135
195
  for (const source of videoSources) source.close();
136
196
  const emitSidecars = (target) => {
137
197
  if (captionsMode === "off") return;
@@ -222,13 +282,12 @@ async function render(opts) {
222
282
  };
223
283
  }
224
284
  /**
225
- * Collect timeline + auto-mixed (narration/music/sfx) audio clips and plan the
226
- * FFmpeg audio graph, returning the `-i`/`-filter_complex`/`-map` argument
227
- * fragments. Shared by the linear `render()` path and the sharded orchestrator
228
- * (which mixes audio once, over the concatenated video). Returns empty args when
229
- * there is nothing to mix.
285
+ * Collect the timeline + auto-mixed (narration/music/sfx) audio clips for a
286
+ * scene the shared front half of the mix used by both `planFinalAudio` (the
287
+ * render/shard path) and `buildMixWav` (the measure-loudness path), so the mix
288
+ * CONTENT measured at commit-time is byte-for-byte the mix rendered later.
230
289
  */
231
- async function planFinalAudio(opts, timelineClips, duration, container) {
290
+ async function collectAudioClips(opts, timelineClips) {
232
291
  const { timingPathFor } = await import("./captions.js").then((n) => n.t);
233
292
  const audioClips = [...timelineClips];
234
293
  const { bedAlreadyReferenced, buildMusicClip, buildNarrationClips, musicPathFor } = await import("./music.js");
@@ -266,7 +325,39 @@ async function planFinalAudio(opts, timelineClips, duration, container) {
266
325
  }
267
326
  }
268
327
  }
269
- const { planAudioMix } = await import("./audioMix.js").then((n) => n.r);
328
+ return audioClips;
329
+ }
330
+ /**
331
+ * Resolve the committed publish gain (dB) for a render: read `<scene>.loudness.json`
332
+ * (when present and `loudness !== 'off'`), recompute the mixHash over the current
333
+ * mix inputs, and HARD-THROW on a mismatch — a re-narrate/re-sfx must invalidate
334
+ * the measurement loudly rather than silently mis-normalize. Returns null when no
335
+ * measurement is committed or loudness is off (no gain applied).
336
+ */
337
+ async function resolveLoudnessGainDb(opts) {
338
+ if ((opts.loudness ?? "auto") === "off") return null;
339
+ const { readLoudness, computeMixHash, loudnessPathFor } = await import("./loudness.js").then((n) => n.c);
340
+ const measurement = readLoudness(opts.modulePath);
341
+ if (!measurement) return null;
342
+ const actual = computeMixHash(opts.modulePath);
343
+ if (actual !== measurement.mixHash) throw new Error(`loudness: ${loudnessPathFor(opts.modulePath)} is stale — the mix inputs changed since it was measured (committed mixHash ${measurement.mixHash.slice(0, 23)}…, current ${actual.slice(0, 23)}…). Re-run \`gs measure-loudness ${opts.modulePath}\` (or pass --loudness off to render without normalization).`);
344
+ return measurement.gain;
345
+ }
346
+ /**
347
+ * Collect audio clips + auto-mixed siblings and plan the FFmpeg audio graph,
348
+ * returning the `-i`/`-filter_complex`/`-map` argument fragments. Shared by the
349
+ * linear `render()` path and the sharded orchestrator (which mixes audio once,
350
+ * over the concatenated video). Returns empty args when there is nothing to mix.
351
+ *
352
+ * When a committed `<scene>.loudness.json` applies, its publish gain is appended
353
+ * as a PURE `volume=<gain>dB` scalar on the FINAL mix node — a single scalar in
354
+ * the existing graph, NOT a second pass. The scalar gain is bit-deterministic
355
+ * (verified) and golden-hashable; the only non-deterministic stages (mix-to-PCM,
356
+ * measure-time ebur128) are quarantined to commit/measure-time per §5.3.
357
+ */
358
+ async function planFinalAudio(opts, timelineClips, duration, container) {
359
+ const audioClips = await collectAudioClips(opts, timelineClips);
360
+ const { planAudioMix, applyMixGainDb } = await import("./audioMix.js").then((n) => n.i);
270
361
  const { pickEncoder } = await import("./encoders.js").then((n) => n.r);
271
362
  const mix = planAudioMix(audioClips, opts.modulePath, duration);
272
363
  if (mix?.hasEasedGain) process.stderr.write("note: eased gain keys are approximated linearly in the FFmpeg mix\n");
@@ -274,12 +365,15 @@ async function planFinalAudio(opts, timelineClips, duration, container) {
274
365
  audioInputs: [],
275
366
  audioArgs: []
276
367
  };
368
+ const gainDb = await resolveLoudnessGainDb(opts);
369
+ const filterComplex = gainDb !== null ? applyMixGainDb(mix.filterComplex, gainDb) : mix.filterComplex;
370
+ if (gainDb !== null) process.stderr.write(`note: applying committed publish loudness gain ${gainDb.toFixed(2)} dB (single-pass scalar)\n`);
277
371
  const audioEnc = pickEncoder("audio", container);
278
372
  return {
279
373
  audioInputs: mix.inputs.flatMap((p) => ["-i", p]),
280
374
  audioArgs: [
281
375
  "-filter_complex",
282
- mix.filterComplex,
376
+ filterComplex,
283
377
  "-map",
284
378
  "0:v",
285
379
  "-map",
@@ -290,5 +384,51 @@ async function planFinalAudio(opts, timelineClips, duration, container) {
290
384
  ]
291
385
  };
292
386
  }
387
+ /**
388
+ * Build the FINAL mixed audio to a WAV file — the measure-loudness input. Uses
389
+ * the SAME `collectAudioClips` + `planAudioMix` as the render path (no loudness
390
+ * gain — measurement is of the un-gained mix), so the measured content is exactly
391
+ * what render will later mix. Returns false when the scene has no audio.
392
+ */
393
+ async function buildMixWav(opts, wavOut) {
394
+ if (!ffmpegAvailable()) throw new Error("gs measure-loudness needs FFmpeg on PATH and none was found.");
395
+ const mod = await loadSceneModule(opts.modulePath);
396
+ const scene = mod.createScene();
397
+ const { compileTimeline } = await import("@glissade/core");
398
+ const compiled = compileTimeline(mod.timeline);
399
+ const duration = compiled.duration;
400
+ const audioClips = await collectAudioClips(opts, [...compiled.audio]);
401
+ const { planAudioMix } = await import("./audioMix.js").then((n) => n.i);
402
+ const mix = planAudioMix(audioClips, opts.modulePath, duration);
403
+ if (!mix) return false;
404
+ const result = spawnSync("ffmpeg", [
405
+ "-y",
406
+ "-hide_banner",
407
+ "-nostats",
408
+ "-f",
409
+ "lavfi",
410
+ "-i",
411
+ "anullsrc=channel_layout=stereo:sample_rate=48000",
412
+ ...mix.inputs.flatMap((p) => ["-i", p]),
413
+ "-filter_complex",
414
+ mix.filterComplex,
415
+ "-map",
416
+ "[aout]",
417
+ "-c:a",
418
+ "pcm_s16le",
419
+ "-ar",
420
+ "48000",
421
+ "-t",
422
+ String(duration),
423
+ wavOut
424
+ ], { stdio: [
425
+ "ignore",
426
+ "ignore",
427
+ "pipe"
428
+ ] });
429
+ if (result.status !== 0) throw new Error(`ffmpeg mix-to-WAV failed (exit ${result.status}):\n${result.stderr?.toString().slice(-2e3)}`);
430
+ scene.size;
431
+ return true;
432
+ }
293
433
  //#endregion
294
- export { planFinalAudio as a, parseFrameRange as i, ffmpegAvailable as n, render as o, loadSceneModule as r, SceneModuleError as t };
434
+ export { loadSceneModule as a, render as c, ffmpegAvailable as i, render_exports as l, buildMixWav as n, parseFrameRange as o, collectAudioClips as r, planFinalAudio as s, SceneModuleError as t, resolveLoudnessGainDb as u };
@@ -1,3 +1,4 @@
1
+ import "node:module";
1
2
  //#region \0rolldown/runtime.js
2
3
  var __defProp = Object.defineProperty;
3
4
  var __exportAll = (all, no_symbols) => {
package/dist/shards.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime.js";
2
- import { a as planFinalAudio } from "./render.js";
2
+ import { s as planFinalAudio } 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, rmSync, writeFileSync } from "node:fs";
@@ -0,0 +1,302 @@
1
+ import { t as SceneModuleError } from "./render.js";
2
+ import { a as splitFrameRange } from "./shards.js";
3
+ import { readFileSync } from "node:fs";
4
+ import { isAbsolute, resolve } from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+ import { createJiti } from "jiti";
7
+ import { createDisplayListBuilder, diffDisplayLists, evaluate, formatDisplayDiff, serializeDisplayList, withDeterminismGuards } from "@glissade/scene";
8
+ import { createHash } from "node:crypto";
9
+ //#region src/verifyDeterminism.ts
10
+ /**
11
+ * gs verify-determinism (DESIGN.md §5.5/§5.6 §7): the cross-shard / cross-machine
12
+ * divergence LOCATOR. It is the VERIFIER of the determinism tenet — it never
13
+ * perturbs the contract: it evaluates under the SAME `withDeterminismGuards('throw')`
14
+ * as `gs render`, hashes the SAME raw RGBA the FFmpeg pipe consumes, and reuses the
15
+ * SHIPPED DisplayList serializer for the per-node sub-hashes. It renders no new
16
+ * pixels through a path the goldens don't already pin.
17
+ *
18
+ * The authoritative byte check is the per-frame `sha256(RGBA)` over the raw
19
+ * `backend.readPixels()` (NOT `encodePng` — sidestepping any PNG-encoder
20
+ * nondeterminism). The per-node sub-hashes LOCALIZE where a frame diverged; they
21
+ * are a locator, not the authority — a node whose isolated emit() depends on the
22
+ * parent CTM may sub-hash differently in isolation than it draws in context.
23
+ *
24
+ * HONEST SCOPE (§5.5 item 6): the byte-equality guarantee is Skia↔Skia ONLY
25
+ * (cross-machine / cross-shard, same pinned toolchain). Browser↔Skia is perceptual
26
+ * (SSIM), never byte-identity — so comparing a non-Skia manifest by byte-hash is a
27
+ * CATEGORY ERROR and is REJECTED with a clear error. The manifest stamps `backend`
28
+ * so an `--against` cross-backend compare is caught.
29
+ */
30
+ /**
31
+ * Load a scene module with a FRESH module cache (`moduleCache: false`), so each
32
+ * call re-runs the module's top level from scratch — exactly what a separate `gs`
33
+ * shard process does. This is the faithful in-process model of cross-process
34
+ * sharding: it resets any module-level state (the §5.5-item-5 cross-frame
35
+ * accumulation a render shard child wouldn't share), so a stateful impurity
36
+ * actually diverges here instead of being masked by a shared module instance.
37
+ */
38
+ async function freshLoadSceneModule(modulePath) {
39
+ const abs = isAbsolute(modulePath) ? modulePath : resolve(process.cwd(), modulePath);
40
+ const loaded = await createJiti(pathToFileURL(process.cwd() + "/").href, { moduleCache: false }).import(pathToFileURL(abs).href, { default: true });
41
+ if (typeof loaded?.createScene !== "function" || loaded?.timeline === void 0) throw new SceneModuleError(modulePath, "default export is not a SceneModule");
42
+ return loaded;
43
+ }
44
+ var VerifyDeterminismError = class extends Error {
45
+ constructor(message) {
46
+ super(message);
47
+ this.name = "VerifyDeterminismError";
48
+ }
49
+ };
50
+ /** sha256 hex of arbitrary bytes / string (the committed-byte-hash precedent — node:crypto, no BLAKE3). */
51
+ function sha256(data) {
52
+ return createHash("sha256").update(data).digest("hex");
53
+ }
54
+ /**
55
+ * Per-node DisplayList sub-hashes for one frame: each node's emit() in ISOLATION,
56
+ * serialized through the SHIPPED `serializeDisplayList` (the byte-preserving
57
+ * collapse serializer that backs `.dl.json` + the cacheKey), then sha256'd. This
58
+ * mirrors `auditCacheCold`'s per-node emit — the divergence locator.
59
+ */
60
+ function nodeSubHashes(scene, ctx) {
61
+ const out = {};
62
+ for (const [id, node] of scene.nodes) {
63
+ const b = createDisplayListBuilder(scene.size);
64
+ node.emit(b, ctx);
65
+ out[id] = sha256(serializeDisplayList(b.finish()));
66
+ }
67
+ return out;
68
+ }
69
+ /**
70
+ * Build a frames manifest for an inclusive frame range. Each frame is evaluated
71
+ * under `withDeterminismGuards('throw')` (EXACTLY as render.ts does — any
72
+ * clock/random/timer call in scene code throws HERE during verification),
73
+ * rasterized on Skia, and the raw RGBA from `readPixels()` is sha256'd.
74
+ *
75
+ * Asset loading (fonts/images/video) is intentionally out of scope: this is a
76
+ * geometry/raster determinism probe over the pure DisplayList path. A scene with
77
+ * external assets still verifies its drawn output; unregistered fonts fall back
78
+ * deterministically (the same as the linear render path with no registration).
79
+ */
80
+ async function buildManifest(mod, first, last, fpsOverride) {
81
+ const { SkiaBackend } = await import("@glissade/backend-skia");
82
+ const scene = mod.createScene();
83
+ const doc = mod.timeline;
84
+ const fps = fpsOverride ?? doc.fps ?? 60;
85
+ const backend = new SkiaBackend(scene.size.w, scene.size.h);
86
+ scene.setTextMeasurer(backend);
87
+ const frames = [];
88
+ for (let f = first; f <= last; f++) {
89
+ const t = f / fps;
90
+ const dl = withDeterminismGuards("throw", () => evaluate(scene, doc, t));
91
+ backend.render(dl);
92
+ const rgba = await backend.readPixels();
93
+ const ctx = {
94
+ time: t,
95
+ frame: doc.fps !== void 0 ? Math.round(t * doc.fps) : Math.round(t * fps),
96
+ measurer: scene.textMeasurer
97
+ };
98
+ frames.push({
99
+ frame: f,
100
+ hash: sha256(new Uint8Array(rgba.buffer, rgba.byteOffset, rgba.byteLength)),
101
+ nodes: nodeSubHashes(scene, ctx)
102
+ });
103
+ }
104
+ backend.dispose();
105
+ const groups = [];
106
+ for (const [id, node] of scene.nodes) if (Array.isArray(node.children)) groups.push(id);
107
+ return {
108
+ manifestVersion: 1,
109
+ backend: "skia",
110
+ size: scene.size,
111
+ fps,
112
+ groups,
113
+ frames
114
+ };
115
+ }
116
+ function serializeManifest(m) {
117
+ return JSON.stringify(m, null, 2);
118
+ }
119
+ function parseManifest(json) {
120
+ const doc = JSON.parse(json);
121
+ if (doc.manifestVersion !== 1) throw new VerifyDeterminismError(`unsupported manifestVersion ${String(doc.manifestVersion)}; this build reads 1`);
122
+ if (doc.backend === void 0 || !doc.size || doc.fps === void 0 || !Array.isArray(doc.frames) || !Array.isArray(doc.groups)) throw new VerifyDeterminismError("malformed frames.manifest (need backend, size, fps, groups[], frames[])");
123
+ return doc;
124
+ }
125
+ /**
126
+ * Compare two manifests frame-by-frame. The full-frame RGBA hash is authoritative;
127
+ * the per-node sub-hashes localize WHERE a divergent frame differs. Stops at the
128
+ * FIRST divergent frame (the bisect target).
129
+ */
130
+ function compareManifests(a, b) {
131
+ if (a.backend !== b.backend) throw new VerifyDeterminismError(`cross-backend byte-compare rejected: '${a.backend}' vs '${b.backend}'. Byte-equality is a Skia↔Skia (cross-machine/shard) guarantee only; browser↔Skia is perceptual (SSIM) parity, never byte-identity (§5.5 item 6).`);
132
+ const byFrameB = new Map(b.frames.map((e) => [e.frame, e]));
133
+ let compared = 0;
134
+ for (const ea of a.frames) {
135
+ const eb = byFrameB.get(ea.frame);
136
+ if (!eb) continue;
137
+ compared++;
138
+ if (ea.hash === eb.hash) continue;
139
+ const groups = new Set(a.groups);
140
+ let node;
141
+ let groupFallback;
142
+ for (const [id, ha] of Object.entries(ea.nodes)) {
143
+ if (eb.nodes[id] === ha) continue;
144
+ if (groups.has(id)) {
145
+ groupFallback ??= id;
146
+ continue;
147
+ }
148
+ node = id;
149
+ break;
150
+ }
151
+ node ??= groupFallback;
152
+ return {
153
+ ok: false,
154
+ compared,
155
+ divergence: {
156
+ frame: ea.frame,
157
+ hash: {
158
+ a: ea.hash,
159
+ b: eb.hash
160
+ },
161
+ ...node !== void 0 ? { node } : {}
162
+ }
163
+ };
164
+ }
165
+ return {
166
+ ok: true,
167
+ compared
168
+ };
169
+ }
170
+ /**
171
+ * Replay a fresh scene through frames `[from..frame]` to reach the SAME module
172
+ * state the divergent render had at `frame`, then return the divergent NODE's
173
+ * isolated DisplayList at `frame`. Replaying the leading frames is what makes a
174
+ * cross-frame-state impurity reproduce here: the linear side replays from the
175
+ * range start, the shard side replays from the shard's sub-range start, so a
176
+ * counter/accumulator reaches different values exactly as it did in the manifests.
177
+ */
178
+ function replayNodeEmit(mod, from, frame, fps, node) {
179
+ const scene = mod.createScene();
180
+ const target = scene.nodes.get(node);
181
+ if (!target) return void 0;
182
+ const docFps = mod.timeline.fps;
183
+ return withDeterminismGuards("throw", () => {
184
+ for (let f = from; f <= frame; f++) evaluate(scene, mod.timeline, f / fps);
185
+ const t = frame / fps;
186
+ const ctx = {
187
+ time: t,
188
+ frame: docFps !== void 0 ? Math.round(t * docFps) : Math.round(t * fps),
189
+ measurer: scene.textMeasurer
190
+ };
191
+ const b = createDisplayListBuilder(scene.size);
192
+ target.emit(b, ctx);
193
+ return b.finish();
194
+ });
195
+ }
196
+ /** Produce the command-level drill (`diffDisplayLists` + `formatDisplayDiff`) of the divergent node. */
197
+ async function runBisect(plan) {
198
+ const modA = await freshLoadSceneModule(plan.modulePath);
199
+ const modB = await freshLoadSceneModule(plan.modulePath);
200
+ const dlA = replayNodeEmit(modA, plan.fromA, plan.frame, plan.fps, plan.node);
201
+ const dlB = replayNodeEmit(modB, plan.fromB, plan.frame, plan.fps, plan.node);
202
+ if (!dlA || !dlB) return `node '${plan.node}' is not present in both scenes — cannot drill`;
203
+ return formatDisplayDiff(diffDisplayLists(dlA, dlB));
204
+ }
205
+ /**
206
+ * Resolve the inclusive frame range to verify. Mirrors render.ts: an explicit
207
+ * --range wins; otherwise the whole timeline [0, ceil(duration*fps)-1].
208
+ */
209
+ async function resolveRange(mod, opts) {
210
+ const fps = opts.fps ?? mod.timeline.fps ?? 60;
211
+ if (opts.frameRange) {
212
+ const [a, b] = opts.frameRange;
213
+ return {
214
+ first: a,
215
+ last: Math.max(a, b),
216
+ fps
217
+ };
218
+ }
219
+ const { compileTimeline } = await import("@glissade/core");
220
+ const duration = compileTimeline(mod.timeline).duration;
221
+ return {
222
+ first: 0,
223
+ last: Math.max(0, Math.ceil(duration * fps) - 1),
224
+ fps
225
+ };
226
+ }
227
+ async function verifyDeterminismCommand(opts) {
228
+ const mod = await freshLoadSceneModule(opts.modulePath);
229
+ const { first, last, fps } = await resolveRange(mod, opts);
230
+ if (opts.emit !== void 0) {
231
+ const manifest = await buildManifest(mod, first, last, fps);
232
+ const { writeFileSync } = await import("node:fs");
233
+ writeFileSync(opts.emit, serializeManifest(manifest));
234
+ return {
235
+ ok: true,
236
+ frames: manifest.frames.length,
237
+ report: `wrote ${manifest.frames.length}-frame manifest (frames ${first}..${last}) → ${opts.emit}`
238
+ };
239
+ }
240
+ const linear = await buildManifest(mod, first, last, fps);
241
+ if (opts.against !== void 0) {
242
+ const baseline = parseManifest(readFileSync(opts.against, "utf8"));
243
+ if (baseline.backend !== linear.backend) throw new VerifyDeterminismError(`cross-backend byte-compare rejected: baseline '${opts.against}' is backend '${baseline.backend}', this render is '${linear.backend}'. Byte-equality is a Skia↔Skia (cross-machine/shard) guarantee only; browser↔Skia is perceptual (SSIM) parity, never byte-identity (§5.5 item 6). Use SSIM parity for cross-backend.`);
244
+ return finalize(compareManifests(baseline, linear), opts, `linear render vs baseline ${opts.against}`, void 0);
245
+ }
246
+ if (opts.shards !== void 0 && opts.shards > 1) {
247
+ const ranges = splitFrameRange(first, last, opts.shards);
248
+ const shardFrames = [];
249
+ for (const r of ranges) {
250
+ const sub = await buildManifest(await freshLoadSceneModule(opts.modulePath), r.first, r.last, fps);
251
+ shardFrames.push(...sub.frames);
252
+ }
253
+ const cmp = compareManifests(linear, {
254
+ ...linear,
255
+ frames: shardFrames
256
+ });
257
+ const bisectFor = (d) => {
258
+ if (d.node === void 0) return void 0;
259
+ const shard = ranges.find((r) => d.frame >= r.first && d.frame <= r.last);
260
+ return {
261
+ modulePath: opts.modulePath,
262
+ node: d.node,
263
+ frame: d.frame,
264
+ fps,
265
+ fromA: first,
266
+ fromB: shard?.first ?? first
267
+ };
268
+ };
269
+ return finalize(cmp, opts, `linear vs ${ranges.length}-shard render (frames ${first}..${last})`, bisectFor);
270
+ }
271
+ return {
272
+ ok: true,
273
+ frames: linear.frames.length,
274
+ report: `built a ${linear.frames.length}-frame manifest (frames ${first}..${last}) under determinism guards — no clock/random violation. Pass --shards N or --against <manifest> to byte-compare.`
275
+ };
276
+ }
277
+ /** Shared tail: build the report + optional --bisect drill for a comparison result. */
278
+ async function finalize(cmp, opts, label, bisectFor) {
279
+ if (cmp.ok) return {
280
+ ok: true,
281
+ frames: cmp.compared,
282
+ report: `byte-identical: ${cmp.compared} frames match (${label})`
283
+ };
284
+ const d = cmp.divergence;
285
+ let report = `DIVERGENCE (${label})\n frame ${d.frame}: RGBA sha256 ${d.hash.a.slice(0, 16)}… != ${d.hash.b.slice(0, 16)}…\n` + (d.node !== void 0 ? ` first divergent node: '${d.node}' (sub-hash locator)` : " no per-node sub-hash differs (a parent-CTM / composite effect localized the frame hash only)");
286
+ if (opts.bisect && d.node !== void 0) {
287
+ const plan = bisectFor?.(d);
288
+ if (plan) {
289
+ const drill = await runBisect(plan);
290
+ d.bisect = drill;
291
+ report += `\n --bisect drill (node '${d.node}' @ frame ${d.frame}):\n${drill.replace(/^/gm, " ")}`;
292
+ } else report += `\n --bisect: no command-level drill available (an --against baseline carries hashes, not a scene)`;
293
+ }
294
+ return {
295
+ ok: false,
296
+ frames: cmp.compared,
297
+ divergence: d,
298
+ report
299
+ };
300
+ }
301
+ //#endregion
302
+ export { verifyDeterminismCommand };
@@ -0,0 +1,26 @@
1
+ import { t as __exportAll } from "./rolldown-runtime.js";
2
+ import { createRequire } from "node:module";
3
+ //#region src/version.ts
4
+ /**
5
+ * The glissade VERSION string folded into the persistent frame-cache key
6
+ * (DESIGN.md §3.5, 0.12). Lockstep `0.x` versioning means the @glissade/cli
7
+ * package version bumps with EVERY release, so any Raster2D-composite or
8
+ * Skia-toolchain change ships under a new version — making bump-on-version the
9
+ * cheapest CORRECT cache invalidation. Read once from the package manifest (the
10
+ * single source of truth) rather than hard-coded so it can never drift from the
11
+ * published version.
12
+ */
13
+ var version_exports = /* @__PURE__ */ __exportAll({ glissadeVersion: () => glissadeVersion });
14
+ let cached;
15
+ /** The @glissade/cli package version (the glissade VERSION for the cache key). */
16
+ function glissadeVersion() {
17
+ if (cached !== void 0) return cached;
18
+ try {
19
+ cached = createRequire(import.meta.url)("../package.json").version ?? "0.0.0";
20
+ } catch {
21
+ cached = "0.0.0";
22
+ }
23
+ return cached;
24
+ }
25
+ //#endregion
26
+ export { version_exports as n, glissadeVersion as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/cli",
3
- "version": "0.11.0",
3
+ "version": "0.12.0-pre.0",
4
4
  "description": "glissade CLI: headless rendering via backend-skia (+ FFmpeg mux).",
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
@@ -23,15 +23,15 @@
23
23
  "@napi-rs/canvas": "^0.1.65",
24
24
  "esbuild": "^0.28.0",
25
25
  "jiti": "^2.4.2",
26
- "@glissade/backend-skia": "0.11.0",
27
- "@glissade/core": "0.11.0",
28
- "@glissade/interact": "0.11.0",
29
- "@glissade/lottie": "0.11.0",
30
- "@glissade/svg": "0.11.0",
31
- "@glissade/narrate": "0.11.0",
32
- "@glissade/player": "0.11.0",
33
- "@glissade/scene": "0.11.0",
34
- "@glissade/sfx": "0.11.0"
26
+ "@glissade/backend-skia": "0.12.0-pre.0",
27
+ "@glissade/core": "0.12.0-pre.0",
28
+ "@glissade/interact": "0.12.0-pre.0",
29
+ "@glissade/lottie": "0.12.0-pre.0",
30
+ "@glissade/svg": "0.12.0-pre.0",
31
+ "@glissade/narrate": "0.12.0-pre.0",
32
+ "@glissade/player": "0.12.0-pre.0",
33
+ "@glissade/scene": "0.12.0-pre.0",
34
+ "@glissade/sfx": "0.12.0-pre.0"
35
35
  },
36
36
  "repository": {
37
37
  "type": "git",