arcana / app.py
JG1310's picture
footer: raise contrast (opacity .33->.75, lighter parchment) per organizer feedback
92a72c7 verified
Raw
History Blame Contribute Delete
73.6 kB
"""Arcana — themed concept-tarot generator & reader (SPEC §13, §14).
Gradio + FastAPI app (Globe's mounting pattern). Landing → Tarot Reading / View
Deck; generate a deck in a chosen visual style, then Save & Finish or go straight
into a reading; the reading is theatre — cards start face-down (the deck-back) and
flip one at a time, a per-card partial fading in as each lands, the full synthesis
held until the end.
Everything runs FULLY LOCAL ("off the grid") on open ≤32B models on the Space's own
GPU (Hugging Face ZeroGPU): Qwen3-14B (all text roles) via llama.cpp and FLUX.2-klein
for the art via diffusers. No cloud inference — nothing leaves the Space.
"""
from __future__ import annotations
# IMPORT ORDER MATTERS: `spaces` must be imported BEFORE gradio (and before torch)
# so it can patch Gradio for ZeroGPU. If gradio loads first, ZeroGPU never
# initialises and the Space boots then immediately gets SIGTERM ("Shutting down").
try:
import spaces
_HAS_SPACES = True
except Exception:
_HAS_SPACES = False
import base64
import html
import json
import os
import re
import tempfile
import threading
import time
import zipfile
import gradio as gr
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from arcana.build import (DECKS_DIR, build_deck, deck_dir, list_decks, load_deck,
save_deck, slugify)
from arcana.llm import get_llm
from arcana.reader import (SPREADS, card_partial, draw_spread, final_synthesis)
from arcana.styles import CUSTOM_PLACEHOLDER, STYLE_CHOICES
ROOT = os.path.dirname(os.path.abspath(__file__))
DEFAULT_THEME = "thermodynamics"
# ---- ZeroGPU (local-first build) ------------------------------------------
# When LLM/IMAGE backends are local, the heavy work runs on the Space's GPU and
# must be wrapped in @spaces.GPU. Off-Space (local dev) @GPU is a no-op.
LOCAL_GPU = (os.environ.get("LLM_BACKEND", "").lower() == "local"
or os.environ.get("IMAGE_BACKEND", "").lower() == "local")
if _HAS_SPACES:
def GPU(duration=120):
return spaces.GPU(duration=duration)
else:
def GPU(duration=120):
def _deco(fn):
return fn
return _deco
# Warm the FLUX pipe at STARTUP on CPU (the expensive from_pretrained), like the
# reference klein space — so each conjure's @spaces.GPU call only pays the fast
# .to('cuda') move, not the disk read + build. CPU-only here (no .to('cuda'), which
# would trip ZeroGPU emulation); the move-to-GPU happens inside the GPU fn. Daemon
# thread so it never blocks boot.
def _preload_image_pipe():
try:
from arcana.gpu_models import preload_pipe
preload_pipe()
except Exception:
pass
if os.environ.get("IMAGE_BACKEND", "").lower() == "local":
threading.Thread(target=_preload_image_pipe, daemon=True).start()
WELCOME = ("Every world has its hidden archetypes.<br>"
"Name one, and I shall draw out its fortune.")
EXAMPLE_THEMES = ["physics", "birds", "lord of the rings characters", "breakfast foods"]
SPREAD_CHOICES = [("Single card", "single"), ("Past · Present · Future", "three")]
PAGE_ORDER = ["landing", "taro", "generate", "pick", "reading", "view"]
def _abs(art_path: str | None):
return os.path.join(ROOT, art_path) if art_path else None
def url_for(art_path: str | None) -> str:
# Served by Gradio's own static-file route (demo.launch + allowed_paths). This
# replaces the FastAPI /decks mount, which is incompatible with ZeroGPU (ZeroGPU
# needs gradio's native launch, not a custom uvicorn-mounted app).
return f"/gradio_api/file={_abs(art_path)}" if art_path else ""
def disp_url(c: dict) -> str:
"""Lightweight WEBP for display; falls back to the full PNG if absent."""
return url_for(c.get("disp_path") or c.get("art_path"))
def full_url(c: dict) -> str:
"""Capped high-quality WEBP loaded when a card is clicked (not the giant PNG)."""
return url_for(c.get("full_path") or c.get("art_path"))
with open(os.path.join(ROOT, "frontend", "deckview.html"), encoding="utf-8") as _f:
_DECKVIEW_HTML = _f.read()
def _card_desc(c: dict) -> str:
"""Short caption text for a card — its essence if the loremaster has written it,
else the (draft) upright meaning, trimmed."""
d = (c.get("essence") or c.get("upright_meaning") or "").strip()
d = d.replace("**", "").replace("*", "") # drop raw markdown markers in the caption
return d[:200]
def _status_paths(deck: dict):
dd = deck_dir(deck["deck_id"])
return os.path.join(dd, "art.json"), os.path.join(dd, "lore.json")
def _write_json_atomic(path: str, data: dict) -> None:
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f)
os.replace(tmp, path) # atomic → the poller never reads a half-written file
def _write_art_status(deck: dict, done: bool) -> None:
"""Live status the deck-view widget polls: which cards are painted (num → url)."""
art, _ = _status_paths(deck)
cards = {c["arcana_number"]: disp_url(c) for c in deck["cards"] if c.get("disp_path")}
_write_json_atomic(art, {"cards": cards, "done": done,
"back": url_for(deck.get("back_disp") or deck.get("back_path"))})
def _write_lore_status(deck: dict, done: bool) -> None:
"""Live status the widget polls: each card's (refined) meaning."""
_, lore = _status_paths(deck)
cards = {c["arcana_number"]: {"essence": c.get("essence", ""),
"upright": c.get("upright_meaning", ""),
"reversed": c.get("reversed_meaning", "")}
for c in deck["cards"]}
_write_json_atomic(lore, {"cards": cards, "done": done})
def deck_iframe(deck: dict | None, nonce: int = 0, live: bool = False,
art_url: str = "", lore_url: str = "") -> str:
"""The deck browser — a self-contained coverflow/overhead widget embedded via an
iframe ``srcdoc`` (the whole widget HTML is inlined, with the deck's image URLs +
names injected as base64 JSON in a global). Using srcdoc instead of a /viewer
static mount keeps everything inside gradio's native server (ZeroGPU-compatible).
All swipe/flip/fade/toggle/lightbox is handled client-side in the widget.
live=True seeds the widget with names + (draft) descriptions and EMPTY art (a
shimmer placeholder per card); the widget then polls `art_url`/`lore_url` and
fills each card's art + refined meaning in as the two GPU phases finish."""
if not deck or not deck.get("cards"):
return "<div class='spread-empty'>No deck to show.</div>"
data = {
"back": url_for(deck.get("back_disp") or deck.get("back_path")),
"live": bool(live), "artUrl": art_url, "loreUrl": lore_url,
"cards": [{"num": c["arcana_number"], "name": c["concept"],
"arc": c.get("arcana_name", ""), "desc": _card_desc(c),
"front": disp_url(c), "full": full_url(c)}
for c in deck["cards"]],
}
b64 = base64.b64encode(json.dumps(data).encode("utf-8")).decode()
inject = f"<script>window.DECK_B64={json.dumps(b64)};</script>"
doc = _DECKVIEW_HTML.replace("</head>", inject + "</head>", 1)
srcdoc = html.escape(doc, quote=True)
return (f"<iframe srcdoc='{srcdoc}' title='Deck' "
"style='width:100%;height:74vh;max-height:660px;min-height:520px;border:0;"
"display:block;background:transparent;'></iframe>")
def zip_deck(deck: dict | None):
"""Bundle the current deck (deck.json + card images) into a zip for download."""
if not deck:
return None
did = deck.get("deck_id", "deck")
d = deck_dir(did)
zpath = os.path.join(tempfile.gettempdir(), f"arcana-{slugify(did)}.zip")
with zipfile.ZipFile(zpath, "w", zipfile.ZIP_DEFLATED) as z:
z.writestr("deck.json", json.dumps(deck, indent=2, ensure_ascii=False))
if os.path.isdir(d):
for root, _, files in os.walk(d):
for fn in files:
if fn == "deck.json":
continue
fp = os.path.join(root, fn)
z.write(fp, os.path.relpath(fp, d))
return zpath
def deck_label(d: dict) -> str:
return f"{d['theme']} · {d.get('visual_style','?')}"
def deck_choices():
return [(deck_label(d), d["deck_id"]) for d in list_decks()]
def show(target: str):
return [gr.update(visible=(p == target)) for p in PAGE_ORDER]
# ----------------------------------------------------------------- reading theatre
# pacing
FLIP_BEAT = 0.25 # brief beat after a card flips before its reading starts
FADE_STEP = 0.06 # per-word fade stagger (client-side CSS), seconds
READ_HOLD = 1.3 # extra dwell after a card's reading finishes fading
WORDS_FADE_CAP = 14.0 # never wait longer than this for a fade to finish
def _fade(text: str, step: float = FADE_STEP, start: float = 0.0) -> str:
"""Wrap each word in a span that fades in on a staggered delay — the browser
animates the whole block in one render (no per-word server round-trips).
The model likes to emphasise with Markdown (**bold**, *italic*); rather than fight
it we honour it as a formatting cue. We split the text into styled runs and fade
word-by-word within them, so the effect and the HTML both stay valid."""
runs, pos = [], 0
for m in re.finditer(r"\*\*(.+?)\*\*|\*(.+?)\*", text, flags=re.DOTALL):
if m.start() > pos:
runs.append((text[pos:m.start()], False, False))
if m.group(1) is not None:
runs.append((m.group(1), True, False))
else:
runs.append((m.group(2), False, True))
pos = m.end()
if pos < len(text):
runs.append((text[pos:], False, False))
out, idx = [], 0
for seg, bold, ital in runs:
cls = "fw" + (" fwb" if bold else "") + (" fwi" if ital else "")
for w in seg.split():
out.append(f"<span class='{cls}' style='animation-delay:{start + idx * step:.2f}s'>"
f"{html.escape(w)} </span>")
idx += 1
return "".join(out)
def _flip_card(d: dict, back_url: str, anim: bool) -> str:
rev = d["orientation"] == "reversed"
front, back = disp_url(d), url_for(back_url) # lightweight WEBPs
cls = "flip-inner up" + (" anim" if anim else "")
return (f"<div class='flip active-flip'><div class='{cls}'>"
f"<div class='face back'><img src='{html.escape(back)}' alt=''></div>"
f"<div class='face front'><img src='{html.escape(front)}' "
f"class='{'reversed' if rev else ''}' alt=''></div></div></div>")
def _still_card(d: dict) -> str:
rev = d["orientation"] == "reversed"
return (f"<img class='still-card {'reversed' if rev else ''}' "
f"src='{html.escape(disp_url(d))}' alt=''>")
def _text_col(d: dict, body_html: str) -> str:
head = (f"<div class='r-head'>{html.escape(d['position'])} "
f"<span class='r-or'>· {d['orientation']}</span></div>")
ess = (f"<div class='r-ess'>{html.escape(d.get('essence', ''))}</div>"
if d.get("essence") else "")
body = f"<div class='r-body'>{body_html}</div>" if body_html else ""
return f"<div class='text-col'>{head}{ess}{body}</div>"
def _stage_active(back_url, active=None, synth_html=""):
"""The TOP area: the currently-active card (+ its reading), or the synthesis.
Rendered ABOVE the Next button so Next always sits right under the live card —
the already-read history goes in _stage_done, below Next."""
parts = []
if active is not None:
d, body, anim = active
parts.append(f"<div class='active'><div class='card-col'>"
f"{_flip_card(d, back_url, anim)}</div>{_text_col(d, body)}</div>")
if synth_html:
parts.append(f"<div id='fortune-box'>{synth_html}</div>")
return f"<div class='reading-stage'>{''.join(parts)}</div>"
def _stage_done(completed):
"""The BOTTOM area: the list of already-read cards (below the Next button)."""
if not completed:
return ""
rows = "".join(f"<div class='done-row'><div class='card-col'>{_still_card(d)}</div>"
f"{_text_col(d, body)}</div>" for d, body in completed)
return f"<div class='reading-stage'><div class='completed'>{rows}</div></div>"
# Manual, reader-paced theatre (§14): "Draw" reveals the first card; a "Next" button
# advances one beat at a time so people can read in their own time. Reading state
# lives in a session dict carried in gr.State between clicks.
def _next_btn(label: str | None):
return gr.update(value=label, visible=True) if label else gr.update(visible=False)
@GPU(duration=300)
def _gpu_reading(deck, question, drawn):
"""Compute ALL card readings + the synthesis in ONE GPU session (the GGUF
weights can't be cached across separate ZeroGPU calls). Revealed progressively
client-side afterwards. Returns (partials, synthesis)."""
from arcana.timing import record
import time as _t
llm = get_llm()
partials = []
tc = _t.time()
for i in range(len(drawn)):
try:
partials.append(card_partial(deck, question, drawn, i, llm=llm))
except Exception:
partials.append("")
record("reading_cards", _t.time() - tc, {"n": len(drawn)})
ts = _t.time()
try:
synth = final_synthesis(deck, question, drawn, partials, llm=llm)
except Exception as e:
synth = f"(the synthesis slips away — {type(e).__name__})"
record("reading_synthesis", _t.time() - ts)
return partials, synth
def _reveal_card(sess):
"""Flip the card at sess['i'] into the active area and reveal its (already
computed) reading. Yields (active_html, done_html, sess, next_button_update)."""
drawn, back, i = sess["drawn"], sess["back"], sess["i"]
d, completed = drawn[i], sess["completed"]
done_html = _stage_done(completed)
label = "Next card ▸" if i + 1 < len(drawn) else "Reveal the verdict ▸"
# flip + essence show first, then the deeper reading fades in a beat later
yield _stage_active(back, active=(d, "", True)), done_html, sess, _next_btn(label)
time.sleep(FLIP_BEAT)
p = sess["partials_pre"][i] if i < len(sess.get("partials_pre", [])) else ""
sess["last_p"] = p
body = _fade(p) if p else ""
yield _stage_active(back, active=(d, body, False)), done_html, sess, _next_btn(label)
def do_draw(deck, question, spread, reversals):
"""Begin a reading: draw the spread, compute every card's reading on the GPU in
one pass, then reveal card by card as the reader clicks Next."""
if not deck:
yield ("<div class='spread-empty'>Conjure or open a deck first.</div>", "",
None, _next_btn(None))
return
drawn = draw_spread(deck, spread=spread, reversals=reversals)
yield ("<div class='spread-empty'>…the oracle contemplates the spread…</div>", "",
None, _next_btn(None))
partials, synth = _gpu_reading(deck, question, drawn)
sess = {"deck": deck, "drawn": drawn, "question": question,
"back": deck.get("back_disp") or deck.get("back_path"), "i": 0, "completed": [],
"partials_pre": partials, "synth_pre": synth, "last_p": "", "phase": "card"}
yield from _reveal_card(sess)
def do_next(sess):
"""Advance the reading one beat (reader-paced)."""
if not sess or sess.get("phase") != "card":
yield gr.update(), gr.update(), sess, _next_btn(None)
return
# drop the current card to the history list below, with its full reading
d = sess["drawn"][sess["i"]]
lp = sess.get("last_p") or ""
sess["completed"].append((d, f"<span>{html.escape(lp)}</span>" if lp else ""))
if sess["i"] + 1 < len(sess["drawn"]):
sess["i"] += 1
yield from _reveal_card(sess)
return
# all cards read — reveal the synthesis in the (top) active area
sess["phase"] = "synth"
back = sess["back"]
done_html = _stage_done(sess["completed"])
yield (_stage_active(back, synth_html="<div class='syn-load'>…the oracle gathers the threads…</div>"),
done_html, sess, _next_btn(None))
time.sleep(FLIP_BEAT)
synth = sess.get("synth_pre") or "(the synthesis slips away)"
sess["phase"] = "done"
yield (_stage_active(back, synth_html=f"<h3>✦ The cards together</h3>"
f"<div class='syn-body'>{_fade(synth)}</div>"),
done_html, sess, _next_btn(None))
# ----------------------------------------------------------------- generate
def do_conjure(theme, style, custom):
"""Build a deck in a worker thread, streaming progress; when done, open the
deck in the SAME Deck-View/Overhead browser used by View Deck (so you can flip
through the new cards), with Read / Save actions there.
Outputs: gen_status, deck_state, vd_deck, vd_html, download_btn, *pages,
read_btn, download_btn2(view_note)."""
HOLD = (gr.update(),) * 4 # deck_state, vd_deck, vd_html, download_btn
# read_btn + view_note tail (grey out Read/Download while the deck is still painting)
TAIL_IDLE = (gr.update(), gr.update())
READ_BUSY = gr.update(interactive=False, value="🎴 Painting the deck…")
READ_DONE = gr.update(interactive=True, value="✦ Read this deck")
NOTE_BUSY = ("🎴 Painting the cards & writing the lore — the reading and download "
"unlock once the whole deck is complete.")
def stay(status): # progress: update only the status line, stay on the generate page
return (status, *HOLD, *show("generate"), *TAIL_IDLE)
NOOP = (gr.update(),) * (5 + len(PAGE_ORDER) + 2) # change nothing, keep the stream alive
theme = (theme or "").strip()
if not theme:
yield stay("Name a theme to begin."); return
# PHASE 1 — the mapping (its own GPU call): theme → 22 named cards with draft
# meanings + art briefs. As soon as it lands we jump STRAIGHT TO THE DECK VIEW.
t_map0 = time.time()
deck = None
for kind, payload, frac in _gpu_map(theme, style, custom):
if kind == "error":
yield stay(f"⚠️ The conjuring faltered ({payload}). Try again."); return
elif kind == "progress":
yield stay(_loader("✦ Finding the cards", payload,
f"mapping {html.escape(theme)} onto the 22 cards of the major arcana…"))
elif kind == "done":
deck = payload
if not deck:
yield stay("⚠️ The conjuring faltered (no deck). Try again."); return
t_map = time.time() - t_map0
# Fan the 22 cards across the GPU slots, round-robin balanced.
by = {c["arcana_number"]: c for c in deck["cards"]}
nums = [c["arcana_number"] for c in deck["cards"]]
chunks = [c for c in (nums[i::PARALLEL_GPUS] for i in range(PARALLEL_GPUS)) if c]
NW = len(chunks)
def merge_fields(res, chunk, fields):
rb = {c["arcana_number"]: c for c in (res or {}).get("cards", [])}
for n in chunk:
src = rb.get(n, {})
for k in fields:
if src.get(k):
by[n][k] = src[k]
# PHASE 2 — SCRIBE (parallel): generate all meanings + art prompts behind a LOADER
# (the deck view is withheld until the lore is fully written, per the new design).
t_scribe0 = time.time()
def scribe_tick(n):
pct = int(100 * n / NW)
bar = f"<div class='gen-bar'><div style='width:{pct}%'></div></div>"
return stay("<div class='gen-load'><div class='gen-spin'></div>"
"<h2>✦ Writing the lore</h2><p>the scribe inks the 22 cards…</p>"
f"<p class='gen-sub'>{n}/{NW} batches</p>{bar}</div>")
yield from _run_parallel(chunks, fn=lambda i, ch: _gpu_scribe(deck, ch),
on_done=lambda res, ch: merge_fields(res, ch, _SCRIBE_OUT),
tick=scribe_tick)
# RECONCILE: if a scribe chunk failed, its cards still have blank meaning/art.
# Re-run the scribe on JUST those (one more GPU pass) so we never paint an empty,
# subjectless prompt — which FLUX fills with a generic medieval/religious figure —
# and so those cards aren't left meaningless in the reading.
def _blank(c):
return not (c.get("art_prompt") or "").strip()
missing = [c["arcana_number"] for c in deck["cards"] if _blank(c)]
for _try in range(2):
if not missing:
break
yield stay("<div class='gen-load'><div class='gen-spin'></div>"
"<h2>✦ Writing the lore</h2><p>finishing the last cards…</p></div>")
try:
merge_fields(_gpu_scribe(deck, missing), missing, _SCRIBE_OUT)
except Exception as e:
print(f"[SCRIBE_RECONCILE_ERR] {missing}: {type(e).__name__}: {e}", flush=True)
missing = [c["arcana_number"] for c in deck["cards"] if _blank(c)]
if missing: # still blank after retries → at least give FLUX the concept as subject
print(f"[SCRIBE_FALLBACK] concept-only art for {missing}", flush=True)
for c in deck["cards"]:
if _blank(c):
c["art_prompt"] = c["concept"]
t_scribe = time.time() - t_scribe0
# Lore is done → OPEN THE REAL DECK VIEW now (names + full meanings); the art then
# lazy-loads card-by-card (the iframe polls art.json, which we rebuild from disk).
_write_art_from_disk(deck, done=False)
art_url = url_for(os.path.relpath(_status_paths(deck)[0], ROOT))
yield ("", deck, deck,
deck_iframe(deck, _next_nonce(), live=True, art_url=art_url, lore_url=""),
gr.update(interactive=False), *show("view"), READ_BUSY, NOTE_BUSY)
# PHASE 3 — PAINT (parallel): each tick we rebuild art.json from whatever card
# images exist on disk, so cards fill in INDIVIDUALLY as their files land (lazy),
# not in chunk-batches.
def merge_paint(res, ch):
merge_fields(res, ch, _PAINT_OUT)
for bk in ("back_path", "back_disp"):
if (res or {}).get(bk):
deck[bk] = res[bk]
def paint_tick(n):
_write_art_from_disk(deck, done=False)
return NOOP
t_paint0 = time.time()
yield from _run_parallel(chunks, fn=lambda i, ch: _gpu_paint(deck, ch, i == 0),
on_done=merge_paint, tick=paint_tick)
_reconcile_from_disk(deck) # rescue any cards a failed chunk left unmerged
_write_art_from_disk(deck, done=True)
t_paint = time.time() - t_paint0
# timings go to the LOGS only (the [TIMING] lines + /timings), not the UI
from arcana.timing import record as _rec
_rec("conjure_total", t_map + t_scribe + t_paint,
{"map": round(t_map), "scribe": round(t_scribe), "paint": round(t_paint)})
yield ("", deck, deck, deck_iframe(deck, _next_nonce()),
gr.update(value=zip_deck(deck), interactive=True), *show("view"),
READ_DONE, "")
_SCRIBE_OUT = ("essence", "upright_meaning", "reversed_meaning", "art_prompt")
_PAINT_OUT = ("art_path", "disp_path", "full_path", "seed")
# Fan-out width. ZeroGPU grants ~4-5 concurrent slots (fluctuating) for this account,
# so 4 fits in one wave with minimal model-load overhead; higher just queues into extra
# waves. Env-overridable for experiments.
PARALLEL_GPUS = int(os.environ.get("PARALLEL_GPUS", "4"))
def _run_parallel(chunks, fn, on_done, tick):
"""Run fn(i, chunk) for each chunk on its own thread → concurrent @spaces.GPU
calls. As each finishes, on_done(result, chunk) merges it; each loop yields
tick(n_done) to refresh the UI / status while the GPUs work (keeps the Gradio
stream alive). Chunk errors are LOGGED (not swallowed) so a failed chunk is
visible — the caller reconciles from disk afterwards so its cards aren't lost."""
import concurrent.futures as _cf
with _cf.ThreadPoolExecutor(max_workers=len(chunks)) as ex:
futs = {ex.submit(fn, i, ch): ch for i, ch in enumerate(chunks)}
pending, n = set(futs), 0
while pending:
for f in [x for x in pending if x.done()]:
pending.discard(f)
try:
on_done(f.result(), futs[f])
except Exception as e:
print(f"[CHUNK_ERR] cards {futs[f]}: {type(e).__name__}: {e}", flush=True)
n += 1
yield tick(n)
time.sleep(0.4)
def _reconcile_from_disk(deck: dict) -> dict:
"""Fill any card's image paths from the files that ACTUALLY exist on disk. Guards
against a paint chunk that errored after saving files but before its result merged
— without this, such cards stay blank (shimmering) in the final deck view."""
dd = deck_dir(deck["deck_id"])
thumbs = os.path.join(dd, "thumbs")
for c in deck["cards"]:
base = f"{c['arcana_number']:02d}_{slugify(c['concept'])}"
for key, path in (("art_path", os.path.join(dd, base + ".png")),
("disp_path", os.path.join(thumbs, base + ".webp")),
("full_path", os.path.join(thumbs, base + ".full.webp"))):
if not c.get(key) and os.path.exists(path):
c[key] = os.path.relpath(path, ROOT)
for key, path in (("back_path", os.path.join(dd, "back.png")),
("back_disp", os.path.join(thumbs, "back.webp"))):
if not deck.get(key) and os.path.exists(path):
deck[key] = os.path.relpath(path, ROOT)
return deck
def _write_art_from_disk(deck: dict, done: bool) -> None:
"""Rebuild art.json from the card images that ACTUALLY exist on disk — so the
deck view fills in card-by-card (lazy) as each file lands, regardless of which
parallel chunk produced it. Disp path is deterministic from arcana#+concept."""
thumbs = os.path.join(deck_dir(deck["deck_id"]), "thumbs")
cards = {}
for c in deck["cards"]:
p = os.path.join(thumbs, f"{c['arcana_number']:02d}_{slugify(c['concept'])}.webp")
if os.path.exists(p):
cards[c["arcana_number"]] = url_for(os.path.relpath(p, ROOT))
bp = os.path.join(thumbs, "back.webp")
back = url_for(os.path.relpath(bp, ROOT)) if os.path.exists(bp) else ""
_write_json_atomic(_status_paths(deck)[0], {"cards": cards, "back": back, "done": done})
def _loader(title: str, msg: str, sub: str) -> str:
return ("<div class='gen-load'><div class='gen-spin'></div>"
f"<h2>{title}</h2><p>{html.escape(msg)}</p>"
+ (f"<p class='gen-sub'>{sub}</p>" if sub else "") + "</div>")
def _conjure_grid(deck: dict, painted: dict, title: str, sub: str,
show_desc: bool, frac: float | None = None) -> str:
"""Progressive deck-view grid: every card shows its name immediately, its
description once the loremaster has written it, and its art the moment FLUX
finishes painting it (a shimmer placeholder until then)."""
cells = []
for c in deck["cards"]:
num = c["arcana_number"]
name = html.escape(c.get("concept", ""))
arc = html.escape(c.get("arcana_name", ""))
url = painted.get(num)
art = (f"<img src='{url}' alt='{name}' loading='lazy'/>" if url
else "<div class='cj-shim'></div>")
desc = ""
if show_desc:
d = c.get("essence") or c.get("upright_meaning", "")
if d:
desc = f"<p class='cj-desc'>{html.escape(d[:160])}</p>"
cells.append(
f"<div class='cj-card{' cj-lit' if url else ''}'>"
f"<div class='cj-art'>{art}</div>"
f"<div class='cj-meta'><span class='cj-arc'>{arc}</span>"
f"<span class='cj-name'>{name}</span>{desc}</div></div>")
bar = (f"<div class='gen-bar'><div style='width:{int((frac or 0) * 100)}%'></div></div>"
if frac is not None else "")
return ("<div class='cj-wrap'><div class='cj-head'>"
f"<div class='gen-spin'></div><h2>{title}</h2>"
f"<p class='gen-sub'>{sub}</p>{bar}</div>"
f"<div class='cj-grid'>{''.join(cells)}</div></div>")
def _threaded_progress(work):
"""Shared body for the @GPU phase generators: run `work(emit)` in a thread (so
it shares this call's attached GPU) and yield (kind, payload, frac) events."""
st = {"msg": "…", "frac": 0.0, "done": False, "result": None, "err": None}
def run():
try:
st["result"] = work(lambda m, f: st.update(msg=m, frac=f))
except Exception as e:
st["err"] = e
finally:
st["done"] = True
threading.Thread(target=run, daemon=True).start()
while not st["done"]:
yield ("progress", st["msg"], min(st["frac"], 0.99))
time.sleep(0.4)
if st["err"] or st["result"] is None:
why = f"{type(st['err']).__name__}: {st['err']}" if st["err"] else "no result"
yield ("error", why[:300], 0.0)
else:
yield ("done", st["result"], 1.0)
@GPU(duration=400)
def _gpu_map(theme, style, custom):
"""Phase 1 (own GPU call): the 30B designer mapping ONLY — theme → 22 named cards
with justifications, draft meanings, and art briefs. Returns fast enough to jump
straight to the deck view; the loremaster + FLUX then run in parallel. 400s covers
first-call warm-up + the single designer pass."""
from arcana.build import map_phase
yield from _threaded_progress(
lambda emit: map_phase(theme, visual_style=style, custom_style=custom, progress=emit))
@GPU(duration=240)
def _gpu_scribe(deck, subset=None):
"""Generative scribe (one parallel chunk): invent meaning + art_prompt for the
`subset` of arcana numbers (default all 22). Returns the deck with that subset
filled; the MAIN process merges chunks + writes lore.json (avoids 4 workers
clobbering one status file)."""
from arcana.build import scribe_phase
scribe_phase(deck, subset=subset)
return deck
@GPU(duration=200)
def _gpu_paint(deck, subset, paint_back=False):
"""Paint one parallel chunk of cards (+ the deck-back if paint_back). Saves images
to the shared disk and returns the deck with that subset's paths filled; the MAIN
process merges + writes art.json."""
from arcana.build import paint_subset
paint_subset(deck, subset, paint_back=paint_back)
return deck
@GPU(duration=300)
def _gpu_images(deck):
"""Phase 2 (own GPU call): paint back + 22 cards with FLUX (no LLM resident).
Streams each finished card's display URL as it's painted so the deck-view grid
can fill in card-by-card. paint_phase mutates `deck` in this worker; we scan it
each tick and ship the ready cards' URLs (the saved files are served by the main
process). Yields ('paint', {msg, cards:[{num,url}], back}, frac) then ('done', deck)."""
from arcana.build import paint_phase
st = {"msg": "…", "frac": 0.0, "done": False, "result": None, "err": None}
def run():
try:
st["result"] = paint_phase(deck, lambda m, f: st.update(msg=m, frac=f))
except Exception as e:
st["err"] = e
finally:
st["done"] = True
def snapshot():
cards = [{"num": c["arcana_number"], "url": disp_url(c)}
for c in deck["cards"] if c.get("disp_path")]
return {"msg": st["msg"], "cards": cards,
"back": url_for(deck.get("back_disp") or deck.get("back_path"))}
threading.Thread(target=run, daemon=True).start()
while not st["done"]:
_write_art_status(deck, done=False) # live status the deck view polls
yield ("paint", snapshot(), min(st["frac"], 0.99))
time.sleep(0.4)
if st["err"] or st["result"] is None:
_write_art_status(deck, done=True) # stop the poller even on failure
why = f"{type(st['err']).__name__}: {st['err']}" if st["err"] else "no result"
yield ("error", why[:300], 0.0); return
_write_art_status(deck, done=True)
yield ("paint", snapshot(), 1.0)
yield ("done", st["result"], 1.0)
def toggle_custom(style):
return gr.update(visible=(style == "custom"))
# ----------------------------------------------------------------- nav helpers
_vd_nonce = [0]
def _next_nonce():
_vd_nonce[0] += 1
return _vd_nonce[0]
# a bundled, pre-generated deck so anyone (incl. free/over-quota ZeroGPU users) can
# explore the full View + Read flow with zero generation cost — see "Open demo pack"
DEMO_ZIP = os.path.join(ROOT, "demo", "breakfast_foods_rws.zip")
def _load_deck_from_zip(path):
"""Reconstruct a deck from a downloaded .zip: read deck.json and extract its images
back into decks/<deck_id>/ so the saved (ROOT-relative) image paths resolve and the
files are servable. Returns the deck dict, or None on any problem."""
import zipfile
try:
with zipfile.ZipFile(path) as z:
deck = json.loads(z.read("deck.json").decode("utf-8"))
did = deck.get("deck_id") or "uploaded-deck"
deck["deck_id"] = did
dd = deck_dir(did)
os.makedirs(dd, exist_ok=True)
for m in z.namelist():
if m == "deck.json" or m.endswith("/"):
continue
if ".." in m or m.startswith("/"): # zip-slip guard
continue
tgt = os.path.join(dd, m)
os.makedirs(os.path.dirname(tgt), exist_ok=True)
with z.open(m) as src, open(tgt, "wb") as out:
out.write(src.read())
return deck if isinstance(deck, dict) and deck.get("cards") else None
except Exception:
return None
def open_from_zip(file, mode):
"""Open a deck from an UPLOADED .zip (portable, survives the ephemeral cache).
Outputs: deck_state, vd_deck, vd_html, download_btn, *pages, read_btn, view_note,
read_dl, pick_note, stage, stage_done, next_btn, reading_sess."""
read_on = gr.update(interactive=True, value="✦ Read this deck")
deck = _load_deck_from_zip(file) if file else None
if not deck:
return (gr.update(), gr.update(), gr.update(), gr.update(), *show("pick"),
gr.update(), gr.update(), gr.update(),
"Upload a valid deck **.zip** (the file you downloaded from a conjure).",
*NO_RESET)
z = zip_deck(deck) # re-zip so both download buttons have the file ready
if mode == "view":
return (deck, deck, deck_iframe(deck, _next_nonce()),
gr.update(value=z, interactive=True), *show("view"),
read_on, "", gr.update(value=z), "", *NO_RESET)
# read mode → straight to the reading room (clear any prior reading)
return (deck, deck, gr.update(), gr.update(value=z, interactive=True),
*show("reading"), read_on, "", gr.update(value=z), "", *RESET_READING)
def open_demo(mode):
"""Open the bundled demo deck (breakfast foods · Rider-Waite-Smith) in the current
mode (view or read) — no upload, no generation. Same outputs as open_from_zip."""
return open_from_zip(DEMO_ZIP, mode)
# clear any prior reading when the reading room re-opens (stage, history, Next btn,
# session) so a stale reading from a previous deck never lingers behind the new one
STAGE_INIT = "<div class='spread-empty'>Pose your question, then draw.</div>"
RESET_READING = (STAGE_INIT, "", gr.update(visible=False), None) # stage, stage_done, next_btn, reading_sess
NO_RESET = (gr.update(), gr.update(), gr.update(), gr.update())
def read_this_deck(deck):
"""Start a reading of the deck currently being viewed. Outputs: deck_state,
*pages, read_dl, stage, stage_done, next_btn, reading_sess."""
if not deck:
return (gr.update(), *show("view"), gr.update(), *NO_RESET)
return (deck, *show("reading"), gr.update(value=zip_deck(deck)), *RESET_READING)
def enter_generate():
"""Show the generate page and reset its bits. Outputs: *pages, custom_box, gen_status."""
return (*show("generate"), gr.update(visible=False), "")
def save_current(deck):
if deck:
save_deck(deck)
return "✦ Deck saved."
return "Nothing to save yet."
# ----------------------------------------------------------------- css
CSS = """
@import url('https://fonts.googleapis.com/css2?family=Cinzel+Decorative:wght@700;900&family=Cormorant+Garamond:ital,wght@0,500;0,600;0,700;1,500;1,600&family=Marcellus&display=swap');
:root { --gold:#d8b15a; --ink:#0b0716; --parch:#f3e6c4;
--font-display:'Cinzel Decorative', ui-serif, Georgia, serif;
--font-script:'Cormorant Garamond', ui-serif, Georgia, serif; /* fancy but readable */
--font-body:'Cormorant Garamond', ui-serif, Georgia, serif;
--font-ui:'Marcellus', ui-serif, Georgia, serif; }
.gradio-container { background:
radial-gradient(1200px 760px at 50% -6%, #241a3e 0%, #130d24 52%, #080510 100%) !important;
color: var(--parch) !important;
font-family: var(--font-body) !important; font-size: 1.7rem !important;
/* dark-theme Gradio's component fills so nothing reads as a white tile */
--block-background-fill: rgba(18,12,34,.55) !important;
--block-border-color: rgba(216,177,90,.22) !important;
--background-fill-primary: transparent !important;
--background-fill-secondary: rgba(18,12,34,.4) !important;
--input-background-fill: rgba(10,7,20,.62) !important;
--input-border-color: rgba(216,177,90,.25) !important;
--border-color-primary: rgba(216,177,90,.2) !important;
--body-text-color: var(--parch) !important;
--body-text-color-subdued: #cdbf93 !important;
/* component labels: no pill, just words */
--block-label-text-color: #e8d9a8 !important;
--block-title-text-color: #e8d9a8 !important;
--block-label-background-fill: transparent !important;
--block-title-background-fill: transparent !important;
--block-label-border-color: transparent !important;
--block-title-border-color: transparent !important;
--block-label-border-width: 0px !important;
--block-title-border-width: 0px !important; }
#deck, #preview { background:transparent !important; }
#deck .thumbnail-item, #preview .thumbnail-item, .thumbnail-item, .grid-wrap { background:transparent !important; }
.gradio-container::before { content:""; position:fixed; inset:0; pointer-events:none; z-index:0;
background-image:
radial-gradient(1px 1px at 20% 30%, #fff 50%, transparent),
radial-gradient(1px 1px at 70% 20%, #fff 50%, transparent),
radial-gradient(1px 1px at 40% 70%, #ffe9b0 50%, transparent),
radial-gradient(1px 1px at 85% 60%, #fff 50%, transparent),
radial-gradient(1px 1px at 55% 45%, #fff 50%, transparent),
radial-gradient(1px 1px at 10% 80%, #ffe9b0 50%, transparent),
radial-gradient(1px 1px at 90% 85%, #fff 50%, transparent);
opacity:.5; }
.gradio-container > * { position:relative; z-index:1; }
#brand { text-align:center; letter-spacing:6px; font-size:3.4rem; margin:14px 0 0; color:#f6ecd0;
text-shadow:0 2px 34px rgba(216,177,90,.6); font-family:var(--font-display); font-weight:900; }
#welcome { text-align:center; font-size:2.7rem; color:#f0e2b6; font-family:var(--font-script);
font-style:italic; font-weight:500; max-width:820px; margin:18px auto 28px; line-height:1.45;
text-shadow:0 2px 18px rgba(216,177,90,.3); }
.arc-h { text-align:center; color:#f1e4be !important; letter-spacing:2px; font-family:var(--font-display) !important; }
.arc-h h3 { font-size:2.5rem !important; }
.gradio-container h1,.gradio-container h2,.gradio-container h3,.gradio-container h4 { color:#f1e4be !important;
font-family:var(--font-display) !important; }
/* large but fluid so menus fit the viewport (scales down on smaller screens) */
.gradio-container button { font-family:var(--font-ui) !important;
font-size:clamp(1.5rem, 1.9vw, 2.1rem) !important; letter-spacing:.6px; padding:12px 20px !important; }
button.primary, .big-btn { background:linear-gradient(135deg,#9a7b2e,#d8b15a) !important;
color:#1a1208 !important; border:0 !important; font-weight:700 !important; letter-spacing:1px;
font-size:clamp(1.6rem, 2vw, 2.2rem) !important; padding:14px 24px !important; }
.gradio-container .secondary { border:1px solid rgba(216,177,90,.4) !important; color:#e7d9af !important;
background:rgba(216,177,90,.08) !important; }
/* component labels — render as clean subheadings (no pill), good contrast.
Gradio puts the label in a bare <span> inside .container; the pill bg/border
come from the --block-label/title vars killed above. */
.gradio-container label, .gradio-container label > span,
.gradio-container .container > span, .gradio-container .container > label > span,
.gradio-container span[data-testid="block-info"] {
font-family:var(--font-ui) !important; font-size:2rem !important;
color:#e8d9a8 !important; -webkit-text-fill-color:#e8d9a8 !important;
letter-spacing:.5px; background:transparent !important; border:0 !important;
box-shadow:none !important; padding:0 0 .35rem 0 !important; margin:0 !important; }
.gradio-container .block-info { font-size:1.5rem !important; color:#cdbf93 !important; }
.status { text-align:center; font-style:italic; color:#e2d5aa; min-height:1.3em; }
.status, .status p, .status strong, .status em, .status span {
font-size:2.5rem !important; line-height:1.45 !important; }
.status h3 { font-size:2.7rem !important; font-style:normal; margin:.4rem 0; }
/* two-stage conjure loader */
.gen-load { text-align:center; padding:34px 16px; }
.gen-load h2 { font-family:'Cinzel Decorative',serif; color:#d8b15a !important;
font-size:2.6rem !important; font-style:normal; margin:18px 0 8px; }
.gen-load p { color:#cdbf93 !important; font-style:italic; font-size:1.9rem !important;
margin:.2rem 0; line-height:1.4 !important; }
.gen-load .gen-sub { color:#9a8f70 !important; font-size:1.5rem !important; }
.gen-spin { width:58px; height:58px; margin:0 auto; border:6px solid rgba(216,177,90,.22);
border-top-color:#d8b15a; border-radius:50%; animation:gspin 1s linear infinite; }
@keyframes gspin { to { transform:rotate(360deg); } }
.gen-bar { max-width:440px; margin:20px auto 0; height:12px; background:rgba(216,177,90,.18);
border-radius:6px; overflow:hidden; }
.gen-bar > div { height:100%; background:linear-gradient(90deg,#9a7b2e,#d8b15a);
transition:width .35s ease; }
/* progressive conjure grid (deck view fills in as names/desc/art arrive) */
.cj-wrap { padding:14px 8px 26px; }
.cj-head { text-align:center; margin-bottom:18px; }
.cj-head h2 { font-family:'Cinzel Decorative',serif; color:#d8b15a !important;
font-size:2.3rem !important; margin:12px 0 6px; }
.cj-head .gen-sub { color:#9a8f70 !important; font-size:1.45rem !important;
font-style:italic; }
.cj-head .gen-bar { max-width:440px; }
.cj-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(150px,1fr));
gap:14px; max-width:1180px; margin:0 auto; }
.cj-card { background:rgba(20,16,30,.55); border:1px solid rgba(216,177,90,.20);
border-radius:10px; overflow:hidden; display:flex; flex-direction:column;
opacity:.55; transition:opacity .5s ease, border-color .5s ease; }
.cj-card.cj-lit { opacity:1; border-color:rgba(216,177,90,.5); }
.cj-art { aspect-ratio:3/5; position:relative; background:rgba(216,177,90,.04); }
.cj-art img { width:100%; height:100%; object-fit:cover; display:block;
animation:cjfade .6s ease; }
@keyframes cjfade { from { opacity:0; } to { opacity:1; } }
.cj-shim { position:absolute; inset:0; background:linear-gradient(100deg,
rgba(216,177,90,.05) 30%, rgba(216,177,90,.16) 50%, rgba(216,177,90,.05) 70%);
background-size:220% 100%; animation:cjshim 1.4s linear infinite; }
@keyframes cjshim { to { background-position:-220% 0; } }
.cj-meta { padding:8px 10px 11px; }
.cj-arc { display:block; color:#9a8f70 !important; font-size:1.0rem !important;
letter-spacing:.04em; text-transform:uppercase; }
.cj-name { display:block; font-family:'Cinzel Decorative',serif;
color:#e9d9a8 !important; font-size:1.45rem !important; line-height:1.2; margin-top:2px; }
.cj-desc { color:#bcae86 !important; font-size:1.12rem !important; font-style:italic;
line-height:1.35 !important; margin:5px 0 0 !important; }
/* suppress Gradio's pulsing blue/orange "generating" border-box on components
while an event streams (we have our own loaders) */
.gradio-container .generating,
.gradio-container .block.generating,
.gradio-container *.generating {
border-color: transparent !important; box-shadow: none !important;
animation: none !important; }
.gradio-container .generating::before, .gradio-container .generating::after {
display: none !important; animation: none !important; }
/* dark-theme the deck .zip upload widget (Gradio's default dropzone/preview is light) */
#pick-file, #pick-file .block, #pick-file .wrap, #pick-file .upload-container,
#pick-file [class*="upload"], #pick-file [class*="file-preview"], #pick-file table,
#pick-file td, #pick-file tr {
background: rgba(12,8,24,.78) !important; color:#f6ecd0 !important;
-webkit-text-fill-color:#f6ecd0 !important; border-color: rgba(216,177,90,.35) !important; }
#pick-file a, #pick-file .file-name { color:#e9d9a8 !important; -webkit-text-fill-color:#e9d9a8 !important; }
#footer { text-align:left !important; opacity:.75; font-size:.8rem !important;
margin:26px 0 8px; }
#footer p { font-size:.8rem !important; line-height:1.35 !important;
color:#cabd96 !important; margin:0 !important; }
#chips { flex-wrap:wrap !important; gap:6px !important; justify-content:center; }
#chips .secondary { border-radius:999px !important; font-size:1.7rem !important; padding:8px 20px !important; }
#deck img, #preview img { border-radius:8px; box-shadow:0 6px 20px rgba(0,0,0,.55); }
#deck .grid-wrap, #preview .grid-wrap, #deck .gallery, #preview .gallery { background:transparent !important; }
/* reading stage: active area (current card + reading) → completed list → synthesis (§4) */
#stage .reading-stage { display:flex; flex-direction:column; gap:28px; }
.active { display:flex; gap:32px; align-items:flex-start; background:rgba(20,13,36,.62);
border:1px solid rgba(216,177,90,.45); border-radius:18px; padding:26px 30px;
box-shadow:0 14px 46px rgba(0,0,0,.55); }
.active .card-col { flex:0 0 250px; }
.active .active-flip { width:clamp(150px, 18vw, 210px); perspective:1300px; }
.active .active-flip .flip-inner { position:relative; width:clamp(150px, 18vw, 210px);
height:clamp(230px, 27.5vw, 321px); transform-style:preserve-3d; }
.completed { display:flex; flex-direction:column; gap:22px; }
.done-row { display:flex; gap:28px; align-items:flex-start; background:rgba(14,9,26,.45);
border:1px solid rgba(216,177,90,.2); border-radius:14px; padding:18px 22px; }
.done-row .card-col { flex:0 0 150px; }
.still-card { width:150px; border-radius:9px; display:block; box-shadow:0 8px 24px rgba(0,0,0,.55); }
.still-card.reversed { transform:rotate(180deg); }
.text-col { flex:1; min-width:0; }
.r-head { font-family:var(--font-display); color:var(--gold); font-size:1.9rem; letter-spacing:1px; margin-bottom:10px; }
.r-or { color:#cdbf93; font-style:italic; }
.r-ess { font-style:italic; color:#cfc096; font-size:clamp(1.4rem, 1.7vw, 1.8rem); line-height:1.32; margin-bottom:12px; }
.active .r-body { font-size:clamp(1.5rem, 1.9vw, 2.05rem); line-height:1.45; color:#ece0bd; }
.done-row .r-body { font-size:2.1rem; line-height:1.45; color:#d9cda7; }
#fortune-box { background:rgba(16,10,30,.7); border:1px solid rgba(216,177,90,.45); border-radius:18px;
padding:30px 36px; box-shadow:0 14px 46px rgba(0,0,0,.5); }
#fortune-box h3 { font-family:var(--font-display); color:#e8c877; font-size:clamp(1.8rem, 2.2vw, 2.3rem); text-align:center; margin:.2rem 0 1rem; }
.syn-body { font-size:clamp(1.5rem, 1.9vw, 2.1rem); line-height:1.5; color:#f4e8c6; }
.syn-load { font-style:italic; color:#cdbf93; font-size:2rem; text-align:center; padding:10px; }
/* word fade-in (§2) — each word eases in on a staggered delay */
.fw { opacity:0; display:inline; animation:fadeWord .7s ease forwards; }
.fwb { font-weight:700; color:#e9d2a0; } /* the model's **bold** as emphasis */
.fwi { font-style:italic; }
@keyframes fadeWord { from { opacity:0; } to { opacity:1; } }
/* input contrast — light text on dark fields, never yellow-on-white (§4) */
.gradio-container textarea, .gradio-container input, .gradio-container select,
.gradio-container [contenteditable="true"], .gradio-container .wrap-inner input {
background:rgba(12,8,24,.78) !important; color:#f6ecd0 !important;
-webkit-text-fill-color:#f6ecd0 !important; font-size:2rem !important; }
.gradio-container ::placeholder { color:#b3a47e !important; -webkit-text-fill-color:#b3a47e !important; opacity:1 !important; }
/* checkboxes: a clearly-visible gold tick when checked (§5) */
.gradio-container input[type="checkbox"] {
accent-color: var(--gold) !important; -webkit-text-fill-color: initial !important;
width:1.6rem !important; height:1.6rem !important; min-width:1.6rem !important;
background: rgba(243,230,196,.85) !important; border:1px solid var(--gold) !important;
border-radius:4px !important; appearance:auto !important; -webkit-appearance:checkbox !important;
opacity:1 !important; cursor:pointer; }
/* "Allow reversals": nudge right + tighter wrap between the two words */
#rev-box { margin-left:10px !important; }
#rev-box label, #rev-box label span { line-height:1.12 !important; }
/* dropdown popup + options — never yellow-on-white (§5) */
.gradio-container ul[role="listbox"], .gradio-container .options, .gradio-container .option,
.gradio-container li[role="option"], .gradio-container li.item, .gradio-container [class*="options"] {
background:#16102a !important; color:#f3e6c4 !important; -webkit-text-fill-color:#f3e6c4 !important;
font-size:1.9rem !important; }
.gradio-container li[role="option"]:hover, .gradio-container .option:hover,
.gradio-container li[role="option"][aria-selected="true"], .gradio-container .option.selected,
.gradio-container .options .active, .gradio-container li.item.selected {
background:#33224f !important; color:#f8efce !important; -webkit-text-fill-color:#f8efce !important; }
.gradio-container label, .gradio-container .block-info, .gradio-container span[data-testid] { color:#d8cba2 !important; }
/* deck grid — card art clean, name printed BELOW (§3) */
.deck-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(168px, 1fr)); gap:26px 18px; padding:14px 4px; }
.dcard { margin:0; display:block; cursor:pointer; transition:transform .15s ease; }
.dcard:hover { transform:translateY(-4px); }
.dcard img { width:100%; border-radius:11px; display:block; box-shadow:0 8px 26px rgba(0,0,0,.6); }
/* view-deck toggle */
#view-toggle { margin:6px 0 22px; }
#view-toggle .wrap, #view-toggle fieldset, #view-toggle > div { justify-content:center !important; gap:28px !important; }
#view-toggle label { font-size:1.9rem !important; }
/* deck view — one card flanked by ‹ › arrows; click for full size */
#dv-row { flex-wrap:nowrap !important; align-items:center !important; justify-content:center !important; gap:18px !important; }
#dv-row > * { flex:0 0 auto !important; min-width:0 !important; }
#dv-stage { flex:1 1 auto !important; width:auto !important; }
.deck-view { display:flex; flex-direction:column; align-items:center; gap:14px; }
.dv-card { display:block; cursor:zoom-in; }
.dv-card img { width:clamp(280px, 40vw, 460px); max-height:64vh; object-fit:contain;
border-radius:14px; display:block; box-shadow:0 18px 54px rgba(0,0,0,.65); transition:transform .15s ease; }
.dv-card:hover img { transform:scale(1.015); }
.dv-cap { font-family:var(--font-display); color:var(--gold); font-size:2.2rem; text-align:center;
display:flex; gap:16px; align-items:baseline; justify-content:center; }
.dv-count { font-family:var(--font-body); color:#b6a982; font-size:1.5rem; font-style:italic; }
.dv-hint { color:#9a8f70; font-style:italic; font-size:1.4rem; }
.dv-arrow { font-size:3rem !important; color:#1a1208 !important; font-weight:700 !important;
background:linear-gradient(135deg,#9a7b2e,#d8b15a) !important; border:0 !important;
border-radius:14px !important; padding:6px 0 !important; line-height:1 !important;
width:66px !important; min-width:66px !important; max-width:66px !important; }
/* flip spread */
.spread { display:flex; gap:24px; justify-content:center; flex-wrap:wrap; padding:18px 0 6px; }
.flip { width:200px; margin:0; perspective:1200px; }
.flip-inner { position:relative; width:200px; height:306px; transform-style:preserve-3d; }
.flip-inner.up { transform:rotateY(0deg); }
.flip-inner.down { transform:rotateY(180deg); }
.flip-inner.anim { animation:flipIn .85s cubic-bezier(.4,.1,.2,1) both; }
@keyframes flipIn { from { transform:rotateY(180deg); } to { transform:rotateY(0deg); } }
.face { position:absolute; inset:0; backface-visibility:hidden; border-radius:12px; overflow:hidden;
box-shadow:0 12px 34px rgba(0,0,0,.6); }
.face img { width:100%; height:100%; object-fit:cover; display:block; }
.face.front img.reversed { transform:rotate(180deg); }
.face.back { transform:rotateY(180deg); border:2px solid var(--gold); }
figcaption { text-align:center; margin-top:12px; display:flex; flex-direction:column; gap:2px;
font-family:var(--font-body); }
figcaption .pos { color:var(--gold); font-size:.8rem; letter-spacing:2.5px; text-transform:uppercase; }
figcaption .concept { color:#f3e6c4; font-size:1.4rem; line-height:1.15; }
figcaption .orient { font-size:.92rem; font-style:italic; }
figcaption .orient.rev { color:#d98a78; } figcaption .orient.upr { color:#9ec99a; }
.spread-empty { text-align:center; color:#cdbf93; font-style:italic; padding:40px 20px; font-size:2.5rem; }
/* the reader-paced Next button, bottom-right */
/* Next floats over the reading area (always reachable, not buried below the cards) */
#next-row { position:fixed !important; right:30px; bottom:28px; z-index:60;
width:auto !important; margin:0 !important; padding:0 !important; background:transparent !important; }
#next-row, #next-row .block, #next-row .form { background:transparent !important; box-shadow:none !important; }
#next-row > * { flex:0 0 auto !important; min-width:0 !important; }
#next-row .big-btn { box-shadow:0 10px 30px rgba(0,0,0,.55), 0 0 0 1px rgba(216,177,90,.3) !important; }
footer { display:none !important; }
/* ---- robustness / responsiveness (consistent across resolutions) ---- */
/* components never clip or scroll their own content (kills stray scrollbars) */
.gradio-container .block, .gradio-container .form { overflow: visible !important; }
/* control rows wrap instead of overflowing on narrow screens */
.gradio-container .row:not(#dv-row):not(#next-row) { flex-wrap: wrap !important; }
#read-opts { gap:18px !important; }
/* fluid type so large text scales down gracefully on smaller viewports */
@media (max-width: 1150px) {
.gradio-container button { font-size:2rem !important; }
button.primary, .big-btn { font-size:2.1rem !important; }
#stage .r-head { font-size:1.6rem !important; }
.active .r-body, #reading, .syn-body, #fortune-box .syn-body { font-size:2rem !important; }
.r-ess { font-size:1.7rem !important; }
.dv-cap { font-size:1.9rem !important; }
}
@media (max-width: 820px) {
.gradio-container { font-size:1.4rem !important; }
.gradio-container button { font-size:1.7rem !important; }
button.primary, .big-btn { font-size:1.8rem !important; }
.active { flex-direction:column !important; align-items:center !important; }
.active .r-body, #reading, .syn-body { font-size:1.7rem !important; }
.done-row { flex-direction:column !important; align-items:center !important; }
}
"""
TRACE_ON = os.environ.get("ARCANA_TRACE", "") == "1" # the JG1310 trace-logging copy sets this
def _assemble_trace(steps, task, theme, visual_style, deck_id, models, pipeline, extra=None):
import hashlib
from datetime import datetime, timezone
trace_id = hashlib.sha1(f"{deck_id}:{task}".encode("utf-8")).hexdigest()[:8]
obj = {
"trace_id": trace_id, "app": "Arcana", "task": task,
"theme": theme, "visual_style": visual_style, "deck_id": deck_id,
"ts": datetime.now(timezone.utc).isoformat(),
"models": models, "pipeline": pipeline, "n_steps": len(steps),
"provenance": ("Live capture — every step's prompts, raw model output, and "
"latency were recorded as the pipeline ran (sequential trace "
"harness on the JG1310/arcana-gpu-dev trace-logging copy)."),
"steps": steps,
}
if extra:
obj.update(extra)
return obj
_TR_TEXT = {"name": "Qwen3-14B", "backend": "llama.cpp"}
_TR_IMG = {"name": "FLUX.2-klein-9B", "backend": "diffusers"}
def _heartbeat(target, args):
"""Run `target(*args, state)` in a worker thread, yielding a heartbeat every ~3s so
the SSE stream stays alive through long GPU phases; final yield is the result/ERROR.
Splitting the work into SHORT GPU calls (this is used per-phase) also avoids the
'GPU task aborted' that a single multi-minute ZeroGPU hold triggers."""
import threading, time as _t
state = {"done": False, "result": None, "err": None, "msg": "starting…"}
th = threading.Thread(target=target, args=(*args, state), daemon=True)
th.start()
while not state["done"]:
_t.sleep(3)
yield f"⏳ {state['msg']}"
yield ("ERROR\n" + state["err"]) if state["err"] else state["result"]
def _run_llm_phase(theme, style, custom, question, state):
"""designer → scribe (4 chunks) → reader, all through tracing wrappers. Returns the
deck + the LLM trace steps (no images here, so it stays a short ~150s GPU call)."""
import json as _json, traceback
from arcana import tracing, gpu_models as gm
from arcana.styles import resolve_style
from arcana.designer import design_deck
from arcana.loremaster import scribe_deck
from arcana.build import deck_dir, slugify, _seed_base
from arcana.reader import draw_spread, card_partial, final_synthesis
from arcana.llm import get_llm
try:
theme = (theme or "Greek mythology").strip()
question = (question or "").strip()
TEXT = {**_TR_TEXT, "repo": gm.MAP_REPO, "file": gm.MAP_FILE}
gtr = tracing.Tracer()
gllm = tracing.TracingLLM(get_llm(), gtr)
state["msg"] = "designer: mapping the 22 arcana…"
gtr.ctx("designer", "mapping")
deck = design_deck(theme, llm=gllm)
style_id, style_suffix = resolve_style(style, custom)
deck_id = f"{slugify(theme)}-{style_id}-{int(time.time())}"
os.makedirs(deck_dir(deck_id), exist_ok=True)
deck.update({"deck_id": deck_id, "theme": theme, "visual_style": style_id,
"style_suffix": style_suffix, "seed_base": _seed_base(theme, style_id)})
nums = [c["arcana_number"] for c in deck["cards"]]
chunks = [sorted(c) for c in (nums[i::4] for i in range(4)) if c]
for wi, ch in enumerate(chunks):
state["msg"] = f"scribe: writing lore + art ({wi + 1}/{len(chunks)} batches)…"
gtr.ctx("scribe", "meaning+art", worker=wi, cards=ch)
scribe_deck(deck, subset=ch, llm=gllm)
gen_llm_steps = gtr.steps
rtr = tracing.Tracer()
rllm = tracing.TracingLLM(get_llm(), rtr)
drawn = draw_spread(deck, spread="three", reversals=True, seed=7)
partials = []
for i in range(len(drawn)):
state["msg"] = f"reader: interpreting card {i + 1}/{len(drawn)}…"
rtr.ctx("reader", "card_reading", position=drawn[i]["position"],
concept=drawn[i]["concept"], orientation=drawn[i]["orientation"])
partials.append(card_partial(deck, question, drawn, i, llm=rllm))
state["msg"] = "reader: final synthesis…"
rtr.ctx("reader", "synthesis")
final_synthesis(deck, question, drawn, partials, llm=rllm)
meta = {"theme": theme, "visual_style": style_id, "deck_id": deck_id,
"question": question or "(open reading)", "spread": "three",
"text_model": TEXT, "image_model": _TR_IMG,
"drawn": [{"position": d["position"], "concept": d["concept"],
"orientation": d["orientation"]} for d in drawn]}
state["result"] = _json.dumps({"deck": deck, "gen_llm_steps": gen_llm_steps,
"read_steps": rtr.steps, "meta": meta},
ensure_ascii=False)
except Exception:
state["err"] = traceback.format_exc()
finally:
state["done"] = True
def _run_paint_phase(deck_json, subset_csv, state):
"""Trace the FLUX image calls for a subset of cards (or 'back'). Image is discarded —
the trace records the real call (styled prompt the model receives, seed, latency).
Kept to a short subset per call so the GPU hold stays well under the abort threshold."""
import json as _json, traceback
from arcana import tracing, gpu_models as gm
from arcana.imagegen import get_imagegen
from arcana.build import _back_prompt
try:
deck = _json.loads(deck_json)
by_n = {c["arcana_number"]: c for c in deck["cards"]}
style_suffix = deck.get("style_suffix")
seed_base = deck["seed_base"]
IMG = {**_TR_IMG, "repo": gm.IMAGE_MODEL}
tr = tracing.Tracer()
tig = tracing.TracingImageGen(get_imagegen(deck_style=style_suffix), tr, style_suffix, IMG["name"])
if subset_csv.strip().lower() == "back":
state["msg"] = "painter: deck-back…"
tig.generate(_back_prompt(style_suffix), seed=seed_base - 1)
tr.steps[-1]["card"] = {"deck_back": True}
else:
nums = [int(x) for x in subset_csv.split(",") if x.strip() != ""]
for i, m in enumerate(nums):
state["msg"] = f"painter: FLUX rendering {i + 1}/{len(nums)} (card {m})…"
tig.generate(by_n[m].get("art_prompt", ""), seed=seed_base + m)
tr.steps[-1]["card"] = {"arcana_number": m, "arcana_name": by_n[m].get("arcana_name"),
"concept": by_n[m].get("concept")}
state["result"] = _json.dumps({"paint_steps": tr.steps}, ensure_ascii=False)
except Exception:
state["err"] = traceback.format_exc()
finally:
state["done"] = True
@GPU(duration=300)
def trace_llm(theme, style, custom, question):
"""LIVE trace (trace-logging copy only): designer + scribe + reader in one short call."""
yield from _heartbeat(_run_llm_phase, (theme, style, custom, question))
@GPU(duration=300)
def trace_paint(deck_json, subset_csv):
"""LIVE trace (trace-logging copy only): FLUX image calls for a subset of cards."""
yield from _heartbeat(_run_paint_phase, (deck_json, subset_csv))
# ----------------------------------------------------------------- ui
def build_demo() -> gr.Blocks:
with gr.Blocks(title="Arcana") as demo:
deck_state = gr.State(None)
pick_mode = gr.State("read")
reading_sess = gr.State(None)
vd_deck = gr.State(None)
gr.HTML("<div id='brand'>✦ ARCANA ✦</div>")
# ---------- landing
with gr.Column(visible=True) as pg_landing:
gr.HTML(f"<div id='welcome'>{WELCOME}</div>")
with gr.Row():
gr.Column(scale=1, min_width=10)
btn_taro = gr.Button("✦ Tarot Reading", elem_classes=["big-btn"], scale=2)
btn_viewnav = gr.Button("◈ View Deck", scale=2)
gr.Column(scale=1, min_width=10)
# ---------- tarot submenu
with gr.Column(visible=True) as pg_taro:
gr.Markdown("### Tarot Reading", elem_classes=["arc-h"])
with gr.Row():
btn_gen_new = gr.Button("✦ Generate New Deck", elem_classes=["big-btn"], scale=2)
btn_read_existing = gr.Button("◈ Read from Existing Deck", scale=2)
taro_back = gr.Button("↩ Back", scale=1, elem_classes=["secondary"])
# ---------- generate
with gr.Column(visible=True) as pg_generate:
gr.Markdown("### Conjure a Deck", elem_classes=["arc-h"])
theme = gr.Textbox(
placeholder="Name a category to build a tarot deck from…",
show_label=False, value="", max_lines=1)
gr.Markdown("*…or tap an example:*", elem_classes=["status"])
with gr.Row(elem_id="chips"):
chips = [gr.Button(t, size="sm", elem_classes=["secondary"]) for t in EXAMPLE_THEMES]
# full-width dropdown; the custom-style box unfurls BELOW it (stacked),
# and only when "Custom…" is chosen — hidden by default so it never shows
# spuriously (e.g. during a conjure).
style = gr.Dropdown(STYLE_CHOICES, value="rider-waite-smith",
label="Visual style")
with gr.Column(visible=False) as custom_box:
custom = gr.Textbox(label="Describe your style",
placeholder=CUSTOM_PLACEHOLDER, max_lines=1)
conjure = gr.Button("✦ Conjure the deck", elem_classes=["big-btn"])
gen_status = gr.Markdown("", elem_classes=["status"])
gen_back = gr.Button("↩ Back", scale=1, elem_classes=["secondary"])
# ---------- pick (open a downloaded deck .zip — read-from-existing OR view)
with gr.Column(visible=True) as pg_pick:
pick_title = gr.Markdown("### Open a Deck", elem_classes=["arc-h"])
pick_file = gr.File(label="Upload a deck .zip (the file you downloaded from a "
"previous conjure)", file_types=[".zip"], type="filepath",
elem_id="pick-file")
pick_open = gr.Button("✦ Open", elem_classes=["big-btn"])
pick_demo = gr.Button("🍳 Open demo pack — breakfast foods (Rider-Waite-Smith)",
elem_classes=["secondary"])
pick_note = gr.Markdown("", elem_classes=["status"])
pick_back = gr.Button("↩ Back", scale=1, elem_classes=["secondary"])
# ---------- reading room
with gr.Column(visible=True) as pg_reading:
reading_head = gr.Markdown("### The Reading Room", elem_classes=["arc-h"])
question = gr.Textbox(label="Your question", max_lines=2,
placeholder="Ask the cards a question, or leave blank for an open reading…")
with gr.Row(elem_id="read-opts"):
spread = gr.Dropdown(SPREAD_CHOICES, value="three", label="Spread",
scale=3, min_width=260)
reversals = gr.Checkbox(value=True, label="Allow reversals",
scale=2, min_width=220, elem_id="rev-box")
draw = gr.Button("✦ Draw the cards", elem_classes=["big-btn"])
stage = gr.HTML(STAGE_INIT, elem_id="stage")
next_btn = gr.Button("Next card ▸", visible=False, elem_classes=["big-btn"],
elem_id="next-btn")
stage_done = gr.HTML("", elem_id="stage-done") # read-history, below Next
with gr.Row():
read_dl = gr.DownloadButton("⤓ Download deck (.zip)", scale=1,
elem_classes=["secondary"])
read_back = gr.Button("↩ Back to start", scale=1, elem_classes=["secondary"])
# ---------- view deck (coverflow + overhead, all client-side in the widget)
with gr.Column(visible=True) as pg_view:
view_head = gr.Markdown("### The Deck", elem_classes=["arc-h"])
vd_html = gr.HTML(elem_id="dv-stage")
with gr.Row():
read_btn = gr.Button("✦ Read this deck", elem_classes=["big-btn"], scale=2)
download_btn = gr.DownloadButton("⤓ Download deck (.zip)", scale=1,
elem_classes=["secondary"])
view_back = gr.Button("↩ Back to start", scale=1, elem_classes=["secondary"])
view_note = gr.Markdown("", elem_classes=["status"])
pages = [pg_landing, pg_taro, pg_generate, pg_pick, pg_reading, pg_view]
# attribution footer (subtle, persistent, bottom-left) — one credit per line,
# uniform grammar "X powered by <model> from <maker> · <licence>"; carries FLUX's notice
gr.Markdown(
"Text model powered by Qwen3-14B from Alibaba Cloud · Apache-2.0 \n"
"Art model powered by FLUX.2-klein from Black Forest Labs · FLUX.2 Non-Commercial \n"
"Compute powered by ZeroGPU from Hugging Face", elem_id="footer")
# ---------- wiring: navigation
btn_taro.click(lambda: show("taro"), None, pages)
taro_back.click(lambda: show("landing"), None, pages)
gen_back.click(lambda: show("taro"), None, pages)
pick_back.click(lambda: show("landing"), None, pages)
read_back.click(lambda: show("landing"), None, pages)
view_back.click(lambda: show("landing"), None, pages)
btn_gen_new.click(enter_generate, None, [*pages, custom_box, gen_status])
btn_read_existing.click(lambda: "read", None, pick_mode).then(
lambda: show("pick"), None, pages)
btn_viewnav.click(lambda: "view", None, pick_mode).then(
lambda: show("pick"), None, pages)
# show the custom-style field only when 'Custom…' is picked (toggle the box).
# .input (user-initiated) carries the just-selected value; .change could lag a
# selection behind, so the box only appeared on the 2nd pick.
style.input(toggle_custom, style, custom_box)
# example theme chips — one click sets the theme AND conjures it from
# scratch (the examples ARE live demos: every card is freshly generated).
# example chips just FILL the theme box (press Conjure to start) — they no
# longer auto-conjure
for chip, t in zip(chips, EXAMPLE_THEMES):
chip.click(lambda t=t: (t, f"“{t}” — press ✦ Conjure to begin."),
None, [theme, gen_status])
# generate → opens the new deck in the Deck-View/Overhead browser (pg_view)
conjure.click(do_conjure, [theme, style, custom],
[gen_status, deck_state, vd_deck, vd_html, download_btn, *pages,
read_btn, view_note])
# pick → open from uploaded .zip
_pick_outputs = [deck_state, vd_deck, vd_html, download_btn, *pages,
read_btn, view_note, read_dl, pick_note,
stage, stage_done, next_btn, reading_sess]
pick_open.click(open_from_zip, [pick_file, pick_mode], _pick_outputs)
pick_demo.click(open_demo, pick_mode, _pick_outputs)
read_btn.click(read_this_deck, vd_deck,
[deck_state, *pages, read_dl,
stage, stage_done, next_btn, reading_sess])
# reading
draw.click(do_draw, [deck_state, question, spread, reversals],
[stage, stage_done, reading_sess, next_btn])
next_btn.click(do_next, reading_sess, [stage, stage_done, reading_sess, next_btn])
# pages are created visible=True so gallery components mount cleanly
# (a Gradio Gallery built inside a visible=False column never mounts);
# on load, collapse to just the landing page.
demo.load(lambda: show("landing"), None, pages)
# TRACE-LOGGING COPY ONLY (ARCANA_TRACE=1): a hidden endpoint that runs the full
# pipeline through tracing wrappers and returns the live traces. Never registered
# on the production Space, so its behaviour is byte-identical there.
if TRACE_ON:
_llm_in = [gr.Textbox("Greek mythology", visible=False),
gr.Textbox("rider-waite-smith", visible=False),
gr.Textbox("", visible=False), gr.Textbox("", visible=False)]
_llm_out = gr.Textbox(visible=False)
gr.Button(visible=False).click(trace_llm, _llm_in, _llm_out, api_name="trace_llm")
_pt_deck = gr.Textbox("", visible=False)
_pt_sub = gr.Textbox("", visible=False)
_pt_out = gr.Textbox(visible=False)
gr.Button(visible=False).click(trace_paint, [_pt_deck, _pt_sub], _pt_out,
api_name="trace_paint")
return demo
os.makedirs(DECKS_DIR, exist_ok=True)
demo = build_demo()
demo.queue(default_concurrency_limit=4)
# ZeroGPU requires gradio's NATIVE launch — a custom uvicorn-mounted FastAPI app
# boots then immediately gets SIGTERM (ZeroGPU never registers). Card images are
# served via gradio's own static-file route (allowed_paths) instead of a /decks
# mount; the deck-view widget is inlined via iframe srcdoc instead of a /viewer
# mount. ssr_mode=False (ssr tries to bind a 2nd port and crashes here).
if __name__ == "__main__":
demo.launch(server_name="0.0.0.0", server_port=int(os.environ.get("PORT", 7860)),
allowed_paths=[DECKS_DIR, os.path.join(ROOT, "frontend")],
css=CSS, theme=gr.themes.Soft(), ssr_mode=False)