@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/audioMix.js +16 -1
- package/dist/cacheVerify.js +134 -0
- package/dist/cli.js +227 -2
- package/dist/diff.js +66 -0
- package/dist/fonts.js +122 -0
- package/dist/frameCache.js +326 -0
- package/dist/index.d.ts +549 -8
- package/dist/index.js +9 -3
- package/dist/loudness.js +264 -0
- package/dist/music.js +1 -1
- package/dist/narrationLintCommand.js +314 -0
- package/dist/prepare.js +1 -1
- package/dist/render.js +210 -15
- package/dist/rolldown-runtime.js +1 -0
- package/dist/shards.js +1 -1
- package/dist/verifyDeterminism.js +324 -0
- package/dist/version.js +26 -0
- package/package.json +10 -10
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
131
|
-
|
|
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
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 };
|
package/dist/rolldown-runtime.js
CHANGED
package/dist/shards.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { t as __exportAll } from "./rolldown-runtime.js";
|
|
2
|
-
import {
|
|
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 };
|
package/dist/version.js
ADDED
|
@@ -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 };
|