# musehub#129 — Open Graph repo cards (Phase 1 design)
**Status:** Phase 1 amended — Phase 2/3 implementation in progress
**Branch:** `feat/opengraph-repo-cards`
**Issue:** https://staging.musehub.ai/gabriel/musehub/issues/129
---
## Problem (verified in code)
| Layer | State |
| --- | --- |
| `_og_tags()` (`_ui_helpers.py:38–58`) | Supports `image`; emits `og:image` / `twitter:image` when set |
| `ui_repo.py:216` | Never passes `image` |
| `base.html` | **Does not render `og_meta` at all** — handlers pass the dict but templates never emit `` tags. Phase 3 adds `{% block og_meta %}` in `
`. |
| Image generation | None exists today |
Facebook/X today scrape only `` (`{owner}/{slug} — MuseHub — MuseHub`), producing bare-text previews.
---
## Goal
`GET /{owner}/{repo_slug}/opengraph-image` returns a **1200×630 PNG** suitable for `og:image` / `twitter:image`. Repo home pages reference it with an **absolute HTTPS URL** plus a **cache-bust query param**. Verified on at least one platform debugger (Facebook Sharing Debugger or X Card Validator).
**Out of scope (first pass):** issues, proposals, mists, releases; `_og_tags()` contract changes; `jsonld_repo()` changes.
---
## Decision 1 — Rendering approach
### Chosen: **HTML template → headless Chromium screenshot (Playwright)**
| Option | Verdict |
| --- | --- |
| **Playwright HTML→PNG** | **Selected.** Layout is a Jinja2 template with inline CSS (no `app.css` dependency). Matches GitHub / Vercel `@vercel/og` iteration model. |
| Pillow / cairo compositing | Rejected for v1. High layout iteration cost. |
| cairosvg-only (SVG output) | Used **only** to rasterize owner sigil SVG → PNG for embedding in the HTML card. |
### Runtime lifecycle (amended)
- One shared `Browser` per process (lazy init on first render; started via `sync_playwright` in a thread).
- Per-render isolated `page` with viewport 1200×630.
- `asyncio.Lock` per `content_hash` (singleflight) — concurrent cache misses do not spawn duplicate renders.
- Graceful browser shutdown in FastAPI `lifespan` on app exit.
- `StubOpenGraphRenderer` in `MUSE_ENV=test` — CI never launches Chromium.
### Font strategy (amended)
- Inter loaded from Bunny fonts CDN with `page.set_content(..., wait_until="networkidle")` before screenshot.
- System sans fallback in CSS if CDN unreachable.
### Avatar embedding
- Call `generate_sigil()` directly (same as `ui_avatars.py`).
- Rasterize via `cairosvg` → base64 PNG in HTML — no localhost fetch during screenshot.
---
## Decision 2 — Card layout
### Wireframe (1200×630)
```
┌────────────────────────────────────────────────────────────────────────────┐
│ [MuseHub logo] MuseHub [domain badge] │
├────────────────────────────────────────────────────────────────────────────┤
│ ┌────┐ │
│ │avatar│ gabriel/muse │
│ │ 80px│ Short description, up to two lines with ellipsis │
│ └────┘ │
│ │
│ ⌁ 847 commits 🍴 3 forks ◉ 12 issues open │
└────────────────────────────────────────────────────────────────────────────┘
```
### Fields and data sources
| Field | Source |
| --- | --- |
| Owner avatar | `generate_sigil()` + identity row for `owner` |
| Repo title | `{owner}/{repo_slug}` |
| Description | `repo.description` or fallback `Music composition repository by {owner}` |
| Domain badge | `repo.domain_id` when not `code`/empty |
| Commits | `get_repo_home_stats().total_commits` |
| Forks | `list_repo_forks_flat().total` (public forks only) |
| Open issues | `MusehubIssue.state == 'open'` count |
| Stars | **Omitted** — no `star_count` on `MusehubRepo` yet |
### Fallbacks
| Case | Behavior |
| --- | --- |
| Empty description | Owner fallback string |
| Unregistered owner | `sha256(handle)` sigil (per `ui_avatars.py`) |
| Long repo name | CSS `text-overflow: ellipsis` |
| Long description | ~120 chars / 2 lines |
| Private repo, anonymous | **404** |
| Renderer failure | **503**; do not cache failures |
---
## Decision 3 — Caching
### Strategy: **content-hash key + BlobBackend persistence**
**Amended storage path:** `BlobBackend` only supports `objects/{object_id}` where `object_id` is `sha256:<64-hex>`. OG PNGs are stored as:
```
object_id = sha256:{content_hash_hex}
S3 key = objects/sha256:{content_hash_hex}
```
No `og-cards/` prefix (unlike `mpacks/`). Hash inputs include `owner`, `slug`, and all visible fields — owner/slug need not appear in the storage key.
**Hash inputs** (canonical JSON, sorted keys, SHA-256):
```json
{
"v": 1,
"owner": "gabriel",
"slug": "muse",
"description": "…",
"domain_id": "code",
"total_commits": 847,
"fork_count": 3,
"open_issue_count": 12,
"owner_identity_hex": "…",
"identity_type": "human",
"layout_version": 1
}
```
**Request flow:** compute hash → `get_backend().get(object_id)` → on miss, singleflight render → `put(object_id, png)`.
**Orphan PNGs:** old hashes remain in object store when metadata changes; acceptable at current scale.
### Crawler cache busting (amended — Phase 3)
Facebook caches `og:image` by URL. Stable URL serves new bytes invisibly to crawlers.
**Phase 3 `og:image` URL:**
```
https://{host}/{owner}/{slug}/opengraph-image?cb={content_hash[:12]}
```
`repo_page` computes the same hash from data already gathered (plus fork count). Endpoint ignores `?cb=` for routing.
---
## Decision 4 — Routing and wiring
### Endpoint
```
GET /{owner}/{repo_slug}/opengraph-image
```
- `ui_opengraph.py`, registered in `main.py` **before** `ui_repo_routes`.
- Rate limit: `60/minute` per IP (`OPENGRAPH_LIMIT`).
- `Content-Type: image/png`, `Cache-Control: public, max-age=86400, immutable`, `ETag: "{content_hash}"`.
### Phase 3 — `base.html` meta tags (amended)
```html
{% block og_meta %}
{%- if og_meta is defined and og_meta %}
{%- for key, value in og_meta.items() %}
{%- if key.startswith('twitter:') %}
{%- else %}
{%- endif %}
{%- endfor %}
{%- endif %}
{% endblock %}
```
### Phase 3 — `ui_repo.py`
- Pass `image=` with absolute URL + `?cb=` hash.
- `twitter_card="summary_large_image"`.
- Optional: `url=` for `og:url` (page canonical URL).
---
## Test matrix
| ID | Assertion |
| --- | --- |
| OG_01 | 200, `image/png`, PNG magic bytes |
| OG_02 | **Stub renderer** records template context with description text |
| OG_03 | Avatar context present for registered and unregistered owners |
| OG_04 | Stats in template context reflect DB values |
| OG_05 | Renderer call count 1 on repeat; 2 after metadata change |
| OG_06 | Repo HTML has `og:image` meta with absolute URL and `?cb=` param |
---
## Docker amendments
- Runtime `apt-get`: `libcairo2` (cairosvg), Playwright Chromium system deps.
- `PLAYWRIGHT_BROWSERS_PATH=/ms-playwright`, installed as root, `chown musehub`.
- `playwright install chromium` in runtime stage.
---
## Review checklist
- [x] Rendering: Playwright + inline HTML + stub for tests
- [x] Storage: `sha256:{content_hash}` via BlobBackend
- [x] Crawler cache bust: `?cb=` on `og:image`
- [x] Meta tags: `property` for OG, `name` for Twitter
- [x] Stats: commits / forks / open issues (no stars)