egaki 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +57 -0
- package/README.md +107 -3
- package/dist/cli.js +459 -124
- package/dist/credentials.d.ts +11 -2
- package/dist/credentials.d.ts.map +1 -1
- package/dist/credentials.js +68 -5
- package/dist/login.d.ts.map +1 -1
- package/dist/login.js +45 -4
- package/dist/model-catalog.d.ts +96 -3
- package/dist/model-catalog.d.ts.map +1 -1
- package/dist/model-catalog.js +427 -3
- package/dist/models.d.ts +4 -4
- package/dist/models.d.ts.map +1 -1
- package/dist/models.js +162 -96
- package/dist/video-model-catalog.d.ts +1 -51
- package/dist/video-model-catalog.d.ts.map +1 -1
- package/dist/video-model-catalog.js +5 -306
- package/dist/xai-auth.d.ts +29 -0
- package/dist/xai-auth.d.ts.map +1 -0
- package/dist/xai-auth.js +325 -0
- package/package.json +5 -4
- package/src/cli.ts +621 -133
- package/src/credentials.ts +70 -7
- package/src/login.ts +48 -3
- package/src/model-catalog.ts +537 -7
- package/src/models.ts +239 -119
- package/src/video-model-catalog.ts +14 -363
- package/src/xai-auth.ts +396 -0
package/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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
//
|
|
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.
|
|
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.
|
|
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(
|
|
273
|
-
.option('--duration [seconds]', z.number().describe('Video duration in seconds
|
|
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('
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
//
|
|
505
|
-
|
|
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
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
|
|
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
|
-
|
|
543
|
-
console.error(pc.dim(`Cost: ${formatCost(cost)}`));
|
|
544
|
-
}
|
|
858
|
+
printCost(cost);
|
|
545
859
|
if (json) {
|
|
546
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
826
|
-
console.error(pc.dim(`Cost: ${formatCost(cost)}`));
|
|
827
|
-
}
|
|
1128
|
+
printCost(cost);
|
|
828
1129
|
if (json) {
|
|
829
|
-
|
|
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
|
-
|
|
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 ────────────────────────────────────────────────────────────
|