autocaption-webgpu / src /components /ImageAnalysisView.tsx
Seym0n's picture
feat: caption copy
ea25548
raw
history blame
14.1 kB
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>
);
}