329 lines
9.2 KiB
Python
329 lines
9.2 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
视频切割导出模块
|
||
功能:根据图片名找到原始视频,按时间范围切割并导出
|
||
依赖:系统需要安装 ffmpeg
|
||
"""
|
||
|
||
import os
|
||
import re
|
||
import subprocess
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
# ========== 配置 ==========
|
||
VIDEO_DIR = "./shipin" # 原始视频目录
|
||
EXPORT_DIR = "./export_videos" # 导出视频目录
|
||
VIDEO_EXTENSIONS = {'.avi', '.mp4', '.mkv', '.mov', '.flv', '.wmv', '.webm', '.ts', '.m4v'}
|
||
# ==========================
|
||
|
||
|
||
def parse_image_info(image_name: str) -> Optional[dict]:
|
||
"""
|
||
从图片名解析视频名和时间点
|
||
|
||
Args:
|
||
image_name: 图片文件名,例如 '001 Puss Gets the Boot [1940]_00m05s_213.jpg'
|
||
|
||
Returns:
|
||
{
|
||
'video_name': '001 Puss Gets the Boot [1940]',
|
||
'timestamp_seconds': 5.213
|
||
}
|
||
解析失败返回 None
|
||
"""
|
||
# 移除扩展名
|
||
name_without_ext = Path(image_name).stem
|
||
|
||
# 匹配时间戳格式:_XXmYYs_ZZZ
|
||
match = re.search(r'_(\d{2})m(\d{2})s_(\d{3})$', name_without_ext)
|
||
if not match:
|
||
return None
|
||
|
||
minutes = int(match.group(1))
|
||
seconds = int(match.group(2))
|
||
milliseconds = int(match.group(3))
|
||
timestamp_seconds = minutes * 60 + seconds + milliseconds / 1000
|
||
|
||
# 提取视频名(去掉时间戳部分)
|
||
video_name = name_without_ext[:match.start()]
|
||
|
||
return {
|
||
'video_name': video_name,
|
||
'timestamp_seconds': timestamp_seconds
|
||
}
|
||
|
||
|
||
def find_video_file(video_name: str) -> Optional[str]:
|
||
"""
|
||
根据视频名找到对应的视频文件
|
||
|
||
Args:
|
||
video_name: 视频名,例如 '001 Puss Gets the Boot [1940]'
|
||
|
||
Returns:
|
||
视频文件路径,未找到返回 None
|
||
"""
|
||
video_dir = Path(VIDEO_DIR)
|
||
for ext in VIDEO_EXTENSIONS:
|
||
video_path = video_dir / f"{video_name}{ext}"
|
||
if video_path.exists():
|
||
return str(video_path)
|
||
return None
|
||
|
||
|
||
def get_video_duration(video_path: str) -> float:
|
||
"""
|
||
获取视频总时长(秒)
|
||
|
||
Args:
|
||
video_path: 视频文件路径
|
||
|
||
Returns:
|
||
视频时长(秒),失败返回 0.0
|
||
"""
|
||
try:
|
||
cmd = [
|
||
'ffprobe',
|
||
'-v', 'error',
|
||
'-show_entries', 'format=duration',
|
||
'-of', 'default=noprint_wrappers=1:nokey=1',
|
||
video_path
|
||
]
|
||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||
if result.returncode == 0:
|
||
return float(result.stdout.strip())
|
||
except (subprocess.TimeoutExpired, ValueError):
|
||
pass
|
||
return 0.0
|
||
|
||
|
||
def format_time(seconds: float) -> str:
|
||
"""
|
||
格式化时间:秒 -> '01m23s'
|
||
|
||
Args:
|
||
seconds: 秒数
|
||
|
||
Returns:
|
||
格式化的时间字符串
|
||
"""
|
||
mins = int(seconds // 60)
|
||
secs = int(seconds % 60)
|
||
return f"{mins:02d}m{secs:02d}s"
|
||
|
||
|
||
def cut_video(
|
||
video_path: str,
|
||
start_time: float,
|
||
end_time: float,
|
||
output_path: str
|
||
) -> dict:
|
||
"""
|
||
使用 ffmpeg 切割视频
|
||
|
||
Args:
|
||
video_path: 原始视频路径
|
||
start_time: 开始时间(秒)
|
||
end_time: 结束时间(秒)
|
||
output_path: 输出路径
|
||
|
||
Returns:
|
||
{
|
||
'success': bool,
|
||
'output_path': str,
|
||
'start_time': float,
|
||
'end_time': float,
|
||
'duration': float,
|
||
'error': str
|
||
}
|
||
"""
|
||
# 确保输出目录存在
|
||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||
|
||
# 计算持续时间
|
||
duration = end_time - start_time
|
||
|
||
cmd = [
|
||
'ffmpeg',
|
||
'-i', video_path,
|
||
'-ss', str(start_time),
|
||
'-t', str(duration),
|
||
'-c', 'copy', # 直接复制,不重新编码(速度快)
|
||
'-y', # 覆盖已存在文件
|
||
'-avoid_negative_ts', 'make_zero', # 避免负时间戳
|
||
output_path
|
||
]
|
||
|
||
try:
|
||
result = subprocess.run(
|
||
cmd,
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=600 # 10分钟超时
|
||
)
|
||
if result.returncode == 0:
|
||
return {
|
||
'success': True,
|
||
'output_path': output_path,
|
||
'start_time': start_time,
|
||
'end_time': end_time,
|
||
'duration': duration,
|
||
'error': None
|
||
}
|
||
else:
|
||
return {
|
||
'success': False,
|
||
'output_path': None,
|
||
'start_time': start_time,
|
||
'end_time': end_time,
|
||
'duration': duration,
|
||
'error': f"ffmpeg 错误: {result.stderr[-300:]}"
|
||
}
|
||
except subprocess.TimeoutExpired:
|
||
return {
|
||
'success': False,
|
||
'output_path': None,
|
||
'start_time': start_time,
|
||
'end_time': end_time,
|
||
'duration': duration,
|
||
'error': "视频切割超时(超过10分钟)"
|
||
}
|
||
|
||
|
||
def export_video_clip(
|
||
image_name: str,
|
||
mode: str = "around",
|
||
seconds: int = 300
|
||
) -> dict:
|
||
"""
|
||
根据图片导出视频片段
|
||
|
||
Args:
|
||
image_name: 图片文件名
|
||
mode: 导出模式
|
||
- "around": 前后各 N 秒
|
||
- "after": 后 N 秒
|
||
- "before": 前 N 秒
|
||
- "total": 整个视频
|
||
seconds: 秒数(total 模式下忽略)
|
||
|
||
Returns:
|
||
{
|
||
'success': bool,
|
||
'video_path': str, # 导出的视频路径
|
||
'video_name': str, # 视频名
|
||
'start_time': str, # 开始时间(格式化)
|
||
'end_time': str, # 结束时间(格式化)
|
||
'duration': str, # 时长(格式化)
|
||
'original_image': str, # 原始图片名
|
||
'original_timestamp': str, # 图片在视频中的时间点
|
||
'error': str # 错误信息
|
||
}
|
||
"""
|
||
# 1. 解析图片信息
|
||
info = parse_image_info(image_name)
|
||
if not info:
|
||
return {
|
||
'success': False,
|
||
'error': f"无法解析图片名: {image_name}(格式应为:视频名_XXmYYs_ZZZ.jpg)"
|
||
}
|
||
|
||
video_name = info['video_name']
|
||
timestamp = info['timestamp_seconds']
|
||
|
||
# 2. 找到原始视频
|
||
video_path = find_video_file(video_name)
|
||
if not video_path:
|
||
return {
|
||
'success': False,
|
||
'error': f"未找到视频: {video_name}(请确认视频文件在 {VIDEO_DIR} 目录下)"
|
||
}
|
||
|
||
# 3. 获取视频总时长
|
||
video_duration = get_video_duration(video_path)
|
||
if video_duration <= 0:
|
||
return {
|
||
'success': False,
|
||
'error': "无法获取视频时长,请确认视频文件格式正确"
|
||
}
|
||
|
||
# 4. 计算切割范围
|
||
if mode == "total":
|
||
start_time = 0
|
||
end_time = video_duration
|
||
elif mode == "around":
|
||
start_time = max(0, timestamp - seconds)
|
||
end_time = min(video_duration, timestamp + seconds)
|
||
elif mode == "after":
|
||
start_time = timestamp
|
||
end_time = min(video_duration, timestamp + seconds)
|
||
elif mode == "before":
|
||
start_time = max(0, timestamp - seconds)
|
||
end_time = timestamp
|
||
else:
|
||
return {
|
||
'success': False,
|
||
'error': f"未知模式: {mode}(支持:around/after/before/total)"
|
||
}
|
||
|
||
# 5. 生成输出路径
|
||
start_str = format_time(start_time)
|
||
end_str = format_time(end_time)
|
||
output_filename = f"{video_name}_{start_str}-{end_str}.mp4"
|
||
output_path = os.path.join(EXPORT_DIR, output_filename)
|
||
|
||
# 6. 执行切割
|
||
print(f"开始切割视频: {video_name}")
|
||
print(f" 时间范围: {start_str} -> {end_str}")
|
||
print(f" 输出路径: {output_path}")
|
||
|
||
result = cut_video(video_path, start_time, end_time, output_path)
|
||
|
||
if result['success']:
|
||
print(f"✅ 视频导出成功: {output_path}")
|
||
return {
|
||
'success': True,
|
||
'video_path': output_path,
|
||
'video_name': video_name,
|
||
'start_time': start_str,
|
||
'end_time': end_str,
|
||
'duration': format_time(result['duration']),
|
||
'original_image': image_name,
|
||
'original_timestamp': format_time(timestamp),
|
||
'error': None
|
||
}
|
||
else:
|
||
print(f"❌ 视频导出失败: {result['error']}")
|
||
return {
|
||
'success': False,
|
||
'error': result['error']
|
||
}
|
||
|
||
|
||
# ========== 测试 ==========
|
||
if __name__ == "__main__":
|
||
import sys
|
||
|
||
# 测试解析功能
|
||
test_images = [
|
||
"001 Puss Gets the Boot [1940]_05m23s_456.jpg",
|
||
"002 The Midnight Snack [1941]_02m15s_123.jpg",
|
||
"invalid_image_name.jpg"
|
||
]
|
||
|
||
print("=== 测试图片名解析 ===")
|
||
for img in test_images:
|
||
info = parse_image_info(img)
|
||
print(f" {img}")
|
||
print(f" -> {info}")
|
||
|
||
# 测试导出功能(如果有命令行参数)
|
||
if len(sys.argv) > 1:
|
||
print("\n=== 测试视频导出 ===")
|
||
test_image = sys.argv[1]
|
||
test_mode = sys.argv[2] if len(sys.argv) > 2 else "around"
|
||
test_seconds = int(sys.argv[3]) if len(sys.argv) > 3 else 120
|
||
|
||
result = export_video_clip(test_image, mode=test_mode, seconds=test_seconds)
|
||
print(f"\n导出结果: {result}") |