diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d81f564..a5f5ffa 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 연동해 완전 자동화 아이디어 있음) diff --git a/SETUP.md b/SETUP.md index eea49b3..c1c3a1b 100644 --- a/SETUP.md +++ b/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 | 표시만 깨짐. 로직·결과와 무관 | --- diff --git a/capcut_agent/comments.py b/capcut_agent/comments.py index 569932c..5a382f9 100644 --- a/capcut_agent/comments.py +++ b/capcut_agent/comments.py @@ -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 diff --git a/capcut_agent/pipeline.py b/capcut_agent/pipeline.py index 4bfdeb0..3d87a9e 100644 --- a/capcut_agent/pipeline.py +++ b/capcut_agent/pipeline.py @@ -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( diff --git a/capcut_agent/prompts.py b/capcut_agent/prompts.py index 25c6b11..3688a0b 100644 --- a/capcut_agent/prompts.py +++ b/capcut_agent/prompts.py @@ -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) diff --git a/capcut_agent/recommend.py b/capcut_agent/recommend.py new file mode 100644 index 0000000..ac48e9b --- /dev/null +++ b/capcut_agent/recommend.py @@ -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 diff --git a/docs/superpowers/plans/2026-08-04-컷별-댓글-추천.md b/docs/superpowers/plans/2026-08-04-컷별-댓글-추천.md index 3d0a7eb..e5c6522 100644 --- a/docs/superpowers/plans/2026-08-04-컷별-댓글-추천.md +++ b/docs/superpowers/plans/2026-08-04-컷별-댓글-추천.md @@ -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 | diff --git a/docs/superpowers/specs/2026-08-04-댓글검색-design.md b/docs/superpowers/specs/2026-08-04-댓글검색-design.md new file mode 100644 index 0000000..70e5edf --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-댓글검색-design.md @@ -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). diff --git a/docs/superpowers/specs/2026-08-04-컷별-댓글-추천-design.md b/docs/superpowers/specs/2026-08-04-컷별-댓글-추천-design.md index fd319a5..9a89ede 100644 --- a/docs/superpowers/specs/2026-08-04-컷별-댓글-추천-design.md +++ b/docs/superpowers/specs/2026-08-04-컷별-댓글-추천-design.md @@ -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()` 그대로 컷 하나가 추천 부족으로 덜 차도 다음 컷 카드가 앞으로 밀리지 않는다 — 지금 방식의 약점이 여기서 사라진다. diff --git a/server/app.py b/server/app.py index e5d8fe6..dfe2f3b 100644 --- a/server/app.py +++ b/server/app.py @@ -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)}) diff --git a/server/static/auto.js b/server/static/auto.js index a7eadc1..c3780ae 100644 --- a/server/static/auto.js +++ b/server/static/auto.js @@ -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(//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=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=''+ + ''; + 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="

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

"+ @@ -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(){ ''+ ' 카드 3초 고정 — 모자라도 늘리지 않고 뒤는 비움 (부분삭제 편집용)'+ '
'; + 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); diff --git a/server/static/index.html b/server/static/index.html index b4a1857..17f668e 100644 --- a/server/static/index.html +++ b/server/static/index.html @@ -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;} diff --git a/프롬프트/댓글_추천.md b/프롬프트/댓글_추천.md new file mode 100644 index 0000000..5636ea7 --- /dev/null +++ b/프롬프트/댓글_추천.md @@ -0,0 +1,16 @@ +너는 숏폼 편집자다. 컷마다 화면 아래에 띄울 유튜브 댓글을 고른다. + +규칙: +- 컷 자막의 **내용과 의미가 통하는** 댓글만 고른다. 억지로 채우지 마라. +- 어울리는 게 없으면 그 컷은 picks 를 빈 배열로 둔다. 빈 채로 두는 게 엉뚱한 것보다 낫다. +- 한 댓글은 한 컷에만 쓴다. 여러 컷에 어울리면 가장 잘 맞는 컷 하나에만 넣어라. +- 아래 목록에 있는 댓글 번호만 쓴다. 없는 번호를 지어내지 마라. +- 컷마다 최대 {k}개까지. + +컷 목록: +{cuts} + +댓글 후보 (번호. 👍좋아요 / 본문): +{comments} + +각 컷마다 {{"cut": 컷번호, "picks": [댓글번호…]}} 를 JSON 배열로만 출력. \ No newline at end of file