댓글 매칭이 일어나는 모든 곳에 같은 추천·배치를 적용한다. 붙여넣기 탭은 자막이 JSON 안에 있어 파이프라인·추천 엔진을 안 건드리고 화면만 붙이면 되므로 같은 스펙에 담는다. 카드 패널 렌더러가 세 갈래로 갈라지는 걸 막기 위해 renderCutPanel 하나로 모으는 것도 포함. 파일 탭은 제외 — 로컬 파일이라 원본 영상 시각을 몰라 분:초 매칭이 불가하다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
288 lines
16 KiB
Markdown
288 lines
16 KiB
Markdown
# 컷별 댓글 추천을 남은 두 탭에 — 구간 탭(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. 열린 질문
|
||
|
||
없음. 미결이 생기면 여기에 적고 구현 전에 사용자에게 묻는다.
|