@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 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
- /** `<module>.loudness.json` for a scene module. */
466
- declare function loudnessPathFor(modulePath: string): string;
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
- /** `<module>.loudness.json` for a scene module. */
150
- function loudnessPathFor(modulePath) {
151
- return modulePath.replace(/\.[jt]sx?$/, "") + ".loudness.json";
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
- const result = await render({
170
- ...opts,
171
- out: localeOutPath(opts.out, locale, opts.format),
172
- locale
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 measurement = readLoudness(opts.modulePath);
579
- if (!measurement) return null;
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) throw new Error(`loudness: ${loudnessPathFor(opts.modulePath)} is stale — the mix inputs changed since it was measured (committed mixHash ${measurement.mixHash.slice(0, 23)}…, current ${actual.slice(0, 23)}…). Re-run \`gs measure-loudness ${opts.modulePath}\` (or pass --loudness off to render without normalization).`);
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.0",
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.0",
27
- "@glissade/core": "0.15.0-pre.0",
28
- "@glissade/interact": "0.15.0-pre.0",
29
- "@glissade/lottie": "0.15.0-pre.0",
30
- "@glissade/svg": "0.15.0-pre.0",
31
- "@glissade/narrate": "0.15.0-pre.0",
32
- "@glissade/player": "0.15.0-pre.0",
33
- "@glissade/scene": "0.15.0-pre.0",
34
- "@glissade/sfx": "0.15.0-pre.0"
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",