capcut-agent/docs/superpowers/plans/2026-08-04-컷별-댓글-추천.md
hehihoho3@gmail.com bf1b387d6d chore: git 저장소 초기화 (기존 코드 스냅샷)
컷별 댓글 추천 작업을 태스크 단위로 되돌릴 수 있게 버전관리를 시작한다.
.gitignore 로 영상·캐시(.downloads 2.7G, .comments 72M, .media 28M)와
비밀키(.gemini_key)를 제외했다.

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

50 KiB
Raw Blame History

컷별 댓글 추천 · 컷 위치 배치 구현 계획

에이전트 작업자용: 이 계획은 superpowers:subagent-driven-development(권장) 또는 superpowers:executing-plans로 태스크 단위로 실행한다. 단계는 체크박스(- [ ])로 추적한다.

목표: 자동 탭 검토 화면의 댓글을 컷별로 추천하고, 선택한 카드를 그 컷 구간 위에 깐다.

접근: 컷이 있는 모드(full/paste)는 컷 자막으로 Gemini 추천 + 타임스탬프 우선. 통짜 모드(whole/wpaste)는 Gemini 없이 시각 슬롯으로 배정. 카드 시간은 서버가 확정하지 않고 "몇 번 컷 소속"만 넘겨 파이프라인이 placements로 계산한다(무음 제거 재매핑 때문).

기술 스택: Python 3.13 / FastAPI / 표준 라이브러리 urllib(Gemini 호출) / 바닐라 JS

스펙: docs/superpowers/specs/2026-08-04-컷별-댓글-추천-design.md

Global Constraints

  • 코드를 고쳤으면 캡컷_에이전트_구간합치기.bat을 반드시 재시작한다. uvicorn hot-reload가 없어 검은 창을 닫고 다시 실행해야 반영된다. "안 돼요"의 가장 흔한 원인.
  • 이 프로젝트는 git 저장소가 아니다. 커밋 단계가 없다. 각 태스크는 인라인 assert 통과 + 구문/임포트 검증으로 끝낸다.
  • 테스트 프레임워크가 없다. pytest를 도입하지 않는다. 검증은 python - <<'PY' … PY 인라인 assert 스크립트로 한다(기존 계획 문서와 같은 방식).
  • Windows 콘솔은 cp949라 한글 print가 깨진다. 검증 스크립트 첫 줄에 import sys; sys.stdout.reconfigure(encoding='utf-8')를 넣는다.
  • 자막 타이밍 원칙을 건드리지 않는다. Whisper 단어 타임스탬프 = 시간, Gemini = 글자만. 이 계획은 댓글 카드만 다룬다.
  • 카드 1장 기준 길이는 3.0초(CARD_SEC). 기존 _load_comment_cards(min_sec=3.0)와 같은 값.
  • 새 모듈은 capcut_agent/ 아래, 기존 파일들과 같은 스타일(한국어 docstring, from __future__ import annotations, 표준 라이브러리만).

파일 구조

파일 책임
capcut_agent/comments.py (수정) 댓글 수집·타임스탬프 파싱·매칭. match_slots() 추가 — 시각 슬롯 배정
capcut_agent/recommend.py (신규) 컷별 추천 전담. 순수 배정 로직 + Gemini 호출 + 하이라이트 조립
capcut_agent/prompts.py (수정) 추천 프롬프트 파일 로더 추가
capcut_agent/pipeline.py (수정) 카드 경로 목록 분리, 컷 소속 기반 카드 시간 계산, 무음 재매핑
server/app.py (수정) /auto/analyzecuts[]를 실어 보냄, /auto/buildcard_cuts 수신
server/static/auto.js (수정) 컷별 카드 섹션 렌더, 컷별 선택 상한, card_cuts 전송

recommend.py가 새로 생기는 이유: 배정 규칙(타임스탬프 우선·중복 제거·모드 분기)이 comments.py(수집·파싱)와도, app.py(HTTP)와도 책임이 다르다. 순수 함수로 떼어놔야 Gemini 없이 단위 검증이 된다.


Task 1: 시각 슬롯 매칭 (match_slots)

통짜 모드가 쓸 순수 함수. 구간을 n개 슬롯으로 잘라 슬롯마다 댓글 1개씩 배정한다.

Files:

  • Modify: capcut_agent/comments.py (파일 끝, top_liked() 다음)

Interfaces:

  • Consumes: 기존 match_ranges(comments, ranges), MAX_TIMES

  • Produces: match_slots(comments, start, total, n, exclude=None) -> List[Optional[int]] — 길이 n, 각 원소는 댓글 idx 또는 None

  • Step 1: 실패하는 검증 스크립트 작성

C:\Users\hehih\AppData\Local\Temp\claude\D-------00----capcut2\...\scratchpad\t1.py (또는 임의 임시 경로)에 저장:

import sys; sys.stdout.reconfigure(encoding='utf-8')
from capcut_agent.comments import match_slots

cs = [
    {"idx": 0, "likeCount": 10, "times": [100.0]},
    {"idx": 1, "likeCount": 99, "times": [101.0]},
    {"idx": 2, "likeCount": 50, "times": [107.0]},
    {"idx": 3, "likeCount": 80, "times": []},                    # 분:초 없음
    {"idx": 4, "likeCount": 70, "times": [1.0, 2.0, 3.0, 4.0]},  # 목차 댓글(MAX_TIMES 초과)
]
# start=100, total=9, n=3 → 슬롯 [100,103) [103,106) [106,109)
assert match_slots(cs, 100.0, 9.0, 3) == [1, None, 2], match_slots(cs, 100.0, 9.0, 3)
# 좋아요 높은 idx1 이 슬롯0을 가져가고, 제외하면 idx0 이 온다
assert match_slots(cs, 100.0, 9.0, 3, exclude={1}) == [0, None, 2]
# 목차 댓글(times 4개)은 어느 슬롯에도 안 걸린다
assert match_slots(cs, 0.0, 9.0, 3) == [None, None, None]
# 한 댓글이 두 슬롯에 중복 배정되지 않는다 (슬롯 경계는 양끝 포함이라 idx1 이 둘 다 걸린다)
assert match_slots(cs, 100.0, 2.0, 2) == [1, None], match_slots(cs, 100.0, 2.0, 2)
assert match_slots(cs, 100.0, 0.0, 3) == []      # total 0 → 빈 리스트
assert match_slots(cs, 100.0, 9.0, 0) == []      # n 0 → 빈 리스트
print("Task1 OK")
  • Step 2: 실패 확인

Run: cd "D:/개인폴더/00.유튭/capcut2" && python <임시경로>/t1.py Expected: FAIL — ImportError: cannot import name 'match_slots'

  • Step 3: 구현

capcut_agent/comments.py 끝에 추가:

def match_slots(comments: List[Dict], start: float, total: float, n: int,
                exclude=None) -> List[Optional[int]]:
    """구간을 n개 슬롯으로 잘라 슬롯마다 댓글 1개씩 배정 — 통짜 모드용.

    슬롯 k = 원본 [start + k·total/n, start + (k+1)·total/n).
    통짜 모드는 구간을 그대로 이어붙여 **타임라인 시각 = 원본 시각**이라 이 계산이 성립한다
    (컷 있는 모드는 컷 순서를 섞어 재배치하므로 쓰면 안 된다 — 내용 기반 추천을 쓸 것).

    각 슬롯은 그 시간대를 언급한 댓글 중 좋아요 최다 1개. 없으면 None.
    한 댓글이 두 슬롯에 겹치지 않는다. exclude(idx 집합)는 처음부터 제외.
    Returns: 길이 n 리스트 (n<=0 이거나 total<=0 이면 빈 리스트)
    """
    if n <= 0 or total <= 0:
        return []
    used = set(exclude or ())
    step = total / n
    out: List[Optional[int]] = []
    for k in range(n):
        s = start + k * step
        pick = None
        for idx in match_ranges(comments, [(s, s + step)]):
            if idx not in used:
                pick = idx
                used.add(idx)
                break
        out.append(pick)
    return out

typing import에 Optional을 추가한다(파일 상단 from typing import Dict, Listfrom typing import Dict, List, Optional).

  • Step 4: 통과 확인

Run: cd "D:/개인폴더/00.유튭/capcut2" && python <임시경로>/t1.py Expected: Task1 OK

  • Step 5: 구문·임포트 검증
cd "D:/개인폴더/00.유튭/capcut2"
python -c "import ast; ast.parse(open('capcut_agent/comments.py', encoding='utf-8').read())"
python -c "from server import app; print('import OK')"

Task 2: 컷별 배정 순수 로직 (build_cut_picks)

Gemini 결과를 받아 타임스탬프 우선·중복 제거로 컷별 최종 추천을 만든다. Gemini를 호출하지 않으므로 단독 검증이 된다.

Files:

  • Create: capcut_agent/recommend.py

Interfaces:

  • Consumes: comments.match_ranges

  • Produces:

    • CARD_SEC = 3.0
    • quotas_for(cuts) -> List[int] — 컷별 카드 장수 max(1, round(len/CARD_SEC))
    • build_cut_picks(cuts, comments, ai_picks, quotas) -> List[List[dict]] — 컷별 [{"idx": int, "why": "ts"|"ai"}]
  • Step 1: 실패하는 검증 스크립트 작성

import sys; sys.stdout.reconfigure(encoding='utf-8')
from capcut_agent.recommend import quotas_for, build_cut_picks

cuts = [{"start": 100.0, "end": 105.0, "bottom": "아까랑 완전 스타일이 달라"},
        {"start": 200.0, "end": 206.0, "bottom": "우리에게 익숙한 평냥은"}]
assert quotas_for(cuts) == [2, 2], quotas_for(cuts)
assert quotas_for([{"start": 0.0, "end": 1.0, "bottom": ""}]) == [1]   # 짧아도 최소 1
assert quotas_for([{"start": 0.0, "end": 10.0, "bottom": ""}]) == [3]  # round(10/3)=3

cs = [
    {"idx": 0, "likeCount": 50, "times": [101.0]},   # 컷0 언급
    {"idx": 1, "likeCount": 90, "times": [104.0]},   # 컷0 언급, 좋아요 더 높음
    {"idx": 2, "likeCount": 70, "times": []},        # 타임스탬프 없음
    {"idx": 3, "likeCount": 60, "times": []},
    {"idx": 9, "likeCount": 10, "times": []},        # ai 가 골랐지만 존재함
]
# 컷0: ts 2장이 quota 2를 다 채움 → ai 는 안 들어감
# 컷1: ts 없음 → ai 가 채움. 없는 idx(777)는 버림. 컷0에 쓴 idx1 은 중복이라 버림
ai = {0: [2, 3], 1: [1, 777, 3, 9]}
got = build_cut_picks(cuts, cs, ai, [2, 2])
assert got[0] == [{"idx": 1, "why": "ts"}, {"idx": 0, "why": "ts"}], got[0]
assert got[1] == [{"idx": 3, "why": "ai"}, {"idx": 9, "why": "ai"}], got[1]

# ai 가 비어도(폴백) 터지지 않는다 — ts 만으로 채운다
got2 = build_cut_picks(cuts, cs, {}, [2, 2])
assert got2[0] == [{"idx": 1, "why": "ts"}, {"idx": 0, "why": "ts"}]
assert got2[1] == []
print("Task2 OK")
  • Step 2: 실패 확인

Run: python <임시경로>/t2.py Expected: FAIL — ModuleNotFoundError: No module named 'capcut_agent.recommend'

  • Step 3: 구현

capcut_agent/recommend.py 생성:

"""컷별 댓글 추천 — 자동 탭 검토 화면용.

왜 별도 모듈인가: 배정 규칙(타임스탬프 우선·중복 제거·모드 분기)은 댓글 수집
(`comments.py`)과도, HTTP 처리(`server/app.py`)와도 책임이 다르다. 순수 함수로 떼어놔야
Gemini 없이 단위 검증이 된다.

설계 근거는 docs/superpowers/specs/2026-08-04-컷별-댓글-추천-design.md.
"""
from __future__ import annotations

from typing import Dict, List

from .comments import match_ranges

# 카드 1장이 차지하는 기준 시간(초). pipeline._load_comment_cards(min_sec) 과 같은 값.
CARD_SEC = 3.0


def quotas_for(cuts) -> List[int]:
    """컷별 카드 장수 — max(1, round(컷길이 / CARD_SEC)). 짧은 컷도 최소 1장."""
    return [max(1, round((c["end"] - c["start"]) / CARD_SEC)) for c in cuts]


def build_cut_picks(cuts, comments, ai_picks: Dict[int, List[int]],
                    quotas) -> List[List[dict]]:
    """컷별 추천 확정 — 타임스탬프 우선, 남는 자리만 AI, 전 컷 통틀어 중복 금지.

    타임스탬프가 AI보다 먼저인 이유: 그 컷의 **원본 구간**을 콕 집어 언급한 댓글은
    근거가 확실하다. 추측(AI)을 이기게 둘 이유가 없다.
    중복은 앞 컷이 가져간다(뒤 컷은 다음 후보로 밀린다).

    ai_picks: {컷인덱스: [댓글idx …]} — Gemini 실패 시 {} 를 넘기면 타임스탬프만으로 채운다.
    Returns: 컷별 [{"idx": int, "why": "ts"|"ai"}]
    """
    valid = {c["idx"] for c in comments}
    used: set = set()
    out: List[List[dict]] = []
    for i, cut in enumerate(cuts):
        quota = quotas[i] if i < len(quotas) else 0
        picks: List[dict] = []
        for idx in match_ranges(comments, [(cut["start"], cut["end"])]):
            if len(picks) >= quota:
                break
            if idx not in used:
                picks.append({"idx": idx, "why": "ts"})
                used.add(idx)
        for idx in (ai_picks.get(i) or []):
            if len(picks) >= quota:
                break
            if idx in valid and idx not in used:
                picks.append({"idx": idx, "why": "ai"})
                used.add(idx)
        out.append(picks)
    return out
  • Step 4: 통과 확인

Run: python <임시경로>/t2.py Expected: Task2 OK

  • Step 5: 구문 검증
python -c "import ast; ast.parse(open('capcut_agent/recommend.py', encoding='utf-8').read())"

Task 3: 추천 프롬프트 파일

프롬프트/댓글_추천.md를 메모장으로 고칠 수 있게 한다. 기존 load_step1()과 같은 패턴 (없으면 기본값으로 생성 후 읽기).

Files:

  • Modify: capcut_agent/prompts.py

Interfaces:

  • Produces: prompts.load_recommend() -> str, prompts.RECOMMEND_PATH

  • Step 1: 검증 스크립트 작성

import sys, os; sys.stdout.reconfigure(encoding='utf-8')
from capcut_agent import prompts
p = prompts.load_recommend()
assert "{cuts}" in p and "{comments}" in p and "{k}" in p, "치환자 누락"
assert os.path.isfile(prompts.RECOMMEND_PATH), "파일이 자동 생성되지 않음"
print("Task3 OK")
  • Step 2: 실패 확인

Run: python <임시경로>/t3.py Expected: FAIL — AttributeError: module 'capcut_agent.prompts' has no attribute 'load_recommend'

  • Step 3: 구현

capcut_agent/prompts.py의 경로 상수 옆(STEP3_PATH 다음 줄)에 추가:

RECOMMEND_PATH = os.path.join(PROMPT_DIR, "댓글_추천.md")

DEFAULT_CONFIG 위에 기본 프롬프트 추가:

# 컷별 댓글 추천(자동 탭). {cuts} {comments} {k} 를 채워 쓴다.
DEFAULT_RECOMMEND = """너는 숏폼 편집자다. 컷마다 화면 아래에 띄울 유튜브 댓글을 고른다.

규칙:
- 컷 자막의 **내용과 의미가 통하는** 댓글만 고른다. 억지로 채우지 마라.
- 어울리는 게 없으면 그 컷은 picks 를 빈 배열로 둔다. 빈 채로 두는 게 엉뚱한 것보다 낫다.
- 한 댓글은 한 컷에만 쓴다. 여러 컷에 어울리면 가장 잘 맞는 컷 하나에만 넣어라.
- 아래 목록에 있는 댓글 번호만 쓴다. 없는 번호를 지어내지 마라.
- 컷마다 최대 {k}개까지.

컷 목록:
{cuts}

댓글 후보 (번호. 👍좋아요 / 본문):
{comments}

각 컷마다 {{"cut": 컷번호, "picks": [댓글번호…]}} 를 JSON 배열로만 출력."""

load_step3() 아래에 로더 추가:

def load_recommend() -> str:
    """컷별 댓글 추천 프롬프트. 없으면 기본값으로 파일을 만들고 읽는다(메모장 수정 가능)."""
    if not os.path.isfile(RECOMMEND_PATH):
        os.makedirs(PROMPT_DIR, exist_ok=True)
        with open(RECOMMEND_PATH, "w", encoding="utf-8") as f:
            f.write(DEFAULT_RECOMMEND)
    with open(RECOMMEND_PATH, encoding="utf-8") as f:
        return f.read()

reset()은 건드리지 않는다 — Step 1/설정만 되돌리는 함수이고, 추천 프롬프트를 지우면 사용자가 고친 내용이 날아간다.

  • Step 4: 통과 확인

Run: python <임시경로>/t3.py Expected: Task3 OK프롬프트/댓글_추천.md가 생성됨

  • Step 5: 구문 검증
python -c "import ast; ast.parse(open('capcut_agent/prompts.py', encoding='utf-8').read())"

Task 4: Gemini 호출 (ai_pick_cuts)

텍스트 전용 호출. plan.py._call()parts[0]에 영상 fileData가 항상 들어가 재사용할 수 없다. correct.py의 텍스트 호출 패턴(responseSchema + 실패 시 안전 반환)을 따른다.

Files:

  • Modify: capcut_agent/recommend.py

Interfaces:

  • Consumes: correct._gemini_key, prompts.load_recommend, comments.MAX_TIMES

  • Produces: ai_pick_cuts(cuts, comments, quotas, *, model=None, key=None, timeout=90.0) -> Dict[int, List[int]] — 실패하면 예외 없이 {}

  • Step 1: 검증 스크립트 작성 (네트워크 없이)

import sys; sys.stdout.reconfigure(encoding='utf-8')
from capcut_agent import recommend

cuts = [{"start": 0.0, "end": 5.0, "bottom": "가"}]
cs = [{"idx": 0, "likeCount": 5, "times": [], "text": "ㅋㅋㅋ", "authorName": "a"}]

# 키가 없으면 조용히 {} — 예외를 던지면 안 된다
assert recommend.ai_pick_cuts(cuts, cs, [1], key="") == {}

# 응답 파싱: 배열 → dict, 잘못된 항목은 버린다
raw = '[{"cut":0,"picks":[3,1]},{"cut":"x","picks":[2]},{"cut":1},{"nope":1}]'
assert recommend._parse_ai(raw) == {0: [3, 1]}, recommend._parse_ai(raw)
assert recommend._parse_ai("깨진 텍스트") == {}
assert recommend._parse_ai('{"cut":0}') == {}          # 배열이 아니면 버림

# 후보 목록: 목차 댓글 제외 + 좋아요순 + 본문 절단
many = [{"idx": i, "likeCount": i, "times": [], "text": "가" * 500} for i in range(5)]
many.append({"idx": 99, "likeCount": 999, "times": [1.0, 2.0, 3.0, 4.0], "text": "목차"})
lines = recommend._candidate_lines(many, 3)
assert len(lines.splitlines()) == 3, lines
assert lines.splitlines()[0].startswith("4."), lines   # 좋아요 최다부터
assert "99." not in lines                              # 목차 댓글 제외
assert len(lines.splitlines()[0]) < 260                # 본문 200자 절단
print("Task4 OK")
  • Step 2: 실패 확인

Run: python <임시경로>/t4.py Expected: FAIL — AttributeError: module 'capcut_agent.recommend' has no attribute 'ai_pick_cuts'

  • Step 3: 구현

capcut_agent/recommend.py 상단 import에 추가:

import json
import urllib.error
import urllib.request

from . import prompts
from .comments import MAX_TIMES, match_ranges
from .correct import _gemini_key

(기존 from .comments import match_ranges 줄은 위 줄로 대체한다.)

상수·함수 추가:

# Gemini에 넘길 댓글 후보 수. 더 늘려도 채택률이 안 오르고 토큰만 먹는다.
AI_CANDIDATES = 150
TEXT_CAP = 200          # 댓글 본문 절단 길이
AI_MODEL = "gemini-2.5-flash"   # correct.py 와 같은 무료 티어 모델
_ENDPOINT = ("https://generativelanguage.googleapis.com/v1beta/models/"
             "{model}:generateContent?key={key}")

_SCHEMA = {"type": "array", "items": {"type": "object", "properties": {
    "cut": {"type": "integer"},
    "picks": {"type": "array", "items": {"type": "integer"}}},
    "required": ["cut", "picks"]}}


def _candidate_lines(comments, limit: int = AI_CANDIDATES) -> str:
    """Gemini에 줄 댓글 후보 — 목차 댓글 제외, 좋아요순 상위 limit, 본문 절단."""
    usable = [c for c in comments if len(c.get("times") or []) <= MAX_TIMES]
    usable.sort(key=lambda c: -c.get("likeCount", 0))
    out = []
    for c in usable[:limit]:
        text = " ".join(str(c.get("text") or "").split())[:TEXT_CAP]
        out.append(f"{c['idx']}. 👍{c.get('likeCount', 0)} / {text}")
    return "\n".join(out)


def _cut_lines(cuts) -> str:
    """컷 목록 — 번호·길이·자막. 자막이 추천의 유일한 근거다."""
    out = []
    for i, c in enumerate(cuts):
        bottom = " ".join(str(c.get("bottom") or "").split()) or "(자막 없음)"
        effect = " ".join(str(c.get("effect") or "").split())
        line = f"{i}. ({c['end'] - c['start']:.1f}초) {bottom}"
        if effect:
            line += f"  [효과자막: {effect}]"
        out.append(line)
    return "\n".join(out)


def _parse_ai(raw: str) -> Dict[int, List[int]]:
    """Gemini 응답 → {컷인덱스: [댓글idx …]}. 형식이 어긋난 항목은 조용히 버린다."""
    try:
        data = json.loads(raw)
    except (json.JSONDecodeError, TypeError):
        return {}
    if not isinstance(data, list):
        return {}
    out: Dict[int, List[int]] = {}
    for row in data:
        if not isinstance(row, dict):
            continue
        cut, picks = row.get("cut"), row.get("picks")
        if not isinstance(cut, int) or isinstance(cut, bool):
            continue
        if not isinstance(picks, list):
            continue
        idxs = [p for p in picks if isinstance(p, int) and not isinstance(p, bool)]
        if idxs:
            out[cut] = idxs
    return out


def ai_pick_cuts(cuts, comments, quotas, *, model: str = AI_MODEL,
                 key=None, timeout: float = 90.0) -> Dict[int, List[int]]:
    """컷 자막으로 컷별 추천을 받는다. **실패하면 예외 없이 {}** — 호출부가 폴백한다.

    plan.py._call() 은 parts[0] 에 영상 fileData 가 항상 들어가 재사용할 수 없다.
    correct.py 의 텍스트 전용 호출 패턴(responseSchema + 실패 시 안전 반환)을 따른다.
    """
    key = key if key is not None else _gemini_key()
    if not key or not cuts or not comments:
        return {}
    k = max(quotas) + 2 if quotas else 3          # 갈아끼울 여유분
    try:
        prompt = prompts.load_recommend().format(
            cuts=_cut_lines(cuts), comments=_candidate_lines(comments), k=k)
    except (KeyError, IndexError, OSError):       # 사용자가 프롬프트를 깨뜨린 경우
        return {}
    body = {
        "contents": [{"parts": [{"text": prompt}]}],
        "generationConfig": {"temperature": 0.3,
                             "responseMimeType": "application/json",
                             "responseSchema": _SCHEMA},
    }
    req = urllib.request.Request(
        _ENDPOINT.format(model=model, key=key),
        data=json.dumps(body).encode("utf-8"),
        headers={"Content-Type": "application/json"})
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            data = json.loads(resp.read().decode("utf-8"))
        raw = data["candidates"][0]["content"]["parts"][0]["text"]
    except (urllib.error.URLError, KeyError, IndexError, TimeoutError,
            json.JSONDecodeError, OSError):
        return {}                                  # 429 포함 — 폴백은 호출부 몫
    return _parse_ai(raw)
  • Step 4: 통과 확인

Run: python <임시경로>/t4.py Expected: Task4 OK

  • Step 5: 구문·임포트 검증
python -c "import ast; ast.parse(open('capcut_agent/recommend.py', encoding='utf-8').read())"
python -c "from capcut_agent import recommend; print('import OK')"

Task 5: 하이라이트 조립 (build_highlight_cuts)

모드를 갈라 컷별 추천을 완성한다. 컷이 1개이고 자막이 비었으면 통짜 → 시각 슬롯, 아니면 → Gemini + 타임스탬프.

Files:

  • Modify: capcut_agent/recommend.py

Interfaces:

  • Consumes: quotas_for, build_cut_picks, ai_pick_cuts, comments.match_slots, comments.top_liked

  • Produces: build_highlight_cuts(hl, comments) -> (List[dict], int)([{"i","sec","bottom","quota","picks"}], need). picks 원소는 {"idx","why"}, whyts|ai|like

  • Step 1: 검증 스크립트 작성

import sys; sys.stdout.reconfigure(encoding='utf-8')
from capcut_agent import recommend

cs = [
    {"idx": 0, "likeCount": 50, "times": [101.0]},
    {"idx": 1, "likeCount": 90, "times": [104.0]},
    {"idx": 2, "likeCount": 70, "times": []},
    {"idx": 3, "likeCount": 60, "times": []},
]

# ── 통짜: 컷 1개 + 자막 없음 → Gemini 호출 없이 슬롯 배정 ──
whole = {"paste": {"cuts": [{"start": 100.0, "end": 106.0, "bottom": "", "effect": ""}]}}
cuts, need = recommend.build_highlight_cuts(whole, cs)
assert need == 2 and len(cuts) == 1, (need, cuts)
assert cuts[0]["quota"] == 2
# 슬롯 [100,103)=idx0, [103,106)=idx1
assert cuts[0]["picks"] == [{"idx": 0, "why": "ts"}, {"idx": 1, "why": "ts"}], cuts[0]["picks"]

# 슬롯에 아무도 없으면 좋아요 상위(타임스탬프 없는 댓글)로 메운다
whole2 = {"paste": {"cuts": [{"start": 0.0, "end": 6.0, "bottom": "", "effect": ""}]}}
c2, _ = recommend.build_highlight_cuts(whole2, cs)
assert c2[0]["picks"] == [{"idx": 2, "why": "like"}, {"idx": 3, "why": "like"}], c2[0]["picks"]

# ── 컷 있는 모드: 키 없이 돌리면 ai={} 로 폴백, ts 만 남는다 ──
multi = {"paste": {"cuts": [
    {"start": 100.0, "end": 105.0, "bottom": "가", "effect": ""},
    {"start": 200.0, "end": 206.0, "bottom": "나", "effect": ""}]}}
cuts3, need3 = recommend.build_highlight_cuts(multi, cs, key="")
assert need3 == 4, need3
assert cuts3[0]["picks"] == [{"idx": 1, "why": "ts"}, {"idx": 0, "why": "ts"}]
assert cuts3[1]["picks"] == []
assert cuts3[1]["bottom"] == "나" and cuts3[1]["sec"] == 6.0

# 댓글이 아예 없어도 터지지 않는다
c4, n4 = recommend.build_highlight_cuts(multi, [], key="")
assert n4 == 4 and all(x["picks"] == [] for x in c4)
print("Task5 OK")
  • Step 2: 실패 확인

Run: python <임시경로>/t5.py Expected: FAIL — AttributeError: ... has no attribute 'build_highlight_cuts'

  • Step 3: 구현

capcut_agent/recommend.py import에 match_slots, top_liked 추가:

from .comments import MAX_TIMES, match_ranges, match_slots, top_liked

파일 끝에 추가:

def is_whole(cuts) -> bool:
    """통짜 하이라이트인가 — 컷 1개 + 자막 없음(app._whole_hl 이 만드는 모양).

    통짜는 자막이 없어 내용 추천의 근거가 없다. 대신 타임라인 시각 = 원본 시각이라
    시각으로 정확히 맞출 수 있다(스펙 §4.1).
    """
    return len(cuts) == 1 and not (cuts[0].get("bottom") or "").strip()


def _whole_picks(cut, comments, n: int) -> List[dict]:
    """통짜 — 슬롯마다 그 시간대 언급 댓글, 빈 슬롯은 좋아요 상위로 메움."""
    slots = match_slots(comments, cut["start"], cut["end"] - cut["start"], n)
    used = {i for i in slots if i is not None}
    no_ts = [c for c in comments if not c.get("times")]
    fill = iter(top_liked(no_ts, used, n))
    out: List[dict] = []
    for s in slots:
        if s is not None:
            out.append({"idx": s, "why": "ts"})
            continue
        nxt = next(fill, None)
        if nxt is not None:
            out.append({"idx": nxt, "why": "like"})
    return out


def build_highlight_cuts(hl, comments, *, key=None):
    """하이라이트 하나 → (cuts[], need). 모드는 컷 모양으로 판별한다.

    Returns: ([{"i","sec","bottom","quota","picks"}], need)
      need = Σ quota — 기존 int(total//3) 을 대체한다(컷 경계에 맞추는 쪽이 맞다).
    Gemini 실패는 여기서 흡수된다(ai_pick_cuts 가 {} 를 준다) — 예외를 올리지 않는다.
    """
    cuts = (hl.get("paste") or {}).get("cuts") or []
    if not cuts:
        return [], 0
    quotas = quotas_for(cuts)
    if is_whole(cuts):
        picks = [_whole_picks(cuts[0], comments, quotas[0])]
    else:
        ai = ai_pick_cuts(cuts, comments, quotas, key=key)
        picks = build_cut_picks(cuts, comments, ai, quotas)
    out = [{"i": i, "sec": round(c["end"] - c["start"], 1),
            "bottom": c.get("bottom") or "", "quota": quotas[i], "picks": picks[i]}
           for i, c in enumerate(cuts)]
    return out, sum(quotas)
  • Step 4: 통과 확인

Run: python <임시경로>/t5.py Expected: Task5 OK

  • Step 5: 구문·임포트 검증
python -c "import ast; ast.parse(open('capcut_agent/recommend.py', encoding='utf-8').read())"
python -c "from capcut_agent import recommend; print('import OK')"

Task 6: /auto/analyzecuts[]를 실어 보냄

Files:

  • Modify: server/app.py:523-533 (댓글 매칭 블록)

Interfaces:

  • Consumes: recommend.build_highlight_cuts(hl, comments)

  • Produces: SSE result.highlights[].cuts = [{"i","sec","bottom","quota","picks"}], highlights[].need = Σ quota. 기존 matched/candidates그대로 유지 ( 섹션과 폴백에 쓴다)

  • Step 1: 임포트 추가

server/app.py 상단 from capcut_agent import ... 근처(24행 부근)에:

from capcut_agent import recommend
  • Step 2: 매칭 블록 교체

523~533행을 아래로 바꾼다:

        # 댓글 매칭 — 전체 전송, 브라우저가 '더보기'로 30장씩 나눠 그린다.
        # 후보(candidates)는 분:초 언급이 아예 없는 댓글만 — 타임스탬프 댓글은
        # 자기 구간의 ⭐에서 잡히므로, 다른 구간 얘기하는 댓글이 섞이지 않게.
        no_ts = [c for c in comments if not c["times"]]
        for h in highlights:
            if "paste" not in h:
                continue
            matched = hlab.match_window(comments, h["start"], h["end"])
            h["matched"] = matched
            h["candidates"] = hlab.top_liked(no_ts, set(matched), len(no_ts))
            # 컷별 추천 — 실패해도 위의 matched/candidates 로 화면이 돌아간다.
            # Gemini 호출이 섞여 있어 블로킹이므로 스레드로 뺀다.
            try:
                cuts, need = await asyncio.to_thread(
                    recommend.build_highlight_cuts, h, comments)
                if cuts:
                    h["cuts"], h["need"] = cuts, need
            except Exception as exc:  # noqa: BLE001 — 추천 실패가 생성을 막으면 안 된다
                warnings.append(
                    f"ID {h.get('id')} 컷별 추천 실패 — 기존 방식으로 표시 "
                    f"({type(exc).__name__}: {exc})")
        yield _sse({"type": "result", "highlights": highlights,
                    "comments": comments, "warnings": warnings})
  • Step 3: 구문·임포트 검증
cd "D:/개인폴더/00.유튭/capcut2"
python -c "import ast; ast.parse(open('server/app.py', encoding='utf-8').read())"
python -c "from server import app; print('import OK')"
  • Step 4: 응답 모양 확인 (네트워크 없이)
import sys; sys.stdout.reconfigure(encoding='utf-8')
from capcut_agent import recommend
hl = {"id": 1, "paste": {"cuts": [
    {"start": 0.0, "end": 5.0, "bottom": "가", "effect": ""},
    {"start": 10.0, "end": 16.0, "bottom": "나", "effect": ""}]}}
cuts, need = recommend.build_highlight_cuts(hl, [], key="")
assert [c["i"] for c in cuts] == [0, 1]
assert set(cuts[0]) == {"i", "sec", "bottom", "quota", "picks"}, cuts[0]
print("Task6 OK", need, cuts)

Expected: Task6 OK 4 [...]


Task 7: 카드를 컷 구간 안에 배치

⚠ 무음 제거를 켜면 timeline_dur가 줄고 자막이 _remap_caps()로 재매핑된다. 카드도 같은 시점에 같은 방식으로 재매핑해야 한다. 카드 튜플 (start, end, path)는 자막 (start, end, text)와 모양이 같아 _remap_caps()를 그대로 쓸 수 있다.

Files:

  • Modify: capcut_agent/pipeline.py_load_comment_cards 분리(38-77행), process_paste 시그니처(305-318행), 자막 배치부(363-390행), 카드 로드부(440-443행)

Interfaces:

  • Produces:

    • _card_paths(folder) -> List[str] — 정렬된 카드 이미지 경로
    • _cards_by_cut(paths, card_cuts, placements, dur, *, min_sec=3.0, fixed=False) -> List[Tuple[float, float, str]]
    • process_paste(..., card_cuts: Optional[List[int]] = None)
  • Step 1: 검증 스크립트 작성

import sys; sys.stdout.reconfigure(encoding='utf-8')
from capcut_agent.pipeline import _cards_by_cut, _remap_caps

paths = ["a.png", "b.png", "c.png", "d.png"]
placements = [(0.0, 6.0), (6.0, 12.0)]     # 컷0 0~6초, 컷1 6~12초
# 카드 0,1 → 컷0 / 카드 2,3 → 컷1
got = _cards_by_cut(paths, [0, 0, 1, 1], placements, 12.0)
assert got == [(0.0, 3.0, "a.png"), (3.0, 6.0, "b.png"),
               (6.0, 9.0, "c.png"), (9.0, 12.0, "d.png")], got

# 컷0 이 1장뿐이어도 컷1 카드가 앞으로 밀리지 않는다 (지금 방식의 약점이 사라지는 지점)
got2 = _cards_by_cut(paths[:3], [0, 1, 1], placements, 12.0)
assert got2 == [(0.0, 6.0, "a.png"), (6.0, 9.0, "b.png"), (9.0, 12.0, "c.png")], got2

# fixed=True → 3초 고정, 컷 뒷부분은 비움
got3 = _cards_by_cut(paths[:2], [0, 1], placements, 12.0, fixed=True)
assert got3 == [(0.0, 3.0, "a.png"), (6.0, 9.0, "b.png")], got3

# 영상 실제 길이로 클램프 — dur 밖 컷은 버린다
got4 = _cards_by_cut(paths, [0, 0, 1, 1], placements, 6.0)
assert got4 == [(0.0, 3.0, "a.png"), (3.0, 6.0, "b.png")], got4

# 범위 밖 컷 인덱스는 무시
assert _cards_by_cut(paths[:1], [7], placements, 12.0) == []
assert _cards_by_cut([], [], placements, 12.0) == []

# 카드도 자막과 같은 함수로 무음 재매핑된다 (튜플 모양이 같다)
keep = [(0.0, 3.0), (6.0, 12.0)]           # 3~6초가 무음 → 제거
assert _remap_caps([(0.0, 3.0, "a.png"), (6.0, 9.0, "c.png")], keep) == \
       [(0.0, 3.0, "a.png"), (3.0, 6.0, "c.png")]
print("Task7 OK")
  • Step 2: 실패 확인

Run: python <임시경로>/t7.py Expected: FAIL — ImportError: cannot import name '_cards_by_cut'

  • Step 3: 카드 경로 목록 분리

capcut_agent/pipeline.py_load_comment_cards(38-77행)를 아래로 교체한다. 정렬 규칙은 그대로 두고 경로 목록만 함수로 뺀다.

def _card_paths(folder: str):
    """댓글 카드 이미지 경로 목록. 배치 방식과 무관하게 '순서'만 정한다.

    정렬 규칙:
      - 모든 파일명이 숫자로 시작하면 → 숫자순(1, 2, 10 …)
      - 아니면 → 저장(생성) 순서 = 다운로드한 순서
    folder 가 비었거나 없으면 [].
    """
    import re
    folder = (folder or "").strip().strip('"')
    if not folder or not os.path.isdir(folder):
        return []
    imgs = [os.path.join(folder, f) for f in os.listdir(folder)
            if os.path.splitext(f)[1].lower() in (".png", ".jpg", ".jpeg", ".webp")]
    if not imgs:
        return []

    def _leadnum(path):
        m = re.match(r"\s*0*(\d+)", os.path.splitext(os.path.basename(path))[0])
        return int(m.group(1)) if m else None

    if all(_leadnum(p) is not None for p in imgs):
        imgs.sort(key=_leadnum)                 # 파일명 숫자순
    else:
        imgs.sort(key=lambda p: os.path.getctime(p))   # 저장(생성) 순서
    return imgs


def _load_comment_cards(folder: str, dur: float, min_sec: float = 3.0,
                        fixed: bool = False):
    """지정 폴더의 이미지를 영상 길이에 맞춰 하단에 균등 배치.

    장수: n = min(카드 수, max(1, floor(dur/min_sec))) — 카드 하나가 min_sec 밑으로
    내려가지 않는 상한. 배치 간격은 항상 dur/n → 카드가 모자라도 끝까지 빈 곳 없이
    채워지고(간격이 3초 이상으로 늘어남), 넘치면 초과분은 버린다.
    fixed=True 면 늘리지 않고 min_sec 고정 — 모자라면 뒤는 비운다
    (편집하면서 부분삭제를 많이 하는 경우 카드가 늘어나 있으면 타이밍이 꼬여서).

    ⚠ 이 함수는 컷을 모른다. 자동 탭처럼 '어느 컷에 붙일지'가 정해진 경우엔
    `_cards_by_cut()` 을 쓴다. 폴더 지정 경로(파일/유튜브 구간 탭)는 계속 이 함수를 쓴다.
    Returns: [(start, end, path)]
    """
    imgs = _card_paths(folder)
    if not imgs or dur <= 0:
        return []
    n = min(len(imgs), max(1, int(dur // min_sec)))
    if fixed:            # 3초 고정 — 뒤가 비어도 늘리지 않음
        return [(i * min_sec, min((i + 1) * min_sec, dur), path)
                for i, path in enumerate(imgs[:n])]
    interval = dur / n
    return [(i * interval, (i + 1) * interval, path)
            for i, path in enumerate(imgs[:n])]


def _cards_by_cut(paths, card_cuts, placements, dur: float, *,
                  min_sec: float = 3.0, fixed: bool = False):
    """카드를 '소속 컷 구간 안'에 배치 — 자동 탭 전용.

    card_cuts[i] = i번째 카드가 속한 컷 인덱스. 컷 안에서는 균등 분할
    (fixed=True 면 min_sec 고정, 컷 뒷부분은 비움).

    왜 컷 단위인가: 전체 균등 배치(`_load_comment_cards`)는 컷 경계를 몰라서
    3번 컷 얘기하는 댓글이 7번 컷 위에 뜬다. 컷 안에서 계산하면 한 컷이 덜 차도
    **다음 컷 카드가 앞으로 밀리지 않는다.**
    Returns: [(start, end, path)] 시간순
    """
    if not paths or not card_cuts or not placements or dur <= 0:
        return []
    groups: dict = {}
    for i, ci in enumerate(card_cuts[:len(paths)]):
        if isinstance(ci, int) and 0 <= ci < len(placements):
            groups.setdefault(ci, []).append(i)
    out = []
    for ci, idxs in groups.items():
        p0, p1 = placements[ci]
        p1 = min(p1, dur)
        if p1 <= p0:
            continue                       # 영상 실제 길이 밖 컷은 버린다
        step = min_sec if fixed else (p1 - p0) / len(idxs)
        for k, i in enumerate(idxs):
            s = p0 + k * step
            if s >= p1:
                break                      # fixed 로 컷을 넘치면 뒤는 비운다
            out.append((s, min(s + step, p1), paths[i]))
    out.sort(key=lambda t: t[0])
    return out
  • Step 4: 통과 확인

Run: python <임시경로>/t7.py Expected: Task7 OK

  • Step 5: process_pastecard_cuts 연결

시그니처(305-318행)의 cards_fixed: bool = False, 다음 줄에 추가:

    card_cuts: Optional[List[int]] = None,

typing import에 Optional이 없으면 추가한다(from typing import ... Optional).

자막 배치부 바로 아래(373행 video_clips = [(0.0, dur)] )에 카드 시간 계산을 넣는다. 무음 제거 전이어야 자막과 같은 타임라인에서 시작한다:

    # 댓글 카드(자동 탭): '몇 번 컷 소속'만 받아 여기서 시간을 만든다.
    # 서버가 시간을 확정하면 무음 제거 때 자막만 당겨지고 카드는 혼자 어긋난다.
    cut_cards = _cards_by_cut(_card_paths(comments_dir), card_cuts or [],
                              placements, dur, fixed=cards_fixed)

무음 제거 블록 안(eff_caps = _remap_caps(eff_caps, keep) 다음 줄)에 추가:

            cut_cards = _remap_caps(cut_cards, keep)

카드 로드부(440-443행)를 교체:

    # 댓글 카드: 컷 소속이 지정됐으면 그 컷 구간 안(위에서 계산), 아니면 폴더 균등 배치
    cards = cut_cards or _load_comment_cards(comments_dir, timeline_dur, fixed=cards_fixed)
    if cards:
        yield {"type": "log",
               "msg": f"댓글 카드 {len(cards)}개 하단 삽입"
                      + ("(컷별 배치)" if cut_cards else "(전체 균등)")}
  • Step 6: 구문·임포트 검증
python -c "import ast; ast.parse(open('capcut_agent/pipeline.py', encoding='utf-8').read())"
python -c "from server import app; print('import OK')"

Task 8: /auto/buildcard_cuts를 받아 넘김

Files:

  • Modify: server/app.py:589-632 (auto_build), server/app.py:213-226 (process_paste 호출)

Interfaces:

  • Consumes: 폼 필드 card_cuts — JSON 정수 배열 문자열(예: "[0,0,1,1]")

  • Produces: JOBS[h]["card_cuts"]process_paste(card_cuts=…)

  • Step 1: 폼 필드 추가

auto_build 시그니처의 cards_fixed: str = Form(""), 다음에:

    card_cuts: str = Form(""),
  • Step 2: 파싱 후 job에 싣기

JOBS[h] = {...} 직전에 파싱을 넣는다:

    # 카드별 소속 컷 — 값이 깨져도 빌드를 막지 않는다(없으면 기존 전체 균등 배치)
    cut_map: list[int] = []
    try:
        parsed = json.loads(card_cuts) if card_cuts.strip() else []
        if isinstance(parsed, list):
            cut_map = [int(v) for v in parsed if isinstance(v, int)]
    except (json.JSONDecodeError, ValueError, TypeError):
        cut_map = []

JOBS[h] 딕셔너리의 "cards_fixed": _truthy(cards_fixed), 다음에:

        "card_cuts": cut_map,
  • Step 3: process_paste 호출에 전달

server/app.py:225 cards_fixed=job.get("cards_fixed", False), 다음 줄에:

                    card_cuts=job.get("card_cuts") or None,
  • Step 4: 구문·임포트 검증
python -c "import ast; ast.parse(open('server/app.py', encoding='utf-8').read())"
python -c "from server import app; print('import OK')"
  • Step 5: 파싱 방어 확인
import sys, json; sys.stdout.reconfigure(encoding='utf-8')
def parse(card_cuts):
    try:
        p = json.loads(card_cuts) if card_cuts.strip() else []
        return [int(v) for v in p if isinstance(v, int)] if isinstance(p, list) else []
    except (json.JSONDecodeError, ValueError, TypeError):
        return []
assert parse("[0,0,1]") == [0, 0, 1]
assert parse("") == [] and parse("깨짐") == [] and parse('{"a":1}') == []
assert parse('[0,"x",1,null]') == [0, 1]
print("Task8 OK")

Task 9: 검토 화면을 컷별로 렌더

Files:

  • Modify: server/static/auto.jstoggle()(91-100행), 하이라이트 패널(438-472행), 빌드 전송(544-563행)

Interfaces:

  • Consumes: hl.cuts = [{"i","sec","bottom","quota","picks"}], hl.need

  • Produces: 폼 필드 card_cuts (JSON 배열), sel[hlId]컷 순서대로 정렬된 idx 배열

  • Step 1: 컷별 선택 상태 헬퍼 추가

toggle() 위에 추가:

/* ── 컷별 선택 ── 카드는 '어느 컷 소속'인지가 배치를 정한다.
   selCut[hlId][idx] = 컷 인덱스. sel[hlId] 는 항상 컷 순서로 정렬해 둔다
   (업로드 순서 = 배치 순서라서). */
const selCut={};
function cutOf(hlId,idx){ return (selCut[hlId]||{})[idx]; }
function sortSel(hlId){
  const m=selCut[hlId]||{};
  sel[hlId].sort((a,b)=>(m[a]??999)-(m[b]??999));
}
function cutFull(hl,ci){
  const m=selCut[hl.id]||{};
  const q=(hl.cuts&&hl.cuts[ci])?hl.cuts[ci].quota:hl.need;
  return sel[hl.id].filter(i=>m[i]===ci).length>=q;
}
  • Step 2: toggle()을 컷 단위 상한으로 교체

91-100행을 아래로 바꾼다:

function toggle(hlId,idx,ci){
  const hl=hlById(hlId), list=sel[hlId];
  selCut[hlId]=selCut[hlId]||{};
  const at=list.indexOf(idx);
  if(at>=0){ list.splice(at,1); delete selCut[hlId][idx]; }
  else{
    if(hl.cuts&&ci!==undefined){
      if(cutFull(hl,ci)) return;               // 그 컷 장수 초과 방지
      selCut[hlId][idx]=ci;
    }else{
      if(list.length>=hl.need) return;         // 폴백(컷 정보 없음)
    }
    list.push(idx);
    sortSel(hlId);
  }
  refreshSel(hlId);
}

cardEl()toggle(hlId,idx)를 호출하는 곳과 cardSection()이 카드를 만드는 곳에 컷 인덱스를 넘겨야 한다. cardSection(label,list,hlId,firstBatch)에 5번째 인자 ci를 추가하고, 내부 cardEl(c,hlId) 호출을 cardEl(c,hlId,ci)로 바꾼다. cardEl의 클릭 핸들러에서 toggle(hlId,c.idx)toggle(hlId,c.idx,ci).

선택 해제 칩(chip.querySelector(".x"))의 toggle(hlId,idx)는 그대로 둔다 (해제는 컷을 몰라도 된다).

  • Step 3: 패널 렌더를 컷별로

438행 sel[hl.id]=... 부터 472행까지를 아래로 교체한다:

    // 자동 선택: 컷별 추천을 컷 순서대로. hl.cuts 가 없으면 기존 방식으로 폴백.
    sel[hl.id]=[]; selCut[hl.id]={};
    if(hl.cuts){
      for(const cu of hl.cuts)
        for(const p of (cu.picks||[]))
          if(byIdx[p.idx]!==undefined&&!sel[hl.id].includes(p.idx)){
            sel[hl.id].push(p.idx); selCut[hl.id][p.idx]=cu.i;
          }
    }else{
      sel[hl.id]=(hl.matched||[]).slice(0,hl.need).filter(i=>byIdx[i]!==undefined);
    }
    const cuts=hl.paste.cuts;
    let html="<h3>ID "+hl.id+" · "+fmtT(hl.start)+"~"+fmtT(hl.end)+
      " <span style='color:var(--muted2);font-weight:400;'>"+esc(hl.reason||"")+"</span></h3>"+
      '<div class="hlmeta">컷 '+cuts.length+"개 · 총 "+hl.total+"초 · 카드 "+hl.need+"장 필요 · "+
      '<span id="hlcnt-'+hl.id+'" class="tnum"></span></div>';
    let opts=[];
    if(hl.editable_title){        // 구간 통짜: 제목 직접 입력(비우면 제목 없이)
      html+='<div class="hlsec">제목: '+
        '<input class="hlinput" id="hltop-'+hl.id+'" placeholder="윗줄 · 주황 (선택)"> '+
        '<input class="hlinput" id="hlmain-'+hl.id+'" placeholder="아랫줄 · 흰색 (선택)"></div>';
    }else{
      const t0={top:hl.paste.title_top,main:hl.paste.title_main,kind:"최종 선택"};
      opts=[t0].concat((hl.titles||[]).filter(t=>t.top!==t0.top||t.main!==t0.main));
      html+='<div class="hlsec">제목: <select id="hlt-'+hl.id+'">'+
        opts.map((t,i)=>'<option value="'+i+'">'+esc(t.top)+" / "+esc(t.main)+
          (t.kind?"  — "+esc(t.kind):"")+"</option>").join("")+"</select></div>";
    }
    html+='<div class="selbar" id="hlsel-'+hl.id+'"></div>';
    box.innerHTML=html;
    box.dataset.titles=JSON.stringify(opts);
    // 영상 편집안(컷 목록·JSON) — 선택 요약 바 위에, 기본 접힘
    box.insertBefore(cutsSection(hl),$("#hlsel-"+hl.id));
    const WHY={ts:"⭐",ai:"🤖",like:""};
    if(hl.cuts){
      // 컷별 추천 — 추천분 먼저, 그 컷 시간대 언급 댓글을 뒤에 붙여 갈아끼울 수 있게
      for(const cu of hl.cuts){
        const rec=(cu.picks||[]).map(p=>p.idx).filter(i=>byIdx[i]!==undefined);
        const why=(cu.picks||[]).map(p=>WHY[p.why]||"").join("");
        const rest=(hl.matched||[]).filter(i=>byIdx[i]!==undefined&&!rec.includes(i));
        const label="컷 "+(cu.i+1)+" · "+cu.sec+"초 · 카드 "+cu.quota+"장"+
          (cu.bottom?"  — "+cu.bottom:"")+(why?"   "+why:"");
        box.appendChild(cardSection(label,rec.concat(rest),hl.id,
          Math.max(CARD_PAGE,rec.length),cu.i));
      }
    }else{
      // 폴백 — 추천 실패 시 기존 화면 그대로
      const m=(hl.matched||[]).filter(i=>byIdx[i]!==undefined);
      box.appendChild(cardSection(
        "⭐ 이 구간을 언급한 댓글 "+m.length+"장 (좋아요순, 자동 선택)",
        m,hl.id,Math.max(CARD_PAGE,sel[hl.id].length)));
    }
    //  좋아요 상위 후보 (전체 — 30장씩 더보기)
    const cand=(hl.candidates||[]).filter(i=>byIdx[i]!==undefined);
    if(cand.length){
      box.appendChild(cardSection(
        " 좋아요 상위 후보 "+cand.length+"장 (부족분 클릭)",
        cand,hl.id,CARD_PAGE));
    }

cardSectionciundefined인 섹션( 후보, 폴백)에서 카드를 누르면 toggle이 컷 없이 동작한다. 이 경우 sortSel이 그 카드를 맨 뒤로 보낸다(??999) — 마지막 컷 뒤에 붙는다는 뜻이고, 배치상 문제 없다.

  • Step 4: 빌드 전송에 card_cuts 추가

553행 fd.append("cards_fixed",…); 다음에:

      const cmap=selCut[hl.id]||{};

카드 캡처 루프에서 성공한 카드만 순서대로 기록해야 한다. 554-563행을 교체:

      let n=0; const sentCuts=[];
      for(const idx of sel[hl.id]){
        const w=wrapOf(hl.id,idx);
        if(!w) continue;
        boardSet(hl.id,null,"카드 캡처 중… "+(++n)+"/"+sel[hl.id].length,"active");
        try{
          const blob=await captureCard(w);
          fd.append("cards",blob,String(n).padStart(3,"0")+".png");
          sentCuts.push(cmap[idx]??((hl.paste.cuts||[]).length-1));
        }catch(e){n--;boardSet(hl.id,null,"카드 1장 캡처 실패(건너뜀)","active");}
      }
      if(hl.cuts) fd.append("card_cuts",JSON.stringify(sentCuts));

캡처 실패 시 n--를 하는 이유: 파일명 번호(001.png)와 sentCuts 인덱스가 어긋나면 카드가 엉뚱한 컷에 붙는다.

  • Step 5: 문법 검증
node --check "server/static/auto.js"

Expected: 출력 없음(성공). node가 없으면 브라우저 콘솔에서 확인한다.


Task 10: 실사용 검증 + 문서

Files:

  • Modify: ARCHITECTURE.md, SETUP.md

  • Step 1: 서버 재시작

검은 창을 닫고 캡컷_에이전트_구간합치기.bat을 다시 실행한다. 이걸 빼먹으면 아래가 전부 헛일이다.

  • Step 2: full 모드 1건 실행

자동 탭 → 유튜브 URL → 분석 시작. 확인:

  1. 검토 화면에 컷 1 · 5.0초 · 카드 2장 — <자막> 형태의 섹션이 컷 수만큼 뜨는가
  2. 각 섹션에 /🤖 배지가 붙은 추천이 자동 선택돼 있는가
  3. 한 컷의 장수를 다 채운 뒤 그 섹션에서 카드를 더 눌러도 선택이 안 되는가
  4. 헤더 카운터가 Σ quota 기준으로 맞는가
  • Step 3: 드래프트 1개 생성 후 카드 시간 확인

빌드 후 CapCut 드래프트에서:

import sys, json, os; sys.stdout.reconfigure(encoding='utf-8')
from capcut_agent.draft import DEFAULT_DRAFT_ROOT as R, timeline_jsons
d = os.path.join(R, "<드래프트폴더명>")
j = json.load(open(timeline_jsons(d)[0], encoding="utf-8"))
for tr in j["tracks"]:
    if tr.get("name") == "comment":
        for s in tr["segments"]:
            t = s["target_timerange"]
            print(round(t["start"]/1e6, 2), "~", round((t["start"]+t["duration"])/1e6, 2))

각 카드 시간이 편집안의 해당 컷 구간 안에 들어가는지 확인한다(컷 경계는 컷 길이 누적).

  • Step 4: 무음 제거 ON 재실행

같은 하이라이트를 무음 제거 체크 후 다시 만든다. 카드가 자막과 함께 앞으로 당겨졌는지, 카드가 영상 끝을 넘지 않는지 확인한다.

  • Step 5: 통짜 모드 확인

구간 통짜 모드로 실행 → 컷 섹션이 1개만 뜨고, 추천 배지가 /만 있고 🤖가 없는지 확인 (통짜는 Gemini를 안 쓴다).

  • Step 6: 폴백 확인

.gemini_key를 잠시 다른 이름으로 바꾸고 paste 모드 실행 → 컷 섹션은 뜨되 🤖 없이 만 있거나 비어 있는지, 에러 없이 생성까지 되는지 확인. 확인 후 파일명을 되돌린다.

  • Step 7: 문서 갱신

ARCHITECTURE.md:

  • 모듈 목록(§1)에 recommend.py 한 줄 추가
  • 댓글 카드 배치 설명에 "자동 탭은 컷 소속(card_cuts)으로 컷 구간 안에 배치, 나머지 탭은 전체 균등(_load_comment_cards)" 추가

SETUP.md 문제 해결표에 추가:

증상 원인 해결
댓글이 엉뚱한 장면에 뜬다 컷별 추천이 실패해 기존 전체 균등 배치로 폴백 검토 화면 상단 경고 확인. Gemini 한도 초과면 잠시 뒤 재시도
컷 섹션이 안 보이고 하나만 뜬다 추천 실패 폴백 위와 동일. 프롬프트/댓글_추천.md를 고쳤다면 {cuts} {comments} {k} 가 남아 있는지 확인

자체 점검 결과

스펙 커버리지

스펙 항목 담당 태스크
§4.1 모드 분기 Task 5 (is_whole)
§4.2 컷 있는 모드 — Gemini + 타임스탬프 우선 + 중복 제거 Task 2, 4, 5
§4.2 통짜 — 슬롯 배정 + 좋아요 메움 Task 1, 5
§4.3 quota = max(1, round(len/3)), need = Σ quota Task 2, 5
§4.4 card_cuts 전달 + placements 계산 + 무음 재매핑 Task 7, 8, 9
§5 데이터 스키마 (cuts[], picks[].why, card_cuts) Task 5, 6, 8
§6 변경 파일 6개 Task 1~9
§7 폴백 (쿼터·파싱 실패·부족·h-lab 실패) Task 4({} 반환), 6(try/except), 9(hl.cuts 없으면 기존 화면)
§8 검증 6항목 Task 10

타입 일관성

  • picks 원소는 전 구간 {"idx": int, "why": str} — Task 2/5가 만들고 Task 9가 읽는다
  • cuts[] 키는 i, sec, bottom, quota, picks — Task 5가 만들고 Task 6/9가 읽는다
  • card_cuts는 정수 배열 — Task 9(JS)가 만들고 Task 8(파싱)→Task 7(_cards_by_cut)이 읽는다
  • 카드 튜플은 (start, end, path)_load_comment_cards/_cards_by_cut/_remap_caps 공통