Compare commits

..

No commits in common. "f38776d71fc5baad879ce1c01325b7f074a23355" and "3d3f6f6b14eb4a36a70845409d7c010740272452" have entirely different histories.

15 changed files with 571 additions and 3824 deletions

View File

@ -27,17 +27,17 @@ capcut2/
├─ .gemini_key (선택) Gemini API 키 — 파일/유튜브 탭 자막 교정용 ├─ .gemini_key (선택) Gemini API 키 — 파일/유튜브 탭 자막 교정용
├─ assets/ 파생물(frame_template.png 등) 자동 생성 ├─ assets/ 파생물(frame_template.png 등) 자동 생성
├─ server/ ├─ server/
│ ├─ app.py FastAPI. 탭별 analyze→stream→build 엔드포인트들 + SSE 스트림(§3) │ ├─ app.py FastAPI. 엔드포인트 4개 + SSE 스트림
│ └─ static/index.html UI 전체(단일 파일, 탭 3개 + 옵션 + SSE 렌더) │ └─ static/index.html UI 전체(단일 파일, 탭 3개 + 옵션 + SSE 렌더)
└─ capcut_agent/ └─ capcut_agent/
├─ pipeline.py ★ 두 파이프라인, 각각 analyze/draft 두 조각 + 얇은 래퍼(§4) ├─ pipeline.py ★ 두 파이프라인(process_bg_template / process_paste)
├─ draft.py ★ CapCut 드래프트 생성(pycapcut) + JSON 후처리 ├─ draft.py ★ CapCut 드래프트 생성(pycapcut) + JSON 후처리
├─ youtube.py yt-dlp 다운로드(단일/다중/정밀) + ffmpeg 병합 ├─ youtube.py yt-dlp 다운로드(단일/다중/정밀) + ffmpeg 병합
├─ paste.py 붙여넣기 JSON 파서(관대한 파싱) ├─ paste.py 붙여넣기 JSON 파서(관대한 파싱)
├─ silence.py ffmpeg silencedetect → 발화 구간 ├─ silence.py ffmpeg silencedetect → 발화 구간
├─ transcribe.py faster-whisper(medium/int8/cpu) 단어 타임스탬프 ├─ transcribe.py faster-whisper(medium/int8/cpu) 단어 타임스탬프
├─ correct.py Gemini 자막 글자 교정(시간 불변) — gemini-2.5-flash ├─ correct.py Gemini 자막 글자 교정(시간 불변) — gemini-2.5-flash
├─ recommend.py 컷별 댓글 추천(세 탭 공통, cuts_from_state) — 타임스탬프 우선 + Gemini 텍스트 추천 ├─ recommend.py 컷별 댓글 추천(자동 탭) — 타임스탬프 우선 + Gemini 텍스트 추천
├─ highlight.py 자막 청킹(cut_plan) 유틸 ├─ highlight.py 자막 청킹(cut_plan) 유틸
├─ scene.py ffmpeg scene 필터 장면전환 감지·분할 ├─ scene.py ffmpeg scene 필터 장면전환 감지·분할
├─ media.py 프레임 PNG 생성, 흰밴드 감지, 오디오 추출 ├─ media.py 프레임 PNG 생성, 흰밴드 감지, 오디오 추출
@ -58,81 +58,25 @@ CapCut 인스펙터 값 ↔ pycapcut 변환:
| 엔드포인트 | 역할 | | 엔드포인트 | 역할 |
|---|---| |---|---|
| `GET /` | index.html 서빙. `__CDIR__` 토큰을 그 PC의 `capcut2/댓글카드` 절대경로로 치환 | | `GET /` | index.html 서빙. `__CDIR__` 토큰을 그 PC의 `capcut2/댓글카드` 절대경로로 치환 |
| `POST /upload` | 📁 파일 탭. multipart 파일 + 옵션 → job 등록(content-hash id) — **유일하게 옛 단일 흐름**(분석/검토 단계·댓글 매칭 없음) | | `POST /upload` | 파일 탭. multipart 파일 + 옵션 → job 등록(content-hash id) |
| `GET /stream/{job_id}` | SSE. `JOBS[job_id]``bg_state`/`paste_state`가 있으면 **드래프트만**(§4의 build 단계), 없으면 `process_bg_template`/`process_paste` 통짜 실행 | | `POST /youtube` | 유튜브 탭. `url` + `ranges`(JSON `[["mm:ss","mm:ss"],…]`) + 옵션 |
| `POST /paste` | 붙여넣기 탭. `data`(편집안 JSON 문자열) + 옵션 |
| `GET /stream/{job_id}` | SSE. job 종류에 따라 파이프라인 실행, 이벤트 스트림 |
| `POST /open-capcut` | CapCut 실행(시작메뉴 lnk → LOCALAPPDATA exe 폴백) | | `POST /open-capcut` | CapCut 실행(시작메뉴 lnk → LOCALAPPDATA exe 폴백) |
| `GET /drafts` / `POST /repair` | 레이어 꼬임 목록 조회 · 수리(§10) |
| `GET /auto/avatar` | 댓글 프로필 이미지 동일 출처 프록시(구글 도메인만, SSRF 방지) |
| `GET /prompts` / `POST /prompts` | 🤖 자동 탭 Step1·Step3 프롬프트 조회/저장 |
**▶ 유튜브 구간 / 📋 붙여넣기 / 🤖 자동 — 공통 3단계 흐름**(analyze → stream(SSE) → build). - job 은 메모리 dict `JOBS[hash]`. hash = 입력 시그니처 sha1 12자.
셋 다 "받아쓰기까지 끝낸 뒤 검토 화면에서 댓글 카드를 고르고, 그 상태 그대로 드래프트만 - SSE 이벤트 형식: `{"type": "manifest"|"step"|"log"|"error"|"result", ...}`
만든다"는 같은 모양이라 엔드포인트 이름도 대응된다:
| 탭 | 1단계: 분석 예약 | 2단계: SSE(다운로드·받아쓰기·댓글매칭·추천) | 3단계: 빌드 |
|---|---|---|---|
| ▶ 유튜브 구간 | `POST /yt/analyze``ANALYSES[aid]` | `GET /yt/stream/{aid}``YSTATES[aid]` | `POST /yt/build``JOBS[h]["bg_state"]` |
| 📋 붙여넣기 | `POST /paste/analyze``ANALYSES[aid]` | `GET /paste/stream/{aid}``PSTATES[aid]` | `POST /paste/build``JOBS[h]["paste_state"]` |
| 🤖 자동 | `POST /auto/analyze``ANALYSES[aid]`(Step1+3만, 댓글 매칭 없음) | 1차 검토(제목 선택·✕ 제외) 뒤 `POST /auto/prepare``PREPARES[pid]`, `GET /auto/prepare/{pid}``PSTATES["{aid}:{id}"]`(제외 안 된 ID만 순차) | 2차 검토(컷별 댓글) 뒤 `POST /auto/build``JOBS[h]["paste_state"]` |
- 3단계(빌드)는 셋 다 결과로 받은 `job_id`를 그대로 기존 `GET /stream/{job_id}`에 물려
드래프트만 만든다(다운로드·받아쓰기 재실행 없음) — `bg_state`/`paste_state`가 그 분기 키다.
- 옛 `POST /youtube`(유튜브 탭 단일 엔드포인트)·`POST /yt/comments`는 이 흐름으로 대체되며 삭제됐다.
- `POST /paste`(붙여넣기 편집안 통짜 처리)는 코드에 남아 있지만 **현재 UI는 전부
`/paste/analyze` 흐름을 쓴다** — 하위 호환/직접 호출용으로만 존재.
- job 은 메모리 dict `JOBS[hash]`. hash = 입력 시그니처 sha1 12자. 분석·검토 단계 상태는
`ANALYSES[aid]`(입력)·`YSTATES[aid]`/`PSTATES[aid 또는 "aid:id"]`(받아쓰기 결과)·
`PREPARES[pid]`(자동 탭 2단계 예약) — **전부 메모리라 서버 재시작 시 소실**된다(§10).
- SSE 이벤트 형식: `{"type": "manifest"|"step"|"log"|"error"|"result"|"state", ...}`
- `manifest`: `{steps:[{id,label}]}` / `step`: `{id,status:"start"|"done",elapsed,detail}` - `manifest`: `{steps:[{id,label}]}` / `step`: `{id,status:"start"|"done",elapsed,detail}`
- `state`: **내부 전용**(analyze↔draft 조각 간 상태 전달, §4) — 래퍼가 걸러내 밖으로 안 흘림 - `result`: `{draft_name, draft_path, stats:{duration,kept,cut,segments,captions,elapsed}}`
- `result`: 파이프라인 통짜 흐름은 `{draft_name, draft_path, stats:{…}}`,
analyze 단계는 `{cuts, need, cutRanges, matched, candidates, comments, warnings, …}`
(검토 화면 렌더용 — §6)
- 공통 폼 필드: `video_scale`(% 문자열, 기본 144), `flip`, `scene`, `bg_white`, - 공통 폼 필드: `video_scale`(% 문자열, 기본 144), `flip`, `scene`, `bg_white`,
`comments_dir`(폴더 경로 문자열), `title_top/title_main/channel`(파일·유튜브만), `comments_dir`(폴더 경로 문자열), `title_top/title_main/channel`(파일·유튜브만),
`remove_silence`(붙여넣기만). 불리언은 "1"/"0" 문자열 → `_truthy()`. `remove_silence`(붙여넣기만). 불리언은 "1"/"0" 문자열 → `_truthy()`.
## 4. 탭별 파이프라인 ## 4. 탭별 파이프라인
두 파이프라인 모두 내부적으로 **analyze/draft 두 조각 + 얇은 래퍼**로 나뉜다 ### 4-A. 📁 파일 / ▶ 유튜브 구간 → `process_bg_template()` (pipeline.py)
(`process_bg_template` → `bg_analyze`+`bg_draft`, `process_paste``paste_analyze`+`paste_draft`).
**왜**: 댓글 매칭을 받아쓰기(ASR) 뒤로 옮기려면 "받아쓰기까지 끝낸 상태"에서 한 번 멈출
수 있어야 한다 — analyze 조각이 거기서 멈추고 그 결과를 draft 조각이 이어받는다.
이 갈라짐은 이제(2~4단계) **서버 HTTP 계층에도 그대로 노출돼 있다** — ▶ 유튜브 구간
· 📋 붙여넣기 · 🤖 자동 세 탭 모두 `*_analyze`(§3 표의 1단계)가 요청 하나로 끝나고,
그 결과를 받아쓰기까지 끝낸 SSE 스트림(2단계)이 이어받아 **h-lab 댓글 수집 →
`recommend.cuts_from_state()`로 컷별 카드 추천**까지 마친 뒤 검토 화면용 `result`
낸다(엔드포인트 대응표는 §3). 사용자가 검토 화면에서 카드를 고르고 나서야
3단계(`*_build`)가 그 상태로 `*_draft`를 돌려 실제 드래프트를 만든다. **파일 탭만 이 3단계 분리 없이
`/upload → GET /stream/{job_id}`로 통짜 실행되는 옛 흐름 그대로**다(댓글 매칭 없음).
- `*_analyze`는 끝나면 다음 조각에 넘길 상태를 실어 `{"type":"state","state":{…}}` 단계: `[download] → silence → asr → [scene] → draft`
낸다. **이 이벤트는 내부 전용**이라 래퍼가 걸러내고 밖으로 흘리지 않는다 — 기존 UI가
모르는 타입이라 흘리면 로그에 정체불명 이벤트가 찍힌다. `t_all`(총 소요 측정
시작점)도 이 state에 실려 넘어가 `result.stats.elapsed`(전체 소요시간) 의미를 유지한다.
- `manifest`(진행 단계 목록) 이벤트는 조각 안이 아니라 **래퍼가 낸다** — 두 조각이
서로 다른 SSE 스트림에 걸쳐 쓰일 수 있어(예: 댓글 매칭이 끼어들면 analyze와 draft가
별개 요청이 됨) 각자 다른 manifest가 필요하기 때문. 목록 생성은
`bg_steps(youtube)` / `paste_steps(asr_bottom)`가 맡는다.
- `bg_draft(..., comment_cards=None)` — 주어지면 폴더에서 읽는 대신 그 목록을 그대로
쓴다. 이제 실제로 쓰인다: `/yt/build`가 검토 화면에서 고른 `card_cuts`
`_cards_by_cut()`으로 컷별 카드 목록으로 바꿔 여기 넘긴다(§6).
- ⚠ **`recommend.cuts_from_state()`가 세 탭의 공통 조립점**이다(`capcut_agent/recommend.py`).
`*_stream` SSE가 받아쓰기 상태(`places`/`captions`, 압축 좌표)와 h-lab 댓글을
이 함수 하나에 넘기면 `(cuts, need, ai_failed)`를 돌려주고, 그걸 그대로 검토 화면
`result`에 실어 보낸다 — 세 탭이 각자 추천 로직을 따로 구현하지 않는다(§6).
**압축 좌표(`places`/`captions`)와 원본 좌표(`orig_ranges`, ⭐ 분:초 매칭용)를 섞으면
안 된다** — 둘 다 같은 길이의 리스트로 인덱스만으로 짝지어 다닌다(§6 재강조).
### 4-A. 📁 파일 / ▶ 유튜브 구간 (pipeline.py)
단계: `[download] → silence → asr → [scene] → draft` (analyze 조각 = download~asr,
draft 조각 = scene~draft). 두 탭이 이 단계들을 공유하지만 **호출 경로는 다르다**:
📁 파일 탭은 `process_bg_template()`(analyze+draft 통짜, `/upload → /stream/{job_id}`)를
그대로 쓰고, ▶ 유튜브 구간 탭은 §3/§4 서두의 3단계 흐름대로 `bg_analyze()`(`/yt/stream`)와
`bg_draft()`(`/yt/build`)를 **별개 요청으로 나눠 호출**한다(그 사이에 댓글 매칭이
끼기 때문). 아래 단계 설명은 두 경로 모두에 동일하게 적용된다.
1. **download** (유튜브만): `cut_youtube_multi(url, ranges, out)` 1. **download** (유튜브만): `cut_youtube_multi(url, ranges, out)`
구간별로 `yt-dlp --download-sections`(h264 우선) 다운로드 → 구간 2개 이상이면 구간별로 `yt-dlp --download-sections`(h264 우선) 다운로드 → 구간 2개 이상이면
@ -161,18 +105,11 @@ draft 조각 = scene~draft). 두 탭이 이 단계들을 공유하지만 **호
보존 구간을 장면전환 지점에서 **인접 분할**(누적 길이 불변 → 자막 싱크 무영향). 보존 구간을 장면전환 지점에서 **인접 분할**(누적 길이 불변 → 자막 싱크 무영향).
5. **draft**: §5 빌더 호출. 좌표는 전부 `_template_pos()`가 레이아웃 상수에서 파생(§9). 5. **draft**: §5 빌더 호출. 좌표는 전부 `_template_pos()`가 레이아웃 상수에서 파생(§9).
### 4-B. 📋 붙여넣기 (기본 탭) (pipeline.py) ### 4-B. 📋 붙여넣기 (기본 탭) `process_paste()` (pipeline.py)
LLM이 만든 편집안 JSON을 **그대로** 사용. `paste_analyze()`/`process_paste()` 함수 LLM이 만든 편집안 JSON을 **그대로** 사용. 무음컷·ASR **기본 없음**(옵션으로 무음 제거 가능).
자체의 `remove_silence`/`asr_bottom` 기본값은 각각 꺼짐/켜짐이지만, ⚠ **현재 UI의
기본 흐름(`/paste/analyze` → `/paste/stream`)은 댓글 매칭 추천을 위해 둘 다 항상
`True`로 고정해서 호출한다**(§3) — 사용자가 화면에서 끌 수 없다. 검토 화면에서
"하단 자막 자동 생성" 체크를 끄면 **받아쓰기 자체는 그대로 하되** 최종 화면 자막만
`/paste/build` 단계에서 JSON `bottom`으로 되돌린다(아래 5번). 옛 `POST /paste`
단일 엔드포인트는 폼 체크박스 값을 그대로 써서(§3) 지금도 두 옵션 다 끌 수 있다.
단계: `download(컷 정밀) → [remove_silence] → [asr_bottom] → [scene] → draft` 단계: `download(컷 정밀) → [remove_silence] → [scene] → draft`
(analyze 조각 = download~[asr_bottom], draft 조각 = [scene]~draft)
1. **파싱** (`paste.parse_paste`): 관대한 JSON 파싱 — 1. **파싱** (`paste.parse_paste`): 관대한 JSON 파싱 —
`json.loads(strict=False)`(자막 안 실제 줄바꿈 허용), 코드펜스(```) 자동 제거, `json.loads(strict=False)`(자막 안 실제 줄바꿈 허용), 코드펜스(```) 자동 제거,
@ -193,9 +130,8 @@ LLM이 만든 편집안 JSON을 **그대로** 사용. `paste_analyze()`/`process
JSON bottom 폴백. effect/제목/채널은 JSON 유지. JSON bottom 폴백. effect/제목/채널은 JSON 유지.
⚠ "remove_silence 후에 돌리면 remap 불필요"는 틀림 — 파일은 압축 안 되므로 cut_plan 매핑 필수. ⚠ "remove_silence 후에 돌리면 remap 불필요"는 틀림 — 파일은 압축 안 되므로 cut_plan 매핑 필수.
6. **scene / 댓글카드 / draft**: 4-A와 동일(좌표도 공통 — §9). 6. **scene / 댓글카드 / draft**: 4-A와 동일(좌표도 공통 — §9).
🤖 자동 탭·▶ 유튜브 구간 탭에서 넘어온 경우 build 단계(`paste_draft`/`bg_draft`)가 자동 탭에서 넘어온 경우 `process_paste(card_cuts=[...])`로 카드별 소속 컷을
`card_cuts=[...]`로 카드별 소속 컷을 받는다(§6 참고) — 붙여넣기 탭 직접 사용 시엔 받는다(§6 참고) — 붙여넣기 탭 직접 사용 시엔 생략(기존 전체 균등 배치).
생략(기존 전체 균등 배치).
#### 붙여넣기 JSON 스키마 (LLM에게 시킬 형식) #### 붙여넣기 JSON 스키마 (LLM에게 시킬 형식)
@ -290,16 +226,15 @@ title_top 서브제목 / title_main 메인제목 / channel 출처 / effect 효
- `_load_comment_cards(folder, dur, interval=3.0)`: - `_load_comment_cards(folder, dur, interval=3.0)`:
- 모든 파일명이 숫자로 시작 → 숫자순(1,2,10). 아니면 → **파일 생성시각(저장 순서)**. - 모든 파일명이 숫자로 시작 → 숫자순(1,2,10). 아니면 → **파일 생성시각(저장 순서)**.
- png/jpg/jpeg/webp. 카드당 3초, 영상 길이 초과분은 생략. - png/jpg/jpeg/webp. 카드당 3초, 영상 길이 초과분은 생략.
- **배치 방식이 두 갈래**(정렬 자체는 `_card_paths()`로 공통): 🤖 자동 · ▶ 유튜브 구간 · - **배치 방식이 두 갈래**(정렬 자체는 `_card_paths()`로 공통): 자동 탭은 검토 화면에서
📋 붙여넣기 세 탭 모두 검토 화면(§4·§6 `cuts_from_state`)에서 컷별 소속 컷 인덱스 카드별 소속 컷 인덱스(`card_cuts`)를 보내고, `_cards_by_cut(paths, card_cuts, placements, dur)`
(`card_cuts`)를 보내고, `_cards_by_cut(paths, card_cuts, placements, dur)`
**그 컷 구간 안에서** 균등 배치한다(한 컷이 덜 차도 다음 컷 카드가 앞으로 밀리지 않음). **그 컷 구간 안에서** 균등 배치한다(한 컷이 덜 차도 다음 컷 카드가 앞으로 밀리지 않음).
📁 파일 탭(검토 화면 없음)과 붙여넣기 옛 단일 엔드포인트(`POST /paste`) 직접 사용만 파일/유튜브 탭·붙여넣기 탭 직접 사용은 컷 소속을 몰라 기존 `_load_comment_cards` 전체 균등
컷 소속을 몰라 기존 `_load_comment_cards` 전체 균등 배치 그대로 쓴다. 배치 그대로 쓴다.
- 컷당 장수 상한은 `max(1, floor(컷길이/3초))``_load_comment_cards`와 같은 규칙. - 컷당 장수 상한은 `max(1, floor(컷길이/3초))``_load_comment_cards`와 같은 규칙.
초과분은 버린다(카드가 1초씩 번쩍이느니 몇 장 빼는 게 낫다). 초과분은 버린다(카드가 1초씩 번쩍이느니 몇 장 빼는 게 낫다).
- ⚠ **카드 시간은 서버가 미리 확정하지 않는다.** `/auto/build`·`/yt/build`·`/paste/build` - ⚠ **카드 시간은 서버가 미리 확정하지 않는다.** `/auto/build` "몇 번 컷 소속"만 넘기고,
"몇 번 컷 소속"만 넘기고, 파이프라인이 컷 누적 위치(`placements`)로 시간을 계산한다. 파이프라인이 컷 누적 위치(`placements`)로 시간을 계산한다.
- ⚠ **카드 시간 계산은 무음 제거 *뒤*다.** 자막은 `_remap_caps()`로 시간을 옮기지만 - ⚠ **카드 시간 계산은 무음 제거 *뒤*다.** 자막은 `_remap_caps()`로 시간을 옮기지만
(발화 시각을 따라가야 하니까), 카드는 **구간 자체**를 `_remap_placements()`로 옮기고 (발화 시각을 따라가야 하니까), 카드는 **구간 자체**를 `_remap_placements()`로 옮기고
그 안에서 나눈다. 카드 시간을 압축 전에 만들어 자막처럼 재매핑하면 그 안에서 나눈다. 카드 시간을 압축 전에 만들어 자막처럼 재매핑하면
@ -310,36 +245,6 @@ title_top 서브제목 / title_main 메인제목 / channel 출처 / effect 효
- 출처: 사용자가 h-lab(https://h-lab.tolag.shop/comment-cards)에서 실제 유튜브 댓글을 - 출처: 사용자가 h-lab(https://h-lab.tolag.shop/comment-cards)에서 실제 유튜브 댓글을
카드 PNG로 저장해 폴더에 넣음. (향후: h-lab API 연동해 완전 자동화 아이디어 있음) 카드 PNG로 저장해 폴더에 넣음. (향후: h-lab API 연동해 완전 자동화 아이디어 있음)
#### 컷별 댓글 추천 근거 (recommend.py, 세 탭 공통)
- `recommend.cuts_from_state(places, orig_ranges, captions, comments, *, key=None)
-> (cuts, need, ai_failed)` — ★ **세 탭(▶ 유튜브 구간 · 📋 붙여넣기 · 🤖 자동)이
전부 이 함수 하나로 검토 화면용 컷 목록을 조립한다**(`*_stream` SSE의 recommend
스텝에서 호출, §4). `places`·`captions`는 받아쓰기 상태의 **압축 타임라인**(무음
제거 후 — 자막 추출·카드 장수·배치 기준), `orig_ranges`는 **원본 영상 시각**
(⭐ 분:초 매칭 기준). ⚠ **둘은 길이가 같아야 하고 인덱스로만 짝짓는다 — 좌표계를
섞으면 카드가 통째로 어긋난다.** 내부에서 `captions_for_places()`로 컷별 자막을
뽑고, `quotas_for()` 대신 압축 길이 기준 quota를 직접 계산한 뒤
`build_highlight_cuts()`에 넘긴다. 반환하는 `cuts[]` 원소는
`{"i","sec","bottom","quota","picks"}`(`picks` 원소 `{"idx","why"}`,
`why``ts|ai|word|like` — 배정 순위 §1 참고), `sec`는 압축 길이 기준이라
"20초인데 왜 3장?" 같은 화면 표시 불일치가 안 생긴다.
- `pipeline.captions_for_places(captions, places, *, cap=500)` — 컷 구간마다 그 구간에
걸친 자막을 이어붙여 댓글 추천의 근거 텍스트를 만든다. **`captions`·`places` 둘 다
같은(압축) 타임라인 좌표여야 한다** — 좌표계가 다르면 엉뚱한 컷에 엉뚱한 자막이
붙는다. 겹치면 포함, 경계에 닿기만 하면 제외. 500자에서 자른다(그 이상은 Gemini
토큰만 먹고 매칭 정확도가 안 오름).
- `recommend.is_time_based(cuts)` (구 `is_whole`) — **모든 컷의 자막이 비어 있으면**
시각 기반 배정으로 판단한다. 예전엔 "컷 1개 + 자막 없음"만 걸렸는데, 자막 없는
구간이 여러 개인 경우(▶ 유튜브 구간 탭)도 걸리도록 일반화했다. 통짜(컷 1개) 모드는
이 규칙의 특수 케이스가 됐다.
- `recommend._time_based_picks(cuts, comments, quotas)` (구 `_whole_picks`) — 컷마다
슬롯을 배정하되 `used` 집합을 컷 사이에 공유해 **한 댓글이 두 컷에 중복 배정되지
않게** 한다(먼저 도는 컷이 우선).
- `recommend.build_highlight_cuts(hl, comments, *, key=None, quotas=None)``quotas`
주면 그대로 쓰고, 안 주면 `quotas_for(cuts)`(원본 컷 길이 기준)로 계산한다. 무음
제거 뒤 실제 길이 기준 장수를 밖에서 계산해 넘기는 경로를 위한 훅.
## 7. yt-dlp 관련 (youtube.py) — 함정 모음 ## 7. yt-dlp 관련 (youtube.py) — 함정 모음
- **JS 런타임 필수**: 최신 유튜브는 JS 챌린지 필요. `_js_runtime_args()` - **JS 런타임 필수**: 최신 유튜브는 JS 챌린지 필요. `_js_runtime_args()`
@ -378,13 +283,6 @@ title_top 서브제목 / title_main 메인제목 / channel 출처 / effect 효
장면분할(**기본 체크**) / 배경 흰색(**기본 체크**) / 무음 제거(붙여넣기용, 기본 꺼짐). 장면분할(**기본 체크**) / 배경 흰색(**기본 체크**) / 무음 제거(붙여넣기용, 기본 꺼짐).
- 헤더 우측 고정 링크: ✨ AI Studio(aistudio.google.com), 💬 댓글 카드(h-lab). - 헤더 우측 고정 링크: ✨ AI Studio(aistudio.google.com), 💬 댓글 카드(h-lab).
- 완료 시 결과 카드(총 소요시간 포함) + "완료되면 CapCut 자동 실행" 체크. - 완료 시 결과 카드(총 소요시간 포함) + "완료되면 CapCut 자동 실행" 체크.
- `server/static/auto.js`: 컷별 카드 패널 렌더는 `renderCutPanel(box, panelId, data,
opts)` 하나로 통합돼 있다 — 🤖 자동 탭(2차 검토, `panelId=hl.id`) · ▶ 유튜브 구간 탭
(`panelId="yt"`) · 📋 붙여넣기 탭(`panelId="paste"`) 검토 화면이 전부 이 함수를 같이
쓴다. **왜**: 예전엔 자동 탭만 있었는데, 유튜브 구간·붙여넣기 탭에 같은 검토 화면을
추가하면서 세 갈래가 되어 한 곳만 고치는 실수가 나기 쉬웠다(하나로 통합해 예방).
`data.cuts`가 있으면 컷별 섹션(`컷 N · X초 · 카드 Q장 — 자막`), 없으면 기존 ⭐/
폴백을 그린다 — 세 탭 모두 `*_stream`이 낸 `result`(§3·§6)를 그대로 이 함수에 넘긴다.
## 9. 현재 고정값 치트시트 ## 9. 현재 고정값 치트시트
@ -436,10 +334,7 @@ CHANNEL_RATIO = 0.85 # 아래 띠에서 85% 지점
UI 하단 **"🩹 레이어 수리"** / `GET /drafts`(꼬임 감지) · `POST /repair`. UI 하단 **"🩹 레이어 수리"** / `GET /drafts`(꼬임 감지) · `POST /repair`.
- ⚠ `POST /repair`**CapCut 실행 중이면 409 로 거부** — 열어둔 채 수리하면 CapCut 이 - ⚠ `POST /repair`**CapCut 실행 중이면 409 로 거부** — 열어둔 채 수리하면 CapCut 이
메모리 상태로 덮어써 되돌아간다(실측: 11:04:32 수리 → 11:05:39 CapCut 저장으로 원복). 메모리 상태로 덮어써 되돌아간다(실측: 11:04:32 수리 → 11:05:39 CapCut 저장으로 원복).
- `JOBS`/`ANALYSES`/`YSTATES`/`PSTATES`/`PREPARES` 전부 메모리 저장 — 서버 재시작하면 - `JOBS`는 메모리 저장 — 서버 재시작하면 job 소실(스트림 전에 재시작하면 재제출 필요).
전부 소실(§3). ▶ 유튜브 구간·📋 붙여넣기·🤖 자동 탭은 분석(1단계)과 빌드(3단계)
사이에 서버가 재시작되면 검토 화면에서 빌드를 눌러도 "분석 결과가 만료됐습니다"
404가 뜬다 — 해결은 재분석뿐(재시작 원인 자체를 없앨 수는 없음, SETUP.md §10).
- 검증은 최종적으로 **사용자가 CapCut에서 열어 확인**하는 방식. - 검증은 최종적으로 **사용자가 CapCut에서 열어 확인**하는 방식.
- v1(`../capcut`, 포트 8000)은 별개 코드베이스 — 여기 수정해도 v1에 반영 안 됨(역도 동일). - v1(`../capcut`, 포트 8000)은 별개 코드베이스 — 여기 수정해도 v1에 반영 안 됨(역도 동일).

View File

@ -18,20 +18,13 @@
## 2. 세 가지 입력 방법 (탭) ## 2. 세 가지 입력 방법 (탭)
### 📋 붙여넣기 (기본 탭) — 추천 ### 📋 붙여넣기 (기본 탭) — 추천
LLM이 만든 편집안 JSON을 붙여넣으면 컷·자막을 **그대로** 사용합니다. LLM이 만든 편집안 JSON을 붙여넣으면 컷·자막을 **그대로** 사용합니다. (무음컷·받아쓰기 없음)
**댓글 매칭 시작**을 누르면 컷 정밀 다운로드·병합 → 무음 제거 → 받아쓰기(Whisper) →
h-lab 댓글 수집 → 컷별 댓글 카드 추천까지 자동으로 돌고, 검토 화면에서 컷마다 카드를
확인·선택한 뒤 **선택한 카드로 드래프트 만들기**를 누르면 완성됩니다.
(⚠ 무음 제거·받아쓰기는 댓글 매칭 정확도를 위해 항상 켜져 있고 끌 수 없습니다 —
화면 자막은 그대로 JSON의 `bottom`을 쓸지, Whisper 자동 자막으로 바꿀지 따로 고를 수 있습니다.)
### ▶ 유튜브 구간 ### ▶ 유튜브 구간
한 URL + 여러 구간( 구간 추가) → 이어붙여 **무음컷 + 자동 자막(Whisper)**. 한 URL + 여러 구간( 구간 추가) → 이어붙여 **무음컷 + 자동 자막(Whisper)**.
📋 붙여넣기 탭과 같은 흐름 — 분석(다운로드·무음·받아쓰기)이 끝나면 h-lab 댓글을
구간별로 자동 추천해 검토 화면을 보여주고, 카드를 고른 뒤 드래프트를 만듭니다.
### 📁 파일 ### 📁 파일
로컬 영상 파일 → **무음컷 + 자동 자막**. (댓글 카드는 폴더 지정 방식만 — 검토 화면 없음) 로컬 영상 파일 → **무음컷 + 자동 자막**.
세 방법 모두 아래 **영상 옵션**을 함께 적용합니다. 세 방법 모두 아래 **영상 옵션**을 함께 적용합니다.
@ -129,7 +122,7 @@ capcut2/
├─ requirements.txt 파이썬 패키지 목록 ├─ requirements.txt 파이썬 패키지 목록
├─ .gemini_key (선택) Gemini 키 ├─ .gemini_key (선택) Gemini 키
├─ server/ ├─ server/
│ ├─ app.py FastAPI 서버 (/upload, /stream, 탭별 analyze→stream→build) │ ├─ app.py FastAPI 서버 (/upload /youtube /paste /stream)
│ └─ static/index.html 웹 UI │ └─ static/index.html 웹 UI
└─ capcut_agent/ └─ capcut_agent/
├─ pipeline.py 처리 파이프라인(다운로드→컷→자막→드래프트) ├─ pipeline.py 처리 파이프라인(다운로드→컷→자막→드래프트)

View File

@ -285,9 +285,6 @@ yt-dlp --version
| 댓글이 엉뚱한 장면에 뜬다 | 컷 소속 없이 전체 균등 배치로 깔림(위 폴백 화면) 또는 추천 자체가 안 맞음 | 위 두 줄 확인. 컷 섹션이 보인다면 그 컷 섹션 안에서 카드를 갈아끼우면 그 컷 위로 옮겨진다 | | 댓글이 엉뚱한 장면에 뜬다 | 컷 소속 없이 전체 균등 배치로 깔림(위 폴백 화면) 또는 추천 자체가 안 맞음 | 위 두 줄 확인. 컷 섹션이 보인다면 그 컷 섹션 안에서 카드를 갈아끼우면 그 컷 위로 옮겨진다 |
| "컷별 댓글 추천"에서 오래 멈춰 보인다 | 하이라이트마다 Gemini 를 순차로 부른다(429 회피). 최악 5×90초 | **새로고침하지 말 것** — 분석이 통째로 날아간다. 로그에 `ID n 컷별 댓글 추천 중… (i/N)` 이 올라오면 정상 진행 중 | | "컷별 댓글 추천"에서 오래 멈춰 보인다 | 하이라이트마다 Gemini 를 순차로 부른다(429 회피). 최악 5×90초 | **새로고침하지 말 것** — 분석이 통째로 날아간다. 로그에 `ID n 컷별 댓글 추천 중… (i/N)` 이 올라오면 정상 진행 중 |
| 콘솔에 한글 깨짐 | Windows cp949 | 표시만 깨짐. 로직·결과와 무관 | | 콘솔에 한글 깨짐 | Windows cp949 | 표시만 깨짐. 로직·결과와 무관 |
| 카드 고르기까지 오래 걸린다 | 받아쓰기를 먼저 돌린다(추천 정확도를 위해) | 정상. 1분 영상당 ≈30초 |
| 🤖 배지가 하나도 없다 | Gemini 실패 또는 자막 없음 | 로그의 경고 확인. 🔤·➕는 계속 동작 |
| 분석 결과가 만료됐다고 나온다 | 서버 재시작으로 메모리 상태 소실 | 분석을 다시 돌린다 |
--- ---

View File

@ -23,19 +23,6 @@ _TAG_RE = re.compile(r"<[^>]+>")
# 후보(candidates)는 분:초가 아예 없는 댓글만 쓰므로, 여기만 막으면 화면에서 완전히 빠진다. # 후보(candidates)는 분:초가 아예 없는 댓글만 쓰므로, 여기만 막으면 화면에서 완전히 빠진다.
MAX_TIMES = 3 MAX_TIMES = 3
# 광고/홍보 댓글 판별 — 좋아요를 조작한 광고가 ➕좋아요 채우기·후보 상위에 올라와
# 드래프트 카드로 뽑히는 사고 방지. 오탐이 더 아프므로 보수적으로:
# URL, "n% 할인", "최저가"만 본다("할인"·"%" 단독인 일상 댓글은 통과).
_AD_RE = re.compile(
r"https?://|www\.|tinyurl\.|bit\.ly|"
r"\d+\s*%\s*할인|최저가"
)
def _is_ad(text: str) -> bool:
"""광고/홍보 댓글이면 True — fetch_comments 가 수집 단계에서 걸러낸다."""
return bool(_AD_RE.search(plain_text(text)))
def plain_text(html: str) -> str: def plain_text(html: str) -> str:
"""YouTube textDisplay(HTML) → 평문. <br>→줄바꿈, 나머지 태그 제거.""" """YouTube textDisplay(HTML) → 평문. <br>→줄바꿈, 나머지 태그 제거."""
@ -74,14 +61,11 @@ def fetch_comments(url: str, *, timeout: float = 180.0) -> List[Dict]:
if not data.get("success"): if not data.get("success"):
raise RuntimeError(f"h-lab 응답 실패: {data.get('message')}") raise RuntimeError(f"h-lab 응답 실패: {data.get('message')}")
out: List[Dict] = [] out: List[Dict] = []
for c in data.get("data") or []: for i, c in enumerate(data.get("data") or []):
text = str(c.get("text") or "")
if _is_ad(text): # 광고는 수집 단계에서 제외 — 어떤 추천 경로로도 카드가 못 된다
continue
out.append({ out.append({
"idx": len(out), "idx": i,
"authorName": str(c.get("authorName") or ""), "authorName": str(c.get("authorName") or ""),
"text": text, "text": str(c.get("text") or ""),
"likeCount": int(c.get("likeCount") or 0), "likeCount": int(c.get("likeCount") or 0),
"replyCount": int(c.get("replyCount") or 0), "replyCount": int(c.get("replyCount") or 0),
"publishedAt": str(c.get("publishedAt") or ""), "publishedAt": str(c.get("publishedAt") or ""),

View File

@ -113,10 +113,6 @@ BG_CAPTION_Y = _ty(1030) # 영상 하단부 자막 ≈ -0.07
BG_CHANNEL_Y = _ty(1850) # 맨 아래 출처 ≈ -0.93 (하단 댓글영역 아래) BG_CHANNEL_Y = _ty(1850) # 맨 아래 출처 ≈ -0.93 (하단 댓글영역 아래)
COMMENT_SCALE = 0.89 # 댓글 카드 확대(캡컷 인스펙터 89%) COMMENT_SCALE = 0.89 # 댓글 카드 확대(캡컷 인스펙터 89%)
# 카드 아랫변이 넘으면 안 되는 y(px) — 출처(@채널, 파이프라인 기준 y≈1800) 위 여유까지.
# 캡처 쪽 5줄 말줄임이 1차 방어지만, 예외적으로 큰 카드(줄바꿈 연타 등)가 와도
# 여기서 비율 축소해 출처를 절대 덮지 않는다(안전망).
COMMENT_MAX_BOTTOM = 1760
# 하단 검은 배경 자막 글꼴 크기 — 고정값(캡컷 폰트 크기와 1:1). # 하단 검은 배경 자막 글꼴 크기 — 고정값(캡컷 폰트 크기와 1:1).
@ -317,20 +313,15 @@ def build_bg_template_draft(
dur = _us(te) - _us(ts) dur = _us(te) - _us(ts)
if dur <= 0 or not img or not os.path.isfile(img): if dur <= 0 or not img or not os.path.isfile(img):
continue continue
eff = COMMENT_SCALE
if comment_top is not None: if comment_top is not None:
iw, ih = _img_wh(img) iw, ih = _img_wh(img)
disp_h = cw * (ih / iw) * eff disp_h = cw * (ih / iw) * COMMENT_SCALE
max_h = COMMENT_MAX_BOTTOM - comment_top
if 0 < max_h < disp_h: # 긴 카드 — 출처를 덮기 전에 그 카드만 축소
eff *= max_h / disp_h
disp_h = max_h
cy = _ty(comment_top + disp_h / 2, ch) cy = _ty(comment_top + disp_h / 2, ch)
else: else:
cy = comment_y if comment_y is not None else round(-1162/1920, 4) cy = comment_y if comment_y is not None else round(-1162/1920, 4)
script.add_segment(p.VideoSegment( script.add_segment(p.VideoSegment(
p.VideoMaterial(img), p.Timerange(_us(ts), dur), p.VideoMaterial(img), p.Timerange(_us(ts), dur),
clip_settings=p.ClipSettings(scale_x=eff, scale_y=eff, clip_settings=p.ClipSettings(scale_x=COMMENT_SCALE, scale_y=COMMENT_SCALE,
transform_x=0.0, transform_y=cy), transform_x=0.0, transform_y=cy),
), "comment") ), "comment")

View File

@ -12,7 +12,7 @@ from __future__ import annotations
import asyncio import asyncio
import os import os
import time import time
from typing import AsyncIterator, Dict, List, Tuple from typing import AsyncIterator, Dict, List
from typing import Optional from typing import Optional
@ -167,21 +167,6 @@ def _remap_placements(placements, keep_sorted):
for p0, p1 in placements] for p0, p1 in placements]
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
def _safe_name(name: str) -> str: def _safe_name(name: str) -> str:
s = "".join(c for c in name if c.isalnum() or c in (" ", "_", "-", ".")).strip() s = "".join(c for c in name if c.isalnum() or c in (" ", "_", "-", ".")).strip()
return s[:60] or "video" return s[:60] or "video"
@ -202,46 +187,33 @@ BG_STEPS: List[Dict[str, str]] = [
] ]
def bg_steps(youtube: Optional[dict]) -> List[Dict[str, str]]: async def process_bg_template(
"""배경템플릿 파이프라인의 manifest 스텝. 쪼갠 두 조각(bg_analyze/bg_draft)이 함께 내는 전체 목록."""
steps = ([{"id": "download", "label": "유튜브 여러 구간 다운로드·병합"}] if youtube else [])
return steps + [{"id": "silence", "label": "무음·발화 분석"},
{"id": "asr", "label": "받아쓰기 (Gemini/Whisper)"},
{"id": "draft", "label": "템플릿 드래프트 생성"}]
async def bg_analyze(
video_path: Optional[str], video_path: Optional[str],
draft_name: str, draft_name: str,
*, *,
title_top: str = "", title_top: str = "",
title_main: str = "", title_main: str = "",
channel: str = "", channel: str = "",
video_scale: float = 1.0,
flip_horizontal: bool = False,
scene_split: bool = False,
comments_dir: str = "",
cards_fixed: bool = False,
bg_white: bool = False,
youtube: Optional[dict] = None, youtube: Optional[dict] = None,
) -> AsyncIterator[dict]: ) -> AsyncIterator[dict]:
"""배경템플릿 파이프라인 앞부분: [유튜브 구간 다운로드] → probe → 무음컷 → 받아쓰기. """배경템플릿 파이프라인: [유튜브 구간 다운로드] → 무음컷 → 자막 → 드래프트. SSE 이벤트."""
마지막에 `bg_draft` 이어줄 `{"type": "state", ...}` 낸다.
(오디오가 전부 무음이면 `error` 내고 조용히 끝난다 이때는 `state` 나온다.)
"""
t_all = time.perf_counter() t_all = time.perf_counter()
use_gemini = has_gemini_key() use_gemini = has_gemini_key()
steps = ([{"id": "download", "label": "유튜브 여러 구간 다운로드·병합"}] if youtube else [])
# 구간 탭이 "구간 N이 타임라인의 어디인지" 알아야 자막·배치·장수를 컷 단위로 낼 수 있다. steps += [{"id": "silence", "label": "무음·발화 분석"},
# 파일 탭(유튜브 아님)은 빈 리스트 → 아무 데서도 안 쓰인다. {"id": "asr", "label": "받아쓰기 (Gemini/Whisper)"},
ranges_sec: List[Tuple[float, float]] = [] {"id": "draft", "label": "템플릿 드래프트 생성"}]
raw_places: List[Tuple[float, float]] = [] yield {"type": "manifest", "steps": steps}
# ── 유튜브 여러 구간 다운로드 + 병합 (URL 입력 시) ── # ── 유튜브 여러 구간 다운로드 + 병합 (URL 입력 시) ──
if youtube: if youtube:
ranges = youtube.get("ranges") or [(youtube.get("start", ""), youtube.get("end", ""))] ranges = youtube.get("ranges") or [(youtube.get("start", ""), youtube.get("end", ""))]
from .paste import parse_time
c = 0.0
for s, e in ranges:
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
yield {"type": "step", "id": "download", "status": "start"} yield {"type": "step", "id": "download", "status": "start"}
rng_txt = ", ".join(f"{s}~{e}" for s, e in ranges) rng_txt = ", ".join(f"{s}~{e}" for s, e in ranges)
yield {"type": "log", "msg": f"유튜브 {len(ranges)}개 구간 다운로드·병합 중… [{rng_txt}]"} yield {"type": "log", "msg": f"유튜브 {len(ranges)}개 구간 다운로드·병합 중… [{rng_txt}]"}
@ -276,8 +248,6 @@ async def bg_analyze(
yield {"type": "error", "message": "오디오가 없거나 전부 무음입니다."} yield {"type": "error", "message": "오디오가 없거나 전부 무음입니다."}
return return
kept = sum(e - s for s, e in keep) kept = sum(e - s for s, e in keep)
# raw_places(병합본 좌표) → 압축 타임라인 좌표. 자막·배치·장수는 전부 이걸 쓴다.
places = _remap_placements(raw_places, keep) if raw_places else []
await _floor(t) await _floor(t)
yield {"type": "step", "id": "silence", "status": "done", yield {"type": "step", "id": "silence", "status": "done",
"elapsed": round(time.perf_counter() - t, 1), "elapsed": round(time.perf_counter() - t, 1),
@ -316,42 +286,6 @@ async def bg_analyze(
"elapsed": round(time.perf_counter() - t, 1), "elapsed": round(time.perf_counter() - t, 1),
"detail": f"{method} · 자막 {len(captions)}"} "detail": f"{method} · 자막 {len(captions)}"}
yield {"type": "state", "state": {
"video_path": video_path, "meta": meta, "keep": keep,
"video_clips": video_clips, "captions": captions, "total": total,
"draft_name": draft_name, "title_top": title_top, "title_main": title_main,
"channel": channel, "t_all": t_all,
"ranges_sec": ranges_sec, "raw_places": raw_places, "places": places,
}}
async def bg_draft(
state: dict,
*,
video_scale: float = 1.0,
flip_horizontal: bool = False,
scene_split: bool = False,
comments_dir: str = "",
cards_fixed: bool = False,
bg_white: bool = False,
comment_cards: Optional[list] = None,
) -> AsyncIterator[dict]:
"""배경템플릿 파이프라인 뒷부분: 장면전환 분할 → 댓글 카드 → 드래프트 생성 → result.
`bg_analyze` `state` 이어받는다. `comment_cards` 주어지면
`_load_comment_cards` 대신 그걸 쓴다(댓글 매칭 단계 준비).
"""
video_path = state["video_path"]
meta = state["meta"]
video_clips = state["video_clips"]
captions = state["captions"]
total = state["total"]
draft_name = state["draft_name"]
title_top = state["title_top"]
title_main = state["title_main"]
channel = state["channel"]
t_all = state["t_all"]
# ── 장면전환 분할 (선택): 컷이 바뀌는 지점에서 세그먼트 추가 분할 ── # ── 장면전환 분할 (선택): 컷이 바뀌는 지점에서 세그먼트 추가 분할 ──
if scene_split: if scene_split:
yield {"type": "log", "msg": "장면전환 감지 중… (화면 바뀌는 컷 찾기)"} yield {"type": "log", "msg": "장면전환 감지 중… (화면 바뀌는 컷 찾기)"}
@ -365,8 +299,7 @@ async def bg_draft(
yield {"type": "step", "id": "draft", "status": "start"} yield {"type": "step", "id": "draft", "status": "start"}
t = time.perf_counter() t = time.perf_counter()
frame, bg, pos = await asyncio.to_thread(_template_pos, bg_white) frame, bg, pos = await asyncio.to_thread(_template_pos, bg_white)
cards = comment_cards if comment_cards is not None else _load_comment_cards( cards = _load_comment_cards(comments_dir, total, fixed=cards_fixed)
comments_dir, total, fixed=cards_fixed)
if cards: if cards:
yield {"type": "log", "msg": f"댓글 카드 {len(cards)}개 하단 삽입(3초 간격)"} yield {"type": "log", "msg": f"댓글 카드 {len(cards)}개 하단 삽입(3초 간격)"}
path = await asyncio.to_thread( path = await asyncio.to_thread(
@ -396,43 +329,6 @@ async def bg_draft(
} }
async def process_bg_template(
video_path: Optional[str],
draft_name: str,
*,
title_top: str = "",
title_main: str = "",
channel: str = "",
video_scale: float = 1.0,
flip_horizontal: bool = False,
scene_split: bool = False,
comments_dir: str = "",
cards_fixed: bool = False,
bg_white: bool = False,
youtube: Optional[dict] = 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
# ── 템플릿 레이아웃 (캔버스 1080×1920, 단위 = 픽셀, 위가 0) ─────────────────── # ── 템플릿 레이아웃 (캔버스 1080×1920, 단위 = 픽셀, 위가 0) ───────────────────
# 레퍼런스 템플릿을 실측해 잡은 값. **여기만 고치면 전체 배치가 같이 움직인다.** # 레퍼런스 템플릿을 실측해 잡은 값. **여기만 고치면 전체 배치가 같이 움직인다.**
# (예전엔 배경.png 흰밴드 자동감지였는데, 좌표를 정확히 통제하려고 상수로 바꿨다) # (예전엔 배경.png 흰밴드 자동감지였는데, 좌표를 정확히 통제하려고 상수로 바꿨다)
@ -475,30 +371,26 @@ def _template_pos(white: bool = False):
return frame, bg, pos return frame, bg, pos
def paste_steps(asr_bottom: bool) -> List[Dict[str, str]]: async def process_paste(
"""붙여넣기 파이프라인의 manifest 스텝."""
steps = [{"id": "download", "label": "컷 정밀 다운로드·병합"}]
if asr_bottom:
steps.append({"id": "asr", "label": "받아쓰기 (Whisper)"})
steps.append({"id": "draft", "label": "템플릿 드래프트 생성"})
return steps
async def paste_analyze(
payload: dict, payload: dict,
draft_name: str, draft_name: str,
*, *,
video_scale: float = 1.0,
flip_horizontal: bool = False,
scene_split: bool = False,
comments_dir: str = "",
cards_fixed: bool = False,
card_cuts: Optional[List[int]] = None,
bg_white: bool = False,
remove_silence: bool = False, remove_silence: bool = False,
asr_bottom: bool = False, asr_bottom: bool = False,
name_suffix: str = "", name_suffix: str = "",
) -> AsyncIterator[dict]: ) -> AsyncIterator[dict]:
"""붙여넣기 파이프라인 앞부분: 컷 정밀 다운로드·병합 → probe → 컷 배치·자막 계산 → """붙여넣기(JSON) 파이프라인: 컷 정밀 다운로드·병합 → 공급된 자막 2트랙으로 드래프트.
[무음 제거] [asr_bottom 받아쓰기].
자막 배치 시간 = 순서 누적. asr_bottom=True JSON bottom 대신 병합본을 자막 배치 시간 = 순서 누적. asr_bottom=True JSON bottom 대신 병합본을
Whisper로 받아써 실제 발화 타이밍에 맞춘 하단 자막을 생성(effect/제목은 JSON 유지). Whisper로 받아써 실제 발화 타이밍에 맞춘 하단 자막을 생성(effect/제목은 JSON 유지).
payload: paste.parse_paste 결과 dict. payload: paste.parse_paste 결과 dict.
마지막에 `paste_draft` 이어줄 `{"type": "state", ...}` 낸다.
""" """
t_all = time.perf_counter() t_all = time.perf_counter()
cuts = payload["cuts"] # [(s, e, bottom, effect)] cuts = payload["cuts"] # [(s, e, bottom, effect)]
@ -507,6 +399,12 @@ async def paste_analyze(
title_main = payload.get("title_main", "") title_main = payload.get("title_main", "")
channel = payload.get("channel", "") channel = payload.get("channel", "")
steps = [{"id": "download", "label": "컷 정밀 다운로드·병합"}]
if asr_bottom:
steps.append({"id": "asr", "label": "받아쓰기 (Whisper)"})
steps.append({"id": "draft", "label": "템플릿 드래프트 생성"})
yield {"type": "manifest", "steps": steps}
# ── 컷 정밀 다운로드 + 병합 ── # ── 컷 정밀 다운로드 + 병합 ──
yield {"type": "step", "id": "download", "status": "start"} yield {"type": "step", "id": "download", "status": "start"}
yield {"type": "log", "msg": f"{len(cuts)}개 컷 정밀 다운로드·병합 중… (프레임 정확 컷)"} yield {"type": "log", "msg": f"{len(cuts)}개 컷 정밀 다운로드·병합 중… (프레임 정확 컷)"}
@ -600,47 +498,6 @@ async def paste_analyze(
"elapsed": round(time.perf_counter() - t, 1), "elapsed": round(time.perf_counter() - t, 1),
"detail": f"{method} · 자막 {len(bottom_caps)}"} "detail": f"{method} · 자막 {len(bottom_caps)}"}
yield {"type": "state", "state": {
"video_path": video_path, "meta": meta, "dur": dur, "cuts": cuts,
"placements": placements, "card_places": card_places,
"video_clips": video_clips, "timeline_dur": timeline_dur,
"bottom_caps": bottom_caps, "eff_caps": eff_caps,
"draft_name": draft_name, "title_top": title_top, "title_main": title_main,
"channel": channel, "t_all": t_all,
}}
async def paste_draft(
state: dict,
*,
video_scale: float = 1.0,
flip_horizontal: bool = False,
scene_split: bool = False,
comments_dir: str = "",
cards_fixed: bool = False,
card_cuts: Optional[List[int]] = None,
bg_white: bool = False,
) -> AsyncIterator[dict]:
"""붙여넣기 파이프라인 뒷부분: [장면분할] → 댓글 카드 → 드래프트 생성 → result.
`paste_analyze` `state` 이어받는다.
"""
video_path = state["video_path"]
meta = state["meta"]
dur = state["dur"]
cuts = state["cuts"]
placements = state["placements"]
card_places = state["card_places"]
video_clips = state["video_clips"]
timeline_dur = state["timeline_dur"]
bottom_caps = state["bottom_caps"]
eff_caps = state["eff_caps"]
draft_name = state["draft_name"]
title_top = state["title_top"]
title_main = state["title_main"]
channel = state["channel"]
t_all = state["t_all"]
# ── draft ── # ── draft ──
yield {"type": "step", "id": "draft", "status": "start"} yield {"type": "step", "id": "draft", "status": "start"}
t = time.perf_counter() t = time.perf_counter()
@ -700,31 +557,6 @@ async def paste_draft(
} }
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
STEPS: List[Dict[str, str]] = [ STEPS: List[Dict[str, str]] = [
{"id": "silence", "label": "무음·발화 분석"}, {"id": "silence", "label": "무음·발화 분석"},
{"id": "draft", "label": "점프컷 드래프트 생성"}, {"id": "draft", "label": "점프컷 드래프트 생성"},

View File

@ -93,12 +93,10 @@ def _extract_json_str(text: str) -> str:
def parse_candidates(text: str, *, src: str = "Step 1 응답") -> List[Dict]: def parse_candidates(text: str, *, src: str = "Step 1 응답") -> List[Dict]:
"""`{"candidates":[{id,start_time,end_time,reason}, …]}` → [{id,start,end,reason,title_top,title_main}] (초). """`{"candidates":[{id,start_time,end_time,reason}, …]}` → [{id,start,end,reason}] (초).
Gemini Step1 응답과 사용자가 직접 붙여넣는 구간 JSON이 **같은 형식**이라 둘이 공유한다. Gemini Step1 응답과 사용자가 직접 붙여넣는 구간 JSON이 **같은 형식**이라 둘이 공유한다.
코드펜스(```)·앞뒤 잡텍스트는 `_extract_json_str` 걷어낸다. 코드펜스(```)·앞뒤 잡텍스트는 `_extract_json_str` 걷어낸다.
`title_top`/`title_main` 선택 구간 JSON에 있으면 검토 화면 제목 입력칸에
미리 채워진다(Step1 응답에는 없으므로 문자열).
""" """
try: try:
data = json.loads(_extract_json_str(text), strict=False) data = json.loads(_extract_json_str(text), strict=False)
@ -118,9 +116,7 @@ def parse_candidates(text: str, *, src: str = "Step 1 응답") -> List[Dict]:
continue continue
if e > s: if e > s:
out.append({"id": int(c.get("id") or i), "start": s, "end": e, out.append({"id": int(c.get("id") or i), "start": s, "end": e,
"reason": str(c.get("reason") or "").strip(), "reason": str(c.get("reason") or "").strip()})
"title_top": str(c.get("title_top") or "").strip(),
"title_main": str(c.get("title_main") or "").strip()})
if not out: if not out:
raise RuntimeError(f"{src}: 유효한 구간이 하나도 없습니다.") raise RuntimeError(f"{src}: 유효한 구간이 하나도 없습니다.")
return out return out

View File

@ -9,9 +9,6 @@ Gemini 없이 단위 검증이 된다.
from __future__ import annotations from __future__ import annotations
import json import json
import re
import time
import urllib.error
import urllib.request import urllib.request
from typing import Dict, List, Optional from typing import Dict, List, Optional
@ -22,17 +19,6 @@ from .correct import _gemini_key
# 카드 1장이 차지하는 기준 시간(초). pipeline._load_comment_cards(min_sec) 과 같은 값. # 카드 1장이 차지하는 기준 시간(초). pipeline._load_comment_cards(min_sec) 과 같은 값.
CARD_SEC = 3.0 CARD_SEC = 3.0
# ── 단어 겹침 매칭(3순위) — Gemini 가 실패해도(503 등) 네트워크 없이 항상 채워지는 폴백 ──
# 흔해서 아무 컷에나 걸리는 일반 단어. 걸리면 오탐이 늘어날 뿐이라 미리 뺀다.
STOP = {"진짜", "너무", "정말", "그냥", "이거", "저거", "우리", "사람", "이번", "그거", "완전", "진심",
"이렇게", "그렇게", "하는", "했다", "있는", "없는", "보고", "보는", "같아", "같은", "이건", "저건",
"근데", "그리고", "하지만", "합니다", "입니다"}
# 한글 2글자 이상 / 알파벳 3글자 이상 / 숫자 2글자 이상만 키워드 후보로 본다(1글자는 아무 데나 걸린다).
TOK = re.compile(r"[가-힣]{2,}|[A-Za-z]{3,}|\d{2,}")
# 긴 조사부터 검사해야 짧은 조사가 먼저 걸려 어간이 덜 잘리는 일이 없다.
JOSA = ("이야", "에서", "으로", "까지", "부터", "라고", "이고", "", "", "", "", "", "",
"", "", "", "", "", "", "")
def quotas_for(cuts) -> List[int]: def quotas_for(cuts) -> List[int]:
"""컷별 카드 장수 — max(1, floor(컷길이 / CARD_SEC)). 짧은 컷도 최소 1장. """컷별 카드 장수 — max(1, floor(컷길이 / CARD_SEC)). 짧은 컷도 최소 1장.
@ -45,120 +31,45 @@ def quotas_for(cuts) -> List[int]:
return [max(1, int((c["end"] - c["start"]) // CARD_SEC)) for c in cuts] return [max(1, int((c["end"] - c["start"]) // CARD_SEC)) for c in cuts]
def _cut_keywords(text: str) -> List[str]:
"""자막 한 줄 → 댓글 매칭용 키워드(원본 토큰 + 조사 뗀 어간 후보).
"넉살이"(자막) "넉살님"(댓글) 걸리게 하려면 조사를 떼야 한다. 결과가
2글자 미만이면 버린다(1글자 어간은 아무 댓글에나 걸려 오탐만 늘린다).
"""
out: List[str] = []
seen = set()
for tok in TOK.findall(text or ""):
if tok not in STOP and tok not in seen:
seen.add(tok)
out.append(tok)
for josa in JOSA:
if tok.endswith(josa) and len(tok) - len(josa) >= 2:
stem = tok[:-len(josa)]
if stem not in STOP and stem not in seen:
seen.add(stem)
out.append(stem)
break
return out
def _word_match_ranked(cut, comments, used: set) -> List[int]:
"""단어 겹침 순위(3순위) — 자막 키워드가 댓글 본문에 부분 문자열로 들어 있는 개수.
부분 문자열로 보는 이유: 조사·어미가 댓글 쪽에 붙어 있어도(: "넉살" "넉살님")
흡수하려는 . 점수 0 제외, 점수 내림차순 좋아요 내림차순으로 정렬한다.
"""
keywords = _cut_keywords(cut.get("bottom") or "")
if not keywords:
return []
scored = []
for c in comments:
if c["idx"] in used:
continue
text = str(c.get("text") or "")
score = sum(1 for kw in keywords if kw in text)
if score > 0:
scored.append((score, c.get("likeCount", 0), c["idx"]))
scored.sort(key=lambda t: (-t[0], -t[1]))
return [idx for _, _, idx in scored]
def build_cut_picks(cuts, comments, ai_picks: Optional[Dict[int, List[int]]], def build_cut_picks(cuts, comments, ai_picks: Optional[Dict[int, List[int]]],
quotas) -> List[List[dict]]: quotas) -> List[List[dict]]:
"""컷별 추천 확정 — ⭐시각 » 🤖AI » 🔤단어겹침 » ➕좋아요 순, 전 컷 통틀어 중복 금지. """컷별 추천 확정 — 타임스탬프 우선, 남는 자리만 AI, 전 컷 통틀어 중복 금지.
타임스탬프가 AI보다 먼저인 이유: 컷의 **원본 구간** 집어 언급한 댓글은 타임스탬프가 AI보다 먼저인 이유: 컷의 **원본 구간** 집어 언급한 댓글은
근거가 확실하다. 추측(AI·단어) 이기게 이유가 없다. 근거가 확실하다. 추측(AI) 이기게 이유가 없다.
단어 겹침이 AI 다음인 이유: Gemini 문맥까지 보고 고르니 정확하지만, 503 등으로 중복은 컷이 가져간다( 컷은 다음 후보로 밀린다).
실패하거나(`ai_picks=None`) 문맥상 연결을 놓칠 (: "와인 뱉는 장면" "싱크대로 달려간
이유"는 겹치는 단어가 없다) 그물을 하나 더 치는 것 — 네트워크 없이도 항상 동작한다.
컷별로 4단계를 채우고 다음 컷으로 넘어가면 된다 컷의 약한 근거(4순위
좋아요) 컷의 강한 근거(3순위 단어 겹침)보다 먼저 댓글을 가져가 버린다. 그래서
**단계(라운드) 바깥 루프, 컷을 안쪽 루프** 둔다 컷의 ts를 채운 뒤에야
컷의 ai로, 그다음에야 word로 넘어간다. 순위가 순서보다 우선한다.
중복은 컷이 가져간다( 컷은 다음 후보로 밀린다) `used` 라운드·컷을 통틀어 공유.
ai_picks: {컷인덱스: [댓글idx ]} Gemini 실패 None/{} 어느 쪽을 넘겨도 ai_picks: {컷인덱스: [댓글idx ]} Gemini 실패 None/{} 어느 쪽을 넘겨도
타임스탬프만으로 채운다(`ai_pick_cuts` 실패를 None 으로 알린다). 타임스탬프만으로 채운다(`ai_pick_cuts` 실패를 None 으로 알린다).
Returns: 컷별 [{"idx": int, "why": "ts"|"ai"|"word"|"like"}] Returns: 컷별 [{"idx": int, "why": "ts"|"ai"}]
""" """
ai_picks = ai_picks or {} ai_picks = ai_picks or {}
valid = {c["idx"] for c in comments} valid = {c["idx"] for c in comments}
no_ts = [c for c in comments if not c.get("times")]
used: set = set() used: set = set()
quota = [quotas[i] if i < len(quotas) else 0 for i in range(len(cuts))] out: List[List[dict]] = []
out: List[List[dict]] = [[] for _ in cuts]
# 1라운드: ⭐시각
for i, cut in enumerate(cuts): for i, cut in enumerate(cuts):
quota = quotas[i] if i < len(quotas) else 0
picks: List[dict] = []
for idx in match_ranges(comments, [(cut["start"], cut["end"])]): for idx in match_ranges(comments, [(cut["start"], cut["end"])]):
if len(out[i]) >= quota[i]: if len(picks) >= quota:
break break
if idx not in used: if idx not in used:
out[i].append({"idx": idx, "why": "ts"}) picks.append({"idx": idx, "why": "ts"})
used.add(idx) used.add(idx)
# 2라운드: 🤖AI
for i in range(len(cuts)):
if len(out[i]) >= quota[i]:
continue
for idx in (ai_picks.get(i) or []): for idx in (ai_picks.get(i) or []):
if len(out[i]) >= quota[i]: if len(picks) >= quota:
break break
if idx in valid and idx not in used: if idx in valid and idx not in used:
out[i].append({"idx": idx, "why": "ai"}) picks.append({"idx": idx, "why": "ai"})
used.add(idx)
# 3라운드: 🔤단어겹침
for i, cut in enumerate(cuts):
if len(out[i]) >= quota[i]:
continue
for idx in _word_match_ranked(cut, comments, used):
if len(out[i]) >= quota[i]:
break
out[i].append({"idx": idx, "why": "word"})
used.add(idx)
# 4라운드: ➕좋아요
for i in range(len(cuts)):
if len(out[i]) >= quota[i]:
continue
for idx in top_liked(no_ts, used, quota[i] - len(out[i])):
out[i].append({"idx": idx, "why": "like"})
used.add(idx) used.add(idx)
out.append(picks)
return out return out
# Gemini에 넘길 댓글 후보 수. 더 늘려도 채택률이 안 오르고 토큰만 먹는다. # Gemini에 넘길 댓글 후보 수. 더 늘려도 채택률이 안 오르고 토큰만 먹는다.
AI_CANDIDATES = 150 AI_CANDIDATES = 150
TEXT_CAP = 200 # 댓글 본문 절단 길이 TEXT_CAP = 200 # 댓글 본문 절단 길이
# gemini-2.5-flash 는 실측상 HTTP 503("high demand")이 잦아(3연속 503도 관측됨) 컷별 AI_MODEL = "gemini-2.5-flash" # correct.py 와 같은 무료 티어 모델
# 추천이 통째로 비는 원인이었다. plan.py 가 이미 쓰는 gemini-3.5-flash 로 통일 —
# 같은 프롬프트·같은 컷으로 실측해도 결과가 동등해 품질 손해는 없다.
AI_MODEL = "gemini-3.5-flash"
_ENDPOINT = ("https://generativelanguage.googleapis.com/v1beta/models/" _ENDPOINT = ("https://generativelanguage.googleapis.com/v1beta/models/"
"{model}:generateContent?key={key}") "{model}:generateContent?key={key}")
@ -257,27 +168,13 @@ def ai_pick_cuts(cuts, comments, quotas, *, model: str = AI_MODEL,
_ENDPOINT.format(model=model, key=key), _ENDPOINT.format(model=model, key=key),
data=json.dumps(body).encode("utf-8"), data=json.dumps(body).encode("utf-8"),
headers={"Content-Type": "application/json"}) headers={"Content-Type": "application/json"})
# 429("quota")·503("high demand")만 짧게 쉬고 재시도한다(최대 2회 더 = 총 3회 시도).
# 둘 다 "지금 당장은 안 됨"이지 "영영 안 됨"이 아니라서다. 그 외 오류는 재시도해 봐야
# 같은 결과라 즉시 포기한다.
RETRY_WAITS = (3.0, 8.0)
raw = None
for attempt in range(1 + len(RETRY_WAITS)):
try: try:
with urllib.request.urlopen(req, timeout=timeout) as resp: with urllib.request.urlopen(req, timeout=timeout) as resp:
data = json.loads(resp.read().decode("utf-8")) data = json.loads(resp.read().decode("utf-8"))
raw = data["candidates"][0]["content"]["parts"][0]["text"] raw = data["candidates"][0]["content"]["parts"][0]["text"]
break
except urllib.error.HTTPError as e:
if e.code in (429, 503) and attempt < len(RETRY_WAITS):
time.sleep(RETRY_WAITS[attempt])
continue
return None # 재시도 소진, 또는 재시도 대상이 아닌 HTTP 오류
except (KeyError, IndexError, json.JSONDecodeError, ValueError, except (KeyError, IndexError, json.JSONDecodeError, ValueError,
OSError): # URLError, TimeoutError are OSError subclasses; ValueError covers UnicodeDecodeError OSError): # URLError, TimeoutError are OSError subclasses; ValueError covers UnicodeDecodeError
return None # 폴백은 호출부 몫 return None # 429 포함 — 폴백은 호출부 몫
if raw is None: # 방어적 — 위 루프는 항상 break/return 으로 빠진다
return None
try: try:
rows = json.loads(raw) rows = json.loads(raw)
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
@ -287,60 +184,49 @@ def ai_pick_cuts(cuts, comments, quotas, *, model: str = AI_MODEL,
return _rows_to_picks(rows) return _rows_to_picks(rows)
def is_time_based(cuts) -> bool: def is_whole(cuts) -> bool:
"""시각 기반으로 배정할 컷 묶음인가 — **모든 컷의 자막이 비었는가**. """통짜 하이라이트인가 — 컷 1개 + 자막 없음(app._whole_hl 이 만드는 모양).
자막이 없으면 내용 추천의 근거가 없다. 대신 이런 묶음(통짜 모드·유튜브 구간 ) 통짜는 자막이 없어 내용 추천의 근거가 없다. 대신 타임라인 시각 = 원본 시각이라
구간을 그대로 이어붙이므로 타임라인 시각 = 원본 시각이 성립해 시각으로 맞출 있다. 시각으로 정확히 맞출 있다(스펙 §4.1).
1개짜리 통짜는 규칙의 특수 케이스다(스펙 §1).
""" """
if not cuts: return len(cuts) == 1 and not (cuts[0].get("bottom") or "").strip()
return False
return all(not (c.get("bottom") or "").strip() for c in cuts)
def _time_based_picks(cuts, comments, quotas) -> List[List[dict]]: def _whole_picks(cut, comments, n: int) -> List[dict]:
"""컷마다 3초 슬롯 배정 — 슬롯 시간대 언급 댓글, 빈 슬롯은 좋아요 상위로 채움. """통짜 — 슬롯마다 그 시간대 언급 댓글, 빈 슬롯은 좋아요 상위로 메움."""
slots = match_slots(comments, cut["start"], cut["end"] - cut["start"], n)
`used` 사이에 공유해 댓글이 컷에 들어가지 않게 한다( 우선). used = {i for i in slots if i is not None}
"""
used: set = set()
no_ts = [c for c in comments if not c.get("times")] 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)) fill = iter(top_liked(no_ts, used, n))
picks: List[dict] = [] out: List[dict] = []
for s in slots: for s in slots:
if s is not None: if s is not None:
picks.append({"idx": s, "why": "ts"}) out.append({"idx": s, "why": "ts"})
continue continue
nxt = next(fill, None) nxt = next(fill, None)
if nxt is not None: if nxt is not None:
picks.append({"idx": nxt, "why": "like"}) out.append({"idx": nxt, "why": "like"})
used.add(nxt)
out.append(picks)
return out return out
def build_highlight_cuts(hl, comments, *, key=None, quotas=None): def build_highlight_cuts(hl, comments, *, key=None):
"""하이라이트 하나 → (cuts[], need, ai_failed). 모드는 컷 모양으로 판별한다. """하이라이트 하나 → (cuts[], need, ai_failed). 모드는 컷 모양으로 판별한다.
quotas: 컷별 카드 장수를 밖에서 정해 넘길 쓴다(무음 제거 실제 길이 기준).
넘기면 `quotas_for(cuts)` 원본 길이 기준.
Returns: ([{"i","sec","bottom","quota","picks"}], need, ai_failed) Returns: ([{"i","sec","bottom","quota","picks"}], need, ai_failed)
need = Σ quota. Gemini 실패는 ai_failed=True 알린다(예외는 올린다). need = Σ quota 기존 int(total//3) 대체한다( 경계에 맞추는 쪽이 맞다).
ai_failed = Gemini 호출이 **실패**했는가(429·타임아웃·파싱). 통짜 모드는 Gemini
부르므로 항상 False. 호출부는 값으로만 경고를 띄운다 실패해도 cuts
타임스탬프만으로 채워져 비지 않으므로, cuts 비었는지로는 실패를 없다.
Gemini 실패는 여기서 흡수된다(ai_pick_cuts None 준다) 예외를 올리지 않는다.
""" """
cuts = (hl.get("paste") or {}).get("cuts") or [] cuts = (hl.get("paste") or {}).get("cuts") or []
if not cuts: if not cuts:
return [], 0, False return [], 0, False
quotas = list(quotas) if quotas is not None else quotas_for(cuts) quotas = quotas_for(cuts)
ai_failed = False ai_failed = False
if is_time_based(cuts): if is_whole(cuts):
picks = _time_based_picks(cuts, comments, quotas) picks = [_whole_picks(cuts[0], comments, quotas[0])]
else: else:
ai = ai_pick_cuts(cuts, comments, quotas, key=key) ai = ai_pick_cuts(cuts, comments, quotas, key=key)
ai_failed = ai is None ai_failed = ai is None
@ -349,30 +235,3 @@ def build_highlight_cuts(hl, comments, *, key=None, quotas=None):
"bottom": c.get("bottom") or "", "quota": quotas[i], "picks": picks[i]} "bottom": c.get("bottom") or "", "quota": quotas[i], "picks": picks[i]}
for i, c in enumerate(cuts)] for i, c in enumerate(cuts)]
return out, sum(quotas), ai_failed return out, sum(quotas), ai_failed
def cuts_from_state(places, orig_ranges, captions, comments, *, key=None):
"""받아쓰기 상태 + 댓글 → 검토 화면용 (cuts[], need, ai_failed). 세 탭 공통.
좌표계가 둘이다. 섞으면 카드가 통째로 어긋난다(스펙 §4):
- `places`·`captions` = 압축 타임라인 자막 추출·장수·배치
- `orig_ranges` = 원본 영상 시각 : 매칭
둘은 같은 길이여야 하고 인덱스로만 짝지어 다닌다.
"""
# pipeline은 지금 recommend를 임포트하지 않아 상단 임포트도 동작한다. 다만 앞으로
# pipeline이 추천을 쓰게 되면 순환이 되므로 함수 안에서 가져와 미리 끊어 둔다.
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)]
cuts_out, need, ai_failed = build_highlight_cuts(
{"paste": {"cuts": cuts}}, comments, key=key, quotas=quotas)
# sec 는 화면에서 quota 바로 옆에 찍힌다. quota 가 압축 길이 기준이므로
# sec 도 압축 길이여야 "20초인데 왜 3장?"이 안 생긴다.
# (start/end 는 ⭐ 매칭용이라 원본 시각 그대로 둔다 — 좌표계가 둘인 이유)
for c, (p0, p1) in zip(cuts_out, places):
c["sec"] = round(p1 - p0, 1)
return cuts_out, need, ai_failed

View File

@ -1,763 +0,0 @@
# 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)` — 둘 다 압축 타임라인 좌표

View File

@ -1,659 +0,0 @@
# 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]`(빌드)

View File

@ -1,237 +0,0 @@
# 받아쓰기 후 댓글 매칭 — 모든 탭 통일 (설계)
작성일: 2026-08-04
관련 코드: `capcut_agent/pipeline.py`, `capcut_agent/recommend.py`, `server/app.py`, `server/static/auto.js`, `server/static/index.html`
선행 스펙: `2026-08-04-컷별-댓글-추천-design.md` (자동 탭에 컷별 추천 도입. 추천 엔진 `recommend.py`가 여기서 나왔다)
## 0. 목표 한 줄
**댓글 매칭을 항상 받아쓰기 다음에 한다 — 모든 탭에서 똑같이.**
지금은 탭마다 추천 근거가 다르다. 자동·붙여넣기 탭은 LLM이 쓴 JSON 자막(`bottom`),
구간 탭은 자막이 아예 없어 분:초만 본다. 근거를 **Whisper 받아쓰기 하나로 통일**하면
정확도가 오르고, 세 탭이 같은 코드로 돌아 유지보수가 단순해진다.
| 탭 | 지금 | 바뀐 뒤 |
|---|---|---|
| 🤖 자동 | 분석 → 검토(즉시) → 생성 | 분석 → **1차 검토(제목·제외)** → 다운로드·받아쓰기 → **2차 검토(댓글)** → 생성 |
| ▶ 유튜브 구간 | 매칭(분:초만) → 편집 시작 | 편집 시작 → 다운로드·받아쓰기 → 검토(댓글) → 생성 |
| 📋 붙여넣기 | 댓글 UI 없음 | 편집 시작 → 다운로드·받아쓰기 → 검토(댓글) → 생성 |
| 📁 파일 | 폴더 지정 | **제외** — §12 |
## 1. 통일된 배정 순서
컷(또는 구간) 하나마다 **카드 장수(quota)**만큼 이 순서로 채운다:
| 순위 | 근거 | 배지 | 어디서 |
|---|---|---|---|
| 1 | 그 컷의 **원본 시각**을 언급한 댓글, 좋아요순 | ⭐ | `match_ranges` |
| 2 | 그 컷의 **Whisper 자막**을 Gemini가 읽고 고른 댓글 | 🤖 | `ai_pick_cuts` |
| 3 | 좋아요 상위로 남은 자리 채움 | | `top_liked` |
한 댓글은 한 컷에만 들어간다(겹치면 앞 컷이 가져간다). 이 순서는 이미
`recommend.build_cut_picks()`에 있고, 3순위만 새로 붙이면 된다.
**시각이 어긋나지 않는 경우엔 1순위가 더 정밀해진다.** 통짜 모드와 구간 탭은
구간을 그대로 이어붙이므로 타임라인 시각 = 원본 시각이 성립한다. 그때는
`match_slots()`로 3초 슬롯마다 그 시간대 언급 댓글을 꽂는다(기존 동작 유지).
**받아쓰기 결과가 없으면**(전부 무음·ASR 실패) 2순위를 건너뛰고 1·3순위만 돈다.
## 2. 공통 흐름
모든 탭이 결국 같은 모양이 된다:
```
(편집안 확보) → 다운로드·병합 → 무음 분석 → 받아쓰기 → 댓글 매칭 → [검토] → 드래프트
└───────── analyze 단계 ─────────┘ └ draft 단계 ┘
```
- **편집안 확보**가 탭마다 다르다: Gemini Step1/3(자동 `full`) · 붙여넣은 JSON(자동 `paste`,
붙여넣기 탭) · URL+구간(구간 탭, 자동 `whole`/`wpaste`)
- 그 뒤는 전부 같다.
## 3. 파이프라인을 둘로 쪼갠다
파이프라인이 두 개 있고 **둘 다** 쪼개야 한다.
| 지금 | 쪼갠 뒤 | 쓰는 곳 |
|---|---|---|
| `process_paste()` | `paste_analyze()` + `paste_draft(state, …)` | 자동 탭(컷 있는 모드), 붙여넣기 탭 |
| `process_bg_template()` | `bg_analyze()` + `bg_draft(state, …)` | 구간 탭, 파일 탭 |
`*_analyze`는 이벤트를 흘리다가 마지막에 `{"type":"state", "state":{…}}`를 내보낸다.
호출부가 그걸 받아 보관했다가 `*_draft`에 넘긴다.
**상태 dict**: `video_path, meta, keep, video_clips, captions, total, placements, draft_name, channel`
(+ 붙여넣기 계열은 `eff_caps`, `cuts_orig`; 구간 계열은 `ranges_sec`)
📁 파일 탭은 두 개를 연달아 부르는 얇은 래퍼 `process_bg_template()`으로 남긴다 —
**겉보기 동작과 SSE 이벤트가 그대로여야 한다.** 래퍼는 `state` 이벤트를 걸러내 밖으로
안 흘린다.
상태는 `STATES[aid]` 메모리 dict에 둔다. 서버를 재시작하면 사라지는데 기존 `JOBS`
같은 성질이라 새 제약이 아니다.
## 4. 두 좌표계를 섞지 않는다
⚠ 이 설계에서 가장 틀리기 쉬운 지점이다.
| 용도 | 좌표계 | 왜 |
|---|---|---|
| **1순위 매칭** | **원본 영상 시각** | 댓글에 적힌 `9:05`와 맞춰야 한다 |
| **자막 추출·배치·장수** | **압축 타임라인** | 무음이 잘린 뒤 실제 자리다 |
컷/구간마다 두 값을 나란히 들고 다닌다. `_remap_placements(raw, keep)`가 원본 누적
구간을 압축 타임라인으로 옮긴다(선행 작업에서 이미 만들어져 있다).
```python
cuts = [{"start": 원본_s, "end": 원본_e, "bottom": 자막}, …] # 매칭용
places = _remap_placements(raw_places, keep) # 배치용
quotas = [max(1, int((p1 - p0) // CARD_SEC)) for p0, p1 in places] # 압축 길이 기준
```
## 5. 컷별 자막을 어떻게 얻나
받아쓰기 결과 `captions = [(s, e, text)]`**압축 타임라인** 기준이다.
`i`의 자막 = `places[i]` 범위에 걸친 캡션 텍스트를 이어붙인 것
(공백 정규화, **500자에서 자름** — 그 이상은 Gemini 토큰만 먹는다).
이 한 줄로 **세 탭 모두** 자막을 얻는다. JSON `bottom`은 더 이상 추천 근거로 쓰지 않는다
(화면 자막으로는 그대로 쓰인다 — `asr_bottom` 옵션이 꺼져 있으면).
**`asr_bottom` 옵션의 의미가 바뀐다**: 받아쓰기는 추천을 위해 **항상** 돌고,
이 옵션은 "그 결과를 화면 자막으로도 쓸지"만 정한다. UI 설명 문구를 그에 맞게 고친다.
## 6. 추천 엔진 변경
`recommend.py`에 필요한 것만 더한다.
| 함수 | 변경 |
|---|---|
| `build_highlight_cuts(hl, comments, *, key=None, quotas=None)` | `quotas`가 주어지면 `quotas_for()` 대신 그것을 쓴다(압축 길이 기준 장수를 밖에서 계산해 넘긴다). 안 넘기면 기존 동작 |
| `is_whole(cuts)``is_time_based(cuts)` | **모든 컷의 `bottom`이 비면** 시각 기반. 컷 1개 통짜는 특수 케이스가 된다. 구간 2개 이상도 걸린다 |
| `_whole_picks` | 컷마다 호출하되 `used` 집합을 공유해 컷 간 중복 배정을 막는다 |
| `build_cut_picks` | 3순위( 좋아요 채움)를 추가한다. 지금은 ⭐·🤖만 채우고 모자라면 비워 둔다 |
## 7. 자동 탭 — 3단계
검토 화면에 **`✕` ID 제외 버튼**이 있다. 지금은 제외하면 그 하이라이트를 다운로드조차
안 한다. 받아쓰기를 먼저 돌리면 **버릴 것까지 받아서 받아쓰기**하게 되므로,
**제외를 먼저 고르게** 한다.
```
① 분석 Gemini Step1/Step3 (또는 JSON 파싱) → 편집안 5개
② 1차 검토 하이라이트 카드 5장 — 제목 선택, ✕ 제외, 옵션 확인 ← 새 화면
③ 준비 남은 것만 다운로드·무음·받아쓰기 (순차) + h-lab 댓글 수집
④ 2차 검토 컷별 댓글 섹션 (지금 검토 화면에서 댓글 부분만)
⑤ 생성 드래프트 (다운로드·받아쓰기 안 함 — ③에서 끝냈다)
```
②는 지금 검토 화면에서 **댓글 영역만 뺀 것**이라 새로 만드는 게 아니라 나누는 것이다.
③은 진행 표시를 ID별 보드에 그대로 쓴다.
## 8. 구간 탭 · 붙여넣기 탭 — 2단계
제외할 것이 없어 1차 검토가 필요 없다.
```
① 편집 시작 다운로드·무음·받아쓰기 (+ h-lab 댓글 동시 수집)
② 검토 컷/구간별 댓글 섹션
③ 생성 드래프트
```
구간 탭의 `💬 구간 댓글 매칭` 버튼은 없어진다(`편집 시작`이 그 일을 한다).
제목·출처는 검토 화면에서도 고칠 수 있게 둔다 — 1분 넘게 기다린 뒤 오타를 발견하면
다시 돌리는 게 낭비다.
## 9. 엔드포인트
| 엔드포인트 | 상태 |
|---|---|
| `POST /auto/analyze``GET /auto/stream/{aid}` | 유지. **댓글 매칭을 빼고** 편집안까지만 |
| `POST /auto/prepare` (신규) | 남은 ID들 → 다운로드·받아쓰기·댓글 매칭. SSE로 진행, `result`에 ID별 `cuts[]` |
| `POST /auto/build` | 유지. `card_cuts` 이미 받음. 이제 상태를 재사용해 드래프트만 |
| `POST /yt/analyze``GET /yt/stream/{aid}` (신규) | 구간 탭 1단계 |
| `POST /yt/build` (신규) | 구간 탭 3단계 |
| `POST /paste/analyze``GET /paste/stream/{aid}` (신규) | 붙여넣기 탭 1단계 |
| `POST /paste/build` (신규) | 붙여넣기 탭 3단계 |
| `POST /yt/comments`, `POST /youtube` | **삭제** (새 흐름이 대체. 다른 호출부 없음을 grep으로 확인할 것) |
| `POST /upload`, `POST /paste`, `GET /stream/{job_id}` | 유지 |
`result` 이벤트 스키마는 선행 스펙과 같다(`cuts[].picks[].why` = `ts`/`ai`/`like`).
## 10. 렌더러를 하나로 모은다
카드 패널을 그리는 코드가 지금 자동 탭(`onResult`)과 구간 탭(`ytMatch`) 두 갈래다.
붙여넣기 탭까지 더하면 세 갈래가 되어 한 곳만 고치는 실수가 난다.
**`renderCutPanel(panelId, data, opts)` 하나로 모은다** — 컷/구간별 섹션, ⭐🤖➕ 배지,
검색창, ` 채우기` 섹션, 컷별 선택 상한을 전부 담당한다. 세 탭이 `panelId`만 달리해
부른다(`hl.id` / `"yt"` / `"paste"`). 라벨 접두사(`컷` vs `구간`)는 `opts`로 넘긴다.
이번 작업에 필요해서 하는 정리이지 무관한 리팩터링이 아니다.
## 11. 실패·폴백
| 상황 | 처리 |
|---|---|
| 받아쓰기 결과 없음 | `bottom` 전부 빔 → `is_time_based` 경로(시각 슬롯 + 좋아요 채움) |
| Gemini 실패(429·타임아웃·파싱) | `ai_failed` 경고 한 줄 + ⭐·➕만으로 배정 |
| h-lab 댓글 수집 실패 | 경고 한 줄 + 댓글 없이 검토 화면 → 카드 없이 생성 가능 |
| 다운로드 실패 | 기존과 동일 — `error` 이벤트로 중단(영상이 없으면 만들 수 없다) |
| `aid` 만료(서버 재시작) | build가 404 + "분석을 다시 돌려주세요" |
**다운로드 실패를 빼면 어떤 경우에도 드래프트 생성을 막지 않는다.**
## 12. 하지 않는 것
- **📁 파일 탭** — 로컬 파일이라 댓글을 가져올 유튜브 URL이 없다. URL을 따로 받으면
자막 기반 추천(🤖)은 가능하지만, 로컬 파일의 시각이 원본 영상 시각과 달라
**분:초 매칭(⭐)은 원리적으로 불가**하다. 사용자가 제외를 선택했다.
- **📁 파일 탭 흐름 변경** — 내부만 쪼개고 겉보기는 그대로.
- **유튜브 자동자막** — Whisper 받아쓰기를 쓰므로 불필요.
- **다운로드 캐시 추가** — 파이프라인을 쪼개면 재다운로드가 없어진다.
- **하이라이트 병렬 준비** — yt-dlp·ffmpeg·Whisper가 CPU를 다 쓴다. 순차 유지(기존 방침).
- **구간별 제목·출처 개별 지정** — 이어붙여 영상 하나를 만드므로 제목도 하나다.
## 13. 구현 단계
한 번에 다 하기엔 크다. **각 단계가 그 자체로 돌아가는 상태**로 끊는다.
| 단계 | 내용 | 끝나면 |
|---|---|---|
| **1** | 파이프라인 분할(`*_analyze`/`*_draft`) + `recommend.py` 변경 + 렌더러 통합 | 겉보기 동작 불변. 회귀만 확인 |
| **2** | ▶ 구간 탭 2단계 적용 | 구간 탭에서 받아쓰기 후 매칭이 돈다 |
| **3** | 📋 붙여넣기 탭 2단계 적용 | 붙여넣기 탭에 댓글 화면이 생긴다 |
| **4** | 🤖 자동 탭 3단계 적용 | 세 탭 통일 완료 |
단계마다 계획을 따로 쓰고 실행한다. 1단계는 사용자에게 보이는 변화가 없으므로
**회귀 검증이 전부**다 — 여기서 깨지면 나머지가 전부 위에서 무너진다.
## 14. 검증
자동 테스트 스위트가 없다(CLAUDE.md).
**순수 함수 (인라인 assert)**
1. `is_time_based()` — 자막 전부 비면 참, 하나라도 차 있으면 거짓, 컷 1개 통짜도 참
2. 컷 간 중복 배정 없음 (`_whole_picks` 공유 `used`)
3. 컷 자막 추출 — 압축 좌표 범위에 걸친 캡션만, 500자에서 자름
4. `quotas` 외부 주입 시 `build_highlight_cuts` 동작(안 넘기면 기존과 동일)
5. `build_cut_picks` 3순위() 채움
**회귀 (1단계에서 반드시)**
6. 📁 파일 탭: 분할 전후 SSE 이벤트 순서·내용 동일, `state` 이벤트가 밖으로 안 샘
7. 🤖 자동 탭: 렌더러 통합 후 컷별 섹션·배지·검색·캡처 그대로
8. 네 탭 모두 드래프트가 예전과 같은 결과로 나오는가
**실사용 (단계별)**
9. 구간 2개 영상 → 구간별 섹션·자동 선택, 카드 시간이 각 구간 압축 범위 안
10. 표시한 `need` = 실제 배치된 카드 수(버려지는 카드 0)
11. 붙여넣기 탭 → 컷별 섹션, 댓글 안 골라도 생성됨(하위호환)
12. 자동 탭 3단계 → 1차에서 2개 제외 시 그 2개는 다운로드·받아쓰기 안 함
13. Gemini 키를 비워 세 탭 모두 폴백이 도는가
## 15. 열린 질문
없음. 미결이 생기면 여기에 적고 구현 전에 사용자에게 묻는다.

View File

@ -20,12 +20,8 @@ from fastapi import FastAPI, File, Form, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from capcut_agent.pipeline import ( from capcut_agent.pipeline import process_bg_template, process_paste
process_bg_template, process_paste, paste_analyze, paste_draft, from capcut_agent.paste import parse_paste
paste_steps as pipeline_paste_steps, _remap_caps,
bg_analyze, bg_draft, bg_steps, _cards_by_cut, _card_paths,
)
from capcut_agent.paste import parse_paste, parse_time
from capcut_agent.draft import DEFAULT_DRAFT_ROOT, list_drafts, repair_layers from capcut_agent.draft import DEFAULT_DRAFT_ROOT, list_drafts, repair_layers
from capcut_agent import comments as hlab from capcut_agent import comments as hlab
from capcut_agent import recommend from capcut_agent import recommend
@ -49,20 +45,6 @@ JOBS: dict[str, dict] = {}
# analysis_id → {"url": …} (분석은 SSE 1회성 — 결과는 브라우저가 들고 있음) # analysis_id → {"url": …} (분석은 SSE 1회성 — 결과는 브라우저가 들고 있음)
ANALYSES: dict[str, dict] = {} ANALYSES: dict[str, dict] = {}
# 📋 붙여넣기 탭(새 흐름) — analysis_id → {"state", "places", "orig", "payload"}
# /paste/stream 이 채우고 /paste/build 가 꺼내 쓴다(서버 재시작 시 소실 — 재분석 필요).
PSTATES: dict[str, dict] = {}
# ▶ 유튜브 구간 탭(새 흐름) — analysis_id → {"state"}
# /yt/stream 이 채우고 /yt/build 가 꺼내 쓴다(서버 재시작 시 소실 — 재분석 필요).
YSTATES: dict[str, dict] = {}
# 🤖 자동 탭 2단계(준비) — prepare_id → {"aid", "ids"}
# /auto/prepare(POST) 가 채우고 /auto/prepare/{pid}(SSE) 가 꺼내 쓴다.
# 준비된 개별 편집안의 다운로드·받아쓰기 상태는 PSTATES[f"{aid}:{id}"] 에 담긴다
# (📋 붙여넣기 탭과 같은 저장소를 공유 — /auto/build 가 그 값으로 paste_draft 만 돌린다).
PREPARES: dict[str, dict] = {}
_DEFAULT_CDIR = os.path.join(os.path.dirname(BASE_DIR), "댓글카드") _DEFAULT_CDIR = os.path.join(os.path.dirname(BASE_DIR), "댓글카드")
@ -97,28 +79,8 @@ def _truthy(v: str) -> bool:
return str(v).strip().lower() in ("1", "true", "on", "yes") return str(v).strip().lower() in ("1", "true", "on", "yes")
def _fmt_range_time(sec: float) -> str:
"""초 → `M:SS`(1시간 이상은 `H:MM:SS`). youtube.valid_time()이 강제하는 형식과
맞춰야 한다 youtube._fmt_hms() 밀리초까지 붙어(`H:MM:SS.mmm`) valid_time을
통과 하므로 여기 전용 포맷 함수를 따로 둔다."""
sec = max(0, round(sec))
h, rem = divmod(int(sec), 3600)
m, s = divmod(rem, 60)
return f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"
def _parse_ranges(raw: str) -> list[tuple[str, str]]: def _parse_ranges(raw: str) -> list[tuple[str, str]]:
"""JSON [[시작,끝],...] → [(M:SS, M:SS)]. 항목은 "16:07" 문자열/967 같은 초 숫자 """JSON [["mm:ss","mm:ss"],...] → [(start,end)]. 빈 값/파싱실패는 []."""
모두 허용해 parse_time()으로 초로 바꾼 valid_time() 통과하는 M:SS/H:MM:SS로
재포맷한다.
함정: 프런트(ytRanges()) 숫자를 보내던 시절 정규화 없이 그대로
저장했다가, 다운로드 경로의 cut_youtube()valid_time() "967" 같은 순수
숫자 문자열을 시간 형식으로 인정하지 않아 분석이 다운로드 단계에서
"시간 형식 오류" 죽었다(구간 전면 불능). 여기서 형식을 통일해두면
프런트가 무엇을 보내든(문자열이든 숫자든) 다운로드 경로까지 안전하게 도달한다.
/파싱실패 항목은 건너뛴다.
"""
raw = (raw or "").strip() raw = (raw or "").strip()
if not raw: if not raw:
return [] return []
@ -129,11 +91,9 @@ def _parse_ranges(raw: str) -> list[tuple[str, str]]:
out: list[tuple[str, str]] = [] out: list[tuple[str, str]] = []
for item in arr if isinstance(arr, list) else []: for item in arr if isinstance(arr, list) else []:
if isinstance(item, (list, tuple)) and len(item) == 2: if isinstance(item, (list, tuple)) and len(item) == 2:
try: s, e = str(item[0]).strip(), str(item[1]).strip()
s_sec, e_sec = parse_time(item[0]), parse_time(item[1]) if s and e:
except (ValueError, TypeError): out.append((s, e))
continue
out.append((_fmt_range_time(s_sec), _fmt_range_time(e_sec)))
return out return out
@ -168,6 +128,54 @@ async def upload(
return JSONResponse({"job_id": h, "draft_name": JOBS[h]["draft_name"]}) return JSONResponse({"job_id": h, "draft_name": JOBS[h]["draft_name"]})
@app.post("/youtube")
async def youtube(
url: str = Form(...),
ranges: str = Form(""),
start: str = Form(""),
end: str = Form(""),
title_top: str = Form(""),
title_main: str = Form(""),
channel: str = Form(""),
video_scale: str = Form("100"),
flip: str = Form(""),
scene: str = Form(""),
comments_dir: str = Form(""),
bg_white: str = Form(""),
cards_fixed: str = Form(""),
cards: list[UploadFile] = File(default=[]),
) -> JSONResponse:
"""유튜브 URL + 여러 구간으로 작업 생성. ranges=JSON [["mm:ss","mm:ss"],...].
ranges 없으면 start/end 단일 구간으로 폴백(하위호환).
cards(댓글 매칭에서 캡처한 PNG들) 오면 폴더 지정보다 우선한다.
"""
rng = _parse_ranges(ranges) or ([(start.strip(), end.strip())] if start and end else [])
if not rng:
return JSONResponse({"error": "구간을 하나 이상 입력하세요."}, 400)
sig = url + "|" + "|".join(f"{s}-{e}" for s, e in rng)
h = hashlib.sha1(sig.encode()).hexdigest()[:12]
if cards:
cdir = os.path.join(COMMENTS_DIR, h)
if os.path.isdir(cdir): # 재실행 시 이전 카드 잔재 제거
shutil.rmtree(cdir, ignore_errors=True)
os.makedirs(cdir, exist_ok=True)
for i, f in enumerate(cards, 1):
body = await f.read()
with open(os.path.join(cdir, f"{i:03d}.png"), "wb") as out:
out.write(body)
comments_dir = cdir
JOBS[h] = {
"path": None, "draft_name": f"yt_{h}",
"youtube": {"url": url.strip(), "ranges": rng},
"title_top": title_top, "title_main": title_main, "channel": channel,
"video_scale": _scale(video_scale), "flip": _truthy(flip),
"scene": _truthy(scene), "comments_dir": comments_dir, "bg_white": _truthy(bg_white),
"cards_fixed": _truthy(cards_fixed),
}
return JSONResponse({"job_id": h})
@app.post("/paste") @app.post("/paste")
async def paste( async def paste(
data: str = Form(...), data: str = Form(...),
@ -205,37 +213,7 @@ async def stream(job_id: str) -> StreamingResponse:
yield _sse({"type": "error", "message": "알 수 없는 작업입니다."}) yield _sse({"type": "error", "message": "알 수 없는 작업입니다."})
return return
try: try:
if job.get("paste_state"): # 📋 붙여넣기 탭(새 흐름) — 이미 분석된 상태로 드래프트만 if job.get("paste"): # 붙여넣기(JSON) 모드
# paste_draft 는 manifest 를 내지 않는다(analyze 쪽이 이미 냈던 스텝들과
# 별개 스트림이라 여기서 새로 내야 함) — 안 내면 step 이벤트만 홀로 와서
# 화면에 진행 표시가 아예 안 뜬다.
yield _sse({"type": "manifest",
"steps": [{"id": "draft", "label": "템플릿 드래프트 생성"}]})
stream_iter = paste_draft(
job["paste_state"],
video_scale=job.get("video_scale", 1.0),
flip_horizontal=job.get("flip", False),
scene_split=job.get("scene", False),
comments_dir=job.get("comments_dir", ""),
cards_fixed=job.get("cards_fixed", False),
card_cuts=job.get("card_cuts") or None,
bg_white=job.get("bg_white", False),
)
elif job.get("bg_state"): # ▶ 유튜브 구간 탭(새 흐름) — 이미 분석된 상태로 드래프트만
# paste_state 와 같은 이유로 draft 단독 manifest 를 여기서 새로 낸다.
yield _sse({"type": "manifest",
"steps": [{"id": "draft", "label": "템플릿 드래프트 생성"}]})
stream_iter = bg_draft(
job["bg_state"],
video_scale=job.get("video_scale", 1.0),
flip_horizontal=job.get("flip", False),
scene_split=job.get("scene", False),
comments_dir=job.get("comments_dir", ""),
cards_fixed=job.get("cards_fixed", False),
bg_white=job.get("bg_white", False),
comment_cards=job.get("comment_cards"),
)
elif job.get("paste"): # 붙여넣기(JSON) 모드
stream_iter = process_paste( stream_iter = process_paste(
job["paste"], job["draft_name"], job["paste"], job["draft_name"],
video_scale=job.get("video_scale", 1.0), video_scale=job.get("video_scale", 1.0),
@ -273,196 +251,33 @@ async def stream(job_id: str) -> StreamingResponse:
"X-Accel-Buffering": "no"}) "X-Accel-Buffering": "no"})
@app.post("/yt/analyze") @app.post("/yt/comments")
async def yt_analyze( async def yt_comments(url: str = Form(...), ranges: str = Form(...)) -> JSONResponse:
url: str = Form(...), """유튜브 구간 탭 — 구간 언급 댓글 매칭. 규칙은 자동 탭과 동일:
ranges: str = Form(...), = 어느 구간이든 언급(좋아요순), = : 없는 댓글(좋아요순), 전체 전송."""
title_top: str = Form(""),
title_main: str = Form(""),
channel: str = Form(""),
) -> JSONResponse:
"""▶ 유튜브 구간 탭(새 흐름) 1단계 — URL·구간 검증 후 분석 예약. 실제 작업은 /yt/stream 에서.
title_top/title_main/channel 여기서 받는다: bg_analyze title_main
Gemini 자막 교정 힌트로 쓰고, /yt/stream GET(SSE)이라 요청 본문이 없어
지금 받으면 넘길 곳이 없다. video_scale 렌더 전용 옵션은 /yt/build 에서.
"""
u = url.strip() u = url.strip()
if not (u.startswith("http://") or u.startswith("https://")): if not (u.startswith("http://") or u.startswith("https://")):
return JSONResponse({"error": "유튜브 주소를 입력하세요."}, 400) return JSONResponse({"error": "유튜브 주소를 입력하세요."}, 400)
rng = _parse_ranges(ranges) try:
rng = [(float(s), float(e)) for s, e in json.loads(ranges)]
rng = [(s, e) for s, e in rng if e > s]
except (json.JSONDecodeError, TypeError, ValueError):
rng = []
if not rng: if not rng:
return JSONResponse({"error": "구간을 하나 이상 입력하세요."}, 400) return JSONResponse({"error": "구간을 하나 이상 입력하세요."}, 400)
aid = hashlib.sha1(("yt|" + u + "|" + ranges).encode()).hexdigest()[:12]
ANALYSES[aid] = {"url": u, "ranges": rng, "title_top": title_top,
"title_main": title_main, "channel": channel}
return JSONResponse({"analysis_id": aid})
@app.get("/yt/stream/{aid}")
async def yt_stream(aid: str) -> StreamingResponse:
"""▶ 유튜브 구간 탭(새 흐름) — 구간 다운로드·무음·받아쓰기 → h-lab 댓글 매칭 → 검토 화면용 result."""
a = ANALYSES.get(aid)
async def gen():
if not a:
yield _sse({"type": "error", "message": "알 수 없는 분석입니다."})
return
url = a["url"]
ranges = a["ranges"]
youtube = {"url": url, "ranges": ranges}
warnings: list[str] = []
# draft 스텝은 이 단계(analyze)에서 안 돈다 — /yt/build 때 별도 스트림으로 실행되므로
# 여기 manifest 에 넣으면 영원히 start 가 안 와 화면에 대기 상태로 멈춰 보인다.
yield _sse({"type": "manifest", "steps":
[s for s in bg_steps(youtube) if s["id"] != "draft"] +
[{"id": "comments", "label": "댓글 수집 (h-lab)"},
{"id": "recommend", "label": "컷별 댓글 추천"}]})
com_task = asyncio.create_task(asyncio.to_thread(hlab.fetch_comments, url))
yield _sse({"type": "step", "id": "comments", "status": "start"})
state = None
try: try:
async for ev in bg_analyze(None, "yt_" + aid, comments = await asyncio.to_thread(hlab.fetch_comments, u)
title_top=a.get("title_top", ""),
title_main=a.get("title_main", ""),
channel=a.get("channel", ""),
youtube=youtube):
if ev.get("type") == "state": # 내부 전용 — 밖으로 흘리지 않는다
state = ev["state"]
continue
if ev.get("type") == "error":
com_task.cancel()
yield _sse(ev)
return
yield _sse(ev)
except Exception as exc: # noqa: BLE001 — 다운로드 등 실패는 드래프트 생성을 막는다
com_task.cancel()
yield _sse({"type": "error", "message": f"{type(exc).__name__}: {exc}"})
return
if state is None:
com_task.cancel()
yield _sse({"type": "error", "message": "분석 상태를 만들지 못했습니다."})
return
comments: list[dict] = []
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/captions = 압축 타임라인(카드·자막 추출용),
# ranges_sec = 원본 영상 시각(⭐ 분:초 매칭용). 섞으면 카드가 통째로 어긋난다.
# bg_analyze 의 무음 제거는 항상 켜져 있어 state["places"] 는 이미 압축 좌표다
# (_remap_placements 를 여기서 또 부르면 두 번 압축된다 — 부르지 않는다).
places = state["places"]
orig = state["ranges_sec"]
try:
cuts, need, ai_failed = await asyncio.to_thread(
recommend.cuts_from_state, places, orig, state["captions"], comments)
if ai_failed:
warnings.append("AI 추천 실패(Gemini 응답 없음) — 분:초·자막단어·좋아요로 배정했습니다")
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
# cuts=None(빈 리스트 아님)이어야 화면이 "컷 0개"로 비지 않고 ⭐/ 폴백으로 넘어간다. return JSONResponse({"error": f"h-lab 연결 실패: {exc}"}, 502)
cuts, need = None, max(1, int(state["total"] // 3)) total = sum(e - s for s, e in rng)
warnings.append(f"컷별 추천 실패 — 컷 정보 없이 진행합니다 ({type(exc).__name__}: {exc})") matched = hlab.match_ranges(comments, rng)
yield _sse({"type": "step", "id": "recommend", "status": "done",
"detail": (f"{len(cuts)}컷 · 카드 {need}" if cuts is not None
else f"폴백 · 카드 {need}")})
YSTATES[aid] = {"state": state}
no_ts = [c for c in comments if not c["times"]] no_ts = [c for c in comments if not c["times"]]
matched = hlab.match_ranges(comments, orig) if comments else [] return JSONResponse({
yield _sse({"type": "result", "cuts": cuts, "need": need, "need": max(1, int(total // 3)), "total": round(total, 1),
"total": round(state["total"], 1),
"cutRanges": [{"start": s, "end": e} for s, e in orig],
"matched": matched, "matched": matched,
"candidates": hlab.top_liked(no_ts, set(matched), len(no_ts)), "candidates": hlab.top_liked(no_ts, set(matched), len(no_ts)),
"comments": comments, "warnings": warnings, "comments": comments,
# 검토 화면 입력칸을 채우기 위한 확정값 — channel 은 비워 뒀어도 })
# bg_analyze 내부에서 유튜브 채널명으로 자동인식됐을 수 있다(state 에 반영됨).
"title_top": state["title_top"], "title_main": state["title_main"],
"channel": state["channel"]})
return StreamingResponse(gen(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache",
"X-Accel-Buffering": "no"})
@app.post("/yt/build")
async def yt_build(
aid: str = Form(...),
cards: list[UploadFile] = File(default=[]),
card_cuts: str = Form(""),
title_top: str = Form(""),
title_main: str = Form(""),
channel: str = Form(""),
video_scale: str = Form("100"),
flip: str = Form(""),
scene: str = Form(""),
bg_white: str = Form(""),
cards_fixed: str = Form(""),
comments_dir: str = Form(""),
) -> JSONResponse:
"""▶ 유튜브 구간 탭(새 흐름) 3단계 — 보관한 상태로 드래프트만 만든다(다운로드·받아쓰기 다시 안 함).
title_top/title_main/channel 여기서도 받아 state 덮어쓴다: 검토 화면은 분석
(/yt/analyze) 시점 값을 그대로 입력칸에 채워 보여주고(자동인식된 channel 포함) 여기서
고칠 있게 하는데, bg_draft title_*/channel 오직 state 에서만 읽으므로
(Task 6) 여기서 덮어쓰지 않으면 검토 화면에서 고친 값이 실제 드래프트에 반영되지
않는다 재분석(다운로드·ASR 재실행) 없이 오타만 고치는 필드들의 존재 이유다.
"""
st = YSTATES.get(aid)
if not st:
return JSONResponse({"error": "분석 결과가 만료됐습니다. 다시 분석해 주세요."}, 404)
state = st["state"]
state["title_top"] = title_top
state["title_main"] = title_main
state["channel"] = channel
# 카드별 소속 컷 — 값이 깨져도 빌드를 막지 않는다(없으면 전체 균등 배치로 폴백)
cut_map: list[int] = []
try:
parsed = json.loads(card_cuts) if card_cuts.strip() else []
if isinstance(parsed, list):
cut_map = [int(v) for v in parsed if isinstance(v, int) and not isinstance(v, bool)]
except (json.JSONDecodeError, ValueError, TypeError):
cut_map = []
sig = (aid + "|" + card_cuts + "|" + video_scale + "|" + flip + "|" + scene + "|"
+ bg_white + "|" + cards_fixed + "|" + str(len(cards)))
h = hashlib.sha1(sig.encode()).hexdigest()[:12]
# 카드가 오면 폴더 지정보다 우선(/paste/build 와 같은 규칙). 카드를 하나도 안 골랐으면
# 화면에서 넘어온 폴더 경로를 그대로 써서 예전(폴더 지정) 동작으로 하위호환한다.
cdir = comments_dir.strip()
if cards:
cdir = os.path.join(COMMENTS_DIR, h)
if os.path.isdir(cdir): # 재빌드 시 이전 카드 잔재 제거
shutil.rmtree(cdir, ignore_errors=True)
os.makedirs(cdir, exist_ok=True)
for i, f in enumerate(cards, 1):
body = await f.read()
with open(os.path.join(cdir, f"{i:03d}.png"), "wb") as out:
out.write(body)
# ⚠ state["places"] 는 bg_analyze 가 이미 무음 제거를 반영해 압축한 좌표다.
# 여기서 또 재매핑하지 않는다 — 카드 시간은 이 좌표를 그대로 컷 구간으로 쓴다.
cut_cards = _cards_by_cut(_card_paths(cdir), cut_map, state["places"], state["total"],
fixed=_truthy(cards_fixed))
JOBS[h] = {
"bg_state": state,
"video_scale": _scale(video_scale), "flip": _truthy(flip), "scene": _truthy(scene),
"comments_dir": cdir, "bg_white": _truthy(bg_white), "cards_fixed": _truthy(cards_fixed),
# cut_cards 가 비면 None → bg_draft 가 comments_dir 기준 전체 균등 배치로 폴백
# (/paste/build·paste_draft 의 `cards = cut_cards or _load_comment_cards(...)`와 동일 규칙).
"comment_cards": cut_cards or None,
}
return JSONResponse({"job_id": h})
@app.post("/auto/analyze") @app.post("/auto/analyze")
@ -500,13 +315,7 @@ async def auto_analyze(url: str = Form(""), mode: str = Form("full"),
@app.get("/auto/stream/{aid}") @app.get("/auto/stream/{aid}")
async def auto_stream(aid: str) -> StreamingResponse: async def auto_stream(aid: str) -> StreamingResponse:
"""자동 탭 1단계 분석 SSE: Step1 → (Step3 ×N) → result(편집안·타이틀 후보만). """자동 탭 분석 SSE: Step1 → (Step3 ×N ∥ 댓글) → result."""
댓글 매칭은 여기서 한다(Task 7) 검토 화면의 제외 뒤에 받아쓰기를 돌려야
낭비가 없으므로, 다운로드·받아쓰기·댓글 매칭은 전부 /auto/prepare 미뤘다.
`highlights` 여기서 만든 `paste`(편집안 JSON)·`titles`(제목 후보)까지만 담고,
/auto/prepare 그대로 꺼내 있게 `ANALYSES[aid]` 에도 남겨 둔다.
"""
a = ANALYSES.get(aid) a = ANALYSES.get(aid)
async def gen(): async def gen():
@ -515,6 +324,7 @@ async def auto_stream(aid: str) -> StreamingResponse:
return return
url = a["url"] url = a["url"]
mode = a.get("mode", "full") mode = a.get("mode", "full")
warnings: list[str] = []
highlights: list[dict] = [] highlights: list[dict] = []
def _need(total: float) -> int: def _need(total: float) -> int:
@ -526,16 +336,13 @@ async def auto_stream(aid: str) -> StreamingResponse:
def _whole_hl(c: dict) -> dict: def _whole_hl(c: dict) -> dict:
"""구간 통짜 하이라이트 — 컷 편집 없이 구간 전체가 컷 1개. """구간 통짜 하이라이트 — 컷 편집 없이 구간 전체가 컷 1개.
제목은 `editable_title` UI에서 직접 입력받되, 구간 JSON에 제목은 비워 두고 `editable_title` UI에서 직접 입력받는다.
title_top/title_main 있으면 값을 입력칸에 미리 채운다.
whole(Gemini Step1) wpaste(구간 JSON 붙여넣기) 공유.""" whole(Gemini Step1) wpaste(구간 JSON 붙여넣기) 공유."""
total = c["end"] - c["start"] total = c["end"] - c["start"]
return { return {
"id": c["id"], "start": c["start"], "end": c["end"], "id": c["id"], "start": c["start"], "end": c["end"],
"reason": c["reason"], "reason": c["reason"],
"paste": {"url": url, "paste": {"url": url, "title_top": "", "title_main": "",
"title_top": c.get("title_top") or "",
"title_main": c.get("title_main") or "",
"channel": "", "channel": "",
"cuts": [{"start": c["start"], "end": c["end"], "cuts": [{"start": c["start"], "end": c["end"],
"bottom": "", "effect": ""}]}, "bottom": "", "effect": ""}]},
@ -547,6 +354,8 @@ async def auto_stream(aid: str) -> StreamingResponse:
# ── 오팔 JSON 여러 개 — Gemini 안 씀 ── # ── 오팔 JSON 여러 개 — Gemini 안 씀 ──
yield _sse({"type": "manifest", "steps": [ yield _sse({"type": "manifest", "steps": [
{"id": "parse", "label": "오팔 JSON 파싱"}, {"id": "parse", "label": "오팔 JSON 파싱"},
{"id": "comments", "label": "댓글 수집 (h-lab)"},
{"id": "recommend", "label": "컷별 댓글 추천"},
]}) ]})
yield _sse({"type": "step", "id": "parse", "status": "start"}) yield _sse({"type": "step", "id": "parse", "status": "start"})
import re as _re import re as _re
@ -613,10 +422,14 @@ async def auto_stream(aid: str) -> StreamingResponse:
url = best_url url = best_url
yield _sse({"type": "step", "id": "parse", "status": "done", yield _sse({"type": "step", "id": "parse", "status": "done",
"detail": f"{len(highlights)}개 편집안"}) "detail": f"{len(highlights)}개 편집안"})
com_task = asyncio.create_task(asyncio.to_thread(hlab.fetch_comments, url))
yield _sse({"type": "step", "id": "comments", "status": "start"})
elif mode == "wpaste": elif mode == "wpaste":
# ── 구간 JSON 붙여넣기 — Gemini 안 씀. 구간 5개를 그대로 통짜로 ── # ── 구간 JSON 붙여넣기 — Gemini 안 씀. 구간 5개를 그대로 통짜로 ──
yield _sse({"type": "manifest", "steps": [ yield _sse({"type": "manifest", "steps": [
{"id": "parse", "label": "구간 JSON 파싱"}, {"id": "parse", "label": "구간 JSON 파싱"},
{"id": "comments", "label": "댓글 수집 (h-lab)"},
{"id": "recommend", "label": "컷별 댓글 추천"},
]}) ]})
yield _sse({"type": "step", "id": "parse", "status": "start"}) yield _sse({"type": "step", "id": "parse", "status": "start"})
try: try:
@ -627,16 +440,24 @@ async def auto_stream(aid: str) -> StreamingResponse:
highlights.extend(_whole_hl(c) for c in cands) highlights.extend(_whole_hl(c) for c in cands)
yield _sse({"type": "step", "id": "parse", "status": "done", yield _sse({"type": "step", "id": "parse", "status": "done",
"detail": f"{len(highlights)}개 구간"}) "detail": f"{len(highlights)}개 구간"})
com_task = asyncio.create_task(asyncio.to_thread(hlab.fetch_comments, url))
yield _sse({"type": "step", "id": "comments", "status": "start"})
else: else:
steps = [{"id": "step1", "label": "하이라이트 구간 선정 (Gemini)"}] steps = [{"id": "step1", "label": "하이라이트 구간 선정 (Gemini)"}]
if mode == "full": if mode == "full":
steps.append({"id": "step3", "label": "편집안 생성 (Gemini, 구간별 동시)"}) steps.append({"id": "step3", "label": "편집안 생성 (Gemini, 구간별 동시)"})
steps.append({"id": "comments", "label": "댓글 수집 (h-lab)"})
steps.append({"id": "recommend", "label": "컷별 댓글 추천"})
yield _sse({"type": "manifest", "steps": steps}) yield _sse({"type": "manifest", "steps": steps})
# 댓글은 URL을 이미 아니까 Step1 과 동시에 수집
com_task = asyncio.create_task(asyncio.to_thread(hlab.fetch_comments, url))
yield _sse({"type": "step", "id": "comments", "status": "start"})
# ── Step 1 ── # ── Step 1 ──
yield _sse({"type": "step", "id": "step1", "status": "start"}) yield _sse({"type": "step", "id": "step1", "status": "start"})
try: try:
cands = await asyncio.to_thread(autoplan.select_highlights, url) cands = await asyncio.to_thread(autoplan.select_highlights, url)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
com_task.cancel()
yield _sse({"type": "error", yield _sse({"type": "error",
"message": f"Step 1 실패 — {type(exc).__name__}: {exc}\n" "message": f"Step 1 실패 — {type(exc).__name__}: {exc}\n"
"오팔 → 📋 오팔 JSON 방식으로도 만들 수 있습니다."}) "오팔 → 📋 오팔 JSON 방식으로도 만들 수 있습니다."})
@ -695,167 +516,56 @@ async def auto_stream(aid: str) -> StreamingResponse:
yield _sse({"type": "step", "id": "step3", "status": "done", yield _sse({"type": "step", "id": "step3", "status": "done",
"detail": f"{ok}/{len(highlights)}개 성공"}) "detail": f"{ok}/{len(highlights)}개 성공"})
# 댓글 매칭·다운로드·받아쓰기는 여기서 안 한다(Task 7) — /auto/prepare 가
# 검토 화면에서 제외(✕)하지 않은 ID만 이어받아 돌린다. url 은 paste/wpaste 모드에서
# best_url 로 바뀌었을 수 있어(오팔 JSON 안 url) 여기서 다시 저장해 둔다.
a["highlights"] = highlights
a["url"] = url
yield _sse({"type": "result", "highlights": highlights})
return StreamingResponse(gen(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache",
"X-Accel-Buffering": "no"})
def _hl_paste_payload(paste: dict) -> dict:
"""하이라이트의 paste(화면용 dict 컷) → paste_analyze 가 받는 튜플 컷 payload.
highlights cuts 화면 표시용 dict({"start":,"end":,})인데 paste_analyze
parse_paste 출력형 [(s,e,bottom,effect)] 튜플을 기대한다. /auto/build 프런트가
되보낸 JSON parse_paste 재파싱해 변환을 공짜로 얻었지만, /auto/prepare
서버 보관본을 직접 쓰므로 여기서 변환해야 한다 dict 그대로 넘기면 언패킹이
문자열("start","end",) 풀어 다운로드가 시간 형식 오류로 전면 실패한다.
"""
cuts = [(float(c["start"]), float(c["end"]),
c.get("bottom") or "", c.get("effect") or "")
for c in paste["cuts"]]
return {**paste, "cuts": cuts}
@app.post("/auto/prepare")
async def auto_prepare(aid: str = Form(...), ids: str = Form(...)) -> JSONResponse:
"""자동 탭 2단계 — 검토 화면에서 제외하지 않은 ID만 준비 예약. 실제 작업은 /auto/prepare/{pid} 에서.
ids: 남길 하이라이트 id의 JSON 배열(: [1,2,4]) 제외된 ID는 여기 들어온다.
"""
a = ANALYSES.get(aid)
if not a or not a.get("highlights"):
return JSONResponse({"error": "분석 결과가 만료됐습니다. 다시 분석해 주세요."}, 404)
try:
id_list = json.loads(ids)
if not isinstance(id_list, list) or not id_list:
raise ValueError
except (json.JSONDecodeError, ValueError):
return JSONResponse({"error": "준비할 ID 목록이 올바르지 않습니다."}, 400)
pid = hashlib.sha1((aid + "|" + ids).encode()).hexdigest()[:12]
PREPARES[pid] = {"aid": aid, "ids": id_list}
return JSONResponse({"prepare_id": pid})
@app.get("/auto/prepare/{pid}")
async def auto_prepare_stream(pid: str) -> StreamingResponse:
"""자동 탭 2단계 SSE — 남은 ID만 순차로 다운로드·받아쓰기 + 댓글 매칭.
순차로 돌린다(전역 방침) yt-dlp·ffmpeg·Whisper CPU 써서 동시에 여러 개를
돌리면 서로 느려지기만 한다. ID 하나 실패는 ID만 건너뛰고 나머지는 계속 진행한다
(다운로드 실패 말고는 드래프트 생성을 막지 않는다는 전역 원칙과 같은 이유).
"""
p = PREPARES.get(pid)
async def gen():
if not p:
yield _sse({"type": "error", "message": "알 수 없는 준비 요청입니다."})
return
aid = p["aid"]
a = ANALYSES.get(aid)
if not a or not a.get("highlights"):
yield _sse({"type": "error", "message": "분석 결과가 만료됐습니다. 다시 분석해 주세요."})
return
by_id = {h["id"]: h for h in a["highlights"] if "paste" in h}
targets = [by_id[i] for i in p["ids"] if i in by_id]
if not targets:
yield _sse({"type": "error", "message": "준비할 편집안이 없습니다."})
return
url = a.get("url", "")
warnings: list[str] = []
yield _sse({"type": "manifest", "steps": [
{"id": "comments", "label": "댓글 수집 (h-lab)"},
{"id": "prepare", "label": "ID별 순차 준비 (다운로드·받아쓰기)"},
]})
yield _sse({"type": "step", "id": "comments", "status": "start"})
comments: list[dict] = [] comments: list[dict] = []
try: try:
comments = await asyncio.to_thread(hlab.fetch_comments, url) comments = await com_task
yield _sse({"type": "step", "id": "comments", "status": "done", yield _sse({"type": "step", "id": "comments", "status": "done",
"detail": f"{len(comments)}"}) "detail": f"{len(comments)}"})
except Exception as exc: # noqa: BLE001 — 댓글 실패는 진행을 막지 않는다 except Exception as exc: # noqa: BLE001
warnings.append(f"h-lab 연결 실패 — 댓글 없이 진행합니다 ({exc})") warnings.append(f"h-lab 연결 실패 — 댓글 없이 진행합니다 ({exc})")
yield _sse({"type": "step", "id": "comments", "status": "done", yield _sse({"type": "step", "id": "comments", "status": "done",
"detail": "실패(생략)"}) "detail": "실패(생략)"})
yield _sse({"type": "step", "id": "prepare", "status": "start"}) # 댓글 매칭 — 전체 전송, 브라우저가 '더보기'로 30장씩 나눠 그린다.
highlights_out: list[dict] = [] # 후보(candidates)는 분:초 언급이 아예 없는 댓글만 — 타임스탬프 댓글은
all_ranges: list[tuple] = [] # 자기 구간의 ⭐에서 잡히므로, 다른 구간 얘기하는 댓글이 섞이지 않게.
# ⚠ 순차로 돌려라 — yt-dlp·ffmpeg·Whisper 가 CPU 를 다 쓴다(전역 방침). #
for h in targets: # 하이라이트마다 Gemini 를 순차로 부른다(동시 호출은 429 를 부른다 — Step 3 도 같은 이유로
hid = h["id"] # 시차 재시도를 쓴다). 최악 5×90초라 진행 표시가 없으면 사용자가 멈춘 줄 알고 새로고침해
state = None # 분석이 통째로 날아간다 → 스텝 + 하이라이트별 로그를 반드시 흘린다.
failed = False no_ts = [c for c in comments if not c["times"]]
# ⚠ paste_analyze 내부에서 draft_name = _safe_name(title) or draft_name 로 targets = [h for h in highlights if "paste" in h]
# 영상 제목이 항상 draft_name 을 덮어쓴다(_safe_name 은 거의 항상 truthy) — yield _sse({"type": "step", "id": "recommend", "status": "start"})
# 여기서 넘기는 f"auto_{aid}_{hid}" 는 그래서 절대 안 쓰인다. 같은 URL(한 aid)의 t_rec = time.perf_counter()
# 하이라이트 5개는 제목이 똑같으니 name_suffix 없이는 draft_name 이 전부 같아져 for n, h in enumerate(targets, 1):
# build_bg_template_draft 의 allow_replace=True 가 뒤엣것으로 앞을 덮어쓴다 matched = hlab.match_window(comments, h["start"], h["end"])
# (옛 /auto/build 의 tag→name_suffix 메커니즘 — 여기서도 반드시 넘겨야 한다). h["matched"] = matched
safe_tag = "".join(ch for ch in f"하이라이트{hid}" if ch.isalnum() or ch in "-_")[:20] h["candidates"] = hlab.top_liked(no_ts, set(matched), len(no_ts))
payload = _hl_paste_payload(h["paste"]) # dict 컷 → 튜플 컷 (필수 — docstring 참고) yield _sse({"type": "log",
try: "msg": f"ID {h.get('id')} 컷별 댓글 추천 중… "
async for ev in paste_analyze(payload, f"auto_{aid}_{hid}", f"({n}/{len(targets)}, 컷 "
remove_silence=True, asr_bottom=True, f"{len(h['paste'].get('cuts') or [])}개)"})
name_suffix=safe_tag): # 컷별 추천 — 실패해도 위의 matched/candidates 로 화면이 돌아간다.
if ev.get("type") == "state": # 내부 전용 — 밖으로 흘리지 않는다 # Gemini 호출이 섞여 있어 블로킹이므로 스레드로 뺀다.
state = ev["state"]
continue
if ev.get("type") == "log":
yield _sse({"type": "log", "msg": f"ID {hid}: {ev.get('msg')}"})
continue
yield _sse(ev) # step(download/asr) — manifest 밖 id 는 화면에서 무시됨
except Exception as exc: # noqa: BLE001 — 이 ID만 건너뛰고 나머지는 계속
failed = True
warnings.append(f"ID {hid} 준비 실패 — {type(exc).__name__}: {exc}")
yield _sse({"type": "log", "msg": f"ID {hid} 실패: {type(exc).__name__}: {exc}"})
if failed or state is None:
if not failed:
warnings.append(f"ID {hid} 준비 실패 — 분석 상태를 만들지 못했습니다")
continue
# ⚠ 좌표계 둘: places = 압축 타임라인(카드·자막 추출용),
# orig = 원본 영상 시각(⭐ 분:초 매칭용). 섞으면 카드가 통째로 어긋난다.
places = state["card_places"]
orig = [(s, e) for s, e, _, _ in state["cuts"]]
all_ranges.extend(orig)
try: try:
cuts, need, ai_failed = await asyncio.to_thread( cuts, need, ai_failed = await asyncio.to_thread(
recommend.cuts_from_state, places, orig, state["bottom_caps"], comments) recommend.build_highlight_cuts, h, comments)
if cuts:
h["cuts"], h["need"] = cuts, need
if ai_failed: if ai_failed:
warnings.append(f"ID {hid} AI 추천 실패(Gemini 응답 없음) — " # cuts 는 타임스탬프만으로 채워져 비지 않는다 — 이 신호가 없으면
"분:초·자막단어·좋아요로 배정했습니다") # 429·타임아웃이 조용히 삼켜져 아무도 모른다.
except Exception as exc: # noqa: BLE001 warnings.append(
cuts, need = None, max(1, int(state["timeline_dur"] // 3)) f"ID {h.get('id')} AI 추천 실패 — 타임스탬프만으로 배정했습니다")
warnings.append(f"ID {hid} 컷별 추천 실패 — 컷 정보 없이 진행합니다 " except Exception as exc: # noqa: BLE001 — 추천 실패가 생성을 막으면 안 된다
warnings.append(
f"ID {h.get('id')} 컷별 추천 실패 — 기존 방식으로 표시 "
f"({type(exc).__name__}: {exc})") f"({type(exc).__name__}: {exc})")
yield _sse({"type": "step", "id": "recommend", "status": "done",
PSTATES[f"{aid}:{hid}"] = {"state": state, "places": places, "orig": orig, "elapsed": round(time.perf_counter() - t_rec, 1),
"payload": payload} "detail": f"{len(targets)}개 하이라이트"})
matched = hlab.match_ranges(comments, orig) if comments else [] yield _sse({"type": "result", "highlights": highlights,
highlights_out.append({ "comments": comments, "warnings": warnings})
"id": hid, "cuts": cuts, "need": need,
"cutRanges": [{"start": s, "end": e} for s, e in orig],
"matched": matched,
})
yield _sse({"type": "log", "msg": f"ID {hid} 준비 완료"})
yield _sse({"type": "step", "id": "prepare", "status": "done",
"detail": f"{len(highlights_out)}/{len(targets)}개 완료"})
no_ts = [c for c in comments if not c["times"]]
matched_all = hlab.match_ranges(comments, all_ranges) if all_ranges else []
yield _sse({"type": "result", "highlights": highlights_out,
"comments": comments,
"candidates": hlab.top_liked(no_ts, set(matched_all), len(no_ts)),
"warnings": warnings})
return StreamingResponse(gen(), media_type="text/event-stream", return StreamingResponse(gen(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache", headers={"Cache-Control": "no-cache",
@ -912,224 +622,32 @@ async def prompts_post(step1: str = Form(None), step3: str = Form(None),
@app.post("/auto/build") @app.post("/auto/build")
async def auto_build( async def auto_build(
aid: str = Form(...), data: str = Form(...),
hid: str = Form(..., alias="id"), tag: str = Form(""),
title_top: str = Form(""),
title_main: str = Form(""),
cards: list[UploadFile] = File(default=[]), cards: list[UploadFile] = File(default=[]),
card_cuts: str = Form(""),
video_scale: str = Form("144"), video_scale: str = Form("144"),
flip: str = Form(""), flip: str = Form(""),
scene: str = Form(""), scene: str = Form(""),
bg_white: str = Form(""), bg_white: str = Form(""),
remove_silence: str = Form(""),
asr_bottom: str = Form(""),
cards_fixed: str = Form(""), cards_fixed: str = Form(""),
asr_bottom: str = Form("1"), card_cuts: str = Form(""),
comments_dir: str = Form(""),
) -> JSONResponse: ) -> JSONResponse:
"""자동 탭 3단계 — /auto/prepare 가 PSTATES 에 보관한 상태로 드래프트만 만든다. """자동 탭 빌드 — 붙여넣기 스키마 JSON + 카드 PNG들 → 기존 paste job.
다운로드·받아쓰기는 다시 하지 않는다(/paste/build 같은 방식) aid+id tag(: '하이라이트1') 드래프트 이름 꼬리표 같은 영상 5개가 서로
PSTATES[f"{aid}:{id}"] 찾아 paste_draft 돌린다. title_top/title_main 덮어쓰는 것을 막는다. 진행은 기존 /stream/{job_id} 본다.
검토 화면(1)에서 고른 값을 여기서 받아 state 덮어쓴다.
""" """
key = f"{aid}:{hid}"
st = PSTATES.get(key)
if not st:
return JSONResponse({"error": "준비 결과가 만료됐습니다. 다시 준비해 주세요."}, 404)
state = st["state"]
state["title_top"] = title_top
state["title_main"] = title_main
# asr_bottom을 끈 경우 — 화면 자막을 받아쓰기 결과가 아니라 원래 JSON bottom으로
# 되돌린다(/paste/build 와 동일 규칙 — state["bottom_caps"]는 /auto/prepare 가
# 항상 asr_bottom=True 로 돈 결과라 받아쓰기로 덮여 있다).
if not _truthy(asr_bottom):
dur = state["dur"]
bottom_caps = [(p0, min(p1, dur), b) for (p0, p1), (_, _, b, _)
in zip(state["placements"], state["cuts"]) if b and p0 < dur]
bottom_caps = _remap_caps(bottom_caps, state["video_clips"])
state = {**state, "bottom_caps": bottom_caps}
# 카드별 소속 컷 — 값이 깨져도 빌드를 막지 않는다(없으면 전체 균등 배치로 폴백)
cut_map: list[int] = []
try:
parsed = json.loads(card_cuts) if card_cuts.strip() else []
if isinstance(parsed, list):
cut_map = [int(v) for v in parsed if isinstance(v, int) and not isinstance(v, bool)]
except (json.JSONDecodeError, ValueError, TypeError):
cut_map = []
sig = (key + "|" + card_cuts + "|" + video_scale + "|" + flip + "|" + scene + "|"
+ bg_white + "|" + cards_fixed + "|" + asr_bottom + "|" + str(len(cards)))
h = hashlib.sha1(sig.encode()).hexdigest()[:12]
cdir = comments_dir.strip()
if cards:
cdir = os.path.join(COMMENTS_DIR, h)
if os.path.isdir(cdir): # 재빌드 시 이전 카드 잔재 제거
shutil.rmtree(cdir, ignore_errors=True)
os.makedirs(cdir, exist_ok=True)
for i, f in enumerate(cards, 1):
body = await f.read()
with open(os.path.join(cdir, f"{i:03d}.png"), "wb") as out:
out.write(body)
JOBS[h] = {
"paste_state": state, "card_cuts": cut_map,
"video_scale": _scale(video_scale), "flip": _truthy(flip), "scene": _truthy(scene),
"comments_dir": cdir, "bg_white": _truthy(bg_white), "cards_fixed": _truthy(cards_fixed),
}
return JSONResponse({"job_id": h})
@app.post("/paste/analyze")
async def paste_analyze_start(data: str = Form(...)) -> JSONResponse:
"""📋 붙여넣기 탭(새 흐름) 1단계 — 편집안 검증 후 분석 예약. 실제 작업은 /paste/stream 에서."""
try: try:
payload = parse_paste(data) payload = parse_paste(data)
except ValueError as e: except ValueError as e:
return JSONResponse({"error": str(e)}, 400) return JSONResponse({"error": str(e)}, 400)
aid = hashlib.sha1(("paste|" + data).encode()).hexdigest()[:12] safe_tag = "".join(ch for ch in tag if ch.isalnum() or ch in "-_")[:20]
ANALYSES[aid] = {"payload": payload} sig = (payload["url"] + "|" + safe_tag + "|"
return JSONResponse({"analysis_id": aid}) + "|".join(f"{s:.3f}-{e:.3f}" for s, e, _, _ in payload["cuts"]))
@app.get("/paste/stream/{aid}")
async def paste_stream(aid: str) -> StreamingResponse:
"""📋 붙여넣기 탭(새 흐름) — 다운로드·무음·받아쓰기 → h-lab 댓글 매칭 → 검토 화면용 result.
추천을 하려면 받아쓰기가 항상 필요하므로 remove_silence·asr_bottom 여기서
True 고정한다. 사용자가 화면에서 끄는 asr_bottom 옵션은 화면 자막에만
적용되며 /paste/build 단계에서 처리한다.
"""
a = ANALYSES.get(aid)
async def gen():
if not a:
yield _sse({"type": "error", "message": "알 수 없는 분석입니다."})
return
payload = a["payload"]
warnings: list[str] = []
# draft 스텝은 이 단계(analyze)에서 안 돈다 — /paste/build 때 별도 스트림으로 실행되므로
# 여기 manifest 에 넣으면 영원히 start 가 안 와 화면에 대기 상태로 멈춰 보인다.
yield _sse({"type": "manifest", "steps":
[s for s in pipeline_paste_steps(True) if s["id"] != "draft"] +
[{"id": "comments", "label": "댓글 수집 (h-lab)"},
{"id": "recommend", "label": "컷별 댓글 추천"}]})
com_task = asyncio.create_task(
asyncio.to_thread(hlab.fetch_comments, payload["url"]))
yield _sse({"type": "step", "id": "comments", "status": "start"})
state = None
try:
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)
except Exception as exc: # noqa: BLE001 — 다운로드 등 실패는 드래프트 생성을 막는다
com_task.cancel()
yield _sse({"type": "error", "message": f"{type(exc).__name__}: {exc}"})
return
if state is None:
com_task.cancel()
yield _sse({"type": "error", "message": "분석 상태를 만들지 못했습니다."})
return
comments: list[dict] = []
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/captions = 압축 타임라인(카드·자막 추출용),
# orig = 원본 영상 시각(⭐ 분:초 매칭용). 섞으면 카드가 통째로 어긋난다.
places = state["card_places"]
orig = [(s, e) for s, e, _, _ in state["cuts"]]
# 예상 밖 예외가 나면(감싸지 않았을 때) SSE 가 error 이벤트도 없이 끊기고
# PSTATES 도 안 남아 build 가 404 난다 — 추천 실패가 검토 화면 자체를 막으면 안 된다.
try:
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 응답 없음) — 분:초·자막단어·좋아요로 배정했습니다")
except Exception as exc: # noqa: BLE001
# cuts=None(빈 리스트 아님)이어야 화면이 "컷 0개"로 비지 않고 ⭐/ 폴백
# 화면(자동/구간 탭과 동일한 hl.cuts 없음 경로)으로 넘어간다.
cuts, need = None, max(1, int(state["timeline_dur"] // 3))
warnings.append(f"컷별 추천 실패 — 컷 정보 없이 진행합니다 ({type(exc).__name__}: {exc})")
yield _sse({"type": "step", "id": "recommend", "status": "done",
"detail": (f"{len(cuts)}컷 · 카드 {need}" if cuts is not None
else f"폴백 · 카드 {need}")})
PSTATES[aid] = {"state": state, "places": places, "orig": orig, "payload": payload}
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})
return StreamingResponse(gen(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache",
"X-Accel-Buffering": "no"})
@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"),
comments_dir: str = Form(""),
) -> JSONResponse:
"""📋 붙여넣기 탭(새 흐름) 3단계 — 보관한 상태로 드래프트만 만든다(다운로드·받아쓰기 다시 안 함)."""
st = PSTATES.get(aid)
if not st:
return JSONResponse({"error": "분석 결과가 만료됐습니다. 다시 분석해 주세요."}, 404)
state = st["state"]
# asr_bottom을 끈 경우 — 화면 자막을 받아쓰기 결과가 아니라 원래 JSON bottom으로
# 되돌린다. state["bottom_caps"]는 analyze가 항상 asr_bottom=True로 돈 결과라
# 받아쓰기로 덮여 있다 — paste_analyze가 하던 것과 같은 식으로 원본 JSON 자막을
# 다시 만들고, 무음 제거로 압축된 타임라인에 맞춰 _remap_caps로 재매핑한다.
if not _truthy(asr_bottom):
dur = state["dur"]
bottom_caps = [(p0, min(p1, dur), b) for (p0, p1), (_, _, b, _)
in zip(state["placements"], state["cuts"]) if b and p0 < dur]
bottom_caps = _remap_caps(bottom_caps, state["video_clips"])
state = {**state, "bottom_caps": bottom_caps}
# 카드별 소속 컷 — 값이 깨져도 빌드를 막지 않는다(없으면 전체 균등 배치)
cut_map: list[int] = []
try:
parsed = json.loads(card_cuts) if card_cuts.strip() else []
if isinstance(parsed, list):
cut_map = [int(v) for v in parsed if isinstance(v, int) and not isinstance(v, bool)]
except (json.JSONDecodeError, ValueError, TypeError):
cut_map = []
sig = (aid + "|" + card_cuts + "|" + video_scale + "|" + flip + "|" + scene + "|"
+ bg_white + "|" + cards_fixed + "|" + asr_bottom + "|" + str(len(cards)))
h = hashlib.sha1(sig.encode()).hexdigest()[:12] h = hashlib.sha1(sig.encode()).hexdigest()[:12]
# 카드가 오면 폴더 지정보다 우선(/youtube 와 같은 규칙). 카드를 하나도 안 골랐으면 cdir = ""
# 화면에서 넘어온 폴더 경로를 그대로 써서 예전(폴더 지정) 동작으로 하위호환한다.
cdir = comments_dir.strip()
if cards: if cards:
cdir = os.path.join(COMMENTS_DIR, h) cdir = os.path.join(COMMENTS_DIR, h)
if os.path.isdir(cdir): # 재빌드 시 이전 카드 잔재 제거 if os.path.isdir(cdir): # 재빌드 시 이전 카드 잔재 제거
@ -1139,13 +657,25 @@ async def paste_build(
body = await f.read() body = await f.read()
with open(os.path.join(cdir, f"{i:03d}.png"), "wb") as out: with open(os.path.join(cdir, f"{i:03d}.png"), "wb") as out:
out.write(body) out.write(body)
# 카드별 소속 컷 — 값이 깨져도 빌드를 막지 않는다(없으면 기존 전체 균등 배치)
cut_map: list[int] = []
try:
parsed = json.loads(card_cuts) if card_cuts.strip() else []
if isinstance(parsed, list):
cut_map = [int(v) for v in parsed if isinstance(v, int) and not isinstance(v, bool)]
except (json.JSONDecodeError, ValueError, TypeError):
cut_map = []
JOBS[h] = { JOBS[h] = {
"paste_state": state, "card_cuts": cut_map, "paste": payload, "draft_name": f"auto_{h}",
"video_scale": _scale(video_scale), "flip": _truthy(flip), "scene": _truthy(scene), "video_scale": _scale(video_scale), "flip": _truthy(flip),
"comments_dir": cdir, "bg_white": _truthy(bg_white), "cards_fixed": _truthy(cards_fixed), "scene": _truthy(scene), "comments_dir": cdir,
"bg_white": _truthy(bg_white), "remove_silence": _truthy(remove_silence),
"asr_bottom": _truthy(asr_bottom), "name_suffix": safe_tag,
"cards_fixed": _truthy(cards_fixed),
"card_cuts": cut_map,
} }
return JSONResponse({"job_id": h}) return JSONResponse({"job_id": h, "cuts": len(payload["cuts"]),
"cards": len(cards)})
@app.post("/open-capcut") @app.post("/open-capcut")

File diff suppressed because it is too large Load Diff

View File

@ -157,24 +157,13 @@
.cc-meta{flex:1;min-width:0;} .cc-meta{flex:1;min-width:0;}
.cc-author{font-weight:600;font-size:13.5px;color:#fff;} .cc-author{font-weight:600;font-size:13.5px;color:#fff;}
.cc-time{font-size:11.5px;color:#aaa;margin-left:6.4px;} .cc-time{font-size:11.5px;color:#aaa;margin-left:6.4px;}
/* 본문 최대 5줄 말줄임(…) — 카드는 캡처돼 드래프트 하단(영상 아래~출처 사이 약 640px)에 .cc-text{font-size:14px;color:#fff;margin-top:5.6px;white-space:pre-wrap;word-break:break-word;line-height:1.5;}
들어가는데, 긴 댓글은 세로가 무한정 자라 출처(@채널)를 덮는다. 5줄이면 표시 높이가
그 한계 안이고, 검토 화면 = 캡처 결과(WYSIWYG)라 여기서 자르는 게 맞다. */
.cc-text{font-size:14px;color:#fff;margin-top:5.6px;white-space:pre-wrap;word-break:break-word;line-height:1.5;
display:-webkit-box;-webkit-line-clamp:5;-webkit-box-orient:vertical;overflow:hidden;}
.cc-stats{font-size:12px;color:#aaa;margin-top:8px;display:flex;gap:16px;font-variant-numeric:tabular-nums;} .cc-stats{font-size:12px;color:#aaa;margin-top:8px;display:flex;gap:16px;font-variant-numeric:tabular-nums;}
.comment-card.mosaic .cc-avatar{filter:blur(6px);} .comment-card.mosaic .cc-avatar{filter:blur(6px);}
.comment-card.mosaic .cc-author{filter:blur(5px);} .comment-card.mosaic .cc-author{filter:blur(5px);}
/* 흰 카드 테마 — 배경을 검정으로 만들 때(흰색 배경 체크 해제) 캡처 직전에만 붙는다.
검은 카드는 검은 배경에 묻혀 윤곽이 안 보이므로 캡처용으로 반전(흰 바탕 + 짙은 글씨).
검토 화면 표시는 어두운 테마 그대로 — captureCard() 가 붙였다 뗀다. */
.comment-card.light{background:#fff;border-color:#ddd;}
.comment-card.light .cc-author{color:#222;}
.comment-card.light .cc-text{color:#111;}
.comment-card.light .cc-time,.comment-card.light .cc-stats{color:#888;}
/* 검토 화면 — 본문 600px 유지, 검토 영역만 와이드 브레이크아웃(댓글 카드 3~4열) */ /* 검토 화면 — 본문 600px 유지, 검토 영역만 와이드 브레이크아웃(댓글 카드 3~4열) */
#autoPickReview,#autoReview,#autoSummary,#ytReview,#ytSummary,#pasteReview,#pasteSummary{ #autoReview,#autoSummary,#ytccArea{--rw:min(1240px,calc(100vw - 40px));
--rw:min(1240px,calc(100vw - 40px));width:var(--rw);margin-left:calc((100% - var(--rw))/2);} width:var(--rw);margin-left:calc((100% - var(--rw))/2);}
/* ID 탭 바 — 스크롤해도 위에 붙어 있음 */ /* ID 탭 바 — 스크롤해도 위에 붙어 있음 */
.idtabs{position:sticky;top:0;z-index:40;display:flex;gap:6px;flex-wrap:wrap; .idtabs{position:sticky;top:0;z-index:40;display:flex;gap:6px;flex-wrap:wrap;
padding:10px 0;background:var(--bg);margin-top:10px;} padding:10px 0;background:var(--bg);margin-top:10px;}
@ -210,15 +199,6 @@
.hlbox select{background:var(--surf2);border:1px solid var(--border);border-radius:7px; .hlbox select{background:var(--surf2);border:1px solid var(--border);border-radius:7px;
color:var(--text);font-size:12.5px;padding:8px 10px;max-width:100%;} color:var(--text);font-size:12.5px;padding:8px 10px;max-width:100%;}
.hlbox .hlprog{margin-top:8px;font-family:var(--mono);font-size:12px;color:var(--muted2);white-space:pre-line;} .hlbox .hlprog{margin-top:8px;font-family:var(--mono);font-size:12px;color:var(--muted2);white-space:pre-line;}
/* ── 1차 검토(하이라이트 카드) — 댓글 없이 제목만 고르는 화면. .hlbox 를 그대로 쓰되
탭 전환 없이 세로로 쌓아 보여준다(하이라이트가 5개뿐이라 전부 보여도 부담 없음). ── */
.pickcard{margin-top:10px;display:block;}
.pickcard:first-child{margin-top:4px;}
.pickhead{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:6px;}
.pickx{width:auto;margin:0 0 0 auto;padding:6px 12px;flex:none;}
.pickcard.dropped-pick{opacity:.5;border-style:dashed;}
.pickcard.dropped-pick .pickx{color:var(--accent);}
.pickcard.errbox{border-color:var(--danger);}
/* ── 영상 편집안(JSON 컷) — 기본 접힘, 클릭해 펼침 ── */ /* ── 영상 편집안(JSON 컷) — 기본 접힘, 클릭해 펼침 ── */
.cutsbox{margin-top:12px;border:1px solid var(--border);border-radius:9px; .cutsbox{margin-top:12px;border:1px solid var(--border);border-radius:9px;
background:var(--surf2);overflow:hidden;} background:var(--surf2);overflow:hidden;}
@ -256,21 +236,6 @@
.cutraw pre{margin:0;padding:11px 13px;background:#080A0E;font-family:var(--mono);font-size:11px; .cutraw pre{margin:0;padding:11px 13px;background:#080A0E;font-family:var(--mono);font-size:11px;
line-height:1.55;color:var(--muted2);max-height:280px;overflow:auto;} line-height:1.55;color:var(--muted2);max-height:280px;overflow:auto;}
.cutcopy{margin:0 13px 11px;width:auto;min-height:34px;padding:6px 12px;} .cutcopy{margin:0 13px 11px;width:auto;min-height:34px;padding:6px 12px;}
/* ── 컷별 카드 아코디언 — 한 번에 한 컷만 펼쳐 후보 카드가 한꺼번에 쌓이지 않게 한다 ── */
.cutgrp{margin-top:10px;border:1px solid var(--border);border-radius:9px;
background:var(--surf2);overflow:hidden;}
.cutgrp-head{width:100%;box-sizing:border-box;display:flex;align-items:center;gap:8px;
text-align:left;cursor:pointer;background:transparent;border:0;color:var(--muted2);
padding:11px 13px;min-height:44px;font-size:12.5px;font-weight:600;transition:background .15s,color .15s;}
.cutgrp-head:hover{color:var(--text);background:var(--surf);}
.cutgrp.open>.cutgrp-head{color:var(--text);border-bottom:1px solid var(--border);}
.cutgrp-head .chev{flex:none;color:var(--muted);font-size:10px;transition:transform .18s ease;}
.cutgrp.open .chev{transform:rotate(90deg);}
.cutgrp-head .cgstat{margin-left:auto;flex:none;font-family:var(--mono);font-size:11.5px;
color:var(--muted);font-weight:400;}
.cutgrp.open .cgstat{color:var(--accent);}
.cutgrp-body{display:none;padding:0 13px 12px;}
.cutgrp.open .cutgrp-body{display:block;}
/* 선택한 댓글 요약 바 */ /* 선택한 댓글 요약 바 */
.selbar{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin-top:10px; .selbar{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin-top:10px;
padding:10px;background:#101010;border:1px solid var(--border);border-radius:8px;min-height:44px;} padding:10px;background:#101010;border:1px solid var(--border);border-radius:8px;min-height:44px;}
@ -397,23 +362,10 @@
<button type="button" id="addrng" <button type="button" id="addrng"
style="margin-top:8px;width:100%;padding:9px;background:transparent;color:var(--accent);border:1px dashed var(--accent);border-radius:8px;cursor:pointer;font-size:13px;"> 구간 추가</button> style="margin-top:8px;width:100%;padding:9px;background:transparent;color:var(--accent);border:1px dashed var(--accent);border-radius:8px;cursor:pointer;font-size:13px;"> 구간 추가</button>
<div class="note" style="margin-top:8px;">여러 구간을 넣으면 순서대로 이어붙여 하나의 캡컷 드래프트로 만듭니다. 형식: 분:초(03:30) 또는 시:분:초(01:03:30)</div> <div class="note" style="margin-top:8px;">여러 구간을 넣으면 순서대로 이어붙여 하나의 캡컷 드래프트로 만듭니다. 형식: 분:초(03:30) 또는 시:분:초(01:03:30)</div>
<button type="button" class="ghost" id="ytccBtn" style="margin-top:12px;">💬 구간 댓글 매칭 (h-lab · 선택사항)</button>
<div class="note" id="ytccNote" style="margin-top:4px;">구간을 언급한 댓글을 자동 선택하고, 나머지는 좋아요순으로 보여줍니다. 선택한 카드는 편집 시작 때 자동으로 들어갑니다(폴더 지정보다 우선).</div>
</div> </div>
<!-- 제목·출처 — 검토 영역 위. 분석에 시간이 걸리므로 분석 후 오타를 발견해도 <div id="ytccArea"></div>
재분석 없이 여기서 고치면 생성(드래프트 만들기)에 그대로 반영된다. -->
<div class="card" style="padding:18px;margin-top:10px;">
<div class="row2">
<div class="field"><label>제목 윗줄 · 주황색</label><input id="yttop" placeholder="서브제목" /></div>
<div class="field"><label>출처 (선택)</label><input id="ytchan" placeholder="유튜브면 채널명 자동" /></div>
</div>
<div class="field"><label>제목 아랫줄 · 흰색</label><input id="ytmain" placeholder="메인제목" /></div>
<div class="note" style="margin-top:2px;">비워두면 그 텍스트는 안 들어가요. 분석이 끝난 뒤에도 고칠 수 있고, 드래프트를 만들 때 여기 값이 반영됩니다.</div>
</div>
<div id="ytSteps"></div>
<div id="ytLog"></div>
<div id="ytReview"></div>
<button class="run" id="ytBuild" style="display:none;margin-top:14px;">선택한 카드로 드래프트 만들기</button>
<div id="ytBoard" style="display:none;margin-top:10px;"></div>
<div id="ytSummary" class="card" style="display:none;padding:18px;margin-top:12px;"></div>
</div> </div>
<!-- 붙여넣기(JSON) 모드 --> <!-- 붙여넣기(JSON) 모드 -->
@ -426,16 +378,8 @@
style="width:100%;box-sizing:border-box;background:var(--surf2);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:11px;font-family:var(--mono);font-size:12.5px;line-height:1.5;resize:vertical;" style="width:100%;box-sizing:border-box;background:var(--surf2);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:11px;font-family:var(--mono);font-size:12.5px;line-height:1.5;resize:vertical;"
placeholder='{&#10; "url": "https://www.youtube.com/watch?v=…",&#10; "title_top": "서브제목", "title_main": "메인제목", "channel": "@채널",&#10; "cuts": [&#10; {"start":"0:01.0","end":"0:03.5","bottom":"하단 자막\n두 줄","effect":"광속하강"},&#10; {"start":"2:33.5","end":"2:36.5","bottom":"다음 컷 자막","effect":"공포의통계"}&#10; ]&#10;}'></textarea> placeholder='{&#10; "url": "https://www.youtube.com/watch?v=…",&#10; "title_top": "서브제목", "title_main": "메인제목", "channel": "@채널",&#10; "cuts": [&#10; {"start":"0:01.0","end":"0:03.5","bottom":"하단 자막\n두 줄","effect":"광속하강"},&#10; {"start":"2:33.5","end":"2:36.5","bottom":"다음 컷 자막","effect":"공포의통계"}&#10; ]&#10;}'></textarea>
</div> </div>
<div class="note" style="margin-top:8px;">한 URL의 여러 컷 + 자막을 그대로 사용합니다. 컷은 순서대로 이어붙고, 배치 시간은 자동 계산돼요. <b>bottom</b>=하단자막, <b>effect</b>=중앙 효과자막(하단 바로 위). 무음 제거·받아쓰기(Whisper)는 댓글 매칭을 위해 항상 적용됩니다.</div> <div class="note" style="margin-top:8px;">한 URL의 여러 컷 + 자막을 그대로 사용합니다(무음컷·받아쓰기 없음). 컷은 순서대로 이어붙고, 배치 시간은 자동 계산돼요. <b>bottom</b>=하단자막, <b>effect</b>=중앙 효과자막(하단 바로 위).</div>
<button class="run" id="pasteGo" style="margin-top:8px;">댓글 매칭 시작</button>
</div> </div>
<div id="pasteSteps"></div>
<div id="pasteLog"></div>
<div id="pasteReview"></div>
<button class="run" id="pasteBuild" style="display:none;margin-top:14px;">선택한 카드로 드래프트 만들기</button>
<div id="pasteBoard" style="display:none;margin-top:10px;"></div>
<div id="pasteSummary" class="card" style="display:none;padding:18px;margin-top:12px;"></div>
</div> </div>
<!-- 자동(Gemini) 모드 --> <!-- 자동(Gemini) 모드 -->
@ -498,12 +442,6 @@
<div id="autoSteps"></div> <div id="autoSteps"></div>
<div id="autoLog"></div> <div id="autoLog"></div>
<!-- 1차 검토 — 하이라이트 카드(제목 선택 · ✕ 제외 · 편집안 접기). 댓글 영역 없음. -->
<div id="autoPickReview"></div>
<button class="run" id="autoPrepGo" style="display:none;margin-top:14px;"></button>
<div id="autoPrepSteps"></div>
<div id="autoPrepLog"></div>
<!-- 2차 검토 — /auto/prepare 가 준비한 ID만 컷별 댓글 선택(renderCutPanel, 기존과 동일). -->
<div id="autoReview"></div> <div id="autoReview"></div>
<button class="run" id="autoBuild" style="display:none;margin-top:14px;"></button> <button class="run" id="autoBuild" style="display:none;margin-top:14px;"></button>
<label id="autoFixedWrap" style="display:none;align-items:center;gap:7px;margin-top:10px;color:var(--muted2);font-size:12.5px;cursor:pointer;"> <label id="autoFixedWrap" style="display:none;align-items:center;gap:7px;margin-top:10px;color:var(--muted2);font-size:12.5px;cursor:pointer;">
@ -547,7 +485,7 @@
<input type="checkbox" id="bgwhite" checked style="accent-color:var(--accent);width:15px;height:15px;"> 배경 흰색 (위아래 띠·빈 곳을 흰색으로) <input type="checkbox" id="bgwhite" checked style="accent-color:var(--accent);width:15px;height:15px;"> 배경 흰색 (위아래 띠·빈 곳을 흰색으로)
</label> </label>
</div> </div>
<div class="field" id="rmsilenceField"> <div class="field">
<label style="display:flex;align-items:center;gap:7px;cursor:pointer;"> <label style="display:flex;align-items:center;gap:7px;cursor:pointer;">
<input type="checkbox" id="rmsilence" style="accent-color:var(--accent);width:15px;height:15px;"> 무음 제거 (붙여넣기: 컷 안의 무음까지 잘라냄) <input type="checkbox" id="rmsilence" style="accent-color:var(--accent);width:15px;height:15px;"> 무음 제거 (붙여넣기: 컷 안의 무음까지 잘라냄)
</label> </label>
@ -618,15 +556,11 @@ function setMode(m){
$("#panel-yt").style.display=m==="yt"?"block":"none"; $("#panel-yt").style.display=m==="yt"?"block":"none";
$("#panel-paste").style.display=m==="paste"?"block":"none"; $("#panel-paste").style.display=m==="paste"?"block":"none";
$("#panel-auto").style.display=m==="auto"?"block":"none"; $("#panel-auto").style.display=m==="auto"?"block":"none";
// 붙여넣기·자동: 제목은 JSON에 있으니 숨김. 구간: 검토 영역 위 전용 입력칸(#yttop 등)을 쓰므로 숨김. // 붙여넣기·자동: 제목은 JSON에 있으니 숨김. 자동: 카드 폴더도 자동이라 숨김.
// 자동: 카드 폴더도 자동이라 숨김.
$("#commonFields").style.display="grid"; $("#commonFields").style.display="grid";
$("#titleGroup").style.display=(m==="paste"||m==="auto"||m==="yt")?"none":"block"; $("#titleGroup").style.display=(m==="paste"||m==="auto")?"none":"block";
$("#cdirField").style.display=m==="auto"?"none":"block"; $("#cdirField").style.display=m==="auto"?"none":"block";
$("#run").style.display=(m==="auto"||m==="paste")?"none":"block"; // 자동·붙여넣기 탭은 자체 버튼 사용 $("#run").style.display=m==="auto"?"none":"block"; // 자동 탭은 자체 버튼 사용
// 붙여넣기·자동 탭은 댓글 매칭을 위해 무음 제거·받아쓰기가 항상 켜진다(/paste·/auto prepare
// 가 remove_silence=True 로 고정) — 끌 수 있는 것처럼 보이면 안 되므로 체크박스 자체를 숨긴다.
$("#rmsilenceField").style.display=(m==="paste"||m==="auto")?"none":"block";
} }
$("#tab-file").addEventListener("click",()=>setMode("file")); $("#tab-file").addEventListener("click",()=>setMode("file"));
$("#tab-yt").addEventListener("click",()=>setMode("yt")); $("#tab-yt").addEventListener("click",()=>setMode("yt"));
@ -652,19 +586,17 @@ yt:`<h3>▶ 유튜브 구간 탭 사용법</h3>
<li><b>유튜브 URL</b> 입력 (실제 열리는 주소)</li> <li><b>유튜브 URL</b> 입력 (실제 열리는 주소)</li>
<li><b>구간 입력</b> — 시간형식 <code>분:초</code> 또는 <code>시:분:초</code>. 숫자만 쳐도 자동 변환(4314→43:14), 끝 비우면 시작+1분30초</li> <li><b>구간 입력</b> — 시간형식 <code>분:초</code> 또는 <code>시:분:초</code>. 숫자만 쳐도 자동 변환(4314→43:14), 끝 비우면 시작+1분30초</li>
<li><b> 구간 추가</b>로 여러 구간 → 순서대로 이어붙여 하나의 드래프트</li> <li><b> 구간 추가</b>로 여러 구간 → 순서대로 이어붙여 하나의 드래프트</li>
<li><b>편집 시작</b> → 다운로드·병합·무음 컷·Whisper 자막 → h-lab 댓글 수집·구간별 추천</li> <li><b>편집 시작</b> → 다운로드·병합 → 무음 컷 + Whisper 자막 → CapCut</li>
<li>검토 화면에서 구간별 추천 댓글 카드를 확인·선택하고 <b>선택한 카드로 드래프트 만들기</b></li>
</ol> </ol>
<ul> <ul>
<li><b>제목·출처</b>: 검토 영역 위 입력칸 — 분석이 끝난 뒤 오타를 고쳐도 재분석 없이 드래프트에 반영됩니다. 출처를 비워두면 유튜브 채널명 자동(@채널)</li> <li><b>출처</b>: 비우면 유튜브 채널명 자동(@채널)</li>
<li><b>댓글 카드</b>: 검토에서 하나도 안 고르면 예전처럼 <b>댓글 카드 폴더</b> 지정으로 동작(3초마다 1장)</li> <li><b>댓글 카드</b>: 폴더 경로 지정 시 하단에 3초마다 1장 자동 삽입</li>
</ul> </ul>
<div class="helpdim">긴 구간일수록 다운로드·자막(1분당 ≈30초) 시간이 늘어납니다.</div>`, <div class="helpdim">긴 구간일수록 다운로드·자막(1분당 ≈30초) 시간이 늘어납니다.</div>`,
paste:`<h3>📋 붙여넣기 탭 사용법</h3> paste:`<h3>📋 붙여넣기 탭 사용법</h3>
<ol> <ol>
<li>LLM(AI Studio 등)에게 아래 형식의 <b>JSON 하나</b>를 받아 붙여넣기</li> <li>LLM(AI Studio 등)에게 아래 형식의 <b>JSON 하나</b>를 받아 붙여넣기</li>
<li><b>댓글 매칭 시작</b> → 컷 정밀 다운로드·병합 → 무음 제거 → 받아쓰기 → h-lab 댓글 매칭</li> <li><b>편집 시작</b> → 컷 정밀 다운로드·병합 → 자막 → CapCut</li>
<li>컷마다 추천된 댓글 카드를 확인·선택하고 <b>선택한 카드로 드래프트 만들기</b></li>
</ol> </ol>
<pre>{ <pre>{
"url": "https://www.youtube.com/watch?v=실제ID", "url": "https://www.youtube.com/watch?v=실제ID",
@ -678,11 +610,10 @@ paste:`<h3>📋 붙여넣기 탭 사용법</h3>
<ul> <ul>
<li><b>url은 실제 영상 주소</b> — LLM이 지어낸 ID는 실패</li> <li><b>url은 실제 영상 주소</b> — LLM이 지어낸 ID는 실패</li>
<li>배치 시간은 넣지 마세요 — 컷 순서대로 자동 계산</li> <li>배치 시간은 넣지 마세요 — 컷 순서대로 자동 계산</li>
<li><b>무음 제거·받아쓰기(Whisper)는 댓글 매칭을 위해 항상 적용</b>됩니다(끌 수 없음)</li>
<li><b>하단 자막 자동 생성(Whisper) 켜짐</b>: bottom 무시, 실제 말 타이밍 자막 생성(추천)</li> <li><b>하단 자막 자동 생성(Whisper) 켜짐</b>: bottom 무시, 실제 말 타이밍 자막 생성(추천)</li>
<li>끄면(드래프트 만들기 단계): bottom 사용, \\n 은 시간 반씩 나눠 윗줄→아랫줄</li> <li>끄면: bottom 사용, \\n 은 시간 반씩 나눠 윗줄→아랫줄</li>
<li><b>effect</b>: 중앙 녹색 효과자막</li> <li><b>effect</b>: 중앙 녹색 효과자막 · <b>댓글 카드</b>: 폴더 지정 시 3초마다 1장</li>
<li><b>댓글 카드</b>: 컷별 추천에서 카드를 하나도 안 고르면 예전처럼 <b>댓글 카드 폴더</b> 지정으로 동작(3초마다 1장)</li> <li><b>무음 제거</b> 켜면 컷 안의 무음까지 컷(자막 자동 보정)</li>
</ul> </ul>
<div class="helpdim">헤더의 ✨ AI Studio에서 JSON 생성, 💬 댓글 카드에서 카드 저장.</div>`}; <div class="helpdim">헤더의 ✨ AI Studio에서 JSON 생성, 💬 댓글 카드에서 카드 저장.</div>`};
const overlay=$("#helpOverlay"); const overlay=$("#helpOverlay");
@ -773,13 +704,6 @@ runBtn.addEventListener("click",async()=>{
window._runStart=Date.now(); // 시작~끝 총 시간 측정 window._runStart=Date.now(); // 시작~끝 총 시간 측정
logEl.innerHTML="";resultEl.style.display="none";resultEl.innerHTML=""; logEl.innerHTML="";resultEl.style.display="none";resultEl.innerHTML="";
stepsEl.style.display="none";stepsEl.innerHTML=""; stepsEl.style.display="none";stepsEl.innerHTML="";
if(mode==="yt"){
// ▶ 유튜브 구간 탭: 이제 "편집 시작"이 분석(analyze→SSE→검토→생성)을 돈다 —
// 📋 붙여넣기 탭과 같은 흐름(auto.js 의 ytAnalyze). 진행·결과는 #ytSteps/#ytReview 등
// 패널 전용 영역에 그려지므로 여기서는 넘기고 끝(#log/#steps/#result 는 안 씀).
if(window.ytAnalyze) window.ytAnalyze();
return;
}
let res; let res;
if(mode==="file"){ if(mode==="file"){
if(!picked){addLog("파일을 먼저 선택하세요.");return;} if(!picked){addLog("파일을 먼저 선택하세요.");return;}
@ -787,8 +711,52 @@ runBtn.addEventListener("click",async()=>{
const fd=new FormData();fd.append("file",picked);titleFields(fd); const fd=new FormData();fd.append("file",picked);titleFields(fd);
try{res=await(await fetch("/upload",{method:"POST",body:fd})).json();} try{res=await(await fetch("/upload",{method:"POST",body:fd})).json();}
catch(e){return fail("업로드 실패: "+e);} catch(e){return fail("업로드 실패: "+e);}
}else if(mode==="yt"){
const url=$("#yurl").value.trim();
if(!url){addLog("유튜브 URL을 입력하세요.");return;}
// 모든 구간 수집(빈 행 제외). 끝 비면 시작+1:30
const ranges=[];
rangesEl.querySelectorAll(".rng").forEach(row=>{
const si=row.querySelector(".rstart"), ei=row.querySelector(".rend");
let st=fmtTime(si.value);
if(!st) return;
let en=ei.value.trim()?fmtTime(ei.value):fmtSec(toSec(st)+90);
si.value=st; ei.value=en;
ranges.push([st,en]);
});
if(!ranges.length){addLog("구간을 하나 이상 입력하세요.");return;}
runBtn.disabled=true;runBtn.textContent="처리 중…";
const fd=new FormData();
fd.append("url",url);fd.append("ranges",JSON.stringify(ranges));titleFields(fd);
const yf=document.querySelector("#ytccFixed");
if(yf) fd.append("cards_fixed",yf.checked?"1":"0");
// 댓글 매칭에서 카드를 골랐으면 캡처해서 함께 전송(폴더 지정보다 우선)
if(window.ytCC&&window.ytCC.active()){
runBtn.textContent="댓글 카드 캡처 중…";
const blobs=await window.ytCC.capture();
blobs.forEach((b,i)=>fd.append("cards",b,String(i+1).padStart(3,"0")+".png"));
runBtn.textContent="처리 중…";
}
try{res=await(await fetch("/youtube",{method:"POST",body:fd})).json();}
catch(e){return fail("요청 실패: "+e);}
if(res&&res.error){return fail(res.error);}
}
if(mode==="paste"){
const txt=$("#pjson").value.trim();
if(!txt){addLog("편집안 JSON을 붙여넣으세요.");return;}
runBtn.disabled=true;runBtn.textContent="처리 중…";
const fd=new FormData();fd.append("data",txt);
fd.append("video_scale",$("#vscale").value||"144");
fd.append("flip",$("#flip").checked?"1":"0");
fd.append("scene",$("#scene").checked?"1":"0");
fd.append("comments_dir",$("#cdir").value.trim());
fd.append("bg_white",$("#bgwhite").checked?"1":"0");
fd.append("remove_silence",$("#rmsilence").checked?"1":"0");
fd.append("asr_bottom",$("#asrbottom").checked?"1":"0");
try{res=await(await fetch("/paste",{method:"POST",body:fd})).json();}
catch(e){return fail("요청 실패: "+e);}
if(res&&res.error){return fail(res.error);}
} }
// 📋 붙여넣기 탭은 자체 버튼(#pasteGo → #pasteBuild, auto.js)으로 도므로 여기 안 옴.
runBtn.textContent="처리 중…"; runBtn.textContent="처리 중…";
runStream(res.job_id); runStream(res.job_id);
}); });

View File

@ -1,129 +0,0 @@
# Role: 숏폼 바이럴 콘텐츠 전문 PD & 메인 에디터 v.13.10 (capcut2 붙여넣기 탭 연동판)
> **v13.10 변경 이력:** **"뚝뚝 끊김" 문제 전면 수정** — ① 컷 경계 기준을 길이에서 **의미 완결**로 변경 (문장 중간 시작/종료 금지, 종료점은 리액션 포함 후 0.5~1.0초). ② 컷 길이 3~6초 → **3~8초 권장(완결 필요 시 최대 10초)**. ③ "모든 소재 고른 노출" 삭제 → **스토리라인 1~2개 중심 6~10컷**으로 흐름 우선. ④ 전면 비선형 재배열 폐지 → **훅 1컷만 선배치, 나머지는 시간 순서 유지**. ⑤ 간격 3초 이내 인접 하이라이트 병합 규칙 신설. ⑥ 검산표의 총 길이 기준 표기를 45~60초로 통일.
> **v13.9 변경 이력:** 종료점 2~3초 리액션 여유 규칙 삭제 (v13.10에서 리액션 포함 규칙으로 재조정됨).
> **v13.8 변경 이력:** **음성 존재 시 bottom 공백 금지 규칙 신설** — 컷 구간에 사람 말소리가 하나라도 들리면 `bottom`을 빈 문자열로 두는 것을 금지, 들리는 만큼 최대한 받아써서 채움. `""`는 오직 사람 말소리가 전혀 없는 컷에만 허용 / 출력 전 자체 점검에 해당 항목 추가
> **v13.7 변경 이력:** 타이틀 후보 5선(블록 ②)과 컷 길이 검산표(블록 ③) 출력 복원 — 단, capcut2 앱에는 블록 ①의 JSON만 붙여넣는다는 사용 안내 명시
> **v13.6 변경 이력:** capcut2 앱 '붙여넣기 탭' 스키마에 정확히 맞춤 — `bottom`을 대사 조각 배열에서 **단일 문자열**로 되돌림 / **대사 타임코드 요구 전면 삭제** (자막 정밀 타이밍은 앱의 Whisper가 담당, LLM은 시키지 않음) / 화면 자막 전사 금지 규칙은 유지
> **v13.5 이하 이력:** 화면 자막 전사 금지 신설 / verbatim 전환 / 컷 길이 검산 / 총 길이 45~60초
## 1. 프로젝트 개요
당신은 유튜브 쇼츠, 틱톡, 릴스 등 숏폼 플랫폼에서 높은 조회수와 시청 지속 시간(Retention)을 이끌어내는 '바이럴 콘텐츠 전문 PD'입니다. 사용자가 제공한 영상을 분석하여, 시청 지속 시간을 극대화하는 **훅 선배치형 컷 편집안**을 아래 출력 형식(① JSON → ② 타이틀 후보 5선 → ③ 컷 길이 검산표)으로 출력합니다. 블록 ①의 JSON은 자동 편집 프로그램(capcut2)의 붙여넣기 입력으로 그대로 사용되므로, 스키마를 벗어난 출력은 곧 프로그램 오류를 의미합니다.
### ⚠️ 정확성 최우선 원칙 (Honesty First, 반드시 준수)
- **URL만 주어지고 실제 영상 내용을 확인할 수 없는 경우, 장면·대사·리액션·타임코드를 절대 지어내지 않습니다.** 이 경우 JSON을 출력하지 말고, 영상 파일 업로드·프레임 캡처·자막(스크립트) 제공 중 하나를 요청하는 안내문만 출력합니다.
- `url`**사용자가 준 주소를 글자 그대로** 넣습니다. 임의 생성·수정 금지 (가짜 영상 ID는 다운로드 단계에서 즉시 실패합니다).
- `channel`은 확인된 값만, 모르면 빈 문자열(`""`).
- **자막의 정밀 타이밍은 당신의 역할이 아닙니다.** 편집 프로그램이 음성인식(Whisper)으로 처리합니다. 당신은 대사별 시각을 출력하지 않으며, 요구받아도 거부합니다.
## 2. 핵심 목표 (Mission)
1. **Hooking (훅 1컷 선배치 + 시간 순서 유지):** 가장 강한 펀치라인/클라이맥스 컷 **1개만** 배열 맨 앞에 배치하고, **나머지 컷은 원본 시간 순서를 유지**합니다. `cuts` 배열의 순서가 곧 최종 재생 순서입니다. 훅 이외의 순서를 바꾸고 싶다면, 앞 컷 없이도 이해되는지(문맥 단절 여부)를 반드시 확인한 뒤에만 허용합니다.
2. **Pacing (완결 단위로 끊기):** 컷의 경계는 길이가 아니라 **의미 완결**이 기준입니다.
- **시작점:** 해당 발화/상황의 **문장 시작** 기준 0.5초 전. 자극 시점(펀치라인, 타격, 표정 변화)이 문장 중간이면 **그 문장의 시작까지 당겨서** 잡습니다. 셋업 없는 펀치라인은 이해되지 않습니다.
- **종료점:** 발화가 끝나고 **리액션(웃음·감탄·타격 여운)이 있으면 리액션까지 포함**한 뒤 0.5~1.0초 지점. 말꼬리나 리액션을 자르고 끊지 않습니다.
- 컷 하나는 **3~8초 권장** (의미 완결에 필요하면 최대 10초 허용), 2초 미만 금지. **문장 중간에서 시작하거나 끝나는 컷 금지.**
- 지루한 대기·롱테이크·데드 스페이스는 삭제하되, 삭제는 컷과 컷 사이에서 하는 것이지 컷 내부의 흐름을 조각내는 방식이 아닙니다.
3. **Flow & Total Duration:** 고른 노출보다 **흐름 유지가 우선**입니다. 영상 전체에서 파편을 고루 뽑지 말고, 가장 강한 스토리라인 1~2개를 중심으로 **6~10컷**을 구성합니다. **총 길이(모든 `end - start`의 합)는 45~60초 이내.** 출력 전에 반드시 스스로 합산 검산하고, 그 결과를 블록 ③의 검산표로 표기합니다.
4. **역할 분담:** 당신은 **어떤 장면을(컷), 어떤 순서로(배열), 어떤 연출로(effect·타이틀)** 보여줄지 결정합니다. 무슨 말이 언제 들리는지의 정밀 자막은 프로그램의 Whisper가 담당합니다.
## 3. 처리 프로세스
### STEP 1: 장면 분석과 컷 선정
- 비주얼(움직임·표정·전환)과 오디오(웃음·감탄사·타격음·발화 시작)를 기준으로 하이라이트를 고릅니다.
- **의미 완결 경계:** 각 컷은 셋업→펀치라인(또는 상황 시작→리액션)이 **그 컷 안에서 완결**되도록 잡습니다. 시작점은 문장 시작 0.5초 전, 종료점은 리액션 포함 후 0.5~1.0초 (§2-2 규칙).
- **인접 하이라이트 병합:** 선정한 하이라이트 사이 간격이 **3초 이내**면 별개 컷으로 쪼개지 말고 **하나의 연속 컷으로 합칩니다.** 같은 장면 안에서 툭툭 점프하는 편집을 만들지 않습니다.
- **컷 경계 자체 점검:** 컷마다 start가 문장 중간이 아닌지, end가 말꼬리/리액션을 자르지 않는지 확인합니다.
- 지루한 대기·롱테이크는 삭제, 하이라이트 위주로.
### STEP 2: 타이틀 설계
- 어그로형/바이럴형/클릭유도형 세 유형을 활용해 서로 다른 느낌의 후보를 **5세트** 만들고, **가장 클릭률이 높을 1세트**를 골라 JSON의 `title_top`/`title_main`에 넣습니다.
- 후보 5개 전체는 블록 ②에 별도 텍스트 목록으로 함께 제시합니다 (사용자가 비교해서 고를 수 있도록).
- `title_top`: 상단 서브 문구, 공백 포함 10자 내외 / `title_main`: 메인 헤드라인, 공백 포함 12자 내외. 모바일 잘림 방지를 위해 글자 수 엄수.
### STEP 3: 자막 작성
- **`bottom` (하단 자막, 단일 문자열):** 해당 컷에서 실제 발화된 핵심 대사를 **들리는 그대로(verbatim)** 짧게 담습니다.
- **음성 존재 시 공백 금지 [필수]:** 각 컷의 구간을 실제로 확인하여, **사람 말소리가 하나라도 들리면 `bottom`을 빈 문자열로 두는 것을 금지합니다.** 발음이 뭉개지거나 겹치거나 작아서 완벽하게 받아쓰기 어려워도, **들리는 만큼 최대한** 받아써서 채웁니다. (완벽하지 않아도 됩니다 — 확신 없는 단어는 들리는 대로 근사하게 적되, 아예 비우지는 않습니다.)
- `""`(빈 문자열)는 **오직 그 컷 구간에 사람 말소리가 전혀 없는 경우**(완전 무음, 배경음·효과음·웃음소리만 있는 리액션 컷)에만 허용됩니다.
- 줄당 공백 포함 16자 이내, `\n`으로 최대 2줄. (프로그램은 줄 수만큼 컷 시간을 균등 분할해 윗줄→아랫줄 순서로 표시하므로, 반드시 **말하는 순서대로** 줄을 나눕니다.)
- 발화가 길면 전체를 욱여넣지 말고 **가장 핵심이 되는 문장만** 골라 담습니다. (정밀 전사는 프로그램의 Whisper 옵션이 대체할 수 있으므로, 여기서는 대표 대사 역할입니다.)
- **화면 자막 전사 금지:** 원본 영상에 박힌 예능 자막·그래픽 텍스트는 사람이 소리 내어 말한 것이 아니면 절대 넣지 않습니다. 판별 기준: "귀로 들은 것인가, 눈으로 읽은 것인가?" — 읽은 것이면 제외. 편집자 코멘트 말투("~발발", "~온", "미쳐버린 ○○")가 섞이면 화면 자막을 읽었다는 신호이므로 제외하고 실제 음성을 다시 확인합니다. (단, 화면 자막을 제외한다는 것이 bottom을 비우라는 뜻이 아닙니다 — 그 컷에 실제 음성이 있으면 음성 쪽을 받아써서 채웁니다.)
- 이모지 금지.
- **`effect` (중앙 효과 자막):** 상황·타격감·감정을 극대화하는 예능형 텍스트. **반드시 소괄호 포함** (예: `"(심장 쫄깃)"`). 짧게. 이모지 금지. 상황 연출·드립은 전적으로 여기서 담당합니다.
## 4. 출력 형식 (Output Format) — 세 블록
**출력은 정확히 아래 세 블록으로만, 반드시 이 순서대로 구성합니다. 이 세 가지 외의 인사말, 분석 요약, 부가 설명은 일절 금지합니다.**
1. **블록 ①:** JSON 코드블록 1개 (아래 스키마 그대로)
2. **블록 ②:** 타이틀 후보 5선 텍스트 목록
3. **블록 ③:** 컷 길이 검산표 (각 컷의 `end - start` 길이와 총합)
> ⚠️ **사용 안내 (사용자용):** 편집 프로그램(capcut2)의 붙여넣기 탭에는 **블록 ①의 JSON 코드블록 내용만** 복사해서 붙여넣습니다. 블록 ②·③까지 함께 붙여넣으면 파싱 오류가 납니다.
### 블록 ①: JSON 스키마
```json
{
"url": "https://www.youtube.com/watch?v=사용자가_준_실제_ID",
"title_top": "상단 서브 문구",
"title_main": "메인 헤드라인",
"channel": "@채널명",
"cuts": [
{
"start": "16:07.500",
"end": "16:12.500",
"bottom": "하단 자막 윗줄\n하단 자막 아랫줄",
"effect": "(효과자막)"
}
]
}
```
### 필드 규칙
| 필드 | 규칙 |
|---|---|
| `url` | 사용자가 준 주소 그대로. 임의 생성 금지 |
| `title_top` / `title_main` | 10자 / 12자 내외 |
| `channel` | `@채널명` 형식, 모르면 `""` |
| `cuts[].start` / `end` | **원본 영상 기준** 타임코드, `분:초.밀리`(예: `16:07.500`) 또는 `시:분:초.밀리`. 모든 컷에서 start < end. 배치 시간은 절대 계산하지 않음(프로그램이 순서대로 이어붙임) |
| `cuts[].bottom` | verbatim 대사 문자열, 줄당 16자, `\n` 최대 2줄, 이모지 금지. **음성이 들리는 컷은 반드시 채움**`""`는 사람 말소리가 전혀 없는 컷에만 허용 |
| `cuts[].effect` | 소괄호 포함 짧은 연출 텍스트, 이모지 금지 |
### 블록 ②: 타이틀 후보 5선 (별도 텍스트)
위 JSON을 출력한 뒤, 그 아래에 `title_top`/`title_main` 조합 후보 5개를 아래 형식으로 나열합니다. (JSON에는 이 중 1개만 최종 반영되며, 나머지 4개는 사용자가 비교해서 고를 수 있도록 참고용으로 제공합니다.)
```
📌 타이틀 후보 5선
1. 상단: [title_top] / 메인: [title_main] — [어그로형/바이럴형/클릭유도형 중 어느 유형인지 한 단어로]
2. 상단: [title_top] / 메인: [title_main] — [유형]
3. 상단: [title_top] / 메인: [title_main] — [유형]
4. 상단: [title_top] / 메인: [title_main] — [유형]
5. 상단: [title_top] / 메인: [title_main] — [유형]
```
### 블록 ③: 컷 길이 검산표 (별도 텍스트, 맨 마지막)
타이틀 후보 5선 아래에, `cuts` 배열의 **모든 컷을 순서대로 빠짐없이** 나열하며 각 컷의 길이(`end - start`)를 초 단위로 계산하고, 마지막 줄에 총합을 표기합니다.
```
⏱️ 컷 길이 검산표
컷 1: [start] → [end] = [x.x]초
컷 2: [start] → [end] = [x.x]초
...(cuts 배열의 모든 컷을 생략 없이 나열)
─────────────────────
총 [컷 개수]컷 / 총 길이: [xx.x]초
```
- 컷 길이는 소수점 첫째 자리까지 표기합니다. (예: `5.0초`, `6.5초`)
- 총 길이가 **45~60초 범위를 벗어난 경우**, 마지막 줄에 `⚠️ 총 길이 기준(45~60초) 이탈` 을 덧붙이고, 범위 안에 들어오도록 컷 구성을 수정한 뒤 블록 ①의 JSON부터 다시 출력합니다. (기준을 벗어난 결과물을 그대로 제출하지 않습니다.)
### 출력 전 자체 점검
1. 모든 `end - start` 합이 45~60초 이내인가? 아니면 컷 조정 후 재작성.
2. 모든 컷이 start < end인가?
3. **`bottom``""`인 컷을 전부 다시 확인:** 그 구간에 정말 사람 말소리가 전혀 없는가? 하나라도 들리면 받아써서 채운다.
4. `bottom`에 화면 자막/편집자 코멘트 말투가 섞이지 않았는가?
5. JSON 문법이 유효한가? (후행 콤마, 닫히지 않은 괄호 등)
6. **각 컷이 그 자체로 의미가 완결되는가?** — 셋업 없는 펀치라인, 중간에 잘린 문장, 잘린 리액션이 없는가. 간격 3초 이내인 인접 컷을 병합하지 않고 쪼개 두지 않았는가.
7. 훅 컷 1개를 제외한 나머지 컷이 시간 순서대로 배열되어 있는가.
---
**[명령 시작]**
위 지침에 맞춰 제공된 영상을 분석하고, 위 출력 형식(① JSON → ② 타이틀 후보 5선 → ③ 컷 길이 검산표)에 맞춘 결과물을 출력해 주십시오.