| """Generate a shareable "Soul Card" PNG for a summoned entity. |
| |
| People share images, not HTML. When a visitor summons a soul, we compose a |
| 1200×675 postcard — the location backdrop, the soul's cutout, its name, |
| greeting, and wants — that is downloadable and screenshot-friendly. This is the |
| viral artifact: "look what I brought into Aether Garden." |
| """ |
|
|
| from __future__ import annotations |
|
|
| import hashlib |
| import os |
| import tempfile |
| from pathlib import Path |
|
|
| from PIL import Image, ImageDraw, ImageEnhance, ImageFilter, ImageFont |
|
|
| from ui import assets |
| from world.locations import get_location_by_id |
|
|
| _FONT_DIR = assets.ASSET_DIR / "fonts" |
| _OUT_DIR = Path(tempfile.gettempdir()) / "aether_soul_cards" |
| _OUT_DIR.mkdir(parents=True, exist_ok=True) |
|
|
| W, H = 1200, 675 |
| GOLD = (201, 161, 59) |
| CREAM = (244, 234, 208) |
| MUTED = (112, 88, 56) |
|
|
| TYPE_LABELS = { |
| "character": "Character", |
| "creature": "Creature", |
| "object": "Object", |
| "place": "Place", |
| } |
|
|
|
|
| def _font(name: str, size: int) -> ImageFont.FreeTypeFont: |
| path = _FONT_DIR / name |
| try: |
| return ImageFont.truetype(str(path), size) |
| except Exception: |
| return ImageFont.load_default() |
|
|
|
|
| def _hex_to_rgb(value: str | None, fallback=(200, 160, 64)) -> tuple[int, int, int]: |
| if not value: |
| return fallback |
| value = value.strip().lstrip("#") |
| if len(value) == 3: |
| value = "".join(c * 2 for c in value) |
| try: |
| return tuple(int(value[i : i + 2], 16) for i in (0, 2, 4)) |
| except Exception: |
| return fallback |
|
|
|
|
| def _cover(img: Image.Image, w: int, h: int) -> Image.Image: |
| src_ratio = img.width / img.height |
| dst_ratio = w / h |
| if src_ratio > dst_ratio: |
| new_h = h |
| new_w = int(h * src_ratio) |
| else: |
| new_w = w |
| new_h = int(w / src_ratio) |
| img = img.resize((new_w, new_h), Image.LANCZOS) |
| left = (new_w - w) // 2 |
| top = (new_h - h) // 2 |
| return img.crop((left, top, left + w, top + h)) |
|
|
|
|
| def _wrap(draw, text, font, max_w): |
| words = (text or "").split() |
| lines, cur = [], "" |
| for word in words: |
| trial = (cur + " " + word).strip() |
| if draw.textlength(trial, font=font) <= max_w: |
| cur = trial |
| else: |
| if cur: |
| lines.append(cur) |
| cur = word |
| if cur: |
| lines.append(cur) |
| return lines |
|
|
|
|
| def _draw_block(draw, x, y, label, text, label_font, body_font, max_w, accent, |
| gap=6, line_h=30, max_lines=2): |
| |
| draw.text((x, y), label.upper(), font=label_font, fill=GOLD) |
| y += 24 |
| lines = _wrap(draw, text, body_font, max_w)[:max_lines] |
| for ln in lines: |
| draw.text((x, y), ln, font=body_font, fill=CREAM) |
| y += line_h |
| return y + gap |
|
|
|
|
| def build_soul_card(entity: dict) -> str | None: |
| """Compose and save a Soul Card PNG. Returns the file path, or None.""" |
| try: |
| location = get_location_by_id(entity["location_id"]) or {} |
| slug = location.get("slug") |
| accent = _hex_to_rgb(location.get("glow_color")) |
|
|
| canvas = Image.new("RGB", (W, H), (243, 230, 200)) |
| paper = Image.new("RGBA", (W, H), (0, 0, 0, 0)) |
| pd = ImageDraw.Draw(paper) |
| for i in range(0, W, 4): |
| alpha = 8 + (i % 17) |
| pd.line([(i, 0), (i, H)], fill=(80, 54, 28, alpha)) |
| for i in range(0, H, 5): |
| alpha = 6 + (i % 11) |
| pd.line([(0, i), (W, i)], fill=(122, 88, 51, alpha)) |
| canvas = Image.alpha_composite(canvas.convert("RGBA"), paper).convert("RGB") |
|
|
| |
| loc_file = assets.LOCATION_IMAGES.get(slug) if slug else None |
| bg_path = assets.ASSET_DIR / loc_file if loc_file else None |
| if bg_path and bg_path.exists(): |
| bg = Image.open(bg_path).convert("RGB") |
| bg = _cover(bg, W, H) |
| bg = ImageEnhance.Brightness(bg).enhance(0.5) |
| bg = ImageEnhance.Color(bg).enhance(0.62) |
| canvas.paste(bg, (0, 0)) |
|
|
| |
| overlay = Image.new("RGBA", (W, H), (0, 0, 0, 0)) |
| od = ImageDraw.Draw(overlay) |
| for i in range(H): |
| a = int(150 * (i / H) ** 1.3) |
| od.line([(0, i), (W, i)], fill=(6, 6, 12, a)) |
| |
| |
| panel_start = 500 |
| for i in range(W): |
| if i < panel_start: |
| continue |
| frac = (i - panel_start) / (W - panel_start) |
| a = int(24 + 175 * frac) |
| od.line([(i, 0), (i, H)], fill=(35, 24, 12, min(a, 208))) |
| canvas = Image.alpha_composite(canvas.convert("RGBA"), overlay).convert("RGB") |
|
|
| |
| glow = Image.new("RGBA", (W, H), (0, 0, 0, 0)) |
| gd = ImageDraw.Draw(glow) |
| gd.ellipse([-200, -360, 700, 320], fill=(*accent, 38)) |
| glow = glow.filter(ImageFilter.GaussianBlur(120)) |
| canvas = Image.alpha_composite(canvas.convert("RGBA"), glow).convert("RGB") |
|
|
| draw = ImageDraw.Draw(canvas) |
|
|
| |
| sprite_file = assets.sprite_filename(entity) |
| sp_path = assets.ASSET_DIR / sprite_file |
| if sp_path.exists(): |
| sprite = Image.open(sp_path).convert("RGBA") |
| target_h = 455 |
| ratio = target_h / sprite.height |
| sprite = sprite.resize((int(sprite.width * ratio), target_h), Image.LANCZOS) |
| |
| shadow = Image.new("RGBA", (W, H), (0, 0, 0, 0)) |
| sd = ImageDraw.Draw(shadow) |
| cx = 70 + sprite.width // 2 |
| sd.ellipse([cx - 150, 560, cx + 150, 620], fill=(0, 0, 0, 150)) |
| shadow = shadow.filter(ImageFilter.GaussianBlur(22)) |
| canvas = Image.alpha_composite(canvas.convert("RGBA"), shadow).convert("RGB") |
| |
| frame = Image.new("RGBA", (W, H), (0, 0, 0, 0)) |
| fd = ImageDraw.Draw(frame) |
| oval = [38, 84, 38 + sprite.width + 84, 84 + target_h + 36] |
| fd.ellipse(oval, fill=(35, 24, 12, 168), outline=(201, 161, 59, 230), width=6) |
| inner = [oval[0] + 10, oval[1] + 10, oval[2] - 10, oval[3] - 10] |
| fd.ellipse(inner, outline=(232, 200, 115, 160), width=2) |
| frame = frame.filter(ImageFilter.GaussianBlur(0.3)) |
| canvas = Image.alpha_composite(canvas.convert("RGBA"), frame).convert("RGB") |
| canvas.paste(sprite, (70, 120), sprite) |
| draw = ImageDraw.Draw(canvas) |
|
|
| |
| tx = 590 |
| max_w = W - tx - 60 |
| y = 78 |
|
|
| f_kicker = _font("Cinzel.ttf", 19) |
| f_name = _font("Cinzel.ttf", 56) |
| f_name_sm = _font("Cinzel.ttf", 42) |
| f_meta = _font("Cinzel.ttf", 18) |
| f_label = _font("Cinzel.ttf", 15) |
| f_body = _font("EBGaramond.ttf", 25) |
| f_quote = _font("EBGaramond-Italic.ttf", 27) |
| f_foot = _font("Cinzel.ttf", 17) |
|
|
| draw.text((tx, y), "A PAGE FROM THE BOOK OF AGES", font=f_kicker, fill=GOLD) |
| y += 40 |
|
|
| name = entity.get("name", "A New Soul") |
| name_font = f_name if draw.textlength(name, font=f_name) <= max_w else f_name_sm |
| name_lines = _wrap(draw, name, name_font, max_w)[:2] |
| for ln in name_lines: |
| draw.text((tx, y), ln, font=name_font, fill=GOLD) |
| y += name_font.size + 8 |
| y += 6 |
|
|
| loc_name = (location.get("name", "") or "").replace("The ", "") |
| type_label = TYPE_LABELS.get(entity.get("type", ""), str(entity.get("type", "")).title()) |
| meta = f"{type_label} · {loc_name}" if loc_name else type_label |
| draw.text((tx, y), meta.upper(), font=f_meta, fill=MUTED) |
| y += 40 |
|
|
| |
| draw.line([(tx, y), (tx + 90, y)], fill=accent, width=2) |
| y += 22 |
|
|
| |
| greeting = entity.get("greeting", "") |
| if greeting: |
| for ln in _wrap(draw, f'"{greeting}"', f_quote, max_w)[:3]: |
| draw.text((tx, y), ln, font=f_quote, fill=CREAM) |
| y += 34 |
| y += 14 |
|
|
| |
| y = _draw_block(draw, tx, y, "Seeks", entity.get("primary_goal", ""), |
| f_label, f_body, max_w, accent, line_h=28, max_lines=2) |
| y = _draw_block(draw, tx, y, "Fears", entity.get("primary_fear", ""), |
| f_label, f_body, max_w, accent, line_h=28, max_lines=2) |
|
|
| |
| draw.line([(tx, H - 64), (W - 60, H - 64)], fill=(90, 80, 60), width=1) |
| draw.text((tx, H - 50), "AETHER GARDEN - BOOK OF AGES, VOL. I", font=f_foot, fill=GOLD) |
| tagline = "Torn from a living chronicle" |
| f_tag = _font("EBGaramond-Italic.ttf", 20) |
| tw = draw.textlength(tagline, font=f_tag) |
| draw.text((W - 60 - tw, H - 49), tagline, font=f_tag, fill=MUTED) |
|
|
| |
| draw.rectangle([6, 6, W - 7, H - 7], outline=(*accent,), width=2) |
| for y in range(0, H, 26): |
| jitter = 4 if (y // 26) % 2 == 0 else -3 |
| draw.line([(0, y), (10 + jitter, y + 1)], fill=(194, 168, 119), width=2) |
| draw.line([(W - (10 + jitter), y), (W, y + 1)], fill=(194, 168, 119), width=2) |
|
|
| |
| key = hashlib.md5( |
| f"{entity.get('id','')}-{entity.get('name','')}".encode() |
| ).hexdigest()[:12] |
| out = _OUT_DIR / f"soul_{key}.png" |
| canvas.save(out, "PNG") |
| return str(out) |
| except Exception as e: |
| print(f"Soul card generation failed: {e}") |
| return None |
|
|
|
|
| def share_caption(entity: dict) -> str: |
| import os |
|
|
| location = get_location_by_id(entity["location_id"]) or {} |
| loc_name = (location.get("name", "") or "").replace("The ", "") |
| name = entity.get("name", "a new soul") |
| where = f" in the {loc_name}" if loc_name else "" |
| space_url = os.environ.get("HF_SPACE_URL") or os.environ.get("SPACE_URL", "") |
| link = f"\n\n{space_url}" if space_url else "" |
| return ( |
| f"I summoned {name}{where} — and it now lives its own life in Aether Garden, " |
| f"a persistent AI world that remembers everything. Nobody scripted it; the Oracle " |
| f"dreamed them into being.{link}\n\n" |
| f"#AetherGarden #ThousandTokenWood #Gradio #HuggingFace" |
| ) |
|
|