@glissade/cli 0.14.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.
@@ -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
  /**
@@ -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
 
@@ -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)
@@ -142,6 +147,9 @@ measure-loudness options (the explicit publish-loudness measure step; commits *.
142
147
  --profile <id> youtube (default) | shorts (both -14 LUFS) | podcast (-16) | broadcast/ebu (-23); all cap at -1 dBTP
143
148
  measures the final mix (ebur128) and commits a deterministic peak-clamped gain; render applies it
144
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.
145
153
 
146
154
  narrate options (the explicit TTS prepare step; render itself stays offline):
147
155
  --provider <id> fake | espeak | piper | kokoro | openai (default: the script's provider, else espeak)
@@ -289,12 +297,14 @@ async function main() {
289
297
  if (command === "measure-loudness") {
290
298
  const { measureLoudnessCommand } = await import("./loudness.js").then((n) => n.c);
291
299
  try {
300
+ const mlLocale = flags.get("locale");
292
301
  const result = await measureLoudnessCommand({
293
302
  modulePath,
294
303
  ...flags.has("profile") ? { profile: flags.get("profile") } : {},
295
304
  ...flags.get("narration") === "off" ? { narration: "off" } : {},
296
305
  ...flags.get("music") === "off" ? { music: "off" } : {},
297
- ...flags.get("sfx") === "off" ? { sfx: "off" } : {}
306
+ ...flags.get("sfx") === "off" ? { sfx: "off" } : {},
307
+ ...mlLocale !== void 0 && mlLocale !== "" ? { locale: mlLocale } : {}
298
308
  });
299
309
  const m = result.measurement;
300
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`);
@@ -443,10 +453,19 @@ async function main() {
443
453
  };
444
454
  }
445
455
  if (flags.has("watch")) process.stderr.write("note: --watch is not yet implemented in this release; rendering once\n");
456
+ let locales;
457
+ if (flags.has("locales") && flags.get("locales")) {
458
+ 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)");
459
+ try {
460
+ locales = parseLocalesList(flags.get("locales"));
461
+ } catch (err) {
462
+ fail(err instanceof LocaleArgsError ? err.message : err instanceof Error ? err.message : String(err));
463
+ }
464
+ }
446
465
  const fpsFlag = flags.get("fps");
447
466
  const started = performance.now();
448
467
  try {
449
- const result = await render({
468
+ const sharedOpts = {
450
469
  modulePath,
451
470
  out: flags.get("out") ?? "out",
452
471
  ...fpsFlag ? { fps: parseInt(fpsFlag, 10) } : {},
@@ -464,7 +483,6 @@ async function main() {
464
483
  ...flags.has("lossless-intermediate") ? { losslessIntermediate: true } : {},
465
484
  ...flags.has("allow-gpu-shards") ? { allowGpuShards: true } : {},
466
485
  ...cache !== void 0 ? { cache } : {},
467
- ...flags.has("locale") && flags.get("locale") ? { locale: flags.get("locale") } : {},
468
486
  captions: parseCaptionsModeOrFail(flags.get("captions")),
469
487
  narration: flags.get("narration") === "off" ? "off" : "auto",
470
488
  music: flags.get("music") === "off" ? "off" : "auto",
@@ -475,10 +493,21 @@ async function main() {
475
493
  if (n % 30 === 0 || n === total) process.stderr.write(`\rrendering ${n}/${total} frames`);
476
494
  } else if (n % 300 === 0 || n === total) process.stderr.write(`rendering ${n}/${total} frames\n`);
477
495
  }
478
- });
479
- const secs = ((performance.now() - started) / 1e3).toFixed(2);
496
+ };
480
497
  const cr = process.stderr.isTTY ? "\r" : "";
481
- process.stderr.write(`${cr}rendered ${result.frames} frames in ${secs}s → ${result.out}\n`);
498
+ if (locales) {
499
+ const fan = await renderLocales(sharedOpts, locales);
500
+ const secs = ((performance.now() - started) / 1e3).toFixed(2);
501
+ for (const { locale, result } of fan) process.stderr.write(`${cr}rendered [${locale}] ${result.frames} frames → ${result.out}\n`);
502
+ process.stderr.write(`done: ${fan.length} locale${fan.length === 1 ? "" : "s"} in ${secs}s\n`);
503
+ } else {
504
+ const result = await render({
505
+ ...sharedOpts,
506
+ ...flags.has("locale") && flags.get("locale") ? { locale: flags.get("locale") } : {}
507
+ });
508
+ const secs = ((performance.now() - started) / 1e3).toFixed(2);
509
+ process.stderr.write(`${cr}rendered ${result.frames} frames in ${secs}s → ${result.out}\n`);
510
+ }
482
511
  } catch (err) {
483
512
  fail(err instanceof Error ? err.message : String(err));
484
513
  }
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,63 @@ 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
+ * 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.
277
+ */
278
+ declare function renderLocales(opts: Omit<RenderOptions, 'locale'>, locales: readonly string[]): Promise<{
279
+ locale: string;
280
+ result: {
281
+ frames: number;
282
+ out: string;
283
+ };
284
+ }[]>;
285
+ /** A scene module's default export is not a valid `SceneModule`. */
231
286
  declare class SceneModuleError extends Error {
232
287
  constructor(modulePath: string, detail: string);
233
288
  }
@@ -276,8 +331,16 @@ declare function collectAudioClips(opts: Pick<RenderOptions, 'modulePath' | 'nar
276
331
  * `.wav`/music stem invalidates the measurement here too (the §5.3 stale-gain
277
332
  * gate). `timelineClips` defaults to the scene's compiled timeline audio when
278
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.
279
342
  */
280
- 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>;
281
344
  /**
282
345
  * Collect audio clips + auto-mixed siblings and plan the FFmpeg audio graph,
283
346
  * returning the `-i`/`-filter_complex`/`-map` argument fragments. Shared by the
@@ -303,7 +366,7 @@ declare function planFinalAudio(opts: RenderOptions, timelineClips: AudioClip[],
303
366
  * gain — measurement is of the un-gained mix), so the measured content is exactly
304
367
  * what render will later mix. Returns false when the scene has no audio.
305
368
  */
306
- 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>;
307
370
  //#endregion
308
371
  //#region src/loudness.d.ts
309
372
  /**
@@ -412,10 +475,18 @@ declare function peakClampBinds(profile: PublishProfile, inputI: number, inputTp
412
475
  * hash. The scene module path itself is excluded.
413
476
  */
414
477
  declare function computeMixHash(modulePath: string, extraInputs?: readonly string[]): string;
415
- /** `<module>.loudness.json` for a scene module. */
416
- 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;
417
488
  /** Read + validate a committed measurement, or null when none is committed. */
418
- declare function readLoudness(modulePath: string): LoudnessMeasurement | null;
489
+ declare function readLoudness(modulePath: string, locale?: string): LoudnessMeasurement | null;
419
490
  /**
420
491
  * Parse the JSON block ffmpeg's `loudnorm=print_format=json` prints to stderr.
421
492
  * It carries `input_i` / `input_tp` / `input_lra` (the measured loudness),
@@ -445,6 +516,13 @@ interface MeasureLoudnessOptions {
445
516
  narration?: 'auto' | 'off';
446
517
  music?: 'auto' | 'off';
447
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;
448
526
  }
449
527
  interface MeasureLoudnessResult {
450
528
  measurement: LoudnessMeasurement;
@@ -833,4 +911,4 @@ declare function cacheVerifyCommand(opts: CacheVerifyOptions): Promise<CacheVeri
833
911
  /** The @glissade/cli package version (the glissade VERSION for the cache key). */
834
912
  declare function glissadeVersion(): string;
835
913
  //#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 };
914
+ 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
@@ -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`);
@@ -214,7 +223,7 @@ function measureFile(audioPath) {
214
223
  */
215
224
  async function measureLoudnessCommand(opts) {
216
225
  const profile = resolveProfile(opts.profile ?? "youtube");
217
- const { buildMixWav } = await import("./render.js").then((n) => n.l);
226
+ const { buildMixWav } = await import("./render.js").then((n) => n.p);
218
227
  const tmp = mkdtempSync(join(tmpdir(), "glissade-loudness-"));
219
228
  try {
220
229
  const wavPath = join(tmp, "mix.wav");
@@ -222,17 +231,19 @@ 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);
229
239
  const clampBound = peakClampBinds(profile, inputI, inputTp);
230
- const { collectMixAudioInputs } = await import("./render.js").then((n) => n.l);
240
+ const { collectMixAudioInputs } = await import("./render.js").then((n) => n.p);
231
241
  const extraInputs = await collectMixAudioInputs({
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,
@@ -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,103 @@ 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
+ * 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.
170
+ */
171
+ async function renderLocales(opts, locales) {
172
+ const results = [];
173
+ for (const locale of locales) {
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
+ }
186
+ results.push({
187
+ locale,
188
+ result
189
+ });
190
+ }
191
+ return results;
192
+ }
193
+ /** A scene module's default export is not a valid `SceneModule`. */
99
194
  var SceneModuleError = class extends Error {
100
195
  constructor(modulePath, detail) {
101
196
  super(`${modulePath}: ${detail}\nA scene module default-exports { createScene(): Scene, timeline: Timeline } (SceneModule).`);
@@ -455,23 +550,26 @@ async function collectAudioClips(opts, timelineClips) {
455
550
  * clips — the narration/music/sfx siblings still resolve from `modulePath`.
456
551
  */
457
552
  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();
553
+ const { preservingMessageTable } = await import("@glissade/core/i18n");
554
+ return preservingMessageTable(async () => {
555
+ let tl = timelineClips;
556
+ if (tl === void 0) try {
557
+ const mod = await loadSceneModule(opts.modulePath, opts.locale);
558
+ const { compileTimeline } = await import("@glissade/core");
559
+ tl = [...compileTimeline(mod.timeline).audio];
560
+ } catch {
561
+ tl = [];
562
+ }
563
+ const clips = await collectAudioClips(opts, tl);
564
+ const { resolveAssetPath } = await import("./audioMix.js").then((n) => n.i);
565
+ const paths = /* @__PURE__ */ new Set();
566
+ for (const c of clips) try {
567
+ paths.add(resolveAssetPath(c.asset.url, opts.modulePath));
568
+ } catch {
569
+ paths.add(c.asset.url);
570
+ }
571
+ return [...paths].sort();
572
+ });
475
573
  }
476
574
  /**
477
575
  * Resolve the committed publish gain (dB) for a render: read `<scene>.loudness.json`
@@ -485,15 +583,34 @@ async function collectMixAudioInputs(opts, timelineClips) {
485
583
  * `.wav`/music stem invalidates the measurement here too (the §5.3 stale-gain
486
584
  * gate). `timelineClips` defaults to the scene's compiled timeline audio when
487
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.
488
594
  */
489
595
  async function resolveLoudnessGainDb(opts, timelineClips) {
490
596
  if ((opts.loudness ?? "auto") === "off") return null;
491
597
  const { readLoudness, computeMixHash, loudnessPathFor } = await import("./loudness.js").then((n) => n.c);
492
- const measurement = readLoudness(opts.modulePath);
493
- 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
+ }
494
607
  const extraInputs = await collectMixAudioInputs(opts, timelineClips);
495
608
  const actual = computeMixHash(opts.modulePath, extraInputs);
496
- 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
+ }
497
614
  return measurement.gain;
498
615
  }
499
616
  /**
@@ -548,43 +665,46 @@ async function planFinalAudio(opts, timelineClips, duration, container) {
548
665
  */
549
666
  async function buildMixWav(opts, wavOut) {
550
667
  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;
668
+ const { preservingMessageTable } = await import("@glissade/core/i18n");
669
+ return preservingMessageTable(async () => {
670
+ const mod = await loadSceneModule(opts.modulePath, opts.locale);
671
+ const scene = mod.createScene();
672
+ const { compileTimeline } = await import("@glissade/core");
673
+ const compiled = compileTimeline(mod.timeline);
674
+ const duration = compiled.duration;
675
+ const audioClips = await collectAudioClips(opts, [...compiled.audio]);
676
+ const { planAudioMix } = await import("./audioMix.js").then((n) => n.i);
677
+ const mix = planAudioMix(audioClips, opts.modulePath, duration);
678
+ if (!mix) return false;
679
+ const result = spawnSync("ffmpeg", [
680
+ "-y",
681
+ "-hide_banner",
682
+ "-nostats",
683
+ "-f",
684
+ "lavfi",
685
+ "-i",
686
+ "anullsrc=channel_layout=stereo:sample_rate=48000",
687
+ ...mix.inputs.flatMap((p) => ["-i", p]),
688
+ "-filter_complex",
689
+ mix.filterComplex,
690
+ "-map",
691
+ "[aout]",
692
+ "-c:a",
693
+ "pcm_s16le",
694
+ "-ar",
695
+ "48000",
696
+ "-t",
697
+ String(duration),
698
+ wavOut
699
+ ], { stdio: [
700
+ "ignore",
701
+ "ignore",
702
+ "pipe"
703
+ ] });
704
+ if (result.status !== 0) throw new Error(`ffmpeg mix-to-WAV failed (exit ${result.status}):\n${result.stderr?.toString().slice(-2e3)}`);
705
+ scene.size;
706
+ return true;
707
+ });
588
708
  }
589
709
  //#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 };
710
+ 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",
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.14.0",
27
- "@glissade/core": "0.14.0",
28
- "@glissade/interact": "0.14.0",
29
- "@glissade/lottie": "0.14.0",
30
- "@glissade/svg": "0.14.0",
31
- "@glissade/narrate": "0.14.0",
32
- "@glissade/player": "0.14.0",
33
- "@glissade/scene": "0.14.0",
34
- "@glissade/sfx": "0.14.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",