@glissade/cli 0.12.0-pre.0 → 0.12.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.
@@ -76,7 +76,8 @@ async function cacheVerifyCommand(opts) {
76
76
  const sample = Math.max(1, Math.floor(opts.sample ?? 1));
77
77
  const ctx = opts.keyContextOverride ?? {
78
78
  version: glissadeVersion(),
79
- capsId: capsId(backend.caps)
79
+ capsId: capsId(backend.caps),
80
+ assetsDigest: ""
80
81
  };
81
82
  const keyer = opts.keyerOverride ?? frameCacheKey;
82
83
  const dir = mkdtempSync(join(tmpdir(), "gscache-verify-"));
@@ -21,15 +21,22 @@ import { deflateSync, inflateSync } from "node:zlib";
21
21
  * THE failure mode is SILENT corruption: a stale tile served from an INCOMPLETE
22
22
  * key. So the key MUST fold EVERY byte-affecting input (§ key completeness):
23
23
  * 1. the frame's DisplayList-snapshot bytes (`serializeDisplayList` — the shipped
24
- * byte-preserving collapse serializer; resources already inlined to content,
25
- * and the post-evaluate DisplayList already encodes resolved device
26
- * transforms, so this captures geometry/paint/transform for a whole frame),
24
+ * byte-preserving collapse serializer; the post-evaluate DisplayList encodes
25
+ * resolved device transforms, so this captures geometry/paint/transform for a
26
+ * whole frame). CAVEAT: image/video/font draws carry only the asset *id* (not
27
+ * pixels/glyphs), so the DisplayList alone does NOT see an in-place edit of
28
+ * `logo.png`/`clip.mp4`/`font.ttf` — component (4) closes that hole,
27
29
  * 2. the glissade VERSION (any Raster2D-composite / Skia-toolchain change
28
30
  * invalidates every frame — bump-on-version is the cheapest correct
29
31
  * invalidation; text/font tiles are thereby scoped to the pinned toolchain),
30
- * 3. the BackendCaps id (filters / shaders / maxTextureSize).
31
- * `version` and `capsId` have no source inside `scene`, so they are INJECTED via a
32
- * `CacheKeyContext` from the CLI NEVER read from disk.
32
+ * 3. the BackendCaps id (filters / shaders / maxTextureSize),
33
+ * 4. the ASSET-CONTENT digest: a single sha256 over the sorted (assetId
34
+ * sha256(bytes)) map of every referenced image/video/font. Editing an asset's
35
+ * BYTES in place (same id/url/family) changes this digest → the key changes →
36
+ * no stale pixels are served. It over-invalidates a frame that doesn't use a
37
+ * changed asset (whole-run scope), which is CORRECT-safe — never stale.
38
+ * `version`, `capsId`, and `assetsDigest` have no source inside `scene`, so they are
39
+ * INJECTED via a `CacheKeyContext` from the CLI — NEVER read from disk.
33
40
  *
34
41
  * HIT == MISS: the HIT path reuses the SAME composite/encode routine as a MISS.
35
42
  * The miss path renders, reads raw RGBA, stores it; the hit path loads the stored
@@ -53,6 +60,7 @@ var frameCache_exports = /* @__PURE__ */ __exportAll({
53
60
  FrameCacheError: () => FrameCacheError,
54
61
  capsId: () => capsId,
55
62
  clearFrameCache: () => clearFrameCache,
63
+ combineAssetDigests: () => combineAssetDigests,
56
64
  frameCacheKey: () => frameCacheKey,
57
65
  parseCacheMaxSize: () => parseCacheMaxSize,
58
66
  probeEntryHeader: () => probeEntryHeader
@@ -70,14 +78,33 @@ var FrameCacheError = class extends Error {
70
78
  };
71
79
  /**
72
80
  * Stable, content-addressed key for ONE whole frame. Folds EVERY byte-affecting
73
- * input: the DisplayList-snapshot bytes (geometry/paint/transform, resources
74
- * inlined to content), the glissade version, and the backend caps id. An
75
- * incomplete key here IS the only failure mode (a stale frame served for changed
76
- * content), so the NEGATIVE gate test asserts that dropping any component makes
77
- * `gs cache verify` fail.
81
+ * input: the DisplayList-snapshot bytes (geometry/paint/transform — but only the
82
+ * asset *id* for image/video/font draws), the glissade version, the backend caps
83
+ * id, AND the asset-content digest (the BYTES behind those ids). An incomplete key
84
+ * here IS the only failure mode (a stale frame served for changed content), so the
85
+ * NEGATIVE gate test asserts that dropping any component makes `gs cache verify`
86
+ * fail.
78
87
  */
79
88
  function frameCacheKey(dl, ctx) {
80
- return createHash("sha256").update(serializeDisplayList(dl)).update("\0").update(ctx.version).update("\0").update(ctx.capsId).digest("hex");
89
+ return createHash("sha256").update(serializeDisplayList(dl)).update("\0").update(ctx.version).update("\0").update(ctx.capsId).update(" ").update(ctx.assetsDigest).digest("hex");
90
+ }
91
+ /**
92
+ * Combine per-asset byte digests into ONE stable digest for the key context. Sorts
93
+ * by assetId so the order assets loaded in never affects the key, then folds each
94
+ * `id\0byteSha256\0` pair. An empty map → '' (no binary assets → no extra
95
+ * invalidation, byte-identical key to a pre-asset-digest scene). Each byteDigest is
96
+ * the caller's sha256 of the asset's raw bytes.
97
+ */
98
+ function combineAssetDigests(byId) {
99
+ if (byId.size === 0) return "";
100
+ const h = createHash("sha256");
101
+ for (const id of [...byId.keys()].sort()) {
102
+ h.update(id);
103
+ h.update("\0");
104
+ h.update(byId.get(id));
105
+ h.update("\0");
106
+ }
107
+ return h.digest("hex");
81
108
  }
82
109
  /** A canonical BackendCaps id (filters sorted, shaders, maxTextureSize) — the §3 caps fold. */
83
110
  function capsId(caps) {
package/dist/index.d.ts CHANGED
@@ -19,6 +19,14 @@ interface CacheKeyContext {
19
19
  version: string;
20
20
  /** BackendCaps id (filters/shaders/maxTextureSize) — folds the rasterizer's capabilities. */
21
21
  capsId: string;
22
+ /**
23
+ * A digest of the BYTES of every referenced image/video/font asset (a single
24
+ * sha256 over the sorted assetId→byteDigest map; see `assetsDigest`). The
25
+ * DisplayList carries only the asset *id*, NOT pixels, so without this an
26
+ * in-place edit of an asset (same id/url) would collide the key and serve stale
27
+ * pixels. Empty string when the scene references no binary assets.
28
+ */
29
+ assetsDigest: string;
22
30
  }
23
31
  interface FrameCacheOptions {
24
32
  dir: string;
@@ -33,13 +41,22 @@ declare class FrameCacheError extends Error {
33
41
  }
34
42
  /**
35
43
  * Stable, content-addressed key for ONE whole frame. Folds EVERY byte-affecting
36
- * input: the DisplayList-snapshot bytes (geometry/paint/transform, resources
37
- * inlined to content), the glissade version, and the backend caps id. An
38
- * incomplete key here IS the only failure mode (a stale frame served for changed
39
- * content), so the NEGATIVE gate test asserts that dropping any component makes
40
- * `gs cache verify` fail.
44
+ * input: the DisplayList-snapshot bytes (geometry/paint/transform — but only the
45
+ * asset *id* for image/video/font draws), the glissade version, the backend caps
46
+ * id, AND the asset-content digest (the BYTES behind those ids). An incomplete key
47
+ * here IS the only failure mode (a stale frame served for changed content), so the
48
+ * NEGATIVE gate test asserts that dropping any component makes `gs cache verify`
49
+ * fail.
41
50
  */
42
51
  declare function frameCacheKey(dl: DisplayList, ctx: CacheKeyContext): string;
52
+ /**
53
+ * Combine per-asset byte digests into ONE stable digest for the key context. Sorts
54
+ * by assetId so the order assets loaded in never affects the key, then folds each
55
+ * `id\0byteSha256\0` pair. An empty map → '' (no binary assets → no extra
56
+ * invalidation, byte-identical key to a pre-asset-digest scene). Each byteDigest is
57
+ * the caller's sha256 of the asset's raw bytes.
58
+ */
59
+
43
60
  /** A canonical BackendCaps id (filters sorted, shaders, maxTextureSize) — the §3 caps fold. */
44
61
  declare function capsId(caps: {
45
62
  filters: ReadonlySet<string>;
@@ -207,14 +224,34 @@ declare function render(opts: RenderOptions): Promise<{
207
224
  * CONTENT measured at commit-time is byte-for-byte the mix rendered later.
208
225
  */
209
226
  declare function collectAudioClips(opts: Pick<RenderOptions, 'modulePath' | 'narration' | 'music' | 'sfx'>, timelineClips: AudioClip[]): Promise<AudioClip[]>;
227
+ /**
228
+ * Resolve the ACTUAL audio files that feed the final mix (timeline clips +
229
+ * auto-mixed narration/music/sfx), as absolute paths. This is what the mixHash
230
+ * must hash the BYTES of: editing a timeline `.wav` or a music stem in place leaves
231
+ * the timing manifests unchanged, so without folding these bytes a stale publish
232
+ * gain would be applied silently. Reuses `collectAudioClips` (the same gather the
233
+ * mix itself uses), then resolves each clip asset URL. De-duplicated + sorted so
234
+ * measure-time and render-time agree regardless of clip order. `timelineClips` is
235
+ * the scene's compiled timeline audio; when omitted it is compiled from the module
236
+ * (so measure-time and the bare-gate path see the SAME timeline clips render does).
237
+ * A module that can't load (e.g. a unit-test stub path) falls back to no timeline
238
+ * clips — the narration/music/sfx siblings still resolve from `modulePath`.
239
+ */
240
+
210
241
  /**
211
242
  * Resolve the committed publish gain (dB) for a render: read `<scene>.loudness.json`
212
243
  * (when present and `loudness !== 'off'`), recompute the mixHash over the current
213
244
  * mix inputs, and HARD-THROW on a mismatch — a re-narrate/re-sfx must invalidate
214
245
  * the measurement loudly rather than silently mis-normalize. Returns null when no
215
246
  * measurement is committed or loudness is off (no gain applied).
247
+ *
248
+ * The mixHash folds the BYTES of the actual mix audio inputs (timeline clips +
249
+ * narration/music/sfx) — not just the timing manifests — so an in-place edit of a
250
+ * `.wav`/music stem invalidates the measurement here too (the §5.3 stale-gain
251
+ * gate). `timelineClips` defaults to the scene's compiled timeline audio when
252
+ * omitted, so a bare `{ modulePath }` call still gates correctly.
216
253
  */
217
- declare function resolveLoudnessGainDb(opts: Pick<RenderOptions, 'modulePath' | 'loudness'>): Promise<number | null>;
254
+ declare function resolveLoudnessGainDb(opts: Pick<RenderOptions, 'modulePath' | 'loudness' | 'narration' | 'music' | 'sfx'>, timelineClips?: AudioClip[]): Promise<number | null>;
218
255
  /**
219
256
  * Collect audio clips + auto-mixed siblings and plan the FFmpeg audio graph,
220
257
  * returning the `-i`/`-filter_complex`/`-map` argument fragments. Shared by the
@@ -226,6 +263,9 @@ declare function resolveLoudnessGainDb(opts: Pick<RenderOptions, 'modulePath' |
226
263
  * the existing graph, NOT a second pass. The scalar gain is bit-deterministic
227
264
  * (verified) and golden-hashable; the only non-deterministic stages (mix-to-PCM,
228
265
  * measure-time ebur128) are quarantined to commit/measure-time per §5.3.
266
+ *
267
+ * (Note: `collectMixAudioInputs` resolves auto-mixed narration/music/sfx siblings
268
+ * from `modulePath`, so a `timelineClips: []` default still gates those.)
229
269
  */
230
270
  declare function planFinalAudio(opts: RenderOptions, timelineClips: AudioClip[], duration: number, container: 'mp4' | 'webm'): Promise<{
231
271
  audioInputs: string[];
@@ -334,13 +374,16 @@ declare function computeGainDb(profile: PublishProfile, inputI: number, inputTp:
334
374
  */
335
375
  declare function peakClampBinds(profile: PublishProfile, inputI: number, inputTp: number): boolean;
336
376
  /**
337
- * Hash the CONTENT of the mix's input manifests so a re-narrate / re-sfx / music
338
- * change invalidates a committed measurement. We hash the files' BYTES (not
339
- * mtime): the narration/music/sfx timing manifests and any wired timeline audio
340
- * sidecars. Missing siblings are recorded by name with a sentinel so adding one
341
- * later also changes the hash. The scene module path itself is excluded — the
342
- * mix is a function of the manifests, and timeline `audio` clips flow through the
343
- * narration/music/sfx manifests or the explicitly-passed extra inputs.
377
+ * Hash the CONTENT of the mix's inputs so a re-narrate / re-sfx / music change OR
378
+ * an in-place edit of an audio file invalidates a committed measurement. We hash
379
+ * the files' BYTES (not mtime): the narration/music/sfx timing manifests AND every
380
+ * path in `extraInputs` the resolved mix AUDIO files (timeline clips + the music
381
+ * stem + narration cache audio), supplied by `collectMixAudioInputs` at both the
382
+ * measure and render call sites so the two agree. Hashing only the timing manifests
383
+ * would miss an edited `.wav`/stem (same manifest, changed bytes) and apply a stale
384
+ * publish gain silently — so `extraInputs` MUST be passed on the gate path. Missing
385
+ * files are recorded by name with a sentinel so adding one later also changes the
386
+ * hash. The scene module path itself is excluded.
344
387
  */
345
388
  declare function computeMixHash(modulePath: string, extraInputs?: readonly string[]): string;
346
389
  /** `<module>.loudness.json` for a scene module. */
package/dist/loudness.js CHANGED
@@ -117,13 +117,16 @@ function peakClampBinds(profile, inputI, inputTp) {
117
117
  return profile.truePeakDb - inputTp < profile.targetLufs - inputI;
118
118
  }
119
119
  /**
120
- * Hash the CONTENT of the mix's input manifests so a re-narrate / re-sfx / music
121
- * change invalidates a committed measurement. We hash the files' BYTES (not
122
- * mtime): the narration/music/sfx timing manifests and any wired timeline audio
123
- * sidecars. Missing siblings are recorded by name with a sentinel so adding one
124
- * later also changes the hash. The scene module path itself is excluded — the
125
- * mix is a function of the manifests, and timeline `audio` clips flow through the
126
- * narration/music/sfx manifests or the explicitly-passed extra inputs.
120
+ * Hash the CONTENT of the mix's inputs so a re-narrate / re-sfx / music change OR
121
+ * an in-place edit of an audio file invalidates a committed measurement. We hash
122
+ * the files' BYTES (not mtime): the narration/music/sfx timing manifests AND every
123
+ * path in `extraInputs` the resolved mix AUDIO files (timeline clips + the music
124
+ * stem + narration cache audio), supplied by `collectMixAudioInputs` at both the
125
+ * measure and render call sites so the two agree. Hashing only the timing manifests
126
+ * would miss an edited `.wav`/stem (same manifest, changed bytes) and apply a stale
127
+ * publish gain silently — so `extraInputs` MUST be passed on the gate path. Missing
128
+ * files are recorded by name with a sentinel so adding one later also changes the
129
+ * hash. The scene module path itself is excluded.
127
130
  */
128
131
  function computeMixHash(modulePath, extraInputs = []) {
129
132
  const base = modulePath.replace(/\.[jt]sx?$/, "");
@@ -223,7 +226,14 @@ async function measureLoudnessCommand(opts) {
223
226
  const { inputI, inputTp, inputLra } = measureFile(wavPath);
224
227
  const gain = computeGainDb(profile, inputI, inputTp);
225
228
  const clampBound = peakClampBinds(profile, inputI, inputTp);
226
- const mixHash = computeMixHash(opts.modulePath);
229
+ const { collectMixAudioInputs } = await import("./render.js").then((n) => n.l);
230
+ const extraInputs = await collectMixAudioInputs({
231
+ modulePath: opts.modulePath,
232
+ narration: opts.narration ?? "auto",
233
+ music: opts.music ?? "auto",
234
+ sfx: opts.sfx ?? "auto"
235
+ });
236
+ const mixHash = computeMixHash(opts.modulePath, extraInputs);
227
237
  const measurement = {
228
238
  loudnessVersion: 1,
229
239
  profileId: profile.id,
package/dist/render.js CHANGED
@@ -19,6 +19,7 @@ var render_exports = /* @__PURE__ */ __exportAll({
19
19
  SceneModuleError: () => SceneModuleError,
20
20
  buildMixWav: () => buildMixWav,
21
21
  collectAudioClips: () => collectAudioClips,
22
+ collectMixAudioInputs: () => collectMixAudioInputs,
22
23
  ffmpegAvailable: () => ffmpegAvailable,
23
24
  loadSceneModule: () => loadSceneModule,
24
25
  parseFrameRange: () => parseFrameRange,
@@ -114,6 +115,9 @@ async function render(opts) {
114
115
  }
115
116
  const videoSources = [];
116
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");
117
121
  const fontRegistry = buildFontRegistry(doc.assets);
118
122
  if (fontRegistry.faces().length > 0) {
119
123
  const { GlobalFonts } = await import("@napi-rs/canvas");
@@ -123,12 +127,16 @@ async function render(opts) {
123
127
  if (/\.woff2?$/i.test(face.url)) {
124
128
  ingest ??= await import("@glissade/core/font-ingest");
125
129
  const src = await readFile(path);
130
+ assetDigests.set(`font:${face.family}:${face.url}`, digestBytes(src));
126
131
  const result = await ingest.ingestFont({
127
132
  family: face.family,
128
133
  src
129
134
  });
130
135
  GlobalFonts.register(Buffer.from(result.bytes), face.family);
131
- } else GlobalFonts.registerFromPath(path, face.family);
136
+ } else {
137
+ assetDigests.set(`font:${face.family}:${face.url}`, digestBytes(await readFile(path)));
138
+ GlobalFonts.registerFromPath(path, face.family);
139
+ }
132
140
  }
133
141
  }
134
142
  await validateSceneFonts(scene, doc, async (url) => {
@@ -141,11 +149,15 @@ async function render(opts) {
141
149
  }, { mode: opts.strictFonts ? "strict" : "dev" });
142
150
  for (const [assetId, ref] of Object.entries(doc.assets ?? {})) if (ref.kind === "font") {} else if (ref.kind === "image") {
143
151
  const { loadImage } = await import("@napi-rs/canvas");
144
- 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));
145
155
  } else if (ref.kind === "video") {
146
156
  if (!ffmpegAvailable()) throw new Error(`video asset '${assetId}' needs FFmpeg on PATH for frame extraction (§5.4)`);
147
157
  const { FfmpegVideoFrameSource } = await import("./videoSource.js").then((n) => n.i);
148
- 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);
149
161
  await source.warm(0, source.duration);
150
162
  backend.setVideoAsset(assetId, source);
151
163
  videoSources.push(source);
@@ -153,7 +165,7 @@ async function render(opts) {
153
165
  let frameCache;
154
166
  let keyCtx;
155
167
  if (opts.cache && opts.cache.mode !== "off") {
156
- const { FrameCache, capsId } = await import("./frameCache.js").then((n) => n.s);
168
+ const { FrameCache, capsId, combineAssetDigests } = await import("./frameCache.js").then((n) => n.s);
157
169
  const { glissadeVersion } = await import("./version.js").then((n) => n.n);
158
170
  frameCache = new FrameCache({
159
171
  dir: opts.cache.dir,
@@ -162,7 +174,8 @@ async function render(opts) {
162
174
  });
163
175
  keyCtx = {
164
176
  version: glissadeVersion(),
165
- capsId: capsId(backend.caps)
177
+ capsId: capsId(backend.caps),
178
+ assetsDigest: combineAssetDigests(assetDigests)
166
179
  };
167
180
  }
168
181
  for (let f = firstFrame; f <= lastFrame; f++) {
@@ -328,18 +341,57 @@ async function collectAudioClips(opts, timelineClips) {
328
341
  return audioClips;
329
342
  }
330
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
+ /**
331
376
  * Resolve the committed publish gain (dB) for a render: read `<scene>.loudness.json`
332
377
  * (when present and `loudness !== 'off'`), recompute the mixHash over the current
333
378
  * mix inputs, and HARD-THROW on a mismatch — a re-narrate/re-sfx must invalidate
334
379
  * the measurement loudly rather than silently mis-normalize. Returns null when no
335
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.
336
387
  */
337
- async function resolveLoudnessGainDb(opts) {
388
+ async function resolveLoudnessGainDb(opts, timelineClips) {
338
389
  if ((opts.loudness ?? "auto") === "off") return null;
339
390
  const { readLoudness, computeMixHash, loudnessPathFor } = await import("./loudness.js").then((n) => n.c);
340
391
  const measurement = readLoudness(opts.modulePath);
341
392
  if (!measurement) return null;
342
- const actual = computeMixHash(opts.modulePath);
393
+ const extraInputs = await collectMixAudioInputs(opts, timelineClips);
394
+ const actual = computeMixHash(opts.modulePath, extraInputs);
343
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).`);
344
396
  return measurement.gain;
345
397
  }
@@ -354,6 +406,9 @@ async function resolveLoudnessGainDb(opts) {
354
406
  * the existing graph, NOT a second pass. The scalar gain is bit-deterministic
355
407
  * (verified) and golden-hashable; the only non-deterministic stages (mix-to-PCM,
356
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.)
357
412
  */
358
413
  async function planFinalAudio(opts, timelineClips, duration, container) {
359
414
  const audioClips = await collectAudioClips(opts, timelineClips);
@@ -365,7 +420,7 @@ async function planFinalAudio(opts, timelineClips, duration, container) {
365
420
  audioInputs: [],
366
421
  audioArgs: []
367
422
  };
368
- const gainDb = await resolveLoudnessGainDb(opts);
423
+ const gainDb = await resolveLoudnessGainDb(opts, timelineClips);
369
424
  const filterComplex = gainDb !== null ? applyMixGainDb(mix.filterComplex, gainDb) : mix.filterComplex;
370
425
  if (gainDb !== null) process.stderr.write(`note: applying committed publish loudness gain ${gainDb.toFixed(2)} dB (single-pass scalar)\n`);
371
426
  const audioEnc = pickEncoder("audio", container);
@@ -131,9 +131,13 @@ function compareManifests(a, b) {
131
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
132
  const byFrameB = new Map(b.frames.map((e) => [e.frame, e]));
133
133
  let compared = 0;
134
+ let absent = 0;
134
135
  for (const ea of a.frames) {
135
136
  const eb = byFrameB.get(ea.frame);
136
- if (!eb) continue;
137
+ if (!eb) {
138
+ absent++;
139
+ continue;
140
+ }
137
141
  compared++;
138
142
  if (ea.hash === eb.hash) continue;
139
143
  const groups = new Set(a.groups);
@@ -162,6 +166,16 @@ function compareManifests(a, b) {
162
166
  }
163
167
  };
164
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
+ };
165
179
  return {
166
180
  ok: true,
167
181
  compared
@@ -276,10 +290,18 @@ async function verifyDeterminismCommand(opts) {
276
290
  }
277
291
  /** Shared tail: build the report + optional --bisect drill for a comparison result. */
278
292
  async function finalize(cmp, opts, label, bisectFor) {
279
- if (cmp.ok) return {
280
- ok: true,
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,
281
303
  frames: cmp.compared,
282
- report: `byte-identical: ${cmp.compared} frames match (${label})`
304
+ report: `VERIFY FAILED (${label})\n ${cmp.reason ?? "0 frames compared"}`
283
305
  };
284
306
  const d = cmp.divergence;
285
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)");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/cli",
3
- "version": "0.12.0-pre.0",
3
+ "version": "0.12.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.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"
26
+ "@glissade/backend-skia": "0.12.0",
27
+ "@glissade/core": "0.12.0",
28
+ "@glissade/interact": "0.12.0",
29
+ "@glissade/lottie": "0.12.0",
30
+ "@glissade/svg": "0.12.0",
31
+ "@glissade/narrate": "0.12.0",
32
+ "@glissade/player": "0.12.0",
33
+ "@glissade/scene": "0.12.0",
34
+ "@glissade/sfx": "0.12.0"
35
35
  },
36
36
  "repository": {
37
37
  "type": "git",