컷별 댓글 추천 작업을 태스크 단위로 되돌릴 수 있게 버전관리를 시작한다. .gitignore 로 영상·캐시(.downloads 2.7G, .comments 72M, .media 28M)와 비밀키(.gemini_key)를 제외했다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
17 KiB
작업 기록 (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종 (이것 때문에 오래 못 잡았다)
- 파트를 단독 재생하면 멀쩡하다 — ffmpeg가 깨진 앞부분을 건너뛴다. concat 재인코딩할 때만 초록으로 구워진다.
ffprobe -read_intervals는 못 잡는다 — 키프레임으로 시크해버려 "첫 프레임이 I프레임"이라고 거짓 보고한다. (%+2와%+#5가 서로 다른 답을 냈다)ffmpeg -v error로 디코딩해도 에러가 안 난다 — h264 은닉 처리. 픽셀로만 보인다.
→ 유일하게 확실한 판정: 시크 없이 앞에서부터 프레임을 훑어 첫 키프레임 시각을 본다.
추가한 것
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 공통)
- 받은 파일의 첫 키프레임이 맨 앞이면 → 통과
- 아니면 버리고 재다운로드 (
DL_ATTEMPTS=2) — 간헐적이라 대개 여기서 해결 - 그래도 깨지면 → 앞에
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, 임시파일 잔여 없음 |
재현 명령:
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(formdraft=<폴더명>,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은 열려 있는 동안 파일을 다시 읽지 않는다. 가드가 없으면 "수리했는데 그대로예요"가 무한 반복된다. 실제로 두 번 반복됐다.
사용 절차 (순서 지켜야 함)
- CapCut에서 그 프로젝트를 닫는다 (권장: 프로그램 완전 종료)
- 웹 UI 하단 🩹 레이어 수리 → 드래프트 선택 → 실행
- 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)
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)
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 상단 (여기만 고치면 전부 따라 움직임)
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 인자 추가. 댓글 카드 세로 위치를 카드마다 계산한다:
표시높이 = 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 ✓
재현 명령:
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에서 재현하는 순서
- 선행 조건 확인 —
SETUP.md기준. PATH에ffmpeg/ffprobe,yt-dlp, Node.js 또는 deno(yt-dlp JS 런타임). CapCut 설치 + 코트라 볼드체 캐시(없으면 기본 폰트로 안전 동작). - 위 1·2·4·5·6·7을 순서대로 적용. 3번은 적용하지 말 것(원복된 실험).
.bat재시작.- 아래 검증 전부 통과 확인.
검증 명령 모음
# 구문 + 임포트 체인
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에서 열어보는 방식이다.
미해결로 남은 것
- 레이어 삐짐 근본 예방 — 생성 시점 예방책 없음. 현재는 사후 수리뿐. 재도전하려면 §3의 막다른 길 목록을 먼저 읽을 것.
- 제목이 안 보인다는 보고(2026-07-29) — 파일상으로는 정상이었다.
내용·크기·색·폰트·시간범위·
visible=true·위치 전부 확인했으나 미리보기에만 안 나왔다. 원인 미규명. 재발하면 트랙의 👁(눈) 아이콘 상태부터 확인할 것. - 영상보다 오버레이가 짧아지는 문제 — 클립을 뒤에 추가하면
frame·제목 트랙은 생성 당시 길이에서 끝나 그 뒤 구간엔 템플릿이 아예 없다(실측: 영상 44.13s vs 프레임 33.97s). 자물쇠를 풀고 같이 늘려야 한다. 자동화 안 됨.