pareidolia: souls now mutter aloud + share pages with OG cards + parse soft-accept (marks stay dormant)
5643daf verified | """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() | |