- 面板: 会话持久化(oc_sess.json)/余额实时/文件栏(搜索+MD+拖拽)/朗读(edge-tts)/工具过程实时显示/漫剧画布 - 面板: 经验自动检索注入+存经验按钮(experience.py) - 管线: local_motion本地运镜/IMAGE-FIRST-GUIDE/LOCAL-PIPELINE-V1 - 经验: EED-EXPER-014 面板工程+管线+飞书扒取全记录 - 资料: 飞书《清欢AIGC伪真人短剧全流程》归档
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())
|