#!/usr/bin/env python # -*- coding: UTF-8 -*- ''' @Project: NarratoAI @File : generate_video @Author : 小林同学 @Date : 2025/5/7 上午11:55 ''' import os import traceback from typing import Optional, Dict, Any from loguru import logger from moviepy import ( VideoFileClip, AudioFileClip, CompositeAudioClip, CompositeVideoClip, TextClip, afx ) from moviepy.video.tools.subtitles import SubtitlesClip from PIL import ImageFont from app.utils import utils def merge_materials( video_path: str, audio_path: str, output_path: str, subtitle_path: Optional[str] = None, bgm_path: Optional[str] = None, options: Optional[Dict[str, Any]] = None ) -> str: """ 合并视频、音频、BGM和字幕素材生成最终视频 参数: video_path: 视频文件路径 audio_path: 音频文件路径 output_path: 输出文件路径 subtitle_path: 字幕文件路径,可选 bgm_path: 背景音乐文件路径,可选 options: 其他选项配置,可包含以下字段: - voice_volume: 人声音量,默认1.0 - bgm_volume: 背景音乐音量,默认0.3 - original_audio_volume: 原始音频音量,默认0.0 - keep_original_audio: 是否保留原始音频,默认False - subtitle_font: 字幕字体,默认None,系统会使用默认字体 - subtitle_font_size: 字幕字体大小,默认40 - subtitle_color: 字幕颜色,默认白色 - subtitle_bg_color: 字幕背景颜色,默认透明 - subtitle_position: 字幕位置,可选值'bottom', 'top', 'center',默认'bottom' - custom_position: 自定义位置 - stroke_color: 描边颜色,默认黑色 - stroke_width: 描边宽度,默认1 - threads: 处理线程数,默认2 - fps: 输出帧率,默认30 返回: 输出视频的路径 """ # 合并选项默认值 if options is None: options = {} # 设置默认参数值 voice_volume = options.get('voice_volume', 1.0) bgm_volume = options.get('bgm_volume', 0.3) original_audio_volume = options.get('original_audio_volume', 0.0) # 默认为0,即不保留原声 keep_original_audio = options.get('keep_original_audio', False) # 是否保留原声 subtitle_font = options.get('subtitle_font', '') subtitle_font_size = options.get('subtitle_font_size', 40) subtitle_color = options.get('subtitle_color', '#FFFFFF') subtitle_bg_color = options.get('subtitle_bg_color', 'transparent') subtitle_position = options.get('subtitle_position', 'bottom') custom_position = options.get('custom_position', 70) stroke_color = options.get('stroke_color', '#000000') stroke_width = options.get('stroke_width', 1) threads = options.get('threads', 2) fps = options.get('fps', 30) # 处理透明背景色问题 - MoviePy 2.1.1不支持'transparent'值 if subtitle_bg_color == 'transparent': subtitle_bg_color = None # None在新版MoviePy中表示透明背景 # 创建输出目录(如果不存在) output_dir = os.path.dirname(output_path) os.makedirs(output_dir, exist_ok=True) logger.info(f"开始合并素材...") logger.info(f" ① 视频: {video_path}") logger.info(f" ② 音频: {audio_path}") if subtitle_path: logger.info(f" ③ 字幕: {subtitle_path}") if bgm_path: logger.info(f" ④ 背景音乐: {bgm_path}") logger.info(f" ⑤ 输出: {output_path}") # 加载视频 try: video_clip = VideoFileClip(video_path) logger.info(f"视频尺寸: {video_clip.size[0]}x{video_clip.size[1]}, 时长: {video_clip.duration}秒") # 提取视频原声(如果需要) original_audio = None if keep_original_audio and original_audio_volume > 0: try: original_audio = video_clip.audio if original_audio: original_audio = original_audio.with_effects([afx.MultiplyVolume(original_audio_volume)]) logger.info(f"已提取视频原声,音量设置为: {original_audio_volume}") else: logger.warning("视频没有音轨,无法提取原声") except Exception as e: logger.error(f"提取视频原声失败: {str(e)}") original_audio = None # 移除原始音轨,稍后会合并新的音频 video_clip = video_clip.without_audio() except Exception as e: logger.error(f"加载视频失败: {str(e)}") raise # 处理背景音乐和所有音频轨道合成 audio_tracks = [] # 先添加主音频(配音) if audio_path and os.path.exists(audio_path): try: voice_audio = AudioFileClip(audio_path).with_effects([afx.MultiplyVolume(voice_volume)]) audio_tracks.append(voice_audio) logger.info(f"已添加配音音频,音量: {voice_volume}") except Exception as e: logger.error(f"加载配音音频失败: {str(e)}") # 添加原声(如果需要) if original_audio is not None: audio_tracks.append(original_audio) logger.info(f"已添加视频原声,音量: {original_audio_volume}") # 添加背景音乐(如果有) if bgm_path and os.path.exists(bgm_path): try: bgm_clip = AudioFileClip(bgm_path).with_effects([ afx.MultiplyVolume(bgm_volume), afx.AudioFadeOut(3), afx.AudioLoop(duration=video_clip.duration), ]) audio_tracks.append(bgm_clip) logger.info(f"已添加背景音乐,音量: {bgm_volume}") except Exception as e: logger.error(f"添加背景音乐失败: \n{traceback.format_exc()}") # 合成最终的音频轨道 if audio_tracks: final_audio = CompositeAudioClip(audio_tracks) video_clip = video_clip.with_audio(final_audio) logger.info(f"已合成所有音频轨道,共{len(audio_tracks)}个") else: logger.warning("没有可用的音频轨道,输出视频将没有声音") # 处理字体路径 font_path = None if subtitle_path and subtitle_font: font_path = os.path.join(utils.font_dir(), subtitle_font) if os.name == "nt": font_path = font_path.replace("\\", "/") logger.info(f"使用字体: {font_path}") # 处理视频尺寸 video_width, video_height = video_clip.size # 字幕处理函数 def create_text_clip(subtitle_item): """创建单个字幕片段""" phrase = subtitle_item[1] max_width = video_width * 0.9 # 如果有字体路径,进行文本换行处理 wrapped_txt = phrase txt_height = 0 if font_path: wrapped_txt, txt_height = wrap_text( phrase, max_width=max_width, font=font_path, fontsize=subtitle_font_size ) # 创建文本片段 try: _clip = TextClip( text=wrapped_txt, font=font_path, font_size=subtitle_font_size, color=subtitle_color, bg_color=subtitle_bg_color, # 这里已经在前面处理过,None表示透明 stroke_color=stroke_color, stroke_width=stroke_width, ) except Exception as e: logger.error(f"创建字幕片段失败: {str(e)}, 使用简化参数重试") # 如果上面的方法失败,尝试使用更简单的参数 _clip = TextClip( text=wrapped_txt, font=font_path, font_size=subtitle_font_size, color=subtitle_color, ) # 设置字幕时间 duration = subtitle_item[0][1] - subtitle_item[0][0] _clip = _clip.with_start(subtitle_item[0][0]) _clip = _clip.with_end(subtitle_item[0][1]) _clip = _clip.with_duration(duration) # 设置字幕位置 if subtitle_position == "bottom": _clip = _clip.with_position(("center", video_height * 0.95 - _clip.h)) elif subtitle_position == "top": _clip = _clip.with_position(("center", video_height * 0.05)) elif subtitle_position == "custom": margin = 10 max_y = video_height - _clip.h - margin min_y = margin custom_y = (video_height - _clip.h) * (custom_position / 100) custom_y = max( min_y, min(custom_y, max_y) ) _clip = _clip.with_position(("center", custom_y)) else: # center _clip = _clip.with_position(("center", "center")) return _clip # 创建TextClip工厂函数 def make_textclip(text): return TextClip( text=text, font=font_path, font_size=subtitle_font_size, color=subtitle_color, ) # 处理字幕 if subtitle_path and os.path.exists(subtitle_path): try: # 加载字幕文件 sub = SubtitlesClip( subtitles=subtitle_path, encoding="utf-8", make_textclip=make_textclip ) # 创建每个字幕片段 text_clips = [] for item in sub.subtitles: clip = create_text_clip(subtitle_item=item) text_clips.append(clip) # 合成视频和字幕 video_clip = CompositeVideoClip([video_clip, *text_clips]) logger.info(f"已添加{len(text_clips)}个字幕片段") except Exception as e: logger.error(f"处理字幕失败: \n{traceback.format_exc()}") # 导出最终视频 try: video_clip.write_videofile( output_path, audio_codec="aac", temp_audiofile_path=output_dir, threads=threads, fps=fps, ) logger.success(f"素材合并完成: {output_path}") except Exception as e: logger.error(f"导出视频失败: {str(e)}") raise finally: # 释放资源 video_clip.close() del video_clip return output_path def wrap_text(text, max_width, font="Arial", fontsize=60): """ 文本换行函数,使长文本适应指定宽度 参数: text: 需要换行的文本 max_width: 最大宽度(像素) font: 字体路径 fontsize: 字体大小 返回: 换行后的文本和文本高度 """ # 创建ImageFont对象 try: font_obj = ImageFont.truetype(font, fontsize) except: # 如果无法加载指定字体,使用默认字体 font_obj = ImageFont.load_default() def get_text_size(inner_text): inner_text = inner_text.strip() left, top, right, bottom = font_obj.getbbox(inner_text) return right - left, bottom - top width, height = get_text_size(text) if width <= max_width: return text, height 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 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 return result, height if __name__ == '__main__': merger_mp4 = '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/merger.mp4' merger_sub = '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/merged_subtitle_00_00_00-00_01_30.srt' merger_audio = '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/merger_audio.mp3' bgm_path = '/Users/apple/Desktop/home/NarratoAI/resource/songs/bgm.mp3' output_video = '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/combined_test.mp4' # 调用示例 options = { 'voice_volume': 1.0, # 配音音量 'bgm_volume': 0.1, # 背景音乐音量 'original_audio_volume': 1.0, # 视频原声音量,0表示不保留 'keep_original_audio': True, # 是否保留原声 'subtitle_font': 'MicrosoftYaHeiNormal.ttc', # 这里使用相对字体路径,会自动在 font_dir() 目录下查找 'subtitle_font_size': 40, 'subtitle_color': '#FFFFFF', 'subtitle_bg_color': None, # 直接使用None表示透明背景 'subtitle_position': 'bottom', 'threads': 2 } try: merge_materials( video_path=merger_mp4, audio_path=merger_audio, subtitle_path=merger_sub, bgm_path=bgm_path, output_path=output_video, options=options ) except Exception as e: logger.error(f"合并素材失败: \n{traceback.format_exc()}")