@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/index.d.ts CHANGED
@@ -1,9 +1,117 @@
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
+ interface FrameCacheOptions {
24
+ dir: string;
25
+ mode: CacheMode;
26
+ /** max total bytes of stored (compressed) entries before LRU eviction; default 2 GB. */
27
+ maxSize?: number;
28
+ }
29
+ /** Default LRU cap: 2 GB of compressed entries (the card's default). */
30
+ declare const DEFAULT_CACHE_MAX_SIZE: number;
31
+ declare class FrameCacheError extends Error {
32
+ constructor(message: string);
33
+ }
34
+ /**
35
+ * Stable, content-addressed key for ONE whole frame. Folds EVERY byte-affecting
36
+ * input: the DisplayList-snapshot bytes (geometry/paint/transform, resources
37
+ * inlined to content), the glissade version, and the backend caps id. An
38
+ * incomplete key here IS the only failure mode (a stale frame served for changed
39
+ * content), so the NEGATIVE gate test asserts that dropping any component makes
40
+ * `gs cache verify` fail.
41
+ */
42
+ declare function frameCacheKey(dl: DisplayList, ctx: CacheKeyContext): string;
43
+ /** A canonical BackendCaps id (filters sorted, shaders, maxTextureSize) — the §3 caps fold. */
44
+ declare function capsId(caps: {
45
+ filters: ReadonlySet<string>;
46
+ shaders: boolean;
47
+ maxTextureSize: number;
48
+ }): string;
49
+ interface CacheStats {
50
+ hits: number;
51
+ misses: number;
52
+ stored: number;
53
+ evicted: number;
54
+ }
55
+ /**
56
+ * The persistent whole-frame raster cache. One instance per render process; shards
57
+ * each open their own over the SAME dir (content-addressed + atomic writes make
58
+ * that safe). A `mode:'off'` instance is inert — `get` always misses and `put` is a
59
+ * no-op — so the cache-off path is the exact current baseline.
60
+ */
61
+ declare class FrameCache {
62
+ readonly dir: string;
63
+ readonly mode: CacheMode;
64
+ readonly maxSize: number;
65
+ private readonly stats;
66
+ constructor(opts: FrameCacheOptions);
67
+ private pathFor;
68
+ /**
69
+ * Look up a whole-frame RGBA by key. On a hit, the file's mtime is touched
70
+ * (access-time bump for the LRU) and the decoded RGBA returned; the caller blits
71
+ * it via `backend.putPixels` and runs the IDENTICAL encode. Returns undefined on
72
+ * a miss or in `mode:'off'`. A corrupt/truncated entry is treated as a miss (it
73
+ * is deleted so it re-renders) rather than a hard error — the cache is advisory.
74
+ */
75
+ get(key: string): Uint8ClampedArray | undefined;
76
+ /**
77
+ * Store a whole-frame RGBA under its key. No-op in `read-only`/`off`. The write
78
+ * is atomic (temp file + rename) so a crash/interrupt never leaves a torn entry
79
+ * a later run could half-read (the single failure mode). After a store the LRU
80
+ * size cap is enforced.
81
+ */
82
+ put(key: string, width: number, height: number, rgba: Uint8ClampedArray): void;
83
+ /**
84
+ * Evict oldest (by mtime) entries until the total compressed size is within
85
+ * `maxSize`. Content-addressed + idempotent, so a delete racing a peer's
86
+ * regeneration is benign. Runs after each store; cheap relative to a frame
87
+ * rasterize.
88
+ */
89
+ private enforceSizeCap;
90
+ /** Snapshot of hit/miss/store/evict counters (for the render summary + tests). */
91
+ getStats(): Readonly<CacheStats>;
92
+ /** Total compressed bytes currently on disk (for tests / a `--cache` summary). */
93
+ diskSize(): number;
94
+ /** Number of entries currently on disk (tests). */
95
+ entryCount(): number;
96
+ }
97
+ /**
98
+ * Parse a `--cache-max-size` flag: a raw byte count or a human size (`2GB`,
99
+ * `512MB`, `1.5g`). Rejects garbage so a typo doesn't silently default. Case- and
100
+ * suffix-insensitive (b/k/m/g, optionally with a trailing `b`).
101
+ */
102
+ declare function parseCacheMaxSize(flag: string): number;
103
+ /**
104
+ * Read the magic-validated header of an entry WITHOUT inflating its body — a cheap
105
+ * existence + size probe used by `gs cache verify` to report what it sampled.
106
+ */
107
+ declare function probeEntryHeader(file: string): {
108
+ width: number;
109
+ height: number;
110
+ } | undefined;
111
+ /** Remove the whole `.gscache` dir (a `gs cache clear` convenience / test cleanup). */
112
+ declare function clearFrameCache(dir: string): void;
113
+ //#endregion
114
+ //#region src/render.d.ts
7
115
  interface RenderOptions {
8
116
  modulePath: string;
9
117
  out: string;
@@ -30,6 +138,13 @@ interface RenderOptions {
30
138
  narration?: 'auto' | 'off';
31
139
  /** auto (default): mix effect hits from a sibling *.sfx.timing.json. */
32
140
  sfx?: 'auto' | 'off';
141
+ /**
142
+ * auto (default): apply a committed `<scene>.loudness.json` publish gain (a
143
+ * pure scalar `volume=<gain>dB` on the final mix node) when one exists, and
144
+ * HARD-THROW if its mixHash no longer matches the mix inputs (a re-narrate must
145
+ * invalidate loudly). 'off' ignores any committed measurement.
146
+ */
147
+ loudness?: 'auto' | 'off';
33
148
  /** also write WebVTT chapters from cue markers ('vtt'); cues.json is always written when cues exist. */
34
149
  chapters?: 'vtt' | 'off';
35
150
  /** cue kinds that become VTT chapters (default just 'chapter'); cues.json keeps all kinds. */
@@ -55,6 +170,19 @@ interface RenderOptions {
55
170
  * this is set.
56
171
  */
57
172
  allowGpuShards?: boolean;
173
+ /**
174
+ * --cache (§3.5, 0.12): persistent whole-frame raster cache. `dir` is the
175
+ * `.gscache` directory (shared across runs AND shards); `mode` defaults to 'off'
176
+ * (the exact current equality baseline). `maxSize` caps the LRU (default 2 GB).
177
+ * A hit serves a stored RGBA byte-identical to a cold render — it wins REPEATED
178
+ * renders and the UNCHANGED-PREFIX of a single-segment edit. A full re-narrate
179
+ * shifts every frame's timing → every DisplayList changes → every frame MISSES.
180
+ */
181
+ cache?: {
182
+ dir: string;
183
+ mode: CacheMode;
184
+ maxSize?: number;
185
+ };
58
186
  onProgress?: (frame: number, total: number) => void;
59
187
  }
60
188
  declare class SceneModuleError extends Error {
@@ -73,16 +201,199 @@ declare function render(opts: RenderOptions): Promise<{
73
201
  out: string;
74
202
  }>;
75
203
  /**
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.
204
+ * Collect the timeline + auto-mixed (narration/music/sfx) audio clips for a
205
+ * scene the shared front half of the mix used by both `planFinalAudio` (the
206
+ * render/shard path) and `buildMixWav` (the measure-loudness path), so the mix
207
+ * CONTENT measured at commit-time is byte-for-byte the mix rendered later.
208
+ */
209
+ declare function collectAudioClips(opts: Pick<RenderOptions, 'modulePath' | 'narration' | 'music' | 'sfx'>, timelineClips: AudioClip[]): Promise<AudioClip[]>;
210
+ /**
211
+ * Resolve the committed publish gain (dB) for a render: read `<scene>.loudness.json`
212
+ * (when present and `loudness !== 'off'`), recompute the mixHash over the current
213
+ * mix inputs, and HARD-THROW on a mismatch — a re-narrate/re-sfx must invalidate
214
+ * the measurement loudly rather than silently mis-normalize. Returns null when no
215
+ * measurement is committed or loudness is off (no gain applied).
216
+ */
217
+ declare function resolveLoudnessGainDb(opts: Pick<RenderOptions, 'modulePath' | 'loudness'>): Promise<number | null>;
218
+ /**
219
+ * Collect audio clips + auto-mixed siblings and plan the FFmpeg audio graph,
220
+ * returning the `-i`/`-filter_complex`/`-map` argument fragments. Shared by the
221
+ * linear `render()` path and the sharded orchestrator (which mixes audio once,
222
+ * over the concatenated video). Returns empty args when there is nothing to mix.
223
+ *
224
+ * When a committed `<scene>.loudness.json` applies, its publish gain is appended
225
+ * as a PURE `volume=<gain>dB` scalar on the FINAL mix node — a single scalar in
226
+ * the existing graph, NOT a second pass. The scalar gain is bit-deterministic
227
+ * (verified) and golden-hashable; the only non-deterministic stages (mix-to-PCM,
228
+ * measure-time ebur128) are quarantined to commit/measure-time per §5.3.
81
229
  */
82
230
  declare function planFinalAudio(opts: RenderOptions, timelineClips: AudioClip[], duration: number, container: 'mp4' | 'webm'): Promise<{
83
231
  audioInputs: string[];
84
232
  audioArgs: string[];
85
233
  }>;
234
+ /**
235
+ * Build the FINAL mixed audio to a WAV file — the measure-loudness input. Uses
236
+ * the SAME `collectAudioClips` + `planAudioMix` as the render path (no loudness
237
+ * gain — measurement is of the un-gained mix), so the measured content is exactly
238
+ * what render will later mix. Returns false when the scene has no audio.
239
+ */
240
+ declare function buildMixWav(opts: Pick<RenderOptions, 'modulePath' | 'narration' | 'music' | 'sfx'>, wavOut: string): Promise<boolean>;
241
+ //#endregion
242
+ //#region src/loudness.d.ts
243
+ /**
244
+ * gs measure-loudness (0.12, DESIGN §5.3): loudness-normalized PUBLISH profiles
245
+ * via a deterministic, peak-clamped scalar GAIN — NOT a render-pipeline change.
246
+ *
247
+ * The key insight: YouTube/Shorts re-normalize loudness platform-side, so the
248
+ * publish target is *≤ target-LUFS AND ≤ -1 dBTP*, not exact. So we never need a
249
+ * two-pass limiter on the render hot path. Instead:
250
+ *
251
+ * 1. `gs measure-loudness` runs ffmpeg's `loudnorm` measurement pass (a
252
+ * print-only ebur128 + true-peak gate) at MEASURE-time over the final
253
+ * mixed PCM, reading `inputI` (integrated LUFS), `inputTp` (true peak
254
+ * dBTP), `inputLra`. This is the ONE non-deterministic stage and it is
255
+ * QUARANTINED to commit-time — §5.3 already concedes mix-to-PCM bytes are
256
+ * per-path only.
257
+ * 2. It commits a `<scene>.loudness.json` carrying the measured numbers, the
258
+ * chosen profile, the resulting `gain` (dB), and a `mixHash` binding the
259
+ * measurement to the mix CONTENT (the narration/music/sfx manifests).
260
+ * 3. `gs render` reads that file and applies `gain` as a PURE scalar
261
+ * `volume=<gain>dB` multiply on the FINAL mix node — a single scalar in the
262
+ * existing filter graph, NOT a new ffmpeg pass. That stage is bit-exact and
263
+ * golden-hashable (a multiply at the same float→Int16 boundary the rest of
264
+ * the audio path shares).
265
+ *
266
+ * The gain is peak-clamped: `gain = min(gainForTargetLUFS, (-1 dBTP) - inputTp)`.
267
+ * The clamp uses the MEASURED true-peak, so the published output is guaranteed
268
+ * ≤ -1 dBTP with no render-time oversampling. (For 0.12 the brickwall true-peak
269
+ * LIMITER is DEFERRED — un-normalized profiles still get the gain plus an
270
+ * advisory warning when a peaky source would have needed brickwalling.)
271
+ */
272
+ declare class LoudnessError extends Error {
273
+ constructor(detail: string);
274
+ }
275
+ /** A publish target: integrated-loudness goal + the true-peak ceiling. */
276
+ interface PublishProfile {
277
+ readonly id: string;
278
+ /** integrated-loudness target, LUFS */
279
+ readonly targetLufs: number;
280
+ /** true-peak ceiling, dBTP (the peak clamp is computed against this) */
281
+ readonly truePeakDb: number;
282
+ /**
283
+ * Whether the platform re-normalizes loudness on its side. For
284
+ * platform-normalized targets (YouTube/Shorts) a peaky source that can't reach
285
+ * `targetLufs` without clipping is fine — the platform finishes the job. For
286
+ * un-normalized targets (podcast/broadcast) it earns an advisory warning,
287
+ * since 0.12 ships no brickwall limiter to recover the headroom.
288
+ */
289
+ readonly platformNormalized: boolean;
290
+ }
291
+ declare const PUBLISH_PROFILES: Readonly<Record<string, PublishProfile>>;
292
+ declare const DEFAULT_PROFILE_ID = "youtube";
293
+ /** Resolve a profile id (case-insensitive) or throw with the valid set. */
294
+ declare function resolveProfile(id: string): PublishProfile;
295
+ declare const LOUDNESS_SCHEMA_VERSION: 1;
296
+ /** The committed `<scene>.loudness.json`. */
297
+ interface LoudnessMeasurement {
298
+ loudnessVersion: typeof LOUDNESS_SCHEMA_VERSION;
299
+ /** the resolved publish profile id (youtube/shorts/podcast/broadcast/ebu) */
300
+ profileId: string;
301
+ /** measured integrated loudness, LUFS (ebur128) */
302
+ inputI: number;
303
+ /** measured true peak, dBTP */
304
+ inputTp: number;
305
+ /** measured loudness range, LU */
306
+ inputLra: number;
307
+ /**
308
+ * the deterministic gain to apply at render, in dB.
309
+ * `gain = min(targetLufs - inputI, truePeakDb - inputTp)` — the peak clamp
310
+ * guarantees the published output is ≤ truePeakDb using the MEASURED peak.
311
+ */
312
+ gain: number;
313
+ /**
314
+ * binds this measurement to the mix CONTENT version (a hash of the mix input
315
+ * manifests — narration/music/sfx timing + any wired timeline audio — NOT
316
+ * mtime). Render recomputes it and HARD-THROWS on mismatch so a re-narrate
317
+ * invalidates the measurement loudly instead of silently mis-normalizing.
318
+ */
319
+ mixHash: string;
320
+ }
321
+ /**
322
+ * The peak-clamped publish gain (dB). `gainForTargetLUFS = target - inputI`
323
+ * raises/lowers to the integrated target; the clamp `truePeak - inputTp` caps it
324
+ * so the result never exceeds the true-peak ceiling. We take the min: a source
325
+ * that can't reach the loudness target without clipping is left below it (the
326
+ * platform re-normalizes the rest for `youtube`/`shorts`).
327
+ */
328
+ declare function computeGainDb(profile: PublishProfile, inputI: number, inputTp: number): number;
329
+ /**
330
+ * True when the peak clamp BOUND the gain — i.e. the source is too peaky to reach
331
+ * the loudness target without exceeding the true-peak ceiling. For an
332
+ * un-normalized profile this is where a brickwall limiter would normally recover
333
+ * the headroom (deferred in 0.12 → advisory warning).
334
+ */
335
+ declare function peakClampBinds(profile: PublishProfile, inputI: number, inputTp: number): boolean;
336
+ /**
337
+ * Hash the CONTENT of the mix's input manifests so a re-narrate / re-sfx / music
338
+ * change invalidates a committed measurement. We hash the files' BYTES (not
339
+ * mtime): the narration/music/sfx timing manifests and any wired timeline audio
340
+ * sidecars. Missing siblings are recorded by name with a sentinel so adding one
341
+ * later also changes the hash. The scene module path itself is excluded — the
342
+ * mix is a function of the manifests, and timeline `audio` clips flow through the
343
+ * narration/music/sfx manifests or the explicitly-passed extra inputs.
344
+ */
345
+ declare function computeMixHash(modulePath: string, extraInputs?: readonly string[]): string;
346
+ /** `<module>.loudness.json` for a scene module. */
347
+ declare function loudnessPathFor(modulePath: string): string;
348
+ /** Read + validate a committed measurement, or null when none is committed. */
349
+ declare function readLoudness(modulePath: string): LoudnessMeasurement | null;
350
+ /**
351
+ * Parse the JSON block ffmpeg's `loudnorm=print_format=json` prints to stderr.
352
+ * It carries `input_i` / `input_tp` / `input_lra` (the measured loudness),
353
+ * alongside the would-be normalization params (which we ignore — we apply our
354
+ * own peak-clamped scalar gain instead).
355
+ */
356
+ declare function parseLoudnormJson(stderr: string): {
357
+ inputI: number;
358
+ inputTp: number;
359
+ inputLra: number;
360
+ };
361
+ /**
362
+ * Run ffmpeg's loudnorm measurement pass over a built mix WAV/PCM file and return
363
+ * the measured loudness. This is the quarantined non-deterministic stage — it
364
+ * runs ONLY at measure-time (commit), never during render.
365
+ */
366
+ declare function measureFile(audioPath: string): {
367
+ inputI: number;
368
+ inputTp: number;
369
+ inputLra: number;
370
+ };
371
+ interface MeasureLoudnessOptions {
372
+ modulePath: string;
373
+ /** publish profile id; default 'youtube' */
374
+ profile?: string;
375
+ /** narration auto-mix toggle (mirrors render); default auto */
376
+ narration?: 'auto' | 'off';
377
+ music?: 'auto' | 'off';
378
+ sfx?: 'auto' | 'off';
379
+ }
380
+ interface MeasureLoudnessResult {
381
+ measurement: LoudnessMeasurement;
382
+ loudnessPath: string;
383
+ /** profile the measurement was taken against */
384
+ profile: PublishProfile;
385
+ /** true when the peak clamp bound the gain (advisory: would need a brickwall limiter on an un-normalized profile) */
386
+ clampBound: boolean;
387
+ /** an advisory warning string when an un-normalized profile can't reach its target without clipping; else null */
388
+ warning: string | null;
389
+ }
390
+ /**
391
+ * Build the final mix to a WAV, run the ffmpeg loudnorm measurement over it, and
392
+ * commit a `<scene>.loudness.json` with the peak-clamped publish gain + a mixHash
393
+ * bound to the mix-input manifests. This is the measure step (commit-time); it is
394
+ * the only place the non-deterministic ebur128/mix-to-PCM stages run.
395
+ */
396
+ declare function measureLoudnessCommand(opts: MeasureLoudnessOptions): Promise<MeasureLoudnessResult>;
86
397
  //#endregion
87
398
  //#region src/shards.d.ts
88
399
  declare class ShardError extends Error {
@@ -181,6 +492,15 @@ interface AudioMixPlan {
181
492
  filterComplex: string;
182
493
  hasEasedGain: boolean;
183
494
  }
495
+ /**
496
+ * Append a PURE scalar publish-loudness gain to a mix's `-filter_complex`: the
497
+ * graph's final `[aout]` label is renamed and a `volume=<gain>dB` node feeds the
498
+ * new `[aout]`. This is a single multiply on the FINAL mix node — NOT a second
499
+ * ffmpeg pass — and is bit-deterministic (verified) + golden-hashable. A gain of
500
+ * exactly 0 dB is a no-op (returned unchanged) so an at-target source preserves
501
+ * the prior, un-gained bytes.
502
+ */
503
+ declare function applyMixGainDb(filterComplex: string, gainDb: number): string;
184
504
  /** Build the FFmpeg mix plan for clips that intersect [0, duration]. */
185
505
  declare function planAudioMix(clips: AudioClip[], modulePath: string, duration: number): AudioMixPlan | null;
186
506
  //#endregion
@@ -259,4 +579,182 @@ interface ImportCommandResult {
259
579
  }
260
580
  declare function importCommand(opts: ImportOptions): Promise<ImportCommandResult>;
261
581
  //#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 };
582
+ //#region src/diff.d.ts
583
+ interface DiffOptions {
584
+ modulePath: string;
585
+ /** seconds (the §5 Player-API time unit; `diff` is a single-frame still). */
586
+ at: number;
587
+ /** baseline path: `.dl.json` (structural diff) or `.png` (byte-compare). */
588
+ against: string;
589
+ }
590
+ interface DiffResult {
591
+ /** true when the scene matches the baseline. */
592
+ equal: boolean;
593
+ /** the rendered command-tree / byte-compare report (always populated). */
594
+ report: string;
595
+ /** the freshly-evaluated DisplayList (so callers can re-snapshot). */
596
+ actual: DisplayList;
597
+ }
598
+ /** Evaluate the scene module at `t` to its DisplayList (single-frame, no audio). */
599
+ declare function evaluateAt(modulePath: string, t: number): Promise<DisplayList>;
600
+ declare function diffCommand(opts: DiffOptions): Promise<DiffResult>;
601
+ /** Snapshot a scene's DisplayList at `t` to a `.dl.json` string (the baseline writer). */
602
+ declare function snapshotAt(modulePath: string, t: number): Promise<string>;
603
+ //#endregion
604
+ //#region src/narrationLint.d.ts
605
+ type LintRule = 'reading-speed' | 'anchor-budget' | 'caption-fit' | 'beat-drift' | 'silence';
606
+ interface Diagnostic {
607
+ /** which rule fired */
608
+ rule: LintRule;
609
+ /** 1 = HARD (can fail CI); 2 = WARN-only (never fails CI) */
610
+ tier: 1 | 2;
611
+ /** 'error' (Tier-1) | 'warn' (Tier-2) — derived from tier, surfaced for tables */
612
+ severity: 'error' | 'warn';
613
+ /** the segment / pause / cue id this is about */
614
+ id: string;
615
+ /** a one-line human message (the table cell) */
616
+ message: string;
617
+ /** measured numbers, for --json consumers and the --fix diff */
618
+ detail?: Record<string, number | string>;
619
+ }
620
+ /**
621
+ * The caption-fit probe: drive the REAL caption node with a cue's text and
622
+ * report its measured geometry. The CLI builds this from the loaded scene +
623
+ * the Skia measurer; a pure unit test can stub it. Returns null when the scene
624
+ * has no caption node (caption-fit then doesn't run).
625
+ */
626
+ interface CaptionProbe {
627
+ /** scene height (px) — the box the caption must stay inside, bottom-anchored */
628
+ readonly sceneH: number;
629
+ /** the caption node's configured max line count before it's "too tall" */
630
+ readonly maxLines: number;
631
+ /**
632
+ * Lay out `text` on the real caption node (real width/font/autoFit + the real
633
+ * breakLines) and report the wrapped line count and the lowest ink pixel's
634
+ * absolute Y on the scene (so a block that runs off the bottom is caught).
635
+ */
636
+ measure(text: string): {
637
+ lines: number;
638
+ bottomY: number;
639
+ };
640
+ }
641
+ interface LintOptions {
642
+ /** max chars-per-second over a cue before reading-speed fires; default 17 */
643
+ maxCps?: number;
644
+ /** the caption-fit probe (real geometry); omit to skip caption-fit */
645
+ caption?: CaptionProbe;
646
+ /** include Tier-2 (warn-only) diagnostics; default true */
647
+ warnings?: boolean;
648
+ }
649
+ /**
650
+ * Lint the committed narration timing. Pure: same inputs (timing + the probe's
651
+ * measurements) → same diagnostics, in any order. Tier-1 diagnostics are
652
+ * errors a caller can exit non-zero on; Tier-2 are warnings that never gate CI.
653
+ */
654
+ declare function lintNarration(timing: NarrationTiming, opts?: LintOptions): Diagnostic[];
655
+ /** True when any Tier-1 diagnostic is present — the CI gate / exit-code signal. */
656
+ declare function hasErrors(diags: readonly Diagnostic[]): boolean;
657
+ /** A human-readable table (the default terminal output). */
658
+ declare function formatTable(diags: readonly Diagnostic[]): string;
659
+ /**
660
+ * A git-apply-able unified diff that bumps the committed budgets (and
661
+ * captionSplit, when a caption is too dense) to the values the manifest already
662
+ * shows — the `--fix` SUGGESTION. NEVER writes anything: it prints a diff the
663
+ * author reviews and applies (re-narrate is the real fix; this just unblocks CI
664
+ * when the over-budget value is genuinely the new intent). Targets the SCRIPT
665
+ * (`*.narration.json`), since budgets are committed there.
666
+ */
667
+ declare function fixDiff(diags: readonly Diagnostic[], scriptPath: string, script: {
668
+ budgets?: Record<string, number>;
669
+ }): string;
670
+ //#endregion
671
+ //#region src/narrationLintCommand.d.ts
672
+ interface NarrationLintOptions {
673
+ /** a scene module (resolves the sibling timing manifest) or the manifest itself */
674
+ input: string;
675
+ maxCps?: number;
676
+ /** caption node maxLines for the fit rule; default 2 (captionNode's own default) */
677
+ maxLines?: number;
678
+ json?: boolean;
679
+ fix?: boolean;
680
+ /** skip Tier-2 (warn-only) diagnostics */
681
+ noWarnings?: boolean;
682
+ }
683
+ interface NarrationLintResult {
684
+ diagnostics: Diagnostic[];
685
+ /** true when any Tier-1 diagnostic fired (the CI gate / exit code) */
686
+ hasErrors: boolean;
687
+ /** the rendered human/JSON/diff text to print */
688
+ output: string;
689
+ timingPath: string;
690
+ }
691
+ /** `<scene>.narration.timing.json` or the manifest path itself. */
692
+ declare function lintTimingPathFor(input: string): Promise<string>;
693
+ /**
694
+ * Build a caption-fit probe by loading the scene, registering its fonts, and
695
+ * driving the REAL `captions` node with the Skia measurer. Returns null when
696
+ * the input is a bare manifest (no scene module) or the scene has no caption
697
+ * node — caption-fit then doesn't run, and the lint is still exact for the
698
+ * other rules. Async because font registration + the scene load are.
699
+ */
700
+ declare function buildCaptionProbe(input: string, maxLines: number): Promise<CaptionProbe | null>;
701
+ /** Run the lint end-to-end and render the output (JSON / table / --fix diff). */
702
+ declare function narrationLintCommand(opts: NarrationLintOptions): Promise<NarrationLintResult>;
703
+ //#endregion
704
+ //#region src/cacheVerify.d.ts
705
+ interface CacheVerifyOptions {
706
+ modulePath: string;
707
+ /** inclusive frame range [first, last]; default the whole timeline. */
708
+ frameRange?: [number, number];
709
+ /** fps override (default: timeline fps, else 60). */
710
+ fps?: number;
711
+ /** sample 1-of-N frames for the byte compare (default 1 = every frame). LOGGED. */
712
+ sample?: number;
713
+ /**
714
+ * INTERNAL test seam: override the cache-key context. The NEGATIVE gate passes a
715
+ * deliberately INCOMPLETE keyer to prove the verify FAILS when the key drops a
716
+ * byte-affecting component. Never set by the CLI.
717
+ */
718
+ keyContextOverride?: CacheKeyContext;
719
+ /**
720
+ * INTERNAL test seam: a custom keyer (e.g. one that IGNORES the DisplayList) to
721
+ * simulate a structurally-incomplete key for the NEGATIVE gate.
722
+ */
723
+ keyerOverride?: (dl: DisplayList, ctx: CacheKeyContext) => string;
724
+ }
725
+ interface CacheVerifyResult {
726
+ ok: boolean;
727
+ /** frames actually byte-compared (the sampled set). */
728
+ comparedFrames: number[];
729
+ /** total frames in range (compared ⊆ this). */
730
+ totalFrames: number;
731
+ /** the first mismatching frame, when !ok. */
732
+ mismatch?: {
733
+ frame: number;
734
+ cached: string;
735
+ cold: string;
736
+ };
737
+ report: string;
738
+ }
739
+ /**
740
+ * The verify gate. Renders the range through a read-WRITE cache once (to WARM it),
741
+ * then compares — for each SAMPLED frame — a read-ONLY render (serving the warmed
742
+ * hits) against a cache-OFF render, by encodePng sha256. Equal frame-for-frame is
743
+ * the contract; the first mismatch fails the gate.
744
+ */
745
+ declare function cacheVerifyCommand(opts: CacheVerifyOptions): Promise<CacheVerifyResult>;
746
+ //#endregion
747
+ //#region src/version.d.ts
748
+ /**
749
+ * The glissade VERSION string folded into the persistent frame-cache key
750
+ * (DESIGN.md §3.5, 0.12). Lockstep `0.x` versioning means the @glissade/cli
751
+ * package version bumps with EVERY release, so any Raster2D-composite or
752
+ * Skia-toolchain change ships under a new version — making bump-on-version the
753
+ * cheapest CORRECT cache invalidation. Read once from the package manifest (the
754
+ * single source of truth) rather than hard-coded so it can never drift from the
755
+ * published version.
756
+ */
757
+ /** The @glissade/cli package version (the glissade VERSION for the cache key). */
758
+ declare function glissadeVersion(): string;
759
+ //#endregion
760
+ 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 };