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