import argparse import shutil import sys from pathlib import Path from PIL import Image, ImageOps from pillow_heif import register_heif_opener from jsonfile import PROJECT_ROOT FILES_DIR = PROJECT_ROOT / "static" / "files" ORIGINALS_DIR = PROJECT_ROOT / "tools" / "content" / ".cache" / "heic-originals" HEIC_EXTENSIONS = {".heic", ".heif"} PHOTO_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"} JPEG_QUALITY = 90 GPS_TAG = 0x8825 register_heif_opener() def visible_files(folder: Path, extensions: set[str]) -> list[Path]: return sorted( path for path in folder.rglob("*") if path.is_file() and path.suffix.lower() in extensions and not any(part.startswith(".") for part in path.relative_to(folder).parts) ) def save_without_metadata(image: Image.Image, target: Path) -> None: upright = ImageOps.exif_transpose(image) if target.suffix.lower() in {".jpg", ".jpeg"}: upright.convert("RGB").save(target, "JPEG", quality=JPEG_QUALITY, optimize=True) else: upright.save(target) def keep_original(source: Path) -> Path: backup = ORIGINALS_DIR / source.relative_to(FILES_DIR) backup.parent.mkdir(parents=True, exist_ok=True) shutil.move(source, backup) return backup def convert_heic(source: Path, delete_originals: bool) -> str: target = source.with_suffix(".jpg") if target.exists(): return f"skipped {source.relative_to(FILES_DIR)}, {target.name} already exists" with Image.open(source) as image: save_without_metadata(image, target) if delete_originals: source.unlink() return f"converted {target.relative_to(FILES_DIR)}" return f"converted {target.relative_to(FILES_DIR)}, original kept in {keep_original(source).relative_to(PROJECT_ROOT)}" def has_location(path: Path) -> bool: try: with Image.open(path) as image: return bool(image.getexif().get_ifd(GPS_TAG)) except OSError: return False def strip_location(path: Path) -> str: with Image.open(path) as image: image.load() save_without_metadata(image, path) return f"removed location from {path.relative_to(FILES_DIR)}" def report(action) -> int: try: print(action()) return 0 except (OSError, ValueError) as error: print(f"Error: {error}", file=sys.stderr) return 1 def main() -> int: parser = argparse.ArgumentParser(description="Convert HEIC photos in static/files to JPEG and remove location data from photos") parser.add_argument("--delete-originals", action="store_true", help="delete converted HEIC files instead of moving them to tools/content/.cache") parser.add_argument("--keep-location", action="store_true", help="leave GPS data in existing photos") arguments = parser.parse_args() if not FILES_DIR.exists(): print(f"{FILES_DIR.relative_to(PROJECT_ROOT)} does not exist", file=sys.stderr) return 1 heic_files = visible_files(FILES_DIR, HEIC_EXTENSIONS) located_photos = [] if arguments.keep_location else [photo for photo in visible_files(FILES_DIR, PHOTO_EXTENSIONS) if has_location(photo)] if not heic_files and not located_photos: print("Nothing to do: no HEIC files and no photos with location data") return 0 failed = 0 for source in heic_files: failed += report(lambda: convert_heic(source, arguments.delete_originals)) for photo in located_photos: failed += report(lambda: strip_location(photo)) print(f"Done: {len(heic_files) + len(located_photos) - failed} changed, {failed} failed") return 1 if failed else 0 if __name__ == "__main__": sys.exit(main())