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
package/cli.js
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const readline = require('readline');
|
|
7
|
+
const GeminiGenerator = require('./generators/GeminiGenerator');
|
|
8
|
+
const FalGenerator = require('./generators/FalGenerator');
|
|
9
|
+
|
|
10
|
+
const CONFIG_DIR = path.join(os.homedir(), '.genmix');
|
|
11
|
+
const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
|
|
12
|
+
const IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'webp', 'avif', 'tiff', 'tif', 'gif']);
|
|
13
|
+
|
|
14
|
+
function color(code, message) {
|
|
15
|
+
return `\x1b[${code}m${message}\x1b[0m`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function info(message) {
|
|
19
|
+
console.log(color('36', message));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function success(message) {
|
|
23
|
+
console.log(color('32', message));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function error(message) {
|
|
27
|
+
console.error(color('31', message));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function loadConfig() {
|
|
31
|
+
if (!fs.existsSync(CONFIG_PATH)) {
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
|
|
37
|
+
if (!raw.trim()) {
|
|
38
|
+
return {};
|
|
39
|
+
}
|
|
40
|
+
const parsed = JSON.parse(raw);
|
|
41
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
42
|
+
} catch (err) {
|
|
43
|
+
throw new Error(`Failed reading config at ${CONFIG_PATH}: ${err.message}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function saveConfig(config) {
|
|
48
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
49
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
50
|
+
}
|
|
51
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function promptQuestion(question) {
|
|
55
|
+
return new Promise((resolve) => {
|
|
56
|
+
const rl = readline.createInterface({
|
|
57
|
+
input: process.stdin,
|
|
58
|
+
output: process.stdout
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
rl.question(question, (answer) => {
|
|
62
|
+
rl.close();
|
|
63
|
+
resolve(answer);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function promptApiKey() {
|
|
69
|
+
const apiKey = (await promptQuestion('Enter your Gemini API key: ')).trim();
|
|
70
|
+
if (!apiKey) {
|
|
71
|
+
throw new Error('API key cannot be empty.');
|
|
72
|
+
}
|
|
73
|
+
return apiKey;
|
|
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
|
+
|
|
84
|
+
function printHelp() {
|
|
85
|
+
console.log(`
|
|
86
|
+
Usage:
|
|
87
|
+
genmix "prompt text" [options]
|
|
88
|
+
genmix --config
|
|
89
|
+
genmix --help
|
|
90
|
+
|
|
91
|
+
Options:
|
|
92
|
+
-p, --provider <gemini|fal> Provider (default: gemini)
|
|
93
|
+
-n, --number <N> Number of images (default: 1)
|
|
94
|
+
-q, --quality <1K|2K|4K> Image quality (default: 1K)
|
|
95
|
+
-r, --ratio <ratio> Aspect ratio (default: 1:1 for gemini, auto for fal)
|
|
96
|
+
--width <px> Final output width in pixels (requires --height)
|
|
97
|
+
--height <px> Final output height in pixels (requires --width)
|
|
98
|
+
-m, --model <...> Model by provider:
|
|
99
|
+
gemini -> pro|flash (default: flash)
|
|
100
|
+
fal -> pro|flash (aliases: banana-pro|banana2|2, default: flash)
|
|
101
|
+
-o, --output <path> Output directory OR full output file path
|
|
102
|
+
-f, --format <format> Output format when output is a directory (default: jpg)
|
|
103
|
+
--no-sharp Save raw model bytes without Sharp conversion
|
|
104
|
+
--ref <path[:text]> Reference image; optional description after ":" (repeatable)
|
|
105
|
+
For URLs, use plain URL or URL::description
|
|
106
|
+
--help Show this help message
|
|
107
|
+
--config Set or update persisted API key
|
|
108
|
+
|
|
109
|
+
Examples:
|
|
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"
|
|
113
|
+
genmix "logo in watercolor style" -n 2 -q 2K -o ./output
|
|
114
|
+
genmix "app icon" -q 4K --width 400 --height 400 --output ./renders/icon.png
|
|
115
|
+
genmix "new version of this room" --ref room.jpg:"keep composition"
|
|
116
|
+
genmix "portrait variation" --ref subject.png --output ./renders/portrait.png
|
|
117
|
+
`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function parseRefValue(value) {
|
|
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(':');
|
|
135
|
+
if (firstColonIndex === -1) {
|
|
136
|
+
return { imagePath: raw, description: '' };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const imagePath = raw.slice(0, firstColonIndex).trim();
|
|
140
|
+
const description = raw.slice(firstColonIndex + 1).trim();
|
|
141
|
+
return { imagePath, description };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function parseArgs(argv) {
|
|
145
|
+
const parsed = {
|
|
146
|
+
promptParts: [],
|
|
147
|
+
references: [],
|
|
148
|
+
provider: 'gemini',
|
|
149
|
+
numberOfImages: 1,
|
|
150
|
+
quality: '1K',
|
|
151
|
+
aspectRatio: null,
|
|
152
|
+
aspectRatioWasProvided: false,
|
|
153
|
+
model: null,
|
|
154
|
+
modelWasProvided: false,
|
|
155
|
+
output: '.',
|
|
156
|
+
format: 'jpg',
|
|
157
|
+
targetWidth: null,
|
|
158
|
+
targetHeight: null,
|
|
159
|
+
useSharp: true,
|
|
160
|
+
showHelp: false,
|
|
161
|
+
runConfig: false
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
165
|
+
const arg = argv[i];
|
|
166
|
+
const next = argv[i + 1];
|
|
167
|
+
|
|
168
|
+
if (arg === '--help' || arg === '-h') {
|
|
169
|
+
parsed.showHelp = true;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (arg === '--config') {
|
|
174
|
+
parsed.runConfig = true;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (arg === '-n' || arg === '--number') {
|
|
179
|
+
if (!next) throw new Error(`${arg} requires a value.`);
|
|
180
|
+
parsed.numberOfImages = Number(next);
|
|
181
|
+
i += 1;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (arg === '-q' || arg === '--quality') {
|
|
186
|
+
if (!next) throw new Error(`${arg} requires a value.`);
|
|
187
|
+
parsed.quality = next;
|
|
188
|
+
i += 1;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
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
|
+
|
|
199
|
+
if (arg === '-r' || arg === '--ratio') {
|
|
200
|
+
if (!next) throw new Error(`${arg} requires a value.`);
|
|
201
|
+
parsed.aspectRatio = next;
|
|
202
|
+
parsed.aspectRatioWasProvided = true;
|
|
203
|
+
i += 1;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (arg === '--width') {
|
|
208
|
+
if (!next) throw new Error(`${arg} requires a value.`);
|
|
209
|
+
parsed.targetWidth = Number(next);
|
|
210
|
+
i += 1;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (arg === '--height') {
|
|
215
|
+
if (!next) throw new Error(`${arg} requires a value.`);
|
|
216
|
+
parsed.targetHeight = Number(next);
|
|
217
|
+
i += 1;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (arg === '-m' || arg === '--model') {
|
|
222
|
+
if (!next) throw new Error(`${arg} requires a value.`);
|
|
223
|
+
parsed.model = String(next).toLowerCase();
|
|
224
|
+
parsed.modelWasProvided = true;
|
|
225
|
+
i += 1;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (arg === '-o' || arg === '--output') {
|
|
230
|
+
if (!next) throw new Error(`${arg} requires a value.`);
|
|
231
|
+
parsed.output = next;
|
|
232
|
+
i += 1;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (arg === '-f' || arg === '--format') {
|
|
237
|
+
if (!next) throw new Error(`${arg} requires a value.`);
|
|
238
|
+
parsed.format = String(next).replace(/^\./, '').toLowerCase();
|
|
239
|
+
i += 1;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (arg === '--ref') {
|
|
244
|
+
if (!next) throw new Error(`${arg} requires a value.`);
|
|
245
|
+
parsed.references.push(parseRefValue(next));
|
|
246
|
+
i += 1;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (arg === '--no-sharp') {
|
|
251
|
+
parsed.useSharp = false;
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (arg.startsWith('-')) {
|
|
256
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
parsed.promptParts.push(arg);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (!parsed.runConfig && !parsed.showHelp && parsed.promptParts.length === 0) {
|
|
263
|
+
throw new Error('Missing prompt text. Use genmix "your prompt".');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (!Number.isInteger(parsed.numberOfImages) || parsed.numberOfImages <= 0) {
|
|
267
|
+
throw new Error('Number of images must be a positive integer.');
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const quality = parsed.quality.toUpperCase();
|
|
271
|
+
if (!['1K', '2K', '4K'].includes(quality)) {
|
|
272
|
+
throw new Error('Quality must be one of: 1K, 2K, 4K.');
|
|
273
|
+
}
|
|
274
|
+
parsed.quality = quality;
|
|
275
|
+
|
|
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).');
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const hasOnlyOneDimension = (parsed.targetWidth === null) !== (parsed.targetHeight === null);
|
|
297
|
+
if (hasOnlyOneDimension) {
|
|
298
|
+
throw new Error('Both --width and --height are required together.');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const hasTargetDimensions = parsed.targetWidth !== null && parsed.targetHeight !== null;
|
|
302
|
+
if (hasTargetDimensions) {
|
|
303
|
+
if (!Number.isInteger(parsed.targetWidth) || parsed.targetWidth <= 0) {
|
|
304
|
+
throw new Error('Width must be a positive integer.');
|
|
305
|
+
}
|
|
306
|
+
if (!Number.isInteger(parsed.targetHeight) || parsed.targetHeight <= 0) {
|
|
307
|
+
throw new Error('Height must be a positive integer.');
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (!parsed.useSharp && hasTargetDimensions) {
|
|
312
|
+
throw new Error('Resizing requires Sharp. Remove --no-sharp to use --width/--height.');
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (!parsed.aspectRatioWasProvided && !hasTargetDimensions) {
|
|
316
|
+
parsed.aspectRatio = parsed.provider === 'fal' ? 'auto' : '1:1';
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
for (const ref of parsed.references) {
|
|
320
|
+
if (!ref.imagePath) {
|
|
321
|
+
throw new Error('Reference path cannot be empty.');
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
parsed.prompt = parsed.promptParts.join(' ').trim();
|
|
326
|
+
return parsed;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function resolveOutput(outputArg, format) {
|
|
330
|
+
const extension = path.extname(outputArg).replace(/^\./, '').toLowerCase();
|
|
331
|
+
const isFilePath = extension && IMAGE_EXTENSIONS.has(extension);
|
|
332
|
+
|
|
333
|
+
if (isFilePath) {
|
|
334
|
+
const absolute = path.resolve(process.cwd(), outputArg);
|
|
335
|
+
return {
|
|
336
|
+
directory: path.dirname(absolute),
|
|
337
|
+
filename: path.basename(absolute, path.extname(absolute)),
|
|
338
|
+
extension
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
directory: path.resolve(process.cwd(), outputArg),
|
|
344
|
+
filename: null,
|
|
345
|
+
extension: format
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function ensureApiKey(provider = 'gemini') {
|
|
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
|
+
|
|
368
|
+
const envApiKey = process.env.GEMINI_API_KEY;
|
|
369
|
+
if (envApiKey && envApiKey.trim()) {
|
|
370
|
+
return envApiKey.trim();
|
|
371
|
+
}
|
|
372
|
+
if (config.geminiApiKey && config.geminiApiKey.trim()) {
|
|
373
|
+
return config.geminiApiKey.trim();
|
|
374
|
+
}
|
|
375
|
+
if (config.apiKey && config.apiKey.trim()) {
|
|
376
|
+
return config.apiKey.trim();
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
info('No Gemini API key found. Let us configure it now.');
|
|
380
|
+
const newApiKey = await promptApiKey();
|
|
381
|
+
saveConfig({ ...config, apiKey: newApiKey, geminiApiKey: newApiKey });
|
|
382
|
+
success(`Gemini API key saved to ${CONFIG_PATH}`);
|
|
383
|
+
return newApiKey;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function runConfigCommand(provider) {
|
|
387
|
+
const existing = loadConfig();
|
|
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}.`);
|
|
403
|
+
} else {
|
|
404
|
+
info('No saved Gemini API key found.');
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const newApiKey = await promptApiKey();
|
|
408
|
+
saveConfig({ ...existing, apiKey: newApiKey, geminiApiKey: newApiKey });
|
|
409
|
+
success(`Gemini API key saved to ${CONFIG_PATH}`);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function runGeneration(args) {
|
|
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') {
|
|
425
|
+
generator.pro();
|
|
426
|
+
} else {
|
|
427
|
+
generator.flash();
|
|
428
|
+
}
|
|
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
|
+
|
|
434
|
+
for (const ref of args.references) {
|
|
435
|
+
if (typeof generator.addReference === 'function') {
|
|
436
|
+
generator.addReference(ref.imagePath, ref.description);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
info('Generating image(s)...');
|
|
441
|
+
const generation = await generator.generate(args.prompt, {
|
|
442
|
+
numberOfImages: args.numberOfImages,
|
|
443
|
+
quality: args.quality,
|
|
444
|
+
aspectRatio: args.aspectRatio,
|
|
445
|
+
width: args.targetWidth,
|
|
446
|
+
height: args.targetHeight
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
if (generation.text) {
|
|
450
|
+
info(`Model response: ${generation.text}`);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (!generation.images || generation.images.length === 0) {
|
|
454
|
+
info('No images were returned by the model.');
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const saveOptions = resolveOutput(args.output, args.format);
|
|
459
|
+
saveOptions.useSharp = args.useSharp;
|
|
460
|
+
if (args.targetWidth && args.targetHeight) {
|
|
461
|
+
info(`Resizing final output to ${args.targetWidth}x${args.targetHeight}.`);
|
|
462
|
+
}
|
|
463
|
+
const savedPaths = await generator.save(saveOptions);
|
|
464
|
+
savedPaths.forEach((savedPath) => {
|
|
465
|
+
success(`Saved: ${savedPath}`);
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async function main() {
|
|
470
|
+
try {
|
|
471
|
+
const args = parseArgs(process.argv.slice(2));
|
|
472
|
+
|
|
473
|
+
if (args.showHelp) {
|
|
474
|
+
printHelp();
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (args.runConfig) {
|
|
479
|
+
await runConfigCommand(args.provider);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
await runGeneration(args);
|
|
484
|
+
} catch (err) {
|
|
485
|
+
error(err.message);
|
|
486
|
+
process.exitCode = 1;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (require.main === module) {
|
|
491
|
+
main();
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
module.exports = {
|
|
495
|
+
parseArgs,
|
|
496
|
+
resolveOutput
|
|
497
|
+
};
|
|
@@ -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,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: ydgrqwuq05
|
|
3
|
+
type: decision
|
|
4
|
+
title: 'Decision: GenMix CLI output and refs'
|
|
5
|
+
created: '2026-03-19 12:44:27'
|
|
6
|
+
---
|
|
7
|
+
# Decision: GenMix CLI output path and reference text
|
|
8
|
+
|
|
9
|
+
**What**: The CLI supports `--output` as either a directory or a full output file path, and supports `--ref` values as `path` or `path:description`.
|
|
10
|
+
**Where**: `cli.js` argument parsing and output resolution.
|
|
11
|
+
**Why**: Users need deterministic output naming/location and richer reference guidance for prompt conditioning.
|
|
12
|
+
**Alternatives rejected**: Separate flags for `--ref-path` + `--ref-text` were rejected because repeated `--ref` with `path:description` is simpler for command-line ergonomics.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: af6zbnxehw
|
|
3
|
+
type: decision
|
|
4
|
+
title: 'Decision: Smart CLI target size flow'
|
|
5
|
+
created: '2026-03-19 13:28:11'
|
|
6
|
+
---
|
|
7
|
+
# Decision: Smart CLI target size flow
|
|
8
|
+
|
|
9
|
+
**What**: Added `--size WxH` and `--width/--height` to derive generation aspect ratio automatically and then resize final output to exact dimensions.
|
|
10
|
+
**Where**: `cli.js` and CLI docs in `README.md`.
|
|
11
|
+
**Why**: Users need exact output dimensions (e.g. 400x400) while keeping generation quality independent (e.g. 4K).
|
|
12
|
+
**Alternatives rejected**: Keeping resize manual in code-only API was rejected because CLI users need first-class support.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: dw7miivs7w
|
|
3
|
+
type: pattern
|
|
4
|
+
title: 'Lesson: Keep CLI surface minimal'
|
|
5
|
+
created: '2026-03-19 13:34:50'
|
|
6
|
+
---
|
|
7
|
+
# Pattern: Keep CLI surface minimal
|
|
8
|
+
|
|
9
|
+
**What**: Prefer one clear way to express output dimensions in CLI. Removed redundant `--size` in favor of `--width` + `--height`.
|
|
10
|
+
**Where used**: `cli.js` argument parsing/help and `README.md` CLI documentation.
|
|
11
|
+
**When to apply**: When multiple flags encode the same behavior and one can be removed without losing capability.
|
|
12
|
+
**Why**: Simpler UX, less ambiguity, fewer parsing edge cases.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: ly0zl6yegp
|
|
3
|
+
type: pattern
|
|
4
|
+
title: 'Lesson: Avoid CLI-core validation duplication'
|
|
5
|
+
created: '2026-03-19 13:36:41'
|
|
6
|
+
---
|
|
7
|
+
# Pattern: Single-source validation in core
|
|
8
|
+
|
|
9
|
+
**What**: Keep dimension/aspect-ratio derivation and validation in `GeminiGenerator` (core), not duplicated in `cli.js`.
|
|
10
|
+
**Where used**: `cli.js` now only parses input and basic presence/type checks; `_normalizeGenerateOptions()` in `GeminiGenerator` handles semantic validation.
|
|
11
|
+
**When to apply**: Any feature shared by CLI and library/API usage.
|
|
12
|
+
**Why**: Prevent drift, inconsistent behavior, and double-maintenance bugs.
|