376 lines
14 KiB
Python
376 lines
14 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
创建剪映草稿
|
||
"""
|
||
|
||
import asyncio
|
||
from pathlib import Path
|
||
from pathlib import Path
|
||
from random import choice
|
||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||
from typing import Tuple, Union
|
||
|
||
import edge_tts
|
||
from pyJianYingDraft import (
|
||
AudioMaterial,
|
||
AudioSegment,
|
||
ClipSettings,
|
||
DraftFolder,
|
||
FontType,
|
||
KeyframeProperty,
|
||
TextBackground,
|
||
TextBorder,
|
||
TextSegment,
|
||
TextStyle,
|
||
TrackType,
|
||
VideoMaterial,
|
||
VideoSegment,
|
||
trange,
|
||
)
|
||
|
||
|
||
class JianYingDraftGenerator:
|
||
"""
|
||
剪映草稿生成器
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
drafts_folder_path: str = r"E:\JianYingPro Drafts",
|
||
):
|
||
"""
|
||
初始化
|
||
:param drafts_folder_path: 剪映草稿文件夹路径
|
||
"""
|
||
try:
|
||
# 初始化草稿文件夹管理器
|
||
self.drafts_manager = DraftFolder(
|
||
folder_path=Path(drafts_folder_path).as_posix()
|
||
)
|
||
# 初始化素材文件夹路径
|
||
self.materials_folder_path = Path(__file__).parent
|
||
except Exception as exception:
|
||
raise RuntimeError(f"发生异常:{str(exception)}") from exception
|
||
|
||
def text_to_speech(
|
||
self,
|
||
audio_path: Path,
|
||
text: str,
|
||
) -> None:
|
||
"""
|
||
合成音频并本地保存音频文件
|
||
:param audio_path: 音频文件路径
|
||
:param text: 待合成音频的文本
|
||
"""
|
||
try:
|
||
# 异步调用 EdgeTTS 合成音频
|
||
async def _async_synthetize():
|
||
communicator = edge_tts.Communicate(
|
||
text=text.strip(),
|
||
voice="zh-CN-XiaoxiaoNeural", # 语音角色
|
||
rate="+20%", # 语速
|
||
volume="+10%", # 音量
|
||
pitch="+15Hz", # 音调
|
||
)
|
||
await communicator.save(audio_path.as_posix())
|
||
return
|
||
|
||
return asyncio.run(_async_synthetize())
|
||
except Exception as exception:
|
||
raise RuntimeError(
|
||
f"合成音频并本地保存音频文件发生异常:{str(exception)}"
|
||
) from exception
|
||
|
||
def add_text_segment(
|
||
self,
|
||
track_name: str,
|
||
text: str,
|
||
timerange: Tuple[Union[int, str], Union[int, str]],
|
||
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:
|
||
# 构建文本片段
|
||
text_segment = TextSegment(
|
||
text=text.replace("\\n", "\n"),
|
||
timerange=trange(*timerange),
|
||
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: str,
|
||
target_timerange: Tuple[Union[int, str], Union[int, str]],
|
||
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: 音频素材
|
||
:param target_timerange: 音频素材在轨道上的范围,包括开始时间和持续时长
|
||
:param source_timerange: 截取音频素材范围,包括开始时间和持续时长,默认根据音频素材开始时间根据播放速度截取与音频素材持续时长等长的部分
|
||
:param speed: 播放速度,默认为 1.0
|
||
:param volume: 播放音量,默认为 1.0
|
||
:param fade: 淡入淡出设置,默认为无
|
||
:return: 无
|
||
"""
|
||
try:
|
||
# 构建音频片段
|
||
audio_segment = AudioSegment(
|
||
material=material,
|
||
target_timerange=trange(*target_timerange),
|
||
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)
|
||
|
||
except Exception as exception:
|
||
raise RuntimeError(str(exception)) from exception
|
||
|
||
def add_video_segment(
|
||
self,
|
||
track_name: str,
|
||
material: str,
|
||
target_timerange: Tuple[Union[int, str], Union[int, str]],
|
||
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 materia: 视频或图片素材
|
||
: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_segment = VideoSegment(
|
||
material=material,
|
||
target_timerange=trange(*target_timerange),
|
||
speed=speed,
|
||
volume=volume,
|
||
clip_settings=(
|
||
ClipSettings(**clip_settings) if clip_settings else None
|
||
),
|
||
)
|
||
# 添加关键帧
|
||
if keyframes:
|
||
for _property, offset, value in keyframes:
|
||
video_segment.add_keyframe(_property, offset, 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)
|
||
|
||
except Exception as exception:
|
||
raise RuntimeError(str(exception)) from exception
|
||
|
||
def create_draft(
|
||
self,
|
||
task_id: str,
|
||
contents: List[str],
|
||
):
|
||
"""
|
||
创建草稿
|
||
:param task_id: 任务标识
|
||
:param contents: 分镜内容列表
|
||
:return: 无
|
||
"""
|
||
# 新建草稿
|
||
self.draft = self.drafts_manager.create_draft(
|
||
draft_name=task_id,
|
||
allow_replace=True, # 允许覆盖
|
||
width=1080,
|
||
height=1920,
|
||
fps=30,
|
||
)
|
||
|
||
# 添加视频轨道
|
||
self.draft.add_track(
|
||
track_type=TrackType.video,
|
||
track_name=(video_track_name := "video"),
|
||
)
|
||
# 添加文本轨道
|
||
self.draft.add_track(
|
||
track_type=TrackType.text,
|
||
track_name=(text_track_name := "text"),
|
||
)
|
||
# 添加音频轨道(字幕音频)
|
||
self.draft.add_track(
|
||
track_type=TrackType.audio,
|
||
track_name=(subtitle_audio_track_name := "subtitle_audio"),
|
||
)
|
||
|
||
# 添加音频轨道(背景音频)
|
||
self.draft.add_track(
|
||
track_type=TrackType.audio,
|
||
track_name=(background_audio_track_name := "background_audio"),
|
||
)
|
||
|
||
# 构造字幕音频文件夹路径
|
||
subtitle_audios_folder_path = self.materials_folder_path / "subtitle_audios"
|
||
subtitle_audios_folder_path.mkdir(exist_ok=True)
|
||
|
||
duration = 0 # 视频持续时长
|
||
for i, content in enumerate(contents, start=1):
|
||
content_duration = 0 # 素材持续时长
|
||
for j, text in enumerate(
|
||
[text for text in content.split(",") if text.strip()], start=1
|
||
):
|
||
# 构建片段字幕音频文件路径
|
||
audio_path = (
|
||
subtitle_audios_folder_path / f"{task_id}_{i:02d}_{j:02d}.mp3"
|
||
)
|
||
if not audio_path.exists():
|
||
# 合成片段字幕音频并本地保存片段字幕音频文件
|
||
self.text_to_speech(
|
||
audio_path=audio_path,
|
||
text=text,
|
||
)
|
||
# 片段字幕音频素材化
|
||
audio = AudioMaterial(path=audio_path.as_posix())
|
||
# 向文本轨道添加文本片段(片段字幕文本)
|
||
self.add_text_segment(
|
||
track_name=text_track_name,
|
||
text=text,
|
||
timerange=(duration, audio.duration),
|
||
style={
|
||
"size": 12.0,
|
||
"align": 1, # 水平居中(0为左对齐、1为水平居中、2为右对齐)
|
||
"auto_wrapping": True, # 自动换行
|
||
}, # 字体样式,字号为12,水平居中
|
||
clip_settings={
|
||
"transform_y": -0.85,
|
||
}, # 图像设置垂直下移60%
|
||
effect={
|
||
"effect_id": "6896137924853763336", # 使用花字
|
||
},
|
||
)
|
||
# 向字幕音频的音频轨道添加音频片段(片段字幕音频)
|
||
self.add_audio_segment(
|
||
track_name=subtitle_audio_track_name,
|
||
material=audio.path,
|
||
target_timerange=(duration, audio.duration),
|
||
volume=1.0,
|
||
)
|
||
|
||
duration += audio.duration
|
||
content_duration += audio.duration
|
||
|
||
# 构建分镜视频路径
|
||
video_path = Path(
|
||
self.materials_folder_path / "videos" / f"{task_id}_{i:02d}.mp4"
|
||
)
|
||
# 分镜视频素材化
|
||
video = VideoMaterial(path=video_path.as_posix())
|
||
# 向视频轨道添加视频片段(分镜视频)
|
||
self.add_video_segment(
|
||
track_name=video_track_name,
|
||
material=video.path,
|
||
target_timerange=(duration - content_duration, content_duration),
|
||
volume=0.0,
|
||
speed=round(video.duration / content_duration, 2) - 0.01, # 冗余安全量
|
||
)
|
||
|
||
# 构建背景音频文件路径
|
||
background_audio_path = choice(
|
||
list((self.materials_folder_path / "background_audios").glob("*.mp3"))
|
||
)
|
||
# 背景音频素材化
|
||
background_audio = AudioMaterial(path=background_audio_path.as_posix())
|
||
# 向背景音频的音频轨道添加音频片段(背景音频)
|
||
self.add_audio_segment(
|
||
track_name=background_audio_track_name,
|
||
material=background_audio.path,
|
||
target_timerange=(0, duration),
|
||
volume=0.4,
|
||
)
|
||
|
||
# 将草稿保存至剪映草稿文件夹内
|
||
self.draft.save()
|