import base64 import logging import io import datetime 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("MattCP") async def handle(args: Dict[str, Any]): """ Generates a contact sheet of images. Grid: 10 columns x 7 rows (70 images total). Sized for optimal token usage (1120 tokens) on llama.cpp. """ 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)") # Fixed grid dimensions for token optimization COLS = config.CONTACT_SHEET_COLS ROWS = config.CONTACT_SHEET_ROWS PAGE_SIZE = COLS * 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}."} thumb_size = 192 # 192 / 48 = 4 patches per side 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"} ]