srtchecktest / src /App.jsx
bigbossmonster's picture
Upload 5 files
eef0607 verified
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>
);
}