""" Generate a stage backdrop image from the session `setting` sentence via Hugging Face Inference (text-to-image). The image prompt is built from the setting text (the narrative stage description), plus a short prefix so the result stays wide, puppet-friendly, and low-clutter. Output is a JPEG data URL for use in CSS `background-image: url(...)`. """ from __future__ import annotations import base64 import io import logging import os from typing import Any from puppet_theater.backends import _hf_api_token logger = logging.getLogger(__name__) _DEFAULT_T2I_MODEL = "black-forest-labs/FLUX.1-schnell" _DEFAULT_T2I_TIMEOUT = 90.0 _MAX_SETTING_CHARS = 900 _MAX_OUTPUT_WIDTH = 1152 def setting_backdrop_t2i_enabled() -> bool: """When True, premise flow prefers HF text-to-image from `setting` over stock URL LLM (see session).""" raw = os.getenv("HF_SETTING_BACKDROP_IMAGE", "1").strip().lower() if raw in {"0", "false", "no", "off"}: return False return bool(_hf_api_token()) def build_setting_text_to_image_prompt(setting: str) -> str: """Compose the text-to-image prompt from the show setting (f-string over setting text).""" s = " ".join(setting.strip().split()) if len(s) > _MAX_SETTING_CHARS: s = s[:_MAX_SETTING_CHARS].rsplit(" ", 1)[0].rstrip(",;:") return ( "Wide cinematic 16:9 stage backdrop for puppet theater. " "Soft painterly or photographic look, cohesive mood, gentle depth. " "Keep the center area calm and visually simple so puppets in the foreground stay readable. " "No text, no watermark, no logos, no UI, no subtitles. " f"Scene and atmosphere: {s}" ) def try_setting_backdrop_data_url(setting: str) -> tuple[str | None, dict[str, Any]]: """ Call HF text-to-image with prompt derived from `setting`. Returns (data URL or None, metadata dict). """ meta: dict[str, Any] = {"model": os.getenv("HF_BACKDROP_T2I_MODEL", _DEFAULT_T2I_MODEL).strip() or _DEFAULT_T2I_MODEL} if not setting_backdrop_t2i_enabled(): meta["skipped"] = "disabled_or_no_token" return None, meta token = _hf_api_token() if not token: meta["skipped"] = "no_token" return None, meta prompt = build_setting_text_to_image_prompt(setting) meta["prompt_char_len"] = len(prompt) neg = ( "text, watermark, signature, logo, subtitle, ui, frame, border, " "dense crowd, many faces, extreme clutter, harsh noise" ) timeout = float(os.getenv("HF_BACKDROP_T2I_TIMEOUT", str(_DEFAULT_T2I_TIMEOUT))) timeout = max(15.0, min(timeout, 180.0)) try: from huggingface_hub import InferenceClient from PIL import Image except ImportError as exc: meta["error"] = f"missing_dependency:{exc}" return None, meta client = InferenceClient(token=token, timeout=timeout) model = str(meta["model"]) try: image = client.text_to_image( prompt, negative_prompt=neg, model=model, width=1152, height=648, ) except Exception as first: meta["first_try_error"] = str(first)[:400] try: image = client.text_to_image(prompt, negative_prompt=neg, model=model) except Exception as second: meta["error"] = str(second)[:500] logger.warning("setting_backdrop_t2i: failed model=%r err=%s", model, meta["error"]) return None, meta if not isinstance(image, Image.Image): meta["error"] = "unexpected_image_type" return None, meta image = image.convert("RGB") w, h = image.size if w > _MAX_OUTPUT_WIDTH and w > 0: nh = max(1, int(h * (_MAX_OUTPUT_WIDTH / float(w)))) image = image.resize((_MAX_OUTPUT_WIDTH, nh), Image.Resampling.LANCZOS) buf = io.BytesIO() image.save(buf, format="JPEG", quality=86, optimize=True) raw = buf.getvalue() meta["jpeg_bytes"] = len(raw) b64 = base64.standard_b64encode(raw).decode("ascii") data_url = f"data:image/jpeg;base64,{b64}" meta["ok"] = True logger.info( "setting_backdrop_t2i: ok model=%r jpeg_bytes=%s", model, meta["jpeg_bytes"], ) return data_url, meta def backdrop_url_for_trace(url: str | None) -> str: """Avoid megabyte-long data URLs inside trace JSON.""" if not url: return "" if url.startswith("data:image"): return "data:image/jpeg;base64,...(generated)" return url[:400]