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/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 { VIDEO_CATALOG } from './video-model-catalog.js'
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
- console.error(pc.red(err.message))
67
- if (err.stack) {
68
- const lines = err.stack.split('\n')
69
- const stackOnly = lines.filter((l) => l.trimStart().startsWith('at '))
70
- if (stackOnly.length > 0) {
71
- console.error(pc.dim(stackOnly.join('\n')))
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
- // ChatGPT uses browser OAuth — skip key reading
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.enum(IMAGE_MODELS).default(DEFAULT_MODEL).describe('Model ID for generation'),
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
- // goke infers schema .default() values via Zod's input type, which leaves
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.enum(VIDEO_MODELS).default(DEFAULT_VIDEO_MODEL).describe('Video model ID for generation'),
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('Video resolution in WIDTHxHEIGHT or provider format (e.g. 1280x720, 720p)'),
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 (provider/model-specific limits apply)'),
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
- 'Optional reference image for image-to-video. Accepts local file path or URL (http/https)',
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
- // Apply the same .default(...) values goke's input-type inference drops.
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
- const inputImage = options.input
476
- ? await readInputSource(options.input)
477
- : undefined
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 = options.type === 'video'
525
- ? VIDEO_CATALOG
526
- : options.type === 'image'
527
- ? CATALOG
528
- : [...CATALOG, ...VIDEO_CATALOG]
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
- // Use the correct providerOptions key based on provider (google vs vertex)
648
- const providerOptionsKey = config.provider === 'vertex' ? 'vertex' : 'google'
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
- [providerOptionsKey]: {
662
- ...(allowPeople ? { personGeneration: 'allow_all' } : {}),
663
- ...(aspectRatio ? { aspectRatio } : {}),
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
- // Write first image to stdout for piping
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: string[] = []
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
- if (cost != null) {
693
- console.error(pc.dim(`Cost: ${formatCost(cost)}`))
694
- }
1219
+ printCost(cost)
695
1220
 
696
1221
  if (json) {
697
- const output = {
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
- const result = await generateText({
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
- const file = imageFiles[0]
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: string[] = []
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
- if (cost != null) {
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
- const output = {
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
- const video = result.videos[0]
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: string[] = []
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
- if (cost != null) {
1093
- console.error(pc.dim(`Cost: ${formatCost(cost)}`))
1094
- }
1616
+ printCost(cost)
1095
1617
 
1096
1618
  if (json) {
1097
- const output = {
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
- console.log(JSON.stringify(output, null, 2))
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