capcut-agent/작업기록_2026-07-27~31.md
hehihoho3@gmail.com bf1b387d6d chore: git 저장소 초기화 (기존 코드 스냅샷)
컷별 댓글 추천 작업을 태스크 단위로 되돌릴 수 있게 버전관리를 시작한다.
.gitignore 로 영상·캐시(.downloads 2.7G, .comments 72M, .media 28M)와
비밀키(.gemini_key)를 제외했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 11:36:05 +09:00

371 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 작업 기록 (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).
자물쇠를 풀고 같이 늘려야 한다. 자동화 안 됨.