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
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
"""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 [],
|
||||
)
|
||||
Reference in New Issue
Block a user