Skip to content

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 -- ./images

How 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:

  1. Recursively scan directory for image files
  2. Filter by supported types (JPEG, PNG, GIF, WebP)
  3. Load each image as a data URL
  4. Send to GPT-4o Vision API for analysis
  5. Extract structured data via tool calling
  6. 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 both

Output 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

![sunset.png](./images/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:** peaceful

Code 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(`![${result.filename}](${relativePath})`, '');
    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(`![${result.filename}](${relativePath})`, '');
    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(`![${result.filename}](${relativePath})`, '');
    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

OptionDefaultDescription
--formatbothOutput format: json, markdown, or both

Supported Formats

FormatExtension
JPEG.jpg, .jpeg
PNG.png
GIF.gif
WebP.webp

Environment Variables

VariableRequiredDescription
OPENAI_API_KEYYesOpenAI 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.

Full Source

View on GitHub

Released under the MIT License.