@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/index.d.ts CHANGED
@@ -1,9 +1,134 @@
1
1
  import { AudioClip, CompiledTimeline, Key, Timeline } from "@glissade/core";
2
- import { Scene, SceneModule, VideoFrameSource } from "@glissade/scene";
2
+ import { DisplayList, Scene, SceneModule, VideoFrameSource } from "@glissade/scene";
3
+ import { NarrationTiming } from "@glissade/narrate";
3
4
  import { Image } from "@napi-rs/canvas";
4
5
 
5
- //#region src/render.d.ts
6
+ //#region src/frameCache.d.ts
6
7
 
8
+ /** read-write (default with --cache): serve hits, store misses. read-only: serve
9
+ * hits but never write (a shared/read-only cache). off: bypass entirely — the
10
+ * exact current equality baseline, no behavior change. */
11
+ type CacheMode = 'read-write' | 'read-only' | 'off';
12
+ /**
13
+ * The two key components that have NO source inside `scene` and so MUST be injected
14
+ * by the CLI (never read from disk). Folding both into the key is THE guard against
15
+ * silent stale-tile corruption when the toolchain or backend caps change.
16
+ */
17
+ interface CacheKeyContext {
18
+ /** glissade VERSION — any composite/toolchain change bumps this, invalidating every frame. */
19
+ version: string;
20
+ /** BackendCaps id (filters/shaders/maxTextureSize) — folds the rasterizer's capabilities. */
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;
30
+ }
31
+ interface FrameCacheOptions {
32
+ dir: string;
33
+ mode: CacheMode;
34
+ /** max total bytes of stored (compressed) entries before LRU eviction; default 2 GB. */
35
+ maxSize?: number;
36
+ }
37
+ /** Default LRU cap: 2 GB of compressed entries (the card's default). */
38
+ declare const DEFAULT_CACHE_MAX_SIZE: number;
39
+ declare class FrameCacheError extends Error {
40
+ constructor(message: string);
41
+ }
42
+ /**
43
+ * Stable, content-addressed key for ONE whole frame. Folds EVERY byte-affecting
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.
50
+ */
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
+
60
+ /** A canonical BackendCaps id (filters sorted, shaders, maxTextureSize) — the §3 caps fold. */
61
+ declare function capsId(caps: {
62
+ filters: ReadonlySet<string>;
63
+ shaders: boolean;
64
+ maxTextureSize: number;
65
+ }): string;
66
+ interface CacheStats {
67
+ hits: number;
68
+ misses: number;
69
+ stored: number;
70
+ evicted: number;
71
+ }
72
+ /**
73
+ * The persistent whole-frame raster cache. One instance per render process; shards
74
+ * each open their own over the SAME dir (content-addressed + atomic writes make
75
+ * that safe). A `mode:'off'` instance is inert — `get` always misses and `put` is a
76
+ * no-op — so the cache-off path is the exact current baseline.
77
+ */
78
+ declare class FrameCache {
79
+ readonly dir: string;
80
+ readonly mode: CacheMode;
81
+ readonly maxSize: number;
82
+ private readonly stats;
83
+ constructor(opts: FrameCacheOptions);
84
+ private pathFor;
85
+ /**
86
+ * Look up a whole-frame RGBA by key. On a hit, the file's mtime is touched
87
+ * (access-time bump for the LRU) and the decoded RGBA returned; the caller blits
88
+ * it via `backend.putPixels` and runs the IDENTICAL encode. Returns undefined on
89
+ * a miss or in `mode:'off'`. A corrupt/truncated entry is treated as a miss (it
90
+ * is deleted so it re-renders) rather than a hard error — the cache is advisory.
91
+ */
92
+ get(key: string): Uint8ClampedArray | undefined;
93
+ /**
94
+ * Store a whole-frame RGBA under its key. No-op in `read-only`/`off`. The write
95
+ * is atomic (temp file + rename) so a crash/interrupt never leaves a torn entry
96
+ * a later run could half-read (the single failure mode). After a store the LRU
97
+ * size cap is enforced.
98
+ */
99
+ put(key: string, width: number, height: number, rgba: Uint8ClampedArray): void;
100
+ /**
101
+ * Evict oldest (by mtime) entries until the total compressed size is within
102
+ * `maxSize`. Content-addressed + idempotent, so a delete racing a peer's
103
+ * regeneration is benign. Runs after each store; cheap relative to a frame
104
+ * rasterize.
105
+ */
106
+ private enforceSizeCap;
107
+ /** Snapshot of hit/miss/store/evict counters (for the render summary + tests). */
108
+ getStats(): Readonly<CacheStats>;
109
+ /** Total compressed bytes currently on disk (for tests / a `--cache` summary). */
110
+ diskSize(): number;
111
+ /** Number of entries currently on disk (tests). */
112
+ entryCount(): number;
113
+ }
114
+ /**
115
+ * Parse a `--cache-max-size` flag: a raw byte count or a human size (`2GB`,
116
+ * `512MB`, `1.5g`). Rejects garbage so a typo doesn't silently default. Case- and
117
+ * suffix-insensitive (b/k/m/g, optionally with a trailing `b`).
118
+ */
119
+ declare function parseCacheMaxSize(flag: string): number;
120
+ /**
121
+ * Read the magic-validated header of an entry WITHOUT inflating its body — a cheap
122
+ * existence + size probe used by `gs cache verify` to report what it sampled.
123
+ */
124
+ declare function probeEntryHeader(file: string): {
125
+ width: number;
126
+ height: number;
127
+ } | undefined;
128
+ /** Remove the whole `.gscache` dir (a `gs cache clear` convenience / test cleanup). */
129
+ declare function clearFrameCache(dir: string): void;
130
+ //#endregion
131
+ //#region src/render.d.ts
7
132
  interface RenderOptions {
8
133
  modulePath: string;
9
134
  out: string;
@@ -30,6 +155,13 @@ interface RenderOptions {
30
155
  narration?: 'auto' | 'off';
31
156
  /** auto (default): mix effect hits from a sibling *.sfx.timing.json. */
32
157
  sfx?: 'auto' | 'off';
158
+ /**
159
+ * auto (default): apply a committed `<scene>.loudness.json` publish gain (a
160
+ * pure scalar `volume=<gain>dB` on the final mix node) when one exists, and
161
+ * HARD-THROW if its mixHash no longer matches the mix inputs (a re-narrate must
162
+ * invalidate loudly). 'off' ignores any committed measurement.
163
+ */
164
+ loudness?: 'auto' | 'off';
33
165
  /** also write WebVTT chapters from cue markers ('vtt'); cues.json is always written when cues exist. */
34
166
  chapters?: 'vtt' | 'off';
35
167
  /** cue kinds that become VTT chapters (default just 'chapter'); cues.json keeps all kinds. */
@@ -55,6 +187,19 @@ interface RenderOptions {
55
187
  * this is set.
56
188
  */
57
189
  allowGpuShards?: boolean;
190
+ /**
191
+ * --cache (§3.5, 0.12): persistent whole-frame raster cache. `dir` is the
192
+ * `.gscache` directory (shared across runs AND shards); `mode` defaults to 'off'
193
+ * (the exact current equality baseline). `maxSize` caps the LRU (default 2 GB).
194
+ * A hit serves a stored RGBA byte-identical to a cold render — it wins REPEATED
195
+ * renders and the UNCHANGED-PREFIX of a single-segment edit. A full re-narrate
196
+ * shifts every frame's timing → every DisplayList changes → every frame MISSES.
197
+ */
198
+ cache?: {
199
+ dir: string;
200
+ mode: CacheMode;
201
+ maxSize?: number;
202
+ };
58
203
  onProgress?: (frame: number, total: number) => void;
59
204
  }
60
205
  declare class SceneModuleError extends Error {
@@ -73,16 +218,225 @@ declare function render(opts: RenderOptions): Promise<{
73
218
  out: string;
74
219
  }>;
75
220
  /**
76
- * Collect timeline + auto-mixed (narration/music/sfx) audio clips and plan the
77
- * FFmpeg audio graph, returning the `-i`/`-filter_complex`/`-map` argument
78
- * fragments. Shared by the linear `render()` path and the sharded orchestrator
79
- * (which mixes audio once, over the concatenated video). Returns empty args when
80
- * there is nothing to mix.
221
+ * Collect the timeline + auto-mixed (narration/music/sfx) audio clips for a
222
+ * scene the shared front half of the mix used by both `planFinalAudio` (the
223
+ * render/shard path) and `buildMixWav` (the measure-loudness path), so the mix
224
+ * CONTENT measured at commit-time is byte-for-byte the mix rendered later.
225
+ */
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
+
241
+ /**
242
+ * Resolve the committed publish gain (dB) for a render: read `<scene>.loudness.json`
243
+ * (when present and `loudness !== 'off'`), recompute the mixHash over the current
244
+ * mix inputs, and HARD-THROW on a mismatch — a re-narrate/re-sfx must invalidate
245
+ * the measurement loudly rather than silently mis-normalize. Returns null when no
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.
253
+ */
254
+ declare function resolveLoudnessGainDb(opts: Pick<RenderOptions, 'modulePath' | 'loudness' | 'narration' | 'music' | 'sfx'>, timelineClips?: AudioClip[]): Promise<number | null>;
255
+ /**
256
+ * Collect audio clips + auto-mixed siblings and plan the FFmpeg audio graph,
257
+ * returning the `-i`/`-filter_complex`/`-map` argument fragments. Shared by the
258
+ * linear `render()` path and the sharded orchestrator (which mixes audio once,
259
+ * over the concatenated video). Returns empty args when there is nothing to mix.
260
+ *
261
+ * When a committed `<scene>.loudness.json` applies, its publish gain is appended
262
+ * as a PURE `volume=<gain>dB` scalar on the FINAL mix node — a single scalar in
263
+ * the existing graph, NOT a second pass. The scalar gain is bit-deterministic
264
+ * (verified) and golden-hashable; the only non-deterministic stages (mix-to-PCM,
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.)
81
269
  */
82
270
  declare function planFinalAudio(opts: RenderOptions, timelineClips: AudioClip[], duration: number, container: 'mp4' | 'webm'): Promise<{
83
271
  audioInputs: string[];
84
272
  audioArgs: string[];
85
273
  }>;
274
+ /**
275
+ * Build the FINAL mixed audio to a WAV file — the measure-loudness input. Uses
276
+ * the SAME `collectAudioClips` + `planAudioMix` as the render path (no loudness
277
+ * gain — measurement is of the un-gained mix), so the measured content is exactly
278
+ * what render will later mix. Returns false when the scene has no audio.
279
+ */
280
+ declare function buildMixWav(opts: Pick<RenderOptions, 'modulePath' | 'narration' | 'music' | 'sfx'>, wavOut: string): Promise<boolean>;
281
+ //#endregion
282
+ //#region src/loudness.d.ts
283
+ /**
284
+ * gs measure-loudness (0.12, DESIGN §5.3): loudness-normalized PUBLISH profiles
285
+ * via a deterministic, peak-clamped scalar GAIN — NOT a render-pipeline change.
286
+ *
287
+ * The key insight: YouTube/Shorts re-normalize loudness platform-side, so the
288
+ * publish target is *≤ target-LUFS AND ≤ -1 dBTP*, not exact. So we never need a
289
+ * two-pass limiter on the render hot path. Instead:
290
+ *
291
+ * 1. `gs measure-loudness` runs ffmpeg's `loudnorm` measurement pass (a
292
+ * print-only ebur128 + true-peak gate) at MEASURE-time over the final
293
+ * mixed PCM, reading `inputI` (integrated LUFS), `inputTp` (true peak
294
+ * dBTP), `inputLra`. This is the ONE non-deterministic stage and it is
295
+ * QUARANTINED to commit-time — §5.3 already concedes mix-to-PCM bytes are
296
+ * per-path only.
297
+ * 2. It commits a `<scene>.loudness.json` carrying the measured numbers, the
298
+ * chosen profile, the resulting `gain` (dB), and a `mixHash` binding the
299
+ * measurement to the mix CONTENT (the narration/music/sfx manifests).
300
+ * 3. `gs render` reads that file and applies `gain` as a PURE scalar
301
+ * `volume=<gain>dB` multiply on the FINAL mix node — a single scalar in the
302
+ * existing filter graph, NOT a new ffmpeg pass. That stage is bit-exact and
303
+ * golden-hashable (a multiply at the same float→Int16 boundary the rest of
304
+ * the audio path shares).
305
+ *
306
+ * The gain is peak-clamped: `gain = min(gainForTargetLUFS, (-1 dBTP) - inputTp)`.
307
+ * The clamp uses the MEASURED true-peak, so the published output is guaranteed
308
+ * ≤ -1 dBTP with no render-time oversampling. (For 0.12 the brickwall true-peak
309
+ * LIMITER is DEFERRED — un-normalized profiles still get the gain plus an
310
+ * advisory warning when a peaky source would have needed brickwalling.)
311
+ */
312
+ declare class LoudnessError extends Error {
313
+ constructor(detail: string);
314
+ }
315
+ /** A publish target: integrated-loudness goal + the true-peak ceiling. */
316
+ interface PublishProfile {
317
+ readonly id: string;
318
+ /** integrated-loudness target, LUFS */
319
+ readonly targetLufs: number;
320
+ /** true-peak ceiling, dBTP (the peak clamp is computed against this) */
321
+ readonly truePeakDb: number;
322
+ /**
323
+ * Whether the platform re-normalizes loudness on its side. For
324
+ * platform-normalized targets (YouTube/Shorts) a peaky source that can't reach
325
+ * `targetLufs` without clipping is fine — the platform finishes the job. For
326
+ * un-normalized targets (podcast/broadcast) it earns an advisory warning,
327
+ * since 0.12 ships no brickwall limiter to recover the headroom.
328
+ */
329
+ readonly platformNormalized: boolean;
330
+ }
331
+ declare const PUBLISH_PROFILES: Readonly<Record<string, PublishProfile>>;
332
+ declare const DEFAULT_PROFILE_ID = "youtube";
333
+ /** Resolve a profile id (case-insensitive) or throw with the valid set. */
334
+ declare function resolveProfile(id: string): PublishProfile;
335
+ declare const LOUDNESS_SCHEMA_VERSION: 1;
336
+ /** The committed `<scene>.loudness.json`. */
337
+ interface LoudnessMeasurement {
338
+ loudnessVersion: typeof LOUDNESS_SCHEMA_VERSION;
339
+ /** the resolved publish profile id (youtube/shorts/podcast/broadcast/ebu) */
340
+ profileId: string;
341
+ /** measured integrated loudness, LUFS (ebur128) */
342
+ inputI: number;
343
+ /** measured true peak, dBTP */
344
+ inputTp: number;
345
+ /** measured loudness range, LU */
346
+ inputLra: number;
347
+ /**
348
+ * the deterministic gain to apply at render, in dB.
349
+ * `gain = min(targetLufs - inputI, truePeakDb - inputTp)` — the peak clamp
350
+ * guarantees the published output is ≤ truePeakDb using the MEASURED peak.
351
+ */
352
+ gain: number;
353
+ /**
354
+ * binds this measurement to the mix CONTENT version (a hash of the mix input
355
+ * manifests — narration/music/sfx timing + any wired timeline audio — NOT
356
+ * mtime). Render recomputes it and HARD-THROWS on mismatch so a re-narrate
357
+ * invalidates the measurement loudly instead of silently mis-normalizing.
358
+ */
359
+ mixHash: string;
360
+ }
361
+ /**
362
+ * The peak-clamped publish gain (dB). `gainForTargetLUFS = target - inputI`
363
+ * raises/lowers to the integrated target; the clamp `truePeak - inputTp` caps it
364
+ * so the result never exceeds the true-peak ceiling. We take the min: a source
365
+ * that can't reach the loudness target without clipping is left below it (the
366
+ * platform re-normalizes the rest for `youtube`/`shorts`).
367
+ */
368
+ declare function computeGainDb(profile: PublishProfile, inputI: number, inputTp: number): number;
369
+ /**
370
+ * True when the peak clamp BOUND the gain — i.e. the source is too peaky to reach
371
+ * the loudness target without exceeding the true-peak ceiling. For an
372
+ * un-normalized profile this is where a brickwall limiter would normally recover
373
+ * the headroom (deferred in 0.12 → advisory warning).
374
+ */
375
+ declare function peakClampBinds(profile: PublishProfile, inputI: number, inputTp: number): boolean;
376
+ /**
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.
387
+ */
388
+ declare function computeMixHash(modulePath: string, extraInputs?: readonly string[]): string;
389
+ /** `<module>.loudness.json` for a scene module. */
390
+ declare function loudnessPathFor(modulePath: string): string;
391
+ /** Read + validate a committed measurement, or null when none is committed. */
392
+ declare function readLoudness(modulePath: string): LoudnessMeasurement | null;
393
+ /**
394
+ * Parse the JSON block ffmpeg's `loudnorm=print_format=json` prints to stderr.
395
+ * It carries `input_i` / `input_tp` / `input_lra` (the measured loudness),
396
+ * alongside the would-be normalization params (which we ignore — we apply our
397
+ * own peak-clamped scalar gain instead).
398
+ */
399
+ declare function parseLoudnormJson(stderr: string): {
400
+ inputI: number;
401
+ inputTp: number;
402
+ inputLra: number;
403
+ };
404
+ /**
405
+ * Run ffmpeg's loudnorm measurement pass over a built mix WAV/PCM file and return
406
+ * the measured loudness. This is the quarantined non-deterministic stage — it
407
+ * runs ONLY at measure-time (commit), never during render.
408
+ */
409
+ declare function measureFile(audioPath: string): {
410
+ inputI: number;
411
+ inputTp: number;
412
+ inputLra: number;
413
+ };
414
+ interface MeasureLoudnessOptions {
415
+ modulePath: string;
416
+ /** publish profile id; default 'youtube' */
417
+ profile?: string;
418
+ /** narration auto-mix toggle (mirrors render); default auto */
419
+ narration?: 'auto' | 'off';
420
+ music?: 'auto' | 'off';
421
+ sfx?: 'auto' | 'off';
422
+ }
423
+ interface MeasureLoudnessResult {
424
+ measurement: LoudnessMeasurement;
425
+ loudnessPath: string;
426
+ /** profile the measurement was taken against */
427
+ profile: PublishProfile;
428
+ /** true when the peak clamp bound the gain (advisory: would need a brickwall limiter on an un-normalized profile) */
429
+ clampBound: boolean;
430
+ /** an advisory warning string when an un-normalized profile can't reach its target without clipping; else null */
431
+ warning: string | null;
432
+ }
433
+ /**
434
+ * Build the final mix to a WAV, run the ffmpeg loudnorm measurement over it, and
435
+ * commit a `<scene>.loudness.json` with the peak-clamped publish gain + a mixHash
436
+ * bound to the mix-input manifests. This is the measure step (commit-time); it is
437
+ * the only place the non-deterministic ebur128/mix-to-PCM stages run.
438
+ */
439
+ declare function measureLoudnessCommand(opts: MeasureLoudnessOptions): Promise<MeasureLoudnessResult>;
86
440
  //#endregion
87
441
  //#region src/shards.d.ts
88
442
  declare class ShardError extends Error {
@@ -181,6 +535,15 @@ interface AudioMixPlan {
181
535
  filterComplex: string;
182
536
  hasEasedGain: boolean;
183
537
  }
538
+ /**
539
+ * Append a PURE scalar publish-loudness gain to a mix's `-filter_complex`: the
540
+ * graph's final `[aout]` label is renamed and a `volume=<gain>dB` node feeds the
541
+ * new `[aout]`. This is a single multiply on the FINAL mix node — NOT a second
542
+ * ffmpeg pass — and is bit-deterministic (verified) + golden-hashable. A gain of
543
+ * exactly 0 dB is a no-op (returned unchanged) so an at-target source preserves
544
+ * the prior, un-gained bytes.
545
+ */
546
+ declare function applyMixGainDb(filterComplex: string, gainDb: number): string;
184
547
  /** Build the FFmpeg mix plan for clips that intersect [0, duration]. */
185
548
  declare function planAudioMix(clips: AudioClip[], modulePath: string, duration: number): AudioMixPlan | null;
186
549
  //#endregion
@@ -259,4 +622,182 @@ interface ImportCommandResult {
259
622
  }
260
623
  declare function importCommand(opts: ImportOptions): Promise<ImportCommandResult>;
261
624
  //#endregion
262
- export { AudioMixError, type AudioMixPlan, type DevOptions, type DevServer, type EncoderChoice, FfmpegVideoFrameSource, type ImportCommandResult, type ImportOptions, MachineExportError, type MachineRenderFlags, NoEncoderError, type RenderOptions, type RenderShardedArgs, SceneModuleError, ShardError, type ShardRange, type VideoInfo, VideoProbeError, atempoChain, availableEncoders, dev, ffmpegAvailable, gainExpression, importCommand, loadSceneModule, parseEncoderList, pickEncoder, planAudioMix, planFinalAudio, probeVideo, render, renderSharded, resolveAssetPath, resolveRenderDoc, sceneHasGpuNodes, splitFrameRange };
625
+ //#region src/diff.d.ts
626
+ interface DiffOptions {
627
+ modulePath: string;
628
+ /** seconds (the §5 Player-API time unit; `diff` is a single-frame still). */
629
+ at: number;
630
+ /** baseline path: `.dl.json` (structural diff) or `.png` (byte-compare). */
631
+ against: string;
632
+ }
633
+ interface DiffResult {
634
+ /** true when the scene matches the baseline. */
635
+ equal: boolean;
636
+ /** the rendered command-tree / byte-compare report (always populated). */
637
+ report: string;
638
+ /** the freshly-evaluated DisplayList (so callers can re-snapshot). */
639
+ actual: DisplayList;
640
+ }
641
+ /** Evaluate the scene module at `t` to its DisplayList (single-frame, no audio). */
642
+ declare function evaluateAt(modulePath: string, t: number): Promise<DisplayList>;
643
+ declare function diffCommand(opts: DiffOptions): Promise<DiffResult>;
644
+ /** Snapshot a scene's DisplayList at `t` to a `.dl.json` string (the baseline writer). */
645
+ declare function snapshotAt(modulePath: string, t: number): Promise<string>;
646
+ //#endregion
647
+ //#region src/narrationLint.d.ts
648
+ type LintRule = 'reading-speed' | 'anchor-budget' | 'caption-fit' | 'beat-drift' | 'silence';
649
+ interface Diagnostic {
650
+ /** which rule fired */
651
+ rule: LintRule;
652
+ /** 1 = HARD (can fail CI); 2 = WARN-only (never fails CI) */
653
+ tier: 1 | 2;
654
+ /** 'error' (Tier-1) | 'warn' (Tier-2) — derived from tier, surfaced for tables */
655
+ severity: 'error' | 'warn';
656
+ /** the segment / pause / cue id this is about */
657
+ id: string;
658
+ /** a one-line human message (the table cell) */
659
+ message: string;
660
+ /** measured numbers, for --json consumers and the --fix diff */
661
+ detail?: Record<string, number | string>;
662
+ }
663
+ /**
664
+ * The caption-fit probe: drive the REAL caption node with a cue's text and
665
+ * report its measured geometry. The CLI builds this from the loaded scene +
666
+ * the Skia measurer; a pure unit test can stub it. Returns null when the scene
667
+ * has no caption node (caption-fit then doesn't run).
668
+ */
669
+ interface CaptionProbe {
670
+ /** scene height (px) — the box the caption must stay inside, bottom-anchored */
671
+ readonly sceneH: number;
672
+ /** the caption node's configured max line count before it's "too tall" */
673
+ readonly maxLines: number;
674
+ /**
675
+ * Lay out `text` on the real caption node (real width/font/autoFit + the real
676
+ * breakLines) and report the wrapped line count and the lowest ink pixel's
677
+ * absolute Y on the scene (so a block that runs off the bottom is caught).
678
+ */
679
+ measure(text: string): {
680
+ lines: number;
681
+ bottomY: number;
682
+ };
683
+ }
684
+ interface LintOptions {
685
+ /** max chars-per-second over a cue before reading-speed fires; default 17 */
686
+ maxCps?: number;
687
+ /** the caption-fit probe (real geometry); omit to skip caption-fit */
688
+ caption?: CaptionProbe;
689
+ /** include Tier-2 (warn-only) diagnostics; default true */
690
+ warnings?: boolean;
691
+ }
692
+ /**
693
+ * Lint the committed narration timing. Pure: same inputs (timing + the probe's
694
+ * measurements) → same diagnostics, in any order. Tier-1 diagnostics are
695
+ * errors a caller can exit non-zero on; Tier-2 are warnings that never gate CI.
696
+ */
697
+ declare function lintNarration(timing: NarrationTiming, opts?: LintOptions): Diagnostic[];
698
+ /** True when any Tier-1 diagnostic is present — the CI gate / exit-code signal. */
699
+ declare function hasErrors(diags: readonly Diagnostic[]): boolean;
700
+ /** A human-readable table (the default terminal output). */
701
+ declare function formatTable(diags: readonly Diagnostic[]): string;
702
+ /**
703
+ * A git-apply-able unified diff that bumps the committed budgets (and
704
+ * captionSplit, when a caption is too dense) to the values the manifest already
705
+ * shows — the `--fix` SUGGESTION. NEVER writes anything: it prints a diff the
706
+ * author reviews and applies (re-narrate is the real fix; this just unblocks CI
707
+ * when the over-budget value is genuinely the new intent). Targets the SCRIPT
708
+ * (`*.narration.json`), since budgets are committed there.
709
+ */
710
+ declare function fixDiff(diags: readonly Diagnostic[], scriptPath: string, script: {
711
+ budgets?: Record<string, number>;
712
+ }): string;
713
+ //#endregion
714
+ //#region src/narrationLintCommand.d.ts
715
+ interface NarrationLintOptions {
716
+ /** a scene module (resolves the sibling timing manifest) or the manifest itself */
717
+ input: string;
718
+ maxCps?: number;
719
+ /** caption node maxLines for the fit rule; default 2 (captionNode's own default) */
720
+ maxLines?: number;
721
+ json?: boolean;
722
+ fix?: boolean;
723
+ /** skip Tier-2 (warn-only) diagnostics */
724
+ noWarnings?: boolean;
725
+ }
726
+ interface NarrationLintResult {
727
+ diagnostics: Diagnostic[];
728
+ /** true when any Tier-1 diagnostic fired (the CI gate / exit code) */
729
+ hasErrors: boolean;
730
+ /** the rendered human/JSON/diff text to print */
731
+ output: string;
732
+ timingPath: string;
733
+ }
734
+ /** `<scene>.narration.timing.json` or the manifest path itself. */
735
+ declare function lintTimingPathFor(input: string): Promise<string>;
736
+ /**
737
+ * Build a caption-fit probe by loading the scene, registering its fonts, and
738
+ * driving the REAL `captions` node with the Skia measurer. Returns null when
739
+ * the input is a bare manifest (no scene module) or the scene has no caption
740
+ * node — caption-fit then doesn't run, and the lint is still exact for the
741
+ * other rules. Async because font registration + the scene load are.
742
+ */
743
+ declare function buildCaptionProbe(input: string, maxLines: number): Promise<CaptionProbe | null>;
744
+ /** Run the lint end-to-end and render the output (JSON / table / --fix diff). */
745
+ declare function narrationLintCommand(opts: NarrationLintOptions): Promise<NarrationLintResult>;
746
+ //#endregion
747
+ //#region src/cacheVerify.d.ts
748
+ interface CacheVerifyOptions {
749
+ modulePath: string;
750
+ /** inclusive frame range [first, last]; default the whole timeline. */
751
+ frameRange?: [number, number];
752
+ /** fps override (default: timeline fps, else 60). */
753
+ fps?: number;
754
+ /** sample 1-of-N frames for the byte compare (default 1 = every frame). LOGGED. */
755
+ sample?: number;
756
+ /**
757
+ * INTERNAL test seam: override the cache-key context. The NEGATIVE gate passes a
758
+ * deliberately INCOMPLETE keyer to prove the verify FAILS when the key drops a
759
+ * byte-affecting component. Never set by the CLI.
760
+ */
761
+ keyContextOverride?: CacheKeyContext;
762
+ /**
763
+ * INTERNAL test seam: a custom keyer (e.g. one that IGNORES the DisplayList) to
764
+ * simulate a structurally-incomplete key for the NEGATIVE gate.
765
+ */
766
+ keyerOverride?: (dl: DisplayList, ctx: CacheKeyContext) => string;
767
+ }
768
+ interface CacheVerifyResult {
769
+ ok: boolean;
770
+ /** frames actually byte-compared (the sampled set). */
771
+ comparedFrames: number[];
772
+ /** total frames in range (compared ⊆ this). */
773
+ totalFrames: number;
774
+ /** the first mismatching frame, when !ok. */
775
+ mismatch?: {
776
+ frame: number;
777
+ cached: string;
778
+ cold: string;
779
+ };
780
+ report: string;
781
+ }
782
+ /**
783
+ * The verify gate. Renders the range through a read-WRITE cache once (to WARM it),
784
+ * then compares — for each SAMPLED frame — a read-ONLY render (serving the warmed
785
+ * hits) against a cache-OFF render, by encodePng sha256. Equal frame-for-frame is
786
+ * the contract; the first mismatch fails the gate.
787
+ */
788
+ declare function cacheVerifyCommand(opts: CacheVerifyOptions): Promise<CacheVerifyResult>;
789
+ //#endregion
790
+ //#region src/version.d.ts
791
+ /**
792
+ * The glissade VERSION string folded into the persistent frame-cache key
793
+ * (DESIGN.md §3.5, 0.12). Lockstep `0.x` versioning means the @glissade/cli
794
+ * package version bumps with EVERY release, so any Raster2D-composite or
795
+ * Skia-toolchain change ships under a new version — making bump-on-version the
796
+ * cheapest CORRECT cache invalidation. Read once from the package manifest (the
797
+ * single source of truth) rather than hard-coded so it can never drift from the
798
+ * published version.
799
+ */
800
+ /** The @glissade/cli package version (the glissade VERSION for the cache key). */
801
+ declare function glissadeVersion(): string;
802
+ //#endregion
803
+ export { AudioMixError, type AudioMixPlan, type CacheKeyContext, type CacheMode, type CacheVerifyOptions, type CacheVerifyResult, type CaptionProbe, DEFAULT_CACHE_MAX_SIZE, DEFAULT_PROFILE_ID, type DevOptions, type DevServer, type Diagnostic, type DiffOptions, type DiffResult, type EncoderChoice, FfmpegVideoFrameSource, FrameCache, FrameCacheError, type FrameCacheOptions, type ImportCommandResult, type ImportOptions, LOUDNESS_SCHEMA_VERSION, type LintOptions, type LintRule, LoudnessError, type LoudnessMeasurement, MachineExportError, type MachineRenderFlags, type MeasureLoudnessOptions, type MeasureLoudnessResult, type NarrationLintOptions, type NarrationLintResult, NoEncoderError, PUBLISH_PROFILES, type PublishProfile, type RenderOptions, type RenderShardedArgs, SceneModuleError, ShardError, type ShardRange, type VideoInfo, VideoProbeError, applyMixGainDb, atempoChain, availableEncoders, buildCaptionProbe, buildMixWav, cacheVerifyCommand, capsId, clearFrameCache, collectAudioClips, computeGainDb, computeMixHash, dev, diffCommand, evaluateAt, ffmpegAvailable, fixDiff, formatTable, frameCacheKey, gainExpression, glissadeVersion, hasErrors, importCommand, lintNarration, lintTimingPathFor, loadSceneModule, loudnessPathFor, measureFile, measureLoudnessCommand, narrationLintCommand, parseCacheMaxSize, parseEncoderList, parseLoudnormJson, peakClampBinds, pickEncoder, planAudioMix, planFinalAudio, probeEntryHeader, probeVideo, readLoudness, render, renderSharded, resolveAssetPath, resolveLoudnessGainDb, resolveProfile, resolveRenderDoc, sceneHasGpuNodes, snapshotAt, splitFrameRange };
package/dist/index.js CHANGED
@@ -1,9 +1,15 @@
1
- import { a as planFinalAudio, n as ffmpegAvailable, o as render, r as loadSceneModule, t as SceneModuleError } from "./render.js";
1
+ import { a as loadSceneModule, c as render, i as ffmpegAvailable, n as buildMixWav, r as collectAudioClips, s as planFinalAudio, t as SceneModuleError, u as resolveLoudnessGainDb } from "./render.js";
2
+ import { a as computeGainDb, d as parseLoudnormJson, f as peakClampBinds, i as PUBLISH_PROFILES, l as measureFile, m as resolveProfile, n as LOUDNESS_SCHEMA_VERSION, o as computeMixHash, p as readLoudness, r as LoudnessError, s as loudnessPathFor, t as DEFAULT_PROFILE_ID, u as measureLoudnessCommand } from "./loudness.js";
2
3
  import { a as pickEncoder, i as parseEncoderList, n as availableEncoders, t as NoEncoderError } from "./encoders.js";
3
4
  import { a as splitFrameRange, n as renderSharded, r as sceneHasGpuNodes, t as ShardError } from "./shards.js";
4
5
  import { n as VideoProbeError, r as probeVideo, t as FfmpegVideoFrameSource } from "./videoSource.js";
5
- import { a as planAudioMix, i as gainExpression, n as atempoChain, o as resolveAssetPath, t as AudioMixError } from "./audioMix.js";
6
+ import { a as gainExpression, n as applyMixGainDb, o as planAudioMix, r as atempoChain, s as resolveAssetPath, t as AudioMixError } from "./audioMix.js";
6
7
  import { r as resolveRenderDoc, t as MachineExportError } from "./machines.js";
7
8
  import { t as dev } from "./dev.js";
8
9
  import { t as importCommand } from "./import.js";
9
- export { AudioMixError, FfmpegVideoFrameSource, MachineExportError, NoEncoderError, SceneModuleError, ShardError, VideoProbeError, atempoChain, availableEncoders, dev, ffmpegAvailable, gainExpression, importCommand, loadSceneModule, parseEncoderList, pickEncoder, planAudioMix, planFinalAudio, probeVideo, render, renderSharded, resolveAssetPath, resolveRenderDoc, sceneHasGpuNodes, splitFrameRange };
10
+ import { i as snapshotAt, r as evaluateAt, t as diffCommand } from "./diff.js";
11
+ import { a as fixDiff, c as lintNarration, n as lintTimingPathFor, o as formatTable, r as narrationLintCommand, s as hasErrors, t as buildCaptionProbe } from "./narrationLintCommand.js";
12
+ import { a as clearFrameCache, c as parseCacheMaxSize, i as capsId, l as probeEntryHeader, n as FrameCache, o as frameCacheKey, r as FrameCacheError, t as DEFAULT_CACHE_MAX_SIZE } from "./frameCache.js";
13
+ import { t as glissadeVersion } from "./version.js";
14
+ import { t as cacheVerifyCommand } from "./cacheVerify.js";
15
+ export { AudioMixError, DEFAULT_CACHE_MAX_SIZE, DEFAULT_PROFILE_ID, FfmpegVideoFrameSource, FrameCache, FrameCacheError, LOUDNESS_SCHEMA_VERSION, LoudnessError, MachineExportError, NoEncoderError, PUBLISH_PROFILES, SceneModuleError, ShardError, VideoProbeError, applyMixGainDb, atempoChain, availableEncoders, buildCaptionProbe, buildMixWav, cacheVerifyCommand, capsId, clearFrameCache, collectAudioClips, computeGainDb, computeMixHash, dev, diffCommand, evaluateAt, ffmpegAvailable, fixDiff, formatTable, frameCacheKey, gainExpression, glissadeVersion, hasErrors, importCommand, lintNarration, lintTimingPathFor, loadSceneModule, loudnessPathFor, measureFile, measureLoudnessCommand, narrationLintCommand, parseCacheMaxSize, parseEncoderList, parseLoudnormJson, peakClampBinds, pickEncoder, planAudioMix, planFinalAudio, probeEntryHeader, probeVideo, readLoudness, render, renderSharded, resolveAssetPath, resolveLoudnessGainDb, resolveProfile, resolveRenderDoc, sceneHasGpuNodes, snapshotAt, splitFrameRange };