@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 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.1",
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.1",
24
- "@glissade/core": "0.5.0-pre.1",
25
- "@glissade/interact": "0.5.0-pre.1",
26
- "@glissade/lottie": "0.5.0-pre.1",
27
- "@glissade/narrate": "0.5.0-pre.1",
28
- "@glissade/player": "0.5.0-pre.1",
29
- "@glissade/scene": "0.5.0-pre.1"
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",