Public Access
- api/meal_plans.py: /regenerate now passes exclude_recipe_ids into generate_meal_plan - planner/generate.py: filter recipe_dicts by exclude_recipe_ids set - image_generation.py: OpenAI gpt-image-1 client with prompt building, b64_json handling - main.py: StaticFiles mount at /static for generated images - admin.py: POST /api/admin/trigger-images endpoint for batch generation - scripts/generate_images.py: CLI for batch image generation - docker-compose.yml + nginx: volume mounts for static/images persistence - Verify MealPlanItem.votes ↔ MealPlanVote relationship is correct; no model bug exists
232 lines
7.3 KiB
Python
232 lines
7.3 KiB
Python
"""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": <int>,
|
||
"succeeded": <int>,
|
||
"failed": <int>,
|
||
"skipped": <int>,
|
||
"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,
|
||
}
|