카드 캡처는 댓글 길이만큼 세로로 자라는데 배치 높이 한계(영상 아래~출처 사이 약 640px)가 어디에도 없어 장문 댓글이 출처(@채널)와 화면을 덮었다. 1차: 카드 본문을 최대 5줄 말줄임(검토 화면 = 캡처 결과라 WYSIWYG). 2차: 그래도 표시 높이가 한계를 넘는 카드는 draft 배치에서 비율 축소. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
879 lines
38 KiB
Python
879 lines
38 KiB
Python
"""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
|
||
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
|
||
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: # 서브제목: 주황, 크기 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/<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",
|
||
}
|
||
|
||
|
||
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)
|