// App State Management const state = { videoFile: null, videoUrl: null, audioBuffer: null, wavBlob: null, transcriptData: null, isProcessing: false, isDemoMode: false, currentActiveIndex: -1, apiKey: localStorage.getItem('typhoon_api_key') || 'sk-9gvN1OPfmjUnnBxWTrcLSe5viRBPyzOSdTuR6zaL5HuxRSS6', model: localStorage.getItem('typhoon_model') || 'typhoon-asr-realtime', language: localStorage.getItem('typhoon_language') || '' }; // UI Elements const els = { uploadZone: document.getElementById('uploadZone'), fileInput: document.getElementById('fileInput'), videoContainer: document.getElementById('videoContainer'), videoPlayer: document.getElementById('videoPlayer'), settingsBtn: document.getElementById('settingsBtn'), settingsPanel: document.getElementById('settingsPanel'), closeSettings: document.getElementById('closeSettings'), apiKeyInput: document.getElementById('apiKey'), modelSelect: document.getElementById('modelSelect'), languageSelect: document.getElementById('languageSelect'), saveSettings: document.getElementById('saveSettings'), transcribeBtn: document.getElementById('transcribeBtn'), demoBtn: document.getElementById('demoBtn'), loadingOverlay: document.getElementById('loadingOverlay'), loadingStatus: document.getElementById('loadingStatus'), loadingProgress: document.getElementById('loadingProgress'), progressBarFill: document.getElementById('progressBarFill'), mainDashboard: document.getElementById('mainDashboard'), transcriptPanel: document.getElementById('transcriptPanel'), fullTextContent: document.getElementById('fullTextContent'), segmentsList: document.getElementById('segmentsList'), searchTranscript: document.getElementById('searchTranscript'), tabText: document.getElementById('tabText'), tabSegments: document.getElementById('tabSegments'), panelText: document.getElementById('panelText'), panelSegments: document.getElementById('panelSegments'), exportBtn: document.getElementById('exportBtn'), exportMenu: document.getElementById('exportMenu'), exportTxt: document.getElementById('exportTxt'), exportSrt: document.getElementById('exportSrt'), exportVtt: document.getElementById('exportVtt'), exportJson: document.getElementById('exportJson'), fileInfo: document.getElementById('fileInfo'), fileName: document.getElementById('fileName'), fileSize: document.getElementById('fileSize') }; // Initialize Application function init() { setupEventListeners(); loadSettings(); // Set default api key if present if (state.apiKey) { els.apiKeyInput.value = state.apiKey; } } // Load configurations from state function loadSettings() { els.apiKeyInput.value = state.apiKey; els.modelSelect.value = state.model; els.languageSelect.value = state.language; } // Setup Event Listeners function setupEventListeners() { // Drag and Drop els.uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); els.uploadZone.classList.add('dragover'); }); els.uploadZone.addEventListener('dragleave', () => { els.uploadZone.classList.remove('dragover'); }); els.uploadZone.addEventListener('drop', (e) => { e.preventDefault(); els.uploadZone.classList.remove('dragover'); const file = e.dataTransfer.files[0]; if (file && file.type.startsWith('video/')) { handleVideoSelect(file); } else { showNotification('กรุณาเลือกไฟล์วิดีโอเท่านั้น', 'error'); } }); els.uploadZone.addEventListener('click', () => { els.fileInput.click(); }); els.fileInput.addEventListener('change', (e) => { const file = e.target.files[0]; if (file) { handleVideoSelect(file); } }); // Settings Panel els.settingsBtn.addEventListener('click', () => { els.settingsPanel.classList.toggle('open'); }); els.closeSettings.addEventListener('click', () => { els.settingsPanel.classList.remove('open'); }); els.saveSettings.addEventListener('click', () => { state.apiKey = els.apiKeyInput.value.trim(); state.model = els.modelSelect.value; state.language = els.languageSelect.value; localStorage.setItem('typhoon_api_key', state.apiKey); localStorage.setItem('typhoon_model', state.model); localStorage.setItem('typhoon_language', state.language); els.settingsPanel.classList.remove('open'); showNotification('บันทึกการตั้งค่าแล้ว', 'success'); }); // Transcription Controls els.transcribeBtn.addEventListener('click', () => startTranscriptionFlow(false)); els.demoBtn.addEventListener('click', () => startTranscriptionFlow(true)); // Tab Navigation els.tabText.addEventListener('click', () => switchTab('text')); els.tabSegments.addEventListener('click', () => switchTab('segments')); // Video Timeupdate for Interactive Transcript Sync els.videoPlayer.addEventListener('timeupdate', syncTranscriptHighlight); // Search Transcript els.searchTranscript.addEventListener('input', handleSearch); // Export Dropdown els.exportBtn.addEventListener('click', (e) => { e.stopPropagation(); els.exportMenu.classList.toggle('show'); }); document.addEventListener('click', () => { els.exportMenu.classList.remove('show'); }); els.exportTxt.addEventListener('click', () => exportTranscript('txt')); els.exportSrt.addEventListener('click', () => exportTranscript('srt')); els.exportVtt.addEventListener('click', () => exportTranscript('vtt')); els.exportJson.addEventListener('click', () => exportTranscript('json')); } // Handle File Selection function handleVideoSelect(file) { state.videoFile = file; // Format file size const sizeMB = (file.size / (1024 * 1024)).toFixed(1); els.fileName.textContent = file.name; els.fileSize.textContent = `${sizeMB} MB`; if (file.size > 200 * 1024 * 1024) { showNotification('ไฟล์มีขนาดใหญ่กว่า 200MB การประมวลผลเสียงในเบราว์เซอร์อาจใช้เวลานานขึ้น', 'warning'); } // Create Video URL and load it if (state.videoUrl) { URL.revokeObjectURL(state.videoUrl); } state.videoUrl = URL.createObjectURL(file); els.videoPlayer.src = state.videoUrl; // Reset previous data state.transcriptData = null; state.audioBuffer = null; state.wavBlob = null; els.mainDashboard.classList.add('hidden'); els.fileInfo.classList.remove('hidden'); els.videoContainer.classList.remove('hidden'); showNotification('โหลดไฟล์วิดีโอสำเร็จ', 'success'); } // Switch between full text and interactive segments tabs function switchTab(tabType) { if (tabType === 'text') { els.tabText.classList.add('active'); els.tabSegments.classList.remove('active'); els.panelText.classList.remove('hidden'); els.panelSegments.classList.add('hidden'); } else { els.tabText.classList.remove('active'); els.tabSegments.classList.add('active'); els.panelText.classList.add('hidden'); els.panelSegments.classList.remove('hidden'); } } // Flow Manager: Audio Extraction -> Worker Encoding -> ASR API Call async function startTranscriptionFlow(isDemo = false) { if (!state.videoFile) { showNotification('กรุณาอัปโหลดไฟล์วิดีโอก่อน', 'error'); return; } if (!isDemo && !state.apiKey) { els.settingsPanel.classList.add('open'); showNotification('กรุณากรอก Typhoon API Key ในหน้าการตั้งค่าก่อนเริ่มถอดความ', 'warning'); return; } state.isDemoMode = isDemo; state.isProcessing = true; updateLoadingUI('เริ่มกระบวนการ...', 0); els.loadingOverlay.classList.remove('hidden'); try { // 1. Extract audio updateLoadingUI('กำลังสกัดสัญญาณเสียงจากไฟล์วิดีโอ...', 15); const audioBuffer = await extractAudioFromVideo(state.videoFile); state.audioBuffer = audioBuffer; // 2. Encode to WAV updateLoadingUI('กำลังแปลงสัญญาณเสียงเป็น 16kHz WAV...', 40); const wavBlob = await encodeAudioToWav(audioBuffer); state.wavBlob = wavBlob; // 3. Call Typhoon API if (isDemo) { updateLoadingUI('จำลองการเรียกใช้งาน Typhoon AI (Demo Mode)...', 80); await delay(1500); // Simulate API latency state.transcriptData = generateDemoTranscript(els.videoPlayer.duration || 20); } else { updateLoadingUI('กำลังส่งไฟล์เสียงไปประมวลผลที่ Typhoon AI ASR...', 70); const transcript = await callTyphoonASR(wavBlob); state.transcriptData = transcript; } // 4. Render results renderTranscript(state.transcriptData); els.mainDashboard.classList.remove('hidden'); showNotification('ถอดความวิดีโอสำเร็จ!', 'success'); } catch (error) { console.error(error); showNotification(`เกิดข้อผิดพลาด: ${error.message}`, 'error'); } finally { state.isProcessing = false; els.loadingOverlay.classList.add('hidden'); } } // Update loader screen text and bar width function updateLoadingUI(statusText, progressPercent) { els.loadingStatus.textContent = statusText; els.progressBarFill.style.width = `${progressPercent}%`; els.loadingProgress.textContent = `${progressPercent}%`; } // Web Audio API: Extract audio from video blob async function extractAudioFromVideo(file) { const fileReader = new FileReader(); const arrayBufferPromise = new Promise((resolve, reject) => { fileReader.onload = () => resolve(fileReader.result); fileReader.onerror = () => reject(new Error('ไม่สามารถอ่านไฟล์วิดีโอได้')); }); fileReader.readAsArrayBuffer(file); const arrayBuffer = await arrayBufferPromise; // Use AudioContext to decode const AudioContextClass = window.AudioContext || window.webkitAudioContext; const audioCtx = new AudioContextClass(); try { return await audioCtx.decodeAudioData(arrayBuffer); } catch (e) { throw new Error('ไม่สามารถถอดรหัสเสียงจากวิดีโอนี้ได้ (บราวเซอร์ไม่รองรับ Codec นี้)'); } finally { audioCtx.close(); } } // Web Worker Helper: Encode AudioBuffer to WAV function encodeAudioToWav(audioBuffer) { return new Promise((resolve, reject) => { const channelData = audioBuffer.getChannelData(0); // Mono channel const sampleRate = audioBuffer.sampleRate; // Load web worker const worker = new Worker('wav-worker.js'); worker.postMessage({ channelData: channelData, sampleRate: sampleRate }); worker.onmessage = function(e) { const data = e.data; if (data.type === 'progress') { // Map progress from worker into loader progress (40% to 70% range) const mappedProgress = Math.round(40 + (data.progress * 0.3)); updateLoadingUI(`แปลงสัญญาณเสียงเป็น 16kHz WAV (${data.status === 'downsampling' ? 'Downsampling' : 'Encoding'})...`, mappedProgress); } else if (data.type === 'done') { const blob = new Blob([data.buffer], { type: 'audio/wav' }); worker.terminate(); resolve(blob); } else if (data.type === 'error') { worker.terminate(); reject(new Error(data.message)); } }; worker.onerror = function(err) { worker.terminate(); reject(new Error('เกิดความล้มเหลวในการทำงานของ Web Worker')); }; }); } // Call OpenTyphoon Speech-To-Text API async function callTyphoonASR(wavBlob) { const formData = new FormData(); formData.append('file', wavBlob, 'audio.wav'); formData.append('model', state.model); formData.append('response_format', 'verbose_json'); if (state.language) { formData.append('language', state.language); } const url = 'https://api.opentyphoon.ai/v1/audio/transcriptions'; const headers = { 'Authorization': `Bearer ${state.apiKey}` }; const response = await fetch(url, { method: 'POST', headers: headers, body: formData }); if (!response.ok) { const errorText = await response.text(); let errorJson; try { errorJson = JSON.parse(errorText); } catch(e) {} const msg = errorJson?.error?.message || errorText || `HTTP ${response.status}`; throw new Error(`การสื่อสารกับ API ล้มเหลว: ${msg}`); } return await response.json(); } // Generate high-quality mock data for demo mode function generateDemoTranscript(duration) { const baseSegments = [ { text: "สวัสดีครับ ยินดีต้อนรับสู่แอปพลิเคชันถอดสคริปต์วิดีโอด้วยเทคโนโลยี AI จาก Typhoon" }, { text: "โดยแอปพลิเคชันนี้ทำงานบนเว็บบราวเซอร์ของคุณโดยตรง มีความปลอดภัยสูง และทำงานได้รวดเร็ว" }, { text: "คุณสามารถสกัดเสียงจากวิดีโอ แปลงฟอร์แมต และส่งให้โมเดล Typhoon ASR ถอดความออกมา" }, { text: "ระบบอินเตอร์แอคทีฟของเราจะไฮไลต์ข้อความประโยคต่างๆ ตามที่กำลังเล่นอยู่ในวิดีโอแบบสดๆ" }, { text: "หากคุณต้องการย้อนกลับไปฟังเฉพาะบางคำหรือบางประโยค ก็เพียงแค่คลิกเลือกประโยคนั้นเพื่อรับชมได้ทันที" }, { text: "และสุดท้าย คุณยังสามารถดาวน์โหลดเอกสารเก็บไว้ใช้งานต่อได้ ไม่ว่าจะเป็นรูปแบบไฟล์ Text ธรรมดา หรือ ซับไตเติล SRT และ VTT" } ]; const count = baseSegments.length; const segmentDuration = duration / count; const segments = baseSegments.map((seg, index) => { return { id: index, start: index * segmentDuration, end: (index + 1) * segmentDuration, text: seg.text }; }); const fullText = segments.map(s => s.text).join(' '); return { text: fullText, segments: segments }; } // Render Transcript results into UI function renderTranscript(data) { // Render Full Text els.fullTextContent.innerHTML = `
${data.text}
`; // Render Interactive Segments els.segmentsList.innerHTML = ''; if (data.segments && data.segments.length > 0) { data.segments.forEach((seg, index) => { const segmentEl = document.createElement('div'); segmentEl.className = 'segment-item'; segmentEl.dataset.index = index; segmentEl.dataset.start = seg.start; segmentEl.dataset.end = seg.end; const timeStr = formatTimeShort(seg.start); segmentEl.innerHTML = ` ${timeStr} ${seg.text} `; // Jump video when clicking on a segment (but not if editing text) segmentEl.addEventListener('click', (e) => { if (e.target.classList.contains('segment-text')) { return; // Avoid jumping video when trying to edit text } els.videoPlayer.currentTime = seg.start; els.videoPlayer.play(); }); const textEl = segmentEl.querySelector('.segment-text'); // Save changes on blur textEl.addEventListener('blur', () => { const newText = textEl.textContent.trim(); if (newText !== seg.text) { seg.text = newText; // Sync with state.transcriptData state.transcriptData.text = state.transcriptData.segments.map(s => s.text).join(' '); els.fullTextContent.innerHTML = `${state.transcriptData.text}
`; showNotification('บันทึกการแก้ไขคำถอดความแล้ว', 'success'); } }); // Pressing enter blurs input (saves changes) textEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); textEl.blur(); } }); els.segmentsList.appendChild(segmentEl); }); } else { // If API response doesn't contain segments array, construct single segment els.segmentsList.innerHTML = `${newText}
`; showNotification('บันทึกการแก้ไขคำถอดความแล้ว', 'success'); } }); textEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); textEl.blur(); } }); } } // Sync video timeline with highlighted transcript segment function syncTranscriptHighlight() { if (!state.transcriptData || !state.transcriptData.segments) return; const currentTime = els.videoPlayer.currentTime; const segments = state.transcriptData.segments; // Find current segment index let activeIndex = -1; for (let i = 0; i < segments.length; i++) { if (currentTime >= segments[i].start && currentTime <= segments[i].end) { activeIndex = i; break; } } // If active segment changed, update classes and scroll if (activeIndex !== state.currentActiveIndex) { state.currentActiveIndex = activeIndex; // Remove active state from all const items = els.segmentsList.querySelectorAll('.segment-item'); items.forEach(item => item.classList.remove('active')); if (activeIndex !== -1) { const activeItem = els.segmentsList.querySelector(`.segment-item[data-index="${activeIndex}"]`); if (activeItem) { activeItem.classList.add('active'); // Auto scroll segment list to center the active item const containerHeight = els.segmentsList.clientHeight; const itemTop = activeItem.offsetTop; const itemHeight = activeItem.clientHeight; els.segmentsList.scrollTo({ top: itemTop - (containerHeight / 2) + (itemHeight / 2), behavior: 'smooth' }); } } } } // Handle search functionality inside segments function handleSearch(e) { const query = e.target.value.toLowerCase().trim(); const items = els.segmentsList.querySelectorAll('.segment-item'); items.forEach(item => { const text = item.querySelector('.segment-text').textContent.toLowerCase(); if (text.includes(query)) { item.classList.remove('hidden-search'); // Highlight matching search term in text if (query !== '') { const originalText = state.transcriptData.segments[item.dataset.index].text; const regex = new RegExp(`(${escapeRegExp(query)})`, 'gi'); item.querySelector('.segment-text').innerHTML = originalText.replace(regex, '$1'); } else { item.querySelector('.segment-text').innerHTML = state.transcriptData.segments[item.dataset.index].text; } } else { item.classList.add('hidden-search'); } }); } function escapeRegExp(string) { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } // Helpers: Time Formatting function formatTimeShort(seconds) { const m = Math.floor(seconds / 60).toString().padStart(2, '0'); const s = Math.floor(seconds % 60).toString().padStart(2, '0'); return `${m}:${s}`; } function formatTimeSRT(seconds) { const h = Math.floor(seconds / 3600).toString().padStart(2, '0'); const m = Math.floor((seconds % 3600) / 60).toString().padStart(2, '0'); const s = Math.floor(seconds % 60).toString().padStart(2, '0'); const ms = Math.floor((seconds % 1) * 1000).toString().padStart(3, '0'); return `${h}:${m}:${s},${ms}`; } function formatTimeVTT(seconds) { const h = Math.floor(seconds / 3600).toString().padStart(2, '0'); const m = Math.floor((seconds % 3600) / 60).toString().padStart(2, '0'); const s = Math.floor(seconds % 60).toString().padStart(2, '0'); const ms = Math.floor((seconds % 1) * 1000).toString().padStart(3, '0'); return `${h}:${m}:${s}.${ms}`; } // Export Transcript in diverse formats function exportTranscript(format) { if (!state.transcriptData) { showNotification('ไม่มีข้อมูลทรานสคริปต์ให้ดาวน์โหลด', 'error'); return; } let content = ''; let filename = `${state.videoFile ? state.videoFile.name.replace(/\.[^/.]+$/, "") : "transcript"}`; let mimeType = 'text/plain'; const segments = state.transcriptData.segments || []; if (format === 'txt') { content = state.transcriptData.text; filename += '.txt'; } else if (format === 'srt') { filename += '.srt'; content = segments.map((seg, index) => { return `${index + 1}\n${formatTimeSRT(seg.start)} --> ${formatTimeSRT(seg.end)}\n${seg.text}\n`; }).join('\n'); } else if (format === 'vtt') { filename += '.vtt'; content = 'WEBVTT\n\n' + segments.map((seg, index) => { return `${index + 1}\n${formatTimeVTT(seg.start)} --> ${formatTimeVTT(seg.end)}\n${seg.text}\n`; }).join('\n'); } else if (format === 'json') { filename += '.json'; content = JSON.stringify(state.transcriptData, null, 2); mimeType = 'application/json'; } // Trigger browser download const blob = new Blob([content], { type: `${mimeType};charset=utf-8` }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); showNotification(`ดาวน์โหลดไฟล์ ${format.toUpperCase()} สำเร็จ`, 'success'); } // Utility: Show Toast Notifications function showNotification(message, type = 'info') { // Check if notification container exists, if not create it let container = document.getElementById('notificationContainer'); if (!container) { container = document.createElement('div'); container.id = 'notificationContainer'; document.body.appendChild(container); } const toast = document.createElement('div'); toast.className = `toast toast-${type}`; let icon = '🔔'; if (type === 'success') icon = '✅'; if (type === 'error') icon = '❌'; if (type === 'warning') icon = '⚠️'; toast.innerHTML = ` `; container.appendChild(toast); // Triggers smooth slide in setTimeout(() => toast.classList.add('show'), 10); // Remove toast after 4s setTimeout(() => { toast.classList.remove('show'); setTimeout(() => { toast.remove(); }, 300); }, 4000); } // Delay helper function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } // Start the app when content is loaded document.addEventListener('DOMContentLoaded', init);