From 2d12bd0fac40d40ac2ef02533c32dd7ef3d7fd89 Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Tue, 4 Aug 2026 11:36:40 +0900 Subject: [PATCH 01/21] =?UTF-8?q?docs:=20=EA=B3=84=ED=9A=8D=EC=9D=98=20Glo?= =?UTF-8?q?bal=20Constraints=20=EB=A5=BC=20git=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EB=B0=98=EC=98=81=ED=95=B4=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit git 저장소가 아니라 커밋 단계가 없다고 적혀 있었으나, 저장소를 만들었으므로 태스크마다 커밋하도록 바꾼다. Co-Authored-By: Claude Opus 5 (1M context) --- docs/superpowers/plans/2026-08-04-컷별-댓글-추천.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-08-04-컷별-댓글-추천.md b/docs/superpowers/plans/2026-08-04-컷별-댓글-추천.md index 3d0a7eb..2b34a2b 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가 깨진다. 검증 스크립트 첫 줄에 From 47362a814e47b67b87a51545c8c87d547bbafc03 Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Tue, 4 Aug 2026 11:39:39 +0900 Subject: [PATCH 02/21] =?UTF-8?q?match=5Fslots()=20=ED=95=A8=EC=88=98=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=E2=80=94=20=EA=B5=AC=EA=B0=84=20=EC=8A=AC?= =?UTF-8?q?=EB=A1=AF=20=EB=B0=B0=EC=A0=95=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 통짜 모드에서 영상 구간을 n개 슬롯으로 나누고 각 슬롯에 댓글을 배정하는 순수 함수를 추가했다. - match_ranges()를 활용해 슬롯 시간대와 매칭되는 댓글을 찾음 - 좋아요 높은 댓글부터 슬롯에 배정 (슬롯당 최대 1개) - 한 댓글이 여러 슬롯에 중복되지 않도록 관리 - exclude 집합으로 사전에 제외된 댓글 처리 Co-Authored-By: Claude Opus 5 (1M context) --- capcut_agent/comments.py | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) 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 From df8bc7baec3d75ccf61666fea680121cdb8986a7 Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Tue, 4 Aug 2026 11:44:59 +0900 Subject: [PATCH 03/21] =?UTF-8?q?=EC=BB=B7=EB=B3=84=20=EB=8C=93=EA=B8=80?= =?UTF-8?q?=20=EB=B0=B0=EC=A0=95=20=EC=88=9C=EC=88=98=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=E2=80=94=20quotas=5Ffor,=20build=5Fcut=5Fpicks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배정 규칙(타임스탐프 우선·중복 제거·Gemini 폴백)을 댓글 수집·HTTP 처리와 분리해 순수 함수로 구현. 기준 카드 길이 3초(CARD_SEC)로 컷별 쿼터 계산. 타임스탠프 매칭은 match_ranges() 이용, Gemini 없이 검증 가능. Co-Authored-By: Claude Opus 5 (1M context) --- capcut_agent/recommend.py | 54 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 capcut_agent/recommend.py diff --git a/capcut_agent/recommend.py b/capcut_agent/recommend.py new file mode 100644 index 0000000..bdecba7 --- /dev/null +++ b/capcut_agent/recommend.py @@ -0,0 +1,54 @@ +"""컷별 댓글 추천 — 자동 탭 검토 화면용. + +왜 별도 모듈인가: 배정 규칙(타임스탬프 우선·중복 제거·모드 분기)은 댓글 수집 +(`comments.py`)과도, HTTP 처리(`server/app.py`)와도 책임이 다르다. 순수 함수로 떼어놔야 +Gemini 없이 단위 검증이 된다. + +설계 근거는 docs/superpowers/specs/2026-08-04-컷별-댓글-추천-design.md. +""" +from __future__ import annotations + +from typing import Dict, List + +from .comments import match_ranges + +# 카드 1장이 차지하는 기준 시간(초). pipeline._load_comment_cards(min_sec) 과 같은 값. +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] + + +def build_cut_picks(cuts, comments, ai_picks: Dict[int, List[int]], + quotas) -> List[List[dict]]: + """컷별 추천 확정 — 타임스탬프 우선, 남는 자리만 AI, 전 컷 통틀어 중복 금지. + + 타임스탬프가 AI보다 먼저인 이유: 그 컷의 **원본 구간**을 콕 집어 언급한 댓글은 + 근거가 확실하다. 추측(AI)을 이기게 둘 이유가 없다. + 중복은 앞 컷이 가져간다(뒤 컷은 다음 후보로 밀린다). + + ai_picks: {컷인덱스: [댓글idx …]} — Gemini 실패 시 {} 를 넘기면 타임스탬프만으로 채운다. + Returns: 컷별 [{"idx": int, "why": "ts"|"ai"}] + """ + 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 From 8367219b7f2a894dd13c1fffc54289acd57211c5 Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Tue, 4 Aug 2026 11:50:12 +0900 Subject: [PATCH 04/21] =?UTF-8?q?=EC=BB=B7=EB=B3=84=20=EB=8C=93=EA=B8=80?= =?UTF-8?q?=20=EC=B6=94=EC=B2=9C=20=ED=94=84=EB=A1=AC=ED=94=84=ED=8A=B8=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EB=A1=9C=EB=8D=94=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RECOMMEND_PATH 상수 추가 (프롬프트/댓글_추천.md) - DEFAULT_RECOMMEND 기본 프롬프트 추가 ({cuts}, {comments}, {k} 치환자 포함) - load_recommend() 함수 구현 (없으면 기본값으로 생성 후 읽기, load_step1()과 같은 패턴) 사용자가 메모장에서 프롬프트를 직접 수정할 수 있도록 설계. Task 4에서 .format(cuts=…, comments=…, k=…)으로 채워져 Gemini에 전달됨. Co-Authored-By: Claude Opus 5 (1M context) --- capcut_agent/prompts.py | 29 +++++++++++++++++++++++++++++ 프롬프트/댓글_추천.md | 16 ++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 프롬프트/댓글_추천.md 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/프롬프트/댓글_추천.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 From 7cb70cc33ad53bad2200839ccda52b267688cbfa Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Tue, 4 Aug 2026 11:56:08 +0900 Subject: [PATCH 05/21] =?UTF-8?q?Gemini=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=EB=A1=9C=20=EC=BB=B7=EB=B3=84=20=EB=8C=93?= =?UTF-8?q?=EA=B8=80=20=EC=B6=94=EC=B2=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit correct.py의 텍스트 호출 패턴을 따라 ai_pick_cuts() 및 헬퍼 함수(_candidate_lines, _cut_lines, _parse_ai)를 추가했다. - 목차 댓글 필터링(MAX_TIMES 기준) - 좋아요순 정렬 - 응답 파싱 시 형식 오류는 조용히 버림 - 모든 실패(키 없음, 타임아웃, JSON 깨짐 등)에 {} 반환 Co-Authored-By: Claude Opus 5 (1M context) --- capcut_agent/recommend.py | 103 +++++++++++++++++++++++++++++++++++++- 1 file changed, 102 insertions(+), 1 deletion(-) diff --git a/capcut_agent/recommend.py b/capcut_agent/recommend.py index bdecba7..798d8c2 100644 --- a/capcut_agent/recommend.py +++ b/capcut_agent/recommend.py @@ -8,9 +8,14 @@ Gemini 없이 단위 검증이 된다. """ from __future__ import annotations +import json +import urllib.error +import urllib.request from typing import Dict, List -from .comments import match_ranges +from . import prompts +from .comments import MAX_TIMES, match_ranges +from .correct import _gemini_key # 카드 1장이 차지하는 기준 시간(초). pipeline._load_comment_cards(min_sec) 과 같은 값. CARD_SEC = 3.0 @@ -52,3 +57,99 @@ def build_cut_picks(cuts, comments, ai_picks: Dict[int, List[int]], 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 …]}. 형식이 어긋난 항목은 조용히 버린다.""" + try: + data = json.loads(raw) + except (json.JSONDecodeError, TypeError): + return {} + if not isinstance(data, list): + return {} + 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) -> Dict[int, List[int]]: + """컷 자막으로 컷별 추천을 받는다. **실패하면 예외 없이 {}** — 호출부가 폴백한다. + + 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, OSError): # 사용자가 프롬프트를 깨뜨린 경우 + return {} + 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 (urllib.error.URLError, KeyError, IndexError, TimeoutError, + json.JSONDecodeError, OSError): + return {} # 429 포함 — 폴백은 호출부 몫 + return _parse_ai(raw) From ee30e87bb413b66e64be62b4d8c93df536c31650 Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Tue, 4 Aug 2026 12:02:36 +0900 Subject: [PATCH 06/21] =?UTF-8?q?=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A0:=20ValueError=EC=99=80=20UnicodeDecodeErr?= =?UTF-8?q?or=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - str.format() 예외 처리에 ValueError 추가 (템플릿 중괄호 불균형) - .decode(utf-8) 예외 처리에 ValueError 추가 (UnicodeDecodeError는 ValueError 서브클래스) - URLError/TimeoutError는 이미 OSError 서브클래스이므로 정리 (주석 추가) - ai_pick_cuts는 모든 실패에 예외 없이 {} 반환하는 계약 이행 Co-Authored-By: Claude Opus 5 (1M context) --- capcut_agent/recommend.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/capcut_agent/recommend.py b/capcut_agent/recommend.py index 798d8c2..162155f 100644 --- a/capcut_agent/recommend.py +++ b/capcut_agent/recommend.py @@ -133,7 +133,7 @@ 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): # 사용자가 프롬프트를 깨뜨린 경우 + except (KeyError, IndexError, ValueError, OSError): # 사용자가 프롬프트를 깨뜨린 경우 return {} body = { "contents": [{"parts": [{"text": prompt}]}], @@ -149,7 +149,7 @@ 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): + except (KeyError, IndexError, json.JSONDecodeError, ValueError, + OSError): # URLError, TimeoutError are OSError subclasses; ValueError covers UnicodeDecodeError return {} # 429 포함 — 폴백은 호출부 몫 return _parse_ai(raw) From 88768020bff0321be721dd29e31c5320d21f0622 Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Tue, 4 Aug 2026 12:05:05 +0900 Subject: [PATCH 07/21] =?UTF-8?q?docs:=20=EA=B3=84=ED=9A=8D=EC=84=9C=20Tas?= =?UTF-8?q?k=204=20=EC=98=88=EC=8B=9C=20except=20=ED=8A=9C=ED=94=8C?= =?UTF-8?q?=EC=9D=98=20=EC=98=88=EC=99=B8=20=EB=88=84=EC=B6=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리뷰에서 잡힌 구멍이 계획서 참고 코드에도 그대로 있었다. str.format 의 ValueError 와 .decode 의 UnicodeDecodeError(= ValueError 서브클래스)가 빠져 있어, 그대로 옮기면 "어떤 실패에도 예외 없음" 계약이 깨진다. Co-Authored-By: Claude Opus 5 (1M context) --- docs/superpowers/plans/2026-08-04-컷별-댓글-추천.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-08-04-컷별-댓글-추천.md b/docs/superpowers/plans/2026-08-04-컷별-댓글-추천.md index 2b34a2b..5c52695 100644 --- a/docs/superpowers/plans/2026-08-04-컷별-댓글-추천.md +++ b/docs/superpowers/plans/2026-08-04-컷별-댓글-추천.md @@ -502,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, @@ -518,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) ``` From 005beba4c33e9983c8b9c47168f8d86f1be0e66c Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Tue, 4 Aug 2026 12:07:48 +0900 Subject: [PATCH 08/21] =?UTF-8?q?=ED=95=98=EC=9D=B4=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EC=A1=B0=EB=A6=BD=20=E2=80=94=20build=5Fhighlight?= =?UTF-8?q?=5Fcuts()=20=EB=AA=A8=EB=93=9C=20=EB=B6=84=EA=B8=B0=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 통짜 모드(컷 1개 + 자막 없음)는 시각 슬롯으로 배정, 컷 있는 모드는 Gemini 추천. is_whole() 판별, _whole_picks() 슬롯 채우기, build_highlight_cuts() 조립. import에 match_slots, top_liked 추가. Co-Authored-By: Claude Opus 5 (1M context) --- capcut_agent/recommend.py | 50 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/capcut_agent/recommend.py b/capcut_agent/recommend.py index 162155f..0d3865a 100644 --- a/capcut_agent/recommend.py +++ b/capcut_agent/recommend.py @@ -14,7 +14,7 @@ import urllib.request from typing import Dict, List from . import prompts -from .comments import MAX_TIMES, match_ranges +from .comments import MAX_TIMES, match_ranges, match_slots, top_liked from .correct import _gemini_key # 카드 1장이 차지하는 기준 시간(초). pipeline._load_comment_cards(min_sec) 과 같은 값. @@ -153,3 +153,51 @@ def ai_pick_cuts(cuts, comments, quotas, *, model: str = AI_MODEL, OSError): # URLError, TimeoutError are OSError subclasses; ValueError covers UnicodeDecodeError return {} # 429 포함 — 폴백은 호출부 몫 return _parse_ai(raw) + + +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). 모드는 컷 모양으로 판별한다. + + Returns: ([{"i","sec","bottom","quota","picks"}], need) + need = Σ quota — 기존 int(total//3) 을 대체한다(컷 경계에 맞추는 쪽이 맞다). + Gemini 실패는 여기서 흡수된다(ai_pick_cuts 가 {} 를 준다) — 예외를 올리지 않는다. + """ + cuts = (hl.get("paste") or {}).get("cuts") or [] + if not cuts: + return [], 0 + quotas = quotas_for(cuts) + if is_whole(cuts): + picks = [_whole_picks(cuts[0], comments, quotas[0])] + else: + ai = ai_pick_cuts(cuts, comments, quotas, key=key) + 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) From 90ff2b174fd47e7c74c7eedca648a997558ecccf Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Tue, 4 Aug 2026 12:12:35 +0900 Subject: [PATCH 09/21] =?UTF-8?q?/auto/analyze=20=EA=B2=B0=EA=B3=BC?= =?UTF-8?q?=EC=97=90=20=EC=BB=B7=EB=B3=84=20=EC=B6=94=EC=B2=9C(cuts[])=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 댓글 매칭 후 recommend.build_highlight_cuts()를 호출해 하이라이트별 cuts/need를 SSE result에 실어 보낸다. Gemini 호출이 블로킹이라 asyncio.to_thread로 스레드에서 돌리고, 실패해도 기존 matched/candidates로 화면이 폴백하도록 warnings에만 기록하고 드래프트 생성은 막지 않는다. Co-Authored-By: Claude Opus 5 (1M context) --- server/app.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/server/app.py b/server/app.py index e5d8fe6..f75eed2 100644 --- a/server/app.py +++ b/server/app.py @@ -23,6 +23,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 @@ -530,6 +531,17 @@ async def auto_stream(aid: str) -> StreamingResponse: matched = hlab.match_window(comments, h["start"], h["end"]) h["matched"] = matched h["candidates"] = hlab.top_liked(no_ts, set(matched), len(no_ts)) + # 컷별 추천 — 실패해도 위의 matched/candidates 로 화면이 돌아간다. + # Gemini 호출이 섞여 있어 블로킹이므로 스레드로 뺀다. + try: + cuts, need = await asyncio.to_thread( + recommend.build_highlight_cuts, h, comments) + if cuts: + h["cuts"], h["need"] = cuts, need + except Exception as exc: # noqa: BLE001 — 추천 실패가 생성을 막으면 안 된다 + warnings.append( + f"ID {h.get('id')} 컷별 추천 실패 — 기존 방식으로 표시 " + f"({type(exc).__name__}: {exc})") yield _sse({"type": "result", "highlights": highlights, "comments": comments, "warnings": warnings}) From 0a0b43efc22d47831be464dc454323a20feee909 Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Tue, 4 Aug 2026 12:18:33 +0900 Subject: [PATCH 10/21] =?UTF-8?q?=EB=8C=93=EA=B8=80=20=EC=B9=B4=EB=93=9C?= =?UTF-8?q?=EB=A5=BC=20=EC=BB=B7=20=EA=B5=AC=EA=B0=84=20=EC=95=88=EC=97=90?= =?UTF-8?q?=20=EB=B0=B0=EC=B9=98=ED=95=98=EB=8A=94=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _load_comment_cards를 _card_paths/_load_comment_cards/_cards_by_cut로 분리했다. 자동 탭은 카드가 어느 컷 소속인지(card_cuts)만 넘기면 그 컷 구간 안에서 균등 배치되어, 한 컷의 카드가 모자라도 다음 컷 카드가 앞으로 밀리지 않는다(기존 전체 균등 배치의 약점). 카드 시간은 무음 제거 전 placements 직후에 계산하고, 자막과 동일하게 _remap_caps()로 재매핑한다 — 카드 튜플(start,end,path)이 자막 튜플(start,end,text)과 모양이 같아 그대로 재사용 가능하다. card_cuts가 없으면 기존 _load_comment_cards 폴백으로 떨어져 파일/유튜브 구간 탭은 영향 없다. Co-Authored-By: Claude Opus 5 (1M context) --- capcut_agent/pipeline.py | 83 +++++++++++++++++++++++++++++++++------- 1 file changed, 69 insertions(+), 14 deletions(-) diff --git a/capcut_agent/pipeline.py b/capcut_agent/pipeline.py index 4bfdeb0..71a2e2c 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,40 @@ 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 고정, 컷 뒷부분은 비움). + + 왜 컷 단위인가: 전체 균등 배치(`_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 # 영상 실제 길이 밖 컷은 버린다 + step = min_sec if fixed else (p1 - p0) / len(idxs) + 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 @@ -311,6 +357,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 +417,11 @@ async def process_paste( eff_caps = [(p0, min(p1, dur), ef) for (p0, p1), (_, _, _, ef) in zip(placements, cuts) if ef and p0 < dur] + # 댓글 카드(자동 탭): '몇 번 컷 소속'만 받아 여기서 시간을 만든다. + # 서버가 시간을 확정하면 무음 제거 때 자막만 당겨지고 카드는 혼자 어긋난다. + cut_cards = _cards_by_cut(_card_paths(comments_dir), card_cuts or [], + placements, dur, fixed=cards_fixed) + video_clips = [(0.0, dur)] # 병합본 = 한 덩어리(재컷 없음) timeline_dur = dur @@ -384,6 +436,7 @@ async def process_paste( keep = sorted(keep) bottom_caps = _remap_caps(bottom_caps, keep) eff_caps = _remap_caps(eff_caps, keep) + cut_cards = _remap_caps(cut_cards, keep) video_clips = keep timeline_dur = sum(e - s for s, e in keep) yield {"type": "log", @@ -437,10 +490,12 @@ 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 = cut_cards or _load_comment_cards(comments_dir, timeline_dur, fixed=cards_fixed) if cards: - yield {"type": "log", "msg": f"댓글 카드 {len(cards)}개 하단 삽입(3초 간격)"} + yield {"type": "log", + "msg": f"댓글 카드 {len(cards)}개 하단 삽입" + + ("(컷별 배치)" if cut_cards else "(전체 균등)")} path = await asyncio.to_thread( lambda: build_bg_template_draft( From c3556edb9db6b5f64295500a67d1c31ef42249ee Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Tue, 4 Aug 2026 12:23:18 +0900 Subject: [PATCH 11/21] =?UTF-8?q?/auto/build=EA=B0=80=20card=5Fcuts=20?= =?UTF-8?q?=ED=8F=BC=20=ED=95=84=EB=93=9C=EB=A5=BC=20=EB=B0=9B=EC=95=84=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=EC=97=90=20?= =?UTF-8?q?=EB=84=98=EA=B9=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auto_build 시그니처에 card_cuts 폼 필드 추가 - JSON 배열 문자열 파싱 (값 깨져도 빌드 계속) - job dict에 저장 후 process_paste 호출에 전달 - bool 필터링 강화: JSON true/false가 정수로 둔갑하지 않도록 이유: JSON의 true/false가 Python bool로 파싱되는데, bool이 int의 서브클래스라 isinstance(v, int)를 통과해 컷 인덱스 1/0으로 대신 쓰이는 문제 해결. capcut_agent/recommend.py 패턴과 일관성 있음. Co-Authored-By: Claude Opus 5 (1M context) --- server/app.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/server/app.py b/server/app.py index f75eed2..3e85c73 100644 --- a/server/app.py +++ b/server/app.py @@ -224,6 +224,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( @@ -610,6 +611,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. @@ -634,6 +636,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), @@ -641,6 +651,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)}) From 5b58e94ad1bec968de53a1d656fd6511cb8489f2 Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Tue, 4 Aug 2026 12:28:11 +0900 Subject: [PATCH 12/21] =?UTF-8?q?=EA=B2=80=ED=86=A0=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=EC=9D=84=20=EC=BB=B7=EB=B3=84=20=EC=B9=B4=EB=93=9C=20=EB=AC=B6?= =?UTF-8?q?=EC=9D=8C=EC=9C=BC=EB=A1=9C=20=EB=A0=8C=EB=8D=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서버가 하이라이트마다 컷 단위 추천(hl.cuts)을 내려주기 시작해서(6~8번 커밋), 화면도 컷 소속을 알아야 카드를 그 컷 구간 안에 배치할 수 있다. 카드가 어느 컷에 속하는지(selCut)를 추적해 컷별 상한(quota)을 걸고, sel[hlId]를 항상 컷 순서로 정렬해 업로드 파일명(001.png…)과 card_cuts 배열 인덱스가 어긋나지 않게 했다. hl.cuts가 없으면(추천 실패) 기존 ⭐ 통짜 화면으로 그대로 폴백한다. Co-Authored-By: Claude Opus 5 (1M context) --- server/static/auto.js | 78 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 63 insertions(+), 15 deletions(-) diff --git a/server/static/auto.js b/server/static/auto.js index a7eadc1..c75b31a 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,7 @@ function wrapOf(hlId,idx){ /* ── 카드 섹션 — 30장씩 렌더 + 더보기 (전체 댓글을 받아도 DOM은 점진 생성) ── */ const CARD_PAGE=30; -function cardSection(label,list,hlId,firstBatch){ +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); @@ -75,7 +75,7 @@ function cardSection(label,list,hlId,firstBatch){ for(;shown(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 +456,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,11 +489,25 @@ 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))); // 자동 선택분은 첫 화면에 다 보이게 + const WHY={ts:"⭐",ai:"🤖",like:"➕"}; + if(hl.cuts){ + // 컷별 추천 — 추천분 먼저, 그 컷 시간대 언급 댓글을 뒤에 붙여 갈아끼울 수 있게 + 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)); + 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)); + } + }else{ + // 폴백 — 추천 실패 시 기존 화면 그대로 + 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){ @@ -551,7 +596,8 @@ 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; @@ -559,8 +605,10 @@ async function buildAll(){ try{ const blob=await captureCard(w); fd.append("cards",blob,String(n).padStart(3,"0")+".png"); - }catch(e){boardSet(hl.id,null,"카드 1장 캡처 실패(건너뜀)","active");} + sentCuts.push(cmap[idx]??((hl.paste.cuts||[]).length-1)); + }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); From 6f90efd2bcec46bf97bd77cfe06273930878ed9c Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Tue, 4 Aug 2026 12:38:36 +0900 Subject: [PATCH 13/21] =?UTF-8?q?docs:=20=EC=BB=B7=EB=B3=84=20=EB=8C=93?= =?UTF-8?q?=EA=B8=80=20=EC=B6=94=EC=B2=9C=20=EA=B8=B0=EB=8A=A5=EC=9D=84=20?= =?UTF-8?q?ARCHITECTURE/SETUP=EC=97=90=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recommend.py 모듈, 카드 배치가 자동 탭(card_cuts→_cards_by_cut)과 나머지 탭(_load_comment_cards 전체 균등)으로 갈린 이유, 그리고 서버가 카드 시간을 미리 확정하지 않는 이유(무음 제거 시 자막만 재매핑되고 카드가 혼자 어긋나는 것을 방지)를 문서에 남겨 나중에 이 설계를 실수로 되돌리는 것을 막는다. SETUP.md 문제 해결표에도 추천 실패 폴백 증상 두 건을 추가. Co-Authored-By: Claude Opus 5 (1M context) --- ARCHITECTURE.md | 13 +++++++++++++ SETUP.md | 2 ++ 2 files changed, 15 insertions(+) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d81f564..6ded5f3 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,16 @@ 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` 전체 균등 + 배치 그대로 쓴다. + - ⚠ **카드 시간은 서버가 미리 확정하지 않는다.** `/auto/build`는 "몇 번 컷 소속"만 넘기고, + 파이프라인이 컷 누적 위치(`placements`)로 시간을 계산한다. 무음 제거를 켜면 타임라인이 + 압축되는데(`timeline_dur = sum(keep)`), 서버가 시간을 미리 박아두면 자막만 재매핑되고 + 카드는 혼자 어긋나기 때문이다 — 카드도 자막과 **같은 `_remap_caps()`**로 재매핑된다 + (튜플 모양이 `(start, end, path)`로 같아서 가능). 이 순서를 뒤집지 말 것. - 렌더: 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..7e947f7 100644 --- a/SETUP.md +++ b/SETUP.md @@ -280,6 +280,8 @@ yt-dlp --version | 드래프트 열면 영상이 흰띠·댓글 위로 삐짐 | CapCut 편집 중 render_index 꼬임 | 최신 `draft.py`는 오버레이/제목 트랙 자동 잠금으로 예방 | | **클립을 옮긴 뒤 확대하면 그 클립만 템플릿 밖으로 삐짐** | CapCut이 옮긴 클립에 렌더순서를 새로(맨 위로) 매김 — 잠금으로 못 막음 | **캡컷에서 그 프로젝트를 닫고** → 웹 UI 하단 **"🩹 레이어 수리"** → 드래프트 선택 → 실행 → 캡컷에서 다시 열기 | | **영상 중간에 초록 화면이 몇 초 나옴** | yt-dlp가 키프레임 아닌 위치에서 잘라 참조 프레임이 없음 | 최신 `youtube.py`가 컷마다 자동 검증→재다운로드→정밀 재컷. 로그에 `🩹` 표시. 예전에 받은 영상은 다시 만들어야 함 | +| 댓글이 엉뚱한 장면에 뜬다 | 컷별 추천이 실패해 기존 전체 균등 배치로 폴백 | 검토 화면 상단 경고 확인. Gemini 한도 초과면 잠시 뒤 재시도 | +| 컷 섹션이 안 보이고 ⭐ 하나만 뜬다 | 추천 실패 폴백 | 위와 동일. `프롬프트/댓글_추천.md`를 고쳤다면 `{cuts}` `{comments}` `{k}` 가 남아 있는지 확인 | | 콘솔에 한글 깨짐 | Windows cp949 | 표시만 깨짐. 로직·결과와 무관 | --- From 1e435309335b7abd1f4da08b7136c6f34523e46c Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Tue, 4 Aug 2026 14:09:19 +0900 Subject: [PATCH 14/21] =?UTF-8?q?Gemini=20=EC=B6=94=EC=B2=9C=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=EB=A5=BC=20=ED=99=94=EB=A9=B4=EC=97=90=20=EC=95=8C?= =?UTF-8?q?=EB=A6=B0=EB=8B=A4=20(=EC=A1=B0=EC=9A=A9=ED=9E=88=20=EC=82=BC?= =?UTF-8?q?=ED=82=A4=EC=A7=80=20=EC=95=8A=EC=9D=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ai_pick_cuts 가 429·타임아웃·JSON 파싱 실패를 전부 {} 로 삼켜서, build_highlight_cuts 는 여전히 타임스탬프만으로 채운 cuts 를 돌려주고 app.py 의 `if cuts:` 가 참이 되어 except 가 절대 타지 않았다. 스펙 §7이 요구한 "⚠ 추천 실패 → 기존 방식" 경고가 화면에 안 떴다. - ai_pick_cuts: 실패는 None, 성공은 dict(빈 dict 포함). 키가 없는 경우는 실패가 아니다 — 키 없이 쓰는 것도 정상 사용이라 {} 를 준다. 실패로 치는 건 HTTP·타임아웃·응답 파싱·프롬프트 치환 오류뿐. - 응답 파싱을 _rows_to_picks() 로 떼어내 '깨진 응답'(None)과 '어울리는 게 없어 빈 배열'({})을 구분한다. _parse_ai() 계약은 그대로 둔다. - build_highlight_cuts → (cuts, need, ai_failed) 3-튜플. 통짜 모드는 Gemini 를 안 부르므로 ai_failed=False. - build_cut_picks 는 ai_picks=None 이어도 안전(`ai_picks or {}`). - app.py: ai_failed 면 warnings 에 "AI 추천 실패 — 타임스탬프만으로 배정" 한 줄. Co-Authored-By: Claude Opus 5 (1M context) --- capcut_agent/recommend.py | 60 ++++++++++++++++++++++++++++----------- server/app.py | 8 +++++- 2 files changed, 51 insertions(+), 17 deletions(-) diff --git a/capcut_agent/recommend.py b/capcut_agent/recommend.py index 0d3865a..f7e4b60 100644 --- a/capcut_agent/recommend.py +++ b/capcut_agent/recommend.py @@ -9,9 +9,8 @@ Gemini 없이 단위 검증이 된다. from __future__ import annotations import json -import urllib.error import urllib.request -from typing import Dict, List +from typing import Dict, List, Optional from . import prompts from .comments import MAX_TIMES, match_ranges, match_slots, top_liked @@ -26,7 +25,7 @@ def quotas_for(cuts) -> List[int]: return [max(1, round((c["end"] - c["start"]) / CARD_SEC)) for c in cuts] -def build_cut_picks(cuts, comments, ai_picks: Dict[int, List[int]], +def build_cut_picks(cuts, comments, ai_picks: Optional[Dict[int, List[int]]], quotas) -> List[List[dict]]: """컷별 추천 확정 — 타임스탬프 우선, 남는 자리만 AI, 전 컷 통틀어 중복 금지. @@ -34,9 +33,11 @@ def build_cut_picks(cuts, comments, ai_picks: Dict[int, List[int]], 근거가 확실하다. 추측(AI)을 이기게 둘 이유가 없다. 중복은 앞 컷이 가져간다(뒤 컷은 다음 후보로 밀린다). - ai_picks: {컷인덱스: [댓글idx …]} — Gemini 실패 시 {} 를 넘기면 타임스탬프만으로 채운다. + 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]] = [] @@ -97,13 +98,22 @@ def _cut_lines(cuts) -> str: def _parse_ai(raw: str) -> Dict[int, List[int]]: - """Gemini 응답 → {컷인덱스: [댓글idx …]}. 형식이 어긋난 항목은 조용히 버린다.""" + """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): @@ -120,21 +130,28 @@ def _parse_ai(raw: str) -> Dict[int, List[int]]: def ai_pick_cuts(cuts, comments, quotas, *, model: str = AI_MODEL, - key=None, timeout: float = 90.0) -> Dict[int, List[int]]: - """컷 자막으로 컷별 추천을 받는다. **실패하면 예외 없이 {}** — 호출부가 폴백한다. + 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 {} + 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 {} + return None body = { "contents": [{"parts": [{"text": prompt}]}], "generationConfig": {"temperature": 0.3, @@ -151,8 +168,14 @@ def ai_pick_cuts(cuts, comments, quotas, *, model: str = AI_MODEL, raw = data["candidates"][0]["content"]["parts"][0]["text"] except (KeyError, IndexError, json.JSONDecodeError, ValueError, OSError): # URLError, TimeoutError are OSError subclasses; ValueError covers UnicodeDecodeError - return {} # 429 포함 — 폴백은 호출부 몫 - return _parse_ai(raw) + 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: @@ -182,22 +205,27 @@ def _whole_picks(cut, comments, n: int) -> List[dict]: def build_highlight_cuts(hl, comments, *, key=None): - """하이라이트 하나 → (cuts[], need). 모드는 컷 모양으로 판별한다. + """하이라이트 하나 → (cuts[], need, ai_failed). 모드는 컷 모양으로 판별한다. - Returns: ([{"i","sec","bottom","quota","picks"}], need) + Returns: ([{"i","sec","bottom","quota","picks"}], need, ai_failed) need = Σ quota — 기존 int(total//3) 을 대체한다(컷 경계에 맞추는 쪽이 맞다). - Gemini 실패는 여기서 흡수된다(ai_pick_cuts 가 {} 를 준다) — 예외를 올리지 않는다. + 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 + 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) + return out, sum(quotas), ai_failed diff --git a/server/app.py b/server/app.py index 3e85c73..4d48125 100644 --- a/server/app.py +++ b/server/app.py @@ -525,6 +525,7 @@ async def auto_stream(aid: str) -> StreamingResponse: # 댓글 매칭 — 전체 전송, 브라우저가 '더보기'로 30장씩 나눠 그린다. # 후보(candidates)는 분:초 언급이 아예 없는 댓글만 — 타임스탬프 댓글은 # 자기 구간의 ⭐에서 잡히므로, 다른 구간 얘기하는 댓글이 섞이지 않게. + # no_ts = [c for c in comments if not c["times"]] for h in highlights: if "paste" not in h: @@ -535,10 +536,15 @@ async def auto_stream(aid: str) -> StreamingResponse: # 컷별 추천 — 실패해도 위의 matched/candidates 로 화면이 돌아간다. # Gemini 호출이 섞여 있어 블로킹이므로 스레드로 뺀다. try: - cuts, need = await asyncio.to_thread( + 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')} 컷별 추천 실패 — 기존 방식으로 표시 " From 4e479b50ce906f6f166d73530974c1a330d1459b Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Tue, 4 Aug 2026 14:09:41 +0900 Subject: [PATCH 15/21] =?UTF-8?q?=EC=B6=94=EC=B2=9C=20=EB=8B=A8=EA=B3=84?= =?UTF-8?q?=EC=97=90=20=EC=A7=84=ED=96=89=20=ED=91=9C=EC=8B=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(=EC=B5=9C=EB=8C=80=20450=EC=B4=88=20=EB=AC=B4?= =?UTF-8?q?=EB=B0=98=EC=9D=91=20=EC=A0=9C=EA=B1=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /auto/stream 은 하이라이트 5개를 순차로 돌며 각각 Gemini 를 부른다(timeout=90). manifest 에 recommend 스텝이 없고 루프 안에 로그도 없어서, 마지막으로 보이는 게 "댓글 수집 done" 이고 그 뒤 최악 450초 동안 아무 이벤트도 안 나갔다. 사용자가 멈춘 줄 알고 새로고침하면 분석이 통째로 날아간다. 순차 호출 자체는 유지한다 — 동시에 5발을 쏘면 429 가 난다(Step 3 도 같은 이유로 시차 재시도를 쓴다). 대신 보이게만 만든다: - 세 갈래 manifest(paste / wpaste / else) 전부에 recommend 스텝 추가 - 루프 앞뒤로 step start/done(detail = 처리한 하이라이트 수) - 하이라이트마다 "ID n 컷별 댓글 추천 중… (i/N, 컷 M개)" 로그 한 줄 Co-Authored-By: Claude Opus 5 (1M context) --- server/app.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/server/app.py b/server/app.py index 4d48125..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 @@ -354,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 @@ -427,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: @@ -444,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)) @@ -526,13 +530,21 @@ async def auto_stream(aid: str) -> StreamingResponse: # 후보(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: @@ -549,6 +561,9 @@ async def auto_stream(aid: str) -> StreamingResponse: 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}) From 499223a12559b677f5b9d46628322ee8e8b51256 Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Tue, 4 Aug 2026 14:12:31 +0900 Subject: [PATCH 16/21] =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EB=B0=B0=EC=B9=98?= =?UTF-8?q?=EB=A5=BC=20=EB=AC=B4=EC=9D=8C=20=EC=A0=9C=EA=B1=B0=20=EB=92=A4?= =?UTF-8?q?=EB=A1=9C=20=EC=98=AE=EA=B2=A8=203=EC=B4=88=20=EC=95=BD?= =?UTF-8?q?=EC=86=8D=EC=9D=84=20=EC=A7=80=ED=82=A8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 카드 시간을 무음 제거 **전** 타임라인에서 만들고 _remap_caps 로 압축하고 있었다. 자막은 그게 맞다(발화 시각을 따라가야 하니까). 하지만 카드는 다르다: - cards_fixed=True 는 "카드는 정확히 3초"라는 절대 길이 약속이라 압축하면 깨진다. 컷 한가운데가 무음이면 3.0초짜리가 2.0초로 눌린다. - fixed=False 에서도 3초 하한이 사라져 있었다. 기존 _load_comment_cards 는 n = max(1, int(dur // 3)) 으로 카드당 3초를 보장하는데 _cards_by_cut 엔 그게 없었고, quota 는 **원본** 컷 길이로 계산되므로 무음이 절반인 24초 컷은 8장 → 압축 12초 → 장당 1.5초가 됐다. 고친 방식: - _remap_placements() 추가 — 카드는 '시간'이 아니라 '컷 구간 자체'를 옮긴다. - _cards_by_cut 호출을 무음 제거 블록 뒤로 옮기고 압축된 구간·timeline_dur 로 계산. 무음 제거가 꺼져 있으면 card_places = placements 그대로다. - _cards_by_cut 에 컷당 장수 상한 min(len, max(1, floor(컷길이/min_sec))) 추가. 초과분은 버린다 — _load_comment_cards 와 같은 규칙. - 이제 필요 없어진 cut_cards 의 _remap_caps 호출 제거. 기존 탭(process_bg_template)과 _load_comment_cards 는 손대지 않았다. Co-Authored-By: Claude Opus 5 (1M context) --- capcut_agent/pipeline.py | 42 ++++++++++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/capcut_agent/pipeline.py b/capcut_agent/pipeline.py index 71a2e2c..e95164d 100644 --- a/capcut_agent/pipeline.py +++ b/capcut_agent/pipeline.py @@ -96,6 +96,13 @@ def _cards_by_cut(paths, card_cuts, placements, dur: float, *, 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번 컷 위에 뜬다. 컷 안에서 계산하면 한 컷이 덜 차도 **다음 컷 카드가 앞으로 밀리지 않는다.** @@ -113,7 +120,9 @@ def _cards_by_cut(paths, card_cuts, placements, dur: float, *, p1 = min(p1, dur) if p1 <= p0: continue # 영상 실제 길이 밖 컷은 버린다 - step = min_sec if fixed else (p1 - p0) / len(idxs) + 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: @@ -144,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" @@ -417,11 +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] - # 댓글 카드(자동 탭): '몇 번 컷 소속'만 받아 여기서 시간을 만든다. - # 서버가 시간을 확정하면 무음 제거 때 자막만 당겨지고 카드는 혼자 어긋난다. - cut_cards = _cards_by_cut(_card_paths(comments_dir), card_cuts or [], - placements, dur, fixed=cards_fixed) - + card_places = placements # 카드 배치 기준 구간(무음 제거 시 압축본으로 교체) video_clips = [(0.0, dur)] # 병합본 = 한 덩어리(재컷 없음) timeline_dur = dur @@ -436,7 +455,8 @@ async def process_paste( keep = sorted(keep) bottom_caps = _remap_caps(bottom_caps, keep) eff_caps = _remap_caps(eff_caps, keep) - cut_cards = _remap_caps(cut_cards, keep) + # 카드는 '시간'이 아니라 '컷 구간'을 옮긴다 — 아래에서 이 구간 안에 나눠 넣는다. + card_places = _remap_placements(placements, keep) video_clips = keep timeline_dur = sum(e - s for s, e in keep) yield {"type": "log", @@ -490,7 +510,13 @@ async def process_paste( video_clips = split_clips_at_scenes(video_clips, scenes) yield {"type": "log", "msg": f"장면전환 {len(scenes)}곳 → 세그먼트 {len(video_clips)}개"} - # 댓글 카드: 컷 소속이 지정됐으면 그 컷 구간 안(위에서 계산), 아니면 폴더 균등 배치 + # 댓글 카드(자동 탭): '몇 번 컷 소속'만 받아 여기서 시간을 만든다. + # 서버가 시간을 확정하면 무음 제거 때 자막만 당겨지고 카드는 혼자 어긋난다. + # ⚠ 무음 제거 **뒤**에 계산한다 — 압축 전 시간으로 만들어 재매핑하면 + # 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", From c6092a93ac5d02a9dd7edda39951c60193a27cdf Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Tue, 4 Aug 2026 14:15:06 +0900 Subject: [PATCH 17/21] =?UTF-8?q?=E2=9E=95=20=EC=84=B9=EC=85=98=EC=9D=84?= =?UTF-8?q?=20=EC=BB=B7=20=EC=95=88=EC=9C=BC=EB=A1=9C=20=ED=9D=A1=EC=88=98?= =?UTF-8?q?=20=E2=80=94=20=EA=B3=A0=EB=A5=B8=20=EC=B9=B4=EB=93=9C=EA=B0=80?= =?UTF-8?q?=20=EB=A7=88=EC=A7=80=EB=A7=89=20=EC=BB=B7=EC=97=90=20=EC=B2=98?= =?UTF-8?q?=EB=B0=95=ED=9E=88=EC=A7=80=20=EC=95=8A=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 독립 "➕ 좋아요 상위 후보" 섹션은 cardSection 에 ci 를 안 넘겨서 toggle(hlId,idx,undefined) → selCut 에 기록 안 됨 → 빌드 때 마지막 컷으로 폴백했다. 계획서는 "맨 뒤로 가니 마지막 컷 뒤에 붙는다, 배치상 문제 없다"고 적었는데 틀렸다. _cards_by_cut 은 컷 뒤에 붙이지 않고 **컷 안에서 균등 분할**한다 — 4초짜리 마지막 컷에 5장이 들어가면 0.8초씩 번쩍인다. AI 프롬프트가 "억지로 채우지 마라, 없으면 빈 배열로 둬라"라고 지시하므로 quota 미달이 기본 동작이고, 스펙 §7 은 그 미달을 "사람이 ➕에서 채운다"로 풀었다. 즉 이건 예외가 아니라 주 워크플로다. - hl.cuts 가 있으면 컷마다 두 섹션(둘 다 ci=cu.i): ① 컷 N · X초 · 카드 Q장 — 자막 ② ↳ 좋아요 상위에서 채우기 ②는 컷마다 반복되므로 초기 렌더를 6장(CUT_FILL_PAGE)으로 줄이고 나머지는 더보기. - 마지막 컷 폴백 제거. ci 가 없으면 엉뚱한 컷에 넣지 말고 그 카드를 건너뛴다 (fd.append("cards") 와 sentCuts.push 는 반드시 쌍으로 — 하나만 돌면 전부 어긋난다). - hl.cuts 가 없는 폴백 경로(⭐ + 독립 ➕)는 그대로 둔다. 같이: 컷 섹션의 '나머지'를 그 컷 시간대 언급 댓글로 좁혔다. hl.matched 전체를 컷마다 복제하면 컷 12개 × 30장 = 360장, 하이라이트 5개면 1800장으로 부풀고(기존 300장) 같은 댓글이 여러 섹션에 중복돼 한쪽을 고르면 쌍둥이까지 하이라이트됐다. 데이터(byIdx[i].times, paste.cuts[i].start/end)는 이미 클라이언트에 다 있다. 호출되지 않는 죽은 함수 cutOf() 도 제거. Co-Authored-By: Claude Opus 5 (1M context) --- server/static/auto.js | 45 +++++++++++++++++++++++++++++++------------ 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/server/static/auto.js b/server/static/auto.js index c75b31a..e73cf46 100644 --- a/server/static/auto.js +++ b/server/static/auto.js @@ -61,6 +61,8 @@ function wrapOf(hlId,idx){ /* ── 카드 섹션 — 30장씩 렌더 + 더보기 (전체 댓글을 받아도 DOM은 점진 생성) ── */ const CARD_PAGE=30; +// '좋아요 상위에서 채우기' 섹션은 컷마다 반복된다 — 초기 렌더를 줄여 DOM 폭증을 막는다 +const CUT_FILL_PAGE=6; function cardSection(label,list,hlId,firstBatch,ci){ const sec=document.createElement("div");sec.className="hlsec"; const head=document.createElement("div");head.textContent=label; @@ -92,7 +94,6 @@ function cardSection(label,list,hlId,firstBatch,ci){ selCut[hlId][idx] = 컷 인덱스. sel[hlId] 는 항상 컷 순서로 정렬해 둔다 (업로드 순서 = 배치 순서라서). */ const selCut={}; -function cutOf(hlId,idx){ return (selCut[hlId]||{})[idx]; } function sortSel(hlId){ const m=selCut[hlId]||{}; sel[hlId].sort((a,b)=>(m[a]??999)-(m[b]??999)); @@ -490,30 +491,44 @@ function onResult(ev){ // 영상 편집안(컷 목록·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)이 없어 빌드 때 갈 곳이 없다. 그런데 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(""); - const rest=(hl.matched||[]).filter(i=>byIdx[i]!==undefined&&!rec.includes(i)); + /* 추천 뒤에 붙이는 '나머지'는 **그 컷 시간대를 언급한 댓글만**. + hl.matched 전체를 컷마다 복제하면 컷 12개 × 30장 = 360장씩 부풀고, + 같은 댓글이 여러 컷 섹션에 중복돼 한쪽을 고르면 쌍둥이까지 하이라이트된다. */ + const cs=(hl.paste.cuts||[])[cu.i]||{start:0,end:-1}; + const rest=(hl.matched||[]).filter(i=> + byIdx[i]!==undefined&&!rec.includes(i)&& + (byIdx[i].times||[]).some(t=>t>=cs.start&&t<=cs.end)); const label="컷 "+(cu.i+1)+" · "+cu.sec+"초 · 카드 "+cu.quota+"장"+ (cu.bottom?" — "+cu.bottom:"")+(why?" "+why:""); box.appendChild(cardSection(label,rec.concat(rest),hl.id, Math.max(CARD_PAGE,rec.length),cu.i)); + // 부족분 채우기 — 컷마다 반복되므로 처음엔 조금만 그리고 나머지는 더보기로 + if(cand.length) + box.appendChild(cardSection( + "↳ 좋아요 상위에서 채우기 "+cand.length+"장 (이 컷에 넣기)", + cand,hl.id,CUT_FILL_PAGE,cu.i)); } }else{ - // 폴백 — 추천 실패 시 기존 화면 그대로 + // 폴백 — hl.cuts 가 없으면(컷 정보 없음) 예전 화면 그대로: ⭐ + 독립 ➕ const m=(hl.matched||[]).filter(i=>byIdx[i]!==undefined); box.appendChild(cardSection( "⭐ 이 구간을 언급한 댓글 "+m.length+"장 (좋아요순, 자동 선택)", m,hl.id,Math.max(CARD_PAGE,sel[hl.id].length))); - } - // ➕ 좋아요 상위 후보 (전체 — 30장씩 더보기) - const cand=(hl.candidates||[]).filter(i=>byIdx[i]!==undefined); - 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)); + } } R.appendChild(box); refreshSel(hl.id); @@ -601,11 +616,17 @@ async function buildAll(){ 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"); - 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)); From 754692a343dad2eddcb6bbb37cc492a139cf16b8 Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Tue, 4 Aug 2026 14:16:45 +0900 Subject: [PATCH 18/21] =?UTF-8?q?=EB=AC=B8=EC=84=9C=203=EA=B0=9C=EB=A5=BC?= =?UTF-8?q?=20=EC=8B=A4=EC=A0=9C=20=EB=8F=99=EC=9E=91=EC=97=90=20=EB=A7=9E?= =?UTF-8?q?=EA=B2=8C=20=EA=B3=A0=EC=B9=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 계획서: "ci 가 undefined 면 마지막 컷 뒤에 붙는다, 배치상 문제 없다"는 근거가 틀렸다. _cards_by_cut 은 컷 뒤가 아니라 컷 안에서 균등 분할한다 — 마지막 컷에 몰리면 번쩍인다. 왜 ➕ 를 컷 안으로 흡수했는지로 다시 썼고 Step 3/4 코드도 실제 구현으로 맞췄다. ARCHITECTURE.md: "카드도 자막과 같은 _remap_caps() 로 재매핑" → 실제로는 _remap_placements() 로 컷 구간을 옮기고 그 안에서 나눈다(무음 제거 뒤에 계산). 3초 하한 규칙도 명시. SETUP.md: "컷 섹션이 안 보이고 ⭐ 하나만 뜬다 | Gemini 한도 초과" 는 나오지 않는 증상이다 — 한도를 넘겨도 컷 섹션은 그대로 뜨고 🤖 배지만 사라진다. 또 경고는 "검토 화면 상단"이 아니라 로그 영역(alog)으로 나간다. 두 행을 실제 증상 4행으로 다시 썼다. Co-Authored-By: Claude Opus 5 (1M context) --- ARCHITECTURE.md | 13 +++-- SETUP.md | 6 +- .../plans/2026-08-04-컷별-댓글-추천.md | 56 ++++++++++++++----- 3 files changed, 54 insertions(+), 21 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6ded5f3..a5f5ffa 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -231,11 +231,16 @@ title_top 서브제목 / title_main 메인제목 / channel 출처 / effect 효 **그 컷 구간 안에서** 균등 배치한다(한 컷이 덜 차도 다음 컷 카드가 앞으로 밀리지 않음). 파일/유튜브 탭·붙여넣기 탭 직접 사용은 컷 소속을 몰라 기존 `_load_comment_cards` 전체 균등 배치 그대로 쓴다. + - 컷당 장수 상한은 `max(1, floor(컷길이/3초))` — `_load_comment_cards`와 같은 규칙. + 초과분은 버린다(카드가 1초씩 번쩍이느니 몇 장 빼는 게 낫다). - ⚠ **카드 시간은 서버가 미리 확정하지 않는다.** `/auto/build`는 "몇 번 컷 소속"만 넘기고, - 파이프라인이 컷 누적 위치(`placements`)로 시간을 계산한다. 무음 제거를 켜면 타임라인이 - 압축되는데(`timeline_dur = sum(keep)`), 서버가 시간을 미리 박아두면 자막만 재매핑되고 - 카드는 혼자 어긋나기 때문이다 — 카드도 자막과 **같은 `_remap_caps()`**로 재매핑된다 - (튜플 모양이 `(start, end, path)`로 같아서 가능). 이 순서를 뒤집지 말 것. + 파이프라인이 컷 누적 위치(`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 7e947f7..c1c3a1b 100644 --- a/SETUP.md +++ b/SETUP.md @@ -280,8 +280,10 @@ yt-dlp --version | 드래프트 열면 영상이 흰띠·댓글 위로 삐짐 | CapCut 편집 중 render_index 꼬임 | 최신 `draft.py`는 오버레이/제목 트랙 자동 잠금으로 예방 | | **클립을 옮긴 뒤 확대하면 그 클립만 템플릿 밖으로 삐짐** | CapCut이 옮긴 클립에 렌더순서를 새로(맨 위로) 매김 — 잠금으로 못 막음 | **캡컷에서 그 프로젝트를 닫고** → 웹 UI 하단 **"🩹 레이어 수리"** → 드래프트 선택 → 실행 → 캡컷에서 다시 열기 | | **영상 중간에 초록 화면이 몇 초 나옴** | yt-dlp가 키프레임 아닌 위치에서 잘라 참조 프레임이 없음 | 최신 `youtube.py`가 컷마다 자동 검증→재다운로드→정밀 재컷. 로그에 `🩹` 표시. 예전에 받은 영상은 다시 만들어야 함 | -| 댓글이 엉뚱한 장면에 뜬다 | 컷별 추천이 실패해 기존 전체 균등 배치로 폴백 | 검토 화면 상단 경고 확인. Gemini 한도 초과면 잠시 뒤 재시도 | -| 컷 섹션이 안 보이고 ⭐ 하나만 뜬다 | 추천 실패 폴백 | 위와 동일. `프롬프트/댓글_추천.md`를 고쳤다면 `{cuts}` `{comments}` `{k}` 가 남아 있는지 확인 | +| 컷 섹션에 🤖 배지가 하나도 없다 (⭐만 있거나 비어 있음) | 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/docs/superpowers/plans/2026-08-04-컷별-댓글-추천.md b/docs/superpowers/plans/2026-08-04-컷별-댓글-추천.md index 5c52695..c1554b3 100644 --- a/docs/superpowers/plans/2026-08-04-컷별-댓글-추천.md +++ b/docs/superpowers/plans/2026-08-04-컷별-댓글-추천.md @@ -1123,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` 추가** @@ -1169,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: 문법 검증** From c39b72518ff18cba3d9522fcdcc99d1cae3317e4 Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Tue, 4 Aug 2026 15:05:14 +0900 Subject: [PATCH 19/21] =?UTF-8?q?=EC=BB=B7=EB=B3=84=20=EC=B9=B4=EB=93=9C?= =?UTF-8?q?=20=EC=9E=A5=EC=88=98=20=EA=B3=B5=EC=8B=9D=EC=9D=84=20floor?= =?UTF-8?q?=EB=A1=9C=20=ED=86=B5=EC=9D=BC=20=E2=80=94=20round=EC=99=80=20?= =?UTF-8?q?=EC=96=B4=EA=B8=8B=EB=82=98=20=EC=B9=B4=EB=93=9C=EA=B0=80=20?= =?UTF-8?q?=EC=A1=B0=EC=9A=A9=ED=9E=88=20=EB=B2=84=EB=A0=A4=EC=A7=80?= =?UTF-8?q?=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recommend.quotas_for()는 round, pipeline._cards_by_cut()의 컷당 장수 상한은 floor를 써서 둘이 어긋났다. 5초 컷처럼 round가 floor보다 큰 쪽으로 갈리는 컷은 UI가 카드 2장을 고르게 하고도 빌드 단계에서 상한(1장)에 걸려 뒤 1장이 로그 없이 버려졌다. 사용자 결정에 따라 floor로 통일(카드 한 장이 항상 3초 이상 — 기존 _load_comment_cards 규칙과 동일)하고, 무음 제거로 컷이 압축돼 여전히 카드가 버려지는 경우(이건 불가피)를 컷별 배치 로그에 표시해 더 이상 조용히 사라지지 않게 했다. 관련 설계/계획 문서의 공식·테스트 기대값도 floor 기준으로 맞췄다. Co-Authored-By: Claude Opus 5 (1M context) --- capcut_agent/pipeline.py | 12 +++++++++--- capcut_agent/recommend.py | 10 ++++++++-- .../plans/2026-08-04-컷별-댓글-추천.md | 18 +++++++++--------- .../specs/2026-08-04-컷별-댓글-추천-design.md | 5 ++++- 4 files changed, 30 insertions(+), 15 deletions(-) diff --git a/capcut_agent/pipeline.py b/capcut_agent/pipeline.py index e95164d..3d87a9e 100644 --- a/capcut_agent/pipeline.py +++ b/capcut_agent/pipeline.py @@ -519,9 +519,15 @@ async def process_paste( # 컷 소속이 지정됐으면 그 컷 구간 안, 아니면 폴더 전체 균등 배치 cards = cut_cards or _load_comment_cards(comments_dir, timeline_dur, fixed=cards_fixed) if cards: - yield {"type": "log", - "msg": f"댓글 카드 {len(cards)}개 하단 삽입" - + ("(컷별 배치)" if cut_cards else "(전체 균등)")} + 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/recommend.py b/capcut_agent/recommend.py index f7e4b60..ac48e9b 100644 --- a/capcut_agent/recommend.py +++ b/capcut_agent/recommend.py @@ -21,8 +21,14 @@ 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장. + + 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]]], diff --git a/docs/superpowers/plans/2026-08-04-컷별-댓글-추천.md b/docs/superpowers/plans/2026-08-04-컷별-댓글-추천.md index c1554b3..e5c6522 100644 --- a/docs/superpowers/plans/2026-08-04-컷별-댓글-추천.md +++ b/docs/superpowers/plans/2026-08-04-컷별-댓글-추천.md @@ -160,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"}]` @@ -172,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 언급 @@ -226,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]], @@ -584,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") ``` @@ -1293,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 index fd319a5..75a4487 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 배치 — 시간은 파이프라인이 계산한다 From 93fa0a3a218f76534dc9b9ae171cf4557c928a05 Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Tue, 4 Aug 2026 15:06:30 +0900 Subject: [PATCH 20/21] =?UTF-8?q?docs:=20=EC=8A=A4=ED=8E=99=20=C2=A74.4=20?= =?UTF-8?q?=EB=A5=BC=20=EC=88=98=EC=A0=95=20=ED=9B=84=20=EC=8B=A4=EC=A0=9C?= =?UTF-8?q?=20=EB=B0=B0=EC=B9=98=20=EB=B0=A9=EC=8B=9D=EC=97=90=20=EB=A7=9E?= =?UTF-8?q?=EA=B2=8C=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 무음 제거 시 카드 '시간'을 _remap_caps 로 옮긴다고 적혀 있었으나, 그러면 cards_fixed(3초 고정)가 압축되며 깨진다. 실제로는 _remap_placements 로 컷 '구간'을 먼저 옮기고 그 안에서 나눈다. 장수 캡과 버림 로그도 명시. Co-Authored-By: Claude Opus 5 (1M context) --- docs/superpowers/specs/2026-08-04-컷별-댓글-추천-design.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-08-04-컷별-댓글-추천-design.md b/docs/superpowers/specs/2026-08-04-컷별-댓글-추천-design.md index 75a4487..9a89ede 100644 --- a/docs/superpowers/specs/2026-08-04-컷별-댓글-추천-design.md +++ b/docs/superpowers/specs/2026-08-04-컷별-댓글-추천-design.md @@ -112,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()` 그대로 컷 하나가 추천 부족으로 덜 차도 다음 컷 카드가 앞으로 밀리지 않는다 — 지금 방식의 약점이 여기서 사라진다. From 20a7c0c0a2e0053a7354b6dd5a90a3a67daf4e1c Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Tue, 4 Aug 2026 16:05:41 +0900 Subject: [PATCH 21/21] =?UTF-8?q?feat:=20=EB=8C=93=EA=B8=80=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=ED=99=94=EB=A9=B4=EC=97=90=20=EA=B2=80=EC=83=89(?= =?UTF-8?q?=ED=95=84=ED=84=B0)=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 카드가 수백~수천 장이라 원하는 댓글을 눈으로 찾기 어려웠다. 패널마다 검색창을 두고 모든 카드 섹션이 자기 전체 목록에서 필터링한다 — '더보기'로 안 펼친 카드도 검색되면 나온다. 캡처 누락 방지: 빌드/캡처는 wrapOf() 로 DOM에서 카드를 찾고 없으면 조용히 건너뛴다. 검색으로 선택 카드가 DOM에서 빠질 수 있으므로 buildAll 과 ytCC.capture 시작 시 clearSearch() 로 강제 해제한다. Co-Authored-By: Claude Opus 5 (1M context) --- .../specs/2026-08-04-댓글검색-design.md | 40 ++++++++++++ server/static/auto.js | 63 +++++++++++++++++-- server/static/index.html | 6 ++ 3 files changed, 103 insertions(+), 6 deletions(-) create mode 100644 docs/superpowers/specs/2026-08-04-댓글검색-design.md 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/server/static/auto.js b/server/static/auto.js index e73cf46..c3780ae 100644 --- a/server/static/auto.js +++ b/server/static/auto.js @@ -63,6 +63,16 @@ function wrapOf(hlId,idx){ const CARD_PAGE=30; // '좋아요 상위에서 채우기' 섹션은 컷마다 반복된다 — 초기 렌더를 줄여 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; @@ -71,25 +81,62 @@ function cardSection(label,list,hlId,firstBatch,ci){ 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 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] 는 항상 컷 순서로 정렬해 둔다 (업로드 순서 = 배치 순서라서). */ @@ -490,6 +537,7 @@ function onResult(ev){ box.dataset.titles=JSON.stringify(opts); // 영상 편집안(컷 목록·JSON) — 선택 요약 바 위에, 기본 접힘 box.insertBefore(cutsSection(hl),$("#hlsel-"+hl.id)); + box.appendChild(searchBar(hl.id)); // 카드 섹션들 위 — cardSection 보다 먼저 만들어야 SECS 초기화됨 const WHY={ts:"⭐",ai:"🤖",like:"➕"}; const cand=(hl.candidates||[]).filter(i=>byIdx[i]!==undefined); if(hl.cuts){ @@ -589,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{ @@ -716,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))); @@ -733,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;}