# 영상이 템플릿 밖으로 삐져나오는 문제 — 원인·조치 기록

작성 2026-07-27. 관련 코드: `capcut_agent/draft.py`, `server/app.py`, `server/static/index.html`.
배경 지식은 `ARCHITECTURE.md` §5(드래프트 빌더) / §10(하지 말 것)를 볼 것 — 여기선 중복하지 않는다.

## 1. 증상

캡컷에서 클립을 **복사하거나 옮긴 뒤 확대**하면, 그 클립만 흰 띠(`frame`)와 댓글 카드
(`comment`) 위로 올라가 템플릿 밖으로 삐져나온다. 다른 클립은 멀쩡하다.

## 2. 원인 (실측 증거)

탭별 코드 차이가 **아니다.** 세 탭 모두 같은 빌더(`build_bg_template_draft`)를 쓰고,
생성 직후 `draft_content.json`은 완전히 동일하다.

```
구간탭 드래프트 (사용자가 클립 옮김/복사함)
  bg  ri=[0]   main ri=[1, 4]   frame ri=[2]   comment ri=[3]
                       ↑ 옮기거나 복사한 클립만 4 = frame·comment 보다 위

붙여넣기 드래프트 (클립을 안 건드림)
  bg  ri=[0]   main ri=[1]      frame ri=[2]   comment ri=[3]   ← 정상
```

즉 **캡컷이 새로 생긴 비디오 세그먼트(복사·이동)에 렌더순서를 새로 찍는다.**
붙여넣기 탭이 멀쩡해 보인 건 거기서 클립을 안 옮겼기 때문이고, 거기서도 옮기면 똑같이 깨진다.

재현 2회 모두 값이 `4`였다:

| 시점 | 조작 | 결과 |
|---|---|---|
| 1차 | 클립 2개를 타임라인 앞으로 **이동** | 그 2개만 `ri=4` (확대 189.8%) |
| 2차 | 수리 후 클립 1개 **복사** + 확대 | 그 1개만 `ri=4` (확대 225.6%) |

**⚠ 2026-07-31 반례**: `악뮤 수현의 하루_…_하이라이트3`에서 복붙+확대(201.7%) 클립이
`ri=3`을 받았다(비디오 최대는 comment의 3 → max+1이면 4여야 함). 즉 "정확히 max+1"
규칙은 보편이 아니다. 확실한 결론은 **"복붙/이동 클립의 ri가 frame(2) 위로 재부여된다"**
뿐이며, 수리는 정상값으로 되돌리는 방식이라 3이든 4든 동일하게 고쳐진다.

### 왜 트랙 잠금으로는 못 막나

`_lock_tracks`는 `bg`·`frame`·`comment`·제목 트랙만 잠근다. `main`은 사용자가 편집해야 하므로
잠글 수 없고, 문제는 그 `main` 트랙 안에서 일어난다.

### 왜 생성 시점 값으로도 못 막나 (규칙이 `max+1`이라면)

텍스트 쪽 증거상 캡컷은 새 세그먼트에 **같은 타입 대역 안에서 `현재 최대 + 1`** 을 준다
(사용자가 추가한 텍스트가 14109 → 14110 → … 순차 증가). 비디오도 같은 규칙이면
`frame`을 아무리 높여도 새 클립이 그보다 +1을 받으므로 **예방이 원리적으로 불가능**하다.
텍스트(14000+)는 비디오와 대역이 달라 영향을 받지 않는다.

## 3. 적용한 것

### (a) 레이어 수리 기능 — 사후 복구 (확실히 동작함)

**`capcut_agent/draft.py`**

| 이름 | 역할 |
|---|---|
| `CANON_RI` | 트랙별 정상 render_index — `bg 0 / main 1 / frame 2 / comment 3` |
| `_count_bad_ri(json_path)` | 정상값과 다른 비디오 세그먼트 수 = 꼬임 개수 |
| `list_drafts(draft_root)` | 드래프트 목록(최근 수정순) + `broken`/`bad` 플래그 |
| `repair_layers(draft_dir)` | 비디오 세그먼트 render_index를 트랙별 정상값으로 되돌리고 `_lock_tracks` 재적용. 이름 없는(사용자 추가) 비디오 트랙은 4,5… 로 밀어 맨 위 유지 |

> ⚠ 2026-08-03: 아래 표는 **루트 `draft_content.json` 만** 다루던 시절 기록이다.
> 지금은 `Timelines/*` 사본까지 함께 고친다 — **(a-3)** 을 반드시 볼 것.

- 백업은 **`<파일명>.repair.bak`**. `draft_content.json.bak`은 **캡컷 자체 백업 파일명이라
  쓰면 안 된다**(처음에 이걸로 썼다가 캡컷 백업을 덮어썼음 — 같은 실수 반복 금지).
- 고칠 게 없으면 백업도 안 만들고 잠금만 다시 채운다(멱등).

**`server/app.py`**

- `GET  /drafts` → `{"drafts":[{name, mtime, broken, bad}, …]}` 최근 30개
- `POST /repair` (form `draft=<드래프트폴더명>`) → `{"ok", "fixed", "detail"}`
  경로는 `os.path.basename`으로 정규화해 드래프트 루트 밖으로 못 나가게 막음.
  **`_capcut_running()` 이면 409로 거부**(`force=1` 로만 강행). 실측: 11:04:32 수리 →
  11:05:39 CapCut 저장으로 되돌아감. CapCut 은 파일을 다시 읽지 않고 메모리 상태로 덮어쓴다.
  이 가드가 없으면 "수리했는데 그대로예요"가 반복된다.

**`server/static/index.html`**

- 페이지 하단 `<details>` **"🩹 레이어 수리"** 섹션. 펼치면 `/drafts`를 불러 목록 표시
  (`⚠ 이름 (꼬임 N개)` / `✓ 이름`), 선택 후 실행 → 결과 표시 → 목록 갱신.

**사용 절차 (중요)**

1. 캡컷에서 **그 프로젝트를 닫는다**(홈으로). 열어둔 채 수리하면 캡컷이 메모리 상태로 덮어쓴다.
2. 웹 UI 하단 **🩹 레이어 수리** → 드래프트 선택 → 실행.
3. 캡컷에서 다시 연다.

### (a-3) 2026-08-03 — "수리해도 그대로예요"의 진짜 이유: `Timelines/`

**증상**: 수리를 눌러 `fixed=2` 가 나오는데도 캡컷에서 열면 그대로 삐져 있다.

**원인**: CapCut 9.x(`draft_meta_info.json` 의 `draft_new_version` = 164.0.0)부터 프로젝트
실데이터가 **`Timelines/<GUID>/`** 아래로 옮겨갔다. `repair_layers()` 는 루트
`draft_content.json` 만 고쳤으므로 **캡컷이 실제로 읽는 파일은 손대지 않았다.**

실측 증거(드래프트 `열심히 하는 나경`, `소지섭이 올리브를…(2)`):

| 증거 | 내용 |
|---|---|
| `Timelines/` 생성 시점 | **캡컷에서 한 번이라도 연** 드래프트에만 있다. 빌더가 막 만든 드래프트엔 없다 → 그래서 *생성*은 멀쩡했고 *수리*만 안 먹었다 |
| 루트 파일 없이도 편집됨 | `소지섭이…(2)` 는 루트 `draft_content.json` 이 **아예 없는데** 캡컷이 계속 편집 중(`Timelines/…/template.json` 이 최신) → 루트는 레거시 미러 |
| 저장 순서 | 저장 시각이 항상 `template.json` 이 가장 늦다 |
| 수리 직후 대조 | 루트만 `main ri=[1]`, `Timelines/*/draft_content.json` · `template.json` 은 `[1, 4]` 그대로 |

**조치** (`capcut_agent/draft.py`)

| 이름 | 변경 |
|---|---|
| `timeline_jsons(draft_dir)` | 신규. 루트 `draft_content.json` + `Timelines/*/draft_content.json` + `Timelines/*/template.json` 목록. `.tmp` 는 저장 중 임시파일이라 제외 |
| `_repair_one(json_path)` | 신규. 파일 1개 수리(기존 `repair_layers` 본문). 백업 `<파일명>.repair.bak` |
| `repair_layers()` | 위 파일 **전부** 수리. 반환에 `files`(고친 파일 수) 추가. 파일마다 내용이 같으므로 `fixed` 는 합이 아니라 최댓값 |
| `_lock_tracks()` | 사본 전부에 잠금 적용. 바뀔 때만 기록 |
| `list_drafts()` / `_count_bad_ri` | 루트가 없어도(=`Timelines` 만 있어도) 목록에 나오고 꼬임을 센다 |
| `_registered_drafts()` 신규 · `list_drafts()` 에 `path` | **기본 폴더만 훑으면 안 된다.** 캡컷 설정에서 저장 위치를 바꾸면 드래프트가 `%LOCALAPPDATA%\CapCut\…\com.lveditor.draft` 밖에 생긴다(실측: `222222222222` 가 `D:/개인폴더/00.유튭/정치/capcut/CapCut Drafts` 에 있어 목록에 안 떴다). 어디에 있든 드래프트 루트의 **`root_meta_info.json` → `all_draft_store[].draft_fold_path`** 가 알고 있다. 목록 항목에 `path` 를 실어 그걸 수리 키로 쓴다 |
| `server/app.py` `_resolve_draft()` 신규 | 폼 값이 이제 폴더**경로**라 `basename` 고정으로는 못 막는다 → `list_drafts()` 가 아는 드래프트에만 매칭해 임의 경로 접근 차단(폴더명도 구버전 호환으로 받음) |
| `_draft_title()` 신규 · `list_drafts()` 에 `title` | 캡컷에서 프로젝트 이름을 바꿔도 **폴더명은 안 바뀐다**(예: 화면 `열심히 하는 나경 땜걸~ 찡긋` ↔ 폴더 `열심히 하는 나경`). 목록이 폴더명만 보여줘서 "내 프로젝트가 목록에 없다"는 착각이 생겼다 → 이름은 **`root_meta_info.json` 우선**(캡컷 홈이 읽는 인덱스라 이게 정답. 폴더 안 `draft_meta_info.json` 엔 옛 이름이 남아 있다 — 실측: 홈 `222222222222` ↔ 폴더 메타 `언니 괜찮아…22222222222222222222`), 없으면 폴더 메타 → 폴더명 순. 폴더명과 다르면 `[폴더: …]` 를 덧붙인다 |

검증: `열심히 하는 나경` → `{'fixed': 2, 'detail': {'main': 2}, 'files': 2}`, 세 파일 모두
`main ri=[1]`, 재실행 시 `fixed=0`(멱등). **최종 확인은 캡컷에서 열어봐야 한다.**

### (a-2) ❌ 원복됨 — 레이어 대역 분리 (지금 코드에 **없다**)

> ⚠ **2026-07-30 사용자 요청으로 원복.** `_apply_layer_bands()`는 삭제됐고
> `CANON_RI`는 `{"bg":0,"main":1,"frame":2,"comment":3}`로 돌아갔다.
> 아래는 **시도 기록**이며 현재 동작이 아니다. 그대로 믿지 말 것.
> 검증도 못 했다 — 판별용 드래프트가 삭제됐고, CapCut은 접근성 트리가 비어 있어
> UI 자동화로도 확인 불가. 재도전하려면 §5 "막다른 길"을 먼저 읽을 것.

(이하 원복 전 기록)

`draft.py` `_apply_layer_bands()` — `build_bg_template_draft()` 저장 직후 자동 실행.

```
bg 0  <  main 1  <<  frame 14500 < comment 14501  <<  자막·제목·효과 24000+
```

- `CANON_RI = {"bg":0, "main":1, "frame":14500, "comment":14501}`, `TEXT_RI_MIN = 24000`
- **왜 통하나**: CapCut 이 새 비디오 세그먼트에 주는 `max+1` 의 `max` 가 **비디오 대역
  안에서만** 계산되면, frame 이 14500 이어도 복붙 클립은 계속 `4` 를 받아 프레임 아래에 갇힌다.
- **왜 텍스트를 24000+ 로 올리나**: CapCut 렌더 해석이 ①전역 ri 비교든 ②타입별 레이어든
  두 경우 모두 순서가 유지되게 하려고. (①이면 frame 14500 > caption 14000 이라 자막이 가려짐)
- **틀려도 손해 없음**: 가정이 틀리면 복붙 클립이 14502 를 받아 예전과 똑같이 동작할 뿐.
  그래서 검증 없이 기본값으로 넣었다.
- `repair_layers()` 도 같은 대역으로 맞춘다 → 예전 드래프트(frame=2, 텍스트 14000+)를
  변환하는 용도로 남는다. **멱등**(두 번 돌려도 fixed=0).

검증(실제 드래프트 생성 후 `draft_content.json` 확인):

| 트랙 | ri |
|---|---|
| bg / main | 0 / 1 |
| frame / comment | 14500 / 14501 |
| caption / title_top / title_main / channel / effect | 25001 / 25002 / 25003 / 25004 / 25005 |

### (b) 그래도 안 되면 — 판별 근거와 다음 수단

**확정된 것**: 새 비디오 세그먼트(복붙·이동)는 `현재 비디오 최대 render_index + 1` 을 받는다.
드래프트 4건에서 재현:

| 드래프트 | 비디오 최대 | 새 클립이 받은 값 |
|---|---|---|
| 핑계고 3015-3240 (이동) | 3 (comment) | **4** |
| 핑계고 3015-3240 (복사) | 3 | **4** |
| 눈감고 들으면… (복붙) | 3 | **4** |
| 대성 태양 GD… (comment 없음) | 2 (frame) | **3** |

(a-2)를 적용한 드래프트에서 복붙 클립이 **14502** 를 받는다면 "max 는 비디오 전체 대상"이
확정되는 것이고, 그때는 생성 시점 예방이 원리적으로 불가능하다. 그 경우 남는 수단은
편집 마무리 절차(캡컷 완전 종료 → 수리 → 재오픈 → 내보내기)뿐이다.

### (b-old) 이전 실험 기록 (미결로 종료)

**확정된 것**: 새 비디오 세그먼트(복붙·이동)는 `현재 비디오 최대 render_index + 1` 을 받는다.
드래프트 4건에서 재현:

| 드래프트 | 비디오 최대 | 새 클립이 받은 값 |
|---|---|---|
| 핑계고 3015-3240 (이동) | 3 (comment) | **4** |
| 핑계고 3015-3240 (복사) | 3 | **4** |
| 눈감고 들으면… (복붙) | 3 | **4** |
| 대성 태양 GD… (comment 없음) | 2 (frame) | **3** |

**남은 물음**: 이 "최대값"이 ① 비디오 트랙의 모든 세그먼트인지, ② `ri < 10000`(비디오 대역)
세그먼트만인지. ②라면 `frame`을 대역 밖(14500)에 두면 새 클립은 계속 4를 받아
**프레임 아래에 갇힌다 = 영구 해결**. ①이라면 14501을 받아 실패 = 예방 불가 확정.

근거: 사용자가 추가한 **텍스트**는 텍스트 대역 안에서만 증가했고(14109→14114), 복붙한
**비디오**는 그 14000번대를 **무시하고** 4를 받았다 → 대역별로 따로 셀 가능성이 있다.
단 비디오 세그먼트가 `ri ≥ 10000` 인 사례가 기존 드래프트에 **0건**이라 데이터로는 판별 불가.

**판별용 드래프트 `__레이어테스트` 를 만들어 둠** (frame `ri=14500`, 텍스트 24000+,
전 트랙 잠금 해제). 여기서 클립 하나 복붙 → 확대 → 삐지는지 보면 끝.
확인 후 이 절을 확정 결론으로 교체하고 테스트 드래프트는 삭제할 것.

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

### (c) 문서

- `ARCHITECTURE.md` §10 — "잠금으로도 못 막는 경우가 있다(main 트랙)" 항목 추가.
- `SETUP.md` 문제 해결표 — "클립을 옮긴 뒤 확대하면 그 클립만 삐짐" 행 추가.

## 4. 시도해볼 만한 우회

- 캡컷에서 `Ctrl+C`/`Ctrl+V` 대신 **우클릭 → 복제**. 다른 코드 경로라 렌더순서를 새로 안 찍을
  가능성이 있다. 되면 이게 가장 싼 답(코드 변경 불필요). — **미검증**

## 5. 막다른 길 (다시 시도하지 말 것)

- **마스크**: 캡컷 마스크는 클립과 함께 확대/이동하므로 확대 시 같이 커진다 → 클램프 못 함.
- **frame을 텍스트/스티커 트랙으로**: `StickerSegment`는 캡컷 클라우드 스티커
  `resource_id`만 받는다 → 로컬 PNG를 못 올린다.
- **영상에 흰 띠를 미리 구워 넣기**(`media.to_template` 방식): 확대하면 띠까지 같이 커져
  화면 밖으로 나가고 영상이 캔버스를 다 덮는다.
- **트랙 잠금 확대**: `main`을 잠그면 사용자가 편집을 못 한다.
