Files
wehub-resource-sync 41b710f9c7
CI / Frontend checks (push) Failing after 0s
CI / Backend tests (push) Failing after 1s
I18n Documentation Sync / sync-docs (push) Failing after 0s
chore: import upstream snapshot with attribution
2026-07-13 12:28:40 +08:00

460 lines
17 KiB
Python

"""
视频处理工具
"""
import subprocess
import json
import logging
import re
from typing import List, Dict, Optional
from pathlib import Path
from .ffmpeg_utils import get_ffmpeg_path, get_ffprobe_path
# 修复导入问题
try:
from ..core.shared_config import CLIPS_DIR, COLLECTIONS_DIR
except ImportError:
# 如果相对导入失败,尝试绝对导入
import sys
from pathlib import Path
backend_path = Path(__file__).parent.parent
if str(backend_path) not in sys.path:
sys.path.insert(0, str(backend_path))
from ..core.shared_config import CLIPS_DIR, COLLECTIONS_DIR
logger = logging.getLogger(__name__)
class VideoProcessor:
"""视频处理工具类"""
def __init__(self, clips_dir: Optional[str] = None, collections_dir: Optional[str] = None):
# 强制使用传入的项目特定路径,不使用全局路径作为后备
if not clips_dir:
raise ValueError("clips_dir 参数是必需的,不能使用全局路径")
if not collections_dir:
raise ValueError("collections_dir 参数是必需的,不能使用全局路径")
self.clips_dir = Path(clips_dir)
self.collections_dir = Path(collections_dir)
@staticmethod
def sanitize_filename(filename: str) -> str:
"""
清理文件名,移除或替换不合法的字符
Args:
filename: 原始文件名
Returns:
清理后的文件名
"""
# 移除或替换不合法的字符
# Windows和Unix系统都不允许的字符: < > : " | ? * \ /
# 替换为下划线
sanitized = re.sub(r'[<>:"|?*\\/]', '_', filename)
# 移除前后空格和点
sanitized = sanitized.strip(' .')
# 限制长度,避免文件名过长
if len(sanitized) > 100:
sanitized = sanitized[:100]
# 确保文件名不为空
if not sanitized:
sanitized = "untitled"
return sanitized
@staticmethod
def convert_srt_time_to_ffmpeg_time(srt_time: str) -> str:
"""
将SRT时间格式转换为FFmpeg时间格式
Args:
srt_time: SRT时间格式 (如 "00:00:06,140" 或 "00:00:06.140")
Returns:
FFmpeg时间格式 (如 "00:00:06.140")
"""
# 将逗号替换为点
return srt_time.replace(',', '.')
@staticmethod
def convert_seconds_to_ffmpeg_time(seconds: float) -> str:
"""
将秒数转换为FFmpeg时间格式
Args:
seconds: 秒数
Returns:
FFmpeg时间格式 (如 "00:00:06.140")
"""
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
secs = int(seconds % 60)
milliseconds = int((seconds % 1) * 1000)
return f"{hours:02d}:{minutes:02d}:{secs:02d}.{milliseconds:03d}"
@staticmethod
def convert_ffmpeg_time_to_seconds(time_str: str) -> float:
"""
将FFmpeg时间格式转换为秒数
Args:
time_str: FFmpeg时间格式 (如 "00:00:06.140")
Returns:
秒数
"""
try:
# 处理毫秒部分
if '.' in time_str:
time_part, ms_part = time_str.split('.')
milliseconds = int(ms_part)
else:
time_part = time_str
milliseconds = 0
# 解析时分秒
h, m, s = map(int, time_part.split(':'))
return h * 3600 + m * 60 + s + milliseconds / 1000
except Exception as e:
logger.error(f"时间格式转换失败: {time_str}, 错误: {e}")
return 0.0
@staticmethod
def extract_clip(input_video: Path, output_path: Path,
start_time: str, end_time: str) -> bool:
"""
从视频中提取指定时间段的片段
Args:
input_video: 输入视频路径
output_path: 输出视频路径
start_time: 开始时间 (格式: "00:01:25,140")
end_time: 结束时间 (格式: "00:02:53,500")
Returns:
是否成功
"""
try:
# 确保输出目录存在
output_path.parent.mkdir(parents=True, exist_ok=True)
# 转换时间格式:从SRT格式转换为FFmpeg格式
ffmpeg_start_time = VideoProcessor.convert_srt_time_to_ffmpeg_time(start_time)
ffmpeg_end_time = VideoProcessor.convert_srt_time_to_ffmpeg_time(end_time)
# 计算持续时间
start_seconds = VideoProcessor.convert_ffmpeg_time_to_seconds(ffmpeg_start_time)
end_seconds = VideoProcessor.convert_ffmpeg_time_to_seconds(ffmpeg_end_time)
duration = end_seconds - start_seconds
# 构建优化的FFmpeg命令
# 使用 -ss 在输入前进行精确定位,使用 -t 指定持续时间
ffmpeg_bin = get_ffmpeg_path()
cmd = [
ffmpeg_bin,
'-ss', ffmpeg_start_time, # 在输入前定位,更精确
'-i', str(input_video),
'-t', str(duration), # 使用持续时间而不是绝对结束时间
'-c:v', 'copy', # 复制视频流
'-c:a', 'copy', # 复制音频流
'-avoid_negative_ts', 'make_zero',
'-y', # 覆盖输出文件
str(output_path)
]
# 执行命令
result = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8', errors='ignore')
if result.returncode == 0:
logger.info(f"成功提取视频片段: {output_path} ({ffmpeg_start_time} -> {ffmpeg_end_time}, 时长: {duration:.2f}秒)")
return True
else:
logger.error(f"提取视频片段失败: {result.stderr}")
return False
except Exception as e:
logger.error(f"视频处理异常: {str(e)}")
return False
@staticmethod
def create_collection(clips_list: List[Path], output_path: Path) -> bool:
"""
将多个视频片段拼接成合集
Args:
clips_list: 视频片段路径列表
output_path: 输出合集路径
Returns:
是否成功
"""
try:
# 验证输入参数
if not clips_list:
logger.error("clips_list为空,无法创建合集")
return False
# 验证所有视频文件是否存在
valid_clips = []
for clip_path in clips_list:
if not clip_path.exists():
logger.warning(f"视频文件不存在,跳过: {clip_path}")
continue
valid_clips.append(clip_path)
if not valid_clips:
logger.error("没有有效的视频文件,无法创建合集")
return False
# 确保输出目录存在
output_path.parent.mkdir(parents=True, exist_ok=True)
# 创建concat文件
concat_file = output_path.parent / "concat_list.txt"
with open(concat_file, 'w', encoding='utf-8') as f:
for clip_path in valid_clips:
# 使用绝对路径并转义单引号
abs_path = clip_path.absolute()
escaped_path = str(abs_path).replace("'", "'\"'\"'")
f.write(f"file '{escaped_path}'\n")
# 验证concat文件内容
if concat_file.stat().st_size == 0:
logger.error("concat文件为空,无法创建合集")
concat_file.unlink(missing_ok=True)
return False
# 构建FFmpeg命令 - 使用H.264编码确保兼容性
ffmpeg_bin = get_ffmpeg_path()
cmd = [
ffmpeg_bin,
'-f', 'concat',
'-safe', '0',
'-i', str(concat_file),
'-c:v', 'libx264', # 使用H.264视频编码
'-preset', 'ultrafast', # 使用最快的编码预设
'-crf', '28', # 稍微降低质量以加快编码速度
'-c:a', 'aac', # 使用AAC音频编码
'-b:a', '128k', # 音频比特率
'-movflags', '+faststart', # 优化网络播放
'-y',
str(output_path)
]
logger.info(f"执行FFmpeg命令: {' '.join(cmd)}")
# 执行命令
result = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8', errors='ignore')
# 清理临时文件
concat_file.unlink(missing_ok=True)
if result.returncode == 0:
logger.info(f"成功创建合集: {output_path}")
return True
else:
logger.error(f"创建合集失败: {result.stderr}")
logger.error(f"FFmpeg stdout: {result.stdout}")
return False
except Exception as e:
logger.error(f"视频拼接异常: {str(e)}")
return False
@staticmethod
def extract_thumbnail(video_path: Path, output_path: Path, time_offset: int = 5) -> bool:
"""
从视频中提取缩略图
Args:
video_path: 视频文件路径
output_path: 输出缩略图路径
time_offset: 提取时间点(秒)
Returns:
是否成功
"""
try:
# 确保输出目录存在
output_path.parent.mkdir(parents=True, exist_ok=True)
# 构建FFmpeg命令
cmd = [
'ffmpeg',
'-i', str(video_path),
'-ss', str(time_offset),
'-vframes', '1',
'-q:v', '2', # 高质量
'-y', # 覆盖输出文件
str(output_path)
]
# 执行命令
result = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8', errors='ignore')
if result.returncode == 0 and output_path.exists():
logger.info(f"成功提取缩略图: {output_path}")
return True
else:
logger.error(f"提取缩略图失败: {result.stderr}")
return False
except Exception as e:
logger.error(f"提取缩略图异常: {str(e)}")
return False
@staticmethod
def get_video_info(video_path: Path) -> Dict:
"""
获取视频信息
Args:
video_path: 视频文件路径
Returns:
视频信息字典
"""
try:
ffprobe_bin = get_ffprobe_path()
cmd = [
ffprobe_bin,
'-v', 'quiet',
'-print_format', 'json',
'-show_format',
'-show_streams',
str(video_path)
]
result = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8', errors='ignore')
if result.returncode == 0:
info = json.loads(result.stdout)
return {
'duration': float(info['format']['duration']),
'size': int(info['format']['size']),
'bitrate': int(info['format']['bit_rate']),
'streams': info['streams']
}
else:
logger.error(f"获取视频信息失败: {result.stderr}")
return {}
except Exception as e:
logger.error(f"获取视频信息异常: {str(e)}")
return {}
def batch_extract_clips(self, input_video: Path, clips_data: List[Dict]) -> List[Path]:
"""
批量提取视频片段
Args:
input_video: 输入视频路径
clips_data: 片段数据列表,每个元素包含id、title、start_time、end_time
Returns:
成功提取的片段路径列表
"""
successful_clips = []
for clip_data in clips_data:
clip_id = clip_data['id']
title = clip_data.get('title', f"片段_{clip_id}")
start_time = clip_data['start_time']
end_time = clip_data['end_time']
# 处理时间格式 - 如果是秒数,转换为SRT格式
if isinstance(start_time, (int, float)):
start_time = VideoProcessor.convert_seconds_to_ffmpeg_time(start_time)
if isinstance(end_time, (int, float)):
end_time = VideoProcessor.convert_seconds_to_ffmpeg_time(end_time)
# 使用标题作为文件名,并清理不合法的字符
# 在文件名中包含clip_id,便于后续合集拼接时查找
safe_title = VideoProcessor.sanitize_filename(title)
output_path = self.clips_dir / f"{clip_id}_{safe_title}.mp4"
logger.info(f"提取切片 {clip_id}: {start_time} -> {end_time}, 输出: {output_path}")
if VideoProcessor.extract_clip(input_video, output_path, start_time, end_time):
successful_clips.append(output_path)
logger.info(f"切片 {clip_id} 提取成功")
else:
logger.error(f"切片 {clip_id} 提取失败")
return successful_clips
def create_collections_from_metadata(self, collections_data: List[Dict]) -> List[Dict]:
"""
根据元数据创建合集
Args:
collections_data: 合集数据列表
Returns:
成功创建的合集信息列表,包含视频路径和缩略图路径
"""
successful_collections = []
for collection_data in collections_data:
collection_id = collection_data['id']
collection_title = collection_data.get('collection_title', f'合集_{collection_id}')
clip_ids = collection_data['clip_ids']
# 构建片段路径列表
clips_list = []
for clip_id in clip_ids:
# 查找对应的切片文件
# 新的文件名格式是: {clip_id}_{title}.mp4
clip_path = self.clips_dir / f"{clip_id}_*.mp4"
found_clips = list(self.clips_dir.glob(f"{clip_id}_*.mp4"))
if found_clips:
found_clip = found_clips[0] # 取第一个匹配的文件
clips_list.append(found_clip)
logger.info(f"找到合集 {collection_id} 的切片: {found_clip.name}")
else:
logger.warning(f"未找到合集 {collection_id} 的切片 {clip_id}")
if clips_list:
# 使用collection_title作为文件名,并清理不合法的字符
safe_title = VideoProcessor.sanitize_filename(collection_title)
output_path = self.collections_dir / f"{safe_title}.mp4"
if VideoProcessor.create_collection(clips_list, output_path):
# 生成合集缩略图
thumbnail_path = None
try:
thumbnail_filename = f"{collection_id}_{safe_title}_thumbnail.jpg"
thumbnail_path = self.collections_dir / thumbnail_filename
# 从视频中提取缩略图(第2秒的帧)
thumbnail_success = VideoProcessor.extract_thumbnail(output_path, thumbnail_path, time_offset=2)
if thumbnail_success:
logger.info(f"合集 {collection_id} 缩略图生成成功: {thumbnail_path}")
else:
logger.warning(f"合集 {collection_id} 缩略图生成失败")
thumbnail_path = None
except Exception as e:
logger.error(f"生成合集 {collection_id} 缩略图时出错: {e}")
thumbnail_path = None
# 返回包含视频路径和缩略图路径的信息
collection_info = {
'collection_id': collection_id,
'video_path': str(output_path),
'thumbnail_path': str(thumbnail_path) if thumbnail_path else None,
'title': collection_title
}
successful_collections.append(collection_info)
logger.info(f"成功创建合集 {collection_id}: {output_path}")
else:
logger.warning(f"合集 {collection_id} 没有找到任何有效的切片文件")
return successful_collections