Files
Davide Scaini 693f720cbd feat: OG link previews — track image + meta tags for Telegram/WhatsApp
- bincio/render/ogimage.py: generate 400x400 elevation-coloured PNG with Pillow
- bincio/serve/routers/ogimage.py: /activity/{id}/ OG HTML stub for bot UAs;
  /og-image/{user}/{id}.png serves pre-generated images with on-demand fallback
- scripts/generate_og_images.py: batch pre-generation, incremental (mtime skip)
- scripts/strava_elevation_audit.py: add source/threshold/MA columns and pct stats
- pyproject.toml: add Pillow>=10 to serve extras
2026-05-23 21:44:19 +02:00

122 lines
4.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""OG image generation — 400×400 track-on-dark PNG for social link previews."""
from __future__ import annotations
import io
import math
from pathlib import Path
from typing import Optional
# Colour stops matching ActivityMap.svelte _linearColor stops
_STOPS: list[tuple[float, tuple[int, int, int]]] = [
(0.00, (59, 130, 246)), # blue-500 (low)
(0.33, (74, 222, 128)), # green-400
(0.66, (250, 204, 21)), # yellow-400
(1.00, (239, 68, 68)), # red-500 (high)
]
_BG = (9, 9, 11) # zinc-950
_SIZE = 400
_PAD = 28
_WIDTH = 5 # logical line width; rendered at 2× then downscaled
def _lerp_color(t: float) -> tuple[int, int, int]:
t = max(0.0, min(1.0, t))
for i in range(len(_STOPS) - 1):
t0, c0 = _STOPS[i]
t1, c1 = _STOPS[i + 1]
if t <= t1:
f = (t - t0) / (t1 - t0) if t1 > t0 else 0.0
return (
round(c0[0] + f * (c1[0] - c0[0])),
round(c0[1] + f * (c1[1] - c0[1])),
round(c0[2] + f * (c1[2] - c0[2])),
)
return _STOPS[-1][1]
def generate(
lat_arr: list[Optional[float]],
lon_arr: list[Optional[float]],
ele_arr: list[Optional[float]],
) -> bytes:
"""Return PNG bytes for a 400×400 elevation-coloured track image.
Any of the three arrays may have None gaps (no-GPS seconds).
Returns a plain dark square if there are fewer than 2 valid GPS points.
"""
try:
from PIL import Image, ImageDraw # type: ignore[import]
except ImportError as e:
raise RuntimeError("Pillow is required for OG image generation") from e
# Collect valid GPS points paired with elevation (None → 0 for colouring)
pts: list[tuple[float, float, float]] = []
for lat, lon, ele in zip(lat_arr, lon_arr, ele_arr):
if lat is not None and lon is not None:
pts.append((float(lat), float(lon), float(ele) if ele is not None else 0.0))
if len(pts) < 2:
img = Image.new("RGB", (_SIZE, _SIZE), _BG)
buf = io.BytesIO()
img.save(buf, "PNG", optimize=True)
return buf.getvalue()
lats = [p[0] for p in pts]
lons = [p[1] for p in pts]
eles = [p[2] for p in pts]
min_lat, max_lat = min(lats), max(lats)
min_lon, max_lon = min(lons), max(lons)
min_ele, max_ele = min(eles), max(eles)
ele_range = max_ele - min_ele or 1.0
# Mercator correction: compress longitude range by cos(mid_lat) so the
# track doesn't look stretched horizontally at higher latitudes.
cos_lat = math.cos(math.radians((min_lat + max_lat) / 2))
usable = _SIZE - 2 * _PAD
lat_span = max_lat - min_lat or 1e-6
lon_span = (max_lon - min_lon) * cos_lat or 1e-6
scale = min(usable / lat_span, usable / lon_span)
# Centre the track within the canvas
x_off = _PAD + (usable - (max_lon - min_lon) * cos_lat * scale) / 2
y_off = _PAD + (usable - lat_span * scale) / 2
def project(lat: float, lon: float) -> tuple[float, float]:
x = x_off + (lon - min_lon) * cos_lat * scale
y = _SIZE - (y_off + (lat - min_lat) * scale)
return x, y
# Render at 2× resolution then downscale for cheap anti-aliasing
S = _SIZE * 2
lw = _WIDTH * 2
img = Image.new("RGB", (S, S), _BG)
draw = ImageDraw.Draw(img)
for i in range(len(pts) - 1):
x0, y0 = project(pts[i][0], pts[i][1])
x1, y1 = project(pts[i+1][0], pts[i+1][1])
t = (eles[i] - min_ele) / ele_range
color = _lerp_color(t)
draw.line([(x0 * 2, y0 * 2), (x1 * 2, y1 * 2)], fill=color, width=lw)
img = img.resize((_SIZE, _SIZE), Image.LANCZOS)
buf = io.BytesIO()
img.save(buf, "PNG", optimize=True)
return buf.getvalue()
def generate_for_activity(ts_path: Path) -> bytes:
"""Convenience wrapper: read a .timeseries.json file and call generate()."""
import json
ts = json.loads(ts_path.read_text(encoding="utf-8"))
return generate(
ts.get("lat") or [],
ts.get("lon") or [],
ts.get("elevation_m") or [],
)