@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
@@ -97,6 +97,145 @@ export function findAuthoredViewport(exports: Record<string, unknown>): Authored
97
97
  return null;
98
98
  }
99
99
 
100
+ /**
101
+ * A scenario-authored soundtrack, mirroring the proto `SoundtrackConfig`
102
+ * in camelCase (the same shape as `@scenar/core`'s `Soundtrack`).
103
+ */
104
+ export interface AuthoredSoundtrack {
105
+ musicSrc?: string;
106
+ musicVolume?: number;
107
+ duckingVolume?: number;
108
+ sfx?: boolean;
109
+ }
110
+
111
+ /** The soundtrack's known field names — used to pick a clean copy. */
112
+ const SOUNDTRACK_FIELDS = ["musicSrc", "musicVolume", "duckingVolume", "sfx"] as const;
113
+
114
+ function pickSoundtrack(value: unknown): AuthoredSoundtrack | null {
115
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
116
+ const record = value as Record<string, unknown>;
117
+ const soundtrack: Record<string, unknown> = {};
118
+ for (const field of SOUNDTRACK_FIELDS) {
119
+ if (record[field] !== undefined) soundtrack[field] = record[field];
120
+ }
121
+ return soundtrack as AuthoredSoundtrack;
122
+ }
123
+
124
+ /**
125
+ * Find the scenario's authored soundtrack in a loaded module's exports,
126
+ * or null when the module authors none.
127
+ *
128
+ * Mirrors {@link findAuthoredViewport}'s two authored forms and
129
+ * precedence exactly:
130
+ *
131
+ * 1. A scenario-shaped export — an object carrying both a `soundtrack`
132
+ * and a delayMs-bearing `steps` array (what `createScenario()`
133
+ * returns when the author passes a soundtrack).
134
+ * 2. An export *named* `soundtrack` — the directory-form counterpart,
135
+ * for authors of a raw steps array.
136
+ *
137
+ * Field validity (volume ranges, non-empty musicSrc) is the validator's
138
+ * concern, not discovery's — this only locates the authored object.
139
+ */
140
+ export function findAuthoredSoundtrack(
141
+ exports: Record<string, unknown>,
142
+ ): AuthoredSoundtrack | null {
143
+ for (const value of Object.values(exports)) {
144
+ if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
145
+ const record = value as Record<string, unknown>;
146
+ if (record["soundtrack"] !== undefined && isStepsShape(record["steps"])) {
147
+ return pickSoundtrack(record["soundtrack"]);
148
+ }
149
+ }
150
+ if (exports["soundtrack"] !== undefined) {
151
+ return pickSoundtrack(exports["soundtrack"]);
152
+ }
153
+ return null;
154
+ }
155
+
156
+ /**
157
+ * A scenario-authored title card, mirroring the proto `TitleCard`
158
+ * in camelCase (the same shape as `@scenar/core`'s `TitleCard`).
159
+ */
160
+ export interface AuthoredTitleCard {
161
+ title?: string;
162
+ subtitle?: string;
163
+ logoSrc?: string;
164
+ ctaText?: string;
165
+ durationMs?: number;
166
+ }
167
+
168
+ /**
169
+ * A scenario-authored title-cards config, mirroring the proto
170
+ * `TitleCardsConfig` in camelCase.
171
+ */
172
+ export interface AuthoredTitleCards {
173
+ intro?: AuthoredTitleCard;
174
+ outro?: AuthoredTitleCard;
175
+ }
176
+
177
+ /** The card's known field names — used to pick a clean copy. */
178
+ const TITLE_CARD_FIELDS = [
179
+ "title",
180
+ "subtitle",
181
+ "logoSrc",
182
+ "ctaText",
183
+ "durationMs",
184
+ ] as const;
185
+
186
+ function pickTitleCard(value: unknown): AuthoredTitleCard | null {
187
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
188
+ const record = value as Record<string, unknown>;
189
+ const card: Record<string, unknown> = {};
190
+ for (const field of TITLE_CARD_FIELDS) {
191
+ if (record[field] !== undefined) card[field] = record[field];
192
+ }
193
+ return card as AuthoredTitleCard;
194
+ }
195
+
196
+ function pickTitleCards(value: unknown): AuthoredTitleCards | null {
197
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
198
+ const record = value as Record<string, unknown>;
199
+ const cards: AuthoredTitleCards = {};
200
+ const intro = pickTitleCard(record["intro"]);
201
+ const outro = pickTitleCard(record["outro"]);
202
+ if (intro) cards.intro = intro;
203
+ if (outro) cards.outro = outro;
204
+ return cards;
205
+ }
206
+
207
+ /**
208
+ * Find the scenario's authored title cards in a loaded module's exports,
209
+ * or null when the module authors none.
210
+ *
211
+ * Mirrors {@link findAuthoredSoundtrack}'s two authored forms and
212
+ * precedence exactly:
213
+ *
214
+ * 1. A scenario-shaped export — an object carrying both a `titleCards`
215
+ * and a delayMs-bearing `steps` array (what `createScenario()`
216
+ * returns when the author passes title cards).
217
+ * 2. An export *named* `titleCards` — the directory-form counterpart,
218
+ * for authors of a raw steps array.
219
+ *
220
+ * Field validity (non-empty title, positive duration) is the validator's
221
+ * concern, not discovery's — this only locates the authored object.
222
+ */
223
+ export function findAuthoredTitleCards(
224
+ exports: Record<string, unknown>,
225
+ ): AuthoredTitleCards | null {
226
+ for (const value of Object.values(exports)) {
227
+ if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
228
+ const record = value as Record<string, unknown>;
229
+ if (record["titleCards"] !== undefined && isStepsShape(record["steps"])) {
230
+ return pickTitleCards(record["titleCards"]);
231
+ }
232
+ }
233
+ if (exports["titleCards"] !== undefined) {
234
+ return pickTitleCards(exports["titleCards"]);
235
+ }
236
+ return null;
237
+ }
238
+
100
239
  /**
101
240
  * Dynamically import a TypeScript steps file and extract the
102
241
  * steps array by duck-typing ({@link findStepsArray}).
@@ -113,3 +252,29 @@ export async function loadStepsFromTs(filePath: string): Promise<ImportedStep[]>
113
252
  }
114
253
  return steps;
115
254
  }
255
+
256
+ /**
257
+ * Dynamically import a TypeScript steps file and extract the authored
258
+ * soundtrack ({@link findAuthoredSoundtrack}), or null when the module
259
+ * authors none. Node caches the module, so calling this alongside
260
+ * {@link loadStepsFromTs} costs one import.
261
+ */
262
+ export async function loadSoundtrackFromTs(
263
+ filePath: string,
264
+ ): Promise<AuthoredSoundtrack | null> {
265
+ const mod = await import(pathToFileURL(filePath).href);
266
+ return findAuthoredSoundtrack(mod.default ?? mod);
267
+ }
268
+
269
+ /**
270
+ * Dynamically import a TypeScript steps file and extract the authored
271
+ * title cards ({@link findAuthoredTitleCards}), or null when the module
272
+ * authors none. Node caches the module, so calling this alongside
273
+ * {@link loadStepsFromTs} costs one import.
274
+ */
275
+ export async function loadTitleCardsFromTs(
276
+ filePath: string,
277
+ ): Promise<AuthoredTitleCards | null> {
278
+ const mod = await import(pathToFileURL(filePath).href);
279
+ return findAuthoredTitleCards(mod.default ?? mod);
280
+ }
@@ -3,20 +3,28 @@ interface CacheEntry {
3
3
  durationMs: number;
4
4
  }
5
5
  interface CacheFile {
6
+ /**
7
+ * The generating provider's cache identity (see `TtsProvider.fingerprint`).
8
+ * Audio from one provider/model/default-voice configuration must never be
9
+ * served as a cache hit for another. Legacy cache files predate this field;
10
+ * they never match and regenerate once.
11
+ */
12
+ fingerprint: string;
6
13
  voice: string;
7
14
  steps: (CacheEntry | null)[];
8
15
  }
9
- export declare function computeHash(narration: string, voice: string): string;
16
+ export declare function computeHash(narration: string, voice: string, fingerprint: string): string;
10
17
  export declare function loadCache(outputDir: string): Promise<CacheFile | null>;
11
18
  export declare function saveCache(outputDir: string, cache: CacheFile): Promise<void>;
12
19
  export declare function fileExists(filePath: string): Promise<boolean>;
13
20
  /**
14
- * Check whether a step's narration is already cached (matching hash
15
- * and the MP3 file still exists on disk).
21
+ * Check whether a step's narration is already cached: the cache file was
22
+ * written by the same provider configuration (fingerprint) and voice, the
23
+ * step's hash matches, and the MP3 file still exists on disk.
16
24
  */
17
- export declare function isCached(cache: CacheFile | null, stepIndex: number, hash: string, voice: string): boolean;
25
+ export declare function isCached(cache: CacheFile | null, stepIndex: number, hash: string, voice: string, fingerprint: string): boolean;
18
26
  export declare function getCachedDuration(cache: CacheFile, stepIndex: number): number;
19
- export declare function buildCacheFile(voice: string, totalSteps: number, entries: Map<number, {
27
+ export declare function buildCacheFile(fingerprint: string, voice: string, totalSteps: number, entries: Map<number, {
20
28
  hash: string;
21
29
  durationMs: number;
22
30
  }>): CacheFile;
@@ -1 +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"}
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;;;;;OAKG;IACH,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,CAAC,UAAU,GAAG,IAAI,CAAC,EAAE,CAAC;CAC9B;AAID,wBAAgB,WAAW,CACzB,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,EACb,WAAW,EAAE,MAAM,GAClB,MAAM,CAIR;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;;;;GAIG;AACH,wBAAgB,QAAQ,CACtB,KAAK,EAAE,SAAS,GAAG,IAAI,EACvB,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,EACb,WAAW,EAAE,MAAM,GAClB,OAAO,CAMT;AAED,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,SAAS,EAChB,SAAS,EAAE,MAAM,GAChB,MAAM,CAER;AAED,wBAAgB,cAAc,CAC5B,WAAW,EAAE,MAAM,EACnB,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"}
@@ -2,9 +2,9 @@ import { createHash } from "node:crypto";
2
2
  import { readFile, writeFile, access } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  const CACHE_FILENAME = ".narration-cache.json";
5
- export function computeHash(narration, voice) {
5
+ export function computeHash(narration, voice, fingerprint) {
6
6
  return createHash("sha256")
7
- .update(`${voice}\0${narration}`)
7
+ .update(`${fingerprint}\0${voice}\0${narration}`)
8
8
  .digest("hex");
9
9
  }
10
10
  export async function loadCache(outputDir) {
@@ -29,23 +29,25 @@ export async function fileExists(filePath) {
29
29
  }
30
30
  }
31
31
  /**
32
- * Check whether a step's narration is already cached (matching hash
33
- * and the MP3 file still exists on disk).
32
+ * Check whether a step's narration is already cached: the cache file was
33
+ * written by the same provider configuration (fingerprint) and voice, the
34
+ * step's hash matches, and the MP3 file still exists on disk.
34
35
  */
35
- export function isCached(cache, stepIndex, hash, voice) {
36
- if (!cache || cache.voice !== voice)
36
+ export function isCached(cache, stepIndex, hash, voice, fingerprint) {
37
+ if (!cache || cache.fingerprint !== fingerprint || cache.voice !== voice) {
37
38
  return false;
39
+ }
38
40
  const entry = cache.steps[stepIndex];
39
41
  return entry !== null && entry !== undefined && entry.hash === hash;
40
42
  }
41
43
  export function getCachedDuration(cache, stepIndex) {
42
44
  return cache.steps[stepIndex].durationMs;
43
45
  }
44
- export function buildCacheFile(voice, totalSteps, entries) {
46
+ export function buildCacheFile(fingerprint, voice, totalSteps, entries) {
45
47
  const steps = Array.from({ length: totalSteps }, () => null);
46
48
  for (const [idx, entry] of entries) {
47
49
  steps[idx] = entry;
48
50
  }
49
- return { voice, steps };
51
+ return { fingerprint, voice, steps };
50
52
  }
51
53
  //# sourceMappingURL=narration-cache.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"narration-cache.js","sourceRoot":"","sources":["../../../src/util/narration-cache.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAC/D,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAYjC,MAAM,cAAc,GAAG,uBAAuB,CAAC;AAE/C,MAAM,UAAU,WAAW,CAAC,SAAiB,EAAE,KAAa;IAC1D,OAAO,UAAU,CAAC,QAAQ,CAAC;SACxB,MAAM,CAAC,GAAG,KAAK,KAAK,SAAS,EAAE,CAAC;SAChC,MAAM,CAAC,KAAK,CAAC,CAAC;AACnB,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,SAAS,CAAC,SAAiB;IAC/C,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,QAAQ,CAAC,IAAI,CAAC,SAAS,EAAE,cAAc,CAAC,EAAE,OAAO,CAAC,CAAC;QACrE,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CAAc,CAAC;IACtC,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,SAAS,CAC7B,SAAiB,EACjB,KAAgB;IAEhB,MAAM,SAAS,CACb,IAAI,CAAC,SAAS,EAAE,cAAc,CAAC,EAC/B,IAAI,CAAC,SAAS,CAAC,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC,GAAG,IAAI,CACtC,CAAC;AACJ,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,UAAU,CAAC,QAAgB;IAC/C,IAAI,CAAC;QACH,MAAM,MAAM,CAAC,QAAQ,CAAC,CAAC;QACvB,OAAO,IAAI,CAAC;IACd,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,QAAQ,CACtB,KAAuB,EACvB,SAAiB,EACjB,IAAY,EACZ,KAAa;IAEb,IAAI,CAAC,KAAK,IAAI,KAAK,CAAC,KAAK,KAAK,KAAK;QAAE,OAAO,KAAK,CAAC;IAClD,MAAM,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC;IACrC,OAAO,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,CAAC,IAAI,KAAK,IAAI,CAAC;AACtE,CAAC;AAED,MAAM,UAAU,iBAAiB,CAC/B,KAAgB,EAChB,SAAiB;IAEjB,OAAO,KAAK,CAAC,KAAK,CAAC,SAAS,CAAE,CAAC,UAAU,CAAC;AAC5C,CAAC;AAED,MAAM,UAAU,cAAc,CAC5B,KAAa,EACb,UAAkB,EAClB,OAA0D;IAE1D,MAAM,KAAK,GAA0B,KAAK,CAAC,IAAI,CAC7C,EAAE,MAAM,EAAE,UAAU,EAAE,EACtB,GAAG,EAAE,CAAC,IAAI,CACX,CAAC;IACF,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,OAAO,EAAE,CAAC;QACnC,KAAK,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC;IACrB,CAAC;IACD,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;AAC1B,CAAC"}
1
+ {"version":3,"file":"narration-cache.js","sourceRoot":"","sources":["../../../src/util/narration-cache.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAC/D,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAmBjC,MAAM,cAAc,GAAG,uBAAuB,CAAC;AAE/C,MAAM,UAAU,WAAW,CACzB,SAAiB,EACjB,KAAa,EACb,WAAmB;IAEnB,OAAO,UAAU,CAAC,QAAQ,CAAC;SACxB,MAAM,CAAC,GAAG,WAAW,KAAK,KAAK,KAAK,SAAS,EAAE,CAAC;SAChD,MAAM,CAAC,KAAK,CAAC,CAAC;AACnB,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,SAAS,CAAC,SAAiB;IAC/C,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,QAAQ,CAAC,IAAI,CAAC,SAAS,EAAE,cAAc,CAAC,EAAE,OAAO,CAAC,CAAC;QACrE,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CAAc,CAAC;IACtC,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,SAAS,CAC7B,SAAiB,EACjB,KAAgB;IAEhB,MAAM,SAAS,CACb,IAAI,CAAC,SAAS,EAAE,cAAc,CAAC,EAC/B,IAAI,CAAC,SAAS,CAAC,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC,GAAG,IAAI,CACtC,CAAC;AACJ,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,UAAU,CAAC,QAAgB;IAC/C,IAAI,CAAC;QACH,MAAM,MAAM,CAAC,QAAQ,CAAC,CAAC;QACvB,OAAO,IAAI,CAAC;IACd,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,QAAQ,CACtB,KAAuB,EACvB,SAAiB,EACjB,IAAY,EACZ,KAAa,EACb,WAAmB;IAEnB,IAAI,CAAC,KAAK,IAAI,KAAK,CAAC,WAAW,KAAK,WAAW,IAAI,KAAK,CAAC,KAAK,KAAK,KAAK,EAAE,CAAC;QACzE,OAAO,KAAK,CAAC;IACf,CAAC;IACD,MAAM,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC;IACrC,OAAO,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,CAAC,IAAI,KAAK,IAAI,CAAC;AACtE,CAAC;AAED,MAAM,UAAU,iBAAiB,CAC/B,KAAgB,EAChB,SAAiB;IAEjB,OAAO,KAAK,CAAC,KAAK,CAAC,SAAS,CAAE,CAAC,UAAU,CAAC;AAC5C,CAAC;AAED,MAAM,UAAU,cAAc,CAC5B,WAAmB,EACnB,KAAa,EACb,UAAkB,EAClB,OAA0D;IAE1D,MAAM,KAAK,GAA0B,KAAK,CAAC,IAAI,CAC7C,EAAE,MAAM,EAAE,UAAU,EAAE,EACtB,GAAG,EAAE,CAAC,IAAI,CACX,CAAC;IACF,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,OAAO,EAAE,CAAC;QACnC,KAAK,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC;IACrB,CAAC;IACD,OAAO,EAAE,WAAW,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;AACvC,CAAC"}
@@ -8,15 +8,26 @@ interface CacheEntry {
8
8
  }
9
9
 
10
10
  interface CacheFile {
11
+ /**
12
+ * The generating provider's cache identity (see `TtsProvider.fingerprint`).
13
+ * Audio from one provider/model/default-voice configuration must never be
14
+ * served as a cache hit for another. Legacy cache files predate this field;
15
+ * they never match and regenerate once.
16
+ */
17
+ fingerprint: string;
11
18
  voice: string;
12
19
  steps: (CacheEntry | null)[];
13
20
  }
14
21
 
15
22
  const CACHE_FILENAME = ".narration-cache.json";
16
23
 
17
- export function computeHash(narration: string, voice: string): string {
24
+ export function computeHash(
25
+ narration: string,
26
+ voice: string,
27
+ fingerprint: string,
28
+ ): string {
18
29
  return createHash("sha256")
19
- .update(`${voice}\0${narration}`)
30
+ .update(`${fingerprint}\0${voice}\0${narration}`)
20
31
  .digest("hex");
21
32
  }
22
33
 
@@ -49,16 +60,20 @@ export async function fileExists(filePath: string): Promise<boolean> {
49
60
  }
50
61
 
51
62
  /**
52
- * Check whether a step's narration is already cached (matching hash
53
- * and the MP3 file still exists on disk).
63
+ * Check whether a step's narration is already cached: the cache file was
64
+ * written by the same provider configuration (fingerprint) and voice, the
65
+ * step's hash matches, and the MP3 file still exists on disk.
54
66
  */
55
67
  export function isCached(
56
68
  cache: CacheFile | null,
57
69
  stepIndex: number,
58
70
  hash: string,
59
71
  voice: string,
72
+ fingerprint: string,
60
73
  ): boolean {
61
- if (!cache || cache.voice !== voice) return false;
74
+ if (!cache || cache.fingerprint !== fingerprint || cache.voice !== voice) {
75
+ return false;
76
+ }
62
77
  const entry = cache.steps[stepIndex];
63
78
  return entry !== null && entry !== undefined && entry.hash === hash;
64
79
  }
@@ -71,6 +86,7 @@ export function getCachedDuration(
71
86
  }
72
87
 
73
88
  export function buildCacheFile(
89
+ fingerprint: string,
74
90
  voice: string,
75
91
  totalSteps: number,
76
92
  entries: Map<number, { hash: string; durationMs: number }>,
@@ -82,5 +98,5 @@ export function buildCacheFile(
82
98
  for (const [idx, entry] of entries) {
83
99
  steps[idx] = entry;
84
100
  }
85
- return { voice, steps };
101
+ return { fingerprint, voice, steps };
86
102
  }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Presenter clip cache — the `.narration-cache.json` pattern applied to
3
+ * `scenar presenter`, where a cache hit saves real money (a HeyGen
4
+ * generation costs dollars, not milliseconds).
5
+ *
6
+ * The per-step hash keys on the narration MP3 **bytes**: the audio is
7
+ * the entire upstream state of a clip, so re-narrating a step (new
8
+ * voice, new text, new provider) invalidates its presenter clip
9
+ * automatically — no coordination needed between the two commands.
10
+ */
11
+ interface PresenterCacheEntry {
12
+ hash: string;
13
+ durationMs: number;
14
+ }
15
+ export interface PresenterCacheFile {
16
+ /**
17
+ * The generation configuration this cache was written under:
18
+ * `heygen/<engine>/<resolution>/<container>/<avatarId>`. Any change
19
+ * regenerates everything — a clip from one avatar or quality tier
20
+ * must never be served as a hit for another. The container is
21
+ * recorded even though v1 is mp4-only, so a future webm option
22
+ * invalidates correctly.
23
+ */
24
+ fingerprint: string;
25
+ steps: (PresenterCacheEntry | null)[];
26
+ }
27
+ /** Compose the cache fingerprint for one generation configuration. */
28
+ export declare function presenterFingerprint(engine: string, resolution: string, container: string, avatarId: string): string;
29
+ /** Hash a step's narration audio bytes — the clip's entire upstream state. */
30
+ export declare function computePresenterHash(audioBytes: Buffer): string;
31
+ export declare function loadPresenterCache(outputDir: string): Promise<PresenterCacheFile | null>;
32
+ export declare function savePresenterCache(outputDir: string, cache: PresenterCacheFile): Promise<void>;
33
+ /**
34
+ * Whether a step's clip is already cached under this fingerprint. The
35
+ * caller still verifies the clip file exists on disk — a cache entry
36
+ * for a deleted file regenerates.
37
+ */
38
+ export declare function isPresenterCached(cache: PresenterCacheFile | null, stepIndex: number, hash: string, fingerprint: string): boolean;
39
+ export declare function buildPresenterCacheFile(fingerprint: string, totalSteps: number, entries: Map<number, {
40
+ hash: string;
41
+ durationMs: number;
42
+ }>): PresenterCacheFile;
43
+ export {};
44
+ //# sourceMappingURL=presenter-cache.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"presenter-cache.d.ts","sourceRoot":"","sources":["../../../src/util/presenter-cache.ts"],"names":[],"mappings":"AAIA;;;;;;;;;GASG;AAEH,UAAU,mBAAmB;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,kBAAkB;IACjC;;;;;;;OAOG;IACH,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,CAAC,mBAAmB,GAAG,IAAI,CAAC,EAAE,CAAC;CACvC;AAID,sEAAsE;AACtE,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,MAAM,GACf,MAAM,CAER;AAED,8EAA8E;AAC9E,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAE/D;AAED,wBAAsB,kBAAkB,CACtC,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC,CAOpC;AAED,wBAAsB,kBAAkB,CACtC,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,kBAAkB,GACxB,OAAO,CAAC,IAAI,CAAC,CAKf;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,kBAAkB,GAAG,IAAI,EAChC,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,MAAM,GAClB,OAAO,CAIT;AAED,wBAAgB,uBAAuB,CACrC,WAAW,EAAE,MAAM,EACnB,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,CAAC,GACzD,kBAAkB,CASpB"}
@@ -0,0 +1,43 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ const CACHE_FILENAME = ".presenter-cache.json";
5
+ /** Compose the cache fingerprint for one generation configuration. */
6
+ export function presenterFingerprint(engine, resolution, container, avatarId) {
7
+ return `heygen/${engine}/${resolution}/${container}/${avatarId}`;
8
+ }
9
+ /** Hash a step's narration audio bytes — the clip's entire upstream state. */
10
+ export function computePresenterHash(audioBytes) {
11
+ return createHash("sha256").update(audioBytes).digest("hex");
12
+ }
13
+ export async function loadPresenterCache(outputDir) {
14
+ try {
15
+ const raw = await readFile(join(outputDir, CACHE_FILENAME), "utf-8");
16
+ return JSON.parse(raw);
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ export async function savePresenterCache(outputDir, cache) {
23
+ await writeFile(join(outputDir, CACHE_FILENAME), JSON.stringify(cache, null, 2) + "\n");
24
+ }
25
+ /**
26
+ * Whether a step's clip is already cached under this fingerprint. The
27
+ * caller still verifies the clip file exists on disk — a cache entry
28
+ * for a deleted file regenerates.
29
+ */
30
+ export function isPresenterCached(cache, stepIndex, hash, fingerprint) {
31
+ if (!cache || cache.fingerprint !== fingerprint)
32
+ return false;
33
+ const entry = cache.steps[stepIndex];
34
+ return entry !== null && entry !== undefined && entry.hash === hash;
35
+ }
36
+ export function buildPresenterCacheFile(fingerprint, totalSteps, entries) {
37
+ const steps = Array.from({ length: totalSteps }, () => null);
38
+ for (const [idx, entry] of entries) {
39
+ steps[idx] = entry;
40
+ }
41
+ return { fingerprint, steps };
42
+ }
43
+ //# sourceMappingURL=presenter-cache.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"presenter-cache.js","sourceRoot":"","sources":["../../../src/util/presenter-cache.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AACvD,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AA+BjC,MAAM,cAAc,GAAG,uBAAuB,CAAC;AAE/C,sEAAsE;AACtE,MAAM,UAAU,oBAAoB,CAClC,MAAc,EACd,UAAkB,EAClB,SAAiB,EACjB,QAAgB;IAEhB,OAAO,UAAU,MAAM,IAAI,UAAU,IAAI,SAAS,IAAI,QAAQ,EAAE,CAAC;AACnE,CAAC;AAED,8EAA8E;AAC9E,MAAM,UAAU,oBAAoB,CAAC,UAAkB;IACrD,OAAO,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;AAC/D,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,kBAAkB,CACtC,SAAiB;IAEjB,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,QAAQ,CAAC,IAAI,CAAC,SAAS,EAAE,cAAc,CAAC,EAAE,OAAO,CAAC,CAAC;QACrE,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CAAuB,CAAC;IAC/C,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,kBAAkB,CACtC,SAAiB,EACjB,KAAyB;IAEzB,MAAM,SAAS,CACb,IAAI,CAAC,SAAS,EAAE,cAAc,CAAC,EAC/B,IAAI,CAAC,SAAS,CAAC,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC,GAAG,IAAI,CACtC,CAAC;AACJ,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,iBAAiB,CAC/B,KAAgC,EAChC,SAAiB,EACjB,IAAY,EACZ,WAAmB;IAEnB,IAAI,CAAC,KAAK,IAAI,KAAK,CAAC,WAAW,KAAK,WAAW;QAAE,OAAO,KAAK,CAAC;IAC9D,MAAM,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC;IACrC,OAAO,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,CAAC,IAAI,KAAK,IAAI,CAAC;AACtE,CAAC;AAED,MAAM,UAAU,uBAAuB,CACrC,WAAmB,EACnB,UAAkB,EAClB,OAA0D;IAE1D,MAAM,KAAK,GAAmC,KAAK,CAAC,IAAI,CACtD,EAAE,MAAM,EAAE,UAAU,EAAE,EACtB,GAAG,EAAE,CAAC,IAAI,CACX,CAAC;IACF,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,OAAO,EAAE,CAAC;QACnC,KAAK,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC;IACrB,CAAC;IACD,OAAO,EAAE,WAAW,EAAE,KAAK,EAAE,CAAC;AAChC,CAAC"}
@@ -0,0 +1,101 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+
5
+ /**
6
+ * Presenter clip cache — the `.narration-cache.json` pattern applied to
7
+ * `scenar presenter`, where a cache hit saves real money (a HeyGen
8
+ * generation costs dollars, not milliseconds).
9
+ *
10
+ * The per-step hash keys on the narration MP3 **bytes**: the audio is
11
+ * the entire upstream state of a clip, so re-narrating a step (new
12
+ * voice, new text, new provider) invalidates its presenter clip
13
+ * automatically — no coordination needed between the two commands.
14
+ */
15
+
16
+ interface PresenterCacheEntry {
17
+ hash: string;
18
+ durationMs: number;
19
+ }
20
+
21
+ export interface PresenterCacheFile {
22
+ /**
23
+ * The generation configuration this cache was written under:
24
+ * `heygen/<engine>/<resolution>/<container>/<avatarId>`. Any change
25
+ * regenerates everything — a clip from one avatar or quality tier
26
+ * must never be served as a hit for another. The container is
27
+ * recorded even though v1 is mp4-only, so a future webm option
28
+ * invalidates correctly.
29
+ */
30
+ fingerprint: string;
31
+ steps: (PresenterCacheEntry | null)[];
32
+ }
33
+
34
+ const CACHE_FILENAME = ".presenter-cache.json";
35
+
36
+ /** Compose the cache fingerprint for one generation configuration. */
37
+ export function presenterFingerprint(
38
+ engine: string,
39
+ resolution: string,
40
+ container: string,
41
+ avatarId: string,
42
+ ): string {
43
+ return `heygen/${engine}/${resolution}/${container}/${avatarId}`;
44
+ }
45
+
46
+ /** Hash a step's narration audio bytes — the clip's entire upstream state. */
47
+ export function computePresenterHash(audioBytes: Buffer): string {
48
+ return createHash("sha256").update(audioBytes).digest("hex");
49
+ }
50
+
51
+ export async function loadPresenterCache(
52
+ outputDir: string,
53
+ ): Promise<PresenterCacheFile | null> {
54
+ try {
55
+ const raw = await readFile(join(outputDir, CACHE_FILENAME), "utf-8");
56
+ return JSON.parse(raw) as PresenterCacheFile;
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ export async function savePresenterCache(
63
+ outputDir: string,
64
+ cache: PresenterCacheFile,
65
+ ): Promise<void> {
66
+ await writeFile(
67
+ join(outputDir, CACHE_FILENAME),
68
+ JSON.stringify(cache, null, 2) + "\n",
69
+ );
70
+ }
71
+
72
+ /**
73
+ * Whether a step's clip is already cached under this fingerprint. The
74
+ * caller still verifies the clip file exists on disk — a cache entry
75
+ * for a deleted file regenerates.
76
+ */
77
+ export function isPresenterCached(
78
+ cache: PresenterCacheFile | null,
79
+ stepIndex: number,
80
+ hash: string,
81
+ fingerprint: string,
82
+ ): boolean {
83
+ if (!cache || cache.fingerprint !== fingerprint) return false;
84
+ const entry = cache.steps[stepIndex];
85
+ return entry !== null && entry !== undefined && entry.hash === hash;
86
+ }
87
+
88
+ export function buildPresenterCacheFile(
89
+ fingerprint: string,
90
+ totalSteps: number,
91
+ entries: Map<number, { hash: string; durationMs: number }>,
92
+ ): PresenterCacheFile {
93
+ const steps: (PresenterCacheEntry | null)[] = Array.from(
94
+ { length: totalSteps },
95
+ () => null,
96
+ );
97
+ for (const [idx, entry] of entries) {
98
+ steps[idx] = entry;
99
+ }
100
+ return { fingerprint, steps };
101
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * A scenario-local asset reference resolved for shipping: where the
3
+ * authored file is, and where it lands in a staged public dir or packed
4
+ * bundle (the authored path with its leading `./` removed, so the
5
+ * runtime reference resolves onto the shipped file in both outputs).
6
+ */
7
+ export interface ScenarioAsset {
8
+ /** Absolute path of the authored file. */
9
+ readonly sourcePath: string;
10
+ /** Destination path relative to the public dir / bundle root. */
11
+ readonly destRelPath: string;
12
+ }
13
+ /**
14
+ * True when an asset src is a remote URL (has a scheme) rather than a
15
+ * scenario-relative path. Remote assets are referenced, never copied.
16
+ */
17
+ export declare function isRemoteUrl(src: string): boolean;
18
+ /**
19
+ * Resolve a scenario-relative asset reference against its scenario
20
+ * directory — the shared containment-and-existence contract behind
21
+ * every shipped asset (soundtrack music, title-card logos).
22
+ *
23
+ * Returns `null` for remote URLs (used from where they live). Throws
24
+ * with an actionable message when the path escapes the scenario
25
+ * directory or the file does not exist — both would otherwise surface
26
+ * as a broken reference in the packed embed or the rendered video.
27
+ *
28
+ * @param configPath - The authored field, for error messages
29
+ * (e.g. "soundtrack.musicSrc").
30
+ * @param expectation - What a valid reference looks like, appended to
31
+ * the error (e.g. 'an MP3 inside the scenario directory').
32
+ */
33
+ export declare function resolveScenarioAsset(scenarioDir: string, src: string, configPath: string, expectation: string): Promise<ScenarioAsset | null>;
34
+ /**
35
+ * The image extensions the packed-embed deploy contract serves —
36
+ * derived from the canonical extension table so this can never drift
37
+ * from the server's allowlist. Notably NO svg (active content).
38
+ */
39
+ export declare const IMAGE_EXTENSIONS: readonly string[];
40
+ /**
41
+ * Resolve a title card's `logo_src` against its scenario directory,
42
+ * additionally enforcing the deploy contract's image formats — an
43
+ * unservable extension must fail at build time, not as a 404 (or a
44
+ * rejected deploy) later.
45
+ */
46
+ export declare function resolveLogoAsset(scenarioDir: string, logoSrc: string, configPath: string): Promise<ScenarioAsset | null>;
47
+ //# sourceMappingURL=scenario-assets.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scenario-assets.d.ts","sourceRoot":"","sources":["../../../src/util/scenario-assets.ts"],"names":[],"mappings":"AAIA;;;;;GAKG;AACH,MAAM,WAAW,aAAa;IAC5B,0CAA0C;IAC1C,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,iEAAiE;IACjE,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;CAC9B;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAEhD;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,oBAAoB,CACxC,WAAW,EAAE,MAAM,EACnB,GAAG,EAAE,MAAM,EACX,UAAU,EAAE,MAAM,EAClB,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAuB/B;AAED;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,EAAE,SAAS,MAAM,EAIZ,CAAC;AAEnC;;;;;GAKG;AACH,wBAAsB,gBAAgB,CACpC,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAmB/B"}
@@ -0,0 +1,71 @@
1
+ import { access } from "node:fs/promises";
2
+ import { isAbsolute, join, normalize } from "node:path";
3
+ import { CONTENT_TYPE_BY_EXTENSION, finalExtension } from "../pack/bundle-contract.js";
4
+ /**
5
+ * True when an asset src is a remote URL (has a scheme) rather than a
6
+ * scenario-relative path. Remote assets are referenced, never copied.
7
+ */
8
+ export function isRemoteUrl(src) {
9
+ return /^[a-z][a-z0-9+.-]*:\/\//i.test(src);
10
+ }
11
+ /**
12
+ * Resolve a scenario-relative asset reference against its scenario
13
+ * directory — the shared containment-and-existence contract behind
14
+ * every shipped asset (soundtrack music, title-card logos).
15
+ *
16
+ * Returns `null` for remote URLs (used from where they live). Throws
17
+ * with an actionable message when the path escapes the scenario
18
+ * directory or the file does not exist — both would otherwise surface
19
+ * as a broken reference in the packed embed or the rendered video.
20
+ *
21
+ * @param configPath - The authored field, for error messages
22
+ * (e.g. "soundtrack.musicSrc").
23
+ * @param expectation - What a valid reference looks like, appended to
24
+ * the error (e.g. 'an MP3 inside the scenario directory').
25
+ */
26
+ export async function resolveScenarioAsset(scenarioDir, src, configPath, expectation) {
27
+ if (isRemoteUrl(src))
28
+ return null;
29
+ const destRelPath = normalize(src).replace(/^\.\//, "");
30
+ if (isAbsolute(destRelPath) || destRelPath === ".." || destRelPath.startsWith("../")) {
31
+ throw new Error(`${configPath} "${src}" points outside the scenario directory.\n` +
32
+ `The reference must be ${expectation} so it can ship with the bundle,\n` +
33
+ "or an absolute URL.");
34
+ }
35
+ const sourcePath = join(scenarioDir, destRelPath);
36
+ try {
37
+ await access(sourcePath);
38
+ }
39
+ catch {
40
+ throw new Error(`${configPath} "${src}" not found at ${sourcePath}.\n` +
41
+ `Add the file (${expectation}), or fix the path in the config.`);
42
+ }
43
+ return { sourcePath, destRelPath };
44
+ }
45
+ /**
46
+ * The image extensions the packed-embed deploy contract serves —
47
+ * derived from the canonical extension table so this can never drift
48
+ * from the server's allowlist. Notably NO svg (active content).
49
+ */
50
+ export const IMAGE_EXTENSIONS = Object.entries(CONTENT_TYPE_BY_EXTENSION)
51
+ .filter(([, contentType]) => contentType.startsWith("image/"))
52
+ .map(([extension]) => extension);
53
+ /**
54
+ * Resolve a title card's `logo_src` against its scenario directory,
55
+ * additionally enforcing the deploy contract's image formats — an
56
+ * unservable extension must fail at build time, not as a 404 (or a
57
+ * rejected deploy) later.
58
+ */
59
+ export async function resolveLogoAsset(scenarioDir, logoSrc, configPath) {
60
+ const asset = await resolveScenarioAsset(scenarioDir, logoSrc, configPath, "an image inside the scenario directory (e.g. ./logo.png)");
61
+ if (asset === null)
62
+ return null;
63
+ const extension = finalExtension(asset.destRelPath);
64
+ if (!IMAGE_EXTENSIONS.includes(extension)) {
65
+ throw new Error(`${configPath} "${logoSrc}" is not a supported image format.\n` +
66
+ `The deploy contract serves: ${IMAGE_EXTENSIONS.join(", ")}. ` +
67
+ "SVG is deliberately excluded (active content) — inline it in a view instead.");
68
+ }
69
+ return asset;
70
+ }
71
+ //# sourceMappingURL=scenario-assets.js.map