# 작업 기록 (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` 페이지 하단 `
` 섹션. 펼치면 `/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). 자물쇠를 풀고 같이 늘려야 한다. 자동화 안 됨.