chore: git 저장소 초기화 (기존 코드 스냅샷)

컷별 댓글 추천 작업을 태스크 단위로 되돌릴 수 있게 버전관리를 시작한다.
.gitignore 로 영상·캐시(.downloads 2.7G, .comments 72M, .media 28M)와
비밀키(.gemini_key)를 제외했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-08-04 11:36:05 +09:00
commit bf1b387d6d
52 changed files with 11603 additions and 0 deletions

17
.gitignore vendored Normal file
View File

@ -0,0 +1,17 @@
# 비밀키 — 절대 커밋 금지
.gemini_key
# 작업 산출물·캐시 (용량이 크고 재생성됨)
.downloads/
.comments/
.uploads/
.media/
.cache/
댓글카드/
# 파이썬
__pycache__/
*.pyc
# 서브에이전트 작업 공간 (superpowers SDD)
.superpowers/

330
ARCHITECTURE.md Normal file
View File

@ -0,0 +1,330 @@
# 캡컷 에이전트 · 구간합치기 (capcut2) — 시스템 명세
> 이 문서는 다른 Claude 세션(또는 개발자)이 이 프로젝트를 읽고 바로 협업할 수 있게 쓴 **전체 구현 명세**입니다.
> 사용자용 요약은 [README.md](README.md) 참고. 이 문서가 더 깊고 정확합니다.
## 0. 한 줄 요약
유튜브 영상(URL/파일/LLM 편집안 JSON) → 다운로드·컷·자막·배경 템플릿을 자동 조립해
**편집 가능한 CapCut 드래프트**(draft_content.json)를 로컬 CapCut 프로젝트 폴더에 생성하는
FastAPI + 정적 HTML 로컬 웹앱. 포트 **8001** (v1 `../capcut`은 8000, 별개 앱).
실행: `캡컷_에이전트_구간합치기.bat` → uvicorn `server.app:app` → http://127.0.0.1:8001
**코드 수정 후엔 반드시 .bat 재시작** (파이썬 코드가 서버에 물려 있음).
---
## 1. 폴더 구조
```
capcut2/
├─ 캡컷_에이전트_구간합치기.bat 런처(포트 8001, 브라우저 자동 오픈)
├─ 배경.png 배경 템플릿(검정-흰-검정 세로 1080×1920).
│ (레이아웃엔 더 이상 안 씀 — pipeline.py 상수로 통제)
├─ 댓글카드/ 댓글 카드 이미지 폴더(기본 경로, UI에 자동 주입)
├─ requirements.txt fastapi uvicorn python-multipart pyCapCut Pillow
│ pymediainfo yt-dlp faster-whisper
├─ .gemini_key (선택) Gemini API 키 — 파일/유튜브 탭 자막 교정용
├─ assets/ 파생물(frame_template.png 등) 자동 생성
├─ server/
│ ├─ app.py FastAPI. 엔드포인트 4개 + SSE 스트림
│ └─ static/index.html UI 전체(단일 파일, 탭 3개 + 옵션 + SSE 렌더)
└─ capcut_agent/
├─ pipeline.py ★ 두 파이프라인(process_bg_template / process_paste)
├─ draft.py ★ CapCut 드래프트 생성(pycapcut) + JSON 후처리
├─ youtube.py yt-dlp 다운로드(단일/다중/정밀) + ffmpeg 병합
├─ paste.py 붙여넣기 JSON 파서(관대한 파싱)
├─ silence.py ffmpeg silencedetect → 발화 구간
├─ transcribe.py faster-whisper(medium/int8/cpu) 단어 타임스탬프
├─ correct.py Gemini 자막 글자 교정(시간 불변) — gemini-2.5-flash
├─ highlight.py 자막 청킹(cut_plan) 유틸
├─ scene.py ffmpeg scene 필터 장면전환 감지·분할
├─ media.py 프레임 PNG 생성, 흰밴드 감지, 오디오 추출
└─ probe.py pymediainfo 영상 메타
```
## 2. 좌표계 (중요)
CapCut 인스펙터 값 ↔ pycapcut 변환:
- **위치 Y**: `CapCut Y = transform_y × 1920` (canvas 1080×1920 기준). 위가 +, 아래가 .
예) transform_y = 559/1920 = 0.2911 → 인스펙터에 Y 559로 표시.
- **확대(%)**: `scale_x = scale_y = 비율` (0.89 → 89%).
- **글자 크기**: pycapcut TextStyle.size ≈ CapCut 폰트 크기 1:1.
- 헬퍼 `_ty(y_px) = (960 - y_px) / 960` (draft.py) — 픽셀 y → transform_y.
## 3. 서버 (server/app.py)
| 엔드포인트 | 역할 |
|---|---|
| `GET /` | index.html 서빙. `__CDIR__` 토큰을 그 PC의 `capcut2/댓글카드` 절대경로로 치환 |
| `POST /upload` | 파일 탭. multipart 파일 + 옵션 → job 등록(content-hash id) |
| `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 폴백) |
- job 은 메모리 dict `JOBS[hash]`. hash = 입력 시그니처 sha1 12자.
- SSE 이벤트 형식: `{"type": "manifest"|"step"|"log"|"error"|"result", ...}`
- `manifest`: `{steps:[{id,label}]}` / `step`: `{id,status:"start"|"done",elapsed,detail}`
- `result`: `{draft_name, draft_path, stats:{duration,kept,cut,segments,captions,elapsed}}`
- 공통 폼 필드: `video_scale`(% 문자열, 기본 144), `flip`, `scene`, `bg_white`,
`comments_dir`(폴더 경로 문자열), `title_top/title_main/channel`(파일·유튜브만),
`remove_silence`(붙여넣기만). 불리언은 "1"/"0" 문자열 → `_truthy()`.
## 4. 탭별 파이프라인
### 4-A. 📁 파일 / ▶ 유튜브 구간 → `process_bg_template()` (pipeline.py)
단계: `[download] → silence → asr → [scene] → draft`
1. **download** (유튜브만): `cut_youtube_multi(url, ranges, out)`
구간별로 `yt-dlp --download-sections`(h264 우선) 다운로드 → 구간 2개 이상이면
ffmpeg concat demuxer로 **재인코딩 병합**(libx264 crf20 + aac). 채널명 자동 추출
→ 출처(channel) 미입력 시 `@채널명` 자동.
2. **silence**: `detect_speech_segments(noise_db=-28, min_silence=0.3, pad=0.04)`
ffmpeg silencedetect의 여집합 = 발화 구간 `keep=[(s,e)]`. 이게 곧 비디오 컷.
3. **asr**: **타이밍 = Whisper, 글자 = Gemini 제자리 교정** (핵심 설계):
- faster-whisper(medium/int8/cpu, `word_timestamps=True, vad_filter=False,
no_repeat_ngram_size=3`)로 단어 타임스탬프 → `cut_plan()`이 단어를
새(컷 압축) 타임라인으로 매핑 후 짧은 자막으로 청킹.
- **청킹 = DP 줄바꿈 최적화**(`_chunk_words`). 0.6s 이상 쉬는 곳으로 덩어리를 나눈 뒤,
덩어리 안에서 `Σ(줄길이10)² + 끊는 자리 벌점`이 최소가 되는 줄바꿈을 고른다.
벌점: 다음 어절이 의존명사·보조용언(`_is_bound`: "수/것/번도/들어…") +60,
앞 어절이 관형형·관형사(`_is_adnominal`/`_DETERMINERS`: "쓸/당황하는/한") +50,
문장부호·어미로 끝나면 보너스. 하드캡 14자(`fit_caption_size` 한 줄 폭 한계)/2.8s.
⚠ 앞에서부터 12자 차면 무조건 끊던 greedy 방식은 "예를 / 들어", "쓸 / 수 있잖아요"
처럼 한 덩어리를 갈랐다(실측 자막 경계의 8.5% → DP 적용 후 0.7%).
★ DP는 **묶는 방법만** 고른다 — 단어 타임스탬프는 손대지 않으므로 싱크 불변.
- `.gemini_key` 있으면 `correct_captions()`**글자만** 1:1 교정(줄 수·순서·시간
절대 불변 → 싱크 유지). 실패 시 Whisper 원문 유지.
- ⚠ 과거 시도: Gemini 오디오 전사 타임스탬프 직접 사용 → 드리프트로 자막 밀림.
글자수 기반 정렬도 누적 드리프트. **절대 되돌리지 말 것.**
- ASR은 `.cache/`에 content-hash 캐시. numba 동시 호출 segfault → `ASR_LOCK`.
4. **scene** (옵션): `detect_scene_changes(threshold=0.4)``split_clips_at_scenes()`
보존 구간을 장면전환 지점에서 **인접 분할**(누적 길이 불변 → 자막 싱크 무영향).
5. **draft**: §5 빌더 호출. 좌표는 전부 `_template_pos()`가 레이아웃 상수에서 파생(§9).
### 4-B. 📋 붙여넣기 (기본 탭) → `process_paste()` (pipeline.py)
LLM이 만든 편집안 JSON을 **그대로** 사용. 무음컷·ASR **기본 없음**(옵션으로 무음 제거 가능).
단계: `download(컷 정밀) → [remove_silence] → [scene] → draft`
1. **파싱** (`paste.parse_paste`): 관대한 JSON 파싱 —
`json.loads(strict=False)`(자막 안 실제 줄바꿈 허용), 코드펜스(```) 자동 제거,
시간은 `parse_time()`: `"분:초.밀리"`/`"시:분:초.밀리"`/초 단독, 콤마 밀리초도 허용.
검증 실패 시 한국어 메시지로 400.
2. **download**: `download_paste_cuts()` — 컷마다 `cut_youtube_precise()`:
`--download-sections "*HH:MM:SS.mmm-…" --force-keyframes-at-cuts`(프레임 정확).
**실패 시 keyframe 컷으로 자동 폴백**(일부 영상에서 ffmpeg 크래시 방어).
전부 받은 뒤 concat 재인코딩 병합. 제목/채널 자동 추출.
3. **자막 배치**: 배치 시간은 **컷 순서 누적 자동 계산** (LLM이 배치시간 계산하면
오타 나므로 입력받지 않음). `bottom` → 하단 자막, `effect` → 중앙 효과자막.
4. **remove_silence** (옵션, 기본 꺼짐): 병합본에서 무음 감지 → 컷 추가 압축,
자막을 `_remap_caps()`로 압축 타임라인에 재매핑(무음에만 걸친 자막은 버림).
5. **asr_bottom** (옵션, 기본 켜짐): 병합본을 Whisper로 받아써 **JSON bottom 을 대체**하는
하단 자막 생성(실제 발화 타이밍). 구현 핵심: Whisper 타임스탬프는 '병합 파일' 기준이므로
`cut_plan(video_clips, tr)` 로 매핑+청킹 — 무음제거 켜면 video_clips=keep(압축 매핑),
끄면 [(0,dur)](항등). `.gemini_key` 있으면 글자만 1:1 교정(시간 불변). ASR 결과가 비면
JSON bottom 폴백. effect/제목/채널은 JSON 유지.
⚠ "remove_silence 후에 돌리면 remap 불필요"는 틀림 — 파일은 압축 안 되므로 cut_plan 매핑 필수.
6. **scene / 댓글카드 / draft**: 4-A와 동일(좌표도 공통 — §9).
#### 붙여넣기 JSON 스키마 (LLM에게 시킬 형식)
```json
{
"url": "https://www.youtube.com/watch?v=실제영상ID",
"title_top": "서브제목(주황)", "title_main": "메인제목(흰색)", "channel": "@채널",
"cuts": [
{"start":"16:07.500","end":"16:12.500",
"bottom":"하단 자막 윗줄\n하단 자막 아랫줄","effect":"(효과자막)"}
]
}
```
- `url`: **반드시 실제 영상**. LLM이 지어낸 가짜 ID(oembed 404)로 실패한 전례 있음.
- `start/end`: 원본 영상 기준. 배치 시간·SRT 타임코드는 **받지 않는다**.
- `bottom``\n`: **같은 위치에서 시간을 줄 수만큼 균등 분할**해 순서대로 표시
(윗줄 → 아랫줄). 위아래 스택이 아님. LLM이 `\\n`(이중 이스케이프)으로 줘도,
글자 그대로 `\n`/`\r`이 와도 draft.py `_cap_lines()`가 전부 줄바꿈으로 처리.
- `title_*`/`channel`/`bottom`/`effect` 전부 선택(빈 값이면 생략).
#### LLM 지시문 템플릿
> 아래 스키마의 JSON 하나로만 출력해. 설명·마크다운 금지.
> url은 내가 준 이 주소 그대로(임의 생성 금지). start/end는 원본 타임스탬프(분:초.밀리).
> 배치 시간은 계산하지 마라(앱이 순서대로 이어붙임). bottom은 2줄(\n), effect는 짧게.
> 모든 컷 start < end.
## 5. 드래프트 빌더 (draft.py `build_bg_template_draft`)
캔버스 1080×1920. 트랙 구성(아래→위 렌더 순):
```
[bg] (배경 흰색일 때만) 흰 단색 1080×1920 PNG, 전체 길이
main 영상. 보존 구간들을 이어붙임(점프컷). scale=video_scale, flip, video_y=영상 창 중앙
frame make_frame() 생성 — 상하 띠 불투명(검정 or 흰), 가운데 투명(영상 비침). 좌표는 §9 상수
[comment] 댓글 카드 이미지들 — scale 0.89 고정, X 0, 윗변=영상 바로 아래, 카드당 3초
caption 하단 자막(텍스트)
title_top 서브제목 / title_main 메인제목 / channel 출처 / effect 효과자막
```
핵심 구현 포인트:
- **소재 길이 클램프**: ffprobe duration이 CapCut 소재 길이보다 수십 ms 길 수 있음
→ 컷 끝을 `material.duration`으로 클램프(SegmentOverlap/초과 오류 방어).
- **자막 스타일**: **주황 `#ff8000`**(`CAPTION_COLOR`) + 볼드 + **그림자**, **배경박스 없음**,
크기 **`CAPTION_SIZE = 12.0` 고정**(캡컷 폰트 크기 1:1).
배경은 `TextSegment(background=...)` 인자를 **생략**해서 끈다 → `background_style` 키 자체가
안 나가고 CapCut 이 읽으면서 0(없음)으로 채운다(제목 텍스트가 원래 이 방식).
그림자는 저장 후 `_apply_shadow_to_track(draft_dir, "caption", _TEXT_SHADOW)` 로 주입.
예전엔 최장 줄 기준 `fit_caption_size()`로 7~13 자동이었으나 드래프트마다 크기가
달라져 고정으로 바꿈. 청킹 하드캡 14자 × 크기10(≈51.5px) ≈ 721px < 1080 넘침.
`fit_caption_size()`는 남겨둠(미사용, 자동 맞춤으로 되돌릴 때 사용).
- **`\n` 시간분할**: n줄이면 구간을 n등분해 각 줄을 같은 위치(caption_y)에 순차 표시.
- **제목**: title_top 주황 `(1.0,0.62,0.05)` size14 bold 검은외곽선18 /
title_main 흰색 size18 bold. 위치는 §9. 전체 길이 표시.
- **효과자막**: 녹색 `#0dff63` size13 **bold 아님** 검은외곽선18 (붙여넣기 전용).
- **배경 흰색 모드**(`bg_white=True`): 흰 띠 프레임 + 흰 배경 레이어. 추가로
channel 글자 검정, title_main 외곽선 두께 50, title_top에 **그림자 JSON 주입**
(`_apply_shadow_to_text`, CapCut 실측값: alpha .9, diffuse .025, distance 5, angle 45).
- ⚠ **그림자는 두 군데를 같이 넣어야 켜진다**: `styles[].shadows`(`_TEXT_SHADOW`) **와**
소재 최상위 플래그(`_SHADOW_MATERIAL`: `has_shadow=true`, `shadow_color`, `shadow_alpha`,
`shadow_angle`, `shadow_distance`, `shadow_point`, `shadow_smoothing`).
`shadows` 만 넣으면 JSON 엔 있는데 `has_shadow=false`**화면엔 안 나온다**
기존 title_top 그림자가 딱 이 상태였고(실측 확인) 같이 고쳤다. 값은 CapCut UI 로 켠
자막에서 실측한 것.
- **폰트**: 코트라 볼드체 — pycapcut FontType에 없어 **저장 후 draft_content.json의
모든 텍스트 styles[].font에 직접 주입**(`_apply_font_to_texts`). 경로는
`%LOCALAPPDATA%/CapCut/User Data/Cache/effect/7480846567709265157/...`(PC 무관).
캐시 없으면(그 PC CapCut에서 폰트 미사용) 주입 생략 → 기본 폰트로 안전 동작.
- **트랙 자동 잠금**(`_lock_tracks`, 저장 직후): `frame`·`comment`·`title_top`·
`title_main`·`channel` 트랙을 잠근다(`attribute |= 4`; mute 비트1은 OR로 보존).
`main`(영상)·`caption`(자막)·`effect`는 편집용이라 **안 잠금**. `bg`(흰 배경)도
**안 잠금** — 맨 아래 레이어라 꼬여도 화면에 영향이 없고, 영상 길이를 늘릴 때 같이
늘려야 해서 잠겨 있으면 불편하다(사용자 요청으로 제외). **파일/유튜브/붙여넣기
전 탭 공통** — 세 탭 모두 이 빌더(`build_bg_template_draft`)를 쓰므로 자동 적용.
- ⚠ **왜 잠그나(중요)**: CapCut에서 재생헤드 전체 분할 등으로 오버레이 트랙이 쪼개지면
CapCut이 세그먼트 `render_index`를 다시 매기다 **일부 main 영상 세그먼트를 frame/comment
위로 올려버려**(예: main ri=4 > frame ri=2) 그 클립이 흰 띠·댓글을 덮고 삐져나오는 버그가
있었다. 오버레이/제목 트랙을 미리 잠그면 분할·재배치가 막혀 레이어가 안 꼬인다.
- `render_index`**시간 무관 전역 쌓임 순서**(클수록 위). 정상 생성물은 bg=0<main=1<
frame=2<comment=3 으로 일관. 이미 꼬인 드래프트는 트랙 세그먼트 render_index를
트랙별 단일값으로 재통일하면 복구된다(`repair_layers()`).
- 잠금 인코딩은 CapCut 실측 확인값(트랙 `attribute` 비트4=잠금, 비트0=mute).
- 저장 위치: `%LOCALAPPDATA%/CapCut/User Data/Projects/com.lveditor.draft/<드래프트명>/`
- ⚠ **draft_content.json**이 정답 파일(draft_info.json 아님). 같은 트랙에 같은
시간대 세그먼트 2개 넣으면 `SegmentOverlap` 에러.
## 6. 댓글 카드 시스템
- UI "댓글 카드 폴더" 칸(기본값 = `capcut2/댓글카드`, 서버가 실제 경로 주입).
**비우면 카드 없음.**
- `_load_comment_cards(folder, dur, interval=3.0)`:
- 모든 파일명이 숫자로 시작 → 숫자순(1,2,10). 아니면 → **파일 생성시각(저장 순서)**.
- png/jpg/jpeg/webp. 카드당 3초, 영상 길이 초과분은 생략.
- 렌더: comment 트랙에 **scale 0.89 / X 0**, 세로는 **윗변이 영상 바로 아래**에 오도록 카드마다 계산(§9).
- 출처: 사용자가 h-lab(https://h-lab.tolag.shop/comment-cards)에서 실제 유튜브 댓글을
카드 PNG로 저장해 폴더에 넣음. (향후: h-lab API 연동해 완전 자동화 아이디어 있음)
## 7. yt-dlp 관련 (youtube.py) — 함정 모음
- **JS 런타임 필수**: 최신 유튜브는 JS 챌린지 필요. `_js_runtime_args()`
deno→node→bun 순으로 자동 감지해 `--js-runtimes` 지정. 없으면 포맷 누락 →
다운로드된 스트림으로 ffmpeg가 크래시(exit 3436169992)했던 전례.
- 포맷: `bv*[vcodec^=avc1]+ba[acodec^=mp4a]/…/b` (h264+aac 우선, CapCut 호환).
받은 게 h264 아니면 ffmpeg 재인코딩.
- 한글 경로: `PYTHONIOENCODING=utf-8` env + stdout 파싱 대신 **glob으로 결과 파일 탐색**
(Windows cp949 디코드 깨짐 방어). 모든 subprocess는 `encoding="utf-8", errors="replace"`.
- 정밀 컷: `--force-keyframes-at-cuts` 1차 → 실패 시 keyframe 컷 폴백.
- ⚠ **초록 화면(GOP 중간 컷) — 반드시 검증할 것**: 위 폴백이 걸리면 yt-dlp가
키프레임이 아닌 위치에서 스트림 복사로 잘라, **첫 키프레임 전까지 참조 프레임이 없는**
파일이 나온다(실측: 9컷 중 1개, 첫 키프레임 2.27s). 그대로 concat 재인코딩하면
그 구간이 통째로 **초록 화면**으로 구워진다.
- **탐지 함정 3종**: ① 파트를 단독 재생하면 ffmpeg가 깨진 앞부분을 건너뛰어 멀쩡해 보인다.
`ffprobe -read_intervals`는 키프레임으로 **시크**해버려 첫 프레임을 놓친다.
`ffmpeg -v error`로 디코딩해도 **에러가 안 난다**(h264 은닉 처리). 픽셀로만 보인다.
- **유일한 확실한 판정**: 시크 없이 앞에서부터 프레임을 훑어 **첫 키프레임 시각**을 본다
`_first_keyframe_sec()`. 0이 아니면 그만큼 앞이 깨진 것.
- **대응**(`cut_youtube` / `cut_youtube_precise` 공통): 검증 실패 → 재다운로드
(`DL_ATTEMPTS=2`, 간헐적이라 보통 여기서 해결) → 그래도 깨지면 앞에 `RECUT_LEAD=6`
여유를 붙여 받아 **로컬에서 뒤쪽 want초만 재인코딩**해 잘라낸다(깨진 앞부분은 버리는
여유 구간에 들어가므로 항상 깨끗). 수리 내역은 `REPAIR_LOG` → SSE 로그(🩹)로 노출.
- ⚠ Windows: `_first_keyframe_sec`의 ffprobe 파이프를 안 닫으면 파일이 잠겨
바로 뒤 `_unlink`가 PermissionError로 실패한다 → `stdout.close()` 후 kill/wait.
- "Video unavailable" 디버깅: `curl "https://www.youtube.com/oembed?url=...&format=json"`
이 404면 yt-dlp 문제가 아니라 **영상 자체가 없는 것**(LLM이 지어낸 ID 등).
## 8. UI (index.html) 요약
- 탭 3개: 파일 / 유튜브 구간 / **붙여넣기(기본 활성)**. 붙여넣기 탭에선 제목 입력칸
숨김(JSON에 있으므로), 옵션들은 노출.
- 유튜브 탭: " 구간 추가"로 구간 여러 개(각 행 시작/끝, ✕ 삭제). 시간 자동 포맷
(4314→43:14), 끝 비우면 시작+90초.
- 옵션(공통): 영상 확대 슬라이더(기본 **144%**) / 댓글 카드 폴더 / 좌우반전 /
장면분할(**기본 체크**) / 배경 흰색(**기본 체크**) / 무음 제거(붙여넣기용, 기본 꺼짐).
- 헤더 우측 고정 링크: ✨ AI Studio(aistudio.google.com), 💬 댓글 카드(h-lab).
- 완료 시 결과 카드(총 소요시간 포함) + "완료되면 CapCut 자동 실행" 체크.
## 9. 현재 고정값 치트시트
**레이아웃은 `pipeline.py` 상단 상수 한 곳에서 파생된다** — 여기만 고치면 전부 따라 움직인다.
(예전엔 `배경.png` 흰밴드 자동감지였으나 좌표를 정확히 통제하려고 상수로 바꿨다.
`배경.png`는 더 이상 레이아웃에 관여하지 않는다.)
```python
VIDEO_TOP = 323 VIDEO_BOTTOM = 1122 # 영상 창 (흰 띠 사이)
TITLE_TOP_Y = 109 TITLE_MAIN_Y = 252 # 제목 두 줄 중앙
CAPTION_GAP = 72 EFFECT_GAP = 25 # 영상 창 안쪽 아래/위 여백
COMMENT_TOP = VIDEO_BOTTOM # 댓글 카드 윗변 = 영상 바로 아래
CHANNEL_RATIO = 0.85 # 아래 띠에서 85% 지점
```
| 항목 | 캔버스 y(px) | transform_y | CapCut 표시 |
|---|---|---|---|
| 영상 창(투명 구간) | 323 ~ 1122 | — | — |
| 영상(main) 중앙 | 722.5 | 0.2474 | Y 475 |
| title_top (주황 size14) | 109 | 0.8865 | Y 1702 |
| title_main (흰색 size18) | 252 | 0.7375 | Y 1416 |
| 효과자막 (#0dff63 size13, bold X) | 348 | 0.6375 | Y 1224 |
| 하단 자막 (size **12**, **#ff8000**, 그림자, 배경 없음) | 1050 | 0.0938 | Y 180 |
| 댓글 카드 (scale 0.89, X 0, 3초/장) | 윗변 1122 | 카드마다 계산 | — |
| channel (size 10) | 1800 | 0.8753 | Y 1681 |
| 영상 확대 기본 | — | — | 144% |
- **댓글 카드 세로 위치는 카드마다 다르게 계산**된다 — 카드 이미지 높이가 제각각이라
중앙값 하나로는 "영상 바로 아래"에 못 붙인다. `표시높이 = 1080 × (h/w) × 0.89`,
`중앙 = COMMENT_TOP + 표시높이/2`. (`comment_top` 인자, 실측: 422px/590px 카드 모두 윗변 1122)
## 10. 알려진 제약 / 하지 말 것
- **코드 수정 후 .bat 재시작 필수** — 안 하면 옛 코드가 계속 돎(가장 흔한 "안 돼요" 원인).
- Gemini 오디오 전사의 타임스탬프를 자막 타이밍으로 쓰지 말 것(드리프트). 타이밍은
Whisper 단어 타임스탬프만.
- 같은 텍스트 트랙에 동시간 세그먼트 2개 금지(SegmentOverlap).
- 오버레이(frame/comment)·제목 트랙은 생성 시 자동 잠금(§5). **이 잠금을 빼지 말 것**
CapCut 편집 중 render_index 재배치로 영상이 프레임 위로 삐지는 버그의 예방책. 편집하다
제목을 고쳐야 하면 그 트랙 자물쇠만 잠깐 푼다. (`bg`는 잠그지 않는다 — 맨 아래라 무해)
- ⚠ **잠금으로도 못 막는 경우 — `main` 트랙(미해결)**: main 은 편집용이라 잠글 수 없고,
CapCut 은 **새로 만든 비디오 세그먼트(복붙·분할 후 이동)**`max(비디오 render_index)+1`
을 새로 찍는다(실측 4건: 최대 3 → **4**, 최대 2 → **3**). 그 클립만 frame·comment 위로
올라가 확대 시 흰 띠·댓글 위로 삐진다.
- **생성 시점 예방은 미해결**. frame 을 비디오 대역 밖(14500)으로 올리는 방식을 시도했다가
**원복**했다(사용자 요청). 근거·재시도 조건은 `레이어_삐짐_수리.md` 참고.
- 현재 수단은 **사후 수리**: `draft.repair_layers(draft_dir)` — 트랙별 정상 render_index
(bg0/main1/frame2/comment3)로 되돌리고 재잠금, `draft_content.repair.bak` 백업.
UI 하단 **"🩹 레이어 수리"** / `GET /drafts`(꼬임 감지) · `POST /repair`.
- ⚠ `POST /repair`**CapCut 실행 중이면 409 로 거부** — 열어둔 채 수리하면 CapCut 이
메모리 상태로 덮어써 되돌아간다(실측: 11:04:32 수리 → 11:05:39 CapCut 저장으로 원복).
- `JOBS`는 메모리 저장 — 서버 재시작하면 job 소실(스트림 전에 재시작하면 재제출 필요).
- 검증은 최종적으로 **사용자가 CapCut에서 열어 확인**하는 방식.
- v1(`../capcut`, 포트 8000)은 별개 코드베이스 — 여기 수정해도 v1에 반영 안 됨(역도 동일).
## 11. 협업 시 참고
- 파이썬 검증 습관: `python -c "import ast; ast.parse(open(f,encoding='utf-8').read())"`
`from server import app` 임포트 확인 → 가능하면 실제 드래프트 빌드 후
draft_content.json을 열어 값 검증(테스트 드래프트는 빌드 후 삭제).
- Windows 콘솔은 cp949라 한글/특수문자 print가 깨져 보일 수 있음(로직과 무관).
- 사용자의 CapCut 좌표 요청("위치 559로") = transform_y로 환산해 반영하면 됨(§2).
- 테스트용 영상이 필요하면 `.downloads/`의 기존 mp4를 재사용(네트워크 불필요).

84
CLAUDE.md Normal file
View File

@ -0,0 +1,84 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 이 프로젝트가 하는 일
유튜브 영상(URL/파일/LLM 편집안 JSON) → 다운로드·컷·자막·배경 템플릿을 자동 조립해
**편집 가능한 CapCut 드래프트**(`draft_content.json`)를 로컬 CapCut 프로젝트 폴더에 생성하는
FastAPI + 정적 HTML 로컬 웹앱. 포트 **8001** (형제 앱 v1 `../capcut`은 8000, 완전히 별개 코드베이스).
## 먼저 읽어야 할 문서
이 저장소에는 이미 깊은 명세 문서가 있다. 코드를 건드리기 전에 반드시 참고:
- **`ARCHITECTURE.md`** — 전체 구현 명세(좌표계, 파이프라인 단계별 동작, 드래프트 빌더 내부,
yt-dlp 함정, 고정값 치트시트, "하지 말 것" 목록). **가장 정확하고 깊은 문서.**
- **`README.md`** — 사용자용 요약, 붙여넣기 JSON 스키마, 다른 PC 설치법, 문제 해결표.
CLAUDE.md는 위 두 문서와 중복하지 않는다. 세부는 그쪽을 볼 것.
## 실행 / 개발 명령
```bash
# 서버 실행 (Windows 런처 — 브라우저 자동 오픈, 포트 8001)
캡컷_에이전트_구간합치기.bat
# 런처 없이 직접 실행
python -m uvicorn server.app:app --port 8001
# 의존성 설치
python -m pip install -r requirements.txt
# 파이썬 구문 검증 (수정 후 습관)
python -c "import ast; ast.parse(open('capcut_agent/pipeline.py', encoding='utf-8').read())"
python -c "from server import app" # 임포트 체인 확인
```
시스템 의존성(pip 아님, PATH 필요): **ffmpeg/ffprobe**, **Node.js 또는 deno**(yt-dlp JS 런타임),
**CapCut**(드래프트 열기 + 코트라 볼드체 폰트 캐시).
### ⚠️ 가장 중요한 규칙: 코드 수정 후 .bat 재시작 필수
파이썬 코드가 uvicorn 서버에 물려 있어 **hot-reload 안 됨**. 코드를 고쳤으면 반드시
검은 창을 닫고 `.bat`을 다시 실행해야 반영된다. 이것을 잊는 게 "안 돼요"의 가장 흔한 원인.
## 아키텍처 큰 그림
요청은 세 탭(파일 / 유튜브 구간 / 붙여넣기)에서 들어와 두 파이프라인 중 하나로 갈린다:
- **`server/app.py`** — FastAPI. 엔드포인트 4개(`/upload` `/youtube` `/paste` `/open-capcut`) +
SSE 스트림(`/stream/{job_id}`). job은 메모리 dict `JOBS[hash]`에 저장(서버 재시작 시 소실).
폼 필드 → 파이프라인 인자 변환 담당.
- **`server/static/index.html`** — UI 전체(단일 파일, 탭 3개 + 옵션 + SSE 렌더).
- **`capcut_agent/pipeline.py`** — ★ 진입점 두 개:
- `process_bg_template()` — 파일/유튜브 탭. `download → silence → asr → [scene] → draft`
- `process_paste()` — 붙여넣기 탭. `download(정밀 컷) → [remove_silence] → [asr_bottom] → [scene] → draft`
- **`capcut_agent/draft.py`** — ★ `build_bg_template_draft`. pycapcut으로 드래프트 생성 후
`draft_content.json`을 직접 후처리(폰트·그림자 주입 등).
- 나머지 `capcut_agent/*.py` — youtube(다운로드/병합), paste(관대한 JSON 파서), silence,
transcribe(faster-whisper), correct(Gemini 교정), scene, media, probe. 역할은 ARCHITECTURE.md §1.
### 절대 되돌리면 안 되는 핵심 설계 결정
- **자막 타이밍 = Whisper 단어 타임스탬프, 글자만 = Gemini 제자리 교정(시간 불변).**
과거에 Gemini 오디오 전사 타임스탬프를 직접 쓰거나 글자수 기반 정렬을 시도했다가
누적 드리프트로 자막이 밀렸다. Gemini는 글자만 1:1 교정, 줄 수·순서·시간은 절대 건드리지 않는다.
- **붙여넣기 탭은 배치 시간을 입력받지 않는다** — 컷 순서대로 누적 자동 계산(LLM이 계산하면 오타).
- **같은 텍스트 트랙에 동시간 세그먼트 2개 금지** — CapCut `SegmentOverlap` 에러.
- 정답 파일은 **`draft_content.json`** (draft_info.json 아님).
### 좌표계 (draft.py 작업 시)
`CapCut Y = transform_y × 1920` (canvas 1080×1920, 위가 +/아래가 ). 헬퍼 `_ty(y_px) = (960 - y_px)/960`.
확대(%) = scale 비율. 사용자가 "위치 559로" 라고 하면 transform_y로 환산해 반영. 고정값은 ARCHITECTURE.md §9 치트시트.
## 검증 방식
이 프로젝트에 자동 테스트 스위트는 없다. 검증은:
1. 구문 파싱 + 임포트 체인 확인(위 명령).
2. 가능하면 실제 드래프트를 빌드해 `draft_content.json` 값을 열어 확인(테스트 드래프트는 확인 후 삭제).
3. 최종적으로 **사용자가 CapCut에서 열어 확인**.
테스트용 영상이 필요하면 `.downloads/`의 기존 mp4 재사용(네트워크 불필요).
Windows 콘솔은 cp949라 한글/특수문자 print가 깨져 보일 수 있음(로직과 무관).

139
README.md Normal file
View File

@ -0,0 +1,139 @@
# 캡컷 에이전트 · 구간합치기 (v2)
유튜브 영상 → **무음컷 · 자막 · 배경 템플릿**이 박힌 **편집 가능한 CapCut 드래프트**를 자동 생성하는 로컬 웹앱.
특히 **한 URL의 여러 구간을 이어붙이고**, LLM이 만든 편집안(컷+자막)을 **JSON 붙여넣기**로 한 번에 처리합니다.
---
## 1. 처음 실행 (이 PC)
1. `캡컷_에이전트_구간합치기.bat` 더블클릭
2. 잠시 후 브라우저가 http://127.0.0.1:8001 로 자동으로 열림
3. **이 검은 창은 켜두세요** (닫으면 서버가 꺼짐)
> 코드를 수정했으면 **반드시 창을 닫고 .bat 을 다시 실행**해야 반영됩니다.
---
## 2. 세 가지 입력 방법 (탭)
### 📋 붙여넣기 (기본 탭) — 추천
LLM이 만든 편집안 JSON을 붙여넣으면 컷·자막을 **그대로** 사용합니다. (무음컷·받아쓰기 없음)
### ▶ 유튜브 구간
한 URL + 여러 구간( 구간 추가) → 이어붙여 **무음컷 + 자동 자막(Whisper)**.
### 📁 파일
로컬 영상 파일 → **무음컷 + 자동 자막**.
세 방법 모두 아래 **영상 옵션**을 함께 적용합니다.
---
## 3. 붙여넣기 JSON 형식 (핵심)
아래 **JSON 하나**만 붙여넣으면 됩니다. 배치 시간·SRT 타임코드는 **넣지 마세요** — 앱이 컷을 순서대로 이어붙이며 자동 계산합니다.
```json
{
"url": "https://www.youtube.com/watch?v=영상ID",
"title_top": "서브제목",
"title_main": "메인제목",
"channel": "@채널명",
"cuts": [
{"start":"0:01.0","end":"0:03.5","bottom":"하단 자막 윗줄\n하단 자막 아랫줄","effect":"효과자막"},
{"start":"2:33.5","end":"2:36.5","bottom":"다음 컷 자막","effect":"공포의통계"}
]
}
```
| 필드 | 설명 |
|------|------|
| `url` | **실제 영상 주소** (브라우저에서 열리는 것). LLM이 지어낸 가짜 ID 금지 |
| `cuts[].start` / `end` | 원본 영상 타임스탬프. `분:초.밀리` 또는 `시:분:초.밀리` (밀리초 생략 가능) |
| `cuts[].bottom` | 하단 자막. `\n` 이 있으면 **같은 자리에서 시간을 반씩 나눠** 윗줄→아랫줄 순서로 표시 |
| `cuts[].effect` | 중앙 효과 자막(녹색). 짧게 |
| `title_top` / `title_main` / `channel` | 선택. 비우면 안 들어감(채널은 유튜브에서 자동) |
### LLM 에게 요청할 때 (이대로 복사)
> 아래 스키마의 **JSON 하나로만** 출력해. 설명·마크다운 금지.
> - `url` 은 내가 준 이 주소를 **그대로** 써라(임의 생성 금지): `여기에_실제_URL`
> - `start`/`end` 는 원본 영상 타임스탬프(`분:초.밀리`).
> - **배치 시간은 계산하지 마라.** 앱이 순서대로 이어붙인다.
> - `bottom` 은 2줄(`\n`), `effect` 는 짧은 한 마디.
> - 모든 컷은 `start < end`.
> 💡 자막이 안 쪼개지고 `\n` 글자가 그대로 보여도 앱이 알아서 처리합니다(`\n`·`\\n` 모두 인식).
---
## 4. 영상 옵션 (공통)
- **영상 확대** — 기본 144%. 슬라이더로 조절(캡컷에서 다시 조정 가능)
- **좌우반전(미러)** — 영상만 좌우 뒤집기
- **장면분할** — 화면이 확 바뀌는 지점마다 컷 자동 분할(캡컷에서 개별 편집 가능). 시간이 더 걸림
- **완료되면 CapCut 자동 실행** — 체크 시 결과 후 캡컷 자동 오픈
자막 위치·색은 코드 기본값(하단자막 CapCut Y=-559, 효과자막 Y=866·녹색 `#0dff63`).
---
## 5. 다른 PC 에서 실행
`capcut2` 폴더를 통째로 복사한 뒤(`.downloads`·`.cache`·`.uploads` 캐시는 빼도 됨 — 자동 생성):
1. **Python 3.10+** 설치 — https://python.org (설치 시 **Add to PATH** 체크)
2. 이 폴더에서 패키지 설치:
```
python -m pip install -r requirements.txt
```
3. **ffmpeg / ffprobe** 설치(PATH 필요) — `winget install Gyan.FFmpeg`
4. **Node.js** 설치 — https://nodejs.org (yt-dlp 유튜브 추출용 JS 런타임)
5. **CapCut** 설치
6. `캡컷_에이전트_구간합치기.bat` 실행
> **코트라 볼드체**: 새 PC의 CapCut 에서 그 폰트를 한 번 사용하면 캐시가 생겨 자동 적용됩니다. 없으면 기본 폰트로 나옵니다(에러 아님).
> **드래프트 저장 위치**: 그 PC의 CapCut 프로젝트 폴더를 자동 인식합니다.
> **.gemini_key**: '파일'·'유튜브 구간' 탭 자막 교정용. 붙여넣기 탭만 쓰면 없어도 됩니다.
---
## 6. 문제 해결
| 증상 | 해결 |
|------|------|
| 수정한 게 반영 안 됨 | 검은 창 닫고 **.bat 재시작** |
| `python not found` | Python 재설치 시 PATH 체크 후 재부팅 |
| 유튜브 `Video unavailable` | 영상이 실제 공개인지 확인 → `python -m pip install -U yt-dlp` |
| 다운로드가 자꾸 깨짐 | `python -m pip install -U yt-dlp` (유튜브가 가끔 바뀜) |
| 자막이 밀림 | 붙여넣기 탭은 컷·자막을 그대로 쓰므로 안 밀림. 파일/유튜브 탭은 Whisper 타이밍 사용 |
| 자막 `\n` 이 글자로 박힘 | .bat 재시작하면 해결(앱이 `\n`·`\\n` 자동 분할) |
| 폰트가 다르게 나옴 | CapCut 에서 코트라 볼드체 한 번 사용해 캐시 생성 |
---
## 7. 폴더 구조 (참고)
```
capcut2/
├─ 캡컷_에이전트_구간합치기.bat 실행 파일(포트 8001)
├─ 배경.png 배경 템플릿(검정-흰-검정)
├─ requirements.txt 파이썬 패키지 목록
├─ .gemini_key (선택) Gemini 키
├─ server/
│ ├─ app.py FastAPI 서버 (/upload /youtube /paste /stream)
│ └─ static/index.html 웹 UI
└─ capcut_agent/
├─ pipeline.py 처리 파이프라인(다운로드→컷→자막→드래프트)
├─ youtube.py 유튜브 구간/다중/정밀 다운로드·병합
├─ paste.py 붙여넣기 JSON 파서
├─ draft.py CapCut 드래프트 생성(pycapcut)
├─ scene.py 장면전환 감지·분할
├─ silence.py 무음 감지
├─ transcribe.py Whisper 받아쓰기(파일/유튜브 탭)
├─ correct.py Gemini 자막 교정(선택)
├─ highlight.py 자막 청킹
├─ media.py 프레임/오디오 처리
└─ probe.py 영상 메타 조회
```

316
SETUP.md Normal file
View File

@ -0,0 +1,316 @@
# SETUP.md — 다른 PC 설치 · 환경 · 버전 명세
> 이 프로젝트(캡컷 에이전트 · 구간합치기 v2)를 **다른 Windows PC에서 그대로 돌리기 위한** 전체 스펙·버전 문서.
> 구현 세부는 [ARCHITECTURE.md](ARCHITECTURE.md), 사용법은 [README.md](README.md) 참고.
> 아래 버전들은 **현재 작동 중인 PC에서 실측한 값**(2026-07 기준)이라, 이 조합이면 확실히 돕니다.
---
## 0. 30초 요약 체크리스트
다른 PC에서 이 6개만 맞추면 됩니다:
- [ ] **Windows 10/11** (64-bit)
- [ ] **Python 3.13** 설치 + PATH 등록
- [ ] **ffmpeg / ffprobe** PATH 등록 (pip 아님)
- [ ] **Node.js**(또는 deno) PATH 등록 (yt-dlp JS 런타임)
- [ ] **CapCut** 설치 + (선택) 코트라 볼드체 1회 사용해 폰트 캐시 생성
- [ ] `python -m pip install -r requirements.txt` 실행
그다음 `캡컷_에이전트_구간합치기.bat` 더블클릭 → http://127.0.0.1:8001
---
## 1. 시스템 요구사항
| 항목 | 실측 버전 | 비고 |
|---|---|---|
| OS | Windows 11 (10.0.26200) | Windows 10 64-bit 이상 권장. 콘솔 cp949라 한글 print 깨져 보여도 로직 무관 |
| Python | **3.13.0** | 3.10~3.13 범위면 대체로 OK. python.org 설치 시 "Add to PATH" 체크 |
| 아키텍처 | x64 | faster-whisper(ctranslate2)·onnxruntime가 x64 전제 |
| 디스크 여유 | 약 3~4GB | Whisper medium 모델(~1.5GB) + 패키지 + 다운로드 캐시 |
| 인터넷 | 최초 1회 필수 | 패키지·Whisper 모델·유튜브 다운로드에 필요 |
### 권장 사양 (쾌적하게 돌리려면)
병목은 **자막 받아쓰기(ASR)** 입니다. faster-whisper `medium` 모델을 **CPU(int8)** 로 돌리므로
**CPU 성능·코어 수가 속도를 좌우**합니다. (아래 GPU 항목 참고 — 현재 GPU는 안 씀.)
| 항목 | 최소 | 권장 | 비고 |
|---|---|---|---|
| **CPU** | 4코어 | **8코어 이상** (최신 Ryzen 5/7, Intel i5/i7) | ASR·ffmpeg 재인코딩이 CPU 바운드. 코어 많을수록 자막·병합 빠름 |
| **RAM** | 8GB | **16GB** | Whisper 모델 로드(~2~3GB) + ffmpeg 재인코딩 + 브라우저 동시 |
| **저장소** | HDD 가능 | **SSD(NVMe 권장)** | 영상 재인코딩·프레임 추출 I/O가 많음. SSD면 체감 큰 차이 |
| **디스크 여유** | 4GB | **10GB+** | 여러 영상 다운로드·ASR 캐시가 쌓임(`.downloads` `.cache`) |
| **GPU** | 불필요 | 불필요 | ⚠ **현재 코드는 Whisper를 `device="cpu"` 로 고정** → GPU 있어도 이득 없음. CPU에 투자할 것 |
| **네트워크** | — | 안정적 유선/와이파이 | 유튜브 다운로드용. 구간 컷 속도는 회선보다 **ffmpeg 8.0.1**(§2-1)이 핵심 |
> **속도 감각**: 붙여넣기 탭에서 ASR을 끄면 CPU 부담이 확 줄어 어떤 PC에서도 빠릅니다.
> 파일/유튜브 탭(자동 자막)은 CPU가 약하면 영상 길이에 비례해 ASR이 오래 걸립니다.
---
## 2. 시스템 의존성 (pip 아님 — 별도 설치 + PATH 필수)
이 3개는 파이썬 패키지가 아니라 **OS에 따로 깔고 PATH에 잡혀야** 합니다. 없으면 조용히 실패하거나 ffmpeg 크래시가 납니다.
### 2-1. ffmpeg / ffprobe ★필수 · ⭐버전 8.0.1 반드시 고정
| 항목 | 실측 |
|---|---|
| 버전 | **ffmpeg 8.0.1** (gyan.dev `essentials_build`) — ⭐**이 버전으로 고정** |
| 확인 | `ffmpeg -version`, `ffprobe -version` 둘 다 나와야 함 |
> ⚠️ **가장 중요 — 최신(8.1.x) 쓰지 말 것.** ffmpeg **8.1.x**는 `--download-sections`(유튜브 구간 컷)
> 경로에서 **HTTP seek 회귀**가 있어 10초 클립 다운로드가 **90초+** 로 극단적으로 느려집니다.
> **8.0.1로 내리면 12~15초로 정상화.** (전체 다운로드는 이 경로를 안 타서 멀쩡하므로,
> "일반 다운로드는 빠른데 구간 컷만 느리다"면 100% 이 문제입니다.)
- 다운로드(8.0.1 정확히): **https://github.com/GyanD/codexffmpeg/releases/tag/8.0.1**`ffmpeg-8.0.1-essentials_build.7z`
- 압축 해제 후 `bin` 폴더(= `ffmpeg.exe`, `ffprobe.exe`)를 **시스템 PATH에 추가**. 이미 8.1.x가 잡혀 있으면 **그 경로를 지우고** 8.0.1로 교체(또는 exe 2개 덮어쓰기).
- **터미널 새로 열고** `ffmpeg -version``8.0.1-essentials_build`인지 확인.
- 용도: 구간 병합·재인코딩, 무음 감지, 장면분할, 오디오 추출, 프레임 추출.
**속도 검증**(셋업 후 10초 구간 컷이 몇 초 걸리는지):
```powershell
yt-dlp --js-runtimes node --download-sections "*0:30-0:40" --force-keyframes-at-cuts -f "bv*[vcodec^=avc1]+ba[acodec^=mp4a]/b" --merge-output-format mp4 -o "%TEMP%/sec.%(ext)s" "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
```
**12~15초 = ✅ 정상** / 90초+ = ffmpeg가 아직 8.1.x (터미널 새로 열었는지 재확인).
### 2-2. JS 런타임 (Node.js 또는 deno) ★필수
| 항목 | 실측 |
|---|---|
| Node.js | **v22.17.0** |
| deno | 1.3.14 (있으면 우선 사용) |
- 최신 유튜브는 JS 챌린지가 있어 **런타임이 없으면 포맷 누락 → ffmpeg 크래시**가 납니다.
- `youtube.py``deno → node → bun` 순으로 자동 감지(`--js-runtimes`)하므로 **셋 중 하나만** 있으면 됨.
- 가장 쉬운 선택: **Node.js LTS** 설치 (https://nodejs.org) → `node --version` 확인.
### 2-3. CapCut ★필수
| 용도 | 필수 여부 |
|---|---|
| 드래프트 저장 위치 제공(`%LOCALAPPDATA%/CapCut/...`) | 필수 |
| 완료 후 자동 열기(`/open-capcut`) | 선택 |
| 코트라 볼드체 폰트 캐시 | 선택(없으면 기본 폰트로 안전 동작) |
- CapCut 데스크톱(Windows) 설치. 드래프트는 아래 경로에 생성됨:
`%LOCALAPPDATA%\CapCut\User Data\Projects\com.lveditor.draft\<드래프트명>\`
- 폰트 캐시는 §6 참고.
---
## 3. Python 패키지 (pip)
### 3-1. 느슨한 설치 (requirements.txt — 최신으로 받음)
```bash
python -m pip install -r requirements.txt
```
`requirements.txt` 내용:
```
fastapi
uvicorn
python-multipart
pyCapCut
Pillow
pymediainfo
yt-dlp
faster-whisper
```
### 3-2. 버전 고정 (재현성 100% — 이 조합이 실제로 도는 버전)
다른 PC에서 **최신 버전 충돌이 걱정되면** 아래를 `requirements.lock.txt`로 저장해
`python -m pip install -r requirements.lock.txt` 로 설치하세요.
```
# ── 직접 의존성 ──────────────────────────────
fastapi==0.115.14
uvicorn==0.35.0
python-multipart==0.0.20
pyCapCut==0.0.3 # import 이름은 pycapcut
Pillow==10.4.0
pymediainfo==7.0.1
yt-dlp==2026.7.4
faster-whisper==1.2.1
# ── faster-whisper / fastapi 전이 의존성(자동 설치되지만 버전 고정용) ──
av==16.0.1
ctranslate2==4.6.2
onnxruntime==1.23.2
tokenizers==0.21.2
huggingface-hub==0.33.4
numpy==2.2.0
starlette==0.46.2
pydantic==2.11.7
```
> **참고**
> - `yt-dlp`는 pip로 깔면 `yt-dlp` **콘솔 스크립트가 PATH에 생겨** 코드가 subprocess로 호출합니다. (유튜브는 자주 막히니 **주기적으로 `python -m pip install -U yt-dlp` 업데이트 권장** — 버전 고정하지 말 것.)
> - SSE(진행상황 스트림)는 FastAPI 내장 `StreamingResponse` 사용 → `sse-starlette` **불필요**.
> - Gemini 교정은 표준 라이브러리 `urllib`로 REST 직접 호출 → `google-generativeai` **패키지 불필요**.
> - `pymediainfo`는 내부적으로 **MediaInfo DLL**을 씀. 대개 wheel에 포함되나, 안 되면 https://mediaarea.net/en/MediaInfo 의 DLL을 PATH에 두면 됨.
---
## 4. 자동 다운로드되는 모델 (faster-whisper)
파일/유튜브 탭에서 **자막 받아쓰기(ASR)** 를 처음 실행할 때, HuggingFace에서 자동 다운로드됩니다.
| 항목 | 값 |
|---|---|
| 모델 | `Systran/faster-whisper-medium` |
| 크기 | 약 **1.5GB** |
| 설정 | `medium` / `compute_type=int8` / `device=cpu` / 언어 `ko` |
| 캐시 위치 | `%USERPROFILE%\.cache\huggingface\hub\models--Systran--faster-whisper-medium` |
| ASR 결과 캐시 | 프로젝트 `.cache\` (content-hash 기준) |
- **최초 1회 인터넷 필요.** 이후 오프라인 동작.
- **붙여넣기 탭만 쓰고 ASR(하단자막 자동생성)을 끄면** 이 모델은 안 받아도 됨.
- 미리 받아두려면 아무 영상이나 파일 탭에 한 번 돌리면 캐시됨. (또는 다른 PC의 위 캐시 폴더를 통째로 복사해도 됨.)
---
## 5. (선택) Gemini 자막 글자 교정
자막 **글자만** 1:1 교정(시간 불변). 키 없으면 **자동 스킵**(Whisper 원문 유지)이라 필수는 아님.
| 항목 | 값 |
|---|---|
| 모델 | `gemini-2.5-flash` (무료 티어 지원) |
| 호출 | REST(`generativelanguage.googleapis.com`) via `urllib` — 추가 패키지 없음 |
| 키 주입 방법 (둘 중 하나) | ① 프로젝트 루트에 **`.gemini_key`** 파일(키 한 줄) ② 환경변수 `GEMINI_API_KEY` 또는 `GOOGLE_API_KEY` |
- 키 발급: https://aistudio.google.com → API key
- 429(무료 한도 초과) 시 Whisper 원문으로 폴백.
---
## 6. 코트라 볼드체 폰트 (선택, 있으면 예쁨)
pycapcut FontType에 없어 **저장 후 draft_content.json에 폰트 경로를 직접 주입**합니다.
| 항목 | 값 |
|---|---|
| 캐시 경로 | `%LOCALAPPDATA%\CapCut\User Data\Cache\effect\7480846567709265157\782a91b14f1661b95e7e587be27f1af4\font.ttf` |
| 크기 | 약 642KB |
| 경로 성격 | **PC 무관**(CapCut 전역 폰트 ID라 어느 PC든 동일 경로) |
- 이 파일이 **있어야** 자막/제목이 코트라 볼드체로 나옴. 없으면 주입을 **자동 생략** → CapCut 기본 폰트로 안전 동작(에러 아님).
- 다른 PC에서 만들려면: 그 PC의 **CapCut에서 코트라 볼드체를 한 번 사용**(아무 텍스트에 적용)하면 캐시가 생성됨. 또는 위 `font.ttf`를 같은 경로에 복사.
---
## 7. 다른 PC 설치 순서 (처음부터)
```powershell
# 1) Python 3.13 설치 (python.org, "Add Python to PATH" 체크) → 확인
python --version
# 2) ffmpeg 설치 후 bin 폴더를 PATH 등록 → 확인
ffmpeg -version
ffprobe -version
# 3) Node.js LTS 설치 → 확인
node --version
# 4) CapCut 설치 (그리고 원하면 코트라 볼드체 1회 사용)
# 5) 프로젝트 폴더 통째로 복사한 뒤, 그 폴더에서:
python -m pip install -r requirements.txt
# 6) (선택) Gemini 키
# .gemini_key 파일에 키 한 줄 저장 또는 환경변수 GEMINI_API_KEY 설정
# 7) 실행
캡컷_에이전트_구간합치기.bat
```
> 폴더를 복사할 때 `.cache/`, `.downloads/` 같은 대용량 파생물은 빼도 됨(자동 재생성).
> `.gemini_key`는 개인 키라 공유 주의.
---
## 8. 실행 · 포트
| 항목 | 값 |
|---|---|
| 런처 | `캡컷_에이전트_구간합치기.bat` (브라우저 자동 오픈) |
| 직접 실행 | `python -m uvicorn server.app:app --port 8001` |
| 주소 | http://127.0.0.1:8001 |
| 포트 | **8001** (형제 앱 v1 `../capcut`은 8000 — 동시 실행 가능) |
> ⚠️ **코드 수정 후엔 반드시 검은 창 닫고 .bat 재실행.** uvicorn hot-reload 안 됨(가장 흔한 "안 돼요" 원인).
---
## 9. 설치 검증
```bash
# 파이썬 임포트 체인 확인 (에러 없이 통과해야 함)
python -c "from server import app; print('app OK')"
python -c "from capcut_agent import pipeline, draft, youtube, transcribe, correct; print('modules OK')"
# 외부 도구 확인
ffmpeg -version | head -1
node --version
yt-dlp --version
```
최종 검증은 실제로 짧은 유튜브 구간 하나를 돌려 **CapCut에서 드래프트가 열리는지** 확인.
---
## 10. 문제 해결 (다른 PC 이식 시 흔한 것)
| 증상 | 원인 | 해결 |
|---|---|---|
| `ffmpeg`/`ffprobe` not found | PATH 미등록 | ffmpeg `bin`을 시스템 PATH에 추가, 창 새로 열기 |
| 유튜브 다운로드가 ffmpeg 크래시(exit 3436169992) | JS 런타임 없음 | Node.js 또는 deno 설치 |
| "Video unavailable" | 영상 자체 없음/지역제한 | `curl "https://www.youtube.com/oembed?url=<URL>&format=json"` 404면 영상 문제 |
| 유튜브가 갑자기 다 실패 | yt-dlp 구버전 | `python -m pip install -U yt-dlp` |
| 자막이 기본 폰트로 나옴 | 코트라 볼드체 캐시 없음 | §6 — CapCut에서 1회 사용 or font.ttf 복사 (없어도 동작은 함) |
| ASR 첫 실행이 매우 느림/멈춘 듯 | Whisper medium(1.5GB) 다운로드 중 | 최초 1회. 인터넷 확인, 기다리기 |
| 자막 교정이 안 됨 | Gemini 키 없음/429 | 선택 기능. 없으면 Whisper 원문 사용(정상) |
| 병합 시 `Invalid argument`(exit 4294967274) | (해결됨) 한글 경로 concat 버그 | 최신 `youtube.py`면 ASCII 임시링크로 자동 우회 |
| 드래프트 열면 영상이 흰띠·댓글 위로 삐짐 | CapCut 편집 중 render_index 꼬임 | 최신 `draft.py`는 오버레이/제목 트랙 자동 잠금으로 예방 |
| **클립을 옮긴 뒤 확대하면 그 클립만 템플릿 밖으로 삐짐** | CapCut이 옮긴 클립에 렌더순서를 새로(맨 위로) 매김 — 잠금으로 못 막음 | **캡컷에서 그 프로젝트를 닫고** → 웹 UI 하단 **"🩹 레이어 수리"** → 드래프트 선택 → 실행 → 캡컷에서 다시 열기 |
| **영상 중간에 초록 화면이 몇 초 나옴** | yt-dlp가 키프레임 아닌 위치에서 잘라 참조 프레임이 없음 | 최신 `youtube.py`가 컷마다 자동 검증→재다운로드→정밀 재컷. 로그에 `🩹` 표시. 예전에 받은 영상은 다시 만들어야 함 |
| 콘솔에 한글 깨짐 | Windows cp949 | 표시만 깨짐. 로직·결과와 무관 |
---
## 11. 한눈에 보는 버전 표 (복붙용)
```
OS Windows 11 (10.0.26200), x64
Python 3.13.0
ffmpeg/ffprobe 8.0.1 (gyan.dev essentials)
Node.js 22.17.0 (또는 deno 1.3.14)
CapCut 데스크톱(Windows)
fastapi 0.115.14
uvicorn 0.35.0
python-multipart 0.0.20
pyCapCut 0.0.3 (import pycapcut)
Pillow 10.4.0
pymediainfo 7.0.1
yt-dlp 2026.7.4 (최신 유지 권장)
faster-whisper 1.2.1
├ av 16.0.1
├ ctranslate2 4.6.2
├ onnxruntime 1.23.2
├ tokenizers 0.21.2
├ huggingface-hub 0.33.4
└ numpy 2.2.0
starlette 0.46.2
pydantic 2.11.7
ASR 모델 Systran/faster-whisper-medium (~1.5GB, int8/cpu)
Gemini(선택) gemini-2.5-flash (REST/urllib, 키 선택)
포트 8001
```

BIN
assets/bg_template.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
assets/bg_white.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
assets/frame_template.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

92
build_bg_template.py Normal file
View File

@ -0,0 +1,92 @@
"""배경템플릿 CLI: 잘라둔 클립 → 무음컷 + 자막 + (검정-흰색-검정) 배경 템플릿.
영상은 영역 '움직일 수 있는' 레이어 캡컷에서 확대/좌우 이동은 사용자가.
제목(2)·자막·채널은 캡컷 편집 가능 텍스트.
사용:
python build_bg_template.py <video> [draft_name]
"""
from __future__ import annotations
import os
import sys
import time
from capcut_agent.probe import probe
from capcut_agent.transcribe import transcribe
from capcut_agent.silence import detect_speech_segments
from capcut_agent.highlight import cut_plan
from capcut_agent.pipeline import _template_pos, VIDEO_TOP, VIDEO_BOTTOM
from capcut_agent.draft import build_bg_template_draft, _ty
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
ROOT = os.path.dirname(os.path.abspath(__file__))
# 템플릿(검정-흰색-검정, 흰=영상영역): 루트 배경.png 우선, 없으면 배경템플릿.png/assets
TEMPLATE = os.path.join(ROOT, "배경.png")
for alt in (os.path.join(ROOT, "배경템플릿.png"), os.path.join(ROOT, "assets", "bg_template.png")):
if not os.path.isfile(TEMPLATE):
TEMPLATE = alt
# 매 실행마다 투명 가운데(흰밴드) 프레임을 템플릿에서 파생(템플릿 수정 시 자동 반영)
FRAME_IMAGE = os.path.join(ROOT, "assets", "frame_template.png")
REC_TITLE_TOP = "<리센느 비하인드>"
REC_TITLE_MAIN = "'거제야호'는 대본에 없었다"
REC_CHANNEL = "@밥묵자 · 리센느"
def main(argv: list[str]) -> int:
if len(argv) < 2:
print("usage: python build_bg_template.py <video> [name]")
return 2
video = os.path.abspath(argv[1])
if not os.path.isfile(video):
print(f"파일 없음: {video}")
return 2
if not os.path.isfile(TEMPLATE):
print(f"배경 템플릿 없음: {TEMPLATE}")
return 2
name = argv[2] if len(argv) > 2 else (
os.path.splitext(os.path.basename(video))[0] + "_배경템플릿"
)
t0 = time.time()
# 레이아웃은 pipeline._template_pos 하나로 통일(값이 두 군데로 갈라지지 않게).
FRAME_IMAGE, _bg, pos = _template_pos(False) # 검은 띠 버전
print(f"[frame] 영상창 {VIDEO_TOP}~{VIDEO_BOTTOM}px "
f"(상단 띠 {VIDEO_TOP}px, 하단 띠 {1920 - VIDEO_BOTTOM}px=댓글영역)")
meta = probe(video)
print(f"[probe] {meta.width}x{meta.height} @ {meta.fps}fps, {meta.duration:.1f}s")
# 컷 기준 = 실제 오디오 무음(silencedetect). VAD가 놓치는 짧은 외침도 보존.
# 깔끔하게: 짧은 공백(0.3s↑)·룸톤(-28dB↓)까지 제거, pad 작게(컷 타이트).
keep = detect_speech_segments(video, meta.duration,
noise_db=-28.0, min_silence=0.3, pad=0.04)
if not keep:
print("[silence] 오디오 없음/전부 무음 — 확인 필요")
return 1
# 자막은 전사에서 가져와 시간 맞춰 얹음. VAD 끔 → 짧은 외침에도 자막 생성
tr = transcribe(video, model_size="medium", language="ko", use_cache=True,
vad_filter=False)
video_clips, captions, total = cut_plan(keep, tr)
print(f"[cut] 보존 {len(video_clips)}구간, {total:.1f}s "
f"({meta.duration - total:.1f}s 무음 제거) · 자막 {len(captions)}")
path = build_bg_template_draft(
video, None, FRAME_IMAGE, video_clips, captions, meta, name, # bg 레이어 없음(빈곳 검정)
title_top=REC_TITLE_TOP, title_main=REC_TITLE_MAIN, channel=REC_CHANNEL,
**pos,
)
print(f"[draft] {path}")
print(f" 제목: {REC_TITLE_TOP} / {REC_TITLE_MAIN} | 채널: {REC_CHANNEL}")
print(f"[done] {time.time()-t0:.1f}s. CapCut에서 '{name}' 열어 검증.")
print(" ※ 가운데는 투명(영상이 비침), 검은 띠는 영상 위. 영상 확대/이동은 캡컷에서 직접.")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))

55
build_jumpcut.py Normal file
View File

@ -0,0 +1,55 @@
"""1단 CLI: 영상 1개 → 점프컷 CapCut 드래프트.
사용:
python build_jumpcut.py <video> [draft_name]
검증은 빌드 성공이 아니라 CapCut 에서 직접 재생으로 한다.
"""
from __future__ import annotations
import os
import sys
import time
# Windows 콘솔에서 한글 깨짐 방지
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
from capcut_agent.probe import probe
from capcut_agent.silence import detect_speech_segments
from capcut_agent.draft import build_jumpcut_draft
def main(argv: list[str]) -> int:
if len(argv) < 2:
print("usage: python build_jumpcut.py <video> [draft_name]")
return 2
video = os.path.abspath(argv[1])
if not os.path.isfile(video):
print(f"파일 없음: {video}")
return 2
name = argv[2] if len(argv) > 2 else (
os.path.splitext(os.path.basename(video))[0] + "_jumpcut"
)
t0 = time.time()
meta = probe(video)
print(f"[probe] {meta.width}x{meta.height} @ {meta.fps}fps, {meta.duration:.2f}s")
segs = detect_speech_segments(video, meta.duration)
speech_total = sum(e - s for s, e in segs)
print(f"[silence] 발화 {len(segs)}개 구간, 합계 {speech_total:.2f}s "
f"(원본 {meta.duration:.2f}s, 컷 {meta.duration - speech_total:.2f}s 제거)")
path = build_jumpcut_draft(video, segs, meta, name)
print(f"[draft] 생성 완료 → {path}")
print(f"[done] {time.time() - t0:.1f}s. CapCut 에서 '{name}' 열어 재생 검증하세요.")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))

72
build_letterbox.py Normal file
View File

@ -0,0 +1,72 @@
"""레터박스 CLI: 미리 잘라둔 클립 → 무음 컷 + 자막 + 9:16 검은 띠 드래프트.
사용:
python build_letterbox.py <video> [draft_name]
처리:
1. 영상을 9:16 레터박스(가로영상 중앙 + 위아래 검은 ) h264 mp4 굽기(.media/)
2. 전사(캐시) 발화 세그먼트만 남겨 점프컷 + 세그먼트 자막 동기
3. 캔버스 1080×1920, 레터박스 영상은 scale 1.0(비율 일치) 크롭/transform 추측 없음
"""
from __future__ import annotations
import os
import sys
import time
from capcut_agent.probe import probe
from capcut_agent.transcribe import transcribe
from capcut_agent.highlight import clips_in_window
from capcut_agent.media import to_letterbox
from capcut_agent.draft import build_shortform_draft
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
MEDIA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".media")
os.makedirs(MEDIA_DIR, exist_ok=True)
def main(argv: list[str]) -> int:
if len(argv) < 2:
print("usage: python build_letterbox.py <video> [name]")
return 2
video = os.path.abspath(argv[1])
if not os.path.isfile(video):
print(f"파일 없음: {video}")
return 2
name = argv[2] if len(argv) > 2 else (
os.path.splitext(os.path.basename(video))[0] + "_레터박스"
)
t0 = time.time()
src_meta = probe(video)
print(f"[probe] {src_meta.width}x{src_meta.height} @ {src_meta.fps}fps, {src_meta.duration:.1f}s")
# 1) 9:16 레터박스 굽기 (전체 클립, 타임라인 보존)
lb_path = os.path.join(MEDIA_DIR, f"{name}.mp4")
to_letterbox(video, lb_path)
lb_meta = probe(lb_path)
print(f"[letterbox] {lb_meta.width}x{lb_meta.height}{lb_path}")
# 2) 전사 → 발화 세그먼트 = 점프컷 + 자막
tr = transcribe(video, model_size="medium", language="ko", use_cache=True)
clips = clips_in_window(tr, 0.0, src_meta.duration)
if not clips:
print("[asr] 발화 세그먼트 없음 — 오디오 확인 필요")
return 1
kept = sum(e - s for s, e, _ in clips)
print(f"[cut] 발화 {len(clips)}구간, {kept:.1f}s (원본 {src_meta.duration:.1f}s, "
f"{src_meta.duration - kept:.1f}s 무음 제거)")
# 3) 드래프트 (레터박스 영상은 9:16 → scale 1.0 자동)
path = build_shortform_draft(lb_path, clips, lb_meta, name, captions=True)
print(f"[draft] {path}")
print(f"[done] {time.time()-t0:.1f}s. CapCut에서 '{name}' 열어 검증.")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))

89
build_shortform.py Normal file
View File

@ -0,0 +1,89 @@
"""숏폼 CLI: 롱폼 + 하이라이트 윈도우 → 9:16 중앙크롭 자막 드래프트.
사용:
python build_shortform.py <video> <start> <end> [draft_name]
start/end (: 312) 또는 mm:ss(: 5:12)
처리:
1. 전사(캐시) 윈도우 발화 세그먼트(자막 동기) 추출
2. 윈도우 구간만 h264 mp4 추출(.media/) AV1/webm 함정 + CapCut 미리보기 회피
3. clip 타임스탬프를 추출본 0 기준으로 리베이스 9:16 자막 드래프트 빌드
"""
from __future__ import annotations
import os
import subprocess
import sys
import time
from capcut_agent.probe import probe
from capcut_agent.transcribe import transcribe
from capcut_agent.highlight import clips_in_window, window_bounds
from capcut_agent.draft import build_shortform_draft
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
MEDIA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".media")
os.makedirs(MEDIA_DIR, exist_ok=True)
def parse_t(v: str) -> float:
if ":" in v:
m, s = v.split(":")
return int(m) * 60 + float(s)
return float(v)
def extract_window(video: str, start: float, end: float, out_path: str) -> None:
"""[start,end] 구간을 h264 mp4 로 추출. 출력 t=0 == start (정확 seek)."""
dur = end - start + 0.3 # 끝 pad 여유
cmd = [
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
"-ss", f"{start:.3f}", "-i", video, "-t", f"{dur:.3f}",
"-c:v", "libx264", "-preset", "veryfast", "-crf", "20",
"-pix_fmt", "yuv420p", "-c:a", "aac", "-ar", "44100",
out_path,
]
subprocess.run(cmd, check=True, encoding="utf-8", errors="replace")
def main(argv: list[str]) -> int:
if len(argv) < 4:
print("usage: python build_shortform.py <video> <start> <end> [name]")
return 2
video = os.path.abspath(argv[1])
ws, we = parse_t(argv[2]), parse_t(argv[3])
name = argv[4] if len(argv) > 4 else f"short_{int(ws)}_{int(we)}"
t0 = time.time()
tr = transcribe(video, model_size="medium", language="ko", use_cache=True)
print(f"[asr] segments={len(tr.segments)} (cache)")
ws, we = window_bounds(tr, ws, we) # 앞뒤 무음 트림
clips = clips_in_window(tr, ws, we)
if not clips:
print("[window] 발화 없음 — 구간 확인 필요")
return 1
kept = sum(e - s for s, e, _ in clips)
print(f"[window] {ws:.1f}{we:.1f}s → clips={len(clips)}, kept={kept:.1f}s")
# 윈도우 구간만 h264 추출 (AV1/webm + CapCut 미리보기 함정 회피)
media_path = os.path.join(MEDIA_DIR, f"{name}.mp4")
extract_window(video, ws, we, media_path)
print(f"[extract] {media_path}")
# clip 을 추출본 0 기준으로 리베이스
rebased = [(s - ws, e - ws, txt) for s, e, txt in clips]
meta = probe(media_path) # 추출본 메타(해상도/fps)
path = build_shortform_draft(media_path, rebased, meta, name, captions=True)
print(f"[draft] {path}")
print(f"[done] {time.time()-t0:.1f}s. CapCut에서 '{name}' 열어 검증.")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))

77
build_template.py Normal file
View File

@ -0,0 +1,77 @@
"""템플릿 CLI: 잘라둔 클립 → 제목 띠 + 무음컷 + 자막 + 채널 띠 (9:16 숏폼 템플릿).
사용:
python build_template.py <video> [draft_name]
(제목/채널은 아래 추천값으로 들어가고, CapCut에서 편집 가능)
레이아웃: 상단 검은 =제목(2) / 중앙=영상(좌우 살짝 크롭) / 하단 검은 =채널.
제목·자막·채널은 캡컷 편집 가능한 텍스트, 영상 틀만 굽는다.
"""
from __future__ import annotations
import os
import sys
import time
from capcut_agent.probe import probe
from capcut_agent.transcribe import transcribe
from capcut_agent.highlight import clips_in_window
from capcut_agent.media import to_template
from capcut_agent.draft import build_template_draft
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
MEDIA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".media")
os.makedirs(MEDIA_DIR, exist_ok=True)
# 추천 제목/채널 (클립: 거제야호 애드립 비하인드). 캡컷에서 자유 수정.
REC_TITLE_TOP = "<리센느 비하인드>"
REC_TITLE_MAIN = "'거제야호'는 대본에 없었다"
REC_CHANNEL = "밥묵자 · 리센느"
def main(argv: list[str]) -> int:
if len(argv) < 2:
print("usage: python build_template.py <video> [name]")
return 2
video = os.path.abspath(argv[1])
if not os.path.isfile(video):
print(f"파일 없음: {video}")
return 2
name = argv[2] if len(argv) > 2 else (
os.path.splitext(os.path.basename(video))[0] + "_템플릿"
)
t0 = time.time()
src_meta = probe(video)
print(f"[probe] {src_meta.width}x{src_meta.height} @ {src_meta.fps}fps, {src_meta.duration:.1f}s")
tpl_path = os.path.join(MEDIA_DIR, f"{name}.mp4")
to_template(video, tpl_path)
tpl_meta = probe(tpl_path)
print(f"[template] {tpl_meta.width}x{tpl_meta.height}{tpl_path}")
tr = transcribe(video, model_size="medium", language="ko", use_cache=True)
clips = clips_in_window(tr, 0.0, src_meta.duration)
if not clips:
print("[asr] 발화 없음")
return 1
kept = sum(e - s for s, e, _ in clips)
print(f"[cut] 발화 {len(clips)}구간, {kept:.1f}s ({src_meta.duration - kept:.1f}s 무음 제거)")
path = build_template_draft(
tpl_path, clips, tpl_meta, name,
title_top=REC_TITLE_TOP, title_main=REC_TITLE_MAIN, channel=REC_CHANNEL,
)
print(f"[draft] {path}")
print(f" 제목: {REC_TITLE_TOP} / {REC_TITLE_MAIN}")
print(f" 채널: {REC_CHANNEL}")
print(f"[done] {time.time()-t0:.1f}s. CapCut에서 '{name}' 열어 검증.")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))

1
capcut_agent/__init__.py Normal file
View File

@ -0,0 +1 @@
"""캡컷 에이전트 — 한국어 토킹 영상 자동 편집기 (트랙 C / Windows)."""

99
capcut_agent/comments.py Normal file
View File

@ -0,0 +1,99 @@
"""h-lab 댓글 수집 + 타임스탬프 매칭 — 자동 탭용.
h-lab(https://h-lab.tolag.shop) comment-cards API에서 영상 전체 댓글을 받아,
본문 mm:ss / h:mm:ss 언급을 초로 파싱한다. 정규식·평문 변환 규칙은
h-lab comment-cards.js(TS_RE, toPlainText) 동일하게 맞춘다.
"""
from __future__ import annotations
import json
import re
import urllib.request
from typing import Dict, List
H_LAB = "https://h-lab.tolag.shop"
# h-lab comment-cards.js 의 TS_RE 와 동일 규칙
TS_RE = re.compile(r"(?<!\d)(\d{1,2}):([0-5]\d)(?::([0-5]\d))?(?!\d)")
_BR_RE = re.compile(r"<br\s*/?>", re.I)
_TAG_RE = re.compile(r"<[^>]+>")
# 분:초를 이만큼 넘게 나열한 댓글 = '목차 댓글'(하이라이트 모음). 카드로 못 쓴다.
# ⚠ 이게 없으면 모든 구간에 매칭돼서 **어느 ID를 열어도 맨 위에 이 댓글이 뜬다.**
# 후보(candidates)는 분:초가 아예 없는 댓글만 쓰므로, 여기만 막으면 화면에서 완전히 빠진다.
MAX_TIMES = 3
def plain_text(html: str) -> str:
"""YouTube textDisplay(HTML) → 평문. <br>→줄바꿈, 나머지 태그 제거."""
s = _BR_RE.sub("\n", str(html or ""))
return _TAG_RE.sub("", s)
def parse_times(text: str) -> List[float]:
"""댓글 본문 속 mm:ss / h:mm:ss → 초 리스트(중복 제거, 등장 순)."""
out: List[float] = []
seen = set()
for m in TS_RE.finditer(plain_text(text)):
if m.group(3) is not None:
sec = int(m.group(1)) * 3600 + int(m.group(2)) * 60 + int(m.group(3))
else:
sec = int(m.group(1)) * 60 + int(m.group(2))
if sec not in seen:
seen.add(sec)
out.append(float(sec))
return out
def fetch_comments(url: str, *, timeout: float = 180.0) -> List[Dict]:
"""h-lab에서 전체 댓글 수집. 원본 순서 유지, idx = 배열 인덱스(식별자).
실패는 예외 그대로 던진다(URLError/RuntimeError) 호출부(자동 스트림)
"댓글 없이 진행" 폴백을 담당한다.
"""
req = urllib.request.Request(
f"{H_LAB}/api/comment-cards/fetch",
data=json.dumps({"url": url}).encode("utf-8"),
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
data = json.loads(resp.read().decode("utf-8"))
if not data.get("success"):
raise RuntimeError(f"h-lab 응답 실패: {data.get('message')}")
out: List[Dict] = []
for i, c in enumerate(data.get("data") or []):
out.append({
"idx": i,
"authorName": str(c.get("authorName") or ""),
"text": str(c.get("text") or ""),
"likeCount": int(c.get("likeCount") or 0),
"replyCount": int(c.get("replyCount") or 0),
"publishedAt": str(c.get("publishedAt") or ""),
"profileImageUrl": str(c.get("profileImageUrl") or ""),
"times": parse_times(c.get("text") or ""),
})
return out
def match_window(comments: List[Dict], start: float, end: float) -> List[int]:
"""[start, end] 안의 시각을 하나라도 언급한 댓글 idx — 좋아요 내림차순."""
return match_ranges(comments, [(start, end)])
def match_ranges(comments: List[Dict], ranges) -> List[int]:
"""여러 구간 중 어느 하나라도 언급한 댓글 idx — 좋아요 내림차순 (유튜브 구간 탭용).
:초를 MAX_TIMES 개보다 많이 나열한 목차 댓글은 제외(§MAX_TIMES 주석 참고).
"""
hit = [c for c in comments
if len(c["times"]) <= MAX_TIMES
and any(any(s <= t <= e for s, e in ranges) for t in c["times"])]
hit.sort(key=lambda c: -c["likeCount"])
return [c["idx"] for c in hit]
def top_liked(comments: List[Dict], exclude: set, n: int = 20) -> List[int]:
"""exclude(idx 집합) 제외 좋아요 상위 n개 idx."""
rest = [c for c in comments if c["idx"] not in exclude]
rest.sort(key=lambda c: -c["likeCount"])
return [c["idx"] for c in rest[:n]]

139
capcut_agent/correct.py Normal file
View File

@ -0,0 +1,139 @@
"""자막 교정 — Google Gemini(무료 티어) 사용. 텍스트만 전송(영상 X).
없으면 교정 건너뜀(들리는대로 그대로). /순서 보존, 사투리·구어체 유지.
"""
from __future__ import annotations
import base64
import json
import os
import urllib.error
import urllib.request
from typing import List, Optional, Tuple
class GeminiQuotaError(Exception):
"""Gemini 무료 한도 초과(429) — Whisper 폴백 신호."""
GEMINI_MODEL = "gemini-2.5-flash" # 무료 티어 지원(2.0-flash는 limit 0)
_ENDPOINT = "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={key}"
_PROMPT = """너는 한국어 영상 자막 교정기다. 아래 자막 줄들을 교정해라.
규칙:
- 명백한 오타·오인식, 고유명사(인명/지명/유행어), 띄어쓰기만 고친다.
- 사투리·반말·구어체 말투는 그대로 둔다(표준어로 바꾸지 마라).
- 외국 문자나 깨진 글자는 들리는 한국어로 자연스럽게 바꾼다.
- 수와 순서를 절대 바꾸지 마라. 줄을 1:1 교정. 합치거나 나누지 마라.
- 도저히 고치겠으면 원문 그대로 둔다.
영상 제목(문맥): {title}
자막( 순서대로):
{lines}
교정된 줄들을 JSON 문자열 배열로만 출력. 입력과 같은 개수."""
def _gemini_key() -> Optional[str]:
key = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
if key:
return key.strip()
# 프로젝트 루트의 .gemini_key 파일 폴백
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
f = os.path.join(root, ".gemini_key")
if os.path.isfile(f):
return open(f, encoding="utf-8").read().strip() or None
return None
def correct_captions(texts: List[str], *, title: str = "",
model: str = GEMINI_MODEL, key: Optional[str] = None) -> List[str]:
"""자막 리스트를 Gemini로 교정. 실패/키없음/개수불일치 시 원문 반환(안전)."""
key = key or _gemini_key()
if not key or not texts:
return texts
numbered = "\n".join(f"{i+1}. {t}" for i, t in enumerate(texts))
body = {
"contents": [{"parts": [{"text": _PROMPT.format(title=title or "(없음)", lines=numbered)}]}],
"generationConfig": {
"temperature": 0.2,
"responseMimeType": "application/json",
"responseSchema": {"type": "array", "items": {"type": "string"}},
},
}
url = _ENDPOINT.format(model=model, key=key)
req = urllib.request.Request(
url, data=json.dumps(body).encode("utf-8"),
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=60) as resp:
data = json.loads(resp.read().decode("utf-8"))
out = data["candidates"][0]["content"]["parts"][0]["text"]
fixed = json.loads(out)
except (urllib.error.URLError, KeyError, IndexError, json.JSONDecodeError, TimeoutError):
return texts # 안전: 실패 시 원문
if not isinstance(fixed, list) or len(fixed) != len(texts):
return texts # 개수 안 맞으면 매핑 깨지니 원문
return [str(f).strip() or texts[i] for i, f in enumerate(fixed)]
def has_gemini_key() -> bool:
return bool(_gemini_key())
_ASR_PROMPT = (
"이 한국어 영상 오디오를 받아쓰기 해줘. 말소리가 있는 모든 구간을 빠짐없이.\n"
"자막용으로 짧게 나눠라:\n"
"- 한 자막은 한국어 12자 내외(최대 14자).\n"
"- 의미가 자연스럽게 끊기는 지점(어절·구·절 경계)에서 나눠라. 단어 중간이나 "
"어색한 곳(조사 앞 등)에서 끊지 마라. 짧은 한 호흡이 한 자막.\n"
"각 자막을 {\"start\":초, \"end\":초, \"text\":\"...\"} 로(시간은 오디오 시작 기준 초, 소수1자리). "
"사투리·반말·구어체는 그대로. 웃음/박수 등 비언어는 제외. JSON 배열로만."
)
def transcribe_gemini(audio_path: str, *, model: str = GEMINI_MODEL,
key: Optional[str] = None) -> List[Tuple[float, float, str]]:
"""Gemini로 오디오 받아쓰기 → [(start, end, text)] (오디오 시작 기준 초).
429(무료 한도 초과) GeminiQuotaError. 실패는 RuntimeError.
"""
key = key or _gemini_key()
if not key:
raise RuntimeError("Gemini 키 없음")
audio_b64 = base64.b64encode(open(audio_path, "rb").read()).decode()
body = {
"contents": [{"parts": [
{"inline_data": {"mime_type": "audio/mp3", "data": audio_b64}},
{"text": _ASR_PROMPT},
]}],
"generationConfig": {
"temperature": 0.1, "responseMimeType": "application/json",
"responseSchema": {"type": "array", "items": {"type": "object", "properties": {
"start": {"type": "number"}, "end": {"type": "number"}, "text": {"type": "string"}},
"required": ["start", "end", "text"]}},
},
}
url = _ENDPOINT.format(model=model, key=key)
req = urllib.request.Request(url, data=json.dumps(body).encode("utf-8"),
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=180) as resp:
data = json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
if e.code == 429:
raise GeminiQuotaError()
raise RuntimeError(f"Gemini HTTP {e.code}")
out = json.loads(data["candidates"][0]["content"]["parts"][0]["text"])
segs = []
for s in out:
try:
st, en, tx = float(s["start"]), float(s["end"]), str(s["text"]).strip()
except (KeyError, ValueError, TypeError):
continue
if tx and en > st:
segs.append((st, en, tx))
return segs

869
capcut_agent/draft.py Normal file
View File

@ -0,0 +1,869 @@
"""pycapcut 으로 점프컷 CapCut 드래프트 생성.
함정 회피:
- Timerange 정수 µs 직접 구성 (floattim '' 아니라 µs 반올림이므로).
- target(타임라인) 커서를 µs 정수로 누적 세그먼트 사이 1µs /오버랩 방지(tm_duration).
- 드래프트 캔버스를 원본 해상도와 동일하게 transform/scale 불필요(transform_y 함정 회피).
"""
from __future__ import annotations
import os
from typing import List, Optional, Tuple
import pycapcut as p
from .probe import VideoMeta
Segment = Tuple[float, float]
# (src_start_sec, src_end_sec, text|None)
Clip = Tuple[float, float, Optional[str]]
# CapCut(Windows) 드래프트 루트
DEFAULT_DRAFT_ROOT = os.path.join(
os.environ["LOCALAPPDATA"], "CapCut", "User Data", "Projects", "com.lveditor.draft"
)
def _us(seconds: float) -> int:
return round(seconds * p.SEC)
def build_jumpcut_draft(
video_path: str,
speech_segments: List[Segment],
meta: VideoMeta,
draft_name: str,
*,
draft_root: str = DEFAULT_DRAFT_ROOT,
) -> str:
"""발화 구간만 이어붙인 점프컷 드래프트를 생성하고 경로 반환."""
if not speech_segments:
raise ValueError("speech_segments 가 비어 있습니다 (감지된 발화 없음).")
folder = p.DraftFolder(draft_root)
script = folder.create_draft(
draft_name, meta.width, meta.height, fps=meta.fps, allow_replace=True
)
script.add_track(p.TrackType.video)
material = p.VideoMaterial(video_path)
cursor_us = 0 # 타임라인 커서(µs 정수 누적 → 갭 방지)
for s, e in speech_segments:
src_start = _us(s)
dur = _us(e) - src_start # 길이도 같은 반올림으로 일관
if dur <= 0:
continue
source = p.Timerange(src_start, dur)
target = p.Timerange(cursor_us, dur)
script.add_segment(p.VideoSegment(material, target, source_timerange=source))
cursor_us += dur
script.save()
return os.path.join(draft_root, draft_name)
# 숏폼 세로 캔버스 기본값 (9:16)
SHORT_W, SHORT_H = 1080, 1920
def _cover_scale(src_w: int, src_h: int, canvas_w: int, canvas_h: int) -> float:
"""canvas 를 가득 채우는(cover) scale. CapCut scale=1.0 == contain(여백맞춤) 가정.
src canvas 보다 가로로 넓으면 scale=1 에서 가로가 맞고 위아래 여백 세로를
채우려면 그만큼 키운다. 반대도 동일. 결과는 중앙 크롭.
scale 의미는 CapCut 렌더로 실측 검증 필요(transform 함정 구역).
"""
src_ar = src_w / src_h
canvas_ar = canvas_w / canvas_h
if src_ar > canvas_ar: # src 가 더 넓음 → 세로를 채우려 키움
return (canvas_h * src_ar) / canvas_w
else: # src 가 더 좁음/김 → 가로를 채우려 키움
return (canvas_w / src_ar) / canvas_h
# 자막 위치(세로 하단). transform_y 방향은 CapCut 렌더로 실측 검증 필요.
CAPTION_Y = -0.62
# 제목 윗줄 포인트색(주황). RGB 0~1.
TITLE_ACCENT = (1.0, 0.62, 0.05)
def _ty(y_px: float, canvas_h: int = 1920) -> float:
"""픽셀 y → CapCut 정규화 transform_y. (+ 위 / 아래, 0=중앙) 가정."""
return round((canvas_h / 2 - y_px) / (canvas_h / 2), 4)
# 템플릿 텍스트 위치(픽셀 기준 → transform_y). 레이아웃: media.to_template 와 맞춤.
# 상단 띠 0~410 / 영상 410~1410 / 하단 띠 1410~1920
TPL_TITLE_TOP_Y = _ty(150) # 윗줄(작게, 대괄호) ≈ +0.84
TPL_TITLE_MAIN_Y = _ty(285) # 아랫줄(크게) ≈ +0.70
TPL_CAPTION_Y = _ty(1300) # 영상 하단부 자막 ≈ -0.35
TPL_CHANNEL_Y = _ty(1520) # 하단 띠 채널/출처 ≈ -0.58
# 레이아웃(워크맨 스타일): 상단 제목띠 / 영상(위로) / 하단 큰 영역(댓글캡쳐 직접) / 출처
# 캡컷 인스펙터 위치 Y = transform.y × 1920
LAYOUT_TOP_BAR = 440 # 상단 제목 띠
LAYOUT_BOTTOM_TOP = 1100 # 하단 띠 시작 → 영상 영역 = 440~1100, 하단 1100~1920(댓글+출처)
BG_TITLE_TOP_Y = _ty(150) # 제목 윗줄 ≈ +0.84
BG_TITLE_MAIN_Y = _ty(320) # 제목 아랫줄 ≈ +0.67
BG_VIDEO_Y = _ty(770) # 영상 기본 위치(영상 영역 중앙, 위로) ≈ +0.20
BG_CAPTION_Y = _ty(1030) # 영상 하단부 자막 ≈ -0.07
BG_CHANNEL_Y = _ty(1850) # 맨 아래 출처 ≈ -0.93 (하단 댓글영역 아래)
COMMENT_SCALE = 0.89 # 댓글 카드 확대(캡컷 인스펙터 89%)
# 하단 검은 배경 자막 글꼴 크기 — 고정값(캡컷 폰트 크기와 1:1).
# 자동 맞춤(fit_caption_size)은 드래프트마다 크기가 달라져서 고정으로 바꿈.
# 청킹 하드캡이 14자라 크기 10 이면 한 줄 폭 ≈ 14×51.5 ≈ 721px < 1080 → 넘칠 일 없음.
CAPTION_SIZE = 12.0
CAPTION_COLOR = (1.0, 128 / 255, 0.0) # #ff8000 주황
# 자막 한 줄 맞춤: 캡컷 글꼴크기 ↔ Malgun Bold 픽셀 보정(size13≈67px → 5.15px/unit)
CAPTION_PX_PER_UNIT = 5.15
CAPTION_TARGET_PX = 1000 # 한 줄 목표 폭(1080 캔버스에 여유)
_MALGUN_BD = "C:/Windows/Fonts/malgunbd.ttf"
def fit_caption_size(texts: List[str], *, max_size: float = 13.0, min_size: float = 7.0) -> float:
"""가장 긴 자막도 한 줄(≤CAPTION_TARGET_PX)에 들어가는 최대 글꼴 크기.
현재 미사용 하단 자막은 CAPTION_SIZE 고정. 자동 맞춤으로 되돌릴 쓴다.
"""
texts = [t for t in texts if t]
if not texts or not os.path.exists(_MALGUN_BD):
return max_size
from PIL import ImageFont # 지연 import
def width(t: str, px: int) -> int:
bb = ImageFont.truetype(_MALGUN_BD, max(1, px)).getbbox(t)
return bb[2] - bb[0]
longest = max(texts, key=lambda t: width(t, 50))
s = max_size
while s > min_size and width(longest, round(s * CAPTION_PX_PER_UNIT)) > CAPTION_TARGET_PX:
s -= 0.5
return s
def _img_wh(path: str) -> Tuple[int, int]:
"""이미지 픽셀 크기(w, h). 댓글 카드 폭 맞춤 축소에 사용."""
from PIL import Image # 지연 import
with Image.open(path) as im:
return im.width, im.height
def build_bg_template_draft(
clip_path: str,
bg_image_path: Optional[str],
frame_image_path: str,
video_clips: List[Tuple[float, float]],
captions: List[Tuple[float, float, str]],
meta: VideoMeta,
draft_name: str,
*,
title_top: Optional[str] = None,
title_main: Optional[str] = None,
channel: Optional[str] = None,
video_y: float = BG_VIDEO_Y,
video_scale: float = 1.0,
flip_horizontal: bool = False,
title_top_y: float = BG_TITLE_TOP_Y,
title_main_y: float = BG_TITLE_MAIN_Y,
caption_y: float = BG_CAPTION_Y,
channel_y: float = BG_CHANNEL_Y,
effect_captions: Optional[List[Tuple[float, float, str]]] = None,
effect_y: Optional[float] = None,
comment_cards: Optional[List[Tuple[float, float, str]]] = None,
comment_y: Optional[float] = None,
comment_top: Optional[float] = None,
bg_white: bool = False,
canvas: Tuple[int, int] = (SHORT_W, SHORT_H),
draft_root: str = DEFAULT_DRAFT_ROOT,
) -> str:
"""움직일 수 있는 영상 + 투명 가운데 프레임(검은 띠, 위) + 텍스트.
- 영상(main): scale 1.0, transform 0. 사용자가 캡컷에서 확대/좌우 이동.
- frame(검은 +투명 가운데): 영상 영상이 영역을 침범해도 항상 깔끔.
- bg(검정-흰색-검정, 선택): 주면 아래에 깔아 곳을 흰색으로. None 이면 생략( 검정).
렌더 순서(render_index): [bg] < main < frame < text.
"""
if not video_clips:
raise ValueError("video_clips 가 비어 있습니다.")
cw, ch = canvas
has_bg = bool(bg_image_path)
# 제목 두 줄(윗줄 포인트색 + 아랫줄 흰색)은 비어도 자리표시로 항상 표시 → 캡컷에서 편집
if not title_main:
title_main = "메인제목"
if not title_top:
title_top = "서브제목"
folder = p.DraftFolder(draft_root)
script = folder.create_draft(draft_name, cw, ch, fps=meta.fps, allow_replace=True)
# 영상 타입 트랙: [bg] → main → frame(위). 텍스트는 자동으로 더 위.
if has_bg:
script.add_track(p.TrackType.video, "bg", relative_index=0)
script.add_track(p.TrackType.video, "main", relative_index=1 if has_bg else 0)
script.add_track(p.TrackType.video, "frame", relative_index=2 if has_bg else 1)
if comment_cards: # 댓글 카드(검은 띠 위) — 프레임보다 위 레이어
script.add_track(p.TrackType.video, "comment", relative_index=3 if has_bg else 2)
# 텍스트 트랙은 각각 다른 층(render_index)으로 → 겹침/누락 방지
script.add_track(p.TrackType.text, "caption", relative_index=1)
if title_top:
script.add_track(p.TrackType.text, "title_top", relative_index=2)
if title_main:
script.add_track(p.TrackType.text, "title_main", relative_index=3)
if channel:
script.add_track(p.TrackType.text, "channel", relative_index=4)
if effect_captions:
script.add_track(p.TrackType.text, "effect", relative_index=5)
# 영상 소재 로드 + 실제 소재 길이(µs)로 컷 끝 클램프
# ffprobe 가 보고한 길이가 CapCut 이 보는 소재 길이보다 살짝 길 때(마지막 프레임)
# source_timerange 가 소재 길이를 초과해 에러 나는 것 방지.
material = p.VideoMaterial(clip_path)
mat_us = getattr(material, "duration", 0) or 0
clips_us = []
for s, e in video_clips:
s_us, e_us = _us(s), _us(e)
if mat_us:
e_us = min(e_us, mat_us)
if e_us - s_us > 0:
clips_us.append((s_us, e_us))
# 타임라인 길이 = 보존 비디오 구간 합(클램프 반영)
total_us = sum(e - s for s, e in clips_us)
# 흰 배경(맨 아래, 전체 길이) — 선택
if has_bg:
bg_mat = p.VideoMaterial(bg_image_path)
script.add_segment(p.VideoSegment(bg_mat, p.Timerange(0, total_us)), "bg")
# 영상(점프컷): 보존 구간 이어붙임. 확대 + 영상영역 중앙. 캡컷에서 추가 조정 가능.
vclip = p.ClipSettings(scale_x=video_scale, scale_y=video_scale, transform_y=video_y,
flip_horizontal=flip_horizontal)
cursor_us = 0
for s_us, e_us in clips_us:
dur = e_us - s_us
script.add_segment(p.VideoSegment(
material, p.Timerange(cursor_us, dur),
source_timerange=p.Timerange(s_us, dur),
clip_settings=vclip,
), "main")
cursor_us += dur
# 자막: 검은 배경 박스 + 흰 글씨(레퍼런스 스타일), 영상 위.
# bottom 에 \n 있으면 '같은 자리·시간 분할'로 순서대로: 앞 절반 윗줄 → 뒤 절반 아랫줄.
def _cap_lines(t):
# 진짜 줄바꿈뿐 아니라 글자 그대로의 \n \r (LLM 이중 이스케이프)도 줄바꿈으로 처리
t = (t or "")
for a, b in (("\\r\\n", "\n"), ("\\n", "\n"), ("\\r", "\n"), ("\r\n", "\n"), ("\r", "\n")):
t = t.replace(a, b)
return [ln.strip() for ln in t.split("\n") if ln.strip()]
# 배경 박스 없음(background 인자 생략) + 주황 #ff8000 + 그림자(저장 후 주입).
cap_style = p.TextStyle(size=CAPTION_SIZE, bold=True, color=CAPTION_COLOR, align=1)
for ts, te, text in captions:
if _us(te) - _us(ts) <= 0 or not text:
continue
lines = _cap_lines(text)
n = len(lines)
for i, ln in enumerate(lines):
# 구간을 줄 수만큼 균등 분할 → i번째 줄이 i번째 시간대에 표시(같은 위치)
a = ts + (te - ts) * i / n
b = ts + (te - ts) * (i + 1) / n
seg = _us(b) - _us(a)
if seg <= 0:
continue
script.add_segment(p.TextSegment(
ln, p.Timerange(_us(a), seg),
style=cap_style,
clip_settings=p.ClipSettings(transform_y=caption_y),
), "caption")
# 효과 자막(중앙): 하단 자막 '바로 위'. 주황 볼드 + 검은 외곽선(배경박스 없음).
if effect_captions:
eff_y = effect_y if effect_y is not None else (caption_y + 0.11)
eff_style = p.TextStyle(size=13.0, bold=False,
color=(13/255, 255/255, 99/255), align=1) # #0dff63 녹색
eff_border = p.TextBorder(color=(0.0, 0.0, 0.0), width=18.0)
for ts, te, text in effect_captions:
dur = _us(te) - _us(ts)
if dur <= 0 or not text:
continue
script.add_segment(p.TextSegment(
text, p.Timerange(_us(ts), dur),
style=eff_style, border=eff_border,
clip_settings=p.ClipSettings(transform_y=eff_y),
), "effect")
# 프레임(상하 검은 띠 + 투명 가운데) — 영상 위, 전체 길이
frame_mat = p.VideoMaterial(frame_image_path)
script.add_segment(p.VideoSegment(frame_mat, p.Timerange(0, total_us)), "frame")
# 댓글 카드: 하단 띠에 순서대로. 확대 89% 고정, X 0.
# 세로 위치는 comment_top(윗변 픽셀)이 주어지면 **카드마다** 계산 —
# 카드 이미지 높이가 제각각이라 중앙값 하나로는 "영상 바로 아래"에 못 붙인다.
# CapCut scale 1.0 = contain. 댓글 카드는 캔버스(9:16)보다 가로로 넓으므로
# 가로가 먼저 맞아 표시 높이 = 1080 × (h/w) × scale.
if comment_cards:
for ts, te, img in comment_cards:
dur = _us(te) - _us(ts)
if dur <= 0 or not img or not os.path.isfile(img):
continue
if comment_top is not None:
iw, ih = _img_wh(img)
disp_h = cw * (ih / iw) * COMMENT_SCALE
cy = _ty(comment_top + disp_h / 2, ch)
else:
cy = comment_y if comment_y is not None else round(-1162/1920, 4)
script.add_segment(p.VideoSegment(
p.VideoMaterial(img), p.Timerange(_us(ts), dur),
clip_settings=p.ClipSettings(scale_x=COMMENT_SCALE, scale_y=COMMENT_SCALE,
transform_x=0.0, transform_y=cy),
), "comment")
full = p.Timerange(0, total_us)
# 제목 두 줄: 윗줄 = 주황 포인트색, 아랫줄 = 흰색. 둘 다 크게+볼드+검은 외곽선.
# 흰 배경일 땐: 메인제목 외곽선 두께 50, 채널 글씨 검정(안 보임 방지), 서브제목 그림자.
main_border_w = 50.0 if bg_white else 18.0
channel_color = (0.0, 0.0, 0.0) if bg_white else (1.0, 1.0, 1.0)
if title_top: # 서브제목: 주황, 크기 14
script.add_segment(p.TextSegment(
title_top, full,
style=p.TextStyle(size=14.0, bold=True, color=TITLE_ACCENT, align=1),
border=p.TextBorder(color=(0.0, 0.0, 0.0), width=18.0),
clip_settings=p.ClipSettings(transform_y=title_top_y),
), "title_top")
if title_main: # 메인제목: 흰색, 크기 18
script.add_segment(p.TextSegment(
title_main, full,
style=p.TextStyle(size=18.0, bold=True, color=(1.0, 1.0, 1.0), align=1),
border=p.TextBorder(color=(0.0, 0.0, 0.0), width=main_border_w),
clip_settings=p.ClipSettings(transform_y=title_main_y),
), "title_main")
if channel:
# 이미지 설정: 글꼴 크기 10, 가운데 (흰 배경이면 검정)
script.add_segment(p.TextSegment(
channel, full,
style=p.TextStyle(size=10.0, bold=False, color=channel_color, align=1),
clip_settings=p.ClipSettings(transform_y=channel_y),
), "channel")
script.save()
draft_dir = os.path.join(draft_root, draft_name)
_apply_font_to_texts(draft_dir, KOTRA_BOLD) # 모든 텍스트에 코트라 볼드체 주입
_apply_shadow_to_track(draft_dir, "caption", _TEXT_SHADOW) # 하단 자막 그림자
_lock_tracks(draft_dir, LOCK_TRACKS) # 오버레이·제목 트랙 잠금(편집 중 레이어 꼬임 방지)
if bg_white and title_top: # 서브제목에 그림자 주입(캡컷 실측 형식)
_apply_shadow_to_text(draft_dir, title_top, _TEXT_SHADOW)
return draft_dir
# 편집 중 잠글 트랙: CapCut에서 이 트랙들이 실수로 쪼개지면(예: 재생헤드 전체 분할)
# CapCut이 render_index 를 다시 매기다 main 영상을 frame 위로 올려버려 영상이 흰 띠·댓글
# 위로 삐져나오는 버그가 있었다. 미리 잠가 두면 분할/재배치가 막혀 레이어가 안 꼬인다.
# main(영상 편집)·caption(자막 편집)·effect 는 편집해야 하므로 잠그지 않는다.
# bg(흰 배경)도 잠그지 않는다 — 맨 아래 레이어라 꼬여도 화면에 영향이 없고,
# 영상 길이를 늘릴 때 같이 늘려야 해서 잠겨 있으면 불편하다(사용자 요청).
LOCK_TRACKS = ("frame", "comment", "title_top", "title_main", "channel")
# 비디오 트랙 이름 → 정상 render_index(아래→위). 생성 시 pycapcut 이 매기는 값과 동일.
#
# ※ 한때 frame/comment 를 비디오 대역 밖(14500/14501)으로 올리고 텍스트를 24000+ 로 미는
# 방식을 넣었다가 사용자 요청으로 **원복**했다. 의도는 "복붙 클립이 받는 max+1 이
# 비디오 대역 안에서만 계산된다면 frame 아래에 갇힌다"였다. 다시 시도하려면
# 레이어_삐짐_수리.md 를 먼저 읽을 것 — 검증되지 않은 가정이다.
CANON_RI = {"bg": 0, "main": 1, "frame": 2, "comment": 3}
def timeline_jsons(draft_dir: str):
"""이 드래프트에서 render_index 를 담고 있는 JSON 파일 전부(최근 수정순 아님).
**루트 `draft_content.json` 고치면 CapCut 반영되지 않는다.**
CapCut 9.x(`draft_meta_info.json` `draft_new_version` 164+)부터 프로젝트 실데이터가
`Timelines/<GUID>/` 아래로 옮겨갔다. 실측 근거(2026-08-03):
- `Timelines/` 폴더는 **CapCut 에서 번이라도 ** 드래프트에만 생긴다
(빌더가 만든 드래프트엔 없다 그래서 생성은 멀쩡했다).
- 드래프트 `소지섭이 올리브를(2)` 루트 `draft_content.json` **아예 없는데도**
CapCut 계속 편집 중이었다(`Timelines//template.json` 최신).
- 저장 시각도 `template.json` 항상 가장 늦다 루트는 레거시 미러.
그래서 수리는 루트 + `Timelines/*/draft_content.json` + `Timelines/*/template.json`
**전부** 고쳐야 한다. `.tmp` 저장 임시파일이라 건드리지 않는다.
"""
out = []
root = os.path.join(draft_dir, "draft_content.json")
if os.path.isfile(root):
out.append(root)
tl = os.path.join(draft_dir, "Timelines")
if os.path.isdir(tl):
for guid in sorted(os.listdir(tl)):
gdir = os.path.join(tl, guid)
if not os.path.isdir(gdir):
continue
for fn in ("draft_content.json", "template.json"):
p = os.path.join(gdir, fn)
if os.path.isfile(p):
out.append(p)
return out
def list_drafts(draft_root: str = DEFAULT_DRAFT_ROOT):
"""드래프트 목록(최근 수정순). [{name, title, path, mtime, broken, bad}].
- `name` 폴더명, `title` CapCut 홈에 보이는 프로젝트 이름, `path` 실제 폴더 경로(수리 )
- `broken`/`bad` 레이어 꼬임 여부·개수
**기본 폴더만 훑으면 된다.** CapCut 설정에서 저장 위치를 바꾸면 드래프트가
`%LOCALAPPDATA%\\CapCut\\\\com.lveditor.draft` (: `D://CapCut Drafts`) 생긴다.
실측(2026-08-03): 프로젝트 `222222222222` D 드라이브에 있어 목록에 떴다.
어디에 있든 `root_meta_info.json` `all_draft_store[].draft_fold_path` 알고 있다.
캡컷에서 이름을 바꿔도 폴더명은 그대로라 `title` `name` 갈린다
(: 화면 `열심히 하는 나경 땜걸~ 찡긋` 폴더 `열심히 하는 나경`).
"""
out, seen = [], set()
reg = _registered_drafts(draft_root) # {폴더경로: CapCut 홈에 뜨는 이름}
def add(ddir: str) -> None:
key = os.path.normcase(os.path.abspath(ddir))
if key in seen or not os.path.isdir(ddir):
return
files = timeline_jsons(ddir)
if not files:
return
seen.add(key)
bad, mtime = 0, 0.0
for jf in files:
mtime = max(mtime, os.path.getmtime(jf))
try:
# 파일마다 세는 값이 같으므로 합이 아니라 최댓값(꼬인 세그먼트 수)
bad = max(bad, _count_bad_ri(jf))
except Exception: # noqa: BLE001 — 읽기 실패한 드래프트는 목록에만 노출
bad = max(bad, -1) if bad else -1
name = os.path.basename(ddir.rstrip("\\/"))
# 이름은 CapCut 홈과 같게 — root_meta_info 가 정답이고 폴더 안 메타는 옛 이름이 남는다
title = (reg.get(key) or {}).get("name") or _draft_title(ddir) or name
out.append({"name": name, "title": title,
"path": os.path.abspath(ddir), "mtime": mtime,
"broken": bad > 0, "bad": bad})
if os.path.isdir(draft_root):
for name in os.listdir(draft_root):
add(os.path.join(draft_root, name))
for v in reg.values():
add(v["path"])
out.sort(key=lambda d: d["mtime"], reverse=True)
return out
def _registered_drafts(draft_root: str = DEFAULT_DRAFT_ROOT) -> dict:
"""`root_meta_info.json` 에 등록된 드래프트 — {대조용 키: {"path", "name"}}.
키는 `normcase(abspath(...))`(대소문자 무시 대조용)이고, 표시·접근에는 원본 `path` 쓴다.
CapCut 화면이 읽는 인덱스라 기본 경로 드래프트와 최신 이름이 여기에만 있다.
"""
import json
p = os.path.join(draft_root, "root_meta_info.json")
if not os.path.isfile(p):
return {}
try:
store = json.load(open(p, encoding="utf-8")).get("all_draft_store") or []
except Exception: # noqa: BLE001 — 인덱스가 깨져도 기본 폴더 스캔은 살린다
return {}
reg = {}
for d in store:
fp = (d or {}).get("draft_fold_path") or ""
if fp:
path = os.path.abspath(os.path.normpath(fp))
reg[os.path.normcase(path)] = {"path": path,
"name": (d.get("draft_name") or "").strip()}
return reg
def _draft_title(draft_dir: str) -> str:
"""CapCut 홈에 보이는 프로젝트 이름(`draft_meta_info.json` 의 `draft_name`)."""
import json
mi = os.path.join(draft_dir, "draft_meta_info.json")
if not os.path.isfile(mi):
return ""
try:
return (json.load(open(mi, encoding="utf-8")).get("draft_name") or "").strip()
except Exception: # noqa: BLE001 — 이름 못 읽으면 폴더명으로 대체
return ""
def _count_bad_ri(json_path: str) -> int:
"""render_index 가 트랙 정상값과 다른 비디오 세그먼트 수."""
import json
j = json.load(open(json_path, encoding="utf-8"))
n = 0
for tr in j.get("tracks", []):
if tr.get("type") != "video":
continue
want = CANON_RI.get(tr.get("name"))
if want is None:
continue
n += sum(1 for s in tr.get("segments", []) if s.get("render_index") != want)
return n
def repair_layers(draft_dir: str) -> dict:
"""레이어 수리: 비디오 세그먼트 render_index 를 트랙별 정상값으로 되돌리고 재잠금.
필요한가: CapCut 에서 main 클립을 옮기면(드래그/잘라붙이기) CapCut 세그먼트에
`현재 최대 render_index + 1` 새로 찍는다. 그러면 클립만 frame( )·comment(댓글)
위로 올라가서 확대 템플릿 밖으로 삐져나온다. 트랙 잠금으로는 막는다
(main 편집해야 하므로 잠그지 않음). 사후 수리가 유일한 확실한 방법.
CapCut 에서 해당 프로젝트를 '닫은 상태' 실행할 . 열어둔 수리하면 CapCut
메모리 상태로 다시 덮어쓴다.
백업은 `<파일명>.repair.bak` `.bak` CapCut 자체 백업 파일명이라 쓰면 된다.
루트 파일만이 아니라 `Timelines/*` 사본까지 전부 고친다(`timeline_jsons` 주석 참고).
Returns: {"fixed": 고친 세그먼트 , "detail": {트랙명: 개수}, "files": 고친 파일 }
"""
files = timeline_jsons(draft_dir)
if not files:
raise FileNotFoundError(f"타임라인 JSON 없음(draft_content.json / Timelines): {draft_dir}")
fixed, detail, patched = 0, {}, 0
for jf in files:
n, d = _repair_one(jf)
if n:
patched += 1
# 파일마다 같은 내용이므로 합이 아니라 최댓값을 대표값으로 쓴다
if n > fixed:
fixed, detail = n, d
_lock_tracks(draft_dir, LOCK_TRACKS) # 편집 중 풀린 잠금도 다시 채움
return {"fixed": fixed, "detail": detail, "files": patched}
def _repair_one(json_path: str):
"""타임라인 JSON 한 개의 비디오 render_index 를 정상값으로 되돌린다.
Returns: (고친 세그먼트 , {트랙명: 개수}). 고칠 없으면 파일을 건드리지 않는다(멱등).
"""
import json
import shutil
j = json.load(open(json_path, encoding="utf-8"))
fixed, detail = 0, {}
# 알려진 트랙은 고정값, 그 외 사용자가 추가한 비디오 트랙은 그 위(4, 5 …)로 밀어 유지
extra = max(CANON_RI.values()) + 1
for tr in j.get("tracks", []):
if tr.get("type") != "video":
continue
name = tr.get("name")
want = CANON_RI.get(name)
if want is None: # 사용자가 추가한 오버레이 트랙 → 맨 위 유지
want, extra = extra, extra + 1
n = 0
for seg in tr.get("segments", []):
if seg.get("render_index") != want:
seg["render_index"] = want
n += 1
if n:
detail[name or "(이름없음)"] = n
fixed += n
if fixed:
# CapCut 이 draft_content.json.bak 을 자기 백업으로 쓰므로 다른 이름을 쓴다
shutil.copyfile(json_path, os.path.splitext(json_path)[0] + ".repair.bak")
with open(json_path, "w", encoding="utf-8") as f:
json.dump(j, f, ensure_ascii=False)
return fixed, detail
def _lock_tracks(draft_dir: str, names) -> None:
"""지정 트랙을 잠금 처리 — 루트 + `Timelines/*` 사본 전부.
잠금은 트랙 `attribute` 비트4(=4). mute 비트(1) 보존(OR). CapCut 실측 확인값.
"""
import json
nameset = set(names)
for jf in timeline_jsons(draft_dir):
try:
j = json.load(open(jf, encoding="utf-8"))
except Exception: # noqa: BLE001 — 깨진 사본 하나 때문에 전체를 실패시키지 않는다
continue
changed = False
for tr in j.get("tracks", []):
if tr.get("name") in nameset:
attr = (tr.get("attribute") or 0) | 4
if attr != tr.get("attribute"):
tr["attribute"], changed = attr, True
if changed:
with open(jf, "w", encoding="utf-8") as f:
json.dump(j, f, ensure_ascii=False)
# 코트라 볼드체(KOTRA_BOLD) — CapCut 폰트 캐시. pycapcut FontType엔 없어 JSON에 직접 주입.
# 경로는 사용자명에 안 묶이게 LOCALAPPDATA 기반(다른 PC에서도 동작). 단 그 PC CapCut에
# 코트라 볼드체가 한 번 다운로드돼 캐시가 있어야 함(없으면 주입 생략 → 기본 폰트).
KOTRA_BOLD = {
"path": os.path.join(
os.environ.get("LOCALAPPDATA", ""), "CapCut", "User Data", "Cache", "effect",
"7480846567709265157", "782a91b14f1661b95e7e587be27f1af4", "font.ttf",
).replace("\\", "/"),
"id": "7480846567709265157",
}
def _apply_font_to_texts(draft_dir: str, font: dict) -> None:
"""저장된 draft_content.json 의 모든 텍스트 재질 스타일에 폰트 주입.
폰트 캐시 파일이 없으면(다른 PC에 코트라체 미설치 ) 주입 생략 기본 폰트로 안전 동작.
"""
import json
if not font.get("path") or not os.path.isfile(font["path"]):
return
jf = os.path.join(draft_dir, "draft_content.json")
j = json.load(open(jf, encoding="utf-8"))
for m in j["materials"].get("texts", []):
try:
c = json.loads(m["content"])
except Exception:
continue
for st in c.get("styles", []):
st["font"] = dict(font)
m["content"] = json.dumps(c, ensure_ascii=False)
with open(jf, "w", encoding="utf-8") as f:
json.dump(j, f, ensure_ascii=False)
# 텍스트 그림자 — CapCut 실측 형식(색 검정, 불투명도 90%, 흐림 15%, 거리 5, 각도 -45).
# 제목·하단 자막 공용. 반드시 _SHADOW_MATERIAL 과 **같이** 넣어야 실제로 켜진다.
_TEXT_SHADOW = {
"thickness_projection_angle": -45,
"thickness_projection_enable": False,
"diffuse": 0.025,
"alpha": 0.9,
"distance": 5.0,
"content": {"render_type": "solid", "solid": {"color": [0, 0, 0]}},
"angle": -45,
"thickness_projection_distance": 0,
}
# 그림자 켤 때 **소재 레벨**에도 같이 박아야 하는 값 — CapCut UI 로 켠 자막에서 실측.
# ⚠ `styles[].shadows` 만 넣으면 CapCut 이 그림자를 안 켠다(`has_shadow=False` 라서).
# 기존 제목 그림자 주입이 딱 이 상태였다 — JSON 엔 있는데 화면엔 안 나옴.
_SHADOW_MATERIAL = {
"has_shadow": True,
"shadow_alpha": 0.8999999761581421,
"shadow_angle": -45.0,
"shadow_color": "#000000",
"shadow_distance": 5.0,
"shadow_point": {"x": 0.6363961030678928, "y": -0.6363961030678928},
"shadow_smoothing": 0.45000001788139343,
"shadow_thickness_projection_angle": 0.0,
"shadow_thickness_projection_distance": 0.0,
"shadow_thickness_projection_enable": False,
}
def _apply_shadow_to_track(draft_dir: str, track_name: str, shadow: dict) -> None:
"""지정 텍스트 트랙의 모든 소재에 그림자 주입(소재 플래그 + styles[].shadows).
자막은 세그먼트가 수십 개라 텍스트 값으로 찾는 방식(_apply_shadow_to_text) 쓴다
같은 문장이 여러 나올 있어서. 트랙 material_id 잡는다.
"""
import json
jf = os.path.join(draft_dir, "draft_content.json")
if not os.path.isfile(jf):
return
j = json.load(open(jf, encoding="utf-8"))
ids = set()
for tr in j.get("tracks", []):
if tr.get("type") == "text" and tr.get("name") == track_name:
ids.update(s.get("material_id") for s in tr.get("segments", []))
if not ids:
return
for m in j["materials"].get("texts", []):
if m.get("id") not in ids:
continue
m.update(_SHADOW_MATERIAL)
try:
c = json.loads(m["content"])
except Exception: # noqa: BLE001 — 파싱 안 되는 소재는 건너뜀
continue
for st in c.get("styles", []):
st["shadows"] = [dict(shadow)]
m["content"] = json.dumps(c, ensure_ascii=False)
with open(jf, "w", encoding="utf-8") as f:
json.dump(j, f, ensure_ascii=False)
def _apply_shadow_to_text(draft_dir: str, text_value: str, shadow: dict) -> None:
"""draft_content.json 에서 text_value 와 일치하는 텍스트 소재 스타일에 그림자 주입."""
import json
jf = os.path.join(draft_dir, "draft_content.json")
j = json.load(open(jf, encoding="utf-8"))
target = (text_value or "").strip()
for m in j["materials"].get("texts", []):
try:
c = json.loads(m["content"])
except Exception:
continue
if (c.get("text") or "").strip() != target:
continue
m.update(_SHADOW_MATERIAL) # 소재 플래그 없으면 CapCut 이 그림자를 안 켠다
for st in c.get("styles", []):
st["shadows"] = [dict(shadow)]
m["content"] = json.dumps(c, ensure_ascii=False)
with open(jf, "w", encoding="utf-8") as f:
json.dump(j, f, ensure_ascii=False)
def build_template_draft(
video_path: str,
clips: List[Clip],
meta: VideoMeta,
draft_name: str,
*,
title_top: Optional[str] = None,
title_main: Optional[str] = None,
channel: Optional[str] = None,
draft_root: str = DEFAULT_DRAFT_ROOT,
) -> str:
"""템플릿 드래프트: 굽힌 영상(scale 1.0) + 편집가능 제목(2줄)·자막·채널 텍스트.
영상 /검은 띠는 media.to_template 이미 구워져 있고(1080×1920), 여기서는
위에 캡컷에서 수정 가능한 텍스트만 올린다. 텍스트는 시간 겹침을 피해 트랙 분리:
title_top / title_main / caption / channel.
"""
if not clips:
raise ValueError("clips 가 비어 있습니다.")
folder = p.DraftFolder(draft_root)
script = folder.create_draft(draft_name, meta.width, meta.height, fps=meta.fps,
allow_replace=True)
script.add_track(p.TrackType.video)
script.add_track(p.TrackType.text, "caption")
if title_top:
script.add_track(p.TrackType.text, "title_top")
if title_main:
script.add_track(p.TrackType.text, "title_main")
if channel:
script.add_track(p.TrackType.text, "channel")
material = p.VideoMaterial(video_path)
vclip = p.ClipSettings(scale_x=1.0, scale_y=1.0) # 이미 9:16 → 정확 일치
# 스타일 (편안한 톤: 흰 본문 + 검은 외곽선). 크기/색은 캡컷에서 미세조정 가능.
cap_style = p.TextStyle(size=13.0, bold=True, color=(1.0, 1.0, 1.0), align=1)
cap_border = p.TextBorder(color=(0.0, 0.0, 0.0), width=40.0)
cursor_us = 0
total_us = 0
for c in clips:
s, e = c[0], c[1]
text = c[2] if len(c) > 2 else None
dur = _us(e) - _us(s)
if dur <= 0:
continue
target = p.Timerange(cursor_us, dur)
script.add_segment(p.VideoSegment(
material, target, source_timerange=p.Timerange(_us(s), dur),
clip_settings=vclip,
))
if text:
script.add_segment(p.TextSegment(
text, p.Timerange(cursor_us, dur),
style=cap_style, border=cap_border,
clip_settings=p.ClipSettings(transform_y=TPL_CAPTION_Y),
), "caption")
cursor_us += dur
total_us = cursor_us
full = p.Timerange(0, total_us)
# 제목(2줄) — 편안한 톤. 윗줄 작게/연하게, 아랫줄 크게.
if title_top:
script.add_segment(p.TextSegment(
title_top, full,
style=p.TextStyle(size=8.0, bold=False, color=(0.92, 0.92, 0.92), align=1),
clip_settings=p.ClipSettings(transform_y=TPL_TITLE_TOP_Y),
), "title_top")
if title_main:
script.add_segment(p.TextSegment(
title_main, full,
style=p.TextStyle(size=16.0, bold=True, color=(1.0, 1.0, 1.0), align=1),
border=p.TextBorder(color=(0.0, 0.0, 0.0), width=20.0),
clip_settings=p.ClipSettings(transform_y=TPL_TITLE_MAIN_Y),
), "title_main")
if channel:
script.add_segment(p.TextSegment(
channel, full,
style=p.TextStyle(size=6.0, bold=False, color=(0.8, 0.8, 0.8), align=1),
clip_settings=p.ClipSettings(transform_y=TPL_CHANNEL_Y),
), "channel")
script.save()
return os.path.join(draft_root, draft_name)
def build_shortform_draft(
video_path: str,
clips: List[Clip],
meta: VideoMeta,
draft_name: str,
*,
draft_root: str = DEFAULT_DRAFT_ROOT,
canvas: Tuple[int, int] = (SHORT_W, SHORT_H),
captions: bool = True,
) -> str:
"""하이라이트 구간 clip 들을 이어붙인 9:16 중앙크롭 숏폼 드래프트.
clips: (src_start_sec, src_end_sec, text|None) 리스트. text 있고 captions=True
해당 클립 구간에 자막( 글자 + 검은 외곽선, 하단 중앙) 번인.
점프컷과 동일하게 구간만 연결, 캔버스 세로 + 비디오에 center-crop.
자막은 별도 text 트랙에 클립 타임라인에 맞춰 배치 자동 싱크.
"""
if not clips:
raise ValueError("clips 가 비어 있습니다.")
cw, ch = canvas
scale = _cover_scale(meta.width, meta.height, cw, ch)
has_text = captions and any(len(c) > 2 and c[2] for c in clips)
folder = p.DraftFolder(draft_root)
script = folder.create_draft(draft_name, cw, ch, fps=meta.fps, allow_replace=True)
script.add_track(p.TrackType.video)
if has_text:
script.add_track(p.TrackType.text)
material = p.VideoMaterial(video_path)
vclip = p.ClipSettings(scale_x=scale, scale_y=scale, transform_x=0.0, transform_y=0.0)
cap_style = p.TextStyle(size=12.0, bold=True, color=(1.0, 1.0, 1.0), align=1)
cap_border = p.TextBorder(color=(0.0, 0.0, 0.0), width=40.0)
cap_clip = p.ClipSettings(transform_y=CAPTION_Y)
cursor_us = 0
for c in clips:
s, e = c[0], c[1]
text = c[2] if len(c) > 2 else None
src_start = _us(s)
dur = _us(e) - src_start
if dur <= 0:
continue
target = p.Timerange(cursor_us, dur)
script.add_segment(p.VideoSegment(
material, target, source_timerange=p.Timerange(src_start, dur),
clip_settings=vclip,
))
if has_text and text:
script.add_segment(p.TextSegment(
text, p.Timerange(cursor_us, dur),
style=cap_style, border=cap_border, clip_settings=cap_clip,
))
cursor_us += dur
script.save()
return os.path.join(draft_root, draft_name)

404
capcut_agent/highlight.py Normal file
View File

@ -0,0 +1,404 @@
"""하이라이트 선택 보조 — 전사(Transcript) 기반.
v1 정책: 어떤 구간이 '하이라이트'인지는 에이전트(Claude) 전체 대본을 읽고 판단한다.
모듈은 판단을 돕고(대본 포맷팅), 선택된 시간 윈도우를 숏폼 clip 리스트로
변환한다(세그먼트 단위 + 자막 동기화).
"""
from __future__ import annotations
import re
from typing import List, Tuple
from .draft import Clip
from .transcribe import Transcript
def clean_caption(t: str) -> str:
"""들리는 대로 유지(사투리 더듬·발음·반복 살림). 외국문자 환각·잡음만 제거.
한글/숫자/기본 문장부호만 남김 러시아어·아랍어 환각 토큰 제거. 단어 반복·
장음("코, 코, 코스에에", "야호오오") 그대로 (비정상 8+ 장음만 살짝 ).
"""
t = re.sub(r"[^가-힣ㄱ-ㅎㅏ-ㅣ0-9\s.,!?~…]", "", t) # 한글(자모포함)/숫자/부호만
t = re.sub(r"(.)\1{7,}", lambda m: m.group(1) * 4, t) # 8+ 반복만 캡
return re.sub(r"\s+", " ", t).strip(" .,")
def _fmt_ts(sec: float) -> str:
m, s = divmod(int(sec), 60)
return f"{m:02d}:{s:02d}"
def format_for_selection(transcript: Transcript, *, merge_gap: float = 0.0) -> str:
"""대본을 '[mm:ss] 텍스트' 줄들로 포맷 — 에이전트가 읽고 하이라이트 고를 용도."""
lines = []
for s in transcript.segments:
lines.append(f"[{_fmt_ts(s.start)}] {s.text}")
return "\n".join(lines)
def clips_in_window(
transcript: Transcript,
win_start: float,
win_end: float,
*,
pad: float = 0.05,
) -> List[Clip]:
"""[win_start, win_end] 와 겹치는 전사 세그먼트를 clip(s,e,text) 으로 변환.
세그먼트 단위라 발화 사이 무음은 자연히 잘려 숏폼이 타이트해지고, 세그먼트
텍스트가 그대로 자막이 되어 싱크가 맞는다. pad 살짝 여유.
"""
clips: List[Clip] = []
for seg in transcript.segments:
s = max(seg.start, win_start)
e = min(seg.end, win_end)
if e - s <= 0.05:
continue
s = max(0.0, s - pad)
e = e + pad
clips.append((s, e, seg.text))
return clips
def window_bounds(transcript: Transcript, win_start: float, win_end: float) -> Tuple[float, float]:
"""윈도우 내 실제 발화의 시작/끝(앞뒤 무음 트림용)."""
segs = [g for g in transcript.segments if g.end > win_start and g.start < win_end]
if not segs:
return win_start, win_end
return max(win_start, segs[0].start), min(win_end, segs[-1].end)
def _video_clips_offsets(keep_segments):
"""보존 구간 → (video_clips, offsets[(s,e,new_off)], total)."""
video_clips, offsets, cum = [], [], 0.0
for s, e in sorted(keep_segments):
if e - s <= 0:
continue
offsets.append((s, e, cum))
video_clips.append((s, e))
cum += e - s
return video_clips, offsets, cum
def captions_from_segments(
keep_segments: List[Tuple[float, float]],
segments: List[Tuple[float, float, str]],
*,
max_chars: int = 14, # Gemini가 의미단위로 끊어주므로 그 길이까진 그대로 둠
):
"""Gemini 받아쓰기 세그먼트(원본 시간) → 컷 타임라인 자막. 긴 건 max_chars로 분할.
타임스탬프 검증: 보존 구간 (잘린 무음/영상 길이 초과) 버림 Gemini의 가끔 틀린
타임스탬프(영상 길이 초과 ) 자동 제거.
"""
video_clips, offsets, total = _video_clips_offsets(keep_segments)
def map_t(t):
for s, e, off in offsets:
if s - 0.05 <= t <= e + 0.05:
return off + (min(max(t, s), e) - s)
return None
caps = []
for s, e, text in segments:
text = clean_caption(text)
if not text:
continue
ns, ne = map_t(s), map_t(e)
if ns is None or ne is None: # 보존 구간 밖 → 버림(타임스탬프 검증)
continue
if ne <= ns:
ne = ns + 0.3
if len(text) <= max_chars:
caps.append((ns, ne, text))
continue
# 길면 공백 기준 청크 + 시간 비례 분배
chunks, cur = [], ""
for w in text.split(" "):
if cur and len(cur) + 1 + len(w) > max_chars:
chunks.append(cur)
cur = w
else:
cur = (cur + " " + w).strip()
if cur:
chunks.append(cur)
span = ne - ns
totch = sum(len(c) for c in chunks) or 1
t0 = ns
for c in chunks:
t1 = t0 + span * len(c) / totch
caps.append((t0, t1, c))
t0 = t1
caps.sort()
fixed = []
for ns, ne, t in caps: # 겹침 제거
if fixed and ns < fixed[-1][1]:
ns = fixed[-1][1]
if ne <= ns:
ne = ns + 0.2
fixed.append((ns, ne, t))
return video_clips, fixed, total
def _norm_hangul(s: str) -> str:
"""정렬용 정규화 — 한글/숫자만(공백·부호 제거)."""
return re.sub(r"[^가-힣0-9]", "", s)
def align_gemini_to_whisper(
gemini_segs: List[Tuple[float, float, str]],
transcript: Transcript,
) -> List[Tuple[float, float, str]]:
"""Gemini 글자 + Whisper 타이밍 하이브리드.
Gemini 텍스트(품질 , 타임스탬프 ) Whisper의 실제 단어 타임스탬프(정확)
글자수 기준으로 순차 정렬. 세그먼트마다 실제 단어 start/end로 -앵커링
드리프트가 누적되지 않음. Whisper 단어가 없으면 Gemini 원본 시간 유지(폴백).
Returns: [(원본시작, 원본끝, Gemini텍스트)] 원본(클립) 시간 기준.
이후 captions_from_segments 타임라인 매핑.
"""
words: List[Tuple[float, float, str]] = []
for seg in transcript.segments:
for w in (seg.words or []):
nt = _norm_hangul(w.text)
if nt and w.end > w.start:
words.append((w.start, w.end, nt))
words.sort()
if not words:
return gemini_segs # 폴백: Whisper 단어 없음 → Gemini 타임스탬프 그대로
retimed: List[Tuple[float, float, str]] = []
i, n = 0, len(words)
for gs, ge, text in gemini_segs:
target = len(_norm_hangul(text))
if target == 0 or i >= n:
retimed.append((gs, ge, text)) # 남은 단어 없음/빈 텍스트 → 원본 유지
continue
start_t = words[i][0]
acc, last_e = 0, words[i][1]
while i < n and acc < target:
acc += len(words[i][2])
last_e = words[i][1]
i += 1
retimed.append((start_t, last_e, text))
return retimed
# ── 자막 줄바꿈(청킹) 규칙 ─────────────────────────────────────────────
# 문제: 앞에서부터 12자 차면 무조건 끊는 방식이면 "어쩌구 예를 / 들어 어떻게",
# "제가 또 유용하게 쓸 / 수 있잖아요" 처럼 한 덩어리인 말이 두 자막으로 갈린다.
# 해결: 무음으로 나눈 덩어리 안에서 '어디서 끊을지'를 DP로 한 번에 고른다.
# ★ 단어 타임스탬프는 절대 건드리지 않는다 — 묶는 방법만 고르므로 싱크 불변.
# 새 줄을 이 어절로 시작하면 어색한 것들(의존명사·보조용언·연어의 뒷부분).
# 앞줄에 붙어야 말이 된다 → 이 앞에서 끊으면 큰 벌점.
BOUND_WORDS = frozenset("""
들어 들면 들자면 만큼 대로 듯이 가지
정도 밖에 만에 나름 김에 무렵 마련 따름 나위
때문에 때문이 위해 위한 대해 대한 통해 관해 비해 불구하고 아니라 아니고 아니면
보다 봐도 있어 있어요 있는 있다 있고 있을 있었 있잖아요 있습니다
없어 없어요 없는 없다 없고 없을 없었 없잖아요 없습니다
같아 같아요 같은 같이 같다 만하다 버렸 놓고 주세요 드려요
합니다 해요 한다 하는 하고 해서 하죠 하잖아요
""".split())
# 의존명사 어간 + 조사 조합("번도", "적이", "것을", "때가" …)도 같이 잡는다.
# 어간만으로는 "한 / 번도 없습니다" 처럼 조사가 붙은 형태를 놓친다.
_BOUND_STEMS = frozenset("""
가지 정도 만큼 대로 무렵
""".split())
_PARTICLES = ("", "", "", "", "", "", "", "", "", "에서", "으로", "",
"", "", "", "", "", "이라", "라도", "이라도", "조차", "까지", "부터",
"마다", "밖에", "", "이나", "처럼", "보다", "대로", "")
# 어간+조사로 분해되지만 실제로는 홀로 쓰는 말 → 예외
_BOUND_EXC = frozenset("거의 등등 게요 거야 게임 개월 분들 분들이 분들은".split())
# 뒤에 반드시 명사가 오는 관형사 — 여기서 줄을 끊으면 "한 / 번도" 가 된다.
# ※ "네"(=예), "세"(세다) 처럼 흔한 동음이의어는 오탐이 커서 제외.
_DETERMINERS = frozenset("한 두 몇 여러 각 온 전 어떤 무슨 웬 딴 새".split())
# 관형사형 어미(-는/-던/-ㄹ받침)로 끝나는 어절 → 뒤에 반드시 꾸밀 말이 온다.
# ("당황하는 / 것 같은데", "제가 또 유용하게 쓸 / 수 있잖아요")
_ADNOM_TAIL = ("", "")
_ADNOM_SKIP = frozenset("나는 너는 저는 그는 우린 우리는 저희는 얘는 걔는 쟤는 이는".split())
# 문장이 끝나는 자리 = 좋은 끊김
_ENDING_TAIL = ("", "", "", "", "", "", "", "", "", "", "", "",
"지만", "니까", "는데", "어서", "아서")
def _tok(w: str) -> str:
"""벌점 판정용 어절 정규화 — 앞뒤 공백·문장부호 제거."""
return w.strip(" .,!?~…\"'“”")
def _is_adnominal(w: str) -> bool:
"""관형사형(수식어)으로 끝나는 어절인가. 뒤에 명사가 와야 하므로 끊으면 안 됨."""
t = _tok(w)
if len(t) < 2 or t in _ADNOM_SKIP:
return False
if t.endswith(("습니다", "니다", "어요", "아요", "해요", "예요", "이에요")):
return False
if t.endswith(_ADNOM_TAIL):
return True
# 받침 ㄹ (쓸·할·볼·만들…) — 조사 '-을/-를'(밥을·책을)은 제외
last = t[-1]
if t.endswith(("", "", "", "", "")):
return False
return "" <= last <= "" and (ord(last) - 0xAC00) % 28 == 8
def _is_bound(w: str) -> bool:
"""줄 첫머리에 오면 안 되는 어절인가(의존명사·보조용언·연어 뒷부분)."""
t = _tok(w)
if not t:
return False
if t in BOUND_WORDS:
return True
if t in _BOUND_EXC:
return False
for k in (1, 2): # 의존명사 어간(1~2글자) + 조사
if len(t) > k and t[:k] in _BOUND_STEMS and t[k:] in _PARTICLES:
return True
return False
def _break_cost(prev_w: str, next_w: str) -> int:
"""prev_w 와 next_w 사이에서 줄을 끊을 때의 벌점(작을수록 좋은 자리)."""
cost = 0
if _is_bound(next_w):
cost += 60 # "예를 / 들어", "쓸 / 수 있잖아요", "한 / 번도"
if _is_adnominal(prev_w) or _tok(prev_w) in _DETERMINERS:
cost += 50 # "당황하는 / 것 같은데", "한 / 번도"
p = prev_w.strip()
if p.endswith((".", "?", "!")):
cost -= 25 # 문장 끝 = 가장 좋은 자리
elif p.endswith(","):
cost -= 12
elif _tok(p).endswith(_ENDING_TAIL):
cost -= 15 # 어미로 끝남 = 좋은 자리
return cost
def _chunk_words(
words: List[Tuple[float, float, str]],
*,
target_chars: int,
max_chars: int,
max_dur: float,
) -> List[Tuple[float, float, str]]:
"""한 덩어리(무음 사이)의 단어들을 자막 줄로 최적 분할.
비용 = Σ (줄길이 - target_chars)² + 끊는 자리 벌점 최소화하는 줄바꿈을
DP로 고른다(문단 조판과 같은 방식). 어절 수² 실질 비용은 무시할 수준.
"""
n = len(words)
if n == 0:
return []
raw = [w[2] for w in words]
INF = float("inf")
cost = [INF] * (n + 1)
back = [0] * (n + 1)
cost[0] = 0.0
for j in range(1, n + 1):
line_txt = ""
for i in range(j - 1, -1, -1):
# 화면에 실제로 보일 문자열로 길이를 잰다(부호 포함, 최종 자막과 동일)
line_txt = raw[i] + line_txt
line_len = len(clean_caption(line_txt))
single = (j - i == 1)
if not single:
if line_len > max_chars:
break # 더 길어지기만 하므로 중단
if words[j - 1][1] - words[i][0] > max_dur:
break
if cost[i] == INF:
continue
c = cost[i] + (line_len - target_chars) ** 2
if j < n: # 마지막 줄 뒤는 끊는 게 아님
c += _break_cost(raw[j - 1], raw[j])
if c < cost[j]:
cost[j] = c
back[j] = i
lines: List[Tuple[float, float, str]] = []
j = n
while j > 0:
i = back[j]
lines.append((words[i][0], words[j - 1][1], clean_caption("".join(raw[i:j]))))
j = i
lines.reverse()
return lines
def cut_plan(
keep_segments: List[Tuple[float, float]],
transcript: Transcript,
*,
max_chars: int = 14,
target_chars: int = 10,
max_dur: float = 2.8,
max_gap: float = 0.6,
):
"""오디오 기준 보존 구간(keep_segments) + 전사 → 비디오 컷 & 자막 배치 계획.
컷은 '실제 오디오 무음'(silence.detect_speech_segments)으로 정한다 VAD가 놓치는
짧은 외침("거제! 야호!") 오디오가 있으면 보존됨.
자막은 단어 타임스탬프 기준으로 청크화. 어디서 끊을지는 _chunk_words(DP) 고른다
길이는 target_chars 근처로 고르게, "예를/들어" 같이 붙어야 말은 갈라짐.
Returns:
video_clips: [(src_start, src_end)] 보존 구간(타임라인에 순서대로 이어붙임)
captions: [(target_start, target_end, text)] 타임라인 기준 자막
total: 길이()
"""
regions = sorted(keep_segments)
video_clips: List[Tuple[float, float]] = []
offsets: List[Tuple[float, float, float]] = [] # (src_s, src_e, new_offset)
cum = 0.0
for s, e in regions:
if e - s <= 0:
continue
offsets.append((s, e, cum))
video_clips.append((s, e))
cum += e - s
total = cum
def map_t(t: float):
for s, e, off in offsets:
if s - 0.02 <= t <= e + 0.02:
return off + (min(max(t, s), e) - s)
return None # 잘려나간(무음) 구간
# 단어를 새 타임라인으로 매핑(보존 구간 안의 단어만)
words: List[Tuple[float, float, str]] = []
for seg in transcript.segments:
for w in (seg.words or []):
ns, ne = map_t(w.start), map_t(w.end)
if ns is None or ne is None or not w.text.strip():
continue
words.append((ns, ne, w.text))
words.sort()
# max_gap 이상 쉬면 다른 덩어리(문장) → 덩어리마다 DP로 줄바꿈 최적화
captions: List[Tuple[float, float, str]] = []
group: List[Tuple[float, float, str]] = []
def flush():
if not group:
return
for ns, ne, text in _chunk_words(group, target_chars=target_chars,
max_chars=max_chars, max_dur=max_dur):
if len(text) >= 2: # 1글자 잔챙이 제외
captions.append((ns, ne, text))
for w in words:
# 쉼이 길거나 덩어리가 너무 커지면(DP 비용 방어) 끊는다
if group and (w[0] - group[-1][1] > max_gap or len(group) >= 120):
flush()
group = []
group.append(w)
flush()
return video_clips, captions, total

171
capcut_agent/media.py Normal file
View File

@ -0,0 +1,171 @@
"""ffmpeg 미디어 변환 — 레터박스(9:16 검은 띠) 굽기.
핵심: 캡컷 scale/transform 의미 추측(함정) 피하려 영상을 미리 정확한 캔버스
비율로 구워둔다. 결과 mp4 이미 9:16 캡컷에서 scale 1.0 으로 정확히 일치.
부수효과: AV1/webm h264 재인코딩으로 캡컷 미리보기 문제도 해소.
"""
from __future__ import annotations
import os
import subprocess
from typing import Tuple
def make_frame(out_png: str, *, top_bar: int, bottom_top: int,
band_color: Tuple[int, int, int] = (0, 0, 0),
canvas: Tuple[int, int] = (1080, 1920)) -> str:
"""명시적 좌표로 프레임 생성: 상단 띠(0~top_bar) + 투명 가운데 + 하단 띠(bottom_top~H).
하단 띠를 크게(bottom_top 낮게) 잡으면 영상이 위로 올라가고 하단에 댓글 캡쳐용
공간이 생긴다. 띠는 band_color 불투명, 가운데는 투명(영상이 비침).
배경.png 흰밴드 자동감지(make_transparent_frame) 대신 이걸 쓰면 레이아웃을
코드 상수로 정확히 통제할 있다.
"""
from PIL import Image # 지연 import
W, H = canvas
out = Image.new("RGBA", (W, H), (0, 0, 0, 0))
band = (*band_color, 255)
px = out.load()
for y in range(H):
if y < top_bar or y >= bottom_top:
for x in range(W):
px[x, y] = band
out.save(out_png)
return out_png
def extract_audio(src: str, out_path: str) -> str:
"""오디오만 압축 mp3로 추출 (Gemini 받아쓰기 전송용, mono 16k 48k)."""
subprocess.run(
["ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", src,
"-vn", "-ac", "1", "-ar", "16000", "-b:a", "48k", out_path],
check=True, encoding="utf-8", errors="replace",
)
return out_path
def detect_white_band(template_png: str) -> Tuple[int, int]:
"""검정-흰색-검정 템플릿에서 흰 밴드(영상 영역) [top, bottom] 픽셀 반환."""
from PIL import Image # 지연 import
im = Image.open(template_png).convert("RGB")
W, H = im.size
x = W // 2
whites = [y for y in range(H)
if min(im.getpixel((x, y))) > 200]
if not whites:
return 0, H
return whites[0], whites[-1] + 1
def make_transparent_frame(template_png: str, out_png: str,
band_color: Tuple[int, int, int] = (0, 0, 0)) -> str:
"""검정-흰색-검정 배경 템플릿 → 상하 띠는 band_color 로, 가운데(흰색)는 투명으로.
영상 위에 '프레임'으로 올려 상하 띠로 가리고 가운데는 영상이 비치게 한다.
band_color=(0,0,0) 검은 (기본), (255,255,255) .
흰색 밴드 경계를 자동 검출(중앙 컬럼 스캔).
"""
from PIL import Image # 지연 import
im = Image.open(template_png).convert("RGB")
W, H = im.size
x = W // 2
def is_white(y: int) -> bool:
r, g, b = im.getpixel((x, y))
return r > 200 and g > 200 and b > 200
whites = [y for y in range(H) if is_white(y)]
if whites:
top, bot = whites[0], whites[-1] + 1 # 흰색 밴드 = 투명 처리 구간
else:
top, bot = 0, H
r, g, b = band_color
out = Image.new("RGBA", (W, H), (0, 0, 0, 0))
px = out.load()
for y in range(H):
opaque = (y < top) or (y >= bot)
if not opaque:
continue
for xx in range(W):
px[xx, y] = (r, g, b, 255)
out.save(out_png)
return out_png
def make_solid(out_png: str, color: Tuple[int, int, int] = (255, 255, 255),
canvas: Tuple[int, int] = (1080, 1920)) -> str:
"""단색 전체 캔버스 PNG 생성(흰 배경 레이어 등). 가운데 빈 곳까지 그 색으로."""
from PIL import Image # 지연 import
W, H = canvas
Image.new("RGB", (W, H), color).save(out_png)
return out_png
# ── 템플릿 레이아웃 (1080×1920) ──
# 상단 제목 띠 | 영상(좌우 살짝 크롭, 크게) | 하단 채널 띠
TPL_W, TPL_H = 1080, 1920
TPL_TOP_BAR = 410 # 상단 검은 띠(제목)
TPL_VIDEO_H = 1000 # 영상 영역 높이 (410~1410)
# 하단 띠 = 1920 - 410 - 1000 = 510 (채널/출처)
TPL_VIDEO_TOP = TPL_TOP_BAR
TPL_VIDEO_BOTTOM = TPL_TOP_BAR + TPL_VIDEO_H
def to_template(
src: str,
out_path: str,
*,
crf: int = 20,
) -> str:
"""영상을 템플릿 비디오 영역(1080×1000, 좌우 cover 크롭)에 채우고 상하 검은 띠로
1080×1920 합성. 영상 틀만 굽고(제목/자막/채널은 캡컷 텍스트로 별도), scale 함정 회피.
"""
vf = (
f"scale={TPL_W}:{TPL_VIDEO_H}:force_original_aspect_ratio=increase,"
f"crop={TPL_W}:{TPL_VIDEO_H},"
f"pad={TPL_W}:{TPL_H}:0:{TPL_VIDEO_TOP}:color=black,"
f"setsar=1"
)
cmd = [
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
"-i", src, "-vf", vf,
"-c:v", "libx264", "-preset", "veryfast", "-crf", str(crf),
"-pix_fmt", "yuv420p", "-c:a", "aac", "-ar", "44100",
out_path,
]
subprocess.run(cmd, check=True, encoding="utf-8", errors="replace")
return out_path
def to_letterbox(
src: str,
out_path: str,
*,
canvas: Tuple[int, int] = (1080, 1920),
crf: int = 20,
) -> str:
"""가로 영상을 canvas(기본 9:16) 가운데 두고 위아래 검은 띠로 채운 mp4 생성.
scale=...:force_original_aspect_ratio=decrease 캔버스 안에 비율 유지하며 축소,
pad 가운데 정렬 + 나머지 검정. 전체 클립을 그대로(타임라인 보존) 재인코딩.
"""
cw, ch = canvas
vf = (
f"scale={cw}:{ch}:force_original_aspect_ratio=decrease,"
f"pad={cw}:{ch}:(ow-iw)/2:(oh-ih)/2:color=black,"
f"setsar=1"
)
cmd = [
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
"-i", src, "-vf", vf,
"-c:v", "libx264", "-preset", "veryfast", "-crf", str(crf),
"-pix_fmt", "yuv420p", "-c:a", "aac", "-ar", "44100",
out_path,
]
subprocess.run(cmd, check=True, encoding="utf-8", errors="replace")
return out_path

127
capcut_agent/paste.py Normal file
View File

@ -0,0 +1,127 @@
"""붙여넣기(JSON) 파서 — LLM이 만든 하이라이트 편집안을 관대하게 읽는다.
입력 스키마(JSON 하나):
{
"url": "https://www.youtube.com/watch?v=…",
"title_top": "", "title_main": "", "channel": "", # 선택
"cuts": [
{"start": "0:01.0", "end": "0:03.5",
"bottom": "하단 자막\n두 줄", "effect": "광속하강"},
]
}
- 배치 시간은 받지 않는다 컷을 순서대로 이어붙인 누적 길이로 자동 계산.
- 자막은 컷에 1:1 묶여 있어 어긋날 없다(SRT 타임코드 불필요).
- 시간은 관대하게 파싱: "분:초.밀리" / "시:분:초.밀리", 콤마· 밀리초 모두 허용, 단독도 허용.
"""
from __future__ import annotations
import json
from typing import Dict, List, Tuple
def parse_time(v) -> float:
"""'M:SS.mmm' / 'H:MM:SS.mmm' / 'SS.mmm' / 숫자 → 초(float). 콤마도 허용."""
s = str(v).strip().replace(",", ".")
if not s:
raise ValueError("빈 시간값")
if ":" in s:
parts = [float(x) for x in s.split(":")]
if len(parts) == 3:
h, m, sec = parts
elif len(parts) == 2:
h, (m, sec) = 0.0, parts
else:
raise ValueError(f"시간 형식 오류: {v!r}")
return h * 3600 + m * 60 + sec
return float(s)
def split_json_objects(text: str) -> List[str]:
"""텍스트에서 최상위 {…} JSON 블록들을 순서대로 추출.
오팔 출력처럼 JSON 여러 사이에 구분선·타이틀 후보·검산표 잡문이 섞여
있어도 중괄호 균형만 맞으면 전부 찾는다. 문자열 안의 중괄호는 무시.
"""
out: List[str] = []
depth, start, in_str, esc = 0, -1, False, False
for i, ch in enumerate(text or ""):
if in_str:
if esc:
esc = False
elif ch == "\\":
esc = True
elif ch == '"':
in_str = False
continue
if ch == '"':
if depth > 0:
in_str = True
continue
if ch == "{":
if depth == 0:
start = i
depth += 1
elif ch == "}" and depth > 0:
depth -= 1
if depth == 0 and start != -1:
out.append(text[start:i + 1])
start = -1
return out
def parse_paste(raw) -> Dict:
"""붙여넣기 텍스트(JSON) → {url, cuts:[(s,e,bottom,effect)], title_top, title_main, channel}.
파싱/검증 실패 ValueError(사용자에게 그대로 보여줄 한국어 메시지).
"""
if isinstance(raw, (dict, list)):
data = raw
else:
text = (raw or "").strip()
# 흔한 실수: 코드펜스로 감싸서 붙여넣음 → 벗겨줌
if text.startswith("```"):
text = text.strip("`")
nl = text.find("\n")
if nl != -1:
text = text[nl + 1:]
try:
# strict=False: 자막 안 실제 줄바꿈(제어문자) 허용 → LLM 출력 관대 수용
data = json.loads(text, strict=False)
except json.JSONDecodeError as e:
raise ValueError(f"JSON 형식 오류: {e.msg} (줄 {e.lineno})")
if not isinstance(data, dict):
raise ValueError("최상위는 JSON 객체({…})여야 합니다.")
url = str(data.get("url", "")).strip()
if not (url.startswith("http://") or url.startswith("https://")):
raise ValueError("url 필드에 올바른 유튜브 주소가 필요합니다.")
raw_cuts = data.get("cuts")
if not isinstance(raw_cuts, list) or not raw_cuts:
raise ValueError("cuts 배열에 컷을 하나 이상 넣어주세요.")
cuts: List[Tuple[float, float, str, str]] = []
for i, c in enumerate(raw_cuts, 1):
if not isinstance(c, dict):
raise ValueError(f"{i}번 컷이 객체가 아닙니다.")
try:
s = parse_time(c["start"])
e = parse_time(c["end"])
except KeyError as k:
raise ValueError(f"{i}번 컷에 {k} 필드가 없습니다.")
if e <= s:
raise ValueError(f"{i}번 컷: 끝({c.get('end')})이 시작({c.get('start')})보다 커야 합니다.")
bottom = str(c.get("bottom", "") or "").strip()
effect = str(c.get("effect", "") or "").strip()
cuts.append((s, e, bottom, effect))
return {
"url": url,
"cuts": cuts,
"title_top": str(data.get("title_top", "") or "").strip(),
"title_main": str(data.get("title_main", "") or "").strip(),
"channel": str(data.get("channel", "") or "").strip(),
}

535
capcut_agent/pipeline.py Normal file
View File

@ -0,0 +1,535 @@
"""런타임 파이프라인 — SSE 이벤트를 내보내는 async 제너레이터.
현재 단계(1 기준): silence draft.
3·4 추가 asr / filler 스텝을 STEPS 본문에 삽입.
함정 메모:
- ASR(추후) numba 비안전 asyncio.Lock 으로 직렬화(동시 호출 segfault 방지). 자리만 마련.
- 단계당 최소 MIN_STEP 지연 캐시 hit 시에도 애니메이션 가시화.
"""
from __future__ import annotations
import asyncio
import os
import time
from typing import AsyncIterator, Dict, List
from typing import Optional
from .probe import probe
from .silence import detect_speech_segments
from .transcribe import transcribe
from .highlight import cut_plan
from .media import make_frame, make_solid
from .scene import detect_scene_changes, split_clips_at_scenes
from .youtube import cut_youtube, cut_youtube_multi, download_paste_cuts, REPAIR_LOG
from .correct import has_gemini_key, correct_captions
from .draft import build_jumpcut_draft, build_bg_template_draft, _ty
MIN_STEP = 0.5 # 초
# ASR(numba) 직렬화 — 동시 호출 시 segfault 방지
ASR_LOCK = asyncio.Lock()
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_DOWNLOADS = os.path.join(_ROOT, ".downloads")
def _load_comment_cards(folder: str, dur: float, min_sec: float = 3.0,
fixed: bool = False):
"""지정 폴더의 이미지를 영상 길이에 맞춰 하단에 균등 배치.
장수: n = min(카드 , max(1, floor(dur/min_sec))) 카드 하나가 min_sec 밑으로
내려가지 않는 상한. 배치 간격은 항상 dur/n 카드가 모자라도 끝까지 없이
채워지고(간격이 3 이상으로 늘어남), 넘치면 초과분은 버린다.
fixed=True 늘리지 않고 min_sec 고정 모자라면 뒤는 비운다
(편집하면서 부분삭제를 많이 하는 경우 카드가 늘어나 있으면 타이밍이 꼬여서).
정렬 규칙:
- 모든 파일명이 숫자로 시작하면 숫자순(1, 2, 10 )
- 아니면 저장(생성) 순서 = 다운로드한 순서
folder 비었거나 없으면 [] (댓글 카드 없음).
Returns: [(start, end, path)]
"""
import re
folder = (folder or "").strip().strip('"')
if not folder or not os.path.isdir(folder) or dur <= 0:
return []
imgs = [os.path.join(folder, f) for f in os.listdir(folder)
if os.path.splitext(f)[1].lower() in (".png", ".jpg", ".jpeg", ".webp")]
if not imgs:
return []
def _leadnum(path):
m = re.match(r"\s*0*(\d+)", os.path.splitext(os.path.basename(path))[0])
return int(m.group(1)) if m else None
if all(_leadnum(p) is not None for p in imgs):
imgs.sort(key=_leadnum) # 파일명 숫자순
else:
imgs.sort(key=lambda p: os.path.getctime(p)) # 저장(생성) 순서
n = min(len(imgs), max(1, int(dur // min_sec)))
if fixed: # 3초 고정 — 뒤가 비어도 늘리지 않음
return [(i * min_sec, min((i + 1) * min_sec, dur), path)
for i, path in enumerate(imgs[:n])]
interval = dur / n
return [(i * interval, (i + 1) * interval, path)
for i, path in enumerate(imgs[:n])]
def _elapsed_kept(keep_sorted, t: float) -> float:
"""원본 시간 t 가 무음 제거 후(압축) 타임라인에서 놓이는 위치 = t 이전의 보존 길이 합."""
tot = 0.0
for s, e in keep_sorted:
if s >= t:
break
tot += min(t, e) - s
return tot
def _remap_caps(caps, keep_sorted):
"""자막 [(s,e,txt)] 를 무음 제거 타임라인으로 재매핑. 전부 무음이면 버림."""
out = []
for cs, ce, txt in caps:
ns = _elapsed_kept(keep_sorted, cs)
ne = _elapsed_kept(keep_sorted, ce)
if ne - ns > 0.05:
out.append((ns, ne, txt))
return out
def _safe_name(name: str) -> str:
s = "".join(c for c in name if c.isalnum() or c in (" ", "_", "-", ".")).strip()
return s[:60] or "video"
def _template_path() -> str:
for n in ("배경.png", "배경템플릿.png", os.path.join("assets", "bg_template.png")):
p = os.path.join(_ROOT, n)
if os.path.isfile(p):
return p
return os.path.join(_ROOT, "배경.png")
BG_STEPS: List[Dict[str, str]] = [
{"id": "silence", "label": "무음·발화 분석"},
{"id": "asr", "label": "음성 인식(자막)"},
{"id": "draft", "label": "템플릿 드래프트 생성"},
]
async def process_bg_template(
video_path: Optional[str],
draft_name: str,
*,
title_top: str = "",
title_main: str = "",
channel: str = "",
video_scale: float = 1.0,
flip_horizontal: bool = False,
scene_split: bool = False,
comments_dir: str = "",
cards_fixed: bool = False,
bg_white: bool = False,
youtube: Optional[dict] = None,
) -> AsyncIterator[dict]:
"""배경템플릿 파이프라인: [유튜브 구간 다운로드] → 무음컷 → 자막 → 드래프트. SSE 이벤트."""
t_all = time.perf_counter()
use_gemini = has_gemini_key()
steps = ([{"id": "download", "label": "유튜브 여러 구간 다운로드·병합"}] if youtube else [])
steps += [{"id": "silence", "label": "무음·발화 분석"},
{"id": "asr", "label": "받아쓰기 (Gemini/Whisper)"},
{"id": "draft", "label": "템플릿 드래프트 생성"}]
yield {"type": "manifest", "steps": steps}
# ── 유튜브 여러 구간 다운로드 + 병합 (URL 입력 시) ──
if youtube:
ranges = youtube.get("ranges") or [(youtube.get("start", ""), youtube.get("end", ""))]
yield {"type": "step", "id": "download", "status": "start"}
rng_txt = ", ".join(f"{s}~{e}" for s, e in ranges)
yield {"type": "log", "msg": f"유튜브 {len(ranges)}개 구간 다운로드·병합 중… [{rng_txt}]"}
t = time.perf_counter()
REPAIR_LOG.clear()
video_path, title, yt_channel = await asyncio.to_thread(
cut_youtube_multi, youtube["url"], ranges, _DOWNLOADS,
)
for msg in REPAIR_LOG: # 초록 깨짐 자동 수리 내역
yield {"type": "log", "msg": f"🩹 {msg}"}
draft_name = _safe_name(title) or draft_name
# 출처를 안 적었으면 유튜브 채널명으로 자동 채움
if not channel and yt_channel:
channel = f"@{yt_channel}"
yield {"type": "log", "msg": f"출처 자동: {channel}"}
await _floor(t)
yield {"type": "step", "id": "download", "status": "done",
"elapsed": round(time.perf_counter() - t, 1), "detail": title}
yield {"type": "log", "msg": f"입력: {os.path.basename(video_path)}"}
meta = await asyncio.to_thread(probe, video_path)
yield {"type": "log", "msg": f"{meta.width}×{meta.height} · {meta.fps}fps · {meta.duration:.1f}s"}
# ── silence (오디오 무음 컷) ──
yield {"type": "step", "id": "silence", "status": "start"}
t = time.perf_counter()
keep = await asyncio.to_thread(
detect_speech_segments, video_path, meta.duration,
noise_db=-28.0, min_silence=0.3, pad=0.04,
)
if not keep:
yield {"type": "error", "message": "오디오가 없거나 전부 무음입니다."}
return
kept = sum(e - s for s, e in keep)
await _floor(t)
yield {"type": "step", "id": "silence", "status": "done",
"elapsed": round(time.perf_counter() - t, 1),
"detail": f"보존 {len(keep)}구간 · {meta.duration - kept:.1f}s 무음 제거"}
# ── asr (받아쓰기): 타이밍=Whisper 단어 타임스탬프(정확, 드리프트 없음).
# 글자 품질만 Gemini로 제자리 교정(1:1, 시간은 절대 안 건드림) → 싱크 유지 ──
yield {"type": "step", "id": "asr", "status": "start"}
t = time.perf_counter()
yield {"type": "log", "msg": "Whisper로 받아쓰기·타이밍 분석 중… (1분당 ≈30초)"}
async with ASR_LOCK:
tr = await asyncio.to_thread(
transcribe, video_path,
model_size="medium", language="ko", use_cache=True, vad_filter=False,
)
video_clips, captions, total = cut_plan(keep, tr) # Whisper 타이밍 자막
method = "Whisper 추출"
# Gemini 교정: 자막 글자만 다듬고 개수·순서·시간 그대로 유지(싱크 불변).
if use_gemini and captions:
yield {"type": "log", "msg": "Gemini로 자막 글자 교정 중… (시간은 그대로)"}
try:
texts = [c[2] for c in captions]
fixed = await asyncio.to_thread(
correct_captions, texts, title=(title_main or draft_name))
if isinstance(fixed, list) and len(fixed) == len(captions):
captions = [(s, e, (ft.strip() or captions[i][2]))
for i, ((s, e, _), ft) in enumerate(zip(captions, fixed))]
method = "Whisper 타이밍 + Gemini 교정"
except Exception as exc: # noqa: BLE001 — 교정 실패해도 Whisper 원문 유지
yield {"type": "log", "msg": f"⚠ Gemini 교정 실패({type(exc).__name__}) → Whisper 원문"}
await _floor(t)
yield {"type": "step", "id": "asr", "status": "done",
"elapsed": round(time.perf_counter() - t, 1),
"detail": f"{method} · 자막 {len(captions)}"}
# ── 장면전환 분할 (선택): 컷이 바뀌는 지점에서 세그먼트 추가 분할 ──
if scene_split:
yield {"type": "log", "msg": "장면전환 감지 중… (화면 바뀌는 컷 찾기)"}
before = len(video_clips)
scenes = await asyncio.to_thread(detect_scene_changes, video_path)
video_clips = split_clips_at_scenes(video_clips, scenes)
yield {"type": "log",
"msg": f"장면전환 {len(scenes)}곳 → 세그먼트 {before}{len(video_clips)}"}
# ── draft (배경템플릿) ──
yield {"type": "step", "id": "draft", "status": "start"}
t = time.perf_counter()
frame, bg, pos = await asyncio.to_thread(_template_pos, bg_white)
cards = _load_comment_cards(comments_dir, total, fixed=cards_fixed)
if cards:
yield {"type": "log", "msg": f"댓글 카드 {len(cards)}개 하단 삽입(3초 간격)"}
path = await asyncio.to_thread(
lambda: build_bg_template_draft(
video_path, bg, frame, video_clips, captions, meta, draft_name,
title_top=title_top or None, title_main=title_main or None,
channel=channel or None, video_scale=video_scale,
flip_horizontal=flip_horizontal, comment_cards=cards, bg_white=bg_white, **pos,
) # video_clips 는 장면분할 반영된 최신 리스트 사용
)
await _floor(t)
yield {"type": "step", "id": "draft", "status": "done",
"elapsed": round(time.perf_counter() - t, 1), "detail": draft_name}
yield {
"type": "result",
"draft_name": draft_name,
"draft_path": path,
"stats": {
"duration": round(meta.duration, 1),
"kept": round(total, 1),
"cut": round(meta.duration - total, 1),
"segments": len(video_clips),
"captions": len(captions),
"elapsed": round(time.perf_counter() - t_all, 1),
},
}
# ── 템플릿 레이아웃 (캔버스 1080×1920, 단위 = 픽셀, 위가 0) ───────────────────
# 레퍼런스 템플릿을 실측해 잡은 값. **여기만 고치면 전체 배치가 같이 움직인다.**
# (예전엔 배경.png 흰밴드 자동감지였는데, 좌표를 정확히 통제하려고 상수로 바꿨다)
CANVAS_H = 1920
VIDEO_TOP = 323 # 영상 창 시작 = 위 흰 띠가 끝나는 지점
VIDEO_BOTTOM = 1122 # 영상 창 끝 = 아래 흰 띠가 시작하는 지점
TITLE_TOP_Y = 109 # 서브제목(주황) 중앙
TITLE_MAIN_Y = 252 # 메인제목(흰색) 중앙
CAPTION_GAP = 72 # 하단 자막 중앙 = VIDEO_BOTTOM 이 값 (영상 창 안쪽 아래)
EFFECT_GAP = 25 # 효과자막 중앙 = VIDEO_TOP + 이 값 (영상 창 안쪽 위)
COMMENT_TOP = VIDEO_BOTTOM # 댓글 카드 윗변 = 영상 바로 아래(딱 붙음)
CHANNEL_RATIO = 0.85 # 출처: 아래 띠에서 85% 내려간 지점
def _template_pos(white: bool = False):
"""배경템플릿 프레임/배경 생성 + 위치 dict 계산. (frame_path, bg_path, pos) 반환.
white=True 상하 · 곳을 흰색으로( 프레임 + 배경 레이어),
아니면 검은색(기본, 배경 레이어 없음 검정).
좌표는 전부 레이아웃 상수에서 파생 곳만 고치면 된다.
"""
band = (255, 255, 255) if white else (0, 0, 0)
fname = "frame_template_white.png" if white else "frame_template.png"
frame = os.path.join(_ROOT, "assets", fname)
os.makedirs(os.path.dirname(frame), exist_ok=True)
make_frame(frame, top_bar=VIDEO_TOP, bottom_top=VIDEO_BOTTOM, band_color=band)
bg = None
if white:
bg = os.path.join(_ROOT, "assets", "bg_white.png")
make_solid(bg, (255, 255, 255))
pos = dict(
video_y=_ty((VIDEO_TOP + VIDEO_BOTTOM) / 2),
title_top_y=_ty(TITLE_TOP_Y),
title_main_y=_ty(TITLE_MAIN_Y),
caption_y=_ty(VIDEO_BOTTOM - CAPTION_GAP),
effect_y=_ty(VIDEO_TOP + EFFECT_GAP),
channel_y=_ty(VIDEO_BOTTOM + (CANVAS_H - VIDEO_BOTTOM) * CHANNEL_RATIO),
comment_top=COMMENT_TOP,
)
return frame, bg, pos
async def process_paste(
payload: dict,
draft_name: str,
*,
video_scale: float = 1.0,
flip_horizontal: bool = False,
scene_split: bool = False,
comments_dir: str = "",
cards_fixed: bool = False,
bg_white: bool = False,
remove_silence: bool = False,
asr_bottom: bool = False,
name_suffix: str = "",
) -> AsyncIterator[dict]:
"""붙여넣기(JSON) 파이프라인: 컷 정밀 다운로드·병합 → 공급된 자막 2트랙으로 드래프트.
자막 배치 시간 = 순서 누적. asr_bottom=True JSON bottom 대신 병합본을
Whisper로 받아써 실제 발화 타이밍에 맞춘 하단 자막을 생성(effect/제목은 JSON 유지).
payload: paste.parse_paste 결과 dict.
"""
t_all = time.perf_counter()
cuts = payload["cuts"] # [(s, e, bottom, effect)]
url = payload["url"]
title_top = payload.get("title_top", "")
title_main = payload.get("title_main", "")
channel = payload.get("channel", "")
steps = [{"id": "download", "label": "컷 정밀 다운로드·병합"}]
if asr_bottom:
steps.append({"id": "asr", "label": "받아쓰기 (Whisper)"})
steps.append({"id": "draft", "label": "템플릿 드래프트 생성"})
yield {"type": "manifest", "steps": steps}
# ── 컷 정밀 다운로드 + 병합 ──
yield {"type": "step", "id": "download", "status": "start"}
yield {"type": "log", "msg": f"{len(cuts)}개 컷 정밀 다운로드·병합 중… (프레임 정확 컷)"}
t = time.perf_counter()
ranges = [(s, e) for s, e, _, _ in cuts]
REPAIR_LOG.clear()
video_path, title, yt_channel = await asyncio.to_thread(
download_paste_cuts, url, ranges, _DOWNLOADS,
)
for msg in REPAIR_LOG: # 초록 깨짐 자동 수리 내역
yield {"type": "log", "msg": f"🩹 {msg}"}
draft_name = _safe_name(title) or draft_name
if name_suffix: # 같은 영상에서 여럿 만들 때 이름 충돌(=드래프트 교체) 방지
draft_name = f"{draft_name}_{name_suffix}"
if not channel and yt_channel:
channel = f"@{yt_channel}"
yield {"type": "log", "msg": f"출처 자동: {channel}"}
await _floor(t)
yield {"type": "step", "id": "download", "status": "done",
"elapsed": round(time.perf_counter() - t, 1), "detail": title}
meta = await asyncio.to_thread(probe, video_path)
dur = meta.duration
yield {"type": "log", "msg": f"병합 결과 {meta.width}×{meta.height} · {dur:.1f}s"}
# 자막 배치 = 컷 순서 누적(공급된 컷 길이 기준). 영상 실제 길이로 클램프.
placements, c = [], 0.0
for s, e, _, _ in cuts:
placements.append((c, c + (e - s)))
c += (e - s)
bottom_caps = [(p0, min(p1, dur), b) for (p0, p1), (_, _, b, _) in zip(placements, cuts)
if b and p0 < dur]
eff_caps = [(p0, min(p1, dur), ef) for (p0, p1), (_, _, _, ef) in zip(placements, cuts)
if ef and p0 < dur]
video_clips = [(0.0, dur)] # 병합본 = 한 덩어리(재컷 없음)
timeline_dur = dur
# 무음 제거(선택): 컷 안의 무음까지 잘라내고 자막 시간을 압축 타임라인으로 재매핑
if remove_silence:
yield {"type": "log", "msg": "무음 분석 중… (컷 안의 무음 제거)"}
keep = await asyncio.to_thread(
detect_speech_segments, video_path, dur,
noise_db=-28.0, min_silence=0.3, pad=0.04,
)
if keep:
keep = sorted(keep)
bottom_caps = _remap_caps(bottom_caps, keep)
eff_caps = _remap_caps(eff_caps, keep)
video_clips = keep
timeline_dur = sum(e - s for s, e in keep)
yield {"type": "log",
"msg": f"무음 {dur - timeline_dur:.1f}s 제거 → {timeline_dur:.1f}s"}
# ── asr_bottom(선택): 병합본을 Whisper로 받아써 하단 자막 자동 생성 ──
# Whisper 타임스탬프는 '병합 파일' 기준. cut_plan(video_clips, tr)이 파일 시간을
# 최종(무음제거 반영) 타임라인으로 매핑 + 짧은 자막 청킹까지 처리한다.
# (무음제거 안 켰으면 video_clips=[(0,dur)] → 항등 매핑. 파일/유튜브 탭과 동일 패턴)
if asr_bottom:
yield {"type": "step", "id": "asr", "status": "start"}
t = time.perf_counter()
yield {"type": "log", "msg": "Whisper로 받아쓰기 중… (1분당 ≈30초)"}
async with ASR_LOCK:
tr = await asyncio.to_thread(
transcribe, video_path,
model_size="medium", language="ko", use_cache=True, vad_filter=False,
)
_, asr_caps, _ = cut_plan(video_clips, tr)
method = "Whisper 추출"
if asr_caps and has_gemini_key():
yield {"type": "log", "msg": "Gemini로 자막 글자 교정 중… (시간은 그대로)"}
try:
texts = [ct for _, _, ct in asr_caps]
fixed = await asyncio.to_thread(
correct_captions, texts, title=(title_main or draft_name))
if isinstance(fixed, list) and len(fixed) == len(asr_caps):
asr_caps = [(s, e, (ft.strip() or asr_caps[i][2]))
for i, ((s, e, _), ft) in enumerate(zip(asr_caps, fixed))]
method = "Whisper 타이밍 + Gemini 교정"
except Exception as exc: # noqa: BLE001 — 교정 실패해도 원문 유지
yield {"type": "log", "msg": f"⚠ Gemini 교정 실패({type(exc).__name__}) → Whisper 원문"}
if asr_caps:
bottom_caps = asr_caps # JSON bottom 완전 대체
else:
yield {"type": "log", "msg": "⚠ 받아쓰기 결과 없음 → JSON bottom 자막 사용"}
await _floor(t)
yield {"type": "step", "id": "asr", "status": "done",
"elapsed": round(time.perf_counter() - t, 1),
"detail": f"{method} · 자막 {len(bottom_caps)}"}
# ── draft ──
yield {"type": "step", "id": "draft", "status": "start"}
t = time.perf_counter()
frame, bg, pos = await asyncio.to_thread(_template_pos, bg_white)
# 장면분할(선택): 화면 바뀌는 지점마다 세그먼트 추가 분할(자막 시간 불변)
if scene_split:
yield {"type": "log", "msg": "장면전환 감지 중… (화면 바뀌는 컷 찾기)"}
scenes = await asyncio.to_thread(detect_scene_changes, video_path)
video_clips = split_clips_at_scenes(video_clips, scenes)
yield {"type": "log", "msg": f"장면전환 {len(scenes)}곳 → 세그먼트 {len(video_clips)}"}
# 댓글 카드(선택): 지정 폴더의 1,2,3… 을 3초씩 하단에 순서대로
cards = _load_comment_cards(comments_dir, timeline_dur, fixed=cards_fixed)
if cards:
yield {"type": "log", "msg": f"댓글 카드 {len(cards)}개 하단 삽입(3초 간격)"}
path = await asyncio.to_thread(
lambda: build_bg_template_draft(
video_path, bg, frame, video_clips, bottom_caps, meta, draft_name,
title_top=title_top or None, title_main=title_main or None,
channel=channel or None, video_scale=video_scale,
flip_horizontal=flip_horizontal, effect_captions=eff_caps,
comment_cards=cards, bg_white=bg_white, **pos,
)
)
await _floor(t)
yield {"type": "step", "id": "draft", "status": "done",
"elapsed": round(time.perf_counter() - t, 1), "detail": draft_name}
yield {
"type": "result",
"draft_name": draft_name,
"draft_path": path,
"stats": {
"duration": round(dur, 1),
"kept": round(dur, 1),
"cut": 0.0,
"segments": len(cuts),
"captions": len(bottom_caps),
"elapsed": round(time.perf_counter() - t_all, 1),
},
}
STEPS: List[Dict[str, str]] = [
{"id": "silence", "label": "무음·발화 분석"},
{"id": "draft", "label": "점프컷 드래프트 생성"},
]
async def _floor(t0: float) -> None:
"""최소 단계 시간 보장."""
dt = time.perf_counter() - t0
if dt < MIN_STEP:
await asyncio.sleep(MIN_STEP - dt)
async def process_video(video_path: str, draft_name: str) -> AsyncIterator[dict]:
"""영상 1개 처리. SSE 로 흘려보낼 dict 이벤트를 yield."""
t_all = time.perf_counter()
yield {"type": "manifest", "steps": STEPS}
meta = await asyncio.to_thread(probe, video_path)
yield {"type": "meta", "resolution": f"{meta.width}×{meta.height}",
"fps": meta.fps, "duration": round(meta.duration, 1)}
# ── silence ──────────────────────────────────────────
yield {"type": "step", "id": "silence", "status": "start"}
t = time.perf_counter()
segments = await asyncio.to_thread(detect_speech_segments, video_path, meta.duration)
if not segments:
await _floor(t)
yield {"type": "error", "message": "발화 구간이 감지되지 않았습니다 (무음 임계값 확인)."}
return
speech_total = sum(e - s for s, e in segments)
cut = meta.duration - speech_total
await _floor(t)
yield {"type": "step", "id": "silence", "status": "done",
"elapsed": round(time.perf_counter() - t, 1),
"detail": f"발화 {len(segments)}구간 · {cut:.1f}s 컷"}
# ── draft ────────────────────────────────────────────
yield {"type": "step", "id": "draft", "status": "start"}
t = time.perf_counter()
path = await asyncio.to_thread(
build_jumpcut_draft, video_path, segments, meta, draft_name
)
await _floor(t)
yield {"type": "step", "id": "draft", "status": "done",
"elapsed": round(time.perf_counter() - t, 1),
"detail": draft_name}
# ── result ───────────────────────────────────────────
yield {
"type": "result",
"draft_name": draft_name,
"draft_path": path,
"stats": {
"duration": round(meta.duration, 1),
"kept": round(speech_total, 1),
"cut": round(cut, 1),
"cut_pct": round(cut / meta.duration * 100) if meta.duration else 0,
"segments": len(segments),
"resolution": f"{meta.width}×{meta.height}",
"elapsed": round(time.perf_counter() - t_all, 1),
},
}

181
capcut_agent/plan.py Normal file
View File

@ -0,0 +1,181 @@
"""자동 탭 — Gemini로 오팔 파이프라인 대체.
Step 1: 전체 영상( fps) 하이라이트 후보 5.
Step 3: 구간별(기본 fps, videoMetadata로 클립) 붙여넣기 스키마 JSON + 타이틀 후보 5.
오팔 Step 2(노드 구간 전달) 코드에선 함수 인자이므로 존재하지 않는다.
"""
from __future__ import annotations
import json
import re
import urllib.error
import urllib.request
from typing import Dict, List, Optional
from .correct import GeminiQuotaError, _gemini_key
from .paste import parse_paste, parse_time
from . import prompts
DEFAULT_MODEL = "gemini-3.5-flash"
_ENDPOINT = "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={key}"
_FENCE_RE = re.compile(r"```json\s*(.*?)```", re.S)
# 블록 ② "1. 상단: … / 메인: … — [유형]" — 번호·대괄호는 있어도 없어도, 대시 3종 허용
_TITLE_RE = re.compile(
r"^\s*(?:\d+\.\s*)?상단\s*[:]\s*(.+?)\s*/\s*메인\s*[:]\s*(.+?)(?:\s*[—–-]\s*(.+?))?\s*$",
re.M)
def parse_titles(text: str) -> List[Dict]:
"""텍스트에서 타이틀 후보(상단/메인/유형) 추출. 오팔 붙여넣기·Step3 응답 공용."""
out: List[Dict] = []
for m in _TITLE_RE.finditer(text or ""):
out.append({"top": m.group(1).strip(), "main": m.group(2).strip(),
"kind": (m.group(3) or "").strip().strip("[]")})
return out
def _fmt_offset(sec: float) -> str:
return f"{max(0, int(sec))}s"
def _call(video_url: str, prompt_text: str, *, model: str, key: str,
fps: Optional[float] = None, start: Optional[float] = None,
end: Optional[float] = None, timeout: float = 600.0) -> str:
"""Gemini generateContent 1회 → 응답 텍스트. 429는 GeminiQuotaError."""
part: Dict = {"fileData": {"fileUri": video_url}}
meta: Dict = {}
if fps is not None and abs(fps - 1.0) > 1e-9:
meta["fps"] = fps
if start is not None and end is not None:
meta["startOffset"] = _fmt_offset(start)
meta["endOffset"] = _fmt_offset(end)
if meta:
part["videoMetadata"] = meta
body = {
"contents": [{"parts": [part, {"text": prompt_text}]}],
"generationConfig": {"temperature": 0.7},
}
req = urllib.request.Request(
_ENDPOINT.format(model=model, key=key),
data=json.dumps(body).encode("utf-8"),
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
data = json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
if e.code == 429:
raise GeminiQuotaError()
try:
detail = e.read().decode("utf-8", "replace")[:400]
except Exception: # noqa: BLE001
detail = ""
raise RuntimeError(f"Gemini HTTP {e.code}: {detail}")
try:
return data["candidates"][0]["content"]["parts"][0]["text"]
except (KeyError, IndexError, TypeError):
raise RuntimeError(
"Gemini 응답 형식 오류: "
+ json.dumps(data, ensure_ascii=False)[:300])
def _extract_json_str(text: str) -> str:
"""첫 ```json 펜스 안쪽. 펜스 없으면 가장 바깥 {…} 범위, 그것도 없으면 전문."""
m = _FENCE_RE.search(text or "")
if m:
return m.group(1).strip()
t = (text or "").strip()
i, j = t.find("{"), t.rfind("}")
if i != -1 and j > i:
return t[i:j + 1]
return t
def parse_candidates(text: str, *, src: str = "Step 1 응답") -> List[Dict]:
"""`{"candidates":[{id,start_time,end_time,reason}, …]}` → [{id,start,end,reason}] (초).
Gemini Step1 응답과 사용자가 직접 붙여넣는 구간 JSON이 **같은 형식**이라 둘이 공유한다.
코드펜스(```)·앞뒤 잡텍스트는 `_extract_json_str` 걷어낸다.
"""
try:
data = json.loads(_extract_json_str(text), strict=False)
except json.JSONDecodeError:
raise RuntimeError(f"{src} JSON 파싱 실패:\n{text[:400]}")
cands = data.get("candidates") if isinstance(data, dict) else None
if not isinstance(cands, list) or not cands:
raise RuntimeError(f"{src}에 candidates 배열이 없습니다:\n{text[:400]}")
out: List[Dict] = []
for i, c in enumerate(cands, 1):
if not isinstance(c, dict):
continue
try:
s = parse_time(c.get("start_time"))
e = parse_time(c.get("end_time"))
except (ValueError, TypeError):
continue
if e > s:
out.append({"id": int(c.get("id") or i), "start": s, "end": e,
"reason": str(c.get("reason") or "").strip()})
if not out:
raise RuntimeError(f"{src}: 유효한 구간이 하나도 없습니다.")
return out
def select_highlights(url: str, *, key: Optional[str] = None,
model: Optional[str] = None) -> List[Dict]:
"""Step 1: 전체 영상 → 하이라이트 후보 [{id, start, end, reason}] (초)."""
key = key or _gemini_key()
if not key:
raise RuntimeError("Gemini 키 없음 (.gemini_key)")
cfg = prompts.load_config()
model = model or cfg.get("model_step1") or cfg.get("model") or DEFAULT_MODEL
fps = float(cfg.get("fps_step1") or 0.2)
text = _call(url, prompts.load_step1(), model=model, key=key, fps=fps)
return parse_candidates(text)
def _shift_cuts_if_relative(payload: Dict, start: float, end: float) -> str:
"""클립 기준(0부터) 타임코드로 보이면 원본 기준으로 보정. 반환: 로그용 메모.
구간을 잘라 보낸 클립에 대해 모델이 절대/클립 어느 기준으로 답할지 보장이
없다(문서 미명시). 절대 기준으로 보이면 그대로(우선), 클립 기준으로 보이면
start 더하고, 아니면 손대지 않는다(이후 검증·다운로드에서 드러남).
"""
cuts = payload["cuts"]
dur, pad = end - start, 10.0
if all(start - pad <= s and e <= end + pad for s, e, _, _ in cuts):
return "" # 절대 기준 — 그대로
if start > pad and all(0 <= s and e <= dur + pad for s, e, _, _ in cuts):
payload["cuts"] = [(s + start, e + start, b, f) for s, e, b, f in cuts]
return f"컷 타임코드가 클립 기준 → +{start:.0f}s 보정"
return ""
def edit_plan(url: str, start: float, end: float, *, key: Optional[str] = None,
model: Optional[str] = None) -> Dict:
"""Step 3: [start, end] 클립 → {paste, titles, time_note}.
paste 기존 paste.parse_paste 검증을 그대로 통과한 결과이며 url 입력값으로
덮어쓴다(LLM이 영상 ID를 지어내는 사고 차단).
"""
key = key or _gemini_key()
if not key:
raise RuntimeError("Gemini 키 없음 (.gemini_key)")
cfg = prompts.load_config()
model = model or cfg.get("model") or DEFAULT_MODEL
fps = float(cfg.get("fps_step3") or 1.0)
text = _call(url, prompts.load_step3(), model=model, key=key,
fps=fps, start=start, end=end)
# LLM이 쓴 url 은 신뢰하지 않는다 — 검증(parse_paste) 전에 입력 URL 로 강제 교체.
# (Gemini가 가끔 url 을 지어내거나 깨뜨려 ValueError 로 하이라이트가 통째로 죽는다)
try:
raw = json.loads(_extract_json_str(text), strict=False)
except json.JSONDecodeError as e:
raise ValueError(f"JSON 형식 오류: {e.msg} (줄 {e.lineno})")
if isinstance(raw, dict):
raw["url"] = url
payload = parse_paste(raw) # ValueError 는 호출부에서 표시
payload["url"] = url
note = _shift_cuts_if_relative(payload, start, end)
return {"paste": payload, "titles": parse_titles(text), "time_note": note}

41
capcut_agent/probe.py Normal file
View File

@ -0,0 +1,41 @@
"""ffprobe 기반 영상 메타데이터 추출."""
from __future__ import annotations
import json
import subprocess
from dataclasses import dataclass
@dataclass
class VideoMeta:
width: int
height: int
fps: int
duration: float # seconds
def probe(video_path: str) -> VideoMeta:
cmd = [
"ffprobe", "-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=width,height,avg_frame_rate:format=duration",
"-of", "json", video_path,
]
# 한글 경로 대비 UTF-8 고정 (Windows 기본 cp949 디코드 에러 방지)
out = subprocess.run(
cmd, capture_output=True, text=True, check=True,
encoding="utf-8", errors="replace",
).stdout
data = json.loads(out)
stream = data["streams"][0]
width = int(stream["width"])
height = int(stream["height"])
# avg_frame_rate 는 "30000/1001" 형태 → 정수 fps 로 반올림
num, _, den = stream.get("avg_frame_rate", "30/1").partition("/")
den = den or "1"
fps_val = float(num) / float(den) if float(den) else 30.0
fps = max(1, round(fps_val))
duration = float(data["format"]["duration"])
return VideoMeta(width=width, height=height, fps=fps, duration=duration)

102
capcut_agent/prompts.py Normal file
View File

@ -0,0 +1,102 @@
"""프롬프트·설정 파일 관리 — 자동 탭(Gemini)용.
Step 3 지침 = 프로젝트 루트 `숏폼_편집_지침서_v13.7_capcut2연동판.md` (사용자 소유,
reset 대상 아님). Step 1 프롬프트·모델 설정 = `프롬프트/` 폴더(없으면 기본값 생성).
UI( 지침 수정)에서든 메모장에서든 고치면 다음 분석부터 반영된다.
"""
from __future__ import annotations
import json
import os
from typing import Optional
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PROMPT_DIR = os.path.join(_ROOT, "프롬프트")
STEP1_PATH = os.path.join(PROMPT_DIR, "하이라이트_선정.md")
CONFIG_PATH = os.path.join(PROMPT_DIR, "설정.json")
STEP3_PATH = os.path.join(_ROOT, "숏폼_편집_지침서_v13.7_capcut2연동판.md")
# 오팔 Step 1 원문 이식. 원문 JSON 예시의 오타(start_time 중복, end_time 누락)는 수정.
DEFAULT_STEP1 = """당신은 유튜브 영상에서 쇼츠(Shorts)로 제작했을 때 가장 터질 만한 구간을 찾아내는 '바이럴 분석가'입니다. 제공된 영상을 분석해 아래 규칙에 따라 5개의 하이라이트 후보 구간을 선정하세요.
1. 구간의 길이는 최소 1 30초에서 최대 3 사이로 설정할 . (최종 편집을 위한 원천 소스 구간임)
2. 시청자의 시선을 있는 갈등, 웃음, 반전, 혹은 핵심 정보가 포함된 구간을 우선순위로 .
3. 결과물은 반드시 아래의 JSON 형식으로만 출력할 . (다른 설명 금지)
{
"candidates": [
{
"id": 1,
"start_time": "MM:SS",
"end_time": "MM:SS",
"reason": "구간 선정 이유 요약"
},
... ( 5)
]
}
"""
DEFAULT_CONFIG = {
"model": "gemini-3.5-flash",
"model_step1": "",
"fps_step1": 0.2,
"fps_step3": 1.0,
}
def load_step1() -> str:
if not os.path.isfile(STEP1_PATH):
os.makedirs(PROMPT_DIR, exist_ok=True)
with open(STEP1_PATH, "w", encoding="utf-8") as f:
f.write(DEFAULT_STEP1)
with open(STEP1_PATH, encoding="utf-8") as f:
return f.read()
def load_step3() -> str:
if not os.path.isfile(STEP3_PATH):
raise RuntimeError(f"Step 3 지침 파일이 없습니다: {STEP3_PATH}")
with open(STEP3_PATH, encoding="utf-8") as f:
return f.read()
def load_config() -> dict:
"""설정 로드. 파일이 없거나 깨졌으면 기본값(파일은 첫 save 때 생성)."""
cfg = dict(DEFAULT_CONFIG)
try:
with open(CONFIG_PATH, encoding="utf-8") as f:
loaded = json.load(f)
if isinstance(loaded, dict):
cfg.update(loaded)
except (OSError, json.JSONDecodeError, ValueError):
pass
return cfg
def save(*, step1: Optional[str] = None, step3: Optional[str] = None,
config: Optional[str] = None) -> None:
"""전달된 것만 저장. config 는 JSON 문자열 — 형식 오류는 예외로 올린다."""
os.makedirs(PROMPT_DIR, exist_ok=True)
if config is not None: # 검증 먼저(실패 시 아무것도 안 씀)
cfg = json.loads(config)
if not isinstance(cfg, dict):
raise ValueError("설정은 JSON 객체({…})여야 합니다.")
merged = dict(DEFAULT_CONFIG)
merged.update(cfg)
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
json.dump(merged, f, ensure_ascii=False, indent=1)
if step1 is not None:
with open(STEP1_PATH, "w", encoding="utf-8") as f:
f.write(step1)
if step3 is not None:
with open(STEP3_PATH, "w", encoding="utf-8") as f:
f.write(step3)
def reset() -> None:
"""Step 1 프롬프트·설정을 기본값으로. Step 3(지침서)는 사용자 파일이라 불변."""
os.makedirs(PROMPT_DIR, exist_ok=True)
with open(STEP1_PATH, "w", encoding="utf-8") as f:
f.write(DEFAULT_STEP1)
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
json.dump(DEFAULT_CONFIG, f, ensure_ascii=False, indent=1)

69
capcut_agent/scene.py Normal file
View File

@ -0,0 +1,69 @@
"""ffmpeg scene 필터 기반 시각적 장면전환 감지.
화면이 바뀌는 지점(/앵글전환/B롤) 찾아 timestamp(, 소스 기준) 반환.
draft 비디오 세그먼트를 지점에서 추가로 쪼개면, 말이 이어져도(무음 없음)
장면이 바뀌는 곳에 캡컷 편집 컷이 생긴다. 시간은 그대로라 자막 싱크에 영향 없음.
"""
from __future__ import annotations
import re
import subprocess
from typing import List, Tuple
Segment = Tuple[float, float]
_PTS = re.compile(r"pts_time:([\d.]+)")
def detect_scene_changes(video_path: str, *, threshold: float = 0.4) -> List[float]:
"""장면전환 timestamp(초, 소스 기준) 리스트. threshold=scene score(0~1) 임계.
낮을수록 민감(과분할), 높을수록 전환만. 0.4 = 실제 위주.
"""
cmd = [
"ffmpeg", "-hide_banner", "-nostats", "-i", video_path,
"-vf", f"select='gt(scene,{threshold})',showinfo",
"-an", "-f", "null", "-",
]
# showinfo 는 stderr 로 출력. silence.py 와 동일하게 stderr→stdout 병합.
proc = subprocess.run(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, encoding="utf-8", errors="replace",
)
times: List[float] = []
for line in (proc.stdout or "").splitlines():
if "showinfo" not in line:
continue
m = _PTS.search(line)
if m:
times.append(float(m.group(1)))
return sorted(set(times))
def split_clips_at_scenes(
video_clips: List[Segment],
scene_times: List[float],
*,
min_len: float = 0.4,
) -> List[Segment]:
"""보존 구간(소스 시간)을 장면전환 지점에서 추가로 분할.
인접 분할이라 누적 길이·타임라인 매핑이 동일 자막 싱크 영향 없음.
분할 min_len 미만 조각은 조각과 병합(너무 잘게 쪼개짐 방지).
"""
if not scene_times:
return video_clips
out: List[Segment] = []
for s, e in video_clips:
# 이 구간 내부의 장면전환 지점만(양끝 여유 배제)
cuts = [t for t in scene_times if s + min_len <= t <= e - min_len]
if not cuts:
out.append((s, e))
continue
prev = s
for t in cuts:
if t - prev >= min_len:
out.append((prev, t))
prev = t
out.append((prev, e))
return out

90
capcut_agent/silence.py Normal file
View File

@ -0,0 +1,90 @@
"""ffmpeg silencedetect 기반 무음 감지 → 발화(speech) 구간 추출.
핵심: 무음을 직접 찾고, 여집합 = 발화 구간. 발화 구간 끝에 padding
주어 단어 시작/끝이 잘리지 않게 한다(점프컷 특유의 '말 잘림' 방지).
"""
from __future__ import annotations
import re
import subprocess
from typing import List, Tuple
Segment = Tuple[float, float] # (start_sec, end_sec)
_SILENCE_START = re.compile(r"silence_start:\s*(-?[\d.]+)")
_SILENCE_END = re.compile(r"silence_end:\s*(-?[\d.]+)")
def _detect_silences(video_path: str, noise_db: float, min_silence: float) -> List[Segment]:
"""무음 구간 [(start, end), ...] (초) 반환."""
cmd = [
"ffmpeg", "-hide_banner", "-nostats", "-i", video_path,
"-af", f"silencedetect=noise={noise_db}dB:d={min_silence}",
"-f", "null", "-",
]
# silencedetect 로그는 stderr 로 나온다. 단, Windows에서 부모 프로세스(uvicorn)의
# stderr 핸들이 리다이렉트된 경우 capture_output 의 stderr PIPE 가 None 으로 잡히는
# 케이스가 있어, stderr→stdout 병합 후 stdout 을 읽는다(검증된 경로).
proc = subprocess.run(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, encoding="utf-8", errors="replace",
)
log = proc.stdout or ""
silences: List[Segment] = []
cur_start = None
for line in log.splitlines():
m = _SILENCE_START.search(line)
if m:
cur_start = float(m.group(1))
continue
m = _SILENCE_END.search(line)
if m and cur_start is not None:
silences.append((cur_start, float(m.group(1))))
cur_start = None
return silences
def detect_speech_segments(
video_path: str,
duration: float,
*,
noise_db: float = -30.0,
min_silence: float = 0.4,
pad: float = 0.08,
min_speech: float = 0.2,
) -> List[Segment]:
"""무음의 여집합 = 발화 구간. padding/merge/최소길이 필터 적용.
Args:
duration: 전체 영상 길이(). probe 미리 구한 .
noise_db: dB 이하를 무음으로 간주.
min_silence: 길이() 이상 지속돼야 무음으로 .
pad: 발화 구간 여유(). 잘림 방지.
min_speech: 이보다 짧은 발화 조각은 버림(노이즈성 방지).
"""
silences = _detect_silences(video_path, noise_db, min_silence)
# 무음의 여집합 = 발화
speech: List[Segment] = []
cursor = 0.0
for s_start, s_end in silences:
if s_start > cursor:
speech.append((cursor, s_start))
cursor = max(cursor, s_end)
if cursor < duration:
speech.append((cursor, duration))
# padding (양 끝 확장) + 클램프
padded = [(max(0.0, s - pad), min(duration, e + pad)) for s, e in speech]
# padding 으로 겹친 구간 병합
merged: List[Segment] = []
for s, e in padded:
if merged and s <= merged[-1][1]:
merged[-1] = (merged[-1][0], max(merged[-1][1], e))
else:
merged.append((s, e))
# 최소 발화 길이 필터
return [(s, e) for s, e in merged if (e - s) >= min_speech]

129
capcut_agent/transcribe.py Normal file
View File

@ -0,0 +1,129 @@
"""faster-whisper 기반 전체 대본 전사 (세그먼트 + 단어 타임스탬프).
핵심 원칙(참고 영상 08:11): 전체 대본을 먼저 뽑아 맥락을 잡는다.
대본이 NG 탐지 · 자막 · 숏폼 하이라이트 선택의 공통 재료.
함정:
- ASR(numba) 동시 호출 segfault 호출부(pipeline)에서 asyncio.Lock 직렬화.
- 캐시는 content hash 기준(mtime 아님) 같은 영상 재업로드 hit.
"""
from __future__ import annotations
import hashlib
import json
import os
from dataclasses import asdict, dataclass
from typing import List, Optional
CACHE_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".cache")
os.makedirs(CACHE_DIR, exist_ok=True)
# 모델 인스턴스 재사용(로딩 비쌈). (model_size, compute_type) → WhisperModel
_MODEL_CACHE: dict = {}
@dataclass
class Word:
start: float
end: float
text: str
@dataclass
class Seg:
start: float
end: float
text: str
words: List[Word]
@dataclass
class Transcript:
language: str
text: str # 전체 대본 (한 덩어리)
segments: List[Seg]
def to_json(self) -> dict:
return {
"language": self.language,
"text": self.text,
"segments": [
{"start": s.start, "end": s.end, "text": s.text,
"words": [asdict(w) for w in s.words]}
for s in self.segments
],
}
@staticmethod
def from_json(d: dict) -> "Transcript":
return Transcript(
language=d["language"], text=d["text"],
segments=[
Seg(s["start"], s["end"], s["text"],
[Word(**w) for w in s["words"]])
for s in d["segments"]
],
)
def _file_hash(path: str) -> str:
h = hashlib.sha1()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()[:12]
def _get_model(model_size: str, compute_type: str):
key = (model_size, compute_type)
if key not in _MODEL_CACHE:
from faster_whisper import WhisperModel # 지연 import (무거움)
_MODEL_CACHE[key] = WhisperModel(model_size, device="cpu", compute_type=compute_type)
return _MODEL_CACHE[key]
def transcribe(
video_path: str,
*,
model_size: str = "medium",
language: Optional[str] = "ko",
compute_type: str = "int8",
use_cache: bool = True,
vad_filter: bool = True,
) -> Transcript:
"""영상 전사 → Transcript. content-hash 캐시 적용.
vad_filter=True: 무음 제거(환각) 하지만 짧은 외침을 놓칠 있음.
vad_filter=False: 모든 발화 포착(자막 커버리지) 컷이 오디오 기준일 자막용으로 적합.
동기 함수. 호출부에서 ASR_LOCK 으로 직렬화하고 thread 돌릴 .
"""
h = _file_hash(video_path)
tag = ("vad" if vad_filter else "novad") + "2" # 2 = 환각억제(no_repeat) 설정
cache_path = os.path.join(CACHE_DIR, f"asr_{model_size}_{tag}_{h}.json")
if use_cache and os.path.exists(cache_path):
with open(cache_path, encoding="utf-8") as f:
return Transcript.from_json(json.load(f))
model = _get_model(model_size, compute_type)
seg_iter, info = model.transcribe(
video_path,
language=language,
word_timestamps=True,
vad_filter=vad_filter,
vad_parameters={"min_silence_duration_ms": 400} if vad_filter else None,
condition_on_previous_text=False, # 세그먼트 간 환각 드리프트 억제
no_repeat_ngram_size=3, # 긴 어구 환각 루프(외국어 등) 차단. 단어 반복은 허용
)
segments: List[Seg] = []
for s in seg_iter: # 제너레이터 → 여기서 실제 추론 진행
words = [Word(w.start, w.end, w.word) for w in (s.words or [])]
segments.append(Seg(s.start, s.end, s.text.strip(), words))
full_text = " ".join(s.text for s in segments).strip()
tr = Transcript(language=info.language, text=full_text, segments=segments)
if use_cache:
with open(cache_path, "w", encoding="utf-8") as f:
json.dump(tr.to_json(), f, ensure_ascii=False, indent=1)
return tr

442
capcut_agent/youtube.py Normal file
View File

@ -0,0 +1,442 @@
"""유튜브 구간 잘라받기 (ClipCut 로직 흡수). yt-dlp --download-sections.
캡컷 에이전트 앞단: URL + 시작/ 구간만 h264 mp4 받아 파이프라인에 투입.
"""
from __future__ import annotations
import glob
import hashlib
import json
import os
import re
import shutil
import subprocess
from typing import List, Tuple
def _js_runtime_args() -> List[str]:
"""yt-dlp 유튜브 추출용 JS 런타임 지정. 설치된 것(deno/node/bun) 자동 선택.
최신 유튜브는 JS 챌린지 때문에 런타임이 없으면 포맷 누락ffmpeg 크래시가 난다.
없으면 리스트(사용자가 Node.js 설치 필요).
"""
for rt in ("deno", "node", "bun"):
if shutil.which(rt):
return ["--js-runtimes", rt]
return []
# MM:SS 또는 HH:MM:SS
TIME_RE = re.compile(r"^(?:\d{1,2}:)?\d{1,2}:[0-5]\d$")
TIMEOUT_SEC = 1800
def valid_time(t: str) -> bool:
return bool(TIME_RE.match(t.strip()))
def _video_codec(path: str) -> str:
try:
out = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=codec_name", "-of", "json", path],
capture_output=True, text=True, encoding="utf-8", errors="replace", check=True,
).stdout
return json.loads(out)["streams"][0]["codec_name"]
except Exception:
return ""
# ── 초록 프레임(GOP 중간 컷) 검증·복구 ──────────────────────────────────
# yt-dlp --download-sections 가 가끔 키프레임이 아닌 위치에서 스트림 복사로 잘라
# 파일을 만든다. 그러면 첫 키프레임 전까지는 참조 프레임이 없어 디코딩이 안 되고,
# 그 파일을 concat 재인코딩할 때 그 구간이 통째로 '초록 화면'으로 구워진다.
# ⚠ 함정: 파트를 단독 재생하면 ffmpeg 가 깨진 앞부분을 건너뛰어서 멀쩡해 보이고,
# ffprobe -read_intervals 도 키프레임으로 시크해버려 못 잡는다.
# → 시크 없이 프레임을 훑어 '첫 키프레임 시각'을 봐야 한다(0 이 아니면 깨진 것).
KEYFRAME_TOL = 0.05 # 첫 키프레임이 이보다 늦으면 앞부분 깨짐으로 판정(초)
DL_ATTEMPTS = 2 # 깨진 결과 재다운로드 횟수(간헐적 실패용). 그 뒤엔 재컷으로 확정 해결
RECUT_LEAD = 6.0 # 최후 수단: 앞에 이만큼 여유를 받아 로컬에서 다시 자름(초)
# 이번 요청에서 초록 깨짐을 고친 내역(파이프라인이 읽어 SSE 로그로 흘림).
# 로컬 단일 사용자 앱이라 모듈 전역으로 둔다 — 동시 작업 시 로그가 섞일 수 있으나 무해.
REPAIR_LOG: List[str] = []
def _first_keyframe_sec(path: str, max_frames: int = 3000) -> float:
"""첫 비디오 키프레임의 시각(초). 0 이면 맨 앞이 키프레임 = 정상.
시크를 쓰지 않고(=`-read_intervals` 금지) 앞에서부터 훑는다. 키프레임을 만나면
즉시 종료하므로 정상 파일에서는 줄만 읽고 끝난다.
판정 불가(ffprobe 실패 ) 0.0 통과시킨다(다운로드를 살리는 쪽으로).
"""
try:
proc = subprocess.Popen(
["ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "frame=key_frame,pts_time", "-of", "csv=p=0", path],
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
text=True, encoding="utf-8", errors="replace",
)
except OSError:
return 0.0
try:
for i, line in enumerate(proc.stdout or []):
if i >= max_frames:
break
f = line.strip().split(",")
if len(f) >= 2 and f[0] == "1":
try:
return float(f[1])
except ValueError:
return 0.0
return 0.0
finally:
# ⚠ Windows: 파이프를 안 닫으면 ffprobe 가 파일을 계속 잡고 있어
# 바로 뒤따르는 삭제/덮어쓰기가 PermissionError 로 실패한다.
try:
if proc.stdout:
proc.stdout.close()
except OSError:
pass
proc.kill()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
pass
def _duration(path: str) -> float:
try:
out = subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "json", path],
capture_output=True, text=True, encoding="utf-8", errors="replace", check=True,
).stdout
return float(json.loads(out)["format"]["duration"])
except Exception:
return 0.0
def _encode_h264(src: str, dst: str, *, ss: float = 0.0, t: float = 0.0) -> str:
"""h264/aac 로 재인코딩. ss/t 는 '-i 뒤'에 둬서 프레임 정확(출력 시크)."""
cmd = ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", src]
if ss > 0:
cmd += ["-ss", f"{ss:.3f}"]
if t > 0:
cmd += ["-t", f"{t:.3f}"]
cmd += ["-c:v", "libx264", "-preset", "veryfast", "-crf", "20",
"-pix_fmt", "yuv420p", "-c:a", "aac", "-ar", "44100", dst]
subprocess.run(cmd, check=True, encoding="utf-8", errors="replace")
return dst
def _unlink(path: str) -> None:
try:
os.remove(path)
except OSError:
pass
def cut_youtube(url: str, start: str, end: str, out_dir: str) -> Tuple[str, str, str]:
"""유튜브 [start,end] 구간을 h264 mp4 로 받아 (경로, 제목, 채널명) 반환.
- h264(avc1)+aac 우선 pycapcut/CapCut 호환(AV1/VP9 함정 회피). 되면 best.
- 받은 h264 아니면 ffmpeg h264 재인코딩.
- 채널명(uploader) 함께 받아 출처 자동 입력에 사용.
"""
url, start, end = url.strip(), start.strip(), end.strip()
if not (url.startswith("http://") or url.startswith("https://")):
raise ValueError("올바른 URL이 아닙니다.")
if not valid_time(start) or not valid_time(end):
raise ValueError("시간 형식 오류. 예) 03:30 또는 01:03:30")
os.makedirs(out_dir, exist_ok=True)
base_suffix = f"{start}-{end}".replace(":", "")
def _dl(section: str, tag: str = "") -> Tuple[str, str, str]:
"""tag 는 재시도용 파일명 구분자. 제목에선 떼어내 드래프트 이름을 깨끗이 유지."""
suffix = base_suffix + tag
outtmpl = os.path.join(out_dir, f"%(title).80s_{suffix}.%(ext)s")
cmd = [
"yt-dlp", *_js_runtime_args(),
"--download-sections", section,
# h264(avc1) + aac 우선, 안되면 best
"-f", "bv*[vcodec^=avc1]+ba[acodec^=mp4a]/bv*[vcodec^=avc1]+ba/b[ext=mp4]/b",
"--merge-output-format", "mp4", "--no-playlist",
# 채널명(uploader) + 최종 경로 출력 (탭 구분, after_move 시점)
"--no-simulate", "--print", "after_move:%(uploader)s\t%(filepath)s",
"-o", outtmpl, url,
]
# yt-dlp 가 한글 경로를 UTF-8 로 출력하도록 강제(Windows cp949 디코드 깨짐 방지)
env = {**os.environ, "PYTHONIOENCODING": "utf-8", "PYTHONUTF8": "1"}
proc = subprocess.run(cmd, capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=TIMEOUT_SEC, env=env)
if proc.returncode != 0:
tail = (proc.stderr or proc.stdout or "").strip()[-800:]
raise RuntimeError(f"yt-dlp 실패:\n{tail}")
# stdout 에서 채널명 파싱 (경로는 폴더 스캔으로 확정 — 인코딩 의존 제거)
ch = ""
for line in (proc.stdout or "").splitlines():
if "\t" in line:
ch = line.split("\t", 1)[0].strip()
# 출력 폴더에서 suffix 매칭 최신 파일 = 결과물 (stdout 경로 인코딩에 의존 안 함)
cands = [c for c in glob.glob(os.path.join(out_dir, f"*_{suffix}.*"))
if c.lower().endswith((".mp4", ".mkv", ".webm"))]
if not cands:
raise RuntimeError("다운로드 결과 파일을 찾지 못했습니다.")
p = max(cands, key=os.path.getmtime)
t = os.path.splitext(os.path.basename(p))[0]
if tag and t.endswith(tag):
t = t[: -len(tag)]
if _video_codec(p) != "h264": # CapCut 호환(AV1/VP9 회피)
h264 = os.path.splitext(p)[0] + "_h264.mp4"
_encode_h264(p, h264)
_unlink(p)
p = h264
return p, t, ch
# GOP 중간 컷(앞부분 초록) 검증 → 재시도 → 최후엔 앞 여유 붙여 로컬 재컷.
# 상세는 cut_youtube_precise 주석 참고(같은 문제·같은 전략).
last_lead = 0.0
for attempt in range(1, DL_ATTEMPTS + 1):
path, title, channel = _dl(f"*{start}-{end}", "" if attempt == 1 else f"r{attempt}")
last_lead = _first_keyframe_sec(path)
if last_lead <= KEYFRAME_TOL:
if attempt > 1:
REPAIR_LOG.append(f"{start}~{end}: 앞부분 초록 → 재다운로드로 해결")
return path, title, channel
_unlink(path)
REPAIR_LOG.append(f"{start}~{end}: 앞부분 초록 {last_lead:.1f}s → 여유분 재다운로드 후 정밀 재컷")
start_sec, end_sec = _hms_to_sec(start), _hms_to_sec(end)
want = end_sec - start_sec
lead = min(max(RECUT_LEAD, last_lead * 2 + 2.0), start_sec)
if lead <= 0:
raise RuntimeError(
f"구간 앞부분이 계속 깨집니다(첫 키프레임 {last_lead:.2f}s). "
"시작 시각을 조금 뒤로 옮겨 다시 시도하세요.")
path, title, channel = _dl(f"*{_fmt_hms(start_sec - lead)}-{_fmt_hms(end_sec)}", "lead")
recut = os.path.splitext(path)[0] + "_recut.mp4"
_encode_h264(path, recut, ss=max(0.0, _duration(path) - want), t=want)
_unlink(path)
return recut, title, channel
def _concat_parts(parts: List[str], out_dir: str, key: str) -> str:
"""h264 mp4 조각들을 concat demuxer 로 재인코딩 병합 → 합친 파일 경로.
Windows ffmpeg concat demuxer 목록 파일 '내부' non-ASCII(한글) 경로를
열지 못한다("Impossible to open ... Invalid argument"). argv 직접 넘기는 경로는
되지만 목록 경로는 유니코드 변환이 되기 때문. 조각들을 ASCII 임시 이름
(하드링크, 같은 볼륨이라 데이터 복사 없음)으로 가리켜 목록에 넣고, 병합 임시
링크·목록만 지운다. 목록의 상대 basename 목록 파일이 있는 폴더 기준으로 해석된다.
"""
list_txt = os.path.join(out_dir, f"_concat_{key}.txt")
merged = os.path.join(out_dir, f"merged_{key}.mp4")
tmp_links: List[str] = []
try:
with open(list_txt, "w", encoding="utf-8") as f:
for i, p in enumerate(parts):
ext = os.path.splitext(p)[1] or ".mp4"
link = os.path.join(out_dir, f"_cpart_{key}_{i}{ext}")
if os.path.abspath(link) == os.path.abspath(p):
name = os.path.basename(p) # 이미 ASCII 임시명 (이론상 없음)
else:
try:
if os.path.exists(link):
os.remove(link)
os.link(p, link) # 하드링크 (데이터 복사 없음)
except OSError:
shutil.copy2(p, link) # 폴백: 복사 (다른 볼륨 등)
tmp_links.append(link)
name = os.path.basename(link)
f.write(f"file '{name}'\n")
subprocess.run(
["ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
"-f", "concat", "-safe", "0", "-i", list_txt,
"-c:v", "libx264", "-preset", "veryfast", "-crf", "20",
"-pix_fmt", "yuv420p", "-c:a", "aac", "-ar", "44100", merged],
check=True, encoding="utf-8", errors="replace",
)
return merged
finally:
for link in tmp_links:
try:
os.remove(link)
except OSError:
pass
try:
os.remove(list_txt)
except OSError:
pass
def cut_youtube_multi(
url: str,
ranges: List[Tuple[str, str]],
out_dir: str,
) -> Tuple[str, str, str]:
"""한 URL의 여러 [start,end] 구간을 각각 받아 순서대로 이어붙인 h264 mp4 반환.
Returns: (합친영상경로, 제목, 채널명). 구간이 1개면 그대로 반환(병합 생략).
구간은 cut_youtube h264/aac 통일 concat demuxer 재인코딩으로 안전 병합.
"""
if not ranges:
raise ValueError("구간이 하나도 없습니다.")
os.makedirs(out_dir, exist_ok=True)
parts: List[str] = []
title = channel = ""
for i, (s, e) in enumerate(ranges):
p, t, ch = cut_youtube(url, s, e, out_dir)
parts.append(p)
if i == 0:
title, channel = t, ch
if len(parts) == 1:
return parts[0], title, channel
key = hashlib.sha1("|".join(f"{s}-{e}" for s, e in ranges).encode()).hexdigest()[:8]
merged = _concat_parts(parts, out_dir, key)
merged_title = f"{title}_{len(ranges)}구간" if title else f"merged_{key}"
return merged, merged_title, channel
def _hms_to_sec(t: str) -> float:
"""MM:SS 또는 HH:MM:SS → 초. (구간 탭 입력 → 로컬 재컷 계산용)"""
parts = [float(x) for x in t.strip().split(":")]
sec = 0.0
for p in parts:
sec = sec * 60 + p
return sec
def _fmt_hms(sec: float) -> str:
"""초(float) → HH:MM:SS.mmm (yt-dlp download-sections·정밀 컷용)."""
sec = max(0.0, float(sec))
h = int(sec // 3600)
m = int((sec % 3600) // 60)
s = sec % 60
return f"{h:02d}:{m:02d}:{s:06.3f}"
def _dl_section(url: str, start_sec: float, end_sec: float, out_dir: str,
tag: str = "") -> Tuple[str, str, str]:
"""[start_sec, end_sec] 구간 1회 다운로드 → (경로, 제목, 채널). h264 로 정규화.
1) --force-keyframes-at-cuts (프레임 정확) 2) 실패 키프레임 폴백.
tag 파일명 suffix 섞어 재시도 결과가 이전 파일과 섞이게 한다.
"""
section = f"*{_fmt_hms(start_sec)}-{_fmt_hms(end_sec)}"
suffix = hashlib.sha1((section + tag).encode()).hexdigest()[:10]
outtmpl = os.path.join(out_dir, f"%(title).60s_{suffix}.%(ext)s")
env = {**os.environ, "PYTHONIOENCODING": "utf-8", "PYTHONUTF8": "1"}
def _run(extra):
cmd = [
"yt-dlp", *_js_runtime_args(),
"--download-sections", section, *extra,
"-f", "bv*[vcodec^=avc1]+ba[acodec^=mp4a]/bv*[vcodec^=avc1]+ba/b[ext=mp4]/b",
"--merge-output-format", "mp4", "--no-playlist",
"--no-simulate", "--print", "after_move:%(uploader)s\t%(filepath)s",
"-o", outtmpl, url,
]
return subprocess.run(cmd, capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=TIMEOUT_SEC, env=env)
proc = _run(["--force-keyframes-at-cuts"])
if proc.returncode != 0:
proc = _run([])
if proc.returncode != 0:
tail = (proc.stderr or proc.stdout or "").strip()[-800:]
raise RuntimeError(f"yt-dlp 실패:\n{tail}")
channel = ""
for line in (proc.stdout or "").splitlines():
if "\t" in line:
channel = line.split("\t", 1)[0].strip()
cands = [c for c in glob.glob(os.path.join(out_dir, f"*_{suffix}.*"))
if c.lower().endswith((".mp4", ".mkv", ".webm"))]
if not cands:
raise RuntimeError("다운로드 결과 파일을 찾지 못했습니다.")
path = max(cands, key=os.path.getmtime)
title = os.path.splitext(os.path.basename(path))[0]
if _video_codec(path) != "h264": # CapCut 호환(AV1/VP9 회피)
h264 = os.path.splitext(path)[0] + "_h264.mp4"
_encode_h264(path, h264)
_unlink(path)
path = h264
return path, title, channel
def cut_youtube_precise(url: str, start_sec: float, end_sec: float,
out_dir: str) -> Tuple[str, str, str]:
"""[start_sec, end_sec] (밀리초 정밀) 구간을 프레임 정확히·초록 없이 잘라 h264 mp4 로.
yt-dlp 가끔 GOP 중간에서 잘라 앞부분이 깨진(초록) 파일을 준다. 그대로 두면
병합 재인코딩 초록 화면이 구워지므로 매번 검증한다:
1) 받은 파일의 키프레임이 앞이면 정상 그대로 사용
2) 아니면 버리고 재다운로드 (DL_ATTEMPTS ) 간헐적 실패라 대개 여기서 해결
3) 그래도 깨지면 앞에 RECUT_LEAD 여유를 붙여 받아 로컬에서 뒤쪽 want 초만
재인코딩으로 잘라낸다. 깨진 앞부분은 버리는 여유 구간에 들어가므로 항상 깨끗하다.
"""
url = url.strip()
if not (url.startswith("http://") or url.startswith("https://")):
raise ValueError("올바른 URL이 아닙니다.")
if end_sec <= start_sec:
raise ValueError("end 는 start 보다 커야 합니다.")
os.makedirs(out_dir, exist_ok=True)
want = end_sec - start_sec
last_lead = 0.0
for attempt in range(1, DL_ATTEMPTS + 1):
path, title, channel = _dl_section(
url, start_sec, end_sec, out_dir, tag="" if attempt == 1 else f"r{attempt}")
last_lead = _first_keyframe_sec(path)
if last_lead <= KEYFRAME_TOL:
if attempt > 1:
REPAIR_LOG.append(f"{start_sec:.1f}~{end_sec:.1f}s: 앞부분 초록 → 재다운로드로 해결")
return path, title, channel
_unlink(path) # 앞 last_lead 초가 초록 → 버리고 다시
# 최후 수단: 앞 여유 + 로컬 정밀 재컷 (여유가 깨져도 어차피 버리는 부분)
REPAIR_LOG.append(
f"{start_sec:.1f}~{end_sec:.1f}s: 앞부분 초록 {last_lead:.1f}s → 여유분 재다운로드 후 정밀 재컷")
lead = min(max(RECUT_LEAD, last_lead * 2 + 2.0), start_sec)
if lead <= 0:
raise RuntimeError(
f"구간 앞부분이 계속 깨집니다(첫 키프레임 {last_lead:.2f}s). "
"시작 시각을 조금 뒤로 옮겨 다시 시도하세요.")
path, title, channel = _dl_section(url, start_sec - lead, end_sec, out_dir, tag="lead")
dur = _duration(path)
recut = os.path.splitext(path)[0] + "_recut.mp4"
_encode_h264(path, recut, ss=max(0.0, dur - want), t=want) # 뒤에서 want 초만
_unlink(path)
return recut, title, channel
def download_paste_cuts(url: str, cuts: List[Tuple[float, float]],
out_dir: str) -> Tuple[str, str, str]:
"""붙여넣기 컷들(초 단위, 정밀)을 각각 정확히 잘라 순서대로 병합.
Returns: (합친영상경로, 제목, 채널명). 1개면 병합 생략.
"""
if not cuts:
raise ValueError("컷이 하나도 없습니다.")
os.makedirs(out_dir, exist_ok=True)
parts: List[str] = []
title = channel = ""
for i, (s, e) in enumerate(cuts):
p, t, ch = cut_youtube_precise(url, s, e, out_dir)
parts.append(p)
if i == 0:
title, channel = t, ch
if len(parts) == 1:
return parts[0], title, channel
key = hashlib.sha1("|".join(f"{s:.3f}-{e:.3f}" for s, e in cuts).encode()).hexdigest()[:8]
merged = _concat_parts(parts, out_dir, key)
return merged, (f"{title}_{len(cuts)}" if title else f"paste_{key}"), channel

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,339 @@
# 자동 탭 — 오팔 대체 + 댓글 카드 자동 매칭 (설계)
작성일: 2026-07-31
## 1. 배경과 목적
지금 숏폼 5개를 만들려면 사람이 이 순서로 움직인다:
1. 오팔(opal.google.com) 편집기를 열어 Step 3 노드 5개를 **하나씩** 클릭해 JSON을 복사
2. capcut2 붙여넣기 탭에 붙여넣고 실행 → 끝날 때까지 기다림 → 다음 것 복사 (5회 반복)
3. h-lab 댓글 카드 페이지를 열어 영상 댓글을 가져오고, 카드를 골라 PNG로 저장한 뒤
`댓글카드/` 폴더에 넣어둠
복사·붙여넣기 5회 + 브라우저 왕복 + 댓글 수작업이 전부 손이다. 이걸 **유튜브 URL 하나 →
검토 화면에서 클릭 몇 번 → 드래프트 5개**로 줄인다.
핵심 착안점: 오팔 Step 3의 결과에는 `start_time`/`end_time`이 있고, 유튜브 댓글 상당수는
본문에 `2:14` 같은 타임스탬프를 적는다. **그 구간을 언급한 댓글**을 자동으로 뽑으면
"영상에 맞는 댓글 카드"가 사람 손 없이 정해진다.
## 2. 범위
### 하는 것
- 새 탭 **🤖 자동** 추가 — 유튜브 URL 입력 → Gemini 분석 → 검토 → 드래프트 5개 생성
- Gemini API로 오팔 Step 1/Step 3 대체 (`.gemini_key` 재사용)
- h-lab 원격 API에서 댓글을 받아 구간별 자동 매칭 + 부족분 수동 선택
- 브라우저에서 댓글 카드 PNG를 구워 서버로 업로드 (h-lab 카드 디자인 그대로)
- 프롬프트를 파일로 분리해 UI/메모장 어느 쪽에서든 수정 가능
- `_load_comment_cards` 배치 규칙 변경 (모자라면 균등 분배) — **기존 3개 탭에도 적용**
### 하지 않는 것 (명시)
- **붙여넣기 탭을 없애지 않는다.** 오팔을 계속 써도 되고, Gemini가 실패하면 그리로 도망갈 수 있어야 한다.
- **h-lab 코드를 고치지 않는다.** 필요한 API가 이미 다 열려 있다 (§7에 근거).
- **서버에 작업 큐를 만들지 않는다.** 브라우저가 `/auto/build`를 하나씩 순차 호출하면 충분하다.
- **Pillow로 카드를 그리지 않는다.** 디자인이 h-lab과 달라진다.
- **빌드를 병렬로 돌리지 않는다.** yt-dlp·ffmpeg·Whisper가 CPU를 다 쓴다.
- 오팔 Step 2를 옮기지 않는다 — 노드 간 값 전달용이라 코드에선 배열 인덱싱이다.
## 3. 전체 흐름
```
[유튜브 URL] → 분석 시작
├─ Gemini Step 1 (전체 영상) → 후보 5개 {id, start_time, end_time, reason}
├─ Gemini Step 3 × 5 (구간별, 동시) → 블록① JSON + 블록② 타이틀 후보 5선
└─ h-lab fetch × 1 → 댓글 전체 + 본문 mm:ss 파싱
[검토 화면] 하이라이트 카드 5장, 각각:
├ 제목 ▾ 타이틀 후보 5선에서 교체 가능
├ 컷 8개 · 총 52.3초 → 카드 17장 필요
├ ⭐ 2:14~4:02 구간을 언급한 댓글 9장 (좋아요순 자동 체크)
좋아요 상위 후보 20장 (부족한 8장을 클릭)
[5개 전부 만들기]
브라우저: 선택된 카드를 modern-screenshot으로 PNG 캡처(4배)
→ POST /auto/build (하이라이트 1개 + 카드 PNG들) → job_id
→ GET /stream/{job_id} 진행 표시
→ 끝나면 다음 하이라이트로 (순차 5회)
```
Gemini 호출은 총 6번. Step 3 5개는 **동시 요청**(약 1분 → 약 20초). 429(무료 한도)가 나면
그 구간만 순차로 재시도한다.
## 4. 구성요소
| 파일 | 상태 | 하는 일 |
|---|---|---|
| `capcut_agent/plan.py` | 신규 | Gemini 호출(Step 1 / Step 3), 응답에서 JSON 블록·타이틀 후보 추출 |
| `capcut_agent/comments.py` | 신규 | h-lab 댓글 fetch, 본문 타임스탬프 파싱, 구간 매칭·순위 |
| `capcut_agent/prompts.py` | 신규 | 프롬프트 파일 읽기/쓰기/기본값 복원 |
| `server/static/auto.js` | 신규 | 검토 화면, 카드 렌더·캡처, 순차 빌드 |
| `server/static/modern-screenshot.js` | 신규(벤더링) | DOM→PNG 캡처. CDN 대신 동봉해 오프라인·버전고정 |
| `server/app.py` | 수정 | 엔드포인트 5개 추가 |
| `server/static/index.html` | 수정 | 탭 1개 + 패널 추가, 카드 CSS 이식. 기존 3탭 손대지 않음 |
| `capcut_agent/pipeline.py` | 수정 | `_load_comment_cards` 배치 규칙 (§6) |
| `숏폼_편집_지침서_v13.7_capcut2연동판.md` | 기존 그대로 | **Step 3 프롬프트 설정 파일로 그대로 사용** |
| `프롬프트/하이라이트_선정.md` | 신규(자동 생성) | Step 1 프롬프트 |
### 4.1 `capcut_agent/plan.py`
```python
DEFAULT_MODEL = "gemini-3.5-flash" # 설정에서 교체 가능 (§4.5)
# correct.py 의 _gemini_key() / GeminiQuotaError 는 재사용
def select_highlights(url, *, key=None, prompt=None, model=None) -> list[dict]
# → [{"id":1, "start":134.0, "end":242.0, "reason":"…"}, …]
def edit_plan(url, start_sec, end_sec, *, key=None, prompt=None, model=None) -> dict
# → {"paste": {…parse_paste 결과…}, "titles": [{"top":…,"main":…,"kind":"어그로형"}, …]}
```
유튜브 URL을 Gemini에 그대로 넘기고, 구간은 `videoMetadata`로 자른다 (오팔 Step 2의 역할):
```json
{"contents":[{"parts":[
{"fileData":{"fileUri":"https://www.youtube.com/watch?v=…"},
"videoMetadata":{"startOffset":"134s","endOffset":"242s"}},
{"text":"<프롬프트 전문>"}]}],
"generationConfig":{"temperature":0.7}}
```
#### 영상 샘플링 — Step 1과 Step 3을 다르게 보낸다
Gemini는 영상을 기본 **1 FPS · 초당 약 300토큰**으로 읽는다. 그대로 두면 **Step 1(전체 영상)이
55분쯤에서 컨텍스트 100만 토큰을 넘겨 실패한다.** Step 3은 구간이 1분30초~3분이라 무관하다.
| | 보내는 것 | fps | 1시간 원본 기준 |
|---|---|---|---|
| Step 1 | 전체 영상 1회 | **0.2** (5초당 1프레임) | 약 22만 토큰 — 통과 |
| Step 3 | 구간 1개 × 5회 | 기본(1.0) | 구간당 약 5만 토큰 |
`videoMetadata.fps`로 지정한다. 하이라이트 구간을 고르는 일은 표정 디테일보다 흐름·오디오를
보는 작업이라 0.2 fps로 충분하고, **오디오는 fps와 무관하게 그대로 들어간다.** 정밀한 컷 지점과
verbatim 자막이 필요한 Step 3만 기본 fps로 보낸다.
Step 1의 fps도 설정값이다 — 짧은 영상만 다루게 되면 올리면 된다.
응답 파싱:
- Step 3은 블록 ①②③ 세 덩어리로 오므로 **첫 번째 ` ```json ` 펜스 안쪽**만 꺼낸다.
펜스가 없으면 본문 전체를 시도한다.
- 꺼낸 JSON은 **기존 `parse_paste()`에 그대로 통과시킨다** — 검증 로직을 두 벌 만들지 않는다.
- `url` 필드는 파싱 후 **사용자가 입력한 URL로 덮어쓴다** (LLM이 영상 ID를 지어내는 사고 차단).
- 블록 ②는 `^\s*\d+\.\s*상단:\s*(.+?)\s*/\s*메인:\s*(.+?)\s*(?:—\s*(.+))?$` 로 긁는다.
실패해도 오류가 아니라 빈 리스트 → UI에서 드롭다운만 안 뜬다.
`correct.py``_gemini_key()` / `GeminiQuotaError`를 재사용한다.
#### Step 3 타임코드 기준 보정
구간을 잘라 보낸 클립에 대해 모델이 타임코드를 **원본 기준**으로 줄지 **클립 기준(0부터)**
으로 줄지 보장이 없다(문서에 명시 없음). 어느 쪽이 와도 살아남게 휴리스틱으로 보정한다:
1. 모든 컷이 `[start10, end+10]` 안 → 절대 기준으로 보고 그대로 둔다 (우선)
2. 아니고 모든 컷이 `[0, 클립길이+10]` 안이며 `start > 10` → 클립 기준으로 보고 `start`를 더한다
3. 둘 다 아니면 손대지 않는다 (이후 `parse_paste`·다운로드 단계에서 자연히 드러남)
보정이 일어나면 로그에 남긴다.
### 4.2 `capcut_agent/comments.py`
```python
H_LAB = "https://h-lab.tolag.shop"
def fetch_comments(url, *, timeout=180) -> list[dict]
# POST /api/comment-cards/fetch → data[] 그대로 + idx 부여 + times 계산
TS_RE = r"(?<!\d)(\d{1,2}):([0-5]\d)(?::([0-5]\d))?(?!\d)" # h-lab comment-cards.js 와 동일
def match_window(comments, start, end) -> list[dict] # 구간 언급 댓글, 좋아요 내림차순
def top_liked(comments, exclude_idx, n=20) -> list[dict]
```
- 2조각이면 `mm:ss`, 3조각이면 `h:mm:ss` — h-lab JS 규칙을 그대로 옮긴다.
- 한 댓글에 여러 시각이 있으면 **하나라도 구간 안에 들면 매칭**.
- 댓글 원본에 id가 없으므로 h-lab과 같이 **배열 인덱스**를 식별자로 쓴다.
### 4.3 엔드포인트
| 메서드 | 경로 | 내용 |
|---|---|---|
| POST | `/auto/analyze` | Form `url``{analysis_id}`. 실제 작업은 아래 스트림에서 |
| GET | `/auto/stream/{analysis_id}` | SSE. 기존 `/stream`과 동일한 이벤트(`manifest`/`step`/`log`/`result`/`error`) |
| GET | `/auto/avatar?url=` | 프로필 이미지 프록시. `ggpht.com`·`googleusercontent.com`만 허용 |
| GET·POST | `/prompts` | 프롬프트 두 개 읽기/저장. POST에 `reset=1`이면 기본값 복원 |
| POST | `/auto/build` | multipart. 하이라이트 1개 + 카드 PNG들 → `{job_id}` (기존 `/stream`으로 진행 표시) |
`/auto/stream`의 최종 `result` 이벤트 payload:
```json
{"type":"result",
"highlights":[{"id":1,"start":134.0,"end":242.0,"reason":"…",
"paste":{…parse_paste 결과…},
"titles":[{"top":"…","main":"…","kind":"어그로형"}, …],
"total":52.3,"need":17,
"matched":[12,45,3,…], // 댓글 인덱스, 좋아요순
"candidates":[7,19,…]}], // 좋아요 상위 20 (matched 제외)
"comments":[{"idx":0,"authorName":"…","text":"…","likeCount":275098,
"replyCount":1000,"publishedAt":"…","profileImageUrl":"…","times":[134.0]}],
"warnings":["h-lab 연결 실패 — 댓글 없이 진행합니다"]}
```
`/auto/build` 폼 필드:
| 필드 | 값 |
|---|---|
| `data` | 하이라이트의 `paste` JSON 문자열 (붙여넣기 탭과 **동일 스키마**) |
| `cards` | PNG 파일 여러 개, 화면에 보인 순서 그대로 |
| `video_scale` `flip` `scene` `bg_white` `remove_silence` `asr_bottom` | 공통 옵션. `/paste`와 동일 |
서버는 `data``parse_paste()`로 검증하고, 카드들을 `.comments/<job_id>/001.png…`
순서대로 저장한 뒤 그 폴더를 `comments_dir`로 하는 job을 만든다. 그 뒤는 **기존
`process_paste()` 경로를 그대로 탄다.**
파이프라인에 허용하는 수정은 딱 하나 — `process_paste(name_suffix="")` 파라미터 추가.
현재 드래프트 이름은 영상 제목으로 덮어써지고(`pipeline.py:340`) pycapcut은
`allow_replace=True`라 **같은 이름이면 이전 드래프트를 교체**한다. 같은 영상에서 5개를
만들면 컷 개수가 같은 하이라이트끼리 서로 덮어쓰므로, `/auto/build``tag`
(예: `하이라이트1`)를 넘겨 이름 뒤에 붙인다. 기본값 `""` → 기존 탭 동작 불변.
또한 `app.py``/static` StaticFiles 마운트를 추가한다 — 현재는 index.html 한 파일만
직접 읽어 주고 있어 `auto.js`·`modern-screenshot.js`를 서빙할 방법이 없다.
### 4.4 프론트엔드
- 탭 `🤖 자동` 추가. 기존 `setMode()`에 분기 하나 추가.
- 자동 탭에서는 공통 옵션 중 **댓글 카드 폴더 입력을 숨긴다** (자동 생성 폴더를 쓰므로).
나머지(확대·반전·장면분할·배경흰색·무음제거·하단자막자동)는 그대로 쓴다.
- 카드 렌더는 h-lab `comment-cards.html`의 인라인 CSS 중 카드 부분(`.comment-card`,
`.cc-head`, `.cc-avatar`, `.cc-meta`, `.cc-author`, `.cc-time`, `.cc-text`, `.cc-stats`,
`.mosaic`, `.rounded`, `.bg-black`)만 옮겨온다. 툴바·분석 UI는 안 가져온다.
- 카드 스타일 고정값: **배경 검정 · 모서리 둥금 · 모자이크 ON · 캡처 4배**.
(h-lab 기본값과 동일. 토글은 만들지 않는다 — 필요해지면 그때.)
- 캡처: `modernScreenshot.domToBlob(el, {scale:4, backgroundColor:null})`.
### 4.5 프롬프트 파일
- Step 3 = `숏폼_편집_지침서_v13.7_capcut2연동판.md` (이미 있는 파일을 그대로 읽는다)
- Step 1 = `프롬프트/하이라이트_선정.md` (없으면 기본값으로 생성)
- 모델·fps = `프롬프트/설정.json` (없으면 기본값으로 생성)
```json
{"model": "gemini-3.5-flash",
"model_step1": "",
"fps_step1": 0.2,
"fps_step3": 1.0}
```
(`model_step1`은 비우면 `model`과 동일 — Step 1만 Pro로 올리고 싶을 때 채운다.
실제 파일은 주석 없는 순수 JSON.)
기본 Step 1 프롬프트는 오팔 원문을 옮기되 **오타 하나를 고친다** — 원문 JSON 예시에
`start_time`이 두 번 나오고 `end_time`이 빠져 있다.
자동 탭의 **⚙ 지침 수정** 안에서 프롬프트 2개 + 이 설정을 같이 편집한다.
모델을 바꿔 결과를 비교하는 것이 오팔과의 품질 차이를 좁히는 유일한 수단이므로, 이 값은
숨기지 않고 UI에 노출한다.
## 5. 댓글 매칭 규칙
0. 필요 장수 `need` = §6의 `n_max` = `max(1, floor(컷 총길이 / 3))`. (52.3초 → 17장)
§6과 같은 식을 쓴다 — 화면에 "17장 필요"라고 띄우고 실제로는 18장이 들어가는 일이 없게.
1. 매칭 기준 구간은 **Step 1 윈도우** `[start_time, end_time]` (1분30초~3분).
Step 3의 컷은 순서를 섞어 재배치한 45~60초라, 원본에서 "그 장면"을 가리키는 댓글은
Step 1 윈도우로 잡아야 맞는다.
2. `matched` = 구간 안 타임스탬프를 언급한 댓글, **좋아요 내림차순**. 필요 장수까지 자동 체크.
3. `candidates` = 좋아요 상위 20개 중 `matched`에 없는 것. 부족분을 여기서 사람이 클릭.
4. 하나도 안 골라도 된다 → 댓글 없이 드래프트 생성.
5. 카드 순서 = 화면에 보인 순서(⭐ 먼저, 그다음 고른 순서).
## 6. 카드 배치 규칙 (변경)
`pipeline.py:38 _load_comment_cards`
```
n_max = max(1, floor(전체길이 / 3)) # 3초 밑으로는 안 내려감
n = min(카드 수, n_max) # 초과분은 버림 (지금과 동일)
길이 = 전체길이 / n # 항상 3초 이상, 끝까지 빈 곳 없이 채움
i번째 카드 = [i·길이, (i+1)·길이]
```
| 전체 길이 | 카드 수 | 결과 |
|---|---|---|
| 33초 | 11장 | 3.0초씩 (지금과 동일) |
| 33초 | 6장 | **5.5초씩** — 지금은 18초 뒤가 비었다 |
| 33초 | 20장 | 앞 11장만 3.0초씩 (지금과 동일) |
**기존 3개 탭에도 적용된다.** 카드가 모자랄 때 영상 뒷부분이 비는 문제가 같이 없어진다.
이 설계에서 기존 동작이 바뀌는 유일한 지점이다.
## 7. h-lab 확인 결과 (수정 불필요 근거)
원격 API 실측 (2026-07-31):
```
POST https://h-lab.tolag.shop/api/comment-cards/fetch → 200
{"success":true,"data":[{"authorName":"@YouTube","profileImageUrl":"https://yt3.ggpht.com/…",
"text":"…","likeCount":275098,"replyCount":1000,"publishedAt":"2025-04-22T19:05:08Z"}]}
```
- 인증 없이 열려 있고, 카드에 필요한 필드가 전부 온다
- 프로필 이미지는 **capcut2가 자체 프록시**한다 → h-lab CORS 설정에 의존하지 않고,
같은 출처라 캔버스 오염(taint) 없이 캡처된다
- 카드 CSS는 `comment-cards.html`에서 복사해 온다
→ **h-lab에 추가할 API도, 고칠 코드도 없다.**
## 8. 실패 처리
각각 독립적으로 죽고, 죽어도 나머지는 살린다.
| 실패 | 처리 |
|---|---|
| Gemini 키 없음 | 자동 탭에 안내 + 붙여넣기 탭 안내. 분석 시작 자체를 막는다 |
| Gemini 429 (무료 한도) | Step 3는 동시 5개 → 429 나온 구간만 순차 재시도(최대 2회). 그래도 실패하면 그 하이라이트만 제외 |
| 무료 티어 유튜브 하루 8시간 초과 | Gemini가 거절 → 메시지 그대로 노출. 원본 1시간짜리면 하루 약 7~8회가 상한 |
| Step 1 컨텍스트 초과 (아주 긴 원본) | `fps_step1`을 더 낮추라는 안내를 띄운다. 0.2 fps 기준 4시간까지는 들어간다 |
| Gemini 응답에 JSON 블록 없음 | 그 하이라이트만 제외 + 원문을 로그에 남김 |
| `parse_paste` 검증 실패 | 그 하이라이트만 제외 + 사유 표시 |
| Step 1이 5개 못 채움 | 나온 것만 진행. 개수를 강제하지 않는다 |
| h-lab 연결 실패·타임아웃 | **댓글 없이 진행.** 하이라이트는 그대로 만든다. `warnings`에 표시 |
| 구간 매칭 댓글 0개 | 후보만 보여준다. 안 고르면 댓글 없이 생성 |
| 카드 캡처 실패 | 그 카드만 빼고 계속 |
| 빌드 5개 중 3번째 실패 | 멈추지 않고 4·5번 계속. 끝에 "4개 성공, 1개 실패" |
전부 실패해도 **오팔 → 붙여넣기 탭** 경로는 그대로 살아 있다.
## 9. 검증
이 프로젝트에 자동 테스트 스위트는 없다. 단계별로:
1. 구문·임포트: `python -c "import ast; ast.parse(open('capcut_agent/plan.py', encoding='utf-8').read())"`,
`python -c "from server import app"`
2. `comments.py` 타임스탬프 파싱은 순수 함수라 손으로 확인:
`"2:14 개웃김"``[134.0]`, `"1:02:03"``[3723.0]`, `"2025년"``[]`
3. 배치 규칙은 `_load_comment_cards`를 직접 불러 §6 표 3줄을 확인
4. 실제 영상 1개로 자동 탭 한 바퀴 → `draft_content.json` 열어 댓글 세그먼트 시간 확인
5. 최종 확인은 **CapCut에서 열어보기**
⚠️ 코드 수정 후 `.bat` 재시작 필수 (hot-reload 없음).
## 10. 오팔과의 품질 차이
프롬프트도 같고, 영상을 유튜브 URL로 넘기는 방식도 같다. **차이가 난다면 원인은 두 가지뿐이다:**
1. **모델** — 오팔이 어떤 모델을 붙이는지는 오팔 편집기에서 노드를 열면 확인된다.
우리 쪽은 §4.5 설정으로 바꾼다. 기본 `gemini-3.5-flash`, 부족하면 `gemini-3.1-pro-preview`.
Step 1만 Pro로 올리는 것도 가능하다(`model_step1`) — 호출이 1회뿐이라 한도 부담이 작다.
2. **영상 샘플링** — Step 1을 0.2 fps로 낮추는 만큼 오팔보다 덜 본다.
짧은 원본만 다루면 `fps_step1`을 올려 동등하게 맞출 수 있다.
검토 화면이 **빌드 전에** 컷·자막·타이틀을 다 보여주므로, 결과가 못 미치면 그 자리에서
설정을 바꿔 다시 분석하거나 오팔로 돌아가면 된다. 되돌릴 수 없는 지점이 없다.
## 11. 열린 항목
- `modern-screenshot`은 구현 시점에 CDN에서 받아 `server/static/`에 동봉하고 버전을 고정한다.
- 첫 실행에서 실제 소요 시간·토큰을 재고, Step 1의 fps 기본값을 그 결과로 조정한다.

View File

@ -0,0 +1,177 @@
# 컷별 댓글 추천 · 컷 위치 배치 (설계)
작성일: 2026-08-04
관련 코드: `capcut_agent/comments.py`, `capcut_agent/pipeline.py`, `server/app.py`, `server/static/auto.js`
선행 스펙: `2026-07-31-자동탭-오팔대체-댓글자동매칭-design.md` (댓글 매칭의 최초 도입)
## 1. 배경
자동 탭 검토 화면은 하이라이트마다 댓글을 두 덩어리로 보여준다.
- ⭐ **이 구간을 언급한 댓글** — 본문에 `9:05` 같은 분:초가 있고 그게 구간 안인 댓글, 좋아요순 자동선택
- **좋아요 상위 후보** — 분:초가 아예 없는 댓글, 수동 보충
문제는 두 가지다.
**(a) 내용을 안 본다.** `comments.py`의 매칭은 전부 정규식(`TS_RE`)으로 뽑은 타임스탬프뿐이다.
편집안이 컷마다 자막(`bottom`)을 갖고 있는데도 — 예: `아까랑 완전 스타일이 달라` — 매칭에
전혀 쓰이지 않는다. 타임스탬프를 안 적은 댓글은 아무리 그 장면 얘기여도 ⭐에 못 들어온다.
**(b) 어디에 깔릴지 모른다.** `_load_comment_cards()`는 카드를 파일명 순서대로
**타임라인 전체에 균등 배치**한다(간격 `dur/n`). 컷 경계를 모르므로, 3번 컷 얘기하는
댓글이 7번 컷 위에 뜰 수 있다.
이 스펙은 **컷 단위로 추천하고 그 컷 위에 깔리게** 만든다.
## 2. 범위
### 하는 것
- 검토 화면 댓글 영역을 **컷별 묶음**으로 재구성
- 컷 자막 기반 Gemini 추천 (컷이 있는 모드)
- 시각 슬롯 기반 정밀 배정 (통짜 모드, Gemini 미사용)
- 카드를 **그 컷 구간 안에** 배치 — 무음 제거 시 자막과 동일하게 재매핑
### 하지 않는 것
- **유튜브 구간 탭·붙여넣기 탭은 손대지 않는다.** 구간 탭은 컷·자막이 없어 추천 근거가
없고, 붙여넣기 탭은 댓글 선택 UI 자체가 없다. 자동 탭 4개 모드만 대상.
- **`_load_comment_cards()`를 없애지 않는다.** 폴더 지정 경로(파일/유튜브 구간 탭)는 그대로 쓴다.
- **댓글 감정분석·본문 요약·추천 이유 표시·추천 학습을 하지 않는다.** 지금 필요 없다.
- **통짜 모드에 Gemini를 쓰지 않는다.** 근거(자막)가 없는데 비용만 든다 — §4.2.
- **h-lab을 고치지 않는다.** 필요한 댓글 데이터가 이미 다 온다.
## 3. 지금 동작 (변경 전 사실)
| 위치 | 사실 |
|---|---|
| `comments.py` `TS_RE` | `(?<!\d)(\d{1,2}):([0-5]\d)(?::([0-5]\d))?(?!\d)` — h-lab과 동일 규칙 |
| `comments.py` `MAX_TIMES = 3` | 분:초를 3개 넘게 나열한 '목차 댓글'은 매칭에서 제외 |
| `comments.py` `match_ranges()` | 구간 안 시각을 하나라도 언급한 댓글 idx, 좋아요 내림차순 |
| `comments.py` `top_liked()` | exclude 제외 좋아요 상위 n |
| `app.py` `_need(total)` | `max(1, int(total // 3))` — 하이라이트 전체 기준 카드 장수 |
| `app.py` `_whole_hl()` | 통짜 하이라이트는 `cuts:[{start, end, bottom:"", effect:""}]` **1개**, 자막 빈 문자열 |
| `app.py` `/auto/build` | `data`(붙여넣기 스키마 JSON) + `cards` PNG들 → `COMMENTS_DIR/<h>/001.png…`, `comments_dir`로 job 등록 |
| `pipeline.py` `placements` | 컷 순서 누적으로 계산한 **컷별 타임라인 구간** `(p0, p1)` — 자막 배치에 이미 사용 중 |
| `pipeline.py` `_remap_caps()` | 무음 제거 시 자막 시간을 압축 타임라인으로 재매핑 |
| `pipeline.py` `_load_comment_cards()` | 폴더 이미지를 `dur/n` 간격 균등 배치. `fixed=True`면 3초 고정·뒤 비움 |
| `draft.py` `build_bg_template_draft()` | `comment_cards: List[Tuple[start, end, path]]`**이미 시간을 직접 받는다** |
| `plan.py` `_call()` | Gemini 호출. `parts[0]``fileData`(영상)가 **항상 들어간다** → 텍스트 전용 호출 불가 |
| `plan.py` `DEFAULT_MODEL` | `gemini-3.5-flash` |
| `correct.py` | `_gemini_key()`, `GeminiQuotaError`(429) |
## 4. 설계
### 4.1 모드가 갈리는 이유
| 모드 | 컷 | 컷 자막 | 타임라인 시각 ↔ 원본 시각 |
|---|---|---|---|
| `full`, `paste` | 여러 개 | **있음** | 컷 순서를 섞어 재배치 → **다름** |
| `whole`, `wpaste` | **1개** | **빈 문자열** | 구간을 그대로 이어붙임 → **같음** |
컷이 있는 모드는 시각이 어긋나므로 **내용**으로 맞출 수밖에 없고, 통짜 모드는 자막이
없으므로 **시각**으로 맞출 수밖에 없다. 각자 근거가 있는 쪽을 쓴다.
### 4.2 매칭
**컷 있는 모드 (`full`, `paste`) — Gemini 1회/하이라이트**
1. 후보 댓글 = 좋아요 상위 **150장**(본문 200자 절단). 그 이상은 토큰만 먹고 채택률이 낮다.
2. 컷 목록(번호·길이·자막·효과자막)과 함께 텍스트로 던져 컷별 배정을 받는다.
요청 장수는 컷당 `quota + 2` (사람이 갈아끼울 여유분).
3. **타임스탬프 우선**: 그 컷의 **원본 구간** `[cut.start, cut.end]`을 언급한 댓글이 있으면
`match_ranges()` 결과를 먼저 채우고, 남는 자리만 Gemini 추천으로 채운다.
근거가 확실한 쪽을 이기게 둘 이유가 없다.
4. 중복 제거: 한 댓글이 여러 컷에 배정되면 **앞 컷이 가져간다**.
**통짜 모드 (`whole`, `wpaste`) — Gemini 0회**
컷이 1개이고 타임라인 시각 = 원본 시각이므로, 카드 슬롯 `k`의 원본 시간대는
`[start + k·(total/n), start + (k+1)·(total/n))` 로 정확히 계산된다(`n` = 카드 장수).
슬롯마다 그 시간대를 언급한 댓글을 좋아요순으로 꽂고, 빈 슬롯만 상위로 메운다.
지금(구간 전체를 뭉뚱그려 매칭 → 순서 무관)보다 정확해지고 비용은 0이다.
### 4.3 카드 장수
컷별 `quota_i = max(1, round(len_i / 3))`. 하이라이트의 `need = Σ quota_i`
(기존 `int(total // 3)`을 대체 — 값이 ±1 다를 수 있으나 컷 경계에 맞추는 쪽이 맞다).
### 4.4 배치 — 시간은 파이프라인이 계산한다
**서버에서 카드 시간을 확정하면 안 된다.** 무음 제거(`remove_silence`)를 켜면
`timeline_dur`가 줄고 자막이 `_remap_caps()`로 재매핑된다. 카드 시간을 미리 박아두면
그 뒤 혼자 어긋난다.
그래서 `/auto/build`**카드가 어느 컷 소속인지만** 보낸다:
```
card_cuts = [0, 0, 1, 1, 1, 2, …] # 카드 순서대로, 값 = 컷 인덱스
```
`process_paste()`가 이미 갖고 있는 `placements[(p0, p1)]`로 시간을 만든다:
- 컷 `i`에 카드 `m`장 → `p0`부터 `(p1-p0)/m` 간격 (`cards_fixed`면 3초 고정, 컷 뒷부분은 비움)
- 무음 제거가 켜져 있으면 자막과 **같은 `_remap_caps()`** 를 카드에도 적용
- `card_cuts`가 없으면(폴더 지정 경로 등) 기존 `_load_comment_cards()` 그대로
컷 하나가 추천 부족으로 덜 차도 다음 컷 카드가 앞으로 밀리지 않는다 — 지금 방식의 약점이 여기서 사라진다.
## 5. 데이터 스키마
`/auto/analyze` SSE의 하이라이트에 컷별 정보를 싣는다.
```jsonc
{"id":1, "paste":{…}, "total":49.5, "need":18, // need = Σ quota
"cuts":[ // paste.cuts 와 같은 순서·길이
{"i":0, "sec":5.0, "bottom":"아까랑 완전 스타일이 달라", "quota":2,
"picks":[{"idx":12,"why":"ts"}, {"idx":45,"why":"ai"}]},
{"i":1, "sec":5.5, "bottom":"우리에게 익숙한 평냥은", "quota":2,
"picks":[{"idx":7,"why":"ai"}]}
],
"candidates":[19,33,…]} // 컷 무관 좋아요 상위 (수동 보충용)
```
`why`**추천 1건마다** 붙는다 — 한 컷 안에서도 출처가 섞이기 때문이다(§4.2 3번).
| 값 | 뜻 | 화면 표시 |
|---|---|---|
| `ts` | 그 컷의 원본 구간을 언급한 타임스탬프 댓글 | ⭐ |
| `ai` | Gemini가 자막 내용으로 고름 | 🤖 |
| `like` | 통짜 모드에서 빈 슬롯을 좋아요순으로 메움 | |
`/auto/build` 폼에 `card_cuts`(JSON 배열) 추가. 나머지 필드는 그대로.
## 6. 변경 파일
| 파일 | 변경 |
|---|---|
| `capcut_agent/comments.py` | `match_slots(comments, start, total, n)` 추가 — 시각 슬롯별 배정(통짜용) |
| `capcut_agent/recommend.py` (신규) | Gemini 텍스트 전용 호출 + 컷별 배정·중복 제거. `plan.py._call`은 영상 part가 필수라 재사용 불가 |
| `capcut_agent/prompts.py` | 추천 프롬프트를 `프롬프트/댓글_추천.md`로 (없으면 기본값 생성 — 기존 패턴과 동일) |
| `server/app.py` | 하이라이트에 `cuts[]` 실어 보냄, `/auto/build``card_cuts` 폼 수신 |
| `capcut_agent/pipeline.py` | `card_cuts` 인자 추가 → `placements` 기반 시간 계산 + 무음 제거 시 재매핑 |
| `server/static/auto.js` | 카드 섹션을 컷별로 렌더, 선택 상한을 컷별 `quota`로, 선택 순서를 컷 순서로 고정 |
## 7. 실패·폴백
| 상황 | 처리 |
|---|---|
| Gemini 쿼터 초과(`GeminiQuotaError`)·타임아웃·JSON 파싱 실패 | 기존 동작으로 폴백 — 구간 전체 ⭐ + 좋아요순. 화면에 `⚠ 추천 실패 → 기존 방식` 한 줄 |
| Gemini가 없는 idx·범위 밖 idx를 반환 | 조용히 버림 |
| 컷 추천이 `quota`에 모자람 | 그 컷은 있는 만큼만. 사람이 ➕에서 채울 수 있음 |
| h-lab 댓글 수집 실패 | 지금과 동일 — 댓글 없이 진행 |
**어떤 경우에도 드래프트 생성을 막지 않는다.**
## 8. 검증
자동 테스트 스위트가 없으므로(CLAUDE.md) 다음으로 확인한다.
1. `comments.match_slots()` 순수 함수 단위 검사 — 인라인 assert 스크립트
2. 컷별 배정·중복 제거 로직 단위 검사 (Gemini 응답은 고정 JSON으로 대체)
3. 실제 영상 1건으로 `full` 모드 → 검토 화면에 컷별 섹션·추천이 뜨는지
4. 생성된 `draft_content.json`에서 **카드 세그먼트 시간이 컷 구간 안에 들어가는지** 확인
5. 무음 제거 ON 으로 같은 영상 재실행 → 카드가 자막과 함께 당겨졌는지 확인
6. Gemini 키를 일부러 비워 폴백이 도는지 확인
## 9. 열린 질문
없음. 미결이 생기면 여기에 적고 구현 전에 사용자에게 묻는다.

16
requirements.txt Normal file
View File

@ -0,0 +1,16 @@
# 캡컷 에이전트 · 구간합치기 (v2) 파이썬 의존성
# 설치: python -m pip install -r requirements.txt
#
# ※ 이 외에 시스템에 따로 설치 필요(pip 아님):
# - ffmpeg / ffprobe (PATH 에 있어야 함)
# - Node.js 또는 deno (yt-dlp 유튜브 추출용 JS 런타임)
# - CapCut (드래프트 열기 + 코트라 볼드체 폰트 캐시)
fastapi
uvicorn
python-multipart
pyCapCut
Pillow
pymediainfo
yt-dlp
faster-whisper # 파일/유튜브 구간 탭의 자막 받아쓰기용(붙여넣기 탭만 쓰면 불필요)

0
server/__init__.py Normal file
View File

719
server/app.py Normal file
View File

@ -0,0 +1,719 @@
"""캡컷 에이전트 로컬 웹 서버 (FastAPI).
실행: python -m uvicorn server.app:app --port 8000
열기: http://127.0.0.1:8000
"""
from __future__ import annotations
import asyncio
import glob
import hashlib
import json
import os
import shutil
import subprocess
import urllib.parse
import urllib.request
from fastapi import FastAPI, File, Form, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
from fastapi.staticfiles import StaticFiles
from capcut_agent.pipeline import process_bg_template, process_paste
from capcut_agent.paste import parse_paste
from capcut_agent.draft import DEFAULT_DRAFT_ROOT, list_drafts, repair_layers
from capcut_agent import comments as hlab
from capcut_agent import plan as autoplan
from capcut_agent import prompts as prompt_store
from capcut_agent.correct import GeminiQuotaError, has_gemini_key
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
STATIC_DIR = os.path.join(BASE_DIR, "static")
UPLOAD_DIR = os.path.join(os.path.dirname(BASE_DIR), ".uploads")
os.makedirs(UPLOAD_DIR, exist_ok=True)
COMMENTS_DIR = os.path.join(os.path.dirname(BASE_DIR), ".comments")
os.makedirs(COMMENTS_DIR, exist_ok=True)
app = FastAPI(title="캡컷 에이전트")
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
# job_id(content hash) → {path, draft_name, title_top, title_main, channel}
JOBS: dict[str, dict] = {}
# analysis_id → {"url": …} (분석은 SSE 1회성 — 결과는 브라우저가 들고 있음)
ANALYSES: dict[str, dict] = {}
_DEFAULT_CDIR = os.path.join(os.path.dirname(BASE_DIR), "댓글카드")
@app.get("/")
async def index() -> HTMLResponse:
with open(os.path.join(STATIC_DIR, "index.html"), encoding="utf-8") as f:
html = f.read()
# 댓글 카드 폴더 기본 경로 주입(그 PC 기준 실제 경로)
html = html.replace("__CDIR__", _DEFAULT_CDIR.replace("\\", "/"))
# ?v= 캐시 무력화 — StaticFiles 는 Cache-Control 을 안 붙여서 브라우저가 auto.js 를
# 옛 버전 그대로 들고 있는 일이 있었다(index.html 만 바뀌어 UI가 반만 갱신됨).
# 파일 mtime 을 버전으로 박아 파일이 바뀌면 URL 이 달라지게 한다.
mt = 0.0
for f in ("auto.js", "modern-screenshot.js"):
p = os.path.join(STATIC_DIR, f)
if os.path.isfile(p):
mt = max(mt, os.path.getmtime(p))
html = html.replace("__V__", str(int(mt)))
return HTMLResponse(html, headers={"Cache-Control": "no-store"})
def _scale(pct: str) -> float:
"""확대 퍼센트 문자열 → scale(0.5~3.0). 잘못되면 1.0(100%)."""
try:
return max(0.5, min(3.0, float(pct) / 100))
except (ValueError, TypeError):
return 1.0
def _truthy(v: str) -> bool:
return str(v).strip().lower() in ("1", "true", "on", "yes")
def _parse_ranges(raw: str) -> list[tuple[str, str]]:
"""JSON [["mm:ss","mm:ss"],...] → [(start,end)]. 빈 값/파싱실패는 []."""
raw = (raw or "").strip()
if not raw:
return []
try:
arr = json.loads(raw)
except json.JSONDecodeError:
return []
out: list[tuple[str, str]] = []
for item in arr if isinstance(arr, list) else []:
if isinstance(item, (list, tuple)) and len(item) == 2:
s, e = str(item[0]).strip(), str(item[1]).strip()
if s and e:
out.append((s, e))
return out
@app.post("/upload")
async def upload(
file: UploadFile = File(...),
title_top: str = Form(""),
title_main: str = Form(""),
channel: str = Form(""),
video_scale: str = Form("100"),
flip: str = Form(""),
scene: str = Form(""),
comments_dir: str = Form(""),
bg_white: str = Form(""),
) -> JSONResponse:
data = await file.read()
# content hash → 같은 영상 재업로드 시 캐시/멱등 (mtime 아님)
h = hashlib.sha1(data).hexdigest()[:12]
ext = os.path.splitext(file.filename or "")[1].lower() or ".mp4"
path = os.path.join(UPLOAD_DIR, h + ext)
if not os.path.exists(path):
with open(path, "wb") as f:
f.write(data)
base = os.path.splitext(os.path.basename(file.filename or "video"))[0]
safe = "".join(c for c in base if c.isalnum() or c in (" ", "_", "-")).strip() or "video"
JOBS[h] = {
"path": path, "draft_name": f"{safe}_{h}", "youtube": None,
"title_top": title_top, "title_main": title_main, "channel": channel,
"video_scale": _scale(video_scale), "flip": _truthy(flip),
"scene": _truthy(scene), "comments_dir": comments_dir, "bg_white": _truthy(bg_white),
}
return JSONResponse({"job_id": h, "draft_name": JOBS[h]["draft_name"]})
@app.post("/youtube")
async def youtube(
url: str = Form(...),
ranges: str = Form(""),
start: str = Form(""),
end: str = Form(""),
title_top: str = Form(""),
title_main: str = Form(""),
channel: str = Form(""),
video_scale: str = Form("100"),
flip: str = Form(""),
scene: str = Form(""),
comments_dir: str = Form(""),
bg_white: str = Form(""),
cards_fixed: str = Form(""),
cards: list[UploadFile] = File(default=[]),
) -> JSONResponse:
"""유튜브 URL + 여러 구간으로 작업 생성. ranges=JSON [["mm:ss","mm:ss"],...].
ranges 없으면 start/end 단일 구간으로 폴백(하위호환).
cards(댓글 매칭에서 캡처한 PNG들) 오면 폴더 지정보다 우선한다.
"""
rng = _parse_ranges(ranges) or ([(start.strip(), end.strip())] if start and end else [])
if not rng:
return JSONResponse({"error": "구간을 하나 이상 입력하세요."}, 400)
sig = url + "|" + "|".join(f"{s}-{e}" for s, e in rng)
h = hashlib.sha1(sig.encode()).hexdigest()[:12]
if cards:
cdir = os.path.join(COMMENTS_DIR, h)
if os.path.isdir(cdir): # 재실행 시 이전 카드 잔재 제거
shutil.rmtree(cdir, ignore_errors=True)
os.makedirs(cdir, exist_ok=True)
for i, f in enumerate(cards, 1):
body = await f.read()
with open(os.path.join(cdir, f"{i:03d}.png"), "wb") as out:
out.write(body)
comments_dir = cdir
JOBS[h] = {
"path": None, "draft_name": f"yt_{h}",
"youtube": {"url": url.strip(), "ranges": rng},
"title_top": title_top, "title_main": title_main, "channel": channel,
"video_scale": _scale(video_scale), "flip": _truthy(flip),
"scene": _truthy(scene), "comments_dir": comments_dir, "bg_white": _truthy(bg_white),
"cards_fixed": _truthy(cards_fixed),
}
return JSONResponse({"job_id": h})
@app.post("/paste")
async def paste(
data: str = Form(...),
video_scale: str = Form("144"),
flip: str = Form(""),
scene: str = Form(""),
comments_dir: str = Form(""),
bg_white: str = Form(""),
remove_silence: str = Form(""),
asr_bottom: str = Form(""),
) -> JSONResponse:
"""붙여넣기(JSON) 편집안 → 작업 생성. asr_bottom=1 이면 하단 자막을 Whisper로 자동 생성."""
try:
payload = parse_paste(data)
except ValueError as e:
return JSONResponse({"error": str(e)}, 400)
sig = payload["url"] + "|" + "|".join(f"{s:.3f}-{e:.3f}" for s, e, _, _ in payload["cuts"])
h = hashlib.sha1(sig.encode()).hexdigest()[:12]
JOBS[h] = {
"paste": payload, "draft_name": f"paste_{h}",
"video_scale": _scale(video_scale), "flip": _truthy(flip), "scene": _truthy(scene),
"comments_dir": comments_dir, "bg_white": _truthy(bg_white),
"remove_silence": _truthy(remove_silence),
"asr_bottom": _truthy(asr_bottom),
}
return JSONResponse({"job_id": h, "cuts": len(payload["cuts"])})
@app.get("/stream/{job_id}")
async def stream(job_id: str) -> StreamingResponse:
job = JOBS.get(job_id)
async def gen():
if not job:
yield _sse({"type": "error", "message": "알 수 없는 작업입니다."})
return
try:
if job.get("paste"): # 붙여넣기(JSON) 모드
stream_iter = process_paste(
job["paste"], job["draft_name"],
video_scale=job.get("video_scale", 1.0),
flip_horizontal=job.get("flip", False),
scene_split=job.get("scene", False),
comments_dir=job.get("comments_dir", ""),
bg_white=job.get("bg_white", False),
remove_silence=job.get("remove_silence", False),
asr_bottom=job.get("asr_bottom", False),
name_suffix=job.get("name_suffix", ""),
cards_fixed=job.get("cards_fixed", False),
)
else:
stream_iter = process_bg_template(
job["path"], job["draft_name"],
title_top=job.get("title_top", ""),
title_main=job.get("title_main", ""),
channel=job.get("channel", ""),
video_scale=job.get("video_scale", 1.0),
flip_horizontal=job.get("flip", False),
scene_split=job.get("scene", False),
comments_dir=job.get("comments_dir", ""),
bg_white=job.get("bg_white", False),
youtube=job.get("youtube"),
cards_fixed=job.get("cards_fixed", False),
)
async for ev in stream_iter:
yield _sse(ev)
except Exception as exc: # noqa: BLE001 — 사용자에게 그대로 노출
yield _sse({"type": "error", "message": f"{type(exc).__name__}: {exc}"})
return StreamingResponse(gen(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache",
"X-Accel-Buffering": "no"})
@app.post("/yt/comments")
async def yt_comments(url: str = Form(...), ranges: str = Form(...)) -> JSONResponse:
"""유튜브 구간 탭 — 구간 언급 댓글 매칭. 규칙은 자동 탭과 동일:
= 어느 구간이든 언급(좋아요순), = : 없는 댓글(좋아요순), 전체 전송."""
u = url.strip()
if not (u.startswith("http://") or u.startswith("https://")):
return JSONResponse({"error": "유튜브 주소를 입력하세요."}, 400)
try:
rng = [(float(s), float(e)) for s, e in json.loads(ranges)]
rng = [(s, e) for s, e in rng if e > s]
except (json.JSONDecodeError, TypeError, ValueError):
rng = []
if not rng:
return JSONResponse({"error": "구간을 하나 이상 입력하세요."}, 400)
try:
comments = await asyncio.to_thread(hlab.fetch_comments, u)
except Exception as exc: # noqa: BLE001
return JSONResponse({"error": f"h-lab 연결 실패: {exc}"}, 502)
total = sum(e - s for s, e in rng)
matched = hlab.match_ranges(comments, rng)
no_ts = [c for c in comments if not c["times"]]
return JSONResponse({
"need": max(1, int(total // 3)), "total": round(total, 1),
"matched": matched,
"candidates": hlab.top_liked(no_ts, set(matched), len(no_ts)),
"comments": comments,
})
@app.post("/auto/analyze")
async def auto_analyze(url: str = Form(""), mode: str = Form("full"),
data: str = Form("")) -> JSONResponse:
"""자동 탭 1단계 — 분석 예약. 실제 작업은 /auto/stream 에서 SSE 로.
mode: full = Step1+Step3 (AI 컷편집, 기본)
whole = Step1만 구간 5개를 통짜로( 편집 없음)
paste = 오팔 JSON 여러 붙여넣기 Gemini
"""
mode = mode if mode in ("full", "whole", "wpaste", "paste") else "full"
u = url.strip()
if mode == "paste":
if not data.strip():
return JSONResponse({"error": "오팔 JSON을 붙여넣으세요."}, 400)
elif mode == "wpaste":
# 구간 JSON 붙여넣기 — Gemini 안 씀. URL은 JSON에 없으므로 입력칸이 필수.
if not data.strip():
return JSONResponse({"error": "구간 JSON을 붙여넣으세요."}, 400)
if not (u.startswith("http://") or u.startswith("https://")):
return JSONResponse({"error": "유튜브 주소를 입력하세요. "
"(구간 JSON에는 URL이 없어 직접 넣어야 합니다)"}, 400)
else:
if not has_gemini_key():
return JSONResponse({"error": "Gemini 키가 없습니다. 프로젝트 루트의 "
".gemini_key 파일을 확인하세요. (그동안은 오팔 → "
"📋 오팔 JSON 방식을 쓰면 됩니다)"}, 400)
if not (u.startswith("http://") or u.startswith("https://")):
return JSONResponse({"error": "유튜브 주소를 입력하세요."}, 400)
aid = hashlib.sha1((mode + "|" + u + "|" + data).encode()).hexdigest()[:12]
ANALYSES[aid] = {"url": u, "mode": mode, "data": data}
return JSONResponse({"analysis_id": aid})
@app.get("/auto/stream/{aid}")
async def auto_stream(aid: str) -> StreamingResponse:
"""자동 탭 분석 SSE: Step1 → (Step3 ×N ∥ 댓글) → result."""
a = ANALYSES.get(aid)
async def gen():
if not a:
yield _sse({"type": "error", "message": "알 수 없는 분석입니다."})
return
url = a["url"]
mode = a.get("mode", "full")
warnings: list[str] = []
highlights: list[dict] = []
def _need(total: float) -> int:
return max(1, int(total // 3))
def _cuts_json(cuts) -> list[dict]:
return [{"start": s, "end": e, "bottom": b, "effect": f}
for s, e, b, f in cuts]
def _whole_hl(c: dict) -> dict:
"""구간 통짜 하이라이트 — 컷 편집 없이 구간 전체가 컷 1개.
제목은 비워 두고 `editable_title` UI에서 직접 입력받는다.
whole(Gemini Step1) wpaste(구간 JSON 붙여넣기) 공유."""
total = c["end"] - c["start"]
return {
"id": c["id"], "start": c["start"], "end": c["end"],
"reason": c["reason"],
"paste": {"url": url, "title_top": "", "title_main": "",
"channel": "",
"cuts": [{"start": c["start"], "end": c["end"],
"bottom": "", "effect": ""}]},
"titles": [], "editable_title": True,
"total": round(total, 1), "need": _need(total),
}
if mode == "paste":
# ── 오팔 JSON 여러 개 — Gemini 안 씀 ──
yield _sse({"type": "manifest", "steps": [
{"id": "parse", "label": "오팔 JSON 파싱"},
{"id": "comments", "label": "댓글 수집 (h-lab)"},
]})
yield _sse({"type": "step", "id": "parse", "status": "start"})
import re as _re
from capcut_agent.paste import split_json_objects
from capcut_agent.plan import parse_titles
raw_text = a.get("data", "")
blocks = split_json_objects(raw_text)
# 각 JSON 블록의 위치 → 블록 사이 텍스트에서 '타이틀 후보 5선' 추출용
spans, pos = [], 0
for b in blocks:
st = raw_text.find(b, pos)
spans.append((st, st + len(b)))
pos = st + len(b)
# URL 결정: 입력 칸 > 블록들 중 유효한 유튜브 ID(11자)가 있는 첫 URL.
# LLM이 url 을 ""/플레이스홀더로 주는 일이 흔해서 블록별 url 은 믿지 않는다.
vid_re = _re.compile(
r"(?:v=|youtu\.be/|shorts/|embed/)([A-Za-z0-9_-]{11})(?![A-Za-z0-9_-])")
parsed = []
best_url = url
for blk in blocks:
try:
d = json.loads(blk, strict=False)
except json.JSONDecodeError:
d = None
parsed.append(d)
if not best_url and isinstance(d, dict):
u2 = str(d.get("url") or "")
if u2.startswith("http") and vid_re.search(u2):
best_url = u2
yield _sse({"type": "log", "msg": f"URL 자동 인식: {u2}"})
if not best_url:
yield _sse({"type": "error",
"message": "유튜브 URL을 알 수 없습니다 — JSON들의 url이 비어 있거나 "
"플레이스홀더입니다. 위 유튜브 URL 칸을 채우고 다시 시도하세요."})
return
for i, d in enumerate(parsed, 1):
if not isinstance(d, dict):
yield _sse({"type": "log", "msg": f"{i}번 JSON 무시: 형식 오류"})
continue
d["url"] = best_url # ""/플레이스홀더 무시하고 검증 전에 강제 주입
try:
p = parse_paste(d)
except ValueError as e:
yield _sse({"type": "log", "msg": f"{i}번 JSON 무시: {e}"})
continue
# 이 JSON 뒤 ~ 다음 JSON 앞 텍스트의 타이틀 후보를 이 편집안에 연결
tail_end = spans[i][0] if i < len(spans) else len(raw_text)
titles = parse_titles(raw_text[spans[i - 1][1]:tail_end])
total = sum(e - s for s, e, _, _ in p["cuts"])
highlights.append({
"id": len(highlights) + 1,
"start": min(s for s, _, _, _ in p["cuts"]),
"end": max(e for _, e, _, _ in p["cuts"]),
"reason": (p["title_top"] + " / " + p["title_main"]).strip(" /"),
"paste": {"url": p["url"], "title_top": p["title_top"],
"title_main": p["title_main"], "channel": p["channel"],
"cuts": _cuts_json(p["cuts"])},
"titles": titles, "total": round(total, 1), "need": _need(total),
})
if not highlights:
yield _sse({"type": "error",
"message": "붙여넣은 텍스트에서 유효한 편집안 JSON을 찾지 못했습니다."})
return
url = best_url
yield _sse({"type": "step", "id": "parse", "status": "done",
"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":
# ── 구간 JSON 붙여넣기 — Gemini 안 씀. 구간 5개를 그대로 통짜로 ──
yield _sse({"type": "manifest", "steps": [
{"id": "parse", "label": "구간 JSON 파싱"},
{"id": "comments", "label": "댓글 수집 (h-lab)"},
]})
yield _sse({"type": "step", "id": "parse", "status": "start"})
try:
cands = autoplan.parse_candidates(a.get("data", ""), src="구간 JSON")
except Exception as exc: # noqa: BLE001
yield _sse({"type": "error", "message": str(exc)})
return
highlights.extend(_whole_hl(c) for c in cands)
yield _sse({"type": "step", "id": "parse", "status": "done",
"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:
steps = [{"id": "step1", "label": "하이라이트 구간 선정 (Gemini)"}]
if mode == "full":
steps.append({"id": "step3", "label": "편집안 생성 (Gemini, 구간별 동시)"})
steps.append({"id": "comments", "label": "댓글 수집 (h-lab)"})
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 ──
yield _sse({"type": "step", "id": "step1", "status": "start"})
try:
cands = await asyncio.to_thread(autoplan.select_highlights, url)
except Exception as exc: # noqa: BLE001
com_task.cancel()
yield _sse({"type": "error",
"message": f"Step 1 실패 — {type(exc).__name__}: {exc}\n"
"오팔 → 📋 오팔 JSON 방식으로도 만들 수 있습니다."})
return
yield _sse({"type": "step", "id": "step1", "status": "done",
"detail": f"{len(cands)}개 구간"})
if mode == "whole":
# ── 구간 통짜 — 컷 편집 없이 구간 전체가 컷 1개 ──
highlights.extend(_whole_hl(c) for c in cands)
else:
# ── Step 3 (동시) ──
yield _sse({"type": "step", "id": "step3", "status": "start"})
async def _plan_one(i: int, c: dict):
# 429는 시차를 두고 재시도(동시 재충돌 방지: 시도×15s + 구간×5s)
for attempt in range(1, 4):
try:
return await asyncio.to_thread(
autoplan.edit_plan, url, c["start"], c["end"])
except GeminiQuotaError:
if attempt == 3:
raise
await asyncio.sleep(15 * attempt + i * 5)
plan_tasks = [asyncio.create_task(_plan_one(i, c))
for i, c in enumerate(cands)]
for c, t in zip(cands, plan_tasks):
hl = {"id": c["id"], "start": c["start"], "end": c["end"],
"reason": c["reason"]}
try:
r = await t
p = r["paste"]
total = sum(e - s for s, e, _, _ in p["cuts"])
hl.update({
"paste": {"url": p["url"], "title_top": p["title_top"],
"title_main": p["title_main"],
"channel": p["channel"],
"cuts": _cuts_json(p["cuts"])},
"titles": r["titles"],
"total": round(total, 1),
"need": _need(total),
})
if r["time_note"]:
yield _sse({"type": "log",
"msg": f"ID {c['id']}: {r['time_note']}"})
yield _sse({"type": "log",
"msg": f"ID {c['id']} 편집안 완료 — 컷 "
f"{len(p['cuts'])}개 · {total:.1f}"})
except Exception as exc: # noqa: BLE001
hl["error"] = f"{type(exc).__name__}: {exc}"
yield _sse({"type": "log",
"msg": f"ID {c['id']} 실패: {hl['error']}"})
highlights.append(hl)
ok = sum(1 for h in highlights if "paste" in h)
yield _sse({"type": "step", "id": "step3", "status": "done",
"detail": f"{ok}/{len(highlights)}개 성공"})
comments: list[dict] = []
try:
comments = await com_task
yield _sse({"type": "step", "id": "comments", "status": "done",
"detail": f"{len(comments)}"})
except Exception as exc: # noqa: BLE001
warnings.append(f"h-lab 연결 실패 — 댓글 없이 진행합니다 ({exc})")
yield _sse({"type": "step", "id": "comments", "status": "done",
"detail": "실패(생략)"})
# 댓글 매칭 — 전체 전송, 브라우저가 '더보기'로 30장씩 나눠 그린다.
# 후보(candidates)는 분:초 언급이 아예 없는 댓글만 — 타임스탬프 댓글은
# 자기 구간의 ⭐에서 잡히므로, 다른 구간 얘기하는 댓글이 섞이지 않게.
no_ts = [c for c in comments if not c["times"]]
for h in highlights:
if "paste" not in h:
continue
matched = hlab.match_window(comments, h["start"], h["end"])
h["matched"] = matched
h["candidates"] = hlab.top_liked(no_ts, set(matched), len(no_ts))
yield _sse({"type": "result", "highlights": highlights,
"comments": comments, "warnings": warnings})
return StreamingResponse(gen(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache",
"X-Accel-Buffering": "no"})
@app.get("/auto/avatar")
async def auto_avatar(url: str) -> Response:
"""프로필 이미지 동일 출처 프록시(canvas taint 회피). 구글 도메인만(SSRF 방지)."""
try:
host = (urllib.parse.urlparse(url).hostname or "").lower()
except ValueError:
host = ""
if not (host.endswith("ggpht.com") or host.endswith("googleusercontent.com")):
return Response(status_code=400)
def _get():
with urllib.request.urlopen(url, timeout=15) as r:
return r.read(), r.headers.get("Content-Type") or "image/jpeg"
try:
data, ct = await asyncio.to_thread(_get)
except Exception: # noqa: BLE001
return Response(status_code=502)
return Response(content=data, media_type=ct,
headers={"Cache-Control": "public, max-age=21600"})
@app.get("/prompts")
async def prompts_get() -> JSONResponse:
try:
return JSONResponse({
"step1": prompt_store.load_step1(),
"step3": prompt_store.load_step3(),
"config": prompt_store.load_config(),
"step3_path": prompt_store.STEP3_PATH,
})
except Exception as exc: # noqa: BLE001
return JSONResponse({"error": str(exc)}, 500)
@app.post("/prompts")
async def prompts_post(step1: str = Form(None), step3: str = Form(None),
config: str = Form(None), reset: str = Form("")) -> JSONResponse:
try:
if _truthy(reset):
prompt_store.reset() # Step 3 지침서는 사용자 파일 — 리셋 대상 아님
else:
prompt_store.save(step1=step1, step3=step3, config=config)
except Exception as exc: # noqa: BLE001
return JSONResponse({"error": f"저장 실패: {exc}"}, 400)
return JSONResponse({"ok": True})
@app.post("/auto/build")
async def auto_build(
data: str = Form(...),
tag: str = Form(""),
cards: list[UploadFile] = File(default=[]),
video_scale: str = Form("144"),
flip: str = Form(""),
scene: str = Form(""),
bg_white: str = Form(""),
remove_silence: str = Form(""),
asr_bottom: str = Form(""),
cards_fixed: str = Form(""),
) -> JSONResponse:
"""자동 탭 빌드 — 붙여넣기 스키마 JSON + 카드 PNG들 → 기존 paste job.
tag(: '하이라이트1') 드래프트 이름 꼬리표 같은 영상 5개가 서로
덮어쓰는 것을 막는다. 진행은 기존 /stream/{job_id} 본다.
"""
try:
payload = parse_paste(data)
except ValueError as e:
return JSONResponse({"error": str(e)}, 400)
safe_tag = "".join(ch for ch in tag if ch.isalnum() or ch in "-_")[:20]
sig = (payload["url"] + "|" + safe_tag + "|"
+ "|".join(f"{s:.3f}-{e:.3f}" for s, e, _, _ in payload["cuts"]))
h = hashlib.sha1(sig.encode()).hexdigest()[:12]
cdir = ""
if cards:
cdir = os.path.join(COMMENTS_DIR, h)
if os.path.isdir(cdir): # 재빌드 시 이전 카드 잔재 제거
shutil.rmtree(cdir, ignore_errors=True)
os.makedirs(cdir, exist_ok=True)
for i, f in enumerate(cards, 1):
body = await f.read()
with open(os.path.join(cdir, f"{i:03d}.png"), "wb") as out:
out.write(body)
JOBS[h] = {
"paste": payload, "draft_name": f"auto_{h}",
"video_scale": _scale(video_scale), "flip": _truthy(flip),
"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),
}
return JSONResponse({"job_id": h, "cuts": len(payload["cuts"]),
"cards": len(cards)})
@app.post("/open-capcut")
async def open_capcut() -> JSONResponse:
"""CapCut 실행 (Start Menu 바로가기 우선, 없으면 최신 버전 exe)."""
lnk = os.path.join(os.environ.get("APPDATA", ""),
"Microsoft", "Windows", "Start Menu", "Programs", "CapCut.lnk")
target = lnk if os.path.isfile(lnk) else None
if not target:
exes = sorted(glob.glob(os.path.join(
os.environ.get("LOCALAPPDATA", ""), "CapCut", "Apps", "*", "CapCut.exe")))
target = exes[-1] if exes else None
if not target:
return JSONResponse({"ok": False, "error": "CapCut 실행 파일을 못 찾았습니다."}, 404)
try:
os.startfile(target) # type: ignore[attr-defined] (Windows 전용)
return JSONResponse({"ok": True})
except Exception as exc: # noqa: BLE001
return JSONResponse({"ok": False, "error": str(exc)}, 500)
@app.get("/drafts")
async def drafts() -> JSONResponse:
"""드래프트 목록(최근순) + 레이어 꼬임 여부. 수리 UI 용."""
return JSONResponse({"drafts": list_drafts()[:30]})
def _capcut_running() -> bool:
"""CapCut.exe 실행 여부. 실행 중이면 수리해도 CapCut 이 덮어써서 헛수고가 된다."""
try:
out = subprocess.run(["tasklist", "/FI", "IMAGENAME eq CapCut.exe"],
capture_output=True, text=True, errors="replace",
timeout=10).stdout
return "CapCut.exe" in out
except Exception: # noqa: BLE001 — 판정 실패 시 막지 않음
return False
@app.post("/repair")
async def repair(draft: str = Form(...), force: str = Form("")) -> JSONResponse:
"""레이어 수리 — 옮긴 영상 클립이 흰 띠·댓글 위로 삐져나온 것을 되돌린다.
CapCut 실행 중이면 거부한다. 실측 사례: 11:04:32 수리 11:05:39 CapCut 저장으로
되돌아감. CapCut 파일을 다시 읽지 않고 메모리 상태로 덮어쓰기 때문.
"""
if _capcut_running() and not _truthy(force):
return JSONResponse({
"ok": False,
"capcut_running": True,
"error": "CapCut이 실행 중입니다. 수리해도 CapCut이 저장하면서 되돌립니다.\n"
"CapCut을 완전히 종료한 뒤 다시 눌러주세요.",
}, 409)
path = _resolve_draft(draft)
if not path:
return JSONResponse({"ok": False, "error": f"드래프트를 못 찾음: {draft}"}, 404)
try:
res = repair_layers(path)
except Exception as exc: # noqa: BLE001
return JSONResponse({"ok": False, "error": f"{type(exc).__name__}: {exc}"}, 500)
return JSONResponse({"ok": True, "draft": os.path.basename(path), **res})
def _resolve_draft(key: str) -> str:
"""폼 값(드래프트 폴더 경로 또는 폴더명)을 실제 폴더로 변환. 못 찾으면 "".
드래프트가 기본 경로 (CapCut 저장 위치 변경)에도 있어 `basename` 고정은 쓴다.
대신 `list_drafts()` 아는 드래프트에만 매칭해 임의 경로 접근을 막는다.
"""
key = (key or "").strip()
if not key:
return ""
drafts = list_drafts()
want = os.path.normcase(os.path.abspath(key))
for d in drafts: # ① 경로 완전 일치
if os.path.normcase(os.path.abspath(d["path"])) == want:
return d["path"]
base = os.path.basename(key.rstrip("\\/"))
for d in drafts: # ② 폴더명 (구버전 UI 호환)
if d["name"] == key or d["name"] == base:
return d["path"]
return ""
def _sse(obj: dict) -> str:
return f"data: {json.dumps(obj, ensure_ascii=False)}\n\n"

713
server/static/auto.js Normal file
View File

@ -0,0 +1,713 @@
/* (SSE) (ID ) .
카드 DOM·캡처 방식은 h-lab comment-cards 동일 계열(modern-screenshot, 4). */
(function(){
const $=(s)=>document.querySelector(s);
let A=null; // 분석 result {highlights, comments, warnings}
let byIdx={}; // idx → comment
let sel={}; // hl.id → idx 배열(선택 순서 유지: matched 먼저)
let curId=null; // 현재 보고 있는 하이라이트 ID
let dropped=new Set(); // 사용자가 X로 제외한 ID(문자열) — 빌드에서 제외. 되돌리기 가능
const isDropped=(id)=>dropped.has(String(id));
/* ── h-lab timeAgo 이식 ── */
function timeAgo(iso){
if(!iso) return "";
const then=new Date(iso).getTime(); if(isNaN(then)) return "";
const sec=Math.floor((Date.now()-then)/1000);
const u=[["년",31536000],["개월",2592000],["일",86400],["시간",3600],["분",60]];
for(const [l,s] of u){const v=Math.floor(sec/s); if(v>=1) return v+l+" 전";}
return "방금 전";
}
function esc(s){const d=document.createElement("div");d.textContent=String(s==null?"":s);return d.innerHTML;}
function fmtT(sec){const m=Math.floor(sec/60),s=Math.floor(sec%60);return m+":"+String(s).padStart(2,"0");}
let YT_HL=null; // 유튜브 구간 탭의 가상 하이라이트 {id:"yt", need}
function hlById(id){
if(id==="yt") return YT_HL;
return A.highlights.find(h=>h.id===id);
}
/* ── 카드 DOM (h-lab renderCards 구조와 동일) ── */
function cardEl(c,hlId){
const wrap=document.createElement("div");
wrap.className="ccwrap"; wrap.dataset.cidx=c.idx;
const card=document.createElement("div");
card.className="comment-card mosaic"; // 검정배경은 CSS 기본값
const av=c.profileImageUrl?"/auto/avatar?url="+encodeURIComponent(c.profileImageUrl):"";
card.innerHTML=
'<div class="cc-head">'+
'<img class="cc-avatar" loading="lazy" decoding="async" alt="" src="'+av+'">'+
'<div class="cc-meta">'+
'<div><span class="cc-author"></span><span class="cc-time"></span></div>'+
'<div class="cc-text"></div>'+
'<div class="cc-stats"><span>👍 '+(c.likeCount||0).toLocaleString()+'</span>'+
'<span>💬 '+(c.replyCount||0).toLocaleString()+'</span></div>'+
'</div></div>';
card.querySelector(".cc-author").textContent=c.authorName||"";
card.querySelector(".cc-time").textContent=timeAgo(c.publishedAt);
// textDisplay(HTML) → 평문 (h-lab toPlainText와 동일)
const tmp=document.createElement("div");
tmp.innerHTML=String(c.text||"").replace(/<br\s*\/?>/gi,"\n");
card.querySelector(".cc-text").textContent=tmp.textContent||"";
const badge=document.createElement("div");
badge.className="ccbadge"; badge.textContent="✓";
wrap.appendChild(card); wrap.appendChild(badge);
wrap.addEventListener("click",()=>toggle(hlId,c.idx));
return wrap;
}
function wrapOf(hlId,idx){
return $("#hlbox-"+hlId+" .ccwrap[data-cidx='"+idx+"']");
}
/* ── 카드 섹션 — 30장씩 렌더 + 더보기 (전체 댓글을 받아도 DOM은 점진 생성) ── */
const CARD_PAGE=30;
function cardSection(label,list,hlId,firstBatch){
const sec=document.createElement("div");sec.className="hlsec";
const head=document.createElement("div");head.textContent=label;
sec.appendChild(head);
const grid=document.createElement("div");grid.className="cardsec";
sec.appendChild(grid);
const more=document.createElement("button");
more.type="button";more.className="ghost cc-more";
let shown=0;
function render(batch){
const end=Math.min(list.length,shown+batch);
for(;shown<end;shown++){
const c=byIdx[list[shown]];
if(!c) continue;
const w=cardEl(c,hlId);
if(sel[hlId]&&sel[hlId].includes(c.idx)) w.classList.add("sel");
grid.appendChild(w);
}
if(shown>=list.length) more.remove();
else more.textContent="더보기 ▾ (남은 "+(list.length-shown).toLocaleString()+"장)";
applyUsedMarks(hlId); // 새로 그린 카드에도 사용중 표시
}
more.addEventListener("click",()=>render(CARD_PAGE));
sec.appendChild(more);
render(firstBatch||CARD_PAGE);
return sec;
}
function toggle(hlId,idx){
const hl=hlById(hlId), list=sel[hlId];
const at=list.indexOf(idx);
if(at>=0) list.splice(at,1);
else{
if(list.length>=hl.need) return; // 필요 장수 초과 선택 방지
list.push(idx);
}
refreshSel(hlId);
}
/* 선택 상태를 카드 링·요약 칩·탭 카운터에 일괄 반영 */
function refreshSel(hlId){
const hl=hlById(hlId), list=sel[hlId];
const box=$("#hlbox-"+hlId); if(!box) return;
box.querySelectorAll(".ccwrap").forEach(w=>
w.classList.toggle("sel",list.includes(parseInt(w.dataset.cidx,10))));
// 탭 카운터
const tab=$("#idtab-"+hlId);
if(tab){
const ts=tab.querySelector(".ts");
const off=isDropped(hlId);
ts.textContent=off?"제외됨 · 생성 안 함":("카드 "+list.length+"/"+hl.need);
tab.classList.toggle("full",!off&&list.length>=hl.need);
}
// 헤더 카운터
const cnt=$("#hlcnt-"+hlId);
if(cnt) cnt.textContent=list.length+" / "+hl.need+"장 선택";
// 선택 요약 칩
const bar=$("#hlsel-"+hlId);
if(bar){
bar.innerHTML='<span class="selbar-l">✔ 선택한 댓글</span>';
list.forEach((idx,i)=>{
const c=byIdx[idx]; if(!c) return;
const chip=document.createElement("span");
chip.className="selchip";
chip.innerHTML='<span class="no">'+(i+1)+'</span><span class="nm"></span>'+
'<span class="x" title="선택 해제">✕</span>';
chip.querySelector(".nm").textContent=
(c.authorName||"익명")+" · 👍"+(c.likeCount||0).toLocaleString();
chip.title=String(c.text||"").slice(0,120);
chip.addEventListener("click",(e)=>{ // 칩 클릭 = 카드로 스크롤
if(e.target.classList.contains("x")) return;
const w=wrapOf(hlId,idx);
if(w) w.scrollIntoView({behavior:"smooth",block:"center"});
});
chip.querySelector(".x").addEventListener("click",()=>toggle(hlId,idx));
bar.appendChild(chip);
});
if(!list.length){
const em=document.createElement("span");
em.className="selbar-l"; em.textContent="없음 — 아래 카드를 클릭해 선택";
bar.appendChild(em);
}
// 선택만 보기 토글(유지)
const lb=document.createElement("label");
lb.className="selonly-label";
const on=box.classList.contains("selonly");
lb.innerHTML='<input type="checkbox" '+(on?"checked ":"")+
'style="accent-color:var(--accent);width:14px;height:14px;"> 선택한 것만 보기';
lb.querySelector("input").addEventListener("change",(e)=>
box.classList.toggle("selonly",e.target.checked));
bar.appendChild(lb);
}
}
/* ── 다른 ID에서 이미 선택한 댓글 표시 ── */
function usedByOthers(hlId,idx){
const ids=[];
for(const k in sel){
if(String(k)===String(hlId)) continue;
if(isDropped(k)) continue; // 제외된 ID는 '사용중'으로 치지 않음
if(sel[k]&&sel[k].includes(idx)) ids.push(k);
}
return ids;
}
function applyUsedMarks(hlId){
const box=$("#hlbox-"+hlId); if(!box) return;
box.querySelectorAll(".ccwrap").forEach(w=>{
const idx=parseInt(w.dataset.cidx,10);
const ids=usedByOthers(hlId,idx);
w.classList.toggle("used",ids.length>0);
let b=w.querySelector(".ccused");
if(ids.length){
if(!b){b=document.createElement("div");b.className="ccused";w.appendChild(b);}
b.textContent="ID "+ids.join("·")+" 사용중";
w.title="ID "+ids.join(", ")+"에서 이미 선택한 댓글";
}else{
if(b) b.remove();
w.removeAttribute("title");
}
});
}
/* ── 댓글 카드 영역 접기/펼치기 ── */
function setFolded(on){
const R=$("#autoReview");if(!R)return;
R.classList.toggle("folded",on);
const b=$("#ccFoldBtn");
if(b){
b.querySelector(".tt").textContent=on?"▾ 댓글 펼치기":"▴ 댓글 접기";
b.querySelector(".ts").textContent=on?"카드 숨김 상태":"카드 표시 상태";
}
}
/* ── ID 탭 전환 ── */
function showId(hlId){
if(isDropped(hlId)) return; // 제외된 ID는 열지 않음
curId=hlId;
document.querySelectorAll(".hlbox:not(.errbox)").forEach(b=>
b.classList.toggle("show",b.id==="hlbox-"+hlId));
document.querySelectorAll(".idtab").forEach(t=>
t.classList.toggle("active",t.id==="idtab-"+hlId));
applyUsedMarks(hlId); // 탭 열 때 최신 선택 상태로 갱신
}
/* ── ID 제외 / 되돌리기
지우지 않고 '제외' 상태로만 둔다 실수로 눌러도 번에 복구 가능(되돌리기 지원).
제외된 ID는 빌드에서 빠지고, ID가 잡고 있던 댓글도 '사용중' 표시에서 풀린다. */
function liveIds(){
if(!A) return [];
return A.highlights.filter(h=>!h.error&&!isDropped(h.id)).map(h=>h.id);
}
function updateBuildBtn(){
const btn=$("#autoBuild"); if(!btn||!A) return;
const n=liveIds().length;
btn.disabled=(n===0);
btn.textContent=n?(n+"개 전부 만들기"):"만들 ID가 없습니다 — 제외를 해제하세요";
}
function setDropped(hlId,on){
const key=String(hlId);
if(on) dropped.add(key); else dropped.delete(key);
const tab=$("#idtab-"+hlId);
if(tab) tab.classList.toggle("dropped",on);
const x=$("#xdel-"+hlId);
if(x){
x.textContent=on?"↺":"✕";
x.title=on?"다시 포함":"이 ID 제외 (생성 안 함)";
x.setAttribute("aria-label","ID "+hlId+(on?" 다시 포함":" 제외 — 생성하지 않음"));
x.setAttribute("aria-pressed",on?"true":"false");
}
const box=$("#hlbox-"+hlId);
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); // 되돌리면 그 탭을 연다
refreshSel(hlId);
if(curId!=null) applyUsedMarks(curId);
updateBuildBtn();
}
/* (JSON )
hl.paste.cuts = [{start, end, bottom, effect}] (start/end 원본 영상 기준 ). */
function cutsSection(hl){
const cuts=(hl.paste&&hl.paste.cuts)||[];
const d=document.createElement("details");
d.className="cutsbox";
const sum=document.createElement("summary");
sum.innerHTML='<span class="chev" aria-hidden="true">▶</span>'+
'<span class="ctitle">영상 편집안</span>'+
'<span class="cprev"></span>'+
'<span class="cnum tnum">컷 '+cuts.length+"개 · "+(hl.total!=null?hl.total:0)+"초</span>";
const first=cuts.map(c=>(c.bottom||"").split("\n")[0].trim()).filter(Boolean)[0]||"";
sum.querySelector(".cprev").textContent=first?("— "+first):"";
d.appendChild(sum);
const list=document.createElement("div");
list.className="cutlist";
cuts.forEach((c,i)=>{
const row=document.createElement("div");
row.className="cutrow";
const dur=Math.max(0,(c.end||0)-(c.start||0));
row.innerHTML='<span class="cno">'+(i+1)+"</span>"+
'<span class="ctime">'+fmtT(c.start||0)+"~"+fmtT(c.end||0)+
'<span class="cdur">'+dur.toFixed(1)+"초</span></span>"+
'<span class="cbody"><span class="cbot"></span><span class="ceff"></span></span>';
const bot=String(c.bottom||"").trim();
const bEl=row.querySelector(".cbot");
bEl.textContent=bot||"(자막 없음)";
if(!bot) bEl.classList.add("cempty");
const eff=String(c.effect||"").trim();
if(eff) row.querySelector(".ceff").textContent=eff;
else row.querySelector(".ceff").remove();
list.appendChild(row);
});
if(!cuts.length){
const em=document.createElement("div");
em.className="cutrow";em.style.gridTemplateColumns="1fr";
em.textContent="컷이 없습니다.";
list.appendChild(em);
}
d.appendChild(list);
// JSON 원문(더 깊이 접어둠) + 복사
const raw=document.createElement("details");
raw.className="cutraw";
const rs=document.createElement("summary");
rs.textContent="{ } JSON 원문";
raw.appendChild(rs);
const pre=document.createElement("pre");
pre.textContent=JSON.stringify(hl.paste,null,1);
raw.appendChild(pre);
const cp=document.createElement("button");
cp.type="button";cp.className="ghost cutcopy";cp.textContent="JSON 복사";
cp.addEventListener("click",async()=>{
try{await navigator.clipboard.writeText(pre.textContent);cp.textContent="복사됨";}
catch(e){cp.textContent="복사 실패";}
setTimeout(()=>{cp.textContent="JSON 복사";},1200);
});
raw.appendChild(cp);
d.appendChild(raw);
return d;
}
/* ── 방식 선택 ── */
function curMode(){
const r=document.querySelector('input[name="amode"]:checked');
return r?r.value:"full";
}
const MODE_HELP={
full:"Gemini가 영상에서 하이라이트 구간 5개를 골라 편집안을 만들고, h-lab에서 그 구간을 언급한 댓글을 찾아옵니다.",
whole:"Gemini가 구간 5개만 고르고(Step1), 각 구간을 컷 편집 없이 통짜로 만듭니다. 무음 제거·자막은 아래 공통 옵션을 따릅니다.",
wpaste:"Gemini를 쓰지 않습니다. 구간 JSON(candidates 5개)을 붙여넣으면 그 구간을 그대로 통짜로 만듭니다. 제목(윗줄·아랫줄)은 댓글 선택 화면에서 ID별로 직접 씁니다.",
paste:"Gemini를 쓰지 않습니다. 오팔에서 받은 JSON 5개를 통째로 붙여넣으면 댓글 선택 화면으로 갑니다. URL을 비우면 JSON 안의 url을 씁니다.",
};
const PASTE_UI={
paste:{label:"오팔 JSON 붙여넣기 (여러 개를 통째로 — 사이에 구분선·타이틀 후보가 섞여 있어도 됨)",
ph:'오팔 Step 3 결과(JSON 코드블록) 5개를 순서대로 전부 붙여넣으세요.\n블록 ②·③ 텍스트가 섞여 들어와도 자동으로 JSON만 골라냅니다.',
note:"(선택 — 비우면 JSON의 url 사용)"},
wpaste:{label:"구간 JSON 붙여넣기 (candidates 배열 — 구간 5개)",
ph:'{\n "candidates": [\n {"id": 1, "start_time": "14:28", "end_time": "16:15", "reason": "구간 선정 이유"},\n … (총 5개)\n ]\n}',
note:"(필수 — 구간 JSON에는 URL이 없습니다)"},
};
function applyMode(){
const m=curMode();
const p=PASTE_UI[m];
$("#apasteField").style.display=p?"block":"none";
if(p){
$("#apasteLabel").textContent=p.label;
$("#apaste").placeholder=p.ph;
}
$("#autoUrlNote").textContent=p?p.note:"";
$("#autoModeHelp").textContent=MODE_HELP[m];
$("#autoGo").textContent=p?"댓글 매칭 시작":"분석 시작 (하이라이트 5개)";
}
/* ── 분석 ── */
async function analyze(){
const m=curMode();
const url=$("#autoUrl").value.trim();
if(m!=="paste"&&!url){
alert(m==="wpaste"?"유튜브 URL을 입력하세요. (구간 JSON에는 URL이 없습니다)"
:"유튜브 URL을 입력하세요.");return;}
if(PASTE_UI[m]&&!$("#apaste").value.trim()){
alert(m==="wpaste"?"구간 JSON을 붙여넣으세요.":"오팔 JSON을 붙여넣으세요.");return;}
$("#autoGo").disabled=true;$("#autoGo").textContent="분석 중…";
$("#autoReview").innerHTML="";$("#autoSummary").style.display="none";
$("#autoBuild").style.display="none";$("#autoLog").innerHTML="";
$("#buildBoard").style.display="none";$("#buildBoard").innerHTML="";
if($("#autoFixedWrap")) $("#autoFixedWrap").style.display="none";
let res;
try{
const fd=new FormData();
fd.append("url",url);
fd.append("mode",m);
if(PASTE_UI[m]) fd.append("data",$("#apaste").value);
res=await(await fetch("/auto/analyze",{method:"POST",body:fd})).json();
}catch(e){return failA("요청 실패: "+e);}
if(res.error) return failA(res.error);
const es=new EventSource("/auto/stream/"+res.analysis_id);
es.onmessage=(m)=>{
const ev=JSON.parse(m.data);
if(ev.type==="manifest") renderASteps(ev.steps);
else if(ev.type==="step") updateAStep(ev);
else if(ev.type==="log") alog(ev.msg);
else if(ev.type==="result"){es.close();onResult(ev);doneA();}
else if(ev.type==="error"){es.close();failA(ev.message);}
};
es.onerror=()=>{es.close();failA("연결이 끊겼습니다.");};
}
function doneA(){$("#autoGo").disabled=false;applyMode();}
function failA(m){alog("⚠️ "+m);doneA();}
function alog(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;$("#autoLog").appendChild(d);
}
function renderASteps(steps){
$("#autoSteps").innerHTML=steps.map(s=>
'<div class="step" id="ast-'+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("");
$("#autoSteps").style.display="block";
}
function updateAStep(ev){
const el=$("#ast-"+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;}}
}
/* ── 검토 화면 (ID별 탭) ── */
function onResult(ev){
A=ev;byIdx={};sel={};curId=null;dropped=new Set();
(ev.comments||[]).forEach(c=>{byIdx[c.idx]=c;});
(ev.warnings||[]).forEach(w=>alog("⚠️ "+w));
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){
// 탭 버튼
const tab=document.createElement("button");
tab.type="button";tab.className="idtab";tab.id="idtab-"+hl.id;
if(hl.error){
tab.classList.add("err");
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);
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");
box.className="hlbox";box.id="hlbox-"+hl.id;
sel[hl.id]=(hl.matched||[]).slice(0,hl.need).filter(i=>byIdx[i]!==undefined);
const cuts=hl.paste.cuts;
let html="<h3>ID "+hl.id+" · "+fmtT(hl.start)+"~"+fmtT(hl.end)+
" <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));
// ⭐ 구간 언급 댓글 (전체 — 30장씩 더보기)
const m=(hl.matched||[]).filter(i=>byIdx[i]!==undefined);
box.appendChild(cardSection(
"⭐ 이 구간을 언급한 댓글 "+m.length+"장 (좋아요순, 자동 선택)",
m,hl.id,Math.max(CARD_PAGE,sel[hl.id].length))); // 자동 선택분은 첫 화면에 다 보이게
// 좋아요 상위 후보 (전체 — 30장씩 더보기)
const cand=(hl.candidates||[]).filter(i=>byIdx[i]!==undefined);
if(cand.length){
box.appendChild(cardSection(
" 좋아요 상위 후보 "+cand.length+"장 (부족분 클릭)",
cand,hl.id,CARD_PAGE));
}
R.appendChild(box);
refreshSel(hl.id);
}
// 접기/펼치기 버튼 — 탭 바 오른쪽 끝
if(ok){
const fb=document.createElement("button");
fb.type="button";fb.className="idtab ccfoldbtn";fb.id="ccFoldBtn";
fb.innerHTML='<span class="tt">▴ 댓글 접기</span><span class="ts">카드 표시 상태</span>';
fb.addEventListener("click",()=>setFolded(!$("#autoReview").classList.contains("folded")));
tabs.appendChild(fb);
}
if(firstOk!==null) showId(firstOk);
if(ok){
$("#autoBuild").style.display="block";
updateBuildBtn();
if($("#autoFixedWrap")) $("#autoFixedWrap").style.display="flex";
}
}
/* ── 캡처 + 순차 빌드 ── */
async function captureCard(wrap){
const img=wrap.querySelector(".cc-avatar");
if(img&&img.src&&!img.complete){ // 숨겨진 탭의 lazy 아바타 → 강제 로드 후 캡처
img.loading="eager";
try{await img.decode();}catch(e){}
}
const card=wrap.querySelector(".comment-card");
return await window.modernScreenshot.domToBlob(card,{backgroundColor:null,scale:4});
}
const nextFrame=()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)));
/* 빌드 진행판 — 버튼 아래 ID별 한 줄씩, 전체 상황이 한눈에 보임 */
function boardInit(hls){
const bd=$("#buildBoard");
bd.innerHTML=hls.map(hl=>
'<div class="bbrow" id="bb-'+hl.id+'">'+
'<span class="bbid">ID '+hl.id+'</span>'+
'<span class="bbstat">⏳ 대기</span>'+
'<span class="bbmsg"></span></div>').join("");
bd.style.display="block";
}
function boardSet(hlId,stat,msg,cls){
const row=$("#bb-"+hlId); if(!row) return;
row.className="bbrow"+(cls?" "+cls:"");
if(stat!=null) row.querySelector(".bbstat").textContent=stat;
if(msg!=null) row.querySelector(".bbmsg").textContent=msg;
}
async function buildAll(){
const btn=$("#autoBuild");
const hls=A.highlights.filter(h=>!h.error&&!isDropped(h.id)); // 제외한 ID는 안 만듦
if(!hls.length){alert("만들 ID가 없습니다. 제외(✕)를 하나 이상 해제하세요.");return;}
btn.disabled=true;
setFolded(true); // 빌드 시작 → 댓글 영역 접기(진행판에 집중)
boardInit(hls);
$("#buildBoard").scrollIntoView({behavior:"smooth",block:"nearest"});
let ok=0,fail=0;
for(const hl of hls){
showId(hl.id); // 캡처는 보이는 상태에서
await nextFrame();
boardSet(hl.id,"🔄 진행","", "active");
try{
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 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();
fd.append("data",JSON.stringify(paste));
fd.append("tag","하이라이트"+hl.id);
fd.append("video_scale",$("#vscale").value||"144");
fd.append("flip",$("#flip").checked?"1":"0");
fd.append("scene",$("#scene").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("cards_fixed",($("#autoCardsFixed")&&$("#autoCardsFixed").checked)?"1":"0");
let n=0;
for(const idx of sel[hl.id]){
const w=wrapOf(hl.id,idx);
if(!w) continue;
boardSet(hl.id,null,"카드 캡처 중… "+(++n)+"/"+sel[hl.id].length,"active");
try{
const blob=await captureCard(w);
fd.append("cards",blob,String(n).padStart(3,"0")+".png");
}catch(e){boardSet(hl.id,null,"카드 1장 캡처 실패(건너뜀)","active");}
}
boardSet(hl.id,null,"빌드 요청 중…","active");
const res=await(await fetch("/auto/build",{method:"POST",body:fd})).json();
if(res.error) throw new Error(res.error);
const r=await streamJob(res.job_id,hl.id); // 완료/실패까지 대기(순차)
boardSet(hl.id,"✅ 완료",(r&&r.draft_name)?r.draft_name:"","");
ok++;
const tab=$("#idtab-"+hl.id);
if(tab) tab.querySelector(".ts").textContent="✅ 완료";
}catch(e){
boardSet(hl.id,"❌ 실패",String(e.message||e),"err");fail++;
const tab=$("#idtab-"+hl.id);
if(tab) tab.querySelector(".ts").textContent="❌ 실패";
}
}
btn.disabled=false;btn.textContent="다시 만들기";
const s=$("#autoSummary");s.style.display="block";
s.textContent=ok+"개 성공"+(fail?", "+fail+"개 실패":"")+
" — CapCut 프로젝트 목록에서 드래프트를 여세요.";
if(ok&&$("#autoopen")&&$("#autoopen").checked)
fetch("/open-capcut",{method:"POST"});
}
function streamJob(jobId,hlId){
return new Promise((resolve,reject)=>{
const es=new EventSource("/stream/"+jobId);
es.onmessage=(m)=>{
const ev=JSON.parse(m.data);
if(ev.type==="log") boardSet(hlId,null,ev.msg,"active");
else if(ev.type==="step"&&ev.status==="start") boardSet(hlId,null,"▶ "+ev.id,"active");
else if(ev.type==="step"&&ev.status==="done"&&ev.detail)
boardSet(hlId,null,"✓ "+ev.id+" — "+ev.detail,"active");
else if(ev.type==="result"){es.close();resolve(ev);}
else if(ev.type==="error"){es.close();reject(new Error(ev.message));}
};
es.onerror=()=>{es.close();reject(new Error("연결 끊김"));};
});
}
/* ── 유튜브 구간 탭: 댓글 매칭 (자동 탭과 동일 UI) ── */
const YT_OFF=1000000; // 자동 탭 byIdx 와 충돌하지 않게 idx 오프셋
function ytSec(v){
v=(v||"").trim(); if(!v) return null;
const p=v.split(":").map(Number);
if(p.some(isNaN)) return null;
return p.length===3?p[0]*3600+p[1]*60+p[2]:p.length===2?p[0]*60+p[1]:p[0];
}
function ytRanges(){
const out=[];
document.querySelectorAll("#ranges .rng").forEach(row=>{
const s=ytSec(row.querySelector(".rstart").value);
if(s==null) return;
let e=ytSec(row.querySelector(".rend").value);
if(e==null) e=s+90; // 끝 비면 시작+1:30 (실행 로직과 동일)
if(e>s) out.push([s,e]);
});
return out;
}
async function ytMatch(){
const url=($("#yurl").value||"").trim();
if(!url){alert("유튜브 URL을 입력하세요.");return;}
const rng=ytRanges();
if(!rng.length){alert("구간을 하나 이상 입력하세요.");return;}
const btn=$("#ytccBtn");btn.disabled=true;btn.textContent="댓글 수집 중… (h-lab)";
let r;
try{
const fd=new FormData();
fd.append("url",url);fd.append("ranges",JSON.stringify(rng));
r=await(await fetch("/yt/comments",{method:"POST",body:fd})).json();
}catch(e){r={error:"요청 실패: "+e};}
btn.disabled=false;btn.textContent="💬 구간 댓글 다시 매칭";
if(r.error){$("#ytccNote").textContent="⚠ "+r.error;return;}
// idx 오프셋 후 등록 (자동 탭 상태와 공존)
(r.comments||[]).forEach(c=>{c.idx+=YT_OFF;byIdx[c.idx]=c;});
const matched=(r.matched||[]).map(i=>i+YT_OFF).filter(i=>byIdx[i]);
const cand=(r.candidates||[]).map(i=>i+YT_OFF).filter(i=>byIdx[i]);
YT_HL={id:"yt",need:r.need};
sel["yt"]=matched.slice(0,r.need);
const area=$("#ytccArea");area.innerHTML="";
const box=document.createElement("div");
box.className="hlbox show";box.id="hlbox-yt";
box.innerHTML="<h3>💬 구간 댓글</h3>"+
'<div class="hlmeta">구간 '+rng.length+"개 · 총 "+r.total+"초 · 카드 "+r.need+"장 필요 · "+
'<span id="hlcnt-yt" class="tnum"></span></div>'+
'<label class="selonly-label" style="margin-left:0;padding:2px 0;">'+
'<input type="checkbox" id="ytccFixed" checked style="accent-color:var(--accent);width:14px;height:14px;">'+
' 카드 3초 고정 — 모자라도 늘리지 않고 뒤는 비움 (부분삭제 편집용)</label>'+
'<div class="selbar" id="hlsel-yt"></div>';
box.appendChild(cardSection(
"⭐ 구간을 언급한 댓글 "+matched.length+"장 (좋아요순, 자동 선택)",
matched,"yt",Math.max(CARD_PAGE,sel["yt"].length)));
if(cand.length){
box.appendChild(cardSection(
" 좋아요 상위 후보 "+cand.length+"장 (부족분 클릭)",
cand,"yt",CARD_PAGE));
}
area.appendChild(box);
refreshSel("yt");
$("#ytccNote").textContent="선택한 카드는 편집 시작 때 자동으로 들어갑니다(폴더 지정보다 우선). "
+"매칭 "+matched.length+"장 · 후보 "+cand.length+"장 · 댓글 "+(r.comments||[]).length+"개";
}
/* 편집 시작(index.html 인라인 스크립트)에서 쓰는 훅 */
window.ytCC={
active:function(){return !!(YT_HL&&sel["yt"]&&sel["yt"].length);},
capture:async function(){
const out=[];
for(const idx of sel["yt"]){
const w=wrapOf("yt",idx);
if(!w) continue;
try{out.push(await captureCard(w));}catch(e){/* 실패 카드는 건너뜀 */}
}
return out;
},
};
/* ── ⚙ 지침·모델 설정 ── */
async function loadPrompts(){
try{
const r=await(await fetch("/prompts")).json();
if(r.error){$("#pMsg").textContent="⚠ "+r.error;return;}
$("#pStep1").value=r.step1;$("#pStep3").value=r.step3;
$("#pConfig").value=JSON.stringify(r.config,null,1);
}catch(e){$("#pMsg").textContent="⚠ 불러오기 실패";}
}
async function savePrompts(reset){
const fd=new FormData();
if(reset) fd.append("reset","1");
else{
fd.append("step1",$("#pStep1").value);
fd.append("step3",$("#pStep3").value);
fd.append("config",$("#pConfig").value);
}
try{
const r=await(await fetch("/prompts",{method:"POST",body:fd})).json();
$("#pMsg").textContent=r.error?("⚠ "+r.error):"✓ 저장됨 (다음 분석부터 적용)";
if(!r.error&&reset) loadPrompts();
}catch(e){$("#pMsg").textContent="⚠ 저장 실패";}
}
document.addEventListener("DOMContentLoaded",()=>{
$("#autoGo").addEventListener("click",analyze);
$("#autoUrl").addEventListener("keydown",(e)=>{if(e.key==="Enter")analyze();});
document.querySelectorAll('input[name="amode"]').forEach(r=>
r.addEventListener("change",applyMode));
applyMode();
$("#autoBuild").addEventListener("click",buildAll);
$("#autoSettings").addEventListener("toggle",()=>{if($("#autoSettings").open)loadPrompts();});
$("#pSave").addEventListener("click",()=>savePrompts(false));
$("#pReset").addEventListener("click",()=>savePrompts(true));
if($("#ytccBtn")) $("#ytccBtn").addEventListener("click",ytMatch);
});
})();

861
server/static/index.html Normal file
View File

@ -0,0 +1,861 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>캡컷 에이전트 · 구간합치기</title>
<style>
:root{
/* 편집실 팔레트 — 순흑이 아니라 편집 프로그램의 청회색 레이어 */
--bg:#0B0D12; --card:#12151D; --border:#262D3A; --hr:#1C212C;
--surf:#161B25; /* 버튼·칩·탭 표면 (구 #141414/#1c1c1c) */
--surf2:#0D1017; /* 입력면 (구 #121212/#0f0f0f) */
--acc-soft:#0E2320; /* 활성 배경 (구 var(--acc-soft)) */
--text:#EAECF1; --muted:#7E8899; --muted2:#A2ACBD;
--accent:#2CE0C9; /* CapCut 틸 — 이 도구의 종착지 */
--accent-ink:#03211C; /* 틸 버튼 위 글자 */
--rec:#FF5D55; /* 재생헤드 · REC */
--danger:#F0716B;
--mono:ui-monospace,"SF Mono","JetBrains Mono","Cascadia Code",monospace;
--sans:"Pretendard Variable",Pretendard,system-ui,-apple-system,"Segoe UI",sans-serif;
}
*{box-sizing:border-box;} html,body{margin:0;height:100%;}
body{background:var(--bg);color:var(--text);font-family:var(--sans);font-size:14px;line-height:1.5;
-webkit-font-smoothing:antialiased;display:flex;justify-content:center;padding:48px 20px 120px;
background-image:radial-gradient(900px 340px at 50% -80px,rgba(44,224,201,.045),transparent 70%);}
::selection{background:rgba(44,224,201,.25);}
:is(button,input,select,textarea,summary,a):focus-visible{
outline:2px solid var(--accent);outline-offset:2px;border-radius:4px;}
::-webkit-scrollbar{width:10px;height:10px;}
::-webkit-scrollbar-thumb{background:#2A3140;border-radius:5px;border:2px solid var(--bg);}
::-webkit-scrollbar-track{background:transparent;}
.wrap{width:100%;max-width:600px;}
.tnum{font-variant-numeric:tabular-nums;}
header{margin-bottom:22px;}
.brand{display:flex;align-items:center;gap:9px;}
.dot{width:8px;height:8px;border-radius:50%;background:var(--rec);
box-shadow:0 0 8px rgba(255,93,85,.7);flex:none;animation:recblink 2.4s ease-in-out infinite;}
@keyframes recblink{0%,100%{opacity:1;}50%{opacity:.45;}}
h1{font-size:16px;font-weight:700;margin:0;letter-spacing:-0.02em;}
.sub{color:var(--muted);font-family:var(--mono);font-size:11.5px;margin:8px 0 0 17px;letter-spacing:.02em;}
/* ── 시그니처: 필름 타임라인 스트립 — 클립 조각 + 흐르는 재생헤드 ── */
.tl{position:relative;display:flex;gap:4px;align-items:center;height:10px;
margin:14px 0 0;overflow:hidden;border-radius:3px;}
.tl .tlc{display:block;height:100%;border-radius:2px;background:#222A38;flex:none;}
.tl .tlc.on{background:linear-gradient(90deg,rgba(44,224,201,.85),rgba(44,224,201,.5));}
.tl .tlph{position:absolute;top:-2px;bottom:-2px;left:0;width:2px;background:var(--rec);
box-shadow:0 0 6px rgba(255,93,85,.8);animation:phdrift 26s ease-in-out infinite alternate;}
@keyframes phdrift{from{transform:translateX(0);}to{transform:translateX(min(596px,calc(100vw - 44px)));}}
.card{background:var(--card);border:1px solid var(--border);border-radius:12px;}
@media (prefers-reduced-motion:reduce){
*,*::before,*::after{animation:none !important;transition:none !important;}
}
/* tabs */
.tabs{display:flex;gap:6px;margin-bottom:14px;}
.tab{flex:1;background:var(--surf);border:1px solid var(--border);color:var(--muted2);
border-radius:9px;padding:11px 10px;min-height:44px;font-family:var(--sans);font-size:13px;font-weight:500;
cursor:pointer;transition:.15s;}
.tab:hover{border-color:#39424F;color:var(--text);}
.tab.active{background:var(--acc-soft);border-color:var(--accent);color:var(--text);font-weight:600;}
#drop{border:1px dashed var(--border);border-radius:10px;background:var(--card);
padding:32px 20px;text-align:center;cursor:pointer;transition:border-color .15s,background .15s;}
#drop:hover{border-color:#39424F;} #drop.drag{border-color:var(--accent);border-style:solid;}
#drop .big{font-size:14px;} #drop .hint{color:var(--muted);font-family:var(--mono);font-size:12px;margin-top:8px;}
input[type=file]{display:none;}
.chip{font-family:var(--mono);font-size:12px;color:var(--muted2);background:var(--surf);border:1px solid var(--border);
border-radius:6px;padding:5px 9px;display:inline-flex;align-items:center;gap:7px;overflow:hidden;
white-space:nowrap;text-overflow:ellipsis;max-width:100%;}
.chip .k{color:var(--muted);}
.filerow{margin-top:12px;}
.fields{display:grid;gap:8px;margin-top:14px;}
.field label{display:block;color:var(--muted);font-size:11.5px;margin-bottom:4px;letter-spacing:.02em;}
.field input{width:100%;background:var(--surf2);border:1px solid var(--border);border-radius:7px;color:var(--text);
font-family:var(--sans);font-size:13px;padding:9px 11px;outline:none;}
.field input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(44,224,201,.10);}
.field input::placeholder{color:#495364;}
.field.mono input{font-family:var(--mono);}
.row2{display:grid;grid-template-columns:1fr 1fr;gap:8px;}
.note{color:var(--muted);font-family:var(--mono);font-size:11px;margin-top:8px;}
button.run{font-family:var(--sans);font-size:13.5px;font-weight:700;color:var(--accent-ink);background:var(--accent);
border:0;border-radius:9px;padding:12px 16px;min-height:46px;cursor:pointer;width:100%;margin-top:14px;
transition:filter .15s,transform .1s;}
button.run:hover{filter:brightness(1.08);} button.run:active{transform:scale(.985);}
button.run:disabled{opacity:.4;cursor:default;transform:none;}
#log{display:none;margin-top:16px;font-family:var(--mono);font-size:11.5px;color:var(--muted);line-height:1.8;}
#log .line:before{content:" ";color:#39424F;}
#steps{display:none;margin-top:16px;}
.step{display:flex;align-items:flex-start;gap:11px;padding:11px 0;}
.step+.step{border-top:1px solid var(--hr);}
.sdot{width:4px;height:16px;border-radius:2px;background:#29303D;margin-top:2px;flex:none;transition:background .2s;}
.step.active .sdot{background:var(--accent);box-shadow:0 0 8px var(--accent);animation:pulse 1.1s ease-in-out infinite;}
.step.done .sdot{background:var(--accent);} .step.err .sdot{background:var(--danger);box-shadow:0 0 8px var(--danger);}
@keyframes pulse{0%,100%{opacity:1;}50%{opacity:.35;}}
.slabel{flex:1;min-width:0;} .slabel .t{font-size:13.5px;color:var(--muted2);}
.step.active .slabel .t,.step.done .slabel .t{color:var(--text);}
.sdetail{color:var(--muted);font-family:var(--mono);font-size:12px;margin-top:3px;word-break:break-all;}
.step.err .sdetail{color:var(--danger);}
.selapsed{color:var(--muted);font-family:var(--mono);font-size:12px;flex:none;margin-top:1px;}
#result{display:none;margin-top:18px;}
.grid3{display:grid;grid-template-columns:repeat(3,1fr);}
.stat+.stat{border-left:1px solid var(--hr);padding-left:16px;} .stat:not(:first-child){padding-left:16px;}
.stat .v{font-family:var(--mono);font-size:22px;font-weight:500;letter-spacing:-.02em;}
.stat .v.green{color:var(--accent);} .stat .l{color:var(--muted);font-size:11.5px;margin-top:4px;}
hr.sep{border:0;border-top:1px solid var(--hr);margin:16px 0;}
.reslabel{color:var(--muted);font-size:11.5px;margin-bottom:8px;}
.opennote{color:var(--muted);font-family:var(--mono);font-size:11.5px;margin-top:14px;line-height:1.7;}
.opennote b{color:var(--muted2);font-weight:500;}
button.capcut{margin-top:14px;width:100%;font-family:var(--sans);font-size:13.5px;font-weight:700;color:var(--accent-ink);
background:var(--accent);border:0;border-radius:9px;padding:12px;min-height:46px;cursor:pointer;
transition:filter .15s,transform .1s;}
button.capcut:hover{filter:brightness(1.08);} button.capcut:active{transform:scale(.985);}
.ghost{margin-top:8px;width:100%;background:var(--surf);border:1px solid var(--border);color:var(--muted2);
border-radius:8px;padding:9px;font-family:var(--sans);font-size:12.5px;cursor:pointer;}
.ghost:hover{color:var(--text);border-color:#39424F;}
.hdrlinks{display:flex;gap:8px;flex-wrap:wrap;}
.hlink{display:inline-flex;align-items:center;gap:5px;text-decoration:none;color:var(--muted2);
background:var(--card);border:1px solid var(--border);border-radius:999px;padding:5px 12px;
font-size:12px;font-family:var(--sans);white-space:nowrap;transition:color .12s,border-color .12s;}
.hlink:hover{color:var(--accent);border-color:var(--accent);}
.helprow{display:flex;justify-content:flex-end;margin:0 0 6px;}
.helpbtn{background:transparent;color:var(--muted2);border:1px solid var(--border);
border-radius:999px;padding:3px 11px;font-size:11.5px;cursor:pointer;font-family:var(--sans);}
.helpbtn:hover{color:var(--accent);border-color:var(--accent);}
#helpOverlay{position:fixed;inset:0;background:rgba(0,0,0,.65);display:none;
align-items:center;justify-content:center;z-index:50;padding:20px;}
#helpModal{background:var(--card);border:1px solid var(--border);border-radius:14px;
max-width:560px;width:100%;max-height:80vh;overflow-y:auto;padding:22px 24px;position:relative;}
#helpModal h3{margin:0 0 12px;font-size:15px;color:var(--text);}
#helpModal ol,#helpModal ul{margin:8px 0;padding-left:20px;color:var(--muted2);font-size:13px;line-height:1.75;}
#helpModal li b{color:var(--text);font-weight:600;}
#helpModal code{background:var(--surf2);border:1px solid var(--border);border-radius:5px;
padding:1px 6px;font-family:var(--mono);font-size:11.5px;color:var(--accent);}
#helpModal pre{background:var(--surf2);border:1px solid var(--border);border-radius:8px;
padding:10px 12px;font-family:var(--mono);font-size:11px;line-height:1.55;overflow-x:auto;color:var(--muted2);margin:8px 0;}
#helpClose{position:absolute;top:12px;right:14px;background:transparent;border:0;
color:var(--muted2);font-size:16px;cursor:pointer;}
#helpClose:hover{color:var(--text);}
.helpdim{color:var(--muted);font-size:12px;margin-top:10px;line-height:1.6;}
/* ── 자동 탭: 댓글 카드 (h-lab comment-cards 이식 · 검정배경/둥근모서리/모자이크 고정) ── */
.ccwrap{position:relative;display:inline-block;width:320px;margin:0 10px 10px 0;
vertical-align:top;cursor:pointer;border-radius:18px;}
.ccwrap.sel{outline:2px solid var(--accent);outline-offset:2px;}
/* 다른 ID(탭)에서 이미 선택한 댓글 — 주황 점선 + 배지 (자기 탭 선택 초록이 우선) */
.ccwrap.used:not(.sel){outline:2px dashed #d97706;outline-offset:2px;}
.ccwrap .ccused{position:absolute;top:-8px;left:8px;background:#d97706;color:#000;
font-size:10.5px;font-weight:700;padding:2px 8px;border-radius:999px;z-index:2;display:none;}
.ccwrap.used .ccused{display:block;}
.ccwrap .ccbadge{position:absolute;top:-7px;right:-7px;width:22px;height:22px;border-radius:50%;
background:var(--accent);color:#000;font-size:13px;font-weight:700;display:none;
align-items:center;justify-content:center;z-index:2;}
.ccwrap.sel .ccbadge{display:flex;}
.comment-card{background:#000;border:1px solid #333;border-radius:16px;padding:16px;
box-sizing:border-box;width:320px;
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Malgun Gothic",sans-serif;
-webkit-font-smoothing:antialiased;}
.cc-head{display:flex;gap:9.6px;align-items:flex-start;}
.cc-avatar{width:40px;height:40px;border-radius:50%;flex-shrink:0;object-fit:cover;background:#222;}
.cc-meta{flex:1;min-width:0;}
.cc-author{font-weight:600;font-size:13.5px;color:#fff;}
.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;}
.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-author{filter:blur(5px);}
/* 검토 화면 — 본문 600px 유지, 검토 영역만 와이드 브레이크아웃(댓글 카드 3~4열) */
#autoReview,#autoSummary,#ytccArea{--rw:min(1240px,calc(100vw - 40px));
width:var(--rw);margin-left:calc((100% - var(--rw))/2);}
/* ID 탭 바 — 스크롤해도 위에 붙어 있음 */
.idtabs{position:sticky;top:0;z-index:40;display:flex;gap:6px;flex-wrap:wrap;
padding:10px 0;background:var(--bg);margin-top:10px;}
.idtab{flex:1;min-width:120px;min-height:46px;background:var(--surf);border:1px solid var(--border);
border-radius:8px;color:var(--muted2);cursor:pointer;padding:7px 11px;text-align:left;
font-family:var(--sans);transition:.15s;}
.idtab:hover{border-color:#39424F;}
.idtab.active{background:var(--acc-soft);border-color:var(--accent);color:var(--text);}
.idtab.err{border-color:var(--danger);color:var(--danger);cursor:default;}
.idtab .tt{font-size:12.5px;font-weight:600;display:block;}
.idtab .ts{font-size:11px;font-family:var(--mono);display:block;margin-top:2px;color:var(--muted);}
.idtab.full .ts{color:var(--accent);}
/* ID 제외(✕) — .idtab 이 이미 <button> 이라 그 안에 버튼을 못 넣는다(중첩 button 금지).
래퍼로 감싸고 ✕ 를 형제로 두어 겹쳐 놓는다. 자리는 항상 확보(hover 시 레이아웃 안 흔들림) */
.idtabwrap{position:relative;flex:1;min-width:120px;display:flex;}
.idtabwrap .idtab{flex:1;min-width:0;padding-right:36px;}
.xdel{position:absolute;top:50%;right:5px;transform:translateY(-50%);
width:28px;height:28px;display:none;align-items:center;justify-content:center;
border:0;border-radius:7px;background:transparent;color:var(--muted);
font-size:13px;line-height:1;cursor:pointer;transition:background .15s,color .15s;}
.idtabwrap:hover .xdel,.idtab.active~.xdel,.xdel:focus-visible{display:flex;}
.xdel:hover{background:var(--danger);color:#fff;}
.idtab.dropped{opacity:.55;border-style:dashed;background:transparent;}
.idtab.dropped .tt{text-decoration:line-through;}
.idtab.dropped .ts{color:var(--danger);}
.idtab.dropped~.xdel{display:flex;color:var(--accent);font-size:15px;}
.idtab.dropped~.xdel:hover{background:var(--acc-soft);color:var(--accent);}
.hlbox{display:none;margin-top:4px;padding:16px;background:var(--card);border:1px solid var(--border);border-radius:10px;}
.hlbox.show{display:block;}
.hlbox h3{margin:0 0 6px;font-size:14.5px;}
.hlbox .hlmeta{color:var(--muted2);font-size:12.5px;margin-bottom:8px;}
.hlbox .hlsec{margin-top:12px;color:var(--muted2);font-size:12.5px;}
.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%;}
.hlbox .hlprog{margin-top:8px;font-family:var(--mono);font-size:12px;color:var(--muted2);white-space:pre-line;}
/* ── 영상 편집안(JSON 컷) — 기본 접힘, 클릭해 펼침 ── */
.cutsbox{margin-top:12px;border:1px solid var(--border);border-radius:9px;
background:var(--surf2);overflow:hidden;}
.cutsbox>summary{cursor:pointer;list-style:none;display:flex;align-items:center;gap:8px;
padding:11px 13px;min-height:44px;box-sizing:border-box;font-size:12.5px;font-weight:600;
color:var(--muted2);transition:background .15s,color .15s;}
.cutsbox>summary::-webkit-details-marker{display:none;}
.cutsbox>summary:hover{color:var(--text);background:var(--surf);}
.cutsbox[open]>summary{color:var(--text);border-bottom:1px solid var(--border);}
.cutsbox .chev{flex:none;color:var(--muted);font-size:10px;transition:transform .18s ease;}
.cutsbox[open] .chev{transform:rotate(90deg);}
.cutsbox .cprev{color:var(--muted);font-weight:400;overflow:hidden;text-overflow:ellipsis;
white-space:nowrap;min-width:0;}
.cutsbox[open] .cprev{display:none;}
.cutsbox .cnum{margin-left:auto;flex:none;font-family:var(--mono);font-size:11.5px;
color:var(--muted);font-weight:400;}
.cutlist{max-height:340px;overflow-y:auto;}
.cutrow{display:grid;grid-template-columns:24px 104px 1fr;gap:10px;align-items:start;
padding:9px 13px;border-top:1px solid var(--hr);font-size:12.5px;}
.cutrow:first-child{border-top:0;}
.cutrow .cno{font-family:var(--mono);font-size:11.5px;color:var(--muted);text-align:right;}
.cutrow .ctime{font-family:var(--mono);font-size:11.5px;color:var(--accent);
font-variant-numeric:tabular-nums;}
.cutrow .ctime .cdur{display:block;color:var(--muted);}
.cutrow .cbody{min-width:0;}
.cutrow .cbot{color:var(--text);white-space:pre-wrap;word-break:break-word;line-height:1.55;}
.cutrow .cbot.cempty{color:var(--muted);}
.cutrow .ceff{display:block;margin-top:3px;color:#33D17A;font-size:11.5px;word-break:break-word;}
.cutraw{border-top:1px solid var(--border);}
.cutraw>summary{cursor:pointer;list-style:none;padding:9px 13px;min-height:40px;
box-sizing:border-box;font-family:var(--mono);font-size:11.5px;color:var(--muted);
display:flex;align-items:center;transition:color .15s;}
.cutraw>summary::-webkit-details-marker{display:none;}
.cutraw>summary:hover{color:var(--text);}
.cutraw pre{margin:0;padding:11px 13px;background:#080A0E;font-family:var(--mono);font-size:11px;
line-height:1.55;color:var(--muted2);max-height:280px;overflow:auto;}
.cutcopy{margin:0 13px 11px;width:auto;min-height:34px;padding:6px 12px;}
/* 선택한 댓글 요약 바 */
.selbar{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin-top:10px;
padding:10px;background:#101010;border:1px solid var(--border);border-radius:8px;min-height:44px;}
.selbar .selbar-l{color:var(--muted2);font-size:12px;margin-right:4px;}
.selchip{display:inline-flex;align-items:center;gap:6px;background:var(--surf);border:1px solid var(--border);
border-radius:999px;padding:6px 6px 6px 11px;font-size:12px;color:var(--text);cursor:pointer;
max-width:230px;white-space:nowrap;}
.selchip:hover{border-color:var(--accent);}
.selchip .no{color:var(--accent);font-family:var(--mono);flex:none;}
.selchip .nm{overflow:hidden;text-overflow:ellipsis;}
.selchip .x{color:var(--muted2);padding:2px 6px;border-radius:999px;flex:none;font-size:13px;}
.selchip .x:hover{color:#fff;background:var(--danger);}
.selonly-label{display:inline-flex;align-items:center;gap:6px;color:var(--muted2);font-size:12px;
cursor:pointer;margin-left:auto;padding:6px;}
.hlbox.selonly .cardsec .ccwrap:not(.sel){display:none;}
/* 카드 그리드 */
.cardsec{display:flex;flex-wrap:wrap;gap:12px;margin-top:8px;}
.cardsec .ccwrap{margin:0;}
.cc-more{margin-top:10px;min-height:40px;}
.hlbox.selonly .cc-more{display:none;}
/* ⚙ 지침·모델 수정 — 버튼처럼 잘 보이게 */
#autoSettings summary{cursor:pointer;list-style:none;background:var(--surf);border:1px solid var(--border);
border-radius:8px;padding:12px 14px;font-size:13px;font-weight:600;color:var(--text);
display:flex;align-items:center;gap:8px;transition:.15s;min-height:44px;box-sizing:border-box;}
#autoSettings summary::-webkit-details-marker{display:none;}
#autoSettings summary:hover{border-color:var(--accent);}
#autoSettings[open] summary{background:var(--acc-soft);border-color:var(--accent);}
#autoSettings summary .smhint{margin-left:auto;color:var(--muted);font-weight:400;
font-family:var(--mono);font-size:11.5px;}
/* 빌드 중 댓글 영역 접기 — display:none 이면 카드 캡처가 안 되므로 화면 밖으로만 밀어냄 */
#autoReview.folded .cardsec{position:absolute;left:-100000px;top:0;}
#autoReview.folded .cc-more{display:none;}
.ccfoldbtn{flex:none !important;min-width:120px;}
/* 자동 탭 방식 선택 */
.modeopt{position:relative;flex:1;min-width:150px;display:flex;flex-direction:column;gap:3px;align-items:flex-start;
background:var(--surf);border:1px solid var(--border);border-radius:9px;padding:10px 12px;min-height:52px;
font-size:13px;color:var(--muted2);cursor:pointer;transition:.15s;}
.modeopt:hover{border-color:#39424F;color:var(--text);}
.modeopt:has(input:checked){background:var(--acc-soft);border-color:var(--accent);color:var(--text);font-weight:600;}
/* 라디오는 숨기고(카드가 곧 선택 표시) 키보드 포커스 링은 카드에 */
.modeopt input{position:absolute;opacity:0;pointer-events:none;}
.modeopt:has(input:focus-visible){outline:2px solid var(--accent);outline-offset:2px;}
.modeopt .modetitle{display:block;}
.modeopt .modedesc{display:block;color:var(--muted);font-size:11px;font-family:var(--mono);font-weight:400;}
/* 통짜 모드 제목 입력 */
.hlinput{background:var(--surf2);border:1px solid var(--border);border-radius:7px;
color:var(--text);font-size:12.5px;padding:8px 10px;width:200px;max-width:45%;}
/* 빌드 진행판 — 5개 전부 만들기 아래, ID별 상태 한눈에 */
#buildBoard{display:none;margin-top:10px;}
.bbrow{display:flex;gap:10px;align-items:baseline;padding:9px 12px;border:1px solid var(--border);
border-radius:8px;background:var(--card);margin-top:6px;font-size:12.5px;}
.bbrow.active{border-color:var(--accent);}
.bbrow.err{border-color:var(--danger);}
.bbid{font-weight:600;flex:none;min-width:88px;}
.bbstat{flex:none;font-family:var(--mono);font-size:12px;color:var(--muted2);min-width:64px;}
.bbrow.active .bbstat{color:var(--accent);}
.bbrow.err .bbstat{color:var(--danger);}
.bbmsg{font-family:var(--mono);font-size:12px;color:var(--muted2);flex:1;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="brand" style="justify-content:space-between;flex-wrap:wrap;gap:10px;">
<div style="display:flex;align-items:center;gap:9px;"><span class="dot"></span><h1>캡컷 에이전트 · 구간합치기</h1></div>
<div class="hdrlinks">
<a class="hlink" href="https://aistudio.google.com/" target="_blank" rel="noopener">✨ AI Studio</a>
<a class="hlink" href="https://h-lab.tolag.shop/comment-cards" target="_blank" rel="noopener">💬 댓글 카드</a>
</div>
</div>
<div class="sub">한 URL의 여러 구간 → 이어붙여 무음컷 · 자막 · 배경템플릿 → CapCut</div>
<!-- 시그니처: 타임라인 스트립 — 원본에서 하이라이트(틸)만 골라내는 이 도구의 본질 -->
<div class="tl" aria-hidden="true">
<span class="tlc" style="width:9%"></span><span class="tlc on" style="width:7%"></span><span
class="tlc" style="width:14%"></span><span class="tlc" style="width:5%"></span><span
class="tlc on" style="width:8%"></span><span class="tlc" style="width:11%"></span><span
class="tlc" style="width:6%"></span><span class="tlc on" style="width:9%"></span><span
class="tlc" style="width:13%"></span><span class="tlc on" style="width:6%"></span><span
class="tlc" style="width:12%"></span>
<i class="tlph"></i>
</div>
</header>
<div class="tabs">
<button class="tab active" id="tab-file">📁 파일</button>
<button class="tab" id="tab-yt">▶ 유튜브 구간</button>
<button class="tab" id="tab-paste">📋 붙여넣기</button>
<button class="tab" id="tab-auto">🤖 자동</button>
</div>
<!-- 파일 모드 -->
<div id="panel-file">
<div class="helprow"><button type="button" class="helpbtn" data-help="file">? 사용법</button></div>
<div id="drop">
<div class="big">영상을 여기에 드롭하거나 클릭</div>
<div class="hint">mp4 · mov · mkv · webm</div>
<input type="file" id="file" accept="video/*,.mkv,.webm,.mov,.mp4" />
</div>
<div class="filerow" id="filerow" style="display:none;"><span class="chip" id="filechip"></span></div>
</div>
<!-- 유튜브 모드 -->
<div id="panel-yt" style="display:none;">
<div class="helprow"><button type="button" class="helpbtn" data-help="yt">? 사용법</button></div>
<div class="card" style="padding:18px;">
<div class="field"><label>유튜브 URL</label><input id="yurl" placeholder="https://www.youtube.com/watch?v=…" /></div>
<div id="ranges" style="margin-top:12px;">
<div class="row2 rng" style="align-items:end;">
<div class="field mono"><label>구간 1 시작</label><input class="rstart" placeholder="03:30" /></div>
<div class="field mono" style="display:flex;gap:6px;align-items:end;">
<div style="flex:1;"><label></label><input class="rend" placeholder="06:20" style="width:100%;" /></div>
<button type="button" class="rngdel" title="이 구간 삭제"
style="height:38px;padding:0 10px;background:var(--surf);color:var(--muted2);border:1px solid var(--border);border-radius:8px;cursor:pointer;">✕</button>
</div>
</div>
</div>
<button type="button" id="addrng"
style="margin-top:8px;width:100%;padding:9px;background:transparent;color:var(--accent);border:1px dashed var(--accent);border-radius:8px;cursor:pointer;font-size:13px;"> 구간 추가</button>
<div class="note" style="margin-top:8px;">여러 구간을 넣으면 순서대로 이어붙여 하나의 캡컷 드래프트로 만듭니다. 형식: 분:초(03:30) 또는 시:분:초(01:03:30)</div>
<button type="button" class="ghost" id="ytccBtn" style="margin-top:12px;">💬 구간 댓글 매칭 (h-lab · 선택사항)</button>
<div class="note" id="ytccNote" style="margin-top:4px;">구간을 언급한 댓글을 자동 선택하고, 나머지는 좋아요순으로 보여줍니다. 선택한 카드는 편집 시작 때 자동으로 들어갑니다(폴더 지정보다 우선).</div>
</div>
<div id="ytccArea"></div>
</div>
<!-- 붙여넣기(JSON) 모드 -->
<div id="panel-paste" style="display:none;">
<div class="helprow"><button type="button" class="helpbtn" data-help="paste">? 사용법</button></div>
<div class="card" style="padding:18px;">
<div class="field">
<label>편집안 JSON 붙여넣기</label>
<textarea id="pjson" spellcheck="false" rows="12"
style="width:100%;box-sizing:border-box;background:var(--surf2);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:11px;font-family:var(--mono);font-size:12.5px;line-height:1.5;resize:vertical;"
placeholder='{&#10; "url": "https://www.youtube.com/watch?v=…",&#10; "title_top": "서브제목", "title_main": "메인제목", "channel": "@채널",&#10; "cuts": [&#10; {"start":"0:01.0","end":"0:03.5","bottom":"하단 자막\n두 줄","effect":"광속하강"},&#10; {"start":"2:33.5","end":"2:36.5","bottom":"다음 컷 자막","effect":"공포의통계"}&#10; ]&#10;}'></textarea>
</div>
<div class="note" style="margin-top:8px;">한 URL의 여러 컷 + 자막을 그대로 사용합니다(무음컷·받아쓰기 없음). 컷은 순서대로 이어붙고, 배치 시간은 자동 계산돼요. <b>bottom</b>=하단자막, <b>effect</b>=중앙 효과자막(하단 바로 위).</div>
</div>
</div>
<!-- 자동(Gemini) 모드 -->
<div id="panel-auto" style="display:none;">
<div class="card" style="padding:18px;">
<div class="field">
<label>방식</label>
<div style="display:flex;gap:6px;flex-wrap:wrap;">
<label class="modeopt"><input type="radio" name="amode" value="full" checked>
<span class="modetitle">🤖 AI 컷편집</span><span class="modedesc">Step1+3 · 45~60초 숏폼</span></label>
<label class="modeopt"><input type="radio" name="amode" value="whole">
<span class="modetitle">⏩ 구간 통짜</span><span class="modedesc">Step1만 · 구간 통으로</span></label>
<label class="modeopt"><input type="radio" name="amode" value="wpaste">
<span class="modetitle">📐 구간 JSON</span><span class="modedesc">구간 5개 붙여넣기 · 통짜</span></label>
<label class="modeopt"><input type="radio" name="amode" value="paste">
<span class="modetitle">📋 오팔 JSON</span><span class="modedesc">Gemini 안 씀 · 붙여넣기</span></label>
</div>
</div>
<div class="field">
<label>유튜브 URL <span id="autoUrlNote" style="color:var(--muted);font-weight:400;"></span></label>
<input id="autoUrl" placeholder="https://www.youtube.com/watch?v=…" />
</div>
<div class="field" id="apasteField" style="display:none;">
<label id="apasteLabel">오팔 JSON 붙여넣기 (여러 개를 통째로 — 사이에 구분선·타이틀 후보가 섞여 있어도 됨)</label>
<textarea id="apaste" rows="8" spellcheck="false"
style="width:100%;box-sizing:border-box;background:var(--surf2);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:11px;font-family:var(--mono);font-size:12px;line-height:1.5;resize:vertical;"
placeholder='오팔 Step 3 결과(JSON 코드블록) 5개를 순서대로 전부 붙여넣으세요.&#10;블록 ②·③ 텍스트가 섞여 들어와도 자동으로 JSON만 골라냅니다.'></textarea>
</div>
<button class="run" id="autoGo" style="margin-top:8px;">분석 시작 (하이라이트 5개)</button>
<div class="note" id="autoModeHelp" style="margin-top:8px;">
Gemini가 영상에서 하이라이트 구간 5개를 골라 편집안을 만들고, h-lab에서
그 구간을 언급한 댓글을 찾아옵니다.
</div>
</div>
<details id="autoSettings" style="margin-top:12px;">
<summary>⚙ 지침·모델 수정 <span class="smhint">프롬프트 · Gemini 모델 · fps ▾</span></summary>
<div class="card" style="padding:16px;margin-top:10px;">
<div class="field"><label>Step 1 — 하이라이트 선정 프롬프트</label>
<textarea id="pStep1" rows="8" spellcheck="false"
style="width:100%;box-sizing:border-box;background:var(--surf2);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:11px;font-family:var(--mono);font-size:12px;line-height:1.5;resize:vertical;"></textarea>
</div>
<div class="field"><label>Step 3 — 편집 지침서 (숏폼_편집_지침서 파일 그대로)</label>
<textarea id="pStep3" rows="10" spellcheck="false"
style="width:100%;box-sizing:border-box;background:var(--surf2);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:11px;font-family:var(--mono);font-size:12px;line-height:1.5;resize:vertical;"></textarea>
</div>
<div class="field"><label>모델·샘플링 설정 (JSON)</label>
<textarea id="pConfig" rows="5" spellcheck="false"
style="width:100%;box-sizing:border-box;background:var(--surf2);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:11px;font-family:var(--mono);font-size:12px;line-height:1.5;resize:vertical;"></textarea>
<div class="note" style="margin-top:2px;">
model=기본 모델 · model_step1=Step1 전용(비우면 model) ·
fps_step1/fps_step3=영상 샘플링(낮을수록 토큰 절약, 긴 영상은 0.2 권장)
</div>
</div>
<button class="ghost" id="pSave">저장</button>
<button class="ghost" id="pReset" title="Step1 프롬프트·설정만 초기화 (지침서 파일은 그대로)">기본값 복원</button>
<span class="note" id="pMsg"></span>
</div>
</details>
<div id="autoSteps"></div>
<div id="autoLog"></div>
<div id="autoReview"></div>
<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;">
<input type="checkbox" id="autoCardsFixed" checked style="accent-color:var(--accent);width:15px;height:15px;"> 댓글 카드 3초 고정 (모자라도 늘리지 않고 뒤는 비움 — 부분삭제 편집용)
</label>
<div id="buildBoard"></div>
<div id="autoSummary" class="card" style="display:none;padding:18px;margin-top:12px;"></div>
</div>
<!-- 공통: 제목/출처 + 영상 옵션 -->
<div class="fields" id="commonFields">
<div id="titleGroup">
<div class="row2">
<div class="field"><label>제목 윗줄 · 주황색</label><input id="ttop" placeholder="서브제목" /></div>
<div class="field"><label>출처 (선택)</label><input id="chan" placeholder="유튜브면 채널명 자동" /></div>
</div>
<div class="field"><label>제목 아랫줄 · 흰색</label><input id="tmain" placeholder="메인제목" /></div>
<div class="note" style="margin-top:2px;">비워두면 그 텍스트는 안 들어가요. 붙여넣기 모드는 제목이 JSON에 들어갑니다.</div>
</div>
<div class="field">
<label>영상 확대 <span id="vscaleval" style="color:var(--accent);font-family:var(--mono);">144%</span></label>
<input id="vscale" type="range" value="144" min="50" max="300" step="5" style="width:100%;accent-color:var(--accent);cursor:pointer;" />
</div>
<div class="field" id="cdirField">
<label>댓글 카드 폴더 (선택)</label>
<input id="cdir" value="__CDIR__" placeholder="비우면 댓글 카드 안 넣음" />
<div class="note" style="margin-top:2px;">폴더 안 이미지를 <b>저장한 순서</b>대로 3초마다 하단에 삽입. (파일명이 1,2,3…이면 그 숫자순)</div>
</div>
<div class="field">
<label style="display:flex;align-items:center;gap:7px;cursor:pointer;">
<input type="checkbox" id="flip" style="accent-color:var(--accent);width:15px;height:15px;"> 영상 좌우반전 (미러)
</label>
</div>
<div class="field">
<label style="display:flex;align-items:center;gap:7px;cursor:pointer;">
<input type="checkbox" id="scene" checked style="accent-color:var(--accent);width:15px;height:15px;"> 장면분할 (컷 바뀌는 지점 자동 분할)
</label>
</div>
<div class="field">
<label style="display:flex;align-items:center;gap:7px;cursor:pointer;">
<input type="checkbox" id="bgwhite" checked style="accent-color:var(--accent);width:15px;height:15px;"> 배경 흰색 (위아래 띠·빈 곳을 흰색으로)
</label>
</div>
<div class="field">
<label style="display:flex;align-items:center;gap:7px;cursor:pointer;">
<input type="checkbox" id="rmsilence" style="accent-color:var(--accent);width:15px;height:15px;"> 무음 제거 (붙여넣기: 컷 안의 무음까지 잘라냄)
</label>
</div>
<div class="field">
<label style="display:flex;align-items:center;gap:7px;cursor:pointer;">
<input type="checkbox" id="asrbottom" checked style="accent-color:var(--accent);width:15px;height:15px;"> 하단 자막 자동 생성 (Whisper)
</label>
<div class="note" style="margin-top:2px;">붙여넣기: JSON의 bottom 대신, 음성을 인식해 말하는 타이밍에 맞춘 자막을 생성합니다.</div>
</div>
</div>
<button class="run" id="run">편집 시작</button>
<label style="display:flex;align-items:center;gap:7px;margin-top:10px;color:var(--muted2);font-size:12.5px;cursor:pointer;">
<input type="checkbox" id="autoopen" checked style="accent-color:var(--accent);width:15px;height:15px;"> 완료되면 CapCut 자동 실행
</label>
<div id="log"></div>
<div id="steps"></div>
<div id="result" class="card" style="padding:18px;"></div>
<!-- 레이어 수리 -->
<details id="repairBox" style="margin-top:18px;">
<summary style="cursor:pointer;color:var(--muted2);font-size:12.5px;list-style:none;">
🩹 레이어 수리 — 영상이 흰 띠·댓글 위로 삐져나올 때
</summary>
<div class="card" style="padding:16px;margin-top:10px;">
<div class="note" style="margin-top:0;line-height:1.75;">
캡컷에서 클립을 <b style="color:var(--muted2);">복붙하거나 옮기면</b> 캡컷이 그 클립의
렌더순서를 맨 위로 새로 매겨서, 확대했을 때 템플릿(흰 띠·댓글) 위로 삐져나옵니다.<br>
<b style="color:var(--muted2);">캡컷을 완전히 종료한 뒤</b> 누르세요
(실행 중이면 거부합니다 — 캡컷이 저장하면서 되돌리기 때문). 원본은
<code>draft_content.repair.bak</code>으로 백업됩니다.
</div>
<div class="field" style="margin-top:12px;">
<label>드래프트</label>
<select id="rsel" style="width:100%;background:var(--surf2);border:1px solid var(--border);
border-radius:7px;color:var(--text);font-family:var(--mono);font-size:12.5px;padding:9px 11px;outline:none;">
</select>
</div>
<button class="ghost" id="rrun" style="margin-top:10px;">레이어 수리 실행</button>
<div class="note" id="rmsg"></div>
</div>
</details>
</div>
<!-- 사용법 모달 -->
<div id="helpOverlay">
<div id="helpModal">
<button id="helpClose" title="닫기"></button>
<div id="helpBody"></div>
</div>
</div>
<script>
const $=(s)=>document.querySelector(s);
const drop=$("#drop"),fileInput=$("#file"),filerow=$("#filerow"),filechip=$("#filechip"),runBtn=$("#run");
const logEl=$("#log"),stepsEl=$("#steps"),resultEl=$("#result");
let picked=null,mode="file";
// 탭
function setMode(m){
mode=m;
$("#tab-file").classList.toggle("active",m==="file");
$("#tab-yt").classList.toggle("active",m==="yt");
$("#tab-paste").classList.toggle("active",m==="paste");
$("#tab-auto").classList.toggle("active",m==="auto");
$("#panel-file").style.display=m==="file"?"block":"none";
$("#panel-yt").style.display=m==="yt"?"block":"none";
$("#panel-paste").style.display=m==="paste"?"block":"none";
$("#panel-auto").style.display=m==="auto"?"block":"none";
// 붙여넣기·자동: 제목은 JSON에 있으니 숨김. 자동: 카드 폴더도 자동이라 숨김.
$("#commonFields").style.display="grid";
$("#titleGroup").style.display=(m==="paste"||m==="auto")?"none":"block";
$("#cdirField").style.display=m==="auto"?"none":"block";
$("#run").style.display=m==="auto"?"none":"block"; // 자동 탭은 자체 버튼 사용
}
$("#tab-file").addEventListener("click",()=>setMode("file"));
$("#tab-yt").addEventListener("click",()=>setMode("yt"));
$("#tab-paste").addEventListener("click",()=>setMode("paste"));
$("#tab-auto").addEventListener("click",()=>setMode("auto"));
// ── 사용법 모달 ──
const HELP={
file:`<h3>📁 파일 탭 사용법</h3>
<ol>
<li><b>영상 파일 드롭</b>(mp4·mov·mkv·webm) 또는 클릭해 선택</li>
<li>제목·출처는 <b>선택</b> — 비우면 캡컷에서 직접 추가</li>
<li><b>편집 시작</b> → 무음 자동 컷 + Whisper 자막 → CapCut 드래프트 생성</li>
</ol>
<ul>
<li><b>자막</b>: 말하는 타이밍에 맞춰 자동 생성(Gemini 키 있으면 글자 교정)</li>
<li><b>댓글 카드</b>: 폴더에 이미지 넣고 경로 지정 → 하단에 3초마다 1장(저장 순서, 파일명 1·2·3…이면 숫자순)</li>
<li>확대(기본 144%)·좌우반전·장면분할·배경 흰색은 옵션에서</li>
</ul>
<div class="helpdim">완료 후 CapCut에서 드래프트를 열어 확인하세요.</div>`,
yt:`<h3>▶ 유튜브 구간 탭 사용법</h3>
<ol>
<li><b>유튜브 URL</b> 입력 (실제 열리는 주소)</li>
<li><b>구간 입력</b> — 시간형식 <code>분:초</code> 또는 <code>시:분:초</code>. 숫자만 쳐도 자동 변환(4314→43:14), 끝 비우면 시작+1분30초</li>
<li><b> 구간 추가</b>로 여러 구간 → 순서대로 이어붙여 하나의 드래프트</li>
<li><b>편집 시작</b> → 다운로드·병합 → 무음 컷 + Whisper 자막 → CapCut</li>
</ol>
<ul>
<li><b>출처</b>: 비우면 유튜브 채널명 자동(@채널)</li>
<li><b>댓글 카드</b>: 폴더 경로 지정 시 하단에 3초마다 1장 자동 삽입</li>
</ul>
<div class="helpdim">긴 구간일수록 다운로드·자막(1분당 ≈30초) 시간이 늘어납니다.</div>`,
paste:`<h3>📋 붙여넣기 탭 사용법</h3>
<ol>
<li>LLM(AI Studio 등)에게 아래 형식의 <b>JSON 하나</b>를 받아 붙여넣기</li>
<li><b>편집 시작</b> → 컷 정밀 다운로드·병합 → 자막 → CapCut</li>
</ol>
<pre>{
"url": "https://www.youtube.com/watch?v=실제ID",
"title_top": "서브제목", "title_main": "메인제목",
"channel": "@채널",
"cuts": [
{"start":"0:01.0","end":"0:03.5",
"bottom":"자막 윗줄\\n아랫줄","effect":"(효과자막)"}
]
}</pre>
<ul>
<li><b>url은 실제 영상 주소</b> — LLM이 지어낸 ID는 실패</li>
<li>배치 시간은 넣지 마세요 — 컷 순서대로 자동 계산</li>
<li><b>하단 자막 자동 생성(Whisper) 켜짐</b>: bottom 무시, 실제 말 타이밍 자막 생성(추천)</li>
<li>끄면: bottom 사용, \\n 은 시간 반씩 나눠 윗줄→아랫줄</li>
<li><b>effect</b>: 중앙 녹색 효과자막 · <b>댓글 카드</b>: 폴더 지정 시 3초마다 1장</li>
<li><b>무음 제거</b> 켜면 컷 안의 무음까지 컷(자막 자동 보정)</li>
</ul>
<div class="helpdim">헤더의 ✨ AI Studio에서 JSON 생성, 💬 댓글 카드에서 카드 저장.</div>`};
const overlay=$("#helpOverlay");
document.querySelectorAll(".helpbtn").forEach(b=>b.addEventListener("click",()=>{
$("#helpBody").innerHTML=HELP[b.dataset.help]||"";
overlay.style.display="flex";
}));
$("#helpClose").addEventListener("click",()=>overlay.style.display="none");
overlay.addEventListener("click",e=>{if(e.target===overlay)overlay.style.display="none";});
document.addEventListener("keydown",e=>{if(e.key==="Escape")overlay.style.display="none";});
setMode("paste"); // 붙여넣기 탭 기본 활성화
// 시간 입력 자동 변환(4314→43:14, 0302→03:02) + 시작 입력시 끝=시작+1:30
function fmtTime(v){
const d=(v||"").replace(/\D/g,"");
if(!d) return "";
const sec=d.slice(-2).padStart(2,"0");
const min=d.slice(-4,-2);
const hour=d.slice(0,-4);
if(hour) return `${hour}:${(min||"0").padStart(2,"0")}:${sec}`;
if(min) return `${min}:${sec}`;
return `0:${sec}`;
}
function toSec(t){
const p=(t||"").split(":").map(Number);
if(p.some(isNaN)||!p.length) return NaN;
return p.length===3? p[0]*3600+p[1]*60+p[2] : p.length===2? p[0]*60+p[1] : p[0];
}
function fmtSec(s){
s=Math.max(0,Math.round(s));
const h=Math.floor(s/3600), m=Math.floor(s%3600/60), ss=String(s%60).padStart(2,"0");
return h? `${h}:${String(m).padStart(2,"0")}:${ss}` : `${m}:${ss}`;
}
// 여러 구간: 자동 시간변환(focusout 위임) + 시작 입력시 끝=시작+1:30
const rangesEl=$("#ranges");
if(rangesEl){
rangesEl.addEventListener("focusout",ev=>{
const el=ev.target;
if(el.classList.contains("rstart")){
if(!el.value.trim())return;
el.value=fmtTime(el.value);
const row=el.closest(".rng"), end=row.querySelector(".rend");
const s=toSec(el.value);
if(end&&!end.value.trim()&&!isNaN(s)) end.value=fmtSec(s+90);
}else if(el.classList.contains("rend")){
if(el.value.trim()) el.value=fmtTime(el.value);
}
});
// 구간 삭제(위임) — 최소 1개는 남김
rangesEl.addEventListener("click",ev=>{
if(!ev.target.classList.contains("rngdel"))return;
if(rangesEl.querySelectorAll(".rng").length<=1)return;
ev.target.closest(".rng").remove();
renumberRanges();
});
}
function renumberRanges(){
rangesEl.querySelectorAll(".rng").forEach((row,i)=>{
const lb=row.querySelector(".rstart")?.closest(".field")?.querySelector("label");
if(lb) lb.textContent=`구간 ${i+1} 시작`;
});
}
const addRngBtn=$("#addrng");
if(addRngBtn){
addRngBtn.addEventListener("click",()=>{
const row=rangesEl.querySelector(".rng").cloneNode(true);
row.querySelectorAll("input").forEach(i=>i.value="");
rangesEl.appendChild(row);
renumberRanges();
row.querySelector(".rstart")?.focus();
});
}
// 영상 확대 슬라이더 → % 표시
const vs=$("#vscale"); if(vs){ vs.addEventListener("input",()=>{$("#vscaleval").textContent=vs.value+"%";}); }
drop.addEventListener("click",()=>fileInput.click());
fileInput.addEventListener("change",()=>{if(fileInput.files[0])setFile(fileInput.files[0]);});
["dragenter","dragover"].forEach(e=>drop.addEventListener(e,ev=>{ev.preventDefault();drop.classList.add("drag");}));
["dragleave","drop"].forEach(e=>drop.addEventListener(e,ev=>{ev.preventDefault();drop.classList.remove("drag");}));
drop.addEventListener("drop",ev=>{const f=ev.dataTransfer.files[0];if(f)setFile(f);});
function fmtSize(b){if(b>1e9)return(b/1e9).toFixed(1)+" GB";if(b>1e6)return(b/1e6).toFixed(1)+" MB";return(b/1e3).toFixed(0)+" KB";}
function setFile(f){picked=f;filechip.innerHTML=`<span class="k"></span> ${f.name} <span class="k tnum">· ${fmtSize(f.size)}</span>`;filerow.style.display="block";}
function addLog(m){logEl.style.display="block";const d=document.createElement("div");d.className="line";d.textContent=m;logEl.appendChild(d);}
function titleFields(fd){fd.append("title_top",$("#ttop").value);fd.append("title_main",$("#tmain").value);fd.append("channel",$("#chan").value);fd.append("video_scale",$("#vscale").value||"144");fd.append("flip",$("#flip").checked?"1":"0");fd.append("scene",$("#scene").checked?"1":"0");fd.append("comments_dir",$("#cdir").value.trim());fd.append("bg_white",$("#bgwhite").checked?"1":"0");}
runBtn.addEventListener("click",async()=>{
window._runStart=Date.now(); // 시작~끝 총 시간 측정
logEl.innerHTML="";resultEl.style.display="none";resultEl.innerHTML="";
stepsEl.style.display="none";stepsEl.innerHTML="";
let res;
if(mode==="file"){
if(!picked){addLog("파일을 먼저 선택하세요.");return;}
runBtn.disabled=true;runBtn.textContent="업로드 중…";
const fd=new FormData();fd.append("file",picked);titleFields(fd);
try{res=await(await fetch("/upload",{method:"POST",body:fd})).json();}
catch(e){return fail("업로드 실패: "+e);}
}else if(mode==="yt"){
const url=$("#yurl").value.trim();
if(!url){addLog("유튜브 URL을 입력하세요.");return;}
// 모든 구간 수집(빈 행 제외). 끝 비면 시작+1:30
const ranges=[];
rangesEl.querySelectorAll(".rng").forEach(row=>{
const si=row.querySelector(".rstart"), ei=row.querySelector(".rend");
let st=fmtTime(si.value);
if(!st) return;
let en=ei.value.trim()?fmtTime(ei.value):fmtSec(toSec(st)+90);
si.value=st; ei.value=en;
ranges.push([st,en]);
});
if(!ranges.length){addLog("구간을 하나 이상 입력하세요.");return;}
runBtn.disabled=true;runBtn.textContent="처리 중…";
const fd=new FormData();
fd.append("url",url);fd.append("ranges",JSON.stringify(ranges));titleFields(fd);
const yf=document.querySelector("#ytccFixed");
if(yf) fd.append("cards_fixed",yf.checked?"1":"0");
// 댓글 매칭에서 카드를 골랐으면 캡처해서 함께 전송(폴더 지정보다 우선)
if(window.ytCC&&window.ytCC.active()){
runBtn.textContent="댓글 카드 캡처 중…";
const blobs=await window.ytCC.capture();
blobs.forEach((b,i)=>fd.append("cards",b,String(i+1).padStart(3,"0")+".png"));
runBtn.textContent="처리 중…";
}
try{res=await(await fetch("/youtube",{method:"POST",body:fd})).json();}
catch(e){return fail("요청 실패: "+e);}
if(res&&res.error){return fail(res.error);}
}
if(mode==="paste"){
const txt=$("#pjson").value.trim();
if(!txt){addLog("편집안 JSON을 붙여넣으세요.");return;}
runBtn.disabled=true;runBtn.textContent="처리 중…";
const fd=new FormData();fd.append("data",txt);
fd.append("video_scale",$("#vscale").value||"144");
fd.append("flip",$("#flip").checked?"1":"0");
fd.append("scene",$("#scene").checked?"1":"0");
fd.append("comments_dir",$("#cdir").value.trim());
fd.append("bg_white",$("#bgwhite").checked?"1":"0");
fd.append("remove_silence",$("#rmsilence").checked?"1":"0");
fd.append("asr_bottom",$("#asrbottom").checked?"1":"0");
try{res=await(await fetch("/paste",{method:"POST",body:fd})).json();}
catch(e){return fail("요청 실패: "+e);}
if(res&&res.error){return fail(res.error);}
}
runBtn.textContent="처리 중…";
runStream(res.job_id);
});
function runStream(id){
const es=new EventSource(`/stream/${id}`);
es.onmessage=(m)=>{
const ev=JSON.parse(m.data);
if(ev.type==="manifest")renderSteps(ev.steps);
else if(ev.type==="log")addLog(ev.msg);
else if(ev.type==="step")updateStep(ev);
else if(ev.type==="result"){es.close();renderResult(ev);finish();}
else if(ev.type==="error"){es.close();stepError(ev.message);finish();}
};
es.onerror=()=>{es.close();if(!resultEl.innerHTML)stepError("연결이 끊겼습니다.");finish();};
}
function finish(){runBtn.disabled=false;runBtn.textContent="다시 실행";}
function fail(m){stepError(m);finish();}
function renderSteps(steps){
stepsEl.style.display="block";
stepsEl.innerHTML=steps.map(s=>`<div class="step" id="st-${s.id}"><span class="sdot"></span>
<div class="slabel"><div class="t">${s.label}</div><div class="sdetail" hidden></div></div>
<div class="selapsed"></div></div>`).join("");
}
function updateStep(ev){
const el=$(`#st-${ev.id}`);if(!el)return;
if(ev.status==="start"){document.querySelectorAll(".step.active").forEach(s=>s.classList.remove("active"));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;}
if(ev.elapsed!=null)el.querySelector(".selapsed").textContent=ev.elapsed.toFixed(1)+"s";}
}
function stepError(msg){
const a=document.querySelector(".step.active")||document.querySelector(".step:not(.done)");
if(a){a.classList.remove("active");a.classList.add("err");const d=a.querySelector(".sdetail");d.hidden=false;d.textContent=msg;}
else{stepsEl.style.display="block";stepsEl.innerHTML=`<div class="step err"><span class="sdot"></span><div class="slabel"><div class="t">오류</div><div class="sdetail">${msg}</div></div></div>`;}
}
function renderResult(ev){
const s=ev.stats;
const total=window._runStart?((Date.now()-window._runStart)/1000).toFixed(1):s.elapsed;
resultEl.style.display="block";
resultEl.innerHTML=`
<div style="font-family:var(--mono);font-size:13.5px;color:var(--accent);margin-bottom:14px;">⏱ 총 ${total}초 소요</div>
<div class="grid3">
<div class="stat"><div class="v tnum">${s.duration}s</div><div class="l">원본</div></div>
<div class="stat"><div class="v green tnum">${s.cut}s</div><div class="l">무음 컷</div></div>
<div class="stat"><div class="v tnum">${s.captions}</div><div class="l">자막</div></div>
</div>
<hr class="sep" />
<div class="reslabel">드래프트 · ${s.elapsed}s</div>
<div><span class="chip" title="${ev.draft_path}">${ev.draft_name}</span></div>
<button class="capcut" id="capcut">▶ CapCut 실행</button>
<button class="ghost" id="copyp">드래프트 경로 복사</button>
<div class="opennote">CapCut 실행 후 프로젝트 목록에서 <b>${ev.draft_name}</b> 열기<br>
하단 검은 영역에 댓글 캡쳐를 직접 넣으세요</div>`;
const openCapcut=async()=>{
$("#capcut").textContent="실행 중…";
try{const r=await(await fetch("/open-capcut",{method:"POST"})).json();
$("#capcut").textContent=r.ok?"✓ CapCut 열림":"실행 실패 — 직접 열어주세요";}
catch(e){$("#capcut").textContent="실행 실패 — 직접 열어주세요";}
};
$("#capcut").addEventListener("click",openCapcut);
// 완료 시 자동 실행(체크박스 켜짐일 때)
if($("#autoopen")&&$("#autoopen").checked) setTimeout(openCapcut,500);
$("#copyp").addEventListener("click",()=>{navigator.clipboard.writeText(ev.draft_path);
$("#copyp").textContent="복사됨";setTimeout(()=>$("#copyp").textContent="드래프트 경로 복사",1200);});
}
// ── 레이어 수리 ──
async function loadDrafts(){
const sel=$("#rsel"); if(!sel) return;
try{
const r=await(await fetch("/drafts")).json();
// 캡컷에서 이름을 바꾸면 폴더명(=수리 키)과 화면 이름이 갈린다 → 화면 이름으로 찾게 한다
const esc=s=>String(s).replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
sel.innerHTML=(r.drafts||[]).map(d=>{
const t=d.title||d.name;
// value 는 폴더 경로 — 드래프트가 기본 경로 밖(D: 등)에도 있어 폴더명만으론 특정 불가
return `<option value="${esc(d.path||d.name)}">${d.broken?"⚠ ":"✓ "}${esc(t)}`
+`${t!==d.name?` [폴더: ${esc(d.name)}]`:""}`
+`${d.broken?` (꼬임 ${d.bad}개)`:""}</option>`;
}).join("")||`<option value="">드래프트 없음</option>`;
}catch(e){ sel.innerHTML=`<option value="">목록 불러오기 실패</option>`; }
}
if($("#repairBox")){
$("#repairBox").addEventListener("toggle",()=>{ if($("#repairBox").open) loadDrafts(); });
$("#rrun").addEventListener("click",async()=>{
const name=$("#rsel").value; if(!name) return;
$("#rrun").disabled=true; $("#rrun").textContent="수리 중…"; $("#rmsg").textContent="";
try{
const fd=new FormData(); fd.append("draft",name);
const r=await(await fetch("/repair",{method:"POST",body:fd})).json();
$("#rmsg").textContent=r.ok
? (r.fixed?`✓ ${r.fixed}개 세그먼트 원위치 (${Object.entries(r.detail).map(([k,v])=>k+" "+v).join(", ")}) — 파일 ${r.files}개 수정, 캡컷에서 다시 열어보세요`
:"✓ 꼬인 곳 없음 (잠금만 다시 채움)")
: `✗ ${r.error}`;
$("#rmsg").style.color = r.ok ? "" : (r.capcut_running ? "var(--accent)" : "var(--danger)");
$("#rmsg").style.whiteSpace = "pre-line";
loadDrafts();
}catch(e){ $("#rmsg").textContent="✗ 요청 실패"; }
$("#rrun").disabled=false; $("#rrun").textContent="레이어 수리 실행";
});
}
</script>
<script src="/static/modern-screenshot.js?v=__V__"></script>
<script src="/static/auto.js?v=__V__"></script>
</body>
</html>

File diff suppressed because one or more lines are too long

219
레이어_삐짐_수리.md Normal file
View File

@ -0,0 +1,219 @@
# 영상이 템플릿 밖으로 삐져나오는 문제 — 원인·조치 기록
작성 2026-07-27. 관련 코드: `capcut_agent/draft.py`, `server/app.py`, `server/static/index.html`.
배경 지식은 `ARCHITECTURE.md` §5(드래프트 빌더) / §10(하지 말 것)를 볼 것 — 여기선 중복하지 않는다.
## 1. 증상
캡컷에서 클립을 **복사하거나 옮긴 뒤 확대**하면, 그 클립만 흰 띠(`frame`)와 댓글 카드
(`comment`) 위로 올라가 템플릿 밖으로 삐져나온다. 다른 클립은 멀쩡하다.
## 2. 원인 (실측 증거)
탭별 코드 차이가 **아니다.** 세 탭 모두 같은 빌더(`build_bg_template_draft`)를 쓰고,
생성 직후 `draft_content.json`은 완전히 동일하다.
```
구간탭 드래프트 (사용자가 클립 옮김/복사함)
bg ri=[0] main ri=[1, 4] frame ri=[2] comment ri=[3]
↑ 옮기거나 복사한 클립만 4 = frame·comment 보다 위
붙여넣기 드래프트 (클립을 안 건드림)
bg ri=[0] main ri=[1] frame ri=[2] comment ri=[3] ← 정상
```
즉 **캡컷이 새로 생긴 비디오 세그먼트(복사·이동)에 렌더순서를 새로 찍는다.**
붙여넣기 탭이 멀쩡해 보인 건 거기서 클립을 안 옮겼기 때문이고, 거기서도 옮기면 똑같이 깨진다.
재현 2회 모두 값이 `4`였다:
| 시점 | 조작 | 결과 |
|---|---|---|
| 1차 | 클립 2개를 타임라인 앞으로 **이동** | 그 2개만 `ri=4` (확대 189.8%) |
| 2차 | 수리 후 클립 1개 **복사** + 확대 | 그 1개만 `ri=4` (확대 225.6%) |
**⚠ 2026-07-31 반례**: `악뮤 수현의 하루_…_하이라이트3`에서 복붙+확대(201.7%) 클립이
`ri=3`을 받았다(비디오 최대는 comment의 3 → max+1이면 4여야 함). 즉 "정확히 max+1"
규칙은 보편이 아니다. 확실한 결론은 **"복붙/이동 클립의 ri가 frame(2) 위로 재부여된다"**
뿐이며, 수리는 정상값으로 되돌리는 방식이라 3이든 4든 동일하게 고쳐진다.
### 왜 트랙 잠금으로는 못 막나
`_lock_tracks``bg`·`frame`·`comment`·제목 트랙만 잠근다. `main`은 사용자가 편집해야 하므로
잠글 수 없고, 문제는 그 `main` 트랙 안에서 일어난다.
### 왜 생성 시점 값으로도 못 막나 (규칙이 `max+1`이라면)
텍스트 쪽 증거상 캡컷은 새 세그먼트에 **같은 타입 대역 안에서 `현재 최대 + 1`** 을 준다
(사용자가 추가한 텍스트가 14109 → 14110 → … 순차 증가). 비디오도 같은 규칙이면
`frame`을 아무리 높여도 새 클립이 그보다 +1을 받으므로 **예방이 원리적으로 불가능**하다.
텍스트(14000+)는 비디오와 대역이 달라 영향을 받지 않는다.
## 3. 적용한 것
### (a) 레이어 수리 기능 — 사후 복구 (확실히 동작함)
**`capcut_agent/draft.py`**
| 이름 | 역할 |
|---|---|
| `CANON_RI` | 트랙별 정상 render_index — `bg 0 / main 1 / frame 2 / comment 3` |
| `_count_bad_ri(json_path)` | 정상값과 다른 비디오 세그먼트 수 = 꼬임 개수 |
| `list_drafts(draft_root)` | 드래프트 목록(최근 수정순) + `broken`/`bad` 플래그 |
| `repair_layers(draft_dir)` | 비디오 세그먼트 render_index를 트랙별 정상값으로 되돌리고 `_lock_tracks` 재적용. 이름 없는(사용자 추가) 비디오 트랙은 4,5… 로 밀어 맨 위 유지 |
> ⚠ 2026-08-03: 아래 표는 **루트 `draft_content.json` 만** 다루던 시절 기록이다.
> 지금은 `Timelines/*` 사본까지 함께 고친다 — **(a-3)** 을 반드시 볼 것.
- 백업은 **`<파일명>.repair.bak`**. `draft_content.json.bak`은 **캡컷 자체 백업 파일명이라
쓰면 안 된다**(처음에 이걸로 썼다가 캡컷 백업을 덮어썼음 — 같은 실수 반복 금지).
- 고칠 게 없으면 백업도 안 만들고 잠금만 다시 채운다(멱등).
**`server/app.py`**
- `GET /drafts``{"drafts":[{name, mtime, broken, bad}, …]}` 최근 30개
- `POST /repair` (form `draft=<드래프트폴더명>`) → `{"ok", "fixed", "detail"}`
경로는 `os.path.basename`으로 정규화해 드래프트 루트 밖으로 못 나가게 막음.
**`_capcut_running()` 이면 409로 거부**(`force=1` 로만 강행). 실측: 11:04:32 수리 →
11:05:39 CapCut 저장으로 되돌아감. CapCut 은 파일을 다시 읽지 않고 메모리 상태로 덮어쓴다.
이 가드가 없으면 "수리했는데 그대로예요"가 반복된다.
**`server/static/index.html`**
- 페이지 하단 `<details>` **"🩹 레이어 수리"** 섹션. 펼치면 `/drafts`를 불러 목록 표시
(`⚠ 이름 (꼬임 N개)` / `✓ 이름`), 선택 후 실행 → 결과 표시 → 목록 갱신.
**사용 절차 (중요)**
1. 캡컷에서 **그 프로젝트를 닫는다**(홈으로). 열어둔 채 수리하면 캡컷이 메모리 상태로 덮어쓴다.
2. 웹 UI 하단 **🩹 레이어 수리** → 드래프트 선택 → 실행.
3. 캡컷에서 다시 연다.
### (a-3) 2026-08-03 — "수리해도 그대로예요"의 진짜 이유: `Timelines/`
**증상**: 수리를 눌러 `fixed=2` 가 나오는데도 캡컷에서 열면 그대로 삐져 있다.
**원인**: CapCut 9.x(`draft_meta_info.json` 의 `draft_new_version` = 164.0.0)부터 프로젝트
실데이터가 **`Timelines/<GUID>/`** 아래로 옮겨갔다. `repair_layers()` 는 루트
`draft_content.json` 만 고쳤으므로 **캡컷이 실제로 읽는 파일은 손대지 않았다.**
실측 증거(드래프트 `열심히 하는 나경`, `소지섭이 올리브를…(2)`):
| 증거 | 내용 |
|---|---|
| `Timelines/` 생성 시점 | **캡컷에서 한 번이라도 연** 드래프트에만 있다. 빌더가 막 만든 드래프트엔 없다 → 그래서 *생성*은 멀쩡했고 *수리*만 안 먹었다 |
| 루트 파일 없이도 편집됨 | `소지섭이…(2)` 는 루트 `draft_content.json`**아예 없는데** 캡컷이 계속 편집 중(`Timelines/…/template.json` 이 최신) → 루트는 레거시 미러 |
| 저장 순서 | 저장 시각이 항상 `template.json` 이 가장 늦다 |
| 수리 직후 대조 | 루트만 `main ri=[1]`, `Timelines/*/draft_content.json` · `template.json``[1, 4]` 그대로 |
**조치** (`capcut_agent/draft.py`)
| 이름 | 변경 |
|---|---|
| `timeline_jsons(draft_dir)` | 신규. 루트 `draft_content.json` + `Timelines/*/draft_content.json` + `Timelines/*/template.json` 목록. `.tmp` 는 저장 중 임시파일이라 제외 |
| `_repair_one(json_path)` | 신규. 파일 1개 수리(기존 `repair_layers` 본문). 백업 `<파일명>.repair.bak` |
| `repair_layers()` | 위 파일 **전부** 수리. 반환에 `files`(고친 파일 수) 추가. 파일마다 내용이 같으므로 `fixed` 는 합이 아니라 최댓값 |
| `_lock_tracks()` | 사본 전부에 잠금 적용. 바뀔 때만 기록 |
| `list_drafts()` / `_count_bad_ri` | 루트가 없어도(=`Timelines` 만 있어도) 목록에 나오고 꼬임을 센다 |
| `_registered_drafts()` 신규 · `list_drafts()``path` | **기본 폴더만 훑으면 안 된다.** 캡컷 설정에서 저장 위치를 바꾸면 드래프트가 `%LOCALAPPDATA%\CapCut\…\com.lveditor.draft` 밖에 생긴다(실측: `222222222222``D:/개인폴더/00.유튭/정치/capcut/CapCut Drafts` 에 있어 목록에 안 떴다). 어디에 있든 드래프트 루트의 **`root_meta_info.json``all_draft_store[].draft_fold_path`** 가 알고 있다. 목록 항목에 `path` 를 실어 그걸 수리 키로 쓴다 |
| `server/app.py` `_resolve_draft()` 신규 | 폼 값이 이제 폴더**경로**라 `basename` 고정으로는 못 막는다 → `list_drafts()` 가 아는 드래프트에만 매칭해 임의 경로 접근 차단(폴더명도 구버전 호환으로 받음) |
| `_draft_title()` 신규 · `list_drafts()``title` | 캡컷에서 프로젝트 이름을 바꿔도 **폴더명은 안 바뀐다**(예: 화면 `열심히 하는 나경 땜걸~ 찡긋` ↔ 폴더 `열심히 하는 나경`). 목록이 폴더명만 보여줘서 "내 프로젝트가 목록에 없다"는 착각이 생겼다 → 이름은 **`root_meta_info.json` 우선**(캡컷 홈이 읽는 인덱스라 이게 정답. 폴더 안 `draft_meta_info.json` 엔 옛 이름이 남아 있다 — 실측: 홈 `222222222222` ↔ 폴더 메타 `언니 괜찮아…22222222222222222222`), 없으면 폴더 메타 → 폴더명 순. 폴더명과 다르면 `[폴더: …]` 를 덧붙인다 |
검증: `열심히 하는 나경``{'fixed': 2, 'detail': {'main': 2}, 'files': 2}`, 세 파일 모두
`main ri=[1]`, 재실행 시 `fixed=0`(멱등). **최종 확인은 캡컷에서 열어봐야 한다.**
### (a-2) ❌ 원복됨 — 레이어 대역 분리 (지금 코드에 **없다**)
> ⚠ **2026-07-30 사용자 요청으로 원복.** `_apply_layer_bands()`는 삭제됐고
> `CANON_RI``{"bg":0,"main":1,"frame":2,"comment":3}`로 돌아갔다.
> 아래는 **시도 기록**이며 현재 동작이 아니다. 그대로 믿지 말 것.
> 검증도 못 했다 — 판별용 드래프트가 삭제됐고, CapCut은 접근성 트리가 비어 있어
> UI 자동화로도 확인 불가. 재도전하려면 §5 "막다른 길"을 먼저 읽을 것.
(이하 원복 전 기록)
`draft.py` `_apply_layer_bands()``build_bg_template_draft()` 저장 직후 자동 실행.
```
bg 0 < main 1 << frame 14500 < comment 14501 << 자막·제목·효과 24000+
```
- `CANON_RI = {"bg":0, "main":1, "frame":14500, "comment":14501}`, `TEXT_RI_MIN = 24000`
- **왜 통하나**: CapCut 이 새 비디오 세그먼트에 주는 `max+1``max` 가 **비디오 대역
안에서만** 계산되면, frame 이 14500 이어도 복붙 클립은 계속 `4` 를 받아 프레임 아래에 갇힌다.
- **왜 텍스트를 24000+ 로 올리나**: CapCut 렌더 해석이 ①전역 ri 비교든 ②타입별 레이어든
두 경우 모두 순서가 유지되게 하려고. (①이면 frame 14500 > caption 14000 이라 자막이 가려짐)
- **틀려도 손해 없음**: 가정이 틀리면 복붙 클립이 14502 를 받아 예전과 똑같이 동작할 뿐.
그래서 검증 없이 기본값으로 넣었다.
- `repair_layers()` 도 같은 대역으로 맞춘다 → 예전 드래프트(frame=2, 텍스트 14000+)를
변환하는 용도로 남는다. **멱등**(두 번 돌려도 fixed=0).
검증(실제 드래프트 생성 후 `draft_content.json` 확인):
| 트랙 | ri |
|---|---|
| bg / main | 0 / 1 |
| frame / comment | 14500 / 14501 |
| caption / title_top / title_main / channel / effect | 25001 / 25002 / 25003 / 25004 / 25005 |
### (b) 그래도 안 되면 — 판별 근거와 다음 수단
**확정된 것**: 새 비디오 세그먼트(복붙·이동)는 `현재 비디오 최대 render_index + 1` 을 받는다.
드래프트 4건에서 재현:
| 드래프트 | 비디오 최대 | 새 클립이 받은 값 |
|---|---|---|
| 핑계고 3015-3240 (이동) | 3 (comment) | **4** |
| 핑계고 3015-3240 (복사) | 3 | **4** |
| 눈감고 들으면… (복붙) | 3 | **4** |
| 대성 태양 GD… (comment 없음) | 2 (frame) | **3** |
(a-2)를 적용한 드래프트에서 복붙 클립이 **14502** 를 받는다면 "max 는 비디오 전체 대상"이
확정되는 것이고, 그때는 생성 시점 예방이 원리적으로 불가능하다. 그 경우 남는 수단은
편집 마무리 절차(캡컷 완전 종료 → 수리 → 재오픈 → 내보내기)뿐이다.
### (b-old) 이전 실험 기록 (미결로 종료)
**확정된 것**: 새 비디오 세그먼트(복붙·이동)는 `현재 비디오 최대 render_index + 1` 을 받는다.
드래프트 4건에서 재현:
| 드래프트 | 비디오 최대 | 새 클립이 받은 값 |
|---|---|---|
| 핑계고 3015-3240 (이동) | 3 (comment) | **4** |
| 핑계고 3015-3240 (복사) | 3 | **4** |
| 눈감고 들으면… (복붙) | 3 | **4** |
| 대성 태양 GD… (comment 없음) | 2 (frame) | **3** |
**남은 물음**: 이 "최대값"이 ① 비디오 트랙의 모든 세그먼트인지, ② `ri < 10000`(비디오 대역)
세그먼트만인지. ②라면 `frame`을 대역 밖(14500)에 두면 새 클립은 계속 4를 받아
**프레임 아래에 갇힌다 = 영구 해결**. ①이라면 14501을 받아 실패 = 예방 불가 확정.
근거: 사용자가 추가한 **텍스트**는 텍스트 대역 안에서만 증가했고(14109→14114), 복붙한
**비디오**는 그 14000번대를 **무시하고** 4를 받았다 → 대역별로 따로 셀 가능성이 있다.
단 비디오 세그먼트가 `ri ≥ 10000` 인 사례가 기존 드래프트에 **0건**이라 데이터로는 판별 불가.
**판별용 드래프트 `__레이어테스트` 를 만들어 둠** (frame `ri=14500`, 텍스트 24000+,
전 트랙 잠금 해제). 여기서 클립 하나 복붙 → 확대 → 삐지는지 보면 끝.
확인 후 이 절을 확정 결론으로 교체하고 테스트 드래프트는 삭제할 것.
**CapCut UI 자동화로는 못 푼다**: `orca computer` 로 붙어 봤으나 CapCut 은 접근성 트리가
비어 있고(94자) 스크린샷도 실패한다. 사람이 눌러야 한다.
### (c) 문서
- `ARCHITECTURE.md` §10 — "잠금으로도 못 막는 경우가 있다(main 트랙)" 항목 추가.
- `SETUP.md` 문제 해결표 — "클립을 옮긴 뒤 확대하면 그 클립만 삐짐" 행 추가.
## 4. 시도해볼 만한 우회
- 캡컷에서 `Ctrl+C`/`Ctrl+V` 대신 **우클릭 → 복제**. 다른 코드 경로라 렌더순서를 새로 안 찍을
가능성이 있다. 되면 이게 가장 싼 답(코드 변경 불필요). — **미검증**
## 5. 막다른 길 (다시 시도하지 말 것)
- **마스크**: 캡컷 마스크는 클립과 함께 확대/이동하므로 확대 시 같이 커진다 → 클램프 못 함.
- **frame을 텍스트/스티커 트랙으로**: `StickerSegment`는 캡컷 클라우드 스티커
`resource_id`만 받는다 → 로컬 PNG를 못 올린다.
- **영상에 흰 띠를 미리 구워 넣기**(`media.to_template` 방식): 확대하면 띠까지 같이 커져
화면 밖으로 나가고 영상이 캔버스를 다 덮는다.
- **트랙 잠금 확대**: `main`을 잠그면 사용자가 편집을 못 한다.

BIN
배경.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

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

View File

@ -0,0 +1,370 @@
# 작업 기록 (2026-07-27 ~ 07-31) — 다른 PC 재현 가이드
> **읽는 대상**: 다른 Claude 세션 / 다른 PC에서 같은 상태를 만들려는 사람.
> 이 문서는 **이 기간에 내가 바꾼 것만** 다룬다. 앱 전체 구조는 `ARCHITECTURE.md`,
> 설치는 `SETUP.md`, 레이어 삐짐 상세는 `레이어_삐짐_수리.md` 참고.
>
> ⚠ 이 기간에 **다른 세션이 별도로 작업한 부분**(`/auto/*`, `/prompts`, `/yt/comments`
> 엔드포인트, `index.html` 디자인 개편, `_load_comment_cards` 배치 로직)은 여기 없다.
> 그건 내 변경이 아니므로 이 문서만 보고 재현하면 안 된다.
바꾼 파일: `capcut_agent/draft.py`, `capcut_agent/pipeline.py`, `capcut_agent/youtube.py`,
`capcut_agent/media.py`, `server/app.py`, `server/static/index.html`, `build_bg_template.py`,
`ARCHITECTURE.md`, `SETUP.md`.
**모든 변경 후 `.bat` 재시작 필수** (파이썬 코드가 uvicorn에 물려 있어 hot-reload 안 됨).
---
## 요약 — 무엇을 왜 바꿨나
| # | 작업 | 상태 |
|---|---|---|
| 1 | 초록 프레임(GOP 중간 컷) 자동 검증·복구 | ✅ 완료·검증됨 |
| 2 | 레이어 삐짐 — 사후 수리 기능 + CapCut 실행중 가드 | ✅ 완료 (근본 예방은 **미해결**) |
| 3 | 레이어 대역 분리(생성 시점 예방) 시도 | ❌ **원복함** |
| 4 | 하단 자막 크기 10 고정 | ✅ 완료 |
| 5 | `bg` 트랙 잠금 해제 | ✅ 완료 |
| 6 | 템플릿 레이아웃 전면 재조정(상수화) | ✅ 완료·검증됨 |
| 7 | 백업 파일명 충돌 수정 | ✅ 완료 (버그였음) |
---
## 1. 초록 프레임 자동 검증·복구 (`youtube.py`)
### 증상
합쳐진 영상 중간에 **초록 화면이 2초쯤** 나온다. 타임라인 썸네일에도 초록으로 보인다
(= 영상 파일 자체에 구워진 것, CapCut 문제 아님).
### 원인 (실측)
`yt-dlp --download-sections`**가끔 키프레임이 아닌 위치에서 스트림 복사로** 잘라,
첫 키프레임 전까지 참조 프레임이 없는 파일을 만든다.
```
붙여넣기 9컷 중 fe92bcbe1e 파트 → 첫 키프레임이 2.269초 뒤
병합본의 초록 구간 20.32~22.5s 와 정확히 일치 (파트 시작 20.32 + 2.27)
구간 탭 파일 2개도 깨짐 → 첫 키프레임 2.202s / 1.535s
그 병합본(merged_809fd228)의 초록: 108.0~110.4s
```
`--force-keyframes-at-cuts`가 그 파트에서만 실패해 키프레임 컷으로 폴백한 결과인데,
**검증이 없어서 그대로 통과**했다.
### ⚠ 탐지 함정 3종 (이것 때문에 오래 못 잡았다)
1. **파트를 단독 재생하면 멀쩡하다** — ffmpeg가 깨진 앞부분을 건너뛴다.
concat **재인코딩할 때만** 초록으로 구워진다.
2. **`ffprobe -read_intervals`는 못 잡는다** — 키프레임으로 **시크**해버려
"첫 프레임이 I프레임"이라고 거짓 보고한다. (`%+2`와 `%+#5`가 서로 다른 답을 냈다)
3. **`ffmpeg -v error`로 디코딩해도 에러가 안 난다** — h264 은닉 처리. 픽셀로만 보인다.
**유일하게 확실한 판정**: 시크 없이 앞에서부터 프레임을 훑어 **첫 키프레임 시각**을 본다.
### 추가한 것
```python
KEYFRAME_TOL = 0.05 # 첫 키프레임이 이보다 늦으면 앞부분 깨짐으로 판정(초)
DL_ATTEMPTS = 2 # 깨진 결과 재다운로드 횟수(간헐적 실패용)
RECUT_LEAD = 6.0 # 최후 수단: 앞에 이만큼 여유를 받아 로컬에서 다시 자름(초)
REPAIR_LOG: List[str] = [] # 수리 내역 → 파이프라인이 SSE 로그로 흘림
```
| 함수 | 역할 |
|---|---|
| `_first_keyframe_sec(path)` | 시크 없이 훑어 첫 키프레임 시각(초). 0이면 정상. 첫 키프레임 만나면 즉시 종료 |
| `_duration(path)` | ffprobe 길이 |
| `_encode_h264(src, dst, ss, t)` | h264/aac 재인코딩. `ss`/`t`를 **`-i` 뒤**에 둬 프레임 정확(출력 시크) |
| `_unlink(path)` | 조용한 삭제 |
| `_dl_section(...)` | 구간 1회 다운로드(force→폴백) + h264 정규화. `tag`로 재시도 파일명 분리 |
**동작 (구간 탭 `cut_youtube` / 붙여넣기 `cut_youtube_precise` 공통)**
1. 받은 파일의 첫 키프레임이 맨 앞이면 → 통과
2. 아니면 버리고 **재다운로드** (`DL_ATTEMPTS=2`) — 간헐적이라 대개 여기서 해결
3. 그래도 깨지면 → 앞에 `RECUT_LEAD`**여유를 붙여 받아 로컬에서 뒤쪽 want초만 재인코딩**.
깨진 앞부분은 버리는 여유 구간에 들어가므로 **항상 깨끗**하다.
수리가 일어나면 웹 UI 로그에 `🩹` 로 표시된다 (`REPAIR_LOG` → `pipeline.py`에서 yield).
### ⚠ Windows 함정 (실제로 터졌다)
`_first_keyframe_sec`가 ffprobe **파이프를 안 닫으면 파일이 잠겨** 바로 뒤 `_unlink`/덮어쓰기가
`PermissionError`로 실패한다. → `finally`에서 `stdout.close()``kill()``wait()`.
### 검증 (실제 다운로드로 확인함)
| 경로 | 결과 |
|---|---|
| 정상 | 5.505s (요청 5.500s), 첫 키프레임 0.000s |
| 폴백 재컷 강제 | 5.005s (요청 5.000s), 첫 키프레임 0.033s, 임시파일 잔여 없음 |
재현 명령:
```bash
python -X utf8 -c "
import sys; sys.path.insert(0,'.')
from capcut_agent.youtube import _first_keyframe_sec, _duration
p='.downloads/<파일>.mp4'
print(_first_keyframe_sec(p), _duration(p)) # 첫 값이 0.05 초과면 앞부분 초록
"
```
> **이미 받아둔 깨진 영상은 복구 불가** — 다시 만들어야 한다.
---
## 2. 레이어 삐짐 — 사후 수리 (`draft.py` / `app.py` / `index.html`)
상세는 `레이어_삐짐_수리.md`. 여기선 **재현에 필요한 것만**.
### 증상
CapCut에서 클립을 **복붙하거나 자르고 옮긴 뒤 확대**하면, 그 클립만 흰 띠·댓글 위로
올라가 템플릿 밖으로 삐져나온다.
### 원인
CapCut이 **새로 생긴 비디오 세그먼트**(복붙·이동)에 렌더순서를 **frame(2) 위로 새로 찍는다.**
관측값은 대부분 `max(비디오 ri)+1`(=4)이었으나 `3`인 반례도 있어 "정확히 max+1"이 보편은 아니다.
확실한 건 **frame 위로 재부여된다**는 것뿐. 수리는 정상값으로 되돌리는 방식이라 3이든 4든 동일하게 고쳐진다.
`main` 트랙은 편집해야 하므로 잠글 수 없고, 문제는 그 안에서 일어난다 → **트랙 잠금으로 못 막는다.**
### 추가한 것 — `draft.py`
| 이름 | 역할 |
|---|---|
| `CANON_RI = {"bg":0,"main":1,"frame":2,"comment":3}` | 트랙별 정상 render_index |
| `_count_bad_ri(json_path)` | 정상값과 다른 비디오 세그먼트 수 |
| `list_drafts(draft_root)` | 드래프트 목록(최근순) + `broken`/`bad` |
| `repair_layers(draft_dir)` | 정상값으로 되돌리고 `_lock_tracks` 재적용. 멱등 |
### 추가한 것 — `server/app.py`
- `GET /drafts``{"drafts":[{name, mtime, broken, bad}, …]}` 최근 30개
- `POST /repair` (form `draft=<폴더명>`, `force`) → `{"ok","fixed","detail"}`
- 경로는 `os.path.basename`으로 정규화(드래프트 루트 밖 접근 차단)
- **`_capcut_running()`이면 409로 거부** (`force=1`로만 강행)
- `_capcut_running()` = `tasklist /FI "IMAGENAME eq CapCut.exe"` 파싱
### ⚠ 이 가드가 왜 필수인가 (실측)
```
11:04:32 수리 실행 (백업엔 main ri=[1,4])
11:05:39 CapCut이 저장 → 메모리 상태로 덮어써 ri=4 부활
```
CapCut은 열려 있는 동안 **파일을 다시 읽지 않는다.** 가드가 없으면
"수리했는데 그대로예요"가 무한 반복된다. 실제로 두 번 반복됐다.
### 사용 절차 (순서 지켜야 함)
1. CapCut에서 **그 프로젝트를 닫는다** (권장: 프로그램 완전 종료)
2. 웹 UI 하단 **🩹 레이어 수리** → 드래프트 선택 → 실행
3. CapCut에서 다시 연다
### UI — `index.html`
페이지 하단 `<details>` 섹션. 펼치면 `/drafts`를 불러 `⚠ 이름 (꼬임 N개)` / `✓ 이름`으로
표시하고, 선택 후 실행 → 결과 표시 → 목록 갱신.
---
## 3. ❌ 원복함 — 레이어 대역 분리 (생성 시점 예방 시도)
**지금 코드에 없다. 다시 넣지 말 것(사용자가 원복 요청).**
시도한 것: `frame`/`comment`를 비디오 대역 밖(`14500`/`14501`)으로 올리고 텍스트를 `24000+`로 밀기.
가설: CapCut의 `max+1`**비디오 대역 안에서만** 계산되면 복붙 클립은 계속 4를 받아 프레임 아래에 갇힌다.
근거는 있었다 — 사용자가 추가한 **텍스트**는 텍스트 대역 안에서만 증가(14109→14114)했는데,
복붙한 **비디오**는 그 14000번대를 **무시하고** 4를 받았다.
**결과: 검증 못 하고 원복.** 판별용 드래프트를 만들었으나 사용자가 삭제했고,
그 뒤 사용자가 원복을 요청했다. `레이어_삐짐_수리.md`의 (a-2) 절은 **이 원복을 반영 안 한 상태**로
남아 있으니 그대로 믿지 말 것.
**CapCut UI 자동화로는 검증 못 한다**: `orca computer`로 붙어봤으나 CapCut은
접근성 트리가 비어 있고(94자) 스크린샷도 실패한다. 사람이 직접 눌러야 한다.
### 막다른 길 (다시 시도하지 말 것)
- **마스크**: pycapcut `add_mask`의 좌표는 "以素材的像素为单位"(소재 픽셀 기준) → 클립과 같이 확대됨. 클램프 불가.
- **frame을 스티커 트랙으로**: `StickerSegment`는 CapCut 클라우드 `resource_id`만 받음. 로컬 PNG 불가.
- **영상에 흰 띠 미리 굽기**: 확대하면 띠까지 커져 화면 밖으로 나감.
- **`main` 트랙 잠금**: 사용자가 편집을 못 함.
---
## 4. 하단 자막 크기 10 고정 (`draft.py`)
```python
CAPTION_SIZE = 10.0 # 캡컷 폰트 크기와 1:1
```
기존엔 `fit_caption_size()`가 최장 줄 기준으로 7~13 사이 **자동 결정** → 드래프트마다 크기가 달랐다.
이제 항상 10. 세 탭 공통(같은 빌더).
- 넘침 없음: 자막 청킹 하드캡이 한 줄 14자, 크기 10이면 한 줄 폭 ≈ 14×51.5 ≈ **721px < 1080**.
- `fit_caption_size()`**지우지 않고 "미사용" 표시만** 해뒀다(되돌릴 때 사용).
검증: 실제 드래프트의 `draft_content.json`에서 `size=10.0` 확인 (서브 14 / 메인 18은 그대로).
---
## 5. `bg` 트랙 잠금 해제 (`draft.py`)
```python
LOCK_TRACKS = ("frame", "comment", "title_top", "title_main", "channel") # "bg" 제거
```
`bg`(흰 배경)는 **맨 아래 레이어라 순서가 꼬여도 화면에 영향이 없고**, 영상 길이를 늘릴 때
같이 늘려야 해서 잠겨 있으면 불편하다.
`frame`·`comment`·제목 트랙은 **계속 잠근다** — 영상 위 레이어라 쪼개지면 순서가 꼬인다.
검증: 생성 직후 `attribute` — bg=0, main=0, frame=4, comment=4, caption=0, 제목류=4.
(잠금 = `attribute` 비트2(값 4). mute 비트0은 OR로 보존)
> ⚠ `_lock_tracks`**잠그기만 하고 풀지 않는다.** 이미 만들어진 드래프트의 bg는
> CapCut에서 자물쇠를 직접 눌러 풀어야 한다.
---
## 6. ★ 템플릿 레이아웃 전면 재조정 (`pipeline.py` / `media.py` / `draft.py`)
### 무엇이 바뀌었나
예전엔 `배경.png`의 흰 밴드를 **자동 감지**(`detect_white_band`)해서 좌표를 잡았다.
→ 좌표를 정확히 통제할 수 없어서 **상수로 바꿨다.** `배경.png`는 이제 레이아웃에 관여하지 않는다.
### 조정 지점 — `capcut_agent/pipeline.py` 상단 (여기만 고치면 전부 따라 움직임)
```python
CANVAS_H = 1920
VIDEO_TOP = 323 # 영상 창 시작 = 위 흰 띠가 끝나는 지점
VIDEO_BOTTOM = 1122 # 영상 창 끝 = 아래 흰 띠가 시작하는 지점
TITLE_TOP_Y = 109 # 서브제목(주황) 중앙
TITLE_MAIN_Y = 252 # 메인제목(흰색) 중앙
CAPTION_GAP = 72 # 하단 자막 중앙 = VIDEO_BOTTOM 이 값
EFFECT_GAP = 25 # 효과자막 중앙 = VIDEO_TOP + 이 값
COMMENT_TOP = VIDEO_BOTTOM # 댓글 카드 윗변 = 영상 바로 아래(딱 붙음)
CHANNEL_RATIO = 0.85 # 출처: 아래 띠에서 85% 내려간 지점
```
이 값들은 **사용자가 준 레퍼런스 템플릿 이미지를 픽셀 측정해서** 뽑았다
(레퍼런스 폭 311px → 캔버스 1080px 환산계수 3.4727).
### 변경 전/후
| 요소 | 이전 | 이후 |
|---|---|---|
| 영상 창 | 453 ~ 1311 | **323 ~ 1122** |
| 서브제목 | y 203 (Y 1513) | **y 109** (Y 1702) |
| 메인제목 | y 344 (Y 1232) | **y 252** (Y 1416) |
| 하단 자막 | y 1240 (Y 559) | **y 1050** (Y 180) |
| 효과자막 | y 478 (Y 965) | **y 348** (Y 1224) |
| 댓글 카드 | 중앙 y 1541 **고정** | **윗변 1122 = 영상 바로 아래** |
| 출처 | y 1829 | y 1800 |
> 자막·효과자막도 **같이 옮겨야 한다** — 안 그러면 새 영상 창 밖으로 나간다.
### 딸린 변경
**`media.py`** — `make_frame()``band_color` 인자 추가(기존엔 검정 하드코딩).
`_template_pos``make_transparent_frame`(배경.png 감지) 대신 `make_frame`(명시 좌표)을 쓴다.
**`draft.py`** — `comment_top` 인자 추가. 댓글 카드 세로 위치를 **카드마다 계산**한다:
```python
표시높이 = cw × (ih / iw) × COMMENT_SCALE # COMMENT_SCALE = 0.89
중앙 = comment_top + 표시높이 / 2
```
⚠ **카드 이미지 높이가 제각각이라 중앙값 하나로는 "영상 바로 아래"에 못 붙인다.**
(CapCut scale 1.0 = contain. 댓글 카드는 캔버스보다 가로로 넓어 가로가 먼저 맞음)
**`pipeline.py`** — `caption_y`/`effect_y` 하드코딩(`-559/1920`, `965/1920`) 제거.
전부 `_template_pos()`가 상수에서 파생 → **값이 두 군데로 갈라지지 않는다.**
**`build_bg_template.py`**(단독 CLI) — 자체 좌표 계산을 버리고 `_template_pos()` 재사용.
### 검증 (실측)
```
프레임 PNG 투명 구간: 323 ~ 1121 ✓
main 722.5 / caption 1050.0 / title_top 109.0 / title_main 252.0 /
effect 348.0 / channel 1800.3
댓글 카드 — 높이 다른 두 장(422px, 590px) 모두 윗변 정확히 1122 ✓
```
재현 명령:
```bash
python -X utf8 -c "
import sys; sys.path.insert(0,'.')
from capcut_agent.pipeline import _template_pos
frame,bg,pos=_template_pos(True)
for k,v in pos.items():
print(k, v, '' if k=='comment_top' else f'→ 캔버스y={960-960*v:.1f}px, 캡컷Y={round(v*1920)}')
"
```
---
## 7. 백업 파일명 충돌 수정 (버그였음)
`repair_layers`의 백업을 처음에 `draft_content.json.bak`으로 만들었는데,
**그게 CapCut 자체 백업 파일명**이었다. 손대지 않은 프로젝트에도 전부 존재한다.
→ 내 수리가 CapCut 백업 2개를 덮어썼다(프로젝트 자체엔 영향 없음).
**→ `draft_content.repair.bak` 으로 변경. `draft_content.json.bak`은 절대 쓰지 말 것.**
---
## 다른 PC에서 재현하는 순서
1. **선행 조건 확인**`SETUP.md` 기준. PATH에 `ffmpeg`/`ffprobe`, `yt-dlp`,
Node.js 또는 deno(yt-dlp JS 런타임). CapCut 설치 + 코트라 볼드체 캐시(없으면 기본 폰트로 안전 동작).
2. 위 **1·2·4·5·6·7**을 순서대로 적용. 3번은 **적용하지 말 것**(원복된 실험).
3. `.bat` 재시작.
4. 아래 검증 전부 통과 확인.
### 검증 명령 모음
```bash
# 구문 + 임포트 체인
python -X utf8 -c "
import ast
for f in ['capcut_agent/draft.py','capcut_agent/pipeline.py','capcut_agent/youtube.py',
'capcut_agent/media.py','server/app.py','build_bg_template.py']:
ast.parse(open(f,encoding='utf-8').read())
print('구문 OK')
from server import app; print('임포트 체인 OK')
"
# 상수 확정값
python -X utf8 -c "
import sys; sys.path.insert(0,'.')
from capcut_agent.draft import CAPTION_SIZE, LOCK_TRACKS, CANON_RI, COMMENT_SCALE
from capcut_agent.pipeline import VIDEO_TOP, VIDEO_BOTTOM, TITLE_TOP_Y, TITLE_MAIN_Y
from capcut_agent.youtube import KEYFRAME_TOL, DL_ATTEMPTS, RECUT_LEAD
print(CAPTION_SIZE, LOCK_TRACKS, CANON_RI, COMMENT_SCALE)
print(VIDEO_TOP, VIDEO_BOTTOM, TITLE_TOP_Y, TITLE_MAIN_Y)
print(KEYFRAME_TOL, DL_ATTEMPTS, RECUT_LEAD)
"
```
**기대값**
```
10.0 ('frame','comment','title_top','title_main','channel') {'bg':0,'main':1,'frame':2,'comment':3} 0.89
323 1122 109 252
0.05 2 6.0
```
### 실제 드래프트로 최종 확인
테스트 드래프트를 만들어 `draft_content.json`을 열어보고 **확인 후 반드시 삭제**한다
(`shutil.rmtree`). 이 프로젝트엔 자동 테스트 스위트가 없고, 최종 검증은 사용자가
CapCut에서 열어보는 방식이다.
---
## 미해결로 남은 것
1. **레이어 삐짐 근본 예방** — 생성 시점 예방책 없음. 현재는 사후 수리뿐.
재도전하려면 §3의 막다른 길 목록을 먼저 읽을 것.
2. **제목이 안 보인다는 보고**(2026-07-29) — 파일상으로는 정상이었다.
내용·크기·색·폰트·시간범위·`visible=true`·위치 전부 확인했으나 미리보기에만 안 나왔다.
원인 미규명. 재발하면 트랙의 👁(눈) 아이콘 상태부터 확인할 것.
3. **영상보다 오버레이가 짧아지는 문제** — 클립을 뒤에 추가하면 `frame`·제목 트랙은
생성 당시 길이에서 끝나 그 뒤 구간엔 템플릿이 아예 없다(실측: 영상 44.13s vs 프레임 33.97s).
자물쇠를 풀고 같이 늘려야 한다. 자동화 안 됨.

13
참고/ytcut/Dockerfile Normal file
View File

@ -0,0 +1,13 @@
FROM python:3.11-slim
WORKDIR /app
# ffmpeg: --force-keyframes-at-cuts 재인코딩 / mp4 머지에 필요
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
COPY app/requirements.txt /tmp/requirements.txt
RUN pip install --no-cache-dir -r /tmp/requirements.txt
EXPOSE 8000

55
참고/ytcut/README.md Normal file
View File

@ -0,0 +1,55 @@
# YT Cut (ClipCut) — 유튜브 구간 잘라받기
유튜브 영상에서 원하는 시작~끝 구간만 잘라 mp4로 받는 웹앱.
FastAPI + yt-dlp + ffmpeg. 컨테이너 하나로 끝, 외부 DB 불필요.
## 폴더 구성
```
ytcut/
├─ docker-compose.yml
├─ Dockerfile
├─ README.md
├─ downloads/ ← 자동 생성됨. 잘라낸 mp4 저장 위치
└─ app/
├─ main.py
└─ requirements.txt
```
## 사전 준비
- Docker + Docker Compose 설치된 리눅스 서버
- 8906 포트가 비어 있어야 함 (쓰는 포트 있으면 compose의 `8906:8000` 왼쪽 숫자만 변경)
## 설치 & 실행
```bash
cd ytcut
mkdir -p downloads
chown -R 1000:1000 downloads # 컨테이너가 1000:1000 으로 돌아서 쓰기 권한 필요
docker compose up -d --build
```
- 첫 실행은 이미지 빌드(ffmpeg 설치) 때문에 1~3분 걸림
- 로그: `docker compose logs -f`
## 사용법
1. 브라우저에서 `http://<서버IP>:8906` 접속
2. 유튜브 URL 붙여넣기
3. 시작 / 끝 시간 입력 — `분:초`(03:30) 또는 `시:분:초`(01:03:30) 형식
4. **잘라서 받기** → 처리 끝나면 "파일 받기" 버튼으로 다운로드
5. 하단 파일 목록에서 이전에 자른 클립 다시 받기/삭제 가능
## 업데이트
```bash
docker compose up -d --build # 코드/이미지 갱신
# yt-dlp만 최신화하고 싶을 때:
docker compose build --no-cache && docker compose up -d
```
※ 유튜브가 막아서 다운로드가 안 될 때는 yt-dlp 최신화가 보통 해결책.
## 종료 / 삭제
```bash
docker compose down # 중지 (downloads 폴더는 유지)
```
## 참고
- 한 번 처리 제한시간 30분(`main.py`의 `TIMEOUT_SEC`).
- 인증 없음 → 공개 인터넷에 직접 노출 말고, 리버스 프록시(Nginx 등)나 사설망에서 쓰는 걸 권장.
- 도메인 붙이려면 리버스 프록시에서 `호스트:8906` 으로 프록시하면 됨.

467
참고/ytcut/app/main.py Normal file
View File

@ -0,0 +1,467 @@
"""YT Cut - 유튜브 구간 잘라 받기.
GET / + 서버 파일 목록(받기/삭제)
POST /cut yt-dlp로 지정한 시작~ 구간만 mp4로 잘라 /downloads 저장
GET /download 저장된 파일을 브라우저로 내려받기
POST /delete 저장된 파일 삭제
"""
import html
import os
import re
import subprocess
from datetime import datetime
from urllib.parse import quote
from fastapi import FastAPI, Form, HTTPException
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, Response
app = FastAPI(title="ClipCut")
DOWNLOAD_DIR = "/downloads"
# MM:SS 또는 HH:MM:SS 만 허용 (shell 인자로 넘기기 전 검증)
TIME_RE = re.compile(r"^(?:\d{1,2}:)?\d{1,2}:[0-5]\d$")
TIMEOUT_SEC = 1800 # 30분
# ── 인라인 SVG 아이콘 (currentColor 사용) ────────────────────────────
def _svg(body: str) -> str:
return ('<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" '
'stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" '
f'aria-hidden="true">{body}</svg>')
IC_FILM = _svg('<rect x="3" y="4" width="18" height="16" rx="2.2"/>'
'<path d="M7 4v16M17 4v16M3 9h4M3 15h4M17 9h4M17 15h4"/>')
IC_SCISSORS = _svg('<circle cx="6" cy="6" r="2.6"/><circle cx="6" cy="18" r="2.6"/>'
'<path d="M20 4 8.1 15.9M14.5 14.5 20 20M8.1 8.1 12 12"/>')
IC_LINK = _svg('<path d="M9.5 14.5 14.5 9.5"/>'
'<path d="M11.5 7.3 13 5.8a3.7 3.7 0 0 1 5.2 5.2l-1.5 1.5"/>'
'<path d="M12.5 16.7 11 18.2a3.7 3.7 0 0 1-5.2-5.2l1.5-1.5"/>')
IC_DOWNLOAD = _svg('<path d="M12 3.5v11"/><path d="m7.5 10.5 4.5 4.5 4.5-4.5"/>'
'<path d="M5 20.5h14"/>')
IC_TRASH = _svg('<path d="M4 7h16"/><path d="M10 11v6M14 11v6"/>'
'<path d="M6.5 7 7.5 20a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1L17.5 7"/>'
'<path d="M9.5 7V4.5a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1V7"/>')
IC_CLOCK = _svg('<circle cx="12" cy="12" r="8.4"/><path d="M12 7.5V12l3 1.8"/>')
def _safe_name(name: str) -> str:
"""파일명만 허용(경로 탈출 차단)하고 DOWNLOAD_DIR 내부 실제 경로를 반환."""
if "/" in name or "\\" in name or name in ("", ".", ".."):
raise HTTPException(status_code=400, detail="잘못된 파일명")
path = os.path.join(DOWNLOAD_DIR, name)
base = os.path.realpath(DOWNLOAD_DIR)
if not os.path.realpath(path).startswith(base + os.sep):
raise HTTPException(status_code=400, detail="잘못된 경로")
return path
def _human(n: int) -> str:
size = float(n)
for unit in ("B", "KB", "MB", "GB"):
if size < 1024 or unit == "GB":
return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
size /= 1024
return f"{size:.1f} GB"
def _list_files():
try:
names = os.listdir(DOWNLOAD_DIR)
except OSError:
return []
items = []
for name in names:
if name.startswith("."): # .metube 등 숨김 항목 제외
continue
path = os.path.join(DOWNLOAD_DIR, name)
if os.path.isfile(path):
items.append((name, os.path.getsize(path), os.path.getmtime(path)))
items.sort(key=lambda e: e[2], reverse=True) # 최신순
return items
def _file_list_html() -> str:
items = _list_files()
if not items:
body = (f'<div class="empty">{IC_FILM}'
'<p>아직 자른 클립이 없어요.<br>위에서 첫 구간을 잘라보세요.</p></div>')
else:
rows = []
for name, size, mtime in items:
esc = html.escape(name)
href = "download?name=" + quote(name)
date = datetime.fromtimestamp(mtime).strftime("%y.%m.%d %H:%M")
rows.append(
'<li class="file">'
f'<span class="file-ic">{IC_FILM}</span>'
'<span class="file-meta">'
f'<span class="file-name" title="{esc}">{esc}</span>'
f'<span class="file-sub">{_human(size)} · {date}</span>'
'</span>'
f'<a class="iconbtn dl" href="{html.escape(href)}" '
f'aria-label="받기" title="받기">{IC_DOWNLOAD}</a>'
'<form class="inline" method="post" action="delete" '
'onsubmit="return confirm(\'이 파일을 삭제할까요?\')">'
f'<input type="hidden" name="name" value="{esc}">'
'<button class="iconbtn del" type="submit" '
f'aria-label="삭제" title="삭제">{IC_TRASH}</button>'
'</form>'
'</li>'
)
body = '<ul class="files">' + "".join(rows) + '</ul>'
return (
'<section class="files-sec">'
'<div class="files-head"><h2>파일</h2>'
f'<span class="count">{len(items)}개</span></div>'
f'{body}</section>'
)
STYLE = """
*{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#17120E; --bg-soft:#1f1813; --panel:#241c16; --panel-2:#2c2219;
--line:#3a2d22; --line-soft:#2c2219;
--text:#F2EBE2; --muted:#A99C8F; --faint:#7C7064;
--accent:#FF5C39; --accent-soft:#ff5c3922; --accent-ink:#2a0d04;
--ok-bd:#3f7a4e; --ok-bg:#15321f; --ok-tx:#9fe6b4;
--err-bd:#7a3a35; --err-bg:#341b18; --err-tx:#f3b0aa;
--danger:#E0584E;
}
html{-webkit-text-size-adjust:100%}
body{font-family:Pretendard,system-ui,sans-serif;color:var(--text);
background:
radial-gradient(900px 380px at 50% -120px, #ff5c3914, transparent 70%),
var(--bg);
min-height:100vh;line-height:1.55;
padding:max(24px,env(safe-area-inset-top)) 16px 64px;
-webkit-font-smoothing:antialiased;}
.wrap{max-width:560px;margin:0 auto}
.mono{font-family:'JetBrains Mono',ui-monospace,monospace;font-variant-numeric:tabular-nums}
/* header */
.brand{display:flex;align-items:center;gap:13px;margin:6px 2px 22px;
animation:rise .5s .02s both}
.brand .logo{width:46px;height:46px;border-radius:13px;display:grid;place-items:center;
background:linear-gradient(150deg,var(--panel-2),var(--panel));
border:1px solid var(--line);color:var(--accent);flex:none;
box-shadow:inset 0 1px 0 #ffffff10}
.brand .logo svg{width:25px;height:25px}
.brand h1{font-size:20px;font-weight:800;letter-spacing:-.01em}
.brand h1 .ac{color:var(--accent)}
.brand .sub{font-size:13px;font-weight:500;color:var(--muted);margin-top:1px}
.card{background:linear-gradient(180deg,var(--panel),var(--bg-soft));
border:1px solid var(--line);border-radius:20px;padding:20px;
box-shadow:0 30px 70px -34px #000c, inset 0 1px 0 #ffffff08;
animation:rise .5s .06s both}
/* film-strip timeline signature */
.timeline{margin:2px 0 20px}
.tl-track{position:relative;height:48px;border-radius:9px;overflow:hidden;
background:linear-gradient(180deg,#1c1510,#171008);border:1px solid var(--line)}
.tl-track::before,.tl-track::after{content:"";position:absolute;left:0;right:0;height:8px;
background-image:repeating-linear-gradient(90deg,#0c0805 0 9px,transparent 9px 20px)}
.tl-track::before{top:5px}.tl-track::after{bottom:5px}
.tl-seg{position:absolute;top:0;bottom:0;left:30%;right:30%;
background:linear-gradient(180deg,var(--accent-soft),#ff5c3910);
border-left:2px solid var(--accent);border-right:2px solid var(--accent)}
.tl-seg::before{content:"";position:absolute;inset:0;margin:auto;width:26px;height:26px;
border-radius:50%;background:#1a120c;border:1px solid var(--accent);
display:grid;place-items:center}
.tl-cut{position:absolute;inset:0;margin:auto;width:16px;height:16px;color:var(--accent);
z-index:1}
.tl-marks{display:flex;align-items:flex-end;justify-content:space-between;margin-top:11px}
.tl-pt{display:flex;flex-direction:column;gap:2px}
.tl-pt.out{align-items:flex-end}
.tl-pt b{font-size:10px;font-weight:700;letter-spacing:.16em;color:var(--faint)}
.tl-pt time{font-size:19px;font-weight:600;color:var(--text)}
/* fields */
.field{margin-top:15px}
.field>label{display:block;font-size:12px;font-weight:600;letter-spacing:.02em;
color:var(--muted);margin-bottom:7px}
.input-wrap{display:flex;align-items:center;gap:10px;background:var(--bg-soft);
border:1px solid var(--line);border-radius:12px;padding:0 13px;
transition:border-color .15s,box-shadow .15s}
.input-wrap:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
.input-wrap>svg{width:18px;height:18px;color:var(--faint);flex:none}
.input-wrap input{flex:1;min-width:0;background:none;border:0;outline:0;color:var(--text);
font-family:inherit;font-size:15px;padding:13px 0}
.input-wrap input::placeholder{color:var(--faint)}
.tc-row{display:flex;gap:12px}
.tc-row .field{flex:1;margin-top:15px}
.tc input{font-family:'JetBrains Mono',monospace;font-size:15px;letter-spacing:.03em}
.help{font-size:12px;color:var(--faint);margin-top:9px;display:flex;gap:6px;align-items:center}
.help svg{width:14px;height:14px;flex:none}
/* primary cut button */
.cut-btn{width:100%;margin-top:20px;display:flex;gap:9px;align-items:center;justify-content:center;
padding:15px;font-family:inherit;font-size:16px;font-weight:700;color:var(--accent-ink);
background:var(--accent);border:0;border-radius:13px;cursor:pointer;
box-shadow:0 8px 24px -8px #ff5c3977;
transition:filter .15s,box-shadow .2s,transform .1s}
.cut-btn:hover{filter:brightness(1.06);box-shadow:0 12px 30px -9px #ff5c3999}
.cut-btn:active{transform:translateY(1px)}
.cut-btn svg{width:20px;height:20px}
.cut-btn .spin{display:none;width:18px;height:18px;border-radius:50%;
border:2.5px solid #2a0d0455;border-top-color:var(--accent-ink);animation:spin .7s linear infinite}
.cut-btn.loading .ic{display:none}
.cut-btn.loading .spin{display:block}
/* result banner */
.banner{margin-top:18px;padding:14px 16px;border-radius:13px;border:1px solid;
display:flex;flex-direction:column;gap:10px;animation:rise .35s both}
.banner.ok{border-color:var(--ok-bd);background:var(--ok-bg)}
.banner.err{border-color:var(--err-bd);background:var(--err-bg)}
.banner .msg{font-size:14px;font-weight:600;white-space:pre-wrap;word-break:break-all}
.banner.ok .msg{color:var(--ok-tx)}
.banner.err .msg{color:var(--err-tx)}
.dl-big{align-self:flex-start;display:inline-flex;align-items:center;gap:8px;
padding:11px 17px;border-radius:11px;background:var(--accent);color:var(--accent-ink);
font-weight:700;font-size:14px;text-decoration:none;
box-shadow:0 8px 22px -10px #ff5c3988;transition:filter .15s,transform .1s}
.dl-big:hover{filter:brightness(1.06)} .dl-big:active{transform:translateY(1px)}
.dl-big svg{width:18px;height:18px}
.dl-name{font-family:'JetBrains Mono',monospace;font-size:11.5px;color:var(--ok-tx);
opacity:.8;word-break:break-all}
/* file list */
.files-sec{margin-top:26px;animation:rise .5s .1s both}
.files-head{display:flex;align-items:baseline;justify-content:space-between;
padding:0 2px 4px}
.files-head h2{font-size:15px;font-weight:700}
.files-head .count{font-family:'JetBrains Mono',monospace;font-size:12px;color:var(--muted)}
.files{list-style:none}
.file{display:flex;align-items:center;gap:12px;padding:12px 6px;
border-top:1px solid var(--line-soft)}
.file:first-child{border-top:0}
.file-ic{width:40px;height:40px;border-radius:11px;display:grid;place-items:center;
background:var(--bg-soft);border:1px solid var(--line);color:var(--faint);flex:none}
.file-ic svg{width:19px;height:19px}
.file-meta{flex:1;min-width:0}
.file-name{display:block;font-size:14px;font-weight:500;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.file-sub{font-family:'JetBrains Mono',monospace;font-size:11.5px;color:var(--faint);margin-top:3px}
.inline{display:inline;margin:0}
.iconbtn{width:40px;height:40px;flex:none;display:grid;place-items:center;border-radius:11px;
border:1px solid var(--line);background:var(--bg-soft);color:var(--muted);cursor:pointer;
transition:.15s}
.iconbtn svg{width:18px;height:18px}
.iconbtn.dl:hover{color:var(--accent);border-color:var(--accent)}
.iconbtn.del:hover{color:#fff;background:var(--danger);border-color:var(--danger)}
.empty{display:flex;flex-direction:column;align-items:center;gap:12px;
padding:36px 0 30px;color:var(--faint);text-align:center;font-size:13.5px}
.empty svg{width:34px;height:34px;opacity:.7}
/* loading overlay */
.overlay{position:fixed;inset:0;display:none;flex-direction:column;gap:16px;
align-items:center;justify-content:center;z-index:50;text-align:center;
background:rgba(15,11,8,.72);backdrop-filter:blur(6px)}
.overlay.show{display:flex}
.overlay .sp{width:42px;height:42px;border-radius:50%;
border:3px solid #ffffff1f;border-top-color:var(--accent);animation:spin .8s linear infinite}
.overlay p{font-size:14px;font-weight:600;color:var(--text)}
.overlay small{display:block;color:var(--muted);font-weight:400;margin-top:3px}
a,button{-webkit-tap-highlight-color:transparent}
:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:6px}
@keyframes spin{to{transform:rotate(360deg)}}
@keyframes rise{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}
@media (prefers-reduced-motion:reduce){
*{animation:none!important;transition:none!important}
}
"""
SCRIPT = """
(function(){
var s=document.getElementById('start'), e=document.getElementById('end');
var ti=document.getElementById('tcIn'), to=document.getElementById('tcOut');
function up(){ if(ti) ti.textContent=(s.value.trim()||'--:--');
if(to) to.textContent=(e.value.trim()||'--:--'); }
if(s&&e){ s.addEventListener('input',up); e.addEventListener('input',up); }
var f=document.getElementById('cutForm'), ov=document.getElementById('overlay'),
b=document.getElementById('cutBtn');
if(f){ f.addEventListener('submit',function(){
if(b) b.classList.add('loading'); if(ov) ov.classList.add('show'); }); }
})();
"""
HEAD = """<!doctype html><html lang="ko"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="theme-color" content="#17120E">
<title>ClipCut · 유튜브 구간 잘라받기</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap">
<style>__STYLE__</style></head>"""
def page(message: str = "", ok: bool = False, url: str = "",
start: str = "", end: str = "", download_name: str = "") -> str:
banner = ""
if message:
cls = "ok" if ok else "err"
dl = ""
if download_name:
href = "download?name=" + quote(download_name)
dl = (f'<a class="dl-big" href="{html.escape(href)}">{IC_DOWNLOAD}파일 받기</a>'
f'<span class="dl-name">{html.escape(download_name)}</span>')
banner = (f'<div class="banner {cls}"><span class="msg">{html.escape(message)}</span>'
f'{dl}</div>')
tc_in = html.escape(start) if start else "03:30"
tc_out = html.escape(end) if end else "06:20"
head = HEAD.replace("__STYLE__", STYLE)
return f"""{head}
<body>
<div class="overlay" id="overlay">
<div class="sp"></div>
<p>구간을 자르는 <small>영상 길이에 따라 ~ 걸려요</small></p>
</div>
<div class="wrap">
<header class="brand">
<span class="logo">{IC_FILM}</span>
<div>
<h1>Clip<span class="ac">Cut</span></h1>
<div class="sub">유튜브 구간을 잘라 바로 받기</div>
</div>
</header>
<main class="card">
<div class="timeline" aria-hidden="true">
<div class="tl-track">
<span class="tl-seg"></span>
<span class="tl-cut">{IC_SCISSORS}</span>
</div>
<div class="tl-marks">
<span class="tl-pt"><b>IN</b><time class="mono" id="tcIn">{tc_in}</time></span>
<span class="tl-pt out"><b>OUT</b><time class="mono" id="tcOut">{tc_out}</time></span>
</div>
</div>
{banner}
<form method="post" action="cut" id="cutForm">
<div class="field">
<label for="url">유튜브 URL</label>
<div class="input-wrap">{IC_LINK}
<input id="url" name="url" type="url" required
placeholder="https://www.youtube.com/watch?v=…" value="{html.escape(url)}">
</div>
</div>
<div class="tc-row">
<div class="field tc">
<label for="start">시작</label>
<div class="input-wrap">{IC_CLOCK}
<input id="start" name="start" required placeholder="03:30"
inputmode="numeric" value="{html.escape(start)}">
</div>
</div>
<div class="field tc">
<label for="end"></label>
<div class="input-wrap">{IC_CLOCK}
<input id="end" name="end" required placeholder="06:20"
inputmode="numeric" value="{html.escape(end)}">
</div>
</div>
</div>
<p class="help">{IC_CLOCK}형식: :(03:30) 또는 ::(01:03:30)</p>
<button class="cut-btn" id="cutBtn" type="submit">
<span class="ic">{IC_SCISSORS}</span><span class="spin"></span>잘라서 받기
</button>
</form>
</main>
{_file_list_html()}
</div>
<script>{SCRIPT}</script>
</body></html>"""
FAVICON = (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">'
'<rect width="24" height="24" rx="6" fill="#17120E"/>'
'<g fill="none" stroke="#FF5C39" stroke-width="1.7" stroke-linecap="round" '
'stroke-linejoin="round"><circle cx="7" cy="7" r="2.2"/><circle cx="7" cy="17" r="2.2"/>'
'<path d="M19 5 8.5 15.5M14 14l5 5M8.5 8.5 12 12"/></g></svg>'
)
@app.get("/favicon.svg")
@app.get("/favicon.ico")
def favicon() -> Response:
return Response(FAVICON, media_type="image/svg+xml")
@app.get("/", response_class=HTMLResponse)
def index() -> str:
return page()
@app.post("/cut", response_class=HTMLResponse)
def cut(url: str = Form(...), start: str = Form(...), end: str = Form(...)) -> str:
url, start, end = url.strip(), start.strip(), end.strip()
if not (url.startswith("http://") or url.startswith("https://")):
return page("올바른 URL이 아닙니다.", url=url, start=start, end=end)
if not TIME_RE.match(start) or not TIME_RE.match(end):
return page("시간 형식이 올바르지 않습니다. 예) 03:30 또는 01:03:30",
url=url, start=start, end=end)
section = f"*{start}-{end}"
suffix = f"{start}-{end}".replace(":", "")
outtmpl = f"{DOWNLOAD_DIR}/%(title)s_{suffix}.%(ext)s"
cmd = [
"yt-dlp",
"--download-sections", section,
"-f", "bv*+ba/b",
"--merge-output-format", "mp4",
"--no-playlist",
"--no-simulate", "--print", "after_move:filepath", # 최종 저장 경로 출력
"-o", outtmpl,
url,
]
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=TIMEOUT_SEC)
except subprocess.TimeoutExpired:
return page(f"처리 시간이 초과됐습니다(>{TIMEOUT_SEC // 60}분).",
url=url, start=start, end=end)
if proc.returncode != 0:
tail = (proc.stderr or proc.stdout or "").strip()[-1500:]
return page(f"실패했습니다.\n\n{tail}", url=url, start=start, end=end)
final_path = ""
for line in (proc.stdout or "").splitlines():
line = line.strip()
if line.startswith(DOWNLOAD_DIR + "/"):
final_path = line
download_name = os.path.basename(final_path) if final_path else ""
return page(f"완료! {start} ~ {end} 구간을 잘랐어요.", ok=True,
download_name=download_name)
@app.get("/download")
def download(name: str) -> FileResponse:
path = _safe_name(name)
if not os.path.isfile(path):
raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다")
return FileResponse(path, filename=name, media_type="application/octet-stream")
@app.post("/delete")
def delete(name: str = Form(...)) -> RedirectResponse:
path = _safe_name(name)
if os.path.isfile(path):
os.remove(path)
return RedirectResponse("/", status_code=303)

View File

@ -0,0 +1,4 @@
fastapi
uvicorn[standard]
yt-dlp
python-multipart

View File

@ -0,0 +1,18 @@
services:
ytcut:
build:
context: .
dockerfile: Dockerfile
container_name: ytcut
restart: unless-stopped
user: "1000:1000"
ports:
- "8906:8000" # 왼쪽 8906 = 호스트 포트(원하면 변경)
environment:
- TZ=Asia/Seoul
- HOME=/tmp
- PYTHONDONTWRITEBYTECODE=1
volumes:
- ./app:/app # 소스 (uvicorn 없이도 코드 수정 반영)
- ./downloads:/downloads # 잘라낸 mp4가 저장되는 곳
command: uvicorn main:app --host 0.0.0.0 --port 8000

View File

View File

@ -0,0 +1,39 @@
@echo off
chcp 65001 >nul
title CapCut Agent (Multi-Range)
cd /d "%~dp0"
echo.
echo ============================================
echo CapCut Agent - Multi-Range (v2)
echo ============================================
echo.
echo URL : http://127.0.0.1:8001
echo - One URL + multiple time ranges, merged into one draft.
echo - Browser opens automatically in a few seconds.
echo - KEEP THIS WINDOW OPEN. Close it to stop the server.
echo.
REM Python 확인
where python >nul 2>nul
if errorlevel 1 (
echo [ERROR] python not found on PATH.
echo Install Python from python.org and re-run.
echo.
pause
exit /b 1
)
REM 잠시 후 브라우저 열기(서버 뜬 다음). 분리 실행. (v1과 다른 포트 8001 → 동시 실행 가능)
start "" powershell -NoProfile -WindowStyle Hidden -Command "Start-Sleep 3; Start-Process 'http://127.0.0.1:8001'"
REM 서버를 이 창에서 실행(로그/에러가 보임)
python -m uvicorn server.app:app --port 8001
echo.
echo ============================================
echo Server stopped.
echo ============================================
echo (If it failed instantly, read the error above.)
echo.
pause

6
프롬프트/설정.json Normal file
View File

@ -0,0 +1,6 @@
{
"model": "gemini-3.5-flash",
"model_step1": "",
"fps_step1": 0.2,
"fps_step3": 1.0
}

View File

@ -0,0 +1,17 @@
당신은 유튜브 영상에서 쇼츠(Shorts)로 제작했을 때 가장 터질 만한 구간을 찾아내는 '바이럴 분석가'입니다. 제공된 영상을 분석해 아래 규칙에 따라 5개의 하이라이트 후보 구간을 선정하세요.
1. 각 구간의 길이는 최소 1분 30초에서 최대 3분 사이로 설정할 것. (최종 편집을 위한 원천 소스 구간임)
2. 시청자의 시선을 끌 수 있는 갈등, 웃음, 반전, 혹은 핵심 정보가 포함된 구간을 우선순위로 둘 것.
3. 결과물은 반드시 아래의 JSON 형식으로만 출력할 것. (다른 설명 금지)
{
"candidates": [
{
"id": 1,
"start_time": "MM:SS",
"end_time": "MM:SS",
"reason": "구간 선정 이유 요약"
},
... (총 5개)
]
}