返回 ppt-master
image_gen.py
根目录 / skills / ppt-master / scripts / image_gen.py
1 #!/usr/bin/env python3
2 """
3 Unified Image Generation Tool
4
5 Dispatches to the appropriate backend based on explicit provider configuration.
6
7 Backend selection (`IMAGE_BACKEND` in `.env` or the current process environment):
8 IMAGE_BACKEND=gemini -> Gemini backend (google-genai SDK)
9 IMAGE_BACKEND=openai -> OpenAI-compatible backend (raw HTTP via requests)
10 IMAGE_BACKEND=minimax -> MiniMax image backend
11 IMAGE_BACKEND=stability -> Stability AI backend
12 IMAGE_BACKEND=bfl -> Black Forest Labs FLUX backend
13 IMAGE_BACKEND=ideogram -> Ideogram backend
14 IMAGE_BACKEND=qwen -> Alibaba Qwen image backend
15 IMAGE_BACKEND=zhipu -> Zhipu GLM-Image backend
16 IMAGE_BACKEND=volcengine -> Volcengine Seedream backend
17 IMAGE_BACKEND=modelscope -> ModelScope backend
18 IMAGE_BACKEND=siliconflow -> SiliconFlow backend
19 IMAGE_BACKEND=fal -> fal.ai backend
20 IMAGE_BACKEND=replicate -> Replicate backend
21 IMAGE_BACKEND=openrouter -> OpenRouter backend
22
23 Configuration source (process env wins, `.env` is the fallback layer):
24 1. Current process environment variables
25 2. The first `.env` found among:
26 - Current working directory
27 - Skill directory (e.g. `~/.agents/skills/ppt-master/.env`)
28 - Repo root (when running from a clone)
29 - `~/.ppt-master/.env` (user-level config)
30
31 Supported keys:
32 IMAGE_BACKEND (required) backend name
33
34 Provider-specific keys are used for credentials and overrides, for example:
35 GEMINI_API_KEY / GEMINI_MODEL / GEMINI_BASE_URL
36 OPENAI_API_KEY / OPENAI_MODEL / OPENAI_BASE_URL
37 QWEN_API_KEY / QWEN_MODEL / QWEN_BASE_URL
38 ZHIPU_API_KEY / ZHIPU_MODEL / ZHIPU_BASE_URL
39
40 Usage:
41 python3 image_gen.py "prompt" --aspect_ratio 16:9 --image_size 1K -o images/
42 python3 image_gen.py "edit instruction" --reference-image src.png -o images/
43 python3 image_gen.py --manifest project/images/image_prompts.json -o project/images/
44 python3 image_gen.py --list-backends
45 """
46
47 import argparse
48 import concurrent.futures
49 import json
50 import os
51 import re
52 import sys
53 import tempfile
54 import threading
55 import time
56 from pathlib import Path
57
58 from console_encoding import configure_utf8_stdio
59 from config import load_prefixed_env_file, resolve_env_path
60
61 configure_utf8_stdio()
62
63 ENV_PATH = resolve_env_path()
64 IMAGE_ENV_PREFIXES = (
65 "IMAGE_",
66 "GEMINI_",
67 "OPENAI_",
68 "MINIMAX_",
69 "STABILITY_",
70 "BFL_",
71 "IDEOGRAM_",
72 "QWEN_",
73 "DASHSCOPE_",
74 "ZHIPU_",
75 "BIGMODEL_",
76 "VOLCENGINE_",
77 "LAS_",
78 "ARK_",
79 "MODELSCOPE_",
80 "SILICONFLOW_",
81 "FAL_",
82 "REPLICATE_",
83 "OPENROUTER_",
84 )
85 DEPRECATED_IMAGE_KEYS = {
86 "IMAGE_API_KEY",
87 "IMAGE_MODEL",
88 "IMAGE_BASE_URL",
89 }
90
91 # All aspect ratios accepted by the unified CLI
92 # (each backend validates its own subset internally)
93 ALL_ASPECT_RATIOS = [
94 "1:1", "1:2", "1:3", "1:4", "1:8",
95 "2:1", "2:3", "3:1", "3:2", "3:4", "4:1", "4:3",
96 "4:5", "5:4", "8:1", "9:16", "9:21", "10:16",
97 "16:9", "16:10", "21:9",
98 ]
99
100 ALL_IMAGE_SIZES = ["512px", "1K", "2K", "4K"]
101
102 BACKEND_REGISTRY = {
103 "gemini": {
104 "module": "backend_gemini",
105 "tier": "core",
106 "label": "Google Gemini",
107 "default_model": "gemini-3.1-flash-image",
108 "default_image_size": "1K",
109 "key_hint": "GEMINI_API_KEY",
110 "aliases": ["google"],
111 },
112 "openai": {
113 "module": "backend_openai",
114 "tier": "core",
115 "label": "OpenAI / OpenAI-compatible",
116 "default_model": "gpt-image-2",
117 "default_image_size": "1K",
118 "key_hint": "OPENAI_API_KEY",
119 "aliases": ["openai-compatible", "openai_compatible"],
120 },
121 "minimax": {
122 "module": "backend_minimax",
123 "tier": "experimental",
124 "label": "MiniMax Image",
125 "default_model": "image-01",
126 "default_image_size": "1K",
127 "key_hint": "MINIMAX_API_KEY",
128 "aliases": ["minimaxi"],
129 },
130 "qwen": {
131 "module": "backend_qwen",
132 "tier": "core",
133 "label": "Alibaba Qwen Image",
134 "default_model": "qwen-image-2.0-pro",
135 "default_image_size": "1K",
136 "key_hint": "QWEN_API_KEY / DASHSCOPE_API_KEY",
137 "aliases": ["alibaba", "dashscope"],
138 },
139 "zhipu": {
140 "module": "backend_zhipu",
141 "tier": "core",
142 "label": "Zhipu GLM-Image",
143 "default_model": "glm-image",
144 "default_image_size": "1K",
145 "key_hint": "ZHIPU_API_KEY / BIGMODEL_API_KEY",
146 "aliases": ["bigmodel", "glm", "glm-image"],
147 },
148 "volcengine": {
149 "module": "backend_volcengine",
150 "tier": "core",
151 "label": "Volcengine Seedream",
152 "default_model": "doubao-seedream-4-5-251128",
153 "default_image_size": "2K",
154 "key_hint": "LAS_API_KEY / VOLCENGINE_API_KEY / ARK_API_KEY",
155 "aliases": ["ark", "doubao", "seedream"],
156 },
157 "modelscope": {
158 "module": "backend_modelscope",
159 "tier": "experimental",
160 "label": "ModelScope",
161 "default_model": None,
162 "model_hint": "MODELSCOPE_MODEL",
163 "default_image_size": "1K",
164 "key_hint": "MODELSCOPE_API_KEY",
165 "aliases": ["modelscope", "model-scope"]
166 },
167 "stability": {
168 "module": "backend_stability",
169 "tier": "extended",
170 "label": "Stability AI",
171 "default_model": "stable-image-core",
172 "default_image_size": "1K",
173 "key_hint": "STABILITY_API_KEY",
174 "aliases": ["stabilityai", "stability-ai"],
175 },
176 "bfl": {
177 "module": "backend_bfl",
178 "tier": "extended",
179 "label": "Black Forest Labs FLUX",
180 "default_model": "flux-pro-1.1-ultra",
181 "default_image_size": "1K",
182 "key_hint": "BFL_API_KEY",
183 "aliases": ["flux", "black-forest-labs", "black_forest_labs"],
184 },
185 "ideogram": {
186 "module": "backend_ideogram",
187 "tier": "extended",
188 "label": "Ideogram",
189 "default_model": "ideogram-v3",
190 "default_image_size": "1K",
191 "key_hint": "IDEOGRAM_API_KEY",
192 },
193 "siliconflow": {
194 "module": "backend_siliconflow",
195 "tier": "experimental",
196 "label": "SiliconFlow",
197 "default_model": "Qwen/Qwen-Image",
198 "default_image_size": "1K",
199 "key_hint": "SILICONFLOW_API_KEY",
200 "aliases": ["silicon"],
201 },
202 "fal": {
203 "module": "backend_fal",
204 "tier": "experimental",
205 "label": "fal.ai",
206 "default_model": "fal-ai/nano-banana-2",
207 "default_image_size": "1K",
208 "key_hint": "FAL_KEY / FAL_API_KEY",
209 "aliases": ["fal-ai"],
210 },
211 "replicate": {
212 "module": "backend_replicate",
213 "tier": "experimental",
214 "label": "Replicate",
215 "default_model": "black-forest-labs/flux-1.1-pro",
216 "default_image_size": "1K",
217 "key_hint": "REPLICATE_API_TOKEN / REPLICATE_API_KEY",
218 },
219 "openrouter": {
220 "module": "backend_openrouter",
221 "tier": "experimental",
222 "label": "OpenRouter",
223 "default_model": "google/gemini-3.1-flash-image",
224 "default_image_size": "1K",
225 "key_hint": "OPENROUTER_API_KEY",
226 },
227 }
228
229 TIER_ORDER = {"core": 0, "extended": 1, "experimental": 2}
230 SUPPORTED_BACKENDS = tuple(sorted(BACKEND_REGISTRY))
231
232
233 def _load_image_env_file() -> Path | None:
234 """
235 Load image generation config from the resolved `.env` as a fallback layer.
236
237 Existing process environment variables win over `.env`.
238 """
239 replacements = {
240 "IMAGE_API_KEY": "GEMINI_API_KEY / OPENAI_API_KEY / QWEN_API_KEY / ZHIPU_API_KEY / ...",
241 "IMAGE_MODEL": "GEMINI_MODEL / OPENAI_MODEL / QWEN_MODEL / ZHIPU_MODEL / ...",
242 "IMAGE_BASE_URL": "GEMINI_BASE_URL / OPENAI_BASE_URL / QWEN_BASE_URL / ZHIPU_BASE_URL / ...",
243 }
244 deprecated_messages = {
245 key: (
246 "Global image config keys have been removed.\n"
247 f"Use IMAGE_BACKEND plus provider-specific keys instead, such as {replacement}."
248 )
249 for key, replacement in replacements.items()
250 }
251 return load_prefixed_env_file(
252 IMAGE_ENV_PREFIXES,
253 deprecated_keys=deprecated_messages,
254 )
255
256
257 def _validate_runtime_config() -> None:
258 """Reject deprecated global image variables from any configuration source."""
259 for key in DEPRECATED_IMAGE_KEYS:
260 if key not in os.environ:
261 continue
262 replacement = {
263 "IMAGE_API_KEY": "GEMINI_API_KEY / OPENAI_API_KEY / QWEN_API_KEY / ZHIPU_API_KEY / ...",
264 "IMAGE_MODEL": "GEMINI_MODEL / OPENAI_MODEL / QWEN_MODEL / ZHIPU_MODEL / ...",
265 "IMAGE_BASE_URL": "GEMINI_BASE_URL / OPENAI_BASE_URL / QWEN_BASE_URL / ZHIPU_BASE_URL / ...",
266 }[key]
267 raise ValueError(
268 f"Unsupported image config key: {key}\n"
269 "Global image config keys have been removed.\n"
270 f"Use IMAGE_BACKEND plus provider-specific keys instead, such as {replacement}."
271 )
272
273
274 def _build_backend_aliases() -> dict[str, str]:
275 """Build a lookup from aliases to canonical backend names."""
276 aliases = {}
277 for canonical_name, config in BACKEND_REGISTRY.items():
278 aliases[canonical_name] = canonical_name
279 for alias in config.get("aliases", []):
280 aliases[alias] = canonical_name
281 return aliases
282
283
284 BACKEND_ALIASES = _build_backend_aliases()
285
286
287 _BACKEND_PIP_HINTS = {
288 "gemini": "google-genai",
289 "openai": "openai",
290 }
291
292
293 def _load_backend(canonical_name: str) -> tuple[object, str]:
294 """Import and return the configured backend module."""
295 module_name = f"image_backends.{BACKEND_REGISTRY[canonical_name]['module']}"
296 try:
297 module = __import__(module_name, fromlist=["*"])
298 except ImportError as exc:
299 pip_name = _BACKEND_PIP_HINTS.get(canonical_name, exc.name or "<dependency>")
300 print(
301 f"Error: backend '{canonical_name}' needs a package that is not installed.\n"
302 f"Missing: {exc.name}\n"
303 f"Run: pip install {pip_name}",
304 file=sys.stderr,
305 )
306 sys.exit(1)
307 return module, canonical_name
308
309
310 def _print_backend_resolution() -> None:
311 """Print the effective Path A backend without exposing credentials."""
312 backend_from_process = "IMAGE_BACKEND" in os.environ
313 try:
314 env_path = _load_image_env_file()
315 except ValueError as exc:
316 print("Resolved backend: invalid configuration")
317 print(f"Configuration source: {ENV_PATH}")
318 print(f"Configuration error: {exc}")
319 return
320
321 try:
322 _validate_runtime_config()
323 except ValueError as exc:
324 print("Resolved backend: invalid configuration")
325 print("Configuration source: process environment")
326 print(f"Configuration error: {exc}")
327 return
328
329 backend_name = os.environ.get("IMAGE_BACKEND", "").strip().lower()
330 if not backend_name:
331 if backend_from_process:
332 source = "process environment (empty)"
333 elif env_path is not None:
334 source = f"none (checked {env_path})"
335 else:
336 source = "none (no .env found)"
337 print("Resolved backend: not configured (Path A unavailable)")
338 print(f"Configuration source: {source}")
339 return
340
341 canonical = BACKEND_ALIASES.get(backend_name)
342 resolved = canonical or f"invalid ({backend_name})"
343 source = "process environment" if backend_from_process else str(env_path or ENV_PATH)
344 print(f"Resolved backend: {resolved}")
345 print(f"Configuration source: {source}")
346
347
348 def _print_backend_list() -> None:
349 """Print supported backends grouped by support tier."""
350 print("Supported image backends:\n")
351 tiers = ("core", "extended", "experimental")
352 for tier in tiers:
353 print(f"{tier.upper()}:")
354 for name, info in sorted(
355 BACKEND_REGISTRY.items(),
356 key=lambda item: (TIER_ORDER[item[1]["tier"]], item[0]),
357 ):
358 if info["tier"] != tier:
359 continue
360 if info["default_model"]:
361 model_label = f"default={info['default_model']}"
362 else:
363 model_label = f"model=required via {info['model_hint']}"
364 print(
365 f" {name:<12} {info['label']} | "
366 f"{model_label} | "
367 f"size={info['default_image_size']} | keys={info['key_hint']}"
368 )
369 print()
370 print("Recommendation: prefer CORE backends for everyday PPT generation.")
371 _print_backend_resolution()
372
373
374 def _resolve_backend() -> tuple[object, str]:
375 """
376 Determine which backend to use from explicit configuration.
377
378 Returns:
379 A backend module with a generate() function.
380 """
381 backend_name = os.environ.get("IMAGE_BACKEND", "").strip().lower()
382 if backend_name:
383 canonical = BACKEND_ALIASES.get(backend_name)
384 if not canonical:
385 supported = ", ".join(SUPPORTED_BACKENDS)
386 print(f"Error: Unknown IMAGE_BACKEND='{backend_name}'. Supported: {supported}")
387 sys.exit(1)
388 return _load_backend(canonical)
389
390 supported = ", ".join(SUPPORTED_BACKENDS)
391 print(
392 "Error: No image backend configured for Path A (image_gen.py).\n"
393 "\n"
394 "If your host (Codex / Antigravity / Claude Code / etc.) has a native image\n"
395 "generation tool, do NOT run this script — switch to Path B: invoke the host's\n"
396 "image tool directly with the prompts from images/image_prompts.json and save\n"
397 "the outputs to images/<filename>. See references/image-generator.md §7 Path B.\n"
398 "\n"
399 "To use Path A instead, set IMAGE_BACKEND in one of these places:\n"
400 f" 1. Current process environment\n"
401 f" 2. {ENV_PATH}\n"
402 "\n"
403 f"Supported backends: {supported}\n"
404 "\n"
405 "Example:\n"
406 " IMAGE_BACKEND=openai\n"
407 " OPENAI_API_KEY=sk-xxx\n"
408 )
409 sys.exit(1)
410
411
412 _AI_IMAGE_PATH_ROW_RE = re.compile(
413 r"^\s*\|\s*AI Image Acquisition Path\s*\|\s*([^|]+?)\s*\|\s*$",
414 re.MULTILINE,
415 )
416 VALID_AI_IMAGE_ACQUISITION_PATHS = {
417 "api",
418 "auto",
419 "host-native",
420 "manual",
421 }
422
423
424 def _project_design_spec_for_manifest(manifest_path: str) -> Path | None:
425 """Return a project Design Spec for an images/ manifest, when present."""
426 path = Path(manifest_path).resolve()
427 if path.parent.name != "images":
428 return None
429 design_spec = path.parent.parent / "design_spec.md"
430 return design_spec if design_spec.is_file() else None
431
432
433 def _confirmed_image_acquisition_path_for_manifest(
434 manifest_path: str,
435 ) -> str | None:
436 """Return the Design Spec's AI Image Acquisition Path, if present."""
437 design_spec = _project_design_spec_for_manifest(manifest_path)
438 if design_spec is None:
439 return None
440 try:
441 text = design_spec.read_text(encoding="utf-8")
442 except OSError:
443 return None
444 match = _AI_IMAGE_PATH_ROW_RE.search(text)
445 if not match:
446 return None
447 value = match.group(1).strip().lstrip("`*_ ").strip()
448 token_match = re.match(
449 r"^(host[\s_-]*native|api|auto|manual)(?![A-Za-z0-9_-])",
450 value,
451 re.IGNORECASE,
452 )
453 selected = token_match.group(1) if token_match else value
454 return re.sub(r"[\s_]+", "-", selected.strip().lower())
455
456
457 def _guard_confirmed_non_api_path(manifest_path: str) -> None:
458 """Allow project Path A only when its Design Spec explicitly permits it."""
459 design_spec = _project_design_spec_for_manifest(manifest_path)
460 if design_spec is None:
461 return
462 acquisition_path = _confirmed_image_acquisition_path_for_manifest(manifest_path)
463 if acquisition_path not in VALID_AI_IMAGE_ACQUISITION_PATHS:
464 shown = acquisition_path or "(missing)"
465 valid = ", ".join(sorted(VALID_AI_IMAGE_ACQUISITION_PATHS))
466 print(
467 "Error: project manifest mode requires a valid "
468 "AI Image Acquisition Path in design_spec.md §I.\n"
469 f"Found: {shown!r}. Valid values: {valid}.\n"
470 "Return to Generate Step 4 recovery and record the durable "
471 "selection before running Path A."
472 )
473 sys.exit(1)
474 if acquisition_path in {"api", "auto"}:
475 return
476 if acquisition_path == "host-native":
477 print(
478 "Error: Design Spec confirms AI Image Acquisition Path as 'host-native'.\n"
479 "\n"
480 "Do NOT run image_gen.py --manifest for this project. That command is Path A\n"
481 "and may use the configured API/proxy backend. Use the host's native image\n"
482 "generation tool with prompts from images/image_prompts.json, save outputs to\n"
483 "images/<filename>, update each item status to Generated, then run:\n"
484 " python3 scripts/image_gen.py --render-md images/image_prompts.json\n"
485 )
486 else:
487 print(
488 "Error: Design Spec confirms AI Image Acquisition Path as 'manual'.\n"
489 "\n"
490 "Do NOT run image_gen.py --manifest for this project. Render the Markdown\n"
491 "sidecar and hand images/image_prompts.md to the user for external generation:\n"
492 " python3 scripts/image_gen.py --render-md images/image_prompts.json\n"
493 )
494 sys.exit(1)
495
496
497 DEFAULT_MANIFEST_CONCURRENCY = 3
498 MAX_MANIFEST_RATE_LIMIT_ATTEMPTS = 3
499
500 STATUS_PENDING = "Pending"
501 STATUS_GENERATED = "Generated"
502 STATUS_FAILED = "Failed"
503 STATUS_NEEDS_MANUAL = "Needs-Manual"
504 VALID_STATUSES = {STATUS_PENDING, STATUS_GENERATED, STATUS_FAILED, STATUS_NEEDS_MANUAL}
505 RETRYABLE_STATUSES = {STATUS_PENDING, STATUS_FAILED}
506 REQUIRED_ITEM_FIELDS = ("filename", "prompt", "aspect_ratio", "status")
507 VALID_PAGE_ROLES = {"local", "hero_page", "full_page"}
508 VALID_TEXT_POLICIES = {"none", "embedded"}
509 STRUCTURAL_IMAGE_TYPES = {
510 "infographic",
511 "flowchart",
512 "framework",
513 "matrix",
514 "cycle",
515 "funnel",
516 "pyramid",
517 "comparison",
518 "timeline",
519 "map",
520 "scene",
521 }
522 LEGACY_IMAGE_TYPES = {"background", "hero", "portrait", "typography"}
523 EARLY_LEGACY_IMAGE_TYPES = {"illustration", "photography"}
524 VALID_IMAGE_TYPES = (
525 STRUCTURAL_IMAGE_TYPES
526 | LEGACY_IMAGE_TYPES
527 | EARLY_LEGACY_IMAGE_TYPES
528 )
529
530
531 def _validate_bare_output_name(
532 value: str,
533 *,
534 field_name: str,
535 require_extension: bool = False,
536 reject_parent_marker: bool = False,
537 ) -> Path:
538 """Require one cross-platform-safe basename, optionally with an extension."""
539 value_path = Path(value)
540 if (
541 not value.strip()
542 or value in {".", ".."}
543 or reject_parent_marker and ".." in value
544 or "/" in value
545 or "\\" in value
546 or ":" in value
547 or value_path.is_absolute()
548 or value_path.name != value
549 ):
550 raise ValueError(
551 f"{field_name} must be a bare filename without path components, "
552 f"got {value!r}"
553 )
554 if require_extension and not value_path.suffix:
555 raise ValueError(f"{field_name} must include an extension, got {value!r}")
556 return value_path
557
558
559 def load_manifest(path: str) -> dict:
560 """Load and validate an `image_prompts.json` manifest.
561
562 Schema (top level): {"items": [ ... ]}, optionally with
563 `deck_rendering`, `color_scheme`, `generated_at`.
564
565 Each item requires: `filename`, `prompt`, `aspect_ratio`, `status`.
566 Optional: `image_size`, `model`, `alt_text`, `purpose`, `type`,
567 `page_role`, `text_policy`, `slice_grid`, `slice_names`, `last_error`.
568 """
569 from image_backends.backend_common import normalize_image_size
570
571 try:
572 data = json.loads(Path(path).read_text(encoding="utf-8"))
573 except json.JSONDecodeError as exc:
574 raise ValueError(
575 f"Invalid JSON in {path}: {exc.msg} "
576 f"(line {exc.lineno}, col {exc.colno})"
577 ) from exc
578
579 if not isinstance(data, dict):
580 raise ValueError(
581 f"{path}: top level must be a JSON object, "
582 f"got {type(data).__name__}"
583 )
584
585 for field in ("project", "generated_at", "deck_rendering"):
586 if field not in data:
587 continue
588 if not isinstance(data[field], str) or not data[field].strip():
589 raise ValueError(
590 f"{path}: field '{field}' must be a non-empty string when present"
591 )
592 if "deck_style_anchor" in data:
593 legacy_anchor = data["deck_style_anchor"]
594 if not (
595 isinstance(legacy_anchor, str)
596 and legacy_anchor.strip()
597 or isinstance(legacy_anchor, dict)
598 and legacy_anchor
599 ):
600 raise ValueError(
601 f"{path}: legacy field 'deck_style_anchor' must be a "
602 "non-empty string or object when present"
603 )
604
605 if "color_scheme" in data:
606 color_scheme = data["color_scheme"]
607 if not isinstance(color_scheme, dict) or not color_scheme:
608 raise ValueError(
609 f"{path}: field 'color_scheme' must be a non-empty object when present"
610 )
611 for key, value in color_scheme.items():
612 if (
613 not isinstance(key, str)
614 or not key.strip()
615 or not isinstance(value, str)
616 or not value.strip()
617 ):
618 raise ValueError(
619 f"{path}: color_scheme keys and values must be non-empty strings"
620 )
621
622 items = data.get("items")
623 if not isinstance(items, list) or not items:
624 raise ValueError(f"{path}: 'items' must be a non-empty array")
625
626 claimed_outputs: dict[str, str] = {}
627 seen_stems: set[str] = set()
628 missing_page_role = 0
629 missing_text_policy = 0
630 for i, item in enumerate(items):
631 prefix = f"{path}: items[{i}]"
632 if not isinstance(item, dict):
633 raise ValueError(f"{prefix} must be an object")
634 for field in REQUIRED_ITEM_FIELDS:
635 if field not in item:
636 raise ValueError(f"{prefix} missing required field '{field}'")
637 if not isinstance(item[field], str) or not item[field].strip():
638 raise ValueError(
639 f"{prefix} field '{field}' must be a non-empty string"
640 )
641 if item["status"] not in VALID_STATUSES:
642 raise ValueError(
643 f"{prefix} status '{item['status']}' is invalid. "
644 f"Valid: {sorted(VALID_STATUSES)}"
645 )
646 if item["aspect_ratio"] not in ALL_ASPECT_RATIOS:
647 raise ValueError(
648 f"{prefix} aspect_ratio '{item['aspect_ratio']}' is invalid. "
649 f"Valid: {ALL_ASPECT_RATIOS}"
650 )
651 if "image_size" in item:
652 image_size = item["image_size"]
653 if not isinstance(image_size, str) or not image_size.strip():
654 raise ValueError(
655 f"{prefix} field 'image_size' must be a non-empty string"
656 )
657 normalized_size = normalize_image_size(image_size)
658 if normalized_size not in ALL_IMAGE_SIZES:
659 raise ValueError(
660 f"{prefix} image_size '{image_size}' is invalid. "
661 f"Valid: {ALL_IMAGE_SIZES}"
662 )
663
664 page_role = item.get("page_role")
665 if page_role is None:
666 missing_page_role += 1
667 elif not isinstance(page_role, str) or page_role not in VALID_PAGE_ROLES:
668 raise ValueError(
669 f"{prefix} page_role '{page_role}' is invalid. "
670 f"Valid: {sorted(VALID_PAGE_ROLES)}"
671 )
672
673 text_policy = item.get("text_policy")
674 if text_policy is None:
675 missing_text_policy += 1
676 elif (
677 not isinstance(text_policy, str)
678 or text_policy not in VALID_TEXT_POLICIES
679 ):
680 raise ValueError(
681 f"{prefix} text_policy '{text_policy}' is invalid. "
682 f"Valid: {sorted(VALID_TEXT_POLICIES)}"
683 )
684
685 image_type = item.get("type")
686 if image_type is not None:
687 normalized_type = (
688 image_type.strip().lower()
689 if isinstance(image_type, str)
690 else ""
691 )
692 if normalized_type not in VALID_IMAGE_TYPES:
693 raise ValueError(
694 f"{prefix} type '{image_type}' is invalid. "
695 f"Valid current/legacy values: {sorted(VALID_IMAGE_TYPES)}"
696 )
697
698 for field in ("model", "alt_text", "purpose"):
699 if field in item and (
700 not isinstance(item[field], str) or not item[field].strip()
701 ):
702 raise ValueError(
703 f"{prefix} field '{field}' must be a non-empty string when present"
704 )
705 if "last_error" in item and not isinstance(item["last_error"], str):
706 raise ValueError(f"{prefix} field 'last_error' must be a string")
707 has_slice_grid = "slice_grid" in item
708 has_slice_names = "slice_names" in item
709 slice_outputs: list[str] = []
710 if has_slice_grid != has_slice_names:
711 raise ValueError(
712 f"{prefix} fields 'slice_grid' and 'slice_names' must appear together"
713 )
714 if has_slice_grid:
715 slice_grid = item["slice_grid"]
716 grid_match = (
717 re.fullmatch(r"([1-9]\d*)[xX]([1-9]\d*)", slice_grid.strip())
718 if isinstance(slice_grid, str)
719 else None
720 )
721 if grid_match is None:
722 raise ValueError(
723 f"{prefix} field 'slice_grid' must use positive RxC notation"
724 )
725 slice_names = item["slice_names"]
726 if not isinstance(slice_names, str) or not slice_names.strip():
727 raise ValueError(
728 f"{prefix} field 'slice_names' must be a non-empty string"
729 )
730 names = [name.strip() for name in slice_names.split(",")]
731 if any(not name for name in names):
732 raise ValueError(
733 f"{prefix} field 'slice_names' contains an empty name"
734 )
735 rows, cols = map(int, grid_match.groups())
736 if len(names) != rows * cols:
737 raise ValueError(
738 f"{prefix} field 'slice_names' has {len(names)} names but "
739 f"slice_grid {rows}x{cols} requires {rows * cols}"
740 )
741 normalized_outputs: set[str] = set()
742 for name in names:
743 name_path = _validate_bare_output_name(
744 name,
745 field_name=f"{prefix} slice output name",
746 reject_parent_marker=True,
747 )
748 if name_path.suffix and name_path.suffix.lower() != ".png":
749 raise ValueError(
750 f"{prefix} slice output name {name!r} must omit its "
751 "extension or use .png"
752 )
753 output_name = (
754 name if name_path.suffix else f"{name}.png"
755 ).casefold()
756 if output_name in normalized_outputs:
757 raise ValueError(
758 f"{prefix} field 'slice_names' repeats output "
759 f"{output_name!r}"
760 )
761 normalized_outputs.add(output_name)
762 slice_outputs.append(output_name)
763
764 fname = item["filename"]
765 filename_path = _validate_bare_output_name(
766 fname,
767 field_name=f"{prefix} field 'filename'",
768 require_extension=True,
769 )
770 normalized_filename = fname.casefold()
771 if normalized_filename in claimed_outputs:
772 raise ValueError(
773 f"{prefix} output filename {fname!r} conflicts with "
774 f"{claimed_outputs[normalized_filename]} (case-insensitive)"
775 )
776 claimed_outputs[normalized_filename] = f"manifest output {fname!r}"
777
778 stem = filename_path.stem.casefold()
779 if stem in seen_stems:
780 raise ValueError(
781 f"{prefix} duplicate filename stem '{filename_path.stem}' "
782 "would reuse backend output"
783 )
784 seen_stems.add(stem)
785
786 for output_name in slice_outputs:
787 if output_name in claimed_outputs:
788 raise ValueError(
789 f"{prefix} slice output {output_name!r} conflicts with "
790 f"{claimed_outputs[output_name]} (case-insensitive)"
791 )
792 claimed_outputs[output_name] = (
793 f"slice output {output_name!r} from items[{i}]"
794 )
795
796 legacy_parts = []
797 if missing_page_role:
798 legacy_parts.append(
799 f"{missing_page_role} item(s) missing page_role (resolved as local)"
800 )
801 if missing_text_policy:
802 legacy_parts.append(
803 f"{missing_text_policy} item(s) missing text_policy (resolved as none)"
804 )
805 if legacy_parts:
806 print(
807 f"Warning: {path}: legacy manifest compatibility: "
808 + "; ".join(legacy_parts),
809 file=sys.stderr,
810 )
811
812 return data
813
814
815 def save_manifest(path: str, data: dict) -> None:
816 """Atomically write manifest back to disk (tmp file + rename)."""
817 target = Path(path)
818 fd, tmp_path = tempfile.mkstemp(
819 prefix=target.stem + ".",
820 suffix=".tmp",
821 dir=str(target.parent),
822 )
823 try:
824 with os.fdopen(fd, "w", encoding="utf-8") as f:
825 json.dump(data, f, ensure_ascii=False, indent=2)
826 f.write("\n")
827 os.replace(tmp_path, target)
828 except Exception:
829 try:
830 os.unlink(tmp_path)
831 except OSError:
832 pass
833 raise
834
835
836 def _materialize_manifest_image(saved_path: str, target_path: Path) -> str:
837 """Validate backend output and place it at the manifest's exact target path."""
838 from image_backends.backend_common import (
839 save_image_bytes,
840 validate_image_file,
841 )
842
843 source_path = Path(saved_path)
844 validate_image_file(str(source_path))
845
846 if source_path.resolve() != target_path.resolve():
847 try:
848 image_bytes = source_path.read_bytes()
849 except OSError as exc:
850 raise RuntimeError(
851 f"Could not read image output {source_path}: {exc}"
852 ) from exc
853 save_image_bytes(image_bytes, str(target_path))
854
855 validate_image_file(str(target_path))
856 return str(target_path)
857
858
859 def _run_manifest(manifest: dict, manifest_path: str, backend_module, *,
860 initial_concurrency: int,
861 image_size: str,
862 output_dir: str,
863 model: str | None) -> tuple[int, int, int]:
864 """Run Pending/Failed items through the backend with adaptive concurrency.
865
866 Strategy:
867 - Verify every `Generated` item's target before treating it as done;
868 missing or unreadable output returns to `Failed` for this run.
869 - Start at `initial_concurrency` workers per batch.
870 - On any rate-limit error in a batch, halve concurrency (min 1) and
871 requeue the rate-limited items within a fixed attempt budget.
872 - A rate limit at concurrency 1 or after the budget is exhausted is
873 recorded as `status: Failed` + `last_error`; the current run then stops
874 without switching providers.
875 - Per-item failures are recorded as `status: Failed` + `last_error`
876 and not retried within this run. `Failed` remains retryable and
877 non-terminal; the Step 5 gate must resolve it by rerunning this
878 manifest or marking the item `Needs-Manual`.
879 - Global auth or billing errors stop new batches; untouched rows remain
880 retryable. Permanent model or request errors fail only their own row.
881 - Status is written back to the manifest file after each completion;
882 a Ctrl-C in the middle still preserves done items.
883 - `Needs-Manual` items are skipped (user processes them externally).
884
885 Returns (ok_count, failed_count, skipped_count).
886 """
887 manifest_output_dir = Path(manifest_path).resolve().parent
888 if Path(output_dir).resolve() != manifest_output_dir:
889 raise ValueError(
890 "Manifest outputs must stay beside image_prompts.json: "
891 f"expected {manifest_output_dir}, got {Path(output_dir).resolve()}"
892 )
893 output_dir = str(manifest_output_dir)
894
895 from image_backends.backend_common import (
896 is_global_permanent_error,
897 is_permanent_error,
898 is_rate_limit_error,
899 validate_image_file,
900 )
901
902 items = manifest["items"]
903 repaired_generated = False
904 for item in items:
905 if item["status"] != STATUS_GENERATED:
906 continue
907 target_path = Path(output_dir) / item["filename"]
908 try:
909 validate_image_file(str(target_path))
910 except RuntimeError as exc:
911 item["status"] = STATUS_FAILED
912 item["last_error"] = (
913 f"Generated file validation failed: {exc}"
914 )[:500]
915 repaired_generated = True
916 print(
917 f" [RETRY] {item['filename']} was marked Generated but its "
918 f"target is invalid: {exc}"
919 )
920 if repaired_generated:
921 save_manifest(manifest_path, manifest)
922
923 pending_idx = [
924 i for i, it in enumerate(items) if it["status"] in RETRYABLE_STATUSES
925 ]
926 total = len(pending_idx)
927 skipped = len(items) - total
928
929 if total == 0:
930 print(
931 f"[Manifest] Nothing to do — all {len(items)} items already in "
932 "a terminal state (Generated / Needs-Manual)."
933 )
934 return 0, 0, skipped
935
936 print(
937 f"\n[Manifest] {total} item(s) to generate, "
938 f"{skipped} already done. concurrency={initial_concurrency}\n"
939 )
940
941 queue: list[int] = list(pending_idx)
942 ok_count = 0
943 fail_count = 0
944 current = max(1, initial_concurrency)
945 state_lock = threading.Lock()
946 rate_limit_attempts: dict[int, int] = {}
947 stopped_for_global_error = False
948 stopped_for_rate_limit = False
949
950 def _one(idx: int):
951 item = items[idx]
952 try:
953 saved_path = backend_module.generate(
954 prompt=item["prompt"],
955 aspect_ratio=item["aspect_ratio"],
956 image_size=item.get("image_size", image_size),
957 output_dir=output_dir,
958 filename=Path(item["filename"]).stem,
959 model=item.get("model", model),
960 )
961 saved_path = _materialize_manifest_image(
962 saved_path,
963 Path(output_dir) / item["filename"],
964 )
965 return idx, saved_path, None
966 except Exception as exc: # noqa: BLE001 — backend raises arbitrary types
967 return idx, None, exc
968
969 while queue:
970 batch_size = min(current, len(queue))
971 batch_idx = queue[:batch_size]
972 queue = queue[batch_size:]
973
974 print(
975 f"--- Batch of {batch_size} (concurrency={current}, "
976 f"remaining_after={len(queue)}) ---"
977 )
978
979 rate_limited = False
980 with concurrent.futures.ThreadPoolExecutor(max_workers=batch_size) as ex:
981 futures = [ex.submit(_one, i) for i in batch_idx]
982 for fut in concurrent.futures.as_completed(futures):
983 idx, saved_path, exc = fut.result()
984 item = items[idx]
985 with state_lock:
986 if exc is None:
987 item["status"] = STATUS_GENERATED
988 item.pop("last_error", None)
989 ok_count += 1
990 print(f" [OK] {item['filename']}")
991 elif isinstance(exc, ValueError) or is_permanent_error(exc):
992 global_error = is_global_permanent_error(exc)
993 item["status"] = STATUS_FAILED
994 error_scope = "Global" if global_error else "Permanent"
995 repair_target = (
996 "backend access" if global_error else "model or request"
997 )
998 item["last_error"] = (
999 f"{error_scope} backend error: {exc}"
1000 )[:500]
1001 fail_count += 1
1002 if global_error:
1003 stopped_for_global_error = True
1004 print(
1005 f" [FAIL] {item['filename']}: {exc} "
1006 f"(status=Failed; repair {repair_target} before retry)"
1007 )
1008 elif is_rate_limit_error(exc):
1009 rate_limited = True
1010 attempts = rate_limit_attempts.get(idx, 0) + 1
1011 rate_limit_attempts[idx] = attempts
1012 if (
1013 current == 1
1014 or attempts >= MAX_MANIFEST_RATE_LIMIT_ATTEMPTS
1015 ):
1016 boundary = (
1017 "serial concurrency reached"
1018 if current == 1
1019 else "rate-limit attempt budget exhausted"
1020 )
1021 item["status"] = STATUS_FAILED
1022 item["last_error"] = (
1023 f"Rate limit persisted ({boundary}; "
1024 f"attempt {attempts}): {exc}"
1025 )[:500]
1026 fail_count += 1
1027 stopped_for_rate_limit = True
1028 print(
1029 f" [FAIL] {item['filename']}: {exc} "
1030 f"({boundary}; status=Failed)"
1031 )
1032 else:
1033 queue.append(idx)
1034 print(
1035 f" [RATE] {item['filename']} — requeued "
1036 f"(attempt {attempts}/"
1037 f"{MAX_MANIFEST_RATE_LIMIT_ATTEMPTS})"
1038 )
1039 else:
1040 item["status"] = STATUS_FAILED
1041 item["last_error"] = str(exc)[:500]
1042 fail_count += 1
1043 print(
1044 f" [FAIL] {item['filename']}: {exc} "
1045 "(status=Failed; retry or mark Needs-Manual before Executor)"
1046 )
1047 save_manifest(manifest_path, manifest)
1048
1049 if stopped_for_global_error:
1050 print(
1051 "\n Backend authentication or billing requires repair. "
1052 "Stopping new batches; untouched items remain retryable.\n"
1053 )
1054 break
1055 if stopped_for_rate_limit:
1056 print(
1057 "\n Persistent rate limit reached the run boundary. "
1058 "Stopping without switching providers; untouched items remain retryable.\n"
1059 )
1060 break
1061 if rate_limited and current > 1 and queue:
1062 new_current = max(1, current // 2)
1063 print(
1064 f"\n ⚠ Rate-limit hit — concurrency {current} → {new_current}, "
1065 "pausing 10s before next batch\n"
1066 )
1067 current = new_current
1068 time.sleep(10)
1069 elif queue:
1070 time.sleep(2)
1071
1072 stopped_early = stopped_for_global_error or stopped_for_rate_limit
1073 run_state = "Stopped" if stopped_early else "Done"
1074 remaining_note = ""
1075 if stopped_early:
1076 remaining = sum(
1077 1 for item in items if item["status"] in RETRYABLE_STATUSES
1078 )
1079 remaining_note = f"; {remaining} item(s) remain retryable"
1080 print(
1081 f"\n[Manifest] {run_state}: {ok_count} ok / {fail_count} failed "
1082 f"({skipped} pre-skipped{remaining_note}). "
1083 f"Manifest written to {manifest_path}"
1084 )
1085 if fail_count:
1086 print(
1087 "[Manifest] Failed is retryable and non-terminal. "
1088 "Repair permanent backend errors before rerunning; retry transient "
1089 "failures or follow the owning manual recovery before entering "
1090 "Executor."
1091 )
1092 return ok_count, fail_count, skipped
1093
1094
1095 def _resolve_concurrency(cli_value: int | None) -> int:
1096 """CLI value wins over IMAGE_CONCURRENCY env; default 3."""
1097 if cli_value is not None:
1098 return max(1, cli_value)
1099 env_val = os.environ.get("IMAGE_CONCURRENCY", "").strip()
1100 if env_val.isdigit():
1101 return max(1, int(env_val))
1102 return DEFAULT_MANIFEST_CONCURRENCY
1103
1104
1105 def render_manifest_md(manifest: dict) -> str:
1106 """Render a manifest into the paste-ready Markdown view.
1107
1108 The output is a read-only snapshot of the JSON manifest, intended as a
1109 fallback so a user can copy `Prompt` blocks into ChatGPT / Midjourney
1110 when `--manifest` cannot run (no key, no backend, network down).
1111 """
1112 lines: list[str] = []
1113 lines.append("# Image Generation Prompts")
1114 lines.append("")
1115 lines.append("> Auto-generated from `image_prompts.json` by `image_gen.py --render-md`.")
1116 lines.append("> Do not hand-edit — re-run the command to refresh.")
1117 lines.append("")
1118
1119 project = manifest.get("project")
1120 generated_at = manifest.get("generated_at")
1121 color_scheme = manifest.get("color_scheme") or {}
1122 deck_rendering = manifest.get("deck_rendering")
1123 if not deck_rendering:
1124 legacy_anchor = manifest.get("deck_style_anchor")
1125 if isinstance(legacy_anchor, dict):
1126 deck_rendering = (
1127 legacy_anchor.get("visual_style")
1128 or json.dumps(legacy_anchor, ensure_ascii=False, sort_keys=True)
1129 )
1130 else:
1131 deck_rendering = legacy_anchor
1132
1133 if project:
1134 lines.append(f"> Project: {project}")
1135 if generated_at:
1136 lines.append(f"> Generated: {generated_at}")
1137 if color_scheme:
1138 cs = " | ".join(
1139 f"{k.capitalize()} {v}" for k, v in color_scheme.items()
1140 )
1141 lines.append(f"> Color scheme: {cs}")
1142 if deck_rendering:
1143 lines.append(f"> Deck Rendering: {deck_rendering}")
1144 lines.append("")
1145 lines.append("---")
1146 lines.append("")
1147
1148 for i, item in enumerate(manifest["items"], start=1):
1149 lines.append(f"### Image {i}: {item['filename']}")
1150 lines.append("")
1151 lines.append("| Attribute | Value |")
1152 lines.append("|---|---|")
1153 for label, key in (
1154 ("Purpose", "purpose"),
1155 ("Type", "type"),
1156 ("Page role", "page_role"),
1157 ("Text policy", "text_policy"),
1158 ("Aspect ratio", "aspect_ratio"),
1159 ("Image size", "image_size"),
1160 ("Model", "model"),
1161 ("Slice grid", "slice_grid"),
1162 ("Slice names", "slice_names"),
1163 ("Status", "status"),
1164 ):
1165 value = item.get(key)
1166 if not value and key == "page_role":
1167 value = "local (legacy default)"
1168 elif not value and key == "text_policy":
1169 value = "none (legacy default)"
1170 if value:
1171 lines.append(f"| {label} | {value} |")
1172 if item.get("last_error"):
1173 lines.append(f"| Last error | {item['last_error']} |")
1174 lines.append("")
1175 lines.append("**Prompt**:")
1176 lines.append("")
1177 lines.append(item["prompt"])
1178 lines.append("")
1179 if item.get("alt_text"):
1180 lines.append("**Alt Text**:")
1181 lines.append(f"> {item['alt_text']}")
1182 lines.append("")
1183 lines.append("---")
1184 lines.append("")
1185
1186 return "\n".join(lines).rstrip() + "\n"
1187
1188
1189 def render_manifest_md_to_file(manifest_path: str, manifest: dict | None = None) -> str:
1190 """Render the manifest's Markdown sidecar next to the JSON file.
1191
1192 Returns the written path. If `manifest` is omitted, it is loaded from
1193 `manifest_path` first.
1194 """
1195 if manifest is None:
1196 manifest = load_manifest(manifest_path)
1197 md_path = str(Path(manifest_path).with_suffix(".md"))
1198 Path(md_path).write_text(render_manifest_md(manifest), encoding="utf-8")
1199 return md_path
1200
1201
1202 def main() -> None:
1203 """Run the CLI entry point."""
1204 parser = argparse.ArgumentParser(
1205 description="Generate images using AI image model providers."
1206 )
1207 parser.add_argument(
1208 "prompt", nargs="?", default=None,
1209 help=(
1210 "The text prompt for image generation. With --reference-image, "
1211 "this is the edit instruction (required in that mode)."
1212 )
1213 )
1214 parser.add_argument(
1215 "--aspect_ratio", default="1:1", choices=ALL_ASPECT_RATIOS,
1216 help=f"Aspect ratio. Default: 1:1."
1217 )
1218 parser.add_argument(
1219 "--image_size", default=None,
1220 help=(
1221 f"Image size. Choices: {ALL_IMAGE_SIZES}. Default depends on the "
1222 "backend and is shown by --list-backends. (case-insensitive)"
1223 ),
1224 )
1225 parser.add_argument(
1226 "--output", "-o", default=None,
1227 help="Output directory. Default: current directory."
1228 )
1229 parser.add_argument(
1230 "--filename", "-f", default=None,
1231 help="Output filename (without extension). Overrides auto-naming."
1232 )
1233 parser.add_argument(
1234 "--model", "-m", default=None,
1235 help="Model name. Default depends on backend."
1236 )
1237 parser.add_argument(
1238 "--backend", "-b", default=None, choices=SUPPORTED_BACKENDS,
1239 help="Override IMAGE_BACKEND env var."
1240 )
1241 parser.add_argument(
1242 "--list-backends", action="store_true",
1243 help="List available backends grouped by support tier and exit."
1244 )
1245 parser.add_argument(
1246 "--manifest", default=None, metavar="IMAGE_PROMPTS_JSON",
1247 help=(
1248 "Path to image_prompts.json. Runs every Pending/Failed item in "
1249 "parallel; writes status back to the manifest as each completes."
1250 ),
1251 )
1252 parser.add_argument(
1253 "--concurrency", type=int, default=None,
1254 help=(
1255 "Max concurrent requests in --manifest mode. Defaults to "
1256 f"IMAGE_CONCURRENCY env or {DEFAULT_MANIFEST_CONCURRENCY}. "
1257 "Auto-halves on rate-limit; 1 is the serial fallback."
1258 ),
1259 )
1260 parser.add_argument(
1261 "--render-md", dest="render_md", default=None, metavar="IMAGE_PROMPTS_JSON",
1262 help=(
1263 "Render <json>'s read-only Markdown sidecar (image_prompts.md) "
1264 "next to the manifest, then exit. No backend / network needed."
1265 ),
1266 )
1267 parser.add_argument(
1268 "--reference-image", dest="reference_image", default=None, metavar="PATH",
1269 help=(
1270 "Source image for image-to-image editing (single-image mode only). "
1271 "When set, the prompt is used as the edit instruction. Only backends "
1272 "that support editing accept this (currently: gemini, openai). Not "
1273 "valid with --manifest / --render-md / --list-backends."
1274 ),
1275 )
1276
1277 args = parser.parse_args()
1278
1279 if args.filename is not None:
1280 try:
1281 _validate_bare_output_name(
1282 args.filename,
1283 field_name="--filename",
1284 )
1285 except ValueError as exc:
1286 parser.error(str(exc))
1287
1288 if args.reference_image is not None:
1289 # Reference editing is a single-image-only enhancement; keep it out of
1290 # the manifest / sidecar / list surfaces entirely.
1291 conflicting = [
1292 name for name, val in (
1293 ("--manifest", args.manifest),
1294 ("--render-md", args.render_md),
1295 ("--list-backends", args.list_backends),
1296 ) if val
1297 ]
1298 if conflicting:
1299 parser.error(
1300 "--reference-image is single-image mode only and cannot be "
1301 f"combined with {', '.join(conflicting)}."
1302 )
1303 if not args.prompt or not args.prompt.strip():
1304 parser.error(
1305 "--reference-image requires a prompt to use as the edit instruction."
1306 )
1307 if not os.path.isfile(args.reference_image):
1308 parser.error(
1309 f"--reference-image file not found: {args.reference_image}"
1310 )
1311
1312 if args.list_backends:
1313 _print_backend_list()
1314 return
1315
1316 if args.render_md:
1317 if not os.path.isfile(args.render_md):
1318 print(f"Error: manifest file not found: {args.render_md}")
1319 sys.exit(1)
1320 try:
1321 manifest = load_manifest(args.render_md)
1322 except ValueError as e:
1323 print(f"Error: {e}")
1324 sys.exit(1)
1325 md_path = render_manifest_md_to_file(args.render_md, manifest)
1326 print(f"Rendered Markdown sidecar: {md_path}")
1327 return
1328
1329 manifest = None
1330 manifest_output_dir = None
1331 if args.manifest:
1332 if not os.path.isfile(args.manifest):
1333 print(f"Error: manifest file not found: {args.manifest}")
1334 sys.exit(1)
1335 _guard_confirmed_non_api_path(args.manifest)
1336 try:
1337 manifest = load_manifest(args.manifest)
1338 except ValueError as e:
1339 print(f"Error: {e}")
1340 sys.exit(1)
1341 manifest_output_dir = Path(args.manifest).resolve().parent
1342 if args.output and Path(args.output).resolve() != manifest_output_dir:
1343 print(
1344 "Error: --output cannot redirect manifest items outside the "
1345 f"manifest directory ({manifest_output_dir})"
1346 )
1347 sys.exit(1)
1348
1349 try:
1350 _load_image_env_file()
1351 _validate_runtime_config()
1352 except ValueError as e:
1353 print(f"Error: {e}")
1354 sys.exit(1)
1355
1356 # CLI --backend overrides the value loaded from .env
1357 if args.backend:
1358 os.environ["IMAGE_BACKEND"] = args.backend
1359
1360 backend, backend_name = _resolve_backend()
1361 image_size = (
1362 args.image_size
1363 or BACKEND_REGISTRY[backend_name]["default_image_size"]
1364 )
1365 print(f"Using backend: {backend_name}\n")
1366
1367 if args.manifest:
1368 concurrency = _resolve_concurrency(args.concurrency)
1369 try:
1370 _, failed, _ = _run_manifest(
1371 manifest, args.manifest, backend,
1372 initial_concurrency=concurrency,
1373 image_size=image_size,
1374 output_dir=str(manifest_output_dir),
1375 model=args.model,
1376 )
1377 except KeyboardInterrupt:
1378 print("\n\nInterrupted by user. Partial progress preserved in manifest.")
1379 sys.exit(130)
1380 md_path = render_manifest_md_to_file(args.manifest, manifest)
1381 print(f"Rendered Markdown sidecar: {md_path}")
1382 sys.exit(1 if failed else 0)
1383
1384 # Single-image mode. Backfill the historical default prompt only here, so
1385 # plain generation is byte-for-byte unchanged while edit mode still requires
1386 # an explicit instruction (enforced above).
1387 prompt = args.prompt if args.prompt is not None else "a beautiful landscape"
1388
1389 gen_kwargs = {
1390 "prompt": prompt,
1391 "aspect_ratio": args.aspect_ratio,
1392 "image_size": image_size,
1393 "output_dir": args.output,
1394 "filename": args.filename,
1395 "model": args.model,
1396 }
1397 if args.reference_image is not None:
1398 if not getattr(backend, "SUPPORTS_REFERENCE_IMAGE", False):
1399 print(
1400 f"Error: backend '{backend_name}' does not support image editing "
1401 "(--reference-image). Use a backend that does "
1402 "(currently: gemini, openai)."
1403 )
1404 sys.exit(1)
1405 gen_kwargs["reference_image"] = args.reference_image
1406
1407 try:
1408 backend.generate(**gen_kwargs)
1409 except (ValueError, FileNotFoundError) as e:
1410 print(f"Error: {e}")
1411 sys.exit(1)
1412 except RuntimeError as e:
1413 print(f"Error: {e}")
1414 sys.exit(1)
1415 except KeyboardInterrupt:
1416 print("\n\nInterrupted by user.")
1417 sys.exit(130)
1418
1419
1420 if __name__ == "__main__":
1421 main()
1422
1422 lines PYTHON