@glissade/cli 0.11.0 → 0.12.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/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,18 @@ 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
+ collectMixAudioInputs: () => collectMixAudioInputs,
23
+ ffmpegAvailable: () => ffmpegAvailable,
24
+ loadSceneModule: () => loadSceneModule,
25
+ parseFrameRange: () => parseFrameRange,
26
+ planFinalAudio: () => planFinalAudio,
27
+ render: () => render,
28
+ resolveLoudnessGainDb: () => resolveLoudnessGainDb
29
+ });
17
30
  var SceneModuleError = class extends Error {
18
31
  constructor(modulePath, detail) {
19
32
  super(`${modulePath}: ${detail}\nA scene module default-exports { createScene(): Scene, timeline: Timeline } (SceneModule).`);
@@ -101,11 +114,30 @@ async function render(opts) {
101
114
  await loadYogaLayoutEngine();
102
115
  }
103
116
  const videoSources = [];
104
- const { resolveAssetPath: resolveAsset } = await import("./audioMix.js").then((n) => n.r);
117
+ const { resolveAssetPath: resolveAsset } = await import("./audioMix.js").then((n) => n.i);
118
+ const { createHash } = await import("node:crypto");
119
+ const assetDigests = /* @__PURE__ */ new Map();
120
+ const digestBytes = (bytes) => createHash("sha256").update(bytes).digest("hex");
105
121
  const fontRegistry = buildFontRegistry(doc.assets);
106
122
  if (fontRegistry.faces().length > 0) {
107
123
  const { GlobalFonts } = await import("@napi-rs/canvas");
108
- for (const face of fontRegistry.faces()) GlobalFonts.registerFromPath(resolveAsset(face.url, opts.modulePath), face.family);
124
+ let ingest;
125
+ for (const face of fontRegistry.faces()) {
126
+ const path = resolveAsset(face.url, opts.modulePath);
127
+ if (/\.woff2?$/i.test(face.url)) {
128
+ ingest ??= await import("@glissade/core/font-ingest");
129
+ const src = await readFile(path);
130
+ assetDigests.set(`font:${face.family}:${face.url}`, digestBytes(src));
131
+ const result = await ingest.ingestFont({
132
+ family: face.family,
133
+ src
134
+ });
135
+ GlobalFonts.register(Buffer.from(result.bytes), face.family);
136
+ } else {
137
+ assetDigests.set(`font:${face.family}:${face.url}`, digestBytes(await readFile(path)));
138
+ GlobalFonts.registerFromPath(path, face.family);
139
+ }
140
+ }
109
141
  }
110
142
  await validateSceneFonts(scene, doc, async (url) => {
111
143
  try {
@@ -117,21 +149,62 @@ async function render(opts) {
117
149
  }, { mode: opts.strictFonts ? "strict" : "dev" });
118
150
  for (const [assetId, ref] of Object.entries(doc.assets ?? {})) if (ref.kind === "font") {} else if (ref.kind === "image") {
119
151
  const { loadImage } = await import("@napi-rs/canvas");
120
- backend.setImageAsset(assetId, await loadImage(resolveAsset(ref.url, opts.modulePath)));
152
+ const imgPath = resolveAsset(ref.url, opts.modulePath);
153
+ assetDigests.set(`image:${assetId}`, digestBytes(await readFile(imgPath)));
154
+ backend.setImageAsset(assetId, await loadImage(imgPath));
121
155
  } else if (ref.kind === "video") {
122
156
  if (!ffmpegAvailable()) throw new Error(`video asset '${assetId}' needs FFmpeg on PATH for frame extraction (§5.4)`);
123
157
  const { FfmpegVideoFrameSource } = await import("./videoSource.js").then((n) => n.i);
124
- const source = new FfmpegVideoFrameSource(resolveAsset(ref.url, opts.modulePath));
158
+ const videoPath = resolveAsset(ref.url, opts.modulePath);
159
+ assetDigests.set(`video:${assetId}`, digestBytes(await readFile(videoPath)));
160
+ const source = new FfmpegVideoFrameSource(videoPath);
125
161
  await source.warm(0, source.duration);
126
162
  backend.setVideoAsset(assetId, source);
127
163
  videoSources.push(source);
128
164
  }
165
+ let frameCache;
166
+ let keyCtx;
167
+ if (opts.cache && opts.cache.mode !== "off") {
168
+ const { FrameCache, capsId, combineAssetDigests } = await import("./frameCache.js").then((n) => n.s);
169
+ const { glissadeVersion } = await import("./version.js").then((n) => n.n);
170
+ frameCache = new FrameCache({
171
+ dir: opts.cache.dir,
172
+ mode: opts.cache.mode,
173
+ ...opts.cache.maxSize !== void 0 ? { maxSize: opts.cache.maxSize } : {}
174
+ });
175
+ keyCtx = {
176
+ version: glissadeVersion(),
177
+ capsId: capsId(backend.caps),
178
+ assetsDigest: combineAssetDigests(assetDigests)
179
+ };
180
+ }
129
181
  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());
182
+ const dl = withDeterminismGuards("throw", () => evaluate(scene, doc, f / fps));
183
+ let pngBytes;
184
+ if (frameCache && keyCtx) {
185
+ const { frameCacheKey } = await import("./frameCache.js").then((n) => n.s);
186
+ const key = frameCacheKey(dl, keyCtx);
187
+ const cached = frameCache.get(key);
188
+ if (cached) {
189
+ backend.putPixels(cached);
190
+ pngBytes = backend.encodePng();
191
+ } else {
192
+ backend.render(dl);
193
+ pngBytes = backend.encodePng();
194
+ frameCache.put(key, scene.size.w, scene.size.h, await backend.readPixels());
195
+ }
196
+ } else {
197
+ backend.render(dl);
198
+ pngBytes = backend.encodePng();
199
+ }
200
+ writeFileSync(singleFile ? resolve(opts.out) : join(framesDir, `frame-${String(f).padStart(5, "0")}.png`), pngBytes);
132
201
  opts.onProgress?.(f - firstFrame + 1, total);
133
202
  }
134
203
  backend.dispose();
204
+ if (frameCache) {
205
+ const s = frameCache.getStats();
206
+ 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`);
207
+ }
135
208
  for (const source of videoSources) source.close();
136
209
  const emitSidecars = (target) => {
137
210
  if (captionsMode === "off") return;
@@ -222,13 +295,12 @@ async function render(opts) {
222
295
  };
223
296
  }
224
297
  /**
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.
298
+ * Collect the timeline + auto-mixed (narration/music/sfx) audio clips for a
299
+ * scene the shared front half of the mix used by both `planFinalAudio` (the
300
+ * render/shard path) and `buildMixWav` (the measure-loudness path), so the mix
301
+ * CONTENT measured at commit-time is byte-for-byte the mix rendered later.
230
302
  */
231
- async function planFinalAudio(opts, timelineClips, duration, container) {
303
+ async function collectAudioClips(opts, timelineClips) {
232
304
  const { timingPathFor } = await import("./captions.js").then((n) => n.t);
233
305
  const audioClips = [...timelineClips];
234
306
  const { bedAlreadyReferenced, buildMusicClip, buildNarrationClips, musicPathFor } = await import("./music.js");
@@ -266,7 +338,81 @@ async function planFinalAudio(opts, timelineClips, duration, container) {
266
338
  }
267
339
  }
268
340
  }
269
- const { planAudioMix } = await import("./audioMix.js").then((n) => n.r);
341
+ return audioClips;
342
+ }
343
+ /**
344
+ * Resolve the ACTUAL audio files that feed the final mix (timeline clips +
345
+ * auto-mixed narration/music/sfx), as absolute paths. This is what the mixHash
346
+ * must hash the BYTES of: editing a timeline `.wav` or a music stem in place leaves
347
+ * the timing manifests unchanged, so without folding these bytes a stale publish
348
+ * gain would be applied silently. Reuses `collectAudioClips` (the same gather the
349
+ * mix itself uses), then resolves each clip asset URL. De-duplicated + sorted so
350
+ * measure-time and render-time agree regardless of clip order. `timelineClips` is
351
+ * the scene's compiled timeline audio; when omitted it is compiled from the module
352
+ * (so measure-time and the bare-gate path see the SAME timeline clips render does).
353
+ * A module that can't load (e.g. a unit-test stub path) falls back to no timeline
354
+ * clips — the narration/music/sfx siblings still resolve from `modulePath`.
355
+ */
356
+ async function collectMixAudioInputs(opts, timelineClips) {
357
+ let tl = timelineClips;
358
+ if (tl === void 0) try {
359
+ const mod = await loadSceneModule(opts.modulePath);
360
+ const { compileTimeline } = await import("@glissade/core");
361
+ tl = [...compileTimeline(mod.timeline).audio];
362
+ } catch {
363
+ tl = [];
364
+ }
365
+ const clips = await collectAudioClips(opts, tl);
366
+ const { resolveAssetPath } = await import("./audioMix.js").then((n) => n.i);
367
+ const paths = /* @__PURE__ */ new Set();
368
+ for (const c of clips) try {
369
+ paths.add(resolveAssetPath(c.asset.url, opts.modulePath));
370
+ } catch {
371
+ paths.add(c.asset.url);
372
+ }
373
+ return [...paths].sort();
374
+ }
375
+ /**
376
+ * Resolve the committed publish gain (dB) for a render: read `<scene>.loudness.json`
377
+ * (when present and `loudness !== 'off'`), recompute the mixHash over the current
378
+ * mix inputs, and HARD-THROW on a mismatch — a re-narrate/re-sfx must invalidate
379
+ * the measurement loudly rather than silently mis-normalize. Returns null when no
380
+ * measurement is committed or loudness is off (no gain applied).
381
+ *
382
+ * The mixHash folds the BYTES of the actual mix audio inputs (timeline clips +
383
+ * narration/music/sfx) — not just the timing manifests — so an in-place edit of a
384
+ * `.wav`/music stem invalidates the measurement here too (the §5.3 stale-gain
385
+ * gate). `timelineClips` defaults to the scene's compiled timeline audio when
386
+ * omitted, so a bare `{ modulePath }` call still gates correctly.
387
+ */
388
+ async function resolveLoudnessGainDb(opts, timelineClips) {
389
+ if ((opts.loudness ?? "auto") === "off") return null;
390
+ const { readLoudness, computeMixHash, loudnessPathFor } = await import("./loudness.js").then((n) => n.c);
391
+ const measurement = readLoudness(opts.modulePath);
392
+ if (!measurement) return null;
393
+ const extraInputs = await collectMixAudioInputs(opts, timelineClips);
394
+ const actual = computeMixHash(opts.modulePath, extraInputs);
395
+ 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).`);
396
+ return measurement.gain;
397
+ }
398
+ /**
399
+ * Collect audio clips + auto-mixed siblings and plan the FFmpeg audio graph,
400
+ * returning the `-i`/`-filter_complex`/`-map` argument fragments. Shared by the
401
+ * linear `render()` path and the sharded orchestrator (which mixes audio once,
402
+ * over the concatenated video). Returns empty args when there is nothing to mix.
403
+ *
404
+ * When a committed `<scene>.loudness.json` applies, its publish gain is appended
405
+ * as a PURE `volume=<gain>dB` scalar on the FINAL mix node — a single scalar in
406
+ * the existing graph, NOT a second pass. The scalar gain is bit-deterministic
407
+ * (verified) and golden-hashable; the only non-deterministic stages (mix-to-PCM,
408
+ * measure-time ebur128) are quarantined to commit/measure-time per §5.3.
409
+ *
410
+ * (Note: `collectMixAudioInputs` resolves auto-mixed narration/music/sfx siblings
411
+ * from `modulePath`, so a `timelineClips: []` default still gates those.)
412
+ */
413
+ async function planFinalAudio(opts, timelineClips, duration, container) {
414
+ const audioClips = await collectAudioClips(opts, timelineClips);
415
+ const { planAudioMix, applyMixGainDb } = await import("./audioMix.js").then((n) => n.i);
270
416
  const { pickEncoder } = await import("./encoders.js").then((n) => n.r);
271
417
  const mix = planAudioMix(audioClips, opts.modulePath, duration);
272
418
  if (mix?.hasEasedGain) process.stderr.write("note: eased gain keys are approximated linearly in the FFmpeg mix\n");
@@ -274,12 +420,15 @@ async function planFinalAudio(opts, timelineClips, duration, container) {
274
420
  audioInputs: [],
275
421
  audioArgs: []
276
422
  };
423
+ const gainDb = await resolveLoudnessGainDb(opts, timelineClips);
424
+ const filterComplex = gainDb !== null ? applyMixGainDb(mix.filterComplex, gainDb) : mix.filterComplex;
425
+ if (gainDb !== null) process.stderr.write(`note: applying committed publish loudness gain ${gainDb.toFixed(2)} dB (single-pass scalar)\n`);
277
426
  const audioEnc = pickEncoder("audio", container);
278
427
  return {
279
428
  audioInputs: mix.inputs.flatMap((p) => ["-i", p]),
280
429
  audioArgs: [
281
430
  "-filter_complex",
282
- mix.filterComplex,
431
+ filterComplex,
283
432
  "-map",
284
433
  "0:v",
285
434
  "-map",
@@ -290,5 +439,51 @@ async function planFinalAudio(opts, timelineClips, duration, container) {
290
439
  ]
291
440
  };
292
441
  }
442
+ /**
443
+ * Build the FINAL mixed audio to a WAV file — the measure-loudness input. Uses
444
+ * the SAME `collectAudioClips` + `planAudioMix` as the render path (no loudness
445
+ * gain — measurement is of the un-gained mix), so the measured content is exactly
446
+ * what render will later mix. Returns false when the scene has no audio.
447
+ */
448
+ async function buildMixWav(opts, wavOut) {
449
+ if (!ffmpegAvailable()) throw new Error("gs measure-loudness needs FFmpeg on PATH and none was found.");
450
+ const mod = await loadSceneModule(opts.modulePath);
451
+ const scene = mod.createScene();
452
+ const { compileTimeline } = await import("@glissade/core");
453
+ const compiled = compileTimeline(mod.timeline);
454
+ const duration = compiled.duration;
455
+ const audioClips = await collectAudioClips(opts, [...compiled.audio]);
456
+ const { planAudioMix } = await import("./audioMix.js").then((n) => n.i);
457
+ const mix = planAudioMix(audioClips, opts.modulePath, duration);
458
+ if (!mix) return false;
459
+ const result = spawnSync("ffmpeg", [
460
+ "-y",
461
+ "-hide_banner",
462
+ "-nostats",
463
+ "-f",
464
+ "lavfi",
465
+ "-i",
466
+ "anullsrc=channel_layout=stereo:sample_rate=48000",
467
+ ...mix.inputs.flatMap((p) => ["-i", p]),
468
+ "-filter_complex",
469
+ mix.filterComplex,
470
+ "-map",
471
+ "[aout]",
472
+ "-c:a",
473
+ "pcm_s16le",
474
+ "-ar",
475
+ "48000",
476
+ "-t",
477
+ String(duration),
478
+ wavOut
479
+ ], { stdio: [
480
+ "ignore",
481
+ "ignore",
482
+ "pipe"
483
+ ] });
484
+ if (result.status !== 0) throw new Error(`ffmpeg mix-to-WAV failed (exit ${result.status}):\n${result.stderr?.toString().slice(-2e3)}`);
485
+ scene.size;
486
+ return true;
487
+ }
293
488
  //#endregion
294
- export { planFinalAudio as a, parseFrameRange as i, ffmpegAvailable as n, render as o, loadSceneModule as r, SceneModuleError as t };
489
+ 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,324 @@
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
+ let absent = 0;
135
+ for (const ea of a.frames) {
136
+ const eb = byFrameB.get(ea.frame);
137
+ if (!eb) {
138
+ absent++;
139
+ continue;
140
+ }
141
+ compared++;
142
+ if (ea.hash === eb.hash) continue;
143
+ const groups = new Set(a.groups);
144
+ let node;
145
+ let groupFallback;
146
+ for (const [id, ha] of Object.entries(ea.nodes)) {
147
+ if (eb.nodes[id] === ha) continue;
148
+ if (groups.has(id)) {
149
+ groupFallback ??= id;
150
+ continue;
151
+ }
152
+ node = id;
153
+ break;
154
+ }
155
+ node ??= groupFallback;
156
+ return {
157
+ ok: false,
158
+ compared,
159
+ divergence: {
160
+ frame: ea.frame,
161
+ hash: {
162
+ a: ea.hash,
163
+ b: eb.hash
164
+ },
165
+ ...node !== void 0 ? { node } : {}
166
+ }
167
+ };
168
+ }
169
+ if (compared === 0) return {
170
+ ok: false,
171
+ compared,
172
+ reason: `0 frames compared (baseline/render range disjoint): the baseline has ${a.frames.length} frame(s), none present in the render set of ${b.frames.length}`
173
+ };
174
+ if (absent > 0) return {
175
+ ok: true,
176
+ compared,
177
+ reason: `warning: ${absent} baseline frame(s) absent from the render set were not compared (compared ${compared})`
178
+ };
179
+ return {
180
+ ok: true,
181
+ compared
182
+ };
183
+ }
184
+ /**
185
+ * Replay a fresh scene through frames `[from..frame]` to reach the SAME module
186
+ * state the divergent render had at `frame`, then return the divergent NODE's
187
+ * isolated DisplayList at `frame`. Replaying the leading frames is what makes a
188
+ * cross-frame-state impurity reproduce here: the linear side replays from the
189
+ * range start, the shard side replays from the shard's sub-range start, so a
190
+ * counter/accumulator reaches different values exactly as it did in the manifests.
191
+ */
192
+ function replayNodeEmit(mod, from, frame, fps, node) {
193
+ const scene = mod.createScene();
194
+ const target = scene.nodes.get(node);
195
+ if (!target) return void 0;
196
+ const docFps = mod.timeline.fps;
197
+ return withDeterminismGuards("throw", () => {
198
+ for (let f = from; f <= frame; f++) evaluate(scene, mod.timeline, f / fps);
199
+ const t = frame / fps;
200
+ const ctx = {
201
+ time: t,
202
+ frame: docFps !== void 0 ? Math.round(t * docFps) : Math.round(t * fps),
203
+ measurer: scene.textMeasurer
204
+ };
205
+ const b = createDisplayListBuilder(scene.size);
206
+ target.emit(b, ctx);
207
+ return b.finish();
208
+ });
209
+ }
210
+ /** Produce the command-level drill (`diffDisplayLists` + `formatDisplayDiff`) of the divergent node. */
211
+ async function runBisect(plan) {
212
+ const modA = await freshLoadSceneModule(plan.modulePath);
213
+ const modB = await freshLoadSceneModule(plan.modulePath);
214
+ const dlA = replayNodeEmit(modA, plan.fromA, plan.frame, plan.fps, plan.node);
215
+ const dlB = replayNodeEmit(modB, plan.fromB, plan.frame, plan.fps, plan.node);
216
+ if (!dlA || !dlB) return `node '${plan.node}' is not present in both scenes — cannot drill`;
217
+ return formatDisplayDiff(diffDisplayLists(dlA, dlB));
218
+ }
219
+ /**
220
+ * Resolve the inclusive frame range to verify. Mirrors render.ts: an explicit
221
+ * --range wins; otherwise the whole timeline [0, ceil(duration*fps)-1].
222
+ */
223
+ async function resolveRange(mod, opts) {
224
+ const fps = opts.fps ?? mod.timeline.fps ?? 60;
225
+ if (opts.frameRange) {
226
+ const [a, b] = opts.frameRange;
227
+ return {
228
+ first: a,
229
+ last: Math.max(a, b),
230
+ fps
231
+ };
232
+ }
233
+ const { compileTimeline } = await import("@glissade/core");
234
+ const duration = compileTimeline(mod.timeline).duration;
235
+ return {
236
+ first: 0,
237
+ last: Math.max(0, Math.ceil(duration * fps) - 1),
238
+ fps
239
+ };
240
+ }
241
+ async function verifyDeterminismCommand(opts) {
242
+ const mod = await freshLoadSceneModule(opts.modulePath);
243
+ const { first, last, fps } = await resolveRange(mod, opts);
244
+ if (opts.emit !== void 0) {
245
+ const manifest = await buildManifest(mod, first, last, fps);
246
+ const { writeFileSync } = await import("node:fs");
247
+ writeFileSync(opts.emit, serializeManifest(manifest));
248
+ return {
249
+ ok: true,
250
+ frames: manifest.frames.length,
251
+ report: `wrote ${manifest.frames.length}-frame manifest (frames ${first}..${last}) → ${opts.emit}`
252
+ };
253
+ }
254
+ const linear = await buildManifest(mod, first, last, fps);
255
+ if (opts.against !== void 0) {
256
+ const baseline = parseManifest(readFileSync(opts.against, "utf8"));
257
+ 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.`);
258
+ return finalize(compareManifests(baseline, linear), opts, `linear render vs baseline ${opts.against}`, void 0);
259
+ }
260
+ if (opts.shards !== void 0 && opts.shards > 1) {
261
+ const ranges = splitFrameRange(first, last, opts.shards);
262
+ const shardFrames = [];
263
+ for (const r of ranges) {
264
+ const sub = await buildManifest(await freshLoadSceneModule(opts.modulePath), r.first, r.last, fps);
265
+ shardFrames.push(...sub.frames);
266
+ }
267
+ const cmp = compareManifests(linear, {
268
+ ...linear,
269
+ frames: shardFrames
270
+ });
271
+ const bisectFor = (d) => {
272
+ if (d.node === void 0) return void 0;
273
+ const shard = ranges.find((r) => d.frame >= r.first && d.frame <= r.last);
274
+ return {
275
+ modulePath: opts.modulePath,
276
+ node: d.node,
277
+ frame: d.frame,
278
+ fps,
279
+ fromA: first,
280
+ fromB: shard?.first ?? first
281
+ };
282
+ };
283
+ return finalize(cmp, opts, `linear vs ${ranges.length}-shard render (frames ${first}..${last})`, bisectFor);
284
+ }
285
+ return {
286
+ ok: true,
287
+ frames: linear.frames.length,
288
+ 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.`
289
+ };
290
+ }
291
+ /** Shared tail: build the report + optional --bisect drill for a comparison result. */
292
+ async function finalize(cmp, opts, label, bisectFor) {
293
+ if (cmp.ok) {
294
+ const tail = cmp.reason !== void 0 ? `\n ${cmp.reason}` : "";
295
+ return {
296
+ ok: true,
297
+ frames: cmp.compared,
298
+ report: `byte-identical: ${cmp.compared} frames match (${label})${tail}`
299
+ };
300
+ }
301
+ if (cmp.divergence === void 0) return {
302
+ ok: false,
303
+ frames: cmp.compared,
304
+ report: `VERIFY FAILED (${label})\n ${cmp.reason ?? "0 frames compared"}`
305
+ };
306
+ const d = cmp.divergence;
307
+ 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)");
308
+ if (opts.bisect && d.node !== void 0) {
309
+ const plan = bisectFor?.(d);
310
+ if (plan) {
311
+ const drill = await runBisect(plan);
312
+ d.bisect = drill;
313
+ report += `\n --bisect drill (node '${d.node}' @ frame ${d.frame}):\n${drill.replace(/^/gm, " ")}`;
314
+ } else report += `\n --bisect: no command-level drill available (an --against baseline carries hashes, not a scene)`;
315
+ }
316
+ return {
317
+ ok: false,
318
+ frames: cmp.compared,
319
+ divergence: d,
320
+ report
321
+ };
322
+ }
323
+ //#endregion
324
+ 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 };