@glissade/cli 0.13.0 → 0.14.0-pre.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/captions.js CHANGED
@@ -21,8 +21,18 @@ function parseCaptionsMode(raw) {
21
21
  if (raw === "sidecar" || raw === "off") return raw;
22
22
  throw new Error(`--captions must be burn, sidecar, or off (got '${raw}')`);
23
23
  }
24
- /** `<module>.narration.timing.json`, when the scene has been narrated. */
25
- function timingPathFor(modulePath) {
24
+ /**
25
+ * `<module>.narration.timing.json`, when the scene has been narrated. With a
26
+ * `locale`, PREFER the locale-tagged sibling `<module>.<locale>.narration.timing.json`
27
+ * (0.14 localization core) and fall back to the base sibling when it is absent —
28
+ * so a locale that reuses the base narration still renders. No `locale` (the
29
+ * base path) resolves the base sibling, byte-identical to today.
30
+ */
31
+ function timingPathFor(modulePath, locale) {
32
+ if (locale !== void 0 && locale !== "") {
33
+ const localeCandidate = modulePath.replace(/\.[jt]sx?$/, "") + `.${locale}.narration.timing.json`;
34
+ if (existsSync(localeCandidate)) return localeCandidate;
35
+ }
26
36
  const candidate = modulePath.replace(/\.[jt]sx?$/, "") + ".narration.timing.json";
27
37
  return existsSync(candidate) ? candidate : null;
28
38
  }
package/dist/cli.js CHANGED
@@ -110,6 +110,8 @@ render options:
110
110
  (YouTube needs the 1st chapter at 0:00 — auto-anchored — and each chapter >= 10s; author cue ts accordingly)
111
111
  --chapters-kind <k[,k]> cue kinds that become VTT chapters (default: chapter); cues.json keeps all kinds
112
112
  --strict fail on an unregistered font family or an uncovered glyph (§3.6; default: warn)
113
+ --locale <code> resolve the scene against messages.<code>.json (node-id text + free-standing t() keys) and prefer
114
+ the <base>.<code>.narration.timing.json sibling (0.14). No --locale resolves the BASE files
113
115
 
114
116
  diff options (DisplayList diff vs a committed baseline — exits non-zero on any divergence):
115
117
  --at <t> time in SECONDS to evaluate the scene at (required)
@@ -458,6 +460,7 @@ async function main() {
458
460
  ...flags.has("lossless-intermediate") ? { losslessIntermediate: true } : {},
459
461
  ...flags.has("allow-gpu-shards") ? { allowGpuShards: true } : {},
460
462
  ...cache !== void 0 ? { cache } : {},
463
+ ...flags.has("locale") && flags.get("locale") ? { locale: flags.get("locale") } : {},
461
464
  captions: parseCaptionsModeOrFail(flags.get("captions")),
462
465
  narration: flags.get("narration") === "off" ? "off" : "auto",
463
466
  music: flags.get("music") === "off" ? "off" : "auto",
package/dist/index.d.ts CHANGED
@@ -200,6 +200,13 @@ interface RenderOptions {
200
200
  mode: CacheMode;
201
201
  maxSize?: number;
202
202
  };
203
+ /**
204
+ * --locale <code> (0.14 localization core): resolve the scene against a
205
+ * per-locale message table (`messages.<code>.json`) and prefer the
206
+ * locale-tagged narration sibling (`<base>.<code>.narration.timing.json`).
207
+ * Omitted (the base path) resolves the BASE files → byte-identical to today.
208
+ */
209
+ locale?: string;
203
210
  onProgress?: (frame: number, total: number) => void;
204
211
  }
205
212
  declare class SceneModuleError extends Error {
@@ -211,7 +218,7 @@ declare class SceneModuleError extends Error {
211
218
  * ranges are rejected, since a frame index is an integer.
212
219
  */
213
220
 
214
- declare function loadSceneModule(modulePath: string): Promise<SceneModule>;
221
+ declare function loadSceneModule(modulePath: string, locale?: string): Promise<SceneModule>;
215
222
  declare function ffmpegAvailable(): boolean;
216
223
  declare function render(opts: RenderOptions): Promise<{
217
224
  frames: number;
@@ -223,7 +230,7 @@ declare function render(opts: RenderOptions): Promise<{
223
230
  * render/shard path) and `buildMixWav` (the measure-loudness path), so the mix
224
231
  * CONTENT measured at commit-time is byte-for-byte the mix rendered later.
225
232
  */
226
- declare function collectAudioClips(opts: Pick<RenderOptions, 'modulePath' | 'narration' | 'music' | 'sfx'>, timelineClips: AudioClip[]): Promise<AudioClip[]>;
233
+ declare function collectAudioClips(opts: Pick<RenderOptions, 'modulePath' | 'narration' | 'music' | 'sfx' | 'locale'>, timelineClips: AudioClip[]): Promise<AudioClip[]>;
227
234
  /**
228
235
  * Resolve the ACTUAL audio files that feed the final mix (timeline clips +
229
236
  * auto-mixed narration/music/sfx), as absolute paths. This is what the mixHash
package/dist/locale.js ADDED
@@ -0,0 +1,30 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ //#region src/locale.ts
4
+ /**
5
+ * gs render --locale <code> (0.14 localization core): resolve the per-locale
6
+ * message table + the locale-tagged narration timing sibling for a scene module.
7
+ *
8
+ * The render-time half of @glissade/core/i18n. The base (no --locale) path
9
+ * resolves the BASE files (`messages.json` is NOT consulted, the base narration
10
+ * sibling is used) → byte-identical to today. Every locale is opt-in.
11
+ */
12
+ /**
13
+ * The per-locale message table filename, resolved RELATIVE to the scene module
14
+ * directory: `messages.<locale>.json`. DEFAULT convention; one-line change.
15
+ */
16
+ function messagesFileFor(modulePath, locale) {
17
+ return join(dirname(modulePath), `messages.${locale}.json`);
18
+ }
19
+ /**
20
+ * Load the message table for a locale, or `undefined` when no `messages.<locale>.json`
21
+ * exists (a locale with only node-id / narration text and no free-standing t()
22
+ * keys is valid). The base path never calls this.
23
+ */
24
+ function loadMessageTable(modulePath, locale) {
25
+ const file = messagesFileFor(modulePath, locale);
26
+ if (!existsSync(file)) return void 0;
27
+ return JSON.parse(readFileSync(file, "utf8"));
28
+ }
29
+ //#endregion
30
+ export { loadMessageTable };
package/dist/render.js CHANGED
@@ -9,6 +9,59 @@ import { createJiti } from "jiti";
9
9
  import { buildFontRegistry } from "@glissade/core";
10
10
  import { evaluate, validateSceneFonts, withDeterminismGuards } from "@glissade/scene";
11
11
  import { SkiaBackend } from "@glissade/backend-skia";
12
+ //#region src/assetValidation.ts
13
+ /**
14
+ * Walk a scene's node tree and collect every Image/Video asset reference. Pure
15
+ * (no playhead) — the asset id a node references is structural, not a function
16
+ * of time. `assetId` may be `undefined` at runtime (a JS caller that passed a
17
+ * `src` URL instead of an `assetId` — the common codegen mistake), even though
18
+ * scene's type says `string`; surfaced as-is.
19
+ */
20
+ function collectAssetReferences(root) {
21
+ const out = [];
22
+ const visit = (node) => {
23
+ const kind = node.constructor.assetKind;
24
+ if (kind !== void 0) out.push({
25
+ assetId: node.assetId,
26
+ kind,
27
+ nodeId: node.id
28
+ });
29
+ if (node.children) for (const child of node.children) visit(child);
30
+ };
31
+ visit(root);
32
+ return out;
33
+ }
34
+ /**
35
+ * An Image/Video node references an asset id that isn't declared in
36
+ * `timeline.assets`. Distinct from ColdAssetError (a declared-but-not-warmed
37
+ * asset) — this is an AUTHORING mistake caught before evaluation, and the
38
+ * message names the most common cause: passing a `src` URL instead of an
39
+ * `assetId` + a `timeline.assets` entry.
40
+ */
41
+ var UnknownAssetError = class extends Error {
42
+ assetId;
43
+ declared;
44
+ constructor(ref, declared) {
45
+ const id = ref.assetId;
46
+ const at = ref.nodeId !== void 0 ? ` on ${ref.kind} node '${ref.nodeId}'` : ` on an ${ref.kind} node`;
47
+ const declaredList = declared.length > 0 ? declared.join(", ") : "(none)";
48
+ super(`assetId ${id === void 0 ? "<undefined>" : `'${id}'`}${at} is not declared in timeline.assets (declared: ${declaredList}) — an Image/Video needs an \`assetId\` plus a \`timeline.assets\` entry { kind, url }, not a \`src\` URL; remote URLs are not fetched at render (§2.5 offline determinism)`);
49
+ this.name = "UnknownAssetError";
50
+ this.assetId = id;
51
+ this.declared = declared;
52
+ }
53
+ };
54
+ /**
55
+ * Pre-validate Image/Video asset references against the declared timeline asset
56
+ * ids. Throws an UnknownAssetError naming the real mistake when a referenced id
57
+ * is undefined or absent from `declaredIds`. A no-op when every reference is
58
+ * declared.
59
+ */
60
+ function validateAssetReferences(refs, declaredIds) {
61
+ const declared = new Set(declaredIds);
62
+ for (const ref of refs) if (ref.assetId === void 0 || !declared.has(ref.assetId)) throw new UnknownAssetError(ref, [...declared]);
63
+ }
64
+ //#endregion
12
65
  //#region src/render.ts
13
66
  /**
14
67
  * gs render (DESIGN.md §5.1d, §5.7): load a scene module, evaluate each frame,
@@ -46,7 +99,12 @@ function parseFrameRange(flag) {
46
99
  if (b < a) throw new Error(`--range end (${b}) is before start (${a})`);
47
100
  return [a, b];
48
101
  }
49
- async function loadSceneModule(modulePath) {
102
+ async function loadSceneModule(modulePath, locale) {
103
+ const { setMessageTable } = await import("@glissade/core/i18n");
104
+ if (locale !== void 0 && locale !== "") {
105
+ const { loadMessageTable } = await import("./locale.js");
106
+ setMessageTable(loadMessageTable(modulePath, locale));
107
+ } else setMessageTable(void 0);
50
108
  const abs = isAbsolute(modulePath) ? modulePath : resolve(process.cwd(), modulePath);
51
109
  const loaded = await createJiti(pathToFileURL(process.cwd() + "/").href).import(pathToFileURL(abs).href, { default: true });
52
110
  if (typeof loaded?.createScene !== "function" || loaded?.timeline === void 0) throw new SceneModuleError(modulePath, "default export is not a SceneModule");
@@ -56,7 +114,7 @@ function ffmpegAvailable() {
56
114
  return spawnSync("ffmpeg", ["-version"], { stdio: "ignore" }).status === 0;
57
115
  }
58
116
  async function render(opts) {
59
- const mod = await loadSceneModule(opts.modulePath);
117
+ const mod = await loadSceneModule(opts.modulePath, opts.locale);
60
118
  const scene = mod.createScene();
61
119
  const { resolveRenderDoc } = await import("./machines.js").then((n) => n.n);
62
120
  let doc = resolveRenderDoc(mod, scene, {
@@ -64,6 +122,14 @@ async function render(opts) {
64
122
  ...opts.state !== void 0 ? { state: opts.state } : {},
65
123
  ...opts.force !== void 0 ? { force: opts.force } : {}
66
124
  });
125
+ if (opts.locale !== void 0 && opts.locale !== "") {
126
+ const { loadMessageTable } = await import("./locale.js");
127
+ const table = loadMessageTable(opts.modulePath, opts.locale);
128
+ if (table) {
129
+ const { localize } = await import("@glissade/core/i18n");
130
+ doc = localize(doc, table, { locale: opts.locale });
131
+ }
132
+ }
67
133
  const fps = opts.fps ?? doc.fps ?? 60;
68
134
  const captionsMode = opts.captions ?? "burn";
69
135
  const { hideCaptionsDoc, timingPathFor, writeCaptionSidecars } = await import("./captions.js").then((n) => n.t);
@@ -113,6 +179,7 @@ async function render(opts) {
113
179
  const { loadYogaLayoutEngine } = await import("@glissade/scene/layout");
114
180
  await loadYogaLayoutEngine();
115
181
  }
182
+ validateAssetReferences(collectAssetReferences(scene.root), Object.keys(doc.assets ?? {}));
116
183
  const videoSources = [];
117
184
  const { resolveAssetPath: resolveAsset } = await import("./audioMix.js").then((n) => n.i);
118
185
  const { createHash } = await import("node:crypto");
@@ -139,6 +206,8 @@ async function render(opts) {
139
206
  }
140
207
  }
141
208
  }
209
+ const { GlobalFonts: GlobalFontsForValidation } = await import("@napi-rs/canvas");
210
+ const osFamilies = new Set(GlobalFontsForValidation.families.map((f) => f.family.toLowerCase()));
142
211
  await validateSceneFonts(scene, doc, async (url) => {
143
212
  try {
144
213
  const buf = await readFile(resolveAsset(url, opts.modulePath));
@@ -146,7 +215,10 @@ async function render(opts) {
146
215
  } catch {
147
216
  return;
148
217
  }
149
- }, { mode: opts.strictFonts ? "strict" : "dev" });
218
+ }, {
219
+ mode: opts.strictFonts ? "strict" : "dev",
220
+ osFamilies
221
+ });
150
222
  for (const [assetId, ref] of Object.entries(doc.assets ?? {})) if (ref.kind === "font") {} else if (ref.kind === "image") {
151
223
  const { loadImage } = await import("@napi-rs/canvas");
152
224
  const imgPath = resolveAsset(ref.url, opts.modulePath);
@@ -208,7 +280,7 @@ async function render(opts) {
208
280
  for (const source of videoSources) source.close();
209
281
  const emitSidecars = (target) => {
210
282
  if (captionsMode === "off") return;
211
- const timingPath = timingPathFor(opts.modulePath);
283
+ const timingPath = timingPathFor(opts.modulePath, opts.locale);
212
284
  if (!timingPath) {
213
285
  if (captionsMode === "sidecar") process.stderr.write("note: --captions sidecar: no narration timing manifest found; run gs narrate first\n");
214
286
  return;
@@ -305,7 +377,7 @@ async function collectAudioClips(opts, timelineClips) {
305
377
  const audioClips = [...timelineClips];
306
378
  const { bedAlreadyReferenced, buildMusicClip, buildNarrationClips, musicPathFor } = await import("./music.js");
307
379
  if ((opts.narration ?? "auto") === "auto") {
308
- const narrationPath = timingPathFor(opts.modulePath);
380
+ const narrationPath = timingPathFor(opts.modulePath, opts.locale);
309
381
  if (narrationPath) {
310
382
  const voice = buildNarrationClips(narrationPath);
311
383
  if (voice) if (voice.clips.some((c) => bedAlreadyReferenced(audioClips, c.asset.url, opts.modulePath))) process.stderr.write("note: narration already in the timeline audio — auto-mix skipped\n");
@@ -318,7 +390,7 @@ async function collectAudioClips(opts, timelineClips) {
318
390
  if ((opts.music ?? "auto") === "auto") {
319
391
  const musicPath = musicPathFor(opts.modulePath);
320
392
  if (musicPath) {
321
- const bed = buildMusicClip(musicPath, timingPathFor(opts.modulePath));
393
+ const bed = buildMusicClip(musicPath, timingPathFor(opts.modulePath, opts.locale));
322
394
  if (bed) if (bedAlreadyReferenced(audioClips, bed.clip.asset.url, opts.modulePath)) process.stderr.write("note: music bed already in the timeline audio — auto-mix skipped\n");
323
395
  else {
324
396
  audioClips.push(bed.clip);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/cli",
3
- "version": "0.13.0",
3
+ "version": "0.14.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.13.0",
27
- "@glissade/core": "0.13.0",
28
- "@glissade/interact": "0.13.0",
29
- "@glissade/lottie": "0.13.0",
30
- "@glissade/svg": "0.13.0",
31
- "@glissade/narrate": "0.13.0",
32
- "@glissade/player": "0.13.0",
33
- "@glissade/scene": "0.13.0",
34
- "@glissade/sfx": "0.13.0"
26
+ "@glissade/backend-skia": "0.14.0-pre.0",
27
+ "@glissade/core": "0.14.0-pre.0",
28
+ "@glissade/interact": "0.14.0-pre.0",
29
+ "@glissade/lottie": "0.14.0-pre.0",
30
+ "@glissade/svg": "0.14.0-pre.0",
31
+ "@glissade/narrate": "0.14.0-pre.0",
32
+ "@glissade/player": "0.14.0-pre.0",
33
+ "@glissade/scene": "0.14.0-pre.0",
34
+ "@glissade/sfx": "0.14.0-pre.0"
35
35
  },
36
36
  "repository": {
37
37
  "type": "git",