# 작업 기록 (2026-07-27 ~ 07-31) — 다른 PC 재현 가이드

> **읽는 대상**: 다른 Claude 세션 / 다른 PC에서 같은 상태를 만들려는 사람.
> 이 문서는 **이 기간에 내가 바꾼 것만** 다룬다. 앱 전체 구조는 `ARCHITECTURE.md`,
> 설치는 `SETUP.md`, 레이어 삐짐 상세는 `레이어_삐짐_수리.md` 참고.
>
> ⚠ 이 기간에 **다른 세션이 별도로 작업한 부분**(`/auto/*`, `/prompts`, `/yt/comments`
> 엔드포인트, `index.html` 디자인 개편, `_load_comment_cards` 배치 로직)은 여기 없다.
> 그건 내 변경이 아니므로 이 문서만 보고 재현하면 안 된다.

바꾼 파일: `capcut_agent/draft.py`, `capcut_agent/pipeline.py`, `capcut_agent/youtube.py`,
`capcut_agent/media.py`, `server/app.py`, `server/static/index.html`, `build_bg_template.py`,
`ARCHITECTURE.md`, `SETUP.md`.

**모든 변경 후 `.bat` 재시작 필수** (파이썬 코드가 uvicorn에 물려 있어 hot-reload 안 됨).

---

## 요약 — 무엇을 왜 바꿨나

| # | 작업 | 상태 |
|---|---|---|
| 1 | 초록 프레임(GOP 중간 컷) 자동 검증·복구 | ✅ 완료·검증됨 |
| 2 | 레이어 삐짐 — 사후 수리 기능 + CapCut 실행중 가드 | ✅ 완료 (근본 예방은 **미해결**) |
| 3 | 레이어 대역 분리(생성 시점 예방) 시도 | ❌ **원복함** |
| 4 | 하단 자막 크기 10 고정 | ✅ 완료 |
| 5 | `bg` 트랙 잠금 해제 | ✅ 완료 |
| 6 | 템플릿 레이아웃 전면 재조정(상수화) | ✅ 완료·검증됨 |
| 7 | 백업 파일명 충돌 수정 | ✅ 완료 (버그였음) |

---

## 1. 초록 프레임 자동 검증·복구 (`youtube.py`)

### 증상
합쳐진 영상 중간에 **초록 화면이 2초쯤** 나온다. 타임라인 썸네일에도 초록으로 보인다
(= 영상 파일 자체에 구워진 것, CapCut 문제 아님).

### 원인 (실측)
`yt-dlp --download-sections`가 **가끔 키프레임이 아닌 위치에서 스트림 복사로** 잘라,
첫 키프레임 전까지 참조 프레임이 없는 파일을 만든다.

```
붙여넣기 9컷 중 fe92bcbe1e 파트 → 첫 키프레임이 2.269초 뒤
   병합본의 초록 구간 20.32~22.5s 와 정확히 일치 (파트 시작 20.32 + 2.27)
구간 탭 파일 2개도 깨짐 → 첫 키프레임 2.202s / 1.535s
   그 병합본(merged_809fd228)의 초록: 108.0~110.4s
```

`--force-keyframes-at-cuts`가 그 파트에서만 실패해 키프레임 컷으로 폴백한 결과인데,
**검증이 없어서 그대로 통과**했다.

### ⚠ 탐지 함정 3종 (이것 때문에 오래 못 잡았다)

1. **파트를 단독 재생하면 멀쩡하다** — ffmpeg가 깨진 앞부분을 건너뛴다.
   concat **재인코딩할 때만** 초록으로 구워진다.
2. **`ffprobe -read_intervals`는 못 잡는다** — 키프레임으로 **시크**해버려
   "첫 프레임이 I프레임"이라고 거짓 보고한다. (`%+2`와 `%+#5`가 서로 다른 답을 냈다)
3. **`ffmpeg -v error`로 디코딩해도 에러가 안 난다** — h264 은닉 처리. 픽셀로만 보인다.

→ **유일하게 확실한 판정**: 시크 없이 앞에서부터 프레임을 훑어 **첫 키프레임 시각**을 본다.

### 추가한 것

```python
KEYFRAME_TOL = 0.05     # 첫 키프레임이 이보다 늦으면 앞부분 깨짐으로 판정(초)
DL_ATTEMPTS  = 2        # 깨진 결과 재다운로드 횟수(간헐적 실패용)
RECUT_LEAD   = 6.0      # 최후 수단: 앞에 이만큼 여유를 받아 로컬에서 다시 자름(초)
REPAIR_LOG: List[str] = []   # 수리 내역 → 파이프라인이 SSE 로그로 흘림
```

| 함수 | 역할 |
|---|---|
| `_first_keyframe_sec(path)` | 시크 없이 훑어 첫 키프레임 시각(초). 0이면 정상. 첫 키프레임 만나면 즉시 종료 |
| `_duration(path)` | ffprobe 길이 |
| `_encode_h264(src, dst, ss, t)` | h264/aac 재인코딩. `ss`/`t`를 **`-i` 뒤**에 둬 프레임 정확(출력 시크) |
| `_unlink(path)` | 조용한 삭제 |
| `_dl_section(...)` | 구간 1회 다운로드(force→폴백) + h264 정규화. `tag`로 재시도 파일명 분리 |

**동작 (구간 탭 `cut_youtube` / 붙여넣기 `cut_youtube_precise` 공통)**

1. 받은 파일의 첫 키프레임이 맨 앞이면 → 통과
2. 아니면 버리고 **재다운로드** (`DL_ATTEMPTS=2`) — 간헐적이라 대개 여기서 해결
3. 그래도 깨지면 → 앞에 `RECUT_LEAD`초 **여유를 붙여 받아 로컬에서 뒤쪽 want초만 재인코딩**.
   깨진 앞부분은 버리는 여유 구간에 들어가므로 **항상 깨끗**하다.

수리가 일어나면 웹 UI 로그에 `🩹` 로 표시된다 (`REPAIR_LOG` → `pipeline.py`에서 yield).

### ⚠ Windows 함정 (실제로 터졌다)
`_first_keyframe_sec`가 ffprobe **파이프를 안 닫으면 파일이 잠겨** 바로 뒤 `_unlink`/덮어쓰기가
`PermissionError`로 실패한다. → `finally`에서 `stdout.close()` → `kill()` → `wait()`.

### 검증 (실제 다운로드로 확인함)

| 경로 | 결과 |
|---|---|
| 정상 | 5.505s (요청 5.500s), 첫 키프레임 0.000s |
| 폴백 재컷 강제 | 5.005s (요청 5.000s), 첫 키프레임 0.033s, 임시파일 잔여 없음 |

재현 명령:
```bash
python -X utf8 -c "
import sys; sys.path.insert(0,'.')
from capcut_agent.youtube import _first_keyframe_sec, _duration
p='.downloads/<파일>.mp4'
print(_first_keyframe_sec(p), _duration(p))   # 첫 값이 0.05 초과면 앞부분 초록
"
```

> **이미 받아둔 깨진 영상은 복구 불가** — 다시 만들어야 한다.

---

## 2. 레이어 삐짐 — 사후 수리 (`draft.py` / `app.py` / `index.html`)

상세는 `레이어_삐짐_수리.md`. 여기선 **재현에 필요한 것만**.

### 증상
CapCut에서 클립을 **복붙하거나 자르고 옮긴 뒤 확대**하면, 그 클립만 흰 띠·댓글 위로
올라가 템플릿 밖으로 삐져나온다.

### 원인
CapCut이 **새로 생긴 비디오 세그먼트**(복붙·이동)에 렌더순서를 **frame(2) 위로 새로 찍는다.**
관측값은 대부분 `max(비디오 ri)+1`(=4)이었으나 `3`인 반례도 있어 "정확히 max+1"이 보편은 아니다.
확실한 건 **frame 위로 재부여된다**는 것뿐. 수리는 정상값으로 되돌리는 방식이라 3이든 4든 동일하게 고쳐진다.

`main` 트랙은 편집해야 하므로 잠글 수 없고, 문제는 그 안에서 일어난다 → **트랙 잠금으로 못 막는다.**

### 추가한 것 — `draft.py`

| 이름 | 역할 |
|---|---|
| `CANON_RI = {"bg":0,"main":1,"frame":2,"comment":3}` | 트랙별 정상 render_index |
| `_count_bad_ri(json_path)` | 정상값과 다른 비디오 세그먼트 수 |
| `list_drafts(draft_root)` | 드래프트 목록(최근순) + `broken`/`bad` |
| `repair_layers(draft_dir)` | 정상값으로 되돌리고 `_lock_tracks` 재적용. 멱등 |

### 추가한 것 — `server/app.py`

- `GET /drafts` → `{"drafts":[{name, mtime, broken, bad}, …]}` 최근 30개
- `POST /repair` (form `draft=<폴더명>`, `force`) → `{"ok","fixed","detail"}`
  - 경로는 `os.path.basename`으로 정규화(드래프트 루트 밖 접근 차단)
  - **`_capcut_running()`이면 409로 거부** (`force=1`로만 강행)
- `_capcut_running()` = `tasklist /FI "IMAGENAME eq CapCut.exe"` 파싱

### ⚠ 이 가드가 왜 필수인가 (실측)
```
11:04:32  수리 실행 (백업엔 main ri=[1,4])
11:05:39  CapCut이 저장 → 메모리 상태로 덮어써 ri=4 부활
```
CapCut은 열려 있는 동안 **파일을 다시 읽지 않는다.** 가드가 없으면
"수리했는데 그대로예요"가 무한 반복된다. 실제로 두 번 반복됐다.

### 사용 절차 (순서 지켜야 함)
1. CapCut에서 **그 프로젝트를 닫는다** (권장: 프로그램 완전 종료)
2. 웹 UI 하단 **🩹 레이어 수리** → 드래프트 선택 → 실행
3. CapCut에서 다시 연다

### UI — `index.html`
페이지 하단 `<details>` 섹션. 펼치면 `/drafts`를 불러 `⚠ 이름 (꼬임 N개)` / `✓ 이름`으로
표시하고, 선택 후 실행 → 결과 표시 → 목록 갱신.

---

## 3. ❌ 원복함 — 레이어 대역 분리 (생성 시점 예방 시도)

**지금 코드에 없다. 다시 넣지 말 것(사용자가 원복 요청).**

시도한 것: `frame`/`comment`를 비디오 대역 밖(`14500`/`14501`)으로 올리고 텍스트를 `24000+`로 밀기.
가설: CapCut의 `max+1`이 **비디오 대역 안에서만** 계산되면 복붙 클립은 계속 4를 받아 프레임 아래에 갇힌다.

근거는 있었다 — 사용자가 추가한 **텍스트**는 텍스트 대역 안에서만 증가(14109→14114)했는데,
복붙한 **비디오**는 그 14000번대를 **무시하고** 4를 받았다.

**결과: 검증 못 하고 원복.** 판별용 드래프트를 만들었으나 사용자가 삭제했고,
그 뒤 사용자가 원복을 요청했다. `레이어_삐짐_수리.md`의 (a-2) 절은 **이 원복을 반영 안 한 상태**로
남아 있으니 그대로 믿지 말 것.

⚠ **CapCut UI 자동화로는 검증 못 한다**: `orca computer`로 붙어봤으나 CapCut은
접근성 트리가 비어 있고(94자) 스크린샷도 실패한다. 사람이 직접 눌러야 한다.

### 막다른 길 (다시 시도하지 말 것)
- **마스크**: pycapcut `add_mask`의 좌표는 "以素材的像素为单位"(소재 픽셀 기준) → 클립과 같이 확대됨. 클램프 불가.
- **frame을 스티커 트랙으로**: `StickerSegment`는 CapCut 클라우드 `resource_id`만 받음. 로컬 PNG 불가.
- **영상에 흰 띠 미리 굽기**: 확대하면 띠까지 커져 화면 밖으로 나감.
- **`main` 트랙 잠금**: 사용자가 편집을 못 함.

---

## 4. 하단 자막 크기 10 고정 (`draft.py`)

```python
CAPTION_SIZE = 10.0    # 캡컷 폰트 크기와 1:1
```

기존엔 `fit_caption_size()`가 최장 줄 기준으로 7~13 사이 **자동 결정** → 드래프트마다 크기가 달랐다.
이제 항상 10. 세 탭 공통(같은 빌더).

- 넘침 없음: 자막 청킹 하드캡이 한 줄 14자, 크기 10이면 한 줄 폭 ≈ 14×51.5 ≈ **721px < 1080**.
- `fit_caption_size()`는 **지우지 않고 "미사용" 표시만** 해뒀다(되돌릴 때 사용).

검증: 실제 드래프트의 `draft_content.json`에서 `size=10.0` 확인 (서브 14 / 메인 18은 그대로).

---

## 5. `bg` 트랙 잠금 해제 (`draft.py`)

```python
LOCK_TRACKS = ("frame", "comment", "title_top", "title_main", "channel")   # "bg" 제거
```

`bg`(흰 배경)는 **맨 아래 레이어라 순서가 꼬여도 화면에 영향이 없고**, 영상 길이를 늘릴 때
같이 늘려야 해서 잠겨 있으면 불편하다.

`frame`·`comment`·제목 트랙은 **계속 잠근다** — 영상 위 레이어라 쪼개지면 순서가 꼬인다.

검증: 생성 직후 `attribute` — bg=0, main=0, frame=4, comment=4, caption=0, 제목류=4.
(잠금 = `attribute` 비트2(값 4). mute 비트0은 OR로 보존)

> ⚠ `_lock_tracks`는 **잠그기만 하고 풀지 않는다.** 이미 만들어진 드래프트의 bg는
> CapCut에서 자물쇠를 직접 눌러 풀어야 한다.

---

## 6. ★ 템플릿 레이아웃 전면 재조정 (`pipeline.py` / `media.py` / `draft.py`)

### 무엇이 바뀌었나
예전엔 `배경.png`의 흰 밴드를 **자동 감지**(`detect_white_band`)해서 좌표를 잡았다.
→ 좌표를 정확히 통제할 수 없어서 **상수로 바꿨다.** `배경.png`는 이제 레이아웃에 관여하지 않는다.

### 조정 지점 — `capcut_agent/pipeline.py` 상단 (여기만 고치면 전부 따라 움직임)

```python
CANVAS_H      = 1920
VIDEO_TOP     = 323    # 영상 창 시작 = 위 흰 띠가 끝나는 지점
VIDEO_BOTTOM  = 1122   # 영상 창 끝   = 아래 흰 띠가 시작하는 지점
TITLE_TOP_Y   = 109    # 서브제목(주황) 중앙
TITLE_MAIN_Y  = 252    # 메인제목(흰색) 중앙
CAPTION_GAP   = 72     # 하단 자막 중앙 = VIDEO_BOTTOM − 이 값
EFFECT_GAP    = 25     # 효과자막 중앙 = VIDEO_TOP + 이 값
COMMENT_TOP   = VIDEO_BOTTOM   # 댓글 카드 윗변 = 영상 바로 아래(딱 붙음)
CHANNEL_RATIO = 0.85   # 출처: 아래 띠에서 85% 내려간 지점
```

이 값들은 **사용자가 준 레퍼런스 템플릿 이미지를 픽셀 측정해서** 뽑았다
(레퍼런스 폭 311px → 캔버스 1080px 환산계수 3.4727).

### 변경 전/후

| 요소 | 이전 | 이후 |
|---|---|---|
| 영상 창 | 453 ~ 1311 | **323 ~ 1122** |
| 서브제목 | y 203 (Y 1513) | **y 109** (Y 1702) |
| 메인제목 | y 344 (Y 1232) | **y 252** (Y 1416) |
| 하단 자막 | y 1240 (Y −559) | **y 1050** (Y −180) |
| 효과자막 | y 478 (Y 965) | **y 348** (Y 1224) |
| 댓글 카드 | 중앙 y 1541 **고정** | **윗변 1122 = 영상 바로 아래** |
| 출처 | y 1829 | y 1800 |

> 자막·효과자막도 **같이 옮겨야 한다** — 안 그러면 새 영상 창 밖으로 나간다.

### 딸린 변경

**`media.py`** — `make_frame()`에 `band_color` 인자 추가(기존엔 검정 하드코딩).
`_template_pos`가 `make_transparent_frame`(배경.png 감지) 대신 `make_frame`(명시 좌표)을 쓴다.

**`draft.py`** — `comment_top` 인자 추가. 댓글 카드 세로 위치를 **카드마다 계산**한다:

```python
표시높이 = cw × (ih / iw) × COMMENT_SCALE      # COMMENT_SCALE = 0.89
중앙     = comment_top + 표시높이 / 2
```

⚠ **카드 이미지 높이가 제각각이라 중앙값 하나로는 "영상 바로 아래"에 못 붙인다.**
(CapCut scale 1.0 = contain. 댓글 카드는 캔버스보다 가로로 넓어 가로가 먼저 맞음)

**`pipeline.py`** — `caption_y`/`effect_y` 하드코딩(`-559/1920`, `965/1920`) 제거.
전부 `_template_pos()`가 상수에서 파생 → **값이 두 군데로 갈라지지 않는다.**

**`build_bg_template.py`**(단독 CLI) — 자체 좌표 계산을 버리고 `_template_pos()` 재사용.

### 검증 (실측)

```
프레임 PNG 투명 구간: 323 ~ 1121  ✓
main 722.5 / caption 1050.0 / title_top 109.0 / title_main 252.0 /
effect 348.0 / channel 1800.3
댓글 카드 — 높이 다른 두 장(422px, 590px) 모두 윗변 정확히 1122  ✓
```

재현 명령:
```bash
python -X utf8 -c "
import sys; sys.path.insert(0,'.')
from capcut_agent.pipeline import _template_pos
frame,bg,pos=_template_pos(True)
for k,v in pos.items():
    print(k, v, '' if k=='comment_top' else f'→ 캔버스y={960-960*v:.1f}px, 캡컷Y={round(v*1920)}')
"
```

---

## 7. 백업 파일명 충돌 수정 (버그였음)

`repair_layers`의 백업을 처음에 `draft_content.json.bak`으로 만들었는데,
**그게 CapCut 자체 백업 파일명**이었다. 손대지 않은 프로젝트에도 전부 존재한다.
→ 내 수리가 CapCut 백업 2개를 덮어썼다(프로젝트 자체엔 영향 없음).

**→ `draft_content.repair.bak` 으로 변경. `draft_content.json.bak`은 절대 쓰지 말 것.**

---

## 다른 PC에서 재현하는 순서

1. **선행 조건 확인** — `SETUP.md` 기준. PATH에 `ffmpeg`/`ffprobe`, `yt-dlp`,
   Node.js 또는 deno(yt-dlp JS 런타임). CapCut 설치 + 코트라 볼드체 캐시(없으면 기본 폰트로 안전 동작).
2. 위 **1·2·4·5·6·7**을 순서대로 적용. 3번은 **적용하지 말 것**(원복된 실험).
3. `.bat` 재시작.
4. 아래 검증 전부 통과 확인.

### 검증 명령 모음

```bash
# 구문 + 임포트 체인
python -X utf8 -c "
import ast
for f in ['capcut_agent/draft.py','capcut_agent/pipeline.py','capcut_agent/youtube.py',
          'capcut_agent/media.py','server/app.py','build_bg_template.py']:
    ast.parse(open(f,encoding='utf-8').read())
print('구문 OK')
from server import app; print('임포트 체인 OK')
"

# 상수 확정값
python -X utf8 -c "
import sys; sys.path.insert(0,'.')
from capcut_agent.draft import CAPTION_SIZE, LOCK_TRACKS, CANON_RI, COMMENT_SCALE
from capcut_agent.pipeline import VIDEO_TOP, VIDEO_BOTTOM, TITLE_TOP_Y, TITLE_MAIN_Y
from capcut_agent.youtube import KEYFRAME_TOL, DL_ATTEMPTS, RECUT_LEAD
print(CAPTION_SIZE, LOCK_TRACKS, CANON_RI, COMMENT_SCALE)
print(VIDEO_TOP, VIDEO_BOTTOM, TITLE_TOP_Y, TITLE_MAIN_Y)
print(KEYFRAME_TOL, DL_ATTEMPTS, RECUT_LEAD)
"
```

**기대값**
```
10.0 ('frame','comment','title_top','title_main','channel') {'bg':0,'main':1,'frame':2,'comment':3} 0.89
323 1122 109 252
0.05 2 6.0
```

### 실제 드래프트로 최종 확인
테스트 드래프트를 만들어 `draft_content.json`을 열어보고 **확인 후 반드시 삭제**한다
(`shutil.rmtree`). 이 프로젝트엔 자동 테스트 스위트가 없고, 최종 검증은 사용자가
CapCut에서 열어보는 방식이다.

---

## 미해결로 남은 것

1. **레이어 삐짐 근본 예방** — 생성 시점 예방책 없음. 현재는 사후 수리뿐.
   재도전하려면 §3의 막다른 길 목록을 먼저 읽을 것.
2. **제목이 안 보인다는 보고**(2026-07-29) — 파일상으로는 정상이었다.
   내용·크기·색·폰트·시간범위·`visible=true`·위치 전부 확인했으나 미리보기에만 안 나왔다.
   원인 미규명. 재발하면 트랙의 👁(눈) 아이콘 상태부터 확인할 것.
3. **영상보다 오버레이가 짧아지는 문제** — 클립을 뒤에 추가하면 `frame`·제목 트랙은
   생성 당시 길이에서 끝나 그 뒤 구간엔 템플릿이 아예 없다(실측: 영상 44.13s vs 프레임 33.97s).
   자물쇠를 풀고 같이 늘려야 한다. 자동화 안 됨.
