From bf1b387d6d50f12d07a8974e8c1831a2952f0f0f Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Tue, 4 Aug 2026 11:36:05 +0900 Subject: [PATCH] =?UTF-8?q?chore:=20git=20=EC=A0=80=EC=9E=A5=EC=86=8C=20?= =?UTF-8?q?=EC=B4=88=EA=B8=B0=ED=99=94=20(=EA=B8=B0=EC=A1=B4=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=8A=A4=EB=83=85=EC=83=B7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 컷별 댓글 추천 작업을 태스크 단위로 되돌릴 수 있게 버전관리를 시작한다. .gitignore 로 영상·캐시(.downloads 2.7G, .comments 72M, .media 28M)와 비밀키(.gemini_key)를 제외했다. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 17 + ARCHITECTURE.md | 330 ++++ CLAUDE.md | 84 + README.md | 139 ++ SETUP.md | 316 ++++ assets/bg_template.png | Bin 0 -> 15916 bytes assets/bg_white.png | Bin 0 -> 10406 bytes assets/frame_template.png | Bin 0 -> 10589 bytes assets/frame_template_white.png | Bin 0 -> 10725 bytes build_bg_template.py | 92 + build_jumpcut.py | 55 + build_letterbox.py | 72 + build_shortform.py | 89 + build_template.py | 77 + capcut_agent/__init__.py | 1 + capcut_agent/comments.py | 99 ++ capcut_agent/correct.py | 139 ++ capcut_agent/draft.py | 869 ++++++++++ capcut_agent/highlight.py | 404 +++++ capcut_agent/media.py | 171 ++ capcut_agent/paste.py | 127 ++ capcut_agent/pipeline.py | 535 ++++++ capcut_agent/plan.py | 181 ++ capcut_agent/probe.py | 41 + capcut_agent/prompts.py | 102 ++ capcut_agent/scene.py | 69 + capcut_agent/silence.py | 90 + capcut_agent/transcribe.py | 129 ++ capcut_agent/youtube.py | 442 +++++ .../plans/2026-07-31-자동탭-오팔대체.md | 1486 +++++++++++++++++ .../plans/2026-08-04-컷별-댓글-추천.md | 1279 ++++++++++++++ ...-31-자동탭-오팔대체-댓글자동매칭-design.md | 339 ++++ .../specs/2026-08-04-컷별-댓글-추천-design.md | 177 ++ requirements.txt | 16 + server/__init__.py | 0 server/app.py | 719 ++++++++ server/static/auto.js | 713 ++++++++ server/static/index.html | 861 ++++++++++ server/static/modern-screenshot.js | 14 + 레이어_삐짐_수리.md | 219 +++ 배경.png | Bin 0 -> 16019 bytes 숏폼_편집_지침서_v13.7_capcut2연동판.md | 121 ++ 작업기록_2026-07-27~31.md | 370 ++++ 참고/ytcut/Dockerfile | 13 + 참고/ytcut/README.md | 55 + 참고/ytcut/app/main.py | 467 ++++++ 참고/ytcut/app/requirements.txt | 4 + 참고/ytcut/docker-compose.yml | 18 + 참고/ytcut/downloads/.gitkeep | 0 캡컷_에이전트_구간합치기.bat | 39 + 프롬프트/설정.json | 6 + 프롬프트/하이라이트_선정.md | 17 + 52 files changed, 11603 insertions(+) create mode 100644 .gitignore create mode 100644 ARCHITECTURE.md create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 SETUP.md create mode 100644 assets/bg_template.png create mode 100644 assets/bg_white.png create mode 100644 assets/frame_template.png create mode 100644 assets/frame_template_white.png create mode 100644 build_bg_template.py create mode 100644 build_jumpcut.py create mode 100644 build_letterbox.py create mode 100644 build_shortform.py create mode 100644 build_template.py create mode 100644 capcut_agent/__init__.py create mode 100644 capcut_agent/comments.py create mode 100644 capcut_agent/correct.py create mode 100644 capcut_agent/draft.py create mode 100644 capcut_agent/highlight.py create mode 100644 capcut_agent/media.py create mode 100644 capcut_agent/paste.py create mode 100644 capcut_agent/pipeline.py create mode 100644 capcut_agent/plan.py create mode 100644 capcut_agent/probe.py create mode 100644 capcut_agent/prompts.py create mode 100644 capcut_agent/scene.py create mode 100644 capcut_agent/silence.py create mode 100644 capcut_agent/transcribe.py create mode 100644 capcut_agent/youtube.py create mode 100644 docs/superpowers/plans/2026-07-31-자동탭-오팔대체.md create mode 100644 docs/superpowers/plans/2026-08-04-컷별-댓글-추천.md create mode 100644 docs/superpowers/specs/2026-07-31-자동탭-오팔대체-댓글자동매칭-design.md create mode 100644 docs/superpowers/specs/2026-08-04-컷별-댓글-추천-design.md create mode 100644 requirements.txt create mode 100644 server/__init__.py create mode 100644 server/app.py create mode 100644 server/static/auto.js create mode 100644 server/static/index.html create mode 100644 server/static/modern-screenshot.js create mode 100644 레이어_삐짐_수리.md create mode 100644 배경.png create mode 100644 숏폼_편집_지침서_v13.7_capcut2연동판.md create mode 100644 작업기록_2026-07-27~31.md create mode 100644 참고/ytcut/Dockerfile create mode 100644 참고/ytcut/README.md create mode 100644 참고/ytcut/app/main.py create mode 100644 참고/ytcut/app/requirements.txt create mode 100644 참고/ytcut/docker-compose.yml create mode 100644 참고/ytcut/downloads/.gitkeep create mode 100644 캡컷_에이전트_구간합치기.bat create mode 100644 프롬프트/설정.json create mode 100644 프롬프트/하이라이트_선정.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..932dfcf --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# 비밀키 — 절대 커밋 금지 +.gemini_key + +# 작업 산출물·캐시 (용량이 크고 재생성됨) +.downloads/ +.comments/ +.uploads/ +.media/ +.cache/ +댓글카드/ + +# 파이썬 +__pycache__/ +*.pyc + +# 서브에이전트 작업 공간 (superpowers SDD) +.superpowers/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..d81f564 --- /dev/null +++ b/ARCHITECTURE.md @@ -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/` +- ⚠ **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를 재사용(네트워크 불필요). diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c9eee8e --- /dev/null +++ b/CLAUDE.md @@ -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가 깨져 보일 수 있음(로직과 무관). diff --git a/README.md b/README.md new file mode 100644 index 0000000..6adb131 --- /dev/null +++ b/README.md @@ -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 영상 메타 조회 +``` diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..eea49b3 --- /dev/null +++ b/SETUP.md @@ -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=&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 +``` diff --git a/assets/bg_template.png b/assets/bg_template.png new file mode 100644 index 0000000000000000000000000000000000000000..5fa6fbf860448735e8977c598f1d050bb30398a7 GIT binary patch literal 15916 zcmeAS@N?(olHy`uVBq!ia0y~yV6k9eU~k}H1Bz%TJ_kxL+GjdD2V^EEGcZ)lshzOj zo2gLZnEmz8X%W1ZCL%JbzN~@X!HsQxfMAPY?oDT(@_bLAz+EYku5F@xKetvWt=t(G@bS`l zh2&j|XP$Z(a4CG5WRRnMo`3KC#V0Dw_pTGaY`y;a>XWQXcUcGavD$~}{fK{8;wEuG z{DHC0X*O5pW8T{|D>*+%y{?j)VquVaRM<1%+miX&A3L9fbnB}|Uo2plBDLVDmBrPA zt$()d(N^96J*8_S#|8EtB@W?3|J|2upUEkZz?Krfzgpnt&YRb!r3+TglX;#i6w`je zqG-$YUHk1{8tb#y3*@%!yC}(7N!3<CJ_t_ z3Jjhujv*Cu-rU&8$>1QsbnyND%>5k9mNQyU_SU}N3sSo0_A`(p7=mW;gD8(oI}oL? z^e2dFm@+Ck8VK}D2^nX>$#;o~Jjl)omuf(igV$$Jeq+!al^hKO`lp2K(E@R_RsfYh zgQseY8Z8hl(A0Jq4i5+`8SjuXCvoly~?)ig?V5xJX#*Bu|Xu28A45J0bXdw!! zBSve6(OPq~MKRh;8f`+3Hm^tf44_ugXn$&Gb`pPq=OhgUm>s|0|6&{8%%nJ@v9!iQ z1UUV9{s3r{|BP_~Xo%}Gj|`AJV8aZW85)%u4UN&XL27pJpHvbBT%&Ltv=(4ALyeXp zqh-iw8A56qGFonomK&qx#%Q@QT5gPXOomKf?ET*F|J)&yt!=C26(Ex+iC>w)BPWye z*3>nCS(jR`@{%IU-@&H?rcpHE80xTfQo`Ut*a6$G;&CgX!m7-F;7wqUZ@r_Cj+ zX>DK{V;?~9|2gDW4xGcy9j<)l?PfI;cnnAi*)^*xd5e5*-fd(-vr zIqy;`c*q_0m;a%YoXUMGzKbUNc>$2q7LXbGXtp;#y~R|8f->6qhTkS8evBpv1*WCT)AMmeKlFq#fV^TKF(Fj^*#)(fN6 y;b@a!v{^LTG#qUnk9HA8dq$%@Bg%S4_t?)`a(Vvhc*+hmhr!d;&t;ucLK6TZuZY9| literal 0 HcmV?d00001 diff --git a/assets/frame_template_white.png b/assets/frame_template_white.png new file mode 100644 index 0000000000000000000000000000000000000000..38bb28e0157de3ae1bbe77f4374847a9e90768fb GIT binary patch literal 10725 zcmeAS@N?(olHy`uVBq!ia0y~yV6k9eU~k}H1Bz%TJ_kx@uJLqn45^s&_Qpou0|q>%j1(E4#&zONKId

1X2v^dH8^&!ubav&Zkk%Xc&y9gVDS&S{{s+ ziKF$xXmto{5`Y>9qhK@yMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V&~En9Z3v^DPI^ z?+xPR)}SDGF!dm4K=p~F43Kn~H_90egVA&_niodPgV8c^v|bpk4o8~=qs^kxrr~Jw yc(jW!+A|vM8IAUgMter1J)_Z{(P+=;9{ [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

+``` + +- [ ] **Step 3: 댓글 폴더 필드에 id 부여** + +``를 감싸는 `
`를 +`
`로 수정. + +- [ ] **Step 4: setMode 확장** + +`setMode` 함수를 아래로 교체 (auto 분기 + run 버튼·cdir 숨김): + +```javascript +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"; // 자동 탭은 자체 버튼 사용 +} +``` + +그리고 탭 리스너 3줄 아래에 추가: + +```javascript +$("#tab-auto").addEventListener("click",()=>setMode("auto")); +``` + +- [ ] **Step 5: 카드 CSS + 스크립트 로드** + +`` 직전에 카드 CSS 추가 — h-lab `comment-cards.html`의 카드 스타일에서 +고정 조합(bg-black·rounded·mosaic)의 최종값을 이식. 선택 표시는 **래퍼**에 그려 +캡처 대상(.comment-card)에 안 찍히게 한다: + +```css + /* ── 자동 탭: 댓글 카드 (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;} + .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);} + /* 검토 화면 */ + .hlbox{margin-top:14px;padding:16px;background:var(--card,#141414);border:1px solid var(--border);border-radius:10px;} + .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:10px;color:var(--muted2);font-size:12.5px;} + .hlbox select{background:#121212;border:1px solid var(--border);border-radius:7px; + color:var(--text);font-size:12.5px;padding:7px 9px;max-width:100%;} + .hlbox .hlprog{margin-top:8px;font-family:var(--mono);font-size:12px;color:var(--muted2);white-space:pre-line;} + .hlbox.err{border-color:var(--danger,#b33);} +``` + +`` 직전(기존 `` 다음)에: + +```html + + +``` + +- [ ] **Step 6: 검증** + +``` +python -c "from server import app; import server.app as m; \ +html=open('server/static/index.html',encoding='utf-8').read(); \ +ids=['tab-auto','panel-auto','autoUrl','autoGo','autoSteps','autoReview','autoBuild','cdirField','pStep1','pConfig']; \ +missing=[i for i in ids if ('id=\"'+i+'\"') not in html]; assert not missing, missing; print('OK')" +``` +Expected: `OK`. (auto.js가 아직 없어도 브라우저 404일 뿐 — Task 9에서 생성) + +--- + +### Task 9: `server/static/auto.js` — 검토 화면·캡처·순차 빌드 + +**Files:** +- Create: `server/static/auto.js` + +**Interfaces:** +- Consumes: Task 6 엔드포인트, Task 8 DOM id, `window.modernScreenshot.domToBlob` +- Produces: 없음(말단). `/auto/build`의 `data` 필드는 **붙여넣기 탭 스키마**로 변환해 보낸다 — 서버가 준 `paste.cuts`는 이미 `{start,end,bottom,effect}` 객체 배열이므로 그대로 직렬화하면 된다(`parse_time`이 숫자도 허용). + +- [ ] **Step 1: 파일 작성** + +```javascript +/* 자동 탭 — 분석(SSE) → 검토(카드 선택) → 순차 빌드. + 카드 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 먼저) + +/* ── 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");} + +/* ── 카드 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= + '
'+ + ''+ + '
'+ + '
'+ + '
'+ + '
👍 '+(c.likeCount||0).toLocaleString()+''+ + '💬 '+(c.replyCount||0).toLocaleString()+'
'+ + '
'; + 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(//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,wrap)); + return wrap; +} +function toggle(hlId,idx,wrap){ + const hl=A.highlights.find(h=>h.id===hlId); + const list=sel[hlId]; + const at=list.indexOf(idx); + if(at>=0){list.splice(at,1);wrap.classList.remove("sel");} + else{ + if(list.length>=hl.need){return;} // 필요 장수 초과 선택 방지 + list.push(idx);wrap.classList.add("sel"); + } + updateCount(hlId); +} +function updateCount(hlId){ + const hl=A.highlights.find(h=>h.id===hlId); + const el=$("#hlcnt-"+hlId); + if(el) el.textContent=sel[hlId].length+" / "+hl.need+"장 선택"; +} + +/* ── 분석 ── */ +async function analyze(){ + const url=$("#autoUrl").value.trim(); + if(!url){alert("유튜브 URL을 입력하세요.");return;} + $("#autoGo").disabled=true;$("#autoGo").textContent="분석 중…"; + $("#autoReview").innerHTML="";$("#autoSummary").style.display="none"; + $("#autoBuild").style.display="none";$("#autoLog").innerHTML=""; + let res; + try{ + const fd=new FormData();fd.append("url",url); + 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;$("#autoGo").textContent="다시 분석";} +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=> + '
'+ + '
'+esc(s.label)+'
'+ + '
').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;}} +} + +/* ── 검토 화면 ── */ +function onResult(ev){ + A=ev;byIdx={};sel={}; + (ev.comments||[]).forEach(c=>{byIdx[c.idx]=c;}); + (ev.warnings||[]).forEach(w=>alog("⚠️ "+w)); + const R=$("#autoReview");R.innerHTML=""; + let ok=0; + for(const hl of ev.highlights){ + const box=document.createElement("div"); + if(hl.error){ + box.className="hlbox err"; + box.innerHTML="

ID "+hl.id+" · "+fmtT(hl.start)+"~"+fmtT(hl.end)+"

"+ + '
생성 실패: '+esc(hl.error)+"
"; + R.appendChild(box);continue; + } + ok++; + 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="

ID "+hl.id+" · "+fmtT(hl.start)+"~"+fmtT(hl.end)+ + " "+esc(hl.reason||"")+"

"+ + '
컷 '+cuts.length+"개 · 총 "+hl.total+"초 · 카드 "+hl.need+"장 필요 · "+ + '
'; + // 타이틀 선택(후보 5선). 첫 항목 = Gemini 최종 선택값 + const t0={top:hl.paste.title_top,main:hl.paste.title_main,kind:"최종 선택"}; + const opts=[t0].concat((hl.titles||[]).filter(t=>t.top!==t0.top||t.main!==t0.main)); + html+='
제목:
"; + html+='
자막 미리보기: '+ + esc(cuts.slice(0,3).map(c=>(c.bottom||"").split("\n")[0]).filter(Boolean).join(" · "))+" …
"; + box.innerHTML=html; + box.dataset.titles=JSON.stringify(opts); + // ⭐ 구간 언급 댓글 + const sec1=document.createElement("div");sec1.className="hlsec"; + sec1.innerHTML="⭐ 이 구간을 언급한 댓글 "+(hl.matched||[]).length+"장 (자동 선택됨)
"; + for(const i of (hl.matched||[])){ if(byIdx[i]===undefined) continue; + const w=cardEl(byIdx[i],hl.id); + if(sel[hl.id].includes(i)) w.classList.add("sel"); + sec1.appendChild(w); + } + box.appendChild(sec1); + // ➕ 좋아요 상위 후보 + const cand=(hl.candidates||[]).filter(i=>byIdx[i]!==undefined); + if(cand.length){ + const sec2=document.createElement("div");sec2.className="hlsec"; + sec2.innerHTML="➕ 좋아요 상위 후보 (부족분 클릭)
"; + for(const i of cand) sec2.appendChild(cardEl(byIdx[i],hl.id)); + box.appendChild(sec2); + } + const prog=document.createElement("div");prog.className="hlprog";prog.id="hlprog-"+hl.id; + box.appendChild(prog); + R.appendChild(box); + updateCount(hl.id); + } + if(ok){ + $("#autoBuild").style.display="block"; + $("#autoBuild").textContent=ok+"개 전부 만들기"; + } +} + +/* ── 캡처 + 순차 빌드 ── */ +async function captureCard(wrap){ + const card=wrap.querySelector(".comment-card"); + return await window.modernScreenshot.domToBlob(card,{backgroundColor:null,scale:4}); +} +async function buildAll(){ + const btn=$("#autoBuild");btn.disabled=true; + const hls=A.highlights.filter(h=>!h.error); + let ok=0,fail=0; + for(const hl of hls){ + const prog=$("#hlprog-"+hl.id); + try{ + // 선택 타이틀 반영 + const opts=JSON.parse($("#hlbox-"+hl.id).dataset.titles); + const 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"); + let n=0; + for(const idx of sel[hl.id]){ + const wrap=$("#hlbox-"+hl.id).querySelector('.ccwrap[data-cidx="'+idx+'"]'); + if(!wrap) continue; + prog.textContent="카드 캡처 중… "+(++n)+"/"+sel[hl.id].length; + try{ + const blob=await captureCard(wrap); + fd.append("cards",blob,String(n).padStart(3,"0")+".png"); + }catch(e){prog.textContent="카드 1장 캡처 실패(건너뜀)";} + } + prog.textContent="빌드 요청 중…"; + const res=await(await fetch("/auto/build",{method:"POST",body:fd})).json(); + if(res.error) throw new Error(res.error); + await streamJob(res.job_id,prog); // 완료/실패까지 대기(순차) + prog.textContent="✅ 완료";ok++; + }catch(e){ + prog.textContent="❌ 실패: "+(e.message||e);fail++; + } + } + btn.disabled=false;btn.textContent="다시 만들기"; + const s=$("#autoSummary");s.style.display="block"; + s.textContent=ok+"개 성공"+(fail?", "+fail+"개 실패":"")+ + " — CapCut 프로젝트 목록에서 auto_… 드래프트를 여세요."; + if(ok&&$("#autoopen")&&$("#autoopen").checked) + fetch("/open-capcut",{method:"POST"}); +} +function streamJob(jobId,prog){ + return new Promise((resolve,reject)=>{ + const es=new EventSource("/stream/"+jobId); + es.onmessage=(m)=>{ + const ev=JSON.parse(m.data); + if(ev.type==="log") prog.textContent=ev.msg; + else if(ev.type==="step"&&ev.status==="start") prog.textContent="▶ "+ev.id; + 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("연결 끊김"));}; + }); +} + +/* ── ⚙ 지침·모델 설정 ── */ +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();}); + $("#autoBuild").addEventListener("click",buildAll); + $("#autoSettings").addEventListener("toggle",()=>{if($("#autoSettings").open)loadPrompts();}); + $("#pSave").addEventListener("click",()=>savePrompts(false)); + $("#pReset").addEventListener("click",()=>savePrompts(true)); +}); +})(); +``` + +- [ ] **Step 2: 구문 검증 (Node)** + +``` +node --check server/static/auto.js +``` +Expected: 출력 없음(성공). (Node가 yt-dlp 런타임으로 이미 설치돼 있음) + +- [ ] **Step 3: 서버 기동 스모크 (수동)** + +`.bat` 재시작 후 브라우저에서: +1. 🤖 자동 탭이 보이고, 클릭 시 제목 입력·카드 폴더·"편집 시작" 버튼이 숨는가 +2. ⚙ 지침·모델 수정을 펼치면 프롬프트 3칸이 채워지는가 (`/prompts` 확인) +3. 저장 → `프롬프트/설정.json` 파일이 바뀌는가, 기본값 복원이 동작하는가 +4. 기존 3개 탭이 이전과 똑같이 동작하는가 (회귀 확인) + +--- + +### Task 10: 통합 검증 (실영상 1회전) + +**Files:** 없음 (검증만) + +- [ ] **Step 1: 전체 구문·임포트 최종 확인** + +``` +python -c "import ast; [ast.parse(open(f, encoding='utf-8').read()) for f in ['capcut_agent/plan.py','capcut_agent/comments.py','capcut_agent/prompts.py','capcut_agent/pipeline.py','server/app.py']]; print('OK')" +python -c "from server import app; print('OK')" +``` + +- [ ] **Step 2: 실영상 분석 1회 (수동, 네트워크·Gemini 키 필요)** + +`.bat` 실행 → 자동 탭에 실제 유튜브 URL → 분석 시작. 확인: +1. Step1/Step3/댓글 3단계가 순서대로 done 되는가 +2. 하이라이트 박스에 컷 수·총길이·필요 장수가 뜨고, ⭐ 댓글이 자동 선택돼 있는가 +3. 타이틀 드롭다운에 후보가 여러 개 보이는가 +4. "N개 전부 만들기" → 순차로 드래프트 생성, 각 박스에 진행 표시 +5. 첫 실행 소요 시간을 기록 (스펙 §11 — fps 기본값 조정 판단 자료) + +- [ ] **Step 3: 드래프트 파일 확인** + +생성된 드래프트의 `draft_content.json`을 열어: +1. 드래프트 이름이 `<제목>_컷_하이라이트` 형태로 서로 다른가 (Task 2) +2. 댓글 카드 세그먼트 시간이 균등 분배됐는가 — 카드가 `need`보다 적으면 간격이 3초보다 큰가 (Task 1) +3. 마지막 카드 끝 = 영상 총길이인가 + +- [ ] **Step 4: CapCut에서 열어 최종 확인 (사용자)** + +CapCut에서 드래프트 5개를 열어 컷·자막·댓글 카드 위치 확인. 테스트 드래프트는 확인 후 삭제. + +--- + +## Self-Review 결과 (계획 작성 시점) + +- 스펙 §2~§8 전 항목이 Task 1~10에 매핑됨. §4.3의 5개 엔드포인트 = Task 6, §6 = Task 1, name_suffix = Task 2, 타임코드 보정 = Task 5. +- `paste.cuts` 직렬화 형식: 서버 result에서 이미 `{start,end,bottom,effect}` 객체 배열로 변환해 내려주므로(Task 6 Step 2) auto.js는 그대로 stringify → `/auto/build`의 `parse_paste`가 재검증. 타입 일관 확인. +- `need` 계산식 `max(1, int(total // 3))`이 Task 1의 `max(1, int(dur // min_sec))`와 동일식임을 확인. +- 커밋 단계 없음(git 저장소 아님) — 각 태스크의 검증 명령이 게이트. diff --git a/docs/superpowers/plans/2026-08-04-컷별-댓글-추천.md b/docs/superpowers/plans/2026-08-04-컷별-댓글-추천.md new file mode 100644 index 0000000..3d0a7eb --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-컷별-댓글-추천.md @@ -0,0 +1,1279 @@ +# 컷별 댓글 추천 · 컷 위치 배치 구현 계획 + +> **에이전트 작업자용:** 이 계획은 `superpowers:subagent-driven-development`(권장) 또는 +> `superpowers:executing-plans`로 태스크 단위로 실행한다. 단계는 체크박스(`- [ ]`)로 추적한다. + +**목표:** 자동 탭 검토 화면의 댓글을 컷별로 추천하고, 선택한 카드를 그 컷 구간 위에 깐다. + +**접근:** 컷이 있는 모드(`full`/`paste`)는 컷 자막으로 Gemini 추천 + 타임스탬프 우선. +통짜 모드(`whole`/`wpaste`)는 Gemini 없이 시각 슬롯으로 배정. 카드 시간은 서버가 확정하지 않고 +"몇 번 컷 소속"만 넘겨 파이프라인이 `placements`로 계산한다(무음 제거 재매핑 때문). + +**기술 스택:** Python 3.13 / FastAPI / 표준 라이브러리 `urllib`(Gemini 호출) / 바닐라 JS + +**스펙:** `docs/superpowers/specs/2026-08-04-컷별-댓글-추천-design.md` + +## Global Constraints + +- **코드를 고쳤으면 `캡컷_에이전트_구간합치기.bat`을 반드시 재시작한다.** uvicorn hot-reload가 + 없어 검은 창을 닫고 다시 실행해야 반영된다. "안 돼요"의 가장 흔한 원인. +- **이 프로젝트는 git 저장소가 아니다.** 커밋 단계가 없다. 각 태스크는 인라인 assert 통과 + + 구문/임포트 검증으로 끝낸다. +- **테스트 프레임워크가 없다.** pytest를 도입하지 않는다. 검증은 `python - <<'PY' … PY` + 인라인 assert 스크립트로 한다(기존 계획 문서와 같은 방식). +- Windows 콘솔은 cp949라 한글 print가 깨진다. 검증 스크립트 첫 줄에 + `import sys; sys.stdout.reconfigure(encoding='utf-8')`를 넣는다. +- **자막 타이밍 원칙을 건드리지 않는다.** Whisper 단어 타임스탬프 = 시간, Gemini = 글자만. + 이 계획은 댓글 카드만 다룬다. +- 카드 1장 기준 길이는 **3.0초**(`CARD_SEC`). 기존 `_load_comment_cards(min_sec=3.0)`와 같은 값. +- 새 모듈은 `capcut_agent/` 아래, 기존 파일들과 같은 스타일(한국어 docstring, `from __future__ + import annotations`, 표준 라이브러리만). + +--- + +## 파일 구조 + +| 파일 | 책임 | +|---|---| +| `capcut_agent/comments.py` (수정) | 댓글 수집·타임스탬프 파싱·매칭. **`match_slots()` 추가** — 시각 슬롯 배정 | +| `capcut_agent/recommend.py` (신규) | 컷별 추천 전담. 순수 배정 로직 + Gemini 호출 + 하이라이트 조립 | +| `capcut_agent/prompts.py` (수정) | 추천 프롬프트 파일 로더 추가 | +| `capcut_agent/pipeline.py` (수정) | 카드 경로 목록 분리, 컷 소속 기반 카드 시간 계산, 무음 재매핑 | +| `server/app.py` (수정) | `/auto/analyze`가 `cuts[]`를 실어 보냄, `/auto/build`가 `card_cuts` 수신 | +| `server/static/auto.js` (수정) | 컷별 카드 섹션 렌더, 컷별 선택 상한, `card_cuts` 전송 | + +`recommend.py`가 새로 생기는 이유: 배정 규칙(타임스탬프 우선·중복 제거·모드 분기)이 +`comments.py`(수집·파싱)와도, `app.py`(HTTP)와도 책임이 다르다. 순수 함수로 떼어놔야 +Gemini 없이 단위 검증이 된다. + +--- + +## Task 1: 시각 슬롯 매칭 (`match_slots`) + +통짜 모드가 쓸 순수 함수. 구간을 n개 슬롯으로 잘라 슬롯마다 댓글 1개씩 배정한다. + +**Files:** +- Modify: `capcut_agent/comments.py` (파일 끝, `top_liked()` 다음) + +**Interfaces:** +- Consumes: 기존 `match_ranges(comments, ranges)`, `MAX_TIMES` +- Produces: `match_slots(comments, start, total, n, exclude=None) -> List[Optional[int]]` + — 길이 `n`, 각 원소는 댓글 idx 또는 `None` + +- [ ] **Step 1: 실패하는 검증 스크립트 작성** + +`C:\Users\hehih\AppData\Local\Temp\claude\D-------00----capcut2\...\scratchpad\t1.py` +(또는 임의 임시 경로)에 저장: + +```python +import sys; sys.stdout.reconfigure(encoding='utf-8') +from capcut_agent.comments import match_slots + +cs = [ + {"idx": 0, "likeCount": 10, "times": [100.0]}, + {"idx": 1, "likeCount": 99, "times": [101.0]}, + {"idx": 2, "likeCount": 50, "times": [107.0]}, + {"idx": 3, "likeCount": 80, "times": []}, # 분:초 없음 + {"idx": 4, "likeCount": 70, "times": [1.0, 2.0, 3.0, 4.0]}, # 목차 댓글(MAX_TIMES 초과) +] +# start=100, total=9, n=3 → 슬롯 [100,103) [103,106) [106,109) +assert match_slots(cs, 100.0, 9.0, 3) == [1, None, 2], match_slots(cs, 100.0, 9.0, 3) +# 좋아요 높은 idx1 이 슬롯0을 가져가고, 제외하면 idx0 이 온다 +assert match_slots(cs, 100.0, 9.0, 3, exclude={1}) == [0, None, 2] +# 목차 댓글(times 4개)은 어느 슬롯에도 안 걸린다 +assert match_slots(cs, 0.0, 9.0, 3) == [None, None, None] +# 한 댓글이 두 슬롯에 중복 배정되지 않는다 (슬롯 경계는 양끝 포함이라 idx1 이 둘 다 걸린다) +assert match_slots(cs, 100.0, 2.0, 2) == [1, None], match_slots(cs, 100.0, 2.0, 2) +assert match_slots(cs, 100.0, 0.0, 3) == [] # total 0 → 빈 리스트 +assert match_slots(cs, 100.0, 9.0, 0) == [] # n 0 → 빈 리스트 +print("Task1 OK") +``` + +- [ ] **Step 2: 실패 확인** + +Run: `cd "D:/개인폴더/00.유튭/capcut2" && python <임시경로>/t1.py` +Expected: FAIL — `ImportError: cannot import name 'match_slots'` + +- [ ] **Step 3: 구현** + +`capcut_agent/comments.py` 끝에 추가: + +```python +def match_slots(comments: List[Dict], start: float, total: float, n: int, + exclude=None) -> List[Optional[int]]: + """구간을 n개 슬롯으로 잘라 슬롯마다 댓글 1개씩 배정 — 통짜 모드용. + + 슬롯 k = 원본 [start + k·total/n, start + (k+1)·total/n). + 통짜 모드는 구간을 그대로 이어붙여 **타임라인 시각 = 원본 시각**이라 이 계산이 성립한다 + (컷 있는 모드는 컷 순서를 섞어 재배치하므로 쓰면 안 된다 — 내용 기반 추천을 쓸 것). + + 각 슬롯은 그 시간대를 언급한 댓글 중 좋아요 최다 1개. 없으면 None. + 한 댓글이 두 슬롯에 겹치지 않는다. exclude(idx 집합)는 처음부터 제외. + Returns: 길이 n 리스트 (n<=0 이거나 total<=0 이면 빈 리스트) + """ + if n <= 0 or total <= 0: + return [] + used = set(exclude or ()) + step = total / n + out: List[Optional[int]] = [] + for k in range(n): + s = start + k * step + pick = None + for idx in match_ranges(comments, [(s, s + step)]): + if idx not in used: + pick = idx + used.add(idx) + break + out.append(pick) + return out +``` + +`typing` import에 `Optional`을 추가한다(파일 상단 `from typing import Dict, List` → +`from typing import Dict, List, Optional`). + +- [ ] **Step 4: 통과 확인** + +Run: `cd "D:/개인폴더/00.유튭/capcut2" && python <임시경로>/t1.py` +Expected: `Task1 OK` + +- [ ] **Step 5: 구문·임포트 검증** + +```bash +cd "D:/개인폴더/00.유튭/capcut2" +python -c "import ast; ast.parse(open('capcut_agent/comments.py', encoding='utf-8').read())" +python -c "from server import app; print('import OK')" +``` + +--- + +## Task 2: 컷별 배정 순수 로직 (`build_cut_picks`) + +Gemini 결과를 받아 타임스탬프 우선·중복 제거로 컷별 최종 추천을 만든다. +Gemini를 호출하지 않으므로 단독 검증이 된다. + +**Files:** +- Create: `capcut_agent/recommend.py` + +**Interfaces:** +- Consumes: `comments.match_ranges` +- Produces: + - `CARD_SEC = 3.0` + - `quotas_for(cuts) -> List[int]` — 컷별 카드 장수 `max(1, round(len/CARD_SEC))` + - `build_cut_picks(cuts, comments, ai_picks, quotas) -> List[List[dict]]` + — 컷별 `[{"idx": int, "why": "ts"|"ai"}]` + +- [ ] **Step 1: 실패하는 검증 스크립트 작성** + +```python +import sys; sys.stdout.reconfigure(encoding='utf-8') +from capcut_agent.recommend import quotas_for, build_cut_picks + +cuts = [{"start": 100.0, "end": 105.0, "bottom": "아까랑 완전 스타일이 달라"}, + {"start": 200.0, "end": 206.0, "bottom": "우리에게 익숙한 평냥은"}] +assert quotas_for(cuts) == [2, 2], quotas_for(cuts) +assert quotas_for([{"start": 0.0, "end": 1.0, "bottom": ""}]) == [1] # 짧아도 최소 1 +assert quotas_for([{"start": 0.0, "end": 10.0, "bottom": ""}]) == [3] # round(10/3)=3 + +cs = [ + {"idx": 0, "likeCount": 50, "times": [101.0]}, # 컷0 언급 + {"idx": 1, "likeCount": 90, "times": [104.0]}, # 컷0 언급, 좋아요 더 높음 + {"idx": 2, "likeCount": 70, "times": []}, # 타임스탬프 없음 + {"idx": 3, "likeCount": 60, "times": []}, + {"idx": 9, "likeCount": 10, "times": []}, # ai 가 골랐지만 존재함 +] +# 컷0: ts 2장이 quota 2를 다 채움 → ai 는 안 들어감 +# 컷1: ts 없음 → ai 가 채움. 없는 idx(777)는 버림. 컷0에 쓴 idx1 은 중복이라 버림 +ai = {0: [2, 3], 1: [1, 777, 3, 9]} +got = build_cut_picks(cuts, cs, ai, [2, 2]) +assert got[0] == [{"idx": 1, "why": "ts"}, {"idx": 0, "why": "ts"}], got[0] +assert got[1] == [{"idx": 3, "why": "ai"}, {"idx": 9, "why": "ai"}], got[1] + +# ai 가 비어도(폴백) 터지지 않는다 — ts 만으로 채운다 +got2 = build_cut_picks(cuts, cs, {}, [2, 2]) +assert got2[0] == [{"idx": 1, "why": "ts"}, {"idx": 0, "why": "ts"}] +assert got2[1] == [] +print("Task2 OK") +``` + +- [ ] **Step 2: 실패 확인** + +Run: `python <임시경로>/t2.py` +Expected: FAIL — `ModuleNotFoundError: No module named 'capcut_agent.recommend'` + +- [ ] **Step 3: 구현** + +`capcut_agent/recommend.py` 생성: + +```python +"""컷별 댓글 추천 — 자동 탭 검토 화면용. + +왜 별도 모듈인가: 배정 규칙(타임스탬프 우선·중복 제거·모드 분기)은 댓글 수집 +(`comments.py`)과도, HTTP 처리(`server/app.py`)와도 책임이 다르다. 순수 함수로 떼어놔야 +Gemini 없이 단위 검증이 된다. + +설계 근거는 docs/superpowers/specs/2026-08-04-컷별-댓글-추천-design.md. +""" +from __future__ import annotations + +from typing import Dict, List + +from .comments import match_ranges + +# 카드 1장이 차지하는 기준 시간(초). pipeline._load_comment_cards(min_sec) 과 같은 값. +CARD_SEC = 3.0 + + +def quotas_for(cuts) -> List[int]: + """컷별 카드 장수 — max(1, round(컷길이 / CARD_SEC)). 짧은 컷도 최소 1장.""" + return [max(1, round((c["end"] - c["start"]) / CARD_SEC)) for c in cuts] + + +def build_cut_picks(cuts, comments, ai_picks: Dict[int, List[int]], + quotas) -> List[List[dict]]: + """컷별 추천 확정 — 타임스탬프 우선, 남는 자리만 AI, 전 컷 통틀어 중복 금지. + + 타임스탬프가 AI보다 먼저인 이유: 그 컷의 **원본 구간**을 콕 집어 언급한 댓글은 + 근거가 확실하다. 추측(AI)을 이기게 둘 이유가 없다. + 중복은 앞 컷이 가져간다(뒤 컷은 다음 후보로 밀린다). + + ai_picks: {컷인덱스: [댓글idx …]} — Gemini 실패 시 {} 를 넘기면 타임스탬프만으로 채운다. + Returns: 컷별 [{"idx": int, "why": "ts"|"ai"}] + """ + valid = {c["idx"] for c in comments} + used: set = set() + out: List[List[dict]] = [] + for i, cut in enumerate(cuts): + quota = quotas[i] if i < len(quotas) else 0 + picks: List[dict] = [] + for idx in match_ranges(comments, [(cut["start"], cut["end"])]): + if len(picks) >= quota: + break + if idx not in used: + picks.append({"idx": idx, "why": "ts"}) + used.add(idx) + for idx in (ai_picks.get(i) or []): + if len(picks) >= quota: + break + if idx in valid and idx not in used: + picks.append({"idx": idx, "why": "ai"}) + used.add(idx) + out.append(picks) + return out +``` + +- [ ] **Step 4: 통과 확인** + +Run: `python <임시경로>/t2.py` +Expected: `Task2 OK` + +- [ ] **Step 5: 구문 검증** + +```bash +python -c "import ast; ast.parse(open('capcut_agent/recommend.py', encoding='utf-8').read())" +``` + +--- + +## Task 3: 추천 프롬프트 파일 + +`프롬프트/댓글_추천.md`를 메모장으로 고칠 수 있게 한다. 기존 `load_step1()`과 같은 패턴 +(없으면 기본값으로 생성 후 읽기). + +**Files:** +- Modify: `capcut_agent/prompts.py` + +**Interfaces:** +- Produces: `prompts.load_recommend() -> str`, `prompts.RECOMMEND_PATH` + +- [ ] **Step 1: 검증 스크립트 작성** + +```python +import sys, os; sys.stdout.reconfigure(encoding='utf-8') +from capcut_agent import prompts +p = prompts.load_recommend() +assert "{cuts}" in p and "{comments}" in p and "{k}" in p, "치환자 누락" +assert os.path.isfile(prompts.RECOMMEND_PATH), "파일이 자동 생성되지 않음" +print("Task3 OK") +``` + +- [ ] **Step 2: 실패 확인** + +Run: `python <임시경로>/t3.py` +Expected: FAIL — `AttributeError: module 'capcut_agent.prompts' has no attribute 'load_recommend'` + +- [ ] **Step 3: 구현** + +`capcut_agent/prompts.py`의 경로 상수 옆(`STEP3_PATH` 다음 줄)에 추가: + +```python +RECOMMEND_PATH = os.path.join(PROMPT_DIR, "댓글_추천.md") +``` + +`DEFAULT_CONFIG` 위에 기본 프롬프트 추가: + +```python +# 컷별 댓글 추천(자동 탭). {cuts} {comments} {k} 를 채워 쓴다. +DEFAULT_RECOMMEND = """너는 숏폼 편집자다. 컷마다 화면 아래에 띄울 유튜브 댓글을 고른다. + +규칙: +- 컷 자막의 **내용과 의미가 통하는** 댓글만 고른다. 억지로 채우지 마라. +- 어울리는 게 없으면 그 컷은 picks 를 빈 배열로 둔다. 빈 채로 두는 게 엉뚱한 것보다 낫다. +- 한 댓글은 한 컷에만 쓴다. 여러 컷에 어울리면 가장 잘 맞는 컷 하나에만 넣어라. +- 아래 목록에 있는 댓글 번호만 쓴다. 없는 번호를 지어내지 마라. +- 컷마다 최대 {k}개까지. + +컷 목록: +{cuts} + +댓글 후보 (번호. 👍좋아요 / 본문): +{comments} + +각 컷마다 {{"cut": 컷번호, "picks": [댓글번호…]}} 를 JSON 배열로만 출력.""" +``` + +`load_step3()` 아래에 로더 추가: + +```python +def load_recommend() -> str: + """컷별 댓글 추천 프롬프트. 없으면 기본값으로 파일을 만들고 읽는다(메모장 수정 가능).""" + if not os.path.isfile(RECOMMEND_PATH): + os.makedirs(PROMPT_DIR, exist_ok=True) + with open(RECOMMEND_PATH, "w", encoding="utf-8") as f: + f.write(DEFAULT_RECOMMEND) + with open(RECOMMEND_PATH, encoding="utf-8") as f: + return f.read() +``` + +`reset()`은 건드리지 않는다 — Step 1/설정만 되돌리는 함수이고, 추천 프롬프트를 지우면 +사용자가 고친 내용이 날아간다. + +- [ ] **Step 4: 통과 확인** + +Run: `python <임시경로>/t3.py` +Expected: `Task3 OK` — `프롬프트/댓글_추천.md`가 생성됨 + +- [ ] **Step 5: 구문 검증** + +```bash +python -c "import ast; ast.parse(open('capcut_agent/prompts.py', encoding='utf-8').read())" +``` + +--- + +## Task 4: Gemini 호출 (`ai_pick_cuts`) + +텍스트 전용 호출. `plan.py._call()`은 `parts[0]`에 영상 `fileData`가 항상 들어가 재사용할 수 +없다. `correct.py`의 텍스트 호출 패턴(responseSchema + 실패 시 안전 반환)을 따른다. + +**Files:** +- Modify: `capcut_agent/recommend.py` + +**Interfaces:** +- Consumes: `correct._gemini_key`, `prompts.load_recommend`, `comments.MAX_TIMES` +- Produces: `ai_pick_cuts(cuts, comments, quotas, *, model=None, key=None, timeout=90.0) -> Dict[int, List[int]]` + — 실패하면 **예외 없이 `{}`** + +- [ ] **Step 1: 검증 스크립트 작성 (네트워크 없이)** + +```python +import sys; sys.stdout.reconfigure(encoding='utf-8') +from capcut_agent import recommend + +cuts = [{"start": 0.0, "end": 5.0, "bottom": "가"}] +cs = [{"idx": 0, "likeCount": 5, "times": [], "text": "ㅋㅋㅋ", "authorName": "a"}] + +# 키가 없으면 조용히 {} — 예외를 던지면 안 된다 +assert recommend.ai_pick_cuts(cuts, cs, [1], key="") == {} + +# 응답 파싱: 배열 → dict, 잘못된 항목은 버린다 +raw = '[{"cut":0,"picks":[3,1]},{"cut":"x","picks":[2]},{"cut":1},{"nope":1}]' +assert recommend._parse_ai(raw) == {0: [3, 1]}, recommend._parse_ai(raw) +assert recommend._parse_ai("깨진 텍스트") == {} +assert recommend._parse_ai('{"cut":0}') == {} # 배열이 아니면 버림 + +# 후보 목록: 목차 댓글 제외 + 좋아요순 + 본문 절단 +many = [{"idx": i, "likeCount": i, "times": [], "text": "가" * 500} for i in range(5)] +many.append({"idx": 99, "likeCount": 999, "times": [1.0, 2.0, 3.0, 4.0], "text": "목차"}) +lines = recommend._candidate_lines(many, 3) +assert len(lines.splitlines()) == 3, lines +assert lines.splitlines()[0].startswith("4."), lines # 좋아요 최다부터 +assert "99." not in lines # 목차 댓글 제외 +assert len(lines.splitlines()[0]) < 260 # 본문 200자 절단 +print("Task4 OK") +``` + +- [ ] **Step 2: 실패 확인** + +Run: `python <임시경로>/t4.py` +Expected: FAIL — `AttributeError: module 'capcut_agent.recommend' has no attribute 'ai_pick_cuts'` + +- [ ] **Step 3: 구현** + +`capcut_agent/recommend.py` 상단 import에 추가: + +```python +import json +import urllib.error +import urllib.request + +from . import prompts +from .comments import MAX_TIMES, match_ranges +from .correct import _gemini_key +``` + +(기존 `from .comments import match_ranges` 줄은 위 줄로 대체한다.) + +상수·함수 추가: + +```python +# Gemini에 넘길 댓글 후보 수. 더 늘려도 채택률이 안 오르고 토큰만 먹는다. +AI_CANDIDATES = 150 +TEXT_CAP = 200 # 댓글 본문 절단 길이 +AI_MODEL = "gemini-2.5-flash" # correct.py 와 같은 무료 티어 모델 +_ENDPOINT = ("https://generativelanguage.googleapis.com/v1beta/models/" + "{model}:generateContent?key={key}") + +_SCHEMA = {"type": "array", "items": {"type": "object", "properties": { + "cut": {"type": "integer"}, + "picks": {"type": "array", "items": {"type": "integer"}}}, + "required": ["cut", "picks"]}} + + +def _candidate_lines(comments, limit: int = AI_CANDIDATES) -> str: + """Gemini에 줄 댓글 후보 — 목차 댓글 제외, 좋아요순 상위 limit, 본문 절단.""" + usable = [c for c in comments if len(c.get("times") or []) <= MAX_TIMES] + usable.sort(key=lambda c: -c.get("likeCount", 0)) + out = [] + for c in usable[:limit]: + text = " ".join(str(c.get("text") or "").split())[:TEXT_CAP] + out.append(f"{c['idx']}. 👍{c.get('likeCount', 0)} / {text}") + return "\n".join(out) + + +def _cut_lines(cuts) -> str: + """컷 목록 — 번호·길이·자막. 자막이 추천의 유일한 근거다.""" + out = [] + for i, c in enumerate(cuts): + bottom = " ".join(str(c.get("bottom") or "").split()) or "(자막 없음)" + effect = " ".join(str(c.get("effect") or "").split()) + line = f"{i}. ({c['end'] - c['start']:.1f}초) {bottom}" + if effect: + line += f" [효과자막: {effect}]" + out.append(line) + return "\n".join(out) + + +def _parse_ai(raw: str) -> Dict[int, List[int]]: + """Gemini 응답 → {컷인덱스: [댓글idx …]}. 형식이 어긋난 항목은 조용히 버린다.""" + try: + data = json.loads(raw) + except (json.JSONDecodeError, TypeError): + return {} + if not isinstance(data, list): + return {} + out: Dict[int, List[int]] = {} + for row in data: + if not isinstance(row, dict): + continue + cut, picks = row.get("cut"), row.get("picks") + if not isinstance(cut, int) or isinstance(cut, bool): + continue + if not isinstance(picks, list): + continue + idxs = [p for p in picks if isinstance(p, int) and not isinstance(p, bool)] + if idxs: + out[cut] = idxs + return out + + +def ai_pick_cuts(cuts, comments, quotas, *, model: str = AI_MODEL, + key=None, timeout: float = 90.0) -> Dict[int, List[int]]: + """컷 자막으로 컷별 추천을 받는다. **실패하면 예외 없이 {}** — 호출부가 폴백한다. + + plan.py._call() 은 parts[0] 에 영상 fileData 가 항상 들어가 재사용할 수 없다. + correct.py 의 텍스트 전용 호출 패턴(responseSchema + 실패 시 안전 반환)을 따른다. + """ + key = key if key is not None else _gemini_key() + if not key or not cuts or not comments: + return {} + k = max(quotas) + 2 if quotas else 3 # 갈아끼울 여유분 + try: + prompt = prompts.load_recommend().format( + cuts=_cut_lines(cuts), comments=_candidate_lines(comments), k=k) + except (KeyError, IndexError, OSError): # 사용자가 프롬프트를 깨뜨린 경우 + return {} + body = { + "contents": [{"parts": [{"text": prompt}]}], + "generationConfig": {"temperature": 0.3, + "responseMimeType": "application/json", + "responseSchema": _SCHEMA}, + } + 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")) + raw = data["candidates"][0]["content"]["parts"][0]["text"] + except (urllib.error.URLError, KeyError, IndexError, TimeoutError, + json.JSONDecodeError, OSError): + return {} # 429 포함 — 폴백은 호출부 몫 + return _parse_ai(raw) +``` + +- [ ] **Step 4: 통과 확인** + +Run: `python <임시경로>/t4.py` +Expected: `Task4 OK` + +- [ ] **Step 5: 구문·임포트 검증** + +```bash +python -c "import ast; ast.parse(open('capcut_agent/recommend.py', encoding='utf-8').read())" +python -c "from capcut_agent import recommend; print('import OK')" +``` + +--- + +## Task 5: 하이라이트 조립 (`build_highlight_cuts`) + +모드를 갈라 컷별 추천을 완성한다. 컷이 1개이고 자막이 비었으면 통짜 → 시각 슬롯, +아니면 → Gemini + 타임스탬프. + +**Files:** +- Modify: `capcut_agent/recommend.py` + +**Interfaces:** +- Consumes: `quotas_for`, `build_cut_picks`, `ai_pick_cuts`, `comments.match_slots`, `comments.top_liked` +- Produces: `build_highlight_cuts(hl, comments) -> (List[dict], int)` + — `([{"i","sec","bottom","quota","picks"}], need)`. `picks` 원소는 `{"idx","why"}`, + `why` ∈ `ts`|`ai`|`like` + +- [ ] **Step 1: 검증 스크립트 작성** + +```python +import sys; sys.stdout.reconfigure(encoding='utf-8') +from capcut_agent import recommend + +cs = [ + {"idx": 0, "likeCount": 50, "times": [101.0]}, + {"idx": 1, "likeCount": 90, "times": [104.0]}, + {"idx": 2, "likeCount": 70, "times": []}, + {"idx": 3, "likeCount": 60, "times": []}, +] + +# ── 통짜: 컷 1개 + 자막 없음 → Gemini 호출 없이 슬롯 배정 ── +whole = {"paste": {"cuts": [{"start": 100.0, "end": 106.0, "bottom": "", "effect": ""}]}} +cuts, need = recommend.build_highlight_cuts(whole, cs) +assert need == 2 and len(cuts) == 1, (need, cuts) +assert cuts[0]["quota"] == 2 +# 슬롯 [100,103)=idx0, [103,106)=idx1 +assert cuts[0]["picks"] == [{"idx": 0, "why": "ts"}, {"idx": 1, "why": "ts"}], cuts[0]["picks"] + +# 슬롯에 아무도 없으면 좋아요 상위(타임스탬프 없는 댓글)로 메운다 +whole2 = {"paste": {"cuts": [{"start": 0.0, "end": 6.0, "bottom": "", "effect": ""}]}} +c2, _ = recommend.build_highlight_cuts(whole2, cs) +assert c2[0]["picks"] == [{"idx": 2, "why": "like"}, {"idx": 3, "why": "like"}], c2[0]["picks"] + +# ── 컷 있는 모드: 키 없이 돌리면 ai={} 로 폴백, ts 만 남는다 ── +multi = {"paste": {"cuts": [ + {"start": 100.0, "end": 105.0, "bottom": "가", "effect": ""}, + {"start": 200.0, "end": 206.0, "bottom": "나", "effect": ""}]}} +cuts3, need3 = recommend.build_highlight_cuts(multi, cs, key="") +assert need3 == 4, need3 +assert cuts3[0]["picks"] == [{"idx": 1, "why": "ts"}, {"idx": 0, "why": "ts"}] +assert cuts3[1]["picks"] == [] +assert cuts3[1]["bottom"] == "나" and cuts3[1]["sec"] == 6.0 + +# 댓글이 아예 없어도 터지지 않는다 +c4, n4 = recommend.build_highlight_cuts(multi, [], key="") +assert n4 == 4 and all(x["picks"] == [] for x in c4) +print("Task5 OK") +``` + +- [ ] **Step 2: 실패 확인** + +Run: `python <임시경로>/t5.py` +Expected: FAIL — `AttributeError: ... has no attribute 'build_highlight_cuts'` + +- [ ] **Step 3: 구현** + +`capcut_agent/recommend.py` import에 `match_slots`, `top_liked` 추가: + +```python +from .comments import MAX_TIMES, match_ranges, match_slots, top_liked +``` + +파일 끝에 추가: + +```python +def is_whole(cuts) -> bool: + """통짜 하이라이트인가 — 컷 1개 + 자막 없음(app._whole_hl 이 만드는 모양). + + 통짜는 자막이 없어 내용 추천의 근거가 없다. 대신 타임라인 시각 = 원본 시각이라 + 시각으로 정확히 맞출 수 있다(스펙 §4.1). + """ + return len(cuts) == 1 and not (cuts[0].get("bottom") or "").strip() + + +def _whole_picks(cut, comments, n: int) -> List[dict]: + """통짜 — 슬롯마다 그 시간대 언급 댓글, 빈 슬롯은 좋아요 상위로 메움.""" + slots = match_slots(comments, cut["start"], cut["end"] - cut["start"], n) + used = {i for i in slots if i is not None} + no_ts = [c for c in comments if not c.get("times")] + fill = iter(top_liked(no_ts, used, n)) + out: List[dict] = [] + for s in slots: + if s is not None: + out.append({"idx": s, "why": "ts"}) + continue + nxt = next(fill, None) + if nxt is not None: + out.append({"idx": nxt, "why": "like"}) + return out + + +def build_highlight_cuts(hl, comments, *, key=None): + """하이라이트 하나 → (cuts[], need). 모드는 컷 모양으로 판별한다. + + Returns: ([{"i","sec","bottom","quota","picks"}], need) + need = Σ quota — 기존 int(total//3) 을 대체한다(컷 경계에 맞추는 쪽이 맞다). + Gemini 실패는 여기서 흡수된다(ai_pick_cuts 가 {} 를 준다) — 예외를 올리지 않는다. + """ + cuts = (hl.get("paste") or {}).get("cuts") or [] + if not cuts: + return [], 0 + quotas = quotas_for(cuts) + if is_whole(cuts): + picks = [_whole_picks(cuts[0], comments, quotas[0])] + else: + ai = ai_pick_cuts(cuts, comments, quotas, key=key) + picks = build_cut_picks(cuts, comments, ai, quotas) + out = [{"i": i, "sec": round(c["end"] - c["start"], 1), + "bottom": c.get("bottom") or "", "quota": quotas[i], "picks": picks[i]} + for i, c in enumerate(cuts)] + return out, sum(quotas) +``` + +- [ ] **Step 4: 통과 확인** + +Run: `python <임시경로>/t5.py` +Expected: `Task5 OK` + +- [ ] **Step 5: 구문·임포트 검증** + +```bash +python -c "import ast; ast.parse(open('capcut_agent/recommend.py', encoding='utf-8').read())" +python -c "from capcut_agent import recommend; print('import OK')" +``` + +--- + +## Task 6: `/auto/analyze`가 `cuts[]`를 실어 보냄 + +**Files:** +- Modify: `server/app.py:523-533` (댓글 매칭 블록) + +**Interfaces:** +- Consumes: `recommend.build_highlight_cuts(hl, comments)` +- Produces: SSE `result.highlights[].cuts` = `[{"i","sec","bottom","quota","picks"}]`, + `highlights[].need` = Σ quota. 기존 `matched`/`candidates`는 **그대로 유지** + (➕ 섹션과 폴백에 쓴다) + +- [ ] **Step 1: 임포트 추가** + +`server/app.py` 상단 `from capcut_agent import ...` 근처(24행 부근)에: + +```python +from capcut_agent import recommend +``` + +- [ ] **Step 2: 매칭 블록 교체** + +523~533행을 아래로 바꾼다: + +```python + # 댓글 매칭 — 전체 전송, 브라우저가 '더보기'로 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)) + # 컷별 추천 — 실패해도 위의 matched/candidates 로 화면이 돌아간다. + # Gemini 호출이 섞여 있어 블로킹이므로 스레드로 뺀다. + try: + cuts, need = await asyncio.to_thread( + recommend.build_highlight_cuts, h, comments) + if cuts: + h["cuts"], h["need"] = cuts, need + except Exception as exc: # noqa: BLE001 — 추천 실패가 생성을 막으면 안 된다 + warnings.append( + f"ID {h.get('id')} 컷별 추천 실패 — 기존 방식으로 표시 " + f"({type(exc).__name__}: {exc})") + yield _sse({"type": "result", "highlights": highlights, + "comments": comments, "warnings": warnings}) +``` + +- [ ] **Step 3: 구문·임포트 검증** + +```bash +cd "D:/개인폴더/00.유튭/capcut2" +python -c "import ast; ast.parse(open('server/app.py', encoding='utf-8').read())" +python -c "from server import app; print('import OK')" +``` + +- [ ] **Step 4: 응답 모양 확인 (네트워크 없이)** + +```python +import sys; sys.stdout.reconfigure(encoding='utf-8') +from capcut_agent import recommend +hl = {"id": 1, "paste": {"cuts": [ + {"start": 0.0, "end": 5.0, "bottom": "가", "effect": ""}, + {"start": 10.0, "end": 16.0, "bottom": "나", "effect": ""}]}} +cuts, need = recommend.build_highlight_cuts(hl, [], key="") +assert [c["i"] for c in cuts] == [0, 1] +assert set(cuts[0]) == {"i", "sec", "bottom", "quota", "picks"}, cuts[0] +print("Task6 OK", need, cuts) +``` + +Expected: `Task6 OK 4 [...]` + +--- + +## Task 7: 카드를 컷 구간 안에 배치 + +⚠ 무음 제거를 켜면 `timeline_dur`가 줄고 자막이 `_remap_caps()`로 재매핑된다. +카드도 **같은 시점에 같은 방식으로** 재매핑해야 한다. 카드 튜플 `(start, end, path)`는 +자막 `(start, end, text)`와 모양이 같아 `_remap_caps()`를 그대로 쓸 수 있다. + +**Files:** +- Modify: `capcut_agent/pipeline.py` — `_load_comment_cards` 분리(38-77행), `process_paste` + 시그니처(305-318행), 자막 배치부(363-390행), 카드 로드부(440-443행) + +**Interfaces:** +- Produces: + - `_card_paths(folder) -> List[str]` — 정렬된 카드 이미지 경로 + - `_cards_by_cut(paths, card_cuts, placements, dur, *, min_sec=3.0, fixed=False) -> List[Tuple[float, float, str]]` + - `process_paste(..., card_cuts: Optional[List[int]] = None)` + +- [ ] **Step 1: 검증 스크립트 작성** + +```python +import sys; sys.stdout.reconfigure(encoding='utf-8') +from capcut_agent.pipeline import _cards_by_cut, _remap_caps + +paths = ["a.png", "b.png", "c.png", "d.png"] +placements = [(0.0, 6.0), (6.0, 12.0)] # 컷0 0~6초, 컷1 6~12초 +# 카드 0,1 → 컷0 / 카드 2,3 → 컷1 +got = _cards_by_cut(paths, [0, 0, 1, 1], placements, 12.0) +assert got == [(0.0, 3.0, "a.png"), (3.0, 6.0, "b.png"), + (6.0, 9.0, "c.png"), (9.0, 12.0, "d.png")], got + +# 컷0 이 1장뿐이어도 컷1 카드가 앞으로 밀리지 않는다 (지금 방식의 약점이 사라지는 지점) +got2 = _cards_by_cut(paths[:3], [0, 1, 1], placements, 12.0) +assert got2 == [(0.0, 6.0, "a.png"), (6.0, 9.0, "b.png"), (9.0, 12.0, "c.png")], got2 + +# fixed=True → 3초 고정, 컷 뒷부분은 비움 +got3 = _cards_by_cut(paths[:2], [0, 1], placements, 12.0, fixed=True) +assert got3 == [(0.0, 3.0, "a.png"), (6.0, 9.0, "b.png")], got3 + +# 영상 실제 길이로 클램프 — dur 밖 컷은 버린다 +got4 = _cards_by_cut(paths, [0, 0, 1, 1], placements, 6.0) +assert got4 == [(0.0, 3.0, "a.png"), (3.0, 6.0, "b.png")], got4 + +# 범위 밖 컷 인덱스는 무시 +assert _cards_by_cut(paths[:1], [7], placements, 12.0) == [] +assert _cards_by_cut([], [], placements, 12.0) == [] + +# 카드도 자막과 같은 함수로 무음 재매핑된다 (튜플 모양이 같다) +keep = [(0.0, 3.0), (6.0, 12.0)] # 3~6초가 무음 → 제거 +assert _remap_caps([(0.0, 3.0, "a.png"), (6.0, 9.0, "c.png")], keep) == \ + [(0.0, 3.0, "a.png"), (3.0, 6.0, "c.png")] +print("Task7 OK") +``` + +- [ ] **Step 2: 실패 확인** + +Run: `python <임시경로>/t7.py` +Expected: FAIL — `ImportError: cannot import name '_cards_by_cut'` + +- [ ] **Step 3: 카드 경로 목록 분리** + +`capcut_agent/pipeline.py`의 `_load_comment_cards`(38-77행)를 아래로 교체한다. +정렬 규칙은 그대로 두고 경로 목록만 함수로 뺀다. + +```python +def _card_paths(folder: str): + """댓글 카드 이미지 경로 목록. 배치 방식과 무관하게 '순서'만 정한다. + + 정렬 규칙: + - 모든 파일명이 숫자로 시작하면 → 숫자순(1, 2, 10 …) + - 아니면 → 저장(생성) 순서 = 다운로드한 순서 + folder 가 비었거나 없으면 []. + """ + import re + folder = (folder or "").strip().strip('"') + if not folder or not os.path.isdir(folder): + 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)) # 저장(생성) 순서 + return imgs + + +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 고정 — 모자라면 뒤는 비운다 + (편집하면서 부분삭제를 많이 하는 경우 카드가 늘어나 있으면 타이밍이 꼬여서). + + ⚠ 이 함수는 컷을 모른다. 자동 탭처럼 '어느 컷에 붙일지'가 정해진 경우엔 + `_cards_by_cut()` 을 쓴다. 폴더 지정 경로(파일/유튜브 구간 탭)는 계속 이 함수를 쓴다. + Returns: [(start, end, path)] + """ + imgs = _card_paths(folder) + if not imgs or dur <= 0: + return [] + 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 _cards_by_cut(paths, card_cuts, placements, dur: float, *, + min_sec: float = 3.0, fixed: bool = False): + """카드를 '소속 컷 구간 안'에 배치 — 자동 탭 전용. + + card_cuts[i] = i번째 카드가 속한 컷 인덱스. 컷 안에서는 균등 분할 + (fixed=True 면 min_sec 고정, 컷 뒷부분은 비움). + + 왜 컷 단위인가: 전체 균등 배치(`_load_comment_cards`)는 컷 경계를 몰라서 + 3번 컷 얘기하는 댓글이 7번 컷 위에 뜬다. 컷 안에서 계산하면 한 컷이 덜 차도 + **다음 컷 카드가 앞으로 밀리지 않는다.** + Returns: [(start, end, path)] 시간순 + """ + if not paths or not card_cuts or not placements or dur <= 0: + return [] + groups: dict = {} + for i, ci in enumerate(card_cuts[:len(paths)]): + if isinstance(ci, int) and 0 <= ci < len(placements): + groups.setdefault(ci, []).append(i) + out = [] + for ci, idxs in groups.items(): + p0, p1 = placements[ci] + p1 = min(p1, dur) + if p1 <= p0: + continue # 영상 실제 길이 밖 컷은 버린다 + step = min_sec if fixed else (p1 - p0) / len(idxs) + for k, i in enumerate(idxs): + s = p0 + k * step + if s >= p1: + break # fixed 로 컷을 넘치면 뒤는 비운다 + out.append((s, min(s + step, p1), paths[i])) + out.sort(key=lambda t: t[0]) + return out +``` + +- [ ] **Step 4: 통과 확인** + +Run: `python <임시경로>/t7.py` +Expected: `Task7 OK` + +- [ ] **Step 5: `process_paste`에 `card_cuts` 연결** + +시그니처(305-318행)의 `cards_fixed: bool = False,` 다음 줄에 추가: + +```python + card_cuts: Optional[List[int]] = None, +``` + +`typing` import에 `Optional`이 없으면 추가한다(`from typing import ... Optional`). + +자막 배치부 바로 아래(373행 `video_clips = [(0.0, dur)]` **앞**)에 카드 시간 계산을 넣는다. +**무음 제거 전**이어야 자막과 같은 타임라인에서 시작한다: + +```python + # 댓글 카드(자동 탭): '몇 번 컷 소속'만 받아 여기서 시간을 만든다. + # 서버가 시간을 확정하면 무음 제거 때 자막만 당겨지고 카드는 혼자 어긋난다. + cut_cards = _cards_by_cut(_card_paths(comments_dir), card_cuts or [], + placements, dur, fixed=cards_fixed) +``` + +무음 제거 블록 안(`eff_caps = _remap_caps(eff_caps, keep)` 다음 줄)에 추가: + +```python + cut_cards = _remap_caps(cut_cards, keep) +``` + +카드 로드부(440-443행)를 교체: + +```python + # 댓글 카드: 컷 소속이 지정됐으면 그 컷 구간 안(위에서 계산), 아니면 폴더 균등 배치 + cards = cut_cards or _load_comment_cards(comments_dir, timeline_dur, fixed=cards_fixed) + if cards: + yield {"type": "log", + "msg": f"댓글 카드 {len(cards)}개 하단 삽입" + + ("(컷별 배치)" if cut_cards else "(전체 균등)")} +``` + +- [ ] **Step 6: 구문·임포트 검증** + +```bash +python -c "import ast; ast.parse(open('capcut_agent/pipeline.py', encoding='utf-8').read())" +python -c "from server import app; print('import OK')" +``` + +--- + +## Task 8: `/auto/build`가 `card_cuts`를 받아 넘김 + +**Files:** +- Modify: `server/app.py:589-632` (`auto_build`), `server/app.py:213-226` (`process_paste` 호출) + +**Interfaces:** +- Consumes: 폼 필드 `card_cuts` — JSON 정수 배열 문자열(예: `"[0,0,1,1]"`) +- Produces: `JOBS[h]["card_cuts"]` → `process_paste(card_cuts=…)` + +- [ ] **Step 1: 폼 필드 추가** + +`auto_build` 시그니처의 `cards_fixed: str = Form(""),` 다음에: + +```python + card_cuts: str = Form(""), +``` + +- [ ] **Step 2: 파싱 후 job에 싣기** + +`JOBS[h] = {...}` 직전에 파싱을 넣는다: + +```python + # 카드별 소속 컷 — 값이 깨져도 빌드를 막지 않는다(없으면 기존 전체 균등 배치) + cut_map: list[int] = [] + try: + parsed = json.loads(card_cuts) if card_cuts.strip() else [] + if isinstance(parsed, list): + cut_map = [int(v) for v in parsed if isinstance(v, int)] + except (json.JSONDecodeError, ValueError, TypeError): + cut_map = [] +``` + +`JOBS[h]` 딕셔너리의 `"cards_fixed": _truthy(cards_fixed),` 다음에: + +```python + "card_cuts": cut_map, +``` + +- [ ] **Step 3: `process_paste` 호출에 전달** + +`server/app.py:225` `cards_fixed=job.get("cards_fixed", False),` 다음 줄에: + +```python + card_cuts=job.get("card_cuts") or None, +``` + +- [ ] **Step 4: 구문·임포트 검증** + +```bash +python -c "import ast; ast.parse(open('server/app.py', encoding='utf-8').read())" +python -c "from server import app; print('import OK')" +``` + +- [ ] **Step 5: 파싱 방어 확인** + +```python +import sys, json; sys.stdout.reconfigure(encoding='utf-8') +def parse(card_cuts): + try: + p = json.loads(card_cuts) if card_cuts.strip() else [] + return [int(v) for v in p if isinstance(v, int)] if isinstance(p, list) else [] + except (json.JSONDecodeError, ValueError, TypeError): + return [] +assert parse("[0,0,1]") == [0, 0, 1] +assert parse("") == [] and parse("깨짐") == [] and parse('{"a":1}') == [] +assert parse('[0,"x",1,null]') == [0, 1] +print("Task8 OK") +``` + +--- + +## Task 9: 검토 화면을 컷별로 렌더 + +**Files:** +- Modify: `server/static/auto.js` — `toggle()`(91-100행), 하이라이트 패널(438-472행), + 빌드 전송(544-563행) + +**Interfaces:** +- Consumes: `hl.cuts` = `[{"i","sec","bottom","quota","picks"}]`, `hl.need` +- Produces: 폼 필드 `card_cuts` (JSON 배열), `sel[hlId]`는 **컷 순서대로 정렬된 idx 배열** + +- [ ] **Step 1: 컷별 선택 상태 헬퍼 추가** + +`toggle()` 위에 추가: + +```javascript +/* ── 컷별 선택 ── 카드는 '어느 컷 소속'인지가 배치를 정한다. + selCut[hlId][idx] = 컷 인덱스. sel[hlId] 는 항상 컷 순서로 정렬해 둔다 + (업로드 순서 = 배치 순서라서). */ +const selCut={}; +function cutOf(hlId,idx){ return (selCut[hlId]||{})[idx]; } +function sortSel(hlId){ + const m=selCut[hlId]||{}; + sel[hlId].sort((a,b)=>(m[a]??999)-(m[b]??999)); +} +function cutFull(hl,ci){ + const m=selCut[hl.id]||{}; + const q=(hl.cuts&&hl.cuts[ci])?hl.cuts[ci].quota:hl.need; + return sel[hl.id].filter(i=>m[i]===ci).length>=q; +} +``` + +- [ ] **Step 2: `toggle()`을 컷 단위 상한으로 교체** + +91-100행을 아래로 바꾼다: + +```javascript +function toggle(hlId,idx,ci){ + const hl=hlById(hlId), list=sel[hlId]; + selCut[hlId]=selCut[hlId]||{}; + const at=list.indexOf(idx); + if(at>=0){ list.splice(at,1); delete selCut[hlId][idx]; } + else{ + if(hl.cuts&&ci!==undefined){ + if(cutFull(hl,ci)) return; // 그 컷 장수 초과 방지 + selCut[hlId][idx]=ci; + }else{ + if(list.length>=hl.need) return; // 폴백(컷 정보 없음) + } + list.push(idx); + sortSel(hlId); + } + refreshSel(hlId); +} +``` + +`cardEl()`이 `toggle(hlId,idx)`를 호출하는 곳과 `cardSection()`이 카드를 만드는 곳에 +컷 인덱스를 넘겨야 한다. `cardSection(label,list,hlId,firstBatch)`에 5번째 인자 `ci`를 추가하고, +내부 `cardEl(c,hlId)` 호출을 `cardEl(c,hlId,ci)`로 바꾼다. `cardEl`의 클릭 핸들러에서 +`toggle(hlId,c.idx)` → `toggle(hlId,c.idx,ci)`. + +선택 해제 칩(`chip.querySelector(".x")`)의 `toggle(hlId,idx)`는 그대로 둔다 +(해제는 컷을 몰라도 된다). + +- [ ] **Step 3: 패널 렌더를 컷별로** + +438행 `sel[hl.id]=...` 부터 472행까지를 아래로 교체한다: + +```javascript + // 자동 선택: 컷별 추천을 컷 순서대로. hl.cuts 가 없으면 기존 방식으로 폴백. + sel[hl.id]=[]; selCut[hl.id]={}; + if(hl.cuts){ + for(const cu of hl.cuts) + for(const p of (cu.picks||[])) + if(byIdx[p.idx]!==undefined&&!sel[hl.id].includes(p.idx)){ + sel[hl.id].push(p.idx); selCut[hl.id][p.idx]=cu.i; + } + }else{ + sel[hl.id]=(hl.matched||[]).slice(0,hl.need).filter(i=>byIdx[i]!==undefined); + } + const cuts=hl.paste.cuts; + let html="

ID "+hl.id+" · "+fmtT(hl.start)+"~"+fmtT(hl.end)+ + " "+esc(hl.reason||"")+"

"+ + '
컷 '+cuts.length+"개 · 총 "+hl.total+"초 · 카드 "+hl.need+"장 필요 · "+ + '
'; + let opts=[]; + if(hl.editable_title){ // 구간 통짜: 제목 직접 입력(비우면 제목 없이) + html+='
제목: '+ + ' '+ + '
'; + }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+='
제목:
"; + } + html+='
'; + box.innerHTML=html; + box.dataset.titles=JSON.stringify(opts); + // 영상 편집안(컷 목록·JSON) — 선택 요약 바 위에, 기본 접힘 + box.insertBefore(cutsSection(hl),$("#hlsel-"+hl.id)); + const WHY={ts:"⭐",ai:"🤖",like:"➕"}; + if(hl.cuts){ + // 컷별 추천 — 추천분 먼저, 그 컷 시간대 언급 댓글을 뒤에 붙여 갈아끼울 수 있게 + for(const cu of hl.cuts){ + const rec=(cu.picks||[]).map(p=>p.idx).filter(i=>byIdx[i]!==undefined); + const why=(cu.picks||[]).map(p=>WHY[p.why]||"").join(""); + const rest=(hl.matched||[]).filter(i=>byIdx[i]!==undefined&&!rec.includes(i)); + const label="컷 "+(cu.i+1)+" · "+cu.sec+"초 · 카드 "+cu.quota+"장"+ + (cu.bottom?" — "+cu.bottom:"")+(why?" "+why:""); + box.appendChild(cardSection(label,rec.concat(rest),hl.id, + Math.max(CARD_PAGE,rec.length),cu.i)); + } + }else{ + // 폴백 — 추천 실패 시 기존 화면 그대로 + 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)); + } +``` + +`cardSection`의 `ci`가 `undefined`인 섹션(➕ 후보, 폴백)에서 카드를 누르면 `toggle`이 +컷 없이 동작한다. 이 경우 `sortSel`이 그 카드를 맨 뒤로 보낸다(`??999`) — 마지막 컷 뒤에 +붙는다는 뜻이고, 배치상 문제 없다. + +- [ ] **Step 4: 빌드 전송에 `card_cuts` 추가** + +553행 `fd.append("cards_fixed",…);` 다음에: + +```javascript + const cmap=selCut[hl.id]||{}; +``` + +카드 캡처 루프에서 성공한 카드만 순서대로 기록해야 한다. 554-563행을 교체: + +```javascript + let n=0; const sentCuts=[]; + 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"); + sentCuts.push(cmap[idx]??((hl.paste.cuts||[]).length-1)); + }catch(e){n--;boardSet(hl.id,null,"카드 1장 캡처 실패(건너뜀)","active");} + } + if(hl.cuts) fd.append("card_cuts",JSON.stringify(sentCuts)); +``` + +캡처 실패 시 `n--`를 하는 이유: 파일명 번호(`001.png`)와 `sentCuts` 인덱스가 어긋나면 +카드가 엉뚱한 컷에 붙는다. + +- [ ] **Step 5: 문법 검증** + +```bash +node --check "server/static/auto.js" +``` + +Expected: 출력 없음(성공). `node`가 없으면 브라우저 콘솔에서 확인한다. + +--- + +## Task 10: 실사용 검증 + 문서 + +**Files:** +- Modify: `ARCHITECTURE.md`, `SETUP.md` + +- [ ] **Step 1: 서버 재시작** + +검은 창을 닫고 `캡컷_에이전트_구간합치기.bat`을 다시 실행한다. **이걸 빼먹으면 아래가 전부 헛일이다.** + +- [ ] **Step 2: `full` 모드 1건 실행** + +자동 탭 → 유튜브 URL → 분석 시작. 확인: +1. 검토 화면에 `컷 1 · 5.0초 · 카드 2장 — <자막>` 형태의 섹션이 컷 수만큼 뜨는가 +2. 각 섹션에 ⭐/🤖 배지가 붙은 추천이 자동 선택돼 있는가 +3. 한 컷의 장수를 다 채운 뒤 그 섹션에서 카드를 더 눌러도 선택이 안 되는가 +4. 헤더 카운터가 `Σ quota` 기준으로 맞는가 + +- [ ] **Step 3: 드래프트 1개 생성 후 카드 시간 확인** + +빌드 후 CapCut 드래프트에서: + +```python +import sys, json, os; sys.stdout.reconfigure(encoding='utf-8') +from capcut_agent.draft import DEFAULT_DRAFT_ROOT as R, timeline_jsons +d = os.path.join(R, "<드래프트폴더명>") +j = json.load(open(timeline_jsons(d)[0], encoding="utf-8")) +for tr in j["tracks"]: + if tr.get("name") == "comment": + for s in tr["segments"]: + t = s["target_timerange"] + print(round(t["start"]/1e6, 2), "~", round((t["start"]+t["duration"])/1e6, 2)) +``` + +각 카드 시간이 편집안의 해당 컷 구간 안에 들어가는지 확인한다(컷 경계는 컷 길이 누적). + +- [ ] **Step 4: 무음 제거 ON 재실행** + +같은 하이라이트를 `무음 제거` 체크 후 다시 만든다. 카드가 자막과 함께 앞으로 당겨졌는지, +카드가 영상 끝을 넘지 않는지 확인한다. + +- [ ] **Step 5: 통짜 모드 확인** + +`구간 통짜` 모드로 실행 → 컷 섹션이 1개만 뜨고, 추천 배지가 ⭐/➕만 있고 🤖가 없는지 확인 +(통짜는 Gemini를 안 쓴다). + +- [ ] **Step 6: 폴백 확인** + +`.gemini_key`를 잠시 다른 이름으로 바꾸고 `paste` 모드 실행 → 컷 섹션은 뜨되 🤖 없이 +⭐만 있거나 비어 있는지, 에러 없이 생성까지 되는지 확인. 확인 후 파일명을 되돌린다. + +- [ ] **Step 7: 문서 갱신** + +`ARCHITECTURE.md`: +- 모듈 목록(§1)에 `recommend.py` 한 줄 추가 +- 댓글 카드 배치 설명에 "자동 탭은 컷 소속(`card_cuts`)으로 컷 구간 안에 배치, + 나머지 탭은 전체 균등(`_load_comment_cards`)" 추가 + +`SETUP.md` 문제 해결표에 추가: + +| 증상 | 원인 | 해결 | +|---|---|---| +| 댓글이 엉뚱한 장면에 뜬다 | 컷별 추천이 실패해 기존 전체 균등 배치로 폴백 | 검토 화면 상단 경고 확인. Gemini 한도 초과면 잠시 뒤 재시도 | +| 컷 섹션이 안 보이고 ⭐ 하나만 뜬다 | 추천 실패 폴백 | 위와 동일. `프롬프트/댓글_추천.md`를 고쳤다면 `{cuts}` `{comments}` `{k}` 가 남아 있는지 확인 | + +--- + +## 자체 점검 결과 + +**스펙 커버리지** + +| 스펙 항목 | 담당 태스크 | +|---|---| +| §4.1 모드 분기 | Task 5 (`is_whole`) | +| §4.2 컷 있는 모드 — Gemini + 타임스탬프 우선 + 중복 제거 | Task 2, 4, 5 | +| §4.2 통짜 — 슬롯 배정 + 좋아요 메움 | Task 1, 5 | +| §4.3 `quota = max(1, round(len/3))`, `need = Σ quota` | Task 2, 5 | +| §4.4 `card_cuts` 전달 + `placements` 계산 + 무음 재매핑 | Task 7, 8, 9 | +| §5 데이터 스키마 (`cuts[]`, `picks[].why`, `card_cuts`) | Task 5, 6, 8 | +| §6 변경 파일 6개 | Task 1~9 | +| §7 폴백 (쿼터·파싱 실패·부족·h-lab 실패) | Task 4(`{}` 반환), 6(try/except), 9(`hl.cuts` 없으면 기존 화면) | +| §8 검증 6항목 | Task 10 | + +**타입 일관성** + +- `picks` 원소는 전 구간 `{"idx": int, "why": str}` — Task 2/5가 만들고 Task 9가 읽는다 +- `cuts[]` 키는 `i, sec, bottom, quota, picks` — Task 5가 만들고 Task 6/9가 읽는다 +- `card_cuts`는 정수 배열 — Task 9(JS)가 만들고 Task 8(파싱)→Task 7(`_cards_by_cut`)이 읽는다 +- 카드 튜플은 `(start, end, path)` — `_load_comment_cards`/`_cards_by_cut`/`_remap_caps` 공통 diff --git a/docs/superpowers/specs/2026-07-31-자동탭-오팔대체-댓글자동매칭-design.md b/docs/superpowers/specs/2026-07-31-자동탭-오팔대체-댓글자동매칭-design.md new file mode 100644 index 0000000..0e7ed02 --- /dev/null +++ b/docs/superpowers/specs/2026-07-31-자동탭-오팔대체-댓글자동매칭-design.md @@ -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"(? 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//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 기본값을 그 결과로 조정한다. diff --git a/docs/superpowers/specs/2026-08-04-컷별-댓글-추천-design.md b/docs/superpowers/specs/2026-08-04-컷별-댓글-추천-design.md new file mode 100644 index 0000000..fd319a5 --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-컷별-댓글-추천-design.md @@ -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` | `(?/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. 열린 질문 + +없음. 미결이 생기면 여기에 적고 구현 전에 사용자에게 묻는다. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..02d0308 --- /dev/null +++ b/requirements.txt @@ -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 # 파일/유튜브 구간 탭의 자막 받아쓰기용(붙여넣기 탭만 쓰면 불필요) diff --git a/server/__init__.py b/server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/app.py b/server/app.py new file mode 100644 index 0000000..e5d8fe6 --- /dev/null +++ b/server/app.py @@ -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" diff --git a/server/static/auto.js b/server/static/auto.js new file mode 100644 index 0000000..a7eadc1 --- /dev/null +++ b/server/static/auto.js @@ -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= + '
'+ + ''+ + '
'+ + '
'+ + '
'+ + '
👍 '+(c.likeCount||0).toLocaleString()+''+ + '💬 '+(c.replyCount||0).toLocaleString()+'
'+ + '
'; + 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(//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=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='✔ 선택한 댓글'; + list.forEach((idx,i)=>{ + const c=byIdx[idx]; if(!c) return; + const chip=document.createElement("span"); + chip.className="selchip"; + chip.innerHTML=''+(i+1)+''+ + ''; + 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=' 선택한 것만 보기'; + 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=''+ + '영상 편집안'+ + ''+ + '컷 '+cuts.length+"개 · "+(hl.total!=null?hl.total:0)+"초"; + 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=''+(i+1)+""+ + ''+fmtT(c.start||0)+"~"+fmtT(c.end||0)+ + ''+dur.toFixed(1)+"초"+ + ''; + 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=> + '
'+ + '
'+esc(s.label)+'
'+ + '
').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='ID '+hl.id+' ✗생성 실패 (클릭=사유)'; + 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='ID '+hl.id+" · "+fmtT(hl.start)+"~"+fmtT(hl.end)+""+ + ''; + 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="

ID "+hl.id+" · "+fmtT(hl.start)+"~"+fmtT(hl.end)+ + " "+esc(hl.reason||"")+"

"+ + '
컷 '+cuts.length+"개 · 총 "+hl.total+"초 · 카드 "+hl.need+"장 필요 · "+ + '
'; + let opts=[]; + if(hl.editable_title){ // 구간 통짜: 제목 직접 입력(비우면 제목 없이) + html+='
제목: '+ + ' '+ + '
'; + }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+='
제목:
"; + } + html+='
'; + 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='▴ 댓글 접기카드 표시 상태'; + 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=> + '
'+ + 'ID '+hl.id+''+ + '⏳ 대기'+ + '
').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="

💬 구간 댓글

"+ + '
구간 '+rng.length+"개 · 총 "+r.total+"초 · 카드 "+r.need+"장 필요 · "+ + '
'+ + ''+ + '
'; + 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); +}); +})(); diff --git a/server/static/index.html b/server/static/index.html new file mode 100644 index 0000000..b4a1857 --- /dev/null +++ b/server/static/index.html @@ -0,0 +1,861 @@ + + + + + +캡컷 에이전트 · 구간합치기 + + + +
+
+
+

캡컷 에이전트 · 구간합치기

+ +
+
한 URL의 여러 구간 → 이어붙여 무음컷 · 자막 · 배경템플릿 → CapCut
+ + +
+ +
+ + + + +
+ + +
+
+
+
영상을 여기에 드롭하거나 클릭
+
mp4 · mov · mkv · webm
+ +
+ +
+ + + + + + + + + + + +
+
+
+
+
+
+
+
비워두면 그 텍스트는 안 들어가요. 붙여넣기 모드는 제목이 JSON에 들어갑니다.
+
+
+ + +
+
+ + +
폴더 안 이미지를 저장한 순서대로 3초마다 하단에 삽입. (파일명이 1,2,3…이면 그 숫자순)
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
붙여넣기: JSON의 bottom 대신, 음성을 인식해 말하는 타이밍에 맞춘 자막을 생성합니다.
+
+
+ + + +
+
+
+ + +
+ + 🩹 레이어 수리 — 영상이 흰 띠·댓글 위로 삐져나올 때 + +
+
+ 캡컷에서 클립을 복붙하거나 옮기면 캡컷이 그 클립의 + 렌더순서를 맨 위로 새로 매겨서, 확대했을 때 템플릿(흰 띠·댓글) 위로 삐져나옵니다.
+ ⚠ 캡컷을 완전히 종료한 뒤 누르세요 + (실행 중이면 거부합니다 — 캡컷이 저장하면서 되돌리기 때문). 원본은 + draft_content.repair.bak으로 백업됩니다. +
+
+ + +
+ +
+
+
+
+ + +
+
+ +
+
+
+ + + + + + diff --git a/server/static/modern-screenshot.js b/server/static/modern-screenshot.js new file mode 100644 index 0000000..a9c5208 --- /dev/null +++ b/server/static/modern-screenshot.js @@ -0,0 +1,14 @@ +(function(y,v){typeof exports=="object"&&typeof module!="undefined"?v(exports):typeof define=="function"&&define.amd?define(["exports"],v):(y=typeof globalThis!="undefined"?globalThis:y||self,v(y.modernScreenshot={}))})(this,function(y){"use strict";var rr=Object.defineProperty,nr=Object.defineProperties;var or=Object.getOwnPropertyDescriptors;var Z=Object.getOwnPropertySymbols;var xe=Object.prototype.hasOwnProperty,Me=Object.prototype.propertyIsEnumerable;var Oe=Math.pow,Le=(y,v,N)=>v in y?rr(y,v,{enumerable:!0,configurable:!0,writable:!0,value:N}):y[v]=N,D=(y,v)=>{for(var N in v||(v={}))xe.call(v,N)&&Le(y,N,v[N]);if(Z)for(var N of Z(v))Me.call(v,N)&&Le(y,N,v[N]);return y},M=(y,v)=>nr(y,or(v));var je=(y,v)=>{var N={};for(var R in y)xe.call(y,R)&&v.indexOf(R)<0&&(N[R]=y[R]);if(y!=null&&Z)for(var R of Z(y))v.indexOf(R)<0&&Me.call(y,R)&&(N[R]=y[R]);return N};var C=(y,v,N)=>new Promise((R,O)=>{var X=P=>{try{q(N.next(P))}catch(W){O(W)}},j=P=>{try{q(N.throw(P))}catch(W){O(W)}},q=P=>P.done?R(P.value):Promise.resolve(P.value).then(X,j);q((N=N.apply(y,v)).next())});var Be;function v(e,t){return e[13]=1,e[14]=t>>8,e[15]=t&255,e[16]=t>>8,e[17]=t&255,e}const N=112,R=72,O=89,X=115;let j;function q(){const e=new Int32Array(256);for(let t=0;t<256;t++){let r=t;for(let n=0;n<8;n++)r=r&1?3988292384^r>>>1:r>>>1;e[t]=r}return e}function P(e){let t=-1;j||(j=q());for(let r=0;r>>8;return t^-1}function W(e){const t=e.length-1;for(let r=t;r>=4;r--)if(e[r-4]===9&&e[r-3]===N&&e[r-2]===R&&e[r-1]===O&&e[r]===X)return r-3;return 0}function ae(e,t,r=!1){const n=new Uint8Array(13);t*=39.3701,n[0]=N,n[1]=R,n[2]=O,n[3]=X,n[4]=t>>>24,n[5]=t>>>16,n[6]=t>>>8,n[7]=t&255,n[8]=n[4],n[9]=n[5],n[10]=n[6],n[11]=n[7],n[12]=1;const i=P(n),a=new Uint8Array(4);if(a[0]=i>>>24,a[1]=i>>>16,a[2]=i>>>8,a[3]=i&255,r){const s=W(e);return e.set(n,s),e.set(a,s+13),e}else{const s=new Uint8Array(4);s[0]=0,s[1]=0,s[2]=0,s[3]=9;const o=new Uint8Array(54);return o.set(e,0),o.set(s,33),o.set(n,37),o.set(a,50),o}}const qe="AAlwSFlz",We="AAAJcEhZ",He="AAAACXBI";function Ve(e){let t=e.indexOf(qe);return t===-1&&(t=e.indexOf(We)),t===-1&&(t=e.indexOf(He)),t}const se="[modern-screenshot]",U=typeof window!="undefined",ze=U&&"Worker"in window,ie=U&&"atob"in window,Xe=U&&"btoa"in window,ee=U?(Be=window.navigator)==null?void 0:Be.userAgent:"",le=ee.includes("Chrome"),G=ee.includes("AppleWebKit")&&!le,te=ee.includes("Firefox"),Ge=e=>e&&"__CONTEXT__"in e,Ye=e=>e.constructor.name==="CSSFontFaceRule",Je=e=>e.constructor.name==="CSSImportRule",Ke=e=>e.constructor.name==="CSSLayerBlockRule",I=e=>e.nodeType===1,H=e=>typeof e.className=="object",ce=e=>e.tagName==="image",Qe=e=>e.tagName==="use",V=e=>I(e)&&typeof e.style!="undefined"&&!H(e),Ze=e=>e.nodeType===8,et=e=>e.nodeType===3,$=e=>e.tagName==="IMG",Y=e=>e.tagName==="VIDEO",tt=e=>e.tagName==="CANVAS",rt=e=>e.tagName==="TEXTAREA",nt=e=>e.tagName==="INPUT",ot=e=>e.tagName==="STYLE",at=e=>e.tagName==="SCRIPT",st=e=>e.tagName==="SELECT",it=e=>e.tagName==="SLOT",lt=e=>e.tagName==="IFRAME",ct=(...e)=>console.warn(se,...e);function ut(e){var r;const t=(r=e==null?void 0:e.createElement)==null?void 0:r.call(e,"canvas");return t&&(t.height=t.width=1),!!t&&"toDataURL"in t&&!!t.toDataURL("image/webp").includes("image/webp")}const re=e=>e.startsWith("data:");function ue(e,t){if(e.match(/^[a-z]+:\/\//i))return e;if(U&&e.match(/^\/\//))return window.location.protocol+e;if(e.match(/^[a-z]+:/i)||!U)return e;const r=J().implementation.createHTMLDocument(),n=r.createElement("base"),i=r.createElement("a");return r.head.appendChild(n),r.body.appendChild(i),t&&(n.href=t),i.href=e,i.href}function J(e){var t;return(t=e&&I(e)?e==null?void 0:e.ownerDocument:e)!=null?t:window.document}const K="http://www.w3.org/2000/svg";function fe(e,t,r){const n=J(r).createElementNS(K,"svg");return n.setAttributeNS(null,"width",e.toString()),n.setAttributeNS(null,"height",t.toString()),n.setAttributeNS(null,"viewBox",`0 0 ${e} ${t}`),n}function de(e,t){let r=new XMLSerializer().serializeToString(e);return t&&(r=r.replace(/[\u0000-\u0008\v\f\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/gu,"")),`data:image/svg+xml;charset=utf-8,${encodeURIComponent(r)}`}function ft(e,t="image/png",r=1){return C(this,null,function*(){try{return yield new Promise((n,i)=>{e.toBlob(a=>{a?n(a):i(new Error("Blob is null"))},t,r)})}catch(n){if(ie)return dt(e.toDataURL(t,r));throw n}})}function dt(e){var o,c;const[t,r]=e.split(","),n=(c=(o=t.match(/data:(.+);/))==null?void 0:o[1])!=null?c:void 0,i=window.atob(r),a=i.length,s=new Uint8Array(a);for(let u=0;u{const i=new FileReader;i.onload=()=>r(i.result),i.onerror=()=>n(i.error),i.onabort=()=>n(new Error(`Failed read blob to ${t}`)),t==="dataUrl"?i.readAsDataURL(e):t==="arrayBuffer"&&i.readAsArrayBuffer(e)})}const gt=e=>ge(e,"dataUrl"),mt=e=>ge(e,"arrayBuffer");function _(e,t){const r=J(t).createElement("img");return r.decoding="sync",r.loading="eager",r.src=e,r}function L(e,t){return new Promise(r=>{const{timeout:n,ownerDocument:i,onError:a,onWarn:s}=t!=null?t:{},o=typeof e=="string"?_(e,J(i)):e;let c=null,u=null;function l(){r(o),c&&clearTimeout(c),u==null||u()}if(n&&(c=setTimeout(l,n)),Y(o)){const d=o.currentSrc||o.src;if(!d)return o.poster?L(o.poster,t).then(r):l();if(o.readyState>=2)return l();const m=l,f=h=>{s==null||s("Failed video load",d,h),a==null||a(h),l()};u=()=>{o.removeEventListener("loadeddata",m),o.removeEventListener("error",f)},o.addEventListener("loadeddata",m,{once:!0}),o.addEventListener("error",f,{once:!0})}else{const d=ce(o)?o.href.baseVal:o.currentSrc||o.src;if(!d)return l();const m=()=>C(this,null,function*(){if($(o)&&"decode"in o)try{yield o.decode()}catch(h){s==null||s("Failed to decode image, trying to render anyway",o.dataset.originalSrc||d,h)}l()}),f=h=>{s==null||s("Failed image load",o.dataset.originalSrc||d,h),l()};if($(o)&&o.complete)return m();u=()=>{o.removeEventListener("load",m),o.removeEventListener("error",f)},o.addEventListener("load",m,{once:!0}),o.addEventListener("error",f,{once:!0})}})}function me(e,t){return C(this,null,function*(){V(e)&&($(e)||Y(e)?yield L(e,t):yield Promise.all(["img","video"].flatMap(r=>Array.from(e.querySelectorAll(r)).map(n=>L(n,t)))))})}const he=function(){let t=0;const r=()=>`0000${(Math.random()*Oe(36,4)<<0).toString(36)}`.slice(-4);return()=>(t+=1,`u${r()}${t}`)}();function we(e){return e==null?void 0:e.split(",").map(t=>t.trim().replace(/"|'/g,"").toLowerCase()).filter(Boolean)}let pe=0;function ht(e){const t=`${se}[#${pe}]`;return pe++,{time:r=>e&&console.time(`${t} ${r}`),timeEnd:r=>e&&console.timeEnd(`${t} ${r}`),warn:(...r)=>e&&ct(...r)}}function wt(e){return{cache:e?"no-cache":"force-cache"}}function k(e,t){return C(this,null,function*(){return Ge(e)?e:ye(e,M(D({},t),{autoDestruct:!0}))})}function ye(e,t){return C(this,null,function*(){var f,h,g,p,E;const{scale:r=1,workerUrl:n,workerNumber:i=1}=t||{},a=!!(t!=null&&t.debug),s=(f=t==null?void 0:t.features)!=null?f:!0,o=(h=e.ownerDocument)!=null?h:U?window.document:void 0,c=(p=(g=e.ownerDocument)==null?void 0:g.defaultView)!=null?p:U?window:void 0,u=new Map,l=M(D({width:0,height:0,quality:1,type:"image/png",scale:r,backgroundColor:null,style:null,filter:null,maximumCanvasSize:0,timeout:3e4,progress:null,debug:a,fetch:D({requestInit:wt((E=t==null?void 0:t.fetch)==null?void 0:E.bypassingCache),placeholderImage:"data:image/png;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",bypassingCache:!1},t==null?void 0:t.fetch),fetchFn:null,font:{},drawImageInterval:100,workerUrl:null,workerNumber:i,onCloneEachNode:null,onCloneNode:null,onEmbedNode:null,onCreateForeignObjectSvg:null,includeStyleProperties:null,autoDestruct:!1},t),{__CONTEXT__:!0,log:ht(a),node:e,ownerDocument:o,ownerWindow:c,dpi:r===1?null:96*r,svgStyleElement:be(o),svgDefsElement:o==null?void 0:o.createElementNS(K,"defs"),svgStyles:new Map,defaultComputedStyles:new Map,workers:[...Array.from({length:ze&&n&&i?i:0})].map(()=>{try{const b=new Worker(n);return b.onmessage=w=>C(this,null,function*(){var A,F,B,$e;const{url:S,result:T}=w.data;T?(F=(A=u.get(S))==null?void 0:A.resolve)==null||F.call(A,T):($e=(B=u.get(S))==null?void 0:B.reject)==null||$e.call(B,new Error(`Error receiving message from worker: ${S}`))}),b.onmessageerror=w=>{var T,A;const{url:S}=w.data;(A=(T=u.get(S))==null?void 0:T.reject)==null||A.call(T,new Error(`Error receiving message from worker: ${S}`))},b}catch(b){return l.log.warn("Failed to new Worker",b),null}}).filter(Boolean),fontFamilies:new Map,fontCssTexts:new Map,acceptOfImage:`${[ut(o)&&"image/webp","image/svg+xml","image/*","*/*"].filter(Boolean).join(",")};q=0.8`,requests:u,drawImageCount:0,tasks:[],features:s,isEnable:b=>{var w,S;return b==="restoreScrollPosition"?typeof s=="boolean"?!1:(w=s[b])!=null?w:!1:typeof s=="boolean"?s:(S=s[b])!=null?S:!0},shadowRoots:[]});l.log.time("wait until load"),yield me(e,{timeout:l.timeout,onWarn:l.log.warn}),l.log.timeEnd("wait until load");const{width:d,height:m}=pt(e,l);return l.width=d,l.height=m,l})}function be(e){if(!e)return;const t=e.createElement("style"),r=t.ownerDocument.createTextNode(` +.______background-clip--text { + background-clip: text; + -webkit-background-clip: text; +} +`);return t.appendChild(r),t}function pt(e,t){let{width:r,height:n}=t;if(I(e)&&(!r||!n)){const i=e.getBoundingClientRect();r=r||i.width||Number(e.getAttribute("width"))||0,n=n||i.height||Number(e.getAttribute("height"))||0}return{width:r,height:n}}function yt(e,t){return C(this,null,function*(){const{log:r,timeout:n,drawImageCount:i,drawImageInterval:a}=t;r.time("image to canvas");const s=yield L(e,{timeout:n,onWarn:t.log.warn}),{canvas:o,context2d:c}=bt(e.ownerDocument,t),u=()=>{try{c==null||c.drawImage(s,0,0,o.width,o.height)}catch(l){t.log.warn("Failed to drawImage",l)}};if(u(),t.isEnable("fixSvgXmlDecode"))for(let l=0;l{setTimeout(()=>{c==null||c.clearRect(0,0,o.width,o.height),u(),d()},l+a)});return t.drawImageCount=0,r.timeEnd("image to canvas"),o})}function bt(e,t){const{width:r,height:n,scale:i,backgroundColor:a,maximumCanvasSize:s}=t,o=e.createElement("canvas");o.width=Math.floor(r*i),o.height=Math.floor(n*i),o.style.width=`${r}px`,o.style.height=`${n}px`,s&&(o.width>s||o.height>s)&&(o.width>s&&o.height>s?o.width>o.height?(o.height*=s/o.width,o.width=s):(o.width*=s/o.height,o.height=s):o.width>s?(o.height*=s/o.width,o.width=s):(o.width*=s/o.height,o.height=s));const c=o.getContext("2d");return c&&a&&(c.fillStyle=a,c.fillRect(0,0,o.width,o.height)),{canvas:o,context2d:c}}function Se(e,t){if(e.ownerDocument)try{const a=e.toDataURL();if(a!=="data:,")return _(a,e.ownerDocument)}catch(a){t.log.warn("Failed to clone canvas",a)}const r=e.cloneNode(!1),n=e.getContext("2d"),i=r.getContext("2d");try{return n&&i&&i.putImageData(n.getImageData(0,0,e.width,e.height),0,0),r}catch(a){t.log.warn("Failed to clone canvas",a)}return r}function St(e,t){var r;try{if((r=e==null?void 0:e.contentDocument)!=null&&r.documentElement)return ne(e.contentDocument.documentElement,t)}catch(n){t.log.warn("Failed to clone iframe",n)}return e.cloneNode(!1)}function Et(e){const t=e.cloneNode(!1);return e.currentSrc&&e.currentSrc!==e.src&&(t.src=e.currentSrc,t.srcset=""),t.loading==="lazy"&&(t.loading="eager"),t}function Ct(e,t){return C(this,null,function*(){if(e.ownerDocument&&!e.currentSrc&&e.poster)return _(e.poster,e.ownerDocument);const r=e.cloneNode(!1);r.crossOrigin="anonymous",e.currentSrc&&e.currentSrc!==e.src&&(r.src=e.currentSrc);const n=r.ownerDocument;if(n){let i=!0;if(yield L(r,{onError:()=>i=!1,onWarn:t.log.warn}),!i)return e.poster?_(e.poster,e.ownerDocument):r;r.currentTime=e.currentTime,yield new Promise(s=>{r.addEventListener("seeked",s,{once:!0})});const a=n.createElement("canvas");a.width=e.offsetWidth,a.height=e.offsetHeight;try{const s=a.getContext("2d");s&&s.drawImage(r,0,0,a.width,a.height)}catch(s){return t.log.warn("Failed to clone video",s),e.poster?_(e.poster,e.ownerDocument):r}return Se(a,t)}return r})}function Tt(e,t){return tt(e)?Se(e,t):lt(e)?St(e,t):$(e)?Et(e):Y(e)?Ct(e,t):e.cloneNode(!1)}function vt(e){let t=e.sandbox;if(!t){const{ownerDocument:r}=e;try{r&&(t=r.createElement("iframe"),t.id=`__SANDBOX__${he()}`,t.width="0",t.height="0",t.style.visibility="hidden",t.style.position="fixed",r.body.appendChild(t),t.srcdoc='',e.sandbox=t)}catch(n){e.log.warn("Failed to getSandBox",n)}}return t}const At=["width","height","-webkit-text-fill-color"],Nt=["stroke","fill"];function Ee(e,t,r){const{defaultComputedStyles:n}=r,i=e.nodeName.toLowerCase(),a=H(e)&&i!=="svg",s=a?Nt.map(g=>[g,e.getAttribute(g)]).filter(([,g])=>g!==null):[],o=[a&&"svg",i,s.map((g,p)=>`${g}=${p}`).join(","),t].filter(Boolean).join(":");if(n.has(o))return n.get(o);const c=vt(r),u=c==null?void 0:c.contentWindow;if(!u)return new Map;const l=u==null?void 0:u.document;let d,m;a?(d=l.createElementNS(K,"svg"),m=d.ownerDocument.createElementNS(d.namespaceURI,i),s.forEach(([g,p])=>{m.setAttributeNS(null,g,p)}),d.appendChild(m)):d=m=l.createElement(i),m.textContent=" ",l.body.appendChild(d);const f=u.getComputedStyle(m,t),h=new Map;for(let g=f.length,p=0;pn.set(d,l));function s(c){const u=e.getPropertyValue(c),l=e.getPropertyPriority(c),d=c.lastIndexOf("-"),m=d>-1?c.substring(0,d):void 0;if(m){let f=a.get(m);f||(f=new Map,a.set(m,f)),f.set(c,[u,l])}t.get(c)===u&&!l||(m?i.push(m):n.set(c,[u,l]))}return n}function Rt(e,t,r,n){var d,m,f,h;const{ownerWindow:i,includeStyleProperties:a,currentParentNodeStyle:s}=n,o=t.style,c=i.getComputedStyle(e),u=Ee(e,null,n);s==null||s.forEach((g,p)=>{u.delete(p)});const l=Ce(c,u,a);l.delete("transition-property"),l.delete("all"),l.delete("d"),l.delete("content"),r&&(l.delete("position"),l.delete("margin-top"),l.delete("margin-right"),l.delete("margin-bottom"),l.delete("margin-left"),l.delete("margin-block-start"),l.delete("margin-block-end"),l.delete("margin-inline-start"),l.delete("margin-inline-end"),l.set("box-sizing",["border-box",""])),((d=l.get("background-clip"))==null?void 0:d[0])==="text"&&t.classList.add("______background-clip--text"),le&&(l.has("font-kerning")||l.set("font-kerning",["normal",""]),(((m=l.get("overflow-x"))==null?void 0:m[0])==="hidden"||((f=l.get("overflow-y"))==null?void 0:f[0])==="hidden")&&((h=l.get("text-overflow"))==null?void 0:h[0])==="ellipsis"&&e.scrollWidth===e.clientWidth&&l.set("text-overflow",["clip",""]));for(let g=o.length,p=0;p{o.setProperty(E,g,p)}),l}function It(e,t){(rt(e)||nt(e)||st(e))&&t.setAttribute("value",e.value)}const kt=["::before","::after"],Dt=["::-webkit-scrollbar","::-webkit-scrollbar-button","::-webkit-scrollbar-thumb","::-webkit-scrollbar-track","::-webkit-scrollbar-track-piece","::-webkit-scrollbar-corner","::-webkit-resizer"];function Pt(e,t,r,n,i){const{ownerWindow:a,svgStyleElement:s,svgStyles:o,currentNodeStyle:c}=n;if(!s||!a)return;function u(l){var w;const d=a.getComputedStyle(e,l);let m=d.getPropertyValue("content");if(!m||m==="none")return;i==null||i(m),m=m.replace(/(')|(")|(counter\(.+\))/g,"");const f=[he()],h=Ee(e,l,n);c==null||c.forEach((S,T)=>{h.delete(T)});const g=Ce(d,h,n.includeStyleProperties);g.delete("content"),g.delete("-webkit-locale"),((w=g.get("background-clip"))==null?void 0:w[0])==="text"&&t.classList.add("______background-clip--text");const p=[`content: '${m}';`];if(g.forEach(([S,T],A)=>{p.push(`${A}: ${S}${T?" !important":""};`)}),p.length===1)return;try{t.className=[t.className,...f].join(" ")}catch(S){n.log.warn("Failed to copyPseudoClass",S);return}const E=p.join(` + `);let b=o.get(E);b||(b=[],o.set(E,b)),b.push(`.${f[0]}${l}`)}kt.forEach(u),r&&Dt.forEach(u)}const Te=new Set(["symbol"]);function ve(e,t,r,n,i){return C(this,null,function*(){if(I(r)&&(ot(r)||at(r))||n.filter&&!n.filter(r))return;Te.has(t.nodeName)||Te.has(r.nodeName)?n.currentParentNodeStyle=void 0:n.currentParentNodeStyle=n.currentNodeStyle;const a=yield ne(r,n,!1,i);n.isEnable("restoreScrollPosition")&&Ut(e,a),t.appendChild(a)})}function Ae(e,t,r,n){return C(this,null,function*(){var a;let i=e.firstChild;I(e)&&e.shadowRoot&&(i=(a=e.shadowRoot)==null?void 0:a.firstChild,r.shadowRoots.push(e.shadowRoot));for(let s=i;s;s=s.nextSibling)if(!Ze(s))if(I(s)&&it(s)&&typeof s.assignedNodes=="function"){const o=s.assignedNodes();for(let c=0;ce.clientHeight||e.scrollWidth>e.clientWidth)}const p=(d=h.get("text-transform"))==null?void 0:d[0],E=we((m=h.get("font-family"))==null?void 0:m[0]),b=E?w=>{p==="uppercase"?w=w.toUpperCase():p==="lowercase"?w=w.toLowerCase():p==="capitalize"&&(w=w[0].toUpperCase()+w.substring(1)),E.forEach(S=>{let T=s.get(S);T||s.set(S,T=new Set),w.split("").forEach(A=>T.add(A))})}:void 0;return Pt(e,f,g,t,b),It(e,f),Y(e)||(yield Ae(e,f,t,b)),yield o==null?void 0:o(f),f}const c=e.cloneNode(!1);return yield Ae(e,c,t),yield o==null?void 0:o(c),c})}function Ne(e){if(e.ownerDocument=void 0,e.ownerWindow=void 0,e.svgStyleElement=void 0,e.svgDefsElement=void 0,e.svgStyles.clear(),e.defaultComputedStyles.clear(),e.sandbox){try{e.sandbox.remove()}catch(t){e.log.warn("Failed to destroyContext",t)}e.sandbox=void 0}e.workers=[],e.fontFamilies.clear(),e.fontCssTexts.clear(),e.requests.clear(),e.tasks=[],e.shadowRoots=[]}function Bt(e){const o=e,{url:t,timeout:r,responseType:n}=o,i=je(o,["url","timeout","responseType"]),a=new AbortController,s=r?setTimeout(()=>a.abort(),r):void 0;return fetch(t,D({signal:a.signal},i)).then(c=>{if(!c.ok)throw new Error("Failed fetch, not 2xx response",{cause:c});switch(n){case"arrayBuffer":return c.arrayBuffer();case"dataUrl":return c.blob().then(gt);case"text":default:return c.text()}}).finally(()=>clearTimeout(s))}function z(e,t){const{url:r,requestType:n="text",responseType:i="text",imageDom:a}=t;let s=r;const{timeout:o,acceptOfImage:c,requests:u,fetchFn:l,fetch:{requestInit:d,bypassingCache:m,placeholderImage:f},font:h,workers:g,fontFamilies:p}=e;n==="image"&&(G||te)&&e.drawImageCount++;let E=u.get(r);if(!E){m&&m instanceof RegExp&&m.test(s)&&(s+=(/\?/.test(s)?"&":"?")+new Date().getTime());const b=n.startsWith("font")&&h&&h.minify,w=new Set;b&&n.split(";")[1].split(",").forEach(F=>{p.has(F)&&p.get(F).forEach(B=>w.add(B))});const S=b&&w.size,T=D({url:s,timeout:o,responseType:S?"arrayBuffer":i,headers:n==="image"?{accept:c}:void 0},d);E={type:n,resolve:void 0,reject:void 0,response:null},E.response=C(this,null,function*(){if(l&&n==="image"){const A=yield l(r);if(A)return A}return!G&&r.startsWith("http")&&g.length?new Promise((A,F)=>{g[u.size&g.length-1].postMessage(D({rawUrl:r},T)),E.resolve=A,E.reject=F}):Bt(T)}).catch(A=>{if(u.delete(r),n==="image"&&f)return e.log.warn("Failed to fetch image base64, trying to use placeholder image",s),typeof f=="string"?f:f(a);throw A}),u.set(r,E)}return E.response}function Re(e,t,r,n){return C(this,null,function*(){if(!Ie(e))return e;for(const[i,a]of $t(e,t))try{const s=yield z(r,{url:a,requestType:n?"image":"text",responseType:"dataUrl"});e=e.replace(Lt(i),`$1${s}$3`)}catch(s){r.log.warn("Failed to fetch css data url",i,s)}return e})}function Ie(e){return/url\((['"]?)([^'"]+?)\1\)/.test(e)}const ke=/url\((['"]?)([^'"]+?)\1\)/g;function $t(e,t){const r=[];return e.replace(ke,(n,i,a)=>(r.push([a,ue(a,t)]),n)),r.filter(([n])=>!re(n))}function Lt(e){const t=e.replace(/([.*+?^${}()|\[\]\/\\])/g,"\\$1");return new RegExp(`(url\\(['"]?)(${t})(['"]?\\))`,"g")}const xt=["background-image","border-image-source","-webkit-border-image","-webkit-mask-image","list-style-image"];function Mt(e,t){return xt.map(r=>{const n=e.getPropertyValue(r);return!n||n==="none"?null:((G||te)&&t.drawImageCount++,Re(n,null,t,!0).then(i=>{!i||n===i||e.setProperty(r,i,e.getPropertyPriority(r))}))}).filter(Boolean)}function Ot(e,t){if($(e)){const r=e.currentSrc||e.src;if(!re(r))return[z(t,{url:r,imageDom:e,requestType:"image",responseType:"dataUrl"}).then(n=>{n&&(e.srcset="",e.dataset.originalSrc=r,e.src=n||"")})];(G||te)&&t.drawImageCount++}else if(H(e)&&!re(e.href.baseVal)){const r=e.href.baseVal;return[z(t,{url:r,imageDom:e,requestType:"image",responseType:"dataUrl"}).then(n=>{n&&(e.dataset.originalSrc=r,e.href.baseVal=n||"")})]}return[]}function jt(e,t){var o;const{ownerDocument:r,svgDefsElement:n}=t,i=(o=e.getAttribute("href"))!=null?o:e.getAttribute("xlink:href");if(!i)return[];const[a,s]=i.split("#");if(s){const c=`#${s}`,u=t.shadowRoots.reduce((l,d)=>l!=null?l:d.querySelector(`svg ${c}`),r==null?void 0:r.querySelector(`svg ${c}`));if(a&&e.setAttribute("href",c),n!=null&&n.querySelector(c))return[];if(u)return n==null||n.appendChild(u.cloneNode(!0)),[];if(a)return[z(t,{url:a,responseType:"text"}).then(l=>{n==null||n.insertAdjacentHTML("beforeend",l)})]}return[]}function De(e,t){const{tasks:r}=t;I(e)&&(($(e)||ce(e))&&r.push(...Ot(e,t)),Qe(e)&&r.push(...jt(e,t))),V(e)&&r.push(...Mt(e.style,t)),e.childNodes.forEach(n=>{De(n,t)})}function qt(e,t){return C(this,null,function*(){const{ownerDocument:r,svgStyleElement:n,fontFamilies:i,fontCssTexts:a,tasks:s,font:o}=t;if(!(!r||!n||!i.size))if(o&&o.cssText){const c=Ue(o.cssText,t);n.appendChild(r.createTextNode(`${c} +`))}else{const c=Array.from(r.styleSheets).filter(f=>{try{return"cssRules"in f&&!!f.cssRules.length}catch(h){return t.log.warn(`Error while reading CSS rules from ${f.href}`,h),!1}}),u=r.implementation.createHTMLDocument(""),l=u.createElement("style");u.head.appendChild(l);const d=l.sheet;yield Promise.all(c.flatMap(f=>Array.from(f.cssRules).map(h=>C(this,null,function*(){if(Je(h)){const g=h.href;let p="";try{p=yield z(t,{url:g,requestType:"text",responseType:"text"})}catch(b){t.log.warn(`Error fetch remote css import from ${g}`,b)}const E=p.replace(ke,(b,w,S)=>b.replace(S,ue(S,g)));for(const b of Ht(E))try{d.insertRule(b,d.cssRules.length)}catch(w){t.log.warn("Error inserting rule from remote css import",{rule:b,error:w})}}})))),d.cssRules.length&&c.push(d);const m=[];c.forEach(f=>{oe(f.cssRules,m)}),m.filter(f=>{var h;return Ye(f)&&Ie(f.style.getPropertyValue("src"))&&((h=we(f.style.getPropertyValue("font-family")))==null?void 0:h.some(g=>i.has(g)))}).forEach(f=>{const h=f,g=a.get(h.cssText);g?n.appendChild(r.createTextNode(`${g} +`)):s.push(Re(h.cssText,h.parentStyleSheet?h.parentStyleSheet.href:null,t).then(p=>{p=Ue(p,t),a.set(h.cssText,p),n.appendChild(r.createTextNode(`${p} +`))}))})}})}const Wt=/(\/\*[\s\S]*?\*\/)/g,Pe=/((@.*?keyframes [\s\S]*?){([\s\S]*?}\s*?)})/gi;function Ht(e){if(e==null)return[];const t=[];let r=e.replace(Wt,"");for(;;){const a=Pe.exec(r);if(!a)break;t.push(a[0])}r=r.replace(Pe,"");const n=/@import[\s\S]*?url\([^)]*\)[\s\S]*?;/gi,i=new RegExp("((\\s*?(?:\\/\\*[\\s\\S]*?\\*\\/)?\\s*?@media[\\s\\S]*?){([\\s\\S]*?)}\\s*?})|(([\\s\\S]*?){([\\s\\S]*?)})","gi");for(;;){let a=n.exec(r);if(a)i.lastIndex=n.lastIndex;else if(a=i.exec(r),a)n.lastIndex=i.lastIndex;else break;t.push(a[0])}return t}const Vt=/url\([^)]+\)\s*format\((["']?)([^"']+)\1\)/g,zt=/src:\s*(?:url\([^)]+\)\s*format\([^)]+\)[,;]\s*)+/g;function Ue(e,t){const{font:r}=t,n=r?r==null?void 0:r.preferredFormat:void 0;return n?e.replace(zt,i=>{for(;;){const[a,,s]=Vt.exec(i)||[];if(!s)return"";if(s===n)return`src: ${a};`}}):e}function oe(e,t=[]){for(const r of Array.from(e))Ke(r)?t.push(...oe(r.cssRules)):"cssRules"in r?oe(r.cssRules,t):t.push(r);return t}const Xt=/\bx?link:?href\s*=\s*["'](?!data:)[^"']+["']/i;function Gt(e){return Xt.test(e.innerHTML)}function _e(e,t){return C(this,null,function*(){const r=yield k(e,t);if(I(r.node)&&H(r.node)&&!Gt(r.node))return r.node;const{ownerDocument:n,log:i,tasks:a,svgStyleElement:s,svgDefsElement:o,svgStyles:c,font:u,progress:l,autoDestruct:d,onCloneNode:m,onEmbedNode:f,onCreateForeignObjectSvg:h}=r;i.time("clone node");const g=yield ne(r.node,r,!0);if(s&&n){let S="";c.forEach((T,A)=>{S+=`${T.join(`, +`)} { + ${A} +} +`}),s.appendChild(n.createTextNode(S))}i.timeEnd("clone node"),yield m==null?void 0:m(g),u!==!1&&I(g)&&(i.time("embed web font"),yield qt(g,r),i.timeEnd("embed web font")),i.time("embed node"),De(g,r);const p=a.length;let E=0;const b=()=>C(this,null,function*(){for(;;){const S=a.pop();if(!S)break;try{yield S}catch(T){r.log.warn("Failed to run task",T)}l==null||l(++E,p)}});l==null||l(E,p),yield Promise.all([...Array.from({length:4})].map(b)),i.timeEnd("embed node"),yield f==null?void 0:f(g);const w=Yt(g,r);return o&&w.insertBefore(o,w.children[0]),s&&w.insertBefore(s,w.children[0]),d&&Ne(r),yield h==null?void 0:h(w),w})}function Yt(e,t){const{width:r,height:n}=t,i=fe(r,n,e.ownerDocument),a=i.ownerDocument.createElementNS(i.namespaceURI,"foreignObject");return a.setAttributeNS(null,"x","0%"),a.setAttributeNS(null,"y","0%"),a.setAttributeNS(null,"width","100%"),a.setAttributeNS(null,"height","100%"),a.append(e),i.appendChild(a),i}function Q(e,t){return C(this,null,function*(){var s;const r=yield k(e,t),n=yield _e(r),i=de(n,r.isEnable("removeControlCharacter"));r.autoDestruct||(r.svgStyleElement=be(r.ownerDocument),r.svgDefsElement=(s=r.ownerDocument)==null?void 0:s.createElementNS(K,"defs"),r.svgStyles.clear());const a=_(i,n.ownerDocument);return yield yt(a,r)})}function Jt(e,t){return C(this,null,function*(){const r=yield k(e,t),{log:n,type:i,quality:a,dpi:s}=r,o=yield Q(r);n.time("canvas to blob");const c=yield ft(o,i,a);if(["image/png","image/jpeg"].includes(i)&&s){const u=yield mt(c.slice(0,33));let l=new Uint8Array(u);return i==="image/png"?l=ae(l,s):i==="image/jpeg"&&(l=v(l,s)),n.timeEnd("canvas to blob"),new Blob([l,c.slice(33)],{type:i})}return n.timeEnd("canvas to blob"),c})}function x(e,t){return C(this,null,function*(){const r=yield k(e,t),{log:n,quality:i,type:a,dpi:s}=r,o=yield Q(r);n.time("canvas to data url");let c=o.toDataURL(a,i);if(["image/png","image/jpeg"].includes(a)&&s&&ie&&Xe){const[u,l]=c.split(",");let d=0,m=!1;if(a==="image/png"){const w=Ve(l);w>=0?(d=Math.ceil((w+28)/3)*4,m=!0):d=33/3*4}else a==="image/jpeg"&&(d=18/3*4);const f=l.substring(0,d),h=l.substring(d),g=window.atob(f),p=new Uint8Array(g.length);for(let w=0;w ⚠ 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`** + +- 페이지 하단 `
` **"🩹 레이어 수리"** 섹션. 펼치면 `/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//`** 아래로 옮겨갔다. `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`을 잠그면 사용자가 편집을 못 한다. diff --git a/배경.png b/배경.png new file mode 100644 index 0000000000000000000000000000000000000000..ea2b8145a3f3ca10f5ef01cb484d2df61f520dd5 GIT binary patch literal 16019 zcmeAS@N?(olHy`uVBq!ia0y~yV6k9eU~k}H1Bz%TJ_kxL+GjdD2V^EEGcZ)lshzOj zo2gLZnEmz8X%W1ZCL%JbzN~@X!HsQxfMAPY?oDT(@_bLAz+EYku5F@xKetvWt=t(G@bS`l zh2&j|XP$Z(a4CG5WRRnMo`3KC#V0Dw_pTGaY`y;a>XWQXcUcGavD$~}{fK{8;wEuG z{DHC0X*O5pW8T{|D>*+%y{?j)VquVaRM<1%+miX&A3L9fbnB}|Uo2plBDLVDmBrPA zt$()d(N^96J*8_S#|8EtB@W?3|J|2upUEkZz?Krfzgpnt&YRb!r3+TglX;#i6w`je zqG-$YUHk1{8tb#y3*@%!yC}(7N!3<CJ_t_ z3Jjhujv*Cu-rU&8$>1QsbnyNDm%o*GTxPVM?5%yj7i4?I?N=a*K`%reL=`Oj1)>_b zuYwJVSz!m_K5+U9@-lcsAwBdu4Np`&Cjrs1;sOs4z2v8i42V|Av}11v zOU*ieP!X);*{G7ya2ZVuqnTv1WB`?rqs8H9H8EN@jFj z1iGh$1*W5A0mEp42uyc_ClJ<-7KjX^wE{3n4xT{B7;PNUx^YCC^5@?mN<`Fd@I{R_ z+(&yapcW&2x^VHki!*?hH8W)Vw}0Db5W!K>_xcRDL!)^XH?upm!=* hU<&SNFEJsL`WQGNc*n;z7NG6t44$rjF6*2UngDkx47vaS literal 0 HcmV?d00001 diff --git a/숏폼_편집_지침서_v13.7_capcut2연동판.md b/숏폼_편집_지침서_v13.7_capcut2연동판.md new file mode 100644 index 0000000..3563d32 --- /dev/null +++ b/숏폼_편집_지침서_v13.7_capcut2연동판.md @@ -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선 → ③ 컷 길이 검산표)에 맞춘 결과물을 출력해 주십시오. \ No newline at end of file diff --git a/작업기록_2026-07-27~31.md b/작업기록_2026-07-27~31.md new file mode 100644 index 0000000..33bd2e2 --- /dev/null +++ b/작업기록_2026-07-27~31.md @@ -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` +페이지 하단 `
` 섹션. 펼치면 `/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). + 자물쇠를 풀고 같이 늘려야 한다. 자동화 안 됨. diff --git a/참고/ytcut/Dockerfile b/참고/ytcut/Dockerfile new file mode 100644 index 0000000..8b6f4d1 --- /dev/null +++ b/참고/ytcut/Dockerfile @@ -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 diff --git a/참고/ytcut/README.md b/참고/ytcut/README.md new file mode 100644 index 0000000..8d2428e --- /dev/null +++ b/참고/ytcut/README.md @@ -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` 으로 프록시하면 됨. diff --git a/참고/ytcut/app/main.py b/참고/ytcut/app/main.py new file mode 100644 index 0000000..816a5bc --- /dev/null +++ b/참고/ytcut/app/main.py @@ -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 ('{body}') + + +IC_FILM = _svg('' + '') +IC_SCISSORS = _svg('' + '') +IC_LINK = _svg('' + '' + '') +IC_DOWNLOAD = _svg('' + '') +IC_TRASH = _svg('' + '' + '') +IC_CLOCK = _svg('') + + +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'
{IC_FILM}' + '

아직 자른 클립이 없어요.
위에서 첫 구간을 잘라보세요.

') + 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( + '
  • ' + f'{IC_FILM}' + '' + f'{esc}' + f'{_human(size)} · {date}' + '' + f'{IC_DOWNLOAD}' + '
    ' + f'' + '' + '
    ' + '
  • ' + ) + body = '
      ' + "".join(rows) + '
    ' + return ( + '
    ' + '

    파일

    ' + f'{len(items)}개
    ' + f'{body}
    ' + ) + + +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 = """ + + + +ClipCut · 유튜브 구간 잘라받기 + + + + + +""" + + +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'{IC_DOWNLOAD}파일 받기' + f'{html.escape(download_name)}') + banner = (f'') + + 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} + +
    +
    +

    구간을 자르는 중…영상 길이에 따라 몇 초~몇 분 걸려요

    +
    +
    +
    + +
    +

    ClipCut

    +
    유튜브 구간을 잘라 바로 받기
    +
    +
    + +
    + + + {banner} + +
    +
    + +
    {IC_LINK} + +
    +
    +
    +
    + +
    {IC_CLOCK} + +
    +
    +
    + +
    {IC_CLOCK} + +
    +
    +
    +

    {IC_CLOCK}형식: 분:초(03:30) 또는 시:분:초(01:03:30)

    + +
    +
    + + {_file_list_html()} +
    + +""" + + +FAVICON = ( + '' + '' + '' + '' +) + + +@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) diff --git a/참고/ytcut/app/requirements.txt b/참고/ytcut/app/requirements.txt new file mode 100644 index 0000000..64f14f8 --- /dev/null +++ b/참고/ytcut/app/requirements.txt @@ -0,0 +1,4 @@ +fastapi +uvicorn[standard] +yt-dlp +python-multipart diff --git a/참고/ytcut/docker-compose.yml b/참고/ytcut/docker-compose.yml new file mode 100644 index 0000000..9442cc4 --- /dev/null +++ b/참고/ytcut/docker-compose.yml @@ -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 diff --git a/참고/ytcut/downloads/.gitkeep b/참고/ytcut/downloads/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/캡컷_에이전트_구간합치기.bat b/캡컷_에이전트_구간합치기.bat new file mode 100644 index 0000000..c3d7330 --- /dev/null +++ b/캡컷_에이전트_구간합치기.bat @@ -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 diff --git a/프롬프트/설정.json b/프롬프트/설정.json new file mode 100644 index 0000000..473bd92 --- /dev/null +++ b/프롬프트/설정.json @@ -0,0 +1,6 @@ +{ + "model": "gemini-3.5-flash", + "model_step1": "", + "fps_step1": 0.2, + "fps_step3": 1.0 +} \ No newline at end of file diff --git a/프롬프트/하이라이트_선정.md b/프롬프트/하이라이트_선정.md new file mode 100644 index 0000000..3e501b4 --- /dev/null +++ b/프롬프트/하이라이트_선정.md @@ -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개) + ] +}