"""pycapcut 으로 점프컷 CapCut 드래프트 생성. 함정 회피: - Timerange 는 정수 µs 로 직접 구성 (float→tim 은 '초'가 아니라 µs 반올림이므로). - target(타임라인) 커서를 µs 정수로 누적 → 세그먼트 사이 1µs 갭/오버랩 방지(tm_duration). - 드래프트 캔버스를 원본 해상도와 동일하게 → transform/scale 불필요(transform_y 함정 회피). """ from __future__ import annotations import os from typing import List, Optional, Tuple import pycapcut as p from .probe import VideoMeta Segment = Tuple[float, float] # (src_start_sec, src_end_sec, text|None) Clip = Tuple[float, float, Optional[str]] # CapCut(Windows) 드래프트 루트 DEFAULT_DRAFT_ROOT = os.path.join( os.environ["LOCALAPPDATA"], "CapCut", "User Data", "Projects", "com.lveditor.draft" ) def _us(seconds: float) -> int: return round(seconds * p.SEC) def build_jumpcut_draft( video_path: str, speech_segments: List[Segment], meta: VideoMeta, draft_name: str, *, draft_root: str = DEFAULT_DRAFT_ROOT, ) -> str: """발화 구간만 이어붙인 점프컷 드래프트를 생성하고 경로 반환.""" if not speech_segments: raise ValueError("speech_segments 가 비어 있습니다 (감지된 발화 없음).") folder = p.DraftFolder(draft_root) script = folder.create_draft( draft_name, meta.width, meta.height, fps=meta.fps, allow_replace=True ) script.add_track(p.TrackType.video) material = p.VideoMaterial(video_path) cursor_us = 0 # 타임라인 커서(µs 정수 누적 → 갭 방지) for s, e in speech_segments: src_start = _us(s) dur = _us(e) - src_start # 길이도 같은 반올림으로 일관 if dur <= 0: continue source = p.Timerange(src_start, dur) target = p.Timerange(cursor_us, dur) script.add_segment(p.VideoSegment(material, target, source_timerange=source)) cursor_us += dur script.save() return os.path.join(draft_root, draft_name) # 숏폼 세로 캔버스 기본값 (9:16) SHORT_W, SHORT_H = 1080, 1920 def _cover_scale(src_w: int, src_h: int, canvas_w: int, canvas_h: int) -> float: """canvas 를 가득 채우는(cover) scale. CapCut scale=1.0 == contain(여백맞춤) 가정. src 가 canvas 보다 가로로 넓으면 scale=1 에서 가로가 맞고 위아래 여백 → 세로를 채우려면 그만큼 키운다. 반대도 동일. 결과는 중앙 크롭. ※ scale 의미는 CapCut 렌더로 실측 검증 필요(transform 함정 구역). """ src_ar = src_w / src_h canvas_ar = canvas_w / canvas_h if src_ar > canvas_ar: # src 가 더 넓음 → 세로를 채우려 키움 return (canvas_h * src_ar) / canvas_w else: # src 가 더 좁음/김 → 가로를 채우려 키움 return (canvas_w / src_ar) / canvas_h # 자막 위치(세로 하단). transform_y 방향은 CapCut 렌더로 실측 검증 필요. CAPTION_Y = -0.62 # 제목 윗줄 포인트색(주황). RGB 0~1. TITLE_ACCENT = (1.0, 0.62, 0.05) def _ty(y_px: float, canvas_h: int = 1920) -> float: """픽셀 y → CapCut 정규화 transform_y. (+ 위 / − 아래, 0=중앙) 가정.""" return round((canvas_h / 2 - y_px) / (canvas_h / 2), 4) # 템플릿 텍스트 위치(픽셀 기준 → transform_y). 레이아웃: media.to_template 와 맞춤. # 상단 띠 0~410 / 영상 410~1410 / 하단 띠 1410~1920 TPL_TITLE_TOP_Y = _ty(150) # 윗줄(작게, 대괄호) ≈ +0.84 TPL_TITLE_MAIN_Y = _ty(285) # 아랫줄(크게) ≈ +0.70 TPL_CAPTION_Y = _ty(1300) # 영상 하단부 자막 ≈ -0.35 TPL_CHANNEL_Y = _ty(1520) # 하단 띠 채널/출처 ≈ -0.58 # 레이아웃(워크맨 스타일): 상단 제목띠 / 영상(위로) / 하단 큰 영역(댓글캡쳐 직접) / 출처 # 캡컷 인스펙터 위치 Y = transform.y × 1920 LAYOUT_TOP_BAR = 440 # 상단 제목 띠 LAYOUT_BOTTOM_TOP = 1100 # 하단 띠 시작 → 영상 영역 = 440~1100, 하단 1100~1920(댓글+출처) BG_TITLE_TOP_Y = _ty(150) # 제목 윗줄 ≈ +0.84 BG_TITLE_MAIN_Y = _ty(320) # 제목 아랫줄 ≈ +0.67 BG_VIDEO_Y = _ty(770) # 영상 기본 위치(영상 영역 중앙, 위로) ≈ +0.20 BG_CAPTION_Y = _ty(1030) # 영상 하단부 자막 ≈ -0.07 BG_CHANNEL_Y = _ty(1850) # 맨 아래 출처 ≈ -0.93 (하단 댓글영역 아래) COMMENT_SCALE = 0.89 # 댓글 카드 확대(캡컷 인스펙터 89%) # 하단 검은 배경 자막 글꼴 크기 — 고정값(캡컷 폰트 크기와 1:1). # 자동 맞춤(fit_caption_size)은 드래프트마다 크기가 달라져서 고정으로 바꿈. # 청킹 하드캡이 14자라 크기 10 이면 한 줄 폭 ≈ 14×51.5 ≈ 721px < 1080 → 넘칠 일 없음. CAPTION_SIZE = 12.0 CAPTION_COLOR = (1.0, 128 / 255, 0.0) # #ff8000 주황 # 자막 한 줄 맞춤: 캡컷 글꼴크기 ↔ Malgun Bold 픽셀 보정(size13≈67px → 5.15px/unit) CAPTION_PX_PER_UNIT = 5.15 CAPTION_TARGET_PX = 1000 # 한 줄 목표 폭(1080 캔버스에 여유) _MALGUN_BD = "C:/Windows/Fonts/malgunbd.ttf" def fit_caption_size(texts: List[str], *, max_size: float = 13.0, min_size: float = 7.0) -> float: """가장 긴 자막도 한 줄(≤CAPTION_TARGET_PX)에 들어가는 최대 글꼴 크기. ※ 현재 미사용 — 하단 자막은 CAPTION_SIZE 고정. 자동 맞춤으로 되돌릴 때 쓴다. """ texts = [t for t in texts if t] if not texts or not os.path.exists(_MALGUN_BD): return max_size from PIL import ImageFont # 지연 import def width(t: str, px: int) -> int: bb = ImageFont.truetype(_MALGUN_BD, max(1, px)).getbbox(t) return bb[2] - bb[0] longest = max(texts, key=lambda t: width(t, 50)) s = max_size while s > min_size and width(longest, round(s * CAPTION_PX_PER_UNIT)) > CAPTION_TARGET_PX: s -= 0.5 return s def _img_wh(path: str) -> Tuple[int, int]: """이미지 픽셀 크기(w, h). 댓글 카드 폭 맞춤 축소에 사용.""" from PIL import Image # 지연 import with Image.open(path) as im: return im.width, im.height def build_bg_template_draft( clip_path: str, bg_image_path: Optional[str], frame_image_path: str, video_clips: List[Tuple[float, float]], captions: List[Tuple[float, float, str]], meta: VideoMeta, draft_name: str, *, title_top: Optional[str] = None, title_main: Optional[str] = None, channel: Optional[str] = None, video_y: float = BG_VIDEO_Y, video_scale: float = 1.0, flip_horizontal: bool = False, title_top_y: float = BG_TITLE_TOP_Y, title_main_y: float = BG_TITLE_MAIN_Y, caption_y: float = BG_CAPTION_Y, channel_y: float = BG_CHANNEL_Y, effect_captions: Optional[List[Tuple[float, float, str]]] = None, effect_y: Optional[float] = None, comment_cards: Optional[List[Tuple[float, float, str]]] = None, comment_y: Optional[float] = None, comment_top: Optional[float] = None, bg_white: bool = False, canvas: Tuple[int, int] = (SHORT_W, SHORT_H), draft_root: str = DEFAULT_DRAFT_ROOT, ) -> str: """움직일 수 있는 영상 + 투명 가운데 프레임(검은 띠, 위) + 텍스트. - 영상(main): scale 1.0, transform 0. 사용자가 캡컷에서 확대/좌우 이동. - frame(검은 띠+투명 가운데): 영상 위 → 영상이 띠 영역을 침범해도 항상 깔끔. - bg(검정-흰색-검정, 선택): 주면 맨 아래에 깔아 빈 곳을 흰색으로. None 이면 생략(빈 곳 검정). 렌더 순서(render_index): [bg] < main < frame < text. """ if not video_clips: raise ValueError("video_clips 가 비어 있습니다.") cw, ch = canvas has_bg = bool(bg_image_path) # 제목 두 줄(윗줄 포인트색 + 아랫줄 흰색)은 비어도 자리표시로 항상 표시 → 캡컷에서 편집 if not title_main: title_main = "메인제목" if not title_top: title_top = "서브제목" folder = p.DraftFolder(draft_root) script = folder.create_draft(draft_name, cw, ch, fps=meta.fps, allow_replace=True) # 영상 타입 트랙: [bg] → main → frame(위). 텍스트는 자동으로 더 위. if has_bg: script.add_track(p.TrackType.video, "bg", relative_index=0) script.add_track(p.TrackType.video, "main", relative_index=1 if has_bg else 0) script.add_track(p.TrackType.video, "frame", relative_index=2 if has_bg else 1) if comment_cards: # 댓글 카드(검은 띠 위) — 프레임보다 위 레이어 script.add_track(p.TrackType.video, "comment", relative_index=3 if has_bg else 2) # 텍스트 트랙은 각각 다른 층(render_index)으로 → 겹침/누락 방지 script.add_track(p.TrackType.text, "caption", relative_index=1) if title_top: script.add_track(p.TrackType.text, "title_top", relative_index=2) if title_main: script.add_track(p.TrackType.text, "title_main", relative_index=3) if channel: script.add_track(p.TrackType.text, "channel", relative_index=4) if effect_captions: script.add_track(p.TrackType.text, "effect", relative_index=5) # 영상 소재 로드 + 실제 소재 길이(µs)로 컷 끝 클램프 # ffprobe 가 보고한 길이가 CapCut 이 보는 소재 길이보다 살짝 길 때(마지막 프레임) # source_timerange 가 소재 길이를 초과해 에러 나는 것 방지. material = p.VideoMaterial(clip_path) mat_us = getattr(material, "duration", 0) or 0 clips_us = [] for s, e in video_clips: s_us, e_us = _us(s), _us(e) if mat_us: e_us = min(e_us, mat_us) if e_us - s_us > 0: clips_us.append((s_us, e_us)) # 타임라인 길이 = 보존 비디오 구간 합(클램프 반영) total_us = sum(e - s for s, e in clips_us) # 흰 배경(맨 아래, 전체 길이) — 선택 if has_bg: bg_mat = p.VideoMaterial(bg_image_path) script.add_segment(p.VideoSegment(bg_mat, p.Timerange(0, total_us)), "bg") # 영상(점프컷): 보존 구간 이어붙임. 확대 + 영상영역 중앙. 캡컷에서 추가 조정 가능. vclip = p.ClipSettings(scale_x=video_scale, scale_y=video_scale, transform_y=video_y, flip_horizontal=flip_horizontal) cursor_us = 0 for s_us, e_us in clips_us: dur = e_us - s_us script.add_segment(p.VideoSegment( material, p.Timerange(cursor_us, dur), source_timerange=p.Timerange(s_us, dur), clip_settings=vclip, ), "main") cursor_us += dur # 자막: 검은 배경 박스 + 흰 글씨(레퍼런스 스타일), 영상 위. # bottom 에 \n 있으면 '같은 자리·시간 분할'로 순서대로: 앞 절반 윗줄 → 뒤 절반 아랫줄. def _cap_lines(t): # 진짜 줄바꿈뿐 아니라 글자 그대로의 \n \r (LLM 이중 이스케이프)도 줄바꿈으로 처리 t = (t or "") for a, b in (("\\r\\n", "\n"), ("\\n", "\n"), ("\\r", "\n"), ("\r\n", "\n"), ("\r", "\n")): t = t.replace(a, b) return [ln.strip() for ln in t.split("\n") if ln.strip()] # 배경 박스 없음(background 인자 생략) + 주황 #ff8000 + 그림자(저장 후 주입). cap_style = p.TextStyle(size=CAPTION_SIZE, bold=True, color=CAPTION_COLOR, align=1) for ts, te, text in captions: if _us(te) - _us(ts) <= 0 or not text: continue lines = _cap_lines(text) n = len(lines) for i, ln in enumerate(lines): # 구간을 줄 수만큼 균등 분할 → i번째 줄이 i번째 시간대에 표시(같은 위치) a = ts + (te - ts) * i / n b = ts + (te - ts) * (i + 1) / n seg = _us(b) - _us(a) if seg <= 0: continue script.add_segment(p.TextSegment( ln, p.Timerange(_us(a), seg), style=cap_style, clip_settings=p.ClipSettings(transform_y=caption_y), ), "caption") # 효과 자막(중앙): 하단 자막 '바로 위'. 주황 볼드 + 검은 외곽선(배경박스 없음). if effect_captions: eff_y = effect_y if effect_y is not None else (caption_y + 0.11) eff_style = p.TextStyle(size=13.0, bold=False, color=(13/255, 255/255, 99/255), align=1) # #0dff63 녹색 eff_border = p.TextBorder(color=(0.0, 0.0, 0.0), width=18.0) for ts, te, text in effect_captions: dur = _us(te) - _us(ts) if dur <= 0 or not text: continue script.add_segment(p.TextSegment( text, p.Timerange(_us(ts), dur), style=eff_style, border=eff_border, clip_settings=p.ClipSettings(transform_y=eff_y), ), "effect") # 프레임(상하 검은 띠 + 투명 가운데) — 영상 위, 전체 길이 frame_mat = p.VideoMaterial(frame_image_path) script.add_segment(p.VideoSegment(frame_mat, p.Timerange(0, total_us)), "frame") # 댓글 카드: 하단 띠에 순서대로. 확대 89% 고정, X 0. # 세로 위치는 comment_top(윗변 픽셀)이 주어지면 **카드마다** 계산 — # 카드 이미지 높이가 제각각이라 중앙값 하나로는 "영상 바로 아래"에 못 붙인다. # CapCut scale 1.0 = contain. 댓글 카드는 캔버스(9:16)보다 가로로 넓으므로 # 가로가 먼저 맞아 표시 높이 = 1080 × (h/w) × scale. if comment_cards: for ts, te, img in comment_cards: dur = _us(te) - _us(ts) if dur <= 0 or not img or not os.path.isfile(img): continue if comment_top is not None: iw, ih = _img_wh(img) disp_h = cw * (ih / iw) * COMMENT_SCALE cy = _ty(comment_top + disp_h / 2, ch) else: cy = comment_y if comment_y is not None else round(-1162/1920, 4) script.add_segment(p.VideoSegment( p.VideoMaterial(img), p.Timerange(_us(ts), dur), clip_settings=p.ClipSettings(scale_x=COMMENT_SCALE, scale_y=COMMENT_SCALE, transform_x=0.0, transform_y=cy), ), "comment") full = p.Timerange(0, total_us) # 제목 두 줄: 윗줄 = 주황 포인트색, 아랫줄 = 흰색. 둘 다 크게+볼드+검은 외곽선. # 흰 배경일 땐: 메인제목 외곽선 두께 50, 채널 글씨 검정(안 보임 방지), 서브제목 그림자. main_border_w = 50.0 if bg_white else 18.0 channel_color = (0.0, 0.0, 0.0) if bg_white else (1.0, 1.0, 1.0) if title_top: # 서브제목: 주황, 크기 14 script.add_segment(p.TextSegment( title_top, full, style=p.TextStyle(size=14.0, bold=True, color=TITLE_ACCENT, align=1), border=p.TextBorder(color=(0.0, 0.0, 0.0), width=18.0), clip_settings=p.ClipSettings(transform_y=title_top_y), ), "title_top") if title_main: # 메인제목: 흰색, 크기 18 script.add_segment(p.TextSegment( title_main, full, style=p.TextStyle(size=18.0, bold=True, color=(1.0, 1.0, 1.0), align=1), border=p.TextBorder(color=(0.0, 0.0, 0.0), width=main_border_w), clip_settings=p.ClipSettings(transform_y=title_main_y), ), "title_main") if channel: # 이미지 설정: 글꼴 크기 10, 가운데 (흰 배경이면 검정) script.add_segment(p.TextSegment( channel, full, style=p.TextStyle(size=10.0, bold=False, color=channel_color, align=1), clip_settings=p.ClipSettings(transform_y=channel_y), ), "channel") script.save() draft_dir = os.path.join(draft_root, draft_name) _apply_font_to_texts(draft_dir, KOTRA_BOLD) # 모든 텍스트에 코트라 볼드체 주입 _apply_shadow_to_track(draft_dir, "caption", _TEXT_SHADOW) # 하단 자막 그림자 _lock_tracks(draft_dir, LOCK_TRACKS) # 오버레이·제목 트랙 잠금(편집 중 레이어 꼬임 방지) if bg_white and title_top: # 서브제목에 그림자 주입(캡컷 실측 형식) _apply_shadow_to_text(draft_dir, title_top, _TEXT_SHADOW) return draft_dir # 편집 중 잠글 트랙: CapCut에서 이 트랙들이 실수로 쪼개지면(예: 재생헤드 전체 분할) # CapCut이 render_index 를 다시 매기다 main 영상을 frame 위로 올려버려 영상이 흰 띠·댓글 # 위로 삐져나오는 버그가 있었다. 미리 잠가 두면 분할/재배치가 막혀 레이어가 안 꼬인다. # main(영상 편집)·caption(자막 편집)·effect 는 편집해야 하므로 잠그지 않는다. # bg(흰 배경)도 잠그지 않는다 — 맨 아래 레이어라 꼬여도 화면에 영향이 없고, # 영상 길이를 늘릴 때 같이 늘려야 해서 잠겨 있으면 불편하다(사용자 요청). LOCK_TRACKS = ("frame", "comment", "title_top", "title_main", "channel") # 비디오 트랙 이름 → 정상 render_index(아래→위). 생성 시 pycapcut 이 매기는 값과 동일. # # ※ 한때 frame/comment 를 비디오 대역 밖(14500/14501)으로 올리고 텍스트를 24000+ 로 미는 # 방식을 넣었다가 사용자 요청으로 **원복**했다. 의도는 "복붙 클립이 받는 max+1 이 # 비디오 대역 안에서만 계산된다면 frame 아래에 갇힌다"였다. 다시 시도하려면 # 레이어_삐짐_수리.md 를 먼저 읽을 것 — 검증되지 않은 가정이다. CANON_RI = {"bg": 0, "main": 1, "frame": 2, "comment": 3} def timeline_jsons(draft_dir: str): """이 드래프트에서 render_index 를 담고 있는 JSON 파일 전부(최근 수정순 아님). ⚠ **루트 `draft_content.json` 만 고치면 CapCut 에 반영되지 않는다.** CapCut 9.x(`draft_meta_info.json` 의 `draft_new_version` 164+)부터 프로젝트 실데이터가 `Timelines//` 아래로 옮겨갔다. 실측 근거(2026-08-03): - `Timelines/` 폴더는 **CapCut 에서 한 번이라도 연** 드래프트에만 생긴다 (빌더가 막 만든 드래프트엔 없다 → 그래서 생성은 멀쩡했다). - 드래프트 `소지섭이 올리브를…(2)` 는 루트 `draft_content.json` 이 **아예 없는데도** CapCut 이 계속 편집 중이었다(`Timelines/…/template.json` 이 최신). - 저장 시각도 `template.json` 이 항상 가장 늦다 → 루트는 레거시 미러. 그래서 수리는 루트 + `Timelines/*/draft_content.json` + `Timelines/*/template.json` 을 **전부** 고쳐야 한다. `.tmp` 는 저장 중 임시파일이라 건드리지 않는다. """ out = [] root = os.path.join(draft_dir, "draft_content.json") if os.path.isfile(root): out.append(root) tl = os.path.join(draft_dir, "Timelines") if os.path.isdir(tl): for guid in sorted(os.listdir(tl)): gdir = os.path.join(tl, guid) if not os.path.isdir(gdir): continue for fn in ("draft_content.json", "template.json"): p = os.path.join(gdir, fn) if os.path.isfile(p): out.append(p) return out def list_drafts(draft_root: str = DEFAULT_DRAFT_ROOT): """드래프트 목록(최근 수정순). [{name, title, path, mtime, broken, bad}]. - `name` 폴더명, `title` CapCut 홈에 보이는 프로젝트 이름, `path` 실제 폴더 경로(수리 키) - `broken`/`bad` 레이어 꼬임 여부·개수 ⚠ **기본 폴더만 훑으면 안 된다.** CapCut 설정에서 저장 위치를 바꾸면 드래프트가 `%LOCALAPPDATA%\\CapCut\\…\\com.lveditor.draft` 밖(예: `D:/…/CapCut Drafts`)에 생긴다. 실측(2026-08-03): 프로젝트 `222222222222` 가 D 드라이브에 있어 목록에 안 떴다. 어디에 있든 `root_meta_info.json` 의 `all_draft_store[].draft_fold_path` 가 알고 있다. 또 캡컷에서 이름을 바꿔도 폴더명은 그대로라 `title` 과 `name` 이 갈린다 (예: 화면 `열심히 하는 나경 땜걸~ 찡긋` ↔ 폴더 `열심히 하는 나경`). """ out, seen = [], set() reg = _registered_drafts(draft_root) # {폴더경로: CapCut 홈에 뜨는 이름} def add(ddir: str) -> None: key = os.path.normcase(os.path.abspath(ddir)) if key in seen or not os.path.isdir(ddir): return files = timeline_jsons(ddir) if not files: return seen.add(key) bad, mtime = 0, 0.0 for jf in files: mtime = max(mtime, os.path.getmtime(jf)) try: # 파일마다 세는 값이 같으므로 합이 아니라 최댓값(꼬인 세그먼트 수) bad = max(bad, _count_bad_ri(jf)) except Exception: # noqa: BLE001 — 읽기 실패한 드래프트는 목록에만 노출 bad = max(bad, -1) if bad else -1 name = os.path.basename(ddir.rstrip("\\/")) # 이름은 CapCut 홈과 같게 — root_meta_info 가 정답이고 폴더 안 메타는 옛 이름이 남는다 title = (reg.get(key) or {}).get("name") or _draft_title(ddir) or name out.append({"name": name, "title": title, "path": os.path.abspath(ddir), "mtime": mtime, "broken": bad > 0, "bad": bad}) if os.path.isdir(draft_root): for name in os.listdir(draft_root): add(os.path.join(draft_root, name)) for v in reg.values(): add(v["path"]) out.sort(key=lambda d: d["mtime"], reverse=True) return out def _registered_drafts(draft_root: str = DEFAULT_DRAFT_ROOT) -> dict: """`root_meta_info.json` 에 등록된 드래프트 — {대조용 키: {"path", "name"}}. 키는 `normcase(abspath(...))`(대소문자 무시 대조용)이고, 표시·접근에는 원본 `path` 를 쓴다. CapCut 홈 화면이 읽는 인덱스라 기본 경로 밖 드래프트와 최신 이름이 여기에만 있다. """ import json p = os.path.join(draft_root, "root_meta_info.json") if not os.path.isfile(p): return {} try: store = json.load(open(p, encoding="utf-8")).get("all_draft_store") or [] except Exception: # noqa: BLE001 — 인덱스가 깨져도 기본 폴더 스캔은 살린다 return {} reg = {} for d in store: fp = (d or {}).get("draft_fold_path") or "" if fp: path = os.path.abspath(os.path.normpath(fp)) reg[os.path.normcase(path)] = {"path": path, "name": (d.get("draft_name") or "").strip()} return reg def _draft_title(draft_dir: str) -> str: """CapCut 홈에 보이는 프로젝트 이름(`draft_meta_info.json` 의 `draft_name`).""" import json mi = os.path.join(draft_dir, "draft_meta_info.json") if not os.path.isfile(mi): return "" try: return (json.load(open(mi, encoding="utf-8")).get("draft_name") or "").strip() except Exception: # noqa: BLE001 — 이름 못 읽으면 폴더명으로 대체 return "" def _count_bad_ri(json_path: str) -> int: """render_index 가 트랙 정상값과 다른 비디오 세그먼트 수.""" import json j = json.load(open(json_path, encoding="utf-8")) n = 0 for tr in j.get("tracks", []): if tr.get("type") != "video": continue want = CANON_RI.get(tr.get("name")) if want is None: continue n += sum(1 for s in tr.get("segments", []) if s.get("render_index") != want) return n def repair_layers(draft_dir: str) -> dict: """레이어 수리: 비디오 세그먼트 render_index 를 트랙별 정상값으로 되돌리고 재잠금. 왜 필요한가: CapCut 에서 main 클립을 옮기면(드래그/잘라붙이기) CapCut 이 그 세그먼트에 `현재 최대 render_index + 1` 을 새로 찍는다. 그러면 그 클립만 frame(흰 띠)·comment(댓글) 위로 올라가서 확대 시 템플릿 밖으로 삐져나온다. 트랙 잠금으로는 못 막는다 (main 은 편집해야 하므로 잠그지 않음). → 사후 수리가 유일한 확실한 방법. ⚠ CapCut 에서 해당 프로젝트를 '닫은 상태'로 실행할 것. 열어둔 채 수리하면 CapCut 이 메모리 상태로 다시 덮어쓴다. 백업은 `<파일명>.repair.bak` — `.bak`는 CapCut 자체 백업 파일명이라 쓰면 안 된다. ⚠ 루트 파일만이 아니라 `Timelines/*` 사본까지 전부 고친다(`timeline_jsons` 주석 참고). Returns: {"fixed": 고친 세그먼트 수, "detail": {트랙명: 개수}, "files": 고친 파일 수} """ files = timeline_jsons(draft_dir) if not files: raise FileNotFoundError(f"타임라인 JSON 없음(draft_content.json / Timelines): {draft_dir}") fixed, detail, patched = 0, {}, 0 for jf in files: n, d = _repair_one(jf) if n: patched += 1 # 파일마다 같은 내용이므로 합이 아니라 최댓값을 대표값으로 쓴다 if n > fixed: fixed, detail = n, d _lock_tracks(draft_dir, LOCK_TRACKS) # 편집 중 풀린 잠금도 다시 채움 return {"fixed": fixed, "detail": detail, "files": patched} def _repair_one(json_path: str): """타임라인 JSON 한 개의 비디오 render_index 를 정상값으로 되돌린다. Returns: (고친 세그먼트 수, {트랙명: 개수}). 고칠 게 없으면 파일을 건드리지 않는다(멱등). """ import json import shutil j = json.load(open(json_path, encoding="utf-8")) fixed, detail = 0, {} # 알려진 트랙은 고정값, 그 외 사용자가 추가한 비디오 트랙은 그 위(4, 5 …)로 밀어 유지 extra = max(CANON_RI.values()) + 1 for tr in j.get("tracks", []): if tr.get("type") != "video": continue name = tr.get("name") want = CANON_RI.get(name) if want is None: # 사용자가 추가한 오버레이 트랙 → 맨 위 유지 want, extra = extra, extra + 1 n = 0 for seg in tr.get("segments", []): if seg.get("render_index") != want: seg["render_index"] = want n += 1 if n: detail[name or "(이름없음)"] = n fixed += n if fixed: # CapCut 이 draft_content.json.bak 을 자기 백업으로 쓰므로 다른 이름을 쓴다 shutil.copyfile(json_path, os.path.splitext(json_path)[0] + ".repair.bak") with open(json_path, "w", encoding="utf-8") as f: json.dump(j, f, ensure_ascii=False) return fixed, detail def _lock_tracks(draft_dir: str, names) -> None: """지정 트랙을 잠금 처리 — 루트 + `Timelines/*` 사본 전부. 잠금은 트랙 `attribute` 의 비트4(=4). mute 비트(1)는 보존(OR). CapCut 실측 확인값. """ import json nameset = set(names) for jf in timeline_jsons(draft_dir): try: j = json.load(open(jf, encoding="utf-8")) except Exception: # noqa: BLE001 — 깨진 사본 하나 때문에 전체를 실패시키지 않는다 continue changed = False for tr in j.get("tracks", []): if tr.get("name") in nameset: attr = (tr.get("attribute") or 0) | 4 if attr != tr.get("attribute"): tr["attribute"], changed = attr, True if changed: with open(jf, "w", encoding="utf-8") as f: json.dump(j, f, ensure_ascii=False) # 코트라 볼드체(KOTRA_BOLD) — CapCut 폰트 캐시. pycapcut FontType엔 없어 JSON에 직접 주입. # 경로는 사용자명에 안 묶이게 LOCALAPPDATA 기반(다른 PC에서도 동작). 단 그 PC CapCut에 # 코트라 볼드체가 한 번 다운로드돼 캐시가 있어야 함(없으면 주입 생략 → 기본 폰트). KOTRA_BOLD = { "path": os.path.join( os.environ.get("LOCALAPPDATA", ""), "CapCut", "User Data", "Cache", "effect", "7480846567709265157", "782a91b14f1661b95e7e587be27f1af4", "font.ttf", ).replace("\\", "/"), "id": "7480846567709265157", } def _apply_font_to_texts(draft_dir: str, font: dict) -> None: """저장된 draft_content.json 의 모든 텍스트 재질 스타일에 폰트 주입. 폰트 캐시 파일이 없으면(다른 PC에 코트라체 미설치 등) 주입 생략 → 기본 폰트로 안전 동작. """ import json if not font.get("path") or not os.path.isfile(font["path"]): return jf = os.path.join(draft_dir, "draft_content.json") j = json.load(open(jf, encoding="utf-8")) for m in j["materials"].get("texts", []): try: c = json.loads(m["content"]) except Exception: continue for st in c.get("styles", []): st["font"] = dict(font) m["content"] = json.dumps(c, ensure_ascii=False) with open(jf, "w", encoding="utf-8") as f: json.dump(j, f, ensure_ascii=False) # 텍스트 그림자 — CapCut 실측 형식(색 검정, 불투명도 90%, 흐림 15%, 거리 5, 각도 -45). # 제목·하단 자막 공용. 반드시 _SHADOW_MATERIAL 과 **같이** 넣어야 실제로 켜진다. _TEXT_SHADOW = { "thickness_projection_angle": -45, "thickness_projection_enable": False, "diffuse": 0.025, "alpha": 0.9, "distance": 5.0, "content": {"render_type": "solid", "solid": {"color": [0, 0, 0]}}, "angle": -45, "thickness_projection_distance": 0, } # 그림자 켤 때 **소재 레벨**에도 같이 박아야 하는 값 — CapCut UI 로 켠 자막에서 실측. # ⚠ `styles[].shadows` 만 넣으면 CapCut 이 그림자를 안 켠다(`has_shadow=False` 라서). # 기존 제목 그림자 주입이 딱 이 상태였다 — JSON 엔 있는데 화면엔 안 나옴. _SHADOW_MATERIAL = { "has_shadow": True, "shadow_alpha": 0.8999999761581421, "shadow_angle": -45.0, "shadow_color": "#000000", "shadow_distance": 5.0, "shadow_point": {"x": 0.6363961030678928, "y": -0.6363961030678928}, "shadow_smoothing": 0.45000001788139343, "shadow_thickness_projection_angle": 0.0, "shadow_thickness_projection_distance": 0.0, "shadow_thickness_projection_enable": False, } def _apply_shadow_to_track(draft_dir: str, track_name: str, shadow: dict) -> None: """지정 텍스트 트랙의 모든 소재에 그림자 주입(소재 플래그 + styles[].shadows). 자막은 세그먼트가 수십 개라 텍스트 값으로 찾는 방식(_apply_shadow_to_text)을 못 쓴다 — 같은 문장이 여러 번 나올 수 있어서. 트랙 → material_id 로 잡는다. """ import json jf = os.path.join(draft_dir, "draft_content.json") if not os.path.isfile(jf): return j = json.load(open(jf, encoding="utf-8")) ids = set() for tr in j.get("tracks", []): if tr.get("type") == "text" and tr.get("name") == track_name: ids.update(s.get("material_id") for s in tr.get("segments", [])) if not ids: return for m in j["materials"].get("texts", []): if m.get("id") not in ids: continue m.update(_SHADOW_MATERIAL) try: c = json.loads(m["content"]) except Exception: # noqa: BLE001 — 파싱 안 되는 소재는 건너뜀 continue for st in c.get("styles", []): st["shadows"] = [dict(shadow)] m["content"] = json.dumps(c, ensure_ascii=False) with open(jf, "w", encoding="utf-8") as f: json.dump(j, f, ensure_ascii=False) def _apply_shadow_to_text(draft_dir: str, text_value: str, shadow: dict) -> None: """draft_content.json 에서 text_value 와 일치하는 텍스트 소재 스타일에 그림자 주입.""" import json jf = os.path.join(draft_dir, "draft_content.json") j = json.load(open(jf, encoding="utf-8")) target = (text_value or "").strip() for m in j["materials"].get("texts", []): try: c = json.loads(m["content"]) except Exception: continue if (c.get("text") or "").strip() != target: continue m.update(_SHADOW_MATERIAL) # 소재 플래그 없으면 CapCut 이 그림자를 안 켠다 for st in c.get("styles", []): st["shadows"] = [dict(shadow)] m["content"] = json.dumps(c, ensure_ascii=False) with open(jf, "w", encoding="utf-8") as f: json.dump(j, f, ensure_ascii=False) def build_template_draft( video_path: str, clips: List[Clip], meta: VideoMeta, draft_name: str, *, title_top: Optional[str] = None, title_main: Optional[str] = None, channel: Optional[str] = None, draft_root: str = DEFAULT_DRAFT_ROOT, ) -> str: """템플릿 드래프트: 굽힌 영상(scale 1.0) + 편집가능 제목(2줄)·자막·채널 텍스트. 영상 틀/검은 띠는 media.to_template 로 이미 구워져 있고(1080×1920), 여기서는 그 위에 캡컷에서 수정 가능한 텍스트만 올린다. 텍스트는 시간 겹침을 피해 트랙 분리: title_top / title_main / caption / channel. """ if not clips: raise ValueError("clips 가 비어 있습니다.") folder = p.DraftFolder(draft_root) script = folder.create_draft(draft_name, meta.width, meta.height, fps=meta.fps, allow_replace=True) script.add_track(p.TrackType.video) script.add_track(p.TrackType.text, "caption") if title_top: script.add_track(p.TrackType.text, "title_top") if title_main: script.add_track(p.TrackType.text, "title_main") if channel: script.add_track(p.TrackType.text, "channel") material = p.VideoMaterial(video_path) vclip = p.ClipSettings(scale_x=1.0, scale_y=1.0) # 이미 9:16 → 정확 일치 # 스타일 (편안한 톤: 흰 본문 + 검은 외곽선). 크기/색은 캡컷에서 미세조정 가능. cap_style = p.TextStyle(size=13.0, bold=True, color=(1.0, 1.0, 1.0), align=1) cap_border = p.TextBorder(color=(0.0, 0.0, 0.0), width=40.0) cursor_us = 0 total_us = 0 for c in clips: s, e = c[0], c[1] text = c[2] if len(c) > 2 else None dur = _us(e) - _us(s) if dur <= 0: continue target = p.Timerange(cursor_us, dur) script.add_segment(p.VideoSegment( material, target, source_timerange=p.Timerange(_us(s), dur), clip_settings=vclip, )) if text: script.add_segment(p.TextSegment( text, p.Timerange(cursor_us, dur), style=cap_style, border=cap_border, clip_settings=p.ClipSettings(transform_y=TPL_CAPTION_Y), ), "caption") cursor_us += dur total_us = cursor_us full = p.Timerange(0, total_us) # 제목(2줄) — 편안한 톤. 윗줄 작게/연하게, 아랫줄 크게. if title_top: script.add_segment(p.TextSegment( title_top, full, style=p.TextStyle(size=8.0, bold=False, color=(0.92, 0.92, 0.92), align=1), clip_settings=p.ClipSettings(transform_y=TPL_TITLE_TOP_Y), ), "title_top") if title_main: script.add_segment(p.TextSegment( title_main, full, style=p.TextStyle(size=16.0, bold=True, color=(1.0, 1.0, 1.0), align=1), border=p.TextBorder(color=(0.0, 0.0, 0.0), width=20.0), clip_settings=p.ClipSettings(transform_y=TPL_TITLE_MAIN_Y), ), "title_main") if channel: script.add_segment(p.TextSegment( channel, full, style=p.TextStyle(size=6.0, bold=False, color=(0.8, 0.8, 0.8), align=1), clip_settings=p.ClipSettings(transform_y=TPL_CHANNEL_Y), ), "channel") script.save() return os.path.join(draft_root, draft_name) def build_shortform_draft( video_path: str, clips: List[Clip], meta: VideoMeta, draft_name: str, *, draft_root: str = DEFAULT_DRAFT_ROOT, canvas: Tuple[int, int] = (SHORT_W, SHORT_H), captions: bool = True, ) -> str: """하이라이트 구간 clip 들을 이어붙인 9:16 중앙크롭 숏폼 드래프트. clips: (src_start_sec, src_end_sec, text|None) 리스트. text 가 있고 captions=True 면 해당 클립 구간에 자막(흰 글자 + 검은 외곽선, 하단 중앙) 번인. 점프컷과 동일하게 구간만 연결, 캔버스 세로 + 각 비디오에 center-crop. 자막은 별도 text 트랙에 클립 타임라인에 맞춰 배치 → 자동 싱크. """ if not clips: raise ValueError("clips 가 비어 있습니다.") cw, ch = canvas scale = _cover_scale(meta.width, meta.height, cw, ch) has_text = captions and any(len(c) > 2 and c[2] for c in clips) folder = p.DraftFolder(draft_root) script = folder.create_draft(draft_name, cw, ch, fps=meta.fps, allow_replace=True) script.add_track(p.TrackType.video) if has_text: script.add_track(p.TrackType.text) material = p.VideoMaterial(video_path) vclip = p.ClipSettings(scale_x=scale, scale_y=scale, transform_x=0.0, transform_y=0.0) cap_style = p.TextStyle(size=12.0, bold=True, color=(1.0, 1.0, 1.0), align=1) cap_border = p.TextBorder(color=(0.0, 0.0, 0.0), width=40.0) cap_clip = p.ClipSettings(transform_y=CAPTION_Y) cursor_us = 0 for c in clips: s, e = c[0], c[1] text = c[2] if len(c) > 2 else None src_start = _us(s) dur = _us(e) - src_start if dur <= 0: continue target = p.Timerange(cursor_us, dur) script.add_segment(p.VideoSegment( material, target, source_timerange=p.Timerange(src_start, dur), clip_settings=vclip, )) if has_text and text: script.add_segment(p.TextSegment( text, p.Timerange(cursor_us, dur), style=cap_style, border=cap_border, clip_settings=cap_clip, )) cursor_us += dur script.save() return os.path.join(draft_root, draft_name)