testing / static /index.html
bigbossmonster's picture
Update static/index.html
d1afead verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Subtitle QC Assistant</title>
<!-- Libraries -->
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; }
</style>
</head>
<body class="bg-slate-50 text-slate-900">
<div id="root"></div>
<script type="text/babel">
const { useState, useEffect, useMemo, useCallback } = React;
// ==========================================
// Constants & Config
// ==========================================
const DEFAULT_CONFIG = {
apiKeys: '',
batchSize: 20,
modelName: 'gemini-3-flash-preview',
quality: 0.7
};
// ==========================================
// Icons
// ==========================================
const Icon = ({ d, className }) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><path d={d}/></svg>
);
const Icons = {
Upload: (props) => <Icon d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12" {...props} />,
Check: (props) => <Icon d="M22 11.08V12a10 10 0 1 1-5.93-9.14 22 4 12 14.01 9 11.01" {...props} />,
X: (props) => <Icon d="M18 6 6 18M6 6l12 12" {...props} />,
Download: (props) => <Icon d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3" {...props} />,
Settings: (props) => <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>,
Zap: (props) => <Icon d="M13 2 3 14h9l-1 8 10-12h-9l1-8z" {...props} />,
Refresh: (props) => <Icon d="M23 4v6h-6M1 20v-6h6M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" {...props} />,
Clock: (props) => <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>,
Archive: (props) => <Icon d="M21 8v13H3V8M1 3h22v5H1zM10 12h4" {...props} />,
FileText: (props) => <Icon d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" {...props} />,
Spinner: ({className}) => <svg className={`animate-spin ${className}`} viewBox="0 0 24 24"><path fill="none" d="M0 0h24v24H0z"/><path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2zm0 18a8 8 0 1 1 8-8 8 8 0 0 1-8 8z" opacity=".3"/><path d="M12 4a8 8 0 0 1 7.8 6.6L22 10a10 10 0 0 0-19.6 0L4.2 10.6A8 8 0 0 1 12 4z"/></svg>
};
// ==========================================
// Logic / Helpers
// ==========================================
const Helpers = {
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);
},
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);
},
// In index.html -> Helpers object
parseSRT: (data) => {
if (!data) return [];
const normalized = data.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
// Regex matches only the ID and Timestamp
const regex = /(\d+)\n(\d{2}:\d{2}:\d{2}[,.]\d{3}\s*-->\s*\d{2}:\d{2}:\d{2}[,.]\d{3})/g;
const parsed = [];
let match;
const matches = [];
// 1. Scan for all headers first
while ((match = regex.exec(normalized)) !== null) {
matches.push({
id: match[1],
time: match[2],
index: match.index,
endHeader: match.index + match[0].length
});
}
// 2. Extract content between headers
matches.forEach((m, i) => {
const nextMatch = matches[i + 1];
const startText = m.endHeader;
const endText = nextMatch ? nextMatch.index : normalized.length;
// Get text and Trim
const textRaw = normalized.substring(startText, endText).trim();
// Use "[EMPTY]" placeholder if you want to see it visually, or keep it empty
const text = textRaw || "";
const startTimeStr = m.time.split('-->')[0].trim();
parsed.push({
id: m.id,
time: m.time,
startTimeMs: Helpers.timeStringToMs(startTimeStr),
text: text
});
});
return parsed;
}
};
// ==========================================
// Sub-Components
// ==========================================
const StatusBadge = ({ status }) => {
const styles = {
match: "bg-green-100 text-green-800",
mismatch: "bg-red-100 text-red-800",
pending: "bg-gray-100 text-gray-800"
};
const icons = {
match: <Icons.Check className="w-3 h-3 mr-1"/>,
mismatch: <Icons.X className="w-3 h-3 mr-1"/>,
pending: null
};
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${styles[status] || styles.pending}`}>
{icons[status]} {status.charAt(0).toUpperCase() + status.slice(1)}
</span>
);
};
const Header = ({ onOpenSettings, activeWorkers }) => (
<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">
<Icons.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">Verify image subtitles against SRT files using Gemini</p>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="hidden md:block text-xs text-slate-500 text-right">
{activeWorkers > 0 ? (
<span className="text-green-400 font-bold flex items-center justify-end gap-1">
<Icons.Zap className="w-3 h-3 animate-pulse"/>
{activeWorkers} Parallel Key(s) Active
</span>
) : (
"Mode: Sequential Sort (Time Ascending)"
)}
<br/>
Supports Multi-Key Parallel Batching
</div>
<button onClick={onOpenSettings} className="p-2 bg-slate-800 hover:bg-slate-700 rounded-full transition-colors text-slate-300 hover:text-white" title="API Key Settings">
<Icons.Settings className="w-5 h-5" />
</button>
</div>
</div>
</header>
);
const SettingsModal = ({ isOpen, onClose, config, setConfig }) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
<div className="bg-white rounded-xl shadow-2xl max-w-md w-full p-6 animate-in zoom-in duration-200">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-bold text-slate-800">Configuration</h3>
<button onClick={onClose} className="text-slate-400 hover:text-slate-600"><Icons.X className="w-6 h-6" /></button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Gemini API Keys</label>
<textarea value={config.apiKeys} onChange={(e) => setConfig({...config, apiKeys: e.target.value})} placeholder="AIzaSy...&#10;AIzaSy..." rows={3} className="w-full p-2 border border-slate-300 rounded-lg text-xs font-mono" />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Model</label>
<select value={config.modelName} onChange={(e) => setConfig({...config, modelName: e.target.value})} className="w-full p-2 border border-slate-300 rounded-lg text-sm">
<option value="gemini-3-flash-preview">Gemini 3 Flash</option>
<option value="gemini-2.0-flash">Gemini 2.0 Flash</option>
<option value="gemini-1.5-flash">Gemini 1.5 Flash</option>
<option value="gemini-1.5-pro">Gemini 1.5 Pro</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Batch Size</label>
<input type="number" min="1" max="50" value={config.batchSize} onChange={(e) => setConfig({...config, batchSize: Number(e.target.value)})} className="w-full p-2 border border-slate-300 rounded-lg text-sm" />
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Compression Quality: <span className="text-blue-600 font-bold">{config.quality}</span></label>
<input type="range" min="0.1" max="1.0" step="0.1" value={config.quality} onChange={(e) => setConfig({...config, quality: Number(e.target.value)})} className="w-full h-2 bg-slate-200 rounded-lg cursor-pointer" />
</div>
<div className="flex justify-end pt-2">
<button onClick={onClose} className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">Save & Close</button>
</div>
</div>
</div>
</div>
);
};
const UploadSection = ({ srtFile, setSrtFile, mediaFiles, setMediaFiles }) => {
const [isDraggingSrt, setIsDraggingSrt] = useState(false);
const [isDraggingMedia, setIsDraggingMedia] = useState(false);
return (
<section className="grid md:grid-cols-2 gap-6">
<div
className={`relative border-2 border-dashed rounded-xl p-8 flex flex-col items-center justify-center transition-all duration-200
${isDraggingSrt ? 'border-blue-500 bg-blue-50 scale-[1.02] shadow-xl' : 'border-slate-300 hover:border-blue-400 bg-white'}
${srtFile ? 'border-green-400 bg-green-50' : ''}`}
onDragOver={(e) => { e.preventDefault(); setIsDraggingSrt(true); }}
onDragLeave={(e) => { e.preventDefault(); setIsDraggingSrt(false); }}
onDrop={(e) => {
e.preventDefault(); setIsDraggingSrt(false);
if(e.dataTransfer.files.length) setSrtFile(e.dataTransfer.files[0]);
}}
>
<input type="file" accept=".srt" onChange={(e) => setSrtFile(e.target.files[0])} className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" />
<div className="pointer-events-none text-center">
<div className="flex justify-center mb-2"><Icons.Upload className={`w-8 h-8 ${srtFile ? 'text-green-500' : 'text-slate-400'}`} /></div>
<div className="font-semibold text-sm">{srtFile ? srtFile.name : "Upload SRT File"}</div>
</div>
</div>
<div
className={`relative border-2 border-dashed rounded-xl p-8 flex flex-col items-center justify-center transition-all duration-200
${isDraggingMedia ? 'border-blue-500 bg-blue-50 scale-[1.02] shadow-xl' : 'border-slate-300 hover:border-blue-400 bg-white'}
${mediaFiles.length > 0 ? 'border-green-400 bg-green-50' : ''}`}
onDragOver={(e) => { e.preventDefault(); setIsDraggingMedia(true); }}
onDragLeave={(e) => { e.preventDefault(); setIsDraggingMedia(false); }}
onDrop={(e) => {
e.preventDefault(); setIsDraggingMedia(false);
if(e.dataTransfer.files.length) setMediaFiles(e.dataTransfer.files);
}}
>
<input type="file" accept="image/*,.rar,.zip" multiple onChange={(e) => setMediaFiles(e.target.files)} className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" />
<div className="pointer-events-none text-center">
<div className="flex justify-center space-x-2 mb-2">
<Icons.Upload className={`w-8 h-8 ${mediaFiles.length > 0 ? 'text-green-500' : 'text-slate-400'}`} />
<Icons.Archive className={`w-8 h-8 ${mediaFiles.length > 0 ? 'text-green-500' : 'text-slate-400'}`} />
</div>
<div className="font-semibold text-sm">{mediaFiles.length > 0 ? `${mediaFiles.length} Files` : "Upload Media (Img/RAR/ZIP)"}</div>
</div>
</div>
</section>
);
};
const ActionBar = ({ resultCount, pendingCount, isProcessing, onReset, onDownload, onAnalyze, onRetry }) => (
<div className="flex justify-between items-center bg-white p-4 rounded-xl shadow-sm border border-slate-200 sticky top-4 z-40 transition-all">
<div className="font-bold text-lg flex items-center gap-2">
{resultCount > 0 ? "Analysis Results" : "Preview Alignment"}
{pendingCount > 0 && <span className="bg-amber-100 text-amber-800 text-xs px-2 py-1 rounded-full">{pendingCount} Pending</span>}
</div>
<div className="flex gap-3">
<button onClick={onReset} className="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded-lg text-sm font-medium">Reset</button>
{pendingCount > 0 && !isProcessing && (
<button
onClick={onRetry}
className="flex items-center gap-2 px-4 py-2 bg-amber-100 hover:bg-amber-200 text-amber-800 rounded-lg font-bold shadow-sm transition-all"
>
<Icons.Refresh className="w-4 h-4" /> Retry Pending
</button>
)}
{resultCount > 0 ? (
<button onClick={onDownload} className="flex items-center gap-2 px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-bold shadow-sm transition-all">
<Icons.Download className="w-4 h-4" /> Download SRT
</button>
) : (
<button
onClick={onAnalyze}
disabled={isProcessing}
className={`flex items-center gap-2 px-6 py-2 rounded-lg font-bold text-white shadow-sm transition-all ${isProcessing ? 'bg-slate-400 cursor-wait' : 'bg-green-600 hover:bg-green-700'}`}
>
{isProcessing ? <Icons.Spinner className="w-4 h-4 animate-spin"/> : <Icons.Zap className="w-4 h-4"/>}
{isProcessing ? "Analyzing..." : "Analyze with Gemini"}
</button>
)}
</div>
</div>
);
const PreviewList = ({ items, archiveMode }) => (
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden divide-y divide-slate-100">
{archiveMode && <div className="p-3 bg-amber-50 text-amber-800 text-sm text-center border-b border-amber-100">📦 Archive mode active. Images will be extracted and paired on server.</div>}
{items.map((item) => (
<div key={item.id} className="grid grid-cols-12 gap-4 p-4 hover:bg-slate-50 transition-colors group">
<div className="col-span-3">
<div className="relative aspect-video bg-slate-100 rounded-lg overflow-hidden border border-slate-200">
{item.imgUrl ? (
<img src={item.imgUrl} className="w-full h-full object-contain" />
) : (
<div className="w-full h-full flex items-center justify-center text-slate-400 text-xs flex-col">
{item.isArchive ? <Icons.Archive className="w-6 h-6 mb-1"/> : <Icons.X className="w-6 h-6 mb-1"/>}
{item.isArchive ? "Server Side" : "No Image"}
</div>
)}
<div className="absolute bottom-0 left-0 right-0 bg-black/60 text-white text-[10px] p-1 truncate font-mono">{item.imgName}</div>
</div>
</div>
<div className="col-span-9 flex flex-col justify-center">
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-bold text-slate-400 uppercase">Line #{parseInt(item.id)}</span>
<span className="text-xs font-mono text-slate-500 bg-slate-100 px-2 py-0.5 rounded">{item.srt.time.split('-->')[0].trim()}</span>
</div>
<div className="text-sm font-medium text-slate-800 p-2 bg-slate-50 rounded border border-slate-100 group-hover:bg-white">{item.srt.text}</div>
</div>
</div>
))}
</div>
);
const ResultsList = ({ items }) => (
<div className="space-y-4">
{items.map((res, idx) => (
<div key={idx} className={`bg-white rounded-xl shadow-sm border p-4 flex gap-4 ${res.status === 'mismatch' ? 'border-red-200 bg-red-50' : (res.status === 'pending' ? 'border-gray-200 opacity-70' : 'border-slate-200')}`}>
<div className="w-40 bg-slate-100 rounded-lg overflow-hidden flex-shrink-0 border border-slate-200 relative">
<img src={res.thumb} className="w-full h-full object-cover" />
{res.status === 'pending' && <div className="absolute inset-0 bg-black/10 flex items-center justify-center"><Icons.Clock className="w-8 h-8 text-white opacity-80"/></div>}
</div>
<div className="flex-1 grid md:grid-cols-2 gap-6">
<div>
<div className="text-xs font-bold text-slate-400 uppercase mb-1">Original Text</div>
<div className="p-3 bg-slate-100/50 rounded-lg text-sm">{res.expected}</div>
<div className="mt-2 text-xs text-slate-400 font-mono">{res.filename}</div>
</div>
<div>
<div className="flex justify-between items-center mb-1">
<span className="text-xs font-bold text-slate-400 uppercase">AI Verified</span>
<StatusBadge status={res.status} />
</div>
<div className={`p-3 rounded-lg text-sm border ${res.status === 'mismatch' ? 'bg-white border-red-200 text-red-700' : 'bg-green-50 border-green-200 text-green-800'}`}>
{res.status === 'pending' ? 'Analysis Pending...' : (res.detected || "No Text Detected")}
</div>
{res.reason && <div className="text-xs text-slate-500 mt-2 italic">{res.reason}</div>}
</div>
</div>
</div>
))}
</div>
);
const EmptyState = () => (
<div className="text-center py-20 opacity-50">
<div className="w-16 h-16 bg-slate-200 rounded-full flex items-center justify-center mx-auto mb-4">
<Icons.Upload className="w-8 h-8 text-slate-400" />
</div>
<p className="text-lg font-medium text-slate-500">
Server-Side Processing Mode.<br/>
Upload files to sort, pair, and analyze remotely.
</p>
</div>
);
// ==========================================
// Main App
// ==========================================
const App = () => {
// --- State ---
const [config, setConfig] = useState(() => ({
apiKeys: localStorage.getItem('qc_api_keys') || DEFAULT_CONFIG.apiKeys,
batchSize: Number(localStorage.getItem('qc_batch_size')) || DEFAULT_CONFIG.batchSize,
modelName: localStorage.getItem('qc_model') || DEFAULT_CONFIG.modelName,
quality: Number(localStorage.getItem('qc_quality')) || DEFAULT_CONFIG.quality
}));
const [srtFile, setSrtFile] = useState(null);
const [mediaFiles, setMediaFiles] = useState([]);
const [previewItems, setPreviewItems] = useState([]);
const [archiveMode, setArchiveMode] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [results, setResults] = useState([]);
const [error, setError] = useState(null);
const [loadingMessage, setLoadingMessage] = useState('');
const [showSettings, setShowSettings] = useState(false);
// --- Effects ---
useEffect(() => {
localStorage.setItem('qc_api_keys', config.apiKeys);
localStorage.setItem('qc_batch_size', config.batchSize);
localStorage.setItem('qc_model', config.modelName);
localStorage.setItem('qc_quality', config.quality);
}, [config]);
// Preview Generator Logic
useEffect(() => {
const generatePreview = async () => {
if (!srtFile) {
setPreviewItems([]);
return;
}
const text = await srtFile.text();
const parsedSrt = Helpers.parseSRT(text);
parsedSrt.sort((a, b) => a.startTimeMs - b.startTimeMs);
const hasArchive = Array.from(mediaFiles).some(f => f.name.toLowerCase().endsWith('.rar') || f.name.toLowerCase().endsWith('.zip'));
setArchiveMode(hasArchive);
let displayItems = [];
if (hasArchive) {
displayItems = parsedSrt.map((srt, idx) => ({
id: srt.id,
srt: srt,
imgName: "Inside Archive",
imgUrl: null,
isArchive: true
}));
} else if (mediaFiles.length > 0) {
const images = Array.from(mediaFiles)
.filter(f => /\.(jpg|jpeg|png|webp|bmp)$/i.test(f.name))
.map(f => ({ file: f, time: Helpers.filenameToMs(f.name) }))
.filter(f => f.time !== null)
.sort((a, b) => a.time - b.time);
displayItems = parsedSrt.map((srt, idx) => {
const matchedImg = images[idx];
return {
id: srt.id,
srt: srt,
imgName: matchedImg ? matchedImg.file.name : "Missing Image",
imgUrl: matchedImg ? URL.createObjectURL(matchedImg.file) : null,
isArchive: false
};
});
} else {
displayItems = parsedSrt.map((srt, idx) => ({
id: srt.id,
srt: srt,
imgName: "Waiting for media...",
imgUrl: null,
isArchive: false
}));
}
setPreviewItems(displayItems);
};
generatePreview();
}, [srtFile, mediaFiles]);
// --- Handlers ---
const handleAnalyze = async () => {
if (!config.apiKeys.trim()) { setError("API Key missing."); setShowSettings(true); return; }
setIsProcessing(true);
setError(null);
setResults([]);
setLoadingMessage("Uploading and analyzing...");
const formData = new FormData();
formData.append("srt_file", srtFile);
for (let i = 0; i < mediaFiles.length; i++) {
formData.append("media_files", mediaFiles[i]);
}
formData.append("api_keys", config.apiKeys);
formData.append("batch_size", config.batchSize);
formData.append("model_name", config.modelName);
formData.append("compression_quality", config.quality);
try {
const response = await fetch('/api/analyze', { method: 'POST', body: formData });
if (!response.ok) throw new Error(await response.text());
const data = await response.json();
if (data.status === 'success') {
setResults(data.results);
setPreviewItems([]);
} else {
throw new Error(data.message);
}
} catch (err) {
setError(err.message);
} finally {
setIsProcessing(false);
setLoadingMessage('');
}
};
const handleRetryPending = async () => {
const pendingItems = results.filter(r => r.status === 'pending');
if (pendingItems.length === 0) return;
setIsProcessing(true);
setLoadingMessage(`Retrying ${pendingItems.length} items...`);
setError(null);
const filesToUpload = [];
const srtBlocks = [];
const mediaArray = Array.from(mediaFiles);
// Collect files for pending items
pendingItems.forEach(item => {
const file = mediaArray.find(f => f.name === item.filename);
if (file) {
filesToUpload.push(file);
srtBlocks.push(`${item.srt_id}\n${item.srt_time}\n${item.expected}`);
}
});
if (filesToUpload.length === 0) {
setError("Could not find source files for pending items. Archive mode retry not fully supported without re-upload.");
setIsProcessing(false);
return;
}
// Construct subset SRT
const srtContent = srtBlocks.join('\n\n');
const srtBlob = new Blob([srtContent], { type: 'text/plain' });
const srtFileObj = new File([srtBlob], "retry.srt", { type: "text/plain" });
const formData = new FormData();
formData.append("srt_file", srtFileObj);
filesToUpload.forEach(f => formData.append("media_files", f));
formData.append("api_keys", config.apiKeys);
formData.append("batch_size", config.batchSize);
formData.append("model_name", config.modelName);
formData.append("compression_quality", config.quality);
try {
const response = await fetch('/api/analyze', { method: 'POST', body: formData });
if (!response.ok) throw new Error(await response.text());
const data = await response.json();
if (data.status === 'success') {
// Merge new results into existing results
setResults(prevResults => {
const newResults = [...prevResults];
data.results.forEach(newRes => {
const idx = newResults.findIndex(r => r.srt_id === newRes.srt_id);
if (idx !== -1) {
newResults[idx] = newRes;
}
});
return newResults;
});
} else {
throw new Error(data.message);
}
} catch (err) {
setError("Retry failed: " + err.message);
} finally {
setIsProcessing(false);
setLoadingMessage('');
}
};
const handleDownload = () => {
let content = "";
results.forEach(r => {
const text = (r.status === 'mismatch' && r.detected) ? r.detected : r.expected;
content += `${r.srt_id}\n${r.srt_time}\n${text}\n\n`;
});
const url = URL.createObjectURL(new Blob([content], { type: 'text/plain' }));
const a = document.createElement('a');
a.href = url;
a.download = "corrected.srt";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
const handleReset = () => {
setSrtFile(null);
setMediaFiles([]);
setPreviewItems([]);
setResults([]);
setError(null);
};
// --- Render ---
const activeWorkers = isProcessing ? config.apiKeys.split('\n').filter(k => k.trim()).length : 0;
const hasContent = previewItems.length > 0 || results.length > 0;
const pendingCount = results.filter(r => r.status === 'pending').length;
return (
<div className="min-h-screen bg-slate-50 text-slate-900 font-sans pb-20">
<Header onOpenSettings={() => setShowSettings(true)} activeWorkers={activeWorkers} />
<SettingsModal isOpen={showSettings} onClose={() => setShowSettings(false)} config={config} setConfig={setConfig} />
<main className="max-w-6xl mx-auto p-6 space-y-6">
<UploadSection
srtFile={srtFile}
setSrtFile={setSrtFile}
mediaFiles={mediaFiles}
setMediaFiles={setMediaFiles}
/>
{error && (
<div className="bg-red-50 text-red-700 p-4 rounded-lg border border-red-200 flex items-center gap-2">
<Icons.X className="w-5 h-5"/>{error}
</div>
)}
{isProcessing && loadingMessage && (
<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">
<Icons.Spinner className="w-5 h-5" />
<span className="font-medium">{loadingMessage}</span>
</div>
)}
{hasContent && (
<ActionBar
previewCount={previewItems.length}
resultCount={results.length}
pendingCount={pendingCount}
isProcessing={isProcessing}
onReset={handleReset}
onDownload={handleDownload}
onAnalyze={handleAnalyze}
onRetry={handleRetryPending}
/>
)}
{previewItems.length > 0 && results.length === 0 && (
<PreviewList items={previewItems} archiveMode={archiveMode} />
)}
{results.length > 0 && (
<ResultsList items={results} />
)}
{!hasContent && !srtFile && <EmptyState />}
</main>
</div>
);
};
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
</script>
</body>
</html>