#!/usr/bin/env python3 """Extract oriented, vision-readable JPEG proxies from CR3 files.""" import argparse import csv import json import re import shutil import subprocess import tempfile from pathlib import Path STANDARD_TOOL_DIRS = (Path("/opt/homebrew/bin"), Path("/usr/local/bin"), Path("/usr/bin")) def find_tool(name: str) -> str: discovered = shutil.which(name) if discovered: return discovered for directory in STANDARD_TOOL_DIRS: candidate = directory / name if candidate.is_file() and candidate.stat().st_mode & 0o111: return str(candidate) raise SystemExit(f"Required tool not found: {name}") def numeric_key(path: Path): match = re.search(r"(\d+)$", path.stem) return (int(match.group(1)) if match else 0, str(path)) def rotation(label: str) -> str: if "270" in label: return "270" if "90" in label: return "90" if "180" in label: return "180" return "0" def extract_tag(exiftool: str, raw: Path, destination: Path) -> str: for tag in ("JpgFromRaw", "PreviewImage"): with destination.open("wb") as handle: result = subprocess.run( (exiftool, "-b", f"-{tag}", str(raw)), stdout=handle, stderr=subprocess.DEVNULL, ) if result.returncode == 0 and destination.stat().st_size > 0: return tag destination.unlink(missing_ok=True) raise RuntimeError(f"No embedded JPEG preview found in {raw}") def main(): parser = argparse.ArgumentParser() parser.add_argument("raw_root", type=Path) parser.add_argument("output", type=Path) parser.add_argument("--long-edge", type=int, default=1600) parser.add_argument("--quality", type=int, default=90) parser.add_argument("--limit", type=int) parser.add_argument("--overwrite", action="store_true") args = parser.parse_args() exiftool = find_tool("exiftool") magick = find_tool("magick") raw_root = args.raw_root.resolve() output = args.output.resolve() if not raw_root.is_dir(): raise SystemExit(f"RAW root not found: {raw_root}") if args.long_edge < 256 or not 1 <= args.quality <= 100: raise SystemExit("Invalid --long-edge or --quality") output.mkdir(parents=True, exist_ok=True) metadata = json.loads(subprocess.check_output(( exiftool, "-fast2", "-j", "-r", "-ext", "cr3", "-FileName", "-Directory", "-Orientation", "-DateTimeOriginal", "-SubSecTimeOriginal", str(raw_root), ))) by_path = { (Path(item["Directory"]) / item["FileName"]).resolve(): item for item in metadata } raws = sorted(by_path, key=numeric_key) if args.limit is not None: raws = raws[:args.limit] if not raws: raise SystemExit("No CR3 files found") rows = [] failures = [] with tempfile.TemporaryDirectory(prefix="cr3_vision_proxy_") as temp_name: temporary = Path(temp_name) / "embedded.jpg" for index, raw in enumerate(raws, 1): relative = raw.relative_to(raw_root).with_suffix(".jpg") preview = output / relative preview.parent.mkdir(parents=True, exist_ok=True) item = by_path[raw] tag = "existing" try: if args.overwrite or not preview.exists(): tag = extract_tag(exiftool, raw, temporary) command = [magick, str(temporary)] angle = rotation(str(item.get("Orientation", ""))) if angle != "0": command += ["-rotate", angle] command += [ "-resize", f"{args.long_edge}x{args.long_edge}>", "-strip", "-quality", str(args.quality), str(preview), ] subprocess.run( command, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) rows.append({ "raw_file": str(raw), "preview_file": str(preview), "relative_stem": str(relative.with_suffix("")), "extraction_tag": tag, "orientation": item.get("Orientation", ""), "date_time_original": item.get("DateTimeOriginal", ""), "subsec_time_original": item.get("SubSecTimeOriginal", ""), "bytes": preview.stat().st_size, "status": "ready", }) print(f"[{index:04d}/{len(raws):04d}] READY {relative}", flush=True) except Exception as exc: failures.append(f"{raw}: {exc}") manifest = output / "cr3_vision_preview_manifest.csv" fields = ( "raw_file", "preview_file", "relative_stem", "extraction_tag", "orientation", "date_time_original", "subsec_time_original", "bytes", "status", ) with manifest.open("w", newline="", encoding="utf-8") as handle: writer = csv.DictWriter(handle, fieldnames=fields) writer.writeheader() writer.writerows(rows) if failures: failure_path = output / "cr3_vision_preview_failures.txt" failure_path.write_text("\n".join(failures) + "\n", encoding="utf-8") print(f"READY {len(rows)} | FAILED {len(failures)}") print(f"MANIFEST {manifest}") return 1 if failures else 0 if __name__ == "__main__": raise SystemExit(main())