7 Commits

Author SHA1 Message Date
de8adaeadd refactor(agent-office): drop the random idle→break→idle cycle
The pixel-office game UI is gone, so simulating coffee-break /
nap / walk states no longer serves any purpose. Remove:
- scheduler's _check_idle_breaks job (no more 60s idle scan)
- BaseAgent.check_idle_break() and _break_until field
- 'break' from VALID_STATES and from transition() branches
- IDLE_BREAK_THRESHOLD / BREAK_DURATION_MIN / BREAK_DURATION_MAX
  config knobs
- 'idle/break' guard in each agent's on_schedule (now just 'idle')

Agents now sit in 'idle' between scheduled jobs and explicit
commands. Display reads 'Idle' instead of churning between idle
and break.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 08:44:50 +09:00
5cde24115b feat(insta-lab): minimal 테마 card.html.j2 추가 (host repo 영속화)
NAS docker exec로 design_importer minimal 실행한 결과를 컨테이너에서 docker cp로
추출 → host repo에 영속화. 이전엔 컨테이너 ephemeral state라 다음 webhook rebuild에
소실되면서 렌더러가 default 폴백 → 사용자가 본 카드는 minimal 무관.

검증:
- 25,158 bytes, UTF-8 no BOM, <!DOCTYPE 시작
- Jinja parse OK
- background-image 10건, _order.json 순서 일치 (1=start … 10=finish)
- page_no == 분기 10건, 각 페이지 사용자 PNG 정확히 매핑
- Jinja 변수: headline(10), body(9), cta(2), label(4), page_no(1)
2026-05-18 08:03:29 +09:00
318190c93f docs(insta-lab): design_importer는 로컬 실행 권장 — NAS docker exec 시 결과 소실 함정
docker-compose의 insta-lab volume mount는 /app/data만이라 /app/app/templates는
컨테이너 ephemeral state. NAS docker exec로 design_importer 돌리면 card.html.j2가
컨테이너 안에만 생성되고 다음 webhook rebuild에 소실됨 → 렌더러가 default 폴백.

- CLAUDE.md: "실행 위치 — 로컬 권장" 경고 + 로컬 셋업 흐름 + 응급 hotfix docker cp 패턴
- design_importer.py module docstring 동일 내용 반영

PNG 사이즈 1080×1350 → 4:5 비율 권장으로 문서 일치 (이전 검증 완화 반영).
2026-05-18 07:29:55 +09:00
c8684280af feat(insta-lab): minimal theme page_mapping을 _order.json으로 명시
기본 매핑(start→1, cta→10, 나머지 알파벳)으로는 finish.png가 page 3에
배정되는 문제 해결. 카드뉴스 자연스러운 흐름으로 명시:

1. start (인트로)
2. keyword (오늘의 키워드)
3. highlight (핵심 하이라이트)
4. observation (관찰)
5. memo (메모)
6. oneline (한 줄 정리)
7. checklist (체크리스트)
8. study (심화)
9. cta (액션 유도)
10. finish (마감)

다음 design_importer 실행 시 이 매핑이 우선 적용됨.
2026-05-18 00:55:22 +09:00
6895e2f8dc fix(insta-lab): design_importer dimension 검증을 4:5 비율로 완화
운영에서 사용자 디자인이 1122x1402로 작성됨. 1080x1350과 정확히 같은
4:5 종횡비지만 절대 사이즈만 다르므로 정확한 사이즈 강제는 과도.

- 검증: 종횡비 4:5 (±2% tolerance). 1080x1350·1122x1402 등 동일 비율
  높은 해상도 모두 통과.
- Vision은 base64로 원본 분석 (사이즈 무관).
- Playwright는 background-size: cover로 1080x1350 컨테이너에 자동 fit.
- 비율이 깨지면 (예: 1024x1024 정사각) 여전히 reject.

test_validate_images_accepts_higher_resolution_4_5_ratio 신규 (1 case).
2026-05-18 00:42:30 +09:00
34619dc70b fix(insta-lab): add Pillow to requirements.txt (design_importer 의존)
design_importer.py가 1080x1350 이미지 검증을 위해 `from PIL import Image`
사용. 운영 컨테이너에서 ModuleNotFoundError: No module named 'PIL' 발생.

card_renderer는 Playwright만 쓰므로 기존 requirements에 PIL이 없었음.
local pytest는 dev 환경에 Pillow가 이미 설치돼 있어 PASS — 운영 검증
구멍.

Pillow>=10 추가 → 다음 webhook 빌드 시 pip 설치.
2026-05-18 00:33:21 +09:00
47cdc43aa5 Merge pull request 'feat/insta-design-importer' (#7) from feat/insta-design-importer into main
Reviewed-on: #7
2026-05-18 00:28:52 +09:00
12 changed files with 862 additions and 43 deletions

View File

@@ -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 목록**
| 메서드 | 경로 | 설명 |

View File

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

View File

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

View File

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

View File

@@ -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", {})

View File

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

View File

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

View File

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

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

View 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
}

View File

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

View File

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