ndmhung6 commited on
Commit
6402c49
·
1 Parent(s): 4390e2f

feat: redesign Audio Story Studio with 3-step workflow (prompt > script > audio)

Browse files
Files changed (2) hide show
  1. app.py +75 -14
  2. templates/tools/audio_story_studio/index.html +489 -233
app.py CHANGED
@@ -23,7 +23,7 @@ app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB
23
 
24
  os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
25
 
26
- GEMINI_API_KEY = "AQ.Ab8RN6JxMSl1LlebmW8kq6jjam99Z0qKf2wkwu8WjFKVEp6kGQ"
27
  USAGE_FILE = os.path.join(app.config['UPLOAD_FOLDER'], 'gemini_usage.json')
28
 
29
  GEMINI_MODELS = [
@@ -1587,26 +1587,87 @@ def api_prompt_execute():
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,
@@ -1617,12 +1678,12 @@ def audio_story_studio_generate():
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
 
23
 
24
  os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
25
 
26
+ GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "AQ.Ab8RN6JxMSl1LlebmW8kq6jjam99Z0qKf2wkwu8WjFKVEp6kGQ")
27
  USAGE_FILE = os.path.join(app.config['UPLOAD_FOLDER'], 'gemini_usage.json')
28
 
29
  GEMINI_MODELS = [
 
1587
  def audio_story_studio_index():
1588
  return render_template('tools/audio_story_studio/index.html')
1589
 
1590
+ # ── Step 1: Generate optimized prompt from user config ──
1591
+ @app.route('/api/audio-story-studio/create-prompt', methods=['POST'])
1592
+ def audio_story_studio_create_prompt():
1593
+ data = request.json or {}
1594
+ idea = data.get('idea', '').strip()
1595
+ genre = data.get('genre', 'Drama gia đình')
1596
+ style = data.get('style', 'Kịch tính, gay cấn')
1597
+ mood = data.get('mood', 'Căng thẳng')
1598
+ length = data.get('length', '10 phút')
1599
+ audience = data.get('audience', 'Người lớn')
1600
+ extras = data.get('extras', '')
1601
+ model_id = data.get('gemini_model', DEFAULT_MODEL)
1602
+
1603
+ if not idea:
1604
+ return jsonify({'error': 'Vui lòng nhập ý tưởng truyện'}), 400
1605
+
1606
+ system_instruction = (
1607
+ "Bạn là chuyên gia viết prompt cho AI tạo kịch bản truyện audio.\n"
1608
+ "Nhiệm vụ: Từ các thông số người dùng cung cấp, hãy viết MỘT prompt tối ưu bằng tiếng Việt "
1609
+ "để một AI khác có thể dùng prompt đó tạo ra kịch bản truyện audio hoàn chỉnh.\n\n"
1610
+ "Prompt tối ưu cần:\n"
1611
+ "- Mô tả rõ ràng bối cảnh, nhân vật chính, xung đột trung tâm\n"
1612
+ "- Yêu cầu cấu trúc kịch bản: mở đầu hấp dẫn, diễn biến leo thang, cao trào, kết thúc bất ngờ\n"
1613
+ "- Chỉ định tông giọng kể, nhịp độ, cảm xúc\n"
1614
+ "- Yêu cầu viết tối ưu cho đọc thành tiếng (câu ngắn gọn, dùng dấu ... để tạo nhịp)\n"
1615
+ "- Ghi rõ độ dài mong muốn\n\n"
1616
+ "CHỈ trả về prompt tối ưu, không thêm lời giới thiệu hay giải thích.\n"
1617
+ )
1618
+
1619
+ user_input = (
1620
+ f"Ý tưởng: {idea}\n"
1621
+ f"Thể loại: {genre}\n"
1622
+ f"Phong cách kể: {style}\n"
1623
+ f"Tâm trạng/Mood: {mood}\n"
1624
+ f"Độ dài mong muốn: {length}\n"
1625
+ f"Đối tượng khán giả: {audience}\n"
1626
+ )
1627
+ if extras:
1628
+ user_input += f"Yêu cầu thêm: {extras}\n"
1629
+
1630
+ try:
1631
+ response = client.models.generate_content(
1632
+ model=model_id,
1633
+ contents=user_input,
1634
+ config=types.GenerateContentConfig(
1635
+ system_instruction=system_instruction
1636
+ )
1637
+ )
1638
+ increment_usage(model_id)
1639
+ return jsonify({
1640
+ 'success': True,
1641
+ 'optimized_prompt': response.text.strip()
1642
+ })
1643
+ except Exception as e:
1644
+ return jsonify({'error': f"Lỗi tạo prompt: {_err_msg(str(e))}"}), 500
1645
+
1646
+ # ── Step 2: Generate full script from prompt ──
1647
+ @app.route('/api/audio-story-studio/create-script', methods=['POST'])
1648
+ def audio_story_studio_create_script():
1649
  data = request.json or {}
1650
  prompt = data.get('prompt', '').strip()
1651
  model_id = data.get('gemini_model', DEFAULT_MODEL)
1652
 
1653
  if not prompt:
1654
+ return jsonify({'error': 'Vui lòng cung cấp prompt'}), 400
1655
 
1656
  system_instruction = (
1657
+ "Bạn là nhà văn chuyên viết kịch bản truyện audio cho YouTube và Podcast.\n"
1658
+ "Nhiệm vụ: Dựa vào prompt được cung cấp, hãy viết kịch bản truyện audio hoàn chỉnh bằng tiếng Việt.\n\n"
1659
+ "Trả về JSON với cấu trúc:\n"
1660
+ "- 'title': Tiêu đề hấp dẫn cho truyện audio (gây tò mò, kích thích click)\n"
1661
+ "- 'script': Kịch bản truyện audio đầy đủ, viết dạng văn xuôi tối ưu cho đọc thành tiếng. "
1662
+ "Không dùng markup, tag, hay ký hiệu đặc biệt. Chỉ dùng dấu ... để tạo nhịp nghỉ. "
1663
+ "Câu ngắn gọn, súc tích, giàu cảm xúc.\n"
1664
+ "- 'suggested_voice': 'vi-VN-HoaiMyNeural' (Nữ, phù hợp drama, tâm linh, tình cảm) "
1665
+ "hoặc 'vi-VN-NamMinhNeural' (Nam, phù hợp hành động, trinh thám, lịch sử)\n"
1666
+ "- 'rate': Tốc độ đọc khuyến nghị ('-10%' chậm rãi buồn, '-5%' rùng rợn, '+0%' bình thường, '+5%' nhanh gay cấn)\n"
1667
+ "- 'reason': Giải thích ngắn gọn bằng tiếng Việt vì sao chọn giọng và tốc độ này\n\n"
1668
+ "CHỈ trả về JSON hợp lệ, không wrap trong markdown code block."
1669
  )
1670
+
1671
  try:
1672
  response = client.models.generate_content(
1673
  model=model_id,
 
1678
  )
1679
  )
1680
  increment_usage(model_id)
 
1681
  result = json.loads(response.text.strip())
1682
  return jsonify({'success': True, **result})
1683
  except Exception as e:
1684
  return jsonify({'error': f"Lỗi tạo kịch bản: {_err_msg(str(e))}"}), 500
1685
 
1686
+ # ── Step 3: Synthesize audio from script ──
1687
  @app.route('/api/audio-story-studio/synthesize', methods=['POST'])
1688
  def audio_story_studio_synthesize():
1689
  import asyncio, edge_tts
templates/tools/audio_story_studio/index.html CHANGED
@@ -4,6 +4,85 @@
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);
@@ -19,49 +98,52 @@
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 %}
@@ -69,138 +151,283 @@
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>
@@ -216,10 +443,11 @@
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');
@@ -229,188 +457,216 @@
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 %}
 
4
 
5
  {% block style %}
6
  <style>
7
+ /* ── Step Progress Bar ── */
8
+ .step-progress {
9
+ display: flex;
10
+ align-items: center;
11
+ justify-content: center;
12
+ gap: 0;
13
+ margin-bottom: 30px;
14
+ padding: 0 10px;
15
+ }
16
+ .step-item {
17
+ display: flex;
18
+ align-items: center;
19
+ gap: 10px;
20
+ padding: 10px 18px;
21
+ border-radius: 50px;
22
+ font-weight: 600;
23
+ font-size: 0.88rem;
24
+ cursor: default;
25
+ transition: all 0.3s ease;
26
+ background: #f1f5f9;
27
+ color: #94a3b8;
28
+ border: 2px solid transparent;
29
+ }
30
+ .step-item.active {
31
+ background: var(--color-primary-light);
32
+ color: var(--color-primary);
33
+ border-color: var(--color-primary);
34
+ box-shadow: 0 2px 10px rgba(99, 102, 241, 0.2);
35
+ }
36
+ .step-item.done {
37
+ background: #ecfdf5;
38
+ color: #059669;
39
+ border-color: #10b981;
40
+ }
41
+ .step-item .step-num {
42
+ width: 28px;
43
+ height: 28px;
44
+ border-radius: 50%;
45
+ display: flex;
46
+ align-items: center;
47
+ justify-content: center;
48
+ font-size: 0.82rem;
49
+ font-weight: 700;
50
+ background: #e2e8f0;
51
+ color: #64748b;
52
+ flex-shrink: 0;
53
+ }
54
+ .step-item.active .step-num {
55
+ background: var(--color-primary);
56
+ color: #fff;
57
+ }
58
+ .step-item.done .step-num {
59
+ background: #10b981;
60
+ color: #fff;
61
+ }
62
+ .step-connector {
63
+ width: 40px;
64
+ height: 2px;
65
+ background: #e2e8f0;
66
+ flex-shrink: 0;
67
+ }
68
+ .step-connector.done {
69
+ background: #10b981;
70
+ }
71
+
72
+ /* ── Step Panels ── */
73
+ .step-panel {
74
+ display: none;
75
+ animation: fadeSlideIn 0.35s ease;
76
+ }
77
+ .step-panel.active {
78
+ display: block;
79
+ }
80
+ @keyframes fadeSlideIn {
81
+ from { opacity: 0; transform: translateY(12px); }
82
+ to { opacity: 1; transform: translateY(0); }
83
+ }
84
+
85
+ /* ── Audio Player Card ── */
86
  .audio-card {
87
  background: linear-gradient(135deg, #1e1e2f 0%, #11111d 100%);
88
  border: 1px solid rgba(255, 255, 255, 0.1);
 
98
  left: -50%;
99
  width: 200%;
100
  height: 200%;
101
+ background: radial-gradient(circle, rgba(99, 102, 241, 0.12) 0%, transparent 60%);
102
  pointer-events: none;
103
  }
104
+ .audio-player-custom { width: 100%; background: rgba(255,255,255,0.05); border-radius: var(--radius-md); padding: 10px; margin-top: 12px; }
105
+ .audio-player-custom audio { width: 100%; outline: none; }
106
+
107
  .recommendation-badge {
108
  background-color: var(--color-primary-light);
109
  color: var(--color-primary);
110
+ border: 1px solid rgba(99,102,241,0.2);
111
+ padding: 5px 10px;
112
  border-radius: 50px;
113
+ font-size: 0.78rem;
114
  font-weight: 600;
115
  display: inline-flex;
116
  align-items: center;
117
+ gap: 5px;
118
  }
119
+ .voice-badge {
120
+ background: rgba(255,255,255,0.1);
121
+ border-radius: var(--radius-sm);
122
+ padding: 4px 8px;
123
+ font-size: 0.78rem;
124
+ font-weight: 500;
 
 
 
 
125
  }
126
+
127
+ /* ── Result Prompt Box ── */
128
+ .prompt-result-box {
129
+ background: #f8fafc;
130
  border: 1px solid #e2e8f0;
131
  border-radius: var(--radius-md);
132
+ padding: 16px;
133
+ max-height: 350px;
134
  overflow-y: auto;
135
+ font-size: 0.92rem;
136
+ line-height: 1.65;
137
  white-space: pre-wrap;
138
  }
139
+
140
+ /* ── Nav Button Bar ── */
141
+ .step-nav-bar {
142
+ display: flex;
143
+ gap: 10px;
144
+ margin-top: 20px;
145
+ padding-top: 16px;
146
+ border-top: 1px solid #e2e8f0;
147
  }
148
  </style>
149
  {% endblock %}
 
151
  {% block content %}
152
  <div id="loaderOverlay" class="loader-overlay" style="display: none;">
153
  <div class="spinner-pro"></div>
154
+ <p class="mt-3 fw-bold" style="color: var(--color-primary)" id="loaderText">Đang xử lý...</p>
155
  </div>
156
 
157
  <div class="mb-4">
158
  <h2 class="fw-bold"><i class="fas fa-headphones text-primary me-2"></i>AI Audio Story Studio</h2>
159
+ <p class="text-muted mb-0">Tạo truyện audio hoàn chỉnh chỉ từ ý tưởng. Quy trình 3 bước: Sinh Prompt → Viết Kịch Bản → Thu Âm.</p>
160
+ </div>
161
+
162
+ <!-- Step Progress Bar -->
163
+ <div class="step-progress">
164
+ <div class="step-item active" id="stepIndicator1">
165
+ <span class="step-num">1</span>
166
+ <span>Cấu hình & Sinh Prompt</span>
167
+ </div>
168
+ <div class="step-connector" id="connector1"></div>
169
+ <div class="step-item" id="stepIndicator2">
170
+ <span class="step-num">2</span>
171
+ <span>Tạo Kịch Bản</span>
172
+ </div>
173
+ <div class="step-connector" id="connector2"></div>
174
+ <div class="step-item" id="stepIndicator3">
175
+ <span class="step-num">3</span>
176
+ <span>Thu Âm & Phát</span>
177
+ </div>
178
  </div>
179
 
180
+ <!-- ═══════════ STEP 1: Config & Generate Prompt ═══════════ -->
181
+ <div class="step-panel active" id="step1Panel">
182
+ <div class="row g-4">
183
+ <div class="col-lg-7">
184
+ <div class="card prompt-card h-100">
185
+ <div class="card-body p-4">
186
+ <h5 class="panel-title mb-4"><i class="fas fa-pen-fancy"></i> Ý tưởng & Cấu hình</h5>
187
+
188
  <div class="mb-3">
189
+ <label class="form-label fw-bold">Ý tưởng hoặc Chủ đề truyện <span class="text-danger">*</span></label>
190
+ <textarea id="storyIdea" class="form-control" rows="4" placeholder="Ví dụ: Một cô gái phát hiện người chồng sắp cưới có bí mật kinh hoàng sau khi vô tình đọc được tin nhắn trên điện thoại anh ta lúc nửa đêm..."></textarea>
 
191
  </div>
192
 
193
+ <div class="row g-3 mb-3">
 
194
  <div class="col-md-6">
195
+ <label class="form-label fw-bold">Thể loại</label>
196
+ <select id="cfgGenre" class="form-select">
197
+ <option value="Drama gia đình / Ngoại tình / Mẹ chồng nàng dâu">Drama gia đình / Ngoại tình</option>
198
+ <option value="Kinh dị / Tâm linh / Ma quỷ">Kinh dị / Tâm linh</option>
199
+ <option value="Trinh thám / Kỳ án / True Crime">Trinh thám / Kỳ án</option>
200
+ <option value="Tình cảm lãng mạn / Ngôn tình">Tình cảm lãng mạn</option>
201
+ <option value="Trùng sinh / Xuyên không / Tiên hiệp">Trùng sinh / Xuyên không</option>
202
+ <option value="Đời thường / Cảm động / Truyền cảm hứng">Đời thường / Cảm động</option>
203
+ <option value="Trả thù / Phục hận">Trả thù / Phục hận</option>
204
+ <option value="Hài hước / Giải trí">Hài hước / Giải trí</option>
205
+ <option value="Lịch sử / Dã sử">Lịch sử / Dã sử</option>
206
  </select>
207
  </div>
208
  <div class="col-md-6">
209
+ <label class="form-label fw-bold">Phong cách kể</label>
210
+ <select id="cfgStyle" class="form-select">
211
+ <option value="Kịch tính, gay cấn, lôi cuốn">Kịch tính, gay cấn</option>
212
+ <option value="Nhẹ nhàng, truyền cảm, sâu lắng">Nhẹ nhàng, sâu lắng</option>
213
+ <option value="Rùng rợn, ám ảnh, u tối">Rùng rợn, ám ảnh</option>
214
+ <option value="Hài hước, châm biếm, dí dỏm">Hài hước, dí dỏm</option>
215
+ <option value="Trinh thám, bí ẩn, hồi hộp">Trinh thám, hồi hộp</option>
216
+ <option value="Trữ tình, lãng mạn, ngọt ngào">Trữ tình, lãng mạn</option>
217
+ </select>
218
  </div>
219
  </div>
220
 
221
+ <div class="row g-3 mb-3">
222
+ <div class="col-md-4">
223
+ <label class="form-label fw-bold">Tâm trạng chủ đạo</label>
224
+ <select id="cfgMood" class="form-select">
225
+ <option value="Căng thẳng, nghẹt thở">Căng thẳng</option>
226
+ <option value="Buồn bã, xúc động">Buồn bã, xúc động</option>
227
+ <option value="Phẫn nộ, bất bình">Phẫn nộ</option>
228
+ <option value="Sợ hãi, rùng mình">Sợ hãi</option>
229
+ <option value="Vui vẻ, tươi sáng">Vui vẻ</option>
230
+ <option value="Tò mò, bất ngờ">Tò mò</option>
231
+ <option value="Ấm áp, cảm động">Ấm áp</option>
232
+ </select>
233
+ </div>
234
+ <div class="col-md-4">
235
+ <label class="form-label fw-bold">Độ dài</label>
236
+ <select id="cfgLength" class="form-select">
237
+ <option value="5 phút (khoảng 600-800 từ)">5 phút</option>
238
+ <option value="10 phút (khoảng 1200-1500 từ)" selected>10 phút</option>
239
+ <option value="15 phút (khoảng 1800-2200 từ)">15 phút</option>
240
+ <option value="20 phút (khoảng 2500-3000 từ)">20 phút</option>
241
+ </select>
242
+ </div>
243
+ <div class="col-md-4">
244
+ <label class="form-label fw-bold">Đối tượng</label>
245
+ <select id="cfgAudience" class="form-select">
246
+ <option value="Người lớn (18+)">Người lớn (18+)</option>
247
+ <option value="Mọi lứa tuổi">Mọi lứa tuổi</option>
248
+ <option value="Thanh thiếu niên (13-18)">Thanh thiếu niên</option>
249
+ </select>
250
+ </div>
251
+ </div>
252
+
253
+ <div class="mb-3">
254
+ <label class="form-label fw-bold">Yêu cầu thêm <span class="text-muted fw-normal">(tuỳ chọn)</span></label>
255
+ <input id="cfgExtras" class="form-control" placeholder="Ví dụ: Kết thúc twist bất ngờ, có yếu tố flashback, nhân vật chính là phụ nữ trung niên..." />
256
+ </div>
257
+ </div>
258
+ </div>
259
+ </div>
260
+
261
+ <div class="col-lg-5">
262
+ <div class="card prompt-card h-100">
263
+ <div class="card-body p-4 d-flex flex-column justify-content-between">
264
+ <div>
265
+ <h5 class="panel-title mb-4"><i class="fas fa-cog"></i> Cài đặt AI & Giọng đọc</h5>
266
+
267
+ <!-- Model Selection -->
268
+ <div class="mb-3">
269
+ <label class="form-label fw-bold" style="color: var(--color-primary)"><i class="fab fa-google"></i> Mô hình Gemini</label>
270
+ <select id="geminiModel" class="form-select" style="border-color: var(--color-primary)">
271
+ <option value="">Đang tải...</option>
272
+ </select>
273
+ <div id="modelQuota" class="small text-muted mt-1"></div>
274
+ </div>
275
+
276
+ <!-- Voice override -->
277
+ <div class="card bg-light border-0 mb-3">
278
+ <div class="card-body p-3">
279
+ <h6 class="fw-bold small mb-3"><i class="fas fa-microphone-alt"></i> Giọng đọc (Tuỳ chọn)</h6>
280
+ <div class="mb-2">
281
  <select id="manualVoice" class="form-select form-select-sm">
282
+ <option value="auto" selected>Để AI tự phân tích & chọn giọng</option>
283
+ <option value="vi-VN-HoaiMyNeural">Hoài My (Nữ – Drama, Tâm linh, Tình cảm)</option>
284
+ <option value="vi-VN-NamMinhNeural">Nam Minh (Nam – Trinh thám, Hành động, Lịch sử)</option>
285
  </select>
286
  </div>
287
+ <div>
 
288
  <select id="manualRate" class="form-select form-select-sm">
289
+ <option value="auto" selected>Để AI chọn tốc độ</option>
290
  <option value="-15%">Rất chậm (-15%)</option>
291
  <option value="-8%">Hơi chậm (-8%)</option>
292
  <option value="+0%">Bình thường (+0%)</option>
293
+ <option value="+5%">Nhanh (+5%)</option>
294
+ <option value="+10%">Rất nhanh (+10%)</option>
295
  </select>
296
  </div>
297
  </div>
298
  </div>
299
  </div>
300
+
301
+ <button id="btnStep1" class="btn-primary-custom w-100" style="padding: 12px 20px;">
302
+ <i class="fas fa-wand-magic-sparkles me-2"></i>BƯỚC 1: SINH PROMPT TỐI ƯU
303
+ </button>
304
  </div>
305
+ </div>
306
+ </div>
307
+ </div>
308
+ </div>
309
 
310
+ <!-- ═══════════ STEP 2: Review Prompt → Generate Script ═══════════ -->
311
+ <div class="step-panel" id="step2Panel">
312
+ <div class="row g-4">
313
+ <div class="col-lg-6">
314
+ <div class="card prompt-card h-100">
315
+ <div class="card-body p-4 d-flex flex-column">
316
+ <div class="d-flex justify-content-between align-items-center mb-3">
317
+ <h5 class="panel-title m-0"><i class="fas fa-scroll"></i> Prompt tối ưu</h5>
318
+ <button id="copyPromptBtn" class="btn btn-outline-primary btn-sm"><i class="fas fa-copy"></i> Sao chép</button>
319
+ </div>
320
+ <p class="text-muted small mb-3">Đây là prompt được AI tối ưu từ cấu hình của bạn. Bạn có thể chỉnh sửa trước khi tạo kịch bản.</p>
321
+ <textarea id="optimizedPrompt" class="form-control flex-grow-1" rows="14" style="font-size: 0.92rem; line-height: 1.6;"></textarea>
322
+ </div>
323
+ </div>
324
+ </div>
325
+ <div class="col-lg-6">
326
+ <div class="card prompt-card h-100">
327
+ <div class="card-body p-4 d-flex flex-column justify-content-between">
328
+ <div>
329
+ <h5 class="panel-title mb-3"><i class="fas fa-info-circle"></i> Hướng dẫn</h5>
330
+ <div class="alert alert-info small mb-3">
331
+ <i class="fas fa-lightbulb me-1"></i>
332
+ <strong>Mẹo:</strong> Bạn có thể chỉnh sửa prompt bên trái để điều chỉnh nội dung trước khi AI viết kịch bản. Thêm yêu cầu cụ thể sẽ cho ra kịch bản chất lượng hơn.
333
+ </div>
334
+ <div class="alert alert-warning small mb-3">
335
+ <i class="fas fa-arrow-right me-1"></i>
336
+ Bấm nút bên dưới, AI sẽ dựa vào prompt này để viết một <strong>kịch bản truyện audio hoàn chỉnh</strong>, đồng thời tự động phân tích giọng đọc và tốc độ phù hợp.
337
+ </div>
338
+ </div>
339
+
340
+ <div class="step-nav-bar">
341
+ <button id="btnBack1" class="btn btn-outline-secondary flex-grow-1">
342
+ <i class="fas fa-arrow-left me-1"></i> Quay lại
343
+ </button>
344
+ <button id="btnStep2" class="btn-primary-custom flex-grow-1" style="padding: 12px 20px;">
345
+ <i class="fas fa-pen-nib me-2"></i>BƯỚC 2: TẠO KỊCH BẢN
346
+ </button>
347
+ </div>
348
+ </div>
349
  </div>
350
  </div>
351
  </div>
352
+ </div>
353
 
354
+ <!-- ═══════════ STEP 3: Review Script → Synthesize Audio ═══════════ -->
355
+ <div class="step-panel" id="step3Panel">
356
+ <div class="row g-4">
357
+ <div class="col-lg-6">
358
+ <div class="card prompt-card h-100">
359
+ <div class="card-body p-4 d-flex flex-column">
360
+ <div class="d-flex justify-content-between align-items-center mb-3">
361
+ <h5 class="panel-title m-0"><i class="fas fa-file-alt"></i> Kịch bản truyện</h5>
362
+ <button id="copyScriptBtn" class="btn btn-outline-primary btn-sm"><i class="fas fa-copy"></i> Sao chép</button>
363
+ </div>
364
+ <p class="text-muted small mb-2">Chỉnh sửa kịch bản nếu muốn, rồi bấm thu âm.</p>
365
+ <textarea id="storyScript" class="form-control flex-grow-1" rows="16" style="font-size: 0.92rem; line-height: 1.6;"></textarea>
366
+ </div>
367
  </div>
368
  </div>
369
+ <div class="col-lg-6 d-flex flex-column gap-4">
370
+ <!-- AI Recommendation Card -->
371
+ <div class="card prompt-card">
372
+ <div class="card-body p-3">
373
+ <h6 class="fw-bold small mb-2"><i class="fas fa-robot text-primary"></i> AI phân tích</h6>
374
+ <p class="small mb-2"><strong>Tiêu đề:</strong> <span id="storyTitle" class="text-primary fw-bold">—</span></p>
375
+ <p class="small mb-2"><strong>Giọng đọc:</strong> <span id="voiceLabel" class="recommendation-badge">—</span></p>
376
+ <p class="small mb-2"><strong>Tốc độ:</strong> <span id="rateBadge" class="voice-badge">—</span></p>
377
+ <p class="small mb-0 text-muted" id="voiceReason">—</p>
378
+ </div>
379
+ </div>
380
 
381
+ <!-- Manual Override for this step -->
382
+ <div class="card bg-light border-0">
383
+ <div class="card-body p-3">
384
+ <h6 class="fw-bold small mb-2"><i class="fas fa-sliders-h"></i> Ghi đè giọng đọc (tuỳ chọn)</h6>
385
+ <div class="row g-2">
386
+ <div class="col-7">
387
+ <select id="finalVoice" class="form-select form-select-sm">
388
+ <option value="auto" selected>Dùng AI gợi ý</option>
389
+ <option value="vi-VN-HoaiMyNeural">Hoài My (Nữ)</option>
390
+ <option value="vi-VN-NamMinhNeural">Nam Minh (Nam)</option>
391
+ </select>
392
  </div>
393
+ <div class="col-5">
394
+ <select id="finalRate" class="form-select form-select-sm">
395
+ <option value="auto" selected>AI chọn</option>
396
+ <option value="-15%">-15%</option>
397
+ <option value="-8%">-8%</option>
398
+ <option value="+0%">+0%</option>
399
+ <option value="+5%">+5%</option>
400
+ </select>
401
  </div>
402
  </div>
403
+ </div>
404
+ </div>
 
 
405
 
406
+ <!-- Audio Result (Hidden until synthesized) -->
407
+ <div id="audioResultCard" class="card audio-card d-none">
408
+ <div class="card-body p-4">
409
+ <h5 class="fw-bold text-white mb-2" id="audioTitle">🎧 Truyện Audio</h5>
410
  <div class="audio-player-custom">
411
  <audio id="audioPlayer" controls></audio>
412
  </div>
 
 
413
  <div class="d-flex gap-2 mt-3">
414
  <a id="downloadAudioBtn" href="#" download class="btn btn-light btn-sm flex-grow-1 fw-bold">
415
+ <i class="fas fa-download me-1"></i> Tải MP3
416
  </a>
417
  <button id="reSynthesizeBtn" class="btn btn-outline-light btn-sm flex-grow-1 fw-bold">
418
+ <i class="fas fa-sync-alt me-1"></i> Đọc lại
419
  </button>
420
  </div>
421
  </div>
422
  </div>
423
 
424
+ <div class="step-nav-bar">
425
+ <button id="btnBack2" class="btn btn-outline-secondary flex-grow-1">
426
+ <i class="fas fa-arrow-left me-1"></i> Quay lại
427
+ </button>
428
+ <button id="btnStep3" class="btn-primary-custom flex-grow-1" style="padding: 12px 20px;">
429
+ <i class="fas fa-headphones me-2"></i>BƯỚC 3: THU ÂM
430
+ </button>
 
 
 
 
 
431
  </div>
432
  </div>
433
  </div>
 
443
 
444
  {% block scripts %}
445
  <script>
446
+ // ── State ──
447
  let geminiModelList = [];
448
+ let scriptData = {}; // Holds title, suggested_voice, rate, reason from step 2
449
 
450
+ // ── Model Loading ──
451
  async function loadModels() {
452
  try {
453
  const res = await fetch('/api/gemini-models');
 
457
  geminiModelList.forEach(m => {
458
  const opt = document.createElement('option');
459
  opt.value = m.id;
460
+ const q = m.remaining > 0 ? `✓ Còn ${m.remaining}` : `✗ Hết`;
461
+ opt.textContent = `${m.name} — ${q}`;
 
 
462
  if (m.remaining === 0) opt.disabled = true;
463
  sel.appendChild(opt);
464
  });
465
+ updateQuota();
466
+ } catch(e) { console.error(e); }
 
 
467
  }
468
+ function updateQuota() {
469
+ const m = geminiModelList.find(x => x.id === document.getElementById('geminiModel').value);
 
 
470
  const el = document.getElementById('modelQuota');
471
+ if (m) {
472
+ el.innerHTML = m.remaining > 0
473
+ ? `<span style="color:var(--color-primary)"><i class="fas fa-check-circle"></i> Còn <b>${m.remaining}</b> request hôm nay</span>`
474
+ : `<span style="color:#dc3545"><i class="fas fa-times-circle"></i> Đã hết quota</span>`;
 
475
  }
476
  }
477
+ document.getElementById('geminiModel').addEventListener('change', updateQuota);
 
478
  loadModels();
479
 
480
+ // ── Step Navigation ──
481
+ let currentStep = 1;
482
+ function goToStep(n) {
483
+ // Hide all panels
484
+ document.querySelectorAll('.step-panel').forEach(p => p.classList.remove('active'));
485
+ document.getElementById(`step${n}Panel`).classList.add('active');
486
+
487
+ // Update indicators
488
+ for (let i = 1; i <= 3; i++) {
489
+ const ind = document.getElementById(`stepIndicator${i}`);
490
+ ind.classList.remove('active', 'done');
491
+ if (i < n) ind.classList.add('done');
492
+ else if (i === n) ind.classList.add('active');
493
+ }
494
+ // Connectors
495
+ document.getElementById('connector1').className = 'step-connector' + (n > 1 ? ' done' : '');
496
+ document.getElementById('connector2').className = 'step-connector' + (n > 2 ? ' done' : '');
497
+
498
+ currentStep = n;
499
+ clearError();
500
+ }
501
+
502
+ // ── Error Handling ──
503
  function showError(msg) {
 
504
  document.getElementById('errorMsg').textContent = msg;
505
+ document.getElementById('errorArea').classList.remove('d-none');
506
  }
 
507
  function clearError() {
508
  document.getElementById('errorArea').classList.add('d-none');
509
  }
510
 
511
+ // ── STEP 1: Generate Prompt ──
512
+ document.getElementById('btnStep1').addEventListener('click', async () => {
 
 
513
  const idea = document.getElementById('storyIdea').value.trim();
514
+ if (!idea) { showError('Vui lòng nhập ý tưởng truyện!'); return; }
 
 
 
515
 
516
  const loader = document.getElementById('loaderOverlay');
517
+ document.getElementById('loaderText').textContent = 'Bước 1/3 — Đang sinh prompt tối ưu từ ý tưởng...';
 
518
  loader.style.display = 'flex';
519
  clearError();
520
 
521
  try {
522
+ const payload = {
523
+ idea: idea,
524
+ genre: document.getElementById('cfgGenre').value,
525
+ style: document.getElementById('cfgStyle').value,
526
+ mood: document.getElementById('cfgMood').value,
527
+ length: document.getElementById('cfgLength').value,
528
+ audience: document.getElementById('cfgAudience').value,
529
+ extras: document.getElementById('cfgExtras').value,
530
+ gemini_model: document.getElementById('geminiModel').value
531
+ };
532
+ const res = await fetch('/api/audio-story-studio/create-prompt', {
533
  method: 'POST',
534
  headers: { 'Content-Type': 'application/json' },
535
+ body: JSON.stringify(payload)
536
  });
537
  const data = await res.json();
 
538
  if (data.error || !data.success) {
539
+ showError(data.error || 'Lỗi tạo prompt.');
 
540
  return;
541
  }
542
 
543
+ document.getElementById('optimizedPrompt').value = data.optimized_prompt;
544
+ goToStep(2);
545
+ await loadModels();
546
+ } catch(e) {
547
+ showError('Lỗi kết nối. Vui lòng thử lại.');
548
+ } finally {
549
+ loader.style.display = 'none';
550
+ }
551
+ });
552
 
553
+ // ── STEP 2: Generate Script ──
554
+ document.getElementById('btnStep2').addEventListener('click', async () => {
555
+ const prompt = document.getElementById('optimizedPrompt').value.trim();
556
+ if (!prompt) { showError('Prompt đang trống!'); return; }
557
+
558
+ const loader = document.getElementById('loaderOverlay');
559
+ document.getElementById('loaderText').textContent = 'Bước 2/3 — Đang viết kịch bản truyện audio...';
560
+ loader.style.display = 'flex';
561
+ clearError();
562
+
563
+ try {
564
+ const res = await fetch('/api/audio-story-studio/create-script', {
565
+ method: 'POST',
566
+ headers: { 'Content-Type': 'application/json' },
567
+ body: JSON.stringify({
568
+ prompt: prompt,
569
+ gemini_model: document.getElementById('geminiModel').value
570
+ })
571
+ });
572
+ const data = await res.json();
573
+ if (data.error || !data.success) {
574
+ showError(data.error || 'Lỗi tạo kịch bản.');
575
+ return;
576
  }
577
 
578
+ scriptData = data;
579
+ document.getElementById('storyScript').value = data.script;
580
+ document.getElementById('storyTitle').textContent = data.title;
 
581
 
582
+ // Voice label
583
+ const vl = data.suggested_voice === 'vi-VN-HoaiMyNeural' ? '🎙 Hoài My (Nữ)' : '🎙 Nam Minh (Nam)';
584
+ document.getElementById('voiceLabel').textContent = vl;
585
+ document.getElementById('rateBadge').textContent = `Tốc độ: ${data.rate || '+0%'}`;
586
+ document.getElementById('voiceReason').textContent = data.reason || '';
587
 
588
+ goToStep(3);
 
 
589
  await loadModels();
590
  } catch(e) {
591
+ showError('Lỗi kết nối. Vui lòng thử lại.');
592
  } finally {
593
  loader.style.display = 'none';
594
  }
595
  });
596
 
597
+ // ── STEP 3: Synthesize Audio ──
598
+ async function doSynthesize() {
599
+ const script = document.getElementById('storyScript').value.trim();
600
+ if (!script) { showError('Kịch bản đang trống!'); return; }
601
+
602
+ // Determine voice
603
+ let voice = document.getElementById('finalVoice').value;
604
+ if (voice === 'auto') {
605
+ voice = document.getElementById('manualVoice').value;
606
+ if (voice === 'auto') {
607
+ voice = (scriptData && scriptData.suggested_voice) || 'vi-VN-HoaiMyNeural';
608
+ }
609
  }
610
 
611
+ let rate = document.getElementById('finalRate').value;
612
+ if (rate === 'auto') {
613
+ rate = document.getElementById('manualRate').value;
614
+ if (rate === 'auto') {
615
+ rate = (scriptData && scriptData.rate) || '+0%';
616
+ }
 
 
 
 
 
 
 
 
 
617
  }
618
 
619
  const loader = document.getElementById('loaderOverlay');
620
+ document.getElementById('loaderText').textContent = 'Bước 3/3 — Đang tổng hợp giọng đọc AI...';
 
621
  loader.style.display = 'flex';
622
  clearError();
623
 
 
 
 
 
 
 
 
 
 
 
 
624
  try {
625
+ const res = await fetch('/api/audio-story-studio/synthesize', {
626
+ method: 'POST',
627
+ headers: { 'Content-Type': 'application/json' },
628
+ body: JSON.stringify({ script, voice, rate })
629
+ });
630
+ const data = await res.json();
631
+ if (data.error || !data.success) {
632
+ showError(data.error || 'Lỗi thu âm.');
633
+ return;
634
+ }
635
+
636
+ const player = document.getElementById('audioPlayer');
637
+ player.src = data.audio_url;
638
+ player.load();
639
+ document.getElementById('downloadAudioBtn').href = data.audio_url;
640
+ document.getElementById('audioTitle').textContent = `🎧 ${scriptData.title || 'Truyện Audio'}`;
641
+ document.getElementById('audioResultCard').classList.remove('d-none');
642
  } catch(e) {
643
+ showError('Lỗi kết nối khi thu âm.');
644
  } finally {
645
  loader.style.display = 'none';
646
  }
647
+ }
648
 
649
+ document.getElementById('btnStep3').addEventListener('click', doSynthesize);
650
+ document.getElementById('reSynthesizeBtn').addEventListener('click', doSynthesize);
651
+
652
+ // ── Back Buttons ──
653
+ document.getElementById('btnBack1').addEventListener('click', () => goToStep(1));
654
+ document.getElementById('btnBack2').addEventListener('click', () => goToStep(2));
655
+
656
+ // ── Copy Buttons ──
657
+ function copyBtn(textElId, btnElId) {
658
+ document.getElementById(btnElId).addEventListener('click', () => {
659
+ const text = document.getElementById(textElId).value;
660
+ if (!text) return;
661
+ navigator.clipboard.writeText(text).then(() => {
662
+ const btn = document.getElementById(btnElId);
663
+ const orig = btn.innerHTML;
664
+ btn.innerHTML = '<i class="fas fa-check"></i> Đã sao chép!';
665
+ setTimeout(() => btn.innerHTML = orig, 2000);
666
+ });
667
  });
668
+ }
669
+ copyBtn('optimizedPrompt', 'copyPromptBtn');
670
+ copyBtn('storyScript', 'copyScriptBtn');
671
  </script>
672
  {% endblock %}