Project Files
src / media / imageAnalysis.ts
/**
* @file Image analysis tool — loads local images, resizes with ffmpeg, returns base64 data URI.
*
* Uses ffmpeg (absolute path) to avoid PATH and native binary issues in the LM Studio sandbox.
* Images are aggressively compressed to fit within LLM context windows.
*/
import { tool, type Tool, type ToolsProviderController } from "@lmstudio/sdk";
import { z } from "zod";
import { readFile, stat, rm, mkdir } from "fs/promises";
import { extname, isAbsolute, resolve, join } from "path";
import { tmpdir } from "os";
import { randomBytes } from "crypto";
import { execFile } from "child_process";
import { promisify } from "util";
import { getFfmpegPath, getFfprobePath } from "./ffmpegPath";
const execFileAsync = promisify(execFile);
const SUPPORTED_EXTENSIONS = new Set([
".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tiff", ".tif",
]);
const MAX_BASE64_BYTES = 150_000;
/** Block system-sensitive paths. */
const BLOCKED_PREFIXES = ["/etc", "/var", "/usr", "/System", "/Library", "/private"];
function isSafePath(p: string): boolean {
return !BLOCKED_PREFIXES.some(prefix => p.startsWith(prefix));
}
/**
* Core resize logic — shared between single and batch analysis.
*/
export async function resizeImage(
resolvedPath: string,
maxDim: number,
): Promise<{ dataUri: string; originalWidth: number; originalHeight: number; bytes: number }> {
const ffmpeg = await getFfmpegPath();
const ffprobe = await getFfprobePath();
const tmpDir = join(tmpdir(), `maestro-img-${randomBytes(6).toString("hex")}`);
await mkdir(tmpDir, { recursive: true });
const outputPath = join(tmpDir, "resized.jpg");
try {
let originalWidth = 0, originalHeight = 0;
try {
const { stdout } = await execFileAsync(ffprobe, [
"-v", "quiet", "-print_format", "json",
"-show_streams", "-select_streams", "v:0", resolvedPath,
]);
const stream = JSON.parse(stdout)?.streams?.[0];
originalWidth = stream?.width ?? 0;
originalHeight = stream?.height ?? 0;
} catch {}
await execFileAsync(ffmpeg, [
"-i", resolvedPath,
"-vf", `scale='min(${maxDim},iw)':'min(${maxDim},ih)':force_original_aspect_ratio=decrease`,
"-q:v", "8", "-y", outputPath,
]);
let buf = await readFile(outputPath);
if (buf.byteLength > MAX_BASE64_BYTES) {
const smallerPath = join(tmpDir, "smaller.jpg");
await execFileAsync(ffmpeg, [
"-i", resolvedPath,
"-vf", `scale='min(${Math.round(maxDim * 0.5)},iw)':'min(${Math.round(maxDim * 0.5)},ih)':force_original_aspect_ratio=decrease`,
"-q:v", "10", "-y", smallerPath,
]);
buf = await readFile(smallerPath);
}
return {
dataUri: `data:image/jpeg;base64,${buf.toString("base64")}`,
originalWidth, originalHeight, bytes: buf.byteLength,
};
} finally {
await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
}
export function createImageAnalysisTool(ctl: ToolsProviderController, configMaxDim: number = 384): Tool {
return tool({
name: "analyze_image",
description:
"Load an image from a local file path, resize it to a small thumbnail, and return it as a base64 data URI for visual analysis. " +
"Images are compressed to fit in the context window. " +
"Supports JPEG, PNG, WebP, GIF, BMP, TIFF.",
parameters: {
file_path: z.string().describe("Absolute path to the image file."),
max_dimension: z
.number()
.int()
.min(64)
.max(1280)
.optional()
.describe("Max width or height in pixels. Default: 512."),
},
implementation: async (
{ file_path, max_dimension }: { file_path: string; max_dimension?: number },
{ status },
) => {
const maxDim = Math.min(max_dimension ?? configMaxDim, 1280);
const resolvedPath = isAbsolute(file_path) ? file_path : resolve(file_path);
if (!isSafePath(resolvedPath)) {
return { error: `Access denied: '${resolvedPath}' is in a protected system directory.` };
}
const ext = extname(resolvedPath).toLowerCase();
if (!SUPPORTED_EXTENSIONS.has(ext)) {
return { error: `Unsupported image format '${ext}'. Supported: ${[...SUPPORTED_EXTENSIONS].join(", ")}` };
}
try {
const fileStat = await stat(resolvedPath);
if (!fileStat.isFile()) return { error: `Not a file: ${resolvedPath}` };
} catch {
return { error: `File not found: ${resolvedPath}` };
}
status("Resizing image...");
try {
const result = await resizeImage(resolvedPath, maxDim);
status("Image ready");
return {
file_path: resolvedPath,
original_width: result.originalWidth || undefined,
original_height: result.originalHeight || undefined,
resized_bytes: result.bytes,
max_dimension_used: maxDim,
data_uri: result.dataUri,
};
} catch (err: any) {
return { error: `Failed to process image: ${err?.message || String(err)}`, file_path: resolvedPath };
}
},
});
}
/**
* Batch image analysis — analyzes all images in a directory.
* Returns data URIs for each image (up to a limit) for visual inspection.
*/
export function createBatchImageAnalysisTool(ctl: ToolsProviderController, configMaxDim: number = 512): Tool {
return tool({
name: "analyze_images",
description:
"Analyze ALL images in a directory at once. Returns thumbnails for each image. " +
"Much more efficient than calling analyze_image repeatedly. " +
"Use this when you need to see multiple reference images or catalog a folder.",
parameters: {
directory: z.string().describe("Path to directory containing images."),
max_dimension: z.number().int().min(64).max(1280).optional()
.describe("Max width/height per image. Default: 256 for batch (smaller to fit more)."),
limit: z.number().int().min(1).max(50).optional()
.describe("Max number of images to process. Default: 20."),
},
implementation: async (
{ directory, max_dimension, limit: maxImages }: { directory: string; max_dimension?: number; limit?: number },
{ status },
) => {
const maxDim = Math.min(max_dimension ?? 256, 1280);
const imageLimit = maxImages ?? 20;
const dirPath = isAbsolute(directory) ? directory : resolve(directory);
if (!isSafePath(dirPath)) {
return { error: `Access denied: '${dirPath}' is in a protected system directory.` };
}
let entries: string[];
try {
const { readdir } = await import("fs/promises");
entries = await readdir(dirPath);
} catch {
return { error: `Cannot read directory: ${dirPath}` };
}
const imageFiles = entries
.filter(name => SUPPORTED_EXTENSIONS.has(extname(name).toLowerCase()))
.sort()
.slice(0, imageLimit);
if (imageFiles.length === 0) {
return { error: `No supported images found in: ${dirPath}` };
}
status(`Processing ${imageFiles.length} images...`);
// Process in parallel batches of 5 to avoid overwhelming ffmpeg
const results: Array<{ name: string; data_uri: string; width: number; height: number } | { name: string; error: string }> = [];
for (let i = 0; i < imageFiles.length; i += 5) {
const batch = imageFiles.slice(i, i + 5);
const batchResults = await Promise.all(
batch.map(async (name) => {
try {
const fullPath = join(dirPath, name);
const r = await resizeImage(fullPath, maxDim);
return { name, data_uri: r.dataUri, width: r.originalWidth, height: r.originalHeight };
} catch (err: any) {
return { name, error: err?.message || String(err) };
}
}),
);
results.push(...batchResults);
status(`Processed ${Math.min(i + 5, imageFiles.length)}/${imageFiles.length} images...`);
}
const successful = results.filter(r => "data_uri" in r).length;
return {
directory: dirPath,
total_found: entries.filter(n => SUPPORTED_EXTENSIONS.has(extname(n).toLowerCase())).length,
processed: results.length,
successful,
images: results,
};
},
});
}
/**
* Catalog images — returns metadata + short filenames for all images in a directory.
* Lightweight alternative to analyze_images when you just need the inventory.
*/
export function createCatalogImagesTool(ctl: ToolsProviderController): Tool {
return tool({
name: "catalog_images",
description:
"List all images in a directory with metadata (name, dimensions, size). " +
"Use this FIRST to understand available assets before analyzing specific ones. " +
"No image data is returned — just the inventory.",
parameters: {
directory: z.string().describe("Path to directory containing images."),
},
implementation: async (
{ directory }: { directory: string },
{ status },
) => {
const dirPath = isAbsolute(directory) ? directory : resolve(directory);
if (!isSafePath(dirPath)) {
return { error: `Access denied: '${dirPath}'` };
}
let entries: string[];
try {
const { readdir } = await import("fs/promises");
entries = await readdir(dirPath);
} catch {
return { error: `Cannot read directory: ${dirPath}` };
}
const imageFiles = entries
.filter(name => SUPPORTED_EXTENSIONS.has(extname(name).toLowerCase()))
.sort();
if (imageFiles.length === 0) {
return { directory: dirPath, count: 0, images: [] };
}
status(`Cataloging ${imageFiles.length} images...`);
const ffprobe = await getFfprobePath();
const catalog = await Promise.all(
imageFiles.map(async (name) => {
const fullPath = join(dirPath, name);
try {
const fileStat = await stat(fullPath);
let width = 0, height = 0;
try {
const { stdout } = await execFileAsync(ffprobe, [
"-v", "quiet", "-print_format", "json",
"-show_streams", "-select_streams", "v:0", fullPath,
]);
const stream = JSON.parse(stdout)?.streams?.[0];
width = stream?.width ?? 0;
height = stream?.height ?? 0;
} catch {}
return {
name,
width, height,
size_kb: Math.round(fileStat.size / 1024),
ext: extname(name).toLowerCase(),
};
} catch {
return { name, error: "unreadable" };
}
}),
);
return { directory: dirPath, count: catalog.length, images: catalog };
},
});
}