kritnatee's picture
download
raw
5.5 kB
"""
make-video-v3.py — ประกอบวิดีโอจาก scenes.json
- Ken Burns effect (zoompan) แต่ละฉาก
- xfade transition หลากหลายตามที่ scene-planner วางแผนไว้
- รองรับ fade/slideleft/slideright/slideup/slidedown/dissolve/fadeblack
"""
import argparse, json, os, subprocess, sys, glob, random, shutil, math
TRANSITION_MAP = {
"fade": "fade",
"crossfade": "fade",
"dissolve": "dissolve",
"slide_left": "slideleft",
"slide_right": "slideright",
"slide_up": "slideup",
"slide_down": "slidedown",
"zoom_in": "fade",
"fadeblack": "fadeblack",
"fadewhite": "fadewhite",
"pixelize": "pixelize",
"circlecrop": "circlecrop",
}
parser = argparse.ArgumentParser()
parser.add_argument("--scenes", default="scenes.json", help="Production plan from scene-planner")
parser.add_argument("--images-dir", default="images", help="Image directory")
parser.add_argument("--output", required=True)
parser.add_argument("--fps", type=int, default=24)
parser.add_argument("--overlap", type=float, default=0.5, help="Transition overlap in seconds")
args = parser.parse_args()
# Load scenes
with open(args.scenes, encoding="utf-8") as f:
plan = json.load(f)
scenes = plan["scenes"]
print(f" Scenes: {len(scenes)}")
# Find images
image_files = sorted(sum([
glob.glob(os.path.join(args.images_dir, "**", ext), recursive=True)
for ext in ("*.png", "*.jpg", "*.jpeg", "*.webp")
], []))
print(f" Images available: {len(image_files)}")
if not scenes:
print("ERROR: No scenes in plan"); sys.exit(1)
ffmpeg = "ffmpeg.exe" if os.name == "nt" else "ffmpeg"
out_dir = os.path.dirname(args.output) or "."
os.makedirs(out_dir, exist_ok=True)
temp_dir = os.path.join(out_dir, "_segments_v3")
os.makedirs(temp_dir, exist_ok=True)
# Step 1: Render each scene as MP4 clip with Ken Burns
clip_paths = []
for i, scene in enumerate(scenes):
# Find image
img_ref = scene.get("start_image", "")
img_path = None
if img_ref and os.path.exists(img_ref):
img_path = img_ref
elif img_ref:
matches = [f for f in image_files if img_ref in f]
if matches:
img_path = matches[0]
if not img_path and i < len(image_files):
img_path = image_files[i]
if not img_path:
print(f" Scene {i+1}: SKIP (no image)"); continue
# Parse duration
dur = scene.get("duration", 3.0)
fps_val = args.fps
frames = int(dur * fps_val)
# Ken Burns: random zoom + pan
rng = random.Random(hash(img_path) % 10000)
zoom = rng.uniform(1.02, 1.06)
dx = rng.uniform(-20, 20)
dy = rng.uniform(-20, 20)
base_f = max(1, int(frames * 0.03)) # minimum frames for zoom
zoompan = (
f"scale=1920:1080:force_original_aspect_ratio=decrease,"
f"pad=1920:1080:(ow-iw)/2:(oh-ih)/2,"
f"zoompan=z='if(eq(on,1),1,min(zoom+({zoom}-1)/{frames},{zoom}))':"
f"d={frames}:x='iw/2-(iw/zoom/2)+{dx}*(on/{frames})':"
f"y='ih/2-(ih/zoom/2)+{dy}*(on/{frames})':s=1920x1080,"
f"fps={fps_val}"
)
clip_out = os.path.join(temp_dir, f"s{i:03d}.mp4")
subprocess.run([
ffmpeg, "-y", "-loop", "1", "-i", img_path,
"-vf", zoompan,
"-c:v", "libx264", "-preset", "fast", "-crf", "18",
"-r", str(fps_val),
"-t", str(dur), "-an", clip_out
], capture_output=True)
clip_paths.append(clip_out)
print(f" Scene {i+1}: rendered ({dur}s) [{scene.get('mood','?')}]")
if len(clip_paths) < 1:
print("ERROR: no clips"); sys.exit(1)
# Step 2: Chain clips with xfade transitions
print(f" Applying transitions (overlap={args.overlap}s)...")
# Build xfade filter graph
# For N clips, we need N-1 xfade filters
O = args.overlap
filter_parts = []
durations = [scenes[i].get("duration", 3.0) for i in range(len(scenes))]
# Trim durations to actual clips
durations = durations[:len(clip_paths)]
current = "0"
running_sum = 0.0
for i in range(1, len(clip_paths)):
prev = current
current = f"f{i}"
t_name = scenes[i].get("transition_in", "fade") if i < len(scenes) else "fade"
t = TRANSITION_MAP.get(t_name, "fade")
# Input label: [0:v] for first, [f1], [f2], ... for intermediate
prev_label = f"[0:v]" if prev == "0" else f"[{prev}]"
next_label = f"[{i}:v]"
running_sum += durations[i-1]
offset = running_sum - i * O
filter_parts.append(
f"{prev_label}{next_label}xfade=transition={t}:duration={O}:offset={offset}[{current}]"
)
filter_complex = ";".join(filter_parts)
cmd = [
ffmpeg, "-y"
]
for clip in clip_paths:
cmd.extend(["-i", clip])
cmd.extend([
"-filter_complex", filter_complex,
"-map", f"[{current}]",
"-c:v", "libx264", "-preset", "slow", "-crf", "18",
"-pix_fmt", "yuv420p", "-movflags", "+faststart",
args.output
])
print(f" Running FFmpeg xfade...")
result = subprocess.run(cmd, capture_output=True, text=True)
# Cleanup
shutil.rmtree(temp_dir, ignore_errors=True)
if result.returncode == 0:
size_mb = os.path.getsize(args.output) / 1024 / 1024
print(f" -> Video: {args.output} ({size_mb:.1f} MB)")
# Print transition summary
print(f"\n Transition sequence:")
for i in range(1, len(scenes)):
if i < len(scenes):
t_name = scenes[i].get("transition_in", "fade")
print(f" Scene {i}{i+1}: {t_name}")
else:
print(f" ERROR: {result.stderr[-500:]}")
sys.exit(1)

Xet Storage Details

Size:
5.5 kB
·
Xet hash:
ed0fd53677d891769b1ca22562ed5719e4ab891b944742f50303fed62ed00604

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.