71 lines
3.0 KiB
Python
71 lines
3.0 KiB
Python
|
|
#!/usr/bin/env python3
|
|||
|
|
"""local_motion.py — 本地运镜工具(BC-006 IMAGE-FIRST 默认路由 LOCAL_MOTION)
|
|||
|
|
静态图 → ffmpeg zoompan → 竖屏 9:16 运镜片段(¥0 视频模型费)
|
|||
|
|
|
|||
|
|
用法:
|
|||
|
|
python tools/local_motion.py <图片文件夹> -o <输出文件夹> [--dur 6] [--fps 24]
|
|||
|
|
|
|||
|
|
运镜模式循环: push_in / zoom_out / pan_left / pan_right / pan_up / static
|
|||
|
|
输出: <输出文件夹>/shot_01.mp4 ... 每个片段 dur 秒
|
|||
|
|
"""
|
|||
|
|
import os, sys, subprocess, argparse, glob
|
|||
|
|
|
|||
|
|
def normalize(fps):
|
|||
|
|
return ["-vf", f"scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,scale=4320:7680,fps={fps}"]
|
|||
|
|
|
|||
|
|
def zoompan_filter(mode, frames, fps):
|
|||
|
|
z = "z='min(1+0.0008*on,1.6)'" # push_in 推近
|
|||
|
|
x = "x='iw/2-(iw/zoom/2)'"
|
|||
|
|
y = "y='ih/2-(ih/zoom/2)'"
|
|||
|
|
if mode == "zoom_out":
|
|||
|
|
z = "z='max(1.5-0.0008*on,1.0)'"
|
|||
|
|
elif mode == "pan_left": # 从左扫到右
|
|||
|
|
z = "z='1.2'"; x = "x='(iw-iw/zoom)*(on/{})'".format(frames)
|
|||
|
|
elif mode == "pan_right": # 从右扫到左
|
|||
|
|
z = "z='1.2'"; x = "x='(iw-iw/zoom)*(1-on/{})'".format(frames)
|
|||
|
|
elif mode == "pan_up": # 从下往上
|
|||
|
|
z = "z='1.2'"; y = "y='(ih-ih/zoom)*(1-on/{})'".format(frames)
|
|||
|
|
elif mode == "static":
|
|||
|
|
z = "z='1.0'"
|
|||
|
|
return f"zoompan={z}:{x}:{y}:d={frames}:s=1080x1920:fps={fps}"
|
|||
|
|
|
|||
|
|
def make_shot(img, out, dur, fps, mode):
|
|||
|
|
frames = dur * fps
|
|||
|
|
vf = normalize(fps)[1] + "," + zoompan_filter(mode, frames, fps)
|
|||
|
|
cmd = ["ffmpeg", "-y", "-loop", "1", "-i", img, "-vf", vf,
|
|||
|
|
"-t", str(dur), "-r", str(fps), "-c:v", "libx264", "-preset", "fast",
|
|||
|
|
"-crf", "20", "-pix_fmt", "yuv420p", "-an", out]
|
|||
|
|
r = subprocess.run(cmd, capture_output=True, text=True)
|
|||
|
|
return r.returncode == 0, r.stderr[-200:] if r.returncode else ""
|
|||
|
|
|
|||
|
|
def main():
|
|||
|
|
ap = argparse.ArgumentParser()
|
|||
|
|
ap.add_argument("folder")
|
|||
|
|
ap.add_argument("-o", "--output", default="local_motion_out")
|
|||
|
|
ap.add_argument("--dur", type=int, default=6)
|
|||
|
|
ap.add_argument("--fps", type=int, default=24)
|
|||
|
|
args = ap.parse_args()
|
|||
|
|
|
|||
|
|
imgs = sorted(glob.glob(os.path.join(args.folder, "*.png")) +
|
|||
|
|
glob.glob(os.path.join(args.folder, "*.jpg")) +
|
|||
|
|
glob.glob(os.path.join(args.folder, "*.jpeg")) +
|
|||
|
|
glob.glob(os.path.join(args.folder, "*.webp")))
|
|||
|
|
if not imgs:
|
|||
|
|
print("❌ 文件夹里没有图片"); return 1
|
|||
|
|
os.makedirs(args.output, exist_ok=True)
|
|||
|
|
modes = ["push_in", "zoom_out", "pan_left", "pan_right", "pan_up", "static"]
|
|||
|
|
ok = 0
|
|||
|
|
for i, img in enumerate(imgs[:64], 1):
|
|||
|
|
mode = modes[(i - 1) % len(modes)]
|
|||
|
|
out = os.path.join(args.output, f"shot_{i:02d}.mp4")
|
|||
|
|
print(f"[{i}/{len(imgs)}] {os.path.basename(img)} -> {mode} ...", end=" ")
|
|||
|
|
good, err = make_shot(img, out, args.dur, args.fps, mode)
|
|||
|
|
if good:
|
|||
|
|
print("✅"); ok += 1
|
|||
|
|
else:
|
|||
|
|
print("❌", err[-120:])
|
|||
|
|
print(f"完成: {ok}/{len(imgs)} 个片段已生成 -> {args.output}")
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
sys.exit(main())
|