@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 +111 -15
- package/dist/cli.js +2 -1
- package/dist/config.d.ts +10 -0
- package/dist/index.d.ts +1 -314
- package/dist/master.d.ts +330 -0
- package/dist/master.js +27 -8
- package/package.json +10 -10
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
const
|
|
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
|
|
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
|
|
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 (!
|
|
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
|
-
|
|
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:
|
|
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);
|
package/dist/master.d.ts
ADDED
|
@@ -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
|
-
|
|
119
|
-
|
|
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
|
|
152
|
-
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.
|
|
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.
|
|
32
|
-
"@glissade/core": "0.
|
|
33
|
-
"@glissade/interact": "0.
|
|
34
|
-
"@glissade/lottie": "0.
|
|
35
|
-
"@glissade/narrate": "0.
|
|
36
|
-
"@glissade/player": "0.
|
|
37
|
-
"@glissade/scene": "0.
|
|
38
|
-
"@glissade/sfx": "0.
|
|
39
|
-
"@glissade/svg": "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",
|