karchoud's picture
Update web/static/index.html
fa27025 verified
<!DOCTYPE html>
<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 !important; }
/* 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) !important;
background-color: rgba(59, 130, 246, 0.12) !important;
box-shadow: 0 0 30px rgba(59, 130, 246, 0.3) !important;
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>