From fc6761e70e2356e279dd54a1dfb99cf0ff218baa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20So=CC=88lve?= Date: Tue, 28 Apr 2026 07:46:04 +0200 Subject: [PATCH] photos: handle IPmi (iMessage) selection + use preview derivatives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Photos refuses to coerce `id of p` for IPmi class items and rejects exporting them via `media item id "..."` references. We extract the uuid from AppleScript's coercion error message (which dumps the whole selection list literally) and look up the preview derivative via osxphotos — IPmi items live under `scopes/syndication/resources/ derivatives/` and load fine that way. Per-item export with try-wrapper as fallback: lets normal items export even when an IPmi item is in the selection, instead of failing the whole batch. Co-Authored-By: Claude Opus 4.7 (1M context) --- photos_caption.py | 207 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 168 insertions(+), 39 deletions(-) diff --git a/photos_caption.py b/photos_caption.py index 73e6b43..a0e8d4e 100755 --- a/photos_caption.py +++ b/photos_caption.py @@ -15,6 +15,7 @@ any keywords you've added manually). """ import argparse +import re import subprocess from pathlib import Path @@ -48,18 +49,159 @@ def applescript_string(s: str) -> str: return s.replace("\\", "\\\\").replace('"', '\\"') -def get_selection_ids() -> list[str]: +TMP_EXPORT_DIR = Path("/tmp/gemma-photos-export") +IMAGE_EXTS = {".jpg", ".jpeg", ".heic", ".heif", ".png", ".tiff", ".tif"} + + +_ID_IN_ERROR = re.compile(r'id "([A-Fa-f0-9]{8}-[A-Fa-f0-9-]+(?:/L\d+/\d+)?)"') + + +def get_selection_items() -> list[dict]: + """Resolve the current Photos selection. + + For each selected item returns a dict with keys + id: str | None — Photos uuid (e.g. "UUID/L0/001") + selection_index: int — 1-based index in the current selection + + For normal items `id of p` coerces fine. For iMessage attachments + (AppleScript class «IPmi») coercion raises -1700 — but the error + message AppleScript hands back contains the id literal, so we + capture it and parse the uuid from there. Net effect: every + selectable item ends up with an id and can be looked up via + osxphotos. + """ script = ''' tell application "Photos" set sel to (get selection) - set out to "" - repeat with p in sel - set out to out & (id of p) & linefeed + set total to count of sel + set out to (total as text) & linefeed + repeat with i from 1 to total + try + set theId to (id of item i of sel) as text + on error errMsg + set theId to "ERR:" & errMsg + end try + if theId is "" then set theId to "_NOID_" + set out to out & theId & linefeed end repeat return out end tell ''' - return [line for line in osa(script).split("\n") if line] + lines = [line for line in osa(script).split("\n") if line.strip()] + if not lines: + return [] + total = int(lines[0]) + raw_ids = lines[1:] + items = [] + for idx, raw in enumerate(raw_ids, 1): + photo_id: str | None + if raw.startswith("ERR:"): + # Coercion errors come back with the *whole* selection list + # described literally — `... id of item N of {«class IPmi» + # id "UUID1" ..., «class IPmi» id "UUID2" ...} till typ text.` + # — so we have to pick the i-th id, not the first match. + all_ids = _ID_IN_ERROR.findall(raw[4:]) + photo_id = all_ids[idx - 1] if 1 <= idx <= len(all_ids) else None + elif raw == "_NOID_": + photo_id = None + else: + photo_id = raw + items.append({"id": photo_id, "selection_index": idx}) + return items + + +def export_selection_items(indices: list[int]) -> dict[int, Path]: + """Export each given selection index to its own subdirectory. + + Per-item with try-wrapper around the export — Photos refuses to + resolve IPmi class items as `media item id "…"`, so those silently + fail without taking down the whole batch. Returns only the indices + that produced an image file; missing ones likely couldn't be + exported via AppleScript and need another path (or aren't recoverable). + """ + if TMP_EXPORT_DIR.exists(): + for entry in TMP_EXPORT_DIR.iterdir(): + if entry.is_file(): + entry.unlink() + else: + for f in entry.iterdir(): + if f.is_file(): + f.unlink() + entry.rmdir() + TMP_EXPORT_DIR.mkdir(parents=True, exist_ok=True) + + paths: dict[int, Path] = {} + for sidx in indices: + item_dir = TMP_EXPORT_DIR / str(sidx) + item_dir.mkdir(parents=True, exist_ok=True) + script = f''' + tell application "Photos" + set sel to (get selection) + try + export {{item {sidx} of sel}} to (POSIX file "{item_dir}" as alias) without using originals + end try + end tell + ''' + osa(script) + files = sorted( + f for f in item_dir.glob("*") + if f.is_file() and f.suffix.lower() in IMAGE_EXTS + ) + if files: + paths[sidx] = files[0] + return paths + + +def find_preview_path(db, photo_id: str) -> Path | None: + """Return an on-disk preview derivative for a Photos id, or None. + + Only returns derivative previews — never originals. Gemma's vision + encoder resizes to ~768 internally, so loading a 50MB HEIC original + just slows things down. When no derivative is on disk (e.g. iCloud- + only with derivative purged), the caller falls back to bulk export. + """ + uuid = photo_id.split("/")[0] + photo = db.get_photo(uuid) + if photo is None: + return None + for d in photo.path_derivatives or []: + if d and Path(d).exists(): + return Path(d) + return None + + +def resolve_preview_paths(items: list[dict], db) -> dict[int, Path]: + """Resolve a preview JPEG/HEIC path for each selection item. + + Fast path: on-disk derivative via osxphotos. iMessage items live + under `scopes/syndication/resources/derivatives/` and osxphotos + finds them just fine — provided we managed to extract the id from + the AppleScript error in `get_selection_items`. + + Slow path (only when the derivative isn't on disk, e.g. iCloud-only): + per-item AppleScript export with try-wrapper, so IPmi items that + Photos refuses to export silently drop out without taking the + rest of the batch with them. + """ + paths: dict[int, Path] = {} + needs_export: list[dict] = [] + for item in items: + if item["id"]: + preview = find_preview_path(db, item["id"]) + if preview: + paths[item["selection_index"]] = preview + continue + needs_export.append(item) + + if not needs_export: + return paths + + print("Exporterar förhandsvisningar via Photos…", end=" ", flush=True) + indices = [it["selection_index"] for it in needs_export] + exported = export_selection_items(indices) + print(f"{len(exported)}/{len(indices)} fil(er).") + paths.update(exported) + return paths # AppleScript snippet that resolves `targetId` (already declared) to a media @@ -99,29 +241,6 @@ def build_context_block(photo) -> str | None: return "\n".join(parts) if parts else None -def find_local_path(db, photo_id: str) -> Path: - """Resolve a Photos selection id to a locally-available image. - - Prefer the largest preview derivative — always JPEG, present even for - iCloud-only photos, and uniformly sized (Gemma's vision encoder resizes - to ~768 internally so original-resolution gains nothing). Falls back to - edited/original masters only if no derivative exists. - """ - uuid = photo_id.split("/")[0] # strip "/L0/001" suffix - photo = db.get_photo(uuid) - if photo is None: - raise RuntimeError(f"photo not found in library: {uuid}") - for d in photo.path_derivatives or []: - if d and Path(d).exists(): - return Path(d) - for candidate in (photo.path_edited, photo.path): - if candidate and Path(candidate).exists(): - return Path(candidate) - raise RuntimeError( - f"no local image data for {uuid} — derivative may have been purged" - ) - - def set_description(photo_id: str, text: str) -> None: script = f''' tell application "Photos" @@ -239,17 +358,19 @@ def main(): print("--no-caption + --no-keywords → inget att göra.") return - ids = get_selection_ids() - if not ids: + items = get_selection_items() + if not items: print("Inget valt i Photos. Markera bilder och kör igen.") return - print(f"Bearbetar {len(ids)} bild(er){' (dry-run)' if args.dry_run else ''}…\n") + print(f"Bearbetar {len(items)} bild(er){' (dry-run)' if args.dry_run else ''}…\n") print("Läser Photos-bibliotek…", end=" ", flush=True) import osxphotos db = osxphotos.PhotosDB() print(f"{len(db.photos())} bilder.") + paths = resolve_preview_paths(items, db) + print("Laddar Gemma…", end=" ", flush=True) from mlx_vlm import load from mlx_vlm.utils import load_config @@ -257,11 +378,16 @@ def main(): config = load_config(REPO) print("klart.\n") - for i, pid in enumerate(ids, 1): + for i, item in enumerate(items, 1): + pid = item["id"] try: - img = find_local_path(db, pid) + img = paths.get(item["selection_index"]) + if img is None: + print(f" [{i}/{len(items)}] ✗ Ingen bildfil hittades") + continue + context = None - if not args.no_context: + if not args.no_context and pid: photo = db.get_photo(pid.split("/")[0]) if photo: context = build_context_block(photo) @@ -270,20 +396,23 @@ def main(): context=context, explicit_context=args.explicit_context, ) - lines = [f" [{i}/{len(ids)}]"] + lines = [f" [{i}/{len(items)}]"] + if not pid: + lines.append("(saknar id i Photos — analyserar men kan ej " + "skriva tillbaka caption/nyckelord)") if not args.no_caption and caption: - if not args.dry_run: + if not args.dry_run and pid: set_description(pid, caption) lines.append(f"caption: {caption}") if not args.no_keywords and keywords: - if not args.dry_run: + if not args.dry_run and pid: set_keywords(pid, keywords, merge=not args.replace_keywords) lines.append(f"keywords: {', '.join(keywords)}") - if len(lines) == 1: + if not caption and not keywords: lines.append("(modellen svarade utan caption/keywords)") print("\n ".join(lines)) except Exception as e: - print(f" [{i}/{len(ids)}] ✗ {e}") + print(f" [{i}/{len(items)}] ✗ {e}") print("\nKlart.")