@glissade/cli 0.41.1-pre.0 → 0.42.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/cli.js CHANGED
@@ -22,7 +22,9 @@ const KNOWN_BOOLEAN_FLAGS = new Set([
22
22
  "verbose",
23
23
  "allow-degraded",
24
24
  "bisect",
25
- "watch"
25
+ "watch",
26
+ "write",
27
+ "keep-voice"
26
28
  ]);
27
29
  function parseArgs(argv) {
28
30
  const positional = [];
@@ -90,6 +92,7 @@ const USAGE = `usage:
90
92
  gs describe [--out <api.json>] [--examples] snapshot THIS engine's describe() API manifest (stdout, or --out to a file) — the input to gs migrate
91
93
  gs migrate <baseline-api.json> [--json] [--check] diff a saved API manifest against the current engine: moved imports / removed / added / changed, with a suggested fix per breaking item (advisory; --check exits non-zero on any breaking change for CI gating)
92
94
  gs repin <scene-module> --golden <dir> [--name <p>] [--frames a,b,..] [--fps <n>] [--since <ref>] [--write] [--only a,b] [--heatmap <dir>] [--floor <ssim>] [--force] narration-aware golden reviewer: render current vs committed goldens, report perceptual delta + the re-narration cause, re-pin only frames you allow (default dry-run; --floor refuses a bigger-than-expected drop)
95
+ gs localize <scene-module> --to <locale> [--from <locale>] [--write] [--keep-voice] [--json] fork a narration into a new locale (clone segment/pause structure, PRESERVING beat ids so .start() anchors survive) + stub messages.<locale>.json from the scene's t() ids, running the render path's parity + localize checks BEFORE any TTS. Default dry-run (exits non-zero on drift); --write emits <base>.<locale>.narration.json + messages.<locale>.json
93
96
  gs --version print the engine version
94
97
 
95
98
  render options:
@@ -191,7 +194,7 @@ async function main() {
191
194
  process.stdout.write(`${describe().version}\n`);
192
195
  return;
193
196
  }
194
- if (command !== "render" && command !== "diff" && command !== "verify-determinism" && command !== "dev" && command !== "import" && command !== "narrate" && command !== "narration-lint" && command !== "sfx" && command !== "prepare" && command !== "measure-loudness" && command !== "fonts" && command !== "cache" && command !== "mcp" && command !== "build" && command !== "describe" && command !== "migrate" && command !== "repin" && command !== "master") {
197
+ if (command !== "render" && command !== "diff" && command !== "verify-determinism" && command !== "dev" && command !== "import" && command !== "narrate" && command !== "narration-lint" && command !== "sfx" && command !== "prepare" && command !== "measure-loudness" && command !== "fonts" && command !== "cache" && command !== "mcp" && command !== "build" && command !== "describe" && command !== "migrate" && command !== "repin" && command !== "master" && command !== "localize") {
195
198
  console.error(USAGE);
196
199
  process.exit(command === void 0 || command === "help" || command === "--help" ? 0 : 1);
197
200
  }
@@ -352,6 +355,27 @@ async function main() {
352
355
  }
353
356
  return;
354
357
  }
358
+ if (command === "localize") {
359
+ const { positional: lp, flags: lf } = parseArgs(rest);
360
+ const sceneModule = lp[0];
361
+ if (!sceneModule) fail(`gs localize needs <scene-module>\n${USAGE}`);
362
+ const to = lf.get("to");
363
+ if (!to) fail(`gs localize needs --to <locale> (e.g. --to zh)\n${USAGE}`);
364
+ const { localizeCommand, formatLocalizeReport } = await import("./localize.js");
365
+ try {
366
+ const report = await localizeCommand(sceneModule, {
367
+ to,
368
+ ...lf.get("from") ? { from: lf.get("from") } : {},
369
+ ...lf.has("write") ? { write: true } : {},
370
+ ...lf.has("keep-voice") ? { keepVoice: true } : {}
371
+ });
372
+ process.stdout.write(lf.has("json") ? `${JSON.stringify(report, null, 2)}\n` : `${formatLocalizeReport(report)}\n`);
373
+ if (!lf.has("write") && report.preflight.issues.length > 0) process.exit(1);
374
+ } catch (err) {
375
+ fail(err instanceof Error ? err.message : String(err));
376
+ }
377
+ return;
378
+ }
355
379
  const { positional, flags } = parseArgs(rest);
356
380
  const modulePath = positional[0];
357
381
  if (!modulePath) fail(`missing ${command === "import" ? "<lottie.json|asset.svg>" : "<scene-module>"}\n${USAGE}`);
package/dist/index.d.ts CHANGED
@@ -2,6 +2,7 @@ import { AudioClip, CompiledTimeline, Key, Timeline } from "@glissade/core";
2
2
  import { DisplayList, Scene, SceneModule, VideoFrameSource } from "@glissade/scene";
3
3
  import { NarrationTiming } from "@glissade/narrate";
4
4
  import { Image } from "@napi-rs/canvas";
5
+ import * as _glissade_core_i18n0 from "@glissade/core/i18n";
5
6
 
6
7
  //#region src/frameCache.d.ts
7
8
 
@@ -497,7 +498,7 @@ declare class SceneModuleError extends Error {
497
498
  * ranges are rejected, since a frame index is an integer.
498
499
  */
499
500
 
500
- declare function loadSceneModule(modulePath: string, locale?: string): Promise<SceneModule>;
501
+ declare function loadSceneModule(modulePath: string, locale?: string, messageTableOverride?: _glissade_core_i18n0.MessageTable): Promise<SceneModule>;
501
502
  declare function ffmpegAvailable(): boolean;
502
503
  declare function render(opts: RenderOptions): Promise<{
503
504
  frames: number;
@@ -0,0 +1,252 @@
1
+ import { isPause } from "@glissade/narrate";
2
+ import { LocalizationError, ParityError, localize, requireParity } from "@glissade/core/i18n";
3
+ //#region src/localize.ts
4
+ /**
5
+ * `gs localize` (0.42) — the fork/translate/parity orchestrator. Forking a
6
+ * narration into a new locale by hand is where drift creeps in: you clone the
7
+ * script, re-author the timing, hand-write `messages.<locale>.json`, and only
8
+ * discover a missing beat id or an orphaned message when something THROWS deep in
9
+ * a render — after a minute of TTS. `gs localize <base> --to <locale>` does the
10
+ * mechanical fork up front and runs the SAME parity + localize checks the render
11
+ * path runs, BEFORE any synthesis: it clones the segment/pause structure keeping
12
+ * every beat id (so `.start('seg-x')` anchors survive), stubs a translatable
13
+ * `messages.<locale>.json` from the scene's `t()` ids, and reports every drift.
14
+ *
15
+ * This module is the PURE engine (fork + stub + preflight report). The CLI
16
+ * command (file IO, harvesting `t()` ids by loading the scene, `--write`) wraps it.
17
+ * Nothing here synthesizes audio, touches the render path, or calls `evaluate()`.
18
+ */
19
+ var LocalizeError = class extends Error {
20
+ constructor(message) {
21
+ super(message);
22
+ this.name = "LocalizeError";
23
+ }
24
+ };
25
+ /**
26
+ * Fork an authored narration script for a new locale: a structural clone that
27
+ * PRESERVES every segment/pause id (the beat ids `.start()`/`.end()` anchors and
28
+ * SFX/visual cues resolve against — they MUST survive so the translated locale
29
+ * stays anchor-compatible) and carries the source text through as a translate-me
30
+ * placeholder. Pure: the input is never mutated. The `voice` is dropped so the
31
+ * locale picks its own (an English voice on translated text is a mistake) unless
32
+ * `keepVoice` is set.
33
+ */
34
+ function forkNarrationScript(base, opts = {}) {
35
+ const segments = base.segments.map((el) => {
36
+ if (isPause(el)) return { ...el };
37
+ if (opts.keepVoice) return { ...el };
38
+ const { voice: _voice, ...rest } = el;
39
+ return rest;
40
+ });
41
+ const forked = {
42
+ ...base,
43
+ segments
44
+ };
45
+ if (!opts.keepVoice) delete forked.voice;
46
+ return forked;
47
+ }
48
+ /**
49
+ * Derive an authored-script skeleton from a COMMITTED timing manifest — the fork
50
+ * source when the base project kept only `<base>.narration.timing.json` (no
51
+ * authored `<base>.narration.json`). Maps each timed segment/pause back to its
52
+ * script element, preserving ids and text; drops the resolved timing (start/
53
+ * duration/file/words) which a re-narration of the new locale regenerates.
54
+ */
55
+ function scriptFromTiming(timing) {
56
+ const pauses = timing.pauses ?? [];
57
+ const els = [...timing.segments.map((s) => ({
58
+ start: s.start,
59
+ el: {
60
+ id: s.id,
61
+ text: s.text
62
+ }
63
+ })), ...pauses.map((p) => ({
64
+ start: p.start,
65
+ el: {
66
+ id: p.id,
67
+ pause: p.duration,
68
+ ...p.bed ? { bed: p.bed } : {}
69
+ }
70
+ }))];
71
+ els.sort((a, b) => a.start - b.start);
72
+ return {
73
+ narrationVersion: 1,
74
+ ...timing.captionSplit ? { captionSplit: timing.captionSplit } : {},
75
+ ...timing.captionMode ? { captionMode: timing.captionMode } : {},
76
+ ...timing.budgets ? { budgets: timing.budgets } : {},
77
+ segments: els.map((e) => e.el)
78
+ };
79
+ }
80
+ /**
81
+ * Build a translatable `messages.<locale>.json` stub for a set of `t()` ids. Each
82
+ * id maps to the base-locale string as a translate-from placeholder (so the
83
+ * translator sees the source), or `''` when no base value exists. Existing target
84
+ * translations are CARRIED OVER (a re-localize never blanks work already done —
85
+ * the translation-memory seed). Keys are sorted for a deterministic, diff-stable
86
+ * file. Pure.
87
+ */
88
+ function stubMessageTable(ids, opts = {}) {
89
+ const out = {};
90
+ for (const id of [...new Set(ids)].sort()) out[id] = opts.existing?.[id] ?? opts.base?.[id] ?? "";
91
+ return out;
92
+ }
93
+ /**
94
+ * Run the SAME checks the render path runs — `requireParity` across the base and
95
+ * new-locale id manifests, and a dry `localize()` of the scene document with the
96
+ * stub table — but as a NON-throwing report, so every drift surfaces at once
97
+ * before a minute of TTS instead of one-at-a-time at render. Pure over its loaded
98
+ * inputs (it catches the `ParityError`/`LocalizationError` the primitives throw).
99
+ */
100
+ function runLocalizePreflight(args) {
101
+ const issues = [];
102
+ try {
103
+ requireParity(args.baseManifest, args.localeManifest);
104
+ } catch (e) {
105
+ if (e instanceof ParityError) issues.push({
106
+ kind: "parity",
107
+ message: e.message
108
+ });
109
+ else throw e;
110
+ }
111
+ try {
112
+ localize(args.doc, args.stubTable, {
113
+ locale: args.locale,
114
+ ...args.consumedIds ? { consumedIds: args.consumedIds } : {}
115
+ });
116
+ } catch (e) {
117
+ if (e instanceof LocalizationError) issues.push({
118
+ kind: "localize",
119
+ message: e.message
120
+ });
121
+ else throw e;
122
+ }
123
+ const untranslated = Object.values(args.stubTable).filter((v) => v === "").length;
124
+ return {
125
+ locale: args.locale,
126
+ ids: [...args.localeManifest.ids].sort(),
127
+ untranslated,
128
+ issues,
129
+ ok: issues.length === 0
130
+ };
131
+ }
132
+ const nodeIdOf = (target) => {
133
+ const i = target.indexOf("/");
134
+ return i < 0 ? target : target.slice(0, i);
135
+ };
136
+ const moduleStemOf = (modulePath) => modulePath.replace(/\.[jt]sx?$/, "");
137
+ /** beat ids (segments + pauses, in order) of an authored script — the anchor id set. */
138
+ const scriptBeatIds = (script) => script.segments.map((e) => e.id);
139
+ /**
140
+ * Harvest the message-table keys a scene actually uses: every `t()` id (recorded
141
+ * by loading the scene under a table that treats every id as known — a Proxy whose
142
+ * `has` is always true, so no `t()` throws — then reading `getConsumedMessageIds`)
143
+ * plus every `type:'string'` track's node-id (the `localize()` keys). This is the
144
+ * one impure step (loading the module runs its side effects), quarantined here.
145
+ */
146
+ async function harvestMessageIds(modulePath) {
147
+ const recording = new Proxy({}, {
148
+ has: () => true,
149
+ get: (_t, prop) => typeof prop === "string" ? prop : void 0
150
+ });
151
+ const { getConsumedMessageIds } = await import("@glissade/core/i18n");
152
+ const { loadSceneModule } = await import("./render.js").then((n) => n.p);
153
+ const mod = await loadSceneModule(modulePath, void 0, recording);
154
+ mod.createScene();
155
+ const tIds = new Set(getConsumedMessageIds());
156
+ const ids = new Set(tIds);
157
+ const doc = mod.timeline;
158
+ for (const tr of doc.tracks ?? []) if (tr.type === "string") ids.add(nodeIdOf(tr.target));
159
+ return {
160
+ ids: [...ids],
161
+ tIds,
162
+ doc
163
+ };
164
+ }
165
+ /**
166
+ * Fork a scene's narration into a new locale + stub its message table, running the
167
+ * render path's parity + localize checks first. Dry-run by default (reports what it
168
+ * WOULD write); `--write` emits `<base>.<locale>.narration.json` +
169
+ * `messages.<locale>.json`. Never synthesizes audio or calls `evaluate()`.
170
+ */
171
+ async function localizeCommand(modulePath, opts) {
172
+ const { readFileSync, writeFileSync, existsSync } = await import("node:fs");
173
+ const { scriptPathFor } = await import("@glissade/narrate/providers");
174
+ const { messagesFileFor, loadMessageTable } = await import("./locale.js");
175
+ const { timingPathFor } = await import("./captions.js").then((n) => n.t);
176
+ const to = opts.to;
177
+ if (!/^[a-z]{2}(-[A-Za-z0-9]+)*$/.test(to)) throw new LocalizeError(`--to '${to}' is not a locale code (expected e.g. 'zh', 'pt-BR')`);
178
+ const { ids: messageIds, tIds, doc } = await harvestMessageIds(modulePath);
179
+ const scriptPath = scriptPathFor(modulePath);
180
+ const baseTimingPath = timingPathFor(modulePath);
181
+ let baseScript;
182
+ let narrationSource = "none";
183
+ if (existsSync(scriptPath)) {
184
+ baseScript = JSON.parse(readFileSync(scriptPath, "utf8"));
185
+ narrationSource = "script";
186
+ } else if (baseTimingPath && existsSync(baseTimingPath)) {
187
+ baseScript = scriptFromTiming(JSON.parse(readFileSync(baseTimingPath, "utf8")));
188
+ narrationSource = "timing";
189
+ }
190
+ const forked = baseScript ? forkNarrationScript(baseScript, { keepVoice: opts.keepVoice ?? false }) : void 0;
191
+ const beatIds = baseScript ? scriptBeatIds(baseScript) : [];
192
+ const narrationPath = moduleStemOf(modulePath) + `.${to}.narration.json`;
193
+ let localeBeatIds = beatIds;
194
+ if (existsSync(narrationPath)) localeBeatIds = scriptBeatIds(JSON.parse(readFileSync(narrationPath, "utf8")));
195
+ const base = opts.from ? loadMessageTable(modulePath, opts.from) : void 0;
196
+ const messagesPath = messagesFileFor(modulePath, to);
197
+ const existingTable = existsSync(messagesPath) ? JSON.parse(readFileSync(messagesPath, "utf8")) : loadMessageTable(modulePath, to);
198
+ const stubTable = stubMessageTable(messageIds, {
199
+ ...base ? { base } : {},
200
+ ...existingTable ? { existing: existingTable } : {}
201
+ });
202
+ const preflight = runLocalizePreflight({
203
+ locale: to,
204
+ baseManifest: {
205
+ locale: opts.from ?? "base",
206
+ ids: beatIds
207
+ },
208
+ localeManifest: {
209
+ locale: to,
210
+ ids: localeBeatIds
211
+ },
212
+ doc,
213
+ stubTable,
214
+ consumedIds: tIds
215
+ });
216
+ const wrote = [];
217
+ if (opts.write) {
218
+ if (forked) {
219
+ writeFileSync(narrationPath, JSON.stringify(forked, null, 2) + "\n");
220
+ wrote.push(narrationPath);
221
+ }
222
+ writeFileSync(messagesPath, JSON.stringify(stubTable, null, 2) + "\n");
223
+ wrote.push(messagesPath);
224
+ }
225
+ return {
226
+ locale: to,
227
+ messageIds: messageIds.sort(),
228
+ beatIds,
229
+ preflight,
230
+ wrote,
231
+ narrationPath,
232
+ messagesPath,
233
+ narrationSource
234
+ };
235
+ }
236
+ /** Human-readable report (migrate.ts style). */
237
+ function formatLocalizeReport(r) {
238
+ const lines = [];
239
+ lines.push(`gs localize → ${r.locale}`);
240
+ lines.push(` narration: ${r.narrationSource === "none" ? "none found" : `${r.beatIds.length} beat(s) from the ${r.narrationSource} (ids preserved)`}`);
241
+ lines.push(` messages: ${r.messageIds.length} id(s) harvested, ${r.preflight.untranslated} still untranslated`);
242
+ if (r.preflight.issues.length === 0) lines.push(" preflight: ✓ parity + localize clean (safe to translate + narrate)");
243
+ else {
244
+ lines.push(` preflight: ✗ ${r.preflight.issues.length} issue(s) — fix before narrating:`);
245
+ for (const i of r.preflight.issues) lines.push(` [${i.kind}] ${i.message.replace(/\n/g, "\n ")}`);
246
+ }
247
+ if (r.wrote.length) lines.push(` wrote: ${r.wrote.join(", ")}`);
248
+ else lines.push(` (dry run — re-run with --write to emit ${r.narrationPath} + ${r.messagesPath})`);
249
+ return lines.join("\n");
250
+ }
251
+ //#endregion
252
+ export { formatLocalizeReport, localizeCommand };
package/dist/render.js CHANGED
@@ -334,9 +334,10 @@ function warnOnVersionSkew(scenePath) {
334
334
  if (coreVer === void 0 || coreVer === cliVer) return;
335
335
  process.stderr.write(`warning: @glissade version skew — gs (@glissade/cli@${cliVer}) is rendering a scene that resolves @glissade/core@${coreVer}.\n glissade is LOCKSTEP: subpath features register per-package-instance (the /expr sampler, Yoga layout), so under a skew a\n correctly-imported '@glissade/core/expr' or 'layout' can still fail with "need import" / "no LayoutEngine registered".\n Align every @glissade/* dependency to ${cliVer} (e.g. npm i @glissade/core@${cliVer} @glissade/scene@${cliVer}).\n`);
336
336
  }
337
- async function loadSceneModule(modulePath, locale) {
337
+ async function loadSceneModule(modulePath, locale, messageTableOverride) {
338
338
  const { setMessageTable } = await import("@glissade/core/i18n");
339
- if (locale !== void 0 && locale !== "") {
339
+ if (messageTableOverride !== void 0) setMessageTable(messageTableOverride);
340
+ else if (locale !== void 0 && locale !== "") {
340
341
  const { loadMessageTable } = await import("./locale.js");
341
342
  setMessageTable(loadMessageTable(modulePath, locale));
342
343
  } else setMessageTable(void 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/cli",
3
- "version": "0.41.1-pre.0",
3
+ "version": "0.42.0-pre.0",
4
4
  "description": "glissade CLI: headless rendering via backend-skia (+ FFmpeg mux).",
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
@@ -28,15 +28,15 @@
28
28
  "@napi-rs/canvas": "^0.1.65",
29
29
  "esbuild": "0.28.0",
30
30
  "jiti": "^2.4.2",
31
- "@glissade/backend-skia": "0.41.1-pre.0",
32
- "@glissade/core": "0.41.1-pre.0",
33
- "@glissade/interact": "0.41.1-pre.0",
34
- "@glissade/lottie": "0.41.1-pre.0",
35
- "@glissade/narrate": "0.41.1-pre.0",
36
- "@glissade/player": "0.41.1-pre.0",
37
- "@glissade/scene": "0.41.1-pre.0",
38
- "@glissade/sfx": "0.41.1-pre.0",
39
- "@glissade/svg": "0.41.1-pre.0"
31
+ "@glissade/backend-skia": "0.42.0-pre.0",
32
+ "@glissade/core": "0.42.0-pre.0",
33
+ "@glissade/interact": "0.42.0-pre.0",
34
+ "@glissade/lottie": "0.42.0-pre.0",
35
+ "@glissade/narrate": "0.42.0-pre.0",
36
+ "@glissade/player": "0.42.0-pre.0",
37
+ "@glissade/scene": "0.42.0-pre.0",
38
+ "@glissade/sfx": "0.42.0-pre.0",
39
+ "@glissade/svg": "0.42.0-pre.0"
40
40
  },
41
41
  "repository": {
42
42
  "type": "git",