capcut-agent/ARCHITECTURE.md
hehihoho3@gmail.com 754692a343 문서 3개를 실제 동작에 맞게 고침
계획서: "ci 가 undefined 면 마지막 컷 뒤에 붙는다, 배치상 문제 없다"는 근거가 틀렸다.
_cards_by_cut 은 컷 뒤가 아니라 컷 안에서 균등 분할한다 — 마지막 컷에 몰리면 번쩍인다.
왜  를 컷 안으로 흡수했는지로 다시 썼고 Step 3/4 코드도 실제 구현으로 맞췄다.

ARCHITECTURE.md: "카드도 자막과 같은 _remap_caps() 로 재매핑" → 실제로는
_remap_placements() 로 컷 구간을 옮기고 그 안에서 나눈다(무음 제거 뒤에 계산).
3초 하한 규칙도 명시.

SETUP.md: "컷 섹션이 안 보이고  하나만 뜬다 | Gemini 한도 초과" 는 나오지 않는 증상이다
— 한도를 넘겨도 컷 섹션은 그대로 뜨고 🤖 배지만 사라진다. 또 경고는 "검토 화면 상단"이
아니라 로그 영역(alog)으로 나간다. 두 행을 실제 증상 4행으로 다시 썼다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 14:16:45 +09:00

349 lines
26 KiB
Markdown
Raw 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. 엔드포인트 4개 + SSE 스트림
│ └─ static/index.html UI 전체(단일 파일, 탭 3개 + 옵션 + SSE 렌더)
└─ capcut_agent/
├─ pipeline.py ★ 두 파이프라인(process_bg_template / process_paste)
├─ 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 컷별 댓글 추천(자동 탭) — 타임스탬프 우선 + 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) |
| `POST /youtube` | 유튜브 탭. `url` + `ranges`(JSON `[["mm:ss","mm:ss"],…]`) + 옵션 |
| `POST /paste` | 붙여넣기 탭. `data`(편집안 JSON 문자열) + 옵션 |
| `GET /stream/{job_id}` | SSE. job 종류에 따라 파이프라인 실행, 이벤트 스트림 |
| `POST /open-capcut` | CapCut 실행(시작메뉴 lnk → LOCALAPPDATA exe 폴백) |
- job 은 메모리 dict `JOBS[hash]`. hash = 입력 시그니처 sha1 12자.
- SSE 이벤트 형식: `{"type": "manifest"|"step"|"log"|"error"|"result", ...}`
- `manifest`: `{steps:[{id,label}]}` / `step`: `{id,status:"start"|"done",elapsed,detail}`
- `result`: `{draft_name, draft_path, stats:{duration,kept,cut,segments,captions,elapsed}}`
- 공통 폼 필드: `video_scale`(% 문자열, 기본 144), `flip`, `scene`, `bg_white`,
`comments_dir`(폴더 경로 문자열), `title_top/title_main/channel`(파일·유튜브만),
`remove_silence`(붙여넣기만). 불리언은 "1"/"0" 문자열 → `_truthy()`.
## 4. 탭별 파이프라인
### 4-A. 📁 파일 / ▶ 유튜브 구간 → `process_bg_template()` (pipeline.py)
단계: `[download] → silence → asr → [scene] → draft`
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. 📋 붙여넣기 (기본 탭) → `process_paste()` (pipeline.py)
LLM이 만든 편집안 JSON을 **그대로** 사용. 무음컷·ASR **기본 없음**(옵션으로 무음 제거 가능).
단계: `download(컷 정밀) → [remove_silence] → [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).
자동 탭에서 넘어온 경우 `process_paste(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/초과 오류 방어).
- **자막 스타일**: **주황 `#ff8000`**(`CAPTION_COLOR`) + 볼드 + **그림자**, **배경박스 없음**,
크기 **`CAPTION_SIZE = 12.0` 고정**(캡컷 폰트 크기 1:1).
배경은 `TextSegment(background=...)` 인자를 **생략**해서 끈다 → `background_style` 키 자체가
안 나가고 CapCut 이 읽으면서 0(없음)으로 채운다(제목 텍스트가 원래 이 방식).
그림자는 저장 후 `_apply_shadow_to_track(draft_dir, "caption", _TEXT_SHADOW)` 로 주입.
예전엔 최장 줄 기준 `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에서 폰트 미사용) 주입 생략 기본 폰트로 안전 동작.
- **트랙 자동 잠금**(`_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()` 공통): 자동 탭은 검토 화면에서
카드별 소속 인덱스(`card_cuts`) 보내고, `_cards_by_cut(paths, card_cuts, placements, dur)`
**그 컷 구간 안에서** 균등 배치한다( 컷이 차도 다음 카드가 앞으로 밀리지 않음).
파일/유튜브 ·붙여넣기 직접 사용은 소속을 몰라 기존 `_load_comment_cards` 전체 균등
배치 그대로 쓴다.
- 컷당 장수 상한은 `max(1, floor(컷길이/3초))` `_load_comment_cards` 같은 규칙.
초과분은 버린다(카드가 1초씩 번쩍이느니 빼는 낫다).
- **카드 시간은 서버가 미리 확정하지 않는다.** `/auto/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 연동해 완전 자동화 아이디어 있음)
## 7. yt-dlp 관련 (youtube.py) — 함정 모음
- **JS 런타임 필수**: 최신 유튜브는 JS 챌린지 필요. `_js_runtime_args()`
denonodebun 순으로 자동 감지해 `--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에 있으므로), 옵션들은 노출.
- 유튜브 탭: " 구간 추가" 구간 여러 ( 시작/, 삭제). 시간 자동 포맷
(431443:14), 비우면 시작+90초.
- 옵션(공통): 영상 확대 슬라이더(기본 **144%**) / 댓글 카드 폴더 / 좌우반전 /
장면분할(**기본 체크**) / 배경 흰색(**기본 체크**) / 무음 제거(붙여넣기용, 기본 꺼짐).
- 헤더 우측 고정 링크: AI Studio(aistudio.google.com), 💬 댓글 카드(h-lab).
- 완료 결과 카드( 소요시간 포함) + "완료되면 CapCut 자동 실행" 체크.
## 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**, **#ff8000**, 그림자, 배경 없음) | 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` 메모리 저장 서버 재시작하면 job 소실(스트림 전에 재시작하면 재제출 필요).
- 검증은 최종적으로 **사용자가 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를 재사용(네트워크 불필요).