From 8f2010d5a81120bd45a12891d870872177ec977a Mon Sep 17 00:00:00 2001 From: schmeeve Date: Thu, 28 May 2026 11:16:48 -0700 Subject: [PATCH] rename AI snaps --- rename-ai-snaps | 450 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 450 insertions(+) create mode 100755 rename-ai-snaps diff --git a/rename-ai-snaps b/rename-ai-snaps new file mode 100755 index 0000000..e4b9768 --- /dev/null +++ b/rename-ai-snaps @@ -0,0 +1,450 @@ +#!/usr/bin/env python3 +""" +rename-ai-snaps — Scan PNGs for AI prompt metadata and propose descriptive filenames. + +Usage: + ./rename-ai-snaps [path] [--no-interactive] + +Scans directory (default ~/Pictures) for ComfyUI PNGs, reads their embedded prompt, +extracts short keyword descriptions, and interactively renames them to: + schmeeve-AI-{keywords}.png +""" + +import json +import os +import re +import sys +import time +from pathlib import Path + +try: + from PIL import Image +except ImportError: + print("Error: Pillow (PIL) is required. Install with: pip install Pillow") + sys.exit(1) + +# ── stopwords and filter sets ────────────────────────────────────────────── + +QUALITY_TAGS = { + "score_6_up", "score_7_up", "score_8_up", "score_9", + "score_6", "score_7", "score_8", + "masterpiece", "best quality", "good quality", "normal quality", + "high quality", "highly detailed", "very detailed", "extreme detail", + "very_aesthetic", "absurdres", "8k", "4k", + "photorealistic", "photograph", + "depth of field", "solo focus", "cinematic", + "newest", "amazing", "stunning", +} + +QUALITY_WORDS = { + "best", "good", "high", "top", "ultra", "super", + "mega", "hyper", "extreme", "extra", "ultimate", +} + +TECHNICAL_WORDS = { + "detailed", "focus", "quality", "aesthetic", "realistic", + "cinematic", "lighting", "rendering", "shading", "texture", + "newest", "absurdres", +} + +STOP_WORDS = { + "the", "a", "an", "of", "in", "on", "at", "to", "for", "with", + "and", "or", "is", "are", "was", "were", "be", "been", "being", + "have", "has", "had", "do", "does", "did", "will", "would", + "could", "should", "may", "might", "can", "shall", "this", + "that", "these", "those", "it", "its", "by", "from", "as", + "into", "through", "during", "before", "after", "above", "below", + "between", "out", "off", "over", "under", "again", "further", + "then", "once", "here", "there", "when", "where", "why", "how", + "all", "each", "every", "both", "few", "more", "most", "other", + "some", "such", "no", "nor", "not", "only", "own", "same", "so", + "than", "too", "very", "just", "about", "up", "down", + "make", "get", "set", "put", "take", "give", "show", "use", + "like", "look", "see", "want", "need", "let", "close", "full", + "add", "new", "one", "two", "five", + "also", "well", "back", "still", "even", "much", + "you", "your", "my", "me", "we", "our", "they", "them", "their", +} + +GENERIC_WORDS = { + "man", "men", "guy", "guys", "boy", "boys", "woman", "women", + "girl", "girls", "people", "person", "human", "figure", + "photo", "image", "picture", "shot", "view", "pose", "posing", + "face", "head", "body", "skin", "hair", "eyes", "hand", "hands", + "dark", "light", "bright", "color", "colour", +} + +NEGATIVE_INDICATORS = { + "deformed", "distorted", "disfigured", "poorly drawn", "bad anatomy", + "extra digits", "missing digits", "extra limbs", "missing limbs", + "ugly", "tiling", "low quality", "worst quality", "normal quality", + "lowres", "monochrome", "grayscale", "text", "watermark", + "branding", "border", "cropped", "signature", "username", + "error", "mutation", "mutated", "out of frame", "duplicate", "cloned", + "body out of frame", "bad hands", "bad face", "blurry", +} + + + +def spinner(): + chars = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" + i = 0 + while True: + yield chars[i % len(chars)] + i += 1 + + +def is_negative_text(text): + """Heuristic: is this text block a negative prompt?""" + lower = text.lower() + score = 0 + for ind in NEGATIVE_INDICATORS: + if ind in lower: + score += 1 + return score >= 2 + + +def is_quality_only(text): + """Heuristic: does this text block contain only quality/technical tags?""" + lower = text.lower() + # Split into words, strip weighting syntax + words = re.findall(r"[a-z_]+", lower) + if not words: + return False + meaningful = sum(1 for w in words if w not in QUALITY_TAGS and len(w) > 2) + return meaningful == 0 + + +def extract_prompts(filepath): + """Return (positive_prompt, negative_prompt) from a ComfyUI PNG.""" + try: + img = Image.open(filepath) + except Exception: + return None, None + + if "prompt" not in img.info: + return None, None + + try: + data = json.loads(img.info["prompt"]) + except (json.JSONDecodeError, TypeError): + return None, None + + # Gather all text fields from all nodes + candidates = [] + for node in data.values(): + inputs = node.get("inputs", {}) + # Check all common text-carrying fields + for field in ("text", "prompt", "positive", "negative", "value", "string"): + val = inputs.get(field, "") + if isinstance(val, str) and len(val.strip()) > 3: + candidates.append(val.strip()) + + # Second pass: resolve node references (e.g. ["node_id", 0]) + for node in data.values(): + inputs = node.get("inputs", {}) + for field in ("text", "prompt", "positive", "negative", "value", "string"): + val = inputs.get(field) + if isinstance(val, list) and len(val) == 2 and isinstance(val[0], str): + ref_node = data.get(val[0], {}) + # Check if the referenced node has a 'value' or 'text' field + ref_inputs = ref_node.get("inputs", {}) + for rf in ("value", "text", "string"): + rv = ref_inputs.get(rf, "") + if isinstance(rv, str) and len(rv.strip()) > 3: + candidates.append(rv.strip()) + break + + if not candidates: + return None, None + + # Split into positive vs negative + positives = [c for c in candidates if not is_negative_text(c)] + negatives = [c for c in candidates if is_negative_text(c)] + + # Further filter: quality-only texts are not useful for naming + positives = [c for c in positives if not is_quality_only(c)] + + pos = max(positives, key=len) if positives else None + neg = max(negatives, key=len) if negatives else None + return pos, neg + + +def extract_keywords(text, max_chars=40): + """Generate a short dash-separated keyword description from prompt text.""" + if not text: + return None + + text_lower = text.lower() + + # Strip weighting/parenthetical syntax: (word:1.2), [word], {word}, (word) + cleaned = re.sub(r"[\[\(\{][^\]\)\}]*[\]\)\}]", "", text_lower) + + # Split on commas, periods, semicolons, exclamation/question marks + segments = re.split(r"[,.;:!?]+", cleaned) + + # Collect meaningful keywords, preserving order + seen = set() + keywords = [] + for seg in segments: + seg = seg.strip() + if not seg or len(seg) < 4: + continue + + # Extract individual words from segment + words = re.findall(r"[a-zA-Z_]+", seg) + good = [] + for w in words: + wl = w.lower().strip("_") + # Skip short words, stop words, quality tags, negative indicators + if len(wl) < 3: + continue + if wl in STOP_WORDS or wl in GENERIC_WORDS: + continue + if wl in QUALITY_TAGS or wl in QUALITY_WORDS: + continue + if wl in TECHNICAL_WORDS: + continue + if wl in NEGATIVE_INDICATORS: + continue + if wl.startswith("score") or wl.startswith("step"): + continue + if wl.isdigit(): + continue + good.append(wl) + + if good: + # Take up to 2 unseen keywords from this segment + taken = 0 + for g in good: + if g not in seen and taken < 2: + keywords.append(g) + seen.add(g) + taken += 1 + + if not keywords: + return None + + # Build description: up to 5 keywords + desc = "-".join(keywords[:5]) + + # Replace any non-alphanumeric (except hyphen) with hyphens + desc = re.sub(r"[^a-z0-9-]", "-", desc) + desc = re.sub(r"-+", "-", desc).strip("-") + + # Truncate at max_chars, breaking at a word boundary + if len(desc) > max_chars: + desc = desc[:max_chars].rstrip("-") + if max_chars > 10 and "-" in desc: + truncated = "-".join(desc.split("-")[:-1]) + if truncated and len(truncated) > 10: + desc = truncated + + return desc if desc and len(desc) > 3 else None + + +def propose_name(filepath): + """Propose 'schmeeve-AI-{keywords}.png' or None.""" + pos, neg = extract_prompts(filepath) + source = pos or neg + if not source: + return None + + desc = extract_keywords(source) + if not desc: + return None + + return f"schmeeve-AI-{desc}.png" + + +# ── main ─────────────────────────────────────────────────────────────────── + +def main(): + import argparse + + parser = argparse.ArgumentParser( + description="Rename AI-generated PNGs based on embedded prompt metadata.", + ) + parser.add_argument( + "path", nargs="?", default=os.path.expanduser("~/Pictures"), + help="Directory to scan for PNG files (default: ~/Pictures)", + ) + parser.add_argument( + "-n", "--no-interactive", action="store_true", + help="Auto-rename without prompting", + ) + args = parser.parse_args() + + scan_dir = Path(args.path).expanduser().resolve() + if not scan_dir.is_dir(): + print(f"Error: {scan_dir} is not a directory") + sys.exit(1) + + pngs = sorted(scan_dir.glob("*.png")) + + # ── Phase 1: Analyze with spinner ── + sys.stdout.write(" Analyzing PNGs") + sys.stdout.flush() + spin = spinner() + raw_proposals = {} + for p in pngs: + sys.stdout.write(f"\r {next(spin)} Analyzing PNGs") + sys.stdout.flush() + # Skip already-renamed files + if p.name.startswith("schmeeve-AI-"): + continue + name = propose_name(str(p)) + if name: + raw_proposals[p] = name + time.sleep(0.02) + + # Clear the spinner line + sys.stdout.write("\r" + " " * 60 + "\r") + sys.stdout.flush() + + # Deduplicate proposed names + proposals = {} + name_counts = {} + for p, name in raw_proposals.items(): + base = name + if base in name_counts: + name_counts[base] += 1 + stem = base.rsplit(".", 1)[0] + ext = ".png" + name = f"{stem}_{name_counts[base]}{ext}" + else: + name_counts[base] = 0 + proposals[p] = name + + # ── Phase 2: Display proposed names ── + if not proposals: + print(" No renamable PNGs found.") + return + + for i, (old_path, new_name) in enumerate(proposals.items(), 1): + old = old_path.name + stem = old_path.stem + ext = old_path.suffix + # Truncate old name for display + old_display = old if len(old) < 50 else old[:22] + "…" + old[-25:] + print(f" {i:>3}. {old_display}") + print(f" → {new_name}") + + print() + print(f" {len(proposals)} file(s) to rename.\n") + + # ── Phase 3: Rename (interactive or auto) ── + if args.no_interactive: + renamed = 0 + for old_path, new_name in proposals.items(): + new_path = old_path.with_name(new_name) + if new_path.exists(): + stem = new_path.stem + counter = 1 + while new_path.exists(): + new_path = old_path.with_name(f"{stem}_{counter}{old_path.suffix}") + counter += 1 + old_path.rename(new_path) + renamed += 1 + print(f" Renamed {renamed} file(s).") + else: + renamed = 0 + skipped = 0 + items = list(proposals.items()) + i = 0 + while i < len(items): + old_path, new_name = items[i] + old = old_path.name + new = new_name + + print(f"\n [{i+1}/{len(items)}]") + print(f" Current: {old}") + print(f" New: {new}") + remaining = len(items) - i - 1 + rlabel = f"rename all {remaining}" if remaining else "" + sys.stdout.write(f" [Enter]=rename [e]=edit [s]=skip{' [a]=' + rlabel if rlabel else ''} [q]=quit: ") + sys.stdout.flush() + choice = sys.stdin.readline().strip().lower() + + if choice == "q": + remaining = len(items) - i - 1 + if remaining: + print(f" Skipping remaining {remaining} file(s).") + break + elif choice == "s": + skipped += 1 + i += 1 + continue + elif choice == "e": + sys.stdout.write(f" Edit name (will be prefixed 'schmeeve-AI-'): ") + sys.stdout.flush() + custom = sys.stdin.readline().strip() + if custom: + # Sanitize + custom_desc = re.sub(r"[^a-z0-9-]", "-", custom.lower()) + custom_desc = re.sub(r"-+", "-", custom_desc).strip("-") + if custom_desc: + new = f"schmeeve-AI-{custom_desc}.png" + else: + print(" Invalid name, skipping.") + skipped += 1 + i += 1 + continue + else: + # empty = skip + skipped += 1 + i += 1 + continue + # Fall through to rename + elif choice == "": + pass # rename with proposed name + elif choice == "a": + # Rename current and all remaining without further prompts + for j in range(i, len(items)): + p, n = items[j] + np = p.with_name(n) + if np.exists(): + stem = np.stem + counter = 1 + while np.exists(): + np = p.with_name(f"{stem}_{counter}{p.suffix}") + counter += 1 + p.rename(np) + renamed += len(items) - i + break + else: + print(f" Unknown option '{choice}', skipping.") + skipped += 1 + i += 1 + continue + + # Perform rename + new_path = old_path.with_name(new) + if new_path.exists(): + stem = new_path.stem + counter = 1 + while new_path.exists(): + new_path = old_path.with_name(f"{stem}_{counter}{old_path.suffix}") + counter += 1 + print(f" (file existed, saved as {new_path.name})") + old_path.rename(new_path) + renamed += 1 + i += 1 + + print(f"\n Renamed: {renamed} Skipped: {skipped}") + + # One more pass: also look at .jpg? (maybe later) + remaining_ai = 0 + for f in scan_dir.glob("*.jpg"): + try: + img = Image.open(f) + if "prompt" in img.info: + remaining_ai += 1 + except Exception: + pass + if remaining_ai: + print(f" Note: {remaining_ai} JPEG(s) with AI metadata found (not yet supported).") + + +if __name__ == "__main__": + main()