@glissade/cli 0.4.5 → 0.5.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
@@ -43,7 +43,7 @@ const USAGE = `usage:
43
43
  gs render <scene-module> [options]
44
44
  gs dev <scene-module> [--record] [--port <n>]
45
45
  gs import <lottie.json> [--out <dir>] [--allow-degraded]
46
- gs narrate <scene-module|script.narration.json> [--provider <id>] [--force]
46
+ gs narrate <scene-module|script.narration.json> [--provider <id>] [--align <id>] [--force]
47
47
 
48
48
  render options:
49
49
  --out <path> output directory for a PNG sequence, or .mp4/.webm (needs ffmpeg). default: ./out
@@ -53,6 +53,7 @@ render options:
53
53
  --state <name> render one machine state's timeline linearly
54
54
  --force downgrade a trace hash mismatch to a warning
55
55
  --captions <m> burn (default) | sidecar | off; burn/sidecar also write .srt/.vtt
56
+ --narration <m> auto (default): mix the voice from a sibling *.narration.timing.json | off
56
57
  --music <m> auto (default): mix a sibling *.music.timing.json bed, ducked under narration | off
57
58
 
58
59
  dev options:
@@ -64,7 +65,8 @@ import options:
64
65
  --allow-degraded downgrade degradable rejections (expressions, merge-paths modes != 1) to warnings
65
66
 
66
67
  narrate options (the explicit TTS prepare step; render itself stays offline):
67
- --provider <id> fake | espeak | openai (default: the script's provider, else espeak)
68
+ --provider <id> fake | espeak | piper | openai (default: the script's provider, else espeak)
69
+ --align <id> heuristic (default) | vosk | none — word timings for providers that emit none
68
70
  --force ignore the cache and re-synthesize every segment
69
71
  `;
70
72
  async function main() {
@@ -82,9 +84,14 @@ async function main() {
82
84
  const result = await narrateCommand({
83
85
  input: modulePath,
84
86
  ...flags.has("provider") ? { provider: flags.get("provider") } : {},
87
+ ...flags.has("align") ? { aligner: flags.get("align") } : {},
85
88
  ...flags.has("force") ? { force: true } : {}
86
89
  });
87
- const parts = [result.synthesized.length > 0 ? `synthesized ${result.synthesized.join(", ")}` : null, result.reused.length > 0 ? `reused ${result.reused.length} cached` : null].filter(Boolean);
90
+ const parts = [
91
+ result.synthesized.length > 0 ? `synthesized ${result.synthesized.join(", ")}` : null,
92
+ result.reused.length > 0 ? `reused ${result.reused.length} cached` : null,
93
+ result.aligned.length > 0 ? `aligned ${result.aligned.length} via ${result.aligner}` : null
94
+ ].filter(Boolean);
88
95
  process.stderr.write(`gs narrate: ${parts.join("; ") || "nothing to do"} → ${result.timingPath}\n`);
89
96
  } catch (err) {
90
97
  fail(err instanceof Error ? err.message : String(err));
@@ -136,6 +143,7 @@ async function main() {
136
143
  ...flags.has("state") ? { state: flags.get("state") } : {},
137
144
  ...flags.has("force") ? { force: true } : {},
138
145
  captions: parseCaptionsModeOrFail(flags.get("captions")),
146
+ narration: flags.get("narration") === "off" ? "off" : "auto",
139
147
  music: flags.get("music") === "off" ? "off" : "auto",
140
148
  onProgress: (n, total) => {
141
149
  if (process.stderr.isTTY) {
package/dist/index.d.ts CHANGED
@@ -20,6 +20,8 @@ interface RenderOptions {
20
20
  captions?: 'burn' | 'sidecar' | 'off';
21
21
  /** auto (default): mix a sibling *.music.timing.json bed, ducked under narration. */
22
22
  music?: 'auto' | 'off';
23
+ /** auto (default): mix the voice from a sibling *.narration.timing.json. */
24
+ narration?: 'auto' | 'off';
23
25
  onProgress?: (frame: number, total: number) => void;
24
26
  }
25
27
  declare class SceneModuleError extends Error {
package/dist/music.js CHANGED
@@ -1,13 +1,15 @@
1
1
  import { o as resolveAssetPath } from "./audioMix.js";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
- import { music, validateMusicTiming } from "@glissade/narrate";
3
+ import { basename } from "node:path";
4
+ import { music, narration, validateMusicTiming } from "@glissade/narrate";
4
5
  //#region src/music.ts
5
6
  /**
6
- * Render-time music auto-mix (the narration parity): a committed
7
- * `<module>.music.timing.json` with a `stem` field next to the scene mixes
8
- * its bed automatically and when a narration timing manifest ALSO sits
9
- * there, the bed auto-ducks under the voice. Zero-config
10
- * narrated-explainer-with-bed; everything derives from the two manifests.
7
+ * Render-time audio auto-mix. Sibling manifests next to a scene join the
8
+ * timeline mix automatically: the NARRATION voice (`*.narration.timing.json`)
9
+ * and the MUSIC bed (`*.music.timing.json` with a `stem`), the bed
10
+ * auto-ducked under the voice. Scene + manifests → finished mp4, no
11
+ * hand-wired `timeline.audio`. Author-wired clips are detected and not
12
+ * doubled.
11
13
  */
12
14
  /** `<module>.music.timing.json`, when the scene has a music bed. */
13
15
  function musicPathFor(modulePath) {
@@ -15,6 +17,24 @@ function musicPathFor(modulePath) {
15
17
  return existsSync(candidate) ? candidate : null;
16
18
  }
17
19
  /**
20
+ * Build the narration voice clips from a sibling timing manifest — the half
21
+ * of audio auto-mix that 0.4.x's "music parity" was missing (music mixed,
22
+ * narration didn't). Clip urls stay relative to the cache dir, which sits
23
+ * next to the manifest (and the module), so the mixer resolves them against
24
+ * the module dir.
25
+ */
26
+ function buildNarrationClips(timingPath) {
27
+ const timing = JSON.parse(readFileSync(timingPath, "utf8"));
28
+ if (!timing.segments || timing.segments.length === 0) return null;
29
+ const cacheBase = basename(timingPath).replace(/\.narration\.timing\.json$/, "") + ".narration-cache";
30
+ const clips = narration(timing).clips(cacheBase);
31
+ const n = timing.segments.length;
32
+ return {
33
+ clips,
34
+ note: `narration (${n} ${n === 1 ? "segment" : "segments"})`
35
+ };
36
+ }
37
+ /**
18
38
  * Build the bed clip from the manifests. The stem path stays relative — the
19
39
  * manifest sits next to the module, and the mixer resolves clip urls against
20
40
  * the module dir, so the bases coincide.
@@ -37,19 +57,20 @@ function buildMusicClip(musicManifestPath, narrationTimingPath) {
37
57
  };
38
58
  }
39
59
  /**
40
- * True when the timeline's audio already references the bed stem (same
41
- * resolved file) — auto-mix must then SKIP, or the bed plays twice and adds
42
- * +6dB of coherent doubling (measured downstream before this guard existed).
60
+ * True when the timeline's audio already references a file (same resolved
61
+ * path) — auto-mix must then SKIP it, or the source plays twice: a coherent
62
+ * duplicate adds +6dB (measured downstream before this guard existed).
63
+ * Used for both the bed and the narration clips.
43
64
  */
44
- function bedAlreadyReferenced(clips, bedUrl, modulePath) {
45
- const bedPath = resolveAssetPath(bedUrl, modulePath);
65
+ function bedAlreadyReferenced(clips, url, modulePath) {
66
+ const target = resolveAssetPath(url, modulePath);
46
67
  return clips.some((c) => {
47
68
  try {
48
- return resolveAssetPath(c.asset.url, modulePath) === bedPath;
69
+ return resolveAssetPath(c.asset.url, modulePath) === target;
49
70
  } catch {
50
71
  return false;
51
72
  }
52
73
  });
53
74
  }
54
75
  //#endregion
55
- export { bedAlreadyReferenced, buildMusicClip, musicPathFor };
76
+ export { bedAlreadyReferenced, buildMusicClip, buildNarrationClips, musicPathFor };
package/dist/narrate.js CHANGED
@@ -8,12 +8,15 @@ import { scriptPathFor, synthesizeScript } from "@glissade/narrate/providers";
8
8
  async function narrateCommand(opts) {
9
9
  const result = await synthesizeScript(scriptPathFor(opts.input), {
10
10
  ...opts.provider !== void 0 ? { provider: opts.provider } : {},
11
+ ...opts.aligner !== void 0 ? { aligner: opts.aligner } : {},
11
12
  ...opts.force !== void 0 ? { force: opts.force } : {}
12
13
  });
13
14
  return {
14
15
  timingPath: result.timingPath,
15
16
  synthesized: result.synthesized,
16
- reused: result.reused
17
+ reused: result.reused,
18
+ aligned: result.aligned,
19
+ aligner: result.aligner
17
20
  };
18
21
  }
19
22
  //#endregion
package/dist/render.js CHANGED
@@ -131,15 +131,28 @@ async function render(opts) {
131
131
  ]
132
132
  ];
133
133
  const audioClips = [...compiled.audio];
134
- if ((opts.music ?? "auto") === "auto") {
135
- const { bedAlreadyReferenced, buildMusicClip, musicPathFor } = await import("./music.js");
136
- const musicPath = musicPathFor(opts.modulePath);
137
- if (musicPath) {
138
- const bed = buildMusicClip(musicPath, timingPathFor(opts.modulePath));
139
- 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");
140
- else {
141
- audioClips.push(bed.clip);
142
- process.stderr.write(`note: auto-mixing ${bed.note}\n`);
134
+ {
135
+ const { bedAlreadyReferenced, buildMusicClip, buildNarrationClips, musicPathFor } = await import("./music.js");
136
+ if ((opts.narration ?? "auto") === "auto") {
137
+ const narrationPath = timingPathFor(opts.modulePath);
138
+ if (narrationPath) {
139
+ const voice = buildNarrationClips(narrationPath);
140
+ 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");
141
+ else {
142
+ audioClips.push(...voice.clips);
143
+ process.stderr.write(`note: auto-mixing ${voice.note}\n`);
144
+ }
145
+ }
146
+ }
147
+ if ((opts.music ?? "auto") === "auto") {
148
+ const musicPath = musicPathFor(opts.modulePath);
149
+ if (musicPath) {
150
+ const bed = buildMusicClip(musicPath, timingPathFor(opts.modulePath));
151
+ 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");
152
+ else {
153
+ audioClips.push(bed.clip);
154
+ process.stderr.write(`note: auto-mixing ${bed.note}\n`);
155
+ }
143
156
  }
144
157
  }
145
158
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/cli",
3
- "version": "0.4.5",
3
+ "version": "0.5.0-pre.0",
4
4
  "description": "glissade CLI: headless rendering via backend-skia (+ FFmpeg mux).",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -20,13 +20,13 @@
20
20
  "@napi-rs/canvas": "^0.1.65",
21
21
  "esbuild": "^0.28.0",
22
22
  "jiti": "^2.4.2",
23
- "@glissade/backend-skia": "0.4.5",
24
- "@glissade/core": "0.4.5",
25
- "@glissade/interact": "0.4.5",
26
- "@glissade/lottie": "0.4.5",
27
- "@glissade/narrate": "0.4.5",
28
- "@glissade/player": "0.4.5",
29
- "@glissade/scene": "0.4.5"
23
+ "@glissade/backend-skia": "0.5.0-pre.0",
24
+ "@glissade/core": "0.5.0-pre.0",
25
+ "@glissade/interact": "0.5.0-pre.0",
26
+ "@glissade/lottie": "0.5.0-pre.0",
27
+ "@glissade/narrate": "0.5.0-pre.0",
28
+ "@glissade/player": "0.5.0-pre.0",
29
+ "@glissade/scene": "0.5.0-pre.0"
30
30
  },
31
31
  "repository": {
32
32
  "type": "git",