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 +36 -1
- package/cli.js +33 -12
- package/generators/OpenAIGenerator.js +127 -0
- package/index.js +3 -0
- package/package.json +11 -5
- package/skills/genmix/SKILL.md +9 -1
- package/test/openai.test.js +87 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 🎨 GenMix
|
|
2
2
|
|
|
3
|
-
AI-powered image generator supporting Google 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 <
|
|
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:
|
|
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 "
|
|
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
|
|
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
|
-
:
|
|
433
|
+
: args.provider === 'openai'
|
|
434
|
+
? new OpenAIGenerator({ apiKey })
|
|
435
|
+
: new GeminiGenerator({ apiKey });
|
|
417
436
|
|
|
418
|
-
if (args.provider === '
|
|
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.
|
|
4
|
-
"description": "AI-powered image generator supporting Google Gemini
|
|
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.
|
|
42
|
+
"sharp": "^0.35.4"
|
|
37
43
|
},
|
|
38
44
|
"scripts": {
|
|
39
|
-
"test": "
|
|
45
|
+
"test": "node --test"
|
|
40
46
|
}
|
|
41
47
|
}
|
package/skills/genmix/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: genmix
|
|
3
|
-
description: AI-powered image generator using Google Gemini
|
|
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
|
+
});
|