@scenar/cli 0.0.2 → 0.0.4

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.
@@ -1,111 +1,313 @@
1
- import { mkdir, writeFile } from "node:fs/promises";
2
- import { join } from "node:path";
1
+ import { mkdir, writeFile, stat } from "node:fs/promises";
2
+ import { join, resolve, extname, basename, dirname } from "node:path";
3
3
  import { Command } from "commander";
4
4
  import { loadScenarioYaml } from "../util/load-yaml.js";
5
+ import { loadStepsFromTs } from "../util/load-ts.js";
6
+ import { discoverScenarios } from "../util/discover-scenarios.js";
7
+ import {
8
+ computeHash,
9
+ loadCache,
10
+ saveCache,
11
+ fileExists,
12
+ isCached,
13
+ getCachedDuration,
14
+ buildCacheFile,
15
+ } from "../util/narration-cache.js";
5
16
  import { validateScenario } from "../validate/scenario-validator.js";
6
17
  import { resolveProvider } from "../tts/resolve-provider.js";
7
- import type { NarrationManifest, NarrationManifestStep, TtsProvider } from "../tts/types.js";
18
+ import type { TtsProvider } from "../tts/types.js";
8
19
 
9
20
  interface NarrateOptions {
10
21
  tts: string;
11
- out: string;
22
+ out?: string;
12
23
  voice?: string;
24
+ baseUrl?: string;
13
25
  }
14
26
 
15
27
  export function registerNarrateCommand(program: Command): void {
16
28
  program
17
29
  .command("narrate")
18
- .description("Generate narration audio from a scenario YAML file.")
19
- .argument("<file>", "path to scenario YAML file")
20
- .option("--tts <provider>", "TTS provider: echogarden (default) or openai", "echogarden")
21
- .option("--out <dir>", "output directory for audio files", "./narration")
30
+ .description(
31
+ "Generate narration audio from scenario files.\n\n" +
32
+ "Accepts a YAML file, a TypeScript steps file, or a directory\n" +
33
+ "containing scenario subdirectories (each with a steps.ts).",
34
+ )
35
+ .argument("<file-or-dir>", "path to scenario file (.yaml/.ts) or directory")
36
+ .option("--tts <provider>", "TTS provider: echogarden, edge-tts, or openai", "echogarden")
37
+ .option("--out <dir>", "output directory for audio files")
22
38
  .option("--voice <voice>", "voice name (provider-specific)")
23
- .action(async (file: string, options: NarrateOptions) => {
39
+ .option("--base-url <path>", "URL path prefix for src fields in manifest")
40
+ .action(async (fileOrDir: string, options: NarrateOptions) => {
24
41
  const provider = await resolveProvider(options.tts);
25
- await runNarrate(file, options, provider);
42
+ const resolved = resolve(fileOrDir);
43
+ const info = await stat(resolved);
44
+
45
+ if (info.isDirectory()) {
46
+ await runNarrateDirectory(resolved, options, provider);
47
+ } else {
48
+ await runNarrateSingleFile(resolved, options, provider);
49
+ }
26
50
  });
27
51
  }
28
52
 
53
+ // ---------------------------------------------------------------------------
54
+ // Step extraction
55
+ // ---------------------------------------------------------------------------
56
+
29
57
  interface StepWithNarration {
30
58
  index: number;
31
59
  text: string;
32
60
  }
33
61
 
34
- /**
35
- * Core narration logic, separated from provider resolution for testability.
36
- * The command handler resolves the provider, then delegates here.
37
- */
38
- export async function runNarrate(
39
- file: string,
62
+ function extractNarratedSteps(
63
+ steps: Array<{ narration?: string; narrationText?: string }>,
64
+ ): StepWithNarration[] {
65
+ const result: StepWithNarration[] = [];
66
+ for (let i = 0; i < steps.length; i++) {
67
+ const text = steps[i]!.narration ?? steps[i]!.narrationText;
68
+ if (typeof text === "string" && text.length > 0) {
69
+ result.push({ index: i, text });
70
+ }
71
+ }
72
+ return result;
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Runtime manifest (matches @scenar/core NarrationManifest)
77
+ // ---------------------------------------------------------------------------
78
+
79
+ interface RuntimeManifestEntry {
80
+ src: string;
81
+ durationMs: number;
82
+ }
83
+
84
+ interface RuntimeManifest {
85
+ steps: (RuntimeManifestEntry | null)[];
86
+ }
87
+
88
+ function buildRuntimeManifest(
89
+ totalSteps: number,
90
+ entries: Map<number, { src: string; durationMs: number }>,
91
+ ): RuntimeManifest {
92
+ const steps: (RuntimeManifestEntry | null)[] = Array.from(
93
+ { length: totalSteps },
94
+ () => null,
95
+ );
96
+ for (const [idx, entry] of entries) {
97
+ steps[idx] = entry;
98
+ }
99
+ return { steps };
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Single-file narration
104
+ // ---------------------------------------------------------------------------
105
+
106
+ async function loadStepsFromFile(
107
+ filePath: string,
108
+ ): Promise<{ steps: Array<{ narration?: string; narrationText?: string }>; totalSteps: number }> {
109
+ const ext = extname(filePath).toLowerCase();
110
+
111
+ if (ext === ".ts" || ext === ".tsx") {
112
+ const steps = await loadStepsFromTs(filePath);
113
+ return { steps, totalSteps: steps.length };
114
+ }
115
+
116
+ const scenario = await loadScenarioYaml(filePath);
117
+ const validation = validateScenario(scenario);
118
+
119
+ if (!validation.valid) {
120
+ throw new Error(
121
+ `Scenario has ${validation.errors.length} error(s). Run 'scenar validate ${filePath}' for details.`,
122
+ );
123
+ }
124
+
125
+ const steps = (scenario as Record<string, unknown>)["steps"] as
126
+ | Array<Record<string, unknown>>
127
+ | undefined;
128
+ if (!steps) {
129
+ throw new Error("No steps found in scenario.");
130
+ }
131
+
132
+ return { steps: steps as Array<{ narrationText?: string }>, totalSteps: steps.length };
133
+ }
134
+
135
+ async function runNarrateSingleFile(
136
+ filePath: string,
40
137
  options: NarrateOptions,
41
138
  provider: TtsProvider,
42
139
  ): Promise<void> {
43
- const scenario = await loadScenarioYaml(file);
44
- const validation = validateScenario(scenario);
140
+ const { steps, totalSteps } = await loadStepsFromFile(filePath);
141
+ const narratedSteps = extractNarratedSteps(steps);
45
142
 
46
- if (!validation.valid) {
47
- process.stderr.write(`\x1b[31m✗\x1b[0m Scenario has ${validation.errors.length} error(s). Run 'scenar validate ${file}' for details.\n`);
48
- process.exitCode = 1;
143
+ if (narratedSteps.length === 0) {
144
+ process.stderr.write("\x1b[33m!\x1b[0m No steps contain narration text. Nothing to generate.\n");
49
145
  return;
50
146
  }
51
147
 
52
- const steps = (scenario as Record<string, unknown>)["steps"] as Record<string, unknown>[] | undefined;
53
- if (!steps) {
54
- process.stderr.write("\x1b[31m✗\x1b[0m No steps found in scenario.\n");
55
- process.exitCode = 1;
148
+ const outDir = options.out ?? join(dirname(filePath), "narration");
149
+ const voice = options.voice ?? "";
150
+
151
+ await generateNarration({
152
+ scenarioId: basename(dirname(filePath)),
153
+ narratedSteps,
154
+ totalSteps,
155
+ outDir,
156
+ voice,
157
+ baseUrl: options.baseUrl,
158
+ provider,
159
+ });
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Directory-mode narration
164
+ // ---------------------------------------------------------------------------
165
+
166
+ async function runNarrateDirectory(
167
+ dirPath: string,
168
+ options: NarrateOptions,
169
+ provider: TtsProvider,
170
+ ): Promise<void> {
171
+ const scenarios = await discoverScenarios(dirPath);
172
+
173
+ if (scenarios.length === 0) {
174
+ process.stderr.write("\x1b[33m!\x1b[0m No scenario directories with steps.ts found.\n");
56
175
  return;
57
176
  }
58
177
 
59
- const narratedSteps: StepWithNarration[] = [];
60
- for (let i = 0; i < steps.length; i++) {
61
- const text = steps[i]!["narrationText"];
62
- if (typeof text === "string" && text.length > 0) {
63
- narratedSteps.push({ index: i, text });
178
+ process.stderr.write(`Discovered ${scenarios.length} scenario(s)\n\n`);
179
+
180
+ let totalGenerated = 0;
181
+ let totalCached = 0;
182
+ let totalSkipped = 0;
183
+ let scenariosWithNarration = 0;
184
+ const errors: Array<{ id: string; error: unknown }> = [];
185
+
186
+ for (const scenario of scenarios) {
187
+ process.stderr.write(` ${scenario.id}\n`);
188
+ try {
189
+ const steps = await loadStepsFromTs(scenario.stepsPath);
190
+ const narratedSteps = extractNarratedSteps(steps);
191
+
192
+ if (narratedSteps.length === 0) {
193
+ process.stderr.write(" (no narration)\n");
194
+ totalSkipped++;
195
+ continue;
196
+ }
197
+
198
+ scenariosWithNarration++;
199
+
200
+ const outDir = options.out
201
+ ? join(options.out, scenario.id)
202
+ : join(dirPath, scenario.id, "narration");
203
+
204
+ const stats = await generateNarration({
205
+ scenarioId: scenario.id,
206
+ narratedSteps,
207
+ totalSteps: steps.length,
208
+ outDir,
209
+ voice: options.voice ?? "",
210
+ baseUrl: options.baseUrl,
211
+ provider,
212
+ });
213
+
214
+ totalGenerated += stats.generated;
215
+ totalCached += stats.cached;
216
+ } catch (error) {
217
+ process.stderr.write(` \x1b[31mfailed\x1b[0m: ${error}\n`);
218
+ errors.push({ id: scenario.id, error });
64
219
  }
65
220
  }
66
221
 
67
- if (narratedSteps.length === 0) {
68
- process.stderr.write("\x1b[33m⚠\x1b[0m No steps contain narration text. Nothing to generate.\n");
69
- return;
222
+ process.stderr.write("\n");
223
+ process.stderr.write(`Scenarios with narration: ${scenariosWithNarration}\n`);
224
+ process.stderr.write(`Audio files generated: ${totalGenerated}\n`);
225
+ process.stderr.write(`Audio files cached: ${totalCached}\n`);
226
+ process.stderr.write(`Scenarios skipped: ${totalSkipped}\n`);
227
+
228
+ if (errors.length > 0) {
229
+ process.stderr.write(`\n\x1b[31m${errors.length} scenario(s) failed:\x1b[0m\n`);
230
+ for (const { id, error } of errors) {
231
+ process.stderr.write(` - ${id}: ${error}\n`);
232
+ }
233
+ process.exitCode = 1;
234
+ } else {
235
+ process.stderr.write("\nDone\n");
70
236
  }
237
+ }
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // Core generation logic (shared by single-file and directory modes)
241
+ // ---------------------------------------------------------------------------
242
+
243
+ interface GenerateOptions {
244
+ scenarioId: string;
245
+ narratedSteps: StepWithNarration[];
246
+ totalSteps: number;
247
+ outDir: string;
248
+ voice: string;
249
+ baseUrl?: string;
250
+ provider: TtsProvider;
251
+ }
252
+
253
+ interface GenerateStats {
254
+ generated: number;
255
+ cached: number;
256
+ }
257
+
258
+ async function generateNarration(opts: GenerateOptions): Promise<GenerateStats> {
259
+ const { scenarioId, narratedSteps, totalSteps, outDir, voice, baseUrl, provider } = opts;
71
260
 
72
- await mkdir(options.out, { recursive: true });
261
+ await mkdir(outDir, { recursive: true });
73
262
 
74
- const manifestSteps: NarrationManifestStep[] = [];
263
+ const existingCache = await loadCache(outDir);
264
+ const stats: GenerateStats = { generated: 0, cached: 0 };
265
+ const manifestEntries = new Map<number, { src: string; durationMs: number }>();
266
+ const cacheEntries = new Map<number, { hash: string; durationMs: number }>();
75
267
 
76
- for (let i = 0; i < narratedSteps.length; i++) {
77
- const step = narratedSteps[i]!;
268
+ for (const step of narratedSteps) {
269
+ const hash = computeHash(step.text, voice);
78
270
  const fileName = `step-${step.index}.mp3`;
79
- const outputPath = join(options.out, fileName);
271
+ const mp3Path = join(outDir, fileName);
80
272
 
81
- process.stderr.write(
82
- ` [${i + 1}/${narratedSteps.length}] Generating audio for step ${step.index}...\n`,
83
- );
273
+ const srcPrefix = baseUrl
274
+ ? `${baseUrl.replace(/\/$/, "")}/${scenarioId}`
275
+ : `.`;
276
+ const src = `${srcPrefix}/${fileName}`;
277
+
278
+ if (isCached(existingCache, step.index, hash, voice) && await fileExists(mp3Path)) {
279
+ const durationMs = getCachedDuration(existingCache!, step.index);
280
+ manifestEntries.set(step.index, { src, durationMs });
281
+ cacheEntries.set(step.index, { hash, durationMs });
282
+ stats.cached++;
283
+ process.stderr.write(` step ${step.index}: cached\n`);
284
+ continue;
285
+ }
84
286
 
287
+ process.stderr.write(` step ${step.index}: generating...\n`);
85
288
  const result = await provider.synthesize(step.text, {
86
- voice: options.voice,
289
+ voice: voice || undefined,
87
290
  });
88
291
 
89
- await writeFile(outputPath, result.audio);
90
-
91
- manifestSteps.push({
92
- index: step.index,
93
- file: fileName,
94
- durationMs: result.durationMs,
95
- text: step.text,
96
- });
292
+ await writeFile(mp3Path, result.audio);
293
+ manifestEntries.set(step.index, { src, durationMs: result.durationMs });
294
+ cacheEntries.set(step.index, { hash, durationMs: result.durationMs });
295
+ stats.generated++;
296
+ process.stderr.write(` step ${step.index}: ${result.durationMs}ms (${result.audio.length} bytes)\n`);
97
297
  }
98
298
 
99
- const manifest: NarrationManifest = {
100
- generatedAt: new Date().toISOString(),
101
- ttsProvider: provider.name,
102
- steps: manifestSteps,
103
- };
299
+ const manifest = buildRuntimeManifest(totalSteps, manifestEntries);
300
+ await writeFile(
301
+ join(outDir, "manifest.json"),
302
+ JSON.stringify(manifest, null, 2) + "\n",
303
+ );
104
304
 
105
- const manifestPath = join(options.out, "manifest.json");
106
- await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
305
+ const cache = buildCacheFile(voice, totalSteps, cacheEntries);
306
+ await saveCache(outDir, cache);
107
307
 
108
308
  process.stderr.write(
109
- `\n\x1b[32m✓\x1b[0m Generated ${manifestSteps.length} audio file(s) in ${options.out}/\n`,
309
+ `\x1b[32m+\x1b[0m ${scenarioId}: ${stats.generated} generated, ${stats.cached} cached\n`,
110
310
  );
311
+
312
+ return stats;
111
313
  }
package/src/index.js CHANGED
@@ -5,7 +5,7 @@ export function createProgram() {
5
5
  const program = new Command();
6
6
  program
7
7
  .name("scenar")
8
- .description("Scenar CLI — validate scenario YAML and generate narration audio.")
8
+ .description("Scenar CLI — validate scenarios and generate narration audio.")
9
9
  .version("0.0.1");
10
10
  registerValidateCommand(program);
11
11
  registerNarrateCommand(program);
package/src/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,uBAAuB,EAAE,MAAM,wBAAwB,CAAC;AACjE,OAAO,EAAE,sBAAsB,EAAE,MAAM,uBAAuB,CAAC;AAE/D,MAAM,UAAU,aAAa;IAC3B,MAAM,OAAO,GAAG,IAAI,OAAO,EAAE,CAAC;IAE9B,OAAO;SACJ,IAAI,CAAC,QAAQ,CAAC;SACd,WAAW,CAAC,mEAAmE,CAAC;SAChF,OAAO,CAAC,OAAO,CAAC,CAAC;IAEpB,uBAAuB,CAAC,OAAO,CAAC,CAAC;IACjC,sBAAsB,CAAC,OAAO,CAAC,CAAC;IAEhC,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,MAAM,UAAU,GAAG,CAAC,IAAc;IAChC,MAAM,OAAO,GAAG,aAAa,EAAE,CAAC;IAChC,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;AACtB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,uBAAuB,EAAE,MAAM,wBAAwB,CAAC;AACjE,OAAO,EAAE,sBAAsB,EAAE,MAAM,uBAAuB,CAAC;AAE/D,MAAM,UAAU,aAAa;IAC3B,MAAM,OAAO,GAAG,IAAI,OAAO,EAAE,CAAC;IAE9B,OAAO;SACJ,IAAI,CAAC,QAAQ,CAAC;SACd,WAAW,CAAC,+DAA+D,CAAC;SAC5E,OAAO,CAAC,OAAO,CAAC,CAAC;IAEpB,uBAAuB,CAAC,OAAO,CAAC,CAAC;IACjC,sBAAsB,CAAC,OAAO,CAAC,CAAC;IAEhC,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,MAAM,UAAU,GAAG,CAAC,IAAc;IAChC,MAAM,OAAO,GAAG,aAAa,EAAE,CAAC;IAChC,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;AACtB,CAAC"}
package/src/index.ts CHANGED
@@ -7,7 +7,7 @@ export function createProgram(): Command {
7
7
 
8
8
  program
9
9
  .name("scenar")
10
- .description("Scenar CLI — validate scenario YAML and generate narration audio.")
10
+ .description("Scenar CLI — validate scenarios and generate narration audio.")
11
11
  .version("0.0.1");
12
12
 
13
13
  registerValidateCommand(program);
@@ -25,7 +25,11 @@ export interface TtsResult {
25
25
  durationMs: number;
26
26
  }
27
27
  /**
28
- * Shape written to `manifest.json` alongside the narration audio files.
28
+ * @deprecated Legacy CLI manifest shape. The narrate command now writes
29
+ * manifests matching `@scenar/core`'s `NarrationManifest` type directly
30
+ * (positional array of `{ src, durationMs } | null`).
31
+ *
32
+ * Kept temporarily for test compatibility during the transition.
29
33
  */
30
34
  export interface NarrationManifest {
31
35
  generatedAt: string;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/tts/types.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,MAAM,WAAW,WAAW;IAC1B,oEAAoE;IACpE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAEtB;;;OAGG;IACH,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC;CACnE;AAED,MAAM,WAAW,UAAU;IACzB,4CAA4C;IAC5C,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,SAAS;IACxB,+BAA+B;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,4DAA4D;IAC5D,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,qBAAqB,EAAE,CAAC;CAChC;AAED,MAAM,WAAW,qBAAqB;IACpC,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;CACd"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/tts/types.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,MAAM,WAAW,WAAW;IAC1B,oEAAoE;IACpE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAEtB;;;OAGG;IACH,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC;CACnE;AAED,MAAM,WAAW,UAAU;IACzB,4CAA4C;IAC5C,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,SAAS;IACxB,+BAA+B;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,4DAA4D;IAC5D,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;GAMG;AACH,MAAM,WAAW,iBAAiB;IAChC,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,qBAAqB,EAAE,CAAC;CAChC;AAED,MAAM,WAAW,qBAAqB;IACpC,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;CACd"}
package/src/tts/types.ts CHANGED
@@ -29,7 +29,11 @@ export interface TtsResult {
29
29
  }
30
30
 
31
31
  /**
32
- * Shape written to `manifest.json` alongside the narration audio files.
32
+ * @deprecated Legacy CLI manifest shape. The narrate command now writes
33
+ * manifests matching `@scenar/core`'s `NarrationManifest` type directly
34
+ * (positional array of `{ src, durationMs } | null`).
35
+ *
36
+ * Kept temporarily for test compatibility during the transition.
33
37
  */
34
38
  export interface NarrationManifest {
35
39
  generatedAt: string;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Scan a directory for scenario subdirectories. A subdirectory
3
+ * qualifies if it contains a `steps.ts` file.
4
+ *
5
+ * Returns sorted scenario entries with the directory name (used as
6
+ * the scenario ID) and the absolute path to `steps.ts`.
7
+ */
8
+ export declare function discoverScenarios(dir: string): Promise<Array<{
9
+ id: string;
10
+ stepsPath: string;
11
+ }>>;
12
+ //# sourceMappingURL=discover-scenarios.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"discover-scenarios.d.ts","sourceRoot":"","sources":["../../../src/util/discover-scenarios.ts"],"names":[],"mappings":"AAGA;;;;;;GAMG;AACH,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,KAAK,CAAC;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC,CAiBnD"}
@@ -0,0 +1,28 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ /**
4
+ * Scan a directory for scenario subdirectories. A subdirectory
5
+ * qualifies if it contains a `steps.ts` file.
6
+ *
7
+ * Returns sorted scenario entries with the directory name (used as
8
+ * the scenario ID) and the absolute path to `steps.ts`.
9
+ */
10
+ export async function discoverScenarios(dir) {
11
+ const entries = await readdir(dir, { withFileTypes: true });
12
+ const scenarios = [];
13
+ for (const entry of entries) {
14
+ if (!entry.isDirectory())
15
+ continue;
16
+ const stepsPath = join(dir, entry.name, "steps.ts");
17
+ try {
18
+ const { access } = await import("node:fs/promises");
19
+ await access(stepsPath);
20
+ scenarios.push({ id: entry.name, stepsPath });
21
+ }
22
+ catch {
23
+ // No steps.ts — skip.
24
+ }
25
+ }
26
+ return scenarios.sort((a, b) => a.id.localeCompare(b.id));
27
+ }
28
+ //# sourceMappingURL=discover-scenarios.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"discover-scenarios.js","sourceRoot":"","sources":["../../../src/util/discover-scenarios.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAC3C,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAEjC;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,iBAAiB,CACrC,GAAW;IAEX,MAAM,OAAO,GAAG,MAAM,OAAO,CAAC,GAAG,EAAE,EAAE,aAAa,EAAE,IAAI,EAAE,CAAC,CAAC;IAC5D,MAAM,SAAS,GAA6C,EAAE,CAAC;IAE/D,KAAK,MAAM,KAAK,IAAI,OAAO,EAAE,CAAC;QAC5B,IAAI,CAAC,KAAK,CAAC,WAAW,EAAE;YAAE,SAAS;QACnC,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,EAAE,UAAU,CAAC,CAAC;QACpD,IAAI,CAAC;YACH,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,MAAM,CAAC,kBAAkB,CAAC,CAAC;YACpD,MAAM,MAAM,CAAC,SAAS,CAAC,CAAC;YACxB,SAAS,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,KAAK,CAAC,IAAI,EAAE,SAAS,EAAE,CAAC,CAAC;QAChD,CAAC;QAAC,MAAM,CAAC;YACP,sBAAsB;QACxB,CAAC;IACH,CAAC;IAED,OAAO,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,aAAa,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;AAC5D,CAAC"}
@@ -0,0 +1,30 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ /**
5
+ * Scan a directory for scenario subdirectories. A subdirectory
6
+ * qualifies if it contains a `steps.ts` file.
7
+ *
8
+ * Returns sorted scenario entries with the directory name (used as
9
+ * the scenario ID) and the absolute path to `steps.ts`.
10
+ */
11
+ export async function discoverScenarios(
12
+ dir: string,
13
+ ): Promise<Array<{ id: string; stepsPath: string }>> {
14
+ const entries = await readdir(dir, { withFileTypes: true });
15
+ const scenarios: Array<{ id: string; stepsPath: string }> = [];
16
+
17
+ for (const entry of entries) {
18
+ if (!entry.isDirectory()) continue;
19
+ const stepsPath = join(dir, entry.name, "steps.ts");
20
+ try {
21
+ const { access } = await import("node:fs/promises");
22
+ await access(stepsPath);
23
+ scenarios.push({ id: entry.name, stepsPath });
24
+ } catch {
25
+ // No steps.ts — skip.
26
+ }
27
+ }
28
+
29
+ return scenarios.sort((a, b) => a.id.localeCompare(b.id));
30
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Minimal shape extracted from each step object. The loader
3
+ * duck-types the exported array — any array of objects with
4
+ * `delayMs` qualifies as a steps array.
5
+ */
6
+ export interface ImportedStep {
7
+ delayMs: number;
8
+ narration?: string;
9
+ }
10
+ /**
11
+ * Dynamically import a TypeScript steps file and extract the
12
+ * steps array by duck-typing (looks for the first exported array
13
+ * whose elements have a `delayMs` property).
14
+ *
15
+ * Requires the caller's Node process to have a TypeScript loader
16
+ * active (e.g. running via `tsx`). The CLI itself does not depend
17
+ * on any TS compilation tool.
18
+ */
19
+ export declare function loadStepsFromTs(filePath: string): Promise<ImportedStep[]>;
20
+ //# sourceMappingURL=load-ts.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"load-ts.d.ts","sourceRoot":"","sources":["../../../src/util/load-ts.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AACH,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;GAQG;AACH,wBAAsB,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CAiB/E"}
@@ -0,0 +1,25 @@
1
+ import { pathToFileURL } from "node:url";
2
+ /**
3
+ * Dynamically import a TypeScript steps file and extract the
4
+ * steps array by duck-typing (looks for the first exported array
5
+ * whose elements have a `delayMs` property).
6
+ *
7
+ * Requires the caller's Node process to have a TypeScript loader
8
+ * active (e.g. running via `tsx`). The CLI itself does not depend
9
+ * on any TS compilation tool.
10
+ */
11
+ export async function loadStepsFromTs(filePath) {
12
+ const mod = await import(pathToFileURL(filePath).href);
13
+ const exports = mod.default ?? mod;
14
+ for (const value of Object.values(exports)) {
15
+ if (Array.isArray(value) &&
16
+ value.length > 0 &&
17
+ typeof value[0] === "object" &&
18
+ value[0] !== null &&
19
+ "delayMs" in value[0]) {
20
+ return value;
21
+ }
22
+ }
23
+ throw new Error(`No steps array found in ${filePath}`);
24
+ }
25
+ //# sourceMappingURL=load-ts.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"load-ts.js","sourceRoot":"","sources":["../../../src/util/load-ts.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAYzC;;;;;;;;GAQG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CAAC,QAAgB;IACpD,MAAM,GAAG,GAAG,MAAM,MAAM,CAAC,aAAa,CAAC,QAAQ,CAAC,CAAC,IAAI,CAAC,CAAC;IACvD,MAAM,OAAO,GAAG,GAAG,CAAC,OAAO,IAAI,GAAG,CAAC;IAEnC,KAAK,MAAM,KAAK,IAAI,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,EAAE,CAAC;QAC3C,IACE,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;YACpB,KAAK,CAAC,MAAM,GAAG,CAAC;YAChB,OAAO,KAAK,CAAC,CAAC,CAAC,KAAK,QAAQ;YAC5B,KAAK,CAAC,CAAC,CAAC,KAAK,IAAI;YACjB,SAAS,IAAI,KAAK,CAAC,CAAC,CAAC,EACrB,CAAC;YACD,OAAO,KAAuB,CAAC;QACjC,CAAC;IACH,CAAC;IAED,MAAM,IAAI,KAAK,CAAC,2BAA2B,QAAQ,EAAE,CAAC,CAAC;AACzD,CAAC"}
@@ -0,0 +1,39 @@
1
+ import { pathToFileURL } from "node:url";
2
+
3
+ /**
4
+ * Minimal shape extracted from each step object. The loader
5
+ * duck-types the exported array — any array of objects with
6
+ * `delayMs` qualifies as a steps array.
7
+ */
8
+ export interface ImportedStep {
9
+ delayMs: number;
10
+ narration?: string;
11
+ }
12
+
13
+ /**
14
+ * Dynamically import a TypeScript steps file and extract the
15
+ * steps array by duck-typing (looks for the first exported array
16
+ * whose elements have a `delayMs` property).
17
+ *
18
+ * Requires the caller's Node process to have a TypeScript loader
19
+ * active (e.g. running via `tsx`). The CLI itself does not depend
20
+ * on any TS compilation tool.
21
+ */
22
+ export async function loadStepsFromTs(filePath: string): Promise<ImportedStep[]> {
23
+ const mod = await import(pathToFileURL(filePath).href);
24
+ const exports = mod.default ?? mod;
25
+
26
+ for (const value of Object.values(exports)) {
27
+ if (
28
+ Array.isArray(value) &&
29
+ value.length > 0 &&
30
+ typeof value[0] === "object" &&
31
+ value[0] !== null &&
32
+ "delayMs" in value[0]
33
+ ) {
34
+ return value as ImportedStep[];
35
+ }
36
+ }
37
+
38
+ throw new Error(`No steps array found in ${filePath}`);
39
+ }
@@ -0,0 +1,24 @@
1
+ interface CacheEntry {
2
+ hash: string;
3
+ durationMs: number;
4
+ }
5
+ interface CacheFile {
6
+ voice: string;
7
+ steps: (CacheEntry | null)[];
8
+ }
9
+ export declare function computeHash(narration: string, voice: string): string;
10
+ export declare function loadCache(outputDir: string): Promise<CacheFile | null>;
11
+ export declare function saveCache(outputDir: string, cache: CacheFile): Promise<void>;
12
+ export declare function fileExists(filePath: string): Promise<boolean>;
13
+ /**
14
+ * Check whether a step's narration is already cached (matching hash
15
+ * and the MP3 file still exists on disk).
16
+ */
17
+ export declare function isCached(cache: CacheFile | null, stepIndex: number, hash: string, voice: string): boolean;
18
+ export declare function getCachedDuration(cache: CacheFile, stepIndex: number): number;
19
+ export declare function buildCacheFile(voice: string, totalSteps: number, entries: Map<number, {
20
+ hash: string;
21
+ durationMs: number;
22
+ }>): CacheFile;
23
+ export {};
24
+ //# sourceMappingURL=narration-cache.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"narration-cache.d.ts","sourceRoot":"","sources":["../../../src/util/narration-cache.ts"],"names":[],"mappings":"AAIA,UAAU,UAAU;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,UAAU,SAAS;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,CAAC,UAAU,GAAG,IAAI,CAAC,EAAE,CAAC;CAC9B;AAID,wBAAgB,WAAW,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAIpE;AAED,wBAAsB,SAAS,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC,CAO5E;AAED,wBAAsB,SAAS,CAC7B,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,SAAS,GACf,OAAO,CAAC,IAAI,CAAC,CAKf;AAED,wBAAsB,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAOnE;AAED;;;GAGG;AACH,wBAAgB,QAAQ,CACtB,KAAK,EAAE,SAAS,GAAG,IAAI,EACvB,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,GACZ,OAAO,CAIT;AAED,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,SAAS,EAChB,SAAS,EAAE,MAAM,GAChB,MAAM,CAER;AAED,wBAAgB,cAAc,CAC5B,KAAK,EAAE,MAAM,EACb,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,CAAC,GACzD,SAAS,CASX"}