"""Jinja → HTML → Playwright headless screenshot (Windows worker version). NAS DB·db.py 의존성 제거. slate 데이터는 worker가 NAS HTTP API에서 fetch해서 인자로 전달. 결과 PNG는 INSTA_MEDIA_ROOT (/mnt/nas/webpage/data/insta/)에 직접 저장. """ from __future__ import annotations import asyncio import json import logging import os import tempfile from pathlib import Path from typing import Any, Dict, List from jinja2 import Environment, FileSystemLoader, select_autoescape from playwright.async_api import async_playwright CARD_TEMPLATE_DIR = os.getenv("CARD_TEMPLATE_DIR", "/app/templates") INSTA_MEDIA_ROOT = os.getenv("INSTA_MEDIA_ROOT", "/mnt/nas/webpage/data/insta") logger = logging.getLogger(__name__) # Chromium 동시 1개 (CPU·GPU 보호) _RENDER_SEMAPHORE: asyncio.Semaphore | None = None def _render_semaphore() -> asyncio.Semaphore: global _RENDER_SEMAPHORE if _RENDER_SEMAPHORE is None: _RENDER_SEMAPHORE = asyncio.Semaphore(1) return _RENDER_SEMAPHORE # Browser pool — 매 슬레이트마다 launch X. 모듈 레벨 lazy + reuse. _PLAYWRIGHT = None _BROWSER = None async def init_browser() -> None: global _PLAYWRIGHT, _BROWSER if _BROWSER is not None and _BROWSER.is_connected(): return _PLAYWRIGHT = await async_playwright().start() _BROWSER = await _PLAYWRIGHT.chromium.launch() logger.info("Chromium browser pool 초기화 완료") async def shutdown_browser() -> None: global _PLAYWRIGHT, _BROWSER if _BROWSER is not None: try: await _BROWSER.close() except Exception: logger.exception("browser close 중 예외 (무시)") _BROWSER = None if _PLAYWRIGHT is not None: try: await _PLAYWRIGHT.stop() except Exception: logger.exception("playwright stop 중 예외 (무시)") _PLAYWRIGHT = None async def _get_browser(): global _BROWSER if _BROWSER is None or not _BROWSER.is_connected(): await init_browser() return _BROWSER def _env() -> Environment: return Environment( loader=FileSystemLoader(CARD_TEMPLATE_DIR), autoescape=select_autoescape(["html", "j2"]), ) def _coerce(value, default): """NAS API는 cover_copy/body_copies/cta_copy를 dict/list로 반환하지만 구버전 호환을 위해 JSON 문자열도 처리.""" if value is None or value == "": return default if isinstance(value, str): try: return json.loads(value) except (ValueError, TypeError): return default return value def _build_pages(slate: dict) -> List[dict]: """slate dict → 10 page specs.""" cover = _coerce(slate.get("cover_copy"), {}) bodies = _coerce(slate.get("body_copies"), []) cta = _coerce(slate.get("cta_copy"), {}) accent = cover.get("accent_color") or "#0F62FE" pages: List[dict] = [] pages.append({ "page_type": "cover", "page_no": 1, "total_pages": 10, "headline": cover.get("headline", ""), "body": cover.get("body", ""), "accent_color": accent, "cta": "", }) for i, b in enumerate(bodies[:8]): pages.append({ "page_type": "body", "page_no": i + 2, "total_pages": 10, "headline": b.get("headline", ""), "body": b.get("body", ""), "accent_color": accent, "cta": "", }) pages.append({ "page_type": "cta", "page_no": 10, "total_pages": 10, "headline": cta.get("headline", ""), "body": cta.get("body", ""), "accent_color": accent, "cta": cta.get("cta", ""), }) return pages def _slate_dir(slate_id: int) -> str: out = os.path.join(INSTA_MEDIA_ROOT, str(slate_id)) os.makedirs(out, exist_ok=True) return out async def render_slate(slate: dict, slate_id: int, template: str = "default/card.html.j2") -> List[str]: """slate 데이터 + slate_id로 10장 PNG 렌더. 결과 path list 반환.""" async with _render_semaphore(): return await _render_slate_locked(slate, slate_id, template) async def _render_slate_locked(slate: dict, slate_id: int, template: str) -> List[str]: env = _env() template_full = Path(CARD_TEMPLATE_DIR) / template if not template_full.exists(): logger.warning("Template '%s' 없음 → default/card.html.j2 폴백", template) template = "default/card.html.j2" tmpl = env.get_template(template) pages = _build_pages(slate) out_dir = _slate_dir(slate_id) paths: List[str] = [] browser = await _get_browser() ctx = await browser.new_context(viewport={"width": 1080, "height": 1350}) try: page = await ctx.new_page() for spec in pages: html_str = tmpl.render(**spec) with tempfile.NamedTemporaryFile("w", suffix=".html", delete=False, encoding="utf-8") as f: f.write(html_str) html_path = f.name try: await page.goto(f"file://{html_path}", wait_until="networkidle") out_path = os.path.join(out_dir, f"{spec['page_no']:02d}.png") await page.screenshot(path=out_path, full_page=False, omit_background=False) paths.append(out_path) finally: try: os.unlink(html_path) except OSError: pass finally: await ctx.close() return paths