@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/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/
|
|
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
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 };
|