"""OpenAI DALL-E image generation service for recipes. Downloads generated images to `backend/static/images/` and returns relative paths suitable for `Recipe.image_url`. """ from __future__ import annotations import base64 import hashlib import logging import os import time from pathlib import Path from typing import Optional import httpx from sqlalchemy.orm import Session from app.config import settings from app.models import Recipe logger = logging.getLogger(__name__) # Directory where generated images are persisted. STATIC_IMAGES_DIR = Path(__file__).resolve().parent.parent.parent / "static" / "images" PUBLIC_URL_PREFIX = "/static/images/" def _build_prompt(recipe: Recipe) -> str: """Create a vivid, appetising DALL-E prompt from a recipe.""" name = recipe.name or "A delicious dish" ingredients = recipe.ingredients or [] ingredient_names = [str(ing.get("name", "")) for ing in ingredients if ing.get("name")] ingredient_str = ", ".join(ingredient_names[:6]) if ingredient_names else "fresh ingredients" tags = recipe.cuisine_tags or [] tag_str = f", {', '.join(tags)} style" if tags else "" return ( f"Professional food photography of {name}{tag_str}. " f"Served on a clean white ceramic plate with natural lighting, " f"shallow depth of field. Vibrant colours, appetising. " f"Ingredients visible: {ingredient_str}. " f"No text, no watermark." ) def _sanitise_filename(recipe_name: str) -> str: """Return a filesystem-safe lowercase slug.""" keep = recipe_name.lower() for ch in " /'\"?!:\\|;#": keep = keep.replace(ch, "_") return keep.strip("_") def _openai_api_key() -> Optional[str]: if settings.AI_IMAGE_PROVIDER and settings.AI_IMAGE_PROVIDER.lower() != "openai": return None return settings.AI_IMAGE_API_KEY or os.environ.get("OPENAI_API_KEY") def generate_image_for_recipe( recipe: Recipe, db: Optional[Session] = None, size: str = "1024x1024", quality: str = "standard", # or "hd" force: bool = False, ) -> Optional[str]: """Generate an image for *recipe* using DALL-E 3. Returns the public static path (e.g. ``/static/images/caprese_pasta_a1b2.png``) or ``None`` if generation is disabled / fails. If *force* is ``False`` and the recipe already has an ``image_url`` that looks like a local static path, the existing image is returned immediately. """ if not settings.AI_IMAGE_ENABLED: logger.info("AI_IMAGE_ENABLED=false; skipping image generation for %s", recipe.name) return None api_key = _openai_api_key() if not api_key: logger.warning("No OpenAI API key configured; skipping image generation.") return None slug = _sanitise_filename(recipe.name) digest_id = hashlib.sha256(str(recipe.id).encode()).hexdigest()[:8] out_name = f"{slug}_{digest_id}.png" out_path = STATIC_IMAGES_DIR / out_name # Reuse existing image unless forced. if not force and out_path.exists(): logger.debug("Image already exists for %s", recipe.name) return f"{PUBLIC_URL_PREFIX}{out_name}" if not force and recipe.image_url and recipe.image_url.startswith("/static/images/"): return recipe.image_url prompt = _build_prompt(recipe) model = os.environ.get("AI_IMAGE_MODEL", "gpt-image-1") logger.info("Generating image for '%s' via %s …", recipe.name, model) try: payload = { "model": model, "prompt": prompt, "size": size, "n": 1, } # gpt-image-* uses low/medium/high/auto; dall-e uses standard/hd if not model.startswith("gpt-image"): payload["quality"] = quality else: # Map dall-e quality names to gpt-image quality names quality_map = {"standard": "medium", "hd": "high"} payload["quality"] = quality_map.get(quality, "auto") resp = httpx.post( "https://api.openai.com/v1/images/generations", headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, json=payload, timeout=120.0, ) except httpx.HTTPError as exc: logger.error("Network error generating image for '%s': %s", recipe.name, exc) return None if resp.status_code != 200: logger.error( "DALL-E API error for '%s': HTTP %s – %s", recipe.name, resp.status_code, resp.text, ) return None try: data = resp.json()["data"][0] if "b64_json" in data: image_bytes = base64.b64decode(data["b64_json"]) elif "url" in data: image_url = data["url"] img_resp = httpx.get(image_url, timeout=30.0) if img_resp.status_code != 200: logger.error("Failed downloading image for '%s': HTTP %s", recipe.name, img_resp.status_code) return None image_bytes = img_resp.content else: raise KeyError("no image data (url or b64_json)") except (KeyError, IndexError) as exc: logger.error("Unexpected DALL-E response for '%s': %s", recipe.name, exc) return None STATIC_IMAGES_DIR.mkdir(parents=True, exist_ok=True) out_path.write_bytes(image_bytes) public_path = f"{PUBLIC_URL_PREFIX}{out_name}" # Persist on recipe row if a DB session was provided. if db is not None: recipe.image_url = public_path db.add(recipe) db.commit() logger.info("Image saved for '%s' -> %s", recipe.name, public_path) return public_path def generate_images_batch( db: Session, recipe_ids: Optional[list[str]] = None, missing_only: bool = False, force: bool = False, limit: int = 10, ) -> dict: """Batch-generate images for recipes. Returns a dict:: { "total": , "succeeded": , "failed": , "skipped": , "results": {"recipe_id": "image_url | null", …}, } """ from sqlalchemy import or_ query = db.query(Recipe) if recipe_ids: query = query.filter(Recipe.id.in_(recipe_ids)) if missing_only: query = query.filter( or_( Recipe.image_url.is_(None), Recipe.image_url == "", ) ) recipes = query.limit(limit).all() total = len(recipes) succeeded = 0 failed = 0 skipped = 0 results = {} for recipe in recipes: recipe_id = str(recipe.id) # Skip if already has local image unless forced. if not force and recipe.image_url and recipe.image_url.startswith("/static/images/"): skipped += 1 results[recipe_id] = {"status": "skipped", "url": recipe.image_url} continue url = generate_image_for_recipe(recipe, db=db, force=force) if url: succeeded += 1 results[recipe_id] = {"status": "ok", "url": url} else: failed += 1 results[recipe_id] = {"status": "failed", "url": None} time.sleep(2) # rate-limiting courtesy between requests return { "total": total, "succeeded": succeeded, "failed": failed, "skipped": skipped, "results": results, }