"""Offline mock backends — no GPU, no Modal, no cost. They honour the same contracts as the real backends so the whole flow (idea -> bible -> panels -> images -> reader) can be built and tested locally. The mock writer returns valid JSON for BOTH prompt kinds (bible vs panel batch), detected by a marker in the system prompt; the mock artist draws a clearly-placeholder panel. """ from __future__ import annotations import io import json import re from PIL import Image, ImageDraw from .backends import WriterBackend, ArtistBackend from .schema import PAGES, PANELS_PER_PAGE def _is_panel_request(messages: list) -> bool: sys = next((m["content"] for m in messages if m["role"] == "system"), "") return "scripting individual panels" in sys def _user_text(messages: list) -> str: return next((m["content"] for m in reversed(messages) if m["role"] == "user"), "") # Refuse the mock only on an obvious red flag, so the offline path still exercises # the refusal branch when asked. _BLOCK_RE = re.compile(r"\b(child porn|csam|sexual.*(child|minor))\b", re.IGNORECASE) class MockWriter(WriterBackend): """Deterministic JSON for the bible call and the panel-batch calls.""" def chat(self, messages: list) -> str: user = _user_text(messages) if _is_panel_request(messages): return self._panels(user) return self._bible(user) # call #1 def _bible(self, user: str) -> str: if _BLOCK_RE.search(user): return json.dumps({ "approved": False, "refusal_reason": "That request isn't something I can make a comic about.", }) # Pull the reader's request out of the prompt for a touch of flavor. m = re.search(r'"""\s*(.+?)\s*"""', user, re.DOTALL) idea = (m.group(1).strip() if m else "an adventure")[:80] pages = [ {"page": i, "synopsis": f"Page {i}: the tale of {idea} advances toward its end."} for i in range(1, PAGES + 1) ] return json.dumps({ "approved": True, "refusal_reason": "", "title": f"The Saga of {idea.title()}"[:60], "logline": f"A mock comic about {idea}.", "art_style": ("modern western comic book art, bold black ink linework, " "dynamic cel shading"), "palette": "warm saturated comic palette", "characters": [ {"name": "Mara", "appearance": ("a determined young woman, late 20s, short " "auburn hair, green travel cloak over leather armor, a brass compass at her belt")}, {"name": "Finn", "appearance": ("a wiry teenage boy, freckles, messy black hair, " "patched blue tunic, always carrying a worn satchel")}, ], "pages": pages, }) # calls #2..N def _panels(self, user: str) -> str: # The batch's pages are the "Page N:" lines AFTER the "NOW WRITE" marker # (the bible brief above it lists the full synopsis, which we must ignore). tail = user.split("NOW WRITE")[-1] nums = [int(n) for n in re.findall(r"Page (\d+):", tail)] seen, req = set(), [] for n in nums: if n not in seen: seen.add(n) req.append(n) panels = [] for pg in req: for pn in range(1, PANELS_PER_PAGE + 1): panels.append({ "page": pg, "panel": pn, "scene": (f"wide shot, Mara and Finn on page {pg} panel {pn}, " "dramatic lighting, a tense moment in their journey"), "caption": f"Page {pg}, panel {pn}: the journey continues. " f"\"We're close now,\" Mara says.", "characters": ["Mara", "Finn"], }) return json.dumps({"panels": panels}) class MockArtist(ArtistBackend): """A placeholder landscape panel — a tinted gradient with abstract blocks.""" W, H = 1024, 768 def render(self, prompt: str, seed: int = 0) -> bytes: h = (abs(hash((prompt, seed))) & 0xFFFFFFFF) base = _tint(prompt) img = Image.new("RGB", (self.W, self.H), base) d = ImageDraw.Draw(img) for y in range(self.H): f = y / self.H d.line([(0, y), (self.W, y)], fill=tuple(int(c * (1 - 0.4 * f)) for c in base)) rnd = h for _ in range(16): rnd = (rnd * 1103515245 + 12345) & 0x7FFFFFFF x = rnd % self.W rnd = (rnd * 1103515245 + 12345) & 0x7FFFFFFF y = self.H // 2 + rnd % (self.H // 2) rnd = (rnd * 1103515245 + 12345) & 0x7FFFFFFF w = 40 + rnd % 160 shade = tuple(max(0, c - 60) for c in base) d.rectangle([x, y, x + w, y + 50 + rnd % 90], fill=shade) buf = io.BytesIO() img.save(buf, format="JPEG", quality=88) return buf.getvalue() def _tint(prompt: str) -> tuple: p = prompt.lower() if "noir" in p or "dark" in p: return (70, 74, 86) if "warm" in p or "sunset" in p: return (150, 110, 80) if "forest" in p or "green" in p: return (80, 120, 90) return (95, 100, 120)