Spaces:
Build error
Build error
| import React, { useState, useEffect } from 'react'; | |
| import { FileText, Image as LucideImage, CheckCircle, XCircle, Play, AlertCircle, Loader2, Trash2, ArrowRightLeft, UploadCloud, Clock } from 'lucide-react'; | |
| /* Subtitle Verifier (Docker/HF Version) | |
| - Connects to local backend at /api/generate | |
| - No API Key required on frontend | |
| */ | |
| // --- Utility Functions --- | |
| const timeStringToMs = (timeStr) => { | |
| if (!timeStr) return 0; | |
| const [time, ms] = timeStr.replace(',', '.').split('.'); | |
| const [hours, minutes, seconds] = time.split(':').map(Number); | |
| return (hours * 3600000) + (minutes * 60000) + (seconds * 1000) + (Number(ms) || 0); | |
| }; | |
| const filenameToMs = (filename) => { | |
| const match = filename.match(/(\d{1,2})_(\d{2})_(\d{2})_(\d{3})/); | |
| if (!match) return null; | |
| const [_, h, m, s, ms] = match; | |
| return (Number(h) * 3600000) + (Number(m) * 60000) + (Number(s) * 1000) + Number(ms); | |
| }; | |
| const msToTime = (duration) => { | |
| const milliseconds = Math.floor((duration % 1000)); | |
| const seconds = Math.floor((duration / 1000) % 60); | |
| const minutes = Math.floor((duration / (1000 * 60)) % 60); | |
| const hours = Math.floor((duration / (1000 * 60 * 60)) % 24); | |
| const pad = (num, size = 2) => num.toString().padStart(size, '0'); | |
| return `${pad(hours)}:${pad(minutes)}:${pad(seconds)},${pad(milliseconds, 3)}`; | |
| }; | |
| const parseSRT = (data) => { | |
| if (!data) return []; | |
| const normalized = data.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); | |
| const blocks = normalized.split('\n\n'); | |
| return blocks.map(block => { | |
| const lines = block.trim().split('\n'); | |
| if (lines.length < 2) return null; | |
| const timeLine = lines[1]; | |
| if (!timeLine || !timeLine.includes('-->')) return null; | |
| const startTimeStr = timeLine.split('-->')[0].trim(); | |
| return { | |
| id: lines[0], | |
| time: timeLine, | |
| startTimeMs: timeStringToMs(startTimeStr), | |
| text: lines.length > 2 ? lines.slice(2).join(' ') : "[BLANK SUBTITLE]" | |
| }; | |
| }).filter(Boolean); | |
| }; | |
| const compressImage = async (file, maxWidth = 800) => { | |
| return new Promise((resolve) => { | |
| const reader = new FileReader(); | |
| reader.readAsDataURL(file); | |
| reader.onload = (event) => { | |
| const img = new Image(); | |
| img.src = event.target.result; | |
| img.onload = () => { | |
| const canvas = document.createElement('canvas'); | |
| let width = img.width; | |
| let height = img.height; | |
| if (width > maxWidth) { | |
| height = (height * maxWidth) / width; | |
| width = maxWidth; | |
| } | |
| canvas.width = width; | |
| canvas.height = height; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.drawImage(img, 0, 0, width, height); | |
| const dataUrl = canvas.toDataURL('image/jpeg', 0.7); | |
| resolve({ | |
| originalFile: file, | |
| dataUrl: dataUrl, | |
| base64: dataUrl.split(',')[1], | |
| timeMs: filenameToMs(file.name) | |
| }); | |
| }; | |
| img.onerror = () => { resolve(null); }; | |
| }; | |
| reader.onerror = () => { resolve(null); }; | |
| }); | |
| }; | |
| const StatusBadge = ({ status }) => { | |
| if (status === 'match') return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"><CheckCircle className="w-3 h-3 mr-1"/> Match</span>; | |
| if (status === 'mismatch') return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800"><XCircle className="w-3 h-3 mr-1"/> Mismatch</span>; | |
| return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">Pending</span>; | |
| }; | |
| const Header = () => ( | |
| <header className="bg-slate-900 text-white p-6 shadow-lg"> | |
| <div className="max-w-6xl mx-auto flex items-center justify-between"> | |
| <div className="flex items-center space-x-3"> | |
| <div className="bg-blue-600 p-2 rounded-lg"> | |
| <FileText className="w-6 h-6 text-white" /> | |
| </div> | |
| <div> | |
| <h1 className="text-xl font-bold tracking-tight">Subtitle QC Assistant</h1> | |
| <p className="text-slate-400 text-sm">Running securely on Hugging Face Spaces</p> | |
| </div> | |
| </div> | |
| <div className="hidden md:block text-xs text-slate-500 text-right"> | |
| Mode: Sequential Sort (Time Ascending)<br/> | |
| Smart Size-Based Batching (Max 15MB/call) | |
| </div> | |
| </div> | |
| </header> | |
| ); | |
| export default function App() { | |
| const [srtData, setSrtData] = useState([]); | |
| const [images, setImages] = useState([]); | |
| const [pairs, setPairs] = useState([]); | |
| const [isProcessing, setIsProcessing] = useState(false); | |
| const [processedCount, setProcessedCount] = useState(0); | |
| const [error, setError] = useState(null); | |
| const [loadingMessage, setLoadingMessage] = useState(''); | |
| const [isDraggingSrt, setIsDraggingSrt] = useState(false); | |
| const [isDraggingImages, setIsDraggingImages] = useState(false); | |
| useEffect(() => { | |
| if (srtData.length > 0 && images.length > 0) { | |
| const sortedImages = [...images].sort((a, b) => { | |
| if (a.timeMs !== null && b.timeMs !== null) return a.timeMs - b.timeMs; | |
| return a.originalFile.name.localeCompare(b.originalFile.name, undefined, { numeric: true, sensitivity: 'base' }); | |
| }); | |
| const sortedSrt = [...srtData].sort((a, b) => a.startTimeMs - b.startTimeMs); | |
| const newPairs = sortedImages.map((img, index) => { | |
| const matchedSubtitle = sortedSrt[index]; | |
| let matchNote = "Sequential Match"; | |
| let timeDiff = 0; | |
| if (!matchedSubtitle) matchNote = "No SRT Line Available"; | |
| else if (img.timeMs !== null) { | |
| timeDiff = Math.abs(matchedSubtitle.startTimeMs - img.timeMs); | |
| if (timeDiff > 2000) matchNote = `Time Gap: ${msToTime(timeDiff)}`; | |
| else matchNote = "Synced & Sorted"; | |
| } | |
| return { | |
| id: index, | |
| image: img, | |
| subtitle: matchedSubtitle || { text: "(End of SRT file - no line #"+(index+1)+")", time: "--:--", id: "N/A" }, | |
| matchNote: matchNote, | |
| timeDiff: timeDiff, | |
| status: 'pending', | |
| analysis: null | |
| }; | |
| }); | |
| setPairs(newPairs); | |
| } | |
| }, [srtData, images]); | |
| const processSrtFile = (file) => { | |
| if (!file) return; | |
| const reader = new FileReader(); | |
| reader.onload = (evt) => { | |
| try { | |
| const parsed = parseSRT(evt.target.result); | |
| setSrtData(parsed); | |
| setPairs([]); setProcessedCount(0); | |
| } catch (err) { setError("Invalid SRT file format."); } | |
| }; | |
| reader.readAsText(file); | |
| }; | |
| const processImageFiles = async (filesArray) => { | |
| if (filesArray.length === 0) return; | |
| setLoadingMessage(`Compressing ${filesArray.length} images...`); | |
| setIsProcessing(true); setError(null); | |
| try { | |
| const processedResults = []; | |
| const chunkSize = 20; | |
| for (let i = 0; i < filesArray.length; i += chunkSize) { | |
| const chunk = filesArray.slice(i, i + chunkSize); | |
| const results = await Promise.all(chunk.map(file => compressImage(file))); | |
| processedResults.push(...results); | |
| } | |
| const successfulImages = processedResults.filter(Boolean); | |
| if (successfulImages.length === 0) throw new Error("No valid images could be processed."); | |
| setImages(successfulImages); setPairs([]); setProcessedCount(0); | |
| } catch (err) { setError("Failed to process images: " + err.message); } | |
| finally { setIsProcessing(false); setLoadingMessage(''); } | |
| }; | |
| const runAnalysis = async () => { | |
| if (pairs.length === 0) return; | |
| setIsProcessing(true); setError(null); setProcessedCount(0); | |
| const MAX_PAYLOAD_BYTES = 15 * 1024 * 1024; | |
| let currentPairs = [...pairs]; | |
| let batchQueue = []; | |
| let currentBatch = []; | |
| let currentBatchSize = 0; | |
| for (let i = 0; i < currentPairs.length; i++) { | |
| const pair = currentPairs[i]; | |
| const imgSize = pair.image?.base64?.length || 0; | |
| const promptOverhead = 1000; | |
| if (currentBatchSize + imgSize + promptOverhead > MAX_PAYLOAD_BYTES && currentBatch.length > 0) { | |
| batchQueue.push(currentBatch); | |
| currentBatch = []; | |
| currentBatchSize = 0; | |
| } | |
| currentBatch.push(i); | |
| currentBatchSize += (imgSize + promptOverhead); | |
| } | |
| if (currentBatch.length > 0) batchQueue.push(currentBatch); | |
| try { | |
| for (let b = 0; b < batchQueue.length; b++) { | |
| const indices = batchQueue[b]; | |
| const batchSizeBytes = indices.reduce((acc, idx) => acc + (currentPairs[idx].image?.base64?.length || 0), 0); | |
| const batchSizeMB = (batchSizeBytes / (1024 * 1024)).toFixed(2); | |
| setLoadingMessage(`Analyzing batch ${b + 1} of ${batchQueue.length} (${indices.length} items, ~${batchSizeMB}MB)...`); | |
| const contents = [{ | |
| parts: [{ text: `You are a Subtitle Quality Control (QC) bot. I will provide ${indices.length} images and the EXPECTED subtitle text. Return a JSON array strictly following this schema: [{ "index": 0, "detected_text": "...", "match": true/false, "reason": "..." }] Return ONLY the JSON.` }] | |
| }]; | |
| indices.forEach(idx => { | |
| const pair = currentPairs[idx]; | |
| if (pair.image && pair.image.base64) { | |
| contents[0].parts.push({ text: `\n--- Item ${idx} ---\nIndex: ${idx}\nExpected Text: "${pair.subtitle.text}"\nImage:` }); | |
| contents[0].parts.push({ inlineData: { mimeType: "image/jpeg", data: pair.image.base64 } }); | |
| } | |
| }); | |
| // CALL LOCAL BACKEND INSTEAD OF GOOGLE DIRECTLY | |
| const response = await fetch('/api/generate', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| contents: contents, | |
| generationConfig: { responseMimeType: "application/json" } | |
| }) | |
| }); | |
| if (!response.ok) throw new Error(`Server Error (Batch ${b+1}): ${response.status}`); | |
| const data = await response.json(); | |
| let parsedResults = []; | |
| try { | |
| const rawText = data.candidates?.[0]?.content?.parts?.[0]?.text; | |
| if (!rawText) throw new Error("No text in response"); | |
| parsedResults = JSON.parse(rawText); | |
| } catch(e) { throw new Error(`Failed to parse AI response for Batch ${b+1}`); } | |
| currentPairs = currentPairs.map((pair, idx) => { | |
| const res = parsedResults.find(r => r.index === idx); | |
| if (res) return { ...pair, status: res.match ? 'match' : 'mismatch', analysis: res }; | |
| return pair; | |
| }); | |
| setPairs([...currentPairs]); | |
| setProcessedCount(prev => prev + indices.length); | |
| } | |
| } catch (err) { console.error(err); setError("Analysis interrupted: " + err.message); } | |
| finally { setIsProcessing(false); setLoadingMessage(''); } | |
| }; | |
| // Drag & Drop wrappers | |
| const handleSrtDrop = (e) => { e.preventDefault(); setIsDraggingSrt(false); if(e.dataTransfer.files.length) processSrtFile(e.dataTransfer.files[0]); }; | |
| const handleImgDrop = (e) => { e.preventDefault(); setIsDraggingImages(false); if(e.dataTransfer.files.length) processImageFiles(Array.from(e.dataTransfer.files)); }; | |
| const handleReset = () => { setSrtData([]); setImages([]); setPairs([]); setProcessedCount(0); setError(null); }; | |
| return ( | |
| <div className="min-h-screen bg-slate-50 text-slate-900 font-sans"> | |
| <Header /> | |
| <main className="max-w-6xl mx-auto p-6 space-y-8"> | |
| <section className="grid md:grid-cols-2 gap-6"> | |
| <div | |
| className={`border-2 border-dashed rounded-xl p-8 flex flex-col items-center justify-center transition-all ${isDraggingSrt ? 'border-blue-500 bg-blue-50' : 'border-slate-300 bg-white'} ${srtData.length > 0 ? 'border-green-400 bg-green-50' : ''}`} | |
| onDragOver={(e)=>{e.preventDefault(); setIsDraggingSrt(true)}} onDragLeave={()=>setIsDraggingSrt(false)} onDrop={handleSrtDrop} | |
| > | |
| <input type="file" accept=".srt" onChange={(e)=>processSrtFile(e.target.files[0])} className="hidden" id="srt-upload" /> | |
| <label htmlFor="srt-upload" className="cursor-pointer flex flex-col items-center text-center w-full"> | |
| <FileText className={`w-12 h-12 mb-4 ${srtData.length ? 'text-green-600' : 'text-slate-400'}`} /> | |
| <span className="font-semibold text-lg">{srtData.length ? `${srtData.length} Loaded` : 'Drag & Drop SRT'}</span> | |
| </label> | |
| </div> | |
| <div | |
| className={`border-2 border-dashed rounded-xl p-8 flex flex-col items-center justify-center transition-all ${isDraggingImages ? 'border-blue-500 bg-blue-50' : 'border-slate-300 bg-white'} ${images.length > 0 ? 'border-green-400 bg-green-50' : ''}`} | |
| onDragOver={(e)=>{e.preventDefault(); setIsDraggingImages(true)}} onDragLeave={()=>setIsDraggingImages(false)} onDrop={handleImgDrop} | |
| > | |
| <input type="file" accept="image/*" multiple onChange={(e)=>processImageFiles(Array.from(e.target.files))} className="hidden" id="img-upload" /> | |
| <label htmlFor="img-upload" className="cursor-pointer flex flex-col items-center text-center w-full"> | |
| {isProcessing ? <Loader2 className="w-12 h-12 mb-4 text-blue-500 animate-spin" /> : <LucideImage className={`w-12 h-12 mb-4 ${images.length ? 'text-green-600' : 'text-slate-400'}`} />} | |
| <span className="font-semibold text-lg">{images.length ? `${images.length} Loaded` : 'Drag & Drop Images'}</span> | |
| </label> | |
| </div> | |
| </section> | |
| {isProcessing && <div className="bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-center justify-center space-x-3 text-blue-800 animate-pulse"><Loader2 className="w-5 h-5 animate-spin" /><span className="font-medium">{loadingMessage}</span></div>} | |
| {error && <div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center space-x-3 text-red-800"><AlertCircle className="w-5 h-5" /><span>{error}</span></div>} | |
| {pairs.length > 0 && ( | |
| <section className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden"> | |
| <div className="p-4 border-b border-slate-200 bg-slate-50 flex justify-between items-center sticky top-0 z-10"> | |
| <h2 className="font-bold text-lg">Review ({pairs.length})</h2> | |
| <div className="flex space-x-3"> | |
| <button onClick={handleReset} className="flex items-center space-x-2 px-4 py-2 text-sm font-medium text-slate-600 hover:text-red-600"><Trash2 className="w-4 h-4" /><span>Clear</span></button> | |
| <button onClick={runAnalysis} disabled={isProcessing} className={`flex items-center space-x-2 px-6 py-2 rounded-lg font-semibold text-white shadow-md ${isProcessing ? 'bg-slate-400' : 'bg-blue-600 hover:bg-blue-700'}`}> | |
| {isProcessing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />} | |
| <span>{processedCount > 0 ? 'Continue' : 'Run'}</span> | |
| </button> | |
| </div> | |
| </div> | |
| <div className="divide-y divide-slate-100"> | |
| {pairs.map((pair, index) => ( | |
| <div key={index} className={`grid grid-cols-12 gap-4 p-4 ${pair.status === 'mismatch' ? 'bg-red-50/50' : ''}`}> | |
| <div className="col-span-12 md:col-span-3 relative aspect-video bg-slate-100 rounded-lg overflow-hidden border border-slate-200"> | |
| {pair.image?.dataUrl && <img src={pair.image.dataUrl} className="w-full h-full object-contain" />} | |
| <div className="absolute top-0 left-0 right-0 bg-black/60 p-1"><div className="text-white text-[10px] truncate font-mono">{index + 1}. {pair.image?.originalFile?.name}</div></div> | |
| </div> | |
| <div className="col-span-12 md:col-span-4 flex flex-col justify-center"> | |
| <div className="flex justify-between mb-1"><div className="text-xs font-bold text-slate-400 uppercase">Expected</div><div className="text-[10px] px-2 py-0.5 rounded-full bg-blue-100 text-blue-700">{pair.matchNote}</div></div> | |
| <div className="p-3 bg-slate-100 rounded-lg text-slate-700 text-sm">{pair.subtitle.text}</div> | |
| </div> | |
| <div className="col-span-12 md:col-span-5 flex flex-col justify-center"> | |
| <div className="flex justify-between mb-1"><div className="text-xs font-bold text-slate-400 uppercase">Actual</div><StatusBadge status={pair.status} /></div> | |
| <div className={`p-3 rounded-lg text-sm border min-h-[60px] flex items-center ${pair.status === 'match' ? 'bg-green-50 text-green-800' : pair.status === 'mismatch' ? 'bg-red-50 text-red-800' : 'bg-white'}`}> | |
| {pair.analysis ? <div><span className="font-semibold block mb-1">Detected: "{pair.analysis.detected_text}"</span><span className="text-xs opacity-75">{pair.analysis.reason}</span></div> : "Ready..."} | |
| </div> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </section> | |
| )} | |
| </main> | |
| </div> | |
| ); | |
| } |