diff --git a/docs/superpowers/specs/2026-08-04-남은두탭-댓글추천-design.md b/docs/superpowers/specs/2026-08-04-남은두탭-댓글추천-design.md deleted file mode 100644 index c920ed5..0000000 --- a/docs/superpowers/specs/2026-08-04-남은두탭-댓글추천-design.md +++ /dev/null @@ -1,287 +0,0 @@ -# 컷별 댓글 추천을 남은 두 탭에 — 구간 탭(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//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. 열린 질문 - -없음. 미결이 생기면 여기에 적고 구현 전에 사용자에게 묻는다. diff --git a/docs/superpowers/specs/2026-08-04-받아쓰기후-댓글매칭-design.md b/docs/superpowers/specs/2026-08-04-받아쓰기후-댓글매칭-design.md new file mode 100644 index 0000000..c8f6bbb --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-받아쓰기후-댓글매칭-design.md @@ -0,0 +1,237 @@ +# 받아쓰기 후 댓글 매칭 — 모든 탭 통일 (설계) + +작성일: 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. 열린 질문 + +없음. 미결이 생기면 여기에 적고 구현 전에 사용자에게 묻는다.