capcut-agent/docs/superpowers/specs/2026-08-04-받아쓰기후-댓글매칭-design.md
hehihoho3@gmail.com ef78702ca9 docs: 스펙 전면 개정 — 모든 탭이 받아쓰기 후 댓글 매칭
추천 근거를 Whisper 받아쓰기 하나로 통일한다. 지금은 자동·붙여넣기 탭이
LLM이 쓴 JSON 자막을, 구간 탭은 분:초만 본다.

자동 탭은 3단계로 나눈다 — 검토 화면의 ✕ 제외 버튼 때문이다. 받아쓰기를 먼저
돌리면 버릴 하이라이트까지 다운로드·받아쓰기하게 되므로, 제외를 먼저 고르게 한다.

파이프라인 두 개(process_paste, process_bg_template)를 모두 analyze/draft 로
쪼개야 해서 구현을 4단계로 끊는다. 1단계는 겉보기 변화 없이 회귀 검증만.

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

238 lines
13 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/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. 목표 한 줄
**댓글 매칭을 항상 받아쓰기 다음에 한다 — 모든 탭에서 똑같이.**
지금은 탭마다 추천 근거가 다르다. 자동·붙여넣기 탭은 LLM이 쓴 JSON 자막(`bottom`),
구간 탭은 자막이 아예 없어 분:초만 본다. 근거를 **Whisper 받아쓰기 하나로 통일**하면
정확도가 오르고, 세 탭이 같은 코드로 돌아 유지보수가 단순해진다.
| 탭 | 지금 | 바뀐 뒤 |
|---|---|---|
| 🤖 자동 | 분석 → 검토(즉시) → 생성 | 분석 → **1차 검토(제목·제외)** → 다운로드·받아쓰기 → **2차 검토(댓글)** → 생성 |
| ▶ 유튜브 구간 | 매칭(분:초만) → 편집 시작 | 편집 시작 → 다운로드·받아쓰기 → 검토(댓글) → 생성 |
| 📋 붙여넣기 | 댓글 UI 없음 | 편집 시작 → 다운로드·받아쓰기 → 검토(댓글) → 생성 |
| 📁 파일 | 폴더 지정 | **제외** — §12 |
## 1. 통일된 배정 순서
컷(또는 구간) 하나마다 **카드 장수(quota)**만큼 이 순서로 채운다:
| 순위 | 근거 | 배지 | 어디서 |
|---|---|---|---|
| 1 | 그 컷의 **원본 시각**을 언급한 댓글, 좋아요순 | ⭐ | `match_ranges` |
| 2 | 그 컷의 **Whisper 자막**을 Gemini가 읽고 고른 댓글 | 🤖 | `ai_pick_cuts` |
| 3 | 좋아요 상위로 남은 자리 채움 | | `top_liked` |
한 댓글은 한 컷에만 들어간다(겹치면 앞 컷이 가져간다). 이 순서는 이미
`recommend.build_cut_picks()`에 있고, 3순위만 새로 붙이면 된다.
**시각이 어긋나지 않는 경우엔 1순위가 더 정밀해진다.** 통짜 모드와 구간 탭은
구간을 그대로 이어붙이므로 타임라인 시각 = 원본 시각이 성립한다. 그때는
`match_slots()`로 3초 슬롯마다 그 시간대 언급 댓글을 꽂는다(기존 동작 유지).
**받아쓰기 결과가 없으면**(전부 무음·ASR 실패) 2순위를 건너뛰고 1·3순위만 돈다.
## 2. 공통 흐름
모든 탭이 결국 같은 모양이 된다:
```
(편집안 확보) → 다운로드·병합 → 무음 분석 → 받아쓰기 → 댓글 매칭 → [검토] → 드래프트
└───────── analyze 단계 ─────────┘ └ draft 단계 ┘
```
- **편집안 확보**가 탭마다 다르다: Gemini Step1/3(자동 `full`) · 붙여넣은 JSON(자동 `paste`,
붙여넣기 탭) · URL+구간(구간 탭, 자동 `whole`/`wpaste`)
- 그 뒤는 전부 같다.
## 3. 파이프라인을 둘로 쪼갠다
파이프라인이 두 개 있고 **둘 다** 쪼개야 한다.
| 지금 | 쪼갠 뒤 | 쓰는 곳 |
|---|---|---|
| `process_paste()` | `paste_analyze()` + `paste_draft(state, …)` | 자동 탭(컷 있는 모드), 붙여넣기 탭 |
| `process_bg_template()` | `bg_analyze()` + `bg_draft(state, …)` | 구간 탭, 파일 탭 |
`*_analyze`는 이벤트를 흘리다가 마지막에 `{"type":"state", "state":{…}}`를 내보낸다.
호출부가 그걸 받아 보관했다가 `*_draft`에 넘긴다.
**상태 dict**: `video_path, meta, keep, video_clips, captions, total, placements, draft_name, channel`
(+ 붙여넣기 계열은 `eff_caps`, `cuts_orig`; 구간 계열은 `ranges_sec`)
📁 파일 탭은 두 개를 연달아 부르는 얇은 래퍼 `process_bg_template()`으로 남긴다 —
**겉보기 동작과 SSE 이벤트가 그대로여야 한다.** 래퍼는 `state` 이벤트를 걸러내 밖으로
안 흘린다.
상태는 `STATES[aid]` 메모리 dict에 둔다. 서버를 재시작하면 사라지는데 기존 `JOBS`
같은 성질이라 새 제약이 아니다.
## 4. 두 좌표계를 섞지 않는다
⚠ 이 설계에서 가장 틀리기 쉬운 지점이다.
| 용도 | 좌표계 | 왜 |
|---|---|---|
| **1순위 매칭** | **원본 영상 시각** | 댓글에 적힌 `9:05`와 맞춰야 한다 |
| **자막 추출·배치·장수** | **압축 타임라인** | 무음이 잘린 뒤 실제 자리다 |
컷/구간마다 두 값을 나란히 들고 다닌다. `_remap_placements(raw, keep)`가 원본 누적
구간을 압축 타임라인으로 옮긴다(선행 작업에서 이미 만들어져 있다).
```python
cuts = [{"start": 원본_s, "end": 원본_e, "bottom": 자막}, ] # 매칭용
places = _remap_placements(raw_places, keep) # 배치용
quotas = [max(1, int((p1 - p0) // CARD_SEC)) for p0, p1 in places] # 압축 길이 기준
```
## 5. 컷별 자막을 어떻게 얻나
받아쓰기 결과 `captions = [(s, e, text)]`**압축 타임라인** 기준이다.
`i`의 자막 = `places[i]` 범위에 걸친 캡션 텍스트를 이어붙인 것
(공백 정규화, **500자에서 자름** — 그 이상은 Gemini 토큰만 먹는다).
이 한 줄로 **세 탭 모두** 자막을 얻는다. JSON `bottom`은 더 이상 추천 근거로 쓰지 않는다
(화면 자막으로는 그대로 쓰인다 — `asr_bottom` 옵션이 꺼져 있으면).
**`asr_bottom` 옵션의 의미가 바뀐다**: 받아쓰기는 추천을 위해 **항상** 돌고,
이 옵션은 "그 결과를 화면 자막으로도 쓸지"만 정한다. UI 설명 문구를 그에 맞게 고친다.
## 6. 추천 엔진 변경
`recommend.py`에 필요한 것만 더한다.
| 함수 | 변경 |
|---|---|
| `build_highlight_cuts(hl, comments, *, key=None, quotas=None)` | `quotas`가 주어지면 `quotas_for()` 대신 그것을 쓴다(압축 길이 기준 장수를 밖에서 계산해 넘긴다). 안 넘기면 기존 동작 |
| `is_whole(cuts)``is_time_based(cuts)` | **모든 컷의 `bottom`이 비면** 시각 기반. 컷 1개 통짜는 특수 케이스가 된다. 구간 2개 이상도 걸린다 |
| `_whole_picks` | 컷마다 호출하되 `used` 집합을 공유해 컷 간 중복 배정을 막는다 |
| `build_cut_picks` | 3순위( 좋아요 채움)를 추가한다. 지금은 ⭐·🤖만 채우고 모자라면 비워 둔다 |
## 7. 자동 탭 — 3단계
검토 화면에 **`✕` ID 제외 버튼**이 있다. 지금은 제외하면 그 하이라이트를 다운로드조차
안 한다. 받아쓰기를 먼저 돌리면 **버릴 것까지 받아서 받아쓰기**하게 되므로,
**제외를 먼저 고르게** 한다.
```
① 분석 Gemini Step1/Step3 (또는 JSON 파싱) → 편집안 5개
② 1차 검토 하이라이트 카드 5장 — 제목 선택, ✕ 제외, 옵션 확인 ← 새 화면
③ 준비 남은 것만 다운로드·무음·받아쓰기 (순차) + h-lab 댓글 수집
④ 2차 검토 컷별 댓글 섹션 (지금 검토 화면에서 댓글 부분만)
⑤ 생성 드래프트 (다운로드·받아쓰기 안 함 — ③에서 끝냈다)
```
②는 지금 검토 화면에서 **댓글 영역만 뺀 것**이라 새로 만드는 게 아니라 나누는 것이다.
③은 진행 표시를 ID별 보드에 그대로 쓴다.
## 8. 구간 탭 · 붙여넣기 탭 — 2단계
제외할 것이 없어 1차 검토가 필요 없다.
```
① 편집 시작 다운로드·무음·받아쓰기 (+ h-lab 댓글 동시 수집)
② 검토 컷/구간별 댓글 섹션
③ 생성 드래프트
```
구간 탭의 `💬 구간 댓글 매칭` 버튼은 없어진다(`편집 시작`이 그 일을 한다).
제목·출처는 검토 화면에서도 고칠 수 있게 둔다 — 1분 넘게 기다린 뒤 오타를 발견하면
다시 돌리는 게 낭비다.
## 9. 엔드포인트
| 엔드포인트 | 상태 |
|---|---|
| `POST /auto/analyze``GET /auto/stream/{aid}` | 유지. **댓글 매칭을 빼고** 편집안까지만 |
| `POST /auto/prepare` (신규) | 남은 ID들 → 다운로드·받아쓰기·댓글 매칭. SSE로 진행, `result`에 ID별 `cuts[]` |
| `POST /auto/build` | 유지. `card_cuts` 이미 받음. 이제 상태를 재사용해 드래프트만 |
| `POST /yt/analyze``GET /yt/stream/{aid}` (신규) | 구간 탭 1단계 |
| `POST /yt/build` (신규) | 구간 탭 3단계 |
| `POST /paste/analyze``GET /paste/stream/{aid}` (신규) | 붙여넣기 탭 1단계 |
| `POST /paste/build` (신규) | 붙여넣기 탭 3단계 |
| `POST /yt/comments`, `POST /youtube` | **삭제** (새 흐름이 대체. 다른 호출부 없음을 grep으로 확인할 것) |
| `POST /upload`, `POST /paste`, `GET /stream/{job_id}` | 유지 |
`result` 이벤트 스키마는 선행 스펙과 같다(`cuts[].picks[].why` = `ts`/`ai`/`like`).
## 10. 렌더러를 하나로 모은다
카드 패널을 그리는 코드가 지금 자동 탭(`onResult`)과 구간 탭(`ytMatch`) 두 갈래다.
붙여넣기 탭까지 더하면 세 갈래가 되어 한 곳만 고치는 실수가 난다.
**`renderCutPanel(panelId, data, opts)` 하나로 모은다** — 컷/구간별 섹션, ⭐🤖➕ 배지,
검색창, ` 채우기` 섹션, 컷별 선택 상한을 전부 담당한다. 세 탭이 `panelId`만 달리해
부른다(`hl.id` / `"yt"` / `"paste"`). 라벨 접두사(`컷` vs `구간`)는 `opts`로 넘긴다.
이번 작업에 필요해서 하는 정리이지 무관한 리팩터링이 아니다.
## 11. 실패·폴백
| 상황 | 처리 |
|---|---|
| 받아쓰기 결과 없음 | `bottom` 전부 빔 → `is_time_based` 경로(시각 슬롯 + 좋아요 채움) |
| Gemini 실패(429·타임아웃·파싱) | `ai_failed` 경고 한 줄 + ⭐·➕만으로 배정 |
| h-lab 댓글 수집 실패 | 경고 한 줄 + 댓글 없이 검토 화면 → 카드 없이 생성 가능 |
| 다운로드 실패 | 기존과 동일 — `error` 이벤트로 중단(영상이 없으면 만들 수 없다) |
| `aid` 만료(서버 재시작) | build가 404 + "분석을 다시 돌려주세요" |
**다운로드 실패를 빼면 어떤 경우에도 드래프트 생성을 막지 않는다.**
## 12. 하지 않는 것
- **📁 파일 탭** — 로컬 파일이라 댓글을 가져올 유튜브 URL이 없다. URL을 따로 받으면
자막 기반 추천(🤖)은 가능하지만, 로컬 파일의 시각이 원본 영상 시각과 달라
**분:초 매칭(⭐)은 원리적으로 불가**하다. 사용자가 제외를 선택했다.
- **📁 파일 탭 흐름 변경** — 내부만 쪼개고 겉보기는 그대로.
- **유튜브 자동자막** — Whisper 받아쓰기를 쓰므로 불필요.
- **다운로드 캐시 추가** — 파이프라인을 쪼개면 재다운로드가 없어진다.
- **하이라이트 병렬 준비** — yt-dlp·ffmpeg·Whisper가 CPU를 다 쓴다. 순차 유지(기존 방침).
- **구간별 제목·출처 개별 지정** — 이어붙여 영상 하나를 만드므로 제목도 하나다.
## 13. 구현 단계
한 번에 다 하기엔 크다. **각 단계가 그 자체로 돌아가는 상태**로 끊는다.
| 단계 | 내용 | 끝나면 |
|---|---|---|
| **1** | 파이프라인 분할(`*_analyze`/`*_draft`) + `recommend.py` 변경 + 렌더러 통합 | 겉보기 동작 불변. 회귀만 확인 |
| **2** | ▶ 구간 탭 2단계 적용 | 구간 탭에서 받아쓰기 후 매칭이 돈다 |
| **3** | 📋 붙여넣기 탭 2단계 적용 | 붙여넣기 탭에 댓글 화면이 생긴다 |
| **4** | 🤖 자동 탭 3단계 적용 | 세 탭 통일 완료 |
단계마다 계획을 따로 쓰고 실행한다. 1단계는 사용자에게 보이는 변화가 없으므로
**회귀 검증이 전부**다 — 여기서 깨지면 나머지가 전부 위에서 무너진다.
## 14. 검증
자동 테스트 스위트가 없다(CLAUDE.md).
**순수 함수 (인라인 assert)**
1. `is_time_based()` — 자막 전부 비면 참, 하나라도 차 있으면 거짓, 컷 1개 통짜도 참
2. 컷 간 중복 배정 없음 (`_whole_picks` 공유 `used`)
3. 컷 자막 추출 — 압축 좌표 범위에 걸친 캡션만, 500자에서 자름
4. `quotas` 외부 주입 시 `build_highlight_cuts` 동작(안 넘기면 기존과 동일)
5. `build_cut_picks` 3순위() 채움
**회귀 (1단계에서 반드시)**
6. 📁 파일 탭: 분할 전후 SSE 이벤트 순서·내용 동일, `state` 이벤트가 밖으로 안 샘
7. 🤖 자동 탭: 렌더러 통합 후 컷별 섹션·배지·검색·캡처 그대로
8. 네 탭 모두 드래프트가 예전과 같은 결과로 나오는가
**실사용 (단계별)**
9. 구간 2개 영상 → 구간별 섹션·자동 선택, 카드 시간이 각 구간 압축 범위 안
10. 표시한 `need` = 실제 배치된 카드 수(버려지는 카드 0)
11. 붙여넣기 탭 → 컷별 섹션, 댓글 안 골라도 생성됨(하위호환)
12. 자동 탭 3단계 → 1차에서 2개 제외 시 그 2개는 다운로드·받아쓰기 안 함
13. Gemini 키를 비워 세 탭 모두 폴백이 도는가
## 15. 열린 질문
없음. 미결이 생기면 여기에 적고 구현 전에 사용자에게 묻는다.