You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1302 lines
48 KiB
1302 lines
48 KiB
"""
|
|
JianYing Editor Skill - 高级封装
|
|
|
|
基于 pyJianYingDraft (PyPI) 封装的高级 API,提供:
|
|
- 多格式输出:剪映 / DaVinci Resolve / Premiere Pro / Final Cut Pro
|
|
- 自动打包素材 + 工程文件为 zip
|
|
- 简化的媒体导入、文字、特效、转场、关键帧操作
|
|
- 时间单位自动转换(秒/字符串 → 微秒)
|
|
- 枚举模糊匹配(中英文/同义词/fuzzy)
|
|
"""
|
|
|
|
import difflib
|
|
import json
|
|
import os
|
|
import platform
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import xml.etree.ElementTree as ET
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional, Tuple, Union
|
|
from urllib.parse import quote as urlquote
|
|
|
|
import pyJianYingDraft as draft
|
|
from pyJianYingDraft import (
|
|
AudioMaterial,
|
|
AudioSegment,
|
|
ClipSettings,
|
|
DraftFolder,
|
|
FilterType,
|
|
IntroType,
|
|
KeyframeProperty,
|
|
OutroType,
|
|
ScriptFile,
|
|
TextSegment,
|
|
TextStyle,
|
|
Timerange,
|
|
TrackType,
|
|
TransitionType,
|
|
VideoMaterial,
|
|
VideoSceneEffectType,
|
|
VideoSegment,
|
|
tim,
|
|
trange,
|
|
)
|
|
|
|
SEC = draft.SEC # 1_000_000 microseconds
|
|
|
|
# ── CapCut / 剪映兼容 ─────────────────────────────────────────
|
|
|
|
# 剪映(国内版)默认值
|
|
JIANYING_PLATFORM = {
|
|
"os": "mac",
|
|
"os_version": "",
|
|
"app_version": "7.8.0",
|
|
"app_source": "lv",
|
|
"device_id": "",
|
|
"hard_disk_id": "",
|
|
"mac_address": "",
|
|
"app_id": 3704,
|
|
}
|
|
|
|
# CapCut(海外版)默认值
|
|
CAPCUT_PLATFORM = {
|
|
"os": "mac",
|
|
"os_version": "",
|
|
"app_version": "8.4.0",
|
|
"app_source": "cc",
|
|
"device_id": "",
|
|
"hard_disk_id": "",
|
|
"mac_address": "",
|
|
"app_id": 359289,
|
|
}
|
|
|
|
# ── 导出目标 ────────────────────────────────────────────────
|
|
|
|
EXPORT_TARGETS = {
|
|
"jianying": "剪映 / CapCut",
|
|
"davinci": "DaVinci Resolve",
|
|
"premiere": "Adobe Premiere Pro",
|
|
"fcpro": "Final Cut Pro",
|
|
}
|
|
|
|
# ── 中英同义词映射 ──────────────────────────────────────────
|
|
|
|
EFFECT_SYNONYMS = {
|
|
"vintage": ["复古", "复古DV"],
|
|
"retro": ["复古", "复古DV"],
|
|
"light_leak": ["漏光"],
|
|
"blur": ["模糊", "高斯模糊"],
|
|
"glitch": ["故障", "信号故障"],
|
|
"shake": ["抖动", "画面抖动"],
|
|
"zoom": ["放大", "缩放"],
|
|
"flash": ["闪烁", "闪光"],
|
|
"vignette": ["暗角"],
|
|
"film_grain": ["胶片颗粒"],
|
|
}
|
|
|
|
TRANSITION_SYNONYMS = {
|
|
"dissolve": ["叠化"],
|
|
"fade": ["淡化", "淡入淡出"],
|
|
"wipe": ["擦除"],
|
|
"slide": ["滑动", "推移"],
|
|
"zoom": ["缩放"],
|
|
"spin": ["旋转", "中心旋转"],
|
|
"blur": ["模糊"],
|
|
"mix": ["混合"],
|
|
"flash": ["闪白"],
|
|
"ink": ["水墨"],
|
|
"page_turn": ["翻页", "上下翻页"],
|
|
}
|
|
|
|
FILTER_SYNONYMS = {
|
|
"vintage": ["复古", "胶片"],
|
|
"warm": ["暖色", "暖调"],
|
|
"cold": ["冷色", "冷调"],
|
|
"bw": ["黑白", "灰度"],
|
|
"cinematic": ["电影", "电影感"],
|
|
"japanese": ["日系"],
|
|
"retro": ["复古"],
|
|
"dreamy": ["梦幻"],
|
|
}
|
|
|
|
TEXT_ANIM_SYNONYMS = {
|
|
"typewriter": ["打字机", "打字机_I"],
|
|
"fade_in": ["渐显", "渐入"],
|
|
"pop": ["弹入", "弹出"],
|
|
"slide_up": ["上移", "向上滑动"],
|
|
"slide_down": ["下移", "向下滑动"],
|
|
"bounce": ["弹跳"],
|
|
"fly_in": ["飞入"],
|
|
"blur": ["模糊"],
|
|
"barrage": ["弹幕"],
|
|
}
|
|
|
|
|
|
# ── 工具函数 ────────────────────────────────────────────────
|
|
|
|
|
|
def _default_output_root() -> str:
|
|
"""默认输出目录:项目下 .jy-output/"""
|
|
return os.path.join(os.getcwd(), ".jy-output")
|
|
|
|
|
|
def _get_jianying_drafts_root() -> Optional[str]:
|
|
"""探测本机剪映/CapCut 草稿目录,优先 CapCut(海外版),回退剪映。返回 None 如果未安装。"""
|
|
if platform.system() == "Darwin":
|
|
candidates = [
|
|
os.path.expanduser("~/Movies/CapCut/User Data/Projects/com.lveditor.draft"),
|
|
os.path.expanduser("~/Movies/JianyingPro/User Data/Projects/com.lveditor.draft"),
|
|
]
|
|
elif platform.system() == "Windows":
|
|
candidates = [
|
|
os.path.join(os.environ.get("LOCALAPPDATA", ""), "CapCut", "User Data", "Projects", "com.lveditor.draft"),
|
|
os.path.join(os.environ.get("LOCALAPPDATA", ""), "JianyingPro", "User Data", "Projects", "com.lveditor.draft"),
|
|
]
|
|
else:
|
|
return None
|
|
for root in candidates:
|
|
if os.path.isdir(root):
|
|
return root
|
|
return None
|
|
|
|
|
|
def _detect_platform_from_drafts(drafts_root: str) -> Optional[dict]:
|
|
"""从已有草稿的 draft_info.json 中读取 platform 字段,用于匹配本机 CapCut/剪映版本。
|
|
|
|
只提取 platform 设备信息(device_id、mac_address、os_version),
|
|
不提取 version/new_version(由 CapCut 内部升级,不适合用于新草稿)。
|
|
"""
|
|
try:
|
|
for entry in sorted(os.listdir(drafts_root), reverse=True):
|
|
info_path = os.path.join(drafts_root, entry, "draft_info.json")
|
|
if os.path.isfile(info_path):
|
|
with open(info_path, "r", encoding="utf-8") as f:
|
|
d = json.load(f)
|
|
plat = d.get("platform")
|
|
if isinstance(plat, dict) and plat.get("app_id"):
|
|
return plat
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def _is_capcut_root(drafts_root: str) -> bool:
|
|
"""判断草稿目录是否属于 CapCut(海外版)而非剪映。"""
|
|
return "/CapCut/" in drafts_root or "\\CapCut\\" in drafts_root
|
|
|
|
|
|
def _patch_for_capcut78(draft_dir: str) -> None:
|
|
"""将 pyJianYingDraft 生成的 draft_content.json 转换为 CapCut/剪映兼容的 draft_info.json。
|
|
|
|
最小化修改:只更新 platform 和文件名,保留 pyJianYingDraft 原始 version/new_version。
|
|
"""
|
|
content_path = os.path.join(draft_dir, "draft_content.json")
|
|
info_path = os.path.join(draft_dir, "draft_info.json")
|
|
|
|
if not os.path.exists(content_path):
|
|
return
|
|
|
|
with open(content_path, "r", encoding="utf-8") as f:
|
|
d = json.load(f)
|
|
|
|
# 从已有草稿探测设备信息,否则用默认值
|
|
drafts_root = os.path.dirname(draft_dir)
|
|
detected_plat = _detect_platform_from_drafts(drafts_root)
|
|
|
|
if detected_plat:
|
|
plat = dict(detected_plat)
|
|
elif _is_capcut_root(drafts_root):
|
|
plat = dict(CAPCUT_PLATFORM)
|
|
else:
|
|
plat = dict(JIANYING_PLATFORM)
|
|
|
|
# 只更新 platform,不修改 version/new_version
|
|
d["platform"] = plat
|
|
d["last_modified_platform"] = dict(plat)
|
|
|
|
with open(info_path, "w", encoding="utf-8") as f:
|
|
json.dump(d, f, ensure_ascii=False)
|
|
|
|
os.remove(content_path)
|
|
|
|
# 创建 CapCut 所需的辅助文件和目录
|
|
_create_capcut_scaffold(draft_dir)
|
|
|
|
|
|
def _create_capcut_scaffold(draft_dir: str) -> None:
|
|
"""创建 CapCut/剪映草稿所需的辅助文件和空目录。"""
|
|
import time
|
|
now_sec = int(time.time())
|
|
|
|
# 必需的空目录
|
|
for subdir in ("Resources", "adjust_mask", "matting", "qr_upload",
|
|
"smart_crop", "subdraft"):
|
|
os.makedirs(os.path.join(draft_dir, subdir), exist_ok=True)
|
|
|
|
# draft_agency_config.json
|
|
agency_path = os.path.join(draft_dir, "draft_agency_config.json")
|
|
if not os.path.exists(agency_path):
|
|
with open(agency_path, "w", encoding="utf-8") as f:
|
|
json.dump({
|
|
"is_auto_agency_enabled": False,
|
|
"is_auto_agency_popup": False,
|
|
"is_single_agency_mode": False,
|
|
"marterials": None,
|
|
"use_converter": False,
|
|
"video_resolution": 720,
|
|
}, f, ensure_ascii=False)
|
|
|
|
# draft_biz_config.json (空文件)
|
|
biz_path = os.path.join(draft_dir, "draft_biz_config.json")
|
|
if not os.path.exists(biz_path):
|
|
open(biz_path, "w").close()
|
|
|
|
# draft_settings — 补充 cloud_last_modify_platform
|
|
settings_path = os.path.join(draft_dir, "draft_settings")
|
|
if os.path.exists(settings_path):
|
|
content = open(settings_path, "r").read()
|
|
if "cloud_last_modify_platform" not in content:
|
|
content = content.rstrip("\n") + "\ncloud_last_modify_platform=mac\n"
|
|
with open(settings_path, "w") as f:
|
|
f.write(content)
|
|
|
|
# performance_opt_info.json
|
|
perf_path = os.path.join(draft_dir, "performance_opt_info.json")
|
|
if not os.path.exists(perf_path):
|
|
with open(perf_path, "w", encoding="utf-8") as f:
|
|
json.dump({"export_fps": 0, "original_fps": 0, "resolution": ""}, f)
|
|
|
|
# timeline_layout.json
|
|
tl_path = os.path.join(draft_dir, "timeline_layout.json")
|
|
if not os.path.exists(tl_path):
|
|
with open(tl_path, "w", encoding="utf-8") as f:
|
|
json.dump({"enable_layout_mode": False, "layout_list": [], "version": "1.0.0"}, f)
|
|
|
|
|
|
def _copy_media_into_draft(draft_dir: str) -> None:
|
|
"""将 draft_info.json 中引用的外部媒体文件复制到草稿目录内,并更新路径。
|
|
|
|
CapCut 是沙盒应用,无法访问草稿目录外的文件。此函数把所有媒体
|
|
复制到 draft_dir/Resources/ 下,并将 JSON 中的路径改为绝对路径指向副本。
|
|
"""
|
|
info_path = os.path.join(draft_dir, "draft_info.json")
|
|
if not os.path.exists(info_path):
|
|
return
|
|
|
|
with open(info_path, "r", encoding="utf-8") as f:
|
|
d = json.load(f)
|
|
|
|
res_dir = os.path.join(draft_dir, "Resources")
|
|
os.makedirs(res_dir, exist_ok=True)
|
|
|
|
path_map = {} # old_abs -> new_abs
|
|
|
|
for mtype in ("videos", "audios"):
|
|
for mat in d.get("materials", {}).get(mtype, []):
|
|
orig = mat.get("path", "")
|
|
if not orig or not os.path.isabs(orig) or not os.path.exists(orig):
|
|
continue
|
|
# 已经在草稿目录内则跳过
|
|
if os.path.commonpath([orig, draft_dir]) == draft_dir:
|
|
continue
|
|
if orig in path_map:
|
|
mat["path"] = path_map[orig]
|
|
continue
|
|
basename = os.path.basename(orig)
|
|
dest = os.path.join(res_dir, basename)
|
|
# 避免重名
|
|
counter = 1
|
|
while os.path.exists(dest):
|
|
stem, ext = os.path.splitext(basename)
|
|
dest = os.path.join(res_dir, f"{stem}_{counter}{ext}")
|
|
counter += 1
|
|
shutil.copy2(orig, dest)
|
|
path_map[orig] = dest
|
|
mat["path"] = dest
|
|
|
|
with open(info_path, "w", encoding="utf-8") as f:
|
|
json.dump(d, f, ensure_ascii=False)
|
|
|
|
|
|
def _update_draft_meta_timestamps(draft_dir: str, total_duration_us: int = 0) -> None:
|
|
"""更新 draft_meta_info.json 中的时间戳、名称和时长。"""
|
|
meta_path = os.path.join(draft_dir, "draft_meta_info.json")
|
|
if not os.path.exists(meta_path):
|
|
return
|
|
|
|
import time
|
|
now = int(time.time() * 1_000_000)
|
|
draft_name = os.path.basename(draft_dir)
|
|
|
|
with open(meta_path, "r", encoding="utf-8") as f:
|
|
meta = json.load(f)
|
|
|
|
meta["tm_draft_modified"] = now
|
|
if not meta.get("tm_draft_create"):
|
|
meta["tm_draft_create"] = now
|
|
meta["draft_name"] = draft_name
|
|
meta["draft_fold_path"] = draft_dir
|
|
meta["draft_root_path"] = os.path.dirname(draft_dir)
|
|
|
|
if total_duration_us > 0:
|
|
meta["tm_duration"] = total_duration_us
|
|
|
|
# CapCut 需要 draft_materials 包含类型占位
|
|
if not meta.get("draft_materials"):
|
|
meta["draft_materials"] = [
|
|
{"type": t, "value": []} for t in (0, 1, 2, 3, 6, 7, 8)
|
|
]
|
|
|
|
with open(meta_path, "w", encoding="utf-8") as f:
|
|
json.dump(meta, f, ensure_ascii=False)
|
|
|
|
|
|
def safe_tim(inp: Union[str, int, float]) -> int:
|
|
"""增强版时间解析器。
|
|
|
|
支持:
|
|
- int: 视为微秒
|
|
- float: 视为秒
|
|
- str "HH:MM:SS" / "MM:SS": 冒号格式
|
|
- str "1h2m3s" / "500ms" / "200000us": 带单位组合
|
|
- str 纯数字: 视为秒
|
|
"""
|
|
if isinstance(inp, int):
|
|
return inp
|
|
if isinstance(inp, float):
|
|
return int(inp * SEC)
|
|
if not isinstance(inp, str):
|
|
return tim(inp)
|
|
|
|
s = inp.strip()
|
|
|
|
if ":" in s:
|
|
parts = s.split(":")
|
|
try:
|
|
if len(parts) == 3:
|
|
h, m, sec = map(float, parts)
|
|
return int((h * 3600 + m * 60 + sec) * SEC)
|
|
elif len(parts) == 2:
|
|
m, sec = map(float, parts)
|
|
return int((m * 60 + sec) * SEC)
|
|
except ValueError:
|
|
pass
|
|
|
|
unit_pattern = re.compile(r"\s*(\d+(?:\.\d+)?)(ms|us|h|m|s)\s*", re.IGNORECASE)
|
|
unit_scale = {"h": 3600 * SEC, "m": 60 * SEC, "s": SEC, "ms": 1000, "us": 1}
|
|
matches = list(unit_pattern.finditer(s))
|
|
if matches:
|
|
pos = 0
|
|
total_us = 0.0
|
|
for match in matches:
|
|
if match.start() != pos:
|
|
break
|
|
value = float(match.group(1))
|
|
unit = match.group(2).lower()
|
|
total_us += value * unit_scale[unit]
|
|
pos = match.end()
|
|
if pos == len(s):
|
|
return int(total_us)
|
|
|
|
if s.replace(".", "", 1).isdigit():
|
|
return int(float(s) * SEC)
|
|
|
|
return tim(inp)
|
|
|
|
|
|
def resolve_enum(enum_cls, name: str, synonyms_dict: dict):
|
|
"""从枚举类中模糊查找成员(支持同义词和fuzzy匹配)"""
|
|
if not name:
|
|
return None
|
|
if hasattr(enum_cls, name):
|
|
return getattr(enum_cls, name)
|
|
|
|
name_lower = name.lower()
|
|
mapping = {k.lower(): k for k in enum_cls.__members__.keys()}
|
|
|
|
if name_lower in mapping:
|
|
return getattr(enum_cls, mapping[name_lower])
|
|
|
|
for key, synonyms in synonyms_dict.items():
|
|
if name_lower == key.lower():
|
|
for candidate in synonyms:
|
|
cl = candidate.lower()
|
|
if cl in mapping:
|
|
return getattr(enum_cls, mapping[cl])
|
|
if key.lower() in mapping:
|
|
for syn in synonyms:
|
|
if syn.lower() in name_lower or name_lower in syn.lower():
|
|
kl = key.lower()
|
|
if kl in mapping:
|
|
return getattr(enum_cls, mapping[kl])
|
|
|
|
matches = difflib.get_close_matches(name, enum_cls.__members__.keys(), n=1, cutoff=0.6)
|
|
if matches:
|
|
return getattr(enum_cls, matches[0])
|
|
return None
|
|
|
|
|
|
def get_duration_ffprobe(file_path: str) -> float:
|
|
"""通过 ffprobe 获取媒体文件时长(秒)"""
|
|
try:
|
|
result = subprocess.run(
|
|
["ffprobe", "-v", "error", "-show_entries", "format=duration",
|
|
"-of", "default=noprint_wrappers=1:nokey=1", file_path],
|
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
text=True, timeout=10,
|
|
)
|
|
return float(result.stdout.strip())
|
|
except Exception:
|
|
return 0.0
|
|
|
|
|
|
def _us_to_rational(us: int, fps: int = 30) -> str:
|
|
"""微秒转 FCPXML 有理数时间 (e.g. '3003/1001s' for 30fps)"""
|
|
# FCPXML uses rational time: numerator/denominator's'
|
|
# For 30fps: frame_duration = 100/3000s
|
|
# Convert microseconds to seconds fraction
|
|
seconds = us / 1_000_000
|
|
# Use high precision rational
|
|
numerator = int(seconds * fps * 1000)
|
|
denominator = fps * 1000
|
|
return f"{numerator}/{denominator}s"
|
|
|
|
|
|
def _file_uri(path: str) -> str:
|
|
"""文件路径转 file:// URI"""
|
|
abs_path = os.path.abspath(path)
|
|
return "file://" + urlquote(abs_path)
|
|
|
|
|
|
# ── FCPXML 导出器 ──────────────────────────────────────────
|
|
|
|
|
|
class FCPXMLExporter:
|
|
"""将 pyJianYingDraft 的 draft_content.json 转换为 FCPXML 1.9"""
|
|
|
|
def __init__(self, draft_content: dict, project_name: str,
|
|
width: int, height: int, fps: int):
|
|
self.content = draft_content
|
|
self.name = project_name
|
|
self.width = width
|
|
self.height = height
|
|
self.fps = fps
|
|
self._resource_id = 0
|
|
self._asset_map: Dict[str, str] = {} # material_id -> resource_id
|
|
|
|
def _next_id(self) -> str:
|
|
self._resource_id += 1
|
|
return f"r{self._resource_id}"
|
|
|
|
def export(self, media_dir: str = "") -> str:
|
|
"""生成 FCPXML XML 字符串"""
|
|
fcpxml = ET.Element("fcpxml", version="1.9")
|
|
|
|
resources = ET.SubElement(fcpxml, "resources")
|
|
fmt_id = self._next_id()
|
|
frame_dur = f"{1000}/{self.fps * 1000}s"
|
|
ET.SubElement(resources, "format",
|
|
id=fmt_id,
|
|
name=f"FFVideoFormat{self.height}p{self.fps}",
|
|
frameDuration=frame_dur,
|
|
width=str(self.width),
|
|
height=str(self.height))
|
|
|
|
# Build asset resources from materials
|
|
materials = self.content.get("materials", {})
|
|
for video_mat in materials.get("videos", []):
|
|
self._add_asset(resources, video_mat, fmt_id, media_dir,
|
|
has_video="1", has_audio="1")
|
|
for audio_mat in materials.get("audios", []):
|
|
self._add_asset(resources, audio_mat, fmt_id, media_dir,
|
|
has_video="0", has_audio="1")
|
|
|
|
# Build timeline
|
|
library = ET.SubElement(fcpxml, "library")
|
|
event = ET.SubElement(library, "event", name="Timeline")
|
|
project = ET.SubElement(event, "project", name=self.name)
|
|
|
|
total_dur = self._get_total_duration()
|
|
sequence = ET.SubElement(project, "sequence",
|
|
format=fmt_id,
|
|
duration=_us_to_rational(total_dur, self.fps))
|
|
spine = ET.SubElement(sequence, "spine")
|
|
|
|
# Add video segments to spine
|
|
tracks = self.content.get("tracks", [])
|
|
main_video_segs = []
|
|
overlay_segs = []
|
|
audio_segs = []
|
|
text_entries = []
|
|
|
|
for track in tracks:
|
|
ttype = track.get("type", "")
|
|
segments = track.get("segments", [])
|
|
if ttype == "video":
|
|
if not main_video_segs:
|
|
main_video_segs = segments
|
|
else:
|
|
overlay_segs.extend(segments)
|
|
elif ttype == "audio":
|
|
audio_segs.extend(segments)
|
|
elif ttype == "text":
|
|
text_entries.extend(segments)
|
|
|
|
# Main video spine
|
|
for seg in main_video_segs:
|
|
self._add_clip_to_spine(spine, seg, materials)
|
|
|
|
# Overlay video as connected clips (attached to spine)
|
|
for seg in overlay_segs:
|
|
self._add_connected_clip(spine, seg, materials)
|
|
|
|
# Audio as connected clips (lane -1)
|
|
for seg in audio_segs:
|
|
self._add_audio_clip(spine, seg, materials)
|
|
|
|
# Text as titles
|
|
for seg in text_entries:
|
|
self._add_title(spine, seg, materials, fmt_id)
|
|
|
|
tree = ET.ElementTree(fcpxml)
|
|
ET.indent(tree, space=" ")
|
|
return ET.tostring(fcpxml, encoding="unicode",
|
|
xml_declaration=True)
|
|
|
|
def _add_asset(self, resources: ET.Element, mat: dict,
|
|
fmt_id: str, media_dir: str, **kwargs):
|
|
mat_id = mat.get("id", "")
|
|
path = mat.get("path", "")
|
|
|
|
# If media was bundled, use the bundled path
|
|
if media_dir and path:
|
|
basename = os.path.basename(path)
|
|
bundled = os.path.join(media_dir, basename)
|
|
if os.path.exists(bundled):
|
|
path = bundled
|
|
|
|
rid = self._next_id()
|
|
self._asset_map[mat_id] = rid
|
|
|
|
duration_us = mat.get("duration", 0)
|
|
if not duration_us and path and os.path.exists(path):
|
|
duration_us = int(get_duration_ffprobe(path) * SEC)
|
|
|
|
attrs = {
|
|
"id": rid,
|
|
"src": _file_uri(path) if path else "",
|
|
"start": "0/1s",
|
|
"duration": _us_to_rational(duration_us, self.fps),
|
|
"format": fmt_id,
|
|
}
|
|
attrs.update(kwargs)
|
|
ET.SubElement(resources, "asset", **attrs)
|
|
|
|
def _get_total_duration(self) -> int:
|
|
max_end = 0
|
|
for track in self.content.get("tracks", []):
|
|
for seg in track.get("segments", []):
|
|
tr = seg.get("target_timerange", {})
|
|
start = tr.get("start", 0)
|
|
dur = tr.get("duration", 0)
|
|
end = start + dur
|
|
if end > max_end:
|
|
max_end = end
|
|
return max_end
|
|
|
|
def _find_material_id_for_segment(self, seg: dict, materials: dict) -> str:
|
|
"""从 segment 中找到对应的 material_id"""
|
|
return seg.get("material_id", "")
|
|
|
|
def _add_clip_to_spine(self, spine: ET.Element, seg: dict, materials: dict):
|
|
mat_id = self._find_material_id_for_segment(seg, materials)
|
|
ref = self._asset_map.get(mat_id, "")
|
|
if not ref:
|
|
return
|
|
|
|
target_tr = seg.get("target_timerange", {})
|
|
source_tr = seg.get("source_timerange", target_tr)
|
|
offset = _us_to_rational(target_tr.get("start", 0), self.fps)
|
|
duration = _us_to_rational(target_tr.get("duration", 0), self.fps)
|
|
start = _us_to_rational(source_tr.get("start", 0), self.fps)
|
|
|
|
clip = ET.SubElement(spine, "asset-clip",
|
|
ref=ref, offset=offset,
|
|
start=start, duration=duration)
|
|
|
|
# Volume
|
|
volume = seg.get("volume", 1.0)
|
|
if volume != 1.0:
|
|
adjust = ET.SubElement(clip, "adjust-volume",
|
|
amount=f"{volume:.2f}dB")
|
|
|
|
def _add_connected_clip(self, spine: ET.Element, seg: dict, materials: dict):
|
|
"""叠加视频作为 connected clip"""
|
|
mat_id = self._find_material_id_for_segment(seg, materials)
|
|
ref = self._asset_map.get(mat_id, "")
|
|
if not ref:
|
|
return
|
|
|
|
target_tr = seg.get("target_timerange", {})
|
|
source_tr = seg.get("source_timerange", target_tr)
|
|
offset = _us_to_rational(target_tr.get("start", 0), self.fps)
|
|
duration = _us_to_rational(target_tr.get("duration", 0), self.fps)
|
|
start = _us_to_rational(source_tr.get("start", 0), self.fps)
|
|
|
|
# Find the first spine element to attach to
|
|
spine_children = list(spine)
|
|
if spine_children:
|
|
clip = ET.SubElement(spine_children[0], "asset-clip",
|
|
ref=ref, offset=offset, lane="1",
|
|
start=start, duration=duration)
|
|
|
|
def _add_audio_clip(self, spine: ET.Element, seg: dict, materials: dict):
|
|
mat_id = self._find_material_id_for_segment(seg, materials)
|
|
ref = self._asset_map.get(mat_id, "")
|
|
if not ref:
|
|
return
|
|
|
|
target_tr = seg.get("target_timerange", {})
|
|
source_tr = seg.get("source_timerange", target_tr)
|
|
offset = _us_to_rational(target_tr.get("start", 0), self.fps)
|
|
duration = _us_to_rational(target_tr.get("duration", 0), self.fps)
|
|
start = _us_to_rational(source_tr.get("start", 0), self.fps)
|
|
|
|
spine_children = list(spine)
|
|
if spine_children:
|
|
clip = ET.SubElement(spine_children[0], "asset-clip",
|
|
ref=ref, offset=offset, lane="-1",
|
|
start=start, duration=duration)
|
|
volume = seg.get("volume", 1.0)
|
|
if volume != 1.0:
|
|
ET.SubElement(clip, "adjust-volume",
|
|
amount=f"{volume:.2f}dB")
|
|
|
|
def _add_title(self, spine: ET.Element, seg: dict,
|
|
materials: dict, fmt_id: str):
|
|
target_tr = seg.get("target_timerange", {})
|
|
offset = _us_to_rational(target_tr.get("start", 0), self.fps)
|
|
duration = _us_to_rational(target_tr.get("duration", 0), self.fps)
|
|
|
|
# Get text content from materials
|
|
mat_id = self._find_material_id_for_segment(seg, materials)
|
|
text_content = ""
|
|
for txt_mat in materials.get("texts", []):
|
|
if txt_mat.get("id") == mat_id:
|
|
# content 字段包含富文本 JSON
|
|
content_raw = txt_mat.get("content", "")
|
|
try:
|
|
content_data = json.loads(content_raw) if isinstance(content_raw, str) else content_raw
|
|
# 提取纯文本
|
|
if isinstance(content_data, dict):
|
|
styles = content_data.get("styles", [])
|
|
for style in styles:
|
|
r = style.get("range", [0, 0])
|
|
text_content = content_data.get("text", "")
|
|
except (json.JSONDecodeError, TypeError):
|
|
text_content = str(content_raw)
|
|
break
|
|
|
|
if not text_content:
|
|
return
|
|
|
|
spine_children = list(spine)
|
|
parent = spine_children[0] if spine_children else spine
|
|
|
|
title = ET.SubElement(parent, "title",
|
|
ref=fmt_id, offset=offset,
|
|
duration=duration, lane="2",
|
|
name=text_content[:50])
|
|
text_elem = ET.SubElement(title, "text")
|
|
ts = ET.SubElement(text_elem, "text-style", ref=f"ts_{mat_id[:8]}")
|
|
ts.text = text_content
|
|
|
|
|
|
# ── JyProject 主类 ──────────────────────────────────────────
|
|
|
|
|
|
class JyProject:
|
|
"""剪映草稿项目封装,支持多格式导出。
|
|
|
|
用法示例::
|
|
|
|
project = JyProject("我的MV")
|
|
project.add_video("/path/to/video.mp4", start_time=0, duration="5s")
|
|
project.add_audio("/path/to/bgm.mp3", start_time=0)
|
|
project.add_text("Hello World", start_time="1s", duration="3s")
|
|
project.save() # 交互式选择导出目标
|
|
# 或
|
|
project.save(target="davinci") # 直接指定
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
name: str,
|
|
width: int = 1920,
|
|
height: int = 1080,
|
|
fps: int = 30,
|
|
*,
|
|
overwrite: bool = False,
|
|
output_root: Optional[str] = None,
|
|
direct: bool = False,
|
|
):
|
|
self.name = name
|
|
self.width = width
|
|
self.height = height
|
|
self.fps = fps
|
|
self.direct = direct
|
|
|
|
if direct:
|
|
# 直接创建到剪映草稿目录(CapCut 7.8 兼容)
|
|
jy_root = _get_jianying_drafts_root()
|
|
if not jy_root:
|
|
raise RuntimeError(
|
|
"未找到剪映草稿目录。"
|
|
"macOS: ~/Movies/JianyingPro/User Data/Projects/com.lveditor.draft/"
|
|
)
|
|
self.root = jy_root
|
|
else:
|
|
self.root = output_root or _default_output_root()
|
|
|
|
os.makedirs(self.root, exist_ok=True)
|
|
|
|
self._folder = DraftFolder(self.root)
|
|
self._script: ScriptFile = self._folder.create_draft(
|
|
name, width, height, fps, allow_replace=overwrite,
|
|
)
|
|
|
|
# 创建默认轨道
|
|
self._script.add_track(TrackType.video, "main_video")
|
|
self._script.add_track(TrackType.audio, "main_audio")
|
|
self._script.add_track(TrackType.text, "main_text")
|
|
self._script.add_track(TrackType.filter, "main_filter")
|
|
self._script.add_track(TrackType.effect, "main_effect")
|
|
|
|
self._materials = {}
|
|
self._media_files: List[str] = [] # 所有引用的媒体文件路径
|
|
|
|
@property
|
|
def script(self) -> ScriptFile:
|
|
return self._script
|
|
|
|
@property
|
|
def draft_path(self) -> str:
|
|
return os.path.join(self.root, self.name)
|
|
|
|
# ── 媒体导入 ──────────────────────────────────────────
|
|
|
|
def _get_or_create_material(
|
|
self, path: str, material_type: str = "video"
|
|
) -> Union[VideoMaterial, AudioMaterial]:
|
|
abs_path = os.path.abspath(path)
|
|
key = f"{material_type}:{abs_path}"
|
|
|
|
if key not in self._materials:
|
|
if material_type == "audio":
|
|
try:
|
|
mat = AudioMaterial(abs_path)
|
|
except ValueError:
|
|
audio_path = self._extract_audio(abs_path)
|
|
mat = AudioMaterial(audio_path)
|
|
key = f"audio:{audio_path}"
|
|
abs_path = audio_path
|
|
else:
|
|
mat = VideoMaterial(abs_path)
|
|
self._script.add_material(mat)
|
|
self._materials[key] = mat
|
|
if abs_path not in self._media_files:
|
|
self._media_files.append(abs_path)
|
|
|
|
return self._materials[key]
|
|
|
|
def _extract_audio(self, video_path: str) -> str:
|
|
stem = os.path.splitext(os.path.basename(video_path))[0]
|
|
if self.direct:
|
|
# direct 模式:提取到草稿的 audio/ 子目录,CapCut 用绝对路径引用
|
|
cache_dir = os.path.join(self.draft_path, "audio")
|
|
else:
|
|
cache_dir = os.path.join(self.root, "__audio_cache__")
|
|
os.makedirs(cache_dir, exist_ok=True)
|
|
audio_path = os.path.join(cache_dir, f"{stem}.m4a")
|
|
|
|
if os.path.exists(audio_path):
|
|
return audio_path
|
|
|
|
try:
|
|
subprocess.run(
|
|
["ffmpeg", "-y", "-i", video_path,
|
|
"-vn", "-acodec", "aac", "-b:a", "192k", audio_path],
|
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
timeout=30, check=True,
|
|
)
|
|
except Exception as e:
|
|
raise RuntimeError(f"音频提取失败: {e}")
|
|
return audio_path
|
|
|
|
def _ensure_track(self, track_type: TrackType, track_name: Optional[str] = None) -> str:
|
|
if track_name:
|
|
try:
|
|
self._script.add_track(track_type, track_name)
|
|
except Exception:
|
|
pass
|
|
return track_name
|
|
|
|
defaults = {
|
|
"video": "main_video",
|
|
"audio": "main_audio",
|
|
"text": "main_text",
|
|
"filter": "main_filter",
|
|
"effect": "main_effect",
|
|
}
|
|
return defaults.get(track_type.name, track_type.name)
|
|
|
|
def add_video(
|
|
self,
|
|
file_path: str,
|
|
start_time: Union[str, int, float] = 0,
|
|
duration: Optional[Union[str, int, float]] = None,
|
|
*,
|
|
source_start: Union[str, int, float] = 0,
|
|
speed: Optional[float] = None,
|
|
volume: float = 1.0,
|
|
track_name: Optional[str] = None,
|
|
clip_settings: Optional[ClipSettings] = None,
|
|
) -> VideoSegment:
|
|
"""添加视频片段到时间线。"""
|
|
abs_path = os.path.abspath(file_path)
|
|
if not os.path.exists(abs_path):
|
|
raise FileNotFoundError(f"视频文件不存在: {abs_path}")
|
|
|
|
material = self._get_or_create_material(abs_path, "video")
|
|
start_us = safe_tim(start_time)
|
|
|
|
if duration is not None:
|
|
dur_us = safe_tim(duration)
|
|
else:
|
|
dur_seconds = get_duration_ffprobe(abs_path)
|
|
dur_us = int(dur_seconds * SEC) if dur_seconds > 0 else 5 * SEC
|
|
|
|
src_start_us = safe_tim(source_start)
|
|
target_tr = Timerange(start_us, dur_us)
|
|
source_tr = Timerange(src_start_us, dur_us)
|
|
|
|
seg = VideoSegment(
|
|
material, target_tr,
|
|
source_timerange=source_tr,
|
|
speed=speed, volume=volume,
|
|
clip_settings=clip_settings,
|
|
)
|
|
|
|
track = self._ensure_track(TrackType.video, track_name)
|
|
self._script.add_segment(seg, track)
|
|
return seg
|
|
|
|
def add_audio(
|
|
self,
|
|
file_path: str,
|
|
start_time: Union[str, int, float] = 0,
|
|
duration: Optional[Union[str, int, float]] = None,
|
|
*,
|
|
source_start: Union[str, int, float] = 0,
|
|
speed: Optional[float] = None,
|
|
volume: float = 1.0,
|
|
track_name: Optional[str] = None,
|
|
) -> AudioSegment:
|
|
"""添加音频片段到时间线。"""
|
|
abs_path = os.path.abspath(file_path)
|
|
if not os.path.exists(abs_path):
|
|
raise FileNotFoundError(f"音频文件不存在: {abs_path}")
|
|
|
|
material = self._get_or_create_material(abs_path, "audio")
|
|
start_us = safe_tim(start_time)
|
|
|
|
if duration is not None:
|
|
dur_us = safe_tim(duration)
|
|
else:
|
|
dur_seconds = get_duration_ffprobe(abs_path)
|
|
dur_us = int(dur_seconds * SEC) if dur_seconds > 0 else 5 * SEC
|
|
|
|
src_start_us = safe_tim(source_start)
|
|
target_tr = Timerange(start_us, dur_us)
|
|
source_tr = Timerange(src_start_us, dur_us)
|
|
|
|
seg = AudioSegment(
|
|
material, target_tr,
|
|
source_timerange=source_tr,
|
|
speed=speed, volume=volume,
|
|
)
|
|
|
|
track = self._ensure_track(TrackType.audio, track_name)
|
|
self._script.add_segment(seg, track)
|
|
return seg
|
|
|
|
# ── 文字 ──────────────────────────────────────────────
|
|
|
|
def add_text(
|
|
self,
|
|
text: str,
|
|
start_time: Union[str, int, float] = 0,
|
|
duration: Union[str, int, float] = "3s",
|
|
*,
|
|
font_size: float = 8.0,
|
|
color_rgb: Tuple[float, float, float] = (1.0, 1.0, 1.0),
|
|
bold: bool = False,
|
|
italic: bool = False,
|
|
transform_x: float = 0.0,
|
|
transform_y: float = 0.0,
|
|
scale: float = 1.0,
|
|
alpha: float = 1.0,
|
|
track_name: Optional[str] = None,
|
|
) -> TextSegment:
|
|
"""添加文字到时间线。"""
|
|
start_us = safe_tim(start_time)
|
|
dur_us = safe_tim(duration)
|
|
tr = Timerange(start_us, dur_us)
|
|
|
|
style = TextStyle(size=font_size, bold=bold, italic=italic, color=color_rgb)
|
|
clip = ClipSettings(
|
|
transform_x=transform_x, transform_y=transform_y,
|
|
scale_x=scale, scale_y=scale, alpha=alpha,
|
|
)
|
|
|
|
seg = TextSegment(text, tr, style=style, clip_settings=clip)
|
|
track = self._ensure_track(TrackType.text, track_name)
|
|
self._script.add_segment(seg, track)
|
|
return seg
|
|
|
|
def add_subtitle(
|
|
self,
|
|
text: str,
|
|
start_time: Union[str, int, float] = 0,
|
|
duration: Union[str, int, float] = "3s",
|
|
*,
|
|
font_size: float = 6.0,
|
|
color_rgb: Tuple[float, float, float] = (1.0, 1.0, 1.0),
|
|
track_name: Optional[str] = None,
|
|
) -> TextSegment:
|
|
"""添加字幕(预设底部位置)。"""
|
|
return self.add_text(
|
|
text, start_time, duration,
|
|
font_size=font_size, color_rgb=color_rgb,
|
|
transform_y=-0.8,
|
|
track_name=track_name or "subtitles",
|
|
)
|
|
|
|
def import_srt(
|
|
self,
|
|
srt_path: str,
|
|
track_name: str = "subtitles",
|
|
*,
|
|
time_offset: Union[str, float] = 0.0,
|
|
font_size: float = 6.0,
|
|
color_rgb: Tuple[float, float, float] = (1.0, 1.0, 1.0),
|
|
) -> "JyProject":
|
|
"""导入 SRT 字幕文件。"""
|
|
abs_path = os.path.abspath(srt_path)
|
|
if not os.path.exists(abs_path):
|
|
raise FileNotFoundError(f"SRT 文件不存在: {abs_path}")
|
|
self._ensure_track(TrackType.text, track_name)
|
|
style = TextStyle(size=font_size, color=color_rgb)
|
|
clip = ClipSettings(transform_y=-0.8)
|
|
self._script.import_srt(abs_path, track_name,
|
|
time_offset=time_offset,
|
|
text_style=style, clip_settings=clip)
|
|
return self
|
|
|
|
# ── 转场 ──────────────────────────────────────────────
|
|
|
|
def add_transition(self, transition_name: str,
|
|
duration: Union[str, int, float] = "0.5s") -> "JyProject":
|
|
"""在最后两个视频片段之间添加转场。"""
|
|
tt = resolve_enum(TransitionType, transition_name, TRANSITION_SYNONYMS)
|
|
if tt is None:
|
|
raise ValueError(f"未找到转场 '{transition_name}'")
|
|
dur_us = safe_tim(duration)
|
|
print(f"[Transition] {transition_name} -> {tt.name}, duration={dur_us}us")
|
|
return self
|
|
|
|
# ── 滤镜 ──────────────────────────────────────────────
|
|
|
|
def add_filter(self, filter_name: str,
|
|
start_time: Union[str, int, float] = 0,
|
|
duration: Union[str, int, float] = "5s",
|
|
*, intensity: float = 100.0,
|
|
track_name: Optional[str] = None) -> "JyProject":
|
|
"""添加滤镜效果。"""
|
|
ft = resolve_enum(FilterType, filter_name, FILTER_SYNONYMS)
|
|
if ft is None:
|
|
raise ValueError(f"未找到滤镜 '{filter_name}'")
|
|
start_us = safe_tim(start_time)
|
|
dur_us = safe_tim(duration)
|
|
self._script.add_filter(ft, Timerange(start_us, dur_us),
|
|
track_name=track_name, intensity=intensity)
|
|
return self
|
|
|
|
# ── 特效 ──────────────────────────────────────────────
|
|
|
|
def add_effect(self, effect_name: str,
|
|
start_time: Union[str, int, float] = 0,
|
|
duration: Union[str, int, float] = "5s",
|
|
*, track_name: Optional[str] = None) -> "JyProject":
|
|
"""添加场景特效。"""
|
|
et = resolve_enum(VideoSceneEffectType, effect_name, EFFECT_SYNONYMS)
|
|
if et is None:
|
|
raise ValueError(f"未找到特效 '{effect_name}'")
|
|
start_us = safe_tim(start_time)
|
|
dur_us = safe_tim(duration)
|
|
self._script.add_effect(et, Timerange(start_us, dur_us),
|
|
track_name=track_name)
|
|
return self
|
|
|
|
# ── 关键帧 ────────────────────────────────────────────
|
|
|
|
@staticmethod
|
|
def add_keyframe(segment: VideoSegment,
|
|
prop: Union[str, KeyframeProperty],
|
|
time_offset: Union[str, int, float],
|
|
value: float):
|
|
"""为视频片段添加关键帧。"""
|
|
if isinstance(prop, str):
|
|
prop_enum = getattr(KeyframeProperty, prop, None)
|
|
if prop_enum is None:
|
|
raise ValueError(f"未知属性 '{prop}'")
|
|
else:
|
|
prop_enum = prop
|
|
offset = safe_tim(time_offset) if not isinstance(time_offset, int) else time_offset
|
|
segment.add_keyframe(prop_enum, offset, value)
|
|
|
|
# ── 保存 & 打包 ──────────────────────────────────────
|
|
|
|
def _calc_total_duration(self) -> int:
|
|
"""从 draft JSON 中计算时间线总时长(微秒)。"""
|
|
info_path = os.path.join(self.draft_path, "draft_info.json")
|
|
content_path = os.path.join(self.draft_path, "draft_content.json")
|
|
path = info_path if os.path.exists(info_path) else content_path
|
|
if not os.path.exists(path):
|
|
return 0
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
d = json.load(f)
|
|
max_end = 0
|
|
for track in d.get("tracks", []):
|
|
for seg in track.get("segments", []):
|
|
tr = seg.get("target_timerange", {})
|
|
end = tr.get("start", 0) + tr.get("duration", 0)
|
|
if end > max_end:
|
|
max_end = end
|
|
return max_end
|
|
|
|
def save(self, target: Optional[str] = None) -> str:
|
|
"""保存并打包工程文件。
|
|
|
|
Args:
|
|
target: 导出目标,可选值:
|
|
- "jianying" — 剪映 / CapCut
|
|
- "davinci" — DaVinci Resolve
|
|
- "premiere" — Adobe Premiere Pro
|
|
- "fcpro" — Final Cut Pro
|
|
- None — 全部格式都生成
|
|
|
|
Returns:
|
|
输出目录路径(direct 模式)或第一个 zip 路径
|
|
"""
|
|
# 1. 保存 pyJianYingDraft 草稿(总是生成,作为中间表示)
|
|
self._script.save()
|
|
|
|
# 2. direct 模式:直接生成到剪映草稿目录,不打包
|
|
if self.direct:
|
|
_patch_for_capcut78(self.draft_path)
|
|
_copy_media_into_draft(self.draft_path)
|
|
total_dur = self._calc_total_duration()
|
|
_update_draft_meta_timestamps(self.draft_path, total_dur)
|
|
print(f"\n{'=' * 50}")
|
|
print(f"项目 '{self.name}' 已直接创建到剪映草稿目录")
|
|
print(f"路径: {self.draft_path}")
|
|
print("请在 CapCut / 剪映中刷新草稿列表即可打开")
|
|
print(f"{'=' * 50}")
|
|
return self.draft_path
|
|
|
|
# 3. zip 模式:打包素材 + 工程文件
|
|
media_dir = os.path.join(self.draft_path, "media")
|
|
os.makedirs(media_dir, exist_ok=True)
|
|
self._bundle_media(media_dir)
|
|
|
|
outputs = []
|
|
targets = [target] if target else list(EXPORT_TARGETS.keys())
|
|
|
|
for t in targets:
|
|
if t == "jianying":
|
|
zip_path = self._package_jianying(media_dir)
|
|
outputs.append(("剪映 / CapCut", zip_path))
|
|
elif t in ("davinci", "premiere", "fcpro"):
|
|
fcpxml_path = self._export_fcpxml(media_dir)
|
|
zip_path = self._package_fcpxml(fcpxml_path, media_dir, t)
|
|
outputs.append((EXPORT_TARGETS[t], zip_path))
|
|
|
|
print(f"\n{'=' * 50}")
|
|
print(f"项目 '{self.name}' 导出完成:")
|
|
for label, path in outputs:
|
|
size_kb = os.path.getsize(path) / 1024
|
|
print(f" [{label}] {path} ({size_kb:.0f} KB)")
|
|
print(f"{'=' * 50}")
|
|
|
|
return outputs[0][1] if outputs else self.draft_path
|
|
|
|
def _bundle_media(self, media_dir: str):
|
|
"""将所有引用的媒体文件拷贝到 media/ 目录"""
|
|
content_path = os.path.join(self.draft_path, "draft_content.json")
|
|
with open(content_path, "r", encoding="utf-8") as f:
|
|
content = json.load(f)
|
|
|
|
path_map = {}
|
|
for mtype in ["videos", "audios"]:
|
|
for mat in content.get("materials", {}).get(mtype, []):
|
|
orig = mat.get("path", "")
|
|
if not orig or not os.path.exists(orig):
|
|
continue
|
|
if orig in path_map:
|
|
mat["path"] = path_map[orig]
|
|
continue
|
|
basename = os.path.basename(orig)
|
|
dest = os.path.join(media_dir, basename)
|
|
# 避免重名
|
|
if os.path.exists(dest) and not os.path.samefile(orig, dest):
|
|
stem, ext = os.path.splitext(basename)
|
|
dest = os.path.join(media_dir, f"{stem}_{len(path_map)}{ext}")
|
|
if not os.path.exists(dest):
|
|
shutil.copy2(orig, dest)
|
|
path_map[orig] = dest
|
|
mat["path"] = dest
|
|
|
|
with open(content_path, "w", encoding="utf-8") as f:
|
|
json.dump(content, f, ensure_ascii=False)
|
|
|
|
self._path_map = path_map
|
|
|
|
def _package_jianying(self, media_dir: str) -> str:
|
|
"""打包剪映工程为 zip"""
|
|
zip_base = os.path.join(self.root, f"{self.name}_jianying")
|
|
zip_path = shutil.make_archive(zip_base, "zip",
|
|
self.root, self.name)
|
|
return zip_path
|
|
|
|
def _export_fcpxml(self, media_dir: str) -> str:
|
|
"""从 draft_content.json 生成 FCPXML"""
|
|
content_path = os.path.join(self.draft_path, "draft_content.json")
|
|
with open(content_path, "r", encoding="utf-8") as f:
|
|
content = json.load(f)
|
|
|
|
exporter = FCPXMLExporter(content, self.name,
|
|
self.width, self.height, self.fps)
|
|
xml_str = exporter.export(media_dir)
|
|
|
|
fcpxml_path = os.path.join(self.draft_path, f"{self.name}.fcpxml")
|
|
with open(fcpxml_path, "w", encoding="utf-8") as f:
|
|
f.write(xml_str)
|
|
|
|
return fcpxml_path
|
|
|
|
def _package_fcpxml(self, fcpxml_path: str, media_dir: str,
|
|
target: str) -> str:
|
|
"""打包 FCPXML + 素材为 zip"""
|
|
pkg_name = f"{self.name}_{target}"
|
|
pkg_dir = os.path.join(self.root, pkg_name)
|
|
if os.path.exists(pkg_dir):
|
|
shutil.rmtree(pkg_dir)
|
|
os.makedirs(pkg_dir)
|
|
|
|
# Copy FCPXML
|
|
shutil.copy2(fcpxml_path, os.path.join(pkg_dir, f"{self.name}.fcpxml"))
|
|
|
|
# Copy media
|
|
pkg_media = os.path.join(pkg_dir, "media")
|
|
if os.path.exists(media_dir):
|
|
shutil.copytree(media_dir, pkg_media)
|
|
|
|
# Write README
|
|
readme = self._generate_import_readme(target)
|
|
with open(os.path.join(pkg_dir, "导入说明.txt"), "w", encoding="utf-8") as f:
|
|
f.write(readme)
|
|
|
|
zip_path = shutil.make_archive(
|
|
os.path.join(self.root, pkg_name), "zip",
|
|
self.root, pkg_name)
|
|
|
|
# Cleanup temp dir
|
|
shutil.rmtree(pkg_dir)
|
|
return zip_path
|
|
|
|
def _generate_import_readme(self, target: str) -> str:
|
|
instructions = {
|
|
"davinci": (
|
|
f"项目: {self.name}\n"
|
|
f"目标软件: DaVinci Resolve\n\n"
|
|
"导入步骤:\n"
|
|
"1. 解压此 zip 文件到任意目录\n"
|
|
"2. 打开 DaVinci Resolve\n"
|
|
"3. File → Import → Timeline → 选择 .fcpxml 文件\n"
|
|
"4. 确保 media/ 文件夹与 .fcpxml 文件在同一目录\n"
|
|
"5. 如提示找不到素材,手动重新链接 media/ 目录中的文件\n"
|
|
),
|
|
"premiere": (
|
|
f"项目: {self.name}\n"
|
|
f"目标软件: Adobe Premiere Pro\n\n"
|
|
"导入步骤:\n"
|
|
"1. 解压此 zip 文件到任意目录\n"
|
|
"2. 打开 Premiere Pro\n"
|
|
"3. File → Import → 选择 .fcpxml 文件\n"
|
|
"4. 确保 media/ 文件夹与 .fcpxml 文件在同一目录\n"
|
|
"5. 如提示找不到素材,手动重新链接 media/ 目录中的文件\n"
|
|
),
|
|
"fcpro": (
|
|
f"项目: {self.name}\n"
|
|
f"目标软件: Final Cut Pro\n\n"
|
|
"导入步骤:\n"
|
|
"1. 解压此 zip 文件到任意目录\n"
|
|
"2. 打开 Final Cut Pro\n"
|
|
"3. File → Import → XML → 选择 .fcpxml 文件\n"
|
|
"4. 确保 media/ 文件夹与 .fcpxml 文件在同一目录\n"
|
|
),
|
|
"jianying": (
|
|
f"项目: {self.name}\n"
|
|
f"目标软件: 剪映 / CapCut\n\n"
|
|
"导入步骤:\n"
|
|
"1. 解压此 zip 文件\n"
|
|
"2. 将解压后的文件夹复制到剪映草稿目录:\n"
|
|
" macOS: ~/Movies/JianyingPro/User Data/Projects/com.lveditor.draft/\n"
|
|
" Windows: %LOCALAPPDATA%/JianyingPro/User Data/Projects/com.lveditor.draft/\n"
|
|
"3. 打开剪映,在「草稿」中找到该项目\n"
|
|
),
|
|
}
|
|
return instructions.get(target, "")
|
|
|
|
# ── 资产搜索 ──────────────────────────────────────────
|
|
|
|
@staticmethod
|
|
def list_filters() -> List[str]:
|
|
return list(FilterType.__members__.keys())
|
|
|
|
@staticmethod
|
|
def list_transitions() -> List[str]:
|
|
return list(TransitionType.__members__.keys())
|
|
|
|
@staticmethod
|
|
def list_effects() -> List[str]:
|
|
return list(VideoSceneEffectType.__members__.keys())
|
|
|
|
@staticmethod
|
|
def list_text_intros() -> List[str]:
|
|
return list(IntroType.__members__.keys())
|
|
|
|
@staticmethod
|
|
def list_text_outros() -> List[str]:
|
|
return list(OutroType.__members__.keys())
|
|
|
|
@staticmethod
|
|
def list_keyframe_properties() -> List[str]:
|
|
return list(KeyframeProperty.__members__.keys())
|
|
|
|
@staticmethod
|
|
def list_export_targets() -> Dict[str, str]:
|
|
return dict(EXPORT_TARGETS)
|
|
|