cang-ying/video-ai-system/tools/video_composer.py
Zhuyuan Operations ce32073d07 D205 面板功能大升级+本地漫剧管线+经验自动机制+飞书资料归档
- 面板: 会话持久化(oc_sess.json)/余额实时/文件栏(搜索+MD+拖拽)/朗读(edge-tts)/工具过程实时显示/漫剧画布
- 面板: 经验自动检索注入+存经验按钮(experience.py)
- 管线: local_motion本地运镜/IMAGE-FIRST-GUIDE/LOCAL-PIPELINE-V1
- 经验: EED-EXPER-014 面板工程+管线+飞书扒取全记录
- 资料: 飞书《清欢AIGC伪真人短剧全流程》归档
2026-08-03 22:55:02 +08:00

295 lines
9.8 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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()