@scenar/cli 0.10.0 → 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 (164) 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 +8 -0
  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__/load-bundle.test.ts +67 -0
  17. package/src/__tests__/narrate-command.test.ts +65 -1
  18. package/src/__tests__/narration-cache.test.ts +134 -0
  19. package/src/__tests__/pack-bundle-contract.test.ts +2 -0
  20. package/src/__tests__/pack-generate-embed-entry.test.ts +95 -16
  21. package/src/__tests__/presenter-command.test.ts +366 -0
  22. package/src/__tests__/render-command.test.ts +11 -0
  23. package/src/__tests__/render-generate-entry.test.ts +102 -0
  24. package/src/__tests__/resolve-provider.test.ts +32 -3
  25. package/src/__tests__/scenario-validator.test.ts +174 -0
  26. package/src/__tests__/stage-assets.test.ts +269 -0
  27. package/src/commands/narrate.js +1 -1
  28. package/src/commands/narrate.js.map +1 -1
  29. package/src/commands/narrate.ts +1 -1
  30. package/src/commands/presenter.d.ts +3 -0
  31. package/src/commands/presenter.d.ts.map +1 -0
  32. package/src/commands/presenter.js +89 -0
  33. package/src/commands/presenter.js.map +1 -0
  34. package/src/commands/presenter.ts +107 -0
  35. package/src/commands/render.d.ts.map +1 -1
  36. package/src/commands/render.js +2 -0
  37. package/src/commands/render.js.map +1 -1
  38. package/src/commands/render.ts +6 -0
  39. package/src/embed/embed-snippet.d.ts +5 -0
  40. package/src/embed/embed-snippet.d.ts.map +1 -1
  41. package/src/embed/embed-snippet.js +6 -1
  42. package/src/embed/embed-snippet.js.map +1 -1
  43. package/src/embed/embed-snippet.ts +6 -1
  44. package/src/index.d.ts.map +1 -1
  45. package/src/index.js +2 -0
  46. package/src/index.js.map +1 -1
  47. package/src/index.ts +2 -0
  48. package/src/narrate/run-narrate.d.ts +15 -1
  49. package/src/narrate/run-narrate.d.ts.map +1 -1
  50. package/src/narrate/run-narrate.js +11 -4
  51. package/src/narrate/run-narrate.js.map +1 -1
  52. package/src/narrate/run-narrate.ts +15 -6
  53. package/src/pack/bundle-contract.d.ts +1 -0
  54. package/src/pack/bundle-contract.d.ts.map +1 -1
  55. package/src/pack/bundle-contract.js +3 -0
  56. package/src/pack/bundle-contract.js.map +1 -1
  57. package/src/pack/bundle-contract.ts +3 -0
  58. package/src/pack/collect-pack-shots.d.ts +5 -1
  59. package/src/pack/collect-pack-shots.d.ts.map +1 -1
  60. package/src/pack/collect-pack-shots.js +3 -1
  61. package/src/pack/collect-pack-shots.js.map +1 -1
  62. package/src/pack/collect-pack-shots.ts +15 -1
  63. package/src/pack/generate-embed-entry.d.ts +7 -0
  64. package/src/pack/generate-embed-entry.d.ts.map +1 -1
  65. package/src/pack/generate-embed-entry.js +102 -7
  66. package/src/pack/generate-embed-entry.js.map +1 -1
  67. package/src/pack/generate-embed-entry.ts +107 -7
  68. package/src/pack/run-pack.d.ts +1 -0
  69. package/src/pack/run-pack.d.ts.map +1 -1
  70. package/src/pack/run-pack.js +100 -9
  71. package/src/pack/run-pack.js.map +1 -1
  72. package/src/pack/run-pack.ts +133 -9
  73. package/src/presenter/heygen-client.d.ts +69 -0
  74. package/src/presenter/heygen-client.d.ts.map +1 -0
  75. package/src/presenter/heygen-client.js +154 -0
  76. package/src/presenter/heygen-client.js.map +1 -0
  77. package/src/presenter/heygen-client.ts +224 -0
  78. package/src/presenter/run-presenter.d.ts +67 -0
  79. package/src/presenter/run-presenter.d.ts.map +1 -0
  80. package/src/presenter/run-presenter.js +348 -0
  81. package/src/presenter/run-presenter.js.map +1 -0
  82. package/src/presenter/run-presenter.ts +536 -0
  83. package/src/render/generate-entry.d.ts +4 -0
  84. package/src/render/generate-entry.d.ts.map +1 -1
  85. package/src/render/generate-entry.js +75 -7
  86. package/src/render/generate-entry.js.map +1 -1
  87. package/src/render/generate-entry.ts +84 -7
  88. package/src/render/run-render.d.ts +6 -0
  89. package/src/render/run-render.d.ts.map +1 -1
  90. package/src/render/run-render.js +58 -0
  91. package/src/render/run-render.js.map +1 -1
  92. package/src/render/run-render.ts +72 -0
  93. package/src/render/stage-assets.d.ts +44 -0
  94. package/src/render/stage-assets.d.ts.map +1 -0
  95. package/src/render/stage-assets.js +91 -0
  96. package/src/render/stage-assets.js.map +1 -0
  97. package/src/render/stage-assets.ts +130 -0
  98. package/src/tts/echogarden.d.ts.map +1 -1
  99. package/src/tts/echogarden.js +4 -2
  100. package/src/tts/echogarden.js.map +1 -1
  101. package/src/tts/echogarden.ts +5 -2
  102. package/src/tts/edge-tts.d.ts.map +1 -1
  103. package/src/tts/edge-tts.js +2 -1
  104. package/src/tts/edge-tts.js.map +1 -1
  105. package/src/tts/edge-tts.ts +2 -1
  106. package/src/tts/elevenlabs.d.ts +15 -0
  107. package/src/tts/elevenlabs.d.ts.map +1 -0
  108. package/src/tts/elevenlabs.js +73 -0
  109. package/src/tts/elevenlabs.js.map +1 -0
  110. package/src/tts/elevenlabs.ts +105 -0
  111. package/src/tts/openai.d.ts.map +1 -1
  112. package/src/tts/openai.js +2 -1
  113. package/src/tts/openai.js.map +1 -1
  114. package/src/tts/openai.ts +2 -1
  115. package/src/tts/resolve-provider.d.ts +1 -1
  116. package/src/tts/resolve-provider.d.ts.map +1 -1
  117. package/src/tts/resolve-provider.js +10 -3
  118. package/src/tts/resolve-provider.js.map +1 -1
  119. package/src/tts/resolve-provider.ts +12 -4
  120. package/src/tts/types.d.ts +9 -0
  121. package/src/tts/types.d.ts.map +1 -1
  122. package/src/tts/types.ts +10 -0
  123. package/src/util/confirm.d.ts +11 -0
  124. package/src/util/confirm.d.ts.map +1 -0
  125. package/src/util/confirm.js +24 -0
  126. package/src/util/confirm.js.map +1 -0
  127. package/src/util/confirm.ts +23 -0
  128. package/src/util/load-bundle.d.ts +17 -1
  129. package/src/util/load-bundle.d.ts.map +1 -1
  130. package/src/util/load-bundle.js +19 -11
  131. package/src/util/load-bundle.js.map +1 -1
  132. package/src/util/load-bundle.ts +38 -12
  133. package/src/util/load-ts.d.ts +77 -0
  134. package/src/util/load-ts.d.ts.map +1 -1
  135. package/src/util/load-ts.js +125 -0
  136. package/src/util/load-ts.js.map +1 -1
  137. package/src/util/load-ts.ts +165 -0
  138. package/src/util/narration-cache.d.ts +13 -5
  139. package/src/util/narration-cache.d.ts.map +1 -1
  140. package/src/util/narration-cache.js +10 -8
  141. package/src/util/narration-cache.js.map +1 -1
  142. package/src/util/narration-cache.ts +22 -6
  143. package/src/util/presenter-cache.d.ts +44 -0
  144. package/src/util/presenter-cache.d.ts.map +1 -0
  145. package/src/util/presenter-cache.js +43 -0
  146. package/src/util/presenter-cache.js.map +1 -0
  147. package/src/util/presenter-cache.ts +101 -0
  148. package/src/util/scenario-assets.d.ts +47 -0
  149. package/src/util/scenario-assets.d.ts.map +1 -0
  150. package/src/util/scenario-assets.js +71 -0
  151. package/src/util/scenario-assets.js.map +1 -0
  152. package/src/util/scenario-assets.ts +111 -0
  153. package/src/util/soundtrack-assets.d.ts +34 -0
  154. package/src/util/soundtrack-assets.d.ts.map +1 -0
  155. package/src/util/soundtrack-assets.js +48 -0
  156. package/src/util/soundtrack-assets.js.map +1 -0
  157. package/src/util/soundtrack-assets.ts +69 -0
  158. package/src/validate/scenario-validator.d.ts.map +1 -1
  159. package/src/validate/scenario-validator.js +82 -0
  160. package/src/validate/scenario-validator.js.map +1 -1
  161. package/src/validate/scenario-validator.ts +110 -0
  162. package/tsconfig.tsbuildinfo +1 -1
  163. package/example-bundle/assets/index-DpJUOVBT.js +0 -388
  164. package/example-bundle/assets/style-ix_q0zsc.css +0 -1
@@ -53,4 +53,71 @@ describe("loadBundle", () => {
53
53
  const bundle = await loadBundle("/fake/scenarios/no-audio-demo");
54
54
  expect(bundle.narrationManifest).toBeUndefined();
55
55
  });
56
+
57
+ it("loads the presenter manifest when one exists", async () => {
58
+ const presenterManifest = {
59
+ steps: [{ src: "./step-0.mp4", durationMs: 3000 }, null, null],
60
+ };
61
+ vi.mocked(loadTs.loadStepsFromTs).mockResolvedValue(mockSteps);
62
+ // Only the presenter manifest exists for this scenario.
63
+ vi.mocked(fs.access).mockImplementation((path) =>
64
+ String(path).includes(join("presenter", "manifest.json"))
65
+ ? Promise.resolve()
66
+ : Promise.reject(new Error("ENOENT")),
67
+ );
68
+ vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(presenterManifest) as never);
69
+
70
+ const bundle = await loadBundle("/fake/scenarios/presented-demo");
71
+ expect(bundle.presenterManifest).toEqual(presenterManifest);
72
+ expect(bundle.narrationManifest).toBeUndefined();
73
+ });
74
+
75
+ it("leaves the presenter manifest undefined when none exists", async () => {
76
+ vi.mocked(loadTs.loadStepsFromTs).mockResolvedValue(mockSteps);
77
+ vi.mocked(fs.access).mockRejectedValue(new Error("ENOENT"));
78
+
79
+ const bundle = await loadBundle("/fake/scenarios/plain-demo");
80
+ expect(bundle.presenterManifest).toBeUndefined();
81
+ });
82
+
83
+ it("carries the authored soundtrack from the steps module", async () => {
84
+ const soundtrack = { musicSrc: "./soundtrack/music.mp3", sfx: true };
85
+ vi.mocked(loadTs.loadStepsFromTs).mockResolvedValue(mockSteps);
86
+ vi.mocked(loadTs.loadSoundtrackFromTs).mockResolvedValue(soundtrack);
87
+ vi.mocked(fs.access).mockRejectedValue(new Error("ENOENT"));
88
+
89
+ const bundle = await loadBundle("/fake/scenarios/scored-demo");
90
+ expect(bundle.soundtrack).toEqual(soundtrack);
91
+ });
92
+
93
+ it("leaves soundtrack undefined when the module authors none", async () => {
94
+ vi.mocked(loadTs.loadStepsFromTs).mockResolvedValue(mockSteps);
95
+ vi.mocked(loadTs.loadSoundtrackFromTs).mockResolvedValue(null);
96
+ vi.mocked(fs.access).mockRejectedValue(new Error("ENOENT"));
97
+
98
+ const bundle = await loadBundle("/fake/scenarios/silent-demo");
99
+ expect(bundle.soundtrack).toBeUndefined();
100
+ });
101
+
102
+ it("carries authored title cards without synthesizing card steps", async () => {
103
+ const titleCards = { intro: { title: "Acme" }, outro: { title: "Bye" } };
104
+ vi.mocked(loadTs.loadStepsFromTs).mockResolvedValue(mockSteps);
105
+ vi.mocked(loadTs.loadTitleCardsFromTs).mockResolvedValue(titleCards);
106
+ vi.mocked(fs.access).mockRejectedValue(new Error("ENOENT"));
107
+
108
+ const bundle = await loadBundle("/fake/scenarios/framed-demo");
109
+ expect(bundle.titleCards).toEqual(titleCards);
110
+ // The CLI bundle carries the AUTHORED shape — expansion happens in the
111
+ // generated entries, so the steps stay exactly as written.
112
+ expect(bundle.steps).toHaveLength(3);
113
+ });
114
+
115
+ it("leaves titleCards undefined when the module authors none", async () => {
116
+ vi.mocked(loadTs.loadStepsFromTs).mockResolvedValue(mockSteps);
117
+ vi.mocked(loadTs.loadTitleCardsFromTs).mockResolvedValue(null);
118
+ vi.mocked(fs.access).mockRejectedValue(new Error("ENOENT"));
119
+
120
+ const bundle = await loadBundle("/fake/scenarios/plain-demo");
121
+ expect(bundle.titleCards).toBeUndefined();
122
+ });
56
123
  });
@@ -25,10 +25,14 @@ const mockLoadTs = vi.mocked(loadStepsFromTs);
25
25
  const mockDiscover = vi.mocked(discoverScenarios);
26
26
  const mockResolveProvider = vi.mocked(resolveProvider);
27
27
 
28
- function createMockProvider(responses: Array<{ audio: Buffer; durationMs: number }>): TtsProvider {
28
+ function createMockProvider(
29
+ responses: Array<{ audio: Buffer; durationMs: number }>,
30
+ fingerprint = "test-tts/default-config",
31
+ ): TtsProvider {
29
32
  let callIndex = 0;
30
33
  return {
31
34
  name: "test-tts",
35
+ fingerprint,
32
36
  synthesize: vi.fn(async () => {
33
37
  const response = responses[callIndex];
34
38
  if (!response) throw new Error("unexpected synthesize call");
@@ -108,6 +112,7 @@ describe("scenar narrate — runtime manifest format", () => {
108
112
  expect(manifest).not.toHaveProperty("ttsProvider");
109
113
 
110
114
  const cache = JSON.parse(readFileSync(join(outDir, "my-demo", ".narration-cache.json"), "utf-8"));
115
+ expect(cache.fingerprint).toBe("test-tts/default-config");
111
116
  expect(cache.steps).toHaveLength(3);
112
117
  expect(cache.steps[0]).toHaveProperty("hash");
113
118
  expect(cache.steps[0]).toHaveProperty("durationMs", 2340);
@@ -163,6 +168,65 @@ describe("scenar narrate — runtime manifest format", () => {
163
168
  expect(stderrData).toContain("cached");
164
169
  });
165
170
 
171
+ it("regenerates on second run when the provider fingerprint changes", async () => {
172
+ const scenariosDir = join(tempDir, "scenarios");
173
+ mkdirSync(join(scenariosDir, "switch-demo"), { recursive: true });
174
+ writeFileSync(join(scenariosDir, "switch-demo", "steps.ts"), "");
175
+
176
+ mockDiscover.mockResolvedValue([
177
+ { id: "switch-demo", stepsPath: join(scenariosDir, "switch-demo", "steps.ts") },
178
+ ]);
179
+
180
+ mockLoadTs.mockResolvedValue([
181
+ { delayMs: 0, narration: "Hello world." },
182
+ ]);
183
+
184
+ const outDir = join(tempDir, "out");
185
+
186
+ const { registerNarrateCommand } = await import("../commands/narrate.js");
187
+ const { Command } = await import("commander");
188
+
189
+ const openaiLike = createMockProvider(
190
+ [{ audio: Buffer.from("openai-audio"), durationMs: 1200 }],
191
+ "openai/tts-1/alloy",
192
+ );
193
+ mockResolveProvider.mockResolvedValue(openaiLike);
194
+
195
+ const program1 = new Command();
196
+ registerNarrateCommand(program1);
197
+ await program1.parseAsync([
198
+ "node", "scenar", "narrate", scenariosDir, "--out", outDir,
199
+ ]);
200
+
201
+ expect(openaiLike.synthesize).toHaveBeenCalledTimes(1);
202
+
203
+ // Same text, same voice — but a different provider configuration.
204
+ // The old audio must NOT be served as a cache hit.
205
+ const elevenlabsLike = createMockProvider(
206
+ [{ audio: Buffer.from("elevenlabs-audio"), durationMs: 1350 }],
207
+ "elevenlabs/eleven_multilingual_v2/21m00Tcm4TlvDq8ikWAM",
208
+ );
209
+ mockResolveProvider.mockResolvedValue(elevenlabsLike);
210
+
211
+ const program2 = new Command();
212
+ registerNarrateCommand(program2);
213
+ await program2.parseAsync([
214
+ "node", "scenar", "narrate", scenariosDir, "--out", outDir,
215
+ ]);
216
+
217
+ expect(elevenlabsLike.synthesize).toHaveBeenCalledTimes(1);
218
+
219
+ const manifest = JSON.parse(
220
+ readFileSync(join(outDir, "switch-demo", "manifest.json"), "utf-8"),
221
+ );
222
+ expect(manifest.steps[0].durationMs).toBe(1350);
223
+
224
+ const cache = JSON.parse(
225
+ readFileSync(join(outDir, "switch-demo", ".narration-cache.json"), "utf-8"),
226
+ );
227
+ expect(cache.fingerprint).toBe("elevenlabs/eleven_multilingual_v2/21m00Tcm4TlvDq8ikWAM");
228
+ });
229
+
166
230
  it("warns when no scenarios have narration", async () => {
167
231
  const scenariosDir = join(tempDir, "scenarios");
168
232
  mkdirSync(join(scenariosDir, "empty-demo"), { recursive: true });
@@ -0,0 +1,134 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
5
+ import {
6
+ computeHash,
7
+ isCached,
8
+ buildCacheFile,
9
+ loadCache,
10
+ saveCache,
11
+ } from "../util/narration-cache.js";
12
+
13
+ const FINGERPRINT_A = "openai/tts-1/alloy";
14
+ const FINGERPRINT_B = "elevenlabs/eleven_multilingual_v2/21m00Tcm4TlvDq8ikWAM";
15
+
16
+ describe("computeHash", () => {
17
+ it("is deterministic for identical inputs", () => {
18
+ expect(computeHash("hello", "nova", FINGERPRINT_A)).toBe(
19
+ computeHash("hello", "nova", FINGERPRINT_A),
20
+ );
21
+ });
22
+
23
+ it("changes when the text changes", () => {
24
+ expect(computeHash("hello", "nova", FINGERPRINT_A)).not.toBe(
25
+ computeHash("goodbye", "nova", FINGERPRINT_A),
26
+ );
27
+ });
28
+
29
+ it("changes when the voice changes", () => {
30
+ expect(computeHash("hello", "nova", FINGERPRINT_A)).not.toBe(
31
+ computeHash("hello", "alloy", FINGERPRINT_A),
32
+ );
33
+ });
34
+
35
+ it("changes when the provider fingerprint changes", () => {
36
+ // The bug this guards against: same text and voice narrated by a
37
+ // different provider/model must never collide to the same cache key.
38
+ expect(computeHash("hello", "", FINGERPRINT_A)).not.toBe(
39
+ computeHash("hello", "", FINGERPRINT_B),
40
+ );
41
+ });
42
+
43
+ it("does not collide when a field boundary shifts", () => {
44
+ // Delimited hashing: ("ab", "c") and ("a", "bc") must differ.
45
+ expect(computeHash("ab", "c", FINGERPRINT_A)).not.toBe(
46
+ computeHash("a", "bc", FINGERPRINT_A),
47
+ );
48
+ });
49
+ });
50
+
51
+ describe("isCached", () => {
52
+ const hash = computeHash("hello", "nova", FINGERPRINT_A);
53
+ const cache = buildCacheFile(
54
+ FINGERPRINT_A,
55
+ "nova",
56
+ 2,
57
+ new Map([[0, { hash, durationMs: 1200 }]]),
58
+ );
59
+
60
+ it("hits when fingerprint, voice, and hash all match", () => {
61
+ expect(isCached(cache, 0, hash, "nova", FINGERPRINT_A)).toBe(true);
62
+ });
63
+
64
+ it("misses when the provider fingerprint differs", () => {
65
+ expect(isCached(cache, 0, hash, "nova", FINGERPRINT_B)).toBe(false);
66
+ });
67
+
68
+ it("misses when the voice differs", () => {
69
+ expect(isCached(cache, 0, hash, "echo", FINGERPRINT_A)).toBe(false);
70
+ });
71
+
72
+ it("misses when the step hash differs", () => {
73
+ const otherHash = computeHash("changed text", "nova", FINGERPRINT_A);
74
+ expect(isCached(cache, 0, otherHash, "nova", FINGERPRINT_A)).toBe(false);
75
+ });
76
+
77
+ it("misses for a step with no cache entry", () => {
78
+ expect(isCached(cache, 1, hash, "nova", FINGERPRINT_A)).toBe(false);
79
+ });
80
+
81
+ it("misses when there is no cache file", () => {
82
+ expect(isCached(null, 0, hash, "nova", FINGERPRINT_A)).toBe(false);
83
+ });
84
+ });
85
+
86
+ describe("cache file round-trip and legacy handling", () => {
87
+ let tempDir: string;
88
+
89
+ beforeEach(() => {
90
+ tempDir = mkdtempSync(join(tmpdir(), "scenar-cache-test-"));
91
+ });
92
+
93
+ afterEach(() => {
94
+ rmSync(tempDir, { recursive: true, force: true });
95
+ });
96
+
97
+ it("persists the fingerprint through save and load", async () => {
98
+ const hash = computeHash("hello", "", FINGERPRINT_B);
99
+ const cache = buildCacheFile(
100
+ FINGERPRINT_B,
101
+ "",
102
+ 1,
103
+ new Map([[0, { hash, durationMs: 900 }]]),
104
+ );
105
+
106
+ await saveCache(tempDir, cache);
107
+ const loaded = await loadCache(tempDir);
108
+
109
+ expect(loaded).not.toBeNull();
110
+ expect(isCached(loaded, 0, hash, "", FINGERPRINT_B)).toBe(true);
111
+ expect(isCached(loaded, 0, hash, "", FINGERPRINT_A)).toBe(false);
112
+ });
113
+
114
+ it("treats a legacy cache file without a fingerprint as stale", async () => {
115
+ // Cache files written before the fingerprint field existed must
116
+ // regenerate once rather than serve audio of unknown provenance.
117
+ const legacyHash = computeHash("hello", "", FINGERPRINT_A);
118
+ writeFileSync(
119
+ join(tempDir, ".narration-cache.json"),
120
+ JSON.stringify({
121
+ voice: "",
122
+ steps: [{ hash: legacyHash, durationMs: 900 }],
123
+ }),
124
+ );
125
+
126
+ const loaded = await loadCache(tempDir);
127
+ expect(loaded).not.toBeNull();
128
+ expect(isCached(loaded, 0, legacyHash, "", FINGERPRINT_A)).toBe(false);
129
+ });
130
+
131
+ it("returns null when no cache file exists", async () => {
132
+ expect(await loadCache(tempDir)).toBeNull();
133
+ });
134
+ });
@@ -29,6 +29,7 @@ describe("bundle-contract: validateRelativePath", () => {
29
29
  expect(validateRelativePath("assets/index-abc.css")).toBeNull();
30
30
  expect(validateRelativePath("scenario.json")).toBeNull();
31
31
  expect(validateRelativePath("narration/0.mp3")).toBeNull();
32
+ expect(validateRelativePath("presenter/step-0.mp4")).toBeNull();
32
33
  });
33
34
 
34
35
  it("accepts raster images and modern fonts (the Vite asset case)", () => {
@@ -122,6 +123,7 @@ describe("bundle-contract: content types", () => {
122
123
  css: "text/css",
123
124
  json: "application/json",
124
125
  mp3: "audio/mpeg",
126
+ mp4: "video/mp4",
125
127
  png: "image/png",
126
128
  jpg: "image/jpeg",
127
129
  jpeg: "image/jpeg",
@@ -14,8 +14,37 @@ const BASE = {
14
14
  scenarioId: "welcome-tour",
15
15
  canonicalWidth: 896,
16
16
  shellHeight: 480,
17
+ hasPresenter: false,
17
18
  } as const;
18
19
 
20
+ describe("generateEmbedEntry presenter", () => {
21
+ it("fetches the presenter manifest at runtime and pads it through card synthesis", () => {
22
+ const src = generateEmbedEntry({
23
+ ...BASE,
24
+ hasNarration: false,
25
+ hasPresenter: true,
26
+ providersPath: null,
27
+ });
28
+ expect(src).toContain("usePresenterManifest");
29
+ expect(src).toContain('const _resolvePresenterManifestUrl = () => "./presenter/manifest.json";');
30
+ expect(src).toContain(
31
+ 'const _presenterManifest = usePresenterManifest("welcome-tour", _resolvePresenterManifestUrl);',
32
+ );
33
+ expect(src).toContain(
34
+ "applyTitleCards(_steps as any, undefined, _titleCards, _presenterManifest)",
35
+ );
36
+ expect(src).toContain("presenterManifest: _applied.presenterManifest,");
37
+ });
38
+
39
+ it("emits no presenter wiring when the scenario has none", () => {
40
+ const src = generateEmbedEntry({ ...BASE, hasNarration: false, providersPath: null });
41
+ expect(src).not.toContain("usePresenterManifest");
42
+ expect(src).toContain(
43
+ "applyTitleCards(_steps as any, undefined, _titleCards, undefined)",
44
+ );
45
+ });
46
+ });
47
+
19
48
  describe("generateEmbedEntry", () => {
20
49
  it("mounts ScenarioPlayer into #root with the renderStep contract", () => {
21
50
  const src = generateEmbedEntry({ ...BASE, hasNarration: false, providersPath: null });
@@ -24,7 +53,7 @@ describe("generateEmbedEntry", () => {
24
53
  // embedViewport mirrors the DemoViewport numbers so the ready handshake
25
54
  // announces the exact canonical size the bundle lays out at.
26
55
  expect(src).toContain(
27
- "<ScenarioPlayer bundle={_bundle} embed embedViewport={{ widthPx: 896, heightPx: 480 }} onStepChange={_handleStepChange}>",
56
+ "<ScenarioPlayer bundle={_bundle} embed embedViewport={{ widthPx: 896, heightPx: 480 }} captions={_captions} soundtrackSources={_soundtrackSources} onStepChange={_handleStepChange} onCardStepChange={_handleStepChange}>",
28
57
  );
29
58
  expect(src).toContain("renderStep(data, stepIndex)");
30
59
  expect(src).toContain('getElementById("root")');
@@ -50,12 +79,13 @@ describe("generateEmbedEntry", () => {
50
79
 
51
80
  it("imports the interaction primitives + React hooks the embed wiring needs", () => {
52
81
  const src = generateEmbedEntry({ ...BASE, hasNarration: false, providersPath: null });
53
- expect(src).toContain('import { useCallback, useRef, useState } from "react";');
82
+ expect(src).toContain('import { useCallback, useMemo, useRef, useState } from "react";');
54
83
  // ScenarioPlayer/DemoViewport/SCENAR_CLASS plus Cursor, useStepInteractions,
55
- // ViewportTransformLayer, VIEWPORT_TRANSFORM_IDENTITY (playback) and
56
- // ScenarioCaptureMount (the ?shot capture branch) all come from @scenar/react.
84
+ // ViewportTransformLayer, VIEWPORT_TRANSFORM_IDENTITY (playback),
85
+ // ScenarioCaptureMount (the ?shot capture branch), and applyTitleCards
86
+ // (card synthesis at bundle assembly) all come from @scenar/react.
57
87
  expect(src).toContain(
58
- 'import { ScenarioPlayer, DemoViewport, SCENAR_CLASS, Cursor, useStepInteractions, ViewportTransformLayer, VIEWPORT_TRANSFORM_IDENTITY, ScenarioCaptureMount } from "@scenar/react";',
88
+ 'import { ScenarioPlayer, DemoViewport, SCENAR_CLASS, Cursor, useStepInteractions, ViewportTransformLayer, VIEWPORT_TRANSFORM_IDENTITY, ScenarioCaptureMount, applyTitleCards } from "@scenar/react";',
59
89
  );
60
90
  });
61
91
 
@@ -64,13 +94,17 @@ describe("generateEmbedEntry", () => {
64
94
  // A shared container ref flows to DemoViewport, useStepInteractions, and Cursor.
65
95
  expect(src).toContain("const _containerRef = useRef<HTMLDivElement>(null);");
66
96
  expect(src).toContain("<DemoViewport containerRef={_containerRef}");
67
- // Step index is tracked from the player and fed to the interaction scheduler.
97
+ // Step index is tracked from the player and fed to the interaction
98
+ // scheduler — from BOTH step-change callbacks, so card-step
99
+ // interactions (the outro's housekeeping) reach it too.
68
100
  expect(src).toContain("onStepChange={_handleStepChange}");
101
+ expect(src).toContain("onCardStepChange={_handleStepChange}");
69
102
  expect(src).toContain("useStepInteractions({");
70
103
  expect(src).toContain("setCursorTarget: _setCursorTarget,");
71
104
  expect(src).toContain("setViewportTransform: _setViewport,");
72
105
  expect(src).toContain("cameraRef: _cameraRef,");
73
- expect(src).toContain("steps: _steps,");
106
+ // The scheduler sees the card-expanded step list, not the authored one.
107
+ expect(src).toContain("steps: _applied.steps,");
74
108
  // The cursor lives INSIDE the camera layer with the camera's contentRef as
75
109
  // its container, so it scales and pans with the content during viewport
76
110
  // transitions — the documented contract on ViewportTransformLayer.
@@ -86,16 +120,58 @@ describe("generateEmbedEntry", () => {
86
120
  expect(cursorIndex).toBeLessThan(layerCloseIndex);
87
121
  });
88
122
 
123
+ it("reads ?captions once at module load and forwards it as the player prop", () => {
124
+ const src = generateEmbedEntry({ ...BASE, hasNarration: false, providersPath: null });
125
+ // Same frame-time-preference family as ?theme: module-load read, and
126
+ // absent/other values keep captions off — prior embed behavior.
127
+ expect(src).toContain('new URLSearchParams(window.location.search).get("captions")');
128
+ expect(src).toContain('return v === "1" || v === "true";');
129
+ expect(src).toContain("captions={_captions}");
130
+ });
131
+
132
+ it("discovers the soundtrack at runtime and points its sources at the bundle copies", () => {
133
+ const src = generateEmbedEntry({ ...BASE, hasNarration: false, providersPath: null });
134
+ // Runtime mirror of the CLI's findAuthoredSoundtrack — read from the very
135
+ // module the bundle bakes in, so the embed can never disagree with the
136
+ // authored config. Absent soundtrack yields undefined: prior behavior.
137
+ expect(src).toContain("function _findSoundtrack(");
138
+ expect(src).toContain("soundtrack: _soundtrack,");
139
+ // The SFX sources point at the files pack copies into the bundle.
140
+ expect(src).toContain('click: "./soundtrack/sfx/click.mp3"');
141
+ expect(src).toContain('keystroke: "./soundtrack/sfx/keystroke.mp3"');
142
+ expect(src).toContain("soundtrackSources={_soundtrackSources}");
143
+ });
144
+
89
145
  it("feeds the narration manifest into the interaction scheduler when present", () => {
90
146
  const without = generateEmbedEntry({ ...BASE, hasNarration: false, providersPath: null });
91
- // No narration: interactions are timed by step delays only.
92
- expect(without).toContain("narrationManifest: undefined,");
147
+ // No narration: card synthesis pads nothing and interactions are
148
+ // timed by step delays only.
149
+ expect(without).toContain("applyTitleCards(_steps as any, undefined, _titleCards, undefined)");
93
150
 
94
151
  const withNarration = generateEmbedEntry({ ...BASE, hasNarration: true, providersPath: null });
95
- // With narration: both the bundle and useStepInteractions receive _manifest,
96
- // so interaction timing tracks the spoken-clip durations.
97
- expect(withNarration).toContain("narrationManifest: _manifest,");
98
- expect(withNarration).not.toContain("narrationManifest: undefined,");
152
+ // With narration: _manifest flows through card synthesis (which pads
153
+ // it in lockstep with any injected card steps) into both the bundle
154
+ // and useStepInteractions, so interaction timing tracks the
155
+ // spoken-clip durations.
156
+ expect(withNarration).toContain("applyTitleCards(_steps as any, _manifest, _titleCards, undefined)");
157
+ expect(withNarration).toContain("narrationManifest: _applied.narrationManifest,");
158
+ });
159
+
160
+ it("discovers title cards at runtime and synthesizes card steps inside _App", () => {
161
+ const src = generateEmbedEntry({ ...BASE, hasNarration: true, providersPath: null });
162
+ // Runtime mirror of the CLI's findAuthoredTitleCards, same as soundtrack.
163
+ expect(src).toContain("function _findTitleCards(");
164
+ expect(src).toContain(
165
+ "const _titleCards: any = _findTitleCards(_stepsModule as unknown as Record<string, unknown>);",
166
+ );
167
+ // Applied inside _App (the manifest arrives asynchronously), memoized so
168
+ // the steps identity stays stable and the scheduler is not re-armed.
169
+ expect(src).toContain("const _applied = useMemo(");
170
+ expect(src).toContain("applyTitleCards(_steps as any, _manifest, _titleCards, undefined)");
171
+ expect(src).toContain("steps: _applied.steps as any,");
172
+ // Capture mode (?shot) deliberately stays on the AUTHORED steps: shots
173
+ // live on authored steps and cards declare none.
174
+ expect(src).toContain("steps={_steps}");
99
175
  });
100
176
 
101
177
  it("imports the @scenar/react theme + styles so the bundle is self-contained", () => {
@@ -137,7 +213,7 @@ describe("generateEmbedEntry", () => {
137
213
 
138
214
  it("fetches the narration manifest at runtime only when present", () => {
139
215
  const without = generateEmbedEntry({ ...BASE, hasNarration: false, providersPath: null });
140
- expect(without).toContain("narrationManifest: undefined,");
216
+ expect(without).toContain("applyTitleCards(_steps as any, undefined, _titleCards, undefined)");
141
217
  expect(without).not.toContain("useNarrationManifest");
142
218
  expect(without).not.toContain("import _manifest");
143
219
 
@@ -148,13 +224,16 @@ describe("generateEmbedEntry", () => {
148
224
  // the manifest from its own relative location, so clip src values resolve
149
225
  // against ./narration/ at runtime.
150
226
  expect(withNarration).toContain(
151
- 'import { ScenarioPlayer, DemoViewport, SCENAR_CLASS, Cursor, useStepInteractions, ViewportTransformLayer, VIEWPORT_TRANSFORM_IDENTITY, ScenarioCaptureMount, useNarrationManifest } from "@scenar/react";',
227
+ 'import { ScenarioPlayer, DemoViewport, SCENAR_CLASS, Cursor, useStepInteractions, ViewportTransformLayer, VIEWPORT_TRANSFORM_IDENTITY, ScenarioCaptureMount, applyTitleCards, useNarrationManifest } from "@scenar/react";',
152
228
  );
153
229
  expect(withNarration).toContain('const _resolveManifestUrl = () => "./narration/manifest.json";');
154
230
  expect(withNarration).toContain(
155
231
  'const _manifest = useNarrationManifest("welcome-tour", _resolveManifestUrl);',
156
232
  );
157
- expect(withNarration).toContain("narrationManifest: _manifest,");
233
+ // The fetched manifest flows through card synthesis (which pads it in
234
+ // lockstep with any injected card steps) into the bundle.
235
+ expect(withNarration).toContain("applyTitleCards(_steps as any, _manifest, _titleCards, undefined)");
236
+ expect(withNarration).toContain("narrationManifest: _applied.narrationManifest,");
158
237
  });
159
238
 
160
239
  it("wraps in PreviewProviders only when a providers file exists", () => {