Compare commits

...

10 Commits

Author SHA1 Message Date
9b2cc96e3e fix: 긴 댓글 카드가 출처를 덮던 문제 — 캡처 5줄 말줄임 + 드래프트 축소 안전망
카드 캡처는 댓글 길이만큼 세로로 자라는데 배치 높이 한계(영상 아래~출처
사이 약 640px)가 어디에도 없어 장문 댓글이 출처(@채널)와 화면을 덮었다.
1차: 카드 본문을 최대 5줄 말줄임(검토 화면 = 캡처 결과라 WYSIWYG).
2차: 그래도 표시 높이가 한계를 넘는 카드는 draft 배치에서 비율 축소.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 15:59:43 +09:00
2266d0a3ee fix: h-lab 수집 단계에서 광고/홍보 댓글 제외
좋아요를 조작한 광고(할인 링크 등)가 좋아요 채우기·후보 상위로 올라와
드래프트 카드에 들어가는 사고가 실제로 났다. 오탐이 더 아프므로 보수적으로
URL·"n% 할인"·"최저가"만 걸러낸다("할인"·"%" 단독 일상 댓글은 통과).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 15:54:20 +09:00
5295d92dfc fix: 같은 컷 그룹 안에 같은 댓글 카드가 두 장 뜨던 중복 제거
컷별 '채우기' 섹션(cand)이 그 컷 추천(rec)에 이미 나온 카드를 제외하지 않아,
타임스탬프 없는 댓글이 추천되면 추천 섹션과 채우기 섹션에 쌍으로 떴다.
다른 컷의 복사본은 '이동'용 쌍둥이라 그대로 두고, 같은 컷 안에서만 거른다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 15:51:00 +09:00
bb1f168e36 fix: /auto/prepare 가 dict 컷을 튜플로 정규화 안 해 준비가 전면 실패하던 문제 수정
highlights 의 paste.cuts 는 화면용 dict 인데 paste_analyze 는 parse_paste 출력형
튜플을 기대한다. 옛 /auto/build 는 프런트 JSON 재파싱으로 변환을 공짜로 얻었지만
새 /auto/prepare 는 서버 보관본을 직접 넘겨서, dict 언패킹이 키 문자열을 풀어
모든 ID 가 "end 는 start 보다 커야 합니다"(문자열 "end"<="start" 비교)로 즉사했다.
_hl_paste_payload() 로 호출 직전에 변환한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 14:47:10 +09:00
7ea17127b1 fix(important): 자동 탭 "무음 제거" 체크박스를 붙여넣기 탭과 동일하게 숨김
/auto/prepare가 댓글 매칭을 위해 remove_silence=True·asr_bottom=True를
고정하고 /auto/build도 remove_silence 값을 아예 받지 않아, 공통 옵션의
무음 제거 체크박스가 자동 탭에서 죽은 컨트롤이었다(꺼도 무음이 제거됨).
붙여넣기 탭과 대칭으로 setMode()에서 자동 탭일 때도 체크박스를 숨기고,
네 가지 방식(full/whole/wpaste/paste) 도움말 문구에 "무음 제거·받아쓰기는
항상 적용됨"을 명시해 "아래 공통 옵션을 따른다"던 옛 오해성 문구를 바로잡았다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 13:27:44 +09:00
e88571e5e9 fix(important): 자동/구간/붙여넣기 탭 간 검토 상태(byIdx·sel·selCut) 오염 방지
세 result 핸들러(onPrepareResult/onYtResult/onPasteResult)가 byIdx는
전역 리셋하면서 sel/selCut은 자기 탭 키만 건드려, 한 세션에서 두 탭을
쓰면 칩이 다른 탭 댓글을 가리키거나 "ID n 사용중" 오표시가 뜨고, 이미
그려진 다른 탭 카드를 클릭하면 sel[hlId]가 undefined라 toggle()에서
TypeError가 났다(const selCut 재할당 시도 자체도 원래 런타임 에러였음
— let로 변경). "검토는 한 번에 하나" 원칙으로 세 핸들러 모두 byIdx·
sel·selCut을 전역 리셋하고, 다른 탭의 검토 DOM·빌드 버튼도 clearOtherPanels()
로 함께 지워 클릭 경로 자체를 없앴다.

grep 대조: byIdx/sel/selCut을 읽고 쓰는 곳(toggle/sortSel/cutFull/
refreshSel/wrapOf/usedByOthers/applyUsedMarks/buildAll류)은 모두 hlId로
키를 참조하므로 이번 전역 리셋과 일관되게 동작한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 13:27:25 +09:00
6ab3d998e9 fix(critical): 유튜브 구간 탭 시간 형식 불일치로 전면 불능이던 것 수정
ytRanges()가 구간을 초 숫자로 /yt/analyze에 보내는데 다운로드 경로의
cut_youtube()→valid_time()은 MM:SS/HH:MM:SS만 통과시켜, 모든 분석이
다운로드 단계에서 "시간 형식 오류"로 죽고 있었다. 프런트는 입력칸에
이미 들어있는 fmtTime 포맷 문자열을 그대로 보내도록 되돌리고, 서버
(_parse_ranges)도 parse_time()으로 초 환산 후 valid_time이 통과하는
형식으로 재포맷해 프런트가 무엇을 보내든 다운로드 경로까지 안전하게
닿도록 방어를 추가했다. 스크래치패드 스크립트로 정상 케이스·구
버그 케이스·1시간 이상 구간까지 valid_time 통과를 확인했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 13:26:55 +09:00
77265f822e 문서: 세 탭(구간/붙여넣기/자동) analyze→stream→build 3단계 흐름을 문서에 반영
받아쓰기 후 댓글 매칭으로 바뀐 서버 흐름이 ARCHITECTURE.md에 전혀 안 남아 있어
다음 세션이 옛 단일 엔드포인트(POST /youtube 등)를 전제로 코드를 읽을 위험이 있었다.
탭별 흐름 표·cuts_from_state() 공통 조립점·두 좌표계 금기를 명시하고, SETUP.md
문제 해결표에 새 증상 3개, README.md 탭 설명에 새 흐름을 반영했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 12:58:19 +09:00
71d0f2fc4e fix: /auto/prepare가 paste_analyze에 name_suffix를 안 넘겨 드래프트가 덮어써지던 문제 수정
pipeline.py의 draft_name = _safe_name(title) or draft_name는 영상 제목이 항상
draft_name 인자를 무력화해 name_suffix만이 유일한 구분 수단인데, /auto/prepare의
paste_analyze 호출에 그게 빠져 있었다. 결과: 한 URL의 하이라이트 5개가 전부
같은 draft_name을 가져 "전부 만들기" 시 뒤에 만든 것이 앞의 CapCut 드래프트를
조용히 덮어썼다(진행판은 5개 다 완료로 표시). 옛 /auto/build의 tag→name_suffix
방식을 그대로 복원해 ID별 고유 접미사를 넘기게 고쳤다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 12:46:56 +09:00
e650cfb992 자동 탭: 분석→준비→검토 3단계로 쪼개 받아쓰기를 제외 선택 뒤로 미룸
검토 화면의 ✕ 제외 버튼 때문에, 받아쓰기(다운로드·Whisper)를 제외 선택 전에
돌리면 CPU를 낭비한다. /auto/stream은 이제 편집안·타이틀 후보(highlights)만
내고 댓글 매칭을 하지 않는다. 새 /auto/prepare(POST)+/auto/prepare/{pid}(SSE)가
제외 안 한 ID만 순차로 다운로드·받아쓰기 + h-lab 댓글 매칭을 돌려 PSTATES에
담고, /auto/build는 그 상태로 paste_draft만 실행한다(다운로드·받아쓰기 재실행
없음). 화면은 1차 검토(하이라이트 카드: 제목 선택·제외·편집안 접기)와 2차
검토(기존 컷별 댓글 선택 화면, renderCutPanel 등 캡처 루프 그대로 재사용)로
분리했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 12:33:47 +09:00
8 changed files with 652 additions and 274 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. 엔드포인트 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 ★ 두 파이프라인, 각각 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 컷별 댓글 추천(자동 탭) — 타임스탬프 우선 + 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,16 +58,37 @@ 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()`.
@ -77,8 +98,14 @@ 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 조각이 이어받는다.
미리 갈라놨다(1단계 기준으로는 사용자에게 보이는 동작은 그대로). 이 갈라짐은 이제(2~4단계) **서버 HTTP 계층에도 그대로 노출돼 있다** — ▶ 유튜브 구간
· 📋 붙여넣기 · 🤖 자동 세 탭 모두 `*_analyze`(§3 표의 1단계)가 요청 하나로 끝나고,
그 결과를 받아쓰기까지 끝낸 SSE 스트림(2단계)이 이어받아 **h-lab 댓글 수집 →
`recommend.cuts_from_state()`로 컷별 카드 추천**까지 마친 뒤 검토 화면용 `result`
낸다(엔드포인트 대응표는 §3). 사용자가 검토 화면에서 카드를 고르고 나서야
3단계(`*_build`)가 그 상태로 `*_draft`를 돌려 실제 드래프트를 만든다. **파일 탭만 이 3단계 분리 없이
`/upload → GET /stream/{job_id}`로 통짜 실행되는 옛 흐름 그대로**다(댓글 매칭 없음).
- `*_analyze`는 끝나면 다음 조각에 넘길 상태를 실어 `{"type":"state","state":{…}}` - `*_analyze`는 끝나면 다음 조각에 넘길 상태를 실어 `{"type":"state","state":{…}}`
낸다. **이 이벤트는 내부 전용**이라 래퍼가 걸러내고 밖으로 흘리지 않는다 — 기존 UI가 낸다. **이 이벤트는 내부 전용**이라 래퍼가 걸러내고 밖으로 흘리지 않는다 — 기존 UI가
@ -89,12 +116,23 @@ 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. 📁 파일 / ▶ 유튜브 구간 → `process_bg_template()` (pipeline.py) ### 4-A. 📁 파일 / ▶ 유튜브 구간 (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개 이상이면
@ -123,9 +161,15 @@ draft 조각 = scene~draft)
보존 구간을 장면전환 지점에서 **인접 분할**(누적 길이 불변 → 자막 싱크 무영향). 보존 구간을 장면전환 지점에서 **인접 분할**(누적 길이 불변 → 자막 싱크 무영향).
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] → [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)
@ -149,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에게 시킬 형식)
@ -245,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()`로 옮기고
그 안에서 나눈다. 카드 시간을 압축 전에 만들어 자막처럼 재매핑하면 그 안에서 나눈다. 카드 시간을 압축 전에 만들어 자막처럼 재매핑하면
@ -264,8 +310,20 @@ 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` 둘 다
같은(압축) 타임라인 좌표여야 한다** — 좌표계가 다르면 엉뚱한 컷에 엉뚱한 자막이 같은(압축) 타임라인 좌표여야 한다** — 좌표계가 다르면 엉뚱한 컷에 엉뚱한 자막이
@ -320,12 +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, - `server/static/auto.js`: 컷별 카드 패널 렌더는 `renderCutPanel(box, panelId, data,
panelId, data, opts)` 하나로 통합돼 있다 — 자동 탭(`onResult`)과 ▶ 유튜브 구간 탭 opts)` 하나로 통합돼 있다 — 🤖 자동 탭(2차 검토, `panelId=hl.id`) · ▶ 유튜브 구간 탭
`💬 구간 댓글 매칭`(`ytMatch`)이 이 함수를 같이 쓴다. **왜**: 예전엔 두 갈래로 따로 (`panelId="yt"`) · 📋 붙여넣기 탭(`panelId="paste"`) 검토 화면이 전부 이 함수를 같이
구현돼 있었는데, 여기에 📋 붙여넣기 탭까지 더하면 세 갈래가 되어 한 곳만 고치는 쓴다. **왜**: 예전엔 자동 탭만 있었는데, 유튜브 구간·붙여넣기 탭에 같은 검토 화면을
실수가 나기 쉽다. `data.cuts`가 있으면 컷별 섹션(`컷 N · X초 · 카드 Q장 — 자막`), 추가하면서 세 갈래가 되어 한 곳만 고치는 실수가 나기 쉬웠다(하나로 통합해 예방).
없으면 기존 ⭐/ 폴백을 그린다. `data.cuts`가 있으면 컷별 섹션(`컷 N · X초 · 카드 Q장 — 자막`), 없으면 기존 ⭐/
폴백을 그린다 — 세 탭 모두 `*_stream`이 낸 `result`(§3·§6)를 그대로 이 함수에 넘긴다.
## 9. 현재 고정값 치트시트 ## 9. 현재 고정값 치트시트
@ -377,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에 반영 안 됨(역도 동일).

View File

@ -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 처리 파이프라인(다운로드→컷→자막→드래프트)

View File

@ -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 실패 또는 자막 없음 | 로그의 경고 확인. 🔤·➕는 계속 동작 |
| 분석 결과가 만료됐다고 나온다 | 서버 재시작으로 메모리 상태 소실 | 분석을 다시 돌린다 |
--- ---

View File

@ -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 ""),

View File

@ -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")

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 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
@ -57,6 +57,12 @@ 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), "댓글카드")
@ -91,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 []
@ -103,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
@ -472,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():
@ -481,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:
@ -511,8 +544,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
@ -579,14 +610,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:
@ -597,24 +624,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 방식으로도 만들 수 있습니다."})
@ -673,58 +692,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·503(재시도까지 소진)이 조용히 삼켜져 아무도 모른다. "무엇이 안 됐고
# 그래서 어떻게 됐는지"를 한 문장에 담는다(사용자가 로그 영역만 보고 지나침).
warnings.append(
f"ID {h.get('id')} AI 추천 실패(Gemini 응답 없음) — "
"분:초·자막단어·좋아요로 배정했습니다") "분:초·자막단어·좋아요로 배정했습니다")
except Exception as exc: # noqa: BLE001 — 추천 실패가 생성을 막으면 안 된다 except Exception as exc: # noqa: BLE001
warnings.append( cuts, need = None, max(1, int(state["timeline_dur"] // 3))
f"ID {h.get('id')} 컷별 추천 실패 — 기존 방식으로 표시 " warnings.append(f"ID {hid} 컷별 추천 실패 — 컷 정보 없이 진행합니다 "
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",
@ -781,32 +909,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): # 재빌드 시 이전 카드 잔재 제거
@ -816,25 +969,13 @@ 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": 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("/paste/analyze") @app.post("/paste/analyze")

View File

@ -2,11 +2,15 @@
카드 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; // 분석 result {highlights, comments, warnings} let A=null; // 1차 결과(분석) {highlights} — 편집안·타이틀 후보만, 댓글 없음
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(); // 사용자가 X로 제외한 ID(문자열) — 빌드에서 제외. 되돌리기 가능 let dropped=new Set(); // 1차 검토에서 X로 제외한 ID(문자열) — 준비에서 제외. 되돌리기 가능
const isDropped=(id)=>dropped.has(String(id)); const isDropped=(id)=>dropped.has(String(id));
/* ── h-lab timeAgo 이식 ── */ /* ── h-lab timeAgo 이식 ── */
@ -25,7 +29,8 @@ 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;
return A.highlights.find(h=>h.id===id); // 자동 탭: cuts/need 는 2차(prepare) 결과에만 있다 — 카드 선택은 항상 2차에서만 일어난다.
return P&&P.highlights.find(h=>h.id===id);
} }
/* ── 카드 DOM (h-lab renderCards 구조와 동일) ── */ /* ── 카드 DOM (h-lab renderCards 구조와 동일) ── */
@ -215,11 +220,14 @@ 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));
// 부족분 채우기 — 컷마다 반복되므로 처음엔 조금만 그리고 나머지는 더보기로 // 부족분 채우기 — 컷마다 반복되므로 처음엔 조금만 그리고 나머지는 더보기로.
if(cand.length) // 이 컷 추천(rec)에 이미 나온 카드는 뺀다 — 안 빼면 같은 컷 그룹 안에 같은 댓글이
// 두 장 뜬다(다른 컷의 복사본은 '이동'용이라 남기지만, 같은 컷 안은 순수 중복).
const fill=cand.filter(i=>!rec.includes(i));
if(fill.length)
body.appendChild(cardSection( body.appendChild(cardSection(
"↳ 좋아요 상위에서 채우기 "+cand.length+"장 (이 컷에 넣기)", "↳ 좋아요 상위에서 채우기 "+fill.length+"장 (이 컷에 넣기)",
cand,panelId,CUT_FILL_PAGE,cu.i)); fill,panelId,CUT_FILL_PAGE,cu.i));
box.appendChild(grp); box.appendChild(grp);
} }
}else{ }else{
@ -239,7 +247,7 @@ function renderCutPanel(box,panelId,data,opts){
/* ' ' . /* ' ' .
selCut[hlId][idx] = 인덱스. sel[hlId] 항상 순서로 정렬해 둔다 selCut[hlId][idx] = 인덱스. sel[hlId] 항상 순서로 정렬해 둔다
(업로드 순서 = 배치 순서라서). */ (업로드 순서 = 배치 순서라서). */
const selCut={}; let 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));
@ -417,41 +425,37 @@ function showId(hlId){
applyUsedMarks(hlId); // 탭 열 때 최신 선택 상태로 갱신 applyUsedMarks(hlId); // 탭 열 때 최신 선택 상태로 갱신
} }
/* ── ID 제외 / 되돌리기 /* ── 1차 검토: ID 제외 / 되돌리기
지우지 않고 '제외' 상태로만 둔다 실수로 눌러도 번에 복구 가능(되돌리기 지원). 지우지 않고 '제외' 상태로만 둔다 실수로 눌러도 번에 복구 가능(되돌리기 지원).
제외된 ID는 빌드에서 빠지고, ID가 잡고 있던 댓글도 '사용중' 표시에서 풀린다. */ 제외된 ID는 /auto/prepare 넘어간다 받아쓰기를 아예 돌려 낭비가 없다(Task 7). */
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 updateBuildBtn(){ function updatePrepBtn(){
const btn=$("#autoBuild"); if(!btn||!A) return; const btn=$("#autoPrepGo"); 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 setDropped(hlId,on){ function togglePickDrop(hlId){
const key=String(hlId); const on=!isDropped(hlId);
if(on) dropped.add(key); else dropped.delete(key); if(on) dropped.add(String(hlId)); else dropped.delete(String(hlId));
const tab=$("#idtab-"+hlId); const card=$("#pick-"+hlId);
if(tab) tab.classList.toggle("dropped",on); if(card) card.classList.toggle("dropped-pick",on);
const x=$("#xdel-"+hlId); const btn=$("#pickx-"+hlId);
if(x){ if(btn){
x.textContent=on?"↺":"✕"; btn.textContent=on?"↺ 되돌리기":"✕ 제외";
x.title=on?"다시 포함":"이 ID 제외 (생성 안 함)"; btn.title=on?"다시 포함":"이 ID 제외 (준비 안 함)";
x.setAttribute("aria-label","ID "+hlId+(on?" 다시 포함":" 제외 — 생성하지 않음"));
x.setAttribute("aria-pressed",on?"true":"false");
} }
const box=$("#hlbox-"+hlId); updatePrepBtn();
if(box&&on) box.classList.remove("show");
if(on&&String(curId)===key){ // 보던 탭을 제외 → 남은 첫 탭으로 이동
const nxt=liveIds()[0];
if(nxt!=null) showId(nxt); else curId=null;
} }
if(!on) showId(hlId); // 되돌리면 그 탭을 연다 /* ── 2차 검토: '전부 만들기' 버튼 — 준비(prepare)를 마친 편집안 개수 기준 ── */
refreshSel(hlId); function updateBuildBtn(){
if(curId!=null) applyUsedMarks(curId); const btn=$("#autoBuild"); if(!btn||!P) return;
updateBuildBtn(); const n=(P.highlights||[]).length;
btn.disabled=(n===0);
btn.textContent=n?(n+"개 전부 만들기"):"만들 편집안이 없습니다";
} }
/* (JSON ) /* (JSON )
@ -522,11 +526,14 @@ 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에서 그 구간을 언급한 댓글을 찾아옵니다.", full:"Gemini가 영상에서 하이라이트 구간 5개를 골라 편집안을 만들고, h-lab에서 그 구간을 언급한 댓글을 찾아옵니다. 무음 제거·받아쓰기(Whisper)는 댓글 매칭을 위해 항상 적용됩니다(끌 수 없음).",
whole:"Gemini가 구간 5개만 고르고(Step1), 각 구간을 컷 편집 없이 통짜로 만듭니다. 무음 제거·자막은 아래 공통 옵션을 따릅니다.", whole:"Gemini가 구간 5개만 고르고(Step1), 각 구간을 컷 편집 없이 통짜로 만듭니다. 무음 제거·받아쓰기(Whisper)는 댓글 매칭을 위해 항상 적용됩니다(끌 수 없음).",
wpaste:"Gemini를 쓰지 않습니다. 구간 JSON(candidates 5개)을 붙여넣으면 그 구간을 그대로 통짜로 만듭니다. 제목(윗줄·아랫줄)은 댓글 선택 화면에서 ID별로 직접 씁니다.", wpaste:"Gemini를 쓰지 않습니다. 구간 JSON(candidates 5개)을 붙여넣으면 그 구간을 그대로 통짜로 만듭니다. 제목(윗줄·아랫줄)은 댓글 선택 화면에서 ID별로 직접 씁니다. 무음 제거·받아쓰기(Whisper)는 댓글 매칭을 위해 항상 적용됩니다(끌 수 없음).",
paste:"Gemini를 쓰지 않습니다. 오팔에서 받은 JSON 5개를 통째로 붙여넣으면 댓글 선택 화면으로 갑니다. URL을 비우면 JSON 안의 url을 씁니다.", paste:"Gemini를 쓰지 않습니다. 오팔에서 받은 JSON 5개를 통째로 붙여넣으면 댓글 선택 화면으로 갑니다. URL을 비우면 JSON 안의 url을 씁니다. 무음 제거·받아쓰기(Whisper)는 댓글 매칭을 위해 항상 적용됩니다(끌 수 없음).",
}; };
const PASTE_UI={ const PASTE_UI={
paste:{label:"오팔 JSON 붙여넣기 (여러 개를 통째로 — 사이에 구분선·타이틀 후보가 섞여 있어도 됨)", paste:{label:"오팔 JSON 붙여넣기 (여러 개를 통째로 — 사이에 구분선·타이틀 후보가 섞여 있어도 됨)",
@ -549,7 +556,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();
@ -559,6 +566,11 @@ 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="";
@ -572,7 +584,8 @@ 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);
const es=new EventSource("/auto/stream/"+res.analysis_id); AUTO_AID=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);
@ -604,49 +617,172 @@ 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;}}
} }
/* ── 검토 화면 (ID별 탭) ── */ /* 1 ( · · ). .
전환 없이 세로로 쌓는다(보통 5개뿐이라 전부 보여도 부담 없다) 지금 검토 화면에서
댓글 영역만 것이 1 검토라는 설계를 그대로 따른다. */
function onResult(ev){ function onResult(ev){
A=ev;byIdx={};sel={};curId=null;dropped=new Set(); A=ev;
(ev.comments||[]).forEach(c=>{byIdx[c.idx]=c;}); const R=$("#autoPickReview");R.innerHTML="";
(ev.warnings||[]).forEach(w=>alog("⚠️ "+w)); let ok=0;
const R=$("#autoReview");R.innerHTML="";
const tabs=document.createElement("div");tabs.className="idtabs";tabs.id="idTabs";
R.appendChild(tabs);
let firstOk=null,ok=0;
for(const hl of ev.highlights){ for(const hl of ev.highlights){
// 탭 버튼 const card=document.createElement("div");
const tab=document.createElement("button"); card.className="hlbox show pickcard";card.id="pick-"+hl.id;
tab.type="button";tab.className="idtab";tab.id="idtab-"+hl.id;
if(hl.error){ if(hl.error){
tab.classList.add("err"); card.classList.add("errbox");
tab.innerHTML='<span class="tt">ID '+hl.id+' ✗</span><span class="ts">생성 실패 (클릭=사유)</span>'; card.innerHTML='<div class="pickhead"><h3 style="margin:0;">ID '+hl.id+' ✗</h3>'+
tab.title=hl.error; '<span style="color:var(--danger);font-size:12.5px;">생성 실패</span></div>'+
tab.addEventListener("click",()=>alog("ID "+hl.id+" 실패 사유: "+hl.error)); '<div class="hlmeta" style="color:var(--danger);">'+esc(hl.error)+'</div>';
tabs.appendChild(tab); R.appendChild(card);
continue; continue;
} }
ok++; if(firstOk===null) firstOk=hl.id; ok++;
tab.innerHTML='<span class="tt">ID '+hl.id+" · "+fmtT(hl.start)+"~"+fmtT(hl.end)+"</span>"+ const cuts=hl.paste.cuts;
'<span class="ts"></span>'; let html='<div class="pickhead"><h3 style="margin:0;">ID '+hl.id+" · "+fmtT(hl.start)+"~"+fmtT(hl.end)+
tab.addEventListener("click",()=>{ // 제외된 탭은 클릭만으로도 되돌리기 "</h3><span style='color:var(--muted2);font-size:12.5px;'>"+esc(hl.reason||"")+"</span>"+
if(isDropped(hl.id)) setDropped(hl.id,false); else showId(hl.id); '<button type="button" class="ghost pickx" id="pickx-'+hl.id+'">✕ 제외</button></div>'+
}); '<div class="hlmeta">컷 '+cuts.length+"개 · 총 "+hl.total+"초 · 카드 "+hl.need+"장 필요</div>";
// ✕ 는 탭 버튼의 '형제' (button 중첩 불가) — 래퍼로 감싸 겹쳐 놓는다 let opts=[];
const wrap=document.createElement("div"); if(hl.editable_title){ // 구간 통짜: 제목 직접 입력(비우면 제목 없이)
wrap.className="idtabwrap"; html+='<div class="hlsec">제목: '+
const x=document.createElement("button"); '<input class="hlinput" id="hltop-'+hl.id+'" placeholder="윗줄 · 주황 (선택)"> '+
x.type="button";x.className="xdel";x.id="xdel-"+hl.id;x.textContent="✕"; '<input class="hlinput" id="hlmain-'+hl.id+'" placeholder="아랫줄 · 흰색 (선택)"></div>';
x.title="이 ID 제외 (생성 안 함)"; }else{
x.setAttribute("aria-label","ID "+hl.id+" 제외 — 생성하지 않음"); const t0={top:hl.paste.title_top,main:hl.paste.title_main,kind:"최종 선택"};
x.setAttribute("aria-pressed","false"); opts=[t0].concat((hl.titles||[]).filter(t=>t.top!==t0.top||t.main!==t0.main));
x.addEventListener("click",(e)=>{ html+='<div class="hlsec">제목: <select id="hlt-'+hl.id+'">'+
e.stopPropagation(); opts.map((t,i)=>'<option value="'+i+'">'+esc(t.top)+" / "+esc(t.main)+
setDropped(hl.id,!isDropped(hl.id)); (t.kind?" — "+esc(t.kind):"")+"</option>").join("")+"</select></div>";
}); }
wrap.appendChild(tab);wrap.appendChild(x); card.innerHTML=html;
tabs.appendChild(wrap); 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.warnings||[]).forEach(w=>prepLog("⚠️ "+w));
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";
R.appendChild(tabs);
let firstId=null;
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");
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)):"")+
'</span><span class="ts"></span>';
tab.addEventListener("click",()=>showId(hl.id));
tabs.appendChild(tab);
// 패널
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 가 없으면 기존 방식으로 폴백.
@ -660,49 +796,26 @@ function onResult(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);
} }
const cuts=hl.paste.cuts; box.innerHTML='<div class="hlmeta">카드 '+hl.need+"장 필요 · "+
let html="<h3>ID "+hl.id+" · "+fmtT(hl.start)+"~"+fmtT(hl.end)+ '<span id="hlcnt-'+hl.id+'" class="tnum"></span></div>'+
" <span style='color:var(--muted2);font-weight:400;'>"+esc(hl.reason||"")+"</span></h3>"+ '<div class="selbar" id="hlsel-'+hl.id+'"></div>';
'<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:hl.candidates, {cuts:hl.cuts,matched:hl.matched,candidates:ev.candidates,cutRanges:hl.cutRanges},
cutRanges:hl.paste.cuts},{unit:"컷"}); {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";
} }
}
/* ── 캡처 + 순차 빌드 ── */ /* ── 캡처 + 순차 빌드 ── */
async function captureCard(wrap){ async function captureCard(wrap){
@ -744,8 +857,8 @@ function boardSet(hlId,stat,msg,cls){
async function buildAll(){ async function buildAll(){
const btn=$("#autoBuild"); const btn=$("#autoBuild");
const hls=A.highlights.filter(h=>!h.error&&!isDropped(h.id)); // 제외한 ID는 안 만듦 const hls=(P&&P.highlights)||[]; // 준비(prepare)를 마친 ID만 — 실패·제외된 ID는 여기 없음
if(!hls.length){alert("만들 ID가 없습니다. 제외(✕)를 하나 이상 해제하세요.");return;} if(!hls.length){alert("만들 편집안이 없습니다.");return;}
btn.disabled=true; btn.disabled=true;
setFolded(true); // 빌드 시작 → 댓글 영역 접기(진행판에 집중) setFolded(true); // 빌드 시작 → 댓글 영역 접기(진행판에 집중)
boardInit(hls); boardInit(hls);
@ -763,23 +876,18 @@ async function buildAll(){
await nextFrame(); await nextFrame();
boardSet(hl.id,"🔄 진행","", "active"); boardSet(hl.id,"🔄 진행","", "active");
try{ try{
let pick; // 제목은 1차 검토("준비 시작" 클릭 시점)에 이미 잠가 뒀다 — 다운로드·받아쓰기가
if(hl.editable_title){ // 끝난 지금은 1차 카드의 select/input 이 그대로 있어도 참조하지 않는다.
pick={top:($("#hltop-"+hl.id)?$("#hltop-"+hl.id).value.trim():""), const pick=TITLE_PICKS[hl.id]||{top:"",main:""};
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("data",JSON.stringify(paste)); fd.append("aid",AUTO_AID);
fd.append("tag","하이라이트"+hl.id); fd.append("id",String(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]||{};
@ -853,13 +961,20 @@ 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 s=ytSec(row.querySelector(".rstart").value); const sv=(row.querySelector(".rstart").value||"").trim();
if(s==null) return; const sSec=ytSec(sv);
let e=ytSec(row.querySelector(".rend").value); if(sSec==null) return;
if(e==null) e=s+90; // 끝 비면 시작+1:30 (실행 로직과 동일) let ev2=(row.querySelector(".rend").value||"").trim();
if(e>s) out.push([s,e]); let eSec=ytSec(ev2);
if(eSec==null){ eSec=sSec+90; ev2=fmtT(eSec); } // 끝 비면 시작+1:30 (실행 로직과 동일)
if(eSec>sSec) out.push([sv,ev2]);
}); });
return out; return out;
} }
@ -889,7 +1004,9 @@ function ytFail(m){ytLog("⚠️ "+m);ytDone();}
(channel 유튜브에서 자동인식됐을 있음) 여기서 입력칸에 반영해 보여준다 (channel 유튜브에서 자동인식됐을 있음) 여기서 입력칸에 반영해 보여준다
사용자가 여기서 고치면 값이 생성(ytBuildAll) 그대로 실린다. */ 사용자가 여기서 고치면 값이 생성(ytBuildAll) 그대로 실린다. */
function onYtResult(ev){ function onYtResult(ev){
byIdx={};(ev.comments||[]).forEach(c=>{byIdx[c.idx]=c;}); byIdx={};sel={};selCut={};curId=null; // 검토는 한 번에 하나 — 다른 탭 선택 상태까지 전부 리셋
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;
@ -1047,7 +1164,9 @@ 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={};(ev.comments||[]).forEach(c=>{byIdx[c.idx]=c;}); byIdx={};sel={};selCut={};curId=null; // 검토는 한 번에 하나 — 다른 탭 선택 상태까지 전부 리셋
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"]={};
@ -1190,6 +1309,7 @@ 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,13 +157,17 @@
.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);}
/* 검토 화면 — 본문 600px 유지, 검토 영역만 와이드 브레이크아웃(댓글 카드 3~4열) */ /* 검토 화면 — 본문 600px 유지, 검토 영역만 와이드 브레이크아웃(댓글 카드 3~4열) */
#autoReview,#autoSummary,#ytReview,#ytSummary,#pasteReview,#pasteSummary{--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 +203,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;}
@ -478,6 +491,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;">
@ -598,8 +617,9 @@ 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
$("#rmsilenceField").style.display=m==="paste"?"none":"block"; // 가 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"));