# 에반/바이브랩스 영상 스타일 구현 Phase 1 — 코어 엔진

## 작업 레벨: Lv.3

## 배경
기존 `video/` 모듈은 **정적 이미지 슬라이드쇼** (PNG → 전환 효과 → MP4).
메이커 에반/바이브랩스 스타일은 **동적 장면 렌더링** — 검은 배경 위에 텍스트/아이콘이 애니메이션으로 등장.
완전히 다른 렌더링 파이프라인이 필요.

## 참조 영상 스타일 분석

### 메이커 에반 스타일
- 검은/어두운 배경 (RGB 15,15,15)
- 기본 텍스트: 흰색, 강조: 녹색(#00DC64) / 노란색(#FFD700)
- **핵심 기법: 순차 페이드인 (Sequential Fade-in)**
  - 같은 장면 안에서 요소들이 하나씩 순서대로 등장
  - 텍스트 타이핑 효과 (글자가 하나씩 나타남)
  - 아이콘 fade+scale (0.8→1.0, 0.3~0.5초)
- 프로그레스바 채우기 (0→목표값)
- 카운터 롤업 (숫자가 올라감)
- 하단 자막 (TTS와 동기화)
- 느린 템포 (장면당 5~8초)

### 바이브랩스 스타일
- 어두운 배경 (RGB 20,20,30)
- 기본 텍스트: 흰색, 강조: 민트(#00E6B4) / 오렌지(#FF6432)
- **장면 번호 표시** (01, 02, 03...)
- 큰 중앙 텍스트 (한번에 fade-in, 타이핑 아님)
- 체크리스트 카드 (항목 순차 등장)
- 채팅 버블 UI
- 리스트 카드 (아이콘 + 텍스트)
- 빠른 템포 (장면당 3~5초)

## Phase 1 범위: 새로 만들 파일 5개

### 1. `video/styles.py` — 스타일 프리셋

```python
"""영상 스타일 프리셋 정의."""
from __future__ import annotations

EVAN_STYLE = {
    "name": "evan",
    "bg_color": (15, 15, 15),
    "primary_color": (255, 255, 255),       # 기본 텍스트
    "accent_color": (0, 220, 100),           # 녹색 강조
    "accent2_color": (255, 215, 0),          # 노란색 강조
    "subtitle_bg": (0, 0, 0, 180),           # 자막 배경 반투명
    "font_size_title": 72,
    "font_size_body": 48,
    "font_size_label": 36,
    "font_size_subtitle": 40,
    "typing_speed": 12.0,       # 글자/초 (에반 특징: 타이핑)
    "element_delay": 0.8,       # 요소 간 등장 간격(초)
    "fade_duration": 0.5,       # 요소 페이드인 시간
    "scene_padding": 80,        # 장면 여백(px)
    "scene_hold": 2.0,          # 모든 요소 등장 후 유지 시간
    "show_scene_number": False,
    "width": 1080,
    "height": 1920,
    "fps": 30,
}

VIBELABS_STYLE = {
    "name": "vibelabs",
    "bg_color": (20, 20, 30),
    "primary_color": (255, 255, 255),
    "accent_color": (0, 230, 180),           # 민트 강조
    "accent2_color": (255, 100, 50),         # 오렌지 강조
    "subtitle_bg": (0, 0, 0, 200),
    "font_size_title": 80,
    "font_size_body": 44,
    "font_size_label": 32,
    "font_size_subtitle": 38,
    "typing_speed": 0,          # 타이핑 안 함 (한번에 등장)
    "element_delay": 0.5,       # 요소 간 등장 간격 (빠름)
    "fade_duration": 0.3,       # 요소 페이드인 시간 (빠름)
    "scene_padding": 60,
    "scene_hold": 1.5,
    "show_scene_number": True,  # 장면 번호 표시
    "width": 1080,
    "height": 1920,
    "fps": 30,
}

STYLES = {"evan": EVAN_STYLE, "vibelabs": VIBELABS_STYLE}

def get_style(name: str) -> dict:
    """스타일명으로 프리셋 반환. KeyError if not found."""
    return STYLES[name]
```

### 2. `video/animations.py` — 프레임별 애니메이션 함수

PIL로 프레임(numpy array)을 렌더링하는 순수 함수들.
모든 함수는 **투명 배경 RGBA numpy array 리스트**를 반환.
(나중에 SceneRenderer가 배경 위에 합성)

```python
"""프레임별 애니메이션 렌더링 함수들.

모든 함수는 list[np.ndarray] (RGBA frames)를 반환한다.
"""
from __future__ import annotations
import numpy as np
from PIL import Image, ImageDraw, ImageFont

FONT_PATH = "/home/jay/.local/share/fonts/NotoSansCJKkr-Bold.otf"
FONT_PATH_REGULAR = "/home/jay/.local/share/fonts/NotoSansCJKkr-Regular.otf"


def render_typing_frames(
    text: str,
    canvas_size: tuple[int, int],  # (width, height)
    position: tuple[int, int],     # (x, y) 텍스트 시작 좌표 (좌상단)
    font_size: int = 48,
    color: tuple[int, int, int] = (255, 255, 255),
    fps: int = 30,
    chars_per_second: float = 12.0,
    hold_seconds: float = 1.0,
    max_width: int | None = None,  # 자동 줄바꿈 최대 폭
) -> list[np.ndarray]:
    """텍스트 타이핑 애니메이션.

    글자를 하나씩 추가하며 프레임을 생성한다.
    hold_seconds 동안 전체 텍스트를 유지한다.

    자동 줄바꿈: max_width가 주어지면 해당 폭을 초과할 때 줄바꿈.

    Returns:
        RGBA numpy array 리스트. 각 프레임은 canvas_size 크기.
    """


def render_fade_in_text_frames(
    text: str,
    canvas_size: tuple[int, int],
    position: tuple[int, int],
    font_size: int = 48,
    color: tuple[int, int, int] = (255, 255, 255),
    fps: int = 30,
    duration: float = 0.5,
    hold_seconds: float = 1.0,
    max_width: int | None = None,
) -> list[np.ndarray]:
    """텍스트 페이드인 (바이브랩스 스타일 - 한번에 등장).

    투명도 0→255로 점진적 등장.
    """


def render_counter_frames(
    start_value: int,
    end_value: int,
    canvas_size: tuple[int, int],
    position: tuple[int, int],
    font_size: int = 72,
    color: tuple[int, int, int] = (0, 220, 100),
    fps: int = 30,
    duration: float = 2.0,
    hold_seconds: float = 1.0,
    prefix: str = "",
    suffix: str = "",
    use_comma: bool = True,    # 천단위 쉼표
) -> list[np.ndarray]:
    """숫자 카운터 롤업 애니메이션.

    start_value에서 end_value까지 숫자가 올라가는 효과.
    easeOutCubic 이징 적용 (처음 빠르고 끝에 느려짐).
    """


def render_progress_bar_frames(
    target_ratio: float,  # 0.0 ~ 1.0
    canvas_size: tuple[int, int],
    bar_rect: tuple[int, int, int, int],  # (x, y, width, height)
    bg_color: tuple[int, int, int] = (60, 60, 60),
    fill_color: tuple[int, int, int] = (0, 220, 100),
    fps: int = 30,
    duration: float = 1.5,
    hold_seconds: float = 1.0,
    corner_radius: int = 10,
    label: str | None = None,   # 바 위에 표시할 라벨 (예: "85%")
    label_color: tuple[int, int, int] = (255, 255, 255),
    label_font_size: int = 32,
) -> list[np.ndarray]:
    """프로그레스바 채우기 애니메이션.

    bar_rect 영역에 배경 바를 그리고, 0→target_ratio까지 채워나감.
    easeOutCubic 이징 적용.
    """


def render_checklist_frames(
    items: list[str],
    canvas_size: tuple[int, int],
    start_position: tuple[int, int],  # 첫 번째 항목 (x, y)
    item_height: int = 80,            # 항목 간 간격
    font_size: int = 40,
    color: tuple[int, int, int] = (255, 255, 255),
    check_color: tuple[int, int, int] = (0, 220, 100),
    fps: int = 30,
    item_delay: float = 0.5,    # 항목 간 등장 딜레이
    fade_duration: float = 0.3,
    hold_seconds: float = 1.5,
) -> list[np.ndarray]:
    """체크리스트 항목 순차 등장 애니메이션.

    체크마크(✓) + 텍스트가 하나씩 페이드인.
    """
```

**이징 함수** (animations.py 내부):
```python
def _ease_out_cubic(t: float) -> float:
    """easeOutCubic: 처음 빠르고 끝에 느려짐."""
    return 1 - (1 - t) ** 3
```

### 3. `video/scene_renderer.py` — 장면 렌더러

장면(scene dict) 하나를 받아 프레임 시퀀스를 생성.

```python
"""장면별 프레임 시퀀스 렌더러.

scene dict → list[np.ndarray] (RGB frames, 배경 합성 완료)
"""
from __future__ import annotations
import numpy as np
from PIL import Image
from video.animations import (
    render_typing_frames,
    render_fade_in_text_frames,
    render_counter_frames,
    render_progress_bar_frames,
    render_checklist_frames,
)
from video.styles import get_style


class SceneRenderer:
    """스타일 프리셋 기반 장면 렌더러."""

    def __init__(self, style_name: str = "evan"):
        self.style = get_style(style_name)
        self.width = self.style["width"]
        self.height = self.style["height"]
        self.fps = self.style["fps"]

    def render_scene(self, scene: dict) -> list[np.ndarray]:
        """장면 하나를 프레임 배열로 렌더링.

        scene 구조:
        {
            "type": "hook" | "info" | "data" | "checklist" | "cta",
            "elements": [
                {
                    "type": "title" | "body" | "counter" | "progress" | "checklist" | "label",
                    "text": "표시할 텍스트",
                    "animation": "typing" | "fade_in",  # 생략 시 스타일 기본값
                    "color": "primary" | "accent" | "accent2",  # 생략 시 primary
                    "font_size": 48,  # 생략 시 스타일 기본값
                    # counter 전용:
                    "start_value": 0, "end_value": 85, "suffix": "%",
                    # progress 전용:
                    "value": 0.85,
                    # checklist 전용:
                    "items": ["항목1", "항목2", ...],
                },
                ...
            ],
            "duration": 6.0,  # 자동 계산도 가능
            "scene_number": 1,  # show_scene_number=True인 스타일만
        }

        렌더링 방식:
        1. 배경색 프레임 생성 (RGB)
        2. 각 element를 시간순으로 배치 (element_delay 간격)
        3. element별 애니메이션 프레임(RGBA)을 배경 위에 알파 합성
        4. 최종 RGB 프레임 리스트 반환

        Returns:
            RGB numpy array 리스트 (height, width, 3)
        """

    def render_scenes(self, scenes: list[dict]) -> list[list[np.ndarray]]:
        """여러 장면을 순차 렌더링."""
        return [self.render_scene(s) for s in scenes]
```

**핵심 렌더링 알고리즘:**
```
총 프레임 수 = duration × fps
각 element의 시작 시점 = element_index × element_delay
각 element의 프레임 = 해당 animation 함수 호출

for frame_idx in range(total_frames):
    t = frame_idx / fps
    background = bg_color 프레임 (복사)

    for element in elements:
        element_start = element.index * element_delay
        if t >= element_start:
            element_t = t - element_start
            element_frame_idx = element_t * fps
            if element_frame_idx < len(element.frames):
                alpha_composite(background, element.frames[element_frame_idx])
            else:
                # hold 상태 - 마지막 프레임 유지
                alpha_composite(background, element.frames[-1])

    result_frames.append(background)
```

### 4. `video/scene_composer.py` — 카드뉴스 JSON → 장면 변환

```python
"""카드뉴스 슬라이드 JSON → 영상 장면 시퀀스 변환."""
from __future__ import annotations
from video.styles import get_style


def compose_scenes(
    slides: list[dict],
    style_name: str = "evan",
) -> list[dict]:
    """카드뉴스 슬라이드 목록을 영상 장면 목록으로 변환.

    슬라이드 타입 → 장면 타입 매핑:
    - cover → hook (큰 제목 + 훅 텍스트)
    - card_list → checklist (항목 순차 등장)
    - detail → data (라벨+숫자/프로그레스바)
    - body → info (텍스트 정보)
    - summary_cta, cta → cta (CTA 텍스트 + 강조)

    Args:
        slides: 카드뉴스 슬라이드 딕셔너리 목록.
            각 슬라이드는 type, title, items 등의 키를 가짐.
            (generated_content.json 참조)
        style_name: "evan" 또는 "vibelabs"

    Returns:
        장면 딕셔너리 목록 (SceneRenderer.render_scene()에 전달 가능한 형식)
    """
```

**매핑 세부 규칙:**

cover → hook:
```python
{
    "type": "hook",
    "elements": [
        {"type": "title", "text": slide["title"], "color": "accent", "animation": "typing"},  # 에반
        {"type": "body", "text": slide["hook"], "color": "primary", "animation": "typing"},
    ],
    "duration": 자동계산,  # 타이핑 시간 + hold
}
```

card_list → checklist:
```python
{
    "type": "checklist",
    "elements": [
        {"type": "label", "text": slide["title"], "color": "accent", "animation": "fade_in"},
        {"type": "checklist", "items": [item["title"] for item in slide["items"]]},
    ],
    "duration": 자동계산,
}
```

detail → data:
```python
{
    "type": "data",
    "elements": [
        {"type": "label", "text": slide["title"], "color": "accent"},
        # items에서 숫자 추출 가능하면 counter, 아니면 텍스트
        {"type": "body", "text": item["label"] + ": " + item["value"]},
    ],
}
```

cta/summary_cta → cta:
```python
{
    "type": "cta",
    "elements": [
        {"type": "title", "text": "지금 바로 시작하세요", "color": "accent2"},
        {"type": "body", "text": slide.get("cta_text", ""), "color": "primary"},
    ],
}
```

### 5. `video/video_builder.py` — 장면 → 최종 MP4

```python
"""장면 시퀀스 → 최종 MP4 영상 조합."""
from __future__ import annotations
import numpy as np
from moviepy import ImageSequenceClip, concatenate_videoclips
from video.scene_renderer import SceneRenderer
from video.effects import fade_transition
from video.bgm import attach_bgm
from video.styles import get_style


def build_video(
    scenes: list[dict],
    style_name: str = "evan",
    output_path: str = "output.mp4",
    bgm_path: str | None = None,
    bgm_fadeout: float = 2.0,
    transition_type: str = "fade",
    transition_duration: float = 0.3,
) -> str:
    """장면 시퀀스를 최종 MP4로 빌드.

    흐름:
    1. SceneRenderer로 각 장면 → 프레임 배열
    2. 프레임 배열 → ImageSequenceClip (MoviePy)
    3. 장면 간 전환 효과 적용
    4. BGM 합성 (있으면)
    5. FFmpeg로 MP4 인코딩

    Returns:
        출력 파일 경로
    """
```

**핵심: ImageSequenceClip 사용법:**
```python
# numpy frames → MoviePy clip
frames = renderer.render_scene(scene)  # list[np.ndarray] (RGB)
clip = ImageSequenceClip(frames, fps=style["fps"])
```

## 테스트 요구사항

### 단위 테스트 (`video/tests/`)

1. `test_styles.py` — get_style() 정상 동작, 필수 키 존재 확인
2. `test_animations.py` — 각 애니메이션 함수가:
   - 올바른 프레임 수를 반환하는지
   - 프레임 shape이 (height, width, 4) RGBA인지
   - typing: 첫 프레임에 텍스트 없고, 마지막에 전체 텍스트 있는지
   - counter: 첫 프레임 start_value, 마지막 프레임 end_value인지
3. `test_scene_renderer.py` — SceneRenderer:
   - render_scene이 RGB frames 리스트 반환하는지
   - 프레임 수 = duration × fps인지
4. `test_scene_composer.py` — compose_scenes:
   - 각 슬라이드 타입이 올바른 장면 타입으로 변환되는지
   - elements에 필수 키가 있는지
5. `test_video_builder.py` — build_video:
   - 실제 MP4 파일이 생성되는지 (통합 테스트)

### 통합 테스트

실제 카드뉴스 JSON(`output/pipeline_test/generated_content.json`)으로:
1. compose_scenes() → 장면 목록 생성
2. build_video() → MP4 생성
3. 파일 크기 > 0, 재생 가능 확인

에반 스타일 + 바이브랩스 스타일 각 1개씩 생성.
출력 경로: `/home/jay/projects/ThreadAuto/output/videos/test_evan.mp4`, `test_vibelabs.mp4`

## 기존 코드와의 관계

- 기존 `video_generator.py` (슬라이드쇼) → 유지. 건드리지 않음.
- 새로운 `video_builder.py` (장면 기반) → 병렬 존재.
- `effects.py`의 `fade_transition` → video_builder에서 재사용.
- `bgm.py`의 `attach_bgm` → video_builder에서 재사용.
- `config.py` → 새 모듈은 styles.py에서 직접 값을 가져옴 (config.py 의존 X).

## 주의사항

1. **메모리**: 1080x1920 프레임 1개 = ~8MB (RGBA). 60초 영상 = 1800프레임.
   전체를 메모리에 올리면 ~14GB → **장면 단위로 렌더링 후 즉시 MoviePy clip 변환**.
   render_scene()에서 frames를 반환하되, video_builder에서 scene별로 처리.

2. **PIL 한글 렌더링**: `NotoSansCJKkr-Bold.otf` 사용. 이모지는 `_strip_emoji()` 적용.

3. **자동 줄바꿈**: PIL의 `textbbox`로 텍스트 폭 측정 후, max_width 초과 시 줄바꿈.

4. **pyright**: 타입 힌트 완전 적용. `np.ndarray` 등 외부 타입은 `from __future__ import annotations`.

5. **기존 테스트 깨뜨리지 말 것**: 기존 199개 테스트 전부 PASS 유지.
