aether-garden / ui /share.py
kavyabhand's picture
Deploy Aether Garden application
f5dda5f verified
Raw
History Blame Contribute Delete
10.7 kB
"""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"
)