capcut-agent/capcut_agent/draft.py

960 lines
42 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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%)
# 카드 아랫변이 넘으면 안 되는 y(px) — 출처(@채널, 파이프라인 기준 y≈1800) 위 여유까지.
# 캡처 쪽 5줄 말줄임이 1차 방어지만, 예외적으로 큰 카드(줄바꿈 연타 등)가 와도
# 여기서 비율 축소해 출처를 절대 덮지 않는다(안전망).
COMMENT_MAX_BOTTOM = 1760
# 하단 검은 배경 자막 글꼴 크기 — 고정값(캡컷 폰트 크기와 1:1).
# 자동 맞춤(fit_caption_size)은 드래프트마다 크기가 달라져서 고정으로 바꿈.
# 청킹 하드캡이 14자라 크기 10 이면 한 줄 폭 ≈ 14×51.5 ≈ 721px < 1080 → 넘칠 일 없음.
CAPTION_SIZE = 12.0
# 하단 자막 스타일(캡컷 인스펙터 실측과 1:1): 제주명조체 · 흰색 · 검은 획 40 · 배경/그림자 없음.
CAPTION_COLOR = (1.0, 1.0, 1.0) # 흰색
CAPTION_BORDER_W = 40.0 # 획(외곽선) 두께 — 캡컷 UI 값과 동일 스케일
# 자막 한 줄 맞춤: 캡컷 글꼴크기 ↔ 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,
logo_image: Optional[str] = None,
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)
politics_style = bool(logo_image)
# 제목 두 줄(윗줄 포인트색 + 아랫줄 흰색)은 비어도 자리표시로 항상 표시 → 캡컷에서 편집
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(위) → [logo].
if has_bg:
script.add_track(p.TrackType.video, "bg", relative_index=0)
base_ri = 1 if has_bg else 0
script.add_track(p.TrackType.video, "main", relative_index=base_ri)
script.add_track(p.TrackType.video, "frame", relative_index=base_ri + 1)
if logo_image and os.path.isfile(logo_image):
script.add_track(p.TrackType.video, "logo", relative_index=base_ri + 2)
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 인자 생략) + 흰색 + 검은 획 40. 폰트는 저장 후 제주명조체 주입.
cap_style = p.TextStyle(size=18.0 if politics_style else CAPTION_SIZE,
bold=False, color=CAPTION_COLOR, align=1)
cap_border = p.TextBorder(color=(0.0, 0.0, 0.0), width=CAPTION_BORDER_W)
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, border=cap_border,
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")
# 정치팩트랩 로고: 기준 영상처럼 하단 검정 영역 중앙에 고정한다.
if logo_image and os.path.isfile(logo_image):
script.add_segment(p.VideoSegment(
p.VideoMaterial(logo_image), p.Timerange(0, total_us),
clip_settings=p.ClipSettings(scale_x=0.27, scale_y=0.27,
transform_x=0.0, transform_y=_ty(1640, ch)),
), "logo")
# 댓글 카드: 하단 띠에 순서대로. 확대 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
eff = COMMENT_SCALE
if comment_top is not None:
iw, ih = _img_wh(img)
disp_h = cw * (ih / iw) * eff
max_h = COMMENT_MAX_BOTTOM - comment_top
if 0 < max_h < disp_h: # 긴 카드 — 출처를 덮기 전에 그 카드만 축소
eff *= max_h / disp_h
disp_h = max_h
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=eff, scale_y=eff,
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: # 정치: 흰색 / 예능: 주황
script.add_segment(p.TextSegment(
title_top, full,
style=p.TextStyle(size=17.0 if politics_style else 14.0, bold=True,
color=((1.0, 1.0, 1.0) if politics_style else 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: # 정치: 빨강 / 예능: 흰색
script.add_segment(p.TextSegment(
title_main, full,
style=p.TextStyle(size=20.0 if politics_style else 18.0, bold=True,
color=((1.0, 0.18, 0.18) if politics_style else (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=12.0 if politics_style else 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_font_to_track(draft_dir, "caption",
CAFE24_DANJUNGHAE if politics_style else JEJU_MYEONGJO)
if politics_style:
_apply_shadow_to_track(draft_dir, "caption", _TEXT_SHADOW)
# 예능 하단 자막은 그림자 없음(획 40 으로 대체).
_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", "logo", "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/<GUID>/` 아래로 옮겨갔다. 실측 근거(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",
}
# 제주명조체(JejuMyeongjo) — 하단 자막 전용. 코트라 볼드체와 같은 CapCut 폰트 캐시 방식.
# 캐시가 없는 PC 에서는 주입이 생략돼 코트라 볼드체(전체 주입분)가 그대로 남는다.
JEJU_MYEONGJO = {
"path": os.path.join(
os.environ.get("LOCALAPPDATA", ""), "CapCut", "User Data", "Cache", "effect",
"7480851064179133702", "6ab2b6b0bd71a41109ad5506d600f257", "font.ttf",
).replace("\\", "/"),
"id": "7480851064179133702",
}
# 카페24 단정해 — 정치 숏폼 자막. CapCut에서 사용자가 맞춘 기준 드래프트의
# 실제 폰트 소재 ID/캐시 경로를 그대로 사용한다.
CAFE24_DANJUNGHAE = {
"path": os.path.join(
os.environ.get("LOCALAPPDATA", ""), "CapCut", "User Data", "Cache", "effect",
"7528305055972199681", "0e4893968fe2d82714917f69c69826aa", "font.ttf",
).replace("\\", "/"),
"id": "7528305055972199681",
}
def _text_material_ids(j: dict, track_name: str) -> set:
"""지정 텍스트 트랙 세그먼트들의 material_id 집합."""
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", []))
return ids
def _apply_font_to_track(draft_dir: str, track_name: str, font: dict) -> None:
"""지정 텍스트 트랙의 소재에만 폰트 주입(_apply_font_to_texts 의 트랙 한정판).
전체 주입(코트라 볼드체) 뒤에 호출해 하단 자막만 다른 폰트로 덮어쓴다.
"""
import json
if not font.get("path") or not os.path.isfile(font["path"]):
return
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 = _text_material_ids(j, track_name)
if not ids:
return
for m in j["materials"].get("texts", []):
if m.get("id") not in ids:
continue
try:
c = json.loads(m["content"])
except Exception: # noqa: BLE001 — 파싱 안 되는 소재는 건너뜀
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)
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 = _text_material_ids(j, track_name)
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)