# 자동 탭 — 오팔 대체 + 댓글 카드 자동 매칭 (설계) 작성일: 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 기본값을 그 결과로 조정한다.