컷별 댓글 추천 작업을 태스크 단위로 되돌릴 수 있게 버전관리를 시작한다. .gitignore 로 영상·캐시(.downloads 2.7G, .comments 72M, .media 28M)와 비밀키(.gemini_key)를 제외했다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
14 KiB
영상이 템플릿 밖으로 삐져나오는 문제 — 원인·조치 기록
작성 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(formdraft=<드래프트폴더명>) →{"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개)/✓ 이름), 선택 후 실행 → 결과 표시 → 목록 갱신.
사용 절차 (중요)
- 캡컷에서 그 프로젝트를 닫는다(홈으로). 열어둔 채 수리하면 캡컷이 메모리 상태로 덮어쓴다.
- 웹 UI 하단 🩹 레이어 수리 → 드래프트 선택 → 실행.
- 캡컷에서 다시 연다.
(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을 잠그면 사용자가 편집을 못 한다.