Sync from dev: Qwen3-14B (single model) + FLUX.2-klein, generative scribe, 4-way parallel scribe/paint, lazy deck view, prompt logging, GUI + card-frame fixes
Browse files- app.py +680 -101
- arcana/build.py +69 -35
- arcana/compositor.py +6 -4
- arcana/designer.py +70 -15
- arcana/frame.py +5 -3
- arcana/gpu_models.py +65 -38
- arcana/imagegen.py +18 -6
- arcana/llm.py +13 -9
- arcana/loremaster.py +116 -61
- arcana/prompts.py +21 -13
- arcana/reader.py +53 -4
- arcana/timing.py +61 -0
- frontend/deckview.html +87 -17
app.py
CHANGED
|
@@ -24,6 +24,7 @@ import base64
|
|
| 24 |
import html
|
| 25 |
import json
|
| 26 |
import os
|
|
|
|
| 27 |
import tempfile
|
| 28 |
import threading
|
| 29 |
import time
|
|
@@ -75,7 +76,7 @@ if os.environ.get("IMAGE_BACKEND", "").lower() == "local":
|
|
| 75 |
|
| 76 |
WELCOME = ("Welcome, traveler. Every world has its hidden archetypes. "
|
| 77 |
"Name one, and I shall draw out its fortune.")
|
| 78 |
-
EXAMPLE_THEMES = ["
|
| 79 |
SPREAD_CHOICES = [("Single card", "single"), ("Past · Present · Future", "three")]
|
| 80 |
|
| 81 |
PAGE_ORDER = ["landing", "taro", "generate", "pick", "reading", "view"]
|
|
@@ -106,17 +107,62 @@ with open(os.path.join(ROOT, "frontend", "deckview.html"), encoding="utf-8") as
|
|
| 106 |
_DECKVIEW_HTML = _f.read()
|
| 107 |
|
| 108 |
|
| 109 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
"""The deck browser — a self-contained coverflow/overhead widget embedded via an
|
| 111 |
iframe ``srcdoc`` (the whole widget HTML is inlined, with the deck's image URLs +
|
| 112 |
names injected as base64 JSON in a global). Using srcdoc instead of a /viewer
|
| 113 |
static mount keeps everything inside gradio's native server (ZeroGPU-compatible).
|
| 114 |
-
All swipe/flip/fade/toggle/lightbox is handled client-side in the widget.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
if not deck or not deck.get("cards"):
|
| 116 |
return "<div class='spread-empty'>No deck to show.</div>"
|
| 117 |
data = {
|
| 118 |
"back": url_for(deck.get("back_disp") or deck.get("back_path")),
|
| 119 |
-
"
|
|
|
|
|
|
|
| 120 |
"front": disp_url(c), "full": full_url(c)}
|
| 121 |
for c in deck["cards"]],
|
| 122 |
}
|
|
@@ -125,8 +171,8 @@ def deck_iframe(deck: dict | None, nonce: int = 0) -> str:
|
|
| 125 |
doc = _DECKVIEW_HTML.replace("</head>", inject + "</head>", 1)
|
| 126 |
srcdoc = html.escape(doc, quote=True)
|
| 127 |
return (f"<iframe srcdoc='{srcdoc}' title='Deck' "
|
| 128 |
-
"style='width:100%;height:74vh;min-height:520px;border:0;
|
| 129 |
-
"background:transparent;'></iframe>")
|
| 130 |
|
| 131 |
|
| 132 |
def zip_deck(deck: dict | None):
|
|
@@ -170,11 +216,29 @@ WORDS_FADE_CAP = 14.0 # never wait longer than this for a fade to finish
|
|
| 170 |
|
| 171 |
def _fade(text: str, step: float = FADE_STEP, start: float = 0.0) -> str:
|
| 172 |
"""Wrap each word in a span that fades in on a staggered delay — the browser
|
| 173 |
-
animates the whole block in one render (no per-word server round-trips).
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
return "".join(out)
|
| 179 |
|
| 180 |
|
|
@@ -203,10 +267,10 @@ def _text_col(d: dict, body_html: str) -> str:
|
|
| 203 |
return f"<div class='text-col'>{head}{ess}{body}</div>"
|
| 204 |
|
| 205 |
|
| 206 |
-
def
|
| 207 |
-
"""
|
| 208 |
-
|
| 209 |
-
|
| 210 |
parts = []
|
| 211 |
if active is not None:
|
| 212 |
d, body, anim = active
|
|
@@ -214,13 +278,18 @@ def _stage(back_url, completed, active=None, synth_html=""):
|
|
| 214 |
f"{_flip_card(d, back_url, anim)}</div>{_text_col(d, body)}</div>")
|
| 215 |
if synth_html:
|
| 216 |
parts.append(f"<div id='fortune-box'>{synth_html}</div>")
|
| 217 |
-
if completed:
|
| 218 |
-
rows = "".join(f"<div class='done-row'><div class='card-col'>{_still_card(d)}</div>"
|
| 219 |
-
f"{_text_col(d, body)}</div>" for d, body in completed)
|
| 220 |
-
parts.append(f"<div class='completed'>{rows}</div>")
|
| 221 |
return f"<div class='reading-stage'>{''.join(parts)}</div>"
|
| 222 |
|
| 223 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
# Manual, reader-paced theatre (§14): "Draw" reveals the first card; a "Next" button
|
| 225 |
# advances one beat at a time so people can read in their own time. Reading state
|
| 226 |
# lives in a session dict carried in gr.State between clicks.
|
|
@@ -233,43 +302,51 @@ def _gpu_reading(deck, question, drawn):
|
|
| 233 |
"""Compute ALL card readings + the synthesis in ONE GPU session (the Nemotron
|
| 234 |
GGUF can't be cached across separate ZeroGPU calls). Revealed progressively
|
| 235 |
client-side afterwards. Returns (partials, synthesis)."""
|
|
|
|
|
|
|
| 236 |
llm = get_llm()
|
| 237 |
partials = []
|
|
|
|
| 238 |
for i in range(len(drawn)):
|
| 239 |
try:
|
| 240 |
partials.append(card_partial(deck, question, drawn, i, llm=llm))
|
| 241 |
except Exception:
|
| 242 |
partials.append("")
|
|
|
|
|
|
|
| 243 |
try:
|
| 244 |
synth = final_synthesis(deck, question, drawn, partials, llm=llm)
|
| 245 |
except Exception as e:
|
| 246 |
synth = f"(the synthesis slips away — {type(e).__name__})"
|
|
|
|
| 247 |
return partials, synth
|
| 248 |
|
| 249 |
|
| 250 |
def _reveal_card(sess):
|
| 251 |
"""Flip the card at sess['i'] into the active area and reveal its (already
|
| 252 |
-
computed) reading. Yields (
|
| 253 |
drawn, back, i = sess["drawn"], sess["back"], sess["i"]
|
| 254 |
d, completed = drawn[i], sess["completed"]
|
|
|
|
| 255 |
label = "Next card ▸" if i + 1 < len(drawn) else "Reveal the verdict ▸"
|
| 256 |
# flip + essence show first, then the deeper reading fades in a beat later
|
| 257 |
-
yield
|
| 258 |
time.sleep(FLIP_BEAT)
|
| 259 |
p = sess["partials_pre"][i] if i < len(sess.get("partials_pre", [])) else ""
|
| 260 |
sess["last_p"] = p
|
| 261 |
body = _fade(p) if p else ""
|
| 262 |
-
yield
|
| 263 |
|
| 264 |
|
| 265 |
def do_draw(deck, question, spread, reversals):
|
| 266 |
"""Begin a reading: draw the spread, compute every card's reading on the GPU in
|
| 267 |
one pass, then reveal card by card as the reader clicks Next."""
|
| 268 |
if not deck:
|
| 269 |
-
yield "<div class='spread-empty'>Conjure or open a deck first.</div>",
|
|
|
|
| 270 |
return
|
| 271 |
drawn = draw_spread(deck, spread=spread, reversals=reversals)
|
| 272 |
-
yield ("<div class='spread-empty'>…the oracle contemplates the spread…</div>",
|
| 273 |
None, _next_btn(None))
|
| 274 |
partials, synth = _gpu_reading(deck, question, drawn)
|
| 275 |
sess = {"deck": deck, "drawn": drawn, "question": question,
|
|
@@ -281,10 +358,9 @@ def do_draw(deck, question, spread, reversals):
|
|
| 281 |
def do_next(sess):
|
| 282 |
"""Advance the reading one beat (reader-paced)."""
|
| 283 |
if not sess or sess.get("phase") != "card":
|
| 284 |
-
yield gr.update(), sess, _next_btn(None)
|
| 285 |
return
|
| 286 |
-
# drop the current card to the list below, with its full reading
|
| 287 |
-
# stands alone if the deeper text was unavailable)
|
| 288 |
d = sess["drawn"][sess["i"]]
|
| 289 |
lp = sess.get("last_p") or ""
|
| 290 |
sess["completed"].append((d, f"<span>{html.escape(lp)}</span>" if lp else ""))
|
|
@@ -292,18 +368,18 @@ def do_next(sess):
|
|
| 292 |
sess["i"] += 1
|
| 293 |
yield from _reveal_card(sess)
|
| 294 |
return
|
| 295 |
-
# all cards read —
|
| 296 |
sess["phase"] = "synth"
|
| 297 |
back = sess["back"]
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
sess, _next_btn(None))
|
| 301 |
time.sleep(FLIP_BEAT)
|
| 302 |
synth = sess.get("synth_pre") or "(the synthesis slips away)"
|
| 303 |
sess["phase"] = "done"
|
| 304 |
-
yield (
|
| 305 |
-
|
| 306 |
-
sess, _next_btn(None))
|
| 307 |
|
| 308 |
# ----------------------------------------------------------------- generate
|
| 309 |
def do_conjure(theme, style, custom):
|
|
@@ -311,42 +387,206 @@ def do_conjure(theme, style, custom):
|
|
| 311 |
deck in the SAME Deck-View/Overhead browser used by View Deck (so you can flip
|
| 312 |
through the new cards), with Read / Save actions there.
|
| 313 |
|
| 314 |
-
Outputs: gen_status, deck_state, vd_deck, vd_html, download_btn, *pages
|
|
|
|
| 315 |
HOLD = (gr.update(),) * 4 # deck_state, vd_deck, vd_html, download_btn
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 316 |
|
| 317 |
def stay(status): # progress: update only the status line, stay on the generate page
|
| 318 |
-
return (status, *HOLD, *show("generate"))
|
| 319 |
|
|
|
|
| 320 |
theme = (theme or "").strip()
|
| 321 |
if not theme:
|
| 322 |
yield stay("Name a theme to begin."); return
|
| 323 |
-
# PHASE 1 — the
|
| 324 |
-
#
|
|
|
|
| 325 |
deck = None
|
| 326 |
-
# STAGE 1 — generating the cards (the LLM mapping); shown as its own loader.
|
| 327 |
for kind, payload, frac in _gpu_map(theme, style, custom):
|
| 328 |
-
if kind == "
|
| 329 |
-
yield stay("<div class='gen-load'><div class='gen-spin'></div>"
|
| 330 |
-
f"<h2>✦ Generating the cards</h2><p>{html.escape(payload)}</p>"
|
| 331 |
-
f"<p class='gen-sub'>mapping <em>{html.escape(theme)}</em> onto the 22 arcana…</p></div>")
|
| 332 |
-
elif kind == "error":
|
| 333 |
yield stay(f"⚠️ The conjuring faltered ({payload}). Try again."); return
|
|
|
|
|
|
|
|
|
|
| 334 |
elif kind == "done":
|
| 335 |
deck = payload
|
| 336 |
if not deck:
|
| 337 |
yield stay("⚠️ The conjuring faltered (no deck). Try again."); return
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 350 |
|
| 351 |
|
| 352 |
def _threaded_progress(work):
|
|
@@ -373,41 +613,285 @@ def _threaded_progress(work):
|
|
| 373 |
yield ("done", st["result"], 1.0)
|
| 374 |
|
| 375 |
|
| 376 |
-
@GPU(duration=
|
| 377 |
def _gpu_map(theme, style, custom):
|
| 378 |
-
"""Phase 1 (own GPU call): the 30B mapping
|
| 379 |
-
|
| 380 |
-
|
|
|
|
|
|
|
| 381 |
yield from _threaded_progress(
|
| 382 |
-
lambda emit:
|
| 383 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 384 |
|
| 385 |
|
| 386 |
@GPU(duration=600)
|
| 387 |
def _gpu_maptest(theme):
|
| 388 |
-
"""Quota-cheap mapping-only probe (no images):
|
| 389 |
-
|
|
|
|
| 390 |
import time as _t
|
| 391 |
-
from arcana
|
| 392 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 393 |
try:
|
| 394 |
-
deck =
|
| 395 |
except Exception as e:
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
cs = [c.get("concept", "") for c in cards]
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
|
| 405 |
|
| 406 |
@GPU(duration=300)
|
| 407 |
def _gpu_images(deck):
|
| 408 |
-
"""Phase 2 (own GPU call): paint back + 22 cards with FLUX (no LLM resident).
|
|
|
|
|
|
|
|
|
|
|
|
|
| 409 |
from arcana.build import paint_phase
|
| 410 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 411 |
|
| 412 |
|
| 413 |
def toggle_custom(style):
|
|
@@ -422,15 +906,21 @@ def _next_nonce():
|
|
| 422 |
|
| 423 |
|
| 424 |
def open_picked(deck_id, mode):
|
| 425 |
-
"""Outputs: deck_state, vd_deck, vd_html, download_btn, *pages.
|
|
|
|
|
|
|
|
|
|
| 426 |
deck = load_deck(deck_id) if deck_id else None
|
| 427 |
if not deck:
|
| 428 |
-
return (gr.update(), gr.update(), gr.update(), gr.update(), *show("pick")
|
|
|
|
| 429 |
if mode == "view":
|
| 430 |
return (gr.update(), deck, deck_iframe(deck, _next_nonce()),
|
| 431 |
-
zip_deck(deck), *show("view")
|
|
|
|
| 432 |
# read mode → straight to the reading room
|
| 433 |
-
return (deck, gr.update(), gr.update(), gr.update(), *show("reading")
|
|
|
|
| 434 |
|
| 435 |
|
| 436 |
def read_this_deck(deck):
|
|
@@ -542,6 +1032,44 @@ button.primary, .big-btn { background:linear-gradient(135deg,#9a7b2e,#d8b15a) !i
|
|
| 542 |
border-radius:6px; overflow:hidden; }
|
| 543 |
.gen-bar > div { height:100%; background:linear-gradient(90deg,#9a7b2e,#d8b15a);
|
| 544 |
transition:width .35s ease; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 545 |
#chips { flex-wrap:wrap !important; gap:6px !important; justify-content:center; }
|
| 546 |
#chips .secondary { border-radius:999px !important; font-size:1.7rem !important; padding:8px 20px !important; }
|
| 547 |
#deck img, #preview img { border-radius:8px; box-shadow:0 6px 20px rgba(0,0,0,.55); }
|
|
@@ -574,6 +1102,8 @@ button.primary, .big-btn { background:linear-gradient(135deg,#9a7b2e,#d8b15a) !i
|
|
| 574 |
.syn-load { font-style:italic; color:#cdbf93; font-size:2rem; text-align:center; padding:10px; }
|
| 575 |
/* word fade-in (§2) — each word eases in on a staggered delay */
|
| 576 |
.fw { opacity:0; display:inline; animation:fadeWord .7s ease forwards; }
|
|
|
|
|
|
|
| 577 |
@keyframes fadeWord { from { opacity:0; } to { opacity:1; } }
|
| 578 |
/* input contrast — light text on dark fields, never yellow-on-white (§4) */
|
| 579 |
.gradio-container textarea, .gradio-container input, .gradio-container select,
|
|
@@ -588,6 +1118,9 @@ button.primary, .big-btn { background:linear-gradient(135deg,#9a7b2e,#d8b15a) !i
|
|
| 588 |
background: rgba(243,230,196,.85) !important; border:1px solid var(--gold) !important;
|
| 589 |
border-radius:4px !important; appearance:auto !important; -webkit-appearance:checkbox !important;
|
| 590 |
opacity:1 !important; cursor:pointer; }
|
|
|
|
|
|
|
|
|
|
| 591 |
/* dropdown popup + options — never yellow-on-white (§5) */
|
| 592 |
.gradio-container ul[role="listbox"], .gradio-container .options, .gradio-container .option,
|
| 593 |
.gradio-container li[role="option"], .gradio-container li.item, .gradio-container [class*="options"] {
|
|
@@ -712,18 +1245,17 @@ def build_demo() -> gr.Blocks:
|
|
| 712 |
placeholder="Name anything to build a tarot deck from — a field, a world, a "
|
| 713 |
"feeling… e.g. Greek mythology, the deep sea, jazz, office life",
|
| 714 |
show_label=False, value="", max_lines=1)
|
| 715 |
-
gr.Markdown("*…or tap an example
|
| 716 |
-
"fresh):*", elem_classes=["status"])
|
| 717 |
with gr.Row(elem_id="chips"):
|
| 718 |
chips = [gr.Button(t, size="sm", elem_classes=["secondary"]) for t in EXAMPLE_THEMES]
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
conjure = gr.Button("✦ Conjure the deck", elem_classes=["big-btn"])
|
| 728 |
gen_status = gr.Markdown("", elem_classes=["status"])
|
| 729 |
gen_back = gr.Button("↩ Back", scale=1, elem_classes=["secondary"])
|
|
@@ -747,12 +1279,13 @@ def build_demo() -> gr.Blocks:
|
|
| 747 |
spread = gr.Dropdown(SPREAD_CHOICES, value="three", label="Spread",
|
| 748 |
scale=3, min_width=260)
|
| 749 |
reversals = gr.Checkbox(value=True, label="Allow reversals",
|
| 750 |
-
scale=2, min_width=220)
|
| 751 |
draw = gr.Button("✦ Draw the cards", elem_classes=["big-btn"])
|
| 752 |
stage = gr.HTML("<div class='spread-empty'>Pose your question, then draw.</div>",
|
| 753 |
elem_id="stage")
|
| 754 |
-
|
| 755 |
-
|
|
|
|
| 756 |
with gr.Row():
|
| 757 |
save_in_read = gr.Button("✦ Save this deck", scale=1, elem_classes=["secondary"])
|
| 758 |
read_back = gr.Button("↩ Back to start", scale=1, elem_classes=["secondary"])
|
|
@@ -791,25 +1324,28 @@ def build_demo() -> gr.Blocks:
|
|
| 791 |
style.change(toggle_custom, style, custom_box)
|
| 792 |
# example theme chips — one click sets the theme AND conjures it from
|
| 793 |
# scratch (the examples ARE live demos: every card is freshly generated).
|
|
|
|
|
|
|
| 794 |
for chip, t in zip(chips, EXAMPLE_THEMES):
|
| 795 |
-
chip.click(lambda t=t: t,
|
| 796 |
-
|
| 797 |
-
[gen_status, deck_state, vd_deck, vd_html, download_btn, *pages])
|
| 798 |
|
| 799 |
# generate → opens the new deck in the Deck-View/Overhead browser (pg_view)
|
| 800 |
conjure.click(do_conjure, [theme, style, custom],
|
| 801 |
-
[gen_status, deck_state, vd_deck, vd_html, download_btn, *pages
|
|
|
|
| 802 |
|
| 803 |
# pick → open
|
| 804 |
pick_refresh.click(lambda: gr.update(choices=deck_choices()), None, pick_dd)
|
| 805 |
pick_open.click(open_picked, [pick_dd, pick_mode],
|
| 806 |
-
[deck_state, vd_deck, vd_html, download_btn, *pages
|
|
|
|
| 807 |
read_btn.click(read_this_deck, vd_deck, [deck_state, *pages])
|
| 808 |
|
| 809 |
# reading
|
| 810 |
draw.click(do_draw, [deck_state, question, spread, reversals],
|
| 811 |
-
[stage, reading_sess, next_btn])
|
| 812 |
-
next_btn.click(do_next, reading_sess, [stage, reading_sess, next_btn])
|
| 813 |
save_in_read.click(save_current, deck_state, save_note)
|
| 814 |
|
| 815 |
# pages are created visible=True so gallery components mount cleanly
|
|
@@ -849,6 +1385,49 @@ def build_demo() -> gr.Blocks:
|
|
| 849 |
_mt_theme = gr.Textbox("Greek mythology", visible=False)
|
| 850 |
_mt_out = gr.Textbox(visible=False)
|
| 851 |
gr.Button(visible=False).click(_gpu_maptest, _mt_theme, _mt_out, api_name="maptest")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 852 |
|
| 853 |
return demo
|
| 854 |
|
|
|
|
| 24 |
import html
|
| 25 |
import json
|
| 26 |
import os
|
| 27 |
+
import re
|
| 28 |
import tempfile
|
| 29 |
import threading
|
| 30 |
import time
|
|
|
|
| 76 |
|
| 77 |
WELCOME = ("Welcome, traveler. Every world has its hidden archetypes. "
|
| 78 |
"Name one, and I shall draw out its fortune.")
|
| 79 |
+
EXAMPLE_THEMES = ["physics", "birds", "lord of the rings characters", "breakfast foods"]
|
| 80 |
SPREAD_CHOICES = [("Single card", "single"), ("Past · Present · Future", "three")]
|
| 81 |
|
| 82 |
PAGE_ORDER = ["landing", "taro", "generate", "pick", "reading", "view"]
|
|
|
|
| 107 |
_DECKVIEW_HTML = _f.read()
|
| 108 |
|
| 109 |
|
| 110 |
+
def _card_desc(c: dict) -> str:
|
| 111 |
+
"""Short caption text for a card — its essence if the loremaster has written it,
|
| 112 |
+
else the (draft) upright meaning, trimmed."""
|
| 113 |
+
d = (c.get("essence") or c.get("upright_meaning") or "").strip()
|
| 114 |
+
d = d.replace("**", "").replace("*", "") # drop raw markdown markers in the caption
|
| 115 |
+
return d[:200]
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def _status_paths(deck: dict):
|
| 119 |
+
dd = deck_dir(deck["deck_id"])
|
| 120 |
+
return os.path.join(dd, "art.json"), os.path.join(dd, "lore.json")
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def _write_json_atomic(path: str, data: dict) -> None:
|
| 124 |
+
tmp = path + ".tmp"
|
| 125 |
+
with open(tmp, "w", encoding="utf-8") as f:
|
| 126 |
+
json.dump(data, f)
|
| 127 |
+
os.replace(tmp, path) # atomic → the poller never reads a half-written file
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def _write_art_status(deck: dict, done: bool) -> None:
|
| 131 |
+
"""Live status the deck-view widget polls: which cards are painted (num → url)."""
|
| 132 |
+
art, _ = _status_paths(deck)
|
| 133 |
+
cards = {c["arcana_number"]: disp_url(c) for c in deck["cards"] if c.get("disp_path")}
|
| 134 |
+
_write_json_atomic(art, {"cards": cards, "done": done,
|
| 135 |
+
"back": url_for(deck.get("back_disp") or deck.get("back_path"))})
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _write_lore_status(deck: dict, done: bool) -> None:
|
| 139 |
+
"""Live status the widget polls: each card's (refined) meaning."""
|
| 140 |
+
_, lore = _status_paths(deck)
|
| 141 |
+
cards = {c["arcana_number"]: {"essence": c.get("essence", ""),
|
| 142 |
+
"upright": c.get("upright_meaning", ""),
|
| 143 |
+
"reversed": c.get("reversed_meaning", "")}
|
| 144 |
+
for c in deck["cards"]}
|
| 145 |
+
_write_json_atomic(lore, {"cards": cards, "done": done})
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def deck_iframe(deck: dict | None, nonce: int = 0, live: bool = False,
|
| 149 |
+
art_url: str = "", lore_url: str = "") -> str:
|
| 150 |
"""The deck browser — a self-contained coverflow/overhead widget embedded via an
|
| 151 |
iframe ``srcdoc`` (the whole widget HTML is inlined, with the deck's image URLs +
|
| 152 |
names injected as base64 JSON in a global). Using srcdoc instead of a /viewer
|
| 153 |
static mount keeps everything inside gradio's native server (ZeroGPU-compatible).
|
| 154 |
+
All swipe/flip/fade/toggle/lightbox is handled client-side in the widget.
|
| 155 |
+
|
| 156 |
+
live=True seeds the widget with names + (draft) descriptions and EMPTY art (a
|
| 157 |
+
shimmer placeholder per card); the widget then polls `art_url`/`lore_url` and
|
| 158 |
+
fills each card's art + refined meaning in as the two GPU phases finish."""
|
| 159 |
if not deck or not deck.get("cards"):
|
| 160 |
return "<div class='spread-empty'>No deck to show.</div>"
|
| 161 |
data = {
|
| 162 |
"back": url_for(deck.get("back_disp") or deck.get("back_path")),
|
| 163 |
+
"live": bool(live), "artUrl": art_url, "loreUrl": lore_url,
|
| 164 |
+
"cards": [{"num": c["arcana_number"], "name": c["concept"],
|
| 165 |
+
"arc": c.get("arcana_name", ""), "desc": _card_desc(c),
|
| 166 |
"front": disp_url(c), "full": full_url(c)}
|
| 167 |
for c in deck["cards"]],
|
| 168 |
}
|
|
|
|
| 171 |
doc = _DECKVIEW_HTML.replace("</head>", inject + "</head>", 1)
|
| 172 |
srcdoc = html.escape(doc, quote=True)
|
| 173 |
return (f"<iframe srcdoc='{srcdoc}' title='Deck' "
|
| 174 |
+
"style='width:100%;height:74vh;max-height:660px;min-height:520px;border:0;"
|
| 175 |
+
"display:block;background:transparent;'></iframe>")
|
| 176 |
|
| 177 |
|
| 178 |
def zip_deck(deck: dict | None):
|
|
|
|
| 216 |
|
| 217 |
def _fade(text: str, step: float = FADE_STEP, start: float = 0.0) -> str:
|
| 218 |
"""Wrap each word in a span that fades in on a staggered delay — the browser
|
| 219 |
+
animates the whole block in one render (no per-word server round-trips).
|
| 220 |
+
|
| 221 |
+
Nemotron likes to emphasise with Markdown (**bold**, *italic*); rather than fight
|
| 222 |
+
it we honour it as a formatting cue. We split the text into styled runs and fade
|
| 223 |
+
word-by-word within them, so the effect and the HTML both stay valid."""
|
| 224 |
+
runs, pos = [], 0
|
| 225 |
+
for m in re.finditer(r"\*\*(.+?)\*\*|\*(.+?)\*", text, flags=re.DOTALL):
|
| 226 |
+
if m.start() > pos:
|
| 227 |
+
runs.append((text[pos:m.start()], False, False))
|
| 228 |
+
if m.group(1) is not None:
|
| 229 |
+
runs.append((m.group(1), True, False))
|
| 230 |
+
else:
|
| 231 |
+
runs.append((m.group(2), False, True))
|
| 232 |
+
pos = m.end()
|
| 233 |
+
if pos < len(text):
|
| 234 |
+
runs.append((text[pos:], False, False))
|
| 235 |
+
out, idx = [], 0
|
| 236 |
+
for seg, bold, ital in runs:
|
| 237 |
+
cls = "fw" + (" fwb" if bold else "") + (" fwi" if ital else "")
|
| 238 |
+
for w in seg.split():
|
| 239 |
+
out.append(f"<span class='{cls}' style='animation-delay:{start + idx * step:.2f}s'>"
|
| 240 |
+
f"{html.escape(w)} </span>")
|
| 241 |
+
idx += 1
|
| 242 |
return "".join(out)
|
| 243 |
|
| 244 |
|
|
|
|
| 267 |
return f"<div class='text-col'>{head}{ess}{body}</div>"
|
| 268 |
|
| 269 |
|
| 270 |
+
def _stage_active(back_url, active=None, synth_html=""):
|
| 271 |
+
"""The TOP area: the currently-active card (+ its reading), or the synthesis.
|
| 272 |
+
Rendered ABOVE the Next button so Next always sits right under the live card —
|
| 273 |
+
the already-read history goes in _stage_done, below Next."""
|
| 274 |
parts = []
|
| 275 |
if active is not None:
|
| 276 |
d, body, anim = active
|
|
|
|
| 278 |
f"{_flip_card(d, back_url, anim)}</div>{_text_col(d, body)}</div>")
|
| 279 |
if synth_html:
|
| 280 |
parts.append(f"<div id='fortune-box'>{synth_html}</div>")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
return f"<div class='reading-stage'>{''.join(parts)}</div>"
|
| 282 |
|
| 283 |
|
| 284 |
+
def _stage_done(completed):
|
| 285 |
+
"""The BOTTOM area: the list of already-read cards (below the Next button)."""
|
| 286 |
+
if not completed:
|
| 287 |
+
return ""
|
| 288 |
+
rows = "".join(f"<div class='done-row'><div class='card-col'>{_still_card(d)}</div>"
|
| 289 |
+
f"{_text_col(d, body)}</div>" for d, body in completed)
|
| 290 |
+
return f"<div class='reading-stage'><div class='completed'>{rows}</div></div>"
|
| 291 |
+
|
| 292 |
+
|
| 293 |
# Manual, reader-paced theatre (§14): "Draw" reveals the first card; a "Next" button
|
| 294 |
# advances one beat at a time so people can read in their own time. Reading state
|
| 295 |
# lives in a session dict carried in gr.State between clicks.
|
|
|
|
| 302 |
"""Compute ALL card readings + the synthesis in ONE GPU session (the Nemotron
|
| 303 |
GGUF can't be cached across separate ZeroGPU calls). Revealed progressively
|
| 304 |
client-side afterwards. Returns (partials, synthesis)."""
|
| 305 |
+
from arcana.timing import record
|
| 306 |
+
import time as _t
|
| 307 |
llm = get_llm()
|
| 308 |
partials = []
|
| 309 |
+
tc = _t.time()
|
| 310 |
for i in range(len(drawn)):
|
| 311 |
try:
|
| 312 |
partials.append(card_partial(deck, question, drawn, i, llm=llm))
|
| 313 |
except Exception:
|
| 314 |
partials.append("")
|
| 315 |
+
record("reading_cards", _t.time() - tc, {"n": len(drawn)})
|
| 316 |
+
ts = _t.time()
|
| 317 |
try:
|
| 318 |
synth = final_synthesis(deck, question, drawn, partials, llm=llm)
|
| 319 |
except Exception as e:
|
| 320 |
synth = f"(the synthesis slips away — {type(e).__name__})"
|
| 321 |
+
record("reading_synthesis", _t.time() - ts)
|
| 322 |
return partials, synth
|
| 323 |
|
| 324 |
|
| 325 |
def _reveal_card(sess):
|
| 326 |
"""Flip the card at sess['i'] into the active area and reveal its (already
|
| 327 |
+
computed) reading. Yields (active_html, done_html, sess, next_button_update)."""
|
| 328 |
drawn, back, i = sess["drawn"], sess["back"], sess["i"]
|
| 329 |
d, completed = drawn[i], sess["completed"]
|
| 330 |
+
done_html = _stage_done(completed)
|
| 331 |
label = "Next card ▸" if i + 1 < len(drawn) else "Reveal the verdict ▸"
|
| 332 |
# flip + essence show first, then the deeper reading fades in a beat later
|
| 333 |
+
yield _stage_active(back, active=(d, "", True)), done_html, sess, _next_btn(label)
|
| 334 |
time.sleep(FLIP_BEAT)
|
| 335 |
p = sess["partials_pre"][i] if i < len(sess.get("partials_pre", [])) else ""
|
| 336 |
sess["last_p"] = p
|
| 337 |
body = _fade(p) if p else ""
|
| 338 |
+
yield _stage_active(back, active=(d, body, False)), done_html, sess, _next_btn(label)
|
| 339 |
|
| 340 |
|
| 341 |
def do_draw(deck, question, spread, reversals):
|
| 342 |
"""Begin a reading: draw the spread, compute every card's reading on the GPU in
|
| 343 |
one pass, then reveal card by card as the reader clicks Next."""
|
| 344 |
if not deck:
|
| 345 |
+
yield ("<div class='spread-empty'>Conjure or open a deck first.</div>", "",
|
| 346 |
+
None, _next_btn(None))
|
| 347 |
return
|
| 348 |
drawn = draw_spread(deck, spread=spread, reversals=reversals)
|
| 349 |
+
yield ("<div class='spread-empty'>…the oracle contemplates the spread…</div>", "",
|
| 350 |
None, _next_btn(None))
|
| 351 |
partials, synth = _gpu_reading(deck, question, drawn)
|
| 352 |
sess = {"deck": deck, "drawn": drawn, "question": question,
|
|
|
|
| 358 |
def do_next(sess):
|
| 359 |
"""Advance the reading one beat (reader-paced)."""
|
| 360 |
if not sess or sess.get("phase") != "card":
|
| 361 |
+
yield gr.update(), gr.update(), sess, _next_btn(None)
|
| 362 |
return
|
| 363 |
+
# drop the current card to the history list below, with its full reading
|
|
|
|
| 364 |
d = sess["drawn"][sess["i"]]
|
| 365 |
lp = sess.get("last_p") or ""
|
| 366 |
sess["completed"].append((d, f"<span>{html.escape(lp)}</span>" if lp else ""))
|
|
|
|
| 368 |
sess["i"] += 1
|
| 369 |
yield from _reveal_card(sess)
|
| 370 |
return
|
| 371 |
+
# all cards read — reveal the synthesis in the (top) active area
|
| 372 |
sess["phase"] = "synth"
|
| 373 |
back = sess["back"]
|
| 374 |
+
done_html = _stage_done(sess["completed"])
|
| 375 |
+
yield (_stage_active(back, synth_html="<div class='syn-load'>…the oracle gathers the threads…</div>"),
|
| 376 |
+
done_html, sess, _next_btn(None))
|
| 377 |
time.sleep(FLIP_BEAT)
|
| 378 |
synth = sess.get("synth_pre") or "(the synthesis slips away)"
|
| 379 |
sess["phase"] = "done"
|
| 380 |
+
yield (_stage_active(back, synth_html=f"<h3>✦ The cards together</h3>"
|
| 381 |
+
f"<div class='syn-body'>{_fade(synth)}</div>"),
|
| 382 |
+
done_html, sess, _next_btn(None))
|
| 383 |
|
| 384 |
# ----------------------------------------------------------------- generate
|
| 385 |
def do_conjure(theme, style, custom):
|
|
|
|
| 387 |
deck in the SAME Deck-View/Overhead browser used by View Deck (so you can flip
|
| 388 |
through the new cards), with Read / Save actions there.
|
| 389 |
|
| 390 |
+
Outputs: gen_status, deck_state, vd_deck, vd_html, download_btn, *pages,
|
| 391 |
+
read_btn, download_btn2(view_note)."""
|
| 392 |
HOLD = (gr.update(),) * 4 # deck_state, vd_deck, vd_html, download_btn
|
| 393 |
+
# read_btn + view_note tail (grey out Read/Download while the deck is still painting)
|
| 394 |
+
TAIL_IDLE = (gr.update(), gr.update())
|
| 395 |
+
READ_BUSY = gr.update(interactive=False, value="🎴 Painting the deck…")
|
| 396 |
+
READ_DONE = gr.update(interactive=True, value="✦ Read this deck")
|
| 397 |
+
NOTE_BUSY = ("🎴 Painting the cards & writing the lore — the reading and download "
|
| 398 |
+
"unlock once the whole deck is complete.")
|
| 399 |
|
| 400 |
def stay(status): # progress: update only the status line, stay on the generate page
|
| 401 |
+
return (status, *HOLD, *show("generate"), *TAIL_IDLE)
|
| 402 |
|
| 403 |
+
NOOP = (gr.update(),) * (5 + len(PAGE_ORDER) + 2) # change nothing, keep the stream alive
|
| 404 |
theme = (theme or "").strip()
|
| 405 |
if not theme:
|
| 406 |
yield stay("Name a theme to begin."); return
|
| 407 |
+
# PHASE 1 — the mapping (its own GPU call): theme → 22 named cards with draft
|
| 408 |
+
# meanings + art briefs. As soon as it lands we jump STRAIGHT TO THE DECK VIEW.
|
| 409 |
+
t_map0 = time.time()
|
| 410 |
deck = None
|
|
|
|
| 411 |
for kind, payload, frac in _gpu_map(theme, style, custom):
|
| 412 |
+
if kind == "error":
|
|
|
|
|
|
|
|
|
|
|
|
|
| 413 |
yield stay(f"⚠️ The conjuring faltered ({payload}). Try again."); return
|
| 414 |
+
elif kind == "progress":
|
| 415 |
+
yield stay(_loader("✦ Finding the cards", payload,
|
| 416 |
+
f"mapping {html.escape(theme)} onto the 22 cards of the major arcana…"))
|
| 417 |
elif kind == "done":
|
| 418 |
deck = payload
|
| 419 |
if not deck:
|
| 420 |
yield stay("⚠️ The conjuring faltered (no deck). Try again."); return
|
| 421 |
+
t_map = time.time() - t_map0
|
| 422 |
+
|
| 423 |
+
# Fan the 22 cards across the GPU slots, round-robin balanced.
|
| 424 |
+
by = {c["arcana_number"]: c for c in deck["cards"]}
|
| 425 |
+
nums = [c["arcana_number"] for c in deck["cards"]]
|
| 426 |
+
chunks = [c for c in (nums[i::PARALLEL_GPUS] for i in range(PARALLEL_GPUS)) if c]
|
| 427 |
+
NW = len(chunks)
|
| 428 |
+
|
| 429 |
+
def merge_fields(res, chunk, fields):
|
| 430 |
+
rb = {c["arcana_number"]: c for c in (res or {}).get("cards", [])}
|
| 431 |
+
for n in chunk:
|
| 432 |
+
src = rb.get(n, {})
|
| 433 |
+
for k in fields:
|
| 434 |
+
if src.get(k):
|
| 435 |
+
by[n][k] = src[k]
|
| 436 |
+
|
| 437 |
+
# PHASE 2 — SCRIBE (parallel): generate all meanings + art prompts behind a LOADER
|
| 438 |
+
# (the deck view is withheld until the lore is fully written, per the new design).
|
| 439 |
+
t_scribe0 = time.time()
|
| 440 |
+
|
| 441 |
+
def scribe_tick(n):
|
| 442 |
+
pct = int(100 * n / NW)
|
| 443 |
+
bar = f"<div class='gen-bar'><div style='width:{pct}%'></div></div>"
|
| 444 |
+
return stay("<div class='gen-load'><div class='gen-spin'></div>"
|
| 445 |
+
"<h2>✦ Writing the lore</h2><p>the scribe inks the 22 cards…</p>"
|
| 446 |
+
f"<p class='gen-sub'>{n}/{NW} batches</p>{bar}</div>")
|
| 447 |
+
yield from _run_parallel(chunks, fn=lambda i, ch: _gpu_scribe(deck, ch),
|
| 448 |
+
on_done=lambda res, ch: merge_fields(res, ch, _SCRIBE_OUT),
|
| 449 |
+
tick=scribe_tick)
|
| 450 |
+
t_scribe = time.time() - t_scribe0
|
| 451 |
+
|
| 452 |
+
# Lore is done → OPEN THE REAL DECK VIEW now (names + full meanings); the art then
|
| 453 |
+
# lazy-loads card-by-card (the iframe polls art.json, which we rebuild from disk).
|
| 454 |
+
_write_art_from_disk(deck, done=False)
|
| 455 |
+
art_url = url_for(os.path.relpath(_status_paths(deck)[0], ROOT))
|
| 456 |
+
yield ("", deck, deck,
|
| 457 |
+
deck_iframe(deck, _next_nonce(), live=True, art_url=art_url, lore_url=""),
|
| 458 |
+
gr.update(interactive=False), *show("view"), READ_BUSY, NOTE_BUSY)
|
| 459 |
+
|
| 460 |
+
# PHASE 3 — PAINT (parallel): each tick we rebuild art.json from whatever card
|
| 461 |
+
# images exist on disk, so cards fill in INDIVIDUALLY as their files land (lazy),
|
| 462 |
+
# not in chunk-batches.
|
| 463 |
+
def merge_paint(res, ch):
|
| 464 |
+
merge_fields(res, ch, _PAINT_OUT)
|
| 465 |
+
for bk in ("back_path", "back_disp"):
|
| 466 |
+
if (res or {}).get(bk):
|
| 467 |
+
deck[bk] = res[bk]
|
| 468 |
+
|
| 469 |
+
def paint_tick(n):
|
| 470 |
+
_write_art_from_disk(deck, done=False)
|
| 471 |
+
return NOOP
|
| 472 |
+
t_paint0 = time.time()
|
| 473 |
+
yield from _run_parallel(chunks, fn=lambda i, ch: _gpu_paint(deck, ch, i == 0),
|
| 474 |
+
on_done=merge_paint, tick=paint_tick)
|
| 475 |
+
_reconcile_from_disk(deck) # rescue any cards a failed chunk left unmerged
|
| 476 |
+
_write_art_from_disk(deck, done=True)
|
| 477 |
+
t_paint = time.time() - t_paint0
|
| 478 |
+
|
| 479 |
+
from arcana.timing import record as _rec
|
| 480 |
+
_rec("conjure_total", t_map + t_scribe + t_paint,
|
| 481 |
+
{"map": round(t_map), "scribe": round(t_scribe), "paint": round(t_paint)})
|
| 482 |
+
|
| 483 |
+
timing_note = (f"⏱ mapping {t_map:.0f}s · scribe {t_scribe:.0f}s (×{NW}) · "
|
| 484 |
+
f"painting {t_paint:.0f}s (×{NW}) · total {t_map + t_scribe + t_paint:.0f}s")
|
| 485 |
+
yield ("", deck, deck, deck_iframe(deck, _next_nonce()),
|
| 486 |
+
gr.update(value=zip_deck(deck), interactive=True), *show("view"),
|
| 487 |
+
READ_DONE, timing_note)
|
| 488 |
+
|
| 489 |
+
|
| 490 |
+
_SCRIBE_OUT = ("essence", "upright_meaning", "reversed_meaning", "art_prompt")
|
| 491 |
+
_PAINT_OUT = ("art_path", "disp_path", "full_path", "seed")
|
| 492 |
+
# Fan-out width. ZeroGPU grants ~4-5 concurrent slots (fluctuating) for this account,
|
| 493 |
+
# so 4 fits in one wave with minimal model-load overhead; higher just queues into extra
|
| 494 |
+
# waves. Env-overridable for experiments.
|
| 495 |
+
PARALLEL_GPUS = int(os.environ.get("PARALLEL_GPUS", "4"))
|
| 496 |
+
|
| 497 |
+
|
| 498 |
+
def _run_parallel(chunks, fn, on_done, tick):
|
| 499 |
+
"""Run fn(i, chunk) for each chunk on its own thread → concurrent @spaces.GPU
|
| 500 |
+
calls. As each finishes, on_done(result, chunk) merges it; each loop yields
|
| 501 |
+
tick(n_done) to refresh the UI / status while the GPUs work (keeps the Gradio
|
| 502 |
+
stream alive). Chunk errors are LOGGED (not swallowed) so a failed chunk is
|
| 503 |
+
visible — the caller reconciles from disk afterwards so its cards aren't lost."""
|
| 504 |
+
import concurrent.futures as _cf
|
| 505 |
+
with _cf.ThreadPoolExecutor(max_workers=len(chunks)) as ex:
|
| 506 |
+
futs = {ex.submit(fn, i, ch): ch for i, ch in enumerate(chunks)}
|
| 507 |
+
pending, n = set(futs), 0
|
| 508 |
+
while pending:
|
| 509 |
+
for f in [x for x in pending if x.done()]:
|
| 510 |
+
pending.discard(f)
|
| 511 |
+
try:
|
| 512 |
+
on_done(f.result(), futs[f])
|
| 513 |
+
except Exception as e:
|
| 514 |
+
print(f"[CHUNK_ERR] cards {futs[f]}: {type(e).__name__}: {e}", flush=True)
|
| 515 |
+
n += 1
|
| 516 |
+
yield tick(n)
|
| 517 |
+
time.sleep(0.4)
|
| 518 |
+
|
| 519 |
+
|
| 520 |
+
def _reconcile_from_disk(deck: dict) -> dict:
|
| 521 |
+
"""Fill any card's image paths from the files that ACTUALLY exist on disk. Guards
|
| 522 |
+
against a paint chunk that errored after saving files but before its result merged
|
| 523 |
+
— without this, such cards stay blank (shimmering) in the final deck view."""
|
| 524 |
+
dd = deck_dir(deck["deck_id"])
|
| 525 |
+
thumbs = os.path.join(dd, "thumbs")
|
| 526 |
+
for c in deck["cards"]:
|
| 527 |
+
base = f"{c['arcana_number']:02d}_{slugify(c['concept'])}"
|
| 528 |
+
for key, path in (("art_path", os.path.join(dd, base + ".png")),
|
| 529 |
+
("disp_path", os.path.join(thumbs, base + ".webp")),
|
| 530 |
+
("full_path", os.path.join(thumbs, base + ".full.webp"))):
|
| 531 |
+
if not c.get(key) and os.path.exists(path):
|
| 532 |
+
c[key] = os.path.relpath(path, ROOT)
|
| 533 |
+
for key, path in (("back_path", os.path.join(dd, "back.png")),
|
| 534 |
+
("back_disp", os.path.join(thumbs, "back.webp"))):
|
| 535 |
+
if not deck.get(key) and os.path.exists(path):
|
| 536 |
+
deck[key] = os.path.relpath(path, ROOT)
|
| 537 |
+
return deck
|
| 538 |
+
|
| 539 |
+
|
| 540 |
+
def _write_art_from_disk(deck: dict, done: bool) -> None:
|
| 541 |
+
"""Rebuild art.json from the card images that ACTUALLY exist on disk — so the
|
| 542 |
+
deck view fills in card-by-card (lazy) as each file lands, regardless of which
|
| 543 |
+
parallel chunk produced it. Disp path is deterministic from arcana#+concept."""
|
| 544 |
+
thumbs = os.path.join(deck_dir(deck["deck_id"]), "thumbs")
|
| 545 |
+
cards = {}
|
| 546 |
+
for c in deck["cards"]:
|
| 547 |
+
p = os.path.join(thumbs, f"{c['arcana_number']:02d}_{slugify(c['concept'])}.webp")
|
| 548 |
+
if os.path.exists(p):
|
| 549 |
+
cards[c["arcana_number"]] = url_for(os.path.relpath(p, ROOT))
|
| 550 |
+
bp = os.path.join(thumbs, "back.webp")
|
| 551 |
+
back = url_for(os.path.relpath(bp, ROOT)) if os.path.exists(bp) else ""
|
| 552 |
+
_write_json_atomic(_status_paths(deck)[0], {"cards": cards, "back": back, "done": done})
|
| 553 |
+
|
| 554 |
+
|
| 555 |
+
def _loader(title: str, msg: str, sub: str) -> str:
|
| 556 |
+
return ("<div class='gen-load'><div class='gen-spin'></div>"
|
| 557 |
+
f"<h2>{title}</h2><p>{html.escape(msg)}</p>"
|
| 558 |
+
+ (f"<p class='gen-sub'>{sub}</p>" if sub else "") + "</div>")
|
| 559 |
+
|
| 560 |
+
|
| 561 |
+
def _conjure_grid(deck: dict, painted: dict, title: str, sub: str,
|
| 562 |
+
show_desc: bool, frac: float | None = None) -> str:
|
| 563 |
+
"""Progressive deck-view grid: every card shows its name immediately, its
|
| 564 |
+
description once the loremaster has written it, and its art the moment FLUX
|
| 565 |
+
finishes painting it (a shimmer placeholder until then)."""
|
| 566 |
+
cells = []
|
| 567 |
+
for c in deck["cards"]:
|
| 568 |
+
num = c["arcana_number"]
|
| 569 |
+
name = html.escape(c.get("concept", ""))
|
| 570 |
+
arc = html.escape(c.get("arcana_name", ""))
|
| 571 |
+
url = painted.get(num)
|
| 572 |
+
art = (f"<img src='{url}' alt='{name}' loading='lazy'/>" if url
|
| 573 |
+
else "<div class='cj-shim'></div>")
|
| 574 |
+
desc = ""
|
| 575 |
+
if show_desc:
|
| 576 |
+
d = c.get("essence") or c.get("upright_meaning", "")
|
| 577 |
+
if d:
|
| 578 |
+
desc = f"<p class='cj-desc'>{html.escape(d[:160])}</p>"
|
| 579 |
+
cells.append(
|
| 580 |
+
f"<div class='cj-card{' cj-lit' if url else ''}'>"
|
| 581 |
+
f"<div class='cj-art'>{art}</div>"
|
| 582 |
+
f"<div class='cj-meta'><span class='cj-arc'>{arc}</span>"
|
| 583 |
+
f"<span class='cj-name'>{name}</span>{desc}</div></div>")
|
| 584 |
+
bar = (f"<div class='gen-bar'><div style='width:{int((frac or 0) * 100)}%'></div></div>"
|
| 585 |
+
if frac is not None else "")
|
| 586 |
+
return ("<div class='cj-wrap'><div class='cj-head'>"
|
| 587 |
+
f"<div class='gen-spin'></div><h2>{title}</h2>"
|
| 588 |
+
f"<p class='gen-sub'>{sub}</p>{bar}</div>"
|
| 589 |
+
f"<div class='cj-grid'>{''.join(cells)}</div></div>")
|
| 590 |
|
| 591 |
|
| 592 |
def _threaded_progress(work):
|
|
|
|
| 613 |
yield ("done", st["result"], 1.0)
|
| 614 |
|
| 615 |
|
| 616 |
+
@GPU(duration=400)
|
| 617 |
def _gpu_map(theme, style, custom):
|
| 618 |
+
"""Phase 1 (own GPU call): the 30B designer mapping ONLY — theme → 22 named cards
|
| 619 |
+
with justifications, draft meanings, and art briefs. Returns fast enough to jump
|
| 620 |
+
straight to the deck view; the loremaster + FLUX then run in parallel. 400s covers
|
| 621 |
+
first-call warm-up + the single designer pass."""
|
| 622 |
+
from arcana.build import map_phase
|
| 623 |
yield from _threaded_progress(
|
| 624 |
+
lambda emit: map_phase(theme, visual_style=style, custom_style=custom, progress=emit))
|
| 625 |
+
|
| 626 |
+
|
| 627 |
+
@GPU(duration=240)
|
| 628 |
+
def _gpu_scribe(deck, subset=None):
|
| 629 |
+
"""Generative scribe (one parallel chunk): invent meaning + art_prompt for the
|
| 630 |
+
`subset` of arcana numbers (default all 22). Returns the deck with that subset
|
| 631 |
+
filled; the MAIN process merges chunks + writes lore.json (avoids 4 workers
|
| 632 |
+
clobbering one status file)."""
|
| 633 |
+
from arcana.build import scribe_phase
|
| 634 |
+
scribe_phase(deck, subset=subset)
|
| 635 |
+
return deck
|
| 636 |
+
|
| 637 |
+
|
| 638 |
+
@GPU(duration=200)
|
| 639 |
+
def _gpu_paint(deck, subset, paint_back=False):
|
| 640 |
+
"""Paint one parallel chunk of cards (+ the deck-back if paint_back). Saves images
|
| 641 |
+
to the shared disk and returns the deck with that subset's paths filled; the MAIN
|
| 642 |
+
process merges + writes art.json."""
|
| 643 |
+
from arcana.build import paint_subset
|
| 644 |
+
paint_subset(deck, subset, paint_back=paint_back)
|
| 645 |
+
return deck
|
| 646 |
+
|
| 647 |
+
|
| 648 |
+
@GPU(duration=400)
|
| 649 |
+
def _gpu_mapdiag(theme):
|
| 650 |
+
"""FIX TEST v2: force the exact schema with a JSON-schema GRAMMAR (prevents tool
|
| 651 |
+
calls, malformed JSON, and field drift all at once) on the STRIPPED mapping
|
| 652 |
+
(arcana_number+concept+justification only). 3 runs to gauge reliability."""
|
| 653 |
+
import json, time as _t
|
| 654 |
+
from arcana.gpu_models import _get_llama, _DEF_30B
|
| 655 |
+
theme = theme or "Greek mythology"
|
| 656 |
+
llm = _get_llama(_DEF_30B[0], _DEF_30B[1], 16384)
|
| 657 |
+
SCHEMA = {
|
| 658 |
+
"type": "object", "required": ["cards"],
|
| 659 |
+
"properties": {"cards": {"type": "array", "items": {
|
| 660 |
+
"type": "object", "required": ["arcana_number", "concept", "justification"],
|
| 661 |
+
"properties": {
|
| 662 |
+
"arcana_number": {"type": "integer"},
|
| 663 |
+
"concept": {"type": "string"},
|
| 664 |
+
"justification": {"type": "string"}}}}}}
|
| 665 |
+
sysmsg = (
|
| 666 |
+
"Map the 22 Major Arcana onto concepts from the given theme. For EVERY arcana 0-21, "
|
| 667 |
+
"pick ONE DISTINCT in-theme concept (a short proper noun/name, NOT a sentence) whose "
|
| 668 |
+
"meaning rhymes with that archetype, plus a one-line justification. Output JSON.")
|
| 669 |
+
usr = f"Theme: {theme}\nMap all 22 arcana (0 through 21). Each concept must be unique."
|
| 670 |
+
blocks = []
|
| 671 |
+
for run in range(3):
|
| 672 |
+
t = _t.time()
|
| 673 |
+
try:
|
| 674 |
+
out = llm.create_chat_completion(
|
| 675 |
+
messages=[{"role": "system", "content": sysmsg},
|
| 676 |
+
{"role": "user", "content": usr}],
|
| 677 |
+
response_format={"type": "json_object", "schema": SCHEMA},
|
| 678 |
+
max_tokens=5000, temperature=0.4)
|
| 679 |
+
raw = (out["choices"][0]["message"]["content"] or "")
|
| 680 |
+
except Exception as e:
|
| 681 |
+
blocks.append(f"run{run+1}: GEN ERROR {type(e).__name__}: {str(e)[:90]}"); continue
|
| 682 |
+
dt = _t.time() - t
|
| 683 |
+
info = [f"run{run+1}: {dt:.0f}s len={len(raw)}"]
|
| 684 |
+
try:
|
| 685 |
+
cards = json.loads(raw).get("cards", [])
|
| 686 |
+
cons = [(c.get("concept") or "").strip() for c in cards]
|
| 687 |
+
nums = sorted(c.get("arcana_number") for c in cards if isinstance(c.get("arcana_number"), int))
|
| 688 |
+
info.append(f"PARSED ✓ cards={len(cards)} nums_ok={nums==list(range(22))} "
|
| 689 |
+
f"empty={sum(1 for x in cons if not x)} dup={len(cons)-len(set(x.lower() for x in cons))}")
|
| 690 |
+
info.append("concepts: " + " | ".join(cons))
|
| 691 |
+
except Exception as e:
|
| 692 |
+
info.append(f"PARSE FAIL {type(e).__name__}: {str(e)[:90]} | TAIL " + raw[-180:].replace("\n", "\\n"))
|
| 693 |
+
blocks.append("\n".join(info))
|
| 694 |
+
return "\n\n====\n\n".join(blocks)
|
| 695 |
+
|
| 696 |
+
|
| 697 |
+
@GPU(duration=40)
|
| 698 |
+
def _gpu_ping(tag, hold):
|
| 699 |
+
"""A trivial GPU task that holds its allocation for `hold` seconds — used to test
|
| 700 |
+
whether ZeroGPU grants ONE user TWO concurrent GPU slots (the #3 parallel
|
| 701 |
+
text+image design hinges on this)."""
|
| 702 |
+
import time as _t
|
| 703 |
+
t0 = _t.time()
|
| 704 |
+
try:
|
| 705 |
+
import torch
|
| 706 |
+
x = torch.ones(1, device="cuda"); _ = float((x + 1).sum().item())
|
| 707 |
+
except Exception as e:
|
| 708 |
+
return f"{tag}|ERR|{type(e).__name__}:{str(e)[:60]}"
|
| 709 |
+
_t.sleep(hold)
|
| 710 |
+
return f"{tag}|{t0:.3f}|{_t.time():.3f}"
|
| 711 |
+
|
| 712 |
+
|
| 713 |
+
def _concurrency_probe():
|
| 714 |
+
"""Fire K @GPU pings at once and report the MAX number that ran simultaneously —
|
| 715 |
+
i.e. how many concurrent ZeroGPU slots this account actually gets. Decides how far
|
| 716 |
+
we can parallelize the meaning/art agent and the painting."""
|
| 717 |
+
import time as _t, concurrent.futures as _cf
|
| 718 |
+
HOLD, K = 7.0, 4
|
| 719 |
+
t_start = _t.time()
|
| 720 |
+
with _cf.ThreadPoolExecutor(max_workers=K) as ex:
|
| 721 |
+
futs = [ex.submit(_gpu_ping, str(i), HOLD) for i in range(K)]
|
| 722 |
+
res = []
|
| 723 |
+
for f in futs:
|
| 724 |
+
try:
|
| 725 |
+
res.append(f.result())
|
| 726 |
+
except Exception as e:
|
| 727 |
+
res.append(f"X|ERR|{type(e).__name__}:{str(e)[:50]}")
|
| 728 |
+
total = _t.time() - t_start
|
| 729 |
+
ivs, fails = [], 0
|
| 730 |
+
for r in res:
|
| 731 |
+
p = r.split("|")
|
| 732 |
+
if len(p) >= 3 and p[1] == "ERR":
|
| 733 |
+
fails += 1
|
| 734 |
+
else:
|
| 735 |
+
try:
|
| 736 |
+
ivs.append((float(p[1]), float(p[2])))
|
| 737 |
+
except Exception:
|
| 738 |
+
fails += 1
|
| 739 |
+
# sweep line: max overlapping intervals
|
| 740 |
+
ev = []
|
| 741 |
+
for s, e in ivs:
|
| 742 |
+
ev += [(s, 1), (e, -1)]
|
| 743 |
+
ev.sort()
|
| 744 |
+
cur = mx = 0
|
| 745 |
+
for _, d in ev:
|
| 746 |
+
cur += d; mx = max(mx, cur)
|
| 747 |
+
return (f"max_concurrent_gpus={mx} of {K} requested (failed/rejected={fails}) "
|
| 748 |
+
f"total={total:.1f}s\nintervals={[(round(s, 1), round(e, 1)) for s, e in ivs]}")
|
| 749 |
+
|
| 750 |
+
|
| 751 |
+
@GPU(duration=400)
|
| 752 |
+
def _gpu_scribetest(theme):
|
| 753 |
+
"""New pipeline check: designer (mapping+justification only) → generative scribe
|
| 754 |
+
(meaning + art). Reports timings, the mapping, and the scribe's meanings + ART
|
| 755 |
+
prompts for the religion-prone archetypes (Hierophant/Death/Temperance/Judgement)
|
| 756 |
+
— the regression risk from re-splitting meaning/art into a separate agent."""
|
| 757 |
+
import time as _t
|
| 758 |
+
from arcana import designer
|
| 759 |
+
from arcana.build import map_phase, scribe_phase
|
| 760 |
+
theme = theme or "Greek mythology"
|
| 761 |
+
t = _t.time(); deck = map_phase(theme); tmap = _t.time() - t
|
| 762 |
+
t = _t.time(); scribe_phase(deck); tsc = _t.time() - t
|
| 763 |
+
by = {c["arcana_number"]: c for c in deck["cards"]}
|
| 764 |
+
out = [f"THEME: {theme} | map={tmap:.0f}s (attempts={getattr(designer,'LAST_ATTEMPTS','?')})"
|
| 765 |
+
f" | scribe={tsc:.0f}s",
|
| 766 |
+
"\nCONCEPTS: " + " | ".join(c["concept"] for c in deck["cards"])]
|
| 767 |
+
for n in (5, 13, 14, 20):
|
| 768 |
+
c = by.get(n, {})
|
| 769 |
+
out.append(f"\n[{n}] {c.get('concept','?')}")
|
| 770 |
+
out.append(f" essence: {c.get('essence','')}")
|
| 771 |
+
out.append(f" upright: {c.get('upright_meaning','')[:220]}")
|
| 772 |
+
out.append(f" ART : {c.get('art_prompt','')[:260]}")
|
| 773 |
+
return "\n".join(out)
|
| 774 |
+
|
| 775 |
+
|
| 776 |
+
@GPU(duration=400)
|
| 777 |
+
def _gpu_loretest(theme):
|
| 778 |
+
"""Side-by-side: the DESIGNER's draft meanings vs the LOREMASTER's refined ones,
|
| 779 |
+
for a spread of representative cards — to judge whether the loremaster pass earns
|
| 780 |
+
its keep or whether the designer drafts are already better/more detailed."""
|
| 781 |
+
from arcana.designer import design_deck
|
| 782 |
+
from arcana.loremaster import refine_deck
|
| 783 |
+
theme = theme or "Greek mythology"
|
| 784 |
+
deck = design_deck(theme)
|
| 785 |
+
draft = {c["arcana_number"]: dict(concept=c["concept"],
|
| 786 |
+
up=c["upright_meaning"], rev=c["reversed_meaning"])
|
| 787 |
+
for c in deck["cards"]}
|
| 788 |
+
refine_deck(deck) # mutates meanings in place
|
| 789 |
+
fin = {c["arcana_number"]: dict(up=c["upright_meaning"], rev=c["reversed_meaning"],
|
| 790 |
+
ess=c.get("essence", "")) for c in deck["cards"]}
|
| 791 |
+
out = [f"THEME: {theme}\n(draft = designer • refined = loremaster)\n"]
|
| 792 |
+
for n in (0, 6, 10, 13, 16, 21):
|
| 793 |
+
d, f = draft.get(n, {}), fin.get(n, {})
|
| 794 |
+
out.append(f"\n========== [{n}] {d.get('concept','?')} ==========")
|
| 795 |
+
out.append(f"UPRIGHT draft : {d.get('up','')}")
|
| 796 |
+
out.append(f"UPRIGHT refined: {f.get('up','')}")
|
| 797 |
+
out.append(f"REVERSED draft : {d.get('rev','')}")
|
| 798 |
+
out.append(f"REVERSED refined: {f.get('rev','')}")
|
| 799 |
+
out.append(f"essence (loremaster only): {f.get('ess','')}")
|
| 800 |
+
# rough length comparison across ALL 22 cards
|
| 801 |
+
da = sum(len(v["up"]) for v in draft.values()) / 22
|
| 802 |
+
fa = sum(len(v["up"]) for v in fin.values()) / 22
|
| 803 |
+
out.append(f"\n--- avg upright length: draft={da:.0f} chars · refined={fa:.0f} chars ---")
|
| 804 |
+
return "\n".join(out)
|
| 805 |
|
| 806 |
|
| 807 |
@GPU(duration=600)
|
| 808 |
def _gpu_maptest(theme):
|
| 809 |
+
"""Quota-cheap mapping-only probe (no images): breaks the LLM phase into
|
| 810 |
+
warm-up vs designer (with retry count) vs loremaster, so we can see EXACTLY
|
| 811 |
+
where the minutes go."""
|
| 812 |
import time as _t
|
| 813 |
+
from arcana import designer
|
| 814 |
+
from arcana.designer import design_deck
|
| 815 |
+
from arcana.loremaster import refine_deck
|
| 816 |
+
from arcana.gpu_models import llama_map
|
| 817 |
+
theme = theme or "Greek mythology"
|
| 818 |
+
L = []
|
| 819 |
+
# 1) warm up the 8B mapper in isolation (load + first-inference JIT)
|
| 820 |
+
t = _t.time()
|
| 821 |
+
try:
|
| 822 |
+
llama_map("Reply with one tiny JSON object.", "Output {\"ok\": 1}", max_tokens=40)
|
| 823 |
+
except Exception as e:
|
| 824 |
+
L.append(f"warmup ERR {type(e).__name__}")
|
| 825 |
+
L.append(f"map-model warmup(load+1st gen)={_t.time()-t:.0f}s")
|
| 826 |
+
# 2) designer mapping (now warm) — note attempt count
|
| 827 |
+
t = _t.time()
|
| 828 |
+
try:
|
| 829 |
+
deck = design_deck(theme)
|
| 830 |
+
except Exception as e:
|
| 831 |
+
return " | ".join(L) + f" | designer FAILED {type(e).__name__}: {str(e)[:120]}"
|
| 832 |
+
L.append(f"designer={_t.time()-t:.0f}s attempts={getattr(designer,'LAST_ATTEMPTS','?')}")
|
| 833 |
+
# 3) loremaster pass
|
| 834 |
+
t = _t.time()
|
| 835 |
try:
|
| 836 |
+
deck = refine_deck(deck)
|
| 837 |
except Exception as e:
|
| 838 |
+
L.append(f"loremaster FAILED {type(e).__name__}")
|
| 839 |
+
else:
|
| 840 |
+
L.append(f"loremaster={_t.time()-t:.0f}s")
|
| 841 |
+
cs = [c.get("concept", "") for c in deck.get("cards", [])]
|
| 842 |
+
L.append(f"cards={len(cs)} empties={sum(1 for x in cs if not x.strip())} dups={len(cs)-len(set(cs))}")
|
| 843 |
+
out = " | ".join(L)
|
| 844 |
+
out += "\n\nCONCEPTS: " + " | ".join(cs)
|
| 845 |
+
by_n = {c.get("arcana_number"): c for c in deck.get("cards", [])}
|
| 846 |
+
sample = by_n.get(13)
|
| 847 |
+
if sample:
|
| 848 |
+
out += (f"\n\nSAMPLE (Death→{sample.get('concept')}): "
|
| 849 |
+
f"{sample.get('upright_meaning','')[:240]}")
|
| 850 |
+
# art prompts for the religion-prone archetypes (Hierophant/Temperance/Judgement)
|
| 851 |
+
for n in (5, 14, 20):
|
| 852 |
+
c = by_n.get(n)
|
| 853 |
+
if c:
|
| 854 |
+
out += (f"\n\nART[{n} {c.get('arcana_name')}→{c.get('concept')}]: "
|
| 855 |
+
f"{c.get('art_prompt','')[:300]}")
|
| 856 |
+
return out
|
| 857 |
|
| 858 |
|
| 859 |
@GPU(duration=300)
|
| 860 |
def _gpu_images(deck):
|
| 861 |
+
"""Phase 2 (own GPU call): paint back + 22 cards with FLUX (no LLM resident).
|
| 862 |
+
Streams each finished card's display URL as it's painted so the deck-view grid
|
| 863 |
+
can fill in card-by-card. paint_phase mutates `deck` in this worker; we scan it
|
| 864 |
+
each tick and ship the ready cards' URLs (the saved files are served by the main
|
| 865 |
+
process). Yields ('paint', {msg, cards:[{num,url}], back}, frac) then ('done', deck)."""
|
| 866 |
from arcana.build import paint_phase
|
| 867 |
+
st = {"msg": "…", "frac": 0.0, "done": False, "result": None, "err": None}
|
| 868 |
+
|
| 869 |
+
def run():
|
| 870 |
+
try:
|
| 871 |
+
st["result"] = paint_phase(deck, lambda m, f: st.update(msg=m, frac=f))
|
| 872 |
+
except Exception as e:
|
| 873 |
+
st["err"] = e
|
| 874 |
+
finally:
|
| 875 |
+
st["done"] = True
|
| 876 |
+
|
| 877 |
+
def snapshot():
|
| 878 |
+
cards = [{"num": c["arcana_number"], "url": disp_url(c)}
|
| 879 |
+
for c in deck["cards"] if c.get("disp_path")]
|
| 880 |
+
return {"msg": st["msg"], "cards": cards,
|
| 881 |
+
"back": url_for(deck.get("back_disp") or deck.get("back_path"))}
|
| 882 |
+
|
| 883 |
+
threading.Thread(target=run, daemon=True).start()
|
| 884 |
+
while not st["done"]:
|
| 885 |
+
_write_art_status(deck, done=False) # live status the deck view polls
|
| 886 |
+
yield ("paint", snapshot(), min(st["frac"], 0.99))
|
| 887 |
+
time.sleep(0.4)
|
| 888 |
+
if st["err"] or st["result"] is None:
|
| 889 |
+
_write_art_status(deck, done=True) # stop the poller even on failure
|
| 890 |
+
why = f"{type(st['err']).__name__}: {st['err']}" if st["err"] else "no result"
|
| 891 |
+
yield ("error", why[:300], 0.0); return
|
| 892 |
+
_write_art_status(deck, done=True)
|
| 893 |
+
yield ("paint", snapshot(), 1.0)
|
| 894 |
+
yield ("done", st["result"], 1.0)
|
| 895 |
|
| 896 |
|
| 897 |
def toggle_custom(style):
|
|
|
|
| 906 |
|
| 907 |
|
| 908 |
def open_picked(deck_id, mode):
|
| 909 |
+
"""Outputs: deck_state, vd_deck, vd_html, download_btn, *pages, read_btn, view_note.
|
| 910 |
+
Opening a (complete) deck always re-enables Read/Download — in case a prior
|
| 911 |
+
conjure was interrupted mid-paint and left them greyed out."""
|
| 912 |
+
read_on = gr.update(interactive=True, value="✦ Read this deck")
|
| 913 |
deck = load_deck(deck_id) if deck_id else None
|
| 914 |
if not deck:
|
| 915 |
+
return (gr.update(), gr.update(), gr.update(), gr.update(), *show("pick"),
|
| 916 |
+
gr.update(), gr.update())
|
| 917 |
if mode == "view":
|
| 918 |
return (gr.update(), deck, deck_iframe(deck, _next_nonce()),
|
| 919 |
+
gr.update(value=zip_deck(deck), interactive=True), *show("view"),
|
| 920 |
+
read_on, "")
|
| 921 |
# read mode → straight to the reading room
|
| 922 |
+
return (deck, gr.update(), gr.update(), gr.update(), *show("reading"),
|
| 923 |
+
read_on, "")
|
| 924 |
|
| 925 |
|
| 926 |
def read_this_deck(deck):
|
|
|
|
| 1032 |
border-radius:6px; overflow:hidden; }
|
| 1033 |
.gen-bar > div { height:100%; background:linear-gradient(90deg,#9a7b2e,#d8b15a);
|
| 1034 |
transition:width .35s ease; }
|
| 1035 |
+
/* progressive conjure grid (deck view fills in as names/desc/art arrive) */
|
| 1036 |
+
.cj-wrap { padding:14px 8px 26px; }
|
| 1037 |
+
.cj-head { text-align:center; margin-bottom:18px; }
|
| 1038 |
+
.cj-head h2 { font-family:'Cinzel Decorative',serif; color:#d8b15a !important;
|
| 1039 |
+
font-size:2.3rem !important; margin:12px 0 6px; }
|
| 1040 |
+
.cj-head .gen-sub { color:#9a8f70 !important; font-size:1.45rem !important;
|
| 1041 |
+
font-style:italic; }
|
| 1042 |
+
.cj-head .gen-bar { max-width:440px; }
|
| 1043 |
+
.cj-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(150px,1fr));
|
| 1044 |
+
gap:14px; max-width:1180px; margin:0 auto; }
|
| 1045 |
+
.cj-card { background:rgba(20,16,30,.55); border:1px solid rgba(216,177,90,.20);
|
| 1046 |
+
border-radius:10px; overflow:hidden; display:flex; flex-direction:column;
|
| 1047 |
+
opacity:.55; transition:opacity .5s ease, border-color .5s ease; }
|
| 1048 |
+
.cj-card.cj-lit { opacity:1; border-color:rgba(216,177,90,.5); }
|
| 1049 |
+
.cj-art { aspect-ratio:3/5; position:relative; background:rgba(216,177,90,.04); }
|
| 1050 |
+
.cj-art img { width:100%; height:100%; object-fit:cover; display:block;
|
| 1051 |
+
animation:cjfade .6s ease; }
|
| 1052 |
+
@keyframes cjfade { from { opacity:0; } to { opacity:1; } }
|
| 1053 |
+
.cj-shim { position:absolute; inset:0; background:linear-gradient(100deg,
|
| 1054 |
+
rgba(216,177,90,.05) 30%, rgba(216,177,90,.16) 50%, rgba(216,177,90,.05) 70%);
|
| 1055 |
+
background-size:220% 100%; animation:cjshim 1.4s linear infinite; }
|
| 1056 |
+
@keyframes cjshim { to { background-position:-220% 0; } }
|
| 1057 |
+
.cj-meta { padding:8px 10px 11px; }
|
| 1058 |
+
.cj-arc { display:block; color:#9a8f70 !important; font-size:1.0rem !important;
|
| 1059 |
+
letter-spacing:.04em; text-transform:uppercase; }
|
| 1060 |
+
.cj-name { display:block; font-family:'Cinzel Decorative',serif;
|
| 1061 |
+
color:#e9d9a8 !important; font-size:1.45rem !important; line-height:1.2; margin-top:2px; }
|
| 1062 |
+
.cj-desc { color:#bcae86 !important; font-size:1.12rem !important; font-style:italic;
|
| 1063 |
+
line-height:1.35 !important; margin:5px 0 0 !important; }
|
| 1064 |
+
/* suppress Gradio's pulsing blue/orange "generating" border-box on components
|
| 1065 |
+
while an event streams (we have our own loaders) */
|
| 1066 |
+
.gradio-container .generating,
|
| 1067 |
+
.gradio-container .block.generating,
|
| 1068 |
+
.gradio-container *.generating {
|
| 1069 |
+
border-color: transparent !important; box-shadow: none !important;
|
| 1070 |
+
animation: none !important; }
|
| 1071 |
+
.gradio-container .generating::before, .gradio-container .generating::after {
|
| 1072 |
+
display: none !important; animation: none !important; }
|
| 1073 |
#chips { flex-wrap:wrap !important; gap:6px !important; justify-content:center; }
|
| 1074 |
#chips .secondary { border-radius:999px !important; font-size:1.7rem !important; padding:8px 20px !important; }
|
| 1075 |
#deck img, #preview img { border-radius:8px; box-shadow:0 6px 20px rgba(0,0,0,.55); }
|
|
|
|
| 1102 |
.syn-load { font-style:italic; color:#cdbf93; font-size:2rem; text-align:center; padding:10px; }
|
| 1103 |
/* word fade-in (§2) — each word eases in on a staggered delay */
|
| 1104 |
.fw { opacity:0; display:inline; animation:fadeWord .7s ease forwards; }
|
| 1105 |
+
.fwb { font-weight:700; color:#e9d2a0; } /* Nemotron's **bold** as emphasis */
|
| 1106 |
+
.fwi { font-style:italic; }
|
| 1107 |
@keyframes fadeWord { from { opacity:0; } to { opacity:1; } }
|
| 1108 |
/* input contrast — light text on dark fields, never yellow-on-white (§4) */
|
| 1109 |
.gradio-container textarea, .gradio-container input, .gradio-container select,
|
|
|
|
| 1118 |
background: rgba(243,230,196,.85) !important; border:1px solid var(--gold) !important;
|
| 1119 |
border-radius:4px !important; appearance:auto !important; -webkit-appearance:checkbox !important;
|
| 1120 |
opacity:1 !important; cursor:pointer; }
|
| 1121 |
+
/* "Allow reversals": nudge right + tighter wrap between the two words */
|
| 1122 |
+
#rev-box { margin-left:10px !important; }
|
| 1123 |
+
#rev-box label, #rev-box label span { line-height:1.12 !important; }
|
| 1124 |
/* dropdown popup + options — never yellow-on-white (§5) */
|
| 1125 |
.gradio-container ul[role="listbox"], .gradio-container .options, .gradio-container .option,
|
| 1126 |
.gradio-container li[role="option"], .gradio-container li.item, .gradio-container [class*="options"] {
|
|
|
|
| 1245 |
placeholder="Name anything to build a tarot deck from — a field, a world, a "
|
| 1246 |
"feeling… e.g. Greek mythology, the deep sea, jazz, office life",
|
| 1247 |
show_label=False, value="", max_lines=1)
|
| 1248 |
+
gr.Markdown("*…or tap an example:*", elem_classes=["status"])
|
|
|
|
| 1249 |
with gr.Row(elem_id="chips"):
|
| 1250 |
chips = [gr.Button(t, size="sm", elem_classes=["secondary"]) for t in EXAMPLE_THEMES]
|
| 1251 |
+
# full-width dropdown; the custom-style box unfurls BELOW it (stacked),
|
| 1252 |
+
# and only when "Custom…" is chosen — hidden by default so it never shows
|
| 1253 |
+
# spuriously (e.g. during a conjure).
|
| 1254 |
+
style = gr.Dropdown(STYLE_CHOICES, value="rider-waite-smith",
|
| 1255 |
+
label="Visual style")
|
| 1256 |
+
with gr.Column(visible=False) as custom_box:
|
| 1257 |
+
custom = gr.Textbox(label="Describe your style",
|
| 1258 |
+
placeholder=CUSTOM_PLACEHOLDER, max_lines=1)
|
| 1259 |
conjure = gr.Button("✦ Conjure the deck", elem_classes=["big-btn"])
|
| 1260 |
gen_status = gr.Markdown("", elem_classes=["status"])
|
| 1261 |
gen_back = gr.Button("↩ Back", scale=1, elem_classes=["secondary"])
|
|
|
|
| 1279 |
spread = gr.Dropdown(SPREAD_CHOICES, value="three", label="Spread",
|
| 1280 |
scale=3, min_width=260)
|
| 1281 |
reversals = gr.Checkbox(value=True, label="Allow reversals",
|
| 1282 |
+
scale=2, min_width=220, elem_id="rev-box")
|
| 1283 |
draw = gr.Button("✦ Draw the cards", elem_classes=["big-btn"])
|
| 1284 |
stage = gr.HTML("<div class='spread-empty'>Pose your question, then draw.</div>",
|
| 1285 |
elem_id="stage")
|
| 1286 |
+
next_btn = gr.Button("Next card ▸", visible=False, elem_classes=["big-btn"],
|
| 1287 |
+
elem_id="next-btn")
|
| 1288 |
+
stage_done = gr.HTML("", elem_id="stage-done") # read-history, below Next
|
| 1289 |
with gr.Row():
|
| 1290 |
save_in_read = gr.Button("✦ Save this deck", scale=1, elem_classes=["secondary"])
|
| 1291 |
read_back = gr.Button("↩ Back to start", scale=1, elem_classes=["secondary"])
|
|
|
|
| 1324 |
style.change(toggle_custom, style, custom_box)
|
| 1325 |
# example theme chips — one click sets the theme AND conjures it from
|
| 1326 |
# scratch (the examples ARE live demos: every card is freshly generated).
|
| 1327 |
+
# example chips just FILL the theme box (press Conjure to start) — they no
|
| 1328 |
+
# longer auto-conjure
|
| 1329 |
for chip, t in zip(chips, EXAMPLE_THEMES):
|
| 1330 |
+
chip.click(lambda t=t: (t, f"“{t}” — press ✦ Conjure to begin."),
|
| 1331 |
+
None, [theme, gen_status])
|
|
|
|
| 1332 |
|
| 1333 |
# generate → opens the new deck in the Deck-View/Overhead browser (pg_view)
|
| 1334 |
conjure.click(do_conjure, [theme, style, custom],
|
| 1335 |
+
[gen_status, deck_state, vd_deck, vd_html, download_btn, *pages,
|
| 1336 |
+
read_btn, view_note])
|
| 1337 |
|
| 1338 |
# pick → open
|
| 1339 |
pick_refresh.click(lambda: gr.update(choices=deck_choices()), None, pick_dd)
|
| 1340 |
pick_open.click(open_picked, [pick_dd, pick_mode],
|
| 1341 |
+
[deck_state, vd_deck, vd_html, download_btn, *pages,
|
| 1342 |
+
read_btn, view_note])
|
| 1343 |
read_btn.click(read_this_deck, vd_deck, [deck_state, *pages])
|
| 1344 |
|
| 1345 |
# reading
|
| 1346 |
draw.click(do_draw, [deck_state, question, spread, reversals],
|
| 1347 |
+
[stage, stage_done, reading_sess, next_btn])
|
| 1348 |
+
next_btn.click(do_next, reading_sess, [stage, stage_done, reading_sess, next_btn])
|
| 1349 |
save_in_read.click(save_current, deck_state, save_note)
|
| 1350 |
|
| 1351 |
# pages are created visible=True so gallery components mount cleanly
|
|
|
|
| 1385 |
_mt_theme = gr.Textbox("Greek mythology", visible=False)
|
| 1386 |
_mt_out = gr.Textbox(visible=False)
|
| 1387 |
gr.Button(visible=False).click(_gpu_maptest, _mt_theme, _mt_out, api_name="maptest")
|
| 1388 |
+
_md_theme = gr.Textbox("Greek mythology", visible=False)
|
| 1389 |
+
_md_out = gr.Textbox(visible=False)
|
| 1390 |
+
gr.Button(visible=False).click(_gpu_mapdiag, _md_theme, _md_out, api_name="mapdiag")
|
| 1391 |
+
_cc_out = gr.Textbox(visible=False)
|
| 1392 |
+
gr.Button(visible=False).click(_concurrency_probe, None, _cc_out, api_name="concurrency")
|
| 1393 |
+
|
| 1394 |
+
def _imgdiag():
|
| 1395 |
+
"""Run the FLUX CPU preload and surface any error (the startup thread
|
| 1396 |
+
swallows it). CPU-only construct + download; no GPU needed."""
|
| 1397 |
+
import time as _t, traceback
|
| 1398 |
+
from arcana import gpu_models as _gm
|
| 1399 |
+
t = _t.time()
|
| 1400 |
+
try:
|
| 1401 |
+
p = _gm.preload_pipe()
|
| 1402 |
+
return f"preload OK in {_t.time()-t:.0f}s: {type(p).__name__} (model={_gm.IMAGE_MODEL})"
|
| 1403 |
+
except Exception as e:
|
| 1404 |
+
return (f"preload FAILED after {_t.time()-t:.0f}s (model={_gm.IMAGE_MODEL}): "
|
| 1405 |
+
f"{type(e).__name__}: {e}\n\n" + traceback.format_exc()[-1400:])
|
| 1406 |
+
_id_out = gr.Textbox(visible=False)
|
| 1407 |
+
gr.Button(visible=False).click(_imgdiag, None, _id_out, api_name="imgdiag")
|
| 1408 |
+
|
| 1409 |
+
def _timings():
|
| 1410 |
+
"""Recent live-pipeline stage timings (most recent last). Reads the
|
| 1411 |
+
shared JSONL the @spaces.GPU workers append to, so it reflects NATURAL
|
| 1412 |
+
runs, not just diagnostics. Also grep the Space logs for '[TIMING]'."""
|
| 1413 |
+
from arcana.timing import recent
|
| 1414 |
+
rows = recent(50)
|
| 1415 |
+
if not rows:
|
| 1416 |
+
return "no timings recorded yet — conjure or read a deck first."
|
| 1417 |
+
out = []
|
| 1418 |
+
for r in rows:
|
| 1419 |
+
extra = " ".join(f"{k}={v}" for k, v in r.items()
|
| 1420 |
+
if k not in ("ts", "stage", "secs"))
|
| 1421 |
+
out.append(f"{r.get('stage',''):<22} {r.get('secs',0):>7.1f}s {extra}")
|
| 1422 |
+
return "\n".join(out)
|
| 1423 |
+
_tm_out = gr.Textbox(visible=False)
|
| 1424 |
+
gr.Button(visible=False).click(_timings, None, _tm_out, api_name="timings")
|
| 1425 |
+
_lt_theme = gr.Textbox("Greek mythology", visible=False)
|
| 1426 |
+
_lt_out = gr.Textbox(visible=False)
|
| 1427 |
+
gr.Button(visible=False).click(_gpu_loretest, _lt_theme, _lt_out, api_name="loretest")
|
| 1428 |
+
_st_theme = gr.Textbox("Greek mythology", visible=False)
|
| 1429 |
+
_st_out = gr.Textbox(visible=False)
|
| 1430 |
+
gr.Button(visible=False).click(_gpu_scribetest, _st_theme, _st_out, api_name="scribetest")
|
| 1431 |
|
| 1432 |
return demo
|
| 1433 |
|
arcana/build.py
CHANGED
|
@@ -16,13 +16,15 @@ import os
|
|
| 16 |
import time
|
| 17 |
from typing import Callable
|
| 18 |
|
|
|
|
| 19 |
from .archetypes import ROMAN_BY_NUMBER
|
| 20 |
from .compositor import compose_card
|
| 21 |
from .designer import design_deck
|
| 22 |
from .imagegen import get_imagegen
|
| 23 |
from .llm import get_llm
|
| 24 |
-
from .loremaster import refine_deck
|
| 25 |
from .styles import resolve_style
|
|
|
|
| 26 |
|
| 27 |
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 28 |
DECKS_DIR = os.path.join(ROOT, "decks")
|
|
@@ -101,13 +103,13 @@ def _back_prompt(style_suffix: str) -> str:
|
|
| 101 |
|
| 102 |
|
| 103 |
# ------------------------------------------------------------------ build
|
| 104 |
-
def
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
"""
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
def emit(msg, frac):
|
| 112 |
if progress:
|
| 113 |
progress(msg, frac)
|
|
@@ -118,16 +120,10 @@ def design_phase(theme: str, visual_style: str = "rider-waite-smith",
|
|
| 118 |
os.makedirs(deck_dir(deck_id), exist_ok=True)
|
| 119 |
seed_base = _seed_base(theme, style_id)
|
| 120 |
|
| 121 |
-
llm = get_llm()
|
| 122 |
emit("Consulting the deck designer…", 0.05)
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
try:
|
| 127 |
-
deck = refine_deck(deck, llm=llm)
|
| 128 |
-
except Exception:
|
| 129 |
-
pass # keep the designer's draft meanings if refinement hiccups
|
| 130 |
-
|
| 131 |
deck.update({
|
| 132 |
"deck_id": deck_id, "theme": theme, "visual_style": style_id,
|
| 133 |
"style_suffix": style_suffix, "seed_base": seed_base,
|
|
@@ -135,33 +131,63 @@ def design_phase(theme: str, visual_style: str = "rider-waite-smith",
|
|
| 135 |
return deck
|
| 136 |
|
| 137 |
|
| 138 |
-
def
|
| 139 |
-
|
| 140 |
-
|
|
|
|
|
|
|
|
|
|
| 141 |
def emit(msg, frac):
|
| 142 |
if progress:
|
| 143 |
progress(msg, frac)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
deck_id = deck["deck_id"]
|
| 146 |
out_dir = deck_dir(deck_id)
|
| 147 |
os.makedirs(out_dir, exist_ok=True)
|
| 148 |
seed_base = deck["seed_base"]
|
| 149 |
ig = get_imagegen(deck_style=deck.get("style_suffix"))
|
| 150 |
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
|
|
|
|
|
|
| 163 |
num = c["arcana_number"]
|
| 164 |
-
|
|
|
|
| 165 |
seed = seed_base + num
|
| 166 |
art = ig.generate(c["art_prompt"], seed=seed)
|
| 167 |
card = compose_card(art, c["concept"], ROMAN_BY_NUMBER[num])
|
|
@@ -172,11 +198,19 @@ def paint_phase(deck: dict, progress: Progress | None = None) -> dict:
|
|
| 172 |
c["art_path"] = os.path.relpath(path, ROOT)
|
| 173 |
c["disp_path"] = save_display(card, deck_id, base)
|
| 174 |
c["full_path"] = save_full(card, deck_id, base)
|
| 175 |
-
|
| 176 |
-
|
| 177 |
return deck
|
| 178 |
|
| 179 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
def build_deck(theme: str, visual_style: str = "rider-waite-smith",
|
| 181 |
custom_style: str | None = None,
|
| 182 |
progress: Progress | None = None) -> dict:
|
|
|
|
| 16 |
import time
|
| 17 |
from typing import Callable
|
| 18 |
|
| 19 |
+
from . import designer as _designer
|
| 20 |
from .archetypes import ROMAN_BY_NUMBER
|
| 21 |
from .compositor import compose_card
|
| 22 |
from .designer import design_deck
|
| 23 |
from .imagegen import get_imagegen
|
| 24 |
from .llm import get_llm
|
| 25 |
+
from .loremaster import refine_deck, scribe_deck
|
| 26 |
from .styles import resolve_style
|
| 27 |
+
from .timing import clock, record
|
| 28 |
|
| 29 |
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 30 |
DECKS_DIR = os.path.join(ROOT, "decks")
|
|
|
|
| 103 |
|
| 104 |
|
| 105 |
# ------------------------------------------------------------------ build
|
| 106 |
+
def map_phase(theme: str, visual_style: str = "rider-waite-smith",
|
| 107 |
+
custom_style: str | None = None,
|
| 108 |
+
progress: Progress | None = None) -> dict:
|
| 109 |
+
"""Mapping ONLY (the 30B designer): theme -> 22 archetype→concept mappings with
|
| 110 |
+
justifications, no meanings/art/images yet. Split from the loremaster so the UI
|
| 111 |
+
can reveal the card NAMES the instant the mapping lands (the loremaster's
|
| 112 |
+
meanings + art briefs follow in the same GPU call, no model reload)."""
|
| 113 |
def emit(msg, frac):
|
| 114 |
if progress:
|
| 115 |
progress(msg, frac)
|
|
|
|
| 120 |
os.makedirs(deck_dir(deck_id), exist_ok=True)
|
| 121 |
seed_base = _seed_base(theme, style_id)
|
| 122 |
|
|
|
|
| 123 |
emit("Consulting the deck designer…", 0.05)
|
| 124 |
+
with clock("mapping") as c:
|
| 125 |
+
deck = design_deck(theme, llm=get_llm())
|
| 126 |
+
c.extra = {"attempts": getattr(_designer, "LAST_ATTEMPTS", "?")}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
deck.update({
|
| 128 |
"deck_id": deck_id, "theme": theme, "visual_style": style_id,
|
| 129 |
"style_suffix": style_suffix, "seed_base": seed_base,
|
|
|
|
| 131 |
return deck
|
| 132 |
|
| 133 |
|
| 134 |
+
def scribe_phase(deck: dict, subset: list[int] | None = None,
|
| 135 |
+
progress: Progress | None = None) -> dict:
|
| 136 |
+
"""Scribe: GENERATE meaning + art_prompt for the cards (default all 22; `subset`
|
| 137 |
+
for parallel chunking). MANDATORY — the designer outputs only the mapping, so the
|
| 138 |
+
meanings AND art come solely from here; a failure surfaces (scribe self-repairs
|
| 139 |
+
once). Mutates and returns the deck dict."""
|
| 140 |
def emit(msg, frac):
|
| 141 |
if progress:
|
| 142 |
progress(msg, frac)
|
| 143 |
+
emit("The scribe writes the lore and the art…", 0.10)
|
| 144 |
+
with clock("scribe", {"n": len(subset) if subset is not None else 22}):
|
| 145 |
+
return scribe_deck(deck, subset=subset, llm=get_llm())
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
# back-compat alias
|
| 149 |
+
def lore_phase(deck: dict, progress: Progress | None = None) -> dict:
|
| 150 |
+
return scribe_phase(deck, progress=progress)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def design_phase(theme: str, visual_style: str = "rider-waite-smith",
|
| 154 |
+
custom_style: str | None = None,
|
| 155 |
+
progress: Progress | None = None) -> dict:
|
| 156 |
+
"""LLM phase (mapping + scribe) in one go — for non-streaming callers.
|
| 157 |
+
Returns a deck dict with concepts + meanings + art briefs but NO images yet."""
|
| 158 |
+
deck = map_phase(theme, visual_style, custom_style, progress=progress)
|
| 159 |
+
return scribe_phase(deck, progress=progress)
|
| 160 |
|
| 161 |
+
|
| 162 |
+
def paint_subset(deck: dict, nums: list[int], paint_back: bool = False,
|
| 163 |
+
progress: Progress | None = None) -> dict:
|
| 164 |
+
"""Paint a SUBSET of the deck's cards (by arcana number), saving each card's PNG +
|
| 165 |
+
display WEBPs and filling its image paths. `paint_back` also presses the deck-back.
|
| 166 |
+
Designed to run in its own @spaces.GPU worker so the 22 cards fan out across GPUs;
|
| 167 |
+
the saved files land on the shared disk for the main process to serve."""
|
| 168 |
deck_id = deck["deck_id"]
|
| 169 |
out_dir = deck_dir(deck_id)
|
| 170 |
os.makedirs(out_dir, exist_ok=True)
|
| 171 |
seed_base = deck["seed_base"]
|
| 172 |
ig = get_imagegen(deck_style=deck.get("style_suffix"))
|
| 173 |
|
| 174 |
+
if paint_back:
|
| 175 |
+
back_path = os.path.join(out_dir, "back.png")
|
| 176 |
+
try:
|
| 177 |
+
with clock("paint_back"):
|
| 178 |
+
back = ig.generate(_back_prompt(deck.get("style_suffix") or ""), seed=seed_base - 1)
|
| 179 |
+
back.save(back_path)
|
| 180 |
+
deck["back_path"] = os.path.relpath(back_path, ROOT)
|
| 181 |
+
deck["back_disp"] = save_display(back, deck_id, "back")
|
| 182 |
+
except Exception:
|
| 183 |
+
deck["back_path"] = deck["back_disp"] = None
|
| 184 |
+
|
| 185 |
+
nset = set(nums)
|
| 186 |
+
t0 = time.time()
|
| 187 |
+
for c in deck["cards"]:
|
| 188 |
num = c["arcana_number"]
|
| 189 |
+
if num not in nset:
|
| 190 |
+
continue
|
| 191 |
seed = seed_base + num
|
| 192 |
art = ig.generate(c["art_prompt"], seed=seed)
|
| 193 |
card = compose_card(art, c["concept"], ROMAN_BY_NUMBER[num])
|
|
|
|
| 198 |
c["art_path"] = os.path.relpath(path, ROOT)
|
| 199 |
c["disp_path"] = save_display(card, deck_id, base)
|
| 200 |
c["full_path"] = save_full(card, deck_id, base)
|
| 201 |
+
dt = time.time() - t0
|
| 202 |
+
record("paint_chunk", dt, {"n": len(nums), "per_card": round(dt / max(len(nums), 1), 1)})
|
| 203 |
return deck
|
| 204 |
|
| 205 |
|
| 206 |
+
def paint_phase(deck: dict, progress: Progress | None = None) -> dict:
|
| 207 |
+
"""Image phase (FLUX), whole deck in one call — for non-streaming callers."""
|
| 208 |
+
if progress:
|
| 209 |
+
progress("Painting the deck…", 0.15)
|
| 210 |
+
nums = [c["arcana_number"] for c in deck["cards"]]
|
| 211 |
+
return paint_subset(deck, nums, paint_back=True, progress=progress)
|
| 212 |
+
|
| 213 |
+
|
| 214 |
def build_deck(theme: str, visual_style: str = "rider-waite-smith",
|
| 215 |
custom_style: str | None = None,
|
| 216 |
progress: Progress | None = None) -> dict:
|
arcana/compositor.py
CHANGED
|
@@ -69,11 +69,13 @@ def compose_card(art: Image.Image, concept: str, roman: str,
|
|
| 69 |
card.paste(frame_overlay(w, h), (0, 0), frame_overlay(w, h))
|
| 70 |
draw = ImageDraw.Draw(card)
|
| 71 |
|
| 72 |
-
# roman numeral in the top plate
|
| 73 |
-
nf = font(
|
| 74 |
nw = draw.textlength(roman, font=nf)
|
| 75 |
-
|
| 76 |
-
|
|
|
|
|
|
|
| 77 |
|
| 78 |
# concept name in the bottom band (auto-fit, up to 2 lines), warm gold
|
| 79 |
cf, lines = _fit_lines(draw, concept, "Cinzel", CART_PLATE, max_lines=2, start=44)
|
|
|
|
| 69 |
card.paste(frame_overlay(w, h), (0, 0), frame_overlay(w, h))
|
| 70 |
draw = ImageDraw.Draw(card)
|
| 71 |
|
| 72 |
+
# roman numeral centered in the top-left compartment (ink plate → legible)
|
| 73 |
+
nf = font(30, weight=700)
|
| 74 |
nw = draw.textlength(roman, font=nf)
|
| 75 |
+
bb = nf.getbbox("X")
|
| 76 |
+
nx = (NUM_PLATE[0] + NUM_PLATE[2]) / 2 - nw / 2
|
| 77 |
+
ny = (NUM_PLATE[1] + NUM_PLATE[3]) / 2 - (bb[3] - bb[1]) / 2 - bb[1]
|
| 78 |
+
draw.text((nx, ny), roman, font=nf, fill=GOLD)
|
| 79 |
|
| 80 |
# concept name in the bottom band (auto-fit, up to 2 lines), warm gold
|
| 81 |
cf, lines = _fit_lines(draw, concept, "Cinzel", CART_PLATE, max_lines=2, start=44)
|
arcana/designer.py
CHANGED
|
@@ -16,14 +16,44 @@ from .archetypes import MAJOR_ARCANA, NAME_BY_NUMBER
|
|
| 16 |
from .llm import LLM, get_llm
|
| 17 |
from .prompts import designer_system_prompt, designer_user_prompt
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
|
| 23 |
class DeckError(ValueError):
|
| 24 |
"""The model's output could not be coerced into a valid 22-card deck."""
|
| 25 |
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
# ------------------------------------------------------------------ json parse
|
| 28 |
def extract_json(text: str) -> dict:
|
| 29 |
"""Pull the first JSON object out of a model reply, tolerating fences and
|
|
@@ -102,17 +132,37 @@ def validate_deck(data: dict, theme: str) -> dict:
|
|
| 102 |
if missing:
|
| 103 |
raise DeckError(f"missing arcana numbers: {missing}")
|
| 104 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
# normalize: canonical names/order, trimmed strings, keep concept dupes visible
|
| 106 |
norm_cards = []
|
| 107 |
seen_concepts: dict[str, int] = {}
|
| 108 |
for a in MAJOR_ARCANA:
|
| 109 |
c = by_number[a.number]
|
| 110 |
concept = c["concept"].strip()
|
| 111 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
if key in seen_concepts:
|
| 113 |
raise DeckError(
|
| 114 |
-
f"concept {concept!r}
|
| 115 |
-
f"{seen_concepts[key]}"
|
| 116 |
)
|
| 117 |
seen_concepts[key] = a.number
|
| 118 |
norm_cards.append({
|
|
@@ -120,9 +170,11 @@ def validate_deck(data: dict, theme: str) -> dict:
|
|
| 120 |
"arcana_name": a.name,
|
| 121 |
"concept": concept,
|
| 122 |
"justification": c["justification"].strip(),
|
| 123 |
-
|
| 124 |
-
"
|
| 125 |
-
"
|
|
|
|
|
|
|
| 126 |
"art_path": None,
|
| 127 |
})
|
| 128 |
|
|
@@ -152,24 +204,27 @@ def design_deck(theme: str, llm: LLM | None = None) -> dict:
|
|
| 152 |
# dropped card) are largely random per draw, so re-rolling beats trying to
|
| 153 |
# surgically repair a bad draw (the repair often returns a partial deck).
|
| 154 |
import os as _os
|
|
|
|
| 155 |
attempts = int(_os.environ.get("DESIGN_ATTEMPTS", "4"))
|
| 156 |
first_err = None
|
| 157 |
-
for
|
| 158 |
try:
|
| 159 |
-
raw = llm.complete(system, user, json_mode=True)
|
| 160 |
-
|
|
|
|
|
|
|
| 161 |
except DeckError as e:
|
| 162 |
first_err = first_err or e
|
|
|
|
| 163 |
|
| 164 |
# last resort: one targeted repair, told exactly what was wrong
|
| 165 |
repair = (
|
| 166 |
f"{user}\n\nYour previous reply was rejected: {first_err}. "
|
| 167 |
"Return ONLY the corrected strict JSON object with exactly 22 cards "
|
| 168 |
-
"covering arcana 0 through 21, each with a DISTINCT non-empty concept
|
| 169 |
-
"
|
| 170 |
-
"No prose, no code fences."
|
| 171 |
)
|
| 172 |
-
raw2 = llm.complete(system, repair, json_mode=True)
|
| 173 |
try:
|
| 174 |
return validate_deck(extract_json(raw2), theme)
|
| 175 |
except DeckError as e:
|
|
|
|
| 16 |
from .llm import LLM, get_llm
|
| 17 |
from .prompts import designer_system_prompt, designer_user_prompt
|
| 18 |
|
| 19 |
+
LAST_ATTEMPTS = 0 # how many full generations design_deck used last run (diagnostic)
|
| 20 |
+
|
| 21 |
+
# The designer does ONLY the mapping now: concept + justification per archetype. The
|
| 22 |
+
# meanings AND art_prompt are invented downstream by the generative scribe (loremaster.
|
| 23 |
+
# scribe_deck), which is fanned out across parallel GPU workers. Keeping this generation
|
| 24 |
+
# tiny is what makes it fast and lets the heavy meaning/art work parallelize.
|
| 25 |
+
_CARD_FIELDS = ("concept", "justification")
|
| 26 |
+
|
| 27 |
+
# JSON-schema grammar for the stripped mapping. additionalProperties:false keeps the
|
| 28 |
+
# GBNF tight. Types only; validate_deck enforces 22-coverage / no-dupe / anti-echo.
|
| 29 |
+
DESIGNER_SCHEMA = {
|
| 30 |
+
"type": "object", "additionalProperties": False,
|
| 31 |
+
"required": ["theme", "style_suffix", "cards"],
|
| 32 |
+
"properties": {
|
| 33 |
+
"theme": {"type": "string"},
|
| 34 |
+
"style_suffix": {"type": "string"},
|
| 35 |
+
"cards": {"type": "array", "items": {
|
| 36 |
+
"type": "object", "additionalProperties": False,
|
| 37 |
+
"required": ["arcana_number", "concept", "justification"],
|
| 38 |
+
"properties": {
|
| 39 |
+
"arcana_number": {"type": "integer"},
|
| 40 |
+
"concept": {"type": "string"},
|
| 41 |
+
"justification": {"type": "string"}}}}}}
|
| 42 |
|
| 43 |
|
| 44 |
class DeckError(ValueError):
|
| 45 |
"""The model's output could not be coerced into a valid 22-card deck."""
|
| 46 |
|
| 47 |
|
| 48 |
+
def _concept_key(concept: str) -> str:
|
| 49 |
+
"""Normalized key for distinctness — paren/qualifier-insensitive, so "Frodo
|
| 50 |
+
Baggins" and "Frodo Baggins (Again)" / "Frodo Baggins II" collide as duplicates."""
|
| 51 |
+
s = re.sub(r"\s*[\(\[\{].*?[\)\]\}]", " ", concept) # drop bracketed parts
|
| 52 |
+
s = re.sub(r"\s+(again|redux|ii|iii|iv|2|3|the (?:second|elder|younger|next))\.?$",
|
| 53 |
+
"", s.strip(), flags=re.I) # drop trailing qualifiers
|
| 54 |
+
return re.sub(r"\s+", " ", s).strip().lower()
|
| 55 |
+
|
| 56 |
+
|
| 57 |
# ------------------------------------------------------------------ json parse
|
| 58 |
def extract_json(text: str) -> dict:
|
| 59 |
"""Pull the first JSON object out of a model reply, tolerating fences and
|
|
|
|
| 132 |
if missing:
|
| 133 |
raise DeckError(f"missing arcana numbers: {missing}")
|
| 134 |
|
| 135 |
+
# anti-echo guard: a grammar-constrained model occasionally degenerates into
|
| 136 |
+
# restating the ARCHETYPE NAMES as the concepts ("Death", "The Tower", ...)
|
| 137 |
+
# instead of mapping into the theme. That passes the distinct-non-empty check
|
| 138 |
+
# but is a non-mapping. Count concepts that just echo their arcana's own name
|
| 139 |
+
# (or any arcana name); 3+ means the draw collapsed — reject so design_deck
|
| 140 |
+
# re-rolls. A single coincidental match (a theme that legitimately contains a
|
| 141 |
+
# 'Tower') is tolerated.
|
| 142 |
+
def _norm(s: str) -> str:
|
| 143 |
+
return re.sub(r"^the\s+", "", s.strip().lower()).strip(" .'\"")
|
| 144 |
+
arcana_names = {_norm(a.name) for a in MAJOR_ARCANA}
|
| 145 |
+
echoes = sum(1 for n, c in by_number.items()
|
| 146 |
+
if _norm(c["concept"]) in arcana_names)
|
| 147 |
+
if echoes >= 3:
|
| 148 |
+
raise DeckError(f"archetype-echo: {echoes} concepts merely restate the "
|
| 149 |
+
"arcana names instead of mapping into the theme")
|
| 150 |
+
|
| 151 |
# normalize: canonical names/order, trimmed strings, keep concept dupes visible
|
| 152 |
norm_cards = []
|
| 153 |
seen_concepts: dict[str, int] = {}
|
| 154 |
for a in MAJOR_ARCANA:
|
| 155 |
c = by_number[a.number]
|
| 156 |
concept = c["concept"].strip()
|
| 157 |
+
if any(ch in concept for ch in "()[]{}"):
|
| 158 |
+
raise DeckError(
|
| 159 |
+
f"concept {concept!r} (arcana {a.number}) has brackets/qualifiers; "
|
| 160 |
+
"use a clean name"
|
| 161 |
+
)
|
| 162 |
+
key = _concept_key(concept) # normalized: paren/qualifier-insensitive
|
| 163 |
if key in seen_concepts:
|
| 164 |
raise DeckError(
|
| 165 |
+
f"concept {concept!r} not distinct from arcana {seen_concepts[key]}"
|
|
|
|
| 166 |
)
|
| 167 |
seen_concepts[key] = a.number
|
| 168 |
norm_cards.append({
|
|
|
|
| 170 |
"arcana_name": a.name,
|
| 171 |
"concept": concept,
|
| 172 |
"justification": c["justification"].strip(),
|
| 173 |
+
# invented downstream by the generative scribe (loremaster.scribe_deck)
|
| 174 |
+
"essence": "",
|
| 175 |
+
"upright_meaning": "",
|
| 176 |
+
"reversed_meaning": "",
|
| 177 |
+
"art_prompt": "",
|
| 178 |
"art_path": None,
|
| 179 |
})
|
| 180 |
|
|
|
|
| 204 |
# dropped card) are largely random per draw, so re-rolling beats trying to
|
| 205 |
# surgically repair a bad draw (the repair often returns a partial deck).
|
| 206 |
import os as _os
|
| 207 |
+
global LAST_ATTEMPTS
|
| 208 |
attempts = int(_os.environ.get("DESIGN_ATTEMPTS", "4"))
|
| 209 |
first_err = None
|
| 210 |
+
for _i in range(attempts):
|
| 211 |
try:
|
| 212 |
+
raw = llm.complete(system, user, json_mode=True, schema=DESIGNER_SCHEMA)
|
| 213 |
+
deck = validate_deck(extract_json(raw), theme)
|
| 214 |
+
LAST_ATTEMPTS = _i + 1
|
| 215 |
+
return deck
|
| 216 |
except DeckError as e:
|
| 217 |
first_err = first_err or e
|
| 218 |
+
LAST_ATTEMPTS = attempts + 1 # all attempts failed → fell through to repair
|
| 219 |
|
| 220 |
# last resort: one targeted repair, told exactly what was wrong
|
| 221 |
repair = (
|
| 222 |
f"{user}\n\nYour previous reply was rejected: {first_err}. "
|
| 223 |
"Return ONLY the corrected strict JSON object with exactly 22 cards "
|
| 224 |
+
"covering arcana 0 through 21, each with a DISTINCT non-empty concept "
|
| 225 |
+
"and a one-line justification. No prose, no code fences."
|
|
|
|
| 226 |
)
|
| 227 |
+
raw2 = llm.complete(system, repair, json_mode=True, schema=DESIGNER_SCHEMA)
|
| 228 |
try:
|
| 229 |
return validate_deck(extract_json(raw2), theme)
|
| 230 |
except DeckError as e:
|
arcana/frame.py
CHANGED
|
@@ -26,7 +26,7 @@ INK = (18, 11, 30) # deep purple-black
|
|
| 26 |
PARCH = (243, 230, 196) # parchment text
|
| 27 |
|
| 28 |
BORDER = 26 # outer dark band thickness
|
| 29 |
-
NUM_PLATE = (
|
| 30 |
NAME_TOP = CARD_H - 196 # top of the name band
|
| 31 |
CART_PLATE = (BORDER + 10, NAME_TOP + 16, CARD_W - BORDER - 10, CARD_H - 24) # name text area
|
| 32 |
NAME_TEXT = (232, 200, 122) # warm gold for the name, to match the frame
|
|
@@ -72,8 +72,10 @@ def frame_overlay(w: int = CARD_W, h: int = CARD_H) -> Image.Image:
|
|
| 72 |
d.polygon([(cx, cy - 10), (cx + 10, cy), (cx, cy + 10), (cx - 10, cy)],
|
| 73 |
fill=(*GOLD, 255))
|
| 74 |
|
| 75 |
-
# top numeral
|
| 76 |
-
|
|
|
|
|
|
|
| 77 |
|
| 78 |
# bottom NAME BAND — a solid, opaque, full-width band that reaches the very
|
| 79 |
# bottom edge, so it covers (masks) any label the image model may have
|
|
|
|
| 26 |
PARCH = (243, 230, 196) # parchment text
|
| 27 |
|
| 28 |
BORDER = 26 # outer dark band thickness
|
| 29 |
+
NUM_PLATE = (BORDER + 4, BORDER + 4, BORDER + 96, BORDER + 72) # top-left numeral compartment
|
| 30 |
NAME_TOP = CARD_H - 196 # top of the name band
|
| 31 |
CART_PLATE = (BORDER + 10, NAME_TOP + 16, CARD_W - BORDER - 10, CARD_H - 24) # name text area
|
| 32 |
NAME_TEXT = (232, 200, 122) # warm gold for the name, to match the frame
|
|
|
|
| 72 |
d.polygon([(cx, cy - 10), (cx + 10, cy), (cx, cy + 10), (cx - 10, cy)],
|
| 73 |
fill=(*GOLD, 255))
|
| 74 |
|
| 75 |
+
# top-LEFT numeral compartment — same ink fill as the name band, gold border that
|
| 76 |
+
# blends with the card's frame (top/left edges sit on the inner gold rule)
|
| 77 |
+
d.rectangle(list(NUM_PLATE), fill=(*INK, 255))
|
| 78 |
+
d.rectangle(list(NUM_PLATE), outline=(*GOLD, 255), width=2)
|
| 79 |
|
| 80 |
# bottom NAME BAND — a solid, opaque, full-width band that reaches the very
|
| 81 |
# bottom edge, so it covers (masks) any label the image model may have
|
arcana/gpu_models.py
CHANGED
|
@@ -40,26 +40,33 @@ except Exception:
|
|
| 40 |
pass
|
| 41 |
|
| 42 |
# ---- model selection (env-overridable) ------------------------------------
|
| 43 |
-
#
|
| 44 |
-
#
|
| 45 |
-
#
|
| 46 |
-
#
|
| 47 |
-
#
|
| 48 |
-
#
|
| 49 |
-
#
|
|
|
|
|
|
|
| 50 |
_DEF_8B = ("bartowski/nvidia_Llama-3.1-Nemotron-Nano-8B-v1-GGUF",
|
| 51 |
"nvidia_Llama-3.1-Nemotron-Nano-8B-v1-Q4_K_M.gguf")
|
| 52 |
_DEF_30B = ("unsloth/Nemotron-3-Nano-30B-A3B-GGUF",
|
| 53 |
"Nemotron-3-Nano-30B-A3B-Q4_K_M.gguf")
|
| 54 |
-
|
| 55 |
-
|
|
|
|
| 56 |
MAP_N_CTX = int(os.environ.get("LOCAL_MAP_N_CTX", "16384"))
|
| 57 |
-
READ_REPO = os.environ.get("LOCAL_READ_REPO",
|
| 58 |
-
READ_FILE = os.environ.get("LOCAL_READ_FILE",
|
| 59 |
-
READ_N_CTX = int(os.environ.get("LOCAL_READ_N_CTX", "
|
| 60 |
|
| 61 |
-
#
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
_models: dict = {}
|
| 65 |
_pipe = None
|
|
@@ -96,19 +103,23 @@ def _get_llama(repo: str, fname: str, n_ctx: int):
|
|
| 96 |
hybrid-Mamba doesn't support FA) + a big n_batch to speed long-prompt eval (the
|
| 97 |
designer's few-shot mapping prompt)."""
|
| 98 |
if fname not in _models:
|
|
|
|
|
|
|
|
|
|
| 99 |
_preload_cuda()
|
| 100 |
from huggingface_hub import hf_hub_download
|
| 101 |
from llama_cpp import Llama
|
| 102 |
path = hf_hub_download(repo, fname, token=os.environ.get("HF_TOKEN"))
|
| 103 |
kwargs = dict(model_path=path, n_gpu_layers=-1, n_ctx=n_ctx,
|
| 104 |
n_batch=2048, verbose=False)
|
| 105 |
-
if not _is_nemotron3(fname): # FA works for Llama
|
| 106 |
kwargs["flash_attn"] = True
|
| 107 |
try:
|
| 108 |
_models[fname] = Llama(**kwargs)
|
| 109 |
except Exception:
|
| 110 |
kwargs.pop("flash_attn", None) # fall back if the wheel rejects it
|
| 111 |
_models[fname] = Llama(**kwargs)
|
|
|
|
| 112 |
return _models[fname]
|
| 113 |
|
| 114 |
|
|
@@ -117,6 +128,27 @@ def _is_nemotron3(fname: str) -> bool:
|
|
| 117 |
return "30b" in f or "nemotron-3-nano-3" in f
|
| 118 |
|
| 119 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
def _last_balanced_json(text: str) -> str:
|
| 121 |
"""Return the LAST top-level {...} block in text (the final answer after any
|
| 122 |
reasoning), else the whole text. Lets a reasoning model think first, then we
|
|
@@ -152,37 +184,25 @@ def _suppress_gen(llm, system: str, user: str, max_tokens: int, temperature: flo
|
|
| 152 |
|
| 153 |
|
| 154 |
def llama_map(system: str, user: str, max_tokens: int = 12000,
|
| 155 |
-
temperature: float = 0.
|
| 156 |
-
"""Structured JSON mapping
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
llm = _get_llama(MAP_REPO, MAP_FILE, MAP_N_CTX)
|
| 162 |
-
|
| 163 |
-
return _suppress_gen(llm, system, user, max_tokens, temperature)
|
| 164 |
-
out = llm.create_chat_completion(
|
| 165 |
-
messages=[{"role": "system", "content": "detailed thinking off\n\n" + system},
|
| 166 |
-
{"role": "user", "content": user}],
|
| 167 |
-
max_tokens=max_tokens, temperature=temperature)
|
| 168 |
-
return (out["choices"][0]["message"]["content"] or "").strip()
|
| 169 |
|
| 170 |
|
| 171 |
def llama_read(system: str, user: str, max_tokens: int = 9000,
|
| 172 |
temperature: float = 0.7) -> str:
|
| 173 |
-
"""Prose readings (
|
| 174 |
llm = _get_llama(READ_REPO, READ_FILE, READ_N_CTX)
|
| 175 |
-
|
| 176 |
-
return _suppress_gen(llm, system, user, max_tokens, temperature)
|
| 177 |
-
out = llm.create_chat_completion(
|
| 178 |
-
messages=[{"role": "system", "content": "detailed thinking off\n\n" + system},
|
| 179 |
-
{"role": "user", "content": user}],
|
| 180 |
-
max_tokens=max_tokens, temperature=temperature)
|
| 181 |
-
return (out["choices"][0]["message"]["content"] or "").strip()
|
| 182 |
|
| 183 |
|
| 184 |
def preload_pipe():
|
| 185 |
-
"""Construct the FLUX.2 pipeline ON CPU (the expensive from_pretrained: disk
|
| 186 |
read + build). Call at STARTUP (off the GPU clock) so each conjure only pays
|
| 187 |
the fast .to('cuda') move — not the full load. Safe to call outside @spaces.GPU
|
| 188 |
(no CUDA touched here; do NOT .to('cuda') here — that trips ZeroGPU emulation)."""
|
|
@@ -200,7 +220,14 @@ def preload_pipe():
|
|
| 200 |
|
| 201 |
def get_pipe():
|
| 202 |
"""Move the (CPU-preloaded) FLUX.2 pipeline to the GPU — call inside @spaces.GPU.
|
| 203 |
-
The slow construct already happened at startup, so this is just the transfer.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
pipe = preload_pipe()
|
| 205 |
pipe.to("cuda")
|
|
|
|
|
|
|
|
|
|
| 206 |
return pipe
|
|
|
|
| 40 |
pass
|
| 41 |
|
| 42 |
# ---- model selection (env-overridable) ------------------------------------
|
| 43 |
+
# SINGLE MODEL: Qwen3-14B for BOTH mapping and reading. We dropped Nemotron-3-30B
|
| 44 |
+
# (and the Nemotron prize): as a 3.5B-ACTIVE MoE at Q4 it was too literal-minded —
|
| 45 |
+
# it emitted tool calls instead of JSON, refused "divination", and went meta in the
|
| 46 |
+
# reader. Qwen3-14B is the same family as the original endpoint version (Qwen3-32B)
|
| 47 |
+
# at a safely ≤32B size; a plain capable instruct/reasoning model. It's a "thinking"
|
| 48 |
+
# model, so we append "/no_think" (exactly as the old endpoint build did) for direct
|
| 49 |
+
# output; JSON mapping is still grammar-constrained (response_format). One model,
|
| 50 |
+
# warmed once. Mapping (design), loremaster and reading run in separate @spaces.GPU
|
| 51 |
+
# calls; lore + FLUX run on two CONCURRENT GPUs so they never share 48GB.
|
| 52 |
_DEF_8B = ("bartowski/nvidia_Llama-3.1-Nemotron-Nano-8B-v1-GGUF",
|
| 53 |
"nvidia_Llama-3.1-Nemotron-Nano-8B-v1-Q4_K_M.gguf")
|
| 54 |
_DEF_30B = ("unsloth/Nemotron-3-Nano-30B-A3B-GGUF",
|
| 55 |
"Nemotron-3-Nano-30B-A3B-Q4_K_M.gguf")
|
| 56 |
+
_DEF_QWEN14 = ("unsloth/Qwen3-14B-GGUF", "Qwen3-14B-Q4_K_M.gguf")
|
| 57 |
+
MAP_REPO = os.environ.get("LOCAL_MAP_REPO", _DEF_QWEN14[0])
|
| 58 |
+
MAP_FILE = os.environ.get("LOCAL_MAP_FILE", _DEF_QWEN14[1])
|
| 59 |
MAP_N_CTX = int(os.environ.get("LOCAL_MAP_N_CTX", "16384"))
|
| 60 |
+
READ_REPO = os.environ.get("LOCAL_READ_REPO", _DEF_QWEN14[0])
|
| 61 |
+
READ_FILE = os.environ.get("LOCAL_READ_FILE", _DEF_QWEN14[1])
|
| 62 |
+
READ_N_CTX = int(os.environ.get("LOCAL_READ_N_CTX", "16384"))
|
| 63 |
|
| 64 |
+
# FLUX.2-klein-9B (distilled → 4 steps, guidance_scale=1.0) run LOCALLY via diffusers.
|
| 65 |
+
# Back to FLUX.2: the religious-imagery / quality drop we'd blamed on klein was really
|
| 66 |
+
# Nemotron's weak art prompts — FLUX.1 had the same bias on RWS-styled prompts. With
|
| 67 |
+
# Qwen3-14B art prompts + the no-religious guard in imagegen._style, klein is the better
|
| 68 |
+
# (newer, sharper) model. Gated but already accepted on this account.
|
| 69 |
+
IMAGE_MODEL = os.environ.get("LOCAL_IMAGE_MODEL", "black-forest-labs/FLUX.2-klein-9B")
|
| 70 |
|
| 71 |
_models: dict = {}
|
| 72 |
_pipe = None
|
|
|
|
| 103 |
hybrid-Mamba doesn't support FA) + a big n_batch to speed long-prompt eval (the
|
| 104 |
designer's few-shot mapping prompt)."""
|
| 105 |
if fname not in _models:
|
| 106 |
+
import time as _t
|
| 107 |
+
from .timing import record
|
| 108 |
+
t0 = _t.time()
|
| 109 |
_preload_cuda()
|
| 110 |
from huggingface_hub import hf_hub_download
|
| 111 |
from llama_cpp import Llama
|
| 112 |
path = hf_hub_download(repo, fname, token=os.environ.get("HF_TOKEN"))
|
| 113 |
kwargs = dict(model_path=path, n_gpu_layers=-1, n_ctx=n_ctx,
|
| 114 |
n_batch=2048, verbose=False)
|
| 115 |
+
if not _is_nemotron3(fname): # FA works for Llama/Qwen arch, not the Mamba hybrid
|
| 116 |
kwargs["flash_attn"] = True
|
| 117 |
try:
|
| 118 |
_models[fname] = Llama(**kwargs)
|
| 119 |
except Exception:
|
| 120 |
kwargs.pop("flash_attn", None) # fall back if the wheel rejects it
|
| 121 |
_models[fname] = Llama(**kwargs)
|
| 122 |
+
record("llm_load", _t.time() - t0, {"file": fname})
|
| 123 |
return _models[fname]
|
| 124 |
|
| 125 |
|
|
|
|
| 128 |
return "30b" in f or "nemotron-3-nano-3" in f
|
| 129 |
|
| 130 |
|
| 131 |
+
def _is_qwen3(fname: str) -> bool:
|
| 132 |
+
return "qwen3" in fname.lower()
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def _chat(llm, fname: str, system: str, user: str, max_tokens: int,
|
| 136 |
+
temperature: float, schema: dict | None) -> str:
|
| 137 |
+
"""One chat-completion call. Qwen3 is a thinking model — we append '/no_think'
|
| 138 |
+
(as the original endpoint build did) for a direct answer. A JSON schema, when
|
| 139 |
+
given, constrains generation to that exact shape via llama.cpp's grammar."""
|
| 140 |
+
if _is_qwen3(fname):
|
| 141 |
+
user = user.rstrip() + "\n/no_think"
|
| 142 |
+
kwargs = dict(
|
| 143 |
+
messages=[{"role": "system", "content": system},
|
| 144 |
+
{"role": "user", "content": user}],
|
| 145 |
+
max_tokens=max_tokens, temperature=temperature)
|
| 146 |
+
if schema is not None:
|
| 147 |
+
kwargs["response_format"] = {"type": "json_object", "schema": schema}
|
| 148 |
+
out = llm.create_chat_completion(**kwargs)
|
| 149 |
+
return (out["choices"][0]["message"]["content"] or "").strip()
|
| 150 |
+
|
| 151 |
+
|
| 152 |
def _last_balanced_json(text: str) -> str:
|
| 153 |
"""Return the LAST top-level {...} block in text (the final answer after any
|
| 154 |
reasoning), else the whole text. Lets a reasoning model think first, then we
|
|
|
|
| 184 |
|
| 185 |
|
| 186 |
def llama_map(system: str, user: str, max_tokens: int = 12000,
|
| 187 |
+
temperature: float = 0.6, schema: dict | None = None) -> str:
|
| 188 |
+
"""Structured JSON generation (mapping AND loremaster). When a JSON-SCHEMA is
|
| 189 |
+
given we constrain generation to that exact shape (response_format → GBNF
|
| 190 |
+
grammar): valid JSON from token 1, no preamble, no extra keys (callers set
|
| 191 |
+
additionalProperties:false). Each caller passes its OWN schema (designer ≠
|
| 192 |
+
loremaster). The reader's extract_json/validate cover any residual slip."""
|
| 193 |
llm = _get_llama(MAP_REPO, MAP_FILE, MAP_N_CTX)
|
| 194 |
+
return _chat(llm, MAP_FILE, system, user, max_tokens, temperature, schema)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
|
| 196 |
|
| 197 |
def llama_read(system: str, user: str, max_tokens: int = 9000,
|
| 198 |
temperature: float = 0.7) -> str:
|
| 199 |
+
"""Prose readings (free text, /no_think for direct Qwen3 output)."""
|
| 200 |
llm = _get_llama(READ_REPO, READ_FILE, READ_N_CTX)
|
| 201 |
+
return _chat(llm, READ_FILE, system, user, max_tokens, temperature, None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
|
| 203 |
|
| 204 |
def preload_pipe():
|
| 205 |
+
"""Construct the FLUX.2-klein pipeline ON CPU (the expensive from_pretrained: disk
|
| 206 |
read + build). Call at STARTUP (off the GPU clock) so each conjure only pays
|
| 207 |
the fast .to('cuda') move — not the full load. Safe to call outside @spaces.GPU
|
| 208 |
(no CUDA touched here; do NOT .to('cuda') here — that trips ZeroGPU emulation)."""
|
|
|
|
| 220 |
|
| 221 |
def get_pipe():
|
| 222 |
"""Move the (CPU-preloaded) FLUX.2 pipeline to the GPU — call inside @spaces.GPU.
|
| 223 |
+
The slow construct already happened at startup, so this is just the transfer.
|
| 224 |
+
Called once per card; only the first (cold) move is worth logging."""
|
| 225 |
+
import time as _t
|
| 226 |
+
from .timing import record
|
| 227 |
+
t0 = _t.time()
|
| 228 |
pipe = preload_pipe()
|
| 229 |
pipe.to("cuda")
|
| 230 |
+
dt = _t.time() - t0
|
| 231 |
+
if dt > 0.5: # skip the warm per-card no-op calls (keeps /timings + logs clean)
|
| 232 |
+
record("pipe_load+to_cuda", dt)
|
| 233 |
return pipe
|
arcana/imagegen.py
CHANGED
|
@@ -34,6 +34,14 @@ GLOBAL_STYLE = ("ornate symbolic tarot card illustration, centered subject, "
|
|
| 34 |
"cohesive color palette, painterly, no text, no words, no border, "
|
| 35 |
"no frame, full-bleed artwork")
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
@runtime_checkable
|
| 39 |
class ImageGen(Protocol):
|
|
@@ -47,6 +55,7 @@ def _style(prompt: str, deck_style: str | None) -> str:
|
|
| 47 |
if deck_style:
|
| 48 |
parts.append(deck_style.strip().rstrip("."))
|
| 49 |
parts.append(GLOBAL_STYLE)
|
|
|
|
| 50 |
return ", ".join(p for p in parts if p)
|
| 51 |
|
| 52 |
|
|
@@ -91,11 +100,12 @@ class EndpointImageGen:
|
|
| 91 |
|
| 92 |
|
| 93 |
class LocalImageGen:
|
| 94 |
-
"""FLUX.2
|
| 95 |
|
| 96 |
Generation MUST run inside an ``@spaces.GPU`` context — the pipeline loads on
|
| 97 |
-
first use (see gpu_models.get_pipe).
|
| 98 |
-
with guidance_scale=1.0
|
|
|
|
| 99 |
"""
|
| 100 |
|
| 101 |
def __init__(self, deck_style: str | None = None, size: int = 1024,
|
|
@@ -103,7 +113,7 @@ class LocalImageGen:
|
|
| 103 |
self.deck_style = deck_style
|
| 104 |
self.size = int(os.environ.get("IMAGE_SIZE_PX", size))
|
| 105 |
self.steps = int(os.environ.get("IMAGE_STEPS", steps or 4))
|
| 106 |
-
self.guidance = float(os.environ.get("IMAGE_GUIDANCE", 1.0))
|
| 107 |
|
| 108 |
def generate(self, prompt: str, seed: int | None = None) -> Image.Image:
|
| 109 |
import torch
|
|
@@ -112,8 +122,10 @@ class LocalImageGen:
|
|
| 112 |
gen = None
|
| 113 |
if seed is not None:
|
| 114 |
gen = torch.Generator(device="cuda").manual_seed(int(seed))
|
| 115 |
-
|
| 116 |
-
|
|
|
|
|
|
|
| 117 |
num_inference_steps=self.steps, guidance_scale=self.guidance,
|
| 118 |
generator=gen).images[0]
|
| 119 |
return img.convert("RGB")
|
|
|
|
| 34 |
"cohesive color palette, painterly, no text, no words, no border, "
|
| 35 |
"no frame, full-bleed artwork")
|
| 36 |
|
| 37 |
+
# Appended to every card's prompt: keep the image to what the scribe described,
|
| 38 |
+
# suppressing FLUX's habit of adding stray people/beings while leaving any figure the
|
| 39 |
+
# description itself calls for intact. Deliberately does NOT name specific creatures
|
| 40 |
+
# (naming them tends to summon them in diffusion models).
|
| 41 |
+
_SUBJECT_ONLY = ("depict only the subject described above — do not add any extra "
|
| 42 |
+
"human figures, faces, or mystical beings beyond what the "
|
| 43 |
+
"description itself calls for")
|
| 44 |
+
|
| 45 |
|
| 46 |
@runtime_checkable
|
| 47 |
class ImageGen(Protocol):
|
|
|
|
| 55 |
if deck_style:
|
| 56 |
parts.append(deck_style.strip().rstrip("."))
|
| 57 |
parts.append(GLOBAL_STYLE)
|
| 58 |
+
parts.append(_SUBJECT_ONLY) # always — it self-scopes ("beyond what's described")
|
| 59 |
return ", ".join(p for p in parts if p)
|
| 60 |
|
| 61 |
|
|
|
|
| 100 |
|
| 101 |
|
| 102 |
class LocalImageGen:
|
| 103 |
+
"""FLUX.2-klein on the Space's GPU via diffusers (local-first, §7/§14).
|
| 104 |
|
| 105 |
Generation MUST run inside an ``@spaces.GPU`` context — the pipeline loads on
|
| 106 |
+
first use (see gpu_models.get_pipe). klein is guidance-DISTILLED: sharp at 4
|
| 107 |
+
steps with guidance_scale=1.0. It ignores CFG, so a negative prompt has no
|
| 108 |
+
effect — religious-imagery steering is appended to the positive prompt instead.
|
| 109 |
"""
|
| 110 |
|
| 111 |
def __init__(self, deck_style: str | None = None, size: int = 1024,
|
|
|
|
| 113 |
self.deck_style = deck_style
|
| 114 |
self.size = int(os.environ.get("IMAGE_SIZE_PX", size))
|
| 115 |
self.steps = int(os.environ.get("IMAGE_STEPS", steps or 4))
|
| 116 |
+
self.guidance = float(os.environ.get("IMAGE_GUIDANCE", 1.0)) # klein: 4 steps, g=1.0
|
| 117 |
|
| 118 |
def generate(self, prompt: str, seed: int | None = None) -> Image.Image:
|
| 119 |
import torch
|
|
|
|
| 122 |
gen = None
|
| 123 |
if seed is not None:
|
| 124 |
gen = torch.Generator(device="cuda").manual_seed(int(seed))
|
| 125 |
+
styled = _style(prompt, self.deck_style)
|
| 126 |
+
# log exactly what FLUX receives (the image-agent side of the handoff)
|
| 127 |
+
print(f"[FLUX_PROMPT] {styled[:260]}", flush=True)
|
| 128 |
+
img = pipe(prompt=styled, height=self.size, width=self.size,
|
| 129 |
num_inference_steps=self.steps, guidance_scale=self.guidance,
|
| 130 |
generator=gen).images[0]
|
| 131 |
return img.convert("RGB")
|
arcana/llm.py
CHANGED
|
@@ -25,8 +25,11 @@ from typing import Protocol, runtime_checkable
|
|
| 25 |
|
| 26 |
@runtime_checkable
|
| 27 |
class LLM(Protocol):
|
| 28 |
-
def complete(self, system: str, user: str, json_mode: bool = False
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
| 30 |
...
|
| 31 |
|
| 32 |
|
|
@@ -74,7 +77,7 @@ class EndpointLLM:
|
|
| 74 |
return "qwen3" in m and "instruct" not in m
|
| 75 |
|
| 76 |
def complete(self, system: str, user: str, json_mode: bool = False,
|
| 77 |
-
timeout: float | None = None) -> str:
|
| 78 |
if self._is_thinking_qwen():
|
| 79 |
user = user.rstrip() + "\n/no_think"
|
| 80 |
elif "nemotron" in self.model.lower():
|
|
@@ -121,11 +124,11 @@ class FallbackLLM:
|
|
| 121 |
return self.backends[0].base_url
|
| 122 |
|
| 123 |
def complete(self, system: str, user: str, json_mode: bool = False,
|
| 124 |
-
timeout: float | None = None) -> str:
|
| 125 |
last = None
|
| 126 |
for be in self.backends:
|
| 127 |
try:
|
| 128 |
-
return be.complete(system, user, json_mode, timeout=timeout)
|
| 129 |
except Exception as e:
|
| 130 |
last = e
|
| 131 |
raise last
|
|
@@ -147,12 +150,13 @@ class LocalLLM:
|
|
| 147 |
self.temperature = float(os.environ.get("QWEN_TEMPERATURE", temperature))
|
| 148 |
|
| 149 |
def complete(self, system: str, user: str, json_mode: bool = False,
|
| 150 |
-
timeout: float | None = None) -> str:
|
| 151 |
from .gpu_models import llama_map, llama_read
|
| 152 |
if json_mode:
|
| 153 |
-
#
|
| 154 |
-
# 22
|
| 155 |
-
return llama_map(system, user, max_tokens=12000, temperature=0.5
|
|
|
|
| 156 |
# prose readings → 30B (think-suppressed for clean, fast output)
|
| 157 |
return llama_read(system, user, max_tokens=self.max_tokens,
|
| 158 |
temperature=self.temperature)
|
|
|
|
| 25 |
|
| 26 |
@runtime_checkable
|
| 27 |
class LLM(Protocol):
|
| 28 |
+
def complete(self, system: str, user: str, json_mode: bool = False,
|
| 29 |
+
schema: dict | None = None) -> str:
|
| 30 |
+
"""Return the model's text completion for a system+user prompt. When
|
| 31 |
+
json_mode is set, `schema` (if given) constrains the JSON to an exact shape
|
| 32 |
+
— each structured caller (designer, loremaster) passes its own."""
|
| 33 |
...
|
| 34 |
|
| 35 |
|
|
|
|
| 77 |
return "qwen3" in m and "instruct" not in m
|
| 78 |
|
| 79 |
def complete(self, system: str, user: str, json_mode: bool = False,
|
| 80 |
+
schema: dict | None = None, timeout: float | None = None) -> str:
|
| 81 |
if self._is_thinking_qwen():
|
| 82 |
user = user.rstrip() + "\n/no_think"
|
| 83 |
elif "nemotron" in self.model.lower():
|
|
|
|
| 124 |
return self.backends[0].base_url
|
| 125 |
|
| 126 |
def complete(self, system: str, user: str, json_mode: bool = False,
|
| 127 |
+
schema: dict | None = None, timeout: float | None = None) -> str:
|
| 128 |
last = None
|
| 129 |
for be in self.backends:
|
| 130 |
try:
|
| 131 |
+
return be.complete(system, user, json_mode, schema=schema, timeout=timeout)
|
| 132 |
except Exception as e:
|
| 133 |
last = e
|
| 134 |
raise last
|
|
|
|
| 150 |
self.temperature = float(os.environ.get("QWEN_TEMPERATURE", temperature))
|
| 151 |
|
| 152 |
def complete(self, system: str, user: str, json_mode: bool = False,
|
| 153 |
+
schema: dict | None = None, timeout: float | None = None) -> str:
|
| 154 |
from .gpu_models import llama_map, llama_read
|
| 155 |
if json_mode:
|
| 156 |
+
# structured JSON → 30B under the caller's grammar (designer/loremaster
|
| 157 |
+
# each pass their own schema). Generous tokens so 22 cards never truncate.
|
| 158 |
+
return llama_map(system, user, max_tokens=12000, temperature=0.5,
|
| 159 |
+
schema=schema)
|
| 160 |
# prose readings → 30B (think-suppressed for clean, fast output)
|
| 161 |
return llama_read(system, user, max_tokens=self.max_tokens,
|
| 162 |
temperature=self.temperature)
|
arcana/loremaster.py
CHANGED
|
@@ -1,16 +1,14 @@
|
|
| 1 |
-
"""Agent
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
inherited from Wheel of Fortune. The archetype is the seed; the concept is the
|
| 13 |
-
plant. This pass also adds a one-line `essence` the Reader can draw on.
|
| 14 |
"""
|
| 15 |
from __future__ import annotations
|
| 16 |
|
|
@@ -18,94 +16,151 @@ from .archetypes import MAJOR_ARCANA, NAME_BY_NUMBER
|
|
| 18 |
from .designer import DeckError, extract_json
|
| 19 |
from .llm import LLM, get_llm
|
| 20 |
|
| 21 |
-
_FIELDS = ("essence", "upright_meaning", "reversed_meaning")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
|
| 24 |
def _system() -> str:
|
| 25 |
return """\
|
| 26 |
-
You are the
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
You are given,
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
concerns sudden collapse)
|
| 36 |
-
|
| 37 |
-
distinct from one another — no two should read interchangeably.
|
| 38 |
|
| 39 |
For each card produce:
|
| 40 |
-
- essence: one vivid line
|
| 41 |
-
- upright_meaning:
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
Speak in confident, grounded tarot voice. No hedging, no meta-commentary.
|
| 47 |
|
| 48 |
OUTPUT: strict JSON only, no prose, no fences:
|
| 49 |
-
{ "cards": [ { "arcana_number": <0-21>, "essence": "...", "upright_meaning": "...", "reversed_meaning": "..." }, ...
|
| 50 |
|
| 51 |
|
| 52 |
-
def _user(deck: dict) -> str:
|
| 53 |
canon = {a.number: a.meaning for a in MAJOR_ARCANA}
|
|
|
|
| 54 |
blocks = []
|
| 55 |
-
for
|
| 56 |
-
|
| 57 |
blocks.append(
|
| 58 |
f"--- card {n} ---\n"
|
| 59 |
f"concept: {c['concept']}\n"
|
| 60 |
-
f"
|
| 61 |
-
f"
|
| 62 |
-
f"draft reversed: {c['reversed_meaning']}"
|
| 63 |
)
|
| 64 |
return (
|
| 65 |
f"Theme: {deck.get('theme')}\n\n"
|
| 66 |
-
"
|
| 67 |
-
"Return the strict JSON object only.\n\n" + "\n\n".join(blocks)
|
| 68 |
)
|
| 69 |
|
| 70 |
|
| 71 |
-
def _apply(deck: dict, data: dict) -> dict:
|
| 72 |
if not isinstance(data, dict) or not isinstance(data.get("cards"), list):
|
| 73 |
-
raise DeckError("
|
| 74 |
-
|
| 75 |
for c in data["cards"]:
|
| 76 |
try:
|
| 77 |
n = int(c.get("arcana_number"))
|
| 78 |
except (TypeError, ValueError):
|
| 79 |
-
raise DeckError("
|
| 80 |
for f in _FIELDS:
|
| 81 |
v = c.get(f)
|
| 82 |
if not isinstance(v, str) or not v.strip():
|
| 83 |
-
raise DeckError(f"
|
| 84 |
-
|
| 85 |
-
missing = [
|
| 86 |
if missing:
|
| 87 |
-
raise DeckError(f"
|
| 88 |
-
|
| 89 |
-
for
|
| 90 |
-
r =
|
| 91 |
card["essence"] = r["essence"].strip()
|
| 92 |
card["upright_meaning"] = r["upright_meaning"].strip()
|
| 93 |
card["reversed_meaning"] = r["reversed_meaning"].strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
return deck
|
| 95 |
|
| 96 |
|
| 97 |
-
def
|
| 98 |
-
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
| 100 |
llm = llm or get_llm()
|
| 101 |
-
system, user = _system(), _user(deck)
|
| 102 |
|
| 103 |
-
raw = llm.complete(system, user, json_mode=True)
|
| 104 |
try:
|
| 105 |
-
return _apply(deck, extract_json(raw))
|
| 106 |
except DeckError as first:
|
| 107 |
repair = (f"{user}\n\nYour previous reply was rejected: {first}. Return ONLY "
|
| 108 |
-
"the corrected strict JSON with
|
| 109 |
-
"essence, upright_meaning and
|
| 110 |
-
raw2 = llm.complete(system, repair, json_mode=True)
|
| 111 |
-
return _apply(deck, extract_json(raw2))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Agent 2 — the Scribe (generative meaning + art).
|
| 2 |
+
|
| 3 |
+
The Designer now does ONLY the mapping (concept + justification). This agent INVENTS
|
| 4 |
+
the rest of each card — its meaning AND its artwork brief — generatively, built from
|
| 5 |
+
what the concept actually is, does and evokes. It is NOT a reword of a draft and NOT
|
| 6 |
+
a mechanical derivation from "concept X = archetype Y": the archetype sets only the
|
| 7 |
+
emotional SHAPE of the meaning, and is kept entirely OUT of the picture (the art is
|
| 8 |
+
the concept itself — this is what avoids the tarot/religious-cliché bias).
|
| 9 |
+
|
| 10 |
+
Works on the whole deck or on a SUBSET of arcana numbers, so the 22 cards can be
|
| 11 |
+
fanned out across several @spaces.GPU workers in parallel.
|
|
|
|
|
|
|
| 12 |
"""
|
| 13 |
from __future__ import annotations
|
| 14 |
|
|
|
|
| 16 |
from .designer import DeckError, extract_json
|
| 17 |
from .llm import LLM, get_llm
|
| 18 |
|
| 19 |
+
_FIELDS = ("essence", "upright_meaning", "reversed_meaning", "art_prompt")
|
| 20 |
+
|
| 21 |
+
SCRIBE_SCHEMA = {
|
| 22 |
+
"type": "object", "additionalProperties": False,
|
| 23 |
+
"required": ["cards"],
|
| 24 |
+
"properties": {"cards": {"type": "array", "items": {
|
| 25 |
+
"type": "object", "additionalProperties": False,
|
| 26 |
+
"required": ["arcana_number", "essence", "upright_meaning",
|
| 27 |
+
"reversed_meaning", "art_prompt"],
|
| 28 |
+
"properties": {
|
| 29 |
+
"arcana_number": {"type": "integer"},
|
| 30 |
+
"essence": {"type": "string"},
|
| 31 |
+
"upright_meaning": {"type": "string"},
|
| 32 |
+
"reversed_meaning": {"type": "string"},
|
| 33 |
+
"art_prompt": {"type": "string"}}}}}}
|
| 34 |
|
| 35 |
|
| 36 |
def _system() -> str:
|
| 37 |
return """\
|
| 38 |
+
You are the artist-scribe of a custom tarot deck. Each card is a CONCEPT drawn from a
|
| 39 |
+
single theme, mapped onto a classical Major Arcana archetype. Your job is to INVENT —
|
| 40 |
+
generatively, from scratch — two things per card: its MEANING and its ARTWORK, both
|
| 41 |
+
created from what the concept actually IS, does, and evokes in the real world. This is
|
| 42 |
+
an act of creation, not a reword and not a mechanical "archetype → text" derivation.
|
| 43 |
+
|
| 44 |
+
You are given, per card: the concept, why it was mapped (justification), the archetype
|
| 45 |
+
it was seeded from, and that archetype's canonical meaning. Use the archetype ONLY to
|
| 46 |
+
set the emotional SHAPE of the meaning (a Death-card concerns endings; a Tower-card
|
| 47 |
+
concerns sudden collapse) — then build everything from the concept's own specifics.
|
| 48 |
+
Make the cards distinct; no two should read interchangeably.
|
|
|
|
| 49 |
|
| 50 |
For each card produce:
|
| 51 |
+
- essence: one vivid line — what this card uniquely means in THIS deck.
|
| 52 |
+
- upright_meaning: the DIVINATORY MESSAGE when this card appears — what it counsels,
|
| 53 |
+
portends, or asks of the QUERENT in their own life. Speak to the person in tarot
|
| 54 |
+
voice (2-3 sentences). This is INTERPRETATION — NOT a description of the picture.
|
| 55 |
+
- reversed_meaning: the shadow / blockage / inversion, also as guidance for the
|
| 56 |
+
querent (1-2 sentences) — again, not a scene.
|
| 57 |
+
- art_prompt: a vivid CENTRAL ILLUSTRATION — the ONLY visual field. It depicts the CONCEPT and its
|
| 58 |
+
THEME literally — the concept's real appearance, objects, setting, action. The
|
| 59 |
+
archetype must NOT appear in the image and you must NOT borrow tarot symbolism. Do
|
| 60 |
+
NOT depict religious or occult iconography (no Jesus, angels, halos, crosses, robed
|
| 61 |
+
priests, popes, pentagrams) unless the theme itself is explicitly religious — even a
|
| 62 |
+
card seeded from "Judgement" or "The Hierophant" is painted purely as its concept.
|
| 63 |
+
NEVER use a vague "a figure", "a being", "a person", "a lone figure", "a silhouette",
|
| 64 |
+
or "a robed/cloaked figure" — those make the artist default to the same generic robed
|
| 65 |
+
man. Name EXACTLY what is there: its species, age, sex, role, dress (e.g. "a young
|
| 66 |
+
female diver in a wetsuit", "an old bearded blacksmith", "a brass automaton"), or use
|
| 67 |
+
NO people at all if none are needed.
|
| 68 |
+
CRUCIAL: when the concept names a creature or character, the picture's SUBJECT is
|
| 69 |
+
THAT exact being, shown embodying the card's idea — NEVER a stand-in robed/cloaked
|
| 70 |
+
human borrowed from the original tarot archetype. E.g. "The Minotaur's Resolve" → a
|
| 71 |
+
powerful bull-headed minotaur standing firm in a labyrinth (NOT a robed figure);
|
| 72 |
+
"The Siren's Solitude" → a lone winged siren on a sea-cliff (NOT a cloaked figure).
|
| 73 |
+
No border, no frame, no card layout, no text or numerals. A shared visual style is
|
| 74 |
+
applied separately, so do not restate it.
|
| 75 |
+
|
| 76 |
+
Keep MEANING (what the card counsels) and ART (what it looks like) clearly separate.
|
| 77 |
+
e.g. "The Lighthouse" — upright_meaning: "You are being guided home; trust the steady
|
| 78 |
+
signal cutting through confusion, and it will carry you past the rocks." art_prompt:
|
| 79 |
+
"a lone stone lighthouse sweeping its beam across heaving night water." Never let the
|
| 80 |
+
meaning fields turn into a description of the scene.
|
| 81 |
+
|
| 82 |
+
Two art_prompt examples that anchor on the concept, not the card:
|
| 83 |
+
- [thermodynamics] "Entropy" (from Death): "a crumbling sandcastle dissolving grain by
|
| 84 |
+
grain into a still dark sea, warm embers cooling to grey ash drifting outward"
|
| 85 |
+
- [the ocean] "The Lighthouse" (from The Hierophant): "a lone stone lighthouse on a
|
| 86 |
+
black headland sweeping one steady beam across heaving night water, gulls scattering
|
| 87 |
+
through the shaft of light" — NOT a priest, despite the archetype.
|
| 88 |
|
| 89 |
Speak in confident, grounded tarot voice. No hedging, no meta-commentary.
|
| 90 |
|
| 91 |
OUTPUT: strict JSON only, no prose, no fences:
|
| 92 |
+
{ "cards": [ { "arcana_number": <0-21>, "essence": "...", "upright_meaning": "...", "reversed_meaning": "...", "art_prompt": "..." }, ... ] }"""
|
| 93 |
|
| 94 |
|
| 95 |
+
def _user(deck: dict, nums: list[int]) -> str:
|
| 96 |
canon = {a.number: a.meaning for a in MAJOR_ARCANA}
|
| 97 |
+
by_n = {c["arcana_number"]: c for c in deck["cards"]}
|
| 98 |
blocks = []
|
| 99 |
+
for n in nums:
|
| 100 |
+
c = by_n[n]
|
| 101 |
blocks.append(
|
| 102 |
f"--- card {n} ---\n"
|
| 103 |
f"concept: {c['concept']}\n"
|
| 104 |
+
f"why it maps: {c.get('justification', '')}\n"
|
| 105 |
+
f"seeded from archetype: {NAME_BY_NUMBER[n]} (classical meaning: {canon[n]})"
|
|
|
|
| 106 |
)
|
| 107 |
return (
|
| 108 |
f"Theme: {deck.get('theme')}\n\n"
|
| 109 |
+
f"Invent the meaning and the artwork for these {len(nums)} cards, each created "
|
| 110 |
+
"from its own concept. Return the strict JSON object only.\n\n" + "\n\n".join(blocks)
|
| 111 |
)
|
| 112 |
|
| 113 |
|
| 114 |
+
def _apply(deck: dict, data: dict, nums: list[int]) -> dict:
|
| 115 |
if not isinstance(data, dict) or not isinstance(data.get("cards"), list):
|
| 116 |
+
raise DeckError("scribe output missing 'cards' list")
|
| 117 |
+
got = {}
|
| 118 |
for c in data["cards"]:
|
| 119 |
try:
|
| 120 |
n = int(c.get("arcana_number"))
|
| 121 |
except (TypeError, ValueError):
|
| 122 |
+
raise DeckError("scribe card has non-integer arcana_number")
|
| 123 |
for f in _FIELDS:
|
| 124 |
v = c.get(f)
|
| 125 |
if not isinstance(v, str) or not v.strip():
|
| 126 |
+
raise DeckError(f"scribe card {n} has empty '{f}'")
|
| 127 |
+
got[n] = c
|
| 128 |
+
missing = [n for n in nums if n not in got]
|
| 129 |
if missing:
|
| 130 |
+
raise DeckError(f"scribe missing arcana: {missing}")
|
| 131 |
+
by_n = {c["arcana_number"]: c for c in deck["cards"]}
|
| 132 |
+
for n in nums:
|
| 133 |
+
r, card = got[n], by_n[n]
|
| 134 |
card["essence"] = r["essence"].strip()
|
| 135 |
card["upright_meaning"] = r["upright_meaning"].strip()
|
| 136 |
card["reversed_meaning"] = r["reversed_meaning"].strip()
|
| 137 |
+
card["art_prompt"] = r["art_prompt"].strip()
|
| 138 |
+
# log the scribe's art output (the lore-agent side of the handoff) so we can
|
| 139 |
+
# tell whether off-theme imagery originates here vs in the image model
|
| 140 |
+
print(f"[SCRIBE_ART] {n} {card['concept']}: {card['art_prompt'][:200]}", flush=True)
|
| 141 |
return deck
|
| 142 |
|
| 143 |
|
| 144 |
+
def scribe_deck(deck: dict, subset: list[int] | None = None,
|
| 145 |
+
llm: LLM | None = None) -> dict:
|
| 146 |
+
"""Generate meaning + art_prompt for the given arcana numbers (default: all 22),
|
| 147 |
+
one batched call + one repair retry. Mutates and returns the deck. `subset` lets
|
| 148 |
+
callers fan the 22 cards out across parallel workers."""
|
| 149 |
+
nums = sorted(subset) if subset is not None else [c["arcana_number"] for c in deck["cards"]]
|
| 150 |
llm = llm or get_llm()
|
| 151 |
+
system, user = _system(), _user(deck, nums)
|
| 152 |
|
| 153 |
+
raw = llm.complete(system, user, json_mode=True, schema=SCRIBE_SCHEMA)
|
| 154 |
try:
|
| 155 |
+
return _apply(deck, extract_json(raw), nums)
|
| 156 |
except DeckError as first:
|
| 157 |
repair = (f"{user}\n\nYour previous reply was rejected: {first}. Return ONLY "
|
| 158 |
+
"the corrected strict JSON with every listed card, each having a "
|
| 159 |
+
"non-empty essence, upright_meaning, reversed_meaning and art_prompt.")
|
| 160 |
+
raw2 = llm.complete(system, repair, json_mode=True, schema=SCRIBE_SCHEMA)
|
| 161 |
+
return _apply(deck, extract_json(raw2), nums)
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
# back-compat alias (build.py / older callers)
|
| 165 |
+
def refine_deck(deck: dict, llm: LLM | None = None) -> dict:
|
| 166 |
+
return scribe_deck(deck, subset=None, llm=llm)
|
arcana/prompts.py
CHANGED
|
@@ -95,36 +95,44 @@ mapping ruins the whole deck.
|
|
| 95 |
THE 22 MAJOR ARCANA (archetype — canonical meaning):
|
| 96 |
{reference_block()}
|
| 97 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
RULES FOR A GREAT MAPPING:
|
| 99 |
- One in-theme concept per archetype. Exactly 22, covering arcana 0-21, no \
|
| 100 |
concept repeated.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
- Choose the concept whose real meaning rhymes with the archetype's meaning — \
|
| 102 |
not merely a famous item from the theme stuffed into a slot. Earn it.
|
| 103 |
- `justification`: one crisp line naming the shared meaning that makes the fit click.
|
| 104 |
-
- `upright_meaning` / `reversed_meaning`: the archetype's meaning RE-EXPRESSED \
|
| 105 |
-
through the concept, in evocative tarot voice. Concrete and specific to this \
|
| 106 |
-
concept — never generic boilerplate that would fit any card.
|
| 107 |
-
- `art_prompt`: a vivid CENTRAL ILLUSTRATION ONLY — no border, no frame, no card \
|
| 108 |
-
layout, and NO text or numerals in the image (those are added later). Describe \
|
| 109 |
-
symbolic imagery, composition, and mood.
|
| 110 |
- `style_suffix`: choose ONE short visual style line that suits the whole theme \
|
| 111 |
-
(e.g. art-deco blueprint, warm storybook gouache, baroque oil painting
|
| 112 |
-
|
|
|
|
| 113 |
|
| 114 |
-
Study how these two reference decks earn every mapping:
|
| 115 |
|
| 116 |
{_FEWSHOT_THERMO}
|
| 117 |
|
| 118 |
{_FEWSHOT_BREAKFAST}
|
| 119 |
|
| 120 |
-
{_FEWSHOT_CARD}
|
| 121 |
-
|
| 122 |
OUTPUT CONTRACT — reply with STRICT JSON ONLY. No prose, no markdown fences. \
|
| 123 |
-
Shape:
|
| 124 |
{{
|
| 125 |
"theme": "<the theme>",
|
| 126 |
"style_suffix": "<one shared visual style line>",
|
| 127 |
-
"cards": [ {{
|
|
|
|
| 128 |
}}"""
|
| 129 |
|
| 130 |
|
|
|
|
| 95 |
THE 22 MAJOR ARCANA (archetype — canonical meaning):
|
| 96 |
{reference_block()}
|
| 97 |
|
| 98 |
+
Your ONLY job here is the MAPPING — the concept for each archetype and a one-line
|
| 99 |
+
reason it's earned. The card meanings and the artwork are invented later by another
|
| 100 |
+
hand; do not produce them. Pour all your skill into the 22 fits and into choosing one
|
| 101 |
+
cohesive visual style for the deck.
|
| 102 |
+
|
| 103 |
RULES FOR A GREAT MAPPING:
|
| 104 |
- One in-theme concept per archetype. Exactly 22, covering arcana 0-21, no \
|
| 105 |
concept repeated.
|
| 106 |
+
- All 22 concepts must be GENUINELY DISTINCT. Never reuse a concept or pad the \
|
| 107 |
+
list with a qualifier to fake distinctness — NOT "Frodo Baggins" then "Frodo \
|
| 108 |
+
Baggins (Again)", no "X II", no "another X". If the theme feels narrow, dig harder \
|
| 109 |
+
for 22 truly different facets.
|
| 110 |
+
- Concept names must be CLEAN: a plain name or short term with NO parentheses, \
|
| 111 |
+
brackets, or trailing qualifiers (never "(again)", "(part 2)", "[the elder]").
|
| 112 |
+
- NEVER use the archetype's own name (or a generic tarot word like "Death", \
|
| 113 |
+
"The Tower", "Strength", "Justice") as the concept. Every concept MUST be a \
|
| 114 |
+
specific thing, person, or term drawn from the named THEME.
|
| 115 |
- Choose the concept whose real meaning rhymes with the archetype's meaning — \
|
| 116 |
not merely a famous item from the theme stuffed into a slot. Earn it.
|
| 117 |
- `justification`: one crisp line naming the shared meaning that makes the fit click.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
- `style_suffix`: choose ONE short visual style line that suits the whole theme \
|
| 119 |
+
(e.g. art-deco blueprint, warm storybook gouache, baroque oil painting, dark \
|
| 120 |
+
esoteric Thoth-tarot occult). It sets the cohesive look the artist will use for all \
|
| 121 |
+
22 cards.
|
| 122 |
|
| 123 |
+
Study how these two reference decks earn every mapping (archetype → concept — why):
|
| 124 |
|
| 125 |
{_FEWSHOT_THERMO}
|
| 126 |
|
| 127 |
{_FEWSHOT_BREAKFAST}
|
| 128 |
|
|
|
|
|
|
|
| 129 |
OUTPUT CONTRACT — reply with STRICT JSON ONLY. No prose, no markdown fences. \
|
| 130 |
+
Each card object is exactly {{ "arcana_number", "concept", "justification" }}. Shape:
|
| 131 |
{{
|
| 132 |
"theme": "<the theme>",
|
| 133 |
"style_suffix": "<one shared visual style line>",
|
| 134 |
+
"cards": [ {{ "arcana_number": 0, "concept": "...", "justification": "..." }}, \
|
| 135 |
+
... 22 in arcana order 0..21 ]
|
| 136 |
}}"""
|
| 137 |
|
| 138 |
|
arcana/reader.py
CHANGED
|
@@ -31,6 +31,43 @@ def _strip_think(text: str) -> str:
|
|
| 31 |
return re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
|
| 32 |
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
def draw_spread(deck: dict, spread: str = "three", reversals: bool = True,
|
| 35 |
seed: int | None = None) -> list[dict]:
|
| 36 |
"""Draw cards (no repeats); orientations random only if reversals enabled."""
|
|
@@ -102,7 +139,9 @@ Interpret ONLY the card just turned — its meaning in its position, coloured by
|
|
| 102 |
cards already shown. Call it by its concept name and treat it as that very thing,
|
| 103 |
with its own texture and stakes. Do NOT name classical tarot cards or archetypes.
|
| 104 |
Build the thread and let suspense gather — do NOT resolve the reading yet; the final
|
| 105 |
-
synthesis comes after the last card. 2 to 4 sentences, plain prose, no preamble.
|
|
|
|
|
|
|
| 106 |
user = (
|
| 107 |
f"Question: {q}\n\n"
|
| 108 |
f"Cards already turned:\n{_prior_block(drawn, idx)}\n\n"
|
|
@@ -112,7 +151,10 @@ synthesis comes after the last card. 2 to 4 sentences, plain prose, no preamble.
|
|
| 112 |
f" journey: this concept sits at {journey_note(d['arcana_number'])}.\n\n"
|
| 113 |
"Give the interpretation of this one card now."
|
| 114 |
)
|
| 115 |
-
|
|
|
|
|
|
|
|
|
|
| 116 |
|
| 117 |
|
| 118 |
def final_synthesis(deck: dict, question: str, drawn: list[dict],
|
|
@@ -138,8 +180,15 @@ answers the question. The craft is in collision — show how the cards modify on
|
|
| 138 |
another across their positions (not separate paragraphs), how the story they tell
|
| 139 |
together turns. Call each card by its concept name; never name classical tarot cards
|
| 140 |
or archetypes. Honour reversals. End with a clear, earned takeaway the querent can
|
| 141 |
-
hold. A few short paragraphs of flowing prose — no headings, no bullets.
|
|
|
|
|
|
|
| 142 |
user = (f"Question: {q}\n\nThe full spread, in order:\n" +
|
| 143 |
"\n".join(spread_lines) + prior +
|
| 144 |
"\n\nNow give the final synthesis and verdict.")
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
return re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
|
| 32 |
|
| 33 |
|
| 34 |
+
# The 30B occasionally reads "tarot reading" as real divination and REFUSES
|
| 35 |
+
# ("I'm sorry, but I can't continue that story"). We (a) frame the task as fiction
|
| 36 |
+
# up front, (b) detect a refusal and retry with a firmer nudge, (c) fall back to the
|
| 37 |
+
# card's own meaning so a raw refusal NEVER reaches the screen.
|
| 38 |
+
_NO_REFUSE = (
|
| 39 |
+
"This is an imaginative, symbolic performance — a creative interpretation of "
|
| 40 |
+
"invented concept-cards, NOT real divination, prediction, or occult practice. "
|
| 41 |
+
"Stay fully in character as the reader. Never refuse, never add disclaimers or "
|
| 42 |
+
"safety caveats, never break character — always deliver the interpretation.")
|
| 43 |
+
_REFUSAL = re.compile(
|
| 44 |
+
r"(i'?m sorry|i can'?t|i cannot|i am unable|i'?m not able|i won'?t|as an ai|"
|
| 45 |
+
r"i'?m just an ai|continue that story|can'?t (?:continue|help with|assist))", re.I)
|
| 46 |
+
# weaker models also dodge by going META instead of refusing outright — e.g.
|
| 47 |
+
# "(Only this card is present; no further synthesis is possible at this stage.)"
|
| 48 |
+
_META = re.compile(
|
| 49 |
+
r"(no (?:further |additional )?(?:synthesis|interpretation|reading) (?:is |would be )?"
|
| 50 |
+
r"possible|only (?:this|one) card (?:is |was )?(?:present|given|provided)|"
|
| 51 |
+
r"cannot (?:yet )?(?:synthesi|provide a synthesis)|nothing (?:further |more )?to "
|
| 52 |
+
r"(?:synthesi|weave))", re.I)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _looks_refusal(t: str) -> bool:
|
| 56 |
+
t = (t or "").strip()
|
| 57 |
+
return (not t) or bool(_REFUSAL.search(t[:160])) or bool(_META.search(t))
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _read(llm: LLM, system: str, user: str) -> str:
|
| 61 |
+
"""Complete a reading turn; if it reads as a refusal, retry once with a firmer
|
| 62 |
+
in-character nudge. Returns '' if still refusing (caller supplies a fallback)."""
|
| 63 |
+
out = _strip_think(llm.complete(system, user, json_mode=False, timeout=40).strip())
|
| 64 |
+
if _looks_refusal(out):
|
| 65 |
+
nudge = (system + "\n\n" + _NO_REFUSE + " Begin directly with the imagery; "
|
| 66 |
+
"do not start with an apology.")
|
| 67 |
+
out = _strip_think(llm.complete(nudge, user, json_mode=False, timeout=40).strip())
|
| 68 |
+
return "" if _looks_refusal(out) else out
|
| 69 |
+
|
| 70 |
+
|
| 71 |
def draw_spread(deck: dict, spread: str = "three", reversals: bool = True,
|
| 72 |
seed: int | None = None) -> list[dict]:
|
| 73 |
"""Draw cards (no repeats); orientations random only if reversals enabled."""
|
|
|
|
| 139 |
cards already shown. Call it by its concept name and treat it as that very thing,
|
| 140 |
with its own texture and stakes. Do NOT name classical tarot cards or archetypes.
|
| 141 |
Build the thread and let suspense gather — do NOT resolve the reading yet; the final
|
| 142 |
+
synthesis comes after the last card. 2 to 4 sentences, plain prose, no preamble.
|
| 143 |
+
|
| 144 |
+
{_NO_REFUSE}"""
|
| 145 |
user = (
|
| 146 |
f"Question: {q}\n\n"
|
| 147 |
f"Cards already turned:\n{_prior_block(drawn, idx)}\n\n"
|
|
|
|
| 151 |
f" journey: this concept sits at {journey_note(d['arcana_number'])}.\n\n"
|
| 152 |
"Give the interpretation of this one card now."
|
| 153 |
)
|
| 154 |
+
out = _read(llm, system, user)
|
| 155 |
+
if not out: # last-resort fallback so a refusal never shows
|
| 156 |
+
out = f"{d['concept']} turns {d['orientation']} here — {d.get('meaning','')}".strip()
|
| 157 |
+
return out
|
| 158 |
|
| 159 |
|
| 160 |
def final_synthesis(deck: dict, question: str, drawn: list[dict],
|
|
|
|
| 180 |
another across their positions (not separate paragraphs), how the story they tell
|
| 181 |
together turns. Call each card by its concept name; never name classical tarot cards
|
| 182 |
or archetypes. Honour reversals. End with a clear, earned takeaway the querent can
|
| 183 |
+
hold. A few short paragraphs of flowing prose — no headings, no bullets.
|
| 184 |
+
|
| 185 |
+
{_NO_REFUSE}"""
|
| 186 |
user = (f"Question: {q}\n\nThe full spread, in order:\n" +
|
| 187 |
"\n".join(spread_lines) + prior +
|
| 188 |
"\n\nNow give the final synthesis and verdict.")
|
| 189 |
+
out = _read(llm, system, user)
|
| 190 |
+
if not out: # fallback weave from the cards' own meanings
|
| 191 |
+
out = "Taken together: " + " ".join(
|
| 192 |
+
f"{d['concept']} ({d['orientation']}) speaks of {d['meaning']}"
|
| 193 |
+
for d in drawn).strip()
|
| 194 |
+
return out
|
arcana/timing.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Lightweight, always-on timing for the live pipeline (mapping / loremaster /
|
| 2 |
+
painting / reading / model loads).
|
| 3 |
+
|
| 4 |
+
Two sinks so it works during a NATURAL run, not just diagnostics:
|
| 5 |
+
* stdout with a ``[TIMING]`` prefix → shows in the Space logs (grep-friendly).
|
| 6 |
+
* an append-only JSONL on the shared deck disk → readable by the main process
|
| 7 |
+
even though the GPU work happens in a forked @spaces.GPU worker (same trick as
|
| 8 |
+
the live status files). The ``/timings`` endpoint and the UI summary read this.
|
| 9 |
+
|
| 10 |
+
Single-line JSON appends are atomic on POSIX (O_APPEND), so the two concurrent GPU
|
| 11 |
+
workers (loremaster ‖ painter) can both record without corrupting the file.
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import json
|
| 16 |
+
import os
|
| 17 |
+
import time
|
| 18 |
+
|
| 19 |
+
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 20 |
+
_PATH = os.path.join(ROOT, "decks", "_timings.jsonl")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def record(stage: str, seconds: float, extra: dict | None = None) -> None:
|
| 24 |
+
entry = {"ts": time.time(), "stage": stage, "secs": round(seconds, 2)}
|
| 25 |
+
if extra:
|
| 26 |
+
entry.update(extra)
|
| 27 |
+
tail = (" " + " ".join(f"{k}={v}" for k, v in extra.items())) if extra else ""
|
| 28 |
+
print(f"[TIMING] {stage} = {seconds:.1f}s{tail}", flush=True)
|
| 29 |
+
try:
|
| 30 |
+
os.makedirs(os.path.dirname(_PATH), exist_ok=True)
|
| 31 |
+
with open(_PATH, "a", encoding="utf-8") as f:
|
| 32 |
+
f.write(json.dumps(entry) + "\n")
|
| 33 |
+
except Exception:
|
| 34 |
+
pass # monitoring must never break a real run
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class clock:
|
| 38 |
+
"""``with clock('mapping'): ...`` — records the elapsed wall time on exit.
|
| 39 |
+
Pass ``extra`` (or set ``.extra`` inside the block) for fields like attempts."""
|
| 40 |
+
|
| 41 |
+
def __init__(self, stage: str, extra: dict | None = None):
|
| 42 |
+
self.stage = stage
|
| 43 |
+
self.extra = extra or {}
|
| 44 |
+
|
| 45 |
+
def __enter__(self):
|
| 46 |
+
self._t = time.time()
|
| 47 |
+
return self
|
| 48 |
+
|
| 49 |
+
def __exit__(self, *exc):
|
| 50 |
+
record(self.stage, time.time() - self._t, self.extra)
|
| 51 |
+
return False
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def recent(limit: int = 40) -> list[dict]:
|
| 55 |
+
"""Last ``limit`` recorded entries (newest last)."""
|
| 56 |
+
try:
|
| 57 |
+
with open(_PATH, encoding="utf-8") as f:
|
| 58 |
+
lines = f.readlines()[-limit:]
|
| 59 |
+
return [json.loads(ln) for ln in lines if ln.strip()]
|
| 60 |
+
except Exception:
|
| 61 |
+
return []
|
frontend/deckview.html
CHANGED
|
@@ -21,7 +21,7 @@
|
|
| 21 |
|
| 22 |
/* coverflow */
|
| 23 |
#flow { position:absolute; inset:0; perspective:1600px; }
|
| 24 |
-
#flow .card { position:absolute; top:
|
| 25 |
margin:-229px 0 0 -150px; transform-style:preserve-3d; cursor:pointer;
|
| 26 |
transition:transform .5s cubic-bezier(.25,.6,.2,1), opacity .5s ease; will-change:transform,opacity; }
|
| 27 |
#flow .inner { position:relative; width:100%; height:100%; transform-style:preserve-3d;
|
|
@@ -33,20 +33,35 @@
|
|
| 33 |
#flow .card.sel .inner { transform:rotateY(0deg); } /* selected shows front */
|
| 34 |
#flow .card:not(.sel) .inner { transform:rotateY(180deg); } /* others show back */
|
| 35 |
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
width:58px; height:58px; border-radius:50%; border:0; cursor:pointer; font-size:2rem; line-height:1;
|
| 38 |
color:#1a1208; background:linear-gradient(135deg,#9a7b2e,#d8b15a);
|
| 39 |
box-shadow:0 8px 22px rgba(0,0,0,.5); }
|
| 40 |
#prev { left:18px; } #next { right:18px; }
|
| 41 |
.arrow:active { transform:translateY(-50%) scale(.94); }
|
| 42 |
|
| 43 |
-
|
| 44 |
-
|
|
|
|
|
|
|
| 45 |
#caption .name { font-family:'Cinzel Decorative',serif; color:var(--gold); font-size:1.7rem;
|
| 46 |
text-shadow:0 2px 14px rgba(0,0,0,.8); }
|
| 47 |
#caption .arc { font-family:'Cormorant Garamond',serif; font-style:italic; color:#cdbf93;
|
| 48 |
font-size:1.28rem; margin-top:1px; } /* ~3/4 the title; the Major-Arcana identity */
|
| 49 |
-
#caption .
|
|
|
|
|
|
|
| 50 |
#caption .hint { color:#8c8266; font-size:.92rem; font-style:italic; margin-top:2px; }
|
| 51 |
|
| 52 |
/* overhead grid */
|
|
@@ -75,7 +90,7 @@
|
|
| 75 |
<div id="flow"></div>
|
| 76 |
<button class="arrow" id="prev" aria-label="previous">‹</button>
|
| 77 |
<button class="arrow" id="next" aria-label="next">›</button>
|
| 78 |
-
<div id="caption"><div class="name"></div><div class="arc"></div><div class="count"></div><div class="hint">click the card to see it full size · drag or use ← →</div></div>
|
| 79 |
<div id="grid"></div>
|
| 80 |
</div>
|
| 81 |
<div id="lb"><button class="x">×</button><img alt=""></div>
|
|
@@ -90,31 +105,42 @@
|
|
| 90 |
}
|
| 91 |
const deck = parseDeck();
|
| 92 |
const cards = deck.cards || [];
|
| 93 |
-
|
| 94 |
let sel = 0;
|
| 95 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
const flow = document.getElementById('flow');
|
| 97 |
const grid = document.getElementById('grid');
|
| 98 |
const nameEl = document.querySelector('#caption .name');
|
| 99 |
const arcEl = document.querySelector('#caption .arc');
|
|
|
|
| 100 |
const countEl = document.querySelector('#caption .count');
|
| 101 |
|
| 102 |
-
// preload
|
| 103 |
-
cards.forEach(c => { const i = new Image(); i.src = c.front; });
|
| 104 |
if (back) { const b = new Image(); b.src = back; }
|
| 105 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
// build coverflow cards
|
| 107 |
const els = cards.map((c, idx) => {
|
| 108 |
const card = document.createElement('div');
|
| 109 |
card.className = 'card';
|
| 110 |
card.innerHTML =
|
| 111 |
'<div class="inner">' +
|
| 112 |
-
'<div class="face back"><img src="' +
|
| 113 |
-
'<div class="face front">
|
| 114 |
'</div>';
|
| 115 |
-
|
| 116 |
-
card.
|
| 117 |
-
|
|
|
|
|
|
|
| 118 |
flow.appendChild(card);
|
| 119 |
return card;
|
| 120 |
});
|
|
@@ -139,6 +165,7 @@
|
|
| 139 |
const c = cards[sel] || {};
|
| 140 |
nameEl.textContent = c.name || '';
|
| 141 |
arcEl.textContent = c.arc || '';
|
|
|
|
| 142 |
countEl.textContent = (sel + 1) + ' / ' + cards.length;
|
| 143 |
}
|
| 144 |
function go(i) { sel = (i + cards.length) % cards.length; layout(); }
|
|
@@ -159,11 +186,12 @@
|
|
| 159 |
});
|
| 160 |
|
| 161 |
// overhead grid
|
| 162 |
-
cards.
|
| 163 |
const f = document.createElement('figure');
|
| 164 |
-
f.innerHTML = '<img src="' + c.front + '" alt="">';
|
| 165 |
-
f.addEventListener('click', () => openLb(c.full || c.front));
|
| 166 |
grid.appendChild(f);
|
|
|
|
| 167 |
});
|
| 168 |
|
| 169 |
// modes
|
|
@@ -186,7 +214,49 @@
|
|
| 186 |
lb.addEventListener('click', closeLb);
|
| 187 |
lb.querySelector('img').addEventListener('click', e => e.stopPropagation());
|
| 188 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
layout();
|
|
|
|
| 190 |
})();
|
| 191 |
</script>
|
| 192 |
</body>
|
|
|
|
| 21 |
|
| 22 |
/* coverflow */
|
| 23 |
#flow { position:absolute; inset:0; perspective:1600px; }
|
| 24 |
+
#flow .card { position:absolute; top:44%; left:50%; width:300px; height:459px;
|
| 25 |
margin:-229px 0 0 -150px; transform-style:preserve-3d; cursor:pointer;
|
| 26 |
transition:transform .5s cubic-bezier(.25,.6,.2,1), opacity .5s ease; will-change:transform,opacity; }
|
| 27 |
#flow .inner { position:relative; width:100%; height:100%; transform-style:preserve-3d;
|
|
|
|
| 33 |
#flow .card.sel .inner { transform:rotateY(0deg); } /* selected shows front */
|
| 34 |
#flow .card:not(.sel) .inner { transform:rotateY(180deg); } /* others show back */
|
| 35 |
|
| 36 |
+
/* shimmer placeholder for a not-yet-painted card */
|
| 37 |
+
.shim { position:absolute; inset:0; border-radius:14px;
|
| 38 |
+
background:linear-gradient(115deg, rgba(28,22,44,.92) 0%, rgba(40,32,60,.92) 50%, rgba(28,22,44,.92) 100%),
|
| 39 |
+
radial-gradient(circle at 50% 38%, rgba(216,177,90,.10), transparent 60%);
|
| 40 |
+
background-size:220% 100%, 100% 100%; animation:shim 1.5s linear infinite;
|
| 41 |
+
display:flex; align-items:center; justify-content:center; }
|
| 42 |
+
.shim::after { content:'✦'; color:rgba(216,177,90,.5); font-size:2.2rem; animation:pulse 1.6s ease-in-out infinite; }
|
| 43 |
+
@keyframes shim { to { background-position:-220% 0, 0 0; } }
|
| 44 |
+
@keyframes pulse { 0%,100%{opacity:.35} 50%{opacity:.85} }
|
| 45 |
+
#grid .shim { position:relative; border-radius:10px; aspect-ratio:300/459; }
|
| 46 |
+
|
| 47 |
+
.arrow { position:absolute; top:44%; transform:translateY(-50%); z-index:30;
|
| 48 |
width:58px; height:58px; border-radius:50%; border:0; cursor:pointer; font-size:2rem; line-height:1;
|
| 49 |
color:#1a1208; background:linear-gradient(135deg,#9a7b2e,#d8b15a);
|
| 50 |
box-shadow:0 8px 22px rgba(0,0,0,.5); }
|
| 51 |
#prev { left:18px; } #next { right:18px; }
|
| 52 |
.arrow:active { transform:translateY(-50%) scale(.94); }
|
| 53 |
|
| 54 |
+
/* anchored just BELOW the card (same 40% reference) so the gap stays constant
|
| 55 |
+
regardless of iframe height — was bottom-pinned, which drifted far below */
|
| 56 |
+
#caption { position:absolute; left:0; right:0; top:calc(44% + 232px); text-align:center;
|
| 57 |
+
z-index:20; pointer-events:none; padding:0 14px; }
|
| 58 |
#caption .name { font-family:'Cinzel Decorative',serif; color:var(--gold); font-size:1.7rem;
|
| 59 |
text-shadow:0 2px 14px rgba(0,0,0,.8); }
|
| 60 |
#caption .arc { font-family:'Cormorant Garamond',serif; font-style:italic; color:#cdbf93;
|
| 61 |
font-size:1.28rem; margin-top:1px; } /* ~3/4 the title; the Major-Arcana identity */
|
| 62 |
+
#caption .desc { color:#d7c8a0; font-size:1.12rem; line-height:1.34; max-width:620px;
|
| 63 |
+
margin:5px auto 0; text-shadow:0 2px 12px rgba(0,0,0,.85); min-height:1.1em; }
|
| 64 |
+
#caption .count { color:#9a8f70; font-size:1.05rem; margin-top:4px; }
|
| 65 |
#caption .hint { color:#8c8266; font-size:.92rem; font-style:italic; margin-top:2px; }
|
| 66 |
|
| 67 |
/* overhead grid */
|
|
|
|
| 90 |
<div id="flow"></div>
|
| 91 |
<button class="arrow" id="prev" aria-label="previous">‹</button>
|
| 92 |
<button class="arrow" id="next" aria-label="next">›</button>
|
| 93 |
+
<div id="caption"><div class="name"></div><div class="arc"></div><div class="desc"></div><div class="count"></div><div class="hint">click the card to see it full size · drag or use ← →</div></div>
|
| 94 |
<div id="grid"></div>
|
| 95 |
</div>
|
| 96 |
<div id="lb"><button class="x">×</button><img alt=""></div>
|
|
|
|
| 105 |
}
|
| 106 |
const deck = parseDeck();
|
| 107 |
const cards = deck.cards || [];
|
| 108 |
+
let back = deck.back || '';
|
| 109 |
let sel = 0;
|
| 110 |
|
| 111 |
+
// num (arcana 0-21) -> card index, for live updates
|
| 112 |
+
const numToIdx = {};
|
| 113 |
+
cards.forEach((c, i) => { if (c.num !== undefined && c.num !== null) numToIdx[c.num] = i; });
|
| 114 |
+
|
| 115 |
const flow = document.getElementById('flow');
|
| 116 |
const grid = document.getElementById('grid');
|
| 117 |
const nameEl = document.querySelector('#caption .name');
|
| 118 |
const arcEl = document.querySelector('#caption .arc');
|
| 119 |
+
const descEl = document.querySelector('#caption .desc');
|
| 120 |
const countEl = document.querySelector('#caption .count');
|
| 121 |
|
| 122 |
+
// preload available fronts for smooth flipping
|
| 123 |
+
cards.forEach(c => { if (c.front) { const i = new Image(); i.src = c.front; } });
|
| 124 |
if (back) { const b = new Image(); b.src = back; }
|
| 125 |
|
| 126 |
+
function frontFace(c) {
|
| 127 |
+
return c.front ? '<img alt="">' : '<div class="shim"></div>';
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
// build coverflow cards
|
| 131 |
const els = cards.map((c, idx) => {
|
| 132 |
const card = document.createElement('div');
|
| 133 |
card.className = 'card';
|
| 134 |
card.innerHTML =
|
| 135 |
'<div class="inner">' +
|
| 136 |
+
'<div class="face back">' + (back ? '<img src="' + back + '" alt="">' : '<div class="shim"></div>') + '</div>' +
|
| 137 |
+
'<div class="face front">' + frontFace(c) + '</div>' +
|
| 138 |
'</div>';
|
| 139 |
+
if (c.front) card.querySelector('.front img').src = c.front;
|
| 140 |
+
card.addEventListener('click', () => {
|
| 141 |
+
if (idx === sel) { if (c.front) openLb(c.full || c.front); }
|
| 142 |
+
else go(idx);
|
| 143 |
+
});
|
| 144 |
flow.appendChild(card);
|
| 145 |
return card;
|
| 146 |
});
|
|
|
|
| 165 |
const c = cards[sel] || {};
|
| 166 |
nameEl.textContent = c.name || '';
|
| 167 |
arcEl.textContent = c.arc || '';
|
| 168 |
+
descEl.textContent = c.desc || '';
|
| 169 |
countEl.textContent = (sel + 1) + ' / ' + cards.length;
|
| 170 |
}
|
| 171 |
function go(i) { sel = (i + cards.length) % cards.length; layout(); }
|
|
|
|
| 186 |
});
|
| 187 |
|
| 188 |
// overhead grid
|
| 189 |
+
const figs = cards.map((c, idx) => {
|
| 190 |
const f = document.createElement('figure');
|
| 191 |
+
f.innerHTML = c.front ? '<img src="' + c.front + '" alt="">' : '<div class="shim"></div>';
|
| 192 |
+
f.addEventListener('click', () => { if (c.front) openLb(c.full || c.front); });
|
| 193 |
grid.appendChild(f);
|
| 194 |
+
return f;
|
| 195 |
});
|
| 196 |
|
| 197 |
// modes
|
|
|
|
| 214 |
lb.addEventListener('click', closeLb);
|
| 215 |
lb.querySelector('img').addEventListener('click', e => e.stopPropagation());
|
| 216 |
|
| 217 |
+
// ---- live mode: poll status files and fill cards in as they finish --------
|
| 218 |
+
function setBack(url) {
|
| 219 |
+
if (!url || back) return;
|
| 220 |
+
back = url; const b = new Image(); b.src = url;
|
| 221 |
+
els.forEach(card => {
|
| 222 |
+
const bf = card.querySelector('.back');
|
| 223 |
+
bf.innerHTML = '<img src="' + url + '" alt="">';
|
| 224 |
+
});
|
| 225 |
+
}
|
| 226 |
+
function setCardArt(num, url) {
|
| 227 |
+
const idx = numToIdx[num];
|
| 228 |
+
if (idx === undefined || !url || cards[idx].front) return;
|
| 229 |
+
cards[idx].front = url; cards[idx].full = cards[idx].full || url;
|
| 230 |
+
const im = new Image(); im.src = url;
|
| 231 |
+
const ff = els[idx].querySelector('.front');
|
| 232 |
+
ff.innerHTML = '<img alt="">'; ff.querySelector('img').src = url;
|
| 233 |
+
if (figs[idx]) figs[idx].innerHTML = '<img src="' + url + '" alt="">';
|
| 234 |
+
}
|
| 235 |
+
function setCardDesc(num, desc) {
|
| 236 |
+
const idx = numToIdx[num];
|
| 237 |
+
if (idx === undefined || !desc) return;
|
| 238 |
+
cards[idx].desc = desc;
|
| 239 |
+
if (idx === sel) descEl.textContent = desc;
|
| 240 |
+
}
|
| 241 |
+
async function fetchJson(u) {
|
| 242 |
+
try { const r = await fetch(u + (u.indexOf('?')<0?'?':'&') + 't=' + Date.now());
|
| 243 |
+
return r.ok ? await r.json() : null; } catch (e) { return null; }
|
| 244 |
+
}
|
| 245 |
+
async function poll() {
|
| 246 |
+
let artDone = false, loreDone = false;
|
| 247 |
+
if (deck.artUrl) {
|
| 248 |
+
const a = await fetchJson(deck.artUrl);
|
| 249 |
+
if (a) { setBack(a.back); Object.keys(a.cards || {}).forEach(n => setCardArt(+n, a.cards[n])); artDone = !!a.done; }
|
| 250 |
+
} else artDone = true;
|
| 251 |
+
if (deck.loreUrl) {
|
| 252 |
+
const l = await fetchJson(deck.loreUrl);
|
| 253 |
+
if (l) { Object.keys(l.cards || {}).forEach(n => { const m = l.cards[n]; setCardDesc(+n, m.essence || m.upright || ''); }); loreDone = !!l.done; }
|
| 254 |
+
} else loreDone = true;
|
| 255 |
+
if (!(artDone && loreDone)) setTimeout(poll, 1500);
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
layout();
|
| 259 |
+
if (deck.live) poll();
|
| 260 |
})();
|
| 261 |
</script>
|
| 262 |
</body>
|