genmix 1.1.5 → 1.2.2
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/README.md +64 -7
- package/cli.js +128 -27
- package/demo/example-fal.js +82 -0
- package/generators/FalGenerator.js +332 -0
- package/index.js +3 -0
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 🎨 GenMix
|
|
2
2
|
|
|
3
|
-
AI-powered image generator
|
|
3
|
+
AI-powered image generator supporting Google Gemini and Fal Nano Banana 2. Supports image generation from text prompts and image modification with reference images (Gemini).
|
|
4
4
|
|
|
5
5
|
## Features ✨
|
|
6
6
|
|
|
@@ -30,8 +30,11 @@ Create a `.env` file in your project root:
|
|
|
30
30
|
|
|
31
31
|
```env
|
|
32
32
|
GEMINI_API_KEY=your_api_key_here
|
|
33
|
+
FAL_API_KEY=your_fal_api_key_here
|
|
33
34
|
```
|
|
34
35
|
|
|
36
|
+
`GEMINI_API_KEY` is used with provider `gemini` and `FAL_API_KEY` is used with provider `fal`.
|
|
37
|
+
|
|
35
38
|
## Basic Usage
|
|
36
39
|
|
|
37
40
|
### The Power of GenMix: Multiple References & Chainable API
|
|
@@ -51,6 +54,26 @@ const result = await generator
|
|
|
51
54
|
await generator.save({ filename: 'composite-result' });
|
|
52
55
|
```
|
|
53
56
|
|
|
57
|
+
### Provider Selection (Gemini or Fal)
|
|
58
|
+
|
|
59
|
+
```javascript
|
|
60
|
+
import { GeminiGenerator, FalGenerator } from 'genmix';
|
|
61
|
+
|
|
62
|
+
const gemini = new GeminiGenerator({ apiKey: process.env.GEMINI_API_KEY });
|
|
63
|
+
const fal = new FalGenerator({ apiKey: process.env.FAL_API_KEY });
|
|
64
|
+
|
|
65
|
+
await gemini.flash().generate('A cinematic portrait with dramatic lighting');
|
|
66
|
+
await fal.generate('A cinematic portrait with dramatic lighting', {
|
|
67
|
+
numberOfImages: 1,
|
|
68
|
+
quality: '1K',
|
|
69
|
+
aspectRatio: '1:1'
|
|
70
|
+
});
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Fal models available in this integration:
|
|
74
|
+
- `flash` (or `banana2`) -> `fal-ai/nano-banana-2/edit` (image-to-image editing)
|
|
75
|
+
- `pro` (or `banana-pro`) -> `fal-ai/nano-banana-pro/edit` (image-to-image editing)
|
|
76
|
+
|
|
54
77
|
### Model Selection
|
|
55
78
|
|
|
56
79
|
You can easily switch between the Pro and Flash models using chainable methods:
|
|
@@ -134,6 +157,13 @@ new GeminiGenerator({
|
|
|
134
157
|
})
|
|
135
158
|
```
|
|
136
159
|
|
|
160
|
+
```javascript
|
|
161
|
+
new FalGenerator({
|
|
162
|
+
apiKey: string, // Your Fal API key (required)
|
|
163
|
+
modelId: string // Optional: FalGenerator.MODELS.BANANA_2 or BANANA_PRO_EDIT
|
|
164
|
+
})
|
|
165
|
+
```
|
|
166
|
+
|
|
137
167
|
### Model Selection Methods
|
|
138
168
|
|
|
139
169
|
```javascript
|
|
@@ -142,6 +172,15 @@ generator.flash() // Switches to the gemini-3.1-flash-image-preview model
|
|
|
142
172
|
```
|
|
143
173
|
Both methods are chainable and return the generator instance.
|
|
144
174
|
|
|
175
|
+
Fal generator model methods:
|
|
176
|
+
|
|
177
|
+
```javascript
|
|
178
|
+
fal.banana2() // fal-ai/nano-banana-2/edit (image editing)
|
|
179
|
+
fal.bananaPro() // fal-ai/nano-banana-pro/edit (image editing)
|
|
180
|
+
fal.pro() // alias of bananaPro()
|
|
181
|
+
fal.flash() // alias of banana2()
|
|
182
|
+
```
|
|
183
|
+
|
|
145
184
|
### Reference Methods
|
|
146
185
|
|
|
147
186
|
You can also use chainable methods to add one or multiple reference images before calling `generate()`:
|
|
@@ -151,6 +190,8 @@ generator.addReference(image, description) // Adds a reference image (path, URL,
|
|
|
151
190
|
generator.clearReferences() // Removes all queued reference images
|
|
152
191
|
```
|
|
153
192
|
|
|
193
|
+
For `fal` (`flash` and `pro`), references can be URL, data URI, local file path, or Buffer.
|
|
194
|
+
|
|
154
195
|
### generate() Method
|
|
155
196
|
|
|
156
197
|
```javascript
|
|
@@ -323,7 +364,8 @@ try {
|
|
|
323
364
|
genmix/
|
|
324
365
|
└── generators/
|
|
325
366
|
│ ├── BaseGenerator.js # Base class with utilities
|
|
326
|
-
│
|
|
367
|
+
│ ├── GeminiGenerator.js # Gemini API implementation
|
|
368
|
+
│ └── FalGenerator.js # Fal Nano Banana 2 implementation
|
|
327
369
|
├── demo/
|
|
328
370
|
│ ├── example.js # Basic examples
|
|
329
371
|
│ └── example-translation.js # Translate image
|
|
@@ -376,8 +418,8 @@ genmix --config
|
|
|
376
418
|
```
|
|
377
419
|
|
|
378
420
|
API key resolution order in CLI:
|
|
379
|
-
1. `GEMINI_API_KEY`
|
|
380
|
-
2. Saved config (`~/.genmix/config.json`)
|
|
421
|
+
1. Provider-specific environment variable (`GEMINI_API_KEY` or `FAL_API_KEY`)
|
|
422
|
+
2. Saved config (`~/.genmix/config.json`) using `geminiApiKey`/`apiKey` or `falApiKey`
|
|
381
423
|
3. Interactive prompt (then persisted)
|
|
382
424
|
|
|
383
425
|
### Basic CLI commands
|
|
@@ -391,6 +433,12 @@ genmix "A futuristic city with flying cars, cyberpunk style"
|
|
|
391
433
|
|
|
392
434
|
# Generate multiple images
|
|
393
435
|
genmix "A cozy cabin in winter" -n 2 -q 2K -r 16:9 -m flash
|
|
436
|
+
|
|
437
|
+
# Use Fal Nano Banana 2 edit (flash) with reference
|
|
438
|
+
genmix "Restyle this room with warm sunset mood" --provider fal -m flash --ref "./room.jpg" -n 2 -q 2K -r 16:9
|
|
439
|
+
|
|
440
|
+
# Use Fal Nano Banana Pro (edit) with reference
|
|
441
|
+
genmix "make this scene cinematic" --provider fal -m banana-pro --ref "https://example.com/input.png"
|
|
394
442
|
```
|
|
395
443
|
|
|
396
444
|
### Output file path or directory
|
|
@@ -438,6 +486,11 @@ genmix "Restyle this room" --ref "./room.jpg:keep composition and camera angle"
|
|
|
438
486
|
genmix "Create product ad scene" \
|
|
439
487
|
--ref "./product.png:use as main subject" \
|
|
440
488
|
--ref "./bg.jpg:use as background mood"
|
|
489
|
+
|
|
490
|
+
# References for fal models (URL, data URI, or local path)
|
|
491
|
+
genmix "Edit this image for a magazine look" \
|
|
492
|
+
--provider fal -m flash \
|
|
493
|
+
--ref "./photo.png"
|
|
441
494
|
```
|
|
442
495
|
|
|
443
496
|
### CLI options
|
|
@@ -445,13 +498,15 @@ genmix "Create product ad scene" \
|
|
|
445
498
|
```text
|
|
446
499
|
-n, --number <N> Number of images (default: 1)
|
|
447
500
|
-q, --quality <1K|2K|4K> Image quality (default: 1K)
|
|
448
|
-
-
|
|
449
|
-
-
|
|
501
|
+
-p, --provider <gemini|fal> Provider (default: gemini)
|
|
502
|
+
-r, --ratio <ratio> Aspect ratio (default: 1:1 for gemini, auto for fal)
|
|
503
|
+
-m, --model <...> gemini: pro|flash (default: flash)
|
|
504
|
+
fal: pro|flash (aliases: banana-pro|banana2|2, default: flash)
|
|
450
505
|
-o, --output <path> Output directory or full output file path
|
|
451
506
|
-f, --format <format> Output format when output is a directory (default: jpg)
|
|
452
507
|
--width <px> Final output width in pixels (requires --height)
|
|
453
508
|
--height <px> Final output height in pixels (requires --width)
|
|
454
|
-
--ref <path:text>
|
|
509
|
+
--ref <path[:text]> Reference image (path/URL/data URI); for URL descriptions use URL::description
|
|
455
510
|
--no-sharp Save raw model bytes without Sharp conversion (disables resizing)
|
|
456
511
|
--config Set/update persisted API key
|
|
457
512
|
--help Show help
|
|
@@ -461,6 +516,8 @@ genmix "Create product ad scene" \
|
|
|
461
516
|
|
|
462
517
|
- [Code Examples](./demo/)
|
|
463
518
|
- [Google Gemini API Documentation](https://ai.google.dev/)
|
|
519
|
+
- [Fal Nano Banana 2 Edit Documentation](https://fal.ai/models/fal-ai/nano-banana-2/edit/api)
|
|
520
|
+
- [Fal Nano Banana Pro Edit Documentation](https://fal.ai/models/fal-ai/nano-banana-pro/edit/api)
|
|
464
521
|
|
|
465
522
|
## License
|
|
466
523
|
|
package/cli.js
CHANGED
|
@@ -5,6 +5,7 @@ const path = require('path');
|
|
|
5
5
|
const os = require('os');
|
|
6
6
|
const readline = require('readline');
|
|
7
7
|
const GeminiGenerator = require('./generators/GeminiGenerator');
|
|
8
|
+
const FalGenerator = require('./generators/FalGenerator');
|
|
8
9
|
|
|
9
10
|
const CONFIG_DIR = path.join(os.homedir(), '.genmix');
|
|
10
11
|
const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
|
|
@@ -72,6 +73,14 @@ async function promptApiKey() {
|
|
|
72
73
|
return apiKey;
|
|
73
74
|
}
|
|
74
75
|
|
|
76
|
+
async function promptFalApiKey() {
|
|
77
|
+
const apiKey = (await promptQuestion('Enter your Fal API key: ')).trim();
|
|
78
|
+
if (!apiKey) {
|
|
79
|
+
throw new Error('API key cannot be empty.');
|
|
80
|
+
}
|
|
81
|
+
return apiKey;
|
|
82
|
+
}
|
|
83
|
+
|
|
75
84
|
function printHelp() {
|
|
76
85
|
console.log(`
|
|
77
86
|
Usage:
|
|
@@ -80,21 +89,27 @@ Usage:
|
|
|
80
89
|
genmix --help
|
|
81
90
|
|
|
82
91
|
Options:
|
|
92
|
+
-p, --provider <gemini|fal> Provider (default: gemini)
|
|
83
93
|
-n, --number <N> Number of images (default: 1)
|
|
84
94
|
-q, --quality <1K|2K|4K> Image quality (default: 1K)
|
|
85
|
-
-r, --ratio <ratio> Aspect ratio (default: 1:1)
|
|
95
|
+
-r, --ratio <ratio> Aspect ratio (default: 1:1 for gemini, auto for fal)
|
|
86
96
|
--width <px> Final output width in pixels (requires --height)
|
|
87
97
|
--height <px> Final output height in pixels (requires --width)
|
|
88
|
-
-m, --model
|
|
98
|
+
-m, --model <...> Model by provider:
|
|
99
|
+
gemini -> pro|flash (default: flash)
|
|
100
|
+
fal -> pro|flash (aliases: banana-pro|banana2|2, default: flash)
|
|
89
101
|
-o, --output <path> Output directory OR full output file path
|
|
90
102
|
-f, --format <format> Output format when output is a directory (default: jpg)
|
|
91
103
|
--no-sharp Save raw model bytes without Sharp conversion
|
|
92
|
-
--ref <path:text>
|
|
104
|
+
--ref <path[:text]> Reference image; optional description after ":" (repeatable)
|
|
105
|
+
For URLs, use plain URL or URL::description
|
|
93
106
|
--help Show this help message
|
|
94
107
|
--config Set or update persisted API key
|
|
95
108
|
|
|
96
109
|
Examples:
|
|
97
110
|
genmix "cyberpunk city at night"
|
|
111
|
+
genmix "restyle this room" --provider fal --ref ./room.jpg
|
|
112
|
+
genmix "edit this image with sunset light" --provider fal -m banana-pro --ref "https://example.com/photo.jpg"
|
|
98
113
|
genmix "logo in watercolor style" -n 2 -q 2K -o ./output
|
|
99
114
|
genmix "app icon" -q 4K --width 400 --height 400 --output ./renders/icon.png
|
|
100
115
|
genmix "new version of this room" --ref room.jpg:"keep composition"
|
|
@@ -103,13 +118,26 @@ Examples:
|
|
|
103
118
|
}
|
|
104
119
|
|
|
105
120
|
function parseRefValue(value) {
|
|
106
|
-
const
|
|
121
|
+
const raw = value.trim();
|
|
122
|
+
const explicitSeparatorIndex = raw.indexOf('::');
|
|
123
|
+
if (explicitSeparatorIndex !== -1) {
|
|
124
|
+
return {
|
|
125
|
+
imagePath: raw.slice(0, explicitSeparatorIndex).trim(),
|
|
126
|
+
description: raw.slice(explicitSeparatorIndex + 2).trim()
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (/^https?:\/\//i.test(raw)) {
|
|
131
|
+
return { imagePath: raw, description: '' };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const firstColonIndex = raw.indexOf(':');
|
|
107
135
|
if (firstColonIndex === -1) {
|
|
108
|
-
return { imagePath:
|
|
136
|
+
return { imagePath: raw, description: '' };
|
|
109
137
|
}
|
|
110
138
|
|
|
111
|
-
const imagePath =
|
|
112
|
-
const description =
|
|
139
|
+
const imagePath = raw.slice(0, firstColonIndex).trim();
|
|
140
|
+
const description = raw.slice(firstColonIndex + 1).trim();
|
|
113
141
|
return { imagePath, description };
|
|
114
142
|
}
|
|
115
143
|
|
|
@@ -117,11 +145,13 @@ function parseArgs(argv) {
|
|
|
117
145
|
const parsed = {
|
|
118
146
|
promptParts: [],
|
|
119
147
|
references: [],
|
|
148
|
+
provider: 'gemini',
|
|
120
149
|
numberOfImages: 1,
|
|
121
150
|
quality: '1K',
|
|
122
151
|
aspectRatio: null,
|
|
123
152
|
aspectRatioWasProvided: false,
|
|
124
|
-
model:
|
|
153
|
+
model: null,
|
|
154
|
+
modelWasProvided: false,
|
|
125
155
|
output: '.',
|
|
126
156
|
format: 'jpg',
|
|
127
157
|
targetWidth: null,
|
|
@@ -159,6 +189,13 @@ function parseArgs(argv) {
|
|
|
159
189
|
continue;
|
|
160
190
|
}
|
|
161
191
|
|
|
192
|
+
if (arg === '-p' || arg === '--provider') {
|
|
193
|
+
if (!next) throw new Error(`${arg} requires a value.`);
|
|
194
|
+
parsed.provider = String(next).toLowerCase();
|
|
195
|
+
i += 1;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
|
|
162
199
|
if (arg === '-r' || arg === '--ratio') {
|
|
163
200
|
if (!next) throw new Error(`${arg} requires a value.`);
|
|
164
201
|
parsed.aspectRatio = next;
|
|
@@ -184,6 +221,7 @@ function parseArgs(argv) {
|
|
|
184
221
|
if (arg === '-m' || arg === '--model') {
|
|
185
222
|
if (!next) throw new Error(`${arg} requires a value.`);
|
|
186
223
|
parsed.model = String(next).toLowerCase();
|
|
224
|
+
parsed.modelWasProvided = true;
|
|
187
225
|
i += 1;
|
|
188
226
|
continue;
|
|
189
227
|
}
|
|
@@ -235,8 +273,24 @@ function parseArgs(argv) {
|
|
|
235
273
|
}
|
|
236
274
|
parsed.quality = quality;
|
|
237
275
|
|
|
238
|
-
if (!['
|
|
239
|
-
throw new Error('
|
|
276
|
+
if (!['gemini', 'fal'].includes(parsed.provider)) {
|
|
277
|
+
throw new Error('Provider must be "gemini" or "fal".');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (parsed.provider === 'gemini' && !parsed.modelWasProvided) {
|
|
281
|
+
parsed.model = 'flash';
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (parsed.provider === 'fal' && !parsed.modelWasProvided) {
|
|
285
|
+
parsed.model = 'flash';
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (parsed.provider === 'gemini' && !['flash', 'pro'].includes(parsed.model)) {
|
|
289
|
+
throw new Error('Model must be "flash" or "pro" when provider is "gemini".');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (parsed.provider === 'fal' && !['flash', 'pro', 'banana2', 'banana-pro', '2'].includes(parsed.model)) {
|
|
293
|
+
throw new Error('Model must be "flash" or "pro" when provider is "fal" (aliases: banana2, banana-pro, 2).');
|
|
240
294
|
}
|
|
241
295
|
|
|
242
296
|
const hasOnlyOneDimension = (parsed.targetWidth === null) !== (parsed.targetHeight === null);
|
|
@@ -259,7 +313,7 @@ function parseArgs(argv) {
|
|
|
259
313
|
}
|
|
260
314
|
|
|
261
315
|
if (!parsed.aspectRatioWasProvided && !hasTargetDimensions) {
|
|
262
|
-
parsed.aspectRatio = '1:1';
|
|
316
|
+
parsed.aspectRatio = parsed.provider === 'fal' ? 'auto' : '1:1';
|
|
263
317
|
}
|
|
264
318
|
|
|
265
319
|
for (const ref of parsed.references) {
|
|
@@ -292,48 +346,95 @@ function resolveOutput(outputArg, format) {
|
|
|
292
346
|
};
|
|
293
347
|
}
|
|
294
348
|
|
|
295
|
-
async function ensureApiKey() {
|
|
349
|
+
async function ensureApiKey(provider = 'gemini') {
|
|
296
350
|
const config = loadConfig();
|
|
351
|
+
|
|
352
|
+
if (provider === 'fal') {
|
|
353
|
+
const envApiKey = process.env.FAL_API_KEY;
|
|
354
|
+
if (envApiKey && envApiKey.trim()) {
|
|
355
|
+
return envApiKey.trim();
|
|
356
|
+
}
|
|
357
|
+
if (config.falApiKey && config.falApiKey.trim()) {
|
|
358
|
+
return config.falApiKey.trim();
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
info('No Fal API key found. Let us configure it now.');
|
|
362
|
+
const newApiKey = await promptFalApiKey();
|
|
363
|
+
saveConfig({ ...config, falApiKey: newApiKey });
|
|
364
|
+
success(`Fal API key saved to ${CONFIG_PATH}`);
|
|
365
|
+
return newApiKey;
|
|
366
|
+
}
|
|
367
|
+
|
|
297
368
|
const envApiKey = process.env.GEMINI_API_KEY;
|
|
298
369
|
if (envApiKey && envApiKey.trim()) {
|
|
299
370
|
return envApiKey.trim();
|
|
300
371
|
}
|
|
372
|
+
if (config.geminiApiKey && config.geminiApiKey.trim()) {
|
|
373
|
+
return config.geminiApiKey.trim();
|
|
374
|
+
}
|
|
301
375
|
if (config.apiKey && config.apiKey.trim()) {
|
|
302
376
|
return config.apiKey.trim();
|
|
303
377
|
}
|
|
304
378
|
|
|
305
|
-
info('No API key found. Let us configure it now.');
|
|
379
|
+
info('No Gemini API key found. Let us configure it now.');
|
|
306
380
|
const newApiKey = await promptApiKey();
|
|
307
|
-
saveConfig({ ...config, apiKey: newApiKey });
|
|
308
|
-
success(`API key saved to ${CONFIG_PATH}`);
|
|
381
|
+
saveConfig({ ...config, apiKey: newApiKey, geminiApiKey: newApiKey });
|
|
382
|
+
success(`Gemini API key saved to ${CONFIG_PATH}`);
|
|
309
383
|
return newApiKey;
|
|
310
384
|
}
|
|
311
385
|
|
|
312
|
-
async function runConfigCommand() {
|
|
386
|
+
async function runConfigCommand(provider) {
|
|
313
387
|
const existing = loadConfig();
|
|
314
|
-
if (
|
|
315
|
-
|
|
388
|
+
if (provider === 'fal') {
|
|
389
|
+
if (existing.falApiKey) {
|
|
390
|
+
info(`Existing Fal API key found in ${CONFIG_PATH}.`);
|
|
391
|
+
} else {
|
|
392
|
+
info('No saved Fal API key found.');
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const newApiKey = await promptFalApiKey();
|
|
396
|
+
saveConfig({ ...existing, falApiKey: newApiKey });
|
|
397
|
+
success(`Fal API key saved to ${CONFIG_PATH}`);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (existing.apiKey || existing.geminiApiKey) {
|
|
402
|
+
info(`Existing Gemini API key found in ${CONFIG_PATH}.`);
|
|
316
403
|
} else {
|
|
317
|
-
info('No saved API key found.');
|
|
404
|
+
info('No saved Gemini API key found.');
|
|
318
405
|
}
|
|
319
406
|
|
|
320
407
|
const newApiKey = await promptApiKey();
|
|
321
|
-
saveConfig({ ...existing, apiKey: newApiKey });
|
|
322
|
-
success(`API key saved to ${CONFIG_PATH}`);
|
|
408
|
+
saveConfig({ ...existing, apiKey: newApiKey, geminiApiKey: newApiKey });
|
|
409
|
+
success(`Gemini API key saved to ${CONFIG_PATH}`);
|
|
323
410
|
}
|
|
324
411
|
|
|
325
412
|
async function runGeneration(args) {
|
|
326
|
-
const apiKey = await ensureApiKey();
|
|
327
|
-
const generator =
|
|
328
|
-
|
|
329
|
-
|
|
413
|
+
const apiKey = await ensureApiKey(args.provider);
|
|
414
|
+
const generator = args.provider === 'fal'
|
|
415
|
+
? new FalGenerator({ apiKey })
|
|
416
|
+
: new GeminiGenerator({ apiKey });
|
|
417
|
+
|
|
418
|
+
if (args.provider === 'gemini') {
|
|
419
|
+
if (args.model === 'pro') {
|
|
420
|
+
generator.pro();
|
|
421
|
+
} else {
|
|
422
|
+
generator.flash();
|
|
423
|
+
}
|
|
424
|
+
} else if (args.model === 'banana-pro' || args.model === 'pro') {
|
|
330
425
|
generator.pro();
|
|
331
426
|
} else {
|
|
332
427
|
generator.flash();
|
|
333
428
|
}
|
|
334
429
|
|
|
430
|
+
if (args.provider === 'fal' && args.references.length === 0) {
|
|
431
|
+
throw new Error('Fal edit models require at least one --ref input image.');
|
|
432
|
+
}
|
|
433
|
+
|
|
335
434
|
for (const ref of args.references) {
|
|
336
|
-
generator.addReference
|
|
435
|
+
if (typeof generator.addReference === 'function') {
|
|
436
|
+
generator.addReference(ref.imagePath, ref.description);
|
|
437
|
+
}
|
|
337
438
|
}
|
|
338
439
|
|
|
339
440
|
info('Generating image(s)...');
|
|
@@ -375,7 +476,7 @@ async function main() {
|
|
|
375
476
|
}
|
|
376
477
|
|
|
377
478
|
if (args.runConfig) {
|
|
378
|
-
await runConfigCommand();
|
|
479
|
+
await runConfigCommand(args.provider);
|
|
379
480
|
return;
|
|
380
481
|
}
|
|
381
482
|
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
try { process.loadEnvFile(); } catch (error) { }
|
|
2
|
+
import { FalGenerator } from '../index.js';
|
|
3
|
+
|
|
4
|
+
async function runFalFlashExample() {
|
|
5
|
+
console.log('\n=== Fal Example 1: Nano Banana 2 (flash) ===\n');
|
|
6
|
+
|
|
7
|
+
const referenceUrl = process.env.FAL_DEMO_IMAGE_URL;
|
|
8
|
+
if (!referenceUrl) {
|
|
9
|
+
console.log('ℹ️ Skipping flash edit example: set FAL_DEMO_IMAGE_URL in your .env with an image URL.');
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const generator = new FalGenerator();
|
|
14
|
+
generator.flash().addReference(referenceUrl, 'use as base image');
|
|
15
|
+
|
|
16
|
+
const result = await generator.generate(
|
|
17
|
+
'A futuristic city skyline at dusk, cinematic lighting, ultra detailed',
|
|
18
|
+
{
|
|
19
|
+
numberOfImages: 1,
|
|
20
|
+
quality: '1K',
|
|
21
|
+
aspectRatio: '16:9'
|
|
22
|
+
}
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
if (result.images && result.images.length > 0) {
|
|
26
|
+
const saved = await generator.save({
|
|
27
|
+
directory: import.meta.dirname,
|
|
28
|
+
filename: 'fal-flash-example'
|
|
29
|
+
});
|
|
30
|
+
console.log(`✅ Saved flash result: ${saved[0]}`);
|
|
31
|
+
} else {
|
|
32
|
+
console.log('⚠️ No images generated for flash example.');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function runFalProExample() {
|
|
37
|
+
console.log('\n=== Fal Example 2: Nano Banana Pro (edit) ===\n');
|
|
38
|
+
|
|
39
|
+
const referenceUrl = process.env.FAL_DEMO_IMAGE_URL;
|
|
40
|
+
if (!referenceUrl) {
|
|
41
|
+
console.log('ℹ️ Skipping pro edit example: set FAL_DEMO_IMAGE_URL in your .env with an image URL.');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const generator = new FalGenerator();
|
|
46
|
+
generator
|
|
47
|
+
.pro()
|
|
48
|
+
.addReference(referenceUrl, 'use as base image');
|
|
49
|
+
|
|
50
|
+
const result = await generator.generate(
|
|
51
|
+
'Turn this into a cinematic editorial shot with warm golden-hour color grading',
|
|
52
|
+
{
|
|
53
|
+
numberOfImages: 1,
|
|
54
|
+
quality: '1K'
|
|
55
|
+
}
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
if (result.images && result.images.length > 0) {
|
|
59
|
+
const saved = await generator.save({
|
|
60
|
+
directory: import.meta.dirname,
|
|
61
|
+
filename: 'fal-pro-edit-example'
|
|
62
|
+
});
|
|
63
|
+
console.log(`✅ Saved pro edit result: ${saved[0]}`);
|
|
64
|
+
} else {
|
|
65
|
+
console.log('⚠️ No images generated for pro edit example.');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function main() {
|
|
70
|
+
try {
|
|
71
|
+
await runFalFlashExample();
|
|
72
|
+
await runFalProExample();
|
|
73
|
+
console.log('\n🎉 Fal examples completed!\n');
|
|
74
|
+
} catch (error) {
|
|
75
|
+
console.error('❌ Error:', error.message);
|
|
76
|
+
if (error.message.includes('API Key')) {
|
|
77
|
+
console.error('\n💡 Tip: Make sure you have FAL_API_KEY in your .env file');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
main();
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
const axios = require('axios');
|
|
2
|
+
const BaseGenerator = require('./BaseGenerator');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
class FalGenerator extends BaseGenerator {
|
|
7
|
+
static MODELS = {
|
|
8
|
+
BANANA_2: 'fal-ai/nano-banana-2/edit',
|
|
9
|
+
BANANA_PRO_EDIT: 'fal-ai/nano-banana-pro/edit'
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
static SUPPORTED_ASPECT_RATIOS_BY_MODEL = {
|
|
13
|
+
[FalGenerator.MODELS.BANANA_2]: new Set([
|
|
14
|
+
'auto',
|
|
15
|
+
'21:9',
|
|
16
|
+
'16:9',
|
|
17
|
+
'3:2',
|
|
18
|
+
'4:3',
|
|
19
|
+
'5:4',
|
|
20
|
+
'1:1',
|
|
21
|
+
'4:5',
|
|
22
|
+
'3:4',
|
|
23
|
+
'2:3',
|
|
24
|
+
'9:16',
|
|
25
|
+
'4:1',
|
|
26
|
+
'1:4',
|
|
27
|
+
'8:1',
|
|
28
|
+
'1:8'
|
|
29
|
+
]),
|
|
30
|
+
[FalGenerator.MODELS.BANANA_PRO_EDIT]: new Set([
|
|
31
|
+
'auto',
|
|
32
|
+
'21:9',
|
|
33
|
+
'16:9',
|
|
34
|
+
'3:2',
|
|
35
|
+
'4:3',
|
|
36
|
+
'5:4',
|
|
37
|
+
'1:1',
|
|
38
|
+
'4:5',
|
|
39
|
+
'3:4',
|
|
40
|
+
'2:3',
|
|
41
|
+
'9:16'
|
|
42
|
+
])
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
static SUPPORTED_RESOLUTIONS_BY_MODEL = {
|
|
46
|
+
[FalGenerator.MODELS.BANANA_2]: new Set(['0.5K', '1K', '2K', '4K']),
|
|
47
|
+
[FalGenerator.MODELS.BANANA_PRO_EDIT]: new Set(['1K', '2K', '4K'])
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
static COMMON_ASPECT_RATIOS = new Set([
|
|
51
|
+
'auto',
|
|
52
|
+
'21:9',
|
|
53
|
+
'16:9',
|
|
54
|
+
'3:2',
|
|
55
|
+
'4:3',
|
|
56
|
+
'5:4',
|
|
57
|
+
'1:1',
|
|
58
|
+
'4:5',
|
|
59
|
+
'3:4',
|
|
60
|
+
'2:3',
|
|
61
|
+
'9:16'
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
constructor(config = {}) {
|
|
65
|
+
super(config);
|
|
66
|
+
this.apiKey = config.apiKey || process.env.FAL_API_KEY;
|
|
67
|
+
if (!this.apiKey) {
|
|
68
|
+
throw new Error('API Key is required. Provide it in the constructor or set FAL_API_KEY environment variable.');
|
|
69
|
+
}
|
|
70
|
+
this.modelId = config.modelId || FalGenerator.MODELS.BANANA_2;
|
|
71
|
+
this.apiUrl = `https://fal.run/${this.modelId}`;
|
|
72
|
+
this.references = [];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
banana2() {
|
|
76
|
+
this.modelId = FalGenerator.MODELS.BANANA_2;
|
|
77
|
+
this.apiUrl = `https://fal.run/${this.modelId}`;
|
|
78
|
+
return this;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
bananaPro() {
|
|
82
|
+
this.modelId = FalGenerator.MODELS.BANANA_PRO_EDIT;
|
|
83
|
+
this.apiUrl = `https://fal.run/${this.modelId}`;
|
|
84
|
+
return this;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
pro() {
|
|
88
|
+
return this.bananaPro();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
flash() {
|
|
92
|
+
return this.banana2();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
addReference(image, description = '') {
|
|
96
|
+
this.references.push({ image, description });
|
|
97
|
+
return this;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
clearReferences() {
|
|
101
|
+
this.references = [];
|
|
102
|
+
return this;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
_gcd(a, b) {
|
|
106
|
+
let x = Math.abs(a);
|
|
107
|
+
let y = Math.abs(b);
|
|
108
|
+
while (y !== 0) {
|
|
109
|
+
const t = y;
|
|
110
|
+
y = x % y;
|
|
111
|
+
x = t;
|
|
112
|
+
}
|
|
113
|
+
return x || 1;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
_deriveAspectRatio(width, height) {
|
|
117
|
+
const divisor = this._gcd(width, height);
|
|
118
|
+
return `${width / divisor}:${height / divisor}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
_normalizeGenerateOptions(options = {}) {
|
|
122
|
+
const normalized = { ...options };
|
|
123
|
+
const hasWidth = normalized.width !== undefined && normalized.width !== null;
|
|
124
|
+
const hasHeight = normalized.height !== undefined && normalized.height !== null;
|
|
125
|
+
|
|
126
|
+
if (hasWidth !== hasHeight) {
|
|
127
|
+
throw new Error('Both options.width and options.height are required together.');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (hasWidth && hasHeight) {
|
|
131
|
+
const width = Number(normalized.width);
|
|
132
|
+
const height = Number(normalized.height);
|
|
133
|
+
|
|
134
|
+
if (!Number.isInteger(width) || width <= 0) {
|
|
135
|
+
throw new Error('options.width must be a positive integer.');
|
|
136
|
+
}
|
|
137
|
+
if (!Number.isInteger(height) || height <= 0) {
|
|
138
|
+
throw new Error('options.height must be a positive integer.');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const derivedRatio = this._deriveAspectRatio(width, height);
|
|
142
|
+
if (normalized.aspectRatio && normalized.aspectRatio !== derivedRatio) {
|
|
143
|
+
throw new Error(`Aspect ratio mismatch: options.aspectRatio ${normalized.aspectRatio} does not match options.width/options.height (${derivedRatio}).`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
normalized.width = width;
|
|
147
|
+
normalized.height = height;
|
|
148
|
+
normalized.aspectRatio = derivedRatio;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const supportedRatios = FalGenerator.SUPPORTED_ASPECT_RATIOS_BY_MODEL[this.modelId] || FalGenerator.COMMON_ASPECT_RATIOS;
|
|
152
|
+
if (normalized.aspectRatio && !supportedRatios.has(normalized.aspectRatio)) {
|
|
153
|
+
if (hasWidth && hasHeight) {
|
|
154
|
+
normalized.aspectRatio = 'auto';
|
|
155
|
+
} else {
|
|
156
|
+
throw new Error(`Unsupported aspect ratio for Fal provider: ${normalized.aspectRatio}.`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return normalized;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
_mapQualityToResolution(quality) {
|
|
164
|
+
const mapped = String(quality || '1K').toUpperCase();
|
|
165
|
+
const supportedResolutions = FalGenerator.SUPPORTED_RESOLUTIONS_BY_MODEL[this.modelId] || new Set(['1K', '2K', '4K']);
|
|
166
|
+
if (!supportedResolutions.has(mapped)) {
|
|
167
|
+
throw new Error(`options.quality must be one of: ${Array.from(supportedResolutions).join(', ')}.`);
|
|
168
|
+
}
|
|
169
|
+
return mapped;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
_isHttpUrl(value) {
|
|
173
|
+
return typeof value === 'string' && /^https?:\/\//i.test(value.trim());
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
_isDataUri(value) {
|
|
177
|
+
return typeof value === 'string' && /^data:image\/[a-zA-Z0-9.+-]+;base64,/.test(value.trim());
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
_detectMimeTypeFromPath(filePath) {
|
|
181
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
182
|
+
const mimeTypes = {
|
|
183
|
+
'.png': 'image/png',
|
|
184
|
+
'.jpg': 'image/jpeg',
|
|
185
|
+
'.jpeg': 'image/jpeg',
|
|
186
|
+
'.webp': 'image/webp',
|
|
187
|
+
'.gif': 'image/gif',
|
|
188
|
+
'.bmp': 'image/bmp',
|
|
189
|
+
'.tif': 'image/tiff',
|
|
190
|
+
'.tiff': 'image/tiff',
|
|
191
|
+
'.avif': 'image/avif'
|
|
192
|
+
};
|
|
193
|
+
return mimeTypes[ext] || 'image/png';
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
_toDataUriFromBuffer(buffer, mimeType = 'image/png') {
|
|
197
|
+
return `data:${mimeType};base64,${buffer.toString('base64')}`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async _normalizeImageReference(value) {
|
|
201
|
+
if (Buffer.isBuffer(value)) {
|
|
202
|
+
return this._toDataUriFromBuffer(value, 'image/png');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (typeof value !== 'string') {
|
|
206
|
+
throw new Error('Fal references must be URL, data URI, local file path, or Buffer.');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const trimmed = value.trim();
|
|
210
|
+
if (!trimmed) {
|
|
211
|
+
throw new Error('Fal reference cannot be empty.');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (this._isHttpUrl(trimmed) || this._isDataUri(trimmed)) {
|
|
215
|
+
return trimmed;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (!fs.existsSync(trimmed)) {
|
|
219
|
+
throw new Error(`Fal reference file not found: ${trimmed}`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const fileBuffer = fs.readFileSync(trimmed);
|
|
223
|
+
const mimeType = this._detectMimeTypeFromPath(trimmed);
|
|
224
|
+
return this._toDataUriFromBuffer(fileBuffer, mimeType);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async _collectImageUrls(options = {}) {
|
|
228
|
+
const rawReferences = [];
|
|
229
|
+
|
|
230
|
+
if (Array.isArray(options.imageUrls)) {
|
|
231
|
+
for (const value of options.imageUrls) {
|
|
232
|
+
if ((typeof value === 'string' && value.trim()) || Buffer.isBuffer(value)) {
|
|
233
|
+
rawReferences.push(value);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if ((typeof options.referenceImage === 'string' && options.referenceImage.trim()) || Buffer.isBuffer(options.referenceImage)) {
|
|
239
|
+
rawReferences.push(options.referenceImage);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
for (const ref of this.references) {
|
|
243
|
+
if ((typeof ref.image === 'string' && ref.image.trim()) || Buffer.isBuffer(ref.image)) {
|
|
244
|
+
rawReferences.push(ref.image);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const normalizedUrls = [];
|
|
249
|
+
for (const reference of rawReferences) {
|
|
250
|
+
normalizedUrls.push(await this._normalizeImageReference(reference));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const uniqueUrls = Array.from(new Set(normalizedUrls));
|
|
254
|
+
return uniqueUrls;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async generate(prompt, options = {}) {
|
|
258
|
+
if (!prompt || !String(prompt).trim()) {
|
|
259
|
+
throw new Error('Prompt is required.');
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const normalizedOptions = this._normalizeGenerateOptions(options);
|
|
263
|
+
const numberOfImages = normalizedOptions.numberOfImages || 1;
|
|
264
|
+
|
|
265
|
+
if (!Number.isInteger(numberOfImages) || numberOfImages < 1 || numberOfImages > 4) {
|
|
266
|
+
throw new Error('options.numberOfImages must be an integer between 1 and 4 for Fal Nano Banana models.');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const payload = {
|
|
270
|
+
prompt: String(prompt).trim(),
|
|
271
|
+
num_images: numberOfImages,
|
|
272
|
+
resolution: this._mapQualityToResolution(normalizedOptions.quality),
|
|
273
|
+
aspect_ratio: normalizedOptions.aspectRatio || 'auto'
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const imageUrls = await this._collectImageUrls(normalizedOptions);
|
|
277
|
+
if (imageUrls.length === 0) {
|
|
278
|
+
throw new Error('Fal Nano Banana edit models require at least one reference image. Use addReference() or options.referenceImage.');
|
|
279
|
+
}
|
|
280
|
+
payload.image_urls = imageUrls;
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
const response = await axios.post(this.apiUrl, payload, {
|
|
284
|
+
headers: {
|
|
285
|
+
Authorization: `Key ${this.apiKey}`,
|
|
286
|
+
'Content-Type': 'application/json'
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const result = await this.processResponse(response.data);
|
|
291
|
+
this.lastGeneration = {
|
|
292
|
+
prompt: String(prompt).trim(),
|
|
293
|
+
images: result.images,
|
|
294
|
+
text: result.text,
|
|
295
|
+
raw: result.raw,
|
|
296
|
+
formatOptions: normalizedOptions.width && normalizedOptions.height
|
|
297
|
+
? { width: normalizedOptions.width, height: normalizedOptions.height }
|
|
298
|
+
: null
|
|
299
|
+
};
|
|
300
|
+
this.references = [];
|
|
301
|
+
return result;
|
|
302
|
+
} catch (error) {
|
|
303
|
+
const apiError = error.response?.data;
|
|
304
|
+
const message = apiError?.detail || apiError?.error || error.message;
|
|
305
|
+
throw new Error(`Fal API Error: ${message}`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async processResponse(data) {
|
|
310
|
+
const imageEntries = Array.isArray(data?.images) ? data.images : [];
|
|
311
|
+
const images = [];
|
|
312
|
+
|
|
313
|
+
for (const entry of imageEntries) {
|
|
314
|
+
if (!entry || !entry.url) {
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const mediaResponse = await axios.get(entry.url, { responseType: 'arraybuffer' });
|
|
319
|
+
const contentType = entry.content_type || mediaResponse.headers['content-type'] || 'image/png';
|
|
320
|
+
const base64 = Buffer.from(mediaResponse.data).toString('base64');
|
|
321
|
+
images.push(`data:${contentType};base64,${base64}`);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return {
|
|
325
|
+
images,
|
|
326
|
+
text: data?.description || '',
|
|
327
|
+
raw: data
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
module.exports = FalGenerator;
|
package/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "genmix",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "AI-powered image generator
|
|
3
|
+
"version": "1.2.2",
|
|
4
|
+
"description": "AI-powered image generator supporting Google Gemini and Fal Nano Banana 2.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Martin Clasen",
|
|
7
7
|
"keywords": [
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
"flash",
|
|
12
12
|
"image-generator",
|
|
13
13
|
"google-gemini",
|
|
14
|
+
"fal-ai",
|
|
15
|
+
"nano-banana-2",
|
|
16
|
+
"nano-banana-pro",
|
|
14
17
|
"gemini-api",
|
|
15
18
|
"text-to-image",
|
|
16
19
|
"image-modification",
|