capcut-agent/ARCHITECTURE.md
hehihoho3@gmail.com f66b396d71 feat: 하단 자막을 제주명조체·흰색·검은 획 40 으로 (배경·그림자 없음)
캡컷 인스펙터 실측 스크린샷과 1:1 로 맞춤:
- 글꼴 제주명조체(JEJU_MYEONGJO, 캐시 id 7480851064179133702) — 자막 트랙에만 주입
- 색상 주황 #ff8000 → 흰색, 볼드 해제
- 획(외곽선) 검정 두께 40 추가 (CAPTION_BORDER_W)
- 그림자 제거 (_apply_shadow_to_track 을 caption 에 안 부름)
- 크기 12·배경 없음은 기존 그대로

폰트는 전체 주입(_apply_font_to_texts, 코트라 볼드체) 뒤에 새 헬퍼
_apply_font_to_track 으로 caption 트랙만 덮어쓴다 → 제목·출처는 코트라 볼드체 유지.
캐시 없는 PC 에서는 주입이 생략돼 안전하게 코트라 볼드체로 남는다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 22:10:40 +09:00

462 lines
37 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 캡컷 에이전트 · 구간합치기 (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를 재사용(네트워크 불필요).