Spaces:
Running
Running
| import { useState, useRef, useEffect, useCallback } from "react"; | |
| import DraggableContainer from "./DraggableContainer"; | |
| import PromptInput from "./PromptInput"; | |
| import GlassButton from "./GlassButton"; | |
| import GlassContainer from "./GlassContainer"; | |
| import { useVLMContext } from "../context/useVLMContext"; | |
| import { PROMPTS, GLASS_EFFECTS } from "../constants"; | |
| import type { ImageAnalysisResult } from "../types"; | |
| interface ImageAnalysisViewProps { | |
| images: File[]; | |
| onBackToUpload: () => void; | |
| } | |
| export default function ImageAnalysisView({ images, onBackToUpload }: ImageAnalysisViewProps) { | |
| const [results, setResults] = useState<ImageAnalysisResult[]>([]); | |
| const [currentPrompt, setCurrentPrompt] = useState<string>(PROMPTS.default); | |
| const [isAnalyzing, setIsAnalyzing] = useState<boolean>(false); | |
| const [currentImageIndex, setCurrentImageIndex] = useState<number>(0); | |
| const [selectedImageUrl, setSelectedImageUrl] = useState<string>(""); | |
| const [copyStatus, setCopyStatus] = useState<{ [key: string]: 'success' | 'error' | null }>({}); | |
| const [copyAllStatus, setCopyAllStatus] = useState<'success' | 'error' | null>(null); | |
| const { isLoaded, runInference } = useVLMContext(); | |
| const abortControllerRef = useRef<AbortController | null>(null); | |
| // Create preview URL for selected image | |
| useEffect(() => { | |
| if (images[currentImageIndex]) { | |
| const url = URL.createObjectURL(images[currentImageIndex]); | |
| setSelectedImageUrl(url); | |
| return () => URL.revokeObjectURL(url); | |
| } | |
| }, [images, currentImageIndex]); | |
| const analyzeAllImages = useCallback(async () => { | |
| if (!isLoaded || isAnalyzing) return; | |
| setIsAnalyzing(true); | |
| setResults([]); | |
| abortControllerRef.current?.abort(); | |
| abortControllerRef.current = new AbortController(); | |
| const analysisResults: ImageAnalysisResult[] = []; | |
| try { | |
| for (let i = 0; i < images.length; i++) { | |
| if (abortControllerRef.current.signal.aborted) break; | |
| setCurrentImageIndex(i); | |
| const file = images[i]; | |
| try { | |
| const caption = await runInference(file, currentPrompt); | |
| analysisResults.push({ file, caption }); | |
| } catch (error) { | |
| const errorMsg = error instanceof Error ? error.message : String(error); | |
| analysisResults.push({ file, caption: "", error: errorMsg }); | |
| } | |
| setResults([...analysisResults]); | |
| } | |
| } catch (error) { | |
| console.error("Analysis interrupted:", error); | |
| } finally { | |
| setIsAnalyzing(false); | |
| } | |
| }, [images, currentPrompt, isLoaded, runInference, isAnalyzing]); | |
| const handlePromptChange = useCallback((prompt: string) => { | |
| setCurrentPrompt(prompt); | |
| }, []); | |
| const handleImageSelect = useCallback((index: number) => { | |
| setCurrentImageIndex(index); | |
| }, []); | |
| const copyToClipboard = useCallback(async (text: string, itemKey?: string) => { | |
| try { | |
| await navigator.clipboard.writeText(text); | |
| if (itemKey) { | |
| setCopyStatus(prev => ({ ...prev, [itemKey]: 'success' })); | |
| setTimeout(() => setCopyStatus(prev => ({ ...prev, [itemKey]: null })), 2000); | |
| } | |
| return true; | |
| } catch (error) { | |
| console.error('Failed to copy text:', error); | |
| try { | |
| // Fallback for older browsers | |
| const textArea = document.createElement('textarea'); | |
| textArea.value = text; | |
| document.body.appendChild(textArea); | |
| textArea.select(); | |
| document.execCommand('copy'); | |
| document.body.removeChild(textArea); | |
| if (itemKey) { | |
| setCopyStatus(prev => ({ ...prev, [itemKey]: 'success' })); | |
| setTimeout(() => setCopyStatus(prev => ({ ...prev, [itemKey]: null })), 2000); | |
| } | |
| return true; | |
| } catch (fallbackError) { | |
| if (itemKey) { | |
| setCopyStatus(prev => ({ ...prev, [itemKey]: 'error' })); | |
| setTimeout(() => setCopyStatus(prev => ({ ...prev, [itemKey]: null })), 2000); | |
| } | |
| return false; | |
| } | |
| } | |
| }, []); | |
| const stopAnalysis = useCallback(() => { | |
| abortControllerRef.current?.abort(); | |
| setIsAnalyzing(false); | |
| }, []); | |
| const copyAllCaptions = useCallback(async () => { | |
| const captionsText = results | |
| .filter(result => result.caption && !result.error) | |
| .map((result, index) => `Image ${index + 1} (${result.file.name}): ${result.caption}`) | |
| .join('\n\n'); | |
| if (captionsText) { | |
| const success = await copyToClipboard(captionsText); | |
| setCopyAllStatus(success ? 'success' : 'error'); | |
| setTimeout(() => setCopyAllStatus(null), 2000); | |
| } | |
| }, [results, copyToClipboard]); | |
| useEffect(() => { | |
| return () => { | |
| abortControllerRef.current?.abort(); | |
| }; | |
| }, []); | |
| return ( | |
| <div className="absolute inset-0 text-white"> | |
| {/* Main image display */} | |
| <div className="relative w-full h-full flex"> | |
| {/* Image preview */} | |
| <div className="flex-1 flex items-center justify-center p-8"> | |
| {selectedImageUrl && ( | |
| <img | |
| src={selectedImageUrl} | |
| alt={`Preview of ${images[currentImageIndex]?.name}`} | |
| className="max-w-full max-h-full object-contain rounded-lg shadow-2xl" | |
| /> | |
| )} | |
| </div> | |
| {/* Sidebar with image thumbnails and results */} | |
| <div className="w-80 bg-black/20 backdrop-blur-sm border-l border-white/20 overflow-y-auto"> | |
| {/* Controls */} | |
| <div className="p-4 border-b border-white/20"> | |
| <div className="flex gap-2 mb-4"> | |
| <GlassButton onClick={onBackToUpload} className="flex-1"> | |
| Back to Upload | |
| </GlassButton> | |
| {!isAnalyzing ? ( | |
| <GlassButton | |
| onClick={analyzeAllImages} | |
| disabled={!isLoaded} | |
| className="flex-1" | |
| > | |
| Analyze All | |
| </GlassButton> | |
| ) : ( | |
| <GlassButton onClick={stopAnalysis} className="flex-1 bg-red-500/20"> | |
| Stop | |
| </GlassButton> | |
| )} | |
| </div> | |
| {results.length > 0 && !isAnalyzing && ( | |
| <div className="mb-4"> | |
| <GlassButton | |
| onClick={copyAllCaptions} | |
| className={`w-full text-sm transition-colors ${ | |
| copyAllStatus === 'success' ? 'bg-green-500/20 border-green-400/30' : | |
| copyAllStatus === 'error' ? 'bg-red-500/20 border-red-400/30' : '' | |
| }`} | |
| disabled={results.filter(r => r.caption && !r.error).length === 0} | |
| > | |
| {copyAllStatus === 'success' ? ( | |
| <> | |
| <svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> | |
| </svg> | |
| Copied! | |
| </> | |
| ) : copyAllStatus === 'error' ? ( | |
| <> | |
| <svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> | |
| </svg> | |
| Failed to Copy | |
| </> | |
| ) : ( | |
| <> | |
| <svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /> | |
| </svg> | |
| Copy All Captions | |
| </> | |
| )} | |
| </GlassButton> | |
| </div> | |
| )} | |
| {isAnalyzing && ( | |
| <div className="text-sm text-white/70 text-center"> | |
| Analyzing image {currentImageIndex + 1} of {images.length}... | |
| </div> | |
| )} | |
| </div> | |
| {/* Image list with results */} | |
| <div className="p-4 space-y-4"> | |
| {images.map((file, index) => { | |
| const result = results.find(r => r.file === file); | |
| const isSelected = index === currentImageIndex; | |
| const isProcessing = isAnalyzing && index === currentImageIndex; | |
| return ( | |
| <div | |
| key={`${file.name}-${index}`} | |
| className={`cursor-pointer transition-all duration-200 ${ | |
| isSelected ? 'ring-2 ring-blue-400' : '' | |
| }`} | |
| onClick={() => handleImageSelect(index)} | |
| > | |
| <GlassContainer | |
| bgColor={isSelected ? GLASS_EFFECTS.COLORS.BUTTON_BG : GLASS_EFFECTS.COLORS.DEFAULT_BG} | |
| className="p-3 rounded-lg" | |
| > | |
| <div className="flex items-start gap-3"> | |
| {/* Thumbnail */} | |
| <div className="w-16 h-16 bg-gray-700 rounded flex items-center justify-center text-xs flex-shrink-0"> | |
| <img | |
| src={URL.createObjectURL(file)} | |
| alt={file.name} | |
| className="w-full h-full object-cover rounded" | |
| onLoad={(e) => URL.revokeObjectURL((e.target as HTMLImageElement).src)} | |
| /> | |
| </div> | |
| {/* Content */} | |
| <div className="flex-1 min-w-0"> | |
| <div className="text-sm font-medium truncate mb-1"> | |
| {file.name} | |
| </div> | |
| {isProcessing && ( | |
| <div className="text-xs text-blue-400"> | |
| Processing... | |
| </div> | |
| )} | |
| {result && ( | |
| <div className="text-xs"> | |
| {result.error ? ( | |
| <div className="text-red-400"> | |
| Error: {result.error} | |
| </div> | |
| ) : ( | |
| <div className="space-y-2"> | |
| <div className="text-white/80"> | |
| {result.caption} | |
| </div> | |
| {result.caption && ( | |
| <button | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| const itemKey = `${file.name}-${index}`; | |
| copyToClipboard(result.caption, itemKey); | |
| }} | |
| className={`flex items-center gap-1 transition-colors ${ | |
| copyStatus[`${file.name}-${index}`] === 'success' ? 'text-green-400' : | |
| copyStatus[`${file.name}-${index}`] === 'error' ? 'text-red-400' : | |
| 'text-blue-400 hover:text-blue-300' | |
| }`} | |
| title="Copy caption" | |
| > | |
| {copyStatus[`${file.name}-${index}`] === 'success' ? ( | |
| <> | |
| <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> | |
| </svg> | |
| <span className="text-xs">Copied!</span> | |
| </> | |
| ) : copyStatus[`${file.name}-${index}`] === 'error' ? ( | |
| <> | |
| <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> | |
| </svg> | |
| <span className="text-xs">Failed</span> | |
| </> | |
| ) : ( | |
| <> | |
| <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /> | |
| </svg> | |
| <span className="text-xs">Copy</span> | |
| </> | |
| )} | |
| </button> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </GlassContainer> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| </div> | |
| {/* Draggable Prompt Input - Bottom Left */} | |
| <DraggableContainer initialPosition="bottom-left"> | |
| <PromptInput | |
| onPromptChange={handlePromptChange} | |
| disabled={isAnalyzing} | |
| /> | |
| </DraggableContainer> | |
| </div> | |
| ); | |
| } |