Compare commits

..

No commits in common. "9b2cc96e3ed05ae030b32ef2a4322f2c9e3f6b57" and "3c013dcc88a8ffdde0b76ed87b740e6933656631" have entirely different histories.

8 changed files with 271 additions and 649 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,15 +2,11 @@
카드 DOM·캡처 방식은 h-lab comment-cards 동일 계열(modern-screenshot, 4). */ 카드 DOM·캡처 방식은 h-lab comment-cards 동일 계열(modern-screenshot, 4). */
(function(){ (function(){
const $=(s)=>document.querySelector(s); const $=(s)=>document.querySelector(s);
let A=null; // 1차 결과(분석) {highlights} — 편집안·타이틀 후보만, 댓글 없음 let A=null; // 분석 result {highlights, comments, warnings}
let P=null; // 2차 결과(준비) {highlights:[{id,cuts,need,cutRanges,matched}],comments,candidates,warnings}
let AUTO_AID=null; // /auto/analyze 가 돌려준 analysis_id — /auto/prepare 에 넘긴다
let PREP_ID=null; // /auto/prepare 가 돌려준 prepare_id — SSE 구독용
let TITLE_PICKS={}; // hl.id → {top,main} — "준비 시작" 클릭 시점의 제목 선택을 잠가 둔다
let byIdx={}; // idx → comment let byIdx={}; // idx → comment
let sel={}; // hl.id → idx 배열(선택 순서 유지: matched 먼저) let sel={}; // hl.id → idx 배열(선택 순서 유지: matched 먼저)
let curId=null; // 현재 보고 있는 하이라이트 ID let curId=null; // 현재 보고 있는 하이라이트 ID
let dropped=new Set(); // 1차 검토에서 X로 제외한 ID(문자열) — 준비에서 제외. 되돌리기 가능 let dropped=new Set(); // 사용자가 X로 제외한 ID(문자열) — 빌드에서 제외. 되돌리기 가능
const isDropped=(id)=>dropped.has(String(id)); const isDropped=(id)=>dropped.has(String(id));
/* ── h-lab timeAgo 이식 ── */ /* ── h-lab timeAgo 이식 ── */
@ -29,8 +25,7 @@ let PASTE_HL=null; // 📋 붙여넣기 탭의 가상 하이라이트
function hlById(id){ function hlById(id){
if(id==="yt") return YT_HL; if(id==="yt") return YT_HL;
if(id==="paste") return PASTE_HL; if(id==="paste") return PASTE_HL;
// 자동 탭: cuts/need 는 2차(prepare) 결과에만 있다 — 카드 선택은 항상 2차에서만 일어난다. return A.highlights.find(h=>h.id===id);
return P&&P.highlights.find(h=>h.id===id);
} }
/* ── 카드 DOM (h-lab renderCards 구조와 동일) ── */ /* ── 카드 DOM (h-lab renderCards 구조와 동일) ── */
@ -220,14 +215,11 @@ function renderCutPanel(box,panelId,data,opts){
unit+" "+(cu.i+1)+" · "+cu.sec+"초 · 카드 "+cu.quota+"장"+ unit+" "+(cu.i+1)+" · "+cu.sec+"초 · 카드 "+cu.quota+"장"+
(cu.bottom?" — "+cu.bottom:"")+(why?" "+why:""), (cu.bottom?" — "+cu.bottom:"")+(why?" "+why:""),
rec.concat(rest),panelId,Math.max(CARD_PAGE,rec.length),cu.i)); rec.concat(rest),panelId,Math.max(CARD_PAGE,rec.length),cu.i));
// 부족분 채우기 — 컷마다 반복되므로 처음엔 조금만 그리고 나머지는 더보기로. // 부족분 채우기 — 컷마다 반복되므로 처음엔 조금만 그리고 나머지는 더보기로
// 이 컷 추천(rec)에 이미 나온 카드는 뺀다 — 안 빼면 같은 컷 그룹 안에 같은 댓글이 if(cand.length)
// 두 장 뜬다(다른 컷의 복사본은 '이동'용이라 남기지만, 같은 컷 안은 순수 중복).
const fill=cand.filter(i=>!rec.includes(i));
if(fill.length)
body.appendChild(cardSection( body.appendChild(cardSection(
"↳ 좋아요 상위에서 채우기 "+fill.length+"장 (이 컷에 넣기)", "↳ 좋아요 상위에서 채우기 "+cand.length+"장 (이 컷에 넣기)",
fill,panelId,CUT_FILL_PAGE,cu.i)); cand,panelId,CUT_FILL_PAGE,cu.i));
box.appendChild(grp); box.appendChild(grp);
} }
}else{ }else{
@ -247,7 +239,7 @@ function renderCutPanel(box,panelId,data,opts){
/* ' ' . /* ' ' .
selCut[hlId][idx] = 인덱스. sel[hlId] 항상 순서로 정렬해 둔다 selCut[hlId][idx] = 인덱스. sel[hlId] 항상 순서로 정렬해 둔다
(업로드 순서 = 배치 순서라서). */ (업로드 순서 = 배치 순서라서). */
let selCut={}; const selCut={};
function sortSel(hlId){ function sortSel(hlId){
const m=selCut[hlId]||{}; const m=selCut[hlId]||{};
sel[hlId].sort((a,b)=>(m[a]??999)-(m[b]??999)); sel[hlId].sort((a,b)=>(m[a]??999)-(m[b]??999));
@ -425,37 +417,41 @@ function showId(hlId){
applyUsedMarks(hlId); // 탭 열 때 최신 선택 상태로 갱신 applyUsedMarks(hlId); // 탭 열 때 최신 선택 상태로 갱신
} }
/* ── 1차 검토: ID 제외 / 되돌리기 /* ── ID 제외 / 되돌리기
지우지 않고 '제외' 상태로만 둔다 실수로 눌러도 번에 복구 가능(되돌리기 지원). 지우지 않고 '제외' 상태로만 둔다 실수로 눌러도 번에 복구 가능(되돌리기 지원).
제외된 ID는 /auto/prepare 넘어간다 받아쓰기를 아예 돌려 낭비가 없다(Task 7). */ 제외된 ID는 빌드에서 빠지고, ID가 잡고 있던 댓글도 '사용중' 표시에서 풀린다. */
function liveIds(){ function liveIds(){
if(!A) return []; if(!A) return [];
return A.highlights.filter(h=>!h.error&&!isDropped(h.id)).map(h=>h.id); return A.highlights.filter(h=>!h.error&&!isDropped(h.id)).map(h=>h.id);
} }
function updatePrepBtn(){ function updateBuildBtn(){
const btn=$("#autoPrepGo"); if(!btn||!A) return; const btn=$("#autoBuild"); if(!btn||!A) return;
const n=liveIds().length; const n=liveIds().length;
btn.disabled=(n===0); btn.disabled=(n===0);
btn.textContent=n?(n+"개 준비 시작"):"준비할 ID가 없습니다 — 제외를 해제하세요"; btn.textContent=n?(n+"개 전부 만들기"):"만들 ID가 없습니다 — 제외를 해제하세요";
} }
function togglePickDrop(hlId){ function setDropped(hlId,on){
const on=!isDropped(hlId); const key=String(hlId);
if(on) dropped.add(String(hlId)); else dropped.delete(String(hlId)); if(on) dropped.add(key); else dropped.delete(key);
const card=$("#pick-"+hlId); const tab=$("#idtab-"+hlId);
if(card) card.classList.toggle("dropped-pick",on); if(tab) tab.classList.toggle("dropped",on);
const btn=$("#pickx-"+hlId); const x=$("#xdel-"+hlId);
if(btn){ if(x){
btn.textContent=on?"↺ 되돌리기":"✕ 제외"; x.textContent=on?"↺":"✕";
btn.title=on?"다시 포함":"이 ID 제외 (준비 안 함)"; x.title=on?"다시 포함":"이 ID 제외 (생성 안 함)";
x.setAttribute("aria-label","ID "+hlId+(on?" 다시 포함":" 제외 — 생성하지 않음"));
x.setAttribute("aria-pressed",on?"true":"false");
} }
updatePrepBtn(); const box=$("#hlbox-"+hlId);
} if(box&&on) box.classList.remove("show");
/* ── 2차 검토: '전부 만들기' 버튼 — 준비(prepare)를 마친 편집안 개수 기준 ── */ if(on&&String(curId)===key){ // 보던 탭을 제외 → 남은 첫 탭으로 이동
function updateBuildBtn(){ const nxt=liveIds()[0];
const btn=$("#autoBuild"); if(!btn||!P) return; if(nxt!=null) showId(nxt); else curId=null;
const n=(P.highlights||[]).length; }
btn.disabled=(n===0); if(!on) showId(hlId); // 되돌리면 그 탭을 연다
btn.textContent=n?(n+"개 전부 만들기"):"만들 편집안이 없습니다"; refreshSel(hlId);
if(curId!=null) applyUsedMarks(curId);
updateBuildBtn();
} }
/* (JSON ) /* (JSON )
@ -526,14 +522,11 @@ function curMode(){
const r=document.querySelector('input[name="amode"]:checked'); const r=document.querySelector('input[name="amode"]:checked');
return r?r.value:"full"; return r?r.value:"full";
} }
// ⚠ 네 모드 전부 /auto/prepare 가 댓글 매칭을 위해 remove_silence=True·asr_bottom=True 로
// 고정해서 돈다(공통 옵션의 "무음 제거" 체크박스는 자동 탭에서 숨겨져 있다 — setMode() 참조).
// "무음 제거는 아래 공통 옵션을 따른다"는 예전 문구가 이 사실과 어긋나 오해를 낳았었다.
const MODE_HELP={ const MODE_HELP={
full:"Gemini가 영상에서 하이라이트 구간 5개를 골라 편집안을 만들고, h-lab에서 그 구간을 언급한 댓글을 찾아옵니다. 무음 제거·받아쓰기(Whisper)는 댓글 매칭을 위해 항상 적용됩니다(끌 수 없음).", full:"Gemini가 영상에서 하이라이트 구간 5개를 골라 편집안을 만들고, h-lab에서 그 구간을 언급한 댓글을 찾아옵니다.",
whole:"Gemini가 구간 5개만 고르고(Step1), 각 구간을 컷 편집 없이 통짜로 만듭니다. 무음 제거·받아쓰기(Whisper)는 댓글 매칭을 위해 항상 적용됩니다(끌 수 없음).", whole:"Gemini가 구간 5개만 고르고(Step1), 각 구간을 컷 편집 없이 통짜로 만듭니다. 무음 제거·자막은 아래 공통 옵션을 따릅니다.",
wpaste:"Gemini를 쓰지 않습니다. 구간 JSON(candidates 5개)을 붙여넣으면 그 구간을 그대로 통짜로 만듭니다. 제목(윗줄·아랫줄)은 댓글 선택 화면에서 ID별로 직접 씁니다. 무음 제거·받아쓰기(Whisper)는 댓글 매칭을 위해 항상 적용됩니다(끌 수 없음).", wpaste:"Gemini를 쓰지 않습니다. 구간 JSON(candidates 5개)을 붙여넣으면 그 구간을 그대로 통짜로 만듭니다. 제목(윗줄·아랫줄)은 댓글 선택 화면에서 ID별로 직접 씁니다.",
paste:"Gemini를 쓰지 않습니다. 오팔에서 받은 JSON 5개를 통째로 붙여넣으면 댓글 선택 화면으로 갑니다. URL을 비우면 JSON 안의 url을 씁니다. 무음 제거·받아쓰기(Whisper)는 댓글 매칭을 위해 항상 적용됩니다(끌 수 없음).", paste:"Gemini를 쓰지 않습니다. 오팔에서 받은 JSON 5개를 통째로 붙여넣으면 댓글 선택 화면으로 갑니다. URL을 비우면 JSON 안의 url을 씁니다.",
}; };
const PASTE_UI={ const PASTE_UI={
paste:{label:"오팔 JSON 붙여넣기 (여러 개를 통째로 — 사이에 구분선·타이틀 후보가 섞여 있어도 됨)", paste:{label:"오팔 JSON 붙여넣기 (여러 개를 통째로 — 사이에 구분선·타이틀 후보가 섞여 있어도 됨)",
@ -556,7 +549,7 @@ function applyMode(){
$("#autoGo").textContent=p?"댓글 매칭 시작":"분석 시작 (하이라이트 5개)"; $("#autoGo").textContent=p?"댓글 매칭 시작":"분석 시작 (하이라이트 5개)";
} }
/* ── 분석 (1차: 편집안·타이틀 후보만 — 댓글 매칭 없음) ── */ /* ── 분석 ── */
async function analyze(){ async function analyze(){
const m=curMode(); const m=curMode();
const url=$("#autoUrl").value.trim(); const url=$("#autoUrl").value.trim();
@ -566,11 +559,6 @@ async function analyze(){
if(PASTE_UI[m]&&!$("#apaste").value.trim()){ if(PASTE_UI[m]&&!$("#apaste").value.trim()){
alert(m==="wpaste"?"구간 JSON을 붙여넣으세요.":"오팔 JSON을 붙여넣으세요.");return;} alert(m==="wpaste"?"구간 JSON을 붙여넣으세요.":"오팔 JSON을 붙여넣으세요.");return;}
$("#autoGo").disabled=true;$("#autoGo").textContent="분석 중…"; $("#autoGo").disabled=true;$("#autoGo").textContent="분석 중…";
A=null;P=null;dropped=new Set();TITLE_PICKS={};AUTO_AID=null;PREP_ID=null;
$("#autoPickReview").innerHTML="";
$("#autoPrepGo").style.display="none";$("#autoPrepGo").disabled=false;
$("#autoPrepSteps").innerHTML="";$("#autoPrepSteps").style.display="none";
$("#autoPrepLog").innerHTML="";
$("#autoReview").innerHTML="";$("#autoSummary").style.display="none"; $("#autoReview").innerHTML="";$("#autoSummary").style.display="none";
$("#autoBuild").style.display="none";$("#autoLog").innerHTML=""; $("#autoBuild").style.display="none";$("#autoLog").innerHTML="";
$("#buildBoard").style.display="none";$("#buildBoard").innerHTML=""; $("#buildBoard").style.display="none";$("#buildBoard").innerHTML="";
@ -584,8 +572,7 @@ async function analyze(){
res=await(await fetch("/auto/analyze",{method:"POST",body:fd})).json(); res=await(await fetch("/auto/analyze",{method:"POST",body:fd})).json();
}catch(e){return failA("요청 실패: "+e);} }catch(e){return failA("요청 실패: "+e);}
if(res.error) return failA(res.error); if(res.error) return failA(res.error);
AUTO_AID=res.analysis_id; const es=new EventSource("/auto/stream/"+res.analysis_id);
const es=new EventSource("/auto/stream/"+AUTO_AID);
es.onmessage=(m)=>{ es.onmessage=(m)=>{
const ev=JSON.parse(m.data); const ev=JSON.parse(m.data);
if(ev.type==="manifest") renderASteps(ev.steps); if(ev.type==="manifest") renderASteps(ev.steps);
@ -617,172 +604,49 @@ function updateAStep(ev){
if(ev.detail){const d=el.querySelector(".sdetail");d.hidden=false;d.textContent=ev.detail;}} if(ev.detail){const d=el.querySelector(".sdetail");d.hidden=false;d.textContent=ev.detail;}}
} }
/* 1 ( · · ). . /* ── 검토 화면 (ID별 탭) ── */
전환 없이 세로로 쌓는다(보통 5개뿐이라 전부 보여도 부담 없다) 지금 검토 화면에서
댓글 영역만 것이 1 검토라는 설계를 그대로 따른다. */
function onResult(ev){ function onResult(ev){
A=ev; A=ev;byIdx={};sel={};curId=null;dropped=new Set();
const R=$("#autoPickReview");R.innerHTML="";
let ok=0;
for(const hl of ev.highlights){
const card=document.createElement("div");
card.className="hlbox show pickcard";card.id="pick-"+hl.id;
if(hl.error){
card.classList.add("errbox");
card.innerHTML='<div class="pickhead"><h3 style="margin:0;">ID '+hl.id+' ✗</h3>'+
'<span style="color:var(--danger);font-size:12.5px;">생성 실패</span></div>'+
'<div class="hlmeta" style="color:var(--danger);">'+esc(hl.error)+'</div>';
R.appendChild(card);
continue;
}
ok++;
const cuts=hl.paste.cuts;
let html='<div class="pickhead"><h3 style="margin:0;">ID '+hl.id+" · "+fmtT(hl.start)+"~"+fmtT(hl.end)+
"</h3><span style='color:var(--muted2);font-size:12.5px;'>"+esc(hl.reason||"")+"</span>"+
'<button type="button" class="ghost pickx" id="pickx-'+hl.id+'">✕ 제외</button></div>'+
'<div class="hlmeta">컷 '+cuts.length+"개 · 총 "+hl.total+"초 · 카드 "+hl.need+"장 필요</div>";
let opts=[];
if(hl.editable_title){ // 구간 통짜: 제목 직접 입력(비우면 제목 없이)
html+='<div class="hlsec">제목: '+
'<input class="hlinput" id="hltop-'+hl.id+'" placeholder="윗줄 · 주황 (선택)"> '+
'<input class="hlinput" id="hlmain-'+hl.id+'" placeholder="아랫줄 · 흰색 (선택)"></div>';
}else{
const t0={top:hl.paste.title_top,main:hl.paste.title_main,kind:"최종 선택"};
opts=[t0].concat((hl.titles||[]).filter(t=>t.top!==t0.top||t.main!==t0.main));
html+='<div class="hlsec">제목: <select id="hlt-'+hl.id+'">'+
opts.map((t,i)=>'<option value="'+i+'">'+esc(t.top)+" / "+esc(t.main)+
(t.kind?" — "+esc(t.kind):"")+"</option>").join("")+"</select></div>";
}
card.innerHTML=html;
card.dataset.titles=JSON.stringify(opts);
card.appendChild(cutsSection(hl)); // 영상 편집안(컷 목록·JSON) — 기본 접힘
R.appendChild(card);
$("#pickx-"+hl.id).addEventListener("click",()=>togglePickDrop(hl.id));
}
if(ok){$("#autoPrepGo").style.display="block";updatePrepBtn();}
else $("#autoPrepGo").style.display="none";
}
/* " " 2(prepare)
1 카드의 select/input 값을 더는 참조하지 않으므로 미리 읽어 저장해야 한다. */
function collectTitlePicks(){
const out={};
for(const hl of A.highlights){
if(hl.error||isDropped(hl.id)) continue;
let pick;
if(hl.editable_title){
pick={top:($("#hltop-"+hl.id)?$("#hltop-"+hl.id).value.trim():""),
main:($("#hlmain-"+hl.id)?$("#hlmain-"+hl.id).value.trim():"")};
}else{
const card=$("#pick-"+hl.id);
const opts=card?JSON.parse(card.dataset.titles||"[]"):[];
const selEl=$("#hlt-"+hl.id);
pick=opts[parseInt(selEl?selEl.value:0,10)||0]||{top:"",main:""};
}
out[hl.id]=pick;
}
return out;
}
/* ── 2단계: 준비(다운로드·받아쓰기·댓글 매칭) — 제외 안 한 ID만, 서버가 순차로 돈다 ── */
async function prepareAll(){
if(!A) return;
const ids=liveIds();
if(!ids.length){alert("준비할 ID가 없습니다. 제외(✕)를 하나 이상 해제하세요.");return;}
TITLE_PICKS=collectTitlePicks();
const btn=$("#autoPrepGo");
btn.disabled=true;btn.textContent="준비 중…";
$("#autoPrepSteps").innerHTML="";$("#autoPrepLog").innerHTML="";
$("#autoReview").innerHTML="";$("#autoSummary").style.display="none";
$("#autoBuild").style.display="none";
$("#buildBoard").style.display="none";$("#buildBoard").innerHTML="";
if($("#autoFixedWrap")) $("#autoFixedWrap").style.display="none";
let res;
try{
const fd=new FormData();
fd.append("aid",AUTO_AID);
fd.append("ids",JSON.stringify(ids));
res=await(await fetch("/auto/prepare",{method:"POST",body:fd})).json();
}catch(e){return prepFail("요청 실패: "+e);}
if(res.error) return prepFail(res.error);
PREP_ID=res.prepare_id;
const es=new EventSource("/auto/prepare/"+PREP_ID);
es.onmessage=(ev0)=>{
const ev=JSON.parse(ev0.data);
if(ev.type==="manifest") renderPrepSteps(ev.steps);
else if(ev.type==="step") updatePrepStep(ev);
else if(ev.type==="log") prepLog(ev.msg);
else if(ev.type==="result"){es.close();onPrepareResult(ev);prepDone();}
else if(ev.type==="error"){es.close();prepFail(ev.message);}
};
es.onerror=()=>{es.close();prepFail("연결이 끊겼습니다.");};
}
function prepDone(){$("#autoPrepGo").disabled=false;updatePrepBtn();}
function prepFail(m){prepLog("⚠️ "+m);prepDone();}
function prepLog(m){
const d=document.createElement("div");
d.style.cssText="font-family:var(--mono);font-size:12px;color:var(--muted2);margin-top:4px;white-space:pre-wrap;";
d.textContent=m;$("#autoPrepLog").appendChild(d);
}
function renderPrepSteps(steps){
$("#autoPrepSteps").innerHTML=steps.map(s=>
'<div class="step" id="apst-'+s.id+'"><span class="sdot"></span>'+
'<div class="slabel"><div class="t">'+esc(s.label)+'</div><div class="sdetail" hidden></div></div>'+
'<div class="selapsed"></div></div>').join("");
$("#autoPrepSteps").style.display="block";
}
function updatePrepStep(ev){
const el=$("#apst-"+ev.id);if(!el)return;
if(ev.status==="start"){el.classList.add("active");}
else if(ev.status==="done"){el.classList.remove("active");el.classList.add("done");
if(ev.detail){const d=el.querySelector(".sdetail");d.hidden=false;d.textContent=ev.detail;}}
}
/*
byIdx/sel/selCut (auto/yt/paste) 구분 없는 전역 상태다. result 핸들러가
전부 byIdx={} 전역 리셋하면서 sel/selCut 자기 키만 건드리면, 다른 탭이
그려둔 카드가 방금 리셋된 byIdx/sel 참조해 "엉뚱한 댓글을 가리킴"·"ID n 사용중"
오표시·클릭 TypeError(toggle() list.indexOf, sel[hlId] undefined) 난다.
결과가 오면 sel/selCut 전역 리셋하고, 다른 탭의 검토 DOM·빌드 버튼까지
지워 클릭 경로 자체를 없앤다. */
const PANEL_DOM={auto:{review:"#autoReview",build:"#autoBuild"},
yt:{review:"#ytReview",build:"#ytBuild"},
paste:{review:"#pasteReview",build:"#pasteBuild"}};
function clearOtherPanels(exceptId){
for(const id in PANEL_DOM){
if(id===exceptId) continue;
const d=PANEL_DOM[id];
const r=$(d.review); if(r) r.innerHTML="";
const b=$(d.build); if(b) b.style.display="none";
}
}
/* 2 ID + renderCutPanel( ).
준비(prepare) 마친 ID만 온다 실패한 ID는 경고 로그로만 알린다( 자체가 없음). */
function onPrepareResult(ev){
P=ev;byIdx={};sel={};selCut={};curId=null;
clearOtherPanels("auto");
(ev.comments||[]).forEach(c=>{byIdx[c.idx]=c;}); (ev.comments||[]).forEach(c=>{byIdx[c.idx]=c;});
(ev.warnings||[]).forEach(w=>prepLog("⚠️ "+w)); (ev.warnings||[]).forEach(w=>alog("⚠️ "+w));
const R=$("#autoReview");R.innerHTML=""; const R=$("#autoReview");R.innerHTML="";
if(!ev.highlights||!ev.highlights.length){
R.innerHTML='<div class="hlbox show"><div class="hlmeta">준비된 편집안이 없습니다. 위 로그를 확인하세요.</div></div>';
$("#autoBuild").style.display="none";
return;
}
const tabs=document.createElement("div");tabs.className="idtabs";tabs.id="idTabs"; const tabs=document.createElement("div");tabs.className="idtabs";tabs.id="idTabs";
R.appendChild(tabs); R.appendChild(tabs);
let firstId=null; let firstOk=null,ok=0;
for(const hl of ev.highlights){ for(const hl of ev.highlights){
const src=A.highlights.find(h=>h.id===hl.id)||{}; // 탭 버튼
if(firstId===null) firstId=hl.id;
const tab=document.createElement("button"); const tab=document.createElement("button");
tab.type="button";tab.className="idtab";tab.id="idtab-"+hl.id; tab.type="button";tab.className="idtab";tab.id="idtab-"+hl.id;
tab.innerHTML='<span class="tt">ID '+hl.id+(src.start!=null?(" · "+fmtT(src.start)+"~"+fmtT(src.end)):"")+ if(hl.error){
'</span><span class="ts"></span>'; tab.classList.add("err");
tab.addEventListener("click",()=>showId(hl.id)); tab.innerHTML='<span class="tt">ID '+hl.id+' ✗</span><span class="ts">생성 실패 (클릭=사유)</span>';
tab.title=hl.error;
tab.addEventListener("click",()=>alog("ID "+hl.id+" 실패 사유: "+hl.error));
tabs.appendChild(tab); tabs.appendChild(tab);
continue;
}
ok++; if(firstOk===null) firstOk=hl.id;
tab.innerHTML='<span class="tt">ID '+hl.id+" · "+fmtT(hl.start)+"~"+fmtT(hl.end)+"</span>"+
'<span class="ts"></span>';
tab.addEventListener("click",()=>{ // 제외된 탭은 클릭만으로도 되돌리기
if(isDropped(hl.id)) setDropped(hl.id,false); else showId(hl.id);
});
// ✕ 는 탭 버튼의 '형제' (button 중첩 불가) — 래퍼로 감싸 겹쳐 놓는다
const wrap=document.createElement("div");
wrap.className="idtabwrap";
const x=document.createElement("button");
x.type="button";x.className="xdel";x.id="xdel-"+hl.id;x.textContent="✕";
x.title="이 ID 제외 (생성 안 함)";
x.setAttribute("aria-label","ID "+hl.id+" 제외 — 생성하지 않음");
x.setAttribute("aria-pressed","false");
x.addEventListener("click",(e)=>{
e.stopPropagation();
setDropped(hl.id,!isDropped(hl.id));
});
wrap.appendChild(tab);wrap.appendChild(x);
tabs.appendChild(wrap);
// 패널
const box=document.createElement("div"); const box=document.createElement("div");
box.className="hlbox";box.id="hlbox-"+hl.id; box.className="hlbox";box.id="hlbox-"+hl.id;
// 자동 선택: 컷별 추천을 컷 순서대로. hl.cuts 가 없으면 기존 방식으로 폴백. // 자동 선택: 컷별 추천을 컷 순서대로. hl.cuts 가 없으면 기존 방식으로 폴백.
@ -796,25 +660,48 @@ function onPrepareResult(ev){
}else{ }else{
sel[hl.id]=(hl.matched||[]).slice(0,hl.need).filter(i=>byIdx[i]!==undefined); sel[hl.id]=(hl.matched||[]).slice(0,hl.need).filter(i=>byIdx[i]!==undefined);
} }
box.innerHTML='<div class="hlmeta">카드 '+hl.need+"장 필요 · "+ const cuts=hl.paste.cuts;
'<span id="hlcnt-'+hl.id+'" class="tnum"></span></div>'+ let html="<h3>ID "+hl.id+" · "+fmtT(hl.start)+"~"+fmtT(hl.end)+
'<div class="selbar" id="hlsel-'+hl.id+'"></div>'; " <span style='color:var(--muted2);font-weight:400;'>"+esc(hl.reason||"")+"</span></h3>"+
'<div class="hlmeta">컷 '+cuts.length+"개 · 총 "+hl.total+"초 · 카드 "+hl.need+"장 필요 · "+
'<span id="hlcnt-'+hl.id+'" class="tnum"></span></div>';
let opts=[];
if(hl.editable_title){ // 구간 통짜: 제목 직접 입력(비우면 제목 없이)
html+='<div class="hlsec">제목: '+
'<input class="hlinput" id="hltop-'+hl.id+'" placeholder="윗줄 · 주황 (선택)"> '+
'<input class="hlinput" id="hlmain-'+hl.id+'" placeholder="아랫줄 · 흰색 (선택)"></div>';
}else{
const t0={top:hl.paste.title_top,main:hl.paste.title_main,kind:"최종 선택"};
opts=[t0].concat((hl.titles||[]).filter(t=>t.top!==t0.top||t.main!==t0.main));
html+='<div class="hlsec">제목: <select id="hlt-'+hl.id+'">'+
opts.map((t,i)=>'<option value="'+i+'">'+esc(t.top)+" / "+esc(t.main)+
(t.kind?" — "+esc(t.kind):"")+"</option>").join("")+"</select></div>";
}
html+='<div class="selbar" id="hlsel-'+hl.id+'"></div>';
box.innerHTML=html;
box.dataset.titles=JSON.stringify(opts);
// 영상 편집안(컷 목록·JSON) — 선택 요약 바 위에, 기본 접힘
box.insertBefore(cutsSection(hl),$("#hlsel-"+hl.id));
renderCutPanel(box,hl.id, renderCutPanel(box,hl.id,
{cuts:hl.cuts,matched:hl.matched,candidates:ev.candidates,cutRanges:hl.cutRanges}, {cuts:hl.cuts,matched:hl.matched,candidates:hl.candidates,
{unit:"컷"}); cutRanges:hl.paste.cuts},{unit:"컷"});
R.appendChild(box); R.appendChild(box);
refreshSel(hl.id); refreshSel(hl.id);
} }
// 접기/펼치기 버튼 — 탭 바 오른쪽 끝 // 접기/펼치기 버튼 — 탭 바 오른쪽 끝
if(ok){
const fb=document.createElement("button"); const fb=document.createElement("button");
fb.type="button";fb.className="idtab ccfoldbtn";fb.id="ccFoldBtn"; fb.type="button";fb.className="idtab ccfoldbtn";fb.id="ccFoldBtn";
fb.innerHTML='<span class="tt">▴ 댓글 접기</span><span class="ts">카드 표시 상태</span>'; fb.innerHTML='<span class="tt">▴ 댓글 접기</span><span class="ts">카드 표시 상태</span>';
fb.addEventListener("click",()=>setFolded(!$("#autoReview").classList.contains("folded"))); fb.addEventListener("click",()=>setFolded(!$("#autoReview").classList.contains("folded")));
tabs.appendChild(fb); tabs.appendChild(fb);
if(firstId!==null) showId(firstId); }
if(firstOk!==null) showId(firstOk);
if(ok){
$("#autoBuild").style.display="block"; $("#autoBuild").style.display="block";
updateBuildBtn(); updateBuildBtn();
if($("#autoFixedWrap")) $("#autoFixedWrap").style.display="flex"; if($("#autoFixedWrap")) $("#autoFixedWrap").style.display="flex";
}
} }
/* ── 캡처 + 순차 빌드 ── */ /* ── 캡처 + 순차 빌드 ── */
@ -857,8 +744,8 @@ function boardSet(hlId,stat,msg,cls){
async function buildAll(){ async function buildAll(){
const btn=$("#autoBuild"); const btn=$("#autoBuild");
const hls=(P&&P.highlights)||[]; // 준비(prepare)를 마친 ID만 — 실패·제외된 ID는 여기 없음 const hls=A.highlights.filter(h=>!h.error&&!isDropped(h.id)); // 제외한 ID는 안 만듦
if(!hls.length){alert("만들 편집안이 없습니다.");return;} if(!hls.length){alert("만들 ID가 없습니다. 제외(✕)를 하나 이상 해제하세요.");return;}
btn.disabled=true; btn.disabled=true;
setFolded(true); // 빌드 시작 → 댓글 영역 접기(진행판에 집중) setFolded(true); // 빌드 시작 → 댓글 영역 접기(진행판에 집중)
boardInit(hls); boardInit(hls);
@ -876,18 +763,23 @@ async function buildAll(){
await nextFrame(); await nextFrame();
boardSet(hl.id,"🔄 진행","", "active"); boardSet(hl.id,"🔄 진행","", "active");
try{ try{
// 제목은 1차 검토("준비 시작" 클릭 시점)에 이미 잠가 뒀다 — 다운로드·받아쓰기가 let pick;
// 끝난 지금은 1차 카드의 select/input 이 그대로 있어도 참조하지 않는다. if(hl.editable_title){
const pick=TITLE_PICKS[hl.id]||{top:"",main:""}; pick={top:($("#hltop-"+hl.id)?$("#hltop-"+hl.id).value.trim():""),
main:($("#hlmain-"+hl.id)?$("#hlmain-"+hl.id).value.trim():"")};
}else{
const opts=JSON.parse($("#hlbox-"+hl.id).dataset.titles);
pick=opts[parseInt($("#hlt-"+hl.id).value,10)||0];
}
const paste={...hl.paste,title_top:pick.top,title_main:pick.main};
const fd=new FormData(); const fd=new FormData();
fd.append("aid",AUTO_AID); fd.append("data",JSON.stringify(paste));
fd.append("id",String(hl.id)); fd.append("tag","하이라이트"+hl.id);
fd.append("title_top",pick.top||"");
fd.append("title_main",pick.main||"");
fd.append("video_scale",$("#vscale").value||"144"); fd.append("video_scale",$("#vscale").value||"144");
fd.append("flip",$("#flip").checked?"1":"0"); fd.append("flip",$("#flip").checked?"1":"0");
fd.append("scene",$("#scene").checked?"1":"0"); fd.append("scene",$("#scene").checked?"1":"0");
fd.append("bg_white",$("#bgwhite").checked?"1":"0"); fd.append("bg_white",$("#bgwhite").checked?"1":"0");
fd.append("remove_silence",$("#rmsilence").checked?"1":"0");
fd.append("asr_bottom",$("#asrbottom").checked?"1":"0"); fd.append("asr_bottom",$("#asrbottom").checked?"1":"0");
fd.append("cards_fixed",($("#autoCardsFixed")&&$("#autoCardsFixed").checked)?"1":"0"); fd.append("cards_fixed",($("#autoCardsFixed")&&$("#autoCardsFixed").checked)?"1":"0");
const cmap=selCut[hl.id]||{}; const cmap=selCut[hl.id]||{};
@ -961,20 +853,13 @@ function ytSec(v){
return p.length===3?p[0]*3600+p[1]*60+p[2]:p.length===2?p[0]*60+p[1]:p[0]; return p.length===3?p[0]*3600+p[1]*60+p[2]:p.length===2?p[0]*60+p[1]:p[0];
} }
function ytRanges(){ function ytRanges(){
// ⚠ 함정: 여기서 초 숫자로 바꿔 보내면 다운로드 경로(cut_youtube→valid_time)가
// "MM:SS"/"HH:MM:SS"만 받아들여 매 분석이 다운로드 단계에서 죽는다(구간 탭
// 전면 불능 — 서버는 방어적으로 재정규화하지만 프런트도 옛 fmtTime 동작대로
// 문자열 그대로 보내는 게 안전하다). 입력칸은 index.html의 focusout 리스너가
// 이미 fmtTime()으로 "16:07" 형식을 넣어 두므로 그 문자열을 그대로 쓴다.
const out=[]; const out=[];
document.querySelectorAll("#ranges .rng").forEach(row=>{ document.querySelectorAll("#ranges .rng").forEach(row=>{
const sv=(row.querySelector(".rstart").value||"").trim(); const s=ytSec(row.querySelector(".rstart").value);
const sSec=ytSec(sv); if(s==null) return;
if(sSec==null) return; let e=ytSec(row.querySelector(".rend").value);
let ev2=(row.querySelector(".rend").value||"").trim(); if(e==null) e=s+90; // 끝 비면 시작+1:30 (실행 로직과 동일)
let eSec=ytSec(ev2); if(e>s) out.push([s,e]);
if(eSec==null){ eSec=sSec+90; ev2=fmtT(eSec); } // 끝 비면 시작+1:30 (실행 로직과 동일)
if(eSec>sSec) out.push([sv,ev2]);
}); });
return out; return out;
} }
@ -1004,9 +889,7 @@ function ytFail(m){ytLog("⚠️ "+m);ytDone();}
(channel 유튜브에서 자동인식됐을 있음) 여기서 입력칸에 반영해 보여준다 (channel 유튜브에서 자동인식됐을 있음) 여기서 입력칸에 반영해 보여준다
사용자가 여기서 고치면 값이 생성(ytBuildAll) 그대로 실린다. */ 사용자가 여기서 고치면 값이 생성(ytBuildAll) 그대로 실린다. */
function onYtResult(ev){ function onYtResult(ev){
byIdx={};sel={};selCut={};curId=null; // 검토는 한 번에 하나 — 다른 탭 선택 상태까지 전부 리셋 byIdx={};(ev.comments||[]).forEach(c=>{byIdx[c.idx]=c;});
clearOtherPanels("yt");
(ev.comments||[]).forEach(c=>{byIdx[c.idx]=c;});
(ev.warnings||[]).forEach(w=>ytLog("⚠️ "+w)); (ev.warnings||[]).forEach(w=>ytLog("⚠️ "+w));
if(ev.title_top!=null) $("#yttop").value=ev.title_top; if(ev.title_top!=null) $("#yttop").value=ev.title_top;
if(ev.title_main!=null) $("#ytmain").value=ev.title_main; if(ev.title_main!=null) $("#ytmain").value=ev.title_main;
@ -1164,9 +1047,7 @@ function pasteFail(m){pasteLog("⚠️ "+m);pasteDone();}
expandAllCuts·refreshSel 전부 "#hlbox-"+panelId 찾으므로 id를 정확히 맞춰야 한다 expandAllCuts·refreshSel 전부 "#hlbox-"+panelId 찾으므로 id를 정확히 맞춰야 한다
(yt 탭의 #hlbox-yt 동일한 방식). */ (yt 탭의 #hlbox-yt 동일한 방식). */
function onPasteResult(ev){ function onPasteResult(ev){
byIdx={};sel={};selCut={};curId=null; // 검토는 한 번에 하나 — 다른 탭 선택 상태까지 전부 리셋 byIdx={};(ev.comments||[]).forEach(c=>{byIdx[c.idx]=c;});
clearOtherPanels("paste");
(ev.comments||[]).forEach(c=>{byIdx[c.idx]=c;});
(ev.warnings||[]).forEach(w=>pasteLog("⚠️ "+w)); (ev.warnings||[]).forEach(w=>pasteLog("⚠️ "+w));
PASTE_HL={id:"paste",need:ev.need,cuts:ev.cuts,paste:{cuts:ev.cutRanges}}; PASTE_HL={id:"paste",need:ev.need,cuts:ev.cuts,paste:{cuts:ev.cutRanges}};
sel["paste"]=[];selCut["paste"]={}; sel["paste"]=[];selCut["paste"]={};
@ -1309,7 +1190,6 @@ document.addEventListener("DOMContentLoaded",()=>{
document.querySelectorAll('input[name="amode"]').forEach(r=> document.querySelectorAll('input[name="amode"]').forEach(r=>
r.addEventListener("change",applyMode)); r.addEventListener("change",applyMode));
applyMode(); applyMode();
$("#autoPrepGo").addEventListener("click",prepareAll);
$("#autoBuild").addEventListener("click",buildAll); $("#autoBuild").addEventListener("click",buildAll);
$("#autoSettings").addEventListener("toggle",()=>{if($("#autoSettings").open)loadPrompts();}); $("#autoSettings").addEventListener("toggle",()=>{if($("#autoSettings").open)loadPrompts();});
$("#pSave").addEventListener("click",()=>savePrompts(false)); $("#pSave").addEventListener("click",()=>savePrompts(false));

View File

@ -157,17 +157,13 @@
.cc-meta{flex:1;min-width:0;} .cc-meta{flex:1;min-width:0;}
.cc-author{font-weight:600;font-size:13.5px;color:#fff;} .cc-author{font-weight:600;font-size:13.5px;color:#fff;}
.cc-time{font-size:11.5px;color:#aaa;margin-left:6.4px;} .cc-time{font-size:11.5px;color:#aaa;margin-left:6.4px;}
/* 본문 최대 5줄 말줄임(…) — 카드는 캡처돼 드래프트 하단(영상 아래~출처 사이 약 640px)에 .cc-text{font-size:14px;color:#fff;margin-top:5.6px;white-space:pre-wrap;word-break:break-word;line-height:1.5;}
들어가는데, 긴 댓글은 세로가 무한정 자라 출처(@채널)를 덮는다. 5줄이면 표시 높이가
그 한계 안이고, 검토 화면 = 캡처 결과(WYSIWYG)라 여기서 자르는 게 맞다. */
.cc-text{font-size:14px;color:#fff;margin-top:5.6px;white-space:pre-wrap;word-break:break-word;line-height:1.5;
display:-webkit-box;-webkit-line-clamp:5;-webkit-box-orient:vertical;overflow:hidden;}
.cc-stats{font-size:12px;color:#aaa;margin-top:8px;display:flex;gap:16px;font-variant-numeric:tabular-nums;} .cc-stats{font-size:12px;color:#aaa;margin-top:8px;display:flex;gap:16px;font-variant-numeric:tabular-nums;}
.comment-card.mosaic .cc-avatar{filter:blur(6px);} .comment-card.mosaic .cc-avatar{filter:blur(6px);}
.comment-card.mosaic .cc-author{filter:blur(5px);} .comment-card.mosaic .cc-author{filter:blur(5px);}
/* 검토 화면 — 본문 600px 유지, 검토 영역만 와이드 브레이크아웃(댓글 카드 3~4열) */ /* 검토 화면 — 본문 600px 유지, 검토 영역만 와이드 브레이크아웃(댓글 카드 3~4열) */
#autoPickReview,#autoReview,#autoSummary,#ytReview,#ytSummary,#pasteReview,#pasteSummary{ #autoReview,#autoSummary,#ytReview,#ytSummary,#pasteReview,#pasteSummary{--rw:min(1240px,calc(100vw - 40px));
--rw:min(1240px,calc(100vw - 40px));width:var(--rw);margin-left:calc((100% - var(--rw))/2);} width:var(--rw);margin-left:calc((100% - var(--rw))/2);}
/* ID 탭 바 — 스크롤해도 위에 붙어 있음 */ /* ID 탭 바 — 스크롤해도 위에 붙어 있음 */
.idtabs{position:sticky;top:0;z-index:40;display:flex;gap:6px;flex-wrap:wrap; .idtabs{position:sticky;top:0;z-index:40;display:flex;gap:6px;flex-wrap:wrap;
padding:10px 0;background:var(--bg);margin-top:10px;} padding:10px 0;background:var(--bg);margin-top:10px;}
@ -203,15 +199,6 @@
.hlbox select{background:var(--surf2);border:1px solid var(--border);border-radius:7px; .hlbox select{background:var(--surf2);border:1px solid var(--border);border-radius:7px;
color:var(--text);font-size:12.5px;padding:8px 10px;max-width:100%;} color:var(--text);font-size:12.5px;padding:8px 10px;max-width:100%;}
.hlbox .hlprog{margin-top:8px;font-family:var(--mono);font-size:12px;color:var(--muted2);white-space:pre-line;} .hlbox .hlprog{margin-top:8px;font-family:var(--mono);font-size:12px;color:var(--muted2);white-space:pre-line;}
/* ── 1차 검토(하이라이트 카드) — 댓글 없이 제목만 고르는 화면. .hlbox 를 그대로 쓰되
탭 전환 없이 세로로 쌓아 보여준다(하이라이트가 5개뿐이라 전부 보여도 부담 없음). ── */
.pickcard{margin-top:10px;display:block;}
.pickcard:first-child{margin-top:4px;}
.pickhead{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:6px;}
.pickx{width:auto;margin:0 0 0 auto;padding:6px 12px;flex:none;}
.pickcard.dropped-pick{opacity:.5;border-style:dashed;}
.pickcard.dropped-pick .pickx{color:var(--accent);}
.pickcard.errbox{border-color:var(--danger);}
/* ── 영상 편집안(JSON 컷) — 기본 접힘, 클릭해 펼침 ── */ /* ── 영상 편집안(JSON 컷) — 기본 접힘, 클릭해 펼침 ── */
.cutsbox{margin-top:12px;border:1px solid var(--border);border-radius:9px; .cutsbox{margin-top:12px;border:1px solid var(--border);border-radius:9px;
background:var(--surf2);overflow:hidden;} background:var(--surf2);overflow:hidden;}
@ -491,12 +478,6 @@
<div id="autoSteps"></div> <div id="autoSteps"></div>
<div id="autoLog"></div> <div id="autoLog"></div>
<!-- 1차 검토 — 하이라이트 카드(제목 선택 · ✕ 제외 · 편집안 접기). 댓글 영역 없음. -->
<div id="autoPickReview"></div>
<button class="run" id="autoPrepGo" style="display:none;margin-top:14px;"></button>
<div id="autoPrepSteps"></div>
<div id="autoPrepLog"></div>
<!-- 2차 검토 — /auto/prepare 가 준비한 ID만 컷별 댓글 선택(renderCutPanel, 기존과 동일). -->
<div id="autoReview"></div> <div id="autoReview"></div>
<button class="run" id="autoBuild" style="display:none;margin-top:14px;"></button> <button class="run" id="autoBuild" style="display:none;margin-top:14px;"></button>
<label id="autoFixedWrap" style="display:none;align-items:center;gap:7px;margin-top:10px;color:var(--muted2);font-size:12.5px;cursor:pointer;"> <label id="autoFixedWrap" style="display:none;align-items:center;gap:7px;margin-top:10px;color:var(--muted2);font-size:12.5px;cursor:pointer;">
@ -617,9 +598,8 @@ function setMode(m){
$("#titleGroup").style.display=(m==="paste"||m==="auto"||m==="yt")?"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"||m==="paste")?"none":"block"; // 자동·붙여넣기 탭은 자체 버튼 사용 $("#run").style.display=(m==="auto"||m==="paste")?"none":"block"; // 자동·붙여넣기 탭은 자체 버튼 사용
// 붙여넣기·자동 탭은 댓글 매칭을 위해 무음 제거·받아쓰기가 항상 켜진다(/paste·/auto prepare // 붙여넣기 탭은 댓글 매칭을 위해 무음 제거가 항상 켜진다 — 끌 수 있는 것처럼 보이면 안 됨
// 가 remove_silence=True 로 고정) — 끌 수 있는 것처럼 보이면 안 되므로 체크박스 자체를 숨긴다. $("#rmsilenceField").style.display=m==="paste"?"none":"block";
$("#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"));