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
@@ -10,19 +10,26 @@
10
10
  import { goke } from 'goke';
11
11
  import { z } from 'zod';
12
12
  import dedent from 'string-dedent';
13
- import { APICallError, generateImage as aiGenerateImage, generateText, experimental_generateVideo as aiGenerateVideo, NoImageGeneratedError, NoVideoGeneratedError, RetryError, } from 'ai';
13
+ import { APICallError, NoImageGeneratedError, NoVideoGeneratedError, RetryError, } from 'ai';
14
14
  import { select, isCancel, cancel } from '@clack/prompts';
15
15
  import fs from 'node:fs';
16
16
  import path from 'node:path';
17
17
  import pc from 'picocolors';
18
- import { createParser } from 'eventsource-parser';
19
- import pkg from '../package.json' with { type: 'json' };
20
- import { injectCredentialsToEnv, PROVIDERS, getChatGptAuth, saveChatGptAuth, getKeyStatus, } from './credentials.js';
21
- import { IMAGE_MODELS, VIDEO_MODELS, DEFAULT_MODEL, DEFAULT_VIDEO_MODEL, getModelConfig, createImageModel, createTextModel, createVideoModel, shouldUseResponsesApi, } from './models.js';
18
+ import pkg from '../../package.json' with { type: 'json' };
19
+ import { injectCredentialsToEnv, PROVIDERS, getKeyStatus, } from './credentials.js';
20
+ import { DEFAULT_MODEL, DEFAULT_VIDEO_MODEL, getModelConfig, } from './models.js';
22
21
  import { CATALOG, VIDEO_CATALOG, describeProviderValues, } from './model-catalog.js';
23
22
  import { loginInteractive, loginNonInteractive, showLoginStatus, removeLogin, readKeyFromStdin, } from './login.js';
24
23
  import { subscribeInteractive, subscribeNonInteractive, unsubscribe, showUsage, } from './subscription.js';
25
- import { getValidChatGptAuth } from './chatgpt-auth.js';
24
+ import { generateImageUncached as generateImage, generateVideoUncached as generateVideo, calculateCost, ValidationError, } from './generate.js';
25
+ import { generateSpeechUncached as generateSpeech, } from './speech-generate.js';
26
+ import { transcribeAudioUncached as transcribeAudio, segmentsToWordTimestamps, } from './transcription-generate.js';
27
+ import { SPEECH_CATALOG, } from './speech-catalog.js';
28
+ import { TRANSCRIPTION_CATALOG, } from './transcription-catalog.js';
29
+ import { DEFAULT_SPEECH_MODEL, } from './speech-models.js';
30
+ import { DEFAULT_TRANSCRIPTION_MODEL, } from './transcription-models.js';
31
+ import { separateAudioUncached as separateAudio, downloadStem, } from './separation-generate.js';
32
+ import { cloneVoiceUncached as cloneVoice, } from './clone-generate.js';
26
33
  const cli = goke('egaki');
27
34
  process.title = 'egaki';
28
35
  process.on('uncaughtException', (err) => {
@@ -191,72 +198,55 @@ cli
191
198
  .example('# Pipe to another tool')
192
199
  .example('egaki image "logo design" --stdout | convert - -resize 512x512 logo.png')
193
200
  .action(async (prompt, options) => {
194
- // Inject stored API keys as env vars before calling the AI SDK.
195
- injectCredentialsToEnv();
196
201
  const model = options.model ?? await resolveImageModel();
197
202
  const outputPath = options.output ?? 'egaki-output.png';
198
- const count = options.count ?? 1;
199
- const config = getModelConfig(model);
200
- const parsedAspectRatio = parseAspectRatioOption(options.aspectRatio);
201
203
  if (!options.stdout) {
202
204
  console.error(pc.dim(`Model: ${model}`));
203
205
  console.error(pc.dim(`Prompt: ${prompt}`));
206
+ console.error(pc.cyan('Generating...'));
204
207
  }
205
208
  const inputImages = await readInputImages(options.input);
206
209
  const maskImage = options.mask
207
210
  ? await readInputSource(options.mask)
208
211
  : undefined;
209
- // When using ChatGPT OAuth with OpenAI image models, route through the
210
- // Responses API + imageGeneration tool instead of the Image API.
211
- // The Codex OAuth client lacks the `api.model.images.request` scope.
212
- const useResponsesApi = config.strategy === 'image' && shouldUseResponsesApi(model);
213
- if (useResponsesApi) {
214
- if (!options.stdout) {
215
- console.error(pc.dim('Mode: Responses API (ChatGPT OAuth)'));
216
- }
217
- await generateWithResponsesApi({
218
- prompt,
219
- model,
220
- outputPath,
221
- count,
222
- aspectRatio: parsedAspectRatio,
223
- seed: options.seed,
224
- inputImages,
225
- maskImage,
226
- json: options.json || false,
227
- stdout: options.stdout || false,
228
- });
229
- }
230
- else if (config.strategy === 'image') {
231
- await generateWithImageModel({
232
- prompt,
233
- model,
234
- outputPath,
235
- count,
236
- aspectRatio: parsedAspectRatio,
237
- seed: options.seed,
238
- inputImages,
239
- maskImage,
240
- allowPeople: options.allowPeople || false,
241
- quality: options.quality,
242
- resolution: options.resolution,
243
- outputFormat: options.outputFormat,
244
- negativePrompt: options.negativePrompt,
245
- json: options.json || false,
246
- stdout: options.stdout || false,
247
- });
212
+ const result = await runProviderCall('Image generation', () => generateImage({
213
+ prompt,
214
+ model,
215
+ count: options.count ?? 1,
216
+ aspectRatio: options.aspectRatio,
217
+ seed: options.seed,
218
+ inputImages,
219
+ maskImage,
220
+ allowPeople: options.allowPeople || false,
221
+ quality: options.quality,
222
+ resolution: options.resolution,
223
+ outputFormat: options.outputFormat,
224
+ negativePrompt: options.negativePrompt,
225
+ imageSize: options.imageSize,
226
+ }));
227
+ if (options.stdout) {
228
+ writeFirstToStdout(result.images);
229
+ return;
248
230
  }
249
- else {
250
- await generateWithTextModel({
251
- prompt,
231
+ const savedFiles = saveGeneratedFiles(result.images, outputPath);
232
+ printCost(result.cost);
233
+ if (result.revisedPrompt && !options.json) {
234
+ console.error(pc.dim(`Revised prompt: ${result.revisedPrompt}`));
235
+ }
236
+ if (result.text && !options.json) {
237
+ console.error(pc.dim(result.text));
238
+ }
239
+ if (options.json) {
240
+ console.log(JSON.stringify({
252
241
  model,
253
- outputPath,
254
- inputImages,
255
- imageSize: options.imageSize,
256
- aspectRatio: options.aspectRatio,
257
- json: options.json || false,
258
- stdout: options.stdout || false,
259
- });
242
+ files: savedFiles,
243
+ count: result.images.length,
244
+ cost: result.cost,
245
+ usage: result.usage,
246
+ warnings: result.warnings,
247
+ ...(result.text ? { text: result.text } : {}),
248
+ ...(result.revisedPrompt !== undefined ? { revisedPrompt: result.revisedPrompt } : {}),
249
+ }, null, 2));
260
250
  }
261
251
  });
262
252
  // ─── video command ───────────────────────────────────────────────────────────
@@ -289,7 +279,7 @@ cli
289
279
  .describe(`Video operation mode. ${describeProviderValues('mode', [VIDEO_CATALOG])}`))
290
280
  .option('--reference-images [path]', z
291
281
  .array(z.string())
292
- .describe('Reference images for R2V generation, repeatable, 1-7 files or URLs (xAI)'))
282
+ .describe('Reference images for R2V generation, repeatable. xAI: 1-7 images. Seedance: 1-9 images (use @Image1 etc. in prompt)'))
293
283
  .option('--negative-prompt [text]', z
294
284
  .string()
295
285
  .describe('Describe what to avoid in the generated video (Fal models)'))
@@ -309,50 +299,41 @@ cli
309
299
  injectCredentialsToEnv();
310
300
  const model = options.model ?? await resolveVideoModel();
311
301
  const outputPath = options.output ?? 'egaki-output.mp4';
312
- const count = options.count ?? 1;
313
302
  const config = getModelConfig(model);
314
- if (config.strategy !== 'video') {
315
- console.error(pc.red(`Model ${model} is not a video model`));
303
+ if (config instanceof Error) {
304
+ console.error(pc.red(config.message));
316
305
  process.exit(1);
317
306
  }
318
307
  if (!options.stdout) {
319
308
  console.error(pc.dim(`Model: ${model}`));
320
309
  console.error(pc.dim(`Prompt: ${prompt}`));
321
- console.error(pc.dim('Mode: Video API (experimental_generateVideo)'));
310
+ console.error(pc.cyan('Generating...'));
322
311
  }
323
312
  // Determine how --input is used based on --mode:
324
313
  // - edit-video / extend-video: input is a source video, passed as videoUrl (needs URL)
325
314
  // - everything else (i2v, t2v): input is a reference image, passed as bytes
326
315
  const isVideoInputMode = options.mode === 'edit-video' || options.mode === 'extend-video';
327
- // Validate mode-specific required inputs
328
- if (isVideoInputMode && !options.input) {
329
- console.error(pc.red(`--input is required with --mode ${options.mode}. Pass a local video file or URL.`));
330
- process.exit(1);
331
- }
332
- if (options.mode === 'reference-to-video' && (!options.referenceImages || options.referenceImages.length === 0)) {
333
- console.error(pc.red('--reference-images is required with --mode reference-to-video. Pass 1-7 image files or URLs.'));
334
- process.exit(1);
316
+ // Auto-set --mode reference-to-video for providers that require it (xAI) when
317
+ // --reference-images is provided without an explicit --mode.
318
+ if (options.referenceImages?.length && !options.mode && config.provider === 'xai') {
319
+ options.mode = 'reference-to-video';
335
320
  }
336
321
  let inputImage;
337
322
  let videoUrl;
338
323
  if (options.input && isVideoInputMode) {
339
- // Upload local file or use URL directly for video editing/extension
340
324
  videoUrl = await resolveToUrl(options.input);
341
325
  }
342
326
  else if (options.input) {
343
- // Read as bytes for image-to-video
344
327
  inputImage = await readInputSource(options.input);
345
328
  }
346
- // Resolve --reference-images: upload local files to get URLs
347
329
  let resolvedReferenceImages = options.referenceImages;
348
330
  if (resolvedReferenceImages) {
349
331
  resolvedReferenceImages = await Promise.all(resolvedReferenceImages.map((ref) => resolveToUrl(ref)));
350
332
  }
351
- await generateWithVideoModel({
333
+ const result = await runProviderCall('Video generation', () => generateVideo({
352
334
  prompt,
353
335
  model,
354
- outputPath,
355
- count,
336
+ count: options.count ?? 1,
356
337
  aspectRatio: options.aspectRatio,
357
338
  resolution: options.resolution,
358
339
  duration: options.duration,
@@ -363,9 +344,425 @@ cli
363
344
  videoUrl,
364
345
  referenceImages: resolvedReferenceImages,
365
346
  negativePrompt: options.negativePrompt,
366
- json: options.json || false,
367
- stdout: options.stdout || false,
368
- });
347
+ }));
348
+ if (options.stdout) {
349
+ writeFirstToStdout(result.videos);
350
+ return;
351
+ }
352
+ const savedFiles = saveGeneratedFiles(result.videos, outputPath);
353
+ printCost(result.cost);
354
+ if (options.json) {
355
+ console.log(JSON.stringify({
356
+ model,
357
+ files: savedFiles,
358
+ count: result.videos.length,
359
+ cost: result.cost,
360
+ warnings: result.warnings,
361
+ responses: result.responses,
362
+ }, null, 2));
363
+ }
364
+ });
365
+ // ─── speech command ──────────────────────────────────────────────────────────
366
+ cli
367
+ .command('speech [text]', dedent `
368
+ Generate speech audio from text using AI text-to-speech models.
369
+ Supports OpenAI (tts-1, tts-1-hd, gpt-4o-mini-tts), ElevenLabs
370
+ (eleven_v3, eleven_multilingual_v2, eleven_flash_v2_5), and Cartesia
371
+ (sonic-3.5, sonic-3). Uses your provider API key directly (egaki
372
+ subscription does not cover speech yet).
373
+ `)
374
+ .option('-m, --model [model]', z.string().describe('Speech model ID. If omitted, shows an interactive picker (or uses default in non-TTY mode)'))
375
+ .option('-o, --output [path]', z
376
+ .string()
377
+ .default('egaki-output')
378
+ .describe('Output file path (extension added from audio format if omitted)'))
379
+ .option('--voice [voice]', z
380
+ .string()
381
+ .describe('Voice ID or name (provider-specific). OpenAI: alloy, echo, nova, etc. ElevenLabs/Cartesia: voice ID from their library'))
382
+ .option('--output-format [format]', z
383
+ .string()
384
+ .describe('Audio output format: mp3, wav, pcm, opus, aac, flac (provider support varies)'))
385
+ .option('--speed [speed]', z
386
+ .number()
387
+ .describe('Playback speed multiplier (OpenAI: 0.25-4.0, Cartesia: 0.6-1.5)'))
388
+ .option('--instructions [text]', z
389
+ .string()
390
+ .describe('Style instructions for speech (only gpt-4o-mini-tts and ElevenLabs). E.g. "Speak in a calm, soothing tone"'))
391
+ .option('--language [lang]', z
392
+ .string()
393
+ .describe('ISO 639-1 language code (e.g. en, es, fr). ElevenLabs and Cartesia'))
394
+ .option('--stdin', 'Read text from stdin instead of the positional argument')
395
+ .option('--json', 'Output result metadata as JSON to stdout (model, file path, cost)')
396
+ .option('--stdout', 'Write raw audio bytes to stdout instead of saving to a file. Useful for piping')
397
+ .example('# Generate speech with default model')
398
+ .example('egaki speech "Hello, welcome to egaki!"')
399
+ .example('# Use a specific model and voice')
400
+ .example('egaki speech "Good morning" -m tts-1-hd --voice nova')
401
+ .example('# Use GPT-4o Mini TTS with style instructions')
402
+ .example('egaki speech "Breaking news today" -m gpt-4o-mini-tts --instructions "Speak like a news anchor"')
403
+ .example('# ElevenLabs with a voice ID')
404
+ .example('egaki speech "Hello world" -m eleven_v3 --voice 21m00Tcm4TlvDq8ikWAM')
405
+ .example('# Read text from stdin')
406
+ .example('cat script.txt | egaki speech --stdin -o narration.mp3')
407
+ .example('# Cartesia Sonic 3.5 with a voice ID')
408
+ .example('egaki speech "Hello world" -m sonic-3.5 --voice f786b574-daa5-4673-aa0c-cbe3e8534c02')
409
+ .example('# Pipe audio to another tool')
410
+ .example('egaki speech "test" --stdout | ffplay -nodisp -autoexit -')
411
+ .action(async (text = '', options) => {
412
+ let inputText = text;
413
+ if (options.stdin) {
414
+ inputText = await readTextFromStdin();
415
+ }
416
+ if (!inputText || inputText.trim().length === 0) {
417
+ console.error(pc.red('No text provided. Pass text as an argument or use --stdin.'));
418
+ process.exit(1);
419
+ }
420
+ const model = options.model ?? await resolveSpeechModel();
421
+ const outputPath = options.output ?? 'egaki-output';
422
+ if (!options.stdout) {
423
+ console.error(pc.dim(`Model: ${model}`));
424
+ console.error(pc.dim(`Text: ${inputText.length > 80 ? inputText.slice(0, 80) + '...' : inputText}`));
425
+ console.error(pc.cyan('Generating speech...'));
426
+ }
427
+ const result = await runProviderCall('Speech generation', () => generateSpeech({
428
+ text: inputText,
429
+ model,
430
+ voice: options.voice,
431
+ outputFormat: options.outputFormat,
432
+ instructions: options.instructions,
433
+ speed: options.speed,
434
+ language: options.language,
435
+ }));
436
+ if (options.stdout) {
437
+ process.stdout.write(Buffer.from(result.audio.uint8Array));
438
+ return;
439
+ }
440
+ const ext = extensionFromMediaType(result.audio.mediaType);
441
+ const finalPath = ensureExtension(outputPath, ext);
442
+ fs.writeFileSync(finalPath, result.audio.uint8Array);
443
+ console.error(pc.green(`Saved: ${finalPath}`));
444
+ printCost(result.cost);
445
+ if (options.json) {
446
+ console.log(JSON.stringify({
447
+ model,
448
+ file: finalPath,
449
+ cost: result.cost,
450
+ textLength: inputText.length,
451
+ timestamps: result.timestamps ? result.timestamps.length : 0,
452
+ }, null, 2));
453
+ }
454
+ });
455
+ // ─── transcribe command ──────────────────────────────────────────────────────
456
+ cli
457
+ .command('transcribe [audio]', dedent `
458
+ Transcribe audio to text using AI speech-to-text models.
459
+ Supports OpenAI (whisper-1, gpt-4o-transcribe, gpt-4o-mini-transcribe),
460
+ ElevenLabs (scribe_v1), Deepgram (nova-3), Groq (whisper-large-v3,
461
+ whisper-large-v3-turbo, distil-whisper-large-v3-en), and Cartesia
462
+ (ink-whisper). Outputs JSON with text, timestamps, language, and duration.
463
+ `)
464
+ .option('-m, --model [model]', z.string().describe('Transcription model ID. If omitted, shows an interactive picker (or uses default in non-TTY mode)'))
465
+ .option('-o, --output [path]', z
466
+ .string()
467
+ .describe('Output file path for the JSON result'))
468
+ .option('--language [lang]', z
469
+ .string()
470
+ .describe('ISO 639-1 language hint (e.g. en, es, fr). Helps accuracy for known languages'))
471
+ .option('--stdin', 'Read audio from stdin instead of a file path')
472
+ .option('--stdout', 'Write only the plain text transcript to stdout (no JSON metadata)')
473
+ .example('# Transcribe an audio file with default model')
474
+ .example('egaki transcribe recording.mp3')
475
+ .example('# Use a specific model')
476
+ .example('egaki transcribe interview.wav -m gpt-4o-transcribe')
477
+ .example('# Groq for fast, cheap transcription')
478
+ .example('egaki transcribe podcast.mp3 -m whisper-large-v3')
479
+ .example('# Get plain text only')
480
+ .example('egaki transcribe recording.mp3 --stdout')
481
+ .example('# Save JSON result to file')
482
+ .example('egaki transcribe recording.mp3 -o transcript.json')
483
+ .example('# Pipe audio from another command')
484
+ .example('ffmpeg -i video.mp4 -f mp3 - | egaki transcribe --stdin -m whisper-1')
485
+ .action(async (audioPath = '', options) => {
486
+ if (!options.stdin && !audioPath) {
487
+ console.error(pc.red('No audio provided. Pass an audio file path or use --stdin.'));
488
+ process.exit(1);
489
+ }
490
+ let audioData;
491
+ if (options.stdin) {
492
+ const chunks = [];
493
+ for await (const chunk of process.stdin) {
494
+ chunks.push(chunk);
495
+ }
496
+ audioData = new Uint8Array(Buffer.concat(chunks));
497
+ }
498
+ else {
499
+ const resolved = path.resolve(audioPath);
500
+ if (!fs.existsSync(resolved)) {
501
+ console.error(pc.red(`File not found: ${resolved}`));
502
+ process.exit(1);
503
+ }
504
+ audioData = new Uint8Array(fs.readFileSync(resolved));
505
+ }
506
+ if (audioData.length === 0) {
507
+ console.error(pc.red('No audio data provided. Pass an audio file path or use --stdin.'));
508
+ process.exit(1);
509
+ }
510
+ const model = options.model ?? await resolveTranscriptionModel();
511
+ if (!options.stdout) {
512
+ console.error(pc.dim(`Model: ${model}`));
513
+ console.error(pc.dim(`Audio: ${options.stdin ? 'stdin' : audioPath} (${(audioData.length / 1024).toFixed(1)} KB)`));
514
+ console.error(pc.cyan('Transcribing...'));
515
+ }
516
+ const result = await runProviderCall('Transcription', () => transcribeAudio({
517
+ audio: audioData,
518
+ model,
519
+ language: options.language,
520
+ }));
521
+ if (options.stdout) {
522
+ process.stdout.write(result.text);
523
+ return;
524
+ }
525
+ const output = {
526
+ text: result.text,
527
+ segments: result.segments,
528
+ wordTimestamps: segmentsToWordTimestamps(result.segments),
529
+ language: result.language,
530
+ durationInSeconds: result.durationInSeconds,
531
+ model: result.model,
532
+ cost: result.cost,
533
+ };
534
+ if (options.output) {
535
+ const outputPath = path.resolve(options.output);
536
+ fs.writeFileSync(outputPath, JSON.stringify(output, null, 2));
537
+ console.error(pc.green(`Saved: ${outputPath}`));
538
+ }
539
+ else {
540
+ console.log(JSON.stringify(output, null, 2));
541
+ }
542
+ printCost(result.cost);
543
+ });
544
+ // ─── demucs command ──────────────────────────────────────────────────────────
545
+ cli
546
+ .command('demucs [audio]', dedent `
547
+ Separate audio into stems (vocals, background, drums, bass, etc.)
548
+ using fal.ai's Demucs model. Requires a fal.ai API key.
549
+ By default extracts vocals and background (other) stems.
550
+ `)
551
+ .option('--stems [list]', z
552
+ .string()
553
+ .describe('Comma-separated stems to extract: vocals, drums, bass, other, guitar, piano. Default: vocals,other'))
554
+ .option('--model [model]', z
555
+ .string()
556
+ .describe('Demucs model variant. Default: htdemucs_6s. Options: htdemucs, htdemucs_ft, htdemucs_6s, hdemucs_mmi, mdx, mdx_extra, mdx_q, mdx_extra_q'))
557
+ .option('--output-format [format]', z
558
+ .enum(['mp3', 'wav'])
559
+ .default('mp3')
560
+ .describe('Output audio format: mp3 or wav'))
561
+ .option('-o, --output [dir]', z
562
+ .string()
563
+ .describe('Output directory for separated stems. Default: current directory'))
564
+ .option('--shifts [n]', z
565
+ .number()
566
+ .describe('Number of random shifts for quality (higher = better but slower). Default: 1'))
567
+ .option('--overlap [n]', z
568
+ .number()
569
+ .describe('Segment overlap 0.0-1.0 (higher = better quality). Default: 0.25'))
570
+ .option('--stdin', 'Read audio from stdin instead of a file path')
571
+ .option('--json', 'Output result metadata as JSON to stdout')
572
+ .example('# Separate vocals and background from an audio file')
573
+ .example('egaki demucs song.mp3')
574
+ .example('# Extract all stems (vocals, drums, bass, guitar, piano, other)')
575
+ .example('egaki demucs song.mp3 --stems vocals,drums,bass,other,guitar,piano')
576
+ .example('# Extract only vocals as WAV')
577
+ .example('egaki demucs song.mp3 --stems vocals --output-format wav')
578
+ .example('# Save to a specific directory')
579
+ .example('egaki demucs song.mp3 -o stems/')
580
+ .example('# Higher quality (slower)')
581
+ .example('egaki demucs song.mp3 --shifts 5')
582
+ .action(async (audioPath = '', options) => {
583
+ if (!options.stdin && !audioPath) {
584
+ console.error(pc.red('No audio provided. Pass an audio file path or use --stdin.'));
585
+ process.exit(1);
586
+ }
587
+ let audioData;
588
+ let filename;
589
+ if (options.stdin) {
590
+ const chunks = [];
591
+ for await (const chunk of process.stdin) {
592
+ chunks.push(chunk);
593
+ }
594
+ audioData = new Uint8Array(Buffer.concat(chunks));
595
+ }
596
+ else {
597
+ const resolved = path.resolve(audioPath);
598
+ if (!fs.existsSync(resolved)) {
599
+ console.error(pc.red(`File not found: ${resolved}`));
600
+ process.exit(1);
601
+ }
602
+ audioData = new Uint8Array(fs.readFileSync(resolved));
603
+ filename = path.basename(resolved);
604
+ }
605
+ if (audioData.length === 0) {
606
+ console.error(pc.red('No audio data provided.'));
607
+ process.exit(1);
608
+ }
609
+ const stems = options.stems
610
+ ? options.stems.split(',').map((s) => s.trim())
611
+ : ['vocals', 'other'];
612
+ const model = (options.model ?? 'htdemucs_6s');
613
+ const outputFormat = options.outputFormat ?? 'mp3';
614
+ const outputDir = options.output ? path.resolve(options.output) : process.cwd();
615
+ console.error(pc.dim(`Model: ${model}`));
616
+ console.error(pc.dim(`Stems: ${stems.join(', ')}`));
617
+ console.error(pc.dim(`Audio: ${options.stdin ? 'stdin' : audioPath} (${(audioData.length / 1024 / 1024).toFixed(1)} MB)`));
618
+ console.error(pc.cyan('Separating audio...'));
619
+ const result = await runProviderCall('Audio separation', () => separateAudio({
620
+ audio: audioData,
621
+ stems,
622
+ model,
623
+ outputFormat,
624
+ shifts: options.shifts,
625
+ overlap: options.overlap,
626
+ filename,
627
+ onProgress: (msg) => console.error(pc.dim(msg)),
628
+ }));
629
+ // Create output directory if needed
630
+ if (!fs.existsSync(outputDir)) {
631
+ fs.mkdirSync(outputDir, { recursive: true });
632
+ }
633
+ // Download and save each stem
634
+ const baseName = filename
635
+ ? path.basename(filename, path.extname(filename))
636
+ : 'audio';
637
+ const savedFiles = [];
638
+ for (const stem of result.stems) {
639
+ console.error(pc.dim(`Downloading ${stem.name}...`));
640
+ const bytes = await downloadStem(stem.url);
641
+ if (bytes instanceof Error) {
642
+ console.error(pc.red(`Failed to download ${stem.name}: ${bytes.message}`));
643
+ continue;
644
+ }
645
+ const stemPath = path.join(outputDir, `${baseName}-${stem.name}.${outputFormat}`);
646
+ fs.writeFileSync(stemPath, bytes);
647
+ savedFiles.push({ stem: stem.name, path: stemPath, size: bytes.length });
648
+ console.error(pc.green(`Saved: ${stemPath} (${(bytes.length / 1024 / 1024).toFixed(1)} MB)`));
649
+ }
650
+ if (options.json) {
651
+ console.log(JSON.stringify({
652
+ model: result.model,
653
+ stems: savedFiles,
654
+ }, null, 2));
655
+ }
656
+ });
657
+ // ─── voice clone command ─────────────────────────────────────────────────────
658
+ cli
659
+ .command('voice clone [audio]', dedent `
660
+ Clone a voice from an audio clip. Returns a voice ID for use with
661
+ 'egaki speech --voice <id>'. Supports Cartesia (default) and ElevenLabs.
662
+
663
+ Best practices for high-quality clones:
664
+
665
+ 1. Isolate vocals first: 'egaki demucs recording.mp3 --stems vocals'
666
+ removes background music, noise, and other speakers.
667
+ 2. Find a clean snippet: 'egaki transcribe recording-vocals.mp3' to get
668
+ word timestamps. Pick a 5-10s segment with a complete phrase, clear
669
+ speech, no hesitations or crosstalk.
670
+ 3. Trim to speech boundaries:
671
+ ffmpeg -i recording-vocals.mp3 -ss 12.5 -to 22.0 -c copy clip.mp3
672
+ No silence padding at start or end.
673
+ 4. Match energy to intent: the clone mimics the tone and pacing of the
674
+ source clip. Use an energetic clip for energetic output.
675
+ 5. Speak in the target language. Use --language for Cartesia clones.
676
+
677
+ Cartesia: up to 10s of audio, instant, free. Good for short clean clips.
678
+ ElevenLabs: 1-3 min recommended, has --remove-background-noise option.
679
+ `)
680
+ .option('--name [name]', z
681
+ .string()
682
+ .describe('Name for the cloned voice (required)'))
683
+ .option('-p, --provider [provider]', z
684
+ .enum(['cartesia', 'elevenlabs'])
685
+ .default('cartesia')
686
+ .describe('Voice cloning provider: cartesia or elevenlabs'))
687
+ .option('--language [lang]', z
688
+ .string()
689
+ .describe('Cartesia only: ISO 639-1 language code (default: en). E.g. en, es, fr, de, ja'))
690
+ .option('--description [text]', z
691
+ .string()
692
+ .describe('Optional description for the voice'))
693
+ .option('--base-voice-id [id]', z
694
+ .string()
695
+ .describe('Cartesia: optional base voice ID to derive from'))
696
+ .option('--remove-background-noise', 'ElevenLabs: apply AI noise removal to the clip before cloning')
697
+ .option('--stdin', 'Read audio from stdin instead of a file path')
698
+ .option('--json', 'Output result as JSON to stdout')
699
+ .example('# Clone a voice from a recording')
700
+ .example('egaki voice clone recording.wav --name "My Voice"')
701
+ .example('# Clone with ElevenLabs and noise removal')
702
+ .example('egaki voice clone vocals.mp3 --name "Narrator" --provider elevenlabs --remove-background-noise')
703
+ .example('# Full pipeline: separate → trim → clone')
704
+ .example('egaki demucs interview.mp3 --stems vocals')
705
+ .example('ffmpeg -i interview-vocals.mp3 -ss 5.0 -to 15.0 -c copy clip.mp3')
706
+ .example('egaki voice clone clip.mp3 --name "Speaker"')
707
+ .example('# Use the cloned voice')
708
+ .example('egaki speech "Hello world" --voice <voice-id>')
709
+ .action(async (audioPath = '', options) => {
710
+ if (!options.stdin && !audioPath) {
711
+ console.error(pc.red('No audio provided. Pass an audio file path or use --stdin.'));
712
+ process.exit(1);
713
+ }
714
+ const name = options.name;
715
+ if (!name) {
716
+ console.error(pc.red('Voice name is required. Use --name "My Voice".'));
717
+ process.exit(1);
718
+ }
719
+ let audioData;
720
+ let filename;
721
+ if (options.stdin) {
722
+ const chunks = [];
723
+ for await (const chunk of process.stdin) {
724
+ chunks.push(chunk);
725
+ }
726
+ audioData = new Uint8Array(Buffer.concat(chunks));
727
+ }
728
+ else {
729
+ const resolved = path.resolve(audioPath);
730
+ if (!fs.existsSync(resolved)) {
731
+ console.error(pc.red(`File not found: ${resolved}`));
732
+ process.exit(1);
733
+ }
734
+ audioData = new Uint8Array(fs.readFileSync(resolved));
735
+ filename = path.basename(resolved);
736
+ }
737
+ if (audioData.length === 0) {
738
+ console.error(pc.red('No audio data provided.'));
739
+ process.exit(1);
740
+ }
741
+ const provider = (options.provider ?? 'cartesia');
742
+ console.error(pc.dim(`Provider: ${provider}`));
743
+ console.error(pc.dim(`Name: ${name}`));
744
+ console.error(pc.dim(`Audio: ${options.stdin ? 'stdin' : audioPath} (${(audioData.length / 1024).toFixed(1)} KB)`));
745
+ console.error(pc.cyan('Cloning voice...'));
746
+ const result = await runProviderCall('Voice cloning', () => cloneVoice({
747
+ audio: audioData,
748
+ name,
749
+ provider,
750
+ language: options.language,
751
+ description: options.description,
752
+ baseVoiceId: options.baseVoiceId,
753
+ removeBackgroundNoise: options.removeBackgroundNoise || false,
754
+ filename,
755
+ }));
756
+ console.error(pc.green(`Voice cloned successfully!`));
757
+ console.error(pc.bold(`Voice ID: ${result.voiceId}`));
758
+ console.error(pc.dim(`Use it: egaki speech "Hello" --voice ${result.voiceId}`));
759
+ if (options.json) {
760
+ console.log(JSON.stringify({
761
+ voiceId: result.voiceId,
762
+ name: result.name,
763
+ provider: result.provider,
764
+ }, null, 2));
765
+ }
369
766
  });
370
767
  // ─── models command ──────────────────────────────────────────────────────────
371
768
  cli
@@ -375,9 +772,9 @@ cli
375
772
  `)
376
773
  .option('-p, --provider [provider]', z.string().describe('Filter models by provider name (e.g. google, openai, replicate, fal)'))
377
774
  .option('--type [type]', z
378
- .enum(['all', 'image', 'video'])
775
+ .enum(['all', 'image', 'video', 'speech', 'transcription'])
379
776
  .default('all')
380
- .describe('Filter by model type: image (image+text-image), video, or all'))
777
+ .describe('Filter by model type: image, video, speech, transcription, or all'))
381
778
  .option('--json', 'Output as JSON instead of YAML')
382
779
  .action(async (options) => {
383
780
  const yaml = await import('js-yaml');
@@ -386,7 +783,11 @@ cli
386
783
  ? [...VIDEO_CATALOG]
387
784
  : options.type === 'image'
388
785
  ? [...CATALOG]
389
- : [...CATALOG, ...VIDEO_CATALOG];
786
+ : options.type === 'speech'
787
+ ? [...SPEECH_CATALOG]
788
+ : options.type === 'transcription'
789
+ ? [...TRANSCRIPTION_CATALOG]
790
+ : [...CATALOG, ...VIDEO_CATALOG, ...SPEECH_CATALOG, ...TRANSCRIPTION_CATALOG];
390
791
  if (options.provider) {
391
792
  models = models.filter((m) => m.provider === options.provider);
392
793
  if (models.length === 0) {
@@ -416,14 +817,34 @@ cli
416
817
  ? `${m.features.durationRangeSec.min}-${m.features.durationRangeSec.max}`
417
818
  : 'unknown',
418
819
  }
419
- : {
420
- editing: m.features.editing,
421
- inpainting: m.features.inpainting,
422
- seed: m.features.seed,
423
- multipleImages: m.features.multipleImages,
424
- aspectRatios: m.features.aspectRatios.join(', ') || 'none',
425
- ...(m.features.sizes ? { sizes: m.features.sizes.join(', ') } : {}),
426
- },
820
+ : m.strategy === 'speech'
821
+ ? {
822
+ outputFormats: m.features.outputFormats.join(', '),
823
+ instructions: m.features.instructions,
824
+ speed: m.features.speed,
825
+ language: m.features.language,
826
+ maxChars: m.features.maxChars || 'unlimited',
827
+ voices: m.features.defaultVoices.length > 0
828
+ ? m.features.defaultVoices.join(', ')
829
+ : 'use provider voice library',
830
+ }
831
+ : m.strategy === 'transcription'
832
+ ? {
833
+ wordTimestamps: m.features.wordTimestamps,
834
+ diarization: m.features.diarization,
835
+ languageDetection: m.features.languageDetection,
836
+ languageHint: m.features.languageHint,
837
+ inputFormats: m.features.inputFormats.join(', '),
838
+ maxDurationSec: m.features.maxDurationSec || 'unlimited',
839
+ }
840
+ : {
841
+ editing: m.features.editing,
842
+ inpainting: m.features.inpainting,
843
+ seed: m.features.seed,
844
+ multipleImages: m.features.multipleImages,
845
+ aspectRatios: m.features.aspectRatios.join(', ') || 'none',
846
+ ...(m.features.sizes ? { sizes: m.features.sizes.join(', ') } : {}),
847
+ },
427
848
  }));
428
849
  if (options.json) {
429
850
  console.log(JSON.stringify(output, null, 2));
@@ -438,12 +859,22 @@ const UPLOAD_GATEWAY_BASE = 'https://egaki.org';
438
859
  const MAX_UPLOAD_SIZE = 100 * 1024 * 1024;
439
860
  cli.help();
440
861
  cli.version(pkg.version);
441
- await cli.parse();
862
+ export { cli };
442
863
  async function runProviderCall(operation, call) {
443
864
  const result = await call().catch((error) => {
444
865
  printErrorDetails(error, operation);
445
866
  process.exit(1);
446
867
  });
868
+ if (result instanceof Error) {
869
+ // Validation errors get a clean one-line message; provider/runtime errors
870
+ // get full details (status code, response body, stack trace, etc.).
871
+ if (result instanceof ValidationError) {
872
+ console.error(pc.red(result.message));
873
+ process.exit(1);
874
+ }
875
+ printErrorDetails(result, operation);
876
+ process.exit(1);
877
+ }
447
878
  return result;
448
879
  }
449
880
  function printErrorDetails(error, operation) {
@@ -483,6 +914,14 @@ function printErrorDetails(error, operation) {
483
914
  if (error instanceof Error) {
484
915
  printKeyValue('name', error.name);
485
916
  printKeyValue('message', error.message);
917
+ // @ai-sdk/xai discards statusResponse.error when status==='failed',
918
+ // so XAI_VIDEO_GENERATION_FAILED carries no useful info. Add a hint.
919
+ if (error.name === 'XAI_VIDEO_GENERATION_FAILED') {
920
+ console.error(pc.dim('hint: xAI returned status "failed" but @ai-sdk/xai discards the error details.\n' +
921
+ 'Common causes: invalid/corrupt input image, content policy violation, or transient backend error.\n' +
922
+ 'To see the real error, poll the video status endpoint manually:\n' +
923
+ ' curl https://api.x.ai/v1/videos/<request_id> -H "Authorization: Bearer $TOKEN"'));
924
+ }
486
925
  printJsonBlock('details', serializeOwnProperties(error));
487
926
  printCause(error.cause);
488
927
  printStack(error);
@@ -573,7 +1012,7 @@ const FEATURED_IMAGE_MODELS = [
573
1012
  'flux-pro-1.1-ultra',
574
1013
  'recraft-v4',
575
1014
  'dall-e-3',
576
- 'grok-imagine-image-pro',
1015
+ 'grok-imagine-image-quality',
577
1016
  ];
578
1017
  /**
579
1018
  * Resolve the image model: show interactive picker in TTY, use default otherwise.
@@ -634,6 +1073,66 @@ async function resolveVideoModel() {
634
1073
  }
635
1074
  return selected;
636
1075
  }
1076
+ /**
1077
+ * Resolve the speech model: show interactive picker in TTY, use default otherwise.
1078
+ */
1079
+ async function resolveSpeechModel() {
1080
+ const isTTY = process.stdin.isTTY && process.stderr.isTTY;
1081
+ if (!isTTY)
1082
+ return DEFAULT_SPEECH_MODEL;
1083
+ const options = SPEECH_CATALOG.map((m) => {
1084
+ const cost = pc.dim(`$${m.cost.perMillionChars}/M chars`);
1085
+ return {
1086
+ value: m.id,
1087
+ label: `${m.name} ${cost}`,
1088
+ hint: m.id,
1089
+ };
1090
+ });
1091
+ const selected = await select({
1092
+ message: 'Select a speech model',
1093
+ options,
1094
+ });
1095
+ if (isCancel(selected)) {
1096
+ cancel('Cancelled.');
1097
+ process.exit(0);
1098
+ }
1099
+ return selected;
1100
+ }
1101
+ /**
1102
+ * Resolve the transcription model: show interactive picker in TTY, use default otherwise.
1103
+ */
1104
+ async function resolveTranscriptionModel() {
1105
+ const isTTY = process.stdin.isTTY && process.stderr.isTTY;
1106
+ if (!isTTY)
1107
+ return DEFAULT_TRANSCRIPTION_MODEL;
1108
+ const options = TRANSCRIPTION_CATALOG.map((m) => {
1109
+ const cost = pc.dim(`${formatCost(m.cost.perSecond * 60)}/min`);
1110
+ return {
1111
+ value: m.id,
1112
+ label: `${m.name} ${cost}`,
1113
+ hint: m.id,
1114
+ };
1115
+ });
1116
+ const selected = await select({
1117
+ message: 'Select a transcription model',
1118
+ options,
1119
+ });
1120
+ if (isCancel(selected)) {
1121
+ cancel('Cancelled.');
1122
+ process.exit(0);
1123
+ }
1124
+ return selected;
1125
+ }
1126
+ /**
1127
+ * Read text from stdin (for piping: cat file.txt | egaki speech --stdin).
1128
+ */
1129
+ async function readTextFromStdin() {
1130
+ const chunks = [];
1131
+ for await (const chunk of process.stdin) {
1132
+ chunks.push(chunk);
1133
+ }
1134
+ return Buffer.concat(chunks).toString('utf-8').trim();
1135
+ }
637
1136
  function isUrl(input) {
638
1137
  return /^https?:\/\//i.test(input);
639
1138
  }
@@ -648,6 +1147,13 @@ function mimeTypeFromExtension(ext) {
648
1147
  '.jpeg': 'image/jpeg',
649
1148
  '.webp': 'image/webp',
650
1149
  '.gif': 'image/gif',
1150
+ '.mp3': 'audio/mpeg',
1151
+ '.wav': 'audio/wav',
1152
+ '.pcm': 'audio/pcm',
1153
+ '.opus': 'audio/opus',
1154
+ '.aac': 'audio/aac',
1155
+ '.flac': 'audio/flac',
1156
+ '.ogg': 'audio/ogg',
651
1157
  };
652
1158
  return map[ext.toLowerCase()] || 'application/octet-stream';
653
1159
  }
@@ -718,429 +1224,7 @@ async function readInputImages(inputs) {
718
1224
  }
719
1225
  return Promise.all(inputs.map((f) => readInputSource(f)));
720
1226
  }
721
- function buildImageProviderOptions(provider, opts) {
722
- switch (provider) {
723
- case 'google': {
724
- const googleOpts = {
725
- ...(opts.allowPeople ? { personGeneration: 'allow_all' } : {}),
726
- ...(opts.aspectRatio ? { aspectRatio: opts.aspectRatio } : {}),
727
- };
728
- return { google: googleOpts };
729
- }
730
- case 'vertex': {
731
- const vertexOpts = {
732
- ...(opts.allowPeople ? { personGeneration: 'allow_all' } : {}),
733
- ...(opts.negativePrompt ? { negativePrompt: opts.negativePrompt } : {}),
734
- };
735
- return { vertex: vertexOpts };
736
- }
737
- case 'xai': {
738
- const xaiOpts = {
739
- ...(opts.quality ? { quality: opts.quality } : {}),
740
- ...(opts.resolution ? { resolution: opts.resolution } : {}),
741
- ...(opts.outputFormat ? { output_format: opts.outputFormat } : {}),
742
- };
743
- return { xai: xaiOpts };
744
- }
745
- case 'openai': {
746
- const openaiOpts = {
747
- ...(opts.quality ? { quality: opts.quality } : {}),
748
- ...(opts.outputFormat ? { outputFormat: opts.outputFormat } : {}),
749
- };
750
- return { openai: openaiOpts };
751
- }
752
- case 'fal': {
753
- const falOpts = {
754
- ...(opts.outputFormat ? { outputFormat: opts.outputFormat } : {}),
755
- ...(opts.negativePrompt ? { negativePrompt: opts.negativePrompt } : {}),
756
- };
757
- return { fal: falOpts };
758
- }
759
- default:
760
- // Providers routed through the gateway (bfl, recraft, etc.) pass options
761
- // as-is since they don't have local SDK types.
762
- return {};
763
- }
764
- }
765
- function buildVideoProviderOptions(provider, opts) {
766
- switch (provider) {
767
- case 'xai': {
768
- // xAI video modes are a discriminated union; build the correct variant.
769
- const base = {
770
- ...(opts.resolution ? { resolution: opts.resolution } : {}),
771
- };
772
- if (opts.mode === 'edit-video' && opts.videoUrl) {
773
- const xaiOpts = { ...base, mode: 'edit-video', videoUrl: opts.videoUrl };
774
- return { xai: xaiOpts };
775
- }
776
- if (opts.mode === 'extend-video' && opts.videoUrl) {
777
- const xaiOpts = { ...base, mode: 'extend-video', videoUrl: opts.videoUrl };
778
- return { xai: xaiOpts };
779
- }
780
- if (opts.mode === 'reference-to-video' && opts.referenceImages) {
781
- const xaiOpts = { ...base, mode: 'reference-to-video', referenceImageUrls: opts.referenceImages };
782
- return { xai: xaiOpts };
783
- }
784
- // Default: text-to-video or image-to-video (no mode)
785
- if (Object.keys(base).length === 0)
786
- return undefined;
787
- const xaiOpts = { ...base };
788
- return { xai: xaiOpts };
789
- }
790
- case 'fal': {
791
- const falOpts = {
792
- ...(opts.resolution ? { resolution: opts.resolution } : {}),
793
- ...(opts.negativePrompt ? { negativePrompt: opts.negativePrompt } : {}),
794
- };
795
- if (Object.keys(falOpts).length === 0)
796
- return undefined;
797
- return { fal: falOpts };
798
- }
799
- case 'google': {
800
- const googleOpts = {
801
- ...(opts.negativePrompt ? { negativePrompt: opts.negativePrompt } : {}),
802
- };
803
- if (Object.keys(googleOpts).length === 0)
804
- return undefined;
805
- return { google: googleOpts };
806
- }
807
- case 'vertex': {
808
- const vertexOpts = {
809
- ...(opts.negativePrompt ? { negativePrompt: opts.negativePrompt } : {}),
810
- };
811
- if (Object.keys(vertexOpts).length === 0)
812
- return undefined;
813
- return { vertex: vertexOpts };
814
- }
815
- default:
816
- return undefined;
817
- }
818
- }
819
- // Generate using the dedicated generateImage API (Imagen models)
820
- async function generateWithImageModel({ prompt, model, outputPath, count, aspectRatio, seed, inputImages, maskImage, allowPeople, quality, resolution, outputFormat, negativePrompt, json, stdout, }) {
821
- // Build the prompt: plain string or multimodal with reference images
822
- const imagePrompt = inputImages.length > 0
823
- ? { text: prompt, images: inputImages, ...(maskImage ? { mask: maskImage } : {}) }
824
- : prompt;
825
- const config = getModelConfig(model);
826
- const imageModel = await createImageModel(model);
827
- // Build type-safe provider options using the SDK types with `satisfies`.
828
- // Each provider has its own shape; we switch on provider to get compile-time safety.
829
- const providerOptions = buildImageProviderOptions(config.provider, {
830
- aspectRatio,
831
- allowPeople,
832
- quality,
833
- resolution,
834
- outputFormat,
835
- negativePrompt,
836
- });
837
- if (!stdout) {
838
- console.error(pc.cyan('Generating...'));
839
- }
840
- const result = await runProviderCall('Image generation', () => aiGenerateImage({
841
- model: imageModel,
842
- prompt: imagePrompt,
843
- n: count,
844
- ...(aspectRatio ? { aspectRatio } : {}),
845
- ...(seed !== undefined ? { seed } : {}),
846
- providerOptions,
847
- }));
848
- const files = result.images.map((img) => ({
849
- uint8Array: img.uint8Array,
850
- mediaType: img.mediaType,
851
- }));
852
- if (stdout) {
853
- writeFirstToStdout(files);
854
- return;
855
- }
856
- const savedFiles = saveGeneratedFiles(files, outputPath);
857
- const cost = calculateCost(config.cost, result.usage, result.images.length);
858
- printCost(cost);
859
- if (json) {
860
- console.log(JSON.stringify({
861
- model,
862
- files: savedFiles,
863
- count: result.images.length,
864
- cost,
865
- usage: result.usage,
866
- warnings: result.warnings,
867
- }, null, 2));
868
- }
869
- }
870
- // Generate using generateText with responseModalities (Gemini multimodal models)
871
- async function generateWithTextModel({ prompt, model, outputPath, inputImages, imageSize, aspectRatio, json, stdout, }) {
872
- const textModel = await createTextModel(model);
873
- const config = getModelConfig(model);
874
- if (!stdout) {
875
- console.error(pc.cyan('Generating...'));
876
- }
877
- // Build messages: if we have input images, create a multimodal prompt
878
- const messages = inputImages.length > 0
879
- ? [
880
- {
881
- role: 'user',
882
- content: [
883
- { type: 'text', text: prompt },
884
- ...inputImages.map((img) => ({
885
- type: 'image',
886
- image: img,
887
- })),
888
- ],
889
- },
890
- ]
891
- : undefined;
892
- const googleOpts = {
893
- responseModalities: ['TEXT', 'IMAGE'],
894
- ...(imageSize || aspectRatio
895
- ? {
896
- imageConfig: {
897
- ...(imageSize ? { imageSize } : {}),
898
- ...(aspectRatio ? { aspectRatio: aspectRatio } : {}),
899
- },
900
- }
901
- : {}),
902
- };
903
- const providerOptionsKey = config.provider === 'vertex' ? 'vertex' : 'google';
904
- const result = await runProviderCall('Text/image generation', () => generateText({
905
- model: textModel,
906
- ...(messages ? { messages } : { prompt }),
907
- providerOptions: {
908
- [providerOptionsKey]: googleOpts,
909
- },
910
- }));
911
- const imageFiles = result.files.filter((f) => f.mediaType.startsWith('image/'));
912
- if (imageFiles.length === 0) {
913
- console.error(pc.red('No images generated.'));
914
- process.exit(1);
915
- }
916
- if (stdout) {
917
- writeFirstToStdout(imageFiles);
918
- return;
919
- }
920
- const savedFiles = saveGeneratedFiles(imageFiles, outputPath);
921
- const cost = calculateCost(config.cost, result.usage, imageFiles.length);
922
- printCost(cost);
923
- if (result.text && !json) {
924
- console.error(pc.dim(result.text));
925
- }
926
- if (json) {
927
- console.log(JSON.stringify({
928
- model,
929
- files: savedFiles,
930
- count: imageFiles.length,
931
- text: result.text || null,
932
- cost,
933
- usage: result.usage,
934
- }, null, 2));
935
- }
936
- }
937
- // Generate using the OpenAI Responses API with imageGeneration tool.
938
- // Used when auth comes from ChatGPT OAuth (no Image API scope).
939
- async function generateWithResponsesApi({ prompt, model, outputPath, count, aspectRatio, seed, inputImages, maskImage, json, stdout, }) {
940
- if (count !== 1) {
941
- console.error(pc.red('ChatGPT image generation currently supports exactly one output image.'));
942
- process.exit(1);
943
- }
944
- if (seed !== undefined) {
945
- console.error(pc.red('ChatGPT image generation does not support --seed.'));
946
- process.exit(1);
947
- }
948
- if (maskImage) {
949
- console.error(pc.red('ChatGPT image generation does not support --mask yet.'));
950
- process.exit(1);
951
- }
952
- const size = aspectRatio ? chatGptImageSizeFromAspectRatio(aspectRatio) : undefined;
953
- if (aspectRatio && !size) {
954
- console.error(pc.red('ChatGPT image generation only supports --aspect-ratio 1:1, 3:2, or 2:3.'));
955
- process.exit(1);
956
- }
957
- const storedAuth = getChatGptAuth();
958
- if (!storedAuth?.accountId) {
959
- console.error(pc.red('Missing ChatGPT account metadata. Please run `egaki login --provider chatgpt` again.'));
960
- process.exit(1);
961
- }
962
- const auth = await getValidChatGptAuth(storedAuth, saveChatGptAuth);
963
- if (auth instanceof Error) {
964
- console.error(pc.red(auth.message));
965
- process.exit(1);
966
- }
967
- if (!stdout) {
968
- console.error(pc.cyan('Generating...'));
969
- }
970
- const content = [
971
- { type: 'input_text', text: prompt },
972
- ...inputImages.map((image) => ({
973
- type: 'input_image',
974
- image_url: imageBytesToDataUrl(image),
975
- })),
976
- ];
977
- const response = await fetch('https://chatgpt.com/backend-api/codex/responses', {
978
- method: 'POST',
979
- headers: {
980
- Authorization: `Bearer ${auth.access}`,
981
- 'ChatGPT-Account-ID': auth.accountId,
982
- 'Content-Type': 'application/json',
983
- },
984
- body: JSON.stringify({
985
- model: 'gpt-5.4',
986
- instructions: 'You are Codex.',
987
- input: [
988
- {
989
- type: 'message',
990
- role: 'user',
991
- content,
992
- },
993
- ],
994
- tools: [
995
- {
996
- type: 'image_generation',
997
- model,
998
- size: 'auto',
999
- quality: 'auto',
1000
- output_format: 'png',
1001
- output_compression: 100,
1002
- moderation: 'auto',
1003
- ...(size ? { size } : {}),
1004
- },
1005
- ],
1006
- tool_choice: 'auto',
1007
- parallel_tool_calls: true,
1008
- stream: true,
1009
- store: false,
1010
- include: [],
1011
- }),
1012
- });
1013
- if (!response.ok || !response.body) {
1014
- const body = await response.text().catch(() => '');
1015
- console.error(pc.red(`ChatGPT image generation failed: ${response.status}`));
1016
- if (body)
1017
- console.error(pc.dim(body));
1018
- process.exit(1);
1019
- }
1020
- let imageBase64;
1021
- let revisedPrompt = null;
1022
- const processSseMessage = (message) => {
1023
- if (!message.data)
1024
- return;
1025
- try {
1026
- const event = JSON.parse(message.data);
1027
- if (event.type === 'response.image_generation_call.partial_image' &&
1028
- event.partial_image_b64 &&
1029
- !imageBase64) {
1030
- imageBase64 = event.partial_image_b64;
1031
- }
1032
- if (event.type === 'response.output_item.done' &&
1033
- event.item?.type === 'image_generation_call') {
1034
- if (event.item.result)
1035
- imageBase64 = event.item.result;
1036
- revisedPrompt = event.item.revised_prompt ?? revisedPrompt;
1037
- }
1038
- }
1039
- catch {
1040
- // Ignore keepalive and partial parse noise.
1041
- }
1042
- };
1043
- const parser = createParser({
1044
- onEvent: processSseMessage,
1045
- });
1046
- const decoder = new TextDecoder();
1047
- for await (const chunk of response.body) {
1048
- parser.feed(decoder.decode(chunk, { stream: true }));
1049
- }
1050
- parser.feed(decoder.decode());
1051
- if (!imageBase64) {
1052
- console.error(pc.red('No images generated.'));
1053
- process.exit(1);
1054
- }
1055
- const imageBytes = Buffer.from(imageBase64, 'base64');
1056
- const mediaType = 'image/png';
1057
- if (stdout) {
1058
- process.stdout.write(imageBytes);
1059
- return;
1060
- }
1061
- const savedFiles = [];
1062
- const filePath = ensureExtension(outputPath, extensionFromMediaType(mediaType));
1063
- fs.writeFileSync(filePath, imageBytes);
1064
- console.error(pc.green(`Saved: ${filePath}`));
1065
- savedFiles.push(filePath);
1066
- if (revisedPrompt && !json) {
1067
- console.error(pc.dim(`Revised prompt: ${revisedPrompt}`));
1068
- }
1069
- if (json) {
1070
- const output = {
1071
- model,
1072
- files: savedFiles,
1073
- count: 1,
1074
- revisedPrompt,
1075
- };
1076
- console.log(JSON.stringify(output, null, 2));
1077
- }
1078
- }
1079
- // Generate using experimental_generateVideo (video models)
1080
- async function generateWithVideoModel({ prompt, model, outputPath, count, aspectRatio, resolution, duration, fps, seed, inputImage, mode, videoUrl, referenceImages, negativePrompt, json, stdout, }) {
1081
- const videoModel = await createVideoModel(model);
1082
- const config = getModelConfig(model);
1083
- // Validate that the model supports video-url input before building options.
1084
- // Only models with a 'video-url' providerOption (currently xAI) accept edit/extend mode.
1085
- if (videoUrl && !config.providerOptions?.some((opt) => opt.flag === 'video-url')) {
1086
- console.error(pc.red(`Model ${model} does not support video URL input for editing/extension`));
1087
- process.exit(1);
1088
- }
1089
- // Build type-safe provider options for video generation.
1090
- const providerOptions = buildVideoProviderOptions(config.provider, {
1091
- resolution,
1092
- mode,
1093
- videoUrl,
1094
- referenceImages,
1095
- negativePrompt,
1096
- model,
1097
- });
1098
- if (!stdout) {
1099
- console.error(pc.cyan('Generating...'));
1100
- }
1101
- const result = await runProviderCall('Video generation', () => aiGenerateVideo({
1102
- model: videoModel,
1103
- prompt: inputImage
1104
- ? { image: inputImage, text: prompt }
1105
- : prompt,
1106
- n: count,
1107
- ...(aspectRatio ? { aspectRatio: aspectRatio } : {}),
1108
- ...(resolution ? { resolution: resolution } : {}),
1109
- ...(duration != null ? { duration } : {}),
1110
- ...(fps != null ? { fps } : {}),
1111
- ...(seed != null ? { seed } : {}),
1112
- ...(providerOptions ? { providerOptions } : {}),
1113
- }));
1114
- const files = result.videos.map((v) => ({
1115
- uint8Array: v.uint8Array,
1116
- mediaType: v.mediaType,
1117
- }));
1118
- if (stdout) {
1119
- writeFirstToStdout(files);
1120
- return;
1121
- }
1122
- const savedFiles = saveGeneratedFiles(files, outputPath);
1123
- const cost = calculateCost(config.cost, {
1124
- videosGenerated: result.videos.length,
1125
- durationSeconds: duration,
1126
- resolution,
1127
- }, result.videos.length);
1128
- printCost(cost);
1129
- if (json) {
1130
- console.log(JSON.stringify({
1131
- model,
1132
- files: savedFiles,
1133
- count: result.videos.length,
1134
- cost,
1135
- warnings: result.warnings,
1136
- responses: result.responses,
1137
- }, null, 2));
1138
- }
1139
- }
1140
- /**
1141
- * Save generated files to disk. Returns the list of saved file paths.
1142
- * Handles single vs multi-file naming with index suffixes.
1143
- */
1227
+ // ─── shared output helpers ───────────────────────────────────────────────────
1144
1228
  function saveGeneratedFiles(files, outputPath) {
1145
1229
  const savedFiles = [];
1146
1230
  for (let i = 0; i < files.length; i++) {
@@ -1155,43 +1239,18 @@ function saveGeneratedFiles(files, outputPath) {
1155
1239
  }
1156
1240
  return savedFiles;
1157
1241
  }
1158
- /**
1159
- * Print estimated cost to stderr if calculable.
1160
- */
1161
1242
  function printCost(cost) {
1162
1243
  if (cost != null) {
1163
1244
  console.error(pc.dim(`Cost: ${formatCost(cost)}`));
1164
1245
  }
1165
1246
  }
1166
- /**
1167
- * Write the first file's raw bytes to stdout (for piping to other tools).
1168
- */
1169
1247
  function writeFirstToStdout(files) {
1170
1248
  const file = files[0];
1171
1249
  if (file) {
1172
1250
  process.stdout.write(Buffer.from(file.uint8Array));
1173
1251
  }
1174
1252
  }
1175
- // ─── cost helpers ────────────────────────────────────────────────────────────
1176
- function calculateCost(cost, usage, count = 1) {
1177
- if (cost.type === 'per-image') {
1178
- return cost.perImage * count;
1179
- }
1180
- if (cost.type === 'per-token' && usage.inputTokens != null && usage.outputTokens != null) {
1181
- return ((usage.inputTokens * cost.inputPerM + usage.outputTokens * cost.outputPerM) / 1_000_000);
1182
- }
1183
- if (cost.type === 'per-video-second') {
1184
- const durationSec = usage.durationSeconds ?? cost.defaultDurationSec;
1185
- const resolution = normalizeResolutionKey(usage.resolution);
1186
- const tier = cost.tiers.find((t) => normalizeResolutionKey(t.resolution) === resolution) ??
1187
- cost.tiers[0];
1188
- if (!tier) {
1189
- return null;
1190
- }
1191
- return tier.costPerSecond * durationSec * count;
1192
- }
1193
- return null;
1194
- }
1253
+ // ─── display helpers ─────────────────────────────────────────────────────────
1195
1254
  function formatCatalogCost(cost) {
1196
1255
  if (cost.type === 'per-image') {
1197
1256
  return `$${cost.perImage}/image`;
@@ -1199,6 +1258,12 @@ function formatCatalogCost(cost) {
1199
1258
  if (cost.type === 'per-token') {
1200
1259
  return `$${cost.inputPerM}/M input, $${cost.outputPerM}/M output`;
1201
1260
  }
1261
+ if (cost.type === 'per-character') {
1262
+ return `$${cost.perMillionChars}/M chars`;
1263
+ }
1264
+ if (cost.type === 'per-second') {
1265
+ return `${formatCost(cost.perSecond * 60)}/min`;
1266
+ }
1202
1267
  if (cost.type === 'per-video-second') {
1203
1268
  const tierText = cost.tiers
1204
1269
  .map((t) => {
@@ -1217,6 +1282,9 @@ function formatCatalogCost(cost) {
1217
1282
  return 'unknown';
1218
1283
  }
1219
1284
  function formatCost(dollars) {
1285
+ if (dollars < 0.0001) {
1286
+ return `$${dollars.toFixed(6)}`;
1287
+ }
1220
1288
  if (dollars < 0.01) {
1221
1289
  return `$${dollars.toFixed(4)}`;
1222
1290
  }
@@ -1231,63 +1299,21 @@ function extensionFromMediaType(mediaType) {
1231
1299
  'video/mp4': '.mp4',
1232
1300
  'video/webm': '.webm',
1233
1301
  'video/quicktime': '.mov',
1302
+ 'audio/mpeg': '.mp3',
1303
+ 'audio/wav': '.wav',
1304
+ 'audio/pcm': '.pcm',
1305
+ 'audio/opus': '.opus',
1306
+ 'audio/aac': '.aac',
1307
+ 'audio/flac': '.flac',
1308
+ 'audio/ogg': '.ogg',
1234
1309
  };
1235
- return map[mediaType] || (mediaType.startsWith('video/') ? '.mp4' : '.png');
1236
- }
1237
- function inferImageMediaType(image) {
1238
- if (image.length >= 8 && image[0] === 0x89 && image[1] === 0x50 && image[2] === 0x4e && image[3] === 0x47) {
1239
- return 'image/png';
1240
- }
1241
- if (image.length >= 3 && image[0] === 0xff && image[1] === 0xd8 && image[2] === 0xff) {
1242
- return 'image/jpeg';
1243
- }
1244
- if (image.length >= 12 &&
1245
- image[0] === 0x52 && image[1] === 0x49 && image[2] === 0x46 && image[3] === 0x46 &&
1246
- image[8] === 0x57 && image[9] === 0x45 && image[10] === 0x42 && image[11] === 0x50) {
1247
- return 'image/webp';
1248
- }
1249
- if (image.length >= 6 &&
1250
- image[0] === 0x47 && image[1] === 0x49 && image[2] === 0x46 && image[3] === 0x38) {
1251
- return 'image/gif';
1252
- }
1253
- return 'image/png';
1254
- }
1255
- function imageBytesToDataUrl(image) {
1256
- const mediaType = inferImageMediaType(image);
1257
- return `data:${mediaType};base64,${Buffer.from(image).toString('base64')}`;
1258
- }
1259
- function chatGptImageSizeFromAspectRatio(aspectRatio) {
1260
- switch (aspectRatio) {
1261
- case '1:1':
1262
- return '1024x1024';
1263
- case '3:2':
1264
- return '1536x1024';
1265
- case '2:3':
1266
- return '1024x1536';
1267
- default:
1268
- return undefined;
1269
- }
1270
- }
1271
- function normalizeResolutionKey(input) {
1272
- if (!input)
1273
- return undefined;
1274
- const normalized = input.trim().toLowerCase();
1275
- if (normalized === '1920x1080')
1276
- return '1080p';
1277
- if (normalized === '1280x720')
1278
- return '720p';
1279
- if (normalized === '854x480' || normalized === '848x480')
1280
- return '480p';
1281
- return normalized;
1282
- }
1283
- function isAspectRatio(input) {
1284
- return /^\d+:\d+$/.test(input);
1285
- }
1286
- function parseAspectRatioOption(input) {
1287
- if (!input || !isAspectRatio(input)) {
1288
- return undefined;
1289
- }
1290
- return input;
1310
+ if (map[mediaType])
1311
+ return map[mediaType];
1312
+ if (mediaType.startsWith('audio/'))
1313
+ return '.mp3';
1314
+ if (mediaType.startsWith('video/'))
1315
+ return '.mp4';
1316
+ return '.png';
1291
1317
  }
1292
1318
  function ensureExtension(filePath, ext) {
1293
1319
  const parsed = path.parse(filePath);