# 컷별 댓글 추천 · 컷 위치 배치 (설계) 작성일: 2026-08-04 관련 코드: `capcut_agent/comments.py`, `capcut_agent/pipeline.py`, `server/app.py`, `server/static/auto.js` 선행 스펙: `2026-07-31-자동탭-오팔대체-댓글자동매칭-design.md` (댓글 매칭의 최초 도입) ## 1. 배경 자동 탭 검토 화면은 하이라이트마다 댓글을 두 덩어리로 보여준다. - ⭐ **이 구간을 언급한 댓글** — 본문에 `9:05` 같은 분:초가 있고 그게 구간 안인 댓글, 좋아요순 자동선택 - ➕ **좋아요 상위 후보** — 분:초가 아예 없는 댓글, 수동 보충 문제는 두 가지다. **(a) 내용을 안 본다.** `comments.py`의 매칭은 전부 정규식(`TS_RE`)으로 뽑은 타임스탬프뿐이다. 편집안이 컷마다 자막(`bottom`)을 갖고 있는데도 — 예: `아까랑 완전 스타일이 달라` — 매칭에 전혀 쓰이지 않는다. 타임스탬프를 안 적은 댓글은 아무리 그 장면 얘기여도 ⭐에 못 들어온다. **(b) 어디에 깔릴지 모른다.** `_load_comment_cards()`는 카드를 파일명 순서대로 **타임라인 전체에 균등 배치**한다(간격 `dur/n`). 컷 경계를 모르므로, 3번 컷 얘기하는 댓글이 7번 컷 위에 뜰 수 있다. 이 스펙은 **컷 단위로 추천하고 그 컷 위에 깔리게** 만든다. ## 2. 범위 ### 하는 것 - 검토 화면 댓글 영역을 **컷별 묶음**으로 재구성 - 컷 자막 기반 Gemini 추천 (컷이 있는 모드) - 시각 슬롯 기반 정밀 배정 (통짜 모드, Gemini 미사용) - 카드를 **그 컷 구간 안에** 배치 — 무음 제거 시 자막과 동일하게 재매핑 ### 하지 않는 것 - **유튜브 구간 탭·붙여넣기 탭은 손대지 않는다.** 구간 탭은 컷·자막이 없어 추천 근거가 없고, 붙여넣기 탭은 댓글 선택 UI 자체가 없다. 자동 탭 4개 모드만 대상. - **`_load_comment_cards()`를 없애지 않는다.** 폴더 지정 경로(파일/유튜브 구간 탭)는 그대로 쓴다. - **댓글 감정분석·본문 요약·추천 이유 표시·추천 학습을 하지 않는다.** 지금 필요 없다. - **통짜 모드에 Gemini를 쓰지 않는다.** 근거(자막)가 없는데 비용만 든다 — §4.2. - **h-lab을 고치지 않는다.** 필요한 댓글 데이터가 이미 다 온다. ## 3. 지금 동작 (변경 전 사실) | 위치 | 사실 | |---|---| | `comments.py` `TS_RE` | `(?/001.png…`, `comments_dir`로 job 등록 | | `pipeline.py` `placements` | 컷 순서 누적으로 계산한 **컷별 타임라인 구간** `(p0, p1)` — 자막 배치에 이미 사용 중 | | `pipeline.py` `_remap_caps()` | 무음 제거 시 자막 시간을 압축 타임라인으로 재매핑 | | `pipeline.py` `_load_comment_cards()` | 폴더 이미지를 `dur/n` 간격 균등 배치. `fixed=True`면 3초 고정·뒤 비움 | | `draft.py` `build_bg_template_draft()` | `comment_cards: List[Tuple[start, end, path]]` — **이미 시간을 직접 받는다** | | `plan.py` `_call()` | Gemini 호출. `parts[0]`에 `fileData`(영상)가 **항상 들어간다** → 텍스트 전용 호출 불가 | | `plan.py` `DEFAULT_MODEL` | `gemini-3.5-flash` | | `correct.py` | `_gemini_key()`, `GeminiQuotaError`(429) | ## 4. 설계 ### 4.1 모드가 갈리는 이유 | 모드 | 컷 | 컷 자막 | 타임라인 시각 ↔ 원본 시각 | |---|---|---|---| | `full`, `paste` | 여러 개 | **있음** | 컷 순서를 섞어 재배치 → **다름** | | `whole`, `wpaste` | **1개** | **빈 문자열** | 구간을 그대로 이어붙임 → **같음** | 컷이 있는 모드는 시각이 어긋나므로 **내용**으로 맞출 수밖에 없고, 통짜 모드는 자막이 없으므로 **시각**으로 맞출 수밖에 없다. 각자 근거가 있는 쪽을 쓴다. ### 4.2 매칭 **컷 있는 모드 (`full`, `paste`) — Gemini 1회/하이라이트** 1. 후보 댓글 = 좋아요 상위 **150장**(본문 200자 절단). 그 이상은 토큰만 먹고 채택률이 낮다. 2. 컷 목록(번호·길이·자막·효과자막)과 함께 텍스트로 던져 컷별 배정을 받는다. 요청 장수는 컷당 `quota + 2` (사람이 갈아끼울 여유분). 3. **타임스탬프 우선**: 그 컷의 **원본 구간** `[cut.start, cut.end]`을 언급한 댓글이 있으면 `match_ranges()` 결과를 먼저 채우고, 남는 자리만 Gemini 추천으로 채운다. 근거가 확실한 쪽을 이기게 둘 이유가 없다. 4. 중복 제거: 한 댓글이 여러 컷에 배정되면 **앞 컷이 가져간다**. **통짜 모드 (`whole`, `wpaste`) — Gemini 0회** 컷이 1개이고 타임라인 시각 = 원본 시각이므로, 카드 슬롯 `k`의 원본 시간대는 `[start + k·(total/n), start + (k+1)·(total/n))` 로 정확히 계산된다(`n` = 카드 장수). 슬롯마다 그 시간대를 언급한 댓글을 좋아요순으로 꽂고, 빈 슬롯만 ➕ 상위로 메운다. 지금(구간 전체를 뭉뚱그려 매칭 → 순서 무관)보다 정확해지고 비용은 0이다. ### 4.3 카드 장수 컷별 `quota_i = max(1, floor(len_i / 3))`. 하이라이트의 `need = Σ quota_i` (기존 `int(total // 3)`을 대체 — 값이 ±1 다를 수 있으나 컷 경계에 맞추는 쪽이 맞다). round가 아니라 floor인 이유: 빌드 단계(`_cards_by_cut`)의 컷당 장수 상한도 같은 `max(1, floor(컷길이/3))`이라, round를 쓰면 5초 컷처럼 여기서 2장을 고르고도 빌드에서 상한(1장)에 걸려 1장이 말없이 버려지는 불일치가 생긴다. ### 4.4 배치 — 시간은 파이프라인이 계산한다 ⚠ **서버에서 카드 시간을 확정하면 안 된다.** 무음 제거(`remove_silence`)를 켜면 `timeline_dur`가 줄고 자막이 `_remap_caps()`로 재매핑된다. 카드 시간을 미리 박아두면 그 뒤 혼자 어긋난다. 그래서 `/auto/build`는 **카드가 어느 컷 소속인지만** 보낸다: ``` card_cuts = [0, 0, 1, 1, 1, 2, …] # 카드 순서대로, 값 = 컷 인덱스 ``` `process_paste()`가 이미 갖고 있는 `placements[(p0, p1)]`로 시간을 만든다: - 컷 `i`에 카드 `m`장 → `p0`부터 `(p1-p0)/m` 간격 (`cards_fixed`면 3초 고정, 컷 뒷부분은 비움) - 무음 제거가 켜져 있으면 자막과 **같은 `_remap_caps()`** 를 카드에도 적용 - `card_cuts`가 없으면(폴더 지정 경로 등) 기존 `_load_comment_cards()` 그대로 컷 하나가 추천 부족으로 덜 차도 다음 컷 카드가 앞으로 밀리지 않는다 — 지금 방식의 약점이 여기서 사라진다. ## 5. 데이터 스키마 `/auto/analyze` SSE의 하이라이트에 컷별 정보를 싣는다. ```jsonc {"id":1, "paste":{…}, "total":49.5, "need":18, // need = Σ quota "cuts":[ // paste.cuts 와 같은 순서·길이 {"i":0, "sec":5.0, "bottom":"아까랑 완전 스타일이 달라", "quota":2, "picks":[{"idx":12,"why":"ts"}, {"idx":45,"why":"ai"}]}, {"i":1, "sec":5.5, "bottom":"우리에게 익숙한 평냥은", "quota":2, "picks":[{"idx":7,"why":"ai"}]} ], "candidates":[19,33,…]} // 컷 무관 좋아요 상위 (수동 보충용) ``` `why`는 **추천 1건마다** 붙는다 — 한 컷 안에서도 출처가 섞이기 때문이다(§4.2 3번). | 값 | 뜻 | 화면 표시 | |---|---|---| | `ts` | 그 컷의 원본 구간을 언급한 타임스탬프 댓글 | ⭐ | | `ai` | Gemini가 자막 내용으로 고름 | 🤖 | | `like` | 통짜 모드에서 빈 슬롯을 좋아요순으로 메움 | ➕ | `/auto/build` 폼에 `card_cuts`(JSON 배열) 추가. 나머지 필드는 그대로. ## 6. 변경 파일 | 파일 | 변경 | |---|---| | `capcut_agent/comments.py` | `match_slots(comments, start, total, n)` 추가 — 시각 슬롯별 배정(통짜용) | | `capcut_agent/recommend.py` (신규) | Gemini 텍스트 전용 호출 + 컷별 배정·중복 제거. `plan.py._call`은 영상 part가 필수라 재사용 불가 | | `capcut_agent/prompts.py` | 추천 프롬프트를 `프롬프트/댓글_추천.md`로 (없으면 기본값 생성 — 기존 패턴과 동일) | | `server/app.py` | 하이라이트에 `cuts[]` 실어 보냄, `/auto/build`에 `card_cuts` 폼 수신 | | `capcut_agent/pipeline.py` | `card_cuts` 인자 추가 → `placements` 기반 시간 계산 + 무음 제거 시 재매핑 | | `server/static/auto.js` | 카드 섹션을 컷별로 렌더, 선택 상한을 컷별 `quota`로, 선택 순서를 컷 순서로 고정 | ## 7. 실패·폴백 | 상황 | 처리 | |---|---| | Gemini 쿼터 초과(`GeminiQuotaError`)·타임아웃·JSON 파싱 실패 | 기존 동작으로 폴백 — 구간 전체 ⭐ + ➕ 좋아요순. 화면에 `⚠ 추천 실패 → 기존 방식` 한 줄 | | Gemini가 없는 idx·범위 밖 idx를 반환 | 조용히 버림 | | 컷 추천이 `quota`에 모자람 | 그 컷은 있는 만큼만. 사람이 ➕에서 채울 수 있음 | | h-lab 댓글 수집 실패 | 지금과 동일 — 댓글 없이 진행 | **어떤 경우에도 드래프트 생성을 막지 않는다.** ## 8. 검증 자동 테스트 스위트가 없으므로(CLAUDE.md) 다음으로 확인한다. 1. `comments.match_slots()` 순수 함수 단위 검사 — 인라인 assert 스크립트 2. 컷별 배정·중복 제거 로직 단위 검사 (Gemini 응답은 고정 JSON으로 대체) 3. 실제 영상 1건으로 `full` 모드 → 검토 화면에 컷별 섹션·추천이 뜨는지 4. 생성된 `draft_content.json`에서 **카드 세그먼트 시간이 컷 구간 안에 들어가는지** 확인 5. 무음 제거 ON 으로 같은 영상 재실행 → 카드가 자막과 함께 당겨졌는지 확인 6. Gemini 키를 일부러 비워 폴백이 도는지 확인 ## 9. 열린 질문 없음. 미결이 생기면 여기에 적고 구현 전에 사용자에게 묻는다.