feat(insta-lab): card_renderer with Jinja + Playwright (1080x1350)

This commit is contained in:
2026-05-16 00:35:55 +09:00
parent c3a5d7210f
commit 11bd223612
3 changed files with 203 additions and 0 deletions

View File

@@ -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

View File

@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<style>
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;700;900&display=swap');
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 1080px; height: 1350px;
font-family: 'Noto Sans KR', sans-serif;
background: #F7F7FA; color: #14171A;
}
.card {
width: 1080px; height: 1350px;
padding: 80px 72px;
display: flex; flex-direction: column; justify-content: space-between;
background: linear-gradient(180deg, #FFFFFF 0%, #F7F7FA 100%);
border-top: 16px solid {{ accent_color }};
}
.badge {
display: inline-block; padding: 8px 20px; border-radius: 999px;
background: {{ accent_color }}; color: #fff;
font-size: 28px; font-weight: 700; letter-spacing: -0.02em;
}
.headline {
font-size: {{ 96 if page_type == 'cover' else 72 }}px;
font-weight: 900; line-height: 1.15; letter-spacing: -0.04em;
margin-top: 32px;
}
.body {
font-size: 40px; font-weight: 400; line-height: 1.55;
margin-top: 40px; color: #2A2F35;
white-space: pre-wrap;
}
.footer {
display: flex; justify-content: space-between; align-items: center;
font-size: 28px; color: #6B7280; font-weight: 500;
}
.cta { font-weight: 700; color: {{ accent_color }}; }
</style>
</head>
<body>
<div class="card">
<div>
<span class="badge">{{ page_type|upper }}</span>
<h1 class="headline">{{ headline }}</h1>
<p class="body">{{ body }}</p>
</div>
<div class="footer">
<span>{{ page_no }} / {{ total_pages }}</span>
{% if cta %}<span class="cta">{{ cta }}</span>{% endif %}
</div>
</div>
</body>
</html>

View File

@@ -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))