#!/usr/bin/env python3 """ Unified Image Generation Tool Dispatches to the appropriate backend based on explicit provider configuration. Backend selection (`IMAGE_BACKEND` in `.env` or the current process environment): IMAGE_BACKEND=gemini -> Gemini backend (google-genai SDK) IMAGE_BACKEND=openai -> OpenAI-compatible backend (raw HTTP via requests) IMAGE_BACKEND=minimax -> MiniMax image backend IMAGE_BACKEND=stability -> Stability AI backend IMAGE_BACKEND=bfl -> Black Forest Labs FLUX backend IMAGE_BACKEND=ideogram -> Ideogram backend IMAGE_BACKEND=qwen -> Alibaba Qwen image backend IMAGE_BACKEND=zhipu -> Zhipu GLM-Image backend IMAGE_BACKEND=volcengine -> Volcengine Seedream backend IMAGE_BACKEND=modelscope -> ModelScope backend IMAGE_BACKEND=siliconflow -> SiliconFlow backend IMAGE_BACKEND=fal -> fal.ai backend IMAGE_BACKEND=replicate -> Replicate backend IMAGE_BACKEND=openrouter -> OpenRouter backend Configuration source (process env wins, `.env` is the fallback layer): 1. Current process environment variables 2. The first `.env` found among: - Current working directory - Skill directory (e.g. `~/.agents/skills/ppt-master/.env`) - Repo root (when running from a clone) - `~/.ppt-master/.env` (user-level config) Supported keys: IMAGE_BACKEND (required) backend name Provider-specific keys are used for credentials and overrides, for example: GEMINI_API_KEY / GEMINI_MODEL / GEMINI_BASE_URL OPENAI_API_KEY / OPENAI_MODEL / OPENAI_BASE_URL QWEN_API_KEY / QWEN_MODEL / QWEN_BASE_URL ZHIPU_API_KEY / ZHIPU_MODEL / ZHIPU_BASE_URL Usage: python3 image_gen.py "prompt" --aspect_ratio 16:9 --image_size 1K -o images/ python3 image_gen.py "edit instruction" --reference-image src.png -o images/ python3 image_gen.py --manifest project/images/image_prompts.json -o project/images/ python3 image_gen.py --list-backends """ import argparse import concurrent.futures import json import os import re import sys import tempfile import threading import time from pathlib import Path from console_encoding import configure_utf8_stdio from config import load_prefixed_env_file, resolve_env_path configure_utf8_stdio() ENV_PATH = resolve_env_path() IMAGE_ENV_PREFIXES = ( "IMAGE_", "GEMINI_", "OPENAI_", "MINIMAX_", "STABILITY_", "BFL_", "IDEOGRAM_", "QWEN_", "DASHSCOPE_", "ZHIPU_", "BIGMODEL_", "VOLCENGINE_", "ARK_", "MODELSCOPE_", "SILICONFLOW_", "FAL_", "REPLICATE_", "OPENROUTER_", ) DEPRECATED_IMAGE_KEYS = { "IMAGE_API_KEY", "IMAGE_MODEL", "IMAGE_BASE_URL", } # All aspect ratios accepted by the unified CLI # (each backend validates its own subset internally) ALL_ASPECT_RATIOS = [ "1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9" ] ALL_IMAGE_SIZES = ["512px", "1K", "2K", "4K"] BACKEND_REGISTRY = { "gemini": { "module": "backend_gemini", "tier": "core", "label": "Google Gemini", "default_model": "gemini-3.1-flash-image-preview", "key_hint": "GEMINI_API_KEY", "aliases": ["google"], }, "openai": { "module": "backend_openai", "tier": "core", "label": "OpenAI / OpenAI-compatible", "default_model": "gpt-image-2", "key_hint": "OPENAI_API_KEY", "aliases": ["openai-compatible", "openai_compatible"], }, "minimax": { "module": "backend_minimax", "tier": "experimental", "label": "MiniMax Image", "default_model": "image-01", "key_hint": "MINIMAX_API_KEY", "aliases": ["minimaxi"], }, "qwen": { "module": "backend_qwen", "tier": "core", "label": "Alibaba Qwen Image", "default_model": "qwen-image-2.0-pro", "key_hint": "QWEN_API_KEY / DASHSCOPE_API_KEY", "aliases": ["alibaba", "dashscope"], }, "zhipu": { "module": "backend_zhipu", "tier": "core", "label": "Zhipu GLM-Image", "default_model": "glm-image", "key_hint": "ZHIPU_API_KEY / BIGMODEL_API_KEY", "aliases": ["bigmodel", "glm", "glm-image"], }, "volcengine": { "module": "backend_volcengine", "tier": "core", "label": "Volcengine Seedream", "default_model": "doubao-seedream-4-5-251128", "key_hint": "VOLCENGINE_API_KEY / ARK_API_KEY", "aliases": ["ark", "doubao", "seedream"], }, "modelscope": { "module": "backend_modelscope", "tier": "experimental", "label": "ModelScope", "default_model": "Tongyi-MAI/Z-Image-Turbo", "key_hint": "MODELSCOPE_API_KEY", "aliases": ["modelscope", "model-scope"] }, "stability": { "module": "backend_stability", "tier": "extended", "label": "Stability AI", "default_model": "stable-image-core", "key_hint": "STABILITY_API_KEY", "aliases": ["stabilityai", "stability-ai"], }, "bfl": { "module": "backend_bfl", "tier": "extended", "label": "Black Forest Labs FLUX", "default_model": "flux-pro-1.1-ultra", "key_hint": "BFL_API_KEY", "aliases": ["flux", "black-forest-labs", "black_forest_labs"], }, "ideogram": { "module": "backend_ideogram", "tier": "extended", "label": "Ideogram", "default_model": "ideogram-v3", "key_hint": "IDEOGRAM_API_KEY", }, "siliconflow": { "module": "backend_siliconflow", "tier": "experimental", "label": "SiliconFlow", "default_model": "Qwen/Qwen-Image", "key_hint": "SILICONFLOW_API_KEY", "aliases": ["silicon"], }, "fal": { "module": "backend_fal", "tier": "experimental", "label": "fal.ai", "default_model": "fal-ai/imagen3/fast", "key_hint": "FAL_KEY / FAL_API_KEY", "aliases": ["fal-ai"], }, "replicate": { "module": "backend_replicate", "tier": "experimental", "label": "Replicate", "default_model": "black-forest-labs/flux-1.1-pro", "key_hint": "REPLICATE_API_TOKEN / REPLICATE_API_KEY", }, "openrouter": { "module": "backend_openrouter", "tier": "experimental", "label": "OpenRouter", "default_model": "google/gemini-3.1-flash-image-preview", "key_hint": "OPENROUTER_API_KEY", }, } TIER_ORDER = {"core": 0, "extended": 1, "experimental": 2} SUPPORTED_BACKENDS = tuple(sorted(BACKEND_REGISTRY)) def _load_image_env_file() -> None: """ Load image generation config from the resolved `.env` as a fallback layer. Existing process environment variables win over `.env`. """ replacements = { "IMAGE_API_KEY": "GEMINI_API_KEY / OPENAI_API_KEY / QWEN_API_KEY / ZHIPU_API_KEY / ...", "IMAGE_MODEL": "GEMINI_MODEL / OPENAI_MODEL / QWEN_MODEL / ZHIPU_MODEL / ...", "IMAGE_BASE_URL": "GEMINI_BASE_URL / OPENAI_BASE_URL / QWEN_BASE_URL / ZHIPU_BASE_URL / ...", } deprecated_messages = { key: ( "Global image config keys have been removed.\n" f"Use IMAGE_BACKEND plus provider-specific keys instead, such as {replacement}." ) for key, replacement in replacements.items() } load_prefixed_env_file(IMAGE_ENV_PREFIXES, deprecated_keys=deprecated_messages) def _validate_runtime_config() -> None: """Reject deprecated global image variables from any configuration source.""" for key in DEPRECATED_IMAGE_KEYS: if key not in os.environ: continue replacement = { "IMAGE_API_KEY": "GEMINI_API_KEY / OPENAI_API_KEY / QWEN_API_KEY / ZHIPU_API_KEY / ...", "IMAGE_MODEL": "GEMINI_MODEL / OPENAI_MODEL / QWEN_MODEL / ZHIPU_MODEL / ...", "IMAGE_BASE_URL": "GEMINI_BASE_URL / OPENAI_BASE_URL / QWEN_BASE_URL / ZHIPU_BASE_URL / ...", }[key] raise ValueError( f"Unsupported image config key: {key}\n" "Global image config keys have been removed.\n" f"Use IMAGE_BACKEND plus provider-specific keys instead, such as {replacement}." ) def _build_backend_aliases() -> dict[str, str]: """Build a lookup from aliases to canonical backend names.""" aliases = {} for canonical_name, config in BACKEND_REGISTRY.items(): aliases[canonical_name] = canonical_name for alias in config.get("aliases", []): aliases[alias] = canonical_name return aliases BACKEND_ALIASES = _build_backend_aliases() _BACKEND_PIP_HINTS = { "gemini": "google-genai", "openai": "openai", } def _load_backend(canonical_name: str) -> tuple[object, str]: """Import and return the configured backend module.""" module_name = f"image_backends.{BACKEND_REGISTRY[canonical_name]['module']}" try: module = __import__(module_name, fromlist=["*"]) except ImportError as exc: pip_name = _BACKEND_PIP_HINTS.get(canonical_name, exc.name or "") print( f"Error: backend '{canonical_name}' needs a package that is not installed.\n" f"Missing: {exc.name}\n" f"Run: pip install {pip_name}", file=sys.stderr, ) sys.exit(1) return module, canonical_name def _print_backend_list() -> None: """Print supported backends grouped by support tier.""" print("Supported image backends:\n") tiers = ("core", "extended", "experimental") for tier in tiers: print(f"{tier.upper()}:") for name, info in sorted( BACKEND_REGISTRY.items(), key=lambda item: (TIER_ORDER[item[1]["tier"]], item[0]), ): if info["tier"] != tier: continue print( f" {name:<12} {info['label']} | default={info['default_model']} | keys={info['key_hint']}" ) print() print("Recommendation: prefer CORE backends for everyday PPT generation.") print(f"Config fallback file: {ENV_PATH}") def _resolve_backend() -> tuple[object, str]: """ Determine which backend to use from explicit configuration. Returns: A backend module with a generate() function. """ backend_name = os.environ.get("IMAGE_BACKEND", "").strip().lower() if backend_name: canonical = BACKEND_ALIASES.get(backend_name) if not canonical: supported = ", ".join(SUPPORTED_BACKENDS) print(f"Error: Unknown IMAGE_BACKEND='{backend_name}'. Supported: {supported}") sys.exit(1) return _load_backend(canonical) supported = ", ".join(SUPPORTED_BACKENDS) print( "Error: No image backend configured for Path A (image_gen.py).\n" "\n" "If your host (Codex / Antigravity / Claude Code / etc.) has a native image\n" "generation tool, do NOT run this script — switch to Path B: invoke the host's\n" "image tool directly with the prompts from images/image_prompts.json and save\n" "the outputs to images/. See references/image-generator.md §7 Path B.\n" "\n" "To use Path A instead, set IMAGE_BACKEND in one of these places:\n" f" 1. Current process environment\n" f" 2. {ENV_PATH}\n" "\n" f"Supported backends: {supported}\n" "\n" "Example:\n" " IMAGE_BACKEND=openai\n" " OPENAI_API_KEY=sk-xxx\n" ) sys.exit(1) _AI_IMAGE_PATH_ROW_RE = re.compile( r"^\s*\|\s*AI Image Acquisition Path\s*\|\s*([^|]+?)\s*\|\s*$", re.MULTILINE, ) VALID_AI_IMAGE_ACQUISITION_PATHS = { "api", "auto", "host-native", "manual", } def _project_design_spec_for_manifest(manifest_path: str) -> Path | None: """Return a project Design Spec for an images/ manifest, when present.""" path = Path(manifest_path).resolve() if path.parent.name != "images": return None design_spec = path.parent.parent / "design_spec.md" return design_spec if design_spec.is_file() else None def _confirmed_image_acquisition_path_for_manifest( manifest_path: str, ) -> str | None: """Return the Design Spec's AI Image Acquisition Path, if present.""" design_spec = _project_design_spec_for_manifest(manifest_path) if design_spec is None: return None try: text = design_spec.read_text(encoding="utf-8") except OSError: return None match = _AI_IMAGE_PATH_ROW_RE.search(text) if not match: return None value = match.group(1).strip().lstrip("`*_ ").strip() token_match = re.match( r"^(host[\s_-]*native|api|auto|manual)(?![A-Za-z0-9_-])", value, re.IGNORECASE, ) selected = token_match.group(1) if token_match else value return re.sub(r"[\s_]+", "-", selected.strip().lower()) def _guard_confirmed_non_api_path(manifest_path: str) -> None: """Allow project Path A only when its Design Spec explicitly permits it.""" design_spec = _project_design_spec_for_manifest(manifest_path) if design_spec is None: return acquisition_path = _confirmed_image_acquisition_path_for_manifest(manifest_path) if acquisition_path not in VALID_AI_IMAGE_ACQUISITION_PATHS: shown = acquisition_path or "(missing)" valid = ", ".join(sorted(VALID_AI_IMAGE_ACQUISITION_PATHS)) print( "Error: project manifest mode requires a valid " "AI Image Acquisition Path in design_spec.md §I.\n" f"Found: {shown!r}. Valid values: {valid}.\n" "Return to Generate Step 4 recovery and record the durable " "selection before running Path A." ) sys.exit(1) if acquisition_path in {"api", "auto"}: return if acquisition_path == "host-native": print( "Error: Design Spec confirms AI Image Acquisition Path as 'host-native'.\n" "\n" "Do NOT run image_gen.py --manifest for this project. That command is Path A\n" "and may use the configured API/proxy backend. Use the host's native image\n" "generation tool with prompts from images/image_prompts.json, save outputs to\n" "images/, update each item status to Generated, then run:\n" " python3 scripts/image_gen.py --render-md images/image_prompts.json\n" ) else: print( "Error: Design Spec confirms AI Image Acquisition Path as 'manual'.\n" "\n" "Do NOT run image_gen.py --manifest for this project. Render the Markdown\n" "sidecar and hand images/image_prompts.md to the user for external generation:\n" " python3 scripts/image_gen.py --render-md images/image_prompts.json\n" ) sys.exit(1) DEFAULT_MANIFEST_CONCURRENCY = 3 MAX_MANIFEST_RATE_LIMIT_ATTEMPTS = 3 STATUS_PENDING = "Pending" STATUS_GENERATED = "Generated" STATUS_FAILED = "Failed" STATUS_NEEDS_MANUAL = "Needs-Manual" VALID_STATUSES = {STATUS_PENDING, STATUS_GENERATED, STATUS_FAILED, STATUS_NEEDS_MANUAL} RETRYABLE_STATUSES = {STATUS_PENDING, STATUS_FAILED} REQUIRED_ITEM_FIELDS = ("filename", "prompt", "aspect_ratio", "status") VALID_PAGE_ROLES = {"local", "hero_page", "full_page"} VALID_TEXT_POLICIES = {"none", "embedded"} STRUCTURAL_IMAGE_TYPES = { "infographic", "flowchart", "framework", "matrix", "cycle", "funnel", "pyramid", "comparison", "timeline", "map", "scene", } LEGACY_IMAGE_TYPES = {"background", "hero", "portrait", "typography"} EARLY_LEGACY_IMAGE_TYPES = {"illustration", "photography"} VALID_IMAGE_TYPES = ( STRUCTURAL_IMAGE_TYPES | LEGACY_IMAGE_TYPES | EARLY_LEGACY_IMAGE_TYPES ) def _validate_bare_output_name( value: str, *, field_name: str, require_extension: bool = False, reject_parent_marker: bool = False, ) -> Path: """Require one cross-platform-safe basename, optionally with an extension.""" value_path = Path(value) if ( not value.strip() or value in {".", ".."} or reject_parent_marker and ".." in value or "/" in value or "\\" in value or ":" in value or value_path.is_absolute() or value_path.name != value ): raise ValueError( f"{field_name} must be a bare filename without path components, " f"got {value!r}" ) if require_extension and not value_path.suffix: raise ValueError(f"{field_name} must include an extension, got {value!r}") return value_path def load_manifest(path: str) -> dict: """Load and validate an `image_prompts.json` manifest. Schema (top level): {"items": [ ... ]}, optionally with `deck_rendering`, `color_scheme`, `generated_at`. Each item requires: `filename`, `prompt`, `aspect_ratio`, `status`. Optional: `image_size`, `model`, `alt_text`, `purpose`, `type`, `page_role`, `text_policy`, `slice_grid`, `slice_names`, `last_error`. """ from image_backends.backend_common import normalize_image_size try: data = json.loads(Path(path).read_text(encoding="utf-8")) except json.JSONDecodeError as exc: raise ValueError( f"Invalid JSON in {path}: {exc.msg} " f"(line {exc.lineno}, col {exc.colno})" ) from exc if not isinstance(data, dict): raise ValueError( f"{path}: top level must be a JSON object, " f"got {type(data).__name__}" ) for field in ("project", "generated_at", "deck_rendering"): if field not in data: continue if not isinstance(data[field], str) or not data[field].strip(): raise ValueError( f"{path}: field '{field}' must be a non-empty string when present" ) if "deck_style_anchor" in data: legacy_anchor = data["deck_style_anchor"] if not ( isinstance(legacy_anchor, str) and legacy_anchor.strip() or isinstance(legacy_anchor, dict) and legacy_anchor ): raise ValueError( f"{path}: legacy field 'deck_style_anchor' must be a " "non-empty string or object when present" ) if "color_scheme" in data: color_scheme = data["color_scheme"] if not isinstance(color_scheme, dict) or not color_scheme: raise ValueError( f"{path}: field 'color_scheme' must be a non-empty object when present" ) for key, value in color_scheme.items(): if ( not isinstance(key, str) or not key.strip() or not isinstance(value, str) or not value.strip() ): raise ValueError( f"{path}: color_scheme keys and values must be non-empty strings" ) items = data.get("items") if not isinstance(items, list) or not items: raise ValueError(f"{path}: 'items' must be a non-empty array") claimed_outputs: dict[str, str] = {} seen_stems: set[str] = set() missing_page_role = 0 missing_text_policy = 0 for i, item in enumerate(items): prefix = f"{path}: items[{i}]" if not isinstance(item, dict): raise ValueError(f"{prefix} must be an object") for field in REQUIRED_ITEM_FIELDS: if field not in item: raise ValueError(f"{prefix} missing required field '{field}'") if not isinstance(item[field], str) or not item[field].strip(): raise ValueError( f"{prefix} field '{field}' must be a non-empty string" ) if item["status"] not in VALID_STATUSES: raise ValueError( f"{prefix} status '{item['status']}' is invalid. " f"Valid: {sorted(VALID_STATUSES)}" ) if item["aspect_ratio"] not in ALL_ASPECT_RATIOS: raise ValueError( f"{prefix} aspect_ratio '{item['aspect_ratio']}' is invalid. " f"Valid: {ALL_ASPECT_RATIOS}" ) if "image_size" in item: image_size = item["image_size"] if not isinstance(image_size, str) or not image_size.strip(): raise ValueError( f"{prefix} field 'image_size' must be a non-empty string" ) normalized_size = normalize_image_size(image_size) if normalized_size not in ALL_IMAGE_SIZES: raise ValueError( f"{prefix} image_size '{image_size}' is invalid. " f"Valid: {ALL_IMAGE_SIZES}" ) page_role = item.get("page_role") if page_role is None: missing_page_role += 1 elif not isinstance(page_role, str) or page_role not in VALID_PAGE_ROLES: raise ValueError( f"{prefix} page_role '{page_role}' is invalid. " f"Valid: {sorted(VALID_PAGE_ROLES)}" ) text_policy = item.get("text_policy") if text_policy is None: missing_text_policy += 1 elif ( not isinstance(text_policy, str) or text_policy not in VALID_TEXT_POLICIES ): raise ValueError( f"{prefix} text_policy '{text_policy}' is invalid. " f"Valid: {sorted(VALID_TEXT_POLICIES)}" ) image_type = item.get("type") if image_type is not None: normalized_type = ( image_type.strip().lower() if isinstance(image_type, str) else "" ) if normalized_type not in VALID_IMAGE_TYPES: raise ValueError( f"{prefix} type '{image_type}' is invalid. " f"Valid current/legacy values: {sorted(VALID_IMAGE_TYPES)}" ) for field in ("model", "alt_text", "purpose"): if field in item and ( not isinstance(item[field], str) or not item[field].strip() ): raise ValueError( f"{prefix} field '{field}' must be a non-empty string when present" ) if "last_error" in item and not isinstance(item["last_error"], str): raise ValueError(f"{prefix} field 'last_error' must be a string") has_slice_grid = "slice_grid" in item has_slice_names = "slice_names" in item slice_outputs: list[str] = [] if has_slice_grid != has_slice_names: raise ValueError( f"{prefix} fields 'slice_grid' and 'slice_names' must appear together" ) if has_slice_grid: slice_grid = item["slice_grid"] grid_match = ( re.fullmatch(r"([1-9]\d*)[xX]([1-9]\d*)", slice_grid.strip()) if isinstance(slice_grid, str) else None ) if grid_match is None: raise ValueError( f"{prefix} field 'slice_grid' must use positive RxC notation" ) slice_names = item["slice_names"] if not isinstance(slice_names, str) or not slice_names.strip(): raise ValueError( f"{prefix} field 'slice_names' must be a non-empty string" ) names = [name.strip() for name in slice_names.split(",")] if any(not name for name in names): raise ValueError( f"{prefix} field 'slice_names' contains an empty name" ) rows, cols = map(int, grid_match.groups()) if len(names) != rows * cols: raise ValueError( f"{prefix} field 'slice_names' has {len(names)} names but " f"slice_grid {rows}x{cols} requires {rows * cols}" ) normalized_outputs: set[str] = set() for name in names: name_path = _validate_bare_output_name( name, field_name=f"{prefix} slice output name", reject_parent_marker=True, ) if name_path.suffix and name_path.suffix.lower() != ".png": raise ValueError( f"{prefix} slice output name {name!r} must omit its " "extension or use .png" ) output_name = ( name if name_path.suffix else f"{name}.png" ).casefold() if output_name in normalized_outputs: raise ValueError( f"{prefix} field 'slice_names' repeats output " f"{output_name!r}" ) normalized_outputs.add(output_name) slice_outputs.append(output_name) fname = item["filename"] filename_path = _validate_bare_output_name( fname, field_name=f"{prefix} field 'filename'", require_extension=True, ) normalized_filename = fname.casefold() if normalized_filename in claimed_outputs: raise ValueError( f"{prefix} output filename {fname!r} conflicts with " f"{claimed_outputs[normalized_filename]} (case-insensitive)" ) claimed_outputs[normalized_filename] = f"manifest output {fname!r}" stem = filename_path.stem.casefold() if stem in seen_stems: raise ValueError( f"{prefix} duplicate filename stem '{filename_path.stem}' " "would reuse backend output" ) seen_stems.add(stem) for output_name in slice_outputs: if output_name in claimed_outputs: raise ValueError( f"{prefix} slice output {output_name!r} conflicts with " f"{claimed_outputs[output_name]} (case-insensitive)" ) claimed_outputs[output_name] = ( f"slice output {output_name!r} from items[{i}]" ) legacy_parts = [] if missing_page_role: legacy_parts.append( f"{missing_page_role} item(s) missing page_role (resolved as local)" ) if missing_text_policy: legacy_parts.append( f"{missing_text_policy} item(s) missing text_policy (resolved as none)" ) if legacy_parts: print( f"Warning: {path}: legacy manifest compatibility: " + "; ".join(legacy_parts), file=sys.stderr, ) return data def save_manifest(path: str, data: dict) -> None: """Atomically write manifest back to disk (tmp file + rename).""" target = Path(path) fd, tmp_path = tempfile.mkstemp( prefix=target.stem + ".", suffix=".tmp", dir=str(target.parent), ) try: with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) f.write("\n") os.replace(tmp_path, target) except Exception: try: os.unlink(tmp_path) except OSError: pass raise def _materialize_manifest_image(saved_path: str, target_path: Path) -> str: """Validate backend output and place it at the manifest's exact target path.""" from image_backends.backend_common import ( save_image_bytes, validate_image_file, ) source_path = Path(saved_path) validate_image_file(str(source_path)) if source_path.resolve() != target_path.resolve(): try: image_bytes = source_path.read_bytes() except OSError as exc: raise RuntimeError( f"Could not read image output {source_path}: {exc}" ) from exc save_image_bytes(image_bytes, str(target_path)) validate_image_file(str(target_path)) return str(target_path) def _run_manifest(manifest: dict, manifest_path: str, backend_module, *, initial_concurrency: int, image_size: str, output_dir: str, model: str | None) -> tuple[int, int, int]: """Run Pending/Failed items through the backend with adaptive concurrency. Strategy: - Verify every `Generated` item's target before treating it as done; missing or unreadable output returns to `Failed` for this run. - Start at `initial_concurrency` workers per batch. - On any rate-limit error in a batch, halve concurrency (min 1) and requeue the rate-limited items within a fixed attempt budget. - A rate limit at concurrency 1 or after the budget is exhausted is recorded as `status: Failed` + `last_error`; the current run then stops without switching providers. - Per-item failures are recorded as `status: Failed` + `last_error` and not retried within this run. `Failed` remains retryable and non-terminal; the Step 5 gate must resolve it by rerunning this manifest or marking the item `Needs-Manual`. - Status is written back to the manifest file after each completion; a Ctrl-C in the middle still preserves done items. - `Needs-Manual` items are skipped (user processes them externally). Returns (ok_count, failed_count, skipped_count). """ manifest_output_dir = Path(manifest_path).resolve().parent if Path(output_dir).resolve() != manifest_output_dir: raise ValueError( "Manifest outputs must stay beside image_prompts.json: " f"expected {manifest_output_dir}, got {Path(output_dir).resolve()}" ) output_dir = str(manifest_output_dir) from image_backends.backend_common import ( is_rate_limit_error, validate_image_file, ) items = manifest["items"] repaired_generated = False for item in items: if item["status"] != STATUS_GENERATED: continue target_path = Path(output_dir) / item["filename"] try: validate_image_file(str(target_path)) except RuntimeError as exc: item["status"] = STATUS_FAILED item["last_error"] = ( f"Generated file validation failed: {exc}" )[:500] repaired_generated = True print( f" [RETRY] {item['filename']} was marked Generated but its " f"target is invalid: {exc}" ) if repaired_generated: save_manifest(manifest_path, manifest) pending_idx = [ i for i, it in enumerate(items) if it["status"] in RETRYABLE_STATUSES ] total = len(pending_idx) skipped = len(items) - total if total == 0: print( f"[Manifest] Nothing to do — all {len(items)} items already in " "a terminal state (Generated / Needs-Manual)." ) return 0, 0, skipped print( f"\n[Manifest] {total} item(s) to generate, " f"{skipped} already done. concurrency={initial_concurrency}\n" ) queue: list[int] = list(pending_idx) ok_count = 0 fail_count = 0 current = max(1, initial_concurrency) state_lock = threading.Lock() rate_limit_attempts: dict[int, int] = {} stopped_for_rate_limit = False def _one(idx: int): item = items[idx] try: saved_path = backend_module.generate( prompt=item["prompt"], aspect_ratio=item["aspect_ratio"], image_size=item.get("image_size", image_size), output_dir=output_dir, filename=Path(item["filename"]).stem, model=item.get("model", model), ) saved_path = _materialize_manifest_image( saved_path, Path(output_dir) / item["filename"], ) return idx, saved_path, None except Exception as exc: # noqa: BLE001 — backend raises arbitrary types return idx, None, exc while queue: batch_size = min(current, len(queue)) batch_idx = queue[:batch_size] queue = queue[batch_size:] print( f"--- Batch of {batch_size} (concurrency={current}, " f"remaining_after={len(queue)}) ---" ) rate_limited = False with concurrent.futures.ThreadPoolExecutor(max_workers=batch_size) as ex: futures = [ex.submit(_one, i) for i in batch_idx] for fut in concurrent.futures.as_completed(futures): idx, saved_path, exc = fut.result() item = items[idx] with state_lock: if exc is None: item["status"] = STATUS_GENERATED item.pop("last_error", None) ok_count += 1 print(f" [OK] {item['filename']}") elif is_rate_limit_error(exc): rate_limited = True attempts = rate_limit_attempts.get(idx, 0) + 1 rate_limit_attempts[idx] = attempts if ( current == 1 or attempts >= MAX_MANIFEST_RATE_LIMIT_ATTEMPTS ): boundary = ( "serial concurrency reached" if current == 1 else "rate-limit attempt budget exhausted" ) item["status"] = STATUS_FAILED item["last_error"] = ( f"Rate limit persisted ({boundary}; " f"attempt {attempts}): {exc}" )[:500] fail_count += 1 stopped_for_rate_limit = True print( f" [FAIL] {item['filename']}: {exc} " f"({boundary}; status=Failed)" ) else: queue.append(idx) print( f" [RATE] {item['filename']} — requeued " f"(attempt {attempts}/" f"{MAX_MANIFEST_RATE_LIMIT_ATTEMPTS})" ) else: item["status"] = STATUS_FAILED item["last_error"] = str(exc)[:500] fail_count += 1 print( f" [FAIL] {item['filename']}: {exc} " "(status=Failed; retry or mark Needs-Manual before Executor)" ) save_manifest(manifest_path, manifest) if stopped_for_rate_limit: print( "\n Persistent rate limit reached the run boundary. " "Stopping without switching providers; untouched items remain retryable.\n" ) break if rate_limited and current > 1 and queue: new_current = max(1, current // 2) print( f"\n ⚠ Rate-limit hit — concurrency {current} → {new_current}, " "pausing 10s before next batch\n" ) current = new_current time.sleep(10) elif queue: time.sleep(2) run_state = "Stopped" if stopped_for_rate_limit else "Done" remaining_note = "" if stopped_for_rate_limit: remaining = sum( 1 for item in items if item["status"] in RETRYABLE_STATUSES ) remaining_note = f"; {remaining} item(s) remain retryable" print( f"\n[Manifest] {run_state}: {ok_count} ok / {fail_count} failed " f"({skipped} pre-skipped{remaining_note}). " f"Manifest written to {manifest_path}" ) if fail_count: print( "[Manifest] Failed is retryable and non-terminal. " "Resolve failed item(s) by rerunning this manifest or marking them " "Needs-Manual before entering Executor." ) return ok_count, fail_count, skipped def _resolve_concurrency(cli_value: int | None) -> int: """CLI value wins over IMAGE_CONCURRENCY env; default 3.""" if cli_value is not None: return max(1, cli_value) env_val = os.environ.get("IMAGE_CONCURRENCY", "").strip() if env_val.isdigit(): return max(1, int(env_val)) return DEFAULT_MANIFEST_CONCURRENCY def render_manifest_md(manifest: dict) -> str: """Render a manifest into the paste-ready Markdown view. The output is a read-only snapshot of the JSON manifest, intended as a fallback so a user can copy `Prompt` blocks into ChatGPT / Midjourney when `--manifest` cannot run (no key, no backend, network down). """ lines: list[str] = [] lines.append("# Image Generation Prompts") lines.append("") lines.append("> Auto-generated from `image_prompts.json` by `image_gen.py --render-md`.") lines.append("> Do not hand-edit — re-run the command to refresh.") lines.append("") project = manifest.get("project") generated_at = manifest.get("generated_at") color_scheme = manifest.get("color_scheme") or {} deck_rendering = manifest.get("deck_rendering") if not deck_rendering: legacy_anchor = manifest.get("deck_style_anchor") if isinstance(legacy_anchor, dict): deck_rendering = ( legacy_anchor.get("visual_style") or json.dumps(legacy_anchor, ensure_ascii=False, sort_keys=True) ) else: deck_rendering = legacy_anchor if project: lines.append(f"> Project: {project}") if generated_at: lines.append(f"> Generated: {generated_at}") if color_scheme: cs = " | ".join( f"{k.capitalize()} {v}" for k, v in color_scheme.items() ) lines.append(f"> Color scheme: {cs}") if deck_rendering: lines.append(f"> Deck Rendering: {deck_rendering}") lines.append("") lines.append("---") lines.append("") for i, item in enumerate(manifest["items"], start=1): lines.append(f"### Image {i}: {item['filename']}") lines.append("") lines.append("| Attribute | Value |") lines.append("|---|---|") for label, key in ( ("Purpose", "purpose"), ("Type", "type"), ("Page role", "page_role"), ("Text policy", "text_policy"), ("Aspect ratio", "aspect_ratio"), ("Image size", "image_size"), ("Model", "model"), ("Slice grid", "slice_grid"), ("Slice names", "slice_names"), ("Status", "status"), ): value = item.get(key) if not value and key == "page_role": value = "local (legacy default)" elif not value and key == "text_policy": value = "none (legacy default)" if value: lines.append(f"| {label} | {value} |") if item.get("last_error"): lines.append(f"| Last error | {item['last_error']} |") lines.append("") lines.append("**Prompt**:") lines.append("") lines.append(item["prompt"]) lines.append("") if item.get("alt_text"): lines.append("**Alt Text**:") lines.append(f"> {item['alt_text']}") lines.append("") lines.append("---") lines.append("") return "\n".join(lines).rstrip() + "\n" def render_manifest_md_to_file(manifest_path: str, manifest: dict | None = None) -> str: """Render the manifest's Markdown sidecar next to the JSON file. Returns the written path. If `manifest` is omitted, it is loaded from `manifest_path` first. """ if manifest is None: manifest = load_manifest(manifest_path) md_path = str(Path(manifest_path).with_suffix(".md")) Path(md_path).write_text(render_manifest_md(manifest), encoding="utf-8") return md_path def main() -> None: """Run the CLI entry point.""" parser = argparse.ArgumentParser( description="Generate images using AI image model providers." ) parser.add_argument( "prompt", nargs="?", default=None, help=( "The text prompt for image generation. With --reference-image, " "this is the edit instruction (required in that mode)." ) ) parser.add_argument( "--aspect_ratio", default="1:1", choices=ALL_ASPECT_RATIOS, help=f"Aspect ratio. Default: 1:1." ) parser.add_argument( "--image_size", default="1K", help=f"Image size. Choices: {ALL_IMAGE_SIZES}. Default: 1K. (case-insensitive)" ) parser.add_argument( "--output", "-o", default=None, help="Output directory. Default: current directory." ) parser.add_argument( "--filename", "-f", default=None, help="Output filename (without extension). Overrides auto-naming." ) parser.add_argument( "--model", "-m", default=None, help="Model name. Default depends on backend." ) parser.add_argument( "--backend", "-b", default=None, choices=SUPPORTED_BACKENDS, help="Override IMAGE_BACKEND env var." ) parser.add_argument( "--list-backends", action="store_true", help="List available backends grouped by support tier and exit." ) parser.add_argument( "--manifest", default=None, metavar="IMAGE_PROMPTS_JSON", help=( "Path to image_prompts.json. Runs every Pending/Failed item in " "parallel; writes status back to the manifest as each completes." ), ) parser.add_argument( "--concurrency", type=int, default=None, help=( "Max concurrent requests in --manifest mode. Defaults to " f"IMAGE_CONCURRENCY env or {DEFAULT_MANIFEST_CONCURRENCY}. " "Auto-halves on rate-limit; 1 is the serial fallback." ), ) parser.add_argument( "--render-md", dest="render_md", default=None, metavar="IMAGE_PROMPTS_JSON", help=( "Render 's read-only Markdown sidecar (image_prompts.md) " "next to the manifest, then exit. No backend / network needed." ), ) parser.add_argument( "--reference-image", dest="reference_image", default=None, metavar="PATH", help=( "Source image for image-to-image editing (single-image mode only). " "When set, the prompt is used as the edit instruction. Only backends " "that support editing accept this (currently: openai). Not valid with " "--manifest / --render-md / --list-backends." ), ) args = parser.parse_args() if args.filename is not None: try: _validate_bare_output_name( args.filename, field_name="--filename", ) except ValueError as exc: parser.error(str(exc)) if args.reference_image is not None: # Reference editing is a single-image-only enhancement; keep it out of # the manifest / sidecar / list surfaces entirely. conflicting = [ name for name, val in ( ("--manifest", args.manifest), ("--render-md", args.render_md), ("--list-backends", args.list_backends), ) if val ] if conflicting: parser.error( "--reference-image is single-image mode only and cannot be " f"combined with {', '.join(conflicting)}." ) if not args.prompt or not args.prompt.strip(): parser.error( "--reference-image requires a prompt to use as the edit instruction." ) if not os.path.isfile(args.reference_image): parser.error( f"--reference-image file not found: {args.reference_image}" ) if args.list_backends: _print_backend_list() return if args.render_md: if not os.path.isfile(args.render_md): print(f"Error: manifest file not found: {args.render_md}") sys.exit(1) try: manifest = load_manifest(args.render_md) except ValueError as e: print(f"Error: {e}") sys.exit(1) md_path = render_manifest_md_to_file(args.render_md, manifest) print(f"Rendered Markdown sidecar: {md_path}") return manifest = None manifest_output_dir = None if args.manifest: if not os.path.isfile(args.manifest): print(f"Error: manifest file not found: {args.manifest}") sys.exit(1) _guard_confirmed_non_api_path(args.manifest) try: manifest = load_manifest(args.manifest) except ValueError as e: print(f"Error: {e}") sys.exit(1) manifest_output_dir = Path(args.manifest).resolve().parent if args.output and Path(args.output).resolve() != manifest_output_dir: print( "Error: --output cannot redirect manifest items outside the " f"manifest directory ({manifest_output_dir})" ) sys.exit(1) try: _load_image_env_file() _validate_runtime_config() except ValueError as e: print(f"Error: {e}") sys.exit(1) # CLI --backend overrides the value loaded from .env if args.backend: os.environ["IMAGE_BACKEND"] = args.backend backend, backend_name = _resolve_backend() print(f"Using backend: {backend_name}\n") if args.manifest: concurrency = _resolve_concurrency(args.concurrency) try: _, failed, _ = _run_manifest( manifest, args.manifest, backend, initial_concurrency=concurrency, image_size=args.image_size, output_dir=str(manifest_output_dir), model=args.model, ) except KeyboardInterrupt: print("\n\nInterrupted by user. Partial progress preserved in manifest.") sys.exit(130) md_path = render_manifest_md_to_file(args.manifest, manifest) print(f"Rendered Markdown sidecar: {md_path}") sys.exit(1 if failed else 0) # Single-image mode. Backfill the historical default prompt only here, so # plain generation is byte-for-byte unchanged while edit mode still requires # an explicit instruction (enforced above). prompt = args.prompt if args.prompt is not None else "a beautiful landscape" gen_kwargs = { "prompt": prompt, "aspect_ratio": args.aspect_ratio, "image_size": args.image_size, "output_dir": args.output, "filename": args.filename, "model": args.model, } if args.reference_image is not None: if not getattr(backend, "SUPPORTS_REFERENCE_IMAGE", False): print( f"Error: backend '{backend_name}' does not support image editing " "(--reference-image). Use a backend that does (currently: openai)." ) sys.exit(1) gen_kwargs["reference_image"] = args.reference_image try: backend.generate(**gen_kwargs) except (ValueError, FileNotFoundError) as e: print(f"Error: {e}") sys.exit(1) except RuntimeError as e: print(f"Error: {e}") sys.exit(1) except KeyboardInterrupt: print("\n\nInterrupted by user.") sys.exit(130) if __name__ == "__main__": main()