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