heysnap-image-gen 0.1.0
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 +56 -0
- package/dist/cli.js +426 -0
- package/dist/index.js +3 -0
- package/docs/usage.md +214 -0
- package/package.json +27 -0
package/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# image-gen
|
|
2
|
+
|
|
3
|
+
Opinionated GPT Image 2 CLI for VM agents.
|
|
4
|
+
|
|
5
|
+
`image-gen` sends image generation and image edit requests through the HeySnap
|
|
6
|
+
gateway, reads the VM machine token automatically, and writes the resulting
|
|
7
|
+
image files locally.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm install -g heysnap-image-gen
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
image-gen "A polished product photo of a matte black coffee mug" -o mug.png
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Use a reference image:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
image-gen "Restyle this screenshot as a polished SaaS dashboard" \
|
|
25
|
+
--image screenshot.png \
|
|
26
|
+
-o dashboard.png
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Generate multiple images:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
image-gen "Three calm finance app logo concepts" --n 3 --out-dir logos
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Read a long prompt from a file:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
image-gen --prompt-file prompt.txt -o result.png
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Auth
|
|
42
|
+
|
|
43
|
+
The CLI checks token sources in this order:
|
|
44
|
+
|
|
45
|
+
1. `ANK1015_IMAGE_GATEWAY_TOKEN`
|
|
46
|
+
2. `ANK1015_CODEX_GATEWAY_TOKEN`
|
|
47
|
+
3. `ANK1015_MACHINE_TOKEN_FILE`
|
|
48
|
+
4. `/opt/ank1015/machine-token`
|
|
49
|
+
|
|
50
|
+
The default gateway base URL is:
|
|
51
|
+
|
|
52
|
+
```text
|
|
53
|
+
https://api.heysnap.xyz/llm/openai/v1
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
See [docs/usage.md](docs/usage.md) for the full CLI contract.
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
3
|
+
const MODEL = "gpt-image-2";
|
|
4
|
+
const DEFAULT_GATEWAY_BASE_URL = "https://api.heysnap.xyz/llm/openai/v1";
|
|
5
|
+
const DEFAULT_MACHINE_TOKEN_FILE = "/opt/ank1015/machine-token";
|
|
6
|
+
const MAX_INPUT_IMAGES = 16;
|
|
7
|
+
const MAX_OUTPUT_IMAGES = 10;
|
|
8
|
+
const MIN_PIXELS = 655_360;
|
|
9
|
+
const MAX_PIXELS = 8_294_400;
|
|
10
|
+
const MAX_EDGE = 3840;
|
|
11
|
+
class CliError extends Error {
|
|
12
|
+
}
|
|
13
|
+
export const runCli = async (argv, runtime = {}) => {
|
|
14
|
+
const stdout = runtime.stdout ?? process.stdout;
|
|
15
|
+
const stderr = runtime.stderr ?? process.stderr;
|
|
16
|
+
try {
|
|
17
|
+
const parsed = await parseArgs(argv, runtime);
|
|
18
|
+
if (parsed.help === true) {
|
|
19
|
+
stdout.write(`${helpText()}\n`);
|
|
20
|
+
return 0;
|
|
21
|
+
}
|
|
22
|
+
if (parsed.options === undefined) {
|
|
23
|
+
throw new CliError("Missing prompt.");
|
|
24
|
+
}
|
|
25
|
+
const result = await generateImages(parsed.options, runtime);
|
|
26
|
+
stdout.write(`${result.paths.join("\n")}\n`);
|
|
27
|
+
return 0;
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
stderr.write(`image-gen: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
31
|
+
return 1;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
export const parseArgs = async (argv, runtime = {}) => {
|
|
35
|
+
const env = runtime.env ?? process.env;
|
|
36
|
+
const cwd = runtime.cwd ?? process.cwd();
|
|
37
|
+
const promptParts = [];
|
|
38
|
+
const images = [];
|
|
39
|
+
let output;
|
|
40
|
+
let outDir;
|
|
41
|
+
let mask;
|
|
42
|
+
let size = "auto";
|
|
43
|
+
let quality = "auto";
|
|
44
|
+
let format;
|
|
45
|
+
let compression;
|
|
46
|
+
let n = 1;
|
|
47
|
+
let moderation = "auto";
|
|
48
|
+
let promptFile;
|
|
49
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
50
|
+
const arg = argv[index] ?? "";
|
|
51
|
+
if (arg === "--help" || arg === "-h") {
|
|
52
|
+
return { help: true };
|
|
53
|
+
}
|
|
54
|
+
if (arg === "--") {
|
|
55
|
+
promptParts.push(...argv.slice(index + 1));
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
if (!arg.startsWith("-")) {
|
|
59
|
+
promptParts.push(arg);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
switch (arg) {
|
|
63
|
+
case "-o":
|
|
64
|
+
case "--output":
|
|
65
|
+
output = requireValue(argv, index, arg);
|
|
66
|
+
index += 1;
|
|
67
|
+
break;
|
|
68
|
+
case "--out-dir":
|
|
69
|
+
outDir = requireValue(argv, index, arg);
|
|
70
|
+
index += 1;
|
|
71
|
+
break;
|
|
72
|
+
case "-i":
|
|
73
|
+
case "--image":
|
|
74
|
+
images.push(requireValue(argv, index, arg));
|
|
75
|
+
index += 1;
|
|
76
|
+
break;
|
|
77
|
+
case "--mask":
|
|
78
|
+
mask = requireValue(argv, index, arg);
|
|
79
|
+
index += 1;
|
|
80
|
+
break;
|
|
81
|
+
case "--size":
|
|
82
|
+
size = requireValue(argv, index, arg);
|
|
83
|
+
index += 1;
|
|
84
|
+
break;
|
|
85
|
+
case "--quality":
|
|
86
|
+
quality = parseEnum(requireValue(argv, index, arg), ["auto", "low", "medium", "high"], "--quality");
|
|
87
|
+
index += 1;
|
|
88
|
+
break;
|
|
89
|
+
case "--format":
|
|
90
|
+
format = parseEnum(requireValue(argv, index, arg), ["png", "jpeg"], "--format");
|
|
91
|
+
index += 1;
|
|
92
|
+
break;
|
|
93
|
+
case "--compression":
|
|
94
|
+
compression = parseInteger(requireValue(argv, index, arg), "--compression", 0, 100);
|
|
95
|
+
index += 1;
|
|
96
|
+
break;
|
|
97
|
+
case "--n":
|
|
98
|
+
n = parseInteger(requireValue(argv, index, arg), "--n", 1, MAX_OUTPUT_IMAGES);
|
|
99
|
+
index += 1;
|
|
100
|
+
break;
|
|
101
|
+
case "--moderation":
|
|
102
|
+
moderation = parseEnum(requireValue(argv, index, arg), ["auto", "low"], "--moderation");
|
|
103
|
+
index += 1;
|
|
104
|
+
break;
|
|
105
|
+
case "--prompt-file":
|
|
106
|
+
promptFile = requireValue(argv, index, arg);
|
|
107
|
+
index += 1;
|
|
108
|
+
break;
|
|
109
|
+
default:
|
|
110
|
+
throw new CliError(`Unknown option: ${arg}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (promptFile !== undefined && promptParts.length > 0) {
|
|
114
|
+
throw new CliError("Use either a prompt argument or --prompt-file, not both.");
|
|
115
|
+
}
|
|
116
|
+
const prompt = promptFile === undefined
|
|
117
|
+
? promptParts.join(" ").trim()
|
|
118
|
+
: (await readFile(resolve(cwd, promptFile), "utf8")).trim();
|
|
119
|
+
const resolvedFormat = resolveOutputFormat({ explicitFormat: format, output });
|
|
120
|
+
const options = {
|
|
121
|
+
prompt,
|
|
122
|
+
output,
|
|
123
|
+
outDir,
|
|
124
|
+
images,
|
|
125
|
+
mask,
|
|
126
|
+
size,
|
|
127
|
+
quality,
|
|
128
|
+
format: resolvedFormat,
|
|
129
|
+
compression,
|
|
130
|
+
n,
|
|
131
|
+
moderation,
|
|
132
|
+
};
|
|
133
|
+
validateOptions(options, env);
|
|
134
|
+
return { options };
|
|
135
|
+
};
|
|
136
|
+
export const generateImages = async (options, runtime = {}) => {
|
|
137
|
+
const env = runtime.env ?? process.env;
|
|
138
|
+
const cwd = runtime.cwd ?? process.cwd();
|
|
139
|
+
const fetchImpl = runtime.fetch ?? fetch;
|
|
140
|
+
const token = await readMachineToken(env);
|
|
141
|
+
const baseUrl = resolveGatewayBaseUrl(env);
|
|
142
|
+
const request = options.images.length === 0
|
|
143
|
+
? await buildGenerationRequest(options, baseUrl, token)
|
|
144
|
+
: await buildEditRequest(options, baseUrl, token, cwd);
|
|
145
|
+
const response = await fetchImpl(request.url, request.init);
|
|
146
|
+
const body = await readResponseJson(response);
|
|
147
|
+
if (!response.ok) {
|
|
148
|
+
throw new CliError(readApiError(response.status, body));
|
|
149
|
+
}
|
|
150
|
+
const imageResponse = body;
|
|
151
|
+
const base64Images = imageResponse.data?.map((entry) => entry.b64_json).filter(isString) ?? [];
|
|
152
|
+
if (base64Images.length === 0) {
|
|
153
|
+
throw new CliError("Gateway response did not include any image data.");
|
|
154
|
+
}
|
|
155
|
+
const outputPaths = buildOutputPaths(options, base64Images.length, cwd, runtime.now?.() ?? new Date());
|
|
156
|
+
await Promise.all(outputPaths.map(async (outputPath, index) => {
|
|
157
|
+
await mkdir(dirname(outputPath), { recursive: true });
|
|
158
|
+
await writeFile(outputPath, Buffer.from(base64Images[index] ?? "", "base64"));
|
|
159
|
+
}));
|
|
160
|
+
return { paths: outputPaths };
|
|
161
|
+
};
|
|
162
|
+
const buildGenerationRequest = async (options, baseUrl, token) => {
|
|
163
|
+
const body = commonPayload(options);
|
|
164
|
+
return {
|
|
165
|
+
url: `${baseUrl}/images/generations`,
|
|
166
|
+
init: {
|
|
167
|
+
method: "POST",
|
|
168
|
+
headers: {
|
|
169
|
+
"api-key": token,
|
|
170
|
+
"content-type": "application/json",
|
|
171
|
+
},
|
|
172
|
+
body: JSON.stringify(body),
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
};
|
|
176
|
+
const buildEditRequest = async (options, baseUrl, token, cwd) => {
|
|
177
|
+
const form = new FormData();
|
|
178
|
+
const payload = commonPayload(options);
|
|
179
|
+
for (const [key, value] of Object.entries(payload)) {
|
|
180
|
+
form.set(key, String(value));
|
|
181
|
+
}
|
|
182
|
+
for (const imagePath of options.images) {
|
|
183
|
+
form.append("image[]", await pathToBlob(resolve(cwd, imagePath)), basename(imagePath));
|
|
184
|
+
}
|
|
185
|
+
if (options.mask !== undefined) {
|
|
186
|
+
form.set("mask", await pathToBlob(resolve(cwd, options.mask)), basename(options.mask));
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
url: `${baseUrl}/images/edits`,
|
|
190
|
+
init: {
|
|
191
|
+
method: "POST",
|
|
192
|
+
headers: {
|
|
193
|
+
"api-key": token,
|
|
194
|
+
},
|
|
195
|
+
body: form,
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
};
|
|
199
|
+
const commonPayload = (options) => {
|
|
200
|
+
const payload = {
|
|
201
|
+
model: MODEL,
|
|
202
|
+
prompt: options.prompt,
|
|
203
|
+
size: options.size,
|
|
204
|
+
quality: options.quality,
|
|
205
|
+
output_format: options.format ?? "png",
|
|
206
|
+
n: options.n,
|
|
207
|
+
moderation: options.moderation,
|
|
208
|
+
};
|
|
209
|
+
if (options.compression !== undefined) {
|
|
210
|
+
payload["output_compression"] = options.compression;
|
|
211
|
+
}
|
|
212
|
+
return payload;
|
|
213
|
+
};
|
|
214
|
+
const validateOptions = (options, env) => {
|
|
215
|
+
if (options.prompt.length === 0) {
|
|
216
|
+
throw new CliError("Prompt is required.");
|
|
217
|
+
}
|
|
218
|
+
if (options.prompt.length > 32_000) {
|
|
219
|
+
throw new CliError("Prompt must be 32,000 characters or fewer for GPT image models.");
|
|
220
|
+
}
|
|
221
|
+
if (options.output !== undefined && options.outDir !== undefined) {
|
|
222
|
+
throw new CliError("Use either --output or --out-dir, not both.");
|
|
223
|
+
}
|
|
224
|
+
if (options.n > 1 && options.output !== undefined) {
|
|
225
|
+
throw new CliError("Use --out-dir instead of --output when --n is greater than 1.");
|
|
226
|
+
}
|
|
227
|
+
if (options.images.length > MAX_INPUT_IMAGES) {
|
|
228
|
+
throw new CliError(`--image can be repeated up to ${String(MAX_INPUT_IMAGES)} times.`);
|
|
229
|
+
}
|
|
230
|
+
if (options.mask !== undefined && options.images.length === 0) {
|
|
231
|
+
throw new CliError("--mask requires at least one --image.");
|
|
232
|
+
}
|
|
233
|
+
validateSize(options.size);
|
|
234
|
+
if (options.compression !== undefined && (options.format ?? "png") === "png") {
|
|
235
|
+
throw new CliError("--compression is only valid with --format jpeg.");
|
|
236
|
+
}
|
|
237
|
+
const tokenSources = [
|
|
238
|
+
env["ANK1015_IMAGE_GATEWAY_TOKEN"],
|
|
239
|
+
env["ANK1015_CODEX_GATEWAY_TOKEN"],
|
|
240
|
+
env["ANK1015_MACHINE_TOKEN_FILE"],
|
|
241
|
+
];
|
|
242
|
+
if (tokenSources.every((value) => value === undefined || value.trim().length === 0)) {
|
|
243
|
+
// The default VM token path may still exist; defer this to runtime so local tests can inject env cleanly.
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
const validateSize = (size) => {
|
|
248
|
+
if (size === "auto") {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const match = /^(\d+)x(\d+)$/.exec(size);
|
|
252
|
+
if (match === null) {
|
|
253
|
+
throw new CliError("--size must be auto or WIDTHxHEIGHT, for example 1024x1024.");
|
|
254
|
+
}
|
|
255
|
+
const width = Number(match[1]);
|
|
256
|
+
const height = Number(match[2]);
|
|
257
|
+
const longEdge = Math.max(width, height);
|
|
258
|
+
const shortEdge = Math.min(width, height);
|
|
259
|
+
const pixels = width * height;
|
|
260
|
+
if (width % 16 !== 0 || height % 16 !== 0) {
|
|
261
|
+
throw new CliError("--size width and height must both be multiples of 16.");
|
|
262
|
+
}
|
|
263
|
+
if (longEdge > MAX_EDGE) {
|
|
264
|
+
throw new CliError(`--size maximum edge length is ${String(MAX_EDGE)}px.`);
|
|
265
|
+
}
|
|
266
|
+
if (longEdge / shortEdge > 3) {
|
|
267
|
+
throw new CliError("--size long edge to short edge ratio must not exceed 3:1.");
|
|
268
|
+
}
|
|
269
|
+
if (pixels < MIN_PIXELS || pixels > MAX_PIXELS) {
|
|
270
|
+
throw new CliError(`--size total pixels must be between ${String(MIN_PIXELS)} and ${String(MAX_PIXELS)}.`);
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
const buildOutputPaths = (options, count, cwd, now) => {
|
|
274
|
+
if (options.output !== undefined) {
|
|
275
|
+
return [resolve(cwd, options.output)];
|
|
276
|
+
}
|
|
277
|
+
const outputDir = resolve(cwd, options.outDir ?? ".");
|
|
278
|
+
const stamp = toFileTimestamp(now);
|
|
279
|
+
const extension = options.format ?? "png";
|
|
280
|
+
return Array.from({ length: count }, (_, index) => (join(outputDir, `image-${stamp}-${String(index + 1).padStart(2, "0")}.${extension}`)));
|
|
281
|
+
};
|
|
282
|
+
const resolveOutputFormat = (input) => {
|
|
283
|
+
const outputExtension = input.output === undefined ? undefined : extname(input.output);
|
|
284
|
+
const extension = outputExtension === undefined ? undefined : extensionToFormat(outputExtension);
|
|
285
|
+
if (outputExtension !== undefined && outputExtension.length > 0 && extension === undefined) {
|
|
286
|
+
throw new CliError(`Unsupported output extension ${outputExtension}. Use .png, .jpg, or .jpeg.`);
|
|
287
|
+
}
|
|
288
|
+
if (input.explicitFormat !== undefined && extension !== undefined && input.explicitFormat !== extension) {
|
|
289
|
+
throw new CliError(`--format ${input.explicitFormat} does not match output extension ${outputExtension}.`);
|
|
290
|
+
}
|
|
291
|
+
return input.explicitFormat ?? extension ?? "png";
|
|
292
|
+
};
|
|
293
|
+
const extensionToFormat = (extension) => {
|
|
294
|
+
switch (extension.toLowerCase()) {
|
|
295
|
+
case ".png":
|
|
296
|
+
return "png";
|
|
297
|
+
case ".jpg":
|
|
298
|
+
case ".jpeg":
|
|
299
|
+
return "jpeg";
|
|
300
|
+
default:
|
|
301
|
+
return undefined;
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
const pathToBlob = async (path) => {
|
|
305
|
+
const fileStat = await stat(path);
|
|
306
|
+
if (fileStat.size > 50 * 1024 * 1024) {
|
|
307
|
+
throw new CliError(`Image files must be 50MB or smaller: ${path}`);
|
|
308
|
+
}
|
|
309
|
+
return new Blob([await readFile(path)], { type: contentTypeForPath(path) });
|
|
310
|
+
};
|
|
311
|
+
const contentTypeForPath = (path) => {
|
|
312
|
+
switch (extname(path).toLowerCase()) {
|
|
313
|
+
case ".png":
|
|
314
|
+
return "image/png";
|
|
315
|
+
case ".jpg":
|
|
316
|
+
case ".jpeg":
|
|
317
|
+
return "image/jpeg";
|
|
318
|
+
case ".webp":
|
|
319
|
+
return "image/webp";
|
|
320
|
+
default:
|
|
321
|
+
return "application/octet-stream";
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
const readMachineToken = async (env) => {
|
|
325
|
+
const inlineToken = env["ANK1015_IMAGE_GATEWAY_TOKEN"]?.trim()
|
|
326
|
+
|| env["ANK1015_CODEX_GATEWAY_TOKEN"]?.trim();
|
|
327
|
+
if (inlineToken !== undefined && inlineToken.length > 0) {
|
|
328
|
+
return inlineToken;
|
|
329
|
+
}
|
|
330
|
+
const tokenFile = env["ANK1015_MACHINE_TOKEN_FILE"]?.trim() || DEFAULT_MACHINE_TOKEN_FILE;
|
|
331
|
+
try {
|
|
332
|
+
const token = (await readFile(tokenFile, "utf8")).trim();
|
|
333
|
+
if (token.length > 0) {
|
|
334
|
+
return token;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
// The specific error below is clearer for agents than the fs failure.
|
|
339
|
+
}
|
|
340
|
+
throw new CliError(`Machine token not found. Set ANK1015_IMAGE_GATEWAY_TOKEN or ANK1015_MACHINE_TOKEN_FILE.`);
|
|
341
|
+
};
|
|
342
|
+
const resolveGatewayBaseUrl = (env) => {
|
|
343
|
+
const explicit = env["ANK1015_IMAGE_GATEWAY_URL"]?.trim();
|
|
344
|
+
if (explicit !== undefined && explicit.length > 0) {
|
|
345
|
+
return explicit.replace(/\/+$/, "");
|
|
346
|
+
}
|
|
347
|
+
const cloudServer = env["CLOUD_SERVER_PUBLIC_URL"]?.trim();
|
|
348
|
+
if (cloudServer !== undefined && cloudServer.length > 0) {
|
|
349
|
+
return `${cloudServer.replace(/\/+$/, "")}/llm/openai/v1`;
|
|
350
|
+
}
|
|
351
|
+
return DEFAULT_GATEWAY_BASE_URL;
|
|
352
|
+
};
|
|
353
|
+
const readResponseJson = async (response) => {
|
|
354
|
+
const text = await response.text();
|
|
355
|
+
if (text.trim().length === 0) {
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
try {
|
|
359
|
+
return JSON.parse(text);
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
throw new CliError(`Gateway returned invalid JSON with HTTP ${String(response.status)}.`);
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
const readApiError = (status, body) => {
|
|
366
|
+
if (typeof body === "object" && body !== null && "error" in body) {
|
|
367
|
+
const error = body.error;
|
|
368
|
+
const message = typeof error?.message === "string" ? error.message : undefined;
|
|
369
|
+
const code = typeof error?.code === "string" ? error.code : undefined;
|
|
370
|
+
if (message !== undefined && code !== undefined) {
|
|
371
|
+
return `Gateway request failed (${String(status)} ${code}): ${message}`;
|
|
372
|
+
}
|
|
373
|
+
if (message !== undefined) {
|
|
374
|
+
return `Gateway request failed (${String(status)}): ${message}`;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return `Gateway request failed with HTTP ${String(status)}.`;
|
|
378
|
+
};
|
|
379
|
+
const requireValue = (argv, index, option) => {
|
|
380
|
+
const value = argv[index + 1];
|
|
381
|
+
if (value === undefined || value.length === 0 || value.startsWith("-")) {
|
|
382
|
+
throw new CliError(`${option} requires a value.`);
|
|
383
|
+
}
|
|
384
|
+
return value;
|
|
385
|
+
};
|
|
386
|
+
const parseInteger = (value, option, min, max) => {
|
|
387
|
+
if (!/^\d+$/.test(value)) {
|
|
388
|
+
throw new CliError(`${option} must be an integer.`);
|
|
389
|
+
}
|
|
390
|
+
const parsed = Number(value);
|
|
391
|
+
if (parsed < min || parsed > max) {
|
|
392
|
+
throw new CliError(`${option} must be between ${String(min)} and ${String(max)}.`);
|
|
393
|
+
}
|
|
394
|
+
return parsed;
|
|
395
|
+
};
|
|
396
|
+
const parseEnum = (value, allowed, option) => {
|
|
397
|
+
if (!allowed.includes(value)) {
|
|
398
|
+
throw new CliError(`${option} must be one of: ${allowed.join(", ")}.`);
|
|
399
|
+
}
|
|
400
|
+
return value;
|
|
401
|
+
};
|
|
402
|
+
const toFileTimestamp = (date) => date.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[:-]/g, "").replace("T", "-");
|
|
403
|
+
const isString = (value) => typeof value === "string" && value.length > 0;
|
|
404
|
+
const helpText = () => `Usage:
|
|
405
|
+
image-gen <prompt> [options]
|
|
406
|
+
image-gen --prompt-file prompt.txt [options]
|
|
407
|
+
|
|
408
|
+
Examples:
|
|
409
|
+
image-gen "A polished product photo of a matte black coffee mug" -o mug.png
|
|
410
|
+
image-gen "Restyle this screenshot as a clean SaaS dashboard" -i screenshot.png -o dashboard.png
|
|
411
|
+
image-gen "Replace the masked area with a small indoor pool" -i room.png --mask mask.png -o room-edit.png
|
|
412
|
+
image-gen "Three calm finance app logo concepts" --n 3 --out-dir logos
|
|
413
|
+
|
|
414
|
+
Options:
|
|
415
|
+
-o, --output <path> Output file for one image
|
|
416
|
+
--out-dir <dir> Output directory, required when --n > 1
|
|
417
|
+
-i, --image <path> Reference/input image, repeatable up to 16 times
|
|
418
|
+
--mask <path> Mask image for editing the first input image
|
|
419
|
+
--size <size> auto or WIDTHxHEIGHT, default: auto
|
|
420
|
+
--quality <quality> auto | low | medium | high, default: auto
|
|
421
|
+
--format <format> png | jpeg, default: output extension or png
|
|
422
|
+
--compression <0-100> jpeg only
|
|
423
|
+
--n <count> Number of images, 1-10, default: 1
|
|
424
|
+
--moderation <value> auto | low, default: auto
|
|
425
|
+
--prompt-file <path> Read prompt from file
|
|
426
|
+
-h, --help Show this help`;
|
package/dist/index.js
ADDED
package/docs/usage.md
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# image-gen CLI
|
|
2
|
+
|
|
3
|
+
`image-gen` is an opinionated image generation CLI for VM agents. It always uses
|
|
4
|
+
`gpt-image-2`, sends requests through the HeySnap gateway, handles machine-token
|
|
5
|
+
auth automatically, and writes image files locally.
|
|
6
|
+
|
|
7
|
+
The core contract is:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
image-gen <prompt> [options]
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
It returns the generated image path or paths on `stdout`, one path per line. Use
|
|
14
|
+
those paths directly in scripts or agent workflows.
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
image_path="$(image-gen "A clean product photo of a matte black coffee mug" -o mug.png)"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Common Commands
|
|
21
|
+
|
|
22
|
+
Generate one image from a text prompt:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
image-gen "A polished product photo of a matte black coffee mug on a white desk" \
|
|
26
|
+
-o mug.png
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Generate to an automatically named file in the current directory:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
image-gen "A minimal app icon for a calendar app"
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Generate multiple images:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
image-gen "Three calm finance app logo concepts, premium and simple" \
|
|
39
|
+
--n 3 \
|
|
40
|
+
--out-dir logos
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Use one reference image:
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
image-gen "Restyle this screenshot as a polished SaaS dashboard" \
|
|
47
|
+
--image screenshot.png \
|
|
48
|
+
-o dashboard.png
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Use multiple reference images:
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
image-gen "Create a gift basket containing the items from these references" \
|
|
55
|
+
--image lotion.png \
|
|
56
|
+
--image candle.png \
|
|
57
|
+
--image soap.png \
|
|
58
|
+
--image ribbon.png \
|
|
59
|
+
-o basket.png
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Edit an image with a mask:
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
image-gen "Replace the masked area with a small indoor pool" \
|
|
66
|
+
--image room.png \
|
|
67
|
+
--mask mask.png \
|
|
68
|
+
-o room-edit.png
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Read a long prompt from a file:
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
image-gen --prompt-file prompt.txt -o result.png
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Request JPEG output:
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
image-gen "A fast-loading article hero image of a modern workspace" \
|
|
81
|
+
--format jpeg \
|
|
82
|
+
--compression 65 \
|
|
83
|
+
-o hero.jpg
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Options
|
|
87
|
+
|
|
88
|
+
```text
|
|
89
|
+
-o, --output <path> Output file for one image
|
|
90
|
+
--out-dir <dir> Output directory, required when --n > 1
|
|
91
|
+
-i, --image <path> Reference/input image, repeatable up to 16 times
|
|
92
|
+
--mask <path> Mask image for editing the first input image
|
|
93
|
+
--size <size> auto or WIDTHxHEIGHT, default: auto
|
|
94
|
+
--quality <quality> auto | low | medium | high, default: auto
|
|
95
|
+
--format <format> png | jpeg | webp, default: output extension or png
|
|
96
|
+
--compression <0-100> jpeg/webp only
|
|
97
|
+
--n <count> Number of images, 1-10, default: 1
|
|
98
|
+
--moderation <value> auto | low, default: auto
|
|
99
|
+
--prompt-file <path> Read prompt from file
|
|
100
|
+
-h, --help Show help
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Behavior
|
|
104
|
+
|
|
105
|
+
If no `--image` is passed, `image-gen` creates a new image from the text prompt.
|
|
106
|
+
|
|
107
|
+
If one or more `--image` flags are passed, `image-gen` sends an edit/reference
|
|
108
|
+
image request. The prompt should describe the desired output, not just the input.
|
|
109
|
+
|
|
110
|
+
If `--mask` is passed, the mask applies to the first `--image`. The mask and
|
|
111
|
+
first image should have the same dimensions. Use a PNG mask with an alpha
|
|
112
|
+
channel.
|
|
113
|
+
|
|
114
|
+
If `--output` is omitted, files are named like:
|
|
115
|
+
|
|
116
|
+
```text
|
|
117
|
+
image-20260509-101112Z-01.png
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
If `--n` is greater than `1`, use `--out-dir` instead of `--output`.
|
|
121
|
+
|
|
122
|
+
## Size
|
|
123
|
+
|
|
124
|
+
`--size` defaults to `auto`.
|
|
125
|
+
|
|
126
|
+
You can also pass explicit dimensions:
|
|
127
|
+
|
|
128
|
+
```sh
|
|
129
|
+
image-gen "A cinematic landscape poster" --size 1536x1024 -o poster.png
|
|
130
|
+
image-gen "A square app icon" --size 1024x1024 -o icon.png
|
|
131
|
+
image-gen "A portrait poster" --size 1024x1536 -o portrait.png
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Explicit sizes must follow GPT Image 2 constraints:
|
|
135
|
+
|
|
136
|
+
- Width and height must be multiples of `16`.
|
|
137
|
+
- Maximum edge length is `3840px`.
|
|
138
|
+
- Long edge to short edge ratio must not exceed `3:1`.
|
|
139
|
+
- Total pixels must be between `655360` and `8294400`.
|
|
140
|
+
|
|
141
|
+
## Quality
|
|
142
|
+
|
|
143
|
+
`--quality` defaults to `auto`.
|
|
144
|
+
|
|
145
|
+
Use `low` for fast drafts, `medium` for balanced output, and `high` for final
|
|
146
|
+
assets:
|
|
147
|
+
|
|
148
|
+
```sh
|
|
149
|
+
image-gen "A rough thumbnail concept for a travel app" --quality low -o draft.png
|
|
150
|
+
image-gen "A final polished app-store hero graphic" --quality high -o final.png
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Format And Compression
|
|
154
|
+
|
|
155
|
+
Supported formats:
|
|
156
|
+
|
|
157
|
+
- `png`
|
|
158
|
+
- `jpeg`
|
|
159
|
+
|
|
160
|
+
If `--format` is omitted and `--output` has a known extension, the extension is
|
|
161
|
+
used. For example, `-o hero.jpg` requests JPEG.
|
|
162
|
+
|
|
163
|
+
`--compression` is only valid with `jpeg`:
|
|
164
|
+
|
|
165
|
+
```sh
|
|
166
|
+
image-gen "A blog cover image" --format jpeg --compression 70 -o cover.jpg
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Moderation
|
|
170
|
+
|
|
171
|
+
`--moderation` controls GPT Image moderation strictness:
|
|
172
|
+
|
|
173
|
+
- `auto`: default filtering
|
|
174
|
+
- `low`: less restrictive filtering
|
|
175
|
+
|
|
176
|
+
```sh
|
|
177
|
+
image-gen "A dramatic editorial fashion image" --moderation auto -o fashion.png
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Limits
|
|
181
|
+
|
|
182
|
+
- Prompt length: up to `32000` characters.
|
|
183
|
+
- Reference images: up to `16` `--image` flags.
|
|
184
|
+
- Output count: `--n` supports `1` through `10`.
|
|
185
|
+
- Input image files: `50MB` or smaller.
|
|
186
|
+
- Transparent backgrounds are not exposed because this CLI is scoped to
|
|
187
|
+
`gpt-image-2` only.
|
|
188
|
+
- The model is fixed to `gpt-image-2`; there is no `--model` flag.
|
|
189
|
+
|
|
190
|
+
## Script Usage
|
|
191
|
+
|
|
192
|
+
Use `stdout` for paths:
|
|
193
|
+
|
|
194
|
+
```sh
|
|
195
|
+
result="$(image-gen "A small pixel-art save icon" -o save-icon.png)"
|
|
196
|
+
echo "Generated: $result"
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Multiple outputs:
|
|
200
|
+
|
|
201
|
+
```sh
|
|
202
|
+
image-gen "Four onboarding illustrations for a productivity app" \
|
|
203
|
+
--n 4 \
|
|
204
|
+
--out-dir onboarding \
|
|
205
|
+
> generated-images.txt
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Then read each path:
|
|
209
|
+
|
|
210
|
+
```sh
|
|
211
|
+
while read -r path; do
|
|
212
|
+
echo "Generated $path"
|
|
213
|
+
done < generated-images.txt
|
|
214
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "heysnap-image-gen",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Opinionated GPT Image 2 CLI for VM agents.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"image-gen": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"docs",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc -p tsconfig.json",
|
|
16
|
+
"dev": "node --import tsx src/index.ts",
|
|
17
|
+
"start": "node dist/index.js",
|
|
18
|
+
"test": "node --import tsx --test src/**/*.test.ts",
|
|
19
|
+
"prepack": "npm run build",
|
|
20
|
+
"prepublishOnly": "npm run build && npm test"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/node": "^25.6.2",
|
|
24
|
+
"tsx": "^4.21.0",
|
|
25
|
+
"typescript": "^6.0.3"
|
|
26
|
+
}
|
|
27
|
+
}
|