From 0a0b43efc22d47831be464dc454323a20feee909 Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Tue, 4 Aug 2026 12:18:33 +0900 Subject: [PATCH] =?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(