From da5fdc3e4fbe3dac9315bee2385a0ab976a2b898 Mon Sep 17 00:00:00 2001 From: moosecrap Date: Thu, 23 Jul 2026 00:06:23 -0700 Subject: [PATCH] Image preview --- tools/__init__.py | 15 +++++ tools/preview_image.py | 150 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 tools/preview_image.py diff --git a/tools/__init__.py b/tools/__init__.py index 94846c2..79005cb 100644 --- a/tools/__init__.py +++ b/tools/__init__.py @@ -20,6 +20,7 @@ from .read_image import handle as read_image_handler from .read_metadata import handle as read_png_metadata_handler from .contact_sheet import handle as contact_sheet_handler from .list_directory import handle as list_directory_details_handler +from .preview_image import handle as preview_image_handler # Central registry of all available tools TOOL_REGISTRY = [ @@ -72,4 +73,18 @@ TOOL_REGISTRY = [ }, handler=list_directory_details_handler ), + Tool( + name="preview_image", + description="Generates small thumbnails (~70 tokens) and detailed info for images. Can take file paths, or indices/ranges from a contact sheet.", + schema={ + "type": "object", + "properties": { + "path": {"type": "string", "description": "Path to an image file or a directory containing images"}, + "indices": {"type": "string", "description": "1-based indices or ranges (e.g., '1, 3-5, 10') to preview from the directory. Required if path is a directory."}, + "sort_by": {"type": "string", "description": "Sort order to resolve indices: 'mtime' (default), 'name', or 'size'."}, + }, + "required": ["path"], + }, + handler=preview_image_handler + ), ] diff --git a/tools/preview_image.py b/tools/preview_image.py new file mode 100644 index 0000000..64404e2 --- /dev/null +++ b/tools/preview_image.py @@ -0,0 +1,150 @@ +import math +import base64 +import logging +import io +import datetime +from pathlib import Path +from typing import Any, Dict, List, Tuple +from PIL import Image +from tools.utils import format_relative_time, ToolError, get_file_info_list, sort_file_list + +logger = logging.getLogger("MattCP") + +def parse_indices(indices_str: str) -> List[int]: + """ + Parses a string like '11, 31-33, 44' into a list of 1-based indices. + Preserves the order specified in the string. + """ + indices = [] + parts = [p.strip() for p in indices_str.split(',')] + for part in parts: + if not part: + continue + if '-' in part: + try: + start, end = map(int, part.split('-')) + # Expand range in order. Handle reverse ranges as well. + step = 1 if start <= end else -1 + for i in range(start, end + step, step): + indices.append(i) + except ValueError: + raise ToolError(f"Invalid range format: {part}") + else: + try: + indices.append(int(part)) + except ValueError: + raise ToolError(f"Invalid index format: {part}") + return indices + +def calculate_patch_dimensions(orig_w: int, orig_h: int, target_tokens: int = 70): + """ + Calculates optimal pixel dimensions to hit a token budget of at least target_tokens. + The algorithm tries anchoring both Width and Height to find the smallest patch + count that satisfies the target while preserving aspect ratio. + """ + ar = orig_w / orig_h + + # Case A: Width is the anchor (non-padded) + w_anchor = 1 + while True: + w = w_anchor * 48 + h = w / ar + hp = math.ceil(h / 48) + if w_anchor * hp >= target_tokens: + res_a = {"w": w, "h": round(h), "tokens": w_anchor * hp} + break + w_anchor += 1 + + # Case B: Height is the anchor (non-padded) + h_anchor = 1 + while True: + h = h_anchor * 48 + w = h * ar + wp = math.ceil(w / 48) + if h_anchor * wp >= target_tokens: + res_b = {"w": round(w), "h": h, "tokens": h_anchor * wp} + break + h_anchor += 1 + + # Pick the one with the smaller token count + best = res_a if res_a["tokens"] <= res_b["tokens"] else res_b + return max(1, best["w"]), max(1, best["h"]) + +async def handle(args: Dict[str, Any]): + """ + Generates small thumbnails of images with detailed info. + Can take a single file path, or a directory with indices/ranges from contact_sheet. + Thumbnails are optimized for ~70 token usage. + """ + path_str = args.get("path") + indices_str = args.get("indices") + sort_by = args.get("sort_by", "mtime") + + if not path_str: + raise ToolError("Missing path argument") + + path = Path(path_str) + # Store as (path, index_label) where index_label is None if direct path + target_files: List[Tuple[Path, Any]] = [] + + if path.is_file(): + target_files.append((path, None)) + elif path.is_dir(): + if not indices_str: + raise ToolError("Indices are required when providing a directory path. Example: '1, 5-10'") + + exts = ('.png', '.jpg', '.jpeg', '.webp', '.bmp') + file_info_list = get_file_info_list(path, extensions=exts) + file_info_list = sort_file_list(file_info_list, sort_by) + + indices = parse_indices(indices_str) + for idx in indices: + # contact_sheet uses 1-based indexing + if 1 <= idx <= len(file_info_list): + target_files.append((file_info_list[idx-1]["path"], idx)) + else: + logger.warning(f"Index {idx} out of range for directory {path_str}") + else: + raise ToolError(f"Path {path_str} is neither a file nor a directory.") + + if not target_files: + return {"text": "No valid images found to preview."} + + results = [] + for file_path, index_label in target_files: + try: + stats = file_path.stat() + with Image.open(file_path) as img: + orig_w, orig_h = img.size + target_w, target_h = calculate_patch_dimensions(orig_w, orig_h) + + # Use thumbnail() to preserve aspect ratio and fit within the target bounding box. + thumb = img.convert("RGB") + thumb.thumbnail((target_w, target_h), Image.Resampling.LANCZOS) + + buf = io.BytesIO() + thumb.save(buf, format='JPEG', quality=95) + img_data = base64.b64encode(buf.getvalue()).decode("utf-8") + + # Gather info + mtime_rel = format_relative_time(stats.st_mtime) + mtime_abs = datetime.datetime.fromtimestamp(stats.st_mtime).strftime('%Y-%m-%d %H:%M') + size_kb = stats.st_size / 1024 + + # Prepend index if this image was selected via index/range + idx_prefix = f"[Index: {index_label}] " if index_label is not None else "" + + info_text = ( + f"{idx_prefix}File: {file_path.name}\n" + f"Resolution: {orig_w}x{orig_h}\n" + f"Size: {size_kb:.1f}KB\n" + f"Modified: {mtime_rel} ({mtime_abs})" + ) + + results.append({"type": "text", "text": info_text}) + results.append({"type": "image", "data": img_data, "mimeType": "image/jpeg"}) + + except Exception as e: + results.append({"type": "text", "text": f"Error processing {file_path.name}: {e}"}) + + return results