@scenar/cli 0.9.1 → 0.11.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.
Files changed (175) hide show
  1. package/example-bundle/assets/index-DxmqjG-8.js +388 -0
  2. package/example-bundle/assets/style-B2sxnVVZ.css +1 -0
  3. package/example-bundle/embed.js +1 -1
  4. package/example-bundle/index.html +2 -2
  5. package/example-bundle/logo.png +0 -0
  6. package/example-bundle/pack-manifest.json +33 -9
  7. package/example-bundle/soundtrack/music.mp3 +0 -0
  8. package/example-bundle/soundtrack/sfx/click.mp3 +0 -0
  9. package/example-bundle/soundtrack/sfx/keystroke.mp3 +0 -0
  10. package/package.json +4 -4
  11. package/src/__tests__/collect-pack-shots.test.ts +27 -1
  12. package/src/__tests__/elevenlabs-provider.test.ts +187 -0
  13. package/src/__tests__/embed-snippet.test.ts +5 -0
  14. package/src/__tests__/find-authored-soundtrack.test.ts +54 -0
  15. package/src/__tests__/find-authored-title-cards.test.ts +65 -0
  16. package/src/__tests__/find-authored-viewport.test.ts +58 -0
  17. package/src/__tests__/load-bundle.test.ts +67 -0
  18. package/src/__tests__/narrate-command.test.ts +65 -1
  19. package/src/__tests__/narration-cache.test.ts +134 -0
  20. package/src/__tests__/pack-bundle-contract.test.ts +2 -0
  21. package/src/__tests__/pack-generate-embed-entry.test.ts +99 -16
  22. package/src/__tests__/presenter-command.test.ts +366 -0
  23. package/src/__tests__/render-command.test.ts +11 -0
  24. package/src/__tests__/render-generate-entry.test.ts +102 -0
  25. package/src/__tests__/resolve-pack-viewport.test.ts +46 -0
  26. package/src/__tests__/resolve-provider.test.ts +32 -3
  27. package/src/__tests__/scenario-validator.test.ts +174 -0
  28. package/src/__tests__/stage-assets.test.ts +269 -0
  29. package/src/commands/narrate.js +1 -1
  30. package/src/commands/narrate.js.map +1 -1
  31. package/src/commands/narrate.ts +1 -1
  32. package/src/commands/pack.d.ts.map +1 -1
  33. package/src/commands/pack.js +23 -4
  34. package/src/commands/pack.js.map +1 -1
  35. package/src/commands/pack.ts +26 -5
  36. package/src/commands/presenter.d.ts +3 -0
  37. package/src/commands/presenter.d.ts.map +1 -0
  38. package/src/commands/presenter.js +89 -0
  39. package/src/commands/presenter.js.map +1 -0
  40. package/src/commands/presenter.ts +107 -0
  41. package/src/commands/render.d.ts.map +1 -1
  42. package/src/commands/render.js +2 -0
  43. package/src/commands/render.js.map +1 -1
  44. package/src/commands/render.ts +6 -0
  45. package/src/embed/embed-snippet.d.ts +5 -0
  46. package/src/embed/embed-snippet.d.ts.map +1 -1
  47. package/src/embed/embed-snippet.js +6 -1
  48. package/src/embed/embed-snippet.js.map +1 -1
  49. package/src/embed/embed-snippet.ts +6 -1
  50. package/src/index.d.ts.map +1 -1
  51. package/src/index.js +2 -0
  52. package/src/index.js.map +1 -1
  53. package/src/index.ts +2 -0
  54. package/src/narrate/run-narrate.d.ts +15 -1
  55. package/src/narrate/run-narrate.d.ts.map +1 -1
  56. package/src/narrate/run-narrate.js +11 -4
  57. package/src/narrate/run-narrate.js.map +1 -1
  58. package/src/narrate/run-narrate.ts +15 -6
  59. package/src/pack/bundle-contract.d.ts +1 -0
  60. package/src/pack/bundle-contract.d.ts.map +1 -1
  61. package/src/pack/bundle-contract.js +3 -0
  62. package/src/pack/bundle-contract.js.map +1 -1
  63. package/src/pack/bundle-contract.ts +3 -0
  64. package/src/pack/collect-pack-shots.d.ts +16 -5
  65. package/src/pack/collect-pack-shots.d.ts.map +1 -1
  66. package/src/pack/collect-pack-shots.js +13 -4
  67. package/src/pack/collect-pack-shots.js.map +1 -1
  68. package/src/pack/collect-pack-shots.ts +35 -8
  69. package/src/pack/generate-embed-entry.d.ts +7 -0
  70. package/src/pack/generate-embed-entry.d.ts.map +1 -1
  71. package/src/pack/generate-embed-entry.js +105 -7
  72. package/src/pack/generate-embed-entry.js.map +1 -1
  73. package/src/pack/generate-embed-entry.ts +110 -7
  74. package/src/pack/run-pack.d.ts +8 -2
  75. package/src/pack/run-pack.d.ts.map +1 -1
  76. package/src/pack/run-pack.js +120 -17
  77. package/src/pack/run-pack.js.map +1 -1
  78. package/src/pack/run-pack.ts +164 -19
  79. package/src/pack/viewport.d.ts +17 -0
  80. package/src/pack/viewport.d.ts.map +1 -1
  81. package/src/pack/viewport.js +19 -0
  82. package/src/pack/viewport.js.map +1 -1
  83. package/src/pack/viewport.ts +29 -0
  84. package/src/presenter/heygen-client.d.ts +69 -0
  85. package/src/presenter/heygen-client.d.ts.map +1 -0
  86. package/src/presenter/heygen-client.js +154 -0
  87. package/src/presenter/heygen-client.js.map +1 -0
  88. package/src/presenter/heygen-client.ts +224 -0
  89. package/src/presenter/run-presenter.d.ts +67 -0
  90. package/src/presenter/run-presenter.d.ts.map +1 -0
  91. package/src/presenter/run-presenter.js +348 -0
  92. package/src/presenter/run-presenter.js.map +1 -0
  93. package/src/presenter/run-presenter.ts +536 -0
  94. package/src/render/generate-entry.d.ts +4 -0
  95. package/src/render/generate-entry.d.ts.map +1 -1
  96. package/src/render/generate-entry.js +75 -7
  97. package/src/render/generate-entry.js.map +1 -1
  98. package/src/render/generate-entry.ts +84 -7
  99. package/src/render/run-render.d.ts +6 -0
  100. package/src/render/run-render.d.ts.map +1 -1
  101. package/src/render/run-render.js +58 -0
  102. package/src/render/run-render.js.map +1 -1
  103. package/src/render/run-render.ts +72 -0
  104. package/src/render/stage-assets.d.ts +44 -0
  105. package/src/render/stage-assets.d.ts.map +1 -0
  106. package/src/render/stage-assets.js +91 -0
  107. package/src/render/stage-assets.js.map +1 -0
  108. package/src/render/stage-assets.ts +130 -0
  109. package/src/tts/echogarden.d.ts.map +1 -1
  110. package/src/tts/echogarden.js +4 -2
  111. package/src/tts/echogarden.js.map +1 -1
  112. package/src/tts/echogarden.ts +5 -2
  113. package/src/tts/edge-tts.d.ts.map +1 -1
  114. package/src/tts/edge-tts.js +2 -1
  115. package/src/tts/edge-tts.js.map +1 -1
  116. package/src/tts/edge-tts.ts +2 -1
  117. package/src/tts/elevenlabs.d.ts +15 -0
  118. package/src/tts/elevenlabs.d.ts.map +1 -0
  119. package/src/tts/elevenlabs.js +73 -0
  120. package/src/tts/elevenlabs.js.map +1 -0
  121. package/src/tts/elevenlabs.ts +105 -0
  122. package/src/tts/openai.d.ts.map +1 -1
  123. package/src/tts/openai.js +2 -1
  124. package/src/tts/openai.js.map +1 -1
  125. package/src/tts/openai.ts +2 -1
  126. package/src/tts/resolve-provider.d.ts +1 -1
  127. package/src/tts/resolve-provider.d.ts.map +1 -1
  128. package/src/tts/resolve-provider.js +10 -3
  129. package/src/tts/resolve-provider.js.map +1 -1
  130. package/src/tts/resolve-provider.ts +12 -4
  131. package/src/tts/types.d.ts +9 -0
  132. package/src/tts/types.d.ts.map +1 -1
  133. package/src/tts/types.ts +10 -0
  134. package/src/util/confirm.d.ts +11 -0
  135. package/src/util/confirm.d.ts.map +1 -0
  136. package/src/util/confirm.js +24 -0
  137. package/src/util/confirm.js.map +1 -0
  138. package/src/util/confirm.ts +23 -0
  139. package/src/util/load-bundle.d.ts +17 -1
  140. package/src/util/load-bundle.d.ts.map +1 -1
  141. package/src/util/load-bundle.js +19 -11
  142. package/src/util/load-bundle.js.map +1 -1
  143. package/src/util/load-bundle.ts +38 -12
  144. package/src/util/load-ts.d.ts +100 -0
  145. package/src/util/load-ts.d.ts.map +1 -1
  146. package/src/util/load-ts.js +176 -0
  147. package/src/util/load-ts.js.map +1 -1
  148. package/src/util/load-ts.ts +227 -0
  149. package/src/util/narration-cache.d.ts +13 -5
  150. package/src/util/narration-cache.d.ts.map +1 -1
  151. package/src/util/narration-cache.js +10 -8
  152. package/src/util/narration-cache.js.map +1 -1
  153. package/src/util/narration-cache.ts +22 -6
  154. package/src/util/presenter-cache.d.ts +44 -0
  155. package/src/util/presenter-cache.d.ts.map +1 -0
  156. package/src/util/presenter-cache.js +43 -0
  157. package/src/util/presenter-cache.js.map +1 -0
  158. package/src/util/presenter-cache.ts +101 -0
  159. package/src/util/scenario-assets.d.ts +47 -0
  160. package/src/util/scenario-assets.d.ts.map +1 -0
  161. package/src/util/scenario-assets.js +71 -0
  162. package/src/util/scenario-assets.js.map +1 -0
  163. package/src/util/scenario-assets.ts +111 -0
  164. package/src/util/soundtrack-assets.d.ts +34 -0
  165. package/src/util/soundtrack-assets.d.ts.map +1 -0
  166. package/src/util/soundtrack-assets.js +48 -0
  167. package/src/util/soundtrack-assets.js.map +1 -0
  168. package/src/util/soundtrack-assets.ts +69 -0
  169. package/src/validate/scenario-validator.d.ts.map +1 -1
  170. package/src/validate/scenario-validator.js +82 -0
  171. package/src/validate/scenario-validator.js.map +1 -1
  172. package/src/validate/scenario-validator.ts +110 -0
  173. package/tsconfig.tsbuildinfo +1 -1
  174. package/example-bundle/assets/index-DlmG1Uh7.js +0 -388
  175. package/example-bundle/assets/style-BjquDnTE.css +0 -1
@@ -0,0 +1,366 @@
1
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+ vi.mock("../util/load-ts.js", () => ({
7
+ loadStepsFromTs: vi.fn(),
8
+ }));
9
+
10
+ import { loadStepsFromTs } from "../util/load-ts.js";
11
+ import { runPresenter } from "../presenter/run-presenter.js";
12
+
13
+ const mockLoadTs = vi.mocked(loadStepsFromTs);
14
+
15
+ /** A minimal fetch Response stand-in (the client touches only this surface). */
16
+ function fakeResponse(input: {
17
+ status?: number;
18
+ body?: unknown;
19
+ bytes?: Buffer;
20
+ headers?: Record<string, string>;
21
+ }) {
22
+ const status = input.status ?? 200;
23
+ return {
24
+ ok: status >= 200 && status < 300,
25
+ status,
26
+ headers: { get: (key: string) => input.headers?.[key.toLowerCase()] ?? null },
27
+ json: async () => input.body ?? {},
28
+ arrayBuffer: async () => {
29
+ const bytes = input.bytes ?? Buffer.alloc(0);
30
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
31
+ },
32
+ };
33
+ }
34
+
35
+ const CLIP_BYTES = Buffer.from("fake-mp4-bytes");
36
+
37
+ /**
38
+ * Route the HeyGen API surface: upload → create → poll → download.
39
+ * Overridable per test for the failure paths.
40
+ */
41
+ function heygenFetchMock(overrides?: {
42
+ onCreate?: () => ReturnType<typeof fakeResponse>;
43
+ onPoll?: () => ReturnType<typeof fakeResponse>;
44
+ }) {
45
+ return vi.fn(async (url: string | URL, init?: { method?: string }) => {
46
+ const u = String(url);
47
+ if (u.endsWith("/v3/assets")) {
48
+ return fakeResponse({ body: { data: { asset_id: "asset-1" } } });
49
+ }
50
+ if (u.endsWith("/v3/videos") && init?.method === "POST") {
51
+ return overrides?.onCreate?.() ?? fakeResponse({ body: { data: { video_id: "63a1b2c3d4e5" } } });
52
+ }
53
+ if (u.includes("/v3/videos/")) {
54
+ return (
55
+ overrides?.onPoll?.() ??
56
+ fakeResponse({
57
+ body: { data: { status: "completed", video_url: "https://cdn.heygen.test/clip.mp4" } },
58
+ })
59
+ );
60
+ }
61
+ if (u.startsWith("https://cdn.heygen.test/")) {
62
+ return fakeResponse({ bytes: CLIP_BYTES });
63
+ }
64
+ throw new Error(`unexpected fetch: ${u}`);
65
+ });
66
+ }
67
+
68
+ const instantSleep = () => Promise.resolve();
69
+
70
+ describe("scenar presenter", () => {
71
+ let tempDir: string;
72
+ let scenarioDir: string;
73
+ let stepsPath: string;
74
+ let logs: string[];
75
+ const savedEnv = { key: process.env["HEYGEN_API_KEY"], avatar: process.env["HEYGEN_AVATAR_ID"] };
76
+
77
+ /** Two authored steps; step 0 opts in. Narration audio exists for both. */
78
+ const STEPS = [
79
+ { delayMs: 0, narration: "Welcome to the demo.", presenter: true },
80
+ { delayMs: 1500, narration: "That's it." },
81
+ ];
82
+
83
+ function writeNarrationFixture(durations = [4100, 2000]) {
84
+ const narrationDir = join(scenarioDir, "narration");
85
+ mkdirSync(narrationDir, { recursive: true });
86
+ writeFileSync(
87
+ join(narrationDir, "manifest.json"),
88
+ JSON.stringify({
89
+ steps: durations.map((durationMs, i) => ({ src: `./step-${i}.mp3`, durationMs })),
90
+ }),
91
+ );
92
+ durations.forEach((_, i) => {
93
+ writeFileSync(join(narrationDir, `step-${i}.mp3`), `mp3-bytes-${i}`);
94
+ });
95
+ }
96
+
97
+ function run(overrides?: Partial<Parameters<typeof runPresenter>[0]>) {
98
+ return runPresenter({
99
+ target: stepsPath,
100
+ avatar: "de95f90d748e4df3afbec3b7f8078ad8",
101
+ yes: true,
102
+ sleep: instantSleep,
103
+ pollIntervalMs: 0,
104
+ pollTimeoutMs: 1_000,
105
+ onLog: (message) => logs.push(message),
106
+ ...overrides,
107
+ });
108
+ }
109
+
110
+ beforeEach(() => {
111
+ logs = [];
112
+ process.env["HEYGEN_API_KEY"] = "test-key";
113
+ delete process.env["HEYGEN_AVATAR_ID"];
114
+ tempDir = mkdtempSync(join(tmpdir(), "scenar-presenter-"));
115
+ scenarioDir = join(tempDir, "welcome-tour");
116
+ mkdirSync(scenarioDir, { recursive: true });
117
+ stepsPath = join(scenarioDir, "steps.ts");
118
+ writeFileSync(stepsPath, "");
119
+ mockLoadTs.mockResolvedValue(STEPS);
120
+ vi.stubGlobal("fetch", heygenFetchMock());
121
+ });
122
+
123
+ afterEach(() => {
124
+ if (savedEnv.key === undefined) delete process.env["HEYGEN_API_KEY"];
125
+ else process.env["HEYGEN_API_KEY"] = savedEnv.key;
126
+ if (savedEnv.avatar === undefined) delete process.env["HEYGEN_AVATAR_ID"];
127
+ else process.env["HEYGEN_AVATAR_ID"] = savedEnv.avatar;
128
+ vi.unstubAllGlobals();
129
+ vi.clearAllMocks();
130
+ rmSync(tempDir, { recursive: true, force: true });
131
+ });
132
+
133
+ it("generates a clip and writes an authored-keyed manifest with narration durations", async () => {
134
+ writeNarrationFixture();
135
+ const result = await run();
136
+
137
+ expect(result.totalGenerated).toBe(1);
138
+ expect(result.totalFailed).toBe(0);
139
+
140
+ const clipPath = join(scenarioDir, "presenter", "step-0.mp4");
141
+ expect(existsSync(clipPath)).toBe(true);
142
+ expect(readFileSync(clipPath)).toEqual(CLIP_BYTES);
143
+
144
+ const manifest = JSON.parse(
145
+ readFileSync(join(scenarioDir, "presenter", "manifest.json"), "utf-8"),
146
+ );
147
+ // Positional, keyed to AUTHORED steps; durationMs from the narration
148
+ // manifest (B-2) — no media probe anywhere.
149
+ expect(manifest).toEqual({
150
+ steps: [{ src: "./step-0.mp4", durationMs: 4100 }, null],
151
+ });
152
+
153
+ const cache = JSON.parse(
154
+ readFileSync(join(scenarioDir, "presenter", ".presenter-cache.json"), "utf-8"),
155
+ );
156
+ expect(cache.fingerprint).toBe(
157
+ "heygen/iii/720p/mp4/de95f90d748e4df3afbec3b7f8078ad8",
158
+ );
159
+ });
160
+
161
+ it("prints the plan and the uncached-only estimate before generating", async () => {
162
+ writeNarrationFixture();
163
+ await run();
164
+
165
+ const output = logs.join("\n");
166
+ expect(output).toContain(
167
+ "Presenter plan — welcome-tour (engine iii, 720p, avatar de95…8ad8)",
168
+ );
169
+ expect(output).toContain("step 0 4.1s generate");
170
+ // 4.1s at ~$1/min (engine iii) ≈ $0.07.
171
+ expect(output).toContain(
172
+ "Estimated cost: 1 clip(s), 4.1s audio → ~$0.07 (rate: ~$1.00/min, engine iii)",
173
+ );
174
+ });
175
+
176
+ describe("cost gate", () => {
177
+ beforeEach(() => writeNarrationFixture());
178
+
179
+ it("declining the prompt aborts with nothing spent and nothing written", async () => {
180
+ const result = await run({ yes: false, isTty: true, confirmImpl: async () => false });
181
+
182
+ expect(result.aborted).toBe(true);
183
+ expect(result.totalGenerated).toBe(0);
184
+ expect(fetch).not.toHaveBeenCalled();
185
+ expect(existsSync(join(scenarioDir, "presenter"))).toBe(false);
186
+ expect(logs.join("\n")).toContain("Aborted — nothing generated, nothing spent.");
187
+ });
188
+
189
+ it("accepting the prompt proceeds", async () => {
190
+ const result = await run({ yes: false, isTty: true, confirmImpl: async () => true });
191
+ expect(result.aborted).toBe(false);
192
+ expect(result.totalGenerated).toBe(1);
193
+ });
194
+
195
+ it("--yes skips the prompt entirely", async () => {
196
+ const confirmImpl = vi.fn(async () => true);
197
+ await run({ yes: true, confirmImpl });
198
+ expect(confirmImpl).not.toHaveBeenCalled();
199
+ });
200
+
201
+ it("refuses on a non-TTY without --yes — never spend silently", async () => {
202
+ const result = await run({ yes: false, isTty: false });
203
+
204
+ expect(result.aborted).toBe(true);
205
+ expect(fetch).not.toHaveBeenCalled();
206
+ expect(logs.join("\n")).toContain(
207
+ "stdin is not a TTY; pass --yes to confirm the estimated spend.",
208
+ );
209
+ });
210
+ });
211
+
212
+ describe("cache", () => {
213
+ beforeEach(() => writeNarrationFixture());
214
+
215
+ it("a fully-cached rerun makes no network calls and exits without prompting", async () => {
216
+ await run();
217
+ vi.stubGlobal("fetch", heygenFetchMock());
218
+
219
+ const confirmImpl = vi.fn(async () => true);
220
+ const result = await run({ yes: false, isTty: true, confirmImpl });
221
+
222
+ expect(result.totalGenerated).toBe(0);
223
+ expect(result.totalCached).toBe(1);
224
+ expect(result.aborted).toBe(false);
225
+ expect(fetch).not.toHaveBeenCalled();
226
+ expect(confirmImpl).not.toHaveBeenCalled();
227
+ expect(logs.join("\n")).toContain("Nothing to generate — all clips cached.");
228
+ });
229
+
230
+ it("a cached rerun rewrites a deleted manifest without a paid call", async () => {
231
+ await run();
232
+ rmSync(join(scenarioDir, "presenter", "manifest.json"));
233
+ vi.stubGlobal("fetch", heygenFetchMock());
234
+
235
+ await run();
236
+ expect(fetch).not.toHaveBeenCalled();
237
+ const manifest = JSON.parse(
238
+ readFileSync(join(scenarioDir, "presenter", "manifest.json"), "utf-8"),
239
+ );
240
+ expect(manifest.steps[0]).toEqual({ src: "./step-0.mp4", durationMs: 4100 });
241
+ });
242
+
243
+ it("a fingerprint change (different avatar) regenerates", async () => {
244
+ await run();
245
+ vi.stubGlobal("fetch", heygenFetchMock());
246
+
247
+ const result = await run({ avatar: "another-avatar-id-000000" });
248
+ expect(result.totalGenerated).toBe(1);
249
+ expect(fetch).toHaveBeenCalled();
250
+ });
251
+
252
+ it("re-narrated audio (changed mp3 bytes) regenerates that step", async () => {
253
+ await run();
254
+ writeFileSync(join(scenarioDir, "narration", "step-0.mp3"), "different-bytes");
255
+ vi.stubGlobal("fetch", heygenFetchMock());
256
+
257
+ const result = await run();
258
+ expect(result.totalGenerated).toBe(1);
259
+ });
260
+ });
261
+
262
+ describe("failure mapping", () => {
263
+ it("errors before any work when HEYGEN_API_KEY is missing", async () => {
264
+ delete process.env["HEYGEN_API_KEY"];
265
+ await expect(run()).rejects.toThrow(/HEYGEN_API_KEY environment variable is not set/);
266
+ expect(fetch).not.toHaveBeenCalled();
267
+ });
268
+
269
+ it("errors when no avatar id is given by flag or env", async () => {
270
+ await expect(run({ avatar: undefined })).rejects.toThrow(
271
+ "No avatar id. Pass --avatar <id> or set HEYGEN_AVATAR_ID.",
272
+ );
273
+ });
274
+
275
+ it("falls back to HEYGEN_AVATAR_ID when the flag is absent", async () => {
276
+ writeNarrationFixture();
277
+ process.env["HEYGEN_AVATAR_ID"] = "env-avatar-id-00000000";
278
+ const result = await run({ avatar: undefined });
279
+ expect(result.totalGenerated).toBe(1);
280
+ });
281
+
282
+ it("hard-errors listing the steps when narration audio is missing", async () => {
283
+ // No narration fixture written at all.
284
+ await expect(run()).rejects.toThrow(
285
+ `steps 0 opt into the presenter but have no narration audio. ` +
286
+ `Run 'scenar narrate ${stepsPath}' first — presenter clips are derived from narration audio.`,
287
+ );
288
+ expect(fetch).not.toHaveBeenCalled();
289
+ });
290
+
291
+ it("guards an opted-in step without narration text in the validator's wording", async () => {
292
+ mockLoadTs.mockResolvedValue([{ delayMs: 0, presenter: true }]);
293
+ await expect(run()).rejects.toThrow(
294
+ "steps[0].presenter: presenter requires narration_text — " +
295
+ "a presenter clip is derived from the step's narration audio.",
296
+ );
297
+ });
298
+
299
+ it("surfaces a HeyGen failure verbatim, nulls the manifest entry, and reports it", async () => {
300
+ writeNarrationFixture();
301
+ vi.stubGlobal(
302
+ "fetch",
303
+ heygenFetchMock({
304
+ onPoll: () =>
305
+ fakeResponse({
306
+ body: { data: { status: "failed", failure_message: "avatar quota exceeded" } },
307
+ }),
308
+ }),
309
+ );
310
+
311
+ const result = await run();
312
+ expect(result.totalFailed).toBe(1);
313
+ expect(logs.join("\n")).toContain(
314
+ "step 0: HeyGen generation failed: avatar quota exceeded",
315
+ );
316
+
317
+ const manifest = JSON.parse(
318
+ readFileSync(join(scenarioDir, "presenter", "manifest.json"), "utf-8"),
319
+ );
320
+ expect(manifest.steps).toEqual([null, null]);
321
+ });
322
+
323
+ it("honors Retry-After on 429 and succeeds on the retry", async () => {
324
+ writeNarrationFixture();
325
+ let createCalls = 0;
326
+ vi.stubGlobal(
327
+ "fetch",
328
+ heygenFetchMock({
329
+ onCreate: () => {
330
+ createCalls++;
331
+ return createCalls === 1
332
+ ? fakeResponse({ status: 429, headers: { "retry-after": "1" } })
333
+ : fakeResponse({ body: { data: { video_id: "63a1b2c3d4e5" } } });
334
+ },
335
+ }),
336
+ );
337
+
338
+ const result = await run();
339
+ expect(result.totalGenerated).toBe(1);
340
+ expect(createCalls).toBe(2);
341
+ expect(logs.join("\n")).toContain("rate-limited, retrying in 1s (1/3)");
342
+ });
343
+
344
+ it("fails a step that never completes within the poll timeout, naming the video id", async () => {
345
+ writeNarrationFixture();
346
+ vi.stubGlobal(
347
+ "fetch",
348
+ heygenFetchMock({
349
+ onPoll: () => fakeResponse({ body: { data: { status: "processing" } } }),
350
+ }),
351
+ );
352
+
353
+ const result = await run({ pollTimeoutMs: 5, pollIntervalMs: 1, sleep: instantSleep });
354
+ expect(result.totalFailed).toBe(1);
355
+ expect(logs.join("\n")).toContain("check video 63a1b2c3d4e5 in the HeyGen dashboard");
356
+ });
357
+ });
358
+
359
+ it("skips a scenario with no opted-in steps", async () => {
360
+ mockLoadTs.mockResolvedValue([{ delayMs: 0, narration: "Hello." }]);
361
+ const result = await run();
362
+ expect(result.totalSkipped).toBe(1);
363
+ expect(result.scenarios[0]!.skipped).toBe(true);
364
+ expect(fetch).not.toHaveBeenCalled();
365
+ });
366
+ });
@@ -42,6 +42,17 @@ describe("scenar render", () => {
42
42
  expect(optionNames).toContain("--entry");
43
43
  expect(optionNames).toContain("--composition-id");
44
44
  expect(optionNames).toContain("--webpack-override");
45
+ expect(optionNames).toContain("--captions");
46
+ });
47
+
48
+ it("keeps --captions a boolean flag that defaults off", () => {
49
+ const program = createProgram();
50
+ const renderCmd = program.commands.find((c) => c.name() === "render");
51
+ const captions = renderCmd!.options.find((o) => o.long === "--captions");
52
+ expect(captions).toBeDefined();
53
+ // No <value> placeholder: presence of the flag is the opt-in.
54
+ expect(captions!.flags).toBe("--captions");
55
+ expect(captions!.defaultValue).toBeUndefined();
45
56
  });
46
57
 
47
58
  it("defaults fps to 30, width to 1920, height to 1080", () => {
@@ -0,0 +1,102 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { generateRemotionEntry } from "../render/generate-entry.js";
3
+
4
+ const BASE = {
5
+ scenarioDir: "/proj/scenarios/welcome-tour",
6
+ renderFilePath: "/proj/scenarios/welcome-tour/index.tsx",
7
+ scenarioId: "welcome-tour",
8
+ hasNarration: false,
9
+ hasPresenter: false,
10
+ providersPath: null,
11
+ fps: 30,
12
+ width: 1920,
13
+ height: 1080,
14
+ compositionId: "welcome-tour",
15
+ } as const;
16
+
17
+ describe("generateRemotionEntry captions", () => {
18
+ it("emits a plain ScenarioComposition without the flag — prior output unchanged", () => {
19
+ const src = generateRemotionEntry({ ...BASE, captions: false });
20
+ expect(src).toContain("<ScenarioComposition bundle={_bundle}>");
21
+ expect(src).not.toContain("captions");
22
+ });
23
+
24
+ it("bakes the captions prop into the composition when the flag is set", () => {
25
+ const src = generateRemotionEntry({ ...BASE, captions: true });
26
+ expect(src).toContain("<ScenarioComposition bundle={_bundle} captions>");
27
+ });
28
+
29
+ it("bakes the captions prop inside the providers wrapper too", () => {
30
+ const src = generateRemotionEntry({
31
+ ...BASE,
32
+ providersPath: "/proj/.scenar/providers.tsx",
33
+ captions: true,
34
+ });
35
+ expect(src).toContain("<_Providers>");
36
+ expect(src).toContain("<ScenarioComposition bundle={_bundle} captions>");
37
+ });
38
+ });
39
+
40
+ describe("generateRemotionEntry soundtrack", () => {
41
+ it("discovers the soundtrack from the steps module at module-eval time", () => {
42
+ const src = generateRemotionEntry({ ...BASE, captions: false });
43
+ // The runtime mirror of the CLI's findAuthoredSoundtrack: scenario-shaped
44
+ // export first, then the `soundtrack` named export.
45
+ expect(src).toContain("function _findSoundtrack(");
46
+ expect(src).toContain(
47
+ "const _soundtrack: any = _findSoundtrack(_stepsModule as unknown as Record<string, unknown>);",
48
+ );
49
+ });
50
+
51
+ it("carries the discovered soundtrack on the bundle", () => {
52
+ const src = generateRemotionEntry({ ...BASE, captions: false });
53
+ expect(src).toContain("soundtrack: _soundtrack,");
54
+ });
55
+ });
56
+
57
+ describe("generateRemotionEntry title cards", () => {
58
+ it("discovers title cards from the steps module at module-eval time", () => {
59
+ const src = generateRemotionEntry({ ...BASE, captions: false });
60
+ // The runtime mirror of the CLI's findAuthoredTitleCards: scenario-shaped
61
+ // export first, then the `titleCards` named export.
62
+ expect(src).toContain("function _findTitleCards(");
63
+ expect(src).toContain(
64
+ "const _titleCards: any = _findTitleCards(_stepsModule as unknown as Record<string, unknown>);",
65
+ );
66
+ });
67
+
68
+ it("synthesizes card steps before the timeline calculation", () => {
69
+ const src = generateRemotionEntry({ ...BASE, captions: false });
70
+ // Bundle assembly is THE one card-synthesis point; running it before
71
+ // calculateScenarioTimeline is what makes an intro/outro lengthen
72
+ // durationInFrames exactly like an authored step.
73
+ expect(src).toContain(
74
+ 'import { ScenarioComposition, applyTitleCards, calculateScenarioTimeline } from "@scenar/remotion";',
75
+ );
76
+ expect(src).toContain("const _applied = applyTitleCards(_steps, _manifest, _titleCards, _presenterManifest);");
77
+ expect(src.indexOf("const _applied = applyTitleCards(")).toBeLessThan(
78
+ src.indexOf("calculateScenarioTimeline("),
79
+ );
80
+ expect(src).toContain("steps: _applied.steps as any,");
81
+ expect(src).toContain("narrationManifest: _applied.narrationManifest,");
82
+ });
83
+ });
84
+
85
+ describe("generateRemotionEntry presenter", () => {
86
+ it("imports the presenter manifest and pads it through card synthesis", () => {
87
+ const src = generateRemotionEntry({ ...BASE, hasPresenter: true, captions: false });
88
+ expect(src).toContain(
89
+ 'import _presenterManifest from "/proj/scenarios/welcome-tour/presenter/manifest.json";',
90
+ );
91
+ expect(src).toContain(
92
+ "const _applied = applyTitleCards(_steps, _manifest, _titleCards, _presenterManifest);",
93
+ );
94
+ expect(src).toContain("presenterManifest: _applied.presenterManifest,");
95
+ });
96
+
97
+ it("declares an undefined presenter manifest when the scenario has none", () => {
98
+ const src = generateRemotionEntry({ ...BASE, captions: false });
99
+ expect(src).toContain("const _presenterManifest = undefined;");
100
+ expect(src).not.toContain("import _presenterManifest");
101
+ });
102
+ });
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { DEFAULT_VIEWPORT, resolvePackViewport } from "../pack/viewport.js";
3
+
4
+ const AUTHORED = { width: 1440, height: 900 };
5
+
6
+ describe("resolvePackViewport", () => {
7
+ it("explicit options win over the authored viewport", () => {
8
+ expect(resolvePackViewport({ width: 1280, shellHeight: 800 }, AUTHORED)).toEqual({
9
+ width: 1280,
10
+ height: 800,
11
+ source: "explicit",
12
+ });
13
+ });
14
+
15
+ it("the authored viewport wins over the default", () => {
16
+ expect(resolvePackViewport({}, AUTHORED)).toEqual({
17
+ width: 1440,
18
+ height: 900,
19
+ source: "authored",
20
+ });
21
+ });
22
+
23
+ it("falls back to the packer default when nothing is specified", () => {
24
+ expect(resolvePackViewport({}, null)).toEqual({
25
+ width: DEFAULT_VIEWPORT.width,
26
+ height: DEFAULT_VIEWPORT.height,
27
+ source: "default",
28
+ });
29
+ });
30
+
31
+ it("a partial explicit override fills the other axis from the authored viewport", () => {
32
+ expect(resolvePackViewport({ width: 1600 }, AUTHORED)).toEqual({
33
+ width: 1600,
34
+ height: 900,
35
+ source: "explicit",
36
+ });
37
+ });
38
+
39
+ it("a partial explicit override without an authored viewport fills from the default", () => {
40
+ expect(resolvePackViewport({ shellHeight: 1000 }, null)).toEqual({
41
+ width: DEFAULT_VIEWPORT.width,
42
+ height: 1000,
43
+ source: "explicit",
44
+ });
45
+ });
46
+ });
@@ -2,16 +2,36 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
2
2
 
3
3
  vi.mock("../tts/echogarden.js", () => ({
4
4
  isEchogardenAvailable: vi.fn(),
5
- createEchogardenProvider: vi.fn(() => ({ name: "echogarden", synthesize: vi.fn() })),
5
+ createEchogardenProvider: vi.fn(() => ({
6
+ name: "echogarden",
7
+ fingerprint: "echogarden/vits",
8
+ synthesize: vi.fn(),
9
+ })),
6
10
  }));
7
11
 
8
12
  vi.mock("../tts/edge-tts.js", () => ({
9
13
  isEdgeTtsAvailable: vi.fn(),
10
- createEdgeTtsProvider: vi.fn(() => ({ name: "edge-tts", synthesize: vi.fn() })),
14
+ createEdgeTtsProvider: vi.fn(() => ({
15
+ name: "edge-tts",
16
+ fingerprint: "edge-tts/voice",
17
+ synthesize: vi.fn(),
18
+ })),
11
19
  }));
12
20
 
13
21
  vi.mock("../tts/openai.js", () => ({
14
- createOpenAIProvider: vi.fn(() => ({ name: "openai", synthesize: vi.fn() })),
22
+ createOpenAIProvider: vi.fn(() => ({
23
+ name: "openai",
24
+ fingerprint: "openai/tts-1/alloy",
25
+ synthesize: vi.fn(),
26
+ })),
27
+ }));
28
+
29
+ vi.mock("../tts/elevenlabs.js", () => ({
30
+ createElevenLabsProvider: vi.fn(() => ({
31
+ name: "elevenlabs",
32
+ fingerprint: "elevenlabs/model/voice",
33
+ synthesize: vi.fn(),
34
+ })),
15
35
  }));
16
36
 
17
37
  import { resolveProvider } from "../tts/resolve-provider.js";
@@ -31,6 +51,11 @@ describe("resolveProvider", () => {
31
51
  expect(provider.name).toBe("openai");
32
52
  });
33
53
 
54
+ it("returns ElevenLabs provider when requested", async () => {
55
+ const provider = await resolveProvider("elevenlabs");
56
+ expect(provider.name).toBe("elevenlabs");
57
+ });
58
+
34
59
  it("returns Echogarden provider when installed", async () => {
35
60
  mockEchogardenAvailable.mockResolvedValue(true);
36
61
  const provider = await resolveProvider("echogarden");
@@ -56,4 +81,8 @@ describe("resolveProvider", () => {
56
81
  it("throws on unknown provider name", async () => {
57
82
  await expect(resolveProvider("whisper")).rejects.toThrow(/Unknown TTS provider/);
58
83
  });
84
+
85
+ it("lists elevenlabs among supported providers in the unknown-provider error", async () => {
86
+ await expect(resolveProvider("whisper")).rejects.toThrow(/elevenlabs/);
87
+ });
59
88
  });