diff --git a/短视频自动合成_新/caches.db b/短视频自动合成_新/caches.db index b37bdbb..44eacdc 100644 Binary files a/短视频自动合成_新/caches.db and b/短视频自动合成_新/caches.db differ diff --git a/短视频自动合成_新/caches.py b/短视频自动合成_新/caches.py index 293ccd2..f60ee51 100644 --- a/短视频自动合成_新/caches.py +++ b/短视频自动合成_新/caches.py @@ -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: @@ -47,11 +47,11 @@ class Caches(SQLite): CREATE TABLE IF NOT EXISTS caches ( --草稿名称 - draft_name TEXT PRIMARY KEY, - --工作流配置 - configurations TEXT NOT NULL, + draft_name TEXT PRIMARY KEY, + --草稿生成方案 + draft_generate_scheme TEXT NOT NULL, --创建时间戳 - timestamp REAL 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 diff --git a/短视频自动合成_新/drafts.py b/短视频自动合成_新/drafts.py index 9d6a6e4..6a1366b 100644 --- a/短视频自动合成_新/drafts.py +++ b/短视频自动合成_新/drafts.py @@ -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,33 +53,31 @@ 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.draft = drafts_folder.create_draft( - draft_name=draft_name, - allow_replace=True, # 允许覆盖与 draft_name 重名的剪映草稿 - width=video_width, - height=video_height, - fps=video_fps, - ) + # 初始化素材仓库路径 + self.material_vault_path = materials_vault_path + if not self.material_vault_path.exists(): + raise FileNotFoundError(f"素材仓库路径不存在") - # 初始化视频持续时长 - self.video_duration = video_duration - except Exception as exception: - raise RuntimeError(f"创建剪映草稿发生异常:{str(exception)}") from exception + # 创建草稿 + self.draft = drafts_folder.create_draft( + draft_name=draft_name, + allow_replace=True, # 允许覆盖与 draft_name 重名的草稿 + width=video_width, + height=video_height, + fps=video_fps, + ) + + # 初始化视频持续时长 + self.video_duration = video_duration def add_text_segment( self, @@ -305,7 +303,7 @@ class Drafts: video_segment.add_background_filling(**background_filling) # 向指定轨道添加视频或图片片段 self.draft.add_segment(segment=video_segment, track_name=track_name) - + # 更新累计持续时长 cumulative_duration += current_duration except Exception as exception: @@ -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 diff --git a/短视频自动合成_新/drafts_manager.py b/短视频自动合成_新/drafts_manager.py index 93b34c5..0fe4081 100644 --- a/短视频自动合成_新/drafts_manager.py +++ b/短视频自动合成_新/drafts_manager.py @@ -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 = { - "存量_纯图_竖版": [[("底纹", { - "clip_settings": [ +DRAFT_GENERATE_SCHEMES = { + "存量_纯图_竖版": [ + [ + ( + "按钮", { - "scale_x": 1.0, - "scale_y": 1.0, + "clip_settings": [ + { + "scale_x": 1.0, + "scale_y": 1.0, + }, + ], # 剪辑设置 }, - ], # 剪辑设置 - },)], [("底纹", { - "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, + "clip_settings": [ + { + "scale_x": 0.35, + "scale_y": 0.35, + "transform_x": 0, + "transform_y": -0.46, + }, + ], # 剪辑设置 }, - ], # 剪辑设置 - },)], [("提示语", { - "volume": [1.0], # 播放音量 - "clip_settings": [ + ) + ], + [ + ( + "提示语", { - "scale_x": 1.0, - "scale_y": 1.0, + "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} 配置匹配节点") + # 初始化片段生成方案 + segment_generate_scheme = { + "track_name": track_name, + "material_path": material_path, + **{k: choice(v) for k, v in config_metadata.items()}, + } - # 根据节点名称获取节点配置 - scheme.append( - { - node_name: { - - } - } - ) - # 若非生成字幕则在工作流配置添加轨道名称 - if node_name != "generate_subtitle": - configurations[node_name]["track_name"] = ( - matched.group("track_name") - if ( - matched := re.match( - pattern=r"^.+?_(?P.+)$", - string=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" ) - ) - else node_name - ) - # 背景视频时长,若配置包含添加字幕视频则背景视频时长为字幕视频时长,否则为背景视频时长 - 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 + video_duration += VideoMaterial( + path=material_path.as_posix() + ).duration - # 设置背景视频在轨道上的范围为 (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": ( + else: + segment_generate_scheme["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, - ), - } - } - ) + video_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() - ).duration + # 将片段生成方案添加至草稿生成方案 + draft_generate_scheme.append(segment_generate_scheme) # 生成剪映草稿名称 - draft_name = self._generate_draft_name( - configurations=configurations, - ) - # 若草稿名称已缓存则跳过 - if self.caches.query(draft_name=draft_name): + draft_name = ( + hashlib.md5( + json.dumps( + obj=draft_generate_scheme, + cls=self.caches.JSONEncoder, + sort_keys=True, + ensure_ascii=False, + ).encode("utf-8") + ) + .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 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() + 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("已完成") - # 缓存草稿名称和所有节点配置 + 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哈希值的大写十六进制作为草稿名称 - ) diff --git a/短视频自动合成_新/main.py b/短视频自动合成_新/main.py index ba38ae9..00c47a0 100644 --- a/短视频自动合成_新/main.py +++ b/短视频自动合成_新/main.py @@ -14,5 +14,5 @@ if __name__ == "__main__": # 批量创建草稿 drafts_manager.batch_create( - draft_counts=1, + draft_counts=150, )