capcut-agent/docs/superpowers/plans/2026-08-05-2~4단계-세탭-받아쓰기후-매칭.md
hehihoho3@gmail.com bfba87288f docs: 2~4단계 구현 계획 — 세 탭 모두 받아쓰기 후 댓글 매칭
세 탭이 결국 같은 흐름이 되므로 서버 조립 로직을 recommend.cuts_from_state() 하나로
모으고 화면은 이미 통합된 renderCutPanel() 을 재사용한다. 탭마다 다른 건
편집안을 어디서 얻느냐뿐이다.

가장 위험한 지점은 좌표계 둘(원본 시각=매칭, 압축 타임라인=자막·배치·장수)을
섞는 것이라 계획 전반에 명시했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 10:25:19 +09:00

660 lines
29 KiB
Markdown
Raw Permalink 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.

# 2~4단계: 세 탭 모두 받아쓰기 후 댓글 매칭 — 구현 계획
> **에이전트 작업자용:** 이 계획은 `superpowers:subagent-driven-development`(권장) 또는
> `superpowers:executing-plans`로 태스크 단위로 실행한다. 단계는 체크박스(`- [ ]`)로 추적한다.
**목표:** ▶ 유튜브 구간 · 📋 붙여넣기 · 🤖 자동 세 탭이 모두
`다운로드 → 무음 → 받아쓰기 → 댓글 매칭 → 검토 → 드래프트` 순으로 돌게 한다.
**접근:** 1단계에서 쪼갠 `*_analyze`/`*_draft` 위에 얹는다. 서버 쪽 조립 로직을
`recommend.cuts_from_state()` 하나로 모아 세 탭이 같은 코드를 쓰고, 화면은 이미 통합된
`renderCutPanel()`을 재사용한다. 탭마다 다른 것은 **편집안을 어디서 얻느냐**뿐이다.
**기술 스택:** Python 3.13 / FastAPI(SSE) / 바닐라 JS / 표준 라이브러리만
**스펙:** `docs/superpowers/specs/2026-08-04-받아쓰기후-댓글매칭-design.md`
**선행:** `docs/superpowers/plans/2026-08-04-1단계-파이프라인-분할.md` (완료)
## Global Constraints
- **코드를 고쳤으면 `캡컷_에이전트_구간합치기.bat`을 재시작한다.** 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).
- **서버 실행·브라우저 열기 금지.** 실사용 확인은 사용자가 한다.
- **네트워크는 꼭 필요할 때만.** 순수 함수 검증에는 쓰지 마라.
- **좌표계를 섞지 마라 (이 계획에서 가장 위험):**
- **매칭(⭐)** = 원본 영상 시각 — 댓글의 `9:05`와 맞춰야 한다
- **자막 추출·배치·장수** = 압축 타임라인(무음 제거 후) — 실제 자리다
- 배정 순위는 `⭐ts → 🤖ai → 🔤word → like` (라운드 방식, 이미 구현됨).
- 카드 1장 기준 3.0초(`recommend.CARD_SEC`), 컷별 장수 `max(1, floor(길이/3))`.
- **한국어 docstring·주석**, 표준 라이브러리만, 기존 스타일. 바닐라 JS(빌드 도구 금지).
- **어떤 경우에도 드래프트 생성을 막지 마라**(다운로드 실패 제외).
- git 브랜치 `feat/yt-range-comments`. 태스크마다 커밋. 메시지는 한국어 한 줄 요약 + 왜,
마지막 줄에 `Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>`.
- `assets/frame_template.png`는 파이프라인이 매 실행 다시 굽는 산출물 — 더러워지면
`git checkout --`로 되돌려라.
---
## 이미 있는 부품 (전부 검증 완료, 그대로 쓴다)
| 부품 | 위치 | 하는 일 |
|---|---|---|
| `bg_analyze` / `bg_draft` / `bg_steps` | `pipeline.py` | 파일·구간 탭 파이프라인 두 조각 |
| `paste_analyze` / `paste_draft` / `paste_steps` | `pipeline.py` | 자동(컷 모드)·붙여넣기 탭 두 조각 |
| `captions_for_places(captions, places, *, cap=500)` | `pipeline.py` | 압축 좌표 구간별 자막 이어붙이기 |
| `_remap_placements(placements, keep)` | `pipeline.py` | 원본 누적 구간 → 압축 타임라인 |
| `_cards_by_cut(paths, card_cuts, places, dur, *, fixed)` | `pipeline.py` | 카드를 컷 구간 안에 배치 |
| `build_highlight_cuts(hl, comments, *, key, quotas)` | `recommend.py` | `(cuts, need, ai_failed)` |
| `is_time_based(cuts)` | `recommend.py` | 모든 컷 자막이 비면 시각 기반 |
| `renderCutPanel(box, panelId, data, opts)` | `auto.js` | 컷별 섹션·배지·검색·상한 |
| `/auto/analyze``/auto/stream/{aid}``/auto/build` | `app.py` | **세 탭이 따라야 할 본보기** |
| `ANALYSES` / `JOBS` 메모리 dict | `app.py` | 분석·작업 보관 |
---
## 파일 구조
| 파일 | 이 계획에서의 책임 |
|---|---|
| `capcut_agent/pipeline.py` | `bg_analyze`가 구간 위치를 state에 담게 한다 (Task 1) |
| `capcut_agent/recommend.py` | **`cuts_from_state()`** — 상태+댓글 → `cuts[]`. 세 탭 공통 (Task 2) |
| `server/app.py` | 탭별 analyze/stream/build 엔드포인트 (Task 3·5·7) |
| `server/static/index.html` | 탭별 화면 골격 (Task 4·6·7) |
| `server/static/auto.js` | 탭별 흐름 제어. 렌더는 `renderCutPanel` 재사용 (Task 4·6·7) |
---
## Task 1: `bg_analyze`가 구간 위치를 state에 담는다
▶ 구간 탭이 "구간 N이 압축 타임라인의 어디인지"를 알아야 자막 추출·배치·장수가 된다.
지금 `bg_analyze`는 유튜브 구간을 다운로드만 하고 그 경계를 안 남긴다.
**Files:**
- Modify: `capcut_agent/pipeline.py` (`bg_analyze`)
**Interfaces:**
- Produces: `bg_analyze` state에 세 키 추가
- `ranges_sec: List[Tuple[float,float]]` — 구간의 **원본 영상 시각**(초). 유튜브가 아니면 `[]`
- `raw_places: List[Tuple[float,float]]` — 병합본(무음 제거 전) 누적 구간
- `places: List[Tuple[float,float]]`**압축 타임라인** 구간 (`_remap_placements(raw_places, keep)`)
- 파일 탭(유튜브 아님)은 세 키 모두 `[]` — 래퍼가 안 쓰므로 동작 불변
- [ ] **Step 1: 검증 스크립트 작성**
```python
import sys; sys.stdout.reconfigure(encoding='utf-8')
from capcut_agent.pipeline import _remap_placements
# 구간 3개(원본 16:15~16:35, 15:30~16:15, 23:20~23:52) → 병합본 누적
rs=[(975.0,995.0),(930.0,975.0),(1400.0,1432.0)]
raw,c=[],0.0
for s,e in rs:
raw.append((c,c+(e-s))); c+=e-s
assert raw==[(0.0,20.0),(20.0,65.0),(65.0,97.0)], raw
# 무음 제거로 앞 10초가 통째로 잘린 경우
keep=[(10.0,97.0)]
assert _remap_placements(raw,keep)==[(0.0,10.0),(10.0,55.0),(55.0,87.0)], _remap_placements(raw,keep)
print("Task1 계산식 OK")
```
- [ ] **Step 2: 실패 확인** — 위 스크립트는 기존 함수만 쓰므로 **통과한다.**
이 태스크의 실패 확인은 state 키다:
```python
import sys, inspect, re; sys.stdout.reconfigure(encoding='utf-8')
from capcut_agent import pipeline
src=inspect.getsource(pipeline.bg_analyze)
m=re.search(r'"state":\s*\{(.+?)\n\s*\}\s*\}', src, re.S)
keys=set(re.findall(r'"(\w+)":', m.group(1)))
missing={"ranges_sec","raw_places","places"}-keys
assert not missing, f"state 에 없음: {missing}"
print("Task1 상태키 OK")
```
Run 후 Expected: FAIL — `AssertionError: state 에 없음: {...}`
- [ ] **Step 3: 구현**
`bg_analyze`의 유튜브 다운로드 분기에서 구간을 **초**로 파싱해 보관하고, 무음 분석 뒤
압축 좌표로 옮긴다. 시간 문자열 → 초 변환은 `capcut_agent/paste.py``parse_time`을 쓴다
(이미 `mm:ss` / `hh:mm:ss`를 처리한다).
```python
# 구간 탭이 "구간 N이 타임라인의 어디인지" 알아야 자막·배치·장수를 컷 단위로 낼 수 있다.
# 파일 탭(유튜브 아님)은 빈 리스트 → 아무 데서도 안 쓰인다.
ranges_sec: List[Tuple[float, float]] = []
raw_places: List[Tuple[float, float]] = []
if youtube:
from .paste import parse_time
c = 0.0
for s, e in (youtube.get("ranges") or []):
ss, ee = parse_time(str(s)), parse_time(str(e))
ranges_sec.append((ss, ee))
raw_places.append((c, c + (ee - ss)))
c += ee - ss
```
무음 분석(`keep`)이 끝난 뒤:
```python
places = _remap_placements(raw_places, keep) if raw_places else []
```
state에 `ranges_sec`, `raw_places`, `places` 세 키를 더한다.
- [ ] **Step 4: 통과 확인** — 두 스크립트 모두 통과
- [ ] **Step 5: 회귀 확인 — 파일 탭 이벤트 스트림 불변**
1단계에서 쓴 덤프 스크립트를 그대로 재사용한다:
```bash
cd "D:/개인폴더/00.유튭/capcut2"
python <스크래치패드>/dump_events.py <스크래치패드>/after-t1.json
python -c "
import json,sys; sys.stdout.reconfigure(encoding='utf-8')
a=json.load(open(r'<스크래치패드>\before.json',encoding='utf-8'))
b=json.load(open(r'<스크래치패드>\after-t1.json',encoding='utf-8'))
assert a==b, '이벤트가 달라졌다'
print('파일 탭 회귀 OK —',len(a),'이벤트 동일')
"
```
만든 테스트 드래프트(`__회귀테스트`)를 지운다.
- [ ] **Step 6: 구문·임포트 검증 후 커밋**
---
## Task 2: `cuts_from_state()` — 세 탭 공통 조립
상태(받아쓰기 결과 + 구간 위치)와 댓글을 받아 검토 화면이 쓸 `cuts[]`를 만든다.
**세 탭이 이 함수 하나만 부른다.**
**Files:**
- Modify: `capcut_agent/recommend.py`
**Interfaces:**
- Consumes: `pipeline.captions_for_places`, `build_highlight_cuts`
- Produces:
`cuts_from_state(places, orig_ranges, captions, comments, *, key=None) -> (cuts, need, ai_failed)`
- `places` — 압축 타임라인 구간 `[(p0,p1)]` (자막·장수·배치 기준)
- `orig_ranges` — 같은 길이의 **원본 영상 시각** `[(s,e)]` (⭐ 매칭 기준)
- `captions` — 압축 타임라인 기준 `[(s,e,text)]`
- 반환은 `build_highlight_cuts`와 같은 3-튜플
- [ ] **Step 1: 실패하는 검증 스크립트 작성**
```python
import sys; sys.stdout.reconfigure(encoding='utf-8')
from capcut_agent.recommend import cuts_from_state
places=[(0.0,10.0),(10.0,22.0)] # 압축 타임라인
orig =[(975.0,995.0),(930.0,975.0)] # 원본 시각 (구간 순서대로)
caps =[(1.0,3.0,"이거 100만원짜리 와인이야"),
(11.0,13.0,"넉살이 랩을 진짜 잘하더라고")]
cs=[{"idx":0,"likeCount":145,"times":[],"text":"요즘 넉살님 좋아요 랩도 잘하신다"},
{"idx":1,"likeCount":95,"times":[],"text":"100만원짜리 와인 맛이 다른가요"},
{"idx":2,"likeCount":80,"times":[980.0],"text":"여기 진짜 웃김"},
{"idx":3,"likeCount":50,"times":[],"text":"봄 대단하넼"}]
cuts,need,failed=cuts_from_state(places,orig,caps,cs,key="") # key="" → Gemini 안 씀
assert [c["i"] for c in cuts]==[0,1]
assert [c["quota"] for c in cuts]==[3,4], [c["quota"] for c in cuts] # floor(10/3), floor(12/3)
assert need==7 and failed is False
assert cuts[0]["bottom"]=="이거 100만원짜리 와인이야", cuts[0]["bottom"]
assert cuts[1]["bottom"]=="넉살이 랩을 진짜 잘하더라고"
# ⭐ 는 원본 시각 기준 — 980초는 구간0(975~995) 안이다
w0=[p for p in cuts[0]["picks"] if p["idx"]==2]
assert w0 and w0[0]["why"]=="ts", cuts[0]["picks"]
# 🔤 는 자막 단어 기준 — '넉살' 이 구간1 에 붙어야 한다
assert any(p["idx"]==0 and p["why"]=="word" for p in cuts[1]["picks"]), cuts[1]["picks"]
# 자막이 하나도 없으면 시각 기반 경로로 떨어진다(예외 없이)
c2,n2,f2=cuts_from_state(places,orig,[],cs,key="")
assert n2==7 and f2 is False and all(c["bottom"]=="" for c in c2)
assert cuts_from_state([],[],[],cs,key="")==([],0,False)
print("Task2 OK")
```
- [ ] **Step 2: 실패 확인**
Expected: FAIL — `ImportError: cannot import name 'cuts_from_state'`
- [ ] **Step 3: 구현**
`capcut_agent/recommend.py` 끝에 추가한다.
```python
def cuts_from_state(places, orig_ranges, captions, comments, *, key=None):
"""받아쓰기 상태 + 댓글 → 검토 화면용 (cuts[], need, ai_failed). 세 탭 공통.
⚠ 좌표계가 둘이다. 섞으면 카드가 통째로 어긋난다(스펙 §4):
- `places`·`captions` = 압축 타임라인 → 자막 추출·장수·배치
- `orig_ranges` = 원본 영상 시각 → ⭐ 분:초 매칭
둘은 같은 길이여야 하고 인덱스로만 짝지어 다닌다.
"""
from .pipeline import captions_for_places # 순환 임포트 회피 — 호출 시점에 가져온다
if not places or len(places) != len(orig_ranges):
return [], 0, False
bottoms = captions_for_places(captions, places)
quotas = [max(1, int((p1 - p0) // CARD_SEC)) for p0, p1 in places]
cuts = [{"start": s, "end": e, "bottom": b, "effect": ""}
for (s, e), b in zip(orig_ranges, bottoms)]
return build_highlight_cuts({"paste": {"cuts": cuts}}, comments,
key=key, quotas=quotas)
```
`pipeline`을 파일 상단에서 임포트하면 순환이 된다(`pipeline` → `draft`·`comments`,
그리고 `app.py`가 둘 다 임포트). **함수 안에서 임포트해라.** 그 이유를 주석에 남겨라.
- [ ] **Step 4: 통과 확인**`Task2 OK`
- [ ] **Step 5: 구문·임포트 검증 후 커밋**
---
## Task 3: 📋 붙여넣기 탭 — 서버
**Files:**
- Modify: `server/app.py`
**Interfaces:**
- Produces:
- `POST /paste/analyze` (form `data`) → `{"analysis_id": aid}``parse_paste`로 검증만
- `GET /paste/stream/{aid}` (SSE) — `paste_analyze` 실행 + h-lab 댓글 수집 →
`result` 이벤트 `{cuts, need, total, comments, candidates, warnings}`
- `POST /paste/build` (form `aid`, `cards`, `card_cuts`, 옵션들) → `{"job_id": h}`
- `PSTATES: dict[str, dict]``aid``{"state": …, "places": …, "orig": …, "payload": …}`
- [ ] **Step 1: `/paste/analyze` 추가**
`/auto/analyze` 바로 아래에 둔다. `parse_paste(data)`가 던지면 400.
```python
@app.post("/paste/analyze")
async def paste_analyze_start(data: str = Form(...)) -> JSONResponse:
"""📋 붙여넣기 탭 1단계 — 편집안 검증 후 분석 예약. 실제 작업은 /paste/stream 에서."""
try:
payload = parse_paste(data)
except ValueError as e:
return JSONResponse({"error": str(e)}, 400)
aid = hashlib.sha1(("paste|" + data).encode()).hexdigest()[:12]
ANALYSES[aid] = {"payload": payload}
return JSONResponse({"analysis_id": aid})
```
- [ ] **Step 2: `/paste/stream/{aid}` 추가**
`paste_analyze`를 돌리면서 이벤트를 흘리고, `state` 이벤트를 잡아 보관한 뒤
h-lab 댓글과 합쳐 `cuts[]`를 만든다. manifest에는 `recommend` 스텝을 넣는다.
핵심 골격(기존 `/auto/stream`의 패턴을 따른다):
```python
@app.get("/paste/stream/{aid}")
async def paste_stream(aid: str) -> StreamingResponse:
"""📋 붙여넣기 탭 — 다운로드·무음·받아쓰기 → 댓글 매칭 → 검토 화면용 result."""
a = ANALYSES.get(aid)
async def gen():
if not a:
yield _sse({"type": "error", "message": "알 수 없는 분석입니다."})
return
payload = a["payload"]
warnings: list[str] = []
yield _sse({"type": "manifest", "steps": pipeline_paste_steps(True) +
[{"id": "comments", "label": "댓글 수집 (h-lab)"},
{"id": "recommend", "label": "컷별 댓글 추천"}]})
com_task = asyncio.create_task(
asyncio.to_thread(hlab.fetch_comments, payload["url"]))
state = None
async for ev in paste_analyze(payload, "paste_" + aid,
remove_silence=True, asr_bottom=True):
if ev.get("type") == "state":
state = ev["state"]
continue
if ev.get("type") == "error":
com_task.cancel()
yield _sse(ev)
return
yield _sse(ev)
```
**`remove_silence=True, asr_bottom=True` 로 고정한다.** 추천을 위해 받아쓰기가
항상 필요하기 때문이다(스펙 §5). 사용자가 화면에서 끄는 옵션은 **화면 자막에만** 적용되며
그건 build 단계에서 처리한다 — 이 태스크에서는 analyze를 항상 그렇게 돌린다는 것만 지킨다.
댓글 수집 후:
```python
comments = []
try:
comments = await com_task
yield _sse({"type": "step", "id": "comments", "status": "done",
"detail": f"{len(comments)}개"})
except Exception as exc: # noqa: BLE001
warnings.append(f"h-lab 연결 실패 — 댓글 없이 진행합니다 ({exc})")
yield _sse({"type": "step", "id": "comments", "status": "done",
"detail": "실패(생략)"})
yield _sse({"type": "step", "id": "recommend", "status": "start"})
places = state["card_places"]
orig = [(s, e) for s, e, _, _ in state["cuts"]]
cuts, need, ai_failed = await asyncio.to_thread(
recommend.cuts_from_state, places, orig, state["bottom_caps"], comments)
if ai_failed:
warnings.append("AI 추천 실패(Gemini 응답 없음) — 분:초·자막단어·좋아요로 배정했습니다")
yield _sse({"type": "step", "id": "recommend", "status": "done",
"detail": f"{len(cuts)}컷 · 카드 {need}장"})
PSTATES[aid] = {"state": state, "places": places}
no_ts = [c for c in comments if not c["times"]]
matched = hlab.match_ranges(comments, orig) if comments else []
yield _sse({"type": "result", "cuts": cuts, "need": need,
"total": round(state["timeline_dur"], 1),
"cutRanges": [{"start": s, "end": e} for s, e in orig],
"matched": matched,
"candidates": hlab.top_liked(no_ts, set(matched), len(no_ts)),
"comments": comments, "warnings": warnings})
```
`PSTATES: dict[str, dict] = {}``ANALYSES` 옆에 선언한다.
`pipeline_paste_steps``from capcut_agent.pipeline import paste_steps as pipeline_paste_steps`
같은 식으로 가져온다(기존 import 줄에 더해라).
- [ ] **Step 3: `/paste/build` 추가**
`aid`로 보관한 상태를 꺼내 `paste_draft`만 돌리는 job을 만든다. 카드 저장은
`/auto/build`의 코드를 그대로 따른다(`COMMENTS_DIR/<h>/001.png…`).
```python
@app.post("/paste/build")
async def paste_build(aid: str = Form(...), cards: list[UploadFile] = File(default=[]),
card_cuts: str = Form(""), video_scale: str = Form("144"),
flip: str = Form(""), scene: str = Form(""),
bg_white: str = Form(""), cards_fixed: str = Form(""),
asr_bottom: str = Form("1")) -> JSONResponse:
"""📋 붙여넣기 탭 3단계 — 보관한 상태로 드래프트만 만든다(다운로드·받아쓰기 안 함)."""
st = PSTATES.get(aid)
if not st:
return JSONResponse({"error": "분석 결과가 만료됐습니다. 다시 분석해 주세요."}, 404)
JOBS[h] = {"paste_state": st["state"], "card_cuts": cut_map, }
return JSONResponse({"job_id": h})
```
`/stream/{job_id}``paste_state`가 있으면 `paste_draft`를 부르도록 분기를 더한다
(`job.get("paste")` 분기 옆).
**`asr_bottom`이 꺼져 있으면** 화면 자막을 JSON `bottom`으로 되돌려야 한다.
`state["bottom_caps"]`는 받아쓰기 결과로 덮여 있으므로, 끈 경우 원래 JSON 자막으로
다시 만들어 넣어라(`state["cuts"]`와 `state["placements"]`로 계산 — `paste_analyze`
하던 것과 같은 식). 이 처리를 빼면 옵션이 무시된다.
- [ ] **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; sys.stdout.reconfigure(encoding='utf-8')
from server.app import app
paths={r.path for r in app.routes}
for p in ("/paste/analyze","/paste/stream/{aid}","/paste/build"):
assert p in paths, f"{p} 없음: {sorted(paths)}"
print("Task3 라우트 OK")
```
- [ ] **Step 6: 커밋**
---
## Task 4: 📋 붙여넣기 탭 — 화면
**Files:**
- Modify: `server/static/index.html`, `server/static/auto.js`
**Interfaces:**
- Consumes: Task 3의 세 엔드포인트, 기존 `renderCutPanel(box, panelId, data, opts)`
- Produces: `panelId = "paste"` 패널. 기존 붙여넣기 탭 실행 버튼이 분석을 돌린다
- [ ] **Step 1: 화면 골격**
붙여넣기 탭의 실행 버튼을 **`댓글 매칭 시작`**으로 바꾸고, 그 아래에 결과 영역
`<div id="pasteReview"></div>`와 생성 버튼 `<button id="pasteBuild">`를 둔다
(기본 숨김). 자동 탭의 검토 영역 마크업을 본보기로 삼되 하이라이트 탭 줄은 없다 —
붙여넣기 탭은 편집안이 하나뿐이다.
- [ ] **Step 2: 흐름 제어**
`auto.js`에 붙인다(렌더러가 거기 있으므로 같은 파일에 두는 게 맞다):
```javascript
/* ── 📋 붙여넣기 탭: 분석 → 검토 → 생성 ──
자동 탭과 같은 흐름이지만 편집안이 하나뿐이라 ID 탭이 없다. */
let PASTE_AID=null, PASTE_NEED=0;
async function pasteAnalyze(){
const fd=new FormData(); fd.append("data",$("#pdata").value);
const res=await(await fetch("/paste/analyze",{method:"POST",body:fd})).json();
if(res.error){ /* 기존 에러 표시 경로 */ return; }
PASTE_AID=res.analysis_id;
const es=new EventSource("/paste/stream/"+PASTE_AID);
es.onmessage=(m)=>{
const ev=JSON.parse(m.data);
if(ev.type==="result"){ es.close(); onPasteResult(ev); }
else { /* manifest·step·log 를 기존 진행 표시에 연결 */ }
};
}
function onPasteResult(ev){
byIdx={}; (ev.comments||[]).forEach(c=>byIdx[c.idx]=c);
PASTE_NEED=ev.need;
sel["paste"]=[]; selCut["paste"]={};
(ev.cuts||[]).forEach(cu=>(cu.picks||[]).forEach(p=>{
if(byIdx[p.idx]!==undefined&&!sel["paste"].includes(p.idx)){
sel["paste"].push(p.idx); selCut["paste"][p.idx]=cu.i;
}}));
const box=$("#pasteReview"); box.innerHTML="";
renderCutPanel(box,"paste",
{cuts:ev.cuts,matched:ev.matched,candidates:ev.candidates,cutRanges:ev.cutRanges},
{unit:"컷"});
refreshSel("paste");
$("#pasteBuild").style.display="block";
}
```
`refreshSel`·`toggle`·`cutFull`이 `hlById(panelId)``need`/`cuts`를 찾는다.
붙여넣기 패널용 가짜 하이라이트를 하나 등록해야 한다(구간 탭의 `YT_HL`과 같은 방식):
`PASTE_HL={id:"paste", need:ev.need, cuts:ev.cuts, paste:{cuts:ev.cutRanges}}`를 만들고
`hlById`가 그걸 찾도록 더해라.
- [ ] **Step 3: 생성 버튼**
자동 탭 `buildAll`의 캡처 루프를 그대로 따른다. **반드시 지킬 것:**
- 캡처 전에 `clearSearch("paste")`, `expandAllCuts("paste")`, selonly 해제
- `fd.append("cards", …)``sentCuts.push(ci)`**쌍으로만**
- 캡처 실패 시 `n--`
- `ci===undefined`면 그 카드는 건너뛴다(엉뚱한 컷에 붙이지 마라)
- [ ] **Step 4: 문법 검증**
Run: `node --check server/static/auto.js`
Expected: 출력 없음
- [ ] **Step 5: 커밋**
---
## Task 5: ▶ 유튜브 구간 탭 — 서버
**Files:**
- Modify: `server/app.py`
**Interfaces:**
- Produces: `POST /yt/analyze`, `GET /yt/stream/{aid}`, `POST /yt/build`
(Task 3과 같은 모양. 다른 점은 `bg_analyze`를 쓰고 `state["places"]`·`state["ranges_sec"]`를 쓴다는 것)
- 삭제: `POST /yt/comments`, `POST /youtube` — 새 흐름이 대체한다
- [ ] **Step 1: 삭제 전 다른 호출부 확인**
```bash
cd "D:/개인폴더/00.유튭/capcut2"
grep -rn "/yt/comments\|\"/youtube\"\|'/youtube'" server/ --include=*.js --include=*.html
```
`auto.js``ytMatch``index.html` 폼 말고 다른 곳이 있으면 **멈추고 보고해라.**
- [ ] **Step 2: 세 엔드포인트 추가**
Task 3과 같은 구조다. 차이는:
- `POST /yt/analyze` (form `url`, `ranges`) — `_parse_ranges`로 검증, `ANALYSES[aid]`에 보관
- `GET /yt/stream/{aid}``bg_analyze(None, draft_name, youtube={"url":…, "ranges":…})` 실행
- 추천 호출: `recommend.cuts_from_state(state["places"], state["ranges_sec"], state["captions"], comments)`
- `POST /yt/build``bg_draft(state, …, comment_cards=…)`.
카드 시간은 `_cards_by_cut(_card_paths(cdir), card_cuts, state["places"], state["total"], fixed=cards_fixed)`
만들어 `comment_cards`로 넘긴다(`bg_draft`가 1단계에서 그 인자를 받도록 준비돼 있다)
`bg_analyze`의 무음 제거는 **옵션이 아니라 항상 켜져 있다.** 그래서 `state["places"]`
이미 압축 좌표다. `_remap_placements`**또 부르지 마라** — 두 번 압축된다.
- [ ] **Step 3: 옛 엔드포인트 삭제**
`/yt/comments``/youtube`를 지운다. `_parse_ranges`는 새 `/yt/analyze`가 쓰므로 남긴다.
- [ ] **Step 4: 구문·임포트·라우트 검증**
```python
import sys; sys.stdout.reconfigure(encoding='utf-8')
from server.app import app
paths={r.path for r in app.routes}
for p in ("/yt/analyze","/yt/stream/{aid}","/yt/build"):
assert p in paths, f"{p} 없음"
for p in ("/yt/comments","/youtube"):
assert p not in paths, f"{p} 가 아직 있다"
print("Task5 라우트 OK")
```
- [ ] **Step 5: 커밋**
---
## Task 6: ▶ 유튜브 구간 탭 — 화면
**Files:**
- Modify: `server/static/index.html`, `server/static/auto.js`
- [ ] **Step 1: `💬 구간 댓글 매칭` 버튼 제거, `편집 시작`이 분석을 돌리게**
기존 `ytMatch()``window.ytCC`를 지우고, Task 4의 붙여넣기 탭과 같은 흐름으로 바꾼다
(`panelId = "yt"`, `{unit:"구간"}`).
- [ ] **Step 2: 제목·출처를 검토 화면에서도 고칠 수 있게**
분석에 1분 넘게 기다린 뒤 오타를 발견하면 다시 돌리는 게 낭비다. 검토 영역 위에
제목(윗줄·아랫줄)·출처 입력칸을 두고, 생성 시 그 값을 보낸다.
- [ ] **Step 3: 문법 검증 후 커밋**
Run: `node --check server/static/auto.js`
---
## Task 7: 🤖 자동 탭 — 3단계 흐름
검토 화면의 `✕` 제외 버튼 때문에, 받아쓰기를 **제외 선택 뒤에** 돌려야 낭비가 없다.
**Files:**
- Modify: `server/app.py`, `server/static/auto.js`, `server/static/index.html`
**Interfaces:**
- Produces: `POST /auto/prepare` (form `aid`, `ids`=JSON 배열) → `{"prepare_id": pid}`,
`GET /auto/prepare/{pid}` (SSE) — 남은 ID만 다운로드·받아쓰기 + 댓글 매칭 →
`result` `{highlights:[{id, cuts, need, cutRanges}], comments, candidates, warnings}`
- `/auto/stream/{aid}`에서 **댓글 매칭을 뺀다** — 편집안까지만
- `/auto/build`가 보관한 상태로 드래프트만 만든다
- [ ] **Step 1: `/auto/stream`에서 댓글 매칭 제거**
`targets = [h for h in highlights if "paste" in h]` 이후의 매칭·추천 블록을 들어낸다.
`result` 이벤트는 `highlights`(편집안·타이틀 후보)만 싣는다.
- [ ] **Step 2: `/auto/prepare` 추가**
`ids`로 받은 하이라이트만 **순차로** `paste_analyze`를 돌리고, h-lab 댓글을 한 번 받아
ID별로 `cuts_from_state`를 부른다. 상태는 `PSTATES[f"{aid}:{id}"]`에 보관한다.
진행은 ID별 로그로 흘린다(`{"type":"log","msg":"ID 3 준비 완료"}`).
**순차로 돌려라.** yt-dlp·ffmpeg·Whisper가 CPU를 다 쓴다(기존 방침).
- [ ] **Step 3: `/auto/build`가 상태를 쓰게**
`aid`+`id`로 `PSTATES`에서 상태를 꺼내 `paste_draft`만 돌린다. 다운로드·받아쓰기를
다시 하지 않는다.
- [ ] **Step 4: 화면 — 1차 검토 / 2차 검토 분리**
지금 검토 화면에서 **댓글 영역만 뺀 것**이 1차 검토다:
- 1차: 하이라이트 카드(제목 선택·`✕` 제외·편집안 접기) + `준비 시작` 버튼
- `준비 시작``/auto/prepare` SSE (ID별 진행 표시)
- 2차: 지금과 같은 컷별 댓글 섹션 + `전부 만들기`
`renderCutPanel`은 2차에서 그대로 쓴다.
- [ ] **Step 5: 문법·구문 검증 후 커밋**
---
## Task 8: 문서 + 마무리
**Files:**
- Modify: `ARCHITECTURE.md`, `SETUP.md`, `README.md`
- [ ] **Step 1: `ARCHITECTURE.md`**
탭별 흐름 표를 새 흐름으로 갱신하고, `cuts_from_state()`가 세 탭 공통 조립점이라는 것,
두 좌표계를 섞으면 안 된다는 것을 남긴다.
- [ ] **Step 2: `SETUP.md` 문제 해결표**
| 증상 | 원인 | 해결 |
|---|---|---|
| 카드 고르기까지 오래 걸린다 | 받아쓰기를 먼저 돌린다(추천 정확도를 위해) | 정상. 1분 영상당 ≈30초 |
| 🤖 배지가 하나도 없다 | Gemini 실패 또는 자막 없음 | 로그의 경고 확인. 🔤·➕는 계속 동작 |
| 분석 결과가 만료됐다고 나온다 | 서버 재시작으로 메모리 상태 소실 | 분석을 다시 돌린다 |
- [ ] **Step 3: `README.md`** — 탭 설명의 흐름을 갱신
- [ ] **Step 4: 커밋**
---
## 자체 점검 결과
**스펙 커버리지 (2~4단계)**
| 스펙 항목 | 담당 |
|---|---|
| §1 배정 순위 4단계 | 이미 구현됨(1단계 이후 버그 수정에서) |
| §2 공통 흐름 | Task 2·3·5·7 |
| §4 두 좌표계 | Task 1·2 (`places` vs `orig_ranges`) |
| §5 컷별 자막·`asr_bottom` 의미 변경 | Task 2·3 |
| §7 자동 탭 3단계 | Task 7 |
| §8 구간·붙여넣기 2단계 | Task 3·4·5·6 |
| §9 엔드포인트 표 | Task 3·5·7 |
| §11 실패·폴백 | Task 3·5·7 (각 stream의 try/except) |
| §14 검증 | 각 태스크 Step + Task 8 |
**타입 일관성**
- `cuts_from_state(places, orig_ranges, captions, comments, *, key)``(cuts, need, ai_failed)`
- `cuts[]` 원소: `i, sec, bottom, quota, picks`; `picks` 원소: `{"idx","why"}`,
`why``ts|ai|word|like`
- `places`·`captions` = 압축 좌표, `orig_ranges` = 원본 좌표. **둘 다 같은 길이**
- 패널 id: 자동 `hl.id` / 구간 `"yt"` / 붙여넣기 `"paste"`
- 상태 보관: `ANALYSES[aid]`(입력) · `PSTATES[aid]`(받아쓰기 결과) · `JOBS[h]`(빌드)