@crievo/omni 0.0.0-stage → 0.1.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 (709) hide show
  1. package/.env.example +41 -0
  2. package/README.md +2 -2
  3. package/bin/omni.mjs +14 -0
  4. package/docs/onboarding/clone-voice.png +0 -0
  5. package/docs/onboarding/getting-started.png +0 -0
  6. package/docs/onboarding/new-video.png +0 -0
  7. package/docs/onboarding/providers.png +0 -0
  8. package/docs/onboarding/publish.png +0 -0
  9. package/docs/onboarding/references.png +0 -0
  10. package/docs/onboarding.md +212 -0
  11. package/omnivideo.config.example.json +437 -0
  12. package/package.json +40 -4
  13. package/reference/captions.md +156 -0
  14. package/reference/carousel-contract.md +91 -0
  15. package/reference/cloud.md +96 -0
  16. package/reference/learnings.md +792 -0
  17. package/reference/omnichat.md +305 -0
  18. package/reference/premiere-mcp.md +91 -0
  19. package/reference/publish.md +305 -0
  20. package/reference/scenes-contract.md +109 -0
  21. package/reference/studio.md +666 -0
  22. package/reference/voicestudio.md +81 -0
  23. package/reference/writing-for-voice.md +67 -0
  24. package/requirements.txt +3 -0
  25. package/studio/api/app.ts +87 -0
  26. package/studio/api/lib/errors.ts +33 -0
  27. package/studio/api/lib/files.ts +41 -0
  28. package/studio/api/lib/hono.ts +9 -0
  29. package/studio/api/lib/result.ts +14 -0
  30. package/studio/api/lib/validate.ts +31 -0
  31. package/studio/api/lib/video.ts +10 -0
  32. package/studio/api/middleware/json-charset.ts +11 -0
  33. package/studio/api/middleware/local-only.ts +32 -0
  34. package/studio/api/middleware/no-timeout.ts +10 -0
  35. package/studio/api/routes/admin.ts +18 -0
  36. package/studio/api/routes/agent-cascade.ts +100 -0
  37. package/studio/api/routes/agent-history.ts +23 -0
  38. package/studio/api/routes/agent.ts +162 -0
  39. package/studio/api/routes/analytics.ts +31 -0
  40. package/studio/api/routes/approval.ts +60 -0
  41. package/studio/api/routes/archive.ts +12 -0
  42. package/studio/api/routes/captions.ts +28 -0
  43. package/studio/api/routes/carousel.ts +38 -0
  44. package/studio/api/routes/clone.ts +27 -0
  45. package/studio/api/routes/create.ts +19 -0
  46. package/studio/api/routes/events.ts +11 -0
  47. package/studio/api/routes/export.ts +14 -0
  48. package/studio/api/routes/media.ts +30 -0
  49. package/studio/api/routes/omnichat.ts +113 -0
  50. package/studio/api/routes/onboarding.ts +14 -0
  51. package/studio/api/routes/publish.ts +40 -0
  52. package/studio/api/routes/queue.ts +14 -0
  53. package/studio/api/routes/references.ts +40 -0
  54. package/studio/api/routes/runner.ts +17 -0
  55. package/studio/api/routes/schedule.ts +47 -0
  56. package/studio/api/routes/services.ts +11 -0
  57. package/studio/api/routes/sessions.ts +27 -0
  58. package/studio/api/routes/settings.ts +102 -0
  59. package/studio/api/routes/sound.ts +45 -0
  60. package/studio/api/routes/thumbnail.ts +18 -0
  61. package/studio/api/routes/usage.ts +13 -0
  62. package/studio/api/routes/videos.ts +41 -0
  63. package/studio/api/routes/voices.ts +71 -0
  64. package/studio/api/services/admin.ts +113 -0
  65. package/studio/api/services/agent-cascade.ts +975 -0
  66. package/studio/api/services/agent-claude.ts +47 -0
  67. package/studio/api/services/agent-history.ts +103 -0
  68. package/studio/api/services/agent-persona.ts +77 -0
  69. package/studio/api/services/agent-tools.ts +2237 -0
  70. package/studio/api/services/agent-ui-tools.ts +259 -0
  71. package/studio/api/services/agent-wait.ts +32 -0
  72. package/studio/api/services/agent.ts +553 -0
  73. package/studio/api/services/analytics.ts +92 -0
  74. package/studio/api/services/approval.ts +134 -0
  75. package/studio/api/services/archive.ts +38 -0
  76. package/studio/api/services/captions.ts +268 -0
  77. package/studio/api/services/carousel.ts +206 -0
  78. package/studio/api/services/classify.ts +196 -0
  79. package/studio/api/services/clone-phrases.ts +75 -0
  80. package/studio/api/services/clone.ts +722 -0
  81. package/studio/api/services/create.ts +421 -0
  82. package/studio/api/services/events.ts +215 -0
  83. package/studio/api/services/media.ts +32 -0
  84. package/studio/api/services/omnichat.ts +624 -0
  85. package/studio/api/services/onboarding.ts +77 -0
  86. package/studio/api/services/publish.ts +287 -0
  87. package/studio/api/services/queue.ts +150 -0
  88. package/studio/api/services/references.ts +269 -0
  89. package/studio/api/services/root.ts +60 -0
  90. package/studio/api/services/services.ts +114 -0
  91. package/studio/api/services/sessions.ts +1007 -0
  92. package/studio/api/services/settings.ts +983 -0
  93. package/studio/api/services/sound.ts +311 -0
  94. package/studio/api/services/thumbnail.ts +133 -0
  95. package/studio/api/services/usage.ts +27 -0
  96. package/studio/api/services/videos.ts +418 -0
  97. package/studio/api/services/voice-pause.ts +98 -0
  98. package/studio/api/services/voices.ts +324 -0
  99. package/studio/runner/runner.ts +244 -0
  100. package/studio/shared/omni-actions.ts +68 -0
  101. package/studio/shared/omni-admin.ts +52 -0
  102. package/studio/shared/omni-farewell.ts +48 -0
  103. package/studio/shared/omni-hotword.ts +20 -0
  104. package/studio/shared/omni-screens.ts +228 -0
  105. package/studio/shared/schemas/admin.ts +15 -0
  106. package/studio/shared/schemas/agent-cascade.ts +58 -0
  107. package/studio/shared/schemas/agent-history.ts +73 -0
  108. package/studio/shared/schemas/agent.ts +50 -0
  109. package/studio/shared/schemas/analytics.ts +6 -0
  110. package/studio/shared/schemas/approval.ts +22 -0
  111. package/studio/shared/schemas/captions.ts +19 -0
  112. package/studio/shared/schemas/carousel.ts +26 -0
  113. package/studio/shared/schemas/clone.ts +40 -0
  114. package/studio/shared/schemas/common.ts +16 -0
  115. package/studio/shared/schemas/omnichat.ts +54 -0
  116. package/studio/shared/schemas/onboarding.ts +5 -0
  117. package/studio/shared/schemas/publish.ts +45 -0
  118. package/studio/shared/schemas/references.ts +20 -0
  119. package/studio/shared/schemas/settings.ts +82 -0
  120. package/studio/shared/schemas/sound.ts +22 -0
  121. package/studio/shared/schemas/videos.ts +38 -0
  122. package/studio/shared/schemas/voices.ts +23 -0
  123. package/studio/shared/types/api.ts +1290 -0
  124. package/studio/studio.ts +148 -0
  125. package/studio/tsconfig.json +15 -0
  126. package/studio/web/assets/logos/cartesia.svg +4 -0
  127. package/studio/web/assets/logos/elevenlabs.svg +12 -0
  128. package/studio/web/assets/logos/fish.svg +24 -0
  129. package/studio/web/assets/logos/omni-black.png +0 -0
  130. package/studio/web/assets/logos/omni-white.png +0 -0
  131. package/studio/web/assets/logos/voicestudio.svg +21 -0
  132. package/studio/web/components/AgentMark.tsx +7 -0
  133. package/studio/web/components/AgentWalking.tsx +11 -0
  134. package/studio/web/components/ApiKeyField.tsx +104 -0
  135. package/studio/web/components/AppShell.tsx +64 -0
  136. package/studio/web/components/ApprovalNotice.tsx +65 -0
  137. package/studio/web/components/AvatarGroupIcon.tsx +22 -0
  138. package/studio/web/components/AvatarNameField.tsx +86 -0
  139. package/studio/web/components/AvatarPicker.tsx +84 -0
  140. package/studio/web/components/BrandLogo.tsx +16 -0
  141. package/studio/web/components/CartesiaPlanSetting.tsx +90 -0
  142. package/studio/web/components/ClaudeWalking.tsx +32 -0
  143. package/studio/web/components/CloneVoiceDialog/GuidedScript.tsx +39 -0
  144. package/studio/web/components/CloneVoiceDialog/LanguageBar.tsx +37 -0
  145. package/studio/web/components/CloneVoiceDialog/LevelMeter.tsx +31 -0
  146. package/studio/web/components/CloneVoiceDialog/ProviderChoice.tsx +81 -0
  147. package/studio/web/components/CloneVoiceDialog/QualityPanel.tsx +62 -0
  148. package/studio/web/components/CloneVoiceDialog/RecordStep.tsx +50 -0
  149. package/studio/web/components/CloneVoiceDialog/RecorderBar.tsx +79 -0
  150. package/studio/web/components/CloneVoiceDialog/RedoSummary.tsx +54 -0
  151. package/studio/web/components/CloneVoiceDialog/SaveStep.tsx +91 -0
  152. package/studio/web/components/CloneVoiceDialog/SourceBar.tsx +30 -0
  153. package/studio/web/components/CloneVoiceDialog/index.tsx +141 -0
  154. package/studio/web/components/CloneVoiceDialog/types.ts +28 -0
  155. package/studio/web/components/CloneVoiceDialog/useCloneVoice.ts +528 -0
  156. package/studio/web/components/ConnectionSignals.tsx +51 -0
  157. package/studio/web/components/EditVoiceDialog/EditVoiceFooter.tsx +72 -0
  158. package/studio/web/components/EditVoiceDialog/ProviderCards.tsx +81 -0
  159. package/studio/web/components/EditVoiceDialog/index.tsx +256 -0
  160. package/studio/web/components/ErrorBanner.tsx +115 -0
  161. package/studio/web/components/FinishedTask.tsx +41 -0
  162. package/studio/web/components/FishModelSetting.tsx +69 -0
  163. package/studio/web/components/FishPriceSetting.tsx +44 -0
  164. package/studio/web/components/HumanBanner.tsx +64 -0
  165. package/studio/web/components/Lightbox.tsx +41 -0
  166. package/studio/web/components/LogStream.tsx +42 -0
  167. package/studio/web/components/Markdown.tsx +21 -0
  168. package/studio/web/components/MissingDmsPanel/BatchSend.tsx +137 -0
  169. package/studio/web/components/MissingDmsPanel/CommentCard.tsx +168 -0
  170. package/studio/web/components/MissingDmsPanel/CommentFeed.tsx +45 -0
  171. package/studio/web/components/MissingDmsPanel/DmBubble.tsx +38 -0
  172. package/studio/web/components/MissingDmsPanel/ReplyComposer.tsx +63 -0
  173. package/studio/web/components/MissingDmsPanel/index.tsx +98 -0
  174. package/studio/web/components/NotFound.tsx +48 -0
  175. package/studio/web/components/Omni/OmniAvatarPicker.tsx +92 -0
  176. package/studio/web/components/Omni/OmniCards.tsx +202 -0
  177. package/studio/web/components/Omni/OmniGlow.tsx +183 -0
  178. package/studio/web/components/Omni/OmniHistory.tsx +119 -0
  179. package/studio/web/components/Omni/OmniHotword.tsx +40 -0
  180. package/studio/web/components/Omni/OmniMicSelect.tsx +92 -0
  181. package/studio/web/components/Omni/OmniVoiceSample.tsx +81 -0
  182. package/studio/web/components/Omni/OmniWidget.tsx +743 -0
  183. package/studio/web/components/Omni/PendingActions.tsx +200 -0
  184. package/studio/web/components/Omni/Transcript.tsx +127 -0
  185. package/studio/web/components/OmnichatAccountStatus.tsx +87 -0
  186. package/studio/web/components/OmnichatRulePanel/RuleConfig.tsx +150 -0
  187. package/studio/web/components/OmnichatRulePanel/RuleDryRun.tsx +15 -0
  188. package/studio/web/components/OmnichatRulePanel/RuleEnableConfirm.tsx +42 -0
  189. package/studio/web/components/OmnichatRulePanel/RuleSkeleton.tsx +18 -0
  190. package/studio/web/components/OmnichatRulePanel/RuleStatus.tsx +55 -0
  191. package/studio/web/components/OmnichatRulePanel/index.tsx +115 -0
  192. package/studio/web/components/OmnichatRulePanel/rule-steps.ts +67 -0
  193. package/studio/web/components/OmnichatRulePanel/steps/MessageStep.tsx +91 -0
  194. package/studio/web/components/OmnichatRulePanel/steps/RepliesStep.tsx +201 -0
  195. package/studio/web/components/OmnichatRulePanel/steps/TriggerStep.tsx +76 -0
  196. package/studio/web/components/OmnichatRulePanel/steps/types.ts +11 -0
  197. package/studio/web/components/Palette.tsx +17 -0
  198. package/studio/web/components/PlatformCard.tsx +109 -0
  199. package/studio/web/components/PlatformIcon.tsx +19 -0
  200. package/studio/web/components/ProviderCard.tsx +80 -0
  201. package/studio/web/components/ProviderDetail.tsx +367 -0
  202. package/studio/web/components/ProviderLogo.tsx +20 -0
  203. package/studio/web/components/QueuePanel.tsx +77 -0
  204. package/studio/web/components/ReferenceActions.tsx +89 -0
  205. package/studio/web/components/ReferencePicker.tsx +136 -0
  206. package/studio/web/components/ResultMessage.tsx +7 -0
  207. package/studio/web/components/RuleForm.tsx +138 -0
  208. package/studio/web/components/RuleLog.tsx +151 -0
  209. package/studio/web/components/RunningTask.tsx +23 -0
  210. package/studio/web/components/SampleList.tsx +55 -0
  211. package/studio/web/components/SessionPanel.tsx +352 -0
  212. package/studio/web/components/SettingRow.tsx +14 -0
  213. package/studio/web/components/SettingsCategorySkeleton.tsx +25 -0
  214. package/studio/web/components/SidePanel.tsx +77 -0
  215. package/studio/web/components/Sidebar.tsx +289 -0
  216. package/studio/web/components/StageStepper.tsx +87 -0
  217. package/studio/web/components/StudioOffline.tsx +80 -0
  218. package/studio/web/components/TaskName.tsx +17 -0
  219. package/studio/web/components/TopBanners.tsx +30 -0
  220. package/studio/web/components/VideoOmnichatPanel.tsx +287 -0
  221. package/studio/web/components/VoiceAvatar.tsx +33 -0
  222. package/studio/web/components/VoiceCarousel.tsx +196 -0
  223. package/studio/web/components/VoiceCategory.tsx +9 -0
  224. package/studio/web/components/VoiceModal.tsx +39 -0
  225. package/studio/web/components/VoiceProfileCard.tsx +75 -0
  226. package/studio/web/components/VoiceProfilePicker.tsx +107 -0
  227. package/studio/web/components/VoiceProfilesSettings.tsx +150 -0
  228. package/studio/web/components/VoiceProviderCard.tsx +118 -0
  229. package/studio/web/components/VoiceProvidersCard.tsx +26 -0
  230. package/studio/web/components/WaitList.tsx +21 -0
  231. package/studio/web/components/icons.tsx +247 -0
  232. package/studio/web/components/ui/accordion.tsx +47 -0
  233. package/studio/web/components/ui/badge.tsx +18 -0
  234. package/studio/web/components/ui/calendar.tsx +43 -0
  235. package/studio/web/components/ui/chart.tsx +229 -0
  236. package/studio/web/components/ui/checkbox.tsx +26 -0
  237. package/studio/web/components/ui/cx.ts +2 -0
  238. package/studio/web/components/ui/date-time-picker.tsx +84 -0
  239. package/studio/web/components/ui/panel.tsx +23 -0
  240. package/studio/web/components/ui/picker.tsx +58 -0
  241. package/studio/web/components/ui/popover.tsx +24 -0
  242. package/studio/web/components/ui/select.tsx +89 -0
  243. package/studio/web/components/ui/skeleton.tsx +10 -0
  244. package/studio/web/components/ui/slider.tsx +68 -0
  245. package/studio/web/components/ui/switch.tsx +13 -0
  246. package/studio/web/components/ui/tooltip.test.tsx +30 -0
  247. package/studio/web/components/ui/tooltip.tsx +59 -0
  248. package/studio/web/hooks/useAccounts.ts +27 -0
  249. package/studio/web/hooks/useAdmin.ts +12 -0
  250. package/studio/web/hooks/useAnalytics.ts +21 -0
  251. package/studio/web/hooks/useAvatarAudioReactive.ts +87 -0
  252. package/studio/web/hooks/useBrowserConnect.ts +48 -0
  253. package/studio/web/hooks/useClonePlan.ts +14 -0
  254. package/studio/web/hooks/useConnected.ts +7 -0
  255. package/studio/web/hooks/useLiveQuery.ts +29 -0
  256. package/studio/web/hooks/useMenuCollapsed.ts +32 -0
  257. package/studio/web/hooks/useMicDevices.ts +53 -0
  258. package/studio/web/hooks/useMissingDms.ts +211 -0
  259. package/studio/web/hooks/useMissingSetupSteps.ts +26 -0
  260. package/studio/web/hooks/useNow.ts +11 -0
  261. package/studio/web/hooks/useOmniAgent.ts +278 -0
  262. package/studio/web/hooks/useOmniClaude.ts +124 -0
  263. package/studio/web/hooks/useOmniDock.ts +142 -0
  264. package/studio/web/hooks/useOmniGreeting.ts +60 -0
  265. package/studio/web/hooks/useOmniHotword.ts +128 -0
  266. package/studio/web/hooks/useOmnichat.ts +26 -0
  267. package/studio/web/hooks/useOnboarding.ts +27 -0
  268. package/studio/web/hooks/usePost.ts +23 -0
  269. package/studio/web/hooks/useQueue.ts +10 -0
  270. package/studio/web/hooks/useReferences.ts +20 -0
  271. package/studio/web/hooks/useRuleEditor.ts +101 -0
  272. package/studio/web/hooks/useRunner.ts +24 -0
  273. package/studio/web/hooks/useSaveAction.ts +23 -0
  274. package/studio/web/hooks/useSchedule.ts +11 -0
  275. package/studio/web/hooks/useServices.ts +10 -0
  276. package/studio/web/hooks/useSessions.ts +15 -0
  277. package/studio/web/hooks/useSettings.ts +11 -0
  278. package/studio/web/hooks/useSettingsSection.ts +30 -0
  279. package/studio/web/hooks/useSidePanel.ts +23 -0
  280. package/studio/web/hooks/useSound.ts +30 -0
  281. package/studio/web/hooks/useUsage.ts +11 -0
  282. package/studio/web/hooks/useVideoVoice.ts +36 -0
  283. package/studio/web/hooks/useVideos.ts +21 -0
  284. package/studio/web/hooks/useVoiceProfilePreview.ts +52 -0
  285. package/studio/web/hooks/useVoiceProfiles.ts +7 -0
  286. package/studio/web/hooks/useVoiceRunning.ts +10 -0
  287. package/studio/web/hooks/useVoiceStudioClosed.ts +8 -0
  288. package/studio/web/index.html +19 -0
  289. package/studio/web/lib/agent.ts +7 -0
  290. package/studio/web/lib/alerts.ts +210 -0
  291. package/studio/web/lib/analytics-metrics.ts +351 -0
  292. package/studio/web/lib/api.ts +70 -0
  293. package/studio/web/lib/audio.ts +39 -0
  294. package/studio/web/lib/avatars.ts +41 -0
  295. package/studio/web/lib/calendar.ts +218 -0
  296. package/studio/web/lib/caption-position.ts +249 -0
  297. package/studio/web/lib/carousel.ts +24 -0
  298. package/studio/web/lib/chime.ts +42 -0
  299. package/studio/web/lib/clone-voice.ts +55 -0
  300. package/studio/web/lib/export.ts +11 -0
  301. package/studio/web/lib/files.ts +8 -0
  302. package/studio/web/lib/format.ts +93 -0
  303. package/studio/web/lib/media.ts +29 -0
  304. package/studio/web/lib/mic-device.ts +72 -0
  305. package/studio/web/lib/missing-dms.ts +70 -0
  306. package/studio/web/lib/omni-cards.ts +42 -0
  307. package/studio/web/lib/omni-cascade.ts +1067 -0
  308. package/studio/web/lib/omni-greeting-audio.ts +24 -0
  309. package/studio/web/lib/omni-hotword.ts +231 -0
  310. package/studio/web/lib/omni-panel.ts +67 -0
  311. package/studio/web/lib/omni-realtime.ts +811 -0
  312. package/studio/web/lib/omni-ui.ts +402 -0
  313. package/studio/web/lib/omni-wait.ts +174 -0
  314. package/studio/web/lib/omnichat-api.ts +44 -0
  315. package/studio/web/lib/orb.ts +270 -0
  316. package/studio/web/lib/pcm-recorder.ts +115 -0
  317. package/studio/web/lib/platforms.ts +17 -0
  318. package/studio/web/lib/providers.ts +27 -0
  319. package/studio/web/lib/publish-draft.ts +52 -0
  320. package/studio/web/lib/publish-settings.ts +81 -0
  321. package/studio/web/lib/query-client.ts +122 -0
  322. package/studio/web/lib/references.ts +13 -0
  323. package/studio/web/lib/services.ts +40 -0
  324. package/studio/web/lib/sessions.ts +103 -0
  325. package/studio/web/lib/settings-categories.ts +58 -0
  326. package/studio/web/lib/sound-audio.ts +33 -0
  327. package/studio/web/lib/sound-timeline.ts +291 -0
  328. package/studio/web/lib/sse.ts +86 -0
  329. package/studio/web/lib/stage-history.ts +141 -0
  330. package/studio/web/lib/stages.ts +344 -0
  331. package/studio/web/lib/studio-offline.ts +60 -0
  332. package/studio/web/lib/upload.ts +24 -0
  333. package/studio/web/lib/video-tabs.ts +118 -0
  334. package/studio/web/lib/voice-profile-draft.ts +34 -0
  335. package/studio/web/lib/voice-profiles.ts +3 -0
  336. package/studio/web/main.tsx +14 -0
  337. package/studio/web/pages/AdminPage/index.tsx +123 -0
  338. package/studio/web/pages/AgentPage/components/Measurements.tsx +102 -0
  339. package/studio/web/pages/AgentPage/index.tsx +28 -0
  340. package/studio/web/pages/AnalyticsPage/components/About.tsx +52 -0
  341. package/studio/web/pages/AnalyticsPage/components/ChannelCards.tsx +106 -0
  342. package/studio/web/pages/AnalyticsPage/components/ChannelView.tsx +137 -0
  343. package/studio/web/pages/AnalyticsPage/components/CollectionStatus.tsx +46 -0
  344. package/studio/web/pages/AnalyticsPage/components/Comparison.tsx +68 -0
  345. package/studio/web/pages/AnalyticsPage/components/Diagnosis.tsx +50 -0
  346. package/studio/web/pages/AnalyticsPage/components/Highlights.tsx +49 -0
  347. package/studio/web/pages/AnalyticsPage/components/Hint.tsx +11 -0
  348. package/studio/web/pages/AnalyticsPage/components/HookVsBody.tsx +100 -0
  349. package/studio/web/pages/AnalyticsPage/components/HourlyWave.tsx +133 -0
  350. package/studio/web/pages/AnalyticsPage/components/Insights.tsx +70 -0
  351. package/studio/web/pages/AnalyticsPage/components/MetricCard.tsx +46 -0
  352. package/studio/web/pages/AnalyticsPage/components/PlanSection.tsx +40 -0
  353. package/studio/web/pages/AnalyticsPage/components/ProgressionChart.tsx +155 -0
  354. package/studio/web/pages/AnalyticsPage/components/ReelBarsChart.tsx +154 -0
  355. package/studio/web/pages/AnalyticsPage/components/ReelThumb.tsx +19 -0
  356. package/studio/web/pages/AnalyticsPage/components/ReelView.tsx +290 -0
  357. package/studio/web/pages/AnalyticsPage/components/ReelsTable.tsx +269 -0
  358. package/studio/web/pages/AnalyticsPage/components/RequestWithFocus.tsx +38 -0
  359. package/studio/web/pages/AnalyticsPage/components/SegmentedControl.tsx +35 -0
  360. package/studio/web/pages/AnalyticsPage/components/VsMedian.tsx +31 -0
  361. package/studio/web/pages/AnalyticsPage/index.tsx +17 -0
  362. package/studio/web/pages/GettingStartedPage/index.tsx +174 -0
  363. package/studio/web/pages/NewVideoPage/components/DeliverablesPicker.tsx +62 -0
  364. package/studio/web/pages/NewVideoPage/components/FormatPicker.tsx +33 -0
  365. package/studio/web/pages/NewVideoPage/index.tsx +133 -0
  366. package/studio/web/pages/NotFoundPage/index.tsx +9 -0
  367. package/studio/web/pages/OmnichatPage/components/NewFromLink.tsx +59 -0
  368. package/studio/web/pages/OmnichatPage/components/RuleListItem.tsx +64 -0
  369. package/studio/web/pages/OmnichatPage/components/RulePreview.tsx +14 -0
  370. package/studio/web/pages/OmnichatPage/components/RuleView.tsx +88 -0
  371. package/studio/web/pages/OmnichatPage/index.tsx +153 -0
  372. package/studio/web/pages/QueuePage/index.tsx +22 -0
  373. package/studio/web/pages/ReferencePage/components/ReferenceTexts.tsx +131 -0
  374. package/studio/web/pages/ReferencePage/index.tsx +216 -0
  375. package/studio/web/pages/ReferencesPage/components/NewReferenceForm.tsx +150 -0
  376. package/studio/web/pages/ReferencesPage/components/ReferenceCard.tsx +42 -0
  377. package/studio/web/pages/ReferencesPage/components/ReferenceGridSkeleton.tsx +31 -0
  378. package/studio/web/pages/ReferencesPage/index.tsx +67 -0
  379. package/studio/web/pages/SchedulePage/components/CalendarSkeleton.tsx +18 -0
  380. package/studio/web/pages/SchedulePage/components/DayView.tsx +177 -0
  381. package/studio/web/pages/SchedulePage/components/HourPicker.tsx +103 -0
  382. package/studio/web/pages/SchedulePage/components/MonthDayCell.tsx +111 -0
  383. package/studio/web/pages/SchedulePage/components/MonthView.tsx +47 -0
  384. package/studio/web/pages/SchedulePage/components/PlatformIcons.tsx +12 -0
  385. package/studio/web/pages/SchedulePage/components/PostThumb.tsx +15 -0
  386. package/studio/web/pages/SchedulePage/components/WeekColumn.tsx +75 -0
  387. package/studio/web/pages/SchedulePage/components/WeekView.tsx +35 -0
  388. package/studio/web/pages/SchedulePage/index.tsx +285 -0
  389. package/studio/web/pages/SettingsPage/components/AgentAccounts.tsx +263 -0
  390. package/studio/web/pages/SettingsPage/components/AgentCategory.tsx +20 -0
  391. package/studio/web/pages/SettingsPage/components/ModelPicker.tsx +49 -0
  392. package/studio/web/pages/SettingsPage/components/ModelsSettings.tsx +138 -0
  393. package/studio/web/pages/SettingsPage/components/PlatformsCategory.tsx +23 -0
  394. package/studio/web/pages/SettingsPage/components/PublishingCategory/AccountStatus.tsx +29 -0
  395. package/studio/web/pages/SettingsPage/components/PublishingCategory/AutoPublishPanel.tsx +90 -0
  396. package/studio/web/pages/SettingsPage/components/PublishingCategory/BrowserConnect.tsx +62 -0
  397. package/studio/web/pages/SettingsPage/components/PublishingCategory/CheckField.tsx +19 -0
  398. package/studio/web/pages/SettingsPage/components/PublishingCategory/InstagramPanel.tsx +113 -0
  399. package/studio/web/pages/SettingsPage/components/PublishingCategory/OmnichatSettingsPanel.tsx +146 -0
  400. package/studio/web/pages/SettingsPage/components/PublishingCategory/SaveBar.tsx +33 -0
  401. package/studio/web/pages/SettingsPage/components/PublishingCategory/SchedulePanel.tsx +132 -0
  402. package/studio/web/pages/SettingsPage/components/PublishingCategory/SettingsCard.tsx +47 -0
  403. package/studio/web/pages/SettingsPage/components/PublishingCategory/SettingsCardGroup.tsx +19 -0
  404. package/studio/web/pages/SettingsPage/components/PublishingCategory/TextPanel.tsx +55 -0
  405. package/studio/web/pages/SettingsPage/components/PublishingCategory/TiktokPanel.tsx +123 -0
  406. package/studio/web/pages/SettingsPage/components/PublishingCategory/YoutubePanel.tsx +100 -0
  407. package/studio/web/pages/SettingsPage/components/PublishingCategory/badges.tsx +15 -0
  408. package/studio/web/pages/SettingsPage/components/PublishingCategory/index.tsx +40 -0
  409. package/studio/web/pages/SettingsPage/components/PublishingCategory/types.ts +10 -0
  410. package/studio/web/pages/SettingsPage/components/UsageCategory.tsx +389 -0
  411. package/studio/web/pages/SettingsPage/components/VoiceProvidersCategory.tsx +97 -0
  412. package/studio/web/pages/SettingsPage/index.tsx +85 -0
  413. package/studio/web/pages/VideoPage/components/AudioPlayer.tsx +113 -0
  414. package/studio/web/pages/VideoPage/components/CaptionPositionEditor/AxisField.tsx +50 -0
  415. package/studio/web/pages/VideoPage/components/CaptionPositionEditor/CaptionList.tsx +56 -0
  416. package/studio/web/pages/VideoPage/components/CaptionPositionEditor/CaptionListItem.tsx +92 -0
  417. package/studio/web/pages/VideoPage/components/CaptionPositionEditor/CaptionMargins.tsx +57 -0
  418. package/studio/web/pages/VideoPage/components/CaptionPositionEditor/CaptionStyleFields.tsx +70 -0
  419. package/studio/web/pages/VideoPage/components/CaptionPositionEditor/CaptionTextForm.tsx +87 -0
  420. package/studio/web/pages/VideoPage/components/CaptionPositionEditor/ColorField.tsx +37 -0
  421. package/studio/web/pages/VideoPage/components/CaptionPositionEditor/MarginField.tsx +59 -0
  422. package/studio/web/pages/VideoPage/components/CaptionPositionEditor/NumberField.tsx +57 -0
  423. package/studio/web/pages/VideoPage/components/CaptionPositionEditor/PositionControls.tsx +140 -0
  424. package/studio/web/pages/VideoPage/components/CaptionPositionEditor/captionEditor.ts +215 -0
  425. package/studio/web/pages/VideoPage/components/CaptionPositionEditor/useCaptionPosition.ts +154 -0
  426. package/studio/web/pages/VideoPage/components/CarouselChangeRequest.tsx +72 -0
  427. package/studio/web/pages/VideoPage/components/CarouselCompositionStage.tsx +80 -0
  428. package/studio/web/pages/VideoPage/components/CarouselCreate.tsx +63 -0
  429. package/studio/web/pages/VideoPage/components/CarouselMissing.tsx +19 -0
  430. package/studio/web/pages/VideoPage/components/CarouselPublishStage.tsx +173 -0
  431. package/studio/web/pages/VideoPage/components/CarouselScriptStage.tsx +82 -0
  432. package/studio/web/pages/VideoPage/components/CarouselSlideCard.tsx +49 -0
  433. package/studio/web/pages/VideoPage/components/CarouselViewer.tsx +55 -0
  434. package/studio/web/pages/VideoPage/components/ChangedVoices.tsx +53 -0
  435. package/studio/web/pages/VideoPage/components/CompositionStage.tsx +78 -0
  436. package/studio/web/pages/VideoPage/components/CoverPicker.tsx +32 -0
  437. package/studio/web/pages/VideoPage/components/EditorStage.tsx +53 -0
  438. package/studio/web/pages/VideoPage/components/LineTakes.tsx +160 -0
  439. package/studio/web/pages/VideoPage/components/LineTextEditor.tsx +124 -0
  440. package/studio/web/pages/VideoPage/components/LineVoicePicker.tsx +37 -0
  441. package/studio/web/pages/VideoPage/components/MediaImage.tsx +25 -0
  442. package/studio/web/pages/VideoPage/components/MotionScriptStage.tsx +39 -0
  443. package/studio/web/pages/VideoPage/components/NarrationStage.tsx +70 -0
  444. package/studio/web/pages/VideoPage/components/PublishForm.tsx +387 -0
  445. package/studio/web/pages/VideoPage/components/PublishStage.tsx +76 -0
  446. package/studio/web/pages/VideoPage/components/PublishText.tsx +149 -0
  447. package/studio/web/pages/VideoPage/components/ScenesApprovalPanel.tsx +278 -0
  448. package/studio/web/pages/VideoPage/components/ScheduledInfo.tsx +72 -0
  449. package/studio/web/pages/VideoPage/components/ScriptApprovalPanel.tsx +189 -0
  450. package/studio/web/pages/VideoPage/components/ScriptStage.tsx +17 -0
  451. package/studio/web/pages/VideoPage/components/SequencePlayer.tsx +146 -0
  452. package/studio/web/pages/VideoPage/components/SoundTimeline/AddSound.tsx +117 -0
  453. package/studio/web/pages/VideoPage/components/SoundTimeline/AreaSelection.tsx +74 -0
  454. package/studio/web/pages/VideoPage/components/SoundTimeline/BedPanel.tsx +94 -0
  455. package/studio/web/pages/VideoPage/components/SoundTimeline/BedRow.tsx +45 -0
  456. package/studio/web/pages/VideoPage/components/SoundTimeline/CaptionOverlay.tsx +44 -0
  457. package/studio/web/pages/VideoPage/components/SoundTimeline/CaptionSaveBar.tsx +32 -0
  458. package/studio/web/pages/VideoPage/components/SoundTimeline/CaptionsPanel.tsx +22 -0
  459. package/studio/web/pages/VideoPage/components/SoundTimeline/ClipBlock.tsx +111 -0
  460. package/studio/web/pages/VideoPage/components/SoundTimeline/ClipPanel.tsx +76 -0
  461. package/studio/web/pages/VideoPage/components/SoundTimeline/CutsPanel.tsx +44 -0
  462. package/studio/web/pages/VideoPage/components/SoundTimeline/EditorTabBar.tsx +73 -0
  463. package/studio/web/pages/VideoPage/components/SoundTimeline/EffectChoice.tsx +46 -0
  464. package/studio/web/pages/VideoPage/components/SoundTimeline/EffectItem.tsx +89 -0
  465. package/studio/web/pages/VideoPage/components/SoundTimeline/EffectPanel.tsx +232 -0
  466. package/studio/web/pages/VideoPage/components/SoundTimeline/EffectsRow.tsx +21 -0
  467. package/studio/web/pages/VideoPage/components/SoundTimeline/Lane.tsx +39 -0
  468. package/studio/web/pages/VideoPage/components/SoundTimeline/MarkerRow.tsx +40 -0
  469. package/studio/web/pages/VideoPage/components/SoundTimeline/MuteButton.tsx +36 -0
  470. package/studio/web/pages/VideoPage/components/SoundTimeline/NumberInput.tsx +37 -0
  471. package/studio/web/pages/VideoPage/components/SoundTimeline/PlanWarnings.tsx +19 -0
  472. package/studio/web/pages/VideoPage/components/SoundTimeline/PlayerResizeHandle.tsx +50 -0
  473. package/studio/web/pages/VideoPage/components/SoundTimeline/PreviewControls.tsx +51 -0
  474. package/studio/web/pages/VideoPage/components/SoundTimeline/SceneRow.tsx +54 -0
  475. package/studio/web/pages/VideoPage/components/SoundTimeline/SettingsPanel.tsx +65 -0
  476. package/studio/web/pages/VideoPage/components/SoundTimeline/SoundLibrary.tsx +99 -0
  477. package/studio/web/pages/VideoPage/components/SoundTimeline/SoundPreview.tsx +82 -0
  478. package/studio/web/pages/VideoPage/components/SoundTimeline/SpeedPanel.tsx +128 -0
  479. package/studio/web/pages/VideoPage/components/SoundTimeline/TimelineDock.tsx +56 -0
  480. package/studio/web/pages/VideoPage/components/SoundTimeline/TimelineFooter.tsx +26 -0
  481. package/studio/web/pages/VideoPage/components/SoundTimeline/TimelineSkeleton.tsx +21 -0
  482. package/studio/web/pages/VideoPage/components/SoundTimeline/TimelineToolbar.tsx +160 -0
  483. package/studio/web/pages/VideoPage/components/SoundTimeline/TimelineTracks.tsx +121 -0
  484. package/studio/web/pages/VideoPage/components/SoundTimeline/TrackSidebar.tsx +30 -0
  485. package/studio/web/pages/VideoPage/components/SoundTimeline/VoiceRow.tsx +53 -0
  486. package/studio/web/pages/VideoPage/components/SoundTimeline/Waveform.tsx +97 -0
  487. package/studio/web/pages/VideoPage/components/SoundTimeline/clipActions.ts +50 -0
  488. package/studio/web/pages/VideoPage/components/SoundTimeline/effectActions.ts +161 -0
  489. package/studio/web/pages/VideoPage/components/SoundTimeline/index.tsx +161 -0
  490. package/studio/web/pages/VideoPage/components/SoundTimeline/timelineGeometry.ts +45 -0
  491. package/studio/web/pages/VideoPage/components/SoundTimeline/types.ts +43 -0
  492. package/studio/web/pages/VideoPage/components/SoundTimeline/useAutosave.ts +107 -0
  493. package/studio/web/pages/VideoPage/components/SoundTimeline/useEditorShortcuts.ts +168 -0
  494. package/studio/web/pages/VideoPage/components/SoundTimeline/useFitHeight.ts +40 -0
  495. package/studio/web/pages/VideoPage/components/SoundTimeline/useFollowPlayhead.ts +21 -0
  496. package/studio/web/pages/VideoPage/components/SoundTimeline/useListenMix.ts +43 -0
  497. package/studio/web/pages/VideoPage/components/SoundTimeline/usePlayback.ts +231 -0
  498. package/studio/web/pages/VideoPage/components/SoundTimeline/usePlayerWidth.ts +27 -0
  499. package/studio/web/pages/VideoPage/components/SoundTimeline/useSoundData.ts +124 -0
  500. package/studio/web/pages/VideoPage/components/SoundTimeline/useSoundTimeline.ts +511 -0
  501. package/studio/web/pages/VideoPage/components/StageDrawer/CompareVersions.tsx +111 -0
  502. package/studio/web/pages/VideoPage/components/StageDrawer/VersionCard.tsx +81 -0
  503. package/studio/web/pages/VideoPage/components/StageDrawer/VersionMedia.tsx +24 -0
  504. package/studio/web/pages/VideoPage/components/StageDrawer/VersionText.tsx +15 -0
  505. package/studio/web/pages/VideoPage/components/StageDrawer/index.tsx +109 -0
  506. package/studio/web/pages/VideoPage/components/StageProcess.tsx +126 -0
  507. package/studio/web/pages/VideoPage/components/StageResult.tsx +69 -0
  508. package/studio/web/pages/VideoPage/components/SwapReference.tsx +92 -0
  509. package/studio/web/pages/VideoPage/components/ThumbnailPanel.tsx +252 -0
  510. package/studio/web/pages/VideoPage/components/TimingPanel.tsx +89 -0
  511. package/studio/web/pages/VideoPage/components/VideoPlayer.tsx +18 -0
  512. package/studio/web/pages/VideoPage/components/VideoSkeleton.tsx +50 -0
  513. package/studio/web/pages/VideoPage/components/VideoVoiceInfo.tsx +33 -0
  514. package/studio/web/pages/VideoPage/components/VideoVoicePicker.tsx +165 -0
  515. package/studio/web/pages/VideoPage/components/VoicePauseBanner.tsx +56 -0
  516. package/studio/web/pages/VideoPage/components/WithLogo.tsx +10 -0
  517. package/studio/web/pages/VideoPage/index.tsx +295 -0
  518. package/studio/web/pages/VideosPage/components/PublishedOn.tsx +85 -0
  519. package/studio/web/pages/VideosPage/components/ServicesPanel.tsx +81 -0
  520. package/studio/web/pages/VideosPage/components/SessionsPanel.tsx +62 -0
  521. package/studio/web/pages/VideosPage/components/VideoListSkeleton.tsx +25 -0
  522. package/studio/web/pages/VideosPage/components/VideoRow.tsx +122 -0
  523. package/studio/web/pages/VideosPage/index.tsx +134 -0
  524. package/studio/web/pages/VoicePage/index.tsx +28 -0
  525. package/studio/web/router.tsx +201 -0
  526. package/studio/web/styles/base/elements.css +55 -0
  527. package/studio/web/styles/base/utilities.css +25 -0
  528. package/studio/web/styles/components/badge.css +33 -0
  529. package/studio/web/styles/components/claude-running.css +27 -0
  530. package/studio/web/styles/components/claude-session.css +212 -0
  531. package/studio/web/styles/components/claude-walking.css +92 -0
  532. package/studio/web/styles/components/clone-voice-save.css +136 -0
  533. package/studio/web/styles/components/clone-voice.css +379 -0
  534. package/studio/web/styles/components/controls.css +70 -0
  535. package/studio/web/styles/components/data-and-image.css +54 -0
  536. package/studio/web/styles/components/forms.css +132 -0
  537. package/studio/web/styles/components/human-banner.css +99 -0
  538. package/studio/web/styles/components/lightbox.css +50 -0
  539. package/studio/web/styles/components/markdown.css +68 -0
  540. package/studio/web/styles/components/modal.css +70 -0
  541. package/studio/web/styles/components/omni-cards.css +105 -0
  542. package/studio/web/styles/components/omni.css +859 -0
  543. package/studio/web/styles/components/omnichat.css +538 -0
  544. package/studio/web/styles/components/panel.css +115 -0
  545. package/studio/web/styles/components/platform-card.css +140 -0
  546. package/studio/web/styles/components/provider-card.css +132 -0
  547. package/studio/web/styles/components/queue.css +185 -0
  548. package/studio/web/styles/components/reference-actions.css +133 -0
  549. package/studio/web/styles/components/reference-picker.css +146 -0
  550. package/studio/web/styles/components/results-drawer.css +224 -0
  551. package/studio/web/styles/components/segmented.css +115 -0
  552. package/studio/web/styles/components/side-panel-tab.css +23 -0
  553. package/studio/web/styles/components/status-signals.css +66 -0
  554. package/studio/web/styles/components/stepper.css +208 -0
  555. package/studio/web/styles/components/table.css +19 -0
  556. package/studio/web/styles/components/tabs.css +24 -0
  557. package/studio/web/styles/components/ui/accordion.css +71 -0
  558. package/studio/web/styles/components/ui/chart.css +162 -0
  559. package/studio/web/styles/components/ui/checkbox.css +37 -0
  560. package/studio/web/styles/components/ui/date-picker.css +153 -0
  561. package/studio/web/styles/components/ui/select.css +110 -0
  562. package/studio/web/styles/components/ui/skeleton.css +25 -0
  563. package/studio/web/styles/components/ui/slider.css +81 -0
  564. package/studio/web/styles/components/ui/switch.css +41 -0
  565. package/studio/web/styles/components/ui/tooltip.css +24 -0
  566. package/studio/web/styles/components/voice-carousel.css +255 -0
  567. package/studio/web/styles/components/voice-editor.css +179 -0
  568. package/studio/web/styles/components/voice-grid.css +56 -0
  569. package/studio/web/styles/components/voice-profiles.css +192 -0
  570. package/studio/web/styles/design-system.md +136 -0
  571. package/studio/web/styles/index.css +116 -0
  572. package/studio/web/styles/layout/page.css +41 -0
  573. package/studio/web/styles/layout/sidebar-groups.css +39 -0
  574. package/studio/web/styles/layout/sidebar.css +111 -0
  575. package/studio/web/styles/pages/agent.css +26 -0
  576. package/studio/web/styles/pages/analytics/dashboard.css +614 -0
  577. package/studio/web/styles/pages/analytics/insights.css +48 -0
  578. package/studio/web/styles/pages/getting-started.css +166 -0
  579. package/studio/web/styles/pages/new-video/form.css +32 -0
  580. package/studio/web/styles/pages/not-found.css +59 -0
  581. package/studio/web/styles/pages/references.css +204 -0
  582. package/studio/web/styles/pages/schedule/calendar.css +541 -0
  583. package/studio/web/styles/pages/schedule/chip-states.css +60 -0
  584. package/studio/web/styles/pages/settings/models.css +72 -0
  585. package/studio/web/styles/pages/settings/shell.css +361 -0
  586. package/studio/web/styles/pages/settings/usage.css +63 -0
  587. package/studio/web/styles/pages/studio-offline.css +105 -0
  588. package/studio/web/styles/pages/video/caption-controls.css +194 -0
  589. package/studio/web/styles/pages/video/captions-grid.css +70 -0
  590. package/studio/web/styles/pages/video/captions.css +79 -0
  591. package/studio/web/styles/pages/video/editor-chat.css +103 -0
  592. package/studio/web/styles/pages/video/export.css +50 -0
  593. package/studio/web/styles/pages/video/frames.css +29 -0
  594. package/studio/web/styles/pages/video/fullscreen-stage.css +400 -0
  595. package/studio/web/styles/pages/video/instagram-carousel.css +309 -0
  596. package/studio/web/styles/pages/video/layout.css +285 -0
  597. package/studio/web/styles/pages/video/narration.css +138 -0
  598. package/studio/web/styles/pages/video/preview-overview.css +11 -0
  599. package/studio/web/styles/pages/video/processing-notice.css +34 -0
  600. package/studio/web/styles/pages/video/publish-when.css +76 -0
  601. package/studio/web/styles/pages/video/publish.css +97 -0
  602. package/studio/web/styles/pages/video/result.css +96 -0
  603. package/studio/web/styles/pages/video/scenes.css +167 -0
  604. package/studio/web/styles/pages/video/sound-timeline.css +672 -0
  605. package/studio/web/styles/pages/video/sound-toolbar.css +149 -0
  606. package/studio/web/styles/pages/video/take-player.css +100 -0
  607. package/studio/web/styles/pages/video/thumbnail.css +157 -0
  608. package/studio/web/styles/pages/video/timeline.css +117 -0
  609. package/studio/web/styles/pages/video/voice-picker.css +14 -0
  610. package/studio/web/styles/pages/video-list/active-row.css +27 -0
  611. package/studio/web/styles/pages/video-list/card-link.css +49 -0
  612. package/studio/web/styles/pages/video-list/list.css +86 -0
  613. package/studio/web/styles/theme/identity/details.css +204 -0
  614. package/studio/web/styles/theme/identity/shell.css +249 -0
  615. package/studio/web/styles/theme/identity/surfaces.css +183 -0
  616. package/studio/web/styles/theme/identity/titles-and-buttons.css +120 -0
  617. package/studio/web/styles/theme/no-borders/blocks.css +208 -0
  618. package/studio/web/styles/theme/no-borders/controls.css +256 -0
  619. package/studio/web/styles/tokens.css +245 -0
  620. package/studio/web/types/api.ts +2 -0
  621. package/studio/web/types/assets.d.ts +9 -0
  622. package/templates/brief.md +27 -0
  623. package/templates/captions.html +204 -0
  624. package/templates/carousel-composition.md +47 -0
  625. package/templates/carousel-script.md +60 -0
  626. package/templates/components/voiceavatar/README.md +87 -0
  627. package/templates/components/voiceavatar/demo.html +50 -0
  628. package/templates/components/voiceavatar/voiceavatar.js +415 -0
  629. package/templates/motion.md +80 -0
  630. package/templates/playbook.md +43 -0
  631. package/templates/reference.md +123 -0
  632. package/templates/scene.html +112 -0
  633. package/templates/scenes.example.json +96 -0
  634. package/templates/script.md +63 -0
  635. package/templates/slide.html +71 -0
  636. package/templates/wireframe.html +91 -0
  637. package/tools/analysis.mjs +300 -0
  638. package/tools/approval.mjs +442 -0
  639. package/tools/assemble.mjs +942 -0
  640. package/tools/build-motion.mjs +437 -0
  641. package/tools/captions.mjs +246 -0
  642. package/tools/carousel.mjs +558 -0
  643. package/tools/compose.mjs +307 -0
  644. package/tools/cuts.mjs +74 -0
  645. package/tools/editor.mjs +419 -0
  646. package/tools/epidemic-capture.js +54 -0
  647. package/tools/fanout.mjs +69 -0
  648. package/tools/history.mjs +70 -0
  649. package/tools/history.py +61 -0
  650. package/tools/job-queue.mjs +180 -0
  651. package/tools/job_queue.py +430 -0
  652. package/tools/lib/agents.mjs +412 -0
  653. package/tools/lib/channel.mjs +399 -0
  654. package/tools/lib/claude-settings.mjs +67 -0
  655. package/tools/lib/cli.mjs +9 -0
  656. package/tools/lib/fanout.mjs +234 -0
  657. package/tools/lib/http.mjs +94 -0
  658. package/tools/lib/index.js +14 -0
  659. package/tools/lib/mcp-server.mjs +69 -0
  660. package/tools/lib/metrics/metrics.mjs +204 -0
  661. package/tools/lib/narration.mjs +15 -0
  662. package/tools/lib/oauth-client.mjs +295 -0
  663. package/tools/lib/omnichat/config.mjs +139 -0
  664. package/tools/lib/omnichat/errors.mjs +42 -0
  665. package/tools/lib/omnichat/extract.mjs +82 -0
  666. package/tools/lib/omnichat/gate.mjs +151 -0
  667. package/tools/lib/omnichat/manual.mjs +166 -0
  668. package/tools/lib/omnichat/match.mjs +78 -0
  669. package/tools/lib/omnichat/pass.mjs +393 -0
  670. package/tools/lib/omnichat/record.mjs +208 -0
  671. package/tools/lib/omnichat/text.mjs +315 -0
  672. package/tools/lib/omnichat/webhook.mjs +166 -0
  673. package/tools/lib/pairing.mjs +99 -0
  674. package/tools/lib/paths.mjs +41 -0
  675. package/tools/lib/platforms/instagram.mjs +469 -0
  676. package/tools/lib/platforms/tiktok.mjs +343 -0
  677. package/tools/lib/platforms/youtube.mjs +228 -0
  678. package/tools/lib/publish/publish.mjs +244 -0
  679. package/tools/lib/schedule/schedule.mjs +191 -0
  680. package/tools/lib/service.mjs +134 -0
  681. package/tools/lib/skill-client.mjs +126 -0
  682. package/tools/lib/skills-release.mjs +196 -0
  683. package/tools/lib/testing.mjs +52 -0
  684. package/tools/lib/up.mjs +187 -0
  685. package/tools/metrics.mjs +415 -0
  686. package/tools/models.mjs +134 -0
  687. package/tools/narrate.mjs +257 -0
  688. package/tools/omni.mjs +298 -0
  689. package/tools/omni_paths.py +12 -0
  690. package/tools/omnichat.mjs +1577 -0
  691. package/tools/omnivideo.py +1122 -0
  692. package/tools/package.mjs +130 -0
  693. package/tools/platforms.mjs +236 -0
  694. package/tools/premiere.mjs +533 -0
  695. package/tools/publish.mjs +1496 -0
  696. package/tools/reference.mjs +1408 -0
  697. package/tools/render.mjs +426 -0
  698. package/tools/runner.mjs +156 -0
  699. package/tools/setup.mjs +466 -0
  700. package/tools/skills-release.mjs +93 -0
  701. package/tools/sound.mjs +1323 -0
  702. package/tools/soundtrack.mjs +217 -0
  703. package/tools/thumbnail.mjs +271 -0
  704. package/tools/timing.mjs +108 -0
  705. package/tools/usage.py +628 -0
  706. package/tools/voice-envelope.mjs +185 -0
  707. package/tools/voice.py +448 -0
  708. package/tools/voice_sample.py +75 -0
  709. package/tools/wireframe.mjs +230 -0
@@ -0,0 +1,1323 @@
1
+ #!/usr/bin/env node
2
+ /* ============================================================================
3
+ Sound effects: on beats, on transitions, and the video's bed.
4
+
5
+ node tools/sound.mjs capture [n] prints the capture code (Chrome)
6
+ node tools/sound.mjs download videos/<slug> <url> [name] [--search <term>]
7
+ node tools/sound.mjs download kit <url> <name> --role <role> [--search <term>]
8
+ node tools/sound.mjs kit [role|text] lists the global kit by role
9
+ node tools/sound.mjs kit adopt videos/<slug> <name> --role <role> [--as <name-in-kit>]
10
+ moves an effect captured for the video into the kit
11
+ node tools/sound.mjs effects videos/<slug> [text] lists the effects captured for the video
12
+ node tools/sound.mjs videos/<slug> prepares the video's sound/
13
+
14
+ Every effect comes from Epidemic Sound, through one of two registries: the
15
+ global kit (sound.kit in the config, kit/sfx/kit.json), curated once and used
16
+ by every video, each effect with a role (click, pop, tick, typing, confirm,
17
+ whoosh, transition, riser, impact, glitch, bed, texture); and the video's
18
+ sfx/ (sfx/sources.json), for what the kit lacks. No other audio saved on the
19
+ computer gets in. With a search open in Chrome (sound.source.search in the
20
+ config), the `capture` code plays the previews and returns each one's URL
21
+ (audiocdn.epidemicsound.com/lqmp3/<id>.mp3); `download` writes the MP3 to
22
+ the kit or the video's sfx/ and records the origin. An effect outside both
23
+ registries is refused. The name is looked up in the video first, then in the
24
+ kit. From the kit, the effect brings its role's volume (sound.roles in the
25
+ config, or its own `db` in kit.json), added to the sound's `db`.
26
+
27
+ Where sound goes in scenes.json:
28
+ beat.sound at the beat's instant
29
+ scene.transition.sound at the cut that opens the scene ({ "sound": … }, with or without continues)
30
+ bed at the root: one track under the video (music or texture)
31
+
32
+ `sound` is a file name in sfx/, an object with adjustments, or a list of
33
+ them (layers at the same instant):
34
+
35
+ "sound": "clique-seco.mp3"
36
+ "sound": { "name": "whoosh-curto.mp3", "offset": 0.3, "db": -3 }
37
+ "sound": [{ "name": "whoosh.mp3", "anchor": "start", "dur": 1.8 },
38
+ { "name": "impacto.mp3", "offset": 1.8 }]
39
+
40
+ name file in sfx/
41
+ anchor "peak" (default) or "start": which point of the effect lands on the instant
42
+ offset seconds after the instant (negative: before)
43
+ db adjustment over the default level (sound.level_db)
44
+ trim [from, to] in seconds inside the file; `to` null = up to the
45
+ audible tail (the default is the whole file up to it)
46
+ fade [in, out] in seconds
47
+ dur stretches or shrinks the trim to last this long (pitch kept)
48
+ speed speed factor (1.5 = faster); `dur` takes precedence
49
+ pitch semitones (+ higher, − lower), without changing duration
50
+ duck dB of ducking under the voice for this effect only (false: none)
51
+
52
+ bed: { "name", "db", "from", "to", "trim", "fade", "loop", "duck", "speed" }:
53
+ `from`/`to` in seconds of video (default: the whole video), `loop` repeats
54
+ the trim until it covers (texture; not music), `speed` (0.5 to 2) plays the
55
+ music faster with its pitch kept; it is the bed's own, apart from the
56
+ export speed, which never touches the bed.
57
+
58
+ Volume: the effect is measured after being trimmed and processed, by
59
+ perceived loudness (BS.1770 K-weighting over a sound.window_s window), not
60
+ by peak. With `db` 0 it sits sound.level_db below the video voice's
61
+ integrated loudness; the bed, sound.bed.level_db below, measured by its own
62
+ integrated loudness. While the voice speaks, effects drop sound.ducking.db
63
+ and the bed sound.bed.ducking_db. Since assembly normalizes the mix by the
64
+ voice, the final effect level is the same from one video to another.
65
+
66
+ Per-line level: with sound.voice_target_lufs, each line is measured
67
+ (integrated loudness) and brought to that level by a gain applied in the mix
68
+ (assembly, preview and sound/preview.wav); the file in voice/ does not change.
69
+ The gain is limited to ±sound.voice_max_gain_db and never pushes the peak above
70
+ sound.max_peak_db. This keeps Fish, Cartesia and VoiceStudio, or a redone line,
71
+ at the same loudness inside the video; assembly's −14 LUFS stays a single gain at the end.
72
+
73
+ Writes sound/<id>.wav for each effect, already trimmed, processed, leveled
74
+ and ducked (id: <scene>-<element>, <scene>-transition, -2, -3… for layers),
75
+ sound/bed.wav, and sound/plan.json with the second each one starts. Premiere
76
+ only places them (premiere.mjs assemble, track `sound.premiere_track`, the
77
+ bed on the next one). sound/preview.wav mixes voice, effects and bed in final
78
+ timing. From the command line, preparing sound/ waits for the machine's
79
+ queue turn (tools/job-queue.mjs).
80
+ ============================================================================ */
81
+
82
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from "node:fs";
83
+ import { spawnSync } from "node:child_process";
84
+ import { join, resolve, basename, relative } from "node:path";
85
+ import { fileURLToPath } from "node:url";
86
+ import { loadVideo, beatTime } from "./timing.mjs";
87
+ import { animationStart, animationInstant, voiceSpans, lineClips, sanitizeClips } from "./soundtrack.mjs";
88
+ import { turn } from "./job-queue.mjs";
89
+ import { isNarrated } from "./lib/narration.mjs";
90
+ import { keepPrevious } from "./history.mjs";
91
+ import { normalizeCuts, removedDuration, toEdited } from "./cuts.mjs";
92
+ import { CODE_ROOT, isMainModule, DATA_ROOT as ROOT } from "./lib/paths.mjs";
93
+
94
+ const CFG = JSON.parse(readFileSync(join(ROOT, "omnivideo.config.json"), "utf8"));
95
+ const S = {
96
+ level_db: -14,
97
+ default_voice_lufs: -18,
98
+ voice_target_lufs: null,
99
+ voice_max_gain_db: 6,
100
+ window_s: 0.2,
101
+ max_peak_db: -1,
102
+ ...CFG.sound,
103
+ ducking: { db: -6, before: 0.06, after: 0.25, min_gap: 0.35, ...CFG.sound?.ducking },
104
+ bed: { level_db: -24, ducking_db: -8, fade: [1, 1.5], ...CFG.sound?.bed },
105
+ };
106
+ /* role of each kit effect: the default volume (added to the sound's `db`) and
107
+ whether it can be stretched (whoosh yes, impact no) */
108
+ const ROLES = {
109
+ click: { db: -4 },
110
+ pop: { db: -4 },
111
+ tick: { db: -8 },
112
+ typing: { db: -8 },
113
+ confirm: { db: -4 },
114
+ whoosh: { db: 0, stretchable: true },
115
+ transition: { db: 0, stretchable: true },
116
+ riser: { db: 0, stretchable: true },
117
+ impact: { db: -2 },
118
+ glitch: { db: -4 },
119
+ bed: { db: 0 },
120
+ texture: { db: 0, stretchable: true },
121
+ ...CFG.sound?.roles,
122
+ };
123
+ const KIT = resolve(ROOT, CFG.sound?.kit ?? "kit/sfx");
124
+ const CDN = /^https:\/\/audiocdn\.epidemicsound\.com\/lqmp3\/([0-9A-Za-z]+)\.mp3$/;
125
+ const NAME = /^[a-z0-9][a-z0-9._-]*\.mp3$/i;
126
+ const MUSIC_S = 30; /* above this it is a soundtrack, not an effect (the bed may) */
127
+ const RATE = 48000;
128
+
129
+ function run(bin, args, binary = false) {
130
+ const r = spawnSync(bin, args, { maxBuffer: 1 << 30, encoding: binary ? "buffer" : "utf8" });
131
+ if (r.status !== 0) throw new Error(`${bin}: ${String(r.stderr).slice(-500)}`);
132
+ return r.stdout;
133
+ }
134
+
135
+ /* --- decode --------------------------------------------------------------------
136
+ Interleaved stereo, 48 kHz, float: the same format as the WAV that goes to
137
+ the timeline (with less bandwidth, a click's high transient disappears and the
138
+ "peak" becomes the body of the sound, tens of ms later). Trim with atrim, never
139
+ -ss before -i: seeking in an MP3 discards the encoder delay differently from
140
+ decoding from the start, and the peak lands ~23 ms off. */
141
+ function atempo(f) {
142
+ const out = [];
143
+ while (f > 2) {
144
+ out.push(2);
145
+ f /= 2;
146
+ }
147
+ while (f < 0.5) {
148
+ out.push(0.5);
149
+ f /= 0.5;
150
+ }
151
+ if (Math.abs(f - 1) > 1e-4) out.push(f);
152
+ return out.map(x => `atempo=${+x.toFixed(6)}`);
153
+ }
154
+
155
+ /* cached by file, mtime and adjustments (the studio timeline replans on every
156
+ change); callers never modify the buffer in place, trimming always copies */
157
+ const decodeCache = new Map();
158
+ function decode(file, options = {}) {
159
+ const k = `${file}@${statSync(file).mtimeMs}@${JSON.stringify(options)}`;
160
+ if (decodeCache.has(k)) {
161
+ const x = decodeCache.get(k);
162
+ decodeCache.delete(k);
163
+ decodeCache.set(k, x);
164
+ return x;
165
+ }
166
+ const x = decodeNow(file, options);
167
+ decodeCache.set(k, x);
168
+ if (decodeCache.size > 48) decodeCache.delete(decodeCache.keys().next().value);
169
+ return x;
170
+ }
171
+
172
+ function decodeNow(file, { from = 0, to = null, speed = 1, pitch = 0 } = {}) {
173
+ const r = 2 ** (pitch / 12);
174
+ const af = [`aresample=${RATE}`];
175
+ if (from > 0 || to != null) af.push(`atrim=start=${from}${to != null ? `:end=${to}` : ""}`, "asetpts=PTS-STARTPTS");
176
+ if (pitch) af.push(`asetrate=${Math.round(RATE * r)}`, `aresample=${RATE}`);
177
+ af.push(...atempo(speed / r));
178
+ const raw = run(
179
+ "ffmpeg",
180
+ [
181
+ "-hide_banner",
182
+ "-loglevel",
183
+ "error",
184
+ "-i",
185
+ file,
186
+ "-af",
187
+ af.join(","),
188
+ "-ac",
189
+ "2",
190
+ "-ar",
191
+ String(RATE),
192
+ "-f",
193
+ "f32le",
194
+ "-",
195
+ ],
196
+ true,
197
+ );
198
+ return new Float32Array(raw.buffer.slice(raw.byteOffset, raw.byteOffset + Math.floor(raw.length / 4) * 4));
199
+ }
200
+
201
+ /* --- perceived loudness (BS.1770) -------------------------------------------------
202
+ K-weighting at 48 kHz, power per frame summed over both channels. */
203
+ const K = [
204
+ { b: [1.53512485958697, -2.69169618940638, 1.19839281085285], a: [-1.69065929318241, 0.73248077421585] },
205
+ { b: [1, -2, 1], a: [-1.99004745483398, 0.99007225036621] },
206
+ ];
207
+ function kPower(x) {
208
+ const n = x.length >> 1;
209
+ const p = new Float64Array(n);
210
+ for (let c = 0; c < 2; c++) {
211
+ let y = new Float64Array(n);
212
+ for (let i = 0; i < n; i++) y[i] = x[2 * i + c];
213
+ for (const { b, a } of K) {
214
+ const z = new Float64Array(n);
215
+ let x1 = 0,
216
+ x2 = 0,
217
+ y1 = 0,
218
+ y2 = 0;
219
+ for (let i = 0; i < n; i++) {
220
+ const v = b[0] * y[i] + b[1] * x1 + b[2] * x2 - a[0] * y1 - a[1] * y2;
221
+ x2 = x1;
222
+ x1 = y[i];
223
+ y2 = y1;
224
+ y1 = v;
225
+ z[i] = v;
226
+ }
227
+ y = z;
228
+ }
229
+ for (let i = 0; i < n; i++) p[i] += y[i] * y[i];
230
+ }
231
+ return p;
232
+ }
233
+ const lufs = ms => (ms > 0 ? -0.691 + 10 * Math.log10(ms) : -Infinity);
234
+
235
+ /* loudest sliding window; a sound shorter than the window counts as followed by
236
+ silence, which is how the ear integrates a click */
237
+ function maxLoudness(x, window = S.window_s) {
238
+ const p = kPower(x);
239
+ const w = Math.max(1, Math.round(window * RATE));
240
+ let sum = 0,
241
+ max = 0;
242
+ for (let i = 0; i < p.length; i++) {
243
+ sum += p[i];
244
+ if (i >= w) sum -= p[i - w];
245
+ if (sum > max) max = sum;
246
+ }
247
+ return lufs(max / w);
248
+ }
249
+
250
+ /* integrated loudness: 400 ms blocks with 100 ms step, −70 LUFS and −10 LU gates */
251
+ function integratedLoudness(x) {
252
+ const p = kPower(x);
253
+ const w = Math.round(0.4 * RATE),
254
+ step = Math.round(0.1 * RATE);
255
+ const blocks = [];
256
+ for (let i = 0; i + w <= p.length; i += step) {
257
+ let s = 0;
258
+ for (let k = i; k < i + w; k++) s += p[k];
259
+ blocks.push(s / w);
260
+ }
261
+ if (!blocks.length) return maxLoudness(x, 0.4);
262
+ const abs = blocks.filter(b => lufs(b) > -70);
263
+ if (!abs.length) return -Infinity;
264
+ const rel = lufs(abs.reduce((s, b) => s + b, 0) / abs.length) - 10;
265
+ const kept = abs.filter(b => lufs(b) > rel);
266
+ return lufs(kept.reduce((s, b) => s + b, 0) / kept.length);
267
+ }
268
+
269
+ /* --- per-line level (see the header) ----------------------------------------------- */
270
+ const levelCache = new Map();
271
+ export function lineGain(file) {
272
+ const target = S.voice_target_lufs;
273
+ if (!Number.isFinite(target) || !existsSync(file)) return 0;
274
+ const k = `${file}@${statSync(file).mtimeMs}`;
275
+ if (!levelCache.has(k)) {
276
+ const x = decode(file);
277
+ levelCache.set(k, { lufs: integratedLoudness(x), peak_db: 20 * Math.log10(peakOf(x).amp) });
278
+ }
279
+ const { lufs: v, peak_db } = levelCache.get(k);
280
+ if (!Number.isFinite(v)) return 0;
281
+ const max = S.voice_max_gain_db;
282
+ const g = Math.min(Math.max(-max, Math.min(max, target - v)), Math.max(0, S.max_peak_db - peak_db));
283
+ return +g.toFixed(2);
284
+ }
285
+
286
+ function peakOf(x) {
287
+ let ip = 0;
288
+ for (let i = 1; i < x.length; i++) if (Math.abs(x[i]) > Math.abs(x[ip])) ip = i;
289
+ return { s: Math.floor(ip / 2) / RATE, amp: Math.abs(x[ip]) || 1e-9 };
290
+ }
291
+
292
+ /* --- measure an effect file ---------------------------------------------------
293
+ peak: the instant of the largest sample. useful_end: after it the sound stays
294
+ 40 dB below the peak until the end (inaudible tail). volume: the loudest
295
+ perceived level (sound.window_s window) up to the useful end. */
296
+ const cache = new Map();
297
+ export function measureEffect(file) {
298
+ if (cache.has(file)) return cache.get(file);
299
+ const fmt = JSON.parse(run("ffprobe", ["-v", "error", "-print_format", "json", "-show_format", file])).format;
300
+ const duration = +fmt.duration;
301
+ const title = fmt.tags?.title ?? fmt.tags?.TITLE ?? "";
302
+ if (duration > MUSIC_S) {
303
+ const m = { duration, title, music: true };
304
+ cache.set(file, m);
305
+ return m;
306
+ }
307
+ const x = decode(file);
308
+ const { s: peak_s, amp } = peakOf(x);
309
+ let end = x.length - 1;
310
+ while (end > peak_s * RATE * 2 && Math.abs(x[end]) < amp * 0.01) end--;
311
+ const useful_end_s = +Math.min(duration, Math.floor(end / 2) / RATE + 0.05).toFixed(3);
312
+ const m = {
313
+ duration,
314
+ title,
315
+ music: false,
316
+ peak_s: +peak_s.toFixed(4),
317
+ peak_db: +(20 * Math.log10(amp)).toFixed(1),
318
+ useful_end_s,
319
+ volume_lufs: +maxLoudness(x.subarray(0, Math.round(useful_end_s * RATE) * 2)).toFixed(1),
320
+ };
321
+ cache.set(file, m);
322
+ return m;
323
+ }
324
+
325
+ /* --- where an effect comes from --------------------------------------------------
326
+ Only from Epidemic, through two registries: the global kit (sound.kit in the
327
+ config, kit/sfx/ with kit.json), curated once and used by every video, and
328
+ the video's sfx/ (sfx/sources.json), for sound the kit lacks. The name is
329
+ looked up in the video first, then in the kit. */
330
+ const effectsDir = dir => join(resolve(dir), "sfx");
331
+ const readJson = f => (existsSync(f) ? JSON.parse(readFileSync(f, "utf8")) : {});
332
+ const sources = dir => readJson(join(effectsDir(dir), "sources.json"));
333
+ const kitSources = () => readJson(join(KIT, "kit.json"));
334
+
335
+ export function effectFile(dir, name) {
336
+ if (typeof name !== "string" || !NAME.test(name) || basename(name) !== name)
337
+ return { error: `efeito “${name}”: o nome é um MP3 do sfx/ do vídeo ou do kit (ex.: clique-seco.mp3)` };
338
+ const [from, source, where] = sources(dir)[name]
339
+ ? [effectsDir(dir), sources(dir)[name], "sfx/sources.json"]
340
+ : kitSources()[name]
341
+ ? [KIT, kitSources()[name], "o kit"]
342
+ : [];
343
+ if (!from)
344
+ return {
345
+ error: `efeito “${name}” não está no kit nem foi capturado da Epidemic para este vídeo (falta em sfx/sources.json): use um do kit (sound.mjs kit) ou capture com sound.mjs capture e download`,
346
+ };
347
+ const file = join(from, name);
348
+ if (!existsSync(file))
349
+ return { error: `efeito “${name}” está em ${where}, mas o arquivo sumiu: rode sound.mjs download de novo` };
350
+ return { file, kit: from === KIT ? source : null };
351
+ }
352
+
353
+ /* `download` destination: a video dir or the kit ("kit", with --role) */
354
+ function destination(videoDir, role) {
355
+ if (videoDir === "kit") {
356
+ if (!ROLES[role]) throw new Error(`no kit, --role é obrigatório: ${Object.keys(ROLES).join(", ")}`);
357
+ return { dir: KIT, registry: join(KIT, "kit.json"), extra: { role } };
358
+ }
359
+ const dir = resolve(videoDir ?? "");
360
+ if (!existsSync(join(dir, "scenes.json")))
361
+ throw new Error(`${dir} não é a pasta de um vídeo (falta scenes.json) nem "kit"`);
362
+ return { dir: effectsDir(dir), registry: join(effectsDir(dir), "sources.json"), extra: {} };
363
+ }
364
+
365
+ async function download(videoDir, url, name, search, role) {
366
+ const { dir, registry, extra } = destination(videoDir, role);
367
+ const m = CDN.exec(url ?? "");
368
+ if (!m)
369
+ throw new Error(
370
+ `só URL da Epidemic capturada no player (https://audiocdn.epidemicsound.com/lqmp3/<id>.mp3), não “${url}”`,
371
+ );
372
+ name = `${(name ?? m[1]).replace(/\.mp3$/i, "")}.mp3`;
373
+ if (!NAME.test(name)) throw new Error(`nome “${name}”: letras, números, ponto, hífen e sublinhado`);
374
+ const list = readJson(registry);
375
+ const whereAlready = dir === KIT ? "no kit" : "no vídeo";
376
+ const other = Object.entries(list).find(([n, f]) => f.id === m[1] && n !== name);
377
+ if (other) throw new Error(`esse efeito já está ${whereAlready} como “${other[0]}”`);
378
+ if (list[name] && list[name].id !== m[1])
379
+ throw new Error(
380
+ `já existe “${name}” ${whereAlready} com outro efeito (${list[name].title || list[name].id}): escolha outro nome`,
381
+ );
382
+ if (dir !== KIT && kitSources()[name]) console.log(` ⚠ o kit já tem um “${name}”; o do vídeo passa na frente dele`);
383
+
384
+ const r = await fetch(url);
385
+ if (!r.ok) throw new Error(`a Epidemic respondeu ${r.status} para ${url}`);
386
+ mkdirSync(dir, { recursive: true });
387
+ const file = join(dir, name);
388
+ writeFileSync(file, Buffer.from(await r.arrayBuffer()));
389
+ cache.delete(file);
390
+ const med = measureEffect(file);
391
+ list[name] = {
392
+ source: "epidemic",
393
+ id: m[1],
394
+ url,
395
+ title: med.title,
396
+ search: search ?? null,
397
+ downloaded_at: new Date().toISOString().slice(0, 10),
398
+ ...extra,
399
+ };
400
+ writeFileSync(registry, JSON.stringify(list, null, 1) + "\n");
401
+ console.log(`${name} ← ${med.title || m[1]}${extra.role ? ` · kit, papel ${extra.role}` : ""}`);
402
+ console.log(
403
+ med.music
404
+ ? ` ⚠ ${med.duration.toFixed(0)}s: é trilha, não efeito; só serve de música de fundo`
405
+ : ` ${describe(med)}`,
406
+ );
407
+ }
408
+
409
+ const describe = m =>
410
+ `${m.duration.toFixed(2)}s · pico em ${m.peak_s}s (${m.peak_db} dB) · útil até ${m.useful_end_s}s · volume ${m.volume_lufs} LUFS`;
411
+
412
+ function effects(videoDir, filter) {
413
+ const dir = resolve(videoDir ?? "");
414
+ const list = Object.entries(sources(dir));
415
+ if (!list.length) {
416
+ console.log(`nenhum efeito capturado em ${effectsDir(dir)}`);
417
+ return;
418
+ }
419
+ const f = filter?.toLowerCase();
420
+ for (const [name, source] of list) {
421
+ if (f && !`${name} ${source.title}`.toLowerCase().includes(f)) continue;
422
+ const { file, error } = effectFile(dir, name);
423
+ if (error) {
424
+ console.log(`${name}\n ⚠ ${error}`);
425
+ continue;
426
+ }
427
+ const m = measureEffect(file);
428
+ console.log(
429
+ `${name}\n ${m.music ? `${m.duration.toFixed(0)}s · trilha (só música de fundo)` : describe(m)} · ${source.title || source.id}`,
430
+ );
431
+ }
432
+ }
433
+
434
+ /* moves an effect already captured from Epidemic for a video into the kit, with its origin */
435
+ function adopt(videoDir, name, role, as) {
436
+ const dir = resolve(videoDir ?? "");
437
+ if (!ROLES[role]) throw new Error(`--role é obrigatório: ${Object.keys(ROLES).join(", ")}`);
438
+ const source = sources(dir)[name];
439
+ if (!source) throw new Error(`“${name}” não está em ${join(effectsDir(dir), "sources.json")}`);
440
+ const newName = `${(as ?? name).replace(/\.mp3$/i, "")}.mp3`;
441
+ if (!NAME.test(newName)) throw new Error(`nome “${newName}”: letras, números, ponto, hífen e sublinhado`);
442
+ const list = kitSources();
443
+ const other = Object.entries(list).find(([, e]) => e.id === source.id);
444
+ if (other) throw new Error(`esse efeito já está no kit como “${other[0]}”`);
445
+ if (list[newName])
446
+ throw new Error(`o kit já tem “${newName}” (${list[newName].title || list[newName].id}): use --as <outro-nome>`);
447
+ mkdirSync(KIT, { recursive: true });
448
+ writeFileSync(join(KIT, newName), readFileSync(join(effectsDir(dir), name)));
449
+ list[newName] = { ...source, role, adopted_from: basename(dir) };
450
+ writeFileSync(join(KIT, "kit.json"), JSON.stringify(list, null, 1) + "\n");
451
+ console.log(`${newName} ← ${source.title || source.id} · kit, papel ${role}`);
452
+ }
453
+
454
+ /* the kit grouped by role; `filter` is a role or a piece of the name/title */
455
+ function kit(filter) {
456
+ const list = Object.entries(kitSources());
457
+ if (!list.length) {
458
+ console.log(`o kit está vazio (${KIT}): baixe com sound.mjs download kit <url> <nome> --role <papel>`);
459
+ return;
460
+ }
461
+ const f = filter?.toLowerCase();
462
+ for (const role of Object.keys(ROLES)) {
463
+ const ofRole = list.filter(
464
+ ([n, e]) => e.role === role && (!f || f === role || `${n} ${e.title}`.toLowerCase().includes(f)),
465
+ );
466
+ if (!ofRole.length) continue;
467
+ const p = ROLES[role];
468
+ console.log(`${role} (db ${p.db}${p.stretchable ? ", estica" : ""})`);
469
+ for (const [name, e] of ofRole) {
470
+ const file = join(KIT, name);
471
+ if (!existsSync(file)) {
472
+ console.log(` ${name} ⚠ arquivo sumiu`);
473
+ continue;
474
+ }
475
+ const m = measureEffect(file);
476
+ console.log(
477
+ ` ${name.padEnd(28)} ${m.music ? `${m.duration.toFixed(0)}s · trilha` : describe(m)}${e.db != null ? ` · db próprio ${e.db}` : ""}`,
478
+ );
479
+ }
480
+ }
481
+ }
482
+
483
+ function capture(n) {
484
+ const code = readFileSync(join(CODE_ROOT, "tools", "epidemic-capture.js"), "utf8");
485
+ console.log(
486
+ code
487
+ .slice(code.indexOf("=== */") + 6)
488
+ .trim()
489
+ .replace("__N__", String(n)),
490
+ );
491
+ }
492
+
493
+ /* --- voice: reference loudness and where it speaks ----------------------------------- */
494
+ /* cached by the lines' files and mtimes: the studio timeline replans on every adjustment */
495
+ const voiceCache = new Map();
496
+ function voiceOf(dir, video) {
497
+ const files = [];
498
+ for (const scene of video.scenes)
499
+ for (const line of scene.lines ?? []) {
500
+ if (line.wav && existsSync(join(dir, line.wav))) files.push(join(dir, line.wav));
501
+ }
502
+ if (!files.length) return { lufs: S.default_voice_lufs, measured: false };
503
+ const key = `${S.voice_target_lufs}|` + files.map(a => `${a}@${statSync(a).mtimeMs}`).join("|");
504
+ if (voiceCache.has(key)) return voiceCache.get(key);
505
+ /* the effects' reference loudness is the voice as it will be heard, already leveled */
506
+ const gains = files.map(a => lineGain(a));
507
+ const parts = files.map((a, i) => {
508
+ const x = decode(a);
509
+ return gains[i] ? x.map(v => v * 10 ** (gains[i] / 20)) : x;
510
+ });
511
+ const x = new Float32Array(parts.reduce((n, p) => n + p.length, 0));
512
+ let o = 0;
513
+ for (const p of parts) {
514
+ x.set(p, o);
515
+ o += p.length;
516
+ }
517
+ const v = integratedLoudness(x);
518
+ const r = Number.isFinite(v)
519
+ ? { lufs: +v.toFixed(1), measured: true, gains }
520
+ : { lufs: S.default_voice_lufs, measured: false, gains };
521
+ voiceCache.set(key, r);
522
+ return r;
523
+ }
524
+
525
+ /* spans where the voice speaks, in seconds of video: by words when already
526
+ aligned (gaps longer than ducking.min_gap release the ducking), otherwise the
527
+ whole line */
528
+ function voiceSegments(video) {
529
+ const t = [];
530
+ /* each span moves with the voice piece it falls in (editor clips) */
531
+ for (const scene of video.scenes)
532
+ for (const line of scene.lines ?? []) {
533
+ if (line.words?.length) for (const w of line.words) t.push(...voiceSpans(scene, line, w.s, w.e));
534
+ else t.push(...voiceSpans(scene, line, 0, line.end - line.start));
535
+ }
536
+ t.sort((a, b) => a[0] - b[0]);
537
+ const out = [];
538
+ for (const [a, b] of t) {
539
+ const u = out.at(-1);
540
+ if (u && a - u[1] < S.ducking.min_gap) u[1] = Math.max(u[1], b);
541
+ else out.push([a, b]);
542
+ }
543
+ return out.map(([a, b]) => [+a.toFixed(3), +b.toFixed(3)]);
544
+ }
545
+
546
+ /* ducking depth (0 to 1) at each ms of video: goes down over `before` seconds
547
+ until the voice starts, comes back up over `after` seconds once it stops */
548
+ function duckingCurve(segments, total) {
549
+ const n = Math.ceil(total * 1000) + 2;
550
+ const d = new Float32Array(n);
551
+ const { before, after } = S.ducking;
552
+ for (const [a, b] of segments) {
553
+ const i0 = Math.max(0, Math.floor((a - before) * 1000)),
554
+ i1 = Math.min(n - 1, Math.ceil((b + after) * 1000));
555
+ for (let i = i0; i <= i1; i++) {
556
+ const t = i / 1000;
557
+ const f = t < a ? (before > 0 ? 1 - (a - t) / before : 1) : t > b ? (after > 0 ? 1 - (t - b) / after : 0) : 1;
558
+ if (f > d[i]) d[i] = f;
559
+ }
560
+ }
561
+ return t => {
562
+ const x = Math.max(0, Math.min(n - 1.001, t * 1000)),
563
+ i = Math.floor(x);
564
+ return d[i] + (d[i + 1] - d[i]) * (x - i);
565
+ };
566
+ }
567
+
568
+ /* --- WAV ------------------------------------------------------------------------- */
569
+ function writeWav(file, x) {
570
+ const n = x.length;
571
+ const buf = Buffer.alloc(44 + n * 2);
572
+ buf.write("RIFF", 0);
573
+ buf.writeUInt32LE(36 + n * 2, 4);
574
+ buf.write("WAVE", 8);
575
+ buf.write("fmt ", 12);
576
+ buf.writeUInt32LE(16, 16);
577
+ buf.writeUInt16LE(1, 20);
578
+ buf.writeUInt16LE(2, 22);
579
+ buf.writeUInt32LE(RATE, 24);
580
+ buf.writeUInt32LE(RATE * 4, 28);
581
+ buf.writeUInt16LE(4, 32);
582
+ buf.writeUInt16LE(16, 34);
583
+ buf.write("data", 36);
584
+ buf.writeUInt32LE(n * 2, 40);
585
+ for (let i = 0; i < n; i++) buf.writeInt16LE(Math.round(Math.max(-1, Math.min(1, x[i])) * 32767), 44 + i * 2);
586
+ writeFileSync(file, buf);
587
+ }
588
+
589
+ /* gain, fades and ducking applied in place; `t0` is the video second of the first sample */
590
+ function apply(x, { gain_db, fade, duck_db, t0, ducking }) {
591
+ const n = x.length >> 1;
592
+ const g = 10 ** (gain_db / 20);
593
+ const fi = Math.round(fade[0] * RATE),
594
+ fo = Math.round(fade[1] * RATE);
595
+ for (let i = 0; i < n; i++) {
596
+ let v = g;
597
+ if (i < fi) v *= i / fi;
598
+ if (n - i <= fo) v *= (n - i) / fo;
599
+ if (duck_db) v *= 10 ** ((duck_db * ducking(t0 + i / RATE)) / 20);
600
+ x[2 * i] *= v;
601
+ x[2 * i + 1] *= v;
602
+ }
603
+ }
604
+
605
+ /* --- plan ------------------------------------------------------------------------ */
606
+ const layers = sound =>
607
+ (Array.isArray(sound) ? sound : [sound]).map(s => (typeof s === "string" ? { name: s } : { ...s }));
608
+ const num = (v, d) => (v == null ? d : +v);
609
+
610
+ /* the video's sound requests: [{ id, group, type, scene, element, layer, instant, s }] */
611
+ function requests(video) {
612
+ const out = [];
613
+ const add = (type, scene, element, instant, sound) =>
614
+ layers(sound).forEach((s, k) => {
615
+ const group = `${scene.id}-${element}`;
616
+ out.push({ id: k ? `${group}-${k + 1}` : group, group, type, scene: scene.id, element, layer: k, instant, s });
617
+ });
618
+ /* beats and transition illustrate the animation: they move with the animation piece they fall in
619
+ (editor clips, tools/soundtrack.mjs); a beat in a deleted piece is dropped */
620
+ video.scenes.forEach((scene, i) => {
621
+ const start = animationStart(scene);
622
+ if (i > 0 && start != null && scene.transition && typeof scene.transition === "object" && scene.transition.sound)
623
+ add("transition", scene, "transition", start, scene.transition.sound);
624
+ for (const beat of scene.beats ?? []) {
625
+ const t = beat.sound ? animationInstant(scene, beatTime(scene, beat)) : null;
626
+ if (t != null) add("beat", scene, beat.element, t, beat.sound);
627
+ }
628
+ });
629
+ return out;
630
+ }
631
+
632
+ /* editor clips (tools/soundtrack.mjs), validated, on video `v` (the loaded copy or raw scenes.json):
633
+ `scene_clips` { <scene>: clips } and `line_clips` { <line>: clips }. The default arrangement
634
+ (one whole clip, in place) is not stored */
635
+ export function applyClips(v, draft) {
636
+ const fps = v.format.fps;
637
+ for (const [id, clips] of Object.entries(draft?.scene_clips ?? {})) {
638
+ const scene = v.scenes.find(c => c.id === id);
639
+ if (!scene) throw new Error(`cena ${id} não existe`);
640
+ const l = sanitizeClips(clips, scene.duration, 1 / fps);
641
+ delete scene.video_offset;
642
+ delete scene.video_em;
643
+ if (l.length === 1 && l[0].from === 0 && Math.abs(l[0].to - scene.duration) < 1e-3 && !l[0].offset && !l[0].speed)
644
+ delete scene.clips;
645
+ else scene.clips = l;
646
+ }
647
+ for (const [id, clips] of Object.entries(draft?.line_clips ?? {})) {
648
+ const line = v.scenes.flatMap(c => c.lines ?? []).find(f => f.id === id);
649
+ if (!line) throw new Error(`fala ${id} não existe`);
650
+ const dur = line.dur ?? line.end - line.start;
651
+ const l = sanitizeClips(clips, dur, 0.001);
652
+ if (l.length === 1 && l[0].from === 0 && Math.abs(l[0].to - dur) < 2e-3 && !l[0].offset && !l[0].speed)
653
+ delete line.clips;
654
+ else line.clips = l;
655
+ }
656
+ }
657
+
658
+ /* the animation shift (`video_offset`, from before clips): a number, frame-aligned, up to 30 s either way */
659
+ export function sanitizeVideoOffset(offset, fps) {
660
+ const n = Number(offset);
661
+ if (!Number.isFinite(n) || Math.abs(n) > 30) throw new Error(`video_offset: de -30 a 30 segundos; veio "${offset}"`);
662
+ return +(Math.round(n * fps) / fps).toFixed(4);
663
+ }
664
+
665
+ /* --- validate what the studio sends (and what goes into scenes.json) ----------------
666
+ Only known keys, each within its range; defaults are dropped, and a sound with
667
+ only a name becomes the string. Throws with the message for the studio. */
668
+ const RANGES = { offset: [-10, 10], db: [-40, 20], dur: [0.05, 60], speed: [0.25, 4], pitch: [-24, 24] };
669
+ const DEFAULT = { offset: 0, db: 0, speed: 1, pitch: 0 };
670
+ function number(v, [min, max], name) {
671
+ const n = Number(v);
672
+ if (!Number.isFinite(n) || n < min || n > max) throw new Error(`${name}: ${v} fora de ${min} a ${max}`);
673
+ return +n.toFixed(3);
674
+ }
675
+ function pair(v, name, max, nullAtEnd = false) {
676
+ if (!Array.isArray(v) || v.length !== 2) throw new Error(`${name}: use [a, b]`);
677
+ return v.map((x, i) => (x == null && i === 1 && nullAtEnd ? null : number(x, [0, max], name)));
678
+ }
679
+
680
+ export function sanitizeSound(dir, s) {
681
+ const o = typeof s === "string" ? { name: s } : s;
682
+ if (!o || typeof o !== "object") throw new Error("som inválido");
683
+ const { file, error } = effectFile(dir, o.name);
684
+ if (error) throw new Error(error);
685
+ const m = measureEffect(file);
686
+ if (m.music) throw new Error(`“${o.name}” tem ${m.duration.toFixed(0)}s: é trilha, só serve de música de fundo`);
687
+ const v = { name: o.name };
688
+ if (o.anchor === "start") v.anchor = "start";
689
+ else if (o.anchor != null && o.anchor !== "peak") throw new Error(`âncora: ${o.anchor} (use peak ou start)`);
690
+ for (const k of ["offset", "db", "dur", "speed", "pitch"]) {
691
+ if (o[k] == null) continue;
692
+ const n = number(o[k], RANGES[k], k);
693
+ if (n !== DEFAULT[k]) v[k] = n;
694
+ }
695
+ if (o.trim != null) {
696
+ const t = pair(o.trim, "trim", m.duration, true);
697
+ if (t[1] != null && t[1] <= t[0]) throw new Error("trim: o fim vem depois do começo");
698
+ if (t[0] > 0 || t[1] != null) v.trim = t;
699
+ }
700
+ if (o.fade != null) v.fade = pair(o.fade, "fade", 10);
701
+ if (o.duck === false) v.duck = false;
702
+ else if (o.duck != null) v.duck = number(o.duck, [-40, 0], "duck");
703
+ if (v.dur != null) delete v.speed;
704
+ return Object.keys(v).length === 1 ? v.name : v;
705
+ }
706
+
707
+ export function sanitizeBed(dir, b, total) {
708
+ if (b == null) return null;
709
+ const o = typeof b === "string" ? { name: b } : b;
710
+ const { error } = effectFile(dir, o?.name);
711
+ if (error) throw new Error(`música de fundo: ${error}`);
712
+ const v = { name: o.name };
713
+ if (o.db != null && +o.db !== 0) v.db = number(o.db, [-40, 20], "música de fundo db");
714
+ if (o.from != null && +o.from > 0) v.from = number(o.from, [0, total], "música de fundo from");
715
+ if (o.to != null) v.to = number(o.to, [0, total], "música de fundo to");
716
+ if (v.from != null && v.to != null && v.to <= v.from) throw new Error("música de fundo: to vem depois de from");
717
+ if (o.trim != null) v.trim = pair(o.trim, "música de fundo trim", 3600, true);
718
+ if (o.fade != null) v.fade = pair(o.fade, "música de fundo fade", 30);
719
+ if (o.loop) v.loop = true;
720
+ if (o.duck === false) v.duck = false;
721
+ else if (o.duck != null) v.duck = number(o.duck, [-40, 0], "música de fundo duck");
722
+ if (o.speed != null && +o.speed !== 1) v.speed = number(o.speed, [0.5, 2], "música de fundo speed");
723
+ return Object.keys(v).length === 1 ? v.name : v;
724
+ }
725
+
726
+ /* the studio draft applied to a loaded video (copy): { places: [{ type,
727
+ scene, element, sounds: [] }], bed }. Returns the video with sounds swapped. */
728
+ export function applyDraft(dir, video, draft) {
729
+ const v = structuredClone(video);
730
+ for (const l of draft?.places ?? []) {
731
+ const scene = v.scenes.find(c => c.id === l.scene);
732
+ if (!scene) throw new Error(`cena ${l.scene} não existe`);
733
+ const sounds = (l.sounds ?? []).map(s => sanitizeSound(dir, s));
734
+ const sound = sounds.length === 0 ? null : sounds.length === 1 ? sounds[0] : sounds;
735
+ if (l.type === "transition") {
736
+ if (v.scenes.indexOf(scene) === 0) throw new Error("a primeira cena não tem passagem");
737
+ const p = scene.transition && typeof scene.transition === "object" ? { ...scene.transition } : {};
738
+ if (sound) p.sound = sound;
739
+ else delete p.sound;
740
+ if (Object.keys(p).length) scene.transition = p;
741
+ else delete scene.transition;
742
+ } else {
743
+ const beat = (scene.beats ?? []).find(b => b.element === l.element);
744
+ if (!beat) throw new Error(`${l.scene}: beat ${l.element} não existe`);
745
+ if (sound) beat.sound = sound;
746
+ else delete beat.sound;
747
+ }
748
+ }
749
+ if (draft && "bed" in draft) {
750
+ const b = sanitizeBed(dir, draft.bed, v.total_duration);
751
+ if (b) v.bed = b;
752
+ else delete v.bed;
753
+ }
754
+ /* the cuts and the speed only move the bed's clock (finalClock); effects and voice stay put */
755
+ if (draft && "cuts" in draft) {
756
+ const c = normalizeCuts(draft.cuts, v.total_duration, v.format.fps);
757
+ if (c.length) v.cuts = c;
758
+ else delete v.cuts;
759
+ }
760
+ /* always explicit, even at 1: that is what tells it apart from a video that never chose */
761
+ if (draft && "speed" in draft) v.speed = sanitizeSpeed(draft.speed);
762
+ applyClips(v, draft);
763
+ return v;
764
+ }
765
+
766
+ /* the export speed, 1 to 1.5 (assemble.mjs): 110 or 1.1 both mean 110%; empty or invalid is 1 */
767
+ export function sanitizeSpeed(s) {
768
+ const n = Number(
769
+ String(s ?? "")
770
+ .trim()
771
+ .replace(",", ".")
772
+ .replace(/%$/, ""),
773
+ );
774
+ const v = n > 3 ? n / 100 : n;
775
+ return Number.isFinite(v) && v > 0 ? Math.round(Math.min(1.5, Math.max(1, v)) * 100) / 100 : 1;
776
+ }
777
+
778
+ /* what the studio offers: the kit by role and what was captured for the video, measured */
779
+ export function soundCatalog(dir) {
780
+ const item = (from, name, e, origin) => {
781
+ const file = join(from, name);
782
+ if (!existsSync(file)) return null;
783
+ const m = measureEffect(file);
784
+ return {
785
+ name,
786
+ origin,
787
+ role: e.role ?? null,
788
+ title: e.title ?? "",
789
+ music: m.music,
790
+ duration: +m.duration.toFixed(3),
791
+ peak_s: m.peak_s ?? null,
792
+ useful_end_s: m.useful_end_s ?? null,
793
+ volume_lufs: m.volume_lufs ?? null,
794
+ };
795
+ };
796
+ return {
797
+ kit: Object.entries(kitSources())
798
+ .map(([n, e]) => item(KIT, n, e, "kit"))
799
+ .filter(Boolean),
800
+ video: Object.entries(sources(dir))
801
+ .map(([n, e]) => item(effectsDir(dir), n, e, "video"))
802
+ .filter(Boolean),
803
+ roles: ROLES,
804
+ level_db: S.level_db,
805
+ ducking: S.ducking,
806
+ bed: S.bed,
807
+ };
808
+ }
809
+ export const kitDir = KIT;
810
+
811
+ /* --- the bed's clock -----------------------------------------------------------------
812
+ The bed is isolated: the editor's area cuts and the speed (`cuts` and `speed` at the root of
813
+ scenes.json) act on the picture, voice and effects, never on the music. So the bed lives on the
814
+ final timeline (cuts closed, then divided by the speed) and plays there at its own pace, with
815
+ no gaps and no tempo change; `at` takes a scenes.json time to the final timeline. The identity
816
+ clock is the one before any edit (sound/preview.wav mixes everything in scenes.json time). */
817
+ export const identityClock = video => ({ cuts: [], speed: 1, at: t => t, total: video.total_duration });
818
+
819
+ /** @param {any} video @param {number} [speed] */
820
+ export function finalClock(video, speed = video.speed) {
821
+ const cuts = normalizeCuts(video.cuts, video.total_duration, video.format.fps);
822
+ const s = Number(speed) > 0 ? Number(speed) : 1;
823
+ return {
824
+ cuts,
825
+ speed: s,
826
+ at: t => toEdited(t, cuts) / s,
827
+ total: (video.total_duration - removedDuration(cuts)) / s,
828
+ };
829
+ }
830
+
831
+ /* the voice spans on the final timeline (the parts inside a cut are gone) */
832
+ const finalSegments = (segments, clock) =>
833
+ segments.map(([a, b]) => [clock.at(a), clock.at(b)]).filter(([a, b]) => b - a > 0.001);
834
+
835
+ /* Computes and writes the effect (and bed) WAVs in `outDir`. `scenes`: only the
836
+ effects of these scenes (the preview; an empty set: none, just the bed). `clock`: where the bed
837
+ lives (finalClock by default; the effects always stay in scenes.json time). Returns the plan. */
838
+ /** @param {string} dir @param {any} video @param {{ outDir: string, scenes?: Set<string> | null, withBed?: boolean, clock?: ReturnType<typeof finalClock> }} options */
839
+ export function planSound(dir, video, { outDir, scenes = null, withBed = true, clock = finalClock(video) }) {
840
+ const total = video.total_duration;
841
+ const voice = voiceOf(dir, video);
842
+ const segments = voiceSegments(video);
843
+ const ducking = duckingCurve(segments, total);
844
+ const bedDucking = duckingCurve(finalSegments(segments, clock), clock.total);
845
+ const items = [],
846
+ pending = [],
847
+ warnings = [];
848
+ if (!voice.measured && isNarrated(dir))
849
+ warnings.push(`voz ainda sem áudio: efeitos nivelados por ${voice.lufs} LUFS (sound.default_voice_lufs)`);
850
+ mkdirSync(outDir, { recursive: true });
851
+
852
+ for (const p of requests(video)) {
853
+ if (scenes && !scenes.has(p.scene)) continue;
854
+ const { s, id } = p;
855
+ const { file, error, kit } = effectFile(dir, s.name);
856
+ if (error) throw new Error(`${id}: ${error}`);
857
+ const m = measureEffect(file);
858
+ if (m.music)
859
+ throw new Error(
860
+ `${id}: “${s.name}” tem ${m.duration.toFixed(0)}s; é trilha, não efeito (pode ser música de fundo)`,
861
+ );
862
+ /* from the kit, the effect brings its role's volume (or its own) */
863
+ const role = kit ? (ROLES[kit.role] ?? {}) : {};
864
+ const kitDb = kit ? num(kit.db, num(role.db, 0)) : 0;
865
+ if (kit && (s.dur != null || s.speed != null) && !role.stretchable)
866
+ warnings.push(`${id}: “${s.name}” é ${kit.role}, que não costuma aguentar ser esticado`);
867
+
868
+ const from = num(s.trim?.[0], 0),
869
+ to = num(s.trim?.[1], m.useful_end_s);
870
+ if (!(from >= 0 && to > from && from < m.duration))
871
+ throw new Error(`${id}: trim [${s.trim}] fora de “${s.name}” (${m.duration.toFixed(2)}s)`);
872
+ const pitch = num(s.pitch, 0);
873
+ let speed = s.dur != null ? (Math.min(to, m.duration) - from) / +s.dur : num(s.speed, 1);
874
+ if (!(speed >= 0.25 && speed <= 4)) {
875
+ warnings.push(`${id}: velocidade ${speed.toFixed(2)}× fora de 0,25–4×; limitada`);
876
+ speed = Math.min(4, Math.max(0.25, speed || 1));
877
+ }
878
+ let x = decode(file, { from, to: s.trim?.[1] != null || to < m.duration ? to : null, speed, pitch });
879
+ if (x.length < 4) {
880
+ warnings.push(`${id}: o trecho não tem som; ignorado`);
881
+ continue;
882
+ }
883
+
884
+ const peak = peakOf(x);
885
+ const volume = maxLoudness(x);
886
+ const anchor = s.anchor === "start" ? 0 : peak.s;
887
+ const instant = p.instant + num(s.offset, 0);
888
+ let start = instant - anchor;
889
+ let gain = voice.lufs + S.level_db + kitDb + num(s.db, 0) - volume;
890
+ const ceiling = S.max_peak_db - 20 * Math.log10(peak.amp);
891
+ if (gain > ceiling) {
892
+ warnings.push(
893
+ `${id}: limitado em ${ceiling.toFixed(1)} dB para o pico não passar de ${S.max_peak_db} dBFS (pedia ${gain.toFixed(1)})`,
894
+ );
895
+ gain = ceiling;
896
+ }
897
+
898
+ let cut = 0;
899
+ if (start < 0) {
900
+ cut = -start;
901
+ start = 0;
902
+ warnings.push(`${id}: começaria antes do vídeo; ${cut.toFixed(2)}s do começo cortados`);
903
+ }
904
+ let dur = x.length / 2 / RATE - cut;
905
+ if (start + dur > total) {
906
+ dur = total - start;
907
+ warnings.push(`${id}: passa do fim do vídeo; cortado em ${dur.toFixed(2)}s`);
908
+ }
909
+ if (dur <= 0.02) {
910
+ warnings.push(`${id}: não sobra som depois do corte; ignorado`);
911
+ continue;
912
+ }
913
+ x = x.slice(Math.round(cut * RATE) * 2, (Math.round(cut * RATE) + Math.round(dur * RATE)) * 2);
914
+
915
+ const fade = [num(s.fade?.[0], from > 0 || cut > 0 ? 0.004 : 0), num(s.fade?.[1], Math.min(0.05, dur / 4))].map(f =>
916
+ Math.min(f, dur / 2),
917
+ );
918
+ const duck = s.duck === false ? 0 : num(s.duck, S.ducking.db);
919
+ pending.push({
920
+ id,
921
+ group: p.group,
922
+ type: p.type,
923
+ layer: p.layer,
924
+ scene: p.scene,
925
+ element: p.element,
926
+ name: s.name,
927
+ x,
928
+ start,
929
+ dur,
930
+ cut,
931
+ peak: start + peak.s - cut,
932
+ beat: p.instant,
933
+ anchor: s.anchor === "start" ? "start" : "peak",
934
+ gain,
935
+ volume,
936
+ duck,
937
+ fade,
938
+ trim: [from, +to.toFixed(3)],
939
+ speed,
940
+ pitch,
941
+ kit,
942
+ kitDb,
943
+ });
944
+ }
945
+
946
+ pending.sort((a, b) => a.start - b.start);
947
+ const adjusted = [];
948
+ for (let i = 1; i < pending.length; i++) {
949
+ const note = giveWay(pending[i - 1], pending[i]);
950
+ if (note) adjusted.push(note);
951
+ }
952
+
953
+ for (const e of pending) {
954
+ apply(e.x, { gain_db: e.gain, fade: e.fade, duck_db: e.duck, t0: e.start, ducking });
955
+ const wav = join(outDir, `${e.id}.wav`);
956
+ writeWav(wav, e.x);
957
+ items.push({
958
+ id: e.id,
959
+ group: e.group,
960
+ type: e.type,
961
+ layer: e.layer,
962
+ scene: e.scene,
963
+ element: e.element,
964
+ name: e.name,
965
+ file: wav,
966
+ wav: relative(dir, wav),
967
+ start: +e.start.toFixed(3),
968
+ duration: +e.dur.toFixed(3),
969
+ peak: +e.peak.toFixed(3),
970
+ beat: +e.beat.toFixed(3),
971
+ anchor: e.anchor,
972
+ gain_db: +e.gain.toFixed(1),
973
+ volume_lufs: +e.volume.toFixed(1),
974
+ duck_db: e.duck,
975
+ fade: e.fade.map(f => +f.toFixed(3)),
976
+ trim: e.trim,
977
+ speed: +e.speed.toFixed(3),
978
+ pitch: e.pitch,
979
+ cut_start: +e.cut.toFixed(3),
980
+ ...(e.gave_way ? { gave_way: e.gave_way } : {}),
981
+ origin: e.kit ? "kit" : "video",
982
+ role: e.kit?.role ?? null,
983
+ kit_db: e.kitDb,
984
+ });
985
+ }
986
+
987
+ for (let i = 1; i < items.length; i++) {
988
+ const a = items[i - 1],
989
+ b = items[i];
990
+ if (a.group === b.group) continue;
991
+ const overlap = clash(a.start, a.duration, b.start, b.duration);
992
+ if (overlap) warnings.push(`${a.id} e ${b.id} tocam juntos por ${overlap.toFixed(2)}s`);
993
+ }
994
+
995
+ const bed =
996
+ withBed && video.bed ? makeBed(dir, video, { outDir, voice, ducking: bedDucking, warnings, clock }) : null;
997
+ return { items, bed, warnings, adjusted, voice, ducking: { ...S.ducking, segments } };
998
+ }
999
+
1000
+ /* Two effects of different beats on top of each other. `warn`: the overlap, as a fraction of the
1001
+ shorter one, that counts as a clash; `target`: where giving way stops; `attack` and `release`:
1002
+ what is always kept before and after each peak. */
1003
+ const OVERLAP = { warn: 0.5, target: 0.25, min_s: 0.3, attack: 0.1, release: 0.1, fade: 0.08 };
1004
+
1005
+ /** the overlap in seconds when it is a clash, else 0 */
1006
+ function clash(aStart, aDur, bStart, bDur) {
1007
+ const shorter = Math.min(aDur, bDur),
1008
+ overlap = aStart + aDur - bStart;
1009
+ return shorter > OVERLAP.min_s && overlap > OVERLAP.warn * shorter ? overlap : 0;
1010
+ }
1011
+
1012
+ /* Resolves a clash between `a` and the next effect `b` without moving either peak: first `b`
1013
+ loses part of its build-up (it enters later, with a short fade-in), then, if that is not
1014
+ enough, `a` loses part of its tail (shorter, with a fade-out). An effect anchored at its start
1015
+ keeps its start: there the start is the movement it follows. Edits the samples in place and
1016
+ returns what changed, or null. */
1017
+ function giveWay(a, b) {
1018
+ if (a.group === b.group) return null;
1019
+ const before = clash(a.start, a.dur, b.start, b.dur);
1020
+ if (!before) return null;
1021
+ const target = () => OVERLAP.target * Math.min(a.dur, b.dur);
1022
+ const changes = [];
1023
+
1024
+ if (b.anchor !== "start") {
1025
+ const room = b.peak - b.start - OVERLAP.attack;
1026
+ const h = Math.round(Math.min(room, a.start + a.dur - b.start - target()) * RATE) / RATE;
1027
+ if (h > 0.01) {
1028
+ b.x = b.x.slice(Math.round(h * RATE) * 2);
1029
+ b.start += h;
1030
+ b.cut += h;
1031
+ b.dur -= h;
1032
+ b.fade[0] = Math.min(Math.max(b.fade[0], Math.min(OVERLAP.fade, (b.peak - b.start) / 2)), b.dur / 2);
1033
+ b.gave_way = `entra ${h.toFixed(2)}s depois (subida aparada)`;
1034
+ changes.push(`${b.id} ${b.gave_way}`);
1035
+ }
1036
+ }
1037
+
1038
+ if (a.start + a.dur - b.start > target()) {
1039
+ const keep = Math.max(b.start + target() - a.start, a.peak - a.start + OVERLAP.release);
1040
+ const n = Math.round(keep * RATE);
1041
+ if (n / RATE < a.dur - 0.01) {
1042
+ a.x = a.x.slice(0, n * 2);
1043
+ a.dur = n / RATE;
1044
+ a.fade[1] = Math.min(Math.max(a.fade[1], OVERLAP.fade), a.dur / 2);
1045
+ a.gave_way = `termina em ${a.dur.toFixed(2)}s (cauda cortada)`;
1046
+ changes.push(`${a.id} ${a.gave_way}`);
1047
+ }
1048
+ }
1049
+
1050
+ if (!changes.length) return null;
1051
+ const after = Math.max(0, a.start + a.dur - b.start);
1052
+ return `${a.id} e ${b.id} tocavam juntos por ${before.toFixed(2)}s: ${changes.join("; ")}; agora ${after.toFixed(2)}s`;
1053
+ }
1054
+
1055
+ /* `from`/`to` are scenes.json times: they say which part of the video the music covers. On the
1056
+ clock they become where it enters and ends; the music itself is never cut or sped up. `start`
1057
+ in the result is on that clock, and `ducking` is the curve of the voice on it too. */
1058
+ function makeBed(dir, video, { outDir, voice, ducking, warnings, clock }) {
1059
+ const b = typeof video.bed === "string" ? { name: video.bed } : video.bed;
1060
+ const total = video.total_duration;
1061
+ const { file, error } = effectFile(dir, b.name);
1062
+ if (error) throw new Error(`música de fundo: ${error}`);
1063
+ const start = clock.at(Math.max(0, num(b.from, 0))),
1064
+ end = clock.at(Math.min(total, num(b.to, total)));
1065
+ if (end - start < 0.1) {
1066
+ warnings.push("música de fundo: from/to não deixam tempo; ignorada");
1067
+ return null;
1068
+ }
1069
+ let x = decode(file, { from: num(b.trim?.[0], 0), to: b.trim?.[1] ?? null, speed: num(b.speed, 1) });
1070
+ const span = Math.round((end - start) * RATE) * 2;
1071
+ if (x.length < span) {
1072
+ if (b.loop) {
1073
+ const y = new Float32Array(span);
1074
+ for (let o = 0; o < span; o += x.length) y.set(x.subarray(0, Math.min(x.length, span - o)), o);
1075
+ x = y;
1076
+ } else
1077
+ warnings.push(
1078
+ `música de fundo: “${b.name}” acaba ${((span - x.length) / 2 / RATE).toFixed(1)}s antes de ${end.toFixed(1)}s (use loop para textura)`,
1079
+ );
1080
+ }
1081
+ x = x.slice(0, span);
1082
+ const dur = x.length / 2 / RATE;
1083
+ const volume = integratedLoudness(x);
1084
+ if (!Number.isFinite(volume)) {
1085
+ warnings.push("música de fundo: sem som audível; ignorada");
1086
+ return null;
1087
+ }
1088
+ let gain = voice.lufs + S.bed.level_db + num(b.db, 0) - volume;
1089
+ const peak = peakOf(x);
1090
+ gain = Math.min(gain, S.max_peak_db - 20 * Math.log10(peak.amp));
1091
+ const fade = [num(b.fade?.[0], S.bed.fade[0]), num(b.fade?.[1], S.bed.fade[1])].map(f => Math.min(f, dur / 2));
1092
+ const duck = b.duck === false ? 0 : num(b.duck, S.bed.ducking_db);
1093
+ apply(x, { gain_db: gain, fade, duck_db: duck, t0: start, ducking });
1094
+ const wav = join(outDir, "bed.wav");
1095
+ writeWav(wav, x);
1096
+ return {
1097
+ id: "bed",
1098
+ name: b.name,
1099
+ file: wav,
1100
+ wav: relative(dir, wav),
1101
+ start: +start.toFixed(3),
1102
+ duration: +dur.toFixed(3),
1103
+ gain_db: +gain.toFixed(1),
1104
+ volume_lufs: +volume.toFixed(1),
1105
+ duck_db: duck,
1106
+ fade,
1107
+ loop: !!b.loop,
1108
+ };
1109
+ }
1110
+
1111
+ export function prepareSound(videoDir, { preview = true, log = console.log, speed = undefined } = {}) {
1112
+ const dir = resolve(videoDir);
1113
+ const video = loadVideo(dir);
1114
+ const total = video.total_duration;
1115
+ const out = join(dir, "sound");
1116
+ mkdirSync(out, { recursive: true });
1117
+ const previousSound = keepPrevious(dir, "sound", null, ["sound/plan.json", "sound/preview.wav"]);
1118
+ /* sound/preview.wav mixes everything in scenes.json time, so its bed is on the identity clock; the
1119
+ export's (assemble.mjs) is on the final one */
1120
+ const { items, bed, warnings, adjusted, voice, ducking } = planSound(dir, video, {
1121
+ outDir: out,
1122
+ clock: preview ? identityClock(video) : finalClock(video, speed),
1123
+ });
1124
+
1125
+ /* On a single track, two overlapping clips cannot coexist: overwrite cuts
1126
+ the one below. Overlapping effects become one mixed clip, each at its own
1127
+ offset; the rest stay one clip per effect, editable in Premiere. */
1128
+ const clips = [];
1129
+ for (const it of items) {
1130
+ const last = clips[clips.length - 1];
1131
+ if (last && it.start < last.end - 0.001) {
1132
+ last.items.push(it);
1133
+ last.end = Math.max(last.end, it.start + it.duration);
1134
+ } else clips.push({ start: it.start, end: it.start + it.duration, items: [it] });
1135
+ }
1136
+ for (const c of clips) {
1137
+ if (c.items.length === 1) {
1138
+ Object.assign(c, { id: c.items[0].id, wav: c.items[0].wav });
1139
+ continue;
1140
+ }
1141
+ c.id = c.items.map(i => i.id).join("+");
1142
+ c.wav = `sound/${c.id}.wav`;
1143
+ const ins = c.items.flatMap(i => ["-i", join(dir, i.wav)]);
1144
+ const graph =
1145
+ c.items.map((i, k) => `[${k}]adelay=${Math.round((i.start - c.start) * 1000)}:all=1[a${k}]`).join(";") +
1146
+ `;${c.items.map((_, k) => `[a${k}]`).join("")}amix=inputs=${c.items.length}:normalize=0:duration=longest`;
1147
+ run("ffmpeg", [
1148
+ "-y",
1149
+ "-hide_banner",
1150
+ "-loglevel",
1151
+ "error",
1152
+ ...ins,
1153
+ "-filter_complex",
1154
+ graph,
1155
+ "-ar",
1156
+ String(RATE),
1157
+ "-c:a",
1158
+ "pcm_s16le",
1159
+ join(dir, c.wav),
1160
+ ]);
1161
+ }
1162
+ const planClips = clips.map(c => ({
1163
+ id: c.id,
1164
+ wav: c.wav,
1165
+ start: +c.start.toFixed(3),
1166
+ duration: +(c.end - c.start).toFixed(3),
1167
+ effects: c.items.map(i => i.id),
1168
+ }));
1169
+ const withoutFile = ({ file, ...r }) => r;
1170
+ writeFileSync(
1171
+ join(out, "plan.json"),
1172
+ JSON.stringify(
1173
+ {
1174
+ provisional: video.provisional,
1175
+ total_duration: total,
1176
+ voice_lufs: voice.lufs,
1177
+ voice_measured: voice.measured,
1178
+ level_db: S.level_db,
1179
+ window_s: S.window_s,
1180
+ ducking,
1181
+ items: items.map(withoutFile),
1182
+ bed: bed && withoutFile(bed),
1183
+ clips: planClips,
1184
+ adjusted,
1185
+ warnings,
1186
+ },
1187
+ null,
1188
+ 1,
1189
+ ) + "\n",
1190
+ );
1191
+
1192
+ /* preview: voice (if it exists yet), effects and bed in final timing */
1193
+ let withVoice = 0;
1194
+ if (preview && (items.length || bed)) {
1195
+ const inputs = [];
1196
+ for (const scene of video.scenes)
1197
+ for (const line of scene.lines ?? []) {
1198
+ if (line.wav && existsSync(join(dir, line.wav))) {
1199
+ inputs.push(...lineInputs(scene, line, join(dir, line.wav)));
1200
+ withVoice++;
1201
+ }
1202
+ }
1203
+ for (const it of [...items, ...(bed ? [bed] : [])]) inputs.push({ file: it.file, t: it.start });
1204
+ run("ffmpeg", [
1205
+ "-y",
1206
+ "-hide_banner",
1207
+ "-loglevel",
1208
+ "error",
1209
+ ...inputs.flatMap(e => ["-i", e.file]),
1210
+ "-filter_complex",
1211
+ mixGraph(inputs, total),
1212
+ "-t",
1213
+ String(total),
1214
+ "-ar",
1215
+ String(RATE),
1216
+ "-c:a",
1217
+ "pcm_s16le",
1218
+ join(out, "preview.wav"),
1219
+ ]);
1220
+ }
1221
+ previousSound.close();
1222
+
1223
+ log(
1224
+ `${items.length} efeito(s)${bed ? " + cama" : ""}${video.provisional ? " (tempo provisório)" : ""} → ${join(out, "plan.json")}`,
1225
+ );
1226
+ if (Number.isFinite(S.voice_target_lufs) && voice.gains?.some(g => g)) {
1227
+ log(
1228
+ ` falas niveladas em ${S.voice_target_lufs} LUFS: ganho de ${Math.min(...voice.gains).toFixed(1)} a ${Math.max(...voice.gains).toFixed(1)} dB`,
1229
+ );
1230
+ }
1231
+ log(
1232
+ ` voz ${voice.lufs} LUFS${voice.measured ? "" : " (padrão, sem áudio)"} · efeitos ${S.level_db} dB abaixo · ducking ${S.ducking.db} dB em ${ducking.segments.length} trecho(s)`,
1233
+ );
1234
+ for (const it of items) {
1235
+ log(
1236
+ ` ${it.id.padEnd(20)} começa ${it.start.toFixed(2).padStart(6)}s · ${it.anchor === "peak" ? "pico" : "inicio"} ${(it.anchor === "peak" ? it.peak : it.start).toFixed(2)}s (instante ${it.beat.toFixed(2)}s) · ${it.gain_db > 0 ? "+" : ""}${it.gain_db} dB · ${it.name}`,
1237
+ );
1238
+ }
1239
+ if (bed)
1240
+ log(
1241
+ ` cama ${bed.start.toFixed(2)}–${(bed.start + bed.duration).toFixed(2)}s · ${bed.gain_db > 0 ? "+" : ""}${bed.gain_db} dB · ducking ${bed.duck_db} dB · ${bed.name}`,
1242
+ );
1243
+ adjusted.forEach(a => log(` ↔ ${a}`));
1244
+ warnings.forEach(a => log(` ⚠ ${a}`));
1245
+ planClips
1246
+ .filter(c => c.effects.length > 1)
1247
+ .forEach(c => log(` ${c.effects.join(" e ")} se sobrepõem: vão juntos num clipe só (${c.wav})`));
1248
+ if (preview && (items.length || bed))
1249
+ log(
1250
+ `prévia ${withVoice ? `com ${withVoice} fala(s)` : "só com efeitos (a voz ainda não existe)"} → ${join(out, "preview.wav")}`,
1251
+ );
1252
+ return { items, bed, clips: planClips, adjusted, warnings, voice };
1253
+ }
1254
+
1255
+ /* inputs [{ file, t, gain_db? }] summed without normalizing, each at its own second;
1256
+ `gain_db` is the per-line level (lineGain) */
1257
+ /* a line's mix inputs: one per voice piece (editor clips), each with its range of the WAV */
1258
+ export function lineInputs(scene, line, file) {
1259
+ const gain_db = lineGain(file);
1260
+ return lineClips(line).map(cl => ({
1261
+ file,
1262
+ t: scene.start + line.start + cl.from + cl.offset,
1263
+ gain_db,
1264
+ from: cl.from,
1265
+ to: cl.to,
1266
+ ...(cl.speed ? { speed: cl.speed } : {}),
1267
+ }));
1268
+ }
1269
+
1270
+ /* `from`/`to` on an input play only that range of the file (voice piece), `speed` plays it faster
1271
+ with its pitch kept (atempo); what would start before 0 loses its head */
1272
+ export function mixGraph(inputs, total) {
1273
+ const trim = e => {
1274
+ /* seconds lost before 0 are video seconds: in the file, × speed */
1275
+ const cut = Math.max(0, -e.t) * (e.speed ?? 1);
1276
+ const from = (e.from ?? 0) + cut;
1277
+ return e.from != null || cut
1278
+ ? `atrim=start=${from.toFixed(4)}${e.to != null ? `:end=${e.to.toFixed(4)}` : ""},asetpts=PTS-STARTPTS,`
1279
+ : "";
1280
+ };
1281
+ return (
1282
+ inputs
1283
+ .map(
1284
+ (e, i) =>
1285
+ `[${i}]${trim(e)}${e.speed && e.speed !== 1 ? `${atempo(e.speed).join(",")},` : ""}aresample=${RATE}${e.gain_db ? `,volume=${e.gain_db}dB` : ""},aformat=channel_layouts=stereo,adelay=${Math.round(Math.max(0, e.t) * 1000)}:all=1[a${i}]`,
1286
+ )
1287
+ .join(";") +
1288
+ `;${inputs.map((_, i) => `[a${i}]`).join("")}amix=inputs=${inputs.length}:normalize=0:duration=longest,apad=whole_dur=${total}`
1289
+ );
1290
+ }
1291
+
1292
+ /* --- cli -------------------------------------------------------------------------- */
1293
+ if (isMainModule(import.meta.url)) {
1294
+ const [cmd, ...rest] = process.argv.slice(2);
1295
+ try {
1296
+ const option = n => (i => (i < 0 ? undefined : rest.splice(i, 2)[1]))(rest.indexOf(`--${n}`));
1297
+ const search = option("search"),
1298
+ role = option("role");
1299
+ if (cmd === "capture") capture(+(rest[0] ?? 8));
1300
+ else if (cmd === "download") await download(rest[0], rest[1], rest[2], search, role);
1301
+ else if (cmd === "effects") effects(rest[0], rest[1]);
1302
+ else if (cmd === "kit" && rest[0] === "adopt") adopt(rest[1], rest[2], role, option("as") ?? rest[3]);
1303
+ else if (cmd === "kit") kit(rest[0]);
1304
+ else if (cmd && existsSync(join(resolve(cmd), "scenes.json"))) {
1305
+ await turn("sound", cmd);
1306
+ prepareSound(cmd);
1307
+ } else {
1308
+ const source = readFileSync(fileURLToPath(import.meta.url), "utf8").split("\n");
1309
+ console.log(
1310
+ source
1311
+ .slice(
1312
+ 1,
1313
+ source.findIndex((l, i) => i > 1 && l.includes("=====*/")),
1314
+ )
1315
+ .join("\n"),
1316
+ );
1317
+ process.exit(2);
1318
+ }
1319
+ } catch (e) {
1320
+ console.error(`erro: ${e.message}`);
1321
+ process.exit(1);
1322
+ }
1323
+ }