merge: 컷별 댓글 추천 + 댓글 검색
자동 탭에 컷별 댓글 추천·컷 위치 배치를 넣고, 댓글 선택 화면에 검색을 추가한다. 태스크별 리뷰 10회 + 브랜치 전체 리뷰(+수정 웨이브)를 거쳤다. 실사용 검증(실제 영상 → CapCut에서 열어 확인)은 아직 안 했다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
3d3f6f6b14
@ -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 연동해 완전 자동화 아이디어 있음)
|
||||
|
||||
4
SETUP.md
4
SETUP.md
@ -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 | 표시만 깨짐. 로직·결과와 무관 |
|
||||
|
||||
---
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
237
capcut_agent/recommend.py
Normal 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
|
||||
@ -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));
|
||||
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 |
|
||||
|
||||
40
docs/superpowers/specs/2026-08-04-댓글검색-design.md
Normal file
40
docs/superpowers/specs/2026-08-04-댓글검색-design.md
Normal 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).
|
||||
@ -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()` 그대로
|
||||
|
||||
컷 하나가 추천 부족으로 덜 차도 다음 컷 카드가 앞으로 밀리지 않는다 — 지금 방식의 약점이 여기서 사라진다.
|
||||
|
||||
@ -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)})
|
||||
|
||||
@ -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;
|
||||
sel[hl.id]=(hl.matched||[]).slice(0,hl.need).filter(i=>byIdx[i]!==undefined);
|
||||
// 자동 선택: 컷별 추천을 컷 순서대로. 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,17 +537,46 @@ function onResult(ev){
|
||||
box.dataset.titles=JSON.stringify(opts);
|
||||
// 영상 편집안(컷 목록·JSON) — 선택 요약 바 위에, 기본 접힘
|
||||
box.insertBefore(cutsSection(hl),$("#hlsel-"+hl.id));
|
||||
// ⭐ 구간 언급 댓글 (전체 — 30장씩 더보기)
|
||||
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장씩 더보기)
|
||||
box.appendChild(searchBar(hl.id)); // 카드 섹션들 위 — cardSection 보다 먼저 만들어야 SECS 초기화됨
|
||||
const WHY={ts:"⭐",ai:"🤖",like:"➕"};
|
||||
const cand=(hl.candidates||[]).filter(i=>byIdx[i]!==undefined);
|
||||
if(cand.length){
|
||||
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(
|
||||
"➕ 좋아요 상위 후보 "+cand.length+"장 (부족분 클릭)",
|
||||
cand,hl.id,CARD_PAGE));
|
||||
"⭐ 이 구간을 언급한 댓글 "+m.length+"장 (좋아요순, 자동 선택)",
|
||||
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);
|
||||
|
||||
@ -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;}
|
||||
|
||||
16
프롬프트/댓글_추천.md
Normal file
16
프롬프트/댓글_추천.md
Normal file
@ -0,0 +1,16 @@
|
||||
너는 숏폼 편집자다. 컷마다 화면 아래에 띄울 유튜브 댓글을 고른다.
|
||||
|
||||
규칙:
|
||||
- 컷 자막의 **내용과 의미가 통하는** 댓글만 고른다. 억지로 채우지 마라.
|
||||
- 어울리는 게 없으면 그 컷은 picks 를 빈 배열로 둔다. 빈 채로 두는 게 엉뚱한 것보다 낫다.
|
||||
- 한 댓글은 한 컷에만 쓴다. 여러 컷에 어울리면 가장 잘 맞는 컷 하나에만 넣어라.
|
||||
- 아래 목록에 있는 댓글 번호만 쓴다. 없는 번호를 지어내지 마라.
|
||||
- 컷마다 최대 {k}개까지.
|
||||
|
||||
컷 목록:
|
||||
{cuts}
|
||||
|
||||
댓글 후보 (번호. 👍좋아요 / 본문):
|
||||
{comments}
|
||||
|
||||
각 컷마다 {{"cut": 컷번호, "picks": [댓글번호…]}} 를 JSON 배열로만 출력.
|
||||
Loading…
Reference in New Issue
Block a user