egaki 0.4.1 → 0.6.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 +71 -0
- package/README.md +107 -3
- package/dist/cli.js +540 -130
- package/dist/credentials.d.ts +11 -2
- package/dist/credentials.d.ts.map +1 -1
- package/dist/credentials.js +68 -5
- package/dist/login.d.ts.map +1 -1
- package/dist/login.js +45 -4
- package/dist/model-catalog.d.ts +96 -3
- package/dist/model-catalog.d.ts.map +1 -1
- package/dist/model-catalog.js +427 -3
- package/dist/models.d.ts +4 -4
- package/dist/models.d.ts.map +1 -1
- package/dist/models.js +162 -96
- package/dist/video-model-catalog.d.ts +1 -51
- package/dist/video-model-catalog.d.ts.map +1 -1
- package/dist/video-model-catalog.js +5 -306
- package/dist/xai-auth.d.ts +29 -0
- package/dist/xai-auth.d.ts.map +1 -0
- package/dist/xai-auth.js +325 -0
- package/package.json +5 -4
- package/skills/egaki/SKILL.md +10 -1
- package/src/cli.ts +712 -139
- package/src/credentials.ts +70 -7
- package/src/login.ts +48 -3
- package/src/model-catalog.ts +537 -7
- package/src/models.ts +239 -119
- package/src/video-model-catalog.ts +14 -363
- package/src/xai-auth.ts +396 -0
package/src/cli.ts
CHANGED
|
@@ -11,10 +11,19 @@ import { goke } from 'goke'
|
|
|
11
11
|
import { z } from 'zod'
|
|
12
12
|
import dedent from 'string-dedent'
|
|
13
13
|
import {
|
|
14
|
+
APICallError,
|
|
14
15
|
generateImage as aiGenerateImage,
|
|
15
16
|
generateText,
|
|
16
17
|
experimental_generateVideo as aiGenerateVideo,
|
|
18
|
+
NoImageGeneratedError,
|
|
19
|
+
NoVideoGeneratedError,
|
|
20
|
+
RetryError,
|
|
17
21
|
} from 'ai'
|
|
22
|
+
import type { XaiImageModelOptions, XaiVideoModelOptions } from '@ai-sdk/xai'
|
|
23
|
+
import type { GoogleImageModelOptions, GoogleLanguageModelOptions, GoogleVideoModelOptions } from '@ai-sdk/google'
|
|
24
|
+
import type { GoogleVertexImageModelOptions, GoogleVertexVideoModelOptions } from '@ai-sdk/google-vertex'
|
|
25
|
+
import type { FalImageModelOptions, FalVideoModelOptions } from '@ai-sdk/fal'
|
|
26
|
+
import { select, isCancel, cancel } from '@clack/prompts'
|
|
18
27
|
import fs from 'node:fs'
|
|
19
28
|
import path from 'node:path'
|
|
20
29
|
import pc from 'picocolors'
|
|
@@ -38,7 +47,11 @@ import {
|
|
|
38
47
|
createVideoModel,
|
|
39
48
|
shouldUseResponsesApi,
|
|
40
49
|
} from './models.js'
|
|
41
|
-
import {
|
|
50
|
+
import {
|
|
51
|
+
CATALOG,
|
|
52
|
+
VIDEO_CATALOG,
|
|
53
|
+
describeProviderValues,
|
|
54
|
+
} from './model-catalog.js'
|
|
42
55
|
import {
|
|
43
56
|
loginInteractive,
|
|
44
57
|
loginNonInteractive,
|
|
@@ -54,23 +67,31 @@ import {
|
|
|
54
67
|
} from './subscription.js'
|
|
55
68
|
import { getValidChatGptAuth } from './chatgpt-auth.js'
|
|
56
69
|
|
|
70
|
+
// OpenAI doesn't export a providerOptions type for image models yet (vercel/ai#12437).
|
|
71
|
+
// These fields are what providerOptions.openai accepts, derived from
|
|
72
|
+
// node_modules/@ai-sdk/openai/src/image/openai-image-model-options.ts.
|
|
73
|
+
// NOTE: The SDK uses camelCase keys in providerOptions, then internally maps to
|
|
74
|
+
// snake_case for the OpenAI API. Always use camelCase here.
|
|
75
|
+
type OpenAIImageProviderOptions = {
|
|
76
|
+
quality?: 'standard' | 'low' | 'medium' | 'high' | 'auto'
|
|
77
|
+
outputFormat?: 'png' | 'jpeg' | 'webp'
|
|
78
|
+
outputCompression?: number
|
|
79
|
+
size?: `${number}x${number}`
|
|
80
|
+
partialImages?: number | null
|
|
81
|
+
background?: 'auto' | 'opaque' | 'transparent'
|
|
82
|
+
}
|
|
83
|
+
|
|
57
84
|
const cli = goke('egaki')
|
|
58
85
|
|
|
59
86
|
process.title = 'egaki'
|
|
60
87
|
|
|
61
|
-
// Print clean error output for unhandled rejections (e.g. AI SDK APICallError).
|
|
62
|
-
// The AI SDK errors include request bodies, response headers, and other noisy
|
|
63
|
-
// properties that get dumped by Node's default handler. We strip those and just
|
|
64
|
-
// print the message + stack trace.
|
|
65
88
|
process.on('uncaughtException', (err) => {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
}
|
|
89
|
+
printErrorDetails(err)
|
|
90
|
+
process.exit(1)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
process.on('unhandledRejection', (reason) => {
|
|
94
|
+
printErrorDetails(reason)
|
|
74
95
|
process.exit(1)
|
|
75
96
|
})
|
|
76
97
|
|
|
@@ -127,8 +148,8 @@ cli
|
|
|
127
148
|
|
|
128
149
|
// Non-interactive: --provider + --key or stdin
|
|
129
150
|
if (options.provider) {
|
|
130
|
-
//
|
|
131
|
-
if (options.provider === 'chatgpt') {
|
|
151
|
+
// OAuth providers use browser flow — skip key reading
|
|
152
|
+
if (options.provider === 'chatgpt' || options.provider === 'xai-oauth') {
|
|
132
153
|
await loginNonInteractive({ provider: options.provider, key: '' })
|
|
133
154
|
return
|
|
134
155
|
}
|
|
@@ -225,7 +246,7 @@ cli
|
|
|
225
246
|
)
|
|
226
247
|
.option(
|
|
227
248
|
'-m, --model [model]',
|
|
228
|
-
z.
|
|
249
|
+
z.string().describe('Model ID for generation. If omitted, shows an interactive picker (or uses default in non-TTY mode)'),
|
|
229
250
|
)
|
|
230
251
|
.option(
|
|
231
252
|
'-o, --output [path]',
|
|
@@ -274,6 +295,38 @@ cli
|
|
|
274
295
|
'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',
|
|
275
296
|
),
|
|
276
297
|
)
|
|
298
|
+
.option(
|
|
299
|
+
'--quality [level]',
|
|
300
|
+
z
|
|
301
|
+
.string()
|
|
302
|
+
.describe(
|
|
303
|
+
`Image quality level. ${describeProviderValues('quality', [CATALOG])}`,
|
|
304
|
+
),
|
|
305
|
+
)
|
|
306
|
+
.option(
|
|
307
|
+
'--resolution [res]',
|
|
308
|
+
z
|
|
309
|
+
.string()
|
|
310
|
+
.describe(
|
|
311
|
+
`Output resolution. ${describeProviderValues('resolution', [CATALOG])}`,
|
|
312
|
+
),
|
|
313
|
+
)
|
|
314
|
+
.option(
|
|
315
|
+
'--output-format [format]',
|
|
316
|
+
z
|
|
317
|
+
.string()
|
|
318
|
+
.describe(
|
|
319
|
+
`Output image format. ${describeProviderValues('output-format', [CATALOG])}`,
|
|
320
|
+
),
|
|
321
|
+
)
|
|
322
|
+
.option(
|
|
323
|
+
'--negative-prompt [text]',
|
|
324
|
+
z
|
|
325
|
+
.string()
|
|
326
|
+
.describe(
|
|
327
|
+
'Describe what to avoid in the generated image (Fal models)',
|
|
328
|
+
),
|
|
329
|
+
)
|
|
277
330
|
.option(
|
|
278
331
|
'--allow-people',
|
|
279
332
|
'Allow generating images of people (Imagen blocks people by default)',
|
|
@@ -308,10 +361,7 @@ cli
|
|
|
308
361
|
// Inject stored API keys as env vars before calling the AI SDK.
|
|
309
362
|
injectCredentialsToEnv()
|
|
310
363
|
|
|
311
|
-
|
|
312
|
-
// options.model / options.output / options.count as `T | undefined` even
|
|
313
|
-
// though the runtime always resolves them. Apply the same defaults here.
|
|
314
|
-
const model = options.model ?? DEFAULT_MODEL
|
|
364
|
+
const model = options.model ?? await resolveImageModel()
|
|
315
365
|
const outputPath = options.output ?? 'egaki-output.png'
|
|
316
366
|
const count = options.count ?? 1
|
|
317
367
|
const config = getModelConfig(model)
|
|
@@ -359,6 +409,10 @@ cli
|
|
|
359
409
|
inputImages,
|
|
360
410
|
maskImage,
|
|
361
411
|
allowPeople: options.allowPeople || false,
|
|
412
|
+
quality: options.quality,
|
|
413
|
+
resolution: options.resolution,
|
|
414
|
+
outputFormat: options.outputFormat,
|
|
415
|
+
negativePrompt: options.negativePrompt,
|
|
362
416
|
json: options.json || false,
|
|
363
417
|
stdout: options.stdout || false,
|
|
364
418
|
})
|
|
@@ -391,7 +445,7 @@ cli
|
|
|
391
445
|
)
|
|
392
446
|
.option(
|
|
393
447
|
'-m, --model [model]',
|
|
394
|
-
z.
|
|
448
|
+
z.string().describe('Video model ID for generation. If omitted, shows an interactive picker (or uses default in non-TTY mode)'),
|
|
395
449
|
)
|
|
396
450
|
.option(
|
|
397
451
|
'-o, --output [path]',
|
|
@@ -412,11 +466,11 @@ cli
|
|
|
412
466
|
'--resolution [resolution]',
|
|
413
467
|
z
|
|
414
468
|
.string()
|
|
415
|
-
.describe(
|
|
469
|
+
.describe(`Video resolution. ${describeProviderValues('resolution', [VIDEO_CATALOG])}. Google: 720p, 1080p, 4k`),
|
|
416
470
|
)
|
|
417
471
|
.option(
|
|
418
472
|
'--duration [seconds]',
|
|
419
|
-
z.number().describe('Video duration in seconds
|
|
473
|
+
z.number().describe('Video duration in seconds. Veo: 4-8s. xAI Grok: 1-15s. Kling: 5-10s. Wan: 2-15s'),
|
|
420
474
|
)
|
|
421
475
|
.option(
|
|
422
476
|
'--fps [fps]',
|
|
@@ -431,7 +485,31 @@ cli
|
|
|
431
485
|
z
|
|
432
486
|
.string()
|
|
433
487
|
.describe(
|
|
434
|
-
'
|
|
488
|
+
'Input file for video generation. For i2v: a reference image. For edit-video/extend-video: a source video. Accepts local file paths or URLs',
|
|
489
|
+
),
|
|
490
|
+
)
|
|
491
|
+
.option(
|
|
492
|
+
'--mode [mode]',
|
|
493
|
+
z
|
|
494
|
+
.enum(['edit-video', 'extend-video', 'reference-to-video'])
|
|
495
|
+
.describe(
|
|
496
|
+
`Video operation mode. ${describeProviderValues('mode', [VIDEO_CATALOG])}`,
|
|
497
|
+
),
|
|
498
|
+
)
|
|
499
|
+
.option(
|
|
500
|
+
'--reference-images [path]',
|
|
501
|
+
z
|
|
502
|
+
.array(z.string())
|
|
503
|
+
.describe(
|
|
504
|
+
'Reference images for R2V generation, repeatable, 1-7 files or URLs (xAI)',
|
|
505
|
+
),
|
|
506
|
+
)
|
|
507
|
+
.option(
|
|
508
|
+
'--negative-prompt [text]',
|
|
509
|
+
z
|
|
510
|
+
.string()
|
|
511
|
+
.describe(
|
|
512
|
+
'Describe what to avoid in the generated video (Fal models)',
|
|
435
513
|
),
|
|
436
514
|
)
|
|
437
515
|
.option(
|
|
@@ -455,8 +533,7 @@ cli
|
|
|
455
533
|
.action(async (prompt, options) => {
|
|
456
534
|
injectCredentialsToEnv()
|
|
457
535
|
|
|
458
|
-
|
|
459
|
-
const model = options.model ?? DEFAULT_VIDEO_MODEL
|
|
536
|
+
const model = options.model ?? await resolveVideoModel()
|
|
460
537
|
const outputPath = options.output ?? 'egaki-output.mp4'
|
|
461
538
|
const count = options.count ?? 1
|
|
462
539
|
const config = getModelConfig(model)
|
|
@@ -472,9 +549,39 @@ cli
|
|
|
472
549
|
console.error(pc.dim('Mode: Video API (experimental_generateVideo)'))
|
|
473
550
|
}
|
|
474
551
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
552
|
+
// Determine how --input is used based on --mode:
|
|
553
|
+
// - edit-video / extend-video: input is a source video, passed as videoUrl (needs URL)
|
|
554
|
+
// - everything else (i2v, t2v): input is a reference image, passed as bytes
|
|
555
|
+
const isVideoInputMode = options.mode === 'edit-video' || options.mode === 'extend-video'
|
|
556
|
+
|
|
557
|
+
// Validate mode-specific required inputs
|
|
558
|
+
if (isVideoInputMode && !options.input) {
|
|
559
|
+
console.error(pc.red(`--input is required with --mode ${options.mode}. Pass a local video file or URL.`))
|
|
560
|
+
process.exit(1)
|
|
561
|
+
}
|
|
562
|
+
if (options.mode === 'reference-to-video' && (!options.referenceImages || options.referenceImages.length === 0)) {
|
|
563
|
+
console.error(pc.red('--reference-images is required with --mode reference-to-video. Pass 1-7 image files or URLs.'))
|
|
564
|
+
process.exit(1)
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
let inputImage: Uint8Array | undefined
|
|
568
|
+
let videoUrl: string | undefined
|
|
569
|
+
|
|
570
|
+
if (options.input && isVideoInputMode) {
|
|
571
|
+
// Upload local file or use URL directly for video editing/extension
|
|
572
|
+
videoUrl = await resolveToUrl(options.input)
|
|
573
|
+
} else if (options.input) {
|
|
574
|
+
// Read as bytes for image-to-video
|
|
575
|
+
inputImage = await readInputSource(options.input)
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Resolve --reference-images: upload local files to get URLs
|
|
579
|
+
let resolvedReferenceImages = options.referenceImages
|
|
580
|
+
if (resolvedReferenceImages) {
|
|
581
|
+
resolvedReferenceImages = await Promise.all(
|
|
582
|
+
resolvedReferenceImages.map((ref: string) => resolveToUrl(ref)),
|
|
583
|
+
)
|
|
584
|
+
}
|
|
478
585
|
|
|
479
586
|
await generateWithVideoModel({
|
|
480
587
|
prompt,
|
|
@@ -487,6 +594,10 @@ cli
|
|
|
487
594
|
fps: options.fps,
|
|
488
595
|
seed: options.seed,
|
|
489
596
|
inputImage,
|
|
597
|
+
mode: options.mode,
|
|
598
|
+
videoUrl,
|
|
599
|
+
referenceImages: resolvedReferenceImages,
|
|
600
|
+
negativePrompt: options.negativePrompt,
|
|
490
601
|
json: options.json || false,
|
|
491
602
|
stdout: options.stdout || false,
|
|
492
603
|
})
|
|
@@ -515,17 +626,17 @@ cli
|
|
|
515
626
|
)
|
|
516
627
|
.option('--json', 'Output as JSON instead of YAML')
|
|
517
628
|
.action(async (options) => {
|
|
518
|
-
const { CATALOG } = await import('./model-catalog.js')
|
|
519
629
|
const yaml = await import('js-yaml')
|
|
520
630
|
const providerStatuses = Object.fromEntries(
|
|
521
631
|
Object.keys(PROVIDERS).map((provider) => [provider, getKeyStatus(provider)]),
|
|
522
632
|
)
|
|
523
633
|
|
|
524
|
-
let models
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
634
|
+
let models: Array<typeof CATALOG[number] | typeof VIDEO_CATALOG[number]> =
|
|
635
|
+
options.type === 'video'
|
|
636
|
+
? [...VIDEO_CATALOG]
|
|
637
|
+
: options.type === 'image'
|
|
638
|
+
? [...CATALOG]
|
|
639
|
+
: [...CATALOG, ...VIDEO_CATALOG]
|
|
529
640
|
|
|
530
641
|
if (options.provider) {
|
|
531
642
|
models = models.filter((m) => m.provider === options.provider)
|
|
@@ -575,14 +686,300 @@ cli
|
|
|
575
686
|
}
|
|
576
687
|
})
|
|
577
688
|
|
|
689
|
+
/** Upload gateway base URL for temporary file uploads. */
|
|
690
|
+
const UPLOAD_GATEWAY_BASE = 'https://egaki.org'
|
|
691
|
+
/** Max upload size in bytes (must match gateway limit). */
|
|
692
|
+
const MAX_UPLOAD_SIZE = 100 * 1024 * 1024
|
|
693
|
+
|
|
578
694
|
cli.help()
|
|
579
695
|
cli.version(pkg.version)
|
|
580
|
-
cli.parse()
|
|
696
|
+
await cli.parse()
|
|
697
|
+
|
|
698
|
+
// ─── error output helpers ────────────────────────────────────────────────────
|
|
699
|
+
|
|
700
|
+
type PrintableValue = unknown
|
|
701
|
+
type ErrorDetailsInput = Parameters<typeof APICallError.isInstance>[0]
|
|
702
|
+
|
|
703
|
+
async function runProviderCall<T>(operation: string, call: () => Promise<T>): Promise<T> {
|
|
704
|
+
const result = await call().catch((error) => {
|
|
705
|
+
printErrorDetails(error, operation)
|
|
706
|
+
process.exit(1)
|
|
707
|
+
})
|
|
708
|
+
return result
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function printErrorDetails(error: ErrorDetailsInput, operation?: string): void {
|
|
712
|
+
const prefix = operation ? `${operation} failed` : 'Command failed'
|
|
713
|
+
console.error(pc.red(pc.bold(prefix)))
|
|
714
|
+
|
|
715
|
+
if (APICallError.isInstance(error)) {
|
|
716
|
+
printKeyValue('name', error.name)
|
|
717
|
+
printKeyValue('message', error.message)
|
|
718
|
+
printKeyValue('statusCode', error.statusCode)
|
|
719
|
+
printKeyValue('url', error.url)
|
|
720
|
+
printKeyValue('isRetryable', error.isRetryable)
|
|
721
|
+
printJsonBlock('responseBody', parseJsonLike(error.responseBody))
|
|
722
|
+
printJsonBlock('responseHeaders', error.responseHeaders)
|
|
723
|
+
printJsonBlock('requestBodyValues', error.requestBodyValues)
|
|
724
|
+
printJsonBlock('data', error.data)
|
|
725
|
+
printCause(error.cause)
|
|
726
|
+
printStack(error)
|
|
727
|
+
return
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
if (NoImageGeneratedError.isInstance(error) || NoVideoGeneratedError.isInstance(error)) {
|
|
731
|
+
printKeyValue('name', error.name)
|
|
732
|
+
printKeyValue('message', error.message)
|
|
733
|
+
printJsonBlock('responses', error.responses)
|
|
734
|
+
printCause(error.cause)
|
|
735
|
+
printStack(error)
|
|
736
|
+
return
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
if (RetryError.isInstance(error)) {
|
|
740
|
+
printKeyValue('name', error.name)
|
|
741
|
+
printKeyValue('message', error.message)
|
|
742
|
+
printKeyValue('reason', error.reason)
|
|
743
|
+
printJsonBlock('errors', error.errors.map((e) => serializeUnknown(e)))
|
|
744
|
+
printCause(error.cause)
|
|
745
|
+
printStack(error)
|
|
746
|
+
return
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
if (error instanceof Error) {
|
|
750
|
+
printKeyValue('name', error.name)
|
|
751
|
+
printKeyValue('message', error.message)
|
|
752
|
+
printJsonBlock('details', serializeOwnProperties(error))
|
|
753
|
+
printCause(error.cause)
|
|
754
|
+
printStack(error)
|
|
755
|
+
return
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
printJsonBlock('error', serializeUnknown(error))
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function printKeyValue(key: string, value: PrintableValue): void {
|
|
762
|
+
if (value === undefined) return
|
|
763
|
+
console.error(`${pc.dim(`${key}:`)} ${String(value)}`)
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function printJsonBlock(label: string, value: PrintableValue): void {
|
|
767
|
+
if (value === undefined) return
|
|
768
|
+
const serialized = stableStringify(value)
|
|
769
|
+
if (!serialized || serialized === '{}') return
|
|
770
|
+
console.error(pc.dim(`${label}:`))
|
|
771
|
+
console.error(serialized)
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function printCause(cause: Error['cause']): void {
|
|
775
|
+
if (cause === undefined) return
|
|
776
|
+
console.error(pc.dim('cause:'))
|
|
777
|
+
console.error(stableStringify(serializeUnknown(cause)))
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function printStack(error: Error): void {
|
|
781
|
+
if (!error.stack) return
|
|
782
|
+
console.error(pc.dim('stack:'))
|
|
783
|
+
console.error(pc.dim(error.stack))
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function parseJsonLike(value?: string): PrintableValue {
|
|
787
|
+
if (typeof value !== 'string') return value
|
|
788
|
+
try {
|
|
789
|
+
return JSON.parse(value)
|
|
790
|
+
} catch {
|
|
791
|
+
return value
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function serializeUnknown(value: ErrorDetailsInput): PrintableValue {
|
|
796
|
+
if (value instanceof Error) {
|
|
797
|
+
return {
|
|
798
|
+
name: value.name,
|
|
799
|
+
message: value.message,
|
|
800
|
+
...serializeOwnProperties(value),
|
|
801
|
+
...(value.cause === undefined ? {} : { cause: serializeUnknown(value.cause) }),
|
|
802
|
+
...(value.stack ? { stack: value.stack } : {}),
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
return value
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function serializeOwnProperties(error: Error): Record<string, PrintableValue> {
|
|
809
|
+
return Object.fromEntries(
|
|
810
|
+
Object.entries(error).map(([key, value]) => [key, serializeUnknown(value)]),
|
|
811
|
+
)
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function stableStringify(value: PrintableValue): string | undefined {
|
|
815
|
+
const seen = new WeakSet<object>()
|
|
816
|
+
return JSON.stringify(value, (_key, item) => {
|
|
817
|
+
if (typeof item === 'bigint') return item.toString()
|
|
818
|
+
if (item instanceof Date) return item.toISOString()
|
|
819
|
+
if (typeof item !== 'object' || item === null) return item
|
|
820
|
+
if (seen.has(item)) return '[Circular]'
|
|
821
|
+
seen.add(item)
|
|
822
|
+
return item
|
|
823
|
+
}, 2)
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// ─── interactive model pickers ──────────────────────────────────────────────
|
|
827
|
+
|
|
828
|
+
/** Curated image models shown in the interactive picker, ordered by recommendation. */
|
|
829
|
+
const FEATURED_IMAGE_MODELS = [
|
|
830
|
+
'imagen-4.0-generate-001',
|
|
831
|
+
'imagen-4.0-ultra-generate-001',
|
|
832
|
+
'imagen-4.0-fast-generate-001',
|
|
833
|
+
'gemini-2.5-flash-image',
|
|
834
|
+
'gemini-3.1-flash-image-preview',
|
|
835
|
+
'nano-banana-pro-preview',
|
|
836
|
+
'gpt-image-2',
|
|
837
|
+
'gpt-image-1.5',
|
|
838
|
+
'chatgpt-image-latest',
|
|
839
|
+
'flux-kontext-pro',
|
|
840
|
+
'flux-kontext-max',
|
|
841
|
+
'flux-pro-1.1-ultra',
|
|
842
|
+
'recraft-v4',
|
|
843
|
+
'dall-e-3',
|
|
844
|
+
'grok-imagine-image-pro',
|
|
845
|
+
]
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Resolve the image model: show interactive picker in TTY, use default otherwise.
|
|
849
|
+
*/
|
|
850
|
+
async function resolveImageModel(): Promise<string> {
|
|
851
|
+
const isTTY = process.stdin.isTTY && process.stderr.isTTY
|
|
852
|
+
if (!isTTY) return DEFAULT_MODEL
|
|
853
|
+
|
|
854
|
+
const options = FEATURED_IMAGE_MODELS
|
|
855
|
+
.map((id) => CATALOG.find((m) => m.id === id))
|
|
856
|
+
.filter(Boolean)
|
|
857
|
+
.map((m) => {
|
|
858
|
+
const cost = m!.cost.type === 'per-image'
|
|
859
|
+
? pc.dim(`$${m!.cost.perImage}/img`)
|
|
860
|
+
: m!.cost.type === 'per-token'
|
|
861
|
+
? pc.dim(`$${m!.cost.outputPerM}/M out`)
|
|
862
|
+
: ''
|
|
863
|
+
return {
|
|
864
|
+
value: m!.id,
|
|
865
|
+
label: `${m!.name} ${cost}`,
|
|
866
|
+
hint: m!.id,
|
|
867
|
+
}
|
|
868
|
+
})
|
|
869
|
+
|
|
870
|
+
const selected = await select({
|
|
871
|
+
message: 'Select an image model',
|
|
872
|
+
options,
|
|
873
|
+
})
|
|
874
|
+
|
|
875
|
+
if (isCancel(selected)) {
|
|
876
|
+
cancel('Cancelled.')
|
|
877
|
+
process.exit(0)
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
return selected
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* Resolve the video model: show interactive picker in TTY, use default otherwise.
|
|
885
|
+
*/
|
|
886
|
+
async function resolveVideoModel(): Promise<string> {
|
|
887
|
+
const isTTY = process.stdin.isTTY && process.stderr.isTTY
|
|
888
|
+
if (!isTTY) return DEFAULT_VIDEO_MODEL
|
|
889
|
+
|
|
890
|
+
const options = VIDEO_CATALOG.map((m) => {
|
|
891
|
+
const dur = m.features.durationRangeSec
|
|
892
|
+
? pc.dim(`${m.features.durationRangeSec.min}-${m.features.durationRangeSec.max}s`)
|
|
893
|
+
: ''
|
|
894
|
+
return {
|
|
895
|
+
value: m.id,
|
|
896
|
+
label: `${m.name} ${dur}`,
|
|
897
|
+
hint: m.id,
|
|
898
|
+
}
|
|
899
|
+
})
|
|
900
|
+
|
|
901
|
+
const selected = await select({
|
|
902
|
+
message: 'Select a video model',
|
|
903
|
+
options,
|
|
904
|
+
})
|
|
905
|
+
|
|
906
|
+
if (isCancel(selected)) {
|
|
907
|
+
cancel('Cancelled.')
|
|
908
|
+
process.exit(0)
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
return selected
|
|
912
|
+
}
|
|
581
913
|
|
|
582
914
|
function isUrl(input: string): boolean {
|
|
583
915
|
return /^https?:\/\//i.test(input)
|
|
584
916
|
}
|
|
585
917
|
|
|
918
|
+
function mimeTypeFromExtension(ext: string): string {
|
|
919
|
+
const map: Record<string, string> = {
|
|
920
|
+
'.mp4': 'video/mp4',
|
|
921
|
+
'.webm': 'video/webm',
|
|
922
|
+
'.mov': 'video/quicktime',
|
|
923
|
+
'.avi': 'video/x-msvideo',
|
|
924
|
+
'.png': 'image/png',
|
|
925
|
+
'.jpg': 'image/jpeg',
|
|
926
|
+
'.jpeg': 'image/jpeg',
|
|
927
|
+
'.webp': 'image/webp',
|
|
928
|
+
'.gif': 'image/gif',
|
|
929
|
+
}
|
|
930
|
+
return map[ext.toLowerCase()] || 'application/octet-stream'
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* Upload a local file to the egaki gateway and return the public URL.
|
|
935
|
+
* Used when a flag that expects a URL receives a local file path instead.
|
|
936
|
+
*/
|
|
937
|
+
async function uploadFileToGateway(filePath: string): Promise<string> {
|
|
938
|
+
const resolved = path.resolve(filePath)
|
|
939
|
+
if (!fs.existsSync(resolved)) {
|
|
940
|
+
console.error(pc.red(`File not found: ${resolved}`))
|
|
941
|
+
process.exit(1)
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
const stat = fs.statSync(resolved)
|
|
945
|
+
if (stat.size > MAX_UPLOAD_SIZE) {
|
|
946
|
+
console.error(pc.red(`File too large: ${resolved} (${Math.round(stat.size / 1024 / 1024)}MB). Maximum is ${MAX_UPLOAD_SIZE / 1024 / 1024}MB`))
|
|
947
|
+
process.exit(1)
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
const ext = path.extname(resolved)
|
|
951
|
+
const contentType = mimeTypeFromExtension(ext)
|
|
952
|
+
const fileBytes = fs.readFileSync(resolved)
|
|
953
|
+
|
|
954
|
+
console.error(pc.dim(`Uploading ${filePath}...`))
|
|
955
|
+
|
|
956
|
+
const response = await fetch(`${UPLOAD_GATEWAY_BASE}/upload`, {
|
|
957
|
+
method: 'POST',
|
|
958
|
+
headers: { 'Content-Type': contentType },
|
|
959
|
+
body: fileBytes,
|
|
960
|
+
})
|
|
961
|
+
|
|
962
|
+
if (!response.ok) {
|
|
963
|
+
const body = await response.text().catch(() => '')
|
|
964
|
+
console.error(pc.red(`Upload failed: ${response.status} ${response.statusText}`))
|
|
965
|
+
if (body) console.error(pc.dim(body))
|
|
966
|
+
process.exit(1)
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
const result = await response.json() as { url: string }
|
|
970
|
+
console.error(pc.dim(`Uploaded: ${result.url}`))
|
|
971
|
+
return result.url
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* Resolve a value that should be a URL: if it's already a URL, return as-is.
|
|
976
|
+
* If it's a local file path, upload it to the gateway and return the public URL.
|
|
977
|
+
*/
|
|
978
|
+
async function resolveToUrl(input: string): Promise<string> {
|
|
979
|
+
if (isUrl(input)) return input
|
|
980
|
+
return uploadFileToGateway(input)
|
|
981
|
+
}
|
|
982
|
+
|
|
586
983
|
async function readInputSource(input: string): Promise<Uint8Array> {
|
|
587
984
|
if (isUrl(input)) {
|
|
588
985
|
console.error(pc.dim(`Fetching ${input}...`))
|
|
@@ -610,6 +1007,137 @@ async function readInputImages(
|
|
|
610
1007
|
return Promise.all(inputs.map((f) => readInputSource(f)))
|
|
611
1008
|
}
|
|
612
1009
|
|
|
1010
|
+
// ─── type-safe provider option builders ──────────────────────────────────────
|
|
1011
|
+
// Each builder switches on provider name and constructs a typed object using
|
|
1012
|
+
// `satisfies` with the SDK's exported type. This catches typos in keys and
|
|
1013
|
+
// invalid enum values at compile time instead of runtime.
|
|
1014
|
+
|
|
1015
|
+
type ImageOptionInputs = {
|
|
1016
|
+
aspectRatio?: `${number}:${number}`
|
|
1017
|
+
allowPeople: boolean
|
|
1018
|
+
quality?: string
|
|
1019
|
+
resolution?: string
|
|
1020
|
+
outputFormat?: string
|
|
1021
|
+
negativePrompt?: string
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// Return type uses a mapped type that preserves the typed structure while
|
|
1025
|
+
// remaining compatible with the AI SDK's ProviderOptions (Record<string, JSONObject>).
|
|
1026
|
+
// The satisfies checks catch key typos and invalid enum values at compile time,
|
|
1027
|
+
// then we widen to the SDK's expected shape at the boundary.
|
|
1028
|
+
type ProviderOptionsResult = Record<string, Record<string, string | number | boolean | string[] | null | undefined | Record<string, string | number | boolean | null | undefined>>>
|
|
1029
|
+
|
|
1030
|
+
function buildImageProviderOptions(
|
|
1031
|
+
provider: string,
|
|
1032
|
+
opts: ImageOptionInputs,
|
|
1033
|
+
): ProviderOptionsResult {
|
|
1034
|
+
switch (provider) {
|
|
1035
|
+
case 'google': {
|
|
1036
|
+
const googleOpts = {
|
|
1037
|
+
...(opts.allowPeople ? { personGeneration: 'allow_all' as const } : {}),
|
|
1038
|
+
...(opts.aspectRatio ? { aspectRatio: opts.aspectRatio as GoogleImageModelOptions['aspectRatio'] } : {}),
|
|
1039
|
+
} satisfies GoogleImageModelOptions
|
|
1040
|
+
return { google: googleOpts }
|
|
1041
|
+
}
|
|
1042
|
+
case 'vertex': {
|
|
1043
|
+
const vertexOpts = {
|
|
1044
|
+
...(opts.allowPeople ? { personGeneration: 'allow_all' as const } : {}),
|
|
1045
|
+
...(opts.negativePrompt ? { negativePrompt: opts.negativePrompt } : {}),
|
|
1046
|
+
} satisfies GoogleVertexImageModelOptions
|
|
1047
|
+
return { vertex: vertexOpts }
|
|
1048
|
+
}
|
|
1049
|
+
case 'xai': {
|
|
1050
|
+
const xaiOpts = {
|
|
1051
|
+
...(opts.quality ? { quality: opts.quality as XaiImageModelOptions['quality'] } : {}),
|
|
1052
|
+
...(opts.resolution ? { resolution: opts.resolution as XaiImageModelOptions['resolution'] } : {}),
|
|
1053
|
+
...(opts.outputFormat ? { output_format: opts.outputFormat } : {}),
|
|
1054
|
+
} satisfies XaiImageModelOptions
|
|
1055
|
+
return { xai: xaiOpts }
|
|
1056
|
+
}
|
|
1057
|
+
case 'openai': {
|
|
1058
|
+
const openaiOpts = {
|
|
1059
|
+
...(opts.quality ? { quality: opts.quality as OpenAIImageProviderOptions['quality'] } : {}),
|
|
1060
|
+
...(opts.outputFormat ? { outputFormat: opts.outputFormat as OpenAIImageProviderOptions['outputFormat'] } : {}),
|
|
1061
|
+
} satisfies OpenAIImageProviderOptions
|
|
1062
|
+
return { openai: openaiOpts }
|
|
1063
|
+
}
|
|
1064
|
+
case 'fal': {
|
|
1065
|
+
const falOpts = {
|
|
1066
|
+
...(opts.outputFormat ? { outputFormat: opts.outputFormat } : {}),
|
|
1067
|
+
...(opts.negativePrompt ? { negativePrompt: opts.negativePrompt } : {}),
|
|
1068
|
+
} satisfies FalImageModelOptions
|
|
1069
|
+
return { fal: falOpts }
|
|
1070
|
+
}
|
|
1071
|
+
default:
|
|
1072
|
+
// Providers routed through the gateway (bfl, recraft, etc.) pass options
|
|
1073
|
+
// as-is since they don't have local SDK types.
|
|
1074
|
+
return {}
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
type VideoOptionInputs = {
|
|
1079
|
+
resolution?: string
|
|
1080
|
+
mode?: string
|
|
1081
|
+
videoUrl?: string
|
|
1082
|
+
referenceImages?: string[]
|
|
1083
|
+
negativePrompt?: string
|
|
1084
|
+
model: string
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
function buildVideoProviderOptions(
|
|
1088
|
+
provider: string,
|
|
1089
|
+
opts: VideoOptionInputs,
|
|
1090
|
+
): ProviderOptionsResult | undefined {
|
|
1091
|
+
switch (provider) {
|
|
1092
|
+
case 'xai': {
|
|
1093
|
+
// xAI video modes are a discriminated union; build the correct variant.
|
|
1094
|
+
const base = {
|
|
1095
|
+
...(opts.resolution ? { resolution: opts.resolution as '480p' | '720p' } : {}),
|
|
1096
|
+
}
|
|
1097
|
+
if (opts.mode === 'edit-video' && opts.videoUrl) {
|
|
1098
|
+
const xaiOpts = { ...base, mode: 'edit-video' as const, videoUrl: opts.videoUrl } satisfies XaiVideoModelOptions
|
|
1099
|
+
return { xai: xaiOpts }
|
|
1100
|
+
}
|
|
1101
|
+
if (opts.mode === 'extend-video' && opts.videoUrl) {
|
|
1102
|
+
const xaiOpts = { ...base, mode: 'extend-video' as const, videoUrl: opts.videoUrl } satisfies XaiVideoModelOptions
|
|
1103
|
+
return { xai: xaiOpts }
|
|
1104
|
+
}
|
|
1105
|
+
if (opts.mode === 'reference-to-video' && opts.referenceImages) {
|
|
1106
|
+
const xaiOpts = { ...base, mode: 'reference-to-video' as const, referenceImageUrls: opts.referenceImages } satisfies XaiVideoModelOptions
|
|
1107
|
+
return { xai: xaiOpts }
|
|
1108
|
+
}
|
|
1109
|
+
// Default: text-to-video or image-to-video (no mode)
|
|
1110
|
+
if (Object.keys(base).length === 0) return undefined
|
|
1111
|
+
const xaiOpts = { ...base } satisfies XaiVideoModelOptions
|
|
1112
|
+
return { xai: xaiOpts }
|
|
1113
|
+
}
|
|
1114
|
+
case 'fal': {
|
|
1115
|
+
const falOpts = {
|
|
1116
|
+
...(opts.resolution ? { resolution: opts.resolution } : {}),
|
|
1117
|
+
...(opts.negativePrompt ? { negativePrompt: opts.negativePrompt } : {}),
|
|
1118
|
+
} satisfies FalVideoModelOptions
|
|
1119
|
+
if (Object.keys(falOpts).length === 0) return undefined
|
|
1120
|
+
return { fal: falOpts }
|
|
1121
|
+
}
|
|
1122
|
+
case 'google': {
|
|
1123
|
+
const googleOpts = {
|
|
1124
|
+
...(opts.negativePrompt ? { negativePrompt: opts.negativePrompt } : {}),
|
|
1125
|
+
} satisfies GoogleVideoModelOptions
|
|
1126
|
+
if (Object.keys(googleOpts).length === 0) return undefined
|
|
1127
|
+
return { google: googleOpts }
|
|
1128
|
+
}
|
|
1129
|
+
case 'vertex': {
|
|
1130
|
+
const vertexOpts = {
|
|
1131
|
+
...(opts.negativePrompt ? { negativePrompt: opts.negativePrompt } : {}),
|
|
1132
|
+
} satisfies GoogleVertexVideoModelOptions
|
|
1133
|
+
if (Object.keys(vertexOpts).length === 0) return undefined
|
|
1134
|
+
return { vertex: vertexOpts }
|
|
1135
|
+
}
|
|
1136
|
+
default:
|
|
1137
|
+
return undefined
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
613
1141
|
// Generate using the dedicated generateImage API (Imagen models)
|
|
614
1142
|
async function generateWithImageModel({
|
|
615
1143
|
prompt,
|
|
@@ -621,6 +1149,10 @@ async function generateWithImageModel({
|
|
|
621
1149
|
inputImages,
|
|
622
1150
|
maskImage,
|
|
623
1151
|
allowPeople,
|
|
1152
|
+
quality,
|
|
1153
|
+
resolution,
|
|
1154
|
+
outputFormat,
|
|
1155
|
+
negativePrompt,
|
|
624
1156
|
json,
|
|
625
1157
|
stdout,
|
|
626
1158
|
}: {
|
|
@@ -633,6 +1165,10 @@ async function generateWithImageModel({
|
|
|
633
1165
|
inputImages: Uint8Array[]
|
|
634
1166
|
maskImage?: Uint8Array
|
|
635
1167
|
allowPeople: boolean
|
|
1168
|
+
quality?: string
|
|
1169
|
+
resolution?: string
|
|
1170
|
+
outputFormat?: string
|
|
1171
|
+
negativePrompt?: string
|
|
636
1172
|
json: boolean
|
|
637
1173
|
stdout: boolean
|
|
638
1174
|
}) {
|
|
@@ -644,65 +1180,53 @@ async function generateWithImageModel({
|
|
|
644
1180
|
const config = getModelConfig(model)
|
|
645
1181
|
const imageModel = await createImageModel(model)
|
|
646
1182
|
|
|
647
|
-
//
|
|
648
|
-
|
|
1183
|
+
// Build type-safe provider options using the SDK types with `satisfies`.
|
|
1184
|
+
// Each provider has its own shape; we switch on provider to get compile-time safety.
|
|
1185
|
+
const providerOptions = buildImageProviderOptions(config.provider, {
|
|
1186
|
+
aspectRatio,
|
|
1187
|
+
allowPeople,
|
|
1188
|
+
quality,
|
|
1189
|
+
resolution,
|
|
1190
|
+
outputFormat,
|
|
1191
|
+
negativePrompt,
|
|
1192
|
+
})
|
|
649
1193
|
|
|
650
1194
|
if (!stdout) {
|
|
651
1195
|
console.error(pc.cyan('Generating...'))
|
|
652
1196
|
}
|
|
653
1197
|
|
|
654
|
-
const result = await aiGenerateImage({
|
|
1198
|
+
const result = await runProviderCall('Image generation', () => aiGenerateImage({
|
|
655
1199
|
model: imageModel,
|
|
656
1200
|
prompt: imagePrompt,
|
|
657
1201
|
n: count,
|
|
658
1202
|
...(aspectRatio ? { aspectRatio } : {}),
|
|
659
1203
|
...(seed !== undefined ? { seed } : {}),
|
|
660
|
-
providerOptions
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
})
|
|
1204
|
+
providerOptions,
|
|
1205
|
+
}))
|
|
1206
|
+
|
|
1207
|
+
const files = result.images.map((img) => ({
|
|
1208
|
+
uint8Array: img.uint8Array,
|
|
1209
|
+
mediaType: img.mediaType,
|
|
1210
|
+
}))
|
|
667
1211
|
|
|
668
1212
|
if (stdout) {
|
|
669
|
-
|
|
670
|
-
const image = result.images[0]
|
|
671
|
-
if (image) {
|
|
672
|
-
process.stdout.write(Buffer.from(image.uint8Array))
|
|
673
|
-
}
|
|
1213
|
+
writeFirstToStdout(files)
|
|
674
1214
|
return
|
|
675
1215
|
}
|
|
676
1216
|
|
|
677
|
-
const savedFiles
|
|
678
|
-
for (let i = 0; i < result.images.length; i++) {
|
|
679
|
-
const image = result.images[i]!
|
|
680
|
-
const ext = extensionFromMediaType(image.mediaType)
|
|
681
|
-
const filePath =
|
|
682
|
-
result.images.length === 1
|
|
683
|
-
? ensureExtension(outputPath, ext)
|
|
684
|
-
: insertIndex(outputPath, i, ext)
|
|
685
|
-
|
|
686
|
-
fs.writeFileSync(filePath, image.uint8Array)
|
|
687
|
-
console.error(pc.green(`Saved: ${filePath}`))
|
|
688
|
-
savedFiles.push(filePath)
|
|
689
|
-
}
|
|
690
|
-
|
|
1217
|
+
const savedFiles = saveGeneratedFiles(files, outputPath)
|
|
691
1218
|
const cost = calculateCost(config.cost, result.usage, result.images.length)
|
|
692
|
-
|
|
693
|
-
console.error(pc.dim(`Cost: ${formatCost(cost)}`))
|
|
694
|
-
}
|
|
1219
|
+
printCost(cost)
|
|
695
1220
|
|
|
696
1221
|
if (json) {
|
|
697
|
-
|
|
1222
|
+
console.log(JSON.stringify({
|
|
698
1223
|
model,
|
|
699
1224
|
files: savedFiles,
|
|
700
1225
|
count: result.images.length,
|
|
701
1226
|
cost,
|
|
702
1227
|
usage: result.usage,
|
|
703
1228
|
warnings: result.warnings,
|
|
704
|
-
}
|
|
705
|
-
console.log(JSON.stringify(output, null, 2))
|
|
1229
|
+
}, null, 2))
|
|
706
1230
|
}
|
|
707
1231
|
}
|
|
708
1232
|
|
|
@@ -728,7 +1252,6 @@ async function generateWithTextModel({
|
|
|
728
1252
|
}) {
|
|
729
1253
|
const textModel = await createTextModel(model)
|
|
730
1254
|
const config = getModelConfig(model)
|
|
731
|
-
const providerOptionsKey = config.provider === 'vertex' ? 'vertex' : 'google'
|
|
732
1255
|
|
|
733
1256
|
if (!stdout) {
|
|
734
1257
|
console.error(pc.cyan('Generating...'))
|
|
@@ -750,27 +1273,32 @@ async function generateWithTextModel({
|
|
|
750
1273
|
]
|
|
751
1274
|
: undefined
|
|
752
1275
|
|
|
753
|
-
|
|
1276
|
+
// Type-safe Google/Vertex language model options with image generation config.
|
|
1277
|
+
// aspectRatio is cast to the Google SDK's enum type since CLI input is a free string.
|
|
1278
|
+
type GoogleAspectRatio = NonNullable<NonNullable<GoogleLanguageModelOptions['imageConfig']>['aspectRatio']>
|
|
1279
|
+
const googleOpts = {
|
|
1280
|
+
responseModalities: ['TEXT', 'IMAGE'],
|
|
1281
|
+
...(imageSize || aspectRatio
|
|
1282
|
+
? {
|
|
1283
|
+
imageConfig: {
|
|
1284
|
+
...(imageSize ? { imageSize } : {}),
|
|
1285
|
+
...(aspectRatio ? { aspectRatio: aspectRatio as GoogleAspectRatio } : {}),
|
|
1286
|
+
},
|
|
1287
|
+
}
|
|
1288
|
+
: {}),
|
|
1289
|
+
} satisfies GoogleLanguageModelOptions
|
|
1290
|
+
|
|
1291
|
+
const providerOptionsKey = config.provider === 'vertex' ? 'vertex' : 'google'
|
|
1292
|
+
|
|
1293
|
+
const result = await runProviderCall('Text/image generation', () => generateText({
|
|
754
1294
|
model: textModel,
|
|
755
1295
|
...(messages ? { messages } : { prompt }),
|
|
756
1296
|
providerOptions: {
|
|
757
|
-
[providerOptionsKey]:
|
|
758
|
-
responseModalities: ['TEXT', 'IMAGE'],
|
|
759
|
-
...(imageSize || aspectRatio
|
|
760
|
-
? {
|
|
761
|
-
imageConfig: {
|
|
762
|
-
...(imageSize ? { imageSize } : {}),
|
|
763
|
-
...(aspectRatio ? { aspectRatio } : {}),
|
|
764
|
-
},
|
|
765
|
-
}
|
|
766
|
-
: {}),
|
|
767
|
-
},
|
|
1297
|
+
[providerOptionsKey]: googleOpts,
|
|
768
1298
|
},
|
|
769
|
-
})
|
|
1299
|
+
}))
|
|
770
1300
|
|
|
771
|
-
const imageFiles = result.files.filter((f) =>
|
|
772
|
-
return f.mediaType.startsWith('image/')
|
|
773
|
-
})
|
|
1301
|
+
const imageFiles = result.files.filter((f) => f.mediaType.startsWith('image/'))
|
|
774
1302
|
|
|
775
1303
|
if (imageFiles.length === 0) {
|
|
776
1304
|
console.error(pc.red('No images generated.'))
|
|
@@ -778,46 +1306,27 @@ async function generateWithTextModel({
|
|
|
778
1306
|
}
|
|
779
1307
|
|
|
780
1308
|
if (stdout) {
|
|
781
|
-
|
|
782
|
-
if (file) {
|
|
783
|
-
process.stdout.write(Buffer.from(file.uint8Array))
|
|
784
|
-
}
|
|
1309
|
+
writeFirstToStdout(imageFiles)
|
|
785
1310
|
return
|
|
786
1311
|
}
|
|
787
1312
|
|
|
788
|
-
const savedFiles
|
|
789
|
-
for (let i = 0; i < imageFiles.length; i++) {
|
|
790
|
-
const file = imageFiles[i]!
|
|
791
|
-
const ext = extensionFromMediaType(file.mediaType)
|
|
792
|
-
const filePath =
|
|
793
|
-
imageFiles.length === 1
|
|
794
|
-
? ensureExtension(outputPath, ext)
|
|
795
|
-
: insertIndex(outputPath, i, ext)
|
|
796
|
-
|
|
797
|
-
fs.writeFileSync(filePath, file.uint8Array)
|
|
798
|
-
console.error(pc.green(`Saved: ${filePath}`))
|
|
799
|
-
savedFiles.push(filePath)
|
|
800
|
-
}
|
|
801
|
-
|
|
1313
|
+
const savedFiles = saveGeneratedFiles(imageFiles, outputPath)
|
|
802
1314
|
const cost = calculateCost(config.cost, result.usage, imageFiles.length)
|
|
803
|
-
|
|
804
|
-
console.error(pc.dim(`Cost: ${formatCost(cost)}`))
|
|
805
|
-
}
|
|
1315
|
+
printCost(cost)
|
|
806
1316
|
|
|
807
1317
|
if (result.text && !json) {
|
|
808
1318
|
console.error(pc.dim(result.text))
|
|
809
1319
|
}
|
|
810
1320
|
|
|
811
1321
|
if (json) {
|
|
812
|
-
|
|
1322
|
+
console.log(JSON.stringify({
|
|
813
1323
|
model,
|
|
814
1324
|
files: savedFiles,
|
|
815
1325
|
count: imageFiles.length,
|
|
816
1326
|
text: result.text || null,
|
|
817
1327
|
cost,
|
|
818
1328
|
usage: result.usage,
|
|
819
|
-
}
|
|
820
|
-
console.log(JSON.stringify(output, null, 2))
|
|
1329
|
+
}, null, 2))
|
|
821
1330
|
}
|
|
822
1331
|
}
|
|
823
1332
|
|
|
@@ -1026,6 +1535,10 @@ async function generateWithVideoModel({
|
|
|
1026
1535
|
fps,
|
|
1027
1536
|
seed,
|
|
1028
1537
|
inputImage,
|
|
1538
|
+
mode,
|
|
1539
|
+
videoUrl,
|
|
1540
|
+
referenceImages,
|
|
1541
|
+
negativePrompt,
|
|
1029
1542
|
json,
|
|
1030
1543
|
stdout,
|
|
1031
1544
|
}: {
|
|
@@ -1039,16 +1552,38 @@ async function generateWithVideoModel({
|
|
|
1039
1552
|
fps?: number
|
|
1040
1553
|
seed?: number
|
|
1041
1554
|
inputImage?: Uint8Array
|
|
1555
|
+
mode?: string
|
|
1556
|
+
videoUrl?: string
|
|
1557
|
+
referenceImages?: string[]
|
|
1558
|
+
negativePrompt?: string
|
|
1042
1559
|
json: boolean
|
|
1043
1560
|
stdout: boolean
|
|
1044
1561
|
}) {
|
|
1045
1562
|
const videoModel = await createVideoModel(model)
|
|
1563
|
+
const config = getModelConfig(model)
|
|
1564
|
+
|
|
1565
|
+
// Validate that the model supports video-url input before building options.
|
|
1566
|
+
// Only models with a 'video-url' providerOption (currently xAI) accept edit/extend mode.
|
|
1567
|
+
if (videoUrl && !config.providerOptions?.some((opt) => opt.flag === 'video-url')) {
|
|
1568
|
+
console.error(pc.red(`Model ${model} does not support video URL input for editing/extension`))
|
|
1569
|
+
process.exit(1)
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
// Build type-safe provider options for video generation.
|
|
1573
|
+
const providerOptions = buildVideoProviderOptions(config.provider, {
|
|
1574
|
+
resolution,
|
|
1575
|
+
mode,
|
|
1576
|
+
videoUrl,
|
|
1577
|
+
referenceImages,
|
|
1578
|
+
negativePrompt,
|
|
1579
|
+
model,
|
|
1580
|
+
})
|
|
1046
1581
|
|
|
1047
1582
|
if (!stdout) {
|
|
1048
1583
|
console.error(pc.cyan('Generating...'))
|
|
1049
1584
|
}
|
|
1050
1585
|
|
|
1051
|
-
const result = await aiGenerateVideo({
|
|
1586
|
+
const result = await runProviderCall('Video generation', () => aiGenerateVideo({
|
|
1052
1587
|
model: videoModel,
|
|
1053
1588
|
prompt: inputImage
|
|
1054
1589
|
? { image: inputImage, text: prompt }
|
|
@@ -1059,50 +1594,88 @@ async function generateWithVideoModel({
|
|
|
1059
1594
|
...(duration != null ? { duration } : {}),
|
|
1060
1595
|
...(fps != null ? { fps } : {}),
|
|
1061
1596
|
...(seed != null ? { seed } : {}),
|
|
1062
|
-
|
|
1597
|
+
...(providerOptions ? { providerOptions } : {}),
|
|
1598
|
+
}))
|
|
1599
|
+
|
|
1600
|
+
const files = result.videos.map((v: { uint8Array: Uint8Array; mediaType: string }) => ({
|
|
1601
|
+
uint8Array: v.uint8Array,
|
|
1602
|
+
mediaType: v.mediaType,
|
|
1603
|
+
}))
|
|
1063
1604
|
|
|
1064
1605
|
if (stdout) {
|
|
1065
|
-
|
|
1066
|
-
if (video) {
|
|
1067
|
-
process.stdout.write(Buffer.from(video.uint8Array))
|
|
1068
|
-
}
|
|
1606
|
+
writeFirstToStdout(files)
|
|
1069
1607
|
return
|
|
1070
1608
|
}
|
|
1071
1609
|
|
|
1072
|
-
const savedFiles
|
|
1073
|
-
for (let i = 0; i < result.videos.length; i++) {
|
|
1074
|
-
const video = result.videos[i]!
|
|
1075
|
-
const ext = extensionFromMediaType(video.mediaType)
|
|
1076
|
-
const filePath =
|
|
1077
|
-
result.videos.length === 1
|
|
1078
|
-
? ensureExtension(outputPath, ext)
|
|
1079
|
-
: insertIndex(outputPath, i, ext)
|
|
1080
|
-
|
|
1081
|
-
fs.writeFileSync(filePath, video.uint8Array)
|
|
1082
|
-
console.error(pc.green(`Saved: ${filePath}`))
|
|
1083
|
-
savedFiles.push(filePath)
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
const config = getModelConfig(model)
|
|
1610
|
+
const savedFiles = saveGeneratedFiles(files, outputPath)
|
|
1087
1611
|
const cost = calculateCost(config.cost, {
|
|
1088
1612
|
videosGenerated: result.videos.length,
|
|
1089
1613
|
durationSeconds: duration,
|
|
1090
1614
|
resolution,
|
|
1091
1615
|
}, result.videos.length)
|
|
1092
|
-
|
|
1093
|
-
console.error(pc.dim(`Cost: ${formatCost(cost)}`))
|
|
1094
|
-
}
|
|
1616
|
+
printCost(cost)
|
|
1095
1617
|
|
|
1096
1618
|
if (json) {
|
|
1097
|
-
|
|
1619
|
+
console.log(JSON.stringify({
|
|
1098
1620
|
model,
|
|
1099
1621
|
files: savedFiles,
|
|
1100
1622
|
count: result.videos.length,
|
|
1101
1623
|
cost,
|
|
1102
1624
|
warnings: result.warnings,
|
|
1103
1625
|
responses: result.responses,
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1626
|
+
}, null, 2))
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
// ─── shared output helpers ───────────────────────────────────────────────────
|
|
1631
|
+
// Common logic for saving generated files, calculating cost, and printing JSON
|
|
1632
|
+
// metadata. Used by all generation paths to avoid repeating boilerplate.
|
|
1633
|
+
|
|
1634
|
+
type GeneratedFile = {
|
|
1635
|
+
uint8Array: Uint8Array
|
|
1636
|
+
mediaType: string
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
/**
|
|
1640
|
+
* Save generated files to disk. Returns the list of saved file paths.
|
|
1641
|
+
* Handles single vs multi-file naming with index suffixes.
|
|
1642
|
+
*/
|
|
1643
|
+
function saveGeneratedFiles(
|
|
1644
|
+
files: GeneratedFile[],
|
|
1645
|
+
outputPath: string,
|
|
1646
|
+
): string[] {
|
|
1647
|
+
const savedFiles: string[] = []
|
|
1648
|
+
for (let i = 0; i < files.length; i++) {
|
|
1649
|
+
const file = files[i]!
|
|
1650
|
+
const ext = extensionFromMediaType(file.mediaType)
|
|
1651
|
+
const filePath =
|
|
1652
|
+
files.length === 1
|
|
1653
|
+
? ensureExtension(outputPath, ext)
|
|
1654
|
+
: insertIndex(outputPath, i, ext)
|
|
1655
|
+
|
|
1656
|
+
fs.writeFileSync(filePath, file.uint8Array)
|
|
1657
|
+
console.error(pc.green(`Saved: ${filePath}`))
|
|
1658
|
+
savedFiles.push(filePath)
|
|
1659
|
+
}
|
|
1660
|
+
return savedFiles
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
/**
|
|
1664
|
+
* Print estimated cost to stderr if calculable.
|
|
1665
|
+
*/
|
|
1666
|
+
function printCost(cost: number | null): void {
|
|
1667
|
+
if (cost != null) {
|
|
1668
|
+
console.error(pc.dim(`Cost: ${formatCost(cost)}`))
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
/**
|
|
1673
|
+
* Write the first file's raw bytes to stdout (for piping to other tools).
|
|
1674
|
+
*/
|
|
1675
|
+
function writeFirstToStdout(files: GeneratedFile[]): void {
|
|
1676
|
+
const file = files[0]
|
|
1677
|
+
if (file) {
|
|
1678
|
+
process.stdout.write(Buffer.from(file.uint8Array))
|
|
1106
1679
|
}
|
|
1107
1680
|
}
|
|
1108
1681
|
|