5단계 AI 파이프라인: 1. Kei 실장(Opus via Kei API) — 꼭지 추출 + 정보 구조 파악 2. 디자인 팀장 — FAISS 블록 검색 + Opus 추천 + Sonnet 블록 매핑 3. Kei 편집자(Kei API) — 도메인 전문 텍스트 정리 4. 디자인 실무자(Sonnet + Jinja2) — CSS 변수 조정 + HTML 조립 5. 디자인 팀장(Sonnet) — 균형 재검토 (최대 2회 루프) 블록 라이브러리 46개 (6 카테고리) + _legacy 13개 FAISS 블록 검색 (bge-m3, 1024차원) SVG N개 동적 배치 (cos/sin 좌표 계산) Pillow 이미지 크기 측정 + base64 인라인 컨테이너 예산 기반 블록 배치 (zone별 높이 px) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
14 KiB
14 KiB
Phase D: 이미지 처리 — 실행 상세
MDX 콘텐츠의 이미지 참조(
)를 감지하고, 로컬 이미지 파일의 크기를 측정하여 배치 판단에 활용. 서버가 localhost에서 돌므로 로컬 파일 접근 가능. 원칙: 하드코딩 금지. 모든 판단은 AI 사고. 회귀 금지.
실행 순서
D-0 (이미지 경로 입력 UI + API) ← 선행 필수
→ D-1 (Pillow 유틸리티 + 이미지 추출)
→ D-2 + D-3 (비율 기반 배치 — 프롬프트에 정보 전달)
→ D-4 (텍스트 포함 도표 축소 방지)
→ D-5 (HTML에 이미지 삽입 — base64 또는 절대 경로)
D-0: 이미지 경로 입력 UI + API 파라미터 (선행 작업)
현재 상태
- static/index.html:176~179 —
fetch('/api/generate', { body: JSON.stringify({ content }) })→ 텍스트만 전송 - src/main.py:35~36 —
SlideRequest모델에content: str만 - src/pipeline.py:27~29 —
generate_slide(content, manual_layout)→ base_path 없음
작업
1) static/index.html — generate() 함수 수정
- 텍스트에서
패턴 감지 (정규식) - 발견 시: "이미지가 포함된 콘텐츠입니다. 이미지 파일이 있는 프로젝트 폴더 경로를 입력해주세요" 팝업 (prompt)
- 미발견 시: base_path 없이 기존처럼 전송
- API 요청 body:
{ content, base_path }(base_path는 선택)
// 이미지 참조 감지
const imagePattern = /!\[.*?\]\((.*?)\)/g;
const hasImages = imagePattern.test(content);
let basePath = '';
if (hasImages) {
basePath = prompt(
'이미지가 포함된 콘텐츠입니다.\n' +
'이미지 파일이 있는 프로젝트 폴더 경로를 입력해주세요.\n' +
'예: D:\\ad-hoc\\kei\\content'
) || '';
}
// API 전송
body: JSON.stringify({ content, base_path: basePath })
2) src/main.py — SlideRequest 모델 확장
class SlideRequest(BaseModel):
content: str
base_path: str = "" # 이미지 기준 폴더 (선택)
3) src/main.py — generate 엔드포인트에서 base_path 전달
async for event in generate_slide(req.content, base_path=req.base_path):
4) src/pipeline.py — generate_slide() 시그니처 확장
async def generate_slide(
content: str,
manual_layout: dict[str, Any] | None = None,
base_path: str = "",
) -> AsyncIterator[dict[str, str]]:
하드코딩 점검
- 없음. 사용자가 경로를 직접 입력. 코드에 경로 고정값 없음 ✅
충돌/회귀
- SlideRequest에 base_path 추가: 기본값
""→ 기존 요청(base_path 없는)과 호환 ✅ - generate_slide() 시그니처에 base_path 추가: 기본값
""→ 기존 호출과 호환 ✅ - index.html generate() 함수: 이미지 없으면 기존과 동일 동작 ✅
수정 파일
static/index.html— generate() 함수src/main.py— SlideRequest + generate 엔드포인트src/pipeline.py— generate_slide() 시그니처
D-1: Pillow 이미지 크기 읽기 유틸리티
현재 상태
- Pillow import/사용 전무. pyproject.toml에도 없음. src/utils/ 디렉토리 없음.
작업
1) pyproject.toml에 Pillow 추가
dependencies = [
...
"Pillow>=10.0",
]
2) src/image_utils.py 신규 제작
"""이미지 크기 측정 유틸리티."""
from pathlib import Path
from PIL import Image
def get_image_sizes(content: str, base_path: str) -> list[dict]:
"""콘텐츠에서 이미지 참조를 추출하고 로컬 파일 크기를 측정한다.
Args:
content: MDX/텍스트 콘텐츠
base_path: 이미지 파일 기준 폴더 경로
Returns:
[{"path": "/assets/images/DX1.png", "width": 800, "height": 600,
"ratio": 1.33, "orientation": "landscape"}]
"""
import re
if not base_path:
return []
base = Path(base_path)
images = []
for match in re.finditer(r'!\[.*?\]\((.*?)\)', content):
rel_path = match.group(1).strip()
# 상대 경로 해석
abs_path = base / rel_path.lstrip('/')
if abs_path.exists() and abs_path.suffix.lower() in ('.png', '.jpg', '.jpeg', '.gif', '.webp'):
try:
with Image.open(abs_path) as img:
w, h = img.size # 헤더만 읽음
ratio = w / h if h > 0 else 1.0
orientation = "landscape" if ratio > 1.2 else ("portrait" if ratio < 0.8 else "square")
images.append({
"path": rel_path,
"width": w,
"height": h,
"ratio": round(ratio, 2),
"orientation": orientation,
})
except Exception:
images.append({"path": rel_path, "width": 0, "height": 0, "ratio": 0, "orientation": "unknown"})
else:
images.append({"path": rel_path, "width": 0, "height": 0, "ratio": 0, "orientation": "not_found"})
return images
하드코딩 점검
- ratio 기준 1.2, 0.8: CLAUDE.md 원문 "가로형(ratio > 1.2)", "세로형(ratio < 0.8)" — 문서 기준값이므로 하드코딩 아님 ✅
- 이미지 확장자 목록: 웹 표준 이미지 포맷. 변할 일 없음 ✅
충돌/회귀
- 신규 파일 추가만. 기존 코드 변경 없음 ✅
- Pillow는
Image.open()시 헤더만 읽음 (전체 디코딩 안 함) → 성능 안전 ✅
수정 파일
pyproject.toml— Pillow 의존성 추가- 신규
src/image_utils.py
D-2 + D-3: 비율 기반 배치 판단
현재 상태
- 비율 기반 배치 판단 코드 없음
- B-4에서 1단계 images[] 필드 추가했지만, 실제 크기 정보는 없음
작업
pipeline.py에서 D-1 유틸리티 호출 → 이미지 크기/비율 정보를 2단계 Step B와 4단계 Sonnet에 전달
# pipeline.py generate_slide() 내, 2단계 전에:
from src.image_utils import get_image_sizes
image_sizes = get_image_sizes(content, base_path)
if image_sizes:
# analysis에 이미지 크기 정보 추가
analysis["image_sizes"] = image_sizes
design_director.py create_layout_concept()에서 user_prompt에 이미지 정보 포함:
# 이미지 크기 정보가 있으면 프롬프트에 포함
if analysis.get("image_sizes"):
image_info = "\n".join(
f"- {img['path']}: {img['width']}×{img['height']}px, {img['orientation']}"
for img in analysis["image_sizes"]
)
user_prompt += f"\n\n## 이미지 크기 정보\n{image_info}\n"
→ Sonnet(팀장)이 이 정보를 보고 "가로형 → 전체 너비", "세로형 → 텍스트 옆" 등을 판단.
하드코딩 점검
- 비율 판단: AI(팀장)가 orientation 정보를 보고 결정 ✅
- 코드는 크기 정보를 전달만. 배치 결정은 AI ✅
충돌/회귀
- pipeline.py: 기존 흐름 사이에 이미지 측정 삽입. base_path 없으면 빈 리스트 → 영향 없음 ✅
- design_director.py: user_prompt에 텍스트 추가만. 이미지 없으면 추가 안 함 ✅
- analysis에 image_sizes 추가: 기존 코드는
.get()패턴 → 안전 ✅
수정 파일
src/pipeline.py— generate_slide()에서 get_image_sizes() 호출src/design_director.py— create_layout_concept()에서 이미지 정보 프롬프트 포함
D-4: 텍스트 포함 도표 → 과도한 축소 방지
현재 상태
- B-4에서 images[].has_text 필드 추가 (1단계 Kei가 판단)
- 이 정보를 기반으로 "축소하지 마라" 가이드 없음
작업
D-2/D-3의 이미지 정보 전달 시, has_text 정보도 함께 포함:
# pipeline.py에서 1단계 analysis의 images[]와 D-1의 image_sizes를 결합
for img_size in image_sizes:
# 1단계에서 판단한 has_text 정보 매칭
for kei_img in analysis.get("images", []):
if kei_img.get("description", "") in img_size.get("path", ""):
img_size["has_text"] = kei_img.get("has_text", False)
→ Sonnet 프롬프트에 "has_text: true인 이미지는 텍스트가 포함된 도표이므로 과도하게 축소하지 마라" 가이드
하드코딩 점검
- 축소 여부: AI가 판단 ✅
- has_text: 1단계 Kei가 판단 ✅
충돌/회귀
- 기존 데이터에 필드 추가만. 없으면 무시됨 ✅
수정 파일
src/pipeline.py
D-5: 슬라이드 HTML에 이미지 삽입
현재 상태
- 이미지 블록(image-row, image-side-text 등)은
{{ img.src }}슬롯에 경로를 넣지만 - 다운로드 HTML에서 상대 경로는 깨짐 (로컬 파일이므로)
작업
렌더링 완료 후, HTML의 이미지 src를 base64 data URI로 변환:
# renderer.py 또는 pipeline.py에서 최종 HTML 후처리
import base64, re
def embed_images(html: str, base_path: str) -> str:
"""HTML의 이미지 src를 base64 data URI로 변환."""
if not base_path:
return html
base = Path(base_path)
def replace_src(match):
src = match.group(1)
abs_path = base / src.lstrip('/')
if abs_path.exists():
mime = 'image/png' if abs_path.suffix == '.png' else 'image/jpeg'
data = base64.b64encode(abs_path.read_bytes()).decode()
return f'src="data:{mime};base64,{data}"'
return match.group(0)
return re.sub(r'src="(/[^"]+\.(?:png|jpg|jpeg|gif|webp))"', replace_src, html)
하드코딩 점검
- MIME 타입: 확장자 기반 표준 매핑. 하드코딩 아님 ✅
- 이미지 확장자: D-1과 동일 목록 ✅
충돌/회귀
- 최종 HTML 후처리. 이미지 없으면 정규식 매칭 없음 → 기존과 동일 ✅
- base_path 없으면 함수 즉시 반환 → 안전 ✅
수정 파일
src/image_utils.py(embed_images 함수 추가) 또는src/pipeline.py
수정 파일 총괄
| 파일 | 항목 | 변경 성격 |
|---|---|---|
static/index.html |
D-0 | generate() 함수에 이미지 감지 + 경로 입력 팝업 |
src/main.py |
D-0 | SlideRequest에 base_path 추가 + 엔드포인트 전달 |
src/pipeline.py |
D-0, D-2~D-4 | generate_slide() 시그니처 + 이미지 크기 측정 + 프롬프트 전달 |
pyproject.toml |
D-1 | Pillow 의존성 추가 |
신규 src/image_utils.py |
D-1, D-5 | get_image_sizes() + embed_images() |
src/design_director.py |
D-2, D-3 | user_prompt에 이미지 크기/비율 정보 포함 |
검증 체크리스트
- D-0: 이미지 없는 텍스트 → 팝업 안 뜸. 기존과 동일 동작
- D-0: 이미지 있는 MDX → 팝업 뜨고 경로 입력 → API에 base_path 전달
- D-0: 팝업에서 취소 → base_path="" → 이미지 처리 스킵 (에러 없음)
- D-1: Pillow로 이미지 크기 측정. 파일 없으면 width=0, orientation="not_found"
- D-2/D-3: Sonnet 프롬프트에 이미지 크기/orientation 정보 포함
- D-4: has_text=true 이미지에 대해 "축소 금지" 가이드 전달
- D-5: 다운로드 HTML에서 이미지가 base64로 삽입되어 보임
- 이미지 없는 기존 콘텐츠: 전체 파이프라인 기존과 동일하게 동작 (회귀 없음)
수정 이력
| 날짜 | 내용 |
|---|---|
| 2026-03-25 | 초안. D-0(선행 UI) 추가. D-5(HTML 이미지 삽입) 추가. 6개 항목. |
| 2026-03-25 | Phase D 전체 구현 완료. 검증 통과. |
구현 완료 확인
| 항목 | 검증 결과 |
|---|---|
| D-0 | SlideRequest에 base_path="" 기본값 추가. generate_slide()에 base_path 파라미터 추가. index.html에 이미지 감지+팝업. 기존 요청(base_path 없는) 호환 ✅ |
| D-1 | pyproject.toml에 Pillow>=10.0 추가. src/image_utils.py 신규 (get_image_sizes + embed_images). Pillow 10.4.0 설치 확인. base_path 없으면 빈 리스트 반환(안전) |
| D-2+D-3 | pipeline.py에서 get_image_sizes() 호출 → analysis["image_sizes"] 저장. design_director.py user_prompt에 이미지 크기/orientation/배치 가이드 포함. 이미지 없으면 추가 안 함(기존 동일) |
| D-4 | design_director.py에서 has_text=true 이미지에 "(텍스트 포함 도표 — 과도한 축소 금지)" 가이드 자동 추가 |
| D-5 | pipeline.py에서 최종 HTML 반환 전 embed_images(html, base_path) 호출. base_path 없으면 원본 그대로 반환(안전) |
D-0 구현 결과
src/main.py: SlideRequest에base_path: str = ""추가. generate 엔드포인트에서base_path=req.base_path전달.src/pipeline.py: generate_slide() 시그니처에base_path: str = ""추가.static/index.html: generate() 함수에서패턴 감지 → prompt()로 경로 입력 → API에{ content, base_path }전송. 취소 시base_path=""→ 이미지 처리 스킵.
D-1 구현 결과
pyproject.toml:"Pillow>=10.0"의존성 추가.src/image_utils.py신규:get_image_sizes(content, base_path): MDX에서추출 → base_path + 상대경로 해석 → PillowImage.open().size→ {path, width, height, ratio, orientation} 반환embed_images(html, base_path): HTML의src="/...png"→src="data:image/png;base64,..."변환- 파일 미발견/에러 시 orientation="not_found"/"error" → 에러 없이 계속 진행
D-2+D-3+D-4 구현 결과
src/pipeline.py: 1단계 완료 직후get_image_sizes()호출 →analysis["image_sizes"]저장src/design_director.py: user_prompt에 이미지 정보 섹션 추가 — 각 이미지의 크기/orientation + "가로형 → 전체 너비", "세로형 → 텍스트 옆", "텍스트 포함 도표 → 축소 금지" 가이드- has_text 정보는 1단계 Kei의 images[].has_text와 D-1 크기 정보가 결합됨
D-5 구현 결과
src/pipeline.py: yield result 직전에embed_images(html, base_path)호출- base_path 없으면 원본 HTML 그대로 반환 (기존과 동일)