@glissade/cli 0.5.0-pre.1 → 0.5.0-pre.2
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 +14 -1
- package/dist/index.d.ts +2 -0
- package/dist/render.js +12 -0
- package/dist/sfx.js +125 -0
- package/package.json +9 -8
package/dist/cli.js
CHANGED
|
@@ -44,6 +44,7 @@ const USAGE = `usage:
|
|
|
44
44
|
gs dev <scene-module> [--record] [--port <n>]
|
|
45
45
|
gs import <lottie.json> [--out <dir>] [--allow-degraded]
|
|
46
46
|
gs narrate <scene-module|script.narration.json> [--provider <id>] [--align <id>] [--force]
|
|
47
|
+
gs sfx <scene-module|script.sfx.json>
|
|
47
48
|
|
|
48
49
|
render options:
|
|
49
50
|
--out <path> output directory for a PNG sequence, or .mp4/.webm (needs ffmpeg). default: ./out
|
|
@@ -55,6 +56,7 @@ render options:
|
|
|
55
56
|
--captions <m> burn (default) | sidecar | off; burn/sidecar also write .srt/.vtt
|
|
56
57
|
--narration <m> auto (default): mix the voice from a sibling *.narration.timing.json | off
|
|
57
58
|
--music <m> auto (default): mix a sibling *.music.timing.json bed, ducked under narration | off
|
|
59
|
+
--sfx <m> auto (default): mix effect hits from a sibling *.sfx.timing.json | off
|
|
58
60
|
|
|
59
61
|
dev options:
|
|
60
62
|
--record add a Record button; writes .trace.json sidecars next to the module
|
|
@@ -71,7 +73,7 @@ narrate options (the explicit TTS prepare step; render itself stays offline):
|
|
|
71
73
|
`;
|
|
72
74
|
async function main() {
|
|
73
75
|
const [command, ...rest] = process.argv.slice(2);
|
|
74
|
-
if (command !== "render" && command !== "dev" && command !== "import" && command !== "narrate") {
|
|
76
|
+
if (command !== "render" && command !== "dev" && command !== "import" && command !== "narrate" && command !== "sfx") {
|
|
75
77
|
console.error(USAGE);
|
|
76
78
|
process.exit(command === void 0 || command === "help" || command === "--help" ? 0 : 1);
|
|
77
79
|
}
|
|
@@ -98,6 +100,16 @@ async function main() {
|
|
|
98
100
|
}
|
|
99
101
|
return;
|
|
100
102
|
}
|
|
103
|
+
if (command === "sfx") {
|
|
104
|
+
const { prepareSfx, sfxScriptPathFor } = await import("./sfx.js");
|
|
105
|
+
try {
|
|
106
|
+
const result = prepareSfx(sfxScriptPathFor(modulePath));
|
|
107
|
+
process.stderr.write(`gs sfx: ${result.clipCount} ${result.clipCount === 1 ? "hit" : "hits"}, ${result.voices.length} ${result.voices.length === 1 ? "voice" : "voices"} rendered → ${result.timingPath}\n`);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
110
|
+
}
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
101
113
|
if (command === "import") {
|
|
102
114
|
const { importCommand } = await import("./import.js").then((n) => n.n);
|
|
103
115
|
try {
|
|
@@ -145,6 +157,7 @@ async function main() {
|
|
|
145
157
|
captions: parseCaptionsModeOrFail(flags.get("captions")),
|
|
146
158
|
narration: flags.get("narration") === "off" ? "off" : "auto",
|
|
147
159
|
music: flags.get("music") === "off" ? "off" : "auto",
|
|
160
|
+
sfx: flags.get("sfx") === "off" ? "off" : "auto",
|
|
148
161
|
onProgress: (n, total) => {
|
|
149
162
|
if (process.stderr.isTTY) {
|
|
150
163
|
if (n % 30 === 0 || n === total) process.stderr.write(`\rrendering ${n}/${total} frames`);
|
package/dist/index.d.ts
CHANGED
|
@@ -22,6 +22,8 @@ interface RenderOptions {
|
|
|
22
22
|
music?: 'auto' | 'off';
|
|
23
23
|
/** auto (default): mix the voice from a sibling *.narration.timing.json. */
|
|
24
24
|
narration?: 'auto' | 'off';
|
|
25
|
+
/** auto (default): mix effect hits from a sibling *.sfx.timing.json. */
|
|
26
|
+
sfx?: 'auto' | 'off';
|
|
25
27
|
onProgress?: (frame: number, total: number) => void;
|
|
26
28
|
}
|
|
27
29
|
declare class SceneModuleError extends Error {
|
package/dist/render.js
CHANGED
|
@@ -155,6 +155,18 @@ async function render(opts) {
|
|
|
155
155
|
}
|
|
156
156
|
}
|
|
157
157
|
}
|
|
158
|
+
if ((opts.sfx ?? "auto") === "auto") {
|
|
159
|
+
const { buildSfxClipsFromTiming, sfxTimingPathFor } = await import("./sfx.js");
|
|
160
|
+
const sfxPath = sfxTimingPathFor(opts.modulePath);
|
|
161
|
+
if (sfxPath) {
|
|
162
|
+
const fx = buildSfxClipsFromTiming(sfxPath);
|
|
163
|
+
if (fx) if (fx.clips.some((c) => bedAlreadyReferenced(audioClips, c.asset.url, opts.modulePath))) process.stderr.write("note: sfx already in the timeline audio — auto-mix skipped\n");
|
|
164
|
+
else {
|
|
165
|
+
audioClips.push(...fx.clips);
|
|
166
|
+
process.stderr.write(`note: auto-mixing ${fx.note}\n`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
158
170
|
}
|
|
159
171
|
const { planAudioMix } = await import("./audioMix.js").then((n) => n.r);
|
|
160
172
|
const mix = planAudioMix(audioClips, opts.modulePath, duration);
|
package/dist/sfx.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
import { narration } from "@glissade/narrate";
|
|
4
|
+
import { buildSfxClips, renderSfxAssets, sfxFileName, sfxrSource } from "@glissade/sfx";
|
|
5
|
+
//#region src/sfx.ts
|
|
6
|
+
/**
|
|
7
|
+
* gs sfx — the explicit sound-effects prepare step, and the render-time
|
|
8
|
+
* auto-mix half. Like narration, synthesis happens HERE: the prepare step
|
|
9
|
+
* resolves each hit's time (absolute, or anchored to a narration beat so it
|
|
10
|
+
* re-flows on re-narrate), renders the referenced voices to a committed WAV
|
|
11
|
+
* cache (deduped), applies deterministic index-seeded pitch/gain variation, and
|
|
12
|
+
* writes a committed `<base>.sfx.timing.json`. gs render consumes that manifest,
|
|
13
|
+
* fully offline — a sibling next to the scene joins the mix with zero config.
|
|
14
|
+
*/
|
|
15
|
+
var SfxCliError = class extends Error {
|
|
16
|
+
constructor(message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = "SfxCliError";
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
/** `<scene>.sfx.json` for a scene module, or the script path itself. */
|
|
22
|
+
function sfxScriptPathFor(input) {
|
|
23
|
+
if (input.endsWith(".sfx.json")) return input;
|
|
24
|
+
const candidate = input.replace(/\.[jt]sx?$/, "") + ".sfx.json";
|
|
25
|
+
if (!existsSync(candidate)) throw new SfxCliError(`no sfx script at ${candidate} — create it or pass the script path directly`);
|
|
26
|
+
return candidate;
|
|
27
|
+
}
|
|
28
|
+
/** `<module>.sfx.timing.json`, when the scene has effects. */
|
|
29
|
+
function sfxTimingPathFor(modulePath) {
|
|
30
|
+
const candidate = modulePath.replace(/\.[jt]sx?$/, "") + ".sfx.timing.json";
|
|
31
|
+
return existsSync(candidate) ? candidate : null;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Resolve, render, and commit a scene's sound effects. Anchored hits are
|
|
35
|
+
* resolved against the sibling `<base>.narration.timing.json`; absolute hits
|
|
36
|
+
* use their `at`. The referenced voices render once each (deduped) to
|
|
37
|
+
* `<base>.sfx-cache/`, and the index-seeded jitter is baked into the committed
|
|
38
|
+
* clip list — so render is a pure read of the manifest.
|
|
39
|
+
*/
|
|
40
|
+
function prepareSfx(scriptPath) {
|
|
41
|
+
if (!scriptPath.endsWith(".sfx.json")) throw new SfxCliError(`script path must end with .sfx.json: ${scriptPath}`);
|
|
42
|
+
const raw = JSON.parse(readFileSync(scriptPath, "utf8"));
|
|
43
|
+
if (raw.sfxVersion !== 1) throw new SfxCliError(`unsupported sfxVersion ${String(raw.sfxVersion)}`);
|
|
44
|
+
if ((raw.source ?? "sfxr") !== "sfxr") throw new SfxCliError(`gs sfx v1 supports source 'sfxr' only; use buildSfxClips() in code for sample packs`);
|
|
45
|
+
const source = sfxrSource(raw.sampleRate !== void 0 ? { sampleRate: raw.sampleRate } : {});
|
|
46
|
+
const validVoices = new Set(source.voices().map((v) => v.id));
|
|
47
|
+
const base = scriptPath.replace(/\.sfx\.json$/, "");
|
|
48
|
+
const narrationPath = `${base}.narration.timing.json`;
|
|
49
|
+
let beats = null;
|
|
50
|
+
const loadBeats = () => {
|
|
51
|
+
if (beats) return beats;
|
|
52
|
+
if (!existsSync(narrationPath)) throw new SfxCliError(`hit anchors a narration beat but no ${basename(narrationPath)} found — run gs narrate first`);
|
|
53
|
+
beats = narration(JSON.parse(readFileSync(narrationPath, "utf8")));
|
|
54
|
+
return beats;
|
|
55
|
+
};
|
|
56
|
+
const hits = raw.hits.map((h, i) => {
|
|
57
|
+
if (!validVoices.has(h.voice)) throw new SfxCliError(`hit ${i}: unknown voice '${h.voice}' (have: ${[...validVoices].join(", ")})`);
|
|
58
|
+
const hasAnchor = h.anchor !== void 0;
|
|
59
|
+
if (hasAnchor === (h.at !== void 0)) throw new SfxCliError(`hit ${i} ('${h.voice}') needs exactly one of 'anchor' or 'at'`);
|
|
60
|
+
const at = hasAnchor ? loadBeats().at(h.anchor, h.offset ?? 0) : h.at;
|
|
61
|
+
return {
|
|
62
|
+
voice: h.voice,
|
|
63
|
+
at,
|
|
64
|
+
...h.gain !== void 0 ? { gain: h.gain } : {}
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
const timedClips = buildSfxClips(hits, source, {
|
|
68
|
+
seed: raw.seed ?? 0,
|
|
69
|
+
...raw.jitterRate !== void 0 ? { jitterRate: raw.jitterRate } : {},
|
|
70
|
+
...raw.jitterGain !== void 0 ? { jitterGain: raw.jitterGain } : {},
|
|
71
|
+
...raw.gain !== void 0 ? { gain: raw.gain } : {}
|
|
72
|
+
}).map((c, i) => ({
|
|
73
|
+
voice: hits[i].voice,
|
|
74
|
+
at: c.at,
|
|
75
|
+
file: sfxFileName(source.id, hits[i].voice),
|
|
76
|
+
...c.gain ? { gain: c.gain.keys[0].value } : {},
|
|
77
|
+
...c.playbackRate !== void 0 ? { playbackRate: c.playbackRate } : {}
|
|
78
|
+
}));
|
|
79
|
+
const cacheDir = `${base}.sfx-cache`;
|
|
80
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
81
|
+
const assets = renderSfxAssets(source, hits.map((h) => h.voice));
|
|
82
|
+
for (const [file, bytes] of Object.entries(assets)) writeFileSync(join(cacheDir, file), bytes);
|
|
83
|
+
const timing = {
|
|
84
|
+
sfxTimingVersion: 1,
|
|
85
|
+
source: source.id,
|
|
86
|
+
clips: timedClips
|
|
87
|
+
};
|
|
88
|
+
const timingPath = `${base}.sfx.timing.json`;
|
|
89
|
+
writeFileSync(timingPath, JSON.stringify(timing, null, 2) + "\n");
|
|
90
|
+
return {
|
|
91
|
+
timingPath,
|
|
92
|
+
cacheDir,
|
|
93
|
+
voices: Object.keys(assets),
|
|
94
|
+
clipCount: timedClips.length
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Build the effect clips from a sibling timing manifest for render auto-mix.
|
|
99
|
+
* Clip urls stay relative to the cache dir next to the manifest, so the mixer
|
|
100
|
+
* resolves them against the module dir (mirrors buildNarrationClips).
|
|
101
|
+
*/
|
|
102
|
+
function buildSfxClipsFromTiming(timingPath) {
|
|
103
|
+
const timing = JSON.parse(readFileSync(timingPath, "utf8"));
|
|
104
|
+
if (!timing.clips || timing.clips.length === 0) return null;
|
|
105
|
+
const cacheBase = basename(timingPath).replace(/\.sfx\.timing\.json$/, "") + ".sfx-cache";
|
|
106
|
+
const clips = timing.clips.map((c) => ({
|
|
107
|
+
asset: {
|
|
108
|
+
kind: "audio",
|
|
109
|
+
url: `${cacheBase}/${c.file}`
|
|
110
|
+
},
|
|
111
|
+
at: c.at,
|
|
112
|
+
...c.playbackRate !== void 0 ? { playbackRate: c.playbackRate } : {},
|
|
113
|
+
...c.gain !== void 0 ? { gain: { keys: [{
|
|
114
|
+
t: 0,
|
|
115
|
+
value: c.gain
|
|
116
|
+
}] } } : {}
|
|
117
|
+
}));
|
|
118
|
+
const n = clips.length;
|
|
119
|
+
return {
|
|
120
|
+
clips,
|
|
121
|
+
note: `sfx (${n} ${n === 1 ? "hit" : "hits"})`
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
//#endregion
|
|
125
|
+
export { buildSfxClipsFromTiming, prepareSfx, sfxScriptPathFor, sfxTimingPathFor };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glissade/cli",
|
|
3
|
-
"version": "0.5.0-pre.
|
|
3
|
+
"version": "0.5.0-pre.2",
|
|
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,14 @@
|
|
|
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.5.0-pre.
|
|
24
|
-
"@glissade/core": "0.5.0-pre.
|
|
25
|
-
"@glissade/interact": "0.5.0-pre.
|
|
26
|
-
"@glissade/lottie": "0.5.0-pre.
|
|
27
|
-
"@glissade/narrate": "0.5.0-pre.
|
|
28
|
-
"@glissade/player": "0.5.0-pre.
|
|
29
|
-
"@glissade/scene": "0.5.0-pre.
|
|
23
|
+
"@glissade/backend-skia": "0.5.0-pre.2",
|
|
24
|
+
"@glissade/core": "0.5.0-pre.2",
|
|
25
|
+
"@glissade/interact": "0.5.0-pre.2",
|
|
26
|
+
"@glissade/lottie": "0.5.0-pre.2",
|
|
27
|
+
"@glissade/narrate": "0.5.0-pre.2",
|
|
28
|
+
"@glissade/player": "0.5.0-pre.2",
|
|
29
|
+
"@glissade/scene": "0.5.0-pre.2",
|
|
30
|
+
"@glissade/sfx": "0.5.0-pre.2"
|
|
30
31
|
},
|
|
31
32
|
"repository": {
|
|
32
33
|
"type": "git",
|