#!/usr/bin/env python3 """Create DaVinci-ready ping-pong clips from approved burst folders.""" import argparse import csv import json import math import re import shutil import subprocess import tempfile from datetime import datetime from pathlib import Path CADENCES = (6, 8, 10) def capture_seconds(item): if "DateTimeOriginal" not in item: return None base = datetime.strptime(item["DateTimeOriginal"], "%Y:%m:%d %H:%M:%S").timestamp() sub = str(item.get("SubSecTimeOriginal", "0")) return base + int(sub) / (10 ** len(sub)) def frame_number(path): return int(re.search(r"(\d+)$", path.stem).group(1)) def choose_fps(items): ordered = sorted(items, key=lambda item: frame_number(Path(item["FileName"]))) times = [capture_seconds(item) for item in ordered] times = [value for value in times if value is not None] gaps = [times[i + 1] - times[i] for i in range(len(times) - 1)] drive_gaps = sorted(gap for gap in gaps if 0.04 <= gap <= 0.25) if not drive_gaps: return 10 median = drive_gaps[len(drive_gaps) // 2] measured = 1 / median return min(CADENCES, key=lambda fps: abs(fps - measured)) def orientation_filter(items): labels = [str(item.get("Orientation", "")) for item in items] dominant = max(set(labels), key=labels.count) if labels else "" if "270" in dominant: return "transpose=cclock," if "90" in dominant: return "transpose=clock," return "" def render_burst(burst, items, output_dir, target_seconds, minimum_frames, include_review_fragments, codec, width, height, fps_override, overwrite): frames = sorted([ path for path in burst.iterdir() if path.suffix.lower() in (".jpg", ".jpeg", ".tif", ".tiff") ], key=frame_number) if len(frames) < 2: raise ValueError(f"{burst.name} has fewer than two supported frames") metadata = {item["FileName"]: item for item in items} fps = fps_override or choose_fps(items) ping_pong = frames + frames[-2:0:-1] cycle_duration = len(ping_pong) / fps if len(frames) < minimum_frames and not include_review_fragments: print(f"REVIEW {burst.name} | only {len(frames)} source frames", flush=True) return { "burst": burst.name, "status": "fragment_review", "source_frames": len(frames), "fps": fps, "cycles": 0, "base_cycle_seconds": round(cycle_duration, 3), "output_frames": 0, "duration_seconds": 0, "output_file": "", "file_bytes": 0, "corrections": "below minimum source-frame count", } cycles = max(1, math.ceil(target_seconds / cycle_duration)) output_frames = len(ping_pong) * cycles suffix = ".mp4" if codec == "h264" else ".mov" output = output_dir / f"{burst.name}_Boomerang_{fps}fps_{codec}{suffix}" if output.exists() and not overwrite: print(f"SKIP {output.name}", flush=True) return { "burst": burst.name, "status": "existing", "source_frames": len(frames), "fps": fps, "cycles": cycles, "output_frames": output_frames, "base_cycle_seconds": round(cycle_duration, 3), "duration_seconds": round(output_frames / fps, 3), "output_file": output.name, "file_bytes": output.stat().st_size, "corrections": "existing file retained", } corrections = set() with tempfile.TemporaryDirectory(prefix="burst_photo_processing_") as temp_name: temp_dir = Path(temp_name) for frame in frames: if not metadata.get(frame.name, {}).get("Orientation"): corrections.add(f"sequence-level rotation restored for {frame.stem}.jpg") sequence = ping_pong * cycles for index, frame in enumerate(sequence): (temp_dir / f"frame{index:06d}{frames[0].suffix.lower()}").symlink_to( frame.resolve() ) rotate = orientation_filter(items) if codec == "h264": pixel_filter = "format=yuv420p" codec_args = [ "-c:v", "libx264", "-preset", "medium", "-crf", "15", "-profile:v", "high", "-movflags", "+faststart", ] else: pixel_filter = "format=yuv422p10le" codec_args = ["-c:v", "prores_ks", "-profile:v", "3", "-vendor", "apl0"] command = [ "ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-noautorotate", "-framerate", str(fps), "-start_number", "0", "-i", str(temp_dir / f"frame%06d{frames[0].suffix.lower()}"), "-vf", ( f"{rotate}scale={width}:{height}:force_original_aspect_ratio=decrease:" "force_divisible_by=2:flags=lanczos:in_range=full:out_range=tv," f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:black,setsar=1,{pixel_filter}" ), *codec_args, "-fps_mode", "cfr", "-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709", "-movflags", "+faststart", "-metadata", f"title={burst.name} ping-pong burst", str(output), ] subprocess.run(command, check=True) print( f"DONE {output.name} | {len(frames)} source frames | {fps} fps | " f"{cycles} cycle(s) | {output_frames / fps:.2f}s", flush=True, ) return { "burst": burst.name, "status": "rendered", "source_frames": len(frames), "fps": fps, "cycles": cycles, "output_frames": output_frames, "base_cycle_seconds": round(cycle_duration, 3), "duration_seconds": round(output_frames / fps, 3), "output_file": output.name, "file_bytes": output.stat().st_size, "corrections": "; ".join(sorted(corrections)), } def main(): parser = argparse.ArgumentParser() parser.add_argument("--source", type=Path, required=True) parser.add_argument("--output", type=Path) parser.add_argument("--target-seconds", type=float, default=3.0) parser.add_argument("--minimum-frames", type=int, default=5) parser.add_argument("--minimum-cycle", type=float, help=argparse.SUPPRESS) parser.add_argument( "--include-review-fragments", "--include-microbursts", dest="include_review_fragments", action="store_true", ) parser.add_argument("--codec", choices=("h264", "prores"), default="h264") parser.add_argument("--width", type=int, default=1366) parser.add_argument("--height", type=int, default=2048) parser.add_argument("--fps", type=int, choices=CADENCES) parser.add_argument("--only", help="Render one folder, e.g. Burst19") parser.add_argument("--overwrite", action="store_true") args = parser.parse_args() for tool in ("exiftool", "ffmpeg"): if not shutil.which(tool): raise SystemExit(f"Required tool not found: {tool}") if not args.source.is_dir(): raise SystemExit(f"Source folder not found: {args.source}") if args.output is None: args.output = args.source.parent / f"{args.source.name}_Boomerang_{args.codec}" args.output.mkdir(parents=True, exist_ok=True) metadata = json.loads(subprocess.check_output([ "exiftool", "-j", "-r", "-ext", "jpg", "-ext", "jpeg", "-ext", "tif", "-ext", "tiff", "-DateTimeOriginal", "-SubSecTimeOriginal", "-Orientation", "-FileName", "-Directory", str(args.source), ])) by_directory = {} for item in metadata: by_directory.setdefault(item["Directory"], []).append(item) bursts = sorted(args.source.glob("Burst*"), key=lambda path: int(path.name[5:7])) if args.only: bursts = [burst for burst in bursts if burst.name.startswith(args.only)] if not bursts: raise SystemExit(f"No burst folder matched: {args.only}") rows = [] for burst in bursts: rows.append(render_burst( burst, by_directory.get(str(burst), []), args.output, args.target_seconds, args.minimum_frames, args.include_review_fragments, args.codec, args.width, args.height, args.fps, args.overwrite, )) manifest = args.output / "burst_render_manifest.csv" with manifest.open("w", newline="") as handle: writer = csv.DictWriter(handle, fieldnames=rows[0].keys()) writer.writeheader() writer.writerows(rows) print(f"MANIFEST {manifest}", flush=True) if __name__ == "__main__": main()