docs(plan): 인스타 카드뉴스 고도화 구현 plan (6 Phase, 3 repo, TDD)
Phase 1 디자인시스템 템플릿(web-ai+insta-lab) → 2 렌더 견고화(fonts.ready+ PNG검증) → 3 카피 글자수 가이드 → 4 zip 패키지 API → 5 web-ui 버튼 → 6 검증. 템플릿 sync open-item 해결(web-ai templates/ authoritative). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
408
docs/superpowers/plans/2026-06-02-insta-cardnews-upgrade.md
Normal file
408
docs/superpowers/plans/2026-06-02-insta-cardnews-upgrade.md
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
# 인스타 카드뉴스 품질 고도화 + 업로드 친화 패키지 Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 인스타 카드를 모던 미니멀 디자인 시스템으로 격상하고(렌더 견고화로 known-issue 해결), 완성 패키지를 zip으로 받아 인스타에 쉽게 업로드(반자동)할 수 있게 한다.
|
||||||
|
|
||||||
|
**Architecture:** 디자인 시스템 Jinja 템플릿(페이지 타입별 레이아웃)을 web-ai insta-render 워커(authoritative)와 insta-lab(참조 복사본)에 작성. 워커 `card_renderer.py`에 `document.fonts.ready` 대기 + PNG 검증 추가. card_writer 프롬프트에 글자수 가이드. insta-lab에 zip 패키지 API + web-ui 다운로드 버튼. Graph API 미사용(반자동).
|
||||||
|
|
||||||
|
**Tech Stack:** Jinja2 + HTML/CSS, Playwright(Chromium), FastAPI, pytest / React+Vite(web-ui).
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-02-insta-cardnews-upgrade-design.md`
|
||||||
|
|
||||||
|
**⚠️ 3 repo 작업** (커밋·배포 경로 다름):
|
||||||
|
- `web-backend/insta-lab` — git push → Gitea webhook 자동배포 (NAS)
|
||||||
|
- `web-ai/services/insta-render` — **별도 repo(ai-trade.git), Windows 머신 구동** — 워커가 실제 렌더하는 authoritative 템플릿 위치
|
||||||
|
- `web-ui` — **별도 repo**, `npm run release:nas` 수동 배포
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 검증된 컨텍스트
|
||||||
|
- 워커 렌더: `web-ai/services/insta-render/card_renderer.py` — `_build_pages(slate)`가 10 spec 생성(cover page_no=1 / body page_no=2~9 / cta page_no=10, 각 `page_type`/`headline`/`body`/`accent_color`/`cta`/`page_no`/`total_pages`). `CARD_TEMPLATE_DIR`(기본 `/app/templates`)에서 `{theme}/card.html.j2` 로드 → `page.goto(file://, networkidle)` → `screenshot(full_page=False)` @viewport 1080×1350.
|
||||||
|
- 워커 템플릿 실제 위치: `web-ai/services/insta-render/templates/default/card.html.j2` (현재 insta-lab과 동일한 55줄 기본형). **이게 렌더에 쓰이는 authoritative 파일.**
|
||||||
|
- 카피: `insta-lab/app/card_writer.py` `DEFAULT_PROMPT`(DB `slate_writer` 오버라이드 가능). 산출: cover_copy{headline,body,accent_color}/body_copies[8]{headline,body}/cta_copy{headline,body,cta}/suggested_caption/hashtags[].
|
||||||
|
- 슬레이트 PNG: 워커가 `INSTA_MEDIA_ROOT/{slate_id}/{page_no:02d}.png` 저장. NAS에서 `card_assets` 테이블 + `db.list_card_assets(slate_id)`(page_index + 파일경로)로 추적. `GET /api/insta/slates/{id}/assets/{page}`가 단일 PNG 서빙(파일경로 읽어 반환).
|
||||||
|
- 슬레이트 데이터: `db.get_card_slate(slate_id)` + `db.list_card_assets(slate_id)`. `GET /api/insta/slates/{id}`가 slate + assets 반환.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 1 — 모던 미니멀 디자인 시스템 템플릿 (web-ai authoritative + insta-lab 복사본)
|
||||||
|
|
||||||
|
## Task 1.1: 디자인 시스템 card.html.j2 작성
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-ai/services/insta-render/templates/default/card.html.j2` (**렌더 authoritative**)
|
||||||
|
- Modify: `web-backend/insta-lab/app/templates/default/card.html.j2` (참조 복사본 — 동일 내용 유지)
|
||||||
|
|
||||||
|
> 두 파일을 **동일 내용**으로 작성한다. 워커가 web-ai 쪽을 렌더하지만 insta-lab 복사본도 일관성 위해 갱신.
|
||||||
|
|
||||||
|
- [ ] **Step 1: 디자인 시스템 템플릿 작성** — 아래 전체 내용으로 두 파일을 교체:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css');
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
html, body { width: 1080px; height: 1350px; }
|
||||||
|
body {
|
||||||
|
font-family: 'Pretendard', 'Noto Sans KR', sans-serif;
|
||||||
|
background: #F7F7FA; color: #14171A;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
position: relative; width: 1080px; height: 1350px; overflow: hidden;
|
||||||
|
padding: 96px 84px 72px;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
background: #FFFFFF;
|
||||||
|
}
|
||||||
|
.accent-bar { position: absolute; top: 0; left: 0; width: 100%; height: 14px; background: {{ accent_color }}; }
|
||||||
|
.badge {
|
||||||
|
align-self: flex-start; padding: 10px 24px; border-radius: 999px;
|
||||||
|
background: {{ accent_color }}; color: #fff;
|
||||||
|
font-size: 30px; font-weight: 700; letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.idx { font-size: 120px; font-weight: 800; line-height: 1; color: {{ accent_color }}; letter-spacing: -0.04em; }
|
||||||
|
.content { flex: 1; display: flex; flex-direction: column; justify-content: center; gap: 36px; }
|
||||||
|
.headline {
|
||||||
|
font-weight: 800; line-height: 1.18; letter-spacing: -0.04em; color: #14171A;
|
||||||
|
display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden;
|
||||||
|
}
|
||||||
|
.cover .headline { font-size: 104px; -webkit-line-clamp: 4; }
|
||||||
|
.body-page .headline { font-size: 76px; -webkit-line-clamp: 3; }
|
||||||
|
.cta .headline { font-size: 88px; -webkit-line-clamp: 3; }
|
||||||
|
.sub {
|
||||||
|
font-size: 42px; font-weight: 400; line-height: 1.5; color: #3A4047;
|
||||||
|
display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; -webkit-line-clamp: 8;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
font-size: 28px; color: #8A9099; font-weight: 600; margin-top: 40px;
|
||||||
|
}
|
||||||
|
.cta-pill {
|
||||||
|
align-self: flex-start; margin-top: 8px; padding: 18px 40px; border-radius: 16px;
|
||||||
|
background: {{ accent_color }}; color: #fff; font-size: 40px; font-weight: 700;
|
||||||
|
}
|
||||||
|
.progress { display: flex; gap: 10px; }
|
||||||
|
.progress i { width: 14px; height: 14px; border-radius: 50%; background: #D8DCE0; display: inline-block; }
|
||||||
|
.progress i.on { background: {{ accent_color }}; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card {{ 'cover' if page_type=='cover' else ('cta' if page_type=='cta' else 'body-page') }}">
|
||||||
|
<div class="accent-bar"></div>
|
||||||
|
|
||||||
|
{% if page_type == 'cover' %}
|
||||||
|
<span class="badge">{{ category_label|default(headline[:0]) }}{{ '오늘의 이슈' if not category_label }}</span>
|
||||||
|
<div class="content">
|
||||||
|
<h1 class="headline">{{ headline }}</h1>
|
||||||
|
<p class="sub">{{ body }}</p>
|
||||||
|
</div>
|
||||||
|
{% elif page_type == 'cta' %}
|
||||||
|
<div class="content">
|
||||||
|
<h1 class="headline">{{ headline }}</h1>
|
||||||
|
<p class="sub">{{ body }}</p>
|
||||||
|
{% if cta %}<div class="cta-pill">{{ cta }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<span class="idx">{{ '%02d'|format(page_no - 1) }}</span>
|
||||||
|
<div class="content">
|
||||||
|
<h1 class="headline">{{ headline }}</h1>
|
||||||
|
<p class="sub">{{ body }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
{% if page_type == 'cover' or page_type == 'cta' %}
|
||||||
|
<span>{{ brand_handle|default('') }}</span><span>{{ page_no }} / {{ total_pages }}</span>
|
||||||
|
{% else %}
|
||||||
|
<div class="progress">{% for n in range(2, total_pages) %}<i class="{{ 'on' if n <= page_no }}"></i>{% endfor %}</div>
|
||||||
|
<span>{{ page_no }} / {{ total_pages }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
> 디자인 노트: 페이지 타입별 분기(cover 대형 헤드라인+서브+배지 / body 좌상단 인덱스 `01~08`(page_no-1)+헤드라인+본문+진행 점 / cta 요약+CTA pill). `-webkit-line-clamp`로 오버플로우 2차 방어(글자수 가이드가 1차). `accent_color`는 기존 데이터. `brand_handle`은 미설정 시 빈칸(추후 핸들 주입 가능). Pretendard CDN(@import) — Phase 2의 fonts.ready 대기와 짝.
|
||||||
|
|
||||||
|
- [ ] **Step 2: 렌더 스모크 확인 (web-ai)** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render && python -c "from jinja2 import Environment, FileSystemLoader; e=Environment(loader=FileSystemLoader('templates')); t=e.get_template('default/card.html.j2'); [print(pt, len(t.render(page_type=pt, page_no=n, total_pages=10, headline='테스트 헤드라인', body='본문 테스트입니다.', accent_color='#0F62FE', cta='팔로우')) > 0) for pt,n in [('cover',1),('body',3),('cta',10)]]"`
|
||||||
|
Expected: `True` 3줄 (3 페이지 타입 모두 렌더 예외 없음).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit (2 repo 각각)**
|
||||||
|
```bash
|
||||||
|
# web-ai repo
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-ai && git add services/insta-render/templates/default/card.html.j2 && git commit -m "feat(insta-render): 모던 미니멀 디자인 시스템 템플릿"
|
||||||
|
# insta-lab repo (참조 복사본)
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-backend && git add insta-lab/app/templates/default/card.html.j2 && git commit -m "feat(insta-lab): default 템플릿 디자인 시스템 동기화(참조용)"
|
||||||
|
```
|
||||||
|
> 커밋 메시지 trailer 각각에 `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>` 추가.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 2 — 렌더 견고화 (web-ai 워커, known-issue 해결)
|
||||||
|
|
||||||
|
## Task 2.1: fonts.ready 대기 + PNG 비어있음 검증
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-ai/services/insta-render/card_renderer.py` (`_render_slate_locked`)
|
||||||
|
- Test: `web-ai/services/insta-render/tests/test_worker.py` (또는 기존 테스트 파일에 추가)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 실패 테스트** — `tests/test_worker.py`에 추가 (실제 Chromium 렌더 + 검증). 워커 테스트 관례 확인 후 맞출 것; pytest-asyncio 사용 가정:
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
from card_renderer import render_slate, init_browser, shutdown_browser
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_render_produces_nonempty_1080x1350(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr("card_renderer.INSTA_MEDIA_ROOT", str(tmp_path))
|
||||||
|
await init_browser()
|
||||||
|
try:
|
||||||
|
slate = {
|
||||||
|
"cover_copy": {"headline": "헤드라인", "body": "서브", "accent_color": "#0F62FE"},
|
||||||
|
"body_copies": [{"headline": f"포인트{i}", "body": "본문"} for i in range(8)],
|
||||||
|
"cta_copy": {"headline": "요약", "body": "마무리", "cta": "팔로우"},
|
||||||
|
}
|
||||||
|
paths = await render_slate(slate, slate_id=99999)
|
||||||
|
assert len(paths) == 10
|
||||||
|
for p in paths:
|
||||||
|
assert os.path.getsize(p) > 1000 # 비어있지 않음
|
||||||
|
finally:
|
||||||
|
await shutdown_browser()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 실패/현황 확인** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render && python -m pytest tests/test_worker.py::test_render_produces_nonempty_1080x1350 -v`
|
||||||
|
Expected: 현재 코드로도 통과할 수 있으나(렌더 자체는 동작), 폰트/검증 보강 전이므로 FAIL이 아니면 다음 Step에서 검증 로직 추가가 의미를 갖도록 진행. (Playwright/Chromium 미설치 환경이면 `playwright install chromium` 필요 — 안 되면 DONE_WITH_CONCERNS로 보고)
|
||||||
|
|
||||||
|
- [ ] **Step 3: card_renderer 보강** — `_render_slate_locked`의 페이지 루프에서 `page.goto` 직후·`screenshot` 직전에 폰트 대기 추가, screenshot 후 비어있음 검증:
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
await page.goto(f"file://{html_path}", wait_until="networkidle")
|
||||||
|
await page.evaluate("document.fonts.ready") # 웹폰트 로딩 완료까지 대기
|
||||||
|
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)
|
||||||
|
if os.path.getsize(out_path) < 1000: # 빈/깨진 PNG 방어
|
||||||
|
raise RuntimeError(f"rendered PNG too small: {out_path}")
|
||||||
|
paths.append(out_path)
|
||||||
|
finally:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 통과 확인** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render && python -m pytest tests/test_worker.py -v` Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit (web-ai repo)**
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-ai && git add services/insta-render/card_renderer.py services/insta-render/tests/test_worker.py && git commit -m "fix(insta-render): fonts.ready 대기 + PNG 비어있음 검증 (렌더 known-issue 해결)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 3 — 카피 글자수 가이드 (insta-lab)
|
||||||
|
|
||||||
|
## Task 3.1: card_writer 프롬프트에 글자수 상한 추가
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-backend/insta-lab/app/card_writer.py` (`DEFAULT_PROMPT`)
|
||||||
|
- Test: `web-backend/insta-lab/app/test_card_writer_prompt.py` (NEW)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 실패 테스트**
|
||||||
|
|
||||||
|
`insta-lab/app/test_card_writer_prompt.py`:
|
||||||
|
```python
|
||||||
|
from app import card_writer
|
||||||
|
|
||||||
|
def test_default_prompt_has_length_guidance():
|
||||||
|
p = card_writer.DEFAULT_PROMPT
|
||||||
|
# 글자수 가이드가 프롬프트에 포함됐는지
|
||||||
|
assert "22자" in p and "120자" in p
|
||||||
|
# 포맷 placeholder는 유지
|
||||||
|
assert "{category}" in p and "{keyword}" in p and "{articles}" in p
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 실패 확인** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-backend/insta-lab && python -m pytest app/test_card_writer_prompt.py -v` Expected: FAIL
|
||||||
|
|
||||||
|
- [ ] **Step 3: DEFAULT_PROMPT에 가이드 추가** — `DEFAULT_PROMPT` 문자열의 JSON 스키마 안내 뒤(닫는 `}}` 다음)에 글자수 가이드 문단 추가:
|
||||||
|
```python
|
||||||
|
DEFAULT_PROMPT = """너는 인스타그램 카드 뉴스 카피라이터다.
|
||||||
|
카테고리: {category}
|
||||||
|
키워드: {keyword}
|
||||||
|
참고 기사:
|
||||||
|
{articles}
|
||||||
|
|
||||||
|
10페이지 인스타 카드용 카피를 다음 JSON 한 객체로만 출력해라 (코드펜스 금지):
|
||||||
|
{{
|
||||||
|
"cover_copy": {{"headline": "<훅 한 줄>", "body": "<서브카피 1~2줄>", "accent_color": "#hex"}},
|
||||||
|
"body_copies": [
|
||||||
|
{{"headline": "<포인트 헤드라인>", "body": "<2~4문장 본문>"}},
|
||||||
|
... (총 8개)
|
||||||
|
],
|
||||||
|
"cta_copy": {{"headline": "<요약 한 줄>", "body": "<마무리 1~2줄>", "cta": "팔로우/저장 등"}},
|
||||||
|
"suggested_caption": "<인스타 캡션 본문>",
|
||||||
|
"hashtags": ["#태그1", "#태그2", ...]
|
||||||
|
}}
|
||||||
|
|
||||||
|
[글자수 제약 — 카드 디자인 박스에 맞게 반드시 준수]
|
||||||
|
- cover_copy.headline: 22자 이내
|
||||||
|
- body_copies[].headline: 26자 이내
|
||||||
|
- body_copies[].body: 120자 이내 (2~4문장)
|
||||||
|
- cta_copy.headline: 22자 이내
|
||||||
|
초과하면 잘리므로 간결하고 임팩트 있게 작성한다.
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 통과 확인** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-backend/insta-lab && python -m pytest app/test_card_writer_prompt.py -v` Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit (insta-lab)**
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-backend && git add insta-lab/app/card_writer.py insta-lab/app/test_card_writer_prompt.py && git commit -m "feat(insta-lab): card_writer 프롬프트에 글자수 가이드(오버플로우 예방)"
|
||||||
|
```
|
||||||
|
> 주의: 운영 DB에 `slate_writer` prompt_template 오버라이드가 있으면 DEFAULT_PROMPT 대신 그게 쓰임 → 배포 후 필요 시 `PUT /api/insta/templates/prompts/slate_writer`로 동일 가이드 반영(plan §검증에서 안내).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 4 — zip 패키지 다운로드 API (insta-lab)
|
||||||
|
|
||||||
|
## Task 4.1: GET /api/insta/slates/{id}/package
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-backend/insta-lab/app/main.py` (엔드포인트 추가)
|
||||||
|
- Test: `web-backend/insta-lab/app/test_package_api.py` (NEW)
|
||||||
|
|
||||||
|
- [ ] **Step 1: (확인됨) asset 스키마** — `card_assets(slate_id, page_index, file_path, file_hash)`. `db.list_card_assets(slate_id)` → 각 row에 `file_path`·`page_index`. `db.add_card_asset(slate_id, page_index, file_path, file_hash="")`. `db.add_card_slate(row: dict)`. 기존 `/assets/{page}`는 `FileResponse(match["file_path"], media_type="image/png")`. zip 엔드포인트는 동일하게 `a["file_path"]`를 읽는다.
|
||||||
|
|
||||||
|
- [ ] **Step 2: 실패 테스트**
|
||||||
|
|
||||||
|
`insta-lab/app/test_package_api.py`:
|
||||||
|
```python
|
||||||
|
import io, os, tempfile, zipfile, sys
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
def _client(monkeypatch):
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||||
|
from app import config, db
|
||||||
|
tmp = tempfile.mkdtemp()
|
||||||
|
monkeypatch.setattr(config, "INSTA_DATA_PATH", tmp, raising=False)
|
||||||
|
monkeypatch.setattr(db, "DB_PATH", os.path.join(tmp, "insta.db"), raising=False)
|
||||||
|
db.init_db()
|
||||||
|
from app.main import app
|
||||||
|
return TestClient(app), db, tmp
|
||||||
|
|
||||||
|
def test_package_zip_contains_pngs_and_caption(monkeypatch):
|
||||||
|
client, db, tmp = _client(monkeypatch)
|
||||||
|
# 슬레이트 + 2개 asset(실제 PNG 파일) 시드
|
||||||
|
sid = db.add_card_slate({"keyword":"k","category":"economy","status":"rendered",
|
||||||
|
"cover_copy":{"headline":"h"}, "body_copies":[{"headline":"b","body":"x"}]*8,
|
||||||
|
"cta_copy":{}, "suggested_caption":"캡션입니다", "hashtags":["#a","#b"]})
|
||||||
|
cards_dir = os.path.join(tmp, "insta_cards", str(sid)); os.makedirs(cards_dir, exist_ok=True)
|
||||||
|
for pg in (1,2):
|
||||||
|
fp = os.path.join(cards_dir, f"{pg:02d}.png")
|
||||||
|
with open(fp, "wb") as f: f.write(b"\x89PNG\r\n" + b"0"*2000)
|
||||||
|
db.add_card_asset(slate_id=sid, page_index=pg, file_path=fp)
|
||||||
|
r = client.get(f"/api/insta/slates/{sid}/package")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.headers["content-type"] == "application/zip"
|
||||||
|
z = zipfile.ZipFile(io.BytesIO(r.content))
|
||||||
|
names = z.namelist()
|
||||||
|
assert any(n.endswith(".png") for n in names)
|
||||||
|
assert "caption.txt" in names
|
||||||
|
cap = z.read("caption.txt").decode("utf-8")
|
||||||
|
assert "캡션입니다" in cap and "#a" in cap
|
||||||
|
```
|
||||||
|
> `db.add_card_slate`/`add_card_asset`/`list_card_assets`의 실제 시그니처·컬럼명은 db.py 확인 후 맞출 것. asset 경로 컬럼이 `path`가 아니면 테스트·구현 모두 조정.
|
||||||
|
|
||||||
|
- [ ] **Step 3: 실패 확인** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-backend/insta-lab && python -m pytest app/test_package_api.py -v` Expected: FAIL (404)
|
||||||
|
|
||||||
|
- [ ] **Step 4: 엔드포인트 구현** — `insta-lab/app/main.py`에 추가 (`/assets/{page}` 엔드포인트 근처, 동일한 asset 파일경로 접근 방식 사용. `import io, zipfile`은 상단에 추가):
|
||||||
|
```python
|
||||||
|
@app.get("/api/insta/slates/{slate_id}/package")
|
||||||
|
def download_package(slate_id: int):
|
||||||
|
slate = db.get_card_slate(slate_id)
|
||||||
|
if not slate:
|
||||||
|
raise HTTPException(404, "slate not found")
|
||||||
|
assets = sorted(db.list_card_assets(slate_id), key=lambda a: a["page_index"])
|
||||||
|
if not assets:
|
||||||
|
raise HTTPException(409, "아직 렌더된 카드가 없습니다")
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
||||||
|
for a in assets:
|
||||||
|
fp = a["file_path"]
|
||||||
|
if os.path.exists(fp):
|
||||||
|
z.write(fp, arcname=f"{a['page_index']:02d}.png")
|
||||||
|
caption = (slate.get("suggested_caption") or "").strip()
|
||||||
|
tags = slate.get("hashtags") or []
|
||||||
|
if isinstance(tags, str):
|
||||||
|
import json as _json
|
||||||
|
try: tags = _json.loads(tags)
|
||||||
|
except Exception: tags = []
|
||||||
|
caption_full = caption + ("\n\n" + " ".join(tags) if tags else "")
|
||||||
|
z.writestr("caption.txt", caption_full)
|
||||||
|
buf.seek(0)
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
return StreamingResponse(buf, media_type="application/zip", headers={
|
||||||
|
"Content-Disposition": f'attachment; filename="insta_slate_{slate_id}.zip"'})
|
||||||
|
```
|
||||||
|
> `HTTPException`/`os`는 main.py에 이미 import됨. `slate.get("hashtags")`가 JSON 문자열일 수 있어 방어 파싱.
|
||||||
|
|
||||||
|
- [ ] **Step 5: 통과 확인** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-backend/insta-lab && python -m pytest app/test_package_api.py -v` Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit (insta-lab)**
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-backend && git add insta-lab/app/main.py insta-lab/app/test_package_api.py && git commit -m "feat(insta-lab): 슬레이트 zip 패키지 다운로드 API (10 PNG + caption.txt)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 5 — web-ui 패키지 다운로드 버튼 (별도 repo: web-ui)
|
||||||
|
|
||||||
|
## Task 5.1: 슬레이트 상세에 다운로드 버튼
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-ui/src/api.js` (헬퍼)
|
||||||
|
- Modify: insta 카드 페이지 (`web-ui/src/pages/insta/InstaCards.jsx` 또는 슬레이트 상세 컴포넌트)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 구조 확인** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ui && git checkout -b feat/insta-package-download && grep -rln "insta\|슬레이트\|slate" src/pages/insta/ src/api.js 2>/dev/null | head` 로 슬레이트 상세 UI + apiGet 패턴 확인.
|
||||||
|
|
||||||
|
- [ ] **Step 2: api.js 헬퍼 + 다운로드** — `src/api.js`에 패키지 URL 헬퍼 추가(파일 다운로드는 새 탭/anchor로):
|
||||||
|
```javascript
|
||||||
|
export const instaPackageUrl = (slateId) => `/api/insta/slates/${slateId}/package`;
|
||||||
|
```
|
||||||
|
슬레이트 상세 컴포넌트에 버튼 추가 (기존 버튼 스타일 맞춤):
|
||||||
|
```jsx
|
||||||
|
<a className="insta-pkg-btn" href={instaPackageUrl(slate.id)} download>
|
||||||
|
📦 패키지 다운로드 (10장 + 캡션)
|
||||||
|
</a>
|
||||||
|
```
|
||||||
|
> import에 `instaPackageUrl` 추가. 실제 슬레이트 객체의 id 필드명·버튼 클래스는 Step 1 확인 결과에 맞출 것.
|
||||||
|
|
||||||
|
- [ ] **Step 3: 빌드 확인** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ui && npm run build` Expected: exit 0
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit (web-ui repo)**
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-ui && git add src/ && git commit -m "feat: 인스타 슬레이트 패키지 다운로드 버튼"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 6 — 통합 검증
|
||||||
|
|
||||||
|
## Task 6.1: 회귀 + 배포 안내
|
||||||
|
|
||||||
|
- [ ] **Step 1: insta-lab 테스트** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-backend/insta-lab && python -m pytest app/ -q` (Playwright 의존 테스트는 web-ai에만 있음). 신규 통과 + 회귀 없음. (`_shared` import로 main 로드 시 PYTHONPATH 필요하면 test에 sys.path.insert 적용 — Phase 4 test가 이미 처리)
|
||||||
|
- [ ] **Step 2: web-ai 테스트** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render && python -m pytest -q` (Chromium 필요; 미설치 시 `playwright install chromium`).
|
||||||
|
- [ ] **Step 3: 배포 안내** — 3 repo 각각 push/배포:
|
||||||
|
- insta-lab: `git push origin main` → webhook 자동배포(NAS).
|
||||||
|
- web-ai: Windows 머신에서 워커 repo pull + 재시작 (insta-render 서비스). **신규 템플릿이 워커 CARD_TEMPLATE_DIR에 반영돼야 효과 발생.**
|
||||||
|
- web-ui: `npm run release:nas`.
|
||||||
|
- 배포 후 슬레이트 1건 생성 → 카드 PNG 육안 확인(디자인 시스템 적용·폰트 정상) → `/package` zip 다운로드 확인. DB `slate_writer` 오버라이드 존재 시 글자수 가이드 반영.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review 체크리스트 결과
|
||||||
|
- **Spec 커버리지**: 디자인 시스템 템플릿(Task 1.1) / 렌더 견고화 fonts.ready+검증(2.1) / 카피 글자수 가이드(3.1) / zip 패키지(4.1) / web-ui 버튼(5.1) / 검증(6.1). known-issue(폰트·오버플로우)=2.1+템플릿 clamp. 모두 매핑.
|
||||||
|
- **Placeholder**: 모든 코드 step에 실제 코드. db asset 컬럼명·web-ui 슬레이트 필드·워커 테스트 관례는 "Step에서 확인 후 맞춤" 명시(코드베이스 의존, 합리적). brand_handle 기본 빈칸(미설정 허용).
|
||||||
|
- **타입 일관성**: 템플릿이 쓰는 spec 키(page_type/page_no/total_pages/headline/body/accent_color/cta)가 워커 `_build_pages` 산출과 일치. zip 엔드포인트가 쓰는 `list_card_assets`/`get_card_slate`/`suggested_caption`/`hashtags`는 기존 db/슬레이트 스키마와 일치(Step 1에서 asset 경로 컬럼명만 확인).
|
||||||
|
- **3 repo 경로**: 각 Task에 repo별 cd + 커밋 분리 명시.
|
||||||
Reference in New Issue
Block a user