This commit is contained in:
liubiren 2026-10-22 20:18:39 +08:00
parent 6cdfc5a1ba
commit cd239926e4
5 changed files with 228 additions and 381 deletions

Binary file not shown.

View File

@ -30,14 +30,14 @@ class Caches(SQLite):
return o.as_posix()
return super().default(o)
def __init__(self, cache_ttl: int = 30 * 86400):
def __init__(self, ttl: int = 30 * 86400):
"""
初始化
:param cache_ttl: 缓存生存时间,单位为秒,默认为30天
:param ttl: 缓存生存时间,单位为秒,默认为30天
"""
# 初始化
super().__init__(database=Path(__file__).parent.resolve() / "caches.db")
self.cache_ttl = cache_ttl
self.ttl = ttl
# 初始化缓存表(不清理过期缓存)
try:
@ -48,8 +48,8 @@ class Caches(SQLite):
(
--草稿名称
draft_name TEXT PRIMARY KEY,
--工作流配置
configurations TEXT NOT NULL,
--草稿生成方案
draft_generate_scheme TEXT NOT NULL,
--创建时间戳
timestamp REAL NOT NULL
)
@ -63,47 +63,47 @@ class Caches(SQLite):
except Exception as exception:
raise RuntimeError(f"初始化缓存发生异常:{str(exception)}") from exception
def query(self, draft_name: str) -> Optional[Dict[str, Any]]:
def has_same_draft_name(self, draft_name: str) -> bool:
"""
查询并返回单条缓存
是否存在相同草稿名称的缓存
:param draft_name: 草稿名称
:return: 缓存
:return: 是否存在相同草稿名称的缓存
"""
try:
with self:
result = self.query_one(
sql="""
SELECT configurations
SELECT draft_generate_scheme
FROM caches
WHERE draft_name = ? AND timestamp >= ?
""",
parameters=(draft_name, time.time() - self.cache_ttl),
parameters=(draft_name, time.time() - self.ttl),
)
return None if result is None else json.loads(result["configurations"])
return False if result is None else True
except Exception as exception:
raise RuntimeError(
f"查询并获取单条缓存发生异常:{str(exception)}"
f"是否存在相同草稿方案的缓存发生异常:{str(exception)}"
) from exception
def update(
self, draft_name: str, configurations: Dict[str, Dict[str, Any]]
self, draft_name: str, draft_generate_scheme: list[dict]
) -> Optional[bool]:
"""
新增或更新单条缓存
新增或更新草稿生成方案
:param draft_name: 草稿名称
:param configurations: 节点配置
:param draft_generate_scheme: 草稿生成方案
:return: 成功返回True,失败返回False
"""
try:
with self:
return self.execute(
sql="""
INSERT OR REPLACE INTO caches (draft_name, configurations, timestamp) VALUES (?, ?, ?)
INSERT OR REPLACE INTO caches (draft_name, draft_generate_scheme, timestamp) VALUES (?, ?, ?)
""",
parameters=(
draft_name,
json.dumps(
obj=configurations,
obj=draft_generate_scheme,
cls=self.JSONEncoder,
sort_keys=True,
ensure_ascii=False,
@ -112,4 +112,4 @@ class Caches(SQLite):
),
)
except Exception as exception:
raise RuntimeError("新增或更新缓存发生异常") from exception
raise RuntimeError("新增或更新草稿生成方案发生异常") from exception

View File

@ -32,7 +32,7 @@ from edgetts import EdgeTTS
class Drafts:
"""
剪映草稿,支持:
草稿,支持:
1 添加文本片段
2 添加音频片段
3 添加视频或图片片段
@ -43,7 +43,7 @@ class Drafts:
def __init__(
self,
materials_folder_path: Path,
materials_vault_path: Path,
drafts_folder: DraftFolder,
draft_name: str,
video_width: int = 1080,
@ -53,24 +53,24 @@ class Drafts:
):
"""
初始化
:param materials_folder_path: 素材文件夹路径
:param drafts_folder: 剪映草稿文件夹管理器
:param materials_vault_path: 素材仓库路径
:param drafts_folder: 草稿文件夹管理器
:param draft_name: 草稿名称
:param video_width: 视频宽度(单位为像素),默认为 1080
:param video_height: 视频高度(单位为像素),默认为 1920
:param video_fps: 视频帧率(单位为帧/秒),默认为 30
:param video_duration: 视频持续时长(单位为微秒),默认为 0
"""
try:
# 初始化素材文件夹路径
self.materials_folder_path = materials_folder_path
if not self.materials_folder_path.exists():
raise FileNotFoundError(f"素材文件夹路径不存在")
# 创建剪映草稿
# 初始化素材仓库路径
self.material_vault_path = materials_vault_path
if not self.material_vault_path.exists():
raise FileNotFoundError(f"素材仓库路径不存在")
# 创建草稿
self.draft = drafts_folder.create_draft(
draft_name=draft_name,
allow_replace=True, # 允许覆盖与 draft_name 重名的剪映草稿
allow_replace=True, # 允许覆盖与 draft_name 重名的草稿
width=video_width,
height=video_height,
fps=video_fps,
@ -78,8 +78,6 @@ class Drafts:
# 初始化视频持续时长
self.video_duration = video_duration
except Exception as exception:
raise RuntimeError(f"创建剪映草稿发生异常:{str(exception)}") from exception
def add_text_segment(
self,
@ -390,7 +388,7 @@ class Drafts:
)
# 构建字幕音频文件夹路径
subtitle_folder_path = self.materials_folder_path / "字幕音频"
subtitle_folder_path = self.material_vault_path / "字幕音频"
subtitle_folder_path.mkdir(exist_ok=True)
# 实例化 EdgeTTS

View File

@ -8,10 +8,8 @@ import hashlib
import json
from pathlib import Path
from random import choice
import re
import shutil
import sys
from typing import Any, Dict, List, Optional
from pyJianYingDraft import DraftFolder, VideoMaterial
@ -21,53 +19,95 @@ from drafts import Drafts
sys.path.append(Path(__file__).parent.parent.as_posix())
WORKFLOWS = {
"存量_纯图_竖版": [[("底纹", {
DRAFT_GENERATE_SCHEMES = {
"存量_纯图_竖版": [
[
(
"按钮",
{
"clip_settings": [
{
"scale_x": 1.0,
"scale_y": 1.0,
},
], # 剪辑设置
},)], [("底纹", {
},
)
],
[
(
"底纹",
{
"clip_settings": [
{
"scale_x": 1.0,
"scale_y": 1.0,
},
], # 剪辑设置
},)], [("箭头", {
},
)
],
[
(
"箭头",
{
"clip_settings": [
{
"scale_x": 0.35,
"scale_y": 0.35,
"transform_x": 0,
"transform_y": -0.92,
"transform_y": -0.46,
},
], # 剪辑设置
},)], [("提示语", {
},
)
],
[
(
"提示语",
{
"volume": [1.0], # 播放音量
"clip_settings": [
{
"scale_x": 1.0,
"scale_y": 1.0,
"transform_x": 0,
"transform_y": 0.05,
},
], # 剪辑设置
},)],]
},
)
],
],
"存量_纯图_横版": [
[
(
"按钮",
{
"clip_settings": [
{
"rotation": 90,
},
], # 剪辑设置
},
)
],
[
(
"底纹",
{
"clip_settings": [
{
"rotation": 90,
},
], # 剪辑设置
},
)
],
],
}
class DraftsManager:
"""
剪映草稿管理器
@ -86,67 +126,72 @@ class DraftsManager:
# 项目名称
self.project_name = project_name
# 草稿生成方案
self.draft_generate_scheme: list[list[tuple[str, dict]]] = (
DRAFT_GENERATE_SCHEMES.get(self.project_name, [])
)
if not self.draft_generate_scheme:
raise RuntimeError(f"未就 {self.project_name} 配置草稿生成方案")
# 构建素材文件夹路径
self.materials_folder_path = Path(rf"E:\jianying\materials\{project_name}")
# 检查素材文件夹是否存在,若不存在则抛出异常
if not self.materials_folder_path.exists():
raise RuntimeError("素材文件夹不存在")
# 构建此项目素材仓库路径
self.material_vault_path = Path(rf"E:\jianying\materials\{project_name}")
# 检查此项目素材仓库是否存在,若不存在则抛出异常
if not self.material_vault_path.exists():
raise RuntimeError("素材仓库不存在")
# 初始化素材
self.materials = self.init_materials()
self.materials: dict[str, list[Path]] = (
{}
) # 键为素材文件夹名称,值为素材路径列表
# 初始化项目素材
self.init_materials()
# 构建成品文件夹路径
self.products_folder_path = Path(rf"E:\jianying\products\{project_name}")
# 若成品文件夹已存在则先删除在创建
if self.products_folder_path.exists():
shutil.rmtree(self.products_folder_path)
# 检查成品文件夹是否存在,若不存在则创建
self.products_folder_path.mkdir(parents=True)
# 构建剪映草稿文件夹路径
# 构建草稿文件夹路径
self.drafts_folder_path = Path(drafts_folder_path)
# 检查剪映草稿文件夹是否存在,若不存在则抛出异常
# 检查草稿文件夹是否存在,若不存在则抛出异常
if not self.drafts_folder_path.exists():
raise RuntimeError("剪映草稿文件夹不存在")
raise RuntimeError("草稿文件夹不存在")
# 初始化剪映草稿文件夹管理器
self.drafts_folder = DraftFolder(folder_path=drafts_folder_path)
# 初始化草稿文件夹管理器
self.drafts_folder = DraftFolder(self.drafts_folder_path.as_posix())
# 实例化缓存
# 实例化缓存(用于检查草稿是否存在缓存,若存在则重新生成)
self.caches = Caches()
def init_materials(self) -> Dict[str, List[Any]]:
def init_materials(self) -> None:
"""
初始化素材
:return: 所有素材
初始化项目素材
:return: None
"""
materials = {}
# 获取工作流
workflow = WORKFLOWS.get(self.project_name)
if not workflow:
raise RuntimeError(f"未就 {self.project_name} 配置工作流")
# 根据项目工作流初始化素材
for track in workflow:
for segment in track:
material_folder_name, _ = segment
# 草稿生成方案第一层为轨道元信息,第二层为片段元信息。其中,轨道元信息包括若干片段元信息;片段元信息包括素材文件夹名称与配置元信息
for track_metadata in self.draft_generate_scheme:
for segment_metadata in track_metadata:
material_folder_name, _ = segment_metadata
# 构建素材文件夹路径
material_folder_path = self.materials_folder_path / material_folder_name
material_folder_path = self.material_vault_path / material_folder_name
# 若素材文件夹不存在则抛出异常
if not material_folder_path.exists():
raise RuntimeError(f"素材文件夹 {material_folder_name} 不存在")
# 遍历素材文件夹内所有素材路径
materials[material_folder_name] = [
self.materials[material_folder_name] = [
material_path
for material_path in material_folder_path.rglob(
for material_path in material_folder_path.glob(
"*"
)
]
) # 不递归遍历素材文件夹内文件夹
if material_path.is_file()
and not material_path.is_symlink()
and material_path.suffix.lower() in {".mp4", ".mov", ".png"}
] # 暂仅支持素材路径后缀如上
return materials
for material_folder_name in self.materials.keys():
# 统计素材文件夹内素材路径后缀数
suffix_counts = len(
{p.suffix.lower() for p in self.materials[material_folder_name]}
)
if suffix_counts > 1:
raise RuntimeError(
f"素材文件夹 {material_folder_name} 内存在多种素材路径后缀"
)
def batch_create(
self,
@ -154,7 +199,6 @@ class DraftsManager:
video_width: int = 1080,
video_height: int = 1920,
video_fps: int = 30,
capture_duration: Optional[int] = None,
) -> None:
"""
批量创建草稿
@ -162,189 +206,71 @@ class DraftsManager:
:param video_width: 视频宽度(单位为像素),默认为 1080
:param video_height: 视频高度(单位为像素),默认为 1920
:param video_fps: 视频帧率(单位为帧/秒),默认为 30
:param capture_duration: 截取背景视频时长(单位为秒),默认为 None
:return: 无
"""
# 获取工作流
workflow = WORKFLOWS[self.project_name]
index = 1 # 草稿索引
while True:
scheme = [] # 草稿生成方案
for track in workflow:
for segment in track:
material_folder_name, configuration = segment
draft_generate_scheme: list[dict] = [] # 草稿生成方案
video_duration = 0 # 视频时长
for track_idx, track_metadata in enumerate(self.draft_generate_scheme):
# 生成轨道名称
track_name = f"track{track_idx:02d}"
for segment_metadata in track_metadata:
# 获取素材文件夹名称、配置元信息
material_folder_name, config_metadata = segment_metadata
# 随机选择素材路径
material_path = choice(self.materials[material_folder_name])
# 素材后缀名
suffix = material_path.suffix.lower()
# 根据素材类型匹配节点名称
match suffix:
case ".mp4" | ".mov" | ".png":
node_name = "add_video"
case _:
raise RuntimeError(f"未就 {suffix} 配置匹配节点")
# 根据节点名称获取节点配置
scheme.append(
{
node_name: {
# 初始化片段生成方案
segment_generate_scheme = {
"track_name": track_name,
"material_path": material_path,
**{k: choice(v) for k, v in config_metadata.items()},
}
}
)
# 若非生成字幕则在工作流配置添加轨道名称
if node_name != "generate_subtitle":
configurations[node_name]["track_name"] = (
matched.group("track_name")
if (
matched := re.match(
pattern=r"^.+?_(?P<track_name>.+)$",
string=node_name,
)
)
else node_name
# 素材路径后缀
material_path_suffix = material_path.suffix.lower()
# 若为第一轨道则将所有素材的时长作为视频时长,其他轨道则将第一片段的目标时长设置为视频时长
if track_idx == 0:
if material_path_suffix not in [".mp4", ".mov", ".png"]:
raise RuntimeError(
f"第一轨道中素材 {material_path.name} 的类型必须为 mp4, mov, png"
)
# 背景视频时长,若配置包含添加字幕视频则背景视频时长为字幕视频时长,否则为背景视频时长
background_video_duration = (
VideoMaterial(
path=configurations["add_subtitle_video"]["material_path"].as_posix()
).duration
if "add_subtitle_video" in configurations
else VideoMaterial(
path=configurations["add_background_video"]["material_path"].as_posix()
).duration
)
# 若截取背景视频时长非空则将背景视频时长设置为截取背景视频时长
if capture_duration:
background_video_duration = capture_duration * 1_000_000
# 设置背景视频在轨道上的范围为 (0, 背景视频时长)
configurations["add_background_video"]["target_timerange"] = (
0,
background_video_duration,
)
if "add_subtitle_video" in configurations:
# 若包含添加字幕视频则在添加背景视频时其播放音量设置为 0
configurations["add_background_video"]["volume"] = 0.0
# 设置字幕视频在轨道上的范围为 (0, 背景视频时长)
configurations["add_subtitle_video"]["target_timerange"] = (
0,
background_video_duration,
)
# 中贴视频特殊处理:背景视频、背景音频和字幕视频在轨道上的范围为 (0, 背景视频时长),中贴视频在轨道上的范围为 (背景视频时长, 中贴视频时长)
if "add_mid_roll_video" in configurations:
configurations.update(
{
node: {
**configurations[node],
"target_timerange": (
0,
background_video_duration,
),
}
for node in [
"add_background_video",
"add_background_audio",
"add_subtitle_video",
]
if node in configurations
}
)
configurations.update(
{
"add_mid_roll_video": {
**configurations["add_mid_roll_video"],
"target_timerange": (
background_video_duration,
VideoMaterial(
path=configurations["add_mid_roll_video"][
"material_path"
].as_posix()
).duration,
),
}
}
)
# 后贴视频特殊处理:后贴视频在轨道上的范围为 (背景视频时长 + 中贴视频时长, 后贴视频时长)
if "add_post_roll_video" in configurations:
configurations.update(
{
"add_post_roll_video": {
**configurations["add_post_roll_video"],
"target_timerange": (
(
background_video_duration
+ (
VideoMaterial(
path=configurations["add_mid_roll_video"][
"material_path"
].as_posix()
).duration
if "add_mid_roll_video" in configurations
else 0
)
),
VideoMaterial(
path=configurations["add_post_roll_video"][
"material_path"
].as_posix()
).duration,
),
}
}
)
# 添加保存节点
configurations.update(
{
"save": {},
}
)
return configurations
# 定义视频时时长
duration = VideoMaterial(
path=workflow["add_background_video"][
"material_path"
].as_posix()
).duration
# 转换为微秒单位
if capture_duration:
video_duration = capture_duration * 1_000_000
# 添加中贴视频持续时长
if "add_mid_roll_video" in configurations:
video_duration += VideoMaterial(
path=configurations["add_mid_roll_video"][
"material_path"
].as_posix()
).duration
# 添加后贴视频持续时长
if "add_post_roll_video" in configurations:
video_duration += VideoMaterial(
path=configurations["add_post_roll_video"][
"material_path"
].as_posix()
path=material_path.as_posix()
).duration
else:
segment_generate_scheme["target_timerange"] = (
0,
video_duration,
) # 暂仅考虑一个轨道包括一个片段
# 将片段生成方案添加至草稿生成方案
draft_generate_scheme.append(segment_generate_scheme)
# 生成剪映草稿名称
draft_name = self._generate_draft_name(
configurations=configurations,
draft_name = (
hashlib.md5(
json.dumps(
obj=draft_generate_scheme,
cls=self.caches.JSONEncoder,
sort_keys=True,
ensure_ascii=False,
).encode("utf-8")
)
# 若草稿名称已缓存则跳过
if self.caches.query(draft_name=draft_name):
.hexdigest()
.upper()
) # MD5哈希值的大写十六进制作为草稿名称
# 若存在相同草稿名称的缓存则跳过
if self.caches.has_same_draft_name(draft_name):
continue
print(f"正在创建草稿 {draft_name}({index}/{draft_counts})...")
# 创建剪映草稿
draft = Drafts(
materials_folder_path=self.materials_folder_path,
materials_vault_path=self.material_vault_path,
drafts_folder=self.drafts_folder,
draft_name=draft_name,
video_width=video_width,
@ -352,79 +278,26 @@ class DraftsManager:
video_fps=video_fps,
video_duration=video_duration,
)
for segment_generate_scheme in draft_generate_scheme:
# 获取素材路径后缀
material_path_suffix = segment_generate_scheme[
"material_path"
].suffix.lower()
# 根据素材路径后缀匹配具体操作
match material_path_suffix:
case ".mp4" | ".mov" | ".png":
print("-> 正在添加视频片段...", end="")
draft.add_video_segment(**segment_generate_scheme)
print("已完成")
for node_name in configurations:
match node_name:
# 添加字幕
case "generate_subtitle":
print("-> 正在根据字幕文本合成字幕音频并生成字幕...", end="")
draft.generate_subtitle(**configurations[node_name])
print("已完成")
# 添加背景视频
case "add_background_video":
print("-> 正在添加背景视频...", end="")
draft.add_video_segment(**configurations[node_name])
print("已完成")
# 添加背景音频
case "add_background_audio":
print("-> 正在添加背景音频...", end="")
draft.add_audio_segment(**configurations[node_name])
print("已完成")
# 添加中贴视频
case "add_mid_roll_video":
print("-> 正在添加中贴视频...", end="")
draft.add_video_segment(**configurations[node_name])
print("已完成")
# 添加后贴视频
case "add_post_roll_video":
print("-> 正在添加后贴视频...", end="")
draft.add_video_segment(**configurations[node_name])
print("已完成")
# 添加字幕视频
case "add_subtitle_video":
print("-> 正在添加字幕视频...", end="")
draft.add_video_segment(**configurations[node_name])
print("已完成")
# 添加标识图片
case "add_logo_image":
print("-> 正在添加标识图片...", end="")
draft.add_video_segment(**configurations[node_name])
print("已完成")
# 添加标识视频
case "add_logo_video":
print("-> 正在添加标识视频...", end="")
draft.add_video_segment(**configurations[node_name])
print("已完成")
# 添加声明文本
case "add_statement":
print("-> 正在添加声明文本...", end="")
draft.add_text_segment(**configurations[node_name])
print("已完成")
# 添加声明图片
case "add_statement_image":
print("-> 正在添加声明图片...", end="")
draft.add_video_segment(**configurations[node_name])
print("已完成")
# 添加声明视频
case "add_statement_video":
print("-> 正在添加声明视频...", end="")
draft.add_video_segment(**configurations[node_name])
print("已完成")
# 添加贴纸
case _ if node_name.startswith("add_sticker"):
print("-> 正在添加贴纸...", end="")
draft.add_sticker(**configurations[node_name])
print("已完成")
# 保存草稿
case "save":
print("-> 正在保存草稿...", end="")
draft.save()
print("已完成")
# 缓存草稿名称和所有节点配置
# 缓存草稿名称和所有片段配置
self.caches.update(
draft_name=draft_name,
configurations=configurations,
draft_name,
draft_generate_scheme,
)
print("已完成")
@ -433,27 +306,3 @@ class DraftsManager:
index += 1
if index > draft_counts:
break
def generate_draft_name(
self,
configurations: Dict[str, Any],
) -> str:
"""
生成剪映草稿名称
:param configurations: 指定工作流所有节点配置
:return: 草稿名称
"""
return (
hashlib.md5(
json.dumps(
obj=configurations,
cls=self.caches.JSONEncoder,
sort_keys=True,
ensure_ascii=False,
).encode("utf-8")
) # 将工作流配置序列化
.hexdigest()
.upper() # MD5哈希值的大写十六进制作为草稿名称
)

View File

@ -14,5 +14,5 @@ if __name__ == "__main__":
# 批量创建草稿
drafts_manager.batch_create(
draft_counts=1,
draft_counts=150,
)