컷별 댓글 추천 작업을 태스크 단위로 되돌릴 수 있게 버전관리를 시작한다. .gitignore 로 영상·캐시(.downloads 2.7G, .comments 72M, .media 28M)와 비밀키(.gemini_key)를 제외했다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
10 KiB
컷별 댓글 추천 · 컷 위치 배치 (설계)
작성일: 2026-08-04
관련 코드: capcut_agent/comments.py, capcut_agent/pipeline.py, server/app.py, server/static/auto.js
선행 스펙: 2026-07-31-자동탭-오팔대체-댓글자동매칭-design.md (댓글 매칭의 최초 도입)
1. 배경
자동 탭 검토 화면은 하이라이트마다 댓글을 두 덩어리로 보여준다.
- ⭐ 이 구간을 언급한 댓글 — 본문에
9:05같은 분:초가 있고 그게 구간 안인 댓글, 좋아요순 자동선택 - ➕ 좋아요 상위 후보 — 분:초가 아예 없는 댓글, 수동 보충
문제는 두 가지다.
(a) 내용을 안 본다. comments.py의 매칭은 전부 정규식(TS_RE)으로 뽑은 타임스탬프뿐이다.
편집안이 컷마다 자막(bottom)을 갖고 있는데도 — 예: 아까랑 완전 스타일이 달라 — 매칭에
전혀 쓰이지 않는다. 타임스탬프를 안 적은 댓글은 아무리 그 장면 얘기여도 ⭐에 못 들어온다.
(b) 어디에 깔릴지 모른다. _load_comment_cards()는 카드를 파일명 순서대로
타임라인 전체에 균등 배치한다(간격 dur/n). 컷 경계를 모르므로, 3번 컷 얘기하는
댓글이 7번 컷 위에 뜰 수 있다.
이 스펙은 컷 단위로 추천하고 그 컷 위에 깔리게 만든다.
2. 범위
하는 것
- 검토 화면 댓글 영역을 컷별 묶음으로 재구성
- 컷 자막 기반 Gemini 추천 (컷이 있는 모드)
- 시각 슬롯 기반 정밀 배정 (통짜 모드, Gemini 미사용)
- 카드를 그 컷 구간 안에 배치 — 무음 제거 시 자막과 동일하게 재매핑
하지 않는 것
- 유튜브 구간 탭·붙여넣기 탭은 손대지 않는다. 구간 탭은 컷·자막이 없어 추천 근거가 없고, 붙여넣기 탭은 댓글 선택 UI 자체가 없다. 자동 탭 4개 모드만 대상.
_load_comment_cards()를 없애지 않는다. 폴더 지정 경로(파일/유튜브 구간 탭)는 그대로 쓴다.- 댓글 감정분석·본문 요약·추천 이유 표시·추천 학습을 하지 않는다. 지금 필요 없다.
- 통짜 모드에 Gemini를 쓰지 않는다. 근거(자막)가 없는데 비용만 든다 — §4.2.
- h-lab을 고치지 않는다. 필요한 댓글 데이터가 이미 다 온다.
3. 지금 동작 (변경 전 사실)
| 위치 | 사실 |
|---|---|
comments.py TS_RE |
(?<!\d)(\d{1,2}):([0-5]\d)(?::([0-5]\d))?(?!\d) — h-lab과 동일 규칙 |
comments.py MAX_TIMES = 3 |
분:초를 3개 넘게 나열한 '목차 댓글'은 매칭에서 제외 |
comments.py match_ranges() |
구간 안 시각을 하나라도 언급한 댓글 idx, 좋아요 내림차순 |
comments.py top_liked() |
exclude 제외 좋아요 상위 n |
app.py _need(total) |
max(1, int(total // 3)) — 하이라이트 전체 기준 카드 장수 |
app.py _whole_hl() |
통짜 하이라이트는 cuts:[{start, end, bottom:"", effect:""}] 1개, 자막 빈 문자열 |
app.py /auto/build |
data(붙여넣기 스키마 JSON) + cards PNG들 → COMMENTS_DIR/<h>/001.png…, comments_dir로 job 등록 |
pipeline.py placements |
컷 순서 누적으로 계산한 컷별 타임라인 구간 (p0, p1) — 자막 배치에 이미 사용 중 |
pipeline.py _remap_caps() |
무음 제거 시 자막 시간을 압축 타임라인으로 재매핑 |
pipeline.py _load_comment_cards() |
폴더 이미지를 dur/n 간격 균등 배치. fixed=True면 3초 고정·뒤 비움 |
draft.py build_bg_template_draft() |
comment_cards: List[Tuple[start, end, path]] — 이미 시간을 직접 받는다 |
plan.py _call() |
Gemini 호출. parts[0]에 fileData(영상)가 항상 들어간다 → 텍스트 전용 호출 불가 |
plan.py DEFAULT_MODEL |
gemini-3.5-flash |
correct.py |
_gemini_key(), GeminiQuotaError(429) |
4. 설계
4.1 모드가 갈리는 이유
| 모드 | 컷 | 컷 자막 | 타임라인 시각 ↔ 원본 시각 |
|---|---|---|---|
full, paste |
여러 개 | 있음 | 컷 순서를 섞어 재배치 → 다름 |
whole, wpaste |
1개 | 빈 문자열 | 구간을 그대로 이어붙임 → 같음 |
컷이 있는 모드는 시각이 어긋나므로 내용으로 맞출 수밖에 없고, 통짜 모드는 자막이 없으므로 시각으로 맞출 수밖에 없다. 각자 근거가 있는 쪽을 쓴다.
4.2 매칭
컷 있는 모드 (full, paste) — Gemini 1회/하이라이트
- 후보 댓글 = 좋아요 상위 150장(본문 200자 절단). 그 이상은 토큰만 먹고 채택률이 낮다.
- 컷 목록(번호·길이·자막·효과자막)과 함께 텍스트로 던져 컷별 배정을 받는다.
요청 장수는 컷당
quota + 2(사람이 갈아끼울 여유분). - 타임스탬프 우선: 그 컷의 원본 구간
[cut.start, cut.end]을 언급한 댓글이 있으면match_ranges()결과를 먼저 채우고, 남는 자리만 Gemini 추천으로 채운다. 근거가 확실한 쪽을 이기게 둘 이유가 없다. - 중복 제거: 한 댓글이 여러 컷에 배정되면 앞 컷이 가져간다.
통짜 모드 (whole, wpaste) — Gemini 0회
컷이 1개이고 타임라인 시각 = 원본 시각이므로, 카드 슬롯 k의 원본 시간대는
[start + k·(total/n), start + (k+1)·(total/n)) 로 정확히 계산된다(n = 카드 장수).
슬롯마다 그 시간대를 언급한 댓글을 좋아요순으로 꽂고, 빈 슬롯만 ➕ 상위로 메운다.
지금(구간 전체를 뭉뚱그려 매칭 → 순서 무관)보다 정확해지고 비용은 0이다.
4.3 카드 장수
컷별 quota_i = max(1, round(len_i / 3)). 하이라이트의 need = Σ quota_i
(기존 int(total // 3)을 대체 — 값이 ±1 다를 수 있으나 컷 경계에 맞추는 쪽이 맞다).
4.4 배치 — 시간은 파이프라인이 계산한다
⚠ 서버에서 카드 시간을 확정하면 안 된다. 무음 제거(remove_silence)를 켜면
timeline_dur가 줄고 자막이 _remap_caps()로 재매핑된다. 카드 시간을 미리 박아두면
그 뒤 혼자 어긋난다.
그래서 /auto/build는 카드가 어느 컷 소속인지만 보낸다:
card_cuts = [0, 0, 1, 1, 1, 2, …] # 카드 순서대로, 값 = 컷 인덱스
process_paste()가 이미 갖고 있는 placements[(p0, p1)]로 시간을 만든다:
- 컷
i에 카드m장 →p0부터(p1-p0)/m간격 (cards_fixed면 3초 고정, 컷 뒷부분은 비움) - 무음 제거가 켜져 있으면 자막과 같은
_remap_caps()를 카드에도 적용 card_cuts가 없으면(폴더 지정 경로 등) 기존_load_comment_cards()그대로
컷 하나가 추천 부족으로 덜 차도 다음 컷 카드가 앞으로 밀리지 않는다 — 지금 방식의 약점이 여기서 사라진다.
5. 데이터 스키마
/auto/analyze SSE의 하이라이트에 컷별 정보를 싣는다.
{"id":1, "paste":{…}, "total":49.5, "need":18, // need = Σ quota
"cuts":[ // paste.cuts 와 같은 순서·길이
{"i":0, "sec":5.0, "bottom":"아까랑 완전 스타일이 달라", "quota":2,
"picks":[{"idx":12,"why":"ts"}, {"idx":45,"why":"ai"}]},
{"i":1, "sec":5.5, "bottom":"우리에게 익숙한 평냥은", "quota":2,
"picks":[{"idx":7,"why":"ai"}]}
],
"candidates":[19,33,…]} // 컷 무관 좋아요 상위 (수동 보충용)
why는 추천 1건마다 붙는다 — 한 컷 안에서도 출처가 섞이기 때문이다(§4.2 3번).
| 값 | 뜻 | 화면 표시 |
|---|---|---|
ts |
그 컷의 원본 구간을 언급한 타임스탬프 댓글 | ⭐ |
ai |
Gemini가 자막 내용으로 고름 | 🤖 |
like |
통짜 모드에서 빈 슬롯을 좋아요순으로 메움 | ➕ |
/auto/build 폼에 card_cuts(JSON 배열) 추가. 나머지 필드는 그대로.
6. 변경 파일
| 파일 | 변경 |
|---|---|
capcut_agent/comments.py |
match_slots(comments, start, total, n) 추가 — 시각 슬롯별 배정(통짜용) |
capcut_agent/recommend.py (신규) |
Gemini 텍스트 전용 호출 + 컷별 배정·중복 제거. plan.py._call은 영상 part가 필수라 재사용 불가 |
capcut_agent/prompts.py |
추천 프롬프트를 프롬프트/댓글_추천.md로 (없으면 기본값 생성 — 기존 패턴과 동일) |
server/app.py |
하이라이트에 cuts[] 실어 보냄, /auto/build에 card_cuts 폼 수신 |
capcut_agent/pipeline.py |
card_cuts 인자 추가 → placements 기반 시간 계산 + 무음 제거 시 재매핑 |
server/static/auto.js |
카드 섹션을 컷별로 렌더, 선택 상한을 컷별 quota로, 선택 순서를 컷 순서로 고정 |
7. 실패·폴백
| 상황 | 처리 |
|---|---|
Gemini 쿼터 초과(GeminiQuotaError)·타임아웃·JSON 파싱 실패 |
기존 동작으로 폴백 — 구간 전체 ⭐ + ➕ 좋아요순. 화면에 ⚠ 추천 실패 → 기존 방식 한 줄 |
| Gemini가 없는 idx·범위 밖 idx를 반환 | 조용히 버림 |
컷 추천이 quota에 모자람 |
그 컷은 있는 만큼만. 사람이 ➕에서 채울 수 있음 |
| h-lab 댓글 수집 실패 | 지금과 동일 — 댓글 없이 진행 |
어떤 경우에도 드래프트 생성을 막지 않는다.
8. 검증
자동 테스트 스위트가 없으므로(CLAUDE.md) 다음으로 확인한다.
comments.match_slots()순수 함수 단위 검사 — 인라인 assert 스크립트- 컷별 배정·중복 제거 로직 단위 검사 (Gemini 응답은 고정 JSON으로 대체)
- 실제 영상 1건으로
full모드 → 검토 화면에 컷별 섹션·추천이 뜨는지 - 생성된
draft_content.json에서 카드 세그먼트 시간이 컷 구간 안에 들어가는지 확인 - 무음 제거 ON 으로 같은 영상 재실행 → 카드가 자막과 함께 당겨졌는지 확인
- Gemini 키를 일부러 비워 폴백이 도는지 확인
9. 열린 질문
없음. 미결이 생기면 여기에 적고 구현 전에 사용자에게 묻는다.