@scenar/cli 0.0.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.
Files changed (64) hide show
  1. package/LICENSE +190 -0
  2. package/bin/scenar.d.ts +3 -0
  3. package/bin/scenar.d.ts.map +1 -0
  4. package/bin/scenar.js +4 -0
  5. package/bin/scenar.js.map +1 -0
  6. package/package.json +43 -0
  7. package/src/__tests__/edge-tts-provider.test.ts +86 -0
  8. package/src/__tests__/load-yaml.test.ts +78 -0
  9. package/src/__tests__/narrate-command.test.ts +119 -0
  10. package/src/__tests__/openai-provider.test.ts +63 -0
  11. package/src/__tests__/resolve-provider.test.ts +59 -0
  12. package/src/__tests__/scenario-validator.test.ts +353 -0
  13. package/src/__tests__/validate-command.test.ts +80 -0
  14. package/src/commands/narrate.d.ts +15 -0
  15. package/src/commands/narrate.d.ts.map +1 -0
  16. package/src/commands/narrate.js +75 -0
  17. package/src/commands/narrate.js.map +1 -0
  18. package/src/commands/narrate.ts +111 -0
  19. package/src/commands/validate.d.ts +8 -0
  20. package/src/commands/validate.d.ts.map +1 -0
  21. package/src/commands/validate.js +33 -0
  22. package/src/commands/validate.js.map +1 -0
  23. package/src/commands/validate.ts +44 -0
  24. package/src/index.d.ts +4 -0
  25. package/src/index.d.ts.map +1 -0
  26. package/src/index.js +18 -0
  27. package/src/index.js.map +1 -0
  28. package/src/index.ts +22 -0
  29. package/src/tts/echogarden.d.ts +18 -0
  30. package/src/tts/echogarden.d.ts.map +1 -0
  31. package/src/tts/echogarden.js +54 -0
  32. package/src/tts/echogarden.js.map +1 -0
  33. package/src/tts/echogarden.ts +61 -0
  34. package/src/tts/edge-tts.d.ts +19 -0
  35. package/src/tts/edge-tts.d.ts.map +1 -0
  36. package/src/tts/edge-tts.js +63 -0
  37. package/src/tts/edge-tts.js.map +1 -0
  38. package/src/tts/edge-tts.ts +73 -0
  39. package/src/tts/openai.d.ts +7 -0
  40. package/src/tts/openai.d.ts.map +1 -0
  41. package/src/tts/openai.js +54 -0
  42. package/src/tts/openai.js.map +1 -0
  43. package/src/tts/openai.ts +68 -0
  44. package/src/tts/resolve-provider.d.ts +11 -0
  45. package/src/tts/resolve-provider.d.ts.map +1 -0
  46. package/src/tts/resolve-provider.js +47 -0
  47. package/src/tts/resolve-provider.js.map +1 -0
  48. package/src/tts/resolve-provider.ts +61 -0
  49. package/src/tts/types.d.ts +41 -0
  50. package/src/tts/types.d.ts.map +1 -0
  51. package/src/tts/types.js +2 -0
  52. package/src/tts/types.js.map +1 -0
  53. package/src/tts/types.ts +45 -0
  54. package/src/util/load-yaml.d.ts +14 -0
  55. package/src/util/load-yaml.d.ts.map +1 -0
  56. package/src/util/load-yaml.js +55 -0
  57. package/src/util/load-yaml.js.map +1 -0
  58. package/src/util/load-yaml.ts +58 -0
  59. package/src/validate/scenario-validator.d.ts +17 -0
  60. package/src/validate/scenario-validator.d.ts.map +1 -0
  61. package/src/validate/scenario-validator.js +174 -0
  62. package/src/validate/scenario-validator.js.map +1 -0
  63. package/src/validate/scenario-validator.ts +227 -0
  64. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,353 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { validateScenario } from "../validate/scenario-validator.js";
3
+
4
+ function validScenario() {
5
+ return {
6
+ viewport: { width: 896, height: 540 },
7
+ steps: [
8
+ {
9
+ view: "intro",
10
+ delayMs: 0,
11
+ caption: "Welcome",
12
+ narrationText: "Welcome to the demo.",
13
+ },
14
+ ],
15
+ };
16
+ }
17
+
18
+ describe("validateScenario", () => {
19
+ // --- Happy path ---
20
+
21
+ it("accepts a valid scenario", () => {
22
+ const result = validateScenario(validScenario());
23
+ expect(result.valid).toBe(true);
24
+ expect(result.errors).toHaveLength(0);
25
+ });
26
+
27
+ it("accepts a scenario without viewport (optional)", () => {
28
+ const s = validScenario();
29
+ delete (s as Record<string, unknown>)["viewport"];
30
+ expect(validateScenario(s).valid).toBe(true);
31
+ });
32
+
33
+ it("accepts a step without interactions (optional)", () => {
34
+ expect(validateScenario(validScenario()).valid).toBe(true);
35
+ });
36
+
37
+ it("accepts a valid scenario with interactions", () => {
38
+ const s = validScenario();
39
+ s.steps[0] = {
40
+ ...s.steps[0]!,
41
+ interactions: [
42
+ { atPercent: 0.5, type: 5, target: "my-btn" },
43
+ ],
44
+ } as typeof s.steps[0];
45
+ expect(validateScenario(s).valid).toBe(true);
46
+ });
47
+
48
+ // --- Root-level errors ---
49
+
50
+ it("rejects null", () => {
51
+ const result = validateScenario(null);
52
+ expect(result.valid).toBe(false);
53
+ expect(result.errors[0]!.reason).toMatch(/must be an object/);
54
+ });
55
+
56
+ it("rejects non-object", () => {
57
+ const result = validateScenario("not an object");
58
+ expect(result.valid).toBe(false);
59
+ });
60
+
61
+ // --- Steps errors ---
62
+
63
+ it("rejects missing steps", () => {
64
+ const result = validateScenario({ viewport: { width: 800, height: 600 } });
65
+ expect(result.valid).toBe(false);
66
+ expect(result.errors).toContainEqual(
67
+ expect.objectContaining({ path: "steps", reason: expect.stringMatching(/must be an array/) }),
68
+ );
69
+ });
70
+
71
+ it("rejects empty steps array", () => {
72
+ const result = validateScenario({ steps: [] });
73
+ expect(result.valid).toBe(false);
74
+ expect(result.errors).toContainEqual(
75
+ expect.objectContaining({ path: "steps", reason: expect.stringMatching(/at least one/) }),
76
+ );
77
+ });
78
+
79
+ it("rejects step with missing view", () => {
80
+ const result = validateScenario({ steps: [{ delayMs: 0 }] });
81
+ expect(result.valid).toBe(false);
82
+ expect(result.errors).toContainEqual(
83
+ expect.objectContaining({ path: "steps[0].view" }),
84
+ );
85
+ });
86
+
87
+ it("rejects step with empty view string", () => {
88
+ const result = validateScenario({ steps: [{ view: "", delayMs: 0 }] });
89
+ expect(result.valid).toBe(false);
90
+ expect(result.errors).toContainEqual(
91
+ expect.objectContaining({ path: "steps[0].view" }),
92
+ );
93
+ });
94
+
95
+ it("rejects step with negative delayMs", () => {
96
+ const result = validateScenario({ steps: [{ view: "x", delayMs: -1 }] });
97
+ expect(result.valid).toBe(false);
98
+ expect(result.errors).toContainEqual(
99
+ expect.objectContaining({ path: "steps[0].delayMs" }),
100
+ );
101
+ });
102
+
103
+ // --- Viewport errors ---
104
+
105
+ it("rejects viewport with zero width", () => {
106
+ const result = validateScenario({
107
+ viewport: { width: 0, height: 540 },
108
+ steps: [{ view: "x", delayMs: 0 }],
109
+ });
110
+ expect(result.valid).toBe(false);
111
+ expect(result.errors).toContainEqual(
112
+ expect.objectContaining({ path: "viewport.width" }),
113
+ );
114
+ });
115
+
116
+ it("rejects viewport with negative height", () => {
117
+ const result = validateScenario({
118
+ viewport: { width: 800, height: -10 },
119
+ steps: [{ view: "x", delayMs: 0 }],
120
+ });
121
+ expect(result.valid).toBe(false);
122
+ expect(result.errors).toContainEqual(
123
+ expect.objectContaining({ path: "viewport.height" }),
124
+ );
125
+ });
126
+
127
+ // --- Interaction errors ---
128
+
129
+ it("rejects interaction with atPercent below 0", () => {
130
+ const result = validateScenario({
131
+ steps: [{
132
+ view: "x",
133
+ delayMs: 0,
134
+ interactions: [{ atPercent: -0.1, type: 3, target: "btn" }],
135
+ }],
136
+ });
137
+ expect(result.valid).toBe(false);
138
+ expect(result.errors).toContainEqual(
139
+ expect.objectContaining({ path: "steps[0].interactions[0].atPercent" }),
140
+ );
141
+ });
142
+
143
+ it("rejects interaction with atPercent above 1", () => {
144
+ const result = validateScenario({
145
+ steps: [{
146
+ view: "x",
147
+ delayMs: 0,
148
+ interactions: [{ atPercent: 1.5, type: 3, target: "btn" }],
149
+ }],
150
+ });
151
+ expect(result.valid).toBe(false);
152
+ expect(result.errors).toContainEqual(
153
+ expect.objectContaining({ path: "steps[0].interactions[0].atPercent" }),
154
+ );
155
+ });
156
+
157
+ it("rejects interaction with unknown action type number", () => {
158
+ const result = validateScenario({
159
+ steps: [{
160
+ view: "x",
161
+ delayMs: 0,
162
+ interactions: [{ atPercent: 0.5, type: 99, target: "btn" }],
163
+ }],
164
+ });
165
+ expect(result.valid).toBe(false);
166
+ expect(result.errors).toContainEqual(
167
+ expect.objectContaining({ path: "steps[0].interactions[0].type" }),
168
+ );
169
+ });
170
+
171
+ it("rejects interaction with 'unspecified' action type (0)", () => {
172
+ const result = validateScenario({
173
+ steps: [{
174
+ view: "x",
175
+ delayMs: 0,
176
+ interactions: [{ atPercent: 0.5, type: 0, target: "btn" }],
177
+ }],
178
+ });
179
+ expect(result.valid).toBe(false);
180
+ expect(result.errors).toContainEqual(
181
+ expect.objectContaining({
182
+ path: "steps[0].interactions[0].type",
183
+ reason: expect.stringMatching(/unspecified/),
184
+ }),
185
+ );
186
+ });
187
+
188
+ it("accepts action type by string name", () => {
189
+ const result = validateScenario({
190
+ steps: [{
191
+ view: "x",
192
+ delayMs: 0,
193
+ interactions: [{ atPercent: 0.5, type: "click", target: "btn" }],
194
+ }],
195
+ });
196
+ expect(result.valid).toBe(true);
197
+ });
198
+
199
+ it("rejects unknown action type string", () => {
200
+ const result = validateScenario({
201
+ steps: [{
202
+ view: "x",
203
+ delayMs: 0,
204
+ interactions: [{ atPercent: 0.5, type: "explode", target: "btn" }],
205
+ }],
206
+ });
207
+ expect(result.valid).toBe(false);
208
+ });
209
+
210
+ it("rejects missing target for click action", () => {
211
+ const result = validateScenario({
212
+ steps: [{
213
+ view: "x",
214
+ delayMs: 0,
215
+ interactions: [{ atPercent: 0.5, type: 3 }],
216
+ }],
217
+ });
218
+ expect(result.valid).toBe(false);
219
+ expect(result.errors).toContainEqual(
220
+ expect.objectContaining({ path: "steps[0].interactions[0].target" }),
221
+ );
222
+ });
223
+
224
+ // --- Config validation ---
225
+
226
+ it("rejects type action without typeConfig", () => {
227
+ const result = validateScenario({
228
+ steps: [{
229
+ view: "x",
230
+ delayMs: 0,
231
+ interactions: [{ atPercent: 0.5, type: 4, target: "input" }],
232
+ }],
233
+ });
234
+ expect(result.valid).toBe(false);
235
+ expect(result.errors).toContainEqual(
236
+ expect.objectContaining({
237
+ path: "steps[0].interactions[0].typeConfig",
238
+ reason: expect.stringMatching(/required/),
239
+ }),
240
+ );
241
+ });
242
+
243
+ it("rejects typeConfig with empty text", () => {
244
+ const result = validateScenario({
245
+ steps: [{
246
+ view: "x",
247
+ delayMs: 0,
248
+ interactions: [{
249
+ atPercent: 0.5,
250
+ type: 4,
251
+ target: "input",
252
+ typeConfig: { text: "" },
253
+ }],
254
+ }],
255
+ });
256
+ expect(result.valid).toBe(false);
257
+ expect(result.errors).toContainEqual(
258
+ expect.objectContaining({ path: "steps[0].interactions[0].typeConfig.text" }),
259
+ );
260
+ });
261
+
262
+ it("rejects typeConfig with negative typeDelayMs", () => {
263
+ const result = validateScenario({
264
+ steps: [{
265
+ view: "x",
266
+ delayMs: 0,
267
+ interactions: [{
268
+ atPercent: 0.5,
269
+ type: 4,
270
+ target: "input",
271
+ typeConfig: { text: "hello", typeDelayMs: -10 },
272
+ }],
273
+ }],
274
+ });
275
+ expect(result.valid).toBe(false);
276
+ expect(result.errors).toContainEqual(
277
+ expect.objectContaining({ path: "steps[0].interactions[0].typeConfig.typeDelayMs" }),
278
+ );
279
+ });
280
+
281
+ it("rejects hoverConfig with negative hoverDurationMs", () => {
282
+ const result = validateScenario({
283
+ steps: [{
284
+ view: "x",
285
+ delayMs: 0,
286
+ interactions: [{
287
+ atPercent: 0.5,
288
+ type: 5,
289
+ target: "btn",
290
+ hoverConfig: { hoverDurationMs: -1 },
291
+ }],
292
+ }],
293
+ });
294
+ expect(result.valid).toBe(false);
295
+ expect(result.errors).toContainEqual(
296
+ expect.objectContaining({ path: "steps[0].interactions[0].hoverConfig.hoverDurationMs" }),
297
+ );
298
+ });
299
+
300
+ it("rejects drag action without dragConfig", () => {
301
+ const result = validateScenario({
302
+ steps: [{
303
+ view: "x",
304
+ delayMs: 0,
305
+ interactions: [{
306
+ atPercent: 0.5,
307
+ type: 6,
308
+ target: "source",
309
+ }],
310
+ }],
311
+ });
312
+ expect(result.valid).toBe(false);
313
+ expect(result.errors).toContainEqual(
314
+ expect.objectContaining({
315
+ path: "steps[0].interactions[0].dragConfig",
316
+ reason: expect.stringMatching(/required/),
317
+ }),
318
+ );
319
+ });
320
+
321
+ it("rejects dragConfig with empty dragTarget", () => {
322
+ const result = validateScenario({
323
+ steps: [{
324
+ view: "x",
325
+ delayMs: 0,
326
+ interactions: [{
327
+ atPercent: 0.5,
328
+ type: 6,
329
+ target: "source",
330
+ dragConfig: { dragTarget: "" },
331
+ }],
332
+ }],
333
+ });
334
+ expect(result.valid).toBe(false);
335
+ expect(result.errors).toContainEqual(
336
+ expect.objectContaining({ path: "steps[0].interactions[0].dragConfig.dragTarget" }),
337
+ );
338
+ });
339
+
340
+ // --- Collects multiple errors ---
341
+
342
+ it("collects multiple errors across steps and interactions", () => {
343
+ const result = validateScenario({
344
+ viewport: { width: -1, height: 0 },
345
+ steps: [
346
+ { view: "", delayMs: -1 },
347
+ { delayMs: 0 },
348
+ ],
349
+ });
350
+ expect(result.valid).toBe(false);
351
+ expect(result.errors.length).toBeGreaterThanOrEqual(4);
352
+ });
353
+ });
@@ -0,0 +1,80 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { runValidate } from "../commands/validate.js";
3
+
4
+ vi.mock("../util/load-yaml.js", () => ({
5
+ loadScenarioYaml: vi.fn(),
6
+ }));
7
+
8
+ import { loadScenarioYaml } from "../util/load-yaml.js";
9
+
10
+ const mockLoad = vi.mocked(loadScenarioYaml);
11
+
12
+ describe("scenar validate", () => {
13
+ let stdoutData: string;
14
+ let stderrData: string;
15
+ let originalExitCode: number | undefined;
16
+
17
+ beforeEach(() => {
18
+ stdoutData = "";
19
+ stderrData = "";
20
+ originalExitCode = process.exitCode;
21
+ process.exitCode = undefined;
22
+
23
+ vi.spyOn(process.stdout, "write").mockImplementation((chunk: string | Uint8Array) => {
24
+ stdoutData += String(chunk);
25
+ return true;
26
+ });
27
+ vi.spyOn(process.stderr, "write").mockImplementation((chunk: string | Uint8Array) => {
28
+ stderrData += String(chunk);
29
+ return true;
30
+ });
31
+ });
32
+
33
+ afterEach(() => {
34
+ process.exitCode = originalExitCode;
35
+ vi.restoreAllMocks();
36
+ });
37
+
38
+ it("prints success for a valid scenario", async () => {
39
+ mockLoad.mockResolvedValue({
40
+ steps: [{ view: "intro", delayMs: 0 }],
41
+ });
42
+
43
+ await runValidate("demo.yaml", {});
44
+
45
+ expect(stdoutData).toContain("valid");
46
+ expect(process.exitCode).toBeUndefined();
47
+ });
48
+
49
+ it("prints errors and sets exit code 1 for invalid scenario", async () => {
50
+ mockLoad.mockResolvedValue({ steps: [] });
51
+
52
+ await runValidate("bad.yaml", {});
53
+
54
+ expect(stderrData).toContain("error");
55
+ expect(process.exitCode).toBe(1);
56
+ });
57
+
58
+ it("outputs JSON when --json flag is set", async () => {
59
+ mockLoad.mockResolvedValue({
60
+ steps: [{ view: "intro", delayMs: 0 }],
61
+ });
62
+
63
+ await runValidate("demo.yaml", { json: true });
64
+
65
+ const output = JSON.parse(stdoutData);
66
+ expect(output.valid).toBe(true);
67
+ expect(output.errors).toEqual([]);
68
+ });
69
+
70
+ it("outputs JSON with errors and sets exit code 1", async () => {
71
+ mockLoad.mockResolvedValue({ steps: [] });
72
+
73
+ await runValidate("bad.yaml", { json: true });
74
+
75
+ const output = JSON.parse(stdoutData);
76
+ expect(output.valid).toBe(false);
77
+ expect(output.errors.length).toBeGreaterThan(0);
78
+ expect(process.exitCode).toBe(1);
79
+ });
80
+ });
@@ -0,0 +1,15 @@
1
+ import { Command } from "commander";
2
+ import type { TtsProvider } from "../tts/types.js";
3
+ interface NarrateOptions {
4
+ tts: string;
5
+ out: string;
6
+ voice?: string;
7
+ }
8
+ export declare function registerNarrateCommand(program: Command): void;
9
+ /**
10
+ * Core narration logic, separated from provider resolution for testability.
11
+ * The command handler resolves the provider, then delegates here.
12
+ */
13
+ export declare function runNarrate(file: string, options: NarrateOptions, provider: TtsProvider): Promise<void>;
14
+ export {};
15
+ //# sourceMappingURL=narrate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"narrate.d.ts","sourceRoot":"","sources":["../../../src/commands/narrate.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAIpC,OAAO,KAAK,EAA4C,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAE7F,UAAU,cAAc;IACtB,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAY7D;AAOD;;;GAGG;AACH,wBAAsB,UAAU,CAC9B,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,cAAc,EACvB,QAAQ,EAAE,WAAW,GACpB,OAAO,CAAC,IAAI,CAAC,CAqEf"}
@@ -0,0 +1,75 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { loadScenarioYaml } from "../util/load-yaml.js";
4
+ import { validateScenario } from "../validate/scenario-validator.js";
5
+ import { resolveProvider } from "../tts/resolve-provider.js";
6
+ export function registerNarrateCommand(program) {
7
+ program
8
+ .command("narrate")
9
+ .description("Generate narration audio from a scenario YAML file.")
10
+ .argument("<file>", "path to scenario YAML file")
11
+ .option("--tts <provider>", "TTS provider: echogarden (default) or openai", "echogarden")
12
+ .option("--out <dir>", "output directory for audio files", "./narration")
13
+ .option("--voice <voice>", "voice name (provider-specific)")
14
+ .action(async (file, options) => {
15
+ const provider = await resolveProvider(options.tts);
16
+ await runNarrate(file, options, provider);
17
+ });
18
+ }
19
+ /**
20
+ * Core narration logic, separated from provider resolution for testability.
21
+ * The command handler resolves the provider, then delegates here.
22
+ */
23
+ export async function runNarrate(file, options, provider) {
24
+ const scenario = await loadScenarioYaml(file);
25
+ const validation = validateScenario(scenario);
26
+ if (!validation.valid) {
27
+ process.stderr.write(`\x1b[31m✗\x1b[0m Scenario has ${validation.errors.length} error(s). Run 'scenar validate ${file}' for details.\n`);
28
+ process.exitCode = 1;
29
+ return;
30
+ }
31
+ const steps = scenario["steps"];
32
+ if (!steps) {
33
+ process.stderr.write("\x1b[31m✗\x1b[0m No steps found in scenario.\n");
34
+ process.exitCode = 1;
35
+ return;
36
+ }
37
+ const narratedSteps = [];
38
+ for (let i = 0; i < steps.length; i++) {
39
+ const text = steps[i]["narrationText"];
40
+ if (typeof text === "string" && text.length > 0) {
41
+ narratedSteps.push({ index: i, text });
42
+ }
43
+ }
44
+ if (narratedSteps.length === 0) {
45
+ process.stderr.write("\x1b[33m⚠\x1b[0m No steps contain narration text. Nothing to generate.\n");
46
+ return;
47
+ }
48
+ await mkdir(options.out, { recursive: true });
49
+ const manifestSteps = [];
50
+ for (let i = 0; i < narratedSteps.length; i++) {
51
+ const step = narratedSteps[i];
52
+ const fileName = `step-${step.index}.mp3`;
53
+ const outputPath = join(options.out, fileName);
54
+ process.stderr.write(` [${i + 1}/${narratedSteps.length}] Generating audio for step ${step.index}...\n`);
55
+ const result = await provider.synthesize(step.text, {
56
+ voice: options.voice,
57
+ });
58
+ await writeFile(outputPath, result.audio);
59
+ manifestSteps.push({
60
+ index: step.index,
61
+ file: fileName,
62
+ durationMs: result.durationMs,
63
+ text: step.text,
64
+ });
65
+ }
66
+ const manifest = {
67
+ generatedAt: new Date().toISOString(),
68
+ ttsProvider: provider.name,
69
+ steps: manifestSteps,
70
+ };
71
+ const manifestPath = join(options.out, "manifest.json");
72
+ await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
73
+ process.stderr.write(`\n\x1b[32m✓\x1b[0m Generated ${manifestSteps.length} audio file(s) in ${options.out}/\n`);
74
+ }
75
+ //# sourceMappingURL=narrate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"narrate.js","sourceRoot":"","sources":["../../../src/commands/narrate.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,111 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { Command } from "commander";
4
+ import { loadScenarioYaml } from "../util/load-yaml.js";
5
+ import { validateScenario } from "../validate/scenario-validator.js";
6
+ import { resolveProvider } from "../tts/resolve-provider.js";
7
+ import type { NarrationManifest, NarrationManifestStep, TtsProvider } from "../tts/types.js";
8
+
9
+ interface NarrateOptions {
10
+ tts: string;
11
+ out: string;
12
+ voice?: string;
13
+ }
14
+
15
+ export function registerNarrateCommand(program: Command): void {
16
+ program
17
+ .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")
22
+ .option("--voice <voice>", "voice name (provider-specific)")
23
+ .action(async (file: string, options: NarrateOptions) => {
24
+ const provider = await resolveProvider(options.tts);
25
+ await runNarrate(file, options, provider);
26
+ });
27
+ }
28
+
29
+ interface StepWithNarration {
30
+ index: number;
31
+ text: string;
32
+ }
33
+
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,
40
+ options: NarrateOptions,
41
+ provider: TtsProvider,
42
+ ): Promise<void> {
43
+ const scenario = await loadScenarioYaml(file);
44
+ const validation = validateScenario(scenario);
45
+
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;
49
+ return;
50
+ }
51
+
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;
56
+ return;
57
+ }
58
+
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 });
64
+ }
65
+ }
66
+
67
+ if (narratedSteps.length === 0) {
68
+ process.stderr.write("\x1b[33m⚠\x1b[0m No steps contain narration text. Nothing to generate.\n");
69
+ return;
70
+ }
71
+
72
+ await mkdir(options.out, { recursive: true });
73
+
74
+ const manifestSteps: NarrationManifestStep[] = [];
75
+
76
+ for (let i = 0; i < narratedSteps.length; i++) {
77
+ const step = narratedSteps[i]!;
78
+ const fileName = `step-${step.index}.mp3`;
79
+ const outputPath = join(options.out, fileName);
80
+
81
+ process.stderr.write(
82
+ ` [${i + 1}/${narratedSteps.length}] Generating audio for step ${step.index}...\n`,
83
+ );
84
+
85
+ const result = await provider.synthesize(step.text, {
86
+ voice: options.voice,
87
+ });
88
+
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
+ });
97
+ }
98
+
99
+ const manifest: NarrationManifest = {
100
+ generatedAt: new Date().toISOString(),
101
+ ttsProvider: provider.name,
102
+ steps: manifestSteps,
103
+ };
104
+
105
+ const manifestPath = join(options.out, "manifest.json");
106
+ await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
107
+
108
+ process.stderr.write(
109
+ `\n\x1b[32m✓\x1b[0m Generated ${manifestSteps.length} audio file(s) in ${options.out}/\n`,
110
+ );
111
+ }
@@ -0,0 +1,8 @@
1
+ import { Command } from "commander";
2
+ interface ValidateOptions {
3
+ json?: boolean;
4
+ }
5
+ export declare function registerValidateCommand(program: Command): void;
6
+ export declare function runValidate(file: string, options: ValidateOptions): Promise<void>;
7
+ export {};
8
+ //# sourceMappingURL=validate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../../../src/commands/validate.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAIpC,UAAU,eAAe;IACvB,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAED,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAS9D;AAED,wBAAsB,WAAW,CAC/B,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,eAAe,GACvB,OAAO,CAAC,IAAI,CAAC,CAqBf"}
@@ -0,0 +1,33 @@
1
+ import { loadScenarioYaml } from "../util/load-yaml.js";
2
+ import { validateScenario } from "../validate/scenario-validator.js";
3
+ export function registerValidateCommand(program) {
4
+ program
5
+ .command("validate")
6
+ .description("Validate a scenario YAML file against the proto schema.")
7
+ .argument("<file>", "path to scenario YAML file")
8
+ .option("--json", "output validation result as JSON")
9
+ .action(async (file, options) => {
10
+ await runValidate(file, options);
11
+ });
12
+ }
13
+ export async function runValidate(file, options) {
14
+ const scenario = await loadScenarioYaml(file);
15
+ const result = validateScenario(scenario);
16
+ if (options.json) {
17
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
18
+ if (!result.valid)
19
+ process.exitCode = 1;
20
+ return;
21
+ }
22
+ if (result.valid) {
23
+ process.stdout.write(`\x1b[32m✓\x1b[0m Scenario is valid: ${file}\n`);
24
+ return;
25
+ }
26
+ process.stderr.write(`\x1b[31m✗\x1b[0m Scenario has ${result.errors.length} error(s): ${file}\n\n`);
27
+ for (const error of result.errors) {
28
+ process.stderr.write(` \x1b[31m•\x1b[0m ${error.path}: ${error.reason}\n`);
29
+ }
30
+ process.stderr.write("\n");
31
+ process.exitCode = 1;
32
+ }
33
+ //# sourceMappingURL=validate.js.map