@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 +11 -3
- package/dist/index.d.ts +2 -0
- package/dist/music.js +34 -13
- package/dist/narrate.js +4 -1
- package/dist/render.js +22 -9
- package/package.json +8 -8
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 = [
|
|
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 {
|
|
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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
41
|
-
*
|
|
42
|
-
* +6dB
|
|
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,
|
|
45
|
-
const
|
|
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) ===
|
|
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
|
-
|
|
135
|
-
const { bedAlreadyReferenced, buildMusicClip, musicPathFor } = await import("./music.js");
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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.
|
|
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.
|
|
24
|
-
"@glissade/core": "0.
|
|
25
|
-
"@glissade/interact": "0.
|
|
26
|
-
"@glissade/lottie": "0.
|
|
27
|
-
"@glissade/narrate": "0.
|
|
28
|
-
"@glissade/player": "0.
|
|
29
|
-
"@glissade/scene": "0.
|
|
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",
|