refactor(app): 重构视频剪辑功能并优化性能

- 移除了未使用的性能监控模块- 重新实现了硬件加速检测逻辑
- 优化了 FFmpeg命令生成和执行流程- 改进了视频文件命名规则
- 调整了错误处理和日志记录方式
This commit is contained in:
linyq 2025-05-07 23:08:26 +08:00
parent bacc1adfad
commit 0ccb019f88
15 changed files with 102 additions and 202 deletions

Binary file not shown.

View File

@ -7,7 +7,7 @@ from datetime import datetime
import json import json
import requests import requests
from typing import List from typing import List, Optional
from loguru import logger from loguru import logger
from moviepy.video.io.VideoFileClip import VideoFileClip from moviepy.video.io.VideoFileClip import VideoFileClip
@ -307,7 +307,50 @@ def format_timestamp(seconds: float) -> str:
return f"{hours:02d}:{minutes:02d}:{whole_seconds:02d},{milliseconds:03d}" return f"{hours:02d}:{minutes:02d}:{whole_seconds:02d},{milliseconds:03d}"
def save_clip_video(timestamp: str, origin_video: str, save_dir: str = "") -> dict: def _detect_hardware_acceleration() -> Optional[str]:
"""
检测系统可用的硬件加速器
Returns:
Optional[str]: 硬件加速参数如果不支持则返回None
"""
# 检查NVIDIA GPU支持
try:
nvidia_check = subprocess.run(
["ffmpeg", "-hwaccel", "cuda", "-i", "/dev/null", "-f", "null", "-"],
stderr=subprocess.PIPE, stdout=subprocess.PIPE, text=True, check=False
)
if nvidia_check.returncode == 0:
return "cuda"
except Exception:
pass
# 检查MacOS videotoolbox支持
try:
videotoolbox_check = subprocess.run(
["ffmpeg", "-hwaccel", "videotoolbox", "-i", "/dev/null", "-f", "null", "-"],
stderr=subprocess.PIPE, stdout=subprocess.PIPE, text=True, check=False
)
if videotoolbox_check.returncode == 0:
return "videotoolbox"
except Exception:
pass
# 检查Intel Quick Sync支持
try:
qsv_check = subprocess.run(
["ffmpeg", "-hwaccel", "qsv", "-i", "/dev/null", "-f", "null", "-"],
stderr=subprocess.PIPE, stdout=subprocess.PIPE, text=True, check=False
)
if qsv_check.returncode == 0:
return "qsv"
except Exception:
pass
return None
def save_clip_video(timestamp: str, origin_video: str, save_dir: str = "") -> str:
""" """
保存剪辑后的视频 保存剪辑后的视频
@ -329,20 +372,25 @@ def save_clip_video(timestamp: str, origin_video: str, save_dir: str = "") -> di
if not os.path.exists(save_dir): if not os.path.exists(save_dir):
os.makedirs(save_dir) os.makedirs(save_dir)
# 生成更规范的视频文件名 # 解析时间戳
video_id = f"vid-{timestamp.replace(':', '-').replace(',', '_')}" start_str, end_str = timestamp.split('-')
video_path = os.path.join(save_dir, f"{video_id}.mp4")
# 格式化输出文件名(使用连字符替代冒号和逗号)
safe_start_time = start_str.replace(':', '-').replace(',', '-')
safe_end_time = end_str.replace(':', '-').replace(',', '-')
output_filename = f"vid_{safe_start_time}@{safe_end_time}.mp4"
video_path = os.path.join(save_dir, output_filename)
# 如果视频已存在,直接返回 # 如果视频已存在,直接返回
if os.path.exists(video_path) and os.path.getsize(video_path) > 0: if os.path.exists(video_path) and os.path.getsize(video_path) > 0:
logger.info(f"视频已存在: {video_path}") logger.info(f"视频已存在: {video_path}")
return {timestamp: video_path} return video_path
try: try:
# 检查视频是否存在 # 检查视频是否存在
if not os.path.exists(origin_video): if not os.path.exists(origin_video):
logger.error(f"源视频文件不存在: {origin_video}") logger.error(f"源视频文件不存在: {origin_video}")
return {} return ''
# 获取视频总时长 # 获取视频总时长
try: try:
@ -351,17 +399,16 @@ def save_clip_video(timestamp: str, origin_video: str, save_dir: str = "") -> di
total_duration = float(subprocess.check_output(probe_cmd).decode('utf-8').strip()) total_duration = float(subprocess.check_output(probe_cmd).decode('utf-8').strip())
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
logger.error(f"获取视频时长失败: {str(e)}") logger.error(f"获取视频时长失败: {str(e)}")
return {} return ''
# 解析时间戳 # 计算时间点
start_str, end_str = timestamp.split('-')
start = time_to_seconds(start_str) start = time_to_seconds(start_str)
end = time_to_seconds(end_str) end = time_to_seconds(end_str)
# 验证时间段 # 验证时间段
if start >= total_duration: if start >= total_duration:
logger.warning(f"起始时间 {format_timestamp(start)} ({start:.3f}秒) 超出视频总时长 {format_timestamp(total_duration)} ({total_duration:.3f}秒)") logger.warning(f"起始时间 {format_timestamp(start)} ({start:.3f}秒) 超出视频总时长 {format_timestamp(total_duration)} ({total_duration:.3f}秒)")
return {} return ''
if end > total_duration: if end > total_duration:
logger.warning(f"结束时间 {format_timestamp(end)} ({end:.3f}秒) 超出视频总时长 {format_timestamp(total_duration)} ({total_duration:.3f}秒),将自动调整为视频结尾") logger.warning(f"结束时间 {format_timestamp(end)} ({end:.3f}秒) 超出视频总时长 {format_timestamp(total_duration)} ({total_duration:.3f}秒),将自动调整为视频结尾")
@ -369,73 +416,38 @@ def save_clip_video(timestamp: str, origin_video: str, save_dir: str = "") -> di
if end <= start: if end <= start:
logger.warning(f"结束时间 {format_timestamp(end)} 必须大于起始时间 {format_timestamp(start)}") logger.warning(f"结束时间 {format_timestamp(end)} 必须大于起始时间 {format_timestamp(start)}")
return {} return ''
# 计算剪辑时长 # 计算剪辑时长
duration = end - start duration = end - start
logger.info(f"开始剪辑视频: {format_timestamp(start)} - {format_timestamp(end)},时长 {format_timestamp(duration)}") # logger.info(f"开始剪辑视频: {format_timestamp(start)} - {format_timestamp(end)},时长 {format_timestamp(duration)}")
# 检测可用的硬件加速选项 # 检测可用的硬件加速选项
hwaccel = _detect_hardware_acceleration() hwaccel = _detect_hardware_acceleration()
hwaccel_args = []
# 构建ffmpeg命令
ffmpeg_cmd = ["ffmpeg", "-y"]
# 添加硬件加速参数(如果可用)
if hwaccel: if hwaccel:
if hwaccel == "cuda": hwaccel_args = ["-hwaccel", hwaccel]
ffmpeg_cmd.extend(["-hwaccel", "cuda"])
elif hwaccel == "videotoolbox": # macOS
ffmpeg_cmd.extend(["-hwaccel", "videotoolbox"])
elif hwaccel == "qsv": # Intel Quick Sync
ffmpeg_cmd.extend(["-hwaccel", "qsv"])
elif hwaccel == "vaapi": # Linux VA-API
ffmpeg_cmd.extend(["-hwaccel", "vaapi", "-vaapi_device", "/dev/dri/renderD128"])
elif hwaccel == "dxva2": # Windows DXVA2
ffmpeg_cmd.extend(["-hwaccel", "dxva2"])
logger.info(f"使用硬件加速: {hwaccel}") logger.info(f"使用硬件加速: {hwaccel}")
# 设置输入选项和精确剪辑时间范围 # 转换为FFmpeg兼容的时间格式逗号替换为点
ffmpeg_cmd.extend([ ffmpeg_start_time = start_str.replace(',', '.')
"-ss", str(start), # 从这个时间点开始 ffmpeg_end_time = end_str.replace(',', '.')
"-t", str(duration), # 剪辑的持续时间
"-i", origin_video, # 输入文件
"-map_metadata", "-1" # 移除元数据
])
# 设置视频编码参数 # 构建FFmpeg命令
if hwaccel == "cuda": ffmpeg_cmd = [
ffmpeg_cmd.extend(["-c:v", "h264_nvenc", "-preset", "p4", "-profile:v", "high"]) "ffmpeg", "-y", *hwaccel_args,
elif hwaccel == "videotoolbox": "-i", origin_video,
ffmpeg_cmd.extend(["-c:v", "h264_videotoolbox", "-profile:v", "high"]) "-ss", ffmpeg_start_time,
elif hwaccel == "qsv": "-to", ffmpeg_end_time,
ffmpeg_cmd.extend(["-c:v", "h264_qsv", "-preset", "medium"]) "-c:v", "h264_videotoolbox" if hwaccel == "videotoolbox" else "libx264",
elif hwaccel == "vaapi": "-c:a", "aac",
ffmpeg_cmd.extend(["-c:v", "h264_vaapi", "-profile", "high"]) "-strict", "experimental",
else: video_path
ffmpeg_cmd.extend(["-c:v", "libx264", "-preset", "medium", "-profile:v", "high"]) ]
# 音频编码参数(检查是否有音频流) # 执行FFmpeg命令
audio_check_cmd = ["ffprobe", "-i", origin_video, "-show_streams", "-select_streams", "a", logger.info(f"裁剪视频片段: {timestamp} -> {ffmpeg_start_time}{ffmpeg_end_time}")
"-loglevel", "error", "-print_format", "json"] # logger.debug(f"执行命令: {' '.join(ffmpeg_cmd)}")
audio_result = subprocess.run(audio_check_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
audio_info = json.loads(audio_result.stdout) if audio_result.stdout else {"streams": []}
has_audio = len(audio_info.get("streams", [])) > 0
if has_audio:
ffmpeg_cmd.extend(["-c:a", "aac", "-b:a", "128k"])
else:
ffmpeg_cmd.extend(["-an"]) # 没有音频
# 设置输出视频参数
ffmpeg_cmd.extend([
"-pix_fmt", "yuv420p", # 兼容性更好的颜色格式
"-movflags", "+faststart", # 优化MP4文件结构以便快速开始播放
video_path # 输出文件
])
# 执行ffmpeg命令
logger.debug(f"执行命令: {' '.join(ffmpeg_cmd)}")
process = subprocess.run( process = subprocess.run(
ffmpeg_cmd, ffmpeg_cmd,
@ -450,7 +462,7 @@ def save_clip_video(timestamp: str, origin_video: str, save_dir: str = "") -> di
logger.error(f"视频剪辑失败: {process.stderr}") logger.error(f"视频剪辑失败: {process.stderr}")
if os.path.exists(video_path): if os.path.exists(video_path):
os.remove(video_path) os.remove(video_path)
return {} return ''
# 验证生成的视频文件 # 验证生成的视频文件
if os.path.exists(video_path) and os.path.getsize(video_path) > 0: if os.path.exists(video_path) and os.path.getsize(video_path) > 0:
@ -460,68 +472,18 @@ def save_clip_video(timestamp: str, origin_video: str, save_dir: str = "") -> di
if validate_result.returncode == 0: if validate_result.returncode == 0:
logger.info(f"视频剪辑成功: {video_path}") logger.info(f"视频剪辑成功: {video_path}")
return {timestamp: video_path} return video_path
logger.error("视频文件验证失败") logger.error("视频文件验证失败")
if os.path.exists(video_path): if os.path.exists(video_path):
os.remove(video_path) os.remove(video_path)
return {} return ''
except Exception as e: except Exception as e:
logger.error(f"视频剪辑过程中发生错误: \n{str(traceback.format_exc())}") logger.error(f"视频剪辑过程中发生错误: \n{str(traceback.format_exc())}")
if os.path.exists(video_path): if os.path.exists(video_path):
os.remove(video_path) os.remove(video_path)
return {} return ''
return {}
def _detect_hardware_acceleration() -> str:
"""
检测系统可用的硬件加速器
Returns:
str: 可用的硬件加速类型如果没有找到返回空字符串
"""
import platform
system = platform.system().lower()
# 测试常见的硬件加速类型
acceleration_types = []
if system == 'darwin': # macOS
acceleration_types = ["videotoolbox"]
elif system == 'linux':
acceleration_types = ["vaapi", "cuda", "nvenc"]
elif system == 'windows':
acceleration_types = ["cuda", "nvenc", "dxva2", "qsv"]
for accel in acceleration_types:
test_cmd = [
"ffmpeg",
"-hide_banner",
"-loglevel", "error",
"-hwaccel", accel,
"-i", "/dev/null", # 这不是实际文件,但是足以测试硬件加速器是否可用
"-f", "null",
"-"
]
try:
result = subprocess.run(test_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=1)
# 某些硬件加速器会报错但仍然可以使用我们主要检查的是CUDA和类似的错误
stderr = result.stderr.decode('utf-8', errors='ignore')
if result.returncode == 0 or (
"No such file or directory" in stderr and
not any(x in stderr for x in ["Invalid", "Error", "not supported"])
):
logger.info(f"检测到可用的硬件加速器: {accel}")
return accel
except (subprocess.SubprocessError, OSError):
continue
logger.info("未检测到可用的硬件加速器,将使用软件编码")
return ""
def clip_videos(task_id: str, timestamp_terms: List[str], origin_video: str, progress_callback=None) -> dict: def clip_videos(task_id: str, timestamp_terms: List[str], origin_video: str, progress_callback=None) -> dict:
@ -544,7 +506,7 @@ def clip_videos(task_id: str, timestamp_terms: List[str], origin_video: str, pro
saved_video_path = save_clip_video(timestamp=item, origin_video=origin_video, save_dir=material_directory) saved_video_path = save_clip_video(timestamp=item, origin_video=origin_video, save_dir=material_directory)
if saved_video_path: if saved_video_path:
logger.info(f"video saved: {saved_video_path}") logger.info(f"video saved: {saved_video_path}")
video_paths.update(saved_video_path) video_paths.update({index+1:saved_video_path})
# 更新进度 # 更新进度
if progress_callback: if progress_callback:

View File

@ -166,6 +166,8 @@ def start_subclip(task_id: str, params: VideoClipParams, subclip_path_videos: di
params: 视频参数 params: 视频参数
subclip_path_videos: 视频片段路径 subclip_path_videos: 视频片段路径
""" """
global merged_audio_path, merged_subtitle_path
logger.info(f"\n\n## 开始任务: {task_id}") logger.info(f"\n\n## 开始任务: {task_id}")
sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=0) sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=0)
@ -259,7 +261,7 @@ def start_subclip(task_id: str, params: VideoClipParams, subclip_path_videos: di
""" """
logger.info("\n\n## 4. 合并音频和字幕") logger.info("\n\n## 4. 合并音频和字幕")
total_duration = sum([script["duration"] for script in new_script_list]) total_duration = sum([script["duration"] for script in new_script_list])
if tts_results: if tts_segments:
try: try:
# 合并音频文件 # 合并音频文件
merged_audio_path = audio_merger.merge_audio_files( merged_audio_path = audio_merger.merge_audio_files(
@ -273,11 +275,10 @@ def start_subclip(task_id: str, params: VideoClipParams, subclip_path_videos: di
logger.info(f"字幕文件合并成功->{merged_subtitle_path}") logger.info(f"字幕文件合并成功->{merged_subtitle_path}")
except Exception as e: except Exception as e:
logger.error(f"合并音频文件失败: {str(e)}") logger.error(f"合并音频文件失败: {str(e)}")
else:
logger.warning("没有需要合并的音频/字幕")
merged_audio_path = "" merged_audio_path = ""
merged_subtitle_path = "" merged_subtitle_path = ""
else:
logger.error("TTS转换音频失败, 可能是网络不可用! 如果您在中国, 请使用VPN.")
return
""" """
5. 合并视频 5. 合并视频
@ -289,6 +290,7 @@ def start_subclip(task_id: str, params: VideoClipParams, subclip_path_videos: di
logger.info(f"\n\n## 5. 合并视频: => {combined_video_path}") logger.info(f"\n\n## 5. 合并视频: => {combined_video_path}")
# 如果 new_script_list 中没有 video则使用 subclip_path_videos 中的视频 # 如果 new_script_list 中没有 video则使用 subclip_path_videos 中的视频
video_clips = [new_script['video'] if new_script.get('video') else subclip_path_videos.get(new_script.get('_id', '')) for new_script in new_script_list] video_clips = [new_script['video'] if new_script.get('video') else subclip_path_videos.get(new_script.get('_id', '')) for new_script in new_script_list]
merger_video.combine_clip_videos( merger_video.combine_clip_videos(
output_video_path=combined_video_path, output_video_path=combined_video_path,
video_paths=video_clips, video_paths=video_clips,
@ -381,14 +383,15 @@ if __name__ == "__main__":
# 提前裁剪是为了方便检查视频 # 提前裁剪是为了方便检查视频
subclip_path_videos = { subclip_path_videos = {
1: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/588c37cba4e2e62714c24c4c9054fc51/vid-00-00-00_000-00-00-27_000.mp4', 1: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/113343d127b5a09d0bf84b68bd1b3b97/vid_00-00-05-390@00-00-57-980.mp4',
2: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/588c37cba4e2e62714c24c4c9054fc51/vid-00-00-30_000-00-00-57_000.mp4', 2: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/113343d127b5a09d0bf84b68bd1b3b97/vid_00-00-28-900@00-00-43-700.mp4',
3: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/588c37cba4e2e62714c24c4c9054fc51/vid-00-01-00_000-00-01-27_000.mp4', 3: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/113343d127b5a09d0bf84b68bd1b3b97/vid_00-01-17-840@00-01-27-600.mp4',
4: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/588c37cba4e2e62714c24c4c9054fc51/vid-00-01-30_000-00-01-57_000.mp4', 4: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/113343d127b5a09d0bf84b68bd1b3b97/vid_00-02-35-460@00-02-52-380.mp4',
5: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/113343d127b5a09d0bf84b68bd1b3b97/vid_00-06-59-520@00-07-29-500.mp4',
} }
params = VideoClipParams( params = VideoClipParams(
video_clip_json_path="/Users/apple/Desktop/home/NarratoAI/resource/scripts/2025-0507-185159.json", video_clip_json_path="/Users/apple/Desktop/home/NarratoAI/resource/scripts/2025-0507-223311.json",
video_origin_path="/Users/apple/Desktop/home/NarratoAI/resource/videos/test.mp4", video_origin_path="/Users/apple/Desktop/home/NarratoAI/resource/videos/merged_video_4938.mp4",
) )
start_subclip(task_id, params, subclip_path_videos) start_subclip(task_id, params, subclip_path_videos)

View File

@ -8,7 +8,7 @@ from webui.components import basic_settings, video_settings, audio_settings, sub
from webui.utils import cache, file_utils from webui.utils import cache, file_utils
from app.utils import utils from app.utils import utils
from app.models.schema import VideoClipParams, VideoAspect from app.models.schema import VideoClipParams, VideoAspect
from webui.utils.performance import PerformanceMonitor
# 初始化配置 - 必须是第一个 Streamlit 命令 # 初始化配置 - 必须是第一个 Streamlit 命令
st.set_page_config( st.set_page_config(
@ -187,9 +187,6 @@ def render_generate_button():
file_utils.open_task_folder(config.root_dir, task_id) file_utils.open_task_folder(config.root_dir, task_id)
logger.info(tr("视频生成完成")) logger.info(tr("视频生成完成"))
# finally:
# PerformanceMonitor.cleanup_resources()
def main(): def main():
"""主函数""" """主函数"""

View File

@ -8,7 +8,7 @@ from webui.components import (
audio_settings, audio_settings,
subtitle_settings subtitle_settings
) )
from webui.utils import cache, file_utils, performance from webui.utils import cache, file_utils
__all__ = [ __all__ = [
'config', 'config',
@ -17,6 +17,5 @@ __all__ = [
'audio_settings', 'audio_settings',
'subtitle_settings', 'subtitle_settings',
'cache', 'cache',
'file_utils', 'file_utils'
'performance'
] ]

View File

@ -354,12 +354,11 @@ def crop_video(tr, params):
utils.cut_video(params, update_progress) utils.cut_video(params, update_progress)
time.sleep(0.5) time.sleep(0.5)
progress_bar.progress(100) progress_bar.progress(100)
status_text.text("剪完成!")
st.success("视频剪辑成功完成!") st.success("视频剪辑成功完成!")
except Exception as e: except Exception as e:
st.error(f"剪辑过程中发生错误: {str(e)}") st.error(f"剪辑过程中发生错误: {str(e)}")
finally: finally:
time.sleep(2) time.sleep(1)
progress_bar.empty() progress_bar.empty()
status_text.empty() status_text.empty()

View File

@ -1,8 +0,0 @@
from .performance import monitor_performance, PerformanceMonitor
from .cache import *
from .file_utils import *
__all__ = [
'monitor_performance',
'PerformanceMonitor'
]

View File

@ -1,52 +0,0 @@
# import psutil
import os
from loguru import logger
class PerformanceMonitor:
@staticmethod
def monitor_memory():
process = psutil.Process(os.getpid())
memory_info = process.memory_info()
logger.debug(f"Memory usage: {memory_info.rss / 1024 / 1024:.2f} MB")
# 延迟导入torch并检查CUDA
try:
import torch
if torch.cuda.is_available():
gpu_memory = torch.cuda.memory_allocated() / 1024 / 1024
logger.debug(f"GPU Memory usage: {gpu_memory:.2f} MB")
except (ImportError, RuntimeError) as e:
# 无法导入torch或触发CUDA相关错误时静默处理
logger.debug(f"无法获取GPU内存信息: {e}")
@staticmethod
def cleanup_resources():
# 延迟导入torch并清理CUDA
try:
import torch
if torch.cuda.is_available():
torch.cuda.empty_cache()
logger.debug("CUDA缓存已清理")
except (ImportError, RuntimeError) as e:
# 无法导入torch或触发CUDA相关错误时静默处理
logger.debug(f"无法清理CUDA资源: {e}")
import gc
gc.collect()
# 仅报告进程内存不尝试获取GPU内存
process = psutil.Process(os.getpid())
memory_info = process.memory_info()
logger.debug(f"Memory usage after cleanup: {memory_info.rss / 1024 / 1024:.2f} MB")
def monitor_performance(func):
"""性能监控装饰器"""
def wrapper(*args, **kwargs):
try:
PerformanceMonitor.monitor_memory()
result = func(*args, **kwargs)
return result
finally:
PerformanceMonitor.cleanup_resources()
return wrapper