Spaces:
Running on Zero
Running on Zero
| """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) | |