Image Analyzer
Batch process and describe images from a directory using AI vision capabilities.
Features
- Directory Scanning - Recursively find images in directories
- Vision Analysis - Detailed descriptions using GPT-4o
- Structured Output - Tags, objects, colors, and mood extraction
- Multiple Formats - JSON and Markdown report generation
- Progress Tracking - Real-time processing updates
- Error Handling - Graceful handling of invalid images
Quick Start
bash
export OPENAI_API_KEY=your_key
npm run recipe:image-analyzer -- ./imagesHow It Works
This recipe batch processes images from a directory using GPT-4o's vision capabilities, extracting structured metadata and generating reports.
Output formats:
- JSON report with all metadata
- Markdown gallery with embedded images
Flow:
- Recursively scan directory for image files
- Filter by supported types (JPEG, PNG, GIF, WebP)
- Load each image as a data URL
- Send to GPT-4o Vision API for analysis
- Extract structured data via tool calling
- Compile results into JSON and/or Markdown reports
Usage
bash
# Analyze all images in a directory
npm run recipe:image-analyzer -- ./photos
# Output JSON only
npm run recipe:image-analyzer -- ./images --format json
# Output Markdown only
npm run recipe:image-analyzer -- ./assets --format markdown
# Output both (default)
npm run recipe:image-analyzer -- ./images --format bothOutput Schema
Each analyzed image produces structured data:
typescript
interface ImageAnalysis {
filename: string; // Original filename
path: string; // Full file path
description: string; // 2-3 sentence description
tags: string[]; // 3-7 relevant keywords
objects: string[]; // Visible objects
dominantColors: string[]; // 2-4 main colors
mood: string; // Overall atmosphere
}Example Output
JSON:
json
{
"filename": "sunset.png",
"path": "./images/sunset.png",
"description": "A vibrant sunset over rolling hills with dramatic cloud formations.",
"tags": ["nature", "sunset", "landscape", "sky", "clouds"],
"objects": ["sun", "hills", "clouds", "trees"],
"dominantColors": ["orange", "purple", "blue"],
"mood": "peaceful"
}Markdown:
markdown
### sunset.png

A vibrant sunset over rolling hills with dramatic cloud formations.
**Tags:** nature, sunset, landscape, sky, clouds
**Objects:** sun, hills, clouds, trees
**Colors:** orange, purple, blue
**Mood:** peacefulCode Walkthrough
Creating the Analyzer
typescript
/**
* Image Analyzer Library
*
* Exported functions for the Image Analyzer recipe.
* Snippet markers allow VitePress to extract code for documentation.
*/
import * as fs from 'fs';
import * as path from 'path';
import {
ChatClient,
ToolRegistry,
loadImageAsDataUrl,
SUPPORTED_IMAGE_EXTENSIONS
} from '../../../src';
// [start:config]
export const MODEL = 'gpt-4o';
export const MAX_IMAGE_SIZE = 20 * 1024 * 1024; // 20MB per image
// [end:config]
// [start:colors]
export const colors = {
reset: '\x1b[0m',
dim: '\x1b[2m',
bold: '\x1b[1m',
red: '\x1b[31m',
yellow: '\x1b[33m',
green: '\x1b[32m',
cyan: '\x1b[36m',
magenta: '\x1b[35m',
blue: '\x1b[34m',
gray: '\x1b[90m'
};
// [end:colors]
// [start:types]
export interface ImageAnalysis {
filename: string;
path: string;
description: string;
tags: string[];
objects: string[];
dominantColors: string[];
mood: string;
}
export interface BatchReport {
directory: string;
totalImages: number;
processed: number;
results: ImageAnalysis[];
errors: string[];
generatedAt: string;
}
export interface ImageFile {
path: string;
filename: string;
}
// [end:types]
// [start:logging]
export function log(level: 'info' | 'success' | 'error' | 'progress', message: string): void {
const prefix = {
info: `${colors.cyan}ℹ${colors.reset}`,
success: `${colors.green}✓${colors.reset}`,
error: `${colors.red}✗${colors.reset}`,
progress: `${colors.magenta}→${colors.reset}`
};
console.log(`${prefix[level]} ${message}`);
}
export function printBanner(): void {
console.log(`
${colors.cyan}╭─────────────────────────────────────────────╮
│ ${colors.reset}${colors.bold}Image Analyzer${colors.reset}${colors.cyan} │
│ ${colors.dim}Batch image analysis with AI vision${colors.reset}${colors.cyan} │
╰─────────────────────────────────────────────╯${colors.reset}
`);
}
// [end:logging]
// [start:file-utils]
export function isImageFile(filename: string): boolean {
const ext = path.extname(filename).slice(1).toLowerCase();
return SUPPORTED_IMAGE_EXTENSIONS.includes(ext as (typeof SUPPORTED_IMAGE_EXTENSIONS)[number]);
}
export function scanDirectory(dirPath: string, visited: Set<string> = new Set()): ImageFile[] {
const images: ImageFile[] = [];
if (!fs.existsSync(dirPath)) {
return images;
}
// Resolve to real path to detect symlink cycles
let realPath: string;
try {
realPath = fs.realpathSync(dirPath);
} catch {
return images; // Skip broken symlinks
}
if (visited.has(realPath)) {
return images; // Cycle detected, skip
}
visited.add(realPath);
const stat = fs.statSync(dirPath);
if (stat.isFile() && isImageFile(dirPath)) {
return [{ path: dirPath, filename: path.basename(dirPath) }];
}
if (!stat.isDirectory()) {
return images;
}
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
if (entry.name.startsWith('.')) continue;
const fullPath = path.join(dirPath, entry.name);
// Handle symlinks
if (entry.isSymbolicLink()) {
try {
const targetStat = fs.statSync(fullPath);
if (targetStat.isDirectory()) {
images.push(...scanDirectory(fullPath, visited));
} else if (targetStat.isFile() && isImageFile(entry.name)) {
images.push({ path: fullPath, filename: entry.name });
}
} catch {
// Skip broken symlinks
}
} else if (entry.isFile() && isImageFile(entry.name)) {
images.push({ path: fullPath, filename: entry.name });
} else if (entry.isDirectory()) {
images.push(...scanDirectory(fullPath, visited));
}
}
return images;
}
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
// [end:file-utils]
// [start:report-generation]
export function generateJsonReport(report: BatchReport, outputPath: string): void {
fs.writeFileSync(outputPath, JSON.stringify(report, null, 2));
log('success', `JSON report saved: ${outputPath}`);
}
export function generateMarkdownReport(report: BatchReport, outputPath: string): void {
const outputDir = path.dirname(outputPath);
const lines: string[] = [
`# Image Analysis Report`,
'',
`**Directory:** \`${report.directory}\``,
`**Generated:** ${report.generatedAt}`,
`**Images Processed:** ${report.processed}/${report.totalImages}`,
''
];
if (report.errors.length > 0) {
lines.push('## Errors', '');
for (const error of report.errors) {
lines.push(`- ${error}`);
}
lines.push('');
}
lines.push('## Results', '');
for (const result of report.results) {
// Use relative path from output file location for better portability
const relativePath = path.relative(outputDir, result.path);
lines.push(`### ${result.filename}`, '');
lines.push(``, '');
lines.push(result.description, '');
lines.push(`**Tags:** ${result.tags.join(', ')}`, '');
lines.push(`**Objects:** ${result.objects.join(', ')}`, '');
lines.push(`**Colors:** ${result.dominantColors.join(', ')}`, '');
lines.push(`**Mood:** ${result.mood}`, '');
lines.push('---', '');
}
fs.writeFileSync(outputPath, lines.join('\n'));
log('success', `Markdown report saved: ${outputPath}`);
}
// [end:report-generation]
// [start:tool-registration]
export type AnalysisResult = Omit<ImageAnalysis, 'filename' | 'path'>;
export function createToolRegistry(onResult: (result: AnalysisResult) => void): ToolRegistry {
const registry = new ToolRegistry();
registry.registerTool(
'report_analysis',
async (args: AnalysisResult) => {
onResult(args);
return { success: true };
},
{
description: 'Report the structured analysis of an image',
parameters: {
type: 'object',
properties: {
description: {
type: 'string',
description: 'Detailed description of the image (2-3 sentences)'
},
tags: {
type: 'array',
items: { type: 'string' },
description: 'Relevant tags/keywords for the image (3-7 tags)'
},
objects: {
type: 'array',
items: { type: 'string' },
description: 'Main objects visible in the image'
},
dominantColors: {
type: 'array',
items: { type: 'string' },
description: 'Dominant colors in the image (2-4 colors)'
},
mood: {
type: 'string',
description:
'Overall mood/atmosphere of the image (e.g., peaceful, energetic, mysterious)'
}
},
required: ['description', 'tags', 'objects', 'dominantColors', 'mood']
}
}
);
return registry;
}
// [end:tool-registration]
// [start:system-prompt]
export const SYSTEM_PROMPT = `You are an image analysis assistant. When given an image, analyze it thoroughly and call the report_analysis tool with structured data about the image. Be specific and accurate in your descriptions.`;
// [end:system-prompt]
// [start:create-client]
/**
* Result holder for capturing analysis results from tool callbacks.
* Used to share state between the client and analyzeImage function.
*/
export interface AnalysisResultHolder {
result: AnalysisResult | null;
}
export function createAnalyzerClient(
apiKey: string,
resultHolder: AnalysisResultHolder
): ChatClient {
const tools = createToolRegistry((result) => {
resultHolder.result = result;
});
return new ChatClient({
provider: 'openai',
model: MODEL,
apiKey,
tools,
agenticMode: true,
maxToolRounds: 1,
systemPrompt: SYSTEM_PROMPT
});
}
// [end:create-client]
// [start:analyze-image]
export async function analyzeImage(
client: ChatClient,
imageFile: ImageFile,
resultHolder: AnalysisResultHolder
): Promise<ImageAnalysis> {
// Check file size
const stats = fs.statSync(imageFile.path);
if (stats.size > MAX_IMAGE_SIZE) {
throw new Error(
`File too large: ${formatBytes(stats.size)} (max: ${formatBytes(MAX_IMAGE_SIZE)})`
);
}
// Load image as data URL
const imageDataUrl = await loadImageAsDataUrl(imageFile.path);
// Clear previous result
resultHolder.result = null;
// Analyze with vision
const response = await client.chat({
role: 'user',
content: [
{
type: 'text',
text: 'Analyze this image and call the report_analysis tool with your findings. Include a detailed description, relevant tags, visible objects, dominant colors, and the overall mood.'
},
{
type: 'image',
image: imageDataUrl
}
]
});
// Return the captured analysis or fallback
// Use type assertion since resultHolder.result was set by the tool callback
const capturedResult = resultHolder.result as AnalysisResult | null;
if (capturedResult !== null) {
return {
filename: imageFile.filename,
path: imageFile.path,
description: capturedResult.description,
tags: capturedResult.tags,
objects: capturedResult.objects,
dominantColors: capturedResult.dominantColors,
mood: capturedResult.mood
};
}
// Fallback if no tool was called - shouldn't happen with proper prompting
return {
filename: imageFile.filename,
path: imageFile.path,
description: response.content || 'No description available',
tags: [],
objects: [],
dominantColors: [],
mood: 'unknown'
};
}
// [end:analyze-image]
// [start:argument-parsing]
export function parseArguments(args: string[]): {
inputPath: string;
format: 'json' | 'markdown' | 'both';
} {
let inputPath = '';
let format: 'json' | 'markdown' | 'both' = 'both';
for (let i = 0; i < args.length; i++) {
if (args[i] === '--format' || args[i] === '-f') {
const formatArg = args[++i];
if (formatArg === 'json' || formatArg === 'markdown' || formatArg === 'both') {
format = formatArg;
}
} else if (!args[i].startsWith('-')) {
inputPath = args[i];
}
}
return { inputPath, format };
}
// [end:argument-parsing]
// [start:create-report]
export function createEmptyReport(directory: string, totalImages: number): BatchReport {
return {
directory: path.resolve(directory),
totalImages,
processed: 0,
results: [],
errors: [],
generatedAt: new Date().toISOString()
};
}
// [end:create-report]Tool Registration
typescript
/**
* Image Analyzer Library
*
* Exported functions for the Image Analyzer recipe.
* Snippet markers allow VitePress to extract code for documentation.
*/
import * as fs from 'fs';
import * as path from 'path';
import {
ChatClient,
ToolRegistry,
loadImageAsDataUrl,
SUPPORTED_IMAGE_EXTENSIONS
} from '../../../src';
// [start:config]
export const MODEL = 'gpt-4o';
export const MAX_IMAGE_SIZE = 20 * 1024 * 1024; // 20MB per image
// [end:config]
// [start:colors]
export const colors = {
reset: '\x1b[0m',
dim: '\x1b[2m',
bold: '\x1b[1m',
red: '\x1b[31m',
yellow: '\x1b[33m',
green: '\x1b[32m',
cyan: '\x1b[36m',
magenta: '\x1b[35m',
blue: '\x1b[34m',
gray: '\x1b[90m'
};
// [end:colors]
// [start:types]
export interface ImageAnalysis {
filename: string;
path: string;
description: string;
tags: string[];
objects: string[];
dominantColors: string[];
mood: string;
}
export interface BatchReport {
directory: string;
totalImages: number;
processed: number;
results: ImageAnalysis[];
errors: string[];
generatedAt: string;
}
export interface ImageFile {
path: string;
filename: string;
}
// [end:types]
// [start:logging]
export function log(level: 'info' | 'success' | 'error' | 'progress', message: string): void {
const prefix = {
info: `${colors.cyan}ℹ${colors.reset}`,
success: `${colors.green}✓${colors.reset}`,
error: `${colors.red}✗${colors.reset}`,
progress: `${colors.magenta}→${colors.reset}`
};
console.log(`${prefix[level]} ${message}`);
}
export function printBanner(): void {
console.log(`
${colors.cyan}╭─────────────────────────────────────────────╮
│ ${colors.reset}${colors.bold}Image Analyzer${colors.reset}${colors.cyan} │
│ ${colors.dim}Batch image analysis with AI vision${colors.reset}${colors.cyan} │
╰─────────────────────────────────────────────╯${colors.reset}
`);
}
// [end:logging]
// [start:file-utils]
export function isImageFile(filename: string): boolean {
const ext = path.extname(filename).slice(1).toLowerCase();
return SUPPORTED_IMAGE_EXTENSIONS.includes(ext as (typeof SUPPORTED_IMAGE_EXTENSIONS)[number]);
}
export function scanDirectory(dirPath: string, visited: Set<string> = new Set()): ImageFile[] {
const images: ImageFile[] = [];
if (!fs.existsSync(dirPath)) {
return images;
}
// Resolve to real path to detect symlink cycles
let realPath: string;
try {
realPath = fs.realpathSync(dirPath);
} catch {
return images; // Skip broken symlinks
}
if (visited.has(realPath)) {
return images; // Cycle detected, skip
}
visited.add(realPath);
const stat = fs.statSync(dirPath);
if (stat.isFile() && isImageFile(dirPath)) {
return [{ path: dirPath, filename: path.basename(dirPath) }];
}
if (!stat.isDirectory()) {
return images;
}
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
if (entry.name.startsWith('.')) continue;
const fullPath = path.join(dirPath, entry.name);
// Handle symlinks
if (entry.isSymbolicLink()) {
try {
const targetStat = fs.statSync(fullPath);
if (targetStat.isDirectory()) {
images.push(...scanDirectory(fullPath, visited));
} else if (targetStat.isFile() && isImageFile(entry.name)) {
images.push({ path: fullPath, filename: entry.name });
}
} catch {
// Skip broken symlinks
}
} else if (entry.isFile() && isImageFile(entry.name)) {
images.push({ path: fullPath, filename: entry.name });
} else if (entry.isDirectory()) {
images.push(...scanDirectory(fullPath, visited));
}
}
return images;
}
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
// [end:file-utils]
// [start:report-generation]
export function generateJsonReport(report: BatchReport, outputPath: string): void {
fs.writeFileSync(outputPath, JSON.stringify(report, null, 2));
log('success', `JSON report saved: ${outputPath}`);
}
export function generateMarkdownReport(report: BatchReport, outputPath: string): void {
const outputDir = path.dirname(outputPath);
const lines: string[] = [
`# Image Analysis Report`,
'',
`**Directory:** \`${report.directory}\``,
`**Generated:** ${report.generatedAt}`,
`**Images Processed:** ${report.processed}/${report.totalImages}`,
''
];
if (report.errors.length > 0) {
lines.push('## Errors', '');
for (const error of report.errors) {
lines.push(`- ${error}`);
}
lines.push('');
}
lines.push('## Results', '');
for (const result of report.results) {
// Use relative path from output file location for better portability
const relativePath = path.relative(outputDir, result.path);
lines.push(`### ${result.filename}`, '');
lines.push(``, '');
lines.push(result.description, '');
lines.push(`**Tags:** ${result.tags.join(', ')}`, '');
lines.push(`**Objects:** ${result.objects.join(', ')}`, '');
lines.push(`**Colors:** ${result.dominantColors.join(', ')}`, '');
lines.push(`**Mood:** ${result.mood}`, '');
lines.push('---', '');
}
fs.writeFileSync(outputPath, lines.join('\n'));
log('success', `Markdown report saved: ${outputPath}`);
}
// [end:report-generation]
// [start:tool-registration]
export type AnalysisResult = Omit<ImageAnalysis, 'filename' | 'path'>;
export function createToolRegistry(onResult: (result: AnalysisResult) => void): ToolRegistry {
const registry = new ToolRegistry();
registry.registerTool(
'report_analysis',
async (args: AnalysisResult) => {
onResult(args);
return { success: true };
},
{
description: 'Report the structured analysis of an image',
parameters: {
type: 'object',
properties: {
description: {
type: 'string',
description: 'Detailed description of the image (2-3 sentences)'
},
tags: {
type: 'array',
items: { type: 'string' },
description: 'Relevant tags/keywords for the image (3-7 tags)'
},
objects: {
type: 'array',
items: { type: 'string' },
description: 'Main objects visible in the image'
},
dominantColors: {
type: 'array',
items: { type: 'string' },
description: 'Dominant colors in the image (2-4 colors)'
},
mood: {
type: 'string',
description:
'Overall mood/atmosphere of the image (e.g., peaceful, energetic, mysterious)'
}
},
required: ['description', 'tags', 'objects', 'dominantColors', 'mood']
}
}
);
return registry;
}
// [end:tool-registration]
// [start:system-prompt]
export const SYSTEM_PROMPT = `You are an image analysis assistant. When given an image, analyze it thoroughly and call the report_analysis tool with structured data about the image. Be specific and accurate in your descriptions.`;
// [end:system-prompt]
// [start:create-client]
/**
* Result holder for capturing analysis results from tool callbacks.
* Used to share state between the client and analyzeImage function.
*/
export interface AnalysisResultHolder {
result: AnalysisResult | null;
}
export function createAnalyzerClient(
apiKey: string,
resultHolder: AnalysisResultHolder
): ChatClient {
const tools = createToolRegistry((result) => {
resultHolder.result = result;
});
return new ChatClient({
provider: 'openai',
model: MODEL,
apiKey,
tools,
agenticMode: true,
maxToolRounds: 1,
systemPrompt: SYSTEM_PROMPT
});
}
// [end:create-client]
// [start:analyze-image]
export async function analyzeImage(
client: ChatClient,
imageFile: ImageFile,
resultHolder: AnalysisResultHolder
): Promise<ImageAnalysis> {
// Check file size
const stats = fs.statSync(imageFile.path);
if (stats.size > MAX_IMAGE_SIZE) {
throw new Error(
`File too large: ${formatBytes(stats.size)} (max: ${formatBytes(MAX_IMAGE_SIZE)})`
);
}
// Load image as data URL
const imageDataUrl = await loadImageAsDataUrl(imageFile.path);
// Clear previous result
resultHolder.result = null;
// Analyze with vision
const response = await client.chat({
role: 'user',
content: [
{
type: 'text',
text: 'Analyze this image and call the report_analysis tool with your findings. Include a detailed description, relevant tags, visible objects, dominant colors, and the overall mood.'
},
{
type: 'image',
image: imageDataUrl
}
]
});
// Return the captured analysis or fallback
// Use type assertion since resultHolder.result was set by the tool callback
const capturedResult = resultHolder.result as AnalysisResult | null;
if (capturedResult !== null) {
return {
filename: imageFile.filename,
path: imageFile.path,
description: capturedResult.description,
tags: capturedResult.tags,
objects: capturedResult.objects,
dominantColors: capturedResult.dominantColors,
mood: capturedResult.mood
};
}
// Fallback if no tool was called - shouldn't happen with proper prompting
return {
filename: imageFile.filename,
path: imageFile.path,
description: response.content || 'No description available',
tags: [],
objects: [],
dominantColors: [],
mood: 'unknown'
};
}
// [end:analyze-image]
// [start:argument-parsing]
export function parseArguments(args: string[]): {
inputPath: string;
format: 'json' | 'markdown' | 'both';
} {
let inputPath = '';
let format: 'json' | 'markdown' | 'both' = 'both';
for (let i = 0; i < args.length; i++) {
if (args[i] === '--format' || args[i] === '-f') {
const formatArg = args[++i];
if (formatArg === 'json' || formatArg === 'markdown' || formatArg === 'both') {
format = formatArg;
}
} else if (!args[i].startsWith('-')) {
inputPath = args[i];
}
}
return { inputPath, format };
}
// [end:argument-parsing]
// [start:create-report]
export function createEmptyReport(directory: string, totalImages: number): BatchReport {
return {
directory: path.resolve(directory),
totalImages,
processed: 0,
results: [],
errors: [],
generatedAt: new Date().toISOString()
};
}
// [end:create-report]Analyzing Images
typescript
/**
* Image Analyzer Library
*
* Exported functions for the Image Analyzer recipe.
* Snippet markers allow VitePress to extract code for documentation.
*/
import * as fs from 'fs';
import * as path from 'path';
import {
ChatClient,
ToolRegistry,
loadImageAsDataUrl,
SUPPORTED_IMAGE_EXTENSIONS
} from '../../../src';
// [start:config]
export const MODEL = 'gpt-4o';
export const MAX_IMAGE_SIZE = 20 * 1024 * 1024; // 20MB per image
// [end:config]
// [start:colors]
export const colors = {
reset: '\x1b[0m',
dim: '\x1b[2m',
bold: '\x1b[1m',
red: '\x1b[31m',
yellow: '\x1b[33m',
green: '\x1b[32m',
cyan: '\x1b[36m',
magenta: '\x1b[35m',
blue: '\x1b[34m',
gray: '\x1b[90m'
};
// [end:colors]
// [start:types]
export interface ImageAnalysis {
filename: string;
path: string;
description: string;
tags: string[];
objects: string[];
dominantColors: string[];
mood: string;
}
export interface BatchReport {
directory: string;
totalImages: number;
processed: number;
results: ImageAnalysis[];
errors: string[];
generatedAt: string;
}
export interface ImageFile {
path: string;
filename: string;
}
// [end:types]
// [start:logging]
export function log(level: 'info' | 'success' | 'error' | 'progress', message: string): void {
const prefix = {
info: `${colors.cyan}ℹ${colors.reset}`,
success: `${colors.green}✓${colors.reset}`,
error: `${colors.red}✗${colors.reset}`,
progress: `${colors.magenta}→${colors.reset}`
};
console.log(`${prefix[level]} ${message}`);
}
export function printBanner(): void {
console.log(`
${colors.cyan}╭─────────────────────────────────────────────╮
│ ${colors.reset}${colors.bold}Image Analyzer${colors.reset}${colors.cyan} │
│ ${colors.dim}Batch image analysis with AI vision${colors.reset}${colors.cyan} │
╰─────────────────────────────────────────────╯${colors.reset}
`);
}
// [end:logging]
// [start:file-utils]
export function isImageFile(filename: string): boolean {
const ext = path.extname(filename).slice(1).toLowerCase();
return SUPPORTED_IMAGE_EXTENSIONS.includes(ext as (typeof SUPPORTED_IMAGE_EXTENSIONS)[number]);
}
export function scanDirectory(dirPath: string, visited: Set<string> = new Set()): ImageFile[] {
const images: ImageFile[] = [];
if (!fs.existsSync(dirPath)) {
return images;
}
// Resolve to real path to detect symlink cycles
let realPath: string;
try {
realPath = fs.realpathSync(dirPath);
} catch {
return images; // Skip broken symlinks
}
if (visited.has(realPath)) {
return images; // Cycle detected, skip
}
visited.add(realPath);
const stat = fs.statSync(dirPath);
if (stat.isFile() && isImageFile(dirPath)) {
return [{ path: dirPath, filename: path.basename(dirPath) }];
}
if (!stat.isDirectory()) {
return images;
}
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
if (entry.name.startsWith('.')) continue;
const fullPath = path.join(dirPath, entry.name);
// Handle symlinks
if (entry.isSymbolicLink()) {
try {
const targetStat = fs.statSync(fullPath);
if (targetStat.isDirectory()) {
images.push(...scanDirectory(fullPath, visited));
} else if (targetStat.isFile() && isImageFile(entry.name)) {
images.push({ path: fullPath, filename: entry.name });
}
} catch {
// Skip broken symlinks
}
} else if (entry.isFile() && isImageFile(entry.name)) {
images.push({ path: fullPath, filename: entry.name });
} else if (entry.isDirectory()) {
images.push(...scanDirectory(fullPath, visited));
}
}
return images;
}
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
// [end:file-utils]
// [start:report-generation]
export function generateJsonReport(report: BatchReport, outputPath: string): void {
fs.writeFileSync(outputPath, JSON.stringify(report, null, 2));
log('success', `JSON report saved: ${outputPath}`);
}
export function generateMarkdownReport(report: BatchReport, outputPath: string): void {
const outputDir = path.dirname(outputPath);
const lines: string[] = [
`# Image Analysis Report`,
'',
`**Directory:** \`${report.directory}\``,
`**Generated:** ${report.generatedAt}`,
`**Images Processed:** ${report.processed}/${report.totalImages}`,
''
];
if (report.errors.length > 0) {
lines.push('## Errors', '');
for (const error of report.errors) {
lines.push(`- ${error}`);
}
lines.push('');
}
lines.push('## Results', '');
for (const result of report.results) {
// Use relative path from output file location for better portability
const relativePath = path.relative(outputDir, result.path);
lines.push(`### ${result.filename}`, '');
lines.push(``, '');
lines.push(result.description, '');
lines.push(`**Tags:** ${result.tags.join(', ')}`, '');
lines.push(`**Objects:** ${result.objects.join(', ')}`, '');
lines.push(`**Colors:** ${result.dominantColors.join(', ')}`, '');
lines.push(`**Mood:** ${result.mood}`, '');
lines.push('---', '');
}
fs.writeFileSync(outputPath, lines.join('\n'));
log('success', `Markdown report saved: ${outputPath}`);
}
// [end:report-generation]
// [start:tool-registration]
export type AnalysisResult = Omit<ImageAnalysis, 'filename' | 'path'>;
export function createToolRegistry(onResult: (result: AnalysisResult) => void): ToolRegistry {
const registry = new ToolRegistry();
registry.registerTool(
'report_analysis',
async (args: AnalysisResult) => {
onResult(args);
return { success: true };
},
{
description: 'Report the structured analysis of an image',
parameters: {
type: 'object',
properties: {
description: {
type: 'string',
description: 'Detailed description of the image (2-3 sentences)'
},
tags: {
type: 'array',
items: { type: 'string' },
description: 'Relevant tags/keywords for the image (3-7 tags)'
},
objects: {
type: 'array',
items: { type: 'string' },
description: 'Main objects visible in the image'
},
dominantColors: {
type: 'array',
items: { type: 'string' },
description: 'Dominant colors in the image (2-4 colors)'
},
mood: {
type: 'string',
description:
'Overall mood/atmosphere of the image (e.g., peaceful, energetic, mysterious)'
}
},
required: ['description', 'tags', 'objects', 'dominantColors', 'mood']
}
}
);
return registry;
}
// [end:tool-registration]
// [start:system-prompt]
export const SYSTEM_PROMPT = `You are an image analysis assistant. When given an image, analyze it thoroughly and call the report_analysis tool with structured data about the image. Be specific and accurate in your descriptions.`;
// [end:system-prompt]
// [start:create-client]
/**
* Result holder for capturing analysis results from tool callbacks.
* Used to share state between the client and analyzeImage function.
*/
export interface AnalysisResultHolder {
result: AnalysisResult | null;
}
export function createAnalyzerClient(
apiKey: string,
resultHolder: AnalysisResultHolder
): ChatClient {
const tools = createToolRegistry((result) => {
resultHolder.result = result;
});
return new ChatClient({
provider: 'openai',
model: MODEL,
apiKey,
tools,
agenticMode: true,
maxToolRounds: 1,
systemPrompt: SYSTEM_PROMPT
});
}
// [end:create-client]
// [start:analyze-image]
export async function analyzeImage(
client: ChatClient,
imageFile: ImageFile,
resultHolder: AnalysisResultHolder
): Promise<ImageAnalysis> {
// Check file size
const stats = fs.statSync(imageFile.path);
if (stats.size > MAX_IMAGE_SIZE) {
throw new Error(
`File too large: ${formatBytes(stats.size)} (max: ${formatBytes(MAX_IMAGE_SIZE)})`
);
}
// Load image as data URL
const imageDataUrl = await loadImageAsDataUrl(imageFile.path);
// Clear previous result
resultHolder.result = null;
// Analyze with vision
const response = await client.chat({
role: 'user',
content: [
{
type: 'text',
text: 'Analyze this image and call the report_analysis tool with your findings. Include a detailed description, relevant tags, visible objects, dominant colors, and the overall mood.'
},
{
type: 'image',
image: imageDataUrl
}
]
});
// Return the captured analysis or fallback
// Use type assertion since resultHolder.result was set by the tool callback
const capturedResult = resultHolder.result as AnalysisResult | null;
if (capturedResult !== null) {
return {
filename: imageFile.filename,
path: imageFile.path,
description: capturedResult.description,
tags: capturedResult.tags,
objects: capturedResult.objects,
dominantColors: capturedResult.dominantColors,
mood: capturedResult.mood
};
}
// Fallback if no tool was called - shouldn't happen with proper prompting
return {
filename: imageFile.filename,
path: imageFile.path,
description: response.content || 'No description available',
tags: [],
objects: [],
dominantColors: [],
mood: 'unknown'
};
}
// [end:analyze-image]
// [start:argument-parsing]
export function parseArguments(args: string[]): {
inputPath: string;
format: 'json' | 'markdown' | 'both';
} {
let inputPath = '';
let format: 'json' | 'markdown' | 'both' = 'both';
for (let i = 0; i < args.length; i++) {
if (args[i] === '--format' || args[i] === '-f') {
const formatArg = args[++i];
if (formatArg === 'json' || formatArg === 'markdown' || formatArg === 'both') {
format = formatArg;
}
} else if (!args[i].startsWith('-')) {
inputPath = args[i];
}
}
return { inputPath, format };
}
// [end:argument-parsing]
// [start:create-report]
export function createEmptyReport(directory: string, totalImages: number): BatchReport {
return {
directory: path.resolve(directory),
totalImages,
processed: 0,
results: [],
errors: [],
generatedAt: new Date().toISOString()
};
}
// [end:create-report]Configuration
| Option | Default | Description |
|---|---|---|
--format | both | Output format: json, markdown, or both |
Supported Formats
| Format | Extension |
|---|---|
| JPEG | .jpg, .jpeg |
| PNG | .png |
| GIF | .gif |
| WebP | .webp |
Environment Variables
| Variable | Required | Description |
|---|---|---|
OPENAI_API_KEY | Yes | OpenAI API key (GPT-4o required for vision) |
Use Cases
Photo Organization
Automatically tag and describe photos for easier searching and organization.
Content Moderation
Analyze uploaded images for content categorization.
Accessibility
Generate alt-text descriptions for images in web content.
Asset Management
Catalog and describe design assets with searchable metadata.
