capcut-agent/docs/superpowers/specs/2026-08-04-컷별-댓글-추천-design.md
hehihoho3@gmail.com 93fa0a3a21 docs: 스펙 §4.4 를 수정 후 실제 배치 방식에 맞게 갱신
무음 제거 시 카드 '시간'을 _remap_caps 로 옮긴다고 적혀 있었으나, 그러면
cards_fixed(3초 고정)가 압축되며 깨진다. 실제로는 _remap_placements 로
컷 '구간'을 먼저 옮기고 그 안에서 나눈다. 장수 캡과 버림 로그도 명시.

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

186 lines
11 KiB
Markdown
Raw 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-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회/하이라이트**
1. 후보 댓글 = 좋아요 상위 **150장**(본문 200자 절단). 그 이상은 토큰만 먹고 채택률이 낮다.
2. 컷 목록(번호·길이·자막·효과자막)과 함께 텍스트로 던져 컷별 배정을 받는다.
요청 장수는 컷당 `quota + 2` (사람이 갈아끼울 여유분).
3. **타임스탬프 우선**: 그 컷의 **원본 구간** `[cut.start, cut.end]`을 언급한 댓글이 있으면
`match_ranges()` 결과를 먼저 채우고, 남는 자리만 Gemini 추천으로 채운다.
근거가 확실한 쪽을 이기게 둘 이유가 없다.
4. 중복 제거: 한 댓글이 여러 컷에 배정되면 **앞 컷이 가져간다**.
**통짜 모드 (`whole`, `wpaste`) — Gemini 0회**
컷이 1개이고 타임라인 시각 = 원본 시각이므로, 카드 슬롯 `k`의 원본 시간대는
`[start + k·(total/n), start + (k+1)·(total/n))` 로 정확히 계산된다(`n` = 카드 장수).
슬롯마다 그 시간대를 언급한 댓글을 좋아요순으로 꽂고, 빈 슬롯만 상위로 메운다.
지금(구간 전체를 뭉뚱그려 매칭 → 순서 무관)보다 정확해지고 비용은 0이다.
### 4.3 카드 장수
컷별 `quota_i = max(1, floor(len_i / 3))`. 하이라이트의 `need = Σ quota_i`
(기존 `int(total // 3)`을 대체 — 값이 ±1 다를 수 있으나 컷 경계에 맞추는 쪽이 맞다).
round가 아니라 floor인 이유: 빌드 단계(`_cards_by_cut`)의 컷당 장수 상한도 같은
`max(1, floor(컷길이/3))`이라, round를 쓰면 5초 컷처럼 여기서 2장을 고르고도
빌드에서 상한(1장)에 걸려 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_placements()`로 컷 구간을 먼저 압축 타임라인으로 옮긴 뒤**
그 구간 안에서 카드를 나눈다. ⚠ 자막처럼 카드 **시간**을 `_remap_caps()`로 옮기면 안 된다 —
`cards_fixed`(3초 고정)가 압축되면서 깨지고(3.0초 → 2.0초), 3초 하한도 사라진다.
옮기는 것은 시간이 아니라 **컷 구간**이다. 그래서 카드 계산은 무음 제거 **뒤**에 온다.
- 컷당 장수는 압축 후 길이 기준 `max(1, floor((p1-p0)/3))`로 캡한다(§4.3과 같은 공식).
무음 제거로 컷이 짧아져 캡에 걸려 버려진 카드가 있으면 진행 로그에 장수를 남긴다.
- `card_cuts`가 없으면(폴더 지정 경로 등) 기존 `_load_comment_cards()` 그대로
컷 하나가 추천 부족으로 덜 차도 다음 컷 카드가 앞으로 밀리지 않는다 — 지금 방식의 약점이 여기서 사라진다.
## 5. 데이터 스키마
`/auto/analyze` SSE의 하이라이트에 컷별 정보를 싣는다.
```jsonc
{"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) 다음으로 확인한다.
1. `comments.match_slots()` 순수 함수 단위 검사 — 인라인 assert 스크립트
2. 컷별 배정·중복 제거 로직 단위 검사 (Gemini 응답은 고정 JSON으로 대체)
3. 실제 영상 1건으로 `full` 모드 → 검토 화면에 컷별 섹션·추천이 뜨는지
4. 생성된 `draft_content.json`에서 **카드 세그먼트 시간이 컷 구간 안에 들어가는지** 확인
5. 무음 제거 ON 으로 같은 영상 재실행 → 카드가 자막과 함께 당겨졌는지 확인
6. Gemini 키를 일부러 비워 폴백이 도는지 확인
## 9. 열린 질문
없음. 미결이 생기면 여기에 적고 구현 전에 사용자에게 묻는다.