@leejungkiin/awkit 1.6.6 → 1.7.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.
Files changed (47) hide show
  1. package/bin/awk.js +186 -8
  2. package/package.json +5 -3
  3. package/schemas/onboarding-screen.schema.json +108 -0
  4. package/scripts/__pycache__/openrouter_image_gen.cpython-311.pyc +0 -0
  5. package/scripts/automation-gate.js +8 -7
  6. package/scripts/cockpit-quota.js +93 -0
  7. package/scripts/exec-rtk.js +50 -0
  8. package/scripts/openrouter_image_gen.py +772 -0
  9. package/scripts/video-analyzer.js +172 -0
  10. package/skills/CATALOG.md +3 -2
  11. package/skills/TRIGGER_INDEX.md +1 -1
  12. package/skills/ai-sprite-maker/SKILL.md +27 -6
  13. package/skills/ai-sprite-maker/scripts/__pycache__/remove_chroma_key.cpython-311.pyc +0 -0
  14. package/skills/ai-sprite-maker/scripts/remove_chroma_key.py +440 -0
  15. package/skills/awf-caveman/SKILL.md +65 -0
  16. package/skills/expo-build-optimizer/SKILL.md +33 -0
  17. package/skills/ios-app-store-audit/SKILL.md +48 -0
  18. package/skills/ios-expert-coder/SKILL.md +45 -0
  19. package/skills/marketing-spec-writer/SKILL.md +51 -0
  20. package/skills/marketing-spec-writer/templates/MARKETING_SPEC.md +53 -0
  21. package/skills/mascot-designer/SKILL.md +66 -0
  22. package/skills/mascot-designer/examples/witny-case-study.md +35 -0
  23. package/skills/orchestrator/SKILL.md +20 -0
  24. package/skills/review/SKILL.md +87 -0
  25. package/skills/short-maker/scripts/google-flow-cli/README.md +227 -115
  26. package/skills/short-maker/scripts/google-flow-cli/gflow/api/client.py +32 -3
  27. package/skills/short-maker/scripts/google-flow-cli/gflow/api/models.py +4 -2
  28. package/skills/short-maker/scripts/google-flow-cli/gflow/cli/main.py +33 -6
  29. package/skills/short-maker/scripts/google-flow-cli/pyproject.toml +1 -1
  30. package/skills/storyboard-to-scene-pack/SKILL.md +102 -0
  31. package/skills/storyboard-to-scene-pack/agents/openai.yaml +4 -0
  32. package/skills/storyboard-to-scene-pack/assets/preview-template/index.html +101 -0
  33. package/skills/storyboard-to-scene-pack/references/continuity-checklist.md +32 -0
  34. package/skills/storyboard-to-scene-pack/references/scene-prompt-template.md +19 -0
  35. package/skills/storyboard-to-scene-pack/references/storyboard-sheet-template.md +14 -0
  36. package/skills/verification-gate/SKILL.md +4 -0
  37. package/templates/help.html +21 -0
  38. package/templates/project-identity/android.json +24 -0
  39. package/templates/project-identity/backend-nestjs.json +24 -0
  40. package/templates/project-identity/expo.json +24 -0
  41. package/templates/project-identity/ios.json +24 -0
  42. package/templates/project-identity/web-nextjs.json +24 -0
  43. package/templates/specs/design-template.md +71 -161
  44. package/templates/specs/requirements-template.md +133 -65
  45. package/workflows/ui/create-spec-architect.md +80 -50
  46. package/workflows/ui/image-gen.md +118 -0
  47. package/skills/code-review/SKILL.md +0 -115
@@ -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())