diff --git a/.gitattributes b/.gitattributes index a6344aac8c09253b3b630fb776ae94478aa0275b..26e8e64f8b77ef02f0fdbed16a52607de483cc88 100644 --- a/.gitattributes +++ b/.gitattributes @@ -33,3 +33,74 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text *.zip filter=lfs diff=lfs merge=lfs -text *.zst filter=lfs diff=lfs merge=lfs -text *tfevents* filter=lfs diff=lfs merge=lfs -text +assets/fonts/Cinzel.ttf filter=lfs diff=lfs merge=lfs -text +assets/fonts/EBGaramond.ttf filter=lfs diff=lfs merge=lfs -text +decks/breakfast_foods-playful-1780954994/00_the_banana.png filter=lfs diff=lfs merge=lfs -text +decks/breakfast_foods-playful-1780954994/01_the_whisk.png filter=lfs diff=lfs merge=lfs -text +decks/breakfast_foods-playful-1780954994/02_the_covered_dish.png filter=lfs diff=lfs merge=lfs -text +decks/breakfast_foods-playful-1780954994/03_the_stack_of_pancakes.png filter=lfs diff=lfs merge=lfs -text +decks/breakfast_foods-playful-1780954994/04_black_coffee.png filter=lfs diff=lfs merge=lfs -text +decks/breakfast_foods-playful-1780954994/05_the_continental_breakfast.png filter=lfs diff=lfs merge=lfs -text +decks/breakfast_foods-playful-1780954994/06_bacon_and_eggs.png filter=lfs diff=lfs merge=lfs -text +decks/breakfast_foods-playful-1780954994/07_the_toaster.png filter=lfs diff=lfs merge=lfs -text +decks/breakfast_foods-playful-1780954994/08_oatmeal.png filter=lfs diff=lfs merge=lfs -text +decks/breakfast_foods-playful-1780954994/09_the_lone_boiled_egg.png filter=lfs diff=lfs merge=lfs -text +decks/breakfast_foods-playful-1780954994/10_the_cereal_box_prize.png filter=lfs diff=lfs merge=lfs -text +decks/breakfast_foods-playful-1780954994/11_the_diner_check.png filter=lfs diff=lfs merge=lfs -text +decks/breakfast_foods-playful-1780954994/12_french_toast_soaking.png filter=lfs diff=lfs merge=lfs -text +decks/breakfast_foods-playful-1780954994/13_the_burnt_toast.png filter=lfs diff=lfs merge=lfs -text +decks/breakfast_foods-playful-1780954994/14_the_smoothie.png filter=lfs diff=lfs merge=lfs -text +decks/breakfast_foods-playful-1780954994/15_the_sugary_cereal.png filter=lfs diff=lfs merge=lfs -text +decks/breakfast_foods-playful-1780954994/16_the_dropped_tray.png filter=lfs diff=lfs merge=lfs -text +decks/breakfast_foods-playful-1780954994/17_fresh_squeezed_orange_juice.png filter=lfs diff=lfs merge=lfs -text +decks/breakfast_foods-playful-1780954994/18_the_mystery_omelette.png filter=lfs diff=lfs merge=lfs -text +decks/breakfast_foods-playful-1780954994/19_the_sunny_side_up_egg.png filter=lfs diff=lfs merge=lfs -text +decks/breakfast_foods-playful-1780954994/20_order_s_up.png filter=lfs diff=lfs merge=lfs -text +decks/breakfast_foods-playful-1780954994/21_the_full_english.png filter=lfs diff=lfs merge=lfs -text +decks/breakfast_foods-playful-1780954994/back.png filter=lfs diff=lfs merge=lfs -text +decks/lord_of_the_rings-bundled/00_frodo_baggins.png filter=lfs diff=lfs merge=lfs -text +decks/lord_of_the_rings-bundled/01_gandalf_the_grey.png filter=lfs diff=lfs merge=lfs -text +decks/lord_of_the_rings-bundled/02_the_mirror_of_galadriel.png filter=lfs diff=lfs merge=lfs -text +decks/lord_of_the_rings-bundled/03_lothlórien.png filter=lfs diff=lfs merge=lfs -text +decks/lord_of_the_rings-bundled/04_théoden_king.png filter=lfs diff=lfs merge=lfs -text +decks/lord_of_the_rings-bundled/05_the_council_of_elrond.png filter=lfs diff=lfs merge=lfs -text +decks/lord_of_the_rings-bundled/06_aragorn_and_arwen.png filter=lfs diff=lfs merge=lfs -text +decks/lord_of_the_rings-bundled/07_the_battle_of_pelennor_f.png filter=lfs diff=lfs merge=lfs -text +decks/lord_of_the_rings-bundled/08_gollum.png filter=lfs diff=lfs merge=lfs -text +decks/lord_of_the_rings-bundled/09_faramir_in_ithilien.png filter=lfs diff=lfs merge=lfs -text +decks/lord_of_the_rings-bundled/10_the_one_ring.png filter=lfs diff=lfs merge=lfs -text +decks/lord_of_the_rings-bundled/11_the_judgment_of_isildur.png filter=lfs diff=lfs merge=lfs -text +decks/lord_of_the_rings-bundled/12_gandalf_falls_in_moria.png filter=lfs diff=lfs merge=lfs -text +decks/lord_of_the_rings-bundled/13_sauron_s_defeat.png filter=lfs diff=lfs merge=lfs -text +decks/lord_of_the_rings-bundled/14_the_shire_after_the_war.png filter=lfs diff=lfs merge=lfs -text +decks/lord_of_the_rings-bundled/15_the_eye_of_sauron.png filter=lfs diff=lfs merge=lfs -text +decks/lord_of_the_rings-bundled/16_the_fall_of_barad_dûr.png filter=lfs diff=lfs merge=lfs -text +decks/lord_of_the_rings-bundled/17_the_light_of_eärendil.png filter=lfs diff=lfs merge=lfs -text +decks/lord_of_the_rings-bundled/18_the_misty_mountains.png filter=lfs diff=lfs merge=lfs -text +decks/lord_of_the_rings-bundled/19_the_reckoning_at_the_end.png filter=lfs diff=lfs merge=lfs -text +decks/lord_of_the_rings-bundled/20_the_scouring_of_the_shir.png filter=lfs diff=lfs merge=lfs -text +decks/lord_of_the_rings-bundled/21_the_return_of_the_king.png filter=lfs diff=lfs merge=lfs -text +decks/lord_of_the_rings-bundled/back.png filter=lfs diff=lfs merge=lfs -text +decks/thermodynamics-rider-waite-smith-1780952458/00_activation_energy.png filter=lfs diff=lfs merge=lfs -text +decks/thermodynamics-rider-waite-smith-1780952458/01_work.png filter=lfs diff=lfs merge=lfs -text +decks/thermodynamics-rider-waite-smith-1780952458/02_internal_energy.png filter=lfs diff=lfs merge=lfs -text +decks/thermodynamics-rider-waite-smith-1780952458/03_heat_reservoir.png filter=lfs diff=lfs merge=lfs -text +decks/thermodynamics-rider-waite-smith-1780952458/04_the_first_law.png filter=lfs diff=lfs merge=lfs -text +decks/thermodynamics-rider-waite-smith-1780952458/05_standard_state__stp.png filter=lfs diff=lfs merge=lfs -text +decks/thermodynamics-rider-waite-smith-1780952458/06_thermal_contact.png filter=lfs diff=lfs merge=lfs -text +decks/thermodynamics-rider-waite-smith-1780952458/07_the_heat_engine.png filter=lfs diff=lfs merge=lfs -text +decks/thermodynamics-rider-waite-smith-1780952458/08_heat_capacity.png filter=lfs diff=lfs merge=lfs -text +decks/thermodynamics-rider-waite-smith-1780952458/09_absolute_zero.png filter=lfs diff=lfs merge=lfs -text +decks/thermodynamics-rider-waite-smith-1780952458/10_the_carnot_cycle.png filter=lfs diff=lfs merge=lfs -text +decks/thermodynamics-rider-waite-smith-1780952458/11_the_second_law_s_toll.png filter=lfs diff=lfs merge=lfs -text +decks/thermodynamics-rider-waite-smith-1780952458/12_metastability.png filter=lfs diff=lfs merge=lfs -text +decks/thermodynamics-rider-waite-smith-1780952458/13_entropy.png filter=lfs diff=lfs merge=lfs -text +decks/thermodynamics-rider-waite-smith-1780952458/14_thermal_equilibrium.png filter=lfs diff=lfs merge=lfs -text +decks/thermodynamics-rider-waite-smith-1780952458/15_bound_energy.png filter=lfs diff=lfs merge=lfs -text +decks/thermodynamics-rider-waite-smith-1780952458/16_the_critical_point.png filter=lfs diff=lfs merge=lfs -text +decks/thermodynamics-rider-waite-smith-1780952458/17_free_energy.png filter=lfs diff=lfs merge=lfs -text +decks/thermodynamics-rider-waite-smith-1780952458/18_thermal_fluctuations.png filter=lfs diff=lfs merge=lfs -text +decks/thermodynamics-rider-waite-smith-1780952458/19_blackbody_radiation.png filter=lfs diff=lfs merge=lfs -text +decks/thermodynamics-rider-waite-smith-1780952458/20_the_third_law.png filter=lfs diff=lfs merge=lfs -text +decks/thermodynamics-rider-waite-smith-1780952458/21_the_universe.png filter=lfs diff=lfs merge=lfs -text +decks/thermodynamics-rider-waite-smith-1780952458/back.png filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..d078236f03fdf05ea795e3c1cd2056be0eb10dd5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.pyc +*.log +.DS_Store +# local-only scratch — not part of the Space +phase0_out/ +cards/ +/tmp/ diff --git a/README.md b/README.md index b493a7e81487fd1844863e4b54edc68499f6339b..619f3aaf5c7d2498488e1d3f9f4e903360eb6248 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,85 @@ --- title: Arcana -emoji: 💻 +emoji: 🔮 colorFrom: purple -colorTo: purple +colorTo: indigo sdk: gradio -sdk_version: 6.17.3 -python_version: '3.13' +sdk_version: 6.14.0 app_file: app.py pinned: false +license: mit --- -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +# 🔮 Arcana — a themed concept-tarot generator & reader + +Name any **domain or theme** — *thermodynamics*, *Lord of the Rings*, *breakfast +foods*, *the French Revolution* — and Arcana invents a whole tarot deck for it, +paints the 22 cards, and reads your fortune in them. + +Built for the **HF / Gradio Build Small Hackathon** — track *Adventure in +Thousand Token Wood*. + +## How it works — invent the meanings, then reason over them + +The load-bearing AI move is **not** the art (plenty of tools draw tarot cards). +It's the **semantic invention** — deciding what each domain concept *means +archetypally* — and then the **progressive reasoning over a spread** of those +concepts in combination. Both are language tasks, so a small ≤32B text model is +the right core. + +``` +theme + visual style + │ + ▼ + Deck Designer (Qwen-32B) maps the 22 Major Arcana → in-theme concepts, + │ writes upright/reversed meanings + art prompts + ▼ + Loremaster (Qwen-32B) rewrites every meaning to be native to its concept + │ + ├─► open image model (FLUX.1-schnell) → 22 central arts + one deck-back + │ │ + │ ▼ + │ Compositor (Pillow) art + reusable gold frame + crisp title/numeral + ▼ + Reader / Oracle (Qwen-32B) draws a spread and turns the cards one at a time — + a partial read as each lands, the synthesis held to the end +``` + +For example, mapping *thermodynamics*: **Entropy → Death**, **Absolute Zero → +The Hermit**, **The Critical Point → The Tower**. The fit has to feel +*clever-but-right*, never arbitrary — that quality is the whole point. + +## Everything is open and ≤32B + +No closed/frontier models anywhere. The language model is **Qwen3-32B** +(configurable; Qwen2.5-32B-class) and the image model is **FLUX.1-schnell** +(~12B, open) — both served through an OpenAI-compatible inference endpoint, so +the Space itself stays a lean CPU box. The same models run locally on a GPU via +the `local` backend (`LLM_BACKEND` / `IMAGE_BACKEND`). + +## Using it + +- **Tarot Reading → Generate New Deck** — type a theme, pick a visual style + (Rider-Waite-Smith, Thoth, Marseille, Playful, or your own *Custom* look), + conjure the deck, then save it or go straight into a reading. +- **Tarot Reading → Read from Existing Deck** / **View Deck** — open a bundled or + saved deck. Three demo decks ship with the Space. +- A reading turns the cards face-up one at a time with a partial interpretation + each, then weaves them into one synthesis. + +## Configuration + +| env var | default | meaning | +|---|---|---| +| `LLM_BACKEND` | `endpoint` | `endpoint` (hosted open ≤32B) or `local` (GPU) | +| `LLM_PROVIDER` | `hf` | `hf` (HF router) or `deepinfra` | +| `QWEN_MODEL` | `Qwen/Qwen3-32B` | the ≤32B reasoning model | +| `IMAGE_MODEL` | `black-forest-labs/FLUX-1-schnell` | the open ≤32B image model | +| `HF_TOKEN` / `DEEPINFRA_TOKEN` | — | provider keys (Space **secrets**) | + +Set provider keys as **Space secrets**; they are never hardcoded. + +## Assets & licensing + +Card art is generated by an open model. Title/numeral type is set in +[Cinzel](https://fonts.google.com/specimen/Cinzel) (OFL). MIT licensed. diff --git a/app.py b/app.py new file mode 100644 index 0000000000000000000000000000000000000000..701179e9cae8822a39a17ccddefbd46dbe84be6b --- /dev/null +++ b/app.py @@ -0,0 +1,394 @@ +"""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 on open ≤32B models via an inference endpoint; the Space is a lean +CPU box (no torch/diffusers), consistent with Globe. +""" +from __future__ import annotations + +import html +import os +import time + +import gradio as gr +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles + +from arcana.build import (DECKS_DIR, build_deck, list_decks, load_deck, save_deck) +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" + +WELCOME = ("Welcome, traveler. Every world has its hidden archetypes. " + "Name one, and I shall draw out its fortune.") +EXAMPLE_THEMES = ["the French Revolution", "breakfast foods", "Greek mythology"] +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: + return ("/" + art_path) if art_path else "" + + +def gallery_items(deck: dict | None): + if not deck: + return [] + return [(_abs(c["art_path"]), c["concept"]) for c in deck["cards"] if c.get("art_path")] + + +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 +def _card_html(d: dict | None, state: str, anim: bool) -> str: + """state: 'up' | 'down'. anim=True plays the flip on this render.""" + back = url_for(d.get("back_url")) if d else "" + front = url_for(d.get("art_path")) if d else "" + inner_cls = "flip-inner " + ("up" if state == "up" else "down") + (" anim" if anim else "") + if state == "up": + rev = d["orientation"] == "reversed" + cap = (f"
{html.escape(d['position'])}" + f"{html.escape(d['concept'])}" + f"{d['orientation']}
") + front_inner = f"" + else: + cap = "
   
" + front_inner = f"" + return (f"
" + f"
" + f"
{front_inner}
{cap}
") + + +def _spread_html(drawn, back_url, revealed: int, anim_idx: int | None) -> str: + cells = [] + for i, d in enumerate(drawn): + d = {**d, "back_url": back_url} + state = "up" if i < revealed else "down" + cells.append(_card_html(d, state, anim=(i == anim_idx))) + return f"
{''.join(cells)}
" + + +def _facedown_html(n: int, back_url: str) -> str: + cells = [_card_html({"back_url": back_url, "art_path": None}, "down", False) for _ in range(n)] + return f"
{''.join(cells)}
" + + +def _reading_md(drawn, partials, synthesis: str | None) -> str: + out = [] + for j, p in enumerate(partials): + d = drawn[j] + out.append(f"**{d['position']} — {d['concept']}** *({d['orientation']})*\n\n{p}") + if synthesis: + out.append("\n---\n\n### ✦ The cards together\n\n" + synthesis) + return "\n\n".join(out) + + +def do_reading(deck, question, spread, reversals): + """Generator: face-down → flip each card with its partial → held synthesis.""" + if not deck: + yield "
Conjure or open a deck first.
", "" + return + back_url = deck.get("back_path") + drawn = draw_spread(deck, spread=spread, reversals=reversals) + + yield _facedown_html(len(drawn), back_url), "_Focus on your question…_" + time.sleep(0.9) + + partials = [] + for i in range(len(drawn)): + # flip card i (animation plays), partial still arriving + yield (_spread_html(drawn, back_url, revealed=i + 1, anim_idx=i), + _reading_md(drawn, partials, None) + "\n\n_…the card turns…_") + time.sleep(0.5) + try: + p = card_partial(deck, question, drawn, i) + except Exception as e: + p = f"*(the card is hard to read — {type(e).__name__})*" + partials.append(p) + # settle card i face-up (no anim), show its partial + yield (_spread_html(drawn, back_url, revealed=i + 1, anim_idx=None), + _reading_md(drawn, partials, None)) + time.sleep(0.3) + + yield (_spread_html(drawn, back_url, revealed=len(drawn), anim_idx=None), + _reading_md(drawn, partials, None) + "\n\n_…the oracle gathers the threads…_") + try: + synth = final_synthesis(deck, question, drawn, partials) + except Exception as e: + synth = f"*(the synthesis slips away — {type(e).__name__})*" + yield (_spread_html(drawn, back_url, revealed=len(drawn), anim_idx=None), + _reading_md(drawn, partials, synth)) + + +# ----------------------------------------------------------------- generate +def do_conjure(theme, style, custom, progress=gr.Progress()): + theme = (theme or "").strip() + if not theme: + yield (gr.update(), None, "Name a theme first.", gr.update(visible=False)) + return + def cb(msg, frac): + progress(min(frac, 0.99), desc=msg) + deck = build_deck(theme, visual_style=style, custom_style=custom, progress=cb) + yield (gallery_items(deck), deck, + f"**{deck['theme']}** — 22 cards in the *{deck['visual_style']}* style. " + "Save it, or begin your reading.", + gr.update(visible=True)) + + +def toggle_custom(style): + return gr.update(visible=(style == "custom")) + + +# ----------------------------------------------------------------- nav helpers +def open_picked(deck_id, mode): + deck = load_deck(deck_id) if deck_id else None + if not deck: + return (gr.update(), gr.update(), *show("pick")) + if mode == "view": + return (deck, gallery_items(deck), *show("view")) + return (deck, gr.update(), *show("reading")) + + +def save_current(deck): + if deck: + save_deck(deck) + return "✦ Deck saved." + return "Nothing to save yet." + + +# ----------------------------------------------------------------- css +CSS = """ +:root { --gold:#d8b15a; --ink:#0b0716; --parch:#f3e6c4; } +.gradio-container { background: + radial-gradient(1200px 760px at 50% -6%, #241a3e 0%, #130d24 52%, #080510 100%) !important; + color: var(--parch) !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; + --block-label-text-color: #c9bb90 !important; + --body-text-color-subdued: #b6a982 !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; } +.serif, #welcome, .arc-h { font-family: "Iowan Old Style", "Palatino Linotype", ui-serif, Georgia, serif !important; } +#brand { text-align:center; letter-spacing:8px; font-size:2.6rem; margin:10px 0 0; color:#f6ecd0; + text-shadow:0 2px 30px rgba(216,177,90,.55); font-family:ui-serif,Georgia,serif; } +#welcome { text-align:center; font-size:1.25rem; font-style:italic; color:#e7dab2; + max-width:680px; margin:14px auto 22px; line-height:1.6; } +.arc-h { text-align:center; color:#f1e4be !important; letter-spacing:2px; } +.gradio-container h1,.gradio-container h2,.gradio-container h3,.gradio-container h4 { color:#f1e4be !important; + font-family:ui-serif,Georgia,serif !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; } +.gradio-container .secondary { border:1px solid rgba(216,177,90,.4) !important; color:#e7d9af !important; + background:rgba(216,177,90,.08) !important; } +.status { text-align:center; font-style:italic; color:#d8cba2; min-height:1.3em; } +#chips { flex-wrap:wrap !important; gap:6px !important; justify-content:center; } +#chips .secondary { border-radius:999px !important; font-size:.82rem !important; padding:4px 14px !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 { background:rgba(16,10,30,.62); border:1px solid rgba(216,177,90,.3); border-radius:14px; + padding:18px 22px; font-family:ui-serif,Georgia,serif; font-size:1.04rem; line-height:1.68; color:#efe4c4; min-height:60px; } +#reading h3 { color:#d8b15a !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:10px; display:flex; flex-direction:column; gap:1px; + font-family:ui-serif,Georgia,serif; } +figcaption .pos { color:var(--gold); font-size:.72rem; letter-spacing:2px; text-transform:uppercase; } +figcaption .concept { color:#f3e6c4; font-size:1rem; } +figcaption .orient { font-size:.72rem; font-style:italic; } +figcaption .orient.rev { color:#d98a78; } figcaption .orient.upr { color:#9ec99a; } +.spread-empty { text-align:center; color:#b6a982; font-style:italic; padding:34px; } +footer { display:none !important; } +""" + + +# ----------------------------------------------------------------- ui +def build_demo() -> gr.Blocks: + with gr.Blocks(title="Arcana") as demo: + deck_state = gr.State(None) + pick_mode = gr.State("read") + + gr.HTML("
✦ ARCANA ✦
") + + # ---------- landing + with gr.Column(visible=True) as pg_landing: + gr.HTML(f"
{WELCOME}
") + 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="thermodynamics · the French Revolution · breakfast foods…", + show_label=False, value=DEFAULT_THEME, max_lines=1) + with gr.Row(elem_id="chips"): + chips = [gr.Button(t, size="sm", elem_classes=["secondary"]) for t in EXAMPLE_THEMES] + with gr.Row(): + style = gr.Dropdown(STYLE_CHOICES, value="rider-waite-smith", label="Visual style", scale=3) + custom = gr.Textbox(label="Custom style", placeholder=CUSTOM_PLACEHOLDER, + visible=False, scale=4, max_lines=1) + conjure = gr.Button("✦ Conjure the deck", elem_classes=["big-btn"]) + gen_status = gr.Markdown("", elem_classes=["status"]) + preview = gr.Gallery(value=[], columns=6, height=420, object_fit="contain", + show_label=False, elem_id="preview", preview=False) + with gr.Row(visible=False) as gen_fork: + btn_save_finish = gr.Button("✦ Save & Finish", scale=2) + btn_begin_reading = gr.Button("◈ Begin the Reading", elem_classes=["big-btn"], scale=2) + gen_back = gr.Button("↩ Back", scale=1, elem_classes=["secondary"]) + + # ---------- pick (read-from-existing OR view) + with gr.Column(visible=True) as pg_pick: + pick_title = gr.Markdown("### Choose a Deck", elem_classes=["arc-h"]) + pick_dd = gr.Dropdown(choices=deck_choices(), label="Saved & bundled decks") + with gr.Row(): + pick_open = gr.Button("✦ Open", elem_classes=["big-btn"], scale=2) + pick_refresh = gr.Button("⟳ Refresh", scale=1, elem_classes=["secondary"]) + 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"]) + with gr.Row(): + question = gr.Textbox(label="Your question", scale=6, max_lines=2, + placeholder="Will my side-project ever ship?") + spread = gr.Dropdown(SPREAD_CHOICES, value="three", label="Spread", scale=2) + reversals = gr.Checkbox(value=True, label="Allow reversals", scale=1) + draw = gr.Button("✦ Draw the cards", elem_classes=["big-btn"]) + spread_view = gr.HTML("
Pose your question, then draw.
") + reading = gr.Markdown("", elem_id="reading") + with gr.Row(): + save_in_read = gr.Button("✦ Save this deck", scale=1, elem_classes=["secondary"]) + read_back = gr.Button("↩ Back to start", scale=1, elem_classes=["secondary"]) + save_note = gr.Markdown("", elem_classes=["status"]) + + # ---------- view deck + with gr.Column(visible=True) as pg_view: + view_head = gr.Markdown("### The Deck", elem_classes=["arc-h"]) + view_gallery = gr.Gallery(value=[], columns=6, height=640, object_fit="contain", + show_label=False, elem_id="deck", preview=False, + allow_preview=True) + view_back = gr.Button("↩ Back to start", scale=1, elem_classes=["secondary"]) + + pages = [pg_landing, pg_taro, pg_generate, pg_pick, pg_reading, pg_view] + + # ---------- 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(lambda: show("generate"), None, pages) + btn_read_existing.click(lambda: "read", None, pick_mode).then( + lambda: gr.update(choices=deck_choices()), None, pick_dd).then( + lambda: show("pick"), None, pages) + btn_viewnav.click(lambda: "view", None, pick_mode).then( + lambda: gr.update(choices=deck_choices()), None, pick_dd).then( + lambda: show("pick"), None, pages) + + # style picker + examples + style.change(toggle_custom, style, custom) + for chip, t in zip(chips, EXAMPLE_THEMES): + chip.click(lambda t=t: t, None, theme) + + # generate + conjure.click(do_conjure, [theme, style, custom], + [preview, deck_state, gen_status, gen_fork]) + btn_save_finish.click(save_current, deck_state, gen_status).then( + lambda: show("landing"), None, pages) + btn_begin_reading.click(lambda: show("reading"), None, pages) + + # pick → open + pick_refresh.click(lambda: gr.update(choices=deck_choices()), None, pick_dd) + pick_open.click(open_picked, [pick_dd, pick_mode], + [deck_state, view_gallery, *pages]) + + # reading + draw.click(do_reading, [deck_state, question, spread, reversals], + [spread_view, reading]) + save_in_read.click(save_current, deck_state, save_note) + + # 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) + + return demo + + +def build_app() -> FastAPI: + app = FastAPI() + os.makedirs(DECKS_DIR, exist_ok=True) + app.mount("/decks", StaticFiles(directory=DECKS_DIR), name="decks") + demo = build_demo() + demo.queue(default_concurrency_limit=4) + return gr.mount_gradio_app(app, demo, path="/", css=CSS, + theme=gr.themes.Soft(), ssr_mode=False) + + +app = build_app() + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", 7860))) diff --git a/arcana/__init__.py b/arcana/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/arcana/archetypes.py b/arcana/archetypes.py new file mode 100644 index 0000000000000000000000000000000000000000..f6595956cf3219e3e47b951eb900b2e787bd21e0 --- /dev/null +++ b/arcana/archetypes.py @@ -0,0 +1,60 @@ +"""The 22 Major Arcana — the fixed scaffold every deck maps onto. + +Agent 1 (the Deck Designer, SPEC §4) does NOT free-invent 22 cards. It assigns +one in-theme concept to each of these fixed archetypes, justified by *shared +meaning*. That guarantees exactly 22 cards, inherits a coherent meaning +structure, and forces the "clever-but-right" fit that the whole toy lives on. + +`roman` is rendered onto the card frame (SPEC §8/§9); `meaning` is the canonical +gloss that grounds each mapping and goes verbatim into the system prompt. +""" +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Arcana: + number: int # 0-21 + roman: str # "0", "I", ... "XXI" — rendered on the frame + name: str # canonical archetype name + meaning: str # canonical upright gloss that grounds the mapping + + +MAJOR_ARCANA: tuple[Arcana, ...] = ( + Arcana(0, "0", "The Fool", "new beginnings, innocence, leap of faith"), + Arcana(1, "I", "The Magician", "manifestation, willpower, channeling potential"), + Arcana(2, "II", "The High Priestess", "intuition, hidden knowledge, the unseen"), + Arcana(3, "III", "The Empress", "abundance, fertility, creation, nurturing"), + Arcana(4, "IV", "The Emperor", "authority, structure, control, stability"), + Arcana(5, "V", "The Hierophant", "tradition, institutions, shared belief, convention"), + Arcana(6, "VI", "The Lovers", "union, choice, alignment of values"), + Arcana(7, "VII", "The Chariot", "directed force, willpower, triumph through control"), + Arcana(8, "VIII", "Strength", "inner strength, courage, gentleness over force"), + Arcana(9, "IX", "The Hermit", "introspection, solitude, the inward search"), + Arcana(10, "X", "Wheel of Fortune", "cycles, change, fate, turning points"), + Arcana(11, "XI", "Justice", "fairness, cause and effect, truth, accountability"), + Arcana(12, "XII", "The Hanged Man", "surrender, suspension, a new perspective"), + Arcana(13, "XIII", "Death", "endings, irreversible transformation, transition"), + Arcana(14, "XIV", "Temperance", "balance, synthesis, moderation"), + Arcana(15, "XV", "The Devil", "bondage, constraint, materialism, the shadow"), + Arcana(16, "XVI", "The Tower", "sudden upheaval, collapse of false structures"), + Arcana(17, "XVII", "The Star", "hope, renewal, inspiration"), + Arcana(18, "XVIII", "The Moon", "illusion, ambiguity, the unconscious, anxiety"), + Arcana(19, "XIX", "The Sun", "joy, vitality, clarity, success"), + Arcana(20, "XX", "Judgement", "reckoning, awakening, rebirth, a calling"), + Arcana(21, "XXI", "The World", "completion, integration, wholeness"), +) + +assert len(MAJOR_ARCANA) == 22 +assert [a.number for a in MAJOR_ARCANA] == list(range(22)) + +ROMAN_BY_NUMBER: dict[int, str] = {a.number: a.roman for a in MAJOR_ARCANA} +NAME_BY_NUMBER: dict[int, str] = {a.number: a.name for a in MAJOR_ARCANA} + + +def reference_block() -> str: + """The 22 archetypes + canonical meanings, for the system prompt (SPEC §4).""" + return "\n".join( + f"{a.roman:<5} {a.name:<18} — {a.meaning}" for a in MAJOR_ARCANA + ) diff --git a/arcana/build.py b/arcana/build.py new file mode 100644 index 0000000000000000000000000000000000000000..8af9c03549969fc1351d7e335cf6ed35285d96c2 --- /dev/null +++ b/arcana/build.py @@ -0,0 +1,142 @@ +"""End-to-end deck build + persistence (SPEC §11, §12). + +theme + chosen visual style -> mappings (Agent 1) -> concept-native meanings +(Loremaster) -> 22 central arts + ONE deck-back (open ≤32B image model) -> +composited card PNGs. A fixed seed family makes the 22 cards cohere. + +Persistence model (§12): a freshly built deck lives in session memory; its image +files are written to decks// so they can be composited and served, but +the deck is only "saved" (and thus listed by View Deck / Read from Existing) once +save_deck() writes its deck.json. Bundled demo decks ship with a deck.json. +""" +from __future__ import annotations + +import json +import os +import time +from typing import Callable + +from .archetypes import ROMAN_BY_NUMBER +from .compositor import compose_card +from .designer import design_deck +from .imagegen import get_imagegen +from .llm import get_llm +from .loremaster import refine_deck +from .styles import resolve_style + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DECKS_DIR = os.path.join(ROOT, "decks") + +Progress = Callable[[str, float], None] # (message, fraction 0..1) + + +def slugify(s: str) -> str: + return "".join(ch if ch.isalnum() else "_" for ch in s.lower()).strip("_")[:40] + + +def deck_dir(deck_id: str) -> str: + return os.path.join(DECKS_DIR, deck_id) + + +def _seed_base(theme: str, style_id: str) -> int: + # deterministic per (theme, style) so a deck's art is reproducible (§7) + return abs(hash((theme.lower().strip(), style_id))) % 1_000_000 + + +def _back_prompt(style_suffix: str) -> str: + return ("a tarot card back design, ornate symmetrical emblematic mandala, " + "abstract motif, no figures, no scene, " + style_suffix) + + +# ------------------------------------------------------------------ build +def build_deck(theme: str, visual_style: str = "rider-waite-smith", + custom_style: str | None = None, + progress: Progress | None = None) -> dict: + """Design + paint a full deck. Writes image files under decks// and + returns the in-memory deck dict (NOT yet saved — call save_deck to persist).""" + def emit(msg, frac): + if progress: + progress(msg, frac) + + theme = (theme or "").strip() + style_id, style_suffix = resolve_style(visual_style, custom_style) + deck_id = f"{slugify(theme)}-{style_id}-{int(time.time())}" + out_dir = deck_dir(deck_id) + os.makedirs(out_dir, exist_ok=True) + seed_base = _seed_base(theme, style_id) + + llm = get_llm() + emit("Consulting the deck designer…", 0.02) + deck = design_deck(theme, llm=llm) + emit("The loremaster reinterprets the cards…", 0.05) + try: + deck = refine_deck(deck, llm=llm) + except Exception: + pass # keep the designer's draft meanings if refinement hiccups + + deck.update({ + "deck_id": deck_id, + "theme": theme, + "visual_style": style_id, + "style_suffix": style_suffix, + "seed_base": seed_base, + }) + + ig = get_imagegen(deck_style=style_suffix) + + emit("Pressing the deck-back…", 0.08) + back_path = os.path.join(out_dir, "back.png") + try: + back = ig.generate(_back_prompt(style_suffix), seed=seed_base - 1) + back.save(back_path) + deck["back_path"] = os.path.relpath(back_path, ROOT) + except Exception: + deck["back_path"] = None + + n = len(deck["cards"]) + for i, c in enumerate(deck["cards"]): + num = c["arcana_number"] + emit(f"Painting {c['concept']}…", 0.10 + 0.88 * i / n) + seed = seed_base + num + art = ig.generate(c["art_prompt"], seed=seed) + card = compose_card(art, c["concept"], ROMAN_BY_NUMBER[num]) + fname = f"{num:02d}_{slugify(c['concept'])}.png" + path = os.path.join(out_dir, fname) + card.save(path) + c["seed"] = seed + c["art_path"] = os.path.relpath(path, ROOT) + + emit("The deck is ready.", 1.0) + return deck + + +# ------------------------------------------------------------------ persistence +def save_deck(deck: dict) -> str: + """Write deck.json into the deck's dir, registering it as a saved deck (§12).""" + out_dir = deck_dir(deck["deck_id"]) + os.makedirs(out_dir, exist_ok=True) + path = os.path.join(out_dir, "deck.json") + with open(path, "w") as f: + json.dump(deck, f, indent=2, ensure_ascii=False) + return path + + +def load_deck(deck_id: str) -> dict | None: + path = os.path.join(deck_dir(deck_id), "deck.json") + return json.load(open(path)) if os.path.exists(path) else None + + +def list_decks() -> list[dict]: + """All saved/bundled decks (those with a deck.json), newest first.""" + if not os.path.isdir(DECKS_DIR): + return [] + decks = [] + for name in os.listdir(DECKS_DIR): + path = os.path.join(DECKS_DIR, name, "deck.json") + if os.path.exists(path): + try: + decks.append(json.load(open(path))) + except Exception: + pass + decks.sort(key=lambda d: d.get("deck_id", ""), reverse=True) + return decks diff --git a/arcana/compositor.py b/arcana/compositor.py new file mode 100644 index 0000000000000000000000000000000000000000..e3679fabcabfdcfddf7496a3405f8c36d1c692ff --- /dev/null +++ b/arcana/compositor.py @@ -0,0 +1,81 @@ +"""Compositing pipeline (SPEC §9). + +Per card: central art → fill the card → overlay the reusable frame template → +render the concept name + roman numeral as crisp text (never baked into the +diffusion art). Deterministic, fast, Pillow-only. +""" +from __future__ import annotations + +from PIL import Image, ImageDraw + +from .frame import (CARD_H, CARD_W, CART_PLATE, GOLD, NUM_PLATE, PARCH, + font, frame_overlay) + + +def _cover(art: Image.Image, w: int, h: int) -> Image.Image: + """Resize-to-cover then centre-crop so art fills WxH without distortion.""" + aw, ah = art.size + scale = max(w / aw, h / ah) + art = art.resize((max(1, round(aw * scale)), max(1, round(ah * scale))), Image.LANCZOS) + aw, ah = art.size + left, top = (aw - w) // 2, (ah - h) // 2 + return art.crop((left, top, left + w, top + h)) + + +def _fit_lines(draw, text, fnt_face, box, max_lines=2, start=40, min_size=20): + """Largest Cinzel size (and wrap) that fits `text` in `box`. Returns + (font, [lines]).""" + bw = box[2] - box[0] - 24 + bh = box[3] - box[1] - 16 + words = text.upper().split() + for size in range(start, min_size - 1, -2): + fnt = font(size, weight=600, face=fnt_face) + # greedy wrap into <= max_lines + lines, cur = [], "" + for word in words: + trial = (cur + " " + word).strip() + if draw.textlength(trial, font=fnt) <= bw or not cur: + cur = trial + else: + lines.append(cur); cur = word + if cur: + lines.append(cur) + if len(lines) > max_lines: + continue + line_h = (fnt.getbbox("Ag")[3] - fnt.getbbox("Ag")[1]) + 6 + if all(draw.textlength(ln, font=fnt) <= bw for ln in lines) and \ + line_h * len(lines) <= bh: + return fnt, lines + fnt = font(min_size, weight=600, face=fnt_face) + return fnt, [text.upper()[:40]] + + +def _draw_centered(draw, lines, fnt, box, fill): + line_h = (fnt.getbbox("Ag")[3] - fnt.getbbox("Ag")[1]) + 6 + total = line_h * len(lines) + cy = (box[1] + box[3]) / 2 - total / 2 + for ln in lines: + tw = draw.textlength(ln, font=fnt) + draw.text(((box[0] + box[2]) / 2 - tw / 2, cy), ln, font=fnt, fill=fill) + cy += line_h + + +def compose_card(art: Image.Image, concept: str, roman: str, + size=(CARD_W, CARD_H)) -> Image.Image: + """Build the finished upright card PNG. Reversal is a 180° rotation applied + at draw time (§9), not here.""" + w, h = size + card = _cover(art.convert("RGB"), w, h) + card.paste(frame_overlay(w, h), (0, 0), frame_overlay(w, h)) + draw = ImageDraw.Draw(card) + + # roman numeral in the top plate + nf = font(40, weight=700) + nw = draw.textlength(roman, font=nf) + ny = (NUM_PLATE[1] + NUM_PLATE[3]) / 2 - (nf.getbbox("X")[3] - nf.getbbox("X")[1]) / 2 - nf.getbbox("X")[1] + draw.text(((NUM_PLATE[0] + NUM_PLATE[2]) / 2 - nw / 2, ny), roman, font=nf, fill=GOLD) + + # concept name in the bottom cartouche (auto-fit, up to 2 lines) + cf, lines = _fit_lines(draw, concept, "Cinzel", CART_PLATE, max_lines=2, start=44) + _draw_centered(draw, lines, cf, CART_PLATE, PARCH) + return card diff --git a/arcana/designer.py b/arcana/designer.py new file mode 100644 index 0000000000000000000000000000000000000000..6ecf536c84324541b229764b63670c32bf6c64ff --- /dev/null +++ b/arcana/designer.py @@ -0,0 +1,170 @@ +"""Agent 1 — Deck Designer (SPEC §4 + validation §10). + +theme string -> deck dict of exactly 22 archetype-mapped cards. + +Model output is treated as hostile: JSON is extracted defensively (fence-strip / +balanced-block), validated against the 22-archetype contract, and re-prompted +ONCE on a parse or structural failure. Build-time callers should fail loudly; +runtime callers can catch DeckError and degrade. +""" +from __future__ import annotations + +import json +import re + +from .archetypes import MAJOR_ARCANA, NAME_BY_NUMBER +from .llm import LLM, get_llm +from .prompts import designer_system_prompt, designer_user_prompt + +_CARD_FIELDS = ("concept", "justification", "upright_meaning", + "reversed_meaning", "art_prompt") + + +class DeckError(ValueError): + """The model's output could not be coerced into a valid 22-card deck.""" + + +# ------------------------------------------------------------------ json parse +def extract_json(text: str) -> dict: + """Pull the first JSON object out of a model reply, tolerating fences and + stray prose. Strips a Qwen block if one leaks through.""" + if not text or not text.strip(): + raise DeckError("empty model reply") + text = re.sub(r".*?", "", text, flags=re.DOTALL).strip() + # strip ```json ... ``` fences if present + fence = re.search(r"```(?:json)?\s*(.*?)```", text, flags=re.DOTALL) + if fence: + text = fence.group(1).strip() + try: + return json.loads(text) + except json.JSONDecodeError: + pass + # fall back to the first balanced {...} block + start = text.find("{") + if start == -1: + raise DeckError("no JSON object found in reply") + depth, in_str, esc = 0, False, False + for i in range(start, len(text)): + c = text[i] + if in_str: + if esc: + esc = False + elif c == "\\": + esc = True + elif c == '"': + in_str = False + elif c == '"': + in_str = True + elif c == "{": + depth += 1 + elif c == "}": + depth -= 1 + if depth == 0: + try: + return json.loads(text[start:i + 1]) + except json.JSONDecodeError as e: + raise DeckError(f"malformed JSON block: {e}") from e + raise DeckError("unbalanced JSON object in reply") + + +# ------------------------------------------------------------------ validation +def validate_deck(data: dict, theme: str) -> dict: + """Enforce the §10 contract: exactly 22 cards covering arcana 0-21, no dupes, + non-empty meanings and art_prompt. Returns a normalized deck dict. Raises + DeckError on any structural problem (caller re-prompts once).""" + if not isinstance(data, dict): + raise DeckError("top-level JSON is not an object") + cards = data.get("cards") + if not isinstance(cards, list): + raise DeckError("'cards' is missing or not a list") + if len(cards) != 22: + raise DeckError(f"expected 22 cards, got {len(cards)}") + + by_number: dict[int, dict] = {} + for idx, c in enumerate(cards): + if not isinstance(c, dict): + raise DeckError(f"card {idx} is not an object") + try: + n = int(c.get("arcana_number")) + except (TypeError, ValueError): + raise DeckError(f"card {idx} has a non-integer arcana_number") + if not 0 <= n <= 21: + raise DeckError(f"card {idx} arcana_number {n} out of range 0-21") + if n in by_number: + raise DeckError(f"duplicate arcana_number {n}") + for f in _CARD_FIELDS: + v = c.get(f) + if not isinstance(v, str) or not v.strip(): + raise DeckError(f"card {n} ({NAME_BY_NUMBER[n]}) has empty '{f}'") + by_number[n] = c + + missing = [a.number for a in MAJOR_ARCANA if a.number not in by_number] + if missing: + raise DeckError(f"missing arcana numbers: {missing}") + + # normalize: canonical names/order, trimmed strings, keep concept dupes visible + norm_cards = [] + seen_concepts: dict[str, int] = {} + for a in MAJOR_ARCANA: + c = by_number[a.number] + concept = c["concept"].strip() + key = concept.lower() + if key in seen_concepts: + raise DeckError( + f"concept {concept!r} reused for arcana {a.number} and " + f"{seen_concepts[key]}" + ) + seen_concepts[key] = a.number + norm_cards.append({ + "arcana_number": a.number, + "arcana_name": a.name, + "concept": concept, + "justification": c["justification"].strip(), + "upright_meaning": c["upright_meaning"].strip(), + "reversed_meaning": c["reversed_meaning"].strip(), + "art_prompt": c["art_prompt"].strip(), + "art_path": None, + }) + + style = data.get("style_suffix") + if not isinstance(style, str) or not style.strip(): + style = "ornate tarot illustration, cohesive palette, symbolic, no text, no border" + return { + "theme": (data.get("theme") or theme).strip(), + "style_suffix": style.strip(), + "cards": norm_cards, + } + + +# ------------------------------------------------------------------ generate +def design_deck(theme: str, llm: LLM | None = None) -> dict: + """Run Agent 1 for a theme and return a validated deck. Re-prompts ONCE on a + parse/structural failure (SPEC §4, §10). Raises DeckError if both attempts + fail — build-time callers should let this surface.""" + theme = (theme or "").strip() + if not theme: + raise DeckError("empty theme") + llm = llm or get_llm() + system = designer_system_prompt() + user = designer_user_prompt(theme) + + first_err = None + raw = llm.complete(system, user, json_mode=True) + try: + return validate_deck(extract_json(raw), theme) + except DeckError as e: + first_err = e + + # one repair attempt, told exactly what was wrong + repair = ( + f"{user}\n\nYour previous reply was rejected: {first_err}. " + "Return ONLY the corrected strict JSON object with exactly 22 cards " + "covering arcana 0 through 21, each with non-empty concept, " + "justification, upright_meaning, reversed_meaning and art_prompt. " + "No prose, no code fences." + ) + raw2 = llm.complete(system, repair, json_mode=True) + try: + return validate_deck(extract_json(raw2), theme) + except DeckError as e: + raise DeckError(f"deck invalid after repair (first: {first_err}) -> {e}") from e diff --git a/arcana/frame.py b/arcana/frame.py new file mode 100644 index 0000000000000000000000000000000000000000..1b4dc245cc57aaa1f7969af870dc1c2bb4e92848 --- /dev/null +++ b/arcana/frame.py @@ -0,0 +1,79 @@ +"""Decorative card frame — built ONCE and reused for all 22 cards (SPEC §8). + +Approach (A): an authored vector-ish frame overlay rendered with Pillow at a +fixed card size — an RGBA layer with a transparent centre (the art shows +through) plus an ornate gold border, a top plate for the roman numeral, and a +bottom cartouche for the concept name. Per-card text is drawn at composite time +(§9); it is NEVER baked into the generated art. The overlay is cached so the +"build the frame one time" requirement holds. +""" +from __future__ import annotations + +import functools +import os + +from PIL import Image, ImageDraw, ImageFont + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +FONT_DIR = os.path.join(ROOT, "assets", "fonts") + +CARD_W, CARD_H = 720, 1100 + +# occult palette +GOLD = (200, 162, 74) +GOLD_SOFT = (150, 122, 60) +INK = (18, 11, 30) # deep purple-black +PARCH = (243, 230, 196) # parchment text + +BORDER = 26 # outer dark band thickness +NUM_PLATE = (CARD_W // 2 - 70, 30, CARD_W // 2 + 70, 104) # roman numeral +CART_PLATE = (54, CARD_H - 188, CARD_W - 54, CARD_H - 60) # concept title + + +@functools.lru_cache(maxsize=8) +def font(size: int, weight: int = 400, face: str = "Cinzel") -> ImageFont.FreeTypeFont: + f = ImageFont.truetype(os.path.join(FONT_DIR, f"{face}.ttf"), size) + try: # variable fonts: pick a weight on the wght axis + f.set_variation_by_axes([weight]) + except Exception: + pass + return f + + +def _rounded(draw: ImageDraw.ImageDraw, box, radius, fill=None, outline=None, width=2): + draw.rounded_rectangle(box, radius=radius, fill=fill, outline=outline, width=width) + + +@functools.lru_cache(maxsize=4) +def frame_overlay(w: int = CARD_W, h: int = CARD_H) -> Image.Image: + """The reusable frame as an RGBA overlay with a transparent art window.""" + ov = Image.new("RGBA", (w, h), (0, 0, 0, 0)) + d = ImageDraw.Draw(ov) + + # dark vignette band hugging the edges (frames the full-bleed art) + d.rectangle([0, 0, w, h], outline=None) + band = Image.new("RGBA", (w, h), (0, 0, 0, 0)) + bd = ImageDraw.Draw(band) + bd.rectangle([0, 0, w, h], fill=(*INK, 235)) + bd.rectangle([BORDER, BORDER, w - BORDER, h - BORDER], fill=(0, 0, 0, 0)) + ov.alpha_composite(band) + + # double gold rule just inside the band + d.rectangle([BORDER - 6, BORDER - 6, w - BORDER + 6, h - BORDER + 6], + outline=(*GOLD, 255), width=3) + d.rectangle([BORDER + 4, BORDER + 4, w - BORDER - 4, h - BORDER - 4], + outline=(*GOLD_SOFT, 220), width=1) + + # corner flourishes — small gold diamonds + for cx, cy in [(BORDER, BORDER), (w - BORDER, BORDER), + (BORDER, h - BORDER), (w - BORDER, h - BORDER)]: + d.polygon([(cx, cy - 10), (cx + 10, cy), (cx, cy + 10), (cx - 10, cy)], + fill=(*GOLD, 255)) + + # top numeral plate + bottom title cartouche (filled, gold-edged) + _rounded(d, NUM_PLATE, 10, fill=(*INK, 245), outline=(*GOLD, 255), width=2) + _rounded(d, CART_PLATE, 14, fill=(*INK, 245), outline=(*GOLD, 255), width=2) + # inner hairline on the cartouche + _rounded(d, (CART_PLATE[0] + 6, CART_PLATE[1] + 6, CART_PLATE[2] - 6, CART_PLATE[3] - 6), + 10, outline=(*GOLD_SOFT, 200), width=1) + return ov diff --git a/arcana/imagegen.py b/arcana/imagegen.py new file mode 100644 index 0000000000000000000000000000000000000000..7eb389d5f3ef0660f86ee528de00ad9e47c28c30 --- /dev/null +++ b/arcana/imagegen.py @@ -0,0 +1,106 @@ +"""Swappable image generator (SPEC §7). + +The app programs against the ``ImageGen`` protocol. Consistent with the sibling +Globe project (which calls its ≤32B model through an inference endpoint and +keeps the Space a lean CPU box), the default backend calls an OPEN ≤32B image +model — FLUX.1-schnell (~12B) — through the same DeepInfra endpoint we use for +the LLM. No closed API, no local GPU, no torch/diffusers in the deploy. + +Generates the CENTRAL ILLUSTRATION ONLY (no frame, no border, no text — those +are added in the compositor, §8/§9). A fixed style suffix + a fixed seed family +give the 22 cards one cohesive look. + +LocalImageGen is a stub for a GPU box running diffusers locally (earns the +local-first bonus, §14); left unimplemented so v1 doesn't block on GPU access. +""" +from __future__ import annotations + +import base64 +import io +import os +from typing import Protocol, runtime_checkable + +from PIL import Image + +# Open ≤32B image models on DeepInfra's OpenAI-compatible images endpoint. +# FLUX.1-schnell ~12B (best quality / few steps); sdxl-turbo ~3.5B (lighter). +DEFAULT_IMAGE_MODEL = "black-forest-labs/FLUX-1-schnell" +DEFAULT_IMAGE_BASE_URL = "https://api.deepinfra.com/v1/openai" + +# Appended to every art_prompt so all 22 cards share one look (style cohesion +# comes from this + a fixed seed family, not from per-card prompts). The +# per-deck style_suffix from Agent 1 is layered in front of this at call time. +GLOBAL_STYLE = ("ornate symbolic tarot card illustration, centered subject, " + "cohesive color palette, painterly, no text, no words, no border, " + "no frame, full-bleed artwork") + + +@runtime_checkable +class ImageGen(Protocol): + def generate(self, prompt: str, seed: int | None = None) -> Image.Image: + """Return a central-illustration PIL image for the prompt.""" + ... + + +def _style(prompt: str, deck_style: str | None) -> str: + parts = [prompt.strip().rstrip(".")] + if deck_style: + parts.append(deck_style.strip().rstrip(".")) + parts.append(GLOBAL_STYLE) + return ", ".join(p for p in parts if p) + + +class EndpointImageGen: + """Open ≤32B image model behind an OpenAI-compatible images endpoint.""" + + def __init__(self, model: str | None = None, base_url: str | None = None, + api_key: str | None = None, deck_style: str | None = None, + size: str = "1024x1024"): + from openai import OpenAI # lazy + + self.model = model or os.environ.get("IMAGE_MODEL", DEFAULT_IMAGE_MODEL) + self.base_url = base_url or os.environ.get("IMAGE_BASE_URL", DEFAULT_IMAGE_BASE_URL) + self.deck_style = deck_style + self.size = os.environ.get("IMAGE_SIZE", size) + key = (api_key or os.environ.get("IMAGE_API_KEY") + or os.environ.get("DEEPINFRA_TOKEN") or os.environ.get("DEEPINFRA_API_KEY")) + if not key: + raise RuntimeError("No image API key. Set IMAGE_API_KEY or DEEPINFRA_TOKEN.") + self.client = OpenAI(base_url=self.base_url, api_key=key, timeout=120.0) + + def generate(self, prompt: str, seed: int | None = None) -> Image.Image: + kwargs = dict(model=self.model, prompt=_style(prompt, self.deck_style), + n=1, size=self.size) + if seed is not None: # DeepInfra accepts seed as an extra body field + kwargs["extra_body"] = {"seed": int(seed)} + resp = self.client.images.generate(**kwargs) + datum = resp.data[0] + b64 = getattr(datum, "b64_json", None) + if b64: + raw = base64.b64decode(b64) + else: + url = getattr(datum, "url", None) + if not url: + raise RuntimeError("image response had neither b64_json nor url") + if url.startswith("data:"): # data:image/png;base64,XXXX + raw = base64.b64decode(url.split(",", 1)[1]) + else: + import requests + r = requests.get(url, timeout=120); r.raise_for_status(); raw = r.content + return Image.open(io.BytesIO(raw)).convert("RGB") + + +class LocalImageGen: + """STRETCH stub — diffusers SDXL/FLUX on a local GPU (SPEC §7, §14).""" + + def generate(self, prompt: str, seed: int | None = None) -> Image.Image: + raise NotImplementedError( + "local image backend needs a GPU + diffusers; use the endpoint backend." + ) + + +def get_imagegen(deck_style: str | None = None) -> ImageGen: + backend = os.environ.get("IMAGE_BACKEND", "endpoint").lower() + if backend == "local": + return LocalImageGen() + return EndpointImageGen(deck_style=deck_style) diff --git a/arcana/llm.py b/arcana/llm.py new file mode 100644 index 0000000000000000000000000000000000000000..de4371e82d048b398d39f8d32d25180ff3ce3b4e --- /dev/null +++ b/arcana/llm.py @@ -0,0 +1,115 @@ +"""Swappable LLM adapter (SPEC §6). + +The app programs against the ``LLM`` protocol and never knows which backend +serves it. Both agents (Designer §4, Reader §5) are just different prompts +through ``complete()``. + +Backends, selected by ``LLM_BACKEND``: + endpoint (default here) — an OpenAI-compatible endpoint serving the *same + open ≤32B model*. We have no local GPU (CPU-only torch), so dev runs + Qwen3-32B through the HF Inference Providers router / DeepInfra. + This is still an OPEN ≤32B model — never a closed frontier model. + local — Qwen-32B via transformers/vLLM/llama.cpp on a GPU box. Same + interface; wire when GPU is available (earns local-first/llama.cpp + bonus, SPEC §14). Left as a thin stub so v1 doesn't block on it. + +The model string is configurable via ``QWEN_MODEL``. SPEC §2 names +Qwen2.5-32B-Instruct with Qwen3-32B an acceptable swap; Qwen2.5-32B is not +currently hosted on our providers, so the default is Qwen3-32B. +""" +from __future__ import annotations + +import os +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class LLM(Protocol): + def complete(self, system: str, user: str, json_mode: bool = False) -> str: + """Return the model's text completion for a system+user prompt.""" + ... + + +# Qwen3-32B is a "thinking" model: by default it emits a trace that +# overruns max_tokens before any JSON. /no_think gives a direct answer. +DEFAULT_MODEL = "Qwen/Qwen3-32B" + +# provider presets: (base_url, env vars to try for the key, in order) +_PROVIDERS = { + "hf": ("https://router.huggingface.co/v1", ("HF_TOKEN", "HUGGINGFACEHUB_API_TOKEN")), + "deepinfra": ("https://api.deepinfra.com/v1/openai", ("DEEPINFRA_TOKEN", "DEEPINFRA_API_KEY")), +} + + +class EndpointLLM: + """Open ≤32B model behind an OpenAI-compatible endpoint.""" + + def __init__(self, model: str | None = None, provider: str | None = None, + base_url: str | None = None, api_key: str | None = None, + temperature: float = 0.7, max_tokens: int = 6000): + from openai import OpenAI # lazy: the local backend needs no openai + + self.model = model or os.environ.get("QWEN_MODEL", DEFAULT_MODEL) + self.temperature = float(os.environ.get("QWEN_TEMPERATURE", temperature)) + self.max_tokens = int(os.environ.get("QWEN_MAX_TOKENS", max_tokens)) + + provider = (provider or os.environ.get("LLM_PROVIDER", "hf")).lower() + preset_url, key_envs = _PROVIDERS.get(provider, _PROVIDERS["hf"]) + self.base_url = base_url or os.environ.get("LLM_BASE_URL", preset_url) + key = api_key or os.environ.get("LLM_API_KEY") + for env in key_envs: + key = key or os.environ.get(env) + if not key: + raise RuntimeError( + f"No API key for provider {provider!r}. Set LLM_API_KEY or one of " + f"{key_envs} (Space secret for deploy)." + ) + self.client = OpenAI(base_url=self.base_url, api_key=key, timeout=120.0) + + def _is_thinking_qwen(self) -> bool: + m = self.model.lower() + return "qwen3" in m and "instruct" not in m + + def complete(self, system: str, user: str, json_mode: bool = False) -> str: + if self._is_thinking_qwen(): + user = user.rstrip() + "\n/no_think" + kwargs = dict( + model=self.model, + messages=[{"role": "system", "content": system}, + {"role": "user", "content": user}], + temperature=self.temperature, + max_tokens=self.max_tokens, + ) + if json_mode: + kwargs["response_format"] = {"type": "json_object"} + try: + resp = self.client.chat.completions.create(**kwargs) + except Exception: + if json_mode: # some providers reject json_object — retry without it + kwargs.pop("response_format", None) + resp = self.client.chat.completions.create(**kwargs) + else: + raise + return resp.choices[0].message.content or "" + + +class LocalLLM: + """STRETCH stub for a GPU-local Qwen-32B (transformers/vLLM/llama.cpp). + + Point EndpointLLM at a local OpenAI-compatible server + (LLM_BASE_URL=http://127.0.0.1:8080/v1) and that path already works today; a + truly in-process backend would slot in here. Unimplemented so v1 doesn't + block on GPU access (SPEC §6, §12, §14). + """ + + def complete(self, system: str, user: str, json_mode: bool = False) -> str: + raise NotImplementedError( + "local backend needs a GPU; use LLM_BACKEND=endpoint (default) for dev." + ) + + +def get_llm() -> LLM: + backend = os.environ.get("LLM_BACKEND", "endpoint").lower() + if backend == "local": + return LocalLLM() + return EndpointLLM() diff --git a/arcana/loremaster.py b/arcana/loremaster.py new file mode 100644 index 0000000000000000000000000000000000000000..866ea009163d61a1486c8b2dafdf75bdcd1d2884 --- /dev/null +++ b/arcana/loremaster.py @@ -0,0 +1,111 @@ +"""Agent 1.5 — the Loremaster (deck reinterpretation pass). + +After the Designer maps the 22 archetypes onto in-theme concepts, this agent +takes the WHOLE deck — each concept, the classical archetype it came from, that +archetype's canonical meaning, and the Designer's first-draft meanings — and +rewrites every card's meaning to be *native to the concept itself*, rather than a +stiff reproduction of the original tarot card. It sees all 22 at once, so it can +make the deck internally coherent and keep cards distinct. + +The point: a card like "The One Ring" should read about corruption, burden and +the fate it drags behind it — not a generic "cycles and turning points" gloss +inherited from Wheel of Fortune. The archetype is the seed; the concept is the +plant. This pass also adds a one-line `essence` the Reader can draw on. +""" +from __future__ import annotations + +from .archetypes import MAJOR_ARCANA, NAME_BY_NUMBER +from .designer import DeckError, extract_json +from .llm import LLM, get_llm + +_FIELDS = ("essence", "upright_meaning", "reversed_meaning") + + +def _system() -> str: + return """\ +You are the loremaster of a custom tarot deck. Each card began life as a classical +Major Arcana archetype, but has been re-cast as a concept from a single theme. Your +job is to make every card's meaning feel TRUE TO ITS OWN CONCEPT — drawn from what +that concept actually is, does, and evokes — instead of a stiff hand-me-down of the +original tarot card's text. + +You are given, for each card: the concept, the archetype it was seeded from, that +archetype's canonical meaning, and a first-draft upright/reversed meaning. Keep the +archetype's underlying SHAPE (a Death-card still concerns endings; a Tower-card still +concerns sudden collapse) but re-express it through the concept's own specifics, +imagery and stakes. Lean into what makes THIS concept particular. Make the 22 cards +distinct from one another — no two should read interchangeably. + +For each card produce: +- essence: one vivid line naming what this card uniquely means in this deck. +- upright_meaning: 2-3 sentences, concept-native, concrete and evocative — never + boilerplate that could be pasted onto any card. +- reversed_meaning: 1-2 sentences — the shadow / blockage / inversion, also + concept-native. + +Speak in confident, grounded tarot voice. No hedging, no meta-commentary. + +OUTPUT: strict JSON only, no prose, no fences: +{ "cards": [ { "arcana_number": <0-21>, "essence": "...", "upright_meaning": "...", "reversed_meaning": "..." }, ... 22 ] }""" + + +def _user(deck: dict) -> str: + canon = {a.number: a.meaning for a in MAJOR_ARCANA} + blocks = [] + for c in deck["cards"]: + n = c["arcana_number"] + blocks.append( + f"--- card {n} ---\n" + f"concept: {c['concept']}\n" + f"seeded from archetype: {NAME_BY_NUMBER[n]} (classical meaning: {canon[n]})\n" + f"draft upright: {c['upright_meaning']}\n" + f"draft reversed: {c['reversed_meaning']}" + ) + return ( + f"Theme: {deck.get('theme')}\n\n" + "Reinterpret all 22 cards so each meaning is native to its own concept. " + "Return the strict JSON object only.\n\n" + "\n\n".join(blocks) + ) + + +def _apply(deck: dict, data: dict) -> dict: + if not isinstance(data, dict) or not isinstance(data.get("cards"), list): + raise DeckError("loremaster output missing 'cards' list") + refined = {} + for c in data["cards"]: + try: + n = int(c.get("arcana_number")) + except (TypeError, ValueError): + raise DeckError("loremaster card has non-integer arcana_number") + for f in _FIELDS: + v = c.get(f) + if not isinstance(v, str) or not v.strip(): + raise DeckError(f"loremaster card {n} has empty '{f}'") + refined[n] = c + missing = [a.number for a in MAJOR_ARCANA if a.number not in refined] + if missing: + raise DeckError(f"loremaster missing arcana: {missing}") + + for card in deck["cards"]: + r = refined[card["arcana_number"]] + card["essence"] = r["essence"].strip() + card["upright_meaning"] = r["upright_meaning"].strip() + card["reversed_meaning"] = r["reversed_meaning"].strip() + return deck + + +def refine_deck(deck: dict, llm: LLM | None = None) -> dict: + """Rewrite every card's meaning to be concept-native (one batched call, with + one repair retry). Mutates and returns the deck dict.""" + llm = llm or get_llm() + system, user = _system(), _user(deck) + + raw = llm.complete(system, user, json_mode=True) + try: + return _apply(deck, extract_json(raw)) + except DeckError as first: + repair = (f"{user}\n\nYour previous reply was rejected: {first}. Return ONLY " + "the corrected strict JSON with all 22 cards, each having non-empty " + "essence, upright_meaning and reversed_meaning.") + raw2 = llm.complete(system, repair, json_mode=True) + return _apply(deck, extract_json(raw2)) diff --git a/arcana/prompts.py b/arcana/prompts.py new file mode 100644 index 0000000000000000000000000000000000000000..e85619079bc6dd30276de4ff06a9f4df930a8075 --- /dev/null +++ b/arcana/prompts.py @@ -0,0 +1,137 @@ +"""Prompts for Agent 1 — the Deck Designer (SPEC §4). + +Mapping quality is the single most important thing in this build, and the +few-shot examples below are what teach the model the "clever-but-right" +standard. They show the full 22-archetype mapping for one serious theme +(thermodynamics) and one silly theme (breakfast foods), plus one fully detailed +card so the model learns the output format and the meaning-rewriting voice. + +The mappings honour the SPEC's own worked examples: Entropy→Death, +Absolute Zero→The Hermit, Critical Point→The Tower. +""" +from __future__ import annotations + +from .archetypes import reference_block + +# Compact teaching tables: archetype → concept — why it's earned (not arbitrary). +_FEWSHOT_THERMO = """\ +THEME: thermodynamics +0 The Fool → Activation Energy — the leap you must pay up front before anything can begin +I The Magician → Work — directed energy that channels raw potential into real change +II The High Priestess → Internal Energy — the hidden total you can never measure directly, only its changes +III The Empress → Heat Reservoir — an abundant, inexhaustible source that nurtures warmth into everything +IV The Emperor → The First Law — the unbreakable governing rule: energy is conserved, no exceptions +V The Hierophant → Standard State (STP) — the agreed convention every measurement bows to +VI The Lovers → Thermal Contact — two bodies coupled until their temperatures align as one +VII The Chariot → The Heat Engine — chaotic heat harnessed and steered into directed motion +VIII Strength → Heat Capacity — quiet endurance, absorbing energy without ever flaring up +IX The Hermit → Absolute Zero — utter stillness and solitude, all motion finally ceased +X Wheel of Fortune → The Carnot Cycle — the ideal loop that endlessly turns and returns to its start +XI Justice → The Second Law's Toll — every process pays an irreversible tax; you cannot break even +XII The Hanged Man → Metastability — suspended in a false minimum, surrendered but not yet resolved +XIII Death → Entropy — irreversible transformation, the one-way march toward the end-state +XIV Temperance → Thermal Equilibrium — hot and cold blended down to one moderate, settled mean +XV The Devil → Bound Energy — energy chained inside the system, forever unavailable to do work +XVI The Tower → The Critical Point — where distinctions suddenly collapse and the old phase fails +XVII The Star → Free Energy — the hopeful, still-usable energy that can yet be spent on work +XVIII The Moon → Thermal Fluctuations — random microscopic jitter beneath the calm macroscopic surface +XIX The Sun → Blackbody Radiation — the radiant glow of a hot body; literal light and vitality +XX Judgement → The Third Law — as you near absolute zero, entropy resolves to perfect order, a final reckoning +XXI The World → The Universe — system plus surroundings, the whole and total accounting""" + +_FEWSHOT_BREAKFAST = """\ +THEME: breakfast foods +0 The Fool → The Banana — fresh beginnings, and the original literal pratfall waiting to happen +I The Magician → The Whisk — sheer willed motion that conjures raw ingredients into something new +II The High Priestess → The Covered Dish — hidden contents; you must lift the lid to reveal the unseen +III The Empress → The Stack of Pancakes — warm, syrup-rich abundance, endlessly nurturing +IV The Emperor → Black Coffee — the bitter, stabilizing authority that rules the whole morning +V The Hierophant → The Continental Breakfast — the same institutional spread laid out by tradition everywhere +VI The Lovers → Bacon and Eggs — the classic union, two partners always chosen together +VII The Chariot → The Toaster — directed force, bread launched upward through controlled heat +VIII Strength → Oatmeal — humble, gentle power that quietly sustains you all morning long +IX The Hermit → The Lone Boiled Egg — solitary and self-contained, sitting alone in its little cup +X Wheel of Fortune → The Cereal-Box Prize — pure chance; what you get is fate, sight unseen +XI Justice → The Diner Check — fair accounting; you pay for exactly what you ordered +XII The Hanged Man → French Toast Soaking — suspended in the custard, surrendered, transformed by the wait +XIII Death → The Burnt Toast — irreversible; you cannot un-burn it, only scrape it and begin again +XIV Temperance → The Smoothie — disparate fruits blended into one balanced, moderated whole +XV The Devil → The Sugary Cereal — sweet craving and indulgence, the bondage you can't put down +XVI The Tower → The Dropped Tray — sudden upheaval; the whole breakfast collapses to the floor at once +XVII The Star → Fresh-Squeezed Orange Juice — bright hope and vitamin-C renewal, sunny optimism +XVIII The Moon → The Mystery Omelette — ambiguity and unease; you are never quite sure what is inside +XIX The Sun → The Sunny-Side-Up Egg — literally the sun, a bright yolk of joy and clarity +XX Judgement → "Order's Up!" — the breakfast bell, the calling that summons you to the table +XXI The World → The Full English — completion; every element integrated together on one plate""" + +# One fully detailed card so the model learns the output shape and the voice: +# meanings are the archetype RE-EXPRESSED through the concept, never boilerplate. +_FEWSHOT_CARD = """\ +Example of one fully detailed card object (theme: thermodynamics): +{ + "arcana_number": 13, + "arcana_name": "Death", + "concept": "Entropy", + "justification": "Both name an irreversible one-way transformation toward an end-state that cannot be undone.", + "upright_meaning": "An ending you cannot reverse. The old order is dispersing into the new and there is no path back — but this is the natural direction of things, not a punishment. Let what is spent be spent.", + "reversed_meaning": "Clinging to a structure the universe has already moved past; pouring energy into un-mixing what is mixed. A refusal to accept the arrow of time, and the exhaustion that refusal brings.", + "art_prompt": "a central illustration of a crumbling sandcastle dissolving grain by grain into a still dark sea, warm embers cooling to grey ash drifting outward, a single thread unravelling into the dusk" +}""" + + +def designer_system_prompt() -> str: + return f"""\ +You are a master tarot deck designer with a scholar's grasp of symbolism. Your \ +craft is taking ANY theme a person names and discovering, within that theme, the \ +22 Major Arcana hiding inside it. + +You do not invent 22 cards from scratch. You are handed the 22 fixed Major Arcana \ +archetypes below, and for EACH one you assign a single concept drawn from the \ +theme — chosen because it shares the archetype's deep meaning. The fit must feel \ +EARNED and clever-but-right, never arbitrary. A great mapping makes the reader \ +think "of course — I never saw it before, but of course." A lazy or generic \ +mapping ruins the whole deck. + +THE 22 MAJOR ARCANA (archetype — canonical meaning): +{reference_block()} + +RULES FOR A GREAT MAPPING: +- One in-theme concept per archetype. Exactly 22, covering arcana 0-21, no \ +concept repeated. +- Choose the concept whose real meaning rhymes with the archetype's meaning — \ +not merely a famous item from the theme stuffed into a slot. Earn it. +- `justification`: one crisp line naming the shared meaning that makes the fit click. +- `upright_meaning` / `reversed_meaning`: the archetype's meaning RE-EXPRESSED \ +through the concept, in evocative tarot voice. Concrete and specific to this \ +concept — never generic boilerplate that would fit any card. +- `art_prompt`: a vivid CENTRAL ILLUSTRATION ONLY — no border, no frame, no card \ +layout, and NO text or numerals in the image (those are added later). Describe \ +symbolic imagery, composition, and mood. +- `style_suffix`: choose ONE short visual style line that suits the whole theme \ +(e.g. art-deco blueprint, warm storybook gouache, baroque oil painting). It will \ +be appended to every card's art_prompt so the 22 cards share one cohesive look. + +Study how these two reference decks earn every mapping: + +{_FEWSHOT_THERMO} + +{_FEWSHOT_BREAKFAST} + +{_FEWSHOT_CARD} + +OUTPUT CONTRACT — reply with STRICT JSON ONLY. No prose, no markdown fences. \ +Shape: +{{ + "theme": "", + "style_suffix": "", + "cards": [ {{ 22 card objects as shown, in arcana order 0..21 }} ] +}}""" + + +def designer_user_prompt(theme: str) -> str: + return ( + f"Design the Major Arcana deck for this theme: {theme}\n\n" + "Discover the 22 concepts hiding inside it and map one to each archetype, " + "in order from 0 (The Fool) to 21 (The World). Make every mapping earned. " + "Reply with the strict JSON object only." + ) diff --git a/arcana/reader.py b/arcana/reader.py new file mode 100644 index 0000000000000000000000000000000000000000..41e996cfb75353c1206821c4293a7f5867fea72c --- /dev/null +++ b/arcana/reader.py @@ -0,0 +1,143 @@ +"""Agent 2 — the Reader / Oracle, PROGRESSIVE (SPEC §5). + +Real readings are sequential: cards turn one at a time, each interpreted in light +of what came before, with the full synthesis held for the end. This module mirrors +that — the app reveals a partial as each card lands (card_partial), then the held +finale (final_synthesis). The reader speaks the concepts' own language and never +names the classical archetype in its prose (the mapping is hidden, per the deck's +rendering), but it IS told where each card sits on the 0→21 Fool's Journey so it +can add that depth. + +v1 uses one default voice — an earnest, warm mystic — lightly tinted by the deck's +visual style (§5). No custom free-text reading voice in v1. +""" +from __future__ import annotations + +import random +import re + +from .llm import LLM, get_llm +from .styles import reading_tint + +SPREADS: dict[str, list[str]] = { + "single": ["The Heart of It"], + "three": ["Past", "Present", "Future"], +} + +BASE_VOICE = "an earnest, warm mystic — reverent and a little uncanny, but never silly" + + +def _strip_think(text: str) -> str: + return re.sub(r".*?", "", text, flags=re.DOTALL).strip() + + +def draw_spread(deck: dict, spread: str = "three", reversals: bool = True, + seed: int | None = None) -> list[dict]: + """Draw cards (no repeats); orientations random only if reversals enabled.""" + positions = SPREADS.get(spread, SPREADS["three"]) + rng = random.Random(seed) + chosen = rng.sample(deck["cards"], k=len(positions)) + drawn = [] + for pos, card in zip(positions, chosen): + reversed_ = reversals and rng.random() < 0.5 + drawn.append({ + "position": pos, + "arcana_name": card["arcana_name"], + "arcana_number": card["arcana_number"], + "concept": card["concept"], + "orientation": "reversed" if reversed_ else "upright", + "meaning": card["reversed_meaning"] if reversed_ else card["upright_meaning"], + "essence": card.get("essence", ""), + "art_path": card.get("art_path"), + }) + return drawn + + +def journey_note(n: int) -> str: + """Where arcana n sits on the 0→21 Fool's Journey — as a phrase, never the + classical card name (the mapping stays hidden in the prose).""" + if n == 0: return "the very first step of the journey, setting out" + if n <= 2: return "the journey's first awakening" + if n <= 5: return "the early road of the journey" + if n <= 7: return "the journey's first real choices" + if n == 8 or n == 9: return "the turn inward at mid-journey" + if n == 10: return "the great turning of the journey's wheel" + if n <= 12: return "the reckoning in the middle of the journey" + if n <= 14: return "the journey's death-and-rebirth passage" + if n == 15: return "the journey's confrontation with the shadow" + if n == 16: return "the crisis point of the journey" + if n <= 19: return "the journey's return toward the light" + if n == 20: return "the journey's reckoning and awakening" + return "the completion of the journey" + + +def _voice(deck: dict) -> str: + tint = reading_tint(deck.get("visual_style", "")) + return BASE_VOICE + (f"; {tint}" if tint else "") + + +def _prior_block(drawn: list[dict], upto: int) -> str: + if upto == 0: + return "(none yet — this is the first card)" + return "\n".join(f"- [{d['position']}] {d['concept']} ({d['orientation']})" + for d in drawn[:upto]) + + +# ------------------------------------------------------------------ stages +def card_partial(deck: dict, question: str, drawn: list[dict], idx: int, + llm: LLM | None = None) -> str: + """Interpret ONLY drawn[idx], coloured by the cards already turned. Builds the + thread; does not resolve (the synthesis does).""" + llm = llm or get_llm() + theme = deck.get("theme", "this world") + d = drawn[idx] + q = question.strip() or "an open reading — whatever the cards wish to say" + system = f"""\ +You are giving a live tarot reading, turning cards one at a time, from a deck whose +cards are concepts from {theme}. Your voice is {_voice(deck)}. + +Interpret ONLY the card just turned — its meaning in its position, coloured by the +cards already shown. Call it by its concept name and treat it as that very thing, +with its own texture and stakes. Do NOT name classical tarot cards or archetypes. +Build the thread and let suspense gather — do NOT resolve the reading yet; the final +synthesis comes after the last card. 2 to 4 sentences, plain prose, no preamble.""" + user = ( + f"Question: {q}\n\n" + f"Cards already turned:\n{_prior_block(drawn, idx)}\n\n" + f"The card just turned — [{d['position']}] {d['concept']} ({d['orientation']})\n" + f" essence: {d.get('essence','')}\n" + f" meaning now: {d['meaning']}\n" + f" journey: this concept sits at {journey_note(d['arcana_number'])}.\n\n" + "Give the interpretation of this one card now." + ) + return _strip_think(llm.complete(system, user, json_mode=False).strip()) + + +def final_synthesis(deck: dict, question: str, drawn: list[dict], + partials: list[str] | None = None, llm: LLM | None = None) -> str: + """Weave all turned cards into one narrative + a closing verdict (§5).""" + llm = llm or get_llm() + theme = deck.get("theme", "this world") + q = question.strip() or "an open reading — whatever the cards wish to say" + spread_lines = [] + for i, d in enumerate(drawn): + ess = f" — {d['essence']}" if d.get("essence") else "" + spread_lines.append( + f"- [{d['position']}] {d['concept']} ({d['orientation']}){ess}\n" + f" meaning now: {d['meaning']}") + prior = ("\n\nWhat you have said so far, card by card:\n" + + "\n".join(f" • {p}" for p in partials)) if partials else "" + system = f"""\ +You are closing a tarot reading from a deck of concepts from {theme}. Your voice is +{_voice(deck)}. + +Now weave ALL the cards into ONE coherent reading and a closing verdict that truly +answers the question. The craft is in collision — show how the cards modify one +another across their positions (not separate paragraphs), how the story they tell +together turns. Call each card by its concept name; never name classical tarot cards +or archetypes. Honour reversals. End with a clear, earned takeaway the querent can +hold. A few short paragraphs of flowing prose — no headings, no bullets.""" + user = (f"Question: {q}\n\nThe full spread, in order:\n" + + "\n".join(spread_lines) + prior + + "\n\nNow give the final synthesis and verdict.") + return _strip_think(llm.complete(system, user, json_mode=False).strip()) diff --git a/arcana/styles.py b/arcana/styles.py new file mode 100644 index 0000000000000000000000000000000000000000..4c5b166e9189baad05c3aa45e49dce26409b400e --- /dev/null +++ b/arcana/styles.py @@ -0,0 +1,66 @@ +"""User-selectable visual styles (SPEC §8) — visual ONLY. + +The user picks the deck's look at generation time. This drives the image-model +style suffix and nothing else; it does NOT add a custom reading voice (§5, +reading voice stays a single default). Each preset is just a stored suffix +string; "custom" passes the user's free text through. The compositor's frame +stays style-neutral in v1 (§9). + +Guardrails ("no text, no border, cohesive palette…") are appended downstream in +imagegen.GLOBAL_STYLE, so suffixes here describe only the artistic look. +""" +from __future__ import annotations + +DEFAULT_STYLE = "rider-waite-smith" + +STYLE_SUFFIXES: dict[str, str] = { + "rider-waite-smith": + "Rider-Waite-Smith tarot art, rich symbolic illustration, warm storybook " + "palette, gold linework, medieval-renaissance figures", + "thoth": + "Thoth tarot art, dark esoteric and ornate, Art-Deco geometry, jewel " + "tones, dense occult symbolism, luminous", + "marseille": + "Tarot de Marseille art, flat woodcut print, austere bold black outlines, " + "primary red blue and yellow, medieval block-print", + "playful": + "bright modern playful pop illustration, bold flat colours, clean vector " + "shapes, whimsical and irreverent, high-contrast", +} + +# label shown in the UI -> preset id (Custom handled separately) +STYLE_CHOICES: list[tuple[str, str]] = [ + ("Rider-Waite-Smith", "rider-waite-smith"), + ("Thoth (dark, esoteric)", "thoth"), + ("Marseille (woodcut)", "marseille"), + ("Playful / Pop", "playful"), + ("Custom…", "custom"), +] + +# a tempting pre-fill so people discover the custom field (§8) +CUSTOM_PLACEHOLDER = "1920s scientific engraving" + +# light esoteric tint for the reading voice, keyed by visual style (§5) +STYLE_READING_TINT: dict[str, str] = { + "thoth": "lean a little more esoteric and occult in your phrasing", + "marseille": "keep the phrasing plain, old and woodcut-stark", + "playful": "keep it lighter and more mischievous", +} + + +def resolve_style(visual_style: str, custom_text: str | None = None) -> tuple[str, str]: + """Return (style_id, style_suffix) for a chosen preset id or 'custom'. + + 'custom' uses the user's free text (falling back to the placeholder); any + unknown id falls back to the default preset. + """ + if visual_style == "custom": + text = (custom_text or "").strip() or CUSTOM_PLACEHOLDER + return "custom", text + if visual_style in STYLE_SUFFIXES: + return visual_style, STYLE_SUFFIXES[visual_style] + return DEFAULT_STYLE, STYLE_SUFFIXES[DEFAULT_STYLE] + + +def reading_tint(style_id: str) -> str: + return STYLE_READING_TINT.get(style_id, "") diff --git a/assets/fonts/Cinzel.ttf b/assets/fonts/Cinzel.ttf new file mode 100644 index 0000000000000000000000000000000000000000..89a863d7005b66dffef6dffba7ac93ed1a740d01 --- /dev/null +++ b/assets/fonts/Cinzel.ttf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f4d83d34d1f6c741193e4acf4b3dff9531e5a67b6aa65228d00a7db72a4e0f34 +size 125468 diff --git a/assets/fonts/EBGaramond.ttf b/assets/fonts/EBGaramond.ttf new file mode 100644 index 0000000000000000000000000000000000000000..c0076f92b2056fa09f10a9b700e1c0e498026c40 --- /dev/null +++ b/assets/fonts/EBGaramond.ttf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:684fcc0db2239b43497341d596d68422ea60ef7522cf2e857071ad84e4ade2a4 +size 851176 diff --git a/decks/breakfast_foods-playful-1780954994/00_the_banana.png b/decks/breakfast_foods-playful-1780954994/00_the_banana.png new file mode 100644 index 0000000000000000000000000000000000000000..1e9fabbb20b10425eabcbf150c8de5177b26978a --- /dev/null +++ b/decks/breakfast_foods-playful-1780954994/00_the_banana.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:218bae8b4594ddd3e643a4188803404fb00e84a284f7737d7a581ac930718713 +size 498176 diff --git a/decks/breakfast_foods-playful-1780954994/01_the_whisk.png b/decks/breakfast_foods-playful-1780954994/01_the_whisk.png new file mode 100644 index 0000000000000000000000000000000000000000..444d52b35ea3960cbc8c43cd1c2fe1bea0b5de6b --- /dev/null +++ b/decks/breakfast_foods-playful-1780954994/01_the_whisk.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1d4cb619baf6bbf9c985639a1860c28d0c4eb25b150d76eda98dd57ba8b386d2 +size 560663 diff --git a/decks/breakfast_foods-playful-1780954994/02_the_covered_dish.png b/decks/breakfast_foods-playful-1780954994/02_the_covered_dish.png new file mode 100644 index 0000000000000000000000000000000000000000..f5b464a866100fdbe75c5f3a754e0631a3964722 --- /dev/null +++ b/decks/breakfast_foods-playful-1780954994/02_the_covered_dish.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:409d446096263ca00238467e39676d35d4184737e4662d19b0e416fa1e0473cd +size 710745 diff --git a/decks/breakfast_foods-playful-1780954994/03_the_stack_of_pancakes.png b/decks/breakfast_foods-playful-1780954994/03_the_stack_of_pancakes.png new file mode 100644 index 0000000000000000000000000000000000000000..bd392820207c87b08b3e33f7c56537a568e4d564 --- /dev/null +++ b/decks/breakfast_foods-playful-1780954994/03_the_stack_of_pancakes.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2acd29766df2f39c9e7cd0ff99d09605e39a30566831c671099f48226a03dc79 +size 455785 diff --git a/decks/breakfast_foods-playful-1780954994/04_black_coffee.png b/decks/breakfast_foods-playful-1780954994/04_black_coffee.png new file mode 100644 index 0000000000000000000000000000000000000000..8ea299e98dff7c6da37ed04cc22e76238a97e2c4 --- /dev/null +++ b/decks/breakfast_foods-playful-1780954994/04_black_coffee.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b1ded0b77f39da35bb45cbd07f72db2c60c33e70f53b45797d7f5a86cee78538 +size 671576 diff --git a/decks/breakfast_foods-playful-1780954994/05_the_continental_breakfast.png b/decks/breakfast_foods-playful-1780954994/05_the_continental_breakfast.png new file mode 100644 index 0000000000000000000000000000000000000000..720b3339d314e69321de174fac0f0ca84e96c9c7 --- /dev/null +++ b/decks/breakfast_foods-playful-1780954994/05_the_continental_breakfast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d175921eb121aee30ca0765c4532634bb346141432b8e50437b691a9c7ce2d58 +size 507869 diff --git a/decks/breakfast_foods-playful-1780954994/06_bacon_and_eggs.png b/decks/breakfast_foods-playful-1780954994/06_bacon_and_eggs.png new file mode 100644 index 0000000000000000000000000000000000000000..0ff8c4eddb51c4a096c6df413d50f8e6585be672 --- /dev/null +++ b/decks/breakfast_foods-playful-1780954994/06_bacon_and_eggs.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e327c99b5235546d302ffc573c4d2ca9000e15113d9255f7f59af96d11a4320c +size 626836 diff --git a/decks/breakfast_foods-playful-1780954994/07_the_toaster.png b/decks/breakfast_foods-playful-1780954994/07_the_toaster.png new file mode 100644 index 0000000000000000000000000000000000000000..1e496766feb42f1eadc829c9f4a4193476cfe166 --- /dev/null +++ b/decks/breakfast_foods-playful-1780954994/07_the_toaster.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e6b520705f39aa3e522328773c9b66ff918162c456f26829ba3810ef944fd8f4 +size 458173 diff --git a/decks/breakfast_foods-playful-1780954994/08_oatmeal.png b/decks/breakfast_foods-playful-1780954994/08_oatmeal.png new file mode 100644 index 0000000000000000000000000000000000000000..cf7ca7201bd848bc91ac90c5713d6a24c93f68d5 --- /dev/null +++ b/decks/breakfast_foods-playful-1780954994/08_oatmeal.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d3d18696b508894925985635fe05afe5c7b79d13afde39cbc114758921ad2b78 +size 771405 diff --git a/decks/breakfast_foods-playful-1780954994/09_the_lone_boiled_egg.png b/decks/breakfast_foods-playful-1780954994/09_the_lone_boiled_egg.png new file mode 100644 index 0000000000000000000000000000000000000000..88e8ac96c07f316267a6534477d661e41945df8e --- /dev/null +++ b/decks/breakfast_foods-playful-1780954994/09_the_lone_boiled_egg.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a723e0e40a3ab0b24eadce9a0ccc6ad50c17124fb84106beed29d5302888121c +size 329520 diff --git a/decks/breakfast_foods-playful-1780954994/10_the_cereal_box_prize.png b/decks/breakfast_foods-playful-1780954994/10_the_cereal_box_prize.png new file mode 100644 index 0000000000000000000000000000000000000000..a2e70ac984cfcf4fb66824325e14a9258589f098 --- /dev/null +++ b/decks/breakfast_foods-playful-1780954994/10_the_cereal_box_prize.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ebfd1c1ffcd2a355280d2e83939cf9de01f6c1fdfba987aa8a85f6c64c9d8f51 +size 610545 diff --git a/decks/breakfast_foods-playful-1780954994/11_the_diner_check.png b/decks/breakfast_foods-playful-1780954994/11_the_diner_check.png new file mode 100644 index 0000000000000000000000000000000000000000..caca693f2bd628b6d46a62f4c4565d6dfea73be7 --- /dev/null +++ b/decks/breakfast_foods-playful-1780954994/11_the_diner_check.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:21e763a4877aedc8be3db5589cc2fb983b10707d84bdcd537eb4e1e89892d83e +size 888701 diff --git a/decks/breakfast_foods-playful-1780954994/12_french_toast_soaking.png b/decks/breakfast_foods-playful-1780954994/12_french_toast_soaking.png new file mode 100644 index 0000000000000000000000000000000000000000..ce687661b5ff3e4fb22e19851d4eef1e0b7515c3 --- /dev/null +++ b/decks/breakfast_foods-playful-1780954994/12_french_toast_soaking.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7cd448fd4a598ff8d26a3d74a42348744842b74e6cd6ec6193fa2f8763383f05 +size 417917 diff --git a/decks/breakfast_foods-playful-1780954994/13_the_burnt_toast.png b/decks/breakfast_foods-playful-1780954994/13_the_burnt_toast.png new file mode 100644 index 0000000000000000000000000000000000000000..3e4020df9fa2cb1171a08a08ea63303fb49ebadb --- /dev/null +++ b/decks/breakfast_foods-playful-1780954994/13_the_burnt_toast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7bc51d524d7ce1626a170642e4665b4288a62535b0a62e5f5a6ae1cccf197142 +size 695227 diff --git a/decks/breakfast_foods-playful-1780954994/14_the_smoothie.png b/decks/breakfast_foods-playful-1780954994/14_the_smoothie.png new file mode 100644 index 0000000000000000000000000000000000000000..a4bb6aa138de9e255976ccaca071e594783f7079 --- /dev/null +++ b/decks/breakfast_foods-playful-1780954994/14_the_smoothie.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5957afbc5f99c42461b98d7662606eee0d5770e755eda1bf093727d0d72888af +size 321760 diff --git a/decks/breakfast_foods-playful-1780954994/15_the_sugary_cereal.png b/decks/breakfast_foods-playful-1780954994/15_the_sugary_cereal.png new file mode 100644 index 0000000000000000000000000000000000000000..35e1fd9fdc3b2eb241e6d0842b233c5fad0c21ce --- /dev/null +++ b/decks/breakfast_foods-playful-1780954994/15_the_sugary_cereal.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:81ae96c873e47f8c9766a8cc24290b999cf4c8cac704622a4e90f03a81ed4c4f +size 575894 diff --git a/decks/breakfast_foods-playful-1780954994/16_the_dropped_tray.png b/decks/breakfast_foods-playful-1780954994/16_the_dropped_tray.png new file mode 100644 index 0000000000000000000000000000000000000000..29e8577da9c99a2af3637d17659c154cde02cec3 --- /dev/null +++ b/decks/breakfast_foods-playful-1780954994/16_the_dropped_tray.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:51d29a06cbe51c55cf8f5bfa3d4a1ce4775d7f4daa0b1564e25b471fcb7b300d +size 524719 diff --git a/decks/breakfast_foods-playful-1780954994/17_fresh_squeezed_orange_juice.png b/decks/breakfast_foods-playful-1780954994/17_fresh_squeezed_orange_juice.png new file mode 100644 index 0000000000000000000000000000000000000000..f610f8da37d552ce3bbe530def96403fad18b680 --- /dev/null +++ b/decks/breakfast_foods-playful-1780954994/17_fresh_squeezed_orange_juice.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bca6c7832eee106a4c7894d0132c45f8cd53df880f7220bb4260d2da1069628c +size 451978 diff --git a/decks/breakfast_foods-playful-1780954994/18_the_mystery_omelette.png b/decks/breakfast_foods-playful-1780954994/18_the_mystery_omelette.png new file mode 100644 index 0000000000000000000000000000000000000000..ee6027bfc67c946b1af0f6845ac3384825df2f90 --- /dev/null +++ b/decks/breakfast_foods-playful-1780954994/18_the_mystery_omelette.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fc810ffbfe51970ea42f0cf90b05528f29db7d4c4a600a869c64449f8dd5ab61 +size 652150 diff --git a/decks/breakfast_foods-playful-1780954994/19_the_sunny_side_up_egg.png b/decks/breakfast_foods-playful-1780954994/19_the_sunny_side_up_egg.png new file mode 100644 index 0000000000000000000000000000000000000000..98a95d7fc09ec17cb0368bbdc9ebce01f02ad175 --- /dev/null +++ b/decks/breakfast_foods-playful-1780954994/19_the_sunny_side_up_egg.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f93b77b85823b5ec8b086d07eb35ec0b43123e34f685e50bd1fb50f1e0eb6796 +size 647240 diff --git a/decks/breakfast_foods-playful-1780954994/20_order_s_up.png b/decks/breakfast_foods-playful-1780954994/20_order_s_up.png new file mode 100644 index 0000000000000000000000000000000000000000..d6075bd9d1ef268d4a5dcb9789daf911a77c8d85 --- /dev/null +++ b/decks/breakfast_foods-playful-1780954994/20_order_s_up.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bbfdd34c5710f30a011574c5ad68ed4e5abb836ff04ab399b4f01fb473b9297b +size 464062 diff --git a/decks/breakfast_foods-playful-1780954994/21_the_full_english.png b/decks/breakfast_foods-playful-1780954994/21_the_full_english.png new file mode 100644 index 0000000000000000000000000000000000000000..d5ac40babe2fdb7eb8933d6c3123e5e65b7e9405 --- /dev/null +++ b/decks/breakfast_foods-playful-1780954994/21_the_full_english.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5bc7ca410eba06b80bdc5a6e0311ee789a9de8e126f3c6c0d36a9f73fcc52909 +size 699091 diff --git a/decks/breakfast_foods-playful-1780954994/back.png b/decks/breakfast_foods-playful-1780954994/back.png new file mode 100644 index 0000000000000000000000000000000000000000..8db225053d0fc8da73c9f8d51d3235a9f02467dd --- /dev/null +++ b/decks/breakfast_foods-playful-1780954994/back.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f36697cfc2640d0a130b8f43e0de11822bfebb455ed046b506d85a975aab976d +size 1376660 diff --git a/decks/breakfast_foods-playful-1780954994/deck.json b/decks/breakfast_foods-playful-1780954994/deck.json new file mode 100644 index 0000000000000000000000000000000000000000..58a6c4887e1086bb9fe2e4d69fa074c0b9568efc --- /dev/null +++ b/decks/breakfast_foods-playful-1780954994/deck.json @@ -0,0 +1,274 @@ +{ + "theme": "breakfast foods", + "style_suffix": "bright modern playful pop illustration, bold flat colours, clean vector shapes, whimsical and irreverent, high-contrast", + "cards": [ + { + "arcana_number": 0, + "arcana_name": "The Fool", + "concept": "The Banana", + "justification": "A fresh, ripe banana is a simple and innocent beginning, often accompanied by the risk of misstep or falling — a literal pratfall.", + "upright_meaning": "The Banana marks the start of something sweet and ripe — a moment of innocence and optimism, ripe with possibility and a touch of whimsy.", + "reversed_meaning": "A false start or a miscalculation — slipping on your own assumptions or rushing in too soon.", + "art_prompt": "a bright yellow banana hanging from a green stem, sunlight filtering through, with a small figure standing at the edge of a tree branch, one foot in the air", + "art_path": "decks/breakfast_foods-playful-1780954994/00_the_banana.png", + "essence": "Playful new beginnings with a hint of fragility.", + "seed": 871341 + }, + { + "arcana_number": 1, + "arcana_name": "The Magician", + "concept": "The Whisk", + "justification": "The whisk is a tool that transforms raw ingredients into something new through focused motion and energy.", + "upright_meaning": "The Whisk turns raw ingredients into something new — your will is stirring, blending, and bringing ideas to life with focused energy.", + "reversed_meaning": "A lack of momentum — spinning without progress, or mixing without purpose.", + "art_prompt": "a hand holding a whisk moving through a cloud of egg whites, which swirl and rise into a foamy tower", + "art_path": "decks/breakfast_foods-playful-1780954994/01_the_whisk.png", + "essence": "Manifesting through motion and blending.", + "seed": 871342 + }, + { + "arcana_number": 2, + "arcana_name": "The High Priestess", + "concept": "The Covered Dish", + "justification": "The covered dish holds a mystery — something hidden and waiting to be revealed, much like intuition.", + "upright_meaning": "The Covered Dish holds something hidden beneath its lid — trust in the secret forces at work, even when you cannot yet see them.", + "reversed_meaning": "Ignoring what lies beneath — closing the lid too tightly or refusing to look inside.", + "art_prompt": "a steaming covered casserole on a wooden table, the lid slightly ajar, revealing a golden, bubbly interior", + "art_path": "decks/breakfast_foods-playful-1780954994/02_the_covered_dish.png", + "essence": "Mystery beneath the surface.", + "seed": 871343 + }, + { + "arcana_number": 3, + "arcana_name": "The Empress", + "concept": "The Stack of Pancakes", + "justification": "Pancakes are nurturing and abundant, layered with care and richness, much like the Empress's nurturing abundance.", + "upright_meaning": "The Stack of Pancakes is a bountiful creation, rising from care and generosity — a gift of warmth and sustenance shared with others.", + "reversed_meaning": "Overload or neglect — too many layers without support, or a neglected pile that never rises.", + "art_prompt": "a warm stack of fluffy pancakes, syrup glistening on each layer, with a red cherry on top", + "art_path": "decks/breakfast_foods-playful-1780954994/03_the_stack_of_pancakes.png", + "essence": "Nurturing abundance, layer by layer.", + "seed": 871344 + }, + { + "arcana_number": 4, + "arcana_name": "The Emperor", + "concept": "Black Coffee", + "justification": "Black coffee is bitter and bold, symbolizing control and the structure of morning routine.", + "upright_meaning": "Black Coffee offers a bold clarity — a moment of strong authority, a decision made with focus and confidence.", + "reversed_meaning": "Rigid control leading to bitterness — or a stale routine that no longer fuels you.", + "art_prompt": "a steaming black coffee in a porcelain mug, sitting on a wooden tray with a clock and newspaper", + "art_path": "decks/breakfast_foods-playful-1780954994/04_black_coffee.png", + "essence": "Clarity through strength and structure.", + "seed": 871345 + }, + { + "arcana_number": 5, + "arcana_name": "The Hierophant", + "concept": "The Continental Breakfast", + "justification": "The continental breakfast is a standard, traditional offering served at hotels and cafes worldwide.", + "upright_meaning": "The Continental Breakfast invites you into a ritual of comfort and connection — finding your place within a shared experience.", + "reversed_meaning": "Feeling trapped by routine — eating out of habit rather than heart.", + "art_prompt": "a breakfast tray with bread, butter, jam, and a small glass of juice on a white cloth", + "art_path": "decks/breakfast_foods-playful-1780954994/05_the_continental_breakfast.png", + "essence": "Tradition served with a sense of place.", + "seed": 871346 + }, + { + "arcana_number": 6, + "arcana_name": "The Lovers", + "concept": "Bacon and Eggs", + "justification": "Bacon and eggs are a classic pair, often chosen together as a harmonious combination.", + "upright_meaning": "Bacon and Eggs show a deep harmony of parts — when two forces align to create something greater than themselves.", + "reversed_meaning": "A failed pairing — when components clash instead of complementing, or when one dominates the other.", + "art_prompt": "a sizzling pan with eggs and bacon side by side, their juices mingling in a golden pool", + "art_path": "decks/breakfast_foods-playful-1780954994/06_bacon_and_eggs.png", + "essence": "Complementary union.", + "seed": 871347 + }, + { + "arcana_number": 7, + "arcana_name": "The Chariot", + "concept": "The Toaster", + "justification": "The toaster channels heat to control the bread’s transformation into a desired, triumphant form — a chariot of morning energy.", + "upright_meaning": "The Toaster moves with precision — mastering time and temperature to achieve your goal with focus and fire.", + "reversed_meaning": "A failure to execute — undercooked intentions or a fire that burns too hot, too fast.", + "art_prompt": "a vintage toaster glowing red-hot, with golden slices of bread emerging on each side", + "art_path": "decks/breakfast_foods-playful-1780954994/07_the_toaster.png", + "essence": "Control through heat and momentum.", + "seed": 871348 + }, + { + "arcana_number": 8, + "arcana_name": "Strength", + "concept": "Oatmeal", + "justification": "Oatmeal provides quiet, inner strength and sustains through the morning with gentle power.", + "upright_meaning": "Oatmeal is the strength of steady warmth — the kind that nourishes without noise, and supports without strain.", + "reversed_meaning": "Drained energy or lack of foundation — feeling empty or brittle despite having rested.", + "art_prompt": "a bowl of steaming oatmeal with cinnamon sprinkled on top, held in a woman's hand", + "art_path": "decks/breakfast_foods-playful-1780954994/08_oatmeal.png", + "essence": "Strength in quiet endurance.", + "seed": 871349 + }, + { + "arcana_number": 9, + "arcana_name": "The Hermit", + "concept": "The Lone Boiled Egg", + "justification": "The boiled egg is self-contained and solitary, sitting in quiet reflection.", + "upright_meaning": "The Lone Boiled Egg offers a moment of stillness — a time to look inward, to listen to your own quiet voice.", + "reversed_meaning": "Loneliness without purpose — being alone but not at peace with yourself.", + "art_prompt": "a single boiled egg in a small porcelain cup, sitting on a windowsill with soft morning light", + "art_path": "decks/breakfast_foods-playful-1780954994/09_the_lone_boiled_egg.png", + "essence": "Introspection in a shell.", + "seed": 871350 + }, + { + "arcana_number": 10, + "arcana_name": "Wheel of Fortune", + "concept": "The Cereal-Box Prize", + "justification": "The cereal-box prize is a small but precious surprise — a moment of fate in a box.", + "upright_meaning": "The Cereal-Box Prize is a twist of fate — a small treasure from a larger game, a chance to be surprised by fortune.", + "reversed_meaning": "Missed opportunity — the prize is gone, or the box is empty just when you needed it.", + "art_prompt": "a child holding a cereal box with a toy peeking out from the top, eyes wide with wonder", + "art_path": "decks/breakfast_foods-playful-1780954994/10_the_cereal_box_prize.png", + "essence": "Luck in the box.", + "seed": 871351 + }, + { + "arcana_number": 11, + "arcana_name": "Justice", + "concept": "The Diner Check", + "justification": "The check at the diner represents the fair accounting of what was ordered and paid for.", + "upright_meaning": "The Diner Check reveals what you owe and what is owed to you — a moment of truth in exchange and expectation.", + "reversed_meaning": "Unbalanced transactions — being shortchanged, or failing to give what is fair.", + "art_prompt": "a waitress sliding a check across the diner counter, a pen in hand, waiting for signature", + "art_path": "decks/breakfast_foods-playful-1780954994/11_the_diner_check.png", + "essence": "Fair accounting at the table.", + "seed": 871352 + }, + { + "arcana_number": 12, + "arcana_name": "The Hanged Man", + "concept": "French Toast Soaking", + "justification": "French toast is suspended in custard, waiting for transformation — a state of suspension and acceptance.", + "upright_meaning": "French Toast Soaking invites you to let the old be absorbed — the slow transformation of self through patience and surrender.", + "reversed_meaning": "Stuck in waiting — refusing to let go, or waiting without purpose.", + "art_prompt": "a slice of bread soaking in a golden custard bath, slowly absorbing the liquid", + "art_path": "decks/breakfast_foods-playful-1780954994/12_french_toast_soaking.png", + "essence": "Soaking in transformation.", + "seed": 871353 + }, + { + "arcana_number": 13, + "arcana_name": "Death", + "concept": "The Burnt Toast", + "justification": "Burnt toast is an irreversible change — a transformation into something unusable.", + "upright_meaning": "The Burnt Toast signals a necessary end — something must be sacrificed to clear the path for what is to come.", + "reversed_meaning": "Clinging to the past — trying to revive what is already lost.", + "art_prompt": "a piece of burnt toast, curled at the edges, sitting in a cold plate of crumbs", + "art_path": "decks/breakfast_foods-playful-1780954994/13_the_burnt_toast.png", + "essence": "Ashes of a flame.", + "seed": 871354 + }, + { + "arcana_number": 14, + "arcana_name": "Temperance", + "concept": "The Smoothie", + "justification": "Smoothies blend contrasting flavors into a balanced and harmonious whole.", + "upright_meaning": "The Smoothie shows harmony in blend — when ingredients that seem incompatible unite into something both nourishing and refreshing.", + "reversed_meaning": "An imbalance of taste — too much of one flavor, or a drink that refuses to settle.", + "art_prompt": "a vibrant green smoothie in a glass, with a straw and a banana half hanging over the edge", + "art_path": "decks/breakfast_foods-playful-1780954994/14_the_smoothie.png", + "essence": "Balancing the flavors.", + "seed": 871355 + }, + { + "arcana_number": 15, + "arcana_name": "The Devil", + "concept": "The Sugary Cereal", + "justification": "Sugary cereal is addictive and hard to resist — a bondage to craving and indulgence.", + "upright_meaning": "The Sugary Cereal is a trap of sweetness — you are tied to what you crave, unable to break free from its grip.", + "reversed_meaning": "Breaking the spell — shaking free from unhealthy patterns and finding clarity.", + "art_prompt": "a bowl of colorful sugary cereal, the milk bubbling with cartoonish delight", + "art_path": "decks/breakfast_foods-playful-1780954994/15_the_sugary_cereal.png", + "essence": "Bound by craving.", + "seed": 871356 + }, + { + "arcana_number": 16, + "arcana_name": "The Tower", + "concept": "The Dropped Tray", + "justification": "A dropped tray is a sudden collapse — everything falls apart in a moment.", + "upright_meaning": "The Dropped Tray is chaos unleashed — the sudden undoing of what you thought was carefully arranged.", + "reversed_meaning": "Refusing to let go — holding on to broken pieces and pretending they still serve a purpose.", + "art_prompt": "a tray of food mid-fall, glasses and food spilling in slow motion across the floor", + "art_path": "decks/breakfast_foods-playful-1780954994/16_the_dropped_tray.png", + "essence": "Order overturned.", + "seed": 871357 + }, + { + "arcana_number": 17, + "arcana_name": "The Star", + "concept": "Fresh-Squeezed Orange Juice", + "justification": "Fresh orange juice brings hope and renewal — a burst of vitality in the morning.", + "upright_meaning": "Fresh-Squeezed Orange Juice brings a burst of hope — the kind that lifts your spirit and restores your energy.", + "reversed_meaning": "Bitter disappointment — the promise of light turned sour.", + "art_prompt": "a glass of fresh-squeezed orange juice, sunlight streaming in, with drops of juice dripping from the orange", + "art_path": "decks/breakfast_foods-playful-1780954994/17_fresh_squeezed_orange_juice.png", + "essence": "Light in the glass.", + "seed": 871358 + }, + { + "arcana_number": 18, + "arcana_name": "The Moon", + "concept": "The Mystery Omelette", + "justification": "The omelette is made with unknown ingredients — a symbol of uncertainty and fear.", + "upright_meaning": "The Mystery Omelette offers uncertainty — you are cooking with unknowns, trusting the process even if the result is unclear.", + "reversed_meaning": "Truth revealed — the veil is lifted, and the contents are known, even if they are unsettling.", + "art_prompt": "a waiter holding a silver dome over a plate, the omelette hidden beneath, eyes squinting in curiosity", + "art_path": "decks/breakfast_foods-playful-1780954994/18_the_mystery_omelette.png", + "essence": "Mystery in the pan.", + "seed": 871359 + }, + { + "arcana_number": 19, + "arcana_name": "The Sun", + "concept": "The Sunny-Side-Up Egg", + "justification": "The Sunny-Side-Up Egg is a literal sun — bright, joyous, and full of vitality.", + "upright_meaning": "The Sunny-Side-Up Egg is a symbol of clear success — your efforts have risen, and the future looks golden and unbroken.", + "reversed_meaning": "A cracked promise — the yolk breaks, and the optimism fades.", + "art_prompt": "a sunny-side-up egg on a white plate, surrounded by herbs and sunlight", + "art_path": "decks/breakfast_foods-playful-1780954994/19_the_sunny_side_up_egg.png", + "essence": "Sun in the bowl.", + "seed": 871360 + }, + { + "arcana_number": 20, + "arcana_name": "Judgement", + "concept": "\"Order's Up!\"", + "justification": "The breakfast bell is the call to judgment — you are summoned to receive the fruit of your labor.", + "upright_meaning": "Order's Up is a summons to action — the time has come to respond, to wake up or take responsibility.", + "reversed_meaning": "Ignoring the call — pretending you don’t hear it or that it isn’t for you.", + "art_prompt": "a diner bell hanging from a wooden counter, with a chef holding a tray, calling out to the customer", + "art_path": "decks/breakfast_foods-playful-1780954994/20_order_s_up.png", + "essence": "The call from the kitchen.", + "seed": 871361 + }, + { + "arcana_number": 21, + "arcana_name": "The World", + "concept": "The Full English", + "justification": "The full English is a complete and integrated breakfast — everything on the plate working together in harmony.", + "upright_meaning": "The Full English is the gathering of all parts — a satisfying, whole meal that shows integration and fulfillment.", + "reversed_meaning": "Incompletion or imbalance — missing ingredients, or a meal that fails to come together.", + "art_prompt": "a plate containing bacon, eggs, sausages, beans, toast, and mushrooms, all perfectly arranged", + "art_path": "decks/breakfast_foods-playful-1780954994/21_the_full_english.png", + "essence": "Completion on the plate.", + "seed": 871362 + } + ], + "deck_id": "breakfast_foods-playful-1780954994", + "visual_style": "playful", + "seed_base": 871341, + "back_path": "decks/breakfast_foods-playful-1780954994/back.png" +} \ No newline at end of file diff --git a/decks/lord_of_the_rings-bundled/00_frodo_baggins.png b/decks/lord_of_the_rings-bundled/00_frodo_baggins.png new file mode 100644 index 0000000000000000000000000000000000000000..54f9ea6afc8224732692114b1471c46800cf8efa --- /dev/null +++ b/decks/lord_of_the_rings-bundled/00_frodo_baggins.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fb9d9975ea23c4fbe1a60db3c9e7600ebd553e3a203c3e688cc8323b34874ab4 +size 1433153 diff --git a/decks/lord_of_the_rings-bundled/01_gandalf_the_grey.png b/decks/lord_of_the_rings-bundled/01_gandalf_the_grey.png new file mode 100644 index 0000000000000000000000000000000000000000..424f849efa761c3eb0736ffa691c73547fd0504c --- /dev/null +++ b/decks/lord_of_the_rings-bundled/01_gandalf_the_grey.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:83cae7fc315f3c6ff492aa051edf5e4416f1ba09a25a495c05323b16d4f98c61 +size 1539770 diff --git a/decks/lord_of_the_rings-bundled/02_the_mirror_of_galadriel.png b/decks/lord_of_the_rings-bundled/02_the_mirror_of_galadriel.png new file mode 100644 index 0000000000000000000000000000000000000000..d9a5fc195222e0268ed61f12de8bcc418327653f --- /dev/null +++ b/decks/lord_of_the_rings-bundled/02_the_mirror_of_galadriel.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:53403ebd5ad2073c0f65dab76943ae8635e6b10a4c3ad6c92c07d9d9f565499b +size 1675832 diff --git "a/decks/lord_of_the_rings-bundled/03_lothl\303\263rien.png" "b/decks/lord_of_the_rings-bundled/03_lothl\303\263rien.png" new file mode 100644 index 0000000000000000000000000000000000000000..9222a5ebb34a1e806a91214ce531f86fec986d21 --- /dev/null +++ "b/decks/lord_of_the_rings-bundled/03_lothl\303\263rien.png" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a639ef360c852e4a4161166fb97e1dae8bb15620171322a69783f1637cd479ef +size 1371664 diff --git "a/decks/lord_of_the_rings-bundled/04_th\303\251oden_king.png" "b/decks/lord_of_the_rings-bundled/04_th\303\251oden_king.png" new file mode 100644 index 0000000000000000000000000000000000000000..bd1ff8edc64afa4fe49ad103eb9dd31068f573e2 --- /dev/null +++ "b/decks/lord_of_the_rings-bundled/04_th\303\251oden_king.png" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d1e79a40eeed65361b2c11c09b2ae3d1401c90c6a1dee7aebc64b2f0fa5bc7c8 +size 670312 diff --git a/decks/lord_of_the_rings-bundled/05_the_council_of_elrond.png b/decks/lord_of_the_rings-bundled/05_the_council_of_elrond.png new file mode 100644 index 0000000000000000000000000000000000000000..76ca802a3f41f384683d344751161ad16343f7d1 --- /dev/null +++ b/decks/lord_of_the_rings-bundled/05_the_council_of_elrond.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4e94e3facf1dff221db8b4fd9cd46f9d7d640b2f59d4bf5853719d8b109a2904 +size 1498142 diff --git a/decks/lord_of_the_rings-bundled/06_aragorn_and_arwen.png b/decks/lord_of_the_rings-bundled/06_aragorn_and_arwen.png new file mode 100644 index 0000000000000000000000000000000000000000..b22c0de0b18944bd922c7899f68c165068e5050a --- /dev/null +++ b/decks/lord_of_the_rings-bundled/06_aragorn_and_arwen.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:28b6200e8c3d3966f205da2f57ec8e34746c81d4d31a0ff1fb83cacfe8bb4f44 +size 1067749 diff --git a/decks/lord_of_the_rings-bundled/07_the_battle_of_pelennor_f.png b/decks/lord_of_the_rings-bundled/07_the_battle_of_pelennor_f.png new file mode 100644 index 0000000000000000000000000000000000000000..aa112b6f0e13bcfa752aff8f1b2f1c72cb633121 --- /dev/null +++ b/decks/lord_of_the_rings-bundled/07_the_battle_of_pelennor_f.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:75c16677ec4f3e142ec34d1261964ba94118506d5b99f2de4f53d13651bf0355 +size 1627306 diff --git a/decks/lord_of_the_rings-bundled/08_gollum.png b/decks/lord_of_the_rings-bundled/08_gollum.png new file mode 100644 index 0000000000000000000000000000000000000000..f6f94f85b405b84b5531576e92c18c79a9b9e3ea --- /dev/null +++ b/decks/lord_of_the_rings-bundled/08_gollum.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a07de21cb0c64d12d64de1b17dcfc99b4c50d8dc6df6164ccb7988157f8be2dd +size 1254376 diff --git a/decks/lord_of_the_rings-bundled/09_faramir_in_ithilien.png b/decks/lord_of_the_rings-bundled/09_faramir_in_ithilien.png new file mode 100644 index 0000000000000000000000000000000000000000..6da962ef1801237515fa29f3f3616cc1aed7e99b --- /dev/null +++ b/decks/lord_of_the_rings-bundled/09_faramir_in_ithilien.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bc6846cb4d0269d6288985553b967f0dfd7cae6c941e713cffc96bf3536b9880 +size 956324 diff --git a/decks/lord_of_the_rings-bundled/10_the_one_ring.png b/decks/lord_of_the_rings-bundled/10_the_one_ring.png new file mode 100644 index 0000000000000000000000000000000000000000..3925caf716e6b4b59513206847ab88b808c1bb93 --- /dev/null +++ b/decks/lord_of_the_rings-bundled/10_the_one_ring.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fd6415f4aff3304e3235b68014a6831cb8d8249defc711051981dcd4467f7120 +size 1333872 diff --git a/decks/lord_of_the_rings-bundled/11_the_judgment_of_isildur.png b/decks/lord_of_the_rings-bundled/11_the_judgment_of_isildur.png new file mode 100644 index 0000000000000000000000000000000000000000..882c943d92857631f4bbf24878bbb4dd61591fd2 --- /dev/null +++ b/decks/lord_of_the_rings-bundled/11_the_judgment_of_isildur.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a60b647cde5655dc30cdd5341051616bcc3eea62f6ead68c9072addb4f254fb0 +size 1522325 diff --git a/decks/lord_of_the_rings-bundled/12_gandalf_falls_in_moria.png b/decks/lord_of_the_rings-bundled/12_gandalf_falls_in_moria.png new file mode 100644 index 0000000000000000000000000000000000000000..9a967d02dd83284e265235cd62e1eb34ac60aba3 --- /dev/null +++ b/decks/lord_of_the_rings-bundled/12_gandalf_falls_in_moria.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:79701dd1f39dd5c54c5a54aa1159cd8a8919f6415cc804bb21667004026cfca1 +size 1686714 diff --git a/decks/lord_of_the_rings-bundled/13_sauron_s_defeat.png b/decks/lord_of_the_rings-bundled/13_sauron_s_defeat.png new file mode 100644 index 0000000000000000000000000000000000000000..5b21a0e3cc55147be69b6aaa1b72161f98ae0ed9 --- /dev/null +++ b/decks/lord_of_the_rings-bundled/13_sauron_s_defeat.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4204d5456c50f24707418a3b958a52bc702cd135f5383d4b811c80c7e0daab05 +size 1672208 diff --git a/decks/lord_of_the_rings-bundled/14_the_shire_after_the_war.png b/decks/lord_of_the_rings-bundled/14_the_shire_after_the_war.png new file mode 100644 index 0000000000000000000000000000000000000000..e069c49195751c5352ab5cf2480e8a44090b8219 --- /dev/null +++ b/decks/lord_of_the_rings-bundled/14_the_shire_after_the_war.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8393ed1451efe1f24107649f614cc699949175a455bd4e29f8b88f3437f13f63 +size 970668 diff --git a/decks/lord_of_the_rings-bundled/15_the_eye_of_sauron.png b/decks/lord_of_the_rings-bundled/15_the_eye_of_sauron.png new file mode 100644 index 0000000000000000000000000000000000000000..0b6e7bf1cab9f8bd2b300798077dc3534532b309 --- /dev/null +++ b/decks/lord_of_the_rings-bundled/15_the_eye_of_sauron.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3f9472665e275001bbc0a0c0bc891b8e44555990e5d8295645b276b48c6c40b4 +size 1231578 diff --git "a/decks/lord_of_the_rings-bundled/16_the_fall_of_barad_d\303\273r.png" "b/decks/lord_of_the_rings-bundled/16_the_fall_of_barad_d\303\273r.png" new file mode 100644 index 0000000000000000000000000000000000000000..e8611d99cc6779f863613e10ef7a48a05f63f0a3 --- /dev/null +++ "b/decks/lord_of_the_rings-bundled/16_the_fall_of_barad_d\303\273r.png" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c8e6f1cf4d85a2b55f4da2daefc9199526ede49de0c18c512b3c8e6f6762d6b8 +size 1630161 diff --git "a/decks/lord_of_the_rings-bundled/17_the_light_of_e\303\244rendil.png" "b/decks/lord_of_the_rings-bundled/17_the_light_of_e\303\244rendil.png" new file mode 100644 index 0000000000000000000000000000000000000000..11dd2fba6109dd0cccab3210a256da4efc6ce298 --- /dev/null +++ "b/decks/lord_of_the_rings-bundled/17_the_light_of_e\303\244rendil.png" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:70baa7f3a0a76f39c641b1d053d48366bb95a7650887ba1a6eb09f1e73810ab0 +size 689300 diff --git a/decks/lord_of_the_rings-bundled/18_the_misty_mountains.png b/decks/lord_of_the_rings-bundled/18_the_misty_mountains.png new file mode 100644 index 0000000000000000000000000000000000000000..39c6e505dbd46ddecc3e7aaa74cc5144d5dc7b1a --- /dev/null +++ b/decks/lord_of_the_rings-bundled/18_the_misty_mountains.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5bb7b6abb0530236ff10232324e5f431cc484ff0430c12d0a6ce78d570264cd1 +size 1426666 diff --git a/decks/lord_of_the_rings-bundled/19_the_reckoning_at_the_end.png b/decks/lord_of_the_rings-bundled/19_the_reckoning_at_the_end.png new file mode 100644 index 0000000000000000000000000000000000000000..77d58f89334041b705b3f6824b773f68724503f2 --- /dev/null +++ b/decks/lord_of_the_rings-bundled/19_the_reckoning_at_the_end.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e34ecd7704aa8e69c118296f4b36ff35ad86c33f2ff58293b09f98e3d8abfa5f +size 1271157 diff --git a/decks/lord_of_the_rings-bundled/20_the_scouring_of_the_shir.png b/decks/lord_of_the_rings-bundled/20_the_scouring_of_the_shir.png new file mode 100644 index 0000000000000000000000000000000000000000..de4796a9ffca6adecd9ed2f3e2c20f2fcaa1f639 --- /dev/null +++ b/decks/lord_of_the_rings-bundled/20_the_scouring_of_the_shir.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f138869b7af28b5bb57800884fb3b1e3e70459f16bd7e1e380a1647fe70e8fe5 +size 1669141 diff --git a/decks/lord_of_the_rings-bundled/21_the_return_of_the_king.png b/decks/lord_of_the_rings-bundled/21_the_return_of_the_king.png new file mode 100644 index 0000000000000000000000000000000000000000..96f368aec93a9b088ffdc699e63636d6819eae06 --- /dev/null +++ b/decks/lord_of_the_rings-bundled/21_the_return_of_the_king.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eac01a0d834de59bc6dd479d397a17a6dcd9726ff9613391af8d32a7bfd9ffa4 +size 805329 diff --git a/decks/lord_of_the_rings-bundled/back.png b/decks/lord_of_the_rings-bundled/back.png new file mode 100644 index 0000000000000000000000000000000000000000..0dc3569e91bdd01e6f76b4da7a2051e5f6153e6f --- /dev/null +++ b/decks/lord_of_the_rings-bundled/back.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:84a3ae66b8c50d9d4acac4dad875b4902fbaaf5202716dee692c83902a10da85 +size 2126507 diff --git a/decks/lord_of_the_rings-bundled/deck.json b/decks/lord_of_the_rings-bundled/deck.json new file mode 100644 index 0000000000000000000000000000000000000000..3e2c5665f1e4e9a8c6a62c57513de50b6a62024d --- /dev/null +++ b/decks/lord_of_the_rings-bundled/deck.json @@ -0,0 +1,274 @@ +{ + "deck_id": "lord_of_the_rings-bundled", + "theme": "Lord of the Rings", + "visual_style": "custom", + "style_suffix": "medieval illuminated manuscript style with golden ink and intricate linework", + "seed_base": 770077, + "back_path": "decks/lord_of_the_rings-bundled/back.png", + "cards": [ + { + "arcana_number": 0, + "arcana_name": "The Fool", + "concept": "Frodo Baggins", + "justification": "Embodies innocence and the leap of faith at the start of a perilous journey.", + "upright_meaning": "A small but determined soul embarks on a great path, guided by innocence, purpose, and hope.", + "reversed_meaning": "A loss of idealism, or a failure to see the burden one carries.", + "art_prompt": "a hobbit standing at the threshold of a green door, a small ring in hand, with a path winding into distant mountains", + "art_path": "decks/lord_of_the_rings-bundled/00_frodo_baggins.png", + "essence": "A hobbit’s journey into darkness with a heart full of light.", + "seed": 770077 + }, + { + "arcana_number": 1, + "arcana_name": "The Magician", + "concept": "Gandalf the Grey", + "justification": "Gandalf channels great power into purposeful action and transformation.", + "upright_meaning": "A master of elements and knowledge, guiding others with wisdom and inner fire.", + "reversed_meaning": "Power used without wisdom or clarity; guidance turned to confusion.", + "art_prompt": "a tall grey-clad wizard holding a staff, the flame of his pipe glowing as he stands on a cliff overlooking a storm", + "art_path": "decks/lord_of_the_rings-bundled/01_gandalf_the_grey.png", + "essence": "A wizard who wields fire and wisdom to shape the world.", + "seed": 770078 + }, + { + "arcana_number": 2, + "arcana_name": "The High Priestess", + "concept": "The Mirror of Galadriel", + "justification": "The mirror reflects hidden truths and unseen futures.", + "upright_meaning": "A vision of the unseen, offering clarity through reflection and foresight.", + "reversed_meaning": "Misreading what is seen; confusion between truth and illusion.", + "art_prompt": "a polished silver mirror reflecting a starry sky and a shadowy figure standing at the edge of a forest", + "art_path": "decks/lord_of_the_rings-bundled/02_the_mirror_of_galadriel.png", + "essence": "A mirror that reveals the past and future in one glance.", + "seed": 770079 + }, + { + "arcana_number": 3, + "arcana_name": "The Empress", + "concept": "Lothlórien", + "justification": "Lothlórien is a place of abundance, beauty, and nurturing life.", + "upright_meaning": "A place of beauty, healing, and ancient grace, offering rest and renewal.", + "reversed_meaning": "Beauty corrupted or a paradise lost to carelessness.", + "art_prompt": "a golden tree glowing in a forest of silver-barked trees, a river of light flowing nearby", + "art_path": "decks/lord_of_the_rings-bundled/03_lothlórien.png", + "essence": "A land where time slows and peace is woven into the trees.", + "seed": 770080 + }, + { + "arcana_number": 4, + "arcana_name": "The Emperor", + "concept": "Théoden King", + "justification": "Théoden embodies authority, leadership, and the restoration of order.", + "upright_meaning": "A leader who restores order, strength, and dignity to a broken land.", + "reversed_meaning": "A leader who wavers or fails to claim their rightful place.", + "art_prompt": "a tall, kingly figure in white and silver standing on a hill, sword raised, leading riders into the horizon", + "art_path": "decks/lord_of_the_rings-bundled/04_théoden_king.png", + "essence": "A king who returns to reclaim his throne and his people.", + "seed": 770081 + }, + { + "arcana_number": 5, + "arcana_name": "The Hierophant", + "concept": "The Council of Elrond", + "justification": "A gathering of wisdom and tradition to decide the course of action.", + "upright_meaning": "A sacred assembly where choices are made with care and purpose.", + "reversed_meaning": "Disarray in leadership or a failure to reach consensus.", + "art_prompt": "a long, round table with robed figures seated, a ring at the center casting a faint glow", + "art_path": "decks/lord_of_the_rings-bundled/05_the_council_of_elrond.png", + "essence": "A gathering where fate and wisdom are weighed in council.", + "seed": 770082 + }, + { + "arcana_number": 6, + "arcana_name": "The Lovers", + "concept": "Aragorn and Arwen", + "justification": "A union of choice and destiny, bound by love and sacrifice.", + "upright_meaning": "A union of heart and purpose, shaped by fate and sacrifice.", + "reversed_meaning": "Love unfulfilled or a choice not made in time.", + "art_prompt": "two figures standing at the edge of a river, one in royal armor, the other in flowing robes, gazing into each other’s eyes", + "art_path": "decks/lord_of_the_rings-bundled/06_aragorn_and_arwen.png", + "essence": "Two souls bound by love and duty.", + "seed": 770083 + }, + { + "arcana_number": 7, + "arcana_name": "The Chariot", + "concept": "The Battle of Pelennor Fields", + "justification": "A coordinated battle for victory, led by will and courage.", + "upright_meaning": "A decisive moment where unity and courage turn the tide.", + "reversed_meaning": "Leadership failing in chaos; victory slipping away.", + "art_prompt": "a galloping white horse, riderless, galloping toward a burning city, arrows flying in the sky", + "art_path": "decks/lord_of_the_rings-bundled/07_the_battle_of_pelennor_f.png", + "essence": "An army moving as one toward the heart of battle.", + "seed": 770084 + }, + { + "arcana_number": 8, + "arcana_name": "Strength", + "concept": "Gollum", + "justification": "Despite his brokenness, Gollum’s devotion to the Ring shows inner strength and strange loyalty.", + "upright_meaning": "Strength found in vulnerability; a twisted path leading to redemption.", + "reversed_meaning": "A mind torn in two, unable to choose or heal.", + "art_prompt": "a thin, twisted creature clutching a ring with both hands, standing at the edge of a crumbling bridge", + "art_path": "decks/lord_of_the_rings-bundled/08_gollum.png", + "essence": "A soul split between light and shadow, love and obsession.", + "seed": 770085 + }, + { + "arcana_number": 9, + "arcana_name": "The Hermit", + "concept": "Faramir in Ithilien", + "justification": "Faramir, in solitude, watches and learns from the land, waiting for the right time to act.", + "upright_meaning": "A guardian of the borderlands, seeking truth in quiet solitude.", + "reversed_meaning": "Isolation leading to hesitation or missed opportunity.", + "art_prompt": "a lone ranger cloaked in green, standing on a hill at dusk, watching a distant road", + "art_path": "decks/lord_of_the_rings-bundled/09_faramir_in_ithilien.png", + "essence": "A ranger who walks alone, seeking wisdom in the wild.", + "seed": 770086 + }, + { + "arcana_number": 10, + "arcana_name": "Wheel of Fortune", + "concept": "The One Ring", + "justification": "The Ring is a force of fate, turning the course of history with unseen hands.", + "upright_meaning": "A force beyond control, turning the course of destiny in unseen ways.", + "reversed_meaning": "Fate unraveled; what was built is undone by a single lost object.", + "art_prompt": "a golden ring floating in space, casting dark shadows over mountains, rivers, and lands below", + "art_path": "decks/lord_of_the_rings-bundled/10_the_one_ring.png", + "essence": "A ring that turns fortune, binds fate, and whispers temptation.", + "seed": 770087 + }, + { + "arcana_number": 11, + "arcana_name": "Justice", + "concept": "The Judgment of Isildur", + "justification": "Isildur’s failure to destroy the Ring led to a long chain of consequences.", + "upright_meaning": "Consequences return when choices are left unhealed; truth revealed in time.", + "reversed_meaning": "Justice delayed or denied, letting old wounds fester.", + "art_prompt": "a shattered sword in a river, the golden ring floating beside it, a shadowy figure watching from the shore", + "art_path": "decks/lord_of_the_rings-bundled/11_the_judgment_of_isildur.png", + "essence": "A king’s downfall and a nation’s reckoning.", + "seed": 770088 + }, + { + "arcana_number": 12, + "arcana_name": "The Hanged Man", + "concept": "Gandalf falls in Moria", + "justification": "Gandalf is suspended in death and transformation, from grey to white.", + "upright_meaning": "A moment of surrender that leads to transformation and rebirth.", + "reversed_meaning": "A missed chance to let go and move forward.", + "art_prompt": "a tall figure falling from a bridge, a flame in hand, darkness rising behind him", + "art_path": "decks/lord_of_the_rings-bundled/12_gandalf_falls_in_moria.png", + "essence": "A death that becomes a new birth.", + "seed": 770089 + }, + { + "arcana_number": 13, + "arcana_name": "Death", + "concept": "Sauron’s Defeat", + "justification": "The end of Sauron, a transformation of the world, and the end of an age.", + "upright_meaning": "The end of tyranny, the breaking of an evil force, making way for a new age.", + "reversed_meaning": "Clinging to the past, refusing to let go of dying power.", + "art_prompt": "a flaming eye falling apart into ash, a golden ring rising into the sky", + "art_path": "decks/lord_of_the_rings-bundled/13_sauron_s_defeat.png", + "essence": "The fall of a power that ruled in shadow.", + "seed": 770090 + }, + { + "arcana_number": 14, + "arcana_name": "Temperance", + "concept": "The Shire after the War", + "justification": "The Shire represents balance restored after the upheaval of war.", + "upright_meaning": "A return to peace through careful blending of old and new, tradition and change.", + "reversed_meaning": "Peace disrupted, or the struggle to rebuild what was broken.", + "art_prompt": "a green and peaceful landscape with smoke rising from a distant fire; a hobbit smiling in a field", + "art_path": "decks/lord_of_the_rings-bundled/14_the_shire_after_the_war.png", + "essence": "A land restored after the storm.", + "seed": 770091 + }, + { + "arcana_number": 15, + "arcana_name": "The Devil", + "concept": "The Eye of Sauron", + "justification": "Sauron represents temptation, control, and the shadow of corruption.", + "upright_meaning": "A force of dominance and fear, drawing all into its gaze.", + "reversed_meaning": "Breaking free from a shadow’s hold, though it still lingers.", + "art_prompt": "a great eye burning red in a stormy sky, casting long, dark shadows over the land below", + "art_path": "decks/lord_of_the_rings-bundled/15_the_eye_of_sauron.png", + "essence": "An eye that sees, controls, and binds.", + "seed": 770092 + }, + { + "arcana_number": 16, + "arcana_name": "The Tower", + "concept": "The Fall of Barad-dûr", + "justification": "A sudden and dramatic collapse of a powerful and corrupt structure.", + "upright_meaning": "The collapse of a corrupt system; truth and freedom breaking through.", + "reversed_meaning": "Destruction that does not bring renewal; lingering ruin.", + "art_prompt": "a great black tower crumbling in a storm, lightning striking the mountain as fire rains from above", + "art_path": "decks/lord_of_the_rings-bundled/16_the_fall_of_barad_dûr.png", + "essence": "The fall of a dark tower and the end of its lies.", + "seed": 770093 + }, + { + "arcana_number": 17, + "arcana_name": "The Star", + "concept": "The Light of Eärendil", + "justification": "A symbol of hope and renewal, visible even in the darkest times.", + "upright_meaning": "Hope rising in the darkest hour, a beacon for the lost.", + "reversed_meaning": "Doubt creeping in, threatening to smother the flame.", + "art_prompt": "a glowing blue gem in the night sky, casting a pale light over a shadowed land", + "art_path": "decks/lord_of_the_rings-bundled/17_the_light_of_eärendil.png", + "essence": "A light in the darkness that never dies.", + "seed": 770094 + }, + { + "arcana_number": 18, + "arcana_name": "The Moon", + "concept": "The Misty Mountains", + "justification": "The mountains are a place of mystery and hidden paths, where fear and illusion thrive.", + "upright_meaning": "A journey through uncertainty, guided by courage and clarity of purpose.", + "reversed_meaning": "False paths, illusions, or the overcoming of fear through truth.", + "art_prompt": "a tall mountain shrouded in fog at night, a pale moon rising over a winding path", + "art_path": "decks/lord_of_the_rings-bundled/18_the_misty_mountains.png", + "essence": "A mountain path where shadows whisper and fear walks ahead.", + "seed": 770095 + }, + { + "arcana_number": 19, + "arcana_name": "The Sun", + "concept": "The Reckoning at the End of the Third Age", + "justification": "The dawn of a new age, marked by victory and renewal.", + "upright_meaning": "A time of joy and clarity after long struggle; victory’s light shining bright.", + "reversed_meaning": "A victory without full light; peace still waiting to bloom.", + "art_prompt": "a sunrise over the sea, a ship with white sails approaching the horizon", + "art_path": "decks/lord_of_the_rings-bundled/19_the_reckoning_at_the_end.png", + "essence": "The end of the Third Age and the dawn of a new era.", + "seed": 770096 + }, + { + "arcana_number": 20, + "arcana_name": "Judgement", + "concept": "The Scouring of the Shire", + "justification": "A reckoning with past neglect and a call to account for the land and its people.", + "upright_meaning": "A soul called to face its past and make amends for what it has done.", + "reversed_meaning": "Denial of responsibility, or a refusal to return home.", + "art_prompt": "a hobbit standing in a ruined field, looking back at smoke and ruin, with the sky breaking into dawn", + "art_path": "decks/lord_of_the_rings-bundled/20_the_scouring_of_the_shir.png", + "essence": "A land and a heart awoken to their reckoning.", + "seed": 770097 + }, + { + "arcana_number": 21, + "arcana_name": "The World", + "concept": "The Return of the King", + "justification": "A complete journey, with all threads drawn together in the homecoming.", + "upright_meaning": "A journey complete; the return to one’s origin with wisdom and peace.", + "reversed_meaning": "A journey unfinished; some pieces still missing, some paths not taken.", + "art_prompt": "a crowned king walking through a city of white towers, the golden sun rising behind him", + "art_path": "decks/lord_of_the_rings-bundled/21_the_return_of_the_king.png", + "essence": "A king who returns to his people, changed and whole.", + "seed": 770098 + } + ] +} \ No newline at end of file diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/00_activation_energy.png b/decks/thermodynamics-rider-waite-smith-1780952458/00_activation_energy.png new file mode 100644 index 0000000000000000000000000000000000000000..6165884997d6ccd747e39a10126d3c27bde39cfd --- /dev/null +++ b/decks/thermodynamics-rider-waite-smith-1780952458/00_activation_energy.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d1e66aba0de61a7bcffbcb1e947837d135c3039de17de258fca144c9bc2633c5 +size 1329589 diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/01_work.png b/decks/thermodynamics-rider-waite-smith-1780952458/01_work.png new file mode 100644 index 0000000000000000000000000000000000000000..2672f659c79a68d3361a1bd118e00c27c53ceb3f --- /dev/null +++ b/decks/thermodynamics-rider-waite-smith-1780952458/01_work.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a50f7af7d763b52cb655098e7e62a83031233922e1de2b5b54ff4a6812efb4a8 +size 876997 diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/02_internal_energy.png b/decks/thermodynamics-rider-waite-smith-1780952458/02_internal_energy.png new file mode 100644 index 0000000000000000000000000000000000000000..22a693a76105dd16127edeb6a7fcd7572f4e4b98 --- /dev/null +++ b/decks/thermodynamics-rider-waite-smith-1780952458/02_internal_energy.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:994683f8240943d2b1ffb49646a03b43c178e6fa0848c8705c2d83b96a703137 +size 1507113 diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/03_heat_reservoir.png b/decks/thermodynamics-rider-waite-smith-1780952458/03_heat_reservoir.png new file mode 100644 index 0000000000000000000000000000000000000000..1386f41bee4794693442f3d01c9002f28dbda5f0 --- /dev/null +++ b/decks/thermodynamics-rider-waite-smith-1780952458/03_heat_reservoir.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f626f546805e1f60aefa12869cd407e1a4ea0818dd4682ab9649783529c808bb +size 930626 diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/04_the_first_law.png b/decks/thermodynamics-rider-waite-smith-1780952458/04_the_first_law.png new file mode 100644 index 0000000000000000000000000000000000000000..44abc0bbe28043ebd464ea0d130625f78dab6191 --- /dev/null +++ b/decks/thermodynamics-rider-waite-smith-1780952458/04_the_first_law.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:92cb5707358617f054dc693f56b26a9bd87ee02a48753d8bf93a2b517cb8baa6 +size 1544856 diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/05_standard_state__stp.png b/decks/thermodynamics-rider-waite-smith-1780952458/05_standard_state__stp.png new file mode 100644 index 0000000000000000000000000000000000000000..9d086a92c7829da209e0275e379717d00b7d67ca --- /dev/null +++ b/decks/thermodynamics-rider-waite-smith-1780952458/05_standard_state__stp.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4a68d6314e566d8f1bfc6fffc6d9488c2d637576e4ccd2e968c9d7f1909b5ced +size 862391 diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/06_thermal_contact.png b/decks/thermodynamics-rider-waite-smith-1780952458/06_thermal_contact.png new file mode 100644 index 0000000000000000000000000000000000000000..5f38aba66b11207dcbe2420dd6a43c212faa3b13 --- /dev/null +++ b/decks/thermodynamics-rider-waite-smith-1780952458/06_thermal_contact.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ad1046b39361f94e489386b046b3faddb41708e07f17b50e679a6fa4e44f2c2e +size 1540757 diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/07_the_heat_engine.png b/decks/thermodynamics-rider-waite-smith-1780952458/07_the_heat_engine.png new file mode 100644 index 0000000000000000000000000000000000000000..79f2714c87ffc7ae3875ecd9133bc476d8d30a90 --- /dev/null +++ b/decks/thermodynamics-rider-waite-smith-1780952458/07_the_heat_engine.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d96489ad1599a48994864ac1a8c0ef072c7abfeaa8c7bba51a661830dcb85654 +size 939791 diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/08_heat_capacity.png b/decks/thermodynamics-rider-waite-smith-1780952458/08_heat_capacity.png new file mode 100644 index 0000000000000000000000000000000000000000..fd883c0853fb0cebc8a3a96ad890e5fd353bccc9 --- /dev/null +++ b/decks/thermodynamics-rider-waite-smith-1780952458/08_heat_capacity.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e86e6797d95e2a4eda4ad873cc2e65008958782263d8d02f72d741822446aeb4 +size 1296975 diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/09_absolute_zero.png b/decks/thermodynamics-rider-waite-smith-1780952458/09_absolute_zero.png new file mode 100644 index 0000000000000000000000000000000000000000..d790f6247728598c645344b4704974f744a18ab1 --- /dev/null +++ b/decks/thermodynamics-rider-waite-smith-1780952458/09_absolute_zero.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:45e037442400d747c7a7a2d550f18fa36e8a31df85f7bca98a91b81c16f2ac0e +size 690757 diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/10_the_carnot_cycle.png b/decks/thermodynamics-rider-waite-smith-1780952458/10_the_carnot_cycle.png new file mode 100644 index 0000000000000000000000000000000000000000..53f5da47aa0f5bbed8a20f0994120dc6bd306c5c --- /dev/null +++ b/decks/thermodynamics-rider-waite-smith-1780952458/10_the_carnot_cycle.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ae1b52ff8f8587f661d8777f66cf165a14e732be3db53c8b30bb1ee2dee396c3 +size 1695250 diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/11_the_second_law_s_toll.png b/decks/thermodynamics-rider-waite-smith-1780952458/11_the_second_law_s_toll.png new file mode 100644 index 0000000000000000000000000000000000000000..f3883953692449361fb76e7761d75c8da09c62bf --- /dev/null +++ b/decks/thermodynamics-rider-waite-smith-1780952458/11_the_second_law_s_toll.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c34700063fd657af8409af36df29eaec9f2d34048237396f99909a85b8a5c5d3 +size 1531138 diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/12_metastability.png b/decks/thermodynamics-rider-waite-smith-1780952458/12_metastability.png new file mode 100644 index 0000000000000000000000000000000000000000..0300537a63055978d8ebd9c69b2d9d2b7eaa0df7 --- /dev/null +++ b/decks/thermodynamics-rider-waite-smith-1780952458/12_metastability.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d557ec44484a1432b275e94b42750f962ff1a70c276ce18cef9e309a5bd2db9d +size 1397223 diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/13_entropy.png b/decks/thermodynamics-rider-waite-smith-1780952458/13_entropy.png new file mode 100644 index 0000000000000000000000000000000000000000..aa0f2ee1fbe45fb79e84b12e3f5677d3ad076e61 --- /dev/null +++ b/decks/thermodynamics-rider-waite-smith-1780952458/13_entropy.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b1f5fc5b893e20416347411c5c4642f3b3b6070fd3a70831fe54192c36481988 +size 1268680 diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/14_thermal_equilibrium.png b/decks/thermodynamics-rider-waite-smith-1780952458/14_thermal_equilibrium.png new file mode 100644 index 0000000000000000000000000000000000000000..597f0902071fa5fe8d899767103c7df5a4b75cb2 --- /dev/null +++ b/decks/thermodynamics-rider-waite-smith-1780952458/14_thermal_equilibrium.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6f95aa25788e89981f5196ab1baa7e46626bfed1a3d2cc8f735766b0c7b1973c +size 1233392 diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/15_bound_energy.png b/decks/thermodynamics-rider-waite-smith-1780952458/15_bound_energy.png new file mode 100644 index 0000000000000000000000000000000000000000..b62b9c5722aef8683688790ab058a00ef5b8f6db --- /dev/null +++ b/decks/thermodynamics-rider-waite-smith-1780952458/15_bound_energy.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:723925a4f560a4fbab4d8ede304117af3a1f90f482362da53b3a6a3a35bd1998 +size 1048547 diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/16_the_critical_point.png b/decks/thermodynamics-rider-waite-smith-1780952458/16_the_critical_point.png new file mode 100644 index 0000000000000000000000000000000000000000..26d3e19f9eff6e4bc026d9f963634c1e6ddc0367 --- /dev/null +++ b/decks/thermodynamics-rider-waite-smith-1780952458/16_the_critical_point.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:282913ca14ebc84155792b6947094296201b980f8e0701b23d8433a1100d4579 +size 729937 diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/17_free_energy.png b/decks/thermodynamics-rider-waite-smith-1780952458/17_free_energy.png new file mode 100644 index 0000000000000000000000000000000000000000..4498daff13d13bcd529075c075a07f6e9d3bbe6c --- /dev/null +++ b/decks/thermodynamics-rider-waite-smith-1780952458/17_free_energy.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9afd8cab25444cf3569eda9c6df947cc7414be7d7ff7f3ed4e22f1010352711c +size 887111 diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/18_thermal_fluctuations.png b/decks/thermodynamics-rider-waite-smith-1780952458/18_thermal_fluctuations.png new file mode 100644 index 0000000000000000000000000000000000000000..d2e838e306053bb14d55274951ab8e3857557938 --- /dev/null +++ b/decks/thermodynamics-rider-waite-smith-1780952458/18_thermal_fluctuations.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6382be44b78c6c36469294ffe3a6a3fb538632c45b17ea4103328dd8e7bf6a0e +size 775432 diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/19_blackbody_radiation.png b/decks/thermodynamics-rider-waite-smith-1780952458/19_blackbody_radiation.png new file mode 100644 index 0000000000000000000000000000000000000000..1970402a9d25e406e426a81d875aecf1b8a8b021 --- /dev/null +++ b/decks/thermodynamics-rider-waite-smith-1780952458/19_blackbody_radiation.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6f04bdb31d04800d33f5fc68e5b4f6750077f95176894b659a8639e0e9234c34 +size 1239648 diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/20_the_third_law.png b/decks/thermodynamics-rider-waite-smith-1780952458/20_the_third_law.png new file mode 100644 index 0000000000000000000000000000000000000000..9cdc93e863455ddadcd1515244e1e871607d2459 --- /dev/null +++ b/decks/thermodynamics-rider-waite-smith-1780952458/20_the_third_law.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6b7814449eea1f0e5370b282ea700a517626b82d7361ea1f71e2d087352a8fd0 +size 738677 diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/21_the_universe.png b/decks/thermodynamics-rider-waite-smith-1780952458/21_the_universe.png new file mode 100644 index 0000000000000000000000000000000000000000..61ed5d7e0cf1e438ea9015b7d3aed140e1d55fd1 --- /dev/null +++ b/decks/thermodynamics-rider-waite-smith-1780952458/21_the_universe.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dfd12982465b262814da79ec13834e01c3189090644ae67dab26263f7da95a3a +size 1637695 diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/back.png b/decks/thermodynamics-rider-waite-smith-1780952458/back.png new file mode 100644 index 0000000000000000000000000000000000000000..653a070896d6a9ce1893e668a30d8e2369e61125 --- /dev/null +++ b/decks/thermodynamics-rider-waite-smith-1780952458/back.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dd91c3b3dbee6f9f4f37c227a01243928be85f9b7e25e4b96bea597604aaaccb +size 1989487 diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/deck.json b/decks/thermodynamics-rider-waite-smith-1780952458/deck.json new file mode 100644 index 0000000000000000000000000000000000000000..d5e176c77311c9aacab83e7657a50a1d7b0d0444 --- /dev/null +++ b/decks/thermodynamics-rider-waite-smith-1780952458/deck.json @@ -0,0 +1,274 @@ +{ + "theme": "thermodynamics", + "style_suffix": "Rider-Waite-Smith tarot art, rich symbolic illustration, warm storybook palette, gold linework, medieval-renaissance figures", + "cards": [ + { + "arcana_number": 0, + "arcana_name": "The Fool", + "concept": "Activation Energy", + "justification": "The threshold of potential energy that must be overcome for a process to begin — mirroring the leap of faith before a new path unfolds.", + "upright_meaning": "A new process is waiting to begin, but it needs an initial push. You must supply the energy to cross the threshold and begin the transformation.", + "reversed_meaning": "You're trapped in hesitation, unable to ignite the necessary energy to start. You fear the unknown or underestimate the power of that first spark.", + "art_prompt": "a central illustration of a ball perched at the edge of a hill, poised between stillness and motion, with an invisible line of potential energy beneath it", + "art_path": "decks/thermodynamics-rider-waite-smith-1780952458/00_activation_energy.png", + "essence": "The leap requires a spark before it can become flame.", + "seed": 679027 + }, + { + "arcana_number": 1, + "arcana_name": "The Magician", + "concept": "Work", + "justification": "The directed transformation of energy into action — a channeling of raw potential into purposeful change.", + "upright_meaning": "You are channeling energy into productive action. You have the tools, the focus, and the will to bring your vision to life.", + "reversed_meaning": "Your energy is wasted or misdirected. You lack the clarity or discipline to make your efforts meaningful.", + "art_prompt": "a central illustration of a person bending a gear, converting heat from a flame into movement through a pulley", + "art_path": "decks/thermodynamics-rider-waite-smith-1780952458/01_work.png", + "essence": "Action is the conduit through which energy becomes purpose.", + "seed": 679028 + }, + { + "arcana_number": 2, + "arcana_name": "The High Priestess", + "concept": "Internal Energy", + "justification": "Energy that cannot be directly observed, only measured by its changes — hidden truths revealed through careful study.", + "upright_meaning": "There is hidden energy at play in your life. Trust your instinct to read the signs and interpret the silent forces that guide you.", + "reversed_meaning": "You are blind to the internal currents shaping you. Misunderstanding the hidden forces leads to confusion and poor choices.", + "art_prompt": "a central illustration of a closed box with internal glowing particles visible only through the cracks, hinting at energy within", + "art_path": "decks/thermodynamics-rider-waite-smith-1780952458/02_internal_energy.png", + "essence": "Energy flows invisibly, shaping the unseen currents of your world.", + "seed": 679029 + }, + { + "arcana_number": 3, + "arcana_name": "The Empress", + "concept": "Heat Reservoir", + "justification": "An abundant and nurturing source of warmth and energy, continuously giving without diminishing.", + "upright_meaning": "You are drawing from a rich and nurturing energy source. This abundance supports your growth and creative output.", + "reversed_meaning": "You depend too heavily on external sources. You neglect your own internal reservoirs and risk imbalance.", + "art_prompt": "a central illustration of a glowing, open-ended cylindrical reservoir, radiating waves of heat outward into the surrounding darkness", + "art_path": "decks/thermodynamics-rider-waite-smith-1780952458/03_heat_reservoir.png", + "essence": "A wellspring of warmth feeds creation and sustenance.", + "seed": 679030 + }, + { + "arcana_number": 4, + "arcana_name": "The Emperor", + "concept": "The First Law", + "justification": "The unyielding rule of energy conservation — a governing truth that must be obeyed.", + "upright_meaning": "You are maintaining equilibrium through discipline and structure. You uphold the balance by respecting the laws that govern your system.", + "reversed_meaning": "The system is breaking down. You ignore the rules that keep things stable, and entropy begins to take hold.", + "art_prompt": "a central illustration of an ancient stone tablet inscribed with the words: 'Energy is Neither Created Nor Destroyed'", + "art_path": "decks/thermodynamics-rider-waite-smith-1780952458/04_the_first_law.png", + "essence": "Energy is conserved in structure and law.", + "seed": 679031 + }, + { + "arcana_number": 5, + "arcana_name": "The Hierophant", + "concept": "Standard State (STP)", + "justification": "The agreed-upon reference point for all measurements — a universal convention and tradition.", + "upright_meaning": "You align with the accepted conditions. You find comfort and stability in the established, the known, and the familiar.", + "reversed_meaning": "You reject the standard. You challenge the norm or struggle to conform, creating friction with the status quo.", + "art_prompt": "a central illustration of a scale balanced at 25°C and 1 atm, with precise instruments measuring everything on either side", + "art_path": "decks/thermodynamics-rider-waite-smith-1780952458/05_standard_state__stp.png", + "essence": "The default state is one of shared pressure and uniformity.", + "seed": 679032 + }, + { + "arcana_number": 6, + "arcana_name": "The Lovers", + "concept": "Thermal Contact", + "justification": "The union of two systems until their temperatures align — a shared journey to equilibrium.", + "upright_meaning": "You and another are in perfect thermal and emotional resonance. Your shared energy creates a stable, harmonious whole.", + "reversed_meaning": "You are out of sync. There is a mismatch in energy or intention, leaving you isolated or unaligned.", + "art_prompt": "a central illustration of two blocks of metal in contact, with energy flowing between them until their temperatures match", + "art_path": "decks/thermodynamics-rider-waite-smith-1780952458/06_thermal_contact.png", + "essence": "Energy merges when systems connect, creating a new thermal unity.", + "seed": 679033 + }, + { + "arcana_number": 7, + "arcana_name": "The Chariot", + "concept": "The Heat Engine", + "justification": "Transforming heat into motion through control and direction — the triumph of directed force.", + "upright_meaning": "You are converting your inner heat into forward momentum. You are focused, purposeful, and efficient in your pursuit.", + "reversed_meaning": "Your energy is wasted or misused. You are moving, but not forward — direction is lost or inefficient.", + "art_prompt": "a central illustration of a steam-powered chariot, its wheels turning from the heat of a fire at the front", + "art_path": "decks/thermodynamics-rider-waite-smith-1780952458/07_the_heat_engine.png", + "essence": "Efficiency turns heat into motion.", + "seed": 679034 + }, + { + "arcana_number": 8, + "arcana_name": "Strength", + "concept": "Heat Capacity", + "justification": "The ability to absorb energy without significant change — inner fortitude and resilience.", + "upright_meaning": "You endure the heat of change without flaring or fracturing. Your capacity to store energy keeps you calm and steady.", + "reversed_meaning": "You are overwhelmed by input. You absorb too much heat and risk breakdown or burnout.", + "art_prompt": "a central illustration of a large, calm lake absorbing the heat of the sun without boiling over", + "art_path": "decks/thermodynamics-rider-waite-smith-1780952458/08_heat_capacity.png", + "essence": "Resilience lies in absorbing change without breaking.", + "seed": 679035 + }, + { + "arcana_number": 9, + "arcana_name": "The Hermit", + "concept": "Absolute Zero", + "justification": "The state of complete stillness and solitude — the ultimate inward journey.", + "upright_meaning": "You have reached a state of perfect stillness and solitude. Here, you confront your purest essence, free from external influence.", + "reversed_meaning": "You are caught in a cycle of noise and distraction. True silence and clarity remain elusive.", + "art_prompt": "a central illustration of a frozen, perfect crystal, motionless in the void, surrounded by a dark, endless space", + "art_path": "decks/thermodynamics-rider-waite-smith-1780952458/09_absolute_zero.png", + "essence": "Zero is the quietest point, where energy has vanished.", + "seed": 679036 + }, + { + "arcana_number": 10, + "arcana_name": "Wheel of Fortune", + "concept": "The Carnot Cycle", + "justification": "An idealized loop of energy conversion that returns to its starting point — the eternal turning of fate.", + "upright_meaning": "You are caught in a familiar cycle of energy transfer. Change is inevitable, bringing you back to where you started — but transformed.", + "reversed_meaning": "The cycle is broken or stalled. You are stuck in a loop without forward motion or resolution.", + "art_prompt": "a central illustration of a reversible Carnot engine, cycling endlessly between hot and cold reservoirs", + "art_path": "decks/thermodynamics-rider-waite-smith-1780952458/10_the_carnot_cycle.png", + "essence": "Cycles repeat, and heat moves in loops to bring you back.", + "seed": 679037 + }, + { + "arcana_number": 11, + "arcana_name": "Justice", + "concept": "The Second Law's Toll", + "justification": "The law that no system is perfect — every process pays an irreversible cost.", + "upright_meaning": "You are facing the cost of progress. You understand that no action is without consequence, and you accept the losses as part of growth.", + "reversed_meaning": "You ignore the cost of progress. You are trying to reverse the flow or deny entropy’s inevitable role.", + "art_prompt": "a central illustration of a scale tipping toward entropy, with energy being lost to disorder and heat", + "art_path": "decks/thermodynamics-rider-waite-smith-1780952458/11_the_second_law_s_toll.png", + "essence": "Every gain has a cost — entropy always takes its toll.", + "seed": 679038 + }, + { + "arcana_number": 12, + "arcana_name": "The Hanged Man", + "concept": "Metastability", + "justification": "A state of false equilibrium — suspended and waiting for change.", + "upright_meaning": "You are in a temporary equilibrium, stable but not permanent. Change is possible, but it requires surrender to move beyond.", + "reversed_meaning": "You fear collapse and cling to the false stability. You are trapped in a metastable state, afraid to let go.", + "art_prompt": "a central illustration of a perfectly balanced, but unstable crystal, poised on the edge of transformation", + "art_path": "decks/thermodynamics-rider-waite-smith-1780952458/12_metastability.png", + "essence": "You are suspended in a false state — waiting for the push.", + "seed": 679039 + }, + { + "arcana_number": 13, + "arcana_name": "Death", + "concept": "Entropy", + "justification": "The one-way journey toward disorder — an irreversible transformation.", + "upright_meaning": "An end is coming — complete and irreversible. Let go of the old structure and allow it to dissolve into the natural order.", + "reversed_meaning": "You fight the inevitable. You are trying to reverse entropy, to restore what is already lost.", + "art_prompt": "a central illustration of a once-ordered structure crumbling into dust and fire, with no path back", + "art_path": "decks/thermodynamics-rider-waite-smith-1780952458/13_entropy.png", + "essence": "Disorder is the natural state — and all things must return to it.", + "seed": 679040 + }, + { + "arcana_number": 14, + "arcana_name": "Temperance", + "concept": "Thermal Equilibrium", + "justification": "The blending of extremes into a balanced whole — harmony through moderation.", + "upright_meaning": "You are merging opposing forces into a balanced whole. Harmony is found through integration, not dominance.", + "reversed_meaning": "You are in conflict or imbalance. You fail to synthesize differences and maintain disharmony.", + "art_prompt": "a central illustration of two streams joining into a single river, one hot and one cold, merging into warm, flowing water", + "art_path": "decks/thermodynamics-rider-waite-smith-1780952458/14_thermal_equilibrium.png", + "essence": "Balance is achieved when energies blend in harmony.", + "seed": 679041 + }, + { + "arcana_number": 15, + "arcana_name": "The Devil", + "concept": "Bound Energy", + "justification": "Energy locked and inaccessible — the chains of constraint and materialism.", + "upright_meaning": "You are trapped by internal constraints. Energy is present, but it cannot be accessed freely — you're bound by your own structure.", + "reversed_meaning": "You are breaking free. The system that held you is unraveling, and energy is now available for transformation.", + "art_prompt": "a central illustration of a glowing sphere locked in a steel cage, its energy pulsing but unable to escape", + "art_path": "decks/thermodynamics-rider-waite-smith-1780952458/15_bound_energy.png", + "essence": "You are bound by your own system — energy is trapped and undelivered.", + "seed": 679042 + }, + { + "arcana_number": 16, + "arcana_name": "The Tower", + "concept": "The Critical Point", + "justification": "The sudden collapse of identity — a phase or structure that is no longer viable.", + "upright_meaning": "A critical point is reached. The old system is unsustainable and must be torn down for something new to emerge.", + "reversed_meaning": "You delay the necessary collapse. The pressure builds until it is forced upon you with greater force.", + "art_prompt": "a central illustration of a building made of ice melting into a puddle, the structure dissolving into formless water", + "art_path": "decks/thermodynamics-rider-waite-smith-1780952458/16_the_critical_point.png", + "essence": "The system can no longer hold — it will collapse or be reset.", + "seed": 679043 + }, + { + "arcana_number": 17, + "arcana_name": "The Star", + "concept": "Free Energy", + "justification": "The usable energy available for work — a beacon of hope and future possibility.", + "upright_meaning": "Hope is alive. You have usable energy left — the path is still open, and the future remains within your grasp.", + "reversed_meaning": "Hope is dimming. You feel drained, with no energy left to act or believe in change.", + "art_prompt": "a central illustration of a glowing star rising from the void, casting light on a dark horizon", + "art_path": "decks/thermodynamics-rider-waite-smith-1780952458/17_free_energy.png", + "essence": "Free energy is the spark that rekindles hope.", + "seed": 679044 + }, + { + "arcana_number": 18, + "arcana_name": "The Moon", + "concept": "Thermal Fluctuations", + "justification": "Tiny, random changes beneath the surface — the unseen disturbances of the unconscious.", + "upright_meaning": "You sense hidden energy shifts. There are subtle changes and fluctuations in the unseen, shaping your world in real time.", + "reversed_meaning": "You are lost in uncertainty. You cannot tell what is real and what is merely fluctuation or illusion.", + "art_prompt": "a central illustration of a calm ocean with tiny, imperceptible waves rising and falling in slow motion", + "art_path": "decks/thermodynamics-rider-waite-smith-1780952458/18_thermal_fluctuations.png", + "essence": "Energy fluctuates beneath the surface — reality is not what it seems.", + "seed": 679045 + }, + { + "arcana_number": 19, + "arcana_name": "The Sun", + "concept": "Blackbody Radiation", + "justification": "The radiant energy of a hot body — vitality and clarity in their purest form.", + "upright_meaning": "You are radiating with clarity and purpose. Your energy is fully expressed, unfiltered and unbroken, bringing light to your path.", + "reversed_meaning": "Your radiance is dimmed. You feel shadowed or unable to express your true energy and purpose.", + "art_prompt": "a central illustration of a glowing, hot sphere radiating light and warmth outward into space", + "art_path": "decks/thermodynamics-rider-waite-smith-1780952458/19_blackbody_radiation.png", + "essence": "You radiate with clarity — your energy is fully expressed.", + "seed": 679046 + }, + { + "arcana_number": 20, + "arcana_name": "Judgement", + "concept": "The Third Law", + "justification": "A final reckoning at absolute zero — the purification of entropy and the calling of all things to order.", + "upright_meaning": "A reckoning and awakening occur. From this moment, a new order is born from the dissolution of the old.", + "reversed_meaning": "You remain in disorder. You are unable to reach a final resolution or perfect state — the cycle continues.", + "art_prompt": "a central illustration of a perfect crystal forming in a void, its shape clean and final, as if summoned by a voice from above", + "art_path": "decks/thermodynamics-rider-waite-smith-1780952458/20_the_third_law.png", + "essence": "Order is restored in time — but never perfectly.", + "seed": 679047 + }, + { + "arcana_number": 21, + "arcana_name": "The World", + "concept": "The Universe", + "justification": "The total system — everything included in one grand whole and final reckoning.", + "upright_meaning": "You see the whole system — you are integrated, complete, and part of a greater whole. Your journey is fulfilled.", + "reversed_meaning": "You are incomplete. You fail to see the full picture or remain separated from the whole.", + "art_prompt": "a central illustration of a vast diagram of the universe, with each system connected in perfect, endless cycles of energy exchange", + "art_path": "decks/thermodynamics-rider-waite-smith-1780952458/21_the_universe.png", + "essence": "The whole is greater than the sum of its parts — or is it?", + "seed": 679048 + } + ], + "deck_id": "thermodynamics-rider-waite-smith-1780952458", + "visual_style": "rider-waite-smith", + "seed_base": 679027, + "back_path": "decks/thermodynamics-rider-waite-smith-1780952458/back.png" +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..e77e5c3a08ad7448e82a6b3a044e94451e1dd0f7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +gradio==6.14.0 +openai>=1.40,<3 +fastapi>=0.110 +uvicorn>=0.30 +pillow>=10 +requests>=2.31 diff --git a/scripts/phase0_mapping.py b/scripts/phase0_mapping.py new file mode 100644 index 0000000000000000000000000000000000000000..4a6dda24243cac43499386f58bc9602f527af380 --- /dev/null +++ b/scripts/phase0_mapping.py @@ -0,0 +1,77 @@ +"""Phase 0 (SPEC §12) — prove mapping quality before any art or UI. + +Runs Agent 1 (the Deck Designer) against the open ≤32B model for several varied +themes and prints each deck as a readable archetype→concept table plus the full +JSON. Inspect by hand: do the mappings read as clever-but-right across all +themes? If not, fix the prompt/few-shots before building anything else. + +Usage: + set -a; source ~/tokens; set +a + python -m scripts.phase0_mapping # default 5 themes + python -m scripts.phase0_mapping "free jazz" "tax law" +""" +from __future__ import annotations + +import json +import os +import sys +import time + +from arcana.archetypes import MAJOR_ARCANA +from arcana.designer import DeckError, design_deck +from arcana.llm import get_llm + +DEFAULT_THEMES = [ + "thermodynamics", # serious / scientific + "Lord of the Rings", # fandom + "the French Revolution", # history + "breakfast foods", # silly + "professional wrestling", # pop-culture wildcard +] + +OUT_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "phase0_out") + + +def print_table(deck: dict) -> None: + print(f"\n style: {deck['style_suffix']}") + for a in MAJOR_ARCANA: + c = deck["cards"][a.number] + print(f" {a.roman:<5} {a.name:<18} → {c['concept']}") + print(f" ↳ {c['justification']}") + + +def main(themes: list[str]) -> int: + os.makedirs(OUT_DIR, exist_ok=True) + llm = get_llm() + print(f"Model: {getattr(llm, 'model', '?')} via {getattr(llm, 'base_url', '?')}") + + failures = 0 + for theme in themes: + print("\n" + "=" * 72) + print(f"THEME: {theme}") + print("=" * 72) + t0 = time.time() + try: + deck = design_deck(theme, llm=llm) + except DeckError as e: + failures += 1 + print(f" ✗ FAILED: {e} ({time.time() - t0:.1f}s)") + continue + dt = time.time() - t0 + print_table(deck) + slug = "".join(ch if ch.isalnum() else "_" for ch in theme.lower())[:40] + path = os.path.join(OUT_DIR, f"{slug}.json") + with open(path, "w") as f: + json.dump(deck, f, indent=2, ensure_ascii=False) + print(f"\n ✓ 22 cards in {dt:.1f}s → {path}") + + print("\n" + "=" * 72) + print(f"DONE — {len(themes) - failures}/{len(themes)} decks generated.") + print("Inspect the tables above: do the mappings feel clever-but-right?") + return 1 if failures else 0 + + +if __name__ == "__main__": + args = sys.argv[1:] or DEFAULT_THEMES + raise SystemExit(main(args)) diff --git a/scripts/phase1_deck.py b/scripts/phase1_deck.py new file mode 100644 index 0000000000000000000000000000000000000000..33e70d5bff2b5ee6b440053e6e0af8737e1093b4 --- /dev/null +++ b/scripts/phase1_deck.py @@ -0,0 +1,88 @@ +"""Phase 1 (SPEC §12) — local end-to-end deck: art + frame + composite. + +Loads a deck JSON (from Phase 0), generates the 22 central arts via the open +≤32B image endpoint, composites each with the reusable frame + correct +name/numeral, writes 22 finished card PNGs, and a contact sheet to eyeball. + +Usage: + set -a; source ~/tokens; set +a + python -m scripts.phase1_deck phase0_out/lord_of_the_rings.json + python -m scripts.phase1_deck phase0_out/lord_of_the_rings.json --limit 6 +""" +from __future__ import annotations + +import argparse +import json +import os +import time + +from PIL import Image + +from arcana.archetypes import ROMAN_BY_NUMBER +from arcana.compositor import CARD_H, CARD_W, compose_card +from arcana.imagegen import get_imagegen + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +BASE_SEED = 770077 # fixed seed family → deck cohesion (§7) + + +def contact_sheet(cards: list[Image.Image], cols: int = 6) -> Image.Image: + if not cards: + return Image.new("RGB", (CARD_W, CARD_H), (10, 8, 16)) + tw, th = CARD_W // 3, CARD_H // 3 + rows = (len(cards) + cols - 1) // cols + sheet = Image.new("RGB", (cols * (tw + 10) + 10, rows * (th + 10) + 10), (10, 8, 16)) + for i, c in enumerate(cards): + thumb = c.resize((tw, th), Image.LANCZOS) + x = 10 + (i % cols) * (tw + 10) + y = 10 + (i // cols) * (th + 10) + sheet.paste(thumb, (x, y)) + return sheet + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("deck_json") + ap.add_argument("--limit", type=int, default=0, help="only first N cards (quick test)") + args = ap.parse_args() + + deck = json.load(open(args.deck_json)) + theme = deck["theme"] + style = deck.get("style_suffix") + cards = deck["cards"] + if args.limit: + cards = cards[:args.limit] + + slug = "".join(ch if ch.isalnum() else "_" for ch in theme.lower())[:40] + out_dir = os.path.join(ROOT, "cards", slug) + os.makedirs(out_dir, exist_ok=True) + print(f"THEME: {theme}\n style: {style}\n out: {out_dir}") + + ig = get_imagegen(deck_style=style) + composed = [] + for c in cards: + n = c["arcana_number"] + t0 = time.time() + try: + art = ig.generate(c["art_prompt"], seed=BASE_SEED + n) + except Exception as e: + print(f" ✗ {n:>2} {c['concept']}: art failed {type(e).__name__}: {str(e)[:90]}") + continue + card = compose_card(art, c["concept"], ROMAN_BY_NUMBER[n]) + path = os.path.join(out_dir, f"{n:02d}_{''.join(ch if ch.isalnum() else '_' for ch in c['concept'].lower())[:24]}.png") + card.save(path) + c["art_path"] = os.path.relpath(path, ROOT) + composed.append(card) + print(f" ✓ {n:>2} {c['arcana_name']:<18} → {c['concept']:<28} {time.time()-t0:.1f}s") + + sheet = contact_sheet(composed) + sheet_path = os.path.join(ROOT, "cards", f"{slug}_contact.png") + sheet.save(sheet_path) + with open(os.path.join(out_dir, "deck.json"), "w") as f: + json.dump(deck, f, indent=2, ensure_ascii=False) + print(f"\n contact sheet → {sheet_path} ({len(composed)} cards)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())