@glissade/cli 0.14.0-pre.1 → 0.15.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.
@@ -1,5 +1,5 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime.js";
2
- import { a as loadSceneModule } from "./render.js";
2
+ import { o as loadSceneModule } from "./render.js";
3
3
  import { i as capsId, n as FrameCache, o as frameCacheKey } from "./frameCache.js";
4
4
  import { t as glissadeVersion } from "./version.js";
5
5
  import { mkdtempSync, rmSync } from "node:fs";
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { c as render, o as parseFrameRange } from "./render.js";
2
+ import { c as parseFrameRange, d as render, f as renderLocales, l as parseLocalesList, t as LocaleArgsError } from "./render.js";
3
3
  import { n as parseCaptionsMode } from "./captions.js";
4
4
  //#region src/args.ts
5
5
  /**
@@ -115,6 +115,11 @@ render options:
115
115
  off by default, IGNORED under --strict so the strict verdict stays host-independent; §3.6)
116
116
  --locale <code> resolve the scene against messages.<code>.json (node-id text + free-standing t() keys) and prefer
117
117
  the <base>.<code>.narration.timing.json sibling (0.14). No --locale resolves the BASE files
118
+ --locales <a,b> (0.15) fan out: render the scene ONCE PER comma-separated locale, each over the --locale <code>
119
+ path, to a DISTINCT per-locale output. A video/png --out gets a locale segment before the
120
+ extension (out/ep.mp4 → out/ep.<locale>.mp4); a directory --out gets a per-locale subdir
121
+ (out/ → out/<locale>/). Mutually exclusive with --locale. A locale with NO resolvable assets
122
+ aborts the whole fan-out with the same UnknownLocaleError --locale throws (never silently skipped)
118
123
 
119
124
  diff options (DisplayList diff vs a committed baseline — exits non-zero on any divergence):
120
125
  --at <t> time in SECONDS to evaluate the scene at (required)
@@ -443,10 +448,19 @@ async function main() {
443
448
  };
444
449
  }
445
450
  if (flags.has("watch")) process.stderr.write("note: --watch is not yet implemented in this release; rendering once\n");
451
+ let locales;
452
+ if (flags.has("locales") && flags.get("locales")) {
453
+ if (flags.has("locale") && flags.get("locale")) fail("--locale and --locales are mutually exclusive — pass one (--locale renders a single locale; --locales fans out over many)");
454
+ try {
455
+ locales = parseLocalesList(flags.get("locales"));
456
+ } catch (err) {
457
+ fail(err instanceof LocaleArgsError ? err.message : err instanceof Error ? err.message : String(err));
458
+ }
459
+ }
446
460
  const fpsFlag = flags.get("fps");
447
461
  const started = performance.now();
448
462
  try {
449
- const result = await render({
463
+ const sharedOpts = {
450
464
  modulePath,
451
465
  out: flags.get("out") ?? "out",
452
466
  ...fpsFlag ? { fps: parseInt(fpsFlag, 10) } : {},
@@ -464,7 +478,6 @@ async function main() {
464
478
  ...flags.has("lossless-intermediate") ? { losslessIntermediate: true } : {},
465
479
  ...flags.has("allow-gpu-shards") ? { allowGpuShards: true } : {},
466
480
  ...cache !== void 0 ? { cache } : {},
467
- ...flags.has("locale") && flags.get("locale") ? { locale: flags.get("locale") } : {},
468
481
  captions: parseCaptionsModeOrFail(flags.get("captions")),
469
482
  narration: flags.get("narration") === "off" ? "off" : "auto",
470
483
  music: flags.get("music") === "off" ? "off" : "auto",
@@ -475,10 +488,21 @@ async function main() {
475
488
  if (n % 30 === 0 || n === total) process.stderr.write(`\rrendering ${n}/${total} frames`);
476
489
  } else if (n % 300 === 0 || n === total) process.stderr.write(`rendering ${n}/${total} frames\n`);
477
490
  }
478
- });
479
- const secs = ((performance.now() - started) / 1e3).toFixed(2);
491
+ };
480
492
  const cr = process.stderr.isTTY ? "\r" : "";
481
- process.stderr.write(`${cr}rendered ${result.frames} frames in ${secs}s → ${result.out}\n`);
493
+ if (locales) {
494
+ const fan = await renderLocales(sharedOpts, locales);
495
+ const secs = ((performance.now() - started) / 1e3).toFixed(2);
496
+ for (const { locale, result } of fan) process.stderr.write(`${cr}rendered [${locale}] ${result.frames} frames → ${result.out}\n`);
497
+ process.stderr.write(`done: ${fan.length} locale${fan.length === 1 ? "" : "s"} in ${secs}s\n`);
498
+ } else {
499
+ const result = await render({
500
+ ...sharedOpts,
501
+ ...flags.has("locale") && flags.get("locale") ? { locale: flags.get("locale") } : {}
502
+ });
503
+ const secs = ((performance.now() - started) / 1e3).toFixed(2);
504
+ process.stderr.write(`${cr}rendered ${result.frames} frames in ${secs}s → ${result.out}\n`);
505
+ }
482
506
  } catch (err) {
483
507
  fail(err instanceof Error ? err.message : String(err));
484
508
  }
package/dist/diff.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime.js";
2
- import { a as loadSceneModule } from "./render.js";
2
+ import { o as loadSceneModule } from "./render.js";
3
3
  import { readFileSync } from "node:fs";
4
4
  import { diffDisplayLists, evaluate, formatDisplayDiff, parseDisplaySnapshot, serializeDisplayList } from "@glissade/scene";
5
5
  //#region src/diff.ts
package/dist/fonts.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as loadSceneModule } from "./render.js";
1
+ import { o as loadSceneModule } from "./render.js";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { buildFontRegistry } from "@glissade/core";
4
4
  import { collectTextUsages } from "@glissade/scene";
package/dist/index.d.ts CHANGED
@@ -226,8 +226,58 @@ interface RenderOptions {
226
226
  * verdict depend on the host. The true-OS `osCatalog` is folded in ONLY when
227
227
  * `allowSystemFonts` is set AND `strict` is false — so --strict stays host-
228
228
  * independent regardless of the flag. Pure: no I/O, no host queries.
229
+ *
230
+ * 0.15 FIX 5 (osFamilies brand-warn gap): when an OS family name COLLIDES with a
231
+ * family glissade actually registered from `doc.assets`, the OS-catalog fold must
232
+ * NOT re-add it as an "OS-only" exemption — a registered family is a declared
233
+ * brand font that must stay subject to glyph-coverage validation, not be waved
234
+ * through as a system family. We therefore skip any osCatalog entry that collides
235
+ * with a registered family (the `registered` seed already carries it, and core's
236
+ * `validateFonts` runs coverage for registered families regardless of exemption).
237
+ * The exemption is for GENUINELY-OS-only families, never registered ones that
238
+ * happen to share a name.
229
239
  */
230
240
 
241
+ declare class LocaleArgsError extends Error {
242
+ constructor(detail: string);
243
+ }
244
+ /**
245
+ * Parse the `--locales <a,b,c>` comma-separated list into a de-duplicated,
246
+ * order-preserving array of locale codes. Empty / whitespace-only entries are
247
+ * dropped; an all-empty list is rejected (a `--locales` with nothing to render
248
+ * is a user error, not a silent no-op).
249
+ */
250
+ declare function parseLocalesList(raw: string): string[];
251
+ /**
252
+ * Derive the per-locale output path from the base `out` and a locale code.
253
+ *
254
+ * Convention: a video/PNG FILE path (`out/episode.mp4`, `out/still.png`) gets a
255
+ * locale segment inserted before the extension → `out/episode.<locale>.mp4`. Any
256
+ * other `out` (a PNG-sequence DIRECTORY, the default `out`) gets a per-locale
257
+ * SUBDIR → `out/<locale>` (so `out/en/frame-00000.png`, `out/zh/frame-00000.png`).
258
+ * `--format png-seq` forces the directory convention even for a video-looking
259
+ * name, matching how `render()` itself treats `out` under that flag.
260
+ */
261
+ declare function localeOutPath(out: string, locale: string, format?: 'png-seq'): string;
262
+ /**
263
+ * §0.15 `--locales` fan-out: render the scene once per locale, writing one
264
+ * artifact per locale to a distinct per-locale path (`localeOutPath`). Each
265
+ * per-locale render IS the 0.14 single-`--locale` render — so `--locales en,zh`
266
+ * ≡ `--locale en` then `--locale zh` with distinct outputs.
267
+ *
268
+ * Fails LOUDLY: a locale in the list with no resolvable assets throws the 0.14
269
+ * `UnknownLocaleError` (naming the bad locale) from inside `render()`, aborting
270
+ * the whole fan-out — locales already rendered keep their artifacts, but the
271
+ * process exits non-zero so a missing-asset locale is never silently skipped.
272
+ */
273
+ declare function renderLocales(opts: Omit<RenderOptions, 'locale'>, locales: readonly string[]): Promise<{
274
+ locale: string;
275
+ result: {
276
+ frames: number;
277
+ out: string;
278
+ };
279
+ }[]>;
280
+ /** A scene module's default export is not a valid `SceneModule`. */
231
281
  declare class SceneModuleError extends Error {
232
282
  constructor(modulePath: string, detail: string);
233
283
  }
@@ -833,4 +883,4 @@ declare function cacheVerifyCommand(opts: CacheVerifyOptions): Promise<CacheVeri
833
883
  /** The @glissade/cli package version (the glissade VERSION for the cache key). */
834
884
  declare function glissadeVersion(): string;
835
885
  //#endregion
836
- export { AudioMixError, type AudioMixPlan, type CacheKeyContext, type CacheMode, type CacheVerifyOptions, type CacheVerifyResult, type CaptionProbe, DEFAULT_CACHE_MAX_SIZE, 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, LoudnessError, type LoudnessMeasurement, MachineExportError, type MachineRenderFlags, type MeasureLoudnessOptions, type MeasureLoudnessResult, type NarrationLintOptions, type NarrationLintResult, NoEncoderError, PUBLISH_PROFILES, type PublishProfile, type RenderOptions, type RenderShardedArgs, 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, loudnessPathFor, measureFile, measureLoudnessCommand, narrationLintCommand, parseCacheMaxSize, parseEncoderList, parseLoudnormJson, peakClampBinds, pickEncoder, planAudioMix, planFinalAudio, probeEntryHeader, probeVideo, readLoudness, render, renderSharded, resolveAssetPath, resolveLoudnessGainDb, resolveProfile, resolveRenderDoc, sceneHasGpuNodes, snapshotAt, splitFrameRange };
886
+ export { AudioMixError, type AudioMixPlan, type CacheKeyContext, type CacheMode, type CacheVerifyOptions, type CacheVerifyResult, type CaptionProbe, DEFAULT_CACHE_MAX_SIZE, 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, 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, resolveAssetPath, resolveLoudnessGainDb, resolveProfile, resolveRenderDoc, sceneHasGpuNodes, snapshotAt, splitFrameRange };
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as loadSceneModule, c as render, i as ffmpegAvailable, n as buildMixWav, r as collectAudioClips, s as planFinalAudio, t as SceneModuleError, u as resolveLoudnessGainDb } from "./render.js";
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 { 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";
3
3
  import { a as pickEncoder, i as parseEncoderList, n as availableEncoders, t as NoEncoderError } from "./encoders.js";
4
4
  import { a as splitFrameRange, n as renderSharded, r as sceneHasGpuNodes, t as ShardError } from "./shards.js";
@@ -12,4 +12,4 @@ import { a as fixDiff, c as lintNarration, n as lintTimingPathFor, o as formatTa
12
12
  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";
13
13
  import { t as glissadeVersion } from "./version.js";
14
14
  import { t as cacheVerifyCommand } from "./cacheVerify.js";
15
- export { AudioMixError, DEFAULT_CACHE_MAX_SIZE, DEFAULT_PROFILE_ID, FfmpegVideoFrameSource, FrameCache, FrameCacheError, LOUDNESS_SCHEMA_VERSION, 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, loudnessPathFor, measureFile, measureLoudnessCommand, narrationLintCommand, parseCacheMaxSize, parseEncoderList, parseLoudnormJson, peakClampBinds, pickEncoder, planAudioMix, planFinalAudio, probeEntryHeader, probeVideo, readLoudness, render, renderSharded, resolveAssetPath, resolveLoudnessGainDb, resolveProfile, resolveRenderDoc, sceneHasGpuNodes, snapshotAt, splitFrameRange };
15
+ export { AudioMixError, DEFAULT_CACHE_MAX_SIZE, 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, resolveAssetPath, resolveLoudnessGainDb, resolveProfile, resolveRenderDoc, sceneHasGpuNodes, snapshotAt, splitFrameRange };
package/dist/loudness.js CHANGED
@@ -214,7 +214,7 @@ function measureFile(audioPath) {
214
214
  */
215
215
  async function measureLoudnessCommand(opts) {
216
216
  const profile = resolveProfile(opts.profile ?? "youtube");
217
- const { buildMixWav } = await import("./render.js").then((n) => n.l);
217
+ const { buildMixWav } = await import("./render.js").then((n) => n.p);
218
218
  const tmp = mkdtempSync(join(tmpdir(), "glissade-loudness-"));
219
219
  try {
220
220
  const wavPath = join(tmp, "mix.wav");
@@ -227,7 +227,7 @@ async function measureLoudnessCommand(opts) {
227
227
  const { inputI, inputTp, inputLra } = measureFile(wavPath);
228
228
  const gain = computeGainDb(profile, inputI, inputTp);
229
229
  const clampBound = peakClampBinds(profile, inputI, inputTp);
230
- const { collectMixAudioInputs } = await import("./render.js").then((n) => n.l);
230
+ const { collectMixAudioInputs } = await import("./render.js").then((n) => n.p);
231
231
  const extraInputs = await collectMixAudioInputs({
232
232
  modulePath: opts.modulePath,
233
233
  narration: opts.narration ?? "auto",
@@ -1,5 +1,5 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime.js";
2
- import { a as loadSceneModule } from "./render.js";
2
+ import { o as loadSceneModule } from "./render.js";
3
3
  import { existsSync, readFileSync } from "node:fs";
4
4
  import { splitCaption } from "@glissade/narrate";
5
5
  //#region src/narrationLint.ts
package/dist/prepare.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as loadSceneModule } from "./render.js";
1
+ import { o as loadSceneModule } from "./render.js";
2
2
  import { narrateCommand } from "./narrate.js";
3
3
  import { prepareSfx, sfxScriptPathFor } from "./sfx.js";
4
4
  import { existsSync } from "node:fs";
package/dist/render.js CHANGED
@@ -69,6 +69,7 @@ function validateAssetReferences(refs, declaredIds) {
69
69
  * when requested and available. No browser anywhere.
70
70
  */
71
71
  var render_exports = /* @__PURE__ */ __exportAll({
72
+ LocaleArgsError: () => LocaleArgsError,
72
73
  SceneModuleError: () => SceneModuleError,
73
74
  buildFontExemptSet: () => buildFontExemptSet,
74
75
  buildMixWav: () => buildMixWav,
@@ -76,9 +77,12 @@ var render_exports = /* @__PURE__ */ __exportAll({
76
77
  collectMixAudioInputs: () => collectMixAudioInputs,
77
78
  ffmpegAvailable: () => ffmpegAvailable,
78
79
  loadSceneModule: () => loadSceneModule,
80
+ localeOutPath: () => localeOutPath,
79
81
  parseFrameRange: () => parseFrameRange,
82
+ parseLocalesList: () => parseLocalesList,
80
83
  planFinalAudio: () => planFinalAudio,
81
84
  render: () => render,
85
+ renderLocales: () => renderLocales,
82
86
  resolveLoudnessGainDb: () => resolveLoudnessGainDb
83
87
  });
84
88
  /**
@@ -90,12 +94,91 @@ var render_exports = /* @__PURE__ */ __exportAll({
90
94
  * verdict depend on the host. The true-OS `osCatalog` is folded in ONLY when
91
95
  * `allowSystemFonts` is set AND `strict` is false — so --strict stays host-
92
96
  * independent regardless of the flag. Pure: no I/O, no host queries.
97
+ *
98
+ * 0.15 FIX 5 (osFamilies brand-warn gap): when an OS family name COLLIDES with a
99
+ * family glissade actually registered from `doc.assets`, the OS-catalog fold must
100
+ * NOT re-add it as an "OS-only" exemption — a registered family is a declared
101
+ * brand font that must stay subject to glyph-coverage validation, not be waved
102
+ * through as a system family. We therefore skip any osCatalog entry that collides
103
+ * with a registered family (the `registered` seed already carries it, and core's
104
+ * `validateFonts` runs coverage for registered families regardless of exemption).
105
+ * The exemption is for GENUINELY-OS-only families, never registered ones that
106
+ * happen to share a name.
93
107
  */
94
108
  function buildFontExemptSet(registered, opts) {
95
109
  const exempt = new Set(registered);
96
- if (opts.allowSystemFonts && !opts.strict) for (const f of opts.osCatalog) exempt.add(f);
110
+ if (opts.allowSystemFonts && !opts.strict) {
111
+ for (const f of opts.osCatalog) if (!registered.has(f)) exempt.add(f);
112
+ }
97
113
  return exempt;
98
114
  }
115
+ var LocaleArgsError = class extends Error {
116
+ constructor(detail) {
117
+ super(detail);
118
+ this.name = "LocaleArgsError";
119
+ }
120
+ };
121
+ /**
122
+ * Parse the `--locales <a,b,c>` comma-separated list into a de-duplicated,
123
+ * order-preserving array of locale codes. Empty / whitespace-only entries are
124
+ * dropped; an all-empty list is rejected (a `--locales` with nothing to render
125
+ * is a user error, not a silent no-op).
126
+ */
127
+ function parseLocalesList(raw) {
128
+ const seen = /* @__PURE__ */ new Set();
129
+ const out = [];
130
+ for (const part of raw.split(",")) {
131
+ const code = part.trim();
132
+ if (code === "" || seen.has(code)) continue;
133
+ seen.add(code);
134
+ out.push(code);
135
+ }
136
+ if (out.length === 0) throw new LocaleArgsError(`--locales is empty — pass a comma-separated list, e.g. --locales en,zh`);
137
+ return out;
138
+ }
139
+ /**
140
+ * Derive the per-locale output path from the base `out` and a locale code.
141
+ *
142
+ * Convention: a video/PNG FILE path (`out/episode.mp4`, `out/still.png`) gets a
143
+ * locale segment inserted before the extension → `out/episode.<locale>.mp4`. Any
144
+ * other `out` (a PNG-sequence DIRECTORY, the default `out`) gets a per-locale
145
+ * SUBDIR → `out/<locale>` (so `out/en/frame-00000.png`, `out/zh/frame-00000.png`).
146
+ * `--format png-seq` forces the directory convention even for a video-looking
147
+ * name, matching how `render()` itself treats `out` under that flag.
148
+ */
149
+ function localeOutPath(out, locale, format) {
150
+ const isVideoName = format !== "png-seq" && /\.(mp4|webm)$/i.test(out);
151
+ const isPngFile = format !== "png-seq" && /\.png$/i.test(out);
152
+ if (isVideoName || isPngFile) return out.replace(/(\.[^.]+)$/, `.${locale}$1`);
153
+ return join(out, locale);
154
+ }
155
+ /**
156
+ * §0.15 `--locales` fan-out: render the scene once per locale, writing one
157
+ * artifact per locale to a distinct per-locale path (`localeOutPath`). Each
158
+ * per-locale render IS the 0.14 single-`--locale` render — so `--locales en,zh`
159
+ * ≡ `--locale en` then `--locale zh` with distinct outputs.
160
+ *
161
+ * Fails LOUDLY: a locale in the list with no resolvable assets throws the 0.14
162
+ * `UnknownLocaleError` (naming the bad locale) from inside `render()`, aborting
163
+ * the whole fan-out — locales already rendered keep their artifacts, but the
164
+ * process exits non-zero so a missing-asset locale is never silently skipped.
165
+ */
166
+ async function renderLocales(opts, locales) {
167
+ const results = [];
168
+ for (const locale of locales) {
169
+ const result = await render({
170
+ ...opts,
171
+ out: localeOutPath(opts.out, locale, opts.format),
172
+ locale
173
+ });
174
+ results.push({
175
+ locale,
176
+ result
177
+ });
178
+ }
179
+ return results;
180
+ }
181
+ /** A scene module's default export is not a valid `SceneModule`. */
99
182
  var SceneModuleError = class extends Error {
100
183
  constructor(modulePath, detail) {
101
184
  super(`${modulePath}: ${detail}\nA scene module default-exports { createScene(): Scene, timeline: Timeline } (SceneModule).`);
@@ -455,23 +538,26 @@ async function collectAudioClips(opts, timelineClips) {
455
538
  * clips — the narration/music/sfx siblings still resolve from `modulePath`.
456
539
  */
457
540
  async function collectMixAudioInputs(opts, timelineClips) {
458
- let tl = timelineClips;
459
- if (tl === void 0) try {
460
- const mod = await loadSceneModule(opts.modulePath);
461
- const { compileTimeline } = await import("@glissade/core");
462
- tl = [...compileTimeline(mod.timeline).audio];
463
- } catch {
464
- tl = [];
465
- }
466
- const clips = await collectAudioClips(opts, tl);
467
- const { resolveAssetPath } = await import("./audioMix.js").then((n) => n.i);
468
- const paths = /* @__PURE__ */ new Set();
469
- for (const c of clips) try {
470
- paths.add(resolveAssetPath(c.asset.url, opts.modulePath));
471
- } catch {
472
- paths.add(c.asset.url);
473
- }
474
- return [...paths].sort();
541
+ const { preservingMessageTable } = await import("@glissade/core/i18n");
542
+ return preservingMessageTable(async () => {
543
+ let tl = timelineClips;
544
+ if (tl === void 0) try {
545
+ const mod = await loadSceneModule(opts.modulePath);
546
+ const { compileTimeline } = await import("@glissade/core");
547
+ tl = [...compileTimeline(mod.timeline).audio];
548
+ } catch {
549
+ tl = [];
550
+ }
551
+ const clips = await collectAudioClips(opts, tl);
552
+ const { resolveAssetPath } = await import("./audioMix.js").then((n) => n.i);
553
+ const paths = /* @__PURE__ */ new Set();
554
+ for (const c of clips) try {
555
+ paths.add(resolveAssetPath(c.asset.url, opts.modulePath));
556
+ } catch {
557
+ paths.add(c.asset.url);
558
+ }
559
+ return [...paths].sort();
560
+ });
475
561
  }
476
562
  /**
477
563
  * Resolve the committed publish gain (dB) for a render: read `<scene>.loudness.json`
@@ -548,43 +634,46 @@ async function planFinalAudio(opts, timelineClips, duration, container) {
548
634
  */
549
635
  async function buildMixWav(opts, wavOut) {
550
636
  if (!ffmpegAvailable()) throw new Error("gs measure-loudness needs FFmpeg on PATH and none was found.");
551
- const mod = await loadSceneModule(opts.modulePath);
552
- const scene = mod.createScene();
553
- const { compileTimeline } = await import("@glissade/core");
554
- const compiled = compileTimeline(mod.timeline);
555
- const duration = compiled.duration;
556
- const audioClips = await collectAudioClips(opts, [...compiled.audio]);
557
- const { planAudioMix } = await import("./audioMix.js").then((n) => n.i);
558
- const mix = planAudioMix(audioClips, opts.modulePath, duration);
559
- if (!mix) return false;
560
- const result = spawnSync("ffmpeg", [
561
- "-y",
562
- "-hide_banner",
563
- "-nostats",
564
- "-f",
565
- "lavfi",
566
- "-i",
567
- "anullsrc=channel_layout=stereo:sample_rate=48000",
568
- ...mix.inputs.flatMap((p) => ["-i", p]),
569
- "-filter_complex",
570
- mix.filterComplex,
571
- "-map",
572
- "[aout]",
573
- "-c:a",
574
- "pcm_s16le",
575
- "-ar",
576
- "48000",
577
- "-t",
578
- String(duration),
579
- wavOut
580
- ], { stdio: [
581
- "ignore",
582
- "ignore",
583
- "pipe"
584
- ] });
585
- if (result.status !== 0) throw new Error(`ffmpeg mix-to-WAV failed (exit ${result.status}):\n${result.stderr?.toString().slice(-2e3)}`);
586
- scene.size;
587
- return true;
637
+ const { preservingMessageTable } = await import("@glissade/core/i18n");
638
+ return preservingMessageTable(async () => {
639
+ const mod = await loadSceneModule(opts.modulePath);
640
+ const scene = mod.createScene();
641
+ const { compileTimeline } = await import("@glissade/core");
642
+ const compiled = compileTimeline(mod.timeline);
643
+ const duration = compiled.duration;
644
+ const audioClips = await collectAudioClips(opts, [...compiled.audio]);
645
+ const { planAudioMix } = await import("./audioMix.js").then((n) => n.i);
646
+ const mix = planAudioMix(audioClips, opts.modulePath, duration);
647
+ if (!mix) return false;
648
+ const result = spawnSync("ffmpeg", [
649
+ "-y",
650
+ "-hide_banner",
651
+ "-nostats",
652
+ "-f",
653
+ "lavfi",
654
+ "-i",
655
+ "anullsrc=channel_layout=stereo:sample_rate=48000",
656
+ ...mix.inputs.flatMap((p) => ["-i", p]),
657
+ "-filter_complex",
658
+ mix.filterComplex,
659
+ "-map",
660
+ "[aout]",
661
+ "-c:a",
662
+ "pcm_s16le",
663
+ "-ar",
664
+ "48000",
665
+ "-t",
666
+ String(duration),
667
+ wavOut
668
+ ], { stdio: [
669
+ "ignore",
670
+ "ignore",
671
+ "pipe"
672
+ ] });
673
+ if (result.status !== 0) throw new Error(`ffmpeg mix-to-WAV failed (exit ${result.status}):\n${result.stderr?.toString().slice(-2e3)}`);
674
+ scene.size;
675
+ return true;
676
+ });
588
677
  }
589
678
  //#endregion
590
- export { loadSceneModule as a, render as c, ffmpegAvailable as i, render_exports as l, buildMixWav as n, parseFrameRange as o, collectAudioClips as r, planFinalAudio as s, SceneModuleError as t, resolveLoudnessGainDb as u };
679
+ export { ffmpegAvailable as a, parseFrameRange as c, render as d, renderLocales as f, collectAudioClips as i, parseLocalesList as l, resolveLoudnessGainDb as m, SceneModuleError as n, loadSceneModule as o, render_exports as p, buildMixWav as r, localeOutPath as s, LocaleArgsError as t, planFinalAudio as u };
package/dist/shards.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime.js";
2
- import { s as planFinalAudio } from "./render.js";
2
+ import { u as planFinalAudio } from "./render.js";
3
3
  import { a as pickEncoder } from "./encoders.js";
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
@@ -1,4 +1,4 @@
1
- import { t as SceneModuleError } from "./render.js";
1
+ import { n as SceneModuleError } from "./render.js";
2
2
  import { a as splitFrameRange } from "./shards.js";
3
3
  import { readFileSync } from "node:fs";
4
4
  import { isAbsolute, resolve } from "node:path";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/cli",
3
- "version": "0.14.0-pre.1",
3
+ "version": "0.15.0-pre.0",
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.14.0-pre.1",
27
- "@glissade/core": "0.14.0-pre.1",
28
- "@glissade/interact": "0.14.0-pre.1",
29
- "@glissade/lottie": "0.14.0-pre.1",
30
- "@glissade/svg": "0.14.0-pre.1",
31
- "@glissade/narrate": "0.14.0-pre.1",
32
- "@glissade/player": "0.14.0-pre.1",
33
- "@glissade/scene": "0.14.0-pre.1",
34
- "@glissade/sfx": "0.14.0-pre.1"
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"
35
35
  },
36
36
  "repository": {
37
37
  "type": "git",