# -*- coding: utf-8 -*- import random import subprocess import time from pathlib import Path from typing import Any, Dict, Optional import pyJianYingDraft import win32con import win32gui from draft import JianYingDraft class JianYingExport: """ 封装 pyJianYing中导出草稿的相关功能,支持: 1、初始化素材文件夹内所有素材 2、就工作流添加工作 3、基于工作流生成草稿 """ # noinspection PyShadowingNames def __init__( self, materials_folder_path: str, program_path: str = r"E:\JianyingPro\5.9.0.11632\JianyingPro.exe", drafts_folder_path: str = r"E:\JianyingPro Drafts", draft_counts: int = 10, video_width: int = 1080, video_height: int = 1920, video_fps: int = 30, ): """ 初始化 :param program_path: 剪映执行程序路径 :param drafts_folder_path: 剪映草稿文件夹路径 :param materials_folder_path: 素材文件夹路径 :param draft_counts: 草稿数,默认为 10 :param video_width: 视频宽度,默认为 1080像素 :param video_height: 视频高度,默认为 1920像素 :param video_fps: 视频帧率(单位为帧/秒),默认为 30 """ # noinspection PyBroadException try: self.program_path = Path(program_path) if not self.program_path.exists(): raise RuntimeError("剪映执行程序路径不存在") # 初始化剪映专业版进程 self.jianying_process = None self.drafts_folder_path = Path(drafts_folder_path) if not self.drafts_folder_path.exists(): raise RuntimeError("剪映草稿文件夹路径不存在") # 初始化草稿文件夹管理器 self.drafts_folder = pyJianYingDraft.DraftFolder( folder_path=self.drafts_folder_path.as_posix() ) self.materials_folder_path = Path(materials_folder_path) if not self.materials_folder_path.exists(): raise RuntimeError("素材文件夹路径不存在") # 初始化导出文件夹路径 self.exports_folder_path = Path( self.materials_folder_path.as_posix().replace("materials", "exports") ) self.exports_folder_path.mkdir() # 若导出文件夹存在则抛出异常,需手动处理 self.materials = {} # 初始化素材文件夹内所有素材 self._init_materials() # 构建项目名称 self.project_name = self.materials_folder_path.stem # 初始化工作流,其目的是按照工作流和工作配置拼接素材,先生成草稿再导出 self.workflow = [ "add_subtitles", "add_background_video", "add_statement", "add_sticker1", "add_sticker2", "save", ] # 初始化工作配置 self.configuration = { "add_subtitles": { "text": self.materials["subtitles_text"], "timbre": [ "zh-CN-XiaoxiaoNeural", "zh-CN-XiaoyiNeural", "zh-CN-YunjianNeural", "zh-CN-YunxiNeural", "zh-CN-YunxiaNeural", "zh-CN-YunyangNeural", ], # 音色 "style": [ {"size": 9.0}, {"size": 10.0}, {"size": 11.0}, ], # 字体样式 "effect": [ {"effect_id": "7127561998556089631"}, {"effect_id": "7166467215410187552"}, {"effect_id": "6896138122774498567"}, {"effect_id": "7166469374507765031"}, {"effect_id": "6896137924853763336"}, {"effect_id": "6896137990788091143"}, {"effect_id": "7127614731187211551"}, {"effect_id": "7127823362356743461"}, {"effect_id": "7127653467555990821"}, {"effect_id": "7127828216647011592"}, ], # 花字设置 }, # 添加字幕工作配置 "add_background_video": { "track_name": ["background_video"], "material_path": self.materials["background_video_material_path"], "volume": [0.3, 0.4, 0.5], "clip_settings": [ None, { "transform_x": 0.1, }, { "transform_x": 0.2, }, { "transform_x": -0.1, }, { "transform_x": -0.2, }, { "transform_y": 0.1, }, { "transform_y": 0.2, }, { "transform_y": -0.1, }, { "transform_y": -0.2, }, { "transform_x": 0.1, "transform_y": 0.1, }, { "transform_x": 0.1, "transform_y": -0.1, }, { "transform_x": -0.1, "transform_y": 0.1, }, { "transform_x": -0.1, "transform_y": -0.1, }, { "transform_x": 0.2, "transform_y": 0.2, }, { "transform_x": 0.2, "transform_y": -0.2, }, { "transform_x": -0.2, "transform_y": 0.2, }, { "transform_x": -0.2, "transform_y": -0.2, }, ], # 图像调节设置 }, # 添加背景视频工作配置 "add_logo": { "track_name": ["logo"], "material_path": self.materials["logo_material_path"], "clip_settings": [ { "scale_x": 0.2, "scale_y": 0.2, "transform_x": -0.78, "transform_y": 0.82, }, { "scale_x": 0.2, "scale_y": 0.2, "transform_x": -0.68, "transform_y": 0.82, }, { "scale_x": 0.2, "scale_y": 0.2, "transform_x": 0, "transform_y": 0.82, }, { "scale_x": 0.2, "scale_y": 0.2, "transform_x": 0.68, "transform_y": 0.82, }, { "scale_x": 0.2, "scale_y": 0.2, "transform_x": 0.78, "transform_y": 0.82, }, ], }, # 添加标识工作配置 "add_statement": { "track_name": ["statement"], "text": self.materials["statement_text"], "style": [ {"size": 6.0, "align": 1, "vertical": True}, {"size": 7.0, "align": 1, "vertical": True}, {"size": 8.0, "align": 1, "vertical": True}, ], # 文本样式 "border": [ {"width": 40.0}, {"width": 44.0}, {"width": 50.0}, {"width": 55.0}, {"width": 60.0}, ], # 描边宽度 "clip_settings": [ { "transform_x": -0.80, }, { "transform_x": -0.82, }, { "transform_x": -0.84, }, ], # 图像调节设置 }, # 添加声明工作配置 "add_sticker1": { "track_name": ["sticker1"], "resource_id": [ "7110124379568098568", "7019687632804334861", "6895933678262750478", "7010558788675652900", "7026858083393588487", "7222940306558209336", "7120543009489341727", "6939830545673227557", "6939826722451754271", "7210221631132749093", "7138432572488453408", "7137700067338620192", "6895924436822674696", "7134644683506044163", "7062539853430279437", ], "clip_settings": [ { "scale_x": 0.75, "scale_y": 0.75, "transform_x": -0.75, "transform_y": 0.75, }, { "scale_x": 0.75, "scale_y": 0.75, "transform_y": 0.75, }, { "scale_x": 0.75, "scale_y": 0.75, "transform_x": 0.75, "transform_y": 0.75, }, ], # 图像调节设置 }, # 添加贴纸工作配置1(不包含箭头类) "add_sticker2": { "track_name": ["sticker2"], "resource_id": [ "7143078914989018379", "7142870400358255905", "7185568038027103544", "7024342011440319781", "7205042602184363322", ], "clip_settings": [ { "scale_x": 0.75, "scale_y": 0.75, "transform_x": -0.8, "transform_y": -0.62, }, ], # 图像调节设置 }, # 添加贴纸工作配置2(箭头类) } self.video_width, self.video_height = video_width, video_height self.video_fps = video_fps self.draft_counts = draft_counts # 初始化所有草稿名称 self.draft_names = [] except Exception as exception: raise RuntimeError(f"发生异常:{str(exception)}") def _init_materials(self) -> None: """ 初始化素材文件夹内所有素材 :return: 无 """ # 字幕文本 subtitles_path = self.materials_folder_path / "字幕文本.txt" if subtitles_path.exists() and subtitles_path.is_file(): with open(subtitles_path, "r", encoding="utf-8") as file: subtitles_text = file.readlines() if not subtitles_text: raise RuntimeError("字幕文本为空") self.materials["subtitles_text"] = subtitles_text else: raise RuntimeError("字幕文本不存在") # 背景视频 background_videos_path = self.materials_folder_path / "背景视频" if background_videos_path.exists() and background_videos_path.is_dir(): background_video_material_path = [ file_path for file_path in background_videos_path.rglob("*.mp4") if file_path.is_file() ] if not background_video_material_path: raise RuntimeError("背景视频为空") self.materials["background_video_material_path"] = ( background_video_material_path ) else: raise RuntimeError("背景视频文件夹不存在") # 标识 logo_path = self.materials_folder_path / "标识.png" if logo_path.exists() and logo_path.is_file(): self.materials["logo_material_path"] = [logo_path] # 有且只有一张标识 else: raise RuntimeError("标识不存在") # 声明文本 statement_path = self.materials_folder_path / "声明文本.txt" if statement_path.exists() and statement_path.is_file(): with open(statement_path, "r", encoding="utf-8") as file: statement_text = file.readlines() if not statement_text: raise RuntimeError("声明文本为空") self.materials["statement_text"] = statement_text else: raise RuntimeError("声明不存在") def export(self, batch_draft_counts: int = 1): """ 导出草稿 :param batch_draft_counts: 每批次导出草稿数 """ # 按照工作流和工作配置拼接素材,生成草稿 self._generate() # 批次导出 for batch_start in range(0, self.draft_counts, batch_draft_counts): # 当前批次所有草稿名称 batch_draft_names = self.draft_names[ batch_start : batch_start + batch_draft_counts ] # 启动剪映专业版进程 self._start_process() time.sleep(2) # 初始化剪映控制器 jianying_controller = pyJianYingDraft.JianyingController() for draft_name in batch_draft_names: print(f"正在导出 {draft_name}...") if (self.exports_folder_path / f"{draft_name}.mp4").is_file(): print("存在相同名称的草稿,跳过") continue jianying_controller.export_draft( draft_name=draft_name, output_path=self.exports_folder_path.as_posix(), ) print("已完成") print() # 关闭剪映专业版进程 self._close_process() time.sleep(2) for draft_name in batch_draft_names: # 就已导出草稿删除 self.drafts_folder.remove(draft_name=draft_name) time.sleep(2) def _generate( self, ) -> None: """ 按照工作流和工作配置拼接素材,生成草稿 :return: 无 """ for idx in range(self.draft_counts): # 构建草稿名称 draft_name = self.project_name + f"{idx + 1:03d}" print(f"正在合成短视频 {draft_name}, {idx + 1}/{self.draft_counts}...") # 实例化 JianYingDraft draft = JianYingDraft( drafts_folder=self.drafts_folder, draft_name=draft_name, video_width=self.video_width, video_height=self.video_height, video_fps=self.video_fps, materials_folder_path=self.materials_folder_path, ) for work in self.workflow: match work: case "add_subtitles": print("-> 正在添加字幕...", end="") draft.add_subtitles(**self._random(work=work)) print("已完成") # 添加背景视频 case "add_background_video": print("-> 正在添加背景视频...", end="") draft.add_video_segment(**self._random(work=work)) print("已完成") # 添加标识 case "add_logo": print("-> 正在添加标识...", end="") draft.add_video_segment(**self._random(work=work)) print("已完成") # 添加声明 case "add_statement": print("-> 正在添加声明...", end="") draft.add_text_segment(**self._random(work=work)) print("已完成") # 添加贴纸 case _ if work.startswith("add_sticker"): print("-> 正在添加贴纸...", end="") draft.add_sticker(**self._random(work=work)) print("已完成") # 将草稿保存至剪映草稿文件夹内 case "save": print("-> 正在将草稿保存至剪映草稿文件夹内...", end="") draft.save() print("已完成") self.draft_names.append(draft_name) print("已完成") print() # 就所有草稿名称倒叙排序排序 self.draft_names.sort(reverse=True) def _random( self, work: str, ) -> Dict[str, Any]: """ 随机化工作配置项 :param work: 工作,包括添加字幕、添加背景视频、添加标识、添加声明和添加贴纸 :return: 工作配置 """ return { key: random.choice(value) for key, value in self.configuration[work].items() } def _start_process(self, timeout: int = 60) -> None: """ 启动剪映专业版进程 :param timeout: 最大等待时间(单位为秒),默认为 60 :return: 无 """ try: # 关闭剪映专业版进程 self._close_process() # 非堵塞方法 self.jianying_process = subprocess.Popen( args=self.program_path.as_posix(), shell=True, # 适配 Windows路径中的空格 stdout=subprocess.DEVNULL, # 重定向 stderr=subprocess.DEVNULL, creationflags=subprocess.CREATE_NEW_CONSOLE, ) start_time = time.time() while time.time() - start_time < timeout: # 定位剪映执行程序窗口 if self._locate_window() is not None: print(f"已启动剪映专业版进程,PID {self.jianying_process.pid}") return time.sleep(2) raise RuntimeError("启动超时") except Exception as exception: raise RuntimeError(f"启动剪映专业版进程发生异常:{str(exception)}") def _close_process(self, timeout: int = 60) -> None: """ 关闭剪映专业版进程 :param timeout: 最大等待时间(单位为秒),默认为 60 :return: 无 """ try: # 定位剪映执行程序窗口 window_handle = self._locate_window() if window_handle is not None: # 请求关闭剪映执行程序窗口 win32gui.SendMessage(window_handle, win32con.WM_CLOSE, 0, 0) start_time = time.time() while time.time() - start_time < timeout: if not win32gui.IsWindow(window_handle): print("已关闭剪映专业版进程") return else: time.sleep(2) raise RuntimeError("关闭超时") except Exception as exception: raise RuntimeError(f"关闭剪映专业版进程发生异常:{str(exception)}") @staticmethod def _locate_window() -> Optional[int]: """ 定位剪映执行程序窗口 :return: 剪映执行程序窗口句柄 """ window_handle = None def callback(handle, _): """ 遍历所有窗口的回调函数 """ # 初始化窗口句柄 nonlocal window_handle # 获取窗口标题 window_text = win32gui.GetWindowText(handle) # 检查窗口是否可见且窗口标题为剪映专业版 if win32gui.IsWindowVisible(handle) and window_text == "剪映专业版": window_handle = handle return False return True # 遍历所有顶层窗口 win32gui.EnumWindows(callback, None) return window_handle