@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,386 @@
1
+ #!/usr/bin/env node
2
+ // _ingest-footage.mjs — ingest a DIRECTORY of raw footage into the design root's
3
+ // content-addressed asset store, behind `maude design ingest-footage` (DDR-062
4
+ // dispatch). The folder-scale counterpart to `_fetch-asset.mjs` (single URL).
5
+ //
6
+ // feature-footage-analysis-director, Task 3. WHY THIS EXISTS: the `/design:reel`
7
+ // director pipeline wants to start from a folder of client clips
8
+ // (`/…/podklady/video`). This verb copies each recognised video (and any image/
9
+ // audio) into `<designRoot>/assets/<sha8>.<ext>` — the SAME content-addressed
10
+ // convention `/_api/asset` + `_fetch-asset.mjs` use, so a clip references
11
+ // uniformly as `assets/<sha8>.mp4` on both the main and the canvas origin.
12
+ //
13
+ // SECURITY / DISCIPLINE (mirrors _fetch-asset.mjs):
14
+ // • magic-byte sniff decides the category + extension — the source name /
15
+ // extension is NEVER trusted (a `.mp4` that is really an HTML polyglot →
16
+ // skipped). Mirrors api.ts sniffAssetType (mp4/mov/m4v/webm video · mp3/wav/
17
+ // m4a audio · png/jpg/gif/webp image); SVG/script → null → skipped.
18
+ // • content-addressed `<sha8>.<ext>` name, `[a-z0-9._-]` only, written FLAT
19
+ // under assets/ with a realpath containment assertion.
20
+ // • per-file size cap (default 100 MB — the DDR-148 video ceiling); oversized
21
+ // files are SKIPPED LOUDLY (listed in the manifest), never silently dropped.
22
+ // • the source directory is READ-ONLY — this never writes back into it.
23
+ // • no network, no shell interpolation, no container parsing (magic bytes only).
24
+ //
25
+ // Reached via `maude design ingest-footage <dir> --root <repo>`, never a raw path.
26
+ // stdout (default) = a JSON manifest { clips:[{asset,src,bytes,category}],
27
+ // skipped:[{src,why}], assetsDir }. Exit: 0 ok (even with skips) · 2 usage ·
28
+ // 6 write/containment error · 1 other.
29
+
30
+ import { createHash } from 'node:crypto';
31
+ import {
32
+ closeSync,
33
+ copyFileSync,
34
+ createReadStream,
35
+ existsSync,
36
+ mkdirSync,
37
+ openSync,
38
+ readdirSync,
39
+ readSync,
40
+ statSync,
41
+ } from 'node:fs';
42
+ import { extname, join, resolve, sep } from 'node:path';
43
+ import { pathToFileURL } from 'node:url';
44
+
45
+ // The DDR-148 video ceiling (100 MB). Images/audio also ride this generous cap
46
+ // here — the reel pipeline's inputs are dominated by video. Override per-file
47
+ // via --max-bytes.
48
+ const DEFAULT_MAX_BYTES = 100 * 1024 * 1024;
49
+
50
+ class IngestError extends Error {
51
+ constructor(code, message) {
52
+ super(message);
53
+ this.code = code;
54
+ }
55
+ }
56
+
57
+ // ── magic-byte sniff (mirrors apps/studio/api.ts sniffAssetType) ──────────────
58
+
59
+ /** Sniff → { ext, category } or null. Bytes decide; name/extension untrusted. */
60
+ export function sniffAsset(b) {
61
+ // Images (mirrors sniffImageType).
62
+ if (
63
+ b.length >= 8 &&
64
+ b[0] === 0x89 &&
65
+ b[1] === 0x50 &&
66
+ b[2] === 0x4e &&
67
+ b[3] === 0x47 &&
68
+ b[4] === 0x0d &&
69
+ b[5] === 0x0a &&
70
+ b[6] === 0x1a &&
71
+ b[7] === 0x0a
72
+ )
73
+ return { ext: 'png', category: 'image' };
74
+ if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff)
75
+ return { ext: 'jpg', category: 'image' };
76
+ if (
77
+ b.length >= 6 &&
78
+ b[0] === 0x47 &&
79
+ b[1] === 0x49 &&
80
+ b[2] === 0x46 &&
81
+ b[3] === 0x38 &&
82
+ (b[4] === 0x37 || b[4] === 0x39) &&
83
+ b[5] === 0x61
84
+ )
85
+ return { ext: 'gif', category: 'image' };
86
+ if (
87
+ b.length >= 12 &&
88
+ b[0] === 0x52 &&
89
+ b[1] === 0x49 &&
90
+ b[2] === 0x46 &&
91
+ b[3] === 0x46 &&
92
+ b[8] === 0x57 &&
93
+ b[9] === 0x45 &&
94
+ b[10] === 0x42 &&
95
+ b[11] === 0x50
96
+ )
97
+ return { ext: 'webp', category: 'image' };
98
+ // ISO-BMFF (mp4 / mov / m4a / m4v): "ftyp" at offset 4, brand at 8.
99
+ if (b.length >= 12 && b[4] === 0x66 && b[5] === 0x74 && b[6] === 0x79 && b[7] === 0x70) {
100
+ const brand = String.fromCharCode(b[8] ?? 0, b[9] ?? 0, b[10] ?? 0, b[11] ?? 0);
101
+ if (brand === 'qt ') return { ext: 'mov', category: 'video' };
102
+ if (brand.startsWith('M4A')) return { ext: 'm4a', category: 'audio' };
103
+ if (brand.startsWith('M4V')) return { ext: 'm4v', category: 'video' };
104
+ return { ext: 'mp4', category: 'video' };
105
+ }
106
+ // Matroska / WebM — EBML header.
107
+ if (b.length >= 4 && b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 0xa3)
108
+ return { ext: 'webm', category: 'video' };
109
+ // MP3 — "ID3" or frame-sync.
110
+ if (b.length >= 3 && b[0] === 0x49 && b[1] === 0x44 && b[2] === 0x33)
111
+ return { ext: 'mp3', category: 'audio' };
112
+ if (b.length >= 2 && b[0] === 0xff && ((b[1] ?? 0) & 0xe0) === 0xe0)
113
+ return { ext: 'mp3', category: 'audio' };
114
+ // WAV.
115
+ if (
116
+ b.length >= 12 &&
117
+ b[0] === 0x52 &&
118
+ b[1] === 0x49 &&
119
+ b[2] === 0x46 &&
120
+ b[3] === 0x46 &&
121
+ b[8] === 0x57 &&
122
+ b[9] === 0x41 &&
123
+ b[10] === 0x56 &&
124
+ b[11] === 0x45
125
+ )
126
+ return { ext: 'wav', category: 'audio' };
127
+ return null;
128
+ }
129
+
130
+ // ── path helpers (mirror _fetch-asset.mjs containment) ───────────────────────
131
+
132
+ /** Resolve `<root>/<designRootRel>/assets` with a containment assertion. */
133
+ export function assetsDirFor(root, designRootRel) {
134
+ const rootAbs = resolve(root);
135
+ const assetsDir = resolve(rootAbs, designRootRel, 'assets');
136
+ if (assetsDir !== rootAbs && !assetsDir.startsWith(rootAbs + sep))
137
+ throw new IngestError(6, `assets dir escapes root: ${assetsDir}`);
138
+ return assetsDir;
139
+ }
140
+
141
+ /** Assert a content-addressed name and resolve it inside assets/ (no traversal). */
142
+ export function containedName(assetsDir, name) {
143
+ if (!/^[a-z0-9]{8}\.(png|jpg|gif|webp|mp4|mov|m4v|webm|mp3|wav|m4a)$/.test(name))
144
+ throw new IngestError(6, `generated name failed the charset contract: ${name}`);
145
+ const fileAbs = resolve(assetsDir, name);
146
+ if (fileAbs !== join(assetsDir, name) || !fileAbs.startsWith(assetsDir + sep))
147
+ throw new IngestError(6, `resolved asset path escapes assets dir: ${fileAbs}`);
148
+ return fileAbs;
149
+ }
150
+
151
+ // ── sniff head + full-file hash (streamed — clips can be 100 MB) ─────────────
152
+
153
+ /** Read the first `n` bytes of a file for the magic-byte sniff. */
154
+ function readHead(file, n = 16) {
155
+ const fd = openSync(file, 'r');
156
+ try {
157
+ const buf = Buffer.alloc(n);
158
+ const bytesRead = readSync(fd, buf, 0, n, 0);
159
+ return buf.subarray(0, bytesRead);
160
+ } finally {
161
+ closeSync(fd);
162
+ }
163
+ }
164
+
165
+ /** Full-file sha256 (streamed) → first 8 hex chars. */
166
+ function hashFile(file) {
167
+ return new Promise((res, rej) => {
168
+ const h = createHash('sha256');
169
+ createReadStream(file)
170
+ .on('error', rej)
171
+ .on('data', (d) => h.update(d))
172
+ .on('end', () => res(h.digest('hex').slice(0, 8)));
173
+ });
174
+ }
175
+
176
+ // ── directory walk + ingest ──────────────────────────────────────────────────
177
+
178
+ function listFiles(dir, recursive) {
179
+ const out = [];
180
+ const walk = (d) => {
181
+ let entries;
182
+ try {
183
+ entries = readdirSync(d, { withFileTypes: true });
184
+ } catch (e) {
185
+ throw new IngestError(2, `cannot read directory ${d}: ${e?.message ?? e}`);
186
+ }
187
+ for (const ent of entries) {
188
+ if (ent.name.startsWith('.')) continue; // skip dotfiles / .DS_Store
189
+ const abs = join(d, ent.name);
190
+ if (ent.isDirectory()) {
191
+ if (recursive) walk(abs);
192
+ } else if (ent.isFile()) {
193
+ out.push(abs);
194
+ }
195
+ }
196
+ };
197
+ walk(dir);
198
+ return out.sort();
199
+ }
200
+
201
+ /**
202
+ * Ingest every recognised media file under `dir` into assets/. Returns
203
+ * { clips, skipped, assetsDir }. Never throws on a per-file problem (that file
204
+ * is skipped with a reason); throws IngestError only for whole-run failures
205
+ * (unreadable dir, containment breach).
206
+ */
207
+ export async function ingestFootage({
208
+ dir,
209
+ root,
210
+ designRootRel = '.design',
211
+ maxBytes = DEFAULT_MAX_BYTES,
212
+ recursive = false,
213
+ }) {
214
+ const srcDir = resolve(dir);
215
+ if (!existsSync(srcDir) || !statSync(srcDir).isDirectory())
216
+ throw new IngestError(2, `not a directory: ${srcDir}`);
217
+
218
+ const assetsDir = assetsDirFor(root, designRootRel);
219
+ mkdirSync(assetsDir, { recursive: true });
220
+
221
+ const clips = [];
222
+ const skipped = [];
223
+ const seen = new Set(); // sha8 dedupe within this run
224
+
225
+ for (const file of listFiles(srcDir, recursive)) {
226
+ let size;
227
+ try {
228
+ size = statSync(file).size;
229
+ } catch (e) {
230
+ skipped.push({ src: file, why: `stat failed: ${e?.message ?? e}` });
231
+ continue;
232
+ }
233
+ if (size === 0) {
234
+ skipped.push({ src: file, why: 'empty file' });
235
+ continue;
236
+ }
237
+ if (size > maxBytes) {
238
+ skipped.push({
239
+ src: file,
240
+ why: `exceeds the ${Math.round(maxBytes / (1024 * 1024))} MB per-file cap (${Math.round(size / (1024 * 1024))} MB)`,
241
+ });
242
+ continue;
243
+ }
244
+ let info;
245
+ try {
246
+ info = sniffAsset(readHead(file));
247
+ } catch (e) {
248
+ skipped.push({ src: file, why: `read failed: ${e?.message ?? e}` });
249
+ continue;
250
+ }
251
+ if (!info) {
252
+ skipped.push({
253
+ src: file,
254
+ why: `unrecognised media (not video/image/audio); ext ${extname(file) || '(none)'}`,
255
+ });
256
+ continue;
257
+ }
258
+ let sha8;
259
+ try {
260
+ sha8 = await hashFile(file);
261
+ } catch (e) {
262
+ skipped.push({ src: file, why: `hash failed: ${e?.message ?? e}` });
263
+ continue;
264
+ }
265
+ const name = `${sha8}.${info.ext}`;
266
+ let fileAbs;
267
+ try {
268
+ fileAbs = containedName(assetsDir, name);
269
+ } catch (e) {
270
+ skipped.push({ src: file, why: e?.message ?? 'containment error' });
271
+ continue;
272
+ }
273
+ // Content-addressed dedupe: identical bytes → identical name → copy once.
274
+ if (!existsSync(fileAbs)) {
275
+ try {
276
+ copyFileSync(file, fileAbs);
277
+ } catch (e) {
278
+ skipped.push({ src: file, why: `copy failed: ${e?.message ?? e}` });
279
+ continue;
280
+ }
281
+ }
282
+ if (!seen.has(sha8)) {
283
+ seen.add(sha8);
284
+ clips.push({ asset: `assets/${name}`, src: file, bytes: size, category: info.category });
285
+ }
286
+ }
287
+
288
+ return { clips, skipped, assetsDir };
289
+ }
290
+
291
+ // ── CLI ──────────────────────────────────────────────────────────────────────
292
+
293
+ function parseArgv(argv) {
294
+ const out = {
295
+ dir: null,
296
+ root: null,
297
+ designRoot: '.design',
298
+ maxBytes: DEFAULT_MAX_BYTES,
299
+ recursive: false,
300
+ json: true,
301
+ };
302
+ for (let i = 0; i < argv.length; i += 1) {
303
+ const a = argv[i];
304
+ switch (a) {
305
+ case '--root':
306
+ out.root = argv[++i];
307
+ break;
308
+ case '--design-root':
309
+ out.designRoot = argv[++i];
310
+ break;
311
+ case '--max-bytes':
312
+ out.maxBytes = Number(argv[++i]);
313
+ break;
314
+ case '--recursive':
315
+ out.recursive = true;
316
+ break;
317
+ case '--json':
318
+ out.json = true;
319
+ break;
320
+ case '--help':
321
+ case '-h':
322
+ out.help = true;
323
+ break;
324
+ default:
325
+ if (a.startsWith('-')) throw new IngestError(2, `unknown flag ${a}`);
326
+ if (out.dir === null) out.dir = a;
327
+ else throw new IngestError(2, `unexpected extra arg ${a}`);
328
+ }
329
+ }
330
+ return out;
331
+ }
332
+
333
+ const HELP = `ingest-footage — copy a folder of raw footage into the design asset store
334
+ (reached via \`maude design ingest-footage\`)
335
+
336
+ Usage:
337
+ maude design ingest-footage <dir> --root <repo> [--design-root .design]
338
+ [--recursive] [--max-bytes N]
339
+
340
+ Walks <dir>, magic-byte-sniffs each file (mp4/mov/m4v/webm video · mp3/wav/m4a
341
+ audio · png/jpg/gif/webp image; SVG/script/unrecognised skipped), content-
342
+ addresses each to <designRoot>/assets/<sha8>.<ext>, and prints a JSON manifest.
343
+ Oversized / unrecognised files are SKIPPED and listed under "skipped" — never
344
+ silently dropped. The source directory is read-only.
345
+
346
+ Exit: 0 ok (even with skips) · 2 usage · 6 write/containment error · 1 other.`;
347
+
348
+ async function main() {
349
+ let opts;
350
+ try {
351
+ opts = parseArgv(process.argv.slice(2));
352
+ } catch (err) {
353
+ process.stderr.write(`ingest-footage: ${err.message}\n`);
354
+ process.exit(err instanceof IngestError ? err.code : 2);
355
+ }
356
+ if (opts.help) {
357
+ process.stdout.write(`${HELP}\n`);
358
+ return;
359
+ }
360
+ if (!opts.dir) {
361
+ process.stderr.write('ingest-footage: <dir> required\n');
362
+ process.exit(2);
363
+ }
364
+ if (!Number.isFinite(opts.maxBytes) || opts.maxBytes <= 0) {
365
+ process.stderr.write('ingest-footage: --max-bytes must be a positive number\n');
366
+ process.exit(2);
367
+ }
368
+ const root = opts.root || process.env.CLAUDE_PROJECT_DIR || process.cwd();
369
+ try {
370
+ const r = await ingestFootage({
371
+ dir: opts.dir,
372
+ root,
373
+ designRootRel: opts.designRoot,
374
+ maxBytes: opts.maxBytes,
375
+ recursive: opts.recursive,
376
+ });
377
+ process.stdout.write(`${JSON.stringify(r, null, 2)}\n`);
378
+ } catch (err) {
379
+ process.stderr.write(`ingest-footage: ${err.message}\n`);
380
+ process.exit(err instanceof IngestError ? err.code : 1);
381
+ }
382
+ }
383
+
384
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
385
+ main();
386
+ }
@@ -0,0 +1,269 @@
1
+ // _probe-footage-playwright.mjs — keyframe extraction for the footage director
2
+ // (feature-footage-analysis-director, Task 4). The READ counterpart to
3
+ // _ingest-footage.mjs: given ONE content-addressed clip, decode it in headless
4
+ // Chromium and dump N evenly-spaced keyframe PNGs the `footage-analyst` agent
5
+ // then WATCHES (vision) to characterize the clip.
6
+ //
7
+ // WHY A BROWSER, NOT ffmpeg: DDR-148's posture is "no native renderer binaries".
8
+ // Chromium already decodes video (it's the capture spine's engine). We don't even
9
+ // need Remotion here — a plain HTML5 <video>, seeked frame-by-frame and
10
+ // screenshotted, is the whole mechanism. No dev server is required either: the
11
+ // page is navigated as file:// and the clip is a file:// subresource (same
12
+ // scheme; `--allow-file-access-from-files` permits the load), so probing works
13
+ // offline / pre-server, unlike the export capture (which drives the live Player).
14
+ //
15
+ // Determinism note (DDR-094 — freeze-frames lie): the frames are evenly spaced
16
+ // midpoints across the clip, so a downstream reader gets genuine motion samples,
17
+ // not one repeated frame.
18
+ //
19
+ // OUTPUT: keyframe PNGs are THROWAWAY SCRATCH → written under --out-dir (a /tmp
20
+ // scratch path, DDR-115: capture scratch never gets a new `_*` dir, never lands
21
+ // in the VERSIONED assets/). stdout = a JSON manifest
22
+ // { asset, durationSec, width, height, frames:[{index,t,png}] }
23
+ //
24
+ // Reached via `maude design probe-footage`, never a raw path.
25
+ // Exit: 0 ok · 2 usage · 3 no browser (via _pw-launch) · 4 decode/probe error · 1 other.
26
+
27
+ import { mkdirSync, writeFileSync } from 'node:fs';
28
+ import { isAbsolute, join, resolve, sep } from 'node:path';
29
+ import { pathToFileURL } from 'node:url';
30
+
31
+ import { launchChromium } from './_pw-launch.mjs';
32
+
33
+ const DEFAULT_FRAMES = 12;
34
+ // Cap the longest side of a keyframe PNG — vision only needs legible frames, and
35
+ // small PNGs keep the analyst's context lean. 960px is ample.
36
+ const MAX_FRAME_EDGE = 960;
37
+
38
+ function parseArgv(argv) {
39
+ const out = {
40
+ asset: null,
41
+ root: null,
42
+ designRoot: '.design',
43
+ frames: DEFAULT_FRAMES,
44
+ outDir: null,
45
+ };
46
+ for (let i = 0; i < argv.length; i += 1) {
47
+ const a = argv[i];
48
+ switch (a) {
49
+ case '--asset':
50
+ out.asset = argv[++i];
51
+ break;
52
+ case '--root':
53
+ out.root = argv[++i];
54
+ break;
55
+ case '--design-root':
56
+ out.designRoot = argv[++i];
57
+ break;
58
+ case '--frames':
59
+ out.frames = Number(argv[++i]);
60
+ break;
61
+ case '--out-dir':
62
+ out.outDir = argv[++i];
63
+ break;
64
+ case '--help':
65
+ case '-h':
66
+ out.help = true;
67
+ break;
68
+ default:
69
+ if (a.startsWith('-')) throw usage(`unknown flag ${a}`);
70
+ if (out.asset === null) out.asset = a;
71
+ else throw usage(`unexpected extra arg ${a}`);
72
+ }
73
+ }
74
+ return out;
75
+ }
76
+
77
+ function usage(msg) {
78
+ const e = new Error(msg);
79
+ e.code = 2;
80
+ return e;
81
+ }
82
+ function probeError(msg) {
83
+ const e = new Error(msg);
84
+ e.code = 4;
85
+ return e;
86
+ }
87
+
88
+ const HELP = `probe-footage — extract keyframe PNGs from one clip for vision analysis
89
+ (reached via \`maude design probe-footage\`)
90
+
91
+ Usage:
92
+ maude design probe-footage <assets/<sha8>.<ext>> --root <repo>
93
+ [--design-root .design] [--frames N] [--out-dir DIR]
94
+
95
+ Decodes the clip in headless Chromium, seeks to N evenly-spaced frames, writes a
96
+ PNG per frame to --out-dir (a /tmp scratch dir — NOT assets/), and prints a JSON
97
+ manifest { asset, durationSec, width, height, frames }.
98
+
99
+ Exit: 0 ok · 2 usage · 3 no browser · 4 decode/probe error · 1 other.`;
100
+
101
+ /** Resolve the clip to an absolute path inside <designRoot>/assets and assert containment. */
102
+ function resolveAsset(root, designRootRel, asset) {
103
+ const rel = String(asset).replace(/^\/+/, ''); // tolerate a leading slash
104
+ if (isAbsolute(rel) || rel.includes('..'))
105
+ throw usage(`asset must be a relative assets/ path: ${asset}`);
106
+ const assetsDir = resolve(root, designRootRel, 'assets');
107
+ // Accept both `assets/<name>` and a bare `<name>`.
108
+ const name = rel.startsWith('assets/') ? rel.slice('assets/'.length) : rel;
109
+ if (!/^[a-z0-9]{8}\.[a-z0-9]{2,4}$/.test(name))
110
+ throw usage(`asset name must be content-addressed <sha8>.<ext>: ${name}`);
111
+ const abs = resolve(assetsDir, name);
112
+ if (abs !== join(assetsDir, name) || !abs.startsWith(assetsDir + sep))
113
+ throw usage(`asset path escapes assets dir: ${abs}`);
114
+ return { abs, name, rel: `assets/${name}` };
115
+ }
116
+
117
+ async function probe({ asset, root, designRootRel, frames, outDir }) {
118
+ const { abs, name, rel } = resolveAsset(root, designRootRel, asset);
119
+ const nFrames = Number.isInteger(frames) && frames > 0 ? Math.min(frames, 64) : DEFAULT_FRAMES;
120
+ const dir =
121
+ outDir || join(process.env.TMPDIR || '/tmp', `maude-footage-${name.replace(/\./g, '_')}`);
122
+ mkdirSync(dir, { recursive: true });
123
+
124
+ const browser = await launchChromium({
125
+ headless: true,
126
+ // file:// page loading a file:// video subresource needs this flag.
127
+ args: ['--allow-file-access-from-files', '--autoplay-policy=no-user-gesture-required'],
128
+ });
129
+ try {
130
+ const page = await browser.newPage();
131
+ // A real file:// host page (NOT about:blank — its opaque origin can't load a
132
+ // file:// subresource even with --allow-file-access-from-files). With a
133
+ // file:// page + the flag, the clip loads as a same-scheme cross-dir resource.
134
+ const hostHtml = join(dir, '_probe.html');
135
+ writeFileSync(hostHtml, '<!doctype html><meta charset="utf-8"><body style="margin:0"></body>');
136
+ await page.goto(pathToFileURL(hostHtml).href);
137
+
138
+ // Load metadata: duration + intrinsic dimensions.
139
+ const fileUrl = pathToFileURL(abs).href;
140
+ const meta = await page.evaluate(async (url) => {
141
+ const v = document.createElement('video');
142
+ v.id = 'probe-v';
143
+ v.muted = true;
144
+ v.playsInline = true;
145
+ v.preload = 'auto';
146
+ v.style.position = 'fixed';
147
+ v.style.left = '0';
148
+ v.style.top = '0';
149
+ v.style.margin = '0';
150
+ document.body.style.margin = '0';
151
+ document.body.appendChild(v);
152
+ const done = new Promise((res, rej) => {
153
+ v.addEventListener('loadedmetadata', () => res(true), { once: true });
154
+ v.addEventListener('error', () => rej(new Error('video error while loading metadata')), {
155
+ once: true,
156
+ });
157
+ setTimeout(() => rej(new Error('timed out loading metadata (15s)')), 15000);
158
+ });
159
+ v.src = url;
160
+ await done;
161
+ return { duration: v.duration, width: v.videoWidth, height: v.videoHeight };
162
+ }, fileUrl);
163
+
164
+ if (!Number.isFinite(meta.duration) || meta.duration <= 0 || !meta.width || !meta.height) {
165
+ throw probeError(
166
+ `clip has no seekable video track (duration=${meta.duration}, ${meta.width}x${meta.height})`
167
+ );
168
+ }
169
+
170
+ // Size the <video> element to its intrinsic size, capped, and set the viewport
171
+ // to match so an element screenshot captures the whole frame at 1:1-ish.
172
+ const scale = Math.min(1, MAX_FRAME_EDGE / Math.max(meta.width, meta.height));
173
+ const drawW = Math.max(1, Math.round(meta.width * scale));
174
+ const drawH = Math.max(1, Math.round(meta.height * scale));
175
+ await page.setViewportSize({ width: drawW, height: drawH });
176
+ await page.evaluate(
177
+ ({ w, h }) => {
178
+ const v = document.getElementById('probe-v');
179
+ v.style.width = `${w}px`;
180
+ v.style.height = `${h}px`;
181
+ // Expose a promise-returning seek helper for the frame loop.
182
+ window.__seekTo = (t) =>
183
+ new Promise((res, rej) => {
184
+ const onSeeked = () => {
185
+ v.removeEventListener('seeked', onSeeked);
186
+ res(true);
187
+ };
188
+ v.addEventListener('seeked', onSeeked);
189
+ setTimeout(() => {
190
+ v.removeEventListener('seeked', onSeeked);
191
+ rej(new Error(`seek to ${t}s timed out`));
192
+ }, 8000);
193
+ v.currentTime = t;
194
+ });
195
+ },
196
+ { w: drawW, h: drawH }
197
+ );
198
+
199
+ const video = page.locator('#probe-v');
200
+ const outFrames = [];
201
+ for (let i = 0; i < nFrames; i += 1) {
202
+ // Even midpoints across (0, duration) — avoids the exact-0 / exact-end
203
+ // frames some codecs won't decode cleanly.
204
+ const t = (meta.duration * (i + 0.5)) / nFrames;
205
+ try {
206
+ await page.evaluate((tt) => window.__seekTo(tt), t);
207
+ } catch (e) {
208
+ // A single flaky seek shouldn't abort the whole probe — skip this frame.
209
+ outFrames.push({
210
+ index: i,
211
+ t: Number(t.toFixed(3)),
212
+ png: null,
213
+ error: String(e?.message ?? e),
214
+ });
215
+ continue;
216
+ }
217
+ const png = join(dir, `frame-${String(i).padStart(2, '0')}.png`);
218
+ await video.screenshot({ path: png });
219
+ outFrames.push({ index: i, t: Number(t.toFixed(3)), png });
220
+ }
221
+
222
+ return {
223
+ asset: rel,
224
+ durationSec: Number(meta.duration.toFixed(3)),
225
+ width: meta.width,
226
+ height: meta.height,
227
+ outDir: dir,
228
+ frames: outFrames,
229
+ };
230
+ } finally {
231
+ await browser.close();
232
+ }
233
+ }
234
+
235
+ async function main() {
236
+ let opts;
237
+ try {
238
+ opts = parseArgv(process.argv.slice(2));
239
+ } catch (err) {
240
+ process.stderr.write(`probe-footage: ${err.message}\n`);
241
+ process.exit(err.code ?? 2);
242
+ }
243
+ if (opts.help) {
244
+ process.stdout.write(`${HELP}\n`);
245
+ return;
246
+ }
247
+ if (!opts.asset) {
248
+ process.stderr.write('probe-footage: <assets/<sha8>.<ext>> required\n');
249
+ process.exit(2);
250
+ }
251
+ const root = opts.root || process.env.CLAUDE_PROJECT_DIR || process.cwd();
252
+ try {
253
+ const r = await probe({
254
+ asset: opts.asset,
255
+ root,
256
+ designRootRel: opts.designRoot,
257
+ frames: opts.frames,
258
+ outDir: opts.outDir,
259
+ });
260
+ process.stdout.write(`${JSON.stringify(r, null, 2)}\n`);
261
+ } catch (err) {
262
+ process.stderr.write(`probe-footage: ${err.message}\n`);
263
+ process.exit(err.code ?? 1);
264
+ }
265
+ }
266
+
267
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
268
+ main();
269
+ }