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