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

> **에이전트 작업자용:** 이 계획은 `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, floor(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) == [1, 2], quotas_for(cuts)   # floor(5/3)=1, floor(6/3)=2
assert quotas_for([{"start": 0.0, "end": 1.0, "bottom": ""}]) == [1]   # 짧아도 최소 1
assert quotas_for([{"start": 0.0, "end": 10.0, "bottom": ""}]) == [3]  # floor(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, floor(컷길이 / CARD_SEC)). 짧은 컷도 최소 1장."""
    return [max(1, int((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 == 3, need3        # floor(5/3)=1 + floor(6/3)=2
assert cuts3[0]["picks"] == [{"idx": 1, "why": "ts"}]   # quota 1 → 좋아요 높은 idx1 만
assert cuts3[1]["picks"] == []
assert cuts3[1]["bottom"] == "나" and cuts3[1]["sec"] == 6.0

# 댓글이 아예 없어도 터지지 않는다
c4, n4 = recommend.build_highlight_cuts(multi, [], key="")
assert n4 == 3 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:"➕"};
    const cand=(hl.candidates||[]).filter(i=>byIdx[i]!==undefined);
    if(hl.cuts){
      // 컷별 추천 — 컷마다 두 섹션(둘 다 ci=cu.i). 독립 ➕ 섹션을 두지 않는다(아래 설명).
      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("");
        // '나머지'는 그 컷 시간대를 언급한 댓글만 — matched 전체를 컷마다 복제하면
        // DOM 이 컷 수만큼 부풀고 같은 댓글이 여러 섹션에 중복 표시된다.
        const cs=(hl.paste.cuts||[])[cu.i]||{start:0,end:-1};
        const rest=(hl.matched||[]).filter(i=>
          byIdx[i]!==undefined&&!rec.includes(i)&&
          (byIdx[i].times||[]).some(t=>t>=cs.start&&t<=cs.end));
        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));
        // 부족분 채우기 — 컷마다 반복되므로 초기 렌더는 6장(CUT_FILL_PAGE)만
        if(cand.length)
          box.appendChild(cardSection(
            "↳ 좋아요 상위에서 채우기 "+cand.length+"장 (이 컷에 넣기)",
            cand,hl.id,CUT_FILL_PAGE,cu.i));
      }
    }else{
      // 폴백 — hl.cuts 가 없으면 예전 화면 그대로: ⭐ + 독립 ➕
      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)));
      if(cand.length){
        box.appendChild(cardSection(
          "➕ 좋아요 상위 후보 "+cand.length+"장 (부족분 클릭)",
          cand,hl.id,CARD_PAGE));
      }
    }
```

⚠ **왜 독립 ➕ 섹션을 없앴나** (초안의 판단이 틀렸다).

초안은 "`ci`가 `undefined`면 `sortSel`이 그 카드를 맨 뒤로 보내니(`??999`) 마지막 컷
**뒤에** 붙는다 — 배치상 문제 없다"고 적었다. **틀렸다.** `_cards_by_cut`은 카드를 컷
뒤에 붙이지 않고 **컷 안에서 균등 분할**한다. `card_cuts`에 마지막 컷 인덱스가 들어가면
그 컷 구간 안으로 전부 밀려 들어가, 4초짜리 마지막 컷에 5장이면 0.8초씩 번쩍인다.

게다가 이건 드문 경로가 아니다. 추천 프롬프트가 "억지로 채우지 마라, 어울리는 게 없으면
빈 배열로 둬라"라고 지시하므로 **quota 미달이 기본 동작**이고, 스펙 §7은 그 미달을
"사람이 ➕에서 채운다"로 풀게 했다 — 즉 주 워크플로다.

그래서 `hl.cuts`가 있는 경로에서는 ➕를 **컷 섹션 안으로 흡수**한다(위 코드).
`hl.cuts`가 없는 폴백 경로만 예전처럼 ⭐ + 독립 ➕를 쓴다 — 거기선 애초에 컷 소속 개념이
없고 `card_cuts`도 안 보낸다.

- [ ] **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;
        const ci=cmap[idx];
        // 컷 소속을 모르는 카드는 건너뛴다 — 엉뚱한 컷에 몰아넣지 않는다
        if(hl.cuts&&ci===undefined) continue;
        boardSet(hl.id,null,"카드 캡처 중… "+(++n)+"/"+sel[hl.id].length,"active");
        try{
          const blob=await captureCard(w);
          // ⚠ append 와 push 는 반드시 쌍으로
          fd.append("cards",blob,String(n).padStart(3,"0")+".png");
          sentCuts.push(ci);
        }catch(e){n--;boardSet(hl.id,null,"카드 1장 캡처 실패(건너뜀)","active");}
      }
      if(hl.cuts) fd.append("card_cuts",JSON.stringify(sentCuts));
```

캡처 실패 시 `n--`를 하는 이유: 파일명 번호(`001.png`)와 `sentCuts` 인덱스가 어긋나면
카드가 엉뚱한 컷에 붙는다. 같은 이유로 `fd.append("cards", …)`와 `sentCuts.push(…)`는
**반드시 쌍으로** 돈다 — 하나만 실행되는 경로를 만들면 그 뒤 카드가 전부 한 칸씩 밀린다.
컷 소속을 모르는 카드(`ci === undefined`)는 마지막 컷으로 폴백하지 않고 **둘 다 건너뛴다**.

- [ ] **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, floor(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` 공통
