Janiusx commited on
Commit
ff7ed83
·
0 Parent(s):

Deploy Typhoon Video Transcriber

Browse files
Files changed (6) hide show
  1. README.md +51 -0
  2. app.js +672 -0
  3. index.css +823 -0
  4. index.html +187 -0
  5. server.js +48 -0
  6. wav-worker.js +96 -0
README.md ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Typhoon Video Transcriber
3
+ emoji: 🌀
4
+ colorFrom: indigo
5
+ colorTo: purple
6
+ sdk: static
7
+ pinned: false
8
+ ---
9
+
10
+ # Typhoon Video Transcriber
11
+
12
+ แอปพลิเคชันถอดความวิดีโอ (Speech-to-Text) อัจฉริยะในเบราว์เซอร์ ทำงานร่วมกับโมเดลภาษาประมวลผลเสียงภาษาไทยประสิทธิภาพสูงของ **OpenTyphoon ASR API** (SCB 10X)
13
+
14
+ ## คุณสมบัติเด่น
15
+ 1. **แยกเสียงในบราว์เซอร์ (Client-side Extraction):** ถอดรหัสและแยกสัญญาณเสียงออกจากไฟล์วิดีโอโดยตรงด้วย Web Audio API โดยไม่ต้องอัปโหลดวิดีโอทั้งไฟล์ขึ้นเซิร์ฟเวอร์ส่วนกลาง
16
+ 2. **แปลงไฟล์เสียงแบบอะซิงโครนัส (Web Worker WAV Encoder):** แปลงเสียงเป็นรูปแบบ `16-bit Mono WAV 16kHz` ผ่าน Web Worker เบื้องหลัง เพื่อคงความลื่นไหลของหน้าจอ UI แม้ไฟล์วิดีโอมีขนาดใหญ่
17
+ 3. **อินเตอร์เฟสอินเตอร์แอคทีฟ (Interactive Timeline Sync):** ไฮไลต์ประโยคตามวิดีโอที่กำลังเล่นอยู่แบบ Real-time และสามารถคลิกที่ประโยคเพื่อสั่งการให้ตัวเล่นวิดีโอข้ามไปยังส่วนนั้น ๆ ได้ทันที
18
+ 4. **ค้นหาข้อความ (Text Search):** ระบบค้นหาคำและประโยคพร้อมไฮไลต์คำพ้องแบบทันที
19
+ 5. **ฟังก์ชันการ Export:** ดาวน์โหลดสคริปต์ที่ได้ออกมาเป็นไฟล์ Text (.txt), SRT Subtitles (.srt), VTT Subtitles (.vtt) และ JSON (.json)
20
+ 6. **API Key Security & Default:** บันทึกข้อมูลคีย์ใน `localStorage` ของเบราว์เซอร์ของคุณ และเชื่อมต่อโดยตรงกับ OpenTyphoon API เท่านั้น ปราศจากการดักจับหรือเก็บข้อมูลผ่านเซิร์ฟเวอร์ภายนอก
21
+
22
+ ---
23
+
24
+ ## ขั้นตอนการติดตั้งและรันโปรแกรม
25
+
26
+ แอปพลิเคชันนี้ออกแบบมาให้ไม่มี Dependency ภายนอกใด ๆ สามารถเริ่มทำงานได้ทันทีด้วย Node.js:
27
+
28
+ 1. เปิด Terminal หรือ PowerShell ในโฟลเดอร์นี้
29
+ 2. รันคำสั่งเริ่มเซิร์ฟเวอร์:
30
+ ```bash
31
+ node server.js
32
+ ```
33
+ 3. เปิดเว็บเบราว์เซอร์แล้วเข้าใช้งานที่ลิงก์ด้านล่าง:
34
+ [http://localhost:3000](http://localhost:3000)
35
+
36
+ ---
37
+
38
+ ## การเตรียมคีย์ OpenTyphoon API
39
+ - ระบบได้รับการตั้งค่าเริ่มต้นด้วย API Key ของคุณที่ให้มาเรียบร้อยแล้ว
40
+ - หากต้องการเปลี่ยนหรืออัปเดต API Key:
41
+ 1. คลิกปุ่ม **"ตั้งค่า API"** ที่แถบเมนูด้านขวาบน
42
+ 2. ใส่ API Key ของคุณในช่องกรอกข้อมูลแล้วกดบันทึก
43
+
44
+ ---
45
+
46
+ ## ขั้นตอนการใช้งาน
47
+ 1. **อัปโหลดวิดีโอ:** ลากไฟล์วิดีโอของคุณมาวางที่กล่องอัปโหลด หรือกดเพื่อเลือกไฟล์จากเครื่อง
48
+ 2. **เริ่มต้นถอดสคริปต์:**
49
+ - กดปุ่ม **"ถอดความด้วย Typhoon AI"** เพื่อทำการถอดเสียงจริง
50
+ - หรือกดปุ่ม **"ทดลองใช้ระบบพรีวิว (Demo Mode)"** หากต้องการทดสอบระบบนำทาง ข้อความไฮไลต์ และการ Export โดยไม่ตัดโควต้า API จริง
51
+ 3. **ดาวน์โหลดข้อมูล:** คลิกที่ปุ่ม **"ดาวน์โหลด (Export)"** และเลือกฟอร์แมตไฟล���ที่ต้องการบันทึกเก็บไว้ใช้งาน
app.js ADDED
@@ -0,0 +1,672 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // App State Management
2
+ const state = {
3
+ videoFile: null,
4
+ videoUrl: null,
5
+ audioBuffer: null,
6
+ wavBlob: null,
7
+ transcriptData: null,
8
+ isProcessing: false,
9
+ isDemoMode: false,
10
+ currentActiveIndex: -1,
11
+ apiKey: localStorage.getItem('typhoon_api_key') || 'sk-9gvN1OPfmjUnnBxWTrcLSe5viRBPyzOSdTuR6zaL5HuxRSS6',
12
+ model: localStorage.getItem('typhoon_model') || 'typhoon-asr-realtime',
13
+ language: localStorage.getItem('typhoon_language') || ''
14
+ };
15
+
16
+ // UI Elements
17
+ const els = {
18
+ uploadZone: document.getElementById('uploadZone'),
19
+ fileInput: document.getElementById('fileInput'),
20
+ videoContainer: document.getElementById('videoContainer'),
21
+ videoPlayer: document.getElementById('videoPlayer'),
22
+ settingsBtn: document.getElementById('settingsBtn'),
23
+ settingsPanel: document.getElementById('settingsPanel'),
24
+ closeSettings: document.getElementById('closeSettings'),
25
+ apiKeyInput: document.getElementById('apiKey'),
26
+ modelSelect: document.getElementById('modelSelect'),
27
+ languageSelect: document.getElementById('languageSelect'),
28
+ saveSettings: document.getElementById('saveSettings'),
29
+
30
+ transcribeBtn: document.getElementById('transcribeBtn'),
31
+ demoBtn: document.getElementById('demoBtn'),
32
+ loadingOverlay: document.getElementById('loadingOverlay'),
33
+ loadingStatus: document.getElementById('loadingStatus'),
34
+ loadingProgress: document.getElementById('loadingProgress'),
35
+ progressBarFill: document.getElementById('progressBarFill'),
36
+
37
+ mainDashboard: document.getElementById('mainDashboard'),
38
+ transcriptPanel: document.getElementById('transcriptPanel'),
39
+ fullTextContent: document.getElementById('fullTextContent'),
40
+ segmentsList: document.getElementById('segmentsList'),
41
+ searchTranscript: document.getElementById('searchTranscript'),
42
+
43
+ tabText: document.getElementById('tabText'),
44
+ tabSegments: document.getElementById('tabSegments'),
45
+ panelText: document.getElementById('panelText'),
46
+ panelSegments: document.getElementById('panelSegments'),
47
+
48
+ exportBtn: document.getElementById('exportBtn'),
49
+ exportMenu: document.getElementById('exportMenu'),
50
+ exportTxt: document.getElementById('exportTxt'),
51
+ exportSrt: document.getElementById('exportSrt'),
52
+ exportVtt: document.getElementById('exportVtt'),
53
+ exportJson: document.getElementById('exportJson'),
54
+
55
+ fileInfo: document.getElementById('fileInfo'),
56
+ fileName: document.getElementById('fileName'),
57
+ fileSize: document.getElementById('fileSize')
58
+ };
59
+
60
+ // Initialize Application
61
+ function init() {
62
+ setupEventListeners();
63
+ loadSettings();
64
+
65
+ // Set default api key if present
66
+ if (state.apiKey) {
67
+ els.apiKeyInput.value = state.apiKey;
68
+ }
69
+ }
70
+
71
+ // Load configurations from state
72
+ function loadSettings() {
73
+ els.apiKeyInput.value = state.apiKey;
74
+ els.modelSelect.value = state.model;
75
+ els.languageSelect.value = state.language;
76
+ }
77
+
78
+ // Setup Event Listeners
79
+ function setupEventListeners() {
80
+ // Drag and Drop
81
+ els.uploadZone.addEventListener('dragover', (e) => {
82
+ e.preventDefault();
83
+ els.uploadZone.classList.add('dragover');
84
+ });
85
+
86
+ els.uploadZone.addEventListener('dragleave', () => {
87
+ els.uploadZone.classList.remove('dragover');
88
+ });
89
+
90
+ els.uploadZone.addEventListener('drop', (e) => {
91
+ e.preventDefault();
92
+ els.uploadZone.classList.remove('dragover');
93
+ const file = e.dataTransfer.files[0];
94
+ if (file && file.type.startsWith('video/')) {
95
+ handleVideoSelect(file);
96
+ } else {
97
+ showNotification('กรุณาเลือกไฟล์วิดีโอเท่านั้น', 'error');
98
+ }
99
+ });
100
+
101
+ els.uploadZone.addEventListener('click', () => {
102
+ els.fileInput.click();
103
+ });
104
+
105
+ els.fileInput.addEventListener('change', (e) => {
106
+ const file = e.target.files[0];
107
+ if (file) {
108
+ handleVideoSelect(file);
109
+ }
110
+ });
111
+
112
+ // Settings Panel
113
+ els.settingsBtn.addEventListener('click', () => {
114
+ els.settingsPanel.classList.toggle('open');
115
+ });
116
+
117
+ els.closeSettings.addEventListener('click', () => {
118
+ els.settingsPanel.classList.remove('open');
119
+ });
120
+
121
+ els.saveSettings.addEventListener('click', () => {
122
+ state.apiKey = els.apiKeyInput.value.trim();
123
+ state.model = els.modelSelect.value;
124
+ state.language = els.languageSelect.value;
125
+
126
+ localStorage.setItem('typhoon_api_key', state.apiKey);
127
+ localStorage.setItem('typhoon_model', state.model);
128
+ localStorage.setItem('typhoon_language', state.language);
129
+
130
+ els.settingsPanel.classList.remove('open');
131
+ showNotification('บันทึกการตั้งค่าแล้ว', 'success');
132
+ });
133
+
134
+ // Transcription Controls
135
+ els.transcribeBtn.addEventListener('click', () => startTranscriptionFlow(false));
136
+ els.demoBtn.addEventListener('click', () => startTranscriptionFlow(true));
137
+
138
+ // Tab Navigation
139
+ els.tabText.addEventListener('click', () => switchTab('text'));
140
+ els.tabSegments.addEventListener('click', () => switchTab('segments'));
141
+
142
+ // Video Timeupdate for Interactive Transcript Sync
143
+ els.videoPlayer.addEventListener('timeupdate', syncTranscriptHighlight);
144
+
145
+ // Search Transcript
146
+ els.searchTranscript.addEventListener('input', handleSearch);
147
+
148
+ // Export Dropdown
149
+ els.exportBtn.addEventListener('click', (e) => {
150
+ e.stopPropagation();
151
+ els.exportMenu.classList.toggle('show');
152
+ });
153
+
154
+ document.addEventListener('click', () => {
155
+ els.exportMenu.classList.remove('show');
156
+ });
157
+
158
+ els.exportTxt.addEventListener('click', () => exportTranscript('txt'));
159
+ els.exportSrt.addEventListener('click', () => exportTranscript('srt'));
160
+ els.exportVtt.addEventListener('click', () => exportTranscript('vtt'));
161
+ els.exportJson.addEventListener('click', () => exportTranscript('json'));
162
+ }
163
+
164
+ // Handle File Selection
165
+ function handleVideoSelect(file) {
166
+ state.videoFile = file;
167
+
168
+ // Format file size
169
+ const sizeMB = (file.size / (1024 * 1024)).toFixed(1);
170
+ els.fileName.textContent = file.name;
171
+ els.fileSize.textContent = `${sizeMB} MB`;
172
+
173
+ if (file.size > 200 * 1024 * 1024) {
174
+ showNotification('ไฟล์มีขนาดใหญ่กว่า 200MB การประมวลผลเสียงในเบราว์เซอร์อาจใช้เวลานานขึ้น', 'warning');
175
+ }
176
+
177
+ // Create Video URL and load it
178
+ if (state.videoUrl) {
179
+ URL.revokeObjectURL(state.videoUrl);
180
+ }
181
+ state.videoUrl = URL.createObjectURL(file);
182
+ els.videoPlayer.src = state.videoUrl;
183
+
184
+ // Reset previous data
185
+ state.transcriptData = null;
186
+ state.audioBuffer = null;
187
+ state.wavBlob = null;
188
+ els.mainDashboard.classList.add('hidden');
189
+ els.fileInfo.classList.remove('hidden');
190
+ els.videoContainer.classList.remove('hidden');
191
+
192
+ showNotification('โหลดไฟล์วิดีโอสำเร็จ', 'success');
193
+ }
194
+
195
+ // Switch between full text and interactive segments tabs
196
+ function switchTab(tabType) {
197
+ if (tabType === 'text') {
198
+ els.tabText.classList.add('active');
199
+ els.tabSegments.classList.remove('active');
200
+ els.panelText.classList.remove('hidden');
201
+ els.panelSegments.classList.add('hidden');
202
+ } else {
203
+ els.tabText.classList.remove('active');
204
+ els.tabSegments.classList.add('active');
205
+ els.panelText.classList.add('hidden');
206
+ els.panelSegments.classList.remove('hidden');
207
+ }
208
+ }
209
+
210
+ // Flow Manager: Audio Extraction -> Worker Encoding -> ASR API Call
211
+ async function startTranscriptionFlow(isDemo = false) {
212
+ if (!state.videoFile) {
213
+ showNotification('กรุณาอัปโหลดไฟล์วิดีโอก่อน', 'error');
214
+ return;
215
+ }
216
+
217
+ if (!isDemo && !state.apiKey) {
218
+ els.settingsPanel.classList.add('open');
219
+ showNotification('กรุณากรอก Typhoon API Key ในหน้าการตั้งค่าก่อนเริ่มถอดความ', 'warning');
220
+ return;
221
+ }
222
+
223
+ state.isDemoMode = isDemo;
224
+ state.isProcessing = true;
225
+ updateLoadingUI('เริ่มกระบวนการ...', 0);
226
+ els.loadingOverlay.classList.remove('hidden');
227
+
228
+ try {
229
+ // 1. Extract audio
230
+ updateLoadingUI('กำลังสกัดสัญญาณเสียงจากไฟล์วิดีโอ...', 15);
231
+ const audioBuffer = await extractAudioFromVideo(state.videoFile);
232
+ state.audioBuffer = audioBuffer;
233
+
234
+ // 2. Encode to WAV
235
+ updateLoadingUI('กำลังแปลงสัญญาณเสียงเป็น 16kHz WAV...', 40);
236
+ const wavBlob = await encodeAudioToWav(audioBuffer);
237
+ state.wavBlob = wavBlob;
238
+
239
+ // 3. Call Typhoon API
240
+ if (isDemo) {
241
+ updateLoadingUI('จำลองการเรียกใช้งาน Typhoon AI (Demo Mode)...', 80);
242
+ await delay(1500); // Simulate API latency
243
+ state.transcriptData = generateDemoTranscript(els.videoPlayer.duration || 20);
244
+ } else {
245
+ updateLoadingUI('กำลังส่งไฟล์เสียงไปประมวลผลที่ Typhoon AI ASR...', 70);
246
+ const transcript = await callTyphoonASR(wavBlob);
247
+ state.transcriptData = transcript;
248
+ }
249
+
250
+ // 4. Render results
251
+ renderTranscript(state.transcriptData);
252
+ els.mainDashboard.classList.remove('hidden');
253
+ showNotification('ถอดความวิดีโอสำเร็จ!', 'success');
254
+ } catch (error) {
255
+ console.error(error);
256
+ showNotification(`เกิดข้อผิดพลาด: ${error.message}`, 'error');
257
+ } finally {
258
+ state.isProcessing = false;
259
+ els.loadingOverlay.classList.add('hidden');
260
+ }
261
+ }
262
+
263
+ // Update loader screen text and bar width
264
+ function updateLoadingUI(statusText, progressPercent) {
265
+ els.loadingStatus.textContent = statusText;
266
+ els.progressBarFill.style.width = `${progressPercent}%`;
267
+ els.loadingProgress.textContent = `${progressPercent}%`;
268
+ }
269
+
270
+ // Web Audio API: Extract audio from video blob
271
+ async function extractAudioFromVideo(file) {
272
+ const fileReader = new FileReader();
273
+
274
+ const arrayBufferPromise = new Promise((resolve, reject) => {
275
+ fileReader.onload = () => resolve(fileReader.result);
276
+ fileReader.onerror = () => reject(new Error('ไม่สามารถอ่านไฟล์วิดีโอได้'));
277
+ });
278
+
279
+ fileReader.readAsArrayBuffer(file);
280
+ const arrayBuffer = await arrayBufferPromise;
281
+
282
+ // Use AudioContext to decode
283
+ const AudioContextClass = window.AudioContext || window.webkitAudioContext;
284
+ const audioCtx = new AudioContextClass();
285
+
286
+ try {
287
+ return await audioCtx.decodeAudioData(arrayBuffer);
288
+ } catch (e) {
289
+ throw new Error('ไม่สามารถถอดรหัสเสียงจากวิดีโอนี้ได้ (บราวเซอร์ไม่รองรับ Codec นี้)');
290
+ } finally {
291
+ audioCtx.close();
292
+ }
293
+ }
294
+
295
+ // Web Worker Helper: Encode AudioBuffer to WAV
296
+ function encodeAudioToWav(audioBuffer) {
297
+ return new Promise((resolve, reject) => {
298
+ const channelData = audioBuffer.getChannelData(0); // Mono channel
299
+ const sampleRate = audioBuffer.sampleRate;
300
+
301
+ // Load web worker
302
+ const worker = new Worker('wav-worker.js');
303
+
304
+ worker.postMessage({
305
+ channelData: channelData,
306
+ sampleRate: sampleRate
307
+ });
308
+
309
+ worker.onmessage = function(e) {
310
+ const data = e.data;
311
+ if (data.type === 'progress') {
312
+ // Map progress from worker into loader progress (40% to 70% range)
313
+ const mappedProgress = Math.round(40 + (data.progress * 0.3));
314
+ updateLoadingUI(`แปลงสัญญาณเสียงเป็น 16kHz WAV (${data.status === 'downsampling' ? 'Downsampling' : 'Encoding'})...`, mappedProgress);
315
+ } else if (data.type === 'done') {
316
+ const blob = new Blob([data.buffer], { type: 'audio/wav' });
317
+ worker.terminate();
318
+ resolve(blob);
319
+ } else if (data.type === 'error') {
320
+ worker.terminate();
321
+ reject(new Error(data.message));
322
+ }
323
+ };
324
+
325
+ worker.onerror = function(err) {
326
+ worker.terminate();
327
+ reject(new Error('เกิดความล้มเหลวในการทำงานของ Web Worker'));
328
+ };
329
+ });
330
+ }
331
+
332
+ // Call OpenTyphoon Speech-To-Text API
333
+ async function callTyphoonASR(wavBlob) {
334
+ const formData = new FormData();
335
+ formData.append('file', wavBlob, 'audio.wav');
336
+ formData.append('model', state.model);
337
+ formData.append('response_format', 'verbose_json');
338
+
339
+ if (state.language) {
340
+ formData.append('language', state.language);
341
+ }
342
+
343
+ const url = 'https://api.opentyphoon.ai/v1/audio/transcriptions';
344
+ const headers = {
345
+ 'Authorization': `Bearer ${state.apiKey}`
346
+ };
347
+
348
+ const response = await fetch(url, {
349
+ method: 'POST',
350
+ headers: headers,
351
+ body: formData
352
+ });
353
+
354
+ if (!response.ok) {
355
+ const errorText = await response.text();
356
+ let errorJson;
357
+ try {
358
+ errorJson = JSON.parse(errorText);
359
+ } catch(e) {}
360
+ const msg = errorJson?.error?.message || errorText || `HTTP ${response.status}`;
361
+ throw new Error(`การสื่อสารกับ API ล้มเหลว: ${msg}`);
362
+ }
363
+
364
+ return await response.json();
365
+ }
366
+
367
+ // Generate high-quality mock data for demo mode
368
+ function generateDemoTranscript(duration) {
369
+ const baseSegments = [
370
+ { text: "สวัสดีครับ ยินดีต้อนรับสู่แอปพลิเคชันถอดสคริปต์วิดีโอด้วยเทคโนโลยี AI จาก Typhoon" },
371
+ { text: "โดยแอปพลิเคชันนี้ทำงานบนเว็บบราวเซอร์ของคุณโดยตรง มีความปลอดภัยสูง และทำงานได้รวดเร็ว" },
372
+ { text: "คุณสามารถสกัดเสียงจากวิดีโอ แปลงฟอร์แมต และส่งให้โมเดล Typhoon ASR ถอดความออกมา" },
373
+ { text: "ระบบอินเตอร์แอคทีฟของเราจะไฮไลต์ข้อความประโยคต่างๆ ตามที่กำลังเล่นอยู่ในวิดีโอแบบสดๆ" },
374
+ { text: "หากคุณต้องการย้อนกลับไปฟังเฉพาะบางคำหรือบางประโยค ก็เพียงแค่คลิกเลือกประโยคนั้นเพื่อรับชมได้ทันที" },
375
+ { text: "และสุดท้าย คุณยังสามารถดาวน์โหลดเอกสารเก็บไว้ใช้งานต่อได้ ไม่ว่าจะเป็นรูปแบบไฟล์ Text ธรรมดา หรือ ซับไตเติล SRT และ VTT" }
376
+ ];
377
+
378
+ const count = baseSegments.length;
379
+ const segmentDuration = duration / count;
380
+
381
+ const segments = baseSegments.map((seg, index) => {
382
+ return {
383
+ id: index,
384
+ start: index * segmentDuration,
385
+ end: (index + 1) * segmentDuration,
386
+ text: seg.text
387
+ };
388
+ });
389
+
390
+ const fullText = segments.map(s => s.text).join(' ');
391
+
392
+ return {
393
+ text: fullText,
394
+ segments: segments
395
+ };
396
+ }
397
+
398
+ // Render Transcript results into UI
399
+ function renderTranscript(data) {
400
+ // Render Full Text
401
+ els.fullTextContent.innerHTML = `<p>${data.text}</p>`;
402
+
403
+ // Render Interactive Segments
404
+ els.segmentsList.innerHTML = '';
405
+
406
+ if (data.segments && data.segments.length > 0) {
407
+ data.segments.forEach((seg, index) => {
408
+ const segmentEl = document.createElement('div');
409
+ segmentEl.className = 'segment-item';
410
+ segmentEl.dataset.index = index;
411
+ segmentEl.dataset.start = seg.start;
412
+ segmentEl.dataset.end = seg.end;
413
+
414
+ const timeStr = formatTimeShort(seg.start);
415
+
416
+ segmentEl.innerHTML = `
417
+ <span class="segment-time" title="คลิกเพื่อข้ามวิดีโอไปช่วงเวลานี้">${timeStr}</span>
418
+ <span class="segment-text" contenteditable="true" spellcheck="false" title="คลิกเพื่อแก้ไขข้อความ">${seg.text}</span>
419
+ `;
420
+
421
+ // Jump video when clicking on a segment (but not if editing text)
422
+ segmentEl.addEventListener('click', (e) => {
423
+ if (e.target.classList.contains('segment-text')) {
424
+ return; // Avoid jumping video when trying to edit text
425
+ }
426
+ els.videoPlayer.currentTime = seg.start;
427
+ els.videoPlayer.play();
428
+ });
429
+
430
+ const textEl = segmentEl.querySelector('.segment-text');
431
+
432
+ // Save changes on blur
433
+ textEl.addEventListener('blur', () => {
434
+ const newText = textEl.textContent.trim();
435
+ if (newText !== seg.text) {
436
+ seg.text = newText;
437
+ // Sync with state.transcriptData
438
+ state.transcriptData.text = state.transcriptData.segments.map(s => s.text).join(' ');
439
+ els.fullTextContent.innerHTML = `<p>${state.transcriptData.text}</p>`;
440
+ showNotification('บันทึกการแก้ไขคำถอดความแล้ว', 'success');
441
+ }
442
+ });
443
+
444
+ // Pressing enter blurs input (saves changes)
445
+ textEl.addEventListener('keydown', (e) => {
446
+ if (e.key === 'Enter') {
447
+ e.preventDefault();
448
+ textEl.blur();
449
+ }
450
+ });
451
+
452
+ els.segmentsList.appendChild(segmentEl);
453
+ });
454
+ } else {
455
+ // If API response doesn't contain segments array, construct single segment
456
+ els.segmentsList.innerHTML = `
457
+ <div class="segment-item" data-index="0" data-start="0" data-end="${els.videoPlayer.duration || 10}">
458
+ <span class="segment-time">00:00</span>
459
+ <span class="segment-text" contenteditable="true" spellcheck="false" title="คลิกเพื่อแก้ไขข้อความ">${data.text}</span>
460
+ </div>
461
+ `;
462
+
463
+ const segmentEl = els.segmentsList.querySelector('.segment-item');
464
+ const textEl = segmentEl.querySelector('.segment-text');
465
+
466
+ segmentEl.addEventListener('click', (e) => {
467
+ if (e.target.classList.contains('segment-text')) return;
468
+ els.videoPlayer.currentTime = 0;
469
+ els.videoPlayer.play();
470
+ });
471
+
472
+ textEl.addEventListener('blur', () => {
473
+ const newText = textEl.textContent.trim();
474
+ if (newText !== data.text) {
475
+ data.text = newText;
476
+ els.fullTextContent.innerHTML = `<p>${newText}</p>`;
477
+ showNotification('บันทึกการแก้ไขคำถอดความแล้ว', 'success');
478
+ }
479
+ });
480
+
481
+ textEl.addEventListener('keydown', (e) => {
482
+ if (e.key === 'Enter') {
483
+ e.preventDefault();
484
+ textEl.blur();
485
+ }
486
+ });
487
+ }
488
+ }
489
+
490
+ // Sync video timeline with highlighted transcript segment
491
+ function syncTranscriptHighlight() {
492
+ if (!state.transcriptData || !state.transcriptData.segments) return;
493
+
494
+ const currentTime = els.videoPlayer.currentTime;
495
+ const segments = state.transcriptData.segments;
496
+
497
+ // Find current segment index
498
+ let activeIndex = -1;
499
+ for (let i = 0; i < segments.length; i++) {
500
+ if (currentTime >= segments[i].start && currentTime <= segments[i].end) {
501
+ activeIndex = i;
502
+ break;
503
+ }
504
+ }
505
+
506
+ // If active segment changed, update classes and scroll
507
+ if (activeIndex !== state.currentActiveIndex) {
508
+ state.currentActiveIndex = activeIndex;
509
+
510
+ // Remove active state from all
511
+ const items = els.segmentsList.querySelectorAll('.segment-item');
512
+ items.forEach(item => item.classList.remove('active'));
513
+
514
+ if (activeIndex !== -1) {
515
+ const activeItem = els.segmentsList.querySelector(`.segment-item[data-index="${activeIndex}"]`);
516
+ if (activeItem) {
517
+ activeItem.classList.add('active');
518
+
519
+ // Auto scroll segment list to center the active item
520
+ const containerHeight = els.segmentsList.clientHeight;
521
+ const itemTop = activeItem.offsetTop;
522
+ const itemHeight = activeItem.clientHeight;
523
+
524
+ els.segmentsList.scrollTo({
525
+ top: itemTop - (containerHeight / 2) + (itemHeight / 2),
526
+ behavior: 'smooth'
527
+ });
528
+ }
529
+ }
530
+ }
531
+ }
532
+
533
+ // Handle search functionality inside segments
534
+ function handleSearch(e) {
535
+ const query = e.target.value.toLowerCase().trim();
536
+ const items = els.segmentsList.querySelectorAll('.segment-item');
537
+
538
+ items.forEach(item => {
539
+ const text = item.querySelector('.segment-text').textContent.toLowerCase();
540
+ if (text.includes(query)) {
541
+ item.classList.remove('hidden-search');
542
+ // Highlight matching search term in text
543
+ if (query !== '') {
544
+ const originalText = state.transcriptData.segments[item.dataset.index].text;
545
+ const regex = new RegExp(`(${escapeRegExp(query)})`, 'gi');
546
+ item.querySelector('.segment-text').innerHTML = originalText.replace(regex, '<mark class="highlight-mark">$1</mark>');
547
+ } else {
548
+ item.querySelector('.segment-text').innerHTML = state.transcriptData.segments[item.dataset.index].text;
549
+ }
550
+ } else {
551
+ item.classList.add('hidden-search');
552
+ }
553
+ });
554
+ }
555
+
556
+ function escapeRegExp(string) {
557
+ return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
558
+ }
559
+
560
+ // Helpers: Time Formatting
561
+ function formatTimeShort(seconds) {
562
+ const m = Math.floor(seconds / 60).toString().padStart(2, '0');
563
+ const s = Math.floor(seconds % 60).toString().padStart(2, '0');
564
+ return `${m}:${s}`;
565
+ }
566
+
567
+ function formatTimeSRT(seconds) {
568
+ const h = Math.floor(seconds / 3600).toString().padStart(2, '0');
569
+ const m = Math.floor((seconds % 3600) / 60).toString().padStart(2, '0');
570
+ const s = Math.floor(seconds % 60).toString().padStart(2, '0');
571
+ const ms = Math.floor((seconds % 1) * 1000).toString().padStart(3, '0');
572
+ return `${h}:${m}:${s},${ms}`;
573
+ }
574
+
575
+ function formatTimeVTT(seconds) {
576
+ const h = Math.floor(seconds / 3600).toString().padStart(2, '0');
577
+ const m = Math.floor((seconds % 3600) / 60).toString().padStart(2, '0');
578
+ const s = Math.floor(seconds % 60).toString().padStart(2, '0');
579
+ const ms = Math.floor((seconds % 1) * 1000).toString().padStart(3, '0');
580
+ return `${h}:${m}:${s}.${ms}`;
581
+ }
582
+
583
+ // Export Transcript in diverse formats
584
+ function exportTranscript(format) {
585
+ if (!state.transcriptData) {
586
+ showNotification('ไม่มีข้อมูลทรานสคริปต์ให้ดาวน์โหลด', 'error');
587
+ return;
588
+ }
589
+
590
+ let content = '';
591
+ let filename = `${state.videoFile ? state.videoFile.name.replace(/\.[^/.]+$/, "") : "transcript"}`;
592
+ let mimeType = 'text/plain';
593
+
594
+ const segments = state.transcriptData.segments || [];
595
+
596
+ if (format === 'txt') {
597
+ content = state.transcriptData.text;
598
+ filename += '.txt';
599
+ } else if (format === 'srt') {
600
+ filename += '.srt';
601
+ content = segments.map((seg, index) => {
602
+ return `${index + 1}\n${formatTimeSRT(seg.start)} --> ${formatTimeSRT(seg.end)}\n${seg.text}\n`;
603
+ }).join('\n');
604
+ } else if (format === 'vtt') {
605
+ filename += '.vtt';
606
+ content = 'WEBVTT\n\n' + segments.map((seg, index) => {
607
+ return `${index + 1}\n${formatTimeVTT(seg.start)} --> ${formatTimeVTT(seg.end)}\n${seg.text}\n`;
608
+ }).join('\n');
609
+ } else if (format === 'json') {
610
+ filename += '.json';
611
+ content = JSON.stringify(state.transcriptData, null, 2);
612
+ mimeType = 'application/json';
613
+ }
614
+
615
+ // Trigger browser download
616
+ const blob = new Blob([content], { type: `${mimeType};charset=utf-8` });
617
+ const url = URL.createObjectURL(blob);
618
+ const a = document.createElement('a');
619
+ a.href = url;
620
+ a.download = filename;
621
+ document.body.appendChild(a);
622
+ a.click();
623
+ document.body.removeChild(a);
624
+ URL.revokeObjectURL(url);
625
+
626
+ showNotification(`ดาวน์โหลดไฟล์ ${format.toUpperCase()} สำเร็จ`, 'success');
627
+ }
628
+
629
+ // Utility: Show Toast Notifications
630
+ function showNotification(message, type = 'info') {
631
+ // Check if notification container exists, if not create it
632
+ let container = document.getElementById('notificationContainer');
633
+ if (!container) {
634
+ container = document.createElement('div');
635
+ container.id = 'notificationContainer';
636
+ document.body.appendChild(container);
637
+ }
638
+
639
+ const toast = document.createElement('div');
640
+ toast.className = `toast toast-${type}`;
641
+
642
+ let icon = '🔔';
643
+ if (type === 'success') icon = '✅';
644
+ if (type === 'error') icon = '❌';
645
+ if (type === 'warning') icon = '⚠️';
646
+
647
+ toast.innerHTML = `
648
+ <span class="toast-icon">${icon}</span>
649
+ <span class="toast-message">${message}</span>
650
+ `;
651
+
652
+ container.appendChild(toast);
653
+
654
+ // Triggers smooth slide in
655
+ setTimeout(() => toast.classList.add('show'), 10);
656
+
657
+ // Remove toast after 4s
658
+ setTimeout(() => {
659
+ toast.classList.remove('show');
660
+ setTimeout(() => {
661
+ toast.remove();
662
+ }, 300);
663
+ }, 4000);
664
+ }
665
+
666
+ // Delay helper
667
+ function delay(ms) {
668
+ return new Promise(resolve => setTimeout(resolve, ms));
669
+ }
670
+
671
+ // Start the app when content is loaded
672
+ document.addEventListener('DOMContentLoaded', init);
index.css ADDED
@@ -0,0 +1,823 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Import premium fonts: Outfit for English & Sarabun for Thai */
2
+ @import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=Sarabun:wght@300;400;500;600;700&display=swap');
3
+
4
+ :root {
5
+ --bg-primary: #080B11;
6
+ --bg-secondary: rgba(17, 24, 39, 0.7);
7
+ --bg-panel: rgba(30, 41, 59, 0.45);
8
+ --border-color: rgba(255, 255, 255, 0.08);
9
+
10
+ --primary: #6366f1;
11
+ --primary-glow: rgba(99, 102, 241, 0.35);
12
+ --primary-hover: #4f46e5;
13
+ --accent: #8b5cf6;
14
+ --accent-glow: rgba(139, 92, 246, 0.3);
15
+
16
+ --text-main: #f8fafc;
17
+ --text-muted: #94a3b8;
18
+ --text-dark: #64748b;
19
+
20
+ --success: #10b981;
21
+ --error: #ef4444;
22
+ --warning: #f59e0b;
23
+
24
+ --glass-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
25
+ --font-family: 'Outfit', 'Sarabun', system-ui, -apple-system, sans-serif;
26
+ --transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
27
+ }
28
+
29
+ /* Reset and base styles */
30
+ * {
31
+ box-sizing: border-box;
32
+ margin: 0;
33
+ padding: 0;
34
+ }
35
+
36
+ body {
37
+ background-color: var(--bg-primary);
38
+ background-image:
39
+ radial-gradient(at 0% 0%, rgba(99, 102, 241, 0.1) 0px, transparent 50%),
40
+ radial-gradient(at 100% 100%, rgba(139, 92, 246, 0.1) 0px, transparent 50%);
41
+ background-attachment: fixed;
42
+ color: var(--text-main);
43
+ font-family: var(--font-family);
44
+ min-height: 100vh;
45
+ display: flex;
46
+ flex-direction: column;
47
+ overflow-x: hidden;
48
+ }
49
+
50
+ /* Scrollbar styling */
51
+ ::-webkit-scrollbar {
52
+ width: 6px;
53
+ height: 6px;
54
+ }
55
+
56
+ ::-webkit-scrollbar-track {
57
+ background: rgba(255, 255, 255, 0.02);
58
+ }
59
+
60
+ ::-webkit-scrollbar-thumb {
61
+ background: rgba(255, 255, 255, 0.15);
62
+ border-radius: 4px;
63
+ }
64
+
65
+ ::-webkit-scrollbar-thumb:hover {
66
+ background: var(--primary);
67
+ }
68
+
69
+ /* Header */
70
+ header {
71
+ backdrop-filter: blur(12px);
72
+ background: rgba(8, 11, 17, 0.85);
73
+ border-bottom: 1px solid var(--border-color);
74
+ padding: 1.25rem 2rem;
75
+ display: flex;
76
+ justify-content: space-between;
77
+ align-items: center;
78
+ position: sticky;
79
+ top: 0;
80
+ z-index: 50;
81
+ }
82
+
83
+ .logo-container {
84
+ display: flex;
85
+ align-items: center;
86
+ gap: 0.75rem;
87
+ }
88
+
89
+ .logo-icon {
90
+ background: linear-gradient(135deg, var(--primary), var(--accent));
91
+ width: 38px;
92
+ height: 38px;
93
+ border-radius: 10px;
94
+ display: flex;
95
+ align-items: center;
96
+ justify-content: center;
97
+ font-weight: 700;
98
+ font-size: 1.2rem;
99
+ box-shadow: 0 0 15px var(--primary-glow);
100
+ }
101
+
102
+ h1 {
103
+ font-size: 1.35rem;
104
+ font-weight: 600;
105
+ letter-spacing: -0.5px;
106
+ background: linear-gradient(to right, #ffffff, #cbd5e1);
107
+ -webkit-background-clip: text;
108
+ -webkit-text-fill-color: transparent;
109
+ }
110
+
111
+ /* Controls & Settings Trigger */
112
+ .header-actions {
113
+ display: flex;
114
+ gap: 1rem;
115
+ }
116
+
117
+ .btn {
118
+ background: rgba(255, 255, 255, 0.05);
119
+ border: 1px solid var(--border-color);
120
+ color: var(--text-main);
121
+ cursor: pointer;
122
+ display: inline-flex;
123
+ align-items: center;
124
+ gap: 0.5rem;
125
+ font-family: var(--font-family);
126
+ font-size: 0.9rem;
127
+ font-weight: 500;
128
+ padding: 0.6rem 1.2rem;
129
+ border-radius: 8px;
130
+ transition: var(--transition-smooth);
131
+ }
132
+
133
+ .btn:hover {
134
+ background: rgba(255, 255, 255, 0.1);
135
+ border-color: rgba(255, 255, 255, 0.2);
136
+ }
137
+
138
+ .btn-primary {
139
+ background: linear-gradient(135deg, var(--primary), var(--accent));
140
+ border: none;
141
+ box-shadow: 0 4px 14px var(--primary-glow);
142
+ }
143
+
144
+ .btn-primary:hover {
145
+ transform: translateY(-1px);
146
+ box-shadow: 0 6px 20px var(--primary-glow);
147
+ filter: brightness(1.1);
148
+ }
149
+
150
+ /* Main Container */
151
+ main {
152
+ flex: 1;
153
+ max-width: 1400px;
154
+ width: 100%;
155
+ margin: 0 auto;
156
+ padding: 2rem;
157
+ display: flex;
158
+ flex-direction: column;
159
+ gap: 2rem;
160
+ }
161
+
162
+ /* Upload Container */
163
+ .upload-container {
164
+ background: var(--bg-secondary);
165
+ border: 2px dashed rgba(99, 102, 241, 0.3);
166
+ border-radius: 16px;
167
+ padding: 4rem 2rem;
168
+ text-align: center;
169
+ cursor: pointer;
170
+ backdrop-filter: blur(16px);
171
+ box-shadow: var(--glass-shadow);
172
+ transition: var(--transition-smooth);
173
+ display: flex;
174
+ flex-direction: column;
175
+ align-items: center;
176
+ justify-content: center;
177
+ gap: 1.5rem;
178
+ }
179
+
180
+ .upload-container:hover, .upload-container.dragover {
181
+ border-color: var(--primary);
182
+ background: rgba(99, 102, 241, 0.05);
183
+ box-shadow: 0 0 30px rgba(99, 102, 241, 0.15);
184
+ }
185
+
186
+ .upload-icon {
187
+ width: 80px;
188
+ height: 80px;
189
+ background: rgba(99, 102, 241, 0.1);
190
+ border-radius: 50%;
191
+ display: flex;
192
+ align-items: center;
193
+ justify-content: center;
194
+ color: var(--primary);
195
+ font-size: 2.2rem;
196
+ transition: var(--transition-smooth);
197
+ }
198
+
199
+ .upload-container:hover .upload-icon {
200
+ transform: scale(1.1);
201
+ background: rgba(99, 102, 241, 0.2);
202
+ color: var(--accent);
203
+ }
204
+
205
+ .upload-text h3 {
206
+ font-size: 1.25rem;
207
+ font-weight: 600;
208
+ margin-bottom: 0.5rem;
209
+ }
210
+
211
+ .upload-text p {
212
+ color: var(--text-muted);
213
+ font-size: 0.9rem;
214
+ }
215
+
216
+ /* File Info Bar */
217
+ .file-info-bar {
218
+ background: rgba(255, 255, 255, 0.03);
219
+ border: 1px solid var(--border-color);
220
+ border-radius: 12px;
221
+ padding: 1rem 1.5rem;
222
+ display: flex;
223
+ justify-content: space-between;
224
+ align-items: center;
225
+ animation: fadeIn 0.3s ease;
226
+ }
227
+
228
+ .file-meta {
229
+ display: flex;
230
+ align-items: center;
231
+ gap: 0.75rem;
232
+ }
233
+
234
+ .file-icon {
235
+ font-size: 1.5rem;
236
+ color: var(--primary);
237
+ }
238
+
239
+ .file-details h4 {
240
+ font-size: 0.95rem;
241
+ font-weight: 500;
242
+ }
243
+
244
+ .file-details span {
245
+ font-size: 0.8rem;
246
+ color: var(--text-muted);
247
+ }
248
+
249
+ .action-buttons {
250
+ display: flex;
251
+ gap: 0.75rem;
252
+ }
253
+
254
+ /* Grid Dashboard */
255
+ .dashboard-grid {
256
+ display: grid;
257
+ grid-template-columns: 1.1fr 0.9fr;
258
+ gap: 2rem;
259
+ min-height: 550px;
260
+ }
261
+
262
+ @media (max-width: 1024px) {
263
+ .dashboard-grid {
264
+ grid-template-columns: 1fr;
265
+ }
266
+ }
267
+
268
+ .card {
269
+ background: var(--bg-secondary);
270
+ border: 1px solid var(--border-color);
271
+ border-radius: 16px;
272
+ box-shadow: var(--glass-shadow);
273
+ backdrop-filter: blur(16px);
274
+ overflow: hidden;
275
+ display: flex;
276
+ flex-direction: column;
277
+ }
278
+
279
+ /* Video Player Panel */
280
+ .video-panel {
281
+ padding: 1.25rem;
282
+ }
283
+
284
+ .video-wrapper {
285
+ background: #000;
286
+ border-radius: 10px;
287
+ aspect-ratio: 16/9;
288
+ overflow: hidden;
289
+ position: relative;
290
+ border: 1px solid rgba(255, 255, 255, 0.05);
291
+ }
292
+
293
+ .video-wrapper video {
294
+ width: 100%;
295
+ height: 100%;
296
+ object-fit: contain;
297
+ }
298
+
299
+ .video-info {
300
+ margin-top: 1rem;
301
+ }
302
+
303
+ .video-info h3 {
304
+ font-size: 1.1rem;
305
+ font-weight: 500;
306
+ margin-bottom: 0.25rem;
307
+ }
308
+
309
+ /* Transcript Panel */
310
+ .transcript-panel {
311
+ display: flex;
312
+ flex-direction: column;
313
+ }
314
+
315
+ .transcript-header {
316
+ padding: 1.25rem;
317
+ border-bottom: 1px solid var(--border-color);
318
+ display: flex;
319
+ flex-direction: column;
320
+ gap: 1rem;
321
+ }
322
+
323
+ .search-box {
324
+ position: relative;
325
+ }
326
+
327
+ .search-box input {
328
+ width: 100%;
329
+ background: rgba(255, 255, 255, 0.04);
330
+ border: 1px solid var(--border-color);
331
+ color: var(--text-main);
332
+ font-family: var(--font-family);
333
+ font-size: 0.9rem;
334
+ padding: 0.65rem 1rem 0.65rem 2.2rem;
335
+ border-radius: 8px;
336
+ outline: none;
337
+ transition: var(--transition-smooth);
338
+ }
339
+
340
+ .search-box input:focus {
341
+ border-color: var(--primary);
342
+ background: rgba(255, 255, 255, 0.08);
343
+ box-shadow: 0 0 10px rgba(99, 102, 241, 0.15);
344
+ }
345
+
346
+ .search-icon {
347
+ position: absolute;
348
+ left: 0.8rem;
349
+ top: 50%;
350
+ transform: translateY(-50%);
351
+ color: var(--text-muted);
352
+ font-size: 0.9rem;
353
+ }
354
+
355
+ .tab-export-row {
356
+ display: flex;
357
+ justify-content: space-between;
358
+ align-items: center;
359
+ }
360
+
361
+ .tabs {
362
+ display: flex;
363
+ background: rgba(255, 255, 255, 0.03);
364
+ border-radius: 8px;
365
+ padding: 3px;
366
+ border: 1px solid var(--border-color);
367
+ }
368
+
369
+ .tab-btn {
370
+ background: transparent;
371
+ border: none;
372
+ color: var(--text-muted);
373
+ cursor: pointer;
374
+ font-family: var(--font-family);
375
+ font-weight: 500;
376
+ font-size: 0.85rem;
377
+ padding: 0.5rem 1rem;
378
+ border-radius: 6px;
379
+ transition: var(--transition-smooth);
380
+ }
381
+
382
+ .tab-btn:hover {
383
+ color: var(--text-main);
384
+ }
385
+
386
+ .tab-btn.active {
387
+ background: rgba(255, 255, 255, 0.08);
388
+ color: var(--text-main);
389
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
390
+ }
391
+
392
+ /* Export Dropdown Menu */
393
+ .export-dropdown {
394
+ position: relative;
395
+ }
396
+
397
+ .dropdown-menu {
398
+ position: absolute;
399
+ right: 0;
400
+ top: 105%;
401
+ background: #111827;
402
+ border: 1px solid rgba(255, 255, 255, 0.1);
403
+ border-radius: 10px;
404
+ box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5);
405
+ min-width: 160px;
406
+ z-index: 100;
407
+ display: none;
408
+ flex-direction: column;
409
+ padding: 0.4rem;
410
+ backdrop-filter: blur(20px);
411
+ animation: slideDown 0.2s cubic-bezier(0.4, 0, 0.2, 1);
412
+ }
413
+
414
+ .dropdown-menu.show {
415
+ display: flex;
416
+ }
417
+
418
+ .dropdown-item {
419
+ background: transparent;
420
+ border: none;
421
+ color: var(--text-muted);
422
+ padding: 0.6rem 1rem;
423
+ text-align: left;
424
+ font-family: var(--font-family);
425
+ font-size: 0.85rem;
426
+ border-radius: 6px;
427
+ cursor: pointer;
428
+ transition: var(--transition-smooth);
429
+ display: flex;
430
+ align-items: center;
431
+ gap: 0.5rem;
432
+ }
433
+
434
+ .dropdown-item:hover {
435
+ background: rgba(255, 255, 255, 0.06);
436
+ color: var(--text-main);
437
+ }
438
+
439
+ /* Transcript Body */
440
+ .transcript-body {
441
+ flex: 1;
442
+ padding: 1.25rem;
443
+ max-height: 480px;
444
+ overflow-y: auto;
445
+ position: relative;
446
+ }
447
+
448
+ .panel-content {
449
+ display: flex;
450
+ flex-direction: column;
451
+ gap: 0.75rem;
452
+ }
453
+
454
+ /* Full Text Style */
455
+ #panelText p {
456
+ line-height: 1.7;
457
+ font-size: 0.95rem;
458
+ color: #cbd5e1;
459
+ }
460
+
461
+ /* Interactive Segments List */
462
+ .segments-list {
463
+ display: flex;
464
+ flex-direction: column;
465
+ gap: 0.6rem;
466
+ }
467
+
468
+ .segment-item {
469
+ background: rgba(255, 255, 255, 0.02);
470
+ border: 1px solid rgba(255, 255, 255, 0.03);
471
+ border-radius: 8px;
472
+ padding: 0.8rem 2.2rem 0.8rem 1rem;
473
+ cursor: pointer;
474
+ display: flex;
475
+ gap: 1rem;
476
+ align-items: flex-start;
477
+ transition: var(--transition-smooth);
478
+ position: relative;
479
+ overflow: hidden;
480
+ }
481
+
482
+ .segment-item:hover {
483
+ background: rgba(255, 255, 255, 0.05);
484
+ border-color: rgba(99, 102, 241, 0.25);
485
+ transform: translateX(3px);
486
+ }
487
+
488
+ .segment-item::after {
489
+ content: '✏️';
490
+ position: absolute;
491
+ right: 0.8rem;
492
+ top: 50%;
493
+ transform: translateY(-50%);
494
+ font-size: 0.8rem;
495
+ opacity: 0;
496
+ pointer-events: none;
497
+ transition: var(--transition-smooth);
498
+ }
499
+
500
+ .segment-item:hover::after {
501
+ opacity: 0.4;
502
+ }
503
+
504
+ .segment-time {
505
+ background: rgba(99, 102, 241, 0.12);
506
+ color: var(--primary);
507
+ font-weight: 600;
508
+ font-size: 0.75rem;
509
+ padding: 0.2rem 0.5rem;
510
+ border-radius: 4px;
511
+ font-family: 'Outfit', monospace;
512
+ margin-top: 0.1rem;
513
+ }
514
+
515
+ .segment-text {
516
+ flex: 1;
517
+ line-height: 1.5;
518
+ font-size: 0.92rem;
519
+ color: #e2e8f0;
520
+ outline: none;
521
+ cursor: text;
522
+ padding: 2px 6px;
523
+ border-radius: 4px;
524
+ transition: var(--transition-smooth);
525
+ }
526
+
527
+ .segment-text:focus {
528
+ background: rgba(255, 255, 255, 0.08);
529
+ box-shadow: 0 0 0 1px var(--primary);
530
+ color: white;
531
+ }
532
+
533
+ /* Segment highlighting when playing */
534
+ .segment-item.active {
535
+ background: linear-gradient(90deg, rgba(99, 102, 241, 0.12) 0%, rgba(139, 92, 246, 0.04) 100%);
536
+ border-color: rgba(99, 102, 241, 0.5);
537
+ box-shadow: inset 3px 0 0 var(--primary);
538
+ }
539
+
540
+ .segment-item.active .segment-time {
541
+ background: var(--primary);
542
+ color: white;
543
+ }
544
+
545
+ .segment-item.active .segment-text {
546
+ color: white;
547
+ font-weight: 500;
548
+ }
549
+
550
+ /* Highlight Search Mark */
551
+ .highlight-mark {
552
+ background: rgba(245, 158, 11, 0.25);
553
+ border-bottom: 2px solid var(--warning);
554
+ color: #fff;
555
+ border-radius: 2px;
556
+ padding: 0 2px;
557
+ }
558
+
559
+ .hidden-search {
560
+ display: none !important;
561
+ }
562
+
563
+ /* Settings Slide Panel */
564
+ .settings-panel {
565
+ position: fixed;
566
+ top: 0;
567
+ right: -380px;
568
+ width: 380px;
569
+ height: 100vh;
570
+ background: #0f172a;
571
+ border-left: 1px solid var(--border-color);
572
+ box-shadow: -10px 0 30px rgba(0, 0, 0, 0.5);
573
+ z-index: 1000;
574
+ transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1);
575
+ display: flex;
576
+ flex-direction: column;
577
+ }
578
+
579
+ .settings-panel.open {
580
+ right: 0;
581
+ }
582
+
583
+ .settings-header {
584
+ padding: 1.5rem;
585
+ border-bottom: 1px solid var(--border-color);
586
+ display: flex;
587
+ justify-content: space-between;
588
+ align-items: center;
589
+ }
590
+
591
+ .settings-header h3 {
592
+ font-size: 1.15rem;
593
+ font-weight: 600;
594
+ }
595
+
596
+ .close-btn {
597
+ background: transparent;
598
+ border: none;
599
+ color: var(--text-muted);
600
+ font-size: 1.25rem;
601
+ cursor: pointer;
602
+ transition: var(--transition-smooth);
603
+ }
604
+
605
+ .close-btn:hover {
606
+ color: white;
607
+ }
608
+
609
+ .settings-body {
610
+ padding: 1.5rem;
611
+ flex: 1;
612
+ display: flex;
613
+ flex-direction: column;
614
+ gap: 1.25rem;
615
+ }
616
+
617
+ .form-group {
618
+ display: flex;
619
+ flex-direction: column;
620
+ gap: 0.4rem;
621
+ }
622
+
623
+ .form-group label {
624
+ font-size: 0.82rem;
625
+ font-weight: 500;
626
+ color: var(--text-muted);
627
+ }
628
+
629
+ .form-group input, .form-group select {
630
+ background: rgba(255, 255, 255, 0.03);
631
+ border: 1px solid var(--border-color);
632
+ color: var(--text-main);
633
+ border-radius: 8px;
634
+ padding: 0.65rem 0.85rem;
635
+ font-family: var(--font-family);
636
+ font-size: 0.9rem;
637
+ outline: none;
638
+ transition: var(--transition-smooth);
639
+ }
640
+
641
+ .form-group input:focus, .form-group select:focus {
642
+ border-color: var(--primary);
643
+ background: rgba(255, 255, 255, 0.06);
644
+ }
645
+
646
+ .help-text {
647
+ font-size: 0.78rem;
648
+ color: var(--text-dark);
649
+ line-height: 1.4;
650
+ }
651
+
652
+ .settings-footer {
653
+ padding: 1.5rem;
654
+ border-top: 1px solid var(--border-color);
655
+ }
656
+
657
+ .settings-footer .btn {
658
+ width: 100%;
659
+ justify-content: center;
660
+ }
661
+
662
+ /* Loading Overlay */
663
+ .loading-overlay {
664
+ position: fixed;
665
+ top: 0;
666
+ left: 0;
667
+ width: 100vw;
668
+ height: 100vh;
669
+ background: rgba(8, 11, 17, 0.85);
670
+ backdrop-filter: blur(10px);
671
+ z-index: 2000;
672
+ display: flex;
673
+ align-items: center;
674
+ justify-content: center;
675
+ }
676
+
677
+ .loader-card {
678
+ background: #0f172a;
679
+ border: 1px solid var(--border-color);
680
+ border-radius: 20px;
681
+ padding: 3rem;
682
+ width: 90%;
683
+ max-width: 460px;
684
+ text-align: center;
685
+ box-shadow: 0 20px 40px rgba(0,0,0,0.5);
686
+ display: flex;
687
+ flex-direction: column;
688
+ align-items: center;
689
+ gap: 1.75rem;
690
+ }
691
+
692
+ /* Sound Wave Animation */
693
+ .sound-wave {
694
+ display: flex;
695
+ align-items: center;
696
+ justify-content: center;
697
+ gap: 6px;
698
+ height: 60px;
699
+ }
700
+
701
+ .sound-wave span {
702
+ width: 5px;
703
+ height: 15px;
704
+ background: linear-gradient(to top, var(--primary), var(--accent));
705
+ border-radius: 4px;
706
+ animation: wave 1.2s ease-in-out infinite;
707
+ }
708
+
709
+ .sound-wave span:nth-child(1) { animation-delay: 0.1s; }
710
+ .sound-wave span:nth-child(2) { animation-delay: 0.3s; }
711
+ .sound-wave span:nth-child(3) { animation-delay: 0.5s; }
712
+ .sound-wave span:nth-child(4) { animation-delay: 0.2s; }
713
+ .sound-wave span:nth-child(5) { animation-delay: 0.4s; }
714
+
715
+ @keyframes wave {
716
+ 0%, 100% { height: 12px; }
717
+ 50% { height: 50px; }
718
+ }
719
+
720
+ .loader-text h3 {
721
+ font-size: 1.15rem;
722
+ font-weight: 600;
723
+ margin-bottom: 0.5rem;
724
+ }
725
+
726
+ .loader-text p {
727
+ color: var(--text-muted);
728
+ font-size: 0.88rem;
729
+ min-height: 1.3rem;
730
+ }
731
+
732
+ .progress-container {
733
+ width: 100%;
734
+ }
735
+
736
+ .progress-bar {
737
+ background: rgba(255, 255, 255, 0.05);
738
+ border-radius: 10px;
739
+ height: 6px;
740
+ width: 100%;
741
+ overflow: hidden;
742
+ margin-bottom: 0.5rem;
743
+ position: relative;
744
+ }
745
+
746
+ .progress-bar-fill {
747
+ background: linear-gradient(90deg, var(--primary), var(--accent));
748
+ height: 100%;
749
+ width: 0%;
750
+ border-radius: 10px;
751
+ transition: width 0.3s ease;
752
+ box-shadow: 0 0 10px var(--primary-glow);
753
+ }
754
+
755
+ .progress-text {
756
+ display: flex;
757
+ justify-content: space-between;
758
+ font-size: 0.8rem;
759
+ color: var(--text-muted);
760
+ }
761
+
762
+ /* Toast Notifications Container */
763
+ #notificationContainer {
764
+ position: fixed;
765
+ bottom: 2rem;
766
+ right: 2rem;
767
+ display: flex;
768
+ flex-direction: column;
769
+ gap: 0.75rem;
770
+ z-index: 3000;
771
+ max-width: 380px;
772
+ }
773
+
774
+ .toast {
775
+ background: rgba(15, 23, 42, 0.9);
776
+ border: 1px solid var(--border-color);
777
+ backdrop-filter: blur(10px);
778
+ padding: 0.9rem 1.25rem;
779
+ border-radius: 10px;
780
+ display: flex;
781
+ align-items: center;
782
+ gap: 0.75rem;
783
+ color: white;
784
+ font-size: 0.88rem;
785
+ box-shadow: 0 10px 20px rgba(0,0,0,0.3);
786
+ transform: translateX(120%);
787
+ transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
788
+ }
789
+
790
+ .toast.show {
791
+ transform: translateX(0);
792
+ }
793
+
794
+ .toast-success { border-left: 4px solid var(--success); }
795
+ .toast-error { border-left: 4px solid var(--error); }
796
+ .toast-warning { border-left: 4px solid var(--warning); }
797
+ .toast-info { border-left: 4px solid var(--primary); }
798
+
799
+ /* Utility Classes */
800
+ .hidden {
801
+ display: none !important;
802
+ }
803
+
804
+ /* Keyframes */
805
+ @keyframes fadeIn {
806
+ from { opacity: 0; transform: translateY(5px); }
807
+ to { opacity: 1; transform: translateY(0); }
808
+ }
809
+
810
+ @keyframes slideDown {
811
+ from { opacity: 0; transform: translateY(-10px); }
812
+ to { opacity: 1; transform: translateY(0); }
813
+ }
814
+
815
+ /* Footer */
816
+ footer {
817
+ text-align: center;
818
+ padding: 2rem;
819
+ border-top: 1px solid var(--border-color);
820
+ color: var(--text-dark);
821
+ font-size: 0.8rem;
822
+ margin-top: auto;
823
+ }
index.html ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="th">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Typhoon Video Transcriber - ระบบถอดความวิดีโออัจฉริยะ</title>
7
+ <link rel="stylesheet" href="index.css">
8
+ </head>
9
+ <body>
10
+
11
+ <!-- Header -->
12
+ <header>
13
+ <div class="logo-container">
14
+ <div class="logo-icon">🌀</div>
15
+ <div>
16
+ <h1>Typhoon Video Transcriber</h1>
17
+ </div>
18
+ </div>
19
+ <div class="header-actions">
20
+ <button class="btn" id="settingsBtn">
21
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
22
+ ตั้งค่า API
23
+ </button>
24
+ </div>
25
+ </header>
26
+
27
+ <!-- Main Content -->
28
+ <main>
29
+ <!-- Dropzone Upload Area -->
30
+ <div class="upload-container" id="uploadZone">
31
+ <div class="upload-icon">
32
+ <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="17 8 12 3 7 8"></polyline><line x1="12" y1="3" x2="12" y2="15"></line></svg>
33
+ </div>
34
+ <div class="upload-text">
35
+ <h3>ลากไฟล์วิดีโอมาวางที่นี่</h3>
36
+ <p>หรือคลิกเพื่อเลือกไฟล์วิดีโอจากคอมพิวเตอร์ของคุณ (รองรับ MP4, WebM)</p>
37
+ </div>
38
+ <input type="file" id="fileInput" accept="video/*" class="hidden">
39
+ </div>
40
+
41
+ <!-- Loaded File Info & Run Controls -->
42
+ <div class="file-info-bar hidden" id="fileInfo">
43
+ <div class="file-meta">
44
+ <span class="file-icon">🎬</span>
45
+ <div class="file-details">
46
+ <h4 id="fileName">video.mp4</h4>
47
+ <span id="fileSize">0 MB</span>
48
+ </div>
49
+ </div>
50
+ <div class="action-buttons">
51
+ <button class="btn btn-primary" id="transcribeBtn">
52
+ ถอดความด้วย Typhoon AI
53
+ </button>
54
+ <button class="btn" id="demoBtn">
55
+ ทดลองใช้ระบบพรีวิว (Demo Mode)
56
+ </button>
57
+ </div>
58
+ </div>
59
+
60
+ <!-- Main Dashboard Grid -->
61
+ <div class="dashboard-grid hidden" id="mainDashboard">
62
+
63
+ <!-- Video Preview Card -->
64
+ <div class="card video-panel" id="videoContainer">
65
+ <div class="video-wrapper">
66
+ <video id="videoPlayer" controls></video>
67
+ </div>
68
+ <div class="video-info">
69
+ <h3>พรีวิวมัลติมีเดีย</h3>
70
+ <p class="help-text">คลิกประโยคในทรานสคริปต์ทางขวาเพื่อข้ามเวลาในวิดีโอ</p>
71
+ </div>
72
+ </div>
73
+
74
+ <!-- Transcript Card -->
75
+ <div class="card transcript-panel" id="transcriptPanel">
76
+ <!-- Panel Header -->
77
+ <div class="transcript-header">
78
+ <div class="search-box">
79
+ <svg class="search-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
80
+ <input type="text" id="searchTranscript" placeholder="ค้นหาคำในสคริปต์...">
81
+ </div>
82
+
83
+ <div class="tab-export-row">
84
+ <div class="tabs">
85
+ <button class="tab-btn" id="tabText">ข้อความทั้งหมด</button>
86
+ <button class="tab-btn active" id="tabSegments">สคริปต์ตามเวลา</button>
87
+ </div>
88
+
89
+ <div class="export-dropdown">
90
+ <button class="btn" id="exportBtn">
91
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
92
+ ดาวน์โหลด (Export)
93
+ </button>
94
+ <div class="dropdown-menu" id="exportMenu">
95
+ <button class="dropdown-item" id="exportTxt">💾 ข้อความ (.txt)</button>
96
+ <button class="dropdown-item" id="exportSrt">💾 ซับไตเติล (.srt)</button>
97
+ <button class="dropdown-item" id="exportVtt">💾 ซับไตเติล (.vtt)</button>
98
+ <button class="dropdown-item" id="exportJson">💾 โครงสร้าง (.json)</button>
99
+ </div>
100
+ </div>
101
+ </div>
102
+ </div>
103
+
104
+ <!-- Panel Body -->
105
+ <div class="transcript-body">
106
+ <div id="panelText" class="panel-content hidden">
107
+ <div id="fullTextContent"></div>
108
+ </div>
109
+
110
+ <div id="panelSegments" class="panel-content">
111
+ <div class="segments-list" id="segmentsList"></div>
112
+ </div>
113
+ </div>
114
+ </div>
115
+
116
+ </div>
117
+ </main>
118
+
119
+ <!-- Slide Settings Panel -->
120
+ <div class="settings-panel" id="settingsPanel">
121
+ <div class="settings-header">
122
+ <h3>ตั้งค่าระบบประมวลผล</h3>
123
+ <button class="close-btn" id="closeSettings">&times;</button>
124
+ </div>
125
+ <div class="settings-body">
126
+ <div class="form-group">
127
+ <label for="apiKey">OpenTyphoon API Key</label>
128
+ <input type="password" id="apiKey" placeholder="sk-...">
129
+ <p class="help-text">สามารถหาได้จากเว็บบอร์ด OpenTyphoon API Key นี้จะจัดเก็บภายในบราวเซอร์ของคุณเท่านั้น</p>
130
+ </div>
131
+
132
+ <div class="form-group">
133
+ <label for="modelSelect">โมเดลถอดรหัสเสียง (ASR Model)</label>
134
+ <select id="modelSelect">
135
+ <option value="typhoon-asr-realtime">typhoon-asr-realtime (แนะนำ)</option>
136
+ </select>
137
+ <p class="help-text">typhoon-asr-realtime เป็นโมเดล ASR ที่เร็วและมีประสิทธิภาพสูงสำหรับภาษาไทย</p>
138
+ </div>
139
+
140
+ <div class="form-group">
141
+ <label for="languageSelect">บังคับภาษา (Language Input)</label>
142
+ <select id="languageSelect">
143
+ <option value="">ตรวจหาอัตโนมัติ (Auto Detect)</option>
144
+ <option value="th">ภาษาไทย (Thai)</option>
145
+ <option value="en">ภาษาอังกฤษ (English)</option>
146
+ </select>
147
+ </div>
148
+ </div>
149
+ <div class="settings-footer">
150
+ <button class="btn btn-primary" id="saveSettings">บันทึกการตั้งค่า</button>
151
+ </div>
152
+ </div>
153
+
154
+ <!-- Loading Overlay -->
155
+ <div class="loading-overlay hidden" id="loadingOverlay">
156
+ <div class="loader-card">
157
+ <div class="sound-wave">
158
+ <span></span>
159
+ <span></span>
160
+ <span></span>
161
+ <span></span>
162
+ <span></span>
163
+ </div>
164
+ <div class="loader-text">
165
+ <h3 id="loadingStatus">กำลังประมวลผล...</h3>
166
+ <p>กรุณาเปิดหน้านี้ทิ้งไว้ ระบบกำลังถอดความวิดีโอของคุณ</p>
167
+ </div>
168
+ <div class="progress-container">
169
+ <div class="progress-bar">
170
+ <div class="progress-bar-fill" id="progressBarFill"></div>
171
+ </div>
172
+ <div class="progress-text">
173
+ <span>ความคืบหน้า</span>
174
+ <span id="loadingProgress">0%</span>
175
+ </div>
176
+ </div>
177
+ </div>
178
+ </div>
179
+
180
+ <!-- Footer -->
181
+ <footer>
182
+ <p>พัฒนาด้วยความใส่ใจ ร่วมกับโมเดลภาษาและการถอดความเสียงจาก SCB 10X Typhoon AI</p>
183
+ </footer>
184
+
185
+ <script src="app.js"></script>
186
+ </body>
187
+ </html>
server.js ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const http = require('http');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+
5
+ const PORT = 3000;
6
+
7
+ const MIME_TYPES = {
8
+ '.html': 'text/html',
9
+ '.css': 'text/css',
10
+ '.js': 'text/javascript',
11
+ '.json': 'application/json',
12
+ '.png': 'image/png',
13
+ '.jpg': 'image/jpeg',
14
+ '.wav': 'audio/wav',
15
+ '.mp4': 'video/mp4',
16
+ '.webm': 'video/webm'
17
+ };
18
+
19
+ const server = http.createServer((req, res) => {
20
+ // Decode URL in case of encoded characters
21
+ const decodedUrl = decodeURIComponent(req.url);
22
+ let filePath = path.join(__dirname, decodedUrl === '/' ? 'index.html' : decodedUrl);
23
+
24
+ const ext = path.extname(filePath).toLowerCase();
25
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream';
26
+
27
+ fs.readFile(filePath, (err, data) => {
28
+ if (err) {
29
+ if (err.code === 'ENOENT') {
30
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
31
+ res.end('ไม่พบไฟล์ที่ต้องการ (404 Not Found)');
32
+ } else {
33
+ res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
34
+ res.end(`ข้อผิดพลาดภายในเซิร์ฟเวอร์: ${err.code}`);
35
+ }
36
+ } else {
37
+ res.writeHead(200, { 'Content-Type': contentType });
38
+ res.end(data);
39
+ }
40
+ });
41
+ });
42
+
43
+ server.listen(PORT, () => {
44
+ console.log(`=========================================`);
45
+ console.log(`🌀 Typhoon Video Transcriber Server Started`);
46
+ console.log(`👉 Open in browser: http://localhost:${PORT}`);
47
+ console.log(`=========================================`);
48
+ });
wav-worker.js ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ self.onmessage = function(e) {
2
+ const { channelData, sampleRate } = e.data;
3
+ const targetSampleRate = 16000;
4
+
5
+ try {
6
+ // 1. Progress: Downsampling
7
+ self.postMessage({ type: 'progress', status: 'downsampling', progress: 20 });
8
+ const downsampled = downsample(channelData, sampleRate, targetSampleRate);
9
+
10
+ // 2. Progress: Encoding
11
+ self.postMessage({ type: 'progress', status: 'encoding', progress: 50 });
12
+ const wavBuffer = encodeWAV(downsampled, targetSampleRate);
13
+
14
+ // 3. Progress: Completed
15
+ self.postMessage({ type: 'progress', status: 'completed', progress: 100 });
16
+ self.postMessage({ type: 'done', buffer: wavBuffer }, [wavBuffer]);
17
+ } catch (error) {
18
+ self.postMessage({ type: 'error', message: error.message });
19
+ }
20
+ };
21
+
22
+ // Simple average-based downsampler for speech recognition
23
+ function downsample(buffer, inputSampleRate, outputSampleRate) {
24
+ if (inputSampleRate === outputSampleRate) {
25
+ return buffer;
26
+ }
27
+ const sampleRateRatio = inputSampleRate / outputSampleRate;
28
+ const newLength = Math.round(buffer.length / sampleRateRatio);
29
+ const result = new Float32Array(newLength);
30
+
31
+ let offsetResult = 0;
32
+ let offsetBuffer = 0;
33
+
34
+ while (offsetResult < result.length) {
35
+ const nextOffsetBuffer = Math.round((offsetResult + 1) * sampleRateRatio);
36
+ let accum = 0;
37
+ let count = 0;
38
+ for (let i = offsetBuffer; i < nextOffsetBuffer && i < buffer.length; i++) {
39
+ accum += buffer[i];
40
+ count++;
41
+ }
42
+ result[offsetResult] = count > 0 ? accum / count : 0;
43
+ offsetResult++;
44
+ offsetBuffer = nextOffsetBuffer;
45
+ }
46
+ return result;
47
+ }
48
+
49
+ // Write standard 16-bit PCM WAV file
50
+ function encodeWAV(samples, sampleRate) {
51
+ const buffer = new ArrayBuffer(44 + samples.length * 2);
52
+ const view = new DataView(buffer);
53
+
54
+ /* RIFF identifier */
55
+ writeString(view, 0, 'RIFF');
56
+ /* file length */
57
+ view.setUint32(4, 36 + samples.length * 2, true);
58
+ /* RIFF type */
59
+ writeString(view, 8, 'WAVE');
60
+ /* format chunk identifier */
61
+ writeString(view, 12, 'fmt ');
62
+ /* format chunk length */
63
+ view.setUint32(16, 16, true);
64
+ /* sample format (raw PCM) */
65
+ view.setUint16(20, 1, true);
66
+ /* channel count */
67
+ view.setUint16(22, 1, true); // Mono
68
+ /* sample rate */
69
+ view.setUint32(24, sampleRate, true);
70
+ /* byte rate (sample rate * block align) */
71
+ view.setUint32(28, sampleRate * 2, true);
72
+ /* block align (channel count * bytes per sample) */
73
+ view.setUint16(32, 2, true);
74
+ /* bits per sample */
75
+ view.setUint16(34, 16, true);
76
+ /* data chunk identifier */
77
+ writeString(view, 36, 'data');
78
+ /* data chunk length */
79
+ view.setUint32(40, samples.length * 2, true);
80
+
81
+ // Write samples
82
+ const volume = 1.0;
83
+ let offset = 44;
84
+ for (let i = 0; i < samples.length; i++, offset += 2) {
85
+ const s = Math.max(-1, Math.min(1, samples[i]));
86
+ view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
87
+ }
88
+
89
+ return buffer;
90
+ }
91
+
92
+ function writeString(view, offset, string) {
93
+ for (let i = 0; i < string.length; i++) {
94
+ view.setUint8(offset + i, string.charCodeAt(i));
95
+ }
96
+ }