gabriel / musehub public
opengraph_renderer.py python
145 lines 4.6 KB
Raw
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