├── template ├── draft_biz_config.json ├── performance_opt_info.json ├── common_attachment │ ├── aigc_aigc_generate.json │ └── attachment_script_video.json ├── draft_agency_config.json ├── draft_settings ├── attachment_pc_common.json ├── attachment_editing.json ├── draft_meta_info.json ├── template.tmp ├── template-2.tmp ├── draft_info.json └── draft_info.json.bak ├── template_jianying ├── draft_biz_config.json ├── draft_agency_config.json ├── draft_cover.jpg ├── draft_settings ├── attachment_pc_common.json ├── draft_meta_info.json ├── template.tmp ├── draft_info.json ├── template-2.tmp ├── draft_info.json.bak └── .backup │ ├── 20250516114846_07a08f0b2c07e8e8f544c49e495344ea.save.bak │ └── 20250516114846_541eea452c3d8402ca82d1bb791a9eb6.save.bak ├── requirements.txt ├── requirements-mcp.txt ├── .gitignore ├── mcp_config.json ├── pattern └── README.md ├── .flake8 ├── pyJianYingDraft ├── exceptions.py ├── effect_segment.py ├── metadata │ ├── mask_meta.py │ ├── __init__.py │ ├── capcut_mask_meta.py │ ├── effect_meta.py │ └── capcut_audio_effect_meta.py ├── util.py ├── __init__.py ├── keyframe.py ├── time_util.py ├── draft_content_template.json ├── draft_folder.py ├── animation.py ├── track.py ├── segment.py └── audio_segment.py ├── rest_client_test.http ├── draft_cache.py ├── settings ├── __init__.py └── local.py ├── config.json.example ├── pyproject.toml ├── oss.py ├── create_draft.py ├── examples └── example_capcut_effect.py ├── util.py ├── add_sticker_impl.py ├── add_effect_impl.py ├── get_duration_impl.py ├── MCP_文档_中文.md ├── save_task_cache.py ├── add_subtitle_impl.py ├── MCP_Documentation_English.md ├── README-zh.md ├── add_audio_track.py ├── add_video_keyframe_impl.py ├── downloader.py ├── README.md └── test_mcp_client.py /template/draft_biz_config.json: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /template_jianying/draft_biz_config.json: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /template/performance_opt_info.json: -------------------------------------------------------------------------------- 1 | {"manual_cancle_precombine_segs":null} -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | imageio 2 | psutil 3 | flask 4 | requests 5 | oss2 6 | json5 -------------------------------------------------------------------------------- /requirements-mcp.txt: -------------------------------------------------------------------------------- 1 | # MCP相关依赖 2 | mcp>=1.0.0 3 | aiohttp>=3.8.0 4 | pydantic>=2.0.0 -------------------------------------------------------------------------------- /template_jianying/draft_agency_config.json: -------------------------------------------------------------------------------- 1 | {"marterials":null,"use_converter":false,"video_resolution":720} -------------------------------------------------------------------------------- /template_jianying/draft_cover.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sun-guannan/VectCutAPI/HEAD/template_jianying/draft_cover.jpg -------------------------------------------------------------------------------- /template/common_attachment/aigc_aigc_generate.json: -------------------------------------------------------------------------------- 1 | {"aigc_aigc_generate":{"aigc_generate_segment_list":[],"version":"1.0.0"}} 2 | -------------------------------------------------------------------------------- /template_jianying/draft_settings: -------------------------------------------------------------------------------- 1 | [General] 2 | draft_create_time=1747367326 3 | draft_last_edit_time=1747367328 4 | real_edit_keys=1 5 | real_edit_seconds=2 6 | -------------------------------------------------------------------------------- /template/draft_agency_config.json: -------------------------------------------------------------------------------- 1 | {"is_auto_agency_enabled":false,"is_auto_agency_popup":false,"is_single_agency_mode":false,"marterials":null,"use_converter":false,"video_resolution":720} -------------------------------------------------------------------------------- /template/draft_settings: -------------------------------------------------------------------------------- 1 | [General] 2 | cloud_last_modify_platform=mac 3 | draft_create_time=1751876007 4 | draft_last_edit_time=1751876105 5 | real_edit_keys=1 6 | real_edit_seconds=8 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | ignored 3 | 4 | build/ 5 | dist/ 6 | *.egg-info/ 7 | 8 | .vscode/ 9 | .idea/ 10 | .DS_Store 11 | 12 | config.json 13 | ./resource 14 | 15 | .venv/ 16 | venv/ 17 | tmp/ -------------------------------------------------------------------------------- /template/common_attachment/attachment_script_video.json: -------------------------------------------------------------------------------- 1 | {"script_video":{"attachment_valid":false,"language":"","overdub_recover":[],"overdub_sentence_ids":[],"parts":[],"sync_subtitle":false,"translate_segments":[],"translate_type":"","version":"1.0.0"}} 2 | -------------------------------------------------------------------------------- /template_jianying/attachment_pc_common.json: -------------------------------------------------------------------------------- 1 | {"ai_packaging_infos":[],"ai_packaging_report_info":{"caption_id_list":[],"task_id":"","text_style":"","tos_id":"","video_category":""},"commercial_music_category_ids":[],"pc_feature_flag":0,"recognize_tasks":[],"template_item_infos":[],"unlock_template_ids":[]} -------------------------------------------------------------------------------- /mcp_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "mcpServers": { 3 | "capcut-api": { 4 | "command": "python3.10", 5 | "args": ["mcp_server.py"], 6 | "cwd": "/Users/chuham/Downloads/CapCutAPI-dev", 7 | "env": { 8 | "PYTHONPATH": "/Users/chuham/Downloads/CapCutAPI-dev" 9 | } 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /pattern/README.md: -------------------------------------------------------------------------------- 1 | # Pattern Gallery 2 | 3 | ## 001-words.py 4 | 5 | [source](001-words.py) 6 | 7 | [![Words](https://img.youtube.com/vi/HLSHaJuNtBw/hqdefault.jpg)](https://www.youtube.com/watch?v=HLSHaJuNtBw) 8 | 9 | ## 002-relationship.py 10 | 11 | [source](002-relationship.py) 12 | 13 | [![Relationship](https://img.youtube.com/vi/f2Q1OI_SQZo/hqdefault.jpg)](https://www.youtube.com/watch?v=f2Q1OI_SQZo) 14 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | extend-ignore = E301, E302, E303, E305, E306, E701, F403, F405 3 | exclude = 4 | .git, 5 | __pycache__, 6 | ignored, 7 | build, 8 | pyJianYingDraft/metadata/animation_meta.py, 9 | pyJianYingDraft/metadata/audio_effect_meta.py, 10 | pyJianYingDraft/metadata/filter_meta.py, 11 | pyJianYingDraft/metadata/video_effect_meta.py, 12 | pyJianYingDraft/metadata/transition_meta.py, 13 | pyJianYingDraft\metadata\font_meta.py 14 | max-line-length = 140 15 | -------------------------------------------------------------------------------- /pyJianYingDraft/exceptions.py: -------------------------------------------------------------------------------- 1 | """自定义异常类""" 2 | 3 | class TrackNotFound(NameError): 4 | """未找到满足条件的轨道""" 5 | class AmbiguousTrack(ValueError): 6 | """找到多个满足条件的轨道""" 7 | class SegmentOverlap(ValueError): 8 | """新片段与已有的轨道片段重叠""" 9 | 10 | class MaterialNotFound(NameError): 11 | """未找到满足条件的素材""" 12 | class AmbiguousMaterial(ValueError): 13 | """找到多个满足条件的素材""" 14 | 15 | class ExtensionFailed(ValueError): 16 | """替换素材时延伸片段失败""" 17 | 18 | class DraftNotFound(NameError): 19 | """未找到草稿""" 20 | class AutomationError(Exception): 21 | """自动化操作失败""" 22 | class ExportTimeout(Exception): 23 | """导出超时""" 24 | -------------------------------------------------------------------------------- /template/attachment_pc_common.json: -------------------------------------------------------------------------------- 1 | {"ai_packaging_infos":[],"ai_packaging_report_info":{"caption_id_list":[],"commercial_material":"","material_source":"","method":"","page_from":"","style":"","task_id":"","text_style":"","tos_id":"","video_category":""},"broll":{"ai_packaging_infos":[],"ai_packaging_report_info":{"caption_id_list":[],"commercial_material":"","material_source":"","method":"","page_from":"","style":"","task_id":"","text_style":"","tos_id":"","video_category":""}},"commercial_music_category_ids":[],"pc_feature_flag":0,"recognize_tasks":[],"reference_lines_config":{"horizontal_lines":[],"is_lock":false,"is_visible":false,"vertical_lines":[]},"safe_area_type":0,"template_item_infos":[],"unlock_template_ids":[]} -------------------------------------------------------------------------------- /rest_client_test.http: -------------------------------------------------------------------------------- 1 | ###添加文本 2 | POST http://localhost:9001/add_text 3 | Content-Type: application/json 4 | 5 | { 6 | "text": "你好,世界!", 7 | "start": 0, 8 | "end": 3, 9 | "font": "SourceHanSansCN_Regular", 10 | "font_color": "#FF0000", 11 | "font_size": 30.0 12 | } 13 | 14 | ###添加视频 15 | POST http://localhost:9001/add_video 16 | Content-Type: application/json 17 | 18 | { 19 | "video_url": "http://example.com/video.mp4", 20 | "start": 0, 21 | "end": 10, 22 | "width": 1080, 23 | "height": 1920 24 | } 25 | 26 | ###保存草稿 27 | POST http://localhost:9001/save_draft 28 | Content-Type: application/json 29 | 30 | { 31 | "draft_id": "dfd_cat_1752912902_0cae2e04", 32 | "draft_folder":"/Users/xxx/Movies/JianyingPro/User Data/Projects/com.lveditor.draft/" 33 | } -------------------------------------------------------------------------------- /template/attachment_editing.json: -------------------------------------------------------------------------------- 1 | {"editing_draft":{"ai_remove_filter_words":{"enter_source":"","right_id":""},"ai_shorts_info":{"report_params":"","type":0},"digital_human_template_to_video_info":{"has_upload_material":false,"template_type":0},"draft_used_recommend_function":"","edit_type":0,"eye_correct_enabled_multi_face_time":0,"has_adjusted_render_layer":false,"is_open_expand_player":false,"is_use_adjust":false,"is_use_edit_multi_camera":false,"is_use_lock_object":false,"is_use_loudness_unify":false,"is_use_retouch_face":false,"is_use_smart_adjust_color":false,"is_use_smart_motion":false,"is_use_text_to_audio":false,"material_edit_session":{"material_edit_info":[],"session_id":"","session_time":0},"profile_entrance_type":"","publish_enter_from":"","publish_type":"","single_function_type":0,"text_convert_case_types":[],"version":"1.0.0","video_recording_create_draft":""}} 2 | -------------------------------------------------------------------------------- /draft_cache.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | import pyJianYingDraft as draft 3 | from typing import Dict 4 | 5 | # Modify global variable, use OrderedDict to implement LRU cache, limit the maximum number to 10000 6 | DRAFT_CACHE: Dict[str, 'draft.Script_file'] = OrderedDict() # Use Dict for type hinting 7 | MAX_CACHE_SIZE = 10000 8 | 9 | def update_cache(key: str, value: draft.Script_file) -> None: 10 | """Update LRU cache""" 11 | if key in DRAFT_CACHE: 12 | # If the key exists, delete the old item 13 | DRAFT_CACHE.pop(key) 14 | elif len(DRAFT_CACHE) >= MAX_CACHE_SIZE: 15 | print(f"{key}, Cache is full, deleting the least recently used item") 16 | # If the cache is full, delete the least recently used item (the first item) 17 | DRAFT_CACHE.popitem(last=False) 18 | # Add new item to the end (most recently used) 19 | DRAFT_CACHE[key] = value -------------------------------------------------------------------------------- /settings/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | 配置包入口,导出所有配置 3 | """ 4 | 5 | # 从local模块导入所有配置,local模块已经导入了env和base模块的配置 6 | from .local import * 7 | 8 | __all__ = [ 9 | "IS_CAPCUT_ENV", 10 | "API_KEYS", 11 | "MODEL_CONFIG", 12 | "PURCHASE_LINKS", 13 | "LICENSE_CONFIG" 14 | ] 15 | 16 | # 提供一个获取平台信息的辅助函数 17 | def get_platform_info(): 18 | """ 19 | 获取平台信息,用于script_file.py中的dumps方法,cap_cut需要返回platform信息 20 | 21 | Returns: 22 | dict: 平台信息字典 23 | """ 24 | if not IS_CAPCUT_ENV: 25 | return None 26 | 27 | return { 28 | "app_id": 359289, 29 | "app_source": "cc", 30 | "app_version": "6.5.0", 31 | "device_id": "c4ca4238a0b923820dcc509a6f75849b", 32 | "hard_disk_id": "307563e0192a94465c0e927fbc482942", 33 | "mac_address": "c3371f2d4fb02791c067ce44d8fb4ed5", 34 | "os": "mac", 35 | "os_version": "15.5" 36 | } -------------------------------------------------------------------------------- /pyJianYingDraft/effect_segment.py: -------------------------------------------------------------------------------- 1 | """定义特效/滤镜片段类""" 2 | 3 | from typing import Union, Optional, List 4 | 5 | from .time_util import Timerange 6 | from .segment import Base_segment 7 | from .video_segment import Video_effect, Filter 8 | 9 | from .metadata import Video_scene_effect_type, Video_character_effect_type, Filter_type 10 | 11 | class Effect_segment(Base_segment): 12 | """放置在独立特效轨道上的特效片段""" 13 | 14 | effect_inst: Video_effect 15 | """相应的特效素材 16 | 17 | 在放入轨道时自动添加到素材列表中 18 | """ 19 | 20 | def __init__(self, effect_type: Union[Video_scene_effect_type, Video_character_effect_type], 21 | target_timerange: Timerange, params: Optional[List[Optional[float]]] = None): 22 | self.effect_inst = Video_effect(effect_type, params, apply_target_type=2) # 作用域为全局 23 | super().__init__(self.effect_inst.global_id, target_timerange) 24 | 25 | class Filter_segment(Base_segment): 26 | """放置在独立滤镜轨道上的滤镜片段""" 27 | 28 | material: Filter 29 | """相应的滤镜素材 30 | 31 | 在放入轨道时自动添加到素材列表中 32 | """ 33 | 34 | def __init__(self, meta: Filter_type, target_timerange: Timerange, intensity: float): 35 | self.material = Filter(meta.value, intensity) 36 | super().__init__(self.material.global_id, target_timerange) 37 | -------------------------------------------------------------------------------- /config.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "is_capcut_env": true, // Whether to use CapCut environment (true) or JianYing environment (false) 3 | "draft_domain": "https://www.capcutapi.top", // Base domain for draft operations 4 | "port": 9001, // Port number for the local server 5 | "preview_router": "/draft/downloader", // Router path for preview functionality 6 | "is_upload_draft": false, // Whether to upload drafts to remote storage 7 | "oss_config": { // General OSS (Object Storage Service) configuration 8 | "bucket_name": "your-bucket-name", // OSS bucket name for general storage 9 | "access_key_id": "your-access-key-id", // Access key ID for OSS authentication 10 | "access_key_secret": "your-access-key-secret", // Access key secret for OSS authentication 11 | "endpoint": "https://your-endpoint.aliyuncs.com" // OSS service endpoint URL 12 | }, 13 | "mp4_oss_config": { // MP4-specific OSS configuration 14 | "bucket_name": "your-mp4-bucket-name", // OSS bucket name for MP4 files 15 | "access_key_id": "your-access-key-id", // Access key ID for MP4 OSS authentication 16 | "access_key_secret": "your-access-key-secret", // Access key secret for MP4 OSS authentication 17 | "region": "your-region", // Region for MP4 storage 18 | "endpoint": "http://your-custom-domain" // Custom domain endpoint for MP4 access 19 | } 20 | } -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "capcut-api" 7 | version = "1.0.0" 8 | description = "Open source CapCut API tool with MCP support" 9 | readme = "README.md" 10 | requires-python = ">=3.10" 11 | classifiers = [ 12 | "Development Status :: 4 - Beta", 13 | "Intended Audience :: Developers", 14 | "License :: OSI Approved :: MIT License", 15 | "Operating System :: OS Independent", 16 | "Programming Language :: Python :: 3", 17 | "Programming Language :: Python :: 3.10", 18 | "Programming Language :: Python :: 3.11", 19 | "Programming Language :: Python :: 3.12", 20 | ] 21 | dependencies = [ 22 | "requests>=2.28.0", 23 | "Pillow>=9.0.0", 24 | "numpy>=1.21.0", 25 | "opencv-python>=4.6.0", 26 | "ffmpeg-python>=0.2.0", 27 | "pydantic>=2.0.0", 28 | "fastapi>=0.100.0", 29 | "uvicorn[standard]>=0.23.0", 30 | ] 31 | 32 | [project.optional-dependencies] 33 | mcp = [ 34 | "mcp>=1.0.0", 35 | "aiohttp>=3.8.0", 36 | "websockets>=11.0", 37 | "jsonrpc-base>=2.2.0", 38 | "jsonrpc-websocket>=3.1.0", 39 | "jsonrpc-async>=2.1.0", 40 | ] 41 | 42 | [project.urls] 43 | Homepage = "https://github.com/ashreo/CapCutAPI" 44 | Repository = "https://github.com/ashreo/CapCutAPI.git" 45 | Issues = "https://github.com/ashreo/CapCutAPI/issues" -------------------------------------------------------------------------------- /template_jianying/draft_meta_info.json: -------------------------------------------------------------------------------- 1 | {"cloud_package_completed_time":"","draft_cloud_capcut_purchase_info":"","draft_cloud_last_action_download":false,"draft_cloud_materials":[],"draft_cloud_purchase_info":"","draft_cloud_template_id":"","draft_cloud_tutorial_info":"","draft_cloud_videocut_purchase_info":"","draft_cover":"draft_cover.jpg","draft_deeplink_url":"","draft_enterprise_info":{"draft_enterprise_extra":"","draft_enterprise_id":"","draft_enterprise_name":"","enterprise_material":[]},"draft_fold_path":"/Users/sunguannan/Movies/JianyingPro/User Data/Projects/com.lveditor.draft/5月16日","draft_id":"EC6E4680-CEF1-408B-8BA9-5E13124C61D2","draft_is_ai_packaging_used":false,"draft_is_ai_shorts":false,"draft_is_ai_translate":false,"draft_is_article_video_draft":false,"draft_is_from_deeplink":"false","draft_is_invisible":false,"draft_materials":[{"type":0,"value":[]},{"type":1,"value":[]},{"type":2,"value":[]},{"type":3,"value":[]},{"type":6,"value":[]},{"type":7,"value":[]},{"type":8,"value":[]}],"draft_materials_copied_info":[],"draft_name":"5月16日","draft_new_version":"","draft_removable_storage_device":"","draft_root_path":"/Users/sunguannan/Movies/JianyingPro/User Data/Projects/com.lveditor.draft","draft_segment_extra_info":[],"draft_timeline_materials_size_":7867,"draft_type":"","tm_draft_cloud_completed":"","tm_draft_cloud_modified":0,"tm_draft_create":1747367326219300,"tm_draft_modified":1747367328691892,"tm_draft_removed":0,"tm_duration":0} -------------------------------------------------------------------------------- /pyJianYingDraft/metadata/mask_meta.py: -------------------------------------------------------------------------------- 1 | """视频蒙版元数据""" 2 | 3 | from .effect_meta import Effect_enum 4 | 5 | class Mask_meta: 6 | """蒙版元数据""" 7 | 8 | name: str 9 | """转场名称""" 10 | 11 | resource_type: str 12 | """资源类型, 与蒙版形状相关""" 13 | 14 | resource_id: str 15 | """资源ID""" 16 | effect_id: str 17 | """效果ID""" 18 | md5: str 19 | 20 | default_aspect_ratio: float 21 | """默认宽高比(宽高都是相对素材的比例)""" 22 | 23 | def __init__(self, name: str, resource_type: str, resource_id: str, effect_id: str, md5: str, default_aspect_ratio: float): 24 | self.name = name 25 | self.resource_type = resource_type 26 | self.resource_id = resource_id 27 | self.effect_id = effect_id 28 | self.md5 = md5 29 | 30 | self.default_aspect_ratio = default_aspect_ratio 31 | 32 | class Mask_type(Effect_enum): 33 | """蒙版类型""" 34 | 35 | 线性 = Mask_meta("线性", "line", "6791652175668843016", "636071", "1f467b8b9bb94cecc46d916219b7940a", 1.0) 36 | """默认遮挡下方部分""" 37 | 镜面 = Mask_meta("镜面", "mirror", "6791699060140020232", "636073", "b2c0516d1f737f4542fb9b2862907817", 1.0) 38 | """默认保留两线之间部分""" 39 | 圆形 = Mask_meta("圆形", "circle", "6791700663249146381", "636075", "9a55eae0e99ee6d1ecbc6defaf0501ec", 1.0) 40 | 矩形 = Mask_meta("矩形", "rectangle", "6791700809454195207", "636077", "ef361d96c456cd6077c76d737f98898d", 1.0) 41 | 爱心 = Mask_meta("爱心", "geometric_shape", "6794051276482023949", "636079", "0bf09fa1e3a32464fed4f71e49a8ab01", 1.115) 42 | 星形 = Mask_meta("星形", "geometric_shape", "6794051169434997255", "636081", "155612dee601d3f5422a3fbeabc7610c", 1.05) 43 | -------------------------------------------------------------------------------- /oss.py: -------------------------------------------------------------------------------- 1 | 2 | import oss2 3 | import os 4 | from settings.local import OSS_CONFIG, MP4_OSS_CONFIG 5 | 6 | def upload_to_oss(path): 7 | # Create OSS client 8 | auth = oss2.Auth(OSS_CONFIG['access_key_id'], OSS_CONFIG['access_key_secret']) 9 | bucket = oss2.Bucket(auth, OSS_CONFIG['endpoint'], OSS_CONFIG['bucket_name']) 10 | 11 | # Upload file 12 | object_name = os.path.basename(path) 13 | bucket.put_object_from_file(object_name, path) 14 | 15 | # Generate signed URL (valid for 24 hours) 16 | url = bucket.sign_url('GET', object_name, 24 * 60 * 60) 17 | 18 | # Clean up temporary file 19 | os.remove(path) 20 | 21 | return url 22 | 23 | def upload_mp4_to_oss(path): 24 | """Special method for uploading MP4 files, using custom domain and v4 signature""" 25 | # Directly use credentials from the configuration file 26 | auth = oss2.AuthV4(MP4_OSS_CONFIG['access_key_id'], MP4_OSS_CONFIG['access_key_secret']) 27 | 28 | # Create OSS client with custom domain 29 | bucket = oss2.Bucket( 30 | auth, 31 | MP4_OSS_CONFIG['endpoint'], 32 | MP4_OSS_CONFIG['bucket_name'], 33 | region=MP4_OSS_CONFIG['region'], 34 | is_cname=True 35 | ) 36 | 37 | # Upload file 38 | object_name = os.path.basename(path) 39 | bucket.put_object_from_file(object_name, path) 40 | 41 | # Generate pre-signed URL (valid for 24 hours), set slash_safe to True to avoid path escaping 42 | url = bucket.sign_url('GET', object_name, 24 * 60 * 60, slash_safe=True) 43 | 44 | # Clean up temporary file 45 | os.remove(path) 46 | 47 | return url -------------------------------------------------------------------------------- /template/draft_meta_info.json: -------------------------------------------------------------------------------- 1 | {"cloud_draft_cover":true,"cloud_draft_sync":true,"cloud_package_completed_time":"","draft_cloud_capcut_purchase_info":"","draft_cloud_last_action_download":false,"draft_cloud_package_type":"","draft_cloud_purchase_info":"","draft_cloud_template_id":"","draft_cloud_tutorial_info":"","draft_cloud_videocut_purchase_info":"","draft_cover":"draft_cover.jpg","draft_deeplink_url":"","draft_enterprise_info":{"draft_enterprise_extra":"","draft_enterprise_id":"","draft_enterprise_name":"","enterprise_material":[]},"draft_fold_path":"/Users/sunguannan/Movies/CapCut/User Data/Projects/com.lveditor.draft/0707","draft_id":"989869B1-B560-489C-9C6F-4B444F24BF36","draft_is_ae_produce":false,"draft_is_ai_packaging_used":false,"draft_is_ai_shorts":false,"draft_is_ai_translate":false,"draft_is_article_video_draft":false,"draft_is_cloud_temp_draft":false,"draft_is_from_deeplink":"false","draft_is_invisible":false,"draft_materials":[{"type":0,"value":[]},{"type":1,"value":[]},{"type":2,"value":[]},{"type":3,"value":[]},{"type":6,"value":[]},{"type":7,"value":[]},{"type":8,"value":[]}],"draft_materials_copied_info":[],"draft_name":"0707","draft_need_rename_folder":false,"draft_new_version":"","draft_removable_storage_device":"","draft_root_path":"/Users/sunguannan/Movies/CapCut/User Data/Projects/com.lveditor.draft","draft_segment_extra_info":[],"draft_timeline_materials_size_":2851,"draft_type":"","tm_draft_cloud_completed":"","tm_draft_cloud_entry_id":-1,"tm_draft_cloud_modified":0,"tm_draft_cloud_parent_entry_id":-1,"tm_draft_cloud_space_id":-1,"tm_draft_cloud_user_id":-1,"tm_draft_create":1751876007857286,"tm_draft_modified":1751876105604683,"tm_draft_removed":0,"tm_duration":0} -------------------------------------------------------------------------------- /create_draft.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import pyJianYingDraft as draft 3 | import time 4 | from draft_cache import DRAFT_CACHE, update_cache 5 | 6 | def create_draft(width=1080, height=1920): 7 | """ 8 | Create new CapCut draft 9 | :param width: Video width, default 1080 10 | :param height: Video height, default 1920 11 | :return: (draft_name, draft_path, draft_id, draft_url) 12 | """ 13 | # Generate timestamp and draft_id 14 | unix_time = int(time.time()) 15 | unique_id = uuid.uuid4().hex[:8] # Take the first 8 digits of UUID 16 | draft_id = f"dfd_cat_{unix_time}_{unique_id}" # Use Unix timestamp and UUID combination 17 | 18 | # Create CapCut draft with specified resolution 19 | script = draft.Script_file(width, height) 20 | 21 | # Store in global cache 22 | update_cache(draft_id, script) 23 | 24 | return script, draft_id 25 | 26 | def get_or_create_draft(draft_id=None, width=1080, height=1920): 27 | """ 28 | Get or create CapCut draft 29 | :param draft_id: Draft ID, if None or corresponding zip file not found, create new draft 30 | :param width: Video width, default 1080 31 | :param height: Video height, default 1920 32 | :return: (draft_name, draft_path, draft_id, draft_dir, script) 33 | """ 34 | global DRAFT_CACHE # Declare use of global variable 35 | 36 | if draft_id is not None and draft_id in DRAFT_CACHE: 37 | # Get existing draft information from cache 38 | print(f"Getting draft from cache: {draft_id}") 39 | # Update last access time 40 | update_cache(draft_id, DRAFT_CACHE[draft_id]) 41 | return draft_id, DRAFT_CACHE[draft_id] 42 | 43 | # Create new draft logic 44 | print("Creating new draft") 45 | script, generate_draft_id = create_draft( 46 | width=width, 47 | height=height, 48 | ) 49 | return generate_draft_id, script 50 | -------------------------------------------------------------------------------- /settings/local.py: -------------------------------------------------------------------------------- 1 | """ 2 | 本地配置模块,用于从本地配置文件中加载配置 3 | """ 4 | 5 | import os 6 | import json5 # 替换原来的json模块 7 | 8 | # 配置文件路径 9 | CONFIG_FILE_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "config.json") 10 | 11 | # 默认配置 12 | IS_CAPCUT_ENV = True 13 | 14 | # 默认域名配置 15 | DRAFT_DOMAIN = "https://www.install-ai-guider.top" 16 | 17 | # 默认预览路由 18 | PREVIEW_ROUTER = "/draft/downloader" 19 | 20 | # 是否上传草稿文件 21 | IS_UPLOAD_DRAFT = False 22 | 23 | # 端口号 24 | PORT = 9000 25 | 26 | OSS_CONFIG = [] 27 | MP4_OSS_CONFIG=[] 28 | 29 | # 尝试加载本地配置文件 30 | if os.path.exists(CONFIG_FILE_PATH): 31 | try: 32 | with open(CONFIG_FILE_PATH, "r", encoding="utf-8") as f: 33 | # 使用json5.load替代json.load 34 | local_config = json5.load(f) 35 | 36 | # 更新是否是国际版 37 | if "is_capcut_env" in local_config: 38 | IS_CAPCUT_ENV = local_config["is_capcut_env"] 39 | 40 | # 更新域名配置 41 | if "draft_domain" in local_config: 42 | DRAFT_DOMAIN = local_config["draft_domain"] 43 | 44 | # 更新端口号配置 45 | if "port" in local_config: 46 | PORT = local_config["port"] 47 | 48 | # 更新预览路由 49 | if "preview_router" in local_config: 50 | PREVIEW_ROUTER = local_config["preview_router"] 51 | 52 | # 更新是否上传草稿文件 53 | if "is_upload_draft" in local_config: 54 | IS_UPLOAD_DRAFT = local_config["is_upload_draft"] 55 | 56 | # 更新OSS配置 57 | if "oss_config" in local_config: 58 | OSS_CONFIG = local_config["oss_config"] 59 | 60 | # 更新MP4 OSS配置 61 | if "mp4_oss_config" in local_config: 62 | MP4_OSS_CONFIG = local_config["mp4_oss_config"] 63 | 64 | except Exception as e: 65 | # 配置文件加载失败,使用默认配置 66 | pass -------------------------------------------------------------------------------- /pyJianYingDraft/util.py: -------------------------------------------------------------------------------- 1 | """辅助函数,主要与模板模式有关""" 2 | 3 | import inspect 4 | 5 | from typing import Union, Type 6 | from typing import List, Dict, Any 7 | 8 | JsonExportable = Union[int, float, bool, str, List["JsonExportable"], Dict[str, "JsonExportable"]] 9 | 10 | def provide_ctor_defaults(cls: Type) -> Dict[str, Any]: 11 | """为构造函数提供默认值,以绕开构造函数的参数限制""" 12 | 13 | signature = inspect.signature(cls.__init__) 14 | provided_defaults: Dict[str, Any] = {} 15 | 16 | for name, param in signature.parameters.items(): 17 | if name == 'self': continue 18 | if param.default is not inspect.Parameter.empty: continue 19 | 20 | if param.annotation is int or param.annotation is float: 21 | provided_defaults[name] = 0 22 | elif param.annotation is str: 23 | provided_defaults[name] = "" 24 | elif param.annotation is bool: 25 | provided_defaults[name] = False 26 | else: 27 | raise ValueError(f"Unsupported parameter type: {param.annotation}") 28 | 29 | return provided_defaults 30 | 31 | def assign_attr_with_json(obj: object, attrs: List[str], json_data: Dict[str, Any]): 32 | """根据json数据赋值给指定的对象属性 33 | 34 | 若有复杂类型,则尝试调用其`import_json`方法进行构造 35 | """ 36 | type_hints: Dict[str, Type] = {} 37 | for cls in obj.__class__.__mro__: 38 | if '__annotations__' in cls.__dict__: 39 | type_hints.update(cls.__annotations__) 40 | 41 | for attr in attrs: 42 | if hasattr(type_hints[attr], 'import_json'): 43 | obj.__setattr__(attr, type_hints[attr].import_json(json_data[attr])) 44 | else: 45 | obj.__setattr__(attr, type_hints[attr](json_data[attr])) 46 | 47 | def export_attr_to_json(obj: object, attrs: List[str]) -> Dict[str, JsonExportable]: 48 | """将对象属性导出为json数据 49 | 50 | 若有复杂类型,则尝试调用其`export_json`方法进行导出 51 | """ 52 | json_data: Dict[str, Any] = {} 53 | for attr in attrs: 54 | if hasattr(getattr(obj, attr), 'export_json'): 55 | json_data[attr] = getattr(obj, attr).export_json() 56 | else: 57 | json_data[attr] = getattr(obj, attr) 58 | return json_data 59 | -------------------------------------------------------------------------------- /pyJianYingDraft/metadata/__init__.py: -------------------------------------------------------------------------------- 1 | """记录各种特效/音效/滤镜等的元数据""" 2 | 3 | from .effect_meta import Effect_meta, Effect_param_instance 4 | 5 | from .font_meta import Font_type 6 | from .mask_meta import Mask_type, Mask_meta 7 | from .capcut_mask_meta import CapCut_Mask_type 8 | from .filter_meta import Filter_type 9 | from .transition_meta import Transition_type 10 | from .capcut_transition_meta import CapCut_Transition_type 11 | from .animation_meta import Intro_type, Outro_type, Group_animation_type 12 | from .capcut_animation_meta import CapCut_Intro_type, CapCut_Outro_type, CapCut_Group_animation_type 13 | from .animation_meta import Text_intro, Text_outro, Text_loop_anim 14 | from .capcut_text_animation_meta import CapCut_Text_intro, CapCut_Text_outro, CapCut_Text_loop_anim 15 | from .audio_effect_meta import Audio_scene_effect_type, Tone_effect_type, Speech_to_song_type 16 | from .capcut_audio_effect_meta import CapCut_Voice_filters_effect_type, CapCut_Voice_characters_effect_type, CapCut_Speech_to_song_effect_type 17 | from .video_effect_meta import Video_scene_effect_type, Video_character_effect_type 18 | from .capcut_effect_meta import CapCut_Video_scene_effect_type, CapCut_Video_character_effect_type 19 | 20 | __all__ = [ 21 | "Effect_meta", 22 | "Effect_param_instance", 23 | "Mask_type", 24 | "Mask_meta", 25 | "CapCut_Mask_type", 26 | "Filter_type", 27 | "Font_type", 28 | "Transition_type", 29 | "CapCut_Transition_type", 30 | "Intro_type", 31 | "Outro_type", 32 | "Group_animation_type", 33 | "CapCut_Intro_type", 34 | "CapCut_Outro_type", 35 | "CapCut_Group_animation_type", 36 | "Text_intro", 37 | "Text_outro", 38 | "Text_loop_anim", 39 | "CapCut_Text_intro", 40 | "CapCut_Text_outro", 41 | "CapCut_Text_loop_anim", 42 | "Audio_scene_effect_type", 43 | "Tone_effect_type", 44 | "Speech_to_song_type", 45 | "CapCut_Voice_filters_effect_type", 46 | "CapCut_Voice_characters_effect_type", 47 | "CapCut_Speech_to_song_effect_type", 48 | "Video_scene_effect_type", 49 | "Video_character_effect_type", 50 | "CapCut_Video_scene_effect_type", 51 | "CapCut_Video_character_effect_type" 52 | ] 53 | -------------------------------------------------------------------------------- /template_jianying/template.tmp: -------------------------------------------------------------------------------- 1 | {"canvas_config":{"height":0,"ratio":"original","width":0},"color_space":-1,"config":{"adjust_max_index":1,"attachment_info":[],"combination_max_index":1,"export_range":null,"extract_audio_last_index":1,"lyrics_recognition_id":"","lyrics_sync":true,"lyrics_taskinfo":[],"maintrack_adsorb":true,"material_save_mode":0,"multi_language_current":"none","multi_language_list":[],"multi_language_main":"none","multi_language_mode":"none","original_sound_last_index":1,"record_audio_last_index":1,"sticker_max_index":1,"subtitle_keywords_config":null,"subtitle_recognition_id":"","subtitle_sync":true,"subtitle_taskinfo":[],"system_font_list":[],"video_mute":false,"zoom_info_params":null},"cover":null,"create_time":0,"duration":0,"extra_info":null,"fps":30.0,"free_render_index_mode_on":false,"group_container":null,"id":"32F0961A-740A-4107-A2A1-02C5936204C8","keyframe_graph_list":[],"keyframes":{"adjusts":[],"audios":[],"effects":[],"filters":[],"handwrites":[],"stickers":[],"texts":[],"videos":[]},"last_modified_platform":{"app_id":0,"app_source":"","app_version":"","device_id":"","hard_disk_id":"","mac_address":"","os":"","os_version":""},"materials":{"ai_translates":[],"audio_balances":[],"audio_effects":[],"audio_fades":[],"audio_track_indexes":[],"audios":[],"beats":[],"canvases":[],"chromas":[],"color_curves":[],"digital_humans":[],"drafts":[],"effects":[],"flowers":[],"green_screens":[],"handwrites":[],"hsl":[],"images":[],"log_color_wheels":[],"loudnesses":[],"manual_deformations":[],"masks":[],"material_animations":[],"material_colors":[],"multi_language_refs":[],"placeholders":[],"plugin_effects":[],"primary_color_wheels":[],"realtime_denoises":[],"shapes":[],"smart_crops":[],"smart_relights":[],"sound_channel_mappings":[],"speeds":[],"stickers":[],"tail_leaders":[],"text_templates":[],"texts":[],"time_marks":[],"transitions":[],"video_effects":[],"video_trackings":[],"videos":[],"vocal_beautifys":[],"vocal_separations":[]},"mutable_config":null,"name":"","new_version":"75.0.0","platform":{"app_id":0,"app_source":"","app_version":"","device_id":"","hard_disk_id":"","mac_address":"","os":"","os_version":""},"relationships":[],"render_index_track_mode_on":false,"retouch_cover":null,"source":"default","static_cover_image_path":"","time_marks":null,"tracks":[],"update_time":0,"version":360000} -------------------------------------------------------------------------------- /template_jianying/draft_info.json: -------------------------------------------------------------------------------- 1 | {"canvas_config":{"height":1080,"ratio":"original","width":1920},"color_space":-1,"config":{"adjust_max_index":1,"attachment_info":[],"combination_max_index":1,"export_range":null,"extract_audio_last_index":1,"lyrics_recognition_id":"","lyrics_sync":true,"lyrics_taskinfo":[],"maintrack_adsorb":true,"material_save_mode":0,"multi_language_current":"none","multi_language_list":[],"multi_language_main":"none","multi_language_mode":"none","original_sound_last_index":1,"record_audio_last_index":1,"sticker_max_index":1,"subtitle_keywords_config":null,"subtitle_recognition_id":"","subtitle_sync":true,"subtitle_taskinfo":[],"system_font_list":[],"video_mute":false,"zoom_info_params":null},"cover":null,"create_time":0,"duration":0,"extra_info":null,"fps":30.0,"free_render_index_mode_on":false,"group_container":null,"id":"DF724EE8-CA13-430C-B9E1-643276C263DA","keyframe_graph_list":[],"keyframes":{"adjusts":[],"audios":[],"effects":[],"filters":[],"handwrites":[],"stickers":[],"texts":[],"videos":[]},"last_modified_platform":{"app_id":3704,"app_source":"lv","app_version":"5.9.0","device_id":"9c1d156ba20d5d2ef89a71f1a92596fd","hard_disk_id":"81f3587033961b2ad1d1ef9d11dcce33","mac_address":"5409d920e86b1169992326ea688e0715","os":"mac","os_version":"15.3.2"},"materials":{"ai_translates":[],"audio_balances":[],"audio_effects":[],"audio_fades":[],"audio_track_indexes":[],"audios":[],"beats":[],"canvases":[],"chromas":[],"color_curves":[],"digital_humans":[],"drafts":[],"effects":[],"flowers":[],"green_screens":[],"handwrites":[],"hsl":[],"images":[],"log_color_wheels":[],"loudnesses":[],"manual_deformations":[],"masks":[],"material_animations":[],"material_colors":[],"multi_language_refs":[],"placeholders":[],"plugin_effects":[],"primary_color_wheels":[],"realtime_denoises":[],"shapes":[],"smart_crops":[],"smart_relights":[],"sound_channel_mappings":[],"speeds":[],"stickers":[],"tail_leaders":[],"text_templates":[],"texts":[],"time_marks":[],"transitions":[],"video_effects":[],"video_trackings":[],"videos":[],"vocal_beautifys":[],"vocal_separations":[]},"mutable_config":null,"name":"","new_version":"110.0.0","platform":{"app_id":3704,"app_source":"lv","app_version":"5.9.0","device_id":"9c1d156ba20d5d2ef89a71f1a92596fd","hard_disk_id":"81f3587033961b2ad1d1ef9d11dcce33","mac_address":"5409d920e86b1169992326ea688e0715","os":"mac","os_version":"15.3.2"},"relationships":[],"render_index_track_mode_on":true,"retouch_cover":null,"source":"default","static_cover_image_path":"","time_marks":null,"tracks":[],"update_time":0,"version":360000} -------------------------------------------------------------------------------- /template_jianying/template-2.tmp: -------------------------------------------------------------------------------- 1 | {"canvas_config":{"height":1080,"ratio":"original","width":1920},"color_space":-1,"config":{"adjust_max_index":1,"attachment_info":[],"combination_max_index":1,"export_range":null,"extract_audio_last_index":1,"lyrics_recognition_id":"","lyrics_sync":true,"lyrics_taskinfo":[],"maintrack_adsorb":true,"material_save_mode":0,"multi_language_current":"none","multi_language_list":[],"multi_language_main":"none","multi_language_mode":"none","original_sound_last_index":1,"record_audio_last_index":1,"sticker_max_index":1,"subtitle_keywords_config":null,"subtitle_recognition_id":"","subtitle_sync":true,"subtitle_taskinfo":[],"system_font_list":[],"video_mute":false,"zoom_info_params":null},"cover":null,"create_time":0,"duration":0,"extra_info":null,"fps":30.0,"free_render_index_mode_on":false,"group_container":null,"id":"DF724EE8-CA13-430C-B9E1-643276C263DA","keyframe_graph_list":[],"keyframes":{"adjusts":[],"audios":[],"effects":[],"filters":[],"handwrites":[],"stickers":[],"texts":[],"videos":[]},"last_modified_platform":{"app_id":3704,"app_source":"lv","app_version":"5.9.0","device_id":"9c1d156ba20d5d2ef89a71f1a92596fd","hard_disk_id":"81f3587033961b2ad1d1ef9d11dcce33","mac_address":"5409d920e86b1169992326ea688e0715","os":"mac","os_version":"15.3.2"},"materials":{"ai_translates":[],"audio_balances":[],"audio_effects":[],"audio_fades":[],"audio_track_indexes":[],"audios":[],"beats":[],"canvases":[],"chromas":[],"color_curves":[],"digital_humans":[],"drafts":[],"effects":[],"flowers":[],"green_screens":[],"handwrites":[],"hsl":[],"images":[],"log_color_wheels":[],"loudnesses":[],"manual_deformations":[],"masks":[],"material_animations":[],"material_colors":[],"multi_language_refs":[],"placeholders":[],"plugin_effects":[],"primary_color_wheels":[],"realtime_denoises":[],"shapes":[],"smart_crops":[],"smart_relights":[],"sound_channel_mappings":[],"speeds":[],"stickers":[],"tail_leaders":[],"text_templates":[],"texts":[],"time_marks":[],"transitions":[],"video_effects":[],"video_trackings":[],"videos":[],"vocal_beautifys":[],"vocal_separations":[]},"mutable_config":null,"name":"","new_version":"110.0.0","platform":{"app_id":3704,"app_source":"lv","app_version":"5.9.0","device_id":"9c1d156ba20d5d2ef89a71f1a92596fd","hard_disk_id":"81f3587033961b2ad1d1ef9d11dcce33","mac_address":"5409d920e86b1169992326ea688e0715","os":"mac","os_version":"15.3.2"},"relationships":[],"render_index_track_mode_on":true,"retouch_cover":null,"source":"default","static_cover_image_path":"","time_marks":null,"tracks":[],"update_time":0,"version":360000} -------------------------------------------------------------------------------- /template_jianying/draft_info.json.bak: -------------------------------------------------------------------------------- 1 | {"canvas_config":{"height":1080,"ratio":"original","width":1920},"color_space":-1,"config":{"adjust_max_index":1,"attachment_info":[],"combination_max_index":1,"export_range":null,"extract_audio_last_index":1,"lyrics_recognition_id":"","lyrics_sync":true,"lyrics_taskinfo":[],"maintrack_adsorb":true,"material_save_mode":0,"multi_language_current":"none","multi_language_list":[],"multi_language_main":"none","multi_language_mode":"none","original_sound_last_index":1,"record_audio_last_index":1,"sticker_max_index":1,"subtitle_keywords_config":null,"subtitle_recognition_id":"","subtitle_sync":true,"subtitle_taskinfo":[],"system_font_list":[],"video_mute":false,"zoom_info_params":null},"cover":null,"create_time":0,"duration":0,"extra_info":null,"fps":30.0,"free_render_index_mode_on":false,"group_container":null,"id":"DF724EE8-CA13-430C-B9E1-643276C263DA","keyframe_graph_list":[],"keyframes":{"adjusts":[],"audios":[],"effects":[],"filters":[],"handwrites":[],"stickers":[],"texts":[],"videos":[]},"last_modified_platform":{"app_id":3704,"app_source":"lv","app_version":"5.9.0","device_id":"9c1d156ba20d5d2ef89a71f1a92596fd","hard_disk_id":"81f3587033961b2ad1d1ef9d11dcce33","mac_address":"5409d920e86b1169992326ea688e0715","os":"mac","os_version":"15.3.2"},"materials":{"ai_translates":[],"audio_balances":[],"audio_effects":[],"audio_fades":[],"audio_track_indexes":[],"audios":[],"beats":[],"canvases":[],"chromas":[],"color_curves":[],"digital_humans":[],"drafts":[],"effects":[],"flowers":[],"green_screens":[],"handwrites":[],"hsl":[],"images":[],"log_color_wheels":[],"loudnesses":[],"manual_deformations":[],"masks":[],"material_animations":[],"material_colors":[],"multi_language_refs":[],"placeholders":[],"plugin_effects":[],"primary_color_wheels":[],"realtime_denoises":[],"shapes":[],"smart_crops":[],"smart_relights":[],"sound_channel_mappings":[],"speeds":[],"stickers":[],"tail_leaders":[],"text_templates":[],"texts":[],"time_marks":[],"transitions":[],"video_effects":[],"video_trackings":[],"videos":[],"vocal_beautifys":[],"vocal_separations":[]},"mutable_config":null,"name":"","new_version":"110.0.0","platform":{"app_id":3704,"app_source":"lv","app_version":"5.9.0","device_id":"9c1d156ba20d5d2ef89a71f1a92596fd","hard_disk_id":"81f3587033961b2ad1d1ef9d11dcce33","mac_address":"5409d920e86b1169992326ea688e0715","os":"mac","os_version":"15.3.2"},"relationships":[],"render_index_track_mode_on":true,"retouch_cover":null,"source":"default","static_cover_image_path":"","time_marks":null,"tracks":[],"update_time":0,"version":360000} -------------------------------------------------------------------------------- /template/template.tmp: -------------------------------------------------------------------------------- 1 | {"canvas_config":{"background":null,"height":0,"ratio":"original","width":0},"color_space":-1,"config":{"adjust_max_index":1,"attachment_info":[],"combination_max_index":1,"export_range":null,"extract_audio_last_index":1,"lyrics_recognition_id":"","lyrics_sync":true,"lyrics_taskinfo":[],"maintrack_adsorb":true,"material_save_mode":0,"multi_language_current":"none","multi_language_list":[],"multi_language_main":"none","multi_language_mode":"none","original_sound_last_index":1,"record_audio_last_index":1,"sticker_max_index":1,"subtitle_keywords_config":null,"subtitle_recognition_id":"","subtitle_sync":true,"subtitle_taskinfo":[],"system_font_list":[],"use_float_render":false,"video_mute":false,"zoom_info_params":null},"cover":null,"create_time":0,"duration":0,"extra_info":null,"fps":30.0,"free_render_index_mode_on":false,"group_container":null,"id":"470E26D0-9077-4231-A7A7-619F75FF92B3","is_drop_frame_timecode":false,"keyframe_graph_list":[],"keyframes":{"adjusts":[],"audios":[],"effects":[],"filters":[],"handwrites":[],"stickers":[],"texts":[],"videos":[]},"last_modified_platform":{"app_id":0,"app_source":"","app_version":"","device_id":"","hard_disk_id":"","mac_address":"","os":"","os_version":""},"lyrics_effects":[],"materials":{"ai_translates":[],"audio_balances":[],"audio_effects":[],"audio_fades":[],"audio_track_indexes":[],"audios":[],"beats":[],"canvases":[],"chromas":[],"color_curves":[],"common_mask":[],"digital_human_model_dressing":[],"digital_humans":[],"drafts":[],"effects":[],"flowers":[],"green_screens":[],"handwrites":[],"hsl":[],"images":[],"log_color_wheels":[],"loudnesses":[],"manual_beautys":[],"manual_deformations":[],"material_animations":[],"material_colors":[],"multi_language_refs":[],"placeholder_infos":[],"placeholders":[],"plugin_effects":[],"primary_color_wheels":[],"realtime_denoises":[],"shapes":[],"smart_crops":[],"smart_relights":[],"sound_channel_mappings":[],"speeds":[],"stickers":[],"tail_leaders":[],"text_templates":[],"texts":[],"time_marks":[],"transitions":[],"video_effects":[],"video_trackings":[],"videos":[],"vocal_beautifys":[],"vocal_separations":[]},"mutable_config":null,"name":"","new_version":"75.0.0","path":"","platform":{"app_id":0,"app_source":"","app_version":"","device_id":"","hard_disk_id":"","mac_address":"","os":"","os_version":""},"relationships":[],"render_index_track_mode_on":false,"retouch_cover":null,"source":"default","static_cover_image_path":"","time_marks":null,"tracks":[],"uneven_animation_template_info":{"composition":"","content":"","order":"","sub_template_info_list":[]},"update_time":0,"version":360000} -------------------------------------------------------------------------------- /template_jianying/.backup/20250516114846_07a08f0b2c07e8e8f544c49e495344ea.save.bak: -------------------------------------------------------------------------------- 1 | {"canvas_config":{"height":1080,"ratio":"original","width":1920},"color_space":-1,"config":{"adjust_max_index":1,"attachment_info":[],"combination_max_index":1,"export_range":null,"extract_audio_last_index":1,"lyrics_recognition_id":"","lyrics_sync":true,"lyrics_taskinfo":[],"maintrack_adsorb":true,"material_save_mode":0,"multi_language_current":"none","multi_language_list":[],"multi_language_main":"none","multi_language_mode":"none","original_sound_last_index":1,"record_audio_last_index":1,"sticker_max_index":1,"subtitle_keywords_config":null,"subtitle_recognition_id":"","subtitle_sync":true,"subtitle_taskinfo":[],"system_font_list":[],"video_mute":false,"zoom_info_params":null},"cover":null,"create_time":0,"duration":0,"extra_info":null,"fps":30.0,"free_render_index_mode_on":false,"group_container":null,"id":"DF724EE8-CA13-430C-B9E1-643276C263DA","keyframe_graph_list":[],"keyframes":{"adjusts":[],"audios":[],"effects":[],"filters":[],"handwrites":[],"stickers":[],"texts":[],"videos":[]},"last_modified_platform":{"app_id":3704,"app_source":"lv","app_version":"5.9.0","device_id":"9c1d156ba20d5d2ef89a71f1a92596fd","hard_disk_id":"81f3587033961b2ad1d1ef9d11dcce33","mac_address":"5409d920e86b1169992326ea688e0715","os":"mac","os_version":"15.3.2"},"materials":{"ai_translates":[],"audio_balances":[],"audio_effects":[],"audio_fades":[],"audio_track_indexes":[],"audios":[],"beats":[],"canvases":[],"chromas":[],"color_curves":[],"digital_humans":[],"drafts":[],"effects":[],"flowers":[],"green_screens":[],"handwrites":[],"hsl":[],"images":[],"log_color_wheels":[],"loudnesses":[],"manual_deformations":[],"masks":[],"material_animations":[],"material_colors":[],"multi_language_refs":[],"placeholders":[],"plugin_effects":[],"primary_color_wheels":[],"realtime_denoises":[],"shapes":[],"smart_crops":[],"smart_relights":[],"sound_channel_mappings":[],"speeds":[],"stickers":[],"tail_leaders":[],"text_templates":[],"texts":[],"time_marks":[],"transitions":[],"video_effects":[],"video_trackings":[],"videos":[],"vocal_beautifys":[],"vocal_separations":[]},"mutable_config":null,"name":"","new_version":"110.0.0","platform":{"app_id":3704,"app_source":"lv","app_version":"5.9.0","device_id":"9c1d156ba20d5d2ef89a71f1a92596fd","hard_disk_id":"81f3587033961b2ad1d1ef9d11dcce33","mac_address":"5409d920e86b1169992326ea688e0715","os":"mac","os_version":"15.3.2"},"relationships":[],"render_index_track_mode_on":true,"retouch_cover":null,"source":"default","static_cover_image_path":"","time_marks":null,"tracks":[],"update_time":0,"version":360000} -------------------------------------------------------------------------------- /template_jianying/.backup/20250516114846_541eea452c3d8402ca82d1bb791a9eb6.save.bak: -------------------------------------------------------------------------------- 1 | {"canvas_config":{"height":1080,"ratio":"original","width":1920},"color_space":-1,"config":{"adjust_max_index":1,"attachment_info":[],"combination_max_index":1,"export_range":null,"extract_audio_last_index":1,"lyrics_recognition_id":"","lyrics_sync":true,"lyrics_taskinfo":[],"maintrack_adsorb":true,"material_save_mode":0,"multi_language_current":"none","multi_language_list":[],"multi_language_main":"none","multi_language_mode":"none","original_sound_last_index":1,"record_audio_last_index":1,"sticker_max_index":1,"subtitle_keywords_config":null,"subtitle_recognition_id":"","subtitle_sync":true,"subtitle_taskinfo":[],"system_font_list":[],"video_mute":false,"zoom_info_params":null},"cover":null,"create_time":0,"duration":0,"extra_info":null,"fps":30.0,"free_render_index_mode_on":false,"group_container":null,"id":"DF724EE8-CA13-430C-B9E1-643276C263DA","keyframe_graph_list":[],"keyframes":{"adjusts":[],"audios":[],"effects":[],"filters":[],"handwrites":[],"stickers":[],"texts":[],"videos":[]},"last_modified_platform":{"app_id":3704,"app_source":"lv","app_version":"5.9.0","device_id":"9c1d156ba20d5d2ef89a71f1a92596fd","hard_disk_id":"81f3587033961b2ad1d1ef9d11dcce33","mac_address":"5409d920e86b1169992326ea688e0715","os":"mac","os_version":"15.3.2"},"materials":{"ai_translates":[],"audio_balances":[],"audio_effects":[],"audio_fades":[],"audio_track_indexes":[],"audios":[],"beats":[],"canvases":[],"chromas":[],"color_curves":[],"digital_humans":[],"drafts":[],"effects":[],"flowers":[],"green_screens":[],"handwrites":[],"hsl":[],"images":[],"log_color_wheels":[],"loudnesses":[],"manual_deformations":[],"masks":[],"material_animations":[],"material_colors":[],"multi_language_refs":[],"placeholders":[],"plugin_effects":[],"primary_color_wheels":[],"realtime_denoises":[],"shapes":[],"smart_crops":[],"smart_relights":[],"sound_channel_mappings":[],"speeds":[],"stickers":[],"tail_leaders":[],"text_templates":[],"texts":[],"time_marks":[],"transitions":[],"video_effects":[],"video_trackings":[],"videos":[],"vocal_beautifys":[],"vocal_separations":[]},"mutable_config":null,"name":"","new_version":"110.0.0","platform":{"app_id":3704,"app_source":"lv","app_version":"5.9.0","device_id":"9c1d156ba20d5d2ef89a71f1a92596fd","hard_disk_id":"81f3587033961b2ad1d1ef9d11dcce33","mac_address":"5409d920e86b1169992326ea688e0715","os":"mac","os_version":"15.3.2"},"relationships":[],"render_index_track_mode_on":false,"retouch_cover":null,"source":"default","static_cover_image_path":"","time_marks":null,"tracks":[],"update_time":0,"version":360000} -------------------------------------------------------------------------------- /examples/example_capcut_effect.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import sys 4 | 5 | # 添加父目录到系统路径 6 | sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 7 | 8 | # 从example.py中导入必要的函数 9 | from example import add_video_impl, add_effect, save_draft_impl 10 | 11 | def example_capcut_effect(): 12 | """Test service for adding effects""" 13 | # draft_folder = "/Users/sunguannan/Movies/JianyingPro/User Data/Projects/com.lveditor.draft" 14 | draft_folder = "/Users/sunguannan/Movies/CapCut/User Data/Projects/com.lveditor.draft" 15 | 16 | print("\nTest: Adding effects") 17 | # First add video track 18 | image_result = add_video_impl( 19 | video_url="https://pan.superbed.cn/share/1nbrg1fl/jimeng_daweidai.mp4", 20 | start=0, 21 | end=3.0, 22 | target_start=0, 23 | width=1080, 24 | height=1920 25 | ) 26 | print(f"Video added successfully! {image_result['output']['draft_id']}") 27 | image_result = add_video_impl( 28 | video_url="https://pan.superbed.cn/share/1nbrg1fl/jimeng_daweidai.mp4", 29 | draft_id=image_result['output']['draft_id'], 30 | start=0, 31 | end=3.0, 32 | target_start=3, 33 | ) 34 | print(f"Video added successfully! {image_result['output']['draft_id']}") 35 | 36 | # Then add effect 37 | effect_result = add_effect( 38 | effect_type="Like", 39 | effect_category="character", # Explicitly specify as character effect 40 | start=3, 41 | end=6, 42 | draft_id=image_result['output']['draft_id'], 43 | track_name="effect_01" 44 | ) 45 | print(f"Effect adding result: {effect_result}") 46 | print(save_draft_impl(effect_result['output']['draft_id'], draft_folder)) 47 | 48 | source_folder = os.path.join(os.getcwd(), effect_result['output']['draft_id']) 49 | destination_folder = os.path.join(draft_folder, effect_result['output']['draft_id']) 50 | 51 | if os.path.exists(source_folder): 52 | print(f"Moving {effect_result['output']['draft_id']} to {draft_folder}") 53 | shutil.move(source_folder, destination_folder) 54 | print("Folder moved successfully!") 55 | else: 56 | print(f"Source folder {source_folder} does not exist") 57 | 58 | # Add log to prompt user to find the draft in CapCut 59 | print(f"\n===== IMPORTANT =====\nPlease open CapCut and find the draft named '{effect_result['output']['draft_id']}'\n=======================") 60 | 61 | # Return the first test result for subsequent operations (if any) 62 | return effect_result 63 | 64 | 65 | if __name__ == "__main__": 66 | example_capcut_effect() -------------------------------------------------------------------------------- /template/template-2.tmp: -------------------------------------------------------------------------------- 1 | {"canvas_config":{"background":null,"height":1080,"ratio":"original","width":1920},"color_space":-1,"config":{"adjust_max_index":1,"attachment_info":[],"combination_max_index":1,"export_range":null,"extract_audio_last_index":1,"lyrics_recognition_id":"","lyrics_sync":true,"lyrics_taskinfo":[],"maintrack_adsorb":true,"material_save_mode":0,"multi_language_current":"none","multi_language_list":[],"multi_language_main":"none","multi_language_mode":"none","original_sound_last_index":1,"record_audio_last_index":1,"sticker_max_index":1,"subtitle_keywords_config":null,"subtitle_recognition_id":"","subtitle_sync":true,"subtitle_taskinfo":[],"system_font_list":[],"use_float_render":false,"video_mute":false,"zoom_info_params":null},"cover":null,"create_time":0,"duration":0,"extra_info":null,"fps":30.0,"free_render_index_mode_on":false,"group_container":null,"id":"26FDE5CD-364C-45A3-8930-A40DF2F7EB2B","is_drop_frame_timecode":false,"keyframe_graph_list":[],"keyframes":{"adjusts":[],"audios":[],"effects":[],"filters":[],"handwrites":[],"stickers":[],"texts":[],"videos":[]},"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"},"lyrics_effects":[],"materials":{"ai_translates":[],"audio_balances":[],"audio_effects":[],"audio_fades":[],"audio_track_indexes":[],"audios":[],"beats":[],"canvases":[],"chromas":[],"color_curves":[],"common_mask":[],"digital_human_model_dressing":[],"digital_humans":[],"drafts":[],"effects":[],"flowers":[],"green_screens":[],"handwrites":[],"hsl":[],"images":[],"log_color_wheels":[],"loudnesses":[],"manual_beautys":[],"manual_deformations":[],"material_animations":[],"material_colors":[],"multi_language_refs":[],"placeholder_infos":[],"placeholders":[],"plugin_effects":[],"primary_color_wheels":[],"realtime_denoises":[],"shapes":[],"smart_crops":[],"smart_relights":[],"sound_channel_mappings":[],"speeds":[],"stickers":[],"tail_leaders":[],"text_templates":[],"texts":[],"time_marks":[],"transitions":[],"video_effects":[],"video_trackings":[],"videos":[],"vocal_beautifys":[],"vocal_separations":[]},"mutable_config":null,"name":"","new_version":"138.0.0","path":"","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"},"relationships":[],"render_index_track_mode_on":true,"retouch_cover":null,"source":"default","static_cover_image_path":"","time_marks":null,"tracks":[],"uneven_animation_template_info":{"composition":"","content":"","order":"","sub_template_info_list":[]},"update_time":0,"version":360000} -------------------------------------------------------------------------------- /pyJianYingDraft/metadata/capcut_mask_meta.py: -------------------------------------------------------------------------------- 1 | from .effect_meta import Effect_enum 2 | from .mask_meta import Mask_meta 3 | 4 | class CapCut_Mask_type(Effect_enum): 5 | """CapCut自带的蒙版类型""" 6 | 7 | Split = Mask_meta("Split", "line", "7374020197990011409", "B52CD1BC-63CE-4B74-B180-0B61E4AC928A", "4c6a0ef5de6a844342d40330e00c59eb", 1.0) 8 | """配置: centerX: 0.0, centerY: 0.0, expansion: 0.0, feather: 0.0, height: 0.0, invert: False, rotation: 0.0, roundCorner: 0.0, width: 0.16""" 9 | Filmstrip = Mask_meta("Filmstrip", "mirror", "7374021024985125377", "C4A9A4BD-280D-4625-AA7D-F5F70E97B438", "95ac211c99063c41b86b9b63742f4a6d", 1.0) 10 | """配置: centerX: 0.0, centerY: 0.0, expansion: 0.0, feather: 0.0, height: 0.5, invert: False, rotation: 0.0, roundCorner: 0.0, width: 0.16""" 11 | Circle = Mask_meta("Circle", "circle", "7374021188315517456", "E827751C-8DA7-412C-800D-DF2FE8712F77", "3ab1c47350d987c8ad415497e020a38b", 1.0) 12 | """配置: centerX: 0.0, centerY: 0.0, expansion: 0.0, feather: 0.0, height: 0.5, invert: False, rotation: 0.0, roundCorner: 0.0, width: 0.16""" 13 | Rectangle = Mask_meta("Rectangle", "rectangle", "7374021450748924432", "E55A3414-9C81-4664-BB2B-42528D098F2F", "02b8999168d121538a98ea59127483ef", 1.0) 14 | """配置: centerX: 0.0, centerY: 0.0, expansion: 0.0, feather: 0.0, height: 0.5, invert: False, rotation: 0.0, roundCorner: 0.0, width: 0.16""" 15 | Stars = Mask_meta("Stars", "pentagram", "7374021798087627265", "D824ED5D-D0C1-4EE2-B098-65F99CB38B95", "d3eb0298b2b1c345c123470c9194c8ad", 1.0471014493) 16 | """配置: centerX: 0.0, centerY: 0.0, expansion: 0.0, feather: 0.0, height: 0.5, invert: False, rotation: 0.0, roundCorner: 0.0, width: 0.16""" 17 | Heart = Mask_meta("Heart", "heart", "7350964630979613186", "2CA4F90A-87A6-483E-B71B-FDA65EE46860", "cfa1154e4873fda2c09716c8aa546236", 1.1148148148) 18 | """配置: centerX: 0.0, centerY: 0.0, expansion: 0.0, feather: 0.0, height: 0.5, invert: False, rotation: 0.0, roundCorner: 0.0, width: 0.16""" 19 | Text = Mask_meta("Text", "text", "7439320146876830225", "61E0D039-1A51-4570-B3F1-6AAC82AC1520", "ada210b1e21e860006c8324db359d8a3", 1.0) 20 | """配置: centerX: 0.0, centerY: 0.0, expansion: 0.0, feather: 0.0, height: 0.5, invert: False, rotation: 0.0, roundCorner: 0.0, width: 0.16""" 21 | Brush = Mask_meta("Brush", "custom", "7374021798087627265", "31AC68F1-9EC3-4A4E-8D53-D556B7CDAC9E", "7e3c26bd14a0b68a84fee058db7f1ade", 1.0) 22 | """配置: centerX: 0.0, centerY: 0.0, expansion: 0.0, feather: 0.0, height: 0.5, invert: False, rotation: 0.0, roundCorner: 0.0, width: 0.16""" 23 | Pen = Mask_meta("Pen", "contour", "7414333113955783185", "E3F45CAF-D445-4975-BE57-67AA716425D3", "9ee4e93af66335a75843446555efd8a6", 1.0) 24 | """配置: centerX: 0.0, centerY: 0.0, expansion: 0.0, feather: 0.0, height: 1.0, invert: False, rotation: 0.0, roundCorner: 0.0, width: 1.0""" 25 | -------------------------------------------------------------------------------- /pyJianYingDraft/__init__.py: -------------------------------------------------------------------------------- 1 | from .local_materials import Crop_settings, Video_material, Audio_material 2 | from .keyframe import Keyframe_property 3 | 4 | from .time_util import Timerange 5 | from .audio_segment import Audio_segment 6 | from .video_segment import Video_segment, Sticker_segment, Clip_settings 7 | from .effect_segment import Effect_segment, Filter_segment 8 | from .text_segment import Text_segment, Text_style, Text_border, Text_background, Text_shadow 9 | 10 | from .metadata import Font_type 11 | from .metadata import Mask_type 12 | from .metadata import CapCut_Mask_type 13 | from .metadata import Transition_type, Filter_type 14 | from .metadata import CapCut_Transition_type 15 | from .metadata import Intro_type, Outro_type, Group_animation_type 16 | from .metadata import CapCut_Intro_type, CapCut_Outro_type, CapCut_Group_animation_type 17 | from .metadata import Text_intro, Text_outro, Text_loop_anim 18 | from .metadata import CapCut_Text_intro, CapCut_Text_outro, CapCut_Text_loop_anim 19 | from .metadata import Audio_scene_effect_type, Tone_effect_type, Speech_to_song_type 20 | from .metadata import CapCut_Voice_filters_effect_type, CapCut_Voice_characters_effect_type, CapCut_Speech_to_song_effect_type 21 | from .metadata import Video_scene_effect_type, Video_character_effect_type 22 | from .metadata import CapCut_Video_scene_effect_type, CapCut_Video_character_effect_type 23 | 24 | from .track import Track_type 25 | from .template_mode import Shrink_mode, Extend_mode 26 | from .script_file import Script_file 27 | from .draft_folder import Draft_folder 28 | 29 | from .time_util import SEC, tim, trange 30 | 31 | __all__ = [ 32 | "Font_type", 33 | "Mask_type", 34 | "CapCut_Mask_type", 35 | "Filter_type", 36 | "Transition_type", 37 | "CapCut_Transition_type", 38 | "Intro_type", 39 | "Outro_type", 40 | "Group_animation_type", 41 | "CapCut_Intro_type", 42 | "CapCut_Outro_type", 43 | "CapCut_Group_animation_type", 44 | "Text_intro", 45 | "Text_outro", 46 | "Text_loop_anim", 47 | "CapCut_Text_intro", 48 | "CapCut_Text_outro", 49 | "CapCut_Text_loop_anim", 50 | "Audio_scene_effect_type", 51 | "Tone_effect_type", 52 | "Speech_to_song_type", 53 | "Video_scene_effect_type", 54 | "Video_character_effect_type", 55 | "CapCut_Voice_filters_effect_type", 56 | "CapCut_Voice_characters_effect_type", 57 | "CapCut_Speech_to_song_effect_type", 58 | "CapCut_Video_scene_effect_type", 59 | "CapCut_Video_character_effect_type", 60 | "Crop_settings", 61 | "Video_material", 62 | "Audio_material", 63 | "Keyframe_property", 64 | "Timerange", 65 | "Audio_segment", 66 | "Video_segment", 67 | "Sticker_segment", 68 | "Clip_settings", 69 | "Effect_segment", 70 | "Filter_segment", 71 | "Text_segment", 72 | "Text_style", 73 | "Text_border", 74 | "Text_background", 75 | "Text_shadow", 76 | "Track_type", 77 | "Shrink_mode", 78 | "Extend_mode", 79 | "Script_file", 80 | "Draft_folder", 81 | "SEC", 82 | "tim", 83 | "trange" 84 | ] 85 | -------------------------------------------------------------------------------- /pyJianYingDraft/keyframe.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from enum import Enum 4 | from typing import Dict, List, Any 5 | 6 | class Keyframe: 7 | """一个关键帧(关键点), 目前只支持线性插值""" 8 | 9 | kf_id: str 10 | """关键帧全局id, 自动生成""" 11 | time_offset: int 12 | """相对于素材起始点的时间偏移量""" 13 | values: List[float] 14 | """关键帧的值, 似乎一般只有一个元素""" 15 | 16 | def __init__(self, time_offset: int, value: float): 17 | """给定时间偏移量及关键值, 初始化关键帧""" 18 | self.kf_id = uuid.uuid4().hex 19 | 20 | self.time_offset = time_offset 21 | self.values = [value] 22 | 23 | def export_json(self) -> Dict[str, Any]: 24 | return { 25 | # 默认值 26 | "curveType": "Line", 27 | "graphID": "", 28 | "left_control": {"x": 0.0, "y": 0.0}, 29 | "right_control": {"x": 0.0, "y": 0.0}, 30 | # 自定义属性 31 | "id": self.kf_id, 32 | "time_offset": self.time_offset, 33 | "values": self.values 34 | } 35 | 36 | class Keyframe_property(Enum): 37 | """关键帧所控制的属性类型""" 38 | 39 | position_x = "KFTypePositionX" 40 | """右移为正, 此处的数值应该为`剪映中显示的值` / `草稿宽度`, 也即单位是半个画布宽""" 41 | position_y = "KFTypePositionY" 42 | """上移为正, 此处的数值应该为`剪映中显示的值` / `草稿高度`, 也即单位是半个画布高""" 43 | rotation = "KFTypeRotation" 44 | """顺时针旋转的**角度**""" 45 | 46 | scale_x = "KFTypeScaleX" 47 | """单独控制X轴缩放比例(1.0为不缩放), 与`uniform_scale`互斥""" 48 | scale_y = "KFTypeScaleY" 49 | """单独控制Y轴缩放比例(1.0为不缩放), 与`uniform_scale`互斥""" 50 | uniform_scale = "UNIFORM_SCALE" 51 | """同时控制X轴及Y轴缩放比例(1.0为不缩放), 与`scale_x`和`scale_y`互斥""" 52 | 53 | alpha = "KFTypeAlpha" 54 | """不透明度, 1.0为完全不透明, 仅对`Video_segment`有效""" 55 | saturation = "KFTypeSaturation" 56 | """饱和度, 0.0为原始饱和度, 范围为-1.0到1.0, 仅对`Video_segment`有效""" 57 | contrast = "KFTypeContrast" 58 | """对比度, 0.0为原始对比度, 范围为-1.0到1.0, 仅对`Video_segment`有效""" 59 | brightness = "KFTypeBrightness" 60 | """亮度, 0.0为原始亮度, 范围为-1.0到1.0, 仅对`Video_segment`有效""" 61 | 62 | volume = "KFTypeVolume" 63 | """音量, 1.0为原始音量, 仅对`Audio_segment`和`Video_segment`有效""" 64 | 65 | class Keyframe_list: 66 | """关键帧列表, 记录与某个特定属性相关的一系列关键帧""" 67 | 68 | list_id: str 69 | """关键帧列表全局id, 自动生成""" 70 | keyframe_property: Keyframe_property 71 | """关键帧对应的属性""" 72 | keyframes: List[Keyframe] 73 | """关键帧列表""" 74 | 75 | def __init__(self, keyframe_property: Keyframe_property): 76 | """为给定的关键帧属性初始化关键帧列表""" 77 | self.list_id = uuid.uuid4().hex 78 | 79 | self.keyframe_property = keyframe_property 80 | self.keyframes = [] 81 | 82 | def add_keyframe(self, time_offset: int, value: float): 83 | """给定时间偏移量及关键值, 向此关键帧列表中添加一个关键帧""" 84 | keyframe = Keyframe(time_offset, value) 85 | self.keyframes.append(keyframe) 86 | self.keyframes.sort(key=lambda x: x.time_offset) 87 | 88 | def export_json(self) -> Dict[str, Any]: 89 | return { 90 | "id": self.list_id, 91 | "keyframe_list": [kf.export_json() for kf in self.keyframes], 92 | "material_id": "", 93 | "property_type": self.keyframe_property.value 94 | } 95 | -------------------------------------------------------------------------------- /util.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | import subprocess 3 | import json 4 | import re 5 | import os 6 | import hashlib 7 | import functools 8 | import time 9 | from settings.local import DRAFT_DOMAIN, PREVIEW_ROUTER, IS_CAPCUT_ENV 10 | 11 | def hex_to_rgb(hex_color: str) -> tuple: 12 | """Convert hexadecimal color code to RGB tuple (range 0.0-1.0)""" 13 | hex_color = hex_color.lstrip('#') 14 | if len(hex_color) == 3: 15 | hex_color = ''.join([c*2 for c in hex_color]) # Handle shorthand form (e.g. #fff) 16 | try: 17 | r = int(hex_color[0:2], 16) / 255.0 18 | g = int(hex_color[2:4], 16) / 255.0 19 | b = int(hex_color[4:6], 16) / 255.0 20 | return (r, g, b) 21 | except ValueError: 22 | raise ValueError(f"Invalid hexadecimal color code: {hex_color}") 23 | 24 | 25 | def is_windows_path(path): 26 | """Detect if the path is Windows style""" 27 | # Check if it starts with a drive letter (e.g. C:\) or contains Windows style separators 28 | return re.match(r'^[a-zA-Z]:\\|\\\\', path) is not None 29 | 30 | 31 | def zip_draft(draft_id): 32 | current_dir = os.path.dirname(os.path.abspath(__file__)) 33 | # Compress folder 34 | zip_dir = os.path.join(current_dir, "tmp/zip") 35 | os.makedirs(zip_dir, exist_ok=True) 36 | zip_path = os.path.join(zip_dir, f"{draft_id}.zip") 37 | shutil.make_archive(os.path.join(zip_dir, draft_id), 'zip', os.path.join(current_dir, draft_id)) 38 | return zip_path 39 | 40 | def url_to_hash(url, length=16): 41 | """ 42 | Convert URL to a fixed-length hash string (without extension) 43 | 44 | Parameters: 45 | - url: Original URL string 46 | - length: Length of the hash string (maximum 64, default 16) 47 | 48 | Returns: 49 | - Hash string (e.g.: 3a7f9e7d9a1b4e2d) 50 | """ 51 | # Ensure URL is bytes type 52 | url_bytes = url.encode('utf-8') 53 | 54 | # Use SHA-256 to generate hash (secure and highly unique) 55 | hash_object = hashlib.sha256(url_bytes) 56 | 57 | # Truncate to specified length of hexadecimal string 58 | return hash_object.hexdigest()[:length] 59 | 60 | 61 | def timing_decorator(func_name): 62 | """Decorator: Used to monitor function execution time""" 63 | def decorator(func): 64 | @functools.wraps(func) 65 | def wrapper(*args, **kwargs): 66 | start_time = time.time() 67 | print(f"[{func_name}] Starting execution...") 68 | try: 69 | result = func(*args, **kwargs) 70 | end_time = time.time() 71 | duration = end_time - start_time 72 | print(f"[{func_name}] Execution completed, time taken: {duration:.3f} seconds") 73 | return result 74 | except Exception as e: 75 | end_time = time.time() 76 | duration = end_time - start_time 77 | print(f"[{func_name}] Execution failed, time taken: {duration:.3f} seconds, error: {e}") 78 | raise 79 | return wrapper 80 | return decorator 81 | 82 | def generate_draft_url(draft_id): 83 | return f"{DRAFT_DOMAIN}{PREVIEW_ROUTER}?draft_id={draft_id}&is_capcut={1 if IS_CAPCUT_ENV else 0}" -------------------------------------------------------------------------------- /pyJianYingDraft/time_util.py: -------------------------------------------------------------------------------- 1 | """定义时间范围类以及与时间相关的辅助函数""" 2 | 3 | from typing import Union 4 | from typing import Dict 5 | 6 | SEC = 1000000 7 | """一秒=1e6微秒""" 8 | 9 | def tim(inp: Union[str, float]) -> int: 10 | """将输入的字符串转换为微秒, 也可直接输入微秒数 11 | 12 | 支持类似 "1h52m3s" 或 "0.15s" 这样的格式, 可包含负号以表示负偏移 13 | """ 14 | if isinstance(inp, (int, float)): 15 | return int(round(inp)) 16 | 17 | sign: int = 1 18 | inp = inp.strip().lower() 19 | if inp.startswith("-"): 20 | sign = -1 21 | inp = inp[1:] 22 | 23 | last_index: int = 0 24 | total_time: float = 0 25 | for unit, factor in zip(["h", "m", "s"], [3600*SEC, 60*SEC, SEC]): 26 | unit_index = inp.find(unit) 27 | if unit_index == -1: continue 28 | 29 | total_time += float(inp[last_index:unit_index]) * factor 30 | last_index = unit_index + 1 31 | 32 | return int(round(total_time) * sign) 33 | 34 | class Timerange: 35 | """记录了起始时间及持续长度的时间范围""" 36 | start: int 37 | """起始时间, 单位为微秒""" 38 | duration: int 39 | """持续长度, 单位为微秒""" 40 | 41 | def __init__(self, start: int, duration: int): 42 | """构造一个时间范围 43 | 44 | Args: 45 | start (int): 起始时间, 单位为微秒 46 | duration (int): 持续长度, 单位为微秒 47 | """ 48 | 49 | self.start = start 50 | self.duration = duration 51 | 52 | @classmethod 53 | def import_json(cls, json_obj: Dict[str, str]) -> "Timerange": 54 | """从json对象中恢复Timerange""" 55 | return cls(int(json_obj["start"]), int(json_obj["duration"])) 56 | 57 | @property 58 | def end(self) -> int: 59 | """结束时间, 单位为微秒""" 60 | return self.start + self.duration 61 | 62 | def __eq__(self, other: object) -> bool: 63 | if not isinstance(other, Timerange): 64 | return False 65 | return self.start == other.start and self.duration == other.duration 66 | 67 | def overlaps(self, other: "Timerange") -> bool: 68 | """判断两个时间范围是否有重叠""" 69 | return not (self.end <= other.start or other.end <= self.start) 70 | 71 | def __repr__(self) -> str: 72 | return f"Timerange(start={self.start}, duration={self.duration})" 73 | 74 | def __str__(self) -> str: 75 | return f"[start={self.start}, end={self.end}]" 76 | 77 | def export_json(self) -> Dict[str, int]: 78 | return {"start": self.start, "duration": self.duration} 79 | 80 | def trange(start: Union[str, float], duration: Union[str, float]) -> Timerange: 81 | """Timerange的简便构造函数, 接受字符串或微秒数作为参数 82 | 83 | 支持类似 "1h52m3s" 或 "0.15s" 这样的格式 84 | 85 | Args: 86 | start (Union[str, float]): 起始时间 87 | duration (Union[str, float]): 持续长度, 注意**不是结束时间** 88 | """ 89 | return Timerange(tim(start), tim(duration)) 90 | 91 | def srt_tstamp(srt_tstamp: str) -> int: 92 | """解析srt中的时间戳字符串, 返回微秒数""" 93 | sec_str, ms_str = srt_tstamp.split(",") 94 | parts = sec_str.split(":") + [ms_str] 95 | 96 | total_time = 0 97 | for value, factor in zip(parts, [3600*SEC, 60*SEC, SEC, 1000]): 98 | total_time += int(value) * factor 99 | return total_time 100 | -------------------------------------------------------------------------------- /add_sticker_impl.py: -------------------------------------------------------------------------------- 1 | import pyJianYingDraft as draft 2 | from pyJianYingDraft import trange 3 | from typing import Optional, Dict 4 | from pyJianYingDraft import exceptions 5 | from create_draft import get_or_create_draft 6 | from util import generate_draft_url 7 | 8 | def add_sticker_impl( 9 | resource_id: str, 10 | start: float, 11 | end: float, 12 | draft_id: str = None, 13 | transform_y: float = 0, 14 | transform_x: float = 0, 15 | alpha: float = 1.0, 16 | flip_horizontal: bool = False, 17 | flip_vertical: bool = False, 18 | rotation: float = 0.0, 19 | scale_x: float = 1.0, 20 | scale_y: float = 1.0, 21 | track_name: str = "sticker_main", 22 | relative_index: int = 0, 23 | width: int = 1080, 24 | height: int = 1920 25 | ) -> Dict[str, str]: 26 | """ 27 | Add sticker to specified draft 28 | :param resource_id: Sticker resource ID 29 | :param start: Start time (seconds) 30 | :param end: End time (seconds) 31 | :param draft_id: Draft ID (optional, default None creates a new draft) 32 | :param transform_y: Y-axis position (default 0, screen center) 33 | :param transform_x: X-axis position (default 0, screen center) 34 | :param alpha: Image opacity, range 0-1 (default 1.0, completely opaque) 35 | :param flip_horizontal: Whether to flip horizontally (default False) 36 | :param flip_vertical: Whether to flip vertically (default False) 37 | :param rotation: Clockwise rotation angle, can be positive or negative (default 0.0) 38 | :param scale_x: Horizontal scale ratio (default 1.0) 39 | :param scale_y: Vertical scale ratio (default 1.0) 40 | :param track_name: Track name 41 | :param relative_index: Relative layer position (of the same track type), higher is closer to foreground (default 0) 42 | :param width: Video width, default 1080 43 | :param height: Video height, default 1920 44 | :return: Updated draft information 45 | """ 46 | # Get or create draft 47 | draft_id, script = get_or_create_draft( 48 | draft_id=draft_id, 49 | width=width, 50 | height=height 51 | ) 52 | 53 | # Add sticker track 54 | if track_name is not None: 55 | try: 56 | imported_track = script.get_imported_track(draft.Track_type.sticker, name=track_name) 57 | # If no exception is thrown, the track already exists 58 | except exceptions.TrackNotFound: 59 | # Track doesn't exist, create a new track 60 | script.add_track(draft.Track_type.sticker, track_name=track_name, relative_index=relative_index) 61 | else: 62 | script.add_track(draft.Track_type.sticker, relative_index=relative_index) 63 | 64 | # Create sticker segment 65 | sticker_segment = draft.Sticker_segment( 66 | resource_id, 67 | trange(f"{start}s", f"{end-start}s"), 68 | clip_settings=draft.Clip_settings( 69 | transform_y=transform_y, 70 | transform_x=transform_x, 71 | alpha=alpha, 72 | flip_horizontal=flip_horizontal, 73 | flip_vertical=flip_vertical, 74 | rotation=rotation, 75 | scale_x=scale_x, 76 | scale_y=scale_y 77 | ) 78 | ) 79 | 80 | # Add sticker segment to track 81 | script.add_segment(sticker_segment, track_name=track_name) 82 | 83 | return { 84 | "draft_id": draft_id, 85 | "draft_url": generate_draft_url(draft_id) 86 | } 87 | -------------------------------------------------------------------------------- /pyJianYingDraft/metadata/effect_meta.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from typing import List, Dict, Any 4 | from typing import TypeVar, Optional 5 | 6 | class Effect_param: 7 | """特效参数信息""" 8 | 9 | name: str 10 | """参数名称""" 11 | default_value: float 12 | """默认值""" 13 | min_value: float 14 | """最小值""" 15 | max_value: float 16 | """最大值""" 17 | 18 | def __init__(self, name: str, default_value: float, min_value: float, max_value: float): 19 | self.name = name 20 | self.default_value = default_value 21 | self.min_value = min_value 22 | self.max_value = max_value 23 | 24 | class Effect_param_instance(Effect_param): 25 | """特效参数实例""" 26 | 27 | index: int 28 | """参数索引""" 29 | value: float 30 | """当前值""" 31 | 32 | def __init__(self, meta: Effect_param, index: int, value: float): 33 | super().__init__(meta.name, meta.default_value, meta.min_value, meta.max_value) 34 | self.index = index 35 | self.value = value 36 | 37 | def export_json(self) -> Dict[str, Any]: 38 | return { 39 | "default_value": self.default_value, 40 | "max_value": self.max_value, 41 | "min_value": self.min_value, 42 | "name": self.name, 43 | "parameterIndex": self.index, 44 | "portIndex": 0, 45 | "value": self.value 46 | } 47 | 48 | class Effect_meta: 49 | """特效元数据""" 50 | 51 | name: str 52 | """效果名称""" 53 | is_vip: bool 54 | """是否为VIP特权""" 55 | 56 | resource_id: str 57 | """资源ID""" 58 | effect_id: str 59 | """效果ID""" 60 | md5: str 61 | 62 | params: List[Effect_param] 63 | """效果的参数信息""" 64 | 65 | def __init__(self, name: str, is_vip: bool, resource_id: str, effect_id: str, md5: str, params: List[Effect_param] = []): 66 | self.name = name 67 | self.is_vip = is_vip 68 | self.resource_id = resource_id 69 | self.effect_id = effect_id 70 | self.md5 = md5 71 | self.params = params 72 | 73 | def parse_params(self, params: Optional[List[Optional[float]]]) -> List[Effect_param_instance]: 74 | """解析参数列表(范围0~100), 返回参数实例列表""" 75 | ret: List[Effect_param_instance] = [] 76 | 77 | if params is None: params = [] 78 | for i, param in enumerate(self.params): 79 | val = param.default_value 80 | if i < len(params): 81 | input_v = params[i] 82 | if input_v is not None: 83 | if input_v < 0 or input_v > 100: 84 | raise ValueError("Invalid parameter value %f within %s" % (input_v, str(param))) 85 | val = param.min_value + (param.max_value - param.min_value) * input_v / 100.0 # 从0~100映射到实际值 86 | ret.append(Effect_param_instance(param, i, val)) 87 | return ret 88 | 89 | 90 | Effect_enum_subclass = TypeVar("Effect_enum_subclass", bound="Effect_enum") 91 | 92 | class Effect_enum(Enum): 93 | """特效枚举基类, 提供一个`from_name`方法用于根据名称获取特效元数据""" 94 | 95 | @classmethod 96 | def from_name(cls: "type[Effect_enum_subclass]", name: str) -> Effect_enum_subclass: 97 | """根据名称获取特效元数据, 忽略大小写、空格和下划线 98 | 99 | Args: 100 | name (str): 特效名称 101 | 102 | Raises: 103 | `ValueError`: 特效名称不存在 104 | """ 105 | name = name.lower().replace(" ", "").replace("_", "") 106 | for effect in cls: 107 | if effect.name.lower().replace(" ", "").replace("_", "") == name: 108 | return effect 109 | raise ValueError(f"Effect named '{name}' not found") 110 | -------------------------------------------------------------------------------- /pyJianYingDraft/draft_content_template.json: -------------------------------------------------------------------------------- 1 | { 2 | "canvas_config": { 3 | "height": 1080, 4 | "ratio": "original", 5 | "width": 1920 6 | }, 7 | "color_space": 0, 8 | "config": { 9 | "adjust_max_index": 1, 10 | "attachment_info": [], 11 | "combination_max_index": 1, 12 | "export_range": null, 13 | "extract_audio_last_index": 1, 14 | "lyrics_recognition_id": "", 15 | "lyrics_sync": true, 16 | "lyrics_taskinfo": [], 17 | "maintrack_adsorb": true, 18 | "material_save_mode": 0, 19 | "multi_language_current": "none", 20 | "multi_language_list": [], 21 | "multi_language_main": "none", 22 | "multi_language_mode": "none", 23 | "original_sound_last_index": 1, 24 | "record_audio_last_index": 1, 25 | "sticker_max_index": 1, 26 | "subtitle_keywords_config": null, 27 | "subtitle_recognition_id": "", 28 | "subtitle_sync": true, 29 | "subtitle_taskinfo": [], 30 | "system_font_list": [], 31 | "video_mute": false, 32 | "zoom_info_params": null 33 | }, 34 | "cover": null, 35 | "create_time": 0, 36 | "duration": 0, 37 | "extra_info": null, 38 | "fps": 30.0, 39 | "free_render_index_mode_on": false, 40 | "group_container": null, 41 | "id": "91E08AC5-22FB-47e2-9AA0-7DC300FAEA2B", 42 | "keyframe_graph_list": [], 43 | "keyframes": { 44 | "adjusts": [], 45 | "audios": [], 46 | "effects": [], 47 | "filters": [], 48 | "handwrites": [], 49 | "stickers": [], 50 | "texts": [], 51 | "videos": [] 52 | }, 53 | "last_modified_platform": { 54 | "app_id": 3704, 55 | "app_source": "lv", 56 | "app_version": "5.9.0", 57 | "os": "windows" 58 | }, 59 | "materials": { 60 | "ai_translates": [], 61 | "audio_balances": [], 62 | "audio_effects": [], 63 | "audio_fades": [], 64 | "audio_track_indexes": [], 65 | "audios": [], 66 | "beats": [], 67 | "canvases": [], 68 | "chromas": [], 69 | "color_curves": [], 70 | "digital_humans": [], 71 | "drafts": [], 72 | "effects": [], 73 | "flowers": [], 74 | "green_screens": [], 75 | "handwrites": [], 76 | "hsl": [], 77 | "images": [], 78 | "log_color_wheels": [], 79 | "loudnesses": [], 80 | "manual_deformations": [], 81 | "masks": [], 82 | "material_animations": [], 83 | "material_colors": [], 84 | "multi_language_refs": [], 85 | "placeholders": [], 86 | "plugin_effects": [], 87 | "primary_color_wheels": [], 88 | "realtime_denoises": [], 89 | "shapes": [], 90 | "smart_crops": [], 91 | "smart_relights": [], 92 | "sound_channel_mappings": [], 93 | "speeds": [], 94 | "stickers": [], 95 | "tail_leaders": [], 96 | "text_templates": [], 97 | "texts": [], 98 | "time_marks": [], 99 | "transitions": [], 100 | "video_effects": [], 101 | "video_trackings": [], 102 | "videos": [], 103 | "vocal_beautifys": [], 104 | "vocal_separations": [] 105 | }, 106 | "mutable_config": null, 107 | "name": "", 108 | "new_version": "110.0.0", 109 | "relationships": [], 110 | "render_index_track_mode_on": true, 111 | "retouch_cover": null, 112 | "source": "default", 113 | "static_cover_image_path": "", 114 | "time_marks": null, 115 | "tracks": [ 116 | ], 117 | "update_time": 0, 118 | "version": 360000 119 | } -------------------------------------------------------------------------------- /pyJianYingDraft/draft_folder.py: -------------------------------------------------------------------------------- 1 | """草稿文件夹管理器""" 2 | 3 | import os 4 | import shutil 5 | 6 | from typing import List 7 | 8 | from .script_file import Script_file 9 | 10 | class Draft_folder: 11 | """管理一个文件夹及其内的一系列草稿""" 12 | 13 | folder_path: str 14 | """根路径""" 15 | 16 | def __init__(self, folder_path: str): 17 | """初始化草稿文件夹管理器 18 | 19 | Args: 20 | folder_path (`str`): 包含若干草稿的文件夹, 一般取剪映保存草稿的位置即可 21 | 22 | Raises: 23 | `FileNotFoundError`: 路径不存在 24 | """ 25 | self.folder_path = folder_path 26 | 27 | if not os.path.exists(self.folder_path): 28 | raise FileNotFoundError(f"根文件夹 {self.folder_path} 不存在") 29 | 30 | def list_drafts(self) -> List[str]: 31 | """列出文件夹中所有草稿的名称 32 | 33 | 注意: 本函数只是如实地列出子文件夹的名称, 并不检查它们是否符合草稿的格式 34 | """ 35 | return [f for f in os.listdir(self.folder_path) if os.path.isdir(os.path.join(self.folder_path, f))] 36 | 37 | def remove(self, draft_name: str) -> None: 38 | """删除指定名称的草稿 39 | 40 | Args: 41 | draft_name (`str`): 草稿名称, 即相应文件夹名称 42 | 43 | Raises: 44 | `FileNotFoundError`: 对应的草稿不存在 45 | """ 46 | draft_path = os.path.join(self.folder_path, draft_name) 47 | if not os.path.exists(draft_path): 48 | raise FileNotFoundError(f"草稿文件夹 {draft_name} 不存在") 49 | 50 | shutil.rmtree(draft_path) 51 | 52 | def inspect_material(self, draft_name: str) -> None: 53 | """输出指定名称草稿中的贴纸素材元数据 54 | 55 | Args: 56 | draft_name (`str`): 草稿名称, 即相应文件夹名称 57 | 58 | Raises: 59 | `FileNotFoundError`: 对应的草稿不存在 60 | """ 61 | draft_path = os.path.join(self.folder_path, draft_name) 62 | if not os.path.exists(draft_path): 63 | raise FileNotFoundError(f"草稿文件夹 {draft_name} 不存在") 64 | 65 | script_file = self.load_template(draft_name) 66 | script_file.inspect_material() 67 | 68 | def load_template(self, draft_name: str) -> Script_file: 69 | """在文件夹中打开一个草稿作为模板, 并在其上进行编辑 70 | 71 | Args: 72 | draft_name (`str`): 草稿名称, 即相应文件夹名称 73 | 74 | Returns: 75 | `Script_file`: 以模板模式打开的草稿对象 76 | 77 | Raises: 78 | `FileNotFoundError`: 对应的草稿不存在 79 | """ 80 | draft_path = os.path.join(self.folder_path, draft_name) 81 | if not os.path.exists(draft_path): 82 | raise FileNotFoundError(f"草稿文件夹 {draft_name} 不存在") 83 | 84 | return Script_file.load_template(os.path.join(draft_path, "draft_info.json")) 85 | 86 | def duplicate_as_template(self, template_name: str, new_draft_name: str, allow_replace: bool = False) -> Script_file: 87 | """复制一份给定的草稿, 并在复制出的新草稿上进行编辑 88 | 89 | Args: 90 | template_name (`str`): 原草稿名称 91 | new_draft_name (`str`): 新草稿名称 92 | allow_replace (`bool`, optional): 是否允许覆盖与`new_draft_name`重名的草稿. 默认为否. 93 | 94 | Returns: 95 | `Script_file`: 以模板模式打开的**复制后的**草稿对象 96 | 97 | Raises: 98 | `FileNotFoundError`: 原始草稿不存在 99 | `FileExistsError`: 已存在与`new_draft_name`重名的草稿, 但不允许覆盖. 100 | """ 101 | template_path = os.path.join(self.folder_path, template_name) 102 | new_draft_path = os.path.join(self.folder_path, new_draft_name) 103 | if not os.path.exists(template_path): 104 | raise FileNotFoundError(f"模板草稿 {template_name} 不存在") 105 | if os.path.exists(new_draft_path) and not allow_replace: 106 | raise FileExistsError(f"新草稿 {new_draft_name} 已存在且不允许覆盖") 107 | 108 | # 复制草稿文件夹 109 | shutil.copytree(template_path, new_draft_path, dirs_exist_ok=allow_replace) 110 | 111 | # 打开草稿 112 | return self.load_template(new_draft_name) 113 | -------------------------------------------------------------------------------- /add_effect_impl.py: -------------------------------------------------------------------------------- 1 | from pyJianYingDraft import trange, Video_scene_effect_type, Video_character_effect_type, CapCut_Video_scene_effect_type, CapCut_Video_character_effect_type, exceptions 2 | import pyJianYingDraft as draft 3 | from typing import Optional, Dict, List, Union, Literal 4 | from create_draft import get_or_create_draft 5 | from util import generate_draft_url 6 | from settings import IS_CAPCUT_ENV 7 | 8 | def add_effect_impl( 9 | effect_type: str, # Changed to string type 10 | effect_category: Literal["scene", "character"], 11 | start: float = 0, 12 | end: float = 3.0, 13 | draft_id: Optional[str] = None, 14 | track_name: Optional[str] = "effect_01", 15 | params: Optional[List[Optional[float]]] = None, 16 | width: int = 1080, 17 | height: int = 1920 18 | ) -> Dict[str, str]: 19 | """ 20 | Add an effect to the specified draft 21 | :param effect_type: Effect type name, will be matched from Video_scene_effect_type or Video_character_effect_type 22 | :param effect_category: Effect category, "scene" or "character", default "scene" 23 | :param start: Start time (seconds), default 0 24 | :param end: End time (seconds), default 3 seconds 25 | :param draft_id: Draft ID, if None or corresponding zip file not found, a new draft will be created 26 | :param track_name: Track name, can be omitted when there is only one effect track 27 | :param params: Effect parameter list, items not provided or None in the parameter list will use default values 28 | :param width: Video width, default 1080 29 | :param height: Video height, default 1920 30 | :return: Updated draft information 31 | """ 32 | # Get or create draft 33 | draft_id, script = get_or_create_draft( 34 | draft_id=draft_id, 35 | width=width, 36 | height=height 37 | ) 38 | 39 | # Calculate time range 40 | duration = end - start 41 | t_range = trange(f"{start}s", f"{duration}s") 42 | 43 | # Select the corresponding effect type based on effect category and environment 44 | effect_enum = None 45 | if IS_CAPCUT_ENV: 46 | # If in CapCut environment, use CapCut effects 47 | if effect_category == "scene": 48 | try: 49 | effect_enum = CapCut_Video_scene_effect_type[effect_type] 50 | except: 51 | effect_enum = None 52 | elif effect_category == "character": 53 | try: 54 | effect_enum = CapCut_Video_character_effect_type[effect_type] 55 | except: 56 | effect_enum = None 57 | else: 58 | # Default to using JianYing effects 59 | if effect_category == "scene": 60 | try: 61 | effect_enum = Video_scene_effect_type[effect_type] 62 | except: 63 | effect_enum = None 64 | elif effect_category == "character": 65 | try: 66 | effect_enum = Video_character_effect_type[effect_type] 67 | except: 68 | effect_enum = None 69 | 70 | if effect_enum is None: 71 | raise ValueError(f"Unknown {effect_category} effect type: {effect_type}") 72 | 73 | # Add effect track (only when track doesn't exist) 74 | if track_name is not None: 75 | try: 76 | imported_track=script.get_imported_track(draft.Track_type.effect, name=track_name) 77 | # If no exception is thrown, the track already exists 78 | except exceptions.TrackNotFound: 79 | # Track doesn't exist, create a new track 80 | script.add_track(draft.Track_type.effect, track_name=track_name) 81 | else: 82 | script.add_track(draft.Track_type.effect) 83 | 84 | # Add effect 85 | script.add_effect(effect_enum, t_range, params=params[::-1], track_name=track_name) 86 | 87 | return { 88 | "draft_id": draft_id, 89 | "draft_url": generate_draft_url(draft_id) 90 | } 91 | -------------------------------------------------------------------------------- /get_duration_impl.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import json 3 | import time 4 | 5 | def get_video_duration(video_url): 6 | """ 7 | Get video duration with timeout retry support. 8 | :param video_url: Video URL 9 | :return: Video duration (seconds) 10 | """ 11 | 12 | # Define retry count and wait time for each retry 13 | max_retries = 3 14 | retry_delay_seconds = 1 # 1 second interval between retries 15 | timeout_seconds = 10 # Set timeout for each attempt 16 | 17 | for attempt in range(max_retries): 18 | print(f"Attempting to get video duration (Attempt {attempt + 1}/{max_retries}) ...") 19 | result = {"success": False, "output": 0, "error": None} # Reset result before each retry 20 | 21 | try: 22 | command = [ 23 | 'ffprobe', 24 | '-v', 'error', 25 | '-show_entries', 'stream=duration', 26 | '-show_entries', 'format=duration', 27 | '-print_format', 'json', 28 | video_url 29 | ] 30 | 31 | # Use subprocess.run for more flexible handling of timeout and output 32 | process = subprocess.run(command, 33 | capture_output=True, 34 | text=True, # Auto decode to text 35 | timeout=timeout_seconds, # Use variable to set timeout 36 | check=True) # Raise CalledProcessError if non-zero exit code 37 | 38 | info = json.loads(process.stdout) 39 | 40 | # Prioritize getting duration from streams because it's more accurate 41 | media_streams = [s for s in info.get('streams', []) if 'duration' in s] 42 | 43 | if media_streams: 44 | duration = float(media_streams[0]['duration']) 45 | result["output"] = duration 46 | result["success"] = True 47 | # Otherwise get duration from format information 48 | elif 'format' in info and 'duration' in info['format']: 49 | duration = float(info['format']['duration']) 50 | result["output"] = duration 51 | result["success"] = True 52 | else: 53 | result["error"] = "Audio/video duration information not found." 54 | 55 | # If duration is successfully obtained, return result directly without retrying 56 | if result["success"]: 57 | print(f"Successfully obtained duration: {result['output']:.2f} seconds") 58 | return result 59 | 60 | except subprocess.TimeoutExpired: 61 | result["error"] = f"Getting video duration timed out (exceeded {timeout_seconds} seconds)." 62 | print(f"Attempt {attempt + 1} timed out.") 63 | except subprocess.CalledProcessError as e: 64 | result["error"] = f"Error executing ffprobe command (exit code {e.returncode}): {e.stderr.strip()}" 65 | print(f"Attempt {attempt + 1} failed. Error: {e.stderr.strip()}") 66 | except json.JSONDecodeError as e: 67 | result["error"] = f"Error parsing JSON data: {e}" 68 | print(f"Attempt {attempt + 1} failed. JSON parsing error: {e}") 69 | except FileNotFoundError: 70 | result["error"] = "ffprobe command not found. Please ensure FFmpeg is installed and in system PATH." 71 | print("Error: ffprobe command not found, please check installation.") 72 | return result # No need to retry if ffprobe itself is not found 73 | except Exception as e: 74 | result["error"] = f"Unknown error occurred: {e}" 75 | print(f"Attempt {attempt + 1} failed. Unknown error: {e}") 76 | 77 | # Try using remote service to get duration after each local failure 78 | if not result["success"]: 79 | print(f"Local retrieval failed") 80 | # try: 81 | # remote_duration = get_duration(video_url) 82 | # if remote_duration is not None: 83 | # result["success"] = True 84 | # result["output"] = remote_duration 85 | # result["error"] = None 86 | # print(f"Remote service successfully obtained duration: {remote_duration:.2f} seconds") 87 | # return result # Remote service succeeded, return directly 88 | # else: 89 | # print(f"Remote service also unable to get duration (Attempt {attempt + 1})") 90 | # except Exception as e: 91 | # print(f"Remote service failed to get duration (Attempt {attempt + 1}): {e}") 92 | 93 | # If current attempt failed and max retries not reached, wait and prepare for next retry 94 | if not result["success"] and attempt < max_retries - 1: 95 | print(f"Waiting {retry_delay_seconds} seconds before retrying...") 96 | time.sleep(retry_delay_seconds) 97 | elif not result["success"] and attempt == max_retries - 1: 98 | print(f"Maximum retry count {max_retries} reached, both local and remote services unable to get duration.") 99 | 100 | return result # Return the last failure result after all retries fail -------------------------------------------------------------------------------- /MCP_文档_中文.md: -------------------------------------------------------------------------------- 1 | # CapCut API MCP 服务器使用文档 2 | 3 | ## 概述 4 | 5 | CapCut API MCP 服务器是一个基于 Model Context Protocol (MCP) 的视频编辑服务,提供了完整的 CapCut 视频编辑功能接口。通过 MCP 协议,您可以轻松地在各种应用中集成专业级的视频编辑能力。 6 | 7 | ## 功能特性 8 | 9 | ### 🎬 核心功能 10 | - **草稿管理**: 创建、保存和管理视频项目 11 | - **多媒体支持**: 视频、音频、图片、文本处理 12 | - **高级效果**: 特效、动画、转场、滤镜 13 | - **精确控制**: 时间轴、关键帧、图层管理 14 | 15 | ### 🛠️ 可用工具 (11个) 16 | 17 | | 工具名称 | 功能描述 | 主要参数 | 18 | |---------|----------|----------| 19 | | `create_draft` | 创建新的视频草稿项目 | width, height | 20 | | `add_text` | 添加文字元素 | text, font_size, color, shadow, background | 21 | | `add_video` | 添加视频轨道 | video_url, start, end, transform, volume | 22 | | `add_audio` | 添加音频轨道 | audio_url, volume, speed, effects | 23 | | `add_image` | 添加图片素材 | image_url, transform, animation, transition | 24 | | `add_subtitle` | 添加字幕文件 | srt_path, font_style, position | 25 | | `add_effect` | 添加视觉特效 | effect_type, parameters, duration | 26 | | `add_sticker` | 添加贴纸元素 | resource_id, position, scale, rotation | 27 | | `add_video_keyframe` | 添加关键帧动画 | property_types, times, values | 28 | | `get_video_duration` | 获取视频时长 | video_url | 29 | | `save_draft` | 保存草稿项目 | draft_id | 30 | 31 | ## 安装配置 32 | 33 | ### 环境要求 34 | - Python 3.10+ 35 | - CapCut 应用 (macOS/Windows) 36 | - MCP 客户端支持 37 | 38 | ### 依赖安装 39 | ```bash 40 | # 创建虚拟环境 41 | python3.10 -m venv venv-mcp 42 | source venv-mcp/bin/activate # macOS/Linux 43 | # 或 venv-mcp\Scripts\activate # Windows 44 | 45 | # 安装依赖 46 | pip install -r requirements-mcp.txt 47 | ``` 48 | 49 | ### MCP 配置 50 | 创建或更新 `mcp_config.json` 文件: 51 | 52 | ```json 53 | { 54 | "mcpServers": { 55 | "capcut-api": { 56 | "command": "python3.10", 57 | "args": ["mcp_server.py"], 58 | "cwd": "/path/to/CapCutAPI-dev", 59 | "env": { 60 | "PYTHONPATH": "/path/to/CapCutAPI-dev" 61 | } 62 | } 63 | } 64 | } 65 | ``` 66 | 67 | ## 使用指南 68 | 69 | ### 基础工作流程 70 | 71 | #### 1. 创建草稿 72 | ```python 73 | # 创建 1080x1920 竖屏项目 74 | result = mcp_client.call_tool("create_draft", { 75 | "width": 1080, 76 | "height": 1920 77 | }) 78 | draft_id = result["draft_id"] 79 | ``` 80 | 81 | #### 2. 添加内容 82 | ```python 83 | # 添加标题文字 84 | mcp_client.call_tool("add_text", { 85 | "text": "我的视频标题", 86 | "start": 0, 87 | "end": 5, 88 | "draft_id": draft_id, 89 | "font_size": 48, 90 | "font_color": "#FFFFFF" 91 | }) 92 | 93 | # 添加背景视频 94 | mcp_client.call_tool("add_video", { 95 | "video_url": "https://example.com/video.mp4", 96 | "draft_id": draft_id, 97 | "start": 0, 98 | "end": 10, 99 | "volume": 0.8 100 | }) 101 | ``` 102 | 103 | #### 3. 保存项目 104 | ```python 105 | # 保存草稿 106 | result = mcp_client.call_tool("save_draft", { 107 | "draft_id": draft_id 108 | }) 109 | ``` 110 | 111 | ### 高级功能示例 112 | 113 | #### 文字样式设置 114 | ```python 115 | # 带阴影和背景的文字 116 | mcp_client.call_tool("add_text", { 117 | "text": "高级文字效果", 118 | "draft_id": draft_id, 119 | "font_size": 56, 120 | "font_color": "#FFD700", 121 | "shadow_enabled": True, 122 | "shadow_color": "#000000", 123 | "shadow_alpha": 0.8, 124 | "background_color": "#1E1E1E", 125 | "background_alpha": 0.7, 126 | "background_round_radius": 15 127 | }) 128 | ``` 129 | 130 | #### 关键帧动画 131 | ```python 132 | # 缩放和透明度动画 133 | mcp_client.call_tool("add_video_keyframe", { 134 | "draft_id": draft_id, 135 | "track_name": "video_main", 136 | "property_types": ["scale_x", "scale_y", "alpha"], 137 | "times": [0, 2, 4], 138 | "values": ["1.0", "1.5", "0.5"] 139 | }) 140 | ``` 141 | 142 | #### 多样式文本 143 | ```python 144 | # 不同颜色的文字段落 145 | mcp_client.call_tool("add_text", { 146 | "text": "彩色文字效果", 147 | "draft_id": draft_id, 148 | "text_styles": [ 149 | {"start": 0, "end": 2, "font_color": "#FF0000"}, 150 | {"start": 2, "end": 4, "font_color": "#00FF00"} 151 | ] 152 | }) 153 | ``` 154 | 155 | ## 测试验证 156 | 157 | ### 使用测试客户端 158 | ```bash 159 | # 运行测试客户端 160 | python test_mcp_client.py 161 | ``` 162 | 163 | ### 功能验证清单 164 | - [ ] 服务器启动成功 165 | - [ ] 工具列表获取正常 166 | - [ ] 草稿创建功能 167 | - [ ] 文本添加功能 168 | - [ ] 视频/音频/图片添加 169 | - [ ] 特效和动画功能 170 | - [ ] 草稿保存功能 171 | 172 | ## 故障排除 173 | 174 | ### 常见问题 175 | 176 | #### 1. "CapCut modules not available" 177 | **解决方案**: 178 | - 确认 CapCut 应用已安装 179 | - 检查 Python 路径配置 180 | - 验证依赖包安装 181 | 182 | #### 2. 服务器启动失败 183 | **解决方案**: 184 | - 检查虚拟环境激活 185 | - 验证配置文件路径 186 | - 查看错误日志 187 | 188 | #### 3. 工具调用错误 189 | **解决方案**: 190 | - 检查参数格式 191 | - 验证媒体文件URL 192 | - 确认时间范围设置 193 | 194 | ### 调试模式 195 | ```bash 196 | # 启用详细日志 197 | export DEBUG=1 198 | python mcp_server.py 199 | ``` 200 | 201 | ## 最佳实践 202 | 203 | ### 性能优化 204 | 1. **媒体文件**: 使用压缩格式,避免过大文件 205 | 2. **时间管理**: 合理规划元素时间轴,避免重叠 206 | 3. **内存使用**: 及时保存草稿,清理临时文件 207 | 208 | ### 错误处理 209 | 1. **参数验证**: 调用前检查必需参数 210 | 2. **异常捕获**: 处理网络和文件错误 211 | 3. **重试机制**: 对临时失败进行重试 212 | 213 | ## API 参考 214 | 215 | ### 通用参数 216 | - `draft_id`: 草稿唯一标识符 217 | - `start/end`: 时间范围(秒) 218 | - `width/height`: 项目尺寸 219 | - `transform_x/y`: 位置坐标 220 | - `scale_x/y`: 缩放比例 221 | 222 | ### 返回格式 223 | ```json 224 | { 225 | "success": true, 226 | "result": { 227 | "draft_id": "dfd_cat_xxx", 228 | "draft_url": "https://..." 229 | }, 230 | "features_used": { 231 | "shadow": false, 232 | "background": false, 233 | "multi_style": false 234 | } 235 | } 236 | ``` 237 | 238 | ## 更新日志 239 | 240 | ### v1.0.0 241 | - 初始版本发布 242 | - 支持 11 个核心工具 243 | - 完整的 MCP 协议实现 244 | 245 | ## 技术支持 246 | 247 | 如有问题或建议,请通过以下方式联系: 248 | - GitHub Issues 249 | - 技术文档 250 | - 社区论坛 251 | 252 | --- 253 | 254 | *本文档持续更新,请关注最新版本。* -------------------------------------------------------------------------------- /save_task_cache.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | import threading 3 | from typing import Dict, Any 4 | 5 | # Using OrderedDict to implement LRU cache, limiting the maximum number to 1000 6 | DRAFT_TASKS: Dict[str, dict] = OrderedDict() # Using Dict for type hinting 7 | MAX_TASKS_CACHE_SIZE = 1000 8 | 9 | 10 | def update_tasks_cache(task_id: str, task_status: dict) -> None: 11 | """Update task status LRU cache 12 | 13 | :param task_id: Task ID 14 | :param task_status: Task status information dictionary 15 | """ 16 | 17 | if task_id in DRAFT_TASKS: 18 | # If the key exists, delete the old item 19 | DRAFT_TASKS.pop(task_id) 20 | elif len(DRAFT_TASKS) >= MAX_TASKS_CACHE_SIZE: 21 | # If the cache is full, delete the least recently used item (the first item) 22 | DRAFT_TASKS.popitem(last=False) 23 | # Add new item to the end (most recently used) 24 | DRAFT_TASKS[task_id] = task_status 25 | 26 | def update_task_field(task_id: str, field: str, value: Any) -> None: 27 | """Update a single field in the task status 28 | 29 | :param task_id: Task ID 30 | :param field: Field name to update 31 | :param value: New value for the field 32 | """ 33 | if task_id in DRAFT_TASKS: 34 | # Copy the current status, modify the specified field, then update the cache 35 | task_status = DRAFT_TASKS[task_id].copy() 36 | task_status[field] = value 37 | # Delete the old item and add the updated item 38 | DRAFT_TASKS.pop(task_id) 39 | DRAFT_TASKS[task_id] = task_status 40 | else: 41 | # If the task doesn't exist, create a default status and set the specified field 42 | task_status = { 43 | "status": "initialized", 44 | "message": "Task initialized", 45 | "progress": 0, 46 | "completed_files": 0, 47 | "total_files": 0, 48 | "draft_url": "" 49 | } 50 | task_status[field] = value 51 | # If the cache is full, delete the least recently used item 52 | if len(DRAFT_TASKS) >= MAX_TASKS_CACHE_SIZE: 53 | DRAFT_TASKS.popitem(last=False) 54 | # Add new item 55 | DRAFT_TASKS[task_id] = task_status 56 | 57 | def update_task_fields(task_id: str, **fields) -> None: 58 | """Update multiple fields in the task status 59 | 60 | :param task_id: Task ID 61 | :param fields: Fields to update and their values, provided as keyword arguments 62 | """ 63 | if task_id in DRAFT_TASKS: 64 | # Copy the current status, modify the specified fields, then update the cache 65 | task_status = DRAFT_TASKS[task_id].copy() 66 | for field, value in fields.items(): 67 | task_status[field] = value 68 | # Delete the old item and add the updated item 69 | DRAFT_TASKS.pop(task_id) 70 | DRAFT_TASKS[task_id] = task_status 71 | else: 72 | # If the task doesn't exist, create a default status and set the specified fields 73 | task_status = { 74 | "status": "initialized", 75 | "message": "Task initialized", 76 | "progress": 0, 77 | "completed_files": 0, 78 | "total_files": 0, 79 | "draft_url": "" 80 | } 81 | for field, value in fields.items(): 82 | task_status[field] = value 83 | # If the cache is full, delete the least recently used item 84 | if len(DRAFT_TASKS) >= MAX_TASKS_CACHE_SIZE: 85 | DRAFT_TASKS.popitem(last=False) 86 | # Add new item 87 | DRAFT_TASKS[task_id] = task_status 88 | 89 | def increment_task_field(task_id: str, field: str, increment: int = 1) -> None: 90 | """Increment a numeric field in the task status 91 | 92 | :param task_id: Task ID 93 | :param field: Field name to increment 94 | :param increment: Value to increment by, default is 1 95 | """ 96 | if task_id in DRAFT_TASKS: 97 | # Copy the current status, increment the specified field, then update the cache 98 | task_status = DRAFT_TASKS[task_id].copy() 99 | if field in task_status and isinstance(task_status[field], (int, float)): 100 | task_status[field] += increment 101 | else: 102 | task_status[field] = increment 103 | # Delete the old item and add the updated item 104 | DRAFT_TASKS.pop(task_id) 105 | DRAFT_TASKS[task_id] = task_status 106 | 107 | def get_task_status(task_id: str) -> dict: 108 | """Get task status 109 | 110 | :param task_id: Task ID 111 | :return: Task status information dictionary 112 | """ 113 | task_status = DRAFT_TASKS.get(task_id, { 114 | "status": "not_found", 115 | "message": "Task does not exist", 116 | "progress": 0, 117 | "completed_files": 0, 118 | "total_files": 0, 119 | "draft_url": "" 120 | }) 121 | 122 | # If the task is found, update its position in the LRU cache 123 | if task_id in DRAFT_TASKS: 124 | # First delete, then add to the end, implementing LRU update 125 | update_tasks_cache(task_id, task_status) 126 | 127 | return task_status 128 | 129 | def create_task(task_id: str) -> None: 130 | """Create a new task and initialize its status 131 | 132 | :param task_id: Task ID 133 | """ 134 | task_status = { 135 | "status": "initialized", 136 | "message": "Task initialized", 137 | "progress": 0, 138 | "completed_files": 0, 139 | "total_files": 0, 140 | "draft_url": "" 141 | } 142 | update_tasks_cache(task_id, task_status) -------------------------------------------------------------------------------- /pyJianYingDraft/animation.py: -------------------------------------------------------------------------------- 1 | """定义视频/文本动画相关类""" 2 | 3 | import uuid 4 | 5 | from typing import Union, Optional 6 | from typing import Literal, Dict, List, Any 7 | 8 | from .time_util import Timerange 9 | 10 | from .metadata.animation_meta import Animation_meta 11 | from .metadata import Intro_type, Outro_type, Group_animation_type 12 | from .metadata import CapCut_Intro_type, CapCut_Outro_type, CapCut_Group_animation_type 13 | from .metadata import Text_intro, Text_outro, Text_loop_anim 14 | from .metadata import CapCut_Text_intro, CapCut_Text_loop_anim, CapCut_Text_outro 15 | 16 | class Animation: 17 | """一个视频/文本动画效果""" 18 | 19 | name: str 20 | """动画名称, 默认取为动画效果的名称""" 21 | effect_id: str 22 | """另一种动画id, 由剪映本身提供""" 23 | animation_type: str 24 | """动画类型, 在子类中定义""" 25 | resource_id: str 26 | """资源id, 由剪映本身提供""" 27 | 28 | start: int 29 | """动画相对此片段开头的偏移, 单位为微秒""" 30 | duration: int 31 | """动画持续时间, 单位为微秒""" 32 | 33 | is_video_animation: bool 34 | """是否为视频动画, 在子类中定义""" 35 | 36 | def __init__(self, animation_meta: Animation_meta, start: int, duration: int): 37 | self.name = animation_meta.title 38 | self.effect_id = animation_meta.effect_id 39 | self.resource_id = animation_meta.resource_id 40 | 41 | self.start = start 42 | self.duration = duration 43 | 44 | def export_json(self) -> Dict[str, Any]: 45 | return { 46 | "anim_adjust_params": None, 47 | "platform": "all", 48 | "panel": "video" if self.is_video_animation else "", 49 | "material_type": "video" if self.is_video_animation else "sticker", 50 | 51 | "name": self.name, 52 | "id": self.effect_id, 53 | "type": self.animation_type, 54 | "resource_id": self.resource_id, 55 | 56 | "start": self.start, 57 | "duration": self.duration, 58 | # 不导出path和request_id 59 | } 60 | 61 | class Video_animation(Animation): 62 | """一个视频动画效果""" 63 | 64 | animation_type: Literal["in", "out", "group"] 65 | 66 | def __init__(self, animation_type: Union[Intro_type, Outro_type, Group_animation_type, CapCut_Intro_type, CapCut_Outro_type, CapCut_Group_animation_type], 67 | start: int, duration: int): 68 | super().__init__(animation_type.value, start, duration) 69 | 70 | if ((isinstance(animation_type, Intro_type) or isinstance(animation_type, CapCut_Intro_type))): 71 | self.animation_type = "in" 72 | elif isinstance(animation_type, Outro_type) or isinstance(animation_type, CapCut_Outro_type): 73 | self.animation_type = "out" 74 | elif isinstance(animation_type, Group_animation_type) or isinstance(animation_type, CapCut_Group_animation_type): 75 | self.animation_type = "group" 76 | 77 | self.is_video_animation = True 78 | 79 | class Text_animation(Animation): 80 | """一个文本动画效果""" 81 | 82 | animation_type: Literal["in", "out", "loop"] 83 | 84 | def __init__(self, animation_type: Union[Text_intro, Text_outro, Text_loop_anim, CapCut_Text_intro, CapCut_Text_outro, CapCut_Text_loop_anim], 85 | start: int, duration: int): 86 | super().__init__(animation_type.value, start, duration) 87 | 88 | if (isinstance(animation_type, Text_intro) or isinstance(animation_type, CapCut_Text_intro)): 89 | self.animation_type = "in" 90 | elif (isinstance(animation_type, Text_outro) or isinstance(animation_type, CapCut_Text_outro)): 91 | self.animation_type = "out" 92 | elif (isinstance(animation_type, Text_loop_anim) or isinstance(animation_type, CapCut_Text_loop_anim)): 93 | self.animation_type = "loop" 94 | 95 | self.is_video_animation = False 96 | 97 | class Segment_animations: 98 | """附加于某素材上的一系列动画 99 | 100 | 对视频片段:入场、出场或组合动画;对文本片段:入场、出场或循环动画""" 101 | 102 | animation_id: str 103 | """系列动画的全局id, 自动生成""" 104 | 105 | animations: List[Animation] 106 | """动画列表""" 107 | 108 | def __init__(self): 109 | self.animation_id = uuid.uuid4().hex 110 | self.animations = [] 111 | 112 | def get_animation_trange(self, animation_type: Literal["in", "out", "group", "loop"]) -> Optional[Timerange]: 113 | """获取指定类型的动画的时间范围""" 114 | for animation in self.animations: 115 | if animation.animation_type == animation_type: 116 | return Timerange(animation.start, animation.duration) 117 | return None 118 | 119 | def add_animation(self, animation: Union[Video_animation, Text_animation]) -> None: 120 | # 不允许添加超过一个同类型的动画(如两个入场动画) 121 | if animation.animation_type in [ani.animation_type for ani in self.animations]: 122 | raise ValueError(f"当前片段已存在类型为 '{animation.animation_type}' 的动画") 123 | 124 | if isinstance(animation, Video_animation): 125 | # 不允许组合动画与出入场动画同时出现 126 | if any(ani.animation_type == "group" for ani in self.animations): 127 | raise ValueError("当前片段已存在组合动画, 此时不能添加其它动画") 128 | if animation.animation_type == "group" and len(self.animations) > 0: 129 | raise ValueError("当前片段已存在动画时, 不能添加组合动画") 130 | elif isinstance(animation, Text_animation): 131 | if any(ani.animation_type == "loop" for ani in self.animations): 132 | raise ValueError("当前片段已存在循环动画, 若希望同时使用循环动画和入出场动画, 请先添加出入场动画再添加循环动画") 133 | 134 | self.animations.append(animation) 135 | 136 | def export_json(self) -> Dict[str, Any]: 137 | return { 138 | "id": self.animation_id, 139 | "type": "sticker_animation", 140 | "multi_language_current": "none", 141 | "animations": [animation.export_json() for animation in self.animations] 142 | } 143 | -------------------------------------------------------------------------------- /add_subtitle_impl.py: -------------------------------------------------------------------------------- 1 | import pyJianYingDraft as draft 2 | from util import generate_draft_url, hex_to_rgb 3 | from create_draft import get_or_create_draft 4 | from pyJianYingDraft.text_segment import TextBubble, TextEffect 5 | from typing import Optional 6 | import requests 7 | import os 8 | 9 | def add_subtitle_impl( 10 | srt_path: str, 11 | draft_id: str = None, 12 | track_name: str = "subtitle", 13 | time_offset: float = 0, 14 | # Font style parameters 15 | font: str = None, 16 | font_size: float = 8.0, 17 | bold: bool = False, 18 | italic: bool = False, 19 | underline: bool = False, 20 | font_color: str = "#FFFFFF", 21 | 22 | # Border parameters 23 | border_alpha: float = 1.0, 24 | border_color: str = "#000000", 25 | border_width: float = 0.0, # Default no border display 26 | 27 | # Background parameters 28 | background_color: str = "#000000", 29 | background_style: int = 1, 30 | background_alpha: float = 0.0, # Default no background display 31 | 32 | # Bubble effect 33 | bubble_effect_id: Optional[str] = None, 34 | bubble_resource_id: Optional[str] = None, 35 | 36 | # Text effect 37 | effect_effect_id: Optional[str] = None, 38 | # Image adjustment parameters 39 | transform_x: float = 0.0, 40 | transform_y: float = -0.8, # Default subtitle position at bottom 41 | scale_x: float = 1.0, 42 | scale_y: float = 1.0, 43 | rotation: float = 0.0, 44 | style_reference: draft.Text_segment = None, 45 | vertical: bool = True, # New parameter: whether to display vertically 46 | alpha: float = 0.4, 47 | width: int = 1080, # New parameter 48 | height: int = 1920 # New parameter 49 | ): 50 | """ 51 | Add subtitles to draft 52 | :param srt_path: Subtitle file path or URL or SRT text content 53 | :param draft_id: Draft ID, if None, create a new draft 54 | :param track_name: Track name, default is "subtitle" 55 | :param time_offset: Time offset, default is "0"s 56 | :param text_style: Text style, default is None 57 | :param clip_settings: Clip settings, default is None 58 | :param style_reference: Style reference, default is None 59 | :return: Draft information 60 | """ 61 | # Get or create draft 62 | draft_id, script = get_or_create_draft( 63 | draft_id=draft_id, 64 | width=width, 65 | height=height 66 | ) 67 | 68 | # Process subtitle content 69 | srt_content = None 70 | 71 | # Check if it's a URL 72 | if srt_path.startswith(('http://', 'https://')): 73 | try: 74 | response = requests.get(srt_path) 75 | response.raise_for_status() 76 | 77 | response.encoding = 'utf-8' 78 | srt_content = response.text 79 | except Exception as e: 80 | raise Exception(f"Failed to download subtitle file: {str(e)}") 81 | elif os.path.isfile(srt_path): # Check if it's a file 82 | try: 83 | with open(srt_path, 'r', encoding='utf-8-sig') as f: 84 | srt_content = f.read() 85 | except Exception as e: 86 | raise Exception(f"Failed to read local subtitle file: {str(e)}") 87 | else: 88 | # If not a URL or local file, use content directly 89 | srt_content = srt_path 90 | # Handle possible escape characters 91 | srt_content = srt_content.replace('\\n', '\n').replace('/n', '\n') 92 | 93 | # Import subtitles 94 | # Convert hexadecimal color to RGB 95 | rgb_color = hex_to_rgb(font_color) 96 | 97 | # Create text_border 98 | text_border = None 99 | if border_width > 0: 100 | text_border = draft.Text_border( 101 | alpha=border_alpha, 102 | color=hex_to_rgb(border_color), 103 | width=border_width 104 | ) 105 | 106 | # Create text_background 107 | text_background = None 108 | if background_alpha > 0: 109 | text_background = draft.Text_background( 110 | color=background_color, 111 | style=background_style, 112 | alpha=background_alpha 113 | ) 114 | 115 | # Create text_style 116 | text_style = draft.Text_style( 117 | size=font_size, 118 | bold=bold, 119 | italic=italic, 120 | underline=underline, 121 | color=rgb_color, 122 | align=1, # Keep center alignment 123 | vertical=vertical, # Use the passed vertical parameter 124 | alpha=alpha # Use the passed alpha parameter 125 | ) 126 | 127 | # Create bubble effect 128 | text_bubble = None 129 | if bubble_effect_id and bubble_resource_id: 130 | text_bubble = TextBubble( 131 | effect_id=bubble_effect_id, 132 | resource_id=bubble_resource_id 133 | ) 134 | 135 | # Create text effect 136 | text_effect = None 137 | if effect_effect_id: 138 | text_effect = TextEffect( 139 | effect_id=effect_effect_id, 140 | resource_id=effect_effect_id 141 | ) 142 | 143 | # Create clip_settings 144 | clip_settings = draft.Clip_settings( 145 | transform_x=transform_x, 146 | transform_y=transform_y, 147 | scale_x=scale_x, 148 | scale_y=scale_y, 149 | rotation=rotation 150 | ) 151 | 152 | script.import_srt( 153 | srt_content, 154 | track_name=track_name, 155 | time_offset=int(time_offset * 1000000), # Convert seconds to microseconds 156 | text_style=text_style, 157 | font=font, 158 | clip_settings=clip_settings, 159 | style_reference=style_reference, 160 | border=text_border, 161 | background=text_background, 162 | bubble=text_bubble, 163 | effect=text_effect 164 | ) 165 | 166 | return { 167 | "draft_id": draft_id, 168 | "draft_url": generate_draft_url(draft_id) 169 | } 170 | -------------------------------------------------------------------------------- /template/draft_info.json: -------------------------------------------------------------------------------- 1 | { 2 | "canvas_config": { 3 | "background": null, 4 | "height": 1080, 5 | "ratio": "original", 6 | "width": 1920 7 | }, 8 | "color_space": -1, 9 | "config": { 10 | "adjust_max_index": 1, 11 | "attachment_info": [ 12 | 13 | ], 14 | "combination_max_index": 1, 15 | "export_range": null, 16 | "extract_audio_last_index": 1, 17 | "lyrics_recognition_id": "", 18 | "lyrics_sync": true, 19 | "lyrics_taskinfo": [ 20 | 21 | ], 22 | "maintrack_adsorb": true, 23 | "material_save_mode": 0, 24 | "multi_language_current": "none", 25 | "multi_language_list": [ 26 | 27 | ], 28 | "multi_language_main": "none", 29 | "multi_language_mode": "none", 30 | "original_sound_last_index": 1, 31 | "record_audio_last_index": 1, 32 | "sticker_max_index": 1, 33 | "subtitle_keywords_config": null, 34 | "subtitle_recognition_id": "", 35 | "subtitle_sync": true, 36 | "subtitle_taskinfo": [ 37 | 38 | ], 39 | "system_font_list": [ 40 | 41 | ], 42 | "use_float_render": false, 43 | "video_mute": false, 44 | "zoom_info_params": null 45 | }, 46 | "cover": null, 47 | "create_time": 0, 48 | "duration": 0, 49 | "extra_info": null, 50 | "fps": 30, 51 | "free_render_index_mode_on": false, 52 | "group_container": null, 53 | "id": "26FDE5CD-364C-45A3-8930-A40DF2F7EB2B", 54 | "is_drop_frame_timecode": false, 55 | "keyframe_graph_list": [ 56 | 57 | ], 58 | "keyframes": { 59 | "adjusts": [ 60 | 61 | ], 62 | "audios": [ 63 | 64 | ], 65 | "effects": [ 66 | 67 | ], 68 | "filters": [ 69 | 70 | ], 71 | "handwrites": [ 72 | 73 | ], 74 | "stickers": [ 75 | 76 | ], 77 | "texts": [ 78 | 79 | ], 80 | "videos": [ 81 | 82 | ] 83 | }, 84 | "last_modified_platform": { 85 | "app_id": 359289, 86 | "app_source": "cc", 87 | "app_version": "6.5.0", 88 | "device_id": "c4ca4238a0b923820dcc509a6f75849b", 89 | "hard_disk_id": "307563e0192a94465c0e927fbc482942", 90 | "mac_address": "c3371f2d4fb02791c067ce44d8fb4ed5", 91 | "os": "mac", 92 | "os_version": "15.5" 93 | }, 94 | "lyrics_effects": [ 95 | 96 | ], 97 | "materials": { 98 | "ai_translates": [ 99 | 100 | ], 101 | "audio_balances": [ 102 | 103 | ], 104 | "audio_effects": [ 105 | 106 | ], 107 | "audio_fades": [ 108 | 109 | ], 110 | "audio_track_indexes": [ 111 | 112 | ], 113 | "audios": [ 114 | 115 | ], 116 | "beats": [ 117 | 118 | ], 119 | "canvases": [ 120 | 121 | ], 122 | "chromas": [ 123 | 124 | ], 125 | "color_curves": [ 126 | 127 | ], 128 | "common_mask": [ 129 | 130 | ], 131 | "digital_human_model_dressing": [ 132 | 133 | ], 134 | "digital_humans": [ 135 | 136 | ], 137 | "drafts": [ 138 | 139 | ], 140 | "effects": [ 141 | 142 | ], 143 | "flowers": [ 144 | 145 | ], 146 | "green_screens": [ 147 | 148 | ], 149 | "handwrites": [ 150 | 151 | ], 152 | "hsl": [ 153 | 154 | ], 155 | "images": [ 156 | 157 | ], 158 | "log_color_wheels": [ 159 | 160 | ], 161 | "loudnesses": [ 162 | 163 | ], 164 | "manual_beautys": [ 165 | 166 | ], 167 | "manual_deformations": [ 168 | 169 | ], 170 | "material_animations": [ 171 | 172 | ], 173 | "material_colors": [ 174 | 175 | ], 176 | "multi_language_refs": [ 177 | 178 | ], 179 | "placeholder_infos": [ 180 | 181 | ], 182 | "placeholders": [ 183 | 184 | ], 185 | "plugin_effects": [ 186 | 187 | ], 188 | "primary_color_wheels": [ 189 | 190 | ], 191 | "realtime_denoises": [ 192 | 193 | ], 194 | "shapes": [ 195 | 196 | ], 197 | "smart_crops": [ 198 | 199 | ], 200 | "smart_relights": [ 201 | 202 | ], 203 | "sound_channel_mappings": [ 204 | 205 | ], 206 | "speeds": [ 207 | 208 | ], 209 | "stickers": [ 210 | 211 | ], 212 | "tail_leaders": [ 213 | 214 | ], 215 | "text_templates": [ 216 | 217 | ], 218 | "texts": [ 219 | 220 | ], 221 | "time_marks": [ 222 | 223 | ], 224 | "transitions": [ 225 | 226 | ], 227 | "video_effects": [ 228 | 229 | ], 230 | "video_trackings": [ 231 | 232 | ], 233 | "videos": [ 234 | 235 | ], 236 | "vocal_beautifys": [ 237 | 238 | ], 239 | "vocal_separations": [ 240 | 241 | ] 242 | }, 243 | "mutable_config": null, 244 | "name": "", 245 | "new_version": "138.0.0", 246 | "path": "", 247 | "platform": { 248 | "app_id": 359289, 249 | "app_source": "cc", 250 | "app_version": "6.5.0", 251 | "device_id": "c4ca4238a0b923820dcc509a6f75849b", 252 | "hard_disk_id": "307563e0192a94465c0e927fbc482942", 253 | "mac_address": "c3371f2d4fb02791c067ce44d8fb4ed5", 254 | "os": "mac", 255 | "os_version": "15.5" 256 | }, 257 | "relationships": [ 258 | 259 | ], 260 | "render_index_track_mode_on": true, 261 | "retouch_cover": null, 262 | "source": "default", 263 | "static_cover_image_path": "", 264 | "time_marks": null, 265 | "tracks": [ 266 | 267 | ], 268 | "uneven_animation_template_info": { 269 | "composition": "", 270 | "content": "", 271 | "order": "", 272 | "sub_template_info_list": [ 273 | 274 | ] 275 | }, 276 | "update_time": 0, 277 | "version": 360000 278 | } -------------------------------------------------------------------------------- /template/draft_info.json.bak: -------------------------------------------------------------------------------- 1 | { 2 | "canvas_config": { 3 | "background": null, 4 | "height": 1080, 5 | "ratio": "original", 6 | "width": 1920 7 | }, 8 | "color_space": -1, 9 | "config": { 10 | "adjust_max_index": 1, 11 | "attachment_info": [ 12 | 13 | ], 14 | "combination_max_index": 1, 15 | "export_range": null, 16 | "extract_audio_last_index": 1, 17 | "lyrics_recognition_id": "", 18 | "lyrics_sync": true, 19 | "lyrics_taskinfo": [ 20 | 21 | ], 22 | "maintrack_adsorb": true, 23 | "material_save_mode": 0, 24 | "multi_language_current": "none", 25 | "multi_language_list": [ 26 | 27 | ], 28 | "multi_language_main": "none", 29 | "multi_language_mode": "none", 30 | "original_sound_last_index": 1, 31 | "record_audio_last_index": 1, 32 | "sticker_max_index": 1, 33 | "subtitle_keywords_config": null, 34 | "subtitle_recognition_id": "", 35 | "subtitle_sync": true, 36 | "subtitle_taskinfo": [ 37 | 38 | ], 39 | "system_font_list": [ 40 | 41 | ], 42 | "use_float_render": false, 43 | "video_mute": false, 44 | "zoom_info_params": null 45 | }, 46 | "cover": null, 47 | "create_time": 0, 48 | "duration": 0, 49 | "extra_info": null, 50 | "fps": 30, 51 | "free_render_index_mode_on": false, 52 | "group_container": null, 53 | "id": "26FDE5CD-364C-45A3-8930-A40DF2F7EB2B", 54 | "is_drop_frame_timecode": false, 55 | "keyframe_graph_list": [ 56 | 57 | ], 58 | "keyframes": { 59 | "adjusts": [ 60 | 61 | ], 62 | "audios": [ 63 | 64 | ], 65 | "effects": [ 66 | 67 | ], 68 | "filters": [ 69 | 70 | ], 71 | "handwrites": [ 72 | 73 | ], 74 | "stickers": [ 75 | 76 | ], 77 | "texts": [ 78 | 79 | ], 80 | "videos": [ 81 | 82 | ] 83 | }, 84 | "last_modified_platform": { 85 | "app_id": 359289, 86 | "app_source": "cc", 87 | "app_version": "6.5.0", 88 | "device_id": "c4ca4238a0b923820dcc509a6f75849b", 89 | "hard_disk_id": "307563e0192a94465c0e927fbc482942", 90 | "mac_address": "c3371f2d4fb02791c067ce44d8fb4ed5", 91 | "os": "mac", 92 | "os_version": "15.5" 93 | }, 94 | "lyrics_effects": [ 95 | 96 | ], 97 | "materials": { 98 | "ai_translates": [ 99 | 100 | ], 101 | "audio_balances": [ 102 | 103 | ], 104 | "audio_effects": [ 105 | 106 | ], 107 | "audio_fades": [ 108 | 109 | ], 110 | "audio_track_indexes": [ 111 | 112 | ], 113 | "audios": [ 114 | 115 | ], 116 | "beats": [ 117 | 118 | ], 119 | "canvases": [ 120 | 121 | ], 122 | "chromas": [ 123 | 124 | ], 125 | "color_curves": [ 126 | 127 | ], 128 | "common_mask": [ 129 | 130 | ], 131 | "digital_human_model_dressing": [ 132 | 133 | ], 134 | "digital_humans": [ 135 | 136 | ], 137 | "drafts": [ 138 | 139 | ], 140 | "effects": [ 141 | 142 | ], 143 | "flowers": [ 144 | 145 | ], 146 | "green_screens": [ 147 | 148 | ], 149 | "handwrites": [ 150 | 151 | ], 152 | "hsl": [ 153 | 154 | ], 155 | "images": [ 156 | 157 | ], 158 | "log_color_wheels": [ 159 | 160 | ], 161 | "loudnesses": [ 162 | 163 | ], 164 | "manual_beautys": [ 165 | 166 | ], 167 | "manual_deformations": [ 168 | 169 | ], 170 | "material_animations": [ 171 | 172 | ], 173 | "material_colors": [ 174 | 175 | ], 176 | "multi_language_refs": [ 177 | 178 | ], 179 | "placeholder_infos": [ 180 | 181 | ], 182 | "placeholders": [ 183 | 184 | ], 185 | "plugin_effects": [ 186 | 187 | ], 188 | "primary_color_wheels": [ 189 | 190 | ], 191 | "realtime_denoises": [ 192 | 193 | ], 194 | "shapes": [ 195 | 196 | ], 197 | "smart_crops": [ 198 | 199 | ], 200 | "smart_relights": [ 201 | 202 | ], 203 | "sound_channel_mappings": [ 204 | 205 | ], 206 | "speeds": [ 207 | 208 | ], 209 | "stickers": [ 210 | 211 | ], 212 | "tail_leaders": [ 213 | 214 | ], 215 | "text_templates": [ 216 | 217 | ], 218 | "texts": [ 219 | 220 | ], 221 | "time_marks": [ 222 | 223 | ], 224 | "transitions": [ 225 | 226 | ], 227 | "video_effects": [ 228 | 229 | ], 230 | "video_trackings": [ 231 | 232 | ], 233 | "videos": [ 234 | 235 | ], 236 | "vocal_beautifys": [ 237 | 238 | ], 239 | "vocal_separations": [ 240 | 241 | ] 242 | }, 243 | "mutable_config": null, 244 | "name": "", 245 | "new_version": "138.0.0", 246 | "path": "", 247 | "platform": { 248 | "app_id": 359289, 249 | "app_source": "cc", 250 | "app_version": "6.5.0", 251 | "device_id": "c4ca4238a0b923820dcc509a6f75849b", 252 | "hard_disk_id": "307563e0192a94465c0e927fbc482942", 253 | "mac_address": "c3371f2d4fb02791c067ce44d8fb4ed5", 254 | "os": "mac", 255 | "os_version": "15.5" 256 | }, 257 | "relationships": [ 258 | 259 | ], 260 | "render_index_track_mode_on": true, 261 | "retouch_cover": null, 262 | "source": "default", 263 | "static_cover_image_path": "", 264 | "time_marks": null, 265 | "tracks": [ 266 | 267 | ], 268 | "uneven_animation_template_info": { 269 | "composition": "", 270 | "content": "", 271 | "order": "", 272 | "sub_template_info_list": [ 273 | 274 | ] 275 | }, 276 | "update_time": 0, 277 | "version": 360000 278 | } -------------------------------------------------------------------------------- /MCP_Documentation_English.md: -------------------------------------------------------------------------------- 1 | # CapCut API MCP Server Documentation 2 | 3 | ## Overview 4 | 5 | The CapCut API MCP Server is a video editing service based on the Model Context Protocol (MCP), providing complete CapCut video editing functionality interfaces. Through the MCP protocol, you can easily integrate professional-grade video editing capabilities into various applications. 6 | 7 | ## Features 8 | 9 | ### 🎬 Core Capabilities 10 | - **Draft Management**: Create, save, and manage video projects 11 | - **Multimedia Support**: Video, audio, image, and text processing 12 | - **Advanced Effects**: Effects, animations, transitions, and filters 13 | - **Precise Control**: Timeline, keyframes, and layer management 14 | 15 | ### 🛠️ Available Tools (11 Tools) 16 | 17 | | Tool Name | Description | Key Parameters | 18 | |-----------|-------------|----------------| 19 | | `create_draft` | Create new video draft project | width, height | 20 | | `add_text` | Add text elements | text, font_size, color, shadow, background | 21 | | `add_video` | Add video track | video_url, start, end, transform, volume | 22 | | `add_audio` | Add audio track | audio_url, volume, speed, effects | 23 | | `add_image` | Add image assets | image_url, transform, animation, transition | 24 | | `add_subtitle` | Add subtitle files | srt_path, font_style, position | 25 | | `add_effect` | Add visual effects | effect_type, parameters, duration | 26 | | `add_sticker` | Add sticker elements | resource_id, position, scale, rotation | 27 | | `add_video_keyframe` | Add keyframe animations | property_types, times, values | 28 | | `get_video_duration` | Get video duration | video_url | 29 | | `save_draft` | Save draft project | draft_id | 30 | 31 | ## Installation & Setup 32 | 33 | ### Requirements 34 | - Python 3.10+ 35 | - CapCut Application (macOS/Windows) 36 | - MCP Client Support 37 | 38 | ### Dependencies Installation 39 | ```bash 40 | # Create virtual environment 41 | python3.10 -m venv venv-mcp 42 | source venv-mcp/bin/activate # macOS/Linux 43 | # or venv-mcp\Scripts\activate # Windows 44 | 45 | # Install dependencies 46 | pip install -r requirements-mcp.txt 47 | ``` 48 | 49 | ### MCP Configuration 50 | Create or update `mcp_config.json` file: 51 | 52 | ```json 53 | { 54 | "mcpServers": { 55 | "capcut-api": { 56 | "command": "python3.10", 57 | "args": ["mcp_server.py"], 58 | "cwd": "/path/to/CapCutAPI-dev", 59 | "env": { 60 | "PYTHONPATH": "/path/to/CapCutAPI-dev" 61 | } 62 | } 63 | } 64 | } 65 | ``` 66 | 67 | ## Usage Guide 68 | 69 | ### Basic Workflow 70 | 71 | #### 1. Create Draft 72 | ```python 73 | # Create 1080x1920 portrait project 74 | result = mcp_client.call_tool("create_draft", { 75 | "width": 1080, 76 | "height": 1920 77 | }) 78 | draft_id = result["draft_id"] 79 | ``` 80 | 81 | #### 2. Add Content 82 | ```python 83 | # Add title text 84 | mcp_client.call_tool("add_text", { 85 | "text": "My Video Title", 86 | "start": 0, 87 | "end": 5, 88 | "draft_id": draft_id, 89 | "font_size": 48, 90 | "font_color": "#FFFFFF" 91 | }) 92 | 93 | # Add background video 94 | mcp_client.call_tool("add_video", { 95 | "video_url": "https://example.com/video.mp4", 96 | "draft_id": draft_id, 97 | "start": 0, 98 | "end": 10, 99 | "volume": 0.8 100 | }) 101 | ``` 102 | 103 | #### 3. Save Project 104 | ```python 105 | # Save draft 106 | result = mcp_client.call_tool("save_draft", { 107 | "draft_id": draft_id 108 | }) 109 | ``` 110 | 111 | ### Advanced Features 112 | 113 | #### Text Styling 114 | ```python 115 | # Text with shadow and background 116 | mcp_client.call_tool("add_text", { 117 | "text": "Advanced Text Effects", 118 | "draft_id": draft_id, 119 | "font_size": 56, 120 | "font_color": "#FFD700", 121 | "shadow_enabled": True, 122 | "shadow_color": "#000000", 123 | "shadow_alpha": 0.8, 124 | "background_color": "#1E1E1E", 125 | "background_alpha": 0.7, 126 | "background_round_radius": 15 127 | }) 128 | ``` 129 | 130 | #### Keyframe Animation 131 | ```python 132 | # Scale and opacity animation 133 | mcp_client.call_tool("add_video_keyframe", { 134 | "draft_id": draft_id, 135 | "track_name": "video_main", 136 | "property_types": ["scale_x", "scale_y", "alpha"], 137 | "times": [0, 2, 4], 138 | "values": ["1.0", "1.5", "0.5"] 139 | }) 140 | ``` 141 | 142 | #### Multi-Style Text 143 | ```python 144 | # Different colored text segments 145 | mcp_client.call_tool("add_text", { 146 | "text": "Colorful Text Effect", 147 | "draft_id": draft_id, 148 | "text_styles": [ 149 | {"start": 0, "end": 2, "font_color": "#FF0000"}, 150 | {"start": 2, "end": 4, "font_color": "#00FF00"} 151 | ] 152 | }) 153 | ``` 154 | 155 | ## Testing & Validation 156 | 157 | ### Using Test Client 158 | ```bash 159 | # Run test client 160 | python test_mcp_client.py 161 | ``` 162 | 163 | ### Functionality Checklist 164 | - [ ] Server starts successfully 165 | - [ ] Tool list retrieval works 166 | - [ ] Draft creation functionality 167 | - [ ] Text addition functionality 168 | - [ ] Video/audio/image addition 169 | - [ ] Effects and animation functionality 170 | - [ ] Draft saving functionality 171 | 172 | ## Troubleshooting 173 | 174 | ### Common Issues 175 | 176 | #### 1. "CapCut modules not available" 177 | **Solution**: 178 | - Confirm CapCut application is installed 179 | - Check Python path configuration 180 | - Verify dependency package installation 181 | 182 | #### 2. Server startup failure 183 | **Solution**: 184 | - Check virtual environment activation 185 | - Verify configuration file paths 186 | - Review error logs 187 | 188 | #### 3. Tool call errors 189 | **Solution**: 190 | - Check parameter format 191 | - Verify media file URLs 192 | - Confirm time range settings 193 | 194 | ### Debug Mode 195 | ```bash 196 | # Enable verbose logging 197 | export DEBUG=1 198 | python mcp_server.py 199 | ``` 200 | 201 | ## Best Practices 202 | 203 | ### Performance Optimization 204 | 1. **Media Files**: Use compressed formats, avoid oversized files 205 | 2. **Time Management**: Plan element timelines reasonably, avoid overlaps 206 | 3. **Memory Usage**: Save drafts promptly, clean temporary files 207 | 208 | ### Error Handling 209 | 1. **Parameter Validation**: Check required parameters before calling 210 | 2. **Exception Catching**: Handle network and file errors 211 | 3. **Retry Mechanism**: Retry on temporary failures 212 | 213 | ## API Reference 214 | 215 | ### Common Parameters 216 | - `draft_id`: Unique draft identifier 217 | - `start/end`: Time range (seconds) 218 | - `width/height`: Project dimensions 219 | - `transform_x/y`: Position coordinates 220 | - `scale_x/y`: Scale ratios 221 | 222 | ### Response Format 223 | ```json 224 | { 225 | "success": true, 226 | "result": { 227 | "draft_id": "dfd_cat_xxx", 228 | "draft_url": "https://..." 229 | }, 230 | "features_used": { 231 | "shadow": false, 232 | "background": false, 233 | "multi_style": false 234 | } 235 | } 236 | ``` 237 | 238 | ## Changelog 239 | 240 | ### v1.0.0 241 | - Initial release 242 | - Support for 11 core tools 243 | - Complete MCP protocol implementation 244 | 245 | ## Technical Support 246 | 247 | For questions or suggestions, please contact us through: 248 | - GitHub Issues 249 | - Technical Documentation 250 | - Community Forums 251 | 252 | --- 253 | 254 | *This documentation is continuously updated. Please follow the latest version.* -------------------------------------------------------------------------------- /README-zh.md: -------------------------------------------------------------------------------- 1 | # 通过VectCutAPI连接AI生成的一切 [在线体验](https://www.vectcut.com) 2 | 3 |
4 | 5 | ``` 6 | 👏👏👏👏 庆祝github 800星,送出价值8000点不记名云渲染券:040346B5-8D8F-459E-8EE7-332C0B827117 7 | ``` 8 |
9 | 10 | ## 项目概览 11 | 12 | **VectCutAPI** 是一款强大的云端 剪辑 API,它赋予您对 AI 生成素材(包括图片、音频、视频和文字)的精确控制权。 13 | 它提供了精确的编辑能力来拼接原始的 AI 输出,例如给视频变速或将图片镜像反转。这种能力有效地解决了 AI 生成的结果缺乏精确控制,难以复制的问题,让您能够轻松地将创意想法转化为精致的视频。 14 | 15 | ### 核心优势 16 | 17 | 1. 通过API的方式,提供强大的剪辑能力 18 | 19 | 2. 可以在网页实时预览剪辑结果,无需下载,极大方便工作流开发。 20 | 21 | 3. 可以下载剪辑结果,并导入到剪映/CapCut中二次编辑。 22 | 23 | 4. 可以利用API将剪辑结果生成视频,实现全云端操作。 24 | 25 | ## 效果展示 26 | 27 |
28 | 29 | **MCP,创建属于自己的剪辑Agent** 30 | 31 | [![AI Cut](https://img.youtube.com/vi/fBqy6WFC78E/hqdefault.jpg)](https://www.youtube.com/watch?v=fBqy6WFC78E) 32 | 33 | **通过VectCutAPI,将AI生成的图片,视频组合起来** 34 | 35 | [![Airbnb](https://img.youtube.com/vi/1zmQWt13Dx0/hqdefault.jpg)](https://www.youtube.com/watch?v=1zmQWt13Dx0) 36 | 37 | [![Horse](https://img.youtube.com/vi/IF1RDFGOtEU/hqdefault.jpg)](https://www.youtube.com/watch?v=IF1RDFGOtEU) 38 | 39 | [![Song](https://img.youtube.com/vi/rGNLE_slAJ8/hqdefault.jpg)](https://www.youtube.com/watch?v=rGNLE_slAJ8) 40 | 41 |
42 | 43 | ## 核心功能 44 | 45 | 46 | | 功能模块 | API | MCP 协议 | 描述 | 47 | |---------|----------|----------|------| 48 | | **草稿管理** | ✅ | ✅ | 创建、保存剪映/CapCut草稿文件 | 49 | | **视频处理** | ✅ | ✅ | 多格式视频导入、剪辑、转场、特效 | 50 | | **音频编辑** | ✅ | ✅ | 音频轨道、音量控制、音效处理 | 51 | | **图像处理** | ✅ | ✅ | 图片导入、动画、蒙版、滤镜 | 52 | | **文本编辑** | ✅ | ✅ | 多样式文本、阴影、背景、动画 | 53 | | **字幕系统** | ✅ | ✅ | SRT 字幕导入、样式设置、时间同步 | 54 | | **特效引擎** | ✅ | ✅ | 视觉特效、滤镜、转场动画 | 55 | | **贴纸系统** | ✅ | ✅ | 贴纸素材、位置控制、动画效果 | 56 | | **关键帧** | ✅ | ✅ | 属性动画、时间轴控制、缓动函数 | 57 | | **媒体分析** | ✅ | ✅ | 视频时长获取、格式检测 | 58 | 59 | ## 快速开始 60 | 61 | ### 1. 系统要求 62 | 63 | - Python 3.10+ 64 | - 剪映 或 CapCut 国际版 65 | - FFmpeg 66 | 67 | ### 2. 安装部署 68 | 69 | ```bash 70 | # 1. 克隆项目 71 | git clone https://github.com/sun-guannan/VectCutAPI.git 72 | cd VectCutAPI 73 | 74 | # 2. 创建虚拟环境 (推荐) 75 | python -m venv venv-capcut 76 | source venv-capcut/bin/activate # Linux/macOS 77 | # 或 venv-capcut\Scripts\activate # Windows 78 | 79 | # 3. 安装依赖 80 | pip install -r requirements.txt # HTTP API 基础依赖 81 | pip install -r requirements-mcp.txt # MCP 协议支持 (可选) 82 | 83 | # 4. 配置文件 84 | cp config.json.example config.json 85 | # 根据需要编辑 config.json 86 | ``` 87 | 88 | ### 3. 启动服务 89 | 90 | ```bash 91 | python capcut_server.py # 启动HTTP API服务器, 默认端口: 9001 92 | 93 | python mcp_server.py # 启动 MCP 协议服务,支持 stdio 通信 94 | ``` 95 | 96 | ## MCP 集成指南 97 | 98 | [MCP 文档](./MCP_文档_中文.md) • [MCP English Guide](./MCP_Documentation_English.md) 99 | 100 | ### 1. 客户端配置 101 | 102 | 创建或更新 `mcp_config.json` 配置文件: 103 | 104 | ```json 105 | { 106 | "mcpServers": { 107 | "capcut-api": { 108 | "command": "python3", 109 | "args": ["mcp_server.py"], 110 | "cwd": "/path/to/CapCutAPI", 111 | "env": { 112 | "PYTHONPATH": "/path/to/CapCutAPI", 113 | "DEBUG": "0" 114 | } 115 | } 116 | } 117 | } 118 | ``` 119 | 120 | ### 2. 连接测试 121 | 122 | ```bash 123 | # 测试 MCP 连接 124 | python test_mcp_client.py 125 | 126 | # 预期输出 127 | ✅ MCP 服务器启动成功 128 | ✅ 获取到 11 个可用工具 129 | ✅ 草稿创建测试通过 130 | ``` 131 | 132 | ## 使用示例 133 | 134 | ### 1. API 示例 135 | 添加视频素材 136 | 137 | ```python 138 | import requests 139 | 140 | # 添加背景视频 141 | response = requests.post("http://localhost:9001/add_video", json={ 142 | "video_url": "https://example.com/background.mp4", 143 | "start": 0, 144 | "end": 10 145 | "volume": 0.8, 146 | "transition": "fade_in" 147 | }) 148 | 149 | print(f"视频添加结果: {response.json()}") 150 | ``` 151 | 152 | 创建样式文本 153 | 154 | ```python 155 | import requests 156 | 157 | # 添加标题文字 158 | response = requests.post("http://localhost:9001/add_text", json={ 159 | "text": "欢迎使用 CapCutAPI", 160 | "start": 0, 161 | "end": 5, 162 | "font": "思源黑体", 163 | "font_color": "#FFD700", 164 | "font_size": 48, 165 | "shadow_enabled": True, 166 | "background_color": "#000000" 167 | }) 168 | 169 | print(f"文本添加结果: {response.json()}") 170 | ``` 171 | 172 | 可以在`example.py`文件中获取更多示例。 173 | 174 | ### 2. MCP 协议示例 175 | 176 | 完整工作流程 177 | 178 | ```python 179 | # 1. 创建新项目 180 | draft = mcp_client.call_tool("create_draft", { 181 | "width": 1080, 182 | "height": 1920 183 | }) 184 | draft_id = draft["result"]["draft_id"] 185 | 186 | # 2. 添加背景视频 187 | mcp_client.call_tool("add_video", { 188 | "video_url": "https://example.com/bg.mp4", 189 | "draft_id": draft_id, 190 | "start": 0, 191 | "end": 10, 192 | "volume": 0.6 193 | }) 194 | 195 | # 3. 添加标题文字 196 | mcp_client.call_tool("add_text", { 197 | "text": "AI 驱动的视频制作", 198 | "draft_id": draft_id, 199 | "start": 1, 200 | "end": 6, 201 | "font_size": 56, 202 | "shadow_enabled": True, 203 | "background_color": "#1E1E1E" 204 | }) 205 | 206 | # 4. 添加关键帧动画 207 | mcp_client.call_tool("add_video_keyframe", { 208 | "draft_id": draft_id, 209 | "track_name": "main", 210 | "property_types": ["scale_x", "scale_y", "alpha"], 211 | "times": [0, 2, 4], 212 | "values": ["1.0", "1.2", "0.8"] 213 | }) 214 | 215 | # 5. 保存项目 216 | result = mcp_client.call_tool("save_draft", { 217 | "draft_id": draft_id 218 | }) 219 | 220 | print(f"项目已保存: {result['result']['draft_url']}") 221 | ``` 222 | 高级文本效果 223 | 224 | ```python 225 | # 多样式彩色文本 226 | mcp_client.call_tool("add_text", { 227 | "text": "彩色文字效果展示", 228 | "draft_id": draft_id, 229 | "start": 2, 230 | "end": 8, 231 | "font_size": 42, 232 | "shadow_enabled": True, 233 | "shadow_color": "#FFFFFF", 234 | "background_alpha": 0.8, 235 | "background_round_radius": 20, 236 | "text_styles": [ 237 | {"start": 0, "end": 2, "font_color": "#FF6B6B"}, 238 | {"start": 2, "end": 4, "font_color": "#4ECDC4"}, 239 | {"start": 4, "end": 6, "font_color": "#45B7D1"} 240 | ] 241 | }) 242 | ``` 243 | 244 | ### 3. 下载草稿 245 | 246 | 调用 `save_draft` 会在`capcut_server.py`当前目录下生成一个 `dfd_` 开头的文件夹,将其复制到剪映/CapCut 草稿目录,即可在应用中看到生成的草稿。 247 | 248 | ## 模版 249 | 我们汇总了一些模版,放在`pattern`文件夹下。 250 | 251 | ## 社区与支持 252 | 253 | 我们欢迎各种形式的贡献!我们的迭代规则: 254 | 255 | - 禁止直接向main提交pr 256 | - 可以向dev分支提交pr 257 | - 每周一从dev合并到main分支,并发版 258 | 259 | 260 | ## 进群交流 261 | ![交流群](https://github.com/user-attachments/assets/171a2b77-f9c2-4134-a52f-4c740c1c4d36) 262 | 263 | 264 | 265 | 266 | - 反馈问题 267 | - 功能建议 268 | - 最新消息 269 | 270 | ### 🤝 合作机会 271 | 272 | - **出海视频制作**: 想要利用这个API批量制作出海视频吗?我提供免费的咨询服务,帮助你利用这个API制作。相应的,我要将制作的工作流模板放到这个项目中的template目录中**开源**出来。 273 | 274 | - **加入我们**: 我们的目标是提供稳定可靠的视频剪辑工具,方便融合AI生成的图片/视频/语音。如果你有兴趣,可以先从将工程里的中文翻译成英文开始!提交pr,我会看到。更深入的,还有MCP剪辑Agent, web剪辑端,云渲染这三个模块代码还没有开源出来。 275 | 276 | - **联系方式**: 277 | - 微信:sguann 278 | - 抖音:剪映草稿助手 279 | 280 | 281 | ## 📈 Star History 282 | 283 |
284 | 285 | [![Star History Chart](https://api.star-history.com/svg?repos=sun-guannan/CapCutAPI&type=Date)](https://www.star-history.com/#sun-guannan/CapCutAPI&Date) 286 | 287 | ![GitHub repo size](https://img.shields.io/github/repo-size/sun-guannan/CapCutAPI?style=flat-square) 288 | ![GitHub code size](https://img.shields.io/github/languages/code-size/sun-guannan/CapCutAPI?style=flat-square) 289 | ![GitHub issues](https://img.shields.io/github/issues/sun-guannan/CapCutAPI?style=flat-square) 290 | ![GitHub pull requests](https://img.shields.io/github/issues-pr/sun-guannan/CapCutAPI?style=flat-square) 291 | ![GitHub last commit](https://img.shields.io/github/last-commit/sun-guannan/CapCutAPI?style=flat-square) 292 | 293 |
294 | 295 | *Made with ❤️ by the CapCutAPI Community* 296 | 297 | 298 | -------------------------------------------------------------------------------- /pyJianYingDraft/track.py: -------------------------------------------------------------------------------- 1 | """轨道类及其元数据""" 2 | 3 | import uuid 4 | 5 | from enum import Enum 6 | from typing import TypeVar, Generic, Type 7 | from typing import Dict, List, Any, Union 8 | from dataclasses import dataclass 9 | from abc import ABC, abstractmethod 10 | import pyJianYingDraft as draft 11 | 12 | from .exceptions import SegmentOverlap 13 | from .segment import Base_segment 14 | from .video_segment import Video_segment, Sticker_segment 15 | from .audio_segment import Audio_segment 16 | from .text_segment import Text_segment 17 | from .effect_segment import Effect_segment, Filter_segment 18 | 19 | @dataclass 20 | class Track_meta: 21 | """与轨道类型关联的轨道元数据""" 22 | 23 | segment_type: Union[Type[Video_segment], Type[Audio_segment], 24 | Type[Effect_segment], Type[Filter_segment], 25 | Type[Text_segment], Type[Sticker_segment], None] 26 | """与轨道关联的片段类型""" 27 | render_index: int 28 | """默认渲染顺序, 值越大越接近前景""" 29 | allow_modify: bool 30 | """当被导入时, 是否允许修改""" 31 | 32 | class Track_type(Enum): 33 | """轨道类型枚举 34 | 35 | 变量名对应type属性, 值表示相应的轨道元数据 36 | """ 37 | 38 | video = Track_meta(Video_segment, 0, True) 39 | audio = Track_meta(Audio_segment, 0, True) 40 | effect = Track_meta(Effect_segment, 10000, False) 41 | filter = Track_meta(Filter_segment, 11000, False) 42 | sticker = Track_meta(Sticker_segment, 14000, False) 43 | text = Track_meta(Text_segment, 15000, True) # 原本是14000, 避免与sticker冲突改为15000 44 | 45 | adjust = Track_meta(None, 0, False) 46 | """仅供导入时使用, 不要尝试新建此类型的轨道""" 47 | 48 | @staticmethod 49 | def from_name(name: str) -> "Track_type": 50 | """根据名称获取轨道类型枚举""" 51 | for t in Track_type: 52 | if t.name == name: 53 | return t 54 | raise ValueError("Invalid track type: %s" % name) 55 | 56 | 57 | class Base_track(ABC): 58 | """轨道基类""" 59 | 60 | track_type: Track_type 61 | """轨道类型""" 62 | name: str 63 | """轨道名称""" 64 | track_id: str 65 | """轨道全局ID""" 66 | render_index: int 67 | """渲染顺序, 值越大越接近前景""" 68 | 69 | @abstractmethod 70 | def export_json(self) -> Dict[str, Any]: ... 71 | 72 | Seg_type = TypeVar("Seg_type", bound=Base_segment) 73 | class Track(Base_track, Generic[Seg_type]): 74 | """非模板模式下的轨道""" 75 | 76 | mute: bool 77 | """是否静音""" 78 | 79 | segments: List[Seg_type] 80 | """该轨道包含的片段列表""" 81 | 82 | pending_keyframes: List[Dict[str, Any]] 83 | """待处理的关键帧列表""" 84 | 85 | def __init__(self, track_type: Track_type, name: str, render_index: int, mute: bool): 86 | self.track_type = track_type 87 | self.name = name 88 | self.track_id = uuid.uuid4().hex 89 | self.render_index = render_index 90 | 91 | self.mute = mute 92 | self.segments = [] 93 | self.pending_keyframes = [] 94 | 95 | def add_pending_keyframe(self, property_type: str, time: float, value: str) -> None: 96 | """添加待处理的关键帧 97 | 98 | Args: 99 | property_type: 关键帧属性类型 100 | time: 关键帧时间点(秒) 101 | value: 关键帧值 102 | """ 103 | self.pending_keyframes.append({ 104 | "property_type": property_type, 105 | "time": time, 106 | "value": value 107 | }) 108 | 109 | def process_pending_keyframes(self) -> None: 110 | """处理所有待处理的关键帧""" 111 | if not self.pending_keyframes: 112 | return 113 | 114 | for kf_info in self.pending_keyframes: 115 | property_type = kf_info["property_type"] 116 | time = kf_info["time"] 117 | value = kf_info["value"] 118 | 119 | try: 120 | # 找到时间点对应的片段(时间单位:微秒) 121 | target_time = int(time * 1000000) # 将秒转换为微秒 122 | target_segment = next( 123 | (segment for segment in self.segments 124 | if segment.target_timerange.start <= target_time <= segment.target_timerange.end), 125 | None 126 | ) 127 | 128 | if target_segment is None: 129 | print(f"警告:在轨道 {self.name} 的时间点 {time}s 找不到对应的片段,跳过此关键帧") 130 | continue 131 | 132 | # 将属性类型字符串转换为枚举值 133 | property_enum = getattr(draft.Keyframe_property, property_type) 134 | 135 | # 解析value值 136 | if property_type == 'alpha' and value.endswith('%'): 137 | float_value = float(value[:-1]) / 100 138 | elif property_type == 'volume' and value.endswith('%'): 139 | float_value = float(value[:-1]) / 100 140 | elif property_type == 'rotation' and value.endswith('deg'): 141 | float_value = float(value[:-3]) 142 | elif property_type in ['saturation', 'contrast', 'brightness']: 143 | if value.startswith('+'): 144 | float_value = float(value[1:]) 145 | elif value.startswith('-'): 146 | float_value = -float(value[1:]) 147 | else: 148 | float_value = float(value) 149 | else: 150 | float_value = float(value) 151 | 152 | # 计算时间偏移量 153 | offset_time = target_time - target_segment.target_timerange.start 154 | 155 | # 添加关键帧 156 | target_segment.add_keyframe(property_enum, offset_time, float_value) 157 | print(f"成功添加关键帧: {property_type} 在 {time}s") 158 | except Exception as e: 159 | print(f"添加关键帧失败: {str(e)}") 160 | 161 | # 清空待处理的关键帧 162 | self.pending_keyframes = [] 163 | 164 | @property 165 | def end_time(self) -> int: 166 | """轨道结束时间, 微秒""" 167 | if len(self.segments) == 0: 168 | return 0 169 | return self.segments[-1].target_timerange.end 170 | 171 | @property 172 | def accept_segment_type(self) -> Type[Seg_type]: 173 | """返回该轨道允许的片段类型""" 174 | return self.track_type.value.segment_type # type: ignore 175 | 176 | def add_segment(self, segment: Seg_type) -> "Track[Seg_type]": 177 | """向轨道中添加一个片段, 添加的片段必须匹配轨道类型且不与现有片段重叠 178 | 179 | Args: 180 | segment (Seg_type): 要添加的片段 181 | 182 | Raises: 183 | `TypeError`: 新片段类型与轨道类型不匹配 184 | `SegmentOverlap`: 新片段与现有片段重叠 185 | """ 186 | if not isinstance(segment, self.accept_segment_type): 187 | raise TypeError("New segment (%s) is not of the same type as the track (%s)" % (type(segment), self.accept_segment_type)) 188 | 189 | # 检查片段是否重叠 190 | for seg in self.segments: 191 | if seg.overlaps(segment): 192 | raise SegmentOverlap("New segment overlaps with existing segment [start: {}, end: {}]" 193 | .format(segment.target_timerange.start, segment.target_timerange.end)) 194 | 195 | self.segments.append(segment) 196 | return self 197 | 198 | def export_json(self) -> Dict[str, Any]: 199 | # 为每个片段写入render_index 200 | segment_exports = [seg.export_json() for seg in self.segments] 201 | for seg in segment_exports: 202 | seg["render_index"] = self.render_index 203 | 204 | return { 205 | "attribute": int(self.mute), 206 | "flag": 0, 207 | "id": self.track_id, 208 | "is_default_name": len(self.name) == 0, 209 | "name": self.name, 210 | "segments": segment_exports, 211 | "type": self.track_type.name 212 | } 213 | -------------------------------------------------------------------------------- /add_audio_track.py: -------------------------------------------------------------------------------- 1 | # 导入必要的模块 2 | import os 3 | import pyJianYingDraft as draft 4 | import time 5 | from util import generate_draft_url, is_windows_path, url_to_hash 6 | import re 7 | from typing import Optional, Dict, Tuple, List 8 | 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 9 | from create_draft import get_or_create_draft 10 | from settings.local import IS_CAPCUT_ENV 11 | 12 | def add_audio_track( 13 | audio_url: str, 14 | draft_folder: Optional[str] = None, 15 | start: float = 0, 16 | end: Optional[float] = None, 17 | target_start: float = 0, 18 | draft_id: Optional[str] = None, 19 | volume: float = 1.0, 20 | track_name: str = "audio_main", 21 | speed: float = 1.0, 22 | sound_effects: Optional[List[Tuple[str, Optional[List[Optional[float]]]]]]=None, 23 | width: int = 1080, 24 | height: int = 1920, 25 | duration: Optional[float] = None # Added duration parameter 26 | ) -> Dict[str, str]: 27 | """ 28 | Add an audio track to the specified draft 29 | :param draft_folder: Draft folder path, optional parameter 30 | :param audio_url: Audio URL 31 | :param start: Start time (seconds), default 0 32 | :param end: End time (seconds), default None (use total audio duration) 33 | :param target_start: Target track insertion position (seconds), default 0 34 | :param draft_id: Draft ID, if None or corresponding zip file not found, a new draft will be created 35 | :param volume: Volume level, range 0.0-1.0, default 1.0 36 | :param track_name: Track name, default "audio_main" 37 | :param speed: Playback speed, default 1.0 38 | :param sound_effects: Scene sound effect list, each element is a tuple containing effect type name and parameter list, default None 39 | :return: Updated draft information 40 | """ 41 | # Get or create draft 42 | draft_id, script = get_or_create_draft( 43 | draft_id=draft_id, 44 | width=width, 45 | height=height 46 | ) 47 | 48 | # Add audio track (only when track doesn't exist) 49 | if track_name is not None: 50 | try: 51 | imported_track = script.get_imported_track(draft.Track_type.audio, name=track_name) 52 | # If no exception is thrown, the track already exists 53 | except exceptions.TrackNotFound: 54 | # Track doesn't exist, create a new track 55 | script.add_track(draft.Track_type.audio, track_name=track_name) 56 | else: 57 | script.add_track(draft.Track_type.audio) 58 | 59 | # If duration parameter is provided, prioritize it; otherwise use default audio duration of 0 seconds, real duration will be obtained during download 60 | if duration is not None: 61 | # Use the provided duration, skip duration retrieval and checking 62 | audio_duration = duration 63 | else: 64 | # Use default audio duration of 0 seconds, real duration will be obtained when downloading the draft 65 | audio_duration = 0.0 # Default audio duration is 0 seconds 66 | # duration_result = get_video_duration(audio_url) # Reuse video duration retrieval function 67 | # if not duration_result["success"]: 68 | # print(f"Failed to get audio duration: {duration_result['error']}") 69 | 70 | # # Check if audio duration exceeds 10 minutes 71 | # if duration_result["output"] > 600: # 600 seconds = 10 minutes 72 | # raise Exception(f"Audio duration exceeds 10-minute limit, current duration: {duration_result['output']} seconds") 73 | 74 | # audio_duration = duration_result["output"] 75 | 76 | # Download audio to local 77 | # local_audio_path = download_audio(audio_url, draft_dir) 78 | 79 | material_name = f"audio_{url_to_hash(audio_url)}.mp3" # Use original filename + timestamp + fixed mp3 extension 80 | 81 | # Build draft_audio_path 82 | draft_audio_path = None 83 | if draft_folder: 84 | if is_windows_path(draft_folder): 85 | # Windows path processing 86 | windows_drive, windows_path = re.match(r'([a-zA-Z]:)(.*)', draft_folder).groups() 87 | parts = [p for p in windows_path.split('\\') if p] 88 | draft_audio_path = os.path.join(windows_drive, *parts, draft_id, "assets", "audio", material_name) 89 | # Normalize path (ensure consistent separators) 90 | draft_audio_path = draft_audio_path.replace('/', '\\') 91 | else: 92 | # macOS/Linux path processing 93 | draft_audio_path = os.path.join(draft_folder, draft_id, "assets", "audio", material_name) 94 | 95 | # Set default value for audio end time 96 | audio_end = end if end is not None else audio_duration 97 | 98 | # Calculate audio duration 99 | duration = audio_end - start 100 | 101 | # Create audio segment 102 | if draft_audio_path: 103 | print('replace_path:', draft_audio_path) 104 | audio_material = draft.Audio_material(replace_path=draft_audio_path, remote_url=audio_url, material_name=material_name, duration=audio_duration) 105 | else: 106 | audio_material = draft.Audio_material(remote_url=audio_url, material_name=material_name, duration=audio_duration) 107 | audio_segment = draft.Audio_segment( 108 | audio_material, # Pass material object 109 | target_timerange=trange(f"{target_start}s", f"{duration}s"), # Use target_start and duration 110 | source_timerange=trange(f"{start}s", f"{duration}s"), 111 | speed=speed, # Set playback speed 112 | volume=volume # Set volume 113 | ) 114 | 115 | # Add scene sound effects 116 | if sound_effects: 117 | for effect_name, params in sound_effects: 118 | # Choose different effect types based on IS_CAPCUT_ENV 119 | effect_type = None 120 | 121 | if IS_CAPCUT_ENV: 122 | # In CapCut environment, look for effects in CapCut_Voice_filters_effect_type 123 | try: 124 | effect_type = getattr(CapCut_Voice_filters_effect_type, effect_name) 125 | except AttributeError: 126 | try: 127 | # Look for effects in CapCut_Voice_characters_effect_type 128 | effect_type = getattr(CapCut_Voice_characters_effect_type, effect_name) 129 | except AttributeError: 130 | # If still not found, look for effects in CapCut_Speech_to_song_effect_type 131 | try: 132 | effect_type = getattr(CapCut_Speech_to_song_effect_type, effect_name) 133 | except AttributeError: 134 | effect_type = None 135 | else: 136 | # In JianYing environment, look for effects in Audio_scene_effect_type 137 | try: 138 | effect_type = getattr(Audio_scene_effect_type, effect_name) 139 | except AttributeError: 140 | # If not found in Audio_scene_effect_type, continue searching in other effect types 141 | try: 142 | effect_type = getattr(Tone_effect_type, effect_name) 143 | except AttributeError: 144 | # If still not found, look for effects in Speech_to_song_type 145 | try: 146 | effect_type = getattr(Speech_to_song_type, effect_name) 147 | except AttributeError: 148 | effect_type = None 149 | 150 | # If corresponding effect type is found, add it to the audio segment 151 | if effect_type: 152 | audio_segment.add_effect(effect_type, params) 153 | else: 154 | print(f"Warning: Audio effect named {effect_name} not found") 155 | 156 | # Add audio segment to track 157 | script.add_segment(audio_segment, track_name=track_name) 158 | 159 | return { 160 | "draft_id": draft_id, 161 | "draft_url": generate_draft_url(draft_id) 162 | } 163 | -------------------------------------------------------------------------------- /add_video_keyframe_impl.py: -------------------------------------------------------------------------------- 1 | import pyJianYingDraft as draft 2 | from pyJianYingDraft import exceptions 3 | from create_draft import get_or_create_draft 4 | from typing import Optional, Dict, List 5 | 6 | from util import generate_draft_url 7 | 8 | def add_video_keyframe_impl( 9 | draft_id: Optional[str] = None, 10 | track_name: str = "main", 11 | property_type: str = "alpha", 12 | time: float = 0.0, 13 | value: str = "1.0", 14 | property_types: Optional[List[str]] = None, 15 | times: Optional[List[float]] = None, 16 | values: Optional[List[str]] = None 17 | ) -> Dict[str, str]: 18 | """ 19 | Add keyframes to the specified segment 20 | :param draft_id: Draft ID, if None or corresponding zip file not found, a new draft will be created 21 | :param track_name: Track name, default "main" 22 | :param property_type: Keyframe property type, supports the following values: 23 | - position_x: Horizontal position, range [-1,1], 0 means center, 1 means rightmost 24 | - position_y: Vertical position, range [-1,1], 0 means center, 1 means bottom 25 | - rotation: Clockwise rotation angle 26 | - scale_x: X-axis scale ratio (1.0 means no scaling), mutually exclusive with uniform_scale 27 | - scale_y: Y-axis scale ratio (1.0 means no scaling), mutually exclusive with uniform_scale 28 | - uniform_scale: Overall scale ratio (1.0 means no scaling), mutually exclusive with scale_x and scale_y 29 | - alpha: Opacity, 1.0 means completely opaque 30 | - saturation: Saturation, 0.0 means original saturation, range from -1.0 to 1.0 31 | - contrast: Contrast, 0.0 means original contrast, range from -1.0 to 1.0 32 | - brightness: Brightness, 0.0 means original brightness, range from -1.0 to 1.0 33 | - volume: Volume, 1.0 means original volume 34 | :param time: Keyframe time point (seconds), default 0.0 35 | :param value: Keyframe value, format varies according to property_type: 36 | - position_x/position_y: "0" means center position, range [-1,1] 37 | - rotation: "45deg" means 45 degrees 38 | - scale_x/scale_y/uniform_scale: "1.5" means scale up by 1.5 times 39 | - alpha: "50%" means 50% opacity 40 | - saturation/contrast/brightness: "+0.5" means increase by 0.5, "-0.5" means decrease by 0.5 41 | - volume: "80%" means 80% of original volume 42 | :param property_types: Batch mode: List of keyframe property types, e.g. ["alpha", "position_x", "rotation"] 43 | :param times: Batch mode: List of keyframe time points (seconds), e.g. [0.0, 1.0, 2.0] 44 | :param values: Batch mode: List of keyframe values, e.g. ["1.0", "0.5", "45deg"] 45 | Note: property_types, times, values must be provided together and have equal lengths. If these parameters are provided, single keyframe parameters will be ignored 46 | :return: Updated draft information 47 | """ 48 | # Get or create draft 49 | draft_id, script = get_or_create_draft( 50 | draft_id=draft_id 51 | ) 52 | 53 | try: 54 | # Get specified track 55 | track = script.get_track(draft.Video_segment, track_name=track_name) 56 | 57 | # Get segments in the track 58 | segments = track.segments 59 | if not segments: 60 | raise Exception(f"No segments in track {track_name}") 61 | 62 | # Determine the keyframes list to process 63 | if property_types is not None or times is not None or values is not None: 64 | # Batch mode: use three array parameters 65 | if property_types is None or times is None or values is None: 66 | raise Exception("In batch mode, property_types, times, values must be provided together") 67 | 68 | if not (isinstance(property_types, list) and isinstance(times, list) and isinstance(values, list)): 69 | raise Exception("property_types, times, values must all be list types") 70 | 71 | if len(property_types) == 0: 72 | raise Exception("In batch mode, parameter lists cannot be empty") 73 | 74 | if not (len(property_types) == len(times) == len(values)): 75 | raise Exception(f"property_types, times, values must have equal lengths, current lengths are: {len(property_types)}, {len(times)}, {len(values)}") 76 | 77 | keyframes_to_process = [ 78 | { 79 | "property_type": prop_type, 80 | "time": t, 81 | "value": val 82 | } 83 | for prop_type, t, val in zip(property_types, times, values) 84 | ] 85 | else: 86 | # Single mode: use original parameters 87 | keyframes_to_process = [{ 88 | "property_type": property_type, 89 | "time": time, 90 | "value": value 91 | }] 92 | 93 | # Process each keyframe 94 | added_count = 0 95 | for i, kf in enumerate(keyframes_to_process): 96 | try: 97 | _add_single_keyframe(track, kf["property_type"], kf["time"], kf["value"]) 98 | added_count += 1 99 | except Exception as e: 100 | raise Exception(f"Failed to add keyframe #{i+1} (property_type={kf['property_type']}, time={kf['time']}, value={kf['value']}): {str(e)}") 101 | 102 | result = { 103 | "draft_id": draft_id, 104 | "draft_url": generate_draft_url(draft_id) 105 | } 106 | 107 | # If in batch mode, return the number of added keyframes 108 | if property_types is not None: 109 | result["added_keyframes_count"] = added_count 110 | 111 | return result 112 | 113 | except exceptions.TrackNotFound: 114 | raise Exception(f"Track named {track_name} not found") 115 | except Exception as e: 116 | raise Exception(f"Failed to add keyframe: {str(e)}") 117 | 118 | 119 | def _add_single_keyframe(track, property_type: str, time: float, value: str): 120 | """ 121 | Internal function to add a single keyframe 122 | """ 123 | # Convert property type string to enum value, validate if property type is valid 124 | try: 125 | property_enum = getattr(draft.Keyframe_property, property_type) 126 | except: 127 | raise Exception(f"Unsupported keyframe property type: {property_type}") 128 | 129 | # Parse value based on property type 130 | try: 131 | if property_type in ['position_x', 'position_y']: 132 | # Handle position, range [0,1] 133 | float_value = float(value) 134 | if not -10 <= float_value <= 10: 135 | raise ValueError(f"Value for {property_type} must be between -10 and 10") 136 | elif property_type == 'rotation': 137 | # Handle rotation angle 138 | if value.endswith('deg'): 139 | float_value = float(value[:-3]) 140 | else: 141 | float_value = float(value) 142 | elif property_type == 'alpha': 143 | # Handle opacity 144 | if value.endswith('%'): 145 | float_value = float(value[:-1]) / 100 146 | else: 147 | float_value = float(value) 148 | elif property_type == 'volume': 149 | # Handle volume 150 | if value.endswith('%'): 151 | float_value = float(value[:-1]) / 100 152 | else: 153 | float_value = float(value) 154 | elif property_type in ['saturation', 'contrast', 'brightness']: 155 | # Handle saturation, contrast, brightness 156 | if value.startswith('+'): 157 | float_value = float(value[1:]) 158 | elif value.startswith('-'): 159 | float_value = -float(value[1:]) 160 | else: 161 | float_value = float(value) 162 | else: 163 | # Other properties directly convert to float 164 | float_value = float(value) 165 | except ValueError: 166 | raise Exception(f"Invalid value format: {value}") 167 | 168 | # If track object is provided, use the track's add_pending_keyframe method 169 | track.add_pending_keyframe(property_type, time, value) -------------------------------------------------------------------------------- /downloader.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import time 4 | import requests 5 | import shutil 6 | from requests.exceptions import RequestException, Timeout 7 | from urllib.parse import urlparse, unquote 8 | 9 | def download_video(video_url, draft_name, material_name): 10 | """ 11 | Download video to specified directory 12 | :param video_url: Video URL 13 | :param draft_name: Draft name 14 | :param material_name: Material name 15 | :return: Local video path 16 | """ 17 | # Ensure directory exists 18 | video_dir = f"{draft_name}/assets/video" 19 | os.makedirs(video_dir, exist_ok=True) 20 | 21 | # Generate local filename 22 | local_path = f"{video_dir}/{material_name}" 23 | 24 | # Check if file already exists 25 | if os.path.exists(local_path): 26 | print(f"Video file already exists: {local_path}") 27 | return local_path 28 | 29 | try: 30 | # Use ffmpeg to download video 31 | command = [ 32 | 'ffmpeg', 33 | '-i', video_url, 34 | '-c', 'copy', # Direct copy, no re-encoding 35 | local_path 36 | ] 37 | subprocess.run(command, check=True, capture_output=True) 38 | return local_path 39 | except subprocess.CalledProcessError as e: 40 | raise Exception(f"Failed to download video: {e.stderr.decode('utf-8')}") 41 | 42 | def download_image(image_url, draft_name, material_name): 43 | """ 44 | Download image to specified directory, and convert to PNG format 45 | :param image_url: Image URL 46 | :param draft_name: Draft name 47 | :param material_name: Material name 48 | :return: Local image path 49 | """ 50 | # Ensure directory exists 51 | image_dir = f"{draft_name}/assets/image" 52 | os.makedirs(image_dir, exist_ok=True) 53 | 54 | # Uniformly use png format 55 | local_path = f"{image_dir}/{material_name}" 56 | 57 | # Check if file already exists 58 | if os.path.exists(local_path): 59 | print(f"Image file already exists: {local_path}") 60 | return local_path 61 | 62 | try: 63 | # Use ffmpeg to download and convert image to PNG format 64 | command = [ 65 | 'ffmpeg', 66 | '-headers', 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36\r\nReferer: https://www.163.com/\r\n', 67 | '-i', image_url, 68 | '-vf', 'format=rgba', # Convert to RGBA format to support transparency 69 | '-frames:v', '1', # Ensure only one frame is processed 70 | '-y', # Overwrite existing files 71 | local_path 72 | ] 73 | subprocess.run(command, check=True, capture_output=True) 74 | return local_path 75 | except subprocess.CalledProcessError as e: 76 | raise Exception(f"Failed to download image: {e.stderr.decode('utf-8')}") 77 | 78 | def download_audio(audio_url, draft_name, material_name): 79 | """ 80 | Download audio and transcode to MP3 format to specified directory 81 | :param audio_url: Audio URL 82 | :param draft_name: Draft name 83 | :param material_name: Material name 84 | :return: Local audio path 85 | """ 86 | # Ensure directory exists 87 | audio_dir = f"{draft_name}/assets/audio" 88 | os.makedirs(audio_dir, exist_ok=True) 89 | 90 | # Generate local filename (keep .mp3 extension) 91 | local_path = f"{audio_dir}/{material_name}" 92 | 93 | # Check if file already exists 94 | if os.path.exists(local_path): 95 | print(f"Audio file already exists: {local_path}") 96 | return local_path 97 | 98 | try: 99 | # Use ffmpeg to download and transcode to MP3 (key modification: specify MP3 encoder) 100 | command = [ 101 | 'ffmpeg', 102 | '-i', audio_url, # Input URL 103 | '-c:a', 'libmp3lame', # Force encode audio stream to MP3 104 | '-q:a', '2', # Set audio quality (0-9, 0 is best, 2 balances quality and file size) 105 | '-y', # Overwrite existing files (optional) 106 | local_path # Output path 107 | ] 108 | subprocess.run(command, check=True, capture_output=True, text=True) 109 | return local_path 110 | except subprocess.CalledProcessError as e: 111 | raise Exception(f"Failed to download audio:\n{e.stderr}") 112 | 113 | def download_file(url:str, local_filename, max_retries=3, timeout=180): 114 | # 检查是否是本地文件路径 115 | if os.path.exists(url) and os.path.isfile(url): 116 | # 是本地文件,直接复制 117 | directory = os.path.dirname(local_filename) 118 | 119 | # 创建目标目录(如果不存在) 120 | if directory and not os.path.exists(directory): 121 | os.makedirs(directory, exist_ok=True) 122 | print(f"Created directory: {directory}") 123 | 124 | print(f"Copying local file: {url} to {local_filename}") 125 | start_time = time.time() 126 | 127 | # 复制文件 128 | shutil.copy2(url, local_filename) 129 | 130 | print(f"Copy completed in {time.time()-start_time:.2f} seconds") 131 | print(f"File saved as: {os.path.abspath(local_filename)}") 132 | return True 133 | 134 | # 原有的下载逻辑 135 | # Extract directory part 136 | directory = os.path.dirname(local_filename) 137 | 138 | retries = 0 139 | while retries < max_retries: 140 | try: 141 | if retries > 0: 142 | wait_time = 2 ** retries # Exponential backoff strategy 143 | print(f"Retrying in {wait_time} seconds... (Attempt {retries+1}/{max_retries})") 144 | time.sleep(wait_time) 145 | 146 | print(f"Downloading file: {local_filename}") 147 | start_time = time.time() 148 | 149 | # Create directory (if it doesn't exist) 150 | if directory and not os.path.exists(directory): 151 | os.makedirs(directory, exist_ok=True) 152 | print(f"Created directory: {directory}") 153 | 154 | # Add headers 155 | headers = { 156 | 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36', 157 | 'Referer': 'https://www.163.com/', # 网易的Referer 158 | 'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8', 159 | 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8' 160 | } 161 | 162 | with requests.get(url, stream=True, timeout=timeout, headers=headers) as response: 163 | response.raise_for_status() 164 | 165 | total_size = int(response.headers.get('content-length', 0)) 166 | block_size = 1024 167 | 168 | with open(local_filename, 'wb') as file: 169 | bytes_written = 0 170 | for chunk in response.iter_content(block_size): 171 | if chunk: 172 | file.write(chunk) 173 | bytes_written += len(chunk) 174 | 175 | if total_size > 0: 176 | progress = bytes_written / total_size * 100 177 | # For frequently updated progress, consider using logger.debug or more granular control to avoid large log files 178 | # Or only output progress to console, not write to file 179 | print(f"\r[PROGRESS] {progress:.2f}% ({bytes_written/1024:.2f}KB/{total_size/1024:.2f}KB)", end='') 180 | pass # Avoid printing too much progress information in log files 181 | 182 | if total_size > 0: 183 | # print() # Original newline 184 | pass 185 | print(f"Download completed in {time.time()-start_time:.2f} seconds") 186 | print(f"File saved as: {os.path.abspath(local_filename)}") 187 | return True 188 | 189 | except Timeout: 190 | print(f"Download timed out after {timeout} seconds") 191 | except RequestException as e: 192 | print(f"Request failed: {e}") 193 | except Exception as e: 194 | print(f"Unexpected error during download: {e}") 195 | 196 | retries += 1 197 | 198 | print(f"Download failed after {max_retries} attempts for URL: {url}") 199 | return False 200 | 201 | -------------------------------------------------------------------------------- /pyJianYingDraft/segment.py: -------------------------------------------------------------------------------- 1 | """定义片段基类及部分比较通用的属性类""" 2 | 3 | import uuid 4 | from typing import Optional, Dict, List, Any, Union 5 | 6 | from .animation import Segment_animations 7 | from .time_util import Timerange, tim 8 | from .keyframe import Keyframe_list, Keyframe_property 9 | 10 | class Base_segment: 11 | """片段基类""" 12 | 13 | segment_id: str 14 | """片段全局id, 由程序自动生成""" 15 | material_id: str 16 | """使用的素材id""" 17 | target_timerange: Timerange 18 | """片段在轨道上的时间范围""" 19 | 20 | common_keyframes: List[Keyframe_list] 21 | """各属性的关键帧列表""" 22 | 23 | def __init__(self, material_id: str, target_timerange: Timerange): 24 | self.segment_id = uuid.uuid4().hex 25 | self.material_id = material_id 26 | self.target_timerange = target_timerange 27 | 28 | self.common_keyframes = [] 29 | 30 | @property 31 | def start(self) -> int: 32 | """片段开始时间, 单位为微秒""" 33 | return self.target_timerange.start 34 | @start.setter 35 | def start(self, value: int): 36 | self.target_timerange.start = value 37 | 38 | @property 39 | def duration(self) -> int: 40 | """片段持续时间, 单位为微秒""" 41 | return self.target_timerange.duration 42 | @duration.setter 43 | def duration(self, value: int): 44 | self.target_timerange.duration = value 45 | 46 | @property 47 | def end(self) -> int: 48 | """片段结束时间, 单位为微秒""" 49 | return self.target_timerange.end 50 | 51 | def overlaps(self, other: "Base_segment") -> bool: 52 | """判断是否与另一个片段有重叠""" 53 | return self.target_timerange.overlaps(other.target_timerange) 54 | 55 | def export_json(self) -> Dict[str, Any]: 56 | """返回通用于各种片段的属性""" 57 | return { 58 | "enable_adjust": True, 59 | "enable_color_correct_adjust": False, 60 | "enable_color_curves": True, 61 | "enable_color_match_adjust": False, 62 | "enable_color_wheels": True, 63 | "enable_lut": True, 64 | "enable_smart_color_adjust": False, 65 | "last_nonzero_volume": 1.0, 66 | "reverse": False, 67 | "track_attribute": 0, 68 | "track_render_index": 0, 69 | "visible": True, 70 | # 写入自定义字段 71 | "id": self.segment_id, 72 | "material_id": self.material_id, 73 | "target_timerange": self.target_timerange.export_json(), 74 | 75 | "common_keyframes": [kf_list.export_json() for kf_list in self.common_keyframes], 76 | "keyframe_refs": [], # 意义不明 77 | } 78 | 79 | class Speed: 80 | """播放速度对象, 目前只支持固定速度""" 81 | 82 | global_id: str 83 | """全局id, 由程序自动生成""" 84 | speed: float 85 | """播放速度""" 86 | 87 | def __init__(self, speed: float): 88 | self.global_id = uuid.uuid4().hex 89 | self.speed = speed 90 | 91 | def export_json(self) -> Dict[str, Any]: 92 | return { 93 | "curve_speed": None, 94 | "id": self.global_id, 95 | "mode": 0, 96 | "speed": self.speed, 97 | "type": "speed" 98 | } 99 | 100 | class Clip_settings: 101 | """素材片段的图像调节设置""" 102 | 103 | alpha: float 104 | """图像不透明度, 0-1""" 105 | flip_horizontal: bool 106 | """是否水平翻转""" 107 | flip_vertical: bool 108 | """是否垂直翻转""" 109 | rotation: float 110 | """顺时针旋转的**角度**, 可正可负""" 111 | scale_x: float 112 | """水平缩放比例""" 113 | scale_y: float 114 | """垂直缩放比例""" 115 | transform_x: float 116 | """水平位移, 单位为半个画布宽""" 117 | transform_y: float 118 | """垂直位移, 单位为半个画布高""" 119 | 120 | def __init__(self, *, alpha: float = 1.0, 121 | flip_horizontal: bool = False, flip_vertical: bool = False, 122 | rotation: float = 0.0, 123 | scale_x: float = 1.0, scale_y: float = 1.0, 124 | transform_x: float = 0.0, transform_y: float = 0.0): 125 | """初始化图像调节设置, 默认不作任何图像变换 126 | 127 | Args: 128 | alpha (float, optional): 图像不透明度, 0-1. 默认为1.0. 129 | flip_horizontal (bool, optional): 是否水平翻转. 默认为False. 130 | flip_vertical (bool, optional): 是否垂直翻转. 默认为False. 131 | rotation (float, optional): 顺时针旋转的**角度**, 可正可负. 默认为0.0. 132 | scale_x (float, optional): 水平缩放比例. 默认为1.0. 133 | scale_y (float, optional): 垂直缩放比例. 默认为1.0. 134 | transform_x (float, optional): 水平位移, 单位为半个画布宽. 默认为0.0. 135 | transform_y (float, optional): 垂直位移, 单位为半个画布高. 默认为0.0. 136 | 参考: 剪映导入的字幕似乎取此值为-0.8 137 | """ 138 | self.alpha = alpha 139 | self.flip_horizontal, self.flip_vertical = flip_horizontal, flip_vertical 140 | self.rotation = rotation 141 | self.scale_x, self.scale_y = scale_x, scale_y 142 | self.transform_x, self.transform_y = transform_x, transform_y 143 | 144 | def export_json(self) -> Dict[str, Any]: 145 | clip_settings_json = { 146 | "alpha": self.alpha, 147 | "flip": {"horizontal": self.flip_horizontal, "vertical": self.flip_vertical}, 148 | "rotation": self.rotation, 149 | "scale": {"x": self.scale_x, "y": self.scale_y}, 150 | "transform": {"x": self.transform_x, "y": self.transform_y} 151 | } 152 | return clip_settings_json 153 | 154 | class Media_segment(Base_segment): 155 | """媒体片段基类""" 156 | 157 | source_timerange: Optional[Timerange] 158 | """截取的素材片段的时间范围, 对贴纸而言不存在""" 159 | speed: Speed 160 | """播放速度设置""" 161 | volume: float 162 | """音量""" 163 | 164 | extra_material_refs: List[str] 165 | """附加的素材id列表, 用于链接动画/特效等""" 166 | 167 | def __init__(self, material_id: str, source_timerange: Optional[Timerange], target_timerange: Timerange, speed: float, volume: float): 168 | super().__init__(material_id, target_timerange) 169 | 170 | self.source_timerange = source_timerange 171 | self.speed = Speed(speed) 172 | self.volume = volume 173 | 174 | self.extra_material_refs = [self.speed.global_id] 175 | 176 | def export_json(self) -> Dict[str, Any]: 177 | """返回通用于音频和视频片段的默认属性""" 178 | ret = super().export_json() 179 | ret.update({ 180 | "source_timerange": self.source_timerange.export_json() if self.source_timerange else None, 181 | "speed": self.speed.speed, 182 | "volume": self.volume, 183 | "extra_material_refs": self.extra_material_refs, 184 | }) 185 | return ret 186 | 187 | class Visual_segment(Media_segment): 188 | """视觉片段基类,用于处理所有可见片段(视频、贴纸、文本)的共同属性和行为""" 189 | 190 | clip_settings: Clip_settings 191 | """图像调节设置, 其效果可被关键帧覆盖""" 192 | 193 | uniform_scale: bool 194 | """是否锁定XY轴缩放比例""" 195 | 196 | animations_instance: Optional[Segment_animations] 197 | """动画实例, 可能为空 198 | 199 | 在放入轨道时自动添加到素材列表中 200 | """ 201 | 202 | def __init__(self, material_id: str, source_timerange: Optional[Timerange], target_timerange: Timerange, 203 | speed: float, volume: float, *, clip_settings: Optional[Clip_settings]): 204 | """初始化视觉片段基类 205 | 206 | Args: 207 | material_id (`str`): 素材id 208 | source_timerange (`Timerange`, optional): 截取的素材片段的时间范围 209 | target_timerange (`Timerange`): 片段在轨道上的目标时间范围 210 | speed (`float`): 播放速度 211 | volume (`float`): 音量 212 | clip_settings (`Clip_settings`, optional): 图像调节设置, 默认不作任何变换 213 | """ 214 | super().__init__(material_id, source_timerange, target_timerange, speed, volume) 215 | 216 | self.clip_settings = clip_settings if clip_settings is not None else Clip_settings() 217 | self.uniform_scale = True 218 | self.animations_instance = None 219 | 220 | def add_keyframe(self, _property: Keyframe_property, time_offset: Union[int, str], value: float) -> "Visual_segment": 221 | """为给定属性创建一个关键帧, 并自动加入到关键帧列表中 222 | 223 | Args: 224 | _property (`Keyframe_property`): 要控制的属性 225 | time_offset (`int` or `str`): 关键帧的时间偏移量, 单位为微秒. 若传入字符串则会调用`tim()`函数进行解析. 226 | value (`float`): 属性在`time_offset`处的值 227 | 228 | Raises: 229 | `ValueError`: 试图同时设置`uniform_scale`以及`scale_x`或`scale_y`其中一者 230 | """ 231 | if (_property == Keyframe_property.scale_x or _property == Keyframe_property.scale_y) and self.uniform_scale: 232 | self.uniform_scale = False 233 | elif _property == Keyframe_property.uniform_scale: 234 | if not self.uniform_scale: 235 | raise ValueError("已设置 scale_x 或 scale_y 时, 不能再设置 uniform_scale") 236 | _property = Keyframe_property.scale_x 237 | 238 | if isinstance(time_offset, str): time_offset = tim(time_offset) 239 | 240 | for kf_list in self.common_keyframes: 241 | if kf_list.keyframe_property == _property: 242 | kf_list.add_keyframe(time_offset, value) 243 | return self 244 | kf_list = Keyframe_list(_property) 245 | kf_list.add_keyframe(time_offset, value) 246 | self.common_keyframes.append(kf_list) 247 | return self 248 | 249 | def export_json(self) -> Dict[str, Any]: 250 | """导出通用于所有视觉片段的JSON数据""" 251 | json_dict = super().export_json() 252 | json_dict.update({ 253 | "clip": self.clip_settings.export_json(), 254 | "uniform_scale": {"on": self.uniform_scale, "value": 1.0}, 255 | }) 256 | return json_dict 257 | -------------------------------------------------------------------------------- /pyJianYingDraft/audio_segment.py: -------------------------------------------------------------------------------- 1 | """定义音频片段及其相关类 2 | 3 | 包含淡入淡出效果、音频特效等相关类 4 | """ 5 | 6 | import uuid 7 | from copy import deepcopy 8 | 9 | from typing import Optional, Literal, Union 10 | from typing import Dict, List, Any 11 | 12 | from pyJianYingDraft.metadata.capcut_audio_effect_meta import CapCut_Speech_to_song_effect_type, CapCut_Voice_characters_effect_type, CapCut_Voice_filters_effect_type 13 | 14 | from .time_util import tim, Timerange 15 | from .segment import Media_segment 16 | from .local_materials import Audio_material 17 | from .keyframe import Keyframe_property, Keyframe_list 18 | 19 | from .metadata import Effect_param_instance 20 | from .metadata import Audio_scene_effect_type, Tone_effect_type, Speech_to_song_type 21 | 22 | class Audio_fade: 23 | """音频淡入淡出效果""" 24 | 25 | fade_id: str 26 | """淡入淡出效果的全局id, 自动生成""" 27 | 28 | in_duration: int 29 | """淡入时长, 单位为微秒""" 30 | out_duration: int 31 | """淡出时长, 单位为微秒""" 32 | 33 | def __init__(self, in_duration: int, out_duration: int): 34 | """根据给定的淡入/淡出时长构造一个淡入淡出效果""" 35 | 36 | self.fade_id = uuid.uuid4().hex 37 | self.in_duration = in_duration 38 | self.out_duration = out_duration 39 | 40 | def export_json(self) -> Dict[str, Any]: 41 | return { 42 | "id": self.fade_id, 43 | "fade_in_duration": self.in_duration, 44 | "fade_out_duration": self.out_duration, 45 | "fade_type": 0, 46 | "type": "audio_fade" 47 | } 48 | 49 | class Audio_effect: 50 | """音频特效对象""" 51 | 52 | name: str 53 | """特效名称""" 54 | effect_id: str 55 | """特效全局id, 由程序自动生成""" 56 | resource_id: str 57 | """资源id, 由剪映本身提供""" 58 | 59 | category_id: Literal["sound_effect", "tone", "speech_to_song"] 60 | category_name: Literal["场景音", "音色", "声音成曲"] 61 | 62 | audio_adjust_params: List[Effect_param_instance] 63 | 64 | def __init__(self, effect_meta: Union[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], 65 | params: Optional[List[Optional[float]]] = None): 66 | """根据给定的音效元数据及参数列表构造一个音频特效对象, params的范围是0~100""" 67 | 68 | self.name = effect_meta.value.name 69 | self.effect_id = uuid.uuid4().hex 70 | self.resource_id = effect_meta.value.resource_id 71 | self.audio_adjust_params = [] 72 | 73 | if isinstance(effect_meta, Audio_scene_effect_type): 74 | self.category_id = "sound_effect" 75 | self.category_name = "场景音" 76 | elif isinstance(effect_meta, Tone_effect_type): 77 | self.category_id = "tone" 78 | self.category_name = "音色" 79 | elif isinstance(effect_meta, Speech_to_song_type): 80 | self.category_id = "speech_to_song" 81 | self.category_name = "声音成曲" 82 | elif isinstance(effect_meta, CapCut_Voice_filters_effect_type): 83 | self.category_id = "sound_effect" 84 | self.category_name = "Voice filters" 85 | elif isinstance(effect_meta, CapCut_Voice_characters_effect_type): 86 | self.category_id = "tone" 87 | self.category_name = "Voice characters" 88 | elif isinstance(effect_meta, CapCut_Speech_to_song_effect_type): 89 | self.category_id = "speech_to_song" 90 | self.category_name = "Speech to song" 91 | else: 92 | raise TypeError("不支持的元数据类型 %s" % type(effect_meta)) 93 | 94 | self.audio_adjust_params = effect_meta.value.parse_params(params) 95 | 96 | def export_json(self) -> Dict[str, Any]: 97 | return { 98 | "audio_adjust_params": [param.export_json() for param in self.audio_adjust_params], 99 | "category_id": self.category_id, 100 | "category_name": self.category_name, 101 | "id": self.effect_id, 102 | "is_ugc": False, 103 | "name": self.name, 104 | "production_path": "", 105 | "resource_id": self.resource_id, 106 | "speaker_id": "", 107 | "sub_type": 1, 108 | "time_range": {"duration": 0, "start": 0}, # 似乎并未用到 109 | "type": "audio_effect" 110 | # 不导出path和constant_material_id 111 | } 112 | 113 | class Audio_segment(Media_segment): 114 | """安放在轨道上的一个音频片段""" 115 | 116 | material_instance: Audio_material 117 | """音频素材实例""" 118 | 119 | fade: Optional[Audio_fade] 120 | """音频淡入淡出效果, 可能为空 121 | 122 | 在放入轨道时自动添加到素材列表中 123 | """ 124 | 125 | effects: List[Audio_effect] 126 | """音频特效列表 127 | 128 | 在放入轨道时自动添加到素材列表中 129 | """ 130 | 131 | def __init__(self, material: Audio_material, target_timerange: Timerange, *, 132 | source_timerange: Optional[Timerange] = None, speed: Optional[float] = None, volume: float = 1.0): 133 | """利用给定的音频素材构建一个轨道片段, 并指定其时间信息及播放速度/音量 134 | 135 | Args: 136 | material (`Audio_material`): 素材实例 137 | target_timerange (`Timerange`): 片段在轨道上的目标时间范围 138 | source_timerange (`Timerange`, optional): 截取的素材片段的时间范围, 默认从开头根据`speed`截取与`target_timerange`等长的一部分 139 | speed (`float`, optional): 播放速度, 默认为1.0. 此项与`source_timerange`同时指定时, 将覆盖`target_timerange`中的时长 140 | volume (`float`, optional): 音量, 默认为1.0 141 | 142 | Raises: 143 | `ValueError`: 指定的或计算出的`source_timerange`超出了素材的时长范围 144 | """ 145 | if source_timerange is not None and speed is not None: 146 | target_timerange = Timerange(target_timerange.start, round(source_timerange.duration / speed)) 147 | elif source_timerange is not None and speed is None: 148 | speed = source_timerange.duration / target_timerange.duration 149 | else: # source_timerange is None 150 | speed = speed if speed is not None else 1.0 151 | source_timerange = Timerange(0, round(target_timerange.duration * speed)) 152 | 153 | # if source_timerange.end > material.duration: 154 | # raise ValueError(f"截取的素材时间范围 {source_timerange} 超出了素材时长({material.duration})") 155 | 156 | super().__init__(material.material_id, source_timerange, target_timerange, speed, volume) 157 | 158 | self.material_instance = deepcopy(material) 159 | self.fade = None 160 | self.effects = [] 161 | 162 | def add_effect(self, effect_type: Union[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], 163 | params: Optional[List[Optional[float]]] = None, 164 | effect_id: Optional[str] = None) -> "Audio_segment": 165 | """为音频片段添加一个作用于整个片段的音频效果, 目前"声音成曲"效果不能自动被剪映所识别 166 | 167 | Args: 168 | effect_type (`Audio_scene_effect_type` | `Tone_effect_type` | `Speech_to_song_type`): 音效类型, 一类音效只能添加一个. 169 | params (`List[Optional[float]]`, optional): 音效参数列表, 参数列表中未提供或为None的项使用默认值. 170 | 参数取值范围(0~100)与剪映中一致. 某个特效类型有何参数以及具体参数顺序以枚举类成员的annotation为准. 171 | effect_id (`str`, optional): 音效的ID, 如果不提供则自动生成. 172 | 173 | Raises: 174 | `ValueError`: 试图添加一个已经存在的音效类型、提供的参数数量超过了该音效类型的参数数量, 或参数值超出范围. 175 | """ 176 | if params is not None and len(params) > len(effect_type.value.params): 177 | raise ValueError("为音频效果 %s 传入了过多的参数" % effect_type.value.name) 178 | self.material_instance.has_audio_effect = True # 添加这行代码 179 | effect_inst = Audio_effect(effect_type, params) 180 | if effect_id is not None: 181 | effect_inst.effect_id = effect_id 182 | if effect_inst.category_id in [eff.category_id for eff in self.effects]: 183 | raise ValueError("当前音频片段已经有此类型 (%s) 的音效了" % effect_inst.category_name) 184 | self.effects.append(effect_inst) 185 | self.extra_material_refs.append(effect_inst.effect_id) 186 | 187 | return self 188 | 189 | def add_fade(self, in_duration: Union[str, int], out_duration: Union[str, int]) -> "Audio_segment": 190 | """为音频片段添加淡入淡出效果 191 | 192 | Args: 193 | in_duration (`int` or `str`): 音频淡入时长, 单位为微秒, 若为字符串则会调用`tim()`函数进行解析 194 | out_duration (`int` or `str`): 音频淡出时长, 单位为微秒, 若为字符串则会调用`tim()`函数进行解析 195 | 196 | Raises: 197 | `ValueError`: 当前片段已存在淡入淡出效果 198 | """ 199 | if self.fade is not None: 200 | raise ValueError("当前片段已存在淡入淡出效果") 201 | 202 | if isinstance(in_duration, str): in_duration = tim(in_duration) 203 | if isinstance(out_duration, str): out_duration = tim(out_duration) 204 | 205 | self.fade = Audio_fade(in_duration, out_duration) 206 | self.extra_material_refs.append(self.fade.fade_id) 207 | 208 | return self 209 | 210 | def add_keyframe(self, time_offset: int, volume: float) -> "Audio_segment": 211 | """为音频片段创建一个*控制音量*的关键帧, 并自动加入到关键帧列表中 212 | 213 | Args: 214 | time_offset (`int`): 关键帧的时间偏移量, 单位为微秒 215 | volume (`float`): 音量在`time_offset`处的值 216 | """ 217 | _property = Keyframe_property.volume 218 | for kf_list in self.common_keyframes: 219 | if kf_list.keyframe_property == _property: 220 | kf_list.add_keyframe(time_offset, volume) 221 | return self 222 | kf_list = Keyframe_list(_property) 223 | kf_list.add_keyframe(time_offset, volume) 224 | self.common_keyframes.append(kf_list) 225 | return self 226 | 227 | def export_json(self) -> Dict[str, Any]: 228 | json_dict = super().export_json() 229 | json_dict.update({ 230 | "clip": None, 231 | "hdr_settings": None 232 | }) 233 | return json_dict 234 | -------------------------------------------------------------------------------- /pyJianYingDraft/metadata/capcut_audio_effect_meta.py: -------------------------------------------------------------------------------- 1 | from .effect_meta import Effect_enum 2 | from .effect_meta import Effect_meta, Effect_param 3 | 4 | class CapCut_Voice_filters_effect_type(Effect_enum): 5 | """CapCut自带的Voice filters特效类型""" 6 | 7 | Big_House = Effect_meta("Big House", False, "7350559836590838274", "8954C5C2-A0BB-4915-8CB2-B422445DCB71", "3b1d62bbe927104e393b0fc5043dc0a6", [ 8 | Effect_param("strength", 1.000, 0.000, 1.000)]) 9 | """参数: 10 | - strength: 默认1.00, 0.00 ~ 1.00 11 | """ 12 | Low = Effect_meta("Low", False, "7021052731091587586", "4D23A0EA-5E4B-4B6A-8CE7-E3B0ADAFBCE1", "e2e27786b25e4cf9b4e74558d6f6c832", [ 13 | Effect_param("change_voice_param_pitch", 0.375, 0.000, 1.000), 14 | Effect_param("change_voice_param_timbre", 0.250, 0.000, 1.000)]) 15 | """参数: 16 | - change_voice_param_pitch: 默认0.38, 0.00 ~ 1.00 17 | - change_voice_param_timbre: 默认0.25, 0.00 ~ 1.00 18 | """ 19 | Energetic = Effect_meta("Energetic", False, "7320193885114733057", "FA559CAA-D9AA-443B-9D39-392B43D2DB02", "99fee98d58dd023a9f54a772dffe1ac1", [ 20 | Effect_param("Intensity", 1.000, 0.000, 1.000)]) 21 | """参数: 22 | - Intensity: 默认1.00, 0.00 ~ 1.00 23 | """ 24 | High = Effect_meta("High", False, "7021052551755731457", "E663A5D0-A024-4DED-8F0D-DA6D70A9F50C", "a83c56bd3fb17e93a1437d06498ab7ec", [ 25 | Effect_param("change_voice_param_pitch", 0.834, 0.000, 1.000), 26 | Effect_param("change_voice_param_timbre", 0.334, 0.000, 1.000)]) 27 | """参数: 28 | - change_voice_param_pitch: 默认0.83, 0.00 ~ 1.00 29 | - change_voice_param_timbre: 默认0.33, 0.00 ~ 1.00 30 | """ 31 | Low_Battery = Effect_meta("Low Battery", False, "7021052694370456065", "3FB0AA17-B7E8-4820-86A2-0A34E3F2F881", "a96ff559c9f1afec0603ae8bb107d98c", [ 32 | Effect_param("change_voice_param_strength", 1.000, 0.000, 1.000)]) 33 | """参数: 34 | - change_voice_param_strength: 默认1.00, 0.00 ~ 1.00 35 | """ 36 | Tremble = Effect_meta("Tremble", False, "7021052770924892674", "4AEE9A06-71E0-4188-A87E-A161DDB80F4A", "337b1ba48ea61c95ac84ba238598ca0c", [ 37 | Effect_param("change_voice_param_frequency", 0.714, 0.000, 1.000), 38 | Effect_param("change_voice_param_width", 0.905, 0.000, 1.000)]) 39 | """参数: 40 | - change_voice_param_frequency: 默认0.71, 0.00 ~ 1.00 41 | - change_voice_param_width: 默认0.91, 0.00 ~ 1.00 42 | """ 43 | Electronic = Effect_meta("Electronic", False, "7021052717204247042", "0285BDC4-794F-48D0-A8BD-78B248FDE822", "a6f883d8294fd5f49952cbf08544a0c5", [ 44 | Effect_param("change_voice_param_strength", 1.000, 0.000, 1.000)]) 45 | """参数: 46 | - change_voice_param_strength: 默认1.00, 0.00 ~ 1.00 47 | """ 48 | Sweet = Effect_meta("Sweet", False, "7320193577613529602", "2CD71FC4-7B4E-4E53-B337-32173184F480", "ac2110d039d35ad01ca8452a6eadc921", [ 49 | Effect_param("strength", 1.000, 0.000, 1.000)]) 50 | """参数: 51 | - strength: 默认1.00, 0.00 ~ 1.00 52 | """ 53 | Vinyl = Effect_meta("Vinyl", False, "7025484451710767618", "6D42DEB5-D9DE-479F-955C-9F63C3B88F06", "fe8fdb1bcec05647749e076a15443f08", [ 54 | Effect_param("change_voice_param_strength", 1.000, 0.000, 1.000), 55 | Effect_param("change_voice_param_noise", 0.743, 0.000, 1.000)]) 56 | """参数: 57 | - change_voice_param_strength: 默认1.00, 0.00 ~ 1.00 58 | - change_voice_param_noise: 默认0.74, 0.00 ~ 1.00 59 | """ 60 | Mic_Hog = Effect_meta("Mic Hog", False, "7021052785101640194", "9A48AB2D-B527-4DF0-8512-058819047877", "f2bab335416833134ab4bb780c128cd2", [ 61 | Effect_param("change_voice_param_room", 0.052, 0.000, 1.000), 62 | Effect_param("change_voice_param_strength", 0.450, 0.000, 1.000)]) 63 | """参数: 64 | - change_voice_param_room: 默认0.05, 0.00 ~ 1.00 65 | - change_voice_param_strength: 默认0.45, 0.00 ~ 1.00 66 | """ 67 | LoFi = Effect_meta("Lo-Fi", False, "7025484400313766402", "1C8426BF-AC3B-4F90-B145-CA712BD486D3", "44a00f0e2b85e0006f49ef345a305ec1", [ 68 | Effect_param("change_voice_param_strength", 1.000, 0.000, 1.000)]) 69 | """参数: 70 | - change_voice_param_strength: 默认1.00, 0.00 ~ 1.00 71 | """ 72 | Megaphone = Effect_meta("Megaphone", False, "7021052620592648705", "8A5CF5B8-0959-4E8A-8CC5-AD17BA176D89", "b2ca5803b90f44ee0c833f34ef684d40", [ 73 | Effect_param("change_voice_param_strength", 1.000, 0.000, 1.000)]) 74 | """参数: 75 | - change_voice_param_strength: 默认1.00, 0.00 ~ 1.00 76 | """ 77 | Echo = Effect_meta("Echo", False, "7021052523762946561", "83B7B5D7-B539-4FAA-8CF3-3318394C9278", "c37d02ae5853211ad84c13e6dca31b81", [ 78 | Effect_param("change_voice_param_quantity", 0.800, 0.000, 1.000), 79 | Effect_param("change_voice_param_strength", 0.762, 0.000, 1.000)]) 80 | """参数: 81 | - change_voice_param_quantity: 默认0.80, 0.00 ~ 1.00 82 | - change_voice_param_strength: 默认0.76, 0.00 ~ 1.00 83 | """ 84 | Synth = Effect_meta("Synth", False, "7021052503919694337", "450DE367-AD03-4D06-B635-C947161AAA8E", "0247a95158fda7a9e44ccd4f832a9a14", [ 85 | Effect_param("change_voice_param_strength", 1.000, 0.000, 1.000)]) 86 | """参数: 87 | - change_voice_param_strength: 默认1.00, 0.00 ~ 1.00 88 | """ 89 | Deep = Effect_meta("Deep", False, "7021052537344102913", "188C5140-E4AC-41A5-B42A-901ACAEF1B62", "583e3ccf9d2daad3860aa70ad61b64ca", [ 90 | Effect_param("change_voice_param_pitch", 0.834, 0.000, 1.000), 91 | Effect_param("change_voice_param_timbre", 1.000, 0.000, 1.000)]) 92 | """参数: 93 | - change_voice_param_pitch: 默认0.83, 0.00 ~ 1.00 94 | - change_voice_param_timbre: 默认1.00, 0.00 ~ 1.00 95 | """ 96 | 97 | class CapCut_Voice_characters_effect_type(Effect_enum): 98 | """CapCut自带的Voice characters特效类型""" 99 | 100 | Fussy_male = Effect_meta("Fussy male", False, "7337197310696231425", "00C65F8A-44A7-4B17-8F39-93464E72823D", "", []) 101 | Bestie = Effect_meta("Bestie", False, "7252272084292735489", "B9B3885C-BF7D-4B5C-9545-B0CD3218F292", "", []) 102 | Queen = Effect_meta("Queen", False, "7337197136242545153", "17A7F413-C044-4F1F-9644-1337686BE406", "", []) 103 | Squirrel = Effect_meta("Squirrel", False, "7338257533796094466", "76668599-E132-42DF-99F8-6F086B3B56E9", "b2b3f551b703c87e8e057ad8f92fafbb", [ 104 | Effect_param("strength", 1.000, 0.000, 1.000)]) 105 | """参数: 106 | - strength: 默认1.00, 0.00 ~ 1.00 107 | """ 108 | Distorted = Effect_meta("Distorted", False, "7021052602091573761", "F995614C-D100-481D-A708-59829794EF3E", "ce0bc10d76e22a718094c152f7beae25", [ 109 | Effect_param("change_voice_param_pitch", 0.650, 0.000, 1.000), 110 | Effect_param("change_voice_param_timbre", 0.780, 0.000, 1.000)]) 111 | """参数: 112 | - change_voice_param_pitch: 默认0.65, 0.00 ~ 1.00 113 | - change_voice_param_timbre: 默认0.78, 0.00 ~ 1.00 114 | """ 115 | Chipmunk = Effect_meta("Chipmunk", False, "7021052742021943810", "B5C8BB3C-7765-4572-B8B9-9071A903D899", "4ff3edc0229bfac112c1caefe75e7039", [ 116 | Effect_param("change_voice_param_pitch", 0.500, 0.000, 1.000), 117 | Effect_param("change_voice_param_timbre", 0.500, 0.000, 1.000)]) 118 | """参数: 119 | - change_voice_param_pitch: 默认0.50, 0.00 ~ 1.00 120 | - change_voice_param_timbre: 默认0.50, 0.00 ~ 1.00 121 | """ 122 | Trickster = Effect_meta("Trickster", False, "7254407946195440130", "11F394B0-E601-4DD5-BBEA-76A8CADE222A", "8dd8889045e6c065177df791ddb3dfb8", []) 123 | Elf = Effect_meta("Elf", False, "7021052754512581122", "EF781DEC-265B-4B6D-A68E-5EDC75DDCA84", "bbf0f0d1532a249e9a1f7f3444e1e437", [ 124 | Effect_param("change_voice_param_pitch", 0.750, 0.000, 1.000), 125 | Effect_param("change_voice_param_timbre", 0.600, 0.000, 1.000)]) 126 | """参数: 127 | - change_voice_param_pitch: 默认0.75, 0.00 ~ 1.00 128 | - change_voice_param_timbre: 默认0.60, 0.00 ~ 1.00 129 | """ 130 | Elfy = Effect_meta("Elfy", False, "7311544785477571074", "58E4D6DE-5D7A-42C8-BE16-1AFF43666512", "8dd8889045e6c065177df791ddb3dfb8", []) 131 | Santa = Effect_meta("Santa", False, "7311544442723242497", "8E8A0DA9-1267-41E6-AFA4-20B2A4171BA4", "8dd8889045e6c065177df791ddb3dfb8", []) 132 | Jessie = Effect_meta("Jessie", False, "7254408415026352642", "F3EBF9DB-195D-4531-94A8-F52964DB0C83", "8dd8889045e6c065177df791ddb3dfb8", []) 133 | Good_Guy = Effect_meta("Good Guy", False, "7259231960889823746", "27D9D7EC-BFF2-4481-92D0-40E3CBF9C2FB", "8dd8889045e6c065177df791ddb3dfb8", []) 134 | Robot = Effect_meta("Robot", False, "7021052669863137794", "DD71C5CB-683A-4FFA-BEAA-33D568333486", "123114835bda73b8de4aa106ccde0bb2", [ 135 | Effect_param("change_voice_param_strength", 1.000, 0.000, 1.000)]) 136 | """参数: 137 | - change_voice_param_strength: 默认1.00, 0.00 ~ 1.00 138 | """ 139 | 140 | class CapCut_Speech_to_song_effect_type(Effect_enum): 141 | """CapCut自带的Speech to song特效类型""" 142 | 143 | Folk = Effect_meta("Folk", False, "7413437147539164421", "9A9C3804-5241-44E9-AF56-1BE8271083F2", "", []) 144 | 145 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Connect AI generates via VectCutAPI [Try it online](https://www.vectcut.com) 3 | 4 | ## Project Overview 5 | **VectCutAPI** is a powerful editing API that empowers you to take full control of your AI-generated assets, including images, audio, video, and text. It provides the precision needed to refine and customize raw AI output, such as adjusting video speed or mirroring an image. This capability effectively solves the lack of control often found in AI video generation, allowing you to easily transform your creative ideas into polished videos. 6 | 7 | Enjoy It! 😀😀😀 8 | 9 | [中文说明](README-zh.md) 10 | 11 | ### Advantages 12 | 13 | 1. **API-Powered Editing:** Access all powerfull editing features, including multi-track editing and keyframe animation, through a powerful API. 14 | 15 | 2. **Real-Time Cloud Preview:** Instantly preview your edits on a webpage without downloads, dramatically improving your workflow. 16 | 17 | 3. **Flexible Local Editing:** Export projects as drafts to import into CapCut or Jianying for further refinement. 18 | 19 | 4. **Automated Cloud Generation:** Use the API to render and generate final videos directly in the cloud. 20 | 21 | ## Demos 22 | 23 |
24 | 25 | **MCP, create your own editing Agent** 26 | 27 | [![AI Cut](https://img.youtube.com/vi/fBqy6WFC78E/hqdefault.jpg)](https://www.youtube.com/watch?v=fBqy6WFC78E) 28 | 29 | **Combine AI-generated images and videos using VectCutAPI** 30 | 31 | [More](pattern) 32 | 33 | [![Airbnb](https://img.youtube.com/vi/1zmQWt13Dx0/hqdefault.jpg)](https://www.youtube.com/watch?v=1zmQWt13Dx0) 34 | 35 | [![Horse](https://img.youtube.com/vi/IF1RDFGOtEU/hqdefault.jpg)](https://www.youtube.com/watch?v=IF1RDFGOtEU) 36 | 37 | [![Song](https://img.youtube.com/vi/rGNLE_slAJ8/hqdefault.jpg)](https://www.youtube.com/watch?v=rGNLE_slAJ8) 38 | 39 | 40 |
41 | 42 | ## Key Features 43 | 44 | | Feature Module | API | MCP Protocol | Description | 45 | |---------|----------|----------|------| 46 | | **Draft Management** | ✅ | ✅ | Create and save Jianying/CapCut draft files | 47 | | **Video Processing** | ✅ | ✅ | Import, clip, transition, and apply effects to multiple video formats | 48 | | **Audio Editing** | ✅ | ✅ | Audio tracks, volume control, sound effects processing | 49 | | **Image Processing** | ✅ | ✅ | Image import, animation, masks, filters | 50 | | **Text Editing** | ✅ | ✅ | Multi-style text, shadows, backgrounds, animations | 51 | | **Subtitle System** | ✅ | ✅ | SRT subtitle import, style settings, time synchronization | 52 | | **Effects Engine** | ✅ | ✅ | Visual effects, filters, transition animations | 53 | | **Sticker System** | ✅ | ✅ | Sticker assets, position control, animation effects | 54 | | **Keyframes** | ✅ | ✅ | Property animation, timeline control, easing functions | 55 | | **Media Analysis** | ✅ | ✅ | Get video duration, detect format | 56 | 57 | ## Quick Start 58 | 59 | ### 1\. System Requirements 60 | 61 | - Python 3.10+ 62 | - Jianying or CapCut International version 63 | - FFmpeg 64 | 65 | ### 2\. Installation and Deployment 66 | 67 | ```bash 68 | # 1. Clone the project 69 | git clone https://github.com/sun-guannan/VectCutAPI.git 70 | cd VectCutAPI 71 | 72 | # 2. Create a virtual environment (recommended) 73 | python -m venv venv-capcut 74 | source venv-capcut/bin/activate # Linux/macOS 75 | # or venv-capcut\Scripts\activate # Windows 76 | 77 | # 3. Install dependencies 78 | pip install -r requirements.txt # HTTP API basic dependencies 79 | pip install -r requirements-mcp.txt # MCP protocol support (optional) 80 | 81 | # 4. Configuration file 82 | cp config.json.example config.json 83 | # Edit config.json as needed 84 | ``` 85 | 86 | ### 3\. Start the service 87 | 88 | ```bash 89 | python capcut_server.py # Start the HTTP API server, default port: 9001 90 | 91 | python mcp_server.py # Start the MCP protocol service, supports stdio communication 92 | ``` 93 | 94 | ## MCP Integration Guide 95 | 96 | [MCP 中文文档](https://www.google.com/search?q=./MCP_%E6%96%87%E6%A1%A3_%E4%B8%AD%E6%96%87.md) • [MCP English Guide](https://www.google.com/search?q=./MCP_Documentation_English.md) 97 | 98 | ### 1\. Client Configuration 99 | 100 | Create or update the `mcp_config.json` configuration file: 101 | 102 | ```json 103 | { 104 | "mcpServers": { 105 | "capcut-api": { 106 | "command": "python3", 107 | "args": ["mcp_server.py"], 108 | "cwd": "/path/to/CapCutAPI", 109 | "env": { 110 | "PYTHONPATH": "/path/to/CapCutAPI", 111 | "DEBUG": "0" 112 | } 113 | } 114 | } 115 | } 116 | ``` 117 | 118 | ### 2\. Connection Test 119 | 120 | ```bash 121 | # Test MCP connection 122 | python test_mcp_client.py 123 | 124 | # Expected output 125 | ✅ MCP server started successfully 126 | ✅ Got 11 available tools 127 | ✅ Draft creation test passed 128 | ``` 129 | 130 | ## Usage Examples 131 | 132 | ### 1\. API Example 133 | 134 | Add video material 135 | 136 | ```python 137 | import requests 138 | 139 | # Add background video 140 | response = requests.post("http://localhost:9001/add_video", json={ 141 | "video_url": "https://example.com/background.mp4", 142 | "start": 0, 143 | "end": 10 144 | "volume": 0.8, 145 | "transition": "fade_in" 146 | }) 147 | 148 | print(f"Video addition result: {response.json()}") 149 | ``` 150 | 151 | Create stylized text 152 | 153 | ```python 154 | import requests 155 | 156 | # Add title text 157 | response = requests.post("http://localhost:9001/add_text", json={ 158 | "text": "Welcome to VectCutAPI", 159 | "start": 0, 160 | "end": 5, 161 | "font": "Source Han Sans",read 162 | "font_color": "#FFD700", 163 | "font_size": 48, 164 | "shadow_enabled": True, 165 | "background_color": "#000000" 166 | }) 167 | 168 | print(f"Text addition result: {response.json()}") 169 | ``` 170 | 171 | More examples can be found in the `example.py` file. 172 | 173 | ### 2\. MCP Protocol Example 174 | 175 | Complete workflow 176 | 177 | ```python 178 | # 1. Create a new project 179 | draft = mcp_client.call_tool("create_draft", { 180 | "width": 1080, 181 | "height": 1920 182 | }) 183 | draft_id = draft["result"]["draft_id"] 184 | 185 | # 2. Add background video 186 | mcp_client.call_tool("add_video", { 187 | "video_url": "https://example.com/bg.mp4", 188 | "draft_id": draft_id, 189 | "start": 0, 190 | "end": 10, 191 | "volume": 0.6 192 | }) 193 | 194 | # 3. Add title text 195 | mcp_client.call_tool("add_text", { 196 | "text": "AI-Driven Video Production", 197 | "draft_id": draft_id, 198 | "start": 1, 199 | "end": 6, 200 | "font_size": 56, 201 | "shadow_enabled": True, 202 | "background_color": "#1E1E1E" 203 | }) 204 | 205 | # 4. Add keyframe animation 206 | mcp_client.call_tool("add_video_keyframe", { 207 | "draft_id": draft_id, 208 | "track_name": "main", 209 | "property_types": ["scale_x", "scale_y", "alpha"], 210 | "times": [0, 2, 4], 211 | "values": ["1.0", "1.2", "0.8"] 212 | }) 213 | 214 | # 5. Save the project 215 | result = mcp_client.call_tool("save_draft", { 216 | "draft_id": draft_id 217 | }) 218 | 219 | print(f"Project saved: {result['result']['draft_url']}") 220 | ``` 221 | 222 | Advanced text effects 223 | 224 | ```python 225 | # Multi-style colored text 226 | mcp_client.call_tool("add_text", { 227 | "text": "Colored text effect demonstration", 228 | "draft_id": draft_id, 229 | "start": 2, 230 | "end": 8, 231 | "font_size": 42, 232 | "shadow_enabled": True, 233 | "shadow_color": "#FFFFFF", 234 | "background_alpha": 0.8, 235 | "background_round_radius": 20, 236 | "text_styles": [ 237 | {"start": 0, "end": 2, "font_color": "#FF6B6B"}, 238 | {"start": 2, "end": 4, "font_color": "#4ECDC4"}, 239 | {"start": 4, "end": 6, "font_color": "#45B7D1"} 240 | ] 241 | }) 242 | ``` 243 | 244 | ### 3\. Downloading Drafts 245 | 246 | 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. 247 | 248 | ## Pattern 249 | 250 | You can find a lot of pattern in the `pattern` directory. 251 | 252 | ## Community & Support 253 | 254 | We welcome contributions of all forms\! Our iteration rules are: 255 | 256 | - No direct PRs to main 257 | - PRs can be submitted to the dev branch 258 | - Merges from dev to main and releases will happen every Monday 259 | 260 | ## Contact Us 261 | 262 | ### 🤝 Collaboration 263 | 264 | - **Video Production**: Want to use this API for batch production of videos with AIGC? 265 | 266 | - **Join us**: Our goal is to provide a stable and reliable video editing tool that integrates well with AI-generated images, videos, and audio. If you are interested, submit a PR and I'll see it. For more in-depth involvement, the code for the MCP Editing Agent, web-based editing client, and cloud rendering modules has not been open-sourced yet. 267 | 268 | **Contact**: abelchrisnic@gmail.com 269 | 270 | ## 📈 Star History 271 | 272 |
273 | 274 | [![Star History Chart](https://api.star-history.com/svg?repos=sun-guannan/CapCutAPI&type=Date)](https://www.star-history.com/#sun-guannan/CapCutAPI&Date) 275 | 276 | ![GitHub repo size](https://img.shields.io/github/repo-size/sun-guannan/CapCutAPI?style=flat-square) 277 | ![GitHub code size](https://img.shields.io/github/languages/code-size/sun-guannan/CapCutAPI?style=flat-square) 278 | ![GitHub issues](https://img.shields.io/github/issues/sun-guannan/CapCutAPI?style=flat-square) 279 | ![GitHub pull requests](https://img.shields.io/github/issues-pr/sun-guannan/CapCutAPI?style=flat-square) 280 | ![GitHub last commit](https://img.shields.io/github/last-commit/sun-guannan/CapCutAPI?style=flat-square) 281 | 282 | 283 | [![Verified on MSeeP](https://mseep.ai/badge.svg)](https://mseep.ai/app/69c38d28-a97c-4397-849d-c3e3d241b800) 284 |
285 | 286 | *Made with ❤️ by the CapCutAPI Community* 287 | -------------------------------------------------------------------------------- /test_mcp_client.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | CapCut API MCP 测试客户端 (Complete Version) 4 | 5 | 测试完整版本的MCP服务器,包含所有CapCut API接口 6 | """ 7 | 8 | import subprocess 9 | import json 10 | import time 11 | import sys 12 | 13 | def send_request(process, request_data): 14 | """发送请求并接收响应""" 15 | try: 16 | request_json = json.dumps(request_data, ensure_ascii=False) 17 | print(f"发送请求: {request_json}") 18 | 19 | # 发送请求 20 | process.stdin.write(request_json + "\n") 21 | process.stdin.flush() 22 | 23 | # 等待响应 24 | response_line = process.stdout.readline() 25 | if not response_line.strip(): 26 | print("❌ 收到空响应") 27 | return None 28 | 29 | try: 30 | response = json.loads(response_line.strip()) 31 | print(f"收到响应: {json.dumps(response, ensure_ascii=False, indent=2)}") 32 | return response 33 | except json.JSONDecodeError as e: 34 | print(f"❌ JSON解析错误: {e}") 35 | print(f"原始响应: {response_line}") 36 | return None 37 | 38 | except Exception as e: 39 | print(f"❌ 发送请求时出错: {e}") 40 | return None 41 | 42 | def send_notification(process, notification_data): 43 | """发送通知(不需要响应)""" 44 | try: 45 | notification_json = json.dumps(notification_data, ensure_ascii=False) 46 | print(f"发送通知: {notification_json}") 47 | 48 | process.stdin.write(notification_json + "\n") 49 | process.stdin.flush() 50 | 51 | except Exception as e: 52 | print(f"❌ 发送通知时出错: {e}") 53 | 54 | def main(): 55 | print("🚀 CapCut API MCP 测试客户端 (Complete Version)") 56 | print("🎯 测试所有CapCut API接口功能") 57 | print("=" * 60) 58 | 59 | # 启动MCP服务器 60 | try: 61 | process = subprocess.Popen( 62 | [sys.executable, "mcp_server.py"], # 修改为正确的文件名 63 | stdin=subprocess.PIPE, 64 | stdout=subprocess.PIPE, 65 | stderr=subprocess.PIPE, 66 | text=True, 67 | bufsize=0 # 无缓冲 68 | ) 69 | 70 | print("✅ MCP服务器已启动 (mcp_server.py)") 71 | time.sleep(1) # 等待服务器启动 72 | 73 | # 1. 初始化 74 | init_request = { 75 | "jsonrpc": "2.0", 76 | "id": 1, 77 | "method": "initialize", 78 | "params": { 79 | "protocolVersion": "2024-11-05", 80 | "capabilities": { 81 | "tools": {}, 82 | "resources": {} 83 | }, 84 | "clientInfo": { 85 | "name": "CapCut-Test-Client-Complete", 86 | "version": "1.0.0" 87 | } 88 | } 89 | } 90 | 91 | response = send_request(process, init_request) 92 | if response and "result" in response: 93 | print("✅ 初始化成功") 94 | else: 95 | print("❌ 初始化失败") 96 | return 97 | 98 | # 发送初始化完成通知 99 | init_notification = { 100 | "jsonrpc": "2.0", 101 | "method": "notifications/initialized", 102 | "params": {} 103 | } 104 | send_notification(process, init_notification) 105 | 106 | print("\n=== 📋 获取工具列表 ===") 107 | # 2. 获取工具列表 108 | tools_request = { 109 | "jsonrpc": "2.0", 110 | "id": 2, 111 | "method": "tools/list", 112 | "params": {} 113 | } 114 | 115 | response = send_request(process, tools_request) 116 | if response and "result" in response: 117 | tools = response["result"]["tools"] 118 | print(f"✅ 成功获取 {len(tools)} 个工具:") 119 | for tool in tools: 120 | print(f" • {tool['name']}: {tool['description']}") 121 | else: 122 | print("❌ 获取工具列表失败") 123 | return 124 | 125 | print("\n=== 🎬 测试核心功能 ===\n") 126 | 127 | # 3. 测试创建草稿 128 | print("📝 测试创建草稿") 129 | create_draft_request = { 130 | "jsonrpc": "2.0", 131 | "id": 3, 132 | "method": "tools/call", 133 | "params": { 134 | "name": "create_draft", 135 | "arguments": { 136 | "width": 1080, 137 | "height": 1920 138 | } 139 | } 140 | } 141 | 142 | response = send_request(process, create_draft_request) 143 | if response and "result" in response: 144 | print("✅ 创建草稿成功") 145 | # 提取draft_id用于后续测试 146 | draft_data = json.loads(response["result"]["content"][0]["text"]) 147 | draft_id = draft_data["result"]["draft_id"] 148 | print(f"📋 草稿ID: {draft_id}") 149 | else: 150 | print("❌ 创建草稿失败") 151 | draft_id = None 152 | 153 | # 4. 测试添加文本(带多样式) 154 | print("\n📝 测试添加文本(多样式)") 155 | add_text_request = { 156 | "jsonrpc": "2.0", 157 | "id": 4, 158 | "method": "tools/call", 159 | "params": { 160 | "name": "add_text", 161 | "arguments": { 162 | "text": "Hello CapCut API!", 163 | "start": 0, 164 | "end": 5, 165 | "draft_id": draft_id, 166 | "font_color": "#ff0000", 167 | "font_size": 32, 168 | "shadow_enabled": True, 169 | "shadow_color": "#000000", 170 | "shadow_alpha": 0.8, 171 | "background_color": "#ffffff", 172 | "background_alpha": 0.5, 173 | "text_styles": [ 174 | { 175 | "start": 0, 176 | "end": 5, 177 | "font_size": 36, 178 | "font_color": "#00ff00", 179 | "bold": True 180 | }, 181 | { 182 | "start": 6, 183 | "end": 12, 184 | "font_size": 28, 185 | "font_color": "#0000ff", 186 | "italic": True 187 | } 188 | ] 189 | } 190 | } 191 | } 192 | 193 | response = send_request(process, add_text_request) 194 | if response and "result" in response: 195 | print("✅ 添加文本成功") 196 | else: 197 | print("❌ 添加文本失败") 198 | 199 | # 5. 测试添加视频 200 | print("\n🎬 测试添加视频") 201 | add_video_request = { 202 | "jsonrpc": "2.0", 203 | "id": 5, 204 | "method": "tools/call", 205 | "params": { 206 | "name": "add_video", 207 | "arguments": { 208 | "video_url": "https://example.com/video.mp4", 209 | "draft_id": draft_id, 210 | "start": 0, 211 | "end": 10, 212 | "target_start": 5, 213 | "transition": "fade", 214 | "volume": 0.8 215 | } 216 | } 217 | } 218 | 219 | response = send_request(process, add_video_request) 220 | if response and "result" in response: 221 | print("✅ 添加视频成功") 222 | else: 223 | print("❌ 添加视频失败") 224 | 225 | # 6. 测试添加音频 226 | print("\n🎵 测试添加音频") 227 | add_audio_request = { 228 | "jsonrpc": "2.0", 229 | "id": 6, 230 | "method": "tools/call", 231 | "params": { 232 | "name": "add_audio", 233 | "arguments": { 234 | "audio_url": "https://example.com/audio.mp3", 235 | "draft_id": draft_id, 236 | "start": 0, 237 | "end": 15, 238 | "volume": 0.6 239 | } 240 | } 241 | } 242 | 243 | response = send_request(process, add_audio_request) 244 | if response and "result" in response: 245 | print("✅ 添加音频成功") 246 | else: 247 | print("❌ 添加音频失败") 248 | 249 | # 7. 测试添加图片 250 | print("\n🖼️ 测试添加图片") 251 | add_image_request = { 252 | "jsonrpc": "2.0", 253 | "id": 7, 254 | "method": "tools/call", 255 | "params": { 256 | "name": "add_image", 257 | "arguments": { 258 | "image_url": "https://example.com/image.jpg", 259 | "draft_id": draft_id, 260 | "start": 10, 261 | "end": 15, 262 | "intro_animation": "fade_in", 263 | "outro_animation": "fade_out" 264 | } 265 | } 266 | } 267 | 268 | response = send_request(process, add_image_request) 269 | if response and "result" in response: 270 | print("✅ 添加图片成功") 271 | else: 272 | print("❌ 添加图片失败") 273 | 274 | # 8. 测试获取视频时长 275 | print("\n⏱️ 测试获取视频时长") 276 | get_duration_request = { 277 | "jsonrpc": "2.0", 278 | "id": 8, 279 | "method": "tools/call", 280 | "params": { 281 | "name": "get_video_duration", 282 | "arguments": { 283 | "video_url": "https://example.com/video.mp4" 284 | } 285 | } 286 | } 287 | 288 | response = send_request(process, get_duration_request) 289 | if response and "result" in response: 290 | print("✅ 获取视频时长成功") 291 | else: 292 | print("❌ 获取视频时长失败") 293 | 294 | # 9. 测试保存草稿 295 | print("\n💾 测试保存草稿") 296 | save_draft_request = { 297 | "jsonrpc": "2.0", 298 | "id": 9, 299 | "method": "tools/call", 300 | "params": { 301 | "name": "save_draft", 302 | "arguments": { 303 | "draft_id": draft_id 304 | } 305 | } 306 | } 307 | 308 | response = send_request(process, save_draft_request) 309 | if response and "result" in response: 310 | print("✅ 保存草稿成功") 311 | else: 312 | print("❌ 保存草稿失败") 313 | 314 | print("\n🎉 所有测试完成!CapCut API MCP服务器功能验证成功!") 315 | 316 | print("\n✅ 已验证的功能:") 317 | print(" • 草稿管理 (创建、保存)") 318 | print(" • 文本处理 (多样式、阴影、背景)") 319 | print(" • 视频处理 (添加、转场、音量控制)") 320 | print(" • 音频处理 (添加、音量控制)") 321 | print(" • 图片处理 (添加、动画效果)") 322 | print(" • 工具信息 (时长获取)") 323 | 324 | except Exception as e: 325 | print(f"❌ 测试过程中出错: {e}") 326 | import traceback 327 | traceback.print_exc() 328 | 329 | finally: 330 | # 关闭服务器 331 | try: 332 | process.terminate() 333 | process.wait(timeout=5) 334 | except: 335 | process.kill() 336 | print("🔴 MCP服务器已关闭") 337 | 338 | if __name__ == "__main__": 339 | main() --------------------------------------------------------------------------------