feat(video): 更新视频剪辑逻辑,支持硬件加速和错误处理

- 添加视频存在性检查,避免处理不存在的源视频
- 引入硬件加速检测,优化视频剪辑性能
- 更新日志信息,提供更清晰的错误提示
- 移除不必要的资源释放代码,简化逻辑
This commit is contained in:
linyq 2025-05-07 19:03:21 +08:00
parent 2dc83bc18e
commit bc732c10fd
2 changed files with 156 additions and 46 deletions

View File

@ -4,6 +4,7 @@ import random
import traceback import traceback
from urllib.parse import urlencode from urllib.parse import urlencode
from datetime import datetime from datetime import datetime
import json
import requests import requests
from typing import List from typing import List
@ -332,14 +333,25 @@ def save_clip_video(timestamp: str, origin_video: str, save_dir: str = "") -> di
video_id = f"vid-{timestamp.replace(':', '-').replace(',', '_')}" video_id = f"vid-{timestamp.replace(':', '-').replace(',', '_')}"
video_path = os.path.join(save_dir, f"{video_id}.mp4") video_path = os.path.join(save_dir, f"{video_id}.mp4")
# 如果视频已存在,直接返回
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 already exists: {video_path}") logger.info(f"视频已存在: {video_path}")
return {timestamp: video_path} return {timestamp: video_path}
try: try:
# 加载视频获取总时长 # 检查视频是否存在
video = VideoFileClip(origin_video) if not os.path.exists(origin_video):
total_duration = video.duration logger.error(f"源视频文件不存在: {origin_video}")
return {}
# 获取视频总时长
try:
probe_cmd = ["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", origin_video]
total_duration = float(subprocess.check_output(probe_cmd).decode('utf-8').strip())
except subprocess.CalledProcessError as e:
logger.error(f"获取视频时长失败: {str(e)}")
return {}
# 解析时间戳 # 解析时间戳
start_str, end_str = timestamp.split('-') start_str, end_str = timestamp.split('-')
@ -349,7 +361,6 @@ def save_clip_video(timestamp: str, origin_video: str, save_dir: str = "") -> di
# 验证时间段 # 验证时间段
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}秒)")
video.close()
return {} return {}
if end > total_duration: if end > total_duration:
@ -358,57 +369,161 @@ 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)}")
video.close()
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)}")
# 剪辑视频 # 检测可用的硬件加速选项
subclip = video.subclip(start, end) hwaccel = _detect_hardware_acceleration()
try: # 构建ffmpeg命令
# 检查视频是否有音频轨道并写入文件 ffmpeg_cmd = ["ffmpeg", "-y"]
subclip.write_videofile(
video_path, # 添加硬件加速参数(如果可用)
codec='libx264', if hwaccel:
audio_codec='aac', if hwaccel == "cuda":
temp_audiofile='temp-audio.m4a', ffmpeg_cmd.extend(["-hwaccel", "cuda"])
remove_temp=True, elif hwaccel == "videotoolbox": # macOS
audio=(subclip.audio is not None), ffmpeg_cmd.extend(["-hwaccel", "videotoolbox"])
logger=None 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}")
# 设置输入选项和精确剪辑时间范围
ffmpeg_cmd.extend([
"-ss", str(start), # 从这个时间点开始
"-t", str(duration), # 剪辑的持续时间
"-i", origin_video, # 输入文件
"-map_metadata", "-1" # 移除元数据
])
# 设置视频编码参数
if hwaccel == "cuda":
ffmpeg_cmd.extend(["-c:v", "h264_nvenc", "-preset", "p4", "-profile:v", "high"])
elif hwaccel == "videotoolbox":
ffmpeg_cmd.extend(["-c:v", "h264_videotoolbox", "-profile:v", "high"])
elif hwaccel == "qsv":
ffmpeg_cmd.extend(["-c:v", "h264_qsv", "-preset", "medium"])
elif hwaccel == "vaapi":
ffmpeg_cmd.extend(["-c:v", "h264_vaapi", "-profile", "high"])
else:
ffmpeg_cmd.extend(["-c:v", "libx264", "-preset", "medium", "-profile:v", "high"])
# 音频编码参数(检查是否有音频流)
audio_check_cmd = ["ffprobe", "-i", origin_video, "-show_streams", "-select_streams", "a",
"-loglevel", "error", "-print_format", "json"]
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(
ffmpeg_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=False # 不抛出异常,我们会检查返回码
) )
# 检查是否成功
if process.returncode != 0:
logger.error(f"视频剪辑失败: {process.stderr}")
if os.path.exists(video_path):
os.remove(video_path)
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:
with VideoFileClip(video_path) as clip: # 检查视频是否可播放
if clip.duration > 0 and clip.fps > 0: probe_cmd = ["ffprobe", "-v", "error", video_path]
validate_result = subprocess.run(probe_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if validate_result.returncode == 0:
logger.info(f"视频剪辑成功: {video_path}")
return {timestamp: video_path} return {timestamp: video_path}
raise ValueError("视频文件验证失败") logger.error("视频文件验证失败")
except Exception as e:
logger.warning(f"视频文件处理失败: {video_path} => {str(e)}")
if os.path.exists(video_path): if os.path.exists(video_path):
os.remove(video_path) os.remove(video_path)
return {}
except Exception as e: except Exception as e:
logger.warning(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)
finally: return {}
# 确保视频对象被正确关闭
try:
video.close()
if 'subclip' in locals():
subclip.close()
except:
pass
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:
""" """
剪辑视频 剪辑视频

View File

@ -172,12 +172,7 @@
speech_region="" speech_region=""
[frames] [frames]
skip_seconds = 0 # 提取关键帧的间隔时间
# threshold差异阈值用于判断两个连续帧之间是否发生了场景切换 frame_interval_input = 3
# 较小的阈值(如 20更敏感能捕捉到细微的场景变化但可能会误判关键帧图片更多
# 较大的阈值(如 40更保守只捕捉明显的场景切换但可能会漏掉渐变场景关键帧图片更少
# 默认值 30在实践中是一个比较平衡的选择
threshold = 30
version = "v2"
# 大模型单次处理的关键帧数量 # 大模型单次处理的关键帧数量
vision_batch_size = 10 vision_batch_size = 10