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

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

62 KiB
Raw Permalink Blame History

자동 탭 — 오팔 대체 + 댓글 자동 매칭 구현 계획

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행)를 아래로 교체한다. 정렬 규칙 부분은 기존 그대로다 — 바뀌는 건 마지막 배치 계산뿐.

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_pastename_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/buildJOBS[h]["name_suffix"]로 전달.

  • Step 1: 시그니처에 파라미터 추가

pipeline.py process_paste의 키워드 인자 목록 끝(asr_bottom: bool = False, 다음 줄)에 추가:

    asr_bottom: bool = False,
    name_suffix: str = "",
  • Step 2: 이름 결정부 수정

pipeline.py:340 근처:

    draft_name = _safe_name(title) or draft_name

을 아래로 교체:

    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= 줄 다음에 추가:

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

"""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) -> Noneconfig는 JSON 문자열, json.JSONDecodeError/ValueError 던짐
    • reset() -> None — step1·설정만 기본값 복원(Step 3 지침서는 사용자 파일이라 불변)
    • 상수 STEP3_PATH, DEFAULT_CONFIG
  • Step 1: 파일 작성

"""프롬프트·설정 파일 관리 — 자동 탭(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: 파일 작성

"""자동 탭 — 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 상단 임포트 블록을 다음과 같이 확장:

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 정의 아래에 추가:

COMMENTS_DIR = os.path.join(os.path.dirname(BASE_DIR), ".comments")
os.makedirs(COMMENTS_DIR, exist_ok=True)

app = FastAPI(...) 바로 아래에 추가:

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() 엔드포인트 아래에 추가:

@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
@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
@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> 다음 줄에:

    <button class="tab" id="tab-auto">🤖 자동</button>
  • Step 2: 자동 패널 추가

<!-- 공통: 제목/출처 + 영상 옵션 --> 주석 바로 앞(= panel-paste 닫는 </div> 다음)에 삽입:

  <!-- 자동(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 숨김):

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줄 아래에 추가:

$("#tab-auto").addEventListener("click",()=>setMode("auto"));
  • Step 5: 카드 CSS + 스크립트 로드

</style> 직전에 카드 CSS 추가 — h-lab comment-cards.html의 카드 스타일에서 고정 조합(bg-black·rounded·mosaic)의 최종값을 이식. 선택 표시는 래퍼에 그려 캡처 대상(.comment-card)에 안 찍히게 한다:

  /* ── 자동 탭: 댓글 카드 (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> 다음)에:

<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/builddata 필드는 붙여넣기 탭 스키마로 변환해 보낸다 — 서버가 준 paste.cuts는 이미 {start,end,bottom,effect} 객체 배열이므로 그대로 직렬화하면 된다(parse_time이 숫자도 허용).

  • Step 1: 파일 작성

/* 자동 탭 — 분석(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/buildparse_paste가 재검증. 타입 일관 확인.
  • need 계산식 max(1, int(total // 3))이 Task 1의 max(1, int(dur // min_sec))와 동일식임을 확인.
  • 커밋 단계 없음(git 저장소 아님) — 각 태스크의 검증 명령이 게이트.