diff --git a/README.md b/README.md index 4e8d3ad..b211c87 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,10 @@ - ☑️ [修改文本片段的文本内容](#替换文本片段的内容) ### 批量导出 -- ⬜ 控制剪映打开指定草稿 -- ⬜ 控制剪映导出草稿至指定位置 +- ☑️ 控制剪映打开指定草稿 +- ☑️ 导出草稿至指定位置 +- ⬜ 调节部分导出参数 +- 感谢`@litter jump`提供部分思路 ### 视频与图片 - ☑️ 添加本地视频/图片素材 diff --git a/pyJianYingDraft/__init__.py b/pyJianYingDraft/__init__.py index aa06bf7..7453a57 100644 --- a/pyJianYingDraft/__init__.py +++ b/pyJianYingDraft/__init__.py @@ -17,6 +17,7 @@ from .track import Track_type from .template_mode import Shrink_mode, Extend_mode from .script_file import Script_file from .draft_folder import Draft_folder +from .jianying_controller import Jianying_controller from .time_util import SEC, tim, trange @@ -50,6 +51,7 @@ __all__ = [ "Extend_mode", "Script_file", "Draft_folder", + "Jianying_controller", "SEC", "tim", "trange" diff --git a/pyJianYingDraft/exceptions.py b/pyJianYingDraft/exceptions.py index 1e21a6b..cbc1bc5 100644 --- a/pyJianYingDraft/exceptions.py +++ b/pyJianYingDraft/exceptions.py @@ -14,3 +14,10 @@ class AmbiguousMaterial(ValueError): class ExtensionFailed(ValueError): """替换素材时延伸片段失败""" + +class DraftNotFound(NameError): + """未找到草稿""" +class AutomationError(Exception): + """自动化操作失败""" +class ExportTimeout(Exception): + """导出超时""" diff --git a/pyJianYingDraft/jianying_controller.py b/pyJianYingDraft/jianying_controller.py new file mode 100644 index 0000000..30b4071 --- /dev/null +++ b/pyJianYingDraft/jianying_controller.py @@ -0,0 +1,162 @@ +"""剪映自动化控制,主要与自动导出有关""" + +import time +import shutil +import uiautomation as uia + +from typing import Optional, Literal + +from . import exceptions +from .exceptions import AutomationError + +class Jianying_controller: + """剪映控制器""" + + app: uia.WindowControl + """剪映窗口""" + app_status: Literal["home", "edit", "pre_export"] + + def __init__(self): + """初始化剪映控制器, 此时剪映应该处于目录页""" + self.get_window() + + def export_draft(self, draft_name: str, output_dir: Optional[str] = None, timeout: float = 1e9) -> None: + """导出指定的剪映草稿 + + **注意: 需要确认有导出草稿的权限(不使用VIP功能或已开通VIP), 否则可能陷入死循环** + + Args: + draft_name (`str`): 要导出的剪映草稿名称 + output_path (`str`, optional): 导出路径, 导出完成后会将文件剪切到此, 不指定则使用剪映默认路径. + timeout (`float`, optional): 导出超时时间(秒), 默认无限制. + + Raises: + `DraftNotFound`: 未找到指定名称的剪映草稿 + `AutomationError`: 剪映操作失败 + """ + self.switch_to_home() + + # 点击对应草稿 + draft_name_text = self.app.TextControl(searchDepth=2, + Compare=lambda ctrl, depth: self.__draft_name_cmp(draft_name, ctrl, depth)) + if not draft_name_text.Exists(0): + raise exceptions.DraftNotFound(f"未找到名为{draft_name}的剪映草稿") + draft_btn = draft_name_text.GetParentControl() + assert draft_btn is not None + draft_btn.Click(simulateMove=False) + time.sleep(10) + self.get_window() + + # 点击导出按钮 + export_btn = self.app.TextControl(searchDepth=2, Compare=self.__edit_page_export_cmp) + if not export_btn.Exists(0): + raise AutomationError("未找到导出按钮") + export_btn.Click(simulateMove=False) + time.sleep(3) + self.get_window() + + # 获取原始导出路径 + export_path_sib = self.app.TextControl(searchDepth=2, Compare=self.__export_path_cmp) + if not export_path_sib.Exists(0): + raise AutomationError("未找到导出路径框") + export_path_text = export_path_sib.GetSiblingControl(lambda ctrl: True) + assert export_path_text is not None + export_path = export_path_text.GetPropertyValue(30159) + + # 点击导出 + export_btn = self.app.TextControl(searchDepth=2, Compare=self.__export_btn_cmp) + if not export_btn.Exists(0): + raise AutomationError("未找到导出按钮") + export_btn.Click(simulateMove=False) + time.sleep(2) + + # 等待导出完成 + st = time.time() + while True: + self.get_window() + if self.app_status != "pre_export": continue + + export_window = self.app.WindowControl(searchDepth=1, Name="JianyingPro") + if export_window.Exists(0): + close_btn = export_window.ButtonControl(Name="关闭") + if close_btn.Exists(1): + close_btn.Click(simulateMove=False) + break + + if time.time() - st > timeout: + raise AutomationError("导出超时, 时限为%d秒" % timeout) + + time.sleep(1) + time.sleep(2) + + # 复制导出的文件到指定目录 + if output_dir is not None: + shutil.move(export_path, output_dir) + + def switch_to_home(self) -> None: + """切换到剪映主页""" + if self.app_status == "home": + return + if self.app_status != "edit": + raise AutomationError("仅支持从编辑模式切换到主页") + close_btn = self.app.GroupControl(searchDepth=1, ClassName="TitleBarButton", foundIndex=3) + close_btn.Click(simulateMove=False) + time.sleep(2) + self.get_window() + + def get_window(self) -> None: + """寻找剪映窗口并置顶""" + if hasattr(self, "app") and self.app.Exists(0): + self.app.SetTopmost(False) + + self.app = uia.WindowControl(searchDepth=1, Compare=self.__jianying_window_cmp) + if not self.app.Exists(0): + raise AutomationError("剪映窗口未找到") + + # 寻找可能存在的导出窗口 + export_window = self.app.WindowControl(searchDepth=1, Name="导出") + if export_window.Exists(0): + self.app = export_window + self.app_status = "pre_export" + + self.app.SetActive() + self.app.SetTopmost() + + def __jianying_window_cmp(self, control: uia.WindowControl, depth: int) -> bool: + if control.Name != "剪映专业版": + return False + if "HomePage".lower() in control.ClassName.lower(): + self.app_status = "home" + return True + if "MainWindow".lower() in control.ClassName.lower(): + self.app_status = "edit" + return True + return False + + @staticmethod + def __draft_name_cmp(draft_name: str, control: uia.TextControl, depth: int) -> bool: + if depth != 2: + return False + full_desc: str = control.GetPropertyValue(30159) + return "Title:".lower() in full_desc.lower() and draft_name in full_desc + + @staticmethod + def __edit_page_export_cmp(control: uia.TextControl, depth: int) -> bool: + if depth != 2: + return False + full_desc: str = control.GetPropertyValue(30159).lower() + return "title" in full_desc and "export" in full_desc + + @staticmethod + def __export_btn_cmp(control: uia.TextControl, depth: int) -> bool: + if depth != 2: + return False + full_desc: str = control.GetPropertyValue(30159).lower() + return "ExportOkBtn".lower() == full_desc + + @staticmethod + def __export_path_cmp(control: uia.TextControl, depth: int) -> bool: + if depth != 2: + return False + full_desc: str = control.GetPropertyValue(30159).lower() + return "ExportPath".lower() in full_desc diff --git a/readme_assets/使用思路.jpg b/readme_assets/使用思路.jpg index 5dee0f8..bec55f8 100644 Binary files a/readme_assets/使用思路.jpg and b/readme_assets/使用思路.jpg differ diff --git a/requirements.txt b/requirements.txt index 8c8bc46..b5a20d9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ pymediainfo imageio +uiautomation>=2