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:
commit
bf1b387d6d
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
@ -0,0 +1,17 @@
|
||||
# 비밀키 — 절대 커밋 금지
|
||||
.gemini_key
|
||||
|
||||
# 작업 산출물·캐시 (용량이 크고 재생성됨)
|
||||
.downloads/
|
||||
.comments/
|
||||
.uploads/
|
||||
.media/
|
||||
.cache/
|
||||
댓글카드/
|
||||
|
||||
# 파이썬
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# 서브에이전트 작업 공간 (superpowers SDD)
|
||||
.superpowers/
|
||||
330
ARCHITECTURE.md
Normal file
330
ARCHITECTURE.md
Normal 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
84
CLAUDE.md
Normal 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
139
README.md
Normal 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
316
SETUP.md
Normal 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
BIN
assets/bg_template.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
assets/bg_white.png
Normal file
BIN
assets/bg_white.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
BIN
assets/frame_template.png
Normal file
BIN
assets/frame_template.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
BIN
assets/frame_template_white.png
Normal file
BIN
assets/frame_template_white.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
92
build_bg_template.py
Normal file
92
build_bg_template.py
Normal 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
55
build_jumpcut.py
Normal 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
72
build_letterbox.py
Normal 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
89
build_shortform.py
Normal 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
77
build_template.py
Normal 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
1
capcut_agent/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""캡컷 에이전트 — 한국어 토킹 영상 자동 편집기 (트랙 C / Windows)."""
|
||||
99
capcut_agent/comments.py
Normal file
99
capcut_agent/comments.py
Normal 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
139
capcut_agent/correct.py
Normal 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
869
capcut_agent/draft.py
Normal file
@ -0,0 +1,869 @@
|
||||
"""pycapcut 으로 점프컷 CapCut 드래프트 생성.
|
||||
|
||||
함정 회피:
|
||||
- Timerange 는 정수 µs 로 직접 구성 (float→tim 은 '초'가 아니라 µ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
404
capcut_agent/highlight.py
Normal 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
171
capcut_agent/media.py
Normal 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
127
capcut_agent/paste.py
Normal 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
535
capcut_agent/pipeline.py
Normal 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
181
capcut_agent/plan.py
Normal 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
41
capcut_agent/probe.py
Normal 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
102
capcut_agent/prompts.py
Normal 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
69
capcut_agent/scene.py
Normal 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
90
capcut_agent/silence.py
Normal 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
129
capcut_agent/transcribe.py
Normal 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
442
capcut_agent/youtube.py
Normal 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
|
||||
1486
docs/superpowers/plans/2026-07-31-자동탭-오팔대체.md
Normal file
1486
docs/superpowers/plans/2026-07-31-자동탭-오팔대체.md
Normal file
File diff suppressed because it is too large
Load Diff
1279
docs/superpowers/plans/2026-08-04-컷별-댓글-추천.md
Normal file
1279
docs/superpowers/plans/2026-08-04-컷별-댓글-추천.md
Normal file
File diff suppressed because it is too large
Load Diff
339
docs/superpowers/specs/2026-07-31-자동탭-오팔대체-댓글자동매칭-design.md
Normal file
339
docs/superpowers/specs/2026-07-31-자동탭-오팔대체-댓글자동매칭-design.md
Normal 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. 모든 컷이 `[start−10, 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 기본값을 그 결과로 조정한다.
|
||||
177
docs/superpowers/specs/2026-08-04-컷별-댓글-추천-design.md
Normal file
177
docs/superpowers/specs/2026-08-04-컷별-댓글-추천-design.md
Normal 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
16
requirements.txt
Normal 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
0
server/__init__.py
Normal file
719
server/app.py
Normal file
719
server/app.py
Normal 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
713
server/static/auto.js
Normal 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
861
server/static/index.html
Normal 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='{ "url": "https://www.youtube.com/watch?v=…", "title_top": "서브제목", "title_main": "메인제목", "channel": "@채널", "cuts": [ {"start":"0:01.0","end":"0:03.5","bottom":"하단 자막\n두 줄","effect":"광속하강"}, {"start":"2:33.5","end":"2:36.5","bottom":"다음 컷 자막","effect":"공포의통계"} ] }'></textarea>
|
||||
</div>
|
||||
<div 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개를 순서대로 전부 붙여넣으세요. 블록 ②·③ 텍스트가 섞여 들어와도 자동으로 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=>({"&":"&","<":"<",">":">",'"':"""}[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>
|
||||
14
server/static/modern-screenshot.js
Normal file
14
server/static/modern-screenshot.js
Normal file
File diff suppressed because one or more lines are too long
219
레이어_삐짐_수리.md
Normal file
219
레이어_삐짐_수리.md
Normal 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`을 잠그면 사용자가 편집을 못 한다.
|
||||
121
숏폼_편집_지침서_v13.7_capcut2연동판.md
Normal file
121
숏폼_편집_지침서_v13.7_capcut2연동판.md
Normal 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선 → ③ 컷 길이 검산표)에 맞춘 결과물을 출력해 주십시오.
|
||||
370
작업기록_2026-07-27~31.md
Normal file
370
작업기록_2026-07-27~31.md
Normal 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
13
참고/ytcut/Dockerfile
Normal 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
55
참고/ytcut/README.md
Normal 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
467
참고/ytcut/app/main.py
Normal 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)
|
||||
4
참고/ytcut/app/requirements.txt
Normal file
4
참고/ytcut/app/requirements.txt
Normal file
@ -0,0 +1,4 @@
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
yt-dlp
|
||||
python-multipart
|
||||
18
참고/ytcut/docker-compose.yml
Normal file
18
참고/ytcut/docker-compose.yml
Normal 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
|
||||
0
참고/ytcut/downloads/.gitkeep
Normal file
0
참고/ytcut/downloads/.gitkeep
Normal file
39
캡컷_에이전트_구간합치기.bat
Normal file
39
캡컷_에이전트_구간합치기.bat
Normal 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
6
프롬프트/설정.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"model": "gemini-3.5-flash",
|
||||
"model_step1": "",
|
||||
"fps_step1": 0.2,
|
||||
"fps_step3": 1.0
|
||||
}
|
||||
17
프롬프트/하이라이트_선정.md
Normal file
17
프롬프트/하이라이트_선정.md
Normal 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개)
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user