egaki 0.5.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.
- package/CHANGELOG.md +188 -0
- package/dist/cli/cache-utils.d.ts +33 -0
- package/dist/cli/cache-utils.d.ts.map +1 -0
- package/dist/cli/cache-utils.js +157 -0
- package/dist/cli/cached-generate.d.ts +105 -0
- package/dist/cli/cached-generate.d.ts.map +1 -0
- package/dist/cli/cached-generate.js +208 -0
- package/dist/cli/cached-generate.test.d.ts +11 -0
- package/dist/cli/cached-generate.test.d.ts.map +1 -0
- package/dist/cli/cached-generate.test.js +431 -0
- package/dist/cli/cartesia-provider.d.ts +16 -0
- package/dist/cli/cartesia-provider.d.ts.map +1 -0
- package/dist/cli/cartesia-provider.js +184 -0
- package/dist/cli/chatgpt-auth.d.ts.map +1 -0
- package/dist/cli/cli.d.ts +4 -0
- package/dist/cli/cli.d.ts.map +1 -0
- package/dist/cli/cli.js +1329 -0
- package/dist/cli/clone-generate.d.ts +26 -0
- package/dist/cli/clone-generate.d.ts.map +1 -0
- package/dist/cli/clone-generate.js +148 -0
- package/dist/{credentials.d.ts → cli/credentials.d.ts} +11 -2
- package/dist/cli/credentials.d.ts.map +1 -0
- package/dist/{credentials.js → cli/credentials.js} +88 -5
- package/dist/cli/elevenlabs-speech-provider.d.ts +26 -0
- package/dist/cli/elevenlabs-speech-provider.d.ts.map +1 -0
- package/dist/cli/elevenlabs-speech-provider.js +199 -0
- package/dist/cli/generate.d.ts +140 -0
- package/dist/cli/generate.d.ts.map +1 -0
- package/dist/cli/generate.js +604 -0
- package/dist/cli/generate.test.d.ts +2 -0
- package/dist/cli/generate.test.d.ts.map +1 -0
- package/dist/cli/generate.test.js +131 -0
- package/dist/cli/login.d.ts.map +1 -0
- package/dist/{login.js → cli/login.js} +45 -4
- package/dist/cli/main.d.ts +3 -0
- package/dist/cli/main.d.ts.map +1 -0
- package/dist/cli/main.js +7 -0
- package/dist/cli/model-catalog.d.ts +141 -0
- package/dist/cli/model-catalog.d.ts.map +1 -0
- package/dist/cli/model-catalog.js +2135 -0
- package/dist/{models.d.ts → cli/models.d.ts} +16 -9
- package/dist/cli/models.d.ts.map +1 -0
- package/dist/cli/models.js +313 -0
- package/dist/cli/open-browser.d.ts.map +1 -0
- package/dist/cli/openai-speech-provider.d.ts +3 -0
- package/dist/cli/openai-speech-provider.d.ts.map +1 -0
- package/dist/cli/openai-speech-provider.js +28 -0
- package/dist/cli/separation-generate.d.ts +34 -0
- package/dist/cli/separation-generate.d.ts.map +1 -0
- package/dist/cli/separation-generate.js +117 -0
- package/dist/cli/speech-catalog.d.ts +35 -0
- package/dist/cli/speech-catalog.d.ts.map +1 -0
- package/dist/cli/speech-catalog.js +195 -0
- package/dist/cli/speech-generate.d.ts +86 -0
- package/dist/cli/speech-generate.d.ts.map +1 -0
- package/dist/cli/speech-generate.js +156 -0
- package/dist/cli/speech-models.d.ts +8 -0
- package/dist/cli/speech-models.d.ts.map +1 -0
- package/dist/cli/speech-models.js +109 -0
- package/dist/cli/speech-provider.test.d.ts +2 -0
- package/dist/cli/speech-provider.test.d.ts.map +1 -0
- package/dist/cli/speech-provider.test.js +197 -0
- package/dist/cli/subscription.d.ts.map +1 -0
- package/dist/cli/transcription-catalog.d.ts +35 -0
- package/dist/cli/transcription-catalog.d.ts.map +1 -0
- package/dist/cli/transcription-catalog.js +192 -0
- package/dist/cli/transcription-generate.d.ts +62 -0
- package/dist/cli/transcription-generate.d.ts.map +1 -0
- package/dist/cli/transcription-generate.js +144 -0
- package/dist/cli/transcription-models.d.ts +9 -0
- package/dist/cli/transcription-models.d.ts.map +1 -0
- package/dist/cli/transcription-models.js +100 -0
- package/dist/cli/transcription-providers.d.ts +22 -0
- package/dist/cli/transcription-providers.d.ts.map +1 -0
- package/dist/cli/transcription-providers.js +290 -0
- package/dist/cli/video-model-catalog.d.ts +2 -0
- package/dist/cli/video-model-catalog.d.ts.map +1 -0
- package/dist/cli/video-model-catalog.js +5 -0
- package/dist/cli/xai-auth.d.ts +29 -0
- package/dist/cli/xai-auth.d.ts.map +1 -0
- package/dist/cli/xai-auth.js +325 -0
- package/dist/vite/angled-screen.d.ts +57 -0
- package/dist/vite/angled-screen.d.ts.map +1 -0
- package/dist/vite/angled-screen.js +115 -0
- package/dist/vite/app.d.ts +51 -0
- package/dist/vite/app.d.ts.map +1 -0
- package/dist/vite/app.js +150 -0
- package/dist/vite/bands-shader.d.ts +47 -0
- package/dist/vite/bands-shader.d.ts.map +1 -0
- package/dist/vite/bands-shader.js +267 -0
- package/dist/vite/code-block.d.ts +107 -0
- package/dist/vite/code-block.d.ts.map +1 -0
- package/dist/vite/code-block.js +641 -0
- package/dist/vite/components.d.ts +36 -0
- package/dist/vite/components.d.ts.map +1 -0
- package/dist/vite/components.js +113 -0
- package/dist/vite/dispersion-rings-shader.d.ts +44 -0
- package/dist/vite/dispersion-rings-shader.d.ts.map +1 -0
- package/dist/vite/dispersion-rings-shader.js +282 -0
- package/dist/vite/easing-curves.d.ts +129 -0
- package/dist/vite/easing-curves.d.ts.map +1 -0
- package/dist/vite/easing-curves.js +584 -0
- package/dist/vite/easing-curves.test.d.ts +2 -0
- package/dist/vite/easing-curves.test.d.ts.map +1 -0
- package/dist/vite/easing-curves.test.js +200 -0
- package/dist/vite/generated-media-client.d.ts +35 -0
- package/dist/vite/generated-media-client.d.ts.map +1 -0
- package/dist/vite/generated-media-client.js +76 -0
- package/dist/vite/keyframes.d.ts +129 -0
- package/dist/vite/keyframes.d.ts.map +1 -0
- package/dist/vite/keyframes.js +194 -0
- package/dist/vite/layout-editor.d.ts +45 -0
- package/dist/vite/layout-editor.d.ts.map +1 -0
- package/dist/vite/layout-editor.js +587 -0
- package/dist/vite/layout-transition.d.ts +136 -0
- package/dist/vite/layout-transition.d.ts.map +1 -0
- package/dist/vite/layout-transition.js +581 -0
- package/dist/vite/liquid-gradient-shader.d.ts +62 -0
- package/dist/vite/liquid-gradient-shader.d.ts.map +1 -0
- package/dist/vite/liquid-gradient-shader.js +413 -0
- package/dist/vite/mdx-client.d.ts +15 -0
- package/dist/vite/mdx-client.d.ts.map +1 -0
- package/dist/vite/mdx-client.js +265 -0
- package/dist/vite/mdx-parse.d.ts +102 -0
- package/dist/vite/mdx-parse.d.ts.map +1 -0
- package/dist/vite/mdx-parse.js +251 -0
- package/dist/vite/mdx-provided-components.d.ts +18 -0
- package/dist/vite/mdx-provided-components.d.ts.map +1 -0
- package/dist/vite/mdx-provided-components.js +14 -0
- package/dist/vite/mdx-video.d.ts +327 -0
- package/dist/vite/mdx-video.d.ts.map +1 -0
- package/dist/vite/mdx-video.js +596 -0
- package/dist/vite/mdx-video.test.d.ts +9 -0
- package/dist/vite/mdx-video.test.d.ts.map +1 -0
- package/dist/vite/mdx-video.test.js +1945 -0
- package/dist/vite/media-components.d.ts +45 -0
- package/dist/vite/media-components.d.ts.map +1 -0
- package/dist/vite/media-components.js +355 -0
- package/dist/vite/media-duration-store.d.ts +66 -0
- package/dist/vite/media-duration-store.d.ts.map +1 -0
- package/dist/vite/media-duration-store.js +204 -0
- package/dist/vite/player-page.d.ts +35 -0
- package/dist/vite/player-page.d.ts.map +1 -0
- package/dist/vite/player-page.js +720 -0
- package/dist/vite/render-client.d.ts +18 -0
- package/dist/vite/render-client.d.ts.map +1 -0
- package/dist/vite/render-client.js +67 -0
- package/dist/vite/sdk.d.ts +221 -0
- package/dist/vite/sdk.d.ts.map +1 -0
- package/dist/vite/sdk.js +355 -0
- package/dist/vite/server-components.d.ts +55 -0
- package/dist/vite/server-components.d.ts.map +1 -0
- package/dist/vite/server-components.js +156 -0
- package/dist/vite/server-mdx.d.ts +74 -0
- package/dist/vite/server-mdx.d.ts.map +1 -0
- package/dist/vite/server-mdx.js +259 -0
- package/dist/vite/shader-renderer.d.ts +48 -0
- package/dist/vite/shader-renderer.d.ts.map +1 -0
- package/dist/vite/shader-renderer.js +411 -0
- package/dist/vite/store-hooks.d.ts +12 -0
- package/dist/vite/store-hooks.d.ts.map +1 -0
- package/dist/vite/store-hooks.js +93 -0
- package/dist/vite/store.d.ts +75 -0
- package/dist/vite/store.d.ts.map +1 -0
- package/dist/vite/store.js +52 -0
- package/dist/vite/store.test.d.ts +6 -0
- package/dist/vite/store.test.d.ts.map +1 -0
- package/dist/vite/store.test.js +172 -0
- package/dist/vite/tweakpane-hook.d.ts +94 -0
- package/dist/vite/tweakpane-hook.d.ts.map +1 -0
- package/dist/vite/tweakpane-hook.js +450 -0
- package/dist/vite/vite-plugin.d.ts +28 -0
- package/dist/vite/vite-plugin.d.ts.map +1 -0
- package/dist/vite/vite-plugin.js +538 -0
- package/dist/vite/wave-gradient-shader.d.ts +41 -0
- package/dist/vite/wave-gradient-shader.d.ts.map +1 -0
- package/dist/vite/wave-gradient-shader.js +207 -0
- package/package.json +103 -25
- package/skills/egaki/SKILL.md +162 -1
- package/src/cli/cache-utils.ts +169 -0
- package/src/cli/cached-generate.test.ts +531 -0
- package/src/cli/cached-generate.ts +348 -0
- package/src/cli/cartesia-provider.ts +249 -0
- package/src/cli/cli.ts +1818 -0
- package/src/cli/clone-generate.ts +202 -0
- package/src/{credentials.ts → cli/credentials.ts} +90 -7
- package/src/cli/elevenlabs-speech-provider.ts +243 -0
- package/src/cli/generate.test.ts +166 -0
- package/src/cli/generate.ts +912 -0
- package/src/{login.ts → cli/login.ts} +48 -3
- package/src/cli/main.ts +8 -0
- package/src/cli/model-catalog.ts +2336 -0
- package/src/cli/models.ts +408 -0
- package/src/cli/openai-speech-provider.ts +34 -0
- package/src/cli/separation-generate.ts +187 -0
- package/src/cli/speech-catalog.ts +253 -0
- package/src/cli/speech-generate.ts +275 -0
- package/src/cli/speech-models.ts +146 -0
- package/src/cli/speech-provider.test.ts +226 -0
- package/src/cli/transcription-catalog.ts +250 -0
- package/src/cli/transcription-generate.ts +214 -0
- package/src/cli/transcription-models.ts +136 -0
- package/src/cli/transcription-providers.ts +427 -0
- package/src/cli/video-model-catalog.ts +14 -0
- package/src/cli/xai-auth.ts +396 -0
- package/src/vite/angled-screen.tsx +214 -0
- package/src/vite/app.tsx +197 -0
- package/src/vite/bands-shader.tsx +319 -0
- package/src/vite/code-block.tsx +939 -0
- package/src/vite/components.tsx +278 -0
- package/src/vite/dispersion-rings-shader.tsx +331 -0
- package/src/vite/easing-curves.test.ts +232 -0
- package/src/vite/easing-curves.ts +743 -0
- package/src/vite/generated-media-client.tsx +121 -0
- package/src/vite/keyframes.tsx +347 -0
- package/src/vite/layout-editor.tsx +709 -0
- package/src/vite/layout-transition.tsx +706 -0
- package/src/vite/liquid-gradient-shader.tsx +474 -0
- package/src/vite/mdx-client.tsx +420 -0
- package/src/vite/mdx-parse.ts +343 -0
- package/src/vite/mdx-provided-components.ts +19 -0
- package/src/vite/mdx-video.test.tsx +2219 -0
- package/src/vite/mdx-video.tsx +976 -0
- package/src/vite/media-components.tsx +473 -0
- package/src/vite/media-duration-store.ts +225 -0
- package/src/vite/motion-timing.ts +194 -0
- package/src/vite/player-page.tsx +1107 -0
- package/src/vite/render-client.ts +84 -0
- package/src/vite/sdk.ts +598 -0
- package/src/vite/server-components.tsx +241 -0
- package/src/vite/server-mdx.ts +285 -0
- package/src/vite/shader-renderer.tsx +535 -0
- package/src/vite/store-hooks.ts +117 -0
- package/src/vite/store.test.ts +202 -0
- package/src/vite/store.ts +115 -0
- package/src/vite/styles.css +21 -0
- package/src/vite/tweakpane-hook.tsx +593 -0
- package/src/vite/virtual-modules.d.ts +30 -0
- package/src/vite/vite-plugin.ts +584 -0
- package/src/vite/wave-gradient-shader.tsx +253 -0
- package/README.md +0 -266
- package/dist/antigravity-auth.d.ts +0 -15
- package/dist/antigravity-auth.d.ts.map +0 -1
- package/dist/antigravity-auth.js +0 -544
- package/dist/antigravity-image.d.ts +0 -11
- package/dist/antigravity-image.d.ts.map +0 -1
- package/dist/antigravity-image.js +0 -285
- package/dist/chatgpt-auth.d.ts.map +0 -1
- package/dist/cli.d.ts +0 -3
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js +0 -968
- package/dist/credentials.d.ts.map +0 -1
- package/dist/gateway-model-candidates.generated.d.ts +0 -11
- package/dist/gateway-model-candidates.generated.d.ts.map +0 -1
- package/dist/gateway-model-candidates.generated.js +0 -64
- package/dist/login.d.ts.map +0 -1
- package/dist/model-catalog.d.ts +0 -42
- package/dist/model-catalog.d.ts.map +0 -1
- package/dist/model-catalog.js +0 -1001
- package/dist/models.d.ts.map +0 -1
- package/dist/models.js +0 -246
- package/dist/open-browser.d.ts.map +0 -1
- package/dist/subscription.d.ts.map +0 -1
- package/dist/video-model-catalog.d.ts +0 -52
- package/dist/video-model-catalog.d.ts.map +0 -1
- package/dist/video-model-catalog.js +0 -306
- package/src/cli.ts +0 -1378
- package/src/model-catalog.ts +0 -1077
- package/src/models.ts +0 -309
- package/src/video-model-catalog.ts +0 -363
- /package/dist/{chatgpt-auth.d.ts → cli/chatgpt-auth.d.ts} +0 -0
- /package/dist/{chatgpt-auth.js → cli/chatgpt-auth.js} +0 -0
- /package/dist/{login.d.ts → cli/login.d.ts} +0 -0
- /package/dist/{open-browser.d.ts → cli/open-browser.d.ts} +0 -0
- /package/dist/{open-browser.js → cli/open-browser.js} +0 -0
- /package/dist/{subscription.d.ts → cli/subscription.d.ts} +0 -0
- /package/dist/{subscription.js → cli/subscription.js} +0 -0
- /package/src/{chatgpt-auth.ts → cli/chatgpt-auth.ts} +0 -0
- /package/src/{open-browser.ts → cli/open-browser.ts} +0 -0
- /package/src/{subscription.ts → cli/subscription.ts} +0 -0
package/dist/cli/cli.js
ADDED
|
@@ -0,0 +1,1329 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Main CLI entrypoint for egaki - AI image and video generation.
|
|
3
|
+
// Uses the Vercel AI SDK for image generation across multiple providers.
|
|
4
|
+
// Designed to be called by agents and humans alike.
|
|
5
|
+
//
|
|
6
|
+
// Two generation paths depending on model:
|
|
7
|
+
// - imagen-* models → generateImage() with google.image()
|
|
8
|
+
// - all other models → generateText() with responseModalities: ['IMAGE']
|
|
9
|
+
// The CLI auto-detects which path to use based on model ID prefix.
|
|
10
|
+
import { goke } from 'goke';
|
|
11
|
+
import { z } from 'zod';
|
|
12
|
+
import dedent from 'string-dedent';
|
|
13
|
+
import { APICallError, NoImageGeneratedError, NoVideoGeneratedError, RetryError, } from 'ai';
|
|
14
|
+
import { select, isCancel, cancel } from '@clack/prompts';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import pc from 'picocolors';
|
|
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';
|
|
21
|
+
import { CATALOG, VIDEO_CATALOG, describeProviderValues, } from './model-catalog.js';
|
|
22
|
+
import { loginInteractive, loginNonInteractive, showLoginStatus, removeLogin, readKeyFromStdin, } from './login.js';
|
|
23
|
+
import { subscribeInteractive, subscribeNonInteractive, unsubscribe, showUsage, } from './subscription.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';
|
|
33
|
+
const cli = goke('egaki');
|
|
34
|
+
process.title = 'egaki';
|
|
35
|
+
process.on('uncaughtException', (err) => {
|
|
36
|
+
printErrorDetails(err);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
});
|
|
39
|
+
process.on('unhandledRejection', (reason) => {
|
|
40
|
+
printErrorDetails(reason);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
});
|
|
43
|
+
// ─── login command ───────────────────────────────────────────────────────────
|
|
44
|
+
cli
|
|
45
|
+
.command('login', dedent `
|
|
46
|
+
Configure API keys for image generation providers.
|
|
47
|
+
Interactive mode: shows a provider picker and secure key input.
|
|
48
|
+
Non-interactive mode: pass --provider and --key flags, or pipe key via stdin.
|
|
49
|
+
Keys are saved to ~/.config/egaki/credentials.json (mode 0600).
|
|
50
|
+
`)
|
|
51
|
+
.option('-p, --provider [name]', z
|
|
52
|
+
.string()
|
|
53
|
+
.describe(`Provider name for non-interactive login (${Object.keys(PROVIDERS).join(', ')})`))
|
|
54
|
+
.option('-k, --key [key]', z.string().describe('API key value for non-interactive login'))
|
|
55
|
+
.option('--show', 'Show which providers are configured and their status')
|
|
56
|
+
.option('--remove [provider]', z.string().describe('Remove the stored key for a provider'))
|
|
57
|
+
.example('# Interactive login (pick provider, paste key)')
|
|
58
|
+
.example('egaki login')
|
|
59
|
+
.example('# Non-interactive login with flags')
|
|
60
|
+
.example('egaki login --provider google --key AIza...')
|
|
61
|
+
.example('egaki login --provider vertex --key AIza...')
|
|
62
|
+
.example('# Pipe key from stdin (useful in CI/scripts)')
|
|
63
|
+
.example('echo "AIza..." | egaki login --provider google')
|
|
64
|
+
.example('# Show configured providers')
|
|
65
|
+
.example('egaki login --show')
|
|
66
|
+
.example('# Remove a stored key')
|
|
67
|
+
.example('egaki login --remove google')
|
|
68
|
+
.action(async (options) => {
|
|
69
|
+
if (options.show) {
|
|
70
|
+
showLoginStatus();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (options.remove) {
|
|
74
|
+
removeLogin(options.remove);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
// Non-interactive: --provider + --key or stdin
|
|
78
|
+
if (options.provider) {
|
|
79
|
+
// OAuth providers use browser flow — skip key reading
|
|
80
|
+
if (options.provider === 'chatgpt' || options.provider === 'xai-oauth') {
|
|
81
|
+
await loginNonInteractive({ provider: options.provider, key: '' });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const key = options.key || (await readKeyFromStdin());
|
|
85
|
+
await loginNonInteractive({ provider: options.provider, key });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
// Interactive mode
|
|
89
|
+
await loginInteractive();
|
|
90
|
+
});
|
|
91
|
+
// ─── subscribe command ───────────────────────────────────────────────────────
|
|
92
|
+
cli
|
|
93
|
+
.command('subscribe', dedent `
|
|
94
|
+
Subscribe to Egaki for access to all image models with a single API key.
|
|
95
|
+
You can also use your own provider keys (Google/OpenAI/Replicate/Fal)
|
|
96
|
+
via 'egaki login --provider <name> --key <key>' if you prefer BYOK.
|
|
97
|
+
Egaki subscription avoids managing one key per provider.
|
|
98
|
+
Three plans: Starter ($9/mo, 100 credits), Pro ($29/mo, 500 credits),
|
|
99
|
+
Unlimited ($99/mo, 2000 credits). One credit ≈ one standard image.
|
|
100
|
+
Interactive mode: pick a plan and get a checkout URL (email prefill optional).
|
|
101
|
+
Non-interactive: --email is optional and only pre-fills checkout.
|
|
102
|
+
`)
|
|
103
|
+
.option('-e, --email [email]', z.string().describe('Optional email prefill for checkout (skips interactive prompt)'))
|
|
104
|
+
.option('--plan [plan]', z.string().describe('Plan ID: starter, pro, or unlimited (default: pro)'))
|
|
105
|
+
.example('# Interactive subscribe')
|
|
106
|
+
.example('egaki subscribe')
|
|
107
|
+
.example('# Non-interactive (for agents)')
|
|
108
|
+
.example('egaki subscribe --email user@example.com --plan pro')
|
|
109
|
+
.example('# Non-interactive without email prefill')
|
|
110
|
+
.example('egaki subscribe --plan pro')
|
|
111
|
+
.action(async (options) => {
|
|
112
|
+
const isTTY = process.stdout.isTTY && process.stdin.isTTY;
|
|
113
|
+
if (!isTTY || options.email || options.plan) {
|
|
114
|
+
subscribeNonInteractive(options.email, options.plan);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
await subscribeInteractive();
|
|
118
|
+
});
|
|
119
|
+
// ─── unsubscribe command ─────────────────────────────────────────────────────
|
|
120
|
+
cli
|
|
121
|
+
.command('unsubscribe', dedent `
|
|
122
|
+
Cancel your Egaki subscription. Uses the stored API key to identify
|
|
123
|
+
the subscription. You can resubscribe anytime with 'egaki subscribe'.
|
|
124
|
+
`)
|
|
125
|
+
.example('egaki unsubscribe')
|
|
126
|
+
.action(async () => {
|
|
127
|
+
await unsubscribe();
|
|
128
|
+
});
|
|
129
|
+
// ─── usage command ───────────────────────────────────────────────────────────
|
|
130
|
+
cli
|
|
131
|
+
.command('usage', dedent `
|
|
132
|
+
Show your current Egaki credit usage for this billing period.
|
|
133
|
+
Displays plan, credits used, credits remaining, and period info.
|
|
134
|
+
`)
|
|
135
|
+
.example('egaki usage')
|
|
136
|
+
.action(async () => {
|
|
137
|
+
await showUsage();
|
|
138
|
+
});
|
|
139
|
+
// ─── image command ───────────────────────────────────────────────────────────
|
|
140
|
+
cli
|
|
141
|
+
.command('image <prompt>', dedent `
|
|
142
|
+
Generate images from a text prompt using AI models.
|
|
143
|
+
Supports Imagen models (dedicated image generation) and Gemini
|
|
144
|
+
multimodal models (text+image output). The model type is auto-detected
|
|
145
|
+
from the model ID: imagen-* uses the image API, everything else uses
|
|
146
|
+
the text API with image output enabled.
|
|
147
|
+
`)
|
|
148
|
+
.option('-m, --model [model]', z.string().describe('Model ID for generation. If omitted, shows an interactive picker (or uses default in non-TTY mode)'))
|
|
149
|
+
.option('-o, --output [path]', z
|
|
150
|
+
.string()
|
|
151
|
+
.default('egaki-output.png')
|
|
152
|
+
.describe('Output file path (index suffix added when generating multiple)'))
|
|
153
|
+
.option('-n, --count [n]', z.number().default(1).describe('Number of images to generate'))
|
|
154
|
+
.option('--aspect-ratio [ratio]', z
|
|
155
|
+
.string()
|
|
156
|
+
.describe('Aspect ratio for the generated image. Imagen supports: 1:1, 3:4, 4:3, 9:16, 16:9. Gemini supports additional ratios: 2:3, 3:2, 4:5, 5:4, 21:9'))
|
|
157
|
+
.option('--seed [seed]', z.number().describe('Seed for reproducible generation. Same seed + same prompt = same image'))
|
|
158
|
+
.option('--image-size [size]', z
|
|
159
|
+
.enum(['1K', '2K', '4K'])
|
|
160
|
+
.describe('Output resolution for Gemini text-model image generation. Only applies to gemini-*-image* models'))
|
|
161
|
+
.option('-i, --input [file]', z
|
|
162
|
+
.array(z.string())
|
|
163
|
+
.describe('Reference image for editing or variations (repeatable). Accepts local file paths or URLs (http/https). Pass one or more images along with a text prompt to edit them'))
|
|
164
|
+
.option('--mask [file]', z
|
|
165
|
+
.string()
|
|
166
|
+
.describe('Mask image for inpainting. Accepts a local file path or URL (http/https). White areas in the mask are replaced with generated content. Used together with --input'))
|
|
167
|
+
.option('--quality [level]', z
|
|
168
|
+
.string()
|
|
169
|
+
.describe(`Image quality level. ${describeProviderValues('quality', [CATALOG])}`))
|
|
170
|
+
.option('--resolution [res]', z
|
|
171
|
+
.string()
|
|
172
|
+
.describe(`Output resolution. ${describeProviderValues('resolution', [CATALOG])}`))
|
|
173
|
+
.option('--output-format [format]', z
|
|
174
|
+
.string()
|
|
175
|
+
.describe(`Output image format. ${describeProviderValues('output-format', [CATALOG])}`))
|
|
176
|
+
.option('--negative-prompt [text]', z
|
|
177
|
+
.string()
|
|
178
|
+
.describe('Describe what to avoid in the generated image (Fal models)'))
|
|
179
|
+
.option('--allow-people', 'Allow generating images of people (Imagen blocks people by default)')
|
|
180
|
+
.option('--json', 'Output result metadata as JSON to stdout (model, usage, warnings, file paths)')
|
|
181
|
+
.option('--stdout', 'Write raw image bytes to stdout instead of saving to a file. Useful for piping to other tools')
|
|
182
|
+
.example('# Generate a simple image')
|
|
183
|
+
.example('egaki image "a sunset over mars"')
|
|
184
|
+
.example('# Use a specific model with aspect ratio')
|
|
185
|
+
.example('egaki image "cyberpunk city at night" -m imagen-4.0-ultra-generate-001 --aspect-ratio 16:9')
|
|
186
|
+
.example('# Edit an existing image')
|
|
187
|
+
.example('egaki image "add a wizard hat to the cat" --input cat.jpg -o cat-wizard.png')
|
|
188
|
+
.example('# Edit an image from a URL')
|
|
189
|
+
.example('egaki image "make it pop art" --input https://example.com/photo.jpg')
|
|
190
|
+
.example('# Inpainting with a mask')
|
|
191
|
+
.example('egaki image "fill with flowers" --input photo.jpg --mask mask.png')
|
|
192
|
+
.example('# Generate with Gemini multimodal at 4K')
|
|
193
|
+
.example('egaki image "dreamy landscape" -m gemini-2.5-flash-image --image-size 4K')
|
|
194
|
+
.example('# Route through Vertex AI (Google Cloud billing)')
|
|
195
|
+
.example('egaki image "product photo on marble" -m vertex/imagen-4.0-generate-001')
|
|
196
|
+
.example('# Generate multiple images')
|
|
197
|
+
.example('egaki image "abstract art" -n 4 -o art.png')
|
|
198
|
+
.example('# Pipe to another tool')
|
|
199
|
+
.example('egaki image "logo design" --stdout | convert - -resize 512x512 logo.png')
|
|
200
|
+
.action(async (prompt, options) => {
|
|
201
|
+
const model = options.model ?? await resolveImageModel();
|
|
202
|
+
const outputPath = options.output ?? 'egaki-output.png';
|
|
203
|
+
if (!options.stdout) {
|
|
204
|
+
console.error(pc.dim(`Model: ${model}`));
|
|
205
|
+
console.error(pc.dim(`Prompt: ${prompt}`));
|
|
206
|
+
console.error(pc.cyan('Generating...'));
|
|
207
|
+
}
|
|
208
|
+
const inputImages = await readInputImages(options.input);
|
|
209
|
+
const maskImage = options.mask
|
|
210
|
+
? await readInputSource(options.mask)
|
|
211
|
+
: undefined;
|
|
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;
|
|
230
|
+
}
|
|
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({
|
|
241
|
+
model,
|
|
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));
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
// ─── video command ───────────────────────────────────────────────────────────
|
|
253
|
+
cli
|
|
254
|
+
.command('video <prompt>', dedent `
|
|
255
|
+
Generate videos from a text prompt (or image+text prompt for models that
|
|
256
|
+
support image-to-video). Uses AI SDK experimental_generateVideo under the hood.
|
|
257
|
+
|
|
258
|
+
Agent note: video generation can be slow. When invoking this command from
|
|
259
|
+
automation, use a command timeout of at least 5 minutes.
|
|
260
|
+
`)
|
|
261
|
+
.option('-m, --model [model]', z.string().describe('Video model ID for generation. If omitted, shows an interactive picker (or uses default in non-TTY mode)'))
|
|
262
|
+
.option('-o, --output [path]', z
|
|
263
|
+
.string()
|
|
264
|
+
.default('egaki-output.mp4')
|
|
265
|
+
.describe('Output file path (index suffix added when generating multiple)'))
|
|
266
|
+
.option('-n, --count [n]', z.number().default(1).describe('Number of videos to generate'))
|
|
267
|
+
.option('--aspect-ratio [ratio]', z.string().describe('Video aspect ratio in WIDTH:HEIGHT format (e.g. 16:9, 9:16)'))
|
|
268
|
+
.option('--resolution [resolution]', z
|
|
269
|
+
.string()
|
|
270
|
+
.describe(`Video resolution. ${describeProviderValues('resolution', [VIDEO_CATALOG])}. Google: 720p, 1080p, 4k`))
|
|
271
|
+
.option('--duration [seconds]', z.number().describe('Video duration in seconds. Veo: 4-8s. xAI Grok: 1-15s. Kling: 5-10s. Wan: 2-15s'))
|
|
272
|
+
.option('--fps [fps]', z.number().describe('Frames per second for video models that support fps override'))
|
|
273
|
+
.option('--seed [seed]', z.number().describe('Seed for reproducible video generation (model support varies)'))
|
|
274
|
+
.option('-i, --input [file]', z
|
|
275
|
+
.string()
|
|
276
|
+
.describe('Input file for video generation. For i2v: a reference image. For edit-video/extend-video: a source video. Accepts local file paths or URLs'))
|
|
277
|
+
.option('--mode [mode]', z
|
|
278
|
+
.enum(['edit-video', 'extend-video', 'reference-to-video'])
|
|
279
|
+
.describe(`Video operation mode. ${describeProviderValues('mode', [VIDEO_CATALOG])}`))
|
|
280
|
+
.option('--reference-images [path]', z
|
|
281
|
+
.array(z.string())
|
|
282
|
+
.describe('Reference images for R2V generation, repeatable. xAI: 1-7 images. Seedance: 1-9 images (use @Image1 etc. in prompt)'))
|
|
283
|
+
.option('--negative-prompt [text]', z
|
|
284
|
+
.string()
|
|
285
|
+
.describe('Describe what to avoid in the generated video (Fal models)'))
|
|
286
|
+
.option('--json', 'Output result metadata as JSON to stdout (model, usage, warnings, file paths)')
|
|
287
|
+
.option('--stdout', 'Write raw video bytes to stdout instead of saving to a file. Useful for piping to other tools')
|
|
288
|
+
.example('# Generate a video')
|
|
289
|
+
.example('egaki video "A paper airplane gliding through clouds" -o airplane.mp4')
|
|
290
|
+
.example('# Generate with Veo model + duration')
|
|
291
|
+
.example('egaki video "cinematic rainy street at night" -m veo-3.1-fast-generate-001 --duration 6')
|
|
292
|
+
.example('# Route through Vertex AI (Google Cloud billing)')
|
|
293
|
+
.example('egaki video "storm over mountains" -m vertex/veo-3.1-fast-generate-001 --duration 6')
|
|
294
|
+
.example('# Image-to-video (model support required)')
|
|
295
|
+
.example('egaki video "animate subtle camera pan" --model luma-ray-2 --input frame.png -o animated.mp4')
|
|
296
|
+
.example('# Generate multiple videos')
|
|
297
|
+
.example('egaki video "waves crashing on cliffs" -n 2 -o waves.mp4')
|
|
298
|
+
.action(async (prompt, options) => {
|
|
299
|
+
injectCredentialsToEnv();
|
|
300
|
+
const model = options.model ?? await resolveVideoModel();
|
|
301
|
+
const outputPath = options.output ?? 'egaki-output.mp4';
|
|
302
|
+
const config = getModelConfig(model);
|
|
303
|
+
if (config instanceof Error) {
|
|
304
|
+
console.error(pc.red(config.message));
|
|
305
|
+
process.exit(1);
|
|
306
|
+
}
|
|
307
|
+
if (!options.stdout) {
|
|
308
|
+
console.error(pc.dim(`Model: ${model}`));
|
|
309
|
+
console.error(pc.dim(`Prompt: ${prompt}`));
|
|
310
|
+
console.error(pc.cyan('Generating...'));
|
|
311
|
+
}
|
|
312
|
+
// Determine how --input is used based on --mode:
|
|
313
|
+
// - edit-video / extend-video: input is a source video, passed as videoUrl (needs URL)
|
|
314
|
+
// - everything else (i2v, t2v): input is a reference image, passed as bytes
|
|
315
|
+
const isVideoInputMode = options.mode === 'edit-video' || options.mode === 'extend-video';
|
|
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';
|
|
320
|
+
}
|
|
321
|
+
let inputImage;
|
|
322
|
+
let videoUrl;
|
|
323
|
+
if (options.input && isVideoInputMode) {
|
|
324
|
+
videoUrl = await resolveToUrl(options.input);
|
|
325
|
+
}
|
|
326
|
+
else if (options.input) {
|
|
327
|
+
inputImage = await readInputSource(options.input);
|
|
328
|
+
}
|
|
329
|
+
let resolvedReferenceImages = options.referenceImages;
|
|
330
|
+
if (resolvedReferenceImages) {
|
|
331
|
+
resolvedReferenceImages = await Promise.all(resolvedReferenceImages.map((ref) => resolveToUrl(ref)));
|
|
332
|
+
}
|
|
333
|
+
const result = await runProviderCall('Video generation', () => generateVideo({
|
|
334
|
+
prompt,
|
|
335
|
+
model,
|
|
336
|
+
count: options.count ?? 1,
|
|
337
|
+
aspectRatio: options.aspectRatio,
|
|
338
|
+
resolution: options.resolution,
|
|
339
|
+
duration: options.duration,
|
|
340
|
+
fps: options.fps,
|
|
341
|
+
seed: options.seed,
|
|
342
|
+
inputImage,
|
|
343
|
+
mode: options.mode,
|
|
344
|
+
videoUrl,
|
|
345
|
+
referenceImages: resolvedReferenceImages,
|
|
346
|
+
negativePrompt: options.negativePrompt,
|
|
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
|
+
}
|
|
766
|
+
});
|
|
767
|
+
// ─── models command ──────────────────────────────────────────────────────────
|
|
768
|
+
cli
|
|
769
|
+
.command('models', dedent `
|
|
770
|
+
List all supported image generation models with pricing, features,
|
|
771
|
+
and provider info. Output is YAML for easy reading and piping.
|
|
772
|
+
`)
|
|
773
|
+
.option('-p, --provider [provider]', z.string().describe('Filter models by provider name (e.g. google, openai, replicate, fal)'))
|
|
774
|
+
.option('--type [type]', z
|
|
775
|
+
.enum(['all', 'image', 'video', 'speech', 'transcription'])
|
|
776
|
+
.default('all')
|
|
777
|
+
.describe('Filter by model type: image, video, speech, transcription, or all'))
|
|
778
|
+
.option('--json', 'Output as JSON instead of YAML')
|
|
779
|
+
.action(async (options) => {
|
|
780
|
+
const yaml = await import('js-yaml');
|
|
781
|
+
const providerStatuses = Object.fromEntries(Object.keys(PROVIDERS).map((provider) => [provider, getKeyStatus(provider)]));
|
|
782
|
+
let models = options.type === 'video'
|
|
783
|
+
? [...VIDEO_CATALOG]
|
|
784
|
+
: options.type === 'image'
|
|
785
|
+
? [...CATALOG]
|
|
786
|
+
: options.type === 'speech'
|
|
787
|
+
? [...SPEECH_CATALOG]
|
|
788
|
+
: options.type === 'transcription'
|
|
789
|
+
? [...TRANSCRIPTION_CATALOG]
|
|
790
|
+
: [...CATALOG, ...VIDEO_CATALOG, ...SPEECH_CATALOG, ...TRANSCRIPTION_CATALOG];
|
|
791
|
+
if (options.provider) {
|
|
792
|
+
models = models.filter((m) => m.provider === options.provider);
|
|
793
|
+
if (models.length === 0) {
|
|
794
|
+
console.error(pc.red(`No models found for provider: ${options.provider}`));
|
|
795
|
+
process.exit(1);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
const output = models.map((m) => ({
|
|
799
|
+
id: m.id,
|
|
800
|
+
name: m.name,
|
|
801
|
+
...(m.description ? { description: m.description } : {}),
|
|
802
|
+
provider: m.provider,
|
|
803
|
+
auth: providerStatuses[m.provider] ?? { available: false, source: 'none' },
|
|
804
|
+
strategy: m.strategy,
|
|
805
|
+
released: m.released,
|
|
806
|
+
cost: formatCatalogCost(m.cost),
|
|
807
|
+
features: m.strategy === 'video'
|
|
808
|
+
? {
|
|
809
|
+
textToVideo: m.features.textToVideo,
|
|
810
|
+
imageToVideo: m.features.imageToVideo,
|
|
811
|
+
capabilities: m.features.capabilities.join(', ') || 'none',
|
|
812
|
+
seed: m.features.seed,
|
|
813
|
+
multipleVideos: m.features.multipleVideos,
|
|
814
|
+
aspectRatios: m.features.aspectRatios?.join(', ') || 'none',
|
|
815
|
+
resolutions: m.features.resolutions?.join(', ') || 'unknown',
|
|
816
|
+
durationRangeSec: m.features.durationRangeSec
|
|
817
|
+
? `${m.features.durationRangeSec.min}-${m.features.durationRangeSec.max}`
|
|
818
|
+
: 'unknown',
|
|
819
|
+
}
|
|
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
|
+
},
|
|
848
|
+
}));
|
|
849
|
+
if (options.json) {
|
|
850
|
+
console.log(JSON.stringify(output, null, 2));
|
|
851
|
+
}
|
|
852
|
+
else {
|
|
853
|
+
console.log(yaml.dump(output, { lineWidth: 120, noRefs: true }));
|
|
854
|
+
}
|
|
855
|
+
});
|
|
856
|
+
/** Upload gateway base URL for temporary file uploads. */
|
|
857
|
+
const UPLOAD_GATEWAY_BASE = 'https://egaki.org';
|
|
858
|
+
/** Max upload size in bytes (must match gateway limit). */
|
|
859
|
+
const MAX_UPLOAD_SIZE = 100 * 1024 * 1024;
|
|
860
|
+
cli.help();
|
|
861
|
+
cli.version(pkg.version);
|
|
862
|
+
export { cli };
|
|
863
|
+
async function runProviderCall(operation, call) {
|
|
864
|
+
const result = await call().catch((error) => {
|
|
865
|
+
printErrorDetails(error, operation);
|
|
866
|
+
process.exit(1);
|
|
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
|
+
}
|
|
878
|
+
return result;
|
|
879
|
+
}
|
|
880
|
+
function printErrorDetails(error, operation) {
|
|
881
|
+
const prefix = operation ? `${operation} failed` : 'Command failed';
|
|
882
|
+
console.error(pc.red(pc.bold(prefix)));
|
|
883
|
+
if (APICallError.isInstance(error)) {
|
|
884
|
+
printKeyValue('name', error.name);
|
|
885
|
+
printKeyValue('message', error.message);
|
|
886
|
+
printKeyValue('statusCode', error.statusCode);
|
|
887
|
+
printKeyValue('url', error.url);
|
|
888
|
+
printKeyValue('isRetryable', error.isRetryable);
|
|
889
|
+
printJsonBlock('responseBody', parseJsonLike(error.responseBody));
|
|
890
|
+
printJsonBlock('responseHeaders', error.responseHeaders);
|
|
891
|
+
printJsonBlock('requestBodyValues', error.requestBodyValues);
|
|
892
|
+
printJsonBlock('data', error.data);
|
|
893
|
+
printCause(error.cause);
|
|
894
|
+
printStack(error);
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
if (NoImageGeneratedError.isInstance(error) || NoVideoGeneratedError.isInstance(error)) {
|
|
898
|
+
printKeyValue('name', error.name);
|
|
899
|
+
printKeyValue('message', error.message);
|
|
900
|
+
printJsonBlock('responses', error.responses);
|
|
901
|
+
printCause(error.cause);
|
|
902
|
+
printStack(error);
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
if (RetryError.isInstance(error)) {
|
|
906
|
+
printKeyValue('name', error.name);
|
|
907
|
+
printKeyValue('message', error.message);
|
|
908
|
+
printKeyValue('reason', error.reason);
|
|
909
|
+
printJsonBlock('errors', error.errors.map((e) => serializeUnknown(e)));
|
|
910
|
+
printCause(error.cause);
|
|
911
|
+
printStack(error);
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
if (error instanceof Error) {
|
|
915
|
+
printKeyValue('name', error.name);
|
|
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
|
+
}
|
|
925
|
+
printJsonBlock('details', serializeOwnProperties(error));
|
|
926
|
+
printCause(error.cause);
|
|
927
|
+
printStack(error);
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
printJsonBlock('error', serializeUnknown(error));
|
|
931
|
+
}
|
|
932
|
+
function printKeyValue(key, value) {
|
|
933
|
+
if (value === undefined)
|
|
934
|
+
return;
|
|
935
|
+
console.error(`${pc.dim(`${key}:`)} ${String(value)}`);
|
|
936
|
+
}
|
|
937
|
+
function printJsonBlock(label, value) {
|
|
938
|
+
if (value === undefined)
|
|
939
|
+
return;
|
|
940
|
+
const serialized = stableStringify(value);
|
|
941
|
+
if (!serialized || serialized === '{}')
|
|
942
|
+
return;
|
|
943
|
+
console.error(pc.dim(`${label}:`));
|
|
944
|
+
console.error(serialized);
|
|
945
|
+
}
|
|
946
|
+
function printCause(cause) {
|
|
947
|
+
if (cause === undefined)
|
|
948
|
+
return;
|
|
949
|
+
console.error(pc.dim('cause:'));
|
|
950
|
+
console.error(stableStringify(serializeUnknown(cause)));
|
|
951
|
+
}
|
|
952
|
+
function printStack(error) {
|
|
953
|
+
if (!error.stack)
|
|
954
|
+
return;
|
|
955
|
+
console.error(pc.dim('stack:'));
|
|
956
|
+
console.error(pc.dim(error.stack));
|
|
957
|
+
}
|
|
958
|
+
function parseJsonLike(value) {
|
|
959
|
+
if (typeof value !== 'string')
|
|
960
|
+
return value;
|
|
961
|
+
try {
|
|
962
|
+
return JSON.parse(value);
|
|
963
|
+
}
|
|
964
|
+
catch {
|
|
965
|
+
return value;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
function serializeUnknown(value) {
|
|
969
|
+
if (value instanceof Error) {
|
|
970
|
+
return {
|
|
971
|
+
name: value.name,
|
|
972
|
+
message: value.message,
|
|
973
|
+
...serializeOwnProperties(value),
|
|
974
|
+
...(value.cause === undefined ? {} : { cause: serializeUnknown(value.cause) }),
|
|
975
|
+
...(value.stack ? { stack: value.stack } : {}),
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
return value;
|
|
979
|
+
}
|
|
980
|
+
function serializeOwnProperties(error) {
|
|
981
|
+
return Object.fromEntries(Object.entries(error).map(([key, value]) => [key, serializeUnknown(value)]));
|
|
982
|
+
}
|
|
983
|
+
function stableStringify(value) {
|
|
984
|
+
const seen = new WeakSet();
|
|
985
|
+
return JSON.stringify(value, (_key, item) => {
|
|
986
|
+
if (typeof item === 'bigint')
|
|
987
|
+
return item.toString();
|
|
988
|
+
if (item instanceof Date)
|
|
989
|
+
return item.toISOString();
|
|
990
|
+
if (typeof item !== 'object' || item === null)
|
|
991
|
+
return item;
|
|
992
|
+
if (seen.has(item))
|
|
993
|
+
return '[Circular]';
|
|
994
|
+
seen.add(item);
|
|
995
|
+
return item;
|
|
996
|
+
}, 2);
|
|
997
|
+
}
|
|
998
|
+
// ─── interactive model pickers ──────────────────────────────────────────────
|
|
999
|
+
/** Curated image models shown in the interactive picker, ordered by recommendation. */
|
|
1000
|
+
const FEATURED_IMAGE_MODELS = [
|
|
1001
|
+
'imagen-4.0-generate-001',
|
|
1002
|
+
'imagen-4.0-ultra-generate-001',
|
|
1003
|
+
'imagen-4.0-fast-generate-001',
|
|
1004
|
+
'gemini-2.5-flash-image',
|
|
1005
|
+
'gemini-3.1-flash-image-preview',
|
|
1006
|
+
'nano-banana-pro-preview',
|
|
1007
|
+
'gpt-image-2',
|
|
1008
|
+
'gpt-image-1.5',
|
|
1009
|
+
'chatgpt-image-latest',
|
|
1010
|
+
'flux-kontext-pro',
|
|
1011
|
+
'flux-kontext-max',
|
|
1012
|
+
'flux-pro-1.1-ultra',
|
|
1013
|
+
'recraft-v4',
|
|
1014
|
+
'dall-e-3',
|
|
1015
|
+
'grok-imagine-image-quality',
|
|
1016
|
+
];
|
|
1017
|
+
/**
|
|
1018
|
+
* Resolve the image model: show interactive picker in TTY, use default otherwise.
|
|
1019
|
+
*/
|
|
1020
|
+
async function resolveImageModel() {
|
|
1021
|
+
const isTTY = process.stdin.isTTY && process.stderr.isTTY;
|
|
1022
|
+
if (!isTTY)
|
|
1023
|
+
return DEFAULT_MODEL;
|
|
1024
|
+
const options = FEATURED_IMAGE_MODELS
|
|
1025
|
+
.map((id) => CATALOG.find((m) => m.id === id))
|
|
1026
|
+
.filter(Boolean)
|
|
1027
|
+
.map((m) => {
|
|
1028
|
+
const cost = m.cost.type === 'per-image'
|
|
1029
|
+
? pc.dim(`$${m.cost.perImage}/img`)
|
|
1030
|
+
: m.cost.type === 'per-token'
|
|
1031
|
+
? pc.dim(`$${m.cost.outputPerM}/M out`)
|
|
1032
|
+
: '';
|
|
1033
|
+
return {
|
|
1034
|
+
value: m.id,
|
|
1035
|
+
label: `${m.name} ${cost}`,
|
|
1036
|
+
hint: m.id,
|
|
1037
|
+
};
|
|
1038
|
+
});
|
|
1039
|
+
const selected = await select({
|
|
1040
|
+
message: 'Select an image model',
|
|
1041
|
+
options,
|
|
1042
|
+
});
|
|
1043
|
+
if (isCancel(selected)) {
|
|
1044
|
+
cancel('Cancelled.');
|
|
1045
|
+
process.exit(0);
|
|
1046
|
+
}
|
|
1047
|
+
return selected;
|
|
1048
|
+
}
|
|
1049
|
+
/**
|
|
1050
|
+
* Resolve the video model: show interactive picker in TTY, use default otherwise.
|
|
1051
|
+
*/
|
|
1052
|
+
async function resolveVideoModel() {
|
|
1053
|
+
const isTTY = process.stdin.isTTY && process.stderr.isTTY;
|
|
1054
|
+
if (!isTTY)
|
|
1055
|
+
return DEFAULT_VIDEO_MODEL;
|
|
1056
|
+
const options = VIDEO_CATALOG.map((m) => {
|
|
1057
|
+
const dur = m.features.durationRangeSec
|
|
1058
|
+
? pc.dim(`${m.features.durationRangeSec.min}-${m.features.durationRangeSec.max}s`)
|
|
1059
|
+
: '';
|
|
1060
|
+
return {
|
|
1061
|
+
value: m.id,
|
|
1062
|
+
label: `${m.name} ${dur}`,
|
|
1063
|
+
hint: m.id,
|
|
1064
|
+
};
|
|
1065
|
+
});
|
|
1066
|
+
const selected = await select({
|
|
1067
|
+
message: 'Select a video model',
|
|
1068
|
+
options,
|
|
1069
|
+
});
|
|
1070
|
+
if (isCancel(selected)) {
|
|
1071
|
+
cancel('Cancelled.');
|
|
1072
|
+
process.exit(0);
|
|
1073
|
+
}
|
|
1074
|
+
return selected;
|
|
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
|
+
}
|
|
1136
|
+
function isUrl(input) {
|
|
1137
|
+
return /^https?:\/\//i.test(input);
|
|
1138
|
+
}
|
|
1139
|
+
function mimeTypeFromExtension(ext) {
|
|
1140
|
+
const map = {
|
|
1141
|
+
'.mp4': 'video/mp4',
|
|
1142
|
+
'.webm': 'video/webm',
|
|
1143
|
+
'.mov': 'video/quicktime',
|
|
1144
|
+
'.avi': 'video/x-msvideo',
|
|
1145
|
+
'.png': 'image/png',
|
|
1146
|
+
'.jpg': 'image/jpeg',
|
|
1147
|
+
'.jpeg': 'image/jpeg',
|
|
1148
|
+
'.webp': 'image/webp',
|
|
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',
|
|
1157
|
+
};
|
|
1158
|
+
return map[ext.toLowerCase()] || 'application/octet-stream';
|
|
1159
|
+
}
|
|
1160
|
+
/**
|
|
1161
|
+
* Upload a local file to the egaki gateway and return the public URL.
|
|
1162
|
+
* Used when a flag that expects a URL receives a local file path instead.
|
|
1163
|
+
*/
|
|
1164
|
+
async function uploadFileToGateway(filePath) {
|
|
1165
|
+
const resolved = path.resolve(filePath);
|
|
1166
|
+
if (!fs.existsSync(resolved)) {
|
|
1167
|
+
console.error(pc.red(`File not found: ${resolved}`));
|
|
1168
|
+
process.exit(1);
|
|
1169
|
+
}
|
|
1170
|
+
const stat = fs.statSync(resolved);
|
|
1171
|
+
if (stat.size > MAX_UPLOAD_SIZE) {
|
|
1172
|
+
console.error(pc.red(`File too large: ${resolved} (${Math.round(stat.size / 1024 / 1024)}MB). Maximum is ${MAX_UPLOAD_SIZE / 1024 / 1024}MB`));
|
|
1173
|
+
process.exit(1);
|
|
1174
|
+
}
|
|
1175
|
+
const ext = path.extname(resolved);
|
|
1176
|
+
const contentType = mimeTypeFromExtension(ext);
|
|
1177
|
+
const fileBytes = fs.readFileSync(resolved);
|
|
1178
|
+
console.error(pc.dim(`Uploading ${filePath}...`));
|
|
1179
|
+
const response = await fetch(`${UPLOAD_GATEWAY_BASE}/upload`, {
|
|
1180
|
+
method: 'POST',
|
|
1181
|
+
headers: { 'Content-Type': contentType },
|
|
1182
|
+
body: fileBytes,
|
|
1183
|
+
});
|
|
1184
|
+
if (!response.ok) {
|
|
1185
|
+
const body = await response.text().catch(() => '');
|
|
1186
|
+
console.error(pc.red(`Upload failed: ${response.status} ${response.statusText}`));
|
|
1187
|
+
if (body)
|
|
1188
|
+
console.error(pc.dim(body));
|
|
1189
|
+
process.exit(1);
|
|
1190
|
+
}
|
|
1191
|
+
const result = await response.json();
|
|
1192
|
+
console.error(pc.dim(`Uploaded: ${result.url}`));
|
|
1193
|
+
return result.url;
|
|
1194
|
+
}
|
|
1195
|
+
/**
|
|
1196
|
+
* Resolve a value that should be a URL: if it's already a URL, return as-is.
|
|
1197
|
+
* If it's a local file path, upload it to the gateway and return the public URL.
|
|
1198
|
+
*/
|
|
1199
|
+
async function resolveToUrl(input) {
|
|
1200
|
+
if (isUrl(input))
|
|
1201
|
+
return input;
|
|
1202
|
+
return uploadFileToGateway(input);
|
|
1203
|
+
}
|
|
1204
|
+
async function readInputSource(input) {
|
|
1205
|
+
if (isUrl(input)) {
|
|
1206
|
+
console.error(pc.dim(`Fetching ${input}...`));
|
|
1207
|
+
const res = await fetch(input);
|
|
1208
|
+
if (!res.ok) {
|
|
1209
|
+
console.error(pc.red(`Failed to fetch ${input}: ${res.status} ${res.statusText}`));
|
|
1210
|
+
process.exit(1);
|
|
1211
|
+
}
|
|
1212
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
1213
|
+
}
|
|
1214
|
+
const resolved = path.resolve(input);
|
|
1215
|
+
if (!fs.existsSync(resolved)) {
|
|
1216
|
+
console.error(pc.red(`File not found: ${resolved}`));
|
|
1217
|
+
process.exit(1);
|
|
1218
|
+
}
|
|
1219
|
+
return new Uint8Array(fs.readFileSync(resolved));
|
|
1220
|
+
}
|
|
1221
|
+
async function readInputImages(inputs) {
|
|
1222
|
+
if (!inputs || inputs.length === 0) {
|
|
1223
|
+
return [];
|
|
1224
|
+
}
|
|
1225
|
+
return Promise.all(inputs.map((f) => readInputSource(f)));
|
|
1226
|
+
}
|
|
1227
|
+
// ─── shared output helpers ───────────────────────────────────────────────────
|
|
1228
|
+
function saveGeneratedFiles(files, outputPath) {
|
|
1229
|
+
const savedFiles = [];
|
|
1230
|
+
for (let i = 0; i < files.length; i++) {
|
|
1231
|
+
const file = files[i];
|
|
1232
|
+
const ext = extensionFromMediaType(file.mediaType);
|
|
1233
|
+
const filePath = files.length === 1
|
|
1234
|
+
? ensureExtension(outputPath, ext)
|
|
1235
|
+
: insertIndex(outputPath, i, ext);
|
|
1236
|
+
fs.writeFileSync(filePath, file.uint8Array);
|
|
1237
|
+
console.error(pc.green(`Saved: ${filePath}`));
|
|
1238
|
+
savedFiles.push(filePath);
|
|
1239
|
+
}
|
|
1240
|
+
return savedFiles;
|
|
1241
|
+
}
|
|
1242
|
+
function printCost(cost) {
|
|
1243
|
+
if (cost != null) {
|
|
1244
|
+
console.error(pc.dim(`Cost: ${formatCost(cost)}`));
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
function writeFirstToStdout(files) {
|
|
1248
|
+
const file = files[0];
|
|
1249
|
+
if (file) {
|
|
1250
|
+
process.stdout.write(Buffer.from(file.uint8Array));
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
// ─── display helpers ─────────────────────────────────────────────────────────
|
|
1254
|
+
function formatCatalogCost(cost) {
|
|
1255
|
+
if (cost.type === 'per-image') {
|
|
1256
|
+
return `$${cost.perImage}/image`;
|
|
1257
|
+
}
|
|
1258
|
+
if (cost.type === 'per-token') {
|
|
1259
|
+
return `$${cost.inputPerM}/M input, $${cost.outputPerM}/M output`;
|
|
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
|
+
}
|
|
1267
|
+
if (cost.type === 'per-video-second') {
|
|
1268
|
+
const tierText = cost.tiers
|
|
1269
|
+
.map((t) => {
|
|
1270
|
+
const parts = [
|
|
1271
|
+
t.resolution,
|
|
1272
|
+
t.mode ? `mode=${t.mode}` : undefined,
|
|
1273
|
+
t.audio != null ? `audio=${t.audio}` : undefined,
|
|
1274
|
+
].filter(Boolean);
|
|
1275
|
+
return parts.length > 0
|
|
1276
|
+
? `$${t.costPerSecond}/s (${parts.join(', ')})`
|
|
1277
|
+
: `$${t.costPerSecond}/s`;
|
|
1278
|
+
})
|
|
1279
|
+
.join('; ');
|
|
1280
|
+
return `${tierText} (default duration ${cost.defaultDurationSec}s)`;
|
|
1281
|
+
}
|
|
1282
|
+
return 'unknown';
|
|
1283
|
+
}
|
|
1284
|
+
function formatCost(dollars) {
|
|
1285
|
+
if (dollars < 0.0001) {
|
|
1286
|
+
return `$${dollars.toFixed(6)}`;
|
|
1287
|
+
}
|
|
1288
|
+
if (dollars < 0.01) {
|
|
1289
|
+
return `$${dollars.toFixed(4)}`;
|
|
1290
|
+
}
|
|
1291
|
+
return `$${dollars.toFixed(2)}`;
|
|
1292
|
+
}
|
|
1293
|
+
function extensionFromMediaType(mediaType) {
|
|
1294
|
+
const map = {
|
|
1295
|
+
'image/png': '.png',
|
|
1296
|
+
'image/jpeg': '.jpg',
|
|
1297
|
+
'image/webp': '.webp',
|
|
1298
|
+
'image/gif': '.gif',
|
|
1299
|
+
'video/mp4': '.mp4',
|
|
1300
|
+
'video/webm': '.webm',
|
|
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',
|
|
1309
|
+
};
|
|
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';
|
|
1317
|
+
}
|
|
1318
|
+
function ensureExtension(filePath, ext) {
|
|
1319
|
+
const parsed = path.parse(filePath);
|
|
1320
|
+
if (parsed.ext) {
|
|
1321
|
+
return filePath;
|
|
1322
|
+
}
|
|
1323
|
+
return filePath + ext;
|
|
1324
|
+
}
|
|
1325
|
+
function insertIndex(filePath, index, ext) {
|
|
1326
|
+
const parsed = path.parse(filePath);
|
|
1327
|
+
const finalExt = parsed.ext || ext;
|
|
1328
|
+
return path.join(parsed.dir, `${parsed.name}-${index}${finalExt}`);
|
|
1329
|
+
}
|