Spaces:
Sleeping
Sleeping
| """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) | |
| 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) | |
| 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)) | |
| 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 | |
| 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 | |
| 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 | |
| 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)) | |
| 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) | |