ndmhung6 commited on
Commit
4390e2f
·
1 Parent(s): 8fe2625

feat: add AI Audio Story Studio tool for prompt-based audiobook synthesis

Browse files
app.py CHANGED
@@ -1583,5 +1583,73 @@ def api_prompt_execute():
1583
  'error': _err_msg(str(e))
1584
  }), 500
1585
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1586
  if __name__ == '__main__':
1587
  app.run(debug=True, port=5000)
 
1583
  'error': _err_msg(str(e))
1584
  }), 500
1585
 
1586
+ @app.route('/audio-story-studio')
1587
+ def audio_story_studio_index():
1588
+ return render_template('tools/audio_story_studio/index.html')
1589
+
1590
+ @app.route('/api/audio-story-studio/generate', methods=['POST'])
1591
+ def audio_story_studio_generate():
1592
+ data = request.json or {}
1593
+ prompt = data.get('prompt', '').strip()
1594
+ model_id = data.get('gemini_model', DEFAULT_MODEL)
1595
+
1596
+ if not prompt:
1597
+ return jsonify({'error': 'Vui lòng cung cấp ý tưởng hoặc prompt truyện'}), 400
1598
+
1599
+ system_instruction = (
1600
+ "You are an AI Audiobook and Podcast writer.\n"
1601
+ "Your task is to take a story prompt or idea and output a structured JSON containing:\n"
1602
+ "- 'title': A catchy Vietnamese title for the audio story.\n"
1603
+ "- 'script': A high-quality Vietnamese audio script optimized for narration. Write in a natural, engaging prose style, without channelling roleplay markup or action tags in the speech itself. Max 800 words.\n"
1604
+ "- 'suggested_voice': The recommended voice based on the story type. Use 'vi-VN-HoaiMyNeural' (Female, recommended for emotional, romantic, ghost/horror, or family stories) or 'vi-VN-NamMinhNeural' (Male, recommended for crime, action, detective, or historical stories).\n"
1605
+ "- 'rate': The recommended speaking rate (e.g. '-5%' for spooky/horror stories, '+0%' for standard stories, '-10%' for extremely sad/emotional stories).\n"
1606
+ "- 'reason': A brief explanation in Vietnamese of why this voice and pacing were chosen.\n\n"
1607
+ "You MUST return ONLY a valid JSON object matching this structure. Do not wrap in markdown code blocks."
1608
+ )
1609
+
1610
+ try:
1611
+ response = client.models.generate_content(
1612
+ model=model_id,
1613
+ contents=prompt,
1614
+ config=types.GenerateContentConfig(
1615
+ system_instruction=system_instruction,
1616
+ response_mime_type="application/json"
1617
+ )
1618
+ )
1619
+ increment_usage(model_id)
1620
+
1621
+ result = json.loads(response.text.strip())
1622
+ return jsonify({'success': True, **result})
1623
+ except Exception as e:
1624
+ return jsonify({'error': f"Lỗi tạo kịch bản: {_err_msg(str(e))}"}), 500
1625
+
1626
+ @app.route('/api/audio-story-studio/synthesize', methods=['POST'])
1627
+ def audio_story_studio_synthesize():
1628
+ import asyncio, edge_tts
1629
+ data = request.json or {}
1630
+ text = data.get('script', '').strip()
1631
+ voice = data.get('voice', 'vi-VN-HoaiMyNeural')
1632
+ rate = data.get('rate', '+0%')
1633
+
1634
+ if not text:
1635
+ return jsonify({'error': 'Vui lòng cung cấp nội dung kịch bản'}), 400
1636
+
1637
+ out_filename = f"audio_story_{os.urandom(4).hex()}.mp3"
1638
+ out_path = os.path.join(app.config['UPLOAD_FOLDER'], out_filename)
1639
+
1640
+ async def _generate():
1641
+ communicate = edge_tts.Communicate(text, voice, rate=rate)
1642
+ await communicate.save(out_path)
1643
+
1644
+ try:
1645
+ asyncio.run(_generate())
1646
+ return jsonify({
1647
+ 'success': True,
1648
+ 'audio_url': f'/download/{out_filename}',
1649
+ 'filename': out_filename
1650
+ })
1651
+ except Exception as e:
1652
+ return jsonify({'error': f"Lỗi chuyển giọng đọc: {str(e)}"}), 500
1653
+
1654
  if __name__ == '__main__':
1655
  app.run(debug=True, port=5000)
requirements.txt CHANGED
@@ -11,3 +11,6 @@ werkzeug
11
  gtts
12
  edge-tts
13
  vieneu
 
 
 
 
11
  gtts
12
  edge-tts
13
  vieneu
14
+ moviepy
15
+ replicate
16
+ gradio-client
templates/layout.html CHANGED
@@ -146,6 +146,9 @@
146
  <i class="fas fa-wand-magic-sparkles"></i> Tối ưu Prompt thô
147
  </a>
148
  </div>
 
 
 
149
  </nav>
150
  </div>
151
 
 
146
  <i class="fas fa-wand-magic-sparkles"></i> Tối ưu Prompt thô
147
  </a>
148
  </div>
149
+ <a class="nav-link {% if request.path.startswith('/audio-story-studio') %}active{% endif %}" href="/audio-story-studio">
150
+ <i class="fas fa-podcast"></i> AI Audio Story Studio
151
+ </a>
152
  </nav>
153
  </div>
154
 
templates/tools/audio_story_studio/index.html ADDED
@@ -0,0 +1,416 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "layout.html" %}
2
+
3
+ {% block title %}AI Audio Story Studio - Pum's Toolkit{% endblock %}
4
+
5
+ {% block style %}
6
+ <style>
7
+ .audio-card {
8
+ background: linear-gradient(135deg, #1e1e2f 0%, #11111d 100%);
9
+ border: 1px solid rgba(255, 255, 255, 0.1);
10
+ border-radius: var(--radius-lg);
11
+ color: #ffffff;
12
+ position: relative;
13
+ overflow: hidden;
14
+ }
15
+ .audio-card::before {
16
+ content: '';
17
+ position: absolute;
18
+ top: -50%;
19
+ left: -50%;
20
+ width: 200%;
21
+ height: 200%;
22
+ background: radial-gradient(circle, rgba(99, 102, 241, 0.15) 0%, transparent 60%);
23
+ pointer-events: none;
24
+ }
25
+ .recommendation-badge {
26
+ background-color: var(--color-primary-light);
27
+ color: var(--color-primary);
28
+ border: 1px solid rgba(99, 102, 241, 0.2);
29
+ padding: 6px 12px;
30
+ border-radius: 50px;
31
+ font-size: 0.82rem;
32
+ font-weight: 600;
33
+ display: inline-flex;
34
+ align-items: center;
35
+ gap: 6px;
36
+ }
37
+ .audio-player-custom {
38
+ width: 100%;
39
+ background: rgba(255, 255, 255, 0.05);
40
+ border-radius: var(--radius-md);
41
+ padding: 10px;
42
+ margin-top: 15px;
43
+ }
44
+ .audio-player-custom audio {
45
+ width: 100%;
46
+ outline: none;
47
+ }
48
+ .result-box-custom {
49
+ background-color: #f8fafc;
50
+ border: 1px solid #e2e8f0;
51
+ border-radius: var(--radius-md);
52
+ padding: 15px;
53
+ max-height: 400px;
54
+ overflow-y: auto;
55
+ font-size: 0.95rem;
56
+ line-height: 1.6;
57
+ white-space: pre-wrap;
58
+ }
59
+ .voice-badge {
60
+ background: rgba(255, 255, 255, 0.1);
61
+ border-radius: var(--radius-sm);
62
+ padding: 4px 8px;
63
+ font-size: 0.78rem;
64
+ font-weight: 500;
65
+ }
66
+ </style>
67
+ {% endblock %}
68
+
69
+ {% block content %}
70
+ <div id="loaderOverlay" class="loader-overlay" style="display: none;">
71
+ <div class="spinner-pro"></div>
72
+ <p class="mt-3 fw-bold" style="color: var(--color-primary)" id="loaderText">Đang sáng tác kịch bản và thu âm...</p>
73
+ </div>
74
+
75
+ <div class="mb-4">
76
+ <h2 class="fw-bold"><i class="fas fa-headphones text-primary me-2"></i>AI Audio Story Studio</h2>
77
+ <p class="text-muted">Tự động sáng tác truyện audio ngắn từ ý tưởng, tự động phân tích tông giọng, tốc độ đọc và tạo file âm thanh hoàn chỉnh.</p>
78
+ </div>
79
+
80
+ <div class="row g-4">
81
+ <!-- Left Column: Settings -->
82
+ <div class="col-lg-5">
83
+ <div class="card prompt-card h-100">
84
+ <div class="card-body p-4 d-flex flex-column justify-content-between">
85
+ <div>
86
+ <h5 class="panel-title mb-4"><i class="fas fa-sliders-h"></i> Cấu hình ý tưởng</h5>
87
+
88
+ <div class="mb-3">
89
+ <label class="form-label fw-bold">Ý tưởng hoặc Chủ đề truyện audio</label>
90
+ <textarea id="storyIdea" class="form-control" rows="6" placeholder="Ví dụ: Một vụ án bí ẩn xảy ra ở một ngôi làng sương mù quanh năm, nhân nhân duy nhất là một cậu bé câm... hoặc một câu chuyện ngoại tình đầy kịch tính của gia đình thượng lưu..."></textarea>
91
+ <div class="form-text small">Viết càng chi tiết, cốt truyện và lời văn sẽ càng sống động và kịch tính.</div>
92
+ </div>
93
+
94
+ <!-- Model Selection -->
95
+ <div class="row g-3 align-items-center mb-4">
96
+ <div class="col-md-6">
97
+ <label class="form-label fw-bold" style="color: var(--color-primary)"><i class="fab fa-google"></i> Mô hình AI</label>
98
+ <select id="geminiModel" class="form-select" style="border-color: var(--color-primary)">
99
+ <option value="">Đang tải...</option>
100
+ </select>
101
+ </div>
102
+ <div class="col-md-6">
103
+ <label class="form-label small text-muted">Hạn ngạch request</label>
104
+ <div id="modelQuota" class="small text-muted" style="min-height: 24px;"></div>
105
+ </div>
106
+ </div>
107
+
108
+ <!-- Manual overrides (Optional) -->
109
+ <div class="card bg-light border-0 mb-4">
110
+ <div class="card-body p-3">
111
+ <h6 class="fw-bold small mb-3" style="color: var(--color-text-title)"><i class="fas fa-cog"></i> Tự cấu hình giọng đọc (Nếu muốn bỏ qua AI gợi ý)</h6>
112
+ <div class="row g-2">
113
+ <div class="col-md-7">
114
+ <label class="form-label small mb-1">Chọn Giọng nói</label>
115
+ <select id="manualVoice" class="form-select form-select-sm">
116
+ <option value="auto" selected>-- Để AI tự động phân tích & chọn --</option>
117
+ <option value="vi-VN-HoaiMyNeural">Hoài My (Giọng Nữ Nam Bộ - Truyền cảm / Kinh dị / Drama)</option>
118
+ <option value="vi-VN-NamMinhNeural">Nam Minh (Giọng Nam Bắc Bộ - Hào hùng / Kỳ án / Đanh thép)</option>
119
+ </select>
120
+ </div>
121
+ <div class="col-md-5">
122
+ <label class="form-label small mb-1">Tốc độ (Rate)</label>
123
+ <select id="manualRate" class="form-select form-select-sm">
124
+ <option value="auto" selected>-- Để AI chọn --</option>
125
+ <option value="-15%">Rất chậm (-15%)</option>
126
+ <option value="-8%">Hơi chậm (-8%)</option>
127
+ <option value="+0%">Bình thường (+0%)</option>
128
+ <option value="+10%">Nhanh (+10%)</option>
129
+ </select>
130
+ </div>
131
+ </div>
132
+ </div>
133
+ </div>
134
+ </div>
135
+
136
+ <button id="generateBtn" class="btn-primary-custom w-100" style="padding: 12px 20px;">
137
+ <i class="fas fa-play-circle me-2"></i>TẠO KỊCH BẢN & ĐỌC TRUYỆN
138
+ </button>
139
+ </div>
140
+ </div>
141
+ </div>
142
+
143
+ <!-- Right Column: Outputs -->
144
+ <div class="col-lg-7">
145
+ <!-- Placeholder when no result -->
146
+ <div id="resultPlaceholder" class="card prompt-card h-100 border-dashed d-flex align-items-center justify-content-center p-5 text-center text-muted">
147
+ <div>
148
+ <i class="fas fa-headphones-simple fa-3x mb-3 text-muted" style="opacity: 0.5;"></i>
149
+ <h5>Chưa có truyện nào được tạo</h5>
150
+ <p class="small mb-0">Hãy nhập ý tưởng bên trái và bấm nút để bắt đầu sản xuất truyện audio.</p>
151
+ </div>
152
+ </div>
153
+
154
+ <!-- Result Content (Hidden initially) -->
155
+ <div id="resultContainer" class="d-none d-flex flex-column gap-4">
156
+ <!-- AI Audio Player Card -->
157
+ <div class="card audio-card">
158
+ <div class="card-body p-4">
159
+ <div class="d-flex justify-content-between align-items-start flex-wrap gap-2 mb-3">
160
+ <div>
161
+ <span class="recommendation-badge mb-2" id="voiceTypeBadge">
162
+ <i class="fas fa-microphone-alt"></i> Giọng Nữ (Hoài My)
163
+ </span>
164
+ <h4 class="fw-bold m-0 text-white" id="storyTitle">Tên câu chuyện</h4>
165
+ </div>
166
+ <div class="small text-white-50 text-end" style="max-width: 300px;">
167
+ <span class="voice-badge" id="rateBadge">Tốc độ: Normal</span>
168
+ </div>
169
+ </div>
170
+
171
+ <p class="small text-white-50 mb-3" id="voiceReason">
172
+ Lý do lựa chọn: Câu chuyện mang âm hưởng trầm lắng, thích hợp với chất giọng nữ trầm ấm...
173
+ </p>
174
+
175
+ <!-- Player -->
176
+ <div class="audio-player-custom">
177
+ <audio id="audioPlayer" controls></audio>
178
+ </div>
179
+
180
+ <!-- Audio Actions -->
181
+ <div class="d-flex gap-2 mt-3">
182
+ <a id="downloadAudioBtn" href="#" download class="btn btn-light btn-sm flex-grow-1 fw-bold">
183
+ <i class="fas fa-download me-1"></i> Tải về file Audio (MP3)
184
+ </a>
185
+ <button id="reSynthesizeBtn" class="btn btn-outline-light btn-sm flex-grow-1 fw-bold">
186
+ <i class="fas fa-sync-alt me-1"></i> Đọc lại (Nếu sửa kịch bản dưới)
187
+ </button>
188
+ </div>
189
+ </div>
190
+ </div>
191
+
192
+ <!-- Narration Script Card -->
193
+ <div class="card prompt-card">
194
+ <div class="card-body p-4">
195
+ <div class="d-flex justify-content-between align-items-center mb-3">
196
+ <h5 class="panel-title m-0"><i class="fas fa-file-alt"></i> Kịch bản phát thoại (Bản viết)</h5>
197
+ <button id="copyScriptBtn" class="btn btn-outline-primary btn-sm">
198
+ <i class="fas fa-copy"></i> Sao chép văn bản
199
+ </button>
200
+ </div>
201
+ <p class="text-muted small mb-3">Bạn có thể chỉnh sửa trực tiếp nội dung dưới đây và nhấn nút <strong>"Đọc lại"</strong> ở trên để thu âm lại theo kịch bản mới.</p>
202
+ <textarea id="storyScript" class="form-control" rows="12" style="font-family: var(--font-monospace); font-size: 0.95rem; line-height: 1.6; border-radius: var(--radius-md);"></textarea>
203
+ </div>
204
+ </div>
205
+ </div>
206
+ </div>
207
+ </div>
208
+
209
+ <div id="errorArea" class="mt-4 d-none">
210
+ <div class="alert alert-danger d-flex align-items-center gap-2 mb-0">
211
+ <i class="fas fa-exclamation-circle"></i>
212
+ <span id="errorMsg"></span>
213
+ </div>
214
+ </div>
215
+ {% endblock %}
216
+
217
+ {% block scripts %}
218
+ <script>
219
+ let geminiModelList = [];
220
+ let activeModel = '';
221
+
222
+ // Load models list
223
+ async function loadModels() {
224
+ try {
225
+ const res = await fetch('/api/gemini-models');
226
+ geminiModelList = await res.json();
227
+ const sel = document.getElementById('geminiModel');
228
+ sel.innerHTML = '';
229
+ geminiModelList.forEach(m => {
230
+ const opt = document.createElement('option');
231
+ opt.value = m.id;
232
+ const quotaText = m.remaining > 0
233
+ ? `✓ Còn ${m.remaining}`
234
+ : `✗ Hết (${m.used}/${m.rpd})`;
235
+ opt.textContent = `${m.name} (${quotaText})`;
236
+ if (m.remaining === 0) opt.disabled = true;
237
+ sel.appendChild(opt);
238
+ });
239
+ updateModelQuota();
240
+ } catch(e) {
241
+ console.error('Lỗi tải danh mục model:', e);
242
+ }
243
+ }
244
+
245
+ function updateModelQuota() {
246
+ const sel = document.getElementById('geminiModel');
247
+ const chosen = geminiModelList.find(m => m.id === sel.value);
248
+ const el = document.getElementById('modelQuota');
249
+ if (chosen) {
250
+ const status = chosen.remaining > 0
251
+ ? `<span style="color: var(--color-primary)"><i class="fas fa-check-circle"></i> Còn <strong>${chosen.remaining}</strong> request</span>`
252
+ : `<span style="color: #dc3545"><i class="fas fa-exclamation-triangle"></i> Đã hết quota</span>`;
253
+ el.innerHTML = status;
254
+ }
255
+ }
256
+
257
+ document.getElementById('geminiModel').addEventListener('change', updateModelQuota);
258
+ loadModels();
259
+
260
+ function showError(msg) {
261
+ const area = document.getElementById('errorArea');
262
+ document.getElementById('errorMsg').textContent = msg;
263
+ area.classList.remove('d-none');
264
+ }
265
+
266
+ function clearError() {
267
+ document.getElementById('errorArea').classList.add('d-none');
268
+ }
269
+
270
+ let generatedScriptDetails = null;
271
+
272
+ // Generate Action
273
+ document.getElementById('generateBtn').addEventListener('click', async () => {
274
+ const idea = document.getElementById('storyIdea').value.trim();
275
+ if (!idea) {
276
+ showError('Vui lòng nhập ý tưởng/concept truyện của bạn!');
277
+ return;
278
+ }
279
+
280
+ const loader = document.getElementById('loaderOverlay');
281
+ const loaderText = document.getElementById('loaderText');
282
+ loaderText.textContent = 'Đang dùng Gemini viết kịch bản truyện audio...';
283
+ loader.style.display = 'flex';
284
+ clearError();
285
+
286
+ try {
287
+ // Step 1: Generate Script and Settings via Gemini
288
+ const model_id = document.getElementById('geminiModel').value;
289
+ const res = await fetch('/api/audio-story-studio/generate', {
290
+ method: 'POST',
291
+ headers: { 'Content-Type': 'application/json' },
292
+ body: JSON.stringify({ prompt: idea, gemini_model: model_id })
293
+ });
294
+ const data = await res.json();
295
+
296
+ if (data.error || !data.success) {
297
+ showError(data.error || 'Lỗi khi tạo kịch bản.');
298
+ loader.style.display = 'none';
299
+ return;
300
+ }
301
+
302
+ generatedScriptDetails = data;
303
+
304
+ // Populate fields in UI
305
+ document.getElementById('storyTitle').textContent = data.title;
306
+ document.getElementById('storyScript').value = data.script;
307
+ document.getElementById('voiceReason').textContent = `AI khuyến nghị: ${data.reason}`;
308
+
309
+ // Determine voice to use (Auto or manual override)
310
+ let voiceToUse = document.getElementById('manualVoice').value;
311
+ if (voiceToUse === 'auto') {
312
+ voiceToUse = data.suggested_voice || 'vi-VN-HoaiMyNeural';
313
+ }
314
+
315
+ let rateToUse = document.getElementById('manualRate').value;
316
+ if (rateToUse === 'auto') {
317
+ rateToUse = data.rate || '+0%';
318
+ }
319
+
320
+ // Update badge info
321
+ const voiceLabel = voiceToUse === 'vi-VN-HoaiMyNeural' ? 'Giọng Nữ (Hoài My)' : 'Giọng Nam (Nam Minh)';
322
+ document.getElementById('voiceTypeBadge').innerHTML = `<i class="fas fa-microphone-alt"></i> ${voiceLabel}`;
323
+ document.getElementById('rateBadge').textContent = `Tốc độ: ${rateToUse}`;
324
+
325
+ // Step 2: Synthesize audio automatically
326
+ loaderText.textContent = 'Đang chuyển đổi kịch bản thành giọng đọc trí tuệ nhân tạo...';
327
+ await runSynthesis(data.script, voiceToUse, rateToUse);
328
+
329
+ // Show results
330
+ document.getElementById('resultPlaceholder').classList.add('d-none');
331
+ document.getElementById('resultContainer').classList.remove('d-none');
332
+ await loadModels();
333
+ } catch(e) {
334
+ showError('Đã xảy ra lỗi kết nối. Hãy thử lại.');
335
+ } finally {
336
+ loader.style.display = 'none';
337
+ }
338
+ });
339
+
340
+ // Synthesize function
341
+ async function runSynthesis(script, voice, rate) {
342
+ const res = await fetch('/api/audio-story-studio/synthesize', {
343
+ method: 'POST',
344
+ headers: { 'Content-Type': 'application/json' },
345
+ body: JSON.stringify({ script: script, voice: voice, rate: rate })
346
+ });
347
+ const data = await res.json();
348
+
349
+ if (data.error || !data.success) {
350
+ throw new Error(data.error || 'Lỗi khi tổng hợp âm thanh.');
351
+ }
352
+
353
+ const audioUrl = data.audio_url;
354
+ const player = document.getElementById('audioPlayer');
355
+ player.src = audioUrl;
356
+ player.load();
357
+
358
+ const downloadBtn = document.getElementById('downloadAudioBtn');
359
+ downloadBtn.href = audioUrl;
360
+ }
361
+
362
+ // Re-synthesize button (manual trigger after script edit)
363
+ document.getElementById('reSynthesizeBtn').addEventListener('click', async () => {
364
+ const script = document.getElementById('storyScript').value.trim();
365
+ if (!script) {
366
+ showError('Kịch bản đang trống!');
367
+ return;
368
+ }
369
+
370
+ const loader = document.getElementById('loaderOverlay');
371
+ const loaderText = document.getElementById('loaderText');
372
+ loaderText.textContent = 'Đang cập nhật giọng đọc với kịch bản mới...';
373
+ loader.style.display = 'flex';
374
+ clearError();
375
+
376
+ // Get voice & speed
377
+ let voiceToUse = document.getElementById('manualVoice').value;
378
+ if (voiceToUse === 'auto') {
379
+ voiceToUse = (generatedScriptDetails && generatedScriptDetails.suggested_voice) || 'vi-VN-HoaiMyNeural';
380
+ }
381
+
382
+ let rateToUse = document.getElementById('manualRate').value;
383
+ if (rateToUse === 'auto') {
384
+ rateToUse = (generatedScriptDetails && generatedScriptDetails.rate) || '+0%';
385
+ }
386
+
387
+ try {
388
+ await runSynthesis(script, voiceToUse, rateToUse);
389
+
390
+ // Play audio automatically after synthesis
391
+ document.getElementById('audioPlayer').play();
392
+ } catch(e) {
393
+ showError(e.message || 'Lỗi khi dịch giọng đọc.');
394
+ } finally {
395
+ loader.style.display = 'none';
396
+ }
397
+ });
398
+
399
+ // Copy Script
400
+ document.getElementById('copyScriptBtn').addEventListener('click', () => {
401
+ const text = document.getElementById('storyScript').value;
402
+ if (!text) return;
403
+
404
+ navigator.clipboard.writeText(text).then(() => {
405
+ const btn = document.getElementById('copyScriptBtn');
406
+ const originalHTML = btn.innerHTML;
407
+ btn.innerHTML = '<i class="fas fa-check"></i> Đã sao chép!';
408
+ setTimeout(() => {
409
+ btn.innerHTML = originalHTML;
410
+ }, 2000);
411
+ }).catch(err => {
412
+ showError('Không thể copy tự động.');
413
+ });
414
+ });
415
+ </script>
416
+ {% endblock %}