ASTRALK commited on
Commit
ef73896
·
verified ·
1 Parent(s): 15dcc39

Upload comic/mock_backend.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. 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)