comicx / comic /mock_backend.py
ASTRALK's picture
Upload comic/mock_backend.py with huggingface_hub
ef73896 verified
Raw
History Blame Contribute Delete
5.36 kB
"""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)