@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,419 @@
1
+ #!/usr/bin/env bun
2
+ // _transcribe.mjs — internal shim behind `maude design transcribe` (DDR-062).
3
+ // Local, no-key speech-to-text for automatic subtitles: spawn whisper.cpp
4
+ // (whisper-cli) on an audio/video file, parse its word-level JSON, and emit
5
+ // SRT/VTT through the SHARED captions.ts reflow (the same rules the cloud STT
6
+ // providers — ElevenLabs Scribe, Groq Whisper — run through, so local + cloud
7
+ // subtitles never drift on line-length / timing). Runs under Bun so it can
8
+ // import the `.ts` caption module directly (mirrors _svg-optimize.mjs).
9
+ //
10
+ // whisper.cpp is a SOFT dependency (plugins/design/dependencies.json): absent →
11
+ // this exits non-zero with an actionable message (install it, or use the cloud
12
+ // Scribe path). No dev server, no ffmpeg of our own — mirrors the footage-probe
13
+ // shim shape.
14
+ //
15
+ // Model + binary resolution (whisper.cpp ships neither a default model nor
16
+ // auto-download): `--model <path>` or $MAUDE_WHISPER_MODEL (a ggml .bin);
17
+ // `--whisper <path>` or $MAUDE_WHISPER_CLI, else whisper-cli / whisper / main.
18
+ //
19
+ // Stdout (last line): the produced .srt path. Stderr: progress/diagnostics.
20
+ // Exit: 0 ok / 1 dependency-or-input problem / 3 transcription failed.
21
+
22
+ import { spawnSync } from 'node:child_process';
23
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
24
+ import { tmpdir } from 'node:os';
25
+ import { basename, dirname, extname, join, resolve } from 'node:path';
26
+
27
+ import { wordsToSrt, wordsToVtt } from '../generation/captions.ts';
28
+ import { resolveWhisperModel } from '../generation/whisper-models.ts';
29
+
30
+ /** Read `.design/config.json` → generation.transcription.whisperModel, or null
31
+ * (the preferred managed model id, e.g. "base"). */
32
+ function readConfigWhisperModel(repo) {
33
+ try {
34
+ const cfg = JSON.parse(readFileSync(join(repo, '.design', 'config.json'), 'utf8'));
35
+ const m = cfg?.generation?.transcription?.whisperModel;
36
+ return typeof m === 'string' ? m : null;
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * whisper.cpp can't decode arbitrary containers (mp4/mov/m4a) unless built with
44
+ * ffmpeg — the owner hit this on a real clip (GOTCHA C). If the input isn't
45
+ * already a WAV and system ffmpeg is present, transcode to a temp 16 kHz mono
46
+ * WAV (whisper's native rate) and transcribe THAT. Best-effort: no ffmpeg → we
47
+ * pass the original through and let whisper try (with the existing hint on
48
+ * failure). Returns { path, cleanup } — cleanup removes the temp file (if any).
49
+ */
50
+ function ensureWav(input) {
51
+ if (/\.wav$/i.test(input)) return { path: input, cleanup: () => {} };
52
+ const ffmpeg = spawnSync('ffmpeg', ['-version'], { stdio: 'ignore' });
53
+ if (ffmpeg.error) return { path: input, cleanup: () => {} }; // no ffmpeg → try as-is
54
+ // Security (ethical-hacker Finding 1): write into a FRESH 0700 private dir
55
+ // (mkdtemp), NOT a predictable `/tmp/maude-transcribe-<name>-<pid>.wav`. On a
56
+ // shared/multi-user host (Linux CI, the hub Docker image) a co-tenant could
57
+ // pre-plant a symlink at a guessable path and ffmpeg's `-y` would follow it
58
+ // and overwrite the target. An unguessable per-run dir removes the primitive.
59
+ const dir = mkdtempSync(join(tmpdir(), 'maude-transcribe-'));
60
+ const wav = join(dir, `${basename(input, extname(input))}.wav`);
61
+ process.stderr.write('transcribe: converting to 16 kHz mono WAV via ffmpeg…\n');
62
+ const conv = spawnSync(
63
+ 'ffmpeg',
64
+ ['-y', '-i', input, '-ar', '16000', '-ac', '1', '-c:a', 'pcm_s16le', wav],
65
+ { stdio: ['ignore', 'ignore', 'inherit'] }
66
+ );
67
+ const cleanup = () => rmSync(dir, { recursive: true, force: true });
68
+ if (conv.status !== 0 || !existsSync(wav)) {
69
+ cleanup();
70
+ return { path: input, cleanup: () => {} }; // conversion failed → fall back
71
+ }
72
+ return { path: wav, cleanup };
73
+ }
74
+
75
+ /**
76
+ * whisper.cpp `-oj` JSON → word timings. With `-ml 1` each `transcription[]`
77
+ * entry is ~one word; `offsets.from/to` are milliseconds from the audio start.
78
+ * Pure + exported so the parsing is unit-testable without whisper installed.
79
+ */
80
+ export function whisperJsonToWords(jsonText) {
81
+ let doc;
82
+ try {
83
+ doc = JSON.parse(jsonText);
84
+ } catch {
85
+ return [];
86
+ }
87
+ const segs = Array.isArray(doc?.transcription) ? doc.transcription : [];
88
+ const words = [];
89
+ for (const s of segs) {
90
+ const text = typeof s?.text === 'string' ? s.text.trim() : '';
91
+ if (!text) continue;
92
+ const from = Number(s?.offsets?.from);
93
+ const to = Number(s?.offsets?.to);
94
+ words.push({
95
+ text,
96
+ start: Number.isFinite(from) ? from / 1000 : 0,
97
+ end: Number.isFinite(to) ? to / 1000 : 0,
98
+ });
99
+ }
100
+ return words;
101
+ }
102
+
103
+ function parseArgs(argv) {
104
+ const out = { format: 'srt', words: true };
105
+ for (let i = 0; i < argv.length; i++) {
106
+ const a = argv[i];
107
+ const next = () => argv[++i];
108
+ if (a === '--source') out.source = next();
109
+ else if (a === '--root') out.root = next();
110
+ else if (a === '--out') out.out = next();
111
+ else if (a === '--format')
112
+ out.format = next(); // srt | vtt | both
113
+ else if (a === '--model') out.model = next();
114
+ else if (a === '--whisper') out.whisper = next();
115
+ else if (a === '--provider')
116
+ out.provider = next(); // whisper | elevenlabs | groq
117
+ else if (a === '--lang') out.lang = next();
118
+ else if (a === '--segments')
119
+ out.words = false; // segment-level, not per-word
120
+ else if (a === '--help' || a === '-h') out.help = true;
121
+ }
122
+ return out;
123
+ }
124
+
125
+ const CLOUD_PROVIDERS = new Set(['elevenlabs', 'groq']);
126
+ const KNOWN_PROVIDERS = new Set(['whisper', 'elevenlabs', 'groq']);
127
+
128
+ /**
129
+ * Resolve the transcription engine as an EXPLICIT choice (Task 2.6): the
130
+ * --provider flag wins; else the project's `generation.transcription.provider`
131
+ * config default; else `whisper` (local, free) — and we SAY which we chose.
132
+ * Maude never silently reaches for a paid cloud engine. Returns { provider,
133
+ * defaulted } where `defaulted` means neither the flag nor config set it.
134
+ */
135
+ export function resolveProvider(flag, repo) {
136
+ if (flag) {
137
+ if (!KNOWN_PROVIDERS.has(flag)) {
138
+ process.stderr.write(
139
+ `transcribe: unknown --provider '${flag}' (use whisper | elevenlabs | groq)\n`
140
+ );
141
+ process.exit(2);
142
+ }
143
+ return { provider: flag, defaulted: false };
144
+ }
145
+ const configured = readConfigProvider(repo);
146
+ if (configured && KNOWN_PROVIDERS.has(configured))
147
+ return { provider: configured, defaulted: false };
148
+ return { provider: 'whisper', defaulted: true };
149
+ }
150
+
151
+ /** Read `.design/config.json` → generation.transcription.provider, or null. */
152
+ function readConfigProvider(repo) {
153
+ try {
154
+ const raw = readFileSync(join(repo, '.design', 'config.json'), 'utf8');
155
+ const cfg = JSON.parse(raw);
156
+ const p = cfg?.generation?.transcription?.provider;
157
+ return typeof p === 'string' ? p : null;
158
+ } catch {
159
+ return null;
160
+ }
161
+ }
162
+
163
+ function resolveWhisper(explicit) {
164
+ // Security (Phase-2 ethical-hacker): do NOT include the bare name `main`
165
+ // (whisper.cpp's legacy binary) in the fallback chain — it is one of the most
166
+ // common executable names in the wild, and the probe below EXECUTES each
167
+ // candidate (`--help`). A user running this inside an untrusted repo with `.`
168
+ // on $PATH could otherwise auto-run a seeded `main`. Only the explicit
169
+ // --whisper / $MAUDE_WHISPER_CLI (owner's own choice) and the specific
170
+ // whisper-cli / whisper names are probed.
171
+ const candidates = [explicit, process.env.MAUDE_WHISPER_CLI, 'whisper-cli', 'whisper'].filter(
172
+ Boolean
173
+ );
174
+ for (const c of candidates) {
175
+ const probe = spawnSync(c, ['--help'], { stdio: 'ignore' });
176
+ if (!probe.error) return c;
177
+ }
178
+ return null;
179
+ }
180
+
181
+ /**
182
+ * Cloud STT path (Task 2.6): the user CHOSE elevenlabs / groq. The transcription
183
+ * runs on the dev server's privileged generate route (the key is resolved
184
+ * server-side — never handed to this CLI), which writes the SRT sidecar next to
185
+ * the source and returns its `assets/<sha8>.srt` path. No-silent-switch: if the
186
+ * server is down or the source isn't an assets/ path, we ERROR clearly naming
187
+ * the fix AND the local alternative — we NEVER quietly fall back to whisper.
188
+ */
189
+ async function runCloud(args, repo, provider) {
190
+ const source = args.source;
191
+ if (!source.startsWith('assets/') || source.includes('..')) {
192
+ process.stderr.write(
193
+ `transcribe: cloud engine '${provider}' needs a content-addressed source (assets/<sha8>.<ext>).\n` +
194
+ ' Ingest the file into the project first, or use --provider whisper for an arbitrary local path.\n'
195
+ );
196
+ process.exit(2);
197
+ }
198
+ const serverPath = join(repo, '.design', '_server.json');
199
+ if (!existsSync(serverPath)) {
200
+ process.stderr.write(
201
+ `transcribe: cloud engine '${provider}' needs the dev server running (it resolves your key server-side).\n` +
202
+ ' Start it: maude design server-up — or use --provider whisper (local, no server, no key).\n'
203
+ );
204
+ process.exit(1);
205
+ }
206
+ let port;
207
+ try {
208
+ port = JSON.parse(readFileSync(serverPath, 'utf8'))?.port;
209
+ } catch {
210
+ /* handled below */
211
+ }
212
+ if (!port) {
213
+ process.stderr.write(`transcribe: could not read a port from ${serverPath}\n`);
214
+ process.exit(1);
215
+ }
216
+
217
+ const base = `http://127.0.0.1:${port}/_api/generate-jobs`;
218
+ const body = { modality: 'transcription', provider, sourceAsset: source };
219
+ if (args.model) body.model = args.model;
220
+ if (args.lang) body.params = { language: args.lang };
221
+
222
+ process.stderr.write(`transcribe: submitting to ${provider} (cloud STT)…\n`);
223
+ let jobId;
224
+ try {
225
+ const res = await fetch(base, {
226
+ method: 'POST',
227
+ headers: { 'content-type': 'application/json' },
228
+ body: JSON.stringify(body),
229
+ });
230
+ const json = await res.json().catch(() => ({}));
231
+ if (!res.ok) {
232
+ process.stderr.write(
233
+ `transcribe: ${provider} enqueue rejected: ${json?.error ?? res.status}\n`
234
+ );
235
+ process.exit(3);
236
+ }
237
+ jobId = json.jobId;
238
+ } catch (err) {
239
+ process.stderr.write(`transcribe: could not reach the dev server: ${err?.message ?? err}\n`);
240
+ process.exit(1);
241
+ }
242
+ if (!jobId) {
243
+ process.stderr.write('transcribe: enqueue returned no job id\n');
244
+ process.exit(3);
245
+ }
246
+
247
+ const deadline = Date.now() + 300_000;
248
+ for (;;) {
249
+ await new Promise((r) => setTimeout(r, 2000));
250
+ let job;
251
+ try {
252
+ const jobs = await (await fetch(base)).json();
253
+ job = Array.isArray(jobs?.jobs) ? jobs.jobs.find((j) => j.id === jobId) : null;
254
+ } catch {
255
+ /* transient — keep polling until the deadline */
256
+ }
257
+ if (job?.status === 'done') {
258
+ const out = job.assets?.[0];
259
+ if (!out) {
260
+ process.stderr.write('transcribe: job done but produced no caption sidecar\n');
261
+ process.exit(3);
262
+ }
263
+ // Chosen engine may only produce SRT; the shared reflow output is SRT. If a
264
+ // VTT was asked for, note that the cloud path currently writes SRT only.
265
+ process.stdout.write(`${out.startsWith('/') ? out.slice(1) : out}\n`);
266
+ return;
267
+ }
268
+ if (job?.status === 'failed') {
269
+ process.stderr.write(
270
+ `transcribe: ${provider} transcription failed: ${job.error ?? 'unknown'}\n`
271
+ );
272
+ process.exit(3);
273
+ }
274
+ if (Date.now() >= deadline) {
275
+ process.stderr.write(`transcribe: timed out waiting for ${provider} (job ${jobId})\n`);
276
+ process.exit(3);
277
+ }
278
+ }
279
+ }
280
+
281
+ async function main() {
282
+ const args = parseArgs(process.argv.slice(2));
283
+ if (args.help) {
284
+ process.stderr.write(
285
+ 'usage: maude design transcribe --source <assets/x.mp4|file> [--root <repo>]\n' +
286
+ ' [--provider whisper|elevenlabs|groq] [--format srt|vtt|both] [--model <id>]\n' +
287
+ ' [--whisper <bin>] [--lang <code>] [--segments]\n' +
288
+ '\n' +
289
+ ' The engine is an EXPLICIT choice: --provider wins, else the project\n' +
290
+ ' generation.transcription.provider, else local whisper (never a silent\n' +
291
+ ' switch to a paid cloud engine). whisper = local/free/no-key;\n' +
292
+ ' elevenlabs|groq = cloud (needs the dev server + a key in Settings).\n'
293
+ );
294
+ process.exit(0);
295
+ }
296
+ if (!args.source) {
297
+ process.stderr.write('transcribe: --source is required\n');
298
+ process.exit(1);
299
+ }
300
+
301
+ const repo = args.root || process.env.CLAUDE_PROJECT_DIR || process.cwd();
302
+
303
+ // Resolve the engine as an explicit choice (Task 2.6). A chosen cloud engine
304
+ // routes through the dev server (key server-side); the local default stays
305
+ // whisper. We NEVER auto-switch between them.
306
+ const { provider, defaulted } = resolveProvider(args.provider, repo);
307
+ if (defaulted)
308
+ process.stderr.write(
309
+ 'transcribe: no engine configured — using local whisper ' +
310
+ '(set generation.transcription.provider or pass --provider to choose)\n'
311
+ );
312
+ if (CLOUD_PROVIDERS.has(provider)) {
313
+ await runCloud(args, repo, provider);
314
+ return;
315
+ }
316
+
317
+ // Resolve the input: an `assets/<name>` under the design root, or a plain path.
318
+ // For the assets/ form, reject `..` so the content-addressed branch can't be
319
+ // used to climb out of <designRoot>/assets/ (parity with api.readAssetBytes —
320
+ // the network Scribe path is fully contained; keep this local path close). The
321
+ // plain-path branch intentionally accepts an owner-named file (transcribing an
322
+ // arbitrary local clip is a legitimate CLI use; there is no untrusted caller).
323
+ if (args.source.startsWith('assets/') && args.source.includes('..')) {
324
+ process.stderr.write('transcribe: invalid assets/ path (no "..")\n');
325
+ process.exit(1);
326
+ }
327
+ const input = args.source.startsWith('assets/')
328
+ ? join(repo, '.design', args.source)
329
+ : resolve(repo, args.source);
330
+ if (!existsSync(input)) {
331
+ process.stderr.write(`transcribe: input not found: ${input}\n`);
332
+ process.exit(1);
333
+ }
334
+
335
+ const whisper = resolveWhisper(args.whisper);
336
+ if (!whisper) {
337
+ process.stderr.write(
338
+ 'transcribe: whisper.cpp not found (whisper-cli / whisper / main).\n' +
339
+ ' Install: brew install whisper-cpp (or build https://github.com/ggml-org/whisper.cpp)\n' +
340
+ ' Or set $MAUDE_WHISPER_CLI. For cloud STT instead, use ElevenLabs Scribe / Groq Whisper.\n'
341
+ );
342
+ process.exit(1);
343
+ }
344
+
345
+ // Model resolution (Task 2.7 — one-click): explicit --model / $MAUDE_WHISPER_MODEL
346
+ // wins; otherwise auto-resolve a model downloaded via Settings ("Download &
347
+ // enable"), preferring the configured `generation.transcription.whisperModel`.
348
+ const model =
349
+ args.model ||
350
+ process.env.MAUDE_WHISPER_MODEL ||
351
+ resolveWhisperModel(readConfigWhisperModel(repo));
352
+ if (!model || !existsSync(model)) {
353
+ process.stderr.write(
354
+ 'transcribe: no whisper.cpp model available.\n' +
355
+ ' One-click: open Settings (⌘,) → Subtitles → "Download model" (base is a good multilingual default).\n' +
356
+ ' Or pass --model <ggml-*.bin> / set $MAUDE_WHISPER_MODEL, or use a cloud engine (--provider elevenlabs|groq).\n'
357
+ );
358
+ process.exit(1);
359
+ }
360
+
361
+ // The owner's Czech-footage gotcha: an English-only `.en` model garbles other
362
+ // languages. Warn (don't block — the user may know their audio is English).
363
+ if (/\.en\.bin$/i.test(model) && args.lang && args.lang.toLowerCase() !== 'en') {
364
+ process.stderr.write(
365
+ `transcribe: WARNING — model ${basename(model)} is English-only but --lang ${args.lang} was given; expect garbled output. Use a multilingual model (e.g. ggml-base.bin).\n`
366
+ );
367
+ }
368
+
369
+ // Transcode to a WAV whisper.cpp can always read (GOTCHA C), when needed.
370
+ const wav = ensureWav(input);
371
+
372
+ const outBase = args.out
373
+ ? args.out.replace(/\.(srt|vtt|json)$/i, '')
374
+ : join(dirname(input), basename(input, extname(input)));
375
+
376
+ // whisper.cpp: JSON output for the shared reflow, `-ml 1` for word-level timings.
377
+ const flags = ['-m', model, '-f', wav.path, '-oj', '-of', outBase];
378
+ if (args.words) flags.push('-ml', '1');
379
+ if (args.lang) flags.push('-l', args.lang);
380
+
381
+ process.stderr.write(`transcribe: running ${whisper} (${basename(model)})…\n`);
382
+ const run = spawnSync(whisper, flags, { stdio: ['ignore', 'inherit', 'inherit'] });
383
+ wav.cleanup();
384
+ if (run.status !== 0) {
385
+ process.stderr.write(
386
+ `transcribe: whisper.cpp exited ${run.status ?? '?'} — if the input is a non-WAV container, install ffmpeg so Maude can auto-convert it (or convert to 16 kHz mono WAV first).\n`
387
+ );
388
+ process.exit(3);
389
+ }
390
+
391
+ const jsonPath = `${outBase}.json`;
392
+ if (!existsSync(jsonPath)) {
393
+ process.stderr.write(`transcribe: whisper produced no JSON at ${jsonPath}\n`);
394
+ process.exit(3);
395
+ }
396
+ const words = whisperJsonToWords(readFileSync(jsonPath, 'utf8'));
397
+ if (words.length === 0) {
398
+ process.stderr.write('transcribe: no words recognized (silent or unsupported audio?)\n');
399
+ process.exit(3);
400
+ }
401
+
402
+ const written = [];
403
+ if (args.format === 'srt' || args.format === 'both') {
404
+ const p = `${outBase}.srt`;
405
+ writeFileSync(p, wordsToSrt(words));
406
+ written.push(p);
407
+ }
408
+ if (args.format === 'vtt' || args.format === 'both') {
409
+ const p = `${outBase}.vtt`;
410
+ writeFileSync(p, wordsToVtt(words));
411
+ written.push(p);
412
+ }
413
+ // Last stdout line = the primary (SRT-preferred) caption path, for $(…) capture.
414
+ process.stdout.write(`${written[0]}\n`);
415
+ }
416
+
417
+ // Only run main when invoked directly (import.meta.main) so the parser can be
418
+ // imported by the test without spawning whisper (mirrors the F3 CLI-guard fix).
419
+ if (import.meta.main) main();
@@ -0,0 +1,80 @@
1
+ // _transcribe.test.mjs — the whisper.cpp JSON → word-timings parser (Phase 2).
2
+ // The spawn/model resolution is a soft-dep live gate; this covers the pure,
3
+ // deterministic parsing that turns whisper.cpp `-oj` output into CaptionWords
4
+ // (then captions.ts renders the SRT — covered in captions.test.ts).
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import { wordsToSrt } from '../generation/captions.ts';
11
+ import { resolveProvider, whisperJsonToWords } from './_transcribe.mjs';
12
+
13
+ function tmpRepo(config) {
14
+ const root = mkdtempSync(join(tmpdir(), 'maude-transcribe-'));
15
+ mkdirSync(join(root, '.design'), { recursive: true });
16
+ if (config !== undefined)
17
+ writeFileSync(join(root, '.design', 'config.json'), JSON.stringify(config));
18
+ return root;
19
+ }
20
+
21
+ const WHISPER_JSON = JSON.stringify({
22
+ transcription: [
23
+ { offsets: { from: 0, to: 480 }, text: ' Hello' },
24
+ { offsets: { from: 480, to: 900 }, text: ' there.' },
25
+ { offsets: { from: 1200, to: 1800 }, text: ' Maude' },
26
+ { offsets: { from: 0, to: 0 }, text: ' ' }, // blank → dropped
27
+ ],
28
+ });
29
+
30
+ describe('whisperJsonToWords', () => {
31
+ test('maps offsets (ms) to seconds and trims text', () => {
32
+ const words = whisperJsonToWords(WHISPER_JSON);
33
+ expect(words).toHaveLength(3);
34
+ expect(words[0]).toEqual({ text: 'Hello', start: 0, end: 0.48 });
35
+ expect(words[1]).toEqual({ text: 'there.', start: 0.48, end: 0.9 });
36
+ expect(words[2].text).toBe('Maude');
37
+ });
38
+
39
+ test('feeds captions.ts to produce a valid SRT', () => {
40
+ const srt = wordsToSrt(whisperJsonToWords(WHISPER_JSON));
41
+ expect(srt).toContain('00:00:00,000 --> 00:00:00,900');
42
+ expect(srt).toContain('Hello there.');
43
+ });
44
+
45
+ test('malformed / empty JSON yields no words (never throws)', () => {
46
+ expect(whisperJsonToWords('not json')).toEqual([]);
47
+ expect(whisperJsonToWords('{}')).toEqual([]);
48
+ expect(whisperJsonToWords(JSON.stringify({ transcription: [] }))).toEqual([]);
49
+ });
50
+
51
+ test('tolerates missing offsets', () => {
52
+ const words = whisperJsonToWords(JSON.stringify({ transcription: [{ text: 'x' }] }));
53
+ expect(words).toEqual([{ text: 'x', start: 0, end: 0 }]);
54
+ });
55
+ });
56
+
57
+ describe('resolveProvider (Task 2.6 — explicit engine choice, no silent switch)', () => {
58
+ test('an explicit valid flag wins over config', () => {
59
+ const repo = tmpRepo({ generation: { transcription: { provider: 'groq' } } });
60
+ expect(resolveProvider('elevenlabs', repo)).toEqual({
61
+ provider: 'elevenlabs',
62
+ defaulted: false,
63
+ });
64
+ });
65
+
66
+ test('falls back to the project config default', () => {
67
+ const repo = tmpRepo({ generation: { transcription: { provider: 'groq' } } });
68
+ expect(resolveProvider(undefined, repo)).toEqual({ provider: 'groq', defaulted: false });
69
+ });
70
+
71
+ test('defaults to local whisper (marked defaulted) when nothing is set', () => {
72
+ const repo = tmpRepo(); // no config
73
+ expect(resolveProvider(undefined, repo)).toEqual({ provider: 'whisper', defaulted: true });
74
+ });
75
+
76
+ test('a malformed config provider is ignored → whisper default', () => {
77
+ const repo = tmpRepo({ generation: { transcription: { provider: 'bogus' } } });
78
+ expect(resolveProvider(undefined, repo)).toEqual({ provider: 'whisper', defaulted: true });
79
+ });
80
+ });