merge: 컷별 댓글 추천 + 댓글 검색

자동 탭에 컷별 댓글 추천·컷 위치 배치를 넣고, 댓글 선택 화면에 검색을 추가한다.
태스크별 리뷰 10회 + 브랜치 전체 리뷰(+수정 웨이브)를 거쳤다.
실사용 검증(실제 영상 → CapCut에서 열어 확인)은 아직 안 했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-08-04 16:05:52 +09:00
commit 3d3f6f6b14
13 changed files with 742 additions and 75 deletions

View File

@ -37,6 +37,7 @@ capcut2/
├─ silence.py ffmpeg silencedetect → 발화 구간
├─ transcribe.py faster-whisper(medium/int8/cpu) 단어 타임스탬프
├─ correct.py Gemini 자막 글자 교정(시간 불변) — gemini-2.5-flash
├─ recommend.py 컷별 댓글 추천(자동 탭) — 타임스탬프 우선 + Gemini 텍스트 추천
├─ highlight.py 자막 청킹(cut_plan) 유틸
├─ scene.py ffmpeg scene 필터 장면전환 감지·분할
├─ media.py 프레임 PNG 생성, 흰밴드 감지, 오디오 추출
@ -129,6 +130,8 @@ LLM이 만든 편집안 JSON을 **그대로** 사용. 무음컷·ASR **기본
JSON bottom 폴백. effect/제목/채널은 JSON 유지.
⚠ "remove_silence 후에 돌리면 remap 불필요"는 틀림 — 파일은 압축 안 되므로 cut_plan 매핑 필수.
6. **scene / 댓글카드 / draft**: 4-A와 동일(좌표도 공통 — §9).
자동 탭에서 넘어온 경우 `process_paste(card_cuts=[...])`로 카드별 소속 컷을
받는다(§6 참고) — 붙여넣기 탭 직접 사용 시엔 생략(기존 전체 균등 배치).
#### 붙여넣기 JSON 스키마 (LLM에게 시킬 형식)
@ -223,6 +226,21 @@ title_top 서브제목 / title_main 메인제목 / channel 출처 / effect 효
- `_load_comment_cards(folder, dur, interval=3.0)`:
- 모든 파일명이 숫자로 시작 → 숫자순(1,2,10). 아니면 → **파일 생성시각(저장 순서)**.
- png/jpg/jpeg/webp. 카드당 3초, 영상 길이 초과분은 생략.
- **배치 방식이 두 갈래**(정렬 자체는 `_card_paths()`로 공통): 자동 탭은 검토 화면에서
카드별 소속 컷 인덱스(`card_cuts`)를 보내고, `_cards_by_cut(paths, card_cuts, placements, dur)`
**그 컷 구간 안에서** 균등 배치한다(한 컷이 덜 차도 다음 컷 카드가 앞으로 밀리지 않음).
파일/유튜브 탭·붙여넣기 탭 직접 사용은 컷 소속을 몰라 기존 `_load_comment_cards` 전체 균등
배치 그대로 쓴다.
- 컷당 장수 상한은 `max(1, floor(컷길이/3초))``_load_comment_cards`와 같은 규칙.
초과분은 버린다(카드가 1초씩 번쩍이느니 몇 장 빼는 게 낫다).
- ⚠ **카드 시간은 서버가 미리 확정하지 않는다.** `/auto/build`는 "몇 번 컷 소속"만 넘기고,
파이프라인이 컷 누적 위치(`placements`)로 시간을 계산한다.
- ⚠ **카드 시간 계산은 무음 제거 *뒤*다.** 자막은 `_remap_caps()`로 시간을 옮기지만
(발화 시각을 따라가야 하니까), 카드는 **구간 자체**를 `_remap_placements()`로 옮기고
그 안에서 나눈다. 카드 시간을 압축 전에 만들어 자막처럼 재매핑하면
`cards_fixed`(정확히 3초)가 2초로 눌리고, quota는 원본 길이로 잡혀 있어서
3초 하한도 사라진다(무음이 절반인 24초 컷 → 8장 → 압축 12초 → 장당 1.5초).
이 순서를 뒤집지 말 것.
- 렌더: comment 트랙에 **scale 0.89 / X 0**, 세로는 **윗변이 영상 바로 아래**에 오도록 카드마다 계산(§9).
- 출처: 사용자가 h-lab(https://h-lab.tolag.shop/comment-cards)에서 실제 유튜브 댓글을
카드 PNG로 저장해 폴더에 넣음. (향후: h-lab API 연동해 완전 자동화 아이디어 있음)

View File

@ -280,6 +280,10 @@ yt-dlp --version
| 드래프트 열면 영상이 흰띠·댓글 위로 삐짐 | CapCut 편집 중 render_index 꼬임 | 최신 `draft.py`는 오버레이/제목 트랙 자동 잠금으로 예방 |
| **클립을 옮긴 뒤 확대하면 그 클립만 템플릿 밖으로 삐짐** | CapCut이 옮긴 클립에 렌더순서를 새로(맨 위로) 매김 — 잠금으로 못 막음 | **캡컷에서 그 프로젝트를 닫고** → 웹 UI 하단 **"🩹 레이어 수리"** → 드래프트 선택 → 실행 → 캡컷에서 다시 열기 |
| **영상 중간에 초록 화면이 몇 초 나옴** | yt-dlp가 키프레임 아닌 위치에서 잘라 참조 프레임이 없음 | 최신 `youtube.py`가 컷마다 자동 검증→재다운로드→정밀 재컷. 로그에 `🩹` 표시. 예전에 받은 영상은 다시 만들어야 함 |
| 컷 섹션에 🤖 배지가 하나도 없다 (⭐만 있거나 비어 있음) | Gemini 추천 실패 — 한도 초과(429)·타임아웃·응답 파싱. 컷 섹션 자체는 타임스탬프만으로 그대로 뜬다 | 분석 로그(진행 화면 아래)에 `⚠️ ID n AI 추천 실패 — 타임스탬프만으로 배정했습니다` 가 있는지 확인. 한도 초과면 잠시 뒤 재분석. `프롬프트/댓글_추천.md`를 고쳤다면 `{cuts}` `{comments}` `{k}` 가 남아 있는지 확인 |
| 컷 섹션이 아예 안 보이고 ⭐ + 두 덩어리만 뜬다 | 편집안에 컷 정보가 없어(`hl.cuts` 없음) 예전 화면으로 폴백 | 편집안 생성(Step 3)이 실패한 ID다. 로그에서 그 ID의 실패 사유 확인. 이 화면에서 고른 카드는 컷 배치 없이 전체 균등으로 깔린다 |
| 댓글이 엉뚱한 장면에 뜬다 | 컷 소속 없이 전체 균등 배치로 깔림(위 폴백 화면) 또는 추천 자체가 안 맞음 | 위 두 줄 확인. 컷 섹션이 보인다면 그 컷 섹션 안에서 카드를 갈아끼우면 그 컷 위로 옮겨진다 |
| "컷별 댓글 추천"에서 오래 멈춰 보인다 | 하이라이트마다 Gemini 를 순차로 부른다(429 회피). 최악 5×90초 | **새로고침하지 말 것** — 분석이 통째로 날아간다. 로그에 `ID n 컷별 댓글 추천 중… (i/N)` 이 올라오면 정상 진행 중 |
| 콘솔에 한글 깨짐 | Windows cp949 | 표시만 깨짐. 로직·결과와 무관 |
---

View File

@ -9,7 +9,7 @@ from __future__ import annotations
import json
import re
import urllib.request
from typing import Dict, List
from typing import Dict, List, Optional
H_LAB = "https://h-lab.tolag.shop"
@ -97,3 +97,32 @@ def top_liked(comments: List[Dict], exclude: set, n: int = 20) -> List[int]:
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]]
def match_slots(comments: List[Dict], start: float, total: float, n: int,
exclude=None) -> List[Optional[int]]:
"""구간을 n개 슬롯으로 잘라 슬롯마다 댓글 1개씩 배정 — 통짜 모드용.
슬롯 k = 원본 [start + k·total/n, start + (k+1)·total/n).
통짜 모드는 구간을 그대로 이어붙여 **타임라인 시각 = 원본 시각**이라 계산이 성립한다
( 있는 모드는 순서를 섞어 재배치하므로 쓰면 된다 내용 기반 추천을 ).
슬롯은 시간대를 언급한 댓글 좋아요 최다 1. 없으면 None.
댓글이 슬롯에 겹치지 않는다. exclude(idx 집합) 처음부터 제외.
Returns: 길이 n 리스트 (n<=0 이거나 total<=0 이면 리스트)
"""
if n <= 0 or total <= 0:
return []
used = set(exclude or ())
step = total / n
out: List[Optional[int]] = []
for k in range(n):
s = start + k * step
pick = None
for idx in match_ranges(comments, [(s, s + step)]):
if idx not in used:
pick = idx
used.add(idx)
break
out.append(pick)
return out

View File

@ -35,24 +35,17 @@ _ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_DOWNLOADS = os.path.join(_ROOT, ".downloads")
def _load_comment_cards(folder: str, dur: float, min_sec: float = 3.0,
fixed: bool = False):
"""지정 폴더의 이미지를 영상 길이에 맞춰 하단에 균등 배치.
def _card_paths(folder: str):
"""댓글 카드 이미지 경로 목록. 배치 방식과 무관하게 '순서'만 정한다.
장수: n = min(카드 , max(1, floor(dur/min_sec))) 카드 하나가 min_sec 밑으로
내려가지 않는 상한. 배치 간격은 항상 dur/n 카드가 모자라도 끝까지 없이
채워지고(간격이 3 이상으로 늘어남), 넘치면 초과분은 버린다.
fixed=True 늘리지 않고 min_sec 고정 모자라면 뒤는 비운다
(편집하면서 부분삭제를 많이 하는 경우 카드가 늘어나 있으면 타이밍이 꼬여서).
정렬 규칙:
- 모든 파일명이 숫자로 시작하면 숫자순(1, 2, 10 )
- 아니면 저장(생성) 순서 = 다운로드한 순서
folder 비었거나 없으면 [] (댓글 카드 없음).
Returns: [(start, end, path)]
folder 비었거나 없으면 [].
"""
import re
folder = (folder or "").strip().strip('"')
if not folder or not os.path.isdir(folder) or dur <= 0:
if not folder or not os.path.isdir(folder):
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")]
@ -67,7 +60,26 @@ def _load_comment_cards(folder: str, dur: float, min_sec: float = 3.0,
imgs.sort(key=_leadnum) # 파일명 숫자순
else:
imgs.sort(key=lambda p: os.path.getctime(p)) # 저장(생성) 순서
return imgs
def _load_comment_cards(folder: str, dur: float, min_sec: float = 3.0,
fixed: bool = False):
"""지정 폴더의 이미지를 영상 길이에 맞춰 하단에 균등 배치.
장수: n = min(카드 , max(1, floor(dur/min_sec))) 카드 하나가 min_sec 밑으로
내려가지 않는 상한. 배치 간격은 항상 dur/n 카드가 모자라도 끝까지 없이
채워지고(간격이 3 이상으로 늘어남), 넘치면 초과분은 버린다.
fixed=True 늘리지 않고 min_sec 고정 모자라면 뒤는 비운다
(편집하면서 부분삭제를 많이 하는 경우 카드가 늘어나 있으면 타이밍이 꼬여서).
함수는 컷을 모른다. 자동 탭처럼 '어느 컷에 붙일지' 정해진 경우엔
`_cards_by_cut()` 쓴다. 폴더 지정 경로(파일/유튜브 구간 ) 계속 함수를 쓴다.
Returns: [(start, end, path)]
"""
imgs = _card_paths(folder)
if not imgs or dur <= 0:
return []
n = min(len(imgs), max(1, int(dur // min_sec)))
if fixed: # 3초 고정 — 뒤가 비어도 늘리지 않음
return [(i * min_sec, min((i + 1) * min_sec, dur), path)
@ -77,6 +89,49 @@ def _load_comment_cards(folder: str, dur: float, min_sec: float = 3.0,
for i, path in enumerate(imgs[:n])]
def _cards_by_cut(paths, card_cuts, placements, dur: float, *,
min_sec: float = 3.0, fixed: bool = False):
"""카드를 '소속 컷 구간 안'에 배치 — 자동 탭 전용.
card_cuts[i] = i번째 카드가 속한 인덱스. 안에서는 균등 분할
(fixed=True min_sec 고정, 뒷부분은 비움).
placements/dur **최종(무음 제거 반영) 타임라인** 기준이어야 한다.
압축 시간으로 계산해 놓고 나중에 재매핑하면 fixed=True "정확히 3초" 약속이
깨지고(3초짜리가 2초로 눌린다) 3 하한도 사라진다.
컷당 장수 상한 = max(1, floor(컷길이/min_sec)) `_load_comment_cards` 같은 규칙.
초과분은 버린다(카드가 1초씩 번쩍이느니 빼는 낫다).
단위인가: 전체 균등 배치(`_load_comment_cards`) 경계를 몰라서
3 얘기하는 댓글이 7 위에 뜬다. 안에서 계산하면 컷이 차도
**다음 카드가 앞으로 밀리지 않는다.**
Returns: [(start, end, path)] 시간순
"""
if not paths or not card_cuts or not placements or dur <= 0:
return []
groups: dict = {}
for i, ci in enumerate(card_cuts[:len(paths)]):
if isinstance(ci, int) and 0 <= ci < len(placements):
groups.setdefault(ci, []).append(i)
out = []
for ci, idxs in groups.items():
p0, p1 = placements[ci]
p1 = min(p1, dur)
if p1 <= p0:
continue # 영상 실제 길이 밖 컷은 버린다
n = min(len(idxs), max(1, int((p1 - p0) // min_sec))) # 카드당 min_sec 하한
idxs = idxs[:n]
step = min_sec if fixed else (p1 - p0) / n
for k, i in enumerate(idxs):
s = p0 + k * step
if s >= p1:
break # fixed 로 컷을 넘치면 뒤는 비운다
out.append((s, min(s + step, p1), paths[i]))
out.sort(key=lambda t: t[0])
return out
def _elapsed_kept(keep_sorted, t: float) -> float:
"""원본 시간 t 가 무음 제거 후(압축) 타임라인에서 놓이는 위치 = t 이전의 보존 길이 합."""
tot = 0.0
@ -98,6 +153,20 @@ def _remap_caps(caps, keep_sorted):
return out
def _remap_placements(placements, keep_sorted):
"""컷 구간 [(p0,p1)] 을 무음 제거 타임라인으로 재매핑.
자막(`_remap_caps`) 목적이 다르다. 자막은 '그 말이 나오는 시각' 따라가면 되지만
카드는 ** 구간 자체** 옮겨야 한다. 카드 시간을 압축 전에 만들어 두고 나중에
자막처럼 재매핑하면 `cards_fixed`(정확히 3) 2초로 눌리고, 3 하한도 사라진다.
구간을 먼저 옮기고 안에서 나누면 지켜진다.
통째로 무음이라 사라진 컷은 (x, x) 구간이 되고 `_cards_by_cut` 건너뛴다.
"""
return [(_elapsed_kept(keep_sorted, p0), _elapsed_kept(keep_sorted, p1))
for p0, p1 in placements]
def _safe_name(name: str) -> str:
s = "".join(c for c in name if c.isalnum() or c in (" ", "_", "-", ".")).strip()
return s[:60] or "video"
@ -311,6 +380,7 @@ async def process_paste(
scene_split: bool = False,
comments_dir: str = "",
cards_fixed: bool = False,
card_cuts: Optional[List[int]] = None,
bg_white: bool = False,
remove_silence: bool = False,
asr_bottom: bool = False,
@ -370,6 +440,7 @@ async def process_paste(
eff_caps = [(p0, min(p1, dur), ef) for (p0, p1), (_, _, _, ef) in zip(placements, cuts)
if ef and p0 < dur]
card_places = placements # 카드 배치 기준 구간(무음 제거 시 압축본으로 교체)
video_clips = [(0.0, dur)] # 병합본 = 한 덩어리(재컷 없음)
timeline_dur = dur
@ -384,6 +455,8 @@ async def process_paste(
keep = sorted(keep)
bottom_caps = _remap_caps(bottom_caps, keep)
eff_caps = _remap_caps(eff_caps, keep)
# 카드는 '시간'이 아니라 '컷 구간'을 옮긴다 — 아래에서 이 구간 안에 나눠 넣는다.
card_places = _remap_placements(placements, keep)
video_clips = keep
timeline_dur = sum(e - s for s, e in keep)
yield {"type": "log",
@ -437,10 +510,24 @@ async def process_paste(
video_clips = split_clips_at_scenes(video_clips, scenes)
yield {"type": "log", "msg": f"장면전환 {len(scenes)}곳 → 세그먼트 {len(video_clips)}"}
# 댓글 카드(선택): 지정 폴더의 1,2,3… 을 3초씩 하단에 순서대로
cards = _load_comment_cards(comments_dir, timeline_dur, fixed=cards_fixed)
# 댓글 카드(자동 탭): '몇 번 컷 소속'만 받아 여기서 시간을 만든다.
# 서버가 시간을 확정하면 무음 제거 때 자막만 당겨지고 카드는 혼자 어긋난다.
# ⚠ 무음 제거 **뒤**에 계산한다 — 압축 전 시간으로 만들어 재매핑하면
# cards_fixed(정확히 3초)가 눌리고 카드당 3초 하한이 사라진다.
cut_cards = _cards_by_cut(_card_paths(comments_dir), card_cuts or [],
card_places, timeline_dur, fixed=cards_fixed)
# 컷 소속이 지정됐으면 그 컷 구간 안, 아니면 폴더 전체 균등 배치
cards = cut_cards or _load_comment_cards(comments_dir, timeline_dur, fixed=cards_fixed)
if cards:
yield {"type": "log", "msg": f"댓글 카드 {len(cards)}개 하단 삽입(3초 간격)"}
msg = f"댓글 카드 {len(cards)}개 하단 삽입"
if cut_cards:
# 무음 제거로 컷이 짧아지면 quotas_for(원본 길이)가 고른 장수보다
# _cards_by_cut(압축 후 길이)의 상한이 작아질 수 있다 — 그 차이를 조용히 삼키지 않는다.
dropped = len(card_cuts or []) - len(cut_cards)
msg += "(컷별 배치" + (f", {dropped}장은 컷 길이가 짧아 제외)" if dropped > 0 else ")")
else:
msg += "(전체 균등)"
yield {"type": "log", "msg": msg}
path = await asyncio.to_thread(
lambda: build_bg_template_draft(

View File

@ -15,6 +15,7 @@ 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")
RECOMMEND_PATH = os.path.join(PROMPT_DIR, "댓글_추천.md")
# 오팔 Step 1 원문 이식. 원문 JSON 예시의 오타(start_time 중복, end_time 누락)는 수정.
DEFAULT_STEP1 = """당신은 유튜브 영상에서 쇼츠(Shorts)로 제작했을 때 가장 터질 만한 구간을 찾아내는 '바이럴 분석가'입니다. 제공된 영상을 분석해 아래 규칙에 따라 5개의 하이라이트 후보 구간을 선정하세요.
@ -36,6 +37,24 @@ DEFAULT_STEP1 = """당신은 유튜브 영상에서 쇼츠(Shorts)로 제작했
}
"""
# 컷별 댓글 추천(자동 탭). {cuts} {comments} {k} 를 채워 쓴다.
DEFAULT_RECOMMEND = """너는 숏폼 편집자다. 컷마다 화면 아래에 띄울 유튜브 댓글을 고른다.
규칙:
- 자막의 **내용과 의미가 통하는** 댓글만 고른다. 억지로 채우지 마라.
- 어울리는 없으면 컷은 picks 배열로 둔다. 채로 두는 엉뚱한 것보다 낫다.
- 댓글은 컷에만 쓴다. 여러 컷에 어울리면 가장 맞는 하나에만 넣어라.
- 아래 목록에 있는 댓글 번호만 쓴다. 없는 번호를 지어내지 마라.
- 컷마다 최대 {k}개까지.
목록:
{cuts}
댓글 후보 (번호. 👍좋아요 / 본문):
{comments}
컷마다 {{"cut": 컷번호, "picks": [댓글번호]}} JSON 배열로만 출력."""
DEFAULT_CONFIG = {
"model": "gemini-3.5-flash",
"model_step1": "",
@ -60,6 +79,16 @@ def load_step3() -> str:
return f.read()
def load_recommend() -> str:
"""컷별 댓글 추천 프롬프트. 없으면 기본값으로 파일을 만들고 읽는다(메모장 수정 가능)."""
if not os.path.isfile(RECOMMEND_PATH):
os.makedirs(PROMPT_DIR, exist_ok=True)
with open(RECOMMEND_PATH, "w", encoding="utf-8") as f:
f.write(DEFAULT_RECOMMEND)
with open(RECOMMEND_PATH, encoding="utf-8") as f:
return f.read()
def load_config() -> dict:
"""설정 로드. 파일이 없거나 깨졌으면 기본값(파일은 첫 save 때 생성)."""
cfg = dict(DEFAULT_CONFIG)

237
capcut_agent/recommend.py Normal file
View File

@ -0,0 +1,237 @@
"""컷별 댓글 추천 — 자동 탭 검토 화면용.
별도 모듈인가: 배정 규칙(타임스탬프 우선·중복 제거·모드 분기) 댓글 수집
(`comments.py`)과도, HTTP 처리(`server/app.py`)와도 책임이 다르다. 순수 함수로 떼어놔야
Gemini 없이 단위 검증이 된다.
설계 근거는 docs/superpowers/specs/2026-08-04-컷별-댓글-추천-design.md.
"""
from __future__ import annotations
import json
import urllib.request
from typing import Dict, List, Optional
from . import prompts
from .comments import MAX_TIMES, match_ranges, match_slots, top_liked
from .correct import _gemini_key
# 카드 1장이 차지하는 기준 시간(초). pipeline._load_comment_cards(min_sec) 과 같은 값.
CARD_SEC = 3.0
def quotas_for(cuts) -> List[int]:
"""컷별 카드 장수 — max(1, floor(컷길이 / CARD_SEC)). 짧은 컷도 최소 1장.
round 아니라 floor 이유: `pipeline._cards_by_cut` 컷당 장수 상한(무음 제거
길이 기준)·`pipeline._load_comment_cards` 규칙과 같은 공식이어야, 여기서 고른 카드가
빌드 단계에서 말없이 잘려나가지 않는다(round 쓰면 5 컷처럼 상한보다 1 골라
조용히 버려지는 경우가 생겼다).
"""
return [max(1, int((c["end"] - c["start"]) // CARD_SEC)) for c in cuts]
def build_cut_picks(cuts, comments, ai_picks: Optional[Dict[int, List[int]]],
quotas) -> List[List[dict]]:
"""컷별 추천 확정 — 타임스탬프 우선, 남는 자리만 AI, 전 컷 통틀어 중복 금지.
타임스탬프가 AI보다 먼저인 이유: 컷의 **원본 구간** 집어 언급한 댓글은
근거가 확실하다. 추측(AI) 이기게 이유가 없다.
중복은 컷이 가져간다( 컷은 다음 후보로 밀린다).
ai_picks: {컷인덱스: [댓글idx ]} Gemini 실패 None/{} 어느 쪽을 넘겨도
타임스탬프만으로 채운다(`ai_pick_cuts` 실패를 None 으로 알린다).
Returns: 컷별 [{"idx": int, "why": "ts"|"ai"}]
"""
ai_picks = ai_picks or {}
valid = {c["idx"] for c in comments}
used: set = set()
out: List[List[dict]] = []
for i, cut in enumerate(cuts):
quota = quotas[i] if i < len(quotas) else 0
picks: List[dict] = []
for idx in match_ranges(comments, [(cut["start"], cut["end"])]):
if len(picks) >= quota:
break
if idx not in used:
picks.append({"idx": idx, "why": "ts"})
used.add(idx)
for idx in (ai_picks.get(i) or []):
if len(picks) >= quota:
break
if idx in valid and idx not in used:
picks.append({"idx": idx, "why": "ai"})
used.add(idx)
out.append(picks)
return out
# Gemini에 넘길 댓글 후보 수. 더 늘려도 채택률이 안 오르고 토큰만 먹는다.
AI_CANDIDATES = 150
TEXT_CAP = 200 # 댓글 본문 절단 길이
AI_MODEL = "gemini-2.5-flash" # correct.py 와 같은 무료 티어 모델
_ENDPOINT = ("https://generativelanguage.googleapis.com/v1beta/models/"
"{model}:generateContent?key={key}")
_SCHEMA = {"type": "array", "items": {"type": "object", "properties": {
"cut": {"type": "integer"},
"picks": {"type": "array", "items": {"type": "integer"}}},
"required": ["cut", "picks"]}}
def _candidate_lines(comments, limit: int = AI_CANDIDATES) -> str:
"""Gemini에 줄 댓글 후보 — 목차 댓글 제외, 좋아요순 상위 limit, 본문 절단."""
usable = [c for c in comments if len(c.get("times") or []) <= MAX_TIMES]
usable.sort(key=lambda c: -c.get("likeCount", 0))
out = []
for c in usable[:limit]:
text = " ".join(str(c.get("text") or "").split())[:TEXT_CAP]
out.append(f"{c['idx']}. 👍{c.get('likeCount', 0)} / {text}")
return "\n".join(out)
def _cut_lines(cuts) -> str:
"""컷 목록 — 번호·길이·자막. 자막이 추천의 유일한 근거다."""
out = []
for i, c in enumerate(cuts):
bottom = " ".join(str(c.get("bottom") or "").split()) or "(자막 없음)"
effect = " ".join(str(c.get("effect") or "").split())
line = f"{i}. ({c['end'] - c['start']:.1f}초) {bottom}"
if effect:
line += f" [효과자막: {effect}]"
out.append(line)
return "\n".join(out)
def _parse_ai(raw: str) -> Dict[int, List[int]]:
"""Gemini 응답(문자열) → {컷인덱스: [댓글idx …]}. 형식이 어긋나면 {}.
여기서는 '깨진 응답' '아무것도 안 고름' {} . 둘을 구분해야 하는
호출부(`ai_pick_cuts`) json.loads 직접 하고 `_rows_to_picks()` 쓴다.
"""
try:
data = json.loads(raw)
except (json.JSONDecodeError, TypeError):
return {}
if not isinstance(data, list):
return {}
return _rows_to_picks(data)
def _rows_to_picks(data: list) -> Dict[int, List[int]]:
"""파싱된 배열 → {컷인덱스: [댓글idx …]}. 형식이 어긋난 항목은 조용히 버린다."""
out: Dict[int, List[int]] = {}
for row in data:
if not isinstance(row, dict):
continue
cut, picks = row.get("cut"), row.get("picks")
if not isinstance(cut, int) or isinstance(cut, bool):
continue
if not isinstance(picks, list):
continue
idxs = [p for p in picks if isinstance(p, int) and not isinstance(p, bool)]
if idxs:
out[cut] = idxs
return out
def ai_pick_cuts(cuts, comments, quotas, *, model: str = AI_MODEL,
key=None, timeout: float = 90.0) -> Optional[Dict[int, List[int]]]:
"""컷 자막으로 컷별 추천을 받는다. **예외를 올리지 않는다** — 호출부가 폴백한다.
Returns:
- dict 성공( dict 성공: "어울리는 게 없다" 정상 답이다). 키가 없어도
{} 없이 쓰는 것도 정상 사용이라 실패로 치지 않는다.
- None **실패**. HTTP·타임아웃·응답 파싱·프롬프트 치환 오류.
화면에 "AI 추천 실패" 경고를 띄우려면 둘을 구분해야 한다
(실패해도 타임스탬프 배정은 그대로 돌아가서 겉보기엔 멀쩡하다).
plan.py._call() parts[0] 영상 fileData 항상 들어가 재사용할 없다.
correct.py 텍스트 전용 호출 패턴(responseSchema + 실패 안전 반환) 따른다.
"""
key = key if key is not None else _gemini_key()
if not key or not cuts or not comments:
return {} # 부를 이유가 없다 — 실패가 아니다
k = max(quotas) + 2 if quotas else 3 # 갈아끼울 여유분
try:
prompt = prompts.load_recommend().format(
cuts=_cut_lines(cuts), comments=_candidate_lines(comments), k=k)
except (KeyError, IndexError, ValueError, OSError): # 사용자가 프롬프트를 깨뜨린 경우
return None
body = {
"contents": [{"parts": [{"text": prompt}]}],
"generationConfig": {"temperature": 0.3,
"responseMimeType": "application/json",
"responseSchema": _SCHEMA},
}
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"))
raw = data["candidates"][0]["content"]["parts"][0]["text"]
except (KeyError, IndexError, json.JSONDecodeError, ValueError,
OSError): # URLError, TimeoutError are OSError subclasses; ValueError covers UnicodeDecodeError
return None # 429 포함 — 폴백은 호출부 몫
try:
rows = json.loads(raw)
except (json.JSONDecodeError, TypeError):
return None # 응답이 JSON 이 아니다 = 실패
if not isinstance(rows, list):
return None # 스키마와 다른 모양 = 실패
return _rows_to_picks(rows)
def is_whole(cuts) -> bool:
"""통짜 하이라이트인가 — 컷 1개 + 자막 없음(app._whole_hl 이 만드는 모양).
통짜는 자막이 없어 내용 추천의 근거가 없다. 대신 타임라인 시각 = 원본 시각이라
시각으로 정확히 맞출 있다(스펙 §4.1).
"""
return len(cuts) == 1 and not (cuts[0].get("bottom") or "").strip()
def _whole_picks(cut, comments, n: int) -> List[dict]:
"""통짜 — 슬롯마다 그 시간대 언급 댓글, 빈 슬롯은 좋아요 상위로 메움."""
slots = match_slots(comments, cut["start"], cut["end"] - cut["start"], n)
used = {i for i in slots if i is not None}
no_ts = [c for c in comments if not c.get("times")]
fill = iter(top_liked(no_ts, used, n))
out: List[dict] = []
for s in slots:
if s is not None:
out.append({"idx": s, "why": "ts"})
continue
nxt = next(fill, None)
if nxt is not None:
out.append({"idx": nxt, "why": "like"})
return out
def build_highlight_cuts(hl, comments, *, key=None):
"""하이라이트 하나 → (cuts[], need, ai_failed). 모드는 컷 모양으로 판별한다.
Returns: ([{"i","sec","bottom","quota","picks"}], need, ai_failed)
need = Σ quota 기존 int(total//3) 대체한다( 경계에 맞추는 쪽이 맞다).
ai_failed = Gemini 호출이 **실패**했는가(429·타임아웃·파싱). 통짜 모드는 Gemini
부르므로 항상 False. 호출부는 값으로만 경고를 띄운다 실패해도 cuts
타임스탬프만으로 채워져 비지 않으므로, cuts 비었는지로는 실패를 없다.
Gemini 실패는 여기서 흡수된다(ai_pick_cuts None 준다) 예외를 올리지 않는다.
"""
cuts = (hl.get("paste") or {}).get("cuts") or []
if not cuts:
return [], 0, False
quotas = quotas_for(cuts)
ai_failed = False
if is_whole(cuts):
picks = [_whole_picks(cuts[0], comments, quotas[0])]
else:
ai = ai_pick_cuts(cuts, comments, quotas, key=key)
ai_failed = ai is None
picks = build_cut_picks(cuts, comments, ai, quotas)
out = [{"i": i, "sec": round(c["end"] - c["start"], 1),
"bottom": c.get("bottom") or "", "quota": quotas[i], "picks": picks[i]}
for i, c in enumerate(cuts)]
return out, sum(quotas), ai_failed

View File

@ -17,8 +17,10 @@
- **코드를 고쳤으면 `캡컷_에이전트_구간합치기.bat`을 반드시 재시작한다.** uvicorn hot-reload가
없어 검은 창을 닫고 다시 실행해야 반영된다. "안 돼요"의 가장 흔한 원인.
- **이 프로젝트는 git 저장소가 아니다.** 커밋 단계가 없다. 각 태스크는 인라인 assert 통과 +
구문/임포트 검증으로 끝낸다.
- **git 저장소는 2026-08-04에 초기화됐다**(`bf1b387`, 브랜치 `feat/cut-comment-recommend`).
각 태스크는 인라인 assert 통과 + 구문/임포트 검증 후 **커밋**하고 끝낸다.
커밋 메시지는 한국어 한 줄 요약 + 왜 그렇게 했는지. `.gemini_key`·`.downloads/`·
`.comments/``.gitignore`로 제외돼 있으니 `git add -A`를 써도 안전하다.
- **테스트 프레임워크가 없다.** pytest를 도입하지 않는다. 검증은 `python - <<'PY' … PY`
인라인 assert 스크립트로 한다(기존 계획 문서와 같은 방식).
- Windows 콘솔은 cp949라 한글 print가 깨진다. 검증 스크립트 첫 줄에
@ -158,7 +160,7 @@ Gemini를 호출하지 않으므로 단독 검증이 된다.
- Consumes: `comments.match_ranges`
- Produces:
- `CARD_SEC = 3.0`
- `quotas_for(cuts) -> List[int]` — 컷별 카드 장수 `max(1, round(len/CARD_SEC))`
- `quotas_for(cuts) -> List[int]` — 컷별 카드 장수 `max(1, floor(len/CARD_SEC))`
- `build_cut_picks(cuts, comments, ai_picks, quotas) -> List[List[dict]]`
— 컷별 `[{"idx": int, "why": "ts"|"ai"}]`
@ -170,9 +172,9 @@ from capcut_agent.recommend import quotas_for, build_cut_picks
cuts = [{"start": 100.0, "end": 105.0, "bottom": "아까랑 완전 스타일이 달라"},
{"start": 200.0, "end": 206.0, "bottom": "우리에게 익숙한 평냥은"}]
assert quotas_for(cuts) == [2, 2], quotas_for(cuts)
assert quotas_for(cuts) == [1, 2], quotas_for(cuts) # floor(5/3)=1, floor(6/3)=2
assert quotas_for([{"start": 0.0, "end": 1.0, "bottom": ""}]) == [1] # 짧아도 최소 1
assert quotas_for([{"start": 0.0, "end": 10.0, "bottom": ""}]) == [3] # round(10/3)=3
assert quotas_for([{"start": 0.0, "end": 10.0, "bottom": ""}]) == [3] # floor(10/3)=3
cs = [
{"idx": 0, "likeCount": 50, "times": [101.0]}, # 컷0 언급
@ -224,8 +226,8 @@ CARD_SEC = 3.0
def quotas_for(cuts) -> List[int]:
"""컷별 카드 장수 — max(1, round(컷길이 / CARD_SEC)). 짧은 컷도 최소 1장."""
return [max(1, round((c["end"] - c["start"]) / CARD_SEC)) for c in cuts]
"""컷별 카드 장수 — max(1, floor(컷길이 / CARD_SEC)). 짧은 컷도 최소 1장."""
return [max(1, int((c["end"] - c["start"]) // CARD_SEC)) for c in cuts]
def build_cut_picks(cuts, comments, ai_picks: Dict[int, List[int]],
@ -500,8 +502,8 @@ def ai_pick_cuts(cuts, comments, quotas, *, model: str = AI_MODEL,
try:
prompt = prompts.load_recommend().format(
cuts=_cut_lines(cuts), comments=_candidate_lines(comments), k=k)
except (KeyError, IndexError, OSError): # 사용자가 프롬프트를 깨뜨린 경우
return {}
except (KeyError, IndexError, ValueError, OSError): # 사용자가 프롬프트를 깨뜨린 경우
return {} # ValueError: str.format 은 중괄호가 깨지면 이걸 던진다
body = {
"contents": [{"parts": [{"text": prompt}]}],
"generationConfig": {"temperature": 0.3,
@ -516,8 +518,9 @@ def ai_pick_cuts(cuts, comments, quotas, *, model: str = AI_MODEL,
with urllib.request.urlopen(req, timeout=timeout) as resp:
data = json.loads(resp.read().decode("utf-8"))
raw = data["candidates"][0]["content"]["parts"][0]["text"]
except (urllib.error.URLError, KeyError, IndexError, TimeoutError,
json.JSONDecodeError, OSError):
# URLError·TimeoutError 는 OSError 서브클래스라 따로 안 적는다.
# ValueError 는 .decode("utf-8") 의 UnicodeDecodeError 를 덮는다(OSError 가 아니다).
except (KeyError, IndexError, json.JSONDecodeError, ValueError, OSError):
return {} # 429 포함 — 폴백은 호출부 몫
return _parse_ai(raw)
```
@ -581,14 +584,14 @@ multi = {"paste": {"cuts": [
{"start": 100.0, "end": 105.0, "bottom": "가", "effect": ""},
{"start": 200.0, "end": 206.0, "bottom": "나", "effect": ""}]}}
cuts3, need3 = recommend.build_highlight_cuts(multi, cs, key="")
assert need3 == 4, need3
assert cuts3[0]["picks"] == [{"idx": 1, "why": "ts"}, {"idx": 0, "why": "ts"}]
assert need3 == 3, need3 # floor(5/3)=1 + floor(6/3)=2
assert cuts3[0]["picks"] == [{"idx": 1, "why": "ts"}] # quota 1 → 좋아요 높은 idx1 만
assert cuts3[1]["picks"] == []
assert cuts3[1]["bottom"] == "나" and cuts3[1]["sec"] == 6.0
# 댓글이 아예 없어도 터지지 않는다
c4, n4 = recommend.build_highlight_cuts(multi, [], key="")
assert n4 == 4 and all(x["picks"] == [] for x in c4)
assert n4 == 3 and all(x["picks"] == [] for x in c4)
print("Task5 OK")
```
@ -1120,36 +1123,56 @@ function toggle(hlId,idx,ci){
// 영상 편집안(컷 목록·JSON) — 선택 요약 바 위에, 기본 접힘
box.insertBefore(cutsSection(hl),$("#hlsel-"+hl.id));
const WHY={ts:"⭐",ai:"🤖",like:""};
const cand=(hl.candidates||[]).filter(i=>byIdx[i]!==undefined);
if(hl.cuts){
// 컷별 추천 — 추천분 먼저, 그 컷 시간대 언급 댓글을 뒤에 붙여 갈아끼울 수 있게
// 컷별 추천 — 컷마다 두 섹션(둘 다 ci=cu.i). 독립 섹션을 두지 않는다(아래 설명).
for(const cu of hl.cuts){
const rec=(cu.picks||[]).map(p=>p.idx).filter(i=>byIdx[i]!==undefined);
const why=(cu.picks||[]).map(p=>WHY[p.why]||"").join("");
const rest=(hl.matched||[]).filter(i=>byIdx[i]!==undefined&&!rec.includes(i));
// '나머지'는 그 컷 시간대를 언급한 댓글만 — matched 전체를 컷마다 복제하면
// DOM 이 컷 수만큼 부풀고 같은 댓글이 여러 섹션에 중복 표시된다.
const cs=(hl.paste.cuts||[])[cu.i]||{start:0,end:-1};
const rest=(hl.matched||[]).filter(i=>
byIdx[i]!==undefined&&!rec.includes(i)&&
(byIdx[i].times||[]).some(t=>t>=cs.start&&t<=cs.end));
const label="컷 "+(cu.i+1)+" · "+cu.sec+"초 · 카드 "+cu.quota+"장"+
(cu.bottom?" — "+cu.bottom:"")+(why?" "+why:"");
box.appendChild(cardSection(label,rec.concat(rest),hl.id,
Math.max(CARD_PAGE,rec.length),cu.i));
// 부족분 채우기 — 컷마다 반복되므로 초기 렌더는 6장(CUT_FILL_PAGE)만
if(cand.length)
box.appendChild(cardSection(
"↳ 좋아요 상위에서 채우기 "+cand.length+"장 (이 컷에 넣기)",
cand,hl.id,CUT_FILL_PAGE,cu.i));
}
}else{
// 폴백 — 추천 실패 시 기존 화면 그대로
// 폴백 — hl.cuts 가 없으면 예전 화면 그대로: ⭐ + 독립
const m=(hl.matched||[]).filter(i=>byIdx[i]!==undefined);
box.appendChild(cardSection(
"⭐ 이 구간을 언급한 댓글 "+m.length+"장 (좋아요순, 자동 선택)",
m,hl.id,Math.max(CARD_PAGE,sel[hl.id].length)));
}
// 좋아요 상위 후보 (전체 — 30장씩 더보기)
const cand=(hl.candidates||[]).filter(i=>byIdx[i]!==undefined);
if(cand.length){
box.appendChild(cardSection(
" 좋아요 상위 후보 "+cand.length+"장 (부족분 클릭)",
cand,hl.id,CARD_PAGE));
}
}
```
`cardSection``ci``undefined`인 섹션( 후보, 폴백)에서 카드를 누르면 `toggle`
컷 없이 동작한다. 이 경우 `sortSel`이 그 카드를 맨 뒤로 보낸다(`??999`) — 마지막 컷 뒤에
붙는다는 뜻이고, 배치상 문제 없다.
**왜 독립 섹션을 없앴나** (초안의 판단이 틀렸다).
초안은 "`ci`가 `undefined``sortSel`이 그 카드를 맨 뒤로 보내니(`??999`) 마지막 컷
**뒤에** 붙는다 — 배치상 문제 없다"고 적었다. **틀렸다.** `_cards_by_cut`은 카드를 컷
뒤에 붙이지 않고 **컷 안에서 균등 분할**한다. `card_cuts`에 마지막 컷 인덱스가 들어가면
그 컷 구간 안으로 전부 밀려 들어가, 4초짜리 마지막 컷에 5장이면 0.8초씩 번쩍인다.
게다가 이건 드문 경로가 아니다. 추천 프롬프트가 "억지로 채우지 마라, 어울리는 게 없으면
빈 배열로 둬라"라고 지시하므로 **quota 미달이 기본 동작**이고, 스펙 §7은 그 미달을
"사람이 ➕에서 채운다"로 풀게 했다 — 즉 주 워크플로다.
그래서 `hl.cuts`가 있는 경로에서는 ➕를 **컷 섹션 안으로 흡수**한다(위 코드).
`hl.cuts`가 없는 폴백 경로만 예전처럼 ⭐ + 독립 ➕를 쓴다 — 거기선 애초에 컷 소속 개념이
없고 `card_cuts`도 안 보낸다.
- [ ] **Step 4: 빌드 전송에 `card_cuts` 추가**
@ -1166,18 +1189,24 @@ function toggle(hlId,idx,ci){
for(const idx of sel[hl.id]){
const w=wrapOf(hl.id,idx);
if(!w) continue;
const ci=cmap[idx];
// 컷 소속을 모르는 카드는 건너뛴다 — 엉뚱한 컷에 몰아넣지 않는다
if(hl.cuts&&ci===undefined) continue;
boardSet(hl.id,null,"카드 캡처 중… "+(++n)+"/"+sel[hl.id].length,"active");
try{
const blob=await captureCard(w);
// ⚠ append 와 push 는 반드시 쌍으로
fd.append("cards",blob,String(n).padStart(3,"0")+".png");
sentCuts.push(cmap[idx]??((hl.paste.cuts||[]).length-1));
sentCuts.push(ci);
}catch(e){n--;boardSet(hl.id,null,"카드 1장 캡처 실패(건너뜀)","active");}
}
if(hl.cuts) fd.append("card_cuts",JSON.stringify(sentCuts));
```
캡처 실패 시 `n--`를 하는 이유: 파일명 번호(`001.png`)와 `sentCuts` 인덱스가 어긋나면
카드가 엉뚱한 컷에 붙는다.
카드가 엉뚱한 컷에 붙는다. 같은 이유로 `fd.append("cards", …)``sentCuts.push(…)`
**반드시 쌍으로** 돈다 — 하나만 실행되는 경로를 만들면 그 뒤 카드가 전부 한 칸씩 밀린다.
컷 소속을 모르는 카드(`ci === undefined`)는 마지막 컷으로 폴백하지 않고 **둘 다 건너뛴다**.
- [ ] **Step 5: 문법 검증**
@ -1264,7 +1293,7 @@ for tr in j["tracks"]:
| §4.1 모드 분기 | Task 5 (`is_whole`) |
| §4.2 컷 있는 모드 — Gemini + 타임스탬프 우선 + 중복 제거 | Task 2, 4, 5 |
| §4.2 통짜 — 슬롯 배정 + 좋아요 메움 | Task 1, 5 |
| §4.3 `quota = max(1, round(len/3))`, `need = Σ quota` | Task 2, 5 |
| §4.3 `quota = max(1, floor(len/3))`, `need = Σ quota` | Task 2, 5 |
| §4.4 `card_cuts` 전달 + `placements` 계산 + 무음 재매핑 | Task 7, 8, 9 |
| §5 데이터 스키마 (`cuts[]`, `picks[].why`, `card_cuts`) | Task 5, 6, 8 |
| §6 변경 파일 6개 | Task 1~9 |

View File

@ -0,0 +1,40 @@
# 댓글 선택 화면 검색(필터) — 설계
날짜: 2026-08-04 · 대상: `server/static/auto.js`, `server/static/index.html` (서버 변경 없음)
## 목적
댓글 선택 화면(자동 탭 ID 패널 + 유튜브 구간 탭 댓글 매칭)에서 수백~수천 장 카드 중
원하는 댓글을 텍스트로 찾을 수 있게 한다.
## 동작 (필터 방식)
- 각 패널 상단(선택 요약 바 아래, 카드 섹션 위)에 검색창 1개 + 지우기 ✕.
- 입력(디바운스 150ms) 시 그 패널의 **모든 카드 섹션**(컷별 ⭐추천 / ➕채우기)이
자기 **전체 목록**에서 일치하는 카드만 다시 그린다 — "더보기"로 안 펼친 카드도
일치하면 나온다. 일치가 많으면 기존처럼 30장 + 더보기.
- 일치 기준: 댓글 본문(HTML→평문) + 작성자 이름, 대소문자 무시.
댓글마다 검색용 평문을 1회 만들어 캐시(`c._st`).
- 일치 0장 섹션은 헤더째 숨김. 검색어를 지우면 원래 화면 복원.
- 선택 로직 불변: 카드는 원래 컷 섹션 소속이므로 클릭하면 그 컷에 들어간다.
선택 링·사용중 배지·"선택한 것만 보기"는 기존 그대로(검색과 AND).
## 캡처 누락 방지 (중요)
빌드/캡처는 `wrapOf()`로 DOM에서 카드를 찾고 **없으면 조용히 건너뛴다**
(`buildAll`, `window.ytCC.capture`). 검색 필터로 선택 카드가 DOM에서 빠진 채
빌드하면 카드가 누락되므로:
1. 섹션은 검색 전 렌더 수(`baseShown`)를 기억하고, 검색 해제 시 그만큼 복원한다.
(선택은 렌더된 카드에서만 가능 → 복원하면 선택 카드가 항상 DOM에 있음)
2. `buildAll` 시작 시(각 ID)와 `ytCC.capture` 시작 시 해당 패널의 검색을
강제 초기화(`clearSearch(hlId)`)한 뒤 캡처한다.
## 구현 구조
- `SECS = {hlId: [setFilter,…]}` — 패널별 섹션 필터 레지스트리.
`searchBar(hlId)`가 패널 생성 때 배열을 초기화하고, `cardSection()`이 자기
`setFilter(q)`를 등록한다(재렌더: grid 비우고 필터된 목록으로 다시 페이징).
- `cardSection` 소폭 리팩터: `more.remove()``display:none` 토글
(필터 해제 후 다시 필요할 수 있으므로).
- CSS: `.ccsearch` 몇 줄 (index.html).

View File

@ -91,8 +91,11 @@
### 4.3 카드 장수
컷별 `quota_i = max(1, round(len_i / 3))`. 하이라이트의 `need = Σ quota_i`
컷별 `quota_i = max(1, floor(len_i / 3))`. 하이라이트의 `need = Σ quota_i`
(기존 `int(total // 3)`을 대체 — 값이 ±1 다를 수 있으나 컷 경계에 맞추는 쪽이 맞다).
round가 아니라 floor인 이유: 빌드 단계(`_cards_by_cut`)의 컷당 장수 상한도 같은
`max(1, floor(컷길이/3))`이라, round를 쓰면 5초 컷처럼 여기서 2장을 고르고도
빌드에서 상한(1장)에 걸려 1장이 말없이 버려지는 불일치가 생긴다.
### 4.4 배치 — 시간은 파이프라인이 계산한다
@ -109,7 +112,12 @@ card_cuts = [0, 0, 1, 1, 1, 2, …] # 카드 순서대로, 값 = 컷 인덱스
`process_paste()`가 이미 갖고 있는 `placements[(p0, p1)]`로 시간을 만든다:
- 컷 `i`에 카드 `m`장 → `p0`부터 `(p1-p0)/m` 간격 (`cards_fixed`면 3초 고정, 컷 뒷부분은 비움)
- 무음 제거가 켜져 있으면 자막과 **같은 `_remap_caps()`** 를 카드에도 적용
- 무음 제거가 켜져 있으면 **`_remap_placements()`로 컷 구간을 먼저 압축 타임라인으로 옮긴 뒤**
그 구간 안에서 카드를 나눈다. ⚠ 자막처럼 카드 **시간**을 `_remap_caps()`로 옮기면 안 된다 —
`cards_fixed`(3초 고정)가 압축되면서 깨지고(3.0초 → 2.0초), 3초 하한도 사라진다.
옮기는 것은 시간이 아니라 **컷 구간**이다. 그래서 카드 계산은 무음 제거 **뒤**에 온다.
- 컷당 장수는 압축 후 길이 기준 `max(1, floor((p1-p0)/3))`로 캡한다(§4.3과 같은 공식).
무음 제거로 컷이 짧아져 캡에 걸려 버려진 카드가 있으면 진행 로그에 장수를 남긴다.
- `card_cuts`가 없으면(폴더 지정 경로 등) 기존 `_load_comment_cards()` 그대로
컷 하나가 추천 부족으로 덜 차도 다음 컷 카드가 앞으로 밀리지 않는다 — 지금 방식의 약점이 여기서 사라진다.

View File

@ -12,6 +12,7 @@ import json
import os
import shutil
import subprocess
import time
import urllib.parse
import urllib.request
@ -23,6 +24,7 @@ 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 recommend
from capcut_agent import plan as autoplan
from capcut_agent import prompts as prompt_store
from capcut_agent.correct import GeminiQuotaError, has_gemini_key
@ -223,6 +225,7 @@ async def stream(job_id: str) -> StreamingResponse:
asr_bottom=job.get("asr_bottom", False),
name_suffix=job.get("name_suffix", ""),
cards_fixed=job.get("cards_fixed", False),
card_cuts=job.get("card_cuts") or None,
)
else:
stream_iter = process_bg_template(
@ -352,6 +355,7 @@ async def auto_stream(aid: str) -> StreamingResponse:
yield _sse({"type": "manifest", "steps": [
{"id": "parse", "label": "오팔 JSON 파싱"},
{"id": "comments", "label": "댓글 수집 (h-lab)"},
{"id": "recommend", "label": "컷별 댓글 추천"},
]})
yield _sse({"type": "step", "id": "parse", "status": "start"})
import re as _re
@ -425,6 +429,7 @@ async def auto_stream(aid: str) -> StreamingResponse:
yield _sse({"type": "manifest", "steps": [
{"id": "parse", "label": "구간 JSON 파싱"},
{"id": "comments", "label": "댓글 수집 (h-lab)"},
{"id": "recommend", "label": "컷별 댓글 추천"},
]})
yield _sse({"type": "step", "id": "parse", "status": "start"})
try:
@ -442,6 +447,7 @@ async def auto_stream(aid: str) -> StreamingResponse:
if mode == "full":
steps.append({"id": "step3", "label": "편집안 생성 (Gemini, 구간별 동시)"})
steps.append({"id": "comments", "label": "댓글 수집 (h-lab)"})
steps.append({"id": "recommend", "label": "컷별 댓글 추천"})
yield _sse({"type": "manifest", "steps": steps})
# 댓글은 URL을 이미 아니까 Step1 과 동시에 수집
com_task = asyncio.create_task(asyncio.to_thread(hlab.fetch_comments, url))
@ -523,13 +529,41 @@ async def auto_stream(aid: str) -> StreamingResponse:
# 댓글 매칭 — 전체 전송, 브라우저가 '더보기'로 30장씩 나눠 그린다.
# 후보(candidates)는 분:초 언급이 아예 없는 댓글만 — 타임스탬프 댓글은
# 자기 구간의 ⭐에서 잡히므로, 다른 구간 얘기하는 댓글이 섞이지 않게.
#
# 하이라이트마다 Gemini 를 순차로 부른다(동시 호출은 429 를 부른다 — Step 3 도 같은 이유로
# 시차 재시도를 쓴다). 최악 5×90초라 진행 표시가 없으면 사용자가 멈춘 줄 알고 새로고침해
# 분석이 통째로 날아간다 → 스텝 + 하이라이트별 로그를 반드시 흘린다.
no_ts = [c for c in comments if not c["times"]]
for h in highlights:
if "paste" not in h:
continue
targets = [h for h in highlights if "paste" in h]
yield _sse({"type": "step", "id": "recommend", "status": "start"})
t_rec = time.perf_counter()
for n, h in enumerate(targets, 1):
matched = hlab.match_window(comments, h["start"], h["end"])
h["matched"] = matched
h["candidates"] = hlab.top_liked(no_ts, set(matched), len(no_ts))
yield _sse({"type": "log",
"msg": f"ID {h.get('id')} 컷별 댓글 추천 중… "
f"({n}/{len(targets)}, 컷 "
f"{len(h['paste'].get('cuts') or [])}개)"})
# 컷별 추천 — 실패해도 위의 matched/candidates 로 화면이 돌아간다.
# Gemini 호출이 섞여 있어 블로킹이므로 스레드로 뺀다.
try:
cuts, need, ai_failed = await asyncio.to_thread(
recommend.build_highlight_cuts, h, comments)
if cuts:
h["cuts"], h["need"] = cuts, need
if ai_failed:
# cuts 는 타임스탬프만으로 채워져 비지 않는다 — 이 신호가 없으면
# 429·타임아웃이 조용히 삼켜져 아무도 모른다.
warnings.append(
f"ID {h.get('id')} AI 추천 실패 — 타임스탬프만으로 배정했습니다")
except Exception as exc: # noqa: BLE001 — 추천 실패가 생성을 막으면 안 된다
warnings.append(
f"ID {h.get('id')} 컷별 추천 실패 — 기존 방식으로 표시 "
f"({type(exc).__name__}: {exc})")
yield _sse({"type": "step", "id": "recommend", "status": "done",
"elapsed": round(time.perf_counter() - t_rec, 1),
"detail": f"{len(targets)}개 하이라이트"})
yield _sse({"type": "result", "highlights": highlights,
"comments": comments, "warnings": warnings})
@ -598,6 +632,7 @@ async def auto_build(
remove_silence: str = Form(""),
asr_bottom: str = Form(""),
cards_fixed: str = Form(""),
card_cuts: str = Form(""),
) -> JSONResponse:
"""자동 탭 빌드 — 붙여넣기 스키마 JSON + 카드 PNG들 → 기존 paste job.
@ -622,6 +657,14 @@ async def auto_build(
body = await f.read()
with open(os.path.join(cdir, f"{i:03d}.png"), "wb") as out:
out.write(body)
# 카드별 소속 컷 — 값이 깨져도 빌드를 막지 않는다(없으면 기존 전체 균등 배치)
cut_map: list[int] = []
try:
parsed = json.loads(card_cuts) if card_cuts.strip() else []
if isinstance(parsed, list):
cut_map = [int(v) for v in parsed if isinstance(v, int) and not isinstance(v, bool)]
except (json.JSONDecodeError, ValueError, TypeError):
cut_map = []
JOBS[h] = {
"paste": payload, "draft_name": f"auto_{h}",
"video_scale": _scale(video_scale), "flip": _truthy(flip),
@ -629,6 +672,7 @@ async def auto_build(
"bg_white": _truthy(bg_white), "remove_silence": _truthy(remove_silence),
"asr_bottom": _truthy(asr_bottom), "name_suffix": safe_tag,
"cards_fixed": _truthy(cards_fixed),
"card_cuts": cut_map,
}
return JSONResponse({"job_id": h, "cuts": len(payload["cuts"]),
"cards": len(cards)})

View File

@ -27,7 +27,7 @@ function hlById(id){
}
/* ── 카드 DOM (h-lab renderCards 구조와 동일) ── */
function cardEl(c,hlId){
function cardEl(c,hlId,ci){
const wrap=document.createElement("div");
wrap.className="ccwrap"; wrap.dataset.cidx=c.idx;
const card=document.createElement("div");
@ -51,7 +51,7 @@ function cardEl(c,hlId){
const badge=document.createElement("div");
badge.className="ccbadge"; badge.textContent="✓";
wrap.appendChild(card); wrap.appendChild(badge);
wrap.addEventListener("click",()=>toggle(hlId,c.idx));
wrap.addEventListener("click",()=>toggle(hlId,c.idx,ci));
return wrap;
}
@ -61,7 +61,19 @@ function wrapOf(hlId,idx){
/* ── 카드 섹션 — 30장씩 렌더 + 더보기 (전체 댓글을 받아도 DOM은 점진 생성) ── */
const CARD_PAGE=30;
function cardSection(label,list,hlId,firstBatch){
// '좋아요 상위에서 채우기' 섹션은 컷마다 반복된다 — 초기 렌더를 줄여 DOM 폭증을 막는다
const CUT_FILL_PAGE=6;
/* 검색용 평문(본문+작성자, 소문자) — 댓글마다 1회 계산해 캐시 */
function searchStr(c){
if(c._st===undefined){
const tmp=document.createElement("div");
tmp.innerHTML=String(c.text||"").replace(/<br\s*\/?>/gi,"\n");
c._st=((tmp.textContent||"")+" "+(c.authorName||"")).toLowerCase();
}
return c._st;
}
const SECS={}; // hlId → [setFilter,…] (searchBar 가 패널 생성 때 초기화)
function cardSection(label,list,hlId,firstBatch,ci){
const sec=document.createElement("div");sec.className="hlsec";
const head=document.createElement("div");head.textContent=label;
sec.appendChild(head);
@ -69,32 +81,89 @@ function cardSection(label,list,hlId,firstBatch){
sec.appendChild(grid);
const more=document.createElement("button");
more.type="button";more.className="ghost cc-more";
let shown=0;
const first=firstBatch||CARD_PAGE;
let cur=list,shown=0,baseShown=0; // baseShown: 검색 전 렌더 수 — 해제 시 복원해야
// 선택 카드가 DOM에 남는다(캡처는 wrapOf 로 DOM에서 찾음)
function render(batch){
const end=Math.min(list.length,shown+batch);
const end=Math.min(cur.length,shown+batch);
for(;shown<end;shown++){
const c=byIdx[list[shown]];
const c=byIdx[cur[shown]];
if(!c) continue;
const w=cardEl(c,hlId);
const w=cardEl(c,hlId,ci);
if(sel[hlId]&&sel[hlId].includes(c.idx)) w.classList.add("sel");
grid.appendChild(w);
}
if(shown>=list.length) more.remove();
else more.textContent="더보기 ▾ (남은 "+(list.length-shown).toLocaleString()+"장)";
if(cur===list) baseShown=Math.max(baseShown,shown);
if(shown>=cur.length) more.style.display="none";
else{
more.style.display="";
more.textContent="더보기 ▾ (남은 "+(cur.length-shown).toLocaleString()+"장)";
}
applyUsedMarks(hlId); // 새로 그린 카드에도 사용중 표시
}
more.addEventListener("click",()=>render(CARD_PAGE));
sec.appendChild(more);
render(firstBatch||CARD_PAGE);
render(first);
(SECS[hlId]=SECS[hlId]||[]).push(function setFilter(q){
grid.innerHTML="";shown=0;
if(!q){cur=list;sec.style.display="";render(Math.max(baseShown,first));return;}
cur=list.filter(i=>{const c=byIdx[i];return c&&searchStr(c).includes(q);});
sec.style.display=cur.length?"":"none";
render(CARD_PAGE);
});
return sec;
}
function toggle(hlId,idx){
/* ── 패널 검색창 — 입력하면 그 패널의 모든 섹션이 일치 카드만 다시 그림 ── */
function searchBar(hlId){
SECS[hlId]=[]; // 패널 재생성 시 이전 섹션 필터 폐기
const bar=document.createElement("div");
bar.className="ccsearch";
bar.innerHTML='<input type="search" placeholder="🔍 댓글 검색 (내용·작성자)">'+
'<button type="button" class="ghost ccsx" title="검색 지우기">✕</button>';
const inp=bar.querySelector("input");
let t=null;
function apply(){
const q=inp.value.trim().toLowerCase();
(SECS[hlId]||[]).forEach(f=>f(q));
}
inp.addEventListener("input",()=>{clearTimeout(t);t=setTimeout(apply,150);});
bar.querySelector(".ccsx").addEventListener("click",()=>{inp.value="";apply();inp.focus();});
bar._clear=()=>{if(inp.value){inp.value="";clearTimeout(t);apply();}};
return bar;
}
/* 빌드/캡처 전 필수 — 검색으로 카드가 DOM에서 빠진 채 캡처하면 조용히 누락된다 */
function clearSearch(hlId){
const box=$("#hlbox-"+hlId);
const bar=box&&box.querySelector(".ccsearch");
if(bar&&bar._clear) bar._clear();
}
/* ' ' .
selCut[hlId][idx] = 인덱스. sel[hlId] 항상 순서로 정렬해 둔다
(업로드 순서 = 배치 순서라서). */
const selCut={};
function sortSel(hlId){
const m=selCut[hlId]||{};
sel[hlId].sort((a,b)=>(m[a]??999)-(m[b]??999));
}
function cutFull(hl,ci){
const m=selCut[hl.id]||{};
const q=(hl.cuts&&hl.cuts[ci])?hl.cuts[ci].quota:hl.need;
return sel[hl.id].filter(i=>m[i]===ci).length>=q;
}
function toggle(hlId,idx,ci){
const hl=hlById(hlId), list=sel[hlId];
selCut[hlId]=selCut[hlId]||{};
const at=list.indexOf(idx);
if(at>=0) list.splice(at,1);
if(at>=0){ list.splice(at,1); delete selCut[hlId][idx]; }
else{
if(list.length>=hl.need) return; // 필요 장수 초과 선택 방지
if(hl.cuts&&ci!==undefined){
if(cutFull(hl,ci)) return; // 그 컷 장수 초과 방지
selCut[hlId][idx]=ci;
}else{
if(list.length>=hl.need) return; // 폴백(컷 정보 없음)
}
list.push(idx);
sortSel(hlId);
}
refreshSel(hlId);
}
@ -435,7 +504,17 @@ function onResult(ev){
// 패널
const box=document.createElement("div");
box.className="hlbox";box.id="hlbox-"+hl.id;
// 자동 선택: 컷별 추천을 컷 순서대로. hl.cuts 가 없으면 기존 방식으로 폴백.
sel[hl.id]=[]; selCut[hl.id]={};
if(hl.cuts){
for(const cu of hl.cuts)
for(const p of (cu.picks||[]))
if(byIdx[p.idx]!==undefined&&!sel[hl.id].includes(p.idx)){
sel[hl.id].push(p.idx); selCut[hl.id][p.idx]=cu.i;
}
}else{
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>"+
@ -458,18 +537,47 @@ function onResult(ev){
box.dataset.titles=JSON.stringify(opts);
// 영상 편집안(컷 목록·JSON) — 선택 요약 바 위에, 기본 접힘
box.insertBefore(cutsSection(hl),$("#hlsel-"+hl.id));
// ⭐ 구간 언급 댓글 (전체 — 30장씩 더보기)
box.appendChild(searchBar(hl.id)); // 카드 섹션들 위 — cardSection 보다 먼저 만들어야 SECS 초기화됨
const WHY={ts:"⭐",ai:"🤖",like:""};
const cand=(hl.candidates||[]).filter(i=>byIdx[i]!==undefined);
if(hl.cuts){
/* . :
거기서 고른 카드는 소속(ci) 없어 빌드 곳이 없다. 그런데 AI 프롬프트가
"억지로 채우지 마라"라고 지시하므로 quota 미달이 기본이고, 미달분을 에서
채우는 워크플로다. _cards_by_cut '뒤' 아니라 '안에' 균등 분할하니
소속 없는 카드를 컷에 몰면 4 컷에 5장이 들어가 0.8초씩 번쩍인다. */
for(const cu of hl.cuts){
const rec=(cu.picks||[]).map(p=>p.idx).filter(i=>byIdx[i]!==undefined);
const why=(cu.picks||[]).map(p=>WHY[p.why]||"").join("");
/* '' ** **.
hl.matched 전체를 컷마다 복제하면 12 × 30 = 360장씩 부풀고,
같은 댓글이 여러 섹션에 중복돼 한쪽을 고르면 쌍둥이까지 하이라이트된다. */
const cs=(hl.paste.cuts||[])[cu.i]||{start:0,end:-1};
const rest=(hl.matched||[]).filter(i=>
byIdx[i]!==undefined&&!rec.includes(i)&&
(byIdx[i].times||[]).some(t=>t>=cs.start&&t<=cs.end));
const label="컷 "+(cu.i+1)+" · "+cu.sec+"초 · 카드 "+cu.quota+"장"+
(cu.bottom?" — "+cu.bottom:"")+(why?" "+why:"");
box.appendChild(cardSection(label,rec.concat(rest),hl.id,
Math.max(CARD_PAGE,rec.length),cu.i));
// 부족분 채우기 — 컷마다 반복되므로 처음엔 조금만 그리고 나머지는 더보기로
if(cand.length)
box.appendChild(cardSection(
"↳ 좋아요 상위에서 채우기 "+cand.length+"장 (이 컷에 넣기)",
cand,hl.id,CUT_FILL_PAGE,cu.i));
}
}else{
// 폴백 — hl.cuts 가 없으면(컷 정보 없음) 예전 화면 그대로: ⭐ + 독립
const m=(hl.matched||[]).filter(i=>byIdx[i]!==undefined);
box.appendChild(cardSection(
"⭐ 이 구간을 언급한 댓글 "+m.length+"장 (좋아요순, 자동 선택)",
m,hl.id,Math.max(CARD_PAGE,sel[hl.id].length))); // 자동 선택분은 첫 화면에 다 보이게
// 좋아요 상위 후보 (전체 — 30장씩 더보기)
const cand=(hl.candidates||[]).filter(i=>byIdx[i]!==undefined);
m,hl.id,Math.max(CARD_PAGE,sel[hl.id].length)));
if(cand.length){
box.appendChild(cardSection(
" 좋아요 상위 후보 "+cand.length+"장 (부족분 클릭)",
cand,hl.id,CARD_PAGE));
}
}
R.appendChild(box);
refreshSel(hl.id);
}
@ -529,6 +637,7 @@ async function buildAll(){
let ok=0,fail=0;
for(const hl of hls){
showId(hl.id); // 캡처는 보이는 상태에서
clearSearch(hl.id); // 검색 중이면 카드가 DOM에 없어 캡처가 누락됨
await nextFrame();
boardSet(hl.id,"🔄 진행","", "active");
try{
@ -551,16 +660,25 @@ async function buildAll(){
fd.append("remove_silence",$("#rmsilence").checked?"1":"0");
fd.append("asr_bottom",$("#asrbottom").checked?"1":"0");
fd.append("cards_fixed",($("#autoCardsFixed")&&$("#autoCardsFixed").checked)?"1":"0");
let n=0;
const cmap=selCut[hl.id]||{};
let n=0; const sentCuts=[];
for(const idx of sel[hl.id]){
const w=wrapOf(hl.id,idx);
if(!w) continue;
const ci=cmap[idx];
/* . ,
_cards_by_cut '안에' 균등 분할하므로 그러면 4초짜리 마지막 컷에
여러 장이 몰려 1 미만으로 번쩍인다. 이제 모든 카드가 ci 갖지만 방어로 남긴다. */
if(hl.cuts&&ci===undefined) continue;
boardSet(hl.id,null,"카드 캡처 중… "+(++n)+"/"+sel[hl.id].length,"active");
try{
const blob=await captureCard(w);
// ⚠ append 와 push 는 반드시 쌍으로 — 하나만 돌면 카드가 통째로 엉뚱한 컷에 붙는다
fd.append("cards",blob,String(n).padStart(3,"0")+".png");
}catch(e){boardSet(hl.id,null,"카드 1장 캡처 실패(건너뜀)","active");}
sentCuts.push(ci);
}catch(e){n--;boardSet(hl.id,null,"카드 1장 캡처 실패(건너뜀)","active");}
}
if(hl.cuts) fd.append("card_cuts",JSON.stringify(sentCuts));
boardSet(hl.id,null,"빌드 요청 중…","active");
const res=await(await fetch("/auto/build",{method:"POST",body:fd})).json();
if(res.error) throw new Error(res.error);
@ -647,6 +765,7 @@ async function ytMatch(){
'<input type="checkbox" id="ytccFixed" checked style="accent-color:var(--accent);width:14px;height:14px;">'+
' 카드 3초 고정 — 모자라도 늘리지 않고 뒤는 비움 (부분삭제 편집용)</label>'+
'<div class="selbar" id="hlsel-yt"></div>';
box.appendChild(searchBar("yt"));
box.appendChild(cardSection(
"⭐ 구간을 언급한 댓글 "+matched.length+"장 (좋아요순, 자동 선택)",
matched,"yt",Math.max(CARD_PAGE,sel["yt"].length)));
@ -664,6 +783,7 @@ async function ytMatch(){
window.ytCC={
active:function(){return !!(YT_HL&&sel["yt"]&&sel["yt"].length);},
capture:async function(){
clearSearch("yt"); // 검색 중이면 카드가 DOM에 없어 캡처가 누락됨
const out=[];
for(const idx of sel["yt"]){
const w=wrapOf("yt",idx);

View File

@ -251,6 +251,12 @@
.selonly-label{display:inline-flex;align-items:center;gap:6px;color:var(--muted2);font-size:12px;
cursor:pointer;margin-left:auto;padding:6px;}
.hlbox.selonly .cardsec .ccwrap:not(.sel){display:none;}
/* 댓글 검색창 */
.ccsearch{display:flex;gap:6px;align-items:center;margin-top:10px;}
.ccsearch input{flex:1;max-width:420px;min-height:40px;padding:8px 12px;background:var(--surf);
border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:13px;}
.ccsearch input:focus{outline:none;border-color:var(--accent);}
.ccsearch .ccsx{width:auto;min-height:40px;padding:6px 13px;flex:none;margin:0;}
/* 카드 그리드 */
.cardsec{display:flex;flex-wrap:wrap;gap:12px;margin-top:8px;}
.cardsec .ccwrap{margin:0;}

View File

@ -0,0 +1,16 @@
너는 숏폼 편집자다. 컷마다 화면 아래에 띄울 유튜브 댓글을 고른다.
규칙:
- 컷 자막의 **내용과 의미가 통하는** 댓글만 고른다. 억지로 채우지 마라.
- 어울리는 게 없으면 그 컷은 picks 를 빈 배열로 둔다. 빈 채로 두는 게 엉뚱한 것보다 낫다.
- 한 댓글은 한 컷에만 쓴다. 여러 컷에 어울리면 가장 잘 맞는 컷 하나에만 넣어라.
- 아래 목록에 있는 댓글 번호만 쓴다. 없는 번호를 지어내지 마라.
- 컷마다 최대 {k}개까지.
컷 목록:
{cuts}
댓글 후보 (번호. 👍좋아요 / 본문):
{comments}
각 컷마다 {{"cut": 컷번호, "picks": [댓글번호…]}} 를 JSON 배열로만 출력.