feat(services/insta-render): card_renderer.py + templates (SP-3)
NAS insta-lab/app/card_renderer.py 이식 + DB 의존성 제거. slate 데이터는 worker가 NAS API에서 fetch해 인자로 전달. 결과 PNG는 INSTA_MEDIA_ROOT (/mnt/nas/webpage/data/insta/)에 직접 저장. Browser pool + Semaphore(1) reuse (동시 Chromium 1개). templates는 NAS와 동기화 (default theme + minimal theme). .gitignore에 services/ 추적 예외 추가 (코드는 추적, .env는 유지). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
151
services/insta-render/card_renderer.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""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 _build_pages(slate: dict) -> List[dict]:
|
||||
"""slate dict → 10 page specs."""
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
55
services/insta-render/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>
|
||||
788
services/insta-render/templates/minimal/card.html.j2
Normal file
@@ -0,0 +1,788 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Hedgy Card News – {{ page_no }}/10</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700;900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
background: #d0d0d0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
font-family: 'Noto Sans KR', sans-serif;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
width: 1080px;
|
||||
height: 1350px;
|
||||
overflow: hidden;
|
||||
border-radius: 48px;
|
||||
background-size: cover;
|
||||
background-position: center center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
/* ── shared overlay layer ── */
|
||||
.mask {
|
||||
position: absolute;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
PAGE 1 insta_card_start.png
|
||||
bg: #f2f2f0 (light warm white)
|
||||
═══════════════════════════════════════════ */
|
||||
.p1-headline-mask {
|
||||
top: 222px; left: 48px;
|
||||
width: 580px; height: 150px;
|
||||
background: #f2f2f0;
|
||||
padding: 8px;
|
||||
}
|
||||
.p1-headline-text {
|
||||
position: absolute;
|
||||
top: 222px; left: 48px;
|
||||
width: 580px; height: 150px;
|
||||
padding: 8px;
|
||||
font-size: 108px;
|
||||
font-weight: 900;
|
||||
color: #1e2235;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.p1-body-mask {
|
||||
top: 400px; left: 48px;
|
||||
width: 460px; height: 120px;
|
||||
background: #f2f2f0;
|
||||
padding: 8px;
|
||||
}
|
||||
.p1-body-text {
|
||||
position: absolute;
|
||||
top: 400px; left: 48px;
|
||||
width: 460px; height: 120px;
|
||||
padding: 8px;
|
||||
font-size: 34px;
|
||||
font-weight: 500;
|
||||
color: #4a4e5e;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
}
|
||||
.p1-cta-mask {
|
||||
top: 562px; left: 48px;
|
||||
width: 260px; height: 76px;
|
||||
background: #2f6ef7;
|
||||
border-radius: 38px;
|
||||
padding: 8px;
|
||||
}
|
||||
.p1-cta-text {
|
||||
position: absolute;
|
||||
top: 562px; left: 48px;
|
||||
width: 260px; height: 76px;
|
||||
border-radius: 38px;
|
||||
padding: 8px 24px;
|
||||
font-size: 34px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
PAGE 2 insta_card_keyword.png
|
||||
bg: #3a3fdb (blue gradient)
|
||||
═══════════════════════════════════════════ */
|
||||
.p2-headline-mask {
|
||||
top: 148px; left: 56px;
|
||||
width: 880px; height: 200px;
|
||||
background: #3a3fdb;
|
||||
padding: 8px;
|
||||
}
|
||||
.p2-headline-text {
|
||||
position: absolute;
|
||||
top: 148px; left: 56px;
|
||||
width: 880px; height: 200px;
|
||||
padding: 8px;
|
||||
font-size: 88px;
|
||||
font-weight: 900;
|
||||
color: #ffffff;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.p2-body-mask {
|
||||
top: 370px; left: 56px;
|
||||
width: 880px; height: 80px;
|
||||
background: #3a3fdb;
|
||||
padding: 8px;
|
||||
}
|
||||
.p2-body-text {
|
||||
position: absolute;
|
||||
top: 370px; left: 56px;
|
||||
width: 880px; height: 80px;
|
||||
padding: 8px;
|
||||
font-size: 38px;
|
||||
font-weight: 500;
|
||||
color: #e0e4ff;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
PAGE 3 insta_card_highlight.png
|
||||
bg: #3a3fdb
|
||||
═══════════════════════════════════════════ */
|
||||
.p3-headline-mask {
|
||||
top: 148px; left: 56px;
|
||||
width: 880px; height: 260px;
|
||||
background: #3a3fdb;
|
||||
padding: 8px;
|
||||
}
|
||||
.p3-headline-text {
|
||||
position: absolute;
|
||||
top: 148px; left: 56px;
|
||||
width: 880px; height: 260px;
|
||||
padding: 8px;
|
||||
font-size: 88px;
|
||||
font-weight: 900;
|
||||
color: #ffffff;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
}
|
||||
.p3-body-mask {
|
||||
top: 430px; left: 56px;
|
||||
width: 880px; height: 80px;
|
||||
background: #3a3fdb;
|
||||
padding: 8px;
|
||||
}
|
||||
.p3-body-text {
|
||||
position: absolute;
|
||||
top: 430px; left: 56px;
|
||||
width: 880px; height: 80px;
|
||||
padding: 8px;
|
||||
font-size: 38px;
|
||||
font-weight: 500;
|
||||
color: #e0e4ff;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
PAGE 4 insta_card_observation.png
|
||||
bg: #f2f2f0
|
||||
═══════════════════════════════════════════ */
|
||||
.p4-label-mask {
|
||||
top: 72px; left: 64px;
|
||||
width: 200px; height: 52px;
|
||||
background: #f2f2f0;
|
||||
padding: 8px;
|
||||
}
|
||||
.p4-label-text {
|
||||
position: absolute;
|
||||
top: 72px; left: 64px;
|
||||
width: 200px; height: 52px;
|
||||
padding: 8px;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #2f6ef7;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.p4-headline-mask {
|
||||
top: 148px; left: 56px;
|
||||
width: 700px; height: 110px;
|
||||
background: #f2f2f0;
|
||||
padding: 8px;
|
||||
}
|
||||
.p4-headline-text {
|
||||
position: absolute;
|
||||
top: 148px; left: 56px;
|
||||
width: 700px; height: 110px;
|
||||
padding: 8px;
|
||||
font-size: 72px;
|
||||
font-weight: 900;
|
||||
color: #1e2235;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.p4-body-mask {
|
||||
top: 290px; left: 56px;
|
||||
width: 700px; height: 180px;
|
||||
background: #f2f2f0;
|
||||
padding: 8px;
|
||||
}
|
||||
.p4-body-text {
|
||||
position: absolute;
|
||||
top: 290px; left: 56px;
|
||||
width: 700px; height: 180px;
|
||||
padding: 8px;
|
||||
font-size: 36px;
|
||||
font-weight: 400;
|
||||
color: #3a3e50;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
PAGE 5 insta_card_memo.png
|
||||
bg: #f2f2f0
|
||||
═══════════════════════════════════════════ */
|
||||
.p5-label-mask {
|
||||
top: 72px; left: 64px;
|
||||
width: 200px; height: 52px;
|
||||
background: #f2f2f0;
|
||||
padding: 8px;
|
||||
}
|
||||
.p5-label-text {
|
||||
position: absolute;
|
||||
top: 72px; left: 64px;
|
||||
width: 200px; height: 52px;
|
||||
padding: 8px;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #2f6ef7;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.p5-headline-mask {
|
||||
top: 160px; left: 56px;
|
||||
width: 700px; height: 110px;
|
||||
background: #f2f2f0;
|
||||
padding: 8px;
|
||||
}
|
||||
.p5-headline-text {
|
||||
position: absolute;
|
||||
top: 160px; left: 56px;
|
||||
width: 700px; height: 110px;
|
||||
padding: 8px;
|
||||
font-size: 70px;
|
||||
font-weight: 900;
|
||||
color: #1e2235;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.p5-body-mask {
|
||||
top: 308px; left: 56px;
|
||||
width: 700px; height: 180px;
|
||||
background: #f2f2f0;
|
||||
padding: 8px;
|
||||
}
|
||||
.p5-body-text {
|
||||
position: absolute;
|
||||
top: 308px; left: 56px;
|
||||
width: 700px; height: 180px;
|
||||
padding: 8px;
|
||||
font-size: 34px;
|
||||
font-weight: 400;
|
||||
color: #3a3e50;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
PAGE 6 insta_card_oneline.png
|
||||
bg: #f5f4f2
|
||||
═══════════════════════════════════════════ */
|
||||
.p6-headline-mask {
|
||||
top: 188px; left: 96px;
|
||||
width: 820px; height: 240px;
|
||||
background: #f5f4f2;
|
||||
padding: 8px;
|
||||
}
|
||||
.p6-headline-text {
|
||||
position: absolute;
|
||||
top: 188px; left: 96px;
|
||||
width: 820px; height: 240px;
|
||||
padding: 8px;
|
||||
font-size: 68px;
|
||||
font-weight: 900;
|
||||
color: #1e2235;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
}
|
||||
.p6-body-mask {
|
||||
top: 448px; left: 96px;
|
||||
width: 620px; height: 120px;
|
||||
background: #f5f4f2;
|
||||
padding: 8px;
|
||||
}
|
||||
.p6-body-text {
|
||||
position: absolute;
|
||||
top: 448px; left: 96px;
|
||||
width: 620px; height: 120px;
|
||||
padding: 8px;
|
||||
font-size: 34px;
|
||||
font-weight: 400;
|
||||
color: #5a5e70;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
PAGE 7 insta_card_checklist.png
|
||||
bg: #f5f4f2
|
||||
═══════════════════════════════════════════ */
|
||||
.p7-headline-mask {
|
||||
top: 110px; left: 56px;
|
||||
width: 740px; height: 110px;
|
||||
background: #f5f4f2;
|
||||
padding: 8px;
|
||||
}
|
||||
.p7-headline-text {
|
||||
position: absolute;
|
||||
top: 110px; left: 56px;
|
||||
width: 740px; height: 110px;
|
||||
padding: 8px;
|
||||
font-size: 74px;
|
||||
font-weight: 900;
|
||||
color: #1e2235;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
/* checklist items – 4 rows */
|
||||
.p7-item1-mask { top: 258px; left: 164px; width: 720px; height: 80px; background: #f5f4f2; padding: 8px; }
|
||||
.p7-item1-text { position: absolute; top: 258px; left: 164px; width: 720px; height: 80px; padding: 8px; font-size: 40px; font-weight: 500; color: #2a2d3e; word-wrap: break-word; overflow: hidden; display: flex; align-items: center; }
|
||||
.p7-item2-mask { top: 388px; left: 164px; width: 720px; height: 80px; background: #f5f4f2; padding: 8px; }
|
||||
.p7-item2-text { position: absolute; top: 388px; left: 164px; width: 720px; height: 80px; padding: 8px; font-size: 40px; font-weight: 500; color: #2a2d3e; word-wrap: break-word; overflow: hidden; display: flex; align-items: center; }
|
||||
.p7-item3-mask { top: 518px; left: 164px; width: 720px; height: 80px; background: #f5f4f2; padding: 8px; }
|
||||
.p7-item3-text { position: absolute; top: 518px; left: 164px; width: 720px; height: 80px; padding: 8px; font-size: 40px; font-weight: 500; color: #2a2d3e; word-wrap: break-word; overflow: hidden; display: flex; align-items: center; }
|
||||
.p7-item4-mask { top: 648px; left: 164px; width: 720px; height: 80px; background: #f5f4f2; padding: 8px; }
|
||||
.p7-item4-text { position: absolute; top: 648px; left: 164px; width: 720px; height: 80px; padding: 8px; font-size: 40px; font-weight: 500; color: #2a2d3e; word-wrap: break-word; overflow: hidden; display: flex; align-items: center; }
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
PAGE 8 insta_card_study.png
|
||||
bg: #f2f2f0
|
||||
═══════════════════════════════════════════ */
|
||||
.p8-label-mask {
|
||||
top: 72px; left: 64px;
|
||||
width: 200px; height: 52px;
|
||||
background: #f2f2f0;
|
||||
padding: 8px;
|
||||
}
|
||||
.p8-label-text {
|
||||
position: absolute;
|
||||
top: 72px; left: 64px;
|
||||
width: 200px; height: 52px;
|
||||
padding: 8px;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #2f6ef7;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.p8-headline-mask {
|
||||
top: 160px; left: 56px;
|
||||
width: 700px; height: 110px;
|
||||
background: #f2f2f0;
|
||||
padding: 8px;
|
||||
}
|
||||
.p8-headline-text {
|
||||
position: absolute;
|
||||
top: 160px; left: 56px;
|
||||
width: 700px; height: 110px;
|
||||
padding: 8px;
|
||||
font-size: 72px;
|
||||
font-weight: 900;
|
||||
color: #1e2235;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.p8-body-mask {
|
||||
top: 306px; left: 56px;
|
||||
width: 700px; height: 180px;
|
||||
background: #f2f2f0;
|
||||
padding: 8px;
|
||||
}
|
||||
.p8-body-text {
|
||||
position: absolute;
|
||||
top: 306px; left: 56px;
|
||||
width: 700px; height: 180px;
|
||||
padding: 8px;
|
||||
font-size: 34px;
|
||||
font-weight: 400;
|
||||
color: #3a3e50;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
PAGE 9 insta_card_cta.png
|
||||
bg: #f5f4f2
|
||||
═══════════════════════════════════════════ */
|
||||
.p9-headline-mask {
|
||||
top: 182px; left: 56px;
|
||||
width: 970px; height: 120px;
|
||||
background: #f5f4f2;
|
||||
padding: 8px;
|
||||
}
|
||||
.p9-headline-text {
|
||||
position: absolute;
|
||||
top: 182px; left: 56px;
|
||||
width: 970px; height: 120px;
|
||||
padding: 8px;
|
||||
font-size: 82px;
|
||||
font-weight: 900;
|
||||
color: #1e2235;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
.p9-cta-mask {
|
||||
top: 332px; left: 180px;
|
||||
width: 720px; height: 88px;
|
||||
background: #2244cc;
|
||||
border-radius: 44px;
|
||||
padding: 8px;
|
||||
}
|
||||
.p9-cta-text {
|
||||
position: absolute;
|
||||
top: 332px; left: 180px;
|
||||
width: 720px; height: 88px;
|
||||
border-radius: 44px;
|
||||
padding: 8px;
|
||||
font-size: 42px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.p9-body-mask {
|
||||
top: 980px; left: 56px;
|
||||
width: 860px; height: 60px;
|
||||
background: #f5f4f2;
|
||||
padding: 8px;
|
||||
}
|
||||
.p9-body-text {
|
||||
position: absolute;
|
||||
top: 980px; left: 56px;
|
||||
width: 860px; height: 60px;
|
||||
padding: 8px;
|
||||
font-size: 30px;
|
||||
font-weight: 400;
|
||||
color: #5a5e70;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
PAGE 10 insta_card_finish.png
|
||||
bg: #f2f2f0
|
||||
═══════════════════════════════════════════ */
|
||||
.p10-label-mask {
|
||||
top: 72px; left: 64px;
|
||||
width: 200px; height: 52px;
|
||||
background: #f2f2f0;
|
||||
padding: 8px;
|
||||
}
|
||||
.p10-label-text {
|
||||
position: absolute;
|
||||
top: 72px; left: 64px;
|
||||
width: 200px; height: 52px;
|
||||
padding: 8px;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #2f6ef7;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.p10-headline-mask {
|
||||
top: 155px; left: 56px;
|
||||
width: 700px; height: 110px;
|
||||
background: #f2f2f0;
|
||||
padding: 8px;
|
||||
}
|
||||
.p10-headline-text {
|
||||
position: absolute;
|
||||
top: 155px; left: 56px;
|
||||
width: 700px; height: 110px;
|
||||
padding: 8px;
|
||||
font-size: 72px;
|
||||
font-weight: 900;
|
||||
color: #1e2235;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.p10-body-mask {
|
||||
top: 302px; left: 56px;
|
||||
width: 680px; height: 180px;
|
||||
background: #f2f2f0;
|
||||
padding: 8px;
|
||||
}
|
||||
.p10-body-text {
|
||||
position: absolute;
|
||||
top: 302px; left: 56px;
|
||||
width: 680px; height: 180px;
|
||||
padding: 8px;
|
||||
font-size: 34px;
|
||||
font-weight: 400;
|
||||
color: #3a3e50;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* checklist icon (page 7) */
|
||||
.check-icon {
|
||||
position: absolute;
|
||||
width: 76px; height: 76px;
|
||||
background: #3366ee;
|
||||
border-radius: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.check-icon svg { width: 44px; height: 44px; }
|
||||
|
||||
/* quote mark (page 2 & 3) */
|
||||
.quote-mark {
|
||||
position: absolute;
|
||||
font-size: 100px;
|
||||
font-weight: 900;
|
||||
color: #ffffff;
|
||||
line-height: 1;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
/* left bar (page 6) */
|
||||
.left-bar {
|
||||
position: absolute;
|
||||
top: 196px; left: 64px;
|
||||
width: 10px; height: 232px;
|
||||
background: #7c5ce0;
|
||||
border-radius: 5px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
{% if page_no == 1 %}
|
||||
<!-- ══════════════════════════════════════
|
||||
PAGE 1 · COVER · insta_card_start.png
|
||||
══════════════════════════════════════ -->
|
||||
<div class="card" style="background-image: url('pages/insta_card_start.png');">
|
||||
<!-- headline mask + text -->
|
||||
<div class="mask p1-headline-mask"></div>
|
||||
<div class="mask p1-headline-text">{{ headline }}</div>
|
||||
<!-- body mask + text -->
|
||||
<div class="mask p1-body-mask"></div>
|
||||
<div class="mask p1-body-text">{{ body }}</div>
|
||||
<!-- cta mask + text -->
|
||||
<div class="mask p1-cta-mask"></div>
|
||||
<div class="mask p1-cta-text">{{ cta }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if page_no == 2 %}
|
||||
<!-- ══════════════════════════════════════
|
||||
PAGE 2 · insta_card_keyword.png
|
||||
══════════════════════════════════════ -->
|
||||
<div class="card" style="background-image: url('pages/insta_card_keyword.png');">
|
||||
<!-- quote mark mask -->
|
||||
<div class="mask" style="top:60px;left:48px;width:120px;height:100px;background:#3a3fdb;padding:8px;"></div>
|
||||
<div class="quote-mark" style="top:52px;left:50px;">"</div>
|
||||
<!-- headline -->
|
||||
<div class="mask p2-headline-mask"></div>
|
||||
<div class="mask p2-headline-text">{{ headline }}</div>
|
||||
<!-- body -->
|
||||
<div class="mask p2-body-mask"></div>
|
||||
<div class="mask p2-body-text">{{ body }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if page_no == 3 %}
|
||||
<!-- ══════════════════════════════════════
|
||||
PAGE 3 · insta_card_highlight.png
|
||||
══════════════════════════════════════ -->
|
||||
<div class="card" style="background-image: url('pages/insta_card_highlight.png');">
|
||||
<!-- quote mark mask -->
|
||||
<div class="mask" style="top:60px;left:48px;width:120px;height:100px;background:#3a3fdb;padding:8px;"></div>
|
||||
<div class="quote-mark" style="top:52px;left:50px;">"</div>
|
||||
<!-- headline -->
|
||||
<div class="mask p3-headline-mask"></div>
|
||||
<div class="mask p3-headline-text">{{ headline }}</div>
|
||||
<!-- body -->
|
||||
<div class="mask p3-body-mask"></div>
|
||||
<div class="mask p3-body-text">{{ body }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if page_no == 4 %}
|
||||
<!-- ══════════════════════════════════════
|
||||
PAGE 4 · insta_card_observation.png
|
||||
══════════════════════════════════════ -->
|
||||
<div class="card" style="background-image: url('pages/insta_card_observation.png');">
|
||||
<!-- day label -->
|
||||
<div class="mask p4-label-mask"></div>
|
||||
<div class="mask p4-label-text">{{ label }}</div>
|
||||
<!-- headline -->
|
||||
<div class="mask p4-headline-mask"></div>
|
||||
<div class="mask p4-headline-text">{{ headline }}</div>
|
||||
<!-- body -->
|
||||
<div class="mask p4-body-mask"></div>
|
||||
<div class="mask p4-body-text">{{ body }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if page_no == 5 %}
|
||||
<!-- ══════════════════════════════════════
|
||||
PAGE 5 · insta_card_memo.png
|
||||
══════════════════════════════════════ -->
|
||||
<div class="card" style="background-image: url('pages/insta_card_memo.png');">
|
||||
<!-- day label -->
|
||||
<div class="mask p5-label-mask"></div>
|
||||
<div class="mask p5-label-text">{{ label }}</div>
|
||||
<!-- headline -->
|
||||
<div class="mask p5-headline-mask"></div>
|
||||
<div class="mask p5-headline-text">{{ headline }}</div>
|
||||
<!-- body -->
|
||||
<div class="mask p5-body-mask"></div>
|
||||
<div class="mask p5-body-text">{{ body }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if page_no == 6 %}
|
||||
<!-- ══════════════════════════════════════
|
||||
PAGE 6 · insta_card_oneline.png
|
||||
══════════════════════════════════════ -->
|
||||
<div class="card" style="background-image: url('pages/insta_card_oneline.png');">
|
||||
<!-- purple left bar -->
|
||||
<div class="left-bar"></div>
|
||||
<!-- headline -->
|
||||
<div class="mask p6-headline-mask"></div>
|
||||
<div class="mask p6-headline-text">{{ headline }}</div>
|
||||
<!-- body -->
|
||||
<div class="mask p6-body-mask"></div>
|
||||
<div class="mask p6-body-text">{{ body }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if page_no == 7 %}
|
||||
<!-- ══════════════════════════════════════
|
||||
PAGE 7 · insta_card_checklist.png
|
||||
══════════════════════════════════════ -->
|
||||
<div class="card" style="background-image: url('pages/insta_card_checklist.png');">
|
||||
<!-- section title -->
|
||||
<div class="mask p7-headline-mask"></div>
|
||||
<div class="mask p7-headline-text">{{ headline }}</div>
|
||||
|
||||
<!-- check icons -->
|
||||
<div class="check-icon" style="top:252px;left:56px;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</div>
|
||||
<div class="check-icon" style="top:382px;left:56px;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</div>
|
||||
<div class="check-icon" style="top:512px;left:56px;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</div>
|
||||
<div class="check-icon" style="top:642px;left:56px;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</div>
|
||||
|
||||
<!-- checklist items -->
|
||||
<div class="mask p7-item1-mask"></div>
|
||||
<div class="mask p7-item1-text">{{ item1 }}</div>
|
||||
<div class="mask p7-item2-mask"></div>
|
||||
<div class="mask p7-item2-text">{{ item2 }}</div>
|
||||
<div class="mask p7-item3-mask"></div>
|
||||
<div class="mask p7-item3-text">{{ item3 }}</div>
|
||||
<div class="mask p7-item4-mask"></div>
|
||||
<div class="mask p7-item4-text">{{ item4 }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if page_no == 8 %}
|
||||
<!-- ══════════════════════════════════════
|
||||
PAGE 8 · insta_card_study.png
|
||||
══════════════════════════════════════ -->
|
||||
<div class="card" style="background-image: url('pages/insta_card_study.png');">
|
||||
<!-- day label -->
|
||||
<div class="mask p8-label-mask"></div>
|
||||
<div class="mask p8-label-text">{{ label }}</div>
|
||||
<!-- headline -->
|
||||
<div class="mask p8-headline-mask"></div>
|
||||
<div class="mask p8-headline-text">{{ headline }}</div>
|
||||
<!-- body -->
|
||||
<div class="mask p8-body-mask"></div>
|
||||
<div class="mask p8-body-text">{{ body }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if page_no == 9 %}
|
||||
<!-- ══════════════════════════════════════
|
||||
PAGE 9 · insta_card_cta.png
|
||||
══════════════════════════════════════ -->
|
||||
<div class="card" style="background-image: url('pages/insta_card_cta.png');">
|
||||
<!-- headline -->
|
||||
<div class="mask p9-headline-mask"></div>
|
||||
<div class="mask p9-headline-text">{{ headline }}</div>
|
||||
<!-- cta button -->
|
||||
<div class="mask p9-cta-mask"></div>
|
||||
<div class="mask p9-cta-text">{{ cta }}</div>
|
||||
<!-- body / next episode teaser -->
|
||||
<div class="mask p9-body-mask"></div>
|
||||
<div class="mask p9-body-text">{{ body }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if page_no == 10 %}
|
||||
<!-- ══════════════════════════════════════
|
||||
PAGE 10 · insta_card_finish.png
|
||||
══════════════════════════════════════ -->
|
||||
<div class="card" style="background-image: url('pages/insta_card_finish.png');">
|
||||
<!-- day label -->
|
||||
<div class="mask p10-label-mask"></div>
|
||||
<div class="mask p10-label-text">{{ label }}</div>
|
||||
<!-- headline -->
|
||||
<div class="mask p10-headline-mask"></div>
|
||||
<div class="mask p10-headline-text">{{ headline }}</div>
|
||||
<!-- body -->
|
||||
<div class="mask p10-body-mask"></div>
|
||||
<div class="mask p10-body-text">{{ body }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
12
services/insta-render/templates/minimal/pages/_order.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"insta_card_start.png": 1,
|
||||
"insta_card_keyword.png": 2,
|
||||
"insta_card_highlight.png": 3,
|
||||
"insta_card_observation.png": 4,
|
||||
"insta_card_memo.png": 5,
|
||||
"insta_card_oneline.png": 6,
|
||||
"insta_card_checklist.png": 7,
|
||||
"insta_card_study.png": 8,
|
||||
"insta_card_cta.png": 9,
|
||||
"insta_card_finish.png": 10
|
||||
}
|
||||
|
After Width: | Height: | Size: 1010 KiB |
BIN
services/insta-render/templates/minimal/pages/insta_card_cta.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.3 MiB |