pareidolia / server /og.py
AndresCarreon's picture
pareidolia: souls now mutter aloud + share pages with OG cards + parse soft-accept (marks stay dormant)
5643daf verified
Raw
History Blame Contribute Delete
5.12 kB
"""The share card: one 1200x630 OG composite per published awakening.
``compose_og(image_jpeg, record)`` renders the link-unfurl image for the
/o/{id} share page — the photo on the left, the soul on the right: name in
Cormorant Garamond, the grudge in italic, the wordmark underneath. Porcelain
séance palette (web/style.css is the source of truth for the colors).
Pure CPU + Pillow, deterministic, generated ONCE at publish time and persisted
as ``{id}_og.jpg`` next to the record's other media. Callers treat it as
best-effort: any exception here is caught at the publish site and the record
ships without a composite (the share page then falls back to the plain photo).
Fonts: ``server/assets/cormorant-garamond[-italic].ttf`` (OFL, shipped with
the Space). If they are somehow absent, Pillow's default font keeps the
composite alive — ugly beats absent for a link preview.
"""
from __future__ import annotations
import io
from pathlib import Path
from typing import Any, Mapping
OG_WIDTH = 1200
OG_HEIGHT = 630
PHOTO_WIDTH = 560 # left pane; the soul speaks on the remaining 640px
# web/style.css :root, transcribed (keep in sync by eye, not by import).
PORCELAIN = (11, 10, 9)
CARD_BG = (17, 16, 13)
AMBER = (232, 168, 73)
BONE = (239, 230, 212)
BONE_DIM = (148, 142, 130)
ASSETS_DIR = Path(__file__).resolve().parent / "assets"
FONT_REGULAR = ASSETS_DIR / "cormorant-garamond.ttf"
FONT_ITALIC = ASSETS_DIR / "cormorant-garamond-italic.ttf"
def _font(path: Path, size: int):
from PIL import ImageFont
try:
return ImageFont.truetype(str(path), size=size)
except Exception: # noqa: BLE001 — a missing font must not kill the card
try:
return ImageFont.load_default(size=size)
except TypeError: # pragma: no cover — ancient Pillow without size=
return ImageFont.load_default()
def _wrap(draw: Any, text: str, font: Any, max_width: int, max_lines: int) -> list[str]:
"""Greedy word wrap by measured pixel width; ellipsize when out of lines."""
lines: list[str] = []
current = ""
for word in text.split():
candidate = f"{current} {word}".strip()
if not current or draw.textlength(candidate, font=font) <= max_width:
current = candidate
continue
if len(lines) == max_lines - 1: # no room for another line
lines.append(current.rstrip(".,;: ") + "…")
return lines
lines.append(current)
current = word
if current:
lines.append(current)
return lines
def _display_name(record: Mapping[str, Any]) -> str:
"""Mirror web/main.js displayName: 'the rusted fire hydrant'."""
parts = [record.get("condition"), record.get("object")]
name = " ".join(str(p) for p in parts if p)
return f"the {name}".lower() if name else "an awakened thing"
def compose_og(image_jpeg: bytes, record: Mapping[str, Any]) -> bytes:
"""Photo left, soul right -> JPEG bytes (1200x630). May raise; callers
catch and publish without the composite (best-effort by contract)."""
from PIL import Image, ImageDraw
photo = Image.open(io.BytesIO(image_jpeg)).convert("RGB")
canvas = Image.new("RGB", (OG_WIDTH, OG_HEIGHT), PORCELAIN)
# Left pane: cover-crop the photo into PHOTO_WIDTH x OG_HEIGHT.
scale = max(PHOTO_WIDTH / photo.width, OG_HEIGHT / photo.height)
resized = photo.resize(
(max(1, round(photo.width * scale)), max(1, round(photo.height * scale)))
)
left = (resized.width - PHOTO_WIDTH) // 2
top = (resized.height - OG_HEIGHT) // 2
canvas.paste(resized.crop((left, top, left + PHOTO_WIDTH, top + OG_HEIGHT)), (0, 0))
draw = ImageDraw.Draw(canvas)
# Hairline seam between photo and panel (the gallery wall edge).
draw.rectangle(
(PHOTO_WIDTH, 0, PHOTO_WIDTH + 1, OG_HEIGHT), fill=(60, 45, 22)
)
panel_x = PHOTO_WIDTH + 56
panel_w = OG_WIDTH - panel_x - 56
# the label, lowercase + letterspaced by hand (PIL has no tracking)
label_font = _font(FONT_REGULAR, 26)
draw.text(
(panel_x, 64), " ".join("pareidolia"), font=label_font, fill=AMBER
)
# the name
name_font = _font(FONT_REGULAR, 58)
name_lines = _wrap(draw, _display_name(record), name_font, panel_w, 2)
y = 124
for line in name_lines:
draw.text((panel_x, y), line, font=name_font, fill=BONE)
y += 62
# the grudge, in its own voice
lines = record.get("lines") or {}
grudge = str(lines.get("grudge") or "").strip()
if grudge:
grudge_font = _font(FONT_ITALIC, 40)
quote_lines = _wrap(draw, f"“{grudge}”", grudge_font, panel_w, 5)
y += 26
for line in quote_lines:
draw.text((panel_x, y), line, font=grudge_font, fill=BONE)
y += 48
# the claim, bottom-anchored
tag_font = _font(FONT_ITALIC, 28)
draw.text(
(panel_x, OG_HEIGHT - 78),
"everything secretly has a face.",
font=tag_font,
fill=BONE_DIM,
)
out = io.BytesIO()
canvas.save(out, format="JPEG", quality=88)
return out.getvalue()