feat(insta-lab): card_renderer with Jinja + Playwright (1080x1350)
This commit is contained in:
100
insta-lab/app/card_renderer.py
Normal file
100
insta-lab/app/card_renderer.py
Normal 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
|
||||||
55
insta-lab/app/templates/default/card.html.j2
Normal file
55
insta-lab/app/templates/default/card.html.j2
Normal 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>
|
||||||
48
insta-lab/tests/test_card_renderer.py
Normal file
48
insta-lab/tests/test_card_renderer.py
Normal 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))
|
||||||
Reference in New Issue
Block a user