@glissade/cli 0.41.1 → 0.42.0-pre.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -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] [--strict] [--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 (re-localize CARRIES existing translations over — never clobbers); --strict refuses to write on a preflight failure
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,28 @@ 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
+ ...lf.has("strict") ? { strict: true } : {}
372
+ });
373
+ process.stdout.write(lf.has("json") ? `${JSON.stringify(report, null, 2)}\n` : `${formatLocalizeReport(report)}\n`);
374
+ if (report.preflight.issues.length > 0 && (!lf.has("write") || lf.has("strict"))) process.exit(1);
375
+ } catch (err) {
376
+ fail(err instanceof Error ? err.message : String(err));
377
+ }
378
+ return;
379
+ }
355
380
  const { positional, flags } = parseArgs(rest);
356
381
  const modulePath = positional[0];
357
382
  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,273 @@
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 existingText = /* @__PURE__ */ new Map();
36
+ for (const el of opts.existing?.segments ?? []) if (!isPause(el) && el.text !== "") existingText.set(el.id, el.text);
37
+ const segments = base.segments.map((el) => {
38
+ if (isPause(el)) return { ...el };
39
+ const carried = existingText.get(el.id);
40
+ const withText = carried !== void 0 && carried !== el.text ? {
41
+ ...el,
42
+ text: carried
43
+ } : { ...el };
44
+ if (opts.keepVoice) return withText;
45
+ const { voice: _voice, ...rest } = withText;
46
+ return rest;
47
+ });
48
+ const forked = {
49
+ ...base,
50
+ segments
51
+ };
52
+ if (!opts.keepVoice) delete forked.voice;
53
+ return forked;
54
+ }
55
+ /**
56
+ * Derive an authored-script skeleton from a COMMITTED timing manifest — the fork
57
+ * source when the base project kept only `<base>.narration.timing.json` (no
58
+ * authored `<base>.narration.json`). Maps each timed segment/pause back to its
59
+ * script element, preserving ids and text; drops the resolved timing (start/
60
+ * duration/file/words) which a re-narration of the new locale regenerates.
61
+ */
62
+ function scriptFromTiming(timing) {
63
+ const pauses = timing.pauses ?? [];
64
+ const els = [...timing.segments.map((s) => ({
65
+ start: s.start,
66
+ el: {
67
+ id: s.id,
68
+ text: s.text
69
+ }
70
+ })), ...pauses.map((p) => ({
71
+ start: p.start,
72
+ el: {
73
+ id: p.id,
74
+ pause: p.duration,
75
+ ...p.bed ? { bed: p.bed } : {}
76
+ }
77
+ }))];
78
+ els.sort((a, b) => a.start - b.start);
79
+ return {
80
+ narrationVersion: 1,
81
+ ...timing.captionSplit ? { captionSplit: timing.captionSplit } : {},
82
+ ...timing.captionMode ? { captionMode: timing.captionMode } : {},
83
+ ...timing.budgets ? { budgets: timing.budgets } : {},
84
+ segments: els.map((e) => e.el)
85
+ };
86
+ }
87
+ /**
88
+ * Build a translatable `messages.<locale>.json` stub for a set of `t()` ids. Each
89
+ * id maps to the base-locale string as a translate-from placeholder (so the
90
+ * translator sees the source), or `''` when no base value exists. Existing target
91
+ * translations are CARRIED OVER (a re-localize never blanks work already done —
92
+ * the translation-memory seed). Keys are sorted for a deterministic, diff-stable
93
+ * file. Pure.
94
+ */
95
+ function stubMessageTable(ids, opts = {}) {
96
+ const out = {};
97
+ for (const id of [...new Set(ids)].sort()) out[id] = opts.existing?.[id] ?? opts.base?.[id] ?? "";
98
+ return out;
99
+ }
100
+ /**
101
+ * Run the SAME checks the render path runs — `requireParity` across the base and
102
+ * new-locale id manifests, and a dry `localize()` of the scene document with the
103
+ * stub table — but as a NON-throwing report, so every drift surfaces at once
104
+ * before a minute of TTS instead of one-at-a-time at render. Pure over its loaded
105
+ * inputs (it catches the `ParityError`/`LocalizationError` the primitives throw).
106
+ */
107
+ function runLocalizePreflight(args) {
108
+ const issues = [];
109
+ try {
110
+ requireParity(args.baseManifest, args.localeManifest);
111
+ } catch (e) {
112
+ if (e instanceof ParityError) issues.push({
113
+ kind: "parity",
114
+ message: e.message
115
+ });
116
+ else throw e;
117
+ }
118
+ try {
119
+ localize(args.doc, args.stubTable, {
120
+ locale: args.locale,
121
+ ...args.consumedIds ? { consumedIds: args.consumedIds } : {}
122
+ });
123
+ } catch (e) {
124
+ if (e instanceof LocalizationError) issues.push({
125
+ kind: "localize",
126
+ message: e.message
127
+ });
128
+ else throw e;
129
+ }
130
+ const untranslated = Object.values(args.stubTable).filter((v) => v === "").length;
131
+ return {
132
+ locale: args.locale,
133
+ ids: [...args.localeManifest.ids].sort(),
134
+ untranslated,
135
+ issues,
136
+ ok: issues.length === 0
137
+ };
138
+ }
139
+ const nodeIdOf = (target) => {
140
+ const i = target.indexOf("/");
141
+ return i < 0 ? target : target.slice(0, i);
142
+ };
143
+ const moduleStemOf = (modulePath) => modulePath.replace(/\.[jt]sx?$/, "");
144
+ /** beat ids (segments + pauses, in order) of an authored script — the anchor id set. */
145
+ const scriptBeatIds = (script) => script.segments.map((e) => e.id);
146
+ /**
147
+ * Harvest the message-table keys a scene actually uses: every `t()` id (recorded
148
+ * by loading the scene under a table that treats every id as known — a Proxy whose
149
+ * `has` is always true, so no `t()` throws — then reading `getConsumedMessageIds`)
150
+ * plus every `type:'string'` track's node-id (the `localize()` keys). This is the
151
+ * one impure step (loading the module runs its side effects), quarantined here.
152
+ */
153
+ async function harvestMessageIds(modulePath) {
154
+ const recording = new Proxy({}, {
155
+ has: () => true,
156
+ get: (_t, prop) => typeof prop === "string" ? prop : void 0
157
+ });
158
+ const { getConsumedMessageIds } = await import("@glissade/core/i18n");
159
+ const { loadSceneModule } = await import("./render.js").then((n) => n.p);
160
+ const mod = await loadSceneModule(modulePath, void 0, recording);
161
+ mod.createScene();
162
+ const tIds = new Set(getConsumedMessageIds());
163
+ const ids = new Set(tIds);
164
+ const doc = mod.timeline;
165
+ for (const tr of doc.tracks ?? []) {
166
+ if (tr.type !== "string") continue;
167
+ if (new Set((tr.keys ?? []).map((k) => k.value)).size > 1) continue;
168
+ ids.add(nodeIdOf(tr.target));
169
+ }
170
+ return {
171
+ ids: [...ids],
172
+ tIds,
173
+ doc
174
+ };
175
+ }
176
+ /**
177
+ * Fork a scene's narration into a new locale + stub its message table, running the
178
+ * render path's parity + localize checks first. Dry-run by default (reports what it
179
+ * WOULD write); `--write` emits `<base>.<locale>.narration.json` +
180
+ * `messages.<locale>.json`. Never synthesizes audio or calls `evaluate()`.
181
+ */
182
+ async function localizeCommand(modulePath, opts) {
183
+ const { readFileSync, writeFileSync, existsSync } = await import("node:fs");
184
+ const { scriptPathFor } = await import("@glissade/narrate/providers");
185
+ const { messagesFileFor, loadMessageTable } = await import("./locale.js");
186
+ const { timingPathFor } = await import("./captions.js").then((n) => n.t);
187
+ const to = opts.to;
188
+ 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')`);
189
+ const { ids: messageIds, tIds, doc } = await harvestMessageIds(modulePath);
190
+ const scriptPath = scriptPathFor(modulePath);
191
+ const baseTimingPath = timingPathFor(modulePath);
192
+ let baseScript;
193
+ let narrationSource = "none";
194
+ if (existsSync(scriptPath)) {
195
+ baseScript = JSON.parse(readFileSync(scriptPath, "utf8"));
196
+ narrationSource = "script";
197
+ } else if (baseTimingPath && existsSync(baseTimingPath)) {
198
+ baseScript = scriptFromTiming(JSON.parse(readFileSync(baseTimingPath, "utf8")));
199
+ narrationSource = "timing";
200
+ }
201
+ const narrationPath = moduleStemOf(modulePath) + `.${to}.narration.json`;
202
+ const existingLocale = baseScript && existsSync(narrationPath) ? JSON.parse(readFileSync(narrationPath, "utf8")) : void 0;
203
+ const forked = baseScript ? forkNarrationScript(baseScript, {
204
+ keepVoice: opts.keepVoice ?? false,
205
+ ...existingLocale ? { existing: existingLocale } : {}
206
+ }) : void 0;
207
+ const beatIds = baseScript ? scriptBeatIds(baseScript) : [];
208
+ const localeBeatIds = existingLocale ? scriptBeatIds(existingLocale) : beatIds;
209
+ const carriedSegments = baseScript && forked ? forked.segments.filter((el, i) => !isPause(el) && el.text !== baseScript.segments[i].text).length : 0;
210
+ const base = opts.from ? loadMessageTable(modulePath, opts.from) : void 0;
211
+ const messagesPath = messagesFileFor(modulePath, to);
212
+ const existingTable = existsSync(messagesPath) ? JSON.parse(readFileSync(messagesPath, "utf8")) : loadMessageTable(modulePath, to);
213
+ const stubTable = stubMessageTable(messageIds, {
214
+ ...base ? { base } : {},
215
+ ...existingTable ? { existing: existingTable } : {}
216
+ });
217
+ const preflight = runLocalizePreflight({
218
+ locale: to,
219
+ baseManifest: {
220
+ locale: opts.from ?? "base",
221
+ ids: beatIds
222
+ },
223
+ localeManifest: {
224
+ locale: to,
225
+ ids: localeBeatIds
226
+ },
227
+ doc,
228
+ stubTable,
229
+ consumedIds: tIds
230
+ });
231
+ const wrote = [];
232
+ const refusedWrite = !!(opts.write && opts.strict && !preflight.ok);
233
+ if (opts.write && !refusedWrite) {
234
+ if (forked) {
235
+ writeFileSync(narrationPath, JSON.stringify(forked, null, 2) + "\n");
236
+ wrote.push(narrationPath);
237
+ }
238
+ if (messageIds.length > 0) {
239
+ writeFileSync(messagesPath, JSON.stringify(stubTable, null, 2) + "\n");
240
+ wrote.push(messagesPath);
241
+ }
242
+ }
243
+ return {
244
+ locale: to,
245
+ messageIds: messageIds.sort(),
246
+ beatIds,
247
+ carriedSegments,
248
+ preflight,
249
+ wrote,
250
+ refusedWrite,
251
+ narrationPath,
252
+ messagesPath,
253
+ narrationSource
254
+ };
255
+ }
256
+ /** Human-readable report (migrate.ts style). */
257
+ function formatLocalizeReport(r) {
258
+ const lines = [];
259
+ lines.push(`gs localize → ${r.locale}`);
260
+ lines.push(` narration: ${r.narrationSource === "none" ? "none found" : `${r.beatIds.length} beat(s) from the ${r.narrationSource} (ids preserved)`}` + (r.carriedSegments > 0 ? `; ${r.carriedSegments} translation(s) carried over` : ""));
261
+ lines.push(r.messageIds.length === 0 ? " messages: none localizable (no t() ids / no single-cue string tracks) — narration only" : ` messages: ${r.messageIds.length} id(s) harvested, ${r.preflight.untranslated} still untranslated`);
262
+ if (r.preflight.issues.length === 0) lines.push(" preflight: ✓ parity + localize clean (safe to translate + narrate)");
263
+ else {
264
+ lines.push(` preflight: ✗ ${r.preflight.issues.length} issue(s) — fix before narrating:`);
265
+ for (const i of r.preflight.issues) lines.push(` [${i.kind}] ${i.message.replace(/\n/g, "\n ")}`);
266
+ }
267
+ if (r.refusedWrite) lines.push(" --strict: refused to write (preflight failed); fix the drift above, then re-run");
268
+ else if (r.wrote.length) lines.push(` wrote: ${r.wrote.join(", ")}`);
269
+ else lines.push(` (dry run — re-run with --write to emit ${r.narrationPath}${r.messageIds.length ? ` + ${r.messagesPath}` : ""})`);
270
+ return lines.join("\n");
271
+ }
272
+ //#endregion
273
+ 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",
3
+ "version": "0.42.0-pre.1",
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",
32
- "@glissade/core": "0.41.1",
33
- "@glissade/interact": "0.41.1",
34
- "@glissade/lottie": "0.41.1",
35
- "@glissade/narrate": "0.41.1",
36
- "@glissade/player": "0.41.1",
37
- "@glissade/scene": "0.41.1",
38
- "@glissade/sfx": "0.41.1",
39
- "@glissade/svg": "0.41.1"
31
+ "@glissade/backend-skia": "0.42.0-pre.1",
32
+ "@glissade/core": "0.42.0-pre.1",
33
+ "@glissade/interact": "0.42.0-pre.1",
34
+ "@glissade/lottie": "0.42.0-pre.1",
35
+ "@glissade/narrate": "0.42.0-pre.1",
36
+ "@glissade/player": "0.42.0-pre.1",
37
+ "@glissade/scene": "0.42.0-pre.1",
38
+ "@glissade/sfx": "0.42.0-pre.1",
39
+ "@glissade/svg": "0.42.0-pre.1"
40
40
  },
41
41
  "repository": {
42
42
  "type": "git",