photos/src/components/PhotoThumbnail.tsx
Michael Mainguy 85c1479d94 Add comprehensive AI-powered photo analysis with dual-model classification
## Features Added:
- **Dual Model Classification**: ViT (objects) + CLIP (style/artistic concepts)
- **Image Captioning**: BLIP model for detailed photo descriptions
- **Auto-tagging**: Process all photos with configurable confidence thresholds
- **Tag Management**: Clear all tags functionality with safety confirmations
- **Comprehensive Analysis**: 15-25+ tags per image covering objects, style, mood, lighting

## New API Endpoints:
- `/api/classify/batch` - Batch classification with comprehensive mode
- `/api/classify/comprehensive` - Dual-model analysis for maximum tags
- `/api/classify/config` - Tunable classifier parameters
- `/api/caption/batch` - Batch image captioning
- `/api/tags/clear` - Clear all tags with safety checks

## UI Enhancements:
- Auto-tag All button (processes 5 photos at a time)
- Caption All button (processes 3 photos at a time)
- Clear All Tags button with confirmation dialogs
- Real-time progress bars for batch operations
- Tag pills displayed on thumbnails and image modal
- AI-generated captions shown in image modal

## Performance Optimizations:
- Uses cached thumbnails for 10-100x faster processing
- Parallel model initialization and processing
- Graceful fallback to original files when thumbnails fail
- Configurable batch sizes to prevent memory issues

## Technical Implementation:
- Vision Transformer (ViT) for ImageNet object classification (1000+ classes)
- CLIP for zero-shot artistic/style classification (photography, lighting, mood)
- BLIP for natural language image descriptions
- Comprehensive safety checks and error handling
- Database integration for persistent tag and caption storage

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-27 17:05:54 -05:00

270 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useState, useEffect } from 'react'
import { IconCamera, IconMapPin, IconCalendar, IconEye, IconHeart, IconStar } from '@tabler/icons-react'
import { Photo } from '@/types/photo'
interface PhotoThumbnailProps {
photo: Photo
size?: 'small' | 'medium' | 'large'
showMetadata?: boolean
onPhotoClick?: (photo: Photo) => void
}
export default function PhotoThumbnail({
photo,
size = 'medium',
showMetadata = false,
onPhotoClick
}: PhotoThumbnailProps) {
const [imageError, setImageError] = useState(false)
const [showDetails, setShowDetails] = useState(false)
const [tags, setTags] = useState<Array<{id: string, name: string, color: string}>>([])
// Fetch tags for this photo
useEffect(() => {
const fetchTags = async () => {
try {
const response = await fetch(`/api/photos/${photo.id}/tags`)
if (response.ok) {
const photoTags = await response.json()
setTags(photoTags)
}
} catch (error) {
console.warn('Failed to fetch tags for photo:', photo.id)
}
}
fetchTags()
}, [photo.id])
// Parse metadata
let metadata: any = {}
try {
metadata = photo.metadata ? JSON.parse(photo.metadata) : {}
} catch (error) {
console.warn('Failed to parse photo metadata:', error)
}
const exif = metadata.exif || {}
// Size configurations - keep square containers for grid layout
const sizeConfig = {
small: {
container: 'aspect-square',
thumbnail: 150
},
medium: {
container: 'aspect-square',
thumbnail: 200
},
large: {
container: 'aspect-square',
thumbnail: 300
}
}
const config = sizeConfig[size]
// Format metadata for display
const formatExposureTime = (time: number) => {
if (time >= 1) return `${time}s`
return `1/${Math.round(1 / time)}s`
}
const formatFocalLength = (length: number) => `${Math.round(length)}mm`
const formatISO = (iso: number | number[]) => {
const isoValue = Array.isArray(iso) ? iso[0] : iso
return `ISO ${isoValue}`
}
const formatDate = (dateString: string) => {
try {
return new Date(dateString).toLocaleDateString()
} catch {
return 'Unknown'
}
}
const formatDateTime = (dateString: string) => {
try {
return new Date(dateString).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
} catch {
return 'Unknown'
}
}
// Get the best available date from EXIF data
const getBestDate = () => {
if (exif.date_time_original) return exif.date_time_original
if (exif.date_time_digitized) return exif.date_time_digitized
if (exif.date_time) return exif.date_time
if (photo.created_at) return photo.created_at
return null
}
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
return (
<div
className={`relative group cursor-pointer overflow-hidden rounded-lg shadow-md hover:shadow-lg transition-all duration-200 bg-gray-100 dark:bg-gray-800 ${config.container} w-full`}
onClick={() => onPhotoClick?.(photo)}
onMouseEnter={() => setShowDetails(true)}
onMouseLeave={() => setShowDetails(false)}
>
{/* Thumbnail Image */}
{!imageError ? (
<img
src={`/api/photos/${photo.id}/thumbnail?size=${config.thumbnail}`}
alt={photo.filename}
className="absolute inset-0 w-full h-full object-contain transition-transform duration-200 group-hover:scale-105"
onError={() => setImageError(true)}
loading="lazy"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400 dark:text-gray-600">
<IconCamera size={32} />
</div>
)}
{/* Favorite indicator */}
{photo.favorite && (
<div className="absolute top-2 right-2 z-10">
<IconHeart className="w-5 h-5 text-red-500 fill-current" />
</div>
)}
{/* Rating indicator */}
{photo.rating && photo.rating > 0 && (
<div className="absolute top-2 left-2 z-10 flex">
{[...Array(photo.rating)].map((_, i) => (
<IconStar key={i} className="w-4 h-4 text-yellow-400 fill-current" />
))}
</div>
)}
{/* Tag pills */}
{tags.length > 0 && (
<div className="absolute bottom-2 left-2 right-2 z-10">
<div className="flex flex-wrap gap-1">
{tags.slice(0, size === 'large' ? 6 : size === 'medium' ? 4 : 2).map((tag) => (
<span
key={tag.id}
className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium text-white bg-black bg-opacity-70 backdrop-blur-sm"
style={{ backgroundColor: `${tag.color}cc` }} // Add some transparency
title={tag.name}
>
{tag.name}
</span>
))}
{tags.length > (size === 'large' ? 6 : size === 'medium' ? 4 : 2) && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium text-white bg-gray-700 bg-opacity-80">
+{tags.length - (size === 'large' ? 6 : size === 'medium' ? 4 : 2)}
</span>
)}
</div>
</div>
)}
{/* Metadata overlay - appears at bottom, leaving image visible */}
{showMetadata && (showDetails || size === 'large') && (
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black via-black/80 to-transparent text-white p-3 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<div className="space-y-1 text-xs">
{/* Filename */}
<div className="font-medium truncate" title={photo.filename}>
{photo.filename}
</div>
{/* Camera info and settings in a compact row */}
<div className="flex items-center justify-between text-gray-300">
{(exif.camera_make || exif.camera_model) && (
<div className="flex items-center gap-1 truncate flex-1 mr-2">
<IconCamera className="w-3 h-3 flex-shrink-0" />
<span className="truncate">
{[exif.camera_make, exif.camera_model].filter(Boolean).join(' ')}
</span>
</div>
)}
{/* Photo settings - compact display */}
{(exif.f_number || exif.exposure_time || exif.iso_speed) && (
<div className="flex items-center gap-2 text-xs">
{exif.f_number && <span>f/{exif.f_number}</span>}
{exif.exposure_time && <span>{formatExposureTime(exif.exposure_time)}</span>}
{exif.iso_speed && <span>{formatISO(exif.iso_speed)}</span>}
</div>
)}
</div>
{/* Second row: location, date, dimensions */}
<div className="flex items-center justify-between text-gray-400 text-xs">
<div className="flex items-center gap-3">
{/* GPS location */}
{exif.gps && (exif.gps.latitude || exif.gps.longitude) && (
<div className="flex items-center gap-1">
<IconMapPin className="w-3 h-3" />
<span>{exif.gps.latitude?.toFixed(2)}, {exif.gps.longitude?.toFixed(2)}</span>
</div>
)}
{/* Date taken with time */}
{getBestDate() && (
<div className="flex items-center gap-1">
<IconCalendar className="w-3 h-3" />
<span title={`Original: ${exif.date_time_original || 'N/A'}\nDigitized: ${exif.date_time_digitized || 'N/A'}\nModified: ${exif.date_time || 'N/A'}`}>
{formatDateTime(getBestDate()!)}
</span>
</div>
)}
</div>
{/* Image dimensions and file size */}
<div className="text-right">
{photo.width && photo.height && (
<div>{photo.width} × {photo.height}</div>
)}
<div>{formatFileSize(photo.filesize)}</div>
</div>
</div>
{/* Third row: Additional EXIF date info for large thumbnails */}
{size === 'large' && (exif.date_time_original || exif.date_time_digitized || exif.date_time) && (
<div className="text-gray-500 text-xs mt-1 space-y-0.5">
{exif.date_time_original && exif.date_time_original !== getBestDate() && (
<div>📷 Taken: {formatDateTime(exif.date_time_original)}</div>
)}
{exif.date_time_digitized && exif.date_time_digitized !== getBestDate() && (
<div>💾 Digitized: {formatDateTime(exif.date_time_digitized)}</div>
)}
{exif.date_time && exif.date_time !== getBestDate() && (
<div>📝 Modified: {formatDateTime(exif.date_time)}</div>
)}
</div>
)}
</div>
</div>
)}
{/* Simple overlay for small sizes */}
{size === 'small' && showDetails && (
<div className="absolute inset-0 bg-black bg-opacity-50 text-white p-2 flex items-end opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<div className="text-xs font-medium truncate" title={photo.filename}>
{photo.filename}
</div>
</div>
)}
</div>
)
}