컷별 댓글 추천 작업을 태스크 단위로 되돌릴 수 있게 버전관리를 시작한다. .gitignore 로 영상·캐시(.downloads 2.7G, .comments 72M, .media 28M)와 비밀키(.gemini_key)를 제외했다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
18 KiB
자동 탭 — 오팔 대체 + 댓글 카드 자동 매칭 (설계)
작성일: 2026-07-31
1. 배경과 목적
지금 숏폼 5개를 만들려면 사람이 이 순서로 움직인다:
- 오팔(opal.google.com) 편집기를 열어 Step 3 노드 5개를 하나씩 클릭해 JSON을 복사
- capcut2 붙여넣기 탭에 붙여넣고 실행 → 끝날 때까지 기다림 → 다음 것 복사 (5회 반복)
- 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
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의 역할):
{"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부터) 으로 줄지 보장이 없다(문서에 명시 없음). 어느 쪽이 와도 살아남게 휴리스틱으로 보정한다:
- 모든 컷이
[start−10, end+10]안 → 절대 기준으로 보고 그대로 둔다 (우선) - 아니고 모든 컷이
[0, 클립길이+10]안이며start > 10→ 클립 기준으로 보고start를 더한다 - 둘 다 아니면 손대지 않는다 (이후
parse_paste·다운로드 단계에서 자연히 드러남)
보정이 일어나면 로그에 남긴다.
4.2 capcut_agent/comments.py
H_LAB = "https://h-lab.tolag.shop"
def fetch_comments(url, *, timeout=180) -> list[dict]
# POST /api/comment-cards/fetch → data[] 그대로 + idx 부여 + times 계산
TS_RE = r"(?<!\d)(\d{1,2}):([0-5]\d)(?::([0-5]\d))?(?!\d)" # h-lab comment-cards.js 와 동일
def match_window(comments, start, end) -> list[dict] # 구간 언급 댓글, 좋아요 내림차순
def top_liked(comments, exclude_idx, n=20) -> list[dict]
- 2조각이면
mm:ss, 3조각이면h:mm:ss— h-lab JS 규칙을 그대로 옮긴다. - 한 댓글에 여러 시각이 있으면 하나라도 구간 안에 들면 매칭.
- 댓글 원본에 id가 없으므로 h-lab과 같이 배열 인덱스를 식별자로 쓴다.
4.3 엔드포인트
| 메서드 | 경로 | 내용 |
|---|---|---|
| POST | /auto/analyze |
Form url → {analysis_id}. 실제 작업은 아래 스트림에서 |
| GET | /auto/stream/{analysis_id} |
SSE. 기존 /stream과 동일한 이벤트(manifest/step/log/result/error) |
| GET | /auto/avatar?url= |
프로필 이미지 프록시. ggpht.com·googleusercontent.com만 허용 |
| GET·POST | /prompts |
프롬프트 두 개 읽기/저장. POST에 reset=1이면 기본값 복원 |
| POST | /auto/build |
multipart. 하이라이트 1개 + 카드 PNG들 → {job_id} (기존 /stream으로 진행 표시) |
/auto/stream의 최종 result 이벤트 payload:
{"type":"result",
"highlights":[{"id":1,"start":134.0,"end":242.0,"reason":"…",
"paste":{…parse_paste 결과…},
"titles":[{"top":"…","main":"…","kind":"어그로형"}, …],
"total":52.3,"need":17,
"matched":[12,45,3,…], // 댓글 인덱스, 좋아요순
"candidates":[7,19,…]}], // 좋아요 상위 20 (matched 제외)
"comments":[{"idx":0,"authorName":"…","text":"…","likeCount":275098,
"replyCount":1000,"publishedAt":"…","profileImageUrl":"…","times":[134.0]}],
"warnings":["h-lab 연결 실패 — 댓글 없이 진행합니다"]}
/auto/build 폼 필드:
| 필드 | 값 |
|---|---|
data |
하이라이트의 paste JSON 문자열 (붙여넣기 탭과 동일 스키마) |
cards |
PNG 파일 여러 개, 화면에 보인 순서 그대로 |
video_scale flip scene bg_white remove_silence asr_bottom |
공통 옵션. /paste와 동일 |
서버는 data를 parse_paste()로 검증하고, 카드들을 .comments/<job_id>/001.png…에
순서대로 저장한 뒤 그 폴더를 comments_dir로 하는 job을 만든다. 그 뒤는 기존
process_paste() 경로를 그대로 탄다.
파이프라인에 허용하는 수정은 딱 하나 — process_paste(name_suffix="") 파라미터 추가.
현재 드래프트 이름은 영상 제목으로 덮어써지고(pipeline.py:340) pycapcut은
allow_replace=True라 같은 이름이면 이전 드래프트를 교체한다. 같은 영상에서 5개를
만들면 컷 개수가 같은 하이라이트끼리 서로 덮어쓰므로, /auto/build가 tag
(예: 하이라이트1)를 넘겨 이름 뒤에 붙인다. 기본값 "" → 기존 탭 동작 불변.
또한 app.py에 /static StaticFiles 마운트를 추가한다 — 현재는 index.html 한 파일만
직접 읽어 주고 있어 auto.js·modern-screenshot.js를 서빙할 방법이 없다.
4.4 프론트엔드
- 탭
🤖 자동추가. 기존setMode()에 분기 하나 추가. - 자동 탭에서는 공통 옵션 중 댓글 카드 폴더 입력을 숨긴다 (자동 생성 폴더를 쓰므로). 나머지(확대·반전·장면분할·배경흰색·무음제거·하단자막자동)는 그대로 쓴다.
- 카드 렌더는 h-lab
comment-cards.html의 인라인 CSS 중 카드 부분(.comment-card,.cc-head,.cc-avatar,.cc-meta,.cc-author,.cc-time,.cc-text,.cc-stats,.mosaic,.rounded,.bg-black)만 옮겨온다. 툴바·분석 UI는 안 가져온다. - 카드 스타일 고정값: 배경 검정 · 모서리 둥금 · 모자이크 ON · 캡처 4배. (h-lab 기본값과 동일. 토글은 만들지 않는다 — 필요해지면 그때.)
- 캡처:
modernScreenshot.domToBlob(el, {scale:4, backgroundColor:null}).
4.5 프롬프트 파일
- Step 3 =
숏폼_편집_지침서_v13.7_capcut2연동판.md(이미 있는 파일을 그대로 읽는다) - Step 1 =
프롬프트/하이라이트_선정.md(없으면 기본값으로 생성) - 모델·fps =
프롬프트/설정.json(없으면 기본값으로 생성)
{"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. 댓글 매칭 규칙
- 필요 장수
need= §6의n_max=max(1, floor(컷 총길이 / 3)). (52.3초 → 17장) §6과 같은 식을 쓴다 — 화면에 "17장 필요"라고 띄우고 실제로는 18장이 들어가는 일이 없게. - 매칭 기준 구간은 Step 1 윈도우
[start_time, end_time](1분30초~3분). Step 3의 컷은 순서를 섞어 재배치한 45~60초라, 원본에서 "그 장면"을 가리키는 댓글은 Step 1 윈도우로 잡아야 맞는다. matched= 구간 안 타임스탬프를 언급한 댓글, 좋아요 내림차순. 필요 장수까지 자동 체크.candidates= 좋아요 상위 20개 중matched에 없는 것. 부족분을 여기서 사람이 클릭.- 하나도 안 골라도 된다 → 댓글 없이 드래프트 생성.
- 카드 순서 = 화면에 보인 순서(⭐ 먼저, 그다음 ➕ 고른 순서).
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. 검증
이 프로젝트에 자동 테스트 스위트는 없다. 단계별로:
- 구문·임포트:
python -c "import ast; ast.parse(open('capcut_agent/plan.py', encoding='utf-8').read())",python -c "from server import app" comments.py타임스탬프 파싱은 순수 함수라 손으로 확인:"2:14 개웃김"→[134.0],"1:02:03"→[3723.0],"2025년"→[]- 배치 규칙은
_load_comment_cards를 직접 불러 §6 표 3줄을 확인 - 실제 영상 1개로 자동 탭 한 바퀴 →
draft_content.json열어 댓글 세그먼트 시간 확인 - 최종 확인은 CapCut에서 열어보기
⚠️ 코드 수정 후 .bat 재시작 필수 (hot-reload 없음).
10. 오팔과의 품질 차이
프롬프트도 같고, 영상을 유튜브 URL로 넘기는 방식도 같다. 차이가 난다면 원인은 두 가지뿐이다:
- 모델 — 오팔이 어떤 모델을 붙이는지는 오팔 편집기에서 노드를 열면 확인된다.
우리 쪽은 §4.5 설정으로 바꾼다. 기본
gemini-3.5-flash, 부족하면gemini-3.1-pro-preview. Step 1만 Pro로 올리는 것도 가능하다(model_step1) — 호출이 1회뿐이라 한도 부담이 작다. - 영상 샘플링 — Step 1을 0.2 fps로 낮추는 만큼 오팔보다 덜 본다.
짧은 원본만 다루면
fps_step1을 올려 동등하게 맞출 수 있다.
검토 화면이 빌드 전에 컷·자막·타이틀을 다 보여주므로, 결과가 못 미치면 그 자리에서 설정을 바꿔 다시 분석하거나 오팔로 돌아가면 된다. 되돌릴 수 없는 지점이 없다.
11. 열린 항목
modern-screenshot은 구현 시점에 CDN에서 받아server/static/에 동봉하고 버전을 고정한다.- 첫 실행에서 실제 소요 시간·토큰을 재고, Step 1의 fps 기본값을 그 결과로 조정한다.