diff --git a/README.md b/README.md index 48cf7cc..733081a 100644 --- a/README.md +++ b/README.md @@ -41,9 +41,12 @@ keyword behaviour merges with existing tags (no duplicates, nothing lost). ```bash git clone https://github.com/fltman/gemma4-mac.git cd gemma4-mac -./install.sh +./install.command ``` +…or just **double-click `install.command` in Finder**. The `.command` +extension makes the same script Finder-runnable; no terminal required. + The installer: 1. Verifies Apple Silicon + Python ≥ 3.10 @@ -77,7 +80,7 @@ In interactive mode: `/reset` clears history, `Ctrl-D` exits. 1. Open Photos.app 2. Select one or more photos (Cmd-click for multi-select) -3. Run: +3. Either **double-click `gemma-photos.command`** or run from a terminal: ```bash gemma-photos --dry-run # see what it would write, without touching anything @@ -85,12 +88,17 @@ gemma-photos # set caption + merge keywords gemma-photos --no-caption # only keywords gemma-photos --no-keywords # only caption gemma-photos --replace-keywords # overwrite existing keywords (default merges) +gemma-photos --replace-caption # ignore the existing caption (default uses it as a hint) gemma-photos --explicit-context # weave date, place, and named people into the caption gemma-photos --no-context # ignore Photos metadata entirely gemma-photos --style "poetic, two-line haiku" gemma-photos --prompt "FULL CUSTOM PROMPT — must still emit CAPTION: and KEYWORDS: lines" ``` +By default the photo's existing caption is fed to the model as a hint — +useful when humans got a detail right that the vision model gets wrong +(e.g. specific car brands). Use `--replace-caption` for a clean re-pass. + ## `gemma-yearbook` — auto-curate a year in photos Picks a balanced, deduplicated selection of photos from a date range and @@ -98,6 +106,9 @@ creates a new album in Photos.app. The selection uses **Apple's own per-photo aesthetic scores** (the same ones that drive the "Memories" feature, read via osxphotos) for ranking — no extra ML pass needed. +Either **double-click `gemma-yearbook.command`** (uses defaults — 100 picks +from the current year) or run from a terminal: + ```bash gemma-yearbook --year 2024 # default: 100 photos, album "Yearbook 2024" gemma-yearbook --year 2024 --count 50 --album "Best of '24" @@ -226,12 +237,17 @@ keeping it stable is safer. `gemma-photos` reads the **local preview derivative** straight from the Photos library (via [osxphotos](https://github.com/RhetTbull/osxphotos)) -rather than asking Photos.app to export the original. Two reasons: +rather than asking Photos.app to export the original. Three reasons: 1. **iCloud-only photos work.** With *Optimise Mac Storage* enabled, most originals live in iCloud and aren't on disk — but the previews are. So we can analyse cloud-only items without forcing slow downloads. -2. **Originals add no value here.** Gemma's vision encoder resizes to ~768px +2. **iMessage attachments work.** Photos shared via iMessage live as their + own AppleScript class (`«IPmi»`) and refuse to export through the normal + API. Their preview derivatives, however, sit in + `Photos Library/scopes/syndication/resources/derivatives/` and load fine + — so they're handled the same way as any other photo. +3. **Originals add no value here.** Gemma's vision encoder resizes to ~768px internally, so the difference between a 4032×3024 HEIC original and an 1080×1920 preview vanishes after preprocessing. @@ -271,8 +287,10 @@ quality, more RAM) or `mlx-community/gemma-4-e2b-it-4bit` (smaller, faster). ## Uninstall +Double-click `uninstall.command` in Finder, or: + ```bash -./uninstall.sh +./uninstall.command ``` Removes the alias block from `~/.zshrc` and (with confirmation) the local diff --git a/gemma-photos.command b/gemma-photos.command new file mode 100755 index 0000000..dcf9858 --- /dev/null +++ b/gemma-photos.command @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Double-clickable launcher for gemma-photos. +# Works from Finder (double-click) and from a terminal. +# Expects install.command to have created bin/gemma-photos beforehand. + +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WRAPPER="$REPO_DIR/bin/gemma-photos" + +if [[ ! -x "$WRAPPER" ]]; then + echo "Hittar inte $WRAPPER — kör install.command först." + exit 1 +fi + +cd "$REPO_DIR" +"$WRAPPER" "$@" diff --git a/gemma-yearbook.command b/gemma-yearbook.command new file mode 100755 index 0000000..272cb93 --- /dev/null +++ b/gemma-yearbook.command @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Double-clickable launcher for gemma-yearbook. +# Works from Finder (double-click) and from a terminal. +# Expects install.command to have created bin/gemma-yearbook beforehand. + +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WRAPPER="$REPO_DIR/bin/gemma-yearbook" + +if [[ ! -x "$WRAPPER" ]]; then + echo "Hittar inte $WRAPPER — kör install.command först." + exit 1 +fi + +cd "$REPO_DIR" +"$WRAPPER" "$@" diff --git a/install.sh b/install.command similarity index 96% rename from install.sh rename to install.command index 6dc9764..8745e6b 100755 --- a/install.sh +++ b/install.command @@ -104,4 +104,6 @@ echo " ${BOLD}gemma${RESET} 'hej, vem är du?'" echo " ${BOLD}gemma${RESET} -i path/to/photo.jpg 'beskriv vad du ser'" echo " ${BOLD}gemma-photos${RESET} --dry-run # after selecting photos in Photos.app" echo +echo "Or double-click ${BOLD}gemma-photos.command${RESET} / ${BOLD}gemma-yearbook.command${RESET} in Finder." +echo echo "First run will download the model (~3.5 GB) from Hugging Face." diff --git a/photos_caption.py b/photos_caption.py index 73e6b43..9e21e74 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" @@ -179,26 +298,34 @@ def parse_response(text: str) -> tuple[str, list[str]]: def analyze_image( model, processor, config, image_path: Path, prompt: str, context: str | None = None, explicit_context: bool = False, + existing_caption: str | None = None, ) -> tuple[str, list[str]]: from mlx_vlm import generate from mlx_vlm.prompt_utils import apply_chat_template + parts = [] if context and explicit_context: - full_prompt = ( + parts.append( "Kontext för bilden:\n" f"{context}\n\n" "Väv in plats, datum (eller månad/år) och eventuella personnamn " "från kontexten naturligt i CAPTION. Använd ENDAST de namn som " - "är listade ovan — beskriv andra personer anonymt.\n\n" - f"{prompt}" + "är listade ovan — beskriv andra personer anonymt." ) elif context: - full_prompt = ( + parts.append( "Kontext för bilden (använd endast om relevant för det du ser):\n" - f"{context}\n\n{prompt}" + f"{context}" ) - else: - full_prompt = prompt + if existing_caption: + parts.append( + "Befintlig caption (använd som vägledning vid tveksamma detaljer " + "— t.ex. specifika bilmärken, platser eller namn som är svåra att " + "se i bilden — men korrigera tydliga fel):\n" + f"{existing_caption}" + ) + parts.append(prompt) + full_prompt = "\n\n".join(parts) formatted = apply_chat_template(processor, config, full_prompt, num_images=1) result = generate( model, processor, formatted, @@ -219,6 +346,10 @@ def main(): help="Skip keywords (only set caption)") ap.add_argument("--replace-keywords", action="store_true", help="Overwrite existing keywords (default: merge)") + ap.add_argument("--replace-caption", action="store_true", + help="Ignore the existing caption when generating a new " + "one. Default: pass it to the model as a hint to " + "help disambiguate details (e.g. specific car brands).") ap.add_argument("--style", default=DEFAULT_STYLE, help="Tone/style for the caption (e.g. 'poetisk, två rader')") ap.add_argument("--prompt", default=None, @@ -239,17 +370,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,33 +390,44 @@ 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 + + photo = db.get_photo(pid.split("/")[0]) if pid else None context = None - if not args.no_context: - photo = db.get_photo(pid.split("/")[0]) - if photo: - context = build_context_block(photo) + if not args.no_context and photo: + context = build_context_block(photo) + existing_caption = None + if not args.replace_caption and photo and photo.description: + existing_caption = photo.description.strip() or None caption, keywords = analyze_image( model, processor, config, img, prompt, context=context, explicit_context=args.explicit_context, + existing_caption=existing_caption, ) - 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.") diff --git a/uninstall.sh b/uninstall.command similarity index 100% rename from uninstall.sh rename to uninstall.command