|
|
@ -477,6 +477,7 @@ def message_history_item(item: MessageHistoryItem) -> rx.Component:
|
|||
),
|
||||
padding="0 16px",
|
||||
width="100%",
|
||||
key=item.id,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
任务状态
|
||||
"""
|
||||
from datetime import date as Date, datetime
|
||||
from asyncio import create_task
|
||||
from copy import copy
|
||||
from typing import AsyncGenerator
|
||||
from reflex_base.event import EventSpec
|
||||
from pydantic_ai import DeferredToolRequests, DeferredToolResults, ToolApproved
|
||||
|
|
@ -20,7 +22,7 @@ from pydantic_ai.messages import (
|
|||
)
|
||||
from pydantic_ai.run import AgentRunResultEvent
|
||||
import reflex as rx
|
||||
|
||||
from reflex.event import EventCallback
|
||||
from application.states.database import DatabaseState
|
||||
from application.states.models import (
|
||||
Approval,
|
||||
|
|
@ -265,25 +267,30 @@ class TaskState(rx.State):
|
|||
self,
|
||||
is_resume_run: bool = False,
|
||||
message_id: str | None = None,
|
||||
) -> AsyncGenerator[None]:
|
||||
) -> AsyncGenerator[EventCallback, None]:
|
||||
"""
|
||||
运行
|
||||
:param is_resume_run: 恢复运行
|
||||
:param message_id: 消息唯一标识
|
||||
:return: AsyncGenerator
|
||||
:return: None
|
||||
"""
|
||||
from application.states import SidebarState
|
||||
|
||||
user_prompt = None # 用户提示词
|
||||
deferred_tool_results = None # 延迟工具结果
|
||||
|
||||
# 获取数据库状态
|
||||
database_state = await self.get_state(DatabaseState)
|
||||
# 若当前任务的唯一标识为空则创建任务
|
||||
|
||||
async with self:
|
||||
# 若当前任务的唯一标识为空则创建任务并将此作为当前任务
|
||||
if not self.task_id:
|
||||
# 获取侧边栏状态
|
||||
sidebar_state = await self.get_state(SidebarState)
|
||||
# 将空设置为导航按钮
|
||||
sidebar_state.nav_button = ""
|
||||
|
||||
# 创建任务记录再添加任务
|
||||
# 先创建任务记录再将此添加至当前用户的任务字典
|
||||
self.tasks.update(
|
||||
await database_state.create_task_record(
|
||||
self.user_id, self.temporary_user_prompt
|
||||
|
|
@ -292,11 +299,11 @@ class TaskState(rx.State):
|
|||
# 将最后一个任务的唯一标识作为当前任务唯一标识
|
||||
self.task_id = next(reversed(self.tasks.keys()))
|
||||
|
||||
# 当前任务的唯一标识
|
||||
task_id = self.task_id
|
||||
# 当前任务
|
||||
task = self.tasks[self.task_id]
|
||||
task = self.tasks[task_id]
|
||||
|
||||
user_prompt = None # 用户提示词
|
||||
deferred_tool_results = None # 延迟工具结果
|
||||
# 若为恢复运行
|
||||
if is_resume_run:
|
||||
if not message_id:
|
||||
|
|
@ -317,8 +324,6 @@ class TaskState(rx.State):
|
|||
message.content = f"您输入的是:\n\n{approval.content}\n\n"
|
||||
# 更新审批结果
|
||||
approval.result = True
|
||||
yield # 通知前端更新渲染
|
||||
|
||||
# 构建延迟工具结果
|
||||
deferred_tool_results = DeferredToolResults(
|
||||
approvals={approval.tool_call_id: ToolApproved()}, # 审批通过
|
||||
|
|
@ -327,16 +332,18 @@ class TaskState(rx.State):
|
|||
|
||||
else:
|
||||
# 若用户提示词为空则直接返回
|
||||
if not (user_prompt := task.user_prompt.strip()): # 去除用户提示词首尾空格
|
||||
if not (
|
||||
user_prompt := task.user_prompt.strip()
|
||||
): # 去除用户提示词首尾空格
|
||||
return
|
||||
|
||||
# 清空用户提示词
|
||||
# 清空临时用户提示词和当前任务的用户提示词
|
||||
self.temporary_user_prompt = task.user_prompt = ""
|
||||
# 将正在运行设置为是
|
||||
# 将当前任务设置为正在运行
|
||||
task.is_running = True
|
||||
# 将等待流式输出设置为是
|
||||
# 将当前任务设置为等待流式输出
|
||||
task.awaiting_stream = True
|
||||
# 先创建消息记录再添加消息
|
||||
# 先创建消息记录再添加至当前任务的消息字典
|
||||
task.messages.update(
|
||||
await database_state.create_message_record(
|
||||
task_id=task.id,
|
||||
|
|
@ -346,20 +353,39 @@ class TaskState(rx.State):
|
|||
),
|
||||
)
|
||||
)
|
||||
yield # 通知前端更新渲染
|
||||
|
||||
# 初始化片段索引映射为消息唯一标识字典
|
||||
index_map_to_message_id: dict[int, str] = {}
|
||||
yield type(self).background_run(task_id, user_prompt, deferred_tool_results)
|
||||
|
||||
@rx.event(background=True)
|
||||
async def background_run(
|
||||
self,
|
||||
task_id: str,
|
||||
user_prompt: str | None,
|
||||
deferred_tool_results: DeferredToolResults | None = None,
|
||||
):
|
||||
"""
|
||||
后台运行运行
|
||||
:param task_id: 任务唯一标识
|
||||
:return: None
|
||||
"""
|
||||
|
||||
# 获取数据库状态
|
||||
database_state = await self.get_state(DatabaseState)
|
||||
# 查询消息历史列表
|
||||
message_history = await database_state.read_message_history(
|
||||
task_id=self.task_id
|
||||
)
|
||||
# 使用量
|
||||
usage = task.usage
|
||||
message_history = await database_state.read_message_history(task_id=task_id)
|
||||
|
||||
# 初始化工作流
|
||||
match task.workflow.type:
|
||||
async with self:
|
||||
# 当前任务
|
||||
task = self.tasks[task_id]
|
||||
# 工作流
|
||||
workflow = task.workflow
|
||||
# 使用量
|
||||
usage = copy(task.usage)
|
||||
if usage.details:
|
||||
usage.details = dict(usage.details)
|
||||
|
||||
# 根据工作流的类型匹配流式输出
|
||||
match workflow.type:
|
||||
case WorkflowType.TALK:
|
||||
from application.workshop.talk import (
|
||||
run_stream_events,
|
||||
|
|
@ -391,7 +417,6 @@ class TaskState(rx.State):
|
|||
case WorkflowType.BANK_CUSTOMER_SERVICE:
|
||||
from application.workshop.bank_customer_service import (
|
||||
run_stream_events,
|
||||
Deps,
|
||||
)
|
||||
from application.workshop.database import connection_pool
|
||||
|
||||
|
|
@ -437,6 +462,8 @@ class TaskState(rx.State):
|
|||
|
||||
messages: list[Message] = [] # 消息列表
|
||||
previous_provider_details = {} # 上一个供应商详情
|
||||
# 初始化片段索引映射为消息唯一标识字典
|
||||
index_map_to_message_id: dict[int, str] = {}
|
||||
# 获取运行流式输出事件
|
||||
async for event in stream_events:
|
||||
# 若等待流式输出则将等待流式输出设置为否
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
|
||||
reflex==0.9.4
|
||||
reflex
|
||||
|
|
@ -239,6 +239,11 @@ class DraftsManager:
|
|||
"add_subtitle_video",
|
||||
"add_post_roll_video",
|
||||
], # 分别添加背景视频、字幕视频和后贴视频
|
||||
"存量_纯图_竖版": [
|
||||
"add_background_video",
|
||||
"add_subtitle_video",
|
||||
"add_post_roll_video",
|
||||
], # 分别添加背景视频、字幕视频和后贴视频
|
||||
}
|
||||
# 默认以素材文件夹名称为工作流名称
|
||||
workflow_name = self.materials_folder_path.stem
|
||||
|
|
|
|||
|
|
@ -9,10 +9,10 @@ from drafts_manager import DraftsManager
|
|||
if __name__ == "__main__":
|
||||
# 实例化 DraftsManager
|
||||
drafts_manager = DraftsManager(
|
||||
materials_folder_path=r"E:\jianying\materials\移动",
|
||||
materials_folder_path=r"E:\jianying\materials\存量_纯图_竖版",
|
||||
)
|
||||
|
||||
# 批量创建草稿
|
||||
drafts_manager.batch_create(
|
||||
draft_counts=1500,
|
||||
draft_counts=1,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,115 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
缓存模块
|
||||
"""
|
||||
|
||||
# 列举导入模块
|
||||
import json
|
||||
from pathlib import Path
|
||||
from pathlib import WindowsPath
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
sys.path.append(Path(__file__).parent.parent.as_posix())
|
||||
from utils.sqlite import SQLite
|
||||
|
||||
|
||||
class Caches(SQLite):
|
||||
"""
|
||||
缓存客户端,支持:
|
||||
query:查询并返回单条缓存
|
||||
update:新增或更新单条缓存
|
||||
"""
|
||||
|
||||
# 自定义JSON编码器
|
||||
class JSONEncoder(json.JSONEncoder):
|
||||
def default(self, o):
|
||||
# 若为WindowsPath对象则转为字符串路径
|
||||
if isinstance(o, WindowsPath):
|
||||
return o.as_posix()
|
||||
return super().default(o)
|
||||
|
||||
def __init__(self, cache_ttl: int = 30 * 86400):
|
||||
"""
|
||||
初始化
|
||||
:param cache_ttl: 缓存生存时间,单位为秒,默认为30天
|
||||
"""
|
||||
# 初始化
|
||||
super().__init__(database=Path(__file__).parent.resolve() / "caches.db")
|
||||
self.cache_ttl = cache_ttl
|
||||
|
||||
# 初始化缓存表(不清理过期缓存)
|
||||
try:
|
||||
with self:
|
||||
self.execute(
|
||||
sql="""
|
||||
CREATE TABLE IF NOT EXISTS caches
|
||||
(
|
||||
--草稿名称
|
||||
draft_name TEXT PRIMARY KEY,
|
||||
--工作流配置
|
||||
configurations TEXT NOT NULL,
|
||||
--创建时间戳
|
||||
timestamp REAL NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
self.execute(
|
||||
sql="""
|
||||
CREATE INDEX IF NOT EXISTS idx_timestamp ON caches(timestamp)
|
||||
"""
|
||||
)
|
||||
except Exception as exception:
|
||||
raise RuntimeError(f"初始化缓存发生异常:{str(exception)}") from exception
|
||||
|
||||
def query(self, draft_name: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
查询并返回单条缓存
|
||||
:param draft_name: 草稿名称
|
||||
:return: 缓存
|
||||
"""
|
||||
try:
|
||||
with self:
|
||||
result = self.query_one(
|
||||
sql="""
|
||||
SELECT configurations
|
||||
FROM caches
|
||||
WHERE draft_name = ? AND timestamp >= ?
|
||||
""",
|
||||
parameters=(draft_name, time.time() - self.cache_ttl),
|
||||
)
|
||||
return None if result is None else json.loads(result["configurations"])
|
||||
except Exception as exception:
|
||||
raise RuntimeError(
|
||||
f"查询并获取单条缓存发生异常:{str(exception)}"
|
||||
) from exception
|
||||
|
||||
def update(
|
||||
self, draft_name: str, configurations: Dict[str, Dict[str, Any]]
|
||||
) -> Optional[bool]:
|
||||
"""
|
||||
新增或更新单条缓存
|
||||
:param draft_name: 草稿名称
|
||||
:param configurations: 节点配置
|
||||
:return: 成功返回True,失败返回False
|
||||
"""
|
||||
try:
|
||||
with self:
|
||||
return self.execute(
|
||||
sql="""
|
||||
INSERT OR REPLACE INTO caches (draft_name, configurations, timestamp) VALUES (?, ?, ?)
|
||||
""",
|
||||
parameters=(
|
||||
draft_name,
|
||||
json.dumps(
|
||||
obj=configurations,
|
||||
cls=self.JSONEncoder,
|
||||
sort_keys=True,
|
||||
ensure_ascii=False,
|
||||
),
|
||||
time.time(),
|
||||
),
|
||||
)
|
||||
except Exception as exception:
|
||||
raise RuntimeError("新增或更新缓存发生异常") from exception
|
||||
|
|
@ -0,0 +1,440 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
剪映草稿模块
|
||||
"""
|
||||
|
||||
# 列举导入模块
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
from pyJianYingDraft import (
|
||||
AudioMaterial,
|
||||
AudioSegment,
|
||||
ClipSettings,
|
||||
ClipSettings,
|
||||
DraftFolder,
|
||||
FontType,
|
||||
KeyframeProperty,
|
||||
StickerSegment,
|
||||
TextBackground,
|
||||
TextBorder,
|
||||
TextSegment,
|
||||
TextStyle,
|
||||
TrackType,
|
||||
VideoMaterial,
|
||||
VideoSegment,
|
||||
trange,
|
||||
)
|
||||
from pyJianYingDraft.time_util import tim
|
||||
|
||||
from edgetts import EdgeTTS
|
||||
|
||||
|
||||
class Drafts:
|
||||
"""
|
||||
剪映草稿,支持:
|
||||
1 添加文本片段
|
||||
2 添加音频片段
|
||||
3 添加视频或图片片段
|
||||
4 添加贴纸片段
|
||||
5 生成字幕
|
||||
6 保存剪映草稿
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
materials_folder_path: Path,
|
||||
drafts_folder: DraftFolder,
|
||||
draft_name: str,
|
||||
video_width: int = 1080,
|
||||
video_height: int = 1920,
|
||||
video_fps: int = 30,
|
||||
video_duration: int = 0,
|
||||
):
|
||||
"""
|
||||
初始化
|
||||
:param materials_folder_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.video_duration = video_duration
|
||||
except Exception as exception:
|
||||
raise RuntimeError(f"创建剪映草稿发生异常:{str(exception)}") from exception
|
||||
|
||||
def add_text_segment(
|
||||
self,
|
||||
track_name: str,
|
||||
text: str,
|
||||
add_track: bool = True,
|
||||
timerange: Optional[Tuple[Union[int, str], Union[int, str]]] = None,
|
||||
font: Optional[str] = None,
|
||||
style: Optional[Dict[str, Any]] = None,
|
||||
border: Optional[Dict[str, Any]] = None,
|
||||
background: Optional[Dict[str, Any]] = None,
|
||||
clip_settings: Optional[Dict[str, Any]] = None,
|
||||
bubble: Optional[Dict[str, Any]] = None,
|
||||
effect: Optional[Dict[str, Any]] = None,
|
||||
animation: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
添加文本片段
|
||||
:param track_name: 轨道名称
|
||||
:param add_track: 添加文本轨道,默认为是
|
||||
:param text: 文本
|
||||
:param timerange: 文本素材在轨道上的范围,包括开始时间和持续时长,默认为草稿持续时长
|
||||
:param font: 字体,默认为系统
|
||||
:param style: 字体样式,默认为字号 15,对齐方式 左对齐
|
||||
:param border: 文本描边设置,默认为无
|
||||
:param background: 文本背景设置,默认为无
|
||||
:param clip_settings: 图像调节设置,默认为移动至 (0, 0)
|
||||
:param bubble: 气泡设置,默认为无
|
||||
:param effect: 花字设置,默认为无
|
||||
:param animation: 动画设置,默认为无
|
||||
:return: 无
|
||||
"""
|
||||
try:
|
||||
if add_track:
|
||||
# 添加文本轨道
|
||||
self.draft.add_track(
|
||||
track_type=TrackType.text,
|
||||
track_name=track_name,
|
||||
)
|
||||
|
||||
# 构建文本片段
|
||||
text_segment = TextSegment(
|
||||
text=text.replace("\\n", "\n"),
|
||||
timerange=trange(
|
||||
*(timerange if timerange else (0, self.video_duration))
|
||||
),
|
||||
font=FontType(font) if font else None,
|
||||
style=TextStyle(**style) if style else None,
|
||||
border=TextBorder(**border) if border else None,
|
||||
background=(TextBackground(**background) if background else None),
|
||||
clip_settings=(
|
||||
ClipSettings(**clip_settings) if clip_settings else None
|
||||
),
|
||||
)
|
||||
# 添加气泡
|
||||
if bubble:
|
||||
text_segment.add_bubble(**bubble)
|
||||
# 添加花字
|
||||
# 将花字保存预设后在C:/Users/admin/AppData/Local/JianyingPro/User Data/Presets/Text_V2/预设文本?.textpreset获取花字resource_id
|
||||
if effect:
|
||||
text_segment.add_effect(**effect)
|
||||
# 添加动画
|
||||
if animation:
|
||||
text_segment.add_animation(**animation)
|
||||
# 向指定轨道添加文本片段
|
||||
self.draft.add_segment(segment=text_segment, track_name=track_name)
|
||||
except Exception as exception:
|
||||
raise RuntimeError(str(exception)) from exception
|
||||
|
||||
def add_audio_segment(
|
||||
self,
|
||||
track_name: str,
|
||||
material_path: Path,
|
||||
add_track: bool = True,
|
||||
target_timerange: Optional[Tuple[Union[int, str], Union[int, str]]] = None,
|
||||
source_timerange: Optional[Tuple[str, str]] = None,
|
||||
speed: float = 1.0,
|
||||
volume: float = 1.0,
|
||||
fade: Optional[Tuple[str, str]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
添加音频片段
|
||||
:param track_name: 轨道名称
|
||||
:param add_track: 添加音频轨道,默认为是
|
||||
:param material_path: 音频素材路径
|
||||
:param target_timerange: 音频素材在轨道上的范围,包括开始时间和持续时长,默认为草稿持续时长
|
||||
:param source_timerange: 截取音频素材范围,包括开始时间和持续时长,默认根据音频素材开始时间根据播放速度截取与音频素材持续时长等长的部分
|
||||
:param speed: 播放速度,默认为 1.0
|
||||
:param volume: 播放音量,默认为 1.0
|
||||
:param fade: 淡入淡出设置,默认为无
|
||||
:return: 无
|
||||
"""
|
||||
try:
|
||||
# 音频素材
|
||||
audio_material = AudioMaterial(path=material_path.as_posix())
|
||||
# 音频素材的持续时长
|
||||
audio_material_duration = audio_material.duration
|
||||
# 目标持续时间
|
||||
target_duration = tim(
|
||||
(target_timerange if target_timerange else (0, self.video_duration))[1]
|
||||
)
|
||||
|
||||
if add_track:
|
||||
# 添加音频轨道
|
||||
self.draft.add_track(
|
||||
track_type=TrackType.audio,
|
||||
track_name=track_name,
|
||||
)
|
||||
|
||||
cumulative_duration = 0 # 累计持续时长
|
||||
while cumulative_duration < target_duration:
|
||||
# 构建音频片段
|
||||
audio_segment = AudioSegment(
|
||||
material=audio_material,
|
||||
target_timerange=trange(
|
||||
start=cumulative_duration,
|
||||
duration=min(
|
||||
(target_duration - cumulative_duration),
|
||||
audio_material_duration,
|
||||
),
|
||||
),
|
||||
source_timerange=(
|
||||
trange(*source_timerange) if source_timerange else None
|
||||
),
|
||||
speed=speed,
|
||||
volume=volume,
|
||||
)
|
||||
# 添加淡入淡出
|
||||
if fade:
|
||||
audio_segment.add_fade(*fade)
|
||||
# 向指定轨道添加音频片段
|
||||
self.draft.add_segment(segment=audio_segment, track_name=track_name)
|
||||
|
||||
cumulative_duration += audio_material_duration
|
||||
except Exception as exception:
|
||||
raise RuntimeError(str(exception)) from exception
|
||||
|
||||
def add_video_segment(
|
||||
self,
|
||||
track_name: str,
|
||||
material_path: Path,
|
||||
target_timerange: Optional[Tuple[int, int]] = None,
|
||||
source_timerange: Optional[Tuple[int, int]] = None,
|
||||
speed: float = 1.0,
|
||||
volume: float = 1.0,
|
||||
clip_settings: Optional[Dict[str, Any]] = None,
|
||||
keyframes: Optional[
|
||||
List[Tuple[KeyframeProperty, Union[str, int], float]]
|
||||
] = None,
|
||||
animation: Optional[Dict[str, Any]] = None,
|
||||
transition: Optional[Dict[str, Any]] = None,
|
||||
background_filling: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
添加视频或图片片段
|
||||
:param track_name: 轨道名称
|
||||
:param material_path: 视频或图片素材路径
|
||||
:param target_timerange: 视频或图片素材在轨道上的范围,包括开始时间和持续时长,默认为草稿持续时长
|
||||
:param source_timerange: 截取视频或图片素材范围,包括开始时间和持续时长,默认为空
|
||||
:param speed: 播放速度,默认为 1.0
|
||||
:param volume: 播放音量,默认为 1.0
|
||||
:param clip_settings: 图像调节设置,默认为无
|
||||
:param keyframes: 关键帧设置,默认为无
|
||||
:param animation: 动画设置,默认为无
|
||||
:param transition: 转场设置,默认为无
|
||||
:param background_filling: 背景填充设置,默认为无
|
||||
:return: 无
|
||||
"""
|
||||
try:
|
||||
# 视频素材
|
||||
video_material = VideoMaterial(path=material_path.as_posix())
|
||||
# 视频素材的持续时长
|
||||
video_material_duration = video_material.duration
|
||||
# 若视频或图片素材在轨道上的范围为空则将视频素材持续时长作为目标持续时长
|
||||
if target_timerange:
|
||||
target_start, target_duration = target_timerange
|
||||
else:
|
||||
target_start, target_duration = 0, self.video_duration
|
||||
|
||||
# 添加视频轨道
|
||||
self.draft.add_track(
|
||||
track_type=TrackType.video,
|
||||
track_name=track_name,
|
||||
)
|
||||
|
||||
cumulative_duration = 0 # 累计持续时长
|
||||
while cumulative_duration < target_duration:
|
||||
# 构建视频或图片片段
|
||||
video_segment = VideoSegment(
|
||||
material=video_material,
|
||||
target_timerange=trange(
|
||||
start=target_start + cumulative_duration,
|
||||
duration=(
|
||||
current_duration := min(
|
||||
target_duration - cumulative_duration,
|
||||
video_material_duration,
|
||||
)
|
||||
),
|
||||
),
|
||||
source_timerange=(
|
||||
trange(*source_timerange) if source_timerange else None
|
||||
),
|
||||
speed=speed,
|
||||
volume=volume,
|
||||
clip_settings=(
|
||||
ClipSettings(**clip_settings) if clip_settings else None
|
||||
),
|
||||
)
|
||||
# 添加关键帧
|
||||
if keyframes:
|
||||
for keyframe_property, keyframe_offset, keyframe_value in keyframes:
|
||||
video_segment.add_keyframe(
|
||||
keyframe_property, keyframe_offset, keyframe_value
|
||||
)
|
||||
# 添加动画
|
||||
if animation:
|
||||
video_segment.add_animation(**animation)
|
||||
# 添加转场
|
||||
if transition:
|
||||
video_segment.add_transition(**transition)
|
||||
# 添加背景填充
|
||||
if background_filling:
|
||||
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:
|
||||
raise RuntimeError(str(exception)) from exception
|
||||
|
||||
def add_sticker(
|
||||
self,
|
||||
track_name: str,
|
||||
resource_id: str,
|
||||
target_timerange: Optional[Tuple[Union[int, str], Union[int, str]]] = None,
|
||||
clip_settings: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
向指定贴纸轨道添加贴纸片段
|
||||
:param track_name: 轨道名称
|
||||
:param resource_id: 贴纸资源标识
|
||||
:param target_timerange: 贴纸在轨道上的范围,包括开始时间和持续时长,默认为草稿持续时长
|
||||
:param clip_settings: 图像调节设置,默认为无
|
||||
:return: 无
|
||||
"""
|
||||
try:
|
||||
# 添加贴纸轨道
|
||||
self.draft.add_track(
|
||||
track_type=TrackType.sticker,
|
||||
track_name=track_name,
|
||||
)
|
||||
|
||||
# 构建贴纸
|
||||
# 将贴纸保存为我的预设后在C:\Users\admin\AppData\Local\JianyingPro\User Data\Presets\Combination\Presets\我的预设?\preset_draft获取
|
||||
sticker_segment = StickerSegment(
|
||||
resource_id=resource_id,
|
||||
target_timerange=trange(
|
||||
*(
|
||||
target_timerange
|
||||
if target_timerange
|
||||
else (0, self.video_duration)
|
||||
)
|
||||
),
|
||||
clip_settings=(
|
||||
ClipSettings(**clip_settings) if clip_settings else None
|
||||
),
|
||||
)
|
||||
|
||||
# 向指定贴纸轨道添加贴纸片段
|
||||
self.draft.add_segment(segment=sticker_segment, track_name=track_name)
|
||||
|
||||
except Exception as exception:
|
||||
raise RuntimeError(str(exception)) from exception
|
||||
|
||||
def generate_subtitle(
|
||||
self,
|
||||
texts: str,
|
||||
timbre: str = "zh-CN-XiaoxiaoNeural",
|
||||
rate: str = "+25%",
|
||||
volume: str = "+0%",
|
||||
font: Optional[str] = None,
|
||||
style: Optional[Dict[str, Any]] = {"size": 12.0, "align": "center"},
|
||||
clip_settings: Optional[Dict[str, Any]] = None,
|
||||
effect: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
"""
|
||||
根据字幕文本合成字幕音频并生成字幕
|
||||
:param texts: 字幕文本
|
||||
:param timbre: 声音音色,默认为女声-晓晓
|
||||
:param rate: 语速,默认为 +25%
|
||||
:param volume: 音量,默认为 +0%
|
||||
:param font: 字体,默认为系统默认字体
|
||||
:param style: 文本样式,默认为字号 12,对齐方式 水平居中
|
||||
:param clip_settings: 图像调节设置,默认为移动至 (0, -0.5)
|
||||
:param effect: 花字设置,默认为无
|
||||
:return: 无
|
||||
"""
|
||||
# 添加文本轨道
|
||||
self.draft.add_track(
|
||||
track_type=TrackType.text,
|
||||
track_name=(text_track_name := "subtitle(text)"),
|
||||
)
|
||||
# 添加音频轨道
|
||||
self.draft.add_track(
|
||||
track_type=TrackType.audio,
|
||||
track_name=(audio_track_name := "subtitle(audio)"),
|
||||
)
|
||||
|
||||
# 构建字幕音频文件夹路径
|
||||
subtitle_folder_path = self.materials_folder_path / "字幕音频"
|
||||
subtitle_folder_path.mkdir(exist_ok=True)
|
||||
|
||||
# 实例化 EdgeTTS
|
||||
edge_tts = EdgeTTS(folder_path=subtitle_folder_path)
|
||||
|
||||
cumulative_duration = 0 # 累计持续时长
|
||||
for text in texts.split(","):
|
||||
# 根据字幕文本片段合成语音并将语音文件保存至指定文件夹内
|
||||
subtitle_audio_path, duration = edge_tts.synthetize(
|
||||
text=text, timbre=timbre, rate=rate, volume=volume
|
||||
)
|
||||
# 向指定文本轨道添加文本片段
|
||||
self.add_text_segment(
|
||||
track_name=text_track_name,
|
||||
add_track=False, # 不添加文本轨道
|
||||
text=text,
|
||||
timerange=(cumulative_duration, duration),
|
||||
font=font,
|
||||
style={
|
||||
"size": 12.0,
|
||||
"align": 1,
|
||||
**(style or {}),
|
||||
},
|
||||
clip_settings={
|
||||
"transform_y": -0.6,
|
||||
**(clip_settings or {}),
|
||||
},
|
||||
effect=effect,
|
||||
)
|
||||
# 向指定音频轨道添加音频片段
|
||||
self.add_audio_segment(
|
||||
track_name=audio_track_name,
|
||||
add_track=False, # 不添加音频轨道
|
||||
material_path=subtitle_audio_path,
|
||||
target_timerange=(cumulative_duration, duration),
|
||||
)
|
||||
cumulative_duration += duration
|
||||
|
||||
# 以累计持续时长作为视频持续时长
|
||||
self.video_duration = cumulative_duration
|
||||
|
||||
def save(self) -> None:
|
||||
"""将草稿保存至剪映草稿文件夹内"""
|
||||
try:
|
||||
self.draft.save()
|
||||
except Exception as exception:
|
||||
raise RuntimeError(str(exception)) from exception
|
||||
|
|
@ -0,0 +1,459 @@
|
|||
# -*- coding: utf-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
|
||||
|
||||
from caches import Caches
|
||||
from drafts import Drafts
|
||||
|
||||
sys.path.append(Path(__file__).parent.parent.as_posix())
|
||||
|
||||
|
||||
|
||||
|
||||
WORKFLOWS = {
|
||||
"存量_纯图_竖版": [[("底纹", {
|
||||
"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,
|
||||
},
|
||||
], # 剪辑设置
|
||||
},)], [("提示语", {
|
||||
"volume": [1.0], # 播放音量
|
||||
"clip_settings": [
|
||||
{
|
||||
"scale_x": 1.0,
|
||||
"scale_y": 1.0,
|
||||
},
|
||||
], # 剪辑设置
|
||||
},)],]
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class DraftsManager:
|
||||
"""
|
||||
剪映草稿管理器
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
project_name: str,
|
||||
drafts_folder_path: str = r"E:\JianYingPro Drafts",
|
||||
):
|
||||
"""
|
||||
初始化剪映草稿管理器
|
||||
:param project_name: 项目名称
|
||||
:param drafts_folder_path: 剪映草稿文件夹路径,默认为 E:\\JianYingPro Drafts
|
||||
"""
|
||||
|
||||
# 项目名称
|
||||
self.project_name = project_name
|
||||
|
||||
# 构建素材文件夹路径
|
||||
self.materials_folder_path = Path(rf"E:\jianying\materials\{project_name}")
|
||||
# 检查素材文件夹是否存在,若不存在则抛出异常
|
||||
if not self.materials_folder_path.exists():
|
||||
raise RuntimeError("素材文件夹不存在")
|
||||
|
||||
# 初始化素材
|
||||
self.materials = 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("剪映草稿文件夹不存在")
|
||||
|
||||
# 初始化剪映草稿文件夹管理器
|
||||
self.drafts_folder = DraftFolder(folder_path=drafts_folder_path)
|
||||
|
||||
# 实例化缓存
|
||||
self.caches = Caches()
|
||||
|
||||
def init_materials(self) -> Dict[str, List[Any]]:
|
||||
"""
|
||||
初始化素材
|
||||
:return: 所有素材
|
||||
"""
|
||||
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
|
||||
# 构建素材文件夹路径
|
||||
material_folder_path = self.materials_folder_path / material_folder_name
|
||||
# 若素材文件夹不存在则抛出异常
|
||||
if not material_folder_path.exists():
|
||||
raise RuntimeError(f"素材文件夹 {material_folder_name} 不存在")
|
||||
|
||||
# 遍历素材文件夹内所有素材路径
|
||||
materials[material_folder_name] = [
|
||||
material_path
|
||||
for material_path in material_folder_path.rglob(
|
||||
"*"
|
||||
)
|
||||
]
|
||||
|
||||
return materials
|
||||
|
||||
def batch_create(
|
||||
self,
|
||||
draft_counts: int,
|
||||
video_width: int = 1080,
|
||||
video_height: int = 1920,
|
||||
video_fps: int = 30,
|
||||
capture_duration: Optional[int] = None,
|
||||
) -> None:
|
||||
"""
|
||||
批量创建草稿
|
||||
:param draft_counts: 草稿数
|
||||
: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
|
||||
# 随机选择素材路径
|
||||
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: {
|
||||
|
||||
}
|
||||
}
|
||||
)
|
||||
# 若非生成字幕则在工作流配置添加轨道名称
|
||||
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
|
||||
)
|
||||
|
||||
# 背景视频时长,若配置包含添加字幕视频则背景视频时长为字幕视频时长,否则为背景视频时长
|
||||
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()
|
||||
).duration
|
||||
|
||||
# 生成剪映草稿名称
|
||||
draft_name = self._generate_draft_name(
|
||||
configurations=configurations,
|
||||
)
|
||||
# 若草稿名称已缓存则跳过
|
||||
if self.caches.query(draft_name=draft_name):
|
||||
continue
|
||||
|
||||
print(f"正在创建草稿 {draft_name}({index}/{draft_counts})...")
|
||||
|
||||
# 创建剪映草稿
|
||||
draft = Drafts(
|
||||
materials_folder_path=self.materials_folder_path,
|
||||
drafts_folder=self.drafts_folder,
|
||||
draft_name=draft_name,
|
||||
video_width=video_width,
|
||||
video_height=video_height,
|
||||
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()
|
||||
print("已完成")
|
||||
|
||||
# 缓存草稿名称和所有节点配置
|
||||
self.caches.update(
|
||||
draft_name=draft_name,
|
||||
configurations=configurations,
|
||||
)
|
||||
|
||||
print("已完成")
|
||||
print()
|
||||
|
||||
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哈希值的大写十六进制作为草稿名称
|
||||
)
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
合成语音模块
|
||||
"""
|
||||
|
||||
# 列举导入模块
|
||||
import asyncio
|
||||
from hashlib import md5
|
||||
from pathlib import Path
|
||||
from typing import Tuple, Union
|
||||
|
||||
import edge_tts
|
||||
from mutagen.mp3 import MP3
|
||||
|
||||
|
||||
class EdgeTTS:
|
||||
"""
|
||||
EdgeTTS模块,支持:
|
||||
1、根据文本合成语音并将语音文件保存至指定文件夹内
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
folder_path: Union[str, Path],
|
||||
):
|
||||
"""
|
||||
初始化
|
||||
:param folder_path: 语音文件保存文件夹路径
|
||||
"""
|
||||
# 指定文件夹路径(path对象)
|
||||
self.folder_path = (
|
||||
folder_path if isinstance(folder_path, Path) else Path(folder_path)
|
||||
)
|
||||
|
||||
def synthetize(
|
||||
self,
|
||||
text: str,
|
||||
timbre: str,
|
||||
rate: str,
|
||||
volume: str,
|
||||
) -> Tuple[Path, int]:
|
||||
"""
|
||||
根据文本合成语音并将语音文件保存至指定文件夹内
|
||||
:param text: 文本
|
||||
:param timbre: 音色名称
|
||||
:param rate: 语速
|
||||
:param volume: 音量
|
||||
:return: 语音文件路径(path对象)和持续时长(单位为微秒)
|
||||
"""
|
||||
try:
|
||||
# 异步处理方法
|
||||
async def _async_synthetize():
|
||||
# 构造语音文件名称
|
||||
file_name = f"{md5((text + timbre + rate + volume).encode("utf-8"))
|
||||
.hexdigest()
|
||||
.upper()}.mp3"
|
||||
# 构造语音文件路径
|
||||
audio_path = self.folder_path / file_name
|
||||
communicator = edge_tts.Communicate(
|
||||
text=text.replace("\n", ""),
|
||||
voice=timbre,
|
||||
rate=rate,
|
||||
volume=volume,
|
||||
)
|
||||
await communicator.save(audio_path.as_posix())
|
||||
# 持续时长(单位为微秒)
|
||||
duration = int(
|
||||
round(MP3(audio_path.as_posix()).info.length * 1_000_000)
|
||||
)
|
||||
return audio_path, duration
|
||||
|
||||
return asyncio.run(_async_synthetize())
|
||||
except Exception as exception:
|
||||
raise RuntimeError(
|
||||
f"根据文本合成语音并将语音文件保存至指定文件夹内发生异常:{str(exception)}"
|
||||
) from exception
|
||||
|
|
@ -0,0 +1,250 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
生成图片模块
|
||||
"""
|
||||
|
||||
# 列举导入模块
|
||||
import cv2
|
||||
import numpy
|
||||
from pathlib import Path
|
||||
import random
|
||||
from typing import List, Dict, Optional, Union, Literal, Tuple
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
class Canvas:
|
||||
"""
|
||||
画布,用于绘制图片
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
materials_folder_path: str = r"E:\jianying\materials\纯图",
|
||||
canvas_height: int = 1920,
|
||||
canvas_width: int = 1080,
|
||||
):
|
||||
"""
|
||||
初始化
|
||||
:param materials_folder_path: 所有素材文件夹的根目录路径
|
||||
:param canvas_height: 画布高度
|
||||
:param canvas_width: 画布宽度
|
||||
"""
|
||||
# 构建所有素材文件夹的根目录路径
|
||||
# self.materials_folder_path = Path(materials_folder_path)
|
||||
self.materials_folder_path = Path(
|
||||
"/Users/liubiren/Python/短视频合成自动化/纯图"
|
||||
)
|
||||
|
||||
self.canvas_height, self.canvas_width = canvas_height, canvas_width
|
||||
# 创建画布(默认尺寸为 1920x1080,底色为白色)
|
||||
self.canvas = (
|
||||
numpy.ones((self.canvas_height, self.canvas_width, 4), dtype=numpy.uint8)
|
||||
* 255
|
||||
)
|
||||
|
||||
def _get_materials_paths(self, folder_name: str) -> List[Path]:
|
||||
"""
|
||||
根据素材文件夹名称获取该文件夹内所有素材路径列表
|
||||
:return: 该文件夹内所有素材路径列表
|
||||
"""
|
||||
# 构建指定素材文件路径
|
||||
folder_path = self.materials_folder_path / folder_name
|
||||
if not (folder_path.exists() and folder_path.is_dir()):
|
||||
raise RuntimeError(f"{folder_name} 素材文件夹不存在")
|
||||
# 获取该文件夹内所有素材路径列表
|
||||
material_paths = list(folder_path.rglob(pattern="*.png"))
|
||||
if not material_paths:
|
||||
raise RuntimeError(f"{folder_name} 素材文件夹为空")
|
||||
return material_paths
|
||||
|
||||
def _read_material(
|
||||
self,
|
||||
material_path: Path,
|
||||
) -> numpy.ndarray:
|
||||
"""
|
||||
读取素材
|
||||
:param material_path: 素素材文件路径
|
||||
:return: 素材 OpenCV 数据
|
||||
"""
|
||||
with open(file=material_path, mode="rb") as file:
|
||||
material_buf = numpy.frombuffer(
|
||||
buffer=file.read(), dtype=numpy.uint8
|
||||
) # 二进制数据
|
||||
# 解码为图像 OpenCV 数据
|
||||
material = cv2.imdecode(buf=material_buf, flags=cv2.IMREAD_UNCHANGED)
|
||||
if material is None:
|
||||
raise RuntimeError(f"读取素材失败")
|
||||
return material
|
||||
|
||||
def _resize_material(
|
||||
self,
|
||||
material: numpy.ndarray,
|
||||
scale: Union[Literal["fixed_width"], float] = 1.0,
|
||||
) -> numpy.ndarray:
|
||||
"""
|
||||
缩放素材
|
||||
:param material: 素材 OpenCV 数据
|
||||
:param scale: 缩放比例
|
||||
:return: 缩放后素材 OpenCV 数据
|
||||
"""
|
||||
# 获取素材高度和宽度
|
||||
material_height, material_width = material.shape[:2]
|
||||
|
||||
match scale:
|
||||
case "fixed_width":
|
||||
material_height, material_width = (
|
||||
int(material_height * self.canvas_width / material_width),
|
||||
self.canvas_width,
|
||||
)
|
||||
case _:
|
||||
material_height, material_width = int(material_height * scale), int(
|
||||
material_width * scale
|
||||
)
|
||||
|
||||
return cv2.resize(
|
||||
src=material,
|
||||
dsize=(material_width, material_height),
|
||||
interpolation=cv2.INTER_LINEAR,
|
||||
)
|
||||
|
||||
def _paste_material(
|
||||
self,
|
||||
material: numpy.ndarray,
|
||||
position: Union[str, Tuple[Optional[int], Optional[int]]] = "center",
|
||||
) -> None:
|
||||
"""
|
||||
粘贴素材
|
||||
:param material: 素材 OpenCV 数据
|
||||
:param position: 粘贴位置
|
||||
:return: None
|
||||
"""
|
||||
# 获取素材高度和宽度
|
||||
material_height, material_width = material.shape[:2]
|
||||
|
||||
# 计算素材在画布原点坐标
|
||||
x = (self.canvas_width - material_width) // 2 # 水平方向坐标
|
||||
y = (self.canvas_height - material_height) // 2 # 垂直方向坐标
|
||||
|
||||
match position:
|
||||
case "center": # 居中
|
||||
pass
|
||||
case _:
|
||||
if position[0] is not None:
|
||||
x = position[0] - material_width // 2
|
||||
if position[1] is not None:
|
||||
y = position[1] - material_height // 2
|
||||
|
||||
# 计算素材左上角坐标
|
||||
x1, y1 = max(x, 0), max(y, 0)
|
||||
# 计算素材右下角坐标
|
||||
x2, y2 = min(x + material_width, self.canvas_width), min(
|
||||
y + material_height, self.canvas_height
|
||||
)
|
||||
# 截取素材有效区域
|
||||
material = material[y1 - y : y2 - y, x1 - x : x2 - x]
|
||||
|
||||
# 取出画布对应区域
|
||||
area = self.canvas[y1:y2, x1:x2]
|
||||
|
||||
# 透明度计算
|
||||
alpha_fg = material[..., 3] / 255.0
|
||||
alpha_bg = area[..., 3] / 255.0
|
||||
|
||||
# 颜色混合
|
||||
for c in range(3):
|
||||
area[..., c] = (
|
||||
material[..., c] * alpha_fg + area[..., c] * alpha_bg * (1 - alpha_fg)
|
||||
).astype(numpy.uint8)
|
||||
|
||||
# 新透明度
|
||||
area[..., 3] = ((alpha_fg + alpha_bg * (1 - alpha_fg)) * 255).astype(
|
||||
numpy.uint8
|
||||
)
|
||||
|
||||
# 写回画布
|
||||
self.canvas[y1:y2, x1:x2] = area
|
||||
|
||||
def _save(self) -> None:
|
||||
"""
|
||||
保存图片
|
||||
:return: None
|
||||
"""
|
||||
# 构建图片路径
|
||||
image_path = self.materials_folder_path / "成品" / f"{uuid4().hex.upper()}.jpg"
|
||||
|
||||
# 编码为二进制数据
|
||||
success, image_encoded = cv2.imencode(
|
||||
".jpg", img=self.canvas, params=[cv2.IMWRITE_JPEG_QUALITY, 95]
|
||||
)
|
||||
if success:
|
||||
with open(file=(Path(__file__).parent / image_path), mode="wb") as file:
|
||||
file.write(image_encoded.tobytes())
|
||||
print("✅ 图片保存成功!")
|
||||
else:
|
||||
print("❌ 图片编码失败!")
|
||||
|
||||
def draw(self, folder_names: List[str], counts: int) -> None:
|
||||
"""
|
||||
绘制图片
|
||||
:param folder_names: 绘制所用素材文件夹名称列表
|
||||
:param counts: 绘制图片数量
|
||||
:return: None
|
||||
"""
|
||||
materials_paths = {} # 绘制所用素材文件夹名称和其素材路径列表
|
||||
for folder_name in folder_names:
|
||||
materials_paths.update(
|
||||
{folder_name: self._get_materials_paths(folder_name)}
|
||||
)
|
||||
|
||||
current_count = 0 # 当前绘制图片数量计数
|
||||
while current_count < counts:
|
||||
self.canvas = (
|
||||
numpy.ones(
|
||||
(self.canvas_height, self.canvas_width, 4), dtype=numpy.uint8
|
||||
)
|
||||
* 255
|
||||
)
|
||||
|
||||
# 竖纹素材
|
||||
vertical_strip = self._read_material(
|
||||
material_path=random.choice(materials_paths["竖纹"])
|
||||
)
|
||||
# 等宽缩放竖纹素材
|
||||
vertical_strip = self._resize_material(
|
||||
material=vertical_strip, scale="fixed_width"
|
||||
)
|
||||
# 粘贴竖纹素材到画布
|
||||
self._paste_material(material=vertical_strip)
|
||||
|
||||
# 按钮素材
|
||||
button = self._read_material(
|
||||
material_path=random.choice(materials_paths["按钮"])
|
||||
)
|
||||
# 粘贴按钮素材到画布
|
||||
self._paste_material(material=button)
|
||||
|
||||
# 箭头素材
|
||||
arrow = self._read_material(
|
||||
material_path=random.choice(materials_paths["箭头"])
|
||||
)
|
||||
# 粘贴箭头素材到画布
|
||||
self._paste_material(material=arrow, position=(None, 1280))
|
||||
|
||||
# 声明素材
|
||||
declaration = self._read_material(
|
||||
material_path=random.choice(materials_paths["声明"])
|
||||
)
|
||||
# 粘贴声明素材到画布
|
||||
self._paste_material(material=declaration, position=(None, 1640))
|
||||
|
||||
# 保存图片
|
||||
self._save()
|
||||
current_count += 1
|
||||
|
||||
|
||||
canvas = Canvas()
|
||||
|
||||
canvas.draw(
|
||||
folder_names=["竖纹", "按钮", "箭头", "声明"],
|
||||
counts=500,
|
||||
)
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
主模块
|
||||
"""
|
||||
|
||||
# 列举导入模块
|
||||
from drafts_manager import DraftsManager
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 实例化 DraftsManager
|
||||
drafts_manager = DraftsManager(
|
||||
project_name="存量_纯图_竖版",
|
||||
)
|
||||
|
||||
# 批量创建草稿
|
||||
drafts_manager.batch_create(
|
||||
draft_counts=1,
|
||||
)
|
||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 201 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 225 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 161 KiB |