"""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)) # type: ignore[return-value] 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): # Labels always gold so they stay legible over any location backdrop. 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") # --- backdrop ------------------------------------------------- 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)) # darkening + readability scrims 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)) # right-side text panel scrim — ramps to a near-solid plate so the # name and quote stay crisp over even the busiest backdrop. 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") # accent vignette at top 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) # --- soul cutout --------------------------------------------- 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) # contact shadow 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") # soft glow behind sprite 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) # --- text column --------------------------------------------- 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 # divider draw.line([(tx, y), (tx + 90, y)], fill=accent, width=2) y += 22 # greeting 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 # wants 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) # --- footer -------------------------------------------------- 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) # outer border 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) # --- save ---------------------------------------------------- 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: # never break the summon flow over a card 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" )