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/dist/cli.js CHANGED
@@ -10,7 +10,8 @@
10
10
  import { goke } from 'goke';
11
11
  import { z } from 'zod';
12
12
  import dedent from 'string-dedent';
13
- import { generateImage as aiGenerateImage, generateText, experimental_generateVideo as aiGenerateVideo, } from 'ai';
13
+ import { APICallError, generateImage as aiGenerateImage, generateText, experimental_generateVideo as aiGenerateVideo, NoImageGeneratedError, NoVideoGeneratedError, RetryError, } from 'ai';
14
+ import { select, isCancel, cancel } from '@clack/prompts';
14
15
  import fs from 'node:fs';
15
16
  import path from 'node:path';
16
17
  import pc from 'picocolors';
@@ -18,25 +19,18 @@ import { createParser } from 'eventsource-parser';
18
19
  import pkg from '../package.json' with { type: 'json' };
19
20
  import { injectCredentialsToEnv, PROVIDERS, getChatGptAuth, saveChatGptAuth, getKeyStatus, } from './credentials.js';
20
21
  import { IMAGE_MODELS, VIDEO_MODELS, DEFAULT_MODEL, DEFAULT_VIDEO_MODEL, getModelConfig, createImageModel, createTextModel, createVideoModel, shouldUseResponsesApi, } from './models.js';
21
- import { VIDEO_CATALOG } from './video-model-catalog.js';
22
+ import { CATALOG, VIDEO_CATALOG, describeProviderValues, } from './model-catalog.js';
22
23
  import { loginInteractive, loginNonInteractive, showLoginStatus, removeLogin, readKeyFromStdin, } from './login.js';
23
24
  import { subscribeInteractive, subscribeNonInteractive, unsubscribe, showUsage, } from './subscription.js';
24
25
  import { getValidChatGptAuth } from './chatgpt-auth.js';
25
26
  const cli = goke('egaki');
26
27
  process.title = 'egaki';
27
- // Print clean error output for unhandled rejections (e.g. AI SDK APICallError).
28
- // The AI SDK errors include request bodies, response headers, and other noisy
29
- // properties that get dumped by Node's default handler. We strip those and just
30
- // print the message + stack trace.
31
28
  process.on('uncaughtException', (err) => {
32
- console.error(pc.red(err.message));
33
- if (err.stack) {
34
- const lines = err.stack.split('\n');
35
- const stackOnly = lines.filter((l) => l.trimStart().startsWith('at '));
36
- if (stackOnly.length > 0) {
37
- console.error(pc.dim(stackOnly.join('\n')));
38
- }
39
- }
29
+ printErrorDetails(err);
30
+ process.exit(1);
31
+ });
32
+ process.on('unhandledRejection', (reason) => {
33
+ printErrorDetails(reason);
40
34
  process.exit(1);
41
35
  });
42
36
  // ─── login command ───────────────────────────────────────────────────────────
@@ -75,8 +69,8 @@ cli
75
69
  }
76
70
  // Non-interactive: --provider + --key or stdin
77
71
  if (options.provider) {
78
- // ChatGPT uses browser OAuth — skip key reading
79
- if (options.provider === 'chatgpt') {
72
+ // OAuth providers use browser flow — skip key reading
73
+ if (options.provider === 'chatgpt' || options.provider === 'xai-oauth') {
80
74
  await loginNonInteractive({ provider: options.provider, key: '' });
81
75
  return;
82
76
  }
@@ -144,7 +138,7 @@ cli
144
138
  from the model ID: imagen-* uses the image API, everything else uses
145
139
  the text API with image output enabled.
146
140
  `)
147
- .option('-m, --model [model]', z.enum(IMAGE_MODELS).default(DEFAULT_MODEL).describe('Model ID for generation'))
141
+ .option('-m, --model [model]', z.string().describe('Model ID for generation. If omitted, shows an interactive picker (or uses default in non-TTY mode)'))
148
142
  .option('-o, --output [path]', z
149
143
  .string()
150
144
  .default('egaki-output.png')
@@ -163,6 +157,18 @@ cli
163
157
  .option('--mask [file]', z
164
158
  .string()
165
159
  .describe('Mask image for inpainting. Accepts a local file path or URL (http/https). White areas in the mask are replaced with generated content. Used together with --input'))
160
+ .option('--quality [level]', z
161
+ .string()
162
+ .describe(`Image quality level. ${describeProviderValues('quality', [CATALOG])}`))
163
+ .option('--resolution [res]', z
164
+ .string()
165
+ .describe(`Output resolution. ${describeProviderValues('resolution', [CATALOG])}`))
166
+ .option('--output-format [format]', z
167
+ .string()
168
+ .describe(`Output image format. ${describeProviderValues('output-format', [CATALOG])}`))
169
+ .option('--negative-prompt [text]', z
170
+ .string()
171
+ .describe('Describe what to avoid in the generated image (Fal models)'))
166
172
  .option('--allow-people', 'Allow generating images of people (Imagen blocks people by default)')
167
173
  .option('--json', 'Output result metadata as JSON to stdout (model, usage, warnings, file paths)')
168
174
  .option('--stdout', 'Write raw image bytes to stdout instead of saving to a file. Useful for piping to other tools')
@@ -187,10 +193,7 @@ cli
187
193
  .action(async (prompt, options) => {
188
194
  // Inject stored API keys as env vars before calling the AI SDK.
189
195
  injectCredentialsToEnv();
190
- // goke infers schema .default() values via Zod's input type, which leaves
191
- // options.model / options.output / options.count as `T | undefined` even
192
- // though the runtime always resolves them. Apply the same defaults here.
193
- const model = options.model ?? DEFAULT_MODEL;
196
+ const model = options.model ?? await resolveImageModel();
194
197
  const outputPath = options.output ?? 'egaki-output.png';
195
198
  const count = options.count ?? 1;
196
199
  const config = getModelConfig(model);
@@ -235,6 +238,10 @@ cli
235
238
  inputImages,
236
239
  maskImage,
237
240
  allowPeople: options.allowPeople || false,
241
+ quality: options.quality,
242
+ resolution: options.resolution,
243
+ outputFormat: options.outputFormat,
244
+ negativePrompt: options.negativePrompt,
238
245
  json: options.json || false,
239
246
  stdout: options.stdout || false,
240
247
  });
@@ -261,7 +268,7 @@ cli
261
268
  Agent note: video generation can be slow. When invoking this command from
262
269
  automation, use a command timeout of at least 5 minutes.
263
270
  `)
264
- .option('-m, --model [model]', z.enum(VIDEO_MODELS).default(DEFAULT_VIDEO_MODEL).describe('Video model ID for generation'))
271
+ .option('-m, --model [model]', z.string().describe('Video model ID for generation. If omitted, shows an interactive picker (or uses default in non-TTY mode)'))
265
272
  .option('-o, --output [path]', z
266
273
  .string()
267
274
  .default('egaki-output.mp4')
@@ -270,13 +277,22 @@ cli
270
277
  .option('--aspect-ratio [ratio]', z.string().describe('Video aspect ratio in WIDTH:HEIGHT format (e.g. 16:9, 9:16)'))
271
278
  .option('--resolution [resolution]', z
272
279
  .string()
273
- .describe('Video resolution in WIDTHxHEIGHT or provider format (e.g. 1280x720, 720p)'))
274
- .option('--duration [seconds]', z.number().describe('Video duration in seconds (provider/model-specific limits apply)'))
280
+ .describe(`Video resolution. ${describeProviderValues('resolution', [VIDEO_CATALOG])}. Google: 720p, 1080p, 4k`))
281
+ .option('--duration [seconds]', z.number().describe('Video duration in seconds. Veo: 4-8s. xAI Grok: 1-15s. Kling: 5-10s. Wan: 2-15s'))
275
282
  .option('--fps [fps]', z.number().describe('Frames per second for video models that support fps override'))
276
283
  .option('--seed [seed]', z.number().describe('Seed for reproducible video generation (model support varies)'))
277
284
  .option('-i, --input [file]', z
278
285
  .string()
279
- .describe('Optional reference image for image-to-video. Accepts local file path or URL (http/https)'))
286
+ .describe('Input file for video generation. For i2v: a reference image. For edit-video/extend-video: a source video. Accepts local file paths or URLs'))
287
+ .option('--mode [mode]', z
288
+ .enum(['edit-video', 'extend-video', 'reference-to-video'])
289
+ .describe(`Video operation mode. ${describeProviderValues('mode', [VIDEO_CATALOG])}`))
290
+ .option('--reference-images [path]', z
291
+ .array(z.string())
292
+ .describe('Reference images for R2V generation, repeatable, 1-7 files or URLs (xAI)'))
293
+ .option('--negative-prompt [text]', z
294
+ .string()
295
+ .describe('Describe what to avoid in the generated video (Fal models)'))
280
296
  .option('--json', 'Output result metadata as JSON to stdout (model, usage, warnings, file paths)')
281
297
  .option('--stdout', 'Write raw video bytes to stdout instead of saving to a file. Useful for piping to other tools')
282
298
  .example('# Generate a video')
@@ -291,8 +307,7 @@ cli
291
307
  .example('egaki video "waves crashing on cliffs" -n 2 -o waves.mp4')
292
308
  .action(async (prompt, options) => {
293
309
  injectCredentialsToEnv();
294
- // Apply the same .default(...) values goke's input-type inference drops.
295
- const model = options.model ?? DEFAULT_VIDEO_MODEL;
310
+ const model = options.model ?? await resolveVideoModel();
296
311
  const outputPath = options.output ?? 'egaki-output.mp4';
297
312
  const count = options.count ?? 1;
298
313
  const config = getModelConfig(model);
@@ -305,9 +320,34 @@ cli
305
320
  console.error(pc.dim(`Prompt: ${prompt}`));
306
321
  console.error(pc.dim('Mode: Video API (experimental_generateVideo)'));
307
322
  }
308
- const inputImage = options.input
309
- ? await readInputSource(options.input)
310
- : undefined;
323
+ // Determine how --input is used based on --mode:
324
+ // - edit-video / extend-video: input is a source video, passed as videoUrl (needs URL)
325
+ // - everything else (i2v, t2v): input is a reference image, passed as bytes
326
+ const isVideoInputMode = options.mode === 'edit-video' || options.mode === 'extend-video';
327
+ // Validate mode-specific required inputs
328
+ if (isVideoInputMode && !options.input) {
329
+ console.error(pc.red(`--input is required with --mode ${options.mode}. Pass a local video file or URL.`));
330
+ process.exit(1);
331
+ }
332
+ if (options.mode === 'reference-to-video' && (!options.referenceImages || options.referenceImages.length === 0)) {
333
+ console.error(pc.red('--reference-images is required with --mode reference-to-video. Pass 1-7 image files or URLs.'));
334
+ process.exit(1);
335
+ }
336
+ let inputImage;
337
+ let videoUrl;
338
+ if (options.input && isVideoInputMode) {
339
+ // Upload local file or use URL directly for video editing/extension
340
+ videoUrl = await resolveToUrl(options.input);
341
+ }
342
+ else if (options.input) {
343
+ // Read as bytes for image-to-video
344
+ inputImage = await readInputSource(options.input);
345
+ }
346
+ // Resolve --reference-images: upload local files to get URLs
347
+ let resolvedReferenceImages = options.referenceImages;
348
+ if (resolvedReferenceImages) {
349
+ resolvedReferenceImages = await Promise.all(resolvedReferenceImages.map((ref) => resolveToUrl(ref)));
350
+ }
311
351
  await generateWithVideoModel({
312
352
  prompt,
313
353
  model,
@@ -319,6 +359,10 @@ cli
319
359
  fps: options.fps,
320
360
  seed: options.seed,
321
361
  inputImage,
362
+ mode: options.mode,
363
+ videoUrl,
364
+ referenceImages: resolvedReferenceImages,
365
+ negativePrompt: options.negativePrompt,
322
366
  json: options.json || false,
323
367
  stdout: options.stdout || false,
324
368
  });
@@ -336,13 +380,12 @@ cli
336
380
  .describe('Filter by model type: image (image+text-image), video, or all'))
337
381
  .option('--json', 'Output as JSON instead of YAML')
338
382
  .action(async (options) => {
339
- const { CATALOG } = await import('./model-catalog.js');
340
383
  const yaml = await import('js-yaml');
341
384
  const providerStatuses = Object.fromEntries(Object.keys(PROVIDERS).map((provider) => [provider, getKeyStatus(provider)]));
342
385
  let models = options.type === 'video'
343
- ? VIDEO_CATALOG
386
+ ? [...VIDEO_CATALOG]
344
387
  : options.type === 'image'
345
- ? CATALOG
388
+ ? [...CATALOG]
346
389
  : [...CATALOG, ...VIDEO_CATALOG];
347
390
  if (options.provider) {
348
391
  models = models.filter((m) => m.provider === options.provider);
@@ -389,12 +432,269 @@ cli
389
432
  console.log(yaml.dump(output, { lineWidth: 120, noRefs: true }));
390
433
  }
391
434
  });
435
+ /** Upload gateway base URL for temporary file uploads. */
436
+ const UPLOAD_GATEWAY_BASE = 'https://egaki.org';
437
+ /** Max upload size in bytes (must match gateway limit). */
438
+ const MAX_UPLOAD_SIZE = 100 * 1024 * 1024;
392
439
  cli.help();
393
440
  cli.version(pkg.version);
394
- cli.parse();
441
+ await cli.parse();
442
+ async function runProviderCall(operation, call) {
443
+ const result = await call().catch((error) => {
444
+ printErrorDetails(error, operation);
445
+ process.exit(1);
446
+ });
447
+ return result;
448
+ }
449
+ function printErrorDetails(error, operation) {
450
+ const prefix = operation ? `${operation} failed` : 'Command failed';
451
+ console.error(pc.red(pc.bold(prefix)));
452
+ if (APICallError.isInstance(error)) {
453
+ printKeyValue('name', error.name);
454
+ printKeyValue('message', error.message);
455
+ printKeyValue('statusCode', error.statusCode);
456
+ printKeyValue('url', error.url);
457
+ printKeyValue('isRetryable', error.isRetryable);
458
+ printJsonBlock('responseBody', parseJsonLike(error.responseBody));
459
+ printJsonBlock('responseHeaders', error.responseHeaders);
460
+ printJsonBlock('requestBodyValues', error.requestBodyValues);
461
+ printJsonBlock('data', error.data);
462
+ printCause(error.cause);
463
+ printStack(error);
464
+ return;
465
+ }
466
+ if (NoImageGeneratedError.isInstance(error) || NoVideoGeneratedError.isInstance(error)) {
467
+ printKeyValue('name', error.name);
468
+ printKeyValue('message', error.message);
469
+ printJsonBlock('responses', error.responses);
470
+ printCause(error.cause);
471
+ printStack(error);
472
+ return;
473
+ }
474
+ if (RetryError.isInstance(error)) {
475
+ printKeyValue('name', error.name);
476
+ printKeyValue('message', error.message);
477
+ printKeyValue('reason', error.reason);
478
+ printJsonBlock('errors', error.errors.map((e) => serializeUnknown(e)));
479
+ printCause(error.cause);
480
+ printStack(error);
481
+ return;
482
+ }
483
+ if (error instanceof Error) {
484
+ printKeyValue('name', error.name);
485
+ printKeyValue('message', error.message);
486
+ printJsonBlock('details', serializeOwnProperties(error));
487
+ printCause(error.cause);
488
+ printStack(error);
489
+ return;
490
+ }
491
+ printJsonBlock('error', serializeUnknown(error));
492
+ }
493
+ function printKeyValue(key, value) {
494
+ if (value === undefined)
495
+ return;
496
+ console.error(`${pc.dim(`${key}:`)} ${String(value)}`);
497
+ }
498
+ function printJsonBlock(label, value) {
499
+ if (value === undefined)
500
+ return;
501
+ const serialized = stableStringify(value);
502
+ if (!serialized || serialized === '{}')
503
+ return;
504
+ console.error(pc.dim(`${label}:`));
505
+ console.error(serialized);
506
+ }
507
+ function printCause(cause) {
508
+ if (cause === undefined)
509
+ return;
510
+ console.error(pc.dim('cause:'));
511
+ console.error(stableStringify(serializeUnknown(cause)));
512
+ }
513
+ function printStack(error) {
514
+ if (!error.stack)
515
+ return;
516
+ console.error(pc.dim('stack:'));
517
+ console.error(pc.dim(error.stack));
518
+ }
519
+ function parseJsonLike(value) {
520
+ if (typeof value !== 'string')
521
+ return value;
522
+ try {
523
+ return JSON.parse(value);
524
+ }
525
+ catch {
526
+ return value;
527
+ }
528
+ }
529
+ function serializeUnknown(value) {
530
+ if (value instanceof Error) {
531
+ return {
532
+ name: value.name,
533
+ message: value.message,
534
+ ...serializeOwnProperties(value),
535
+ ...(value.cause === undefined ? {} : { cause: serializeUnknown(value.cause) }),
536
+ ...(value.stack ? { stack: value.stack } : {}),
537
+ };
538
+ }
539
+ return value;
540
+ }
541
+ function serializeOwnProperties(error) {
542
+ return Object.fromEntries(Object.entries(error).map(([key, value]) => [key, serializeUnknown(value)]));
543
+ }
544
+ function stableStringify(value) {
545
+ const seen = new WeakSet();
546
+ return JSON.stringify(value, (_key, item) => {
547
+ if (typeof item === 'bigint')
548
+ return item.toString();
549
+ if (item instanceof Date)
550
+ return item.toISOString();
551
+ if (typeof item !== 'object' || item === null)
552
+ return item;
553
+ if (seen.has(item))
554
+ return '[Circular]';
555
+ seen.add(item);
556
+ return item;
557
+ }, 2);
558
+ }
559
+ // ─── interactive model pickers ──────────────────────────────────────────────
560
+ /** Curated image models shown in the interactive picker, ordered by recommendation. */
561
+ const FEATURED_IMAGE_MODELS = [
562
+ 'imagen-4.0-generate-001',
563
+ 'imagen-4.0-ultra-generate-001',
564
+ 'imagen-4.0-fast-generate-001',
565
+ 'gemini-2.5-flash-image',
566
+ 'gemini-3.1-flash-image-preview',
567
+ 'nano-banana-pro-preview',
568
+ 'gpt-image-2',
569
+ 'gpt-image-1.5',
570
+ 'chatgpt-image-latest',
571
+ 'flux-kontext-pro',
572
+ 'flux-kontext-max',
573
+ 'flux-pro-1.1-ultra',
574
+ 'recraft-v4',
575
+ 'dall-e-3',
576
+ 'grok-imagine-image-pro',
577
+ ];
578
+ /**
579
+ * Resolve the image model: show interactive picker in TTY, use default otherwise.
580
+ */
581
+ async function resolveImageModel() {
582
+ const isTTY = process.stdin.isTTY && process.stderr.isTTY;
583
+ if (!isTTY)
584
+ return DEFAULT_MODEL;
585
+ const options = FEATURED_IMAGE_MODELS
586
+ .map((id) => CATALOG.find((m) => m.id === id))
587
+ .filter(Boolean)
588
+ .map((m) => {
589
+ const cost = m.cost.type === 'per-image'
590
+ ? pc.dim(`$${m.cost.perImage}/img`)
591
+ : m.cost.type === 'per-token'
592
+ ? pc.dim(`$${m.cost.outputPerM}/M out`)
593
+ : '';
594
+ return {
595
+ value: m.id,
596
+ label: `${m.name} ${cost}`,
597
+ hint: m.id,
598
+ };
599
+ });
600
+ const selected = await select({
601
+ message: 'Select an image model',
602
+ options,
603
+ });
604
+ if (isCancel(selected)) {
605
+ cancel('Cancelled.');
606
+ process.exit(0);
607
+ }
608
+ return selected;
609
+ }
610
+ /**
611
+ * Resolve the video model: show interactive picker in TTY, use default otherwise.
612
+ */
613
+ async function resolveVideoModel() {
614
+ const isTTY = process.stdin.isTTY && process.stderr.isTTY;
615
+ if (!isTTY)
616
+ return DEFAULT_VIDEO_MODEL;
617
+ const options = VIDEO_CATALOG.map((m) => {
618
+ const dur = m.features.durationRangeSec
619
+ ? pc.dim(`${m.features.durationRangeSec.min}-${m.features.durationRangeSec.max}s`)
620
+ : '';
621
+ return {
622
+ value: m.id,
623
+ label: `${m.name} ${dur}`,
624
+ hint: m.id,
625
+ };
626
+ });
627
+ const selected = await select({
628
+ message: 'Select a video model',
629
+ options,
630
+ });
631
+ if (isCancel(selected)) {
632
+ cancel('Cancelled.');
633
+ process.exit(0);
634
+ }
635
+ return selected;
636
+ }
395
637
  function isUrl(input) {
396
638
  return /^https?:\/\//i.test(input);
397
639
  }
640
+ function mimeTypeFromExtension(ext) {
641
+ const map = {
642
+ '.mp4': 'video/mp4',
643
+ '.webm': 'video/webm',
644
+ '.mov': 'video/quicktime',
645
+ '.avi': 'video/x-msvideo',
646
+ '.png': 'image/png',
647
+ '.jpg': 'image/jpeg',
648
+ '.jpeg': 'image/jpeg',
649
+ '.webp': 'image/webp',
650
+ '.gif': 'image/gif',
651
+ };
652
+ return map[ext.toLowerCase()] || 'application/octet-stream';
653
+ }
654
+ /**
655
+ * Upload a local file to the egaki gateway and return the public URL.
656
+ * Used when a flag that expects a URL receives a local file path instead.
657
+ */
658
+ async function uploadFileToGateway(filePath) {
659
+ const resolved = path.resolve(filePath);
660
+ if (!fs.existsSync(resolved)) {
661
+ console.error(pc.red(`File not found: ${resolved}`));
662
+ process.exit(1);
663
+ }
664
+ const stat = fs.statSync(resolved);
665
+ if (stat.size > MAX_UPLOAD_SIZE) {
666
+ console.error(pc.red(`File too large: ${resolved} (${Math.round(stat.size / 1024 / 1024)}MB). Maximum is ${MAX_UPLOAD_SIZE / 1024 / 1024}MB`));
667
+ process.exit(1);
668
+ }
669
+ const ext = path.extname(resolved);
670
+ const contentType = mimeTypeFromExtension(ext);
671
+ const fileBytes = fs.readFileSync(resolved);
672
+ console.error(pc.dim(`Uploading ${filePath}...`));
673
+ const response = await fetch(`${UPLOAD_GATEWAY_BASE}/upload`, {
674
+ method: 'POST',
675
+ headers: { 'Content-Type': contentType },
676
+ body: fileBytes,
677
+ });
678
+ if (!response.ok) {
679
+ const body = await response.text().catch(() => '');
680
+ console.error(pc.red(`Upload failed: ${response.status} ${response.statusText}`));
681
+ if (body)
682
+ console.error(pc.dim(body));
683
+ process.exit(1);
684
+ }
685
+ const result = await response.json();
686
+ console.error(pc.dim(`Uploaded: ${result.url}`));
687
+ return result.url;
688
+ }
689
+ /**
690
+ * Resolve a value that should be a URL: if it's already a URL, return as-is.
691
+ * If it's a local file path, upload it to the gateway and return the public URL.
692
+ */
693
+ async function resolveToUrl(input) {
694
+ if (isUrl(input))
695
+ return input;
696
+ return uploadFileToGateway(input);
697
+ }
398
698
  async function readInputSource(input) {
399
699
  if (isUrl(input)) {
400
700
  console.error(pc.dim(`Fetching ${input}...`));
@@ -418,72 +718,159 @@ async function readInputImages(inputs) {
418
718
  }
419
719
  return Promise.all(inputs.map((f) => readInputSource(f)));
420
720
  }
721
+ function buildImageProviderOptions(provider, opts) {
722
+ switch (provider) {
723
+ case 'google': {
724
+ const googleOpts = {
725
+ ...(opts.allowPeople ? { personGeneration: 'allow_all' } : {}),
726
+ ...(opts.aspectRatio ? { aspectRatio: opts.aspectRatio } : {}),
727
+ };
728
+ return { google: googleOpts };
729
+ }
730
+ case 'vertex': {
731
+ const vertexOpts = {
732
+ ...(opts.allowPeople ? { personGeneration: 'allow_all' } : {}),
733
+ ...(opts.negativePrompt ? { negativePrompt: opts.negativePrompt } : {}),
734
+ };
735
+ return { vertex: vertexOpts };
736
+ }
737
+ case 'xai': {
738
+ const xaiOpts = {
739
+ ...(opts.quality ? { quality: opts.quality } : {}),
740
+ ...(opts.resolution ? { resolution: opts.resolution } : {}),
741
+ ...(opts.outputFormat ? { output_format: opts.outputFormat } : {}),
742
+ };
743
+ return { xai: xaiOpts };
744
+ }
745
+ case 'openai': {
746
+ const openaiOpts = {
747
+ ...(opts.quality ? { quality: opts.quality } : {}),
748
+ ...(opts.outputFormat ? { outputFormat: opts.outputFormat } : {}),
749
+ };
750
+ return { openai: openaiOpts };
751
+ }
752
+ case 'fal': {
753
+ const falOpts = {
754
+ ...(opts.outputFormat ? { outputFormat: opts.outputFormat } : {}),
755
+ ...(opts.negativePrompt ? { negativePrompt: opts.negativePrompt } : {}),
756
+ };
757
+ return { fal: falOpts };
758
+ }
759
+ default:
760
+ // Providers routed through the gateway (bfl, recraft, etc.) pass options
761
+ // as-is since they don't have local SDK types.
762
+ return {};
763
+ }
764
+ }
765
+ function buildVideoProviderOptions(provider, opts) {
766
+ switch (provider) {
767
+ case 'xai': {
768
+ // xAI video modes are a discriminated union; build the correct variant.
769
+ const base = {
770
+ ...(opts.resolution ? { resolution: opts.resolution } : {}),
771
+ };
772
+ if (opts.mode === 'edit-video' && opts.videoUrl) {
773
+ const xaiOpts = { ...base, mode: 'edit-video', videoUrl: opts.videoUrl };
774
+ return { xai: xaiOpts };
775
+ }
776
+ if (opts.mode === 'extend-video' && opts.videoUrl) {
777
+ const xaiOpts = { ...base, mode: 'extend-video', videoUrl: opts.videoUrl };
778
+ return { xai: xaiOpts };
779
+ }
780
+ if (opts.mode === 'reference-to-video' && opts.referenceImages) {
781
+ const xaiOpts = { ...base, mode: 'reference-to-video', referenceImageUrls: opts.referenceImages };
782
+ return { xai: xaiOpts };
783
+ }
784
+ // Default: text-to-video or image-to-video (no mode)
785
+ if (Object.keys(base).length === 0)
786
+ return undefined;
787
+ const xaiOpts = { ...base };
788
+ return { xai: xaiOpts };
789
+ }
790
+ case 'fal': {
791
+ const falOpts = {
792
+ ...(opts.resolution ? { resolution: opts.resolution } : {}),
793
+ ...(opts.negativePrompt ? { negativePrompt: opts.negativePrompt } : {}),
794
+ };
795
+ if (Object.keys(falOpts).length === 0)
796
+ return undefined;
797
+ return { fal: falOpts };
798
+ }
799
+ case 'google': {
800
+ const googleOpts = {
801
+ ...(opts.negativePrompt ? { negativePrompt: opts.negativePrompt } : {}),
802
+ };
803
+ if (Object.keys(googleOpts).length === 0)
804
+ return undefined;
805
+ return { google: googleOpts };
806
+ }
807
+ case 'vertex': {
808
+ const vertexOpts = {
809
+ ...(opts.negativePrompt ? { negativePrompt: opts.negativePrompt } : {}),
810
+ };
811
+ if (Object.keys(vertexOpts).length === 0)
812
+ return undefined;
813
+ return { vertex: vertexOpts };
814
+ }
815
+ default:
816
+ return undefined;
817
+ }
818
+ }
421
819
  // Generate using the dedicated generateImage API (Imagen models)
422
- async function generateWithImageModel({ prompt, model, outputPath, count, aspectRatio, seed, inputImages, maskImage, allowPeople, json, stdout, }) {
820
+ async function generateWithImageModel({ prompt, model, outputPath, count, aspectRatio, seed, inputImages, maskImage, allowPeople, quality, resolution, outputFormat, negativePrompt, json, stdout, }) {
423
821
  // Build the prompt: plain string or multimodal with reference images
424
822
  const imagePrompt = inputImages.length > 0
425
823
  ? { text: prompt, images: inputImages, ...(maskImage ? { mask: maskImage } : {}) }
426
824
  : prompt;
427
825
  const config = getModelConfig(model);
428
826
  const imageModel = await createImageModel(model);
429
- // Use the correct providerOptions key based on provider (google vs vertex)
430
- const providerOptionsKey = config.provider === 'vertex' ? 'vertex' : 'google';
827
+ // Build type-safe provider options using the SDK types with `satisfies`.
828
+ // Each provider has its own shape; we switch on provider to get compile-time safety.
829
+ const providerOptions = buildImageProviderOptions(config.provider, {
830
+ aspectRatio,
831
+ allowPeople,
832
+ quality,
833
+ resolution,
834
+ outputFormat,
835
+ negativePrompt,
836
+ });
431
837
  if (!stdout) {
432
838
  console.error(pc.cyan('Generating...'));
433
839
  }
434
- const result = await aiGenerateImage({
840
+ const result = await runProviderCall('Image generation', () => aiGenerateImage({
435
841
  model: imageModel,
436
842
  prompt: imagePrompt,
437
843
  n: count,
438
844
  ...(aspectRatio ? { aspectRatio } : {}),
439
845
  ...(seed !== undefined ? { seed } : {}),
440
- providerOptions: {
441
- [providerOptionsKey]: {
442
- ...(allowPeople ? { personGeneration: 'allow_all' } : {}),
443
- ...(aspectRatio ? { aspectRatio } : {}),
444
- },
445
- },
446
- });
846
+ providerOptions,
847
+ }));
848
+ const files = result.images.map((img) => ({
849
+ uint8Array: img.uint8Array,
850
+ mediaType: img.mediaType,
851
+ }));
447
852
  if (stdout) {
448
- // Write first image to stdout for piping
449
- const image = result.images[0];
450
- if (image) {
451
- process.stdout.write(Buffer.from(image.uint8Array));
452
- }
853
+ writeFirstToStdout(files);
453
854
  return;
454
855
  }
455
- const savedFiles = [];
456
- for (let i = 0; i < result.images.length; i++) {
457
- const image = result.images[i];
458
- const ext = extensionFromMediaType(image.mediaType);
459
- const filePath = result.images.length === 1
460
- ? ensureExtension(outputPath, ext)
461
- : insertIndex(outputPath, i, ext);
462
- fs.writeFileSync(filePath, image.uint8Array);
463
- console.error(pc.green(`Saved: ${filePath}`));
464
- savedFiles.push(filePath);
465
- }
856
+ const savedFiles = saveGeneratedFiles(files, outputPath);
466
857
  const cost = calculateCost(config.cost, result.usage, result.images.length);
467
- if (cost != null) {
468
- console.error(pc.dim(`Cost: ${formatCost(cost)}`));
469
- }
858
+ printCost(cost);
470
859
  if (json) {
471
- const output = {
860
+ console.log(JSON.stringify({
472
861
  model,
473
862
  files: savedFiles,
474
863
  count: result.images.length,
475
864
  cost,
476
865
  usage: result.usage,
477
866
  warnings: result.warnings,
478
- };
479
- console.log(JSON.stringify(output, null, 2));
867
+ }, null, 2));
480
868
  }
481
869
  }
482
870
  // Generate using generateText with responseModalities (Gemini multimodal models)
483
871
  async function generateWithTextModel({ prompt, model, outputPath, inputImages, imageSize, aspectRatio, json, stdout, }) {
484
872
  const textModel = await createTextModel(model);
485
873
  const config = getModelConfig(model);
486
- const providerOptionsKey = config.provider === 'vertex' ? 'vertex' : 'google';
487
874
  if (!stdout) {
488
875
  console.error(pc.cyan('Generating...'));
489
876
  }
@@ -502,65 +889,49 @@ async function generateWithTextModel({ prompt, model, outputPath, inputImages, i
502
889
  },
503
890
  ]
504
891
  : undefined;
505
- const result = await generateText({
892
+ const googleOpts = {
893
+ responseModalities: ['TEXT', 'IMAGE'],
894
+ ...(imageSize || aspectRatio
895
+ ? {
896
+ imageConfig: {
897
+ ...(imageSize ? { imageSize } : {}),
898
+ ...(aspectRatio ? { aspectRatio: aspectRatio } : {}),
899
+ },
900
+ }
901
+ : {}),
902
+ };
903
+ const providerOptionsKey = config.provider === 'vertex' ? 'vertex' : 'google';
904
+ const result = await runProviderCall('Text/image generation', () => generateText({
506
905
  model: textModel,
507
906
  ...(messages ? { messages } : { prompt }),
508
907
  providerOptions: {
509
- [providerOptionsKey]: {
510
- responseModalities: ['TEXT', 'IMAGE'],
511
- ...(imageSize || aspectRatio
512
- ? {
513
- imageConfig: {
514
- ...(imageSize ? { imageSize } : {}),
515
- ...(aspectRatio ? { aspectRatio } : {}),
516
- },
517
- }
518
- : {}),
519
- },
908
+ [providerOptionsKey]: googleOpts,
520
909
  },
521
- });
522
- const imageFiles = result.files.filter((f) => {
523
- return f.mediaType.startsWith('image/');
524
- });
910
+ }));
911
+ const imageFiles = result.files.filter((f) => f.mediaType.startsWith('image/'));
525
912
  if (imageFiles.length === 0) {
526
913
  console.error(pc.red('No images generated.'));
527
914
  process.exit(1);
528
915
  }
529
916
  if (stdout) {
530
- const file = imageFiles[0];
531
- if (file) {
532
- process.stdout.write(Buffer.from(file.uint8Array));
533
- }
917
+ writeFirstToStdout(imageFiles);
534
918
  return;
535
919
  }
536
- const savedFiles = [];
537
- for (let i = 0; i < imageFiles.length; i++) {
538
- const file = imageFiles[i];
539
- const ext = extensionFromMediaType(file.mediaType);
540
- const filePath = imageFiles.length === 1
541
- ? ensureExtension(outputPath, ext)
542
- : insertIndex(outputPath, i, ext);
543
- fs.writeFileSync(filePath, file.uint8Array);
544
- console.error(pc.green(`Saved: ${filePath}`));
545
- savedFiles.push(filePath);
546
- }
920
+ const savedFiles = saveGeneratedFiles(imageFiles, outputPath);
547
921
  const cost = calculateCost(config.cost, result.usage, imageFiles.length);
548
- if (cost != null) {
549
- console.error(pc.dim(`Cost: ${formatCost(cost)}`));
550
- }
922
+ printCost(cost);
551
923
  if (result.text && !json) {
552
924
  console.error(pc.dim(result.text));
553
925
  }
554
926
  if (json) {
555
- const output = {
927
+ console.log(JSON.stringify({
556
928
  model,
557
929
  files: savedFiles,
558
930
  count: imageFiles.length,
559
931
  text: result.text || null,
560
932
  cost,
561
933
  usage: result.usage,
562
- };
563
- console.log(JSON.stringify(output, null, 2));
934
+ }, null, 2));
564
935
  }
565
936
  }
566
937
  // Generate using the OpenAI Responses API with imageGeneration tool.
@@ -706,12 +1077,28 @@ async function generateWithResponsesApi({ prompt, model, outputPath, count, aspe
706
1077
  }
707
1078
  }
708
1079
  // Generate using experimental_generateVideo (video models)
709
- async function generateWithVideoModel({ prompt, model, outputPath, count, aspectRatio, resolution, duration, fps, seed, inputImage, json, stdout, }) {
1080
+ async function generateWithVideoModel({ prompt, model, outputPath, count, aspectRatio, resolution, duration, fps, seed, inputImage, mode, videoUrl, referenceImages, negativePrompt, json, stdout, }) {
710
1081
  const videoModel = await createVideoModel(model);
1082
+ const config = getModelConfig(model);
1083
+ // Validate that the model supports video-url input before building options.
1084
+ // Only models with a 'video-url' providerOption (currently xAI) accept edit/extend mode.
1085
+ if (videoUrl && !config.providerOptions?.some((opt) => opt.flag === 'video-url')) {
1086
+ console.error(pc.red(`Model ${model} does not support video URL input for editing/extension`));
1087
+ process.exit(1);
1088
+ }
1089
+ // Build type-safe provider options for video generation.
1090
+ const providerOptions = buildVideoProviderOptions(config.provider, {
1091
+ resolution,
1092
+ mode,
1093
+ videoUrl,
1094
+ referenceImages,
1095
+ negativePrompt,
1096
+ model,
1097
+ });
711
1098
  if (!stdout) {
712
1099
  console.error(pc.cyan('Generating...'));
713
1100
  }
714
- const result = await aiGenerateVideo({
1101
+ const result = await runProviderCall('Video generation', () => aiGenerateVideo({
715
1102
  model: videoModel,
716
1103
  prompt: inputImage
717
1104
  ? { image: inputImage, text: prompt }
@@ -722,44 +1109,67 @@ async function generateWithVideoModel({ prompt, model, outputPath, count, aspect
722
1109
  ...(duration != null ? { duration } : {}),
723
1110
  ...(fps != null ? { fps } : {}),
724
1111
  ...(seed != null ? { seed } : {}),
725
- });
1112
+ ...(providerOptions ? { providerOptions } : {}),
1113
+ }));
1114
+ const files = result.videos.map((v) => ({
1115
+ uint8Array: v.uint8Array,
1116
+ mediaType: v.mediaType,
1117
+ }));
726
1118
  if (stdout) {
727
- const video = result.videos[0];
728
- if (video) {
729
- process.stdout.write(Buffer.from(video.uint8Array));
730
- }
1119
+ writeFirstToStdout(files);
731
1120
  return;
732
1121
  }
733
- const savedFiles = [];
734
- for (let i = 0; i < result.videos.length; i++) {
735
- const video = result.videos[i];
736
- const ext = extensionFromMediaType(video.mediaType);
737
- const filePath = result.videos.length === 1
738
- ? ensureExtension(outputPath, ext)
739
- : insertIndex(outputPath, i, ext);
740
- fs.writeFileSync(filePath, video.uint8Array);
741
- console.error(pc.green(`Saved: ${filePath}`));
742
- savedFiles.push(filePath);
743
- }
744
- const config = getModelConfig(model);
1122
+ const savedFiles = saveGeneratedFiles(files, outputPath);
745
1123
  const cost = calculateCost(config.cost, {
746
1124
  videosGenerated: result.videos.length,
747
1125
  durationSeconds: duration,
748
1126
  resolution,
749
1127
  }, result.videos.length);
750
- if (cost != null) {
751
- console.error(pc.dim(`Cost: ${formatCost(cost)}`));
752
- }
1128
+ printCost(cost);
753
1129
  if (json) {
754
- const output = {
1130
+ console.log(JSON.stringify({
755
1131
  model,
756
1132
  files: savedFiles,
757
1133
  count: result.videos.length,
758
1134
  cost,
759
1135
  warnings: result.warnings,
760
1136
  responses: result.responses,
761
- };
762
- console.log(JSON.stringify(output, null, 2));
1137
+ }, null, 2));
1138
+ }
1139
+ }
1140
+ /**
1141
+ * Save generated files to disk. Returns the list of saved file paths.
1142
+ * Handles single vs multi-file naming with index suffixes.
1143
+ */
1144
+ function saveGeneratedFiles(files, outputPath) {
1145
+ const savedFiles = [];
1146
+ for (let i = 0; i < files.length; i++) {
1147
+ const file = files[i];
1148
+ const ext = extensionFromMediaType(file.mediaType);
1149
+ const filePath = files.length === 1
1150
+ ? ensureExtension(outputPath, ext)
1151
+ : insertIndex(outputPath, i, ext);
1152
+ fs.writeFileSync(filePath, file.uint8Array);
1153
+ console.error(pc.green(`Saved: ${filePath}`));
1154
+ savedFiles.push(filePath);
1155
+ }
1156
+ return savedFiles;
1157
+ }
1158
+ /**
1159
+ * Print estimated cost to stderr if calculable.
1160
+ */
1161
+ function printCost(cost) {
1162
+ if (cost != null) {
1163
+ console.error(pc.dim(`Cost: ${formatCost(cost)}`));
1164
+ }
1165
+ }
1166
+ /**
1167
+ * Write the first file's raw bytes to stdout (for piping to other tools).
1168
+ */
1169
+ function writeFirstToStdout(files) {
1170
+ const file = files[0];
1171
+ if (file) {
1172
+ process.stdout.write(Buffer.from(file.uint8Array));
763
1173
  }
764
1174
  }
765
1175
  // ─── cost helpers ────────────────────────────────────────────────────────────