diff --git a/insta-lab/app/card_renderer.py b/insta-lab/app/card_renderer.py new file mode 100644 index 0000000..5bbfc71 --- /dev/null +++ b/insta-lab/app/card_renderer.py @@ -0,0 +1,100 @@ +"""Jinja → HTML → Playwright headless screenshot.""" + +import asyncio +import hashlib +import json +import logging +import os +import tempfile +from typing import List + +from jinja2 import Environment, FileSystemLoader, select_autoescape +from playwright.async_api import async_playwright + +from .config import CARDS_DIR, CARD_TEMPLATE_DIR +from . import db + +logger = logging.getLogger(__name__) + + +def _resolve_template_dir() -> str: + """Prefer config CARD_TEMPLATE_DIR if it exists; else fall back to in-repo templates/.""" + if os.path.isdir(CARD_TEMPLATE_DIR): + return CARD_TEMPLATE_DIR + return os.path.join(os.path.dirname(__file__), "templates") + + +def _env() -> Environment: + return Environment( + loader=FileSystemLoader(_resolve_template_dir()), + autoescape=select_autoescape(["html", "j2"]), + ) + + +def _slate_dir(slate_id: int) -> str: + out = os.path.join(CARDS_DIR, str(slate_id)) + os.makedirs(out, exist_ok=True) + return out + + +def _build_pages(slate: dict) -> List[dict]: + cover = json.loads(slate["cover_copy"] or "{}") + bodies = json.loads(slate["body_copies"] or "[]") + cta = json.loads(slate["cta_copy"] or "{}") + 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 + + +async def render_slate(slate_id: int, template: str = "default/card.html.j2") -> List[str]: + slate = db.get_card_slate(slate_id) + if not slate: + raise ValueError(f"slate {slate_id} not found") + env = _env() + tmpl = env.get_template(template) + pages = _build_pages(slate) + out_dir = _slate_dir(slate_id) + paths: List[str] = [] + + async with async_playwright() as p: + browser = await p.chromium.launch() + try: + ctx = await browser.new_context(viewport={"width": 1080, "height": 1350}) + 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) + with open(out_path, "rb") as fp: + file_hash = hashlib.md5(fp.read()).hexdigest() + db.add_card_asset(slate_id, spec["page_no"], out_path, file_hash) + paths.append(out_path) + finally: + try: + os.unlink(html_path) + except OSError: + pass + finally: + await browser.close() + return paths diff --git a/insta-lab/app/templates/default/card.html.j2 b/insta-lab/app/templates/default/card.html.j2 new file mode 100644 index 0000000..836c3cb --- /dev/null +++ b/insta-lab/app/templates/default/card.html.j2 @@ -0,0 +1,55 @@ + + + + + + + +
+
+ {{ page_type|upper }} +

{{ headline }}

+

{{ body }}

+
+ +
+ + diff --git a/insta-lab/tests/test_card_renderer.py b/insta-lab/tests/test_card_renderer.py new file mode 100644 index 0000000..647a71d --- /dev/null +++ b/insta-lab/tests/test_card_renderer.py @@ -0,0 +1,48 @@ +import os +import tempfile + +import pytest + +from app import db as db_module +from app import card_renderer + + +@pytest.fixture +def tmp_db_and_dirs(monkeypatch, tmp_path): + fd, path = tempfile.mkstemp(suffix=".db") + os.close(fd) + monkeypatch.setattr(db_module, "DB_PATH", path) + monkeypatch.setattr(card_renderer, "CARDS_DIR", str(tmp_path / "cards")) + db_module.init_db() + yield path + import gc + gc.collect() + for ext in ("", "-wal", "-shm"): + try: + os.remove(path + ext) + except OSError: + pass + + +def _seed_slate() -> int: + return db_module.add_card_slate({ + "keyword": "테스트", + "category": "economy", + "status": "draft", + "cover_copy": {"headline": "커버 헤드라인", "body": "서브카피", "accent_color": "#0F62FE"}, + "body_copies": [{"headline": f"본문 {i+1}", "body": f"내용 {i+1}"} for i in range(8)], + "cta_copy": {"headline": "마무리", "body": "감사합니다", "cta": "팔로우"}, + }) + + +@pytest.mark.asyncio +async def test_render_slate_produces_ten_pngs(tmp_db_and_dirs): + sid = _seed_slate() + paths = await card_renderer.render_slate(sid) + assert len(paths) == 10 + for p in paths: + assert os.path.exists(p) + assert os.path.getsize(p) > 1000 # > 1 KB sanity + db_module.update_slate_status(sid, "rendered") + assets = db_module.list_card_assets(sid) + assert {a["page_index"] for a in assets} == set(range(1, 11))