genmix 1.0.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 +186 -2
- package/cli.js +497 -0
- package/demo/example-fal.js +82 -0
- package/docs/decisions/genmix-cli-output-path-and-reference-text.md +12 -0
- package/docs/decisions/genmix-cli-smart-target-size.md +12 -0
- package/docs/patterns/genmix-cli-avoid-redundant-flags.md +12 -0
- package/docs/patterns/genmix-single-source-validation.md +12 -0
- package/generators/BaseGenerator.js +38 -23
- package/generators/FalGenerator.js +332 -0
- package/generators/GeminiGenerator.js +59 -4
- package/index.js +3 -0
- package/package.json +9 -2
|
@@ -29,9 +29,13 @@ class BaseGenerator {
|
|
|
29
29
|
* @param {Object} [options.formatOptions] - Format-specific options (quality, compressionLevel, palette, colours, etc.)
|
|
30
30
|
* @returns {Promise<string[]>} Promise that resolves to array of saved file paths.
|
|
31
31
|
*/
|
|
32
|
-
async save({directory = '.', filename = null, extension = 'jpg', formatOptions = null} = {}) {
|
|
32
|
+
async save({directory = '.', filename = null, extension = 'jpg', formatOptions = null, useSharp = true} = {}) {
|
|
33
33
|
const targetImages = this.lastGeneration?.images;
|
|
34
34
|
const targetPrompt = this.lastGeneration?.prompt;
|
|
35
|
+
const inheritedFormatOptions = this.lastGeneration?.formatOptions || null;
|
|
36
|
+
const effectiveFormatOptions = formatOptions
|
|
37
|
+
? { ...(inheritedFormatOptions || {}), ...formatOptions }
|
|
38
|
+
: inheritedFormatOptions;
|
|
35
39
|
|
|
36
40
|
if (!targetImages || !Array.isArray(targetImages) || targetImages.length === 0) {
|
|
37
41
|
console.warn('No images to save.');
|
|
@@ -48,8 +52,8 @@ class BaseGenerator {
|
|
|
48
52
|
}
|
|
49
53
|
|
|
50
54
|
// Use format from formatOptions if provided, otherwise use extension
|
|
51
|
-
if (
|
|
52
|
-
extension =
|
|
55
|
+
if (effectiveFormatOptions && effectiveFormatOptions.format) {
|
|
56
|
+
extension = effectiveFormatOptions.format;
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
// Normalize extension
|
|
@@ -69,32 +73,43 @@ class BaseGenerator {
|
|
|
69
73
|
|
|
70
74
|
for (let index = 0; index < targetImages.length; index++) {
|
|
71
75
|
const imgData = targetImages[index];
|
|
72
|
-
const
|
|
76
|
+
const mimeMatch = imgData.match(/^data:image\/([a-zA-Z0-9.+-]+);base64,/);
|
|
77
|
+
const sourceFormat = mimeMatch ? mimeMatch[1].toLowerCase() : null;
|
|
78
|
+
const normalizedSourceFormat = sourceFormat === 'jpeg' ? 'jpg' : sourceFormat;
|
|
79
|
+
const base64Data = imgData.replace(/^data:image\/[a-zA-Z0-9.+-]+;base64,/, "");
|
|
73
80
|
const buffer = Buffer.from(base64Data, 'base64');
|
|
74
81
|
|
|
82
|
+
const effectiveExtension = useSharp ? fileExtension : (normalizedSourceFormat || fileExtension);
|
|
83
|
+
|
|
75
84
|
let fileName;
|
|
76
85
|
if (filename) {
|
|
77
86
|
// Use custom filename, add index if multiple images
|
|
78
87
|
if (targetImages.length > 1) {
|
|
79
|
-
fileName = `${filename}_${index}.${
|
|
88
|
+
fileName = `${filename}_${index}.${effectiveExtension}`;
|
|
80
89
|
} else {
|
|
81
|
-
fileName = `${filename}.${
|
|
90
|
+
fileName = `${filename}.${effectiveExtension}`;
|
|
82
91
|
}
|
|
83
92
|
} else {
|
|
84
93
|
// Use hash-based filename
|
|
85
94
|
const hash = this.generateHash(targetPrompt + '_' + index);
|
|
86
|
-
fileName = `${hash}.${
|
|
95
|
+
fileName = `${hash}.${effectiveExtension}`;
|
|
87
96
|
}
|
|
88
97
|
|
|
89
98
|
const outputPath = path.join(directory, fileName);
|
|
90
99
|
|
|
100
|
+
if (!useSharp) {
|
|
101
|
+
fs.writeFileSync(outputPath, buffer);
|
|
102
|
+
savedPaths.push(outputPath);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
91
106
|
// Convert image format using sharp
|
|
92
107
|
try {
|
|
93
108
|
let sharpInstance = sharp(buffer);
|
|
94
109
|
|
|
95
110
|
// Resize if dimensions are specified in formatOptions
|
|
96
|
-
if (
|
|
97
|
-
sharpInstance = sharpInstance.resize(
|
|
111
|
+
if (effectiveFormatOptions && effectiveFormatOptions.width && effectiveFormatOptions.height) {
|
|
112
|
+
sharpInstance = sharpInstance.resize(effectiveFormatOptions.width, effectiveFormatOptions.height, {
|
|
98
113
|
fit: 'fill'
|
|
99
114
|
});
|
|
100
115
|
}
|
|
@@ -102,29 +117,29 @@ class BaseGenerator {
|
|
|
102
117
|
// Build format-specific options
|
|
103
118
|
const sharpFormatOptions = {};
|
|
104
119
|
|
|
105
|
-
if (
|
|
106
|
-
if (sharpFormat === 'jpg' &&
|
|
107
|
-
sharpFormatOptions.quality =
|
|
120
|
+
if (effectiveFormatOptions) {
|
|
121
|
+
if (sharpFormat === 'jpg' && effectiveFormatOptions.quality) {
|
|
122
|
+
sharpFormatOptions.quality = effectiveFormatOptions.quality;
|
|
108
123
|
} else if (sharpFormat === 'png') {
|
|
109
|
-
if (
|
|
110
|
-
sharpFormatOptions.compressionLevel =
|
|
124
|
+
if (effectiveFormatOptions.compressionLevel !== undefined) {
|
|
125
|
+
sharpFormatOptions.compressionLevel = effectiveFormatOptions.compressionLevel;
|
|
111
126
|
}
|
|
112
|
-
if (
|
|
113
|
-
sharpFormatOptions.quality =
|
|
127
|
+
if (effectiveFormatOptions.quality !== undefined) {
|
|
128
|
+
sharpFormatOptions.quality = effectiveFormatOptions.quality;
|
|
114
129
|
}
|
|
115
|
-
if (
|
|
116
|
-
sharpFormatOptions.effort =
|
|
130
|
+
if (effectiveFormatOptions.effort !== undefined) {
|
|
131
|
+
sharpFormatOptions.effort = effectiveFormatOptions.effort;
|
|
117
132
|
}
|
|
118
|
-
if (
|
|
133
|
+
if (effectiveFormatOptions.palette) {
|
|
119
134
|
sharpFormatOptions.palette = true;
|
|
120
135
|
// Add dithering for better quality with palette
|
|
121
136
|
sharpFormatOptions.dither = 1.0;
|
|
122
137
|
}
|
|
123
|
-
if (
|
|
124
|
-
sharpFormatOptions.colours =
|
|
138
|
+
if (effectiveFormatOptions.colours) {
|
|
139
|
+
sharpFormatOptions.colours = effectiveFormatOptions.colours;
|
|
125
140
|
}
|
|
126
|
-
} else if (sharpFormat === 'webp' &&
|
|
127
|
-
sharpFormatOptions.quality =
|
|
141
|
+
} else if (sharpFormat === 'webp' && effectiveFormatOptions.quality) {
|
|
142
|
+
sharpFormatOptions.quality = effectiveFormatOptions.quality;
|
|
128
143
|
}
|
|
129
144
|
}
|
|
130
145
|
|
|
@@ -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;
|
|
@@ -67,6 +67,55 @@ class GeminiGenerator extends BaseGenerator {
|
|
|
67
67
|
return this;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
_gcd(a, b) {
|
|
71
|
+
let x = Math.abs(a);
|
|
72
|
+
let y = Math.abs(b);
|
|
73
|
+
while (y !== 0) {
|
|
74
|
+
const t = y;
|
|
75
|
+
y = x % y;
|
|
76
|
+
x = t;
|
|
77
|
+
}
|
|
78
|
+
return x || 1;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
_deriveAspectRatio(width, height) {
|
|
82
|
+
const divisor = this._gcd(width, height);
|
|
83
|
+
return `${width / divisor}:${height / divisor}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
_normalizeGenerateOptions(options = {}) {
|
|
87
|
+
const normalizedOptions = { ...options };
|
|
88
|
+
const hasWidth = normalizedOptions.width !== undefined && normalizedOptions.width !== null;
|
|
89
|
+
const hasHeight = normalizedOptions.height !== undefined && normalizedOptions.height !== null;
|
|
90
|
+
|
|
91
|
+
if (hasWidth !== hasHeight) {
|
|
92
|
+
throw new Error('Both options.width and options.height are required together.');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (hasWidth && hasHeight) {
|
|
96
|
+
const width = Number(normalizedOptions.width);
|
|
97
|
+
const height = Number(normalizedOptions.height);
|
|
98
|
+
|
|
99
|
+
if (!Number.isInteger(width) || width <= 0) {
|
|
100
|
+
throw new Error('options.width must be a positive integer.');
|
|
101
|
+
}
|
|
102
|
+
if (!Number.isInteger(height) || height <= 0) {
|
|
103
|
+
throw new Error('options.height must be a positive integer.');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const derivedRatio = this._deriveAspectRatio(width, height);
|
|
107
|
+
if (normalizedOptions.aspectRatio && normalizedOptions.aspectRatio !== derivedRatio) {
|
|
108
|
+
throw new Error(`Aspect ratio mismatch: options.aspectRatio ${normalizedOptions.aspectRatio} does not match options.width/options.height (${derivedRatio}).`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
normalizedOptions.aspectRatio = derivedRatio;
|
|
112
|
+
normalizedOptions.width = width;
|
|
113
|
+
normalizedOptions.height = height;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return normalizedOptions;
|
|
117
|
+
}
|
|
118
|
+
|
|
70
119
|
/**
|
|
71
120
|
* @param {string} prompt
|
|
72
121
|
* @param {Object} [options]
|
|
@@ -74,19 +123,22 @@ class GeminiGenerator extends BaseGenerator {
|
|
|
74
123
|
* @param {string} [options.numberOfImages] - Number of images to generate
|
|
75
124
|
* @param {string} [options.quality] - Image quality: '1K', '2K', '4K'
|
|
76
125
|
* @param {string} [options.aspectRatio] - Aspect ratio like '1:1', '16:9', etc.
|
|
126
|
+
* @param {number} [options.width] - Final output width in pixels (requires options.height)
|
|
127
|
+
* @param {number} [options.height] - Final output height in pixels (requires options.width)
|
|
77
128
|
* @returns {Promise<{images: string[], text: string, raw: any}>}
|
|
78
129
|
*/
|
|
79
130
|
async generate(prompt, options = {}) {
|
|
131
|
+
const normalizedOptions = this._normalizeGenerateOptions(options);
|
|
80
132
|
// If the user asks for multiple images, we might need to make parallel requests
|
|
81
133
|
// if the API doesn't support candidateCount > 1 for images.
|
|
82
134
|
// Based on search results, candidateCount > 1 can cause 400 errors.
|
|
83
|
-
const numberOfImages =
|
|
135
|
+
const numberOfImages = normalizedOptions.numberOfImages || 1;
|
|
84
136
|
|
|
85
137
|
let result;
|
|
86
138
|
if (numberOfImages > 1) {
|
|
87
|
-
result = await this.generateMultiple(prompt, numberOfImages,
|
|
139
|
+
result = await this.generateMultiple(prompt, numberOfImages, normalizedOptions);
|
|
88
140
|
} else {
|
|
89
|
-
result = await this._generateSingleRequest(prompt,
|
|
141
|
+
result = await this._generateSingleRequest(prompt, normalizedOptions);
|
|
90
142
|
}
|
|
91
143
|
|
|
92
144
|
// Store result in state for saveImages()
|
|
@@ -94,7 +146,10 @@ class GeminiGenerator extends BaseGenerator {
|
|
|
94
146
|
prompt: prompt,
|
|
95
147
|
images: result.images,
|
|
96
148
|
text: result.text,
|
|
97
|
-
raw: result.raw
|
|
149
|
+
raw: result.raw,
|
|
150
|
+
formatOptions: normalizedOptions.width && normalizedOptions.height
|
|
151
|
+
? { width: normalizedOptions.width, height: normalizedOptions.height }
|
|
152
|
+
: null
|
|
98
153
|
};
|
|
99
154
|
|
|
100
155
|
this.references = [];
|
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,16 +11,23 @@
|
|
|
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",
|
|
17
20
|
"style-transfer",
|
|
18
21
|
"generative-ai",
|
|
19
22
|
"genmix",
|
|
23
|
+
"cli",
|
|
20
24
|
"clasen"
|
|
21
25
|
],
|
|
22
26
|
"type": "commonjs",
|
|
23
27
|
"main": "index.js",
|
|
28
|
+
"bin": {
|
|
29
|
+
"genmix": "./cli.js"
|
|
30
|
+
},
|
|
24
31
|
"scripts": {
|
|
25
32
|
"test": "echo \"Error: no test specified\" && exit 1"
|
|
26
33
|
},
|