capcut-agent/docs/superpowers/specs/2026-08-04-구간탭-2단계-댓글추천-design.md
hehihoho3@gmail.com 4abf41ebcb docs: 구간 탭 2단계 분할 + 구간별 댓글 추천 스펙
받아쓰기가 빌드 중간에 있어 카드 고를 때 자막이 없다. 파이프라인을 쪼개
받아쓰기 다음에 고르게 하면 자막 품질·장수 정확도·중복 작업이 한 번에 풀린다.
다운로드 캐시가 없어 '매칭 때 받아쓰기, 빌드 때 캐시' 방식은 시간이 두 배가 된다.

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

12 KiB
Raw Blame History

유튜브 구간 탭 — 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가 여기서 나왔다)

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_segmentskeep, 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**를 쓴다. 두 배열은 인덱스로만 짝지어 다닌다.

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 이벤트:

{"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. 하지 않는 것

  • 📋 붙여넣기 탭 — 별도 스펙. 구간 탭에서 만든 재사용 부품 위에 얹는 게 순서다.
  • 📁 파일 탭 흐름 변경 — 내부만 쪼개고 겉보기는 그대로.
  • 유튜브 자동자막 — Whisper 받아쓰기를 쓰기로 했으므로 불필요.
  • 🤖 자동 탭 — 손대지 않는다.
  • 다운로드 캐시 추가 — 파이프라인을 쪼개면 재다운로드가 없어지므로 필요 없다.
  • 구간별 제목·출처 개별 지정 — 구간을 이어붙여 영상 하나를 만드는 것이므로 제목도 하나다.

14. 검증

자동 테스트 스위트가 없다(CLAUDE.md). 다음으로 확인한다.

  1. is_time_based() / 구간별 _whole_picks 중복 방지 — 인라인 assert
  2. 구간 자막 추출(압축 좌표 범위 → 캡션 이어붙이기) — 인라인 assert
  3. quotas 외부 주입 시 build_highlight_cuts 동작 — 인라인 assert
  4. 📁 파일 탭 회귀: 분할 전후 SSE 이벤트 순서·내용이 같은가 (실제 파일 1건)
  5. 구간 2개짜리 실제 영상 1건 → 검토 화면에 구간별 섹션·자동 선택이 뜨는가
  6. 생성된 draft_content.json에서 카드 시간이 각 구간의 압축 타임라인 범위 안인가
  7. 표시한 need 장수 = 실제 배치된 카드 수 (버려지는 카드 0)
  8. Gemini 키를 비워 폴백이 도는가

15. 열린 질문

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