genmix 1.2.5 → 1.2.7

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
@@ -1,6 +1,6 @@
1
1
  # 🎨 GenMix
2
2
 
3
- AI-powered image generator supporting Google Gemini and Fal Nano Banana 2. Supports image generation from text prompts and image modification with reference images (Gemini).
3
+ AI-powered image generator supporting Google Gemini, Fal Nano Banana 2, and OpenAI GPT Image 2.5. Supports image generation from text prompts and image modification with reference images (Gemini).
4
4
 
5
5
  ## Features ✨
6
6
 
@@ -35,6 +35,41 @@ FAL_API_KEY=your_fal_api_key_here
35
35
 
36
36
  `GEMINI_API_KEY` is used with provider `gemini` and `FAL_API_KEY` is used with provider `fal`.
37
37
 
38
+ ## OpenAI GPT Image 2.5
39
+
40
+ Set `OPENAI_API_KEY` in your environment (the CLI does not persist OpenAI keys).
41
+
42
+ ```javascript
43
+ const { OpenAIGenerator, OPENAI_MODELS } = require('genmix');
44
+ const generator = new OpenAIGenerator(); // Sunburst by default
45
+ await generator.generate('A watercolor city', { quality: 'high' });
46
+ await generator.save({ filename: 'city', extension: 'png' });
47
+
48
+ await generator.flare().addReference('./city.png', 'Preserve the buildings')
49
+ .generate('Change the scene to winter', { quality: 'xhigh' });
50
+ await generator.save({ filename: 'winter', extension: 'png' });
51
+ ```
52
+
53
+ `OPENAI_MODELS.SUNBURST` and `OPENAI_MODELS.FLARE` can also be passed as
54
+ `modelId` in the constructor. Use `.sunburst()` or `.flare()` to switch models.
55
+ References accept local paths, URLs, image data URIs, or Buffers (PNG/JPEG/WebP).
56
+ Successful generation clears queued references; failed requests retain them.
57
+
58
+ ```bash
59
+ genmix "A watercolor city" --provider openai -m sunburst -q high -o city.png
60
+ genmix "Change to winter" --provider openai -m flare --ref city.png -o winter.png
61
+ ```
62
+
63
+ OpenAI quality values are `low`, `medium`, `high`, `xhigh`, `max`, and `auto`
64
+ (default). `numberOfImages` accepts 1–10. `aspectRatio` accepts ratios from 1:3
65
+ to 3:1; generation dimensions approximate that ratio on a 16-pixel grid around
66
+ one megapixel. With no ratio or dimensions, the API chooses the size.
67
+ `width` and `height` set the final dimensions when saving with Sharp, and guide
68
+ the generated aspect ratio. Images are returned as PNG data URIs with the raw API
69
+ response (including usage) in `raw`. Generation and editing use the Image API.
70
+
71
+ Official documentation: [GPT Image generation](https://developers.openai.com/api/docs/guides/image-generation).
72
+
38
73
  ## Basic Usage
39
74
 
40
75
  ### The Power of GenMix: Multiple References & Chainable API
package/cli.js CHANGED
@@ -6,6 +6,7 @@ const os = require('os');
6
6
  const readline = require('readline');
7
7
  const GeminiGenerator = require('./generators/GeminiGenerator');
8
8
  const FalGenerator = require('./generators/FalGenerator');
9
+ const OpenAIGenerator = require('./generators/OpenAIGenerator');
9
10
 
10
11
  const CONFIG_DIR = path.join(os.homedir(), '.genmix');
11
12
  const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
@@ -89,13 +90,14 @@ Usage:
89
90
  genmix --help
90
91
 
91
92
  Options:
92
- -p, --provider <gemini|fal> Provider (default: gemini)
93
+ -p, --provider <gemini|fal|openai> Provider (default: gemini)
93
94
  -n, --number <N> Number of images (default: 1)
94
- -q, --quality <1K|2K|4K> Image quality (default: 1K)
95
- -r, --ratio <ratio> Aspect ratio (default: 1:1 for gemini, auto for fal)
95
+ -q, --quality <quality> Image quality (Gemini/Fal default: 1K; OpenAI: low|medium|high|xhigh|max|auto)
96
+ -r, --ratio <ratio> Aspect ratio (default: 1:1 for gemini, auto for fal/openai)
96
97
  --width <px> Final output width in pixels (requires --height)
97
98
  --height <px> Final output height in pixels (requires --width)
98
99
  -m, --model <...> Model by provider:
100
+ openai -> sunburst|flare (default: sunburst)
99
101
  gemini -> pro|flash (default: flash)
100
102
  fal -> pro|flash (aliases: banana-pro|banana2|2, default: flash)
101
103
  -o, --output <path> Output directory OR full output file path
@@ -147,7 +149,7 @@ function parseArgs(argv) {
147
149
  references: [],
148
150
  provider: 'gemini',
149
151
  numberOfImages: 1,
150
- quality: '1K',
152
+ quality: null,
151
153
  aspectRatio: null,
152
154
  aspectRatioWasProvided: false,
153
155
  model: null,
@@ -267,14 +269,19 @@ function parseArgs(argv) {
267
269
  throw new Error('Number of images must be a positive integer.');
268
270
  }
269
271
 
270
- const quality = parsed.quality.toUpperCase();
271
- if (!['1K', '2K', '4K'].includes(quality)) {
272
+ const quality = (parsed.quality || (parsed.provider === 'openai' ? 'auto' : '1K')).toUpperCase();
273
+ if (parsed.provider !== 'openai' && !['1K', '2K', '4K'].includes(quality)) {
272
274
  throw new Error('Quality must be one of: 1K, 2K, 4K.');
273
275
  }
274
- parsed.quality = quality;
276
+ parsed.quality = parsed.provider === 'openai' ? quality.toLowerCase() : quality;
275
277
 
276
- if (!['gemini', 'fal'].includes(parsed.provider)) {
277
- throw new Error('Provider must be "gemini" or "fal".');
278
+ if (!['gemini', 'fal', 'openai'].includes(parsed.provider)) {
279
+ throw new Error('Provider must be "gemini", "fal", or "openai".');
280
+ }
281
+
282
+ if (parsed.provider === 'openai') {
283
+ if (!parsed.modelWasProvided) parsed.model = 'sunburst';
284
+ if (!['sunburst', 'flare'].includes(parsed.model)) throw new Error('OpenAI model must be sunburst or flare.');
278
285
  }
279
286
 
280
287
  if (parsed.provider === 'gemini' && !parsed.modelWasProvided) {
@@ -313,7 +320,7 @@ function parseArgs(argv) {
313
320
  }
314
321
 
315
322
  if (!parsed.aspectRatioWasProvided && !hasTargetDimensions) {
316
- parsed.aspectRatio = parsed.provider === 'fal' ? 'auto' : '1:1';
323
+ parsed.aspectRatio = parsed.provider !== 'gemini' ? 'auto' : '1:1';
317
324
  }
318
325
 
319
326
  for (const ref of parsed.references) {
@@ -349,6 +356,12 @@ function resolveOutput(outputArg, format) {
349
356
  async function ensureApiKey(provider = 'gemini') {
350
357
  const config = loadConfig();
351
358
 
359
+ if (provider === 'openai') {
360
+ const apiKey = process.env.OPENAI_API_KEY;
361
+ if (!apiKey || !apiKey.trim()) throw new Error('Set OPENAI_API_KEY to use the OpenAI provider.');
362
+ return apiKey.trim();
363
+ }
364
+
352
365
  if (provider === 'fal') {
353
366
  const envApiKey = process.env.FAL_API_KEY;
354
367
  if (envApiKey && envApiKey.trim()) {
@@ -384,6 +397,10 @@ async function ensureApiKey(provider = 'gemini') {
384
397
  }
385
398
 
386
399
  async function runConfigCommand(provider) {
400
+ if (provider === 'openai') {
401
+ info('Set OPENAI_API_KEY in your environment to configure OpenAI.');
402
+ return;
403
+ }
387
404
  const existing = loadConfig();
388
405
  if (provider === 'fal') {
389
406
  if (existing.falApiKey) {
@@ -413,9 +430,13 @@ async function runGeneration(args) {
413
430
  const apiKey = await ensureApiKey(args.provider);
414
431
  const generator = args.provider === 'fal'
415
432
  ? new FalGenerator({ apiKey })
416
- : new GeminiGenerator({ apiKey });
433
+ : args.provider === 'openai'
434
+ ? new OpenAIGenerator({ apiKey })
435
+ : new GeminiGenerator({ apiKey });
417
436
 
418
- if (args.provider === 'gemini') {
437
+ if (args.provider === 'openai') {
438
+ generator[args.model]();
439
+ } else if (args.provider === 'gemini') {
419
440
  if (args.model === 'pro') {
420
441
  generator.pro();
421
442
  } else {
@@ -0,0 +1,127 @@
1
+ const BaseGenerator = require('./BaseGenerator');
2
+ const fs = require('fs/promises');
3
+ const sharp = require('sharp');
4
+
5
+ class OpenAIGenerator extends BaseGenerator {
6
+ static MODELS = {
7
+ SUNBURST: 'gpt-image-2.5-sunburst',
8
+ FLARE: 'gpt-image-2.5-flare'
9
+ };
10
+
11
+ constructor(config = {}) {
12
+ super(config);
13
+ this.apiKey = config.apiKey || process.env.OPENAI_API_KEY;
14
+ if (!this.apiKey) {
15
+ throw new Error('API Key is required. Provide it in the constructor or set OPENAI_API_KEY environment variable.');
16
+ }
17
+ this.modelId = config.modelId || OpenAIGenerator.MODELS.SUNBURST;
18
+ this.references = [];
19
+ }
20
+
21
+ sunburst() {
22
+ this.modelId = OpenAIGenerator.MODELS.SUNBURST;
23
+ return this;
24
+ }
25
+
26
+ flare() {
27
+ this.modelId = OpenAIGenerator.MODELS.FLARE;
28
+ return this;
29
+ }
30
+
31
+ addReference(image, description = '') {
32
+ this.references.push({ image, description });
33
+ return this;
34
+ }
35
+
36
+ clearReferences() {
37
+ this.references = [];
38
+ return this;
39
+ }
40
+
41
+ async _referenceBlob(image) {
42
+ let buffer;
43
+ if (Buffer.isBuffer(image)) {
44
+ buffer = image;
45
+ } else if (typeof image === 'string' && /^https?:\/\//i.test(image)) {
46
+ const response = await fetch(image);
47
+ if (!response.ok) throw new Error(`Failed to download reference: HTTP ${response.status}`);
48
+ buffer = Buffer.from(await response.arrayBuffer());
49
+ } else if (typeof image === 'string' && image.startsWith('data:')) {
50
+ const match = image.match(/^data:image\/[\w.+-]+;base64,([A-Za-z0-9+/=\s]+)$/);
51
+ if (!match) throw new Error('Invalid reference image data URI.');
52
+ buffer = Buffer.from(match[1], 'base64');
53
+ } else if (typeof image === 'string' && image.trim()) {
54
+ buffer = await fs.readFile(image);
55
+ } else {
56
+ throw new Error('Reference image must be a file path, URL, data URI, or Buffer.');
57
+ }
58
+ const { format } = await sharp(buffer).metadata();
59
+ if (!['png', 'jpeg', 'webp'].includes(format)) {
60
+ throw new Error('OpenAI references must be PNG, JPEG, or WebP images.');
61
+ }
62
+ return new Blob([buffer], { type: `image/${format}` });
63
+ }
64
+
65
+ async generate(prompt, options = {}) {
66
+ if (typeof prompt !== 'string' || !prompt.trim()) throw new Error('Prompt is required.');
67
+ const n = options.numberOfImages ?? 1;
68
+ if (!Number.isInteger(n) || n < 1 || n > 10) throw new Error('options.numberOfImages must be an integer between 1 and 10.');
69
+ const quality = options.quality ?? 'auto';
70
+ if (!['low', 'medium', 'high', 'xhigh', 'max', 'auto'].includes(quality)) {
71
+ throw new Error('options.quality must be low, medium, high, xhigh, max, or auto.');
72
+ }
73
+ const hasWidth = options.width != null;
74
+ const hasHeight = options.height != null;
75
+ if (hasWidth !== hasHeight) throw new Error('Both options.width and options.height are required together.');
76
+ const width = Number(options.width);
77
+ const height = Number(options.height);
78
+ if (hasWidth && (!Number.isInteger(width) || width <= 0 || !Number.isInteger(height) || height <= 0)) {
79
+ throw new Error('options.width and options.height must be positive integers.');
80
+ }
81
+ let ratio = hasWidth ? width / height : null;
82
+ if (options.aspectRatio && options.aspectRatio !== 'auto') {
83
+ const match = options.aspectRatio.match(/^(\d+):(\d+)$/);
84
+ if (!match || Number(match[1]) <= 0 || Number(match[2]) <= 0) throw new Error('Invalid aspect ratio.');
85
+ const requestedRatio = Number(match[1]) / Number(match[2]);
86
+ if (ratio && Math.abs(ratio - requestedRatio) > 1e-10) throw new Error('Aspect ratio mismatch with options.width/options.height.');
87
+ ratio = requestedRatio;
88
+ }
89
+ if (ratio && (ratio < 1 / 3 || ratio > 3)) throw new Error('OpenAI aspect ratio must be between 1:3 and 3:1.');
90
+ const size = ratio
91
+ ? `${Math.round(Math.sqrt(1024 * 1024 * ratio) / 16) * 16}x${Math.round(Math.sqrt(1024 * 1024 / ratio) / 16) * 16}`
92
+ : 'auto';
93
+ const references = [...this.references];
94
+ if (options.referenceImage != null) references.push({ image: options.referenceImage, description: '' });
95
+ const descriptions = references.flatMap((ref, index) => ref.description ? [`Image ${index + 1}: ${ref.description}`] : []);
96
+ const payload = { model: this.modelId, prompt: [...descriptions, prompt].join('\n'), n, quality, size, output_format: 'png' };
97
+ const headers = { Authorization: `Bearer ${this.apiKey}` };
98
+ let body;
99
+ if (references.length) {
100
+ body = new FormData();
101
+ for (const [key, value] of Object.entries(payload)) body.append(key, String(value));
102
+ for (const [index, ref] of references.entries()) {
103
+ const blob = await this._referenceBlob(ref.image);
104
+ body.append('image[]', blob, `reference-${index}.${blob.type.split('/')[1]}`);
105
+ }
106
+ } else {
107
+ headers['Content-Type'] = 'application/json';
108
+ body = JSON.stringify(payload);
109
+ }
110
+ const response = await fetch(`https://api.openai.com/v1/images/${references.length ? 'edits' : 'generations'}`, { method: 'POST', headers, body });
111
+ if (!response.ok) {
112
+ let error;
113
+ try { error = await response.json(); } catch {}
114
+ throw new Error(`OpenAI API Error: ${error?.error?.message || `HTTP ${response.status}`}`);
115
+ }
116
+ const raw = await response.json();
117
+ if (!Array.isArray(raw.data) || !raw.data.length || raw.data.some(entry => !entry?.b64_json)) {
118
+ throw new Error('OpenAI API returned no valid image data.');
119
+ }
120
+ const result = { images: raw.data.map(entry => `data:image/png;base64,${entry.b64_json}`), text: '', raw };
121
+ this.lastGeneration = { prompt, ...result, formatOptions: hasWidth ? { width, height } : null };
122
+ this.clearReferences();
123
+ return result;
124
+ }
125
+ }
126
+
127
+ module.exports = OpenAIGenerator;
package/index.js CHANGED
@@ -1,7 +1,10 @@
1
1
  const GeminiGenerator = require('./generators/GeminiGenerator');
2
2
  const FalGenerator = require('./generators/FalGenerator');
3
+ const OpenAIGenerator = require('./generators/OpenAIGenerator');
3
4
 
4
5
  module.exports = {
6
+ OpenAIGenerator,
7
+ OPENAI_MODELS: OpenAIGenerator.MODELS,
5
8
  GeminiGenerator,
6
9
  FalGenerator,
7
10
  MODELS: GeminiGenerator.MODELS,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "genmix",
3
- "version": "1.2.5",
4
- "description": "AI-powered image generator supporting Google Gemini and Fal Nano Banana 2.",
3
+ "version": "1.2.7",
4
+ "description": "AI-powered image generator supporting Google Gemini, Fal Nano Banana 2, and OpenAI GPT Image 2.5.",
5
5
  "license": "MIT",
6
6
  "author": "Martin Clasen",
7
7
  "keywords": [
@@ -21,7 +21,13 @@
21
21
  "generative-ai",
22
22
  "genmix",
23
23
  "cli",
24
- "clasen"
24
+ "clasen",
25
+ "openai",
26
+ "openai-api",
27
+ "gpt-image",
28
+ "gpt-image-2.5",
29
+ "sunburst",
30
+ "flare"
25
31
  ],
26
32
  "type": "commonjs",
27
33
  "main": "index.js",
@@ -33,9 +39,9 @@
33
39
  },
34
40
  "dependencies": {
35
41
  "hash-factory": "^1.1.2",
36
- "sharp": "^0.35.3"
42
+ "sharp": "^0.35.4"
37
43
  },
38
44
  "scripts": {
39
- "test": "echo \"Error: no test specified\" && exit 1"
45
+ "test": "node --test"
40
46
  }
41
47
  }
@@ -1,6 +1,6 @@
1
1
  ---
2
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.
3
+ description: AI-powered image generator using Google Gemini, Fal, or OpenAI GPT Image 2.5. 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
4
  ---
5
5
 
6
6
  # GenMix Skill
@@ -49,3 +49,11 @@ Actions: Create a script using GenMix to generate the image with the prompt "A f
49
49
  **Example 2: Modify an image**
50
50
  User says: "Make this portrait look like a watercolor painting"
51
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.
52
+
53
+ ## OpenAI GPT Image 2.5
54
+
55
+ Use `OpenAIGenerator` with `OPENAI_API_KEY` for GPT Image 2.5. Sunburst is the
56
+ default; `.flare()` selects Flare and `.sunburst()` switches back. Use the same
57
+ `addReference()`, `generate()`, and `save()` flow. Quality accepts `low`, `medium`,
58
+ `high`, `xhigh`, `max`, or `auto`, not Gemini resolution labels. CLI example:
59
+ `genmix "A watercolor city" --provider openai -m sunburst -q high -o city.png`.
@@ -0,0 +1,87 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('node:fs/promises');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+ const sharp = require('sharp');
7
+ const { OpenAIGenerator } = require('..');
8
+
9
+ test('OpenAI generation, editing, state and saving', async t => {
10
+ const png = await sharp({ create: { width: 16, height: 16, channels: 3, background: 'red' } }).png().toBuffer();
11
+ const calls = [];
12
+ const originalFetch = global.fetch;
13
+ global.fetch = async (url, request) => {
14
+ calls.push({ url, ...request });
15
+ return Response.json({ data: [{ b64_json: png.toString('base64') }] });
16
+ };
17
+ t.after(() => { global.fetch = originalFetch; });
18
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'genmix-test-'));
19
+ t.after(() => fs.rm(directory, { recursive: true, force: true }));
20
+ const generator = new OpenAIGenerator({ apiKey: 'test-key' });
21
+ const result = await generator.generate('draw', { quality: 'max', width: 120, height: 80 });
22
+ assert.match(calls[0].url, /\/generations$/);
23
+ assert.equal(JSON.parse(calls[0].body).quality, 'max');
24
+ assert.equal(result.images.length, 1);
25
+ const [saved] = await generator.save({ directory, filename: 'result', extension: 'png' });
26
+ const metadata = await sharp(saved).metadata();
27
+ assert.equal(metadata.width, 120);
28
+ assert.equal(metadata.height, 80);
29
+ await generator.flare().addReference(png, 'Keep the subject').generate('edit', { numberOfImages: 2 });
30
+ const edit = calls[1];
31
+ assert.match(edit.url, /\/edits$/);
32
+ assert.equal(edit.headers['Content-Type'], undefined);
33
+ assert.equal(edit.body.get('model'), 'gpt-image-2.5-flare');
34
+ assert.equal(edit.body.get('n'), '2');
35
+ assert.match(edit.body.get('prompt'), /Image 1: Keep the subject/);
36
+ assert.deepEqual(Buffer.from(await edit.body.get('image[]').arrayBuffer()), png);
37
+ assert.equal(generator.references.length, 0);
38
+ await generator.generate('next');
39
+ assert.match(calls[2].url, /\/generations$/);
40
+ await generator.addReference(saved).addReference(result.images[0]).generate('combine');
41
+ assert.equal(calls[3].body.getAll('image[]').length, 2);
42
+ });
43
+
44
+ test('invalid options and API failures preserve queued references', async t => {
45
+ const generator = new OpenAIGenerator({ apiKey: 'test-key' });
46
+ const originalFetch = global.fetch;
47
+ let calls = 0;
48
+ global.fetch = async () => { calls++; return Response.json({ error: { message: 'quota exceeded' } }, { status: 429 }); };
49
+ t.after(() => { global.fetch = originalFetch; });
50
+ for (const options of [{ quality: '4K' }, { numberOfImages: 0 }, { width: 10 }, { aspectRatio: '4:1' }, { width: 100, height: 100, aspectRatio: '3:2' }]) {
51
+ await assert.rejects(generator.generate('draw', options));
52
+ }
53
+ assert.equal(calls, 0);
54
+ await assert.rejects(generator.generate('draw'), /quota exceeded/);
55
+ const png = await sharp({ create: { width: 16, height: 16, channels: 3, background: 'red' } }).png().toBuffer();
56
+ generator.addReference(png);
57
+ await assert.rejects(generator.generate('edit'), /quota exceeded/);
58
+ assert.equal(generator.references.length, 1);
59
+ global.fetch = async () => Response.json({ data: [] });
60
+ await assert.rejects(generator.generate('edit'), /no valid image data/);
61
+ assert.equal(generator.lastGeneration, null);
62
+ });
63
+
64
+ test('CLI selects OpenAI models and saves generated output', async t => {
65
+ const { execFileSync } = require('node:child_process');
66
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'genmix-cli-'));
67
+ t.after(() => fs.rm(directory, { recursive: true, force: true }));
68
+ const png = await sharp({ create: { width: 16, height: 16, channels: 3, background: 'red' } }).png().toBuffer();
69
+ const preload = path.join(directory, 'mock.cjs');
70
+ await fs.writeFile(preload, `
71
+ const assert = require('node:assert/strict');
72
+ global.fetch = async (url, request) => {
73
+ assert.equal(url, 'https://api.openai.com/v1/images/generations');
74
+ const payload = JSON.parse(request.body);
75
+ assert.equal(payload.model, 'gpt-image-2.5-' + process.env.TEST_MODEL);
76
+ assert.equal(payload.quality, 'auto');
77
+ return Response.json({ data: [{ b64_json: '${png.toString('base64')}' }] });
78
+ };
79
+ `);
80
+ for (const model of ['sunburst', 'flare']) {
81
+ const output = path.join(directory, `${model}.png`);
82
+ execFileSync(process.execPath, ['--require', preload, path.resolve('cli.js'), 'draw', '--provider', 'openai', ...(model === 'flare' ? ['-m', model] : []), '-o', output], {
83
+ env: { ...process.env, OPENAI_API_KEY: 'test-key', TEST_MODEL: model }
84
+ });
85
+ assert.equal((await sharp(output).metadata()).format, 'png');
86
+ }
87
+ });