Popiai-skill仓库
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

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