Spaces:
Sleeping
Sleeping
| <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... 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> |