import base64 import logging import io import datetime import math from pathlib import Path from typing import Any, Dict, List from PIL import Image, ImageDraw, ImageFont, ImageOps import config from tools.utils import format_relative_time, ToolError, get_file_info_list, sort_file_list, get_paginated_list logger = logging.getLogger("MooseCP") async def handle(args: Dict[str, Any]): """ Generates a contact sheet of images. Sized for optimal token usage according to config values. """ dir_path_str = args.get("path") page = int(args.get("page", 1)) sort_by = args.get("sort_by", "mtime") if not dir_path_str: raise ToolError("Missing path argument") dir_path = Path(dir_path_str) if not dir_path.is_dir(): raise ToolError(f"{dir_path_str} is not a directory.") exts = ('.png', '.jpg', '.jpeg', '.webp', '.bmp') file_info_list = get_file_info_list(dir_path, extensions=exts) if not file_info_list: return {"text": "No supported images found in the directory."} # Sorting file_info_list = sort_file_list(file_info_list, sort_by) sort_labels = { "mtime": "Date (newest first)", "size": "Size (largest first)", "name": "Name (alphabetical)" } sort_label = sort_labels.get(sort_by, "Name (alphabetical)") # Grid dimensions for token optimization (see config.py) MAX_COLS = config.CONTACT_SHEET_COLS MAX_ROWS = config.CONTACT_SHEET_ROWS PAGE_SIZE = MAX_COLS * MAX_ROWS total_files = len(file_info_list) paged_files = get_paginated_list(file_info_list, page, PAGE_SIZE) if not paged_files: return {"text": f"No images found on page {page}."} # Dynamic grid sizing to avoid blank space n_images = len(paged_files) thumb_size = config.CONTACT_SHEET_THUMB_SIZE if n_images <= config.CONTACT_SHEET_ROWS ** 2: # Smallest square-ish grid that fits all cols = math.ceil(math.sqrt(n_images)) rows = math.ceil(n_images / cols) else: # Lock rows, expand columns rows = config.CONTACT_SHEET_ROWS cols = math.ceil(n_images / config.CONTACT_SHEET_ROWS) canvas_w = cols * thumb_size canvas_h = rows * thumb_size canvas = Image.new('RGB', (canvas_w, canvas_h), (30, 30, 30)) draw = ImageDraw.Draw(canvas) # Font loading font = None # Try generic system font names first for font_name in config.SYSTEM_FONT_NAMES: try: font = ImageFont.truetype(font_name, 24) break except Exception: continue # Fallback to absolute paths if font is None: for font_path in config.FALLBACK_FONT_PATHS: try: font = ImageFont.truetype(font_path, 24) break except Exception: continue if font is None: font = ImageFont.load_default() start_idx = (page - 1) * PAGE_SIZE for i, info in enumerate(paged_files): full_path = info["path"] row = i // cols col = i % cols x = col * thumb_size y = row * thumb_size global_index = start_idx + i + 1 try: with Image.open(full_path) as img: # Apply EXIF orientation to fix sideways images img = ImageOps.exif_transpose(img) img.thumbnail((thumb_size, thumb_size)) off_x = (thumb_size - img.width) // 2 off_y = (thumb_size - img.height) // 2 canvas.paste(img, (x + off_x, y + off_y)) text = str(global_index) if font: bbox = draw.textbbox((0, 0), text, font=font) text_w, text_h = bbox[2] - bbox[0], bbox[3] - bbox[1] else: text_w, text_h = len(text) * 8, 15 # Adjusted black box to align perfectly with the bottom of the image (y + thumb_size) draw.rectangle([x, y + thumb_size - text_h - 4, x + text_w + 5, y + thumb_size], fill=(0, 0, 0, 180)) draw.text((x + 2, y + thumb_size - text_h - 7), text, fill=(255, 255, 255), font=font) except Exception as e: logger.error(f"Failed to process {info['name']}: {e}") draw.text((x + 5, y + thumb_size // 2), "Error", fill=(255, 0, 0), font=font) buf = io.BytesIO() # Save as JPEG to save data canvas.save(buf, format='JPEG', quality=config.IMAGE_QUALITY) img_data = base64.b64encode(buf.getvalue()).decode("utf-8") total_pages = (total_files + PAGE_SIZE - 1) // PAGE_SIZE first_on_page = paged_files[0] last_on_page = paged_files[-1] if sort_by == "mtime": first_val = f"{format_relative_time(first_on_page['mtime'])} ({datetime.datetime.fromtimestamp(first_on_page['mtime']).strftime('%Y-%m-%d')})" last_val = f"{format_relative_time(last_on_page['mtime'])} ({datetime.datetime.fromtimestamp(last_on_page['mtime']).strftime('%Y-%m-%d')})" elif sort_by == "size": first_val = f"{first_on_page['size']/1024:.1f}KB" last_val = f"{last_on_page['size']/1024:.1f}KB" else: first_val = first_on_page['name'] last_val = last_on_page['name'] header_text = ( f"Directory: {dir_path_str}\n" f"Page {page} of {total_pages} ({total_files} images total). Sorted by: {sort_label}\n" f"Range shown: {first_val} ... {last_val}" ) return [ {"type": "text", "text": header_text}, {"type": "image", "data": img_data, "mimeType": "image/jpeg"} ]