Spaces:
Running
Running
| <html lang="en" class="h-full scroll-smooth"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>RT Caption Generator</title> | |
| <!-- Favicon --> | |
| <link rel="icon" type="image/png" href="/static/transparent_SRT.png"> | |
| <!-- Tailwind CSS (CDN) --> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script> | |
| tailwind.config = { | |
| theme: { | |
| extend: { | |
| colors: { | |
| slate: { | |
| 950: '#0f172a', | |
| } | |
| }, | |
| animation: { | |
| 'spin-slow': 'spin 1.4s linear infinite', | |
| 'fade-up': 'fadeUp 0.5s ease-out both', | |
| 'fade-in': 'fadeIn 0.6s ease-out both', | |
| 'slide-in': 'slideIn 0.5s ease-out both', | |
| 'pulse-bar': 'pulseBar 1.8s ease-in-out infinite', | |
| 'shimmer': 'shimmer 2s infinite', | |
| 'glow': 'glow 2s ease-in-out infinite', | |
| }, | |
| keyframes: { | |
| fadeUp: { | |
| '0%': { opacity: '0', transform: 'translateY(16px)' }, | |
| '100%': { opacity: '1', transform: 'translateY(0)' }, | |
| }, | |
| fadeIn: { | |
| '0%': { opacity: '0' }, | |
| '100%': { opacity: '1' }, | |
| }, | |
| slideIn: { | |
| '0%': { transform: 'translateX(-20px)', opacity: '0' }, | |
| '100%': { transform: 'translateX(0)', opacity: '1' }, | |
| }, | |
| pulseBar: { | |
| '0%, 100%': { opacity: '1' }, | |
| '50%': { opacity: '0.6' }, | |
| }, | |
| shimmer: { | |
| '0%': { transform: 'translateX(-100%)' }, | |
| '100%': { transform: 'translateX(100%)' }, | |
| }, | |
| glow: { | |
| '0%, 100%': { boxShadow: '0 0 20px rgba(59, 130, 246, 0.5)' }, | |
| '50%': { boxShadow: '0 0 40px rgba(59, 130, 246, 0.8)' }, | |
| }, | |
| }, | |
| } | |
| }, | |
| darkMode: 'class', | |
| } | |
| </script> | |
| <!-- Alpine.js (CDN) --> | |
| <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script> | |
| <style> | |
| [x-cloak] { display: none ; } | |
| /* CSS variables for modern theming */ | |
| :root { | |
| --bg-primary: #0f172a; | |
| --bg-secondary: #1e293b; | |
| --bg-tertiary: #334155; | |
| --bg-card: #1a2744; | |
| --text-primary: #f8fafc; | |
| --text-secondary: #cbd5e1; | |
| --text-muted: #94a3b8; | |
| --border-color: #475569; | |
| --accent-blue: #3b82f6; | |
| --accent-purple: #a855f7; | |
| --accent-pink: #ec4899; | |
| --accent-cyan: #06b6d4; | |
| } | |
| .light-mode { | |
| --bg-primary: #f8fafc; | |
| --bg-secondary: #f1f5f9; | |
| --bg-tertiary: #e2e8f0; | |
| --bg-card: #ffffff; | |
| --text-primary: #0f172a; | |
| --text-secondary: #475569; | |
| --text-muted: #64748b; | |
| --border-color: #cbd5e1; | |
| --accent-blue: #2563eb; | |
| --accent-purple: #9333ea; | |
| --accent-pink: #db2777; | |
| --accent-cyan: #0891b2; | |
| } | |
| * { | |
| transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease; | |
| } | |
| body { | |
| background: linear-gradient(135deg, var(--bg-primary) 0%, #1a2744 50%, var(--bg-primary) 100%); | |
| color: var(--text-primary); | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', sans-serif; | |
| } | |
| /* Custom scrollbar */ | |
| ::-webkit-scrollbar { width: 8px; height: 8px; } | |
| ::-webkit-scrollbar-track { background: transparent; } | |
| ::-webkit-scrollbar-thumb { | |
| background: linear-gradient(180deg, var(--accent-blue), var(--accent-purple)); | |
| border-radius: 4px; | |
| } | |
| ::-webkit-scrollbar-thumb:hover { background: var(--accent-blue); } | |
| /* Drop zone hover glow */ | |
| .drop-zone-active { | |
| border-color: var(--accent-blue) ; | |
| background-color: rgba(59, 130, 246, 0.12) ; | |
| box-shadow: 0 0 30px rgba(59, 130, 246, 0.3) ; | |
| transform: scale(1.02); | |
| } | |
| /* Card styling */ | |
| .card { | |
| background: var(--bg-card); | |
| border: 1px solid var(--border-color); | |
| border-radius: 12px; | |
| backdrop-filter: blur(10px); | |
| } | |
| /* Smooth progress bar */ | |
| .progress-bar { | |
| transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1); | |
| background: linear-gradient(90deg, var(--accent-blue), var(--accent-cyan)); | |
| } | |
| /* Button styles */ | |
| .btn-primary { | |
| background: linear-gradient(135deg, var(--accent-blue), var(--accent-purple)); | |
| color: white; | |
| font-weight: 600; | |
| border: none; | |
| border-radius: 8px; | |
| transition: all 0.3s ease; | |
| box-shadow: 0 4px 15px rgba(59, 130, 246, 0.3); | |
| } | |
| .btn-primary:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 6px 25px rgba(59, 130, 246, 0.4); | |
| } | |
| .btn-primary:active { | |
| transform: translateY(0); | |
| } | |
| /* Input styling */ | |
| input[type="text"], | |
| input[type="file"], | |
| select { | |
| background: var(--bg-secondary); | |
| color: var(--text-primary); | |
| border: 1px solid var(--border-color); | |
| } | |
| input[type="text"]:focus, | |
| input[type="file"]:focus, | |
| select:focus { | |
| outline: none; | |
| border-color: var(--accent-blue); | |
| box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); | |
| } | |
| /* Light mode adjustments */ | |
| .light-mode { | |
| background: linear-gradient(135deg, var(--bg-primary) 0%, #e0f2fe 50%, var(--bg-primary) 100%); | |
| } | |
| .light-mode .card { | |
| box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); | |
| } | |
| .light-mode ::-webkit-scrollbar-thumb { | |
| background: linear-gradient(180deg, var(--accent-blue), var(--accent-purple)); | |
| } | |
| </style> | |
| </head> | |
| <body class="h-full font-sans antialiased"> | |
| <div x-data="app()" x-init="init()" x-cloak class="min-h-screen flex flex-col"> | |
| <!-- βββ Modern Navigation Bar ββββββββββββββββββββββββββββββββββββββββββ --> | |
| <nav class="sticky top-0 z-40 border-b border-slate-700/50 bg-slate-900/70 backdrop-blur-xl shadow-lg"> | |
| <div class="max-w-7xl mx-auto px-6"> | |
| <div class="flex items-center justify-between h-20"> | |
| <!-- Logo / Brand --> | |
| <div class="flex items-center gap-3 animate-fade-in"> | |
| <img src="/static/logo SRT.png" alt="RT Caption Generator" class="h-12 w-12 rounded-lg shadow-lg object-cover"> | |
| <div> | |
| <h1 class="text-xl font-bold text-white hidden sm:block bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent"> | |
| Caption Generator | |
| </h1> | |
| <p class="text-xs text-slate-400 hidden sm:block">Real-time SRT Creator</p> | |
| </div> | |
| </div> | |
| <!-- Tabs --> | |
| <div class="flex items-center gap-2 bg-slate-800/50 rounded-lg p-1"> | |
| <button | |
| @click="activeTab = 'new-job'" | |
| :class="activeTab === 'new-job' ? 'bg-gradient-to-r from-blue-600 to-purple-600 text-white shadow-lg' : 'text-slate-400 hover:text-slate-300'" | |
| class="relative px-4 py-2 text-sm font-semibold rounded-md transition-all duration-200"> | |
| β¨ Single | |
| <span x-show="screen === 'progress' && activeTab !== 'new-job'" | |
| class="absolute -top-2 -right-2 w-3 h-3 rounded-full bg-gradient-to-r from-pink-500 to-red-500 animate-pulse animate-glow"></span> | |
| </button> | |
| <button | |
| @click="activeTab = 'batch-job'" | |
| :class="activeTab === 'batch-job' ? 'bg-gradient-to-r from-blue-600 to-purple-600 text-white shadow-lg' : 'text-slate-400 hover:text-slate-300'" | |
| class="relative px-4 py-2 text-sm font-semibold rounded-md transition-all duration-200"> | |
| π¦ Batch | |
| <span x-show="batchScreen === 'progress' && activeTab !== 'batch-job'" | |
| class="absolute -top-2 -right-2 w-3 h-3 rounded-full bg-gradient-to-r from-pink-500 to-red-500 animate-pulse animate-glow"></span> | |
| </button> | |
| <button | |
| @click="activeTab = 'history'; loadHistory()" | |
| :class="activeTab === 'history' ? 'bg-gradient-to-r from-blue-600 to-purple-600 text-white shadow-lg' : 'text-slate-400 hover:text-slate-300'" | |
| class="px-4 py-2 text-sm font-semibold rounded-md transition-all duration-200 flex items-center gap-2"> | |
| π History | |
| <span class="ml-1 px-2 py-0.5 text-xs font-bold bg-slate-700 rounded-full text-cyan-300" x-text="'(' + historyJobs.length + ')'"></span> | |
| </button> | |
| </div> | |
| <!-- Dark mode toggle --> | |
| <button @click="darkMode = !darkMode; applyDarkMode()" | |
| class="p-2.5 rounded-lg text-slate-400 hover:text-blue-400 hover:bg-slate-800 transition-all duration-200"> | |
| <template x-if="darkMode"> | |
| <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"> | |
| <path d="M21.64 13a1 1 0 00-1.05-.14 8 8 0 11-9.95-9.95 1 1 0 00.12 1.05 8 8 0 111.05 9.95z" /> | |
| </svg> | |
| </template> | |
| <template x-if="!darkMode"> | |
| <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"> | |
| <circle cx="12" cy="12" r="5"/> | |
| <path d="M12 2v6m0 10v6M4.22 4.22l4.24 4.24m5.08 5.08l4.24 4.24M2 12h6m10 0h6M4.22 19.78l4.24-4.24m5.08-5.08l4.24-4.24"/> | |
| </svg> | |
| </template> | |
| </button> | |
| </div> | |
| </div> | |
| </nav> | |
| <!-- βββ Main Content βββββββββββββββββββββββββββββββββββββββββββββββββββββ --> | |
| <div class="flex-1 flex flex-col items-center justify-center py-12 px-4"> | |
| <!-- βββ NEW JOB TAB βββββββββββββββββββββββββββββββββββββββββββββββββββββ --> | |
| <div x-show="activeTab === 'new-job'" class="w-full"> | |
| <!-- βββ Upload Screen ββββββββββββββββββββββββββββββββββββ --> | |
| <section x-show="screen === 'upload'" class="w-full max-w-2xl mx-auto space-y-8 animate-fade-up"> | |
| <!-- Header --> | |
| <div class="text-center space-y-3 mb-8"> | |
| <h2 class="text-4xl font-bold bg-gradient-to-r from-blue-400 via-purple-400 to-pink-400 bg-clip-text text-transparent"> | |
| Generate Captions | |
| </h2> | |
| <p class="text-slate-400 text-lg">Upload your audio and script to generate precise SRT subtitles</p> | |
| </div> | |
| <!-- Job name input --> | |
| <div class="card p-6"> | |
| <label class="block text-xs font-semibold text-slate-300 mb-3 uppercase tracking-wider">Job name (optional)</label> | |
| <input type="text" x-model="jobName" placeholder="e.g., episode-1, interview-2024" | |
| class="w-full card px-4 py-3 text-sm text-slate-200 focus:outline-none focus:border-blue-500 | |
| focus:ring-2 focus:ring-blue-400/30 placeholder-slate-500 rounded-lg" /> | |
| </div> | |
| <!-- Drop zones (stacked) --> | |
| <div class="space-y-4"> | |
| <!-- Audio drop zone --> | |
| <div | |
| class="relative rounded-xl border-2 border-dashed border-slate-600 bg-gradient-to-br from-slate-800 to-slate-900 | |
| p-10 text-center cursor-pointer transition-all duration-300 hover:border-slate-500" | |
| :class="[ | |
| audioFile ? 'border-green-500 bg-green-950/20 shadow-lg' : '', | |
| dragCounter.audio > 0 ? 'drop-zone-active' : '' | |
| ]" | |
| @click="$refs.audioInput.click()" | |
| @dragover.prevent | |
| @dragenter.prevent="dragCounter.audio++" | |
| @dragleave="dragCounter.audio--" | |
| @drop.prevent="handleAudioDrop($event); dragCounter.audio = 0"> | |
| <input x-ref="audioInput" type="file" accept=".mp3,.wav,.m4a,.aac,.MP3,.WAV,.M4A,.AAC" | |
| class="hidden" @change="handleAudioFile($event)" /> | |
| <template x-if="!audioFile"> | |
| <div class="space-y-4"> | |
| <div class="inline-block p-4 rounded-full bg-gradient-to-br from-blue-500/20 to-purple-500/20 border border-blue-500/30"> | |
| <svg class="w-8 h-8 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> | |
| <path stroke-linecap="round" stroke-linejoin="round" | |
| d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" /> | |
| </svg> | |
| </div> | |
| <div> | |
| <p class="text-slate-200 font-semibold text-lg">Drop audio here</p> | |
| <p class="text-slate-400 text-sm mt-1">or click to browse</p> | |
| </div> | |
| <p class="text-slate-500 text-xs">MP3 β’ WAV β’ M4A β’ AAC</p> | |
| </div> | |
| </template> | |
| <template x-if="audioFile"> | |
| <div class="space-y-3"> | |
| <div class="flex items-center justify-center gap-3"> | |
| <svg class="w-6 h-6 text-green-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"> | |
| <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /> | |
| </svg> | |
| <span class="text-green-300 font-semibold truncate text-lg" x-text="audioFile.name"></span> | |
| </div> | |
| <div class="text-sm text-slate-400 space-y-1"> | |
| <p x-text="getFileSize(audioFile.size)"></p> | |
| <p x-text="'β±οΈ ~' + getAudioDuration(audioFile.size) + ' minutes'"></p> | |
| </div> | |
| <button class="text-slate-400 hover:text-red-400 transition-colors text-sm font-medium mt-2" | |
| @click.stop="audioFile = null; audioMetadata = ''">β» Change file</button> | |
| </div> | |
| </template> | |
| </div> | |
| <!-- Script drop zone --> | |
| <div | |
| class="relative rounded-xl border-2 border-dashed border-slate-600 bg-gradient-to-br from-slate-800 to-slate-900 | |
| p-10 text-center cursor-pointer transition-all duration-300 hover:border-slate-500" | |
| :class="[ | |
| scriptFile ? 'border-green-500 bg-green-950/20 shadow-lg' : '', | |
| dragCounter.script > 0 ? 'drop-zone-active' : '' | |
| ]" | |
| @click="$refs.scriptInput.click()" | |
| @dragover.prevent | |
| @dragenter.prevent="dragCounter.script++" | |
| @dragleave="dragCounter.script--" | |
| @drop.prevent="handleScriptDrop($event); dragCounter.script = 0"> | |
| <input x-ref="scriptInput" type="file" accept=".txt" | |
| class="hidden" @change="handleScriptFile($event)" /> | |
| <template x-if="!scriptFile"> | |
| <div class="space-y-4"> | |
| <div class="inline-block p-4 rounded-full bg-gradient-to-br from-purple-500/20 to-pink-500/20 border border-purple-500/30"> | |
| <svg class="w-8 h-8 text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> | |
| <path stroke-linecap="round" stroke-linejoin="round" | |
| d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> | |
| </svg> | |
| </div> | |
| <div> | |
| <p class="text-slate-200 font-semibold text-lg">Drop script here</p> | |
| <p class="text-slate-400 text-sm mt-1">or click to browse</p> | |
| </div> | |
| <p class="text-slate-500 text-xs">TXT file (one sentence per line)</p> | |
| </div> | |
| </template> | |
| <template x-if="scriptFile"> | |
| <div class="space-y-3"> | |
| <div class="flex items-center justify-center gap-3"> | |
| <svg class="w-6 h-6 text-green-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"> | |
| <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /> | |
| </svg> | |
| <span class="text-green-300 font-semibold truncate text-lg" x-text="scriptFile.name"></span> | |
| </div> | |
| <div class="text-sm text-slate-400 space-y-1"> | |
| <p x-text="'π ' + scriptMetadata.lineCount + ' sentences'"></p> | |
| <p x-text="'π€ ' + scriptMetadata.wordCount + ' words'"></p> | |
| </div> | |
| <button class="text-slate-400 hover:text-red-400 transition-colors text-sm font-medium mt-2" | |
| @click.stop="scriptFile = null; scriptMetadata = { lineCount: 0, wordCount: 0 }">β» Change file</button> | |
| </div> | |
| </template> | |
| </div> | |
| </div> | |
| <!-- Alerts --> | |
| <template x-if="audioFile && audioFile.size > 200 * 1024 * 1024"> | |
| <div class="flex items-start gap-3 text-sm text-amber-200 bg-gradient-to-r from-amber-950 to-amber-900 rounded-lg px-5 py-4 border border-amber-700/50 shadow-lg"> | |
| <svg class="w-5 h-5 shrink-0 mt-0.5 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> | |
| <path stroke-linecap="round" stroke-linejoin="round" | |
| d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /> | |
| </svg> | |
| <span>β‘ Large file detected β processing may take several minutes</span> | |
| </div> | |
| </template> | |
| <template x-if="scriptFile && scriptMetadata.lineCount === 0"> | |
| <div class="flex items-start gap-3 text-sm text-red-200 bg-gradient-to-r from-red-950 to-red-900 rounded-lg px-5 py-4 border border-red-700/50 shadow-lg"> | |
| <svg class="w-5 h-5 shrink-0 mt-0.5 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> | |
| <path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> | |
| </svg> | |
| <span>β οΈ Script file appears to be empty</span> | |
| </div> | |
| </template> | |
| <!-- Advanced options (Settings) --> | |
| <div class="card overflow-hidden"> | |
| <button class="w-full flex items-center justify-between px-6 py-4 text-sm text-slate-300 | |
| hover:text-slate-100 transition-colors hover:bg-slate-700/30" | |
| @click="showAdvanced = !showAdvanced"> | |
| <div class="flex items-center gap-3"> | |
| <svg class="w-5 h-5 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> | |
| <path stroke-linecap="round" stroke-linejoin="round" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" /> | |
| </svg> | |
| <span class="font-semibold">Advanced Settings</span> | |
| </div> | |
| <div class="text-xs text-slate-500 flex items-center gap-3" x-show="!showAdvanced"> | |
| <span x-text="(opts.wordLevel ? 'π€ Word-level' : 'π Sentence-level') + ' β’ ' + opts.offsetMs + 'ms'"></span> | |
| <svg class="w-4 h-4 transition-transform duration-300 text-slate-400" | |
| :class="showAdvanced ? 'rotate-180' : ''" | |
| fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> | |
| <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" /> | |
| </svg> | |
| </div> | |
| </button> | |
| <div x-show="showAdvanced" x-collapse class="border-t border-slate-700/50 bg-slate-800/30"> | |
| <div class="px-6 py-5 grid grid-cols-1 sm:grid-cols-3 gap-5"> | |
| <div> | |
| <label class="block text-xs font-semibold text-slate-300 mb-2.5 uppercase tracking-wide">π€ Alignment</label> | |
| <select x-model="opts.wordLevel" | |
| class="w-full card px-4 py-2.5 text-sm text-slate-200 focus:outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-400/30 rounded-lg"> | |
| <option :value="true">Word-level (default)</option> | |
| <option :value="false">Sentence-level</option> | |
| </select> | |
| </div> | |
| <div> | |
| <label class="block text-xs font-semibold text-slate-300 mb-2.5 uppercase tracking-wide">β±οΈ Offset (ms)</label> | |
| <input type="number" x-model.number="opts.offsetMs" | |
| class="w-full card px-4 py-2.5 text-sm text-slate-200 focus:outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-400/30 rounded-lg" | |
| placeholder="0" /> | |
| </div> | |
| <div> | |
| <label class="block text-xs font-semibold text-slate-300 mb-2.5 uppercase tracking-wide">π€ Max chars</label> | |
| <input type="number" x-model.number="opts.maxChars" | |
| class="w-full card px-4 py-2.5 text-sm text-slate-200 focus:outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-400/30 rounded-lg" | |
| placeholder="42" /> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Error banner --> | |
| <div x-show="uploadError" x-transition | |
| class="rounded-lg border-l-4 border-red-600 bg-gradient-to-r from-red-950 to-red-900 px-5 py-4 text-sm text-red-200 shadow-lg" | |
| x-text="uploadError"> | |
| </div> | |
| <!-- CTA button --> | |
| <button | |
| class="w-full py-4 rounded-lg font-bold text-white text-lg transition-all duration-300 | |
| btn-primary shadow-2xl | |
| disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:no-underline" | |
| :disabled="!audioFile || !scriptFile || submitting" | |
| @click="submit()" | |
| @keydown.ctrl.enter="submit()" | |
| @keydown.cmd.enter="submit()"> | |
| <span x-show="!submitting" class="flex items-center justify-center gap-2"> | |
| β¨ Generate Captions | |
| </span> | |
| <span x-show="submitting" class="flex items-center justify-center gap-3"> | |
| <svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24"> | |
| <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/> | |
| <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/> | |
| </svg> | |
| <span>Processing your files...</span> | |
| </span> | |
| </button> | |
| </section> | |
| <!-- βββ Progress Screen ββββββββββββββββββββββββββββββββ --> | |
| <section x-show="screen === 'progress'" class="w-full max-w-2xl mx-auto animate-fade-up"> | |
| <div class="card p-10 space-y-8 shadow-2xl"> | |
| <!-- Header --> | |
| <div class="text-center space-y-2"> | |
| <h2 class="text-3xl font-bold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent"> | |
| βοΈ Processing | |
| </h2> | |
| <p class="text-slate-400 text-base" x-text="audioName"></p> | |
| </div> | |
| <!-- Steps with timing --> | |
| <ol class="space-y-4"> | |
| <template x-for="step in steps" :key="step.stage"> | |
| <li class="flex items-start gap-4"> | |
| <div class="w-8 h-8 shrink-0 flex items-center justify-center mt-0.5"> | |
| <template x-if="step.done"> | |
| <div class="w-8 h-8 rounded-full bg-gradient-to-br from-green-400 to-emerald-500 flex items-center justify-center shadow-lg"> | |
| <svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"> | |
| <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /> | |
| </svg> | |
| </div> | |
| </template> | |
| <template x-if="step.active && !step.done"> | |
| <div class="w-8 h-8 rounded-full bg-gradient-to-br from-blue-500 to-purple-500 flex items-center justify-center shadow-lg animate-glow"> | |
| <svg class="w-5 h-5 text-white animate-spin-slow" fill="none" viewBox="0 0 24 24"> | |
| <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3"/> | |
| <path class="opacity-80" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/> | |
| </svg> | |
| </div> | |
| </template> | |
| <template x-if="!step.done && !step.active"> | |
| <div class="w-2 h-2 rounded-full bg-slate-600 mx-auto"></div> | |
| </template> | |
| </div> | |
| <div class="flex-1 pt-1"> | |
| <span class="text-base font-medium" | |
| :class="step.done ? 'text-green-400' : step.active ? 'text-slate-100' : 'text-slate-500'" | |
| x-text="step.label"> | |
| </span> | |
| <template x-if="step.done && step.duration"> | |
| <span class="text-xs text-slate-500 ml-3" x-text="'β ' + step.duration + 's'"></span> | |
| </template> | |
| </div> | |
| </li> | |
| </template> | |
| </ol> | |
| <!-- Progress bar with shimmer --> | |
| <div class="space-y-3"> | |
| <div class="flex justify-between items-center text-sm"> | |
| <span class="text-slate-300 font-medium" x-text="progress.message || 'β³ Initializingβ¦'"></span> | |
| <div class="flex items-center gap-3 text-slate-400"> | |
| <span x-text="'β±οΈ ' + formatElapsed(elapsedSecs)"></span> | |
| <span class="font-bold text-blue-400" x-text="progress.pct + '%'"></span> | |
| </div> | |
| </div> | |
| <template x-if="progress.pct > 35"> | |
| <p class="text-xs text-slate-500">Estimated time remaining: ~45β90 seconds</p> | |
| </template> | |
| <div class="h-3 rounded-full bg-slate-700/50 border border-slate-600 overflow-hidden shadow-inner"> | |
| <div class="h-full progress-bar relative overflow-hidden rounded-full" | |
| :style="`width: ${progress.pct}%`"> | |
| <div class="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent animate-shimmer"></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- βββ Results Screen ββββββββββββββββββββββββββββββββ --> | |
| <section x-show="screen === 'results'" class="w-full max-w-2xl mx-auto space-y-6 animate-fade-up"> | |
| <!-- Hero card --> | |
| <div class="card p-8 shadow-2xl border-t-4 border-t-gradient-to-r from-green-400 to-blue-400"> | |
| <div class="flex items-start justify-between gap-6 mb-8"> | |
| <div class="space-y-3"> | |
| <div class="flex items-center gap-3"> | |
| <div class="inline-block p-2 rounded-full bg-gradient-to-br from-green-500 to-emerald-600"> | |
| <svg class="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"> | |
| <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /> | |
| </svg> | |
| </div> | |
| <h2 class="text-3xl font-bold text-white">Perfect!</h2> | |
| </div> | |
| <p class="text-slate-300"> | |
| <span class="text-green-400 font-bold text-xl" x-text="result.captionCount"></span> | |
| <span class="text-slate-400"> captions generated</span> | |
| </p> | |
| <p class="text-xs text-slate-500">β Saved to your job history</p> | |
| </div> | |
| <!-- Grade badge with tooltip --> | |
| <div class="shrink-0 relative"> | |
| <button @mouseenter="gradeHover = true" @mouseleave="gradeHover = false" | |
| class="text-4xl font-bold w-20 h-20 rounded-2xl flex items-center justify-center transition-all hover:scale-110 shadow-xl" | |
| :class="{ | |
| 'bg-gradient-to-br from-green-500 to-emerald-600 text-white': result.grade === 'A', | |
| 'bg-gradient-to-br from-blue-500 to-cyan-600 text-white': result.grade === 'B', | |
| 'bg-gradient-to-br from-yellow-500 to-amber-600 text-white': result.grade === 'C', | |
| 'bg-gradient-to-br from-orange-500 to-red-600 text-white': result.grade === 'D', | |
| 'bg-gradient-to-br from-red-500 to-rose-600 text-white': result.grade === 'F', | |
| }" | |
| x-text="result.grade || 'β'"> | |
| </button> | |
| <!-- Grade tooltip --> | |
| <div x-show="gradeHover" x-transition:enter="transition ease-out duration-200" x-transition:leave="transition ease-in duration-100" | |
| class="absolute right-0 top-24 bg-slate-800 border border-slate-700 rounded-lg px-4 py-3 text-sm text-slate-200 whitespace-nowrap shadow-2xl z-50 font-medium" | |
| x-text="gradeTooltip(result.grade)"> | |
| </div> | |
| <p class="text-xs text-slate-500 text-center mt-2 font-semibold uppercase tracking-wider">Quality</p> | |
| </div> | |
| </div> | |
| <!-- Quality metrics grid --> | |
| <div x-show="result.metrics" class="grid grid-cols-2 sm:grid-cols-3 gap-4"> | |
| <template x-if="result.metrics"> | |
| <template x-for="item in qualityItems()" :key="item.label"> | |
| <div class="bg-slate-700/30 border border-slate-600 rounded-xl p-4 hover:border-slate-500 transition-all" | |
| :class="item.bad ? 'border-red-600/50 bg-red-950/20' : 'hover:bg-slate-700/50'"> | |
| <p class="text-xs text-slate-400 mb-2 font-semibold uppercase tracking-wide" x-text="item.label"></p> | |
| <p class="text-lg font-bold" | |
| :class="item.bad ? 'text-red-400' : 'text-slate-100'" | |
| x-text="item.value"> | |
| </p> | |
| <template x-if="item.bad && item.helpText"> | |
| <p class="text-xs text-red-400/80 mt-2" x-text="item.helpText"></p> | |
| </template> | |
| </div> | |
| </template> | |
| </template> | |
| </div> | |
| <!-- Warnings --> | |
| <template x-if="result.warnings && result.warnings.length > 0"> | |
| <div class="mt-8 pt-8 border-t border-slate-700 space-y-3"> | |
| <p class="text-xs font-semibold text-amber-400 uppercase tracking-wider">β οΈ Warnings</p> | |
| <template x-for="w in result.warnings" :key="w"> | |
| <div class="flex items-start gap-3 text-sm text-amber-200 bg-gradient-to-r from-amber-950/40 to-amber-900/20 rounded-lg px-4 py-3 border border-amber-700/30"> | |
| <svg class="w-5 h-5 shrink-0 mt-0.5 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> | |
| <path stroke-linecap="round" stroke-linejoin="round" | |
| d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /> | |
| </svg> | |
| <span x-text="w"></span> | |
| </div> | |
| </template> | |
| </div> | |
| </template> | |
| <!-- Suggestions --> | |
| <template x-if="result.suggestions && result.suggestions.length > 0"> | |
| <div class="mt-8 pt-8 border-t border-slate-700"> | |
| <p class="text-xs font-semibold text-blue-400 uppercase tracking-wider mb-4">π‘ Suggestions for improvement</p> | |
| <ul class="space-y-2.5"> | |
| <template x-for="s in result.suggestions" :key="s"> | |
| <li class="flex items-start gap-3 text-sm text-slate-300 bg-blue-950/20 rounded-lg px-4 py-3 border border-blue-700/20"> | |
| <span class="text-blue-400 mt-0.5 font-bold">β</span> | |
| <span x-text="s"></span> | |
| </li> | |
| </template> | |
| </ul> | |
| </div> | |
| </template> | |
| <!-- Actions --> | |
| <div class="mt-8 grid grid-cols-1 sm:grid-cols-3 gap-3"> | |
| <a :href="`/api/jobs/${jobId}/download`" | |
| class="text-center py-3.5 rounded-lg font-semibold text-white btn-primary transition-all | |
| active:scale-95"> | |
| β¬οΈ Download SRT | |
| </a> | |
| <button @click="copySrt()" | |
| class="py-3.5 rounded-lg font-semibold text-slate-200 border-2 border-slate-600 | |
| hover:border-blue-500 hover:text-blue-300 hover:bg-slate-800/50 transition-all active:scale-95"> | |
| π Copy SRT | |
| </button> | |
| <button @click="reset()" | |
| class="py-3.5 rounded-lg font-semibold text-slate-200 border-2 border-slate-600 | |
| hover:border-purple-500 hover:text-purple-300 hover:bg-slate-800/50 transition-all active:scale-95"> | |
| β¨ New Job | |
| </button> | |
| </div> | |
| </div> | |
| <!-- SRT Preview (expandable) --> | |
| <div x-show="srtPreview.length > 0" class="card p-6 shadow-lg"> | |
| <button @click="srtPreviewExpanded = !srtPreviewExpanded" | |
| class="w-full flex items-center justify-between mb-5 group"> | |
| <div class="flex items-center gap-2"> | |
| <span class="text-xs font-bold text-slate-400 uppercase tracking-wider">πΊ Caption Preview</span> | |
| </div> | |
| <button class="text-sm font-semibold text-blue-400 hover:text-blue-300 transition-colors" | |
| x-text="srtPreviewExpanded ? 'βΌ Hide' : ('βΆ Show all ' + result.captionCount + ' captions')"></button> | |
| </button> | |
| <div class="space-y-4 font-mono text-sm max-h-[600px] overflow-y-auto" | |
| :class="!srtPreviewExpanded ? 'max-h-96' : ''"> | |
| <template x-for="(cap, idx) in (srtPreviewExpanded ? srtPreview : srtPreview.slice(0, 5))" :key="cap.index"> | |
| <div class="flex gap-4 p-3 rounded-lg bg-slate-800/30 border border-slate-700/50 hover:border-slate-600 transition-all group"> | |
| <div class="shrink-0"> | |
| <span class="text-slate-500 text-xs font-bold w-6 text-center block" x-text="cap.index"></span> | |
| </div> | |
| <div class="flex-1 min-w-0 space-y-1.5"> | |
| <span class="text-slate-500 text-xs block" x-text="cap.time"></span> | |
| <p class="text-slate-200 break-words text-sm font-medium" dir="auto" x-text="cap.text"></p> | |
| <span class="text-xs font-semibold" | |
| :class="cap.charCount > opts.maxChars ? 'text-red-400' : 'text-slate-500'" | |
| x-text="' ' + cap.charCount + '/' + opts.maxChars + ' chars'"></span> | |
| </div> | |
| </div> | |
| </template> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- βββ Error Screen ββββββββββββββββββββββββββββββββββββ --> | |
| <section x-show="screen === 'error'" class="w-full max-w-2xl mx-auto animate-fade-up"> | |
| <div class="card border-t-4 border-t-red-500 bg-gradient-to-br from-red-950/40 to-slate-900 p-8 space-y-6 shadow-2xl"> | |
| <div class="flex items-start gap-4"> | |
| <div class="p-3 rounded-full bg-gradient-to-br from-red-500 to-rose-600 shrink-0"> | |
| <svg class="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"> | |
| <path stroke-linecap="round" stroke-linejoin="round" | |
| d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" /> | |
| </svg> | |
| <div> | |
| <h2 class="font-semibold text-red-200 text-lg mb-2">Something went wrong</h2> | |
| <p class="text-sm text-red-300" x-text="errorMsg"></p> | |
| </div> | |
| </div> | |
| <!-- Collapsible error details --> | |
| <button @click="errorDetailsExpanded = !errorDetailsExpanded" | |
| class="w-full text-left group"> | |
| <p class="text-xs text-slate-400 uppercase tracking-wider font-semibold group-hover:text-slate-300 transition-colors"> | |
| <span x-text="errorDetailsExpanded ? 'βΌ' : 'βΆ'" class="mr-2"></span>Error details | |
| </p> | |
| </button> | |
| <div x-show="errorDetailsExpanded" x-collapse | |
| class="bg-slate-900/60 border border-slate-700/50 rounded-lg p-4 font-mono text-xs text-slate-400 max-h-56 overflow-y-auto whitespace-pre-wrap break-words" | |
| x-text="errorMsg"> | |
| </div> | |
| <!-- Common causes --> | |
| <div class="bg-blue-950/30 border border-blue-700/30 rounded-lg p-4 space-y-2.5"> | |
| <p class="text-xs font-bold text-blue-400 uppercase tracking-wider">π‘ Common causes</p> | |
| <ul class="space-y-2 text-sm text-slate-300"> | |
| <li class="flex items-start gap-3"> | |
| <span class="text-red-400 font-bold">β</span> | |
| <span>Audio and script language mismatch</span> | |
| </li> | |
| <li class="flex items-start gap-3"> | |
| <span class="text-red-400 font-bold">β</span> | |
| <span>Audio file corrupted, invalid, or too short</span> | |
| </li> | |
| <li class="flex items-start gap-3"> | |
| <span class="text-red-400 font-bold">β</span> | |
| <span>Script contains no recognizable phonemes</span> | |
| </li> | |
| </ul> | |
| </div> | |
| <!-- Actions --> | |
| <div class="flex flex-col sm:flex-row gap-3 pt-4 border-t border-slate-700"> | |
| <button @click="reset()" | |
| class="flex-1 py-3.5 rounded-lg font-semibold text-white btn-primary transition-all | |
| active:scale-95"> | |
| π Try Again | |
| </button> | |
| <button @click="screen = 'upload'; showAdvanced = true; reset()" | |
| class="flex-1 py-3.5 rounded-lg font-semibold text-slate-200 border-2 border-slate-600 | |
| hover:border-blue-500 hover:text-blue-300 hover:bg-slate-800/50 transition-all active:scale-95"> | |
| βοΈ Adjust Settings | |
| </button> | |
| </div> | |
| </div> | |
| </section> | |
| </div> | |
| <!-- βββ BATCH JOB TAB ββββββββββββββββββββββββββββββββββββββββββββββββββββ --> | |
| <div x-show="activeTab === 'batch-job'" class="w-full"> | |
| <!-- βββ Batch Upload Screen ββββββββββββββββββββββββββββββββ --> | |
| <section x-show="batchScreen === 'upload'" class="w-full max-w-4xl mx-auto space-y-8 animate-fade-up"> | |
| <!-- Header --> | |
| <div class="text-center space-y-4 mb-8"> | |
| <h2 class="text-4xl font-bold bg-gradient-to-r from-blue-400 via-purple-400 to-pink-400 bg-clip-text text-transparent"> | |
| π¦ Batch Processing | |
| </h2> | |
| <p class="text-slate-400 text-lg">Upload multiple audio/script pairs for bulk caption generation</p> | |
| <!-- How it works --> | |
| <div class="card p-6 bg-gradient-to-br from-blue-950/20 to-purple-950/20 border border-blue-700/30"> | |
| <div class="flex items-start gap-4"> | |
| <div class="inline-block p-2 rounded-lg bg-gradient-to-br from-blue-500/20 to-purple-500/20 border border-blue-500/30 shrink-0"> | |
| <svg class="w-6 h-6 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> | |
| <path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> | |
| </svg> | |
| </div> | |
| <div class="text-left"> | |
| <h3 class="text-lg font-semibold text-blue-300 mb-3">How Batch Processing Works</h3> | |
| <ul class="space-y-2 text-sm text-slate-300"> | |
| <li class="flex items-start gap-3"> | |
| <span class="text-blue-400 font-bold mt-0.5">1.</span> | |
| <span>Upload multiple files with <strong>matching names</strong>: <code class="bg-slate-800 px-2 py-1 rounded text-blue-300">video1.mp3</code> + <code class="bg-slate-800 px-2 py-1 rounded text-blue-300">video1.txt</code></span> | |
| </li> | |
| <li class="flex items-start gap-3"> | |
| <span class="text-blue-400 font-bold mt-0.5">2.</span> | |
| <span>Files are automatically paired by filename (without extension)</span> | |
| </li> | |
| <li class="flex items-start gap-3"> | |
| <span class="text-blue-400 font-bold mt-0.5">3.</span> | |
| <span>Each pair is processed sequentially with the same settings</span> | |
| </li> | |
| <li class="flex items-start gap-3"> | |
| <span class="text-blue-400 font-bold mt-0.5">4.</span> | |
| <span>Download individual SRT files or all results as a ZIP</span> | |
| </li> | |
| </ul> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Batch file upload zone --> | |
| <div class="card p-8"> | |
| <div | |
| class="relative rounded-xl border-2 border-dashed border-slate-600 bg-gradient-to-br from-slate-800 to-slate-900 | |
| p-16 text-center cursor-pointer transition-all duration-300 hover:border-slate-500" | |
| :class="[ | |
| batchFiles.length > 0 ? 'border-green-500 bg-green-950/20 shadow-lg' : '', | |
| batchDragCounter > 0 ? 'drop-zone-active' : '' | |
| ]" | |
| @click="$refs.batchInput.click()" | |
| @dragover.prevent | |
| @dragenter.prevent="batchDragCounter++" | |
| @dragleave="batchDragCounter--" | |
| @drop.prevent="handleBatchDrop($event); batchDragCounter = 0"> | |
| <input x-ref="batchInput" type="file" multiple | |
| accept=".mp3,.wav,.m4a,.aac,.MP3,.WAV,.M4A,.AAC,.txt" | |
| class="hidden" @change="handleBatchFiles($event)" /> | |
| <template x-if="batchFiles.length === 0"> | |
| <div class="space-y-6"> | |
| <div class="inline-block p-6 rounded-full bg-gradient-to-br from-blue-500/20 to-purple-500/20 border border-blue-500/30"> | |
| <svg class="w-12 h-12 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> | |
| <path stroke-linecap="round" stroke-linejoin="round" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" /> | |
| </svg> | |
| </div> | |
| <div> | |
| <p class="text-slate-200 font-bold text-2xl mb-2">Drop multiple files here</p> | |
| <p class="text-slate-400 text-lg mb-4">or click to browse</p> | |
| <div class="flex flex-wrap justify-center gap-3 text-sm"> | |
| <span class="px-3 py-1 bg-blue-950/50 text-blue-300 rounded-full border border-blue-700/50">Audio: MP3, WAV, M4A, AAC</span> | |
| <span class="px-3 py-1 bg-purple-950/50 text-purple-300 rounded-full border border-purple-700/50">Scripts: TXT</span> | |
| </div> | |
| </div> | |
| </div> | |
| </template> | |
| <template x-if="batchFiles.length > 0"> | |
| <div class="space-y-6"> | |
| <div class="flex items-center justify-center gap-4"> | |
| <svg class="w-8 h-8 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"> | |
| <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /> | |
| </svg> | |
| <span class="text-green-300 font-bold text-2xl" x-text="batchFiles.length + ' files uploaded'"></span> | |
| </div> | |
| <div class="text-slate-400 space-y-1"> | |
| <p x-text="batchPairs.length + ' valid pairs found'"></p> | |
| <p class="text-sm" x-text="'Estimated processing time: ~' + Math.ceil(batchPairs.length * 2) + ' minutes'"></p> | |
| </div> | |
| <button class="text-slate-400 hover:text-red-400 transition-colors font-medium" | |
| @click.stop="clearBatchFiles()">ποΈ Clear all files</button> | |
| </div> | |
| </template> | |
| </div> | |
| <!-- File list and pairs preview --> | |
| <template x-if="batchFiles.length > 0"> | |
| <div class="mt-8 space-y-6"> | |
| <!-- Valid pairs --> | |
| <template x-if="batchPairs.length > 0"> | |
| <div class="space-y-4"> | |
| <h3 class="text-lg font-semibold text-green-400 flex items-center gap-2"> | |
| β Ready to Process (<span x-text="batchPairs.length"></span> pairs) | |
| </h3> | |
| <div class="grid grid-cols-1 md:grid-cols-2 gap-3"> | |
| <template x-for="pair in batchPairs" :key="pair.stem"> | |
| <div class="card p-4 bg-green-950/20 border-green-700/50"> | |
| <div class="font-semibold text-green-300 mb-2" x-text="pair.stem"></div> | |
| <div class="space-y-1 text-xs text-slate-400"> | |
| <div class="flex items-center gap-2"> | |
| <span>π΅</span> | |
| <span x-text="pair.audio.name"></span> | |
| </div> | |
| <div class="flex items-center gap-2"> | |
| <span>π</span> | |
| <span x-text="pair.script.name"></span> | |
| </div> | |
| </div> | |
| </div> | |
| </template> | |
| </div> | |
| </div> | |
| </template> | |
| <!-- Unpaired files --> | |
| <template x-if="batchUnpairedFiles.length > 0"> | |
| <div class="space-y-4"> | |
| <h3 class="text-lg font-semibold text-amber-400 flex items-center gap-2"> | |
| β οΈ Unpaired Files (<span x-text="batchUnpairedFiles.length"></span>) | |
| </h3> | |
| <div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2"> | |
| <template x-for="file in batchUnpairedFiles" :key="file.name"> | |
| <div class="text-xs p-2 bg-amber-950/30 border border-amber-700/50 rounded text-amber-200 truncate" | |
| :title="file.name" x-text="file.name"></div> | |
| </template> | |
| </div> | |
| <p class="text-sm text-amber-300">These files don't have matching pairs and will be ignored.</p> | |
| </div> | |
| </template> | |
| </div> | |
| </template> | |
| <!-- Batch settings (same as single but shared) --> | |
| <template x-if="batchFiles.length > 0"> | |
| <div class="mt-8 pt-8 border-t border-slate-700"> | |
| <div class="card overflow-hidden"> | |
| <div class="px-6 py-4 bg-slate-800/30"> | |
| <div class="flex items-center gap-3"> | |
| <svg class="w-5 h-5 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> | |
| <path stroke-linecap="round" stroke-linejoin="round" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" /> | |
| </svg> | |
| <span class="font-semibold text-slate-200">Batch Processing Settings</span> | |
| <span class="text-xs text-slate-500">(Applied to all pairs)</span> | |
| </div> | |
| </div> | |
| <div class="px-6 py-5 grid grid-cols-1 sm:grid-cols-3 gap-5"> | |
| <div> | |
| <label class="block text-xs font-semibold text-slate-300 mb-2.5 uppercase tracking-wide">π€ Alignment</label> | |
| <select x-model="batchOpts.wordLevel" | |
| class="w-full card px-4 py-2.5 text-sm text-slate-200 focus:outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-400/30 rounded-lg"> | |
| <option :value="true">Word-level (default)</option> | |
| <option :value="false">Sentence-level</option> | |
| </select> | |
| </div> | |
| <div> | |
| <label class="block text-xs font-semibold text-slate-300 mb-2.5 uppercase tracking-wide">β±οΈ Offset (ms)</label> | |
| <input type="number" x-model.number="batchOpts.offsetMs" | |
| class="w-full card px-4 py-2.5 text-sm text-slate-200 focus:outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-400/30 rounded-lg" | |
| placeholder="0" /> | |
| </div> | |
| <div> | |
| <label class="block text-xs font-semibold text-slate-300 mb-2.5 uppercase tracking-wide">π€ Max chars</label> | |
| <input type="number" x-model.number="batchOpts.maxChars" | |
| class="w-full card px-4 py-2.5 text-sm text-slate-200 focus:outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-400/30 rounded-lg" | |
| placeholder="42" /> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </template> | |
| <!-- Error display --> | |
| <div x-show="batchError" x-transition | |
| class="mt-6 rounded-lg border-l-4 border-red-600 bg-gradient-to-r from-red-950 to-red-900 px-5 py-4 text-sm text-red-200 shadow-lg" | |
| x-text="batchError"> | |
| </div> | |
| <!-- Process button --> | |
| <template x-if="batchPairs.length > 0"> | |
| <div class="mt-8"> | |
| <button | |
| class="w-full py-4 rounded-lg font-bold text-white text-lg transition-all duration-300 | |
| btn-primary shadow-2xl | |
| disabled:opacity-50 disabled:cursor-not-allowed" | |
| :disabled="batchSubmitting" | |
| @click="submitBatch()"> | |
| <span x-show="!batchSubmitting" class="flex items-center justify-center gap-2"> | |
| π¦ Process <span x-text="batchPairs.length"></span> Pairs | |
| </span> | |
| <span x-show="batchSubmitting" class="flex items-center justify-center gap-3"> | |
| <svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24"> | |
| <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/> | |
| <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/> | |
| </svg> | |
| <span>Starting batch processing...</span> | |
| </span> | |
| </button> | |
| </div> | |
| </template> | |
| </div> | |
| </section> | |
| <!-- βββ Batch Progress Screen ββββββββββββββββββββββββββββ --> | |
| <section x-show="batchScreen === 'progress'" class="w-full max-w-4xl mx-auto animate-fade-up"> | |
| <div class="card p-10 space-y-8 shadow-2xl"> | |
| <!-- Header --> | |
| <div class="text-center space-y-2"> | |
| <h2 class="text-3xl font-bold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent"> | |
| π¦ Batch Processing | |
| </h2> | |
| <p class="text-slate-400 text-base"> | |
| Processing <span class="text-blue-400 font-semibold" x-text="batchJobs.length"></span> file pairs | |
| </p> | |
| </div> | |
| <!-- Overall progress --> | |
| <div class="space-y-4"> | |
| <div class="flex justify-between items-center text-sm"> | |
| <span class="text-slate-300 font-medium">Overall Progress</span> | |
| <div class="flex items-center gap-3 text-slate-400"> | |
| <span x-text="batchCompletedCount + '/' + batchJobs.length + ' completed'"></span> | |
| <span class="font-bold text-blue-400" x-text="Math.round((batchCompletedCount / batchJobs.length) * 100) + '%'"></span> | |
| </div> | |
| </div> | |
| <div class="h-3 rounded-full bg-slate-700/50 border border-slate-600 overflow-hidden shadow-inner"> | |
| <div class="h-full progress-bar relative overflow-hidden rounded-full" | |
| :style="`width: ${Math.round((batchCompletedCount / batchJobs.length) * 100)}%`"> | |
| <div class="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent animate-shimmer"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Individual job status --> | |
| <div class="space-y-4"> | |
| <h3 class="text-lg font-semibold text-slate-200">Job Status</h3> | |
| <div class="space-y-3 max-h-96 overflow-y-auto"> | |
| <template x-for="job in batchJobs" :key="job.job_id"> | |
| <div class="flex items-center gap-4 p-4 rounded-lg border border-slate-700 bg-slate-800/30"> | |
| <div class="w-6 h-6 shrink-0 flex items-center justify-center"> | |
| <template x-if="job.status === 'completed'"> | |
| <div class="w-6 h-6 rounded-full bg-gradient-to-br from-green-400 to-emerald-500 flex items-center justify-center"> | |
| <svg class="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"> | |
| <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /> | |
| </svg> | |
| </div> | |
| </template> | |
| <template x-if="job.status === 'failed'"> | |
| <div class="w-6 h-6 rounded-full bg-gradient-to-br from-red-500 to-rose-600 flex items-center justify-center"> | |
| <svg class="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> | |
| <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /> | |
| </svg> | |
| </div> | |
| </template> | |
| <template x-if="job.status === 'processing'"> | |
| <div class="w-6 h-6 rounded-full bg-gradient-to-br from-blue-500 to-purple-500 flex items-center justify-center animate-glow"> | |
| <svg class="w-4 h-4 text-white animate-spin-slow" fill="none" viewBox="0 0 24 24"> | |
| <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3"/> | |
| <path class="opacity-80" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/> | |
| </svg> | |
| </div> | |
| </template> | |
| <template x-if="job.status === 'pending'"> | |
| <div class="w-3 h-3 rounded-full bg-slate-600 mx-auto"></div> | |
| </template> | |
| </div> | |
| <div class="flex-1"> | |
| <div class="font-medium" | |
| :class="{ | |
| 'text-green-400': job.status === 'completed', | |
| 'text-red-400': job.status === 'failed', | |
| 'text-blue-400': job.status === 'processing', | |
| 'text-slate-500': job.status === 'pending' | |
| }" | |
| x-text="job.stem"> | |
| </div> | |
| <template x-if="job.status === 'failed' && job.error"> | |
| <div class="text-xs text-red-400 mt-1" x-text="job.error"></div> | |
| </template> | |
| </div> | |
| <template x-if="job.status === 'completed'"> | |
| <div class="flex gap-2"> | |
| <a :href="`/api/jobs/${job.job_id}/download`" | |
| class="text-xs px-3 py-1.5 rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors"> | |
| β¬οΈ Download | |
| </a> | |
| </div> | |
| </template> | |
| </div> | |
| </template> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- βββ Batch Results Screen ββββββββββββββββββββββββββββ --> | |
| <section x-show="batchScreen === 'results'" class="w-full max-w-4xl mx-auto space-y-6 animate-fade-up"> | |
| <div class="card p-8 shadow-2xl border-t-4 border-t-gradient-to-r from-green-400 to-blue-400"> | |
| <div class="text-center space-y-4"> | |
| <div class="flex items-center justify-center gap-3"> | |
| <div class="inline-block p-3 rounded-full bg-gradient-to-br from-green-500 to-emerald-600"> | |
| <svg class="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"> | |
| <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /> | |
| </svg> | |
| </div> | |
| <h2 class="text-4xl font-bold text-white">Batch Complete!</h2> | |
| </div> | |
| <div class="grid grid-cols-3 gap-6 py-6"> | |
| <div class="text-center"> | |
| <div class="text-3xl font-bold text-green-400" x-text="batchSuccessCount"></div> | |
| <div class="text-sm text-slate-400">Successful</div> | |
| </div> | |
| <div class="text-center"> | |
| <div class="text-3xl font-bold text-red-400" x-text="batchFailedCount"></div> | |
| <div class="text-sm text-slate-400">Failed</div> | |
| </div> | |
| <div class="text-center"> | |
| <div class="text-3xl font-bold text-blue-400" x-text="batchTotalCaptions"></div> | |
| <div class="text-sm text-slate-400">Total Captions</div> | |
| </div> | |
| </div> | |
| <div class="flex gap-3 justify-center pt-4"> | |
| <button @click="downloadAllSRT()" | |
| class="px-6 py-3 rounded-lg font-semibold text-white btn-primary transition-all"> | |
| π₯ Download All SRT Files | |
| </button> | |
| <button @click="resetBatch()" | |
| class="px-6 py-3 rounded-lg font-semibold text-slate-200 border-2 border-slate-600 | |
| hover:border-purple-500 hover:text-purple-300 hover:bg-slate-800/50 transition-all"> | |
| β¨ New Batch | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| </div> | |
| <!-- βββ HISTORY TAB ββββββββββββββββββββββββββββββββββββββββββββββββββββββ --> | |
| <div x-show="activeTab === 'history'" class="w-full max-w-6xl mx-auto animate-fade-up"> | |
| <!-- Header --> | |
| <div class="mb-8"> | |
| <h2 class="text-3xl font-bold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent mb-4"> | |
| π Job History | |
| </h2> | |
| <!-- Search input --> | |
| <input type="text" x-model="historySearch" placeholder="π Filter by job nameβ¦" | |
| class="w-full card px-4 py-3 text-sm text-slate-200 focus:outline-none focus:border-blue-500 | |
| focus:ring-2 focus:ring-blue-400/30 placeholder-slate-500 rounded-lg" /> | |
| </div> | |
| <!-- Loading state --> | |
| <template x-if="historyLoading"> | |
| <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> | |
| <template x-for="i in 6"> | |
| <div class="animate-pulse card p-6 space-y-4"> | |
| <div class="h-5 bg-slate-700 rounded w-2/3"></div> | |
| <div class="h-4 bg-slate-700 rounded w-1/2"></div> | |
| <div class="flex gap-2 pt-4"> | |
| <div class="h-9 bg-slate-700 rounded-lg flex-1"></div> | |
| <div class="h-9 bg-slate-700 rounded-lg flex-1"></div> | |
| </div> | |
| </div> | |
| </template> | |
| </div> | |
| </template> | |
| <!-- Empty state --> | |
| <template x-if="!historyLoading && filteredHistory().length === 0"> | |
| <div class="card p-16 text-center shadow-lg"> | |
| <div class="inline-block p-4 rounded-full bg-gradient-to-br from-slate-700 to-slate-800 mb-6"> | |
| <svg class="w-12 h-12 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> | |
| <path stroke-linecap="round" stroke-linejoin="round" | |
| d="M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z" /> | |
| </svg> | |
| </div> | |
| <h3 class="text-2xl font-bold text-slate-200 mb-2">No jobs yet</h3> | |
| <p class="text-slate-400 mb-8">Generate your first SRT captions to see them here</p> | |
| <button @click="activeTab = 'new-job'" | |
| class="inline-block px-8 py-3.5 rounded-lg font-bold text-white btn-primary transition-all | |
| active:scale-95"> | |
| β¨ Create First Job | |
| </button> | |
| </div> | |
| </template> | |
| <!-- Job cards grid --> | |
| <template x-if="!historyLoading && filteredHistory().length > 0"> | |
| <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> | |
| <template x-for="job in filteredHistory()" :key="job.id"> | |
| <div class="card p-6 space-y-5 hover:border-slate-500 transition-all hover:shadow-lg group"> | |
| <!-- Header --> | |
| <div class="flex items-start justify-between gap-3"> | |
| <div class="min-w-0 flex-1"> | |
| <h3 class="text-base font-bold text-white truncate group-hover:text-blue-300 transition-colors" x-text="job.job_name || 'π Untitled'"></h3> | |
| <p class="text-xs text-slate-500 mt-1" x-text="'π ' + formatDate(job.created_at)"></p> | |
| </div> | |
| <span class="inline-block text-xs px-3 py-1.5 rounded-full bg-gradient-to-r from-green-500/20 to-emerald-500/20 text-green-300 font-semibold border border-green-700/30 shrink-0">β Done</span> | |
| </div> | |
| <!-- Actions --> | |
| <div class="flex flex-wrap gap-2 pt-2"> | |
| <button @click="toggleHistoryPreview(job.id)" | |
| :class="expandedPreviews[job.id] ? 'bg-gradient-to-r from-blue-600 to-purple-600 text-white shadow-lg' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700 hover:text-slate-100'" | |
| class="text-xs px-3 py-2 rounded-lg transition-all font-semibold flex items-center gap-1.5"> | |
| <span x-text="expandedPreviews[job.id] ? 'βΌ' : 'βΆ'"></span> | |
| Preview | |
| </button> | |
| <button @click="copyHistorySrt(job.id)" | |
| class="text-xs px-3 py-2 rounded-lg bg-slate-700/50 text-slate-300 hover:bg-slate-700 hover:text-slate-100 transition-all font-semibold"> | |
| π Copy | |
| </button> | |
| <a :href="`/download/${job.id}`" | |
| class="text-xs px-3 py-2 rounded-lg bg-slate-700/50 text-slate-300 hover:bg-slate-700 hover:text-slate-100 transition-all font-semibold"> | |
| β¬οΈ DL | |
| </a> | |
| <button @click="deleteHistoryJob(job.id)" | |
| class="text-xs px-3 py-2 rounded-lg bg-red-950/30 text-red-300 hover:bg-red-900/50 hover:text-red-200 transition-all font-semibold border border-red-700/30"> | |
| ποΈ Delete | |
| </button> | |
| </div> | |
| <!-- Preview panel (expandable) --> | |
| <template x-if="expandedPreviews[job.id]"> | |
| <div x-transition class="border-t border-slate-700 pt-4 space-y-3"> | |
| <p class="text-xs text-slate-400 font-bold uppercase tracking-wider">πΊ Caption Preview</p> | |
| <div class="space-y-2.5 font-mono text-xs max-h-72 overflow-y-auto bg-slate-800/40 rounded-lg p-3 border border-slate-700/30"> | |
| <template x-for="cap in historyPreviews[job.id] || []" :key="cap.index"> | |
| <div class="space-y-1 px-2 py-2 bg-slate-900/30 rounded border border-slate-700/20"> | |
| <span class="text-slate-600 font-bold block" x-text="'#' + cap.index"></span> | |
| <span class="text-slate-500 text-[10px] block" x-text="cap.time"></span> | |
| <p class="text-slate-200 break-words text-sm mt-1" dir="auto" x-text="cap.text"></p> | |
| </div> | |
| </template> | |
| </div> | |
| </div> | |
| </template> | |
| </div> | |
| </template> | |
| </div> | |
| </template> | |
| <!-- Error message --> | |
| <template x-if="historyError && !historyLoading"> | |
| <div class="card border-l-4 border-l-red-600 bg-gradient-to-r from-red-950/40 to-slate-900 p-6 text-center"> | |
| <p class="text-sm text-red-300" x-text="historyError"></p> | |
| </div> | |
| </template> | |
| </div> | |
| </div> | |
| <!-- βββ Toast Notification ββββββββββββββββββββββββββββββββββββββββββββββββ --> | |
| <div x-show="toast.show" | |
| x-transition:enter="transition ease-out duration-300" | |
| x-transition:enter-start="opacity-0 translate-y-4" | |
| x-transition:enter-end="opacity-100 translate-y-0" | |
| x-transition:leave="transition ease-in duration-200" | |
| x-transition:leave-start="opacity-100 translate-y-0" | |
| x-transition:leave-end="opacity-0 translate-y-4" | |
| class="fixed bottom-8 right-8 z-50 flex items-center gap-3 px-5 py-4 rounded-lg shadow-2xl | |
| border text-sm font-semibold max-w-sm backdrop-blur-sm" | |
| :class="{ | |
| 'bg-gradient-to-r from-green-500 to-emerald-600 border-green-400/30 text-white': toast.type === 'success', | |
| 'bg-red-900/90 border-red-700 text-red-200': toast.type === 'error', | |
| 'bg-gray-800/90 border-gray-700 text-gray-200': toast.type === 'info', | |
| }"> | |
| <span x-text="toast.message"></span> | |
| <button @click="toast.show = false" class="ml-1 opacity-60 hover:opacity-100 text-lg leading-none">Γ</button> | |
| </div> | |
| </div><!-- /app root --> | |
| <!-- βββ Alpine.js app logic βββββββββββββββββββββββββββββββββββββββββββββββββββ --> | |
| <script> | |
| function app() { | |
| return { | |
| // βββ UI State ββββββββββββββββββββββββββββββββββββ | |
| activeTab: 'new-job', | |
| screen: 'upload', | |
| showAdvanced: false, | |
| submitting: false, | |
| uploadError: '', | |
| darkMode: true, | |
| gradeHover: false, | |
| srtPreviewExpanded: false, | |
| errorDetailsExpanded: false, | |
| // βββ Files ββββββββββββββββββββββββββββββββββββββββ | |
| audioFile: null, | |
| scriptFile: null, | |
| audioName: '', | |
| jobName: '', | |
| dragCounter: { audio: 0, script: 0 }, | |
| scriptMetadata: { lineCount: 0, wordCount: 0 }, | |
| // βββ Options ββββββββββββββββββββββββββββββββββββββ | |
| opts: { | |
| wordLevel: true, | |
| offsetMs: 0, | |
| maxChars: 42, | |
| }, | |
| // βββ Job ββββββββββββββββββββββββββββββββββββββββββ | |
| jobId: null, | |
| // βββ Progress ββββββββββββββββββββββββββββββββββββ | |
| progress: { stage: '', message: '', pct: 0 }, | |
| steps: [ | |
| { stage: 'validating', label: 'Validating inputs', done: false, active: false, duration: 0 }, | |
| { stage: 'normalizing', label: 'Normalising audio', done: false, active: false, duration: 0 }, | |
| { stage: 'loading_model', label: 'Loading MMS_FA model', done: false, active: false, duration: 0 }, | |
| { stage: 'aligning', label: 'Running alignment', done: false, active: false, duration: 0 }, | |
| { stage: 'writing', label: 'Writing SRT', done: false, active: false, duration: 0 }, | |
| { stage: 'quality', label: 'Quality analysis', done: false, active: false, duration: 0 }, | |
| ], | |
| stepStartTimes: {}, | |
| elapsedSecs: 0, | |
| elapsedTimer: null, | |
| // βββ Results ββββββββββββββββββββββββββββββββββ | |
| result: { captionCount: 0, grade: '', metrics: null, suggestions: [], warnings: [] }, | |
| srtPreview: [], | |
| srtFull: '', | |
| errorMsg: '', | |
| // βββ Batch Processing βββββββββββββββββββββββββ | |
| batchScreen: 'upload', | |
| batchFiles: [], | |
| batchPairs: [], | |
| batchUnpairedFiles: [], | |
| batchDragCounter: 0, | |
| batchSubmitting: false, | |
| batchError: '', | |
| batchOpts: { | |
| wordLevel: true, | |
| offsetMs: 0, | |
| maxChars: 42, | |
| }, | |
| batchJobs: [], | |
| batchCompletedCount: 0, | |
| batchSuccessCount: 0, | |
| batchFailedCount: 0, | |
| batchTotalCaptions: 0, | |
| // βββ History ββββββββββββββββββββββββββββββββββ | |
| activeTab: 'new-job', | |
| historyJobs: [], | |
| historyLoading: false, | |
| historySearch: '', | |
| historyError: '', | |
| previewCache: {}, | |
| historyPreviews: {}, | |
| expandedPreviews: {}, | |
| // βββ Toast ββββββββββββββββββββββββββββββββββββ | |
| toast: { show: false, message: '', type: 'success' }, | |
| toastTimer: null, | |
| // βββ Initialization βββββββββββββββββββββββββββββ | |
| init() { | |
| // Restore dark mode from localStorage | |
| const savedDarkMode = localStorage.getItem('darkMode'); | |
| if (savedDarkMode !== null) { | |
| this.darkMode = savedDarkMode === 'true'; | |
| } | |
| this.applyDarkMode(); | |
| // Pre-fetch history count | |
| this.loadHistory(); | |
| // Keyboard shortcuts | |
| window.addEventListener('keydown', (e) => { | |
| if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; | |
| if (e.key === 'n' || e.key === 'N') this.activeTab = 'new-job'; | |
| if (e.key === 'h' || e.key === 'H') { this.activeTab = 'history'; this.loadHistory(); } | |
| if (e.key === 'Escape') { | |
| this.toast.show = false; | |
| this.gradeHover = false; | |
| } | |
| }); | |
| }, | |
| // βββ File Handlers ββββββββββββββββββββββββββββββββ | |
| handleAudioDrop(e) { | |
| const f = e.dataTransfer.files[0]; | |
| if (f) { | |
| this.audioFile = f; | |
| this.jobName = f.name.split('.').slice(0, -1).join('.'); | |
| } | |
| }, | |
| handleAudioFile(e) { | |
| const f = e.target.files[0]; | |
| if (f) { | |
| this.audioFile = f; | |
| this.jobName = f.name.split('.').slice(0, -1).join('.'); | |
| } | |
| }, | |
| handleScriptDrop(e) { | |
| const f = e.dataTransfer.files[0]; | |
| if (f) { | |
| this.scriptFile = f; | |
| this.readScriptMetadata(f); | |
| } | |
| }, | |
| handleScriptFile(e) { | |
| const f = e.target.files[0]; | |
| if (f) { | |
| this.scriptFile = f; | |
| this.readScriptMetadata(f); | |
| } | |
| }, | |
| readScriptMetadata(file) { | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| try { | |
| const text = e.target.result; | |
| const lines = text.trim().split('\n').filter(l => l.trim().length > 0); | |
| const words = text.trim().split(/\s+/).length; | |
| this.scriptMetadata = { lineCount: lines.length, wordCount: words }; | |
| } catch (_) { | |
| this.scriptMetadata = { lineCount: 0, wordCount: 0 }; | |
| } | |
| }; | |
| reader.readAsText(file); | |
| }, | |
| getFileSize(bytes) { | |
| if (bytes < 1024) return bytes + ' B'; | |
| if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; | |
| return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; | |
| }, | |
| getAudioDuration(bytes) { | |
| // Rough estimate: assume 128 kbps bitrate | |
| const seconds = (bytes / (128000 / 8)); | |
| const mins = Math.round(seconds / 60); | |
| return Math.max(1, mins); | |
| }, | |
| // βββ Submit / SSE ββββββββββββββββββββββββββββββββ | |
| async submit() { | |
| this.uploadError = ''; | |
| this.submitting = true; | |
| const fd = new FormData(); | |
| fd.append('audio', this.audioFile); | |
| fd.append('script', this.scriptFile); | |
| fd.append('word_level', this.opts.wordLevel); | |
| fd.append('offset_ms', this.opts.offsetMs); | |
| fd.append('max_chars', this.opts.maxChars); | |
| let res; | |
| try { | |
| res = await fetch('/api/jobs/single', { method: 'POST', body: fd }); | |
| } catch (err) { | |
| this.uploadError = 'Network error β is the server running?'; | |
| this.submitting = false; | |
| return; | |
| } | |
| if (!res.ok) { | |
| const body = await res.json().catch(() => ({})); | |
| this.uploadError = body.detail || `Server error ${res.status}`; | |
| this.submitting = false; | |
| return; | |
| } | |
| const { job_id, audio_name } = await res.json(); | |
| this.jobId = job_id; | |
| this.audioName = audio_name; | |
| this.submitting = false; | |
| this.screen = 'progress'; | |
| this.elapsedSecs = 0; | |
| this.stepStartTimes = {}; | |
| // Start elapsed timer | |
| if (this.elapsedTimer) clearInterval(this.elapsedTimer); | |
| this.elapsedTimer = setInterval(() => { | |
| this.elapsedSecs++; | |
| }, 1000); | |
| this.listenToJob(job_id); | |
| }, | |
| listenToJob(jobId) { | |
| const es = new EventSource(`/api/jobs/${jobId}/stream`); | |
| const stageOrder = ['validating','normalizing','loading_model','aligning','writing','quality']; | |
| es.onmessage = (e) => { | |
| const ev = JSON.parse(e.data); | |
| this.progress = ev; | |
| const idx = this.steps.findIndex(s => s.stage === ev.stage); | |
| const now = Date.now(); | |
| this.steps.forEach((s, i) => { | |
| if (i < idx) { | |
| s.done = true; | |
| s.active = false; | |
| if (!s.duration && this.stepStartTimes[s.stage]) { | |
| const elapsed = (now - this.stepStartTimes[s.stage]) / 1000; | |
| s.duration = Math.max(0.1, elapsed.toFixed(1)); | |
| } | |
| } else if (i === idx) { | |
| s.done = false; | |
| s.active = true; | |
| if (!this.stepStartTimes[s.stage]) { | |
| this.stepStartTimes[s.stage] = now; | |
| } | |
| } else { | |
| s.done = false; | |
| s.active = false; | |
| } | |
| }); | |
| if (ev.stage === 'done') { | |
| es.close(); | |
| if (this.elapsedTimer) clearInterval(this.elapsedTimer); | |
| this.result.captionCount = ev.caption_count || 0; | |
| this.result.grade = ev.grade || ''; | |
| this.result.warnings = ev.warnings || []; | |
| this.steps.forEach(s => { s.done = true; s.active = false; }); | |
| this.fetchQuality(jobId).then(() => { | |
| this.fetchPreview(jobId).then(() => { | |
| this.screen = 'results'; | |
| }); | |
| }); | |
| } | |
| if (ev.stage === 'error') { | |
| es.close(); | |
| if (this.elapsedTimer) clearInterval(this.elapsedTimer); | |
| this.errorMsg = ev.message; | |
| this.screen = 'error'; | |
| } | |
| }; | |
| es.onerror = () => { | |
| es.close(); | |
| if (this.elapsedTimer) clearInterval(this.elapsedTimer); | |
| if (this.screen === 'progress') { | |
| this.errorMsg = 'Lost connection to server.'; | |
| this.screen = 'error'; | |
| } | |
| }; | |
| }, | |
| async fetchQuality(jobId) { | |
| try { | |
| const res = await fetch(`/api/jobs/${jobId}/quality`); | |
| if (!res.ok) return; | |
| const data = await res.json(); | |
| this.result.metrics = data.metrics; | |
| this.result.suggestions = data.suggestions; | |
| if (data.metrics?.grade) this.result.grade = data.metrics.grade; | |
| } catch (_) {} | |
| }, | |
| async fetchPreview(jobId) { | |
| try { | |
| const res = await fetch(`/api/jobs/${jobId}/download`); | |
| if (!res.ok) return; | |
| this.srtFull = await res.text(); | |
| this.srtPreview = this.parseSrtPreview(this.srtFull, 10); | |
| } catch (_) {} | |
| }, | |
| parseSrtPreview(srt, limit) { | |
| const blocks = srt.trim().split(/\r?\n\r?\n/); | |
| return blocks.slice(0, limit).map(block => { | |
| const lines = block.trim().split(/\r?\n/); | |
| const text = lines.slice(2).join(' ') || ''; | |
| return { | |
| index: lines[0] || '', | |
| time: lines[1] || '', | |
| text: text, | |
| charCount: text.length, | |
| }; | |
| }); | |
| }, | |
| async copySrt() { | |
| try { | |
| await navigator.clipboard.writeText(this.srtFull); | |
| this.showToast('SRT copied to clipboard', 'success'); | |
| } catch (_) { | |
| this.showToast('Failed to copy to clipboard', 'error'); | |
| } | |
| }, | |
| qualityItems() { | |
| const m = this.result.metrics; | |
| if (!m) return []; | |
| return [ | |
| { | |
| label: 'Total captions', | |
| value: m.total_captions, | |
| bad: false, | |
| }, | |
| { | |
| label: 'Avg duration', | |
| value: Math.round(m.avg_duration_ms) + ' ms', | |
| bad: m.avg_duration_ms < 200, | |
| helpText: m.avg_duration_ms < 200 ? 'Very short captions may be hard to read' : undefined, | |
| }, | |
| { | |
| label: 'Overlaps', | |
| value: m.overlapping_count, | |
| bad: m.overlapping_count > 0, | |
| helpText: m.overlapping_count > 0 ? 'Captions overlap in time β CapCut may display incorrectly' : undefined, | |
| }, | |
| { | |
| label: 'Short captions', | |
| value: m.short_caption_count, | |
| bad: m.short_caption_count > 0, | |
| helpText: m.short_caption_count > 0 ? 'Consider merging short captions for readability' : undefined, | |
| }, | |
| { | |
| label: 'Long captions', | |
| value: m.long_caption_count, | |
| bad: m.long_caption_count > 0, | |
| helpText: m.long_caption_count > 0 ? 'Long captions may wrap in CapCut' : undefined, | |
| }, | |
| { | |
| label: 'Large gaps', | |
| value: m.gaps_too_large, | |
| bad: m.gaps_too_large > 0, | |
| helpText: m.gaps_too_large > 0 ? 'Silent periods longer than expected' : undefined, | |
| }, | |
| ]; | |
| }, | |
| gradeTooltip(grade) { | |
| return { | |
| 'A': 'Excellent: no overlaps, no short/long captions, gaps within spec', | |
| 'B': 'Good: minor issues, fully usable in CapCut', | |
| 'C': 'Acceptable: some timing imperfections', | |
| 'D': 'Needs review: multiple quality warnings', | |
| 'F': 'Failed: significant alignment problems', | |
| }[grade] || ''; | |
| }, | |
| formatElapsed(secs) { | |
| const m = Math.floor(secs / 60); | |
| const s = secs % 60; | |
| return `${m}:${String(s).padStart(2, '0')}`; | |
| }, | |
| // βββ History ββββββββββββββββββββββββββββββββββ | |
| async loadHistory() { | |
| this.historyLoading = true; | |
| this.historyError = ''; | |
| try { | |
| const res = await fetch('/history'); | |
| if (!res.ok) throw new Error('Failed to fetch history'); | |
| this.historyJobs = await res.json(); | |
| } catch (err) { | |
| this.historyError = 'Failed to load history: ' + err.message; | |
| } finally { | |
| this.historyLoading = false; | |
| } | |
| }, | |
| filteredHistory() { | |
| if (!this.historySearch) return this.historyJobs; | |
| const q = this.historySearch.toLowerCase(); | |
| return this.historyJobs.filter(j => | |
| (j.job_name || '').toLowerCase().includes(q) | |
| ); | |
| }, | |
| async toggleHistoryPreview(jobId) { | |
| if (this.expandedPreviews[jobId]) { | |
| this.expandedPreviews[jobId] = false; | |
| return; | |
| } | |
| // Fetch if not cached | |
| if (!this.previewCache[jobId]) { | |
| try { | |
| const res = await fetch(`/download/${jobId}`); | |
| if (!res.ok) throw new Error('Failed to fetch SRT'); | |
| const srt = await res.text(); | |
| this.previewCache[jobId] = srt; | |
| this.historyPreviews[jobId] = this.parseSrtPreview(srt, 5); | |
| } catch (err) { | |
| this.showToast('Failed to load preview', 'error'); | |
| return; | |
| } | |
| } else { | |
| this.historyPreviews[jobId] = this.parseSrtPreview(this.previewCache[jobId], 5); | |
| } | |
| this.expandedPreviews[jobId] = true; | |
| }, | |
| async copyHistorySrt(jobId) { | |
| try { | |
| let srt = this.previewCache[jobId]; | |
| if (!srt) { | |
| const res = await fetch(`/download/${jobId}`); | |
| if (!res.ok) throw new Error('Failed to fetch SRT'); | |
| srt = await res.text(); | |
| this.previewCache[jobId] = srt; | |
| } | |
| await navigator.clipboard.writeText(srt); | |
| this.showToast('SRT copied to clipboard', 'success'); | |
| } catch (err) { | |
| this.showToast('Failed to copy to clipboard', 'error'); | |
| } | |
| }, | |
| async deleteHistoryJob(jobId) { | |
| try { | |
| const res = await fetch(`/job/${jobId}`, { method: 'DELETE' }); | |
| if (!res.ok) throw new Error('Failed to delete job'); | |
| this.historyJobs = this.historyJobs.filter(j => j.id !== jobId); | |
| delete this.previewCache[jobId]; | |
| delete this.historyPreviews[jobId]; | |
| delete this.expandedPreviews[jobId]; | |
| this.showToast('Job deleted', 'info'); | |
| } catch (err) { | |
| this.showToast('Failed to delete job: ' + err.message, 'error'); | |
| } | |
| }, | |
| formatDate(dateStr) { | |
| const d = new Date(dateStr); | |
| return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); | |
| }, | |
| // βββ Toast ββββββββββββββββββββββββββββββββββββ | |
| showToast(message, type = 'success', duration = 3500) { | |
| clearTimeout(this.toastTimer); | |
| this.toast = { show: true, message, type }; | |
| this.toastTimer = setTimeout(() => { this.toast.show = false; }, duration); | |
| }, | |
| // βββ Dark Mode ββββββββββββββββββββββββββββββββ | |
| applyDarkMode() { | |
| document.documentElement.classList.toggle('dark', this.darkMode); | |
| document.body.classList.toggle('light-mode', !this.darkMode); | |
| localStorage.setItem('darkMode', this.darkMode); | |
| }, | |
| // βββ Batch Processing Functions ββββββββββββββ | |
| handleBatchFiles(event) { | |
| const files = Array.from(event.target.files); | |
| this.processBatchFiles(files); | |
| }, | |
| handleBatchDrop(event) { | |
| const files = Array.from(event.dataTransfer.files); | |
| this.processBatchFiles(files); | |
| }, | |
| processBatchFiles(files) { | |
| this.batchFiles = files; | |
| this.analyzeBatchFiles(); | |
| }, | |
| analyzeBatchFiles() { | |
| // Group files by stem (filename without extension) | |
| const groups = {}; | |
| this.batchFiles.forEach(file => { | |
| const name = file.name; | |
| const lastDot = name.lastIndexOf('.'); | |
| const stem = lastDot > 0 ? name.substring(0, lastDot) : name; | |
| const ext = lastDot > 0 ? name.substring(lastDot).toLowerCase() : ''; | |
| if (!groups[stem]) { | |
| groups[stem] = {}; | |
| } | |
| if (['.mp3', '.wav', '.m4a', '.aac'].includes(ext)) { | |
| groups[stem].audio = file; | |
| } else if (ext === '.txt') { | |
| groups[stem].script = file; | |
| } | |
| }); | |
| // Find valid pairs and unpaired files | |
| this.batchPairs = []; | |
| this.batchUnpairedFiles = []; | |
| Object.entries(groups).forEach(([stem, group]) => { | |
| if (group.audio && group.script) { | |
| this.batchPairs.push({ | |
| stem: stem, | |
| audio: group.audio, | |
| script: group.script | |
| }); | |
| } else { | |
| if (group.audio) this.batchUnpairedFiles.push(group.audio); | |
| if (group.script) this.batchUnpairedFiles.push(group.script); | |
| } | |
| }); | |
| }, | |
| clearBatchFiles() { | |
| this.batchFiles = []; | |
| this.batchPairs = []; | |
| this.batchUnpairedFiles = []; | |
| this.$refs.batchInput.value = ''; | |
| }, | |
| async submitBatch() { | |
| this.batchError = ''; | |
| this.batchSubmitting = true; | |
| const formData = new FormData(); | |
| // Add all files | |
| this.batchFiles.forEach(file => { | |
| formData.append('files', file); | |
| }); | |
| // Add settings | |
| formData.append('word_level', this.batchOpts.wordLevel); | |
| formData.append('offset_ms', this.batchOpts.offsetMs); | |
| formData.append('max_chars', this.batchOpts.maxChars); | |
| try { | |
| const response = await fetch('/api/jobs/batch', { | |
| method: 'POST', | |
| body: formData | |
| }); | |
| if (!response.ok) { | |
| const error = await response.json().catch(() => ({})); | |
| this.batchError = error.detail || `Server error ${response.status}`; | |
| this.batchSubmitting = false; | |
| return; | |
| } | |
| const result = await response.json(); | |
| this.batchJobs = result.jobs; | |
| this.batchSubmitting = false; | |
| this.batchScreen = 'progress'; | |
| this.batchCompletedCount = 0; | |
| // Start monitoring batch progress | |
| this.monitorBatchProgress(); | |
| } catch (error) { | |
| this.batchError = 'Network error β is the server running?'; | |
| this.batchSubmitting = false; | |
| } | |
| }, | |
| async monitorBatchProgress() { | |
| // Poll for job completion status | |
| const checkInterval = setInterval(async () => { | |
| let completedCount = 0; | |
| let successCount = 0; | |
| let failedCount = 0; | |
| let totalCaptions = 0; | |
| // Check each job status | |
| for (let job of this.batchJobs) { | |
| if (job.status === 'pending') { | |
| try { | |
| const response = await fetch(`/api/jobs/${job.job_id}`); | |
| if (response.ok) { | |
| const status = await response.json(); | |
| if (status.status === 'done') { | |
| job.status = 'completed'; | |
| totalCaptions += status.caption_count || 0; | |
| } else if (status.status === 'failed') { | |
| job.status = 'failed'; | |
| job.error = status.error || 'Processing failed'; | |
| } | |
| } | |
| } catch (e) { | |
| // Continue checking other jobs | |
| } | |
| } | |
| if (job.status === 'completed') { | |
| completedCount++; | |
| successCount++; | |
| } else if (job.status === 'failed') { | |
| completedCount++; | |
| failedCount++; | |
| } | |
| } | |
| this.batchCompletedCount = completedCount; | |
| this.batchSuccessCount = successCount; | |
| this.batchFailedCount = failedCount; | |
| this.batchTotalCaptions = totalCaptions; | |
| // Check if all jobs are complete | |
| if (completedCount >= this.batchJobs.length) { | |
| clearInterval(checkInterval); | |
| setTimeout(() => { | |
| this.batchScreen = 'results'; | |
| }, 1000); | |
| } | |
| }, 2000); // Check every 2 seconds | |
| }, | |
| async downloadAllSRT() { | |
| // For now, just show toast - could implement ZIP download later | |
| this.showToast('Individual downloads available in job status above', 'info', 5000); | |
| }, | |
| resetBatch() { | |
| this.batchScreen = 'upload'; | |
| this.batchFiles = []; | |
| this.batchPairs = []; | |
| this.batchUnpairedFiles = []; | |
| this.batchJobs = []; | |
| this.batchError = ''; | |
| this.batchSubmitting = false; | |
| this.batchCompletedCount = 0; | |
| this.batchSuccessCount = 0; | |
| this.batchFailedCount = 0; | |
| this.batchTotalCaptions = 0; | |
| this.$refs.batchInput.value = ''; | |
| }, | |
| // βββ Reset ββββββββββββββββββββββββββββββββββββ | |
| reset() { | |
| if (this.jobId) { | |
| fetch(`/api/jobs/${this.jobId}`, { method: 'DELETE' }).catch(() => {}); | |
| } | |
| if (this.elapsedTimer) clearInterval(this.elapsedTimer); | |
| this.screen = 'upload'; | |
| this.audioFile = null; | |
| this.scriptFile = null; | |
| this.audioName = ''; | |
| this.jobId = null; | |
| this.jobName = ''; | |
| this.progress = { stage: '', message: '', pct: 0 }; | |
| this.steps.forEach(s => { s.done = false; s.active = false; s.duration = 0; }); | |
| this.result = { captionCount: 0, grade: '', metrics: null, suggestions: [], warnings: [] }; | |
| this.srtPreview = []; | |
| this.srtFull = ''; | |
| this.errorMsg = ''; | |
| this.uploadError = ''; | |
| this.scriptMetadata = { lineCount: 0, wordCount: 0 }; | |
| this.elapsedSecs = 0; | |
| this.srtPreviewExpanded = false; | |
| this.errorDetailsExpanded = false; | |
| }, | |
| }; | |
| } | |
| </script> | |
| </body> | |
| </html> | |