Compare commits
7 Commits
26ef660c75
...
c8793cc3cf
| Author | SHA1 | Date | |
|---|---|---|---|
| c8793cc3cf | |||
| 11e73f6960 | |||
| f1fc3e1102 | |||
| e0e56090ee | |||
| e0269bae39 | |||
| bee0add9dd | |||
| 1adf91a19b |
17
.gitignore
vendored
@@ -65,3 +65,20 @@ KIS_SETUP.md
|
|||||||
# Signal V2 runtime data
|
# Signal V2 runtime data
|
||||||
ai_trade/data/*.db
|
ai_trade/data/*.db
|
||||||
ai_trade/data/*.db-*
|
ai_trade/data/*.db-*
|
||||||
|
|
||||||
|
# Plan-B-Insta services 예외 (코드는 추적, .env는 무시 유지)
|
||||||
|
!services/
|
||||||
|
!services/**/
|
||||||
|
!services/**/*.py
|
||||||
|
!services/**/Dockerfile
|
||||||
|
!services/**/requirements.txt
|
||||||
|
!services/**/.env.example
|
||||||
|
!services/**/*.j2
|
||||||
|
!services/**/*.html
|
||||||
|
!services/**/*.css
|
||||||
|
!services/**/.gitkeep
|
||||||
|
!services/**/pytest.ini
|
||||||
|
!services/docker-compose.yml
|
||||||
|
# 단 실 .env는 무시 유지
|
||||||
|
services/**/.env
|
||||||
|
services/.env
|
||||||
|
|||||||
25
services/docker-compose.yml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
name: web-ai-services
|
||||||
|
|
||||||
|
services:
|
||||||
|
insta-render:
|
||||||
|
build:
|
||||||
|
context: ./insta-render
|
||||||
|
container_name: insta-render
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "18710:8000"
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Seoul
|
||||||
|
- REDIS_URL=${REDIS_URL:-redis://192.168.45.54:6379}
|
||||||
|
- NAS_BASE_URL=${NAS_BASE_URL:-http://192.168.45.54:18700}
|
||||||
|
- INTERNAL_API_KEY=${INTERNAL_API_KEY:-}
|
||||||
|
- INSTA_MEDIA_ROOT=${INSTA_MEDIA_ROOT:-/mnt/nas/webpage/data/insta}
|
||||||
|
- INSTA_MEDIA_URL_PREFIX=${INSTA_MEDIA_URL_PREFIX:-/media/insta}
|
||||||
|
- CARD_TEMPLATE_DIR=/app/templates
|
||||||
|
volumes:
|
||||||
|
- /mnt/nas/webpage/data/insta:/mnt/nas/webpage/data/insta
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
17
services/insta-render/.env.example
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Plan-B-Insta — Windows insta-render worker
|
||||||
|
|
||||||
|
# NAS Redis 큐
|
||||||
|
REDIS_URL=redis://192.168.45.54:6379
|
||||||
|
|
||||||
|
# NAS internal webhook
|
||||||
|
NAS_BASE_URL=http://192.168.45.54:18700
|
||||||
|
INTERNAL_API_KEY=__copy_from_nas_dotenv__
|
||||||
|
|
||||||
|
# NAS SMB mount 안의 미디어 디렉토리 (/mnt/nas/webpage/data/insta/)
|
||||||
|
INSTA_MEDIA_ROOT=/mnt/nas/webpage/data/insta
|
||||||
|
|
||||||
|
# nginx 서빙 prefix (NAS webhook payload에 보낼 result_path 만들 때)
|
||||||
|
INSTA_MEDIA_URL_PREFIX=/media/insta
|
||||||
|
|
||||||
|
# Jinja 템플릿 디렉토리 (이 컨테이너 안)
|
||||||
|
CARD_TEMPLATE_DIR=/app/templates
|
||||||
22
services/insta-render/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
FROM python:3.12-slim-bookworm
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Korean fonts + Chromium runtime deps (Debian 12 / bookworm)
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
fonts-noto-cjk fonts-noto-cjk-extra \
|
||||||
|
libnss3 libnspr4 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 \
|
||||||
|
libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 \
|
||||||
|
libxfixes3 libxrandr2 libgbm1 libxshmfence1 libpango-1.0-0 \
|
||||||
|
libcairo2 libasound2 libatspi2.0-0 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir --timeout 600 --retries 5 -r requirements.txt
|
||||||
|
RUN playwright install chromium
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||||
164
services/insta-render/card_renderer.py
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
"""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
|
||||||
41
services/insta-render/main.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"""insta-render FastAPI entry — health + lifespan (Browser pool + worker loop)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
import card_renderer
|
||||||
|
import worker
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
# Browser pool 초기화 (Chromium launch)
|
||||||
|
await card_renderer.init_browser()
|
||||||
|
# 큐 워커 백그라운드 시작
|
||||||
|
worker_task = asyncio.create_task(worker.worker_loop())
|
||||||
|
logger.info("insta-render lifespan 시작")
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
worker_task.cancel()
|
||||||
|
try:
|
||||||
|
await worker_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
await card_renderer.shutdown_browser()
|
||||||
|
logger.info("insta-render lifespan 종료")
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"ok": True, "service": "insta-render"}
|
||||||
2
services/insta-render/pytest.ini
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[pytest]
|
||||||
|
asyncio_mode = auto
|
||||||
9
services/insta-render/requirements.txt
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.34.0
|
||||||
|
playwright==1.48.0
|
||||||
|
jinja2>=3.1.4
|
||||||
|
Pillow>=10
|
||||||
|
redis>=5.0
|
||||||
|
httpx>=0.27
|
||||||
|
pytest>=8.0
|
||||||
|
pytest-asyncio>=0.24
|
||||||
0
services/insta-render/templates/default/.gitkeep
Normal file
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 |
122
services/insta-render/tests/test_worker.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
"""worker.py — Redis BLPOP + webhook 단위 테스트."""
|
||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
import httpx
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import worker
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_slate():
|
||||||
|
return {
|
||||||
|
"id": 42,
|
||||||
|
"cover_copy": json.dumps({"headline": "테스트 H", "body": "테스트 B", "accent_color": "#FF0000"}),
|
||||||
|
"body_copies": json.dumps([{"headline": "본문1", "body": "..."} for _ in range(8)]),
|
||||||
|
"cta_copy": json.dumps({"headline": "CTA", "body": "...", "cta": "Click"}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_post_update_sends_correct_payload(monkeypatch):
|
||||||
|
monkeypatch.setenv("INTERNAL_API_KEY", "test-secret")
|
||||||
|
monkeypatch.setenv("NAS_BASE_URL", "http://nas.test")
|
||||||
|
# worker 모듈 환경변수 재로딩
|
||||||
|
worker.NAS_BASE_URL = "http://nas.test"
|
||||||
|
worker.INTERNAL_API_KEY = "test-secret"
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
async def fake_post(self, url, headers=None, json=None, **kw):
|
||||||
|
captured["url"] = url
|
||||||
|
captured["headers"] = headers
|
||||||
|
captured["json"] = json
|
||||||
|
class R:
|
||||||
|
status_code = 200
|
||||||
|
text = "ok"
|
||||||
|
return R()
|
||||||
|
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
await worker._post_update(client, "t-1", "processing", 30)
|
||||||
|
|
||||||
|
assert captured["url"] == "http://nas.test/api/internal/insta/update"
|
||||||
|
assert captured["headers"]["X-Internal-Key"] == "test-secret"
|
||||||
|
assert captured["json"]["status"] == "processing"
|
||||||
|
assert captured["json"]["progress"] == 30
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_process_one_success_calls_webhook_twice(monkeypatch, fake_slate):
|
||||||
|
"""processing(50) → succeeded(100) 두 번 호출 + render 한 번."""
|
||||||
|
calls: list = []
|
||||||
|
|
||||||
|
async def fake_post(self, url, headers=None, json=None, **kw):
|
||||||
|
calls.append({"status": json["status"], "progress": json["progress"]})
|
||||||
|
class R:
|
||||||
|
status_code = 200
|
||||||
|
text = "ok"
|
||||||
|
return R()
|
||||||
|
|
||||||
|
async def fake_get(self, url, **kw):
|
||||||
|
class R:
|
||||||
|
status_code = 200
|
||||||
|
def json(self_inner): return fake_slate
|
||||||
|
def raise_for_status(self_inner): pass
|
||||||
|
return R()
|
||||||
|
|
||||||
|
async def fake_render(slate, slate_id, template="default/card.html.j2"):
|
||||||
|
return [f"/tmp/{slate_id}/{i:02d}.png" for i in range(1, 11)]
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
|
||||||
|
monkeypatch.setattr(httpx.AsyncClient, "get", fake_get)
|
||||||
|
monkeypatch.setattr(worker, "render_slate", fake_render)
|
||||||
|
worker.INTERNAL_API_KEY = "test"
|
||||||
|
worker.NAS_BASE_URL = "http://nas.test"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
await worker._process_one(client, {
|
||||||
|
"task_id": "t-2",
|
||||||
|
"params": {"slate_id": 42, "theme": "default"},
|
||||||
|
})
|
||||||
|
|
||||||
|
statuses = [c["status"] for c in calls]
|
||||||
|
assert "processing" in statuses
|
||||||
|
assert "succeeded" in statuses
|
||||||
|
assert calls[-1]["progress"] == 100
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_process_one_render_failure_reports_failed(monkeypatch, fake_slate):
|
||||||
|
"""render 예외 시 failed webhook 호출."""
|
||||||
|
calls: list = []
|
||||||
|
|
||||||
|
async def fake_post(self, url, headers=None, json=None, **kw):
|
||||||
|
calls.append(json)
|
||||||
|
class R: status_code = 200; text = "ok"
|
||||||
|
return R()
|
||||||
|
|
||||||
|
async def fake_get(self, url, **kw):
|
||||||
|
class R:
|
||||||
|
status_code = 200
|
||||||
|
def json(self_inner): return fake_slate
|
||||||
|
def raise_for_status(self_inner): pass
|
||||||
|
return R()
|
||||||
|
|
||||||
|
async def fake_render(*a, **k):
|
||||||
|
raise RuntimeError("Chromium crashed")
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
|
||||||
|
monkeypatch.setattr(httpx.AsyncClient, "get", fake_get)
|
||||||
|
monkeypatch.setattr(worker, "render_slate", fake_render)
|
||||||
|
worker.INTERNAL_API_KEY = "test"
|
||||||
|
worker.NAS_BASE_URL = "http://nas.test"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
await worker._process_one(client, {
|
||||||
|
"task_id": "t-3",
|
||||||
|
"params": {"slate_id": 99},
|
||||||
|
})
|
||||||
|
|
||||||
|
last = calls[-1]
|
||||||
|
assert last["status"] == "failed"
|
||||||
|
assert "Chromium" in last["error"]
|
||||||
109
services/insta-render/worker.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
"""Redis BLPOP worker — queue:insta-render → render_slate → NAS webhook.
|
||||||
|
|
||||||
|
queue:paused가 set이면 대기 (task-watcher가 박재오 활동 감지 시 set).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import redis.asyncio as aioredis
|
||||||
|
|
||||||
|
from card_renderer import render_slate
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
REDIS_URL = os.getenv("REDIS_URL", "redis://192.168.45.54:6379")
|
||||||
|
NAS_BASE_URL = os.getenv("NAS_BASE_URL", "http://192.168.45.54:18700")
|
||||||
|
INTERNAL_API_KEY = os.getenv("INTERNAL_API_KEY", "")
|
||||||
|
INSTA_MEDIA_URL_PREFIX = os.getenv("INSTA_MEDIA_URL_PREFIX", "/media/insta")
|
||||||
|
|
||||||
|
QUEUE_KEY = "queue:insta-render"
|
||||||
|
PAUSED_KEY = "queue:paused"
|
||||||
|
|
||||||
|
|
||||||
|
async def _post_update(client: httpx.AsyncClient, task_id: str, status: str, progress: int,
|
||||||
|
result_path: str | None = None, error: str | None = None) -> None:
|
||||||
|
"""NAS internal webhook 호출."""
|
||||||
|
url = f"{NAS_BASE_URL}/api/internal/insta/update"
|
||||||
|
payload: dict[str, Any] = {"task_id": task_id, "status": status, "progress": progress}
|
||||||
|
if result_path:
|
||||||
|
payload["result_path"] = result_path
|
||||||
|
if error:
|
||||||
|
payload["error"] = error
|
||||||
|
try:
|
||||||
|
r = await client.post(
|
||||||
|
url,
|
||||||
|
headers={"X-Internal-Key": INTERNAL_API_KEY},
|
||||||
|
json=payload,
|
||||||
|
timeout=10.0,
|
||||||
|
)
|
||||||
|
if r.status_code != 200:
|
||||||
|
logger.error("webhook %s returned %d: %s", task_id, r.status_code, r.text[:200])
|
||||||
|
except Exception:
|
||||||
|
logger.exception("webhook %s 호출 실패", task_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_slate(client: httpx.AsyncClient, slate_id: int) -> dict:
|
||||||
|
"""NAS /api/insta/slates/{id} GET. (인증 불필요 — 기존 endpoint)"""
|
||||||
|
r = await client.get(f"{NAS_BASE_URL}/api/insta/slates/{slate_id}", timeout=10.0)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def _process_one(client: httpx.AsyncClient, payload: dict) -> None:
|
||||||
|
"""단일 작업 처리: fetch slate → render → webhook."""
|
||||||
|
task_id = payload["task_id"]
|
||||||
|
params = payload.get("params", {})
|
||||||
|
slate_id = params.get("slate_id")
|
||||||
|
theme = params.get("theme", "default")
|
||||||
|
template = f"{theme}/card.html.j2"
|
||||||
|
|
||||||
|
try:
|
||||||
|
await _post_update(client, task_id, "processing", 20)
|
||||||
|
slate = await _fetch_slate(client, slate_id)
|
||||||
|
await _post_update(client, task_id, "processing", 50)
|
||||||
|
paths = await render_slate(slate, slate_id, template=template)
|
||||||
|
# 결과 URL은 첫 페이지의 nginx 경로
|
||||||
|
first_url = f"{INSTA_MEDIA_URL_PREFIX}/{slate_id}/01.png"
|
||||||
|
await _post_update(
|
||||||
|
client, task_id, "succeeded", 100, result_path=first_url
|
||||||
|
)
|
||||||
|
logger.info("rendered task=%s slate=%s count=%d", task_id, slate_id, len(paths))
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("render task=%s 실패", task_id)
|
||||||
|
await _post_update(client, task_id, "failed", 0, error=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
async def worker_loop():
|
||||||
|
"""무한 루프 — paused 체크 → BLPOP → process_one."""
|
||||||
|
redis = aioredis.from_url(REDIS_URL, decode_responses=False)
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
logger.info("insta-render worker started (queue=%s)", QUEUE_KEY)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
paused = await redis.get(PAUSED_KEY)
|
||||||
|
if paused == b"1":
|
||||||
|
await asyncio.sleep(10)
|
||||||
|
continue
|
||||||
|
item = await redis.blpop(QUEUE_KEY, timeout=1)
|
||||||
|
if item is None:
|
||||||
|
continue
|
||||||
|
_, raw = item
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.error("invalid queue payload: %r", raw[:200])
|
||||||
|
continue
|
||||||
|
await _process_one(client, payload)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info("worker_loop cancelled")
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception("worker_loop iteration 실패, 5초 후 재시도")
|
||||||
|
await asyncio.sleep(5)
|
||||||