@glissade/cli 0.15.0-pre.0 → 0.15.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/cli.js +7 -2
- package/dist/index.d.ts +33 -5
- package/dist/loudness.js +19 -8
- package/dist/render.js +41 -10
- package/package.json +10 -10
package/dist/cli.js
CHANGED
|
@@ -73,7 +73,7 @@ const USAGE = `usage:
|
|
|
73
73
|
gs narration-lint <scene-module|script.narration.timing.json> [--json] [--fix] [--max-cps <n>]
|
|
74
74
|
gs sfx <scene-module|script.sfx.json> [--verbose]
|
|
75
75
|
gs prepare <scene-module> [--provider <id>] [--align <id>] [--force]
|
|
76
|
-
gs measure-loudness <scene-module> [--profile <youtube|shorts|podcast|broadcast|ebu>]
|
|
76
|
+
gs measure-loudness <scene-module> [--profile <youtube|shorts|podcast|broadcast|ebu>] [--locale <code>]
|
|
77
77
|
gs fonts audit <scene-module> list registered families, formats, and missing-glyph runs (§3.6)
|
|
78
78
|
gs cache verify <scene-module> [--range a..b] [--sample <n>] assert cache hits == cold renders (§3.5)
|
|
79
79
|
|
|
@@ -147,6 +147,9 @@ measure-loudness options (the explicit publish-loudness measure step; commits *.
|
|
|
147
147
|
--profile <id> youtube (default) | shorts (both -14 LUFS) | podcast (-16) | broadcast/ebu (-23); all cap at -1 dBTP
|
|
148
148
|
measures the final mix (ebur128) and commits a deterministic peak-clamped gain; render applies it
|
|
149
149
|
as a pure scalar. Needs ffmpeg. Brickwall limiter deferred — peaky un-normalized profiles warn.
|
|
150
|
+
--locale <code> (0.15) measure the per-locale mix (the localized narration sibling) and commit
|
|
151
|
+
<stem>.<locale>.loudness.json. A localized 'gs render --locale <code>' REQUIRES this per-locale
|
|
152
|
+
file (the base measurement can't gate the per-locale mix). No --locale measures the base mix.
|
|
150
153
|
|
|
151
154
|
narrate options (the explicit TTS prepare step; render itself stays offline):
|
|
152
155
|
--provider <id> fake | espeak | piper | kokoro | openai (default: the script's provider, else espeak)
|
|
@@ -294,12 +297,14 @@ async function main() {
|
|
|
294
297
|
if (command === "measure-loudness") {
|
|
295
298
|
const { measureLoudnessCommand } = await import("./loudness.js").then((n) => n.c);
|
|
296
299
|
try {
|
|
300
|
+
const mlLocale = flags.get("locale");
|
|
297
301
|
const result = await measureLoudnessCommand({
|
|
298
302
|
modulePath,
|
|
299
303
|
...flags.has("profile") ? { profile: flags.get("profile") } : {},
|
|
300
304
|
...flags.get("narration") === "off" ? { narration: "off" } : {},
|
|
301
305
|
...flags.get("music") === "off" ? { music: "off" } : {},
|
|
302
|
-
...flags.get("sfx") === "off" ? { sfx: "off" } : {}
|
|
306
|
+
...flags.get("sfx") === "off" ? { sfx: "off" } : {},
|
|
307
|
+
...mlLocale !== void 0 && mlLocale !== "" ? { locale: mlLocale } : {}
|
|
303
308
|
});
|
|
304
309
|
const m = result.measurement;
|
|
305
310
|
process.stderr.write(`gs measure-loudness: profile '${m.profileId}' — in ${m.inputI} LUFS / ${m.inputTp} dBTP → gain ${m.gain >= 0 ? "+" : ""}${m.gain} dB (out ~${(m.inputI + m.gain).toFixed(2)} LUFS / ~${(m.inputTp + m.gain).toFixed(2)} dBTP) → ${result.loudnessPath}\n`);
|
package/dist/index.d.ts
CHANGED
|
@@ -269,6 +269,11 @@ declare function localeOutPath(out: string, locale: string, format?: 'png-seq'):
|
|
|
269
269
|
* `UnknownLocaleError` (naming the bad locale) from inside `render()`, aborting
|
|
270
270
|
* the whole fan-out — locales already rendered keep their artifacts, but the
|
|
271
271
|
* process exits non-zero so a missing-asset locale is never silently skipped.
|
|
272
|
+
*
|
|
273
|
+
* 0.15 FIX 2: a per-locale loudness dead-end (no `<stem>.<locale>.loudness.json`
|
|
274
|
+
* committed) also aborts the batch — but a bare error mid-fan-out reads like a
|
|
275
|
+
* generic crash, so each per-locale failure is wrapped to NAME the failing locale.
|
|
276
|
+
* It still fails loudly (never swallowed): the wrapped message stays actionable.
|
|
272
277
|
*/
|
|
273
278
|
declare function renderLocales(opts: Omit<RenderOptions, 'locale'>, locales: readonly string[]): Promise<{
|
|
274
279
|
locale: string;
|
|
@@ -326,8 +331,16 @@ declare function collectAudioClips(opts: Pick<RenderOptions, 'modulePath' | 'nar
|
|
|
326
331
|
* `.wav`/music stem invalidates the measurement here too (the §5.3 stale-gain
|
|
327
332
|
* gate). `timelineClips` defaults to the scene's compiled timeline audio when
|
|
328
333
|
* omitted, so a bare `{ modulePath }` call still gates correctly.
|
|
334
|
+
*
|
|
335
|
+
* 0.15 FIX 2 (localized loudness): a localized render (`--locale zh`) mixes the
|
|
336
|
+
* per-locale narration → a DIFFERENT mixHash than the base measurement. So when a
|
|
337
|
+
* locale is set it reads the per-locale file `<stem>.<locale>.loudness.json` FIRST.
|
|
338
|
+
* When that per-locale file is MISSING it throws an ACTIONABLE per-locale error
|
|
339
|
+
* (naming the file + the `gs measure-loudness … --locale` to run) instead of the
|
|
340
|
+
* generic stale-mixHash message — there is otherwise no supported way to commit a
|
|
341
|
+
* per-locale measurement, so a base file would always read as stale and dead-end.
|
|
329
342
|
*/
|
|
330
|
-
declare function resolveLoudnessGainDb(opts: Pick<RenderOptions, 'modulePath' | 'loudness' | 'narration' | 'music' | 'sfx'>, timelineClips?: AudioClip[]): Promise<number | null>;
|
|
343
|
+
declare function resolveLoudnessGainDb(opts: Pick<RenderOptions, 'modulePath' | 'loudness' | 'narration' | 'music' | 'sfx' | 'locale'>, timelineClips?: AudioClip[]): Promise<number | null>;
|
|
331
344
|
/**
|
|
332
345
|
* Collect audio clips + auto-mixed siblings and plan the FFmpeg audio graph,
|
|
333
346
|
* returning the `-i`/`-filter_complex`/`-map` argument fragments. Shared by the
|
|
@@ -353,7 +366,7 @@ declare function planFinalAudio(opts: RenderOptions, timelineClips: AudioClip[],
|
|
|
353
366
|
* gain — measurement is of the un-gained mix), so the measured content is exactly
|
|
354
367
|
* what render will later mix. Returns false when the scene has no audio.
|
|
355
368
|
*/
|
|
356
|
-
declare function buildMixWav(opts: Pick<RenderOptions, 'modulePath' | 'narration' | 'music' | 'sfx'>, wavOut: string): Promise<boolean>;
|
|
369
|
+
declare function buildMixWav(opts: Pick<RenderOptions, 'modulePath' | 'narration' | 'music' | 'sfx' | 'locale'>, wavOut: string): Promise<boolean>;
|
|
357
370
|
//#endregion
|
|
358
371
|
//#region src/loudness.d.ts
|
|
359
372
|
/**
|
|
@@ -462,10 +475,18 @@ declare function peakClampBinds(profile: PublishProfile, inputI: number, inputTp
|
|
|
462
475
|
* hash. The scene module path itself is excluded.
|
|
463
476
|
*/
|
|
464
477
|
declare function computeMixHash(modulePath: string, extraInputs?: readonly string[]): string;
|
|
465
|
-
/**
|
|
466
|
-
|
|
478
|
+
/**
|
|
479
|
+
* The committed loudness measurement path for a scene module.
|
|
480
|
+
*
|
|
481
|
+
* Base (no locale): `<stem>.loudness.json` — UNCHANGED. With a locale set (0.15
|
|
482
|
+
* FIX 2): `<stem>.<locale>.loudness.json`. A localized render mixes the per-locale
|
|
483
|
+
* narration (e.g. the zh wavs) → a DIFFERENT mixHash than the base, so it needs its
|
|
484
|
+
* OWN committed measurement; one base file can't gate a localized render (it would
|
|
485
|
+
* always read as a stale mixHash, with no supported way to commit a per-locale one).
|
|
486
|
+
*/
|
|
487
|
+
declare function loudnessPathFor(modulePath: string, locale?: string): string;
|
|
467
488
|
/** Read + validate a committed measurement, or null when none is committed. */
|
|
468
|
-
declare function readLoudness(modulePath: string): LoudnessMeasurement | null;
|
|
489
|
+
declare function readLoudness(modulePath: string, locale?: string): LoudnessMeasurement | null;
|
|
469
490
|
/**
|
|
470
491
|
* Parse the JSON block ffmpeg's `loudnorm=print_format=json` prints to stderr.
|
|
471
492
|
* It carries `input_i` / `input_tp` / `input_lra` (the measured loudness),
|
|
@@ -495,6 +516,13 @@ interface MeasureLoudnessOptions {
|
|
|
495
516
|
narration?: 'auto' | 'off';
|
|
496
517
|
music?: 'auto' | 'off';
|
|
497
518
|
sfx?: 'auto' | 'off';
|
|
519
|
+
/**
|
|
520
|
+
* locale code (0.15 FIX 2). When set, measure the per-locale mix (the localized
|
|
521
|
+
* narration sibling) and commit `<stem>.<locale>.loudness.json` instead of the
|
|
522
|
+
* base file. Mirrors `gs render --locale`, so the measured content matches what
|
|
523
|
+
* a localized render mixes.
|
|
524
|
+
*/
|
|
525
|
+
locale?: string;
|
|
498
526
|
}
|
|
499
527
|
interface MeasureLoudnessResult {
|
|
500
528
|
measurement: LoudnessMeasurement;
|
package/dist/loudness.js
CHANGED
|
@@ -146,13 +146,22 @@ function computeMixHash(modulePath, extraInputs = []) {
|
|
|
146
146
|
}
|
|
147
147
|
return `sha256:${h.digest("hex")}`;
|
|
148
148
|
}
|
|
149
|
-
/**
|
|
150
|
-
|
|
151
|
-
|
|
149
|
+
/**
|
|
150
|
+
* The committed loudness measurement path for a scene module.
|
|
151
|
+
*
|
|
152
|
+
* Base (no locale): `<stem>.loudness.json` — UNCHANGED. With a locale set (0.15
|
|
153
|
+
* FIX 2): `<stem>.<locale>.loudness.json`. A localized render mixes the per-locale
|
|
154
|
+
* narration (e.g. the zh wavs) → a DIFFERENT mixHash than the base, so it needs its
|
|
155
|
+
* OWN committed measurement; one base file can't gate a localized render (it would
|
|
156
|
+
* always read as a stale mixHash, with no supported way to commit a per-locale one).
|
|
157
|
+
*/
|
|
158
|
+
function loudnessPathFor(modulePath, locale) {
|
|
159
|
+
const stem = modulePath.replace(/\.[jt]sx?$/, "");
|
|
160
|
+
return locale !== void 0 && locale !== "" ? `${stem}.${locale}.loudness.json` : `${stem}.loudness.json`;
|
|
152
161
|
}
|
|
153
162
|
/** Read + validate a committed measurement, or null when none is committed. */
|
|
154
|
-
function readLoudness(modulePath) {
|
|
155
|
-
const path = loudnessPathFor(modulePath);
|
|
163
|
+
function readLoudness(modulePath, locale) {
|
|
164
|
+
const path = loudnessPathFor(modulePath, locale);
|
|
156
165
|
if (!existsSync(path)) return null;
|
|
157
166
|
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
158
167
|
if (raw.loudnessVersion !== 1) throw new LoudnessError(`${path}: unsupported loudnessVersion ${String(raw.loudnessVersion)} (expected 1); re-run gs measure-loudness`);
|
|
@@ -222,7 +231,8 @@ async function measureLoudnessCommand(opts) {
|
|
|
222
231
|
modulePath: opts.modulePath,
|
|
223
232
|
narration: opts.narration ?? "auto",
|
|
224
233
|
music: opts.music ?? "auto",
|
|
225
|
-
sfx: opts.sfx ?? "auto"
|
|
234
|
+
sfx: opts.sfx ?? "auto",
|
|
235
|
+
...opts.locale !== void 0 && opts.locale !== "" ? { locale: opts.locale } : {}
|
|
226
236
|
}, wavPath)) throw new LoudnessError(`${opts.modulePath} has no audio to measure (no timeline audio and no narration/music/sfx manifests)`);
|
|
227
237
|
const { inputI, inputTp, inputLra } = measureFile(wavPath);
|
|
228
238
|
const gain = computeGainDb(profile, inputI, inputTp);
|
|
@@ -232,7 +242,8 @@ async function measureLoudnessCommand(opts) {
|
|
|
232
242
|
modulePath: opts.modulePath,
|
|
233
243
|
narration: opts.narration ?? "auto",
|
|
234
244
|
music: opts.music ?? "auto",
|
|
235
|
-
sfx: opts.sfx ?? "auto"
|
|
245
|
+
sfx: opts.sfx ?? "auto",
|
|
246
|
+
...opts.locale !== void 0 && opts.locale !== "" ? { locale: opts.locale } : {}
|
|
236
247
|
});
|
|
237
248
|
const mixHash = computeMixHash(opts.modulePath, extraInputs);
|
|
238
249
|
const measurement = {
|
|
@@ -244,7 +255,7 @@ async function measureLoudnessCommand(opts) {
|
|
|
244
255
|
gain: floorGain2(gain),
|
|
245
256
|
mixHash
|
|
246
257
|
};
|
|
247
|
-
const loudnessPath = loudnessPathFor(opts.modulePath);
|
|
258
|
+
const loudnessPath = loudnessPathFor(opts.modulePath, opts.locale);
|
|
248
259
|
writeFileSync(loudnessPath, JSON.stringify(measurement, null, 2) + "\n");
|
|
249
260
|
return {
|
|
250
261
|
measurement,
|
package/dist/render.js
CHANGED
|
@@ -162,15 +162,27 @@ function localeOutPath(out, locale, format) {
|
|
|
162
162
|
* `UnknownLocaleError` (naming the bad locale) from inside `render()`, aborting
|
|
163
163
|
* the whole fan-out — locales already rendered keep their artifacts, but the
|
|
164
164
|
* process exits non-zero so a missing-asset locale is never silently skipped.
|
|
165
|
+
*
|
|
166
|
+
* 0.15 FIX 2: a per-locale loudness dead-end (no `<stem>.<locale>.loudness.json`
|
|
167
|
+
* committed) also aborts the batch — but a bare error mid-fan-out reads like a
|
|
168
|
+
* generic crash, so each per-locale failure is wrapped to NAME the failing locale.
|
|
169
|
+
* It still fails loudly (never swallowed): the wrapped message stays actionable.
|
|
165
170
|
*/
|
|
166
171
|
async function renderLocales(opts, locales) {
|
|
167
172
|
const results = [];
|
|
168
173
|
for (const locale of locales) {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
+
let result;
|
|
175
|
+
try {
|
|
176
|
+
result = await render({
|
|
177
|
+
...opts,
|
|
178
|
+
out: localeOutPath(opts.out, locale, opts.format),
|
|
179
|
+
locale
|
|
180
|
+
});
|
|
181
|
+
} catch (err) {
|
|
182
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
183
|
+
if (err instanceof Error && msg.includes(`'${locale}'`)) throw err;
|
|
184
|
+
throw new Error(`--locales: locale '${locale}' failed — ${msg}`, { cause: err });
|
|
185
|
+
}
|
|
174
186
|
results.push({
|
|
175
187
|
locale,
|
|
176
188
|
result
|
|
@@ -542,7 +554,7 @@ async function collectMixAudioInputs(opts, timelineClips) {
|
|
|
542
554
|
return preservingMessageTable(async () => {
|
|
543
555
|
let tl = timelineClips;
|
|
544
556
|
if (tl === void 0) try {
|
|
545
|
-
const mod = await loadSceneModule(opts.modulePath);
|
|
557
|
+
const mod = await loadSceneModule(opts.modulePath, opts.locale);
|
|
546
558
|
const { compileTimeline } = await import("@glissade/core");
|
|
547
559
|
tl = [...compileTimeline(mod.timeline).audio];
|
|
548
560
|
} catch {
|
|
@@ -571,15 +583,34 @@ async function collectMixAudioInputs(opts, timelineClips) {
|
|
|
571
583
|
* `.wav`/music stem invalidates the measurement here too (the §5.3 stale-gain
|
|
572
584
|
* gate). `timelineClips` defaults to the scene's compiled timeline audio when
|
|
573
585
|
* omitted, so a bare `{ modulePath }` call still gates correctly.
|
|
586
|
+
*
|
|
587
|
+
* 0.15 FIX 2 (localized loudness): a localized render (`--locale zh`) mixes the
|
|
588
|
+
* per-locale narration → a DIFFERENT mixHash than the base measurement. So when a
|
|
589
|
+
* locale is set it reads the per-locale file `<stem>.<locale>.loudness.json` FIRST.
|
|
590
|
+
* When that per-locale file is MISSING it throws an ACTIONABLE per-locale error
|
|
591
|
+
* (naming the file + the `gs measure-loudness … --locale` to run) instead of the
|
|
592
|
+
* generic stale-mixHash message — there is otherwise no supported way to commit a
|
|
593
|
+
* per-locale measurement, so a base file would always read as stale and dead-end.
|
|
574
594
|
*/
|
|
575
595
|
async function resolveLoudnessGainDb(opts, timelineClips) {
|
|
576
596
|
if ((opts.loudness ?? "auto") === "off") return null;
|
|
577
597
|
const { readLoudness, computeMixHash, loudnessPathFor } = await import("./loudness.js").then((n) => n.c);
|
|
578
|
-
const
|
|
579
|
-
|
|
598
|
+
const hasLocale = opts.locale !== void 0 && opts.locale !== "";
|
|
599
|
+
const measurement = readLoudness(opts.modulePath, opts.locale);
|
|
600
|
+
if (!measurement) {
|
|
601
|
+
if (hasLocale && readLoudness(opts.modulePath) !== null) {
|
|
602
|
+
const perLocale = loudnessPathFor(opts.modulePath, opts.locale);
|
|
603
|
+
throw new Error(`loudness: no ${perLocale} for locale '${opts.locale}' — a localized render mixes the per-locale narration, which has a different loudness than the base mix, so it needs its OWN measurement. Run \`gs measure-loudness ${opts.modulePath} --locale ${opts.locale}\` to commit it (or pass --loudness off to render this locale without normalization).`);
|
|
604
|
+
}
|
|
605
|
+
return null;
|
|
606
|
+
}
|
|
580
607
|
const extraInputs = await collectMixAudioInputs(opts, timelineClips);
|
|
581
608
|
const actual = computeMixHash(opts.modulePath, extraInputs);
|
|
582
|
-
if (actual !== measurement.mixHash)
|
|
609
|
+
if (actual !== measurement.mixHash) {
|
|
610
|
+
const path = loudnessPathFor(opts.modulePath, opts.locale);
|
|
611
|
+
const reRun = hasLocale ? `gs measure-loudness ${opts.modulePath} --locale ${opts.locale}` : `gs measure-loudness ${opts.modulePath}`;
|
|
612
|
+
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).`);
|
|
613
|
+
}
|
|
583
614
|
return measurement.gain;
|
|
584
615
|
}
|
|
585
616
|
/**
|
|
@@ -636,7 +667,7 @@ async function buildMixWav(opts, wavOut) {
|
|
|
636
667
|
if (!ffmpegAvailable()) throw new Error("gs measure-loudness needs FFmpeg on PATH and none was found.");
|
|
637
668
|
const { preservingMessageTable } = await import("@glissade/core/i18n");
|
|
638
669
|
return preservingMessageTable(async () => {
|
|
639
|
-
const mod = await loadSceneModule(opts.modulePath);
|
|
670
|
+
const mod = await loadSceneModule(opts.modulePath, opts.locale);
|
|
640
671
|
const scene = mod.createScene();
|
|
641
672
|
const { compileTimeline } = await import("@glissade/core");
|
|
642
673
|
const compiled = compileTimeline(mod.timeline);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glissade/cli",
|
|
3
|
-
"version": "0.15.0-pre.
|
|
3
|
+
"version": "0.15.0-pre.1",
|
|
4
4
|
"description": "glissade CLI: headless rendering via backend-skia (+ FFmpeg mux).",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"engines": {
|
|
@@ -23,15 +23,15 @@
|
|
|
23
23
|
"@napi-rs/canvas": "^0.1.65",
|
|
24
24
|
"esbuild": "^0.28.0",
|
|
25
25
|
"jiti": "^2.4.2",
|
|
26
|
-
"@glissade/backend-skia": "0.15.0-pre.
|
|
27
|
-
"@glissade/core": "0.15.0-pre.
|
|
28
|
-
"@glissade/interact": "0.15.0-pre.
|
|
29
|
-
"@glissade/lottie": "0.15.0-pre.
|
|
30
|
-
"@glissade/svg": "0.15.0-pre.
|
|
31
|
-
"@glissade/narrate": "0.15.0-pre.
|
|
32
|
-
"@glissade/player": "0.15.0-pre.
|
|
33
|
-
"@glissade/scene": "0.15.0-pre.
|
|
34
|
-
"@glissade/sfx": "0.15.0-pre.
|
|
26
|
+
"@glissade/backend-skia": "0.15.0-pre.1",
|
|
27
|
+
"@glissade/core": "0.15.0-pre.1",
|
|
28
|
+
"@glissade/interact": "0.15.0-pre.1",
|
|
29
|
+
"@glissade/lottie": "0.15.0-pre.1",
|
|
30
|
+
"@glissade/svg": "0.15.0-pre.1",
|
|
31
|
+
"@glissade/narrate": "0.15.0-pre.1",
|
|
32
|
+
"@glissade/player": "0.15.0-pre.1",
|
|
33
|
+
"@glissade/scene": "0.15.0-pre.1",
|
|
34
|
+
"@glissade/sfx": "0.15.0-pre.1"
|
|
35
35
|
},
|
|
36
36
|
"repository": {
|
|
37
37
|
"type": "git",
|