컷별 댓글 추천 작업을 태스크 단위로 되돌릴 수 있게 버전관리를 시작한다. .gitignore 로 영상·캐시(.downloads 2.7G, .comments 72M, .media 28M)와 비밀키(.gemini_key)를 제외했다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1487 lines
62 KiB
Markdown
1487 lines
62 KiB
Markdown
# 자동 탭 — 오팔 대체 + 댓글 자동 매칭 구현 계획
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** 유튜브 URL 하나 → Gemini가 하이라이트 5개 편집안 생성 → h-lab 댓글 자동 매칭 → 검토 화면에서 클릭 몇 번 → 드래프트 5개.
|
||
|
||
**Architecture:** 새 탭 하나 + 신규 모듈 3개(plan/comments/prompts). 빌드는 기존 `process_paste()` 경로를 그대로 타고, 서버는 상태를 최소로(분석은 SSE 1회성, 카드는 `.comments/<job>/`). 카드 PNG는 브라우저가 h-lab CSS로 렌더해 캡처.
|
||
|
||
**Tech Stack:** FastAPI + 바닐라 JS(단일 index.html + auto.js), Gemini REST(urllib), modern-screenshot(벤더링).
|
||
|
||
**스펙:** `docs/superpowers/specs/2026-07-31-자동탭-오팔대체-댓글자동매칭-design.md`
|
||
|
||
## Global Constraints
|
||
|
||
- **git 저장소가 아니다** — 커밋 단계 없음. 각 태스크 끝 검증 명령이 게이트를 대신한다.
|
||
- 자동 테스트 스위트 없음. 검증 = `python -c "import ast; ast.parse(...)"` + `python -c "from server import app"` + 함수 직접 호출.
|
||
- 코드 수정 후 서버 반영은 `.bat` 재시작 필요(hot-reload 없음). 태스크 검증은 서버 없이 하는 것 우선.
|
||
- Windows 콘솔 cp949 — 검증 출력에서 한글이 깨져 보여도 로직 문제 아님. print 검증은 ASCII 위주로.
|
||
- 포트 8001. 기존 3개 탭(파일/유튜브/붙여넣기) 동작을 바꾸지 않는다 — 유일한 예외는 Task 1의 카드 배치 규칙.
|
||
- 기존 코드 스타일: 한국어 docstring, 표준 라이브러리(urllib) 사용(외부 HTTP 라이브러리 금지), 타입힌트 `from __future__ import annotations`.
|
||
- Gemini 키: 프로젝트 루트 `.gemini_key` (이미 존재). 커밋·로그 노출 금지.
|
||
|
||
---
|
||
|
||
### Task 1: 카드 배치 규칙 — 균등 분배 (`_load_comment_cards`)
|
||
|
||
**Files:**
|
||
- Modify: `capcut_agent/pipeline.py:38-71` (`_load_comment_cards`)
|
||
|
||
**Interfaces:**
|
||
- Produces: `_load_comment_cards(folder: str, dur: float, min_sec: float = 3.0) -> list[tuple[float, float, str]]` — 기존 두 호출부(`pipeline.py:226`, `pipeline.py:430`)는 `(folder, dur)` 위치 인자만 쓰므로 시그니처 호환.
|
||
|
||
- [ ] **Step 1: 함수 교체**
|
||
|
||
`pipeline.py`의 `_load_comment_cards` 전체(38~71행)를 아래로 교체한다. 정렬 규칙 부분은 기존 그대로다 — 바뀌는 건 마지막 배치 계산뿐.
|
||
|
||
```python
|
||
def _load_comment_cards(folder: str, dur: float, min_sec: float = 3.0):
|
||
"""지정 폴더의 이미지를 영상 길이에 맞춰 하단에 균등 배치.
|
||
|
||
장수: n = min(카드 수, max(1, floor(dur/min_sec))) — 카드 하나가 min_sec 밑으로
|
||
내려가지 않는 상한. 배치 간격은 항상 dur/n → 카드가 모자라도 끝까지 빈 곳 없이
|
||
채워지고(간격이 3초 이상으로 늘어남), 넘치면 초과분은 버린다.
|
||
정렬 규칙:
|
||
- 모든 파일명이 숫자로 시작하면 → 숫자순(1, 2, 10 …)
|
||
- 아니면 → 저장(생성) 순서 = 다운로드한 순서
|
||
folder 가 비었거나 없으면 [] (댓글 카드 없음).
|
||
Returns: [(start, end, path)]
|
||
"""
|
||
import re
|
||
folder = (folder or "").strip().strip('"')
|
||
if not folder or not os.path.isdir(folder) or dur <= 0:
|
||
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)) # 저장(생성) 순서
|
||
|
||
n = min(len(imgs), max(1, int(dur // min_sec)))
|
||
interval = dur / n
|
||
return [(i * interval, (i + 1) * interval, path)
|
||
for i, path in enumerate(imgs[:n])]
|
||
```
|
||
|
||
- [ ] **Step 2: 구문 검증**
|
||
|
||
```
|
||
python -c "import ast; ast.parse(open('capcut_agent/pipeline.py', encoding='utf-8').read())"
|
||
```
|
||
Expected: 출력 없음(성공).
|
||
|
||
- [ ] **Step 3: 동작 검증 — 스펙 §6 표 3줄**
|
||
|
||
임시 폴더에 더미 PNG를 만들어 3가지 경우를 확인한다.
|
||
|
||
```
|
||
python - <<'EOF'
|
||
import os, tempfile
|
||
from capcut_agent.pipeline import _load_comment_cards
|
||
d = tempfile.mkdtemp()
|
||
def setup(k):
|
||
for f in os.listdir(d): os.remove(os.path.join(d, f))
|
||
for i in range(1, k+1): open(os.path.join(d, f"{i}.png"), "wb").write(b"x")
|
||
setup(11); r = _load_comment_cards(d, 33.0)
|
||
assert len(r) == 11 and abs(r[0][1]-3.0) < 1e-6 and abs(r[-1][1]-33.0) < 1e-6, r
|
||
setup(6); r = _load_comment_cards(d, 33.0)
|
||
assert len(r) == 6 and abs(r[0][1]-5.5) < 1e-6 and abs(r[-1][1]-33.0) < 1e-6, r
|
||
setup(20); r = _load_comment_cards(d, 33.0)
|
||
assert len(r) == 11 and abs(r[0][1]-3.0) < 1e-6, r
|
||
setup(1); r = _load_comment_cards(d, 2.0) # 2초 영상 + 카드 1장 → 1장, 2초
|
||
assert len(r) == 1 and abs(r[0][1]-2.0) < 1e-6, r
|
||
print("OK")
|
||
EOF
|
||
```
|
||
Expected: `OK`
|
||
|
||
---
|
||
|
||
### Task 2: `process_paste`에 `name_suffix` 추가 (드래프트 이름 충돌 방지)
|
||
|
||
**Files:**
|
||
- Modify: `capcut_agent/pipeline.py:298-340` (`process_paste` 시그니처와 이름 결정부)
|
||
- Modify: `server/app.py:176-186` (`stream()`의 `process_paste` 호출)
|
||
|
||
**Interfaces:**
|
||
- Produces: `process_paste(..., name_suffix: str = "")` — 값이 있으면 최종 드래프트 이름 뒤에 `_{suffix}` 부착. Task 6의 `/auto/build`가 `JOBS[h]["name_suffix"]`로 전달.
|
||
|
||
- [ ] **Step 1: 시그니처에 파라미터 추가**
|
||
|
||
`pipeline.py` `process_paste`의 키워드 인자 목록 끝(`asr_bottom: bool = False,` 다음 줄)에 추가:
|
||
|
||
```python
|
||
asr_bottom: bool = False,
|
||
name_suffix: str = "",
|
||
```
|
||
|
||
- [ ] **Step 2: 이름 결정부 수정**
|
||
|
||
`pipeline.py:340` 근처:
|
||
|
||
```python
|
||
draft_name = _safe_name(title) or draft_name
|
||
```
|
||
을 아래로 교체:
|
||
```python
|
||
draft_name = _safe_name(title) or draft_name
|
||
if name_suffix: # 같은 영상에서 여럿 만들 때 이름 충돌(=드래프트 교체) 방지
|
||
draft_name = f"{draft_name}_{name_suffix}"
|
||
```
|
||
|
||
- [ ] **Step 3: `app.py` 호출부에 전달**
|
||
|
||
`server/app.py` `stream()`의 `process_paste(` 호출에서 `asr_bottom=` 줄 다음에 추가:
|
||
|
||
```python
|
||
asr_bottom=job.get("asr_bottom", False),
|
||
name_suffix=job.get("name_suffix", ""),
|
||
```
|
||
|
||
- [ ] **Step 4: 검증**
|
||
|
||
```
|
||
python -c "import ast; ast.parse(open('capcut_agent/pipeline.py', encoding='utf-8').read())"
|
||
python -c "from server import app"
|
||
python -c "import inspect; from capcut_agent.pipeline import process_paste; assert 'name_suffix' in inspect.signature(process_paste).parameters; print('OK')"
|
||
```
|
||
Expected: 마지막 줄 `OK`.
|
||
|
||
---
|
||
|
||
### Task 3: `capcut_agent/comments.py` — h-lab 댓글 수집·타임스탬프 매칭
|
||
|
||
**Files:**
|
||
- Create: `capcut_agent/comments.py`
|
||
|
||
**Interfaces:**
|
||
- Produces (Task 6이 사용):
|
||
- `fetch_comments(url: str, *, timeout: float = 180.0) -> list[dict]` — dict 키: `idx, authorName, text, likeCount, replyCount, publishedAt, profileImageUrl, times`
|
||
- `match_window(comments: list[dict], start: float, end: float) -> list[int]` — idx, 좋아요 내림차순
|
||
- `top_liked(comments: list[dict], exclude: set, n: int = 20) -> list[int]`
|
||
- `parse_times(text: str) -> list[float]`
|
||
|
||
- [ ] **Step 1: 파일 작성**
|
||
|
||
```python
|
||
"""h-lab 댓글 수집 + 타임스탬프 매칭 — 자동 탭용.
|
||
|
||
h-lab(https://h-lab.tolag.shop)의 comment-cards API에서 영상 전체 댓글을 받아,
|
||
본문 속 mm:ss / h:mm:ss 언급을 초로 파싱한다. 정규식·평문 변환 규칙은
|
||
h-lab comment-cards.js(TS_RE, toPlainText)와 동일하게 맞춘다.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
import urllib.request
|
||
from typing import Dict, List
|
||
|
||
H_LAB = "https://h-lab.tolag.shop"
|
||
|
||
# h-lab comment-cards.js 의 TS_RE 와 동일 규칙
|
||
TS_RE = re.compile(r"(?<!\d)(\d{1,2}):([0-5]\d)(?::([0-5]\d))?(?!\d)")
|
||
_BR_RE = re.compile(r"<br\s*/?>", re.I)
|
||
_TAG_RE = re.compile(r"<[^>]+>")
|
||
|
||
|
||
def plain_text(html: str) -> str:
|
||
"""YouTube textDisplay(HTML) → 평문. <br>→줄바꿈, 나머지 태그 제거."""
|
||
s = _BR_RE.sub("\n", str(html or ""))
|
||
return _TAG_RE.sub("", s)
|
||
|
||
|
||
def parse_times(text: str) -> List[float]:
|
||
"""댓글 본문 속 mm:ss / h:mm:ss → 초 리스트(중복 제거, 등장 순)."""
|
||
out: List[float] = []
|
||
seen = set()
|
||
for m in TS_RE.finditer(plain_text(text)):
|
||
if m.group(3) is not None:
|
||
sec = int(m.group(1)) * 3600 + int(m.group(2)) * 60 + int(m.group(3))
|
||
else:
|
||
sec = int(m.group(1)) * 60 + int(m.group(2))
|
||
if sec not in seen:
|
||
seen.add(sec)
|
||
out.append(float(sec))
|
||
return out
|
||
|
||
|
||
def fetch_comments(url: str, *, timeout: float = 180.0) -> List[Dict]:
|
||
"""h-lab에서 전체 댓글 수집. 원본 순서 유지, idx = 배열 인덱스(식별자).
|
||
|
||
실패는 예외 그대로 던진다(URLError/RuntimeError) — 호출부(자동 탭 스트림)가
|
||
"댓글 없이 진행" 폴백을 담당한다.
|
||
"""
|
||
req = urllib.request.Request(
|
||
f"{H_LAB}/api/comment-cards/fetch",
|
||
data=json.dumps({"url": url}).encode("utf-8"),
|
||
headers={"Content-Type": "application/json"},
|
||
)
|
||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||
data = json.loads(resp.read().decode("utf-8"))
|
||
if not data.get("success"):
|
||
raise RuntimeError(f"h-lab 응답 실패: {data.get('message')}")
|
||
out: List[Dict] = []
|
||
for i, c in enumerate(data.get("data") or []):
|
||
out.append({
|
||
"idx": i,
|
||
"authorName": str(c.get("authorName") or ""),
|
||
"text": str(c.get("text") or ""),
|
||
"likeCount": int(c.get("likeCount") or 0),
|
||
"replyCount": int(c.get("replyCount") or 0),
|
||
"publishedAt": str(c.get("publishedAt") or ""),
|
||
"profileImageUrl": str(c.get("profileImageUrl") or ""),
|
||
"times": parse_times(c.get("text") or ""),
|
||
})
|
||
return out
|
||
|
||
|
||
def match_window(comments: List[Dict], start: float, end: float) -> List[int]:
|
||
"""[start, end] 안의 시각을 하나라도 언급한 댓글 idx — 좋아요 내림차순."""
|
||
hit = [c for c in comments if any(start <= t <= end for t in c["times"])]
|
||
hit.sort(key=lambda c: -c["likeCount"])
|
||
return [c["idx"] for c in hit]
|
||
|
||
|
||
def top_liked(comments: List[Dict], exclude: set, n: int = 20) -> List[int]:
|
||
"""exclude(idx 집합) 제외 좋아요 상위 n개 idx."""
|
||
rest = [c for c in comments if c["idx"] not in exclude]
|
||
rest.sort(key=lambda c: -c["likeCount"])
|
||
return [c["idx"] for c in rest[:n]]
|
||
```
|
||
|
||
- [ ] **Step 2: 파싱 검증 (스펙 §9-2)**
|
||
|
||
```
|
||
python - <<'EOF'
|
||
from capcut_agent.comments import parse_times, match_window, top_liked
|
||
assert parse_times("2:14 funny") == [134.0]
|
||
assert parse_times("1:02:03") == [3723.0]
|
||
assert parse_times("year 2025") == [] # 콜론 없는 숫자
|
||
assert parse_times("12:345") == [] # 초가 3자리면 비매칭 (?!\d)
|
||
assert parse_times("see 2:14 and 2:14 and 3:00") == [134.0, 180.0] # 중복 제거
|
||
assert parse_times("<b>0:59</b><br>1:00") == [59.0, 60.0] # 태그 제거
|
||
cs = [
|
||
{"idx": 0, "likeCount": 5, "times": [100.0]},
|
||
{"idx": 1, "likeCount": 99, "times": [150.0, 500.0]},
|
||
{"idx": 2, "likeCount": 50, "times": []},
|
||
{"idx": 3, "likeCount": 70, "times": [400.0]},
|
||
]
|
||
assert match_window(cs, 90, 200) == [1, 0] # 좋아요순
|
||
assert top_liked(cs, {1}, 2) == [3, 2]
|
||
print("OK")
|
||
EOF
|
||
```
|
||
Expected: `OK`
|
||
|
||
- [ ] **Step 3: 실서버 연결 1회 확인 (네트워크 필요)**
|
||
|
||
```
|
||
python - <<'EOF'
|
||
from capcut_agent.comments import fetch_comments
|
||
cs = fetch_comments("https://www.youtube.com/watch?v=dQw4w9WgXcQ")
|
||
assert len(cs) > 10 and "likeCount" in cs[0] and "times" in cs[0]
|
||
print("OK", len(cs))
|
||
EOF
|
||
```
|
||
Expected: `OK <개수>`. (h-lab이 죽어 있으면 예외 — 이 스텝만 보류하고 진행 가능)
|
||
|
||
---
|
||
|
||
### Task 4: `capcut_agent/prompts.py` — 프롬프트·설정 파일 관리
|
||
|
||
**Files:**
|
||
- Create: `capcut_agent/prompts.py`
|
||
- Create(자동): `프롬프트/하이라이트_선정.md`, `프롬프트/설정.json` (첫 load 시 생성)
|
||
|
||
**Interfaces:**
|
||
- Produces (Task 5·6이 사용):
|
||
- `load_step1() -> str` / `load_step3() -> str` / `load_config() -> dict`
|
||
- `save(*, step1=None, step3=None, config=None) -> None` — `config`는 JSON 문자열, `json.JSONDecodeError`/`ValueError` 던짐
|
||
- `reset() -> None` — step1·설정만 기본값 복원(Step 3 지침서는 사용자 파일이라 불변)
|
||
- 상수 `STEP3_PATH`, `DEFAULT_CONFIG`
|
||
|
||
- [ ] **Step 1: 파일 작성**
|
||
|
||
```python
|
||
"""프롬프트·설정 파일 관리 — 자동 탭(Gemini)용.
|
||
|
||
Step 3 지침 = 프로젝트 루트 `숏폼_편집_지침서_v13.7_capcut2연동판.md` (사용자 소유,
|
||
reset 대상 아님). Step 1 프롬프트·모델 설정 = `프롬프트/` 폴더(없으면 기본값 생성).
|
||
UI(⚙ 지침 수정)에서든 메모장에서든 고치면 다음 분석부터 반영된다.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
from typing import Optional
|
||
|
||
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
PROMPT_DIR = os.path.join(_ROOT, "프롬프트")
|
||
STEP1_PATH = os.path.join(PROMPT_DIR, "하이라이트_선정.md")
|
||
CONFIG_PATH = os.path.join(PROMPT_DIR, "설정.json")
|
||
STEP3_PATH = os.path.join(_ROOT, "숏폼_편집_지침서_v13.7_capcut2연동판.md")
|
||
|
||
# 오팔 Step 1 원문 이식. 원문 JSON 예시의 오타(start_time 중복, end_time 누락)는 수정.
|
||
DEFAULT_STEP1 = """당신은 유튜브 영상에서 쇼츠(Shorts)로 제작했을 때 가장 터질 만한 구간을 찾아내는 '바이럴 분석가'입니다. 제공된 영상을 분석해 아래 규칙에 따라 5개의 하이라이트 후보 구간을 선정하세요.
|
||
|
||
1. 각 구간의 길이는 최소 1분 30초에서 최대 3분 사이로 설정할 것. (최종 편집을 위한 원천 소스 구간임)
|
||
2. 시청자의 시선을 끌 수 있는 갈등, 웃음, 반전, 혹은 핵심 정보가 포함된 구간을 우선순위로 둘 것.
|
||
3. 결과물은 반드시 아래의 JSON 형식으로만 출력할 것. (다른 설명 금지)
|
||
|
||
{
|
||
"candidates": [
|
||
{
|
||
"id": 1,
|
||
"start_time": "MM:SS",
|
||
"end_time": "MM:SS",
|
||
"reason": "구간 선정 이유 요약"
|
||
},
|
||
... (총 5개)
|
||
]
|
||
}
|
||
"""
|
||
|
||
DEFAULT_CONFIG = {
|
||
"model": "gemini-3.5-flash",
|
||
"model_step1": "",
|
||
"fps_step1": 0.2,
|
||
"fps_step3": 1.0,
|
||
}
|
||
|
||
|
||
def load_step1() -> str:
|
||
if not os.path.isfile(STEP1_PATH):
|
||
os.makedirs(PROMPT_DIR, exist_ok=True)
|
||
with open(STEP1_PATH, "w", encoding="utf-8") as f:
|
||
f.write(DEFAULT_STEP1)
|
||
with open(STEP1_PATH, encoding="utf-8") as f:
|
||
return f.read()
|
||
|
||
|
||
def load_step3() -> str:
|
||
if not os.path.isfile(STEP3_PATH):
|
||
raise RuntimeError(f"Step 3 지침 파일이 없습니다: {STEP3_PATH}")
|
||
with open(STEP3_PATH, encoding="utf-8") as f:
|
||
return f.read()
|
||
|
||
|
||
def load_config() -> dict:
|
||
"""설정 로드. 파일이 없거나 깨졌으면 기본값(파일은 첫 save 때 생성)."""
|
||
cfg = dict(DEFAULT_CONFIG)
|
||
try:
|
||
with open(CONFIG_PATH, encoding="utf-8") as f:
|
||
loaded = json.load(f)
|
||
if isinstance(loaded, dict):
|
||
cfg.update(loaded)
|
||
except (OSError, json.JSONDecodeError, ValueError):
|
||
pass
|
||
return cfg
|
||
|
||
|
||
def save(*, step1: Optional[str] = None, step3: Optional[str] = None,
|
||
config: Optional[str] = None) -> None:
|
||
"""전달된 것만 저장. config 는 JSON 문자열 — 형식 오류는 예외로 올린다."""
|
||
os.makedirs(PROMPT_DIR, exist_ok=True)
|
||
if config is not None: # 검증 먼저(실패 시 아무것도 안 씀)
|
||
cfg = json.loads(config)
|
||
if not isinstance(cfg, dict):
|
||
raise ValueError("설정은 JSON 객체({…})여야 합니다.")
|
||
merged = dict(DEFAULT_CONFIG)
|
||
merged.update(cfg)
|
||
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
|
||
json.dump(merged, f, ensure_ascii=False, indent=1)
|
||
if step1 is not None:
|
||
with open(STEP1_PATH, "w", encoding="utf-8") as f:
|
||
f.write(step1)
|
||
if step3 is not None:
|
||
with open(STEP3_PATH, "w", encoding="utf-8") as f:
|
||
f.write(step3)
|
||
|
||
|
||
def reset() -> None:
|
||
"""Step 1 프롬프트·설정을 기본값으로. Step 3(지침서)는 사용자 파일이라 불변."""
|
||
os.makedirs(PROMPT_DIR, exist_ok=True)
|
||
with open(STEP1_PATH, "w", encoding="utf-8") as f:
|
||
f.write(DEFAULT_STEP1)
|
||
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
|
||
json.dump(DEFAULT_CONFIG, f, ensure_ascii=False, indent=1)
|
||
```
|
||
|
||
- [ ] **Step 2: 검증**
|
||
|
||
```
|
||
python - <<'EOF'
|
||
from capcut_agent import prompts
|
||
s1 = prompts.load_step1()
|
||
assert "end_time" in s1 and "candidates" in s1 # 오타 수정 확인
|
||
s3 = prompts.load_step3()
|
||
assert "cuts" in s3 and "bottom" in s3 # 기존 지침서 로드
|
||
cfg = prompts.load_config()
|
||
assert cfg["model"] == "gemini-3.5-flash" and cfg["fps_step1"] == 0.2
|
||
prompts.save(config='{"model":"gemini-3.1-pro-preview"}')
|
||
assert prompts.load_config()["model"] == "gemini-3.1-pro-preview"
|
||
assert prompts.load_config()["fps_step1"] == 0.2 # 병합 유지
|
||
prompts.reset()
|
||
assert prompts.load_config()["model"] == "gemini-3.5-flash"
|
||
try:
|
||
prompts.save(config='잘못된 json')
|
||
raise SystemExit("FAIL: no exception")
|
||
except Exception:
|
||
pass
|
||
print("OK")
|
||
EOF
|
||
```
|
||
Expected: `OK`. 실행 후 `프롬프트/` 폴더에 두 파일 생성 확인: `ls 프롬프트`
|
||
|
||
---
|
||
|
||
### Task 5: `capcut_agent/plan.py` — Gemini Step 1 / Step 3
|
||
|
||
**Files:**
|
||
- Create: `capcut_agent/plan.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `correct._gemini_key`, `correct.GeminiQuotaError`, `paste.parse_paste`, `paste.parse_time`, `prompts.load_step1/load_step3/load_config`
|
||
- Produces (Task 6이 사용):
|
||
- `select_highlights(url: str, *, key=None, model=None) -> list[dict]` — `[{"id":1,"start":134.0,"end":242.0,"reason":"…"}]`. 실패 시 `RuntimeError`/`GeminiQuotaError`
|
||
- `edit_plan(url: str, start: float, end: float, *, key=None, model=None) -> dict` — `{"paste": <parse_paste 결과>, "titles": [{"top","main","kind"}], "time_note": ""|"클립 기준 → +시작 보정"}`
|
||
|
||
- [ ] **Step 1: 파일 작성**
|
||
|
||
```python
|
||
"""자동 탭 — Gemini로 오팔 파이프라인 대체.
|
||
|
||
Step 1: 전체 영상(저 fps) → 하이라이트 후보 5개.
|
||
Step 3: 구간별(기본 fps, videoMetadata로 클립) → 붙여넣기 탭 스키마 JSON + 타이틀 후보 5선.
|
||
오팔 Step 2(노드 간 구간 값 전달)는 코드에선 함수 인자이므로 존재하지 않는다.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
import urllib.error
|
||
import urllib.request
|
||
from typing import Dict, List, Optional
|
||
|
||
from .correct import GeminiQuotaError, _gemini_key
|
||
from .paste import parse_paste, parse_time
|
||
from . import prompts
|
||
|
||
DEFAULT_MODEL = "gemini-3.5-flash"
|
||
_ENDPOINT = "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={key}"
|
||
|
||
_FENCE_RE = re.compile(r"```json\s*(.*?)```", re.S)
|
||
# 블록 ② "1. 상단: … / 메인: … — 유형" (전각·반각 콜론, —/- 모두 허용)
|
||
_TITLE_RE = re.compile(
|
||
r"^\s*\d+\.\s*상단\s*[::]\s*(.+?)\s*/\s*메인\s*[::]\s*(.+?)(?:\s*[—-]\s*(\S+))?\s*$",
|
||
re.M)
|
||
|
||
|
||
def _fmt_offset(sec: float) -> str:
|
||
return f"{max(0, int(sec))}s"
|
||
|
||
|
||
def _call(video_url: str, prompt_text: str, *, model: str, key: str,
|
||
fps: Optional[float] = None, start: Optional[float] = None,
|
||
end: Optional[float] = None, timeout: float = 600.0) -> str:
|
||
"""Gemini generateContent 1회 → 응답 텍스트. 429는 GeminiQuotaError."""
|
||
part: Dict = {"fileData": {"fileUri": video_url}}
|
||
meta: Dict = {}
|
||
if fps is not None and abs(fps - 1.0) > 1e-9:
|
||
meta["fps"] = fps
|
||
if start is not None and end is not None:
|
||
meta["startOffset"] = _fmt_offset(start)
|
||
meta["endOffset"] = _fmt_offset(end)
|
||
if meta:
|
||
part["videoMetadata"] = meta
|
||
body = {
|
||
"contents": [{"parts": [part, {"text": prompt_text}]}],
|
||
"generationConfig": {"temperature": 0.7},
|
||
}
|
||
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"))
|
||
except urllib.error.HTTPError as e:
|
||
if e.code == 429:
|
||
raise GeminiQuotaError()
|
||
try:
|
||
detail = e.read().decode("utf-8", "replace")[:400]
|
||
except Exception: # noqa: BLE001
|
||
detail = ""
|
||
raise RuntimeError(f"Gemini HTTP {e.code}: {detail}")
|
||
try:
|
||
return data["candidates"][0]["content"]["parts"][0]["text"]
|
||
except (KeyError, IndexError, TypeError):
|
||
raise RuntimeError(
|
||
"Gemini 응답 형식 오류: "
|
||
+ json.dumps(data, ensure_ascii=False)[:300])
|
||
|
||
|
||
def _extract_json_str(text: str) -> str:
|
||
"""첫 ```json 펜스 안쪽. 펜스 없으면 가장 바깥 {…} 범위, 그것도 없으면 전문."""
|
||
m = _FENCE_RE.search(text or "")
|
||
if m:
|
||
return m.group(1).strip()
|
||
t = (text or "").strip()
|
||
i, j = t.find("{"), t.rfind("}")
|
||
if i != -1 and j > i:
|
||
return t[i:j + 1]
|
||
return t
|
||
|
||
|
||
def select_highlights(url: str, *, key: Optional[str] = None,
|
||
model: Optional[str] = None) -> List[Dict]:
|
||
"""Step 1: 전체 영상 → 하이라이트 후보 [{id, start, end, reason}] (초)."""
|
||
key = key or _gemini_key()
|
||
if not key:
|
||
raise RuntimeError("Gemini 키 없음 (.gemini_key)")
|
||
cfg = prompts.load_config()
|
||
model = model or cfg.get("model_step1") or cfg.get("model") or DEFAULT_MODEL
|
||
fps = float(cfg.get("fps_step1") or 0.2)
|
||
text = _call(url, prompts.load_step1(), model=model, key=key, fps=fps)
|
||
try:
|
||
data = json.loads(_extract_json_str(text), strict=False)
|
||
except json.JSONDecodeError:
|
||
raise RuntimeError(f"Step 1 응답 JSON 파싱 실패:\n{text[:400]}")
|
||
cands = data.get("candidates") if isinstance(data, dict) else None
|
||
if not isinstance(cands, list) or not cands:
|
||
raise RuntimeError(f"Step 1 응답에 candidates 배열이 없습니다:\n{text[:400]}")
|
||
out: List[Dict] = []
|
||
for i, c in enumerate(cands, 1):
|
||
if not isinstance(c, dict):
|
||
continue
|
||
try:
|
||
s = parse_time(c.get("start_time"))
|
||
e = parse_time(c.get("end_time"))
|
||
except (ValueError, TypeError):
|
||
continue
|
||
if e > s:
|
||
out.append({"id": int(c.get("id") or i), "start": s, "end": e,
|
||
"reason": str(c.get("reason") or "").strip()})
|
||
if not out:
|
||
raise RuntimeError("Step 1: 유효한 구간이 하나도 없습니다.")
|
||
return out
|
||
|
||
|
||
def _shift_cuts_if_relative(payload: Dict, start: float, end: float) -> str:
|
||
"""클립 기준(0부터) 타임코드로 보이면 원본 기준으로 보정. 반환: 로그용 메모.
|
||
|
||
구간을 잘라 보낸 클립에 대해 모델이 절대/클립 어느 기준으로 답할지 보장이
|
||
없다(문서 미명시). 절대 기준으로 보이면 그대로(우선), 클립 기준으로 보이면
|
||
start 를 더하고, 둘 다 아니면 손대지 않는다(이후 검증·다운로드에서 드러남).
|
||
"""
|
||
cuts = payload["cuts"]
|
||
dur, pad = end - start, 10.0
|
||
if all(start - pad <= s and e <= end + pad for s, e, _, _ in cuts):
|
||
return "" # 절대 기준 — 그대로
|
||
if start > pad and all(0 <= s and e <= dur + pad for s, e, _, _ in cuts):
|
||
payload["cuts"] = [(s + start, e + start, b, f) for s, e, b, f in cuts]
|
||
return f"컷 타임코드가 클립 기준 → +{start:.0f}s 보정"
|
||
return ""
|
||
|
||
|
||
def edit_plan(url: str, start: float, end: float, *, key: Optional[str] = None,
|
||
model: Optional[str] = None) -> Dict:
|
||
"""Step 3: [start, end] 클립 → {paste, titles, time_note}.
|
||
|
||
paste 는 기존 paste.parse_paste 검증을 그대로 통과한 결과이며 url 은 입력값으로
|
||
덮어쓴다(LLM이 영상 ID를 지어내는 사고 차단).
|
||
"""
|
||
key = key or _gemini_key()
|
||
if not key:
|
||
raise RuntimeError("Gemini 키 없음 (.gemini_key)")
|
||
cfg = prompts.load_config()
|
||
model = model or cfg.get("model") or DEFAULT_MODEL
|
||
fps = float(cfg.get("fps_step3") or 1.0)
|
||
text = _call(url, prompts.load_step3(), model=model, key=key,
|
||
fps=fps, start=start, end=end)
|
||
payload = parse_paste(_extract_json_str(text)) # ValueError 는 호출부에서 표시
|
||
payload["url"] = url
|
||
note = _shift_cuts_if_relative(payload, start, end)
|
||
titles = [{"top": m.group(1).strip(), "main": m.group(2).strip(),
|
||
"kind": (m.group(3) or "").strip()}
|
||
for m in _TITLE_RE.finditer(text)]
|
||
return {"paste": payload, "titles": titles, "time_note": note}
|
||
```
|
||
|
||
- [ ] **Step 2: 구문·파싱 검증 (네트워크 불필요)**
|
||
|
||
```
|
||
python - <<'EOF'
|
||
from capcut_agent.plan import _extract_json_str, _shift_cuts_if_relative, _TITLE_RE
|
||
# 펜스 추출
|
||
t = 'x\n```json\n{"a":1}\n```\ny\n```json\n{"b":2}\n```'
|
||
assert _extract_json_str(t) == '{"a":1}'
|
||
assert _extract_json_str('전문 {"a":1} 뒤') == '{"a":1}'
|
||
# 타이틀 후보
|
||
txt = """📌 타이틀 후보 5선
|
||
1. 상단: 충격 실화 / 메인: 그날의 진실 — 어그로형
|
||
2. 상단: 이게 되네 / 메인: 미친 반전 — 바이럴형
|
||
"""
|
||
ms = _TITLE_RE.findall(txt)
|
||
assert ms[0][:2] == ("충격 실화", "그날의 진실") and ms[1][2] == "바이럴형", ms
|
||
# 타임코드 보정: 절대 → 그대로 / 클립 기준 → 이동 / 판정불가 → 그대로
|
||
p = {"cuts": [(140.0, 145.0, "", ""), (200.0, 205.0, "", "")]}
|
||
assert _shift_cuts_if_relative(p, 134.0, 242.0) == "" and p["cuts"][0][0] == 140.0
|
||
p = {"cuts": [(5.0, 10.0, "", ""), (60.0, 66.0, "", "")]}
|
||
assert _shift_cuts_if_relative(p, 134.0, 242.0) != "" and p["cuts"][0][0] == 139.0
|
||
p = {"cuts": [(500.0, 505.0, "", "")]}
|
||
assert _shift_cuts_if_relative(p, 134.0, 242.0) == "" and p["cuts"][0][0] == 500.0
|
||
print("OK")
|
||
EOF
|
||
```
|
||
Expected: `OK`
|
||
|
||
- [ ] **Step 3: 임포트 체인 확인**
|
||
|
||
```
|
||
python -c "from capcut_agent import plan, comments, prompts; print('OK')"
|
||
```
|
||
Expected: `OK`
|
||
|
||
---
|
||
|
||
### Task 6: 서버 엔드포인트 5개 + StaticFiles 마운트
|
||
|
||
**Files:**
|
||
- Modify: `server/app.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: Task 2 `name_suffix`, Task 3 `comments.*`, Task 4 `prompts.*`, Task 5 `plan.*`
|
||
- Produces (Task 9의 auto.js가 사용):
|
||
- `POST /auto/analyze` Form `url` → `{"analysis_id": str}` | `{"error": str}`
|
||
- `GET /auto/stream/{aid}` SSE — `manifest`/`step`/`log`/`result`/`error` (스펙 §4.3 payload)
|
||
- `GET /auto/avatar?url=` → 이미지 바이트 (ggpht/googleusercontent만)
|
||
- `GET /prompts` → `{"step1","step3","config","step3_path"}` / `POST /prompts` Form `step1?`,`step3?`,`config?`,`reset?`
|
||
- `POST /auto/build` multipart → `{"job_id"}` → 기존 `GET /stream/{job_id}` 로 진행
|
||
- `/static/*` 정적 파일
|
||
|
||
- [ ] **Step 1: 임포트·상수·마운트 추가**
|
||
|
||
`server/app.py` 상단 임포트 블록을 다음과 같이 확장:
|
||
|
||
```python
|
||
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
|
||
```
|
||
|
||
`UPLOAD_DIR` 정의 아래에 추가:
|
||
|
||
```python
|
||
COMMENTS_DIR = os.path.join(os.path.dirname(BASE_DIR), ".comments")
|
||
os.makedirs(COMMENTS_DIR, exist_ok=True)
|
||
```
|
||
|
||
`app = FastAPI(...)` 바로 아래에 추가:
|
||
|
||
```python
|
||
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||
|
||
# analysis_id → {"url": …} (분석은 SSE 1회성 — 결과는 브라우저가 들고 있음)
|
||
ANALYSES: dict[str, dict] = {}
|
||
```
|
||
|
||
- [ ] **Step 2: `/auto/analyze` + `/auto/stream`**
|
||
|
||
`stream()` 엔드포인트 아래에 추가:
|
||
|
||
```python
|
||
@app.post("/auto/analyze")
|
||
async def auto_analyze(url: str = Form(...)) -> JSONResponse:
|
||
"""자동 탭 1단계 — 분석 예약. 실제 작업은 /auto/stream 에서 SSE 로."""
|
||
if not has_gemini_key():
|
||
return JSONResponse({"error": "Gemini 키가 없습니다. 프로젝트 루트의 "
|
||
".gemini_key 파일을 확인하세요. (그동안은 오팔 → "
|
||
"붙여넣기 탭을 쓰면 됩니다)"}, 400)
|
||
u = url.strip()
|
||
if not (u.startswith("http://") or u.startswith("https://")):
|
||
return JSONResponse({"error": "유튜브 주소를 입력하세요."}, 400)
|
||
aid = hashlib.sha1(u.encode()).hexdigest()[:12]
|
||
ANALYSES[aid] = {"url": u}
|
||
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"]
|
||
warnings: list[str] = []
|
||
yield _sse({"type": "manifest", "steps": [
|
||
{"id": "step1", "label": "하이라이트 구간 선정 (Gemini)"},
|
||
{"id": "step3", "label": "편집안 생성 (Gemini, 구간별 동시)"},
|
||
{"id": "comments", "label": "댓글 수집 (h-lab)"},
|
||
]})
|
||
# ── 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
|
||
yield _sse({"type": "error",
|
||
"message": f"Step 1 실패 — {type(exc).__name__}: {exc}\n"
|
||
"오팔 → 붙여넣기 탭으로도 만들 수 있습니다."})
|
||
return
|
||
yield _sse({"type": "step", "id": "step1", "status": "done",
|
||
"detail": f"{len(cands)}개 구간"})
|
||
|
||
# ── Step 3 (동시) ∥ 댓글 수집 ──
|
||
yield _sse({"type": "step", "id": "step3", "status": "start"})
|
||
yield _sse({"type": "step", "id": "comments", "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)]
|
||
com_task = asyncio.create_task(asyncio.to_thread(hlab.fetch_comments, url))
|
||
|
||
highlights: list[dict] = []
|
||
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": [{"start": s, "end": e, "bottom": b, "effect": f}
|
||
for s, e, b, f in p["cuts"]],
|
||
},
|
||
"titles": r["titles"],
|
||
"total": round(total, 1),
|
||
"need": max(1, int(total // 3)),
|
||
})
|
||
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": "실패(생략)"})
|
||
|
||
# 댓글 매칭 + 응답 슬림화(참조된 댓글만 전송, 구간 매칭은 60개 캡)
|
||
used: set[int] = set()
|
||
for h in highlights:
|
||
if "paste" not in h:
|
||
continue
|
||
matched = hlab.match_window(comments, h["start"], h["end"])[:60]
|
||
h["matched"] = matched
|
||
h["candidates"] = hlab.top_liked(comments, set(matched), 20)
|
||
used.update(matched)
|
||
used.update(h["candidates"])
|
||
slim = [c for c in comments if c["idx"] in used]
|
||
yield _sse({"type": "result", "highlights": highlights,
|
||
"comments": slim, "warnings": warnings})
|
||
|
||
return StreamingResponse(gen(), media_type="text/event-stream",
|
||
headers={"Cache-Control": "no-cache",
|
||
"X-Accel-Buffering": "no"})
|
||
```
|
||
|
||
- [ ] **Step 3: `/auto/avatar` + `/prompts`**
|
||
|
||
```python
|
||
@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})
|
||
```
|
||
|
||
- [ ] **Step 4: `/auto/build`**
|
||
|
||
```python
|
||
@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(""),
|
||
) -> 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,
|
||
}
|
||
return JSONResponse({"job_id": h, "cuts": len(payload["cuts"]),
|
||
"cards": len(cards)})
|
||
```
|
||
|
||
- [ ] **Step 5: 검증**
|
||
|
||
```
|
||
python -c "import ast; ast.parse(open('server/app.py', encoding='utf-8').read())"
|
||
python -c "from server import app; rs={r.path for r in app.app.routes}; \
|
||
need={'/auto/analyze','/auto/stream/{aid}','/auto/avatar','/prompts','/auto/build'}; \
|
||
missing=need-rs; assert not missing, missing; print('OK')"
|
||
```
|
||
Expected: `OK`
|
||
|
||
---
|
||
|
||
### Task 7: modern-screenshot 벤더링
|
||
|
||
**Files:**
|
||
- Create: `server/static/modern-screenshot.js` (CDN에서 내려받아 동봉)
|
||
|
||
- [ ] **Step 1: 다운로드**
|
||
|
||
```
|
||
curl -sL "https://cdn.jsdelivr.net/npm/modern-screenshot@4/dist/index.js" -o server/static/modern-screenshot.js
|
||
```
|
||
|
||
- [ ] **Step 2: 검증 — UMD 전역명·크기**
|
||
|
||
```
|
||
python - <<'EOF'
|
||
src = open("server/static/modern-screenshot.js", encoding="utf-8", errors="replace").read()
|
||
assert len(src) > 10_000, f"too small: {len(src)}"
|
||
assert "modernScreenshot" in src and "domToBlob" in src
|
||
print("OK", len(src))
|
||
EOF
|
||
```
|
||
Expected: `OK <크기>`. (h-lab이 같은 @4 CDN 빌드를 쓰므로 전역명은 `window.modernScreenshot`)
|
||
|
||
---
|
||
|
||
### Task 8: index.html — 탭·패널·카드 CSS
|
||
|
||
**Files:**
|
||
- Modify: `server/static/index.html`
|
||
|
||
**Interfaces:**
|
||
- Produces (Task 9의 auto.js가 참조하는 DOM id): `tab-auto`, `panel-auto`, `autoUrl`, `autoGo`, `autoSettings`(details), `pStep1`, `pStep3`, `pConfig`, `pSave`, `pReset`, `pMsg`, `autoSteps`, `autoLog`, `autoReview`, `autoBuild`, `autoSummary`, `cdirField`
|
||
- 기존 공통 옵션 id 재사용: `vscale`, `flip`, `scene`, `bgwhite`, `rmsilence`, `asrbottom`, `autoopen`
|
||
|
||
- [ ] **Step 1: 탭 버튼 추가**
|
||
|
||
`index.html:124` `<button class="tab" id="tab-paste">📋 붙여넣기</button>` 다음 줄에:
|
||
|
||
```html
|
||
<button class="tab" id="tab-auto">🤖 자동</button>
|
||
```
|
||
|
||
- [ ] **Step 2: 자동 패널 추가**
|
||
|
||
`<!-- 공통: 제목/출처 + 영상 옵션 -->` 주석 바로 앞(= `panel-paste` 닫는 `</div>` 다음)에 삽입:
|
||
|
||
```html
|
||
<!-- 자동(Gemini) 모드 -->
|
||
<div id="panel-auto" style="display:none;">
|
||
<div class="card" style="padding:18px;">
|
||
<div class="field">
|
||
<label>유튜브 URL</label>
|
||
<input id="autoUrl" placeholder="https://www.youtube.com/watch?v=…" />
|
||
</div>
|
||
<button class="run" id="autoGo" style="margin-top:8px;">분석 시작 (하이라이트 5개)</button>
|
||
<div class="note" style="margin-top:8px;">
|
||
Gemini가 영상에서 하이라이트 구간 5개를 골라 편집안을 만들고, h-lab에서
|
||
그 구간을 언급한 댓글을 찾아옵니다. 결과가 마음에 안 들면 오팔 →
|
||
붙여넣기 탭도 그대로 쓸 수 있어요.
|
||
</div>
|
||
</div>
|
||
|
||
<details id="autoSettings" style="margin-top:12px;">
|
||
<summary style="cursor:pointer;color:var(--muted2);font-size:12.5px;list-style:none;">
|
||
⚙ 지침·모델 수정
|
||
</summary>
|
||
<div class="card" style="padding:16px;margin-top:10px;">
|
||
<div class="field"><label>Step 1 — 하이라이트 선정 프롬프트</label>
|
||
<textarea id="pStep1" rows="8" spellcheck="false"
|
||
style="width:100%;box-sizing:border-box;background:#0f0f0f;color:#e6e6e6;border:1px solid var(--border);border-radius:8px;padding:11px;font-family:var(--mono);font-size:12px;line-height:1.5;resize:vertical;"></textarea>
|
||
</div>
|
||
<div class="field"><label>Step 3 — 편집 지침서 (숏폼_편집_지침서 파일 그대로)</label>
|
||
<textarea id="pStep3" rows="10" spellcheck="false"
|
||
style="width:100%;box-sizing:border-box;background:#0f0f0f;color:#e6e6e6;border:1px solid var(--border);border-radius:8px;padding:11px;font-family:var(--mono);font-size:12px;line-height:1.5;resize:vertical;"></textarea>
|
||
</div>
|
||
<div class="field"><label>모델·샘플링 설정 (JSON)</label>
|
||
<textarea id="pConfig" rows="5" spellcheck="false"
|
||
style="width:100%;box-sizing:border-box;background:#0f0f0f;color:#e6e6e6;border:1px solid var(--border);border-radius:8px;padding:11px;font-family:var(--mono);font-size:12px;line-height:1.5;resize:vertical;"></textarea>
|
||
<div class="note" style="margin-top:2px;">
|
||
model=기본 모델 · model_step1=Step1 전용(비우면 model) ·
|
||
fps_step1/fps_step3=영상 샘플링(낮을수록 토큰 절약, 긴 영상은 0.2 권장)
|
||
</div>
|
||
</div>
|
||
<button class="ghost" id="pSave">저장</button>
|
||
<button class="ghost" id="pReset" title="Step1 프롬프트·설정만 초기화 (지침서 파일은 그대로)">기본값 복원</button>
|
||
<span class="note" id="pMsg"></span>
|
||
</div>
|
||
</details>
|
||
|
||
<div id="autoSteps"></div>
|
||
<div id="autoLog"></div>
|
||
<div id="autoReview"></div>
|
||
<button class="run" id="autoBuild" style="display:none;margin-top:14px;"></button>
|
||
<div id="autoSummary" class="card" style="display:none;padding:18px;margin-top:12px;"></div>
|
||
</div>
|
||
```
|
||
|
||
- [ ] **Step 3: 댓글 폴더 필드에 id 부여**
|
||
|
||
`<label>댓글 카드 폴더 (선택)</label>`를 감싸는 `<div class="field">`를
|
||
`<div class="field" id="cdirField">`로 수정.
|
||
|
||
- [ ] **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 + 스크립트 로드**
|
||
|
||
`</style>` 직전에 카드 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);}
|
||
```
|
||
|
||
`</body>` 직전(기존 `</script>` 다음)에:
|
||
|
||
```html
|
||
<script src="/static/modern-screenshot.js"></script>
|
||
<script src="/static/auto.js"></script>
|
||
```
|
||
|
||
- [ ] **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=
|
||
'<div class="cc-head">'+
|
||
'<img class="cc-avatar" loading="lazy" decoding="async" alt="" src="'+av+'">'+
|
||
'<div class="cc-meta">'+
|
||
'<div><span class="cc-author"></span><span class="cc-time"></span></div>'+
|
||
'<div class="cc-text"></div>'+
|
||
'<div class="cc-stats"><span>👍 '+(c.likeCount||0).toLocaleString()+'</span>'+
|
||
'<span>💬 '+(c.replyCount||0).toLocaleString()+'</span></div>'+
|
||
'</div></div>';
|
||
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(/<br\s*\/?>/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=>
|
||
'<div class="step" id="ast-'+s.id+'"><span class="sdot"></span>'+
|
||
'<div class="slabel"><div class="t">'+esc(s.label)+'</div><div class="sdetail" hidden></div></div>'+
|
||
'<div class="selapsed"></div></div>').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="<h3>ID "+hl.id+" · "+fmtT(hl.start)+"~"+fmtT(hl.end)+"</h3>"+
|
||
'<div class="hlmeta">생성 실패: '+esc(hl.error)+"</div>";
|
||
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="<h3>ID "+hl.id+" · "+fmtT(hl.start)+"~"+fmtT(hl.end)+
|
||
" <span style='color:var(--muted2);font-weight:400;'>"+esc(hl.reason||"")+"</span></h3>"+
|
||
'<div class="hlmeta">컷 '+cuts.length+"개 · 총 "+hl.total+"초 · 카드 "+hl.need+"장 필요 · "+
|
||
'<span id="hlcnt-'+hl.id+'"></span></div>';
|
||
// 타이틀 선택(후보 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+='<div class="hlsec">제목: <select id="hlt-'+hl.id+'">'+
|
||
opts.map((t,i)=>'<option value="'+i+'">'+esc(t.top)+" / "+esc(t.main)+
|
||
(t.kind?" — "+esc(t.kind):"")+"</option>").join("")+"</select></div>";
|
||
html+='<div class="hlsec">자막 미리보기: '+
|
||
esc(cuts.slice(0,3).map(c=>(c.bottom||"").split("\n")[0]).filter(Boolean).join(" · "))+" …</div>";
|
||
box.innerHTML=html;
|
||
box.dataset.titles=JSON.stringify(opts);
|
||
// ⭐ 구간 언급 댓글
|
||
const sec1=document.createElement("div");sec1.className="hlsec";
|
||
sec1.innerHTML="⭐ 이 구간을 언급한 댓글 "+(hl.matched||[]).length+"장 (자동 선택됨)<br>";
|
||
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="➕ 좋아요 상위 후보 (부족분 클릭)<br>";
|
||
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. 드래프트 이름이 `<제목>_<N>컷_하이라이트<id>` 형태로 서로 다른가 (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 저장소 아님) — 각 태스크의 검증 명령이 게이트.
|