opengraph_renderer.py
python
sha256:7d5985ef251de9f0154f9b185a75cf36174bf73e0c34f5eca133e7bd20224bf4
Merge branch 'feat/opengraph-repo-cards' into dev
Human
23 days ago
| 1 | """Open Graph card renderers — Playwright (prod) and stub (tests). |
| 2 | |
| 3 | Tests always use ``StubOpenGraphRenderer`` via ``MUSE_ENV=test``. Production |
| 4 | uses a process-wide Chromium browser with per-render pages. |
| 5 | """ |
| 6 | |
| 7 | from __future__ import annotations |
| 8 | |
| 9 | import asyncio |
| 10 | import base64 |
| 11 | import logging |
| 12 | from abc import ABC, abstractmethod |
| 13 | from pathlib import Path |
| 14 | from typing import TYPE_CHECKING |
| 15 | |
| 16 | from musehub.config import settings |
| 17 | |
| 18 | if TYPE_CHECKING: |
| 19 | from musehub.services.opengraph_cards import OpenGraphCardContext |
| 20 | |
| 21 | logger = logging.getLogger(__name__) |
| 22 | |
| 23 | _TEMPLATE_DIR = ( |
| 24 | Path(__file__).resolve().parent.parent / "templates" / "musehub" / "opengraph" |
| 25 | ) |
| 26 | |
| 27 | # Minimal 1×1 PNG returned by the stub renderer. |
| 28 | _STUB_PNG: bytes = base64.b64decode( |
| 29 | "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" |
| 30 | ) |
| 31 | |
| 32 | _playwright_instance = None |
| 33 | _browser = None |
| 34 | _renderer_instance: OpenGraphRenderer | None = None |
| 35 | |
| 36 | |
| 37 | class OpenGraphRenderer(ABC): |
| 38 | """Render an ``OpenGraphCardContext`` to PNG bytes.""" |
| 39 | |
| 40 | last_context: OpenGraphCardContext | None = None |
| 41 | render_count: int = 0 |
| 42 | |
| 43 | @abstractmethod |
| 44 | async def render(self, ctx: OpenGraphCardContext) -> bytes: |
| 45 | """Produce a PNG for the given card context.""" |
| 46 | |
| 47 | |
| 48 | class StubOpenGraphRenderer(OpenGraphRenderer): |
| 49 | """Test/dev renderer — records context, returns a tiny valid PNG.""" |
| 50 | |
| 51 | async def render(self, ctx: OpenGraphCardContext) -> bytes: |
| 52 | type(self).last_context = ctx |
| 53 | type(self).render_count += 1 |
| 54 | return _STUB_PNG |
| 55 | |
| 56 | |
| 57 | class PlaywrightOpenGraphRenderer(OpenGraphRenderer): |
| 58 | """HTML template → Chromium screenshot at 1200×630.""" |
| 59 | |
| 60 | async def render(self, ctx: OpenGraphCardContext) -> bytes: |
| 61 | type(self).last_context = ctx |
| 62 | type(self).render_count += 1 |
| 63 | html = _render_template(ctx) |
| 64 | return await asyncio.to_thread(_screenshot_html_sync, html) |
| 65 | |
| 66 | |
| 67 | def _render_template(ctx: OpenGraphCardContext) -> str: |
| 68 | from jinja2 import Environment, FileSystemLoader, select_autoescape |
| 69 | |
| 70 | env = Environment( |
| 71 | loader=FileSystemLoader(str(_TEMPLATE_DIR)), |
| 72 | autoescape=select_autoescape(["html"]), |
| 73 | ) |
| 74 | template = env.get_template("repo_card.html") |
| 75 | domain_label = "" |
| 76 | if ctx.domain_id and ctx.domain_id not in ("", "code", "generic"): |
| 77 | domain_label = ctx.domain_id |
| 78 | return template.render( |
| 79 | owner=ctx.owner, |
| 80 | repo_slug=ctx.repo_slug, |
| 81 | repo_title=f"{ctx.owner}/{ctx.repo_slug}", |
| 82 | description=ctx.description, |
| 83 | domain_label=domain_label, |
| 84 | total_commits=ctx.total_commits, |
| 85 | fork_count=ctx.fork_count, |
| 86 | open_issue_count=ctx.open_issue_count, |
| 87 | avatar_png_b64=ctx.avatar_png_b64, |
| 88 | ) |
| 89 | |
| 90 | |
| 91 | def _get_sync_browser(): |
| 92 | """Return the process-wide Chromium browser, launching on first use.""" |
| 93 | global _playwright_instance, _browser |
| 94 | if _browser is not None: |
| 95 | return _browser |
| 96 | from playwright.sync_api import sync_playwright |
| 97 | |
| 98 | _playwright_instance = sync_playwright().start() |
| 99 | _browser = _playwright_instance.chromium.launch(headless=True) |
| 100 | logger.info("opengraph: Chromium browser launched") |
| 101 | return _browser |
| 102 | |
| 103 | |
| 104 | def _screenshot_html_sync(html: str) -> bytes: |
| 105 | browser = _get_sync_browser() |
| 106 | page = browser.new_page(viewport={"width": 1200, "height": 630}) |
| 107 | try: |
| 108 | page.set_content(html, wait_until="networkidle") |
| 109 | return page.screenshot(type="png", full_page=False) |
| 110 | finally: |
| 111 | page.close() |
| 112 | |
| 113 | |
| 114 | async def shutdown_renderer() -> None: |
| 115 | """Close Chromium and stop Playwright.""" |
| 116 | global _playwright_instance, _browser |
| 117 | if _browser is not None: |
| 118 | await asyncio.to_thread(_browser.close) |
| 119 | _browser = None |
| 120 | if _playwright_instance is not None: |
| 121 | await asyncio.to_thread(_playwright_instance.stop) |
| 122 | _playwright_instance = None |
| 123 | |
| 124 | |
| 125 | def get_opengraph_renderer() -> OpenGraphRenderer: |
| 126 | """Return the active renderer singleton (stub in test, Playwright otherwise).""" |
| 127 | global _renderer_instance |
| 128 | if _renderer_instance is not None: |
| 129 | return _renderer_instance |
| 130 | |
| 131 | if settings.muse_env == "test" or settings.opengraph_renderer == "stub": |
| 132 | _renderer_instance = StubOpenGraphRenderer() |
| 133 | elif settings.opengraph_renderer == "playwright" or settings.opengraph_renderer == "auto": |
| 134 | _renderer_instance = PlaywrightOpenGraphRenderer() |
| 135 | else: |
| 136 | _renderer_instance = StubOpenGraphRenderer() |
| 137 | return _renderer_instance |
| 138 | |
| 139 | |
| 140 | def reset_renderer_for_tests() -> None: |
| 141 | """Clear renderer singleton and stub counters (pytest).""" |
| 142 | global _renderer_instance |
| 143 | _renderer_instance = None |
| 144 | StubOpenGraphRenderer.last_context = None |
| 145 | StubOpenGraphRenderer.render_count = 0 |
File History
1 commit
sha256:7d5985ef251de9f0154f9b185a75cf36174bf73e0c34f5eca133e7bd20224bf4
Merge branch 'feat/opengraph-repo-cards' into dev
Human
23 days ago