Spaces:
Sleeping
Sleeping
feat: redesign Audio Story Studio with 3-step workflow (prompt > script > audio)
Browse files- app.py +75 -14
- 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 |
-
|
| 1591 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 1598 |
|
| 1599 |
system_instruction = (
|
| 1600 |
-
"
|
| 1601 |
-
"
|
| 1602 |
-
"
|
| 1603 |
-
"- '
|
| 1604 |
-
"- '
|
| 1605 |
-
"
|
| 1606 |
-
"
|
| 1607 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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,
|
| 29 |
-
padding:
|
| 30 |
border-radius: 50px;
|
| 31 |
-
font-size: 0.
|
| 32 |
font-weight: 600;
|
| 33 |
display: inline-flex;
|
| 34 |
align-items: center;
|
| 35 |
-
gap:
|
| 36 |
}
|
| 37 |
-
.
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
}
|
| 44 |
-
.audio-player-custom audio {
|
| 45 |
-
width: 100%;
|
| 46 |
-
outline: none;
|
| 47 |
}
|
| 48 |
-
|
| 49 |
-
|
|
|
|
|
|
|
| 50 |
border: 1px solid #e2e8f0;
|
| 51 |
border-radius: var(--radius-md);
|
| 52 |
-
padding:
|
| 53 |
-
max-height:
|
| 54 |
overflow-y: auto;
|
| 55 |
-
font-size: 0.
|
| 56 |
-
line-height: 1.
|
| 57 |
white-space: pre-wrap;
|
| 58 |
}
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
|
|
|
|
|
|
| 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
|
| 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">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
</div>
|
| 79 |
|
| 80 |
-
<
|
| 81 |
-
|
| 82 |
-
<div class="
|
| 83 |
-
<div class="
|
| 84 |
-
<div class="card
|
| 85 |
-
<div>
|
| 86 |
-
<h5 class="panel-title mb-4"><i class="fas fa-
|
| 87 |
-
|
| 88 |
<div class="mb-3">
|
| 89 |
-
<label class="form-label fw-bold">Ý tưởng hoặc Chủ đề truyện
|
| 90 |
-
<textarea id="storyIdea" class="form-control" rows="
|
| 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 |
-
<
|
| 95 |
-
<div class="row g-3 align-items-center mb-4">
|
| 96 |
<div class="col-md-6">
|
| 97 |
-
<label class="form-label fw-bold"
|
| 98 |
-
<select id="
|
| 99 |
-
<option value="">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
</select>
|
| 101 |
</div>
|
| 102 |
<div class="col-md-6">
|
| 103 |
-
<label class="form-label
|
| 104 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
</div>
|
| 106 |
</div>
|
| 107 |
|
| 108 |
-
<
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
<
|
| 112 |
-
|
| 113 |
-
<
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
<select id="manualVoice" class="form-select form-select-sm">
|
| 116 |
-
<option value="auto" selected>
|
| 117 |
-
<option value="vi-VN-HoaiMyNeural">Hoài My (
|
| 118 |
-
<option value="vi-VN-NamMinhNeural">Nam Minh (
|
| 119 |
</select>
|
| 120 |
</div>
|
| 121 |
-
<div
|
| 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>
|
| 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="+
|
|
|
|
| 129 |
</select>
|
| 130 |
</div>
|
| 131 |
</div>
|
| 132 |
</div>
|
| 133 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
</div>
|
| 140 |
</div>
|
| 141 |
</div>
|
|
|
|
| 142 |
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
<div
|
| 147 |
-
<div>
|
| 148 |
-
<
|
| 149 |
-
|
| 150 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
</div>
|
| 152 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
<
|
| 163 |
-
|
| 164 |
-
<
|
| 165 |
</div>
|
| 166 |
-
<div class="
|
| 167 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
</div>
|
| 169 |
</div>
|
| 170 |
-
|
| 171 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 187 |
</button>
|
| 188 |
</div>
|
| 189 |
</div>
|
| 190 |
</div>
|
| 191 |
|
| 192 |
-
<
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 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
|
| 221 |
|
| 222 |
-
//
|
| 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
|
| 233 |
-
|
| 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 |
-
|
| 240 |
-
} catch(e) {
|
| 241 |
-
console.error('Lỗi tải danh mục model:', e);
|
| 242 |
-
}
|
| 243 |
}
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
const sel = document.getElementById('geminiModel');
|
| 247 |
-
const chosen = geminiModelList.find(m => m.id === sel.value);
|
| 248 |
const el = document.getElementById('modelQuota');
|
| 249 |
-
if (
|
| 250 |
-
|
| 251 |
-
? `<span style="color:
|
| 252 |
-
: `<span style="color:
|
| 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 |
-
|
| 264 |
}
|
| 265 |
-
|
| 266 |
function clearError() {
|
| 267 |
document.getElementById('errorArea').classList.add('d-none');
|
| 268 |
}
|
| 269 |
|
| 270 |
-
|
| 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 |
-
|
| 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 |
-
|
| 288 |
-
|
| 289 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
method: 'POST',
|
| 291 |
headers: { 'Content-Type': 'application/json' },
|
| 292 |
-
body: JSON.stringify(
|
| 293 |
});
|
| 294 |
const data = await res.json();
|
| 295 |
-
|
| 296 |
if (data.error || !data.success) {
|
| 297 |
-
showError(data.error || 'Lỗi
|
| 298 |
-
loader.style.display = 'none';
|
| 299 |
return;
|
| 300 |
}
|
| 301 |
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
|
|
|
|
|
|
|
|
|
| 308 |
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
}
|
| 319 |
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
document.getElementById('
|
| 323 |
-
document.getElementById('rateBadge').textContent = `Tốc độ: ${rateToUse}`;
|
| 324 |
|
| 325 |
-
//
|
| 326 |
-
|
| 327 |
-
|
|
|
|
|
|
|
| 328 |
|
| 329 |
-
|
| 330 |
-
document.getElementById('resultPlaceholder').classList.add('d-none');
|
| 331 |
-
document.getElementById('resultContainer').classList.remove('d-none');
|
| 332 |
await loadModels();
|
| 333 |
} catch(e) {
|
| 334 |
-
showError('
|
| 335 |
} finally {
|
| 336 |
loader.style.display = 'none';
|
| 337 |
}
|
| 338 |
});
|
| 339 |
|
| 340 |
-
// Synthesize
|
| 341 |
-
async function
|
| 342 |
-
const
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
|
|
|
| 351 |
}
|
| 352 |
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 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 |
-
|
| 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
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 392 |
} catch(e) {
|
| 393 |
-
showError(
|
| 394 |
} finally {
|
| 395 |
loader.style.display = 'none';
|
| 396 |
}
|
| 397 |
-
}
|
| 398 |
|
| 399 |
-
|
| 400 |
-
document.getElementById('
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 %}
|