From 6df1930d062957cf26b710c956735b03e00ce381 Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Tue, 4 Aug 2026 16:55:31 +0900 Subject: [PATCH] =?UTF-8?q?docs:=201=EB=8B=A8=EA=B3=84=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EA=B3=84=ED=9A=8D=20=E2=80=94=20=ED=8C=8C=EC=9D=B4?= =?UTF-8?q?=ED=94=84=EB=9D=BC=EC=9D=B8=20=EB=B6=84=ED=95=A0=20+=20?= =?UTF-8?q?=EC=B6=94=EC=B2=9C=20=EC=97=94=EC=A7=84=20=EC=A4=80=EB=B9=84=20?= =?UTF-8?q?+=20=EB=A0=8C=EB=8D=94=EB=9F=AC=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 겉보기 동작이 하나도 안 바뀌는 구조 변경이라, 분할 전 이벤트 스트림을 파일로 떠 두고 분할 후와 문자 그대로 비교하는 방식으로 회귀를 검증한다(Task 4·5). Co-Authored-By: Claude Opus 5 (1M context) --- .../plans/2026-08-04-1단계-파이프라인-분할.md | 763 ++++++++++++++++++ 1 file changed, 763 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-04-1단계-파이프라인-분할.md diff --git a/docs/superpowers/plans/2026-08-04-1단계-파이프라인-분할.md b/docs/superpowers/plans/2026-08-04-1단계-파이프라인-분할.md new file mode 100644 index 0000000..5ebd8c2 --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-1단계-파이프라인-분할.md @@ -0,0 +1,763 @@ +# 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) `. + +--- + +## 파일 구조 + +| 파일 | 이 단계에서의 책임 | +|---|---| +| `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)` — 둘 다 압축 타임라인 좌표