Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,12 @@ mcp_client.call_tool("add_text", {

Calling `save_draft` will generate a folder starting with `dfd_` in the current directory of `capcut_server.py`. Copy this to the CapCut/Jianying drafts directory to see the generated draft in the application.

Draft output is selected by `draft_profile` in `config.json`:

- `capcut_legacy`: existing CapCut template.
- `jianying_legacy`: existing Jianying template.
- `jianying_pro_10`: Jianying Pro 10.x Windows-style folder layout using `draft_content.json`.

## Pattern

You can find a lot of pattern in the `pattern` directory.
Expand Down
15 changes: 2 additions & 13 deletions add_audio_track.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
# 导入必要的模块
import os
import pyJianYingDraft as draft
import time
from util import generate_draft_url, is_windows_path, url_to_hash
import re
from util import generate_draft_url, url_to_hash, build_draft_asset_path
from typing import Optional, Dict, Tuple, List
from pyJianYingDraft import exceptions, Audio_scene_effect_type, Tone_effect_type, Speech_to_song_type, CapCut_Voice_filters_effect_type,CapCut_Voice_characters_effect_type,CapCut_Speech_to_song_effect_type, trange
from create_draft import get_or_create_draft
Expand Down Expand Up @@ -81,16 +79,7 @@ def add_audio_track(
# Build draft_audio_path
draft_audio_path = None
if draft_folder:
if is_windows_path(draft_folder):
# Windows path processing
windows_drive, windows_path = re.match(r'([a-zA-Z]:)(.*)', draft_folder).groups()
parts = [p for p in windows_path.split('\\') if p]
draft_audio_path = os.path.join(windows_drive, *parts, draft_id, "assets", "audio", material_name)
# Normalize path (ensure consistent separators)
draft_audio_path = draft_audio_path.replace('/', '\\')
else:
# macOS/Linux path processing
draft_audio_path = os.path.join(draft_folder, draft_id, "assets", "audio", material_name)
draft_audio_path = build_draft_asset_path(draft_folder, draft_id, "audio", material_name)

# Set default value for audio end time
audio_end = end if end is not None else audio_duration
Expand Down
17 changes: 2 additions & 15 deletions add_image_impl.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import os
import uuid
import pyJianYingDraft as draft
import time
from settings.local import IS_CAPCUT_ENV
from util import generate_draft_url, is_windows_path, url_to_hash
from util import generate_draft_url, url_to_hash, build_draft_asset_path
from pyJianYingDraft import trange, Clip_settings
import re
from typing import Optional, Dict
from pyJianYingDraft import exceptions
from create_draft import get_or_create_draft
Expand Down Expand Up @@ -116,18 +114,7 @@ def add_image_impl(
# Build draft_image_path
draft_image_path = None
if draft_folder:
# Detect input path type and process
if is_windows_path(draft_folder):
# Windows path processing
windows_drive, windows_path = re.match(r'([a-zA-Z]:)(.*)', draft_folder).groups()
parts = [p for p in windows_path.split('\\') if p] # Split path and filter empty parts
draft_image_path = os.path.join(windows_drive, *parts, draft_id, "assets", "image", material_name)
# Normalize path (ensure consistent separators)
draft_image_path = draft_image_path.replace('/', '\\')
else:
# macOS/Linux path processing
draft_image_path = os.path.join(draft_folder, draft_id, "assets", "image", material_name)

draft_image_path = build_draft_asset_path(draft_folder, draft_id, "image", material_name)
# Print path information
print('replace_path:', draft_image_path)

Expand Down
17 changes: 2 additions & 15 deletions add_video_track.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import os
import pyJianYingDraft as draft
import time
from settings.local import IS_CAPCUT_ENV
from util import generate_draft_url, is_windows_path, url_to_hash
from util import generate_draft_url, url_to_hash, build_draft_asset_path
from pyJianYingDraft import trange, Clip_settings
import re
from typing import Optional, Dict
from pyJianYingDraft import exceptions
from create_draft import get_or_create_draft
Expand Down Expand Up @@ -125,18 +123,7 @@ def add_video_track(
# Build draft_video_path
draft_video_path = None
if draft_folder:
# Detect input path type and process
if is_windows_path(draft_folder):
# Windows path processing
windows_drive, windows_path = re.match(r'([a-zA-Z]:)(.*)', draft_folder).groups()
parts = [p for p in windows_path.split('\\') if p] # Split path and filter empty parts
draft_video_path = os.path.join(windows_drive, *parts, draft_id, "assets", "video", material_name)
# Normalize path (ensure consistent separators)
draft_video_path = draft_video_path.replace('/', '\\')
else:
# macOS/Linux path processing
draft_video_path = os.path.join(draft_folder, draft_id, "assets", "video", material_name)

draft_video_path = build_draft_asset_path(draft_folder, draft_id, "video", material_name)
# Print path information
print('replace_path:', draft_video_path)

Expand Down
5 changes: 3 additions & 2 deletions config.json.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"is_capcut_env": true, // Whether to use CapCut environment (true) or JianYing environment (false)
"draft_profile": "capcut_legacy", // capcut_legacy, jianying_legacy, or jianying_pro_10 for Jianying Pro 10.x
"is_capcut_env": true, // Backward-compatible fallback. Ignored when draft_profile is set.
"draft_domain": "https://www.capcutapi.top", // Base domain for draft operations
"port": 9001, // Port number for the local server
"preview_router": "/draft/downloader", // Router path for preview functionality
Expand All @@ -17,4 +18,4 @@
"region": "your-region", // Region for MP4 storage
"endpoint": "http://your-custom-domain" // Custom domain endpoint for MP4 access
}
}
}
174 changes: 174 additions & 0 deletions draft_profiles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import json
import os
import shutil
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Iterable, List, Optional


@dataclass(frozen=True)
class DraftProfile:
name: str
template_dir: str
content_file: str
content_mirrors: tuple[str, ...] = ()
timeline_content_file: Optional[str] = None
is_capcut_env: bool = True
platform: Optional[Dict[str, object]] = None


CAPCUT_PLATFORM = {
"app_id": 359289,
"app_source": "cc",
"app_version": "6.5.0",
"device_id": "c4ca4238a0b923820dcc509a6f75849b",
"hard_disk_id": "307563e0192a94465c0e927fbc482942",
"mac_address": "c3371f2d4fb02791c067ce44d8fb4ed5",
"os": "mac",
"os_version": "15.5",
}

JIANYING_10_PLATFORM = {
"app_id": 3704,
"app_source": "lv",
"app_version": "10.2.0",
"os": "windows",
}

PROFILES: Dict[str, DraftProfile] = {
"capcut_legacy": DraftProfile(
name="capcut_legacy",
template_dir="template",
content_file="draft_info.json",
is_capcut_env=True,
platform=CAPCUT_PLATFORM,
),
"jianying_legacy": DraftProfile(
name="jianying_legacy",
template_dir="template_jianying",
content_file="draft_info.json",
is_capcut_env=False,
platform=JIANYING_10_PLATFORM,
),
"jianying_pro_10": DraftProfile(
name="jianying_pro_10",
template_dir="template_jianying_10_2",
content_file="draft_content.json",
content_mirrors=("draft_content.json.bak", "template-2.tmp"),
timeline_content_file="template.tmp",
is_capcut_env=False,
platform=JIANYING_10_PLATFORM,
),
}

PROFILE_ALIASES = {
"capcut": "capcut_legacy",
"capcut_legacy": "capcut_legacy",
"jianying": "jianying_legacy",
"jianying_legacy": "jianying_legacy",
"jianying_10": "jianying_pro_10",
"jianying_10_x": "jianying_pro_10",
"jianying_pro_10": "jianying_pro_10",
"jianying_pro_10_2": "jianying_pro_10",
"jianying_pro_10_2_0": "jianying_pro_10",
}


def normalize_profile_name(name: Optional[str]) -> str:
if not name:
return "capcut_legacy"
key = name.strip().lower().replace(".", "_").replace("-", "_")
if key not in PROFILE_ALIASES:
raise ValueError(
f"Unknown draft profile '{name}'. Supported profiles: {', '.join(sorted(PROFILE_ALIASES))}"
)
return PROFILE_ALIASES[key]


def get_draft_profile(name: Optional[str] = None) -> DraftProfile:
if name is None:
try:
from settings.local import DRAFT_PROFILE
except Exception:
DRAFT_PROFILE = "capcut_legacy"
name = DRAFT_PROFILE
return PROFILES[normalize_profile_name(name)]


def get_template_dir(name: Optional[str] = None) -> str:
return get_draft_profile(name).template_dir


def write_profile_content(profile: DraftProfile, draft_dir: os.PathLike, content: str) -> List[Path]:
draft_path = Path(draft_dir)
written: List[Path] = []
content_data = json.loads(content)

targets = [profile.content_file, *profile.content_mirrors]
for relative_path in targets:
path = draft_path / relative_path
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
written.append(path)

if profile.timeline_content_file:
timelines_dir = draft_path / "Timelines"
if timelines_dir.exists():
timeline_dirs = [path for path in timelines_dir.iterdir() if path.is_dir()]
timeline_id = content_data.get("id")
if timeline_id and timeline_dirs:
timeline_dir = timeline_dirs[0]
desired_timeline_dir = timelines_dir / timeline_id
if timeline_dir != desired_timeline_dir:
if desired_timeline_dir.exists():
shutil.rmtree(desired_timeline_dir)
timeline_dir.rename(desired_timeline_dir)
timeline_dirs = [desired_timeline_dir]

for timeline_dir in timeline_dirs:
timeline_targets = set(targets)
timeline_targets.add(profile.timeline_content_file)
for relative_path in timeline_targets:
path = timeline_dir / relative_path
path.write_text(content, encoding="utf-8")
written.append(path)

if timeline_id:
now_us = int(time.time() * 1_000_000)
project = {
"config": {
"color_space": -1,
"render_index_track_mode_on": False,
"use_float_render": False,
},
"create_time": now_us,
"id": timeline_id,
"main_timeline_id": timeline_id,
"timelines": [
{
"create_time": now_us,
"id": timeline_id,
"is_marked_delete": False,
"name": "时间线01",
"update_time": now_us,
}
],
"update_time": now_us,
"version": 0,
}
for relative_path in ("project.json", "project.json.bak"):
path = timelines_dir / relative_path
path.write_text(json.dumps(project, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
written.append(path)

layout_path = draft_path / "timeline_layout.json"
if layout_path.exists():
layout = json.loads(layout_path.read_text(encoding="utf-8"))
for item in layout.get("dockItems", []):
item["timelineIds"] = [timeline_id]
item["timelineNames"] = ["时间线01"]
layout_path.write_text(json.dumps(layout, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
written.append(layout_path)

return written
3 changes: 2 additions & 1 deletion pyJianYingDraft/draft_folder.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from typing import List

from .script_file import Script_file
from draft_profiles import get_draft_profile

class Draft_folder:
"""管理一个文件夹及其内的一系列草稿"""
Expand Down Expand Up @@ -81,7 +82,7 @@ def load_template(self, draft_name: str) -> Script_file:
if not os.path.exists(draft_path):
raise FileNotFoundError(f"草稿文件夹 {draft_name} 不存在")

return Script_file.load_template(os.path.join(draft_path, "draft_info.json"))
return Script_file.load_template(os.path.join(draft_path, get_draft_profile().content_file))

def duplicate_as_template(self, template_name: str, new_draft_name: str, allow_replace: bool = False) -> Script_file:
"""复制一份给定的草稿, 并在复制出的新草稿上进行编辑
Expand Down
40 changes: 14 additions & 26 deletions pyJianYingDraft/script_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from .track import Track_type, Base_track, Track

from settings.local import IS_CAPCUT_ENV
from draft_profiles import get_draft_profile
from .metadata import Video_scene_effect_type, Video_character_effect_type, Filter_type, Font_type

class Script_material:
Expand Down Expand Up @@ -98,7 +99,7 @@ def __contains__(self, item) -> bool:
else:
raise TypeError("Invalid argument type '%s'" % type(item))

def export_json(self) -> Dict[str, List[Any]]:
def export_json(self, is_capcut_env: Optional[bool] = None) -> Dict[str, List[Any]]:
result = {
"ai_translates": [],
"audio_balances": [],
Expand Down Expand Up @@ -146,8 +147,10 @@ def export_json(self) -> Dict[str, List[Any]]:
"vocal_separations": []
}

# 根据IS_CAPCUT_ENV决定使用common_mask还是masks
if IS_CAPCUT_ENV:
# 根据当前草稿 profile 决定使用 common_mask 还是 masks
if is_capcut_env is None:
is_capcut_env = get_draft_profile().is_capcut_env
if is_capcut_env:
result["common_mask"] = self.masks
else:
result["masks"] = self.masks
Expand Down Expand Up @@ -876,34 +879,19 @@ def inspect_material(self) -> None:
if effect["type"] == "text_effect":
print("\tResource id: %s '%s'" % (effect["resource_id"], effect.get("name", "")))

def dumps(self) -> str:
def dumps(self, profile=None) -> str:
"""将草稿文件内容导出为JSON字符串"""
if profile is None:
profile = get_draft_profile()
self.content["fps"] = self.fps
self.content["duration"] = self.duration
self.content["canvas_config"] = {"width": self.width, "height": self.height, "ratio": "original"}
self.content["materials"] = self.materials.export_json()

self.content["last_modified_platform"] = {
"app_id": 359289,
"app_source": "cc",
"app_version": "6.5.0",
"device_id": "c4ca4238a0b923820dcc509a6f75849b",
"hard_disk_id": "307563e0192a94465c0e927fbc482942",
"mac_address": "c3371f2d4fb02791c067ce44d8fb4ed5",
"os": "mac",
"os_version": "15.5"
}
self.content["materials"] = self.materials.export_json(profile.is_capcut_env)

self.content["platform"] = {
"app_id": 359289,
"app_source": "cc",
"app_version": "6.5.0",
"device_id": "c4ca4238a0b923820dcc509a6f75849b",
"hard_disk_id": "307563e0192a94465c0e927fbc482942",
"mac_address": "c3371f2d4fb02791c067ce44d8fb4ed5",
"os": "mac",
"os_version": "15.5"
}
platform = profile.platform
if platform:
self.content["last_modified_platform"] = dict(platform)
self.content["platform"] = dict(platform)

# 合并导入的素材
for material_type, material_list in self.imported_materials.items():
Expand Down
Loading