#!/usr/bin/env python3 """ 最终合成脚本 - 将所有素材合成为 1080×1920 竖版推广视频 使用方式: # 动作模板:渲染字幕 → 拼接视频段 → 叠加尾板 → 混音 python composite.py action \\ --dance ./dance_video.mp4 \\ --tutorial ./tutorial_segment.mp4 \\ --voiceover ./voiceover.mp3 \\ --bgm ./bgm.mp3 \\ --subtitle-json ./voiceover_subtitle.json \\ --font ./fonts/Poppins-Bold.ttf \\ --endcard ./assets/endcard_en.mp4 \\ --output ./final.mp4 # 图片模板:写真轮播 → 教程预拼 → 底层合成 → 叠加PiP+字幕 → 混音 python composite.py image \\ --photos ./photos/photo_01.jpg ./photos/photo_02.jpg ./photos/photo_03.jpg \\ --tutorial ./tut_combined.mp4 \\ --pip ./digital_person/pip_clean.mov \\ --voiceover ./voiceover.mp3 \\ --subtitle-json ./voiceover_subtitle.json \\ --font ./fonts/Poppins-Bold.ttf \\ --endcard ./assets/endcard_en.mp4 \\ --output ./final.mp4 依赖: - render_subtitles.py(字幕渲染) - tutorial_synthesis.py(教程段合成) - ffmpeg(视频处理) """ import argparse import subprocess import sys from pathlib import Path from typing import List, Optional # ─────────────────────────── 配置 ─────────────────────────── CANVAS_W = 1080 CANVAS_H = 1920 TARGET_FPS = 30 BGM_VOLUME = 0.15 # 字幕位置 SUBTITLE_Y = 1050 # PiP 参数 PIP_SCALE_W = 520 PIP_SCALE_H = 693 PIP_X = -60 PIP_Y = 1227 PIP_START = 0.5 # ─────────────────────────── 辅助函数 ─────────────────────────── def ensure_dir(path: Path) -> None: path.parent.mkdir(parents=True, exist_ok=True) def run_ffmpeg(cmd: List[str], check: bool = True) -> None: """执行 ffmpeg 命令""" result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode != 0: print(f"❌ ffmpeg 错误:\n{result.stderr[-800:]}") if check: sys.exit(1) def render_subtitles( subtitle_json: str, font_path: str, output_dir: str, ) -> None: """调用 render_subtitles.py 渲染字幕 PNG""" script_path = Path(__file__).parent / "render_subtitles.py" cmd = [ sys.executable, str(script_path), "--script", subtitle_json, "--font", font_path, "--output", output_dir, "--width", str(CANVAS_W), "--height", str(CANVAS_H), ] subprocess.run(cmd, check=True) def concat_videos(video_paths: List[str], output: str) -> None: """用 concat demuxer 拼接多个视频""" tmp_dir = Path(output).parent / "_tmp_concat" tmp_dir.mkdir(parents=True, exist_ok=True) list_file = tmp_dir / "list.txt" tmp_files = [] for i, vp in enumerate(video_paths): tmp_out = str(tmp_dir / f"seg_{i:02d}.mp4") run_ffmpeg( [ "ffmpeg", "-y", "-i", vp, "-vf", f"scale={CANVAS_W}:{CANVAS_H},fps={TARGET_FPS},format=yuv420p,setsar=1", "-an", "-c:v", "libx264", "-pix_fmt", "yuv420p", tmp_out, ] ) tmp_files.append(tmp_out) with open(list_file, "w") as f: for tf in tmp_files: f.write(f"file '{tf}'\n") run_ffmpeg( [ "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(list_file), "-c", "copy", output, ] ) def photo_carousel( photo_paths: List[str], each_duration: float, transition: str, output: str, ) -> None: """生成写真图轮播视频(xfade 转场,禁止硬切)""" if len(photo_paths) < 2: # 单张图直接循环 run_ffmpeg( [ "ffmpeg", "-y", "-loop", "1", "-t", str(each_duration), "-i", photo_paths[0], "-vf", f"scale={CANVAS_W}:{CANVAS_H},fps={TARGET_FPS},format=yuv420p", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-t", str(each_duration), output, ] ) return # 多张图用 xfade 连接 tmp_dir = Path(output).parent / "_tmp_carousel" tmp_dir.mkdir(parents=True, exist_ok=True) inputs = [] for p in photo_paths: inputs += ["-loop", "1", "-t", str(each_duration), "-i", p] # 构建 xfade filter chain streams = "[0:v]" for i in range(1, len(photo_paths)): xfade_dur = each_duration * 0.3 offset = each_duration * i - xfade_dur streams += f"[{i}:v]xfade=transition={transition}:duration={xfade_dur}:offset={offset}[v{i}]" if i < len(photo_paths) - 1: streams += f";[v{i}]" total_dur = each_duration * len(photo_paths) n = len(photo_paths) xfade_dur = each_duration * 0.3 # 先把每个图片流标准化 filter_parts = [] for i in range(n): filter_parts.append( f"[{i}:v]scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2,fps={TARGET_FPS},format=yuv420p,setsar=1[v{i}]" ) # 链式 xfade:[v0][v1]xfade→[x1]; [x1][v2]xfade→[x2]; ... prev = "v0" for i in range(1, n): offset = each_duration * i - xfade_dur out_label = f"x{i}" if i < n - 1 else "xout" filter_parts.append( f"[{prev}][v{i}]xfade=transition={transition}:duration={xfade_dur}:offset={offset}[{out_label}]" ) prev = out_label run_ffmpeg( [ "ffmpeg", "-y", *inputs, "-filter_complex", ";".join(filter_parts), "-map", "[xout]", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-t", str(total_dur), output, ] ) def overlay_pip(base_video: str, pip_video: str, start_time: float, output: str) -> str: """叠加数字人 PiP,返回叠加后的视频路径""" tmp = str(Path(output).parent / "_tmp_pip.mp4") run_ffmpeg( [ "ffmpeg", "-y", "-i", base_video, "-i", pip_video, "-filter_complex", f"[1:v]scale={PIP_SCALE_W}:{PIP_SCALE_H}[pip];" f"[0:v][pip]overlay=x={PIP_X}:y={PIP_Y}:enable='gte(t,{start_time})'", "-c:v", "libx264", "-pix_fmt", "yuv420p", tmp, ] ) return tmp def overlay_subtitles( video: str, subtitle_dir: str, subtitle_json: str, output: str ) -> None: """按时间戳叠加字幕 PNG""" import json with open(subtitle_json) as f: data = json.load(f) segments = data.get("segments", data) if not segments: print("⚠️ 未找到字幕 segments,直接复制视频") subprocess.run(["cp", video, output], check=True) return # 构建字幕 overlay filter filters = [] import os png_files: list[str] = [ str(p) for p in sorted(Path(subtitle_dir).glob("sub_*.png")) ] for i, (seg, png) in enumerate(zip(segments, png_files)): start = seg["start"] end = seg["end"] filters.append( f"[{i}:v]format=rgba,overlay=x=0:y={SUBTITLE_Y}:enable='between(t,{start},{end})'[s{i}]" ) if i == 0: base = f"-i{os.fspath(png)}" else: base = f"-i{os.fspath(png)}" # 简化:用 ffmpeg 的 subtitles filter 或逐个 overlay # 逐个 overlay cmd = ["ffmpeg", "-y", "-i", video] for png in png_files: cmd.extend(["-i", str(png)]) filter_parts = [] prev = "0:v" for i in range(len(png_files)): seg = segments[i] out_label = f"vs{i}" if i < len(png_files) - 1 else "vout" filter_parts.append( f"[{prev}][{i + 1}:v]overlay=0:{SUBTITLE_Y}:enable='between(t,{seg['start']},{seg['end']})'[{out_label}]" ) prev = out_label filter_complex = ";".join(filter_parts) cmd += [ "-filter_complex", filter_complex, "-map", "[vout]", "-c:v", "libx264", output, ] run_ffmpeg(cmd) def mix_audio( video: str, voiceover: str, bgm: Optional[str], output: str, ) -> None: """混音:口播 + BGM + 尾板自带音频""" if bgm: run_ffmpeg( [ "ffmpeg", "-y", "-i", video, "-i", voiceover, "-i", bgm, "-filter_complex", f"[1:a]volume=1.0[vo];[2:a]volume={BGM_VOLUME}[bg];" "[vo][bg]amix=inputs=2:duration=first[aout]", "-map", "0:v", "-map", "[aout]", "-c:v", "copy", "-c:a", "aac", "-shortest", output, ] ) else: run_ffmpeg( [ "ffmpeg", "-y", "-i", video, "-i", voiceover, "-filter_complex", "[1:a]volume=1.0[aout]", "-map", "0:v", "-map", "[aout]", "-c:v", "copy", "-c:a", "aac", "-shortest", output, ] ) # ─────────────────────────── 子命令 ─────────────────────────── def cmd_action(args: argparse.Namespace) -> None: """动作模板合成""" print("🎬 动作模板合成...") tmp_dir = Path(args.output).parent / "_tmp_action" tmp_dir.mkdir(parents=True, exist_ok=True) # 1. 渲染字幕 subtitle_dir = str(tmp_dir / "subtitles") if args.subtitle_json: print("📝 渲染字幕...") render_subtitles(args.subtitle_json, args.font, subtitle_dir) # 2. 拼接底层视频(舞蹈 + 教程段) segments = [args.dance] if args.tutorial: segments.append(args.tutorial) base_video = str(tmp_dir / "base_concat.mp4") concat_videos(segments, base_video) print(f" ✅ 底层拼接完成:{base_video}") # 3. 叠加字幕 if args.subtitle_json: print("📝 叠加字幕...") with_subtitle = str(tmp_dir / "with_subtitle.mp4") overlay_subtitles(base_video, subtitle_dir, args.subtitle_json, with_subtitle) base_video = with_subtitle # 4. 拼接尾板 print("🔗 拼接尾板...") with_endcard = str(tmp_dir / "with_endcard.mp4") concat_videos([base_video, args.endcard], with_endcard) # 5. 混音 print("🎵 混音...") mix_audio(with_endcard, args.voiceover, args.bgm, args.output) print(f"✅ 完成:{args.output}") def cmd_image(args: argparse.Namespace) -> None: """图片模板合成""" print("🖼️ 图片模板合成...") tmp_dir = Path(args.output).parent / "_tmp_image" tmp_dir.mkdir(parents=True, exist_ok=True) # 0. 渲染字幕 subtitle_dir = str(tmp_dir / "subtitles") if args.subtitle_json: print("📝 渲染字幕...") render_subtitles(args.subtitle_json, args.font, subtitle_dir) # 1. 写真轮播 print("📸 生成写真轮播...") carousel = str(tmp_dir / "carousel.mp4") photo_carousel( args.photos, each_duration=2.0, transition="slideleft", output=carousel ) # 2. 教程预拼(调用 tutorial_synthesis.py pipeline) # tut_combined.mp4 由 tutorial_synthesis.py pipeline 阶段生成,此处接收的是已拼接好的 tut_combined.mp4 # 将 tut_combined.mp4 叠加到写真图3 上 print("🎬 合成教程 overlay...") with_tutorial = str(tmp_dir / "with_tutorial.mp4") run_ffmpeg( [ "ffmpeg", "-y", "-i", carousel, # 底层:写真图1+2 xfade "-i", args.tutorial, # tut_combined "-filter_complex", "[1:v]scale=1080:1920,fps=30,format=yuv420p,setsar=1[tt];" "[0:v][tt]overlay=0:0:enable='between(t,4.0,15.0)'[vout]", "-map", "[vout]", "-c:v", "libx264", "-pix_fmt", "yuv420p", with_tutorial, ] ) # 3. 叠加 PiP if args.pip: print("🤳 叠加数字人 PiP...") with_pip = str(tmp_dir / "with_pip.mp4") pip_result = overlay_pip( with_tutorial, args.pip, start_time=PIP_START, output=with_pip ) with_pip = pip_result # overlay_pip 返回实际输出路径 else: with_pip = with_tutorial # 4. 叠加字幕 if args.subtitle_json: print("📝 叠加字幕...") with_subtitle = str(tmp_dir / "with_subtitle.mp4") overlay_subtitles(with_pip, subtitle_dir, args.subtitle_json, with_subtitle) with_pip = with_subtitle # 5. 拼接尾板 print("🔗 拼接尾板...") with_endcard = str(tmp_dir / "with_endcard.mp4") concat_videos([with_pip, args.endcard], with_endcard) # 6. 混音(使用数字人视频自带音频 + BGM) print("🎵 混音...") mix_audio(with_endcard, args.voiceover, args.bgm, args.output) print(f"✅ 完成:{args.output}") # ─────────────────────────── 主入口 ─────────────────────────── def main(): parser = argparse.ArgumentParser( description="最终合成:动作模板 / 图片模板 → 1080×1920 竖版推广视频", formatter_class=argparse.RawDescriptionHelpFormatter, ) subparsers = parser.add_subparsers(dest="cmd", required=True) # action 子命令 p = subparsers.add_parser("action", help="动作模板合成") p.add_argument("--dance", required=True, help="舞蹈视频路径") p.add_argument("--tutorial", default=None, help="教程段视频路径(可选)") p.add_argument("--voiceover", required=True, help="口播音频路径") p.add_argument("--bgm", default=None, help="BGM 路径(可选)") p.add_argument("--subtitle-json", required=True, help="字幕时间戳 JSON") p.add_argument("--font", required=True, help="Poppins-Bold.ttf 字体路径") p.add_argument("--endcard", required=True, help="尾板视频路径") p.add_argument("--output", required=True, help="输出最终视频路径") # image 子命令 p = subparsers.add_parser("image", help="图片模板合成") p.add_argument("--photos", nargs="+", required=True, help="写真图路径列表") p.add_argument( "--tutorial", required=True, help="教程预拼视频 tut_combined.mp4 路径" ) p.add_argument("--pip", default=None, help="数字人抠像视频 .mov 路径(可选)") p.add_argument("--voiceover", required=True, help="口播音频路径") p.add_argument("--bgm", default=None, help="BGM 路径(可选)") p.add_argument("--subtitle-json", required=True, help="字幕时间戳 JSON") p.add_argument("--font", required=True, help="Poppins-Bold.ttf 字体路径") p.add_argument("--endcard", required=True, help="尾板视频路径") p.add_argument("--output", required=True, help="输出最终视频路径") args = parser.parse_args() if args.cmd == "action": cmd_action(args) elif args.cmd == "image": cmd_image(args) if __name__ == "__main__": main()