## Features Added: - **Automatic Documentation Generation**: Uses next-swagger-doc to scan API routes - **Interactive Swagger UI**: Try-it-out functionality for testing endpoints - **OpenAPI 3.0 Specification**: Industry-standard API documentation format - **Comprehensive Schemas**: Type definitions for all request/response objects ## New Documentation System: - `/docs` - Interactive Swagger UI documentation page - `/api/docs` - OpenAPI specification JSON endpoint - `src/lib/swagger.ts` - Documentation configuration and schemas - Complete JSDoc examples for batch classification endpoint ## Documentation Features: - Real-time API testing from documentation interface - Detailed request/response examples and schemas - Parameter validation and error response documentation - Organized by tags (Classification, Captioning, Tags, etc.) - Dark/light mode support with loading states ## AI Roadmap & Guides: - `AIROADMAP.md` - Comprehensive roadmap for future AI enhancements - `API_DOCUMENTATION.md` - Complete guide for maintaining documentation ## Benefits: - Documentation stays automatically synchronized with code changes - No separate docs to maintain - generated from JSDoc comments - Professional API documentation for integration and development - Export capabilities for Postman, Insomnia, and other tools 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
378 lines
13 KiB
TypeScript
378 lines
13 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server'
|
|
import { photoService } from '@/lib/photo-service'
|
|
import { imageClassifier } from '@/lib/image-classifier'
|
|
import { existsSync } from 'fs'
|
|
|
|
interface BatchClassifyRequest {
|
|
directory?: string
|
|
limit?: number
|
|
offset?: number
|
|
minConfidence?: number
|
|
onlyUntagged?: boolean
|
|
dryRun?: boolean
|
|
customLabels?: string[]
|
|
maxResults?: number
|
|
comprehensive?: boolean // Use both ViT + CLIP for more tags
|
|
categories?: {
|
|
general?: string[]
|
|
time?: string[]
|
|
weather?: string[]
|
|
subjects?: string[]
|
|
locations?: string[]
|
|
style?: string[]
|
|
seasons?: string[]
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @swagger
|
|
* /api/classify/batch:
|
|
* post:
|
|
* summary: Batch classify photos using AI models
|
|
* description: Process multiple photos with AI classification using ViT for objects and optionally CLIP for artistic/style concepts. Supports comprehensive mode for maximum tag diversity.
|
|
* tags: [Classification]
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/BatchClassifyRequest'
|
|
* examples:
|
|
* basic:
|
|
* summary: Basic batch classification
|
|
* value:
|
|
* limit: 10
|
|
* minConfidence: 0.3
|
|
* onlyUntagged: true
|
|
* comprehensive:
|
|
* summary: Comprehensive mode with dual models
|
|
* value:
|
|
* limit: 5
|
|
* comprehensive: true
|
|
* minConfidence: 0.05
|
|
* maxResults: 25
|
|
* onlyUntagged: true
|
|
* responses:
|
|
* 200:
|
|
* description: Classification results with summary statistics
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* summary:
|
|
* type: object
|
|
* properties:
|
|
* processed: { type: 'integer', description: 'Number of photos processed' }
|
|
* successful: { type: 'integer', description: 'Number of successful classifications' }
|
|
* failed: { type: 'integer', description: 'Number of failed classifications' }
|
|
* totalTagsAdded: { type: 'integer', description: 'Total tags added to database' }
|
|
* config: { $ref: '#/components/schemas/ClassifierConfig' }
|
|
* results:
|
|
* type: array
|
|
* items:
|
|
* type: object
|
|
* properties:
|
|
* photoId: { type: 'string' }
|
|
* filename: { type: 'string' }
|
|
* tagsAdded: { type: 'integer' }
|
|
* topTags:
|
|
* type: array
|
|
* items:
|
|
* $ref: '#/components/schemas/ClassificationResult'
|
|
* hasMore: { type: 'boolean', description: 'Whether more photos are available to process' }
|
|
* 400:
|
|
* description: Invalid request parameters
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* 500:
|
|
* description: Server error during classification
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
*/
|
|
export async function POST(request: NextRequest) {
|
|
try {
|
|
const body: BatchClassifyRequest = await request.json()
|
|
|
|
const {
|
|
directory,
|
|
limit = 50, // Process 50 photos at a time to avoid memory issues
|
|
offset = 0,
|
|
minConfidence = 0.3,
|
|
onlyUntagged = false,
|
|
dryRun = false,
|
|
customLabels,
|
|
maxResults,
|
|
comprehensive = false,
|
|
categories
|
|
} = body
|
|
|
|
// Build classifier configuration for this batch
|
|
const classifierConfig = {
|
|
minConfidence: comprehensive ? 0.05 : minConfidence, // Lower threshold for comprehensive mode
|
|
maxResults: comprehensive ? 25 : (maxResults || 10), // More results for comprehensive mode
|
|
customLabels,
|
|
categories
|
|
}
|
|
|
|
// Initialize classifier(s)
|
|
if (comprehensive) {
|
|
console.log('[BATCH CLASSIFY] Initializing comprehensive classifiers (ViT + CLIP)...')
|
|
await Promise.all([
|
|
imageClassifier.initialize(),
|
|
imageClassifier.initializeZeroShot()
|
|
])
|
|
} else {
|
|
console.log('[BATCH CLASSIFY] Initializing image classifier...')
|
|
await imageClassifier.initialize()
|
|
}
|
|
|
|
// Get photos to process
|
|
console.log('[BATCH CLASSIFY] Getting photos to classify...')
|
|
let photos = photoService.getPhotos({
|
|
directory,
|
|
limit,
|
|
offset,
|
|
sortBy: 'created_at',
|
|
sortOrder: 'ASC'
|
|
})
|
|
|
|
// Filter to only untagged photos if requested
|
|
if (onlyUntagged) {
|
|
photos = photos.filter(photo => {
|
|
const tags = photoService.getPhotoTags(photo.id)
|
|
return tags.length === 0
|
|
})
|
|
}
|
|
|
|
console.log(`[BATCH CLASSIFY] Processing ${photos.length} photos...`)
|
|
|
|
const results = []
|
|
let processed = 0
|
|
let successful = 0
|
|
let failed = 0
|
|
let totalTagsAdded = 0
|
|
|
|
for (const photo of photos) {
|
|
try {
|
|
processed++
|
|
console.log(`[BATCH CLASSIFY] Processing ${processed}/${photos.length}: ${photo.filename}`)
|
|
|
|
// Try to get cached thumbnail first, fall back to file path
|
|
let imageSource: string | Buffer = photo.filepath
|
|
let usingThumbnail = false
|
|
const thumbnailBlob = photoService.getCachedThumbnail(photo.id, 200) // Use 200px thumbnail
|
|
|
|
if (thumbnailBlob) {
|
|
console.log(`[BATCH CLASSIFY] Using cached thumbnail: ${photo.filename}`)
|
|
imageSource = thumbnailBlob
|
|
usingThumbnail = true
|
|
} else {
|
|
// Check if file exists for fallback
|
|
if (!existsSync(photo.filepath)) {
|
|
console.warn(`[BATCH CLASSIFY] File not found and no thumbnail: ${photo.filepath}`)
|
|
failed++
|
|
continue
|
|
}
|
|
console.log(`[BATCH CLASSIFY] Using original file (no thumbnail): ${photo.filename}`)
|
|
}
|
|
|
|
// Get existing tags if any
|
|
const existingTags = photoService.getPhotoTags(photo.id)
|
|
|
|
// Classify the image with fallback handling
|
|
let classifications
|
|
try {
|
|
if (comprehensive) {
|
|
const comprehensiveResult = await imageClassifier.classifyImageComprehensive(imageSource, classifierConfig)
|
|
classifications = comprehensiveResult.combinedResults
|
|
console.log(`[BATCH CLASSIFY] Comprehensive: ${comprehensiveResult.objectClassification.length} object + ${comprehensiveResult.styleClassification.length} style tags`)
|
|
} else {
|
|
classifications = await imageClassifier.classifyImage(imageSource, customLabels, classifierConfig)
|
|
}
|
|
} catch (error) {
|
|
if (usingThumbnail && existsSync(photo.filepath)) {
|
|
console.warn(`[BATCH CLASSIFY] Thumbnail failed for ${photo.filename}, falling back to original file:`, error)
|
|
try {
|
|
// Fallback to original file
|
|
if (comprehensive) {
|
|
const comprehensiveResult = await imageClassifier.classifyImageComprehensive(photo.filepath, classifierConfig)
|
|
classifications = comprehensiveResult.combinedResults
|
|
} else {
|
|
classifications = await imageClassifier.classifyImage(photo.filepath, customLabels, classifierConfig)
|
|
}
|
|
console.log(`[BATCH CLASSIFY] Fallback to original file successful: ${photo.filename}`)
|
|
} catch (fallbackError) {
|
|
console.error(`[BATCH CLASSIFY] Both thumbnail and original file failed for ${photo.filename}:`, fallbackError)
|
|
throw fallbackError
|
|
}
|
|
} else {
|
|
throw error
|
|
}
|
|
}
|
|
|
|
// Filter by confidence and exclude existing tags
|
|
// Note: classifications are already filtered by confidence in classifyImage
|
|
const existingTagNames = existingTags.map(tag => tag.name.toLowerCase())
|
|
const newTags = classifications
|
|
.filter(result => !existingTagNames.includes(result.label.toLowerCase()))
|
|
.map(result => ({ name: result.label, confidence: result.score }))
|
|
|
|
if (dryRun) {
|
|
// Just log what would be added
|
|
results.push({
|
|
photoId: photo.id,
|
|
filename: photo.filename,
|
|
existingTags: existingTags.length,
|
|
newClassifications: newTags,
|
|
wouldAdd: newTags.length
|
|
})
|
|
} else {
|
|
// Actually add the tags
|
|
const addedCount = photoService.addPhotoTags(photo.id, newTags)
|
|
totalTagsAdded += addedCount
|
|
|
|
results.push({
|
|
photoId: photo.id,
|
|
filename: photo.filename,
|
|
existingTags: existingTags.length,
|
|
classificationsFound: classifications.length,
|
|
newTagsAdded: addedCount,
|
|
topTags: newTags.slice(0, 5) // Show top 5 tags for logging
|
|
})
|
|
}
|
|
|
|
successful++
|
|
|
|
// Log progress every 10 photos
|
|
if (processed % 10 === 0) {
|
|
console.log(`[BATCH CLASSIFY] Progress: ${processed}/${photos.length} (${successful} successful, ${failed} failed)`)
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error(`[BATCH CLASSIFY] Error processing ${photo.filename}:`, error)
|
|
failed++
|
|
|
|
results.push({
|
|
photoId: photo.id,
|
|
filename: photo.filename,
|
|
error: error instanceof Error ? error.message : 'Unknown error'
|
|
})
|
|
}
|
|
}
|
|
|
|
console.log(`[BATCH CLASSIFY] Completed: ${successful} successful, ${failed} failed, ${totalTagsAdded} total tags added`)
|
|
|
|
return NextResponse.json({
|
|
summary: {
|
|
processed,
|
|
successful,
|
|
failed,
|
|
totalTagsAdded,
|
|
config: classifierConfig,
|
|
dryRun,
|
|
onlyUntagged
|
|
},
|
|
results: dryRun ? results : results.slice(0, 10), // Limit results in response for performance
|
|
hasMore: photos.length === limit // Indicate if there might be more photos to process
|
|
})
|
|
|
|
} catch (error) {
|
|
console.error('[BATCH CLASSIFY] Error:', error)
|
|
return NextResponse.json(
|
|
{ error: 'Failed to batch classify images' },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @swagger
|
|
* /api/classify/batch:
|
|
* get:
|
|
* summary: Get classification status and statistics
|
|
* description: Returns statistics about photo classification status including total photos, tagged/untagged counts, and most common tags.
|
|
* tags: [Classification]
|
|
* parameters:
|
|
* - in: query
|
|
* name: directory
|
|
* schema:
|
|
* type: string
|
|
* description: Optional directory to filter statistics
|
|
* example: /Users/photos/2024
|
|
* responses:
|
|
* 200:
|
|
* description: Classification status and statistics
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* total: { type: 'integer', description: 'Total number of photos' }
|
|
* tagged: { type: 'integer', description: 'Number of photos with tags' }
|
|
* untagged: { type: 'integer', description: 'Number of photos without tags' }
|
|
* taggedPercentage: { type: 'integer', description: 'Percentage of photos with tags' }
|
|
* topTags:
|
|
* type: array
|
|
* items:
|
|
* type: object
|
|
* properties:
|
|
* name: { type: 'string', description: 'Tag name' }
|
|
* count: { type: 'integer', description: 'Number of photos with this tag' }
|
|
* classifierReady: { type: 'boolean', description: 'Whether AI classifier is ready' }
|
|
* 500:
|
|
* description: Server error
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
*/
|
|
export async function GET(request: NextRequest) {
|
|
try {
|
|
const { searchParams } = new URL(request.url)
|
|
const directory = searchParams.get('directory') || undefined
|
|
|
|
// Get total photos
|
|
const allPhotos = photoService.getPhotos({ directory })
|
|
|
|
// Get photos with tags
|
|
const photosWithTags = allPhotos.filter(photo => {
|
|
const tags = photoService.getPhotoTags(photo.id)
|
|
return tags.length > 0
|
|
})
|
|
|
|
// Get most common tags
|
|
const tagCounts: { [tagName: string]: number } = {}
|
|
allPhotos.forEach(photo => {
|
|
const tags = photoService.getPhotoTags(photo.id)
|
|
tags.forEach(tag => {
|
|
tagCounts[tag.name] = (tagCounts[tag.name] || 0) + 1
|
|
})
|
|
})
|
|
|
|
const topTags = Object.entries(tagCounts)
|
|
.sort(([,a], [,b]) => b - a)
|
|
.slice(0, 20)
|
|
.map(([name, count]) => ({ name, count }))
|
|
|
|
return NextResponse.json({
|
|
total: allPhotos.length,
|
|
tagged: photosWithTags.length,
|
|
untagged: allPhotos.length - photosWithTags.length,
|
|
taggedPercentage: Math.round((photosWithTags.length / allPhotos.length) * 100),
|
|
topTags,
|
|
classifierReady: imageClassifier.isReady()
|
|
})
|
|
|
|
} catch (error) {
|
|
console.error('[BATCH CLASSIFY STATUS] Error:', error)
|
|
return NextResponse.json(
|
|
{ error: 'Failed to get classification status' },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
} |