transcript_ai / app.js
Janiusx
Deploy Typhoon Video Transcriber
ff7ed83
Raw
History Blame Contribute Delete
24.9 kB
// 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 = `<p>${data.text}</p>`;
// 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 = `
<span class="segment-time" title="คลิกเพื่อข้ามวิดีโอไปช่วงเวลานี้">${timeStr}</span>
<span class="segment-text" contenteditable="true" spellcheck="false" title="คลิกเพื่อแก้ไขข้อความ">${seg.text}</span>
`;
// 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 = `<p>${state.transcriptData.text}</p>`;
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 = `
<div class="segment-item" data-index="0" data-start="0" data-end="${els.videoPlayer.duration || 10}">
<span class="segment-time">00:00</span>
<span class="segment-text" contenteditable="true" spellcheck="false" title="คลิกเพื่อแก้ไขข้อความ">${data.text}</span>
</div>
`;
const segmentEl = els.segmentsList.querySelector('.segment-item');
const textEl = segmentEl.querySelector('.segment-text');
segmentEl.addEventListener('click', (e) => {
if (e.target.classList.contains('segment-text')) return;
els.videoPlayer.currentTime = 0;
els.videoPlayer.play();
});
textEl.addEventListener('blur', () => {
const newText = textEl.textContent.trim();
if (newText !== data.text) {
data.text = newText;
els.fullTextContent.innerHTML = `<p>${newText}</p>`;
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, '<mark class="highlight-mark">$1</mark>');
} 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 = `
<span class="toast-icon">${icon}</span>
<span class="toast-message">${message}</span>
`;
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);