# 캡컷 에이전트 · 구간합치기 (capcut2) — 시스템 명세

> 이 문서는 다른 Claude 세션(또는 개발자)이 이 프로젝트를 읽고 바로 협업할 수 있게 쓴 **전체 구현 명세**입니다.
> 사용자용 요약은 [README.md](README.md) 참고. 이 문서가 더 깊고 정확합니다.

## 0. 한 줄 요약

유튜브 영상(URL/파일/LLM 편집안 JSON) → 다운로드·컷·자막·배경 템플릿을 자동 조립해
**편집 가능한 CapCut 드래프트**(draft_content.json)를 로컬 CapCut 프로젝트 폴더에 생성하는
FastAPI + 정적 HTML 로컬 웹앱. 포트 **8001** (v1 `../capcut`은 8000, 별개 앱).

실행: `캡컷_에이전트_구간합치기.bat` → uvicorn `server.app:app` → http://127.0.0.1:8001
**코드 수정 후엔 반드시 .bat 재시작** (파이썬 코드가 서버에 물려 있음).

---

## 1. 폴더 구조

```
capcut2/
├─ 캡컷_에이전트_구간합치기.bat   런처(포트 8001, 브라우저 자동 오픈)
├─ 배경.png                      배경 템플릿(검정-흰-검정 세로 1080×1920).
│                                (레이아웃엔 더 이상 안 씀 — pipeline.py 상수로 통제)
├─ 댓글카드/                     댓글 카드 이미지 폴더(기본 경로, UI에 자동 주입)
├─ requirements.txt              fastapi uvicorn python-multipart pyCapCut Pillow
│                                pymediainfo yt-dlp faster-whisper
├─ .gemini_key                   (선택) Gemini API 키 — 파일/유튜브 탭 자막 교정용
├─ assets/                       파생물(frame_template.png 등) 자동 생성
├─ server/
│  ├─ app.py                     FastAPI. 탭별 analyze→stream→build 엔드포인트들 + SSE 스트림(§3)
│  └─ static/index.html          UI 전체(단일 파일, 탭 3개 + 옵션 + SSE 렌더)
└─ capcut_agent/
   ├─ pipeline.py                ★ 두 파이프라인, 각각 analyze/draft 두 조각 + 얇은 래퍼(§4)
   ├─ draft.py                   ★ CapCut 드래프트 생성(pycapcut) + JSON 후처리
   ├─ youtube.py                 yt-dlp 다운로드(단일/다중/정밀) + ffmpeg 병합
   ├─ paste.py                   붙여넣기 JSON 파서(관대한 파싱)
   ├─ silence.py                 ffmpeg silencedetect → 발화 구간
   ├─ transcribe.py              faster-whisper(medium/int8/cpu) 단어 타임스탬프
   ├─ correct.py                 Gemini 자막 글자 교정(시간 불변) — gemini-2.5-flash
   ├─ recommend.py               컷별 댓글 추천(세 탭 공통, cuts_from_state) — 타임스탬프 우선 + Gemini 텍스트 추천
   ├─ highlight.py               자막 청킹(cut_plan) 유틸
   ├─ scene.py                   ffmpeg scene 필터 장면전환 감지·분할
   ├─ media.py                   프레임 PNG 생성, 흰밴드 감지, 오디오 추출
   └─ probe.py                   pymediainfo 영상 메타
```

## 2. 좌표계 (중요)

CapCut 인스펙터 값 ↔ pycapcut 변환:
- **위치 Y**: `CapCut Y = transform_y × 1920` (canvas 1080×1920 기준). 위가 +, 아래가 −.
  예) transform_y = −559/1920 = −0.2911 → 인스펙터에 Y −559로 표시.
- **확대(%)**: `scale_x = scale_y = 비율` (0.89 → 89%).
- **글자 크기**: pycapcut TextStyle.size ≈ CapCut 폰트 크기 1:1.
- 헬퍼 `_ty(y_px) = (960 - y_px) / 960` (draft.py) — 픽셀 y → transform_y.

## 3. 서버 (server/app.py)

| 엔드포인트 | 역할 |
|---|---|
| `GET /` | index.html 서빙. `__CDIR__` 토큰을 그 PC의 `capcut2/댓글카드` 절대경로로 치환 |
| `POST /upload` | 📁 파일 탭. multipart 파일 + 옵션 → job 등록(content-hash id) — **유일하게 옛 단일 흐름**(분석/검토 단계·댓글 매칭 없음) |
| `GET /stream/{job_id}` | SSE. `JOBS[job_id]`에 `bg_state`/`paste_state`가 있으면 **드래프트만**(§4의 build 단계), 없으면 `process_bg_template`/`process_paste` 통짜 실행 |
| `POST /open-capcut` | CapCut 실행(시작메뉴 lnk → LOCALAPPDATA exe 폴백) |
| `GET /drafts` / `POST /repair` | 레이어 꼬임 목록 조회 · 수리(§10) |
| `GET /auto/avatar` | 댓글 프로필 이미지 동일 출처 프록시(구글 도메인만, SSRF 방지) |
| `GET /prompts` / `POST /prompts` | 🤖 자동 탭 Step1·Step3 프롬프트 조회/저장 |

**▶ 유튜브 구간 / 📋 붙여넣기 / 🤖 자동 — 공통 3단계 흐름**(analyze → stream(SSE) → build).
셋 다 "받아쓰기까지 끝낸 뒤 검토 화면에서 댓글 카드를 고르고, 그 상태 그대로 드래프트만
만든다"는 같은 모양이라 엔드포인트 이름도 대응된다:

| 탭 | 1단계: 분석 예약 | 2단계: SSE(다운로드·받아쓰기·댓글매칭·추천) | 3단계: 빌드 |
|---|---|---|---|
| ▶ 유튜브 구간 | `POST /yt/analyze` → `ANALYSES[aid]` | `GET /yt/stream/{aid}` → `YSTATES[aid]` | `POST /yt/build` → `JOBS[h]["bg_state"]` |
| 📋 붙여넣기 | `POST /paste/analyze` → `ANALYSES[aid]` | `GET /paste/stream/{aid}` → `PSTATES[aid]` | `POST /paste/build` → `JOBS[h]["paste_state"]` |
| 🤖 자동 | `POST /auto/analyze` → `ANALYSES[aid]`(Step1+3만, 댓글 매칭 없음) | 1차 검토(제목 선택·✕ 제외) 뒤 `POST /auto/prepare` → `PREPARES[pid]`, `GET /auto/prepare/{pid}` → `PSTATES["{aid}:{id}"]`(제외 안 된 ID만 순차) | 2차 검토(컷별 댓글) 뒤 `POST /auto/build` → `JOBS[h]["paste_state"]` |

- 3단계(빌드)는 셋 다 결과로 받은 `job_id`를 그대로 기존 `GET /stream/{job_id}`에 물려
  드래프트만 만든다(다운로드·받아쓰기 재실행 없음) — `bg_state`/`paste_state`가 그 분기 키다.
- 옛 `POST /youtube`(유튜브 탭 단일 엔드포인트)·`POST /yt/comments`는 이 흐름으로 대체되며 삭제됐다.
- `POST /paste`(붙여넣기 편집안 통짜 처리)는 코드에 남아 있지만 **현재 UI는 전부
  `/paste/analyze` 흐름을 쓴다** — 하위 호환/직접 호출용으로만 존재.
- job 은 메모리 dict `JOBS[hash]`. hash = 입력 시그니처 sha1 12자. 분석·검토 단계 상태는
  `ANALYSES[aid]`(입력)·`YSTATES[aid]`/`PSTATES[aid 또는 "aid:id"]`(받아쓰기 결과)·
  `PREPARES[pid]`(자동 탭 2단계 예약) — **전부 메모리라 서버 재시작 시 소실**된다(§10).
- SSE 이벤트 형식: `{"type": "manifest"|"step"|"log"|"error"|"result"|"state", ...}`
  - `manifest`: `{steps:[{id,label}]}` / `step`: `{id,status:"start"|"done",elapsed,detail}`
  - `state`: **내부 전용**(analyze↔draft 조각 간 상태 전달, §4) — 래퍼가 걸러내 밖으로 안 흘림
  - `result`: 파이프라인 통짜 흐름은 `{draft_name, draft_path, stats:{…}}`,
    analyze 단계는 `{cuts, need, cutRanges, matched, candidates, comments, warnings, …}`
    (검토 화면 렌더용 — §6)
- 공통 폼 필드: `video_scale`(% 문자열, 기본 144), `flip`, `scene`, `bg_white`,
  `comments_dir`(폴더 경로 문자열), `title_top/title_main/channel`(파일·유튜브만),
  `remove_silence`(붙여넣기만). 불리언은 "1"/"0" 문자열 → `_truthy()`.

## 4. 탭별 파이프라인

두 파이프라인 모두 내부적으로 **analyze/draft 두 조각 + 얇은 래퍼**로 나뉜다
(`process_bg_template` → `bg_analyze`+`bg_draft`, `process_paste` → `paste_analyze`+`paste_draft`).
**왜**: 댓글 매칭을 받아쓰기(ASR) 뒤로 옮기려면 "받아쓰기까지 끝낸 상태"에서 한 번 멈출
수 있어야 한다 — analyze 조각이 거기서 멈추고 그 결과를 draft 조각이 이어받는다.
이 갈라짐은 이제(2~4단계) **서버 HTTP 계층에도 그대로 노출돼 있다** — ▶ 유튜브 구간
· 📋 붙여넣기 · 🤖 자동 세 탭 모두 `*_analyze`(§3 표의 1단계)가 요청 하나로 끝나고,
그 결과를 받아쓰기까지 끝낸 SSE 스트림(2단계)이 이어받아 **h-lab 댓글 수집 →
`recommend.cuts_from_state()`로 컷별 카드 추천**까지 마친 뒤 검토 화면용 `result`를
낸다(엔드포인트 대응표는 §3). 사용자가 검토 화면에서 카드를 고르고 나서야
3단계(`*_build`)가 그 상태로 `*_draft`를 돌려 실제 드래프트를 만든다. **파일 탭만 이 3단계 분리 없이
`/upload → GET /stream/{job_id}`로 통짜 실행되는 옛 흐름 그대로**다(댓글 매칭 없음).

- `*_analyze`는 끝나면 다음 조각에 넘길 상태를 실어 `{"type":"state","state":{…}}`를
  낸다. **이 이벤트는 내부 전용**이라 래퍼가 걸러내고 밖으로 흘리지 않는다 — 기존 UI가
  모르는 타입이라 흘리면 로그에 정체불명 이벤트가 찍힌다. `t_all`(총 소요 측정
  시작점)도 이 state에 실려 넘어가 `result.stats.elapsed`(전체 소요시간) 의미를 유지한다.
- `manifest`(진행 단계 목록) 이벤트는 조각 안이 아니라 **래퍼가 낸다** — 두 조각이
  서로 다른 SSE 스트림에 걸쳐 쓰일 수 있어(예: 댓글 매칭이 끼어들면 analyze와 draft가
  별개 요청이 됨) 각자 다른 manifest가 필요하기 때문. 목록 생성은
  `bg_steps(youtube)` / `paste_steps(asr_bottom)`가 맡는다.
- `bg_draft(..., comment_cards=None)` — 주어지면 폴더에서 읽는 대신 그 목록을 그대로
  쓴다. 이제 실제로 쓰인다: `/yt/build`가 검토 화면에서 고른 `card_cuts`를
  `_cards_by_cut()`으로 컷별 카드 목록으로 바꿔 여기 넘긴다(§6).
- ⚠ **`recommend.cuts_from_state()`가 세 탭의 공통 조립점**이다(`capcut_agent/recommend.py`).
  각 `*_stream` SSE가 받아쓰기 상태(`places`/`captions`, 압축 좌표)와 h-lab 댓글을
  이 함수 하나에 넘기면 `(cuts, need, ai_failed)`를 돌려주고, 그걸 그대로 검토 화면
  `result`에 실어 보낸다 — 세 탭이 각자 추천 로직을 따로 구현하지 않는다(§6).
  **압축 좌표(`places`/`captions`)와 원본 좌표(`orig_ranges`, ⭐ 분:초 매칭용)를 섞으면
  안 된다** — 둘 다 같은 길이의 리스트로 인덱스만으로 짝지어 다닌다(§6 재강조).

### 4-A. 📁 파일 / ▶ 유튜브 구간 (pipeline.py)

단계: `[download] → silence → asr → [scene] → draft` (analyze 조각 = download~asr,
draft 조각 = scene~draft). 두 탭이 이 단계들을 공유하지만 **호출 경로는 다르다**:
📁 파일 탭은 `process_bg_template()`(analyze+draft 통짜, `/upload → /stream/{job_id}`)를
그대로 쓰고, ▶ 유튜브 구간 탭은 §3/§4 서두의 3단계 흐름대로 `bg_analyze()`(`/yt/stream`)와
`bg_draft()`(`/yt/build`)를 **별개 요청으로 나눠 호출**한다(그 사이에 댓글 매칭이
끼기 때문). 아래 단계 설명은 두 경로 모두에 동일하게 적용된다.

1. **download** (유튜브만): `cut_youtube_multi(url, ranges, out)` —
   구간별로 `yt-dlp --download-sections`(h264 우선) 다운로드 → 구간 2개 이상이면
   ffmpeg concat demuxer로 **재인코딩 병합**(libx264 crf20 + aac). 채널명 자동 추출
   → 출처(channel) 미입력 시 `@채널명` 자동.
2. **silence**: `detect_speech_segments(noise_db=-28, min_silence=0.3, pad=0.04)` —
   ffmpeg silencedetect의 여집합 = 발화 구간 `keep=[(s,e)]`. 이게 곧 비디오 컷.
3. **asr**: **타이밍 = Whisper, 글자 = Gemini 제자리 교정** (핵심 설계):
   - faster-whisper(medium/int8/cpu, `word_timestamps=True, vad_filter=False,
     no_repeat_ngram_size=3`)로 단어 타임스탬프 → `cut_plan()`이 단어를
     새(컷 압축) 타임라인으로 매핑 후 짧은 자막으로 청킹.
   - **청킹 = DP 줄바꿈 최적화**(`_chunk_words`). 0.6s 이상 쉬는 곳으로 덩어리를 나눈 뒤,
     덩어리 안에서 `Σ(줄길이−10)² + 끊는 자리 벌점`이 최소가 되는 줄바꿈을 고른다.
     벌점: 다음 어절이 의존명사·보조용언(`_is_bound`: "수/것/번도/들어…") +60,
     앞 어절이 관형형·관형사(`_is_adnominal`/`_DETERMINERS`: "쓸/당황하는/한") +50,
     문장부호·어미로 끝나면 보너스. 하드캡 14자(`fit_caption_size` 한 줄 폭 한계)/2.8s.
     ⚠ 앞에서부터 12자 차면 무조건 끊던 greedy 방식은 "예를 / 들어", "쓸 / 수 있잖아요"
     처럼 한 덩어리를 갈랐다(실측 자막 경계의 8.5% → DP 적용 후 0.7%).
     ★ DP는 **묶는 방법만** 고른다 — 단어 타임스탬프는 손대지 않으므로 싱크 불변.
   - `.gemini_key` 있으면 `correct_captions()`로 **글자만** 1:1 교정(줄 수·순서·시간
     절대 불변 → 싱크 유지). 실패 시 Whisper 원문 유지.
   - ⚠ 과거 시도: Gemini 오디오 전사 타임스탬프 직접 사용 → 드리프트로 자막 밀림.
     글자수 기반 정렬도 누적 드리프트. **절대 되돌리지 말 것.**
   - ASR은 `.cache/`에 content-hash 캐시. numba 동시 호출 segfault → `ASR_LOCK`.
4. **scene** (옵션): `detect_scene_changes(threshold=0.4)` → `split_clips_at_scenes()` —
   보존 구간을 장면전환 지점에서 **인접 분할**(누적 길이 불변 → 자막 싱크 무영향).
5. **draft**: §5 빌더 호출. 좌표는 전부 `_template_pos()`가 레이아웃 상수에서 파생(§9).

### 4-B. 📋 붙여넣기 (기본 탭) (pipeline.py)

LLM이 만든 편집안 JSON을 **그대로** 사용. `paste_analyze()`/`process_paste()` 함수
자체의 `remove_silence`/`asr_bottom` 기본값은 각각 꺼짐/켜짐이지만, ⚠ **현재 UI의
기본 흐름(`/paste/analyze` → `/paste/stream`)은 댓글 매칭 추천을 위해 둘 다 항상
`True`로 고정해서 호출한다**(§3) — 사용자가 화면에서 끌 수 없다. 검토 화면에서
"하단 자막 자동 생성" 체크를 끄면 **받아쓰기 자체는 그대로 하되** 최종 화면 자막만
`/paste/build` 단계에서 JSON `bottom`으로 되돌린다(아래 5번). 옛 `POST /paste`
단일 엔드포인트는 폼 체크박스 값을 그대로 써서(§3) 지금도 두 옵션 다 끌 수 있다.

단계: `download(컷 정밀) → [remove_silence] → [asr_bottom] → [scene] → draft`
(analyze 조각 = download~[asr_bottom], draft 조각 = [scene]~draft)

1. **파싱** (`paste.parse_paste`): 관대한 JSON 파싱 —
   `json.loads(strict=False)`(자막 안 실제 줄바꿈 허용), 코드펜스(```) 자동 제거,
   시간은 `parse_time()`: `"분:초.밀리"`/`"시:분:초.밀리"`/초 단독, 콤마 밀리초도 허용.
   검증 실패 시 한국어 메시지로 400.
2. **download**: `download_paste_cuts()` — 컷마다 `cut_youtube_precise()`:
   `--download-sections "*HH:MM:SS.mmm-…" --force-keyframes-at-cuts`(프레임 정확).
   **실패 시 keyframe 컷으로 자동 폴백**(일부 영상에서 ffmpeg 크래시 방어).
   전부 받은 뒤 concat 재인코딩 병합. 제목/채널 자동 추출.
3. **자막 배치**: 배치 시간은 **컷 순서 누적 자동 계산** (LLM이 배치시간 계산하면
   오타 나므로 입력받지 않음). `bottom` → 하단 자막, `effect` → 중앙 효과자막.
4. **remove_silence** (옵션, 기본 꺼짐): 병합본에서 무음 감지 → 컷 추가 압축,
   자막을 `_remap_caps()`로 압축 타임라인에 재매핑(무음에만 걸친 자막은 버림).
5. **asr_bottom** (옵션, 기본 켜짐): 병합본을 Whisper로 받아써 **JSON bottom 을 대체**하는
   하단 자막 생성(실제 발화 타이밍). 구현 핵심: Whisper 타임스탬프는 '병합 파일' 기준이므로
   `cut_plan(video_clips, tr)` 로 매핑+청킹 — 무음제거 켜면 video_clips=keep(압축 매핑),
   끄면 [(0,dur)](항등). `.gemini_key` 있으면 글자만 1:1 교정(시간 불변). ASR 결과가 비면
   JSON bottom 폴백. effect/제목/채널은 JSON 유지.
   ⚠ "remove_silence 후에 돌리면 remap 불필요"는 틀림 — 파일은 압축 안 되므로 cut_plan 매핑 필수.
6. **scene / 댓글카드 / draft**: 4-A와 동일(좌표도 공통 — §9).
   🤖 자동 탭·▶ 유튜브 구간 탭에서 넘어온 경우 build 단계(`paste_draft`/`bg_draft`)가
   `card_cuts=[...]`로 카드별 소속 컷을 받는다(§6 참고) — 붙여넣기 탭 직접 사용 시엔
   생략(기존 전체 균등 배치).

#### 붙여넣기 JSON 스키마 (LLM에게 시킬 형식)

```json
{
  "url": "https://www.youtube.com/watch?v=실제영상ID",
  "title_top": "서브제목(주황)", "title_main": "메인제목(흰색)", "channel": "@채널",
  "cuts": [
    {"start":"16:07.500","end":"16:12.500",
     "bottom":"하단 자막 윗줄\n하단 자막 아랫줄","effect":"(효과자막)"}
  ]
}
```

- `url`: **반드시 실제 영상**. LLM이 지어낸 가짜 ID(oembed 404)로 실패한 전례 있음.
- `start/end`: 원본 영상 기준. 배치 시간·SRT 타임코드는 **받지 않는다**.
- `bottom`의 `\n`: **같은 위치에서 시간을 줄 수만큼 균등 분할**해 순서대로 표시
  (윗줄 → 아랫줄). 위아래 스택이 아님. LLM이 `\\n`(이중 이스케이프)으로 줘도,
  글자 그대로 `\n`/`\r`이 와도 draft.py `_cap_lines()`가 전부 줄바꿈으로 처리.
- `title_*`/`channel`/`bottom`/`effect` 전부 선택(빈 값이면 생략).

#### LLM 지시문 템플릿

> 아래 스키마의 JSON 하나로만 출력해. 설명·마크다운 금지.
> url은 내가 준 이 주소 그대로(임의 생성 금지). start/end는 원본 타임스탬프(분:초.밀리).
> 배치 시간은 계산하지 마라(앱이 순서대로 이어붙임). bottom은 2줄(\n), effect는 짧게.
> 모든 컷 start < end.

## 5. 드래프트 빌더 (draft.py `build_bg_template_draft`)

캔버스 1080×1920. 트랙 구성(아래→위 렌더 순):

```
[bg]      (배경 흰색일 때만) 흰 단색 1080×1920 PNG, 전체 길이
main      영상. 보존 구간들을 이어붙임(점프컷). scale=video_scale, flip, video_y=영상 창 중앙
frame     make_frame() 생성 — 상하 띠 불투명(검정 or 흰), 가운데 투명(영상 비침). 좌표는 §9 상수
[comment] 댓글 카드 이미지들 — scale 0.89 고정, X 0, 윗변=영상 바로 아래, 카드당 3초
caption   하단 자막(텍스트)
title_top 서브제목 / title_main 메인제목 / channel 출처 / effect 효과자막
```

핵심 구현 포인트:
- **소재 길이 클램프**: ffprobe duration이 CapCut 소재 길이보다 수십 ms 길 수 있음
  → 컷 끝을 `material.duration`으로 클램프(SegmentOverlap/초과 오류 방어).
- **자막 스타일**: **제주명조체** + **흰색**(`CAPTION_COLOR`) + 볼드 아님 +
  **검은 획(외곽선) 두께 40**(`CAPTION_BORDER_W`), **배경박스 없음**, **그림자 없음**,
  크기 **`CAPTION_SIZE = 12.0` 고정**(캡컷 폰트 크기 1:1).
  배경은 `TextSegment(background=...)` 인자를 **생략**해서 끈다 → `background_style` 키 자체가
  안 나가고 CapCut 이 읽으면서 0(없음)으로 채운다(제목 텍스트가 원래 이 방식).
  폰트는 저장 후 `_apply_font_to_track(draft_dir, "caption", JEJU_MYEONGJO)` 로 **자막 트랙에만**
  주입한다(전체 주입 `_apply_font_to_texts(…, KOTRA_BOLD)` 뒤에 덮어씀 → 제목·출처는 코트라 볼드체).
  획 두께는 pycapcut `TextBorder(width=…)` 가 캡컷 UI 값과 같은 0~100 스케일
  (JSON 엔 `width/100*0.2` = 0.08 로 나감).
  ⚠ 하단 자막 그림자는 **끔** — `_apply_shadow_to_track` 은 남아 있지만 자막엔 호출하지 않는다
  (되돌리려면 `build_bg_template_draft` 끝의 주석 줄 참고).
  예전엔 최장 줄 기준 `fit_caption_size()`로 7~13 자동이었으나 드래프트마다 크기가
  달라져 고정으로 바꿈. 청킹 하드캡 14자 × 크기10(≈51.5px) ≈ 721px < 1080 → 안 넘침.
  `fit_caption_size()`는 남겨둠(미사용, 자동 맞춤으로 되돌릴 때 사용).
- **`\n` 시간분할**: n줄이면 구간을 n등분해 각 줄을 같은 위치(caption_y)에 순차 표시.
- **제목**: title_top 주황 `(1.0,0.62,0.05)` size14 bold 검은외곽선18 /
  title_main 흰색 size18 bold. 위치는 §9. 전체 길이 표시.
- **효과자막**: 녹색 `#0dff63` size13 **bold 아님** 검은외곽선18 (붙여넣기 전용).
- **배경 흰색 모드**(`bg_white=True`): 흰 띠 프레임 + 흰 배경 레이어. 추가로
  channel 글자 검정, title_main 외곽선 두께 50, title_top에 **그림자 JSON 주입**
  (`_apply_shadow_to_text`, CapCut 실측값: alpha .9, diffuse .025, distance 5, angle −45).
- ⚠ **그림자는 두 군데를 같이 넣어야 켜진다**: `styles[].shadows`(`_TEXT_SHADOW`) **와**
  소재 최상위 플래그(`_SHADOW_MATERIAL`: `has_shadow=true`, `shadow_color`, `shadow_alpha`,
  `shadow_angle`, `shadow_distance`, `shadow_point`, `shadow_smoothing`).
  `shadows` 만 넣으면 JSON 엔 있는데 `has_shadow=false` 라 **화면엔 안 나온다** —
  기존 title_top 그림자가 딱 이 상태였고(실측 확인) 같이 고쳤다. 값은 CapCut UI 로 켠
  자막에서 실측한 것.
- **폰트**: 코트라 볼드체 — pycapcut FontType에 없어 **저장 후 draft_content.json의
  모든 텍스트 styles[].font에 직접 주입**(`_apply_font_to_texts`). 경로는
  `%LOCALAPPDATA%/CapCut/User Data/Cache/effect/7480846567709265157/...`(PC 무관).
  캐시 없으면(그 PC CapCut에서 폰트 미사용) 주입 생략 → 기본 폰트로 안전 동작.
  **하단 자막만 제주명조체**(`JEJU_MYEONGJO`, id `7480851064179133702`) —
  전체 주입 뒤 `_apply_font_to_track(…, "caption", …)` 으로 덮어쓴다.
- **트랙 자동 잠금**(`_lock_tracks`, 저장 직후): `frame`·`comment`·`title_top`·
  `title_main`·`channel` 트랙을 잠근다(`attribute |= 4`; mute 비트1은 OR로 보존).
  `main`(영상)·`caption`(자막)·`effect`는 편집용이라 **안 잠금**. `bg`(흰 배경)도
  **안 잠금** — 맨 아래 레이어라 꼬여도 화면에 영향이 없고, 영상 길이를 늘릴 때 같이
  늘려야 해서 잠겨 있으면 불편하다(사용자 요청으로 제외). **파일/유튜브/붙여넣기
  전 탭 공통** — 세 탭 모두 이 빌더(`build_bg_template_draft`)를 쓰므로 자동 적용.
  - ⚠ **왜 잠그나(중요)**: CapCut에서 재생헤드 전체 분할 등으로 오버레이 트랙이 쪼개지면
    CapCut이 세그먼트 `render_index`를 다시 매기다 **일부 main 영상 세그먼트를 frame/comment
    위로 올려버려**(예: main ri=4 > frame ri=2) 그 클립이 흰 띠·댓글을 덮고 삐져나오는 버그가
    있었다. 오버레이/제목 트랙을 미리 잠그면 분할·재배치가 막혀 레이어가 안 꼬인다.
  - `render_index`는 **시간 무관 전역 쌓임 순서**(클수록 위). 정상 생성물은 bg=0<main=1<
    frame=2<comment=3 으로 일관. 이미 꼬인 드래프트는 각 트랙 세그먼트 render_index를
    트랙별 단일값으로 재통일하면 복구된다(`repair_layers()`).
  - 잠금 인코딩은 CapCut 실측 확인값(트랙 `attribute` 비트4=잠금, 비트0=mute).
- 저장 위치: `%LOCALAPPDATA%/CapCut/User Data/Projects/com.lveditor.draft/<드래프트명>/`
- ⚠ **draft_content.json**이 정답 파일(draft_info.json 아님). 같은 트랙에 같은
  시간대 세그먼트 2개 넣으면 `SegmentOverlap` 에러.

## 6. 댓글 카드 시스템

- UI "댓글 카드 폴더" 칸(기본값 = `capcut2/댓글카드`, 서버가 실제 경로 주입).
  **비우면 카드 없음.**
- `_load_comment_cards(folder, dur, interval=3.0)`:
  - 모든 파일명이 숫자로 시작 → 숫자순(1,2,10). 아니면 → **파일 생성시각(저장 순서)**.
  - png/jpg/jpeg/webp. 카드당 3초, 영상 길이 초과분은 생략.
- **배치 방식이 두 갈래**(정렬 자체는 `_card_paths()`로 공통): 🤖 자동 · ▶ 유튜브 구간 ·
  📋 붙여넣기 세 탭 모두 검토 화면(§4·§6 `cuts_from_state`)에서 컷별 소속 컷 인덱스
  (`card_cuts`)를 보내고, `_cards_by_cut(paths, card_cuts, placements, dur)`가
  **그 컷 구간 안에서** 균등 배치한다(한 컷이 덜 차도 다음 컷 카드가 앞으로 밀리지 않음).
  📁 파일 탭(검토 화면 없음)과 붙여넣기 옛 단일 엔드포인트(`POST /paste`) 직접 사용만
  컷 소속을 몰라 기존 `_load_comment_cards` 전체 균등 배치 그대로 쓴다.
  - 컷당 장수 상한은 `max(1, floor(컷길이/3초))` — `_load_comment_cards`와 같은 규칙.
    초과분은 버린다(카드가 1초씩 번쩍이느니 몇 장 빼는 게 낫다).
  - ⚠ **카드 시간은 서버가 미리 확정하지 않는다.** `/auto/build`·`/yt/build`·`/paste/build`는
    "몇 번 컷 소속"만 넘기고, 파이프라인이 컷 누적 위치(`placements`)로 시간을 계산한다.
  - ⚠ **카드 시간 계산은 무음 제거 *뒤*다.** 자막은 `_remap_caps()`로 시간을 옮기지만
    (발화 시각을 따라가야 하니까), 카드는 **구간 자체**를 `_remap_placements()`로 옮기고
    그 안에서 나눈다. 카드 시간을 압축 전에 만들어 자막처럼 재매핑하면
    `cards_fixed`(정확히 3초)가 2초로 눌리고, quota는 원본 길이로 잡혀 있어서
    3초 하한도 사라진다(무음이 절반인 24초 컷 → 8장 → 압축 12초 → 장당 1.5초).
    이 순서를 뒤집지 말 것.
- 렌더: comment 트랙에 **scale 0.89 / X 0**, 세로는 **윗변이 영상 바로 아래**에 오도록 카드마다 계산(§9).
- 출처: 사용자가 h-lab(https://h-lab.tolag.shop/comment-cards)에서 실제 유튜브 댓글을
  카드 PNG로 저장해 폴더에 넣음. (향후: h-lab API 연동해 완전 자동화 아이디어 있음)

#### 컷별 댓글 추천 근거 (recommend.py, 세 탭 공통)

- `recommend.cuts_from_state(places, orig_ranges, captions, comments, *, key=None)
  -> (cuts, need, ai_failed)` — ★ **세 탭(▶ 유튜브 구간 · 📋 붙여넣기 · 🤖 자동)이
  전부 이 함수 하나로 검토 화면용 컷 목록을 조립한다**(`*_stream` SSE의 recommend
  스텝에서 호출, §4). `places`·`captions`는 받아쓰기 상태의 **압축 타임라인**(무음
  제거 후 — 자막 추출·카드 장수·배치 기준), `orig_ranges`는 **원본 영상 시각**
  (⭐ 분:초 매칭 기준). ⚠ **둘은 길이가 같아야 하고 인덱스로만 짝짓는다 — 좌표계를
  섞으면 카드가 통째로 어긋난다.** 내부에서 `captions_for_places()`로 컷별 자막을
  뽑고, `quotas_for()` 대신 압축 길이 기준 quota를 직접 계산한 뒤
  `build_highlight_cuts()`에 넘긴다. 반환하는 `cuts[]` 원소는
  `{"i","sec","bottom","quota","picks"}`(`picks` 원소 `{"idx","why"}`,
  `why` ∈ `ts|ai|word|like` — 배정 순위 §1 참고), `sec`는 압축 길이 기준이라
  "20초인데 왜 3장?" 같은 화면 표시 불일치가 안 생긴다.
- `pipeline.captions_for_places(captions, places, *, cap=500)` — 컷 구간마다 그 구간에
  걸친 자막을 이어붙여 댓글 추천의 근거 텍스트를 만든다. **`captions`·`places` 둘 다
  같은(압축) 타임라인 좌표여야 한다** — 좌표계가 다르면 엉뚱한 컷에 엉뚱한 자막이
  붙는다. 겹치면 포함, 경계에 닿기만 하면 제외. 500자에서 자른다(그 이상은 Gemini
  토큰만 먹고 매칭 정확도가 안 오름).
- `recommend.is_time_based(cuts)` (구 `is_whole`) — **모든 컷의 자막이 비어 있으면**
  시각 기반 배정으로 판단한다. 예전엔 "컷 1개 + 자막 없음"만 걸렸는데, 자막 없는
  구간이 여러 개인 경우(▶ 유튜브 구간 탭)도 걸리도록 일반화했다. 통짜(컷 1개) 모드는
  이 규칙의 특수 케이스가 됐다.
- `recommend._time_based_picks(cuts, comments, quotas)` (구 `_whole_picks`) — 컷마다
  슬롯을 배정하되 `used` 집합을 컷 사이에 공유해 **한 댓글이 두 컷에 중복 배정되지
  않게** 한다(먼저 도는 컷이 우선).
- `recommend.build_highlight_cuts(hl, comments, *, key=None, quotas=None)` — `quotas`를
  주면 그대로 쓰고, 안 주면 `quotas_for(cuts)`(원본 컷 길이 기준)로 계산한다. 무음
  제거 뒤 실제 길이 기준 장수를 밖에서 계산해 넘기는 경로를 위한 훅.

## 7. yt-dlp 관련 (youtube.py) — 함정 모음

- **JS 런타임 필수**: 최신 유튜브는 JS 챌린지 필요. `_js_runtime_args()`가
  deno→node→bun 순으로 자동 감지해 `--js-runtimes` 지정. 없으면 포맷 누락 →
  다운로드된 스트림으로 ffmpeg가 크래시(exit 3436169992)했던 전례.
- 포맷: `bv*[vcodec^=avc1]+ba[acodec^=mp4a]/…/b` (h264+aac 우선, CapCut 호환).
  받은 게 h264 아니면 ffmpeg 재인코딩.
- 한글 경로: `PYTHONIOENCODING=utf-8` env + stdout 파싱 대신 **glob으로 결과 파일 탐색**
  (Windows cp949 디코드 깨짐 방어). 모든 subprocess는 `encoding="utf-8", errors="replace"`.
- 정밀 컷: `--force-keyframes-at-cuts` 1차 → 실패 시 keyframe 컷 폴백.
- ⚠ **초록 화면(GOP 중간 컷) — 반드시 검증할 것**: 위 폴백이 걸리면 yt-dlp가
  키프레임이 아닌 위치에서 스트림 복사로 잘라, **첫 키프레임 전까지 참조 프레임이 없는**
  파일이 나온다(실측: 9컷 중 1개, 첫 키프레임 2.27s). 그대로 concat 재인코딩하면
  그 구간이 통째로 **초록 화면**으로 구워진다.
  - **탐지 함정 3종**: ① 파트를 단독 재생하면 ffmpeg가 깨진 앞부분을 건너뛰어 멀쩡해 보인다.
    ② `ffprobe -read_intervals`는 키프레임으로 **시크**해버려 첫 프레임을 놓친다.
    ③ `ffmpeg -v error`로 디코딩해도 **에러가 안 난다**(h264 은닉 처리). 픽셀로만 보인다.
  - **유일한 확실한 판정**: 시크 없이 앞에서부터 프레임을 훑어 **첫 키프레임 시각**을 본다
    → `_first_keyframe_sec()`. 0이 아니면 그만큼 앞이 깨진 것.
  - **대응**(`cut_youtube` / `cut_youtube_precise` 공통): 검증 실패 → 재다운로드
    (`DL_ATTEMPTS=2`, 간헐적이라 보통 여기서 해결) → 그래도 깨지면 앞에 `RECUT_LEAD=6`초
    여유를 붙여 받아 **로컬에서 뒤쪽 want초만 재인코딩**해 잘라낸다(깨진 앞부분은 버리는
    여유 구간에 들어가므로 항상 깨끗). 수리 내역은 `REPAIR_LOG` → SSE 로그(🩹)로 노출.
  - ⚠ Windows: `_first_keyframe_sec`의 ffprobe 파이프를 안 닫으면 파일이 잠겨
    바로 뒤 `_unlink`가 PermissionError로 실패한다 → `stdout.close()` 후 kill/wait.
- "Video unavailable" 디버깅: `curl "https://www.youtube.com/oembed?url=...&format=json"`
  이 404면 yt-dlp 문제가 아니라 **영상 자체가 없는 것**(LLM이 지어낸 ID 등).

## 8. UI (index.html) 요약

- 탭 3개: 파일 / 유튜브 구간 / **붙여넣기(기본 활성)**. 붙여넣기 탭에선 제목 입력칸
  숨김(JSON에 있으므로), 옵션들은 노출.
- 유튜브 탭: "＋ 구간 추가"로 구간 여러 개(각 행 시작/끝, ✕ 삭제). 시간 자동 포맷
  (4314→43:14), 끝 비우면 시작+90초.
- 옵션(공통): 영상 확대 슬라이더(기본 **144%**) / 댓글 카드 폴더 / 좌우반전 /
  장면분할(**기본 체크**) / 배경 흰색(**기본 체크**) / 무음 제거(붙여넣기용, 기본 꺼짐).
- 헤더 우측 고정 링크: ✨ AI Studio(aistudio.google.com), 💬 댓글 카드(h-lab).
- 완료 시 결과 카드(총 소요시간 포함) + "완료되면 CapCut 자동 실행" 체크.
- `server/static/auto.js`: 컷별 카드 패널 렌더는 `renderCutPanel(box, panelId, data,
  opts)` 하나로 통합돼 있다 — 🤖 자동 탭(2차 검토, `panelId=hl.id`) · ▶ 유튜브 구간 탭
  (`panelId="yt"`) · 📋 붙여넣기 탭(`panelId="paste"`) 검토 화면이 전부 이 함수를 같이
  쓴다. **왜**: 예전엔 자동 탭만 있었는데, 유튜브 구간·붙여넣기 탭에 같은 검토 화면을
  추가하면서 세 갈래가 되어 한 곳만 고치는 실수가 나기 쉬웠다(하나로 통합해 예방).
  `data.cuts`가 있으면 컷별 섹션(`컷 N · X초 · 카드 Q장 — 자막`), 없으면 기존 ⭐/➕
  폴백을 그린다 — 세 탭 모두 `*_stream`이 낸 `result`(§3·§6)를 그대로 이 함수에 넘긴다.

## 9. 현재 고정값 치트시트

**레이아웃은 `pipeline.py` 상단 상수 한 곳에서 파생된다** — 여기만 고치면 전부 따라 움직인다.
(예전엔 `배경.png` 흰밴드 자동감지였으나 좌표를 정확히 통제하려고 상수로 바꿨다.
 `배경.png`는 더 이상 레이아웃에 관여하지 않는다.)

```python
VIDEO_TOP = 323        VIDEO_BOTTOM = 1122     # 영상 창 (흰 띠 사이)
TITLE_TOP_Y = 109      TITLE_MAIN_Y = 252      # 제목 두 줄 중앙
CAPTION_GAP = 72       EFFECT_GAP = 25         # 영상 창 안쪽 아래/위 여백
COMMENT_TOP = VIDEO_BOTTOM                     # 댓글 카드 윗변 = 영상 바로 아래
CHANNEL_RATIO = 0.85                           # 아래 띠에서 85% 지점
```

| 항목 | 캔버스 y(px) | transform_y | CapCut 표시 |
|---|---|---|---|
| 영상 창(투명 구간) | 323 ~ 1122 | — | — |
| 영상(main) 중앙 | 722.5 | 0.2474 | Y 475 |
| title_top (주황 size14) | 109 | 0.8865 | Y 1702 |
| title_main (흰색 size18) | 252 | 0.7375 | Y 1416 |
| 효과자막 (#0dff63 size13, bold X) | 348 | 0.6375 | Y 1224 |
| 하단 자막 (제주명조체 size **12**, **흰색**, 검은 획 40, 배경·그림자 없음) | 1050 | −0.0938 | Y −180 |
| 댓글 카드 (scale 0.89, X 0, 3초/장) | 윗변 1122 | 카드마다 계산 | — |
| channel (size 10) | 1800 | −0.8753 | Y −1681 |
| 영상 확대 기본 | — | — | 144% |

- **댓글 카드 세로 위치는 카드마다 다르게 계산**된다 — 카드 이미지 높이가 제각각이라
  중앙값 하나로는 "영상 바로 아래"에 못 붙인다. `표시높이 = 1080 × (h/w) × 0.89`,
  `중앙 = COMMENT_TOP + 표시높이/2`. (`comment_top` 인자, 실측: 422px/590px 카드 모두 윗변 1122)

## 10. 알려진 제약 / 하지 말 것

- **코드 수정 후 .bat 재시작 필수** — 안 하면 옛 코드가 계속 돎(가장 흔한 "안 돼요" 원인).
- Gemini 오디오 전사의 타임스탬프를 자막 타이밍으로 쓰지 말 것(드리프트). 타이밍은
  Whisper 단어 타임스탬프만.
- 같은 텍스트 트랙에 동시간 세그먼트 2개 금지(SegmentOverlap).
- 오버레이(frame/comment)·제목 트랙은 생성 시 자동 잠금(§5). **이 잠금을 빼지 말 것** —
  CapCut 편집 중 render_index 재배치로 영상이 프레임 위로 삐지는 버그의 예방책. 편집하다
  제목을 고쳐야 하면 그 트랙 자물쇠만 잠깐 푼다. (`bg`는 잠그지 않는다 — 맨 아래라 무해)
- ⚠ **잠금으로도 못 막는 경우 — `main` 트랙(미해결)**: main 은 편집용이라 잠글 수 없고,
  CapCut 은 **새로 만든 비디오 세그먼트(복붙·분할 후 이동)** 에 `max(비디오 render_index)+1`
  을 새로 찍는다(실측 4건: 최대 3 → **4**, 최대 2 → **3**). 그 클립만 frame·comment 위로
  올라가 확대 시 흰 띠·댓글 위로 삐진다.
  - **생성 시점 예방은 미해결**. frame 을 비디오 대역 밖(14500)으로 올리는 방식을 시도했다가
    **원복**했다(사용자 요청). 근거·재시도 조건은 `레이어_삐짐_수리.md` 참고.
  - 현재 수단은 **사후 수리**: `draft.repair_layers(draft_dir)` — 트랙별 정상 render_index
    (bg0/main1/frame2/comment3)로 되돌리고 재잠금, `draft_content.repair.bak` 백업.
    UI 하단 **"🩹 레이어 수리"** / `GET /drafts`(꼬임 감지) · `POST /repair`.
  - ⚠ `POST /repair` 는 **CapCut 실행 중이면 409 로 거부** — 열어둔 채 수리하면 CapCut 이
    메모리 상태로 덮어써 되돌아간다(실측: 11:04:32 수리 → 11:05:39 CapCut 저장으로 원복).
- `JOBS`/`ANALYSES`/`YSTATES`/`PSTATES`/`PREPARES` 전부 메모리 저장 — 서버 재시작하면
  전부 소실(§3). ▶ 유튜브 구간·📋 붙여넣기·🤖 자동 탭은 분석(1단계)과 빌드(3단계)
  사이에 서버가 재시작되면 검토 화면에서 빌드를 눌러도 "분석 결과가 만료됐습니다"
  404가 뜬다 — 해결은 재분석뿐(재시작 원인 자체를 없앨 수는 없음, SETUP.md §10).
- 검증은 최종적으로 **사용자가 CapCut에서 열어 확인**하는 방식.
- v1(`../capcut`, 포트 8000)은 별개 코드베이스 — 여기 수정해도 v1에 반영 안 됨(역도 동일).

## 11. 협업 시 참고

- 파이썬 검증 습관: `python -c "import ast; ast.parse(open(f,encoding='utf-8').read())"`
  → `from server import app` 임포트 확인 → 가능하면 실제 드래프트 빌드 후
  draft_content.json을 열어 값 검증(테스트 드래프트는 빌드 후 삭제).
- Windows 콘솔은 cp949라 한글/특수문자 print가 깨져 보일 수 있음(로직과 무관).
- 사용자의 CapCut 좌표 요청("위치 −559로") = transform_y로 환산해 반영하면 됨(§2).
- 테스트용 영상이 필요하면 `.downloads/`의 기존 mp4를 재사용(네트워크 불필요).
