무음 제거 시 카드 '시간'을 _remap_caps 로 옮긴다고 적혀 있었으나, 그러면 cards_fixed(3초 고정)가 압축되며 깨진다. 실제로는 _remap_placements 로 컷 '구간'을 먼저 옮기고 그 안에서 나눈다. 장수 캡과 버림 로그도 명시. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
186 lines
11 KiB
Markdown
186 lines
11 KiB
Markdown
# 컷별 댓글 추천 · 컷 위치 배치 (설계)
|
||
|
||
작성일: 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. 열린 질문
|
||
|
||
없음. 미결이 생기면 여기에 적고 구현 전에 사용자에게 묻는다.
|