genmix 1.0.4 → 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 +190 -2
- package/cli.js +396 -0
- package/demo/example-multiple.js +7 -9
- package/demo/example-references.js +51 -0
- package/demo/example-translate.js +10 -16
- package/demo/example.js +4 -9
- package/demo/package.json +12 -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 +136 -23
- package/index.js +1 -0
- package/package.json +7 -2
- package/skills/genmix/SKILL.md +51 -0
package/README.md
CHANGED
|
@@ -17,6 +17,13 @@ AI-powered image generator using Google Gemini API. Supports image generation fr
|
|
|
17
17
|
npm install genmix
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
+
### AI Skill
|
|
21
|
+
You can also add GenMix as a skill for AI agentic development:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npx skills add https://github.com/clasen/GenMix --skill genmix
|
|
25
|
+
```
|
|
26
|
+
|
|
20
27
|
## Setup
|
|
21
28
|
|
|
22
29
|
Create a `.env` file in your project root:
|
|
@@ -27,10 +34,47 @@ GEMINI_API_KEY=your_api_key_here
|
|
|
27
34
|
|
|
28
35
|
## Basic Usage
|
|
29
36
|
|
|
37
|
+
### The Power of GenMix: Multiple References & Chainable API
|
|
38
|
+
|
|
39
|
+
GenMix shines when combining multiple images with specific instructions using its intuitive chainable API:
|
|
40
|
+
|
|
41
|
+
```javascript
|
|
42
|
+
import { GeminiGenerator } from 'genmix';
|
|
43
|
+
const generator = new GeminiGenerator();
|
|
44
|
+
|
|
45
|
+
const result = await generator
|
|
46
|
+
.pro() // Use the Pro model for best results
|
|
47
|
+
.addReference('./person.jpg', 'Use this person as the main subject')
|
|
48
|
+
.addReference('./background.jpg', 'Use this as the background setting')
|
|
49
|
+
.generate('A photo of the person standing in the background setting, cinematic lighting');
|
|
50
|
+
|
|
51
|
+
await generator.save({ filename: 'composite-result' });
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Model Selection
|
|
55
|
+
|
|
56
|
+
You can easily switch between the Pro and Flash models using chainable methods:
|
|
57
|
+
|
|
58
|
+
```javascript
|
|
59
|
+
import { GeminiGenerator } from 'genmix';
|
|
60
|
+
|
|
61
|
+
const generator = new GeminiGenerator();
|
|
62
|
+
|
|
63
|
+
// Use the Pro model
|
|
64
|
+
await generator
|
|
65
|
+
.pro()
|
|
66
|
+
.generate('A highly detailed portrait of a cat');
|
|
67
|
+
|
|
68
|
+
// Switch to the Flash model
|
|
69
|
+
await generator
|
|
70
|
+
.flash()
|
|
71
|
+
.generate('A quick sketch of a dog');
|
|
72
|
+
```
|
|
73
|
+
|
|
30
74
|
### Simple Image Generation
|
|
31
75
|
|
|
32
76
|
```javascript
|
|
33
|
-
|
|
77
|
+
import { GeminiGenerator } from 'genmix';
|
|
34
78
|
|
|
35
79
|
const generator = new GeminiGenerator();
|
|
36
80
|
|
|
@@ -90,6 +134,23 @@ new GeminiGenerator({
|
|
|
90
134
|
})
|
|
91
135
|
```
|
|
92
136
|
|
|
137
|
+
### Model Selection Methods
|
|
138
|
+
|
|
139
|
+
```javascript
|
|
140
|
+
generator.pro() // Switches to the gemini-3-pro-image-preview model
|
|
141
|
+
generator.flash() // Switches to the gemini-3.1-flash-image-preview model
|
|
142
|
+
```
|
|
143
|
+
Both methods are chainable and return the generator instance.
|
|
144
|
+
|
|
145
|
+
### Reference Methods
|
|
146
|
+
|
|
147
|
+
You can also use chainable methods to add one or multiple reference images before calling `generate()`:
|
|
148
|
+
|
|
149
|
+
```javascript
|
|
150
|
+
generator.addReference(image, description) // Adds a reference image (path, URL, Buffer)
|
|
151
|
+
generator.clearReferences() // Removes all queued reference images
|
|
152
|
+
```
|
|
153
|
+
|
|
93
154
|
### generate() Method
|
|
94
155
|
|
|
95
156
|
```javascript
|
|
@@ -105,6 +166,8 @@ await generator.generate(prompt, options)
|
|
|
105
166
|
| `options.numberOfImages` | number | Number of images to generate | 1 |
|
|
106
167
|
| `options.quality` | string | Quality: '1K', '2K', '4K' | - |
|
|
107
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`) | - |
|
|
108
171
|
|
|
109
172
|
### save() Method
|
|
110
173
|
|
|
@@ -142,6 +205,25 @@ await generator.save({ filename: 'my-image' });
|
|
|
142
205
|
await generator.save();
|
|
143
206
|
```
|
|
144
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
|
+
|
|
145
227
|
**Note:**
|
|
146
228
|
- When multiple images are generated and a custom filename is provided, they will be saved as `filename_0.jpg`, `filename_1.jpg`, etc.
|
|
147
229
|
- The method uses Sharp for image conversion, supporting high-quality format conversion
|
|
@@ -190,7 +272,7 @@ const result = await generator.generate(
|
|
|
190
272
|
### Using Buffers
|
|
191
273
|
|
|
192
274
|
```javascript
|
|
193
|
-
|
|
275
|
+
import fs from 'fs';
|
|
194
276
|
const imageBuffer = fs.readFileSync('./image.png');
|
|
195
277
|
|
|
196
278
|
const result = await generator.generate(
|
|
@@ -269,6 +351,112 @@ genmix/
|
|
|
269
351
|
|
|
270
352
|
4. **Result Caching**: Images are automatically saved with unique hash based on the prompt
|
|
271
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
|
+
|
|
272
460
|
## Additional Resources
|
|
273
461
|
|
|
274
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
|
+
};
|
package/demo/example-multiple.js
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
|
-
process.loadEnvFile();
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
process.loadEnvFile();
|
|
2
|
+
import { GeminiGenerator } from '../index.js';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import fs from 'fs';
|
|
5
5
|
|
|
6
6
|
async function main() {
|
|
7
7
|
try {
|
|
8
8
|
const generator = new GeminiGenerator();
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
const localImagePath = path.join(import.meta.dirname, 'camera_4126.jpg');
|
|
11
|
+
|
|
13
12
|
if (fs.existsSync(localImagePath)) {
|
|
14
13
|
console.log('🎨 Generating multiple variations...\n');
|
|
15
14
|
|
|
@@ -23,7 +22,7 @@ async function main() {
|
|
|
23
22
|
);
|
|
24
23
|
|
|
25
24
|
if (result.images && result.images.length > 0) {
|
|
26
|
-
const saved = await generator.save({ directory:
|
|
25
|
+
const saved = await generator.save({ directory: import.meta.dirname });
|
|
27
26
|
console.log(`✅ ${saved.length} variations saved:`);
|
|
28
27
|
saved.forEach(p => console.log(` - ${p}`));
|
|
29
28
|
console.log();
|
|
@@ -46,4 +45,3 @@ async function main() {
|
|
|
46
45
|
}
|
|
47
46
|
|
|
48
47
|
main();
|
|
49
|
-
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
try { process.loadEnvFile(); } catch (error) { }
|
|
2
|
+
import { GeminiGenerator } from '../index.js';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
try {
|
|
7
|
+
const generator = new GeminiGenerator();
|
|
8
|
+
|
|
9
|
+
const background = path.join(import.meta.dirname, 'background.jpg');
|
|
10
|
+
const subject = path.join(import.meta.dirname, 'selfie.jpg');
|
|
11
|
+
|
|
12
|
+
console.log('🖼️ Adding references...');
|
|
13
|
+
generator
|
|
14
|
+
.addReference(background, 'use as background landscape')
|
|
15
|
+
.addReference(subject, 'use as the main subject');
|
|
16
|
+
|
|
17
|
+
console.log('🎨 Generating composite image...\n');
|
|
18
|
+
|
|
19
|
+
const result = await generator.generate(
|
|
20
|
+
'Place the subject in front of the background, cinematic lighting, golden hour',
|
|
21
|
+
{ quality: '2K' }
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
if (result.text) {
|
|
25
|
+
console.log('📝 Model notes:', result.text);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (result.images && result.images.length > 0) {
|
|
29
|
+
const saved = await generator.save({
|
|
30
|
+
directory: import.meta.dirname,
|
|
31
|
+
filename: 'composite-result'
|
|
32
|
+
});
|
|
33
|
+
console.log(`✅ Saved: ${saved[0]}\n`);
|
|
34
|
+
} else {
|
|
35
|
+
console.log('⚠️ No images generated.\n');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
console.log('🎉 Example completed!\n');
|
|
39
|
+
|
|
40
|
+
} catch (error) {
|
|
41
|
+
console.error('❌ Error:', error.message);
|
|
42
|
+
|
|
43
|
+
if (error.message.includes('API Key')) {
|
|
44
|
+
console.error('\n💡 Tip: Make sure you have GEMINI_API_KEY in your .env file');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (error.message.includes('reference image')) {
|
|
48
|
+
console.error('\n💡 Tip: Place background.jpg and subject.jpg in the demo/ folder');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
@@ -1,14 +1,13 @@
|
|
|
1
|
-
process.loadEnvFile();
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
process.loadEnvFile();
|
|
2
|
+
import { GeminiGenerator } from '../index.js';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import fs from 'fs';
|
|
5
5
|
|
|
6
6
|
async function main() {
|
|
7
7
|
try {
|
|
8
8
|
const generator = new GeminiGenerator();
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
const localImagePath = path.join(__dirname, 'camera_4126.jpg');
|
|
10
|
+
const localImagePath = path.join(import.meta.dirname, 'camera_4126.jpg');
|
|
12
11
|
|
|
13
12
|
if (fs.existsSync(localImagePath)) {
|
|
14
13
|
console.log('📸 Processing local image...\n');
|
|
@@ -23,21 +22,17 @@ async function main() {
|
|
|
23
22
|
);
|
|
24
23
|
|
|
25
24
|
if (result1.images && result1.images.length > 0) {
|
|
26
|
-
|
|
27
|
-
const ptDir = path.join(__dirname, 'pt');
|
|
25
|
+
const ptDir = path.join(import.meta.dirname, 'pt');
|
|
28
26
|
if (!fs.existsSync(ptDir)) {
|
|
29
27
|
fs.mkdirSync(ptDir, { recursive: true });
|
|
30
28
|
}
|
|
31
|
-
|
|
32
|
-
// Use the same filename as the original
|
|
29
|
+
|
|
33
30
|
const originalName = path.basename(localImagePath, path.extname(localImagePath));
|
|
34
|
-
|
|
35
|
-
// Get reference metadata to match format
|
|
31
|
+
|
|
36
32
|
const refMetadata = generator.getReferenceMetadata();
|
|
37
33
|
console.log('📊 Reference format:', refMetadata);
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const saved = await generator.save({
|
|
34
|
+
|
|
35
|
+
const saved = await generator.save({
|
|
41
36
|
directory: ptDir,
|
|
42
37
|
filename: originalName,
|
|
43
38
|
formatOptions: refMetadata
|
|
@@ -64,4 +59,3 @@ async function main() {
|
|
|
64
59
|
}
|
|
65
60
|
|
|
66
61
|
main();
|
|
67
|
-
|
package/demo/example.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
process.loadEnvFile();
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
process.loadEnvFile();
|
|
2
|
+
import { GeminiGenerator } from '../index.js';
|
|
4
3
|
|
|
5
4
|
async function exampleBasicGeneration() {
|
|
6
5
|
console.log('\n=== Example 1: Basic Image Generation ===\n');
|
|
@@ -12,7 +11,7 @@ async function exampleBasicGeneration() {
|
|
|
12
11
|
console.log('Generating image...');
|
|
13
12
|
const result = await generator.generate(prompt, {
|
|
14
13
|
numberOfImages: 2,
|
|
15
|
-
quality: '1K',
|
|
14
|
+
quality: '1K',
|
|
16
15
|
aspectRatio: '1:1'
|
|
17
16
|
});
|
|
18
17
|
|
|
@@ -25,20 +24,16 @@ async function exampleBasicGeneration() {
|
|
|
25
24
|
if (result.images && result.images.length > 0) {
|
|
26
25
|
console.log(`Found ${result.images.length} images.`);
|
|
27
26
|
|
|
28
|
-
const savedPaths = await generator.save({ directory:
|
|
27
|
+
const savedPaths = await generator.save({ directory: import.meta.dirname });
|
|
29
28
|
savedPaths.forEach(p => console.log(`Saved image to ${p}`));
|
|
30
29
|
} else {
|
|
31
30
|
console.log('No images generated.');
|
|
32
31
|
}
|
|
33
32
|
}
|
|
34
33
|
|
|
35
|
-
|
|
36
|
-
|
|
37
34
|
async function main() {
|
|
38
35
|
try {
|
|
39
|
-
// Example 1: Basic generation
|
|
40
36
|
await exampleBasicGeneration();
|
|
41
|
-
|
|
42
37
|
console.log('\n=== All examples completed! ===\n');
|
|
43
38
|
} catch (error) {
|
|
44
39
|
console.error('Error:', error.message);
|
|
@@ -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
|
|
|
@@ -4,10 +4,15 @@ const fs = require('fs');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
|
|
6
6
|
class GeminiGenerator extends BaseGenerator {
|
|
7
|
+
static MODELS = {
|
|
8
|
+
PRO: 'gemini-3-pro-image-preview',
|
|
9
|
+
FLASH: 'gemini-3.1-flash-image-preview',
|
|
10
|
+
};
|
|
11
|
+
|
|
7
12
|
/**
|
|
8
13
|
* @param {Object} [config]
|
|
9
14
|
* @param {string} [config.apiKey]
|
|
10
|
-
* @param {string} [config.modelId]
|
|
15
|
+
* @param {string} [config.modelId] - Model ID or use GeminiGenerator.MODELS constants
|
|
11
16
|
*/
|
|
12
17
|
constructor(config = {}) {
|
|
13
18
|
super(config);
|
|
@@ -16,36 +21,124 @@ class GeminiGenerator extends BaseGenerator {
|
|
|
16
21
|
if (!this.apiKey) {
|
|
17
22
|
throw new Error('API Key is required. Provide it in the constructor or set GEMINI_API_KEY environment variable.');
|
|
18
23
|
}
|
|
19
|
-
|
|
20
|
-
// However, for standard image generation via prompt, specific models like 'gemini-1.5-pro' or 'imagen-3.0-generate-001' are often used.
|
|
21
|
-
// Keeping the user's modelId preference.
|
|
22
|
-
this.modelId = config.modelId || 'gemini-3-pro-image-preview';
|
|
23
|
-
// NOTE: For simple generation, we might not need streamGenerateContent unless we want text streaming.
|
|
24
|
-
// But let's stick to what worked for text, and adjust the body.
|
|
24
|
+
this.modelId = config.modelId || GeminiGenerator.MODELS.FLASH;
|
|
25
25
|
this.apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${this.modelId}:streamGenerateContent`;
|
|
26
26
|
this.referenceMetadata = null;
|
|
27
|
+
this.references = [];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Add a reference image to be included in the next generate() call.
|
|
32
|
+
* @param {string|Buffer} image - Path to image file, URL, base64 data URI, or Buffer
|
|
33
|
+
* @param {string} [description] - How the model should use this image (e.g. 'use as background')
|
|
34
|
+
* @returns {GeminiGenerator} this instance for chaining
|
|
35
|
+
*/
|
|
36
|
+
addReference(image, description = '') {
|
|
37
|
+
this.references.push({ image, description });
|
|
38
|
+
return this;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Remove all queued reference images.
|
|
43
|
+
* @returns {GeminiGenerator} this instance for chaining
|
|
44
|
+
*/
|
|
45
|
+
clearReferences() {
|
|
46
|
+
this.references = [];
|
|
47
|
+
return this;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Switch to the Pro model
|
|
52
|
+
* @returns {GeminiGenerator} this instance for chaining
|
|
53
|
+
*/
|
|
54
|
+
pro() {
|
|
55
|
+
this.modelId = GeminiGenerator.MODELS.PRO;
|
|
56
|
+
this.apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${this.modelId}:streamGenerateContent`;
|
|
57
|
+
return this;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Switch to the Flash model
|
|
62
|
+
* @returns {GeminiGenerator} this instance for chaining
|
|
63
|
+
*/
|
|
64
|
+
flash() {
|
|
65
|
+
this.modelId = GeminiGenerator.MODELS.FLASH;
|
|
66
|
+
this.apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${this.modelId}:streamGenerateContent`;
|
|
67
|
+
return this;
|
|
68
|
+
}
|
|
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;
|
|
27
117
|
}
|
|
28
118
|
|
|
29
119
|
/**
|
|
30
120
|
* @param {string} prompt
|
|
31
121
|
* @param {Object} [options]
|
|
32
|
-
* @param {string|Buffer} [options.referenceImage] - Path to image file, base64 data URI, or Buffer
|
|
122
|
+
* @param {string|Buffer} [options.referenceImage] - Path to image file, base64 data URI, or Buffer (legacy single-image API)
|
|
33
123
|
* @param {string} [options.numberOfImages] - Number of images to generate
|
|
34
124
|
* @param {string} [options.quality] - Image quality: '1K', '2K', '4K'
|
|
35
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)
|
|
36
128
|
* @returns {Promise<{images: string[], text: string, raw: any}>}
|
|
37
129
|
*/
|
|
38
130
|
async generate(prompt, options = {}) {
|
|
131
|
+
const normalizedOptions = this._normalizeGenerateOptions(options);
|
|
39
132
|
// If the user asks for multiple images, we might need to make parallel requests
|
|
40
133
|
// if the API doesn't support candidateCount > 1 for images.
|
|
41
134
|
// Based on search results, candidateCount > 1 can cause 400 errors.
|
|
42
|
-
const numberOfImages =
|
|
135
|
+
const numberOfImages = normalizedOptions.numberOfImages || 1;
|
|
43
136
|
|
|
44
137
|
let result;
|
|
45
138
|
if (numberOfImages > 1) {
|
|
46
|
-
result = await this.generateMultiple(prompt, numberOfImages,
|
|
139
|
+
result = await this.generateMultiple(prompt, numberOfImages, normalizedOptions);
|
|
47
140
|
} else {
|
|
48
|
-
result = await this._generateSingleRequest(prompt,
|
|
141
|
+
result = await this._generateSingleRequest(prompt, normalizedOptions);
|
|
49
142
|
}
|
|
50
143
|
|
|
51
144
|
// Store result in state for saveImages()
|
|
@@ -53,9 +146,14 @@ class GeminiGenerator extends BaseGenerator {
|
|
|
53
146
|
prompt: prompt,
|
|
54
147
|
images: result.images,
|
|
55
148
|
text: result.text,
|
|
56
|
-
raw: result.raw
|
|
149
|
+
raw: result.raw,
|
|
150
|
+
formatOptions: normalizedOptions.width && normalizedOptions.height
|
|
151
|
+
? { width: normalizedOptions.width, height: normalizedOptions.height }
|
|
152
|
+
: null
|
|
57
153
|
};
|
|
58
154
|
|
|
155
|
+
this.references = [];
|
|
156
|
+
|
|
59
157
|
return result;
|
|
60
158
|
}
|
|
61
159
|
|
|
@@ -225,27 +323,42 @@ class GeminiGenerator extends BaseGenerator {
|
|
|
225
323
|
}
|
|
226
324
|
}
|
|
227
325
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
326
|
+
/**
|
|
327
|
+
* Builds the API parts array from queued references + prompt.
|
|
328
|
+
* Each reference becomes [inlineData, text description (if any)], followed by the main prompt.
|
|
329
|
+
* @param {string} prompt
|
|
330
|
+
* @returns {Promise<Object[]>}
|
|
331
|
+
* @private
|
|
332
|
+
*/
|
|
333
|
+
async _buildReferenceParts(prompt) {
|
|
232
334
|
const parts = [];
|
|
233
335
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
const imageData = await this._processReferenceImage(options.referenceImage);
|
|
336
|
+
for (const ref of this.references) {
|
|
337
|
+
const imageData = await this._processReferenceImage(ref.image);
|
|
237
338
|
parts.push({
|
|
238
339
|
inlineData: {
|
|
239
340
|
mimeType: imageData.mimeType,
|
|
240
341
|
data: imageData.data
|
|
241
342
|
}
|
|
242
343
|
});
|
|
344
|
+
if (ref.description) {
|
|
345
|
+
parts.push({ text: `Reference: ${ref.description}` });
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
parts.push({ text: prompt });
|
|
350
|
+
return parts;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async _generateSingleRequest(prompt, options) {
|
|
354
|
+
const url = `${this.apiUrl}?key=${this.apiKey}`;
|
|
355
|
+
|
|
356
|
+
// Legacy single-image API: promote to references for unified code path
|
|
357
|
+
if (options.referenceImage && this.references.length === 0) {
|
|
358
|
+
this.addReference(options.referenceImage);
|
|
243
359
|
}
|
|
244
360
|
|
|
245
|
-
|
|
246
|
-
parts.push({
|
|
247
|
-
text: prompt,
|
|
248
|
-
});
|
|
361
|
+
const parts = await this._buildReferenceParts(prompt);
|
|
249
362
|
|
|
250
363
|
// Simplified data structure to minimize conflicts
|
|
251
364
|
const data = {
|
package/index.js
CHANGED
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",
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"nano",
|
|
9
9
|
"banana",
|
|
10
10
|
"pro",
|
|
11
|
+
"flash",
|
|
11
12
|
"image-generator",
|
|
12
13
|
"google-gemini",
|
|
13
14
|
"gemini-api",
|
|
@@ -16,15 +17,19 @@
|
|
|
16
17
|
"style-transfer",
|
|
17
18
|
"generative-ai",
|
|
18
19
|
"genmix",
|
|
20
|
+
"cli",
|
|
19
21
|
"clasen"
|
|
20
22
|
],
|
|
21
23
|
"type": "commonjs",
|
|
22
24
|
"main": "index.js",
|
|
25
|
+
"bin": {
|
|
26
|
+
"genmix": "./cli.js"
|
|
27
|
+
},
|
|
23
28
|
"scripts": {
|
|
24
29
|
"test": "echo \"Error: no test specified\" && exit 1"
|
|
25
30
|
},
|
|
26
31
|
"dependencies": {
|
|
27
|
-
"axios": "^1.13.
|
|
32
|
+
"axios": "^1.13.6",
|
|
28
33
|
"hash-factory": "^1.1.2",
|
|
29
34
|
"sharp": "^0.33.5"
|
|
30
35
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: genmix
|
|
3
|
+
description: AI-powered image generator using Google Gemini API. Use this skill when the user asks to generate an image from text, modify an existing image with a reference, apply style transfer, or create an image based on a prompt.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# GenMix Skill
|
|
7
|
+
|
|
8
|
+
# Instructions
|
|
9
|
+
|
|
10
|
+
### Step 1: Install GenMix
|
|
11
|
+
If GenMix is not already installed in the project, install it:
|
|
12
|
+
```bash
|
|
13
|
+
npm install genmix
|
|
14
|
+
```
|
|
15
|
+
Ensure `GEMINI_API_KEY` is set in the `.env` file.
|
|
16
|
+
|
|
17
|
+
### Step 2: Write the Generation Script
|
|
18
|
+
Write a Node.js script to use GenMix based on the user's request.
|
|
19
|
+
|
|
20
|
+
Example for generating a new image:
|
|
21
|
+
```javascript
|
|
22
|
+
import { GeminiGenerator } from 'genmix';
|
|
23
|
+
|
|
24
|
+
const generator = new GeminiGenerator();
|
|
25
|
+
await generator.generate('Your prompt here', { quality: '2K' });
|
|
26
|
+
await generator.save({ directory: './output' });
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Example for modifying an image with a reference:
|
|
30
|
+
```javascript
|
|
31
|
+
import { GeminiGenerator } from 'genmix';
|
|
32
|
+
|
|
33
|
+
const generator = new GeminiGenerator();
|
|
34
|
+
await generator
|
|
35
|
+
.addReference('./path/to/reference.jpg', 'Description of reference')
|
|
36
|
+
.generate('Your modification prompt here', { quality: '2K' });
|
|
37
|
+
await generator.save({ directory: './output' });
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Step 3: Execute the script
|
|
41
|
+
Run the script using Node.js to generate the image.
|
|
42
|
+
|
|
43
|
+
## Examples
|
|
44
|
+
|
|
45
|
+
**Example 1: Generate a futuristic city**
|
|
46
|
+
User says: "Generate an image of a futuristic city"
|
|
47
|
+
Actions: Create a script using GenMix to generate the image with the prompt "A futuristic city with flying cars, cyberpunk style", run the script, and save the output.
|
|
48
|
+
|
|
49
|
+
**Example 2: Modify an image**
|
|
50
|
+
User says: "Make this portrait look like a watercolor painting"
|
|
51
|
+
Actions: Create a script using GenMix, add the portrait as a reference image, use the prompt "Transform this photo into a watercolor painting", run the script, and save the output.
|