import json
import random
import re
import shutil
import time
from pathlib import Path
from moviepy.editor import (VideoFileClip, concatenate_videoclips,
AudioFileClip, CompositeAudioClip,
concatenate_audioclips)
from utils import file_utils
def get_subdirectory_videos(subdir):
"""获取指定子文件夹中的所有视频文件(适配 video_with_audio_sub_xxxx.mp4 格式)"""
subdir = Path(subdir)
if not subdir.is_dir():
return []
# 查找当前子文件夹中的视频文件(不递归到更深层次)
video_files = list(subdir.glob("video_with_audio_sub_*.mp4"))
if not video_files:
print(f"子文件夹 {subdir} 中未找到符合格式的视频文件")
return []
# 按文件名中的数字序号排序
try:
# 调整正则表达式以匹配包含effect的文件名
return sorted(video_files,
key=lambda x: int(re.search(r'video_with_audio_sub_(?:effect_)?(\d+)', x.stem).group(1)))
except (AttributeError, ValueError) as e:
print(f"警告: 子文件夹 {subdir} 中视频文件排序失败 - {e},将使用默认排序")
return sorted(video_files)
def get_subdirectories(parent_dir):
"""获取指定目录下的所有直接子文件夹"""
parent_dir = Path(parent_dir)
if not parent_dir.is_dir():
return []
# 只返回直接子文件夹,不包括更深层次的
return [d for d in parent_dir.iterdir() if d.is_dir()]
def safe_close(clip):
"""安全关闭视频片段,避免资源泄漏"""
try:
if clip:
# 先关闭音频流
if hasattr(clip, 'audio') and clip.audio:
try:
clip.audio.close()
except:
pass
# 再关闭视频流
try:
clip.close()
except:
pass
except Exception as e:
print(f"关闭视频片段时出错: {e}")
def get_background_music_list():
"""获取背景音乐文件路径列表"""
# 背景音乐位于与agents同级的config文件夹下
config_dir = Path(__file__).parent.parent / "config"
# 支持的音乐文件扩展名
music_extensions = ['.mp3', '.wav', '.ogg', '.flac', '.m4a']
music_files = []
# 查找所有背景音乐文件
for ext in music_extensions:
# 查找以 back_music 开头的文件
music_files.extend(list(config_dir.glob(f"back_music*{ext}")))
# 也可以查找其他命名模式的背景音乐
music_files.extend(list(config_dir.glob(f"background*{ext}")))
music_files.extend(list(config_dir.glob(f"bgm*{ext}")))
# 去重并排序
music_files = list(set(music_files))
music_files.sort()
if not music_files:
print(f"警告: 未找到背景音乐文件,搜索目录: {config_dir}")
return []
print(f"找到 {len(music_files)} 个背景音乐文件:")
for music in music_files:
print(f" - {music.name}")
return [str(music) for music in music_files]
def get_all_transition_sounds():
"""获取所有可用的转场音效"""
# 转场音效位于与agents同级的bgm文件夹下
bgm_dir = Path(__file__).parent.parent / "cut_bgm"
if not bgm_dir.exists() or not bgm_dir.is_dir():
print(f"警告: 未找到转场音效文件夹 {bgm_dir}")
return []
# 获取所有音频文件
audio_extensions = ['.mp3', '.wav', '.ogg', '.flac']
sound_files = []
for ext in audio_extensions:
sound_files.extend(list(bgm_dir.glob(f"*{ext}")))
if not sound_files:
print(f"警告: 转场音效文件夹 {bgm_dir} 中未找到音频文件")
return []
return [str(sound) for sound in sound_files]
def boost_audio(audio_clip, gain_db=9.0): # 增加原音频增益(从6dB提高到9dB)
"""增加音频增益"""
try:
# 将分贝转换为倍数
gain_factor = 10 ** (gain_db / 20)
return audio_clip.volumex(gain_factor)
except Exception as e:
print(f"音频增益处理失败: {e}")
return audio_clip
def process_background_music(video_clip, music_path, music_volume=0.07):
"""处理背景音乐:调整音量,根据视频时长循环或截断"""
try:
# 加载背景音乐
music = AudioFileClip(music_path)
# 获取视频时长和原音频
video_duration = video_clip.duration
original_audio = video_clip.audio
# 应用音量调整 - 使用与第一个代码相同的volumex方法
music = music.volumex(music_volume)
print(f"背景音乐音量已调整为原音量的 {music_volume * 100}%")
# 处理背景音乐时长
music_duration = music.duration
if music_duration >= video_duration:
# 音乐时长足够,截断到视频时长
music = music.subclip(0, video_duration)
else:
# 音乐时长不足,循环播放
num_loops = int(video_duration / music_duration) + 1
music_clips = [music for _ in range(num_loops)]
music = concatenate_audioclips(music_clips).subclip(0, video_duration)
# 混合原音频和背景音乐
if original_audio is not None:
# 增加原音频增益(使用更新后的9dB增益)
boosted_original_audio = boost_audio(original_audio)
final_audio = CompositeAudioClip([boosted_original_audio, music])
else:
final_audio = music
return final_audio
except Exception as e:
print(f"处理背景音乐时出错: {e}")
return None
def add_audio_crossfade(clips, crossfade_duration=0.6):
"""
在视频片段之间添加音频交叉淡入淡出效果,避免突然的音频截断
crossfade_duration: 交叉淡入淡出的时长(秒)
"""
if len(clips) <= 1:
return clips # 只有一个片段,不需要处理
processed_clips = []
# 处理第一个片段
first_clip = clips[0]
if first_clip.audio and first_clip.duration > crossfade_duration:
# 对第一个片段的音频末尾添加淡出效果
audio = first_clip.audio
fade_out_audio = audio.audio_fadeout(crossfade_duration)
first_clip = first_clip.set_audio(fade_out_audio)
processed_clips.append(first_clip)
# 处理中间片段
for i in range(1, len(clips) - 1):
current_clip = clips[i]
if current_clip.audio and current_clip.duration > crossfade_duration * 2:
# 对中间片段的音频添加淡入淡出效果
audio = current_clip.audio
fade_audio = audio.audio_fadein(crossfade_duration)
fade_audio = fade_audio.audio_fadeout(crossfade_duration)
current_clip = current_clip.set_audio(fade_audio)
processed_clips.append(current_clip)
# 处理最后一个片段 - 添加淡入和淡出
last_clip = clips[-1]
if last_clip.audio:
# 先添加淡入
if last_clip.duration > crossfade_duration:
audio = last_clip.audio
fade_audio = audio.audio_fadein(crossfade_duration)
# 再添加淡出
if last_clip.duration > crossfade_duration * 2:
fade_audio = fade_audio.audio_fadeout(crossfade_duration)
last_clip = last_clip.set_audio(fade_audio)
processed_clips.append(last_clip)
return processed_clips
def normalize_audio_levels(clips, target_dBFS=-14): # 提高目标音量(从-16dBFS提高到-14dBFS)
"""
标准化所有片段的音频电平,使其音量一致
target_dBFS: 目标音量级别(分贝全标度),提高目标值使声音更大
"""
processed_clips = []
for clip in clips:
if clip.audio:
# 计算当前音频的最大音量
try:
current_dBFS = clip.audio.max_volume()
# 计算需要的音量调整
if current_dBFS != 0: # 避免除以零
change_in_dBFS = target_dBFS - current_dBFS
# 应用音量调整
normalized_audio = clip.audio.volumex(10 ** (change_in_dBFS / 20))
clip = clip.set_audio(normalized_audio)
except Exception as e:
print(f"标准化音频电平时出错: {e}")
# 出错时保持原音频不变
processed_clips.append(clip)
return processed_clips
def add_transition_effects(clips):
"""
为视频片段添加转场音效:
- 第一个和第二个视频之间必须随机添加一个转场特效
- 其他视频每三个视频后随机添加一个转场特效
- 转场音效添加在后一个视频的第一秒
"""
if len(clips) <= 1:
return clips # 只有一个片段,不需要转场
processed_clips = []
all_transition_sounds = get_all_transition_sounds()
has_transition_sounds = len(all_transition_sounds) > 0
# 先添加第一个片段
processed_clips.append(clips[0])
# 遍历剩余片段,处理转场
for i in range(1, len(clips)):
current_clip = clips[i]
transition_sound = None
add_transition = False
# 转场逻辑:
# 1. 第一个和第二个视频之间(i=1)必须添加转场
# 2. 之后每三个视频后(i=4,7,10...)随机添加转场
if has_transition_sounds:
if i == 1:
# 第一个转场点:必须添加
add_transition = True
elif (i - 1) % 3 == 0:
# 每三个视频后的转场点:50%概率添加
add_transition = random.random() < 0.3
# 如果需要添加转场且有可用音效
if add_transition and has_transition_sounds:
# 随机选择一个转场音效
selected_sound_path = random.choice(all_transition_sounds)
print(f"在第 {i} 个视频前添加转场音效: {Path(selected_sound_path).name}")
try:
# 加载转场音效
transition_sound = AudioFileClip(selected_sound_path)
# 限制转场音效时长不超过1秒
if transition_sound.duration > 1:
transition_sound = transition_sound.subclip(0, 1)
# 调整转场音效音量(使用volumex方法,与第一个代码保持一致)
transition_sound = transition_sound.volumex(0.15)
except Exception as e:
print(f"加载转场音效时出错: {e}")
transition_sound = None
# 处理当前片段,添加转场音效(如果有)
if transition_sound:
# 确保当前视频足够长,可以添加1秒的转场
if current_clip.duration < 1:
# 视频太短,直接添加,不添加转场音效
processed_clips.append(current_clip)
try:
transition_sound.close()
except:
pass
continue
# 截取当前视频的前1秒
current_clip_first_part = current_clip.subclip(0, 1)
# 在当前视频的前1秒添加转场音效
final_audio = CompositeAudioClip([
current_clip_first_part.audio,
transition_sound.set_start(0) # 从0秒开始播放转场音效
])
# 创建带转场音效的前1秒视频片段
current_clip_with_transition = current_clip_first_part.set_audio(final_audio)
# 创建当前视频除了前1秒的部分
current_clip_main_part = current_clip.subclip(1, current_clip.duration)
# 添加处理后的当前片段
processed_clips.append(current_clip_with_transition)
processed_clips.append(current_clip_main_part)
# 关闭转场音效
try:
transition_sound.close()
except:
pass
else:
# 不添加转场音效,直接添加当前片段
processed_clips.append(current_clip)
return processed_clips
def concatenate_videos(video_files, output_path, music_path=None, progress_callback=None):
"""拼接所有已带字幕的视频文件并添加背景音乐和转场音效"""
if not video_files:
print("没有可用的视频文件进行拼接")
return False
total_files = len(video_files)
print(f"开始拼接 {total_files} 个已带字幕的视频文件...")
if progress_callback:
progress_callback(0, f"准备拼接 {total_files} 个视频文件")
# 加载所有视频片段
clips = []
try:
for i, video_file in enumerate(video_files):
progress = int((i / total_files) * 30) # 加载阶段占30%进度
if progress_callback:
progress_callback(progress, f"加载视频片段 {i + 1}/{total_files}")
print(f"加载视频片段 {i + 1}/{total_files}: {video_file}")
try:
clip = VideoFileClip(str(video_file))
clip = clip.set_fps(30) # 统一帧率
# 统一尺寸
if clips:
target_size = clips[0].size
if clip.size != target_size:
print(f"警告: 视频 {video_file.name} 尺寸与其他不同,将调整为 {target_size}")
clip = clip.resize(target_size)
clips.append(clip)
except Exception as e:
print(f"加载视频失败 {video_file}: {e}")
# 清理已加载的片段
for c in clips:
safe_close(c)
return False
if not clips:
print("没有成功加载任何视频片段")
return False
# 标准化音频电平
print("标准化音频电平...")
if progress_callback:
progress_callback(30, "正在标准化音频电平")
clips = normalize_audio_levels(clips) # 使用更新后的目标音量
# 添加音频交叉淡入淡出
print("添加音频交叉淡入淡出...")
clips = add_audio_crossfade(clips)
# 添加转场音效
print("添加转场音效...")
if progress_callback:
progress_callback(32, "正在添加转场音效")
clips_with_transitions = add_transition_effects(clips)
print("拼接所有视频片段...")
if progress_callback:
progress_callback(35, "开始拼接视频片段")
# 拼接所有片段
final_clip = concatenate_videoclips(clips_with_transitions, method="compose")
# 处理背景音乐,可通过参数调整音量
if music_path:
print(f"添加背景音乐: {Path(music_path).name}")
if progress_callback:
progress_callback(40, "正在处理背景音乐")
# 在这里可以调整背景音乐音量,例如设置为0.1表示原音量的10%
final_audio = process_background_music(final_clip, music_path, music_volume=0.07)
if final_audio:
final_clip = final_clip.set_audio(final_audio)
else:
print("使用原视频音频,未添加背景音乐")
else:
print("未指定背景音乐,使用原视频音频")
if progress_callback:
progress_callback(40, "未找到背景音乐,使用原视频音频")
# 如果最终音频仍然很小,增加整体增益
if final_clip.audio:
try:
# 对于CompositeAudioClip,我们使用另一种方式计算音量
if isinstance(final_clip.audio, CompositeAudioClip):
# 直接设置增益,略微提高整体音量
gain_factor = 1.5 # 增加整体音量
print(f"对复合音频应用固定增益: {gain_factor:.2f}倍")
boosted_audio = final_clip.audio.volumex(gain_factor)
final_clip = final_clip.set_audio(boosted_audio)
else:
# 检查音频的最大音量
max_volume = final_clip.audio.max_volume()
print(f"最终音频最大音量: {max_volume}")
# 如果音量小于0.5,增加增益
if max_volume < 0.5:
gain_needed = 0.9 / max_volume # 从0.8提高到0.9,增加所需增益
if gain_needed > 3.5: # 从3提高到3.5,允许更大的增益
gain_needed = 3.5
print(f"增加音频增益: {gain_needed:.2f}倍")
boosted_audio = final_clip.audio.volumex(gain_needed)
final_clip = final_clip.set_audio(boosted_audio)
except Exception as e:
print(f"增加音频增益时出错: {e}")
# 准备输出
output_path.parent.mkdir(parents=True, exist_ok=True)
temp_output = output_path.with_suffix('.temp.mp4')
# 备份已有文件
if output_path.exists():
backup_path = output_path.with_suffix(f'.backup_{int(time.time())}.mp4')
shutil.move(str(output_path), str(backup_path))
print(f"已存在同名文件,已备份至: {backup_path}")
print(f"正在渲染最终视频到: {output_path}")
if progress_callback:
progress_callback(50, "开始渲染最终视频")
# 渲染进度回调
def render_progress(progress):
render_percent = 50 + int(progress * 0.5)
if progress_callback:
progress_callback(render_percent, f"正在渲染: {int(progress * 100)}%")
# 写入视频文件
final_clip.write_videofile(
str(temp_output),
codec="libx264",
audio_codec="aac",
fps=30,
threads=1,
preset="medium",
# logger=None,
)
temp_output.rename(output_path)
print(f"视频拼接完成! 保存至: {output_path}")
if progress_callback:
progress_callback(100, "视频拼接完成")
return True
except Exception as e:
print(f"视频渲染失败: {e}")
if 'temp_output' in locals() and temp_output.exists():
temp_output.unlink()
return False
finally:
# 确保所有资源都被释放
print("释放视频资源...")
for clip in clips:
safe_close(clip)
if 'final_clip' in locals():
safe_close(final_clip)
def generate_final_videos_by_subdir(progress_callback=None):
"""按子文件夹生成拼接视频,每个子文件夹生成一个视频文件并存储在对应子目录中"""
config = file_utils.load_config()
# 1. 获取视频文件所在的父目录
try:
parent_dir = file_utils.ensure_directory_exists(config['paths']['subtitled_videos_dir'])
except KeyError:
parent_dir = file_utils.ensure_directory_exists(config['paths']['output_dir'])
# 2. 获取所有直接子文件夹
subdirectories = get_subdirectories(parent_dir)
if not subdirectories:
print(f"在目录 {parent_dir} 下未找到任何子文件夹")
return False
print(f"找到 {len(subdirectories)} 个子文件夹,将逐个处理...")
if progress_callback:
progress_callback(0, f"找到 {len(subdirectories)} 个子文件夹")
# 3. 获取背景音乐列表
background_music_list = get_background_music_list()
if not background_music_list:
print("警告: 未找到任何背景音乐文件,所有视频将使用原音频")
else:
print(f"已加载 {len(background_music_list)} 个背景音乐文件,将循环使用")
# 4. 准备视频输出目录
try:
video_dir = file_utils.ensure_directory_exists(config['paths']['video_dir'])
except KeyError:
print("警告: 配置中缺少video_dir,使用默认路径./data/video")
video_dir = Path(__file__).parent.parent / "data" / "video"
video_dir.mkdir(parents=True, exist_ok=True)
# 5. 逐个处理子文件夹
all_success = True
total_subdirs = len(subdirectories)
for idx, subdir in enumerate(subdirectories, 1):
subdir_name = subdir.name
print(f"\n===== 开始处理子文件夹 ({idx}/{total_subdirs}): {subdir_name} =====")
# 为当前子文件夹选择背景音乐(循环使用)
current_music = None
if background_music_list:
music_index = (idx - 1) % len(background_music_list) # 循环索引
current_music = background_music_list[music_index]
print(f"使用背景音乐: {Path(current_music).name} (第 {music_index + 1}/{len(background_music_list)} 个)")
# 计算当前子文件夹处理在总进度中的占比
subdir_progress_base = int((idx - 1) / total_subdirs * 100)
subdir_progress_range = int(100 / total_subdirs)
def subdir_progress_callback(percent, message):
"""子文件夹处理的进度回调包装器"""
overall_percent = subdir_progress_base + int(percent / 100 * subdir_progress_range)
if progress_callback:
progress_callback(overall_percent, f"子文件夹 {subdir_name}: {message}")
# 获取当前子文件夹中的视频文件
video_files = get_subdirectory_videos(subdir)
if not video_files:
print(f"子文件夹 {subdir_name} 中没有可处理的视频文件,跳过...")
all_success = False
continue
subdir_progress_callback(0, f"找到 {len(video_files)} 个视频文件")
# 创建对应子目录
output_subdir = video_dir / subdir_name
output_subdir.mkdir(parents=True, exist_ok=True)
# 查找当前子目录中现有视频文件,确定起始编号
existing_files = list(output_subdir.glob("final_video_*.mp4"))
if existing_files:
# 提取现有文件的编号
numbers = []
for file in existing_files:
match = re.search(r'final_video_(\d+)\.mp4', str(file))
if match:
try:
numbers.append(int(match.group(1)))
except ValueError:
continue
current_number = max(numbers) + 1 if numbers else 1
else:
current_number = 1
# 生成输出文件名(基于顺序编号)
output_name = f"final_video_{current_number:03d}.mp4" # 使用3位数字编号,确保排序正确
output_path = output_subdir / output_name
# 拼接当前子文件夹中的视频,传入选定的背景音乐
success = concatenate_videos(video_files, output_path, current_music, subdir_progress_callback)
if success:
# 计算总时长
total_duration = 0
for f in video_files:
clip = None
try:
clip = VideoFileClip(str(f))
total_duration += clip.duration
except Exception as e:
print(f"计算视频 {f} 时长时出错: {e}")
finally:
safe_close(clip)
# 保存视频信息(更新了音频相关参数)
video_info = {
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
"source_subdirectory": str(subdir),
"video_count": len(video_files),
"output_path": str(output_path),
"total_duration": total_duration,
"source_files": [str(f) for f in video_files],
"background_music": current_music if current_music else "None",
"background_music_name": Path(current_music).name if current_music else "None",
"has_background_music": current_music is not None,
"background_music_volume": 0.07, # 更新为当前使用的背景音乐音量
"has_transition_effects": len(get_all_transition_sounds()) > 0,
"has_audio_crossfade": True,
"audio_crossfade_duration": 0.1,
"audio_boost_applied": True,
"audio_boost_amount_db": 9.0, # 更新为当前使用的增益值
"audio_normalization_target": -14 # 更新为当前使用的标准化目标值
}
info_path = output_path.with_suffix('.json')
with open(info_path, 'w', encoding='utf-8') as f:
json.dump(video_info, f, indent=2, ensure_ascii=False)
print(f"子文件夹 {subdir_name} 视频信息保存至: {info_path}")
else:
print(f"子文件夹 {subdir_name} 视频拼接失败")
all_success = False
return all_success
if __name__ == '__main__':
print("开始按子文件夹拼接已带字幕的视频文件...")
start_time = time.time()
def print_progress(percent, message):
"""命令行进度显示回调函数"""
print(f"进度: {percent}% - {message}")
try:
if generate_final_videos_by_subdir(print_progress):
duration = time.time() - start_time
print(f"\n所有子文件夹视频拼接完成! 总耗时: {duration:.2f}秒")
else:
print("\n部分或全部子文件夹视频拼接失败")
except Exception as e:
print(f"\n视频拼接过程中出错: {e}")
import traceback
traceback.print_exc()
加载背景音乐后,在应用的时候不按照顺序应用,采用随机的方式
最新发布