Buckets:
| """ | |
| 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.