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.
504 lines
15 KiB
504 lines
15 KiB
#!/usr/bin/env python3
|
|
"""
|
|
教程合成脚本 - 制作教程段素材(高亮框 + 箭头 + 上传动画 + 预拼接)
|
|
|
|
使用方式:
|
|
# 1. 生成高亮框教程截图(tut_002)
|
|
python tutorial_synthesis.py make-highlight \\
|
|
--template-screenshot tut_002_raw.jpg \\
|
|
--output tut_002.png \\
|
|
--box-x 100 --box-y 200 --box-w 300 --box-h 150
|
|
|
|
# 2. 生成上传动画(tut_003 → tut_004)
|
|
python tutorial_synthesis.py upload-anim \\
|
|
--before tut_003.png --after tut_004.png \\
|
|
--output tut_upload_anim.mp4 \\
|
|
--duration 2.5
|
|
|
|
# 3. 预拼接教程段(tut_001 + tut_002 + 上传动画)
|
|
python tutorial_synthesis.py combine \\
|
|
--segments tut1_norm.mp4 tut_002_norm.jpg tut_upload_anim.mp4 \\
|
|
--output tut_combined.mp4
|
|
|
|
# 4. 完整流水线(一次性执行上述所有步骤)
|
|
python tutorial_synthesis.py pipeline \\
|
|
--tut001 tut_001.mp4 \\
|
|
--tut002-raw tut_002_raw.jpg \\
|
|
--tut003 tut_003.png \\
|
|
--tut004 tut_004.png \\
|
|
--output-dir ./
|
|
"""
|
|
|
|
import argparse
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any, List, Optional
|
|
|
|
from PIL import Image, ImageDraw, ImageFilter
|
|
|
|
|
|
# ─────────────────────────── 配置 ───────────────────────────
|
|
|
|
CANVAS_WIDTH = 1080
|
|
CANVAS_HEIGHT = 1920
|
|
TARGET_FPS = 30
|
|
|
|
# 高亮框参数
|
|
HIGHLIGHT_FILL = (255, 100, 150, 60) # 半透明粉色
|
|
HIGHLIGHT_OUTLINE = (255, 80, 130, 220) # 深粉色描边
|
|
HIGHLIGHT_LINE_WIDTH = 4
|
|
|
|
|
|
# ─────────────────────────── 工具函数 ───────────────────────────
|
|
|
|
|
|
def rounded_rectangle(
|
|
draw: Any,
|
|
xy: tuple,
|
|
radius: int = 20,
|
|
fill: Optional[tuple] = None,
|
|
outline: Optional[tuple] = None,
|
|
width: int = 1,
|
|
) -> None:
|
|
"""绘制圆角矩形"""
|
|
x1, y1, x2, y2 = xy
|
|
|
|
# 画圆弧
|
|
draw.arc(
|
|
[x1, y1, x1 + radius * 2, y1 + radius * 2], 180, 270, fill or outline, width
|
|
)
|
|
draw.arc(
|
|
[x2 - radius * 2, y1, x2, y1 + radius * 2], 270, 360, fill or outline, width
|
|
)
|
|
draw.arc(
|
|
[x1, y2 - radius * 2, x1 + radius * 2, y2], 90, 180, fill or outline, width
|
|
)
|
|
draw.arc([x2 - radius * 2, y2 - radius * 2, x2, y2], 0, 90, fill or outline, width)
|
|
|
|
# 画矩形主体
|
|
draw.rectangle([x1 + radius, y1, x2 - radius, y2], fill=fill)
|
|
draw.rectangle([x1, y1 + radius, x2, y2 - radius], fill=fill)
|
|
|
|
# 画边框
|
|
draw.rectangle([x1, y1 + radius, x1 + width, y2 - radius], fill=outline)
|
|
draw.rectangle([x2 - width, y1 + radius, x2, y2 - radius], fill=outline)
|
|
draw.rectangle([x1 + radius, y1, x2 - radius, y1 + width], fill=outline)
|
|
draw.rectangle([x1 + radius, y2 - width, x2 - radius, y2], fill=outline)
|
|
|
|
|
|
def draw_arrow(
|
|
draw: Any,
|
|
x: int,
|
|
y: int,
|
|
size: int = 30,
|
|
angle: float = 0,
|
|
color: tuple = (255, 80, 130),
|
|
width: int = 4,
|
|
) -> None:
|
|
"""绘制手绘风格箭头(简化版:向下箭头)"""
|
|
# 向下箭头
|
|
cx, cy = x, y
|
|
tip = (cx, cy + size)
|
|
left = (cx - size // 2, cy)
|
|
right = (cx + size // 2, cy)
|
|
draw.line([tip, left], fill=color, width=width)
|
|
draw.line([tip, right], fill=color, width=width)
|
|
draw.line([left, (left[0], left[1] - size // 2)], fill=color, width=width)
|
|
draw.line([right, (right[0], right[1] - size // 2)], fill=color, width=width)
|
|
|
|
|
|
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 命令"""
|
|
print(f"🔧 {' '.join(cmd[:6])}...")
|
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
if result.returncode != 0:
|
|
print(f"❌ ffmpeg 错误:\n{result.stderr[-500:]}")
|
|
if check:
|
|
sys.exit(1)
|
|
|
|
|
|
# ─────────────────────────── 子命令实现 ───────────────────────────
|
|
|
|
|
|
def cmd_make_highlight(args: argparse.Namespace) -> None:
|
|
"""生成带高亮框的教程截图"""
|
|
print(f"🎨 生成高亮框教程截图...")
|
|
|
|
# 加载原图
|
|
img = Image.open(args.template_screenshot).convert("RGBA")
|
|
|
|
# 缩放到目标分辨率(如果需要)
|
|
LANCZOS = getattr(Image, "LANCZOS", Image.BICUBIC)
|
|
if img.size != (CANVAS_WIDTH, CANVAS_HEIGHT):
|
|
img = img.resize((CANVAS_WIDTH, CANVAS_HEIGHT), LANCZOS)
|
|
|
|
draw = ImageDraw.Draw(img)
|
|
|
|
# 绘制圆角高亮框
|
|
box = (args.box_x, args.box_y, args.box_x + args.box_w, args.box_y + args.box_h)
|
|
rounded_rectangle(
|
|
draw,
|
|
box,
|
|
radius=15,
|
|
fill=HIGHLIGHT_FILL,
|
|
outline=HIGHLIGHT_OUTLINE,
|
|
width=HIGHLIGHT_LINE_WIDTH,
|
|
)
|
|
|
|
# 绘制箭头(指向高亮区域)
|
|
arrow_x = args.box_x + args.box_w // 2
|
|
arrow_y = args.box_y + args.box_h + 20
|
|
draw_arrow(draw, arrow_x, arrow_y, size=25, color=HIGHLIGHT_OUTLINE, width=3)
|
|
|
|
# 保存
|
|
ensure_dir(Path(args.output))
|
|
img.save(args.output, "PNG")
|
|
print(f"✅ 输出:{args.output}")
|
|
|
|
|
|
def cmd_upload_anim(args: argparse.Namespace) -> None:
|
|
"""生成上传动画(xfade 过渡)"""
|
|
print(f"🎬 生成上传动画...")
|
|
|
|
output = Path(args.output)
|
|
ensure_dir(output)
|
|
|
|
# xfade 参数
|
|
duration = args.duration
|
|
transition = args.transition # slideup / slideleft / fadeblack
|
|
xfade_dur = duration * 0.3 # 转场占 30%
|
|
offset = duration - xfade_dur # 第二张图出现时间
|
|
|
|
cmd = [
|
|
"ffmpeg",
|
|
"-y",
|
|
"-loop",
|
|
"1",
|
|
"-t",
|
|
str(duration),
|
|
"-i",
|
|
str(args.before),
|
|
"-loop",
|
|
"1",
|
|
"-t",
|
|
str(duration),
|
|
"-i",
|
|
str(args.after),
|
|
"-filter_complex",
|
|
f"[0:v]fps={TARGET_FPS},format=yuv420p[v0];"
|
|
f"[1:v]fps={TARGET_FPS},format=yuv420p[v1];"
|
|
f"[v0][v1]xfade=transition={transition}:duration={xfade_dur}:offset={offset}",
|
|
"-c:v",
|
|
"libx264",
|
|
"-pix_fmt",
|
|
"yuv420p",
|
|
"-t",
|
|
str(duration),
|
|
str(output),
|
|
]
|
|
run_ffmpeg(cmd)
|
|
print(f"✅ 输出:{output}")
|
|
|
|
|
|
def cmd_combine(args: argparse.Namespace) -> None:
|
|
"""预拼接教程段(统一编码 + concat)"""
|
|
print(f"🔗 预拼接教程段...")
|
|
|
|
output = Path(args.output)
|
|
ensure_dir(output)
|
|
|
|
temp_files = []
|
|
tmp_dir = Path("_temp_tut_combine")
|
|
tmp_dir.mkdir(exist_ok=True)
|
|
|
|
try:
|
|
# 1. 统一所有素材的分辨率/帧率/像素格式
|
|
for i, seg in enumerate(args.segments):
|
|
seg_path = Path(seg)
|
|
tmp_out = tmp_dir / f"seg_{i:02d}_norm.mp4"
|
|
|
|
if seg_path.suffix in [".jpg", ".png"]:
|
|
# 图片:循环播放 1.5s
|
|
cmd = [
|
|
"ffmpeg",
|
|
"-y",
|
|
"-loop",
|
|
"1",
|
|
"-t",
|
|
"1.5",
|
|
"-i",
|
|
str(seg_path),
|
|
"-vf",
|
|
f"scale={CANVAS_WIDTH}:{CANVAS_HEIGHT},"
|
|
f"fps={TARGET_FPS},format=yuv420p,setsar=1",
|
|
"-c:v",
|
|
"libx264",
|
|
"-pix_fmt",
|
|
"yuv420p",
|
|
"-t",
|
|
"1.5",
|
|
str(tmp_out),
|
|
]
|
|
else:
|
|
# 视频:统一编码
|
|
cmd = [
|
|
"ffmpeg",
|
|
"-y",
|
|
"-i",
|
|
str(seg_path),
|
|
"-vf",
|
|
f"scale={CANVAS_WIDTH}:{CANVAS_HEIGHT},"
|
|
f"fps={TARGET_FPS},format=yuv420p,setsar=1",
|
|
"-an",
|
|
"-c:v",
|
|
"libx264",
|
|
"-pix_fmt",
|
|
"yuv420p",
|
|
str(tmp_out),
|
|
]
|
|
run_ffmpeg(cmd)
|
|
temp_files.append(str(tmp_out))
|
|
|
|
# 2. concat demuxer 拼接(不用 concat filter)
|
|
concat_file = tmp_dir / "concat_list.txt"
|
|
with open(concat_file, "w") as f:
|
|
for tf in temp_files:
|
|
f.write(f"file '{tf}'\n")
|
|
|
|
cmd = [
|
|
"ffmpeg",
|
|
"-y",
|
|
"-f",
|
|
"concat",
|
|
"-safe",
|
|
"0",
|
|
"-i",
|
|
str(concat_file),
|
|
"-c",
|
|
"copy",
|
|
str(output),
|
|
]
|
|
run_ffmpeg(cmd)
|
|
|
|
finally:
|
|
# 清理临时文件
|
|
import shutil
|
|
|
|
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
|
|
print(f"✅ 输出:{output}")
|
|
|
|
|
|
def cmd_pipeline(args: argparse.Namespace) -> None:
|
|
"""完整流水线:生成 tut_002 高亮框 → 上传动画 → 预拼接"""
|
|
import shutil
|
|
|
|
tmp_dir = Path("_temp_tut_pipeline")
|
|
if tmp_dir.exists():
|
|
shutil.rmtree(tmp_dir)
|
|
tmp_dir.mkdir(parents=True)
|
|
|
|
output_dir = Path(args.output_dir)
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Step 1: 生成 tut_002(高亮框)
|
|
tut002 = output_dir / "tut_002.png"
|
|
print("\n📸 Step 1: 生成 tut_002 高亮框...")
|
|
img = Image.open(args.tut002_raw).convert("RGBA")
|
|
if img.size != (CANVAS_WIDTH, CANVAS_HEIGHT):
|
|
img = img.resize((CANVAS_WIDTH, CANVAS_HEIGHT), Image.LANCZOS)
|
|
draw = ImageDraw.Draw(img)
|
|
# 默认高亮框(居中偏上)
|
|
box = (200, 400, 880, 900)
|
|
rounded_rectangle(
|
|
draw,
|
|
box,
|
|
radius=15,
|
|
fill=HIGHLIGHT_FILL,
|
|
outline=HIGHLIGHT_OUTLINE,
|
|
width=HIGHLIGHT_LINE_WIDTH,
|
|
)
|
|
draw_arrow(draw, 540, 920, size=25, color=HIGHLIGHT_OUTLINE, width=3)
|
|
img.save(tut002, "PNG")
|
|
print(f" ✅ {tut002}")
|
|
|
|
# Step 2: 生成上传动画
|
|
tut_upload = tmp_dir / "tut_upload_anim.mp4"
|
|
print("\n🎬 Step 2: 生成上传动画...")
|
|
cmd = [
|
|
"ffmpeg",
|
|
"-y",
|
|
"-loop",
|
|
"1",
|
|
"-t",
|
|
"2.5",
|
|
"-i",
|
|
str(args.tut003),
|
|
"-loop",
|
|
"1",
|
|
"-t",
|
|
"2.5",
|
|
"-i",
|
|
str(args.tut004),
|
|
"-filter_complex",
|
|
f"[0:v]fps={TARGET_FPS},format=yuv420p[v0];"
|
|
f"[1:v]fps={TARGET_FPS},format=yuv420p[v1];"
|
|
f"[v0][v1]xfade=transition=slideup:duration=0.8:offset=1.7",
|
|
"-c:v",
|
|
"libx264",
|
|
"-pix_fmt",
|
|
"yuv420p",
|
|
"-t",
|
|
"2.5",
|
|
str(tut_upload),
|
|
]
|
|
run_ffmpeg(cmd)
|
|
|
|
# Step 3: 统一编码所有片段
|
|
print("\n🔗 Step 3: 统一编码...")
|
|
norm_files = []
|
|
for i, (src, dur) in enumerate(
|
|
[
|
|
(Path(args.tut001), None), # tut_001 视频
|
|
(tut002, "1.2"), # tut_002 图片
|
|
(tut_upload, "2.5"), # 上传动画
|
|
]
|
|
):
|
|
tmp_out = tmp_dir / f"norm_{i}.mp4"
|
|
if src.suffix in [".jpg", ".png"]:
|
|
cmd = [
|
|
"ffmpeg",
|
|
"-y",
|
|
"-loop",
|
|
"1",
|
|
"-t",
|
|
dur,
|
|
"-i",
|
|
str(src),
|
|
"-vf",
|
|
f"scale={CANVAS_WIDTH}:{CANVAS_HEIGHT},"
|
|
f"fps={TARGET_FPS},format=yuv420p,setsar=1",
|
|
"-c:v",
|
|
"libx264",
|
|
"-pix_fmt",
|
|
"yuv420p",
|
|
"-t",
|
|
dur,
|
|
str(tmp_out),
|
|
]
|
|
else:
|
|
cmd = [
|
|
"ffmpeg",
|
|
"-y",
|
|
"-i",
|
|
str(src),
|
|
"-vf",
|
|
f"scale={CANVAS_WIDTH}:{CANVAS_HEIGHT},"
|
|
f"fps={TARGET_FPS},format=yuv420p,setsar=1",
|
|
"-an",
|
|
"-c:v",
|
|
"libx264",
|
|
"-pix_fmt",
|
|
"yuv420p",
|
|
str(tmp_out),
|
|
]
|
|
run_ffmpeg(cmd)
|
|
norm_files.append(str(tmp_out))
|
|
|
|
# Step 4: concat 拼接
|
|
print("\n🔗 Step 4: 拼接...")
|
|
tut_combined = output_dir / "tut_combined.mp4"
|
|
concat_file = tmp_dir / "concat.txt"
|
|
with open(concat_file, "w") as f:
|
|
for nf in norm_files:
|
|
f.write(f"file '{nf}'\n")
|
|
|
|
cmd = [
|
|
"ffmpeg",
|
|
"-y",
|
|
"-f",
|
|
"concat",
|
|
"-safe",
|
|
"0",
|
|
"-i",
|
|
str(concat_file),
|
|
"-c",
|
|
"copy",
|
|
str(tut_combined),
|
|
]
|
|
run_ffmpeg(cmd)
|
|
|
|
shutil.rmtree(tmp_dir)
|
|
print(f"\n✅ 流水线完成!")
|
|
print(f" tut_002.png → {tut002}")
|
|
print(f" tut_upload_anim.mp4 → {tmp_dir / 'tut_upload_anim.mp4'}")
|
|
print(f" tut_combined.mp4 → {tut_combined}")
|
|
|
|
|
|
# ─────────────────────────── 主入口 ───────────────────────────
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="教程合成脚本:高亮框 + 上传动画 + 预拼接",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog=__doc__,
|
|
)
|
|
subparsers = parser.add_subparsers(dest="cmd", required=True)
|
|
|
|
# make-highlight
|
|
p_highlight = subparsers.add_parser("make-highlight", help="生成带高亮框的教程截图")
|
|
p_highlight.add_argument("--template-screenshot", required=True, help="原教程截图")
|
|
p_highlight.add_argument("--output", required=True, help="输出 PNG 路径")
|
|
p_highlight.add_argument("--box-x", type=int, default=200, help="高亮框 X")
|
|
p_highlight.add_argument("--box-y", type=int, default=400, help="高亮框 Y")
|
|
p_highlight.add_argument("--box-w", type=int, default=680, help="高亮框宽度")
|
|
p_highlight.add_argument("--box-h", type=int, default=300, help="高亮框高度")
|
|
|
|
# upload-anim
|
|
p_anim = subparsers.add_parser("upload-anim", help="生成上传动画")
|
|
p_anim.add_argument("--before", required=True, help="上传前状态图片")
|
|
p_anim.add_argument("--after", required=True, help="上传后状态图片")
|
|
p_anim.add_argument("--output", required=True, help="输出 MP4 路径")
|
|
p_anim.add_argument("--duration", type=float, default=2.5, help="动画总时长(秒)")
|
|
p_anim.add_argument(
|
|
"--transition",
|
|
default="slideup",
|
|
choices=["slideup", "slideleft", "fadeblack"],
|
|
help="转场类型",
|
|
)
|
|
|
|
# combine
|
|
p_combine = subparsers.add_parser("combine", help="预拼接教程段")
|
|
p_combine.add_argument(
|
|
"--segments", nargs="+", required=True, help="素材列表(视频/图片)"
|
|
)
|
|
p_combine.add_argument("--output", required=True, help="输出 MP4 路径")
|
|
|
|
# pipeline
|
|
p_pipeline = subparsers.add_parser("pipeline", help="完整流水线")
|
|
p_pipeline.add_argument("--tut001", required=True, help="tut_001 视频")
|
|
p_pipeline.add_argument("--tut002-raw", required=True, help="tut_002 原图")
|
|
p_pipeline.add_argument("--tut003", required=True, help="上传前状态 tut_003")
|
|
p_pipeline.add_argument("--tut004", required=True, help="上传后状态 tut_004")
|
|
p_pipeline.add_argument("--output-dir", default=".", help="输出目录")
|
|
|
|
args = parser.parse_args()
|
|
|
|
if args.cmd == "make-highlight":
|
|
cmd_make_highlight(args)
|
|
elif args.cmd == "upload-anim":
|
|
cmd_upload_anim(args)
|
|
elif args.cmd == "combine":
|
|
cmd_combine(args)
|
|
elif args.cmd == "pipeline":
|
|
cmd_pipeline(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
|