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 = () => (
);
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 (
{isProcessing && {loadingMessage}
}
{error && }
{pairs.length > 0 && (
Review ({pairs.length})
{pairs.map((pair, index) => (
{pair.image?.dataUrl &&

}
{index + 1}. {pair.image?.originalFile?.name}
{pair.analysis ?
Detected: "{pair.analysis.detected_text}"{pair.analysis.reason}
: "Ready..."}
))}
)}
);
}