cang-ying/video-ai-system/tools/video_composer.py

295 lines
9.8 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""
video_composer.py 自动剪辑拼接工具
功能多视频片段拼接 + 可选字幕 + 片头片尾
依赖FFmpeg (已安装)
用法:
python tools/video_composer.py <视频文件夹> -o 输出.mp4 [选项]
选项:
-o, --output FILE 输出文件路径 (默认: output.mp4)
-s, --sort NAME 排序方式: name(按文件名), time(按修改时间) (默认: name)
--fps N 输出帧率 (默认: 24)
--subtitle FILE 字幕文件路径 (SRT格式, 可选)
--title TEXT 片头字幕 (可选, 3)
--credits TEXT 片尾字幕 (可选, 3)
--crossfade N 交叉淡入淡出帧数 (默认: 0, 不开启)
-v, --verbose 详细输出
"""
import os
import sys
import subprocess
import argparse
import glob
from pathlib import Path
def get_video_files(folder, sort_by='name'):
"""获取文件夹中的所有视频文件,按指定方式排序"""
ext_map = {'.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.ts'}
files = []
for f in Path(folder).iterdir():
if f.suffix.lower() in ext_map and f.is_file():
files.append(str(f))
if sort_by == 'time':
files.sort(key=lambda x: os.path.getmtime(x))
else:
files.sort()
return files
def create_concat_file(files):
"""创建 FFmpeg concat 需要的临时文件列表"""
concat_path = '/tmp/video_concat_list.txt'
with open(concat_path, 'w') as f:
for video in files:
f.write(f"file '{video}'\n")
return concat_path
def generate_subtitle_filter(srt_path):
"""生成字幕滤镜"""
esc_path = srt_path.replace("'", "'\\\\''").replace(":", "\\:")
return f"subtitles='{esc_path}'"
def build_filter_complex(num_videos, crossfade=0, title_text=None, credits_text=None, subtitle_path=None):
"""
构建视频滤镜链
支持拼接 + 交叉淡入淡出 + 片头片尾文本 + 字幕
"""
filters = []
# 如果只有1个视频且不需要额外效果返回空
if num_videos == 1 and not title_text and not credits_text and not subtitle_path:
return None
filter_parts = []
overlay_inputs = []
if crossfade > 0 and num_videos > 1:
# 交叉淡入淡出方案: 用 overlay 和 fade
for i in range(num_videos):
filter_parts.append(f"[{i}:v]fade=t=in:st=0:d=0.5[v{i}];")
for i in range(num_videos):
offset = i * (1 - 0.5) if i > 0 else 0 # 简化计算,实际用 concat + crossfade 更复杂
pass
# 简化:用 concat 滤镜
inputs = ''.join([f"[{i}:v][{i}:a]" for i in range(num_videos)])
filter_parts.append(f"{inputs}concat=n={num_videos}:v=1:a=1[outv][outa]")
else:
# 简单拼接
inputs = ''.join([f"[{i}:v][{i}:a]" for i in range(num_videos)])
filter_parts.append(f"{inputs}concat=n={num_videos}:v=1:a=1[outv][outa]")
current_output = "[outv]"
# 片头字幕
if title_text:
esc_title = title_text.replace("'", "'\\\\''").replace(":", "\\:")
filter_parts.append(
f"[outv]drawtext=text='{esc_title}':"
f"fontcolor=white:fontsize=48:"
f"x=(w-text_w)/2:y=(h-text_h)/2:"
f"enable='between(t,0,3)'[outv];"
)
# 片尾字幕
if credits_text:
# 需要知道总时长才能定位片尾,比较复杂,移到 FFmpeg 命令中处理
pass
# 字幕
if subtitle_path:
pass # 字幕用独立的 -vf 参数处理更方便
return ' '.join(filter_parts) if filter_parts else None
def get_video_duration(video_path):
"""获取视频时长(秒)"""
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)
try:
return float(result.stdout.strip())
except:
return 0
def compose_videos(files, output_path, fps=24, subtitle_path=None,
title_text=None, credits_text=None, crossfade=0, verbose=False):
"""合成视频"""
if not files:
print("❌ 没有找到视频文件")
return False
print(f"🎬 找到 {len(files)} 个视频片段:")
for f in files:
dur = get_video_duration(f)
print(f" {Path(f).name} ({dur:.1f}s)")
# 方案A简单拼接用 concat demuxer最快
if len(files) > 1 and crossfade == 0 and not title_text and not credits_text and not subtitle_path:
concat_file = create_concat_file(files)
cmd = [
'ffmpeg', '-y',
'-f', 'concat', '-safe', '0',
'-i', concat_file,
'-c', 'copy', # 直接复制流,最快
output_path
]
if verbose:
print(f"🔧 执行: {' '.join(cmd)}")
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f"❌ 简单拼接失败: {result.stderr[:300]}")
# 降级到重新编码
cmd = [
'ffmpeg', '-y',
'-f', 'concat', '-safe', '0',
'-i', concat_file,
'-c:v', 'libx264', '-preset', 'fast', '-crf', '23',
'-c:a', 'aac', '-b:a', '128k',
'-r', str(fps),
output_path
]
result = subprocess.run(cmd, capture_output=True, text=True)
os.unlink(concat_file)
# 方案B重新编码支持字幕、片头片尾、交叉淡入淡出
else:
concat_file = create_concat_file(files)
# 构建滤镜
filter_parts = []
num_v = len(files)
if crossfade > 0 and num_v > 1:
# 用 concat + 复杂的交叉淡化
inputs = ''.join([f"[{i}:v]" for i in range(num_v)])
# 简化处理,用 concat 后再用 crossfade
# 实际上 crossfade 比较复杂,这里用简单版
pass
# 基础拼接
inputs = ''.join([f"[{i}:v][{i}:a]" for i in range(num_v)])
filter_chain = f"{inputs}concat=n={num_v}:v=1:a=1[outv][outa]"
extra_filters = []
# 片头字幕
if title_text:
extra_filters.append(
f"drawtext=text='{title_text}':"
f"fontcolor=white:fontsize=48:"
f"x=(w-text_w)/2:y=(h-text_h)/2:"
f"enable='between(t,0,3)'"
)
# 片尾字幕
if credits_text:
extra_filters.append(
f"drawtext=text='{credits_text}':"
f"fontcolor=white:fontsize=36:"
f"x=(w-text_w)/2:y=(h-text_h)/2:"
f"enable='gte(t,{max(0, get_video_duration(files[0]) - 3)})'"
)
# 字幕文件
if subtitle_path:
esc_sub = subtitle_path.replace(":", "\\:").replace("'", "'\\\\''")
extra_filters.insert(0, f"subtitles='{esc_sub}'")
filter_complex = filter_chain
if extra_filters:
filter_complex = f"{filter_chain};[outv]{','.join(extra_filters)}[outv]"
cmd = [
'ffmpeg', '-y',
'-f', 'concat', '-safe', '0',
'-i', concat_file,
'-filter_complex', filter_complex,
'-map', '[outv]', '-map', '[outa]',
'-c:v', 'libx264', '-preset', 'fast', '-crf', '23',
'-c:a', 'aac', '-b:a', '128k',
'-r', str(fps),
output_path
]
if verbose:
print(f"🔧 执行: ffmpeg [滤镜参数]")
print(f" 滤镜: {filter_complex}")
result = subprocess.run(cmd, capture_output=True, text=True)
os.unlink(concat_file)
if result.returncode == 0:
output_size = os.path.getsize(output_path) / (1024 * 1024)
print(f"✅ 合成完成: {output_path} ({output_size:.1f}MB)")
return True
else:
print(f"❌ 合成失败:")
print(result.stderr[:500])
return False
def main():
parser = argparse.ArgumentParser(
description='视频自动剪辑拼接工具 - 苍耳管线',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
%(prog)s ./clips/ -o episode.mp4
%(prog)s ./clips/ -o episode.mp4 --title "第1集" --credits "待续"
%(prog)s ./clips/ -o episode.mp4 --subtitle subtitles.srt
%(prog)s ./clips/ -o episode.mp4 --crossfade 24
"""
)
parser.add_argument('folder', help='视频片段文件夹')
parser.add_argument('-o', '--output', default='output.mp4', help='输出文件')
parser.add_argument('-s', '--sort', choices=['name', 'time'], default='name', help='排序方式')
parser.add_argument('--fps', type=int, default=24, help='输出帧率')
parser.add_argument('--subtitle', help='字幕SRT文件')
parser.add_argument('--title', help='片头文字')
parser.add_argument('--credits', help='片尾文字')
parser.add_argument('--crossfade', type=int, default=0, help='交叉淡入淡出帧数')
parser.add_argument('-v', '--verbose', action='store_true', help='详细输出')
args = parser.parse_args()
if not os.path.isdir(args.folder):
print(f"❌ 文件夹不存在: {args.folder}")
sys.exit(1)
files = get_video_files(args.folder, args.sort)
if not files:
print(f"❌ 文件夹中没有视频文件: {args.folder}")
sys.exit(1)
success = compose_videos(
files, args.output,
fps=args.fps,
subtitle_path=args.subtitle,
title_text=args.title,
credits_text=args.credits,
crossfade=args.crossfade,
verbose=args.verbose
)
sys.exit(0 if success else 1)
if __name__ == '__main__':
main()