@vibeframe/cli 0.27.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 (420) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/.turbo/turbo-lint.log +21 -0
  3. package/.turbo/turbo-test.log +689 -0
  4. package/dist/agent/adapters/claude.d.ts +15 -0
  5. package/dist/agent/adapters/claude.d.ts.map +1 -0
  6. package/dist/agent/adapters/claude.js +119 -0
  7. package/dist/agent/adapters/claude.js.map +1 -0
  8. package/dist/agent/adapters/gemini.d.ts +15 -0
  9. package/dist/agent/adapters/gemini.d.ts.map +1 -0
  10. package/dist/agent/adapters/gemini.js +132 -0
  11. package/dist/agent/adapters/gemini.js.map +1 -0
  12. package/dist/agent/adapters/index.d.ts +27 -0
  13. package/dist/agent/adapters/index.d.ts.map +1 -0
  14. package/dist/agent/adapters/index.js +38 -0
  15. package/dist/agent/adapters/index.js.map +1 -0
  16. package/dist/agent/adapters/ollama.d.ts +20 -0
  17. package/dist/agent/adapters/ollama.d.ts.map +1 -0
  18. package/dist/agent/adapters/ollama.js +186 -0
  19. package/dist/agent/adapters/ollama.js.map +1 -0
  20. package/dist/agent/adapters/openai.d.ts +15 -0
  21. package/dist/agent/adapters/openai.d.ts.map +1 -0
  22. package/dist/agent/adapters/openai.js +92 -0
  23. package/dist/agent/adapters/openai.js.map +1 -0
  24. package/dist/agent/adapters/xai.d.ts +15 -0
  25. package/dist/agent/adapters/xai.d.ts.map +1 -0
  26. package/dist/agent/adapters/xai.js +95 -0
  27. package/dist/agent/adapters/xai.js.map +1 -0
  28. package/dist/agent/index.d.ts +69 -0
  29. package/dist/agent/index.d.ts.map +1 -0
  30. package/dist/agent/index.js +180 -0
  31. package/dist/agent/index.js.map +1 -0
  32. package/dist/agent/memory/index.d.ts +70 -0
  33. package/dist/agent/memory/index.d.ts.map +1 -0
  34. package/dist/agent/memory/index.js +132 -0
  35. package/dist/agent/memory/index.js.map +1 -0
  36. package/dist/agent/prompts/system.d.ts +6 -0
  37. package/dist/agent/prompts/system.d.ts.map +1 -0
  38. package/dist/agent/prompts/system.js +103 -0
  39. package/dist/agent/prompts/system.js.map +1 -0
  40. package/dist/agent/tools/ai-editing.d.ts +15 -0
  41. package/dist/agent/tools/ai-editing.d.ts.map +1 -0
  42. package/dist/agent/tools/ai-editing.js +763 -0
  43. package/dist/agent/tools/ai-editing.js.map +1 -0
  44. package/dist/agent/tools/ai-generation.d.ts +13 -0
  45. package/dist/agent/tools/ai-generation.d.ts.map +1 -0
  46. package/dist/agent/tools/ai-generation.js +973 -0
  47. package/dist/agent/tools/ai-generation.js.map +1 -0
  48. package/dist/agent/tools/ai-pipeline.d.ts +14 -0
  49. package/dist/agent/tools/ai-pipeline.d.ts.map +1 -0
  50. package/dist/agent/tools/ai-pipeline.js +961 -0
  51. package/dist/agent/tools/ai-pipeline.js.map +1 -0
  52. package/dist/agent/tools/ai.d.ts +13 -0
  53. package/dist/agent/tools/ai.d.ts.map +1 -0
  54. package/dist/agent/tools/ai.js +19 -0
  55. package/dist/agent/tools/ai.js.map +1 -0
  56. package/dist/agent/tools/batch.d.ts +6 -0
  57. package/dist/agent/tools/batch.d.ts.map +1 -0
  58. package/dist/agent/tools/batch.js +383 -0
  59. package/dist/agent/tools/batch.js.map +1 -0
  60. package/dist/agent/tools/e2e.test.d.ts +26 -0
  61. package/dist/agent/tools/e2e.test.d.ts.map +1 -0
  62. package/dist/agent/tools/e2e.test.js +397 -0
  63. package/dist/agent/tools/e2e.test.js.map +1 -0
  64. package/dist/agent/tools/export.d.ts +6 -0
  65. package/dist/agent/tools/export.d.ts.map +1 -0
  66. package/dist/agent/tools/export.js +171 -0
  67. package/dist/agent/tools/export.js.map +1 -0
  68. package/dist/agent/tools/filesystem.d.ts +6 -0
  69. package/dist/agent/tools/filesystem.d.ts.map +1 -0
  70. package/dist/agent/tools/filesystem.js +212 -0
  71. package/dist/agent/tools/filesystem.js.map +1 -0
  72. package/dist/agent/tools/index.d.ts +65 -0
  73. package/dist/agent/tools/index.d.ts.map +1 -0
  74. package/dist/agent/tools/index.js +120 -0
  75. package/dist/agent/tools/index.js.map +1 -0
  76. package/dist/agent/tools/integration.test.d.ts +11 -0
  77. package/dist/agent/tools/integration.test.d.ts.map +1 -0
  78. package/dist/agent/tools/integration.test.js +659 -0
  79. package/dist/agent/tools/integration.test.js.map +1 -0
  80. package/dist/agent/tools/media.d.ts +6 -0
  81. package/dist/agent/tools/media.d.ts.map +1 -0
  82. package/dist/agent/tools/media.js +616 -0
  83. package/dist/agent/tools/media.js.map +1 -0
  84. package/dist/agent/tools/project.d.ts +6 -0
  85. package/dist/agent/tools/project.d.ts.map +1 -0
  86. package/dist/agent/tools/project.js +284 -0
  87. package/dist/agent/tools/project.js.map +1 -0
  88. package/dist/agent/tools/timeline.d.ts +6 -0
  89. package/dist/agent/tools/timeline.d.ts.map +1 -0
  90. package/dist/agent/tools/timeline.js +873 -0
  91. package/dist/agent/tools/timeline.js.map +1 -0
  92. package/dist/agent/types.d.ts +59 -0
  93. package/dist/agent/types.d.ts.map +1 -0
  94. package/dist/agent/types.js +5 -0
  95. package/dist/agent/types.js.map +1 -0
  96. package/dist/commands/agent.d.ts +21 -0
  97. package/dist/commands/agent.d.ts.map +1 -0
  98. package/dist/commands/agent.js +290 -0
  99. package/dist/commands/agent.js.map +1 -0
  100. package/dist/commands/ai-analyze.d.ts +106 -0
  101. package/dist/commands/ai-analyze.d.ts.map +1 -0
  102. package/dist/commands/ai-analyze.js +327 -0
  103. package/dist/commands/ai-analyze.js.map +1 -0
  104. package/dist/commands/ai-animated-caption.d.ts +64 -0
  105. package/dist/commands/ai-animated-caption.d.ts.map +1 -0
  106. package/dist/commands/ai-animated-caption.js +272 -0
  107. package/dist/commands/ai-animated-caption.js.map +1 -0
  108. package/dist/commands/ai-audio.d.ts +20 -0
  109. package/dist/commands/ai-audio.d.ts.map +1 -0
  110. package/dist/commands/ai-audio.js +808 -0
  111. package/dist/commands/ai-audio.js.map +1 -0
  112. package/dist/commands/ai-broll.d.ts +15 -0
  113. package/dist/commands/ai-broll.d.ts.map +1 -0
  114. package/dist/commands/ai-broll.js +406 -0
  115. package/dist/commands/ai-broll.js.map +1 -0
  116. package/dist/commands/ai-edit-cli.d.ts +14 -0
  117. package/dist/commands/ai-edit-cli.d.ts.map +1 -0
  118. package/dist/commands/ai-edit-cli.js +579 -0
  119. package/dist/commands/ai-edit-cli.js.map +1 -0
  120. package/dist/commands/ai-edit.d.ts +398 -0
  121. package/dist/commands/ai-edit.d.ts.map +1 -0
  122. package/dist/commands/ai-edit.js +1019 -0
  123. package/dist/commands/ai-edit.js.map +1 -0
  124. package/dist/commands/ai-fill-gaps.d.ts +14 -0
  125. package/dist/commands/ai-fill-gaps.d.ts.map +1 -0
  126. package/dist/commands/ai-fill-gaps.js +451 -0
  127. package/dist/commands/ai-fill-gaps.js.map +1 -0
  128. package/dist/commands/ai-helpers.d.ts +20 -0
  129. package/dist/commands/ai-helpers.d.ts.map +1 -0
  130. package/dist/commands/ai-helpers.js +59 -0
  131. package/dist/commands/ai-helpers.js.map +1 -0
  132. package/dist/commands/ai-highlights.d.ts +127 -0
  133. package/dist/commands/ai-highlights.d.ts.map +1 -0
  134. package/dist/commands/ai-highlights.js +1026 -0
  135. package/dist/commands/ai-highlights.js.map +1 -0
  136. package/dist/commands/ai-image.d.ts +34 -0
  137. package/dist/commands/ai-image.d.ts.map +1 -0
  138. package/dist/commands/ai-image.js +653 -0
  139. package/dist/commands/ai-image.js.map +1 -0
  140. package/dist/commands/ai-motion.d.ts +50 -0
  141. package/dist/commands/ai-motion.d.ts.map +1 -0
  142. package/dist/commands/ai-motion.js +271 -0
  143. package/dist/commands/ai-motion.js.map +1 -0
  144. package/dist/commands/ai-narrate.d.ts +66 -0
  145. package/dist/commands/ai-narrate.d.ts.map +1 -0
  146. package/dist/commands/ai-narrate.js +329 -0
  147. package/dist/commands/ai-narrate.js.map +1 -0
  148. package/dist/commands/ai-review.d.ts +57 -0
  149. package/dist/commands/ai-review.d.ts.map +1 -0
  150. package/dist/commands/ai-review.js +251 -0
  151. package/dist/commands/ai-review.js.map +1 -0
  152. package/dist/commands/ai-script-pipeline-cli.d.ts +9 -0
  153. package/dist/commands/ai-script-pipeline-cli.d.ts.map +1 -0
  154. package/dist/commands/ai-script-pipeline-cli.js +1494 -0
  155. package/dist/commands/ai-script-pipeline-cli.js.map +1 -0
  156. package/dist/commands/ai-script-pipeline.d.ts +259 -0
  157. package/dist/commands/ai-script-pipeline.d.ts.map +1 -0
  158. package/dist/commands/ai-script-pipeline.js +1027 -0
  159. package/dist/commands/ai-script-pipeline.js.map +1 -0
  160. package/dist/commands/ai-suggest-edit.d.ts +14 -0
  161. package/dist/commands/ai-suggest-edit.d.ts.map +1 -0
  162. package/dist/commands/ai-suggest-edit.js +220 -0
  163. package/dist/commands/ai-suggest-edit.js.map +1 -0
  164. package/dist/commands/ai-video-fx.d.ts +14 -0
  165. package/dist/commands/ai-video-fx.d.ts.map +1 -0
  166. package/dist/commands/ai-video-fx.js +395 -0
  167. package/dist/commands/ai-video-fx.js.map +1 -0
  168. package/dist/commands/ai-video.d.ts +15 -0
  169. package/dist/commands/ai-video.d.ts.map +1 -0
  170. package/dist/commands/ai-video.js +785 -0
  171. package/dist/commands/ai-video.js.map +1 -0
  172. package/dist/commands/ai-viral.d.ts +15 -0
  173. package/dist/commands/ai-viral.d.ts.map +1 -0
  174. package/dist/commands/ai-viral.js +519 -0
  175. package/dist/commands/ai-viral.js.map +1 -0
  176. package/dist/commands/ai-visual-fx.d.ts +14 -0
  177. package/dist/commands/ai-visual-fx.d.ts.map +1 -0
  178. package/dist/commands/ai-visual-fx.js +505 -0
  179. package/dist/commands/ai-visual-fx.js.map +1 -0
  180. package/dist/commands/ai.d.ts +38 -0
  181. package/dist/commands/ai.d.ts.map +1 -0
  182. package/dist/commands/ai.js +225 -0
  183. package/dist/commands/ai.js.map +1 -0
  184. package/dist/commands/ai.test.d.ts +2 -0
  185. package/dist/commands/ai.test.d.ts.map +1 -0
  186. package/dist/commands/ai.test.js +554 -0
  187. package/dist/commands/ai.test.js.map +1 -0
  188. package/dist/commands/analyze.d.ts +16 -0
  189. package/dist/commands/analyze.d.ts.map +1 -0
  190. package/dist/commands/analyze.js +247 -0
  191. package/dist/commands/analyze.js.map +1 -0
  192. package/dist/commands/audio.d.ts +18 -0
  193. package/dist/commands/audio.d.ts.map +1 -0
  194. package/dist/commands/audio.js +539 -0
  195. package/dist/commands/audio.js.map +1 -0
  196. package/dist/commands/batch.d.ts +3 -0
  197. package/dist/commands/batch.d.ts.map +1 -0
  198. package/dist/commands/batch.js +366 -0
  199. package/dist/commands/batch.js.map +1 -0
  200. package/dist/commands/batch.test.d.ts +2 -0
  201. package/dist/commands/batch.test.d.ts.map +1 -0
  202. package/dist/commands/batch.test.js +203 -0
  203. package/dist/commands/batch.test.js.map +1 -0
  204. package/dist/commands/detect.d.ts +3 -0
  205. package/dist/commands/detect.d.ts.map +1 -0
  206. package/dist/commands/detect.js +273 -0
  207. package/dist/commands/detect.js.map +1 -0
  208. package/dist/commands/doctor.d.ts +6 -0
  209. package/dist/commands/doctor.d.ts.map +1 -0
  210. package/dist/commands/doctor.js +191 -0
  211. package/dist/commands/doctor.js.map +1 -0
  212. package/dist/commands/edit-cmd.d.ts +26 -0
  213. package/dist/commands/edit-cmd.d.ts.map +1 -0
  214. package/dist/commands/edit-cmd.js +870 -0
  215. package/dist/commands/edit-cmd.js.map +1 -0
  216. package/dist/commands/export.d.ts +39 -0
  217. package/dist/commands/export.d.ts.map +1 -0
  218. package/dist/commands/export.js +730 -0
  219. package/dist/commands/export.js.map +1 -0
  220. package/dist/commands/generate.d.ts +25 -0
  221. package/dist/commands/generate.d.ts.map +1 -0
  222. package/dist/commands/generate.js +1885 -0
  223. package/dist/commands/generate.js.map +1 -0
  224. package/dist/commands/media.d.ts +3 -0
  225. package/dist/commands/media.d.ts.map +1 -0
  226. package/dist/commands/media.js +165 -0
  227. package/dist/commands/media.js.map +1 -0
  228. package/dist/commands/output.d.ts +45 -0
  229. package/dist/commands/output.d.ts.map +1 -0
  230. package/dist/commands/output.js +122 -0
  231. package/dist/commands/output.js.map +1 -0
  232. package/dist/commands/pipeline.d.ts +19 -0
  233. package/dist/commands/pipeline.d.ts.map +1 -0
  234. package/dist/commands/pipeline.js +345 -0
  235. package/dist/commands/pipeline.js.map +1 -0
  236. package/dist/commands/project.d.ts +3 -0
  237. package/dist/commands/project.d.ts.map +1 -0
  238. package/dist/commands/project.js +139 -0
  239. package/dist/commands/project.js.map +1 -0
  240. package/dist/commands/project.test.d.ts +2 -0
  241. package/dist/commands/project.test.d.ts.map +1 -0
  242. package/dist/commands/project.test.js +105 -0
  243. package/dist/commands/project.test.js.map +1 -0
  244. package/dist/commands/sanitize.d.ts +21 -0
  245. package/dist/commands/sanitize.d.ts.map +1 -0
  246. package/dist/commands/sanitize.js +56 -0
  247. package/dist/commands/sanitize.js.map +1 -0
  248. package/dist/commands/schema.d.ts +11 -0
  249. package/dist/commands/schema.d.ts.map +1 -0
  250. package/dist/commands/schema.js +101 -0
  251. package/dist/commands/schema.js.map +1 -0
  252. package/dist/commands/setup.d.ts +6 -0
  253. package/dist/commands/setup.d.ts.map +1 -0
  254. package/dist/commands/setup.js +440 -0
  255. package/dist/commands/setup.js.map +1 -0
  256. package/dist/commands/timeline.d.ts +3 -0
  257. package/dist/commands/timeline.d.ts.map +1 -0
  258. package/dist/commands/timeline.js +469 -0
  259. package/dist/commands/timeline.js.map +1 -0
  260. package/dist/commands/timeline.test.d.ts +2 -0
  261. package/dist/commands/timeline.test.d.ts.map +1 -0
  262. package/dist/commands/timeline.test.js +320 -0
  263. package/dist/commands/timeline.test.js.map +1 -0
  264. package/dist/commands/validate.d.ts +32 -0
  265. package/dist/commands/validate.d.ts.map +1 -0
  266. package/dist/commands/validate.js +63 -0
  267. package/dist/commands/validate.js.map +1 -0
  268. package/dist/config/config.test.d.ts +2 -0
  269. package/dist/config/config.test.d.ts.map +1 -0
  270. package/dist/config/config.test.js +164 -0
  271. package/dist/config/config.test.js.map +1 -0
  272. package/dist/config/index.d.ts +35 -0
  273. package/dist/config/index.d.ts.map +1 -0
  274. package/dist/config/index.js +101 -0
  275. package/dist/config/index.js.map +1 -0
  276. package/dist/config/schema.d.ts +43 -0
  277. package/dist/config/schema.d.ts.map +1 -0
  278. package/dist/config/schema.js +42 -0
  279. package/dist/config/schema.js.map +1 -0
  280. package/dist/engine/index.d.ts +3 -0
  281. package/dist/engine/index.d.ts.map +1 -0
  282. package/dist/engine/index.js +2 -0
  283. package/dist/engine/index.js.map +1 -0
  284. package/dist/engine/project.d.ts +84 -0
  285. package/dist/engine/project.d.ts.map +1 -0
  286. package/dist/engine/project.js +355 -0
  287. package/dist/engine/project.js.map +1 -0
  288. package/dist/engine/project.test.d.ts +2 -0
  289. package/dist/engine/project.test.d.ts.map +1 -0
  290. package/dist/engine/project.test.js +599 -0
  291. package/dist/engine/project.test.js.map +1 -0
  292. package/dist/index.d.ts +7 -0
  293. package/dist/index.d.ts.map +1 -0
  294. package/dist/index.js +131 -0
  295. package/dist/index.js.map +1 -0
  296. package/dist/utils/api-key.d.ts +36 -0
  297. package/dist/utils/api-key.d.ts.map +1 -0
  298. package/dist/utils/api-key.js +211 -0
  299. package/dist/utils/api-key.js.map +1 -0
  300. package/dist/utils/api-key.test.d.ts +2 -0
  301. package/dist/utils/api-key.test.d.ts.map +1 -0
  302. package/dist/utils/api-key.test.js +35 -0
  303. package/dist/utils/api-key.test.js.map +1 -0
  304. package/dist/utils/audio.d.ts +23 -0
  305. package/dist/utils/audio.d.ts.map +1 -0
  306. package/dist/utils/audio.js +79 -0
  307. package/dist/utils/audio.js.map +1 -0
  308. package/dist/utils/exec-safe.d.ts +22 -0
  309. package/dist/utils/exec-safe.d.ts.map +1 -0
  310. package/dist/utils/exec-safe.js +62 -0
  311. package/dist/utils/exec-safe.js.map +1 -0
  312. package/dist/utils/first-run.d.ts +13 -0
  313. package/dist/utils/first-run.d.ts.map +1 -0
  314. package/dist/utils/first-run.js +48 -0
  315. package/dist/utils/first-run.js.map +1 -0
  316. package/dist/utils/provider-resolver.d.ts +15 -0
  317. package/dist/utils/provider-resolver.d.ts.map +1 -0
  318. package/dist/utils/provider-resolver.js +42 -0
  319. package/dist/utils/provider-resolver.js.map +1 -0
  320. package/dist/utils/remotion.d.ts +210 -0
  321. package/dist/utils/remotion.d.ts.map +1 -0
  322. package/dist/utils/remotion.js +731 -0
  323. package/dist/utils/remotion.js.map +1 -0
  324. package/dist/utils/subtitle.d.ts +65 -0
  325. package/dist/utils/subtitle.d.ts.map +1 -0
  326. package/dist/utils/subtitle.js +135 -0
  327. package/dist/utils/subtitle.js.map +1 -0
  328. package/dist/utils/subtitle.test.d.ts +2 -0
  329. package/dist/utils/subtitle.test.d.ts.map +1 -0
  330. package/dist/utils/subtitle.test.js +175 -0
  331. package/dist/utils/subtitle.test.js.map +1 -0
  332. package/dist/utils/tty.d.ts +45 -0
  333. package/dist/utils/tty.d.ts.map +1 -0
  334. package/dist/utils/tty.js +172 -0
  335. package/dist/utils/tty.js.map +1 -0
  336. package/package.json +102 -0
  337. package/src/agent/adapters/claude.ts +143 -0
  338. package/src/agent/adapters/gemini.ts +159 -0
  339. package/src/agent/adapters/index.ts +61 -0
  340. package/src/agent/adapters/ollama.ts +231 -0
  341. package/src/agent/adapters/openai.ts +116 -0
  342. package/src/agent/adapters/xai.ts +119 -0
  343. package/src/agent/index.ts +251 -0
  344. package/src/agent/memory/index.ts +151 -0
  345. package/src/agent/prompts/system.ts +106 -0
  346. package/src/agent/tools/ai-editing.ts +845 -0
  347. package/src/agent/tools/ai-generation.ts +1073 -0
  348. package/src/agent/tools/ai-pipeline.ts +1055 -0
  349. package/src/agent/tools/ai.ts +21 -0
  350. package/src/agent/tools/batch.ts +429 -0
  351. package/src/agent/tools/e2e.test.ts +545 -0
  352. package/src/agent/tools/export.ts +184 -0
  353. package/src/agent/tools/filesystem.ts +237 -0
  354. package/src/agent/tools/index.ts +150 -0
  355. package/src/agent/tools/integration.test.ts +775 -0
  356. package/src/agent/tools/media.ts +697 -0
  357. package/src/agent/tools/project.ts +313 -0
  358. package/src/agent/tools/timeline.ts +951 -0
  359. package/src/agent/types.ts +68 -0
  360. package/src/commands/agent.ts +340 -0
  361. package/src/commands/ai-analyze.ts +429 -0
  362. package/src/commands/ai-animated-caption.ts +390 -0
  363. package/src/commands/ai-audio.ts +941 -0
  364. package/src/commands/ai-broll.ts +490 -0
  365. package/src/commands/ai-edit-cli.ts +658 -0
  366. package/src/commands/ai-edit.ts +1542 -0
  367. package/src/commands/ai-fill-gaps.ts +566 -0
  368. package/src/commands/ai-helpers.ts +65 -0
  369. package/src/commands/ai-highlights.ts +1303 -0
  370. package/src/commands/ai-image.ts +761 -0
  371. package/src/commands/ai-motion.ts +347 -0
  372. package/src/commands/ai-narrate.ts +451 -0
  373. package/src/commands/ai-review.ts +309 -0
  374. package/src/commands/ai-script-pipeline-cli.ts +1710 -0
  375. package/src/commands/ai-script-pipeline.ts +1365 -0
  376. package/src/commands/ai-suggest-edit.ts +264 -0
  377. package/src/commands/ai-video-fx.ts +445 -0
  378. package/src/commands/ai-video.ts +915 -0
  379. package/src/commands/ai-viral.ts +595 -0
  380. package/src/commands/ai-visual-fx.ts +601 -0
  381. package/src/commands/ai.test.ts +627 -0
  382. package/src/commands/ai.ts +307 -0
  383. package/src/commands/analyze.ts +282 -0
  384. package/src/commands/audio.ts +644 -0
  385. package/src/commands/batch.test.ts +279 -0
  386. package/src/commands/batch.ts +440 -0
  387. package/src/commands/detect.ts +329 -0
  388. package/src/commands/doctor.ts +237 -0
  389. package/src/commands/edit-cmd.ts +1014 -0
  390. package/src/commands/export.ts +918 -0
  391. package/src/commands/generate.ts +2146 -0
  392. package/src/commands/media.ts +177 -0
  393. package/src/commands/output.ts +142 -0
  394. package/src/commands/pipeline.ts +398 -0
  395. package/src/commands/project.test.ts +127 -0
  396. package/src/commands/project.ts +149 -0
  397. package/src/commands/sanitize.ts +60 -0
  398. package/src/commands/schema.ts +130 -0
  399. package/src/commands/setup.ts +509 -0
  400. package/src/commands/timeline.test.ts +499 -0
  401. package/src/commands/timeline.ts +529 -0
  402. package/src/commands/validate.ts +77 -0
  403. package/src/config/config.test.ts +197 -0
  404. package/src/config/index.ts +125 -0
  405. package/src/config/schema.ts +82 -0
  406. package/src/engine/index.ts +2 -0
  407. package/src/engine/project.test.ts +702 -0
  408. package/src/engine/project.ts +439 -0
  409. package/src/index.ts +146 -0
  410. package/src/utils/api-key.test.ts +41 -0
  411. package/src/utils/api-key.ts +247 -0
  412. package/src/utils/audio.ts +83 -0
  413. package/src/utils/exec-safe.ts +75 -0
  414. package/src/utils/first-run.ts +52 -0
  415. package/src/utils/provider-resolver.ts +56 -0
  416. package/src/utils/remotion.ts +951 -0
  417. package/src/utils/subtitle.test.ts +227 -0
  418. package/src/utils/subtitle.ts +169 -0
  419. package/src/utils/tty.ts +196 -0
  420. package/tsconfig.json +20 -0
@@ -0,0 +1,918 @@
1
+ import { Command } from "commander";
2
+ import { readFile, access, stat } from "node:fs/promises";
3
+ import { resolve, basename } from "node:path";
4
+ import { spawn } from "node:child_process";
5
+ import chalk from "chalk";
6
+ import ora from "ora";
7
+ import { Project, type ProjectFile } from "../engine/index.js";
8
+ import { execSafe, ffprobeDuration } from "../utils/exec-safe.js";
9
+
10
+ /**
11
+ * Resolve project file path - handles both file paths and directory paths
12
+ * If path is a directory, looks for project.vibe.json inside
13
+ */
14
+ async function resolveProjectPath(inputPath: string): Promise<string> {
15
+ const filePath = resolve(process.cwd(), inputPath);
16
+
17
+ try {
18
+ const stats = await stat(filePath);
19
+ if (stats.isDirectory()) {
20
+ return resolve(filePath, "project.vibe.json");
21
+ }
22
+ } catch {
23
+ // Path doesn't exist or other error - let readFile handle it
24
+ }
25
+
26
+ return filePath;
27
+ }
28
+
29
+ /**
30
+ * Get the duration of a media file using ffprobe
31
+ * For images, returns a default duration since they have no inherent time
32
+ */
33
+ export async function getMediaDuration(
34
+ filePath: string,
35
+ mediaType: "video" | "audio" | "image",
36
+ defaultImageDuration: number = 5
37
+ ): Promise<number> {
38
+ if (mediaType === "image") {
39
+ return defaultImageDuration;
40
+ }
41
+
42
+ try {
43
+ return await ffprobeDuration(filePath);
44
+ } catch {
45
+ return defaultImageDuration;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Check if a media file has an audio stream
51
+ */
52
+ export async function checkHasAudio(filePath: string): Promise<boolean> {
53
+ try {
54
+ const { stdout } = await execSafe("ffprobe", [
55
+ "-v", "error", "-select_streams", "a", "-show_entries", "stream=codec_type", "-of", "default=noprint_wrappers=1:nokey=1", filePath,
56
+ ]);
57
+ return stdout.trim().length > 0;
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Export result for programmatic usage
65
+ */
66
+ export interface ExportResult {
67
+ success: boolean;
68
+ message: string;
69
+ outputPath?: string;
70
+ }
71
+
72
+ /**
73
+ * Gap filling strategy for timeline gaps
74
+ * - "black": Fill gaps with black frames (fallback)
75
+ * - "extend": Extend adjacent clips using source media if available
76
+ */
77
+ export type GapFillStrategy = "black" | "extend";
78
+
79
+ /**
80
+ * Export options
81
+ */
82
+ export interface ExportOptions {
83
+ preset?: "draft" | "standard" | "high" | "ultra";
84
+ format?: "mp4" | "webm" | "mov";
85
+ overwrite?: boolean;
86
+ gapFill?: GapFillStrategy;
87
+ }
88
+
89
+ /**
90
+ * Reusable export function for programmatic usage
91
+ */
92
+ export async function runExport(
93
+ projectPath: string,
94
+ outputPath: string,
95
+ options: ExportOptions = {}
96
+ ): Promise<ExportResult> {
97
+ const { preset = "standard", format = "mp4", overwrite = false, gapFill = "extend" } = options;
98
+
99
+ try {
100
+ // Check if FFmpeg is installed
101
+ const ffmpegPath = await findFFmpeg();
102
+ if (!ffmpegPath) {
103
+ return {
104
+ success: false,
105
+ message: "FFmpeg not found. Install with: brew install ffmpeg (macOS) or apt install ffmpeg (Linux)",
106
+ };
107
+ }
108
+
109
+ // Load project
110
+ const filePath = await resolveProjectPath(projectPath);
111
+ const content = await readFile(filePath, "utf-8");
112
+ const data: ProjectFile = JSON.parse(content);
113
+ const project = Project.fromJSON(data);
114
+
115
+ const summary = project.getSummary();
116
+
117
+ if (summary.clipCount === 0) {
118
+ return {
119
+ success: false,
120
+ message: "Project has no clips to export",
121
+ };
122
+ }
123
+
124
+ // Determine output path
125
+ const finalOutputPath = resolve(process.cwd(), outputPath);
126
+
127
+ // Get preset settings
128
+ const presetSettings = getPresetSettings(preset, summary.aspectRatio);
129
+
130
+ // Get clips sorted by start time
131
+ const clips = project.getClips().sort((a, b) => a.startTime - b.startTime);
132
+ const sources = project.getSources();
133
+
134
+ // Verify source files exist and check for audio streams
135
+ const sourceAudioMap = new Map<string, boolean>();
136
+ for (const clip of clips) {
137
+ const source = sources.find((s) => s.id === clip.sourceId);
138
+ if (source) {
139
+ try {
140
+ await access(source.url);
141
+ // Check if video source has audio
142
+ if (source.type === "video" && !sourceAudioMap.has(source.id)) {
143
+ sourceAudioMap.set(source.id, await checkHasAudio(source.url));
144
+ }
145
+ } catch {
146
+ return {
147
+ success: false,
148
+ message: `Source file not found: ${source.url}`,
149
+ };
150
+ }
151
+ }
152
+ }
153
+
154
+ // Build FFmpeg command
155
+ const ffmpegArgs = buildFFmpegArgs(clips, sources, presetSettings, finalOutputPath, { overwrite, format, gapFill }, sourceAudioMap);
156
+
157
+ // Run FFmpeg
158
+ await runFFmpegProcess(ffmpegPath, ffmpegArgs, () => {});
159
+
160
+ return {
161
+ success: true,
162
+ message: `Exported: ${outputPath}`,
163
+ outputPath: finalOutputPath,
164
+ };
165
+ } catch (error) {
166
+ const errorMessage = error instanceof Error ? error.message : String(error);
167
+ return {
168
+ success: false,
169
+ message: `Export failed: ${errorMessage}`,
170
+ };
171
+ }
172
+ }
173
+
174
+ export const exportCommand = new Command("export")
175
+ .description("Export project to video file")
176
+ .argument("<project>", "Project file path")
177
+ .option("-o, --output <path>", "Output file path")
178
+ .option("-f, --format <format>", "Output format (mp4, webm, mov)", "mp4")
179
+ .option(
180
+ "-p, --preset <preset>",
181
+ "Quality preset (draft, standard, high, ultra)",
182
+ "standard"
183
+ )
184
+ .option("-y, --overwrite", "Overwrite output file if exists", false)
185
+ .option("-g, --gap-fill <strategy>", "Gap filling strategy (black, extend)", "extend")
186
+ .action(async (projectPath: string, options) => {
187
+ const spinner = ora("Checking FFmpeg...").start();
188
+
189
+ try {
190
+ // Check if FFmpeg is installed
191
+ const ffmpegPath = await findFFmpeg();
192
+ if (!ffmpegPath) {
193
+ spinner.fail(chalk.red("FFmpeg not found"));
194
+ console.error();
195
+ console.error(chalk.yellow("Please install FFmpeg:"));
196
+ console.error(chalk.dim(" macOS: brew install ffmpeg"));
197
+ console.error(chalk.dim(" Ubuntu: sudo apt install ffmpeg"));
198
+ console.error(chalk.dim(" Windows: winget install ffmpeg"));
199
+ process.exit(1);
200
+ }
201
+
202
+ // Load project
203
+ spinner.text = "Loading project...";
204
+ const filePath = await resolveProjectPath(projectPath);
205
+ const content = await readFile(filePath, "utf-8");
206
+ const data: ProjectFile = JSON.parse(content);
207
+ const project = Project.fromJSON(data);
208
+
209
+ const summary = project.getSummary();
210
+
211
+ if (summary.clipCount === 0) {
212
+ spinner.fail(chalk.red("Project has no clips to export"));
213
+ process.exit(1);
214
+ }
215
+
216
+ // Determine output path
217
+ const outputPath = options.output
218
+ ? resolve(process.cwd(), options.output)
219
+ : resolve(
220
+ process.cwd(),
221
+ `${basename(projectPath, ".vibe.json")}.${options.format}`
222
+ );
223
+
224
+ // Get preset settings
225
+ const presetSettings = getPresetSettings(options.preset, summary.aspectRatio);
226
+
227
+ // Get clips sorted by start time
228
+ const clips = project.getClips().sort((a, b) => a.startTime - b.startTime);
229
+ const sources = project.getSources();
230
+
231
+ // Verify source files exist and check for audio streams
232
+ spinner.text = "Verifying source files...";
233
+ const sourceAudioMap = new Map<string, boolean>();
234
+ for (const clip of clips) {
235
+ const source = sources.find((s) => s.id === clip.sourceId);
236
+ if (source) {
237
+ try {
238
+ await access(source.url);
239
+ // Check if video source has audio
240
+ if (source.type === "video" && !sourceAudioMap.has(source.id)) {
241
+ sourceAudioMap.set(source.id, await checkHasAudio(source.url));
242
+ }
243
+ } catch {
244
+ spinner.fail(chalk.red(`Source file not found: ${source.url}`));
245
+ process.exit(1);
246
+ }
247
+ }
248
+ }
249
+
250
+ // Build FFmpeg command
251
+ spinner.text = "Building export command...";
252
+ const gapFillStrategy = (options.gapFill === "black" ? "black" : "extend") as GapFillStrategy;
253
+ const ffmpegArgs = buildFFmpegArgs(clips, sources, presetSettings, outputPath, { ...options, gapFill: gapFillStrategy }, sourceAudioMap);
254
+
255
+ if (process.env.DEBUG) {
256
+ console.log("\nFFmpeg command:");
257
+ console.log("ffmpeg", ffmpegArgs.join(" "));
258
+ console.log();
259
+ }
260
+
261
+ // Run FFmpeg
262
+ spinner.text = "Encoding...";
263
+
264
+ await runFFmpegProcess(ffmpegPath, ffmpegArgs, (progress) => {
265
+ spinner.text = `Encoding... ${progress}%`;
266
+ });
267
+
268
+ spinner.succeed(chalk.green(`Exported: ${outputPath}`));
269
+
270
+ console.log();
271
+ console.log(chalk.dim(" Duration:"), `${summary.duration.toFixed(1)}s`);
272
+ console.log(chalk.dim(" Clips:"), summary.clipCount);
273
+ console.log(chalk.dim(" Format:"), options.format);
274
+ console.log(chalk.dim(" Preset:"), options.preset);
275
+ console.log(chalk.dim(" Resolution:"), presetSettings.resolution);
276
+ console.log();
277
+ } catch (error) {
278
+ spinner.fail(chalk.red("Export failed"));
279
+ if (error instanceof Error) {
280
+ console.error(chalk.red(error.message));
281
+ if (process.env.DEBUG) {
282
+ console.error(error.stack);
283
+ }
284
+ }
285
+ process.exit(1);
286
+ }
287
+ });
288
+
289
+ /**
290
+ * Find FFmpeg executable
291
+ */
292
+ async function findFFmpeg(): Promise<string | null> {
293
+ try {
294
+ const { stdout } = await execSafe("which", ["ffmpeg"]);
295
+ return stdout.trim().split("\n")[0];
296
+ } catch {
297
+ try {
298
+ const { stdout } = await execSafe("where", ["ffmpeg"]);
299
+ return stdout.trim().split("\n")[0];
300
+ } catch {
301
+ return null;
302
+ }
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Detect gaps in timeline between clips
308
+ * Returns array of gaps with start and end times
309
+ */
310
+ function detectTimelineGaps(
311
+ clips: Array<{ startTime: number; duration: number }>,
312
+ totalDuration?: number
313
+ ): Array<{ start: number; end: number }> {
314
+ if (clips.length === 0) return [];
315
+
316
+ const gaps: Array<{ start: number; end: number }> = [];
317
+ const sortedClips = [...clips].sort((a, b) => a.startTime - b.startTime);
318
+
319
+ // Check for gap at the start (first clip doesn't start at 0)
320
+ if (sortedClips[0].startTime > 0.001) {
321
+ gaps.push({ start: 0, end: sortedClips[0].startTime });
322
+ }
323
+
324
+ // Check for gaps between clips
325
+ for (let i = 0; i < sortedClips.length - 1; i++) {
326
+ const clipEnd = sortedClips[i].startTime + sortedClips[i].duration;
327
+ const nextStart = sortedClips[i + 1].startTime;
328
+ // Allow small tolerance for floating point errors
329
+ if (nextStart > clipEnd + 0.001) {
330
+ gaps.push({ start: clipEnd, end: nextStart });
331
+ }
332
+ }
333
+
334
+ // Check for gap at the end if totalDuration is provided
335
+ if (totalDuration !== undefined) {
336
+ const lastClip = sortedClips[sortedClips.length - 1];
337
+ const lastClipEnd = lastClip.startTime + lastClip.duration;
338
+ if (totalDuration > lastClipEnd + 0.001) {
339
+ gaps.push({ start: lastClipEnd, end: totalDuration });
340
+ }
341
+ }
342
+
343
+ return gaps;
344
+ }
345
+
346
+ /**
347
+ * Gap fill plan for a single gap
348
+ */
349
+ interface GapFillPlan {
350
+ gap: { start: number; end: number };
351
+ fills: Array<{
352
+ type: "extend-before" | "extend-after" | "black";
353
+ sourceId?: string;
354
+ sourceUrl?: string;
355
+ start: number;
356
+ end: number;
357
+ sourceStart?: number;
358
+ sourceEnd?: number;
359
+ }>;
360
+ }
361
+
362
+ /**
363
+ * Create gap fill plans by extending adjacent clips
364
+ * Priority:
365
+ * 1. Extend clip AFTER the gap backwards (if sourceStartOffset > 0)
366
+ * 2. Extend clip BEFORE the gap forwards (if source has unused duration)
367
+ * 3. Fallback to black frames
368
+ */
369
+ function createGapFillPlans(
370
+ gaps: Array<{ start: number; end: number }>,
371
+ clips: Array<{ startTime: number; duration: number; sourceId: string; sourceStartOffset: number; sourceEndOffset: number }>,
372
+ sources: Array<{ id: string; url: string; type: string; duration: number }>
373
+ ): GapFillPlan[] {
374
+ const sortedClips = [...clips].sort((a, b) => a.startTime - b.startTime);
375
+
376
+ return gaps.map((gap) => {
377
+ const fills: GapFillPlan["fills"] = [];
378
+ let remainingStart = gap.start;
379
+ let remainingEnd = gap.end;
380
+
381
+ // Find clip AFTER the gap (for extending backwards)
382
+ const clipAfter = sortedClips.find((c) => Math.abs(c.startTime - gap.end) < 0.01);
383
+
384
+ // Find clip BEFORE the gap (for extending forwards)
385
+ const clipBefore = sortedClips.find((c) => Math.abs((c.startTime + c.duration) - gap.start) < 0.01);
386
+
387
+ // Try extending clip after the gap backwards first
388
+ if (clipAfter && clipAfter.sourceStartOffset > 0.01) {
389
+ const source = sources.find((s) => s.id === clipAfter.sourceId);
390
+ if (source && source.type === "video") {
391
+ const availableExtension = clipAfter.sourceStartOffset;
392
+ const extensionDuration = Math.min(availableExtension, remainingEnd - remainingStart);
393
+
394
+ if (extensionDuration > 0.01) {
395
+ // Extend from the gap end backwards
396
+ const fillStart = remainingEnd - extensionDuration;
397
+ const sourceStart = clipAfter.sourceStartOffset - extensionDuration;
398
+ const sourceEnd = clipAfter.sourceStartOffset;
399
+
400
+ fills.push({
401
+ type: "extend-after",
402
+ sourceId: source.id,
403
+ sourceUrl: source.url,
404
+ start: fillStart,
405
+ end: remainingEnd,
406
+ sourceStart,
407
+ sourceEnd,
408
+ });
409
+
410
+ remainingEnd = fillStart;
411
+ }
412
+ }
413
+ }
414
+
415
+ // If there's still a gap, try extending clip before the gap forwards
416
+ if (remainingEnd - remainingStart > 0.01 && clipBefore) {
417
+ const source = sources.find((s) => s.id === clipBefore.sourceId);
418
+ if (source && source.type === "video") {
419
+ const usedEndInSource = clipBefore.sourceEndOffset;
420
+ const availableExtension = source.duration - usedEndInSource;
421
+
422
+ if (availableExtension > 0.01) {
423
+ const extensionDuration = Math.min(availableExtension, remainingEnd - remainingStart);
424
+
425
+ if (extensionDuration > 0.01) {
426
+ const sourceStart = usedEndInSource;
427
+ const sourceEnd = usedEndInSource + extensionDuration;
428
+
429
+ fills.push({
430
+ type: "extend-before",
431
+ sourceId: source.id,
432
+ sourceUrl: source.url,
433
+ start: remainingStart,
434
+ end: remainingStart + extensionDuration,
435
+ sourceStart,
436
+ sourceEnd,
437
+ });
438
+
439
+ remainingStart = remainingStart + extensionDuration;
440
+ }
441
+ }
442
+ }
443
+ }
444
+
445
+ // Fill any remaining gap with black
446
+ if (remainingEnd - remainingStart > 0.01) {
447
+ fills.push({
448
+ type: "black",
449
+ start: remainingStart,
450
+ end: remainingEnd,
451
+ });
452
+ }
453
+
454
+ return { gap, fills };
455
+ });
456
+ }
457
+
458
+ /**
459
+ * Build FFmpeg arguments for export
460
+ */
461
+ function buildFFmpegArgs(
462
+ clips: ReturnType<Project["getClips"]>,
463
+ sources: ReturnType<Project["getSources"]>,
464
+ presetSettings: PresetSettings,
465
+ outputPath: string,
466
+ options: { overwrite?: boolean; format?: string; gapFill?: GapFillStrategy },
467
+ sourceAudioMap: Map<string, boolean> = new Map()
468
+ ): string[] {
469
+ const args: string[] = [];
470
+
471
+ // Overwrite flag first
472
+ if (options.overwrite) {
473
+ args.push("-y");
474
+ }
475
+
476
+ // Add input files
477
+ const sourceMap = new Map<string, number>();
478
+ let inputIndex = 0;
479
+
480
+ for (const clip of clips) {
481
+ const source = sources.find((s) => s.id === clip.sourceId);
482
+ if (source && !sourceMap.has(source.id)) {
483
+ // Add -loop 1 before image inputs to create a continuous video stream
484
+ if (source.type === "image") {
485
+ args.push("-loop", "1");
486
+ }
487
+ args.push("-i", source.url);
488
+ sourceMap.set(source.id, inputIndex);
489
+ inputIndex++;
490
+ }
491
+ }
492
+
493
+ // Build filter complex
494
+ const filterParts: string[] = [];
495
+
496
+ // Separate clips by track type for proper timeline-based export
497
+ // Get track info to determine clip types
498
+ const videoClips = clips.filter((clip) => {
499
+ const source = sources.find((s) => s.id === clip.sourceId);
500
+ return source && (source.type === "image" || source.type === "video");
501
+ }).sort((a, b) => a.startTime - b.startTime);
502
+
503
+ // Include audio clips from:
504
+ // 1. Explicit audio sources (narration, music)
505
+ // 2. Video sources when there are NO separate audio clips (e.g., highlight reels)
506
+ const explicitAudioClips = clips.filter((clip) => {
507
+ const source = sources.find((s) => s.id === clip.sourceId);
508
+ return source && source.type === "audio";
509
+ }).sort((a, b) => a.startTime - b.startTime);
510
+
511
+ // If no explicit audio clips, extract audio from video clips
512
+ const audioClips = explicitAudioClips.length > 0
513
+ ? explicitAudioClips
514
+ : clips.filter((clip) => {
515
+ const source = sources.find((s) => s.id === clip.sourceId);
516
+ return source && source.type === "video";
517
+ }).sort((a, b) => a.startTime - b.startTime);
518
+
519
+ // Get target resolution for scaling (all clips must match for concat)
520
+ const [targetWidth, targetHeight] = presetSettings.resolution.split("x").map(Number);
521
+
522
+ // Detect gaps in video timeline
523
+ // For totalDuration, use the longest audio clip end time if explicit audio exists
524
+ // (audio is usually the reference for timing in b-roll scenarios)
525
+ let totalDuration: number | undefined;
526
+ if (explicitAudioClips.length > 0) {
527
+ const audioEnd = Math.max(...explicitAudioClips.map(c => c.startTime + c.duration));
528
+ totalDuration = audioEnd;
529
+ }
530
+ const videoGaps = detectTimelineGaps(videoClips, totalDuration);
531
+
532
+ // Create gap fill plans based on strategy
533
+ const gapFillStrategy = options.gapFill || "extend";
534
+ const gapFillPlans = gapFillStrategy === "extend"
535
+ ? createGapFillPlans(videoGaps, videoClips, sources)
536
+ : videoGaps.map((gap) => ({
537
+ gap,
538
+ fills: [{ type: "black" as const, start: gap.start, end: gap.end }],
539
+ }));
540
+
541
+ // Build ordered list of video segments (clips and gap fills interleaved)
542
+ interface VideoSegment {
543
+ type: 'clip' | 'extended' | 'black';
544
+ clip?: typeof videoClips[0];
545
+ sourceId?: string;
546
+ sourceUrl?: string;
547
+ startTime: number;
548
+ duration?: number;
549
+ sourceStart?: number;
550
+ sourceEnd?: number;
551
+ }
552
+ const videoSegments: VideoSegment[] = [];
553
+
554
+ // Add video clips as segments
555
+ for (const clip of videoClips) {
556
+ videoSegments.push({ type: 'clip', clip, startTime: clip.startTime });
557
+ }
558
+
559
+ // Add gap fills as segments (from gap fill plans)
560
+ for (const plan of gapFillPlans) {
561
+ for (const fill of plan.fills) {
562
+ if (fill.type === "black") {
563
+ videoSegments.push({
564
+ type: 'black',
565
+ startTime: fill.start,
566
+ duration: fill.end - fill.start,
567
+ });
568
+ } else {
569
+ // extend-before or extend-after
570
+ videoSegments.push({
571
+ type: 'extended',
572
+ sourceId: fill.sourceId,
573
+ sourceUrl: fill.sourceUrl,
574
+ startTime: fill.start,
575
+ duration: fill.end - fill.start,
576
+ sourceStart: fill.sourceStart,
577
+ sourceEnd: fill.sourceEnd,
578
+ });
579
+ }
580
+ }
581
+ }
582
+
583
+ // Sort by start time
584
+ videoSegments.sort((a, b) => a.startTime - b.startTime);
585
+
586
+ // Process video segments (clips, extended clips, and black frames)
587
+ const videoStreams: string[] = [];
588
+ let videoStreamIdx = 0;
589
+
590
+ for (const segment of videoSegments) {
591
+ if (segment.type === 'clip' && segment.clip) {
592
+ const clip = segment.clip;
593
+ const source = sources.find((s) => s.id === clip.sourceId);
594
+ if (!source) continue;
595
+
596
+ const srcIdx = sourceMap.get(source.id);
597
+ if (srcIdx === undefined) continue;
598
+
599
+ // Video filter chain - images need different handling than video
600
+ let videoFilter: string;
601
+ if (source.type === "image") {
602
+ // Images: trim from 0 to clip duration (no source offset since images are looped)
603
+ videoFilter = `[${srcIdx}:v]trim=start=0:end=${clip.duration},setpts=PTS-STARTPTS`;
604
+ } else {
605
+ // Video: use source offsets
606
+ const trimStart = clip.sourceStartOffset;
607
+ const trimEnd = clip.sourceStartOffset + clip.duration;
608
+ videoFilter = `[${srcIdx}:v]trim=start=${trimStart}:end=${trimEnd},setpts=PTS-STARTPTS`;
609
+ }
610
+
611
+ // Scale to target resolution for concat compatibility (force same size, pad if needed)
612
+ videoFilter += `,scale=${targetWidth}:${targetHeight}:force_original_aspect_ratio=decrease,pad=${targetWidth}:${targetHeight}:(ow-iw)/2:(oh-ih)/2,setsar=1`;
613
+
614
+ // Apply effects
615
+ for (const effect of clip.effects || []) {
616
+ if (effect.type === "fadeIn") {
617
+ videoFilter += `,fade=t=in:st=0:d=${effect.duration}`;
618
+ } else if (effect.type === "fadeOut") {
619
+ const fadeStart = clip.duration - effect.duration;
620
+ videoFilter += `,fade=t=out:st=${fadeStart}:d=${effect.duration}`;
621
+ }
622
+ }
623
+
624
+ videoFilter += `[v${videoStreamIdx}]`;
625
+ filterParts.push(videoFilter);
626
+ videoStreams.push(`[v${videoStreamIdx}]`);
627
+ videoStreamIdx++;
628
+ } else if (segment.type === 'extended' && segment.sourceId) {
629
+ // Extended segment - use source video to fill gap
630
+ const srcIdx = sourceMap.get(segment.sourceId);
631
+ if (srcIdx === undefined) {
632
+ // Fallback to black if source not found in input map
633
+ const gapFilter = `color=c=black:s=${targetWidth}x${targetHeight}:d=${segment.duration}:r=30,format=yuv420p[v${videoStreamIdx}]`;
634
+ filterParts.push(gapFilter);
635
+ videoStreams.push(`[v${videoStreamIdx}]`);
636
+ videoStreamIdx++;
637
+ continue;
638
+ }
639
+
640
+ const videoFilter = `[${srcIdx}:v]trim=start=${segment.sourceStart}:end=${segment.sourceEnd},setpts=PTS-STARTPTS,scale=${targetWidth}:${targetHeight}:force_original_aspect_ratio=decrease,pad=${targetWidth}:${targetHeight}:(ow-iw)/2:(oh-ih)/2,setsar=1[v${videoStreamIdx}]`;
641
+ filterParts.push(videoFilter);
642
+ videoStreams.push(`[v${videoStreamIdx}]`);
643
+ videoStreamIdx++;
644
+ } else if (segment.type === 'black') {
645
+ // Generate black frame for the gap duration
646
+ const gapFilter = `color=c=black:s=${targetWidth}x${targetHeight}:d=${segment.duration}:r=30,format=yuv420p[v${videoStreamIdx}]`;
647
+ filterParts.push(gapFilter);
648
+ videoStreams.push(`[v${videoStreamIdx}]`);
649
+ videoStreamIdx++;
650
+ }
651
+ }
652
+
653
+ // Detect gaps in audio timeline (use same totalDuration for consistency)
654
+ const audioGaps = detectTimelineGaps(audioClips, totalDuration);
655
+
656
+ // Build ordered list of audio segments
657
+ interface AudioSegment {
658
+ type: 'clip' | 'gap';
659
+ clip?: typeof audioClips[0];
660
+ gap?: { start: number; end: number };
661
+ startTime: number;
662
+ }
663
+ const audioSegments: AudioSegment[] = [];
664
+
665
+ // Add audio clips as segments
666
+ for (const clip of audioClips) {
667
+ audioSegments.push({ type: 'clip', clip, startTime: clip.startTime });
668
+ }
669
+
670
+ // Add gaps as segments
671
+ for (const gap of audioGaps) {
672
+ audioSegments.push({ type: 'gap', gap, startTime: gap.start });
673
+ }
674
+
675
+ // Sort by start time
676
+ audioSegments.sort((a, b) => a.startTime - b.startTime);
677
+
678
+ // Process audio segments (clips and gaps)
679
+ const audioStreams: string[] = [];
680
+ let audioStreamIdx = 0;
681
+
682
+ for (const segment of audioSegments) {
683
+ if (segment.type === 'clip' && segment.clip) {
684
+ const clip = segment.clip;
685
+ const source = sources.find((s) => s.id === clip.sourceId);
686
+ if (!source) continue;
687
+
688
+ const srcIdx = sourceMap.get(source.id);
689
+ if (srcIdx === undefined) continue;
690
+
691
+ // Check if source has audio (audio sources always have audio, video sources need to be checked)
692
+ const hasAudio = source.type === "audio" || sourceAudioMap.get(source.id) === true;
693
+
694
+ let audioFilter: string;
695
+ if (hasAudio) {
696
+ const audioTrimStart = clip.sourceStartOffset;
697
+ const audioTrimEnd = clip.sourceStartOffset + clip.duration;
698
+ const sourceDuration = source.duration || 0;
699
+ const clipDuration = clip.duration;
700
+
701
+ if (source.type === "audio" && sourceDuration > clipDuration && audioTrimStart === 0) {
702
+ // Audio source is longer than clip slot — speed up to fit instead of truncating
703
+ const tempo = sourceDuration / clipDuration;
704
+ if (tempo <= 2.0) {
705
+ // atempo sounds natural up to ~1.3x, acceptable up to 2x
706
+ audioFilter = `[${srcIdx}:a]atempo=${tempo.toFixed(4)},asetpts=PTS-STARTPTS`;
707
+ } else {
708
+ // Too fast would sound bad — fall back to trim
709
+ audioFilter = `[${srcIdx}:a]atrim=start=${audioTrimStart}:end=${audioTrimEnd},asetpts=PTS-STARTPTS`;
710
+ }
711
+ } else {
712
+ // Normal trim for video-embedded audio, audio that fits, or offset clips
713
+ audioFilter = `[${srcIdx}:a]atrim=start=${audioTrimStart}:end=${audioTrimEnd},asetpts=PTS-STARTPTS`;
714
+ }
715
+ } else {
716
+ // Source has no audio - generate silence for the clip duration
717
+ audioFilter = `anullsrc=r=48000:cl=stereo,atrim=0:${clip.duration},asetpts=PTS-STARTPTS`;
718
+ }
719
+
720
+ // Apply audio effects
721
+ for (const effect of clip.effects || []) {
722
+ if (effect.type === "fadeIn") {
723
+ audioFilter += `,afade=t=in:st=0:d=${effect.duration}`;
724
+ } else if (effect.type === "fadeOut") {
725
+ const fadeStart = clip.duration - effect.duration;
726
+ audioFilter += `,afade=t=out:st=${fadeStart}:d=${effect.duration}`;
727
+ }
728
+ }
729
+
730
+ audioFilter += `[a${audioStreamIdx}]`;
731
+ filterParts.push(audioFilter);
732
+ audioStreams.push(`[a${audioStreamIdx}]`);
733
+ audioStreamIdx++;
734
+ } else if (segment.type === 'gap' && segment.gap) {
735
+ // Generate silence for the gap duration
736
+ const gapDuration = segment.gap.end - segment.gap.start;
737
+ const audioGapFilter = `anullsrc=r=48000:cl=stereo,atrim=0:${gapDuration},asetpts=PTS-STARTPTS[a${audioStreamIdx}]`;
738
+ filterParts.push(audioGapFilter);
739
+ audioStreams.push(`[a${audioStreamIdx}]`);
740
+ audioStreamIdx++;
741
+ }
742
+ }
743
+
744
+ // Concatenate video clips
745
+ if (videoStreams.length > 1) {
746
+ filterParts.push(
747
+ `${videoStreams.join("")}concat=n=${videoStreams.length}:v=1:a=0[outv]`
748
+ );
749
+ } else if (videoStreams.length === 1) {
750
+ // Single video clip - just copy
751
+ filterParts.push(`${videoStreams[0]}copy[outv]`);
752
+ }
753
+
754
+ // Concatenate or mix audio clips
755
+ if (audioStreams.length > 1) {
756
+ filterParts.push(
757
+ `${audioStreams.join("")}concat=n=${audioStreams.length}:v=0:a=1[outa]`
758
+ );
759
+ } else if (audioStreams.length === 1) {
760
+ // Single audio clip - just copy
761
+ filterParts.push(`${audioStreams[0]}acopy[outa]`);
762
+ }
763
+
764
+ // Add filter complex
765
+ args.push("-filter_complex", filterParts.join(";"));
766
+
767
+ // Map outputs
768
+ args.push("-map", "[outv]");
769
+ if (audioStreams.length > 0) {
770
+ args.push("-map", "[outa]");
771
+ }
772
+
773
+ // Add encoding settings
774
+ args.push(...presetSettings.ffmpegArgs);
775
+
776
+ // Output file
777
+ args.push(outputPath);
778
+
779
+ return args;
780
+ }
781
+
782
+ /**
783
+ * Run FFmpeg with progress reporting
784
+ */
785
+ function runFFmpegProcess(
786
+ ffmpegPath: string,
787
+ args: string[],
788
+ onProgress: (percent: number) => void
789
+ ): Promise<void> {
790
+ return new Promise((resolve, reject) => {
791
+ const ffmpeg = spawn(ffmpegPath, args, {
792
+ stdio: ["pipe", "pipe", "pipe"],
793
+ });
794
+
795
+ let duration = 0;
796
+ let stderr = "";
797
+
798
+ ffmpeg.stderr?.on("data", (data: Buffer) => {
799
+ const output = data.toString();
800
+ stderr += output;
801
+
802
+ // Parse duration
803
+ const durationMatch = output.match(/Duration: (\d+):(\d+):(\d+\.\d+)/);
804
+ if (durationMatch) {
805
+ const [, hours, minutes, seconds] = durationMatch;
806
+ duration =
807
+ parseInt(hours) * 3600 +
808
+ parseInt(minutes) * 60 +
809
+ parseFloat(seconds);
810
+ }
811
+
812
+ // Parse progress
813
+ const timeMatch = output.match(/time=(\d+):(\d+):(\d+\.\d+)/);
814
+ if (timeMatch && duration > 0) {
815
+ const [, hours, minutes, seconds] = timeMatch;
816
+ const currentTime =
817
+ parseInt(hours) * 3600 +
818
+ parseInt(minutes) * 60 +
819
+ parseFloat(seconds);
820
+ const percent = Math.min(100, Math.round((currentTime / duration) * 100));
821
+ onProgress(percent);
822
+ }
823
+ });
824
+
825
+ ffmpeg.on("close", (code) => {
826
+ if (code === 0) {
827
+ resolve();
828
+ } else {
829
+ // Extract error message
830
+ const errorMatch = stderr.match(/Error.*$/m);
831
+ const errorMsg = errorMatch ? errorMatch[0] : `FFmpeg exited with code ${code}`;
832
+ reject(new Error(errorMsg));
833
+ }
834
+ });
835
+
836
+ ffmpeg.on("error", (err) => {
837
+ reject(err);
838
+ });
839
+ });
840
+ }
841
+
842
+ interface PresetSettings {
843
+ resolution: string;
844
+ videoBitrate: string;
845
+ audioBitrate: string;
846
+ ffmpegArgs: string[];
847
+ }
848
+
849
+ function getPresetSettings(
850
+ preset: string,
851
+ aspectRatio: string
852
+ ): PresetSettings {
853
+ const presets: Record<string, PresetSettings> = {
854
+ draft: {
855
+ resolution: "640x360",
856
+ videoBitrate: "1M",
857
+ audioBitrate: "128k",
858
+ ffmpegArgs: [
859
+ "-c:v", "libx264",
860
+ "-preset", "ultrafast",
861
+ "-crf", "28",
862
+ "-c:a", "aac",
863
+ "-b:a", "128k",
864
+ ],
865
+ },
866
+ standard: {
867
+ resolution: "1280x720",
868
+ videoBitrate: "4M",
869
+ audioBitrate: "192k",
870
+ ffmpegArgs: [
871
+ "-c:v", "libx264",
872
+ "-preset", "medium",
873
+ "-crf", "23",
874
+ "-c:a", "aac",
875
+ "-b:a", "192k",
876
+ ],
877
+ },
878
+ high: {
879
+ resolution: "1920x1080",
880
+ videoBitrate: "8M",
881
+ audioBitrate: "256k",
882
+ ffmpegArgs: [
883
+ "-c:v", "libx264",
884
+ "-preset", "slow",
885
+ "-crf", "18",
886
+ "-c:a", "aac",
887
+ "-b:a", "256k",
888
+ ],
889
+ },
890
+ ultra: {
891
+ resolution: "3840x2160",
892
+ videoBitrate: "20M",
893
+ audioBitrate: "320k",
894
+ ffmpegArgs: [
895
+ "-c:v", "libx264",
896
+ "-preset", "slow",
897
+ "-crf", "15",
898
+ "-c:a", "aac",
899
+ "-b:a", "320k",
900
+ ],
901
+ },
902
+ };
903
+
904
+ // Adjust resolution for aspect ratio
905
+ const settings = { ...presets[preset] || presets.standard };
906
+
907
+ if (aspectRatio === "9:16") {
908
+ // Vertical video
909
+ const [w, h] = settings.resolution.split("x");
910
+ settings.resolution = `${h}x${w}`;
911
+ } else if (aspectRatio === "1:1") {
912
+ // Square video
913
+ const h = settings.resolution.split("x")[1];
914
+ settings.resolution = `${h}x${h}`;
915
+ }
916
+
917
+ return settings;
918
+ }