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