Files
Popiai-skill/social-media/hilo-promo/scripts/tutorial_synthesis.py
T

505 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()