@leejungkiin/awkit 1.6.5 → 1.6.8
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 +121 -130
- package/bin/awk.js +111 -8
- package/package.json +5 -3
- package/schemas/onboarding-screen.schema.json +108 -0
- package/scripts/__pycache__/openrouter_image_gen.cpython-311.pyc +0 -0
- package/scripts/cockpit-quota.js +93 -0
- package/scripts/openrouter_image_gen.py +772 -0
- package/scripts/video-analyzer.js +172 -0
- package/skills/CATALOG.md +2 -1
- package/skills/ai-sprite-maker/SKILL.md +27 -6
- package/skills/ai-sprite-maker/scripts/__pycache__/remove_chroma_key.cpython-311.pyc +0 -0
- package/skills/ai-sprite-maker/scripts/remove_chroma_key.py +440 -0
- package/skills/awf-caveman/SKILL.md +65 -0
- package/skills/expo-build-optimizer/SKILL.md +33 -0
- package/skills/ios-app-store-audit/SKILL.md +48 -0
- package/skills/ios-expert-coder/SKILL.md +45 -0
- package/skills/mascot-designer/SKILL.md +66 -0
- package/skills/mascot-designer/examples/witny-case-study.md +35 -0
- package/skills/orchestrator/SKILL.md +20 -0
- package/skills/short-maker/scripts/google-flow-cli/README.md +227 -115
- package/skills/short-maker/scripts/google-flow-cli/gflow/api/client.py +32 -3
- package/skills/short-maker/scripts/google-flow-cli/gflow/api/models.py +4 -2
- package/skills/short-maker/scripts/google-flow-cli/gflow/cli/main.py +33 -6
- package/skills/short-maker/scripts/google-flow-cli/pyproject.toml +1 -1
- package/skills/verification-gate/SKILL.md +4 -0
- package/templates/help.html +21 -0
- package/templates/project-identity/android.json +24 -0
- package/templates/project-identity/backend-nestjs.json +24 -0
- package/templates/project-identity/expo.json +24 -0
- package/templates/project-identity/ios.json +24 -0
- package/templates/project-identity/web-nextjs.json +24 -0
- package/templates/specs/design-template.md +71 -161
- package/templates/specs/requirements-template.md +133 -65
- package/workflows/ui/create-spec-architect.md +80 -50
- package/workflows/ui/image-gen.md +118 -0
|
@@ -0,0 +1,772 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""OpenRouter Image Generation CLI — ported from Codex image_gen.py.
|
|
3
|
+
|
|
4
|
+
Supports generate (text-to-image) and edit (image-to-image) via OpenRouter
|
|
5
|
+
chat/completions endpoint with GPT Image models.
|
|
6
|
+
|
|
7
|
+
Features ported from Codex:
|
|
8
|
+
- Structured prompt augmentation (--style, --lighting, --palette, etc.)
|
|
9
|
+
- Auto-downscale for web assets
|
|
10
|
+
- Retry with exponential backoff on transient/rate-limit errors
|
|
11
|
+
- Async batch processing from JSONL
|
|
12
|
+
- Dry-run mode for payload inspection
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import asyncio
|
|
19
|
+
import base64
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
from io import BytesIO
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
28
|
+
|
|
29
|
+
DEFAULT_MODEL = "openai/gpt-5.4-image-2"
|
|
30
|
+
DEFAULT_SIZE = "auto"
|
|
31
|
+
DEFAULT_QUALITY = "medium"
|
|
32
|
+
DEFAULT_OUTPUT_FORMAT = "webp"
|
|
33
|
+
DEFAULT_CONCURRENCY = 3
|
|
34
|
+
DEFAULT_DOWNSCALE_SUFFIX = "-web"
|
|
35
|
+
DEFAULT_MAX_TOKENS = 4096
|
|
36
|
+
OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions"
|
|
37
|
+
TMPFILES_UPLOAD_URL = "https://tmpfiles.org/api/v1/upload"
|
|
38
|
+
MAX_IMAGE_BYTES = 50 * 1024 * 1024
|
|
39
|
+
MAX_BATCH_JOBS = 100
|
|
40
|
+
|
|
41
|
+
CRED_PATH = os.path.expanduser("~/.gemini/antigravity/.credentials.json")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Helpers
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
def _die(message: str, code: int = 1) -> None:
|
|
49
|
+
print(f"Error: {message}", file=sys.stderr)
|
|
50
|
+
raise SystemExit(code)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _warn(message: str) -> None:
|
|
54
|
+
print(f"Warning: {message}", file=sys.stderr)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _load_api_key() -> str:
|
|
58
|
+
key = os.getenv("OPENROUTER_API_KEY")
|
|
59
|
+
if key:
|
|
60
|
+
return key
|
|
61
|
+
try:
|
|
62
|
+
with open(CRED_PATH, "r") as f:
|
|
63
|
+
creds = json.load(f)
|
|
64
|
+
key = creds.get("openrouter_api_key")
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
if not key:
|
|
68
|
+
_die(
|
|
69
|
+
"OpenRouter API key not found. Set OPENROUTER_API_KEY env var "
|
|
70
|
+
"or add openrouter_api_key to ~/.gemini/antigravity/.credentials.json"
|
|
71
|
+
)
|
|
72
|
+
return key
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _ensure_requests():
|
|
76
|
+
try:
|
|
77
|
+
import requests # noqa: F401
|
|
78
|
+
return requests
|
|
79
|
+
except ImportError:
|
|
80
|
+
_die("requests library is required. Install with: pip install requests")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# Image upload (local file → tmpfiles.org → public URL)
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def _upload_to_tmpfiles(image_path: str) -> str:
|
|
88
|
+
requests = _ensure_requests()
|
|
89
|
+
print(f"📤 Uploading local image to tmpfiles.org...", file=sys.stderr)
|
|
90
|
+
try:
|
|
91
|
+
with open(image_path, "rb") as f:
|
|
92
|
+
resp = requests.post(TMPFILES_UPLOAD_URL, files={"file": f}, timeout=60)
|
|
93
|
+
resp.raise_for_status()
|
|
94
|
+
data = resp.json()
|
|
95
|
+
if "data" in data and "url" in data["data"]:
|
|
96
|
+
url = data["data"]["url"]
|
|
97
|
+
direct_url = url.replace("tmpfiles.org/", "tmpfiles.org/dl/")
|
|
98
|
+
print(f"✅ Uploaded: {direct_url}", file=sys.stderr)
|
|
99
|
+
return direct_url
|
|
100
|
+
except Exception as e:
|
|
101
|
+
_warn(f"tmpfiles.org upload failed ({e}), falling back to base64")
|
|
102
|
+
|
|
103
|
+
# Fallback: inline base64
|
|
104
|
+
import mimetypes
|
|
105
|
+
mime_type, _ = mimetypes.guess_type(image_path)
|
|
106
|
+
if not mime_type:
|
|
107
|
+
mime_type = "image/png"
|
|
108
|
+
with open(image_path, "rb") as f:
|
|
109
|
+
encoded = base64.b64encode(f.read()).decode("utf-8")
|
|
110
|
+
return f"data:{mime_type};base64,{encoded}"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _resolve_image_url(image_input: str) -> str:
|
|
114
|
+
if image_input.startswith(("http://", "https://", "data:")):
|
|
115
|
+
return image_input
|
|
116
|
+
path = Path(image_input)
|
|
117
|
+
if not path.exists():
|
|
118
|
+
_die(f"Image file not found: {path}")
|
|
119
|
+
if path.stat().st_size > MAX_IMAGE_BYTES:
|
|
120
|
+
_warn(f"Image exceeds 50MB: {path}")
|
|
121
|
+
return _upload_to_tmpfiles(str(path))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
# Prompt augmentation (ported from Codex)
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
def _fields_from_args(args: argparse.Namespace) -> Dict[str, Optional[str]]:
|
|
129
|
+
return {
|
|
130
|
+
"use_case": getattr(args, "use_case", None),
|
|
131
|
+
"scene": getattr(args, "scene", None),
|
|
132
|
+
"subject": getattr(args, "subject", None),
|
|
133
|
+
"style": getattr(args, "style", None),
|
|
134
|
+
"composition": getattr(args, "composition", None),
|
|
135
|
+
"lighting": getattr(args, "lighting", None),
|
|
136
|
+
"palette": getattr(args, "palette", None),
|
|
137
|
+
"materials": getattr(args, "materials", None),
|
|
138
|
+
"text": getattr(args, "text", None),
|
|
139
|
+
"constraints": getattr(args, "constraints", None),
|
|
140
|
+
"negative": getattr(args, "negative", None),
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _augment_prompt(augment: bool, prompt: str, fields: Dict[str, Optional[str]]) -> str:
|
|
145
|
+
if not augment:
|
|
146
|
+
return prompt
|
|
147
|
+
sections: List[str] = []
|
|
148
|
+
mapping = [
|
|
149
|
+
("use_case", "Use case"),
|
|
150
|
+
("scene", "Scene/background"),
|
|
151
|
+
("subject", "Subject"),
|
|
152
|
+
("style", "Style/medium"),
|
|
153
|
+
("composition", "Composition/framing"),
|
|
154
|
+
("lighting", "Lighting/mood"),
|
|
155
|
+
("palette", "Color palette"),
|
|
156
|
+
("materials", "Materials/textures"),
|
|
157
|
+
("text", "Text (verbatim)"),
|
|
158
|
+
("constraints", "Constraints"),
|
|
159
|
+
("negative", "Avoid"),
|
|
160
|
+
]
|
|
161
|
+
for key, label in mapping:
|
|
162
|
+
val = fields.get(key)
|
|
163
|
+
if val and key != "text":
|
|
164
|
+
sections.append(f"{label}: {val}")
|
|
165
|
+
elif val and key == "text":
|
|
166
|
+
sections.append(f'{label}: "{val}"')
|
|
167
|
+
if sections:
|
|
168
|
+
return f"Primary request: {prompt}\n" + "\n".join(sections)
|
|
169
|
+
return prompt
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
# Retry logic (ported from Codex)
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
def _is_transient(status_code: int) -> bool:
|
|
177
|
+
return status_code in (429, 500, 502, 503, 504)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _extract_retry_after(headers: dict) -> Optional[float]:
|
|
181
|
+
val = headers.get("retry-after") or headers.get("Retry-After")
|
|
182
|
+
if val:
|
|
183
|
+
try:
|
|
184
|
+
return float(val)
|
|
185
|
+
except ValueError:
|
|
186
|
+
pass
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _call_openrouter(
|
|
191
|
+
api_key: str,
|
|
192
|
+
payload: dict,
|
|
193
|
+
*,
|
|
194
|
+
max_attempts: int = 3,
|
|
195
|
+
timeout: int = 180,
|
|
196
|
+
label: str = "",
|
|
197
|
+
) -> dict:
|
|
198
|
+
requests = _ensure_requests()
|
|
199
|
+
headers = {
|
|
200
|
+
"Authorization": f"Bearer {api_key}",
|
|
201
|
+
"Content-Type": "application/json",
|
|
202
|
+
"HTTP-Referer": "https://github.com/antigravity-awf",
|
|
203
|
+
"X-Title": "Antigravity Image Gen",
|
|
204
|
+
}
|
|
205
|
+
last_exc = None
|
|
206
|
+
for attempt in range(1, max_attempts + 1):
|
|
207
|
+
try:
|
|
208
|
+
resp = requests.post(
|
|
209
|
+
OPENROUTER_API_URL,
|
|
210
|
+
headers=headers,
|
|
211
|
+
json=payload,
|
|
212
|
+
timeout=timeout,
|
|
213
|
+
)
|
|
214
|
+
if resp.status_code == 200:
|
|
215
|
+
return resp.json()
|
|
216
|
+
if _is_transient(resp.status_code) and attempt < max_attempts:
|
|
217
|
+
sleep_s = _extract_retry_after(dict(resp.headers)) or min(60.0, 2.0 ** attempt)
|
|
218
|
+
print(
|
|
219
|
+
f"{label} attempt {attempt}/{max_attempts} got {resp.status_code}; "
|
|
220
|
+
f"retrying in {sleep_s:.1f}s",
|
|
221
|
+
file=sys.stderr,
|
|
222
|
+
)
|
|
223
|
+
time.sleep(sleep_s)
|
|
224
|
+
continue
|
|
225
|
+
# Non-transient error or last attempt — log response body
|
|
226
|
+
try:
|
|
227
|
+
err_body = resp.text[:500]
|
|
228
|
+
except Exception:
|
|
229
|
+
err_body = "(unreadable)"
|
|
230
|
+
print(f"{label} API error {resp.status_code}: {err_body}", file=sys.stderr)
|
|
231
|
+
resp.raise_for_status()
|
|
232
|
+
except Exception as e:
|
|
233
|
+
last_exc = e
|
|
234
|
+
if attempt == max_attempts:
|
|
235
|
+
raise
|
|
236
|
+
sleep_s = min(60.0, 2.0 ** attempt)
|
|
237
|
+
print(
|
|
238
|
+
f"{label} attempt {attempt}/{max_attempts} failed ({e.__class__.__name__}); "
|
|
239
|
+
f"retrying in {sleep_s:.1f}s",
|
|
240
|
+
file=sys.stderr,
|
|
241
|
+
)
|
|
242
|
+
time.sleep(sleep_s)
|
|
243
|
+
raise last_exc or RuntimeError("unknown error")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
async def _call_openrouter_async(
|
|
247
|
+
api_key: str,
|
|
248
|
+
payload: dict,
|
|
249
|
+
*,
|
|
250
|
+
max_attempts: int = 3,
|
|
251
|
+
timeout: int = 180,
|
|
252
|
+
label: str = "",
|
|
253
|
+
) -> dict:
|
|
254
|
+
"""Async wrapper — runs sync call in executor to avoid blocking event loop."""
|
|
255
|
+
loop = asyncio.get_event_loop()
|
|
256
|
+
return await loop.run_in_executor(
|
|
257
|
+
None,
|
|
258
|
+
lambda: _call_openrouter(
|
|
259
|
+
api_key, payload, max_attempts=max_attempts, timeout=timeout, label=label
|
|
260
|
+
),
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# ---------------------------------------------------------------------------
|
|
265
|
+
# Response parsing — extract image from OpenRouter chat/completions response
|
|
266
|
+
# ---------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
def _extract_images_from_response(data: dict) -> List[bytes]:
|
|
269
|
+
"""Extract base64 image bytes from OpenRouter response."""
|
|
270
|
+
images: List[bytes] = []
|
|
271
|
+
if "choices" not in data or not data["choices"]:
|
|
272
|
+
return images
|
|
273
|
+
|
|
274
|
+
message = data["choices"][0].get("message", {})
|
|
275
|
+
|
|
276
|
+
# Path 1: message.images[] (OpenAI native format via OpenRouter)
|
|
277
|
+
if "images" in message:
|
|
278
|
+
for img in message["images"]:
|
|
279
|
+
if isinstance(img, dict):
|
|
280
|
+
url = img.get("image_url", {}).get("url", "")
|
|
281
|
+
else:
|
|
282
|
+
url = str(img)
|
|
283
|
+
if url.startswith("data:image"):
|
|
284
|
+
_, encoded = url.split(",", 1)
|
|
285
|
+
images.append(base64.b64decode(encoded))
|
|
286
|
+
elif url.startswith("http"):
|
|
287
|
+
requests = _ensure_requests()
|
|
288
|
+
resp = requests.get(url, timeout=60)
|
|
289
|
+
resp.raise_for_status()
|
|
290
|
+
images.append(resp.content)
|
|
291
|
+
|
|
292
|
+
# Path 2: content contains markdown image or inline base64
|
|
293
|
+
if not images and "content" in message:
|
|
294
|
+
content = message["content"] or ""
|
|
295
|
+
# Check for base64 data URIs
|
|
296
|
+
b64_matches = re.findall(r'data:image/[^;]+;base64,([A-Za-z0-9+/=]+)', content)
|
|
297
|
+
for b64 in b64_matches:
|
|
298
|
+
try:
|
|
299
|
+
images.append(base64.b64decode(b64))
|
|
300
|
+
except Exception:
|
|
301
|
+
pass
|
|
302
|
+
# Check for HTTP URLs
|
|
303
|
+
if not images:
|
|
304
|
+
url_matches = re.findall(r'(https?://\S+\.(?:png|jpg|jpeg|webp|gif))', content)
|
|
305
|
+
if url_matches:
|
|
306
|
+
requests = _ensure_requests()
|
|
307
|
+
for url in url_matches[:1]:
|
|
308
|
+
try:
|
|
309
|
+
resp = requests.get(url, timeout=60)
|
|
310
|
+
resp.raise_for_status()
|
|
311
|
+
images.append(resp.content)
|
|
312
|
+
except Exception:
|
|
313
|
+
pass
|
|
314
|
+
|
|
315
|
+
return images
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# ---------------------------------------------------------------------------
|
|
319
|
+
# Output helpers
|
|
320
|
+
# ---------------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
def _build_output_paths(
|
|
323
|
+
out: str, output_format: str, count: int, out_dir: Optional[str]
|
|
324
|
+
) -> List[Path]:
|
|
325
|
+
ext = "." + output_format
|
|
326
|
+
if out_dir:
|
|
327
|
+
base = Path(out_dir)
|
|
328
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
329
|
+
return [base / f"image_{i}{ext}" for i in range(1, count + 1)]
|
|
330
|
+
|
|
331
|
+
out_path = Path(out)
|
|
332
|
+
if out_path.suffix == "":
|
|
333
|
+
out_path = out_path.with_suffix(ext)
|
|
334
|
+
|
|
335
|
+
if count == 1:
|
|
336
|
+
return [out_path]
|
|
337
|
+
return [
|
|
338
|
+
out_path.with_name(f"{out_path.stem}-{i}{out_path.suffix}")
|
|
339
|
+
for i in range(1, count + 1)
|
|
340
|
+
]
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _derive_downscale_path(path: Path, suffix: str) -> Path:
|
|
344
|
+
if suffix and not suffix.startswith(("-", "_")):
|
|
345
|
+
suffix = "-" + suffix
|
|
346
|
+
return path.with_name(f"{path.stem}{suffix}{path.suffix}")
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _downscale_image_bytes(image_bytes: bytes, *, max_dim: int, output_format: str) -> bytes:
|
|
350
|
+
try:
|
|
351
|
+
from PIL import Image
|
|
352
|
+
except ImportError:
|
|
353
|
+
_die("Downscaling requires Pillow. Install with: pip install Pillow")
|
|
354
|
+
|
|
355
|
+
with Image.open(BytesIO(image_bytes)) as img:
|
|
356
|
+
img.load()
|
|
357
|
+
w, h = img.size
|
|
358
|
+
scale = min(1.0, float(max_dim) / float(max(w, h)))
|
|
359
|
+
target = (max(1, int(round(w * scale))), max(1, int(round(h * scale))))
|
|
360
|
+
resized = img if target == (w, h) else img.resize(target, Image.Resampling.LANCZOS)
|
|
361
|
+
|
|
362
|
+
fmt = output_format.lower()
|
|
363
|
+
if fmt in ("jpg", "jpeg"):
|
|
364
|
+
fmt = "jpeg"
|
|
365
|
+
if resized.mode in ("RGBA", "LA"):
|
|
366
|
+
bg = Image.new("RGB", resized.size, (255, 255, 255))
|
|
367
|
+
bg.paste(resized, mask=resized.split()[-1])
|
|
368
|
+
resized = bg
|
|
369
|
+
else:
|
|
370
|
+
resized = resized.convert("RGB")
|
|
371
|
+
|
|
372
|
+
out = BytesIO()
|
|
373
|
+
resized.save(out, format=fmt.upper())
|
|
374
|
+
return out.getvalue()
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _convert_image_format(image_bytes: bytes, output_format: str) -> bytes:
|
|
378
|
+
try:
|
|
379
|
+
from PIL import Image
|
|
380
|
+
except ImportError:
|
|
381
|
+
_die("Format conversion requires Pillow. Install with: pip install Pillow")
|
|
382
|
+
|
|
383
|
+
with Image.open(BytesIO(image_bytes)) as img:
|
|
384
|
+
fmt = output_format.lower()
|
|
385
|
+
if fmt in ("jpg", "jpeg"):
|
|
386
|
+
fmt = "jpeg"
|
|
387
|
+
if img.mode in ("RGBA", "LA"):
|
|
388
|
+
bg = Image.new("RGB", img.size, (255, 255, 255))
|
|
389
|
+
bg.paste(img, mask=img.split()[-1])
|
|
390
|
+
img = bg
|
|
391
|
+
else:
|
|
392
|
+
img = img.convert("RGB")
|
|
393
|
+
|
|
394
|
+
out = BytesIO()
|
|
395
|
+
img.save(out, format=fmt.upper())
|
|
396
|
+
return out.getvalue()
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _write_images(
|
|
400
|
+
images: List[bytes],
|
|
401
|
+
outputs: List[Path],
|
|
402
|
+
*,
|
|
403
|
+
force: bool = False,
|
|
404
|
+
downscale_max_dim: Optional[int] = None,
|
|
405
|
+
downscale_suffix: str = DEFAULT_DOWNSCALE_SUFFIX,
|
|
406
|
+
output_format: str = DEFAULT_OUTPUT_FORMAT,
|
|
407
|
+
) -> None:
|
|
408
|
+
for idx, img_bytes in enumerate(images):
|
|
409
|
+
if idx >= len(outputs):
|
|
410
|
+
break
|
|
411
|
+
|
|
412
|
+
fmt = output_format.lower()
|
|
413
|
+
is_webp = img_bytes.startswith(b"RIFF") and b"WEBP" in img_bytes[:16]
|
|
414
|
+
is_png = img_bytes.startswith(b"\x89PNG\r\n\x1a\n")
|
|
415
|
+
is_jpeg = img_bytes.startswith(b"\xff\xd8")
|
|
416
|
+
|
|
417
|
+
needs_convert = False
|
|
418
|
+
if fmt == "webp" and not is_webp:
|
|
419
|
+
needs_convert = True
|
|
420
|
+
elif fmt in ("jpg", "jpeg") and not is_jpeg:
|
|
421
|
+
needs_convert = True
|
|
422
|
+
elif fmt == "png" and not is_png:
|
|
423
|
+
needs_convert = True
|
|
424
|
+
|
|
425
|
+
if needs_convert:
|
|
426
|
+
try:
|
|
427
|
+
img_bytes = _convert_image_format(img_bytes, output_format)
|
|
428
|
+
except Exception as e:
|
|
429
|
+
_warn(f"Failed to convert image format to {output_format}: {e}")
|
|
430
|
+
|
|
431
|
+
out_path = outputs[idx]
|
|
432
|
+
if out_path.exists() and not force:
|
|
433
|
+
_die(f"Output already exists: {out_path} (use --force to overwrite)")
|
|
434
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
435
|
+
out_path.write_bytes(img_bytes)
|
|
436
|
+
print(f"Wrote {out_path}")
|
|
437
|
+
|
|
438
|
+
if downscale_max_dim is not None:
|
|
439
|
+
derived = _derive_downscale_path(out_path, downscale_suffix)
|
|
440
|
+
if derived.exists() and not force:
|
|
441
|
+
_die(f"Output already exists: {derived} (use --force)")
|
|
442
|
+
resized = _downscale_image_bytes(
|
|
443
|
+
img_bytes, max_dim=downscale_max_dim, output_format=output_format
|
|
444
|
+
)
|
|
445
|
+
derived.write_bytes(resized)
|
|
446
|
+
print(f"Wrote {derived} (downscaled to max {downscale_max_dim}px)")
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
# ---------------------------------------------------------------------------
|
|
450
|
+
# Build OpenRouter payload
|
|
451
|
+
# ---------------------------------------------------------------------------
|
|
452
|
+
|
|
453
|
+
def _build_messages(prompt: str, image_urls: Optional[List[str]] = None) -> list:
|
|
454
|
+
if image_urls:
|
|
455
|
+
content: list = [{"type": "text", "text": prompt}]
|
|
456
|
+
for url in image_urls:
|
|
457
|
+
content.append({"type": "image_url", "image_url": {"url": url}})
|
|
458
|
+
return [{"role": "user", "content": content}]
|
|
459
|
+
return [{"role": "user", "content": prompt}]
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _build_payload(
|
|
463
|
+
model: str,
|
|
464
|
+
messages: list,
|
|
465
|
+
*,
|
|
466
|
+
max_tokens: int = DEFAULT_MAX_TOKENS,
|
|
467
|
+
) -> dict:
|
|
468
|
+
return {
|
|
469
|
+
"model": model,
|
|
470
|
+
"messages": messages,
|
|
471
|
+
"max_tokens": max_tokens,
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
# ---------------------------------------------------------------------------
|
|
476
|
+
# Commands: generate, edit, generate-batch
|
|
477
|
+
# ---------------------------------------------------------------------------
|
|
478
|
+
|
|
479
|
+
def _cmd_generate(args: argparse.Namespace) -> None:
|
|
480
|
+
api_key = _load_api_key()
|
|
481
|
+
prompt = _read_prompt(args.prompt, getattr(args, "prompt_file", None))
|
|
482
|
+
fields = _fields_from_args(args)
|
|
483
|
+
prompt = _augment_prompt(args.augment, prompt, fields)
|
|
484
|
+
|
|
485
|
+
messages = _build_messages(prompt)
|
|
486
|
+
payload = _build_payload(args.model, messages, max_tokens=args.max_tokens)
|
|
487
|
+
|
|
488
|
+
output_format = args.output_format or DEFAULT_OUTPUT_FORMAT
|
|
489
|
+
outputs = _build_output_paths(args.out, output_format, args.n, args.out_dir)
|
|
490
|
+
|
|
491
|
+
if args.dry_run:
|
|
492
|
+
print(json.dumps({"outputs": [str(p) for p in outputs], **payload}, indent=2))
|
|
493
|
+
return
|
|
494
|
+
|
|
495
|
+
print(f"🎨 [OpenRouter] Generating {args.n} image(s) with {args.model}...", file=sys.stderr)
|
|
496
|
+
print(f"📝 Prompt: {prompt[:120]}{'...' if len(prompt) > 120 else ''}", file=sys.stderr)
|
|
497
|
+
|
|
498
|
+
started = time.time()
|
|
499
|
+
images = []
|
|
500
|
+
for i in range(args.n):
|
|
501
|
+
if args.n > 1:
|
|
502
|
+
print(f"⏳ Generating image {i+1}/{args.n}...", file=sys.stderr)
|
|
503
|
+
|
|
504
|
+
data = _call_openrouter(api_key, payload, max_attempts=args.max_attempts, label=f"[generate {i+1}]" if args.n > 1 else "[generate]")
|
|
505
|
+
batch_images = _extract_images_from_response(data)
|
|
506
|
+
if batch_images:
|
|
507
|
+
images.extend(batch_images)
|
|
508
|
+
else:
|
|
509
|
+
_die(f"No images found in response for image {i+1}.\nResponse: {json.dumps(data, indent=2)}")
|
|
510
|
+
|
|
511
|
+
elapsed = time.time() - started
|
|
512
|
+
print(f"✅ Generation completed in {elapsed:.1f}s.", file=sys.stderr)
|
|
513
|
+
|
|
514
|
+
_write_images(
|
|
515
|
+
images,
|
|
516
|
+
outputs,
|
|
517
|
+
force=args.force,
|
|
518
|
+
downscale_max_dim=args.downscale_max_dim,
|
|
519
|
+
downscale_suffix=args.downscale_suffix,
|
|
520
|
+
output_format=output_format,
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
for p in outputs:
|
|
524
|
+
if p.exists():
|
|
525
|
+
print(f"✨ file://{p}")
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _cmd_edit(args: argparse.Namespace) -> None:
|
|
529
|
+
api_key = _load_api_key()
|
|
530
|
+
prompt = _read_prompt(args.prompt, getattr(args, "prompt_file", None))
|
|
531
|
+
fields = _fields_from_args(args)
|
|
532
|
+
prompt = _augment_prompt(args.augment, prompt, fields)
|
|
533
|
+
|
|
534
|
+
# Resolve all input images to URLs
|
|
535
|
+
image_urls = [_resolve_image_url(img) for img in args.image]
|
|
536
|
+
|
|
537
|
+
messages = _build_messages(prompt, image_urls)
|
|
538
|
+
payload = _build_payload(args.model, messages, max_tokens=args.max_tokens)
|
|
539
|
+
|
|
540
|
+
output_format = args.output_format or DEFAULT_OUTPUT_FORMAT
|
|
541
|
+
outputs = _build_output_paths(args.out, output_format, args.n, args.out_dir)
|
|
542
|
+
|
|
543
|
+
if args.dry_run:
|
|
544
|
+
print(json.dumps({"outputs": [str(p) for p in outputs], **payload}, indent=2))
|
|
545
|
+
return
|
|
546
|
+
|
|
547
|
+
print(f"🎨 [OpenRouter] Editing with {args.model} ({len(image_urls)} image(s)) x {args.n}...", file=sys.stderr)
|
|
548
|
+
print(f"📝 Prompt: {prompt[:120]}{'...' if len(prompt) > 120 else ''}", file=sys.stderr)
|
|
549
|
+
|
|
550
|
+
started = time.time()
|
|
551
|
+
images = []
|
|
552
|
+
for i in range(args.n):
|
|
553
|
+
if args.n > 1:
|
|
554
|
+
print(f"⏳ Editing image {i+1}/{args.n}...", file=sys.stderr)
|
|
555
|
+
|
|
556
|
+
data = _call_openrouter(api_key, payload, max_attempts=args.max_attempts, label=f"[edit {i+1}]" if args.n > 1 else "[edit]")
|
|
557
|
+
batch_images = _extract_images_from_response(data)
|
|
558
|
+
if batch_images:
|
|
559
|
+
images.extend(batch_images)
|
|
560
|
+
else:
|
|
561
|
+
_die(f"No images found in response for edit {i+1}.\nResponse: {json.dumps(data, indent=2)}")
|
|
562
|
+
|
|
563
|
+
elapsed = time.time() - started
|
|
564
|
+
print(f"✅ Edit completed in {elapsed:.1f}s.", file=sys.stderr)
|
|
565
|
+
|
|
566
|
+
_write_images(
|
|
567
|
+
images,
|
|
568
|
+
outputs,
|
|
569
|
+
force=args.force,
|
|
570
|
+
downscale_max_dim=args.downscale_max_dim,
|
|
571
|
+
downscale_suffix=args.downscale_suffix,
|
|
572
|
+
output_format=output_format,
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
for p in outputs:
|
|
576
|
+
if p.exists():
|
|
577
|
+
print(f"✨ file://{p}")
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _cmd_generate_batch(args: argparse.Namespace) -> None:
|
|
581
|
+
api_key = _load_api_key()
|
|
582
|
+
jobs = _read_jobs_jsonl(args.input)
|
|
583
|
+
out_dir = Path(args.out_dir)
|
|
584
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
585
|
+
base_fields = _fields_from_args(args)
|
|
586
|
+
output_format = args.output_format or DEFAULT_OUTPUT_FORMAT
|
|
587
|
+
|
|
588
|
+
print(f"🎨 [OpenRouter Batch] {len(jobs)} jobs, concurrency={args.concurrency}", file=sys.stderr)
|
|
589
|
+
|
|
590
|
+
async def run_all():
|
|
591
|
+
sem = asyncio.Semaphore(args.concurrency)
|
|
592
|
+
failed = 0
|
|
593
|
+
|
|
594
|
+
async def run_one(i: int, job: dict):
|
|
595
|
+
nonlocal failed
|
|
596
|
+
prompt = str(job["prompt"]).strip()
|
|
597
|
+
job_fields = {k: job.get(k, base_fields.get(k)) for k in base_fields}
|
|
598
|
+
augmented = _augment_prompt(args.augment, prompt, job_fields)
|
|
599
|
+
|
|
600
|
+
image_urls = None
|
|
601
|
+
if "image" in job:
|
|
602
|
+
raw_images = job["image"] if isinstance(job["image"], list) else [job["image"]]
|
|
603
|
+
image_urls = [_resolve_image_url(img) for img in raw_images]
|
|
604
|
+
|
|
605
|
+
messages = _build_messages(augmented, image_urls)
|
|
606
|
+
payload = _build_payload(args.model, messages, max_tokens=args.max_tokens)
|
|
607
|
+
|
|
608
|
+
ext = "." + output_format
|
|
609
|
+
out_path = out_dir / f"{i:03d}-{_slugify(prompt[:60])}{ext}"
|
|
610
|
+
label = f"[job {i}/{len(jobs)}]"
|
|
611
|
+
|
|
612
|
+
try:
|
|
613
|
+
async with sem:
|
|
614
|
+
print(f"{label} starting", file=sys.stderr)
|
|
615
|
+
started = time.time()
|
|
616
|
+
data = await _call_openrouter_async(
|
|
617
|
+
api_key, payload, max_attempts=args.max_attempts, label=label
|
|
618
|
+
)
|
|
619
|
+
elapsed = time.time() - started
|
|
620
|
+
print(f"{label} completed in {elapsed:.1f}s", file=sys.stderr)
|
|
621
|
+
|
|
622
|
+
images = _extract_images_from_response(data)
|
|
623
|
+
if images:
|
|
624
|
+
_write_images(images, [out_path], force=args.force, output_format=output_format)
|
|
625
|
+
else:
|
|
626
|
+
print(f"{label} no images in response", file=sys.stderr)
|
|
627
|
+
failed += 1
|
|
628
|
+
except Exception as e:
|
|
629
|
+
print(f"{label} failed: {e}", file=sys.stderr)
|
|
630
|
+
failed += 1
|
|
631
|
+
if args.fail_fast:
|
|
632
|
+
raise
|
|
633
|
+
|
|
634
|
+
tasks = [asyncio.create_task(run_one(i, job)) for i, job in enumerate(jobs, 1)]
|
|
635
|
+
await asyncio.gather(*tasks, return_exceptions=not args.fail_fast)
|
|
636
|
+
return failed
|
|
637
|
+
|
|
638
|
+
failed = asyncio.run(run_all())
|
|
639
|
+
if failed:
|
|
640
|
+
raise SystemExit(1)
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
# ---------------------------------------------------------------------------
|
|
644
|
+
# Utility
|
|
645
|
+
# ---------------------------------------------------------------------------
|
|
646
|
+
|
|
647
|
+
def _read_prompt(prompt: Optional[str], prompt_file: Optional[str]) -> str:
|
|
648
|
+
if prompt and prompt_file:
|
|
649
|
+
_die("Use --prompt or --prompt-file, not both.")
|
|
650
|
+
if prompt_file:
|
|
651
|
+
p = Path(prompt_file)
|
|
652
|
+
if not p.exists():
|
|
653
|
+
_die(f"Prompt file not found: {p}")
|
|
654
|
+
return p.read_text(encoding="utf-8").strip()
|
|
655
|
+
if prompt:
|
|
656
|
+
return prompt.strip()
|
|
657
|
+
_die("Missing prompt. Use --prompt or --prompt-file.")
|
|
658
|
+
return ""
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _slugify(value: str) -> str:
|
|
662
|
+
value = value.strip().lower()
|
|
663
|
+
value = re.sub(r"[^a-z0-9]+", "-", value)
|
|
664
|
+
value = re.sub(r"-{2,}", "-", value).strip("-")
|
|
665
|
+
return value[:60] if value else "output"
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def _read_jobs_jsonl(path: str) -> List[Dict[str, Any]]:
|
|
669
|
+
p = Path(path)
|
|
670
|
+
if not p.exists():
|
|
671
|
+
_die(f"Input file not found: {p}")
|
|
672
|
+
jobs: List[Dict[str, Any]] = []
|
|
673
|
+
for line_no, raw in enumerate(p.read_text(encoding="utf-8").splitlines(), start=1):
|
|
674
|
+
line = raw.strip()
|
|
675
|
+
if not line or line.startswith("#"):
|
|
676
|
+
continue
|
|
677
|
+
try:
|
|
678
|
+
if line.startswith("{"):
|
|
679
|
+
item = json.loads(line)
|
|
680
|
+
else:
|
|
681
|
+
item = {"prompt": line}
|
|
682
|
+
if "prompt" not in item or not str(item["prompt"]).strip():
|
|
683
|
+
_die(f"Missing prompt at line {line_no}")
|
|
684
|
+
jobs.append(item)
|
|
685
|
+
except json.JSONDecodeError as e:
|
|
686
|
+
_die(f"Invalid JSON on line {line_no}: {e}")
|
|
687
|
+
if not jobs:
|
|
688
|
+
_die("No jobs found in input file.")
|
|
689
|
+
if len(jobs) > MAX_BATCH_JOBS:
|
|
690
|
+
_die(f"Too many jobs ({len(jobs)}). Max is {MAX_BATCH_JOBS}.")
|
|
691
|
+
return jobs
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
# ---------------------------------------------------------------------------
|
|
695
|
+
# CLI parser
|
|
696
|
+
# ---------------------------------------------------------------------------
|
|
697
|
+
|
|
698
|
+
def _add_shared_args(parser: argparse.ArgumentParser) -> None:
|
|
699
|
+
parser.add_argument("--model", default=DEFAULT_MODEL, help="OpenRouter model ID")
|
|
700
|
+
parser.add_argument("--prompt", help="Text prompt for generation")
|
|
701
|
+
parser.add_argument("--prompt-file", help="Read prompt from file")
|
|
702
|
+
parser.add_argument("--n", type=int, default=1, help="Number of images (1-10)")
|
|
703
|
+
parser.add_argument("--max-tokens", type=int, default=DEFAULT_MAX_TOKENS)
|
|
704
|
+
parser.add_argument("--out", default="output.webp", help="Output path")
|
|
705
|
+
parser.add_argument("--out-dir", help="Output directory (for batch)")
|
|
706
|
+
parser.add_argument("--output-format", default=DEFAULT_OUTPUT_FORMAT, choices=["png", "webp", "jpeg"])
|
|
707
|
+
parser.add_argument("--force", action="store_true", help="Overwrite existing files")
|
|
708
|
+
parser.add_argument("--dry-run", action="store_true", help="Print payload without calling API")
|
|
709
|
+
parser.add_argument("--max-attempts", type=int, default=3, help="Retry attempts on transient errors")
|
|
710
|
+
|
|
711
|
+
# Prompt augmentation
|
|
712
|
+
parser.add_argument("--augment", dest="augment", action="store_true")
|
|
713
|
+
parser.add_argument("--no-augment", dest="augment", action="store_false")
|
|
714
|
+
parser.set_defaults(augment=True)
|
|
715
|
+
parser.add_argument("--use-case", help="Augment: use case context")
|
|
716
|
+
parser.add_argument("--scene", help="Augment: scene/background description")
|
|
717
|
+
parser.add_argument("--subject", help="Augment: subject description")
|
|
718
|
+
parser.add_argument("--style", help="Augment: art style/medium")
|
|
719
|
+
parser.add_argument("--composition", help="Augment: composition/framing")
|
|
720
|
+
parser.add_argument("--lighting", help="Augment: lighting/mood")
|
|
721
|
+
parser.add_argument("--palette", help="Augment: color palette")
|
|
722
|
+
parser.add_argument("--materials", help="Augment: materials/textures")
|
|
723
|
+
parser.add_argument("--text", help="Augment: text to render (verbatim)")
|
|
724
|
+
parser.add_argument("--constraints", help="Augment: constraints")
|
|
725
|
+
parser.add_argument("--negative", help="Augment: things to avoid")
|
|
726
|
+
|
|
727
|
+
# Post-processing: downscale
|
|
728
|
+
parser.add_argument("--downscale-max-dim", type=int, help="Generate additional downscaled copy")
|
|
729
|
+
parser.add_argument("--downscale-suffix", default=DEFAULT_DOWNSCALE_SUFFIX)
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def main() -> int:
|
|
733
|
+
parser = argparse.ArgumentParser(
|
|
734
|
+
description="OpenRouter Image Generation CLI (Codex-grade)"
|
|
735
|
+
)
|
|
736
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
737
|
+
|
|
738
|
+
# generate
|
|
739
|
+
gen_parser = subparsers.add_parser("generate", help="Create a new image from text")
|
|
740
|
+
_add_shared_args(gen_parser)
|
|
741
|
+
gen_parser.set_defaults(func=_cmd_generate)
|
|
742
|
+
|
|
743
|
+
# edit (image-to-image)
|
|
744
|
+
edit_parser = subparsers.add_parser("edit", help="Edit/transform an existing image")
|
|
745
|
+
_add_shared_args(edit_parser)
|
|
746
|
+
edit_parser.add_argument("--image", action="append", required=True, help="Input image path/URL")
|
|
747
|
+
edit_parser.set_defaults(func=_cmd_edit)
|
|
748
|
+
|
|
749
|
+
# generate-batch
|
|
750
|
+
batch_parser = subparsers.add_parser("generate-batch", help="Batch generate from JSONL")
|
|
751
|
+
_add_shared_args(batch_parser)
|
|
752
|
+
batch_parser.add_argument("--input", required=True, help="Path to JSONL file")
|
|
753
|
+
batch_parser.add_argument("--concurrency", type=int, default=DEFAULT_CONCURRENCY)
|
|
754
|
+
batch_parser.add_argument("--fail-fast", action="store_true")
|
|
755
|
+
batch_parser.set_defaults(func=_cmd_generate_batch)
|
|
756
|
+
|
|
757
|
+
args = parser.parse_args()
|
|
758
|
+
|
|
759
|
+
# Validation
|
|
760
|
+
if args.n < 1 or args.n > 10:
|
|
761
|
+
_die("--n must be between 1 and 10")
|
|
762
|
+
if args.max_attempts < 1 or args.max_attempts > 10:
|
|
763
|
+
_die("--max-attempts must be between 1 and 10")
|
|
764
|
+
if args.command == "generate-batch" and not args.out_dir:
|
|
765
|
+
_die("generate-batch requires --out-dir")
|
|
766
|
+
|
|
767
|
+
args.func(args)
|
|
768
|
+
return 0
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
if __name__ == "__main__":
|
|
772
|
+
raise SystemExit(main())
|