egaki 0.5.0 → 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 +57 -0
- package/README.md +107 -3
- package/dist/cli.js +459 -124
- 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/src/cli.ts +621 -133
- 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,18 @@ 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'
|
|
18
26
|
import { select, isCancel, cancel } from '@clack/prompts'
|
|
19
27
|
import fs from 'node:fs'
|
|
20
28
|
import path from 'node:path'
|
|
@@ -39,8 +47,11 @@ import {
|
|
|
39
47
|
createVideoModel,
|
|
40
48
|
shouldUseResponsesApi,
|
|
41
49
|
} from './models.js'
|
|
42
|
-
import {
|
|
43
|
-
|
|
50
|
+
import {
|
|
51
|
+
CATALOG,
|
|
52
|
+
VIDEO_CATALOG,
|
|
53
|
+
describeProviderValues,
|
|
54
|
+
} from './model-catalog.js'
|
|
44
55
|
import {
|
|
45
56
|
loginInteractive,
|
|
46
57
|
loginNonInteractive,
|
|
@@ -56,23 +67,31 @@ import {
|
|
|
56
67
|
} from './subscription.js'
|
|
57
68
|
import { getValidChatGptAuth } from './chatgpt-auth.js'
|
|
58
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
|
+
|
|
59
84
|
const cli = goke('egaki')
|
|
60
85
|
|
|
61
86
|
process.title = 'egaki'
|
|
62
87
|
|
|
63
|
-
// Print clean error output for unhandled rejections (e.g. AI SDK APICallError).
|
|
64
|
-
// The AI SDK errors include request bodies, response headers, and other noisy
|
|
65
|
-
// properties that get dumped by Node's default handler. We strip those and just
|
|
66
|
-
// print the message + stack trace.
|
|
67
88
|
process.on('uncaughtException', (err) => {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
}
|
|
89
|
+
printErrorDetails(err)
|
|
90
|
+
process.exit(1)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
process.on('unhandledRejection', (reason) => {
|
|
94
|
+
printErrorDetails(reason)
|
|
76
95
|
process.exit(1)
|
|
77
96
|
})
|
|
78
97
|
|
|
@@ -129,8 +148,8 @@ cli
|
|
|
129
148
|
|
|
130
149
|
// Non-interactive: --provider + --key or stdin
|
|
131
150
|
if (options.provider) {
|
|
132
|
-
//
|
|
133
|
-
if (options.provider === 'chatgpt') {
|
|
151
|
+
// OAuth providers use browser flow — skip key reading
|
|
152
|
+
if (options.provider === 'chatgpt' || options.provider === 'xai-oauth') {
|
|
134
153
|
await loginNonInteractive({ provider: options.provider, key: '' })
|
|
135
154
|
return
|
|
136
155
|
}
|
|
@@ -227,7 +246,7 @@ cli
|
|
|
227
246
|
)
|
|
228
247
|
.option(
|
|
229
248
|
'-m, --model [model]',
|
|
230
|
-
z.
|
|
249
|
+
z.string().describe('Model ID for generation. If omitted, shows an interactive picker (or uses default in non-TTY mode)'),
|
|
231
250
|
)
|
|
232
251
|
.option(
|
|
233
252
|
'-o, --output [path]',
|
|
@@ -276,6 +295,38 @@ cli
|
|
|
276
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',
|
|
277
296
|
),
|
|
278
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
|
+
)
|
|
279
330
|
.option(
|
|
280
331
|
'--allow-people',
|
|
281
332
|
'Allow generating images of people (Imagen blocks people by default)',
|
|
@@ -358,6 +409,10 @@ cli
|
|
|
358
409
|
inputImages,
|
|
359
410
|
maskImage,
|
|
360
411
|
allowPeople: options.allowPeople || false,
|
|
412
|
+
quality: options.quality,
|
|
413
|
+
resolution: options.resolution,
|
|
414
|
+
outputFormat: options.outputFormat,
|
|
415
|
+
negativePrompt: options.negativePrompt,
|
|
361
416
|
json: options.json || false,
|
|
362
417
|
stdout: options.stdout || false,
|
|
363
418
|
})
|
|
@@ -390,7 +445,7 @@ cli
|
|
|
390
445
|
)
|
|
391
446
|
.option(
|
|
392
447
|
'-m, --model [model]',
|
|
393
|
-
z.
|
|
448
|
+
z.string().describe('Video model ID for generation. If omitted, shows an interactive picker (or uses default in non-TTY mode)'),
|
|
394
449
|
)
|
|
395
450
|
.option(
|
|
396
451
|
'-o, --output [path]',
|
|
@@ -411,11 +466,11 @@ cli
|
|
|
411
466
|
'--resolution [resolution]',
|
|
412
467
|
z
|
|
413
468
|
.string()
|
|
414
|
-
.describe(
|
|
469
|
+
.describe(`Video resolution. ${describeProviderValues('resolution', [VIDEO_CATALOG])}. Google: 720p, 1080p, 4k`),
|
|
415
470
|
)
|
|
416
471
|
.option(
|
|
417
472
|
'--duration [seconds]',
|
|
418
|
-
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'),
|
|
419
474
|
)
|
|
420
475
|
.option(
|
|
421
476
|
'--fps [fps]',
|
|
@@ -430,7 +485,31 @@ cli
|
|
|
430
485
|
z
|
|
431
486
|
.string()
|
|
432
487
|
.describe(
|
|
433
|
-
'
|
|
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)',
|
|
434
513
|
),
|
|
435
514
|
)
|
|
436
515
|
.option(
|
|
@@ -470,9 +549,39 @@ cli
|
|
|
470
549
|
console.error(pc.dim('Mode: Video API (experimental_generateVideo)'))
|
|
471
550
|
}
|
|
472
551
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
+
}
|
|
476
585
|
|
|
477
586
|
await generateWithVideoModel({
|
|
478
587
|
prompt,
|
|
@@ -485,6 +594,10 @@ cli
|
|
|
485
594
|
fps: options.fps,
|
|
486
595
|
seed: options.seed,
|
|
487
596
|
inputImage,
|
|
597
|
+
mode: options.mode,
|
|
598
|
+
videoUrl,
|
|
599
|
+
referenceImages: resolvedReferenceImages,
|
|
600
|
+
negativePrompt: options.negativePrompt,
|
|
488
601
|
json: options.json || false,
|
|
489
602
|
stdout: options.stdout || false,
|
|
490
603
|
})
|
|
@@ -518,11 +631,12 @@ cli
|
|
|
518
631
|
Object.keys(PROVIDERS).map((provider) => [provider, getKeyStatus(provider)]),
|
|
519
632
|
)
|
|
520
633
|
|
|
521
|
-
let models
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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]
|
|
526
640
|
|
|
527
641
|
if (options.provider) {
|
|
528
642
|
models = models.filter((m) => m.provider === options.provider)
|
|
@@ -572,9 +686,142 @@ cli
|
|
|
572
686
|
}
|
|
573
687
|
})
|
|
574
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
|
+
|
|
575
694
|
cli.help()
|
|
576
695
|
cli.version(pkg.version)
|
|
577
|
-
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
|
+
}
|
|
578
825
|
|
|
579
826
|
// ─── interactive model pickers ──────────────────────────────────────────────
|
|
580
827
|
|
|
@@ -668,6 +915,71 @@ function isUrl(input: string): boolean {
|
|
|
668
915
|
return /^https?:\/\//i.test(input)
|
|
669
916
|
}
|
|
670
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
|
+
|
|
671
983
|
async function readInputSource(input: string): Promise<Uint8Array> {
|
|
672
984
|
if (isUrl(input)) {
|
|
673
985
|
console.error(pc.dim(`Fetching ${input}...`))
|
|
@@ -695,6 +1007,137 @@ async function readInputImages(
|
|
|
695
1007
|
return Promise.all(inputs.map((f) => readInputSource(f)))
|
|
696
1008
|
}
|
|
697
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
|
+
|
|
698
1141
|
// Generate using the dedicated generateImage API (Imagen models)
|
|
699
1142
|
async function generateWithImageModel({
|
|
700
1143
|
prompt,
|
|
@@ -706,6 +1149,10 @@ async function generateWithImageModel({
|
|
|
706
1149
|
inputImages,
|
|
707
1150
|
maskImage,
|
|
708
1151
|
allowPeople,
|
|
1152
|
+
quality,
|
|
1153
|
+
resolution,
|
|
1154
|
+
outputFormat,
|
|
1155
|
+
negativePrompt,
|
|
709
1156
|
json,
|
|
710
1157
|
stdout,
|
|
711
1158
|
}: {
|
|
@@ -718,6 +1165,10 @@ async function generateWithImageModel({
|
|
|
718
1165
|
inputImages: Uint8Array[]
|
|
719
1166
|
maskImage?: Uint8Array
|
|
720
1167
|
allowPeople: boolean
|
|
1168
|
+
quality?: string
|
|
1169
|
+
resolution?: string
|
|
1170
|
+
outputFormat?: string
|
|
1171
|
+
negativePrompt?: string
|
|
721
1172
|
json: boolean
|
|
722
1173
|
stdout: boolean
|
|
723
1174
|
}) {
|
|
@@ -729,65 +1180,53 @@ async function generateWithImageModel({
|
|
|
729
1180
|
const config = getModelConfig(model)
|
|
730
1181
|
const imageModel = await createImageModel(model)
|
|
731
1182
|
|
|
732
|
-
//
|
|
733
|
-
|
|
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
|
+
})
|
|
734
1193
|
|
|
735
1194
|
if (!stdout) {
|
|
736
1195
|
console.error(pc.cyan('Generating...'))
|
|
737
1196
|
}
|
|
738
1197
|
|
|
739
|
-
const result = await aiGenerateImage({
|
|
1198
|
+
const result = await runProviderCall('Image generation', () => aiGenerateImage({
|
|
740
1199
|
model: imageModel,
|
|
741
1200
|
prompt: imagePrompt,
|
|
742
1201
|
n: count,
|
|
743
1202
|
...(aspectRatio ? { aspectRatio } : {}),
|
|
744
1203
|
...(seed !== undefined ? { seed } : {}),
|
|
745
|
-
providerOptions
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
})
|
|
1204
|
+
providerOptions,
|
|
1205
|
+
}))
|
|
1206
|
+
|
|
1207
|
+
const files = result.images.map((img) => ({
|
|
1208
|
+
uint8Array: img.uint8Array,
|
|
1209
|
+
mediaType: img.mediaType,
|
|
1210
|
+
}))
|
|
752
1211
|
|
|
753
1212
|
if (stdout) {
|
|
754
|
-
|
|
755
|
-
const image = result.images[0]
|
|
756
|
-
if (image) {
|
|
757
|
-
process.stdout.write(Buffer.from(image.uint8Array))
|
|
758
|
-
}
|
|
1213
|
+
writeFirstToStdout(files)
|
|
759
1214
|
return
|
|
760
1215
|
}
|
|
761
1216
|
|
|
762
|
-
const savedFiles
|
|
763
|
-
for (let i = 0; i < result.images.length; i++) {
|
|
764
|
-
const image = result.images[i]!
|
|
765
|
-
const ext = extensionFromMediaType(image.mediaType)
|
|
766
|
-
const filePath =
|
|
767
|
-
result.images.length === 1
|
|
768
|
-
? ensureExtension(outputPath, ext)
|
|
769
|
-
: insertIndex(outputPath, i, ext)
|
|
770
|
-
|
|
771
|
-
fs.writeFileSync(filePath, image.uint8Array)
|
|
772
|
-
console.error(pc.green(`Saved: ${filePath}`))
|
|
773
|
-
savedFiles.push(filePath)
|
|
774
|
-
}
|
|
775
|
-
|
|
1217
|
+
const savedFiles = saveGeneratedFiles(files, outputPath)
|
|
776
1218
|
const cost = calculateCost(config.cost, result.usage, result.images.length)
|
|
777
|
-
|
|
778
|
-
console.error(pc.dim(`Cost: ${formatCost(cost)}`))
|
|
779
|
-
}
|
|
1219
|
+
printCost(cost)
|
|
780
1220
|
|
|
781
1221
|
if (json) {
|
|
782
|
-
|
|
1222
|
+
console.log(JSON.stringify({
|
|
783
1223
|
model,
|
|
784
1224
|
files: savedFiles,
|
|
785
1225
|
count: result.images.length,
|
|
786
1226
|
cost,
|
|
787
1227
|
usage: result.usage,
|
|
788
1228
|
warnings: result.warnings,
|
|
789
|
-
}
|
|
790
|
-
console.log(JSON.stringify(output, null, 2))
|
|
1229
|
+
}, null, 2))
|
|
791
1230
|
}
|
|
792
1231
|
}
|
|
793
1232
|
|
|
@@ -813,7 +1252,6 @@ async function generateWithTextModel({
|
|
|
813
1252
|
}) {
|
|
814
1253
|
const textModel = await createTextModel(model)
|
|
815
1254
|
const config = getModelConfig(model)
|
|
816
|
-
const providerOptionsKey = config.provider === 'vertex' ? 'vertex' : 'google'
|
|
817
1255
|
|
|
818
1256
|
if (!stdout) {
|
|
819
1257
|
console.error(pc.cyan('Generating...'))
|
|
@@ -835,27 +1273,32 @@ async function generateWithTextModel({
|
|
|
835
1273
|
]
|
|
836
1274
|
: undefined
|
|
837
1275
|
|
|
838
|
-
|
|
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({
|
|
839
1294
|
model: textModel,
|
|
840
1295
|
...(messages ? { messages } : { prompt }),
|
|
841
1296
|
providerOptions: {
|
|
842
|
-
[providerOptionsKey]:
|
|
843
|
-
responseModalities: ['TEXT', 'IMAGE'],
|
|
844
|
-
...(imageSize || aspectRatio
|
|
845
|
-
? {
|
|
846
|
-
imageConfig: {
|
|
847
|
-
...(imageSize ? { imageSize } : {}),
|
|
848
|
-
...(aspectRatio ? { aspectRatio } : {}),
|
|
849
|
-
},
|
|
850
|
-
}
|
|
851
|
-
: {}),
|
|
852
|
-
},
|
|
1297
|
+
[providerOptionsKey]: googleOpts,
|
|
853
1298
|
},
|
|
854
|
-
})
|
|
1299
|
+
}))
|
|
855
1300
|
|
|
856
|
-
const imageFiles = result.files.filter((f) =>
|
|
857
|
-
return f.mediaType.startsWith('image/')
|
|
858
|
-
})
|
|
1301
|
+
const imageFiles = result.files.filter((f) => f.mediaType.startsWith('image/'))
|
|
859
1302
|
|
|
860
1303
|
if (imageFiles.length === 0) {
|
|
861
1304
|
console.error(pc.red('No images generated.'))
|
|
@@ -863,46 +1306,27 @@ async function generateWithTextModel({
|
|
|
863
1306
|
}
|
|
864
1307
|
|
|
865
1308
|
if (stdout) {
|
|
866
|
-
|
|
867
|
-
if (file) {
|
|
868
|
-
process.stdout.write(Buffer.from(file.uint8Array))
|
|
869
|
-
}
|
|
1309
|
+
writeFirstToStdout(imageFiles)
|
|
870
1310
|
return
|
|
871
1311
|
}
|
|
872
1312
|
|
|
873
|
-
const savedFiles
|
|
874
|
-
for (let i = 0; i < imageFiles.length; i++) {
|
|
875
|
-
const file = imageFiles[i]!
|
|
876
|
-
const ext = extensionFromMediaType(file.mediaType)
|
|
877
|
-
const filePath =
|
|
878
|
-
imageFiles.length === 1
|
|
879
|
-
? ensureExtension(outputPath, ext)
|
|
880
|
-
: insertIndex(outputPath, i, ext)
|
|
881
|
-
|
|
882
|
-
fs.writeFileSync(filePath, file.uint8Array)
|
|
883
|
-
console.error(pc.green(`Saved: ${filePath}`))
|
|
884
|
-
savedFiles.push(filePath)
|
|
885
|
-
}
|
|
886
|
-
|
|
1313
|
+
const savedFiles = saveGeneratedFiles(imageFiles, outputPath)
|
|
887
1314
|
const cost = calculateCost(config.cost, result.usage, imageFiles.length)
|
|
888
|
-
|
|
889
|
-
console.error(pc.dim(`Cost: ${formatCost(cost)}`))
|
|
890
|
-
}
|
|
1315
|
+
printCost(cost)
|
|
891
1316
|
|
|
892
1317
|
if (result.text && !json) {
|
|
893
1318
|
console.error(pc.dim(result.text))
|
|
894
1319
|
}
|
|
895
1320
|
|
|
896
1321
|
if (json) {
|
|
897
|
-
|
|
1322
|
+
console.log(JSON.stringify({
|
|
898
1323
|
model,
|
|
899
1324
|
files: savedFiles,
|
|
900
1325
|
count: imageFiles.length,
|
|
901
1326
|
text: result.text || null,
|
|
902
1327
|
cost,
|
|
903
1328
|
usage: result.usage,
|
|
904
|
-
}
|
|
905
|
-
console.log(JSON.stringify(output, null, 2))
|
|
1329
|
+
}, null, 2))
|
|
906
1330
|
}
|
|
907
1331
|
}
|
|
908
1332
|
|
|
@@ -1111,6 +1535,10 @@ async function generateWithVideoModel({
|
|
|
1111
1535
|
fps,
|
|
1112
1536
|
seed,
|
|
1113
1537
|
inputImage,
|
|
1538
|
+
mode,
|
|
1539
|
+
videoUrl,
|
|
1540
|
+
referenceImages,
|
|
1541
|
+
negativePrompt,
|
|
1114
1542
|
json,
|
|
1115
1543
|
stdout,
|
|
1116
1544
|
}: {
|
|
@@ -1124,16 +1552,38 @@ async function generateWithVideoModel({
|
|
|
1124
1552
|
fps?: number
|
|
1125
1553
|
seed?: number
|
|
1126
1554
|
inputImage?: Uint8Array
|
|
1555
|
+
mode?: string
|
|
1556
|
+
videoUrl?: string
|
|
1557
|
+
referenceImages?: string[]
|
|
1558
|
+
negativePrompt?: string
|
|
1127
1559
|
json: boolean
|
|
1128
1560
|
stdout: boolean
|
|
1129
1561
|
}) {
|
|
1130
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
|
+
})
|
|
1131
1581
|
|
|
1132
1582
|
if (!stdout) {
|
|
1133
1583
|
console.error(pc.cyan('Generating...'))
|
|
1134
1584
|
}
|
|
1135
1585
|
|
|
1136
|
-
const result = await aiGenerateVideo({
|
|
1586
|
+
const result = await runProviderCall('Video generation', () => aiGenerateVideo({
|
|
1137
1587
|
model: videoModel,
|
|
1138
1588
|
prompt: inputImage
|
|
1139
1589
|
? { image: inputImage, text: prompt }
|
|
@@ -1144,50 +1594,88 @@ async function generateWithVideoModel({
|
|
|
1144
1594
|
...(duration != null ? { duration } : {}),
|
|
1145
1595
|
...(fps != null ? { fps } : {}),
|
|
1146
1596
|
...(seed != null ? { seed } : {}),
|
|
1147
|
-
|
|
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
|
+
}))
|
|
1148
1604
|
|
|
1149
1605
|
if (stdout) {
|
|
1150
|
-
|
|
1151
|
-
if (video) {
|
|
1152
|
-
process.stdout.write(Buffer.from(video.uint8Array))
|
|
1153
|
-
}
|
|
1606
|
+
writeFirstToStdout(files)
|
|
1154
1607
|
return
|
|
1155
1608
|
}
|
|
1156
1609
|
|
|
1157
|
-
const savedFiles
|
|
1158
|
-
for (let i = 0; i < result.videos.length; i++) {
|
|
1159
|
-
const video = result.videos[i]!
|
|
1160
|
-
const ext = extensionFromMediaType(video.mediaType)
|
|
1161
|
-
const filePath =
|
|
1162
|
-
result.videos.length === 1
|
|
1163
|
-
? ensureExtension(outputPath, ext)
|
|
1164
|
-
: insertIndex(outputPath, i, ext)
|
|
1165
|
-
|
|
1166
|
-
fs.writeFileSync(filePath, video.uint8Array)
|
|
1167
|
-
console.error(pc.green(`Saved: ${filePath}`))
|
|
1168
|
-
savedFiles.push(filePath)
|
|
1169
|
-
}
|
|
1170
|
-
|
|
1171
|
-
const config = getModelConfig(model)
|
|
1610
|
+
const savedFiles = saveGeneratedFiles(files, outputPath)
|
|
1172
1611
|
const cost = calculateCost(config.cost, {
|
|
1173
1612
|
videosGenerated: result.videos.length,
|
|
1174
1613
|
durationSeconds: duration,
|
|
1175
1614
|
resolution,
|
|
1176
1615
|
}, result.videos.length)
|
|
1177
|
-
|
|
1178
|
-
console.error(pc.dim(`Cost: ${formatCost(cost)}`))
|
|
1179
|
-
}
|
|
1616
|
+
printCost(cost)
|
|
1180
1617
|
|
|
1181
1618
|
if (json) {
|
|
1182
|
-
|
|
1619
|
+
console.log(JSON.stringify({
|
|
1183
1620
|
model,
|
|
1184
1621
|
files: savedFiles,
|
|
1185
1622
|
count: result.videos.length,
|
|
1186
1623
|
cost,
|
|
1187
1624
|
warnings: result.warnings,
|
|
1188
1625
|
responses: result.responses,
|
|
1189
|
-
}
|
|
1190
|
-
|
|
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))
|
|
1191
1679
|
}
|
|
1192
1680
|
}
|
|
1193
1681
|
|