@glissade/cli 0.42.0 → 0.43.0-pre.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/build.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime.js";
2
2
  import { t as glissadeVersion } from "./version.js";
3
+ import { execFileSync } from "node:child_process";
3
4
  import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
4
5
  import { basename, dirname, join, relative, resolve } from "node:path";
5
6
  import { pathToFileURL } from "node:url";
@@ -67,15 +68,19 @@ function planScene(scene, steps, probe) {
67
68
  * ffmpeg; the default `runStep` delegates to the shipped narrate/sfx/loudness/render.
68
69
  */
69
70
  var build_exports = /* @__PURE__ */ __exportAll({
71
+ affectedScenes: () => affectedScenes,
70
72
  applicableSteps: () => applicableSteps,
71
73
  buildCommand: () => buildCommand,
72
74
  defineProject: () => defineProject,
75
+ gitChangedFiles: () => gitChangedFiles,
73
76
  hashInputs: () => hashInputs,
74
77
  loadConfig: () => loadConfig,
75
78
  mixDefaults: () => mixDefaults,
76
79
  outputVideo: () => outputVideo,
77
80
  renderDefaults: () => renderDefaults,
78
81
  resolveScenes: () => resolveScenes,
82
+ sceneInputFiles: () => sceneInputFiles,
83
+ selectAffectedScenes: () => selectAffectedScenes,
79
84
  stepInputs: () => stepInputs,
80
85
  stepOutput: () => stepOutput,
81
86
  stepSalt: () => stepSalt
@@ -213,6 +218,64 @@ function applicableSteps(scene) {
213
218
  steps.push("render");
214
219
  return PIPELINE.filter((s) => steps.includes(s));
215
220
  }
221
+ /** Every input file whose change makes a scene stale — the scene source + every
222
+ * step's inputs (upstream sidecars included), as absolute paths. */
223
+ function sceneInputFiles(scene) {
224
+ const files = new Set([scene]);
225
+ for (const step of applicableSteps(scene)) for (const f of stepInputs(scene, step)) files.add(f);
226
+ return [...files];
227
+ }
228
+ /**
229
+ * Restrict a scene list to those a git diff TOUCHED — a scene is affected when any
230
+ * of its input files (source + sidecars) is in `changedPaths`. Pure: the caller
231
+ * supplies the resolved changed-file set (from `gitChangedFiles`), so the whole
232
+ * selector is unit-testable without git. This is a COARSE pre-filter on top of the
233
+ * per-step content-hash staleness (planScene still hash-checks each selected scene),
234
+ * so it never runs a scene the diff didn't touch, and never skips a real hash change
235
+ * within the ones it keeps. `changedPaths` are absolute.
236
+ */
237
+ function affectedScenes(scenes, changedPaths) {
238
+ return scenes.filter((s) => sceneInputFiles(s).some((f) => changedPaths.has(f)));
239
+ }
240
+ const isCodeFile = (f) => /\.(ts|tsx|js|jsx|mjs|cjs)$/.test(f);
241
+ /**
242
+ * The `--affected` selector with a SAFE-BY-DEFAULT fallback. A scene's staleness is
243
+ * tracked by its own files (source + sidecars), but a scene `.ts` *imports* other
244
+ * modules — and a change to a shared `src/util.ts` (or the config, or any code file
245
+ * not attributable to a scene) affects scenes transitively, invisibly to the
246
+ * file-level diff. Silently narrowing that to nothing would ship stale renders — the
247
+ * exact silent-skip the rest of the system fails loud on. So: if the diff touched a
248
+ * CODE file (`.ts`/`.js`/…) that is NOT any scene's recognized input, we cannot
249
+ * attribute it, so we DON'T narrow — rebuild every scene (the per-step content hash
250
+ * still skips the genuinely-fresh ones). A diff of only non-code files (docs, an
251
+ * unrelated JSON) narrows normally. (Precise import-graph affectedness — rebuild only
252
+ * true dependents — is a follow-up; footgun-free-80% over precise-but-unshipped-100%.)
253
+ */
254
+ function selectAffectedScenes(scenes, changedPaths) {
255
+ const accountedFor = new Set(scenes.flatMap(sceneInputFiles));
256
+ return [...changedPaths].some((f) => isCodeFile(f) && !accountedFor.has(f)) ? [...scenes] : affectedScenes(scenes, changedPaths);
257
+ }
258
+ /** The files changed since a git ref (`git diff --name-only <ref>`), as absolute paths. */
259
+ function gitChangedFiles(ref, root) {
260
+ const top = execFileSync("git", ["rev-parse", "--show-toplevel"], {
261
+ cwd: root,
262
+ encoding: "utf8"
263
+ }).trim();
264
+ const out = execFileSync("git", [
265
+ "diff",
266
+ "--name-only",
267
+ ref
268
+ ], {
269
+ cwd: root,
270
+ encoding: "utf8"
271
+ });
272
+ const files = /* @__PURE__ */ new Set();
273
+ for (const line of out.split("\n")) {
274
+ const rel = line.trim();
275
+ if (rel) files.add(resolve(top, rel));
276
+ }
277
+ return files;
278
+ }
216
279
  function hashInputs(paths, salt) {
217
280
  const h = createHash("sha256").update(salt).update("\0");
218
281
  for (const p of paths) {
@@ -237,49 +300,82 @@ function loadManifest(root) {
237
300
  function saveManifest(root, m) {
238
301
  writeFileSync(manifestPath(root), JSON.stringify(m, null, 2));
239
302
  }
240
- async function buildCommand(opts, deps = { runStep: defaultRunStep }) {
241
- const cfg = await loadConfig(opts.config);
242
- const root = dirname(resolve(opts.config));
243
- let scenes = resolveScenes(cfg.scenes, root);
244
- if (opts.only?.length) scenes = scenes.filter((s) => opts.only.some((o) => s.includes(o)));
245
- const version = glissadeVersion();
246
- const manifest = loadManifest(root);
247
- const log = opts.onLog ?? (() => {});
248
- const allPlans = [];
303
+ /** One pass of the per-scene pipeline (plan run/skip per step, execute the stale ones,
304
+ * record hashes). Shared by the render phase and the post-master remux phase — the
305
+ * same staleness machinery, so the master's committed loudness re-runs exactly the
306
+ * render steps whose `loudness.json` changed. Mutates `manifest` in place. */
307
+ async function runScenePass(scenes, cfg, root, version, manifest, deps, explain, log) {
308
+ const plans = [];
249
309
  let ran = 0;
250
310
  let skipped = 0;
251
311
  for (const scene of scenes) {
252
312
  const key = relative(root, scene);
253
313
  const rec = manifest.scenes[key] ?? {};
254
314
  const videoPath = outputVideo(scene, cfg, root);
255
- const plans = planScene(key, applicableSteps(scene), {
315
+ const scenePlans = planScene(key, applicableSteps(scene), {
256
316
  currentHash: (step) => hashInputs(stepInputs(scene, step), stepSalt(step, cfg, version)),
257
317
  recordedHash: (step) => rec[step],
258
318
  outputExists: (step) => existsSync(stepOutput(scene, step, videoPath))
259
319
  });
260
- for (const plan of plans) {
320
+ for (const plan of scenePlans) {
261
321
  log(`${key} ${plan.step}: ${plan.action} (${plan.reason})`);
262
322
  if (plan.action === "skip") {
263
323
  skipped++;
264
324
  continue;
265
325
  }
266
326
  ran++;
267
- if (!opts.explain) {
327
+ if (!explain) {
268
328
  await deps.runStep(scene, plan.step, cfg, videoPath);
269
329
  rec[plan.step] = hashInputs(stepInputs(scene, plan.step), stepSalt(plan.step, cfg, version));
270
330
  }
271
331
  }
272
332
  manifest.scenes[key] = rec;
273
- allPlans.push(...plans);
333
+ plans.push(...scenePlans);
334
+ }
335
+ return {
336
+ ran,
337
+ skipped,
338
+ plans
339
+ };
340
+ }
341
+ async function buildCommand(opts, deps = { runStep: defaultRunStep }) {
342
+ const cfg = await loadConfig(opts.config);
343
+ const root = dirname(resolve(opts.config));
344
+ const allScenes = resolveScenes(cfg.scenes, root);
345
+ let renderScenes = allScenes;
346
+ if (opts.only?.length) renderScenes = renderScenes.filter((s) => opts.only.some((o) => s.includes(o)));
347
+ if (opts.affected !== void 0) renderScenes = selectAffectedScenes(renderScenes, gitChangedFiles(opts.affected, root));
348
+ const version = glissadeVersion();
349
+ const manifest = loadManifest(root);
350
+ const log = opts.onLog ?? (() => {});
351
+ const p1 = await runScenePass(renderScenes, cfg, root, version, manifest, deps, !!opts.explain, log);
352
+ let ran = p1.ran;
353
+ let skipped = p1.skipped;
354
+ const allPlans = [...p1.plans];
355
+ let mastered = 0;
356
+ if (cfg.master && allScenes.length > 0) if (opts.explain) log(`master: WOULD master ${allScenes.length} member(s) to a shared target, then remux any whose loudness moves`);
357
+ else {
358
+ log("master: shared-target loudness across the project");
359
+ mastered = await (deps.runMaster ?? defaultRunMaster)(allScenes, cfg.master, log);
360
+ const p3 = await runScenePass(allScenes, cfg, root, version, manifest, deps, false, log);
361
+ ran += p3.ran;
362
+ skipped += p3.skipped;
363
+ allPlans.push(...p3.plans);
274
364
  }
275
365
  if (!opts.explain) saveManifest(root, manifest);
276
366
  return {
277
- scenes: scenes.length,
367
+ scenes: renderScenes.length,
278
368
  ran,
279
369
  skipped,
280
- plans: allPlans
370
+ plans: allPlans,
371
+ mastered
281
372
  };
282
373
  }
374
+ /** Default project-master executor — the real shared-target `runMaster`. */
375
+ async function defaultRunMaster(members, opts, log) {
376
+ const { runMaster } = await import("./master.js").then((n) => n.a);
377
+ return (await runMaster(members, opts, log)).members.length;
378
+ }
283
379
  /** Default step executor — the shipped commands. */
284
380
  async function defaultRunStep(scene, step, cfg, videoPath) {
285
381
  switch (step) {
package/dist/cli.js CHANGED
@@ -88,7 +88,7 @@ const USAGE = `usage:
88
88
  gs fonts audit <scene-module> list registered families, formats, and missing-glyph runs (§3.6)
89
89
  gs cache verify <scene-module> [--range a..b] [--sample <n>] assert cache hits == cold renders (§3.5)
90
90
  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)
91
- gs build [filter...] [--config <glissade.config.ts>] [--explain] content-graph DAG runner: narrate→sfx→loudness→render per scene, runs ONLY the stale subtree
91
+ gs build [filter...] [--config <glissade.config.ts>] [--affected <git-ref>] [--explain] content-graph DAG runner: narrate→sfx→loudness→render per scene, runs ONLY the stale subtree. --affected <ref> pre-filters to scenes a git diff since <ref> touched (rebuild only what a change set touched; composed with the per-step content-hash staleness)
92
92
  gs describe [--out <api.json>] [--examples] snapshot THIS engine's describe() API manifest (stdout, or --out to a file) — the input to gs migrate
93
93
  gs migrate <baseline-api.json> [--json] [--check] diff a saved API manifest against the current engine: moved imports / removed / added / changed, with a suggested fix per breaking item (advisory; --check exits non-zero on any breaking change for CI gating)
94
94
  gs repin <scene-module> --golden <dir> [--name <p>] [--frames a,b,..] [--fps <n>] [--since <ref>] [--write] [--only a,b] [--heatmap <dir>] [--floor <ssim>] [--force] narration-aware golden reviewer: render current vs committed goldens, report perceptual delta + the re-narration cause, re-pin only frames you allow (default dry-run; --floor refuses a bigger-than-expected drop)
@@ -227,6 +227,7 @@ async function main() {
227
227
  config,
228
228
  explain,
229
229
  ...bp.length ? { only: bp } : {},
230
+ ...bf.get("affected") ? { affected: bf.get("affected") } : {},
230
231
  onLog: (line) => process.stderr.write(`${line}\n`)
231
232
  });
232
233
  process.stderr.write(`gs build: ${r.ran} step${r.ran === 1 ? "" : "s"} ${explain ? "WOULD run" : "ran"}, ${r.skipped} fresh, across ${r.scenes} scene${r.scenes === 1 ? "" : "s"}${explain ? " (--explain: nothing executed)" : ""}\n`);
package/dist/config.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { c as MasterRunOptions } from "./master.js";
2
+
1
3
  //#region src/build.d.ts
2
4
 
3
5
  interface ProjectConfig {
@@ -5,6 +7,14 @@ interface ProjectConfig {
5
7
  scenes: string[];
6
8
  /** output dir for rendered videos (default: alongside each scene as `<base>.mp4`). */
7
9
  out?: string;
10
+ /**
11
+ * 0.43 project runtime: master the WHOLE project to a SHARED loudness target after
12
+ * rendering. When set, `gs build` runs a second, cross-scene phase — render all →
13
+ * BARRIER → master (one shared LUFS target + limiter across every member) → the
14
+ * render staleness (a changed `<scene>.loudness.json`) remuxes exactly the members
15
+ * whose gain moved. Absent → the classic per-scene pipeline, unchanged.
16
+ */
17
+ master?: MasterRunOptions;
8
18
  /** per-scene render/cache defaults. */
9
19
  defaults?: {
10
20
  fps?: number;
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { A as peakClampBinds, C as PublishProfile, D as measureFile, E as loudnessPathFor, M as resolveProfile, O as measureLoudnessCommand, S as PUBLISH_PROFILES, T as computeMixHash, _ as LOUDNESS_SCHEMA_VERSION, a as MasterMemberResult, b as MeasureLoudnessOptions, d as masterAfChain, f as masterCommand, g as DEFAULT_PROFILE_ID, h as CommittedLimiter, i as MasterLimiter, j as readLoudness, k as parseLoudnormJson, l as MemberMeasure, m as planMaster, n as MasterConfig, o as MasterPlan, p as normalizeMasterConfig, r as MasterError, s as MasterResult, t as DEFAULT_MAX_GR_DB, u as MemberPlan, v as LoudnessError, w as computeGainDb, x as MeasureLoudnessResult, y as LoudnessMeasurement } from "./master.js";
1
2
  import { AudioClip, CompiledTimeline, Key, Timeline } from "@glissade/core";
2
3
  import { DisplayList, Scene, SceneModule, VideoFrameSource } from "@glissade/scene";
3
4
  import { NarrationTiming } from "@glissade/narrate";
@@ -129,200 +130,6 @@ declare function probeEntryHeader(file: string): {
129
130
  /** Remove the whole `.gscache` dir (a `gs cache clear` convenience / test cleanup). */
130
131
  declare function clearFrameCache(dir: string): void;
131
132
  //#endregion
132
- //#region src/loudness.d.ts
133
- /**
134
- * gs measure-loudness (0.12, DESIGN §5.3): loudness-normalized PUBLISH profiles
135
- * via a deterministic, peak-clamped scalar GAIN — NOT a render-pipeline change.
136
- *
137
- * The key insight: YouTube/Shorts re-normalize loudness platform-side, so the
138
- * publish target is *≤ target-LUFS AND ≤ -1 dBTP*, not exact. So we never need a
139
- * two-pass limiter on the render hot path. Instead:
140
- *
141
- * 1. `gs measure-loudness` runs ffmpeg's `loudnorm` measurement pass (a
142
- * print-only ebur128 + true-peak gate) at MEASURE-time over the final
143
- * mixed PCM, reading `inputI` (integrated LUFS), `inputTp` (true peak
144
- * dBTP), `inputLra`. This is the ONE non-deterministic stage and it is
145
- * QUARANTINED to commit-time — §5.3 already concedes mix-to-PCM bytes are
146
- * per-path only.
147
- * 2. It commits a `<scene>.loudness.json` carrying the measured numbers, the
148
- * chosen profile, the resulting `gain` (dB), and a `mixHash` binding the
149
- * measurement to the mix CONTENT (the narration/music/sfx manifests).
150
- * 3. `gs render` reads that file and applies `gain` as a PURE scalar
151
- * `volume=<gain>dB` multiply on the FINAL mix node — a single scalar in the
152
- * existing filter graph, NOT a new ffmpeg pass. That stage is bit-exact and
153
- * golden-hashable (a multiply at the same float→Int16 boundary the rest of
154
- * the audio path shares).
155
- *
156
- * The gain is peak-clamped: `gain = min(gainForTargetLUFS, (-1 dBTP) - inputTp)`.
157
- * The clamp uses the MEASURED true-peak, so the published output is guaranteed
158
- * ≤ -1 dBTP with no render-time oversampling. (For 0.12 the brickwall true-peak
159
- * LIMITER is DEFERRED — un-normalized profiles still get the gain plus an
160
- * advisory warning when a peaky source would have needed brickwalling.)
161
- */
162
- declare class LoudnessError extends Error {
163
- constructor(detail: string);
164
- }
165
- /** A publish target: integrated-loudness goal + the true-peak ceiling. */
166
- interface PublishProfile {
167
- readonly id: string;
168
- /** integrated-loudness target, LUFS */
169
- readonly targetLufs: number;
170
- /** true-peak ceiling, dBTP (the peak clamp is computed against this) */
171
- readonly truePeakDb: number;
172
- /**
173
- * Whether the platform re-normalizes loudness on its side. For
174
- * platform-normalized targets (YouTube/Shorts) a peaky source that can't reach
175
- * `targetLufs` without clipping is fine — the platform finishes the job. For
176
- * un-normalized targets (podcast/broadcast) it earns an advisory warning,
177
- * since 0.12 ships no brickwall limiter to recover the headroom.
178
- */
179
- readonly platformNormalized: boolean;
180
- }
181
- declare const PUBLISH_PROFILES: Readonly<Record<string, PublishProfile>>;
182
- declare const DEFAULT_PROFILE_ID = "youtube";
183
- /** Resolve a profile id (case-insensitive) or throw with the valid set. */
184
- declare function resolveProfile(id: string): PublishProfile;
185
- declare const LOUDNESS_SCHEMA_VERSION: 1;
186
- /**
187
- * The brickwall true-peak limiter a `gs master` pass commits (0.39). Absent on a
188
- * plain `gs measure-loudness` measurement (that stays a pure peak-clamped scalar
189
- * gain, byte-identical). Present, render/remux applies `volume=<gain>dB` THEN an
190
- * `alimiter` holding the true peak at `ceilingDb` — the non-linear stage that lets
191
- * a peaky member reach the target instead of landing LUs low. Applied in the mix
192
- * filter graph (deterministic ffmpeg), NOT a render-time per-frame scalar.
193
- */
194
- interface CommittedLimiter {
195
- readonly mode: 'truepeak';
196
- /** the true-peak ceiling the limiter holds, dBTP. */
197
- readonly ceilingDb: number;
198
- }
199
- /** The committed `<scene>.loudness.json`. */
200
- interface LoudnessMeasurement {
201
- loudnessVersion: typeof LOUDNESS_SCHEMA_VERSION;
202
- /** the resolved publish profile id (youtube/shorts/podcast/broadcast/ebu) */
203
- profileId: string;
204
- /** measured integrated loudness, LUFS (ebur128) */
205
- inputI: number;
206
- /** measured true peak, dBTP */
207
- inputTp: number;
208
- /** measured loudness range, LU */
209
- inputLra: number;
210
- /**
211
- * the deterministic gain to apply at render, in dB.
212
- * `gain = min(targetLufs - inputI, truePeakDb - inputTp)` — the peak clamp
213
- * guarantees the published output is ≤ truePeakDb using the MEASURED peak.
214
- */
215
- gain: number;
216
- /**
217
- * binds this measurement to the mix CONTENT version (a hash of the mix input
218
- * manifests — narration/music/sfx timing + any wired timeline audio — NOT
219
- * mtime). Render recomputes it and HARD-THROWS on mismatch so a re-narrate
220
- * invalidates the measurement loudly instead of silently mis-normalizing.
221
- */
222
- mixHash: string;
223
- /**
224
- * the true-peak limiter to apply after the gain (0.39 `gs master`). Absent on a
225
- * plain `gs measure-loudness` measurement (gain-only, byte-identical). When
226
- * present, `gain` may exceed the raw peak clamp (the limiter shaves the
227
- * overshoot to `limiter.ceilingDb`).
228
- */
229
- limiter?: CommittedLimiter;
230
- }
231
- /**
232
- * The peak-clamped publish gain (dB). `gainForTargetLUFS = target - inputI`
233
- * raises/lowers to the integrated target; the clamp `truePeak - inputTp` caps it
234
- * so the result never exceeds the true-peak ceiling. We take the min: a source
235
- * that can't reach the loudness target without clipping is left below it (the
236
- * platform re-normalizes the rest for `youtube`/`shorts`).
237
- */
238
- declare function computeGainDb(profile: PublishProfile, inputI: number, inputTp: number): number;
239
- /**
240
- * True when the peak clamp BOUND the gain — i.e. the source is too peaky to reach
241
- * the loudness target without exceeding the true-peak ceiling. For an
242
- * un-normalized profile this is where a brickwall limiter would normally recover
243
- * the headroom (deferred in 0.12 → advisory warning).
244
- */
245
- declare function peakClampBinds(profile: PublishProfile, inputI: number, inputTp: number): boolean;
246
- /**
247
- * Hash the CONTENT of the mix's inputs so a re-narrate / re-sfx / music change OR
248
- * an in-place edit of an audio file invalidates a committed measurement. We hash
249
- * the files' BYTES (not mtime): the narration/music/sfx timing manifests AND every
250
- * path in `extraInputs` — the resolved mix AUDIO files (timeline clips + the music
251
- * stem + narration cache audio), supplied by `collectMixAudioInputs` at both the
252
- * measure and render call sites so the two agree. Hashing only the timing manifests
253
- * would miss an edited `.wav`/stem (same manifest, changed bytes) and apply a stale
254
- * publish gain silently — so `extraInputs` MUST be passed on the gate path. Missing
255
- * files are recorded by name with a sentinel so adding one later also changes the
256
- * hash. The scene module path itself is excluded.
257
- */
258
- declare function computeMixHash(modulePath: string, extraInputs?: readonly string[]): string;
259
- /**
260
- * The committed loudness measurement path for a scene module.
261
- *
262
- * Base (no locale): `<stem>.loudness.json` — UNCHANGED. With a locale set (0.15
263
- * FIX 2): `<stem>.<locale>.loudness.json`. A localized render mixes the per-locale
264
- * narration (e.g. the zh wavs) → a DIFFERENT mixHash than the base, so it needs its
265
- * OWN committed measurement; one base file can't gate a localized render (it would
266
- * always read as a stale mixHash, with no supported way to commit a per-locale one).
267
- */
268
- declare function loudnessPathFor(modulePath: string, locale?: string): string;
269
- /** Read + validate a committed measurement, or null when none is committed. */
270
- declare function readLoudness(modulePath: string, locale?: string): LoudnessMeasurement | null;
271
- /**
272
- * Parse the JSON block ffmpeg's `loudnorm=print_format=json` prints to stderr.
273
- * It carries `input_i` / `input_tp` / `input_lra` (the measured loudness),
274
- * alongside the would-be normalization params (which we ignore — we apply our
275
- * own peak-clamped scalar gain instead).
276
- */
277
- declare function parseLoudnormJson(stderr: string): {
278
- inputI: number;
279
- inputTp: number;
280
- inputLra: number;
281
- };
282
- /**
283
- * Run ffmpeg's loudnorm measurement pass over a built mix WAV/PCM file and return
284
- * the measured loudness. This is the quarantined non-deterministic stage — it
285
- * runs ONLY at measure-time (commit), never during render.
286
- */
287
- declare function measureFile(audioPath: string): {
288
- inputI: number;
289
- inputTp: number;
290
- inputLra: number;
291
- };
292
- interface MeasureLoudnessOptions {
293
- modulePath: string;
294
- /** publish profile id; default 'youtube' */
295
- profile?: string;
296
- /** narration auto-mix toggle (mirrors render); default auto */
297
- narration?: 'auto' | 'off';
298
- music?: 'auto' | 'off';
299
- sfx?: 'auto' | 'off';
300
- /**
301
- * locale code (0.15 FIX 2). When set, measure the per-locale mix (the localized
302
- * narration sibling) and commit `<stem>.<locale>.loudness.json` instead of the
303
- * base file. Mirrors `gs render --locale`, so the measured content matches what
304
- * a localized render mixes.
305
- */
306
- locale?: string;
307
- }
308
- interface MeasureLoudnessResult {
309
- measurement: LoudnessMeasurement;
310
- loudnessPath: string;
311
- /** profile the measurement was taken against */
312
- profile: PublishProfile;
313
- /** true when the peak clamp bound the gain (advisory: would need a brickwall limiter on an un-normalized profile) */
314
- clampBound: boolean;
315
- /** an advisory warning string when an un-normalized profile can't reach its target without clipping; else null */
316
- warning: string | null;
317
- }
318
- /**
319
- * Build the final mix to a WAV, run the ffmpeg loudnorm measurement over it, and
320
- * commit a `<scene>.loudness.json` with the peak-clamped publish gain + a mixHash
321
- * bound to the mix-input manifests. This is the measure step (commit-time); it is
322
- * the only place the non-deterministic ebur128/mix-to-PCM stages run.
323
- */
324
- declare function measureLoudnessCommand(opts: MeasureLoudnessOptions): Promise<MeasureLoudnessResult>;
325
- //#endregion
326
133
  //#region src/render.d.ts
327
134
  interface RenderOptions {
328
135
  modulePath: string;
@@ -571,126 +378,6 @@ declare function planFinalAudio(opts: RenderOptions, timelineClips: AudioClip[],
571
378
  */
572
379
  declare function buildMixWav(opts: Pick<RenderOptions, 'modulePath' | 'narration' | 'music' | 'sfx' | 'locale'>, wavOut: string): Promise<boolean>;
573
380
  //#endregion
574
- //#region src/master.d.ts
575
- declare class MasterError extends Error {
576
- constructor(message: string);
577
- }
578
- /** How much gain-reduction the limiter may apply to buy headroom (dB). */
579
- declare const DEFAULT_MAX_GR_DB = 6;
580
- /** The limiter spec (from `glissade.master.json`). */
581
- interface MasterLimiter {
582
- /** true-peak brickwall (the only mode today). */
583
- readonly mode: 'truepeak';
584
- /** the true-peak ceiling, dBTP (defaults to the profile's `truePeakDb`). */
585
- readonly ceilingDb?: number;
586
- /** limiter lookahead, ms (advisory — maps to the ffmpeg attack window). */
587
- readonly lookaheadMs?: number;
588
- /** max gain-reduction the limiter may apply (dB); beyond this a member can't
589
- * reach the target cleanly and the shared target drops. Default 6. */
590
- readonly maxGrDb?: number;
591
- }
592
- /** `glissade.master.json`. */
593
- interface MasterConfig {
594
- /** publish profile id (youtube/shorts/podcast/broadcast/ebu). */
595
- readonly profile?: string;
596
- /** member scene globs (like `gs build`'s `scenes`). */
597
- readonly members: readonly string[];
598
- /** the limiter (or `false` to keep the legacy peak-clamp behaviour). */
599
- readonly limiter?: MasterLimiter | false;
600
- /** `shared-target` (all members hit one LUFS) or `per-asset` (each hits its own max). */
601
- readonly consistency?: 'shared-target' | 'per-asset';
602
- }
603
- /** One member's measured input loudness (from the ffmpeg measure pass). */
604
- interface MemberMeasure {
605
- readonly id: string;
606
- /** integrated loudness, LUFS. */
607
- readonly inputI: number;
608
- /** true peak, dBTP. */
609
- readonly inputTp: number;
610
- }
611
- /** The plan for one member: its target + the gain to apply + predicted limiting. */
612
- interface MemberPlan extends MemberMeasure {
613
- /** the output LUFS this member is driven to. */
614
- readonly target: number;
615
- /** the gain to apply before the limiter, dB. */
616
- readonly gain: number;
617
- /** predicted limiter gain-reduction, dB (0 when the raw peak clears the ceiling). */
618
- readonly grDb: number;
619
- /** predicted output true peak, dBTP (== ceiling when the limiter engages). */
620
- readonly predOutTp: number;
621
- /** true when this member reaches the shared target within the GR budget. */
622
- readonly reachable: boolean;
623
- }
624
- interface MasterPlan {
625
- /** the LUFS target the set is normalized to (shared-target) or the profile cap. */
626
- readonly sharedTarget: number;
627
- readonly profileTarget: number;
628
- readonly ceilingDb: number;
629
- /** true when a limiter is in play (false = legacy peak-clamp). */
630
- readonly limiter: boolean;
631
- readonly maxGrDb: number;
632
- readonly members: readonly MemberPlan[];
633
- }
634
- /**
635
- * Plan a master pass. Pure: measured members → shared target + per-member gains.
636
- *
637
- * - With a limiter, a member reaches `target` by applying `gain = target - inputI`
638
- * and the limiter shaves any overshoot (`grDb`) down to the ceiling.
639
- * - Without a limiter (`limiter: null`), the gain is peak-CLAMPED (the legacy
640
- * `min(target-inputI, ceiling-inputTp)`), so a peaky member lands below target.
641
- * - `shared-target` normalizes every member to one LUFS (the loudest all reach);
642
- * `per-asset` drives each to its own max.
643
- */
644
- declare function planMaster(measures: readonly MemberMeasure[], profile: PublishProfile, opts: {
645
- limiter: MasterLimiter | null;
646
- consistency: 'shared-target' | 'per-asset';
647
- }): MasterPlan;
648
- /**
649
- * The `-af` chain that applies a master's gain + (optional) TRUE-peak limiter to a
650
- * WAV — the SAME {@link loudnessFilterNodes} the render `filter_complex` applies,
651
- * so the `gs master` verify pass measures exactly what a render will produce.
652
- */
653
- declare function masterAfChain(gainDb: number, limiter: CommittedLimiter | null): Promise<string>;
654
- interface MasterMemberResult {
655
- readonly id: string;
656
- readonly loudnessPath: string;
657
- readonly measurement: LoudnessMeasurement;
658
- /** measured input LUFS / dBTP. */
659
- readonly inputI: number;
660
- readonly inputTp: number;
661
- /** VERIFIED output LUFS / dBTP (re-measured after gain+limiter). */
662
- readonly outI: number;
663
- readonly outTp: number;
664
- readonly gain: number;
665
- readonly grDb: number;
666
- /** true when the verified output true-peak exceeded the ceiling (shouldn't happen). */
667
- readonly overCeiling: boolean;
668
- }
669
- interface MasterResult {
670
- readonly sharedTarget: number;
671
- readonly ceilingDb: number;
672
- readonly limiter: boolean;
673
- readonly members: readonly MasterMemberResult[];
674
- readonly report: string;
675
- }
676
- interface MasterCommandOptions {
677
- configPath: string;
678
- onLog?: (line: string) => void;
679
- }
680
- /**
681
- * `gs master <glissade.master.json>` — measure every member, plan the shared
682
- * target + limiter, apply+VERIFY per member, and commit `<scene>.loudness.json`
683
- * ×N. Reuses `buildMixWav`/`measureFile` (measure) and `resolveScenes` (globs);
684
- * writes the existing sidecar shape + the `limiter` block so render composes it as
685
- * a mix-only remux under the mixHash preflight.
686
- */
687
- declare function masterCommand(opts: MasterCommandOptions): Promise<MasterResult>;
688
- /** Validate + normalize a parsed `glissade.master.json` (fail-loud). */
689
- declare function normalizeMasterConfig(raw: unknown): Required<Pick<MasterConfig, 'members' | 'consistency'>> & {
690
- profile: string;
691
- limiter: MasterLimiter | null;
692
- };
693
- //#endregion
694
381
  //#region src/shards.d.ts
695
382
  declare class ShardError extends Error {
696
383
  constructor(message: string);
@@ -0,0 +1,330 @@
1
+ //#region src/loudness.d.ts
2
+ /**
3
+ * gs measure-loudness (0.12, DESIGN §5.3): loudness-normalized PUBLISH profiles
4
+ * via a deterministic, peak-clamped scalar GAIN — NOT a render-pipeline change.
5
+ *
6
+ * The key insight: YouTube/Shorts re-normalize loudness platform-side, so the
7
+ * publish target is *≤ target-LUFS AND ≤ -1 dBTP*, not exact. So we never need a
8
+ * two-pass limiter on the render hot path. Instead:
9
+ *
10
+ * 1. `gs measure-loudness` runs ffmpeg's `loudnorm` measurement pass (a
11
+ * print-only ebur128 + true-peak gate) at MEASURE-time over the final
12
+ * mixed PCM, reading `inputI` (integrated LUFS), `inputTp` (true peak
13
+ * dBTP), `inputLra`. This is the ONE non-deterministic stage and it is
14
+ * QUARANTINED to commit-time — §5.3 already concedes mix-to-PCM bytes are
15
+ * per-path only.
16
+ * 2. It commits a `<scene>.loudness.json` carrying the measured numbers, the
17
+ * chosen profile, the resulting `gain` (dB), and a `mixHash` binding the
18
+ * measurement to the mix CONTENT (the narration/music/sfx manifests).
19
+ * 3. `gs render` reads that file and applies `gain` as a PURE scalar
20
+ * `volume=<gain>dB` multiply on the FINAL mix node — a single scalar in the
21
+ * existing filter graph, NOT a new ffmpeg pass. That stage is bit-exact and
22
+ * golden-hashable (a multiply at the same float→Int16 boundary the rest of
23
+ * the audio path shares).
24
+ *
25
+ * The gain is peak-clamped: `gain = min(gainForTargetLUFS, (-1 dBTP) - inputTp)`.
26
+ * The clamp uses the MEASURED true-peak, so the published output is guaranteed
27
+ * ≤ -1 dBTP with no render-time oversampling. (For 0.12 the brickwall true-peak
28
+ * LIMITER is DEFERRED — un-normalized profiles still get the gain plus an
29
+ * advisory warning when a peaky source would have needed brickwalling.)
30
+ */
31
+ declare class LoudnessError extends Error {
32
+ constructor(detail: string);
33
+ }
34
+ /** A publish target: integrated-loudness goal + the true-peak ceiling. */
35
+ interface PublishProfile {
36
+ readonly id: string;
37
+ /** integrated-loudness target, LUFS */
38
+ readonly targetLufs: number;
39
+ /** true-peak ceiling, dBTP (the peak clamp is computed against this) */
40
+ readonly truePeakDb: number;
41
+ /**
42
+ * Whether the platform re-normalizes loudness on its side. For
43
+ * platform-normalized targets (YouTube/Shorts) a peaky source that can't reach
44
+ * `targetLufs` without clipping is fine — the platform finishes the job. For
45
+ * un-normalized targets (podcast/broadcast) it earns an advisory warning,
46
+ * since 0.12 ships no brickwall limiter to recover the headroom.
47
+ */
48
+ readonly platformNormalized: boolean;
49
+ }
50
+ declare const PUBLISH_PROFILES: Readonly<Record<string, PublishProfile>>;
51
+ declare const DEFAULT_PROFILE_ID = "youtube";
52
+ /** Resolve a profile id (case-insensitive) or throw with the valid set. */
53
+ declare function resolveProfile(id: string): PublishProfile;
54
+ declare const LOUDNESS_SCHEMA_VERSION: 1;
55
+ /**
56
+ * The brickwall true-peak limiter a `gs master` pass commits (0.39). Absent on a
57
+ * plain `gs measure-loudness` measurement (that stays a pure peak-clamped scalar
58
+ * gain, byte-identical). Present, render/remux applies `volume=<gain>dB` THEN an
59
+ * `alimiter` holding the true peak at `ceilingDb` — the non-linear stage that lets
60
+ * a peaky member reach the target instead of landing LUs low. Applied in the mix
61
+ * filter graph (deterministic ffmpeg), NOT a render-time per-frame scalar.
62
+ */
63
+ interface CommittedLimiter {
64
+ readonly mode: 'truepeak';
65
+ /** the true-peak ceiling the limiter holds, dBTP. */
66
+ readonly ceilingDb: number;
67
+ }
68
+ /** The committed `<scene>.loudness.json`. */
69
+ interface LoudnessMeasurement {
70
+ loudnessVersion: typeof LOUDNESS_SCHEMA_VERSION;
71
+ /** the resolved publish profile id (youtube/shorts/podcast/broadcast/ebu) */
72
+ profileId: string;
73
+ /** measured integrated loudness, LUFS (ebur128) */
74
+ inputI: number;
75
+ /** measured true peak, dBTP */
76
+ inputTp: number;
77
+ /** measured loudness range, LU */
78
+ inputLra: number;
79
+ /**
80
+ * the deterministic gain to apply at render, in dB.
81
+ * `gain = min(targetLufs - inputI, truePeakDb - inputTp)` — the peak clamp
82
+ * guarantees the published output is ≤ truePeakDb using the MEASURED peak.
83
+ */
84
+ gain: number;
85
+ /**
86
+ * binds this measurement to the mix CONTENT version (a hash of the mix input
87
+ * manifests — narration/music/sfx timing + any wired timeline audio — NOT
88
+ * mtime). Render recomputes it and HARD-THROWS on mismatch so a re-narrate
89
+ * invalidates the measurement loudly instead of silently mis-normalizing.
90
+ */
91
+ mixHash: string;
92
+ /**
93
+ * the true-peak limiter to apply after the gain (0.39 `gs master`). Absent on a
94
+ * plain `gs measure-loudness` measurement (gain-only, byte-identical). When
95
+ * present, `gain` may exceed the raw peak clamp (the limiter shaves the
96
+ * overshoot to `limiter.ceilingDb`).
97
+ */
98
+ limiter?: CommittedLimiter;
99
+ }
100
+ /**
101
+ * The peak-clamped publish gain (dB). `gainForTargetLUFS = target - inputI`
102
+ * raises/lowers to the integrated target; the clamp `truePeak - inputTp` caps it
103
+ * so the result never exceeds the true-peak ceiling. We take the min: a source
104
+ * that can't reach the loudness target without clipping is left below it (the
105
+ * platform re-normalizes the rest for `youtube`/`shorts`).
106
+ */
107
+ declare function computeGainDb(profile: PublishProfile, inputI: number, inputTp: number): number;
108
+ /**
109
+ * True when the peak clamp BOUND the gain — i.e. the source is too peaky to reach
110
+ * the loudness target without exceeding the true-peak ceiling. For an
111
+ * un-normalized profile this is where a brickwall limiter would normally recover
112
+ * the headroom (deferred in 0.12 → advisory warning).
113
+ */
114
+ declare function peakClampBinds(profile: PublishProfile, inputI: number, inputTp: number): boolean;
115
+ /**
116
+ * Hash the CONTENT of the mix's inputs so a re-narrate / re-sfx / music change OR
117
+ * an in-place edit of an audio file invalidates a committed measurement. We hash
118
+ * the files' BYTES (not mtime): the narration/music/sfx timing manifests AND every
119
+ * path in `extraInputs` — the resolved mix AUDIO files (timeline clips + the music
120
+ * stem + narration cache audio), supplied by `collectMixAudioInputs` at both the
121
+ * measure and render call sites so the two agree. Hashing only the timing manifests
122
+ * would miss an edited `.wav`/stem (same manifest, changed bytes) and apply a stale
123
+ * publish gain silently — so `extraInputs` MUST be passed on the gate path. Missing
124
+ * files are recorded by name with a sentinel so adding one later also changes the
125
+ * hash. The scene module path itself is excluded.
126
+ */
127
+ declare function computeMixHash(modulePath: string, extraInputs?: readonly string[]): string;
128
+ /**
129
+ * The committed loudness measurement path for a scene module.
130
+ *
131
+ * Base (no locale): `<stem>.loudness.json` — UNCHANGED. With a locale set (0.15
132
+ * FIX 2): `<stem>.<locale>.loudness.json`. A localized render mixes the per-locale
133
+ * narration (e.g. the zh wavs) → a DIFFERENT mixHash than the base, so it needs its
134
+ * OWN committed measurement; one base file can't gate a localized render (it would
135
+ * always read as a stale mixHash, with no supported way to commit a per-locale one).
136
+ */
137
+ declare function loudnessPathFor(modulePath: string, locale?: string): string;
138
+ /** Read + validate a committed measurement, or null when none is committed. */
139
+ declare function readLoudness(modulePath: string, locale?: string): LoudnessMeasurement | null;
140
+ /**
141
+ * Parse the JSON block ffmpeg's `loudnorm=print_format=json` prints to stderr.
142
+ * It carries `input_i` / `input_tp` / `input_lra` (the measured loudness),
143
+ * alongside the would-be normalization params (which we ignore — we apply our
144
+ * own peak-clamped scalar gain instead).
145
+ */
146
+ declare function parseLoudnormJson(stderr: string): {
147
+ inputI: number;
148
+ inputTp: number;
149
+ inputLra: number;
150
+ };
151
+ /**
152
+ * Run ffmpeg's loudnorm measurement pass over a built mix WAV/PCM file and return
153
+ * the measured loudness. This is the quarantined non-deterministic stage — it
154
+ * runs ONLY at measure-time (commit), never during render.
155
+ */
156
+ declare function measureFile(audioPath: string): {
157
+ inputI: number;
158
+ inputTp: number;
159
+ inputLra: number;
160
+ };
161
+ interface MeasureLoudnessOptions {
162
+ modulePath: string;
163
+ /** publish profile id; default 'youtube' */
164
+ profile?: string;
165
+ /** narration auto-mix toggle (mirrors render); default auto */
166
+ narration?: 'auto' | 'off';
167
+ music?: 'auto' | 'off';
168
+ sfx?: 'auto' | 'off';
169
+ /**
170
+ * locale code (0.15 FIX 2). When set, measure the per-locale mix (the localized
171
+ * narration sibling) and commit `<stem>.<locale>.loudness.json` instead of the
172
+ * base file. Mirrors `gs render --locale`, so the measured content matches what
173
+ * a localized render mixes.
174
+ */
175
+ locale?: string;
176
+ }
177
+ interface MeasureLoudnessResult {
178
+ measurement: LoudnessMeasurement;
179
+ loudnessPath: string;
180
+ /** profile the measurement was taken against */
181
+ profile: PublishProfile;
182
+ /** true when the peak clamp bound the gain (advisory: would need a brickwall limiter on an un-normalized profile) */
183
+ clampBound: boolean;
184
+ /** an advisory warning string when an un-normalized profile can't reach its target without clipping; else null */
185
+ warning: string | null;
186
+ }
187
+ /**
188
+ * Build the final mix to a WAV, run the ffmpeg loudnorm measurement over it, and
189
+ * commit a `<scene>.loudness.json` with the peak-clamped publish gain + a mixHash
190
+ * bound to the mix-input manifests. This is the measure step (commit-time); it is
191
+ * the only place the non-deterministic ebur128/mix-to-PCM stages run.
192
+ */
193
+ declare function measureLoudnessCommand(opts: MeasureLoudnessOptions): Promise<MeasureLoudnessResult>;
194
+ //#endregion
195
+ //#region src/master.d.ts
196
+ declare class MasterError extends Error {
197
+ constructor(message: string);
198
+ }
199
+ /** How much gain-reduction the limiter may apply to buy headroom (dB). */
200
+ declare const DEFAULT_MAX_GR_DB = 6;
201
+ /** The limiter spec (from `glissade.master.json`). */
202
+ interface MasterLimiter {
203
+ /** true-peak brickwall (the only mode today). */
204
+ readonly mode: 'truepeak';
205
+ /** the true-peak ceiling, dBTP (defaults to the profile's `truePeakDb`). */
206
+ readonly ceilingDb?: number;
207
+ /** limiter lookahead, ms (advisory — maps to the ffmpeg attack window). */
208
+ readonly lookaheadMs?: number;
209
+ /** max gain-reduction the limiter may apply (dB); beyond this a member can't
210
+ * reach the target cleanly and the shared target drops. Default 6. */
211
+ readonly maxGrDb?: number;
212
+ }
213
+ /** `glissade.master.json`. */
214
+ interface MasterConfig {
215
+ /** publish profile id (youtube/shorts/podcast/broadcast/ebu). */
216
+ readonly profile?: string;
217
+ /** member scene globs (like `gs build`'s `scenes`). */
218
+ readonly members: readonly string[];
219
+ /** the limiter (or `false` to keep the legacy peak-clamp behaviour). */
220
+ readonly limiter?: MasterLimiter | false;
221
+ /** `shared-target` (all members hit one LUFS) or `per-asset` (each hits its own max). */
222
+ readonly consistency?: 'shared-target' | 'per-asset';
223
+ }
224
+ /** One member's measured input loudness (from the ffmpeg measure pass). */
225
+ interface MemberMeasure {
226
+ readonly id: string;
227
+ /** integrated loudness, LUFS. */
228
+ readonly inputI: number;
229
+ /** true peak, dBTP. */
230
+ readonly inputTp: number;
231
+ }
232
+ /** The plan for one member: its target + the gain to apply + predicted limiting. */
233
+ interface MemberPlan extends MemberMeasure {
234
+ /** the output LUFS this member is driven to. */
235
+ readonly target: number;
236
+ /** the gain to apply before the limiter, dB. */
237
+ readonly gain: number;
238
+ /** predicted limiter gain-reduction, dB (0 when the raw peak clears the ceiling). */
239
+ readonly grDb: number;
240
+ /** predicted output true peak, dBTP (== ceiling when the limiter engages). */
241
+ readonly predOutTp: number;
242
+ /** true when this member reaches the shared target within the GR budget. */
243
+ readonly reachable: boolean;
244
+ }
245
+ interface MasterPlan {
246
+ /** the LUFS target the set is normalized to (shared-target) or the profile cap. */
247
+ readonly sharedTarget: number;
248
+ readonly profileTarget: number;
249
+ readonly ceilingDb: number;
250
+ /** true when a limiter is in play (false = legacy peak-clamp). */
251
+ readonly limiter: boolean;
252
+ readonly maxGrDb: number;
253
+ readonly members: readonly MemberPlan[];
254
+ }
255
+ /**
256
+ * Plan a master pass. Pure: measured members → shared target + per-member gains.
257
+ *
258
+ * - With a limiter, a member reaches `target` by applying `gain = target - inputI`
259
+ * and the limiter shaves any overshoot (`grDb`) down to the ceiling.
260
+ * - Without a limiter (`limiter: null`), the gain is peak-CLAMPED (the legacy
261
+ * `min(target-inputI, ceiling-inputTp)`), so a peaky member lands below target.
262
+ * - `shared-target` normalizes every member to one LUFS (the loudest all reach);
263
+ * `per-asset` drives each to its own max.
264
+ */
265
+ declare function planMaster(measures: readonly MemberMeasure[], profile: PublishProfile, opts: {
266
+ limiter: MasterLimiter | null;
267
+ consistency: 'shared-target' | 'per-asset';
268
+ }): MasterPlan;
269
+ /**
270
+ * The `-af` chain that applies a master's gain + (optional) TRUE-peak limiter to a
271
+ * WAV — the SAME {@link loudnessFilterNodes} the render `filter_complex` applies,
272
+ * so the `gs master` verify pass measures exactly what a render will produce.
273
+ */
274
+ declare function masterAfChain(gainDb: number, limiter: CommittedLimiter | null): Promise<string>;
275
+ interface MasterMemberResult {
276
+ readonly id: string;
277
+ readonly loudnessPath: string;
278
+ readonly measurement: LoudnessMeasurement;
279
+ /** measured input LUFS / dBTP. */
280
+ readonly inputI: number;
281
+ readonly inputTp: number;
282
+ /** VERIFIED output LUFS / dBTP (re-measured after gain+limiter). */
283
+ readonly outI: number;
284
+ readonly outTp: number;
285
+ readonly gain: number;
286
+ readonly grDb: number;
287
+ /** true when the verified output true-peak exceeded the ceiling (shouldn't happen). */
288
+ readonly overCeiling: boolean;
289
+ }
290
+ interface MasterResult {
291
+ readonly sharedTarget: number;
292
+ readonly ceilingDb: number;
293
+ readonly limiter: boolean;
294
+ readonly members: readonly MasterMemberResult[];
295
+ readonly report: string;
296
+ }
297
+ interface MasterCommandOptions {
298
+ configPath: string;
299
+ onLog?: (line: string) => void;
300
+ }
301
+ /**
302
+ * `gs master <glissade.master.json>` — measure every member, plan the shared
303
+ * target + limiter, apply+VERIFY per member, and commit `<scene>.loudness.json`
304
+ * ×N. Reuses `buildMixWav`/`measureFile` (measure) and `resolveScenes` (globs);
305
+ * writes the existing sidecar shape + the `limiter` block so render composes it as
306
+ * a mix-only remux under the mixHash preflight.
307
+ */
308
+ declare function masterCommand(opts: MasterCommandOptions): Promise<MasterResult>;
309
+ /** The shared-target loudness options a master pass needs, minus the member list. */
310
+ interface MasterRunOptions {
311
+ profile?: string;
312
+ limiter?: MasterLimiter | false | null;
313
+ consistency?: 'shared-target' | 'per-asset';
314
+ }
315
+ /**
316
+ * The master core, callable with ALREADY-RESOLVED member paths + options — so both
317
+ * `gs master <glissade.master.json>` and the 0.43 project runtime's shared-master
318
+ * phase drive it (the runtime passes the project's scenes as members directly, no
319
+ * temp config file). Measures every member's mix, plans one shared target, and
320
+ * commits `<scene>.loudness.json` ×N with the limiter block (render applies it as a
321
+ * mix-only remux under the mixHash preflight).
322
+ */
323
+
324
+ /** Validate + normalize a parsed `glissade.master.json` (fail-loud). */
325
+ declare function normalizeMasterConfig(raw: unknown): Required<Pick<MasterConfig, 'members' | 'consistency'>> & {
326
+ profile: string;
327
+ limiter: MasterLimiter | null;
328
+ };
329
+ //#endregion
330
+ export { peakClampBinds as A, PublishProfile as C, measureFile as D, loudnessPathFor as E, resolveProfile as M, measureLoudnessCommand as O, PUBLISH_PROFILES as S, computeMixHash as T, LOUDNESS_SCHEMA_VERSION as _, MasterMemberResult as a, MeasureLoudnessOptions as b, MasterRunOptions as c, masterAfChain as d, masterCommand as f, DEFAULT_PROFILE_ID as g, CommittedLimiter as h, MasterLimiter as i, readLoudness as j, parseLoudnormJson as k, MemberMeasure as l, planMaster as m, MasterConfig as n, MasterPlan as o, normalizeMasterConfig as p, MasterError as r, MasterResult as s, DEFAULT_MAX_GR_DB as t, MemberPlan as u, LoudnessError as v, computeGainDb as w, MeasureLoudnessResult as x, LoudnessMeasurement as y };
package/dist/master.js CHANGED
@@ -30,7 +30,8 @@ var master_exports = /* @__PURE__ */ __exportAll({
30
30
  masterAfChain: () => masterAfChain,
31
31
  masterCommand: () => masterCommand,
32
32
  normalizeMasterConfig: () => normalizeMasterConfig,
33
- planMaster: () => planMaster
33
+ planMaster: () => planMaster,
34
+ runMaster: () => runMaster
34
35
  });
35
36
  var MasterError = class extends Error {
36
37
  constructor(message) {
@@ -109,14 +110,32 @@ async function masterAfChain(gainDb, limiter) {
109
110
  */
110
111
  async function masterCommand(opts) {
111
112
  const cfg = normalizeMasterConfig(JSON.parse(readFileSync(opts.configPath, "utf8")));
112
- const profile = resolveProfile(cfg.profile);
113
- const log = opts.onLog ?? (() => {});
114
113
  const { resolveScenes } = await import("./build.js").then((n) => n.t);
115
- const { buildMixWav, collectMixAudioInputs } = await import("./render.js").then((n) => n.p);
116
114
  const members = resolveScenes(cfg.members, process.cwd());
117
115
  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 ? {
116
+ return runMaster(members, {
117
+ profile: cfg.profile,
118
+ limiter: cfg.limiter,
119
+ consistency: cfg.consistency
120
+ }, opts.onLog);
121
+ }
122
+ /**
123
+ * The master core, callable with ALREADY-RESOLVED member paths + options — so both
124
+ * `gs master <glissade.master.json>` and the 0.43 project runtime's shared-master
125
+ * phase drive it (the runtime passes the project's scenes as members directly, no
126
+ * temp config file). Measures every member's mix, plans one shared target, and
127
+ * commits `<scene>.loudness.json` ×N with the limiter block (render applies it as a
128
+ * mix-only remux under the mixHash preflight).
129
+ */
130
+ async function runMaster(members, opts = {}, onLog) {
131
+ const profile = resolveProfile(opts.profile ?? "youtube");
132
+ const consistency = opts.consistency ?? "shared-target";
133
+ const limiter = opts.limiter || null;
134
+ const log = onLog ?? (() => {});
135
+ const { buildMixWav, collectMixAudioInputs } = await import("./render.js").then((n) => n.p);
136
+ if (members.length === 0) throw new MasterError("master: no members to master");
137
+ const ceilingDb = limiter?.ceilingDb ?? profile.truePeakDb;
138
+ const committedLimiter = limiter ? {
120
139
  mode: "truepeak",
121
140
  ceilingDb
122
141
  } : null;
@@ -148,8 +167,8 @@ async function masterCommand(opts) {
148
167
  inputI: m.inputI,
149
168
  inputTp: m.inputTp
150
169
  })), profile, {
151
- limiter: cfg.limiter,
152
- consistency: cfg.consistency
170
+ limiter,
171
+ consistency
153
172
  });
154
173
  log(`shared target ${plan.sharedTarget} LUFS, ceiling ${ceilingDb} dBTP${plan.limiter ? "" : " (no limiter — legacy peak-clamp)"}`);
155
174
  const results = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/cli",
3
- "version": "0.42.0",
3
+ "version": "0.43.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.42.0",
32
- "@glissade/core": "0.42.0",
33
- "@glissade/interact": "0.42.0",
34
- "@glissade/lottie": "0.42.0",
35
- "@glissade/narrate": "0.42.0",
36
- "@glissade/player": "0.42.0",
37
- "@glissade/scene": "0.42.0",
38
- "@glissade/sfx": "0.42.0",
39
- "@glissade/svg": "0.42.0"
31
+ "@glissade/backend-skia": "0.43.0-pre.1",
32
+ "@glissade/core": "0.43.0-pre.1",
33
+ "@glissade/interact": "0.43.0-pre.1",
34
+ "@glissade/lottie": "0.43.0-pre.1",
35
+ "@glissade/narrate": "0.43.0-pre.1",
36
+ "@glissade/player": "0.43.0-pre.1",
37
+ "@glissade/scene": "0.43.0-pre.1",
38
+ "@glissade/sfx": "0.43.0-pre.1",
39
+ "@glissade/svg": "0.43.0-pre.1"
40
40
  },
41
41
  "repository": {
42
42
  "type": "git",