#!/usr/bin/env python3 """Plan burst groups from EXIF capture timing without changing originals.""" import argparse import csv import json import math import re import subprocess from datetime import datetime from pathlib import Path CADENCES = (6, 8, 10) def number(name): match = re.search(r"(\d+)(?=\.[^.]+$)", name) return int(match.group(1)) if match else 0 def seconds(item): stamp = item.get("DateTimeOriginal") if not stamp: return None base = datetime.strptime(stamp, "%Y:%m:%d %H:%M:%S").timestamp() sub = str(item.get("SubSecTimeOriginal", "0")) return base + int(sub) / (10 ** len(sub)) def cadence(group): gaps = [group[i + 1]["time"] - group[i]["time"] for i in range(len(group) - 1)] short = sorted(gap for gap in gaps if 0.04 <= gap <= 0.25) if not short: return 10 measured = 1 / short[len(short) // 2] return min(CADENCES, key=lambda value: abs(value - measured)) def main(): parser = argparse.ArgumentParser() parser.add_argument("source", type=Path, help="Folder containing CR3 or JPEG files") parser.add_argument("--output", type=Path, default=Path("burst_plan.csv")) parser.add_argument("--gap", type=float, default=0.25) parser.add_argument("--minimum-frames", type=int, default=5) parser.add_argument("--minimum-output", type=float, default=3.0) args = parser.parse_args() if not args.source.is_dir(): raise SystemExit(f"Source folder not found: {args.source}") metadata = json.loads(subprocess.check_output([ "exiftool", "-fast2", "-j", "-r", "-ext", "cr3", "-ext", "jpg", "-ext", "jpeg", "-DateTimeOriginal", "-SubSecTimeOriginal", "-Orientation", "-FileName", "-Directory", str(args.source), ])) # Prefer RAW metadata when a folder tree contains both RAW files and derived exports. if any(item["FileName"].lower().endswith(".cr3") for item in metadata): metadata = [item for item in metadata if item["FileName"].lower().endswith(".cr3")] # Grouped preview trees can contain the same source filename more than once. # Keep one metadata record per camera basename so duplicates cannot become motion. deduplicated = {} for item in metadata: deduplicated.setdefault(Path(item["FileName"]).stem.lower(), item) metadata = list(deduplicated.values()) missing = [item["FileName"] for item in metadata if seconds(item) is None] usable = [{**item, "time": seconds(item)} for item in metadata if seconds(item) is not None] usable.sort(key=lambda item: (item["time"], number(item["FileName"]))) groups = [] for item in usable: if not groups or item["time"] - groups[-1][-1]["time"] > args.gap: groups.append([item]) else: groups[-1].append(item) rows = [] for index, group in enumerate(groups, 1): fps = cadence(group) frames = len(group) cycle_frames = (2 * frames - 2) if frames > 1 else 1 cycle = cycle_frames / fps cycles = max(1, math.ceil(args.minimum_output / cycle)) rows.append({ "candidate": f"Candidate{index:03d}", "first_file": group[0]["FileName"], "last_file": group[-1]["FileName"], "unique_frames": frames, "capture_span_seconds": round(group[-1]["time"] - group[0]["time"], 3), "playback_fps": fps, "pingpong_cycle_seconds": round(cycle, 3), "cycles_for_minimum_output": cycles, "output_seconds": round(cycle * cycles, 3), "qualification": "visual_review" if frames >= args.minimum_frames else "fragment_review", }) args.output.parent.mkdir(parents=True, exist_ok=True) with args.output.open("w", newline="") as handle: writer = csv.DictWriter(handle, fieldnames=rows[0].keys() if rows else []) if rows: writer.writeheader() writer.writerows(rows) print(f"Candidates: {len(rows)}") print(f"Review fragments: {sum(row['qualification'] == 'fragment_review' for row in rows)}") print(f"Missing timestamps: {len(missing)}") print(f"Plan: {args.output}") if missing: missing_path = args.output.with_suffix(".missing_metadata.txt") missing_path.write_text("\n".join(missing) + "\n") print(f"Missing metadata list: {missing_path}") if __name__ == "__main__": main()