This commit is contained in:
liubiren 2026-10-21 20:50:01 +08:00
parent d9153b13bb
commit 6cdfc5a1ba
1883 changed files with 1474 additions and 83 deletions

View File

@ -477,6 +477,7 @@ def message_history_item(item: MessageHistoryItem) -> rx.Component:
), ),
padding="0 16px", padding="0 16px",
width="100%", width="100%",
key=item.id,
) )

View File

@ -3,6 +3,8 @@
任务状态 任务状态
""" """
from datetime import date as Date, datetime from datetime import date as Date, datetime
from asyncio import create_task
from copy import copy
from typing import AsyncGenerator from typing import AsyncGenerator
from reflex_base.event import EventSpec from reflex_base.event import EventSpec
from pydantic_ai import DeferredToolRequests, DeferredToolResults, ToolApproved from pydantic_ai import DeferredToolRequests, DeferredToolResults, ToolApproved
@ -20,7 +22,7 @@ from pydantic_ai.messages import (
) )
from pydantic_ai.run import AgentRunResultEvent from pydantic_ai.run import AgentRunResultEvent
import reflex as rx import reflex as rx
from reflex.event import EventCallback
from application.states.database import DatabaseState from application.states.database import DatabaseState
from application.states.models import ( from application.states.models import (
Approval, Approval,
@ -265,101 +267,125 @@ class TaskState(rx.State):
self, self,
is_resume_run: bool = False, is_resume_run: bool = False,
message_id: str | None = None, message_id: str | None = None,
) -> AsyncGenerator[None]: ) -> AsyncGenerator[EventCallback, None]:
""" """
运行 运行
:param is_resume_run: 恢复运行 :param is_resume_run: 恢复运行
:param message_id: 消息唯一标识 :param message_id: 消息唯一标识
:return: AsyncGenerator :return: None
""" """
from application.states import SidebarState from application.states import SidebarState
# 获取数据库状态
database_state = await self.get_state(DatabaseState)
# 若当前任务的唯一标识为空则创建任务
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
)
)
# 将最后一个任务的唯一标识作为当前任务唯一标识
self.task_id = next(reversed(self.tasks.keys()))
# 当前任务
task = self.tasks[self.task_id]
user_prompt = None # 用户提示词 user_prompt = None # 用户提示词
deferred_tool_results = None # 延迟工具结果 deferred_tool_results = None # 延迟工具结果
# 若为恢复运行
if is_resume_run:
if not message_id:
return
# 指定消息唯一标识的消息 # 获取数据库状态
message = task.messages.get(message_id) database_state = await self.get_state(DatabaseState)
if (
not message
or not isinstance(approval := message.approval, Approval)
or not approval.content
):
return
# 将等待流式输出设置为是 async with self:
task.awaiting_stream = True # 若当前任务的唯一标识为空则创建任务并将此作为当前任务
# 更新消息内容 if not self.task_id:
message.content = f"您输入的是:\n\n{approval.content}\n\n" # 获取侧边栏状态
# 更新审批结果 sidebar_state = await self.get_state(SidebarState)
approval.result = True # 将空设置为导航按钮
yield # 通知前端更新渲染 sidebar_state.nav_button = ""
# 构建延迟工具结果 # 先创建任务记录再将此添加至当前用户的任务字典
deferred_tool_results = DeferredToolResults( self.tasks.update(
approvals={approval.tool_call_id: ToolApproved()}, # 审批通过 await database_state.create_task_record(
metadata={approval.tool_call_id: {"content": approval.content}}, self.user_id, self.temporary_user_prompt
) )
else:
# 若用户提示词为空则直接返回
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,
message=Message(
type=MessageType.USER_PROMPT,
content=user_prompt,
),
) )
) # 将最后一个任务的唯一标识作为当前任务唯一标识
yield # 通知前端更新渲染 self.task_id = next(reversed(self.tasks.keys()))
# 初始化片段索引映射为消息唯一标识字典 # 当前任务的唯一标识
index_map_to_message_id: dict[int, str] = {} task_id = self.task_id
# 当前任务
task = self.tasks[task_id]
# 若为恢复运行
if is_resume_run:
if not message_id:
return
# 指定消息唯一标识的消息
message = task.messages.get(message_id)
if (
not message
or not isinstance(approval := message.approval, Approval)
or not approval.content
):
return
# 将等待流式输出设置为是
task.awaiting_stream = True
# 更新消息内容
message.content = f"您输入的是:\n\n{approval.content}\n\n"
# 更新审批结果
approval.result = True
# 构建延迟工具结果
deferred_tool_results = DeferredToolResults(
approvals={approval.tool_call_id: ToolApproved()}, # 审批通过
metadata={approval.tool_call_id: {"content": approval.content}},
)
else:
# 若用户提示词为空则直接返回
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,
message=Message(
type=MessageType.USER_PROMPT,
content=user_prompt,
),
)
)
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( message_history = await database_state.read_message_history(task_id=task_id)
task_id=self.task_id
)
# 使用量
usage = task.usage
# 初始化工作流 async with self:
match task.workflow.type: # 当前任务
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: case WorkflowType.TALK:
from application.workshop.talk import ( from application.workshop.talk import (
run_stream_events, run_stream_events,
@ -391,7 +417,6 @@ class TaskState(rx.State):
case WorkflowType.BANK_CUSTOMER_SERVICE: case WorkflowType.BANK_CUSTOMER_SERVICE:
from application.workshop.bank_customer_service import ( from application.workshop.bank_customer_service import (
run_stream_events, run_stream_events,
Deps,
) )
from application.workshop.database import connection_pool from application.workshop.database import connection_pool
@ -437,6 +462,8 @@ class TaskState(rx.State):
messages: list[Message] = [] # 消息列表 messages: list[Message] = [] # 消息列表
previous_provider_details = {} # 上一个供应商详情 previous_provider_details = {} # 上一个供应商详情
# 初始化片段索引映射为消息唯一标识字典
index_map_to_message_id: dict[int, str] = {}
# 获取运行流式输出事件 # 获取运行流式输出事件
async for event in stream_events: async for event in stream_events:
# 若等待流式输出则将等待流式输出设置为否 # 若等待流式输出则将等待流式输出设置为否

Binary file not shown.

View File

@ -1,2 +1,2 @@
reflex==0.9.4 reflex

View File

@ -239,6 +239,11 @@ class DraftsManager:
"add_subtitle_video", "add_subtitle_video",
"add_post_roll_video", "add_post_roll_video",
], # 分别添加背景视频、字幕视频和后贴视频 ], # 分别添加背景视频、字幕视频和后贴视频
"存量_纯图_竖版": [
"add_background_video",
"add_subtitle_video",
"add_post_roll_video",
], # 分别添加背景视频、字幕视频和后贴视频
} }
# 默认以素材文件夹名称为工作流名称 # 默认以素材文件夹名称为工作流名称
workflow_name = self.materials_folder_path.stem workflow_name = self.materials_folder_path.stem

View File

@ -9,10 +9,10 @@ from drafts_manager import DraftsManager
if __name__ == "__main__": if __name__ == "__main__":
# 实例化 DraftsManager # 实例化 DraftsManager
drafts_manager = DraftsManager( drafts_manager = DraftsManager(
materials_folder_path=r"E:\jianying\materials\移动", materials_folder_path=r"E:\jianying\materials\存量_纯图_竖版",
) )
# 批量创建草稿 # 批量创建草稿
drafts_manager.batch_create( drafts_manager.batch_create(
draft_counts=1500, draft_counts=1,
) )

Binary file not shown.

View File

@ -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

View File

@ -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

View File

@ -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哈希值的大写十六进制作为草稿名称
)

View File

@ -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

View File

@ -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,
)

View File

@ -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,
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Some files were not shown because too many files have changed in this diff Show More