Upload comic/mock_backend.py with huggingface_hub
Browse files- comic/mock_backend.py +139 -0
comic/mock_backend.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Offline mock backends — no GPU, no Modal, no cost.
|
| 2 |
+
|
| 3 |
+
They honour the same contracts as the real backends so the whole flow (idea ->
|
| 4 |
+
bible -> panels -> images -> reader) can be built and tested locally. The mock
|
| 5 |
+
writer returns valid JSON for BOTH prompt kinds (bible vs panel batch), detected by
|
| 6 |
+
a marker in the system prompt; the mock artist draws a clearly-placeholder panel.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import io
|
| 12 |
+
import json
|
| 13 |
+
import re
|
| 14 |
+
|
| 15 |
+
from PIL import Image, ImageDraw
|
| 16 |
+
|
| 17 |
+
from .backends import WriterBackend, ArtistBackend
|
| 18 |
+
from .schema import PAGES, PANELS_PER_PAGE
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _is_panel_request(messages: list) -> bool:
|
| 22 |
+
sys = next((m["content"] for m in messages if m["role"] == "system"), "")
|
| 23 |
+
return "scripting individual panels" in sys
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _user_text(messages: list) -> str:
|
| 27 |
+
return next((m["content"] for m in reversed(messages)
|
| 28 |
+
if m["role"] == "user"), "")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# Refuse the mock only on an obvious red flag, so the offline path still exercises
|
| 32 |
+
# the refusal branch when asked.
|
| 33 |
+
_BLOCK_RE = re.compile(r"\b(child porn|csam|sexual.*(child|minor))\b", re.IGNORECASE)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class MockWriter(WriterBackend):
|
| 37 |
+
"""Deterministic JSON for the bible call and the panel-batch calls."""
|
| 38 |
+
|
| 39 |
+
def chat(self, messages: list) -> str:
|
| 40 |
+
user = _user_text(messages)
|
| 41 |
+
if _is_panel_request(messages):
|
| 42 |
+
return self._panels(user)
|
| 43 |
+
return self._bible(user)
|
| 44 |
+
|
| 45 |
+
# call #1
|
| 46 |
+
def _bible(self, user: str) -> str:
|
| 47 |
+
if _BLOCK_RE.search(user):
|
| 48 |
+
return json.dumps({
|
| 49 |
+
"approved": False,
|
| 50 |
+
"refusal_reason": "That request isn't something I can make a comic about.",
|
| 51 |
+
})
|
| 52 |
+
# Pull the reader's request out of the prompt for a touch of flavor.
|
| 53 |
+
m = re.search(r'"""\s*(.+?)\s*"""', user, re.DOTALL)
|
| 54 |
+
idea = (m.group(1).strip() if m else "an adventure")[:80]
|
| 55 |
+
pages = [
|
| 56 |
+
{"page": i, "synopsis": f"Page {i}: the tale of {idea} advances toward its end."}
|
| 57 |
+
for i in range(1, PAGES + 1)
|
| 58 |
+
]
|
| 59 |
+
return json.dumps({
|
| 60 |
+
"approved": True,
|
| 61 |
+
"refusal_reason": "",
|
| 62 |
+
"title": f"The Saga of {idea.title()}"[:60],
|
| 63 |
+
"logline": f"A mock comic about {idea}.",
|
| 64 |
+
"art_style": ("modern western comic book art, bold black ink linework, "
|
| 65 |
+
"dynamic cel shading"),
|
| 66 |
+
"palette": "warm saturated comic palette",
|
| 67 |
+
"characters": [
|
| 68 |
+
{"name": "Mara", "appearance": ("a determined young woman, late 20s, short "
|
| 69 |
+
"auburn hair, green travel cloak over leather armor, a brass compass at her belt")},
|
| 70 |
+
{"name": "Finn", "appearance": ("a wiry teenage boy, freckles, messy black hair, "
|
| 71 |
+
"patched blue tunic, always carrying a worn satchel")},
|
| 72 |
+
],
|
| 73 |
+
"pages": pages,
|
| 74 |
+
})
|
| 75 |
+
|
| 76 |
+
# calls #2..N
|
| 77 |
+
def _panels(self, user: str) -> str:
|
| 78 |
+
# The batch's pages are the "Page N:" lines AFTER the "NOW WRITE" marker
|
| 79 |
+
# (the bible brief above it lists the full synopsis, which we must ignore).
|
| 80 |
+
tail = user.split("NOW WRITE")[-1]
|
| 81 |
+
nums = [int(n) for n in re.findall(r"Page (\d+):", tail)]
|
| 82 |
+
seen, req = set(), []
|
| 83 |
+
for n in nums:
|
| 84 |
+
if n not in seen:
|
| 85 |
+
seen.add(n)
|
| 86 |
+
req.append(n)
|
| 87 |
+
panels = []
|
| 88 |
+
for pg in req:
|
| 89 |
+
for pn in range(1, PANELS_PER_PAGE + 1):
|
| 90 |
+
panels.append({
|
| 91 |
+
"page": pg,
|
| 92 |
+
"panel": pn,
|
| 93 |
+
"scene": (f"wide shot, Mara and Finn on page {pg} panel {pn}, "
|
| 94 |
+
"dramatic lighting, a tense moment in their journey"),
|
| 95 |
+
"caption": f"Page {pg}, panel {pn}: the journey continues. "
|
| 96 |
+
f"\"We're close now,\" Mara says.",
|
| 97 |
+
"characters": ["Mara", "Finn"],
|
| 98 |
+
})
|
| 99 |
+
return json.dumps({"panels": panels})
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
class MockArtist(ArtistBackend):
|
| 103 |
+
"""A placeholder landscape panel — a tinted gradient with abstract blocks."""
|
| 104 |
+
|
| 105 |
+
W, H = 1024, 768
|
| 106 |
+
|
| 107 |
+
def render(self, prompt: str, seed: int = 0) -> bytes:
|
| 108 |
+
h = (abs(hash((prompt, seed))) & 0xFFFFFFFF)
|
| 109 |
+
base = _tint(prompt)
|
| 110 |
+
img = Image.new("RGB", (self.W, self.H), base)
|
| 111 |
+
d = ImageDraw.Draw(img)
|
| 112 |
+
for y in range(self.H):
|
| 113 |
+
f = y / self.H
|
| 114 |
+
d.line([(0, y), (self.W, y)],
|
| 115 |
+
fill=tuple(int(c * (1 - 0.4 * f)) for c in base))
|
| 116 |
+
rnd = h
|
| 117 |
+
for _ in range(16):
|
| 118 |
+
rnd = (rnd * 1103515245 + 12345) & 0x7FFFFFFF
|
| 119 |
+
x = rnd % self.W
|
| 120 |
+
rnd = (rnd * 1103515245 + 12345) & 0x7FFFFFFF
|
| 121 |
+
y = self.H // 2 + rnd % (self.H // 2)
|
| 122 |
+
rnd = (rnd * 1103515245 + 12345) & 0x7FFFFFFF
|
| 123 |
+
w = 40 + rnd % 160
|
| 124 |
+
shade = tuple(max(0, c - 60) for c in base)
|
| 125 |
+
d.rectangle([x, y, x + w, y + 50 + rnd % 90], fill=shade)
|
| 126 |
+
buf = io.BytesIO()
|
| 127 |
+
img.save(buf, format="JPEG", quality=88)
|
| 128 |
+
return buf.getvalue()
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def _tint(prompt: str) -> tuple:
|
| 132 |
+
p = prompt.lower()
|
| 133 |
+
if "noir" in p or "dark" in p:
|
| 134 |
+
return (70, 74, 86)
|
| 135 |
+
if "warm" in p or "sunset" in p:
|
| 136 |
+
return (150, 110, 80)
|
| 137 |
+
if "forest" in p or "green" in p:
|
| 138 |
+
return (80, 120, 90)
|
| 139 |
+
return (95, 100, 120)
|