- Replace single-file 8441-line HTML with Vue 3 SPA - Pinia stores: auth, oils, recipes, diary, ui - Composables: useApi, useDialog, useSmartPaste, useOilTranslation - 6 shared components: RecipeCard, RecipeDetailOverlay, TagPicker, etc. - 9 page views: RecipeSearch, RecipeManager, Inventory, OilReference, etc. - 14 Cypress E2E test specs (113 tests), all passing - Multi-stage Dockerfile (Node build + Python runtime) - Demo video generation scripts (TTS + subtitles + screen recording) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
143 lines
5.4 KiB
Python
143 lines
5.4 KiB
Python
#!/usr/bin/env python3
|
||
"""Generate TTS narration for the demo video using edge-tts.
|
||
Uses XiaoxiaoNeural with natural pacing and SSML prosody control."""
|
||
|
||
import asyncio
|
||
import sys
|
||
import os
|
||
import struct
|
||
import wave
|
||
|
||
OUTPUT_DIR = sys.argv[1] if len(sys.argv) > 1 else "demo-output"
|
||
|
||
# (start_seconds, text, [optional: pause_after_ms])
|
||
# Timeline aligned with demo-subtitles.srt and demo-walkthrough.cy.js
|
||
SEGMENTS = [
|
||
(0.5, '欢迎使用 doTERRA 精油配方计算器。这是一个功能完整的精油配方管理工具。'),
|
||
(5.0, '首页展示了所有公共配方,以卡片形式排列。'),
|
||
(9.0, '可以上下滚动,浏览更多配方。'),
|
||
(12.0, '在搜索框输入关键词,可以快速筛选配方。'),
|
||
(16.0, '输入薰衣草,立即过滤出包含薰衣草的配方。'),
|
||
(20.0, '点击任意配方卡片,查看配方详情。'),
|
||
(24.5, '详情页显示精油成分、用量、单价、和总成本。'),
|
||
(30.0, '切换到精油价目页面,查看所有精油价格。'),
|
||
(34.5, '支持按名称搜索精油。'),
|
||
(38.0, '可以切换瓶价和滴价两种显示模式。'),
|
||
(42.0, '管理配方页面,用于配方的增删改查和批量操作。'),
|
||
(47.0, '支持标签筛选、批量打标签、批量导出卡片。'),
|
||
(52.0, '个人库存页面,标记自己拥有的精油。'),
|
||
(56.5, '系统会自动推荐你能制作的配方。'),
|
||
(60.0, '操作日志记录所有数据变更,支持按类型和用户筛选。'),
|
||
(66.0, '内置Bug追踪系统,支持优先级、状态流转和评论。'),
|
||
(72.0, '用户管理可以创建用户、分配角色、审核翻译建议。'),
|
||
(78.0, '最后回到首页。演示到此结束。'),
|
||
(82.5, '感谢观看!这是基于Vue 3、Vite和Pinia构建的现代化应用。'),
|
||
]
|
||
|
||
VOICE = "zh-CN-XiaoxiaoNeural"
|
||
RATE = "+0%"
|
||
PITCH = "+0Hz"
|
||
|
||
try:
|
||
import edge_tts
|
||
except ImportError:
|
||
print("Installing edge-tts...")
|
||
os.system(f"{sys.executable} -m pip install edge-tts -q")
|
||
import edge_tts
|
||
|
||
|
||
async def generate_segment(text, output_path):
|
||
"""Generate a single TTS segment with natural prosody."""
|
||
communicate = edge_tts.Communicate(text, VOICE, rate=RATE, pitch=PITCH)
|
||
await communicate.save(output_path)
|
||
|
||
|
||
async def main():
|
||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||
segment_files = []
|
||
|
||
print(f"Generating {len(SEGMENTS)} TTS segments with {VOICE}...")
|
||
|
||
for i, (start_sec, text) in enumerate(SEGMENTS):
|
||
seg_path = os.path.join(OUTPUT_DIR, f"seg_{i:02d}.mp3")
|
||
print(f" [{i+1}/{len(SEGMENTS)}] {start_sec:5.1f}s {text[:40]}...")
|
||
await generate_segment(text, seg_path)
|
||
segment_files.append((start_sec, seg_path))
|
||
|
||
# Total duration: last segment start + 8s buffer
|
||
total_duration = SEGMENTS[-1][0] + 8
|
||
|
||
print(f"\nAssembling narration track ({total_duration:.0f}s)...")
|
||
|
||
# Generate silence base
|
||
silence_path = os.path.join(OUTPUT_DIR, "silence.mp3")
|
||
os.system(
|
||
f'ffmpeg -y -f lavfi -i anullsrc=r=44100:cl=mono '
|
||
f'-t {total_duration} -c:a libmp3lame -q:a 2 "{silence_path}" 2>/dev/null'
|
||
)
|
||
|
||
# Build ffmpeg complex filter to overlay each segment at its timestamp
|
||
inputs = [f'-i "{silence_path}"']
|
||
filter_parts = []
|
||
|
||
for i, (start_sec, seg_path) in enumerate(segment_files):
|
||
inputs.append(f'-i "{seg_path}"')
|
||
delay_ms = int(start_sec * 1000)
|
||
# Normalize volume for each segment
|
||
filter_parts.append(f'[{i+1}]adelay={delay_ms}|{delay_ms},volume=1.5[d{i}]')
|
||
|
||
# Mix all
|
||
mix_inputs = "[0]" + "".join(f"[d{i}]" for i in range(len(segment_files)))
|
||
n_inputs = len(segment_files) + 1
|
||
filter_parts.append(
|
||
f'{mix_inputs}amix=inputs={n_inputs}:duration=longest:dropout_transition=0,'
|
||
f'volume={n_inputs}[out]'
|
||
)
|
||
|
||
filter_str = ";".join(filter_parts)
|
||
output_path = os.path.join(OUTPUT_DIR, "narration.mp3")
|
||
|
||
cmd = (
|
||
f'ffmpeg -y {" ".join(inputs)} '
|
||
f'-filter_complex "{filter_str}" '
|
||
f'-map "[out]" -c:a libmp3lame -q:a 2 "{output_path}" 2>/dev/null'
|
||
)
|
||
|
||
ret = os.system(cmd)
|
||
if ret != 0:
|
||
print("ERROR: ffmpeg narration assembly failed")
|
||
print("Trying fallback: simple concatenation...")
|
||
# Fallback: just concatenate with silence gaps
|
||
concat_list = os.path.join(OUTPUT_DIR, "concat.txt")
|
||
with open(concat_list, "w") as f:
|
||
prev_end = 0
|
||
for start_sec, seg_path in segment_files:
|
||
gap = start_sec - prev_end
|
||
if gap > 0:
|
||
gap_path = os.path.join(OUTPUT_DIR, f"gap_{prev_end:.0f}.mp3")
|
||
os.system(f'ffmpeg -y -f lavfi -i anullsrc=r=44100:cl=mono -t {gap} -c:a libmp3lame -q:a 2 "{gap_path}" 2>/dev/null')
|
||
f.write(f"file '{gap_path}'\n")
|
||
f.write(f"file '{seg_path}'\n")
|
||
# Estimate segment duration ~3s
|
||
prev_end = start_sec + 3
|
||
os.system(f'ffmpeg -y -f concat -safe 0 -i "{concat_list}" -c:a libmp3lame -q:a 2 "{output_path}" 2>/dev/null')
|
||
|
||
# Cleanup
|
||
for _, seg_path in segment_files:
|
||
try: os.remove(seg_path)
|
||
except: pass
|
||
try: os.remove(silence_path)
|
||
except: pass
|
||
|
||
print(f"\nDone! Output: {output_path}")
|
||
if os.path.exists(output_path):
|
||
size = os.path.getsize(output_path) / 1024
|
||
print(f"Size: {size:.0f} KB")
|
||
else:
|
||
print("ERROR: Output file not created")
|
||
sys.exit(1)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
asyncio.run(main())
|