Refactor frontend to Vue 3 + Vite + Pinia + Cypress E2E
- 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>
This commit is contained in:
75
frontend/scripts/demo-subtitles.srt
Normal file
75
frontend/scripts/demo-subtitles.srt
Normal file
@@ -0,0 +1,75 @@
|
||||
1
|
||||
00:00:00,500 --> 00:00:04,500
|
||||
欢迎使用 doTERRA 精油配方计算器,这是一个功能完整的精油配方管理工具
|
||||
|
||||
2
|
||||
00:00:05,000 --> 00:00:08,500
|
||||
首页展示了所有公共配方,以卡片形式排列
|
||||
|
||||
3
|
||||
00:00:09,000 --> 00:00:11,500
|
||||
可以上下滚动浏览更多配方
|
||||
|
||||
4
|
||||
00:00:12,000 --> 00:00:15,500
|
||||
在搜索框输入关键词,可以快速筛选配方
|
||||
|
||||
5
|
||||
00:00:16,000 --> 00:00:19,500
|
||||
输入"薰衣草",立即过滤出包含薰衣草的配方
|
||||
|
||||
6
|
||||
00:00:20,000 --> 00:00:24,000
|
||||
点击任意配方卡片,查看配方详情
|
||||
|
||||
7
|
||||
00:00:24,500 --> 00:00:29,000
|
||||
详情页显示精油成分、用量、单价和总成本
|
||||
|
||||
8
|
||||
00:00:30,000 --> 00:00:34,000
|
||||
切换到"精油价目"页面,查看所有精油价格
|
||||
|
||||
9
|
||||
00:00:34,500 --> 00:00:37,500
|
||||
支持按名称搜索精油
|
||||
|
||||
10
|
||||
00:00:38,000 --> 00:00:41,500
|
||||
可以切换"瓶价"和"滴价"两种显示模式
|
||||
|
||||
11
|
||||
00:00:42,000 --> 00:00:46,500
|
||||
"管理配方"页面用于配方的增删改查和批量操作
|
||||
|
||||
12
|
||||
00:00:47,000 --> 00:00:51,000
|
||||
支持标签筛选、批量打标签、批量导出卡片
|
||||
|
||||
13
|
||||
00:00:52,000 --> 00:00:56,000
|
||||
"个人库存"页面标记自己拥有的精油
|
||||
|
||||
14
|
||||
00:00:56,500 --> 00:00:59,500
|
||||
系统会自动推荐你能制作的配方
|
||||
|
||||
15
|
||||
00:01:00,000 --> 00:01:05,000
|
||||
"操作日志"记录所有数据变更,支持按类型和用户筛选
|
||||
|
||||
16
|
||||
00:01:06,000 --> 00:01:10,500
|
||||
内置 Bug 追踪系统,支持优先级、状态流转和评论
|
||||
|
||||
17
|
||||
00:01:12,000 --> 00:01:17,000
|
||||
"用户管理"可以创建用户、分配角色、审核翻译建议
|
||||
|
||||
18
|
||||
00:01:18,000 --> 00:01:22,000
|
||||
最后回到首页,演示到此结束
|
||||
|
||||
19
|
||||
00:01:22,500 --> 00:01:26,000
|
||||
感谢观看!这是基于 Vue 3 + Vite + Pinia 构建的现代化应用
|
||||
67
frontend/scripts/generate-demo-video.sh
Normal file
67
frontend/scripts/generate-demo-video.sh
Normal file
@@ -0,0 +1,67 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
FRONTEND_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
PROJECT_DIR="$(dirname "$FRONTEND_DIR")"
|
||||
OUTPUT_DIR="$FRONTEND_DIR/demo-output"
|
||||
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
echo "=== Step 1: Generate TTS audio ==="
|
||||
source "$PROJECT_DIR/.venv/bin/activate"
|
||||
python3 "$SCRIPT_DIR/generate-tts.py" "$OUTPUT_DIR"
|
||||
|
||||
echo "=== Step 2: Record Cypress demo ==="
|
||||
cd "$FRONTEND_DIR"
|
||||
npx cypress run --spec "cypress/e2e/demo-walkthrough.cy.js" || true
|
||||
|
||||
# Find the recorded video
|
||||
VIDEO=$(find cypress/videos -name "demo-walkthrough*" -type f 2>/dev/null | head -1)
|
||||
if [ -z "$VIDEO" ]; then
|
||||
echo "ERROR: No video found in cypress/videos/"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found video: $VIDEO"
|
||||
cp "$VIDEO" "$OUTPUT_DIR/raw-screen.mp4"
|
||||
|
||||
echo "=== Step 3: Combine video + audio + subtitles ==="
|
||||
|
||||
# Get video and audio durations
|
||||
VIDEO_DUR=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$OUTPUT_DIR/raw-screen.mp4" | cut -d. -f1)
|
||||
AUDIO_DUR=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$OUTPUT_DIR/narration.mp3" | cut -d. -f1)
|
||||
echo "Video: ${VIDEO_DUR}s, Audio: ${AUDIO_DUR}s"
|
||||
|
||||
# Use the longer duration, speed-adjust video if needed
|
||||
if [ "$VIDEO_DUR" -gt "$AUDIO_DUR" ]; then
|
||||
# Speed up video to match audio length
|
||||
SPEED=$(python3 -c "print(round($VIDEO_DUR / $AUDIO_DUR, 3))")
|
||||
echo "Speeding up video by ${SPEED}x"
|
||||
ffmpeg -y -i "$OUTPUT_DIR/raw-screen.mp4" \
|
||||
-filter:v "setpts=PTS/${SPEED}" \
|
||||
-an "$OUTPUT_DIR/adjusted-screen.mp4" 2>/dev/null
|
||||
elif [ "$AUDIO_DUR" -gt "$VIDEO_DUR" ]; then
|
||||
# Slow down video
|
||||
SPEED=$(python3 -c "print(round($VIDEO_DUR / $AUDIO_DUR, 3))")
|
||||
echo "Slowing video to ${SPEED}x"
|
||||
ffmpeg -y -i "$OUTPUT_DIR/raw-screen.mp4" \
|
||||
-filter:v "setpts=PTS/${SPEED}" \
|
||||
-an "$OUTPUT_DIR/adjusted-screen.mp4" 2>/dev/null
|
||||
else
|
||||
cp "$OUTPUT_DIR/raw-screen.mp4" "$OUTPUT_DIR/adjusted-screen.mp4"
|
||||
fi
|
||||
|
||||
# Combine: video + audio + burned-in subtitles
|
||||
ffmpeg -y \
|
||||
-i "$OUTPUT_DIR/adjusted-screen.mp4" \
|
||||
-i "$OUTPUT_DIR/narration.mp3" \
|
||||
-vf "subtitles=$SCRIPT_DIR/demo-subtitles.srt:force_style='FontSize=20,FontName=Noto Sans CJK SC,PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,Outline=2,Shadow=1,MarginV=30'" \
|
||||
-c:v libx264 -preset fast -crf 23 \
|
||||
-c:a aac -b:a 128k \
|
||||
-shortest \
|
||||
"$OUTPUT_DIR/demo-final.mp4" 2>/dev/null
|
||||
|
||||
echo ""
|
||||
echo "=== Done! ==="
|
||||
echo "Output: $OUTPUT_DIR/demo-final.mp4"
|
||||
ls -lh "$OUTPUT_DIR/demo-final.mp4"
|
||||
142
frontend/scripts/generate-tts.py
Normal file
142
frontend/scripts/generate-tts.py
Normal file
@@ -0,0 +1,142 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user