#!/usr/bin/env python3 """ OpenAI Compatible Image Generation Backend Generates images via OpenAI-compatible APIs (OpenAI, local models like Qwen-Image, etc.). Used by image_gen.py as a backend module. Configuration keys: OPENAI_API_KEY (required) API key OPENAI_BASE_URL (optional) Custom API endpoint (e.g. http://127.0.0.1:3000/v1) OPENAI_MODEL (optional) Model name (default: gpt-image-2) OPENAI_SIZE_PRESET (optional) auto, legacy, gpt-image, gpt-image-2, or dall-e-2 OPENAI_RESPONSE_FORMAT (optional) auto, b64_json, url, or omit OPENAI_QUALITY (optional) auto, omit, low, medium, high, standard, or hd OPENAI_OUTPUT_FORMAT (optional) png, jpeg, or webp for GPT image models OPENAI_OUTPUT_COMPRESSION (optional) 0-100, only for jpeg/webp GPT image output OPENAI_BACKGROUND (optional) auto or opaque for gpt-image-2 OPENAI_MODERATION (optional) auto or low for GPT image models Image editing (image-to-image): When image_gen.py passes reference_image= (single-image CLI only, via --reference-image), this backend calls /v1/images/edits with the source image + the prompt as an edit instruction, instead of /v1/images/generations. Dependencies: pip install requests Pillow """ import sys from pathlib import Path _SCRIPTS_DIR = Path(__file__).resolve().parents[1] if str(_SCRIPTS_DIR) not in sys.path: sys.path.insert(0, str(_SCRIPTS_DIR)) from console_encoding import configure_utf8_stdio # noqa: E402 configure_utf8_stdio() if __name__ == "__main__": print(__doc__) print("Use via: python3 skills/ppt-master/scripts/image_gen.py \"prompt\" --backend openai") raise SystemExit(0 if any(arg in {"-h", "--help", "help"} for arg in sys.argv[1:]) else 1) import base64 import mimetypes import os import time import threading from collections.abc import Mapping import requests from image_backends.backend_common import ( MAX_RETRIES, download_image, http_error, is_rate_limit_error, normalize_image_size, resolve_output_path, retry_delay, save_image_bytes, ) # ╔══════════════════════════════════════════════════════════════════╗ # ║ Constants ║ # ╚══════════════════════════════════════════════════════════════════╝ # Aspect ratio -> DALL-E 3 / legacy compatible size mapping. # Unknown OpenAI-compatible models use this table to preserve old behavior. LEGACY_COMPAT_ASPECT_RATIO_TO_SIZE = { "1:1": "1024x1024", "16:9": "1792x1024", "9:16": "1024x1792", "3:2": "1536x1024", "2:3": "1024x1536", "4:3": "1536x1024", # closest available "3:4": "1024x1536", # closest available "4:5": "1024x1024", # fallback to square "5:4": "1024x1024", # fallback to square "21:9": "1792x1024", # closest wide format } # GPT Image 1/1.5/mini officially support only square, landscape, portrait, or auto. GPT_IMAGE_LEGACY_ASPECT_RATIO_TO_SIZE = { "1:1": "1024x1024", "16:9": "1536x1024", "9:16": "1024x1536", "3:2": "1536x1024", "2:3": "1024x1536", "4:3": "1536x1024", "3:4": "1024x1536", "4:5": "1024x1536", "5:4": "1536x1024", "21:9": "1536x1024", } # GPT Image 2 supports flexible sizes when both edges are multiples of 16, # the edge ratio is <= 3:1, and the total pixels are within model limits. GPT_IMAGE_2_SIZES = { "512px": { "1:1": "1024x1024", "16:9": "1280x720", "9:16": "720x1280", "3:2": "1248x832", "2:3": "832x1248", "4:3": "1024x768", "3:4": "768x1024", "4:5": "896x1120", "5:4": "1120x896", "21:9": "1280x544", }, "1K": { "1:1": "1024x1024", "16:9": "1280x720", "9:16": "720x1280", "3:2": "1248x832", "2:3": "832x1248", "4:3": "1024x768", "3:4": "768x1024", "4:5": "896x1120", "5:4": "1120x896", "21:9": "1280x544", }, "2K": { "1:1": "2048x2048", "16:9": "2048x1152", "9:16": "1152x2048", "3:2": "2016x1344", "2:3": "1344x2016", "4:3": "1920x1440", "3:4": "1440x1920", "4:5": "1600x2000", "5:4": "2000x1600", "21:9": "2560x1088", }, "4K": { "1:1": "2880x2880", "16:9": "3840x2160", "9:16": "2160x3840", "3:2": "3520x2352", "2:3": "2352x3520", "4:3": "3264x2448", "3:4": "2448x3264", "4:5": "2560x3200", "5:4": "3200x2560", "21:9": "3840x1648", }, } DALL_E_2_SIZE_BY_IMAGE_SIZE = { "512px": "512x512", "1K": "1024x1024", "2K": "1024x1024", "4K": "1024x1024", } VALID_ASPECT_RATIOS = list(LEGACY_COMPAT_ASPECT_RATIO_TO_SIZE.keys()) # image_size -> quality mapping IMAGE_SIZE_TO_QUALITY = { "512px": "low", "1K": "medium", "2K": "high", "4K": "high", } DEFAULT_MODEL = "gpt-image-2" GPT_IMAGE_2_MIN_PIXELS = 655_360 GPT_IMAGE_2_MAX_PIXELS = 8_294_400 GPT_IMAGE_2_MAX_EDGE = 3840 GPT_IMAGE_2_MAX_RATIO = 3 GPT_IMAGE_OUTPUT_FORMATS = {"png", "jpeg", "webp"} GPT_IMAGE_OUTPUT_EXTENSIONS = { "png": ".png", "jpeg": ".jpg", "webp": ".webp", } OPENAI_SIZE_PRESETS = { "auto", "legacy", "gpt-image", "gpt-image-legacy", "gpt-image-2", "dall-e-2", "dalle-2", } OPENAI_RESPONSE_FORMATS = {"auto", "b64_json", "url", "omit"} OPENAI_QUALITY_VALUES = { "auto", "omit", "low", "medium", "high", "standard", "hd", } GPT_IMAGE_BACKGROUNDS = {"auto", "opaque", "transparent"} GPT_IMAGE_MODERATION_VALUES = {"auto", "low"} DEFAULT_BASE_URL = "https://api.openai.com/v1" # Signals to image_gen.py that this backend can accept a reference_image # (image-to-image edit via /v1/images/edits). Other backends omit this marker. SUPPORTS_REFERENCE_IMAGE = True def _field(value, name: str): """Read a response field from either an SDK object or a dict.""" if isinstance(value, Mapping): return value.get(name) return getattr(value, name, None) def _normalized_model(model: str) -> str: return (model or "").strip().lower() def _is_gpt_image_model(model: str) -> bool: return _normalized_model(model).startswith("gpt-image-") def _is_gpt_image_2(model: str) -> bool: return _normalized_model(model).startswith("gpt-image-2") def _is_dall_e_2(model: str) -> bool: return _normalized_model(model) == "dall-e-2" def _parse_size(size: str) -> tuple[int, int]: try: width_s, height_s = size.lower().split("x", 1) return int(width_s), int(height_s) except Exception as exc: raise ValueError(f"Invalid image size '{size}'. Expected WIDTHxHEIGHT.") from exc def _validate_gpt_image_2_size(size: str) -> None: width, height = _parse_size(size) total_pixels = width * height long_edge = max(width, height) short_edge = min(width, height) errors = [] if long_edge > GPT_IMAGE_2_MAX_EDGE: errors.append(f"max edge {long_edge}px exceeds {GPT_IMAGE_2_MAX_EDGE}px") if width % 16 != 0 or height % 16 != 0: errors.append("both edges must be multiples of 16px") if long_edge / short_edge > GPT_IMAGE_2_MAX_RATIO: errors.append("long:short edge ratio must not exceed 3:1") if not (GPT_IMAGE_2_MIN_PIXELS <= total_pixels <= GPT_IMAGE_2_MAX_PIXELS): errors.append( f"total pixels {total_pixels:,} must be between " f"{GPT_IMAGE_2_MIN_PIXELS:,} and {GPT_IMAGE_2_MAX_PIXELS:,}" ) if errors: raise ValueError(f"Invalid gpt-image-2 size '{size}': {', '.join(errors)}") def _select_size( model: str, aspect_ratio: str, image_size: str, size_preset: str | None = None, ) -> str: """Select a model-compatible size while preserving legacy fallbacks.""" preset = size_preset or "auto" if preset in {"gpt-image-2"} or (preset == "auto" and _is_gpt_image_2(model)): size = GPT_IMAGE_2_SIZES[image_size][aspect_ratio] _validate_gpt_image_2_size(size) return size if preset in {"gpt-image", "gpt-image-legacy"} or ( preset == "auto" and _is_gpt_image_model(model) ): return GPT_IMAGE_LEGACY_ASPECT_RATIO_TO_SIZE[aspect_ratio] if preset in {"dall-e-2", "dalle-2"} or (preset == "auto" and _is_dall_e_2(model)): return DALL_E_2_SIZE_BY_IMAGE_SIZE[image_size] return LEGACY_COMPAT_ASPECT_RATIO_TO_SIZE[aspect_ratio] def _supports_response_format(model: str) -> bool: """GPT Image models always return base64; DALL-E/compatible models may need this.""" return not _is_gpt_image_model(model) def _read_env_choice(name: str, allowed: set[str]) -> str | None: value = os.environ.get(name) if value is None or not value.strip(): return None normalized = value.strip().lower() if normalized not in allowed: allowed_list = ", ".join(sorted(allowed)) raise ValueError(f"Invalid {name}='{value}'. Supported: {allowed_list}") return normalized def _read_env_int(name: str, minimum: int, maximum: int) -> int | None: value = os.environ.get(name) if value is None or not value.strip(): return None try: parsed = int(value) except ValueError as exc: raise ValueError(f"Invalid {name}='{value}'. Expected integer {minimum}-{maximum}.") from exc if not (minimum <= parsed <= maximum): raise ValueError(f"Invalid {name}={parsed}. Expected integer {minimum}-{maximum}.") return parsed def _gpt_image_options(model: str) -> tuple[dict, str]: """Read optional GPT Image request parameters from environment.""" output_format = _read_env_choice("OPENAI_OUTPUT_FORMAT", GPT_IMAGE_OUTPUT_FORMATS) output_ext = GPT_IMAGE_OUTPUT_EXTENSIONS[output_format] if output_format else ".png" options = {} if output_format: options["output_format"] = output_format output_compression = _read_env_int("OPENAI_OUTPUT_COMPRESSION", 0, 100) if output_compression is not None: if output_format not in {"jpeg", "webp"}: raise ValueError( "OPENAI_OUTPUT_COMPRESSION is only supported when " "OPENAI_OUTPUT_FORMAT is jpeg or webp." ) options["output_compression"] = output_compression background = _read_env_choice("OPENAI_BACKGROUND", GPT_IMAGE_BACKGROUNDS) if background: if _is_gpt_image_2(model) and background == "transparent": raise ValueError("gpt-image-2 does not support OPENAI_BACKGROUND=transparent.") options["background"] = background moderation = _read_env_choice("OPENAI_MODERATION", GPT_IMAGE_MODERATION_VALUES) if moderation: options["moderation"] = moderation return options, output_ext def _image_generations_url(base_url: str | None) -> str: base = (base_url or DEFAULT_BASE_URL).rstrip("/") if base.endswith("/images/generations"): return base return f"{base}/images/generations" def _image_edits_url(base_url: str | None) -> str: base = (base_url or DEFAULT_BASE_URL).rstrip("/") if base.endswith("/images/edits"): return base if base.endswith("/images/generations"): # Swap the sibling endpoint rather than appending to a full URL. return base[: -len("/generations")] + "/edits" return f"{base}/images/edits" def _read_size_preset() -> str | None: """Read the optional size mapping preset for OpenAI-compatible providers.""" return _read_env_choice("OPENAI_SIZE_PRESET", OPENAI_SIZE_PRESETS) def _read_response_format() -> str | None: """Read the optional response_format override.""" return _read_env_choice("OPENAI_RESPONSE_FORMAT", OPENAI_RESPONSE_FORMATS) def _read_quality(image_size: str) -> str | None: """Resolve the quality field for OpenAI-compatible requests.""" quality = _read_env_choice("OPENAI_QUALITY", OPENAI_QUALITY_VALUES) if quality == "omit": return None if quality and quality != "auto": return quality return IMAGE_SIZE_TO_QUALITY.get(image_size, "auto") def _apply_response_format(request: dict, model: str) -> None: """Apply response_format while preserving the existing default behavior.""" response_format = _read_response_format() if response_format == "omit": return if response_format in {"b64_json", "url"}: request["response_format"] = response_format return if _supports_response_format(model): request["response_format"] = "b64_json" def _post_image_generation(api_key: str, base_url: str | None, request: dict) -> dict: headers = { "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", } response = requests.post( _image_generations_url(base_url), headers=headers, json=request, timeout=300, ) if not response.ok: raise http_error(response, "OpenAI image generation") try: return response.json() except ValueError as exc: raise RuntimeError("OpenAI image generation returned invalid JSON.") from exc def _post_image_edit(api_key: str, base_url: str | None, data: dict, image_path: str) -> dict: headers = {"Authorization": f"Bearer {api_key}"} # GPT Image models take the image list field 'image[]'; dall-e-2 and other # OpenAI-compatible edit models use the singular 'image'. model = str(data.get("model", "")) field = "image[]" if _is_gpt_image_model(model) else "image" mime_type = mimetypes.guess_type(image_path)[0] or "application/octet-stream" # Let requests build the multipart/form-data body (and its Content-Type # boundary) from files=; do not set Content-Type by hand. with open(image_path, "rb") as image_file: response = requests.post( _image_edits_url(base_url), headers=headers, data=data, files=[(field, (Path(image_path).name, image_file, mime_type))], timeout=300, ) if not response.ok: raise http_error(response, "OpenAI image edit") try: return response.json() except ValueError as exc: raise RuntimeError("OpenAI image edit returned invalid JSON.") from exc # ╔══════════════════════════════════════════════════════════════════╗ # ║ Image Generation ║ # ╚══════════════════════════════════════════════════════════════════╝ def _generate_image(api_key: str, prompt: str, aspect_ratio: str = "1:1", image_size: str = "1K", output_dir: str = None, filename: str = None, model: str = DEFAULT_MODEL, base_url: str = None) -> str: """ Image generation via OpenAI-compatible API. Maps aspect_ratio to OpenAI's size parameter, and image_size to quality. Returns: Path of the saved image file Raises: RuntimeError: When generation fails """ # Map parameters size_preset = _read_size_preset() size = _select_size(model, aspect_ratio, image_size, size_preset) quality = _read_quality(image_size) output_ext = ".png" request = { "prompt": prompt, "model": model, "size": size, "n": 1, } if quality is not None: request["quality"] = quality if _is_gpt_image_model(model): gpt_options, output_ext = _gpt_image_options(model) request.update(gpt_options) _apply_response_format(request, model) mode_label = f"Proxy: {base_url}" if base_url else "OpenAI API" print(f"[OpenAI - {mode_label}]") print(f" Model: {model}") print(f" Prompt: {prompt[:120]}{'...' if len(prompt) > 120 else ''}") print(f" Size: {size} (from aspect_ratio={aspect_ratio})") if size_preset and size_preset != "auto": print(f" Size Preset: {size_preset}") if quality is not None: print(f" Quality: {quality} (from image_size={image_size})") else: print(" Quality: omitted") if request.get("response_format"): print(f" Response: {request['response_format']}") elif _read_response_format() == "omit": print(" Response: omitted") if request.get("output_format"): print(f" Format: {request['output_format']}") if request.get("output_compression") is not None: print(f" Compression: {request['output_compression']}") if request.get("background"): print(f" Background: {request['background']}") if request.get("moderation"): print(f" Moderation: {request['moderation']}") print() start_time = time.time() print(f" [..] Generating...", end="", flush=True) # Heartbeat thread heartbeat_stop = threading.Event() def _heartbeat(): while not heartbeat_stop.is_set(): heartbeat_stop.wait(5) if not heartbeat_stop.is_set(): elapsed = time.time() - start_time print(f" {elapsed:.0f}s...", end="", flush=True) hb_thread = threading.Thread(target=_heartbeat, daemon=True) hb_thread.start() try: resp = _post_image_generation(api_key, base_url, request) finally: heartbeat_stop.set() hb_thread.join(timeout=1) elapsed = time.time() - start_time print(f"\n [DONE] Image generated ({elapsed:.1f}s)") data = _field(resp, "data") if resp is not None else None if data: path = resolve_output_path(prompt, output_dir, filename, output_ext) first_image = data[0] b64_json = _field(first_image, "b64_json") image_url = _field(first_image, "url") if b64_json: image_data = base64.b64decode(b64_json) return save_image_bytes(image_data, path) if image_url: return download_image(image_url, path) raise RuntimeError("No image was generated. The server may have refused the request.") def _edit_image(api_key: str, prompt: str, reference_image: str, aspect_ratio: str = "1:1", image_size: str = "1K", output_dir: str = None, filename: str = None, model: str = DEFAULT_MODEL, base_url: str = None) -> str: """ Image-to-image edit via the OpenAI-compatible /v1/images/edits endpoint. Sends the reference image plus the prompt (used as the edit instruction). Mirrors _generate_image's size/quality/format handling but posts multipart/form-data instead of JSON. Returns: Path of the saved image file """ size_preset = _read_size_preset() size = _select_size(model, aspect_ratio, image_size, size_preset) quality = None if _is_dall_e_2(model) else _read_quality(image_size) output_ext = ".png" request = { "prompt": prompt, "model": model, "size": size, "n": 1, } if quality is not None: request["quality"] = quality if _is_gpt_image_model(model): gpt_options, output_ext = _gpt_image_options(model) request.update(gpt_options) _apply_response_format(request, model) mode_label = f"Proxy: {base_url}" if base_url else "OpenAI API" print(f"[OpenAI - {mode_label}]") print(f" Mode: edit (image-to-image)") print(f" Model: {model}") print(f" Reference: {reference_image}") print(f" Prompt: {prompt[:120]}{'...' if len(prompt) > 120 else ''}") print(f" Size: {size} (from aspect_ratio={aspect_ratio})") if size_preset and size_preset != "auto": print(f" Size Preset: {size_preset}") if quality is not None: print(f" Quality: {quality} (from image_size={image_size})") else: print(" Quality: omitted") if request.get("response_format"): print(f" Response: {request['response_format']}") elif _read_response_format() == "omit": print(" Response: omitted") if request.get("output_format"): print(f" Format: {request['output_format']}") if request.get("output_compression") is not None: print(f" Compression: {request['output_compression']}") if request.get("background"): print(f" Background: {request['background']}") if request.get("moderation"): print(f" Moderation: {request['moderation']}") print() start_time = time.time() print(f" [..] Editing...", end="", flush=True) heartbeat_stop = threading.Event() def _heartbeat(): while not heartbeat_stop.is_set(): heartbeat_stop.wait(5) if not heartbeat_stop.is_set(): elapsed = time.time() - start_time print(f" {elapsed:.0f}s...", end="", flush=True) hb_thread = threading.Thread(target=_heartbeat, daemon=True) hb_thread.start() try: resp = _post_image_edit(api_key, base_url, request, reference_image) finally: heartbeat_stop.set() hb_thread.join(timeout=1) elapsed = time.time() - start_time print(f"\n [DONE] Image edited ({elapsed:.1f}s)") data = _field(resp, "data") if resp is not None else None if data: path = resolve_output_path(prompt, output_dir, filename, output_ext) first_image = data[0] b64_json = _field(first_image, "b64_json") image_url = _field(first_image, "url") if b64_json: image_data = base64.b64decode(b64_json) return save_image_bytes(image_data, path) if image_url: return download_image(image_url, path) raise RuntimeError("No image was returned. The server may have refused the edit request.") # ╔══════════════════════════════════════════════════════════════════╗ # ║ Public Entry Point ║ # ╚══════════════════════════════════════════════════════════════════╝ def generate(prompt: str, aspect_ratio: str = "1:1", image_size: str = "1K", output_dir: str = None, filename: str = None, model: str = None, max_retries: int = MAX_RETRIES, reference_image: str = None) -> str: """ OpenAI-compatible image generation (or image-to-image edit) with retry. Reads credentials from the current process environment or a `.env` file: OPENAI_API_KEY OPENAI_BASE_URL OPENAI_MODEL (optional override) Args: prompt: Prompt text (edit instruction when reference_image is set) aspect_ratio: Aspect ratio, mapped to OpenAI size image_size: Image size, mapped to OpenAI quality output_dir: Output directory filename: Output filename (without extension) model: Model name (default: gpt-image-2) max_retries: Maximum number of retries reference_image: Optional source image path. When set, the request goes to /v1/images/edits (image-to-image) instead of /v1/images/generations. Returns: Path of the saved image file """ api_key = os.environ.get("OPENAI_API_KEY") base_url = os.environ.get("OPENAI_BASE_URL") if not api_key: raise ValueError( "No API key found. Set OPENAI_API_KEY in the current environment or a .env file." ) if model is None: model = os.environ.get("OPENAI_MODEL") or DEFAULT_MODEL image_size = normalize_image_size(image_size) if aspect_ratio not in LEGACY_COMPAT_ASPECT_RATIO_TO_SIZE: supported = list(LEGACY_COMPAT_ASPECT_RATIO_TO_SIZE.keys()) raise ValueError( f"Unsupported aspect ratio '{aspect_ratio}' for OpenAI backend. " f"Supported: {supported}" ) last_error = None for attempt in range(max_retries + 1): try: if reference_image is not None: return _edit_image(api_key, prompt, reference_image, aspect_ratio, image_size, output_dir, filename, model, base_url) return _generate_image(api_key, prompt, aspect_ratio, image_size, output_dir, filename, model, base_url) except Exception as e: last_error = e if attempt < max_retries and is_rate_limit_error(e): delay = retry_delay(attempt, rate_limited=True) print(f"\n [WARN] Rate limit hit (attempt {attempt + 1}/{max_retries + 1}). " f"Waiting {delay}s before retry...") time.sleep(delay) elif attempt < max_retries: delay = retry_delay(attempt, rate_limited=False) print(f"\n [WARN] Error (attempt {attempt + 1}/{max_retries + 1}): {e}. " f"Retrying in {delay}s...") time.sleep(delay) else: break raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")