You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
542 lines
16 KiB
542 lines
16 KiB
#!/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()
|
|
|