genmix 1.0.5 → 1.1.5
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 +127 -0
- package/cli.js +396 -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/GeminiGenerator.js +59 -4
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -166,6 +166,8 @@ await generator.generate(prompt, options)
|
|
|
166
166
|
| `options.numberOfImages` | number | Number of images to generate | 1 |
|
|
167
167
|
| `options.quality` | string | Quality: '1K', '2K', '4K' | - |
|
|
168
168
|
| `options.aspectRatio` | string | Aspect ratio: '1:1', '16:9', '4:3', etc. | - |
|
|
169
|
+
| `options.width` | number | Final output width in pixels (requires `height`) | - |
|
|
170
|
+
| `options.height` | number | Final output height in pixels (requires `width`) | - |
|
|
169
171
|
|
|
170
172
|
### save() Method
|
|
171
173
|
|
|
@@ -203,6 +205,25 @@ await generator.save({ filename: 'my-image' });
|
|
|
203
205
|
await generator.save();
|
|
204
206
|
```
|
|
205
207
|
|
|
208
|
+
### Smart target size in library mode (non-CLI)
|
|
209
|
+
|
|
210
|
+
You can request high generation quality and still force an exact final output size directly in `generate()`:
|
|
211
|
+
|
|
212
|
+
```javascript
|
|
213
|
+
const generator = new GeminiGenerator();
|
|
214
|
+
|
|
215
|
+
await generator.generate('App icon, flat minimal style', {
|
|
216
|
+
quality: '4K', // generation quality (independent)
|
|
217
|
+
width: 400,
|
|
218
|
+
height: 400
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// width/height resize is automatically applied on save()
|
|
222
|
+
await generator.save({ filename: 'icon-400x400', extension: 'png' });
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
If you also pass `aspectRatio`, it must match the ratio derived from `width`/`height`.
|
|
226
|
+
|
|
206
227
|
**Note:**
|
|
207
228
|
- When multiple images are generated and a custom filename is provided, they will be saved as `filename_0.jpg`, `filename_1.jpg`, etc.
|
|
208
229
|
- The method uses Sharp for image conversion, supporting high-quality format conversion
|
|
@@ -330,6 +351,112 @@ genmix/
|
|
|
330
351
|
|
|
331
352
|
4. **Result Caching**: Images are automatically saved with unique hash based on the prompt
|
|
332
353
|
|
|
354
|
+
## CLI Usage
|
|
355
|
+
|
|
356
|
+
### Global CLI Installation
|
|
357
|
+
|
|
358
|
+
Install GenMix globally to use it from the command line:
|
|
359
|
+
|
|
360
|
+
```bash
|
|
361
|
+
npm install -g genmix
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
### First run and API key persistence
|
|
365
|
+
|
|
366
|
+
If no API key is available, the CLI asks for it on first use and saves it to:
|
|
367
|
+
|
|
368
|
+
```text
|
|
369
|
+
~/.genmix/config.json
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
You can set or update it explicitly anytime:
|
|
373
|
+
|
|
374
|
+
```bash
|
|
375
|
+
genmix --config
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
API key resolution order in CLI:
|
|
379
|
+
1. `GEMINI_API_KEY` environment variable
|
|
380
|
+
2. Saved config (`~/.genmix/config.json`)
|
|
381
|
+
3. Interactive prompt (then persisted)
|
|
382
|
+
|
|
383
|
+
### Basic CLI commands
|
|
384
|
+
|
|
385
|
+
```bash
|
|
386
|
+
# Show help
|
|
387
|
+
genmix --help
|
|
388
|
+
|
|
389
|
+
# Generate from prompt
|
|
390
|
+
genmix "A futuristic city with flying cars, cyberpunk style"
|
|
391
|
+
|
|
392
|
+
# Generate multiple images
|
|
393
|
+
genmix "A cozy cabin in winter" -n 2 -q 2K -r 16:9 -m flash
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
### Output file path or directory
|
|
397
|
+
|
|
398
|
+
`--output` accepts either:
|
|
399
|
+
- a directory path, or
|
|
400
|
+
- a full output file path (including filename + extension)
|
|
401
|
+
|
|
402
|
+
```bash
|
|
403
|
+
# Save to a directory (auto-generated hash filename)
|
|
404
|
+
genmix "Watercolor fox logo" --output ./output
|
|
405
|
+
|
|
406
|
+
# Save to exact file path and filename
|
|
407
|
+
genmix "Watercolor fox logo" --output ./output/logo-fox.png
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
### Smart target size (independent from generation quality)
|
|
411
|
+
|
|
412
|
+
You can ask the model for high generation quality (for example `4K`) and still force a final exact output size.
|
|
413
|
+
|
|
414
|
+
When you pass target dimensions, GenMix CLI:
|
|
415
|
+
1. Derives the generation ratio automatically (for example `400x400` -> `1:1`, `1920x1080` -> `16:9`)
|
|
416
|
+
2. Generates using your selected quality (`1K`, `2K`, or `4K`)
|
|
417
|
+
3. Resizes the final image to the exact dimensions you requested
|
|
418
|
+
|
|
419
|
+
```bash
|
|
420
|
+
# Ask for 4K quality, deliver exact 400x400 output
|
|
421
|
+
genmix "app icon, flat minimal style" -q 4K --width 400 --height 400 --output ./output/icon.png
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
If you also pass `--ratio`, it must match the derived ratio from the target size.
|
|
425
|
+
|
|
426
|
+
### References with optional text description
|
|
427
|
+
|
|
428
|
+
Use `--ref <path:text>` to add reference images with optional guidance text:
|
|
429
|
+
|
|
430
|
+
```bash
|
|
431
|
+
# Reference path only
|
|
432
|
+
genmix "Restyle this room" --ref ./room.jpg
|
|
433
|
+
|
|
434
|
+
# Reference path + description
|
|
435
|
+
genmix "Restyle this room" --ref "./room.jpg:keep composition and camera angle"
|
|
436
|
+
|
|
437
|
+
# Multiple references with descriptions
|
|
438
|
+
genmix "Create product ad scene" \
|
|
439
|
+
--ref "./product.png:use as main subject" \
|
|
440
|
+
--ref "./bg.jpg:use as background mood"
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
### CLI options
|
|
444
|
+
|
|
445
|
+
```text
|
|
446
|
+
-n, --number <N> Number of images (default: 1)
|
|
447
|
+
-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)
|
|
450
|
+
-o, --output <path> Output directory or full output file path
|
|
451
|
+
-f, --format <format> Output format when output is a directory (default: jpg)
|
|
452
|
+
--width <px> Final output width in pixels (requires --height)
|
|
453
|
+
--height <px> Final output height in pixels (requires --width)
|
|
454
|
+
--ref <path:text> Reference image, optional description after ":"
|
|
455
|
+
--no-sharp Save raw model bytes without Sharp conversion (disables resizing)
|
|
456
|
+
--config Set/update persisted API key
|
|
457
|
+
--help Show help
|
|
458
|
+
```
|
|
459
|
+
|
|
333
460
|
## Additional Resources
|
|
334
461
|
|
|
335
462
|
- [Code Examples](./demo/)
|
package/cli.js
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
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
|
+
|
|
9
|
+
const CONFIG_DIR = path.join(os.homedir(), '.genmix');
|
|
10
|
+
const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
|
|
11
|
+
const IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'webp', 'avif', 'tiff', 'tif', 'gif']);
|
|
12
|
+
|
|
13
|
+
function color(code, message) {
|
|
14
|
+
return `\x1b[${code}m${message}\x1b[0m`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function info(message) {
|
|
18
|
+
console.log(color('36', message));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function success(message) {
|
|
22
|
+
console.log(color('32', message));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function error(message) {
|
|
26
|
+
console.error(color('31', message));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function loadConfig() {
|
|
30
|
+
if (!fs.existsSync(CONFIG_PATH)) {
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
|
|
36
|
+
if (!raw.trim()) {
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
const parsed = JSON.parse(raw);
|
|
40
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
41
|
+
} catch (err) {
|
|
42
|
+
throw new Error(`Failed reading config at ${CONFIG_PATH}: ${err.message}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function saveConfig(config) {
|
|
47
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
48
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
49
|
+
}
|
|
50
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function promptQuestion(question) {
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
const rl = readline.createInterface({
|
|
56
|
+
input: process.stdin,
|
|
57
|
+
output: process.stdout
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
rl.question(question, (answer) => {
|
|
61
|
+
rl.close();
|
|
62
|
+
resolve(answer);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function promptApiKey() {
|
|
68
|
+
const apiKey = (await promptQuestion('Enter your Gemini API key: ')).trim();
|
|
69
|
+
if (!apiKey) {
|
|
70
|
+
throw new Error('API key cannot be empty.');
|
|
71
|
+
}
|
|
72
|
+
return apiKey;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function printHelp() {
|
|
76
|
+
console.log(`
|
|
77
|
+
Usage:
|
|
78
|
+
genmix "prompt text" [options]
|
|
79
|
+
genmix --config
|
|
80
|
+
genmix --help
|
|
81
|
+
|
|
82
|
+
Options:
|
|
83
|
+
-n, --number <N> Number of images (default: 1)
|
|
84
|
+
-q, --quality <1K|2K|4K> Image quality (default: 1K)
|
|
85
|
+
-r, --ratio <ratio> Aspect ratio (default: 1:1)
|
|
86
|
+
--width <px> Final output width in pixels (requires --height)
|
|
87
|
+
--height <px> Final output height in pixels (requires --width)
|
|
88
|
+
-m, --model <pro|flash> Model (default: flash)
|
|
89
|
+
-o, --output <path> Output directory OR full output file path
|
|
90
|
+
-f, --format <format> Output format when output is a directory (default: jpg)
|
|
91
|
+
--no-sharp Save raw model bytes without Sharp conversion
|
|
92
|
+
--ref <path:text> Reference image; optional description after ":" (repeatable)
|
|
93
|
+
--help Show this help message
|
|
94
|
+
--config Set or update persisted API key
|
|
95
|
+
|
|
96
|
+
Examples:
|
|
97
|
+
genmix "cyberpunk city at night"
|
|
98
|
+
genmix "logo in watercolor style" -n 2 -q 2K -o ./output
|
|
99
|
+
genmix "app icon" -q 4K --width 400 --height 400 --output ./renders/icon.png
|
|
100
|
+
genmix "new version of this room" --ref room.jpg:"keep composition"
|
|
101
|
+
genmix "portrait variation" --ref subject.png --output ./renders/portrait.png
|
|
102
|
+
`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function parseRefValue(value) {
|
|
106
|
+
const firstColonIndex = value.indexOf(':');
|
|
107
|
+
if (firstColonIndex === -1) {
|
|
108
|
+
return { imagePath: value.trim(), description: '' };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const imagePath = value.slice(0, firstColonIndex).trim();
|
|
112
|
+
const description = value.slice(firstColonIndex + 1).trim();
|
|
113
|
+
return { imagePath, description };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function parseArgs(argv) {
|
|
117
|
+
const parsed = {
|
|
118
|
+
promptParts: [],
|
|
119
|
+
references: [],
|
|
120
|
+
numberOfImages: 1,
|
|
121
|
+
quality: '1K',
|
|
122
|
+
aspectRatio: null,
|
|
123
|
+
aspectRatioWasProvided: false,
|
|
124
|
+
model: 'flash',
|
|
125
|
+
output: '.',
|
|
126
|
+
format: 'jpg',
|
|
127
|
+
targetWidth: null,
|
|
128
|
+
targetHeight: null,
|
|
129
|
+
useSharp: true,
|
|
130
|
+
showHelp: false,
|
|
131
|
+
runConfig: false
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
135
|
+
const arg = argv[i];
|
|
136
|
+
const next = argv[i + 1];
|
|
137
|
+
|
|
138
|
+
if (arg === '--help' || arg === '-h') {
|
|
139
|
+
parsed.showHelp = true;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (arg === '--config') {
|
|
144
|
+
parsed.runConfig = true;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (arg === '-n' || arg === '--number') {
|
|
149
|
+
if (!next) throw new Error(`${arg} requires a value.`);
|
|
150
|
+
parsed.numberOfImages = Number(next);
|
|
151
|
+
i += 1;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (arg === '-q' || arg === '--quality') {
|
|
156
|
+
if (!next) throw new Error(`${arg} requires a value.`);
|
|
157
|
+
parsed.quality = next;
|
|
158
|
+
i += 1;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (arg === '-r' || arg === '--ratio') {
|
|
163
|
+
if (!next) throw new Error(`${arg} requires a value.`);
|
|
164
|
+
parsed.aspectRatio = next;
|
|
165
|
+
parsed.aspectRatioWasProvided = true;
|
|
166
|
+
i += 1;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (arg === '--width') {
|
|
171
|
+
if (!next) throw new Error(`${arg} requires a value.`);
|
|
172
|
+
parsed.targetWidth = Number(next);
|
|
173
|
+
i += 1;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (arg === '--height') {
|
|
178
|
+
if (!next) throw new Error(`${arg} requires a value.`);
|
|
179
|
+
parsed.targetHeight = Number(next);
|
|
180
|
+
i += 1;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (arg === '-m' || arg === '--model') {
|
|
185
|
+
if (!next) throw new Error(`${arg} requires a value.`);
|
|
186
|
+
parsed.model = String(next).toLowerCase();
|
|
187
|
+
i += 1;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (arg === '-o' || arg === '--output') {
|
|
192
|
+
if (!next) throw new Error(`${arg} requires a value.`);
|
|
193
|
+
parsed.output = next;
|
|
194
|
+
i += 1;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (arg === '-f' || arg === '--format') {
|
|
199
|
+
if (!next) throw new Error(`${arg} requires a value.`);
|
|
200
|
+
parsed.format = String(next).replace(/^\./, '').toLowerCase();
|
|
201
|
+
i += 1;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (arg === '--ref') {
|
|
206
|
+
if (!next) throw new Error(`${arg} requires a value.`);
|
|
207
|
+
parsed.references.push(parseRefValue(next));
|
|
208
|
+
i += 1;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (arg === '--no-sharp') {
|
|
213
|
+
parsed.useSharp = false;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (arg.startsWith('-')) {
|
|
218
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
parsed.promptParts.push(arg);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (!parsed.runConfig && !parsed.showHelp && parsed.promptParts.length === 0) {
|
|
225
|
+
throw new Error('Missing prompt text. Use genmix "your prompt".');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (!Number.isInteger(parsed.numberOfImages) || parsed.numberOfImages <= 0) {
|
|
229
|
+
throw new Error('Number of images must be a positive integer.');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const quality = parsed.quality.toUpperCase();
|
|
233
|
+
if (!['1K', '2K', '4K'].includes(quality)) {
|
|
234
|
+
throw new Error('Quality must be one of: 1K, 2K, 4K.');
|
|
235
|
+
}
|
|
236
|
+
parsed.quality = quality;
|
|
237
|
+
|
|
238
|
+
if (!['flash', 'pro'].includes(parsed.model)) {
|
|
239
|
+
throw new Error('Model must be "flash" or "pro".');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const hasOnlyOneDimension = (parsed.targetWidth === null) !== (parsed.targetHeight === null);
|
|
243
|
+
if (hasOnlyOneDimension) {
|
|
244
|
+
throw new Error('Both --width and --height are required together.');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const hasTargetDimensions = parsed.targetWidth !== null && parsed.targetHeight !== null;
|
|
248
|
+
if (hasTargetDimensions) {
|
|
249
|
+
if (!Number.isInteger(parsed.targetWidth) || parsed.targetWidth <= 0) {
|
|
250
|
+
throw new Error('Width must be a positive integer.');
|
|
251
|
+
}
|
|
252
|
+
if (!Number.isInteger(parsed.targetHeight) || parsed.targetHeight <= 0) {
|
|
253
|
+
throw new Error('Height must be a positive integer.');
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (!parsed.useSharp && hasTargetDimensions) {
|
|
258
|
+
throw new Error('Resizing requires Sharp. Remove --no-sharp to use --width/--height.');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (!parsed.aspectRatioWasProvided && !hasTargetDimensions) {
|
|
262
|
+
parsed.aspectRatio = '1:1';
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
for (const ref of parsed.references) {
|
|
266
|
+
if (!ref.imagePath) {
|
|
267
|
+
throw new Error('Reference path cannot be empty.');
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
parsed.prompt = parsed.promptParts.join(' ').trim();
|
|
272
|
+
return parsed;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function resolveOutput(outputArg, format) {
|
|
276
|
+
const extension = path.extname(outputArg).replace(/^\./, '').toLowerCase();
|
|
277
|
+
const isFilePath = extension && IMAGE_EXTENSIONS.has(extension);
|
|
278
|
+
|
|
279
|
+
if (isFilePath) {
|
|
280
|
+
const absolute = path.resolve(process.cwd(), outputArg);
|
|
281
|
+
return {
|
|
282
|
+
directory: path.dirname(absolute),
|
|
283
|
+
filename: path.basename(absolute, path.extname(absolute)),
|
|
284
|
+
extension
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
directory: path.resolve(process.cwd(), outputArg),
|
|
290
|
+
filename: null,
|
|
291
|
+
extension: format
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function ensureApiKey() {
|
|
296
|
+
const config = loadConfig();
|
|
297
|
+
const envApiKey = process.env.GEMINI_API_KEY;
|
|
298
|
+
if (envApiKey && envApiKey.trim()) {
|
|
299
|
+
return envApiKey.trim();
|
|
300
|
+
}
|
|
301
|
+
if (config.apiKey && config.apiKey.trim()) {
|
|
302
|
+
return config.apiKey.trim();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
info('No API key found. Let us configure it now.');
|
|
306
|
+
const newApiKey = await promptApiKey();
|
|
307
|
+
saveConfig({ ...config, apiKey: newApiKey });
|
|
308
|
+
success(`API key saved to ${CONFIG_PATH}`);
|
|
309
|
+
return newApiKey;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function runConfigCommand() {
|
|
313
|
+
const existing = loadConfig();
|
|
314
|
+
if (existing.apiKey) {
|
|
315
|
+
info(`Existing API key found in ${CONFIG_PATH}.`);
|
|
316
|
+
} else {
|
|
317
|
+
info('No saved API key found.');
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const newApiKey = await promptApiKey();
|
|
321
|
+
saveConfig({ ...existing, apiKey: newApiKey });
|
|
322
|
+
success(`API key saved to ${CONFIG_PATH}`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function runGeneration(args) {
|
|
326
|
+
const apiKey = await ensureApiKey();
|
|
327
|
+
const generator = new GeminiGenerator({ apiKey });
|
|
328
|
+
|
|
329
|
+
if (args.model === 'pro') {
|
|
330
|
+
generator.pro();
|
|
331
|
+
} else {
|
|
332
|
+
generator.flash();
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
for (const ref of args.references) {
|
|
336
|
+
generator.addReference(ref.imagePath, ref.description);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
info('Generating image(s)...');
|
|
340
|
+
const generation = await generator.generate(args.prompt, {
|
|
341
|
+
numberOfImages: args.numberOfImages,
|
|
342
|
+
quality: args.quality,
|
|
343
|
+
aspectRatio: args.aspectRatio,
|
|
344
|
+
width: args.targetWidth,
|
|
345
|
+
height: args.targetHeight
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
if (generation.text) {
|
|
349
|
+
info(`Model response: ${generation.text}`);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (!generation.images || generation.images.length === 0) {
|
|
353
|
+
info('No images were returned by the model.');
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const saveOptions = resolveOutput(args.output, args.format);
|
|
358
|
+
saveOptions.useSharp = args.useSharp;
|
|
359
|
+
if (args.targetWidth && args.targetHeight) {
|
|
360
|
+
info(`Resizing final output to ${args.targetWidth}x${args.targetHeight}.`);
|
|
361
|
+
}
|
|
362
|
+
const savedPaths = await generator.save(saveOptions);
|
|
363
|
+
savedPaths.forEach((savedPath) => {
|
|
364
|
+
success(`Saved: ${savedPath}`);
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function main() {
|
|
369
|
+
try {
|
|
370
|
+
const args = parseArgs(process.argv.slice(2));
|
|
371
|
+
|
|
372
|
+
if (args.showHelp) {
|
|
373
|
+
printHelp();
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (args.runConfig) {
|
|
378
|
+
await runConfigCommand();
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
await runGeneration(args);
|
|
383
|
+
} catch (err) {
|
|
384
|
+
error(err.message);
|
|
385
|
+
process.exitCode = 1;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (require.main === module) {
|
|
390
|
+
main();
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
module.exports = {
|
|
394
|
+
parseArgs,
|
|
395
|
+
resolveOutput
|
|
396
|
+
};
|
|
@@ -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.
|
|
@@ -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
|
|
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "genmix",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.5",
|
|
4
4
|
"description": "AI-powered image generator using Google Gemini API. Supports image generation from text prompts and image modification with reference images.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Martin Clasen",
|
|
@@ -17,10 +17,14 @@
|
|
|
17
17
|
"style-transfer",
|
|
18
18
|
"generative-ai",
|
|
19
19
|
"genmix",
|
|
20
|
+
"cli",
|
|
20
21
|
"clasen"
|
|
21
22
|
],
|
|
22
23
|
"type": "commonjs",
|
|
23
24
|
"main": "index.js",
|
|
25
|
+
"bin": {
|
|
26
|
+
"genmix": "./cli.js"
|
|
27
|
+
},
|
|
24
28
|
"scripts": {
|
|
25
29
|
"test": "echo \"Error: no test specified\" && exit 1"
|
|
26
30
|
},
|