Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
207 changes: 168 additions & 39 deletions photos_caption.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
any keywords you've added manually).
"""
import argparse
import re
import subprocess
from pathlib import Path

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -239,29 +358,36 @@ 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
model, processor = load(REPO)
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)
Expand All @@ -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.")

Expand Down