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 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 (formatOptions && formatOptions.format) {
52
- extension = formatOptions.format;
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 base64Data = imgData.replace(/^data:image\/\w+;base64,/, "");
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}.${fileExtension}`;
88
+ fileName = `${filename}_${index}.${effectiveExtension}`;
80
89
  } else {
81
- fileName = `${filename}.${fileExtension}`;
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}.${fileExtension}`;
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 (formatOptions && formatOptions.width && formatOptions.height) {
97
- sharpInstance = sharpInstance.resize(formatOptions.width, formatOptions.height, {
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 (formatOptions) {
106
- if (sharpFormat === 'jpg' && formatOptions.quality) {
107
- sharpFormatOptions.quality = formatOptions.quality;
120
+ if (effectiveFormatOptions) {
121
+ if (sharpFormat === 'jpg' && effectiveFormatOptions.quality) {
122
+ sharpFormatOptions.quality = effectiveFormatOptions.quality;
108
123
  } else if (sharpFormat === 'png') {
109
- if (formatOptions.compressionLevel !== undefined) {
110
- sharpFormatOptions.compressionLevel = formatOptions.compressionLevel;
124
+ if (effectiveFormatOptions.compressionLevel !== undefined) {
125
+ sharpFormatOptions.compressionLevel = effectiveFormatOptions.compressionLevel;
111
126
  }
112
- if (formatOptions.quality !== undefined) {
113
- sharpFormatOptions.quality = formatOptions.quality;
127
+ if (effectiveFormatOptions.quality !== undefined) {
128
+ sharpFormatOptions.quality = effectiveFormatOptions.quality;
114
129
  }
115
- if (formatOptions.effort !== undefined) {
116
- sharpFormatOptions.effort = formatOptions.effort;
130
+ if (effectiveFormatOptions.effort !== undefined) {
131
+ sharpFormatOptions.effort = effectiveFormatOptions.effort;
117
132
  }
118
- if (formatOptions.palette) {
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 (formatOptions.colours) {
124
- sharpFormatOptions.colours = formatOptions.colours;
138
+ if (effectiveFormatOptions.colours) {
139
+ sharpFormatOptions.colours = effectiveFormatOptions.colours;
125
140
  }
126
- } else if (sharpFormat === 'webp' && formatOptions.quality) {
127
- sharpFormatOptions.quality = formatOptions.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 = options.numberOfImages || 1;
135
+ const numberOfImages = normalizedOptions.numberOfImages || 1;
84
136
 
85
137
  let result;
86
138
  if (numberOfImages > 1) {
87
- result = await this.generateMultiple(prompt, numberOfImages, options);
139
+ result = await this.generateMultiple(prompt, numberOfImages, normalizedOptions);
88
140
  } else {
89
- result = await this._generateSingleRequest(prompt, options);
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.0.5",
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
  },