recommend.quotas_for()는 round, pipeline._cards_by_cut()의 컷당 장수 상한은 floor를 써서 둘이 어긋났다. 5초 컷처럼 round가 floor보다 큰 쪽으로 갈리는 컷은 UI가 카드 2장을 고르게 하고도 빌드 단계에서 상한(1장)에 걸려 뒤 1장이 로그 없이 버려졌다. 사용자 결정에 따라 floor로 통일(카드 한 장이 항상 3초 이상 — 기존 _load_comment_cards 규칙과 동일)하고, 무음 제거로 컷이 압축돼 여전히 카드가 버려지는 경우(이건 불가피)를 컷별 배치 로그에 표시해 더 이상 조용히 사라지지 않게 했다. 관련 설계/계획 문서의 공식·테스트 기대값도 floor 기준으로 맞췄다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1309 lines
53 KiB
Markdown
1309 lines
53 KiB
Markdown
# 컷별 댓글 추천 · 컷 위치 배치 구현 계획
|
||
|
||
> **에이전트 작업자용:** 이 계획은 `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` 공통
|