capcut-agent/docs/superpowers/specs/2026-08-04-남은두탭-댓글추천-design.md
hehihoho3@gmail.com 2d37d42893 docs: 스펙 범위를 구간 탭 + 붙여넣기 탭으로 확장
댓글 매칭이 일어나는 모든 곳에 같은 추천·배치를 적용한다. 붙여넣기 탭은
자막이 JSON 안에 있어 파이프라인·추천 엔진을 안 건드리고 화면만 붙이면 되므로
같은 스펙에 담는다. 카드 패널 렌더러가 세 갈래로 갈라지는 걸 막기 위해
renderCutPanel 하나로 모으는 것도 포함.

파일 탭은 제외 — 로컬 파일이라 원본 영상 시각을 몰라 분:초 매칭이 불가하다.

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

288 lines
16 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.

# 컷별 댓글 추천을 남은 두 탭에 — 구간 탭(2단계 분할) + 붙여넣기 탭 (설계)
작성일: 2026-08-04
관련 코드: `capcut_agent/pipeline.py`, `capcut_agent/recommend.py`, `server/app.py`, `server/static/auto.js`, `server/static/index.html`
선행 스펙: `2026-08-04-컷별-댓글-추천-design.md` (자동 탭. 추천 엔진 `recommend.py`가 여기서 나왔다)
## 0. 목표 한 줄
**댓글 매칭이 일어나는 모든 곳에서 같은 추천·배치가 돌게 한다.**
| 탭 | 상태 | 이 스펙에서 |
|---|---|---|
| 🤖 자동 | ✅ 완료(선행 스펙) | 손대지 않음 |
| ▶ 유튜브 구간 | 분:초만 봄 | **2단계로 쪼개고** Whisper 자막으로 추천 — §2~§12 |
| 📋 붙여넣기 | 댓글 매칭 UI 자체가 없음 | **매칭 화면 추가** — §13 |
| 📁 파일 | 로컬 파일이라 유튜브 URL이 없음 | 제외 — §14 |
두 탭의 난이도가 크게 다르다. 구간 탭은 파이프라인을 쪼개는 구조 변경이고,
붙여넣기 탭은 **파이프라인·추천 엔진을 하나도 안 건드린다**(이미 컷 자막이 있고
`process_paste``card_cuts`를 받는다). 그래서 한 스펙에 같이 담는다.
## 1. 배경
▶ 유튜브 구간 탭의 `💬 구간 댓글 매칭`**댓글 본문의 분:초만** 본다. 실측(2026-08-04):
구간 2개·244초 영상에서 `⭐ 0장`, ` 707장`. 카드 81장이 필요한데 자동 선택이 하나도
안 돼서 **707장 중 81장을 사람이 눈으로 골라야 한다.**
자동 탭은 이 문제를 컷 자막으로 풀었다(선행 스펙). 구간 탭에는 자막이 없어 같은 방법을
못 쓴다 — 고 생각했으나, **이 파이프라인은 이미 Whisper 받아쓰기를 돌린다.** 다만 그게
빌드 중간에 일어나서 카드를 고르는 시점엔 아직 없다.
파이프라인을 쪼개 **받아쓰기 다음에 카드를 고르게** 하면 세 가지가 한 번에 풀린다.
## 2. 지금 동작 (변경 전 사실)
```
[매칭 버튼] POST /yt/comments → h-lab 댓글 + 분:초 매칭 (몇 초)
→ ⭐/ 두 덩어리 렌더 → 사람이 카드 선택 → PNG 캡처
[편집 시작] POST /youtube (cards) → JOBS[h] → GET /stream/{h}
→ process_bg_template: 다운로드 → 무음 → 받아쓰기 → [장면분할] → 드래프트
```
| 위치 | 사실 |
|---|---|
| `app.py` `/yt/comments` | `need = max(1, int(total // 3))`, `total` = **원본** 구간 합. `matched`=`match_ranges`, `candidates`=분:초 없는 좋아요순 |
| `app.py` `/youtube` | `cards` PNG를 `COMMENTS_DIR/<h>/001.png…`에 저장하고 `comments_dir`로 job 등록 |
| `pipeline.py` `process_bg_template` | 📁 파일 탭과 ▶ 구간 탭이 **공유**. 무음 제거가 **항상 켜짐**(옵션 아님) |
| `pipeline.py` | `detect_speech_segments``keep`, `cut_plan(keep, tr)``video_clips, captions, total`. `captions`**압축 타임라인** 기준 |
| `pipeline.py` `_load_comment_cards(dir, total, fixed)` | 카드를 압축 타임라인 **전체에 균등 배치**. 구간 경계를 모른다 |
| `pipeline.py` `_elapsed_kept` / `_remap_placements` | 원본 시각·구간 → 압축 타임라인 변환. 선행 작업에서 이미 만들어져 있다 |
| `youtube.py` `cut_youtube` | **다운로드 캐시가 없다.** 같은 구간을 다시 요청하면 yt-dlp를 다시 돌린다 |
| `transcribe.py` `transcribe(use_cache=True)` | 파일 content-hash 캐시. 같은 파일이면 재사용 |
| `recommend.py` | `quotas_for`, `build_cut_picks`, `ai_pick_cuts`, `build_highlight_cuts`, `is_whole` — 자동 탭용으로 완성됨 |
| `auto.js` | 카드 렌더·검색·캡처를 전담. 구간 탭은 `window.ytCC`로 빌려 쓴다 |
**지금도 있는 결함**: `need`는 원본 244초 기준인데 배치는 무음이 잘린 짧은 타임라인에
된다. 사람이 고른 카드 중 뒤쪽이 말없이 버려진다.
## 3. 왜 다운로드를 두 번 하면 안 되나
"매칭 버튼이 받아쓰기까지 돌리고, 빌드 때는 캐시를 쓴다"가 가장 작은 변경처럼 보이지만
**`cut_youtube`에 다운로드 캐시가 없다.** 빌드에서 yt-dlp가 다시 돌고, 재인코딩까지
다시 한다. 파이프라인에서 제일 오래 걸리는 부분이라 총 시간이 두 배가 된다.
받아쓰기 캐시는 파일 content-hash 기준이라 재다운로드본이 바이트 단위로 같다는
보장도 없다. → **파이프라인을 쪼개는 쪽이 맞다.**
## 4. 바뀐 흐름
```
[편집 시작] POST /yt/analyze → {aid}
GET /yt/stream/{aid} (SSE)
다운로드·병합 → 무음 분석 → 받아쓰기(Whisper + Gemini 교정)
(동시에 h-lab 댓글 수집)
→ 구간별 추천 계산 → result 이벤트 {cuts, comments, need}
→ 중간 상태를 STATES[aid] 에 보관
[검토] 구간별 카드 섹션(자동 선택 채워짐) + 검색창
[생성] POST /yt/build (aid, cards PNG, card_cuts, 제목·옵션) → {job_id}
GET /stream/{job_id} (SSE) → [장면분할] → 드래프트
```
자동 탭의 `분석 → 검토 → 생성`과 같은 모양이 된다.
**치르는 값**: 카드 고르기가 다운로드·받아쓰기 뒤로 밀린다. 지금은 매칭 버튼이 몇 초면
뜨는데 바뀌면 1분 영상당 ≈30초를 기다린 뒤 고른다. **총 시간은 오히려 줄지만
기다림의 위치가 앞으로 온다.** 사용자가 이 트레이드오프를 승인했다.
## 5. 구간별 자막을 어떻게 얻나
받아쓰기가 끝나면 `captions = [(s, e, text)]`**압축 타임라인** 기준으로 나온다.
각 구간이 그 타임라인에서 차지하는 범위는 이미 있는 함수로 구한다:
```
raw_places = [(누적, 누적 + (e_i - s_i)) …] # 병합본(무음 제거 전) 기준
places = _remap_placements(raw_places, keep) # 압축 타임라인 기준
```
구간 `i`의 자막 = `places[i]` 범위에 걸친 캡션 텍스트를 이어붙인 것(공백 정규화,
**500자에서 자름** — 그 이상은 Gemini 토큰만 먹는다).
## 6. 두 좌표계를 섞지 않는다
⚠ 이 설계에서 가장 틀리기 쉬운 지점이다.
| 용도 | 좌표계 | 왜 |
|---|---|---|
| **매칭** (`match_ranges`) | **원본 시각** `[s_i, e_i]` | 댓글에 적힌 `9:05`와 맞춰야 한다 |
| **배치** (`_cards_by_cut`) | **압축 타임라인** `places[i]` | 무음이 잘린 뒤 실제 자리다 |
| **장수** (`quota`) | **압축 타임라인** 길이 | 실제로 들어갈 수 있는 만큼만 |
그래서 추천에 넘기는 컷은 **원본 시각**을 쓰고, 배치와 장수는 **`places`**를 쓴다.
두 배열은 인덱스로만 짝지어 다닌다.
```python
cuts = [{"start": s_i, "end": e_i, "bottom": 구간자막_i} for i ] # 원본 시각
quotas = [max(1, int((p1 - p0) // CARD_SEC)) for p0, p1 in places] # 압축 길이
```
## 7. 추천 엔진은 그대로 쓴다
`cuts[i]["bottom"]`이 차 있으므로 `is_whole()`이 거짓 → **자동 탭과 똑같이
Gemini 내용 매칭 경로**로 간다. 타임스탬프 우선·중복 금지도 그대로다.
`recommend.py`에 필요한 변경은 하나뿐이다:
- `build_highlight_cuts(hl, comments, *, key=None, quotas=None)``quotas`가 주어지면
`quotas_for(cuts)` 대신 그것을 쓴다. 구간 탭은 압축 길이 기준 장수를 밖에서 계산해
넘긴다. 자동 탭은 인자를 안 넘기므로 동작 불변.
**받아쓰기 결과가 없으면**(전부 무음·ASR 실패) `bottom`이 전부 빈 문자열이 되고,
`is_whole()`은 컷 1개일 때만 참이므로 구간 2개 이상이면 Gemini 경로로 가는데 줄 자막이
없다. → `is_whole()`**`is_time_based()`로 일반화**한다: **모든 컷의 `bottom`이 비면
시각 기반**. 컷 1개 통짜는 그 특수 케이스가 된다. `_whole_picks`를 컷마다 돌리되
`used` 집합을 공유해 구간 간 중복 배정을 막는다.
## 8. 장수가 정확해진다
`quota`를 압축 길이로 계산하므로 **표시한 장수 = 실제 들어가는 장수**가 된다.
지금처럼 뒤쪽 카드가 말없이 버려지는 일이 없어진다(§2 마지막 문단의 기존 결함 해소).
## 9. 파이프라인 분할
`process_bg_template()`을 두 개의 async generator로 나눈다.
| 이름 | 하는 일 | 반환 |
|---|---|---|
| `bg_analyze(...)` | [유튜브 다운로드·병합] → 무음 분석 → 받아쓰기(+Gemini 교정) | 이벤트 스트림 |
| `bg_draft(state, ..., comment_cards)` | [장면분할] → 드래프트 생성 | 이벤트 스트림 |
`bg_analyze`는 마지막에 `{"type": "state", "state": {…}}` 이벤트를 내보낸다. 호출부가
그걸 받아 `bg_draft`에 넘긴다. 새 이벤트 타입이므로 **기존 UI는 무시한다**(파일 탭 래퍼가
이 이벤트를 걸러내고 밖으로 안 흘린다 — §9 마지막 문단).
상태 dict: `video_path, meta, keep, video_clips, captions, total, draft_name, channel`
(+ 구간 탭은 `raw_places`, `places`, `ranges_sec`).
📁 파일 탭은 두 개를 연달아 부르는 얇은 래퍼 `process_bg_template()`로 남긴다 —
**겉보기 동작·SSE 이벤트가 그대로여야 한다.**
중간 상태는 `STATES[aid]` 메모리 dict에 둔다. 서버를 재시작하면 사라지는데,
기존 `JOBS`도 같은 성질이라 새 제약이 아니다.
## 10. 데이터 스키마
`/yt/stream/{aid}``result` 이벤트:
```jsonc
{"type":"result",
"need": 62, // Σ quota (압축 길이 기준)
"total": 187.4, // 압축 타임라인 길이(초)
"cuts":[ // 구간과 같은 순서·개수
{"i":0, "sec":92.1, "bottom":"…그 구간 자막…", "quota":30,
"picks":[{"idx":12,"why":"ts"}, {"idx":45,"why":"ai"}]},
{"i":1, "sec":95.3, "bottom":"…", "quota":31, "picks":[…]}
],
"candidates":[19,33,…], // 구간 무관 좋아요 상위 (수동 보충용)
"comments":[…], // 카드 렌더용 전체 댓글
"warnings":[…]}
```
`why`: `ts`(분:초 언급) · `ai`(Gemini가 자막 보고) · `like`(좋아요로 채움) — 자동 탭과 동일.
`POST /yt/build` 폼: `aid`, `cards`(PNG 다수), `card_cuts`(JSON 정수 배열),
`title_top`, `title_main`, `channel`, `video_scale`, `flip`, `scene`, `bg_white`, `cards_fixed`.
**기존 엔드포인트 처리**: `/yt/comments``/youtube`는 구간 탭 전용이고 새 흐름이
대체하므로 **삭제한다**(다른 호출부가 없음을 구현 시 grep으로 확인할 것).
`/upload`(파일 탭)·`/paste`(붙여넣기 탭)·`/stream/{job_id}`는 그대로 둔다.
## 11. 화면
- `💬 구간 댓글 매칭` 버튼은 **없어진다.** `편집 시작`이 분석을 돌리고 검토 화면으로 간다.
- 검토 화면은 자동 탭의 컷별 섹션 렌더러(`cardSection` + 배지 + 검색창)를 **그대로 재사용**한다.
라벨만 `컷 N``구간 N`.
- 제목(윗줄·아랫줄)·출처는 지금처럼 입력받되, 검토 화면에서도 고칠 수 있게 둔다
(분석에 1분 넘게 기다린 뒤 오타를 발견하면 다시 돌리는 건 낭비다).
- 생성 버튼 하나. 진행은 기존 `/stream/{job_id}` 보드에 그대로 표시.
## 12. 실패·폴백
| 상황 | 처리 |
|---|---|
| h-lab 댓글 수집 실패 | 경고 한 줄 + 댓글 없이 검토 화면 → 카드 없이 생성 가능 |
| 받아쓰기 결과 없음 | `bottom` 전부 빔 → `is_time_based` 경로(시각 슬롯 + 좋아요 채움) |
| Gemini 실패(429·타임아웃·파싱) | `ai_failed` 경고 한 줄 + 분:초/좋아요만으로 배정 |
| 다운로드 실패 | 기존과 동일 — `error` 이벤트로 중단 |
| `aid`가 만료(서버 재시작) | `/yt/build`가 404 + "분석을 다시 돌려주세요" |
**어떤 경우에도 드래프트 생성을 막지 않는다**(다운로드 실패 제외 — 그건 영상이 없다).
## 13. 📋 붙여넣기 탭
구간 탭과 달리 **2단계로 쪼갤 필요가 없다.** 자막이 붙여넣은 JSON 안에 이미 있어서
영상을 받기 전에 추천이 끝난다. 그래서 지금 구간 탭이 쓰던 방식 — `💬 댓글 매칭`
버튼으로 먼저 고르고 `편집 시작` — 을 그대로 쓴다.
**파이프라인과 추천 엔진은 한 줄도 안 건드린다:**
- 컷마다 `bottom` 자막이 있다 → `build_highlight_cuts()`가 그대로 걸려 Gemini 내용 매칭이 돈다
- `process_paste(..., card_cuts=…)`는 선행 작업에서 이미 받는다
**새로 만드는 것:**
| 위치 | 변경 |
|---|---|
| `POST /paste/comments` (신규) | 폼 `data`(붙여넣은 JSON) → `parse_paste``fetch_comments(url)``build_highlight_cuts``{cuts, need, comments, candidates, warnings}` |
| `POST /paste` | `cards`(PNG 다수)·`card_cuts` 폼 필드 추가. 저장 로직은 기존 `/youtube`의 것을 그대로 옮긴다 |
| `index.html` | 붙여넣기 탭에 `💬 댓글 매칭` 버튼 + 결과 영역 |
| `auto.js` | `window.pasteCC = {active, capture}` 다리 추가 (기존 `window.ytCC`와 같은 모양) |
**주의**: 붙여넣기 탭의 `무음 제거`·`받아쓰기 자동 생성` 옵션을 켜면 타임라인이 줄거나
자막이 바뀐다. 추천은 붙여넣은 JSON의 `bottom` 기준으로 하되, 배치 장수는 자동 탭과
똑같이 `_cards_by_cut`이 압축 후 길이로 캡하고 버린 장수를 로그에 남긴다. 별도 처리 없음.
## 13-1. 렌더러를 하나로 모은다
지금 카드 패널을 그리는 코드가 자동 탭(`onResult`)과 구간 탭(`ytMatch`) 두 군데에
갈라져 있다. 여기에 붙여넣기 탭까지 더하면 세 갈래가 되어 한 곳만 고치는 실수가 난다.
**`renderCutPanel(panelId, data, opts)` 하나로 모은다** — 컷/구간별 섹션, ⭐🤖➕ 배지,
검색창, ` 채우기` 섹션, 선택 상한을 전부 담당한다. 세 탭이 `panelId`만 달리해서 부른다
(`hl.id` / `"yt"` / `"paste"`). 라벨 접두사(`컷` vs `구간`)는 `opts`로 넘긴다.
이건 이번 작업에 필요해서 하는 정리이지, 무관한 리팩터링이 아니다.
## 14. 하지 않는 것
- **📁 파일 탭** — 로컬 파일이라 댓글을 가져올 유튜브 URL이 없다. URL 칸을 따로 받으면
자막 기반 추천(🤖)은 가능하지만, 로컬 파일의 시각이 원본 영상 시각과 달라
**분:초 매칭(⭐)은 원리적으로 불가**하다. 사용자가 제외를 선택했다.
- **📁 파일 탭 흐름 변경** — `process_bg_template` 내부만 쪼개고 겉보기는 그대로.
- **유튜브 자동자막** — Whisper 받아쓰기를 쓰기로 했으므로 불필요.
- **🤖 자동 탭** — 렌더러 통합(§13-1) 외에는 동작을 바꾸지 않는다.
- **다운로드 캐시 추가** — 파이프라인을 쪼개면 재다운로드가 없어지므로 필요 없다.
- **구간별 제목·출처 개별 지정** — 구간을 이어붙여 영상 하나를 만드는 것이므로 제목도 하나다.
- **붙여넣기 탭 2단계 분할** — 자막이 JSON에 있어 쪼갤 이유가 없다.
## 15. 검증
자동 테스트 스위트가 없다(CLAUDE.md). 다음으로 확인한다.
**순수 함수 (인라인 assert, 네트워크·서버 없이)**
1. `is_time_based()` — 자막이 전부 비면 참, 하나라도 차 있으면 거짓. 컷 1개 통짜도 참
2. 구간별 `_whole_picks` — 구간 간 댓글 중복 배정이 없는가
3. 구간 자막 추출 — 압축 좌표 범위에 걸친 캡션만 이어붙이는가, 500자에서 자르는가
4. `quotas` 외부 주입 시 `build_highlight_cuts` 동작 (안 넘기면 기존과 동일)
**회귀 (실행 확인)**
5. 📁 파일 탭: 분할 전후 SSE 이벤트 순서·내용이 같은가 (실제 파일 1건). `state` 이벤트가 밖으로 새지 않는가
6. 🤖 자동 탭: 렌더러 통합 후 컷별 섹션·배지·검색·캡처가 그대로인가
**구간 탭 (실사용)**
7. 구간 2개짜리 실제 영상 1건 → 검토 화면에 구간별 섹션·자동 선택이 뜨는가
8. `draft_content.json`에서 카드 시간이 **각 구간의 압축 타임라인 범위 안**인가
9. 표시한 `need` 장수 = 실제 배치된 카드 수 (버려지는 카드 0)
**붙여넣기 탭 (실사용)**
10. `💬 댓글 매칭` → 컷별 섹션·추천이 뜨는가
11. 카드가 해당 컷 위에 깔리는가
12. 댓글을 하나도 안 고르고 `편집 시작`해도 기존처럼 생성되는가 (하위호환)
**공통**
13. Gemini 키를 비워 세 탭 모두 폴백이 도는가
## 16. 열린 질문
없음. 미결이 생기면 여기에 적고 구현 전에 사용자에게 묻는다.