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

작성일: 2026-07-31

## 1. 배경과 목적

지금 숏폼 5개를 만들려면 사람이 이 순서로 움직인다:

1. 오팔(opal.google.com) 편집기를 열어 Step 3 노드 5개를 **하나씩** 클릭해 JSON을 복사
2. capcut2 붙여넣기 탭에 붙여넣고 실행 → 끝날 때까지 기다림 → 다음 것 복사 (5회 반복)
3. h-lab 댓글 카드 페이지를 열어 영상 댓글을 가져오고, 카드를 골라 PNG로 저장한 뒤
   `댓글카드/` 폴더에 넣어둠

복사·붙여넣기 5회 + 브라우저 왕복 + 댓글 수작업이 전부 손이다. 이걸 **유튜브 URL 하나 →
검토 화면에서 클릭 몇 번 → 드래프트 5개**로 줄인다.

핵심 착안점: 오팔 Step 3의 결과에는 `start_time`/`end_time`이 있고, 유튜브 댓글 상당수는
본문에 `2:14` 같은 타임스탬프를 적는다. **그 구간을 언급한 댓글**을 자동으로 뽑으면
"영상에 맞는 댓글 카드"가 사람 손 없이 정해진다.

## 2. 범위

### 하는 것
- 새 탭 **🤖 자동** 추가 — 유튜브 URL 입력 → Gemini 분석 → 검토 → 드래프트 5개 생성
- Gemini API로 오팔 Step 1/Step 3 대체 (`.gemini_key` 재사용)
- h-lab 원격 API에서 댓글을 받아 구간별 자동 매칭 + 부족분 수동 선택
- 브라우저에서 댓글 카드 PNG를 구워 서버로 업로드 (h-lab 카드 디자인 그대로)
- 프롬프트를 파일로 분리해 UI/메모장 어느 쪽에서든 수정 가능
- `_load_comment_cards` 배치 규칙 변경 (모자라면 균등 분배) — **기존 3개 탭에도 적용**

### 하지 않는 것 (명시)
- **붙여넣기 탭을 없애지 않는다.** 오팔을 계속 써도 되고, Gemini가 실패하면 그리로 도망갈 수 있어야 한다.
- **h-lab 코드를 고치지 않는다.** 필요한 API가 이미 다 열려 있다 (§7에 근거).
- **서버에 작업 큐를 만들지 않는다.** 브라우저가 `/auto/build`를 하나씩 순차 호출하면 충분하다.
- **Pillow로 카드를 그리지 않는다.** 디자인이 h-lab과 달라진다.
- **빌드를 병렬로 돌리지 않는다.** yt-dlp·ffmpeg·Whisper가 CPU를 다 쓴다.
- 오팔 Step 2를 옮기지 않는다 — 노드 간 값 전달용이라 코드에선 배열 인덱싱이다.

## 3. 전체 흐름

```
[유튜브 URL] → 분석 시작
   │
   ├─ Gemini Step 1 (전체 영상)          → 후보 5개 {id, start_time, end_time, reason}
   ├─ Gemini Step 3 × 5 (구간별, 동시)    → 블록① JSON + 블록② 타이틀 후보 5선
   └─ h-lab fetch × 1                    → 댓글 전체 + 본문 mm:ss 파싱
   │
   ▼
[검토 화면]  하이라이트 카드 5장, 각각:
   ├ 제목        ▾ 타이틀 후보 5선에서 교체 가능
   ├ 컷 8개 · 총 52.3초 → 카드 17장 필요
   ├ ⭐ 2:14~4:02 구간을 언급한 댓글  9장   (좋아요순 자동 체크)
   └ ➕ 좋아요 상위 후보            20장   (부족한 8장을 클릭)
   │
   ▼
[5개 전부 만들기]
   브라우저: 선택된 카드를 modern-screenshot으로 PNG 캡처(4배)
   → POST /auto/build (하이라이트 1개 + 카드 PNG들) → job_id
   → GET /stream/{job_id} 진행 표시
   → 끝나면 다음 하이라이트로 (순차 5회)
```

Gemini 호출은 총 6번. Step 3 5개는 **동시 요청**(약 1분 → 약 20초). 429(무료 한도)가 나면
그 구간만 순차로 재시도한다.

## 4. 구성요소

| 파일 | 상태 | 하는 일 |
|---|---|---|
| `capcut_agent/plan.py` | 신규 | Gemini 호출(Step 1 / Step 3), 응답에서 JSON 블록·타이틀 후보 추출 |
| `capcut_agent/comments.py` | 신규 | h-lab 댓글 fetch, 본문 타임스탬프 파싱, 구간 매칭·순위 |
| `capcut_agent/prompts.py` | 신규 | 프롬프트 파일 읽기/쓰기/기본값 복원 |
| `server/static/auto.js` | 신규 | 검토 화면, 카드 렌더·캡처, 순차 빌드 |
| `server/static/modern-screenshot.js` | 신규(벤더링) | DOM→PNG 캡처. CDN 대신 동봉해 오프라인·버전고정 |
| `server/app.py` | 수정 | 엔드포인트 5개 추가 |
| `server/static/index.html` | 수정 | 탭 1개 + 패널 추가, 카드 CSS 이식. 기존 3탭 손대지 않음 |
| `capcut_agent/pipeline.py` | 수정 | `_load_comment_cards` 배치 규칙 (§6) |
| `숏폼_편집_지침서_v13.7_capcut2연동판.md` | 기존 그대로 | **Step 3 프롬프트 설정 파일로 그대로 사용** |
| `프롬프트/하이라이트_선정.md` | 신규(자동 생성) | Step 1 프롬프트 |

### 4.1 `capcut_agent/plan.py`

```python
DEFAULT_MODEL = "gemini-3.5-flash"     # 설정에서 교체 가능 (§4.5)
                                       # correct.py 의 _gemini_key() / GeminiQuotaError 는 재사용

def select_highlights(url, *, key=None, prompt=None, model=None) -> list[dict]
    # → [{"id":1, "start":134.0, "end":242.0, "reason":"…"}, …]

def edit_plan(url, start_sec, end_sec, *, key=None, prompt=None, model=None) -> dict
    # → {"paste": {…parse_paste 결과…}, "titles": [{"top":…,"main":…,"kind":"어그로형"}, …]}
```

유튜브 URL을 Gemini에 그대로 넘기고, 구간은 `videoMetadata`로 자른다 (오팔 Step 2의 역할):

```json
{"contents":[{"parts":[
  {"fileData":{"fileUri":"https://www.youtube.com/watch?v=…"},
   "videoMetadata":{"startOffset":"134s","endOffset":"242s"}},
  {"text":"<프롬프트 전문>"}]}],
 "generationConfig":{"temperature":0.7}}
```

#### 영상 샘플링 — Step 1과 Step 3을 다르게 보낸다

Gemini는 영상을 기본 **1 FPS · 초당 약 300토큰**으로 읽는다. 그대로 두면 **Step 1(전체 영상)이
55분쯤에서 컨텍스트 100만 토큰을 넘겨 실패한다.** Step 3은 구간이 1분30초~3분이라 무관하다.

| | 보내는 것 | fps | 1시간 원본 기준 |
|---|---|---|---|
| Step 1 | 전체 영상 1회 | **0.2** (5초당 1프레임) | 약 22만 토큰 — 통과 |
| Step 3 | 구간 1개 × 5회 | 기본(1.0) | 구간당 약 5만 토큰 |

`videoMetadata.fps`로 지정한다. 하이라이트 구간을 고르는 일은 표정 디테일보다 흐름·오디오를
보는 작업이라 0.2 fps로 충분하고, **오디오는 fps와 무관하게 그대로 들어간다.** 정밀한 컷 지점과
verbatim 자막이 필요한 Step 3만 기본 fps로 보낸다.

Step 1의 fps도 설정값이다 — 짧은 영상만 다루게 되면 올리면 된다.

응답 파싱:
- Step 3은 블록 ①②③ 세 덩어리로 오므로 **첫 번째 ` ```json ` 펜스 안쪽**만 꺼낸다.
  펜스가 없으면 본문 전체를 시도한다.
- 꺼낸 JSON은 **기존 `parse_paste()`에 그대로 통과시킨다** — 검증 로직을 두 벌 만들지 않는다.
- `url` 필드는 파싱 후 **사용자가 입력한 URL로 덮어쓴다** (LLM이 영상 ID를 지어내는 사고 차단).
- 블록 ②는 `^\s*\d+\.\s*상단:\s*(.+?)\s*/\s*메인:\s*(.+?)\s*(?:—\s*(.+))?$` 로 긁는다.
  실패해도 오류가 아니라 빈 리스트 → UI에서 드롭다운만 안 뜬다.

`correct.py`의 `_gemini_key()` / `GeminiQuotaError`를 재사용한다.

#### Step 3 타임코드 기준 보정

구간을 잘라 보낸 클립에 대해 모델이 타임코드를 **원본 기준**으로 줄지 **클립 기준(0부터)**
으로 줄지 보장이 없다(문서에 명시 없음). 어느 쪽이 와도 살아남게 휴리스틱으로 보정한다:

1. 모든 컷이 `[start−10, end+10]` 안 → 절대 기준으로 보고 그대로 둔다 (우선)
2. 아니고 모든 컷이 `[0, 클립길이+10]` 안이며 `start > 10` → 클립 기준으로 보고 `start`를 더한다
3. 둘 다 아니면 손대지 않는다 (이후 `parse_paste`·다운로드 단계에서 자연히 드러남)

보정이 일어나면 로그에 남긴다.

### 4.2 `capcut_agent/comments.py`

```python
H_LAB = "https://h-lab.tolag.shop"

def fetch_comments(url, *, timeout=180) -> list[dict]
    # POST /api/comment-cards/fetch  → data[] 그대로 + idx 부여 + times 계산

TS_RE = r"(?<!\d)(\d{1,2}):([0-5]\d)(?::([0-5]\d))?(?!\d)"   # h-lab comment-cards.js 와 동일

def match_window(comments, start, end) -> list[dict]   # 구간 언급 댓글, 좋아요 내림차순
def top_liked(comments, exclude_idx, n=20) -> list[dict]
```

- 2조각이면 `mm:ss`, 3조각이면 `h:mm:ss` — h-lab JS 규칙을 그대로 옮긴다.
- 한 댓글에 여러 시각이 있으면 **하나라도 구간 안에 들면 매칭**.
- 댓글 원본에 id가 없으므로 h-lab과 같이 **배열 인덱스**를 식별자로 쓴다.

### 4.3 엔드포인트

| 메서드 | 경로 | 내용 |
|---|---|---|
| POST | `/auto/analyze` | Form `url` → `{analysis_id}`. 실제 작업은 아래 스트림에서 |
| GET | `/auto/stream/{analysis_id}` | SSE. 기존 `/stream`과 동일한 이벤트(`manifest`/`step`/`log`/`result`/`error`) |
| GET | `/auto/avatar?url=` | 프로필 이미지 프록시. `ggpht.com`·`googleusercontent.com`만 허용 |
| GET·POST | `/prompts` | 프롬프트 두 개 읽기/저장. POST에 `reset=1`이면 기본값 복원 |
| POST | `/auto/build` | multipart. 하이라이트 1개 + 카드 PNG들 → `{job_id}` (기존 `/stream`으로 진행 표시) |

`/auto/stream`의 최종 `result` 이벤트 payload:

```json
{"type":"result",
 "highlights":[{"id":1,"start":134.0,"end":242.0,"reason":"…",
                "paste":{…parse_paste 결과…},
                "titles":[{"top":"…","main":"…","kind":"어그로형"}, …],
                "total":52.3,"need":17,
                "matched":[12,45,3,…],       // 댓글 인덱스, 좋아요순
                "candidates":[7,19,…]}],     // 좋아요 상위 20 (matched 제외)
 "comments":[{"idx":0,"authorName":"…","text":"…","likeCount":275098,
              "replyCount":1000,"publishedAt":"…","profileImageUrl":"…","times":[134.0]}],
 "warnings":["h-lab 연결 실패 — 댓글 없이 진행합니다"]}
```

`/auto/build` 폼 필드:

| 필드 | 값 |
|---|---|
| `data` | 하이라이트의 `paste` JSON 문자열 (붙여넣기 탭과 **동일 스키마**) |
| `cards` | PNG 파일 여러 개, 화면에 보인 순서 그대로 |
| `video_scale` `flip` `scene` `bg_white` `remove_silence` `asr_bottom` | 공통 옵션. `/paste`와 동일 |

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

파이프라인에 허용하는 수정은 딱 하나 — `process_paste(name_suffix="")` 파라미터 추가.
현재 드래프트 이름은 영상 제목으로 덮어써지고(`pipeline.py:340`) pycapcut은
`allow_replace=True`라 **같은 이름이면 이전 드래프트를 교체**한다. 같은 영상에서 5개를
만들면 컷 개수가 같은 하이라이트끼리 서로 덮어쓰므로, `/auto/build`가 `tag`
(예: `하이라이트1`)를 넘겨 이름 뒤에 붙인다. 기본값 `""` → 기존 탭 동작 불변.

또한 `app.py`에 `/static` StaticFiles 마운트를 추가한다 — 현재는 index.html 한 파일만
직접 읽어 주고 있어 `auto.js`·`modern-screenshot.js`를 서빙할 방법이 없다.

### 4.4 프론트엔드

- 탭 `🤖 자동` 추가. 기존 `setMode()`에 분기 하나 추가.
- 자동 탭에서는 공통 옵션 중 **댓글 카드 폴더 입력을 숨긴다** (자동 생성 폴더를 쓰므로).
  나머지(확대·반전·장면분할·배경흰색·무음제거·하단자막자동)는 그대로 쓴다.
- 카드 렌더는 h-lab `comment-cards.html`의 인라인 CSS 중 카드 부분(`.comment-card`,
  `.cc-head`, `.cc-avatar`, `.cc-meta`, `.cc-author`, `.cc-time`, `.cc-text`, `.cc-stats`,
  `.mosaic`, `.rounded`, `.bg-black`)만 옮겨온다. 툴바·분석 UI는 안 가져온다.
- 카드 스타일 고정값: **배경 검정 · 모서리 둥금 · 모자이크 ON · 캡처 4배**.
  (h-lab 기본값과 동일. 토글은 만들지 않는다 — 필요해지면 그때.)
- 캡처: `modernScreenshot.domToBlob(el, {scale:4, backgroundColor:null})`.

### 4.5 프롬프트 파일

- Step 3 = `숏폼_편집_지침서_v13.7_capcut2연동판.md` (이미 있는 파일을 그대로 읽는다)
- Step 1 = `프롬프트/하이라이트_선정.md` (없으면 기본값으로 생성)
- 모델·fps = `프롬프트/설정.json` (없으면 기본값으로 생성)

```json
{"model": "gemini-3.5-flash",
 "model_step1": "",
 "fps_step1": 0.2,
 "fps_step3": 1.0}
```

(`model_step1`은 비우면 `model`과 동일 — Step 1만 Pro로 올리고 싶을 때 채운다.
실제 파일은 주석 없는 순수 JSON.)

기본 Step 1 프롬프트는 오팔 원문을 옮기되 **오타 하나를 고친다** — 원문 JSON 예시에
`start_time`이 두 번 나오고 `end_time`이 빠져 있다.

자동 탭의 **⚙ 지침 수정** 안에서 프롬프트 2개 + 이 설정을 같이 편집한다.
모델을 바꿔 결과를 비교하는 것이 오팔과의 품질 차이를 좁히는 유일한 수단이므로, 이 값은
숨기지 않고 UI에 노출한다.

## 5. 댓글 매칭 규칙

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

## 6. 카드 배치 규칙 (변경)

`pipeline.py:38 _load_comment_cards`

```
n_max = max(1, floor(전체길이 / 3))     # 3초 밑으로는 안 내려감
n     = min(카드 수, n_max)             # 초과분은 버림 (지금과 동일)
길이  = 전체길이 / n                    # 항상 3초 이상, 끝까지 빈 곳 없이 채움
i번째 카드 = [i·길이, (i+1)·길이]
```

| 전체 길이 | 카드 수 | 결과 |
|---|---|---|
| 33초 | 11장 | 3.0초씩 (지금과 동일) |
| 33초 | 6장 | **5.5초씩** — 지금은 18초 뒤가 비었다 |
| 33초 | 20장 | 앞 11장만 3.0초씩 (지금과 동일) |

**기존 3개 탭에도 적용된다.** 카드가 모자랄 때 영상 뒷부분이 비는 문제가 같이 없어진다.
이 설계에서 기존 동작이 바뀌는 유일한 지점이다.

## 7. h-lab 확인 결과 (수정 불필요 근거)

원격 API 실측 (2026-07-31):

```
POST https://h-lab.tolag.shop/api/comment-cards/fetch  → 200
{"success":true,"data":[{"authorName":"@YouTube","profileImageUrl":"https://yt3.ggpht.com/…",
  "text":"…","likeCount":275098,"replyCount":1000,"publishedAt":"2025-04-22T19:05:08Z"}]}
```

- 인증 없이 열려 있고, 카드에 필요한 필드가 전부 온다
- 프로필 이미지는 **capcut2가 자체 프록시**한다 → h-lab CORS 설정에 의존하지 않고,
  같은 출처라 캔버스 오염(taint) 없이 캡처된다
- 카드 CSS는 `comment-cards.html`에서 복사해 온다

→ **h-lab에 추가할 API도, 고칠 코드도 없다.**

## 8. 실패 처리

각각 독립적으로 죽고, 죽어도 나머지는 살린다.

| 실패 | 처리 |
|---|---|
| Gemini 키 없음 | 자동 탭에 안내 + 붙여넣기 탭 안내. 분석 시작 자체를 막는다 |
| Gemini 429 (무료 한도) | Step 3는 동시 5개 → 429 나온 구간만 순차 재시도(최대 2회). 그래도 실패하면 그 하이라이트만 제외 |
| 무료 티어 유튜브 하루 8시간 초과 | Gemini가 거절 → 메시지 그대로 노출. 원본 1시간짜리면 하루 약 7~8회가 상한 |
| Step 1 컨텍스트 초과 (아주 긴 원본) | `fps_step1`을 더 낮추라는 안내를 띄운다. 0.2 fps 기준 4시간까지는 들어간다 |
| Gemini 응답에 JSON 블록 없음 | 그 하이라이트만 제외 + 원문을 로그에 남김 |
| `parse_paste` 검증 실패 | 그 하이라이트만 제외 + 사유 표시 |
| Step 1이 5개 못 채움 | 나온 것만 진행. 개수를 강제하지 않는다 |
| h-lab 연결 실패·타임아웃 | **댓글 없이 진행.** 하이라이트는 그대로 만든다. `warnings`에 표시 |
| 구간 매칭 댓글 0개 | 후보만 보여준다. 안 고르면 댓글 없이 생성 |
| 카드 캡처 실패 | 그 카드만 빼고 계속 |
| 빌드 5개 중 3번째 실패 | 멈추지 않고 4·5번 계속. 끝에 "4개 성공, 1개 실패" |

전부 실패해도 **오팔 → 붙여넣기 탭** 경로는 그대로 살아 있다.

## 9. 검증

이 프로젝트에 자동 테스트 스위트는 없다. 단계별로:

1. 구문·임포트: `python -c "import ast; ast.parse(open('capcut_agent/plan.py', encoding='utf-8').read())"`,
   `python -c "from server import app"`
2. `comments.py` 타임스탬프 파싱은 순수 함수라 손으로 확인:
   `"2:14 개웃김"` → `[134.0]`, `"1:02:03"` → `[3723.0]`, `"2025년"` → `[]`
3. 배치 규칙은 `_load_comment_cards`를 직접 불러 §6 표 3줄을 확인
4. 실제 영상 1개로 자동 탭 한 바퀴 → `draft_content.json` 열어 댓글 세그먼트 시간 확인
5. 최종 확인은 **CapCut에서 열어보기**

⚠️ 코드 수정 후 `.bat` 재시작 필수 (hot-reload 없음).

## 10. 오팔과의 품질 차이

프롬프트도 같고, 영상을 유튜브 URL로 넘기는 방식도 같다. **차이가 난다면 원인은 두 가지뿐이다:**

1. **모델** — 오팔이 어떤 모델을 붙이는지는 오팔 편집기에서 노드를 열면 확인된다.
   우리 쪽은 §4.5 설정으로 바꾼다. 기본 `gemini-3.5-flash`, 부족하면 `gemini-3.1-pro-preview`.
   Step 1만 Pro로 올리는 것도 가능하다(`model_step1`) — 호출이 1회뿐이라 한도 부담이 작다.
2. **영상 샘플링** — Step 1을 0.2 fps로 낮추는 만큼 오팔보다 덜 본다.
   짧은 원본만 다루면 `fps_step1`을 올려 동등하게 맞출 수 있다.

검토 화면이 **빌드 전에** 컷·자막·타이틀을 다 보여주므로, 결과가 못 미치면 그 자리에서
설정을 바꿔 다시 분석하거나 오팔로 돌아가면 된다. 되돌릴 수 없는 지점이 없다.

## 11. 열린 항목

- `modern-screenshot`은 구현 시점에 CDN에서 받아 `server/static/`에 동봉하고 버전을 고정한다.
- 첫 실행에서 실제 소요 시간·토큰을 재고, Step 1의 fps 기본값을 그 결과로 조정한다.
