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/dist/cli.js CHANGED
@@ -10,7 +10,7 @@
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
14
  import { select, isCancel, cancel } from '@clack/prompts';
15
15
  import fs from 'node:fs';
16
16
  import path from 'node:path';
@@ -19,26 +19,18 @@ import { createParser } from 'eventsource-parser';
19
19
  import pkg from '../package.json' with { type: 'json' };
20
20
  import { injectCredentialsToEnv, PROVIDERS, getChatGptAuth, saveChatGptAuth, getKeyStatus, } from './credentials.js';
21
21
  import { IMAGE_MODELS, VIDEO_MODELS, DEFAULT_MODEL, DEFAULT_VIDEO_MODEL, getModelConfig, createImageModel, createTextModel, createVideoModel, shouldUseResponsesApi, } from './models.js';
22
- import { CATALOG } from './model-catalog.js';
23
- import { VIDEO_CATALOG } from './video-model-catalog.js';
22
+ import { CATALOG, VIDEO_CATALOG, describeProviderValues, } from './model-catalog.js';
24
23
  import { loginInteractive, loginNonInteractive, showLoginStatus, removeLogin, readKeyFromStdin, } from './login.js';
25
24
  import { subscribeInteractive, subscribeNonInteractive, unsubscribe, showUsage, } from './subscription.js';
26
25
  import { getValidChatGptAuth } from './chatgpt-auth.js';
27
26
  const cli = goke('egaki');
28
27
  process.title = 'egaki';
29
- // Print clean error output for unhandled rejections (e.g. AI SDK APICallError).
30
- // The AI SDK errors include request bodies, response headers, and other noisy
31
- // properties that get dumped by Node's default handler. We strip those and just
32
- // print the message + stack trace.
33
28
  process.on('uncaughtException', (err) => {
34
- console.error(pc.red(err.message));
35
- if (err.stack) {
36
- const lines = err.stack.split('\n');
37
- const stackOnly = lines.filter((l) => l.trimStart().startsWith('at '));
38
- if (stackOnly.length > 0) {
39
- console.error(pc.dim(stackOnly.join('\n')));
40
- }
41
- }
29
+ printErrorDetails(err);
30
+ process.exit(1);
31
+ });
32
+ process.on('unhandledRejection', (reason) => {
33
+ printErrorDetails(reason);
42
34
  process.exit(1);
43
35
  });
44
36
  // ─── login command ───────────────────────────────────────────────────────────
@@ -77,8 +69,8 @@ cli
77
69
  }
78
70
  // Non-interactive: --provider + --key or stdin
79
71
  if (options.provider) {
80
- // ChatGPT uses browser OAuth — skip key reading
81
- if (options.provider === 'chatgpt') {
72
+ // OAuth providers use browser flow — skip key reading
73
+ if (options.provider === 'chatgpt' || options.provider === 'xai-oauth') {
82
74
  await loginNonInteractive({ provider: options.provider, key: '' });
83
75
  return;
84
76
  }
@@ -146,7 +138,7 @@ cli
146
138
  from the model ID: imagen-* uses the image API, everything else uses
147
139
  the text API with image output enabled.
148
140
  `)
149
- .option('-m, --model [model]', z.enum(IMAGE_MODELS).describe('Model ID for generation. If omitted, shows an interactive picker (or uses default in non-TTY mode)'))
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)'))
150
142
  .option('-o, --output [path]', z
151
143
  .string()
152
144
  .default('egaki-output.png')
@@ -165,6 +157,18 @@ cli
165
157
  .option('--mask [file]', z
166
158
  .string()
167
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)'))
168
172
  .option('--allow-people', 'Allow generating images of people (Imagen blocks people by default)')
169
173
  .option('--json', 'Output result metadata as JSON to stdout (model, usage, warnings, file paths)')
170
174
  .option('--stdout', 'Write raw image bytes to stdout instead of saving to a file. Useful for piping to other tools')
@@ -234,6 +238,10 @@ cli
234
238
  inputImages,
235
239
  maskImage,
236
240
  allowPeople: options.allowPeople || false,
241
+ quality: options.quality,
242
+ resolution: options.resolution,
243
+ outputFormat: options.outputFormat,
244
+ negativePrompt: options.negativePrompt,
237
245
  json: options.json || false,
238
246
  stdout: options.stdout || false,
239
247
  });
@@ -260,7 +268,7 @@ cli
260
268
  Agent note: video generation can be slow. When invoking this command from
261
269
  automation, use a command timeout of at least 5 minutes.
262
270
  `)
263
- .option('-m, --model [model]', z.enum(VIDEO_MODELS).describe('Video model ID for generation. If omitted, shows an interactive picker (or uses default in non-TTY mode)'))
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)'))
264
272
  .option('-o, --output [path]', z
265
273
  .string()
266
274
  .default('egaki-output.mp4')
@@ -269,13 +277,22 @@ cli
269
277
  .option('--aspect-ratio [ratio]', z.string().describe('Video aspect ratio in WIDTH:HEIGHT format (e.g. 16:9, 9:16)'))
270
278
  .option('--resolution [resolution]', z
271
279
  .string()
272
- .describe('Video resolution in WIDTHxHEIGHT or provider format (e.g. 1280x720, 720p)'))
273
- .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'))
274
282
  .option('--fps [fps]', z.number().describe('Frames per second for video models that support fps override'))
275
283
  .option('--seed [seed]', z.number().describe('Seed for reproducible video generation (model support varies)'))
276
284
  .option('-i, --input [file]', z
277
285
  .string()
278
- .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)'))
279
296
  .option('--json', 'Output result metadata as JSON to stdout (model, usage, warnings, file paths)')
280
297
  .option('--stdout', 'Write raw video bytes to stdout instead of saving to a file. Useful for piping to other tools')
281
298
  .example('# Generate a video')
@@ -303,9 +320,34 @@ cli
303
320
  console.error(pc.dim(`Prompt: ${prompt}`));
304
321
  console.error(pc.dim('Mode: Video API (experimental_generateVideo)'));
305
322
  }
306
- const inputImage = options.input
307
- ? await readInputSource(options.input)
308
- : 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
+ }
309
351
  await generateWithVideoModel({
310
352
  prompt,
311
353
  model,
@@ -317,6 +359,10 @@ cli
317
359
  fps: options.fps,
318
360
  seed: options.seed,
319
361
  inputImage,
362
+ mode: options.mode,
363
+ videoUrl,
364
+ referenceImages: resolvedReferenceImages,
365
+ negativePrompt: options.negativePrompt,
320
366
  json: options.json || false,
321
367
  stdout: options.stdout || false,
322
368
  });
@@ -337,9 +383,9 @@ cli
337
383
  const yaml = await import('js-yaml');
338
384
  const providerStatuses = Object.fromEntries(Object.keys(PROVIDERS).map((provider) => [provider, getKeyStatus(provider)]));
339
385
  let models = options.type === 'video'
340
- ? VIDEO_CATALOG
386
+ ? [...VIDEO_CATALOG]
341
387
  : options.type === 'image'
342
- ? CATALOG
388
+ ? [...CATALOG]
343
389
  : [...CATALOG, ...VIDEO_CATALOG];
344
390
  if (options.provider) {
345
391
  models = models.filter((m) => m.provider === options.provider);
@@ -386,9 +432,130 @@ cli
386
432
  console.log(yaml.dump(output, { lineWidth: 120, noRefs: true }));
387
433
  }
388
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;
389
439
  cli.help();
390
440
  cli.version(pkg.version);
391
- 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
+ }
392
559
  // ─── interactive model pickers ──────────────────────────────────────────────
393
560
  /** Curated image models shown in the interactive picker, ordered by recommendation. */
394
561
  const FEATURED_IMAGE_MODELS = [
@@ -470,6 +637,64 @@ async function resolveVideoModel() {
470
637
  function isUrl(input) {
471
638
  return /^https?:\/\//i.test(input);
472
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
+ }
473
698
  async function readInputSource(input) {
474
699
  if (isUrl(input)) {
475
700
  console.error(pc.dim(`Fetching ${input}...`));
@@ -493,72 +718,159 @@ async function readInputImages(inputs) {
493
718
  }
494
719
  return Promise.all(inputs.map((f) => readInputSource(f)));
495
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
+ }
496
819
  // Generate using the dedicated generateImage API (Imagen models)
497
- 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, }) {
498
821
  // Build the prompt: plain string or multimodal with reference images
499
822
  const imagePrompt = inputImages.length > 0
500
823
  ? { text: prompt, images: inputImages, ...(maskImage ? { mask: maskImage } : {}) }
501
824
  : prompt;
502
825
  const config = getModelConfig(model);
503
826
  const imageModel = await createImageModel(model);
504
- // Use the correct providerOptions key based on provider (google vs vertex)
505
- 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
+ });
506
837
  if (!stdout) {
507
838
  console.error(pc.cyan('Generating...'));
508
839
  }
509
- const result = await aiGenerateImage({
840
+ const result = await runProviderCall('Image generation', () => aiGenerateImage({
510
841
  model: imageModel,
511
842
  prompt: imagePrompt,
512
843
  n: count,
513
844
  ...(aspectRatio ? { aspectRatio } : {}),
514
845
  ...(seed !== undefined ? { seed } : {}),
515
- providerOptions: {
516
- [providerOptionsKey]: {
517
- ...(allowPeople ? { personGeneration: 'allow_all' } : {}),
518
- ...(aspectRatio ? { aspectRatio } : {}),
519
- },
520
- },
521
- });
846
+ providerOptions,
847
+ }));
848
+ const files = result.images.map((img) => ({
849
+ uint8Array: img.uint8Array,
850
+ mediaType: img.mediaType,
851
+ }));
522
852
  if (stdout) {
523
- // Write first image to stdout for piping
524
- const image = result.images[0];
525
- if (image) {
526
- process.stdout.write(Buffer.from(image.uint8Array));
527
- }
853
+ writeFirstToStdout(files);
528
854
  return;
529
855
  }
530
- const savedFiles = [];
531
- for (let i = 0; i < result.images.length; i++) {
532
- const image = result.images[i];
533
- const ext = extensionFromMediaType(image.mediaType);
534
- const filePath = result.images.length === 1
535
- ? ensureExtension(outputPath, ext)
536
- : insertIndex(outputPath, i, ext);
537
- fs.writeFileSync(filePath, image.uint8Array);
538
- console.error(pc.green(`Saved: ${filePath}`));
539
- savedFiles.push(filePath);
540
- }
856
+ const savedFiles = saveGeneratedFiles(files, outputPath);
541
857
  const cost = calculateCost(config.cost, result.usage, result.images.length);
542
- if (cost != null) {
543
- console.error(pc.dim(`Cost: ${formatCost(cost)}`));
544
- }
858
+ printCost(cost);
545
859
  if (json) {
546
- const output = {
860
+ console.log(JSON.stringify({
547
861
  model,
548
862
  files: savedFiles,
549
863
  count: result.images.length,
550
864
  cost,
551
865
  usage: result.usage,
552
866
  warnings: result.warnings,
553
- };
554
- console.log(JSON.stringify(output, null, 2));
867
+ }, null, 2));
555
868
  }
556
869
  }
557
870
  // Generate using generateText with responseModalities (Gemini multimodal models)
558
871
  async function generateWithTextModel({ prompt, model, outputPath, inputImages, imageSize, aspectRatio, json, stdout, }) {
559
872
  const textModel = await createTextModel(model);
560
873
  const config = getModelConfig(model);
561
- const providerOptionsKey = config.provider === 'vertex' ? 'vertex' : 'google';
562
874
  if (!stdout) {
563
875
  console.error(pc.cyan('Generating...'));
564
876
  }
@@ -577,65 +889,49 @@ async function generateWithTextModel({ prompt, model, outputPath, inputImages, i
577
889
  },
578
890
  ]
579
891
  : undefined;
580
- 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({
581
905
  model: textModel,
582
906
  ...(messages ? { messages } : { prompt }),
583
907
  providerOptions: {
584
- [providerOptionsKey]: {
585
- responseModalities: ['TEXT', 'IMAGE'],
586
- ...(imageSize || aspectRatio
587
- ? {
588
- imageConfig: {
589
- ...(imageSize ? { imageSize } : {}),
590
- ...(aspectRatio ? { aspectRatio } : {}),
591
- },
592
- }
593
- : {}),
594
- },
908
+ [providerOptionsKey]: googleOpts,
595
909
  },
596
- });
597
- const imageFiles = result.files.filter((f) => {
598
- return f.mediaType.startsWith('image/');
599
- });
910
+ }));
911
+ const imageFiles = result.files.filter((f) => f.mediaType.startsWith('image/'));
600
912
  if (imageFiles.length === 0) {
601
913
  console.error(pc.red('No images generated.'));
602
914
  process.exit(1);
603
915
  }
604
916
  if (stdout) {
605
- const file = imageFiles[0];
606
- if (file) {
607
- process.stdout.write(Buffer.from(file.uint8Array));
608
- }
917
+ writeFirstToStdout(imageFiles);
609
918
  return;
610
919
  }
611
- const savedFiles = [];
612
- for (let i = 0; i < imageFiles.length; i++) {
613
- const file = imageFiles[i];
614
- const ext = extensionFromMediaType(file.mediaType);
615
- const filePath = imageFiles.length === 1
616
- ? ensureExtension(outputPath, ext)
617
- : insertIndex(outputPath, i, ext);
618
- fs.writeFileSync(filePath, file.uint8Array);
619
- console.error(pc.green(`Saved: ${filePath}`));
620
- savedFiles.push(filePath);
621
- }
920
+ const savedFiles = saveGeneratedFiles(imageFiles, outputPath);
622
921
  const cost = calculateCost(config.cost, result.usage, imageFiles.length);
623
- if (cost != null) {
624
- console.error(pc.dim(`Cost: ${formatCost(cost)}`));
625
- }
922
+ printCost(cost);
626
923
  if (result.text && !json) {
627
924
  console.error(pc.dim(result.text));
628
925
  }
629
926
  if (json) {
630
- const output = {
927
+ console.log(JSON.stringify({
631
928
  model,
632
929
  files: savedFiles,
633
930
  count: imageFiles.length,
634
931
  text: result.text || null,
635
932
  cost,
636
933
  usage: result.usage,
637
- };
638
- console.log(JSON.stringify(output, null, 2));
934
+ }, null, 2));
639
935
  }
640
936
  }
641
937
  // Generate using the OpenAI Responses API with imageGeneration tool.
@@ -781,12 +1077,28 @@ async function generateWithResponsesApi({ prompt, model, outputPath, count, aspe
781
1077
  }
782
1078
  }
783
1079
  // Generate using experimental_generateVideo (video models)
784
- 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, }) {
785
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
+ });
786
1098
  if (!stdout) {
787
1099
  console.error(pc.cyan('Generating...'));
788
1100
  }
789
- const result = await aiGenerateVideo({
1101
+ const result = await runProviderCall('Video generation', () => aiGenerateVideo({
790
1102
  model: videoModel,
791
1103
  prompt: inputImage
792
1104
  ? { image: inputImage, text: prompt }
@@ -797,44 +1109,67 @@ async function generateWithVideoModel({ prompt, model, outputPath, count, aspect
797
1109
  ...(duration != null ? { duration } : {}),
798
1110
  ...(fps != null ? { fps } : {}),
799
1111
  ...(seed != null ? { seed } : {}),
800
- });
1112
+ ...(providerOptions ? { providerOptions } : {}),
1113
+ }));
1114
+ const files = result.videos.map((v) => ({
1115
+ uint8Array: v.uint8Array,
1116
+ mediaType: v.mediaType,
1117
+ }));
801
1118
  if (stdout) {
802
- const video = result.videos[0];
803
- if (video) {
804
- process.stdout.write(Buffer.from(video.uint8Array));
805
- }
1119
+ writeFirstToStdout(files);
806
1120
  return;
807
1121
  }
808
- const savedFiles = [];
809
- for (let i = 0; i < result.videos.length; i++) {
810
- const video = result.videos[i];
811
- const ext = extensionFromMediaType(video.mediaType);
812
- const filePath = result.videos.length === 1
813
- ? ensureExtension(outputPath, ext)
814
- : insertIndex(outputPath, i, ext);
815
- fs.writeFileSync(filePath, video.uint8Array);
816
- console.error(pc.green(`Saved: ${filePath}`));
817
- savedFiles.push(filePath);
818
- }
819
- const config = getModelConfig(model);
1122
+ const savedFiles = saveGeneratedFiles(files, outputPath);
820
1123
  const cost = calculateCost(config.cost, {
821
1124
  videosGenerated: result.videos.length,
822
1125
  durationSeconds: duration,
823
1126
  resolution,
824
1127
  }, result.videos.length);
825
- if (cost != null) {
826
- console.error(pc.dim(`Cost: ${formatCost(cost)}`));
827
- }
1128
+ printCost(cost);
828
1129
  if (json) {
829
- const output = {
1130
+ console.log(JSON.stringify({
830
1131
  model,
831
1132
  files: savedFiles,
832
1133
  count: result.videos.length,
833
1134
  cost,
834
1135
  warnings: result.warnings,
835
1136
  responses: result.responses,
836
- };
837
- 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));
838
1173
  }
839
1174
  }
840
1175
  // ─── cost helpers ────────────────────────────────────────────────────────────