Spaces:
Running
Running
import traceback | |
# import pysrt | |
from typing import Optional | |
from typing import List | |
from loguru import logger | |
from moviepy import * | |
from PIL import ImageFont | |
from contextlib import contextmanager | |
from moviepy import ( | |
VideoFileClip, | |
AudioFileClip, | |
TextClip, | |
CompositeVideoClip, | |
CompositeAudioClip | |
) | |
from app.models.schema import VideoAspect, SubtitlePosition | |
def wrap_text(text, max_width, font, fontsize=60): | |
""" | |
文本自动换行处理 | |
Args: | |
text: 待处理的文本 | |
max_width: 最大宽度 | |
font: 字体文件路径 | |
fontsize: 字体大小 | |
Returns: | |
tuple: (换行后的文本, 文本高度) | |
""" | |
# 创建字体对象 | |
font = ImageFont.truetype(font, fontsize) | |
def get_text_size(inner_text): | |
inner_text = inner_text.strip() | |
left, top, right, bottom = font.getbbox(inner_text) | |
return right - left, bottom - top | |
width, height = get_text_size(text) | |
if width <= max_width: | |
return text, height | |
logger.debug(f"换行文本, 最大宽度: {max_width}, 文本宽度: {width}, 文本: {text}") | |
processed = True | |
_wrapped_lines_ = [] | |
words = text.split(" ") | |
_txt_ = "" | |
for word in words: | |
_before = _txt_ | |
_txt_ += f"{word} " | |
_width, _height = get_text_size(_txt_) | |
if _width <= max_width: | |
continue | |
else: | |
if _txt_.strip() == word.strip(): | |
processed = False | |
break | |
_wrapped_lines_.append(_before) | |
_txt_ = f"{word} " | |
_wrapped_lines_.append(_txt_) | |
if processed: | |
_wrapped_lines_ = [line.strip() for line in _wrapped_lines_] | |
result = "\n".join(_wrapped_lines_).strip() | |
height = len(_wrapped_lines_) * height | |
# logger.warning(f"wrapped text: {result}") | |
return result, height | |
_wrapped_lines_ = [] | |
chars = list(text) | |
_txt_ = "" | |
for word in chars: | |
_txt_ += word | |
_width, _height = get_text_size(_txt_) | |
if _width <= max_width: | |
continue | |
else: | |
_wrapped_lines_.append(_txt_) | |
_txt_ = "" | |
_wrapped_lines_.append(_txt_) | |
result = "\n".join(_wrapped_lines_).strip() | |
height = len(_wrapped_lines_) * height | |
logger.debug(f"换行文本: {result}") | |
return result, height | |
def manage_clip(clip): | |
""" | |
视频片段资源管理器 | |
Args: | |
clip: 视频片段对象 | |
Yields: | |
VideoFileClip: 视频片段对象 | |
""" | |
try: | |
yield clip | |
finally: | |
clip.close() | |
del clip | |
def resize_video_with_padding(clip, target_width: int, target_height: int): | |
""" | |
调整视频尺寸并添加黑边 | |
Args: | |
clip: 视频片段 | |
target_width: 目标宽度 | |
target_height: 目标高度 | |
Returns: | |
CompositeVideoClip: 调整尺寸后的视频 | |
""" | |
clip_ratio = clip.w / clip.h | |
target_ratio = target_width / target_height | |
if clip_ratio == target_ratio: | |
return clip.resize((target_width, target_height)) | |
if clip_ratio > target_ratio: | |
scale_factor = target_width / clip.w | |
else: | |
scale_factor = target_height / clip.h | |
new_width = int(clip.w * scale_factor) | |
new_height = int(clip.h * scale_factor) | |
clip_resized = clip.resize(newsize=(new_width, new_height)) | |
background = ColorClip( | |
size=(target_width, target_height), | |
color=(0, 0, 0) | |
).set_duration(clip.duration) | |
return CompositeVideoClip([ | |
background, | |
clip_resized.set_position("center") | |
]) | |
def loop_audio_clip(audio_clip: AudioFileClip, target_duration: float) -> AudioFileClip: | |
""" | |
循环音频片段直到达到目标时长 | |
参数: | |
audio_clip: 原始音频片段 | |
target_duration: 目标时长(秒) | |
返回: | |
循环后的音频片段 | |
""" | |
# 计算需要循环的次数 | |
loops_needed = int(target_duration / audio_clip.duration) + 1 | |
# 创建足够长的音频 | |
extended_audio = audio_clip | |
for _ in range(loops_needed - 1): | |
extended_audio = CompositeAudioClip([ | |
extended_audio, | |
audio_clip.set_start(extended_audio.duration) | |
]) | |
# 裁剪到目标时长 | |
return extended_audio.subclip(0, target_duration) | |
def calculate_subtitle_position(position, video_height: int, text_height: int = 0) -> tuple: | |
""" | |
计算字幕在视频中的具体位置 | |
Args: | |
position: 位置配置,可以是 SubtitlePosition 枚举值或表示距顶部百分比的浮点数 | |
video_height: 视频高度 | |
text_height: 字幕文本高度 | |
Returns: | |
tuple: (x, y) 坐标 | |
""" | |
margin = 50 # 字幕距离边缘的边距 | |
if isinstance(position, (int, float)): | |
# 百分比位置 | |
return ('center', int(video_height * position)) | |
# 预设位置 | |
if position == SubtitlePosition.TOP: | |
return ('center', margin) | |
elif position == SubtitlePosition.CENTER: | |
return ('center', video_height // 2) | |
elif position == SubtitlePosition.BOTTOM: | |
return ('center', video_height - margin - text_height) | |
# 默认底部 | |
return ('center', video_height - margin - text_height) | |
def generate_video_v3( | |
video_path: str, | |
subtitle_style: dict, | |
volume_config: dict, | |
subtitle_path: Optional[str] = None, | |
bgm_path: Optional[str] = None, | |
narration_path: Optional[str] = None, | |
output_path: str = "output.mp4", | |
font_path: Optional[str] = None | |
) -> None: | |
""" | |
合并视频素材,包括视频、字幕、BGM和解说音频 | |
参数: | |
video_path: 原视频文件路径 | |
subtitle_path: SRT字幕文件路径(可选) | |
bgm_path: 背景音乐文件路径(可选) | |
narration_path: 解说音频文件路径(可选) | |
output_path: 输出文件路径 | |
volume_config: 音量配置字典,可包含以下键: | |
- original: 原声音量(0-1),默认1.0 | |
- bgm: BGM音量(0-1),默认0.3 | |
- narration: 解说音量(0-1),默认1.0 | |
subtitle_style: 字幕样式配置字典,可包含以下键: | |
- font: 字体名称 | |
- fontsize: 字体大小 | |
- color: 字体颜色 | |
- stroke_color: 描边颜色 | |
- stroke_width: 描边宽度 | |
- bg_color: 背景色 | |
- position: 位置支持 SubtitlePosition 枚举值或 0-1 之间的浮点数(表示距顶部的百分比) | |
- method: 文字渲染方法 | |
font_path: 字体文件路径(.ttf/.otf 等格式) | |
""" | |
# 检查视频文件是否存在 | |
if not os.path.exists(video_path): | |
raise FileNotFoundError(f"视频文件不存在: {video_path}") | |
# 加载视频 | |
video = VideoFileClip(video_path) | |
subtitle_clips = [] | |
# 处理字幕(如果提供) | |
if subtitle_path: | |
if os.path.exists(subtitle_path): | |
# 检查字体文件 | |
if font_path and not os.path.exists(font_path): | |
logger.warning(f"警告:字体文件不存在: {font_path}") | |
try: | |
subs = pysrt.open(subtitle_path) | |
logger.info(f"读取到 {len(subs)} 条字幕") | |
for index, sub in enumerate(subs): | |
start_time = sub.start.ordinal / 1000 | |
end_time = sub.end.ordinal / 1000 | |
try: | |
# 检查字幕文本是否为空 | |
if not sub.text or sub.text.strip() == '': | |
logger.info(f"警告:第 {index + 1} 条字幕内容为空,已跳过") | |
continue | |
# 处理字幕文本:确保是字符串,并处理可能的列表情况 | |
if isinstance(sub.text, (list, tuple)): | |
subtitle_text = ' '.join(str(item) for item in sub.text if item is not None) | |
else: | |
subtitle_text = str(sub.text) | |
subtitle_text = subtitle_text.strip() | |
if not subtitle_text: | |
logger.info(f"警告:第 {index + 1} 条字幕处理后为空,已跳过") | |
continue | |
# 创建临时 TextClip 来获取文本高度 | |
temp_clip = TextClip( | |
subtitle_text, | |
font=font_path, | |
fontsize=subtitle_style['fontsize'], | |
color=subtitle_style['color'] | |
) | |
text_height = temp_clip.h | |
temp_clip.close() | |
# 计算字幕位置 | |
position = calculate_subtitle_position( | |
subtitle_style['position'], | |
video.h, | |
text_height | |
) | |
# 创建最终的 TextClip | |
text_clip = (TextClip( | |
subtitle_text, | |
font=font_path, | |
fontsize=subtitle_style['fontsize'], | |
color=subtitle_style['color'] | |
) | |
.set_position(position) | |
.set_duration(end_time - start_time) | |
.set_start(start_time)) | |
subtitle_clips.append(text_clip) | |
except Exception as e: | |
logger.error(f"警告:创建第 {index + 1} 条字幕时出错: {traceback.format_exc()}") | |
logger.info(f"成功创建 {len(subtitle_clips)} 条字幕剪辑") | |
except Exception as e: | |
logger.info(f"警告:处理字幕文件时出错: {str(e)}") | |
else: | |
logger.info(f"提示:字幕文件不存在: {subtitle_path}") | |
# 合并音频 | |
audio_clips = [] | |
# 添加原声(设置音量) | |
logger.debug(f"音量配置: {volume_config}") | |
if video.audio is not None: | |
original_audio = video.audio.volumex(volume_config['original']) | |
audio_clips.append(original_audio) | |
# 添加BGM(如果提供) | |
if bgm_path: | |
bgm = AudioFileClip(bgm_path) | |
if bgm.duration < video.duration: | |
bgm = loop_audio_clip(bgm, video.duration) | |
else: | |
bgm = bgm.subclip(0, video.duration) | |
bgm = bgm.volumex(volume_config['bgm']) | |
audio_clips.append(bgm) | |
# 添加解说音频(如果提供) | |
if narration_path: | |
narration = AudioFileClip(narration_path).volumex(volume_config['narration']) | |
audio_clips.append(narration) | |
# 合成最终视频(包含字幕) | |
if subtitle_clips: | |
final_video = CompositeVideoClip([video] + subtitle_clips, size=video.size) | |
else: | |
logger.info("警告:没有字幕被添加到视频中") | |
final_video = video | |
if audio_clips: | |
final_audio = CompositeAudioClip(audio_clips) | |
final_video = final_video.set_audio(final_audio) | |
# 导出视频 | |
logger.info("开始导出视频...") # 调试信息 | |
final_video.write_videofile( | |
output_path, | |
codec='libx264', | |
audio_codec='aac', | |
fps=video.fps | |
) | |
logger.info(f"视频已导出到: {output_path}") # 调试信息 | |
# 清理资源 | |
video.close() | |
for clip in subtitle_clips: | |
clip.close() | |
if bgm_path: | |
bgm.close() | |
if narration_path: | |
narration.close() | |