diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..db686c5 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1767313136, + "narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..e4463b7 --- /dev/null +++ b/flake.nix @@ -0,0 +1,45 @@ +{ + description = "Wallpaper renamer: auto-rename images to descriptive snake_case names using a local vision model (Ollama + llava)."; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { inherit system; }; + + # The rename command. It starts an Ollama server (if needed) and pulls the + # vision model on first run, so everything lives inside this shell. + rename-wallpapers = pkgs.writeScriptBin "rename-wallpapers" + (builtins.readFile ./rename-wallpapers.py); + in + { + packages.default = rename-wallpapers; + + devShells.default = pkgs.mkShell { + name = "wallpaper-renamer"; + packages = [ + pkgs.ollama + pkgs.python3 + pkgs.curl + pkgs.ffmpeg + pkgs.fzf + pkgs.chafa + rename-wallpapers + ]; + + shellHook = '' + echo "wallpaper-renamer shell ready." + echo "Usage examples:" + echo " rename-wallpapers # rename all images in this dir" + echo " rename-wallpapers --pick # interactively choose with fzf" + echo " rename-wallpapers --dir ~/Pics # scan another directory" + echo " rename-wallpapers --dry-run *.jpg # preview without renaming" + echo "Model is pulled on first run (one-time download)." + ''; + }; + }); +} diff --git a/rename-wallpapers.py b/rename-wallpapers.py new file mode 100644 index 0000000..0bd71e1 --- /dev/null +++ b/rename-wallpapers.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +"""Rename image files to descriptive snake_case names using a local vision model. + +The model (default: llava) is queried through a running Ollama server. This +script ensures the server is up and the model is pulled before processing. +""" +import argparse +import base64 +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import time + +IMG_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp"} +PROMPT = ( + "You are naming an image file. Look at the image and respond with ONLY a " + "filename, nothing else. Rules: all lowercase; snake_case (underscores only, " + "no spaces, hyphens, or special characters); start with 'a_' or 'an_'; describe " + "the main subject, its state, and the background; 5 to 10 words; end with the " + "correct extension (.jpg/.png/.jpeg). Example: a_snowy_mountain_with_grey_sky.jpg" +) + + +def run(cmd, **kw): + return subprocess.run(cmd, capture_output=True, text=True, **kw) + + +def ollama_ready(): + r = run(["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", + "--max-time", "3", "http://localhost:11434/api/tags"]) + return r.returncode == 0 and r.stdout.strip() == "200" + + +def ensure_server(timeout=90): + if ollama_ready(): + return + print("Starting ollama server...", flush=True) + proc = subprocess.Popen(["ollama", "serve"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + deadline = time.time() + timeout + while time.time() < deadline: + if ollama_ready(): + return + time.sleep(1) + raise SystemExit("ollama server did not become ready in time") + + +def ensure_model(model, timeout=600): + listing = run(["ollama", "list"], timeout=30) + have = set() + for line in listing.stdout.splitlines()[1:]: + have.add(line.split()[0].replace(":latest", "")) + if model.replace(":latest", "") in have: + return + print(f"Pulling model '{model}' (one-time download)...", flush=True) + r = run(["ollama", "pull", model], timeout=timeout) + if r.returncode != 0: + raise SystemExit("failed to pull model:\n" + r.stderr) + + +def resize(path): + out = tempfile.mktemp(suffix=".jpg") + run(["ffmpeg", "-y", "-loglevel", "error", "-i", path, + "-vf", "scale='min(768,iw)':'min(768,ih)'", "-q:v", "4", out], + timeout=120) + if not os.path.exists(out): + # fall back to the original file if ffmpeg chokes + return path + return out + + +def describe(path, model): + r = resize(path) + with open(r, "rb") as f: + b64 = base64.b64encode(f.read()).decode() + payload = { + "model": model, + "messages": [{"role": "user", "content": PROMPT, "images": [b64]}], + "stream": False, + "options": {"temperature": 0.2}, + } + pf = tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) + json.dump(payload, pf) + pf.close() + last = None + for _ in range(3): + try: + p = run(["curl", "-s", "http://localhost:11434/api/chat", + "-H", "Content-Type: application/json", "-d", f"@{pf.name}"], + timeout=240) + return json.loads(p.stdout)["message"]["content"].strip() + except Exception as e: # noqa: BLE001 + last = e + raise last + + +def sanitize(model_out, ext): + m = re.search(r"[a-z0-9_]+\.(?:jpg|jpeg|png|gif|webp)", model_out.lower()) + name = m.group(0) if m else re.sub(r"[^a-z0-9_]", "_", model_out.lower()) + stem, _ = os.path.splitext(name) + stem = re.sub(r"[^a-z0-9_]", "_", stem) + stem = re.sub(r"_+", "_", stem).strip("_") + if not (stem.startswith("a_") or stem.startswith("an_")): + stem = "a_" + stem + return stem + ext.lower() + + +def already_named(fn): + s = fn.lower() + return s.startswith("a_") or s.startswith("an_") or s == "retro_mountain_red.jpg" + + +def find_images(directory, recursive): + found = [] + walker = os.walk(directory) if recursive else [(directory, [], os.listdir(directory))] + for root, _, files in walker: + for fn in files: + if os.path.splitext(fn)[1].lower() in IMG_EXTS: + found.append(os.path.join(root, fn)) + return sorted(found) + + +def pick_with_fzf(paths): + # Feed full paths but display only the basename (--with-nth); fzf still + # outputs the full path, and the preview renders an image thumbnail. + by_full = {p: p for p in paths} + by_base = {os.path.basename(p): p for p in paths} + preview_cmd = os.environ.get( + "RENAME_PREVIEW_CMD", + 'chafa --size 50x32 --color-space rgb {}') + r = run(["fzf", "--multi", "--prompt", "pick images to rename> ", + "--delimiter", "/", "--with-nth", "-1", + "--preview", preview_cmd, + "--preview-window", "right:50%:wrap", + "--border", "--height", "100%"], + input="\n".join(sorted(paths)), timeout=None) + if r.returncode != 0: + return [] + chosen = [line for line in r.stdout.splitlines() if line] + return [by_full.get(c, by_base.get(os.path.basename(c), c)) for c in chosen] + + +def process(path, model, force, dry_run): + fn = os.path.basename(path) + ext = os.path.splitext(fn)[1].lower() + if not force and already_named(fn): + print(f"SKIP {fn}", flush=True) + return None + try: + raw = describe(path, model) + new = sanitize(raw, ext) + except Exception as e: # noqa: BLE001 + print(f"FAIL {fn}: {e}", flush=True) + return None + target = path if force else os.path.join(os.path.dirname(path), new) + i = 1 + while os.path.exists(target) and os.path.abspath(target) != os.path.abspath(path): + target = os.path.join(os.path.dirname(path), + f"{os.path.splitext(new)[0]}_{i}{ext}") + i += 1 + base = os.path.basename(target) + if dry_run: + print(f"{fn} => {base} (raw: {raw})", flush=True) + else: + shutil.move(path, target) + print(f"{fn} => {base}", flush=True) + return base + + +def main(): + ap = argparse.ArgumentParser( + description="Rename images to descriptive snake_case names via a local vision model.") + ap.add_argument("files", nargs="*", help="image files to rename") + ap.add_argument("--dir", default=None, help="directory to scan (default: current dir)") + ap.add_argument("--recursive", action="store_true", help="scan directories recursively") + ap.add_argument("--pick", action="store_true", help="interactively pick with fzf") + ap.add_argument("--model", default=os.environ.get("RENAME_MODEL", "llava"), + help="ollama vision model (default: llava)") + ap.add_argument("--force", action="store_true", help="rename even already-named files") + ap.add_argument("--dry-run", action="store_true", help="print names without renaming") + args = ap.parse_args() + + ensure_server() + ensure_model(args.model) + + paths = list(args.files) + if args.dir: + paths += find_images(args.dir, args.recursive) + if not paths: + paths = find_images(args.dir or ".", args.recursive) + # normalise and dedupe + paths = sorted({os.path.abspath(p) for p in paths}) + + if args.pick: + paths = pick_with_fzf(paths) + if not paths: + print("Nothing selected.", flush=True) + return + + if not paths: + print("No images found.", flush=True) + return + + done = 0 + for p in paths: + if process(p, args.model, args.force, args.dry_run): + done += 1 + print(f"\nDone. {done} image(s) renamed" + + (" (dry-run)" if args.dry_run else "") + ".", flush=True) + + +if __name__ == "__main__": + main()