mirror of
https://github.com/linyqh/NarratoAI.git
synced 2025-12-13 04:02:49 +00:00
feat(video): 更新视频剪辑逻辑,支持硬件加速和错误处理
- 添加视频存在性检查,避免处理不存在的源视频 - 引入硬件加速检测,优化视频剪辑性能 - 更新日志信息,提供更清晰的错误提示 - 移除不必要的资源释放代码,简化逻辑
This commit is contained in:
parent
2dc83bc18e
commit
bc732c10fd
@ -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:
|
||||||
"""
|
"""
|
||||||
剪辑视频
|
剪辑视频
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user