575 lines
22 KiB
Python
575 lines
22 KiB
Python
# -*- 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
|
||
|
||
|
||
# pylint: disable=too-few-public-methods
|
||
# pylint: disable=too-many-instance-attributes
|
||
class JianYingExport:
|
||
"""
|
||
封装 pyJianYingDraft.JianyingController库,支持:
|
||
1、初始化素材文件夹内所有素材
|
||
2、初始化工作流和工作配置
|
||
3、导出草稿
|
||
"""
|
||
|
||
# pylint: disable=too-many-arguments
|
||
# pylint: disable=too-many-positional-arguments
|
||
def __init__(
|
||
self,
|
||
materials_folder_path: str,
|
||
program_path: str = "E:\\JianYingPro\\5.9.0.11632\\JianYingPro.exe", # 仅可在windows运行该脚本
|
||
drafts_folder_path: str = "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
|
||
"""
|
||
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)}") from 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)}"
|
||
) from 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:
|
||
# 请求关闭剪映程序窗口
|
||
# pylint: disable=c-extension-no-member
|
||
win32gui.SendMessage(window_handle, win32con.WM_CLOSE, 0, 0)
|
||
|
||
start_time = time.time()
|
||
while time.time() - start_time < timeout:
|
||
# pylint: disable=c-extension-no-member
|
||
if not win32gui.IsWindow(window_handle):
|
||
print("已关闭剪映专业版进程")
|
||
return
|
||
time.sleep(2)
|
||
raise RuntimeError("关闭超时")
|
||
|
||
except Exception as exception:
|
||
raise RuntimeError(
|
||
f"关闭剪映专业版进程发生异常:{str(exception)}"
|
||
) from exception
|
||
|
||
@staticmethod
|
||
def _locate_window() -> Optional[int]:
|
||
"""
|
||
定位剪映程序窗口
|
||
:return: 剪映程序窗口句柄
|
||
"""
|
||
window_handle = None
|
||
|
||
def callback(handle, _):
|
||
"""
|
||
遍历所有窗口的回调函数
|
||
"""
|
||
# 初始化窗口句柄
|
||
nonlocal window_handle
|
||
# 获取窗口标题
|
||
# pylint: disable=c-extension-no-member
|
||
window_text = win32gui.GetWindowText(handle)
|
||
# 检查窗口是否可见且窗口标题为剪映专业版
|
||
# pylint: disable=c-extension-no-member
|
||
if win32gui.IsWindowVisible(handle) and window_text == "剪映专业版":
|
||
window_handle = handle
|
||
return False
|
||
return True
|
||
|
||
# 遍历所有顶层窗口
|
||
# pylint: disable=c-extension-no-member
|
||
win32gui.EnumWindows(callback, None)
|
||
return window_handle
|