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 Match; if (status === 'mismatch') return Mismatch; return Pending; }; const Header = () => (

Subtitle QC Assistant

Running securely on Hugging Face Spaces

Mode: Sequential Sort (Time Ascending)
Smart Size-Based Batching (Max 15MB/call)
); 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 (
0 ? 'border-green-400 bg-green-50' : ''}`} onDragOver={(e)=>{e.preventDefault(); setIsDraggingSrt(true)}} onDragLeave={()=>setIsDraggingSrt(false)} onDrop={handleSrtDrop} > processSrtFile(e.target.files[0])} className="hidden" id="srt-upload" />
0 ? 'border-green-400 bg-green-50' : ''}`} onDragOver={(e)=>{e.preventDefault(); setIsDraggingImages(true)}} onDragLeave={()=>setIsDraggingImages(false)} onDrop={handleImgDrop} > processImageFiles(Array.from(e.target.files))} className="hidden" id="img-upload" />
{isProcessing &&
{loadingMessage}
} {error &&
{error}
} {pairs.length > 0 && (

Review ({pairs.length})

{pairs.map((pair, index) => (
{pair.image?.dataUrl && }
{index + 1}. {pair.image?.originalFile?.name}
Expected
{pair.matchNote}
{pair.subtitle.text}
Actual
{pair.analysis ?
Detected: "{pair.analysis.detected_text}"{pair.analysis.reason}
: "Ready..."}
))}
)}
); }