egaki 0.6.0 → 0.7.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 (271) hide show
  1. package/CHANGELOG.md +140 -9
  2. package/dist/cli/cache-utils.d.ts +33 -0
  3. package/dist/cli/cache-utils.d.ts.map +1 -0
  4. package/dist/cli/cache-utils.js +157 -0
  5. package/dist/cli/cached-generate.d.ts +105 -0
  6. package/dist/cli/cached-generate.d.ts.map +1 -0
  7. package/dist/cli/cached-generate.js +208 -0
  8. package/dist/cli/cached-generate.test.d.ts +11 -0
  9. package/dist/cli/cached-generate.test.d.ts.map +1 -0
  10. package/dist/cli/cached-generate.test.js +431 -0
  11. package/dist/cli/cartesia-provider.d.ts +16 -0
  12. package/dist/cli/cartesia-provider.d.ts.map +1 -0
  13. package/dist/cli/cartesia-provider.js +184 -0
  14. package/dist/cli/chatgpt-auth.d.ts.map +1 -0
  15. package/dist/cli/cli.d.ts +4 -0
  16. package/dist/cli/cli.d.ts.map +1 -0
  17. package/dist/{cli.js → cli/cli.js} +626 -600
  18. package/dist/cli/clone-generate.d.ts +26 -0
  19. package/dist/cli/clone-generate.d.ts.map +1 -0
  20. package/dist/cli/clone-generate.js +148 -0
  21. package/dist/cli/credentials.d.ts.map +1 -0
  22. package/dist/{credentials.js → cli/credentials.js} +20 -0
  23. package/dist/cli/elevenlabs-speech-provider.d.ts +26 -0
  24. package/dist/cli/elevenlabs-speech-provider.d.ts.map +1 -0
  25. package/dist/cli/elevenlabs-speech-provider.js +199 -0
  26. package/dist/cli/generate.d.ts +140 -0
  27. package/dist/cli/generate.d.ts.map +1 -0
  28. package/dist/cli/generate.js +604 -0
  29. package/dist/cli/generate.test.d.ts +2 -0
  30. package/dist/cli/generate.test.d.ts.map +1 -0
  31. package/dist/cli/generate.test.js +131 -0
  32. package/dist/cli/login.d.ts.map +1 -0
  33. package/dist/cli/main.d.ts +3 -0
  34. package/dist/cli/main.d.ts.map +1 -0
  35. package/dist/cli/main.js +7 -0
  36. package/dist/{model-catalog.d.ts → cli/model-catalog.d.ts} +6 -0
  37. package/dist/cli/model-catalog.d.ts.map +1 -0
  38. package/dist/{model-catalog.js → cli/model-catalog.js} +714 -4
  39. package/dist/{models.d.ts → cli/models.d.ts} +12 -5
  40. package/dist/cli/models.d.ts.map +1 -0
  41. package/dist/{models.js → cli/models.js} +58 -57
  42. package/dist/cli/open-browser.d.ts.map +1 -0
  43. package/dist/cli/openai-speech-provider.d.ts +3 -0
  44. package/dist/cli/openai-speech-provider.d.ts.map +1 -0
  45. package/dist/cli/openai-speech-provider.js +28 -0
  46. package/dist/cli/separation-generate.d.ts +34 -0
  47. package/dist/cli/separation-generate.d.ts.map +1 -0
  48. package/dist/cli/separation-generate.js +117 -0
  49. package/dist/cli/speech-catalog.d.ts +35 -0
  50. package/dist/cli/speech-catalog.d.ts.map +1 -0
  51. package/dist/cli/speech-catalog.js +195 -0
  52. package/dist/cli/speech-generate.d.ts +86 -0
  53. package/dist/cli/speech-generate.d.ts.map +1 -0
  54. package/dist/cli/speech-generate.js +156 -0
  55. package/dist/cli/speech-models.d.ts +8 -0
  56. package/dist/cli/speech-models.d.ts.map +1 -0
  57. package/dist/cli/speech-models.js +109 -0
  58. package/dist/cli/speech-provider.test.d.ts +2 -0
  59. package/dist/cli/speech-provider.test.d.ts.map +1 -0
  60. package/dist/cli/speech-provider.test.js +197 -0
  61. package/dist/cli/subscription.d.ts.map +1 -0
  62. package/dist/cli/transcription-catalog.d.ts +35 -0
  63. package/dist/cli/transcription-catalog.d.ts.map +1 -0
  64. package/dist/cli/transcription-catalog.js +192 -0
  65. package/dist/cli/transcription-generate.d.ts +62 -0
  66. package/dist/cli/transcription-generate.d.ts.map +1 -0
  67. package/dist/cli/transcription-generate.js +144 -0
  68. package/dist/cli/transcription-models.d.ts +9 -0
  69. package/dist/cli/transcription-models.d.ts.map +1 -0
  70. package/dist/cli/transcription-models.js +100 -0
  71. package/dist/cli/transcription-providers.d.ts +22 -0
  72. package/dist/cli/transcription-providers.d.ts.map +1 -0
  73. package/dist/cli/transcription-providers.js +290 -0
  74. package/dist/cli/video-model-catalog.d.ts.map +1 -0
  75. package/dist/cli/xai-auth.d.ts.map +1 -0
  76. package/dist/{xai-auth.js → cli/xai-auth.js} +1 -1
  77. package/dist/vite/angled-screen.d.ts +57 -0
  78. package/dist/vite/angled-screen.d.ts.map +1 -0
  79. package/dist/vite/angled-screen.js +115 -0
  80. package/dist/vite/app.d.ts +51 -0
  81. package/dist/vite/app.d.ts.map +1 -0
  82. package/dist/vite/app.js +150 -0
  83. package/dist/vite/bands-shader.d.ts +47 -0
  84. package/dist/vite/bands-shader.d.ts.map +1 -0
  85. package/dist/vite/bands-shader.js +267 -0
  86. package/dist/vite/code-block.d.ts +107 -0
  87. package/dist/vite/code-block.d.ts.map +1 -0
  88. package/dist/vite/code-block.js +641 -0
  89. package/dist/vite/components.d.ts +36 -0
  90. package/dist/vite/components.d.ts.map +1 -0
  91. package/dist/vite/components.js +113 -0
  92. package/dist/vite/dispersion-rings-shader.d.ts +44 -0
  93. package/dist/vite/dispersion-rings-shader.d.ts.map +1 -0
  94. package/dist/vite/dispersion-rings-shader.js +282 -0
  95. package/dist/vite/easing-curves.d.ts +129 -0
  96. package/dist/vite/easing-curves.d.ts.map +1 -0
  97. package/dist/vite/easing-curves.js +584 -0
  98. package/dist/vite/easing-curves.test.d.ts +2 -0
  99. package/dist/vite/easing-curves.test.d.ts.map +1 -0
  100. package/dist/vite/easing-curves.test.js +200 -0
  101. package/dist/vite/generated-media-client.d.ts +35 -0
  102. package/dist/vite/generated-media-client.d.ts.map +1 -0
  103. package/dist/vite/generated-media-client.js +76 -0
  104. package/dist/vite/keyframes.d.ts +129 -0
  105. package/dist/vite/keyframes.d.ts.map +1 -0
  106. package/dist/vite/keyframes.js +194 -0
  107. package/dist/vite/layout-editor.d.ts +45 -0
  108. package/dist/vite/layout-editor.d.ts.map +1 -0
  109. package/dist/vite/layout-editor.js +587 -0
  110. package/dist/vite/layout-transition.d.ts +136 -0
  111. package/dist/vite/layout-transition.d.ts.map +1 -0
  112. package/dist/vite/layout-transition.js +581 -0
  113. package/dist/vite/liquid-gradient-shader.d.ts +62 -0
  114. package/dist/vite/liquid-gradient-shader.d.ts.map +1 -0
  115. package/dist/vite/liquid-gradient-shader.js +413 -0
  116. package/dist/vite/mdx-client.d.ts +15 -0
  117. package/dist/vite/mdx-client.d.ts.map +1 -0
  118. package/dist/vite/mdx-client.js +265 -0
  119. package/dist/vite/mdx-parse.d.ts +102 -0
  120. package/dist/vite/mdx-parse.d.ts.map +1 -0
  121. package/dist/vite/mdx-parse.js +251 -0
  122. package/dist/vite/mdx-provided-components.d.ts +18 -0
  123. package/dist/vite/mdx-provided-components.d.ts.map +1 -0
  124. package/dist/vite/mdx-provided-components.js +14 -0
  125. package/dist/vite/mdx-video.d.ts +327 -0
  126. package/dist/vite/mdx-video.d.ts.map +1 -0
  127. package/dist/vite/mdx-video.js +596 -0
  128. package/dist/vite/mdx-video.test.d.ts +9 -0
  129. package/dist/vite/mdx-video.test.d.ts.map +1 -0
  130. package/dist/vite/mdx-video.test.js +1945 -0
  131. package/dist/vite/media-components.d.ts +45 -0
  132. package/dist/vite/media-components.d.ts.map +1 -0
  133. package/dist/vite/media-components.js +355 -0
  134. package/dist/vite/media-duration-store.d.ts +66 -0
  135. package/dist/vite/media-duration-store.d.ts.map +1 -0
  136. package/dist/vite/media-duration-store.js +204 -0
  137. package/dist/vite/player-page.d.ts +35 -0
  138. package/dist/vite/player-page.d.ts.map +1 -0
  139. package/dist/vite/player-page.js +720 -0
  140. package/dist/vite/render-client.d.ts +18 -0
  141. package/dist/vite/render-client.d.ts.map +1 -0
  142. package/dist/vite/render-client.js +67 -0
  143. package/dist/vite/sdk.d.ts +221 -0
  144. package/dist/vite/sdk.d.ts.map +1 -0
  145. package/dist/vite/sdk.js +355 -0
  146. package/dist/vite/server-components.d.ts +55 -0
  147. package/dist/vite/server-components.d.ts.map +1 -0
  148. package/dist/vite/server-components.js +156 -0
  149. package/dist/vite/server-mdx.d.ts +74 -0
  150. package/dist/vite/server-mdx.d.ts.map +1 -0
  151. package/dist/vite/server-mdx.js +259 -0
  152. package/dist/vite/shader-renderer.d.ts +48 -0
  153. package/dist/vite/shader-renderer.d.ts.map +1 -0
  154. package/dist/vite/shader-renderer.js +411 -0
  155. package/dist/vite/store-hooks.d.ts +12 -0
  156. package/dist/vite/store-hooks.d.ts.map +1 -0
  157. package/dist/vite/store-hooks.js +93 -0
  158. package/dist/vite/store.d.ts +75 -0
  159. package/dist/vite/store.d.ts.map +1 -0
  160. package/dist/vite/store.js +52 -0
  161. package/dist/vite/store.test.d.ts +6 -0
  162. package/dist/vite/store.test.d.ts.map +1 -0
  163. package/dist/vite/store.test.js +172 -0
  164. package/dist/vite/tweakpane-hook.d.ts +94 -0
  165. package/dist/vite/tweakpane-hook.d.ts.map +1 -0
  166. package/dist/vite/tweakpane-hook.js +450 -0
  167. package/dist/vite/vite-plugin.d.ts +28 -0
  168. package/dist/vite/vite-plugin.d.ts.map +1 -0
  169. package/dist/vite/vite-plugin.js +538 -0
  170. package/dist/vite/wave-gradient-shader.d.ts +41 -0
  171. package/dist/vite/wave-gradient-shader.d.ts.map +1 -0
  172. package/dist/vite/wave-gradient-shader.js +207 -0
  173. package/package.json +101 -24
  174. package/skills/egaki/SKILL.md +162 -1
  175. package/src/cli/cache-utils.ts +169 -0
  176. package/src/cli/cached-generate.test.ts +531 -0
  177. package/src/cli/cached-generate.ts +348 -0
  178. package/src/cli/cartesia-provider.ts +249 -0
  179. package/src/{cli.ts → cli/cli.ts} +836 -884
  180. package/src/cli/clone-generate.ts +202 -0
  181. package/src/{credentials.ts → cli/credentials.ts} +20 -0
  182. package/src/cli/elevenlabs-speech-provider.ts +243 -0
  183. package/src/cli/generate.test.ts +166 -0
  184. package/src/cli/generate.ts +912 -0
  185. package/src/cli/main.ts +8 -0
  186. package/src/{model-catalog.ts → cli/model-catalog.ts} +787 -58
  187. package/src/{models.ts → cli/models.ts} +65 -86
  188. package/src/cli/openai-speech-provider.ts +34 -0
  189. package/src/cli/separation-generate.ts +187 -0
  190. package/src/cli/speech-catalog.ts +253 -0
  191. package/src/cli/speech-generate.ts +275 -0
  192. package/src/cli/speech-models.ts +146 -0
  193. package/src/cli/speech-provider.test.ts +226 -0
  194. package/src/cli/transcription-catalog.ts +250 -0
  195. package/src/cli/transcription-generate.ts +214 -0
  196. package/src/cli/transcription-models.ts +136 -0
  197. package/src/cli/transcription-providers.ts +427 -0
  198. package/src/{xai-auth.ts → cli/xai-auth.ts} +1 -1
  199. package/src/vite/angled-screen.tsx +214 -0
  200. package/src/vite/app.tsx +197 -0
  201. package/src/vite/bands-shader.tsx +319 -0
  202. package/src/vite/code-block.tsx +939 -0
  203. package/src/vite/components.tsx +278 -0
  204. package/src/vite/dispersion-rings-shader.tsx +331 -0
  205. package/src/vite/easing-curves.test.ts +232 -0
  206. package/src/vite/easing-curves.ts +743 -0
  207. package/src/vite/generated-media-client.tsx +121 -0
  208. package/src/vite/keyframes.tsx +347 -0
  209. package/src/vite/layout-editor.tsx +709 -0
  210. package/src/vite/layout-transition.tsx +706 -0
  211. package/src/vite/liquid-gradient-shader.tsx +474 -0
  212. package/src/vite/mdx-client.tsx +420 -0
  213. package/src/vite/mdx-parse.ts +343 -0
  214. package/src/vite/mdx-provided-components.ts +19 -0
  215. package/src/vite/mdx-video.test.tsx +2219 -0
  216. package/src/vite/mdx-video.tsx +976 -0
  217. package/src/vite/media-components.tsx +473 -0
  218. package/src/vite/media-duration-store.ts +225 -0
  219. package/src/vite/motion-timing.ts +194 -0
  220. package/src/vite/player-page.tsx +1107 -0
  221. package/src/vite/render-client.ts +84 -0
  222. package/src/vite/sdk.ts +598 -0
  223. package/src/vite/server-components.tsx +241 -0
  224. package/src/vite/server-mdx.ts +285 -0
  225. package/src/vite/shader-renderer.tsx +535 -0
  226. package/src/vite/store-hooks.ts +117 -0
  227. package/src/vite/store.test.ts +202 -0
  228. package/src/vite/store.ts +115 -0
  229. package/src/vite/styles.css +21 -0
  230. package/src/vite/tweakpane-hook.tsx +593 -0
  231. package/src/vite/virtual-modules.d.ts +30 -0
  232. package/src/vite/vite-plugin.ts +584 -0
  233. package/src/vite/wave-gradient-shader.tsx +253 -0
  234. package/README.md +0 -370
  235. package/dist/antigravity-auth.d.ts +0 -15
  236. package/dist/antigravity-auth.d.ts.map +0 -1
  237. package/dist/antigravity-auth.js +0 -544
  238. package/dist/antigravity-image.d.ts +0 -11
  239. package/dist/antigravity-image.d.ts.map +0 -1
  240. package/dist/antigravity-image.js +0 -285
  241. package/dist/chatgpt-auth.d.ts.map +0 -1
  242. package/dist/cli.d.ts +0 -3
  243. package/dist/cli.d.ts.map +0 -1
  244. package/dist/credentials.d.ts.map +0 -1
  245. package/dist/gateway-model-candidates.generated.d.ts +0 -11
  246. package/dist/gateway-model-candidates.generated.d.ts.map +0 -1
  247. package/dist/gateway-model-candidates.generated.js +0 -64
  248. package/dist/login.d.ts.map +0 -1
  249. package/dist/model-catalog.d.ts.map +0 -1
  250. package/dist/models.d.ts.map +0 -1
  251. package/dist/open-browser.d.ts.map +0 -1
  252. package/dist/subscription.d.ts.map +0 -1
  253. package/dist/video-model-catalog.d.ts.map +0 -1
  254. package/dist/xai-auth.d.ts.map +0 -1
  255. /package/dist/{chatgpt-auth.d.ts → cli/chatgpt-auth.d.ts} +0 -0
  256. /package/dist/{chatgpt-auth.js → cli/chatgpt-auth.js} +0 -0
  257. /package/dist/{credentials.d.ts → cli/credentials.d.ts} +0 -0
  258. /package/dist/{login.d.ts → cli/login.d.ts} +0 -0
  259. /package/dist/{login.js → cli/login.js} +0 -0
  260. /package/dist/{open-browser.d.ts → cli/open-browser.d.ts} +0 -0
  261. /package/dist/{open-browser.js → cli/open-browser.js} +0 -0
  262. /package/dist/{subscription.d.ts → cli/subscription.d.ts} +0 -0
  263. /package/dist/{subscription.js → cli/subscription.js} +0 -0
  264. /package/dist/{video-model-catalog.d.ts → cli/video-model-catalog.d.ts} +0 -0
  265. /package/dist/{video-model-catalog.js → cli/video-model-catalog.js} +0 -0
  266. /package/dist/{xai-auth.d.ts → cli/xai-auth.d.ts} +0 -0
  267. /package/src/{chatgpt-auth.ts → cli/chatgpt-auth.ts} +0 -0
  268. /package/src/{login.ts → cli/login.ts} +0 -0
  269. /package/src/{open-browser.ts → cli/open-browser.ts} +0 -0
  270. /package/src/{subscription.ts → cli/subscription.ts} +0 -0
  271. /package/src/{video-model-catalog.ts → cli/video-model-catalog.ts} +0 -0
@@ -12,40 +12,24 @@ import { z } from 'zod'
12
12
  import dedent from 'string-dedent'
13
13
  import {
14
14
  APICallError,
15
- generateImage as aiGenerateImage,
16
- generateText,
17
- experimental_generateVideo as aiGenerateVideo,
18
15
  NoImageGeneratedError,
19
16
  NoVideoGeneratedError,
20
17
  RetryError,
21
18
  } from 'ai'
22
- import type { XaiImageModelOptions, XaiVideoModelOptions } from '@ai-sdk/xai'
23
- import type { GoogleImageModelOptions, GoogleLanguageModelOptions, GoogleVideoModelOptions } from '@ai-sdk/google'
24
- import type { GoogleVertexImageModelOptions, GoogleVertexVideoModelOptions } from '@ai-sdk/google-vertex'
25
- import type { FalImageModelOptions, FalVideoModelOptions } from '@ai-sdk/fal'
26
19
  import { select, isCancel, cancel } from '@clack/prompts'
27
20
  import fs from 'node:fs'
28
21
  import path from 'node:path'
29
22
  import pc from 'picocolors'
30
- import { createParser, type EventSourceMessage } from 'eventsource-parser'
31
- import pkg from '../package.json' with { type: 'json' }
23
+ import pkg from '../../package.json' with { type: 'json' }
32
24
  import {
33
25
  injectCredentialsToEnv,
34
26
  PROVIDERS,
35
- getChatGptAuth,
36
- saveChatGptAuth,
37
27
  getKeyStatus,
38
28
  } from './credentials.js'
39
29
  import {
40
- IMAGE_MODELS,
41
- VIDEO_MODELS,
42
30
  DEFAULT_MODEL,
43
31
  DEFAULT_VIDEO_MODEL,
44
32
  getModelConfig,
45
- createImageModel,
46
- createTextModel,
47
- createVideoModel,
48
- shouldUseResponsesApi,
49
33
  } from './models.js'
50
34
  import {
51
35
  CATALOG,
@@ -65,21 +49,44 @@ import {
65
49
  unsubscribe,
66
50
  showUsage,
67
51
  } from './subscription.js'
68
- import { getValidChatGptAuth } from './chatgpt-auth.js'
69
-
70
- // OpenAI doesn't export a providerOptions type for image models yet (vercel/ai#12437).
71
- // These fields are what providerOptions.openai accepts, derived from
72
- // node_modules/@ai-sdk/openai/src/image/openai-image-model-options.ts.
73
- // NOTE: The SDK uses camelCase keys in providerOptions, then internally maps to
74
- // snake_case for the OpenAI API. Always use camelCase here.
75
- type OpenAIImageProviderOptions = {
76
- quality?: 'standard' | 'low' | 'medium' | 'high' | 'auto'
77
- outputFormat?: 'png' | 'jpeg' | 'webp'
78
- outputCompression?: number
79
- size?: `${number}x${number}`
80
- partialImages?: number | null
81
- background?: 'auto' | 'opaque' | 'transparent'
82
- }
52
+ import {
53
+ generateImageUncached as generateImage,
54
+ generateVideoUncached as generateVideo,
55
+ calculateCost,
56
+ ValidationError,
57
+ type GeneratedFile,
58
+ } from './generate.js'
59
+ import {
60
+ generateSpeechUncached as generateSpeech,
61
+ } from './speech-generate.js'
62
+ import {
63
+ transcribeAudioUncached as transcribeAudio,
64
+ segmentsToWordTimestamps,
65
+ } from './transcription-generate.js'
66
+ import {
67
+ SPEECH_CATALOG,
68
+ type SpeechModelEntry,
69
+ } from './speech-catalog.js'
70
+ import {
71
+ TRANSCRIPTION_CATALOG,
72
+ type TranscriptionModelEntry,
73
+ } from './transcription-catalog.js'
74
+ import {
75
+ DEFAULT_SPEECH_MODEL,
76
+ } from './speech-models.js'
77
+ import {
78
+ DEFAULT_TRANSCRIPTION_MODEL,
79
+ } from './transcription-models.js'
80
+ import {
81
+ separateAudioUncached as separateAudio,
82
+ downloadStem,
83
+ type StemName,
84
+ type DemucsModel,
85
+ } from './separation-generate.js'
86
+ import {
87
+ cloneVoiceUncached as cloneVoice,
88
+ type CloneProvider,
89
+ } from './clone-generate.js'
83
90
 
84
91
  const cli = goke('egaki')
85
92
 
@@ -358,18 +365,13 @@ cli
358
365
  .example('# Pipe to another tool')
359
366
  .example('egaki image "logo design" --stdout | convert - -resize 512x512 logo.png')
360
367
  .action(async (prompt, options) => {
361
- // Inject stored API keys as env vars before calling the AI SDK.
362
- injectCredentialsToEnv()
363
-
364
368
  const model = options.model ?? await resolveImageModel()
365
369
  const outputPath = options.output ?? 'egaki-output.png'
366
- const count = options.count ?? 1
367
- const config = getModelConfig(model)
368
- const parsedAspectRatio = parseAspectRatioOption(options.aspectRatio)
369
370
 
370
371
  if (!options.stdout) {
371
372
  console.error(pc.dim(`Model: ${model}`))
372
373
  console.error(pc.dim(`Prompt: ${prompt}`))
374
+ console.error(pc.cyan('Generating...'))
373
375
  }
374
376
 
375
377
  const inputImages = await readInputImages(options.input)
@@ -377,56 +379,49 @@ cli
377
379
  ? await readInputSource(options.mask)
378
380
  : undefined
379
381
 
380
- // When using ChatGPT OAuth with OpenAI image models, route through the
381
- // Responses API + imageGeneration tool instead of the Image API.
382
- // The Codex OAuth client lacks the `api.model.images.request` scope.
383
- const useResponsesApi = config.strategy === 'image' && shouldUseResponsesApi(model)
382
+ const result = await runProviderCall('Image generation', () => generateImage({
383
+ prompt,
384
+ model,
385
+ count: options.count ?? 1,
386
+ aspectRatio: options.aspectRatio,
387
+ seed: options.seed,
388
+ inputImages,
389
+ maskImage,
390
+ allowPeople: options.allowPeople || false,
391
+ quality: options.quality,
392
+ resolution: options.resolution,
393
+ outputFormat: options.outputFormat,
394
+ negativePrompt: options.negativePrompt,
395
+ imageSize: options.imageSize,
396
+ }))
397
+
398
+ if (options.stdout) {
399
+ writeFirstToStdout(result.images)
400
+ return
401
+ }
384
402
 
385
- if (useResponsesApi) {
386
- if (!options.stdout) {
387
- console.error(pc.dim('Mode: Responses API (ChatGPT OAuth)'))
388
- }
389
- await generateWithResponsesApi({
390
- prompt,
391
- model,
392
- outputPath,
393
- count,
394
- aspectRatio: parsedAspectRatio,
395
- seed: options.seed,
396
- inputImages,
397
- maskImage,
398
- json: options.json || false,
399
- stdout: options.stdout || false,
400
- })
401
- } else if (config.strategy === 'image') {
402
- await generateWithImageModel({
403
- prompt,
404
- model,
405
- outputPath,
406
- count,
407
- aspectRatio: parsedAspectRatio,
408
- seed: options.seed,
409
- inputImages,
410
- maskImage,
411
- allowPeople: options.allowPeople || false,
412
- quality: options.quality,
413
- resolution: options.resolution,
414
- outputFormat: options.outputFormat,
415
- negativePrompt: options.negativePrompt,
416
- json: options.json || false,
417
- stdout: options.stdout || false,
418
- })
419
- } else {
420
- await generateWithTextModel({
421
- prompt,
403
+ const savedFiles = saveGeneratedFiles(result.images, outputPath)
404
+ printCost(result.cost)
405
+
406
+ if (result.revisedPrompt && !options.json) {
407
+ console.error(pc.dim(`Revised prompt: ${result.revisedPrompt}`))
408
+ }
409
+
410
+ if (result.text && !options.json) {
411
+ console.error(pc.dim(result.text))
412
+ }
413
+
414
+ if (options.json) {
415
+ console.log(JSON.stringify({
422
416
  model,
423
- outputPath,
424
- inputImages,
425
- imageSize: options.imageSize,
426
- aspectRatio: options.aspectRatio,
427
- json: options.json || false,
428
- stdout: options.stdout || false,
429
- })
417
+ files: savedFiles,
418
+ count: result.images.length,
419
+ cost: result.cost,
420
+ usage: result.usage,
421
+ warnings: result.warnings,
422
+ ...(result.text ? { text: result.text } : {}),
423
+ ...(result.revisedPrompt !== undefined ? { revisedPrompt: result.revisedPrompt } : {}),
424
+ }, null, 2))
430
425
  }
431
426
  })
432
427
 
@@ -501,7 +496,7 @@ cli
501
496
  z
502
497
  .array(z.string())
503
498
  .describe(
504
- 'Reference images for R2V generation, repeatable, 1-7 files or URLs (xAI)',
499
+ 'Reference images for R2V generation, repeatable. xAI: 1-7 images. Seedance: 1-9 images (use @Image1 etc. in prompt)',
505
500
  ),
506
501
  )
507
502
  .option(
@@ -535,18 +530,17 @@ cli
535
530
 
536
531
  const model = options.model ?? await resolveVideoModel()
537
532
  const outputPath = options.output ?? 'egaki-output.mp4'
538
- const count = options.count ?? 1
539
533
  const config = getModelConfig(model)
540
534
 
541
- if (config.strategy !== 'video') {
542
- console.error(pc.red(`Model ${model} is not a video model`))
535
+ if (config instanceof Error) {
536
+ console.error(pc.red(config.message))
543
537
  process.exit(1)
544
538
  }
545
539
 
546
540
  if (!options.stdout) {
547
541
  console.error(pc.dim(`Model: ${model}`))
548
542
  console.error(pc.dim(`Prompt: ${prompt}`))
549
- console.error(pc.dim('Mode: Video API (experimental_generateVideo)'))
543
+ console.error(pc.cyan('Generating...'))
550
544
  }
551
545
 
552
546
  // Determine how --input is used based on --mode:
@@ -554,28 +548,21 @@ cli
554
548
  // - everything else (i2v, t2v): input is a reference image, passed as bytes
555
549
  const isVideoInputMode = options.mode === 'edit-video' || options.mode === 'extend-video'
556
550
 
557
- // Validate mode-specific required inputs
558
- if (isVideoInputMode && !options.input) {
559
- console.error(pc.red(`--input is required with --mode ${options.mode}. Pass a local video file or URL.`))
560
- process.exit(1)
561
- }
562
- if (options.mode === 'reference-to-video' && (!options.referenceImages || options.referenceImages.length === 0)) {
563
- console.error(pc.red('--reference-images is required with --mode reference-to-video. Pass 1-7 image files or URLs.'))
564
- process.exit(1)
551
+ // Auto-set --mode reference-to-video for providers that require it (xAI) when
552
+ // --reference-images is provided without an explicit --mode.
553
+ if (options.referenceImages?.length && !options.mode && config.provider === 'xai') {
554
+ options.mode = 'reference-to-video'
565
555
  }
566
556
 
567
557
  let inputImage: Uint8Array | undefined
568
558
  let videoUrl: string | undefined
569
559
 
570
560
  if (options.input && isVideoInputMode) {
571
- // Upload local file or use URL directly for video editing/extension
572
561
  videoUrl = await resolveToUrl(options.input)
573
562
  } else if (options.input) {
574
- // Read as bytes for image-to-video
575
563
  inputImage = await readInputSource(options.input)
576
564
  }
577
565
 
578
- // Resolve --reference-images: upload local files to get URLs
579
566
  let resolvedReferenceImages = options.referenceImages
580
567
  if (resolvedReferenceImages) {
581
568
  resolvedReferenceImages = await Promise.all(
@@ -583,24 +570,587 @@ cli
583
570
  )
584
571
  }
585
572
 
586
- await generateWithVideoModel({
587
- prompt,
588
- model,
589
- outputPath,
590
- count,
591
- aspectRatio: options.aspectRatio,
592
- resolution: options.resolution,
593
- duration: options.duration,
594
- fps: options.fps,
595
- seed: options.seed,
596
- inputImage,
597
- mode: options.mode,
598
- videoUrl,
599
- referenceImages: resolvedReferenceImages,
600
- negativePrompt: options.negativePrompt,
601
- json: options.json || false,
602
- stdout: options.stdout || false,
603
- })
573
+ const result = await runProviderCall('Video generation', () => generateVideo({
574
+ prompt,
575
+ model,
576
+ count: options.count ?? 1,
577
+ aspectRatio: options.aspectRatio,
578
+ resolution: options.resolution,
579
+ duration: options.duration,
580
+ fps: options.fps,
581
+ seed: options.seed,
582
+ inputImage,
583
+ mode: options.mode,
584
+ videoUrl,
585
+ referenceImages: resolvedReferenceImages,
586
+ negativePrompt: options.negativePrompt,
587
+ }))
588
+
589
+ if (options.stdout) {
590
+ writeFirstToStdout(result.videos)
591
+ return
592
+ }
593
+
594
+ const savedFiles = saveGeneratedFiles(result.videos, outputPath)
595
+ printCost(result.cost)
596
+
597
+ if (options.json) {
598
+ console.log(JSON.stringify({
599
+ model,
600
+ files: savedFiles,
601
+ count: result.videos.length,
602
+ cost: result.cost,
603
+ warnings: result.warnings,
604
+ responses: result.responses,
605
+ }, null, 2))
606
+ }
607
+ })
608
+
609
+ // ─── speech command ──────────────────────────────────────────────────────────
610
+
611
+ cli
612
+ .command(
613
+ 'speech [text]',
614
+ dedent`
615
+ Generate speech audio from text using AI text-to-speech models.
616
+ Supports OpenAI (tts-1, tts-1-hd, gpt-4o-mini-tts), ElevenLabs
617
+ (eleven_v3, eleven_multilingual_v2, eleven_flash_v2_5), and Cartesia
618
+ (sonic-3.5, sonic-3). Uses your provider API key directly (egaki
619
+ subscription does not cover speech yet).
620
+ `,
621
+ )
622
+ .option(
623
+ '-m, --model [model]',
624
+ z.string().describe('Speech model ID. If omitted, shows an interactive picker (or uses default in non-TTY mode)'),
625
+ )
626
+ .option(
627
+ '-o, --output [path]',
628
+ z
629
+ .string()
630
+ .default('egaki-output')
631
+ .describe('Output file path (extension added from audio format if omitted)'),
632
+ )
633
+ .option(
634
+ '--voice [voice]',
635
+ z
636
+ .string()
637
+ .describe('Voice ID or name (provider-specific). OpenAI: alloy, echo, nova, etc. ElevenLabs/Cartesia: voice ID from their library'),
638
+ )
639
+ .option(
640
+ '--output-format [format]',
641
+ z
642
+ .string()
643
+ .describe('Audio output format: mp3, wav, pcm, opus, aac, flac (provider support varies)'),
644
+ )
645
+ .option(
646
+ '--speed [speed]',
647
+ z
648
+ .number()
649
+ .describe('Playback speed multiplier (OpenAI: 0.25-4.0, Cartesia: 0.6-1.5)'),
650
+ )
651
+ .option(
652
+ '--instructions [text]',
653
+ z
654
+ .string()
655
+ .describe('Style instructions for speech (only gpt-4o-mini-tts and ElevenLabs). E.g. "Speak in a calm, soothing tone"'),
656
+ )
657
+ .option(
658
+ '--language [lang]',
659
+ z
660
+ .string()
661
+ .describe('ISO 639-1 language code (e.g. en, es, fr). ElevenLabs and Cartesia'),
662
+ )
663
+ .option(
664
+ '--stdin',
665
+ 'Read text from stdin instead of the positional argument',
666
+ )
667
+ .option(
668
+ '--json',
669
+ 'Output result metadata as JSON to stdout (model, file path, cost)',
670
+ )
671
+ .option(
672
+ '--stdout',
673
+ 'Write raw audio bytes to stdout instead of saving to a file. Useful for piping',
674
+ )
675
+ .example('# Generate speech with default model')
676
+ .example('egaki speech "Hello, welcome to egaki!"')
677
+ .example('# Use a specific model and voice')
678
+ .example('egaki speech "Good morning" -m tts-1-hd --voice nova')
679
+ .example('# Use GPT-4o Mini TTS with style instructions')
680
+ .example('egaki speech "Breaking news today" -m gpt-4o-mini-tts --instructions "Speak like a news anchor"')
681
+ .example('# ElevenLabs with a voice ID')
682
+ .example('egaki speech "Hello world" -m eleven_v3 --voice 21m00Tcm4TlvDq8ikWAM')
683
+ .example('# Read text from stdin')
684
+ .example('cat script.txt | egaki speech --stdin -o narration.mp3')
685
+ .example('# Cartesia Sonic 3.5 with a voice ID')
686
+ .example('egaki speech "Hello world" -m sonic-3.5 --voice f786b574-daa5-4673-aa0c-cbe3e8534c02')
687
+ .example('# Pipe audio to another tool')
688
+ .example('egaki speech "test" --stdout | ffplay -nodisp -autoexit -')
689
+ .action(async (text = '', options) => {
690
+ let inputText = text
691
+ if (options.stdin) {
692
+ inputText = await readTextFromStdin()
693
+ }
694
+
695
+ if (!inputText || inputText.trim().length === 0) {
696
+ console.error(pc.red('No text provided. Pass text as an argument or use --stdin.'))
697
+ process.exit(1)
698
+ }
699
+
700
+ const model = options.model ?? await resolveSpeechModel()
701
+ const outputPath = options.output ?? 'egaki-output'
702
+
703
+ if (!options.stdout) {
704
+ console.error(pc.dim(`Model: ${model}`))
705
+ console.error(pc.dim(`Text: ${inputText.length > 80 ? inputText.slice(0, 80) + '...' : inputText}`))
706
+ console.error(pc.cyan('Generating speech...'))
707
+ }
708
+
709
+ const result = await runProviderCall('Speech generation', () => generateSpeech({
710
+ text: inputText,
711
+ model,
712
+ voice: options.voice,
713
+ outputFormat: options.outputFormat,
714
+ instructions: options.instructions,
715
+ speed: options.speed,
716
+ language: options.language,
717
+ }))
718
+
719
+ if (options.stdout) {
720
+ process.stdout.write(Buffer.from(result.audio.uint8Array))
721
+ return
722
+ }
723
+
724
+ const ext = extensionFromMediaType(result.audio.mediaType)
725
+ const finalPath = ensureExtension(outputPath, ext)
726
+ fs.writeFileSync(finalPath, result.audio.uint8Array)
727
+ console.error(pc.green(`Saved: ${finalPath}`))
728
+ printCost(result.cost)
729
+
730
+ if (options.json) {
731
+ console.log(JSON.stringify({
732
+ model,
733
+ file: finalPath,
734
+ cost: result.cost,
735
+ textLength: inputText.length,
736
+ timestamps: result.timestamps ? result.timestamps.length : 0,
737
+ }, null, 2))
738
+ }
739
+ })
740
+
741
+ // ─── transcribe command ──────────────────────────────────────────────────────
742
+
743
+ cli
744
+ .command(
745
+ 'transcribe [audio]',
746
+ dedent`
747
+ Transcribe audio to text using AI speech-to-text models.
748
+ Supports OpenAI (whisper-1, gpt-4o-transcribe, gpt-4o-mini-transcribe),
749
+ ElevenLabs (scribe_v1), Deepgram (nova-3), Groq (whisper-large-v3,
750
+ whisper-large-v3-turbo, distil-whisper-large-v3-en), and Cartesia
751
+ (ink-whisper). Outputs JSON with text, timestamps, language, and duration.
752
+ `,
753
+ )
754
+ .option(
755
+ '-m, --model [model]',
756
+ z.string().describe('Transcription model ID. If omitted, shows an interactive picker (or uses default in non-TTY mode)'),
757
+ )
758
+ .option(
759
+ '-o, --output [path]',
760
+ z
761
+ .string()
762
+ .describe('Output file path for the JSON result'),
763
+ )
764
+ .option(
765
+ '--language [lang]',
766
+ z
767
+ .string()
768
+ .describe('ISO 639-1 language hint (e.g. en, es, fr). Helps accuracy for known languages'),
769
+ )
770
+ .option(
771
+ '--stdin',
772
+ 'Read audio from stdin instead of a file path',
773
+ )
774
+ .option(
775
+ '--stdout',
776
+ 'Write only the plain text transcript to stdout (no JSON metadata)',
777
+ )
778
+ .example('# Transcribe an audio file with default model')
779
+ .example('egaki transcribe recording.mp3')
780
+ .example('# Use a specific model')
781
+ .example('egaki transcribe interview.wav -m gpt-4o-transcribe')
782
+ .example('# Groq for fast, cheap transcription')
783
+ .example('egaki transcribe podcast.mp3 -m whisper-large-v3')
784
+ .example('# Get plain text only')
785
+ .example('egaki transcribe recording.mp3 --stdout')
786
+ .example('# Save JSON result to file')
787
+ .example('egaki transcribe recording.mp3 -o transcript.json')
788
+ .example('# Pipe audio from another command')
789
+ .example('ffmpeg -i video.mp4 -f mp3 - | egaki transcribe --stdin -m whisper-1')
790
+ .action(async (audioPath = '', options) => {
791
+ if (!options.stdin && !audioPath) {
792
+ console.error(pc.red('No audio provided. Pass an audio file path or use --stdin.'))
793
+ process.exit(1)
794
+ }
795
+
796
+ let audioData: Uint8Array
797
+
798
+ if (options.stdin) {
799
+ const chunks: Buffer[] = []
800
+ for await (const chunk of process.stdin) {
801
+ chunks.push(chunk)
802
+ }
803
+ audioData = new Uint8Array(Buffer.concat(chunks))
804
+ } else {
805
+ const resolved = path.resolve(audioPath)
806
+ if (!fs.existsSync(resolved)) {
807
+ console.error(pc.red(`File not found: ${resolved}`))
808
+ process.exit(1)
809
+ }
810
+ audioData = new Uint8Array(fs.readFileSync(resolved))
811
+ }
812
+
813
+ if (audioData.length === 0) {
814
+ console.error(pc.red('No audio data provided. Pass an audio file path or use --stdin.'))
815
+ process.exit(1)
816
+ }
817
+
818
+ const model = options.model ?? await resolveTranscriptionModel()
819
+
820
+ if (!options.stdout) {
821
+ console.error(pc.dim(`Model: ${model}`))
822
+ console.error(pc.dim(`Audio: ${options.stdin ? 'stdin' : audioPath} (${(audioData.length / 1024).toFixed(1)} KB)`))
823
+ console.error(pc.cyan('Transcribing...'))
824
+ }
825
+
826
+ const result = await runProviderCall('Transcription', () => transcribeAudio({
827
+ audio: audioData,
828
+ model,
829
+ language: options.language,
830
+ }))
831
+
832
+ if (options.stdout) {
833
+ process.stdout.write(result.text)
834
+ return
835
+ }
836
+
837
+ const output = {
838
+ text: result.text,
839
+ segments: result.segments,
840
+ wordTimestamps: segmentsToWordTimestamps(result.segments),
841
+ language: result.language,
842
+ durationInSeconds: result.durationInSeconds,
843
+ model: result.model,
844
+ cost: result.cost,
845
+ }
846
+
847
+ if (options.output) {
848
+ const outputPath = path.resolve(options.output)
849
+ fs.writeFileSync(outputPath, JSON.stringify(output, null, 2))
850
+ console.error(pc.green(`Saved: ${outputPath}`))
851
+ } else {
852
+ console.log(JSON.stringify(output, null, 2))
853
+ }
854
+
855
+ printCost(result.cost)
856
+ })
857
+
858
+ // ─── demucs command ──────────────────────────────────────────────────────────
859
+
860
+ cli
861
+ .command(
862
+ 'demucs [audio]',
863
+ dedent`
864
+ Separate audio into stems (vocals, background, drums, bass, etc.)
865
+ using fal.ai's Demucs model. Requires a fal.ai API key.
866
+ By default extracts vocals and background (other) stems.
867
+ `,
868
+ )
869
+ .option(
870
+ '--stems [list]',
871
+ z
872
+ .string()
873
+ .describe('Comma-separated stems to extract: vocals, drums, bass, other, guitar, piano. Default: vocals,other'),
874
+ )
875
+ .option(
876
+ '--model [model]',
877
+ z
878
+ .string()
879
+ .describe('Demucs model variant. Default: htdemucs_6s. Options: htdemucs, htdemucs_ft, htdemucs_6s, hdemucs_mmi, mdx, mdx_extra, mdx_q, mdx_extra_q'),
880
+ )
881
+ .option(
882
+ '--output-format [format]',
883
+ z
884
+ .enum(['mp3', 'wav'])
885
+ .default('mp3')
886
+ .describe('Output audio format: mp3 or wav'),
887
+ )
888
+ .option(
889
+ '-o, --output [dir]',
890
+ z
891
+ .string()
892
+ .describe('Output directory for separated stems. Default: current directory'),
893
+ )
894
+ .option(
895
+ '--shifts [n]',
896
+ z
897
+ .number()
898
+ .describe('Number of random shifts for quality (higher = better but slower). Default: 1'),
899
+ )
900
+ .option(
901
+ '--overlap [n]',
902
+ z
903
+ .number()
904
+ .describe('Segment overlap 0.0-1.0 (higher = better quality). Default: 0.25'),
905
+ )
906
+ .option(
907
+ '--stdin',
908
+ 'Read audio from stdin instead of a file path',
909
+ )
910
+ .option(
911
+ '--json',
912
+ 'Output result metadata as JSON to stdout',
913
+ )
914
+ .example('# Separate vocals and background from an audio file')
915
+ .example('egaki demucs song.mp3')
916
+ .example('# Extract all stems (vocals, drums, bass, guitar, piano, other)')
917
+ .example('egaki demucs song.mp3 --stems vocals,drums,bass,other,guitar,piano')
918
+ .example('# Extract only vocals as WAV')
919
+ .example('egaki demucs song.mp3 --stems vocals --output-format wav')
920
+ .example('# Save to a specific directory')
921
+ .example('egaki demucs song.mp3 -o stems/')
922
+ .example('# Higher quality (slower)')
923
+ .example('egaki demucs song.mp3 --shifts 5')
924
+ .action(async (audioPath = '', options) => {
925
+ if (!options.stdin && !audioPath) {
926
+ console.error(pc.red('No audio provided. Pass an audio file path or use --stdin.'))
927
+ process.exit(1)
928
+ }
929
+
930
+ let audioData: Uint8Array
931
+ let filename: string | undefined
932
+
933
+ if (options.stdin) {
934
+ const chunks: Buffer[] = []
935
+ for await (const chunk of process.stdin) {
936
+ chunks.push(chunk)
937
+ }
938
+ audioData = new Uint8Array(Buffer.concat(chunks))
939
+ } else {
940
+ const resolved = path.resolve(audioPath)
941
+ if (!fs.existsSync(resolved)) {
942
+ console.error(pc.red(`File not found: ${resolved}`))
943
+ process.exit(1)
944
+ }
945
+ audioData = new Uint8Array(fs.readFileSync(resolved))
946
+ filename = path.basename(resolved)
947
+ }
948
+
949
+ if (audioData.length === 0) {
950
+ console.error(pc.red('No audio data provided.'))
951
+ process.exit(1)
952
+ }
953
+
954
+ const stems: StemName[] = options.stems
955
+ ? (options.stems as string).split(',').map((s: string) => s.trim() as StemName)
956
+ : ['vocals', 'other']
957
+ const model = (options.model ?? 'htdemucs_6s') as DemucsModel
958
+ const outputFormat = options.outputFormat ?? 'mp3'
959
+ const outputDir = options.output ? path.resolve(options.output) : process.cwd()
960
+
961
+ console.error(pc.dim(`Model: ${model}`))
962
+ console.error(pc.dim(`Stems: ${stems.join(', ')}`))
963
+ console.error(pc.dim(`Audio: ${options.stdin ? 'stdin' : audioPath} (${(audioData.length / 1024 / 1024).toFixed(1)} MB)`))
964
+ console.error(pc.cyan('Separating audio...'))
965
+
966
+ const result = await runProviderCall('Audio separation', () => separateAudio({
967
+ audio: audioData,
968
+ stems,
969
+ model,
970
+ outputFormat,
971
+ shifts: options.shifts,
972
+ overlap: options.overlap,
973
+ filename,
974
+ onProgress: (msg) => console.error(pc.dim(msg)),
975
+ }))
976
+
977
+ // Create output directory if needed
978
+ if (!fs.existsSync(outputDir)) {
979
+ fs.mkdirSync(outputDir, { recursive: true })
980
+ }
981
+
982
+ // Download and save each stem
983
+ const baseName = filename
984
+ ? path.basename(filename, path.extname(filename))
985
+ : 'audio'
986
+ const savedFiles: Array<{ stem: string; path: string; size: number }> = []
987
+
988
+ for (const stem of result.stems) {
989
+ console.error(pc.dim(`Downloading ${stem.name}...`))
990
+ const bytes = await downloadStem(stem.url)
991
+ if (bytes instanceof Error) {
992
+ console.error(pc.red(`Failed to download ${stem.name}: ${bytes.message}`))
993
+ continue
994
+ }
995
+ const stemPath = path.join(outputDir, `${baseName}-${stem.name}.${outputFormat}`)
996
+ fs.writeFileSync(stemPath, bytes)
997
+ savedFiles.push({ stem: stem.name, path: stemPath, size: bytes.length })
998
+ console.error(pc.green(`Saved: ${stemPath} (${(bytes.length / 1024 / 1024).toFixed(1)} MB)`))
999
+ }
1000
+
1001
+ if (options.json) {
1002
+ console.log(JSON.stringify({
1003
+ model: result.model,
1004
+ stems: savedFiles,
1005
+ }, null, 2))
1006
+ }
1007
+ })
1008
+
1009
+ // ─── voice clone command ─────────────────────────────────────────────────────
1010
+
1011
+ cli
1012
+ .command(
1013
+ 'voice clone [audio]',
1014
+ dedent`
1015
+ Clone a voice from an audio clip. Returns a voice ID for use with
1016
+ 'egaki speech --voice <id>'. Supports Cartesia (default) and ElevenLabs.
1017
+
1018
+ Best practices for high-quality clones:
1019
+
1020
+ 1. Isolate vocals first: 'egaki demucs recording.mp3 --stems vocals'
1021
+ removes background music, noise, and other speakers.
1022
+ 2. Find a clean snippet: 'egaki transcribe recording-vocals.mp3' to get
1023
+ word timestamps. Pick a 5-10s segment with a complete phrase, clear
1024
+ speech, no hesitations or crosstalk.
1025
+ 3. Trim to speech boundaries:
1026
+ ffmpeg -i recording-vocals.mp3 -ss 12.5 -to 22.0 -c copy clip.mp3
1027
+ No silence padding at start or end.
1028
+ 4. Match energy to intent: the clone mimics the tone and pacing of the
1029
+ source clip. Use an energetic clip for energetic output.
1030
+ 5. Speak in the target language. Use --language for Cartesia clones.
1031
+
1032
+ Cartesia: up to 10s of audio, instant, free. Good for short clean clips.
1033
+ ElevenLabs: 1-3 min recommended, has --remove-background-noise option.
1034
+ `,
1035
+ )
1036
+ .option(
1037
+ '--name [name]',
1038
+ z
1039
+ .string()
1040
+ .describe('Name for the cloned voice (required)'),
1041
+ )
1042
+ .option(
1043
+ '-p, --provider [provider]',
1044
+ z
1045
+ .enum(['cartesia', 'elevenlabs'])
1046
+ .default('cartesia')
1047
+ .describe('Voice cloning provider: cartesia or elevenlabs'),
1048
+ )
1049
+ .option(
1050
+ '--language [lang]',
1051
+ z
1052
+ .string()
1053
+ .describe('Cartesia only: ISO 639-1 language code (default: en). E.g. en, es, fr, de, ja'),
1054
+ )
1055
+ .option(
1056
+ '--description [text]',
1057
+ z
1058
+ .string()
1059
+ .describe('Optional description for the voice'),
1060
+ )
1061
+ .option(
1062
+ '--base-voice-id [id]',
1063
+ z
1064
+ .string()
1065
+ .describe('Cartesia: optional base voice ID to derive from'),
1066
+ )
1067
+ .option(
1068
+ '--remove-background-noise',
1069
+ 'ElevenLabs: apply AI noise removal to the clip before cloning',
1070
+ )
1071
+ .option(
1072
+ '--stdin',
1073
+ 'Read audio from stdin instead of a file path',
1074
+ )
1075
+ .option(
1076
+ '--json',
1077
+ 'Output result as JSON to stdout',
1078
+ )
1079
+ .example('# Clone a voice from a recording')
1080
+ .example('egaki voice clone recording.wav --name "My Voice"')
1081
+ .example('# Clone with ElevenLabs and noise removal')
1082
+ .example('egaki voice clone vocals.mp3 --name "Narrator" --provider elevenlabs --remove-background-noise')
1083
+ .example('# Full pipeline: separate → trim → clone')
1084
+ .example('egaki demucs interview.mp3 --stems vocals')
1085
+ .example('ffmpeg -i interview-vocals.mp3 -ss 5.0 -to 15.0 -c copy clip.mp3')
1086
+ .example('egaki voice clone clip.mp3 --name "Speaker"')
1087
+ .example('# Use the cloned voice')
1088
+ .example('egaki speech "Hello world" --voice <voice-id>')
1089
+ .action(async (audioPath = '', options) => {
1090
+ if (!options.stdin && !audioPath) {
1091
+ console.error(pc.red('No audio provided. Pass an audio file path or use --stdin.'))
1092
+ process.exit(1)
1093
+ }
1094
+
1095
+ const name = options.name
1096
+ if (!name) {
1097
+ console.error(pc.red('Voice name is required. Use --name "My Voice".'))
1098
+ process.exit(1)
1099
+ }
1100
+
1101
+ let audioData: Uint8Array
1102
+ let filename: string | undefined
1103
+
1104
+ if (options.stdin) {
1105
+ const chunks: Buffer[] = []
1106
+ for await (const chunk of process.stdin) {
1107
+ chunks.push(chunk)
1108
+ }
1109
+ audioData = new Uint8Array(Buffer.concat(chunks))
1110
+ } else {
1111
+ const resolved = path.resolve(audioPath)
1112
+ if (!fs.existsSync(resolved)) {
1113
+ console.error(pc.red(`File not found: ${resolved}`))
1114
+ process.exit(1)
1115
+ }
1116
+ audioData = new Uint8Array(fs.readFileSync(resolved))
1117
+ filename = path.basename(resolved)
1118
+ }
1119
+
1120
+ if (audioData.length === 0) {
1121
+ console.error(pc.red('No audio data provided.'))
1122
+ process.exit(1)
1123
+ }
1124
+
1125
+ const provider = (options.provider ?? 'cartesia') as CloneProvider
1126
+
1127
+ console.error(pc.dim(`Provider: ${provider}`))
1128
+ console.error(pc.dim(`Name: ${name}`))
1129
+ console.error(pc.dim(`Audio: ${options.stdin ? 'stdin' : audioPath} (${(audioData.length / 1024).toFixed(1)} KB)`))
1130
+ console.error(pc.cyan('Cloning voice...'))
1131
+
1132
+ const result = await runProviderCall('Voice cloning', () => cloneVoice({
1133
+ audio: audioData,
1134
+ name,
1135
+ provider,
1136
+ language: options.language,
1137
+ description: options.description,
1138
+ baseVoiceId: options.baseVoiceId,
1139
+ removeBackgroundNoise: options.removeBackgroundNoise || false,
1140
+ filename,
1141
+ }))
1142
+
1143
+ console.error(pc.green(`Voice cloned successfully!`))
1144
+ console.error(pc.bold(`Voice ID: ${result.voiceId}`))
1145
+ console.error(pc.dim(`Use it: egaki speech "Hello" --voice ${result.voiceId}`))
1146
+
1147
+ if (options.json) {
1148
+ console.log(JSON.stringify({
1149
+ voiceId: result.voiceId,
1150
+ name: result.name,
1151
+ provider: result.provider,
1152
+ }, null, 2))
1153
+ }
604
1154
  })
605
1155
 
606
1156
  // ─── models command ──────────────────────────────────────────────────────────
@@ -620,9 +1170,9 @@ cli
620
1170
  .option(
621
1171
  '--type [type]',
622
1172
  z
623
- .enum(['all', 'image', 'video'])
1173
+ .enum(['all', 'image', 'video', 'speech', 'transcription'])
624
1174
  .default('all')
625
- .describe('Filter by model type: image (image+text-image), video, or all'),
1175
+ .describe('Filter by model type: image, video, speech, transcription, or all'),
626
1176
  )
627
1177
  .option('--json', 'Output as JSON instead of YAML')
628
1178
  .action(async (options) => {
@@ -631,12 +1181,16 @@ cli
631
1181
  Object.keys(PROVIDERS).map((provider) => [provider, getKeyStatus(provider)]),
632
1182
  )
633
1183
 
634
- let models: Array<typeof CATALOG[number] | typeof VIDEO_CATALOG[number]> =
1184
+ let models: Array<typeof CATALOG[number] | typeof VIDEO_CATALOG[number] | SpeechModelEntry | TranscriptionModelEntry> =
635
1185
  options.type === 'video'
636
1186
  ? [...VIDEO_CATALOG]
637
1187
  : options.type === 'image'
638
1188
  ? [...CATALOG]
639
- : [...CATALOG, ...VIDEO_CATALOG]
1189
+ : options.type === 'speech'
1190
+ ? [...SPEECH_CATALOG]
1191
+ : options.type === 'transcription'
1192
+ ? [...TRANSCRIPTION_CATALOG]
1193
+ : [...CATALOG, ...VIDEO_CATALOG, ...SPEECH_CATALOG, ...TRANSCRIPTION_CATALOG]
640
1194
 
641
1195
  if (options.provider) {
642
1196
  models = models.filter((m) => m.provider === options.provider)
@@ -669,14 +1223,34 @@ cli
669
1223
  ? `${m.features.durationRangeSec.min}-${m.features.durationRangeSec.max}`
670
1224
  : 'unknown',
671
1225
  }
672
- : {
673
- editing: m.features.editing,
674
- inpainting: m.features.inpainting,
675
- seed: m.features.seed,
676
- multipleImages: m.features.multipleImages,
677
- aspectRatios: m.features.aspectRatios.join(', ') || 'none',
678
- ...(m.features.sizes ? { sizes: m.features.sizes.join(', ') } : {}),
679
- },
1226
+ : m.strategy === 'speech'
1227
+ ? {
1228
+ outputFormats: m.features.outputFormats.join(', '),
1229
+ instructions: m.features.instructions,
1230
+ speed: m.features.speed,
1231
+ language: m.features.language,
1232
+ maxChars: m.features.maxChars || 'unlimited',
1233
+ voices: m.features.defaultVoices.length > 0
1234
+ ? m.features.defaultVoices.join(', ')
1235
+ : 'use provider voice library',
1236
+ }
1237
+ : m.strategy === 'transcription'
1238
+ ? {
1239
+ wordTimestamps: m.features.wordTimestamps,
1240
+ diarization: m.features.diarization,
1241
+ languageDetection: m.features.languageDetection,
1242
+ languageHint: m.features.languageHint,
1243
+ inputFormats: m.features.inputFormats.join(', '),
1244
+ maxDurationSec: m.features.maxDurationSec || 'unlimited',
1245
+ }
1246
+ : {
1247
+ editing: m.features.editing,
1248
+ inpainting: m.features.inpainting,
1249
+ seed: m.features.seed,
1250
+ multipleImages: m.features.multipleImages,
1251
+ aspectRatios: m.features.aspectRatios.join(', ') || 'none',
1252
+ ...(m.features.sizes ? { sizes: m.features.sizes.join(', ') } : {}),
1253
+ },
680
1254
  }))
681
1255
 
682
1256
  if (options.json) {
@@ -693,18 +1267,30 @@ const MAX_UPLOAD_SIZE = 100 * 1024 * 1024
693
1267
 
694
1268
  cli.help()
695
1269
  cli.version(pkg.version)
696
- await cli.parse()
1270
+
1271
+ export { cli }
1272
+
697
1273
 
698
1274
  // ─── error output helpers ────────────────────────────────────────────────────
699
1275
 
700
1276
  type PrintableValue = unknown
701
1277
  type ErrorDetailsInput = Parameters<typeof APICallError.isInstance>[0]
702
1278
 
703
- async function runProviderCall<T>(operation: string, call: () => Promise<T>): Promise<T> {
1279
+ async function runProviderCall<T>(operation: string, call: () => Promise<Error | T>): Promise<T> {
704
1280
  const result = await call().catch((error) => {
705
1281
  printErrorDetails(error, operation)
706
1282
  process.exit(1)
707
1283
  })
1284
+ if (result instanceof Error) {
1285
+ // Validation errors get a clean one-line message; provider/runtime errors
1286
+ // get full details (status code, response body, stack trace, etc.).
1287
+ if (result instanceof ValidationError) {
1288
+ console.error(pc.red(result.message))
1289
+ process.exit(1)
1290
+ }
1291
+ printErrorDetails(result, operation)
1292
+ process.exit(1)
1293
+ }
708
1294
  return result
709
1295
  }
710
1296
 
@@ -749,6 +1335,18 @@ function printErrorDetails(error: ErrorDetailsInput, operation?: string): void {
749
1335
  if (error instanceof Error) {
750
1336
  printKeyValue('name', error.name)
751
1337
  printKeyValue('message', error.message)
1338
+ // @ai-sdk/xai discards statusResponse.error when status==='failed',
1339
+ // so XAI_VIDEO_GENERATION_FAILED carries no useful info. Add a hint.
1340
+ if (error.name === 'XAI_VIDEO_GENERATION_FAILED') {
1341
+ console.error(
1342
+ pc.dim(
1343
+ 'hint: xAI returned status "failed" but @ai-sdk/xai discards the error details.\n' +
1344
+ 'Common causes: invalid/corrupt input image, content policy violation, or transient backend error.\n' +
1345
+ 'To see the real error, poll the video status endpoint manually:\n' +
1346
+ ' curl https://api.x.ai/v1/videos/<request_id> -H "Authorization: Bearer $TOKEN"',
1347
+ ),
1348
+ )
1349
+ }
752
1350
  printJsonBlock('details', serializeOwnProperties(error))
753
1351
  printCause(error.cause)
754
1352
  printStack(error)
@@ -841,7 +1439,7 @@ const FEATURED_IMAGE_MODELS = [
841
1439
  'flux-pro-1.1-ultra',
842
1440
  'recraft-v4',
843
1441
  'dall-e-3',
844
- 'grok-imagine-image-pro',
1442
+ 'grok-imagine-image-quality',
845
1443
  ]
846
1444
 
847
1445
  /**
@@ -911,6 +1509,75 @@ async function resolveVideoModel(): Promise<string> {
911
1509
  return selected
912
1510
  }
913
1511
 
1512
+ /**
1513
+ * Resolve the speech model: show interactive picker in TTY, use default otherwise.
1514
+ */
1515
+ async function resolveSpeechModel(): Promise<string> {
1516
+ const isTTY = process.stdin.isTTY && process.stderr.isTTY
1517
+ if (!isTTY) return DEFAULT_SPEECH_MODEL
1518
+
1519
+ const options = SPEECH_CATALOG.map((m) => {
1520
+ const cost = pc.dim(`$${m.cost.perMillionChars}/M chars`)
1521
+ return {
1522
+ value: m.id,
1523
+ label: `${m.name} ${cost}`,
1524
+ hint: m.id,
1525
+ }
1526
+ })
1527
+
1528
+ const selected = await select({
1529
+ message: 'Select a speech model',
1530
+ options,
1531
+ })
1532
+
1533
+ if (isCancel(selected)) {
1534
+ cancel('Cancelled.')
1535
+ process.exit(0)
1536
+ }
1537
+
1538
+ return selected
1539
+ }
1540
+
1541
+ /**
1542
+ * Resolve the transcription model: show interactive picker in TTY, use default otherwise.
1543
+ */
1544
+ async function resolveTranscriptionModel(): Promise<string> {
1545
+ const isTTY = process.stdin.isTTY && process.stderr.isTTY
1546
+ if (!isTTY) return DEFAULT_TRANSCRIPTION_MODEL
1547
+
1548
+ const options = TRANSCRIPTION_CATALOG.map((m) => {
1549
+ const cost = pc.dim(`${formatCost(m.cost.perSecond * 60)}/min`)
1550
+ return {
1551
+ value: m.id,
1552
+ label: `${m.name} ${cost}`,
1553
+ hint: m.id,
1554
+ }
1555
+ })
1556
+
1557
+ const selected = await select({
1558
+ message: 'Select a transcription model',
1559
+ options,
1560
+ })
1561
+
1562
+ if (isCancel(selected)) {
1563
+ cancel('Cancelled.')
1564
+ process.exit(0)
1565
+ }
1566
+
1567
+ return selected
1568
+ }
1569
+
1570
+ /**
1571
+ * Read text from stdin (for piping: cat file.txt | egaki speech --stdin).
1572
+ */
1573
+ async function readTextFromStdin(): Promise<string> {
1574
+ const chunks: Buffer[] = []
1575
+ for await (const chunk of process.stdin) {
1576
+ chunks.push(chunk)
1577
+ }
1578
+ return Buffer.concat(chunks).toString('utf-8').trim()
1579
+ }
1580
+
914
1581
  function isUrl(input: string): boolean {
915
1582
  return /^https?:\/\//i.test(input)
916
1583
  }
@@ -926,6 +1593,13 @@ function mimeTypeFromExtension(ext: string): string {
926
1593
  '.jpeg': 'image/jpeg',
927
1594
  '.webp': 'image/webp',
928
1595
  '.gif': 'image/gif',
1596
+ '.mp3': 'audio/mpeg',
1597
+ '.wav': 'audio/wav',
1598
+ '.pcm': 'audio/pcm',
1599
+ '.opus': 'audio/opus',
1600
+ '.aac': 'audio/aac',
1601
+ '.flac': 'audio/flac',
1602
+ '.ogg': 'audio/ogg',
929
1603
  }
930
1604
  return map[ext.toLowerCase()] || 'application/octet-stream'
931
1605
  }
@@ -1007,639 +1681,8 @@ async function readInputImages(
1007
1681
  return Promise.all(inputs.map((f) => readInputSource(f)))
1008
1682
  }
1009
1683
 
1010
- // ─── type-safe provider option builders ──────────────────────────────────────
1011
- // Each builder switches on provider name and constructs a typed object using
1012
- // `satisfies` with the SDK's exported type. This catches typos in keys and
1013
- // invalid enum values at compile time instead of runtime.
1014
-
1015
- type ImageOptionInputs = {
1016
- aspectRatio?: `${number}:${number}`
1017
- allowPeople: boolean
1018
- quality?: string
1019
- resolution?: string
1020
- outputFormat?: string
1021
- negativePrompt?: string
1022
- }
1023
-
1024
- // Return type uses a mapped type that preserves the typed structure while
1025
- // remaining compatible with the AI SDK's ProviderOptions (Record<string, JSONObject>).
1026
- // The satisfies checks catch key typos and invalid enum values at compile time,
1027
- // then we widen to the SDK's expected shape at the boundary.
1028
- type ProviderOptionsResult = Record<string, Record<string, string | number | boolean | string[] | null | undefined | Record<string, string | number | boolean | null | undefined>>>
1029
-
1030
- function buildImageProviderOptions(
1031
- provider: string,
1032
- opts: ImageOptionInputs,
1033
- ): ProviderOptionsResult {
1034
- switch (provider) {
1035
- case 'google': {
1036
- const googleOpts = {
1037
- ...(opts.allowPeople ? { personGeneration: 'allow_all' as const } : {}),
1038
- ...(opts.aspectRatio ? { aspectRatio: opts.aspectRatio as GoogleImageModelOptions['aspectRatio'] } : {}),
1039
- } satisfies GoogleImageModelOptions
1040
- return { google: googleOpts }
1041
- }
1042
- case 'vertex': {
1043
- const vertexOpts = {
1044
- ...(opts.allowPeople ? { personGeneration: 'allow_all' as const } : {}),
1045
- ...(opts.negativePrompt ? { negativePrompt: opts.negativePrompt } : {}),
1046
- } satisfies GoogleVertexImageModelOptions
1047
- return { vertex: vertexOpts }
1048
- }
1049
- case 'xai': {
1050
- const xaiOpts = {
1051
- ...(opts.quality ? { quality: opts.quality as XaiImageModelOptions['quality'] } : {}),
1052
- ...(opts.resolution ? { resolution: opts.resolution as XaiImageModelOptions['resolution'] } : {}),
1053
- ...(opts.outputFormat ? { output_format: opts.outputFormat } : {}),
1054
- } satisfies XaiImageModelOptions
1055
- return { xai: xaiOpts }
1056
- }
1057
- case 'openai': {
1058
- const openaiOpts = {
1059
- ...(opts.quality ? { quality: opts.quality as OpenAIImageProviderOptions['quality'] } : {}),
1060
- ...(opts.outputFormat ? { outputFormat: opts.outputFormat as OpenAIImageProviderOptions['outputFormat'] } : {}),
1061
- } satisfies OpenAIImageProviderOptions
1062
- return { openai: openaiOpts }
1063
- }
1064
- case 'fal': {
1065
- const falOpts = {
1066
- ...(opts.outputFormat ? { outputFormat: opts.outputFormat } : {}),
1067
- ...(opts.negativePrompt ? { negativePrompt: opts.negativePrompt } : {}),
1068
- } satisfies FalImageModelOptions
1069
- return { fal: falOpts }
1070
- }
1071
- default:
1072
- // Providers routed through the gateway (bfl, recraft, etc.) pass options
1073
- // as-is since they don't have local SDK types.
1074
- return {}
1075
- }
1076
- }
1077
-
1078
- type VideoOptionInputs = {
1079
- resolution?: string
1080
- mode?: string
1081
- videoUrl?: string
1082
- referenceImages?: string[]
1083
- negativePrompt?: string
1084
- model: string
1085
- }
1086
-
1087
- function buildVideoProviderOptions(
1088
- provider: string,
1089
- opts: VideoOptionInputs,
1090
- ): ProviderOptionsResult | undefined {
1091
- switch (provider) {
1092
- case 'xai': {
1093
- // xAI video modes are a discriminated union; build the correct variant.
1094
- const base = {
1095
- ...(opts.resolution ? { resolution: opts.resolution as '480p' | '720p' } : {}),
1096
- }
1097
- if (opts.mode === 'edit-video' && opts.videoUrl) {
1098
- const xaiOpts = { ...base, mode: 'edit-video' as const, videoUrl: opts.videoUrl } satisfies XaiVideoModelOptions
1099
- return { xai: xaiOpts }
1100
- }
1101
- if (opts.mode === 'extend-video' && opts.videoUrl) {
1102
- const xaiOpts = { ...base, mode: 'extend-video' as const, videoUrl: opts.videoUrl } satisfies XaiVideoModelOptions
1103
- return { xai: xaiOpts }
1104
- }
1105
- if (opts.mode === 'reference-to-video' && opts.referenceImages) {
1106
- const xaiOpts = { ...base, mode: 'reference-to-video' as const, referenceImageUrls: opts.referenceImages } satisfies XaiVideoModelOptions
1107
- return { xai: xaiOpts }
1108
- }
1109
- // Default: text-to-video or image-to-video (no mode)
1110
- if (Object.keys(base).length === 0) return undefined
1111
- const xaiOpts = { ...base } satisfies XaiVideoModelOptions
1112
- return { xai: xaiOpts }
1113
- }
1114
- case 'fal': {
1115
- const falOpts = {
1116
- ...(opts.resolution ? { resolution: opts.resolution } : {}),
1117
- ...(opts.negativePrompt ? { negativePrompt: opts.negativePrompt } : {}),
1118
- } satisfies FalVideoModelOptions
1119
- if (Object.keys(falOpts).length === 0) return undefined
1120
- return { fal: falOpts }
1121
- }
1122
- case 'google': {
1123
- const googleOpts = {
1124
- ...(opts.negativePrompt ? { negativePrompt: opts.negativePrompt } : {}),
1125
- } satisfies GoogleVideoModelOptions
1126
- if (Object.keys(googleOpts).length === 0) return undefined
1127
- return { google: googleOpts }
1128
- }
1129
- case 'vertex': {
1130
- const vertexOpts = {
1131
- ...(opts.negativePrompt ? { negativePrompt: opts.negativePrompt } : {}),
1132
- } satisfies GoogleVertexVideoModelOptions
1133
- if (Object.keys(vertexOpts).length === 0) return undefined
1134
- return { vertex: vertexOpts }
1135
- }
1136
- default:
1137
- return undefined
1138
- }
1139
- }
1140
-
1141
- // Generate using the dedicated generateImage API (Imagen models)
1142
- async function generateWithImageModel({
1143
- prompt,
1144
- model,
1145
- outputPath,
1146
- count,
1147
- aspectRatio,
1148
- seed,
1149
- inputImages,
1150
- maskImage,
1151
- allowPeople,
1152
- quality,
1153
- resolution,
1154
- outputFormat,
1155
- negativePrompt,
1156
- json,
1157
- stdout,
1158
- }: {
1159
- prompt: string
1160
- model: string
1161
- outputPath: string
1162
- count: number
1163
- aspectRatio?: `${number}:${number}`
1164
- seed?: number
1165
- inputImages: Uint8Array[]
1166
- maskImage?: Uint8Array
1167
- allowPeople: boolean
1168
- quality?: string
1169
- resolution?: string
1170
- outputFormat?: string
1171
- negativePrompt?: string
1172
- json: boolean
1173
- stdout: boolean
1174
- }) {
1175
- // Build the prompt: plain string or multimodal with reference images
1176
- const imagePrompt = inputImages.length > 0
1177
- ? { text: prompt, images: inputImages, ...(maskImage ? { mask: maskImage } : {}) }
1178
- : prompt
1179
-
1180
- const config = getModelConfig(model)
1181
- const imageModel = await createImageModel(model)
1182
-
1183
- // Build type-safe provider options using the SDK types with `satisfies`.
1184
- // Each provider has its own shape; we switch on provider to get compile-time safety.
1185
- const providerOptions = buildImageProviderOptions(config.provider, {
1186
- aspectRatio,
1187
- allowPeople,
1188
- quality,
1189
- resolution,
1190
- outputFormat,
1191
- negativePrompt,
1192
- })
1193
-
1194
- if (!stdout) {
1195
- console.error(pc.cyan('Generating...'))
1196
- }
1197
-
1198
- const result = await runProviderCall('Image generation', () => aiGenerateImage({
1199
- model: imageModel,
1200
- prompt: imagePrompt,
1201
- n: count,
1202
- ...(aspectRatio ? { aspectRatio } : {}),
1203
- ...(seed !== undefined ? { seed } : {}),
1204
- providerOptions,
1205
- }))
1206
-
1207
- const files = result.images.map((img) => ({
1208
- uint8Array: img.uint8Array,
1209
- mediaType: img.mediaType,
1210
- }))
1211
-
1212
- if (stdout) {
1213
- writeFirstToStdout(files)
1214
- return
1215
- }
1216
-
1217
- const savedFiles = saveGeneratedFiles(files, outputPath)
1218
- const cost = calculateCost(config.cost, result.usage, result.images.length)
1219
- printCost(cost)
1220
-
1221
- if (json) {
1222
- console.log(JSON.stringify({
1223
- model,
1224
- files: savedFiles,
1225
- count: result.images.length,
1226
- cost,
1227
- usage: result.usage,
1228
- warnings: result.warnings,
1229
- }, null, 2))
1230
- }
1231
- }
1232
-
1233
- // Generate using generateText with responseModalities (Gemini multimodal models)
1234
- async function generateWithTextModel({
1235
- prompt,
1236
- model,
1237
- outputPath,
1238
- inputImages,
1239
- imageSize,
1240
- aspectRatio,
1241
- json,
1242
- stdout,
1243
- }: {
1244
- prompt: string
1245
- model: string
1246
- outputPath: string
1247
- inputImages: Uint8Array[]
1248
- imageSize?: '1K' | '2K' | '4K'
1249
- aspectRatio?: string
1250
- json: boolean
1251
- stdout: boolean
1252
- }) {
1253
- const textModel = await createTextModel(model)
1254
- const config = getModelConfig(model)
1255
-
1256
- if (!stdout) {
1257
- console.error(pc.cyan('Generating...'))
1258
- }
1259
-
1260
- // Build messages: if we have input images, create a multimodal prompt
1261
- const messages = inputImages.length > 0
1262
- ? [
1263
- {
1264
- role: 'user' as const,
1265
- content: [
1266
- { type: 'text' as const, text: prompt },
1267
- ...inputImages.map((img) => ({
1268
- type: 'image' as const,
1269
- image: img,
1270
- })),
1271
- ],
1272
- },
1273
- ]
1274
- : undefined
1275
-
1276
- // Type-safe Google/Vertex language model options with image generation config.
1277
- // aspectRatio is cast to the Google SDK's enum type since CLI input is a free string.
1278
- type GoogleAspectRatio = NonNullable<NonNullable<GoogleLanguageModelOptions['imageConfig']>['aspectRatio']>
1279
- const googleOpts = {
1280
- responseModalities: ['TEXT', 'IMAGE'],
1281
- ...(imageSize || aspectRatio
1282
- ? {
1283
- imageConfig: {
1284
- ...(imageSize ? { imageSize } : {}),
1285
- ...(aspectRatio ? { aspectRatio: aspectRatio as GoogleAspectRatio } : {}),
1286
- },
1287
- }
1288
- : {}),
1289
- } satisfies GoogleLanguageModelOptions
1290
-
1291
- const providerOptionsKey = config.provider === 'vertex' ? 'vertex' : 'google'
1292
-
1293
- const result = await runProviderCall('Text/image generation', () => generateText({
1294
- model: textModel,
1295
- ...(messages ? { messages } : { prompt }),
1296
- providerOptions: {
1297
- [providerOptionsKey]: googleOpts,
1298
- },
1299
- }))
1300
-
1301
- const imageFiles = result.files.filter((f) => f.mediaType.startsWith('image/'))
1302
-
1303
- if (imageFiles.length === 0) {
1304
- console.error(pc.red('No images generated.'))
1305
- process.exit(1)
1306
- }
1307
-
1308
- if (stdout) {
1309
- writeFirstToStdout(imageFiles)
1310
- return
1311
- }
1312
-
1313
- const savedFiles = saveGeneratedFiles(imageFiles, outputPath)
1314
- const cost = calculateCost(config.cost, result.usage, imageFiles.length)
1315
- printCost(cost)
1316
-
1317
- if (result.text && !json) {
1318
- console.error(pc.dim(result.text))
1319
- }
1320
-
1321
- if (json) {
1322
- console.log(JSON.stringify({
1323
- model,
1324
- files: savedFiles,
1325
- count: imageFiles.length,
1326
- text: result.text || null,
1327
- cost,
1328
- usage: result.usage,
1329
- }, null, 2))
1330
- }
1331
- }
1332
-
1333
- // Generate using the OpenAI Responses API with imageGeneration tool.
1334
- // Used when auth comes from ChatGPT OAuth (no Image API scope).
1335
- async function generateWithResponsesApi({
1336
- prompt,
1337
- model,
1338
- outputPath,
1339
- count,
1340
- aspectRatio,
1341
- seed,
1342
- inputImages,
1343
- maskImage,
1344
- json,
1345
- stdout,
1346
- }: {
1347
- prompt: string
1348
- model: string
1349
- outputPath: string
1350
- count: number
1351
- aspectRatio?: `${number}:${number}`
1352
- seed?: number
1353
- inputImages: Uint8Array[]
1354
- maskImage?: Uint8Array
1355
- json: boolean
1356
- stdout: boolean
1357
- }) {
1358
- if (count !== 1) {
1359
- console.error(pc.red('ChatGPT image generation currently supports exactly one output image.'))
1360
- process.exit(1)
1361
- }
1362
-
1363
- if (seed !== undefined) {
1364
- console.error(pc.red('ChatGPT image generation does not support --seed.'))
1365
- process.exit(1)
1366
- }
1367
-
1368
- if (maskImage) {
1369
- console.error(pc.red('ChatGPT image generation does not support --mask yet.'))
1370
- process.exit(1)
1371
- }
1372
-
1373
- const size = aspectRatio ? chatGptImageSizeFromAspectRatio(aspectRatio) : undefined
1374
- if (aspectRatio && !size) {
1375
- console.error(
1376
- pc.red(
1377
- 'ChatGPT image generation only supports --aspect-ratio 1:1, 3:2, or 2:3.',
1378
- ),
1379
- )
1380
- process.exit(1)
1381
- }
1382
-
1383
- const storedAuth = getChatGptAuth()
1384
- if (!storedAuth?.accountId) {
1385
- console.error(pc.red('Missing ChatGPT account metadata. Please run `egaki login --provider chatgpt` again.'))
1386
- process.exit(1)
1387
- }
1388
-
1389
- const auth = await getValidChatGptAuth(storedAuth, saveChatGptAuth)
1390
- if (auth instanceof Error) {
1391
- console.error(pc.red(auth.message))
1392
- process.exit(1)
1393
- }
1394
-
1395
- if (!stdout) {
1396
- console.error(pc.cyan('Generating...'))
1397
- }
1398
-
1399
- const content: Array<{ type: 'input_text'; text: string } | { type: 'input_image'; image_url: string }> = [
1400
- { type: 'input_text', text: prompt },
1401
- ...inputImages.map((image) => ({
1402
- type: 'input_image' as const,
1403
- image_url: imageBytesToDataUrl(image),
1404
- })),
1405
- ]
1406
-
1407
- const response = await fetch('https://chatgpt.com/backend-api/codex/responses', {
1408
- method: 'POST',
1409
- headers: {
1410
- Authorization: `Bearer ${auth.access}`,
1411
- 'ChatGPT-Account-ID': auth.accountId,
1412
- 'Content-Type': 'application/json',
1413
- },
1414
- body: JSON.stringify({
1415
- model: 'gpt-5.4',
1416
- instructions: 'You are Codex.',
1417
- input: [
1418
- {
1419
- type: 'message',
1420
- role: 'user',
1421
- content,
1422
- },
1423
- ],
1424
- tools: [
1425
- {
1426
- type: 'image_generation',
1427
- model,
1428
- size: 'auto',
1429
- quality: 'auto',
1430
- output_format: 'png',
1431
- output_compression: 100,
1432
- moderation: 'auto',
1433
- ...(size ? { size } : {}),
1434
- },
1435
- ],
1436
- tool_choice: 'auto',
1437
- parallel_tool_calls: true,
1438
- stream: true,
1439
- store: false,
1440
- include: [],
1441
- }),
1442
- })
1443
-
1444
- if (!response.ok || !response.body) {
1445
- const body = await response.text().catch(() => '')
1446
- console.error(pc.red(`ChatGPT image generation failed: ${response.status}`))
1447
- if (body) console.error(pc.dim(body))
1448
- process.exit(1)
1449
- }
1450
-
1451
- let imageBase64: string | undefined
1452
- let revisedPrompt: string | null = null
1453
-
1454
- const processSseMessage = (message: EventSourceMessage) => {
1455
- if (!message.data) return
1456
- try {
1457
- const event = JSON.parse(message.data) as {
1458
- type?: string
1459
- partial_image_b64?: string
1460
- item?: { type?: string; result?: string; revised_prompt?: string | null }
1461
- }
1462
- if (
1463
- event.type === 'response.image_generation_call.partial_image' &&
1464
- event.partial_image_b64 &&
1465
- !imageBase64
1466
- ) {
1467
- imageBase64 = event.partial_image_b64
1468
- }
1469
- if (
1470
- event.type === 'response.output_item.done' &&
1471
- event.item?.type === 'image_generation_call'
1472
- ) {
1473
- if (event.item.result) imageBase64 = event.item.result
1474
- revisedPrompt = event.item.revised_prompt ?? revisedPrompt
1475
- }
1476
- } catch {
1477
- // Ignore keepalive and partial parse noise.
1478
- }
1479
- }
1480
-
1481
- const parser = createParser({
1482
- onEvent: processSseMessage,
1483
- })
1484
-
1485
- const decoder = new TextDecoder()
1486
-
1487
- for await (const chunk of response.body) {
1488
- parser.feed(decoder.decode(chunk, { stream: true }))
1489
- }
1490
- parser.feed(decoder.decode())
1491
-
1492
- if (!imageBase64) {
1493
- console.error(pc.red('No images generated.'))
1494
- process.exit(1)
1495
- }
1496
-
1497
- const imageBytes = Buffer.from(imageBase64, 'base64')
1498
- const mediaType = 'image/png'
1499
-
1500
- if (stdout) {
1501
- process.stdout.write(imageBytes)
1502
- return
1503
- }
1504
-
1505
- const savedFiles: string[] = []
1506
- const filePath = ensureExtension(outputPath, extensionFromMediaType(mediaType))
1507
- fs.writeFileSync(filePath, imageBytes)
1508
- console.error(pc.green(`Saved: ${filePath}`))
1509
- savedFiles.push(filePath)
1510
-
1511
- if (revisedPrompt && !json) {
1512
- console.error(pc.dim(`Revised prompt: ${revisedPrompt}`))
1513
- }
1514
-
1515
- if (json) {
1516
- const output = {
1517
- model,
1518
- files: savedFiles,
1519
- count: 1,
1520
- revisedPrompt,
1521
- }
1522
- console.log(JSON.stringify(output, null, 2))
1523
- }
1524
- }
1525
-
1526
- // Generate using experimental_generateVideo (video models)
1527
- async function generateWithVideoModel({
1528
- prompt,
1529
- model,
1530
- outputPath,
1531
- count,
1532
- aspectRatio,
1533
- resolution,
1534
- duration,
1535
- fps,
1536
- seed,
1537
- inputImage,
1538
- mode,
1539
- videoUrl,
1540
- referenceImages,
1541
- negativePrompt,
1542
- json,
1543
- stdout,
1544
- }: {
1545
- prompt: string
1546
- model: string
1547
- outputPath: string
1548
- count: number
1549
- aspectRatio?: string
1550
- resolution?: string
1551
- duration?: number
1552
- fps?: number
1553
- seed?: number
1554
- inputImage?: Uint8Array
1555
- mode?: string
1556
- videoUrl?: string
1557
- referenceImages?: string[]
1558
- negativePrompt?: string
1559
- json: boolean
1560
- stdout: boolean
1561
- }) {
1562
- const videoModel = await createVideoModel(model)
1563
- const config = getModelConfig(model)
1564
-
1565
- // Validate that the model supports video-url input before building options.
1566
- // Only models with a 'video-url' providerOption (currently xAI) accept edit/extend mode.
1567
- if (videoUrl && !config.providerOptions?.some((opt) => opt.flag === 'video-url')) {
1568
- console.error(pc.red(`Model ${model} does not support video URL input for editing/extension`))
1569
- process.exit(1)
1570
- }
1571
-
1572
- // Build type-safe provider options for video generation.
1573
- const providerOptions = buildVideoProviderOptions(config.provider, {
1574
- resolution,
1575
- mode,
1576
- videoUrl,
1577
- referenceImages,
1578
- negativePrompt,
1579
- model,
1580
- })
1581
-
1582
- if (!stdout) {
1583
- console.error(pc.cyan('Generating...'))
1584
- }
1585
-
1586
- const result = await runProviderCall('Video generation', () => aiGenerateVideo({
1587
- model: videoModel,
1588
- prompt: inputImage
1589
- ? { image: inputImage, text: prompt }
1590
- : prompt,
1591
- n: count,
1592
- ...(aspectRatio ? { aspectRatio: aspectRatio as `${number}:${number}` } : {}),
1593
- ...(resolution ? { resolution: resolution as `${number}x${number}` } : {}),
1594
- ...(duration != null ? { duration } : {}),
1595
- ...(fps != null ? { fps } : {}),
1596
- ...(seed != null ? { seed } : {}),
1597
- ...(providerOptions ? { providerOptions } : {}),
1598
- }))
1599
-
1600
- const files = result.videos.map((v: { uint8Array: Uint8Array; mediaType: string }) => ({
1601
- uint8Array: v.uint8Array,
1602
- mediaType: v.mediaType,
1603
- }))
1604
-
1605
- if (stdout) {
1606
- writeFirstToStdout(files)
1607
- return
1608
- }
1609
-
1610
- const savedFiles = saveGeneratedFiles(files, outputPath)
1611
- const cost = calculateCost(config.cost, {
1612
- videosGenerated: result.videos.length,
1613
- durationSeconds: duration,
1614
- resolution,
1615
- }, result.videos.length)
1616
- printCost(cost)
1617
-
1618
- if (json) {
1619
- console.log(JSON.stringify({
1620
- model,
1621
- files: savedFiles,
1622
- count: result.videos.length,
1623
- cost,
1624
- warnings: result.warnings,
1625
- responses: result.responses,
1626
- }, null, 2))
1627
- }
1628
- }
1629
-
1630
1684
  // ─── shared output helpers ───────────────────────────────────────────────────
1631
- // Common logic for saving generated files, calculating cost, and printing JSON
1632
- // metadata. Used by all generation paths to avoid repeating boilerplate.
1633
-
1634
- type GeneratedFile = {
1635
- uint8Array: Uint8Array
1636
- mediaType: string
1637
- }
1638
1685
 
1639
- /**
1640
- * Save generated files to disk. Returns the list of saved file paths.
1641
- * Handles single vs multi-file naming with index suffixes.
1642
- */
1643
1686
  function saveGeneratedFiles(
1644
1687
  files: GeneratedFile[],
1645
1688
  outputPath: string,
@@ -1660,18 +1703,12 @@ function saveGeneratedFiles(
1660
1703
  return savedFiles
1661
1704
  }
1662
1705
 
1663
- /**
1664
- * Print estimated cost to stderr if calculable.
1665
- */
1666
1706
  function printCost(cost: number | null): void {
1667
1707
  if (cost != null) {
1668
1708
  console.error(pc.dim(`Cost: ${formatCost(cost)}`))
1669
1709
  }
1670
1710
  }
1671
1711
 
1672
- /**
1673
- * Write the first file's raw bytes to stdout (for piping to other tools).
1674
- */
1675
1712
  function writeFirstToStdout(files: GeneratedFile[]): void {
1676
1713
  const file = files[0]
1677
1714
  if (file) {
@@ -1679,54 +1716,7 @@ function writeFirstToStdout(files: GeneratedFile[]): void {
1679
1716
  }
1680
1717
  }
1681
1718
 
1682
- // ─── cost helpers ────────────────────────────────────────────────────────────
1683
-
1684
- function calculateCost(
1685
- cost: {
1686
- type: 'per-image'
1687
- perImage: number
1688
- } | {
1689
- type: 'per-token'
1690
- inputPerM: number
1691
- outputPerM: number
1692
- } | {
1693
- type: 'per-video-second'
1694
- defaultDurationSec: number
1695
- tiers: Array<{ resolution?: string; costPerSecond: number }>
1696
- } | {
1697
- type: 'unknown'
1698
- },
1699
- usage: {
1700
- inputTokens?: number
1701
- outputTokens?: number
1702
- imagesGenerated?: number
1703
- videosGenerated?: number
1704
- durationSeconds?: number
1705
- resolution?: string
1706
- },
1707
- count: number = 1,
1708
- ): number | null {
1709
- if (cost.type === 'per-image') {
1710
- return cost.perImage * count
1711
- }
1712
- if (cost.type === 'per-token' && usage.inputTokens != null && usage.outputTokens != null) {
1713
- return (
1714
- (usage.inputTokens * cost.inputPerM + usage.outputTokens * cost.outputPerM) / 1_000_000
1715
- )
1716
- }
1717
- if (cost.type === 'per-video-second') {
1718
- const durationSec = usage.durationSeconds ?? cost.defaultDurationSec
1719
- const resolution = normalizeResolutionKey(usage.resolution)
1720
- const tier =
1721
- cost.tiers.find((t) => normalizeResolutionKey(t.resolution) === resolution) ??
1722
- cost.tiers[0]
1723
- if (!tier) {
1724
- return null
1725
- }
1726
- return tier.costPerSecond * durationSec * count
1727
- }
1728
- return null
1729
- }
1719
+ // ─── display helpers ─────────────────────────────────────────────────────────
1730
1720
 
1731
1721
  function formatCatalogCost(
1732
1722
  cost: {
@@ -1740,6 +1730,12 @@ function formatCatalogCost(
1740
1730
  type: 'per-video-second'
1741
1731
  defaultDurationSec: number
1742
1732
  tiers: Array<{ resolution?: string; mode?: string; audio?: boolean; costPerSecond: number }>
1733
+ } | {
1734
+ type: 'per-character'
1735
+ perMillionChars: number
1736
+ } | {
1737
+ type: 'per-second'
1738
+ perSecond: number
1743
1739
  } | {
1744
1740
  type: 'unknown'
1745
1741
  },
@@ -1750,6 +1746,12 @@ function formatCatalogCost(
1750
1746
  if (cost.type === 'per-token') {
1751
1747
  return `$${cost.inputPerM}/M input, $${cost.outputPerM}/M output`
1752
1748
  }
1749
+ if (cost.type === 'per-character') {
1750
+ return `$${cost.perMillionChars}/M chars`
1751
+ }
1752
+ if (cost.type === 'per-second') {
1753
+ return `${formatCost(cost.perSecond * 60)}/min`
1754
+ }
1753
1755
  if (cost.type === 'per-video-second') {
1754
1756
  const tierText = cost.tiers
1755
1757
  .map((t) => {
@@ -1769,6 +1771,9 @@ function formatCatalogCost(
1769
1771
  }
1770
1772
 
1771
1773
  function formatCost(dollars: number): string {
1774
+ if (dollars < 0.0001) {
1775
+ return `$${dollars.toFixed(6)}`
1776
+ }
1772
1777
  if (dollars < 0.01) {
1773
1778
  return `$${dollars.toFixed(4)}`
1774
1779
  }
@@ -1784,71 +1789,18 @@ function extensionFromMediaType(mediaType: string): string {
1784
1789
  'video/mp4': '.mp4',
1785
1790
  'video/webm': '.webm',
1786
1791
  'video/quicktime': '.mov',
1792
+ 'audio/mpeg': '.mp3',
1793
+ 'audio/wav': '.wav',
1794
+ 'audio/pcm': '.pcm',
1795
+ 'audio/opus': '.opus',
1796
+ 'audio/aac': '.aac',
1797
+ 'audio/flac': '.flac',
1798
+ 'audio/ogg': '.ogg',
1787
1799
  }
1788
- return map[mediaType] || (mediaType.startsWith('video/') ? '.mp4' : '.png')
1789
- }
1790
-
1791
- function inferImageMediaType(image: Uint8Array): string {
1792
- if (image.length >= 8 && image[0] === 0x89 && image[1] === 0x50 && image[2] === 0x4e && image[3] === 0x47) {
1793
- return 'image/png'
1794
- }
1795
- if (image.length >= 3 && image[0] === 0xff && image[1] === 0xd8 && image[2] === 0xff) {
1796
- return 'image/jpeg'
1797
- }
1798
- if (
1799
- image.length >= 12 &&
1800
- image[0] === 0x52 && image[1] === 0x49 && image[2] === 0x46 && image[3] === 0x46 &&
1801
- image[8] === 0x57 && image[9] === 0x45 && image[10] === 0x42 && image[11] === 0x50
1802
- ) {
1803
- return 'image/webp'
1804
- }
1805
- if (
1806
- image.length >= 6 &&
1807
- image[0] === 0x47 && image[1] === 0x49 && image[2] === 0x46 && image[3] === 0x38
1808
- ) {
1809
- return 'image/gif'
1810
- }
1811
- return 'image/png'
1812
- }
1813
-
1814
- function imageBytesToDataUrl(image: Uint8Array): string {
1815
- const mediaType = inferImageMediaType(image)
1816
- return `data:${mediaType};base64,${Buffer.from(image).toString('base64')}`
1817
- }
1818
-
1819
- function chatGptImageSizeFromAspectRatio(
1820
- aspectRatio: `${number}:${number}`,
1821
- ): '1024x1024' | '1536x1024' | '1024x1536' | undefined {
1822
- switch (aspectRatio) {
1823
- case '1:1':
1824
- return '1024x1024'
1825
- case '3:2':
1826
- return '1536x1024'
1827
- case '2:3':
1828
- return '1024x1536'
1829
- default:
1830
- return undefined
1831
- }
1832
- }
1833
-
1834
- function normalizeResolutionKey(input?: string): string | undefined {
1835
- if (!input) return undefined
1836
- const normalized = input.trim().toLowerCase()
1837
- if (normalized === '1920x1080') return '1080p'
1838
- if (normalized === '1280x720') return '720p'
1839
- if (normalized === '854x480' || normalized === '848x480') return '480p'
1840
- return normalized
1841
- }
1842
-
1843
- function isAspectRatio(input: string): input is `${number}:${number}` {
1844
- return /^\d+:\d+$/.test(input)
1845
- }
1846
-
1847
- function parseAspectRatioOption(input?: string): `${number}:${number}` | undefined {
1848
- if (!input || !isAspectRatio(input)) {
1849
- return undefined
1850
- }
1851
- return input
1800
+ if (map[mediaType]) return map[mediaType]!
1801
+ if (mediaType.startsWith('audio/')) return '.mp3'
1802
+ if (mediaType.startsWith('video/')) return '.mp4'
1803
+ return '.png'
1852
1804
  }
1853
1805
 
1854
1806
  function ensureExtension(filePath: string, ext: string): string {