컷별 댓글 추천 작업을 태스크 단위로 되돌릴 수 있게 버전관리를 시작한다. .gitignore 로 영상·캐시(.downloads 2.7G, .comments 72M, .media 28M)와 비밀키(.gemini_key)를 제외했다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
50 KiB
컷별 댓글 추천 · 컷 위치 배치 구현 계획
에이전트 작업자용: 이 계획은
superpowers:subagent-driven-development(권장) 또는superpowers:executing-plans로 태스크 단위로 실행한다. 단계는 체크박스(- [ ])로 추적한다.
목표: 자동 탭 검토 화면의 댓글을 컷별로 추천하고, 선택한 카드를 그 컷 구간 위에 깐다.
접근: 컷이 있는 모드(full/paste)는 컷 자막으로 Gemini 추천 + 타임스탬프 우선.
통짜 모드(whole/wpaste)는 Gemini 없이 시각 슬롯으로 배정. 카드 시간은 서버가 확정하지 않고
"몇 번 컷 소속"만 넘겨 파이프라인이 placements로 계산한다(무음 제거 재매핑 때문).
기술 스택: Python 3.13 / FastAPI / 표준 라이브러리 urllib(Gemini 호출) / 바닐라 JS
스펙: docs/superpowers/specs/2026-08-04-컷별-댓글-추천-design.md
Global Constraints
- 코드를 고쳤으면
캡컷_에이전트_구간합치기.bat을 반드시 재시작한다. uvicorn hot-reload가 없어 검은 창을 닫고 다시 실행해야 반영된다. "안 돼요"의 가장 흔한 원인. - 이 프로젝트는 git 저장소가 아니다. 커밋 단계가 없다. 각 태스크는 인라인 assert 통과 + 구문/임포트 검증으로 끝낸다.
- 테스트 프레임워크가 없다. pytest를 도입하지 않는다. 검증은
python - <<'PY' … PY인라인 assert 스크립트로 한다(기존 계획 문서와 같은 방식). - Windows 콘솔은 cp949라 한글 print가 깨진다. 검증 스크립트 첫 줄에
import sys; sys.stdout.reconfigure(encoding='utf-8')를 넣는다. - 자막 타이밍 원칙을 건드리지 않는다. Whisper 단어 타임스탬프 = 시간, Gemini = 글자만. 이 계획은 댓글 카드만 다룬다.
- 카드 1장 기준 길이는 3.0초(
CARD_SEC). 기존_load_comment_cards(min_sec=3.0)와 같은 값. - 새 모듈은
capcut_agent/아래, 기존 파일들과 같은 스타일(한국어 docstring,from __future__ import annotations, 표준 라이브러리만).
파일 구조
| 파일 | 책임 |
|---|---|
capcut_agent/comments.py (수정) |
댓글 수집·타임스탬프 파싱·매칭. match_slots() 추가 — 시각 슬롯 배정 |
capcut_agent/recommend.py (신규) |
컷별 추천 전담. 순수 배정 로직 + Gemini 호출 + 하이라이트 조립 |
capcut_agent/prompts.py (수정) |
추천 프롬프트 파일 로더 추가 |
capcut_agent/pipeline.py (수정) |
카드 경로 목록 분리, 컷 소속 기반 카드 시간 계산, 무음 재매핑 |
server/app.py (수정) |
/auto/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
(또는 임의 임시 경로)에 저장:
import sys; sys.stdout.reconfigure(encoding='utf-8')
from capcut_agent.comments import match_slots
cs = [
{"idx": 0, "likeCount": 10, "times": [100.0]},
{"idx": 1, "likeCount": 99, "times": [101.0]},
{"idx": 2, "likeCount": 50, "times": [107.0]},
{"idx": 3, "likeCount": 80, "times": []}, # 분:초 없음
{"idx": 4, "likeCount": 70, "times": [1.0, 2.0, 3.0, 4.0]}, # 목차 댓글(MAX_TIMES 초과)
]
# start=100, total=9, n=3 → 슬롯 [100,103) [103,106) [106,109)
assert match_slots(cs, 100.0, 9.0, 3) == [1, None, 2], match_slots(cs, 100.0, 9.0, 3)
# 좋아요 높은 idx1 이 슬롯0을 가져가고, 제외하면 idx0 이 온다
assert match_slots(cs, 100.0, 9.0, 3, exclude={1}) == [0, None, 2]
# 목차 댓글(times 4개)은 어느 슬롯에도 안 걸린다
assert match_slots(cs, 0.0, 9.0, 3) == [None, None, None]
# 한 댓글이 두 슬롯에 중복 배정되지 않는다 (슬롯 경계는 양끝 포함이라 idx1 이 둘 다 걸린다)
assert match_slots(cs, 100.0, 2.0, 2) == [1, None], match_slots(cs, 100.0, 2.0, 2)
assert match_slots(cs, 100.0, 0.0, 3) == [] # total 0 → 빈 리스트
assert match_slots(cs, 100.0, 9.0, 0) == [] # n 0 → 빈 리스트
print("Task1 OK")
- Step 2: 실패 확인
Run: cd "D:/개인폴더/00.유튭/capcut2" && python <임시경로>/t1.py
Expected: FAIL — ImportError: cannot import name 'match_slots'
- Step 3: 구현
capcut_agent/comments.py 끝에 추가:
def match_slots(comments: List[Dict], start: float, total: float, n: int,
exclude=None) -> List[Optional[int]]:
"""구간을 n개 슬롯으로 잘라 슬롯마다 댓글 1개씩 배정 — 통짜 모드용.
슬롯 k = 원본 [start + k·total/n, start + (k+1)·total/n).
통짜 모드는 구간을 그대로 이어붙여 **타임라인 시각 = 원본 시각**이라 이 계산이 성립한다
(컷 있는 모드는 컷 순서를 섞어 재배치하므로 쓰면 안 된다 — 내용 기반 추천을 쓸 것).
각 슬롯은 그 시간대를 언급한 댓글 중 좋아요 최다 1개. 없으면 None.
한 댓글이 두 슬롯에 겹치지 않는다. exclude(idx 집합)는 처음부터 제외.
Returns: 길이 n 리스트 (n<=0 이거나 total<=0 이면 빈 리스트)
"""
if n <= 0 or total <= 0:
return []
used = set(exclude or ())
step = total / n
out: List[Optional[int]] = []
for k in range(n):
s = start + k * step
pick = None
for idx in match_ranges(comments, [(s, s + step)]):
if idx not in used:
pick = idx
used.add(idx)
break
out.append(pick)
return out
typing import에 Optional을 추가한다(파일 상단 from typing import Dict, List →
from typing import Dict, List, Optional).
- Step 4: 통과 확인
Run: cd "D:/개인폴더/00.유튭/capcut2" && python <임시경로>/t1.py
Expected: Task1 OK
- Step 5: 구문·임포트 검증
cd "D:/개인폴더/00.유튭/capcut2"
python -c "import ast; ast.parse(open('capcut_agent/comments.py', encoding='utf-8').read())"
python -c "from server import app; print('import OK')"
Task 2: 컷별 배정 순수 로직 (build_cut_picks)
Gemini 결과를 받아 타임스탬프 우선·중복 제거로 컷별 최종 추천을 만든다. Gemini를 호출하지 않으므로 단독 검증이 된다.
Files:
- Create:
capcut_agent/recommend.py
Interfaces:
-
Consumes:
comments.match_ranges -
Produces:
CARD_SEC = 3.0quotas_for(cuts) -> List[int]— 컷별 카드 장수max(1, round(len/CARD_SEC))build_cut_picks(cuts, comments, ai_picks, quotas) -> List[List[dict]]— 컷별[{"idx": int, "why": "ts"|"ai"}]
-
Step 1: 실패하는 검증 스크립트 작성
import sys; sys.stdout.reconfigure(encoding='utf-8')
from capcut_agent.recommend import quotas_for, build_cut_picks
cuts = [{"start": 100.0, "end": 105.0, "bottom": "아까랑 완전 스타일이 달라"},
{"start": 200.0, "end": 206.0, "bottom": "우리에게 익숙한 평냥은"}]
assert quotas_for(cuts) == [2, 2], quotas_for(cuts)
assert quotas_for([{"start": 0.0, "end": 1.0, "bottom": ""}]) == [1] # 짧아도 최소 1
assert quotas_for([{"start": 0.0, "end": 10.0, "bottom": ""}]) == [3] # round(10/3)=3
cs = [
{"idx": 0, "likeCount": 50, "times": [101.0]}, # 컷0 언급
{"idx": 1, "likeCount": 90, "times": [104.0]}, # 컷0 언급, 좋아요 더 높음
{"idx": 2, "likeCount": 70, "times": []}, # 타임스탬프 없음
{"idx": 3, "likeCount": 60, "times": []},
{"idx": 9, "likeCount": 10, "times": []}, # ai 가 골랐지만 존재함
]
# 컷0: ts 2장이 quota 2를 다 채움 → ai 는 안 들어감
# 컷1: ts 없음 → ai 가 채움. 없는 idx(777)는 버림. 컷0에 쓴 idx1 은 중복이라 버림
ai = {0: [2, 3], 1: [1, 777, 3, 9]}
got = build_cut_picks(cuts, cs, ai, [2, 2])
assert got[0] == [{"idx": 1, "why": "ts"}, {"idx": 0, "why": "ts"}], got[0]
assert got[1] == [{"idx": 3, "why": "ai"}, {"idx": 9, "why": "ai"}], got[1]
# ai 가 비어도(폴백) 터지지 않는다 — ts 만으로 채운다
got2 = build_cut_picks(cuts, cs, {}, [2, 2])
assert got2[0] == [{"idx": 1, "why": "ts"}, {"idx": 0, "why": "ts"}]
assert got2[1] == []
print("Task2 OK")
- Step 2: 실패 확인
Run: python <임시경로>/t2.py
Expected: FAIL — ModuleNotFoundError: No module named 'capcut_agent.recommend'
- Step 3: 구현
capcut_agent/recommend.py 생성:
"""컷별 댓글 추천 — 자동 탭 검토 화면용.
왜 별도 모듈인가: 배정 규칙(타임스탬프 우선·중복 제거·모드 분기)은 댓글 수집
(`comments.py`)과도, HTTP 처리(`server/app.py`)와도 책임이 다르다. 순수 함수로 떼어놔야
Gemini 없이 단위 검증이 된다.
설계 근거는 docs/superpowers/specs/2026-08-04-컷별-댓글-추천-design.md.
"""
from __future__ import annotations
from typing import Dict, List
from .comments import match_ranges
# 카드 1장이 차지하는 기준 시간(초). pipeline._load_comment_cards(min_sec) 과 같은 값.
CARD_SEC = 3.0
def quotas_for(cuts) -> List[int]:
"""컷별 카드 장수 — max(1, round(컷길이 / CARD_SEC)). 짧은 컷도 최소 1장."""
return [max(1, round((c["end"] - c["start"]) / CARD_SEC)) for c in cuts]
def build_cut_picks(cuts, comments, ai_picks: Dict[int, List[int]],
quotas) -> List[List[dict]]:
"""컷별 추천 확정 — 타임스탬프 우선, 남는 자리만 AI, 전 컷 통틀어 중복 금지.
타임스탬프가 AI보다 먼저인 이유: 그 컷의 **원본 구간**을 콕 집어 언급한 댓글은
근거가 확실하다. 추측(AI)을 이기게 둘 이유가 없다.
중복은 앞 컷이 가져간다(뒤 컷은 다음 후보로 밀린다).
ai_picks: {컷인덱스: [댓글idx …]} — Gemini 실패 시 {} 를 넘기면 타임스탬프만으로 채운다.
Returns: 컷별 [{"idx": int, "why": "ts"|"ai"}]
"""
valid = {c["idx"] for c in comments}
used: set = set()
out: List[List[dict]] = []
for i, cut in enumerate(cuts):
quota = quotas[i] if i < len(quotas) else 0
picks: List[dict] = []
for idx in match_ranges(comments, [(cut["start"], cut["end"])]):
if len(picks) >= quota:
break
if idx not in used:
picks.append({"idx": idx, "why": "ts"})
used.add(idx)
for idx in (ai_picks.get(i) or []):
if len(picks) >= quota:
break
if idx in valid and idx not in used:
picks.append({"idx": idx, "why": "ai"})
used.add(idx)
out.append(picks)
return out
- Step 4: 통과 확인
Run: python <임시경로>/t2.py
Expected: Task2 OK
- Step 5: 구문 검증
python -c "import ast; ast.parse(open('capcut_agent/recommend.py', encoding='utf-8').read())"
Task 3: 추천 프롬프트 파일
프롬프트/댓글_추천.md를 메모장으로 고칠 수 있게 한다. 기존 load_step1()과 같은 패턴
(없으면 기본값으로 생성 후 읽기).
Files:
- Modify:
capcut_agent/prompts.py
Interfaces:
-
Produces:
prompts.load_recommend() -> str,prompts.RECOMMEND_PATH -
Step 1: 검증 스크립트 작성
import sys, os; sys.stdout.reconfigure(encoding='utf-8')
from capcut_agent import prompts
p = prompts.load_recommend()
assert "{cuts}" in p and "{comments}" in p and "{k}" in p, "치환자 누락"
assert os.path.isfile(prompts.RECOMMEND_PATH), "파일이 자동 생성되지 않음"
print("Task3 OK")
- Step 2: 실패 확인
Run: python <임시경로>/t3.py
Expected: FAIL — AttributeError: module 'capcut_agent.prompts' has no attribute 'load_recommend'
- Step 3: 구현
capcut_agent/prompts.py의 경로 상수 옆(STEP3_PATH 다음 줄)에 추가:
RECOMMEND_PATH = os.path.join(PROMPT_DIR, "댓글_추천.md")
DEFAULT_CONFIG 위에 기본 프롬프트 추가:
# 컷별 댓글 추천(자동 탭). {cuts} {comments} {k} 를 채워 쓴다.
DEFAULT_RECOMMEND = """너는 숏폼 편집자다. 컷마다 화면 아래에 띄울 유튜브 댓글을 고른다.
규칙:
- 컷 자막의 **내용과 의미가 통하는** 댓글만 고른다. 억지로 채우지 마라.
- 어울리는 게 없으면 그 컷은 picks 를 빈 배열로 둔다. 빈 채로 두는 게 엉뚱한 것보다 낫다.
- 한 댓글은 한 컷에만 쓴다. 여러 컷에 어울리면 가장 잘 맞는 컷 하나에만 넣어라.
- 아래 목록에 있는 댓글 번호만 쓴다. 없는 번호를 지어내지 마라.
- 컷마다 최대 {k}개까지.
컷 목록:
{cuts}
댓글 후보 (번호. 👍좋아요 / 본문):
{comments}
각 컷마다 {{"cut": 컷번호, "picks": [댓글번호…]}} 를 JSON 배열로만 출력."""
load_step3() 아래에 로더 추가:
def load_recommend() -> str:
"""컷별 댓글 추천 프롬프트. 없으면 기본값으로 파일을 만들고 읽는다(메모장 수정 가능)."""
if not os.path.isfile(RECOMMEND_PATH):
os.makedirs(PROMPT_DIR, exist_ok=True)
with open(RECOMMEND_PATH, "w", encoding="utf-8") as f:
f.write(DEFAULT_RECOMMEND)
with open(RECOMMEND_PATH, encoding="utf-8") as f:
return f.read()
reset()은 건드리지 않는다 — Step 1/설정만 되돌리는 함수이고, 추천 프롬프트를 지우면
사용자가 고친 내용이 날아간다.
- Step 4: 통과 확인
Run: python <임시경로>/t3.py
Expected: Task3 OK — 프롬프트/댓글_추천.md가 생성됨
- Step 5: 구문 검증
python -c "import ast; ast.parse(open('capcut_agent/prompts.py', encoding='utf-8').read())"
Task 4: Gemini 호출 (ai_pick_cuts)
텍스트 전용 호출. plan.py._call()은 parts[0]에 영상 fileData가 항상 들어가 재사용할 수
없다. correct.py의 텍스트 호출 패턴(responseSchema + 실패 시 안전 반환)을 따른다.
Files:
- Modify:
capcut_agent/recommend.py
Interfaces:
-
Consumes:
correct._gemini_key,prompts.load_recommend,comments.MAX_TIMES -
Produces:
ai_pick_cuts(cuts, comments, quotas, *, model=None, key=None, timeout=90.0) -> Dict[int, List[int]]— 실패하면 예외 없이{} -
Step 1: 검증 스크립트 작성 (네트워크 없이)
import sys; sys.stdout.reconfigure(encoding='utf-8')
from capcut_agent import recommend
cuts = [{"start": 0.0, "end": 5.0, "bottom": "가"}]
cs = [{"idx": 0, "likeCount": 5, "times": [], "text": "ㅋㅋㅋ", "authorName": "a"}]
# 키가 없으면 조용히 {} — 예외를 던지면 안 된다
assert recommend.ai_pick_cuts(cuts, cs, [1], key="") == {}
# 응답 파싱: 배열 → dict, 잘못된 항목은 버린다
raw = '[{"cut":0,"picks":[3,1]},{"cut":"x","picks":[2]},{"cut":1},{"nope":1}]'
assert recommend._parse_ai(raw) == {0: [3, 1]}, recommend._parse_ai(raw)
assert recommend._parse_ai("깨진 텍스트") == {}
assert recommend._parse_ai('{"cut":0}') == {} # 배열이 아니면 버림
# 후보 목록: 목차 댓글 제외 + 좋아요순 + 본문 절단
many = [{"idx": i, "likeCount": i, "times": [], "text": "가" * 500} for i in range(5)]
many.append({"idx": 99, "likeCount": 999, "times": [1.0, 2.0, 3.0, 4.0], "text": "목차"})
lines = recommend._candidate_lines(many, 3)
assert len(lines.splitlines()) == 3, lines
assert lines.splitlines()[0].startswith("4."), lines # 좋아요 최다부터
assert "99." not in lines # 목차 댓글 제외
assert len(lines.splitlines()[0]) < 260 # 본문 200자 절단
print("Task4 OK")
- Step 2: 실패 확인
Run: python <임시경로>/t4.py
Expected: FAIL — AttributeError: module 'capcut_agent.recommend' has no attribute 'ai_pick_cuts'
- Step 3: 구현
capcut_agent/recommend.py 상단 import에 추가:
import json
import urllib.error
import urllib.request
from . import prompts
from .comments import MAX_TIMES, match_ranges
from .correct import _gemini_key
(기존 from .comments import match_ranges 줄은 위 줄로 대체한다.)
상수·함수 추가:
# Gemini에 넘길 댓글 후보 수. 더 늘려도 채택률이 안 오르고 토큰만 먹는다.
AI_CANDIDATES = 150
TEXT_CAP = 200 # 댓글 본문 절단 길이
AI_MODEL = "gemini-2.5-flash" # correct.py 와 같은 무료 티어 모델
_ENDPOINT = ("https://generativelanguage.googleapis.com/v1beta/models/"
"{model}:generateContent?key={key}")
_SCHEMA = {"type": "array", "items": {"type": "object", "properties": {
"cut": {"type": "integer"},
"picks": {"type": "array", "items": {"type": "integer"}}},
"required": ["cut", "picks"]}}
def _candidate_lines(comments, limit: int = AI_CANDIDATES) -> str:
"""Gemini에 줄 댓글 후보 — 목차 댓글 제외, 좋아요순 상위 limit, 본문 절단."""
usable = [c for c in comments if len(c.get("times") or []) <= MAX_TIMES]
usable.sort(key=lambda c: -c.get("likeCount", 0))
out = []
for c in usable[:limit]:
text = " ".join(str(c.get("text") or "").split())[:TEXT_CAP]
out.append(f"{c['idx']}. 👍{c.get('likeCount', 0)} / {text}")
return "\n".join(out)
def _cut_lines(cuts) -> str:
"""컷 목록 — 번호·길이·자막. 자막이 추천의 유일한 근거다."""
out = []
for i, c in enumerate(cuts):
bottom = " ".join(str(c.get("bottom") or "").split()) or "(자막 없음)"
effect = " ".join(str(c.get("effect") or "").split())
line = f"{i}. ({c['end'] - c['start']:.1f}초) {bottom}"
if effect:
line += f" [효과자막: {effect}]"
out.append(line)
return "\n".join(out)
def _parse_ai(raw: str) -> Dict[int, List[int]]:
"""Gemini 응답 → {컷인덱스: [댓글idx …]}. 형식이 어긋난 항목은 조용히 버린다."""
try:
data = json.loads(raw)
except (json.JSONDecodeError, TypeError):
return {}
if not isinstance(data, list):
return {}
out: Dict[int, List[int]] = {}
for row in data:
if not isinstance(row, dict):
continue
cut, picks = row.get("cut"), row.get("picks")
if not isinstance(cut, int) or isinstance(cut, bool):
continue
if not isinstance(picks, list):
continue
idxs = [p for p in picks if isinstance(p, int) and not isinstance(p, bool)]
if idxs:
out[cut] = idxs
return out
def ai_pick_cuts(cuts, comments, quotas, *, model: str = AI_MODEL,
key=None, timeout: float = 90.0) -> Dict[int, List[int]]:
"""컷 자막으로 컷별 추천을 받는다. **실패하면 예외 없이 {}** — 호출부가 폴백한다.
plan.py._call() 은 parts[0] 에 영상 fileData 가 항상 들어가 재사용할 수 없다.
correct.py 의 텍스트 전용 호출 패턴(responseSchema + 실패 시 안전 반환)을 따른다.
"""
key = key if key is not None else _gemini_key()
if not key or not cuts or not comments:
return {}
k = max(quotas) + 2 if quotas else 3 # 갈아끼울 여유분
try:
prompt = prompts.load_recommend().format(
cuts=_cut_lines(cuts), comments=_candidate_lines(comments), k=k)
except (KeyError, IndexError, OSError): # 사용자가 프롬프트를 깨뜨린 경우
return {}
body = {
"contents": [{"parts": [{"text": prompt}]}],
"generationConfig": {"temperature": 0.3,
"responseMimeType": "application/json",
"responseSchema": _SCHEMA},
}
req = urllib.request.Request(
_ENDPOINT.format(model=model, key=key),
data=json.dumps(body).encode("utf-8"),
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
data = json.loads(resp.read().decode("utf-8"))
raw = data["candidates"][0]["content"]["parts"][0]["text"]
except (urllib.error.URLError, KeyError, IndexError, TimeoutError,
json.JSONDecodeError, OSError):
return {} # 429 포함 — 폴백은 호출부 몫
return _parse_ai(raw)
- Step 4: 통과 확인
Run: python <임시경로>/t4.py
Expected: Task4 OK
- Step 5: 구문·임포트 검증
python -c "import ast; ast.parse(open('capcut_agent/recommend.py', encoding='utf-8').read())"
python -c "from capcut_agent import recommend; print('import OK')"
Task 5: 하이라이트 조립 (build_highlight_cuts)
모드를 갈라 컷별 추천을 완성한다. 컷이 1개이고 자막이 비었으면 통짜 → 시각 슬롯, 아니면 → Gemini + 타임스탬프.
Files:
- Modify:
capcut_agent/recommend.py
Interfaces:
-
Consumes:
quotas_for,build_cut_picks,ai_pick_cuts,comments.match_slots,comments.top_liked -
Produces:
build_highlight_cuts(hl, comments) -> (List[dict], int)—([{"i","sec","bottom","quota","picks"}], need).picks원소는{"idx","why"},why∈ts|ai|like -
Step 1: 검증 스크립트 작성
import sys; sys.stdout.reconfigure(encoding='utf-8')
from capcut_agent import recommend
cs = [
{"idx": 0, "likeCount": 50, "times": [101.0]},
{"idx": 1, "likeCount": 90, "times": [104.0]},
{"idx": 2, "likeCount": 70, "times": []},
{"idx": 3, "likeCount": 60, "times": []},
]
# ── 통짜: 컷 1개 + 자막 없음 → Gemini 호출 없이 슬롯 배정 ──
whole = {"paste": {"cuts": [{"start": 100.0, "end": 106.0, "bottom": "", "effect": ""}]}}
cuts, need = recommend.build_highlight_cuts(whole, cs)
assert need == 2 and len(cuts) == 1, (need, cuts)
assert cuts[0]["quota"] == 2
# 슬롯 [100,103)=idx0, [103,106)=idx1
assert cuts[0]["picks"] == [{"idx": 0, "why": "ts"}, {"idx": 1, "why": "ts"}], cuts[0]["picks"]
# 슬롯에 아무도 없으면 좋아요 상위(타임스탬프 없는 댓글)로 메운다
whole2 = {"paste": {"cuts": [{"start": 0.0, "end": 6.0, "bottom": "", "effect": ""}]}}
c2, _ = recommend.build_highlight_cuts(whole2, cs)
assert c2[0]["picks"] == [{"idx": 2, "why": "like"}, {"idx": 3, "why": "like"}], c2[0]["picks"]
# ── 컷 있는 모드: 키 없이 돌리면 ai={} 로 폴백, ts 만 남는다 ──
multi = {"paste": {"cuts": [
{"start": 100.0, "end": 105.0, "bottom": "가", "effect": ""},
{"start": 200.0, "end": 206.0, "bottom": "나", "effect": ""}]}}
cuts3, need3 = recommend.build_highlight_cuts(multi, cs, key="")
assert need3 == 4, need3
assert cuts3[0]["picks"] == [{"idx": 1, "why": "ts"}, {"idx": 0, "why": "ts"}]
assert cuts3[1]["picks"] == []
assert cuts3[1]["bottom"] == "나" and cuts3[1]["sec"] == 6.0
# 댓글이 아예 없어도 터지지 않는다
c4, n4 = recommend.build_highlight_cuts(multi, [], key="")
assert n4 == 4 and all(x["picks"] == [] for x in c4)
print("Task5 OK")
- Step 2: 실패 확인
Run: python <임시경로>/t5.py
Expected: FAIL — AttributeError: ... has no attribute 'build_highlight_cuts'
- Step 3: 구현
capcut_agent/recommend.py import에 match_slots, top_liked 추가:
from .comments import MAX_TIMES, match_ranges, match_slots, top_liked
파일 끝에 추가:
def is_whole(cuts) -> bool:
"""통짜 하이라이트인가 — 컷 1개 + 자막 없음(app._whole_hl 이 만드는 모양).
통짜는 자막이 없어 내용 추천의 근거가 없다. 대신 타임라인 시각 = 원본 시각이라
시각으로 정확히 맞출 수 있다(스펙 §4.1).
"""
return len(cuts) == 1 and not (cuts[0].get("bottom") or "").strip()
def _whole_picks(cut, comments, n: int) -> List[dict]:
"""통짜 — 슬롯마다 그 시간대 언급 댓글, 빈 슬롯은 좋아요 상위로 메움."""
slots = match_slots(comments, cut["start"], cut["end"] - cut["start"], n)
used = {i for i in slots if i is not None}
no_ts = [c for c in comments if not c.get("times")]
fill = iter(top_liked(no_ts, used, n))
out: List[dict] = []
for s in slots:
if s is not None:
out.append({"idx": s, "why": "ts"})
continue
nxt = next(fill, None)
if nxt is not None:
out.append({"idx": nxt, "why": "like"})
return out
def build_highlight_cuts(hl, comments, *, key=None):
"""하이라이트 하나 → (cuts[], need). 모드는 컷 모양으로 판별한다.
Returns: ([{"i","sec","bottom","quota","picks"}], need)
need = Σ quota — 기존 int(total//3) 을 대체한다(컷 경계에 맞추는 쪽이 맞다).
Gemini 실패는 여기서 흡수된다(ai_pick_cuts 가 {} 를 준다) — 예외를 올리지 않는다.
"""
cuts = (hl.get("paste") or {}).get("cuts") or []
if not cuts:
return [], 0
quotas = quotas_for(cuts)
if is_whole(cuts):
picks = [_whole_picks(cuts[0], comments, quotas[0])]
else:
ai = ai_pick_cuts(cuts, comments, quotas, key=key)
picks = build_cut_picks(cuts, comments, ai, quotas)
out = [{"i": i, "sec": round(c["end"] - c["start"], 1),
"bottom": c.get("bottom") or "", "quota": quotas[i], "picks": picks[i]}
for i, c in enumerate(cuts)]
return out, sum(quotas)
- Step 4: 통과 확인
Run: python <임시경로>/t5.py
Expected: Task5 OK
- Step 5: 구문·임포트 검증
python -c "import ast; ast.parse(open('capcut_agent/recommend.py', encoding='utf-8').read())"
python -c "from capcut_agent import recommend; print('import OK')"
Task 6: /auto/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행 부근)에:
from capcut_agent import recommend
- Step 2: 매칭 블록 교체
523~533행을 아래로 바꾼다:
# 댓글 매칭 — 전체 전송, 브라우저가 '더보기'로 30장씩 나눠 그린다.
# 후보(candidates)는 분:초 언급이 아예 없는 댓글만 — 타임스탬프 댓글은
# 자기 구간의 ⭐에서 잡히므로, 다른 구간 얘기하는 댓글이 섞이지 않게.
no_ts = [c for c in comments if not c["times"]]
for h in highlights:
if "paste" not in h:
continue
matched = hlab.match_window(comments, h["start"], h["end"])
h["matched"] = matched
h["candidates"] = hlab.top_liked(no_ts, set(matched), len(no_ts))
# 컷별 추천 — 실패해도 위의 matched/candidates 로 화면이 돌아간다.
# Gemini 호출이 섞여 있어 블로킹이므로 스레드로 뺀다.
try:
cuts, need = await asyncio.to_thread(
recommend.build_highlight_cuts, h, comments)
if cuts:
h["cuts"], h["need"] = cuts, need
except Exception as exc: # noqa: BLE001 — 추천 실패가 생성을 막으면 안 된다
warnings.append(
f"ID {h.get('id')} 컷별 추천 실패 — 기존 방식으로 표시 "
f"({type(exc).__name__}: {exc})")
yield _sse({"type": "result", "highlights": highlights,
"comments": comments, "warnings": warnings})
- Step 3: 구문·임포트 검증
cd "D:/개인폴더/00.유튭/capcut2"
python -c "import ast; ast.parse(open('server/app.py', encoding='utf-8').read())"
python -c "from server import app; print('import OK')"
- Step 4: 응답 모양 확인 (네트워크 없이)
import sys; sys.stdout.reconfigure(encoding='utf-8')
from capcut_agent import recommend
hl = {"id": 1, "paste": {"cuts": [
{"start": 0.0, "end": 5.0, "bottom": "가", "effect": ""},
{"start": 10.0, "end": 16.0, "bottom": "나", "effect": ""}]}}
cuts, need = recommend.build_highlight_cuts(hl, [], key="")
assert [c["i"] for c in cuts] == [0, 1]
assert set(cuts[0]) == {"i", "sec", "bottom", "quota", "picks"}, cuts[0]
print("Task6 OK", need, cuts)
Expected: Task6 OK 4 [...]
Task 7: 카드를 컷 구간 안에 배치
⚠ 무음 제거를 켜면 timeline_dur가 줄고 자막이 _remap_caps()로 재매핑된다.
카드도 같은 시점에 같은 방식으로 재매핑해야 한다. 카드 튜플 (start, end, path)는
자막 (start, end, text)와 모양이 같아 _remap_caps()를 그대로 쓸 수 있다.
Files:
- Modify:
capcut_agent/pipeline.py—_load_comment_cards분리(38-77행),process_paste시그니처(305-318행), 자막 배치부(363-390행), 카드 로드부(440-443행)
Interfaces:
-
Produces:
_card_paths(folder) -> List[str]— 정렬된 카드 이미지 경로_cards_by_cut(paths, card_cuts, placements, dur, *, min_sec=3.0, fixed=False) -> List[Tuple[float, float, str]]process_paste(..., card_cuts: Optional[List[int]] = None)
-
Step 1: 검증 스크립트 작성
import sys; sys.stdout.reconfigure(encoding='utf-8')
from capcut_agent.pipeline import _cards_by_cut, _remap_caps
paths = ["a.png", "b.png", "c.png", "d.png"]
placements = [(0.0, 6.0), (6.0, 12.0)] # 컷0 0~6초, 컷1 6~12초
# 카드 0,1 → 컷0 / 카드 2,3 → 컷1
got = _cards_by_cut(paths, [0, 0, 1, 1], placements, 12.0)
assert got == [(0.0, 3.0, "a.png"), (3.0, 6.0, "b.png"),
(6.0, 9.0, "c.png"), (9.0, 12.0, "d.png")], got
# 컷0 이 1장뿐이어도 컷1 카드가 앞으로 밀리지 않는다 (지금 방식의 약점이 사라지는 지점)
got2 = _cards_by_cut(paths[:3], [0, 1, 1], placements, 12.0)
assert got2 == [(0.0, 6.0, "a.png"), (6.0, 9.0, "b.png"), (9.0, 12.0, "c.png")], got2
# fixed=True → 3초 고정, 컷 뒷부분은 비움
got3 = _cards_by_cut(paths[:2], [0, 1], placements, 12.0, fixed=True)
assert got3 == [(0.0, 3.0, "a.png"), (6.0, 9.0, "b.png")], got3
# 영상 실제 길이로 클램프 — dur 밖 컷은 버린다
got4 = _cards_by_cut(paths, [0, 0, 1, 1], placements, 6.0)
assert got4 == [(0.0, 3.0, "a.png"), (3.0, 6.0, "b.png")], got4
# 범위 밖 컷 인덱스는 무시
assert _cards_by_cut(paths[:1], [7], placements, 12.0) == []
assert _cards_by_cut([], [], placements, 12.0) == []
# 카드도 자막과 같은 함수로 무음 재매핑된다 (튜플 모양이 같다)
keep = [(0.0, 3.0), (6.0, 12.0)] # 3~6초가 무음 → 제거
assert _remap_caps([(0.0, 3.0, "a.png"), (6.0, 9.0, "c.png")], keep) == \
[(0.0, 3.0, "a.png"), (3.0, 6.0, "c.png")]
print("Task7 OK")
- Step 2: 실패 확인
Run: python <임시경로>/t7.py
Expected: FAIL — ImportError: cannot import name '_cards_by_cut'
- Step 3: 카드 경로 목록 분리
capcut_agent/pipeline.py의 _load_comment_cards(38-77행)를 아래로 교체한다.
정렬 규칙은 그대로 두고 경로 목록만 함수로 뺀다.
def _card_paths(folder: str):
"""댓글 카드 이미지 경로 목록. 배치 방식과 무관하게 '순서'만 정한다.
정렬 규칙:
- 모든 파일명이 숫자로 시작하면 → 숫자순(1, 2, 10 …)
- 아니면 → 저장(생성) 순서 = 다운로드한 순서
folder 가 비었거나 없으면 [].
"""
import re
folder = (folder or "").strip().strip('"')
if not folder or not os.path.isdir(folder):
return []
imgs = [os.path.join(folder, f) for f in os.listdir(folder)
if os.path.splitext(f)[1].lower() in (".png", ".jpg", ".jpeg", ".webp")]
if not imgs:
return []
def _leadnum(path):
m = re.match(r"\s*0*(\d+)", os.path.splitext(os.path.basename(path))[0])
return int(m.group(1)) if m else None
if all(_leadnum(p) is not None for p in imgs):
imgs.sort(key=_leadnum) # 파일명 숫자순
else:
imgs.sort(key=lambda p: os.path.getctime(p)) # 저장(생성) 순서
return imgs
def _load_comment_cards(folder: str, dur: float, min_sec: float = 3.0,
fixed: bool = False):
"""지정 폴더의 이미지를 영상 길이에 맞춰 하단에 균등 배치.
장수: n = min(카드 수, max(1, floor(dur/min_sec))) — 카드 하나가 min_sec 밑으로
내려가지 않는 상한. 배치 간격은 항상 dur/n → 카드가 모자라도 끝까지 빈 곳 없이
채워지고(간격이 3초 이상으로 늘어남), 넘치면 초과분은 버린다.
fixed=True 면 늘리지 않고 min_sec 고정 — 모자라면 뒤는 비운다
(편집하면서 부분삭제를 많이 하는 경우 카드가 늘어나 있으면 타이밍이 꼬여서).
⚠ 이 함수는 컷을 모른다. 자동 탭처럼 '어느 컷에 붙일지'가 정해진 경우엔
`_cards_by_cut()` 을 쓴다. 폴더 지정 경로(파일/유튜브 구간 탭)는 계속 이 함수를 쓴다.
Returns: [(start, end, path)]
"""
imgs = _card_paths(folder)
if not imgs or dur <= 0:
return []
n = min(len(imgs), max(1, int(dur // min_sec)))
if fixed: # 3초 고정 — 뒤가 비어도 늘리지 않음
return [(i * min_sec, min((i + 1) * min_sec, dur), path)
for i, path in enumerate(imgs[:n])]
interval = dur / n
return [(i * interval, (i + 1) * interval, path)
for i, path in enumerate(imgs[:n])]
def _cards_by_cut(paths, card_cuts, placements, dur: float, *,
min_sec: float = 3.0, fixed: bool = False):
"""카드를 '소속 컷 구간 안'에 배치 — 자동 탭 전용.
card_cuts[i] = i번째 카드가 속한 컷 인덱스. 컷 안에서는 균등 분할
(fixed=True 면 min_sec 고정, 컷 뒷부분은 비움).
왜 컷 단위인가: 전체 균등 배치(`_load_comment_cards`)는 컷 경계를 몰라서
3번 컷 얘기하는 댓글이 7번 컷 위에 뜬다. 컷 안에서 계산하면 한 컷이 덜 차도
**다음 컷 카드가 앞으로 밀리지 않는다.**
Returns: [(start, end, path)] 시간순
"""
if not paths or not card_cuts or not placements or dur <= 0:
return []
groups: dict = {}
for i, ci in enumerate(card_cuts[:len(paths)]):
if isinstance(ci, int) and 0 <= ci < len(placements):
groups.setdefault(ci, []).append(i)
out = []
for ci, idxs in groups.items():
p0, p1 = placements[ci]
p1 = min(p1, dur)
if p1 <= p0:
continue # 영상 실제 길이 밖 컷은 버린다
step = min_sec if fixed else (p1 - p0) / len(idxs)
for k, i in enumerate(idxs):
s = p0 + k * step
if s >= p1:
break # fixed 로 컷을 넘치면 뒤는 비운다
out.append((s, min(s + step, p1), paths[i]))
out.sort(key=lambda t: t[0])
return out
- Step 4: 통과 확인
Run: python <임시경로>/t7.py
Expected: Task7 OK
- Step 5:
process_paste에card_cuts연결
시그니처(305-318행)의 cards_fixed: bool = False, 다음 줄에 추가:
card_cuts: Optional[List[int]] = None,
typing import에 Optional이 없으면 추가한다(from typing import ... Optional).
자막 배치부 바로 아래(373행 video_clips = [(0.0, dur)] 앞)에 카드 시간 계산을 넣는다.
무음 제거 전이어야 자막과 같은 타임라인에서 시작한다:
# 댓글 카드(자동 탭): '몇 번 컷 소속'만 받아 여기서 시간을 만든다.
# 서버가 시간을 확정하면 무음 제거 때 자막만 당겨지고 카드는 혼자 어긋난다.
cut_cards = _cards_by_cut(_card_paths(comments_dir), card_cuts or [],
placements, dur, fixed=cards_fixed)
무음 제거 블록 안(eff_caps = _remap_caps(eff_caps, keep) 다음 줄)에 추가:
cut_cards = _remap_caps(cut_cards, keep)
카드 로드부(440-443행)를 교체:
# 댓글 카드: 컷 소속이 지정됐으면 그 컷 구간 안(위에서 계산), 아니면 폴더 균등 배치
cards = cut_cards or _load_comment_cards(comments_dir, timeline_dur, fixed=cards_fixed)
if cards:
yield {"type": "log",
"msg": f"댓글 카드 {len(cards)}개 하단 삽입"
+ ("(컷별 배치)" if cut_cards else "(전체 균등)")}
- Step 6: 구문·임포트 검증
python -c "import ast; ast.parse(open('capcut_agent/pipeline.py', encoding='utf-8').read())"
python -c "from server import app; print('import OK')"
Task 8: /auto/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(""), 다음에:
card_cuts: str = Form(""),
- Step 2: 파싱 후 job에 싣기
JOBS[h] = {...} 직전에 파싱을 넣는다:
# 카드별 소속 컷 — 값이 깨져도 빌드를 막지 않는다(없으면 기존 전체 균등 배치)
cut_map: list[int] = []
try:
parsed = json.loads(card_cuts) if card_cuts.strip() else []
if isinstance(parsed, list):
cut_map = [int(v) for v in parsed if isinstance(v, int)]
except (json.JSONDecodeError, ValueError, TypeError):
cut_map = []
JOBS[h] 딕셔너리의 "cards_fixed": _truthy(cards_fixed), 다음에:
"card_cuts": cut_map,
- Step 3:
process_paste호출에 전달
server/app.py:225 cards_fixed=job.get("cards_fixed", False), 다음 줄에:
card_cuts=job.get("card_cuts") or None,
- Step 4: 구문·임포트 검증
python -c "import ast; ast.parse(open('server/app.py', encoding='utf-8').read())"
python -c "from server import app; print('import OK')"
- Step 5: 파싱 방어 확인
import sys, json; sys.stdout.reconfigure(encoding='utf-8')
def parse(card_cuts):
try:
p = json.loads(card_cuts) if card_cuts.strip() else []
return [int(v) for v in p if isinstance(v, int)] if isinstance(p, list) else []
except (json.JSONDecodeError, ValueError, TypeError):
return []
assert parse("[0,0,1]") == [0, 0, 1]
assert parse("") == [] and parse("깨짐") == [] and parse('{"a":1}') == []
assert parse('[0,"x",1,null]') == [0, 1]
print("Task8 OK")
Task 9: 검토 화면을 컷별로 렌더
Files:
- Modify:
server/static/auto.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() 위에 추가:
/* ── 컷별 선택 ── 카드는 '어느 컷 소속'인지가 배치를 정한다.
selCut[hlId][idx] = 컷 인덱스. sel[hlId] 는 항상 컷 순서로 정렬해 둔다
(업로드 순서 = 배치 순서라서). */
const selCut={};
function cutOf(hlId,idx){ return (selCut[hlId]||{})[idx]; }
function sortSel(hlId){
const m=selCut[hlId]||{};
sel[hlId].sort((a,b)=>(m[a]??999)-(m[b]??999));
}
function cutFull(hl,ci){
const m=selCut[hl.id]||{};
const q=(hl.cuts&&hl.cuts[ci])?hl.cuts[ci].quota:hl.need;
return sel[hl.id].filter(i=>m[i]===ci).length>=q;
}
- Step 2:
toggle()을 컷 단위 상한으로 교체
91-100행을 아래로 바꾼다:
function toggle(hlId,idx,ci){
const hl=hlById(hlId), list=sel[hlId];
selCut[hlId]=selCut[hlId]||{};
const at=list.indexOf(idx);
if(at>=0){ list.splice(at,1); delete selCut[hlId][idx]; }
else{
if(hl.cuts&&ci!==undefined){
if(cutFull(hl,ci)) return; // 그 컷 장수 초과 방지
selCut[hlId][idx]=ci;
}else{
if(list.length>=hl.need) return; // 폴백(컷 정보 없음)
}
list.push(idx);
sortSel(hlId);
}
refreshSel(hlId);
}
cardEl()이 toggle(hlId,idx)를 호출하는 곳과 cardSection()이 카드를 만드는 곳에
컷 인덱스를 넘겨야 한다. cardSection(label,list,hlId,firstBatch)에 5번째 인자 ci를 추가하고,
내부 cardEl(c,hlId) 호출을 cardEl(c,hlId,ci)로 바꾼다. cardEl의 클릭 핸들러에서
toggle(hlId,c.idx) → toggle(hlId,c.idx,ci).
선택 해제 칩(chip.querySelector(".x"))의 toggle(hlId,idx)는 그대로 둔다
(해제는 컷을 몰라도 된다).
- Step 3: 패널 렌더를 컷별로
438행 sel[hl.id]=... 부터 472행까지를 아래로 교체한다:
// 자동 선택: 컷별 추천을 컷 순서대로. hl.cuts 가 없으면 기존 방식으로 폴백.
sel[hl.id]=[]; selCut[hl.id]={};
if(hl.cuts){
for(const cu of hl.cuts)
for(const p of (cu.picks||[]))
if(byIdx[p.idx]!==undefined&&!sel[hl.id].includes(p.idx)){
sel[hl.id].push(p.idx); selCut[hl.id][p.idx]=cu.i;
}
}else{
sel[hl.id]=(hl.matched||[]).slice(0,hl.need).filter(i=>byIdx[i]!==undefined);
}
const cuts=hl.paste.cuts;
let html="<h3>ID "+hl.id+" · "+fmtT(hl.start)+"~"+fmtT(hl.end)+
" <span style='color:var(--muted2);font-weight:400;'>"+esc(hl.reason||"")+"</span></h3>"+
'<div class="hlmeta">컷 '+cuts.length+"개 · 총 "+hl.total+"초 · 카드 "+hl.need+"장 필요 · "+
'<span id="hlcnt-'+hl.id+'" class="tnum"></span></div>';
let opts=[];
if(hl.editable_title){ // 구간 통짜: 제목 직접 입력(비우면 제목 없이)
html+='<div class="hlsec">제목: '+
'<input class="hlinput" id="hltop-'+hl.id+'" placeholder="윗줄 · 주황 (선택)"> '+
'<input class="hlinput" id="hlmain-'+hl.id+'" placeholder="아랫줄 · 흰색 (선택)"></div>';
}else{
const t0={top:hl.paste.title_top,main:hl.paste.title_main,kind:"최종 선택"};
opts=[t0].concat((hl.titles||[]).filter(t=>t.top!==t0.top||t.main!==t0.main));
html+='<div class="hlsec">제목: <select id="hlt-'+hl.id+'">'+
opts.map((t,i)=>'<option value="'+i+'">'+esc(t.top)+" / "+esc(t.main)+
(t.kind?" — "+esc(t.kind):"")+"</option>").join("")+"</select></div>";
}
html+='<div class="selbar" id="hlsel-'+hl.id+'"></div>';
box.innerHTML=html;
box.dataset.titles=JSON.stringify(opts);
// 영상 편집안(컷 목록·JSON) — 선택 요약 바 위에, 기본 접힘
box.insertBefore(cutsSection(hl),$("#hlsel-"+hl.id));
const WHY={ts:"⭐",ai:"🤖",like:"➕"};
if(hl.cuts){
// 컷별 추천 — 추천분 먼저, 그 컷 시간대 언급 댓글을 뒤에 붙여 갈아끼울 수 있게
for(const cu of hl.cuts){
const rec=(cu.picks||[]).map(p=>p.idx).filter(i=>byIdx[i]!==undefined);
const why=(cu.picks||[]).map(p=>WHY[p.why]||"").join("");
const rest=(hl.matched||[]).filter(i=>byIdx[i]!==undefined&&!rec.includes(i));
const label="컷 "+(cu.i+1)+" · "+cu.sec+"초 · 카드 "+cu.quota+"장"+
(cu.bottom?" — "+cu.bottom:"")+(why?" "+why:"");
box.appendChild(cardSection(label,rec.concat(rest),hl.id,
Math.max(CARD_PAGE,rec.length),cu.i));
}
}else{
// 폴백 — 추천 실패 시 기존 화면 그대로
const m=(hl.matched||[]).filter(i=>byIdx[i]!==undefined);
box.appendChild(cardSection(
"⭐ 이 구간을 언급한 댓글 "+m.length+"장 (좋아요순, 자동 선택)",
m,hl.id,Math.max(CARD_PAGE,sel[hl.id].length)));
}
// ➕ 좋아요 상위 후보 (전체 — 30장씩 더보기)
const cand=(hl.candidates||[]).filter(i=>byIdx[i]!==undefined);
if(cand.length){
box.appendChild(cardSection(
"➕ 좋아요 상위 후보 "+cand.length+"장 (부족분 클릭)",
cand,hl.id,CARD_PAGE));
}
cardSection의 ci가 undefined인 섹션(➕ 후보, 폴백)에서 카드를 누르면 toggle이
컷 없이 동작한다. 이 경우 sortSel이 그 카드를 맨 뒤로 보낸다(??999) — 마지막 컷 뒤에
붙는다는 뜻이고, 배치상 문제 없다.
- Step 4: 빌드 전송에
card_cuts추가
553행 fd.append("cards_fixed",…); 다음에:
const cmap=selCut[hl.id]||{};
카드 캡처 루프에서 성공한 카드만 순서대로 기록해야 한다. 554-563행을 교체:
let n=0; const sentCuts=[];
for(const idx of sel[hl.id]){
const w=wrapOf(hl.id,idx);
if(!w) continue;
boardSet(hl.id,null,"카드 캡처 중… "+(++n)+"/"+sel[hl.id].length,"active");
try{
const blob=await captureCard(w);
fd.append("cards",blob,String(n).padStart(3,"0")+".png");
sentCuts.push(cmap[idx]??((hl.paste.cuts||[]).length-1));
}catch(e){n--;boardSet(hl.id,null,"카드 1장 캡처 실패(건너뜀)","active");}
}
if(hl.cuts) fd.append("card_cuts",JSON.stringify(sentCuts));
캡처 실패 시 n--를 하는 이유: 파일명 번호(001.png)와 sentCuts 인덱스가 어긋나면
카드가 엉뚱한 컷에 붙는다.
- Step 5: 문법 검증
node --check "server/static/auto.js"
Expected: 출력 없음(성공). node가 없으면 브라우저 콘솔에서 확인한다.
Task 10: 실사용 검증 + 문서
Files:
-
Modify:
ARCHITECTURE.md,SETUP.md -
Step 1: 서버 재시작
검은 창을 닫고 캡컷_에이전트_구간합치기.bat을 다시 실행한다. 이걸 빼먹으면 아래가 전부 헛일이다.
- Step 2:
full모드 1건 실행
자동 탭 → 유튜브 URL → 분석 시작. 확인:
- 검토 화면에
컷 1 · 5.0초 · 카드 2장 — <자막>형태의 섹션이 컷 수만큼 뜨는가 - 각 섹션에 ⭐/🤖 배지가 붙은 추천이 자동 선택돼 있는가
- 한 컷의 장수를 다 채운 뒤 그 섹션에서 카드를 더 눌러도 선택이 안 되는가
- 헤더 카운터가
Σ quota기준으로 맞는가
- Step 3: 드래프트 1개 생성 후 카드 시간 확인
빌드 후 CapCut 드래프트에서:
import sys, json, os; sys.stdout.reconfigure(encoding='utf-8')
from capcut_agent.draft import DEFAULT_DRAFT_ROOT as R, timeline_jsons
d = os.path.join(R, "<드래프트폴더명>")
j = json.load(open(timeline_jsons(d)[0], encoding="utf-8"))
for tr in j["tracks"]:
if tr.get("name") == "comment":
for s in tr["segments"]:
t = s["target_timerange"]
print(round(t["start"]/1e6, 2), "~", round((t["start"]+t["duration"])/1e6, 2))
각 카드 시간이 편집안의 해당 컷 구간 안에 들어가는지 확인한다(컷 경계는 컷 길이 누적).
- Step 4: 무음 제거 ON 재실행
같은 하이라이트를 무음 제거 체크 후 다시 만든다. 카드가 자막과 함께 앞으로 당겨졌는지,
카드가 영상 끝을 넘지 않는지 확인한다.
- Step 5: 통짜 모드 확인
구간 통짜 모드로 실행 → 컷 섹션이 1개만 뜨고, 추천 배지가 ⭐/➕만 있고 🤖가 없는지 확인
(통짜는 Gemini를 안 쓴다).
- Step 6: 폴백 확인
.gemini_key를 잠시 다른 이름으로 바꾸고 paste 모드 실행 → 컷 섹션은 뜨되 🤖 없이
⭐만 있거나 비어 있는지, 에러 없이 생성까지 되는지 확인. 확인 후 파일명을 되돌린다.
- Step 7: 문서 갱신
ARCHITECTURE.md:
- 모듈 목록(§1)에
recommend.py한 줄 추가 - 댓글 카드 배치 설명에 "자동 탭은 컷 소속(
card_cuts)으로 컷 구간 안에 배치, 나머지 탭은 전체 균등(_load_comment_cards)" 추가
SETUP.md 문제 해결표에 추가:
| 증상 | 원인 | 해결 |
|---|---|---|
| 댓글이 엉뚱한 장면에 뜬다 | 컷별 추천이 실패해 기존 전체 균등 배치로 폴백 | 검토 화면 상단 경고 확인. Gemini 한도 초과면 잠시 뒤 재시도 |
| 컷 섹션이 안 보이고 ⭐ 하나만 뜬다 | 추천 실패 폴백 | 위와 동일. 프롬프트/댓글_추천.md를 고쳤다면 {cuts} {comments} {k} 가 남아 있는지 확인 |
자체 점검 결과
스펙 커버리지
| 스펙 항목 | 담당 태스크 |
|---|---|
| §4.1 모드 분기 | Task 5 (is_whole) |
| §4.2 컷 있는 모드 — Gemini + 타임스탬프 우선 + 중복 제거 | Task 2, 4, 5 |
| §4.2 통짜 — 슬롯 배정 + 좋아요 메움 | Task 1, 5 |
§4.3 quota = max(1, round(len/3)), need = Σ quota |
Task 2, 5 |
§4.4 card_cuts 전달 + placements 계산 + 무음 재매핑 |
Task 7, 8, 9 |
§5 데이터 스키마 (cuts[], picks[].why, card_cuts) |
Task 5, 6, 8 |
| §6 변경 파일 6개 | Task 1~9 |
| §7 폴백 (쿼터·파싱 실패·부족·h-lab 실패) | Task 4({} 반환), 6(try/except), 9(hl.cuts 없으면 기존 화면) |
| §8 검증 6항목 | Task 10 |
타입 일관성
picks원소는 전 구간{"idx": int, "why": str}— Task 2/5가 만들고 Task 9가 읽는다cuts[]키는i, sec, bottom, quota, picks— Task 5가 만들고 Task 6/9가 읽는다card_cuts는 정수 배열 — Task 9(JS)가 만들고 Task 8(파싱)→Task 7(_cards_by_cut)이 읽는다- 카드 튜플은
(start, end, path)—_load_comment_cards/_cards_by_cut/_remap_caps공통