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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # 🎨 GenMix
2
2
 
3
- AI-powered image generator using Google Gemini API. Supports image generation from text prompts and image modification with reference images.
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
- └── GeminiGenerator.js # Gemini API implementation
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` environment variable
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
- -r, --ratio <ratio> Aspect ratio (default: 1:1)
449
- -m, --model <pro|flash> Model (default: flash)
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> Reference image, optional description after ":"
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 <pro|flash> Model (default: flash)
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> Reference image; optional description after ":" (repeatable)
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 firstColonIndex = value.indexOf(':');
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: value.trim(), description: '' };
136
+ return { imagePath: raw, description: '' };
109
137
  }
110
138
 
111
- const imagePath = value.slice(0, firstColonIndex).trim();
112
- const description = value.slice(firstColonIndex + 1).trim();
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: 'flash',
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 (!['flash', 'pro'].includes(parsed.model)) {
239
- throw new Error('Model must be "flash" or "pro".');
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 (existing.apiKey) {
315
- info(`Existing API key found in ${CONFIG_PATH}.`);
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 = new GeminiGenerator({ apiKey });
328
-
329
- if (args.model === 'pro') {
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(ref.imagePath, ref.description);
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
@@ -1,7 +1,10 @@
1
1
  const GeminiGenerator = require('./generators/GeminiGenerator');
2
+ const FalGenerator = require('./generators/FalGenerator');
2
3
 
3
4
  module.exports = {
4
5
  GeminiGenerator,
6
+ FalGenerator,
5
7
  MODELS: GeminiGenerator.MODELS,
8
+ FAL_MODELS: FalGenerator.MODELS,
6
9
  };
7
10
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "genmix",
3
- "version": "1.1.5",
4
- "description": "AI-powered image generator using Google Gemini API. Supports image generation from text prompts and image modification with reference images.",
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",