Compare commits
39 Commits
3d3f6f6b14
...
f38776d71f
| Author | SHA1 | Date | |
|---|---|---|---|
| f38776d71f | |||
| b49946a4cd | |||
| b5586ecb9c | |||
| 61320773b4 | |||
| 9b2cc96e3e | |||
| 2266d0a3ee | |||
| 5295d92dfc | |||
| bb1f168e36 | |||
| 7ea17127b1 | |||
| e88571e5e9 | |||
| 6ab3d998e9 | |||
| 77265f822e | |||
| 71d0f2fc4e | |||
| e650cfb992 | |||
| 3c013dcc88 | |||
| 678d3f20e5 | |||
| 447e8808ff | |||
| 85101867d5 | |||
| b9d81acfce | |||
| 3dd5eee684 | |||
| 58fab2b2a4 | |||
| bfba87288f | |||
| 7c74fec079 | |||
| 011c042778 | |||
| b1c08908ae | |||
| 9ea70d01a7 | |||
| 058f03265c | |||
| d1ebfaff74 | |||
| 0967e88e7b | |||
| e79f2dfaa3 | |||
| 475b7b0f71 | |||
| 22a9758e74 | |||
| aab2a3199b | |||
| a1f98b0b53 | |||
| 2525fb5231 | |||
| 6df1930d06 | |||
| ef78702ca9 | |||
| 2d37d42893 | |||
| 4abf41ebcb |
153
ARCHITECTURE.md
153
ARCHITECTURE.md
@ -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. 엔드포인트 4개 + SSE 스트림
|
│ ├─ app.py FastAPI. 탭별 analyze→stream→build 엔드포인트들 + SSE 스트림(§3)
|
||||||
│ └─ static/index.html UI 전체(단일 파일, 탭 3개 + 옵션 + SSE 렌더)
|
│ └─ static/index.html UI 전체(단일 파일, 탭 3개 + 옵션 + SSE 렌더)
|
||||||
└─ capcut_agent/
|
└─ capcut_agent/
|
||||||
├─ pipeline.py ★ 두 파이프라인(process_bg_template / process_paste)
|
├─ pipeline.py ★ 두 파이프라인, 각각 analyze/draft 두 조각 + 얇은 래퍼(§4)
|
||||||
├─ 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 컷별 댓글 추천(자동 탭) — 타임스탬프 우선 + Gemini 텍스트 추천
|
├─ recommend.py 컷별 댓글 추천(세 탭 공통, cuts_from_state) — 타임스탬프 우선 + Gemini 텍스트 추천
|
||||||
├─ highlight.py 자막 청킹(cut_plan) 유틸
|
├─ highlight.py 자막 청킹(cut_plan) 유틸
|
||||||
├─ scene.py ffmpeg scene 필터 장면전환 감지·분할
|
├─ scene.py ffmpeg scene 필터 장면전환 감지·분할
|
||||||
├─ media.py 프레임 PNG 생성, 흰밴드 감지, 오디오 추출
|
├─ media.py 프레임 PNG 생성, 흰밴드 감지, 오디오 추출
|
||||||
@ -58,25 +58,81 @@ 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) — **유일하게 옛 단일 흐름**(분석/검토 단계·댓글 매칭 없음) |
|
||||||
| `POST /youtube` | 유튜브 탭. `url` + `ranges`(JSON `[["mm:ss","mm:ss"],…]`) + 옵션 |
|
| `GET /stream/{job_id}` | SSE. `JOBS[job_id]`에 `bg_state`/`paste_state`가 있으면 **드래프트만**(§4의 build 단계), 없으면 `process_bg_template`/`process_paste` 통짜 실행 |
|
||||||
| `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 프롬프트 조회/저장 |
|
||||||
|
|
||||||
- job 은 메모리 dict `JOBS[hash]`. hash = 입력 시그니처 sha1 12자.
|
**▶ 유튜브 구간 / 📋 붙여넣기 / 🤖 자동 — 공통 3단계 흐름**(analyze → stream(SSE) → build).
|
||||||
- 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}`
|
||||||
- `result`: `{draft_name, draft_path, stats:{duration,kept,cut,segments,captions,elapsed}}`
|
- `state`: **내부 전용**(analyze↔draft 조각 간 상태 전달, §4) — 래퍼가 걸러내 밖으로 안 흘림
|
||||||
|
- `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. 탭별 파이프라인
|
||||||
|
|
||||||
### 4-A. 📁 파일 / ▶ 유튜브 구간 → `process_bg_template()` (pipeline.py)
|
두 파이프라인 모두 내부적으로 **analyze/draft 두 조각 + 얇은 래퍼**로 나뉜다
|
||||||
|
(`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}`로 통짜 실행되는 옛 흐름 그대로**다(댓글 매칭 없음).
|
||||||
|
|
||||||
단계: `[download] → silence → asr → [scene] → draft`
|
- `*_analyze`는 끝나면 다음 조각에 넘길 상태를 실어 `{"type":"state","state":{…}}`를
|
||||||
|
낸다. **이 이벤트는 내부 전용**이라 래퍼가 걸러내고 밖으로 흘리지 않는다 — 기존 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개 이상이면
|
||||||
@ -105,11 +161,18 @@ CapCut 인스펙터 값 ↔ pycapcut 변환:
|
|||||||
보존 구간을 장면전환 지점에서 **인접 분할**(누적 길이 불변 → 자막 싱크 무영향).
|
보존 구간을 장면전환 지점에서 **인접 분할**(누적 길이 불변 → 자막 싱크 무영향).
|
||||||
5. **draft**: §5 빌더 호출. 좌표는 전부 `_template_pos()`가 레이아웃 상수에서 파생(§9).
|
5. **draft**: §5 빌더 호출. 좌표는 전부 `_template_pos()`가 레이아웃 상수에서 파생(§9).
|
||||||
|
|
||||||
### 4-B. 📋 붙여넣기 (기본 탭) → `process_paste()` (pipeline.py)
|
### 4-B. 📋 붙여넣기 (기본 탭) (pipeline.py)
|
||||||
|
|
||||||
LLM이 만든 편집안 JSON을 **그대로** 사용. 무음컷·ASR **기본 없음**(옵션으로 무음 제거 가능).
|
LLM이 만든 편집안 JSON을 **그대로** 사용. `paste_analyze()`/`process_paste()` 함수
|
||||||
|
자체의 `remove_silence`/`asr_bottom` 기본값은 각각 꺼짐/켜짐이지만, ⚠ **현재 UI의
|
||||||
|
기본 흐름(`/paste/analyze` → `/paste/stream`)은 댓글 매칭 추천을 위해 둘 다 항상
|
||||||
|
`True`로 고정해서 호출한다**(§3) — 사용자가 화면에서 끌 수 없다. 검토 화면에서
|
||||||
|
"하단 자막 자동 생성" 체크를 끄면 **받아쓰기 자체는 그대로 하되** 최종 화면 자막만
|
||||||
|
`/paste/build` 단계에서 JSON `bottom`으로 되돌린다(아래 5번). 옛 `POST /paste`
|
||||||
|
단일 엔드포인트는 폼 체크박스 값을 그대로 써서(§3) 지금도 두 옵션 다 끌 수 있다.
|
||||||
|
|
||||||
단계: `download(컷 정밀) → [remove_silence] → [scene] → draft`
|
단계: `download(컷 정밀) → [remove_silence] → [asr_bottom] → [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)`(자막 안 실제 줄바꿈 허용), 코드펜스(```) 자동 제거,
|
||||||
@ -130,8 +193,9 @@ LLM이 만든 편집안 JSON을 **그대로** 사용. 무음컷·ASR **기본
|
|||||||
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).
|
||||||
자동 탭에서 넘어온 경우 `process_paste(card_cuts=[...])`로 카드별 소속 컷을
|
🤖 자동 탭·▶ 유튜브 구간 탭에서 넘어온 경우 build 단계(`paste_draft`/`bg_draft`)가
|
||||||
받는다(§6 참고) — 붙여넣기 탭 직접 사용 시엔 생략(기존 전체 균등 배치).
|
`card_cuts=[...]`로 카드별 소속 컷을 받는다(§6 참고) — 붙여넣기 탭 직접 사용 시엔
|
||||||
|
생략(기존 전체 균등 배치).
|
||||||
|
|
||||||
#### 붙여넣기 JSON 스키마 (LLM에게 시킬 형식)
|
#### 붙여넣기 JSON 스키마 (LLM에게 시킬 형식)
|
||||||
|
|
||||||
@ -226,15 +290,16 @@ 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()`로 공통): 🤖 자동 · ▶ 유튜브 구간 ·
|
||||||
카드별 소속 컷 인덱스(`card_cuts`)를 보내고, `_cards_by_cut(paths, card_cuts, placements, dur)`가
|
📋 붙여넣기 세 탭 모두 검토 화면(§4·§6 `cuts_from_state`)에서 컷별 소속 컷 인덱스
|
||||||
|
(`card_cuts`)를 보내고, `_cards_by_cut(paths, card_cuts, placements, dur)`가
|
||||||
**그 컷 구간 안에서** 균등 배치한다(한 컷이 덜 차도 다음 컷 카드가 앞으로 밀리지 않음).
|
**그 컷 구간 안에서** 균등 배치한다(한 컷이 덜 차도 다음 컷 카드가 앞으로 밀리지 않음).
|
||||||
파일/유튜브 탭·붙여넣기 탭 직접 사용은 컷 소속을 몰라 기존 `_load_comment_cards` 전체 균등
|
📁 파일 탭(검토 화면 없음)과 붙여넣기 옛 단일 엔드포인트(`POST /paste`) 직접 사용만
|
||||||
배치 그대로 쓴다.
|
컷 소속을 몰라 기존 `_load_comment_cards` 전체 균등 배치 그대로 쓴다.
|
||||||
- 컷당 장수 상한은 `max(1, floor(컷길이/3초))` — `_load_comment_cards`와 같은 규칙.
|
- 컷당 장수 상한은 `max(1, floor(컷길이/3초))` — `_load_comment_cards`와 같은 규칙.
|
||||||
초과분은 버린다(카드가 1초씩 번쩍이느니 몇 장 빼는 게 낫다).
|
초과분은 버린다(카드가 1초씩 번쩍이느니 몇 장 빼는 게 낫다).
|
||||||
- ⚠ **카드 시간은 서버가 미리 확정하지 않는다.** `/auto/build`는 "몇 번 컷 소속"만 넘기고,
|
- ⚠ **카드 시간은 서버가 미리 확정하지 않는다.** `/auto/build`·`/yt/build`·`/paste/build`는
|
||||||
파이프라인이 컷 누적 위치(`placements`)로 시간을 계산한다.
|
"몇 번 컷 소속"만 넘기고, 파이프라인이 컷 누적 위치(`placements`)로 시간을 계산한다.
|
||||||
- ⚠ **카드 시간 계산은 무음 제거 *뒤*다.** 자막은 `_remap_caps()`로 시간을 옮기지만
|
- ⚠ **카드 시간 계산은 무음 제거 *뒤*다.** 자막은 `_remap_caps()`로 시간을 옮기지만
|
||||||
(발화 시각을 따라가야 하니까), 카드는 **구간 자체**를 `_remap_placements()`로 옮기고
|
(발화 시각을 따라가야 하니까), 카드는 **구간 자체**를 `_remap_placements()`로 옮기고
|
||||||
그 안에서 나눈다. 카드 시간을 압축 전에 만들어 자막처럼 재매핑하면
|
그 안에서 나눈다. 카드 시간을 압축 전에 만들어 자막처럼 재매핑하면
|
||||||
@ -245,6 +310,36 @@ 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()`가
|
||||||
@ -283,6 +378,13 @@ 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. 현재 고정값 치트시트
|
||||||
|
|
||||||
@ -334,7 +436,10 @@ 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`는 메모리 저장 — 서버 재시작하면 job 소실(스트림 전에 재시작하면 재제출 필요).
|
- `JOBS`/`ANALYSES`/`YSTATES`/`PSTATES`/`PREPARES` 전부 메모리 저장 — 서버 재시작하면
|
||||||
|
전부 소실(§3). ▶ 유튜브 구간·📋 붙여넣기·🤖 자동 탭은 분석(1단계)과 빌드(3단계)
|
||||||
|
사이에 서버가 재시작되면 검토 화면에서 빌드를 눌러도 "분석 결과가 만료됐습니다"
|
||||||
|
404가 뜬다 — 해결은 재분석뿐(재시작 원인 자체를 없앨 수는 없음, SETUP.md §10).
|
||||||
- 검증은 최종적으로 **사용자가 CapCut에서 열어 확인**하는 방식.
|
- 검증은 최종적으로 **사용자가 CapCut에서 열어 확인**하는 방식.
|
||||||
- v1(`../capcut`, 포트 8000)은 별개 코드베이스 — 여기 수정해도 v1에 반영 안 됨(역도 동일).
|
- v1(`../capcut`, 포트 8000)은 별개 코드베이스 — 여기 수정해도 v1에 반영 안 됨(역도 동일).
|
||||||
|
|
||||||
|
|||||||
13
README.md
13
README.md
@ -18,13 +18,20 @@
|
|||||||
## 2. 세 가지 입력 방법 (탭)
|
## 2. 세 가지 입력 방법 (탭)
|
||||||
|
|
||||||
### 📋 붙여넣기 (기본 탭) — 추천
|
### 📋 붙여넣기 (기본 탭) — 추천
|
||||||
LLM이 만든 편집안 JSON을 붙여넣으면 컷·자막을 **그대로** 사용합니다. (무음컷·받아쓰기 없음)
|
LLM이 만든 편집안 JSON을 붙여넣으면 컷·자막을 **그대로** 사용합니다.
|
||||||
|
**댓글 매칭 시작**을 누르면 컷 정밀 다운로드·병합 → 무음 제거 → 받아쓰기(Whisper) →
|
||||||
|
h-lab 댓글 수집 → 컷별 댓글 카드 추천까지 자동으로 돌고, 검토 화면에서 컷마다 카드를
|
||||||
|
확인·선택한 뒤 **선택한 카드로 드래프트 만들기**를 누르면 완성됩니다.
|
||||||
|
(⚠ 무음 제거·받아쓰기는 댓글 매칭 정확도를 위해 항상 켜져 있고 끌 수 없습니다 —
|
||||||
|
화면 자막은 그대로 JSON의 `bottom`을 쓸지, Whisper 자동 자막으로 바꿀지 따로 고를 수 있습니다.)
|
||||||
|
|
||||||
### ▶ 유튜브 구간
|
### ▶ 유튜브 구간
|
||||||
한 URL + 여러 구간(+ 구간 추가) → 이어붙여 **무음컷 + 자동 자막(Whisper)**.
|
한 URL + 여러 구간(+ 구간 추가) → 이어붙여 **무음컷 + 자동 자막(Whisper)**.
|
||||||
|
📋 붙여넣기 탭과 같은 흐름 — 분석(다운로드·무음·받아쓰기)이 끝나면 h-lab 댓글을
|
||||||
|
구간별로 자동 추천해 검토 화면을 보여주고, 카드를 고른 뒤 드래프트를 만듭니다.
|
||||||
|
|
||||||
### 📁 파일
|
### 📁 파일
|
||||||
로컬 영상 파일 → **무음컷 + 자동 자막**.
|
로컬 영상 파일 → **무음컷 + 자동 자막**. (댓글 카드는 폴더 지정 방식만 — 검토 화면 없음)
|
||||||
|
|
||||||
세 방법 모두 아래 **영상 옵션**을 함께 적용합니다.
|
세 방법 모두 아래 **영상 옵션**을 함께 적용합니다.
|
||||||
|
|
||||||
@ -122,7 +129,7 @@ capcut2/
|
|||||||
├─ requirements.txt 파이썬 패키지 목록
|
├─ requirements.txt 파이썬 패키지 목록
|
||||||
├─ .gemini_key (선택) Gemini 키
|
├─ .gemini_key (선택) Gemini 키
|
||||||
├─ server/
|
├─ server/
|
||||||
│ ├─ app.py FastAPI 서버 (/upload /youtube /paste /stream)
|
│ ├─ app.py FastAPI 서버 (/upload, /stream, 탭별 analyze→stream→build)
|
||||||
│ └─ static/index.html 웹 UI
|
│ └─ static/index.html 웹 UI
|
||||||
└─ capcut_agent/
|
└─ capcut_agent/
|
||||||
├─ pipeline.py 처리 파이프라인(다운로드→컷→자막→드래프트)
|
├─ pipeline.py 처리 파이프라인(다운로드→컷→자막→드래프트)
|
||||||
|
|||||||
3
SETUP.md
3
SETUP.md
@ -285,6 +285,9 @@ 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 실패 또는 자막 없음 | 로그의 경고 확인. 🔤·➕는 계속 동작 |
|
||||||
|
| 분석 결과가 만료됐다고 나온다 | 서버 재시작으로 메모리 상태 소실 | 분석을 다시 돌린다 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -23,6 +23,19 @@ _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>→줄바꿈, 나머지 태그 제거."""
|
||||||
@ -61,11 +74,14 @@ 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 i, c in enumerate(data.get("data") or []):
|
for c in data.get("data") or []:
|
||||||
|
text = str(c.get("text") or "")
|
||||||
|
if _is_ad(text): # 광고는 수집 단계에서 제외 — 어떤 추천 경로로도 카드가 못 된다
|
||||||
|
continue
|
||||||
out.append({
|
out.append({
|
||||||
"idx": i,
|
"idx": len(out),
|
||||||
"authorName": str(c.get("authorName") or ""),
|
"authorName": str(c.get("authorName") or ""),
|
||||||
"text": str(c.get("text") or ""),
|
"text": text,
|
||||||
"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 ""),
|
||||||
|
|||||||
@ -113,6 +113,10 @@ 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).
|
||||||
@ -313,15 +317,20 @@ 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) * COMMENT_SCALE
|
disp_h = cw * (ih / iw) * eff
|
||||||
|
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=COMMENT_SCALE, scale_y=COMMENT_SCALE,
|
clip_settings=p.ClipSettings(scale_x=eff, scale_y=eff,
|
||||||
transform_x=0.0, transform_y=cy),
|
transform_x=0.0, transform_y=cy),
|
||||||
), "comment")
|
), "comment")
|
||||||
|
|
||||||
|
|||||||
@ -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
|
from typing import AsyncIterator, Dict, List, Tuple
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@ -167,6 +167,21 @@ 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"
|
||||||
@ -187,33 +202,46 @@ BG_STEPS: List[Dict[str, str]] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
async def process_bg_template(
|
def bg_steps(youtube: Optional[dict]) -> List[Dict[str, str]]:
|
||||||
|
"""배경템플릿 파이프라인의 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]:
|
||||||
"""배경템플릿 파이프라인: [유튜브 구간 다운로드] → 무음컷 → 자막 → 드래프트. SSE 이벤트."""
|
"""배경템플릿 파이프라인 앞부분: [유튜브 구간 다운로드] → probe → 무음컷 → 받아쓰기.
|
||||||
|
|
||||||
|
마지막에 `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 [])
|
|
||||||
steps += [{"id": "silence", "label": "무음·발화 분석"},
|
# 구간 탭이 "구간 N이 타임라인의 어디인지" 알아야 자막·배치·장수를 컷 단위로 낼 수 있다.
|
||||||
{"id": "asr", "label": "받아쓰기 (Gemini/Whisper)"},
|
# 파일 탭(유튜브 아님)은 빈 리스트 → 아무 데서도 안 쓰인다.
|
||||||
{"id": "draft", "label": "템플릿 드래프트 생성"}]
|
ranges_sec: List[Tuple[float, float]] = []
|
||||||
yield {"type": "manifest", "steps": steps}
|
raw_places: List[Tuple[float, float]] = []
|
||||||
|
|
||||||
# ── 유튜브 여러 구간 다운로드 + 병합 (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}]"}
|
||||||
@ -248,6 +276,8 @@ async def process_bg_template(
|
|||||||
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),
|
||||||
@ -286,6 +316,42 @@ async def process_bg_template(
|
|||||||
"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": "장면전환 감지 중… (화면 바뀌는 컷 찾기)"}
|
||||||
@ -299,7 +365,8 @@ async def process_bg_template(
|
|||||||
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 = _load_comment_cards(comments_dir, total, fixed=cards_fixed)
|
cards = comment_cards if comment_cards is not None else _load_comment_cards(
|
||||||
|
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(
|
||||||
@ -329,6 +396,43 @@ async def process_bg_template(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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 흰밴드 자동감지였는데, 좌표를 정확히 통제하려고 상수로 바꿨다)
|
||||||
@ -371,26 +475,30 @@ def _template_pos(white: bool = False):
|
|||||||
return frame, bg, pos
|
return frame, bg, pos
|
||||||
|
|
||||||
|
|
||||||
async def process_paste(
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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]:
|
||||||
"""붙여넣기(JSON) 파이프라인: 컷 정밀 다운로드·병합 → 공급된 자막 2트랙으로 드래프트.
|
"""붙여넣기 파이프라인 앞부분: 컷 정밀 다운로드·병합 → probe → 컷 배치·자막 계산 →
|
||||||
|
[무음 제거] → [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)]
|
||||||
@ -399,12 +507,6 @@ async def process_paste(
|
|||||||
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)}개 컷 정밀 다운로드·병합 중… (프레임 정확 컷)"}
|
||||||
@ -498,6 +600,47 @@ async def process_paste(
|
|||||||
"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()
|
||||||
@ -557,6 +700,31 @@ async def process_paste(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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": "점프컷 드래프트 생성"},
|
||||||
|
|||||||
@ -93,10 +93,12 @@ 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}] (초).
|
"""`{"candidates":[{id,start_time,end_time,reason}, …]}` → [{id,start,end,reason,title_top,title_main}] (초).
|
||||||
|
|
||||||
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)
|
||||||
@ -116,7 +118,9 @@ 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
|
||||||
|
|||||||
@ -9,6 +9,9 @@ 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
|
||||||
|
|
||||||
@ -19,6 +22,17 @@ 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장.
|
||||||
@ -31,45 +45,120 @@ 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"}]
|
Returns: 컷별 [{"idx": int, "why": "ts"|"ai"|"word"|"like"}]
|
||||||
"""
|
"""
|
||||||
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()
|
||||||
out: List[List[dict]] = []
|
quota = [quotas[i] if i < len(quotas) else 0 for i in range(len(cuts))]
|
||||||
|
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(picks) >= quota:
|
if len(out[i]) >= quota[i]:
|
||||||
break
|
break
|
||||||
if idx not in used:
|
if idx not in used:
|
||||||
picks.append({"idx": idx, "why": "ts"})
|
out[i].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(picks) >= quota:
|
if len(out[i]) >= quota[i]:
|
||||||
break
|
break
|
||||||
if idx in valid and idx not in used:
|
if idx in valid and idx not in used:
|
||||||
picks.append({"idx": idx, "why": "ai"})
|
out[i].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 # 댓글 본문 절단 길이
|
||||||
AI_MODEL = "gemini-2.5-flash" # correct.py 와 같은 무료 티어 모델
|
# gemini-2.5-flash 는 실측상 HTTP 503("high demand")이 잦아(3연속 503도 관측됨) 컷별
|
||||||
|
# 추천이 통째로 비는 원인이었다. 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}")
|
||||||
|
|
||||||
@ -168,13 +257,27 @@ 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 # 429 포함 — 폴백은 호출부 몫
|
return None # 폴백은 호출부 몫
|
||||||
|
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):
|
||||||
@ -184,49 +287,60 @@ def ai_pick_cuts(cuts, comments, quotas, *, model: str = AI_MODEL,
|
|||||||
return _rows_to_picks(rows)
|
return _rows_to_picks(rows)
|
||||||
|
|
||||||
|
|
||||||
def is_whole(cuts) -> bool:
|
def is_time_based(cuts) -> bool:
|
||||||
"""통짜 하이라이트인가 — 컷 1개 + 자막 없음(app._whole_hl 이 만드는 모양).
|
"""시각 기반으로 배정할 컷 묶음인가 — **모든 컷의 자막이 비었는가**.
|
||||||
|
|
||||||
통짜는 자막이 없어 내용 추천의 근거가 없다. 대신 타임라인 시각 = 원본 시각이라
|
자막이 없으면 내용 추천의 근거가 없다. 대신 이런 묶음(통짜 모드·유튜브 구간 탭)은
|
||||||
시각으로 정확히 맞출 수 있다(스펙 §4.1).
|
구간을 그대로 이어붙이므로 타임라인 시각 = 원본 시각이 성립해 시각으로 맞출 수 있다.
|
||||||
|
컷 1개짜리 통짜는 이 규칙의 특수 케이스다(스펙 §1).
|
||||||
"""
|
"""
|
||||||
return len(cuts) == 1 and not (cuts[0].get("bottom") or "").strip()
|
if not cuts:
|
||||||
|
return False
|
||||||
|
return all(not (c.get("bottom") or "").strip() for c in cuts)
|
||||||
|
|
||||||
|
|
||||||
def _whole_picks(cut, comments, n: int) -> List[dict]:
|
def _time_based_picks(cuts, comments, quotas) -> List[List[dict]]:
|
||||||
"""통짜 — 슬롯마다 그 시간대 언급 댓글, 빈 슬롯은 좋아요 상위로 메움."""
|
"""컷마다 3초 슬롯 배정 — 슬롯 시간대 언급 댓글, 빈 슬롯은 좋아요 상위로 채움.
|
||||||
slots = match_slots(comments, cut["start"], cut["end"] - cut["start"], n)
|
|
||||||
used = {i for i in slots if i is not None}
|
`used` 를 컷 사이에 공유해 한 댓글이 두 컷에 들어가지 않게 한다(앞 컷 우선).
|
||||||
|
"""
|
||||||
|
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))
|
||||||
out: List[dict] = []
|
picks: List[dict] = []
|
||||||
for s in slots:
|
for s in slots:
|
||||||
if s is not None:
|
if s is not None:
|
||||||
out.append({"idx": s, "why": "ts"})
|
picks.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:
|
||||||
out.append({"idx": nxt, "why": "like"})
|
picks.append({"idx": nxt, "why": "like"})
|
||||||
|
used.add(nxt)
|
||||||
|
out.append(picks)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def build_highlight_cuts(hl, comments, *, key=None):
|
def build_highlight_cuts(hl, comments, *, key=None, quotas=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 — 기존 int(total//3) 을 대체한다(컷 경계에 맞추는 쪽이 맞다).
|
need = Σ quota. Gemini 실패는 ai_failed=True 로 알린다(예외는 안 올린다).
|
||||||
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 = quotas_for(cuts)
|
quotas = list(quotas) if quotas is not None else quotas_for(cuts)
|
||||||
ai_failed = False
|
ai_failed = False
|
||||||
if is_whole(cuts):
|
if is_time_based(cuts):
|
||||||
picks = [_whole_picks(cuts[0], comments, quotas[0])]
|
picks = _time_based_picks(cuts, comments, quotas)
|
||||||
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
|
||||||
@ -235,3 +349,30 @@ def build_highlight_cuts(hl, comments, *, key=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
|
||||||
|
|||||||
763
docs/superpowers/plans/2026-08-04-1단계-파이프라인-분할.md
Normal file
763
docs/superpowers/plans/2026-08-04-1단계-파이프라인-분할.md
Normal file
@ -0,0 +1,763 @@
|
|||||||
|
# 1단계: 파이프라인 분할 + 추천 엔진 준비 + 렌더러 통합 — 구현 계획
|
||||||
|
|
||||||
|
> **에이전트 작업자용:** 이 계획은 `superpowers:subagent-driven-development`(권장) 또는
|
||||||
|
> `superpowers:executing-plans`로 태스크 단위로 실행한다. 단계는 체크박스(`- [ ]`)로 추적한다.
|
||||||
|
|
||||||
|
**목표:** 두 파이프라인을 `analyze`/`draft` 두 조각으로 쪼개고, 추천 엔진이 컷별 자막·장수를
|
||||||
|
밖에서 받을 수 있게 만든다. **사용자에게 보이는 동작은 하나도 바뀌지 않는다.**
|
||||||
|
|
||||||
|
**접근:** 각 파이프라인을 "받아쓰기까지"와 "드래프트 생성"으로 나누고, 중간 상태를
|
||||||
|
`{"type":"state"}` 이벤트로 넘긴다. 기존 함수는 두 조각을 연달아 부르는 얇은 래퍼로 남겨
|
||||||
|
**SSE 이벤트 스트림이 문자 그대로 같게** 유지한다.
|
||||||
|
|
||||||
|
**기술 스택:** Python 3.13 / FastAPI / 바닐라 JS / 표준 라이브러리만
|
||||||
|
|
||||||
|
**스펙:** `docs/superpowers/specs/2026-08-04-받아쓰기후-댓글매칭-design.md` (§3, §5, §6, §10, 13단계 표의 1단계)
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- **코드를 고쳤으면 `캡컷_에이전트_구간합치기.bat`을 재시작한다.** uvicorn hot-reload가 없다.
|
||||||
|
- **테스트 프레임워크가 없다. pytest를 도입하지 마라.** 검증은 인라인 assert 스크립트로 한다.
|
||||||
|
스크립트는 `C:\Users\hehih\AppData\Local\Temp\claude\D-------00----capcut2\d2b52aec-0e0e-4485-b47f-a854f6b9c528\scratchpad`
|
||||||
|
아래에 두고 **저장소에 커밋하지 마라**(이 프로젝트에는 테스트 디렉터리가 없다).
|
||||||
|
- 검증 스크립트 첫 줄에 `import sys; sys.stdout.reconfigure(encoding='utf-8')` — 콘솔이 cp949다.
|
||||||
|
- **네트워크를 쓰지 마라.** 회귀 검증은 `.downloads/`의 기존 mp4를 재사용한다.
|
||||||
|
- **이 단계에서 사용자에게 보이는 동작이 바뀌면 결함이다.** 새 기능은 2~4단계에서 쓴다.
|
||||||
|
- 카드 1장 기준 길이는 **3.0초**(`recommend.CARD_SEC`), 컷별 장수는 **`max(1, floor(길이/3))`**.
|
||||||
|
- **한국어 docstring·주석**, 표준 라이브러리만, 기존 `capcut_agent/*.py` 스타일.
|
||||||
|
- **바닐라 JS.** 빌드 도구·npm 패키지 도입 금지.
|
||||||
|
- git 브랜치 `feat/yt-range-comments`. 태스크마다 커밋. 메시지는 한국어 한 줄 요약 +
|
||||||
|
왜 그렇게 했는지 본문, 마지막 줄에 `Co-Authored-By: Claude Opus 5 (1M context) <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)` — 둘 다 압축 타임라인 좌표
|
||||||
659
docs/superpowers/plans/2026-08-05-2~4단계-세탭-받아쓰기후-매칭.md
Normal file
659
docs/superpowers/plans/2026-08-05-2~4단계-세탭-받아쓰기후-매칭.md
Normal file
@ -0,0 +1,659 @@
|
|||||||
|
# 2~4단계: 세 탭 모두 받아쓰기 후 댓글 매칭 — 구현 계획
|
||||||
|
|
||||||
|
> **에이전트 작업자용:** 이 계획은 `superpowers:subagent-driven-development`(권장) 또는
|
||||||
|
> `superpowers:executing-plans`로 태스크 단위로 실행한다. 단계는 체크박스(`- [ ]`)로 추적한다.
|
||||||
|
|
||||||
|
**목표:** ▶ 유튜브 구간 · 📋 붙여넣기 · 🤖 자동 세 탭이 모두
|
||||||
|
`다운로드 → 무음 → 받아쓰기 → 댓글 매칭 → 검토 → 드래프트` 순으로 돌게 한다.
|
||||||
|
|
||||||
|
**접근:** 1단계에서 쪼갠 `*_analyze`/`*_draft` 위에 얹는다. 서버 쪽 조립 로직을
|
||||||
|
`recommend.cuts_from_state()` 하나로 모아 세 탭이 같은 코드를 쓰고, 화면은 이미 통합된
|
||||||
|
`renderCutPanel()`을 재사용한다. 탭마다 다른 것은 **편집안을 어디서 얻느냐**뿐이다.
|
||||||
|
|
||||||
|
**기술 스택:** Python 3.13 / FastAPI(SSE) / 바닐라 JS / 표준 라이브러리만
|
||||||
|
|
||||||
|
**스펙:** `docs/superpowers/specs/2026-08-04-받아쓰기후-댓글매칭-design.md`
|
||||||
|
**선행:** `docs/superpowers/plans/2026-08-04-1단계-파이프라인-분할.md` (완료)
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- **코드를 고쳤으면 `캡컷_에이전트_구간합치기.bat`을 재시작한다.** hot-reload가 없다.
|
||||||
|
- **테스트 프레임워크가 없다. pytest를 도입하지 마라.** 검증은 인라인 assert.
|
||||||
|
스크립트는 `C:\Users\hehih\AppData\Local\Temp\claude\D-------00----capcut2\d2b52aec-0e0e-4485-b47f-a854f6b9c528\scratchpad`
|
||||||
|
아래에 두고 **저장소에 커밋하지 마라**.
|
||||||
|
- 검증 스크립트 첫 줄에 `import sys; sys.stdout.reconfigure(encoding='utf-8')` (콘솔 cp949).
|
||||||
|
- **서버 실행·브라우저 열기 금지.** 실사용 확인은 사용자가 한다.
|
||||||
|
- **네트워크는 꼭 필요할 때만.** 순수 함수 검증에는 쓰지 마라.
|
||||||
|
- **좌표계를 섞지 마라 (이 계획에서 가장 위험):**
|
||||||
|
- **매칭(⭐)** = 원본 영상 시각 — 댓글의 `9:05`와 맞춰야 한다
|
||||||
|
- **자막 추출·배치·장수** = 압축 타임라인(무음 제거 후) — 실제 자리다
|
||||||
|
- 배정 순위는 `⭐ts → 🤖ai → 🔤word → ➕like` (라운드 방식, 이미 구현됨).
|
||||||
|
- 카드 1장 기준 3.0초(`recommend.CARD_SEC`), 컷별 장수 `max(1, floor(길이/3))`.
|
||||||
|
- **한국어 docstring·주석**, 표준 라이브러리만, 기존 스타일. 바닐라 JS(빌드 도구 금지).
|
||||||
|
- **어떤 경우에도 드래프트 생성을 막지 마라**(다운로드 실패 제외).
|
||||||
|
- git 브랜치 `feat/yt-range-comments`. 태스크마다 커밋. 메시지는 한국어 한 줄 요약 + 왜,
|
||||||
|
마지막 줄에 `Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>`.
|
||||||
|
- `assets/frame_template.png`는 파이프라인이 매 실행 다시 굽는 산출물 — 더러워지면
|
||||||
|
`git checkout --`로 되돌려라.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 이미 있는 부품 (전부 검증 완료, 그대로 쓴다)
|
||||||
|
|
||||||
|
| 부품 | 위치 | 하는 일 |
|
||||||
|
|---|---|---|
|
||||||
|
| `bg_analyze` / `bg_draft` / `bg_steps` | `pipeline.py` | 파일·구간 탭 파이프라인 두 조각 |
|
||||||
|
| `paste_analyze` / `paste_draft` / `paste_steps` | `pipeline.py` | 자동(컷 모드)·붙여넣기 탭 두 조각 |
|
||||||
|
| `captions_for_places(captions, places, *, cap=500)` | `pipeline.py` | 압축 좌표 구간별 자막 이어붙이기 |
|
||||||
|
| `_remap_placements(placements, keep)` | `pipeline.py` | 원본 누적 구간 → 압축 타임라인 |
|
||||||
|
| `_cards_by_cut(paths, card_cuts, places, dur, *, fixed)` | `pipeline.py` | 카드를 컷 구간 안에 배치 |
|
||||||
|
| `build_highlight_cuts(hl, comments, *, key, quotas)` | `recommend.py` | `(cuts, need, ai_failed)` |
|
||||||
|
| `is_time_based(cuts)` | `recommend.py` | 모든 컷 자막이 비면 시각 기반 |
|
||||||
|
| `renderCutPanel(box, panelId, data, opts)` | `auto.js` | 컷별 섹션·배지·검색·상한 |
|
||||||
|
| `/auto/analyze` → `/auto/stream/{aid}` → `/auto/build` | `app.py` | **세 탭이 따라야 할 본보기** |
|
||||||
|
| `ANALYSES` / `JOBS` 메모리 dict | `app.py` | 분석·작업 보관 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 파일 구조
|
||||||
|
|
||||||
|
| 파일 | 이 계획에서의 책임 |
|
||||||
|
|---|---|
|
||||||
|
| `capcut_agent/pipeline.py` | `bg_analyze`가 구간 위치를 state에 담게 한다 (Task 1) |
|
||||||
|
| `capcut_agent/recommend.py` | **`cuts_from_state()`** — 상태+댓글 → `cuts[]`. 세 탭 공통 (Task 2) |
|
||||||
|
| `server/app.py` | 탭별 analyze/stream/build 엔드포인트 (Task 3·5·7) |
|
||||||
|
| `server/static/index.html` | 탭별 화면 골격 (Task 4·6·7) |
|
||||||
|
| `server/static/auto.js` | 탭별 흐름 제어. 렌더는 `renderCutPanel` 재사용 (Task 4·6·7) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: `bg_analyze`가 구간 위치를 state에 담는다
|
||||||
|
|
||||||
|
▶ 구간 탭이 "구간 N이 압축 타임라인의 어디인지"를 알아야 자막 추출·배치·장수가 된다.
|
||||||
|
지금 `bg_analyze`는 유튜브 구간을 다운로드만 하고 그 경계를 안 남긴다.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `capcut_agent/pipeline.py` (`bg_analyze`)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `bg_analyze` state에 세 키 추가
|
||||||
|
- `ranges_sec: List[Tuple[float,float]]` — 구간의 **원본 영상 시각**(초). 유튜브가 아니면 `[]`
|
||||||
|
- `raw_places: List[Tuple[float,float]]` — 병합본(무음 제거 전) 누적 구간
|
||||||
|
- `places: List[Tuple[float,float]]` — **압축 타임라인** 구간 (`_remap_placements(raw_places, keep)`)
|
||||||
|
- 파일 탭(유튜브 아님)은 세 키 모두 `[]` — 래퍼가 안 쓰므로 동작 불변
|
||||||
|
|
||||||
|
- [ ] **Step 1: 검증 스크립트 작성**
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys; sys.stdout.reconfigure(encoding='utf-8')
|
||||||
|
from capcut_agent.pipeline import _remap_placements
|
||||||
|
# 구간 3개(원본 16:15~16:35, 15:30~16:15, 23:20~23:52) → 병합본 누적
|
||||||
|
rs=[(975.0,995.0),(930.0,975.0),(1400.0,1432.0)]
|
||||||
|
raw,c=[],0.0
|
||||||
|
for s,e in rs:
|
||||||
|
raw.append((c,c+(e-s))); c+=e-s
|
||||||
|
assert raw==[(0.0,20.0),(20.0,65.0),(65.0,97.0)], raw
|
||||||
|
# 무음 제거로 앞 10초가 통째로 잘린 경우
|
||||||
|
keep=[(10.0,97.0)]
|
||||||
|
assert _remap_placements(raw,keep)==[(0.0,10.0),(10.0,55.0),(55.0,87.0)], _remap_placements(raw,keep)
|
||||||
|
print("Task1 계산식 OK")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 실패 확인** — 위 스크립트는 기존 함수만 쓰므로 **통과한다.**
|
||||||
|
이 태스크의 실패 확인은 state 키다:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, inspect, re; sys.stdout.reconfigure(encoding='utf-8')
|
||||||
|
from capcut_agent import pipeline
|
||||||
|
src=inspect.getsource(pipeline.bg_analyze)
|
||||||
|
m=re.search(r'"state":\s*\{(.+?)\n\s*\}\s*\}', src, re.S)
|
||||||
|
keys=set(re.findall(r'"(\w+)":', m.group(1)))
|
||||||
|
missing={"ranges_sec","raw_places","places"}-keys
|
||||||
|
assert not missing, f"state 에 없음: {missing}"
|
||||||
|
print("Task1 상태키 OK")
|
||||||
|
```
|
||||||
|
|
||||||
|
Run 후 Expected: FAIL — `AssertionError: state 에 없음: {...}`
|
||||||
|
|
||||||
|
- [ ] **Step 3: 구현**
|
||||||
|
|
||||||
|
`bg_analyze`의 유튜브 다운로드 분기에서 구간을 **초**로 파싱해 보관하고, 무음 분석 뒤
|
||||||
|
압축 좌표로 옮긴다. 시간 문자열 → 초 변환은 `capcut_agent/paste.py`의 `parse_time`을 쓴다
|
||||||
|
(이미 `mm:ss` / `hh:mm:ss`를 처리한다).
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 구간 탭이 "구간 N이 타임라인의 어디인지" 알아야 자막·배치·장수를 컷 단위로 낼 수 있다.
|
||||||
|
# 파일 탭(유튜브 아님)은 빈 리스트 → 아무 데서도 안 쓰인다.
|
||||||
|
ranges_sec: List[Tuple[float, float]] = []
|
||||||
|
raw_places: List[Tuple[float, float]] = []
|
||||||
|
if youtube:
|
||||||
|
from .paste import parse_time
|
||||||
|
c = 0.0
|
||||||
|
for s, e in (youtube.get("ranges") or []):
|
||||||
|
ss, ee = parse_time(str(s)), parse_time(str(e))
|
||||||
|
ranges_sec.append((ss, ee))
|
||||||
|
raw_places.append((c, c + (ee - ss)))
|
||||||
|
c += ee - ss
|
||||||
|
```
|
||||||
|
|
||||||
|
무음 분석(`keep`)이 끝난 뒤:
|
||||||
|
|
||||||
|
```python
|
||||||
|
places = _remap_placements(raw_places, keep) if raw_places else []
|
||||||
|
```
|
||||||
|
|
||||||
|
state에 `ranges_sec`, `raw_places`, `places` 세 키를 더한다.
|
||||||
|
|
||||||
|
- [ ] **Step 4: 통과 확인** — 두 스크립트 모두 통과
|
||||||
|
|
||||||
|
- [ ] **Step 5: 회귀 확인 — 파일 탭 이벤트 스트림 불변**
|
||||||
|
|
||||||
|
1단계에서 쓴 덤프 스크립트를 그대로 재사용한다:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "D:/개인폴더/00.유튭/capcut2"
|
||||||
|
python <스크래치패드>/dump_events.py <스크래치패드>/after-t1.json
|
||||||
|
python -c "
|
||||||
|
import json,sys; sys.stdout.reconfigure(encoding='utf-8')
|
||||||
|
a=json.load(open(r'<스크래치패드>\before.json',encoding='utf-8'))
|
||||||
|
b=json.load(open(r'<스크래치패드>\after-t1.json',encoding='utf-8'))
|
||||||
|
assert a==b, '이벤트가 달라졌다'
|
||||||
|
print('파일 탭 회귀 OK —',len(a),'이벤트 동일')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
만든 테스트 드래프트(`__회귀테스트`)를 지운다.
|
||||||
|
|
||||||
|
- [ ] **Step 6: 구문·임포트 검증 후 커밋**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: `cuts_from_state()` — 세 탭 공통 조립
|
||||||
|
|
||||||
|
상태(받아쓰기 결과 + 구간 위치)와 댓글을 받아 검토 화면이 쓸 `cuts[]`를 만든다.
|
||||||
|
**세 탭이 이 함수 하나만 부른다.**
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `capcut_agent/recommend.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `pipeline.captions_for_places`, `build_highlight_cuts`
|
||||||
|
- Produces:
|
||||||
|
`cuts_from_state(places, orig_ranges, captions, comments, *, key=None) -> (cuts, need, ai_failed)`
|
||||||
|
- `places` — 압축 타임라인 구간 `[(p0,p1)]` (자막·장수·배치 기준)
|
||||||
|
- `orig_ranges` — 같은 길이의 **원본 영상 시각** `[(s,e)]` (⭐ 매칭 기준)
|
||||||
|
- `captions` — 압축 타임라인 기준 `[(s,e,text)]`
|
||||||
|
- 반환은 `build_highlight_cuts`와 같은 3-튜플
|
||||||
|
|
||||||
|
- [ ] **Step 1: 실패하는 검증 스크립트 작성**
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys; sys.stdout.reconfigure(encoding='utf-8')
|
||||||
|
from capcut_agent.recommend import cuts_from_state
|
||||||
|
|
||||||
|
places=[(0.0,10.0),(10.0,22.0)] # 압축 타임라인
|
||||||
|
orig =[(975.0,995.0),(930.0,975.0)] # 원본 시각 (구간 순서대로)
|
||||||
|
caps =[(1.0,3.0,"이거 100만원짜리 와인이야"),
|
||||||
|
(11.0,13.0,"넉살이 랩을 진짜 잘하더라고")]
|
||||||
|
cs=[{"idx":0,"likeCount":145,"times":[],"text":"요즘 넉살님 좋아요 랩도 잘하신다"},
|
||||||
|
{"idx":1,"likeCount":95,"times":[],"text":"100만원짜리 와인 맛이 다른가요"},
|
||||||
|
{"idx":2,"likeCount":80,"times":[980.0],"text":"여기 진짜 웃김"},
|
||||||
|
{"idx":3,"likeCount":50,"times":[],"text":"봄 대단하넼"}]
|
||||||
|
|
||||||
|
cuts,need,failed=cuts_from_state(places,orig,caps,cs,key="") # key="" → Gemini 안 씀
|
||||||
|
assert [c["i"] for c in cuts]==[0,1]
|
||||||
|
assert [c["quota"] for c in cuts]==[3,4], [c["quota"] for c in cuts] # floor(10/3), floor(12/3)
|
||||||
|
assert need==7 and failed is False
|
||||||
|
assert cuts[0]["bottom"]=="이거 100만원짜리 와인이야", cuts[0]["bottom"]
|
||||||
|
assert cuts[1]["bottom"]=="넉살이 랩을 진짜 잘하더라고"
|
||||||
|
# ⭐ 는 원본 시각 기준 — 980초는 구간0(975~995) 안이다
|
||||||
|
w0=[p for p in cuts[0]["picks"] if p["idx"]==2]
|
||||||
|
assert w0 and w0[0]["why"]=="ts", cuts[0]["picks"]
|
||||||
|
# 🔤 는 자막 단어 기준 — '넉살' 이 구간1 에 붙어야 한다
|
||||||
|
assert any(p["idx"]==0 and p["why"]=="word" for p in cuts[1]["picks"]), cuts[1]["picks"]
|
||||||
|
# 자막이 하나도 없으면 시각 기반 경로로 떨어진다(예외 없이)
|
||||||
|
c2,n2,f2=cuts_from_state(places,orig,[],cs,key="")
|
||||||
|
assert n2==7 and f2 is False and all(c["bottom"]=="" for c in c2)
|
||||||
|
assert cuts_from_state([],[],[],cs,key="")==([],0,False)
|
||||||
|
print("Task2 OK")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 실패 확인**
|
||||||
|
|
||||||
|
Expected: FAIL — `ImportError: cannot import name 'cuts_from_state'`
|
||||||
|
|
||||||
|
- [ ] **Step 3: 구현**
|
||||||
|
|
||||||
|
`capcut_agent/recommend.py` 끝에 추가한다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def cuts_from_state(places, orig_ranges, captions, comments, *, key=None):
|
||||||
|
"""받아쓰기 상태 + 댓글 → 검토 화면용 (cuts[], need, ai_failed). 세 탭 공통.
|
||||||
|
|
||||||
|
⚠ 좌표계가 둘이다. 섞으면 카드가 통째로 어긋난다(스펙 §4):
|
||||||
|
- `places`·`captions` = 압축 타임라인 → 자막 추출·장수·배치
|
||||||
|
- `orig_ranges` = 원본 영상 시각 → ⭐ 분:초 매칭
|
||||||
|
둘은 같은 길이여야 하고 인덱스로만 짝지어 다닌다.
|
||||||
|
"""
|
||||||
|
from .pipeline import captions_for_places # 순환 임포트 회피 — 호출 시점에 가져온다
|
||||||
|
if not places or len(places) != len(orig_ranges):
|
||||||
|
return [], 0, False
|
||||||
|
bottoms = captions_for_places(captions, places)
|
||||||
|
quotas = [max(1, int((p1 - p0) // CARD_SEC)) for p0, p1 in places]
|
||||||
|
cuts = [{"start": s, "end": e, "bottom": b, "effect": ""}
|
||||||
|
for (s, e), b in zip(orig_ranges, bottoms)]
|
||||||
|
return build_highlight_cuts({"paste": {"cuts": cuts}}, comments,
|
||||||
|
key=key, quotas=quotas)
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠ `pipeline`을 파일 상단에서 임포트하면 순환이 된다(`pipeline` → `draft`·`comments`,
|
||||||
|
그리고 `app.py`가 둘 다 임포트). **함수 안에서 임포트해라.** 그 이유를 주석에 남겨라.
|
||||||
|
|
||||||
|
- [ ] **Step 4: 통과 확인** → `Task2 OK`
|
||||||
|
|
||||||
|
- [ ] **Step 5: 구문·임포트 검증 후 커밋**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: 📋 붙여넣기 탭 — 서버
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `server/app.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces:
|
||||||
|
- `POST /paste/analyze` (form `data`) → `{"analysis_id": aid}` — `parse_paste`로 검증만
|
||||||
|
- `GET /paste/stream/{aid}` (SSE) — `paste_analyze` 실행 + h-lab 댓글 수집 →
|
||||||
|
`result` 이벤트 `{cuts, need, total, comments, candidates, warnings}`
|
||||||
|
- `POST /paste/build` (form `aid`, `cards`, `card_cuts`, 옵션들) → `{"job_id": h}`
|
||||||
|
- `PSTATES: dict[str, dict]` — `aid` → `{"state": …, "places": …, "orig": …, "payload": …}`
|
||||||
|
|
||||||
|
- [ ] **Step 1: `/paste/analyze` 추가**
|
||||||
|
|
||||||
|
`/auto/analyze` 바로 아래에 둔다. `parse_paste(data)`가 던지면 400.
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.post("/paste/analyze")
|
||||||
|
async def paste_analyze_start(data: str = Form(...)) -> JSONResponse:
|
||||||
|
"""📋 붙여넣기 탭 1단계 — 편집안 검증 후 분석 예약. 실제 작업은 /paste/stream 에서."""
|
||||||
|
try:
|
||||||
|
payload = parse_paste(data)
|
||||||
|
except ValueError as e:
|
||||||
|
return JSONResponse({"error": str(e)}, 400)
|
||||||
|
aid = hashlib.sha1(("paste|" + data).encode()).hexdigest()[:12]
|
||||||
|
ANALYSES[aid] = {"payload": payload}
|
||||||
|
return JSONResponse({"analysis_id": aid})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: `/paste/stream/{aid}` 추가**
|
||||||
|
|
||||||
|
`paste_analyze`를 돌리면서 이벤트를 흘리고, `state` 이벤트를 잡아 보관한 뒤
|
||||||
|
h-lab 댓글과 합쳐 `cuts[]`를 만든다. manifest에는 `recommend` 스텝을 넣는다.
|
||||||
|
|
||||||
|
핵심 골격(기존 `/auto/stream`의 패턴을 따른다):
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.get("/paste/stream/{aid}")
|
||||||
|
async def paste_stream(aid: str) -> StreamingResponse:
|
||||||
|
"""📋 붙여넣기 탭 — 다운로드·무음·받아쓰기 → 댓글 매칭 → 검토 화면용 result."""
|
||||||
|
a = ANALYSES.get(aid)
|
||||||
|
|
||||||
|
async def gen():
|
||||||
|
if not a:
|
||||||
|
yield _sse({"type": "error", "message": "알 수 없는 분석입니다."})
|
||||||
|
return
|
||||||
|
payload = a["payload"]
|
||||||
|
warnings: list[str] = []
|
||||||
|
yield _sse({"type": "manifest", "steps": pipeline_paste_steps(True) +
|
||||||
|
[{"id": "comments", "label": "댓글 수집 (h-lab)"},
|
||||||
|
{"id": "recommend", "label": "컷별 댓글 추천"}]})
|
||||||
|
com_task = asyncio.create_task(
|
||||||
|
asyncio.to_thread(hlab.fetch_comments, payload["url"]))
|
||||||
|
state = None
|
||||||
|
async for ev in paste_analyze(payload, "paste_" + aid,
|
||||||
|
remove_silence=True, asr_bottom=True):
|
||||||
|
if ev.get("type") == "state":
|
||||||
|
state = ev["state"]
|
||||||
|
continue
|
||||||
|
if ev.get("type") == "error":
|
||||||
|
com_task.cancel()
|
||||||
|
yield _sse(ev)
|
||||||
|
return
|
||||||
|
yield _sse(ev)
|
||||||
|
…
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠ **`remove_silence=True, asr_bottom=True` 로 고정한다.** 추천을 위해 받아쓰기가
|
||||||
|
항상 필요하기 때문이다(스펙 §5). 사용자가 화면에서 끄는 옵션은 **화면 자막에만** 적용되며
|
||||||
|
그건 build 단계에서 처리한다 — 이 태스크에서는 analyze를 항상 그렇게 돌린다는 것만 지킨다.
|
||||||
|
|
||||||
|
댓글 수집 후:
|
||||||
|
|
||||||
|
```python
|
||||||
|
comments = []
|
||||||
|
try:
|
||||||
|
comments = await com_task
|
||||||
|
yield _sse({"type": "step", "id": "comments", "status": "done",
|
||||||
|
"detail": f"{len(comments)}개"})
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
warnings.append(f"h-lab 연결 실패 — 댓글 없이 진행합니다 ({exc})")
|
||||||
|
yield _sse({"type": "step", "id": "comments", "status": "done",
|
||||||
|
"detail": "실패(생략)"})
|
||||||
|
|
||||||
|
yield _sse({"type": "step", "id": "recommend", "status": "start"})
|
||||||
|
places = state["card_places"]
|
||||||
|
orig = [(s, e) for s, e, _, _ in state["cuts"]]
|
||||||
|
cuts, need, ai_failed = await asyncio.to_thread(
|
||||||
|
recommend.cuts_from_state, places, orig, state["bottom_caps"], comments)
|
||||||
|
if ai_failed:
|
||||||
|
warnings.append("AI 추천 실패(Gemini 응답 없음) — 분:초·자막단어·좋아요로 배정했습니다")
|
||||||
|
yield _sse({"type": "step", "id": "recommend", "status": "done",
|
||||||
|
"detail": f"{len(cuts)}컷 · 카드 {need}장"})
|
||||||
|
|
||||||
|
PSTATES[aid] = {"state": state, "places": places}
|
||||||
|
no_ts = [c for c in comments if not c["times"]]
|
||||||
|
matched = hlab.match_ranges(comments, orig) if comments else []
|
||||||
|
yield _sse({"type": "result", "cuts": cuts, "need": need,
|
||||||
|
"total": round(state["timeline_dur"], 1),
|
||||||
|
"cutRanges": [{"start": s, "end": e} for s, e in orig],
|
||||||
|
"matched": matched,
|
||||||
|
"candidates": hlab.top_liked(no_ts, set(matched), len(no_ts)),
|
||||||
|
"comments": comments, "warnings": warnings})
|
||||||
|
```
|
||||||
|
|
||||||
|
`PSTATES: dict[str, dict] = {}` 를 `ANALYSES` 옆에 선언한다.
|
||||||
|
`pipeline_paste_steps`는 `from capcut_agent.pipeline import paste_steps as pipeline_paste_steps`
|
||||||
|
같은 식으로 가져온다(기존 import 줄에 더해라).
|
||||||
|
|
||||||
|
- [ ] **Step 3: `/paste/build` 추가**
|
||||||
|
|
||||||
|
`aid`로 보관한 상태를 꺼내 `paste_draft`만 돌리는 job을 만든다. 카드 저장은
|
||||||
|
`/auto/build`의 코드를 그대로 따른다(`COMMENTS_DIR/<h>/001.png…`).
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.post("/paste/build")
|
||||||
|
async def paste_build(aid: str = Form(...), cards: list[UploadFile] = File(default=[]),
|
||||||
|
card_cuts: str = Form(""), video_scale: str = Form("144"),
|
||||||
|
flip: str = Form(""), scene: str = Form(""),
|
||||||
|
bg_white: str = Form(""), cards_fixed: str = Form(""),
|
||||||
|
asr_bottom: str = Form("1")) -> JSONResponse:
|
||||||
|
"""📋 붙여넣기 탭 3단계 — 보관한 상태로 드래프트만 만든다(다운로드·받아쓰기 안 함)."""
|
||||||
|
st = PSTATES.get(aid)
|
||||||
|
if not st:
|
||||||
|
return JSONResponse({"error": "분석 결과가 만료됐습니다. 다시 분석해 주세요."}, 404)
|
||||||
|
…
|
||||||
|
JOBS[h] = {"paste_state": st["state"], "card_cuts": cut_map, …}
|
||||||
|
return JSONResponse({"job_id": h})
|
||||||
|
```
|
||||||
|
|
||||||
|
`/stream/{job_id}`가 `paste_state`가 있으면 `paste_draft`를 부르도록 분기를 더한다
|
||||||
|
(`job.get("paste")` 분기 옆).
|
||||||
|
|
||||||
|
⚠ **`asr_bottom`이 꺼져 있으면** 화면 자막을 JSON `bottom`으로 되돌려야 한다.
|
||||||
|
`state["bottom_caps"]`는 받아쓰기 결과로 덮여 있으므로, 끈 경우 원래 JSON 자막으로
|
||||||
|
다시 만들어 넣어라(`state["cuts"]`와 `state["placements"]`로 계산 — `paste_analyze`가
|
||||||
|
하던 것과 같은 식). 이 처리를 빼면 옵션이 무시된다.
|
||||||
|
|
||||||
|
- [ ] **Step 4: 구문·임포트 검증**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -c "import ast; ast.parse(open('server/app.py', encoding='utf-8').read())"
|
||||||
|
python -c "from server import app; print('import OK')"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: 라우트 등록 확인**
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys; sys.stdout.reconfigure(encoding='utf-8')
|
||||||
|
from server.app import app
|
||||||
|
paths={r.path for r in app.routes}
|
||||||
|
for p in ("/paste/analyze","/paste/stream/{aid}","/paste/build"):
|
||||||
|
assert p in paths, f"{p} 없음: {sorted(paths)}"
|
||||||
|
print("Task3 라우트 OK")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: 커밋**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: 📋 붙여넣기 탭 — 화면
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `server/static/index.html`, `server/static/auto.js`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: Task 3의 세 엔드포인트, 기존 `renderCutPanel(box, panelId, data, opts)`
|
||||||
|
- Produces: `panelId = "paste"` 패널. 기존 붙여넣기 탭 실행 버튼이 분석을 돌린다
|
||||||
|
|
||||||
|
- [ ] **Step 1: 화면 골격**
|
||||||
|
|
||||||
|
붙여넣기 탭의 실행 버튼을 **`댓글 매칭 시작`**으로 바꾸고, 그 아래에 결과 영역
|
||||||
|
`<div id="pasteReview"></div>`와 생성 버튼 `<button id="pasteBuild">`를 둔다
|
||||||
|
(기본 숨김). 자동 탭의 검토 영역 마크업을 본보기로 삼되 하이라이트 탭 줄은 없다 —
|
||||||
|
붙여넣기 탭은 편집안이 하나뿐이다.
|
||||||
|
|
||||||
|
- [ ] **Step 2: 흐름 제어**
|
||||||
|
|
||||||
|
`auto.js`에 붙인다(렌더러가 거기 있으므로 같은 파일에 두는 게 맞다):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
/* ── 📋 붙여넣기 탭: 분석 → 검토 → 생성 ──
|
||||||
|
자동 탭과 같은 흐름이지만 편집안이 하나뿐이라 ID 탭이 없다. */
|
||||||
|
let PASTE_AID=null, PASTE_NEED=0;
|
||||||
|
async function pasteAnalyze(){
|
||||||
|
const fd=new FormData(); fd.append("data",$("#pdata").value);
|
||||||
|
const res=await(await fetch("/paste/analyze",{method:"POST",body:fd})).json();
|
||||||
|
if(res.error){ /* 기존 에러 표시 경로 */ return; }
|
||||||
|
PASTE_AID=res.analysis_id;
|
||||||
|
const es=new EventSource("/paste/stream/"+PASTE_AID);
|
||||||
|
es.onmessage=(m)=>{
|
||||||
|
const ev=JSON.parse(m.data);
|
||||||
|
if(ev.type==="result"){ es.close(); onPasteResult(ev); }
|
||||||
|
else { /* manifest·step·log 를 기존 진행 표시에 연결 */ }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function onPasteResult(ev){
|
||||||
|
byIdx={}; (ev.comments||[]).forEach(c=>byIdx[c.idx]=c);
|
||||||
|
PASTE_NEED=ev.need;
|
||||||
|
sel["paste"]=[]; selCut["paste"]={};
|
||||||
|
(ev.cuts||[]).forEach(cu=>(cu.picks||[]).forEach(p=>{
|
||||||
|
if(byIdx[p.idx]!==undefined&&!sel["paste"].includes(p.idx)){
|
||||||
|
sel["paste"].push(p.idx); selCut["paste"][p.idx]=cu.i;
|
||||||
|
}}));
|
||||||
|
const box=$("#pasteReview"); box.innerHTML="";
|
||||||
|
renderCutPanel(box,"paste",
|
||||||
|
{cuts:ev.cuts,matched:ev.matched,candidates:ev.candidates,cutRanges:ev.cutRanges},
|
||||||
|
{unit:"컷"});
|
||||||
|
refreshSel("paste");
|
||||||
|
$("#pasteBuild").style.display="block";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠ `refreshSel`·`toggle`·`cutFull`이 `hlById(panelId)`로 `need`/`cuts`를 찾는다.
|
||||||
|
붙여넣기 패널용 가짜 하이라이트를 하나 등록해야 한다(구간 탭의 `YT_HL`과 같은 방식):
|
||||||
|
`PASTE_HL={id:"paste", need:ev.need, cuts:ev.cuts, paste:{cuts:ev.cutRanges}}`를 만들고
|
||||||
|
`hlById`가 그걸 찾도록 더해라.
|
||||||
|
|
||||||
|
- [ ] **Step 3: 생성 버튼**
|
||||||
|
|
||||||
|
자동 탭 `buildAll`의 캡처 루프를 그대로 따른다. **반드시 지킬 것:**
|
||||||
|
- 캡처 전에 `clearSearch("paste")`, `expandAllCuts("paste")`, selonly 해제
|
||||||
|
- `fd.append("cards", …)`와 `sentCuts.push(ci)`는 **쌍으로만**
|
||||||
|
- 캡처 실패 시 `n--`
|
||||||
|
- `ci===undefined`면 그 카드는 건너뛴다(엉뚱한 컷에 붙이지 마라)
|
||||||
|
|
||||||
|
- [ ] **Step 4: 문법 검증**
|
||||||
|
|
||||||
|
Run: `node --check server/static/auto.js`
|
||||||
|
Expected: 출력 없음
|
||||||
|
|
||||||
|
- [ ] **Step 5: 커밋**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: ▶ 유튜브 구간 탭 — 서버
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `server/app.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `POST /yt/analyze`, `GET /yt/stream/{aid}`, `POST /yt/build`
|
||||||
|
(Task 3과 같은 모양. 다른 점은 `bg_analyze`를 쓰고 `state["places"]`·`state["ranges_sec"]`를 쓴다는 것)
|
||||||
|
- 삭제: `POST /yt/comments`, `POST /youtube` — 새 흐름이 대체한다
|
||||||
|
|
||||||
|
- [ ] **Step 1: 삭제 전 다른 호출부 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "D:/개인폴더/00.유튭/capcut2"
|
||||||
|
grep -rn "/yt/comments\|\"/youtube\"\|'/youtube'" server/ --include=*.js --include=*.html
|
||||||
|
```
|
||||||
|
`auto.js`의 `ytMatch`와 `index.html` 폼 말고 다른 곳이 있으면 **멈추고 보고해라.**
|
||||||
|
|
||||||
|
- [ ] **Step 2: 세 엔드포인트 추가**
|
||||||
|
|
||||||
|
Task 3과 같은 구조다. 차이는:
|
||||||
|
- `POST /yt/analyze` (form `url`, `ranges`) — `_parse_ranges`로 검증, `ANALYSES[aid]`에 보관
|
||||||
|
- `GET /yt/stream/{aid}` — `bg_analyze(None, draft_name, youtube={"url":…, "ranges":…})` 실행
|
||||||
|
- 추천 호출: `recommend.cuts_from_state(state["places"], state["ranges_sec"], state["captions"], comments)`
|
||||||
|
- `POST /yt/build` — `bg_draft(state, …, comment_cards=…)`.
|
||||||
|
카드 시간은 `_cards_by_cut(_card_paths(cdir), card_cuts, state["places"], state["total"], fixed=cards_fixed)`로
|
||||||
|
만들어 `comment_cards`로 넘긴다(`bg_draft`가 1단계에서 그 인자를 받도록 준비돼 있다)
|
||||||
|
|
||||||
|
⚠ `bg_analyze`의 무음 제거는 **옵션이 아니라 항상 켜져 있다.** 그래서 `state["places"]`는
|
||||||
|
이미 압축 좌표다. `_remap_placements`를 **또 부르지 마라** — 두 번 압축된다.
|
||||||
|
|
||||||
|
- [ ] **Step 3: 옛 엔드포인트 삭제**
|
||||||
|
|
||||||
|
`/yt/comments`와 `/youtube`를 지운다. `_parse_ranges`는 새 `/yt/analyze`가 쓰므로 남긴다.
|
||||||
|
|
||||||
|
- [ ] **Step 4: 구문·임포트·라우트 검증**
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys; sys.stdout.reconfigure(encoding='utf-8')
|
||||||
|
from server.app import app
|
||||||
|
paths={r.path for r in app.routes}
|
||||||
|
for p in ("/yt/analyze","/yt/stream/{aid}","/yt/build"):
|
||||||
|
assert p in paths, f"{p} 없음"
|
||||||
|
for p in ("/yt/comments","/youtube"):
|
||||||
|
assert p not in paths, f"{p} 가 아직 있다"
|
||||||
|
print("Task5 라우트 OK")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: 커밋**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: ▶ 유튜브 구간 탭 — 화면
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `server/static/index.html`, `server/static/auto.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1: `💬 구간 댓글 매칭` 버튼 제거, `편집 시작`이 분석을 돌리게**
|
||||||
|
|
||||||
|
기존 `ytMatch()`와 `window.ytCC`를 지우고, Task 4의 붙여넣기 탭과 같은 흐름으로 바꾼다
|
||||||
|
(`panelId = "yt"`, `{unit:"구간"}`).
|
||||||
|
|
||||||
|
- [ ] **Step 2: 제목·출처를 검토 화면에서도 고칠 수 있게**
|
||||||
|
|
||||||
|
분석에 1분 넘게 기다린 뒤 오타를 발견하면 다시 돌리는 게 낭비다. 검토 영역 위에
|
||||||
|
제목(윗줄·아랫줄)·출처 입력칸을 두고, 생성 시 그 값을 보낸다.
|
||||||
|
|
||||||
|
- [ ] **Step 3: 문법 검증 후 커밋**
|
||||||
|
|
||||||
|
Run: `node --check server/static/auto.js`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: 🤖 자동 탭 — 3단계 흐름
|
||||||
|
|
||||||
|
검토 화면의 `✕` 제외 버튼 때문에, 받아쓰기를 **제외 선택 뒤에** 돌려야 낭비가 없다.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `server/app.py`, `server/static/auto.js`, `server/static/index.html`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `POST /auto/prepare` (form `aid`, `ids`=JSON 배열) → `{"prepare_id": pid}`,
|
||||||
|
`GET /auto/prepare/{pid}` (SSE) — 남은 ID만 다운로드·받아쓰기 + 댓글 매칭 →
|
||||||
|
`result` `{highlights:[{id, cuts, need, cutRanges}], comments, candidates, warnings}`
|
||||||
|
- `/auto/stream/{aid}`에서 **댓글 매칭을 뺀다** — 편집안까지만
|
||||||
|
- `/auto/build`가 보관한 상태로 드래프트만 만든다
|
||||||
|
|
||||||
|
- [ ] **Step 1: `/auto/stream`에서 댓글 매칭 제거**
|
||||||
|
|
||||||
|
`targets = [h for h in highlights if "paste" in h]` 이후의 매칭·추천 블록을 들어낸다.
|
||||||
|
`result` 이벤트는 `highlights`(편집안·타이틀 후보)만 싣는다.
|
||||||
|
|
||||||
|
- [ ] **Step 2: `/auto/prepare` 추가**
|
||||||
|
|
||||||
|
`ids`로 받은 하이라이트만 **순차로** `paste_analyze`를 돌리고, h-lab 댓글을 한 번 받아
|
||||||
|
ID별로 `cuts_from_state`를 부른다. 상태는 `PSTATES[f"{aid}:{id}"]`에 보관한다.
|
||||||
|
진행은 ID별 로그로 흘린다(`{"type":"log","msg":"ID 3 준비 완료"}`).
|
||||||
|
|
||||||
|
⚠ **순차로 돌려라.** yt-dlp·ffmpeg·Whisper가 CPU를 다 쓴다(기존 방침).
|
||||||
|
|
||||||
|
- [ ] **Step 3: `/auto/build`가 상태를 쓰게**
|
||||||
|
|
||||||
|
`aid`+`id`로 `PSTATES`에서 상태를 꺼내 `paste_draft`만 돌린다. 다운로드·받아쓰기를
|
||||||
|
다시 하지 않는다.
|
||||||
|
|
||||||
|
- [ ] **Step 4: 화면 — 1차 검토 / 2차 검토 분리**
|
||||||
|
|
||||||
|
지금 검토 화면에서 **댓글 영역만 뺀 것**이 1차 검토다:
|
||||||
|
- 1차: 하이라이트 카드(제목 선택·`✕` 제외·편집안 접기) + `준비 시작` 버튼
|
||||||
|
- `준비 시작` → `/auto/prepare` SSE (ID별 진행 표시)
|
||||||
|
- 2차: 지금과 같은 컷별 댓글 섹션 + `전부 만들기`
|
||||||
|
|
||||||
|
`renderCutPanel`은 2차에서 그대로 쓴다.
|
||||||
|
|
||||||
|
- [ ] **Step 5: 문법·구문 검증 후 커밋**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: 문서 + 마무리
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `ARCHITECTURE.md`, `SETUP.md`, `README.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: `ARCHITECTURE.md`**
|
||||||
|
|
||||||
|
탭별 흐름 표를 새 흐름으로 갱신하고, `cuts_from_state()`가 세 탭 공통 조립점이라는 것,
|
||||||
|
두 좌표계를 섞으면 안 된다는 것을 남긴다.
|
||||||
|
|
||||||
|
- [ ] **Step 2: `SETUP.md` 문제 해결표**
|
||||||
|
|
||||||
|
| 증상 | 원인 | 해결 |
|
||||||
|
|---|---|---|
|
||||||
|
| 카드 고르기까지 오래 걸린다 | 받아쓰기를 먼저 돌린다(추천 정확도를 위해) | 정상. 1분 영상당 ≈30초 |
|
||||||
|
| 🤖 배지가 하나도 없다 | Gemini 실패 또는 자막 없음 | 로그의 경고 확인. 🔤·➕는 계속 동작 |
|
||||||
|
| 분석 결과가 만료됐다고 나온다 | 서버 재시작으로 메모리 상태 소실 | 분석을 다시 돌린다 |
|
||||||
|
|
||||||
|
- [ ] **Step 3: `README.md`** — 탭 설명의 흐름을 갱신
|
||||||
|
|
||||||
|
- [ ] **Step 4: 커밋**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 자체 점검 결과
|
||||||
|
|
||||||
|
**스펙 커버리지 (2~4단계)**
|
||||||
|
|
||||||
|
| 스펙 항목 | 담당 |
|
||||||
|
|---|---|
|
||||||
|
| §1 배정 순위 4단계 | 이미 구현됨(1단계 이후 버그 수정에서) |
|
||||||
|
| §2 공통 흐름 | Task 2·3·5·7 |
|
||||||
|
| §4 두 좌표계 | Task 1·2 (`places` vs `orig_ranges`) |
|
||||||
|
| §5 컷별 자막·`asr_bottom` 의미 변경 | Task 2·3 |
|
||||||
|
| §7 자동 탭 3단계 | Task 7 |
|
||||||
|
| §8 구간·붙여넣기 2단계 | Task 3·4·5·6 |
|
||||||
|
| §9 엔드포인트 표 | Task 3·5·7 |
|
||||||
|
| §11 실패·폴백 | Task 3·5·7 (각 stream의 try/except) |
|
||||||
|
| §14 검증 | 각 태스크 Step + Task 8 |
|
||||||
|
|
||||||
|
**타입 일관성**
|
||||||
|
|
||||||
|
- `cuts_from_state(places, orig_ranges, captions, comments, *, key)` → `(cuts, need, ai_failed)`
|
||||||
|
- `cuts[]` 원소: `i, sec, bottom, quota, picks`; `picks` 원소: `{"idx","why"}`,
|
||||||
|
`why` ∈ `ts|ai|word|like`
|
||||||
|
- `places`·`captions` = 압축 좌표, `orig_ranges` = 원본 좌표. **둘 다 같은 길이**
|
||||||
|
- 패널 id: 자동 `hl.id` / 구간 `"yt"` / 붙여넣기 `"paste"`
|
||||||
|
- 상태 보관: `ANALYSES[aid]`(입력) · `PSTATES[aid]`(받아쓰기 결과) · `JOBS[h]`(빌드)
|
||||||
237
docs/superpowers/specs/2026-08-04-받아쓰기후-댓글매칭-design.md
Normal file
237
docs/superpowers/specs/2026-08-04-받아쓰기후-댓글매칭-design.md
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
# 받아쓰기 후 댓글 매칭 — 모든 탭 통일 (설계)
|
||||||
|
|
||||||
|
작성일: 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. 열린 질문
|
||||||
|
|
||||||
|
없음. 미결이 생기면 여기에 적고 구현 전에 사용자에게 묻는다.
|
||||||
776
server/app.py
776
server/app.py
@ -20,8 +20,12 @@ 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 process_bg_template, process_paste
|
from capcut_agent.pipeline import (
|
||||||
from capcut_agent.paste import parse_paste
|
process_bg_template, process_paste, paste_analyze, paste_draft,
|
||||||
|
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
|
||||||
@ -45,6 +49,20 @@ 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), "댓글카드")
|
||||||
|
|
||||||
@ -79,8 +97,28 @@ 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 [["mm:ss","mm:ss"],...] → [(start,end)]. 빈 값/파싱실패는 []."""
|
"""JSON [[시작,끝],...] → [(M:SS, M:SS)]. 항목은 "16:07" 문자열/967 같은 초 숫자
|
||||||
|
모두 허용해 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 []
|
||||||
@ -91,9 +129,11 @@ 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:
|
||||||
s, e = str(item[0]).strip(), str(item[1]).strip()
|
try:
|
||||||
if s and e:
|
s_sec, e_sec = parse_time(item[0]), parse_time(item[1])
|
||||||
out.append((s, e))
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
out.append((_fmt_range_time(s_sec), _fmt_range_time(e_sec)))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@ -128,54 +168,6 @@ 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(...),
|
||||||
@ -213,7 +205,37 @@ 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"): # 붙여넣기(JSON) 모드
|
if job.get("paste_state"): # 📋 붙여넣기 탭(새 흐름) — 이미 분석된 상태로 드래프트만
|
||||||
|
# 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),
|
||||||
@ -251,33 +273,196 @@ async def stream(job_id: str) -> StreamingResponse:
|
|||||||
"X-Accel-Buffering": "no"})
|
"X-Accel-Buffering": "no"})
|
||||||
|
|
||||||
|
|
||||||
@app.post("/yt/comments")
|
@app.post("/yt/analyze")
|
||||||
async def yt_comments(url: str = Form(...), ranges: str = Form(...)) -> JSONResponse:
|
async def yt_analyze(
|
||||||
"""유튜브 구간 탭 — 구간 언급 댓글 매칭. 규칙은 자동 탭과 동일:
|
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)
|
||||||
try:
|
rng = _parse_ranges(ranges)
|
||||||
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:
|
||||||
comments = await asyncio.to_thread(hlab.fetch_comments, u)
|
async for ev in bg_analyze(None, "yt_" + aid,
|
||||||
|
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
|
||||||
return JSONResponse({"error": f"h-lab 연결 실패: {exc}"}, 502)
|
# cuts=None(빈 리스트 아님)이어야 화면이 "컷 0개"로 비지 않고 ⭐/➕ 폴백으로 넘어간다.
|
||||||
total = sum(e - s for s, e in rng)
|
cuts, need = None, max(1, int(state["total"] // 3))
|
||||||
matched = hlab.match_ranges(comments, rng)
|
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}장")})
|
||||||
|
|
||||||
|
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"]]
|
||||||
return JSONResponse({
|
matched = hlab.match_ranges(comments, orig) if comments else []
|
||||||
"need": max(1, int(total // 3)), "total": round(total, 1),
|
yield _sse({"type": "result", "cuts": cuts, "need": need,
|
||||||
|
"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,
|
"comments": comments, "warnings": warnings,
|
||||||
})
|
# 검토 화면 입력칸을 채우기 위한 확정값 — 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")
|
||||||
@ -315,7 +500,13 @@ 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:
|
||||||
"""자동 탭 분석 SSE: Step1 → (Step3 ×N ∥ 댓글) → result."""
|
"""자동 탭 1단계 분석 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():
|
||||||
@ -324,7 +515,6 @@ 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:
|
||||||
@ -336,13 +526,16 @@ async def auto_stream(aid: str) -> StreamingResponse:
|
|||||||
|
|
||||||
def _whole_hl(c: dict) -> dict:
|
def _whole_hl(c: dict) -> dict:
|
||||||
"""구간 통짜 하이라이트 — 컷 편집 없이 구간 전체가 컷 1개.
|
"""구간 통짜 하이라이트 — 컷 편집 없이 구간 전체가 컷 1개.
|
||||||
제목은 비워 두고 `editable_title` 로 UI에서 직접 입력받는다.
|
제목은 `editable_title` 로 UI에서 직접 입력받되, 구간 JSON에
|
||||||
|
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, "title_top": "", "title_main": "",
|
"paste": {"url": url,
|
||||||
|
"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": ""}]},
|
||||||
@ -354,8 +547,6 @@ 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
|
||||||
@ -422,14 +613,10 @@ 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:
|
||||||
@ -440,24 +627,16 @@ 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 방식으로도 만들 수 있습니다."})
|
||||||
@ -516,56 +695,167 @@ 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 com_task
|
comments = await asyncio.to_thread(hlab.fetch_comments, url)
|
||||||
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": "실패(생략)"})
|
||||||
|
|
||||||
# 댓글 매칭 — 전체 전송, 브라우저가 '더보기'로 30장씩 나눠 그린다.
|
yield _sse({"type": "step", "id": "prepare", "status": "start"})
|
||||||
# 후보(candidates)는 분:초 언급이 아예 없는 댓글만 — 타임스탬프 댓글은
|
highlights_out: list[dict] = []
|
||||||
# 자기 구간의 ⭐에서 잡히므로, 다른 구간 얘기하는 댓글이 섞이지 않게.
|
all_ranges: list[tuple] = []
|
||||||
#
|
# ⚠ 순차로 돌려라 — yt-dlp·ffmpeg·Whisper 가 CPU 를 다 쓴다(전역 방침).
|
||||||
# 하이라이트마다 Gemini 를 순차로 부른다(동시 호출은 429 를 부른다 — Step 3 도 같은 이유로
|
for h in targets:
|
||||||
# 시차 재시도를 쓴다). 최악 5×90초라 진행 표시가 없으면 사용자가 멈춘 줄 알고 새로고침해
|
hid = h["id"]
|
||||||
# 분석이 통째로 날아간다 → 스텝 + 하이라이트별 로그를 반드시 흘린다.
|
state = None
|
||||||
no_ts = [c for c in comments if not c["times"]]
|
failed = False
|
||||||
targets = [h for h in highlights if "paste" in h]
|
# ⚠ paste_analyze 내부에서 draft_name = _safe_name(title) or draft_name 로
|
||||||
yield _sse({"type": "step", "id": "recommend", "status": "start"})
|
# 영상 제목이 항상 draft_name 을 덮어쓴다(_safe_name 은 거의 항상 truthy) —
|
||||||
t_rec = time.perf_counter()
|
# 여기서 넘기는 f"auto_{aid}_{hid}" 는 그래서 절대 안 쓰인다. 같은 URL(한 aid)의
|
||||||
for n, h in enumerate(targets, 1):
|
# 하이라이트 5개는 제목이 똑같으니 name_suffix 없이는 draft_name 이 전부 같아져
|
||||||
matched = hlab.match_window(comments, h["start"], h["end"])
|
# build_bg_template_draft 의 allow_replace=True 가 뒤엣것으로 앞을 덮어쓴다
|
||||||
h["matched"] = matched
|
# (옛 /auto/build 의 tag→name_suffix 메커니즘 — 여기서도 반드시 넘겨야 한다).
|
||||||
h["candidates"] = hlab.top_liked(no_ts, set(matched), len(no_ts))
|
safe_tag = "".join(ch for ch in f"하이라이트{hid}" if ch.isalnum() or ch in "-_")[:20]
|
||||||
yield _sse({"type": "log",
|
payload = _hl_paste_payload(h["paste"]) # dict 컷 → 튜플 컷 (필수 — docstring 참고)
|
||||||
"msg": f"ID {h.get('id')} 컷별 댓글 추천 중… "
|
try:
|
||||||
f"({n}/{len(targets)}, 컷 "
|
async for ev in paste_analyze(payload, f"auto_{aid}_{hid}",
|
||||||
f"{len(h['paste'].get('cuts') or [])}개)"})
|
remove_silence=True, asr_bottom=True,
|
||||||
# 컷별 추천 — 실패해도 위의 matched/candidates 로 화면이 돌아간다.
|
name_suffix=safe_tag):
|
||||||
# Gemini 호출이 섞여 있어 블로킹이므로 스레드로 뺀다.
|
if ev.get("type") == "state": # 내부 전용 — 밖으로 흘리지 않는다
|
||||||
|
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.build_highlight_cuts, h, comments)
|
recommend.cuts_from_state, places, orig, state["bottom_caps"], comments)
|
||||||
if cuts:
|
|
||||||
h["cuts"], h["need"] = cuts, need
|
|
||||||
if ai_failed:
|
if ai_failed:
|
||||||
# cuts 는 타임스탬프만으로 채워져 비지 않는다 — 이 신호가 없으면
|
warnings.append(f"ID {hid} AI 추천 실패(Gemini 응답 없음) — "
|
||||||
# 429·타임아웃이 조용히 삼켜져 아무도 모른다.
|
"분:초·자막단어·좋아요로 배정했습니다")
|
||||||
warnings.append(
|
except Exception as exc: # noqa: BLE001
|
||||||
f"ID {h.get('id')} AI 추천 실패 — 타임스탬프만으로 배정했습니다")
|
cuts, need = None, max(1, int(state["timeline_dur"] // 3))
|
||||||
except Exception as exc: # noqa: BLE001 — 추천 실패가 생성을 막으면 안 된다
|
warnings.append(f"ID {hid} 컷별 추천 실패 — 컷 정보 없이 진행합니다 "
|
||||||
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",
|
|
||||||
"elapsed": round(time.perf_counter() - t_rec, 1),
|
PSTATES[f"{aid}:{hid}"] = {"state": state, "places": places, "orig": orig,
|
||||||
"detail": f"{len(targets)}개 하이라이트"})
|
"payload": payload}
|
||||||
yield _sse({"type": "result", "highlights": highlights,
|
matched = hlab.match_ranges(comments, orig) if comments else []
|
||||||
"comments": comments, "warnings": warnings})
|
highlights_out.append({
|
||||||
|
"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",
|
||||||
@ -622,32 +912,57 @@ 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(
|
||||||
data: str = Form(...),
|
aid: str = Form(...),
|
||||||
tag: str = Form(""),
|
hid: str = Form(..., alias="id"),
|
||||||
|
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(""),
|
||||||
card_cuts: str = Form(""),
|
asr_bottom: str = Form("1"),
|
||||||
|
comments_dir: str = Form(""),
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
"""자동 탭 빌드 — 붙여넣기 스키마 JSON + 카드 PNG들 → 기존 paste job.
|
"""자동 탭 3단계 — /auto/prepare 가 PSTATES 에 보관한 상태로 드래프트만 만든다.
|
||||||
|
|
||||||
tag(예: '하이라이트1')는 드래프트 이름 꼬리표 — 같은 영상 5개가 서로
|
다운로드·받아쓰기는 다시 하지 않는다(/paste/build 와 같은 방식) — aid+id 로
|
||||||
덮어쓰는 것을 막는다. 진행은 기존 /stream/{job_id} 로 본다.
|
PSTATES[f"{aid}:{id}"] 를 찾아 paste_draft 만 돌린다. title_top/title_main 은
|
||||||
|
검토 화면(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:
|
try:
|
||||||
payload = parse_paste(data)
|
parsed = json.loads(card_cuts) if card_cuts.strip() else []
|
||||||
except ValueError as e:
|
if isinstance(parsed, list):
|
||||||
return JSONResponse({"error": str(e)}, 400)
|
cut_map = [int(v) for v in parsed if isinstance(v, int) and not isinstance(v, bool)]
|
||||||
safe_tag = "".join(ch for ch in tag if ch.isalnum() or ch in "-_")[:20]
|
except (json.JSONDecodeError, ValueError, TypeError):
|
||||||
sig = (payload["url"] + "|" + safe_tag + "|"
|
cut_map = []
|
||||||
+ "|".join(f"{s:.3f}-{e:.3f}" for s, e, _, _ in payload["cuts"]))
|
|
||||||
|
sig = (key + "|" + 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]
|
||||||
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): # 재빌드 시 이전 카드 잔재 제거
|
||||||
@ -657,7 +972,150 @@ async def auto_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)
|
||||||
# 카드별 소속 컷 — 값이 깨져도 빌드를 막지 않는다(없으면 기존 전체 균등 배치)
|
|
||||||
|
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:
|
||||||
|
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})
|
||||||
|
|
||||||
|
|
||||||
|
@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] = []
|
cut_map: list[int] = []
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(card_cuts) if card_cuts.strip() else []
|
parsed = json.loads(card_cuts) if card_cuts.strip() else []
|
||||||
@ -665,17 +1123,29 @@ async def auto_build(
|
|||||||
cut_map = [int(v) for v in parsed if isinstance(v, int) and not isinstance(v, bool)]
|
cut_map = [int(v) for v in parsed if isinstance(v, int) and not isinstance(v, bool)]
|
||||||
except (json.JSONDecodeError, ValueError, TypeError):
|
except (json.JSONDecodeError, ValueError, TypeError):
|
||||||
cut_map = []
|
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]
|
||||||
|
# 카드가 오면 폴더 지정보다 우선(/youtube 와 같은 규칙). 카드를 하나도 안 골랐으면
|
||||||
|
# 화면에서 넘어온 폴더 경로를 그대로 써서 예전(폴더 지정) 동작으로 하위호환한다.
|
||||||
|
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] = {
|
JOBS[h] = {
|
||||||
"paste": payload, "draft_name": f"auto_{h}",
|
"paste_state": state, "card_cuts": cut_map,
|
||||||
"video_scale": _scale(video_scale), "flip": _truthy(flip),
|
"video_scale": _scale(video_scale), "flip": _truthy(flip), "scene": _truthy(scene),
|
||||||
"scene": _truthy(scene), "comments_dir": cdir,
|
"comments_dir": cdir, "bg_white": _truthy(bg_white), "cards_fixed": _truthy(cards_fixed),
|
||||||
"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, "cuts": len(payload["cuts"]),
|
return JSONResponse({"job_id": h})
|
||||||
"cards": len(cards)})
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/open-capcut")
|
@app.post("/open-capcut")
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -157,13 +157,24 @@
|
|||||||
.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;}
|
||||||
.cc-text{font-size:14px;color:#fff;margin-top:5.6px;white-space:pre-wrap;word-break:break-word;line-height:1.5;}
|
/* 본문 최대 5줄 말줄임(…) — 카드는 캡처돼 드래프트 하단(영상 아래~출처 사이 약 640px)에
|
||||||
|
들어가는데, 긴 댓글은 세로가 무한정 자라 출처(@채널)를 덮는다. 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열) */
|
||||||
#autoReview,#autoSummary,#ytccArea{--rw:min(1240px,calc(100vw - 40px));
|
#autoPickReview,#autoReview,#autoSummary,#ytReview,#ytSummary,#pasteReview,#pasteSummary{
|
||||||
width:var(--rw);margin-left:calc((100% - var(--rw))/2);}
|
--rw:min(1240px,calc(100vw - 40px));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;}
|
||||||
@ -199,6 +210,15 @@
|
|||||||
.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;}
|
||||||
@ -236,6 +256,21 @@
|
|||||||
.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;}
|
||||||
@ -362,10 +397,23 @@
|
|||||||
<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) 모드 -->
|
||||||
@ -378,8 +426,16 @@
|
|||||||
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='{ "url": "https://www.youtube.com/watch?v=…", "title_top": "서브제목", "title_main": "메인제목", "channel": "@채널", "cuts": [ {"start":"0:01.0","end":"0:03.5","bottom":"하단 자막\n두 줄","effect":"광속하강"}, {"start":"2:33.5","end":"2:36.5","bottom":"다음 컷 자막","effect":"공포의통계"} ] }'></textarea>
|
placeholder='{ "url": "https://www.youtube.com/watch?v=…", "title_top": "서브제목", "title_main": "메인제목", "channel": "@채널", "cuts": [ {"start":"0:01.0","end":"0:03.5","bottom":"하단 자막\n두 줄","effect":"광속하강"}, {"start":"2:33.5","end":"2:36.5","bottom":"다음 컷 자막","effect":"공포의통계"} ] }'></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="note" style="margin-top:8px;">한 URL의 여러 컷 + 자막을 그대로 사용합니다(무음컷·받아쓰기 없음). 컷은 순서대로 이어붙고, 배치 시간은 자동 계산돼요. <b>bottom</b>=하단자막, <b>effect</b>=중앙 효과자막(하단 바로 위).</div>
|
<div class="note" style="margin-top:8px;">한 URL의 여러 컷 + 자막을 그대로 사용합니다. 컷은 순서대로 이어붙고, 배치 시간은 자동 계산돼요. <b>bottom</b>=하단자막, <b>effect</b>=중앙 효과자막(하단 바로 위). 무음 제거·받아쓰기(Whisper)는 댓글 매칭을 위해 항상 적용됩니다.</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) 모드 -->
|
||||||
@ -442,6 +498,12 @@
|
|||||||
|
|
||||||
<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;">
|
||||||
@ -485,7 +547,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">
|
<div class="field" id="rmsilenceField">
|
||||||
<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>
|
||||||
@ -556,11 +618,15 @@ 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에 있으니 숨김. 자동: 카드 폴더도 자동이라 숨김.
|
// 붙여넣기·자동: 제목은 JSON에 있으니 숨김. 구간: 검토 영역 위 전용 입력칸(#yttop 등)을 쓰므로 숨김.
|
||||||
|
// 자동: 카드 폴더도 자동이라 숨김.
|
||||||
$("#commonFields").style.display="grid";
|
$("#commonFields").style.display="grid";
|
||||||
$("#titleGroup").style.display=(m==="paste"||m==="auto")?"none":"block";
|
$("#titleGroup").style.display=(m==="paste"||m==="auto"||m==="yt")?"none":"block";
|
||||||
$("#cdirField").style.display=m==="auto"?"none":"block";
|
$("#cdirField").style.display=m==="auto"?"none":"block";
|
||||||
$("#run").style.display=m==="auto"?"none":"block"; // 자동 탭은 자체 버튼 사용
|
$("#run").style.display=(m==="auto"||m==="paste")?"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"));
|
||||||
@ -586,17 +652,19 @@ 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 자막 → CapCut</li>
|
<li><b>편집 시작</b> → 다운로드·병합·무음 컷·Whisper 자막 → h-lab 댓글 수집·구간별 추천</li>
|
||||||
|
<li>검토 화면에서 구간별 추천 댓글 카드를 확인·선택하고 <b>선택한 카드로 드래프트 만들기</b></li>
|
||||||
</ol>
|
</ol>
|
||||||
<ul>
|
<ul>
|
||||||
<li><b>출처</b>: 비우면 유튜브 채널명 자동(@채널)</li>
|
<li><b>제목·출처</b>: 검토 영역 위 입력칸 — 분석이 끝난 뒤 오타를 고쳐도 재분석 없이 드래프트에 반영됩니다. 출처를 비워두면 유튜브 채널명 자동(@채널)</li>
|
||||||
<li><b>댓글 카드</b>: 폴더 경로 지정 시 하단에 3초마다 1장 자동 삽입</li>
|
<li><b>댓글 카드</b>: 검토에서 하나도 안 고르면 예전처럼 <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> → 컷 정밀 다운로드·병합 → 자막 → CapCut</li>
|
<li><b>댓글 매칭 시작</b> → 컷 정밀 다운로드·병합 → 무음 제거 → 받아쓰기 → h-lab 댓글 매칭</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",
|
||||||
@ -610,10 +678,11 @@ 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>: 중앙 녹색 효과자막 · <b>댓글 카드</b>: 폴더 지정 시 3초마다 1장</li>
|
<li><b>effect</b>: 중앙 녹색 효과자막</li>
|
||||||
<li><b>무음 제거</b> 켜면 컷 안의 무음까지 컷(자막 자동 보정)</li>
|
<li><b>댓글 카드</b>: 컷별 추천에서 카드를 하나도 안 고르면 예전처럼 <b>댓글 카드 폴더</b> 지정으로 동작(3초마다 1장)</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="helpdim">헤더의 ✨ AI Studio에서 JSON 생성, 💬 댓글 카드에서 카드 저장.</div>`};
|
<div class="helpdim">헤더의 ✨ AI Studio에서 JSON 생성, 💬 댓글 카드에서 카드 저장.</div>`};
|
||||||
const overlay=$("#helpOverlay");
|
const overlay=$("#helpOverlay");
|
||||||
@ -704,6 +773,13 @@ 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;}
|
||||||
@ -711,52 +787,8 @@ 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);
|
||||||
});
|
});
|
||||||
|
|||||||
129
숏폼_편집_지침서_v13.10_capcut2연동판.md
Normal file
129
숏폼_편집_지침서_v13.10_capcut2연동판.md
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
# 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선 → ③ 컷 길이 검산표)에 맞춘 결과물을 출력해 주십시오.
|
||||||
Loading…
Reference in New Issue
Block a user