docs: 2~4단계 구현 계획 — 세 탭 모두 받아쓰기 후 댓글 매칭
세 탭이 결국 같은 흐름이 되므로 서버 조립 로직을 recommend.cuts_from_state() 하나로 모으고 화면은 이미 통합된 renderCutPanel() 을 재사용한다. 탭마다 다른 건 편집안을 어디서 얻느냐뿐이다. 가장 위험한 지점은 좌표계 둘(원본 시각=매칭, 압축 타임라인=자막·배치·장수)을 섞는 것이라 계획 전반에 명시했다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7c74fec079
commit
bfba87288f
659
docs/superpowers/plans/2026-08-05-2~4단계-세탭-받아쓰기후-매칭.md
Normal file
659
docs/superpowers/plans/2026-08-05-2~4단계-세탭-받아쓰기후-매칭.md
Normal file
@ -0,0 +1,659 @@
|
|||||||
|
# 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]`(빌드)
|
||||||
Loading…
Reference in New Issue
Block a user