capcut-agent/docs/superpowers/plans/2026-08-04-1단계-파이프라인-분할.md
hehihoho3@gmail.com 6df1930d06 docs: 1단계 구현 계획 — 파이프라인 분할 + 추천 엔진 준비 + 렌더러 통합
겉보기 동작이 하나도 안 바뀌는 구조 변경이라, 분할 전 이벤트 스트림을 파일로 떠 두고
분할 후와 문자 그대로 비교하는 방식으로 회귀를 검증한다(Task 4·5).

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

764 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# 1단계: 파이프라인 분할 + 추천 엔진 준비 + 렌더러 통합 — 구현 계획
> **에이전트 작업자용:** 이 계획은 `superpowers:subagent-driven-development`(권장) 또는
> `superpowers:executing-plans`로 태스크 단위로 실행한다. 단계는 체크박스(`- [ ]`)로 추적한다.
**목표:** 두 파이프라인을 `analyze`/`draft` 두 조각으로 쪼개고, 추천 엔진이 컷별 자막·장수를
밖에서 받을 수 있게 만든다. **사용자에게 보이는 동작은 하나도 바뀌지 않는다.**
**접근:** 각 파이프라인을 "받아쓰기까지"와 "드래프트 생성"으로 나누고, 중간 상태를
`{"type":"state"}` 이벤트로 넘긴다. 기존 함수는 두 조각을 연달아 부르는 얇은 래퍼로 남겨
**SSE 이벤트 스트림이 문자 그대로 같게** 유지한다.
**기술 스택:** Python 3.13 / FastAPI / 바닐라 JS / 표준 라이브러리만
**스펙:** `docs/superpowers/specs/2026-08-04-받아쓰기후-댓글매칭-design.md` (§3, §5, §6, §10, 13단계 표의 1단계)
## Global Constraints
- **코드를 고쳤으면 `캡컷_에이전트_구간합치기.bat`을 재시작한다.** uvicorn hot-reload가 없다.
- **테스트 프레임워크가 없다. pytest를 도입하지 마라.** 검증은 인라인 assert 스크립트로 한다.
스크립트는 `C:\Users\hehih\AppData\Local\Temp\claude\D-------00----capcut2\d2b52aec-0e0e-4485-b47f-a854f6b9c528\scratchpad`
아래에 두고 **저장소에 커밋하지 마라**(이 프로젝트에는 테스트 디렉터리가 없다).
- 검증 스크립트 첫 줄에 `import sys; sys.stdout.reconfigure(encoding='utf-8')` — 콘솔이 cp949다.
- **네트워크를 쓰지 마라.** 회귀 검증은 `.downloads/`의 기존 mp4를 재사용한다.
- **이 단계에서 사용자에게 보이는 동작이 바뀌면 결함이다.** 새 기능은 2~4단계에서 쓴다.
- 카드 1장 기준 길이는 **3.0초**(`recommend.CARD_SEC`), 컷별 장수는 **`max(1, floor(길이/3))`**.
- **한국어 docstring·주석**, 표준 라이브러리만, 기존 `capcut_agent/*.py` 스타일.
- **바닐라 JS.** 빌드 도구·npm 패키지 도입 금지.
- git 브랜치 `feat/yt-range-comments`. 태스크마다 커밋. 메시지는 한국어 한 줄 요약 +
왜 그렇게 했는지 본문, 마지막 줄에 `Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>`.
---
## 파일 구조
| 파일 | 이 단계에서의 책임 |
|---|---|
| `capcut_agent/recommend.py` | 추천 배정. **컷별 자막·장수를 밖에서 받을 수 있게** 한다 |
| `capcut_agent/pipeline.py` | 파이프라인 2개를 `analyze`/`draft`로 쪼갠다. 컷별 자막 추출 헬퍼를 더한다 |
| `server/static/auto.js` | 카드 패널 렌더러를 `renderCutPanel()` 하나로 모은다 |
`server/app.py`**이 단계에서 안 건드린다** — 래퍼가 기존 시그니처를 유지하므로 호출부가
그대로다. 새 엔드포인트는 2단계부터.
---
## Task 1: `is_time_based()` — 시각 기반 판별 일반화
지금 `is_whole(cuts)`는 *컷 1개 + 자막 없음*일 때만 참이다. 구간 탭은 자막 없는 구간이
2개 이상이라 안 걸린다. **모든 컷의 자막이 비면 시각 기반**으로 일반화한다.
컷 1개 통짜는 그 특수 케이스가 되므로 **자동 탭 동작은 그대로**다.
**Files:**
- Modify: `capcut_agent/recommend.py`
**Interfaces:**
- Consumes: `comments.match_slots`, `comments.top_liked`
- Produces:
- `is_time_based(cuts) -> bool` (기존 `is_whole` 대체)
- `_time_based_picks(cuts, comments, quotas) -> List[List[dict]]` — 컷마다 슬롯 배정,
컷 간 중복 금지. 기존 `_whole_picks`를 여러 컷으로 확장
- [ ] **Step 1: 실패하는 검증 스크립트 작성**
```python
import sys; sys.stdout.reconfigure(encoding='utf-8')
from capcut_agent.recommend import is_time_based, _time_based_picks
# 자막이 전부 비면 참 — 컷 개수와 무관
assert is_time_based([{"start":0.0,"end":6.0,"bottom":""}]) is True
assert is_time_based([{"start":0.0,"end":6.0,"bottom":""},
{"start":100.0,"end":106.0,"bottom":" "}]) is True
# 하나라도 차 있으면 거짓
assert is_time_based([{"start":0.0,"end":6.0,"bottom":""},
{"start":100.0,"end":106.0,"bottom":"가"}]) is False
assert is_time_based([]) is False # 컷이 없으면 판별 불가 → 거짓
cs = [
{"idx":0,"likeCount":50,"times":[1.0]}, # 컷0 슬롯0
{"idx":1,"likeCount":90,"times":[4.0]}, # 컷0 슬롯1
{"idx":2,"likeCount":70,"times":[101.0]}, # 컷1 슬롯0
{"idx":3,"likeCount":60,"times":[]}, # 분:초 없음
{"idx":4,"likeCount":10,"times":[]},
]
cuts = [{"start":0.0,"end":6.0,"bottom":""}, {"start":100.0,"end":106.0,"bottom":""}]
got = _time_based_picks(cuts, cs, [2, 2])
assert got[0] == [{"idx":0,"why":"ts"}, {"idx":1,"why":"ts"}], got[0]
# 컷1: 슬롯0=idx2, 슬롯1 비어 좋아요 상위(분:초 없는 것)로 채움
assert got[1] == [{"idx":2,"why":"ts"}, {"idx":3,"why":"like"}], got[1]
# 컷 간 중복 금지: 같은 댓글이 두 컷 시간대에 걸쳐도 앞 컷이 가져간다
cs2 = [{"idx":0,"likeCount":50,"times":[1.0, 101.0]}]
cuts2 = [{"start":0.0,"end":3.0,"bottom":""}, {"start":100.0,"end":103.0,"bottom":""}]
g2 = _time_based_picks(cuts2, cs2, [1, 1])
assert g2[0] == [{"idx":0,"why":"ts"}] and g2[1] == [], g2
assert _time_based_picks([], cs, []) == []
print("Task1 OK")
```
- [ ] **Step 2: 실패 확인**
Run: `cd "D:/개인폴더/00.유튭/capcut2" && python <임시경로>/t1.py`
Expected: FAIL — `ImportError: cannot import name 'is_time_based'`
- [ ] **Step 3: 구현**
먼저 옛 이름을 쓰는 곳이 더 없는지 확인한다(있으면 전부 고쳐야 한다):
```bash
cd "D:/개인폴더/00.유튭/capcut2" && grep -rn "is_whole\|_whole_picks" --include=*.py --include=*.js .
```
그다음 `capcut_agent/recommend.py``is_whole()``_whole_picks()`를 아래로 **대체**한다
(옛 이름은 남기지 마라 — 같은 일을 하는 함수가 둘이면 다음 사람이 틀린 걸 고친다).
`build_highlight_cuts` 안의 호출부도 함께 고친다.
```python
def is_time_based(cuts) -> bool:
"""시각 기반으로 배정할 컷 묶음인가 — **모든 컷의 자막이 비었는가**.
자막이 없으면 내용 추천의 근거가 없다. 대신 이런 묶음(통짜 모드·유튜브 구간 탭)은
구간을 그대로 이어붙이므로 타임라인 시각 = 원본 시각이 성립해 시각으로 맞출 수 있다.
컷 1개짜리 통짜는 이 규칙의 특수 케이스다(스펙 §1).
"""
if not cuts:
return False
return all(not (c.get("bottom") or "").strip() for c in cuts)
def _time_based_picks(cuts, comments, quotas) -> List[List[dict]]:
"""컷마다 3초 슬롯 배정 — 슬롯 시간대 언급 댓글, 빈 슬롯은 좋아요 상위로 채움.
`used` 를 컷 사이에 공유해 한 댓글이 두 컷에 들어가지 않게 한다(앞 컷 우선).
"""
used: set = set()
no_ts = [c for c in comments if not c.get("times")]
out: List[List[dict]] = []
for i, cut in enumerate(cuts):
n = quotas[i] if i < len(quotas) else 0
slots = match_slots(comments, cut["start"], cut["end"] - cut["start"], n,
exclude=used)
used.update(s for s in slots if s is not None)
fill = iter(top_liked(no_ts, used, n))
picks: List[dict] = []
for s in slots:
if s is not None:
picks.append({"idx": s, "why": "ts"})
continue
nxt = next(fill, None)
if nxt is not None:
picks.append({"idx": nxt, "why": "like"})
used.add(nxt)
out.append(picks)
return out
```
`build_highlight_cuts()` 안의 분기를 바꾼다:
```python
if is_time_based(cuts):
picks = _time_based_picks(cuts, comments, quotas)
else:
```
- [ ] **Step 4: 통과 확인**
Run: `python <임시경로>/t1.py``Task1 OK`
- [ ] **Step 5: 자동 탭 회귀 확인 (통짜 모드 동작 불변)**
```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":[]}]
whole = {"paste": {"cuts": [{"start":100.0,"end":106.0,"bottom":"","effect":""}]}}
cuts, need, failed = recommend.build_highlight_cuts(whole, cs)
assert need == 2 and failed is False
assert cuts[0]["picks"] == [{"idx":0,"why":"ts"}, {"idx":1,"why":"ts"}], cuts[0]["picks"]
print("Task1 회귀 OK")
```
- [ ] **Step 6: 구문·임포트 검증 후 커밋**
```bash
cd "D:/개인폴더/00.유튭/capcut2"
python -c "import ast; ast.parse(open('capcut_agent/recommend.py', encoding='utf-8').read())"
python -c "from server import app; print('import OK')"
git add capcut_agent/recommend.py
git commit -m "..."
```
---
## Task 2: `build_highlight_cuts`에 `quotas` 주입
무음 제거 후 실제 길이로 계산한 장수를 밖에서 넘길 수 있게 한다. 안 넘기면 기존 동작.
**Files:**
- Modify: `capcut_agent/recommend.py`
**Interfaces:**
- Consumes: Task 1의 `is_time_based`, `_time_based_picks`
- Produces: `build_highlight_cuts(hl, comments, *, key=None, quotas=None) -> (cuts, need, ai_failed)`
- [ ] **Step 1: 실패하는 검증 스크립트 작성**
```python
import sys; sys.stdout.reconfigure(encoding='utf-8')
from capcut_agent import recommend
hl = {"paste": {"cuts": [
{"start":0.0,"end":10.0,"bottom":"가","effect":""},
{"start":100.0,"end":110.0,"bottom":"나","effect":""}]}}
cs = []
# 안 넘기면 기존대로 컷 길이(10초)로 계산 → floor(10/3)=3 씩
c1, n1, _ = recommend.build_highlight_cuts(hl, cs, key="")
assert [c["quota"] for c in c1] == [3, 3], [c["quota"] for c in c1]
assert n1 == 6
# 넘기면 그걸 쓴다 (무음 제거로 짧아진 경우)
c2, n2, _ = recommend.build_highlight_cuts(hl, cs, key="", quotas=[1, 2])
assert [c["quota"] for c in c2] == [1, 2], [c["quota"] for c in c2]
assert n2 == 3
print("Task2 OK")
```
- [ ] **Step 2: 실패 확인**
Run: `python <임시경로>/t2.py`
Expected: FAIL — `TypeError: build_highlight_cuts() got an unexpected keyword argument 'quotas'`
- [ ] **Step 3: 구현**
`build_highlight_cuts`의 시그니처와 첫 줄들을 고친다:
```python
def build_highlight_cuts(hl, comments, *, key=None, quotas=None):
"""하이라이트 하나 → (cuts[], need, ai_failed). 모드는 컷 모양으로 판별한다.
quotas: 컷별 카드 장수를 밖에서 정해 넘길 때 쓴다(무음 제거 후 실제 길이 기준).
안 넘기면 `quotas_for(cuts)` — 원본 컷 길이 기준.
Returns: ([{"i","sec","bottom","quota","picks"}], need, ai_failed)
need = Σ quota. Gemini 실패는 ai_failed=True 로 알린다(예외는 안 올린다).
"""
cuts = (hl.get("paste") or {}).get("cuts") or []
if not cuts:
return [], 0, False
quotas = list(quotas) if quotas is not None else quotas_for(cuts)
```
나머지 본문은 그대로 둔다.
- [ ] **Step 4: 통과 확인**
Run: `python <임시경로>/t2.py``Task2 OK`
- [ ] **Step 5: 구문·임포트 검증 후 커밋** (Task 1 Step 6과 같은 명령)
---
## Task 3: 컷별 자막 추출 헬퍼
받아쓰기 결과에서 "이 컷 구간에 걸친 자막"을 뽑는다. 2단계부터 세 탭이 다 쓴다.
**Files:**
- Modify: `capcut_agent/pipeline.py`
**Interfaces:**
- Produces: `captions_for_places(captions, places, *, cap=500) -> List[str]`
`places`와 같은 길이. 각 원소는 그 구간에 걸친 자막을 공백 하나로 이어붙인 것
- [ ] **Step 1: 실패하는 검증 스크립트 작성**
```python
import sys; sys.stdout.reconfigure(encoding='utf-8')
from capcut_agent.pipeline import captions_for_places
caps = [(0.0, 2.0, "안녕"), (2.0, 4.0, "반가워"), (5.0, 7.0, "잘가"), (9.0, 11.0, "또봐")]
places = [(0.0, 5.0), (5.0, 10.0)]
got = captions_for_places(caps, places)
assert got == ["안녕 반가워", "잘가 또봐"], got
# 경계에 걸치면 포함(겹치는 부분이 있으면 그 컷의 말이다)
assert captions_for_places([(4.0, 6.0, "걸침")], [(0.0, 5.0), (5.0, 10.0)]) == ["걸침", "걸침"]
# 닿기만 하는 건 제외 (s == p1 또는 e == p0)
assert captions_for_places([(5.0, 6.0, "다음")], [(0.0, 5.0)]) == [""]
# 공백 정규화
assert captions_for_places([(0.0, 1.0, " 여러 칸 ")], [(0.0, 5.0)]) == ["여러 칸"]
# 길이 제한
assert captions_for_places([(0.0, 1.0, "가"*900)], [(0.0, 5.0)], cap=500) == ["가"*500]
assert captions_for_places([], [(0.0, 5.0)]) == [""]
assert captions_for_places(caps, []) == []
print("Task3 OK")
```
- [ ] **Step 2: 실패 확인**
Run: `python <임시경로>/t3.py`
Expected: FAIL — `ImportError: cannot import name 'captions_for_places'`
- [ ] **Step 3: 구현**
`capcut_agent/pipeline.py``_remap_placements()` 다음에 추가한다.
```python
def captions_for_places(captions, places, *, cap: int = 500):
"""컷 구간마다 그 구간에 걸친 자막을 이어붙인다 — 댓글 추천의 근거.
`captions`·`places` 둘 다 **같은(압축) 타임라인** 좌표여야 한다. 겹치는 부분이
조금이라도 있으면 그 컷의 말로 본다(경계에 닿기만 하는 건 제외).
cap 자에서 자른다 — 그 이상은 Gemini 토큰만 먹고 매칭 정확도가 안 오른다.
Returns: places 와 같은 길이의 문자열 리스트
"""
out = []
for p0, p1 in places:
parts = [txt for cs, ce, txt in captions if cs < p1 and ce > p0 and txt]
out.append(" ".join(" ".join(parts).split())[:cap])
return out
```
- [ ] **Step 4: 통과 확인**
Run: `python <임시경로>/t3.py``Task3 OK`
- [ ] **Step 5: 구문·임포트 검증 후 커밋**
---
## Task 4: 회귀 기준선 뜨기 (분할 전 이벤트 스트림 저장)
파이프라인을 쪼개기 **전에** 실제 이벤트 스트림을 파일로 떠 둔다. 쪼갠 뒤 같은 걸
다시 떠서 비교하면 "겉보기 동작 불변"을 증거로 확인할 수 있다.
**Files:** 없음 (검증 산출물만, 스크래치패드에 둔다)
- [ ] **Step 1: 기준선 스크립트 작성**
`<스크래치패드>/dump_events.py`:
```python
import sys, os, json, asyncio, glob
sys.stdout.reconfigure(encoding='utf-8')
sys.path.insert(0, r"D:\개인폴더\00.유튭\capcut2")
from capcut_agent.pipeline import process_bg_template
OUT = sys.argv[1]
vids = sorted(glob.glob(r"D:\개인폴더\00.유튭\capcut2\.downloads\*.mp4"), key=os.path.getsize)
VIDEO = vids[0] # 가장 작은 것 — 받아쓰기가 빨리 끝난다
print("영상:", os.path.basename(VIDEO), round(os.path.getsize(VIDEO)/1e6, 1), "MB")
def scrub(ev):
"""실행마다 달라지는 값(소요시간)을 뺀다 — 비교 대상은 순서와 내용이다."""
e = dict(ev)
e.pop("elapsed", None)
if e.get("type") == "result":
e["stats"] = {k: v for k, v in e["stats"].items() if k != "elapsed"}
e.pop("draft_path", None)
return e
async def main():
evs = []
async for ev in process_bg_template(VIDEO, "__회귀테스트",
title_top="윗줄", title_main="아랫줄", channel="@ch"):
evs.append(scrub(ev))
print(evs[-1].get("type"), evs[-1].get("id", ""), evs[-1].get("status", ""))
json.dump(evs, open(OUT, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
print("저장:", OUT, len(evs), "이벤트")
asyncio.run(main())
```
- [ ] **Step 2: 기준선 뜨기**
Run: `cd "D:/개인폴더/00.유튭/capcut2" && python <스크래치패드>/dump_events.py <스크래치패드>/before.json`
첫 실행은 Whisper 받아쓰기 때문에 몇 분 걸린다(캐시가 없으면). **끝까지 기다려라.**
두 번째부터는 `.cache/asr_*.json` 캐시로 빨라진다.
Expected: `저장: …/before.json N 이벤트`
- [ ] **Step 3: 만들어진 테스트 드래프트 삭제**
```bash
cd "D:/개인폴더/00.유튭/capcut2"
python -c "
import sys, shutil, os; sys.stdout.reconfigure(encoding='utf-8')
from capcut_agent.draft import DEFAULT_DRAFT_ROOT as R
for n in os.listdir(R):
if n.startswith('__회귀테스트'):
shutil.rmtree(os.path.join(R, n), ignore_errors=True); print('삭제:', n)
"
```
- [ ] **Step 4: 커밋 없음**
이 태스크는 산출물이 스크래치패드에만 있다. 커밋할 것이 없다.
`before.json` 경로를 다음 태스크에 넘긴다.
---
## Task 5: `process_bg_template` 분할
**Files:**
- Modify: `capcut_agent/pipeline.py:190-330` (`process_bg_template`)
**Interfaces:**
- Produces:
- `bg_analyze(video_path, draft_name, *, title_top="", title_main="", channel="", youtube=None) -> AsyncIterator[dict]`
— 마지막에 `{"type":"state","state":{…}}`
- `bg_draft(state, *, video_scale=1.0, flip_horizontal=False, scene_split=False, comments_dir="", cards_fixed=False, bg_white=False, comment_cards=None) -> AsyncIterator[dict]`
- `bg_steps(youtube) -> List[dict]` — manifest용 스텝 목록
- `process_bg_template(...)` — 위 셋을 엮는 래퍼. **시그니처·이벤트 불변**
- state 키: `video_path, meta, keep, video_clips, captions, total, draft_name, title_top, title_main, channel, t_all`
- [ ] **Step 1: 구현**
`process_bg_template`을 아래 넷으로 나눈다. **본문은 옮기기만 하고 로직을 바꾸지 마라.**
```python
def bg_steps(youtube) -> List[Dict[str, str]]:
"""배경템플릿 파이프라인의 manifest 스텝. 쪼갠 두 조각이 함께 내는 전체 목록."""
steps = ([{"id": "download", "label": "유튜브 여러 구간 다운로드·병합"}] if youtube else [])
return steps + [{"id": "silence", "label": "무음·발화 분석"},
{"id": "asr", "label": "받아쓰기 (Gemini/Whisper)"},
{"id": "draft", "label": "템플릿 드래프트 생성"}]
```
- `bg_analyze()` = 기존 206~287행([다운로드] → probe → silence → asr) 그대로.
**`yield {"type": "manifest", …}` 는 넣지 마라** — 호출부가 낸다.
마지막에 `yield {"type": "state", "state": {…}}`.
- `bg_draft(state, …)` = 기존 289~330행(장면분할 → 카드 → 드래프트 → result) 그대로.
`comment_cards` 인자가 주어지면 `_load_comment_cards` 대신 그걸 쓴다(2단계 준비).
- 래퍼:
```python
async def process_bg_template(video_path, draft_name, *, title_top="", title_main="",
channel="", video_scale=1.0, flip_horizontal=False,
scene_split=False, comments_dir="", cards_fixed=False,
bg_white=False, youtube=None) -> AsyncIterator[dict]:
"""배경템플릿 파이프라인 — 📁 파일 탭용 얇은 래퍼.
analyze/draft 두 조각을 연달아 부른다. `state` 이벤트는 밖으로 안 흘린다
(기존 UI가 모르는 타입이라 흘리면 로그에 정체불명 이벤트가 찍힌다).
"""
yield {"type": "manifest", "steps": bg_steps(youtube)}
state = None
async for ev in bg_analyze(video_path, draft_name, title_top=title_top,
title_main=title_main, channel=channel, youtube=youtube):
if ev.get("type") == "state":
state = ev["state"]
continue
yield ev
if state is None:
return # analyze 가 error 로 끝난 경우
async for ev in bg_draft(state, video_scale=video_scale,
flip_horizontal=flip_horizontal, scene_split=scene_split,
comments_dir=comments_dir, cards_fixed=cards_fixed,
bg_white=bg_white):
yield ev
```
`t_all`(총 소요)은 `bg_analyze` 시작 시점에 재서 state에 담고, `bg_draft``result`
그걸 쓴다. 그래야 래퍼의 `result.stats.elapsed`가 예전과 같은 의미다.
⚠ 기존 코드의 `yield {"type": "error", …}; return` 경로(오디오 전부 무음)는
`bg_analyze` 안에 그대로 둔다. 그때는 `state` 이벤트가 안 나오고 래퍼가 조용히 끝난다.
- [ ] **Step 2: 구문·임포트 검증**
```bash
cd "D:/개인폴더/00.유튭/capcut2"
python -c "import ast; ast.parse(open('capcut_agent/pipeline.py', encoding='utf-8').read())"
python -c "from server import app; print('import OK')"
```
- [ ] **Step 3: 분할 후 이벤트 스트림 뜨기**
Run: `python <스크래치패드>/dump_events.py <스크래치패드>/after.json`
(받아쓰기는 캐시를 타므로 빠르다)
- [ ] **Step 4: 기준선과 비교 — 여기가 이 태스크의 핵심 검증**
```python
import sys, json; sys.stdout.reconfigure(encoding='utf-8')
a = json.load(open(r"<스크래치패드>\before.json", encoding="utf-8"))
b = json.load(open(r"<스크래치패드>\after.json", encoding="utf-8"))
assert len(a) == len(b), f"이벤트 개수 다름: {len(a)}{len(b)}"
for i, (x, y) in enumerate(zip(a, b)):
assert x == y, f"{i}번째 이벤트 다름:\n before={x}\n after ={y}"
assert not any(e.get("type") == "state" for e in b), "state 이벤트가 밖으로 샜다"
print("Task5 회귀 OK —", len(a), "이벤트 동일")
```
Expected: `Task5 회귀 OK — N 이벤트 동일`
다르면 **로직을 바꾼 것**이다. 옮기기만 해야 한다. 되돌리고 다시 옮겨라.
- [ ] **Step 5: 테스트 드래프트 삭제 후 커밋** (Task 4 Step 3의 삭제 명령 재사용)
---
## Task 6: `process_paste` 분할
**Files:**
- Modify: `capcut_agent/pipeline.py:374-560` (`process_paste`)
**Interfaces:**
- Produces:
- `paste_analyze(payload, draft_name, *, remove_silence=False, asr_bottom=False, name_suffix="") -> AsyncIterator[dict]`
— 마지막에 `{"type":"state","state":{…}}`
- `paste_draft(state, *, video_scale=1.0, flip_horizontal=False, scene_split=False, comments_dir="", cards_fixed=False, card_cuts=None, bg_white=False) -> AsyncIterator[dict]`
- `paste_steps(asr_bottom) -> List[dict]`
- `process_paste(...)` — 래퍼. **시그니처·이벤트 불변**
- state 키: `video_path, meta, dur, cuts, placements, card_places, video_clips, timeline_dur, bottom_caps, eff_caps, draft_name, title_top, title_main, channel, t_all`
- [ ] **Step 1: 구현**
```python
def paste_steps(asr_bottom: bool) -> List[Dict[str, str]]:
"""붙여넣기 파이프라인의 manifest 스텝."""
steps = [{"id": "download", "label": "컷 정밀 다운로드·병합"}]
if asr_bottom:
steps.append({"id": "asr", "label": "받아쓰기 (Whisper)"})
steps.append({"id": "draft", "label": "템플릿 드래프트 생성"})
return steps
```
- `paste_analyze()` = 기존 395~499행(다운로드·병합 → probe → placements/자막 →
[무음 제거] → [asr_bottom]) 그대로. **manifest는 넣지 마라.**
마지막에 `yield {"type": "state", "state": {…}}`.
- `paste_draft(state, …)` = 기존 501~558행(장면분할 → 카드 → 드래프트 → result) 그대로.
- 래퍼는 Task 5와 같은 모양으로 쓴다:
```python
async def process_paste(payload, draft_name, *, video_scale=1.0, flip_horizontal=False,
scene_split=False, comments_dir="", cards_fixed=False,
card_cuts=None, bg_white=False, remove_silence=False,
asr_bottom=False, name_suffix="") -> AsyncIterator[dict]:
"""붙여넣기(JSON) 파이프라인 — 기존 호출부용 얇은 래퍼.
analyze/draft 두 조각을 연달아 부른다. `state` 이벤트는 밖으로 안 흘린다.
"""
yield {"type": "manifest", "steps": paste_steps(asr_bottom)}
state = None
async for ev in paste_analyze(payload, draft_name, remove_silence=remove_silence,
asr_bottom=asr_bottom, name_suffix=name_suffix):
if ev.get("type") == "state":
state = ev["state"]
continue
yield ev
if state is None:
return
async for ev in paste_draft(state, video_scale=video_scale,
flip_horizontal=flip_horizontal, scene_split=scene_split,
comments_dir=comments_dir, cards_fixed=cards_fixed,
card_cuts=card_cuts, bg_white=bg_white):
yield ev
```
- [ ] **Step 2: 구문·임포트 검증** (Task 5 Step 2와 같은 명령)
- [ ] **Step 3: 래퍼가 내는 manifest가 예전과 같은지 확인**
```python
import sys; sys.stdout.reconfigure(encoding='utf-8')
from capcut_agent.pipeline import paste_steps, bg_steps
assert paste_steps(False) == [{"id":"download","label":"컷 정밀 다운로드·병합"},
{"id":"draft","label":"템플릿 드래프트 생성"}]
assert paste_steps(True) == [{"id":"download","label":"컷 정밀 다운로드·병합"},
{"id":"asr","label":"받아쓰기 (Whisper)"},
{"id":"draft","label":"템플릿 드래프트 생성"}]
assert bg_steps(None)[0]["id"] == "silence"
assert bg_steps({"url":"x"})[0]["id"] == "download"
assert [s["id"] for s in bg_steps(None)] == ["silence","asr","draft"]
print("Task6 manifest OK")
```
- [ ] **Step 4: 상태 dict가 draft 단계가 쓰는 키를 다 담는지 확인**
```python
import sys, inspect, re; sys.stdout.reconfigure(encoding='utf-8')
from capcut_agent import pipeline
src = inspect.getsource(pipeline.paste_draft)
used = set(re.findall(r'state\["(\w+)"\]', src)) | set(re.findall(r'state\.get\("(\w+)"', src))
asrc = inspect.getsource(pipeline.paste_analyze)
m = re.search(r'"state":\s*\{(.+?)\n\s*\}\s*\}', asrc, re.S)
produced = set(re.findall(r'"(\w+)":', m.group(1)))
missing = used - produced
assert not missing, f"paste_analyze 가 안 만드는 키를 paste_draft 가 씀: {missing}"
print("Task6 상태키 OK —", sorted(produced))
```
- [ ] **Step 5: 커밋**
---
## Task 7: `renderCutPanel()` — 렌더러 통합
카드 패널을 그리는 코드가 자동 탭(`onResult`)과 구간 탭(`ytMatch`) 두 갈래다.
2~3단계에서 붙여넣기 탭까지 더하면 세 갈래가 되어 한 곳만 고치는 실수가 난다.
**Files:**
- Modify: `server/static/auto.js`
**Interfaces:**
- Produces: `renderCutPanel(box, panelId, data, opts)`
- `box` — 카드 섹션을 붙일 DOM 요소
- `panelId``hl.id` / `"yt"` / `"paste"`
- `data``{cuts, matched, candidates}` (`cuts`가 없으면 폴백 렌더)
- `opts``{unit: "컷"|"구간"}`
- [ ] **Step 1: 함수 추출**
지금 `onResult` 안의 "검색창 → 컷별 섹션 → 후보" 렌더 블록과 `ytMatch` 안의
"검색창 → ⭐ → " 블록을 **하나의 함수로 합친다.** 동작 규칙은 지금 자동 탭 것을 따른다:
```javascript
/* 카드 패널 렌더 — 자동/구간/붙여넣기 세 탭이 공유한다.
갈라 두면 한 곳만 고치는 실수가 난다(배지·검색·선택 상한이 전부 여기 모여 있다). */
function renderCutPanel(box,panelId,data,opts){
const unit=(opts&&opts.unit)||"컷";
const WHY={ts:"⭐",ai:"🤖",like:""};
box.appendChild(searchBar(panelId)); // cardSection 보다 먼저 — SECS 초기화
const cand=(data.candidates||[]).filter(i=>byIdx[i]!==undefined);
if(data.cuts){
for(const cu of data.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 cs=(data.cutRanges||[])[cu.i]||{start:0,end:-1};
const rest=(data.matched||[]).filter(i=>
byIdx[i]!==undefined&&!rec.includes(i)&&
(byIdx[i].times||[]).some(t=>t>=cs.start&&t<=cs.end));
box.appendChild(cardSection(
unit+" "+(cu.i+1)+" · "+cu.sec+"초 · 카드 "+cu.quota+"장"+
(cu.bottom?" — "+cu.bottom:"")+(why?" "+why:""),
rec.concat(rest),panelId,Math.max(CARD_PAGE,rec.length),cu.i));
if(cand.length){
box.appendChild(cardSection(
" ↳ 좋아요 상위에서 채우기",cand,panelId,CUT_FILL_PAGE,cu.i));
}
}
}else{
const m=(data.matched||[]).filter(i=>byIdx[i]!==undefined);
box.appendChild(cardSection(
"⭐ "+unit+"을 언급한 댓글 "+m.length+"장 (좋아요순, 자동 선택)",
m,panelId,Math.max(CARD_PAGE,(sel[panelId]||[]).length)));
if(cand.length){
box.appendChild(cardSection(
" 좋아요 상위 후보 "+cand.length+"장 (부족분 클릭)",cand,panelId,CARD_PAGE));
}
}
}
```
`data.cutRanges` = `hl.paste.cuts`(원본 시각 `{start,end}` 배열). 자동 탭은
`hl.paste.cuts`를, 구간 탭은 나중에 구간 목록을 넘긴다.
- [ ] **Step 2: `onResult`가 이 함수를 부르게 바꾼다**
`onResult`의 렌더 블록을 아래로 대체한다(선택 상태 초기화·제목 UI는 그대로 둔다):
```javascript
renderCutPanel(box,hl.id,
{cuts:hl.cuts,matched:hl.matched,candidates:hl.candidates,
cutRanges:hl.paste.cuts},{unit:"컷"});
```
- [ ] **Step 3: `ytMatch`가 이 함수를 부르게 바꾼다**
`ytMatch`의 렌더 블록(검색창 + ⭐ + )을 아래로 대체한다:
```javascript
renderCutPanel(box,"yt",
{matched:matched,candidates:cand},{unit:"구간"});
```
구간 탭은 아직 `cuts`가 없으므로 폴백 경로로 간다 — **지금과 같은 화면**이다.
- [ ] **Step 4: 문법 검증**
Run: `cd "D:/개인폴더/00.유튭/capcut2" && node --check server/static/auto.js`
Expected: 출력 없음(성공)
- [ ] **Step 5: 중복 코드가 없어졌는지 확인**
```bash
cd "D:/개인폴더/00.유튭/capcut2"
grep -c "좋아요 상위 후보" server/static/auto.js # 1 이어야 한다 (renderCutPanel 안에만)
grep -c "searchBar(" server/static/auto.js # 2 (정의 1 + renderCutPanel 안 1)
```
- [ ] **Step 6: 커밋**
---
## Task 8: 실사용 회귀 확인 + 문서
**Files:**
- Modify: `ARCHITECTURE.md`
- [ ] **Step 1: 서버 재시작**
검은 창을 닫고 `캡컷_에이전트_구간합치기.bat`을 다시 실행한다. **빼먹으면 아래가 헛일이다.**
- [ ] **Step 2: 📁 파일 탭 확인**
`.downloads/`의 mp4 하나를 파일 탭으로 올려 드래프트를 만든다. 확인:
1. 진행 표시가 `무음·발화 분석 → 받아쓰기 → 템플릿 드래프트 생성` 순으로 나오는가
2. 정체불명 이벤트(`state`)가 로그에 안 찍히는가
3. CapCut에서 드래프트가 정상으로 열리는가
- [ ] **Step 3: 🤖 자동 탭 확인 (렌더러 통합 회귀)**
자동 탭 `📋 오팔 JSON` 모드로 편집안 하나를 붙여넣어 검토 화면까지 간다. 확인:
1. 컷별 섹션이 `컷 N · X초 · 카드 Q장 — 자막` 형태로 뜨는가
2. ⭐🤖➕ 배지가 붙는가
3. 검색창이 동작하는가(입력 → 필터, ✕ → 복원)
4. 컷 장수를 채운 뒤 더 눌러도 선택이 안 되는가
5. 빌드까지 가서 카드가 정상으로 들어가는가
- [ ] **Step 4: ▶ 유튜브 구간 탭 확인**
`💬 구간 댓글 매칭` → ⭐/ 두 덩어리가 **지금과 똑같이** 뜨는가. 검색도 되는가.
- [ ] **Step 5: 문서 갱신**
`ARCHITECTURE.md`에 추가한다:
- 모듈 설명에 파이프라인이 `analyze`/`draft` 두 조각으로 나뉜 것과 **왜**(댓글 매칭을
받아쓰기 뒤로 옮기기 위해)
- `state` 이벤트는 내부 전용이며 래퍼가 걸러낸다는 것
- `captions_for_places()`가 컷별 추천 근거를 만든다는 것
- [ ] **Step 6: 커밋**
---
## 자체 점검 결과
**스펙 커버리지 (1단계 해당분)**
| 스펙 항목 | 담당 태스크 |
|---|---|
| §3 파이프라인 분할, `state` 이벤트, 파일 탭 래퍼 | Task 5, 6 |
| §5 컷별 자막 추출(500자 컷) | Task 3 |
| §6 `is_time_based` 일반화, `_whole_picks` 공유 `used` | Task 1 |
| §6 `quotas` 주입 | Task 2 |
| §10 렌더러 통합 | Task 7 |
| §14 검증 1·2·3·4 (순수 함수) | Task 1, 2, 3 |
| §14 검증 6·7·8 (회귀) | Task 4·5(자동 비교), Task 8(실사용) |
**이 단계에 없는 것 (2~4단계로)**: `build_cut_picks` 3순위( 채움) — 자동 탭의 현재
동작을 바꾸므로 실제로 쓰는 2단계에서 넣는다. 새 엔드포인트, 탭별 흐름 변경도 마찬가지.
스펙 §13의 1단계 설명("recommend.py 변경")은 이 범위를 뜻한다.
**타입 일관성**
- `build_highlight_cuts`는 전 구간 `(cuts, need, ai_failed)` 3-튜플
- `cuts[]` 원소 키는 `i, sec, bottom, quota, picks`; `picks` 원소는 `{"idx","why"}`
- `state`는 dict 하나이며 `{"type":"state","state":{…}}`로 감싸 흐른다
- `captions_for_places(captions, places, *, cap)` — 둘 다 압축 타임라인 좌표