# 자동 탭 — 오팔 대체 + 댓글 자동 매칭 구현 계획 > **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//`). 카드 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"(?", re.I) _TAG_RE = re.compile(r"<[^>]+>") def plain_text(html: str) -> str: """YouTube textDisplay(HTML) → 평문.
→줄바꿈, 나머지 태그 제거.""" 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("0:59
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": , "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` `` 다음 줄에: ```html ``` - [ ] **Step 2: 자동 패널 추가** `` 주석 바로 앞(= `panel-paste` 닫는 `` 다음)에 삽입: ```html ``` - [ ] **Step 3: 댓글 폴더 필드에 id 부여** ``를 감싸는 `
`를 `
`로 수정. - [ ] **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 + 스크립트 로드** `` 직전에 카드 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);} ``` `` 직전(기존 `` 다음)에: ```html ``` - [ ] **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= '
'+ ''+ '
'+ '
'+ '
'+ '
👍 '+(c.likeCount||0).toLocaleString()+''+ '💬 '+(c.replyCount||0).toLocaleString()+'
'+ '
'; 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(//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=> '
'+ '
'+esc(s.label)+'
'+ '
').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="

ID "+hl.id+" · "+fmtT(hl.start)+"~"+fmtT(hl.end)+"

"+ '
생성 실패: '+esc(hl.error)+"
"; 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="

ID "+hl.id+" · "+fmtT(hl.start)+"~"+fmtT(hl.end)+ " "+esc(hl.reason||"")+"

"+ '
컷 '+cuts.length+"개 · 총 "+hl.total+"초 · 카드 "+hl.need+"장 필요 · "+ '
'; // 타이틀 선택(후보 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+='
제목:
"; html+='
자막 미리보기: '+ esc(cuts.slice(0,3).map(c=>(c.bottom||"").split("\n")[0]).filter(Boolean).join(" · "))+" …
"; box.innerHTML=html; box.dataset.titles=JSON.stringify(opts); // ⭐ 구간 언급 댓글 const sec1=document.createElement("div");sec1.className="hlsec"; sec1.innerHTML="⭐ 이 구간을 언급한 댓글 "+(hl.matched||[]).length+"장 (자동 선택됨)
"; 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="➕ 좋아요 상위 후보 (부족분 클릭)
"; 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. 드래프트 이름이 `<제목>_컷_하이라이트` 형태로 서로 다른가 (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 저장소 아님) — 각 태스크의 검증 명령이 게이트.