Flatten skill category directory structure
This commit is contained in:
@@ -0,0 +1,504 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user