Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>VerboseWhisper - Elite Transcript AI</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script src="https://unpkg.com/feather-icons"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/feather-icons/dist/feather.min.js"></script> | |
| <style> | |
| .transcript-container { | |
| scrollbar-width: thin; | |
| scrollbar-color: #4f46e5 #e5e7eb; | |
| } | |
| .transcript-container::-webkit-scrollbar { | |
| width: 8px; | |
| } | |
| .transcript-container::-webkit-scrollbar-track { | |
| background: #e5e7eb; | |
| } | |
| .transcript-container::-webkit-scrollbar-thumb { | |
| background-color: #4f46e5; | |
| border-radius: 4px; | |
| } | |
| .word-timestamp { | |
| transition: all 0.2s ease; | |
| } | |
| .segment:hover .word-timestamp { | |
| opacity: 1; | |
| transform: translateY(0); | |
| } | |
| .fullscreen-transcript { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| z-index: 9999; | |
| background: white; | |
| padding: 2rem; | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-gray-50 min-h-screen"> | |
| <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12"> | |
| <!-- Header --> | |
| <div class="text-center mb-12"> | |
| <h1 class="text-4xl font-bold text-indigo-600 mb-2">VerboseWhisper</h1> | |
| <p class="text-xl text-gray-600">Elite AI-powered transcription for YouTube & TikTok</p> | |
| </div> | |
| <!-- Main Content --> | |
| <div class="flex flex-col lg:flex-row gap-8"> | |
| <!-- Input Panel --> | |
| <div class="w-full lg:w-1/3 bg-white rounded-xl shadow-md p-6 sticky top-4"> | |
| <div class="mb-6"> | |
| <label for="video-url" class="block text-sm font-medium text-gray-700 mb-2">Video URL</label> | |
| <div class="flex"> | |
| <input | |
| type="text" | |
| id="video-url" | |
| placeholder="Paste YouTube or TikTok URL here..." | |
| class="flex-1 min-w-0 block w-full px-3 py-2 rounded-l-md border border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" | |
| > | |
| <button | |
| id="transcribe-btn" | |
| class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-r-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" | |
| > | |
| <span>Transcribe</span> | |
| <i data-feather="mic" class="ml-2"></i> | |
| </button> | |
| </div> | |
| </div> | |
| <div class="mb-6"> | |
| <label class="block text-sm font-medium text-gray-700 mb-2">Options</label> | |
| <div class="space-y-3"> | |
| <div class="flex items-center"> | |
| <input id="autopunct" name="autopunct" type="checkbox" class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"> | |
| <label for="autopunct" class="ml-2 block text-sm text-gray-700">Auto-punctuate</label> | |
| </div> | |
| <div class="flex items-center"> | |
| <input id="preserve-fillers" name="preserve-fillers" type="checkbox" checked class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"> | |
| <label for="preserve-fillers" class="ml-2 block text-sm text-gray-700">Preserve fillers (um, ah)</label> | |
| </div> | |
| <div class="flex items-center"> | |
| <input id="word-timestamps" name="word-timestamps" type="checkbox" checked class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"> | |
| <label for="word-timestamps" class="ml-2 block text-sm text-gray-700">Word-level timestamps</label> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="bg-gray-100 rounded-lg p-4"> | |
| <div class="flex items-center justify-between mb-2"> | |
| <h3 class="text-sm font-medium text-gray-700">Status</h3> | |
| <span id="status-indicator" class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-200 text-gray-800"> | |
| Idle | |
| </span> | |
| </div> | |
| <div class="w-full bg-gray-200 rounded-full h-2.5"> | |
| <div id="progress-bar" class="bg-indigo-600 h-2.5 rounded-full" style="width: 0%"></div> | |
| </div> | |
| <p id="status-detail" class="mt-2 text-xs text-gray-600">Ready to transcribe</p> | |
| </div> | |
| </div> | |
| <!-- Transcript Panel --> | |
| <div id="transcript-container" class="w-full lg:w-2/3 bg-white rounded-xl shadow-md p-6 min-h-[70vh] max-h-[85vh] overflow-y-auto transcript-container relative"> | |
| <div id="loading-indicator" class="absolute inset-0 bg-white bg-opacity-80 z-10 flex items-center justify-center hidden"> | |
| <div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-indigo-500"></div> | |
| </div> | |
| <div class="flex justify-between items-center mb-4"> | |
| <h2 class="text-lg font-medium text-gray-900">Transcript</h2> | |
| <div class="flex space-x-2"> | |
| <button id="increase-font" class="p-1 rounded hover:bg-gray-100"> | |
| <i data-feather="plus" class="w-4 h-4 text-gray-600"></i> | |
| </button> | |
| <button id="decrease-font" class="p-1 rounded hover:bg-gray-100"> | |
| <i data-feather="minus" class="w-4 h-4 text-gray-600"></i> | |
| </button> | |
| <button id="toggle-wrap" class="p-1 rounded hover:bg-gray-100"> | |
| <i data-feather="align-left" class="w-4 h-4 text-gray-600"></i> | |
| </button> | |
| <button id="fullscreen-btn" class="p-1 rounded hover:bg-gray-100"> | |
| <i data-feather="maximize" class="w-4 h-4 text-gray-600"></i> | |
| </button> | |
| <button id="copy-all" class="p-1 rounded hover:bg-gray-100"> | |
| <i data-feather="copy" class="w-4 h-4 text-gray-600"></i> | |
| </button> | |
| <button id="export-btn" class="p-1 rounded hover:bg-gray-100"> | |
| <i data-feather="download" class="w-4 h-4 text-gray-600"></i> | |
| </button> | |
| </div> | |
| </div> | |
| <div id="transcript-content" class="font-mono text-sm"> | |
| <p class="text-gray-400 italic">Transcript will appear here...</p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Export Modal --> | |
| <div id="export-modal" class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden"> | |
| <div class="flex items-center justify-center min-h-screen"> | |
| <div class="bg-white rounded-lg shadow-xl p-6 w-full max-w-md"> | |
| <div class="flex justify-between items-center mb-4"> | |
| <h3 class="text-lg font-medium text-gray-900">Export Transcript</h3> | |
| <button id="close-export-modal" class="text-gray-400 hover:text-gray-500"> | |
| <i data-feather="x" class="w-5 h-5"></i> | |
| </button> | |
| </div> | |
| <div class="space-y-2"> | |
| <button class="export-option w-full flex items-center justify-between px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50"> | |
| <span>Plain Text (.txt)</span> | |
| <i data-feather="file-text" class="w-4 h-4"></i> | |
| </button> | |
| <button class="export-option w-full flex items-center justify-between px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50"> | |
| <span>SubRip Subtitles (.srt)</span> | |
| <i data-feather="file-text" class="w-4 h-4"></i> | |
| </button> | |
| <button class="export-option w-full flex items-center justify-between px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50"> | |
| <span>WebVTT (.vtt)</span> | |
| <i data-feather="file-text" class="w-4 h-4"></i> | |
| </button> | |
| <button class="export-option w-full flex items-center justify-between px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50"> | |
| <span>Word Document (.docx)</span> | |
| <i data-feather="file-text" class="w-4 h-4"></i> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| feather.replace(); | |
| // Constants | |
| const POLL_INTERVAL = 2000; | |
| const MAX_RETRIES = 3; | |
| const RETRY_DELAY = 1000; | |
| // DOM Elements | |
| const transcribeBtn = document.getElementById('transcribe-btn'); | |
| const loadingIndicator = document.getElementById('loading-indicator'); | |
| const videoUrlInput = document.getElementById('video-url'); | |
| const transciptContent = document.getElementById('transcript-content'); | |
| const transciptContainer = document.getElementById('transcript-container'); | |
| const statusIndicator = document.getElementById('status-indicator'); | |
| const statusDetail = document.getElementById('status-detail'); | |
| const progressBar = document.getElementById('progress-bar'); | |
| const fullscreenBtn = document.getElementById('fullscreen-btn'); | |
| const increaseFontBtn = document.getElementById('increase-font'); | |
| const decreaseFontBtn = document.getElementById('decrease-font'); | |
| const toggleWrapBtn = document.getElementById('toggle-wrap'); | |
| const copyAllBtn = document.getElementById('copy-all'); | |
| const exportBtn = document.getElementById('export-btn'); | |
| const exportModal = document.getElementById('export-modal'); | |
| const closeExportModal = document.getElementById('close-export-modal'); | |
| const exportOptions = document.querySelectorAll('.export-option'); | |
| // State | |
| let isFullscreen = false; | |
| let fontSize = 14; | |
| let isWrapped = false; | |
| let currentJobId = null; | |
| let eventSource = null; | |
| // Event Listeners | |
| transcribeBtn.addEventListener('click', handleTranscribe); | |
| fullscreenBtn.addEventListener('click', toggleFullscreen); | |
| increaseFontBtn.addEventListener('click', () => adjustFontSize(1)); | |
| decreaseFontBtn.addEventListener('click', () => adjustFontSize(-1)); | |
| toggleWrapBtn.addEventListener('click', toggleTextWrap); | |
| copyAllBtn.addEventListener('click', copyTranscript); | |
| exportBtn.addEventListener('click', () => exportModal.classList.remove('hidden')); | |
| closeExportModal.addEventListener('click', () => exportModal.classList.add('hidden')); | |
| exportOptions.forEach(option => option.addEventListener('click', handleExport)); | |
| // Functions | |
| // URL validation | |
| function isValidVideoUrl(url) { | |
| try { | |
| const parsed = new URL(url); | |
| const host = parsed.hostname; | |
| const path = parsed.pathname; | |
| // YouTube patterns | |
| const ytPatterns = [ | |
| /youtube\.com\/watch\?v=/, | |
| /youtu\.be\//, | |
| /youtube\.com\/shorts\//, | |
| /youtube\.com\/live\// | |
| ]; | |
| // TikTok patterns | |
| const tiktokPatterns = [ | |
| /tiktok\.com\/@.+\/video\//, | |
| /tiktok\.com\/t\/\w+/, | |
| /vm\.tiktok\.com\/\w+/, | |
| /vt\.tiktok\.com\/\w+/ | |
| ]; | |
| return ytPatterns.some(p => p.test(url)) || | |
| tiktokPatterns.some(p => p.test(url)); | |
| } catch { | |
| return false; | |
| } | |
| } | |
| function handleTranscribe() { | |
| const url = videoUrlInput.value.trim(); | |
| if (!url) { | |
| showError("Please enter a YouTube or TikTok URL"); | |
| return; | |
| } | |
| if (!isValidVideoUrl(url)) { | |
| showError("Please enter a valid YouTube or TikTok URL"); | |
| return; | |
| } | |
| // Disable button during processing | |
| transcribeBtn.disabled = true; | |
| transcribeBtn.innerHTML = '<span>Processing</span><i data-feather="loader" class="ml-2 animate-spin"></i>'; | |
| feather.replace(); | |
| // Reset transcript | |
| transciptContent.innerHTML = '<p class="text-gray-400 italic">Processing transcription...</p>'; | |
| loadingIndicator.classList.remove('hidden'); | |
| // Show status | |
| updateStatus('queued', 'Waiting in queue...', 0); | |
| // Make API call | |
| fetch('/transcribe', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| url: url, | |
| options: { | |
| autopunct: document.getElementById('autopunct').checked, | |
| preserve_fillers: document.getElementById('preserve-fillers').checked, | |
| word_timestamps: document.getElementById('word-timestamps').checked, | |
| chunk_sec: 60 | |
| } | |
| }) | |
| }) | |
| .then(response => { | |
| if (response.status === 202) { | |
| return response.json().then(data => { | |
| currentJobId = data.job_id; | |
| startPolling(data.job_id); | |
| }); | |
| } else if (response.status === 200) { | |
| return response.json().then(data => { | |
| updateTranscript(data); | |
| loadingIndicator.classList.add('hidden'); | |
| transcribeBtn.disabled = false; | |
| transcribeBtn.innerHTML = '<span>Transcribe</span><i data-feather="mic" class="ml-2"></i>'; | |
| feather.replace(); | |
| }); | |
| } else { | |
| throw new Error('Failed to start transcription'); | |
| } | |
| }) | |
| .catch(error => { | |
| showError("Failed to start transcription: " + error.message); | |
| loadingIndicator.classList.add('hidden'); | |
| transcribeBtn.disabled = false; | |
| transcribeBtn.innerHTML = '<span>Transcribe</span><i data-feather="mic" class="ml-2"></i>'; | |
| feather.replace(); | |
| }); | |
| // In real implementation: | |
| // fetch('/transcribe', { | |
| // method: 'POST', | |
| // headers: { 'Content-Type': 'application/json' }, | |
| // body: JSON.stringify({ | |
| // url: url, | |
| // options: { | |
| // autopunct: document.getElementById('autopunct').checked, | |
| // preserve_fillers: document.getElementById('preserve-fillers').checked, | |
| // word_timestamps: document.getElementById('word-timestamps').checked | |
| // } | |
| // }) | |
| // }) | |
| // .then(response => response.json()) | |
| // .then(data => { | |
| // currentJobId = data.job_id; | |
| // startPollingOrSSE(data.job_id); | |
| // }) | |
| // .catch(error => { | |
| // showError("Failed to start transcription: " + error.message); | |
| // transcribeBtn.disabled = false; | |
| // transcribeBtn.innerHTML = '<span>Transcribe</span><i data-feather="mic" class="ml-2"></i>'; | |
| // feather.replace(); | |
| // }); | |
| } | |
| function startPolling(jobId) { | |
| let retryCount = 0; | |
| const poll = () => { | |
| fetch(`/transcribe/${jobId}`) | |
| .then(response => { | |
| if (!response.ok) throw new Error('Polling failed'); | |
| return response.json(); | |
| }) | |
| .then(data => { | |
| if (data.status === 'complete') { | |
| updateTranscript(data.result); | |
| loadingIndicator.classList.add('hidden'); | |
| transcribeBtn.disabled = false; | |
| transcribeBtn.innerHTML = '<span>Transcribe</span><i data-feather="mic" class="ml-2"></i>'; | |
| feather.replace(); | |
| } else if (data.status === 'failed') { | |
| showError("Transcription failed: " + (data.error || 'Unknown error')); | |
| loadingIndicator.classList.add('hidden'); | |
| transcribeBtn.disabled = false; | |
| transcribeBtn.innerHTML = '<span>Transcribe</span><i data-feather="mic" class="ml-2"></i>'; | |
| feather.replace(); | |
| } else { | |
| // Update progress | |
| updateStatus(data.status, data.progress?.stage || 'Processing', data.progress?.percent || 0); | |
| // Update partial results if available | |
| if (data.partial_results) { | |
| updateTranscript({ | |
| segments: data.partial_results, | |
| is_partial: true | |
| }); | |
| } | |
| // Continue polling | |
| setTimeout(poll, POLL_INTERVAL); | |
| } | |
| }) | |
| .catch(error => { | |
| if (retryCount < MAX_RETRIES) { | |
| retryCount++; | |
| setTimeout(poll, RETRY_DELAY * retryCount); | |
| } else { | |
| showError("Failed to get transcription status: " + error.message); | |
| loadingIndicator.classList.add('hidden'); | |
| transcribeBtn.disabled = false; | |
| transcribeBtn.innerHTML = '<span>Transcribe</span><i data-feather="mic" class="ml-2"></i>'; | |
| feather.replace(); | |
| } | |
| }); | |
| }; | |
| poll(); | |
| } | |
| function updateStatus(status, detail, percent) { | |
| statusDetail.textContent = detail; | |
| progressBar.style.width = percent + '%'; | |
| let bgColor = 'bg-gray-200'; | |
| let textColor = 'text-gray-800'; | |
| switch(status) { | |
| case 'queued': | |
| bgColor = 'bg-yellow-100'; | |
| textColor = 'text-yellow-800'; | |
| break; | |
| case 'processing': | |
| bgColor = 'bg-blue-100'; | |
| textColor = 'text-blue-800'; | |
| break; | |
| case 'complete': | |
| bgColor = 'bg-green-100'; | |
| textColor = 'text-green-800'; | |
| break; | |
| case 'failed': | |
| bgColor = 'bg-red-100'; | |
| textColor = 'text-red-800'; | |
| break; | |
| } | |
| statusIndicator.className = `inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${bgColor} ${textColor}`; | |
| statusIndicator.textContent = status.charAt(0).toUpperCase() + status.slice(1); | |
| } | |
| function updateTranscript(data) { | |
| if (!data.segments || data.segments.length === 0) return; | |
| let html = ''; | |
| data.segments.forEach(segment => { | |
| const confidence = segment.words?.[0]?.conf || 1.0; | |
| const confidencePercent = Math.round(confidence * 100); | |
| const confidenceColor = confidence > 0.9 ? 'text-green-600' : | |
| confidence > 0.7 ? 'text-yellow-600' : 'text-red-600'; | |
| html += ` | |
| <div class="segment mb-4 pb-2 border-b border-gray-100"> | |
| <div class="flex justify-between items-start"> | |
| <span class="text-xs font-mono text-gray-500"> | |
| ${formatTime(segment.start)} → ${formatTime(segment.end)} | |
| </span> | |
| <span class="text-xs ${confidenceColor}"> | |
| ${confidencePercent}% conf | |
| </span> | |
| </div> | |
| <p class="mt-1 text-gray-800 ${isWrapped ? 'whitespace-pre-wrap' : 'whitespace-pre'}"> | |
| ${segment.text} | |
| </p> | |
| ${segment.words ? ` | |
| <div class="mt-1 flex flex-wrap gap-1"> | |
| ${segment.words.map(word => ` | |
| <span class="word-timestamp relative group"> | |
| <span class="text-gray-700 hover:text-indigo-600 cursor-pointer"> | |
| ${word.w} | |
| </span> | |
| <span class="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-1 px-2 py-1 text-xs text-white bg-gray-900 rounded opacity-0 group-hover:opacity-100 transition-opacity"> | |
| ${formatTime(word.s)}s | |
| </span> | |
| </span> | |
| `).join('')} | |
| </div> | |
| ` : ''} | |
| </div> | |
| `; | |
| }); | |
| if (data.is_partial) { | |
| transciptContent.innerHTML += html; | |
| } else { | |
| transciptContent.innerHTML = html; | |
| } | |
| // Auto-scroll to bottom if new content is added | |
| if (data.is_partial) { | |
| transciptContainer.scrollTop = transciptContainer.scrollHeight; | |
| } | |
| } | |
| // Debounce the transcribe button | |
| transcribeBtn.addEventListener('click', debounce(handleTranscribe, 1000)); | |
| function debounce(func, wait) { | |
| let timeout; | |
| return function() { | |
| const context = this; | |
| const args = arguments; | |
| clearTimeout(timeout); | |
| timeout = setTimeout(() => { | |
| func.apply(context, args); | |
| }, wait); | |
| }; | |
| } | |
| </script> | |
| </body> | |
| </html> |