@glissade/cli 0.12.0-pre.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/cacheVerify.js +2 -1
- package/dist/frameCache.js +39 -12
- package/dist/index.d.ts +56 -13
- package/dist/loudness.js +18 -8
- package/dist/render.js +63 -8
- package/dist/verifyDeterminism.js +26 -4
- package/package.json +10 -10
package/dist/cacheVerify.js
CHANGED
|
@@ -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-"));
|
package/dist/frameCache.js
CHANGED
|
@@ -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;
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
-
*
|
|
32
|
-
*
|
|
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
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* `gs cache verify`
|
|
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
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* `gs cache verify`
|
|
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'
|
|
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
|
|
338
|
-
*
|
|
339
|
-
* mtime): the narration/music/sfx timing manifests
|
|
340
|
-
*
|
|
341
|
-
*
|
|
342
|
-
*
|
|
343
|
-
*
|
|
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
|
|
121
|
-
*
|
|
122
|
-
* mtime): the narration/music/sfx timing manifests
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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)
|
|
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)
|
|
280
|
-
|
|
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: `
|
|
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.
|
|
3
|
+
"version": "0.12.0-pre.1",
|
|
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.
|
|
27
|
-
"@glissade/core": "0.12.0-pre.
|
|
28
|
-
"@glissade/interact": "0.12.0-pre.
|
|
29
|
-
"@glissade/lottie": "0.12.0-pre.
|
|
30
|
-
"@glissade/svg": "0.12.0-pre.
|
|
31
|
-
"@glissade/narrate": "0.12.0-pre.
|
|
32
|
-
"@glissade/player": "0.12.0-pre.
|
|
33
|
-
"@glissade/scene": "0.12.0-pre.
|
|
34
|
-
"@glissade/sfx": "0.12.0-pre.
|
|
26
|
+
"@glissade/backend-skia": "0.12.0-pre.1",
|
|
27
|
+
"@glissade/core": "0.12.0-pre.1",
|
|
28
|
+
"@glissade/interact": "0.12.0-pre.1",
|
|
29
|
+
"@glissade/lottie": "0.12.0-pre.1",
|
|
30
|
+
"@glissade/svg": "0.12.0-pre.1",
|
|
31
|
+
"@glissade/narrate": "0.12.0-pre.1",
|
|
32
|
+
"@glissade/player": "0.12.0-pre.1",
|
|
33
|
+
"@glissade/scene": "0.12.0-pre.1",
|
|
34
|
+
"@glissade/sfx": "0.12.0-pre.1"
|
|
35
35
|
},
|
|
36
36
|
"repository": {
|
|
37
37
|
"type": "git",
|