@glissade/cli 0.38.0 → 0.39.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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  `gs` — headless rendering and the dev/capture loop. `gs render` evaluates every frame, rasterizes on Skia (no browser anywhere), writes a PNG sequence or muxes mp4/webm via FFmpeg with sample-accurate audio. v2: `gs dev --record` serves a scene with its state machines mounted and writes input-trace sidecars; `gs render --trace/--state` are the deterministic export routes for interactive scenes.
4
4
 
5
- The full command set: `render`, `dev`, `import` (Lottie `.json` / `.svg`), `build`, `mcp` (an MCP server over the engine), `describe`, `migrate`, `repin` (narration-aware golden reviewer), `narrate`, `sfx`, `prepare`, `measure-loudness`, `fonts`, `cache`, `diff`, and `verify-determinism`. Run `gs` with no arguments for the full usage.
5
+ The full command set: `render`, `dev`, `import` (Lottie `.json` / `.svg`), `build`, `mcp` (an MCP server over the engine), `describe`, `migrate`, `repin` (narration-aware golden reviewer), `narrate`, `sfx`, `prepare`, `measure-loudness`, `master` (series loudness + limiter), `fonts`, `cache`, `diff`, and `verify-determinism`. Run `gs` with no arguments for the full usage.
6
6
 
7
7
  ```sh
8
8
  npm i -D @glissade/cli
@@ -33,6 +33,34 @@ The SSIM machinery is also exported from `@glissade/backend-skia`
33
33
  (`ssim` / `ssimMap` / `heatmapRgba`) for your own golden tooling. Full guide:
34
34
  [Reviewing goldens](https://github.com/tyevco/glissade/blob/main/docs/golden-review.md).
35
35
 
36
+ ## Series loudness (`gs master`)
37
+
38
+ `gs measure-loudness` gains one asset at a time and clamps the gain against the
39
+ source peak — so a peaky short lands LUs below target and a whole series ends up
40
+ inconsistent. `gs master` measures a whole set together, picks the loudest shared
41
+ LUFS target the set can hit under a shared true-peak ceiling, and ships a brickwall
42
+ true-peak **limiter** so a peaky member recovers headroom instead of landing low.
43
+
44
+ ```jsonc
45
+ // glissade.master.json
46
+ { "profile": "youtube",
47
+ "members": ["episodes/**/e*.ts", "**/*-short.ts"],
48
+ "limiter": { "mode": "truepeak", "ceilingDb": -1.0 },
49
+ "consistency": "shared-target" }
50
+ ```
51
+
52
+ ```sh
53
+ gs master glissade.master.json
54
+ # e01 in -22.1/-6.2dBTP -> +6.9dB,+1.2dB GR out -14.0/-1.0
55
+ # e07-short in -11.8/-0.3 -> -2.2dB out -14.0/-1.0
56
+ # shared target -14.0 LUFS — wrote <scene>.loudness.json ×16
57
+ ```
58
+
59
+ It writes the same `<scene>.loudness.json` sidecar (plus a `limiter` block), so it
60
+ composes with the render-time mixHash preflight and **applies as a mix-only remux
61
+ (~20 s/asset), never a re-render**. Full guide:
62
+ [Series mastering](https://github.com/tyevco/glissade/blob/main/docs/mastering.md).
63
+
36
64
  ## Part of glissade
37
65
 
38
66
  *(glide & slide)* — programmatic motion graphics for TypeScript: realtime-first in any web page, deterministic headless video export from the same code, a visual studio over the same document. No generator functions.
package/dist/audioMix.js CHANGED
@@ -13,6 +13,7 @@ var audioMix_exports = /* @__PURE__ */ __exportAll({
13
13
  applyMixGainDb: () => applyMixGainDb,
14
14
  atempoChain: () => atempoChain,
15
15
  gainExpression: () => gainExpression,
16
+ loudnessFilterNodes: () => loudnessFilterNodes,
16
17
  planAudioMix: () => planAudioMix,
17
18
  resolveAssetPath: () => resolveAssetPath
18
19
  });
@@ -61,19 +62,50 @@ function atempoChain(rate) {
61
62
  if (Math.abs(r - 1) > 1e-9 || chain.length === 0) chain.push(`atempo=${r}`);
62
63
  return chain;
63
64
  }
65
+ /** The mix sample rate the limiter oversamples around (glissade mixes at 48 kHz). */
66
+ const MIX_RATE = 48e3;
67
+ /** 4× oversampling for the true-peak limiter — reconstructs inter-sample peaks. */
68
+ const TP_OVERSAMPLE = MIX_RATE * 4;
64
69
  /**
65
- * Append a PURE scalar publish-loudness gain to a mix's `-filter_complex`: the
66
- * graph's final `[aout]` label is renamed and a `volume=<gain>dB` node feeds the
67
- * new `[aout]`. This is a single multiply on the FINAL mix node — NOT a second
68
- * ffmpeg pass and is bit-deterministic (verified) + golden-hashable. A gain of
69
- * exactly 0 dB is a no-op (returned unchanged) so an at-target source preserves
70
- * the prior, un-gained bytes.
70
+ * True-peak guard (dB): the sample-peak limit is set this far BELOW the ceiling so
71
+ * the downsample-reconstructed TRUE peak lands under `ceilingDb`. Empirically the
72
+ * oversample→limit→downsample residue is ~0.5 dB on worst-case (clipped-noise)
73
+ * content; 0.8 gives a comfortable margin (worst case measured −1.31 dBTP for a −1
74
+ * ceiling) without over-attenuating real beds. WITHOUT the oversample, `alimiter`
75
+ * is a SAMPLE-peak brickwall that leaves the true peak clipping over the ceiling
76
+ * (the 0.39.0-pre.0 defect — mode:'truepeak' didn't hold dBTP).
71
77
  */
72
- function applyMixGainDb(filterComplex, gainDb) {
73
- if (gainDb === 0) return filterComplex;
78
+ const TP_GUARD_DB = .8;
79
+ /**
80
+ * The publish-loudness filter nodes: a `volume=<gain>dB` multiply, and — for a
81
+ * `gs master` measurement — a REAL true-peak limiter (oversample 4× → `alimiter`
82
+ * at `ceilingDb − guard` → downsample) that actually holds the inter-sample /
83
+ * true peak under `ceilingDb`. Shared by the render `filter_complex` apply and the
84
+ * `gs master` verify pass so the committed limiter and the rendered output are the
85
+ * identical deterministic chain. Empty (a no-op) at 0 dB with no limiter.
86
+ */
87
+ function loudnessFilterNodes(gainDb, limiter) {
88
+ const nodes = [];
89
+ if (gainDb !== 0) nodes.push(`volume=${gainDb}dB`);
90
+ if (limiter) {
91
+ const limit = Math.pow(10, (limiter.ceilingDb - TP_GUARD_DB) / 20).toFixed(6);
92
+ nodes.push(`aresample=${TP_OVERSAMPLE}`, `alimiter=limit=${limit}:level=disabled`, `aresample=${MIX_RATE}`);
93
+ }
94
+ return nodes;
95
+ }
96
+ /**
97
+ * Append the publish-loudness stage to a mix's `-filter_complex`: the graph's
98
+ * final `[aout]` label is renamed and the {@link loudnessFilterNodes} (gain +,
99
+ * for a master, the true-peak limiter) feed the new `[aout]`. Bit-deterministic +
100
+ * golden-hashable. A 0 dB gain with NO limiter is a no-op (returned unchanged) so
101
+ * an at-target source preserves the prior, un-gained bytes.
102
+ */
103
+ function applyMixGainDb(filterComplex, gainDb, limiter) {
104
+ const nodes = loudnessFilterNodes(gainDb, limiter);
105
+ if (nodes.length === 0) return filterComplex;
74
106
  const at = filterComplex.lastIndexOf("[aout]");
75
107
  if (at < 0) throw new AudioMixError("mix filter graph has no [aout] to apply the loudness gain to");
76
- return `${filterComplex.slice(0, at) + "[apreg]" + filterComplex.slice(at + 6)};[apreg]volume=${gainDb}dB[aout]`;
108
+ return `${filterComplex.slice(0, at) + "[apreg]" + filterComplex.slice(at + 6)};[apreg]${nodes.join(",")}[aout]`;
77
109
  }
78
110
  /** Build the FFmpeg mix plan for clips that intersect [0, duration]. */
79
111
  function planAudioMix(clips, modulePath, duration) {
package/dist/cli.js CHANGED
@@ -81,6 +81,7 @@ const USAGE = `usage:
81
81
  gs sfx <scene-module|script.sfx.json> [--verbose]
82
82
  gs prepare <scene-module> [--provider <id>] [--align <id>] [--force]
83
83
  gs measure-loudness <scene-module> [--profile <youtube|shorts|podcast|broadcast|ebu>] [--locale <code>]
84
+ gs master <glissade.master.json> SERIES loudness: measure all members, pick the loudest shared LUFS target the set hits under a shared true-peak ceiling, ship the brickwall limiter, write <scene>.loudness.json ×N (applies as a mix-only remux; mixHash unchanged)
84
85
  gs fonts audit <scene-module> list registered families, formats, and missing-glyph runs (§3.6)
85
86
  gs cache verify <scene-module> [--range a..b] [--sample <n>] assert cache hits == cold renders (§3.5)
86
87
  gs mcp <scene-module> start an MCP stdio server for this scene: describe / list_targets / apply_patch / undo / render_frame (the AI-native write layer)
@@ -184,7 +185,7 @@ async function main() {
184
185
  process.stdout.write(`${describe().version}\n`);
185
186
  return;
186
187
  }
187
- if (command !== "render" && command !== "diff" && command !== "verify-determinism" && command !== "dev" && command !== "import" && command !== "narrate" && command !== "narration-lint" && command !== "sfx" && command !== "prepare" && command !== "measure-loudness" && command !== "fonts" && command !== "cache" && command !== "mcp" && command !== "build" && command !== "describe" && command !== "migrate" && command !== "repin") {
188
+ if (command !== "render" && command !== "diff" && command !== "verify-determinism" && command !== "dev" && command !== "import" && command !== "narrate" && command !== "narration-lint" && command !== "sfx" && command !== "prepare" && command !== "measure-loudness" && command !== "fonts" && command !== "cache" && command !== "mcp" && command !== "build" && command !== "describe" && command !== "migrate" && command !== "repin" && command !== "master") {
188
189
  console.error(USAGE);
189
190
  process.exit(command === void 0 || command === "help" || command === "--help" ? 0 : 1);
190
191
  }
@@ -225,6 +226,22 @@ async function main() {
225
226
  }
226
227
  return;
227
228
  }
229
+ if (command === "master") {
230
+ const { positional: mp } = parseArgs(rest);
231
+ const configPath = mp[0] || "glissade.master.json";
232
+ const { masterCommand } = await import("./master.js").then((n) => n.a);
233
+ try {
234
+ const r = await masterCommand({
235
+ configPath,
236
+ onLog: (line) => process.stderr.write(`${line}\n`)
237
+ });
238
+ process.stderr.write(`${r.report}\n`);
239
+ if (r.members.some((m) => m.overCeiling)) process.exit(1);
240
+ } catch (err) {
241
+ fail(err instanceof Error ? err.message : String(err));
242
+ }
243
+ return;
244
+ }
228
245
  if (command === "cache") {
229
246
  const { positional: cp, flags: cf } = parseArgs(rest);
230
247
  const sub = cp[0];
package/dist/index.d.ts CHANGED
@@ -128,6 +128,200 @@ declare function probeEntryHeader(file: string): {
128
128
  /** Remove the whole `.gscache` dir (a `gs cache clear` convenience / test cleanup). */
129
129
  declare function clearFrameCache(dir: string): void;
130
130
  //#endregion
131
+ //#region src/loudness.d.ts
132
+ /**
133
+ * gs measure-loudness (0.12, DESIGN §5.3): loudness-normalized PUBLISH profiles
134
+ * via a deterministic, peak-clamped scalar GAIN — NOT a render-pipeline change.
135
+ *
136
+ * The key insight: YouTube/Shorts re-normalize loudness platform-side, so the
137
+ * publish target is *≤ target-LUFS AND ≤ -1 dBTP*, not exact. So we never need a
138
+ * two-pass limiter on the render hot path. Instead:
139
+ *
140
+ * 1. `gs measure-loudness` runs ffmpeg's `loudnorm` measurement pass (a
141
+ * print-only ebur128 + true-peak gate) at MEASURE-time over the final
142
+ * mixed PCM, reading `inputI` (integrated LUFS), `inputTp` (true peak
143
+ * dBTP), `inputLra`. This is the ONE non-deterministic stage and it is
144
+ * QUARANTINED to commit-time — §5.3 already concedes mix-to-PCM bytes are
145
+ * per-path only.
146
+ * 2. It commits a `<scene>.loudness.json` carrying the measured numbers, the
147
+ * chosen profile, the resulting `gain` (dB), and a `mixHash` binding the
148
+ * measurement to the mix CONTENT (the narration/music/sfx manifests).
149
+ * 3. `gs render` reads that file and applies `gain` as a PURE scalar
150
+ * `volume=<gain>dB` multiply on the FINAL mix node — a single scalar in the
151
+ * existing filter graph, NOT a new ffmpeg pass. That stage is bit-exact and
152
+ * golden-hashable (a multiply at the same float→Int16 boundary the rest of
153
+ * the audio path shares).
154
+ *
155
+ * The gain is peak-clamped: `gain = min(gainForTargetLUFS, (-1 dBTP) - inputTp)`.
156
+ * The clamp uses the MEASURED true-peak, so the published output is guaranteed
157
+ * ≤ -1 dBTP with no render-time oversampling. (For 0.12 the brickwall true-peak
158
+ * LIMITER is DEFERRED — un-normalized profiles still get the gain plus an
159
+ * advisory warning when a peaky source would have needed brickwalling.)
160
+ */
161
+ declare class LoudnessError extends Error {
162
+ constructor(detail: string);
163
+ }
164
+ /** A publish target: integrated-loudness goal + the true-peak ceiling. */
165
+ interface PublishProfile {
166
+ readonly id: string;
167
+ /** integrated-loudness target, LUFS */
168
+ readonly targetLufs: number;
169
+ /** true-peak ceiling, dBTP (the peak clamp is computed against this) */
170
+ readonly truePeakDb: number;
171
+ /**
172
+ * Whether the platform re-normalizes loudness on its side. For
173
+ * platform-normalized targets (YouTube/Shorts) a peaky source that can't reach
174
+ * `targetLufs` without clipping is fine — the platform finishes the job. For
175
+ * un-normalized targets (podcast/broadcast) it earns an advisory warning,
176
+ * since 0.12 ships no brickwall limiter to recover the headroom.
177
+ */
178
+ readonly platformNormalized: boolean;
179
+ }
180
+ declare const PUBLISH_PROFILES: Readonly<Record<string, PublishProfile>>;
181
+ declare const DEFAULT_PROFILE_ID = "youtube";
182
+ /** Resolve a profile id (case-insensitive) or throw with the valid set. */
183
+ declare function resolveProfile(id: string): PublishProfile;
184
+ declare const LOUDNESS_SCHEMA_VERSION: 1;
185
+ /**
186
+ * The brickwall true-peak limiter a `gs master` pass commits (0.39). Absent on a
187
+ * plain `gs measure-loudness` measurement (that stays a pure peak-clamped scalar
188
+ * gain, byte-identical). Present, render/remux applies `volume=<gain>dB` THEN an
189
+ * `alimiter` holding the true peak at `ceilingDb` — the non-linear stage that lets
190
+ * a peaky member reach the target instead of landing LUs low. Applied in the mix
191
+ * filter graph (deterministic ffmpeg), NOT a render-time per-frame scalar.
192
+ */
193
+ interface CommittedLimiter {
194
+ readonly mode: 'truepeak';
195
+ /** the true-peak ceiling the limiter holds, dBTP. */
196
+ readonly ceilingDb: number;
197
+ }
198
+ /** The committed `<scene>.loudness.json`. */
199
+ interface LoudnessMeasurement {
200
+ loudnessVersion: typeof LOUDNESS_SCHEMA_VERSION;
201
+ /** the resolved publish profile id (youtube/shorts/podcast/broadcast/ebu) */
202
+ profileId: string;
203
+ /** measured integrated loudness, LUFS (ebur128) */
204
+ inputI: number;
205
+ /** measured true peak, dBTP */
206
+ inputTp: number;
207
+ /** measured loudness range, LU */
208
+ inputLra: number;
209
+ /**
210
+ * the deterministic gain to apply at render, in dB.
211
+ * `gain = min(targetLufs - inputI, truePeakDb - inputTp)` — the peak clamp
212
+ * guarantees the published output is ≤ truePeakDb using the MEASURED peak.
213
+ */
214
+ gain: number;
215
+ /**
216
+ * binds this measurement to the mix CONTENT version (a hash of the mix input
217
+ * manifests — narration/music/sfx timing + any wired timeline audio — NOT
218
+ * mtime). Render recomputes it and HARD-THROWS on mismatch so a re-narrate
219
+ * invalidates the measurement loudly instead of silently mis-normalizing.
220
+ */
221
+ mixHash: string;
222
+ /**
223
+ * the true-peak limiter to apply after the gain (0.39 `gs master`). Absent on a
224
+ * plain `gs measure-loudness` measurement (gain-only, byte-identical). When
225
+ * present, `gain` may exceed the raw peak clamp (the limiter shaves the
226
+ * overshoot to `limiter.ceilingDb`).
227
+ */
228
+ limiter?: CommittedLimiter;
229
+ }
230
+ /**
231
+ * The peak-clamped publish gain (dB). `gainForTargetLUFS = target - inputI`
232
+ * raises/lowers to the integrated target; the clamp `truePeak - inputTp` caps it
233
+ * so the result never exceeds the true-peak ceiling. We take the min: a source
234
+ * that can't reach the loudness target without clipping is left below it (the
235
+ * platform re-normalizes the rest for `youtube`/`shorts`).
236
+ */
237
+ declare function computeGainDb(profile: PublishProfile, inputI: number, inputTp: number): number;
238
+ /**
239
+ * True when the peak clamp BOUND the gain — i.e. the source is too peaky to reach
240
+ * the loudness target without exceeding the true-peak ceiling. For an
241
+ * un-normalized profile this is where a brickwall limiter would normally recover
242
+ * the headroom (deferred in 0.12 → advisory warning).
243
+ */
244
+ declare function peakClampBinds(profile: PublishProfile, inputI: number, inputTp: number): boolean;
245
+ /**
246
+ * Hash the CONTENT of the mix's inputs so a re-narrate / re-sfx / music change OR
247
+ * an in-place edit of an audio file invalidates a committed measurement. We hash
248
+ * the files' BYTES (not mtime): the narration/music/sfx timing manifests AND every
249
+ * path in `extraInputs` — the resolved mix AUDIO files (timeline clips + the music
250
+ * stem + narration cache audio), supplied by `collectMixAudioInputs` at both the
251
+ * measure and render call sites so the two agree. Hashing only the timing manifests
252
+ * would miss an edited `.wav`/stem (same manifest, changed bytes) and apply a stale
253
+ * publish gain silently — so `extraInputs` MUST be passed on the gate path. Missing
254
+ * files are recorded by name with a sentinel so adding one later also changes the
255
+ * hash. The scene module path itself is excluded.
256
+ */
257
+ declare function computeMixHash(modulePath: string, extraInputs?: readonly string[]): string;
258
+ /**
259
+ * The committed loudness measurement path for a scene module.
260
+ *
261
+ * Base (no locale): `<stem>.loudness.json` — UNCHANGED. With a locale set (0.15
262
+ * FIX 2): `<stem>.<locale>.loudness.json`. A localized render mixes the per-locale
263
+ * narration (e.g. the zh wavs) → a DIFFERENT mixHash than the base, so it needs its
264
+ * OWN committed measurement; one base file can't gate a localized render (it would
265
+ * always read as a stale mixHash, with no supported way to commit a per-locale one).
266
+ */
267
+ declare function loudnessPathFor(modulePath: string, locale?: string): string;
268
+ /** Read + validate a committed measurement, or null when none is committed. */
269
+ declare function readLoudness(modulePath: string, locale?: string): LoudnessMeasurement | null;
270
+ /**
271
+ * Parse the JSON block ffmpeg's `loudnorm=print_format=json` prints to stderr.
272
+ * It carries `input_i` / `input_tp` / `input_lra` (the measured loudness),
273
+ * alongside the would-be normalization params (which we ignore — we apply our
274
+ * own peak-clamped scalar gain instead).
275
+ */
276
+ declare function parseLoudnormJson(stderr: string): {
277
+ inputI: number;
278
+ inputTp: number;
279
+ inputLra: number;
280
+ };
281
+ /**
282
+ * Run ffmpeg's loudnorm measurement pass over a built mix WAV/PCM file and return
283
+ * the measured loudness. This is the quarantined non-deterministic stage — it
284
+ * runs ONLY at measure-time (commit), never during render.
285
+ */
286
+ declare function measureFile(audioPath: string): {
287
+ inputI: number;
288
+ inputTp: number;
289
+ inputLra: number;
290
+ };
291
+ interface MeasureLoudnessOptions {
292
+ modulePath: string;
293
+ /** publish profile id; default 'youtube' */
294
+ profile?: string;
295
+ /** narration auto-mix toggle (mirrors render); default auto */
296
+ narration?: 'auto' | 'off';
297
+ music?: 'auto' | 'off';
298
+ sfx?: 'auto' | 'off';
299
+ /**
300
+ * locale code (0.15 FIX 2). When set, measure the per-locale mix (the localized
301
+ * narration sibling) and commit `<stem>.<locale>.loudness.json` instead of the
302
+ * base file. Mirrors `gs render --locale`, so the measured content matches what
303
+ * a localized render mixes.
304
+ */
305
+ locale?: string;
306
+ }
307
+ interface MeasureLoudnessResult {
308
+ measurement: LoudnessMeasurement;
309
+ loudnessPath: string;
310
+ /** profile the measurement was taken against */
311
+ profile: PublishProfile;
312
+ /** true when the peak clamp bound the gain (advisory: would need a brickwall limiter on an un-normalized profile) */
313
+ clampBound: boolean;
314
+ /** an advisory warning string when an un-normalized profile can't reach its target without clipping; else null */
315
+ warning: string | null;
316
+ }
317
+ /**
318
+ * Build the final mix to a WAV, run the ffmpeg loudnorm measurement over it, and
319
+ * commit a `<scene>.loudness.json` with the peak-clamped publish gain + a mixHash
320
+ * bound to the mix-input manifests. This is the measure step (commit-time); it is
321
+ * the only place the non-deterministic ebur128/mix-to-PCM stages run.
322
+ */
323
+ declare function measureLoudnessCommand(opts: MeasureLoudnessOptions): Promise<MeasureLoudnessResult>;
324
+ //#endregion
131
325
  //#region src/render.d.ts
132
326
  interface RenderOptions {
133
327
  modulePath: string;
@@ -334,7 +528,10 @@ declare function collectAudioClips(opts: Pick<RenderOptions, 'modulePath' | 'nar
334
528
  * generic stale-mixHash message — there is otherwise no supported way to commit a
335
529
  * per-locale measurement, so a base file would always read as stale and dead-end.
336
530
  */
337
- declare function resolveLoudnessGainDb(opts: Pick<RenderOptions, 'modulePath' | 'loudness' | 'narration' | 'music' | 'sfx' | 'locale'>, timelineClips?: AudioClip[]): Promise<number | null>;
531
+ declare function resolveLoudnessGainDb(opts: Pick<RenderOptions, 'modulePath' | 'loudness' | 'narration' | 'music' | 'sfx' | 'locale'>, timelineClips?: AudioClip[]): Promise<{
532
+ gainDb: number;
533
+ limiter?: CommittedLimiter;
534
+ } | null>;
338
535
  /**
339
536
  * Collect audio clips + auto-mixed siblings and plan the FFmpeg audio graph,
340
537
  * returning the `-i`/`-filter_complex`/`-map` argument fragments. Shared by the
@@ -362,179 +559,125 @@ declare function planFinalAudio(opts: RenderOptions, timelineClips: AudioClip[],
362
559
  */
363
560
  declare function buildMixWav(opts: Pick<RenderOptions, 'modulePath' | 'narration' | 'music' | 'sfx' | 'locale'>, wavOut: string): Promise<boolean>;
364
561
  //#endregion
365
- //#region src/loudness.d.ts
366
- /**
367
- * gs measure-loudness (0.12, DESIGN §5.3): loudness-normalized PUBLISH profiles
368
- * via a deterministic, peak-clamped scalar GAIN — NOT a render-pipeline change.
369
- *
370
- * The key insight: YouTube/Shorts re-normalize loudness platform-side, so the
371
- * publish target is *≤ target-LUFS AND ≤ -1 dBTP*, not exact. So we never need a
372
- * two-pass limiter on the render hot path. Instead:
373
- *
374
- * 1. `gs measure-loudness` runs ffmpeg's `loudnorm` measurement pass (a
375
- * print-only ebur128 + true-peak gate) at MEASURE-time over the final
376
- * mixed PCM, reading `inputI` (integrated LUFS), `inputTp` (true peak
377
- * dBTP), `inputLra`. This is the ONE non-deterministic stage and it is
378
- * QUARANTINED to commit-time — §5.3 already concedes mix-to-PCM bytes are
379
- * per-path only.
380
- * 2. It commits a `<scene>.loudness.json` carrying the measured numbers, the
381
- * chosen profile, the resulting `gain` (dB), and a `mixHash` binding the
382
- * measurement to the mix CONTENT (the narration/music/sfx manifests).
383
- * 3. `gs render` reads that file and applies `gain` as a PURE scalar
384
- * `volume=<gain>dB` multiply on the FINAL mix node — a single scalar in the
385
- * existing filter graph, NOT a new ffmpeg pass. That stage is bit-exact and
386
- * golden-hashable (a multiply at the same float→Int16 boundary the rest of
387
- * the audio path shares).
388
- *
389
- * The gain is peak-clamped: `gain = min(gainForTargetLUFS, (-1 dBTP) - inputTp)`.
390
- * The clamp uses the MEASURED true-peak, so the published output is guaranteed
391
- * ≤ -1 dBTP with no render-time oversampling. (For 0.12 the brickwall true-peak
392
- * LIMITER is DEFERRED — un-normalized profiles still get the gain plus an
393
- * advisory warning when a peaky source would have needed brickwalling.)
394
- */
395
- declare class LoudnessError extends Error {
396
- constructor(detail: string);
562
+ //#region src/master.d.ts
563
+ declare class MasterError extends Error {
564
+ constructor(message: string);
397
565
  }
398
- /** A publish target: integrated-loudness goal + the true-peak ceiling. */
399
- interface PublishProfile {
566
+ /** How much gain-reduction the limiter may apply to buy headroom (dB). */
567
+ declare const DEFAULT_MAX_GR_DB = 6;
568
+ /** The limiter spec (from `glissade.master.json`). */
569
+ interface MasterLimiter {
570
+ /** true-peak brickwall (the only mode today). */
571
+ readonly mode: 'truepeak';
572
+ /** the true-peak ceiling, dBTP (defaults to the profile's `truePeakDb`). */
573
+ readonly ceilingDb?: number;
574
+ /** limiter lookahead, ms (advisory — maps to the ffmpeg attack window). */
575
+ readonly lookaheadMs?: number;
576
+ /** max gain-reduction the limiter may apply (dB); beyond this a member can't
577
+ * reach the target cleanly and the shared target drops. Default 6. */
578
+ readonly maxGrDb?: number;
579
+ }
580
+ /** `glissade.master.json`. */
581
+ interface MasterConfig {
582
+ /** publish profile id (youtube/shorts/podcast/broadcast/ebu). */
583
+ readonly profile?: string;
584
+ /** member scene globs (like `gs build`'s `scenes`). */
585
+ readonly members: readonly string[];
586
+ /** the limiter (or `false` to keep the legacy peak-clamp behaviour). */
587
+ readonly limiter?: MasterLimiter | false;
588
+ /** `shared-target` (all members hit one LUFS) or `per-asset` (each hits its own max). */
589
+ readonly consistency?: 'shared-target' | 'per-asset';
590
+ }
591
+ /** One member's measured input loudness (from the ffmpeg measure pass). */
592
+ interface MemberMeasure {
400
593
  readonly id: string;
401
- /** integrated-loudness target, LUFS */
402
- readonly targetLufs: number;
403
- /** true-peak ceiling, dBTP (the peak clamp is computed against this) */
404
- readonly truePeakDb: number;
405
- /**
406
- * Whether the platform re-normalizes loudness on its side. For
407
- * platform-normalized targets (YouTube/Shorts) a peaky source that can't reach
408
- * `targetLufs` without clipping is fine — the platform finishes the job. For
409
- * un-normalized targets (podcast/broadcast) it earns an advisory warning,
410
- * since 0.12 ships no brickwall limiter to recover the headroom.
411
- */
412
- readonly platformNormalized: boolean;
594
+ /** integrated loudness, LUFS. */
595
+ readonly inputI: number;
596
+ /** true peak, dBTP. */
597
+ readonly inputTp: number;
413
598
  }
414
- declare const PUBLISH_PROFILES: Readonly<Record<string, PublishProfile>>;
415
- declare const DEFAULT_PROFILE_ID = "youtube";
416
- /** Resolve a profile id (case-insensitive) or throw with the valid set. */
417
- declare function resolveProfile(id: string): PublishProfile;
418
- declare const LOUDNESS_SCHEMA_VERSION: 1;
419
- /** The committed `<scene>.loudness.json`. */
420
- interface LoudnessMeasurement {
421
- loudnessVersion: typeof LOUDNESS_SCHEMA_VERSION;
422
- /** the resolved publish profile id (youtube/shorts/podcast/broadcast/ebu) */
423
- profileId: string;
424
- /** measured integrated loudness, LUFS (ebur128) */
425
- inputI: number;
426
- /** measured true peak, dBTP */
427
- inputTp: number;
428
- /** measured loudness range, LU */
429
- inputLra: number;
430
- /**
431
- * the deterministic gain to apply at render, in dB.
432
- * `gain = min(targetLufs - inputI, truePeakDb - inputTp)` the peak clamp
433
- * guarantees the published output is ≤ truePeakDb using the MEASURED peak.
434
- */
435
- gain: number;
436
- /**
437
- * binds this measurement to the mix CONTENT version (a hash of the mix input
438
- * manifests — narration/music/sfx timing + any wired timeline audio — NOT
439
- * mtime). Render recomputes it and HARD-THROWS on mismatch so a re-narrate
440
- * invalidates the measurement loudly instead of silently mis-normalizing.
441
- */
442
- mixHash: string;
599
+ /** The plan for one member: its target + the gain to apply + predicted limiting. */
600
+ interface MemberPlan extends MemberMeasure {
601
+ /** the output LUFS this member is driven to. */
602
+ readonly target: number;
603
+ /** the gain to apply before the limiter, dB. */
604
+ readonly gain: number;
605
+ /** predicted limiter gain-reduction, dB (0 when the raw peak clears the ceiling). */
606
+ readonly grDb: number;
607
+ /** predicted output true peak, dBTP (== ceiling when the limiter engages). */
608
+ readonly predOutTp: number;
609
+ /** true when this member reaches the shared target within the GR budget. */
610
+ readonly reachable: boolean;
611
+ }
612
+ interface MasterPlan {
613
+ /** the LUFS target the set is normalized to (shared-target) or the profile cap. */
614
+ readonly sharedTarget: number;
615
+ readonly profileTarget: number;
616
+ readonly ceilingDb: number;
617
+ /** true when a limiter is in play (false = legacy peak-clamp). */
618
+ readonly limiter: boolean;
619
+ readonly maxGrDb: number;
620
+ readonly members: readonly MemberPlan[];
443
621
  }
444
622
  /**
445
- * The peak-clamped publish gain (dB). `gainForTargetLUFS = target - inputI`
446
- * raises/lowers to the integrated target; the clamp `truePeak - inputTp` caps it
447
- * so the result never exceeds the true-peak ceiling. We take the min: a source
448
- * that can't reach the loudness target without clipping is left below it (the
449
- * platform re-normalizes the rest for `youtube`/`shorts`).
450
- */
451
- declare function computeGainDb(profile: PublishProfile, inputI: number, inputTp: number): number;
452
- /**
453
- * True when the peak clamp BOUND the gain — i.e. the source is too peaky to reach
454
- * the loudness target without exceeding the true-peak ceiling. For an
455
- * un-normalized profile this is where a brickwall limiter would normally recover
456
- * the headroom (deferred in 0.12 → advisory warning).
457
- */
458
- declare function peakClampBinds(profile: PublishProfile, inputI: number, inputTp: number): boolean;
459
- /**
460
- * Hash the CONTENT of the mix's inputs so a re-narrate / re-sfx / music change OR
461
- * an in-place edit of an audio file invalidates a committed measurement. We hash
462
- * the files' BYTES (not mtime): the narration/music/sfx timing manifests AND every
463
- * path in `extraInputs` — the resolved mix AUDIO files (timeline clips + the music
464
- * stem + narration cache audio), supplied by `collectMixAudioInputs` at both the
465
- * measure and render call sites so the two agree. Hashing only the timing manifests
466
- * would miss an edited `.wav`/stem (same manifest, changed bytes) and apply a stale
467
- * publish gain silently — so `extraInputs` MUST be passed on the gate path. Missing
468
- * files are recorded by name with a sentinel so adding one later also changes the
469
- * hash. The scene module path itself is excluded.
470
- */
471
- declare function computeMixHash(modulePath: string, extraInputs?: readonly string[]): string;
472
- /**
473
- * The committed loudness measurement path for a scene module.
623
+ * Plan a master pass. Pure: measured members → shared target + per-member gains.
474
624
  *
475
- * Base (no locale): `<stem>.loudness.json` UNCHANGED. With a locale set (0.15
476
- * FIX 2): `<stem>.<locale>.loudness.json`. A localized render mixes the per-locale
477
- * narration (e.g. the zh wavs) → a DIFFERENT mixHash than the base, so it needs its
478
- * OWN committed measurement; one base file can't gate a localized render (it would
479
- * always read as a stale mixHash, with no supported way to commit a per-locale one).
625
+ * - With a limiter, a member reaches `target` by applying `gain = target - inputI`
626
+ * and the limiter shaves any overshoot (`grDb`) down to the ceiling.
627
+ * - Without a limiter (`limiter: null`), the gain is peak-CLAMPED (the legacy
628
+ * `min(target-inputI, ceiling-inputTp)`), so a peaky member lands below target.
629
+ * - `shared-target` normalizes every member to one LUFS (the loudest all reach);
630
+ * `per-asset` drives each to its own max.
480
631
  */
481
- declare function loudnessPathFor(modulePath: string, locale?: string): string;
482
- /** Read + validate a committed measurement, or null when none is committed. */
483
- declare function readLoudness(modulePath: string, locale?: string): LoudnessMeasurement | null;
632
+ declare function planMaster(measures: readonly MemberMeasure[], profile: PublishProfile, opts: {
633
+ limiter: MasterLimiter | null;
634
+ consistency: 'shared-target' | 'per-asset';
635
+ }): MasterPlan;
484
636
  /**
485
- * Parse the JSON block ffmpeg's `loudnorm=print_format=json` prints to stderr.
486
- * It carries `input_i` / `input_tp` / `input_lra` (the measured loudness),
487
- * alongside the would-be normalization params (which we ignore we apply our
488
- * own peak-clamped scalar gain instead).
637
+ * The `-af` chain that applies a master's gain + (optional) TRUE-peak limiter to a
638
+ * WAV the SAME {@link loudnessFilterNodes} the render `filter_complex` applies,
639
+ * so the `gs master` verify pass measures exactly what a render will produce.
489
640
  */
490
- declare function parseLoudnormJson(stderr: string): {
491
- inputI: number;
492
- inputTp: number;
493
- inputLra: number;
494
- };
495
- /**
496
- * Run ffmpeg's loudnorm measurement pass over a built mix WAV/PCM file and return
497
- * the measured loudness. This is the quarantined non-deterministic stage — it
498
- * runs ONLY at measure-time (commit), never during render.
499
- */
500
- declare function measureFile(audioPath: string): {
501
- inputI: number;
502
- inputTp: number;
503
- inputLra: number;
504
- };
505
- interface MeasureLoudnessOptions {
506
- modulePath: string;
507
- /** publish profile id; default 'youtube' */
508
- profile?: string;
509
- /** narration auto-mix toggle (mirrors render); default auto */
510
- narration?: 'auto' | 'off';
511
- music?: 'auto' | 'off';
512
- sfx?: 'auto' | 'off';
513
- /**
514
- * locale code (0.15 FIX 2). When set, measure the per-locale mix (the localized
515
- * narration sibling) and commit `<stem>.<locale>.loudness.json` instead of the
516
- * base file. Mirrors `gs render --locale`, so the measured content matches what
517
- * a localized render mixes.
518
- */
519
- locale?: string;
641
+ declare function masterAfChain(gainDb: number, limiter: CommittedLimiter | null): Promise<string>;
642
+ interface MasterMemberResult {
643
+ readonly id: string;
644
+ readonly loudnessPath: string;
645
+ readonly measurement: LoudnessMeasurement;
646
+ /** measured input LUFS / dBTP. */
647
+ readonly inputI: number;
648
+ readonly inputTp: number;
649
+ /** VERIFIED output LUFS / dBTP (re-measured after gain+limiter). */
650
+ readonly outI: number;
651
+ readonly outTp: number;
652
+ readonly gain: number;
653
+ readonly grDb: number;
654
+ /** true when the verified output true-peak exceeded the ceiling (shouldn't happen). */
655
+ readonly overCeiling: boolean;
520
656
  }
521
- interface MeasureLoudnessResult {
522
- measurement: LoudnessMeasurement;
523
- loudnessPath: string;
524
- /** profile the measurement was taken against */
525
- profile: PublishProfile;
526
- /** true when the peak clamp bound the gain (advisory: would need a brickwall limiter on an un-normalized profile) */
527
- clampBound: boolean;
528
- /** an advisory warning string when an un-normalized profile can't reach its target without clipping; else null */
529
- warning: string | null;
657
+ interface MasterResult {
658
+ readonly sharedTarget: number;
659
+ readonly ceilingDb: number;
660
+ readonly limiter: boolean;
661
+ readonly members: readonly MasterMemberResult[];
662
+ readonly report: string;
663
+ }
664
+ interface MasterCommandOptions {
665
+ configPath: string;
666
+ onLog?: (line: string) => void;
530
667
  }
531
668
  /**
532
- * Build the final mix to a WAV, run the ffmpeg loudnorm measurement over it, and
533
- * commit a `<scene>.loudness.json` with the peak-clamped publish gain + a mixHash
534
- * bound to the mix-input manifests. This is the measure step (commit-time); it is
535
- * the only place the non-deterministic ebur128/mix-to-PCM stages run.
669
+ * `gs master <glissade.master.json>` measure every member, plan the shared
670
+ * target + limiter, apply+VERIFY per member, and commit `<scene>.loudness.json`
671
+ * ×N. Reuses `buildMixWav`/`measureFile` (measure) and `resolveScenes` (globs);
672
+ * writes the existing sidecar shape + the `limiter` block so render composes it as
673
+ * a mix-only remux under the mixHash preflight.
536
674
  */
537
- declare function measureLoudnessCommand(opts: MeasureLoudnessOptions): Promise<MeasureLoudnessResult>;
675
+ declare function masterCommand(opts: MasterCommandOptions): Promise<MasterResult>;
676
+ /** Validate + normalize a parsed `glissade.master.json` (fail-loud). */
677
+ declare function normalizeMasterConfig(raw: unknown): Required<Pick<MasterConfig, 'members' | 'consistency'>> & {
678
+ profile: string;
679
+ limiter: MasterLimiter | null;
680
+ };
538
681
  //#endregion
539
682
  //#region src/shards.d.ts
540
683
  declare class ShardError extends Error {
@@ -634,14 +777,24 @@ interface AudioMixPlan {
634
777
  hasEasedGain: boolean;
635
778
  }
636
779
  /**
637
- * Append a PURE scalar publish-loudness gain to a mix's `-filter_complex`: the
638
- * graph's final `[aout]` label is renamed and a `volume=<gain>dB` node feeds the
639
- * new `[aout]`. This is a single multiply on the FINAL mix node — NOT a second
640
- * ffmpeg pass and is bit-deterministic (verified) + golden-hashable. A gain of
641
- * exactly 0 dB is a no-op (returned unchanged) so an at-target source preserves
642
- * the prior, un-gained bytes.
780
+ * The publish-loudness filter nodes: a `volume=<gain>dB` multiply, and — for a
781
+ * `gs master` measurement a REAL true-peak limiter (oversample → `alimiter`
782
+ * at `ceilingDb guard` downsample) that actually holds the inter-sample /
783
+ * true peak under `ceilingDb`. Shared by the render `filter_complex` apply and the
784
+ * `gs master` verify pass so the committed limiter and the rendered output are the
785
+ * identical deterministic chain. Empty (a no-op) at 0 dB with no limiter.
643
786
  */
644
- declare function applyMixGainDb(filterComplex: string, gainDb: number): string;
787
+
788
+ /**
789
+ * Append the publish-loudness stage to a mix's `-filter_complex`: the graph's
790
+ * final `[aout]` label is renamed and the {@link loudnessFilterNodes} (gain +,
791
+ * for a master, the true-peak limiter) feed the new `[aout]`. Bit-deterministic +
792
+ * golden-hashable. A 0 dB gain with NO limiter is a no-op (returned unchanged) so
793
+ * an at-target source preserves the prior, un-gained bytes.
794
+ */
795
+ declare function applyMixGainDb(filterComplex: string, gainDb: number, limiter?: {
796
+ readonly ceilingDb: number;
797
+ }): string;
645
798
  /** Build the FFmpeg mix plan for clips that intersect [0, duration]. */
646
799
  declare function planAudioMix(clips: AudioClip[], modulePath: string, duration: number): AudioMixPlan | null;
647
800
  //#endregion
@@ -961,4 +1114,4 @@ declare function cacheVerifyCommand(opts: CacheVerifyOptions): Promise<CacheVeri
961
1114
  /** The @glissade/cli package version (the glissade VERSION for the cache key). */
962
1115
  declare function glissadeVersion(): string;
963
1116
  //#endregion
964
- export { AudioMixError, type AudioMixPlan, type CacheKeyContext, type CacheMode, type CacheVerifyOptions, type CacheVerifyResult, type CaptionProbe, DEFAULT_CACHE_MAX_SIZE, DEFAULT_FPS, DEFAULT_FRAMES, 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, LocaleArgsError, LoudnessError, type LoudnessMeasurement, MachineExportError, type MachineRenderFlags, type MeasureLoudnessOptions, type MeasureLoudnessResult, type NarrationLintOptions, type NarrationLintResult, NoEncoderError, PUBLISH_PROFILES, type PublishProfile, type RenderOptions, type RenderShardedArgs, type RepinFrame, type RepinOptions, type RepinResult, type RepinStatus, 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, localeOutPath, loudnessPathFor, measureFile, measureLoudnessCommand, narrationLintCommand, parseCacheMaxSize, parseEncoderList, parseLocalesList, parseLoudnormJson, peakClampBinds, pickEncoder, planAudioMix, planFinalAudio, probeEntryHeader, probeVideo, readLoudness, render, renderLocales, renderSharded, repinCommand, resolveAssetPath, resolveLoudnessGainDb, resolveProfile, resolveRenderDoc, sceneHasGpuNodes, snapshotAt, splitFrameRange };
1117
+ export { AudioMixError, type AudioMixPlan, type CacheKeyContext, type CacheMode, type CacheVerifyOptions, type CacheVerifyResult, type CaptionProbe, type CommittedLimiter, DEFAULT_CACHE_MAX_SIZE, DEFAULT_FPS, DEFAULT_FRAMES, DEFAULT_MAX_GR_DB, 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, LocaleArgsError, LoudnessError, type LoudnessMeasurement, MachineExportError, type MachineRenderFlags, type MasterConfig, MasterError, type MasterLimiter, type MasterMemberResult, type MasterPlan, type MasterResult, type MeasureLoudnessOptions, type MeasureLoudnessResult, type MemberMeasure, type MemberPlan, type NarrationLintOptions, type NarrationLintResult, NoEncoderError, PUBLISH_PROFILES, type PublishProfile, type RenderOptions, type RenderShardedArgs, type RepinFrame, type RepinOptions, type RepinResult, type RepinStatus, 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, localeOutPath, loudnessPathFor, masterAfChain, masterCommand, measureFile, measureLoudnessCommand, narrationLintCommand, normalizeMasterConfig, parseCacheMaxSize, parseEncoderList, parseLocalesList, parseLoudnormJson, peakClampBinds, pickEncoder, planAudioMix, planFinalAudio, planMaster, probeEntryHeader, probeVideo, readLoudness, render, renderLocales, renderSharded, repinCommand, resolveAssetPath, resolveLoudnessGainDb, resolveProfile, resolveRenderDoc, sceneHasGpuNodes, snapshotAt, splitFrameRange };
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { a as ffmpegAvailable, d as render, f as renderLocales, i as collectAudioClips, l as parseLocalesList, m as resolveLoudnessGainDb, n as SceneModuleError, o as loadSceneModule, r as buildMixWav, s as localeOutPath, t as LocaleArgsError, u as planFinalAudio } from "./render.js";
2
2
  import { t as glissadeVersion } from "./version.js";
3
3
  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";
4
+ import { i as masterCommand, n as MasterError, o as normalizeMasterConfig, r as masterAfChain, s as planMaster, t as DEFAULT_MAX_GR_DB } from "./master.js";
4
5
  import { a as pickEncoder, i as parseEncoderList, n as availableEncoders, t as NoEncoderError } from "./encoders.js";
5
6
  import { a as splitFrameRange, n as renderSharded, r as sceneHasGpuNodes, t as ShardError } from "./shards.js";
6
7
  import { n as VideoProbeError, r as probeVideo, t as FfmpegVideoFrameSource } from "./videoSource.js";
@@ -13,4 +14,4 @@ import { n as DEFAULT_FRAMES, r as repinCommand, t as DEFAULT_FPS } from "./repi
13
14
  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";
14
15
  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";
15
16
  import { t as cacheVerifyCommand } from "./cacheVerify.js";
16
- export { AudioMixError, DEFAULT_CACHE_MAX_SIZE, DEFAULT_FPS, DEFAULT_FRAMES, DEFAULT_PROFILE_ID, FfmpegVideoFrameSource, FrameCache, FrameCacheError, LOUDNESS_SCHEMA_VERSION, LocaleArgsError, 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, localeOutPath, loudnessPathFor, measureFile, measureLoudnessCommand, narrationLintCommand, parseCacheMaxSize, parseEncoderList, parseLocalesList, parseLoudnormJson, peakClampBinds, pickEncoder, planAudioMix, planFinalAudio, probeEntryHeader, probeVideo, readLoudness, render, renderLocales, renderSharded, repinCommand, resolveAssetPath, resolveLoudnessGainDb, resolveProfile, resolveRenderDoc, sceneHasGpuNodes, snapshotAt, splitFrameRange };
17
+ export { AudioMixError, DEFAULT_CACHE_MAX_SIZE, DEFAULT_FPS, DEFAULT_FRAMES, DEFAULT_MAX_GR_DB, DEFAULT_PROFILE_ID, FfmpegVideoFrameSource, FrameCache, FrameCacheError, LOUDNESS_SCHEMA_VERSION, LocaleArgsError, LoudnessError, MachineExportError, MasterError, 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, localeOutPath, loudnessPathFor, masterAfChain, masterCommand, measureFile, measureLoudnessCommand, narrationLintCommand, normalizeMasterConfig, parseCacheMaxSize, parseEncoderList, parseLocalesList, parseLoudnormJson, peakClampBinds, pickEncoder, planAudioMix, planFinalAudio, planMaster, probeEntryHeader, probeVideo, readLoudness, render, renderLocales, renderSharded, repinCommand, resolveAssetPath, resolveLoudnessGainDb, resolveProfile, resolveRenderDoc, sceneHasGpuNodes, snapshotAt, splitFrameRange };
package/dist/master.js ADDED
@@ -0,0 +1,249 @@
1
+ import { t as __exportAll } from "./rolldown-runtime.js";
2
+ import { l as measureFile, m as resolveProfile, o as computeMixHash, s as loudnessPathFor } from "./loudness.js";
3
+ import { spawnSync } from "node:child_process";
4
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ //#region src/master.ts
8
+ /**
9
+ * `gs master` (0.39) — SERIES-level loudness: measure every member together, pick
10
+ * the loudest shared LUFS target the whole set can hit under a shared true-peak
11
+ * ceiling, and SHIP the deferred brickwall true-peak limiter so a peaky short
12
+ * recovers headroom instead of landing 2 LU low. Writes a committed
13
+ * `<scene>.loudness.json` per member (the existing sidecar shape + an optional
14
+ * `limiter` block), so it composes with the render-time mixHash preflight and
15
+ * applies as a mix-only remux (never a re-render).
16
+ *
17
+ * This module holds the PURE planning core (no ffmpeg / no I/O): given each
18
+ * member's measured `inputI`/`inputTp`, it computes the shared target + per-member
19
+ * gain + predicted gain-reduction. The ffmpeg measure/apply shell + the command
20
+ * orchestration live alongside; this core is unit-tested without a toolchain.
21
+ *
22
+ * The limiter knot: a brickwall limiter is non-linear, so it can't be a render-
23
+ * time scalar. It's applied in the AUDIO MIX filter graph (volume→alimiter) from
24
+ * params COMMITTED in the measurement — mixHash covers the mix INPUTS (unchanged),
25
+ * so the committed gain+limiter still validate and render remuxes audio-only.
26
+ */
27
+ var master_exports = /* @__PURE__ */ __exportAll({
28
+ DEFAULT_MAX_GR_DB: () => 6,
29
+ MasterError: () => MasterError,
30
+ masterAfChain: () => masterAfChain,
31
+ masterCommand: () => masterCommand,
32
+ normalizeMasterConfig: () => normalizeMasterConfig,
33
+ planMaster: () => planMaster
34
+ });
35
+ var MasterError = class extends Error {
36
+ constructor(message) {
37
+ super(message);
38
+ this.name = "MasterError";
39
+ }
40
+ };
41
+ /** How much gain-reduction the limiter may apply to buy headroom (dB). */
42
+ const DEFAULT_MAX_GR_DB = 6;
43
+ /** The loudest LUFS this member can reach under the ceiling given the GR budget. */
44
+ function maxReachable(m, ceilingDb, maxGrDb, profileTarget) {
45
+ return Math.min(profileTarget, m.inputI + (ceilingDb - m.inputTp) + maxGrDb);
46
+ }
47
+ /**
48
+ * Plan a master pass. Pure: measured members → shared target + per-member gains.
49
+ *
50
+ * - With a limiter, a member reaches `target` by applying `gain = target - inputI`
51
+ * and the limiter shaves any overshoot (`grDb`) down to the ceiling.
52
+ * - Without a limiter (`limiter: null`), the gain is peak-CLAMPED (the legacy
53
+ * `min(target-inputI, ceiling-inputTp)`), so a peaky member lands below target.
54
+ * - `shared-target` normalizes every member to one LUFS (the loudest all reach);
55
+ * `per-asset` drives each to its own max.
56
+ */
57
+ function planMaster(measures, profile, opts) {
58
+ if (measures.length === 0) throw new MasterError("planMaster needs at least one measured member");
59
+ const ceilingDb = opts.limiter?.ceilingDb ?? profile.truePeakDb;
60
+ const maxGrDb = opts.limiter ? opts.limiter.maxGrDb ?? 6 : 0;
61
+ const profileTarget = profile.targetLufs;
62
+ const reach = measures.map((m) => maxReachable(m, ceilingDb, maxGrDb, profileTarget));
63
+ const sharedTarget = Math.min(profileTarget, ...reach);
64
+ const members = measures.map((m, i) => {
65
+ const target = opts.consistency === "shared-target" ? sharedTarget : reach[i];
66
+ const rawGain = target - m.inputI;
67
+ const gain = opts.limiter ? rawGain : Math.min(rawGain, ceilingDb - m.inputTp);
68
+ const postGainTp = m.inputTp + gain;
69
+ const grDb = opts.limiter ? Math.max(0, postGainTp - ceilingDb) : 0;
70
+ const predOutTp = Math.min(ceilingDb, postGainTp);
71
+ const reachable = opts.limiter ? grDb <= maxGrDb + 1e-6 : rawGain <= ceilingDb - m.inputTp + 1e-6;
72
+ return {
73
+ ...m,
74
+ target,
75
+ gain: round2(gain),
76
+ grDb: round2(grDb),
77
+ predOutTp: round2(predOutTp),
78
+ reachable
79
+ };
80
+ });
81
+ return {
82
+ sharedTarget: round2(sharedTarget),
83
+ profileTarget,
84
+ ceilingDb,
85
+ limiter: opts.limiter !== null,
86
+ maxGrDb,
87
+ members
88
+ };
89
+ }
90
+ function round2(n) {
91
+ return Math.round(n * 100) / 100;
92
+ }
93
+ /**
94
+ * The `-af` chain that applies a master's gain + (optional) TRUE-peak limiter to a
95
+ * WAV — the SAME {@link loudnessFilterNodes} the render `filter_complex` applies,
96
+ * so the `gs master` verify pass measures exactly what a render will produce.
97
+ */
98
+ async function masterAfChain(gainDb, limiter) {
99
+ const { loudnessFilterNodes } = await import("./audioMix.js").then((n) => n.i);
100
+ const nodes = loudnessFilterNodes(gainDb, limiter ?? void 0);
101
+ return nodes.length ? nodes.join(",") : "anull";
102
+ }
103
+ /**
104
+ * `gs master <glissade.master.json>` — measure every member, plan the shared
105
+ * target + limiter, apply+VERIFY per member, and commit `<scene>.loudness.json`
106
+ * ×N. Reuses `buildMixWav`/`measureFile` (measure) and `resolveScenes` (globs);
107
+ * writes the existing sidecar shape + the `limiter` block so render composes it as
108
+ * a mix-only remux under the mixHash preflight.
109
+ */
110
+ async function masterCommand(opts) {
111
+ const cfg = normalizeMasterConfig(JSON.parse(readFileSync(opts.configPath, "utf8")));
112
+ const profile = resolveProfile(cfg.profile);
113
+ const log = opts.onLog ?? (() => {});
114
+ const { resolveScenes } = await import("./build.js").then((n) => n.t);
115
+ const { buildMixWav, collectMixAudioInputs } = await import("./render.js").then((n) => n.p);
116
+ const members = resolveScenes(cfg.members, process.cwd());
117
+ if (members.length === 0) throw new MasterError(`master: no scenes matched members ${JSON.stringify(cfg.members)}`);
118
+ const ceilingDb = cfg.limiter?.ceilingDb ?? profile.truePeakDb;
119
+ const committedLimiter = cfg.limiter ? {
120
+ mode: "truepeak",
121
+ ceilingDb
122
+ } : null;
123
+ const tmp = mkdtempSync(join(tmpdir(), "glissade-master-"));
124
+ try {
125
+ const measured = [];
126
+ for (const modulePath of members) {
127
+ const wavPath = join(tmp, `${measured.length}.wav`);
128
+ if (!await buildMixWav({
129
+ modulePath,
130
+ narration: "auto",
131
+ music: "auto",
132
+ sfx: "auto"
133
+ }, wavPath)) {
134
+ log(` skip ${modulePath} (no audio to master)`);
135
+ continue;
136
+ }
137
+ const m = measureFile(wavPath);
138
+ measured.push({
139
+ id: modulePath,
140
+ modulePath,
141
+ wavPath,
142
+ ...m
143
+ });
144
+ }
145
+ if (measured.length === 0) throw new MasterError("master: no members had any audio to measure");
146
+ const plan = planMaster(measured.map((m) => ({
147
+ id: m.id,
148
+ inputI: m.inputI,
149
+ inputTp: m.inputTp
150
+ })), profile, {
151
+ limiter: cfg.limiter,
152
+ consistency: cfg.consistency
153
+ });
154
+ log(`shared target ${plan.sharedTarget} LUFS, ceiling ${ceilingDb} dBTP${plan.limiter ? "" : " (no limiter — legacy peak-clamp)"}`);
155
+ const results = [];
156
+ for (let i = 0; i < measured.length; i++) {
157
+ const meas = measured[i];
158
+ const p = plan.members[i];
159
+ const outWav = join(tmp, `${i}.out.wav`);
160
+ const af = await masterAfChain(p.gain, committedLimiter);
161
+ const r = spawnSync("ffmpeg", [
162
+ "-hide_banner",
163
+ "-nostats",
164
+ "-y",
165
+ "-i",
166
+ meas.wavPath,
167
+ "-af",
168
+ af,
169
+ "-c:a",
170
+ "pcm_s16le",
171
+ "-ar",
172
+ "48000",
173
+ outWav
174
+ ], { encoding: "utf8" });
175
+ if (r.status !== 0) throw new MasterError(`ffmpeg master apply failed for ${meas.id} (exit ${r.status}):\n${(r.stderr ?? "").slice(-800)}`);
176
+ const out = measureFile(outWav);
177
+ const extraInputs = await collectMixAudioInputs({
178
+ modulePath: meas.modulePath,
179
+ narration: "auto",
180
+ music: "auto",
181
+ sfx: "auto"
182
+ });
183
+ const measurement = {
184
+ loudnessVersion: 1,
185
+ profileId: profile.id,
186
+ inputI: round2(meas.inputI),
187
+ inputTp: round2(meas.inputTp),
188
+ inputLra: round2(meas.inputLra),
189
+ gain: p.gain,
190
+ mixHash: computeMixHash(meas.modulePath, extraInputs),
191
+ ...committedLimiter ? { limiter: committedLimiter } : {}
192
+ };
193
+ const loudnessPath = loudnessPathFor(meas.modulePath);
194
+ writeFileSync(loudnessPath, JSON.stringify(measurement, null, 2) + "\n");
195
+ const overCeiling = round2(out.inputTp) > ceilingDb + .05;
196
+ results.push({
197
+ id: meas.id,
198
+ loudnessPath,
199
+ measurement,
200
+ inputI: round2(meas.inputI),
201
+ inputTp: round2(meas.inputTp),
202
+ outI: round2(out.inputI),
203
+ outTp: round2(out.inputTp),
204
+ gain: p.gain,
205
+ grDb: p.grDb,
206
+ overCeiling
207
+ });
208
+ log(` ${short(meas.id)} in ${round2(meas.inputI)}/${round2(meas.inputTp)}dBTP -> ${p.gain >= 0 ? "+" : ""}${p.gain}dB${p.grDb > 0 ? `,${p.grDb}dB GR` : ""} out ${round2(out.inputI)}/${round2(out.inputTp)}${overCeiling ? " ⚠ OVER CEILING" : ""}`);
209
+ }
210
+ const report = `gs master: ${results.length} member${results.length === 1 ? "" : "s"} → shared target ${plan.sharedTarget} LUFS / ${ceilingDb} dBTP${plan.limiter ? " (true-peak limiter)" : ""}, wrote <scene>.loudness.json ×${results.length}`;
211
+ return {
212
+ sharedTarget: plan.sharedTarget,
213
+ ceilingDb,
214
+ limiter: plan.limiter,
215
+ members: results,
216
+ report
217
+ };
218
+ } finally {
219
+ rmSync(tmp, {
220
+ recursive: true,
221
+ force: true
222
+ });
223
+ }
224
+ }
225
+ function short(p) {
226
+ return p.replace(/^.*\//, "").replace(/\.[jt]sx?$/, "");
227
+ }
228
+ /** Validate + normalize a parsed `glissade.master.json` (fail-loud). */
229
+ function normalizeMasterConfig(raw) {
230
+ if (raw === null || typeof raw !== "object") throw new MasterError("master config must be an object");
231
+ const c = raw;
232
+ if (!Array.isArray(c.members) || c.members.length === 0) throw new MasterError("master config needs a non-empty `members` array of scene globs");
233
+ const consistency = c.consistency ?? "shared-target";
234
+ if (consistency !== "shared-target" && consistency !== "per-asset") throw new MasterError(`master consistency must be 'shared-target' or 'per-asset' (got '${consistency}')`);
235
+ let limiter;
236
+ if (c.limiter === false) limiter = null;
237
+ else if (c.limiter === void 0) limiter = { mode: "truepeak" };
238
+ else if (typeof c.limiter === "object" && c.limiter.mode === "truepeak") limiter = c.limiter;
239
+ else throw new MasterError(`master limiter must be { mode: 'truepeak', ceilingDb?, maxGrDb?, lookaheadMs? } or false`);
240
+ if (limiter && limiter.maxGrDb !== void 0 && !(limiter.maxGrDb >= 0)) throw new MasterError(`master limiter.maxGrDb must be >= 0 (got ${limiter.maxGrDb})`);
241
+ return {
242
+ members: c.members,
243
+ consistency,
244
+ profile: c.profile ?? "youtube",
245
+ limiter
246
+ };
247
+ }
248
+ //#endregion
249
+ export { master_exports as a, masterCommand as i, MasterError as n, normalizeMasterConfig as o, masterAfChain as r, planMaster as s, DEFAULT_MAX_GR_DB as t };
package/dist/render.js CHANGED
@@ -784,7 +784,10 @@ async function resolveLoudnessGainDb(opts, timelineClips) {
784
784
  const reRun = hasLocale ? `gs measure-loudness ${opts.modulePath} --locale ${opts.locale}` : `gs measure-loudness ${opts.modulePath}`;
785
785
  throw new Error(`loudness: ${path} is stale — the mix inputs changed since it was measured (committed mixHash ${measurement.mixHash.slice(0, 23)}…, current ${actual.slice(0, 23)}…). Re-run \`${reRun}\` (or pass --loudness off to render without normalization). Note: 0.33 made the mixHash invocation-path-independent — if your mix inputs did NOT change, one re-measure migrates the committed hash.`);
786
786
  }
787
- return measurement.gain;
787
+ return {
788
+ gainDb: measurement.gain,
789
+ ...measurement.limiter ? { limiter: measurement.limiter } : {}
790
+ };
788
791
  }
789
792
  /**
790
793
  * Collect audio clips + auto-mixed siblings and plan the FFmpeg audio graph,
@@ -811,9 +814,9 @@ async function planFinalAudio(opts, timelineClips, duration, container) {
811
814
  audioInputs: [],
812
815
  audioArgs: []
813
816
  };
814
- const gainDb = await resolveLoudnessGainDb(opts, timelineClips);
815
- const filterComplex = gainDb !== null ? applyMixGainDb(mix.filterComplex, gainDb) : mix.filterComplex;
816
- if (gainDb !== null) process.stderr.write(`note: applying committed publish loudness gain ${gainDb.toFixed(2)} dB (single-pass scalar)\n`);
817
+ const loud = await resolveLoudnessGainDb(opts, timelineClips);
818
+ const filterComplex = loud !== null ? applyMixGainDb(mix.filterComplex, loud.gainDb, loud.limiter) : mix.filterComplex;
819
+ if (loud !== null) process.stderr.write(`note: applying committed publish loudness gain ${loud.gainDb.toFixed(2)} dB${loud.limiter ? ` + true-peak limiter @ ${loud.limiter.ceilingDb} dBTP` : " (single-pass scalar)"}\n`);
817
820
  const audioEnc = pickEncoder("audio", container);
818
821
  return {
819
822
  audioInputs: mix.inputs.flatMap((p) => ["-i", p]),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/cli",
3
- "version": "0.38.0",
3
+ "version": "0.39.0-pre.1",
4
4
  "description": "glissade CLI: headless rendering via backend-skia (+ FFmpeg mux).",
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
@@ -28,15 +28,15 @@
28
28
  "@napi-rs/canvas": "^0.1.65",
29
29
  "esbuild": "0.28.0",
30
30
  "jiti": "^2.4.2",
31
- "@glissade/backend-skia": "0.38.0",
32
- "@glissade/core": "0.38.0",
33
- "@glissade/interact": "0.38.0",
34
- "@glissade/lottie": "0.38.0",
35
- "@glissade/narrate": "0.38.0",
36
- "@glissade/player": "0.38.0",
37
- "@glissade/scene": "0.38.0",
38
- "@glissade/sfx": "0.38.0",
39
- "@glissade/svg": "0.38.0"
31
+ "@glissade/backend-skia": "0.39.0-pre.1",
32
+ "@glissade/core": "0.39.0-pre.1",
33
+ "@glissade/interact": "0.39.0-pre.1",
34
+ "@glissade/lottie": "0.39.0-pre.1",
35
+ "@glissade/narrate": "0.39.0-pre.1",
36
+ "@glissade/player": "0.39.0-pre.1",
37
+ "@glissade/scene": "0.39.0-pre.1",
38
+ "@glissade/sfx": "0.39.0-pre.1",
39
+ "@glissade/svg": "0.39.0-pre.1"
40
40
  },
41
41
  "repository": {
42
42
  "type": "git",