capcut-agent/docs/superpowers/plans/2026-08-04-컷별-댓글-추천.md
hehihoho3@gmail.com 88768020bf docs: 계획서 Task 4 예시 except 튜플의 예외 누출 수정
리뷰에서 잡힌 구멍이 계획서 참고 코드에도 그대로 있었다. str.format 의
ValueError 와 .decode 의 UnicodeDecodeError(= ValueError 서브클래스)가
빠져 있어, 그대로 옮기면 "어떤 실패에도 예외 없음" 계약이 깨진다.

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

1283 lines
51 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 컷별 댓글 추천 · 컷 위치 배치 구현 계획
> **에이전트 작업자용:** 이 계획은 `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, ValueError, OSError): # 사용자가 프롬프트를 깨뜨린 경우
return {} # ValueError: str.format 은 중괄호가 깨지면 이걸 던진다
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"]
# URLError·TimeoutError 는 OSError 서브클래스라 따로 안 적는다.
# ValueError 는 .decode("utf-8") 의 UnicodeDecodeError 를 덮는다(OSError 가 아니다).
except (KeyError, IndexError, json.JSONDecodeError, ValueError, 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="<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));
}
```
`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` 공통