capcut-agent/docs/superpowers/specs/2026-08-04-구간탭-2단계-댓글추천-design.md
hehihoho3@gmail.com 4abf41ebcb docs: 구간 탭 2단계 분할 + 구간별 댓글 추천 스펙
받아쓰기가 빌드 중간에 있어 카드 고를 때 자막이 없다. 파이프라인을 쪼개
받아쓰기 다음에 고르게 하면 자막 품질·장수 정확도·중복 작업이 한 번에 풀린다.
다운로드 캐시가 없어 '매칭 때 받아쓰기, 빌드 때 캐시' 방식은 시간이 두 배가 된다.

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

222 lines
12 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`가 여기서 나왔다)
## 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. 하지 않는 것
- **📋 붙여넣기 탭** — 별도 스펙. 구간 탭에서 만든 재사용 부품 위에 얹는 게 순서다.
- **📁 파일 탭 흐름 변경** — 내부만 쪼개고 겉보기는 그대로.
- **유튜브 자동자막** — Whisper 받아쓰기를 쓰기로 했으므로 불필요.
- **🤖 자동 탭** — 손대지 않는다.
- **다운로드 캐시 추가** — 파이프라인을 쪼개면 재다운로드가 없어지므로 필요 없다.
- **구간별 제목·출처 개별 지정** — 구간을 이어붙여 영상 하나를 만드는 것이므로 제목도 하나다.
## 14. 검증
자동 테스트 스위트가 없다(CLAUDE.md). 다음으로 확인한다.
1. `is_time_based()` / 구간별 `_whole_picks` 중복 방지 — 인라인 assert
2. 구간 자막 추출(압축 좌표 범위 → 캡션 이어붙이기) — 인라인 assert
3. `quotas` 외부 주입 시 `build_highlight_cuts` 동작 — 인라인 assert
4. 📁 파일 탭 회귀: 분할 전후 SSE 이벤트 순서·내용이 같은가 (실제 파일 1건)
5. 구간 2개짜리 실제 영상 1건 → 검토 화면에 구간별 섹션·자동 선택이 뜨는가
6. 생성된 `draft_content.json`에서 카드 시간이 **각 구간의 압축 타임라인 범위 안**인가
7. 표시한 `need` 장수 = 실제 배치된 카드 수 (버려지는 카드 0)
8. Gemini 키를 비워 폴백이 도는가
## 15. 열린 질문
없음. 미결이 생기면 여기에 적고 구현 전에 사용자에게 묻는다.