# 컷별 댓글 추천 · 컷 위치 배치 구현 계획 > **에이전트 작업자용:** 이 계획은 `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 저장소는 2026-08-04에 초기화됐다**(`bf1b387`, 브랜치 `feat/cut-comment-recommend`). 각 태스크는 인라인 assert 통과 + 구문/임포트 검증 후 **커밋**하고 끝낸다. 커밋 메시지는 한국어 한 줄 요약 + 왜 그렇게 했는지. `.gemini_key`·`.downloads/`· `.comments/`는 `.gitignore`로 제외돼 있으니 `git add -A`를 써도 안전하다. - **테스트 프레임워크가 없다.** 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/analyze`가 `cuts[]`를 실어 보냄, `/auto/build`가 `card_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` (또는 임의 임시 경로)에 저장: ```python 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` 끝에 추가: ```python 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, List` → `from typing import Dict, List, Optional`). - [ ] **Step 4: 통과 확인** Run: `cd "D:/개인폴더/00.유튭/capcut2" && python <임시경로>/t1.py` Expected: `Task1 OK` - [ ] **Step 5: 구문·임포트 검증** ```bash 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: 실패하는 검증 스크립트 작성** ```python 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` 생성: ```python """컷별 댓글 추천 — 자동 탭 검토 화면용. 왜 별도 모듈인가: 배정 규칙(타임스탬프 우선·중복 제거·모드 분기)은 댓글 수집 (`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: 구문 검증** ```bash 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: 검증 스크립트 작성** ```python 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` 다음 줄)에 추가: ```python RECOMMEND_PATH = os.path.join(PROMPT_DIR, "댓글_추천.md") ``` `DEFAULT_CONFIG` 위에 기본 프롬프트 추가: ```python # 컷별 댓글 추천(자동 탭). {cuts} {comments} {k} 를 채워 쓴다. DEFAULT_RECOMMEND = """너는 숏폼 편집자다. 컷마다 화면 아래에 띄울 유튜브 댓글을 고른다. 규칙: - 컷 자막의 **내용과 의미가 통하는** 댓글만 고른다. 억지로 채우지 마라. - 어울리는 게 없으면 그 컷은 picks 를 빈 배열로 둔다. 빈 채로 두는 게 엉뚱한 것보다 낫다. - 한 댓글은 한 컷에만 쓴다. 여러 컷에 어울리면 가장 잘 맞는 컷 하나에만 넣어라. - 아래 목록에 있는 댓글 번호만 쓴다. 없는 번호를 지어내지 마라. - 컷마다 최대 {k}개까지. 컷 목록: {cuts} 댓글 후보 (번호. 👍좋아요 / 본문): {comments} 각 컷마다 {{"cut": 컷번호, "picks": [댓글번호…]}} 를 JSON 배열로만 출력.""" ``` `load_step3()` 아래에 로더 추가: ```python 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: 구문 검증** ```bash 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: 검증 스크립트 작성 (네트워크 없이)** ```python 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에 추가: ```python 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` 줄은 위 줄로 대체한다.) 상수·함수 추가: ```python # 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: 구문·임포트 검증** ```bash 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"}`, `why` ∈ `ts`|`ai`|`like` - [ ] **Step 1: 검증 스크립트 작성** ```python 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` 추가: ```python from .comments import MAX_TIMES, match_ranges, match_slots, top_liked ``` 파일 끝에 추가: ```python 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: 구문·임포트 검증** ```bash 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/analyze`가 `cuts[]`를 실어 보냄 **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행 부근)에: ```python from capcut_agent import recommend ``` - [ ] **Step 2: 매칭 블록 교체** 523~533행을 아래로 바꾼다: ```python # 댓글 매칭 — 전체 전송, 브라우저가 '더보기'로 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: 구문·임포트 검증** ```bash 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: 응답 모양 확인 (네트워크 없이)** ```python 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: 검증 스크립트 작성** ```python 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행)를 아래로 교체한다. 정렬 규칙은 그대로 두고 경로 목록만 함수로 뺀다. ```python 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_paste`에 `card_cuts` 연결** 시그니처(305-318행)의 `cards_fixed: bool = False,` 다음 줄에 추가: ```python card_cuts: Optional[List[int]] = None, ``` `typing` import에 `Optional`이 없으면 추가한다(`from typing import ... Optional`). 자막 배치부 바로 아래(373행 `video_clips = [(0.0, dur)]` **앞**)에 카드 시간 계산을 넣는다. **무음 제거 전**이어야 자막과 같은 타임라인에서 시작한다: ```python # 댓글 카드(자동 탭): '몇 번 컷 소속'만 받아 여기서 시간을 만든다. # 서버가 시간을 확정하면 무음 제거 때 자막만 당겨지고 카드는 혼자 어긋난다. cut_cards = _cards_by_cut(_card_paths(comments_dir), card_cuts or [], placements, dur, fixed=cards_fixed) ``` 무음 제거 블록 안(`eff_caps = _remap_caps(eff_caps, keep)` 다음 줄)에 추가: ```python cut_cards = _remap_caps(cut_cards, keep) ``` 카드 로드부(440-443행)를 교체: ```python # 댓글 카드: 컷 소속이 지정됐으면 그 컷 구간 안(위에서 계산), 아니면 폴더 균등 배치 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: 구문·임포트 검증** ```bash 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/build`가 `card_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(""),` 다음에: ```python card_cuts: str = Form(""), ``` - [ ] **Step 2: 파싱 후 job에 싣기** `JOBS[h] = {...}` 직전에 파싱을 넣는다: ```python # 카드별 소속 컷 — 값이 깨져도 빌드를 막지 않는다(없으면 기존 전체 균등 배치) 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),` 다음에: ```python "card_cuts": cut_map, ``` - [ ] **Step 3: `process_paste` 호출에 전달** `server/app.py:225` `cards_fixed=job.get("cards_fixed", False),` 다음 줄에: ```python card_cuts=job.get("card_cuts") or None, ``` - [ ] **Step 4: 구문·임포트 검증** ```bash 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: 파싱 방어 확인** ```python 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.js` — `toggle()`(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()` 위에 추가: ```javascript /* ── 컷별 선택 ── 카드는 '어느 컷 소속'인지가 배치를 정한다. 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행을 아래로 바꾼다: ```javascript 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행까지를 아래로 교체한다: ```javascript // 자동 선택: 컷별 추천을 컷 순서대로. 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="

ID "+hl.id+" · "+fmtT(hl.start)+"~"+fmtT(hl.end)+ " "+esc(hl.reason||"")+"

"+ '
컷 '+cuts.length+"개 · 총 "+hl.total+"초 · 카드 "+hl.need+"장 필요 · "+ '
'; let opts=[]; if(hl.editable_title){ // 구간 통짜: 제목 직접 입력(비우면 제목 없이) html+='
제목: '+ ' '+ '
'; }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+='
제목:
"; } html+='
'; 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)); } ``` `cardSection`의 `ci`가 `undefined`인 섹션(➕ 후보, 폴백)에서 카드를 누르면 `toggle`이 컷 없이 동작한다. 이 경우 `sortSel`이 그 카드를 맨 뒤로 보낸다(`??999`) — 마지막 컷 뒤에 붙는다는 뜻이고, 배치상 문제 없다. - [ ] **Step 4: 빌드 전송에 `card_cuts` 추가** 553행 `fd.append("cards_fixed",…);` 다음에: ```javascript const cmap=selCut[hl.id]||{}; ``` 카드 캡처 루프에서 성공한 카드만 순서대로 기록해야 한다. 554-563행을 교체: ```javascript 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: 문법 검증** ```bash 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 드래프트에서: ```python 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` 공통