capcut-agent/docs/superpowers/specs/2026-08-04-컷별-댓글-추천-design.md
hehihoho3@gmail.com c39b72518f 컷별 카드 장수 공식을 floor로 통일 — round와 어긋나 카드가 조용히 버려지던 문제 수정
recommend.quotas_for()는 round, pipeline._cards_by_cut()의 컷당 장수 상한은 floor를
써서 둘이 어긋났다. 5초 컷처럼 round가 floor보다 큰 쪽으로 갈리는 컷은 UI가 카드
2장을 고르게 하고도 빌드 단계에서 상한(1장)에 걸려 뒤 1장이 로그 없이 버려졌다.
사용자 결정에 따라 floor로 통일(카드 한 장이 항상 3초 이상 — 기존
_load_comment_cards 규칙과 동일)하고, 무음 제거로 컷이 압축돼 여전히 카드가
버려지는 경우(이건 불가피)를 컷별 배치 로그에 표시해 더 이상 조용히 사라지지
않게 했다. 관련 설계/계획 문서의 공식·테스트 기대값도 floor 기준으로 맞췄다.

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

10 KiB
Raw Blame History

컷별 댓글 추천 · 컷 위치 배치 (설계)

작성일: 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 (?<!\d)(\d{1,2}):([0-5]\d)(?::([0-5]\d))?(?!\d) — h-lab과 동일 규칙
comments.py MAX_TIMES = 3 분:초를 3개 넘게 나열한 '목차 댓글'은 매칭에서 제외
comments.py match_ranges() 구간 안 시각을 하나라도 언급한 댓글 idx, 좋아요 내림차순
comments.py top_liked() exclude 제외 좋아요 상위 n
app.py _need(total) max(1, int(total // 3)) — 하이라이트 전체 기준 카드 장수
app.py _whole_hl() 통짜 하이라이트는 cuts:[{start, end, bottom:"", effect:""}] 1개, 자막 빈 문자열
app.py /auto/build data(붙여넣기 스키마 JSON) + cards PNG들 → COMMENTS_DIR/<h>/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의 하이라이트에 컷별 정보를 싣는다.

{"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/buildcard_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. 열린 질문

없음. 미결이 생기면 여기에 적고 구현 전에 사용자에게 묻는다.