Compare commits
7 Commits
feat/insta
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| de8adaeadd | |||
| 5cde24115b | |||
| 318190c93f | |||
| c8684280af | |||
| 6895e2f8dc | |||
| 34619dc70b | |||
| 47cdc43aa5 |
20
CLAUDE.md
20
CLAUDE.md
@@ -484,16 +484,30 @@ docker compose up -d
|
||||
- `agent_config.custom_config.auto_select=True`이면 카테고리당 1위 키워드 자동 슬레이트 생성·발송
|
||||
|
||||
**디자인 import (사용자 디자인 PNG → Claude Vision → Jinja HTML 자동 생성)**
|
||||
- `insta-lab/app/templates/<theme>/pages/*.png` (10장, 1080×1350, placeholder 텍스트 박혀있는 형태) → Claude Sonnet Vision → `templates/<theme>/card.html.j2` 자동 생성
|
||||
- CLI: `docker exec insta-lab python -m app.design_importer <theme>`
|
||||
- `insta-lab/app/templates/<theme>/pages/*.png` (10장, 4:5 비율 권장 1080×1350, placeholder 텍스트 박혀있는 형태) → Claude Sonnet Vision → `templates/<theme>/card.html.j2` 자동 생성
|
||||
- 파일명 자동 매핑: `cover`/`start`/`intro` → page 1, `cta`/`outro`/`finish`/`end` → page 10, 나머지 알파벳 순 → page 2~9
|
||||
- 매핑 override: `pages/_order.json`에 `{filename: page_no}` 명시 (10장 + page 1~10 완전 매핑일 때만 적용)
|
||||
- Vision prompt에 placeholder 마스킹 요구 포함 (2-layer: 마스킹 박스 + 동적 텍스트 layer)
|
||||
- 기존 HTML 자동 백업 (`card.html.j2.bak.YYYYMMDD-HHMMSS`)
|
||||
- Jinja 문법 깨진 응답은 `card.html.j2.error.txt`로 보존 + ValueError
|
||||
- 활성화: NAS `.env`에 `INSTA_DEFAULT_THEME=<theme>` 추가 + `docker compose restart insta-lab`
|
||||
- 활성화: `.env`에 `INSTA_DEFAULT_THEME=<theme>` 추가 + `docker compose restart insta-lab` (테마 디렉토리에 `card.html.j2` 없으면 렌더러가 default로 폴백)
|
||||
- 토큰 비용: 1회당 ~15K tokens (~$0.05 Sonnet 기준)
|
||||
|
||||
**⚠️ 실행 위치 — 로컬 권장, NAS docker exec 금지**
|
||||
- docker-compose의 insta-lab volume은 `/app/data`만 마운트. **`/app/app/templates`는 컨테이너 ephemeral state**.
|
||||
- NAS에서 `docker exec insta-lab python -m app.design_importer <theme>`로 돌리면 `card.html.j2`가 컨테이너 안에만 생성되고 다음 image rebuild(다른 push의 webhook이라도) 때 사라짐 → 렌더러가 default로 폴백.
|
||||
- **로컬 실행** (host repo working tree에 영속화 → git push → 자동 배포):
|
||||
```bash
|
||||
cd insta-lab
|
||||
pip install anthropic Pillow jinja2 # 이미 있으면 skip
|
||||
export ANTHROPIC_API_KEY=sk-ant-...
|
||||
python -m app.design_importer <theme> --templates-dir ./app/templates
|
||||
git add app/templates/<theme>/card.html.j2
|
||||
git commit -m "feat(insta-lab): <theme> 디자인 import"
|
||||
git push # → Gitea webhook → NAS rebuild → 영구 활성화
|
||||
```
|
||||
- 응급 hotfix로 NAS에서 돌렸다면 `docker cp insta-lab:/app/app/templates/<theme>/card.html.j2 ./` 후 즉시 host repo에 commit + push 필요
|
||||
|
||||
**insta-lab API 목록**
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import asyncio
|
||||
import random
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from ..config import IDLE_BREAK_THRESHOLD, BREAK_DURATION_MIN, BREAK_DURATION_MAX
|
||||
from ..db import add_log
|
||||
|
||||
VALID_STATES = ("idle", "working", "waiting", "reporting", "break")
|
||||
VALID_STATES = ("idle", "working", "waiting", "reporting")
|
||||
|
||||
class BaseAgent:
|
||||
agent_id: str = ""
|
||||
@@ -14,7 +11,6 @@ class BaseAgent:
|
||||
state: str = "idle"
|
||||
state_detail: str = ""
|
||||
_idle_since: float = 0.0
|
||||
_break_until: float = 0.0
|
||||
_ws_manager = None
|
||||
|
||||
def __init__(self):
|
||||
@@ -32,9 +28,6 @@ class BaseAgent:
|
||||
|
||||
if new_state == "idle":
|
||||
self._idle_since = time.time()
|
||||
elif new_state == "break":
|
||||
duration = random.randint(BREAK_DURATION_MIN, BREAK_DURATION_MAX)
|
||||
self._break_until = time.time() + duration
|
||||
|
||||
add_log(self.agent_id, f"State: {old} -> {new_state} ({detail})")
|
||||
|
||||
@@ -48,19 +41,6 @@ class BaseAgent:
|
||||
await self._ws_manager.send_notification(
|
||||
self.agent_id, "task_completed", task_id, detail or "작업 완료"
|
||||
)
|
||||
if new_state == "break":
|
||||
await self._ws_manager.send_agent_move(self.agent_id, "break_room")
|
||||
elif old == "break" and new_state == "idle":
|
||||
await self._ws_manager.send_agent_move(self.agent_id, "desk")
|
||||
|
||||
async def check_idle_break(self) -> None:
|
||||
now = time.time()
|
||||
if self.state == "idle" and (now - self._idle_since) > IDLE_BREAK_THRESHOLD:
|
||||
if random.random() < 0.5:
|
||||
break_type = random.choice(["커피 타임", "잠깐 산책", "졸고 있음"])
|
||||
await self.transition("break", break_type)
|
||||
elif self.state == "break" and now > self._break_until:
|
||||
await self.transition("idle", "휴식 완료")
|
||||
|
||||
async def on_schedule(self) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -46,7 +46,7 @@ class InstaAgent(BaseAgent):
|
||||
async def on_schedule(self) -> None:
|
||||
"""09:30 매일: 뉴스 수집 → 키워드 추출 → 텔레그램 후보 푸시.
|
||||
custom_config.auto_select=True면 카테고리당 1위 키워드 자동 슬레이트 생성."""
|
||||
if self.state not in ("idle", "break"):
|
||||
if self.state != "idle":
|
||||
return
|
||||
config = get_agent_config(self.agent_id) or {}
|
||||
custom = config.get("custom_config", {}) or {}
|
||||
|
||||
@@ -8,7 +8,7 @@ class LottoAgent(BaseAgent):
|
||||
display_name = "로또 큐레이터"
|
||||
|
||||
async def on_schedule(self) -> None:
|
||||
if self.state not in ("idle", "break"):
|
||||
if self.state != "idle":
|
||||
return
|
||||
await self._run(source="auto")
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ class StockAgent(BaseAgent):
|
||||
display_name = "주식 트레이더"
|
||||
|
||||
async def on_schedule(self) -> None:
|
||||
if self.state not in ("idle", "break"):
|
||||
if self.state != "idle":
|
||||
return
|
||||
|
||||
task_id = create_task(self.agent_id, "news_summary", {"limit": 15})
|
||||
@@ -129,7 +129,7 @@ class StockAgent(BaseAgent):
|
||||
4) status=='success' → telegram_payload.text 를 parse_mode 그대로 전송
|
||||
5) 예외/실패 → 운영자에게 별도 텔레그램 알림 (HTML)
|
||||
"""
|
||||
if self.state not in ("idle", "break"):
|
||||
if self.state != "idle":
|
||||
return
|
||||
|
||||
task_id = create_task(self.agent_id, "screener_run", {"mode": "auto"})
|
||||
@@ -243,7 +243,7 @@ class StockAgent(BaseAgent):
|
||||
4) failures > 30% → 경고 알림 후 메인 메시지 발송
|
||||
5) 정상 → Top 5 호재/악재 메시지 발송 (MarkdownV2)
|
||||
"""
|
||||
if self.state not in ("idle", "break"):
|
||||
if self.state != "idle":
|
||||
return
|
||||
|
||||
task_id = create_task(self.agent_id, "ai_news_sentiment", {})
|
||||
|
||||
@@ -26,11 +26,6 @@ CORS_ALLOW_ORIGINS = os.getenv(
|
||||
"CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080"
|
||||
)
|
||||
|
||||
# Idle break threshold (seconds)
|
||||
IDLE_BREAK_THRESHOLD = int(os.getenv("IDLE_BREAK_THRESHOLD", "300")) # 5 min
|
||||
BREAK_DURATION_MIN = int(os.getenv("BREAK_DURATION_MIN", "60")) # 1 min
|
||||
BREAK_DURATION_MAX = int(os.getenv("BREAK_DURATION_MAX", "180")) # 3 min
|
||||
|
||||
# Lotto Curator
|
||||
LOTTO_BACKEND_URL = os.getenv("LOTTO_BACKEND_URL", "http://lotto:8000")
|
||||
LOTTO_CURATOR_MODEL = os.getenv("LOTTO_CURATOR_MODEL", "claude-sonnet-4-5")
|
||||
|
||||
@@ -5,10 +5,6 @@ from .agents import AGENT_REGISTRY
|
||||
|
||||
scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
|
||||
|
||||
async def _check_idle_breaks():
|
||||
for agent in AGENT_REGISTRY.values():
|
||||
await agent.check_idle_break()
|
||||
|
||||
async def _run_stock_schedule():
|
||||
agent = AGENT_REGISTRY.get("stock")
|
||||
if agent:
|
||||
@@ -78,6 +74,5 @@ def init_scheduler():
|
||||
scheduler.add_job(_run_lotto_schedule, "cron", day_of_week="mon", hour=9, minute=0, id="lotto_curate")
|
||||
scheduler.add_job(_run_youtube_research, "cron", hour=9, minute=0, id="youtube_research")
|
||||
scheduler.add_job(_send_youtube_weekly_report, "cron", day_of_week="mon", hour=8, minute=0, id="youtube_weekly_report")
|
||||
scheduler.add_job(_check_idle_breaks, "interval", seconds=60, id="idle_check")
|
||||
scheduler.add_job(_poll_pipelines, "interval", seconds=30, id="pipeline_poll")
|
||||
scheduler.start()
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
"""사용자 디자인 PNG 10장 → Claude Sonnet Vision → Jinja card.html.j2 자동 생성.
|
||||
|
||||
CLI (이 phase 이후 추가): python -m app.design_importer <theme_name>
|
||||
⚠️ 실행 위치 — 로컬 권장:
|
||||
docker-compose의 insta-lab volume은 /app/data만 마운트. /app/app/templates는
|
||||
컨테이너 ephemeral이라 NAS docker exec로 돌리면 다음 rebuild에 결과물 소실됨.
|
||||
|
||||
로컬:
|
||||
cd insta-lab
|
||||
export ANTHROPIC_API_KEY=sk-ant-...
|
||||
python -m app.design_importer <theme> --templates-dir ./app/templates
|
||||
git add app/templates/<theme>/card.html.j2 && git commit + push
|
||||
|
||||
응급 hotfix만 NAS:
|
||||
docker exec insta-lab python -m app.design_importer <theme>
|
||||
docker cp insta-lab:/app/app/templates/<theme>/card.html.j2 ./<dst>
|
||||
# → 즉시 host repo에 commit + push (안 그러면 다음 rebuild에 소실)
|
||||
"""
|
||||
|
||||
import base64
|
||||
@@ -102,8 +115,16 @@ def _build_mapping(pngs: List[str]) -> Dict[str, int]:
|
||||
return mapping
|
||||
|
||||
|
||||
_EXPECTED_RATIO = 1080 / 1350 # 4:5 = 0.8
|
||||
_RATIO_TOLERANCE = 0.02 # ±2% (1122/1402 ≈ 0.80028도 통과)
|
||||
|
||||
|
||||
def _validate_images(pages_dir: Path) -> None:
|
||||
"""모든 PNG가 정확히 1080×1350인지 검증. 다르면 ValueError.
|
||||
"""모든 PNG가 4:5 종횡비(1080x1350 권장)에 가까운지 검증.
|
||||
|
||||
Vision은 base64로 원본을 분석하고 Playwright는 background-size: cover로
|
||||
1080x1350 컨테이너에 fit하므로 절대 사이즈는 유연. 단 종횡비가 어긋나면
|
||||
카드가 늘어나거나 잘리므로 ±2% 허용 범위 내에서만 통과.
|
||||
|
||||
early-exit 하지 않고 전체 파일을 검사한 뒤 한 메시지에 모아 raise.
|
||||
"""
|
||||
@@ -111,12 +132,17 @@ def _validate_images(pages_dir: Path) -> None:
|
||||
bad = []
|
||||
for png_path in sorted(pages_dir.glob("*.png")):
|
||||
with Image.open(png_path) as img:
|
||||
if img.size != _EXPECTED_SIZE:
|
||||
w, h = img.size
|
||||
if h == 0:
|
||||
bad.append((png_path.name, img.size))
|
||||
continue
|
||||
ratio = w / h
|
||||
if abs(ratio - _EXPECTED_RATIO) > _RATIO_TOLERANCE:
|
||||
bad.append((png_path.name, img.size))
|
||||
if bad:
|
||||
msg = "; ".join(f"{n}: {s[0]}x{s[1]}" for n, s in bad)
|
||||
raise ValueError(
|
||||
f"모든 카드 디자인은 1080x1350이어야 함. 잘못된 파일: {msg}"
|
||||
f"카드 디자인은 4:5 비율(1080x1350 권장)이어야 함. 잘못된 파일: {msg}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
788
insta-lab/app/templates/minimal/card.html.j2
Normal file
788
insta-lab/app/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
insta-lab/app/templates/minimal/pages/_order.json
Normal file
12
insta-lab/app/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
|
||||
}
|
||||
@@ -5,5 +5,6 @@ httpx>=0.27
|
||||
anthropic==0.52.0
|
||||
jinja2>=3.1.4
|
||||
playwright==1.48.0
|
||||
Pillow>=10
|
||||
pytest>=8.0
|
||||
pytest-asyncio>=0.24
|
||||
|
||||
@@ -86,6 +86,14 @@ def _make_png(path: Path, size: tuple[int, int]) -> None:
|
||||
Image.new("RGB", size, color=(200, 200, 200)).save(path, format="PNG")
|
||||
|
||||
|
||||
def test_validate_images_accepts_higher_resolution_4_5_ratio(tmp_theme):
|
||||
"""1080x1350 외에도 같은 4:5 비율이면 통과 (예: 1122x1402, 디자인 도구 export 흔한 사이즈)."""
|
||||
pages = tmp_theme / "pages"
|
||||
for i in range(10):
|
||||
_make_png(pages / f"insta_card_{i:02d}.png", (1122, 1402))
|
||||
design_importer._validate_images(pages) # 예외 없으면 통과
|
||||
|
||||
|
||||
def test_validate_images_accepts_1080x1350(tmp_theme):
|
||||
pages = tmp_theme / "pages"
|
||||
for i in range(10):
|
||||
|
||||
Reference in New Issue
Block a user