This is page 1 of 4. Use http://codebase.md/hey-jian-wei/jianying-mcp?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .gitignore ├── .python-version ├── jianyingdraft │ ├── __init__.py │ ├── debug.py │ ├── jianying │ │ ├── __init__.py │ │ ├── audio.py │ │ ├── draft.py │ │ ├── export.py │ │ ├── text.py │ │ ├── track.py │ │ └── video.py │ ├── server.py │ ├── services │ │ ├── __init__.py │ │ ├── audio_service.py │ │ ├── text_service.py │ │ ├── track_service.py │ │ └── video_service.py │ ├── tool │ │ ├── __init__.py │ │ ├── audio_tool.py │ │ ├── draft_tool.py │ │ ├── text_tool.py │ │ ├── track_tool.py │ │ ├── utility_tool.py │ │ └── video_tool.py │ ├── utils │ │ ├── __init__.py │ │ ├── draft_maintenance.py │ │ ├── effect_manager.py │ │ ├── index_manager.py │ │ ├── media_parser.py │ │ ├── response.py │ │ └── time_format.py │ └── validators │ ├── __init__.py │ ├── material_validator.py │ └── overlap_validator.py ├── material │ ├── audio.MP3 │ ├── video1.mp4 │ ├── video2.mp4 │ └── video3.mp4 ├── pyJianYingDraft │ ├── __init__.py │ ├── animation.py │ ├── assets │ │ ├── __init__.py │ │ ├── draft_content_template.json │ │ └── draft_meta_info.json │ ├── audio_segment.py │ ├── draft_folder.py │ ├── effect_segment.py │ ├── exceptions.py │ ├── jianying_controller.py │ ├── keyframe.py │ ├── local_materials.py │ ├── metadata │ │ ├── __init__.py │ │ ├── animation_meta.py │ │ ├── audio_effect_meta.py │ │ ├── audio_scene_effect.py │ │ ├── effect_meta.py │ │ ├── filter_meta.py │ │ ├── font_meta.py │ │ ├── mask_meta.py │ │ ├── speech_to_song.py │ │ ├── text_intro.py │ │ ├── text_loop.py │ │ ├── text_outro.py │ │ ├── tone_effect.py │ │ ├── transition_meta.py │ │ ├── video_character_effect.py │ │ ├── video_group_animation.py │ │ ├── video_intro.py │ │ ├── video_outro.py │ │ └── video_scene_effect.py │ ├── script_file.py │ ├── segment.py │ ├── template_mode.py │ ├── text_segment.py │ ├── time_util.py │ ├── track.py │ ├── util.py │ └── video_segment.py ├── pyproject.toml ├── README.md └── uv.lock ``` # Files -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- ``` 1 | 3.13 2 | ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | # Python-generated files 2 | __pycache__/ 3 | *.py[oc] 4 | build/ 5 | dist/ 6 | wheels/ 7 | *.egg-info 8 | .venv 9 | draft/ 10 | .idea 11 | ``` -------------------------------------------------------------------------------- /jianyingdraft/__init__.py: -------------------------------------------------------------------------------- ```python 1 | ``` -------------------------------------------------------------------------------- /jianyingdraft/jianying/__init__.py: -------------------------------------------------------------------------------- ```python 1 | ``` -------------------------------------------------------------------------------- /jianyingdraft/services/__init__.py: -------------------------------------------------------------------------------- ```python 1 | ``` -------------------------------------------------------------------------------- /jianyingdraft/tool/__init__.py: -------------------------------------------------------------------------------- ```python 1 | ``` -------------------------------------------------------------------------------- /jianyingdraft/utils/__init__.py: -------------------------------------------------------------------------------- ```python 1 | ``` -------------------------------------------------------------------------------- /jianyingdraft/validators/__init__.py: -------------------------------------------------------------------------------- ```python 1 | ``` -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- ```toml 1 | [project] 2 | name = "jianying-mcp" 3 | version = "0.1.0" 4 | description = "this is jianying mcp" 5 | readme = "README.md" 6 | requires-python = ">=3.13" 7 | dependencies = [ 8 | "imageio>=2.37.0", 9 | "mcp[cli]>=1.12.4", 10 | "pymediainfo>=7.0.1", 11 | "python-dotenv>=1.1.1", 12 | "requests>=2.32.4", 13 | "uiautomation>=2.0.29", 14 | "uvicorn[standard]>=0.35.0", 15 | ] 16 | ``` -------------------------------------------------------------------------------- /jianyingdraft/server.py: -------------------------------------------------------------------------------- ```python 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Author: jian wei 4 | File Name:server.py 5 | """ 6 | import sys 7 | from pathlib import Path 8 | 9 | from mcp.server.fastmcp import FastMCP 10 | 11 | mcp = FastMCP("JianYingDraft") 12 | # 将当前目录添加到python项目 13 | project_root = Path(__file__).parent.parent 14 | sys.path.insert(0, str(project_root)) 15 | 16 | from jianyingdraft.tool.draft_tool import draft_tools 17 | from jianyingdraft.tool.track_tool import track_tools 18 | from jianyingdraft.tool.video_tool import video_tools 19 | from jianyingdraft.tool.text_tool import text_tools 20 | from jianyingdraft.tool.audio_tool import audio_tools 21 | from jianyingdraft.tool.utility_tool import utility_tools 22 | 23 | 24 | def main(): 25 | # 注册所有工具 26 | draft_tools(mcp) 27 | track_tools(mcp) 28 | video_tools(mcp) 29 | text_tools(mcp) 30 | audio_tools(mcp) 31 | utility_tools(mcp) 32 | mcp.run() 33 | 34 | 35 | if __name__ == "__main__": 36 | main() 37 | ``` -------------------------------------------------------------------------------- /pyJianYingDraft/assets/draft_meta_info.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "cloud_package_completed_time": "", 3 | "draft_cloud_capcut_purchase_info": "", 4 | "draft_cloud_last_action_download": false, 5 | "draft_cloud_materials": [], 6 | "draft_cloud_purchase_info": "", 7 | "draft_cloud_template_id": "", 8 | "draft_cloud_tutorial_info": "", 9 | "draft_cloud_videocut_purchase_info": "", 10 | "draft_cover": "", 11 | "draft_deeplink_url": "", 12 | "draft_enterprise_info": { 13 | "draft_enterprise_extra": "", 14 | "draft_enterprise_id": "", 15 | "draft_enterprise_name": "", 16 | "enterprise_material": [] 17 | }, 18 | "draft_fold_path": "", 19 | "draft_id": "BC69C7CD-7C5E-4185-B284-AF3E1047A664", 20 | "draft_is_ai_packaging_used": false, 21 | "draft_is_ai_shorts": false, 22 | "draft_is_ai_translate": false, 23 | "draft_is_article_video_draft": false, 24 | "draft_is_from_deeplink": "false", 25 | "draft_is_invisible": false, 26 | "draft_materials": [ 27 | { 28 | "type": 0, 29 | "value": [] 30 | }, 31 | { 32 | "type": 1, 33 | "value": [] 34 | }, 35 | { 36 | "type": 2, 37 | "value": [] 38 | }, 39 | { 40 | "type": 3, 41 | "value": [] 42 | }, 43 | { 44 | "type": 6, 45 | "value": [] 46 | }, 47 | { 48 | "type": 7, 49 | "value": [] 50 | }, 51 | { 52 | "type": 8, 53 | "value": [] 54 | } 55 | ], 56 | "draft_materials_copied_info": [], 57 | "draft_name": "", 58 | "draft_new_version": "", 59 | "draft_removable_storage_device": "", 60 | "draft_root_path": "", 61 | "draft_segment_extra_info": [], 62 | "draft_type": "", 63 | "tm_draft_cloud_completed": "", 64 | "tm_draft_cloud_modified": 0, 65 | "tm_draft_removed": 0, 66 | "tm_duration": 0 67 | } ``` -------------------------------------------------------------------------------- /jianyingdraft/debug.py: -------------------------------------------------------------------------------- ```python 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Author: jian wei 4 | File Name:debug.py 5 | """ 6 | from jianyingdraft.jianying.draft import Draft 7 | from jianyingdraft.jianying.track import Track 8 | from jianyingdraft.jianying.audio import AudioSegment 9 | from jianyingdraft.jianying.text import TextSegment 10 | from jianyingdraft.jianying.export import ExportDraft 11 | from jianyingdraft.jianying.video import VideoSegment 12 | 13 | # 创建草稿 14 | draft = Draft() 15 | draft_id = draft.create_draft(draft_name='test')['draft_id'] 16 | print(draft_id) 17 | # 创建轨道 18 | text_track_id = Track(draft_id).add_track(track_type='text', track_name='text') 19 | video_track_id = Track(draft_id).add_track(track_type='video', track_name='video') 20 | Track(draft_id).add_track(track_type='audio', track_name='audio') 21 | # 创建音频片段 22 | audio_segment = AudioSegment(draft_id, track_name='audio') 23 | audio_segment.add_audio_segment(material='../material/audio.MP3', 24 | target_timerange='0s-16s') 25 | audio_segment.add_fade('1s', '0.5s') 26 | # 创建视频片段 27 | video_segment1 = VideoSegment(draft_id, track_name='video') 28 | video_segment1.add_video_segment( 29 | material='../material/video1.mp4', 30 | target_timerange='0s-6s' 31 | ) 32 | video_segment1.add_transition('叠化', '1s') 33 | video_segment1.add_filter('冬漫', intensity=50.0) 34 | video_segment2 = VideoSegment(draft_id, track_name='video') 35 | video_segment2.add_video_segment( 36 | material='../material/video2.mp4', 37 | target_timerange='6s-5s' 38 | ) 39 | video_segment2.add_background_filling('blur', blur=0.5) 40 | video_segment2.add_mask( 41 | mask_type='爱心', 42 | center_x=0.5, 43 | center_y=0.5, 44 | size=0.5, 45 | rotation=0.0, 46 | feather=0.0, 47 | invert=False, 48 | rect_width=0.5, 49 | round_corner=0.0 50 | ) 51 | video_segment2.add_transition('闪黑', '1s') 52 | 53 | video_segment3 = VideoSegment(draft_id, track_name='video') 54 | video_segment3.add_video_segment( 55 | material='../material/video3.mp4', 56 | target_timerange='11s-5.20s' 57 | ) 58 | 59 | # 创建文本片段 60 | text_segment1 = TextSegment( 61 | draft_id=draft_id, 62 | track_name="text" 63 | ) 64 | add_text_segment_params = text_segment1.add_text_segment( 65 | text="这是jianying-mcp制作的视频", 66 | timerange="0s-6s", 67 | clip_settings={"transform_y": -0.8} 68 | ) 69 | text_segment1.add_animation('TextIntro', animation_name='向上滑动', duration='1s') 70 | text_segment1.add_animation('TextOutro', animation_name='右上弹出', duration='1s') 71 | 72 | text_segment2 = TextSegment( 73 | draft_id=draft_id, 74 | track_name="text" 75 | ) 76 | text_segment2.add_text_segment( 77 | text="欢迎大家使用", 78 | timerange="6s-5s", 79 | clip_settings={"transform_y": -0.8} 80 | ) 81 | text_segment3 = TextSegment( 82 | draft_id=draft_id, 83 | track_name="text" 84 | ) 85 | text_segment3.add_text_segment( 86 | text="如果这个项目对你有帮助,请给个 Star 支持一下!", 87 | timerange="11s-5.20s", 88 | clip_settings={"transform_y": -0.8} 89 | ) 90 | text_segment3.add_animation("TextLoopAnim", "色差故障") 91 | 92 | ExportDraft().export(draft_id) 93 | ``` -------------------------------------------------------------------------------- /pyJianYingDraft/assets/draft_content_template.json: -------------------------------------------------------------------------------- ```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 | "platform": { 60 | "app_id": 3704, 61 | "app_source": "lv", 62 | "app_version": "5.9.0", 63 | "os": "windows" 64 | }, 65 | "materials": { 66 | "ai_translates": [], 67 | "audio_balances": [], 68 | "audio_effects": [], 69 | "audio_fades": [], 70 | "audio_track_indexes": [], 71 | "audios": [], 72 | "beats": [], 73 | "canvases": [], 74 | "chromas": [], 75 | "color_curves": [], 76 | "digital_humans": [], 77 | "drafts": [], 78 | "effects": [], 79 | "flowers": [], 80 | "green_screens": [], 81 | "handwrites": [], 82 | "hsl": [], 83 | "images": [], 84 | "log_color_wheels": [], 85 | "loudnesses": [], 86 | "manual_deformations": [], 87 | "masks": [], 88 | "material_animations": [], 89 | "material_colors": [], 90 | "multi_language_refs": [], 91 | "placeholders": [], 92 | "plugin_effects": [], 93 | "primary_color_wheels": [], 94 | "realtime_denoises": [], 95 | "shapes": [], 96 | "smart_crops": [], 97 | "smart_relights": [], 98 | "sound_channel_mappings": [], 99 | "speeds": [], 100 | "stickers": [], 101 | "tail_leaders": [], 102 | "text_templates": [], 103 | "texts": [], 104 | "time_marks": [], 105 | "transitions": [], 106 | "video_effects": [], 107 | "video_trackings": [], 108 | "videos": [], 109 | "vocal_beautifys": [], 110 | "vocal_separations": [] 111 | }, 112 | "mutable_config": null, 113 | "name": "", 114 | "new_version": "110.0.0", 115 | "relationships": [], 116 | "render_index_track_mode_on": false, 117 | "retouch_cover": null, 118 | "source": "default", 119 | "static_cover_image_path": "", 120 | "time_marks": null, 121 | "tracks": [ 122 | ], 123 | "update_time": 0, 124 | "version": 360000 125 | } ``` -------------------------------------------------------------------------------- /pyJianYingDraft/__init__.py: -------------------------------------------------------------------------------- ```python 1 | import warnings 2 | import sys 3 | 4 | from .local_materials import CropSettings, VideoMaterial, AudioMaterial 5 | from .keyframe import KeyframeProperty 6 | 7 | from .time_util import Timerange 8 | from .audio_segment import AudioSegment 9 | from .video_segment import VideoSegment, StickerSegment, ClipSettings 10 | from .effect_segment import EffectSegment, FilterSegment 11 | from .text_segment import TextSegment, TextStyle, TextBorder, TextBackground 12 | 13 | from .metadata import FontType 14 | from .metadata import MaskType 15 | from .metadata import TransitionType, FilterType 16 | from .metadata import IntroType, OutroType, GroupAnimationType 17 | from .metadata import TextIntro, TextOutro, TextLoopAnim 18 | from .metadata import AudioSceneEffectType 19 | from .metadata import VideoSceneEffectType, VideoCharacterEffectType 20 | 21 | from .track import TrackType 22 | from .template_mode import ShrinkMode, ExtendMode 23 | from .script_file import ScriptFile 24 | from .draft_folder import DraftFolder 25 | 26 | # 仅在Windows系统下导入jianying_controller 27 | ISWIN = (sys.platform == 'win32') 28 | if ISWIN: 29 | from .jianying_controller import JianyingController, ExportResolution, ExportFramerate 30 | 31 | from .time_util import SEC, tim, trange 32 | 33 | 34 | def _deprecated_class_warning(old_name: str, new_name: str): 35 | warnings.warn( 36 | f"{old_name} is deprecated and will be removed in a future version. " 37 | f"Use {new_name} instead.", 38 | DeprecationWarning, 39 | stacklevel=3 40 | ) 41 | 42 | # 保持向后兼容 43 | class Script_file: 44 | """Deprecated: Use ScriptFile instead.""" 45 | def __new__(cls, *args, **kwargs): 46 | _deprecated_class_warning("Script_file", "ScriptFile") 47 | return ScriptFile(*args, **kwargs) 48 | 49 | class Draft_folder: 50 | """Deprecated: Use DraftFolder instead.""" 51 | def __new__(cls, *args, **kwargs): 52 | _deprecated_class_warning("Draft_folder", "DraftFolder") 53 | return DraftFolder(*args, **kwargs) 54 | 55 | class Shrink_mode: 56 | """Deprecated: Use ShrinkMode instead.""" 57 | def __new__(cls, *args, **kwargs): 58 | _deprecated_class_warning("Shrink_mode", "ShrinkMode") 59 | return ShrinkMode(*args, **kwargs) 60 | 61 | class Extend_mode: 62 | """Deprecated: Use ExtendMode instead.""" 63 | def __new__(cls, *args, **kwargs): 64 | _deprecated_class_warning("Extend_mode", "ExtendMode") 65 | return ExtendMode(*args, **kwargs) 66 | 67 | class Clip_settings: 68 | """Deprecated: Use ClipSettings instead.""" 69 | def __new__(cls, *args, **kwargs): 70 | _deprecated_class_warning("Clip_settings", "ClipSettings") 71 | return ClipSettings(*args, **kwargs) 72 | 73 | class Text_style: 74 | """Deprecated: Use TextStyle instead.""" 75 | def __new__(cls, *args, **kwargs): 76 | _deprecated_class_warning("Text_style", "TextStyle") 77 | return TextStyle(*args, **kwargs) 78 | 79 | class Text_border: 80 | """Deprecated: Use TextBorder instead.""" 81 | def __new__(cls, *args, **kwargs): 82 | _deprecated_class_warning("Text_border", "TextBorder") 83 | return TextBorder(*args, **kwargs) 84 | 85 | class Text_background: 86 | """Deprecated: Use TextBackground instead.""" 87 | def __new__(cls, *args, **kwargs): 88 | _deprecated_class_warning("Text_background", "TextBackground") 89 | return TextBackground(*args, **kwargs) 90 | 91 | class Text_segment: 92 | """Deprecated: Use TextSegment instead.""" 93 | def __new__(cls, *args, **kwargs): 94 | _deprecated_class_warning("Text_segment", "TextSegment") 95 | return TextSegment(*args, **kwargs) 96 | 97 | class Audio_segment: 98 | """Deprecated: Use AudioSegment instead.""" 99 | def __new__(cls, *args, **kwargs): 100 | _deprecated_class_warning("Audio_segment", "AudioSegment") 101 | return AudioSegment(*args, **kwargs) 102 | 103 | class Video_segment: 104 | """Deprecated: Use VideoSegment instead.""" 105 | def __new__(cls, *args, **kwargs): 106 | _deprecated_class_warning("Video_segment", "VideoSegment") 107 | return VideoSegment(*args, **kwargs) 108 | 109 | class Sticker_segment: 110 | """Deprecated: Use StickerSegment instead.""" 111 | def __new__(cls, *args, **kwargs): 112 | _deprecated_class_warning("Sticker_segment", "StickerSegment") 113 | return StickerSegment(*args, **kwargs) 114 | 115 | class Effect_segment: 116 | """Deprecated: Use EffectSegment instead.""" 117 | def __new__(cls, *args, **kwargs): 118 | _deprecated_class_warning("Effect_segment", "EffectSegment") 119 | return EffectSegment(*args, **kwargs) 120 | 121 | class Filter_segment: 122 | """Deprecated: Use FilterSegment instead.""" 123 | def __new__(cls, *args, **kwargs): 124 | _deprecated_class_warning("Filter_segment", "FilterSegment") 125 | return FilterSegment(*args, **kwargs) 126 | 127 | class Video_material: 128 | """Deprecated: Use VideoMaterial instead.""" 129 | def __new__(cls, *args, **kwargs): 130 | _deprecated_class_warning("Video_material", "VideoMaterial") 131 | return VideoMaterial(*args, **kwargs) 132 | 133 | class Audio_material: 134 | """Deprecated: Use AudioMaterial instead.""" 135 | def __new__(cls, *args, **kwargs): 136 | _deprecated_class_warning("Audio_material", "AudioMaterial") 137 | return AudioMaterial(*args, **kwargs) 138 | 139 | class Crop_settings: 140 | """Deprecated: Use CropSettings instead.""" 141 | def __new__(cls, *args, **kwargs): 142 | _deprecated_class_warning("Crop_settings", "CropSettings") 143 | return CropSettings(*args, **kwargs) 144 | 145 | # 枚举类的向后兼容 - 使用代理类 146 | class _DeprecatedEnum: 147 | """带deprecation警告的枚举代理类""" 148 | def __init__(self, original_enum, old_name, new_name): 149 | self._enum = original_enum 150 | self._old_name = old_name 151 | self._new_name = new_name 152 | 153 | def __getattr__(self, name): 154 | # 当访问枚举成员时显示警告 155 | _deprecated_class_warning(self._old_name, self._new_name) 156 | return getattr(self._enum, name) 157 | 158 | def __getitem__(self, name): 159 | # 当通过索引访问时显示警告 160 | _deprecated_class_warning(self._old_name, self._new_name) 161 | return self._enum[name] 162 | 163 | def __repr__(self): 164 | return f"<Deprecated {self._old_name} (use {self._new_name} instead)>" 165 | 166 | Track_type = _DeprecatedEnum(TrackType, "Track_type", "TrackType") 167 | Font_type = _DeprecatedEnum(FontType, "Font_type", "FontType") 168 | Mask_type = _DeprecatedEnum(MaskType, "Mask_type", "MaskType") 169 | Filter_type = _DeprecatedEnum(FilterType, "Filter_type", "FilterType") 170 | Transition_type = _DeprecatedEnum(TransitionType, "Transition_type", "TransitionType") 171 | Intro_type = _DeprecatedEnum(IntroType, "Intro_type", "IntroType") 172 | Outro_type = _DeprecatedEnum(OutroType, "Outro_type", "OutroType") 173 | Group_animation_type = _DeprecatedEnum(GroupAnimationType, "Group_animation_type", "GroupAnimationType") 174 | Text_intro = _DeprecatedEnum(TextIntro, "Text_intro", "TextIntro") 175 | Text_outro = _DeprecatedEnum(TextOutro, "Text_outro", "TextOutro") 176 | Text_loop_anim = _DeprecatedEnum(TextLoopAnim, "Text_loop_anim", "TextLoopAnim") 177 | Audio_scene_effect_type = _DeprecatedEnum(AudioSceneEffectType, "Audio_scene_effect_type", "AudioSceneEffectType") 178 | Video_scene_effect_type = _DeprecatedEnum(VideoSceneEffectType, "Video_scene_effect_type", "VideoSceneEffectType") 179 | Video_character_effect_type = _DeprecatedEnum(VideoCharacterEffectType, "Video_character_effect_type", "VideoCharacterEffectType") 180 | Keyframe_property = _DeprecatedEnum(KeyframeProperty, "Keyframe_property", "KeyframeProperty") 181 | 182 | # 仅在Windows系统下定义jianying_controller相关的向后兼容类 183 | if ISWIN: 184 | class Jianying_controller: 185 | """Deprecated: Use JianyingController instead.""" 186 | def __new__(cls, *args, **kwargs): 187 | _deprecated_class_warning("Jianying_controller", "JianyingController") 188 | return JianyingController(*args, **kwargs) 189 | 190 | Export_resolution = _DeprecatedEnum(ExportResolution, "Export_resolution", "ExportResolution") 191 | Export_framerate = _DeprecatedEnum(ExportFramerate, "Export_framerate", "ExportFramerate") 192 | 193 | # 基础__all__列表(所有平台通用) 194 | __all__ = [ 195 | "FontType", 196 | "MaskType", 197 | "FilterType", 198 | "TransitionType", 199 | "IntroType", 200 | "OutroType", 201 | "GroupAnimationType", 202 | "TextIntro", 203 | "TextOutro", 204 | "TextLoopAnim", 205 | "AudioSceneEffectType", 206 | "VideoSceneEffectType", 207 | "VideoCharacterEffectType", 208 | "CropSettings", 209 | "VideoMaterial", 210 | "AudioMaterial", 211 | "KeyframeProperty", 212 | "Timerange", 213 | "AudioSegment", 214 | "VideoSegment", 215 | "StickerSegment", 216 | "ClipSettings", 217 | "EffectSegment", 218 | "FilterSegment", 219 | "TextSegment", 220 | "TextStyle", 221 | "TextBorder", 222 | "TextBackground", 223 | "TrackType", 224 | "ShrinkMode", 225 | "ExtendMode", 226 | "ScriptFile", 227 | "DraftFolder", 228 | "SEC", 229 | "tim", 230 | "trange", 231 | 232 | # 向后兼容的snake_case类 233 | "Script_file", 234 | "Draft_folder", 235 | "Shrink_mode", 236 | "Extend_mode", 237 | "Track_type", 238 | "Font_type", 239 | "Mask_type", 240 | "Filter_type", 241 | "Transition_type", 242 | "Intro_type", 243 | "Outro_type", 244 | "Group_animation_type", 245 | "Text_intro", 246 | "Text_outro", 247 | "Text_loop_anim", 248 | "Audio_scene_effect_type", 249 | "Video_scene_effect_type", 250 | "Video_character_effect_type", 251 | "Clip_settings", 252 | "Text_style", 253 | "Text_border", 254 | "Text_background", 255 | "Text_segment", 256 | "Audio_segment", 257 | "Video_segment", 258 | "Sticker_segment", 259 | "Effect_segment", 260 | "Filter_segment", 261 | "Video_material", 262 | "Audio_material", 263 | "Crop_settings", 264 | "Keyframe_property", 265 | ] 266 | 267 | # 仅在Windows系统下添加jianying_controller相关的导出 268 | if ISWIN: 269 | __all__.extend([ 270 | "JianyingController", 271 | "ExportResolution", 272 | "ExportFramerate", 273 | "Jianying_controller", 274 | "Export_resolution", 275 | "Export_framerate", 276 | ]) 277 | ``` -------------------------------------------------------------------------------- /pyJianYingDraft/script_file.py: -------------------------------------------------------------------------------- ```python 1 | import os 2 | import json 3 | import math 4 | from copy import deepcopy 5 | 6 | from typing import Optional, Literal, Union, overload, Tuple 7 | from typing import Type, Dict, List, Any 8 | 9 | from . import util 10 | from . import assets 11 | from . import exceptions 12 | from .template_mode import ImportedTrack, EditableTrack, ImportedMediaTrack, ImportedTextTrack, ImportedMediaSegment, ShrinkMode, ExtendMode, import_track 13 | from .time_util import Timerange, tim, srt_tstamp 14 | from .local_materials import VideoMaterial, AudioMaterial 15 | from .segment import BaseSegment, Speed, ClipSettings 16 | from .audio_segment import AudioSegment, AudioFade, AudioEffect 17 | from .video_segment import VideoSegment, StickerSegment, SegmentAnimations, VideoEffect, Transition, Filter, BackgroundFilling 18 | from .effect_segment import EffectSegment, FilterSegment 19 | from .text_segment import TextSegment, TextStyle, TextBubble 20 | from .track import TrackType, BaseTrack, Track 21 | 22 | from .metadata import VideoSceneEffectType, VideoCharacterEffectType, FilterType 23 | 24 | class ScriptMaterial: 25 | """草稿文件中的素材信息部分""" 26 | 27 | audios: List[AudioMaterial] 28 | """音频素材列表""" 29 | videos: List[VideoMaterial] 30 | """视频素材列表""" 31 | stickers: List[Dict[str, Any]] 32 | """贴纸素材列表""" 33 | texts: List[Dict[str, Any]] 34 | """文本素材列表""" 35 | 36 | audio_effects: List[AudioEffect] 37 | """音频特效列表""" 38 | audio_fades: List[AudioFade] 39 | """音频淡入淡出效果列表""" 40 | animations: List[SegmentAnimations] 41 | """动画素材列表""" 42 | video_effects: List[VideoEffect] 43 | """视频特效列表""" 44 | 45 | speeds: List[Speed] 46 | """变速列表""" 47 | masks: List[Dict[str, Any]] 48 | """蒙版列表""" 49 | transitions: List[Transition] 50 | """转场效果列表""" 51 | filters: List[Union[Filter, TextBubble]] 52 | """滤镜/文本花字/文本气泡列表, 导出到`effects`中""" 53 | canvases: List[BackgroundFilling] 54 | """背景填充列表""" 55 | 56 | def __init__(self): 57 | self.audios = [] 58 | self.videos = [] 59 | self.stickers = [] 60 | self.texts = [] 61 | 62 | self.audio_effects = [] 63 | self.audio_fades = [] 64 | self.animations = [] 65 | self.video_effects = [] 66 | 67 | self.speeds = [] 68 | self.masks = [] 69 | self.transitions = [] 70 | self.filters = [] 71 | self.canvases = [] 72 | 73 | @overload 74 | def __contains__(self, item: Union[VideoMaterial, AudioMaterial]) -> bool: ... 75 | @overload 76 | def __contains__(self, item: Union[AudioFade, AudioEffect]) -> bool: ... 77 | @overload 78 | def __contains__(self, item: Union[SegmentAnimations, VideoEffect, Transition, Filter]) -> bool: ... 79 | 80 | def __contains__(self, item) -> bool: 81 | if isinstance(item, VideoMaterial): 82 | return item.material_id in [video.material_id for video in self.videos] 83 | elif isinstance(item, AudioMaterial): 84 | return item.material_id in [audio.material_id for audio in self.audios] 85 | elif isinstance(item, AudioFade): 86 | return item.fade_id in [fade.fade_id for fade in self.audio_fades] 87 | elif isinstance(item, AudioEffect): 88 | return item.effect_id in [effect.effect_id for effect in self.audio_effects] 89 | elif isinstance(item, SegmentAnimations): 90 | return item.animation_id in [ani.animation_id for ani in self.animations] 91 | elif isinstance(item, VideoEffect): 92 | return item.global_id in [effect.global_id for effect in self.video_effects] 93 | elif isinstance(item, Transition): 94 | return item.global_id in [transition.global_id for transition in self.transitions] 95 | elif isinstance(item, Filter): 96 | return item.global_id in [filter_.global_id for filter_ in self.filters] 97 | else: 98 | raise TypeError("Invalid argument type '%s'" % type(item)) 99 | 100 | def export_json(self) -> Dict[str, List[Any]]: 101 | return { 102 | "ai_translates": [], 103 | "audio_balances": [], 104 | "audio_effects": [effect.export_json() for effect in self.audio_effects], 105 | "audio_fades": [fade.export_json() for fade in self.audio_fades], 106 | "audio_track_indexes": [], 107 | "audios": [audio.export_json() for audio in self.audios], 108 | "beats": [], 109 | "canvases": [canvas.export_json() for canvas in self.canvases], 110 | "chromas": [], 111 | "color_curves": [], 112 | "digital_humans": [], 113 | "drafts": [], 114 | "effects": [_filter.export_json() for _filter in self.filters], 115 | "flowers": [], 116 | "green_screens": [], 117 | "handwrites": [], 118 | "hsl": [], 119 | "images": [], 120 | "log_color_wheels": [], 121 | "loudnesses": [], 122 | "manual_deformations": [], 123 | "masks": self.masks, 124 | "material_animations": [ani.export_json() for ani in self.animations], 125 | "material_colors": [], 126 | "multi_language_refs": [], 127 | "placeholders": [], 128 | "plugin_effects": [], 129 | "primary_color_wheels": [], 130 | "realtime_denoises": [], 131 | "shapes": [], 132 | "smart_crops": [], 133 | "smart_relights": [], 134 | "sound_channel_mappings": [], 135 | "speeds": [spd.export_json() for spd in self.speeds], 136 | "stickers": self.stickers, 137 | "tail_leaders": [], 138 | "text_templates": [], 139 | "texts": self.texts, 140 | "time_marks": [], 141 | "transitions": [transition.export_json() for transition in self.transitions], 142 | "video_effects": [effect.export_json() for effect in self.video_effects], 143 | "video_trackings": [], 144 | "videos": [video.export_json() for video in self.videos], 145 | "vocal_beautifys": [], 146 | "vocal_separations": [] 147 | } 148 | 149 | class ScriptFile: 150 | """剪映草稿文件, 大部分接口定义在此""" 151 | 152 | save_path: Optional[str] 153 | """草稿文件保存路径, 仅在模板模式下有效""" 154 | content: Dict[str, Any] 155 | """草稿文件内容""" 156 | 157 | width: int 158 | """视频的宽度, 单位为像素""" 159 | height: int 160 | """视频的高度, 单位为像素""" 161 | fps: int 162 | """视频的帧率""" 163 | duration: int 164 | """视频的总时长, 单位为微秒""" 165 | 166 | materials: ScriptMaterial 167 | """草稿文件中的素材信息部分""" 168 | tracks: Dict[str, Track] 169 | """轨道信息""" 170 | 171 | imported_materials: Dict[str, List[Dict[str, Any]]] 172 | """导入的素材信息""" 173 | imported_tracks: List[ImportedTrack] 174 | """导入的轨道信息""" 175 | 176 | def __init__(self, width: int, height: int, fps: int = 30): 177 | """**创建剪映草稿推荐使用`DraftFolder.create_draft()`而非此方法** 178 | 179 | Args: 180 | width (int): 视频宽度, 单位为像素 181 | height (int): 视频高度, 单位为像素 182 | fps (int, optional): 视频帧率. 默认为30. 183 | """ 184 | self.save_path = None 185 | 186 | self.width = width 187 | self.height = height 188 | self.fps = fps 189 | self.duration = 0 190 | 191 | self.materials = ScriptMaterial() 192 | self.tracks = {} 193 | 194 | self.imported_materials = {} 195 | self.imported_tracks = [] 196 | 197 | with open(assets.get_asset_path('DRAFT_CONTENT_TEMPLATE'), "r", encoding="utf-8") as f: 198 | self.content = json.load(f) 199 | 200 | @staticmethod 201 | def load_template(json_path: str) -> "ScriptFile": 202 | """从JSON文件加载草稿模板 203 | 204 | Args: 205 | json_path (str): JSON文件路径 206 | 207 | Raises: 208 | `FileNotFoundError`: JSON文件不存在 209 | """ 210 | obj = ScriptFile(**util.provide_ctor_defaults(ScriptFile)) 211 | obj.save_path = json_path 212 | if not os.path.exists(json_path): 213 | raise FileNotFoundError("JSON文件 '%s' 不存在" % json_path) 214 | with open(json_path, "r", encoding="utf-8") as f: 215 | obj.content = json.load(f) 216 | 217 | util.assign_attr_with_json(obj, ["fps", "duration"], obj.content) 218 | util.assign_attr_with_json(obj, ["width", "height"], obj.content["canvas_config"]) 219 | 220 | obj.imported_materials = deepcopy(obj.content["materials"]) 221 | obj.imported_tracks = [import_track(track_data) for track_data in obj.content["tracks"]] 222 | 223 | return obj 224 | 225 | def add_material(self, material: Union[VideoMaterial, AudioMaterial]) -> "ScriptFile": 226 | """向草稿文件中添加一个素材""" 227 | if material in self.materials: # 素材已存在 228 | return self 229 | if isinstance(material, VideoMaterial): 230 | self.materials.videos.append(material) 231 | elif isinstance(material, AudioMaterial): 232 | self.materials.audios.append(material) 233 | else: 234 | raise TypeError("错误的素材类型: '%s'" % type(material)) 235 | return self 236 | 237 | def add_track(self, track_type: TrackType, track_name: Optional[str] = None, *, 238 | mute: bool = False, 239 | relative_index: int = 0, absolute_index: Optional[int] = None) -> "ScriptFile": 240 | """向草稿文件中添加一个指定类型、指定名称的轨道, 可以自定义轨道层级 241 | 242 | 注意: 主视频轨道(最底层的视频轨道)上的视频片段必须从0s开始, 否则会被剪映强制对齐至0s. 243 | 244 | 为避免混淆, 仅在创建第一个同类型轨道时允许不指定名称 245 | 246 | Args: 247 | track_type (TrackType): 轨道类型 248 | track_name (str, optional): 轨道名称. 仅在创建第一个同类型轨道时允许不指定. 249 | mute (bool, optional): 轨道是否静音. 默认不静音. 250 | relative_index (int, optional): 相对(同类型轨道的)图层位置, 越高越接近前景. 默认为0. 251 | absolute_index (int, optional): 绝对图层位置, 越高越接近前景. 此参数将直接覆盖相应片段的`render_index`属性, 供有经验的用户使用. 252 | 此参数不能与`relative_index`同时使用. 253 | 254 | Raises: 255 | `NameError`: 已存在同类型轨道且未指定名称, 或已存在同名轨道 256 | """ 257 | 258 | if track_name is None: 259 | if track_type in [track.track_type for track in self.tracks.values()]: 260 | raise NameError("'%s' 类型的轨道已存在, 请为新轨道指定名称以避免混淆" % track_type) 261 | track_name = track_type.name 262 | if track_name in [track.name for track in self.tracks.values()]: 263 | raise NameError("名为 '%s' 的轨道已存在" % track_name) 264 | 265 | render_index = track_type.value.render_index + relative_index 266 | if absolute_index is not None: 267 | render_index = absolute_index 268 | 269 | self.tracks[track_name] = Track(track_type, track_name, render_index, mute) 270 | return self 271 | 272 | def _get_track(self, segment_type: Type[BaseSegment], track_name: Optional[str]) -> Track: 273 | # 指定轨道名称 274 | if track_name is not None: 275 | if track_name not in self.tracks: 276 | raise NameError("不存在名为 '%s' 的轨道" % track_name) 277 | return self.tracks[track_name] 278 | # 寻找唯一的同类型的轨道 279 | count = sum([1 for track in self.tracks.values() if track.accept_segment_type == segment_type]) 280 | if count == 0: raise NameError("不存在接受 '%s' 的轨道" % segment_type) 281 | if count > 1: raise NameError("存在多个接受 '%s' 的轨道, 请指定轨道名称" % segment_type) 282 | 283 | return next(track for track in self.tracks.values() if track.accept_segment_type == segment_type) 284 | 285 | def add_segment(self, segment: Union[VideoSegment, StickerSegment, AudioSegment, TextSegment], 286 | track_name: Optional[str] = None) -> "ScriptFile": 287 | """向指定轨道中添加一个片段 288 | 289 | Args: 290 | segment (`VideoSegment`, `StickerSegment`, `AudioSegment`, or `TextSegment`): 要添加的片段 291 | track_name (`str`, optional): 添加到的轨道名称. 当此类型的轨道仅有一条时可省略. 292 | 293 | Raises: 294 | `NameError`: 未找到指定名称的轨道, 或必须提供`track_name`参数时未提供 295 | `TypeError`: 片段类型不匹配轨道类型 296 | `SegmentOverlap`: 新片段与已有片段重叠 297 | """ 298 | target = self._get_track(type(segment), track_name) 299 | 300 | # 加入轨道并更新时长 301 | target.add_segment(segment) 302 | self.duration = max(self.duration, segment.end) 303 | 304 | # 自动添加相关素材 305 | if isinstance(segment, VideoSegment): 306 | # 出入场等动画 307 | if (segment.animations_instance is not None) and (segment.animations_instance not in self.materials): 308 | self.materials.animations.append(segment.animations_instance) 309 | # 特效 310 | for effect in segment.effects: 311 | if effect not in self.materials: 312 | self.materials.video_effects.append(effect) 313 | # 滤镜 314 | for filter_ in segment.filters: 315 | if filter_ not in self.materials: 316 | self.materials.filters.append(filter_) 317 | # 蒙版 318 | if segment.mask is not None: 319 | self.materials.masks.append(segment.mask.export_json()) 320 | # 转场 321 | if (segment.transition is not None) and (segment.transition not in self.materials): 322 | self.materials.transitions.append(segment.transition) 323 | # 背景填充 324 | if segment.background_filling is not None: 325 | self.materials.canvases.append(segment.background_filling) 326 | 327 | self.materials.speeds.append(segment.speed) 328 | elif isinstance(segment, StickerSegment): 329 | self.materials.stickers.append(segment.export_material()) 330 | elif isinstance(segment, AudioSegment): 331 | # 淡入淡出 332 | if (segment.fade is not None) and (segment.fade not in self.materials): 333 | self.materials.audio_fades.append(segment.fade) 334 | # 特效 335 | for effect in segment.effects: 336 | if effect not in self.materials: 337 | self.materials.audio_effects.append(effect) 338 | self.materials.speeds.append(segment.speed) 339 | elif isinstance(segment, TextSegment): 340 | # 出入场等动画 341 | if (segment.animations_instance is not None) and (segment.animations_instance not in self.materials): 342 | self.materials.animations.append(segment.animations_instance) 343 | # 气泡效果 344 | if segment.bubble is not None: 345 | self.materials.filters.append(segment.bubble) 346 | # 花字效果 347 | if segment.effect is not None: 348 | self.materials.filters.append(segment.effect) 349 | # 字体样式 350 | self.materials.texts.append(segment.export_material()) 351 | 352 | # 添加片段素材 353 | if isinstance(segment, (VideoSegment, AudioSegment)): 354 | self.add_material(segment.material_instance) 355 | 356 | return self 357 | 358 | def add_effect(self, effect: Union[VideoSceneEffectType, VideoCharacterEffectType], 359 | t_range: Timerange, track_name: Optional[str] = None, *, 360 | params: Optional[List[Optional[float]]] = None) -> "ScriptFile": 361 | """向指定的特效轨道中添加一个特效片段 362 | 363 | Args: 364 | effect (`VideoSceneEffectType` or `VideoCharacterEffectType`): 特效类型 365 | t_range (`Timerange`): 特效片段的时间范围 366 | track_name (`str`, optional): 添加到的轨道名称. 当特效轨道仅有一条时可省略. 367 | params (`List[Optional[float]]`, optional): 特效参数列表, 参数列表中未提供或为None的项使用默认值. 368 | 参数取值范围(0~100)与剪映中一致. 某个特效类型有何参数以及具体参数顺序以枚举类成员的annotation为准. 369 | 370 | Raises: 371 | `NameError`: 未找到指定名称的轨道, 或必须提供`track_name`参数时未提供 372 | `TypeError`: 指定的轨道不是特效轨道 373 | `ValueError`: 新片段与已有片段重叠、提供的参数数量超过了该特效类型的参数数量, 或参数值超出范围. 374 | """ 375 | target = self._get_track(EffectSegment, track_name) 376 | 377 | # 加入轨道并更新时长 378 | segment = EffectSegment(effect, t_range, params) 379 | target.add_segment(segment) 380 | self.duration = max(self.duration, t_range.start + t_range.duration) 381 | 382 | # 自动添加相关素材 383 | if segment.effect_inst not in self.materials: 384 | self.materials.video_effects.append(segment.effect_inst) 385 | return self 386 | 387 | def add_filter(self, filter_meta: FilterType, t_range: Timerange, 388 | track_name: Optional[str] = None, intensity: float = 100.0) -> "ScriptFile": 389 | """向指定的滤镜轨道中添加一个滤镜片段 390 | 391 | Args: 392 | filter_meta (`FilterType`): 滤镜类型 393 | t_range (`Timerange`): 滤镜片段的时间范围 394 | track_name (`str`, optional): 添加到的轨道名称. 当滤镜轨道仅有一条时可省略. 395 | intensity (`float`, optional): 滤镜强度(0-100). 仅当所选滤镜能够调节强度时有效. 默认为100. 396 | 397 | Raises: 398 | `NameError`: 未找到指定名称的轨道, 或必须提供`track_name`参数时未提供 399 | `TypeError`: 指定的轨道不是滤镜轨道 400 | `ValueError`: 新片段与已有片段重叠 401 | """ 402 | target = self._get_track(FilterSegment, track_name) 403 | 404 | # 加入轨道并更新时长 405 | segment = FilterSegment(filter_meta, t_range, intensity / 100.0) # 转换为0-1范围 406 | target.add_segment(segment) 407 | self.duration = max(self.duration, t_range.end) 408 | 409 | # 自动添加相关素材 410 | self.materials.filters.append(segment.material) 411 | return self 412 | 413 | def import_srt(self, srt_path: str, track_name: str, *, 414 | time_offset: Union[str, float] = 0.0, 415 | style_reference: Optional[TextSegment] = None, 416 | text_style: TextStyle = TextStyle(size=5, align=1, auto_wrapping=True), 417 | clip_settings: Optional[ClipSettings] = ClipSettings(transform_y=-0.8)) -> "ScriptFile": 418 | """从SRT文件中导入字幕, 支持传入一个`TextSegment`作为样式参考 419 | 420 | 注意: 默认不会使用参考片段的`clip_settings`属性, 若需要请显式为此函数传入`clip_settings=None` 421 | 422 | Args: 423 | srt_path (`str`): SRT文件路径 424 | track_name (`str`): 导入到的文本轨道名称, 若不存在则自动创建 425 | style_reference (`TextSegment`, optional): 作为样式参考的文本片段, 若提供则使用其样式. 426 | time_offset (`Union[str, float]`, optional): 字幕整体时间偏移, 单位为微秒, 默认为0. 427 | text_style (`TextStyle`, optional): 字幕样式, 默认模仿剪映导入字幕时的样式, 会被`style_reference`覆盖. 428 | clip_settings (`ClipSettings`, optional): 图像调节设置, 默认模仿剪映导入字幕时的设置, 会覆盖`style_reference`的设置除非指定为`None`. 429 | 430 | Raises: 431 | `NameError`: 已存在同名轨道 432 | `TypeError`: 轨道类型不匹配 433 | """ 434 | if style_reference is None and clip_settings is None: 435 | raise ValueError("未提供样式参考时请提供`clip_settings`参数") 436 | 437 | time_offset = tim(time_offset) 438 | if track_name not in self.tracks: 439 | self.add_track(TrackType.text, track_name, relative_index=999) # 在所有文本轨道的最上层 440 | 441 | with open(srt_path, "r", encoding="utf-8-sig") as srt_file: 442 | lines = srt_file.readlines() 443 | 444 | def __add_text_segment(text: str, t_range: Timerange) -> None: 445 | if style_reference: 446 | seg = TextSegment.create_from_template(text, t_range, style_reference) 447 | if clip_settings is not None: 448 | seg.clip_settings = deepcopy(clip_settings) 449 | else: 450 | seg = TextSegment(text, t_range, style=text_style, clip_settings=clip_settings) 451 | self.add_segment(seg, track_name) 452 | 453 | index = 0 454 | text: str = "" 455 | text_trange: Timerange 456 | read_state: Literal["index", "timestamp", "content"] = "index" 457 | while index < len(lines): 458 | line = lines[index].strip() 459 | if read_state == "index": 460 | if len(line) == 0: 461 | index += 1 462 | continue 463 | if not line.isdigit(): 464 | raise ValueError("Expected a number at line %d, got '%s'" % (index+1, line)) 465 | index += 1 466 | read_state = "timestamp" 467 | elif read_state == "timestamp": 468 | # 读取时间戳 469 | start_str, end_str = line.split(" --> ") 470 | start, end = srt_tstamp(start_str), srt_tstamp(end_str) 471 | text_trange = Timerange(start + time_offset, end - start) 472 | 473 | index += 1 474 | read_state = "content" 475 | elif read_state == "content": 476 | # 内容结束, 生成片段 477 | if len(line) == 0: 478 | __add_text_segment(text.strip(), text_trange) 479 | 480 | text = "" 481 | read_state = "index" 482 | else: 483 | text += line + "\n" 484 | index += 1 485 | 486 | # 添加最后一个片段 487 | if len(text) > 0: 488 | __add_text_segment(text.strip(), text_trange) 489 | 490 | return self 491 | 492 | def get_imported_track(self, track_type: Literal[TrackType.video, TrackType.audio, TrackType.text], 493 | name: Optional[str] = None, index: Optional[int] = None) -> EditableTrack: 494 | """获取指定类型的导入轨道, 以便在其上进行替换 495 | 496 | 推荐使用轨道名称进行筛选(若已知轨道名称) 497 | 498 | Args: 499 | track_type (`TrackType.video`, `TrackType.audio` or `TrackType.text`): 轨道类型, 目前只支持音视频和文本轨道 500 | name (`str`, optional): 轨道名称, 不指定则不根据名称筛选. 501 | index (`int`, optional): 轨道在**同类型的导入轨道**中的下标, 以0为最下层轨道. 不指定则不根据下标筛选. 502 | 503 | Raises: 504 | `TrackNotFound`: 未找到满足条件的轨道 505 | `AmbiguousTrack`: 找到多个满足条件的轨道 506 | """ 507 | tracks_of_same_type: List[EditableTrack] = [] 508 | for track in self.imported_tracks: 509 | if track.track_type == track_type: 510 | assert isinstance(track, EditableTrack) 511 | tracks_of_same_type.append(track) 512 | 513 | ret: List[EditableTrack] = [] 514 | for ind, track in enumerate(tracks_of_same_type): 515 | if (name is not None) and (track.name != name): continue 516 | if (index is not None) and (ind != index): continue 517 | ret.append(track) 518 | 519 | if len(ret) == 0: 520 | raise exceptions.TrackNotFound( 521 | "没有找到满足条件的轨道: track_type=%s, name=%s, index=%s" % (track_type, name, index)) 522 | if len(ret) > 1: 523 | raise exceptions.AmbiguousTrack( 524 | "找到多个满足条件的轨道: track_type=%s, name=%s, index=%s" % (track_type, name, index)) 525 | 526 | return ret[0] 527 | 528 | def import_track(self, source_file: "ScriptFile", track: EditableTrack, *, 529 | offset: Union[str, int] = 0, 530 | new_name: Optional[str] = None, relative_index: Optional[int] = None) -> "ScriptFile": 531 | """将一个`EditableTrack`导入到当前`ScriptFile`中, 如从模板草稿中导入特定的文本或视频轨道到当前正在编辑的草稿文件中 532 | 533 | 注意: 本方法会保留各片段及其素材的id, 因而不支持向同一草稿多次导入同一轨道 534 | 535 | Args: 536 | source_file (`ScriptFile`): 源文件,包含要导入的轨道 537 | track (`EditableTrack`): 要导入的轨道, 可通过`get_imported_track`方法获取. 538 | offset (`str | int`, optional): 轨道的时间偏移量(微秒), 可以是整数微秒值或时间字符串(如"1s"). 默认不添加偏移. 539 | new_name (`str`, optional): 新轨道名称, 默认使用源轨道名称. 540 | relative_index (`int`, optional): 相对索引,用于调整导入轨道的渲染层级. 默认保持原有层级. 541 | """ 542 | # 直接拷贝原始轨道结构, 按需修改渲染层级 543 | imported_track = deepcopy(track) 544 | if relative_index is not None: 545 | imported_track.render_index = track.track_type.value.render_index + relative_index 546 | if new_name is not None: 547 | imported_track.name = new_name 548 | 549 | # 应用偏移量 550 | offset_us = tim(offset) 551 | if offset_us != 0: 552 | for seg in imported_track.segments: 553 | seg.target_timerange.start = max(0, seg.target_timerange.start + offset_us) 554 | self.imported_tracks.append(imported_track) 555 | 556 | # 收集所有需要复制的素材ID 557 | material_ids = set() 558 | segments: List[Dict[str, Any]] = track.raw_data.get("segments", []) 559 | for segment in segments: 560 | # 主素材ID 561 | material_id = segment.get("material_id") 562 | if material_id: 563 | material_ids.add(material_id) 564 | 565 | # extra_material_refs中的素材ID 566 | extra_refs: List[str] = segment.get("extra_material_refs", []) 567 | material_ids.update(extra_refs) 568 | 569 | # 复制素材 570 | for material_type, material_list in source_file.imported_materials.items(): 571 | for material in material_list: 572 | if material.get("id") in material_ids: 573 | if material_type not in self.imported_materials: 574 | self.imported_materials[material_type] = [] 575 | self.imported_materials[material_type].append(deepcopy(material)) 576 | material_ids.remove(material.get("id")) 577 | 578 | assert len(material_ids) == 0, "未找到以下素材: %s" % material_ids 579 | 580 | # 更新总时长 581 | self.duration = max(self.duration, track.end_time) 582 | 583 | return self 584 | 585 | def replace_material_by_name(self, material_name: str, material: Union[VideoMaterial, AudioMaterial], 586 | replace_crop: bool = False) -> "ScriptFile": 587 | """替换指定名称的素材, 并影响所有引用它的片段 588 | 589 | 这种方法不会改变相应片段的时长和引用范围(`source_timerange`), 尤其适合于图片素材 590 | 591 | Args: 592 | material_name (`str`): 要替换的素材名称 593 | material (`VideoMaterial` or `AudioMaterial`): 新素材, 目前只支持视频和音频 594 | replace_crop (`bool`, optional): 是否替换原素材的裁剪设置, 默认为否. 仅对视频素材有效. 595 | 596 | Raises: 597 | `MaterialNotFound`: 根据指定名称未找到与新素材同类的素材 598 | `AmbiguousMaterial`: 根据指定名称找到多个与新素材同类的素材 599 | """ 600 | video_mode = isinstance(material, VideoMaterial) 601 | # 查找素材 602 | target_json_obj: Optional[Dict[str, Any]] = None 603 | target_material_list = self.imported_materials["videos" if video_mode else "audios"] 604 | name_key = "material_name" if video_mode else "name" 605 | for mat in target_material_list: 606 | if mat[name_key] == material_name: 607 | if target_json_obj is not None: 608 | raise exceptions.AmbiguousMaterial( 609 | "找到多个名为 '%s', 类型为 '%s' 的素材" % (material_name, type(material))) 610 | target_json_obj = mat 611 | if target_json_obj is None: 612 | raise exceptions.MaterialNotFound("没有找到名为 '%s', 类型为 '%s' 的素材" % (material_name, type(material))) 613 | 614 | # 更新素材信息 615 | target_json_obj.update({name_key: material.material_name, "path": material.path, "duration": material.duration}) 616 | if video_mode: 617 | target_json_obj.update({"width": material.width, "height": material.height, "material_type": material.material_type}) 618 | if replace_crop: 619 | target_json_obj.update({"crop": material.crop_settings.export_json()}) 620 | 621 | return self 622 | 623 | def get_video_segment_by_id(self, segment_id: str) -> Optional[Union[VideoSegment, "ImportedMediaSegment"]]: 624 | """根据片段ID获取VideoSegment对象(包括新创建的和导入的) 625 | 626 | Args: 627 | segment_id (str): 片段的唯一ID 628 | 629 | Returns: 630 | Optional[Union[VideoSegment, ImportedMediaSegment]]: 找到的VideoSegment对象,如果未找到则返回None 631 | """ 632 | # 首先在新创建的轨道中查找 633 | for track in self.imported_tracks.values(): 634 | if track.track_type == TrackType.video: 635 | for segment in track.segments: 636 | if isinstance(segment, VideoSegment) and segment.segment_id == segment_id: 637 | return segment 638 | 639 | # 然后在导入的轨道中查找 640 | for track in self.imported_tracks: 641 | if track.track_type == TrackType.video: 642 | for segment in track.segments: 643 | # 对于ImportedMediaSegment,segment_id存储在raw_data的"id"字段中 644 | if hasattr(segment, 'raw_data') and segment.raw_data.get("id") == segment_id: 645 | return segment 646 | # 也检查是否有segment_id属性(以防万一) 647 | elif hasattr(segment, 'segment_id') and segment.segment_id == segment_id: 648 | return segment 649 | 650 | return None 651 | 652 | def get_all_video_segments(self) -> List[Tuple[str, Union[VideoSegment, "ImportedMediaSegment"]]]: 653 | """获取所有VideoSegment及其ID(包括新创建的和导入的) 654 | 655 | Returns: 656 | List[Tuple[str, Union[VideoSegment, ImportedMediaSegment]]]: (segment_id, VideoSegment)的列表 657 | """ 658 | segments = [] 659 | 660 | # 获取新创建的轨道中的视频片段 661 | for track in self.tracks.values(): 662 | if track.track_type == TrackType.video: 663 | for segment in track.segments: 664 | if isinstance(segment, VideoSegment): 665 | segments.append((segment.segment_id, segment)) 666 | 667 | # 获取导入的轨道中的视频片段 668 | for track in self.imported_tracks: 669 | if track.track_type == TrackType.video: 670 | for segment in track.segments: 671 | # 对于ImportedMediaSegment,从raw_data中获取segment_id 672 | if hasattr(segment, 'raw_data'): 673 | segment_id = segment.raw_data.get("id") 674 | if segment_id: 675 | segments.append((segment_id, segment)) 676 | # 也检查是否有segment_id属性(以防万一) 677 | elif hasattr(segment, 'segment_id'): 678 | segments.append((segment.segment_id, segment)) 679 | 680 | return segments 681 | 682 | def list_video_segments(self) -> None: 683 | """打印所有VideoSegment的ID和基本信息(包括新创建的和导入的)""" 684 | segments = self.get_all_video_segments() 685 | for i, (segment_id, segment) in enumerate(segments): 686 | if isinstance(segment, VideoSegment): 687 | # 新创建的VideoSegment 688 | print(f"{i}: ID={segment_id}, 时间范围={segment.target_timerange}, 素材={segment.material_instance.path}") 689 | else: 690 | # 导入的ImportedMediaSegment 691 | print(f"{i}: ID={segment_id}, 时间范围={segment.target_timerange}, 素材=导入的视频片段") 692 | 693 | def replace_material_by_seg(self, track: EditableTrack, segment_index: int, material: Union[VideoMaterial, AudioMaterial], 694 | source_timerange: Optional[Timerange] = None, *, 695 | handle_shrink: ShrinkMode = ShrinkMode.cut_tail, 696 | handle_extend: Union[ExtendMode, List[ExtendMode]] = ExtendMode.cut_material_tail) -> "ScriptFile": 697 | """替换指定音视频轨道上指定片段的素材, 暂不支持变速片段的素材替换 698 | 699 | Args: 700 | track (`EditableTrack`): 要替换素材的轨道, 由`get_imported_track`获取 701 | segment_index (`int`): 要替换素材的片段下标, 从0开始 702 | material (`VideoMaterial` or `AudioMaterial`): 新素材, 必须与原素材类型一致 703 | source_timerange (`Timerange`, optional): 从原素材中截取的时间范围, 默认为全时段, 若是图片素材则默认与原片段等长. 704 | handle_shrink (`Shrink_mode`, optional): 新素材比原素材短时的处理方式, 默认为裁剪尾部, 使片段长度与素材一致. 705 | handle_extend (`Extend_mode` or `List[Extend_mode]`, optional): 新素材比原素材长时的处理方式, 将按顺序逐个尝试直至成功或抛出异常. 706 | 默认为截断素材尾部, 使片段维持原长不变 707 | 708 | Raises: 709 | `IndexError`: `segment_index`越界 710 | `TypeError`: 轨道或素材类型不正确 711 | `ExtensionFailed`: 新素材比原素材长时处理失败 712 | """ 713 | if not isinstance(track, ImportedMediaTrack): 714 | raise TypeError("指定的轨道(类型为 %s)不支持素材替换" % track.track_type) 715 | if not 0 <= segment_index < len(track): 716 | raise IndexError("片段下标 %d 超出 [0, %d) 的范围" % (segment_index, len(track))) 717 | if not track.check_material_type(material): 718 | raise TypeError("指定的素材类型 %s 不匹配轨道类型 %s", (type(material), track.track_type)) 719 | seg = track.segments[segment_index] 720 | 721 | if isinstance(handle_extend, ExtendMode): 722 | handle_extend = [handle_extend] 723 | if source_timerange is None: 724 | if isinstance(material, VideoMaterial) and (material.material_type == "photo"): 725 | source_timerange = Timerange(0, seg.duration) 726 | else: 727 | source_timerange = Timerange(0, material.duration) 728 | 729 | # 处理时间变化 730 | track.process_timerange(segment_index, source_timerange, handle_shrink, handle_extend) 731 | 732 | # 最后替换素材链接 733 | track.segments[segment_index].material_id = material.material_id 734 | self.add_material(material) 735 | 736 | # TODO: 更新总长 737 | return self 738 | 739 | def replace_text(self, track: EditableTrack, segment_index: int, text: Union[str, List[str]], 740 | recalc_style: bool = True) -> "ScriptFile": 741 | """替换指定文本轨道上指定片段的文字内容, 支持普通文本片段或文本模板片段 742 | 743 | Args: 744 | track (`EditableTrack`): 要替换文字的文本轨道, 由`get_imported_track`获取 745 | segment_index (`int`): 要替换文字的片段下标, 从0开始 746 | text (`str` or `List[str]`): 新的文字内容, 对于文本模板而言应传入一个字符串列表. 747 | recalc_style (`bool`): 是否重新计算字体样式分布, 即调整各字体样式应用范围以尽量维持原有占比不变, 默认开启. 748 | 749 | Raises: 750 | `IndexError`: `segment_index`越界 751 | `TypeError`: 轨道类型不正确 752 | `ValueError`: 文本模板片段的文本数量不匹配 753 | """ 754 | if not isinstance(track, ImportedTextTrack): 755 | raise TypeError("指定的轨道(类型为 %s)不支持文本内容替换" % track.track_type) 756 | if not 0 <= segment_index < len(track): 757 | raise IndexError("片段下标 %d 超出 [0, %d) 的范围" % (segment_index, len(track))) 758 | 759 | def __recalc_style_range(old_len: int, new_len: int, styles: List[Dict[str, Any]]) -> List[Dict[str, Any]]: 760 | """调整字体样式分布""" 761 | new_styles: List[Dict[str, Any]] = [] 762 | for style in styles: 763 | start = math.ceil(style["range"][0] / old_len * new_len) 764 | end = math.ceil(style["range"][1] / old_len * new_len) 765 | style["range"] = [start, end] 766 | if start != end: 767 | new_styles.append(style) 768 | return new_styles 769 | 770 | replaced: bool = False 771 | material_id: str = track.segments[segment_index].material_id 772 | # 尝试在文本素材中替换 773 | for mat in self.imported_materials["texts"]: 774 | if mat["id"] != material_id: 775 | continue 776 | 777 | if isinstance(text, list): 778 | if len(text) != 1: 779 | raise ValueError(f"正常文本片段只能有一个文字内容, 但替换内容是 {text}") 780 | text = text[0] 781 | 782 | content = json.loads(mat["content"]) 783 | if recalc_style: 784 | content["styles"] = __recalc_style_range(len(content["text"]), len(text), content["styles"]) 785 | content["text"] = text 786 | mat["content"] = json.dumps(content, ensure_ascii=False) 787 | replaced = True 788 | break 789 | if replaced: 790 | return self 791 | 792 | # 尝试在文本模板中替换 793 | for template in self.imported_materials["text_templates"]: 794 | if template["id"] != material_id: 795 | continue 796 | 797 | resources = template["text_info_resources"] 798 | if isinstance(text, str): 799 | text = [text] 800 | if len(text) > len(resources): 801 | raise ValueError(f"文字模板'{template['name']}'只有{len(resources)}段文本, 但提供了{len(text)}段替换内容") 802 | 803 | for sub_material_id, new_text in zip(map(lambda x: x["text_material_id"], resources), text): 804 | for mat in self.imported_materials["texts"]: 805 | if mat["id"] != sub_material_id: 806 | continue 807 | 808 | try: 809 | content = json.loads(mat["content"]) 810 | if recalc_style: 811 | content["styles"] = __recalc_style_range(len(content["text"]), len(new_text), content["styles"]) 812 | content["text"] = new_text 813 | mat["content"] = json.dumps(content, ensure_ascii=False) 814 | except json.JSONDecodeError: 815 | mat["content"] = new_text 816 | except TypeError: 817 | mat["content"] = new_text 818 | 819 | break 820 | replaced = True 821 | break 822 | 823 | assert replaced, f"未找到指定片段的素材 {material_id}" 824 | 825 | return self 826 | 827 | def inspect_material(self) -> None: 828 | """输出草稿中导入的贴纸、文本气泡以及花字素材的元数据""" 829 | print("贴纸素材:") 830 | for sticker in self.imported_materials["stickers"]: 831 | print("\tResource id: %s '%s'" % (sticker["resource_id"], sticker.get("name", ""))) 832 | 833 | print("文字气泡效果:") 834 | for effect in self.imported_materials["effects"]: 835 | if effect["type"] == "text_shape": 836 | print("\tEffect id: %s ,Resource id: %s '%s'" % 837 | (effect["effect_id"], effect["resource_id"], effect.get("name", ""))) 838 | 839 | print("花字效果:") 840 | for effect in self.imported_materials["effects"]: 841 | if effect["type"] == "text_effect": 842 | print("\tResource id: %s '%s'" % (effect["resource_id"], effect.get("name", ""))) 843 | 844 | def dumps(self) -> str: 845 | """将草稿文件内容导出为JSON字符串""" 846 | self.content["fps"] = self.fps 847 | self.content["duration"] = self.duration 848 | self.content["canvas_config"] = {"width": self.width, "height": self.height, "ratio": "original"} 849 | self.content["materials"] = self.materials.export_json() 850 | 851 | # 合并导入的素材 852 | for material_type, material_list in self.imported_materials.items(): 853 | if material_type not in self.content["materials"]: 854 | self.content["materials"][material_type] = material_list 855 | else: 856 | self.content["materials"][material_type].extend(material_list) 857 | 858 | # 对轨道排序并导出 859 | track_list: List[BaseTrack] = list(self.imported_tracks + list(self.tracks.values())) # 新加入的轨道在列表末尾(上层) 860 | track_list.sort(key=lambda track: track.render_index) 861 | self.content["tracks"] = [track.export_json() for track in track_list] 862 | 863 | return json.dumps(self.content, ensure_ascii=False, indent=4) 864 | 865 | def dump(self, file_path: str) -> None: 866 | """将草稿文件内容写入文件""" 867 | with open(file_path, "w", encoding="utf-8") as f: 868 | f.write(self.dumps()) 869 | 870 | def save(self) -> None: 871 | """保存草稿文件至打开时的路径 872 | 873 | Raises: 874 | `ValueError`: 没有设置保存路径 875 | """ 876 | if self.save_path is None: 877 | raise ValueError("没有设置保存路径, 可能不在模板模式下") 878 | self.dump(self.save_path) 879 | ```