@1agh/maude 0.42.0 → 0.44.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 (332) hide show
  1. package/apps/studio/.ai/cache/_stats.json +7 -0
  2. package/apps/studio/acp/bootstrap-brief.ts +12 -2
  3. package/apps/studio/acp/bridge.ts +44 -10
  4. package/apps/studio/acp/env.ts +23 -0
  5. package/apps/studio/acp/login-state.ts +263 -0
  6. package/apps/studio/acp/plugin-bootstrap.ts +31 -25
  7. package/apps/studio/acp/probe.ts +152 -1
  8. package/apps/studio/annotations-layer.tsx +1451 -324
  9. package/apps/studio/annotations-model.ts +58 -0
  10. package/apps/studio/api.ts +568 -24
  11. package/apps/studio/bin/_audio-search.mjs +140 -0
  12. package/apps/studio/bin/_import-asset-pdf-worker.mjs +34 -0
  13. package/apps/studio/bin/_import-asset.mjs +815 -0
  14. package/apps/studio/bin/_import-brand.mjs +635 -0
  15. package/apps/studio/bin/_import-tokens-alias-resolver.mjs +95 -0
  16. package/apps/studio/bin/_import-tokens.mjs +1201 -0
  17. package/apps/studio/bin/_ingest-footage.mjs +386 -0
  18. package/apps/studio/bin/_probe-footage-playwright.mjs +269 -0
  19. package/apps/studio/bin/_transcribe.mjs +419 -0
  20. package/apps/studio/bin/_transcribe.test.mjs +80 -0
  21. package/apps/studio/bin/_video-playwright.mjs +141 -71
  22. package/apps/studio/bin/audio-search.sh +28 -0
  23. package/apps/studio/bin/generate.sh +154 -0
  24. package/apps/studio/bin/import-asset.sh +34 -0
  25. package/apps/studio/bin/import-brand.sh +29 -0
  26. package/apps/studio/bin/import-tokens.sh +33 -0
  27. package/apps/studio/bin/ingest-footage.sh +27 -0
  28. package/apps/studio/bin/photo-adjust.sh +163 -0
  29. package/apps/studio/bin/photo-bg-remove.sh +258 -0
  30. package/apps/studio/bin/probe-footage.sh +28 -0
  31. package/apps/studio/bin/read-annotations.mjs +125 -2
  32. package/apps/studio/bin/transcribe.sh +39 -0
  33. package/apps/studio/canvas-edit.ts +656 -12
  34. package/apps/studio/canvas-lib.tsx +637 -10
  35. package/apps/studio/canvas-pipeline.ts +65 -7
  36. package/apps/studio/canvas-shell.tsx +156 -16
  37. package/apps/studio/client/app.jsx +2385 -699
  38. package/apps/studio/client/export-center.jsx +35 -1
  39. package/apps/studio/client/generate-dialog.jsx +352 -0
  40. package/apps/studio/client/github.js +18 -0
  41. package/apps/studio/client/inspector-controls.jsx +781 -0
  42. package/apps/studio/client/panels/BrandUploadPanel.jsx +233 -0
  43. package/apps/studio/client/panels/ChatPanel.jsx +15 -33
  44. package/apps/studio/client/panels/GitPanel.jsx +8 -0
  45. package/apps/studio/client/panels/IntroVideoDialog.jsx +44 -0
  46. package/apps/studio/client/panels/OnboardingWizard.jsx +12 -0
  47. package/apps/studio/client/panels/ReadinessList.jsx +227 -7
  48. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +133 -21
  49. package/apps/studio/client/panels/SettingsPanel.jsx +828 -0
  50. package/apps/studio/client/panels/SetupChecklist.jsx +223 -0
  51. package/apps/studio/client/panels/StickerPicker.jsx +160 -0
  52. package/apps/studio/client/panels/TimelinePanel.jsx +15 -2
  53. package/apps/studio/client/photo-knobs.jsx +432 -0
  54. package/apps/studio/client/styles/3-shell-maude.css +376 -28
  55. package/apps/studio/client/styles/4-components.css +223 -0
  56. package/apps/studio/client/styles/6-acp-chat.css +86 -0
  57. package/apps/studio/client/tour/quick-setup-tour.js +39 -0
  58. package/apps/studio/commands/annotation-strokes-command.ts +13 -3
  59. package/apps/studio/commands/edit-source-command.ts +31 -4
  60. package/apps/studio/config.schema.json +70 -0
  61. package/apps/studio/context-menu.tsx +42 -8
  62. package/apps/studio/context.ts +16 -0
  63. package/apps/studio/design-setup-readiness.ts +115 -0
  64. package/apps/studio/dist/client.bundle.js +5572 -21
  65. package/apps/studio/dist/comment-mount.js +2 -2
  66. package/apps/studio/dist/runtime/.min-sizes.json +1 -0
  67. package/apps/studio/dist/runtime/@imgly_background-removal.js +5543 -0
  68. package/apps/studio/dist/runtime/pixi-js.js +519 -519
  69. package/apps/studio/dist/styles.css +1 -1
  70. package/apps/studio/dom-selection.ts +25 -0
  71. package/apps/studio/draw/palette.ts +34 -0
  72. package/apps/studio/exporters/jobs.ts +47 -5
  73. package/apps/studio/exporters/video.ts +24 -4
  74. package/apps/studio/footage/schema.test.ts +287 -0
  75. package/apps/studio/footage/schema.ts +662 -0
  76. package/apps/studio/footage-store.ts +235 -0
  77. package/apps/studio/fs-watch.ts +10 -0
  78. package/apps/studio/generation/adapters/elevenlabs.ts +370 -0
  79. package/apps/studio/generation/adapters/gemini.ts +542 -0
  80. package/apps/studio/generation/adapters/groq.test.ts +80 -0
  81. package/apps/studio/generation/adapters/groq.ts +247 -0
  82. package/apps/studio/generation/audio-library.test.ts +95 -0
  83. package/apps/studio/generation/audio-library.ts +156 -0
  84. package/apps/studio/generation/captions.test.ts +78 -0
  85. package/apps/studio/generation/captions.ts +132 -0
  86. package/apps/studio/generation/download.ts +76 -0
  87. package/apps/studio/generation/elevenlabs.test.ts +223 -0
  88. package/apps/studio/generation/gemini.test.ts +350 -0
  89. package/apps/studio/generation/jobs.test.ts +128 -0
  90. package/apps/studio/generation/jobs.ts +243 -0
  91. package/apps/studio/generation/keys.test.ts +75 -0
  92. package/apps/studio/generation/keys.ts +162 -0
  93. package/apps/studio/generation/prefs.test.ts +95 -0
  94. package/apps/studio/generation/prefs.ts +82 -0
  95. package/apps/studio/generation/registry.ts +88 -0
  96. package/apps/studio/generation/types.test.ts +123 -0
  97. package/apps/studio/generation/types.ts +266 -0
  98. package/apps/studio/generation/whisper-models.test.ts +152 -0
  99. package/apps/studio/generation/whisper-models.ts +257 -0
  100. package/apps/studio/generation/whisper-spike-results.md +61 -0
  101. package/apps/studio/git/endpoints.ts +68 -7
  102. package/apps/studio/git/service.ts +199 -21
  103. package/apps/studio/github/service.ts +67 -0
  104. package/apps/studio/handoff.ts +3 -2
  105. package/apps/studio/http.ts +1039 -22
  106. package/apps/studio/input-router.tsx +10 -0
  107. package/apps/studio/inspect.ts +53 -1
  108. package/apps/studio/media/intro.mp4 +0 -0
  109. package/apps/studio/media-commit-chain.ts +119 -0
  110. package/apps/studio/paths.ts +20 -0
  111. package/apps/studio/photo/filters.ts +285 -0
  112. package/apps/studio/photo/pipeline.ts +607 -0
  113. package/apps/studio/photo/schema.ts +367 -0
  114. package/apps/studio/photo-store.ts +154 -0
  115. package/apps/studio/readiness.ts +108 -20
  116. package/apps/studio/runtime-bundle.ts +29 -6
  117. package/apps/studio/scaffold-design.ts +1145 -0
  118. package/apps/studio/server.ts +18 -2
  119. package/apps/studio/stickers/figjam-doodle/manifest.json +104 -0
  120. package/apps/studio/stickers/figjam-doodle/slice1.png +0 -0
  121. package/apps/studio/stickers/figjam-doodle/slice10.png +0 -0
  122. package/apps/studio/stickers/figjam-doodle/slice11.png +0 -0
  123. package/apps/studio/stickers/figjam-doodle/slice12.png +0 -0
  124. package/apps/studio/stickers/figjam-doodle/slice13.png +0 -0
  125. package/apps/studio/stickers/figjam-doodle/slice14.png +0 -0
  126. package/apps/studio/stickers/figjam-doodle/slice15.png +0 -0
  127. package/apps/studio/stickers/figjam-doodle/slice16.png +0 -0
  128. package/apps/studio/stickers/figjam-doodle/slice17.png +0 -0
  129. package/apps/studio/stickers/figjam-doodle/slice18.png +0 -0
  130. package/apps/studio/stickers/figjam-doodle/slice19.png +0 -0
  131. package/apps/studio/stickers/figjam-doodle/slice2.png +0 -0
  132. package/apps/studio/stickers/figjam-doodle/slice20.png +0 -0
  133. package/apps/studio/stickers/figjam-doodle/slice21.png +0 -0
  134. package/apps/studio/stickers/figjam-doodle/slice22.png +0 -0
  135. package/apps/studio/stickers/figjam-doodle/slice23.png +0 -0
  136. package/apps/studio/stickers/figjam-doodle/slice24.png +0 -0
  137. package/apps/studio/stickers/figjam-doodle/slice3.png +0 -0
  138. package/apps/studio/stickers/figjam-doodle/slice4.png +0 -0
  139. package/apps/studio/stickers/figjam-doodle/slice5.png +0 -0
  140. package/apps/studio/stickers/figjam-doodle/slice6.png +0 -0
  141. package/apps/studio/stickers/figjam-doodle/slice7.png +0 -0
  142. package/apps/studio/stickers/figjam-doodle/slice8.png +0 -0
  143. package/apps/studio/stickers/figjam-doodle/slice9.png +0 -0
  144. package/apps/studio/stickers/life-style/best.png +0 -0
  145. package/apps/studio/stickers/life-style/come-on.png +0 -0
  146. package/apps/studio/stickers/life-style/cut.png +0 -0
  147. package/apps/studio/stickers/life-style/detox.png +0 -0
  148. package/apps/studio/stickers/life-style/just.png +0 -0
  149. package/apps/studio/stickers/life-style/manifest.json +68 -0
  150. package/apps/studio/stickers/life-style/mirror.png +0 -0
  151. package/apps/studio/stickers/life-style/nice.png +0 -0
  152. package/apps/studio/stickers/life-style/objects.png +0 -0
  153. package/apps/studio/stickers/life-style/ok.png +0 -0
  154. package/apps/studio/stickers/life-style/queen.png +0 -0
  155. package/apps/studio/stickers/life-style/star.png +0 -0
  156. package/apps/studio/stickers/life-style/sun.png +0 -0
  157. package/apps/studio/stickers/life-style/water.png +0 -0
  158. package/apps/studio/stickers/life-style/yeah.png +0 -0
  159. package/apps/studio/stickers/life-style/you-can.png +0 -0
  160. package/apps/studio/stickers/opposing-thoughts/check-the-deets.png +0 -0
  161. package/apps/studio/stickers/opposing-thoughts/cut-it.png +0 -0
  162. package/apps/studio/stickers/opposing-thoughts/dope.png +0 -0
  163. package/apps/studio/stickers/opposing-thoughts/fresh.png +0 -0
  164. package/apps/studio/stickers/opposing-thoughts/hot.png +0 -0
  165. package/apps/studio/stickers/opposing-thoughts/idea.png +0 -0
  166. package/apps/studio/stickers/opposing-thoughts/keep-exploring.png +0 -0
  167. package/apps/studio/stickers/opposing-thoughts/killed-it.png +0 -0
  168. package/apps/studio/stickers/opposing-thoughts/love-it.png +0 -0
  169. package/apps/studio/stickers/opposing-thoughts/manifest.json +88 -0
  170. package/apps/studio/stickers/opposing-thoughts/not-sure.png +0 -0
  171. package/apps/studio/stickers/opposing-thoughts/ok.png +0 -0
  172. package/apps/studio/stickers/opposing-thoughts/pure-gold.png +0 -0
  173. package/apps/studio/stickers/opposing-thoughts/save-for-later.png +0 -0
  174. package/apps/studio/stickers/opposing-thoughts/steal-this.png +0 -0
  175. package/apps/studio/stickers/opposing-thoughts/take-a-peek.png +0 -0
  176. package/apps/studio/stickers/opposing-thoughts/this-or-that.png +0 -0
  177. package/apps/studio/stickers/opposing-thoughts/thoughts.png +0 -0
  178. package/apps/studio/stickers/opposing-thoughts/vibes.png +0 -0
  179. package/apps/studio/stickers/opposing-thoughts/winner.png +0 -0
  180. package/apps/studio/stickers/opposing-thoughts/wip.png +0 -0
  181. package/apps/studio/stickers/project-status/group-100.png +0 -0
  182. package/apps/studio/stickers/project-status/group-101.png +0 -0
  183. package/apps/studio/stickers/project-status/group-102.png +0 -0
  184. package/apps/studio/stickers/project-status/group-103.png +0 -0
  185. package/apps/studio/stickers/project-status/group-104.png +0 -0
  186. package/apps/studio/stickers/project-status/group-105.png +0 -0
  187. package/apps/studio/stickers/project-status/group-106.png +0 -0
  188. package/apps/studio/stickers/project-status/group-107.png +0 -0
  189. package/apps/studio/stickers/project-status/group-108.png +0 -0
  190. package/apps/studio/stickers/project-status/group-109.png +0 -0
  191. package/apps/studio/stickers/project-status/group-110.png +0 -0
  192. package/apps/studio/stickers/project-status/group-111.png +0 -0
  193. package/apps/studio/stickers/project-status/group-112.png +0 -0
  194. package/apps/studio/stickers/project-status/group-113.png +0 -0
  195. package/apps/studio/stickers/project-status/group-114.png +0 -0
  196. package/apps/studio/stickers/project-status/group-115.png +0 -0
  197. package/apps/studio/stickers/project-status/group-117.png +0 -0
  198. package/apps/studio/stickers/project-status/group-118.png +0 -0
  199. package/apps/studio/stickers/project-status/group-119.png +0 -0
  200. package/apps/studio/stickers/project-status/group-120.png +0 -0
  201. package/apps/studio/stickers/project-status/group-121.png +0 -0
  202. package/apps/studio/stickers/project-status/group-122.png +0 -0
  203. package/apps/studio/stickers/project-status/group-41.png +0 -0
  204. package/apps/studio/stickers/project-status/group-42.png +0 -0
  205. package/apps/studio/stickers/project-status/group-43.png +0 -0
  206. package/apps/studio/stickers/project-status/group-44.png +0 -0
  207. package/apps/studio/stickers/project-status/group-45.png +0 -0
  208. package/apps/studio/stickers/project-status/group-46.png +0 -0
  209. package/apps/studio/stickers/project-status/group-47.png +0 -0
  210. package/apps/studio/stickers/project-status/group-48.png +0 -0
  211. package/apps/studio/stickers/project-status/group-49.png +0 -0
  212. package/apps/studio/stickers/project-status/group-50.png +0 -0
  213. package/apps/studio/stickers/project-status/group-51.png +0 -0
  214. package/apps/studio/stickers/project-status/group-52.png +0 -0
  215. package/apps/studio/stickers/project-status/group-53.png +0 -0
  216. package/apps/studio/stickers/project-status/group-54.png +0 -0
  217. package/apps/studio/stickers/project-status/group-55.png +0 -0
  218. package/apps/studio/stickers/project-status/group-56.png +0 -0
  219. package/apps/studio/stickers/project-status/group-57.png +0 -0
  220. package/apps/studio/stickers/project-status/group-58.png +0 -0
  221. package/apps/studio/stickers/project-status/group-59.png +0 -0
  222. package/apps/studio/stickers/project-status/group-60.png +0 -0
  223. package/apps/studio/stickers/project-status/group-61.png +0 -0
  224. package/apps/studio/stickers/project-status/group-62.png +0 -0
  225. package/apps/studio/stickers/project-status/group-63.png +0 -0
  226. package/apps/studio/stickers/project-status/group-64.png +0 -0
  227. package/apps/studio/stickers/project-status/group-65.png +0 -0
  228. package/apps/studio/stickers/project-status/group-66.png +0 -0
  229. package/apps/studio/stickers/project-status/group-67.png +0 -0
  230. package/apps/studio/stickers/project-status/group-68.png +0 -0
  231. package/apps/studio/stickers/project-status/group-69.png +0 -0
  232. package/apps/studio/stickers/project-status/group-70.png +0 -0
  233. package/apps/studio/stickers/project-status/group-71.png +0 -0
  234. package/apps/studio/stickers/project-status/group-72.png +0 -0
  235. package/apps/studio/stickers/project-status/group-73.png +0 -0
  236. package/apps/studio/stickers/project-status/group-74.png +0 -0
  237. package/apps/studio/stickers/project-status/group-75.png +0 -0
  238. package/apps/studio/stickers/project-status/group-76.png +0 -0
  239. package/apps/studio/stickers/project-status/group-77.png +0 -0
  240. package/apps/studio/stickers/project-status/group-78.png +0 -0
  241. package/apps/studio/stickers/project-status/group-79.png +0 -0
  242. package/apps/studio/stickers/project-status/group-80.png +0 -0
  243. package/apps/studio/stickers/project-status/group-81.png +0 -0
  244. package/apps/studio/stickers/project-status/group-82.png +0 -0
  245. package/apps/studio/stickers/project-status/group-83.png +0 -0
  246. package/apps/studio/stickers/project-status/group-84.png +0 -0
  247. package/apps/studio/stickers/project-status/group-85.png +0 -0
  248. package/apps/studio/stickers/project-status/group-86.png +0 -0
  249. package/apps/studio/stickers/project-status/group-87.png +0 -0
  250. package/apps/studio/stickers/project-status/group-88.png +0 -0
  251. package/apps/studio/stickers/project-status/group-89.png +0 -0
  252. package/apps/studio/stickers/project-status/group-90.png +0 -0
  253. package/apps/studio/stickers/project-status/group-91.png +0 -0
  254. package/apps/studio/stickers/project-status/group-92.png +0 -0
  255. package/apps/studio/stickers/project-status/group-93.png +0 -0
  256. package/apps/studio/stickers/project-status/group-94.png +0 -0
  257. package/apps/studio/stickers/project-status/group-96.png +0 -0
  258. package/apps/studio/stickers/project-status/group-97.png +0 -0
  259. package/apps/studio/stickers/project-status/group-98.png +0 -0
  260. package/apps/studio/stickers/project-status/group-99.png +0 -0
  261. package/apps/studio/stickers/project-status/manifest.json +328 -0
  262. package/apps/studio/test/_helpers.ts +15 -2
  263. package/apps/studio/test/acp-bootstrap-brief.test.ts +11 -0
  264. package/apps/studio/test/acp-bridge.test.ts +34 -1
  265. package/apps/studio/test/acp-commands.test.ts +2 -1
  266. package/apps/studio/test/acp-env.test.ts +30 -0
  267. package/apps/studio/test/acp-plugin-bootstrap.test.ts +23 -57
  268. package/apps/studio/test/acp-session-plugins.test.ts +72 -1
  269. package/apps/studio/test/annotation-strokes-command.test.ts +17 -0
  270. package/apps/studio/test/annotations-layer.test.ts +174 -0
  271. package/apps/studio/test/asset-api.test.ts +3 -3
  272. package/apps/studio/test/canvas-edit.test.ts +17 -6
  273. package/apps/studio/test/canvas-origin-gate.test.ts +50 -0
  274. package/apps/studio/test/csp-canvas-shell.test.ts +11 -0
  275. package/apps/studio/test/csrf-write-guard.test.ts +23 -1
  276. package/apps/studio/test/design-setup-readiness.test.ts +155 -0
  277. package/apps/studio/test/dynamic-text-edit.test.ts +162 -0
  278. package/apps/studio/test/edit-source-command.test.ts +23 -0
  279. package/apps/studio/test/element-structural-edit.test.ts +154 -0
  280. package/apps/studio/test/fixtures/fake-claude-auth.mjs +13 -0
  281. package/apps/studio/test/footage-store.test.ts +100 -0
  282. package/apps/studio/test/generate-route.test.ts +106 -0
  283. package/apps/studio/test/git-branches.test.ts +97 -0
  284. package/apps/studio/test/github-api.test.ts +56 -1
  285. package/apps/studio/test/import-asset-browser.test.ts +64 -0
  286. package/apps/studio/test/import-asset-route.test.ts +71 -0
  287. package/apps/studio/test/import-asset.test.ts +399 -0
  288. package/apps/studio/test/import-brand-browser.test.ts +88 -0
  289. package/apps/studio/test/import-brand-route.test.ts +120 -0
  290. package/apps/studio/test/import-brand.test.ts +206 -0
  291. package/apps/studio/test/import-tokens.test.ts +722 -0
  292. package/apps/studio/test/input-router.test.ts +33 -0
  293. package/apps/studio/test/inspect-script-syntax.test.ts +53 -0
  294. package/apps/studio/test/media-commit-chain.test.ts +210 -0
  295. package/apps/studio/test/photo-bg-remove-validation.test.ts +71 -0
  296. package/apps/studio/test/photo-canvas-bundle.test.ts +61 -0
  297. package/apps/studio/test/photo-edit-api.test.ts +201 -0
  298. package/apps/studio/test/photo-filters.test.ts +149 -0
  299. package/apps/studio/test/photo-pipeline.test.ts +106 -0
  300. package/apps/studio/test/photo-taxonomy.test.ts +96 -0
  301. package/apps/studio/test/read-annotations.test.ts +164 -0
  302. package/apps/studio/test/readiness.test.ts +13 -11
  303. package/apps/studio/test/scaffold-design.test.ts +122 -0
  304. package/apps/studio/test/shell-importmap.test.ts +49 -0
  305. package/apps/studio/test/text-editability-stamp.test.ts +131 -0
  306. package/apps/studio/test/timeline-parse.test.ts +57 -0
  307. package/apps/studio/test/tool-palette-insert-anchor.test.ts +5 -3
  308. package/apps/studio/test/tour-overlay.test.tsx +16 -0
  309. package/apps/studio/test/undo-stack.test.ts +33 -0
  310. package/apps/studio/test/video-asset.test.ts +5 -5
  311. package/apps/studio/test/video-render-bridge.test.ts +23 -1
  312. package/apps/studio/text-caret.ts +239 -0
  313. package/apps/studio/tool-palette.tsx +49 -21
  314. package/apps/studio/ui-prefs.ts +130 -0
  315. package/apps/studio/undo-stack.ts +94 -2
  316. package/apps/studio/use-annotation-resize.tsx +4 -4
  317. package/apps/studio/use-canvas-media-drop.tsx +40 -1
  318. package/apps/studio/use-chrome-visibility.tsx +8 -2
  319. package/apps/studio/use-selection-set.tsx +21 -0
  320. package/apps/studio/video-comp.tsx +48 -7
  321. package/apps/studio/whats-new.json +149 -0
  322. package/apps/studio/ws.ts +5 -0
  323. package/cli/bin/maude.mjs +6 -6
  324. package/cli/commands/design.mjs +67 -3
  325. package/cli/lib/gitignore-block.mjs +2 -0
  326. package/cli/lib/pkg-root.mjs +107 -0
  327. package/cli/lib/pkg-root.test.mjs +79 -0
  328. package/cli/lib/reconstruct-toolset.test.mjs +194 -0
  329. package/cli/lib/update-check.mjs +8 -0
  330. package/package.json +13 -11
  331. package/plugins/design/dependencies.json +18 -0
  332. package/plugins/design/templates/_shell.html +1 -0
@@ -0,0 +1,235 @@
1
+ /**
2
+ * @file footage-store.ts — Footage-analysis + EDL sidecar persistence
3
+ * (feature-footage-analysis-director, Task 2).
4
+ * @scope apps/studio/footage-store.ts
5
+ * @purpose Read / write the two VERSIONED sidecars behind the footage
6
+ * "director" pipeline (DDR-115 taxonomy):
7
+ * • `assets/<sha8>.footage.json` — a `FootageAnalysis` (per clip)
8
+ * • `<designRoot>/<slug>.edl.json` — an `Edl` (per cut)
9
+ * Mirrors `photo-store.ts`'s `createX(ctx)` factory + `Bun.write`/
10
+ * `Bun.file` shape. Server-side only — imports the DEPENDENCY-FREE
11
+ * `footage/schema.ts`.
12
+ *
13
+ * @security UNLIKE `/_api/photo-edit`, the `/_api/footage` route is
14
+ * MAIN-ORIGIN ONLY (privileged — NOT in either canvas allowlist).
15
+ * It is written by the `footage-analyst` / `footage-director` agents
16
+ * over loopback, never by the untrusted canvas iframe (DDR-054), so
17
+ * a `GET → 405` assertion from the canvas origin guards it
18
+ * (canvas-origin-gate.test.ts). The write path is still derived from
19
+ * caller-supplied params, so the DDR-088 cap stack is preserved:
20
+ * 1. strict sha8 / slug extraction (hex or kebab, bounded) —
21
+ * rejects `..`, `/`, `%2f`, absolute paths by construction;
22
+ * 2. explicit containment assert (poisoned-designRoot backstop);
23
+ * 3. structural `validateFootageAnalysis` / `validateEdl`;
24
+ * 4. body/file size cap (256 KB — generous for analysis JSON).
25
+ */
26
+
27
+ import path from 'node:path';
28
+
29
+ import type { Context } from './context.ts';
30
+ import {
31
+ EDL_VERSION,
32
+ type Edl,
33
+ FOOTAGE_ANALYSIS_VERSION,
34
+ type FootageAnalysis,
35
+ validateEdl,
36
+ validateFootageAnalysis,
37
+ } from './footage/schema.ts';
38
+
39
+ /** Max bytes for either sidecar (read + write). Analysis JSON is small; slack. */
40
+ export const FOOTAGE_MAX_BYTES = 256 * 1024;
41
+
42
+ const SHA_RE = /^[0-9a-f]{8,64}$/;
43
+ // A cut slug: lowercase kebab, bounded. Matches the `slug.sh` normalization
44
+ // shape — no path segment, no dot, so `..`/`/`/absolute can never survive.
45
+ const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,80}$/;
46
+
47
+ /**
48
+ * Extract + validate the content-address sha8 from an `asset` param, in any of
49
+ * the forms a caller might hold (`assets/<sha8>.<ext>`, `/assets/<sha8>.png`,
50
+ * `<sha8>.png`, bare `<sha8>`). The hex regex is the traversal defense.
51
+ * (Copied from photo-store.assetSha8 — same contract, kept local so the two
52
+ * stores stay independent.)
53
+ */
54
+ export function assetSha8(param: string | null | undefined): string | null {
55
+ if (typeof param !== 'string') return null;
56
+ let s = param.trim();
57
+ if (!s) return null;
58
+ s = s.replace(/^\/+/, '');
59
+ s = s.replace(/^assets\//, '');
60
+ s = s.replace(/\.[A-Za-z0-9]+$/, '');
61
+ return SHA_RE.test(s) ? s : null;
62
+ }
63
+
64
+ /** Validate a cut slug (already-normalized kebab), or null. */
65
+ export function edlSlug(param: string | null | undefined): string | null {
66
+ if (typeof param !== 'string') return null;
67
+ const s = param.trim();
68
+ return SLUG_RE.test(s) ? s : null;
69
+ }
70
+
71
+ export interface FootageStore {
72
+ analysisPathForSha8(sha8: string): string;
73
+ edlPathForSlug(slug: string): string;
74
+ getAnalysis(assetParam: string | null | undefined): Promise<FootageAnalysis | null>;
75
+ saveAnalysis(
76
+ assetParam: string | null | undefined,
77
+ analysis: unknown
78
+ ): Promise<
79
+ | { ok: true; path: string; analysis: FootageAnalysis }
80
+ | { ok: false; status: number; error: string }
81
+ >;
82
+ getEdl(slugParam: string | null | undefined): Promise<Edl | null>;
83
+ saveEdl(
84
+ slugParam: string | null | undefined,
85
+ edl: unknown
86
+ ): Promise<{ ok: true; path: string; edl: Edl } | { ok: false; status: number; error: string }>;
87
+ }
88
+
89
+ export function createFootageStore(ctx: Context): FootageStore {
90
+ const designRoot = ctx.paths.designRoot;
91
+ const assetsDir = path.join(designRoot, 'assets');
92
+
93
+ function analysisPathForSha8(sha8: string): string {
94
+ if (!SHA_RE.test(sha8)) throw new Error(`invalid sha8: ${sha8}`);
95
+ const abs = path.join(assetsDir, `${sha8}.footage.json`);
96
+ const rel = path.relative(assetsDir, abs);
97
+ if (rel !== `${sha8}.footage.json` || rel.startsWith('..') || path.isAbsolute(rel)) {
98
+ throw new Error(`footage path escaped assets/: ${abs}`);
99
+ }
100
+ return abs;
101
+ }
102
+
103
+ function edlPathForSlug(slug: string): string {
104
+ if (!SLUG_RE.test(slug)) throw new Error(`invalid slug: ${slug}`);
105
+ const abs = path.join(designRoot, `${slug}.edl.json`);
106
+ const rel = path.relative(designRoot, abs);
107
+ if (rel !== `${slug}.edl.json` || rel.startsWith('..') || path.isAbsolute(rel)) {
108
+ throw new Error(`edl path escaped designRoot: ${abs}`);
109
+ }
110
+ return abs;
111
+ }
112
+
113
+ async function readSidecar<T>(
114
+ abs: string,
115
+ validate: (v: unknown) => { ok: boolean }
116
+ ): Promise<T | null> {
117
+ try {
118
+ const file = Bun.file(abs);
119
+ if (!(await file.exists())) return null;
120
+ if (file.size > FOOTAGE_MAX_BYTES) return null;
121
+ const parsed = JSON.parse(await file.text());
122
+ return validate(parsed).ok ? (parsed as T) : null;
123
+ } catch {
124
+ return null;
125
+ }
126
+ }
127
+
128
+ async function getAnalysis(
129
+ assetParam: string | null | undefined
130
+ ): Promise<FootageAnalysis | null> {
131
+ const sha8 = assetSha8(assetParam);
132
+ if (!sha8) return null;
133
+ return readSidecar<FootageAnalysis>(analysisPathForSha8(sha8), validateFootageAnalysis);
134
+ }
135
+
136
+ async function saveAnalysis(
137
+ assetParam: string | null | undefined,
138
+ analysis: unknown
139
+ ): Promise<
140
+ | { ok: true; path: string; analysis: FootageAnalysis }
141
+ | { ok: false; status: number; error: string }
142
+ > {
143
+ const sha8 = assetSha8(assetParam);
144
+ if (!sha8) return { ok: false, status: 400, error: 'invalid or missing asset (expected sha8)' };
145
+
146
+ let serialized: string;
147
+ try {
148
+ serialized = JSON.stringify(analysis ?? {});
149
+ } catch {
150
+ return { ok: false, status: 400, error: 'body is not serializable JSON' };
151
+ }
152
+ if (Buffer.byteLength(serialized, 'utf8') > FOOTAGE_MAX_BYTES) {
153
+ return {
154
+ ok: false,
155
+ status: 413,
156
+ error: `footage analysis exceeds ${FOOTAGE_MAX_BYTES} bytes`,
157
+ };
158
+ }
159
+
160
+ const check = validateFootageAnalysis(analysis);
161
+ if (!check.ok) {
162
+ return {
163
+ ok: false,
164
+ status: 400,
165
+ error: `invalid FootageAnalysis: ${check.errors.join('; ')}`,
166
+ };
167
+ }
168
+
169
+ // Stamp version only; the sidecar filename (<sha8>.footage.json) is the
170
+ // authoritative source key. `asset` is already validated as a well-formed
171
+ // relative path — keep the client's value verbatim.
172
+ const toWrite: FootageAnalysis = {
173
+ ...(analysis as FootageAnalysis),
174
+ version: FOOTAGE_ANALYSIS_VERSION,
175
+ };
176
+
177
+ let abs: string;
178
+ try {
179
+ abs = analysisPathForSha8(sha8);
180
+ } catch (e) {
181
+ return { ok: false, status: 400, error: e instanceof Error ? e.message : 'bad path' };
182
+ }
183
+ try {
184
+ await Bun.write(abs, JSON.stringify(toWrite, null, 2));
185
+ } catch (e) {
186
+ return { ok: false, status: 500, error: e instanceof Error ? e.message : 'write failed' };
187
+ }
188
+ return { ok: true, path: path.posix.join('assets', `${sha8}.footage.json`), analysis: toWrite };
189
+ }
190
+
191
+ async function getEdl(slugParam: string | null | undefined): Promise<Edl | null> {
192
+ const slug = edlSlug(slugParam);
193
+ if (!slug) return null;
194
+ return readSidecar<Edl>(edlPathForSlug(slug), validateEdl);
195
+ }
196
+
197
+ async function saveEdl(
198
+ slugParam: string | null | undefined,
199
+ edl: unknown
200
+ ): Promise<{ ok: true; path: string; edl: Edl } | { ok: false; status: number; error: string }> {
201
+ const slug = edlSlug(slugParam);
202
+ if (!slug) return { ok: false, status: 400, error: 'invalid or missing slug (expected kebab)' };
203
+
204
+ let serialized: string;
205
+ try {
206
+ serialized = JSON.stringify(edl ?? {});
207
+ } catch {
208
+ return { ok: false, status: 400, error: 'body is not serializable JSON' };
209
+ }
210
+ if (Buffer.byteLength(serialized, 'utf8') > FOOTAGE_MAX_BYTES) {
211
+ return { ok: false, status: 413, error: `edl exceeds ${FOOTAGE_MAX_BYTES} bytes` };
212
+ }
213
+
214
+ const check = validateEdl(edl);
215
+ if (!check.ok) {
216
+ return { ok: false, status: 400, error: `invalid Edl: ${check.errors.join('; ')}` };
217
+ }
218
+
219
+ const toWrite: Edl = { ...(edl as Edl), version: EDL_VERSION };
220
+ let abs: string;
221
+ try {
222
+ abs = edlPathForSlug(slug);
223
+ } catch (e) {
224
+ return { ok: false, status: 400, error: e instanceof Error ? e.message : 'bad path' };
225
+ }
226
+ try {
227
+ await Bun.write(abs, JSON.stringify(toWrite, null, 2));
228
+ } catch (e) {
229
+ return { ok: false, status: 500, error: e instanceof Error ? e.message : 'write failed' };
230
+ }
231
+ return { ok: true, path: `${slug}.edl.json`, edl: toWrite };
232
+ }
233
+
234
+ return { analysisPathForSha8, edlPathForSlug, getAnalysis, saveAnalysis, getEdl, saveEdl };
235
+ }
@@ -34,6 +34,16 @@ export function createFsWatch(ctx: Context): FsWatch {
34
34
 
35
35
  function start() {
36
36
  if (watcher) return;
37
+ // Capture/CI opt-out (MAUDE_NO_WATCH=1): a headless render server has no
38
+ // interactive editor to hot-reload, so it never needs the watcher. It's a
39
+ // DEFENSIVE measure for long video exports — an HMR hard-reload broadcast
40
+ // fired mid-capture (from a concurrent write to designRoot, e.g. a peer edit
41
+ // or a runtime-state save NOT in the skip-list below) destroys the Playwright
42
+ // page's execution context. (NB: in the 2026-07-10 cinematic-cut dogfood the
43
+ // ACTUAL cause of the "Execution context was destroyed" crashes turned out to
44
+ // be renderer compositing pressure — too many full-frame mix-blend-mode /
45
+ // filter layers per frame — not the watcher; this guard is belt-and-braces.)
46
+ if (process.env.MAUDE_NO_WATCH === '1') return;
37
47
  try {
38
48
  watcher = watch(ctx.paths.designRoot, { recursive: true }, (_event, filename) => {
39
49
  if (!filename) return;
@@ -0,0 +1,370 @@
1
+ // generation/adapters/elevenlabs.ts — ElevenLabs audio stack via the REST API
2
+ // (DDR-164, Phase 2). Direct BYOK: the user's own ElevenLabs key →
3
+ // api.elevenlabs.io directly, no aggregator, no SDK (plain `fetch`). ONE key
4
+ // covers the whole audio stack:
5
+ //
6
+ // • Music — POST /v1/music (prompt → mp3)
7
+ // • SFX — POST /v1/sound-generation (prompt → mp3)
8
+ // • TTS — POST /v1/text-to-speech/{voice} (text + voice_id → mp3)
9
+ // • Scribe — POST /v1/speech-to-text (audio → word-timestamps → SRT)
10
+ //
11
+ // The audio verbs return binary mp3 in ONE round trip → an already-`done` Job
12
+ // (callers never branch on sync/async), localized into assets/<sha8>.mp3.
13
+ // Scribe returns word-level timings that captions.ts turns into an SRT the EDL
14
+ // caption track consumes.
15
+ //
16
+ // Security mirrors the Gemini adapter (F1/F2): the key is sent ONLY in the
17
+ // `xi-api-key` header (never a query string → no redirect/log leak); the outbound
18
+ // base is https + host-allowlisted before the key-bearing request; and every
19
+ // response body is byte-capped before it is buffered (RAM-DoS guard).
20
+
21
+ import { wordsToSrt } from '../captions.ts';
22
+ import type {
23
+ AdapterContext,
24
+ GenAsset,
25
+ GenRequest,
26
+ GenResult,
27
+ HistoryAudioItem,
28
+ Job,
29
+ ModelDescriptor,
30
+ ProviderAdapter,
31
+ ProviderDescriptor,
32
+ } from '../types.ts';
33
+
34
+ const DEFAULT_BASE = 'https://api.elevenlabs.io';
35
+ const API_BASE = process.env.MAUDE_ELEVENLABS_API_BASE ?? DEFAULT_BASE;
36
+ const ALLOWED_HOST = 'api.elevenlabs.io';
37
+ const ALLOW_CUSTOM_BASE = /^(1|true|on|yes)$/i.test(process.env.MAUDE_GEN_ALLOW_CUSTOM_BASE ?? '');
38
+
39
+ // Hard ceiling on a provider response before it is buffered (F1 — the same guard
40
+ // the Gemini adapter added). Audio is heavier than a JSON image, so the cap is
41
+ // higher; still bounded against a wedged/hostile upstream.
42
+ const MAX_RESPONSE_BYTES = Math.max(
43
+ 1024 * 1024,
44
+ Number(process.env.MAUDE_ELEVENLABS_MAX_RESPONSE_BYTES) || 128 * 1024 * 1024
45
+ );
46
+
47
+ /** Validate the outbound base ONCE (https + fixed host allowlist), F1/F2. */
48
+ function assertSafeBase(base: string): void {
49
+ let u: URL;
50
+ try {
51
+ u = new URL(base);
52
+ } catch {
53
+ throw new Error('ElevenLabs API base is not a valid URL');
54
+ }
55
+ if (u.protocol !== 'https:') throw new Error('ElevenLabs API base must be https');
56
+ if (u.hostname !== ALLOWED_HOST && !ALLOW_CUSTOM_BASE) {
57
+ throw new Error(
58
+ `ElevenLabs API base host ${u.hostname} is not allowlisted (set MAUDE_GEN_ALLOW_CUSTOM_BASE=1 to override)`
59
+ );
60
+ }
61
+ }
62
+
63
+ /** Read a Response body into bytes with a hard cap (F1). */
64
+ async function readBytesCapped(res: Response, maxBytes: number): Promise<Uint8Array> {
65
+ const declared = Number(res.headers.get('content-length'));
66
+ if (Number.isFinite(declared) && declared > maxBytes) {
67
+ throw new Error(`ElevenLabs response too large (${declared} > ${maxBytes} bytes)`);
68
+ }
69
+ const body = res.body;
70
+ if (!body) return new Uint8Array(await res.arrayBuffer());
71
+ const reader = body.getReader();
72
+ const chunks: Uint8Array[] = [];
73
+ let total = 0;
74
+ for (;;) {
75
+ const { done, value } = await reader.read();
76
+ if (done) break;
77
+ if (value) {
78
+ total += value.byteLength;
79
+ if (total > maxBytes) {
80
+ await reader.cancel().catch(() => {});
81
+ throw new Error(`ElevenLabs response exceeded ${maxBytes} bytes`);
82
+ }
83
+ chunks.push(value);
84
+ }
85
+ }
86
+ return new Uint8Array(Buffer.concat(chunks));
87
+ }
88
+
89
+ /** Read a capped body as text (for the JSON STT response + error messages). */
90
+ async function readTextCapped(res: Response, maxBytes: number): Promise<string> {
91
+ return new TextDecoder().decode(await readBytesCapped(res, maxBytes));
92
+ }
93
+
94
+ export const ELEVENLABS_DESCRIPTOR: ProviderDescriptor = {
95
+ id: 'elevenlabs',
96
+ label: 'ElevenLabs (audio)',
97
+ kind: 'cloud',
98
+ auth: 'api-key',
99
+ keychainService: 'com.maude.app.elevenlabs',
100
+ modalities: ['audio', 'transcription'],
101
+ keyUrl: 'https://elevenlabs.io/app/settings/api-keys',
102
+ notes:
103
+ 'Music, sound effects, text-to-speech, and Scribe speech-to-text — one key for the whole stack, billing your own ElevenLabs account. Music carries commercial-rights tiers (check your plan) and rejects prompts naming artists, songs, or lyrics. Voice cloning requires the voice owner’s consent.',
104
+ };
105
+
106
+ export const ELEVENLABS_MODELS: ModelDescriptor[] = [
107
+ { id: 'eleven_music', label: 'Music', modality: 'audio', costNote: 'per second', sync: true },
108
+ { id: 'eleven_text_to_sound_v2', label: 'Sound effects', modality: 'audio', sync: true },
109
+ {
110
+ id: 'eleven_v3',
111
+ label: 'Text to speech (v3)',
112
+ modality: 'audio',
113
+ costNote: 'per character',
114
+ sync: true,
115
+ },
116
+ {
117
+ id: 'eleven_multilingual_v2',
118
+ label: 'Text to speech (multilingual v2)',
119
+ modality: 'audio',
120
+ sync: true,
121
+ },
122
+ { id: 'scribe_v1', label: 'Scribe (speech-to-text)', modality: 'transcription', sync: true },
123
+ ];
124
+
125
+ /** Which audio verb a request wants — from params.audioKind, else inferred. */
126
+ type AudioKind = 'music' | 'sfx' | 'tts';
127
+ function resolveAudioKind(req: GenRequest): AudioKind {
128
+ const explicit = req.params?.audioKind;
129
+ if (explicit === 'music' || explicit === 'sfx' || explicit === 'tts') return explicit;
130
+ // Infer: a voice_id means speech; otherwise default to music (the flagship).
131
+ if (req.params?.voice_id) return 'tts';
132
+ return 'music';
133
+ }
134
+
135
+ const num = (v: unknown): number | undefined =>
136
+ typeof v === 'number' && Number.isFinite(v) ? v : undefined;
137
+ const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined);
138
+
139
+ interface ScribeWord {
140
+ text?: string;
141
+ start?: number;
142
+ end?: number;
143
+ type?: string;
144
+ }
145
+ interface ScribeResponse {
146
+ text?: string;
147
+ words?: ScribeWord[];
148
+ detail?: unknown;
149
+ }
150
+ interface ErrorResponse {
151
+ detail?: { message?: string } | string;
152
+ }
153
+
154
+ // Task 2.5 — ElevenLabs History (GET /v1/history). The user's re-downloadable
155
+ // past generations; matching + reuse spends NO credit (already paid).
156
+ interface HistoryItemRaw {
157
+ history_item_id?: string;
158
+ text?: string;
159
+ voice_id?: string;
160
+ date_unix?: number;
161
+ }
162
+ interface HistoryResponseRaw {
163
+ history?: HistoryItemRaw[];
164
+ }
165
+
166
+ /** Pure History-response → HistoryAudioItem[] (exported for unit test). */
167
+ export function parseHistory(json: HistoryResponseRaw): HistoryAudioItem[] {
168
+ const items = Array.isArray(json?.history) ? json.history : [];
169
+ const out: HistoryAudioItem[] = [];
170
+ for (const it of items) {
171
+ const id = typeof it.history_item_id === 'string' ? it.history_item_id : '';
172
+ if (!id) continue;
173
+ out.push({
174
+ id,
175
+ text: typeof it.text === 'string' ? it.text : '',
176
+ voiceId: typeof it.voice_id === 'string' ? it.voice_id : undefined,
177
+ at:
178
+ typeof it.date_unix === 'number' && Number.isFinite(it.date_unix)
179
+ ? new Date(it.date_unix * 1000).toISOString()
180
+ : undefined,
181
+ });
182
+ }
183
+ return out;
184
+ }
185
+
186
+ /** A sync Job wrapper — already resolved (mirrors the Gemini adapter). */
187
+ function doneJob(id: string, result: Promise<GenResult>): Job {
188
+ let settled: 'done' | 'failed' = 'done';
189
+ const guarded = result.catch((err) => {
190
+ settled = 'failed';
191
+ throw err;
192
+ });
193
+ return {
194
+ id,
195
+ status: () => settled,
196
+ async *events() {
197
+ try {
198
+ await guarded;
199
+ yield { status: 'done' as const };
200
+ } catch (err) {
201
+ yield {
202
+ status: 'failed' as const,
203
+ message: err instanceof Error ? err.message : String(err),
204
+ };
205
+ }
206
+ },
207
+ result: () => guarded,
208
+ cancel: () => {},
209
+ };
210
+ }
211
+
212
+ export function createElevenLabsAdapter(ctx: AdapterContext): ProviderAdapter {
213
+ function requireKey(): string {
214
+ if (!ctx.apiKey) throw new Error('no ElevenLabs key configured — add one in Settings');
215
+ return ctx.apiKey;
216
+ }
217
+
218
+ /** POST JSON, expect a binary audio body → an audio GenAsset. */
219
+ async function postForAudio(path: string, body: unknown): Promise<GenAsset> {
220
+ assertSafeBase(API_BASE);
221
+ const res = await fetch(`${API_BASE}${path}`, {
222
+ method: 'POST',
223
+ headers: { 'content-type': 'application/json', 'xi-api-key': requireKey() },
224
+ body: JSON.stringify(body),
225
+ signal: ctx.signal ?? AbortSignal.timeout(180_000),
226
+ });
227
+ if (!res.ok) {
228
+ // Error bodies are JSON; the key is never echoed in them.
229
+ let msg = `HTTP ${res.status}`;
230
+ try {
231
+ const j = JSON.parse(await readTextCapped(res, 64 * 1024)) as ErrorResponse;
232
+ const d = j.detail;
233
+ msg = (typeof d === 'string' ? d : d?.message) ?? msg;
234
+ } catch {
235
+ /* keep the status message */
236
+ }
237
+ throw new Error(`ElevenLabs error: ${msg}`);
238
+ }
239
+ const bytes = await readBytesCapped(res, MAX_RESPONSE_BYTES);
240
+ if (bytes.byteLength === 0) throw new Error('ElevenLabs returned an empty audio body');
241
+ return { kind: 'audio', mime: res.headers.get('content-type') || 'audio/mpeg', bytes };
242
+ }
243
+
244
+ async function runAudio(req: GenRequest): Promise<GenResult> {
245
+ const started = Date.now();
246
+ const kind = resolveAudioKind(req);
247
+ const prompt = req.prompt ?? '';
248
+ let asset: GenAsset;
249
+ if (kind === 'tts') {
250
+ const voiceId = str(req.params?.voice_id);
251
+ if (!voiceId) throw new Error('text-to-speech requires a params.voice_id');
252
+ const modelId = str(req.params?.model_id) ?? str(req.model) ?? 'eleven_v3';
253
+ asset = await postForAudio(`/v1/text-to-speech/${encodeURIComponent(voiceId)}`, {
254
+ text: prompt,
255
+ model_id: modelId,
256
+ });
257
+ } else if (kind === 'sfx') {
258
+ const duration = num(req.params?.durationSeconds);
259
+ asset = await postForAudio('/v1/sound-generation', {
260
+ text: prompt,
261
+ ...(duration ? { duration_seconds: duration } : {}),
262
+ });
263
+ } else {
264
+ // music
265
+ const ms = num(req.params?.durationSeconds);
266
+ asset = await postForAudio('/v1/music', {
267
+ prompt,
268
+ ...(ms ? { music_length_ms: Math.round(ms * 1000) } : {}),
269
+ });
270
+ }
271
+ return { assets: [asset], usage: { ms: Date.now() - started } };
272
+ }
273
+
274
+ async function runTranscription(req: GenRequest): Promise<GenResult> {
275
+ const started = Date.now();
276
+ if (!req.sourceAsset) throw new Error('transcription requires a sourceAsset (an audio/video)');
277
+ if (!ctx.readSourceAsset) throw new Error('transcription requires source-asset access');
278
+ const src = await ctx.readSourceAsset(req.sourceAsset);
279
+ if (!src) throw new Error(`source asset not found or unreadable: ${req.sourceAsset}`);
280
+
281
+ assertSafeBase(API_BASE);
282
+ // Multipart: the audio file + model_id. Blob keeps the bytes out of a query.
283
+ // Copy to a plain ArrayBuffer (a valid BlobPart; also normalizes byteOffset).
284
+ const buf = src.bytes.buffer.slice(
285
+ src.bytes.byteOffset,
286
+ src.bytes.byteOffset + src.bytes.byteLength
287
+ ) as ArrayBuffer;
288
+ const form = new FormData();
289
+ form.append('file', new Blob([buf], { type: src.mime }), 'audio');
290
+ form.append('model_id', str(req.model) ?? 'scribe_v1');
291
+ const res = await fetch(`${API_BASE}/v1/speech-to-text`, {
292
+ method: 'POST',
293
+ headers: { 'xi-api-key': requireKey() }, // FormData sets its own content-type boundary
294
+ body: form,
295
+ signal: ctx.signal ?? AbortSignal.timeout(300_000),
296
+ });
297
+ const text = await readTextCapped(res, MAX_RESPONSE_BYTES);
298
+ if (!res.ok) {
299
+ let msg = `HTTP ${res.status}`;
300
+ try {
301
+ const j = JSON.parse(text) as ErrorResponse;
302
+ const d = j.detail;
303
+ msg = (typeof d === 'string' ? d : d?.message) ?? msg;
304
+ } catch {
305
+ /* keep status */
306
+ }
307
+ throw new Error(`ElevenLabs Scribe error: ${msg}`);
308
+ }
309
+ const json = JSON.parse(text) as ScribeResponse;
310
+ // Scribe returns word-level entries with `type` ('word' | 'spacing'); keep words.
311
+ const words = (json.words ?? [])
312
+ .filter((w) => (w.type ?? 'word') === 'word' && typeof w.text === 'string')
313
+ .map((w) => ({ text: String(w.text), start: Number(w.start) || 0, end: Number(w.end) || 0 }));
314
+ const srt = wordsToSrt(words);
315
+ const asset: GenAsset = { kind: 'transcription', mime: 'application/x-subrip', text: srt };
316
+ return { assets: [asset], usage: { ms: Date.now() - started }, raw: { text: json.text } };
317
+ }
318
+
319
+ // Task 2.5 — the user's re-usable audio history. Re-downloading spends NO
320
+ // credit (already paid). Rate-limited LIST → the route caches the listing;
321
+ // the audio GET localizes through the host's saveAsset (magic-byte sniff).
322
+ async function listHistory(): Promise<HistoryAudioItem[]> {
323
+ assertSafeBase(API_BASE);
324
+ const res = await fetch(`${API_BASE}/v1/history?page_size=100`, {
325
+ headers: { 'xi-api-key': requireKey() },
326
+ signal: ctx.signal ?? AbortSignal.timeout(30_000),
327
+ });
328
+ const text = await readTextCapped(res, 8 * 1024 * 1024);
329
+ if (!res.ok) {
330
+ let msg = `HTTP ${res.status}`;
331
+ try {
332
+ const j = JSON.parse(text) as ErrorResponse;
333
+ const d = j.detail;
334
+ msg = (typeof d === 'string' ? d : d?.message) ?? msg;
335
+ } catch {
336
+ /* keep status */
337
+ }
338
+ throw new Error(`ElevenLabs history error: ${msg}`);
339
+ }
340
+ return parseHistory(JSON.parse(text) as HistoryResponseRaw);
341
+ }
342
+
343
+ async function fetchHistoryAudio(id: string): Promise<GenAsset> {
344
+ // The id is provider-scoped and used ONLY in a fixed, non-interpolated path
345
+ // segment (encodeURIComponent) — no query, no key leak.
346
+ assertSafeBase(API_BASE);
347
+ const res = await fetch(`${API_BASE}/v1/history/${encodeURIComponent(id)}/audio`, {
348
+ headers: { 'xi-api-key': requireKey() },
349
+ signal: ctx.signal ?? AbortSignal.timeout(120_000),
350
+ });
351
+ if (!res.ok) throw new Error(`ElevenLabs history audio error: HTTP ${res.status}`);
352
+ const bytes = await readBytesCapped(res, MAX_RESPONSE_BYTES);
353
+ if (bytes.byteLength === 0) throw new Error('ElevenLabs history returned an empty audio body');
354
+ return { kind: 'audio', mime: res.headers.get('content-type') || 'audio/mpeg', bytes };
355
+ }
356
+
357
+ return {
358
+ descriptor: ELEVENLABS_DESCRIPTOR,
359
+ async listModels() {
360
+ return ELEVENLABS_MODELS;
361
+ },
362
+ async submit(req: GenRequest): Promise<Job> {
363
+ const id = `gen_${crypto.randomUUID()}`;
364
+ const run = req.modality === 'transcription' ? runTranscription(req) : runAudio(req);
365
+ return doneJob(id, run);
366
+ },
367
+ listHistory,
368
+ fetchHistoryAudio,
369
+ };
370
+ }