capcut-agent/docs/superpowers/specs/2026-07-31-자동탭-오팔대체-댓글자동매칭-design.md
hehihoho3@gmail.com bf1b387d6d chore: git 저장소 초기화 (기존 코드 스냅샷)
컷별 댓글 추천 작업을 태스크 단위로 되돌릴 수 있게 버전관리를 시작한다.
.gitignore 로 영상·캐시(.downloads 2.7G, .comments 72M, .media 28M)와
비밀키(.gemini_key)를 제외했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 11:36:05 +09:00

18 KiB
Raw Permalink Blame History

자동 탭 — 오팔 대체 + 댓글 카드 자동 매칭 (설계)

작성일: 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

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부터) 으로 줄지 보장이 없다(문서에 명시 없음). 어느 쪽이 와도 살아남게 휴리스틱으로 보정한다:

  1. 모든 컷이 [start10, end+10] 안 → 절대 기준으로 보고 그대로 둔다 (우선)
  2. 아니고 모든 컷이 [0, 클립길이+10] 안이며 start > 10 → 클립 기준으로 보고 start를 더한다
  3. 둘 다 아니면 손대지 않는다 (이후 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와 동일

서버는 dataparse_paste()로 검증하고, 카드들을 .comments/<job_id>/001.png…에 순서대로 저장한 뒤 그 폴더를 comments_dir로 하는 job을 만든다. 그 뒤는 기존 process_paste() 경로를 그대로 탄다.

파이프라인에 허용하는 수정은 딱 하나 — process_paste(name_suffix="") 파라미터 추가. 현재 드래프트 이름은 영상 제목으로 덮어써지고(pipeline.py:340) pycapcut은 allow_replace=True같은 이름이면 이전 드래프트를 교체한다. 같은 영상에서 5개를 만들면 컷 개수가 같은 하이라이트끼리 서로 덮어쓰므로, /auto/buildtag (예: 하이라이트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. 댓글 매칭 규칙

  1. 필요 장수 need = §6의 n_max = max(1, floor(컷 총길이 / 3)). (52.3초 → 17장) §6과 같은 식을 쓴다 — 화면에 "17장 필요"라고 띄우고 실제로는 18장이 들어가는 일이 없게.
  2. 매칭 기준 구간은 Step 1 윈도우 [start_time, end_time] (1분30초~3분). Step 3의 컷은 순서를 섞어 재배치한 45~60초라, 원본에서 "그 장면"을 가리키는 댓글은 Step 1 윈도우로 잡아야 맞는다.
  3. matched = 구간 안 타임스탬프를 언급한 댓글, 좋아요 내림차순. 필요 장수까지 자동 체크.
  4. candidates = 좋아요 상위 20개 중 matched에 없는 것. 부족분을 여기서 사람이 클릭.
  5. 하나도 안 골라도 된다 → 댓글 없이 드래프트 생성.
  6. 카드 순서 = 화면에 보인 순서( 먼저, 그다음 고른 순서).

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 기본값을 그 결과로 조정한다.