MooseCP/tools/contact_sheet.py

163 lines
5.5 KiB
Python

import os
import base64
import mimetypes
import logging
import asyncio
import io
import datetime
import time
from typing import Any, Callable, Dict, List, Union
from PIL import Image, ImageDraw, ImageFont
from tools.utils import format_relative_time
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 = args.get("path")
page = int(args.get("page", 1))
sort_by = args.get("sort_by", "mtime")
if not dir_path:
return {"text": "Error: Missing path argument"}
if not os.path.isdir(dir_path):
return {"text": f"Error: {dir_path} is not a directory."}
exts = ('.png', '.jpg', '.jpeg', '.webp', '.bmp')
file_info_list = []
for f in os.listdir(dir_path):
if f.lower().endswith(exts):
full_path = os.path.join(dir_path, f)
try:
stats = os.stat(full_path)
file_info_list.append({
"name": f,
"path": full_path,
"mtime": stats.st_mtime,
"size": stats.st_size
})
except Exception as e:
logger.error(f"Could not stat {f}: {e}")
if not file_info_list:
return {"text": "No supported images found in the directory."}
# Sorting
if sort_by == "mtime":
file_info_list.sort(key=lambda x: x["mtime"], reverse=True)
sort_label = "Date (newest first)"
elif sort_by == "size":
file_info_list.sort(key=lambda x: x["size"], reverse=True)
sort_label = "Size (largest first)"
else:
file_info_list.sort(key=lambda x: x["name"].lower())
sort_label = "Name (alphabetical)"
# Fixed grid dimensions for token optimization
COLS = 10
ROWS = 7
PAGE_SIZE = COLS * ROWS
total_files = len(file_info_list)
start_idx = (page - 1) * PAGE_SIZE
end_idx = start_idx + PAGE_SIZE
paged_files = file_info_list[start_idx:end_idx]
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
font_paths = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
"/usr/share/fonts/truetype/freefont/FreeSansBold.ttf",
]
for path in font_paths:
if os.path.exists(path):
try:
font = ImageFont.truetype(path, 24)
break
except Exception:
continue
if font is None:
font = ImageFont.load_default()
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:
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))
# Index number in lower-left corner
text = str(global_index)
if font:
bbox = draw.textbbox((0, 0), text, font=font)
text_w = bbox[2] - bbox[0]
text_h = bbox[3] - bbox[1]
else:
text_w = len(text) * 8
text_h = 15
# Semi-opaque background rectangle for the number
draw.rectangle([x, y + thumb_size - text_h - 5, x + text_w + 5, y + thumb_size - 2], 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()
canvas.save(buf, format='PNG')
img_data = base64.b64encode(buf.getvalue()).decode("utf-8")
total_pages = (total_files + PAGE_SIZE - 1) // PAGE_SIZE
# Range shown: first and last of the CURRENT PAGE
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}\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/png"}
]