docs(deploy): 简化和优化部署文档结构与内容
- 精简环境准备部分,去除后端和 FFmpeg 安装说明,聚焦前端依赖安装 - 调整关键配置说明,删除后端相关代理和跨域配置,保留前端代理重点 - 优化部署方案,移除复杂的后端服务部署和代理设置,仅保留纯前端部署示例 - 更新部署验证清单,聚焦前端功能和路由验证 - 精简常见问题,去除后端服务相关故障信息 - 删除后端服务、Docker、性能监控等章节,文档更聚焦轻量部署 - 更新页面路由清单,保持核心页面及功能介绍
This commit is contained in:
parent
54c0bfef68
commit
63056170cc
462
DEPLOY.md
462
DEPLOY.md
|
|
@ -3,12 +3,8 @@
|
||||||
## 项目概述
|
## 项目概述
|
||||||
|
|
||||||
- **项目名称**:AI 英语学习辅助平台
|
- **项目名称**:AI 英语学习辅助平台
|
||||||
- **技术栈**:
|
- **技术栈**:Vue 3 + Vite + Vue Router(History 模式)
|
||||||
- 前端:Vue 3 + Vite + Vue Router(History 模式)
|
- **项目结构**:纯前端静态文件(HTML / CSS / JS),可部署到任意静态托管服务
|
||||||
- 后端:Express.js + FFmpeg(视频合成服务)
|
|
||||||
- **项目结构**:
|
|
||||||
- 前端:纯静态文件(HTML / CSS / JS),可部署到任意静态托管服务
|
|
||||||
- 后端:Node.js 服务,需要 FFmpeg 环境,提供视频合成 API
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -18,61 +14,21 @@
|
||||||
|
|
||||||
| 工具 | 版本要求 | 用途 |
|
| 工具 | 版本要求 | 用途 |
|
||||||
|------|----------|------|
|
|------|----------|------|
|
||||||
| Node.js | >= 18.x(推荐 20.x LTS) | 前端构建 & 后端服务运行 |
|
| Node.js | >= 18.x(推荐 20.x LTS) | 前端构建 |
|
||||||
| npm | >= 9.x | 包管理器 |
|
| npm | >= 9.x | 包管理器 |
|
||||||
| FFmpeg | 最新稳定版 | 后端视频合成(仅后端服务需要) |
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 检查 Node.js 和 npm 版本
|
# 检查 Node.js 和 npm 版本
|
||||||
node -v
|
node -v
|
||||||
npm -v
|
npm -v
|
||||||
|
|
||||||
# 检查 FFmpeg 是否已安装(后端服务需要)
|
|
||||||
ffmpeg -version
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 1.2 安装 FFmpeg(后端服务必需)
|
### 1.2 安装依赖
|
||||||
|
|
||||||
#### Windows
|
|
||||||
|
|
||||||
1. 下载 FFmpeg: https://www.gyan.dev/ffmpeg/builds/
|
|
||||||
2. 解压到 `C:\ffmpeg`
|
|
||||||
3. 添加 `C:\ffmpeg\bin` 到系统环境变量 PATH
|
|
||||||
4. 验证安装:
|
|
||||||
```bash
|
|
||||||
ffmpeg -version
|
|
||||||
```
|
|
||||||
|
|
||||||
#### macOS
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 使用 Homebrew 安装
|
|
||||||
brew install ffmpeg
|
|
||||||
|
|
||||||
# 验证安装
|
|
||||||
ffmpeg -version
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Linux (Ubuntu/Debian)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 安装 FFmpeg
|
|
||||||
sudo apt update && sudo apt install ffmpeg
|
|
||||||
|
|
||||||
# 验证安装
|
|
||||||
ffmpeg -version
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.3 安装依赖
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 安装前端依赖
|
# 安装前端依赖
|
||||||
cd AI_Demo
|
cd AI_Demo
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# 安装后端依赖
|
|
||||||
cd server
|
|
||||||
npm install
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -96,7 +52,7 @@ npm install
|
||||||
|
|
||||||
### 2.2 跨域响应头要求(重要)
|
### 2.2 跨域响应头要求(重要)
|
||||||
|
|
||||||
项目使用了 `@ffmpeg/ffmpeg`(WebAssembly),需要服务器返回以下响应头,否则视频讲解功能将无法运行:
|
项目使用了 `@ffmpeg/ffmpeg`(WebAssembly),需要服务器返回以下响应头:
|
||||||
|
|
||||||
```
|
```
|
||||||
Cross-Origin-Opener-Policy: same-origin
|
Cross-Origin-Opener-Policy: same-origin
|
||||||
|
|
@ -111,14 +67,11 @@ Cross-Origin-Embedder-Policy: require-corp
|
||||||
|
|
||||||
| 前端请求路径前缀 | 代理目标 | 用途 | 备注 |
|
| 前端请求路径前缀 | 代理目标 | 用途 | 备注 |
|
||||||
|-----------------|----------|------|------|
|
|-----------------|----------|------|------|
|
||||||
| `/api/video` | 后端服务(默认 http://localhost:3001) | 视频合成 | **无需跨域代理**,直接访问后端服务 |
|
|
||||||
| `/tts-api` | `https://openspeech.bytedance.com` | 豆包 TTS 语音合成 | 需要反向代理 |
|
| `/tts-api` | `https://openspeech.bytedance.com` | 豆包 TTS 语音合成 | 需要反向代理 |
|
||||||
| `/ark-api` | `https://ark.cn-beijing.volces.com` | 火山引擎 Ark 大模型 | 需要反向代理 |
|
| `/ark-api` | `https://ark.cn-beijing.volces.com` | 火山引擎 Ark 大模型 | 需要反向代理 |
|
||||||
| `/dashscope-api` | `https://dashscope.aliyuncs.com` | 阿里云百炼 | 需要反向代理 |
|
| `/dashscope-api` | `https://dashscope.aliyuncs.com` | 阿里云百炼 | 需要反向代理 |
|
||||||
| `/asr-ws` | `wss://openspeech.bytedance.com` | 豆包 ASR WebSocket | 需要反向代理+注入鉴权 Header |
|
| `/asr-ws` | `wss://openspeech.bytedance.com` | 豆包 ASR WebSocket | 需要反向代理+注入鉴权 Header |
|
||||||
|
|
||||||
> **后端服务说明**:`/api/video` 接口由本项目的后端服务(`server/`)提供,不需要跨域代理,但需要确保后端服务正常运行。
|
|
||||||
|
|
||||||
> **ASR WebSocket 特殊说明**:`/asr-ws` 代理需要在代理层注入以下鉴权 Header(浏览器原生 WebSocket 不支持自定义 Header):
|
> **ASR WebSocket 特殊说明**:`/asr-ws` 代理需要在代理层注入以下鉴权 Header(浏览器原生 WebSocket 不支持自定义 Header):
|
||||||
> - `X-Api-App-Key`
|
> - `X-Api-App-Key`
|
||||||
> - `X-Api-Access-Key`
|
> - `X-Api-Access-Key`
|
||||||
|
|
@ -152,22 +105,6 @@ dist/
|
||||||
npm run preview
|
npm run preview
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.2 后端服务准备
|
|
||||||
|
|
||||||
后端服务无需构建,直接使用源码运行即可。确保:
|
|
||||||
|
|
||||||
1. 已安装 FFmpeg(见 1.2 节)
|
|
||||||
2. 已安装依赖:
|
|
||||||
```bash
|
|
||||||
cd server
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
3. 测试服务是否正常:
|
|
||||||
```bash
|
|
||||||
npm start
|
|
||||||
```
|
|
||||||
访问 http://localhost:3001/health 应返回 `{"status":"ok"}`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 四、部署方案
|
## 四、部署方案
|
||||||
|
|
@ -194,7 +131,7 @@ server {
|
||||||
root /var/www/ai-demo;
|
root /var/www/ai-demo;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
# 跨域响应头(FFmpeg WASM 必需)
|
# 跨域响应头
|
||||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||||
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
||||||
|
|
||||||
|
|
@ -203,26 +140,6 @@ server {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
# 后端视频合成服务代理
|
|
||||||
location /api/video/ {
|
|
||||||
proxy_pass http://localhost:3001/api/video/;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection 'upgrade';
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
|
|
||||||
# 增大请求体大小限制(视频合成可能需要)
|
|
||||||
client_max_body_size 100m;
|
|
||||||
|
|
||||||
# 增加超时时间(视频合成耗时较长)
|
|
||||||
proxy_connect_timeout 300;
|
|
||||||
proxy_send_timeout 300;
|
|
||||||
proxy_read_timeout 300;
|
|
||||||
}
|
|
||||||
|
|
||||||
# 代理:豆包 TTS
|
# 代理:豆包 TTS
|
||||||
location /tts-api/ {
|
location /tts-api/ {
|
||||||
proxy_pass https://openspeech.bytedance.com/;
|
proxy_pass https://openspeech.bytedance.com/;
|
||||||
|
|
@ -282,34 +199,7 @@ nginx -t
|
||||||
systemctl reload nginx
|
systemctl reload nginx
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 4.A.4 启动后端服务
|
#### 4.A.4 配置 HTTPS(推荐)
|
||||||
|
|
||||||
后端服务需要使用进程管理器(如 PM2)保持持续运行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 安装 PM2(如果未安装)
|
|
||||||
npm install -g pm2
|
|
||||||
|
|
||||||
# 进入后端目录
|
|
||||||
cd /path/to/AI_Demo/server
|
|
||||||
|
|
||||||
# 启动后端服务
|
|
||||||
pm2 start index.js --name ai-demo-server
|
|
||||||
|
|
||||||
# 查看服务状态
|
|
||||||
pm2 status
|
|
||||||
|
|
||||||
# 查看日志
|
|
||||||
pm2 logs ai-demo-server
|
|
||||||
|
|
||||||
# 设置开机自启
|
|
||||||
pm2 startup
|
|
||||||
pm2 save
|
|
||||||
```
|
|
||||||
|
|
||||||
后端服务将在 `http://localhost:3001` 启动,Nginx 会将 `/api/video` 请求代理到该端口。
|
|
||||||
|
|
||||||
#### 4.A.5 配置 HTTPS(推荐)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 使用 Certbot 申请免费 SSL 证书
|
# 使用 Certbot 申请免费 SSL 证书
|
||||||
|
|
@ -319,32 +209,9 @@ certbot --nginx -d your-domain.com
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 方案 B:Node.js + Express 部署(前后端分离)
|
### 方案 B:Node.js + Express 部署
|
||||||
|
|
||||||
适用于需要在 Node.js 环境中自定义响应头的场景,前后端独立部署。
|
适用于需要在 Node.js 环境中自定义响应头的场景。
|
||||||
|
|
||||||
#### 4.B.1 后端服务部署
|
|
||||||
|
|
||||||
后端服务已在 `server/` 目录中,需要独立运行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 进入后端目录
|
|
||||||
cd server
|
|
||||||
|
|
||||||
# 安装依赖
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# 使用 PM2 启动服务
|
|
||||||
pm2 start index.js --name ai-demo-backend
|
|
||||||
|
|
||||||
# 设置开机自启
|
|
||||||
pm2 startup
|
|
||||||
pm2 save
|
|
||||||
```
|
|
||||||
|
|
||||||
后端服务将在 `http://localhost:3001` 启动。
|
|
||||||
|
|
||||||
#### 4.B.2 前端静态服务器
|
|
||||||
|
|
||||||
创建前端静态服务器文件 `frontend-server.js`(项目根目录):
|
创建前端静态服务器文件 `frontend-server.js`(项目根目录):
|
||||||
|
|
||||||
|
|
@ -359,20 +226,6 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
const app = express()
|
const app = express()
|
||||||
const PORT = process.env.PORT || 3000
|
const PORT = process.env.PORT || 3000
|
||||||
|
|
||||||
// 全局注入 COOP/COEP 响应头(FFmpeg WASM 必需)
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin')
|
|
||||||
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp')
|
|
||||||
next()
|
|
||||||
})
|
|
||||||
|
|
||||||
// 后端视频合成服务代理
|
|
||||||
app.use('/api/video', createProxyMiddleware({
|
|
||||||
target: 'http://localhost:3001',
|
|
||||||
changeOrigin: true,
|
|
||||||
timeout: 300000, // 5分钟超时
|
|
||||||
}))
|
|
||||||
|
|
||||||
// 第三方 API 代理
|
// 第三方 API 代理
|
||||||
app.use('/tts-api', createProxyMiddleware({
|
app.use('/tts-api', createProxyMiddleware({
|
||||||
target: 'https://openspeech.bytedance.com',
|
target: 'https://openspeech.bytedance.com',
|
||||||
|
|
@ -417,7 +270,6 @@ app.get('*', (req, res) => {
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Frontend server running at http://localhost:${PORT}`)
|
console.log(`Frontend server running at http://localhost:${PORT}`)
|
||||||
console.log(`Backend API: http://localhost:3001`)
|
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -441,16 +293,14 @@ pm2 status
|
||||||
pm2 save
|
pm2 save
|
||||||
```
|
```
|
||||||
|
|
||||||
现在有两个服务运行:
|
前端服务运行在 `http://localhost:3000`(提供静态文件和代理)
|
||||||
- **前端服务**:`http://localhost:3000`(提供静态文件和代理)
|
|
||||||
- **后端服务**:`http://localhost:3001`(视频合成 API)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 方案 C:EdgeOne Pages / Vercel / Netlify(纯静态托管)
|
### 方案 C:EdgeOne Pages / Vercel / Netlify(纯静态托管)
|
||||||
|
|
||||||
> **注意**:此类平台**不支持服务端代理**,`/tts-api`、`/ark-api`、`/dashscope-api`、`/asr-ws` 等接口将因跨域或缺少鉴权 Header 而失败。
|
> **注意**:此类平台**不支持服务端代理**,`/tts-api`、`/ark-api`、`/dashscope-api`、`/asr-ws` 等接口将因跨域或缺少鉴权 Header 而失败。
|
||||||
> 建议仅用于演示或配合独立后端服务使用。
|
> 建议仅用于演示。
|
||||||
|
|
||||||
#### 部署步骤(以 Vercel 为例)
|
#### 部署步骤(以 Vercel 为例)
|
||||||
|
|
||||||
|
|
@ -478,16 +328,7 @@ pm2 save
|
||||||
|
|
||||||
## 五、部署后验证清单
|
## 五、部署后验证清单
|
||||||
|
|
||||||
### 5.1 后端服务验证
|
### 5.1 部署验证
|
||||||
|
|
||||||
| 验证项 | 检查方法 | 预期结果 |
|
|
||||||
|--------|----------|----------|
|
|
||||||
| 后端服务运行 | `pm2 status` 或 `ps aux \| grep node` | 显示 ai-demo-server 进程 |
|
|
||||||
| 健康检查接口 | `curl http://localhost:3001/health` | 返回 `{"status":"ok"}` |
|
|
||||||
| FFmpeg 可用 | `curl http://localhost:3001/api/video/health` | 返回 `{"status":"ok","message":"视频合成服务正常运行"}` |
|
|
||||||
| 后端日志无错误 | `pm2 logs ai-demo-server` | 无报错信息 |
|
|
||||||
|
|
||||||
### 5.2 前端服务验证
|
|
||||||
|
|
||||||
| 验证项 | 检查方法 |
|
| 验证项 | 检查方法 |
|
||||||
|--------|----------|
|
|--------|----------|
|
||||||
|
|
@ -500,18 +341,11 @@ pm2 save
|
||||||
| 试题分析 | 进入试题分析页,上传图片或输入题目 |
|
| 试题分析 | 进入试题分析页,上传图片或输入题目 |
|
||||||
| 单词拼写练习 | 进入单词拼写页,测试拼写检测功能 |
|
| 单词拼写练习 | 进入单词拼写页,测试拼写检测功能 |
|
||||||
|
|
||||||
### 5.3 视频合成功能验证
|
|
||||||
|
|
||||||
| 验证项 | 检查方法 |
|
|
||||||
|--------|----------|
|
|
||||||
| 前端可访问后端 | 浏览器 Network 标签查看 `/api/video/health` 请求 |
|
|
||||||
| 视频合成接口 | 在试题分析页生成视频讲解,检查是否能正常下载 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 六、常见问题排查
|
## 六、常见问题排查
|
||||||
|
|
||||||
### Q1:视频讲解页面报错 `SharedArrayBuffer is not defined`
|
### Q1:页面报错 `SharedArrayBuffer is not defined`
|
||||||
|
|
||||||
**原因**:服务器未返回 `Cross-Origin-Opener-Policy: same-origin` 和 `Cross-Origin-Embedder-Policy: require-corp` 响应头。
|
**原因**:服务器未返回 `Cross-Origin-Opener-Policy: same-origin` 和 `Cross-Origin-Embedder-Policy: require-corp` 响应头。
|
||||||
**解决**:参考第二节 2.2,在 Nginx 或 Node.js 服务器中添加对应响应头。
|
**解决**:参考第二节 2.2,在 Nginx 或 Node.js 服务器中添加对应响应头。
|
||||||
|
|
@ -531,31 +365,9 @@ pm2 save
|
||||||
**原因**:代理未注入豆包 ASR 鉴权 Header,或 WebSocket 升级配置缺失。
|
**原因**:代理未注入豆包 ASR 鉴权 Header,或 WebSocket 升级配置缺失。
|
||||||
**解决**:确认 Nginx 配置中包含 `proxy_set_header Upgrade $http_upgrade;` 及三个 `X-Api-*` Header。
|
**解决**:确认 Nginx 配置中包含 `proxy_set_header Upgrade $http_upgrade;` 及三个 `X-Api-*` Header。
|
||||||
|
|
||||||
### Q5:视频合成失败或接口 504 超时
|
### Q5:构建时内存不足
|
||||||
|
|
||||||
**原因**:视频合成耗时较长,默认代理超时时间不足,或后端服务未启动。
|
**原因**:项目依赖较多,构建产物较大。
|
||||||
**解决**:
|
|
||||||
1. 检查后端服务是否运行:`pm2 status` 或 `curl http://localhost:3001/health`
|
|
||||||
2. 检查 FFmpeg 是否安装:`ffmpeg -version`
|
|
||||||
3. 增加 Nginx 超时时间(已在配置中设置为 300 秒)
|
|
||||||
4. 查看后端日志:`pm2 logs ai-demo-server`
|
|
||||||
|
|
||||||
### Q6:后端服务启动失败:`FFmpeg not found`
|
|
||||||
|
|
||||||
**原因**:系统未安装 FFmpeg 或未添加到环境变量。
|
|
||||||
**解决**:参考第一节 1.2,安装 FFmpeg 并配置环境变量。
|
|
||||||
|
|
||||||
### Q7:视频合成返回 `Internal server error`
|
|
||||||
|
|
||||||
**原因**:可能是 FFmpeg 处理错误、临时目录权限问题或资源下载失败。
|
|
||||||
**解决**:
|
|
||||||
1. 查看后端详细日志:`pm2 logs ai-demo-server`
|
|
||||||
2. 检查 `server/temp/` 目录权限:`ls -la server/temp`
|
|
||||||
3. 测试后端健康检查:`curl http://localhost:3001/api/video/health`
|
|
||||||
|
|
||||||
### Q8:构建时内存不足
|
|
||||||
|
|
||||||
**原因**:项目包含 FFmpeg WASM,构建产物较大。
|
|
||||||
**解决**:
|
**解决**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -579,7 +391,6 @@ NODE_OPTIONS=--max-old-space-size=4096 npm run build
|
||||||
| `/question-generator` | 题目生成 | 自动生成练习题 |
|
| `/question-generator` | 题目生成 | 自动生成练习题 |
|
||||||
| `/question-variant` | 题目变体 | 生成相似题目变体 |
|
| `/question-variant` | 题目变体 | 生成相似题目变体 |
|
||||||
| `/audio-to-text` | 语音转文字 | 音频转文本功能 |
|
| `/audio-to-text` | 语音转文字 | 音频转文本功能 |
|
||||||
| `/question-explanation` | 视频讲解 | 视频合成讲解(使用后端服务)|
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -598,30 +409,11 @@ NODE_OPTIONS=--max-old-space-size=4096 npm run build
|
||||||
| marked | ^17.0.5 |
|
| marked | ^17.0.5 |
|
||||||
| pako | ^2.1.0 |
|
| pako | ^2.1.0 |
|
||||||
|
|
||||||
### 后端依赖(server/package.json)
|
|
||||||
|
|
||||||
| 依赖包 | 版本 | 用途 |
|
|
||||||
|--------|------|------|
|
|
||||||
| express | ^4.21.0 | Web 服务器框架 |
|
|
||||||
| cors | ^2.8.5 | 跨域中间件 |
|
|
||||||
| fluent-ffmpeg | ^2.1.3 | FFmpeg Node.js 封装 |
|
|
||||||
| axios | ^1.7.0 | HTTP 客户端 |
|
|
||||||
| uuid | ^10.0.0 | UUID 生成 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 九、目录结构参考
|
## 九、目录结构参考
|
||||||
|
|
||||||
```
|
```
|
||||||
AI_Demo/
|
AI_Demo/
|
||||||
├── dist/ # 前端构建产物(部署此目录)
|
├── dist/ # 前端构建产物(部署此目录)
|
||||||
├── server/ # 后端服务
|
|
||||||
│ ├── routes/ # 路由模块
|
|
||||||
│ │ └── video.js # 视频合成 API
|
|
||||||
│ ├── temp/ # 临时文件目录(自动创建)
|
|
||||||
│ ├── index.js # 后端服务入口
|
|
||||||
│ ├── package.json # 后端依赖配置
|
|
||||||
│ └── README.md # 后端服务文档
|
|
||||||
├── src/ # 前端源码
|
├── src/ # 前端源码
|
||||||
│ ├── config/index.js # API 密钥配置(部署前确认)
|
│ ├── config/index.js # API 密钥配置(部署前确认)
|
||||||
│ ├── router/index.js # 路由定义
|
│ ├── router/index.js # 路由定义
|
||||||
|
|
@ -635,8 +427,7 @@ AI_Demo/
|
||||||
│ │ ├── ProblemSolving.vue # 解题指导
|
│ │ ├── ProblemSolving.vue # 解题指导
|
||||||
│ │ ├── QuestionGenerator.vue # 题目生成
|
│ │ ├── QuestionGenerator.vue # 题目生成
|
||||||
│ │ ├── QuestionVariant.vue # 题目变体
|
│ │ ├── QuestionVariant.vue # 题目变体
|
||||||
│ │ ├── AudioToText.vue # 语音转文字
|
│ │ └── AudioToText.vue # 语音转文字
|
||||||
│ │ └── QuestionExplanation.vue # 视频讲解
|
|
||||||
│ ├── components/ # 公共组件
|
│ ├── components/ # 公共组件
|
||||||
│ ├── assets/ # 静态资源
|
│ ├── assets/ # 静态资源
|
||||||
│ └── MainLayout.vue # 布局组件
|
│ └── MainLayout.vue # 布局组件
|
||||||
|
|
@ -647,217 +438,42 @@ AI_Demo/
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 十、后端服务详细说明
|
## 十、Docker 部署(可选)
|
||||||
|
|
||||||
### 10.1 服务架构
|
### 10.1 构建镜像
|
||||||
|
|
||||||
后端服务基于 Express.js 构建,主要提供视频合成功能:
|
|
||||||
|
|
||||||
- **技术栈**:Express.js + Fluent-FFmpeg
|
|
||||||
- **默认端口**:3001
|
|
||||||
- **主要功能**:接收图片和音频 URL,合成 MP4 视频文件
|
|
||||||
|
|
||||||
### 10.2 API 接口
|
|
||||||
|
|
||||||
#### POST `/api/video/compose`
|
|
||||||
|
|
||||||
合成视频接口,将多组图片和音频合成为视频。
|
|
||||||
|
|
||||||
**请求体示例**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"slides": [
|
|
||||||
{
|
|
||||||
"imageUrl": "https://example.com/image1.png",
|
|
||||||
"audioUrl": "https://example.com/audio1.mp3"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"imageUrl": "https://example.com/image2.png",
|
|
||||||
"audioUrl": "https://example.com/audio2.mp3"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"width": 1920,
|
|
||||||
"height": 1080
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**参数说明**:
|
|
||||||
- `slides`(必需):幻灯片数组,每个元素包含:
|
|
||||||
- `imageUrl`:图片 URL(支持 HTTP/HTTPS URL 或 base64 data URI)
|
|
||||||
- `audioUrl`:音频 URL(MP3 格式)
|
|
||||||
- `width`(可选):视频宽度,默认 1920
|
|
||||||
- `height`(可选):视频高度,默认 1080
|
|
||||||
|
|
||||||
**响应**:
|
|
||||||
- Content-Type: `video/mp4`
|
|
||||||
- 返回合成后的 MP4 视频文件
|
|
||||||
|
|
||||||
#### GET `/api/video/health`
|
|
||||||
|
|
||||||
健康检查接口,检查 FFmpeg 服务状态。
|
|
||||||
|
|
||||||
**响应示例**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "ok",
|
|
||||||
"message": "视频合成服务正常运行",
|
|
||||||
"formats": 300
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### GET `/health`
|
|
||||||
|
|
||||||
服务健康检查接口。
|
|
||||||
|
|
||||||
**响应示例**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "ok",
|
|
||||||
"timestamp": "2024-01-01T12:00:00.000Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 10.3 环境变量配置
|
|
||||||
|
|
||||||
后端服务支持以下环境变量:
|
|
||||||
|
|
||||||
| 环境变量 | 默认值 | 说明 |
|
|
||||||
|---------|--------|------|
|
|
||||||
| `PORT` | 3001 | 服务监听端口 |
|
|
||||||
|
|
||||||
使用示例:
|
|
||||||
```bash
|
|
||||||
PORT=3002 pm2 start index.js --name ai-demo-server
|
|
||||||
```
|
|
||||||
|
|
||||||
### 10.4 临时文件管理
|
|
||||||
|
|
||||||
- 后端服务在 `server/temp/` 目录下存储临时文件
|
|
||||||
- 每个视频合成任务会创建独立的子目录(使用 UUID 命名)
|
|
||||||
- 任务完成后,临时文件会自动清理
|
|
||||||
- 如遇异常中断,可手动清理 `temp/` 目录
|
|
||||||
|
|
||||||
### 10.5 性能与资源要求
|
|
||||||
|
|
||||||
**建议配置**:
|
|
||||||
- **CPU**:2 核以上(视频合成需要较多 CPU 资源)
|
|
||||||
- **内存**:2GB 以上
|
|
||||||
- **磁盘**:10GB 以上可用空间(临时文件存储)
|
|
||||||
|
|
||||||
**并发处理**:
|
|
||||||
- 单个视频合成任务处理时间:约 10-60 秒(取决于片段数量和时长)
|
|
||||||
- 建议使用队列机制处理高并发请求(当前版本为同步处理)
|
|
||||||
|
|
||||||
### 10.6 日志查看
|
|
||||||
|
|
||||||
使用 PM2 查看后端服务日志:
|
|
||||||
```bash
|
|
||||||
# 查看实时日志
|
|
||||||
pm2 logs ai-demo-server
|
|
||||||
|
|
||||||
# 查看最近 100 行日志
|
|
||||||
pm2 logs ai-demo-server --lines 100
|
|
||||||
|
|
||||||
# 清空日志
|
|
||||||
pm2 flush ai-demo-server
|
|
||||||
```
|
|
||||||
|
|
||||||
日志包含:
|
|
||||||
- 服务启动信息
|
|
||||||
- FFmpeg 命令执行日志
|
|
||||||
- 视频合成进度
|
|
||||||
- 错误信息
|
|
||||||
|
|
||||||
### 10.7 监控与告警
|
|
||||||
|
|
||||||
建议监控以下指标:
|
|
||||||
- 服务进程状态(PM2 监控)
|
|
||||||
- 端口 3001 响应状态(健康检查)
|
|
||||||
- 磁盘空间(`temp/` 目录)
|
|
||||||
- CPU 和内存使用率
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 十一、Docker 部署(可选)
|
|
||||||
|
|
||||||
### 11.1 后端服务 Dockerfile
|
|
||||||
|
|
||||||
已提供 Dockerfile 位于 `Dockerfiles/backend.Dockerfile`(如需创建):
|
|
||||||
|
|
||||||
```dockerfile
|
```dockerfile
|
||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
# 安装 FFmpeg
|
|
||||||
RUN apk add --no-cache ffmpeg
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# 复制依赖文件
|
# 复制依赖文件
|
||||||
COPY server/package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci --only=production
|
RUN npm ci --only=production
|
||||||
|
|
||||||
# 复制源码
|
# 复制源码
|
||||||
COPY server/ ./
|
COPY . ./
|
||||||
|
|
||||||
# 创建临时目录
|
# 构建
|
||||||
RUN mkdir -p temp
|
RUN npm run build
|
||||||
|
|
||||||
EXPOSE 3001
|
EXPOSE 80
|
||||||
|
|
||||||
CMD ["node", "index.js"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
```
|
|
||||||
|
|
||||||
### 11.2 Docker Compose 部署
|
|
||||||
|
|
||||||
创建 `docker-compose.yml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
|
||||||
backend:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfiles/backend.Dockerfile
|
|
||||||
ports:
|
|
||||||
- "3001:3001"
|
|
||||||
volumes:
|
|
||||||
- ./server/temp:/app/temp
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
- PORT=3001
|
|
||||||
|
|
||||||
frontend:
|
|
||||||
image: nginx:alpine
|
|
||||||
ports:
|
|
||||||
- "80:80"
|
|
||||||
volumes:
|
|
||||||
- ./dist:/usr/share/nginx/html
|
|
||||||
- ./nginx.conf:/etc/nginx/conf.d/default.conf
|
|
||||||
depends_on:
|
|
||||||
- backend
|
|
||||||
restart: unless-stopped
|
|
||||||
```
|
|
||||||
|
|
||||||
启动:
|
|
||||||
```bash
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 十二、安全建议
|
## 十一、安全建议
|
||||||
|
|
||||||
1. **HTTPS 强制**:生产环境务必启用 HTTPS
|
1. **HTTPS 强制**:生产环境务必启用 HTTPS
|
||||||
2. **API 密钥保护**:不要将 API 密钥提交到代码仓库
|
2. **API 密钥保护**:不要将 API 密钥提交到代码仓库
|
||||||
3. **文件上传限制**:限制视频合成的图片和音频大小
|
3. **输入验证**:对用户输入进行验证和过滤
|
||||||
4. **频率限制**:对 `/api/video/compose` 接口添加速率限制
|
4. **日志审计**:记录关键操作和错误信息
|
||||||
5. **临时文件清理**:定期检查并清理异常终止的临时文件
|
|
||||||
6. **日志审计**:记录所有视频合成请求的来源和处理结果
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 十三、更新与维护
|
## 十二、更新与维护
|
||||||
|
|
||||||
### 更新前端
|
### 更新前端
|
||||||
|
|
||||||
|
|
@ -868,24 +484,8 @@ npm run build
|
||||||
# 重新部署 dist/ 目录
|
# 重新部署 dist/ 目录
|
||||||
```
|
```
|
||||||
|
|
||||||
### 更新后端
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd server
|
|
||||||
git pull
|
|
||||||
npm install
|
|
||||||
pm2 restart ai-demo-server
|
|
||||||
```
|
|
||||||
|
|
||||||
### 查看服务状态
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pm2 status
|
|
||||||
pm2 logs
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**文档版本**:v2.0
|
**文档版本**:v2.1
|
||||||
**更新日期**:2024-01-01
|
**更新日期**:2025-04-16
|
||||||
**更新内容**:新增后端视频合成服务部署说明
|
**更新内容**:移除视频讲解功能及相关后端服务
|
||||||
|
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
# AI Demo - 后端服务
|
|
||||||
|
|
||||||
视频合成后端服务,使用 FFmpeg 处理视频合成任务。
|
|
||||||
|
|
||||||
## 前置要求
|
|
||||||
|
|
||||||
- Node.js 18+
|
|
||||||
- **FFmpeg** - 需要安装到系统环境变量中
|
|
||||||
|
|
||||||
### 安装 FFmpeg
|
|
||||||
|
|
||||||
#### Windows
|
|
||||||
1. 下载 FFmpeg: https://www.gyan.dev/ffmpeg/builds/
|
|
||||||
2. 解压到 `C:\ffmpeg`
|
|
||||||
3. 添加 `C:\ffmpeg\bin` 到系统环境变量 PATH
|
|
||||||
|
|
||||||
#### macOS
|
|
||||||
```bash
|
|
||||||
brew install ffmpeg
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Linux
|
|
||||||
```bash
|
|
||||||
sudo apt update && sudo apt install ffmpeg
|
|
||||||
```
|
|
||||||
|
|
||||||
## 安装依赖
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd server
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
## 启动服务
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev # 开发模式(自动重启)
|
|
||||||
npm start # 生产模式
|
|
||||||
```
|
|
||||||
|
|
||||||
服务将在 http://localhost:3001 启动。
|
|
||||||
|
|
||||||
## API 接口
|
|
||||||
|
|
||||||
### POST /api/video/compose
|
|
||||||
|
|
||||||
合成视频
|
|
||||||
|
|
||||||
**请求体:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"slides": [
|
|
||||||
{
|
|
||||||
"imageUrl": "https://example.com/image1.png",
|
|
||||||
"audioUrl": "https://example.com/audio1.mp3"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"width": 1920,
|
|
||||||
"height": 1080
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应:** `video/mp4` 格式的视频文件
|
|
||||||
|
|
||||||
### GET /api/video/health
|
|
||||||
|
|
||||||
检查视频服务状态
|
|
||||||
|
|
||||||
## 前端配置
|
|
||||||
|
|
||||||
前端 Vite 开发服务器会自动代理 `/api` 请求到后端服务。
|
|
||||||
|
|
||||||
确保后端服务运行在 `http://localhost:3001`。
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
import express from 'express';
|
|
||||||
import cors from 'cors';
|
|
||||||
import videoRoutes from './routes/video.js';
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
const PORT = process.env.PORT || 3001;
|
|
||||||
|
|
||||||
// 中间件
|
|
||||||
app.use(cors());
|
|
||||||
app.use(express.json({ limit: '50mb' }));
|
|
||||||
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
|
|
||||||
|
|
||||||
// 路由
|
|
||||||
app.use('/api/video', videoRoutes);
|
|
||||||
|
|
||||||
// 健康检查
|
|
||||||
app.get('/health', (req, res) => {
|
|
||||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
|
||||||
});
|
|
||||||
|
|
||||||
// 错误处理
|
|
||||||
app.use((err, req, res, next) => {
|
|
||||||
console.error('Server error:', err);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal server error',
|
|
||||||
message: err.message
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
|
||||||
console.log(`🚀 Server running on http://localhost:${PORT}`);
|
|
||||||
console.log(`📁 Video API: http://localhost:${PORT}/api/video`);
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,16 +0,0 @@
|
||||||
{
|
|
||||||
"name": "ai-demo-server",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"start": "node index.js",
|
|
||||||
"dev": "node --watch index.js"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"express": "^4.21.0",
|
|
||||||
"cors": "^2.8.5",
|
|
||||||
"fluent-ffmpeg": "^2.1.3",
|
|
||||||
"axios": "^1.7.0",
|
|
||||||
"uuid": "^10.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,284 +0,0 @@
|
||||||
import express from 'express';
|
|
||||||
import ffmpeg from 'fluent-ffmpeg';
|
|
||||||
import axios from 'axios';
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
import { promises as fs } from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = path.dirname(__filename);
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
// 临时文件目录
|
|
||||||
const TEMP_DIR = path.join(__dirname, '../temp');
|
|
||||||
|
|
||||||
// 确保临时目录存在
|
|
||||||
async function ensureTempDir() {
|
|
||||||
try {
|
|
||||||
await fs.mkdir(TEMP_DIR, { recursive: true });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('创建临时目录失败:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ensureTempDir();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 下载资源到本地
|
|
||||||
*/
|
|
||||||
async function downloadResource(url, outputPath) {
|
|
||||||
// 处理 base64 图片 (data:image/svg+xml,...)
|
|
||||||
if (url.startsWith('data:')) {
|
|
||||||
const matches = url.match(/^data:image\/(\w+);base64,(.+)$/);
|
|
||||||
if (matches) {
|
|
||||||
const buffer = Buffer.from(matches[2], 'base64');
|
|
||||||
await fs.writeFile(outputPath, buffer);
|
|
||||||
return outputPath;
|
|
||||||
}
|
|
||||||
// 处理 URL 编码的 SVG
|
|
||||||
if (url.startsWith('data:image/svg+xml,')) {
|
|
||||||
const svgContent = decodeURIComponent(url.replace('data:image/svg+xml,', ''));
|
|
||||||
await fs.writeFile(outputPath, svgContent);
|
|
||||||
return outputPath;
|
|
||||||
}
|
|
||||||
throw new Error('不支持的 base64 格式');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理普通 HTTP URL
|
|
||||||
const response = await axios({
|
|
||||||
method: 'GET',
|
|
||||||
url: url,
|
|
||||||
responseType: 'arraybuffer',
|
|
||||||
timeout: 60000,
|
|
||||||
});
|
|
||||||
|
|
||||||
await fs.writeFile(outputPath, response.data);
|
|
||||||
return outputPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取音频时长
|
|
||||||
*/
|
|
||||||
function getAudioDuration(audioPath) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
ffmpeg.ffprobe(audioPath, (err, metadata) => {
|
|
||||||
if (err) {
|
|
||||||
reject(err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const duration = metadata.format.duration;
|
|
||||||
resolve(duration);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 合成单个视频片段
|
|
||||||
*/
|
|
||||||
async function composeSegment(imagePath, audioPath, outputPath, width, height) {
|
|
||||||
const audioDuration = await getAudioDuration(audioPath);
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
ffmpeg()
|
|
||||||
.input(imagePath)
|
|
||||||
.loop(1)
|
|
||||||
.input(audioPath)
|
|
||||||
.outputOptions([
|
|
||||||
'-tune stillimage',
|
|
||||||
'-c:a aac',
|
|
||||||
'-b:a 192k',
|
|
||||||
'-pix_fmt yuv420p',
|
|
||||||
'-shortest'
|
|
||||||
])
|
|
||||||
.videoFilter(`scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2`)
|
|
||||||
.duration(audioDuration + 0.5)
|
|
||||||
.output(outputPath)
|
|
||||||
.on('start', (commandLine) => {
|
|
||||||
console.log('FFmpeg 命令:', commandLine);
|
|
||||||
})
|
|
||||||
.on('end', () => resolve(outputPath))
|
|
||||||
.on('error', (err, stdout, stderr) => {
|
|
||||||
console.error('FFmpeg 错误输出:', stderr);
|
|
||||||
reject(err);
|
|
||||||
})
|
|
||||||
.run();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 合并多个视频片段
|
|
||||||
*/
|
|
||||||
async function mergeSegments(segmentPaths, outputPath) {
|
|
||||||
const listPath = path.join(TEMP_DIR, `concat_${uuidv4()}.txt`);
|
|
||||||
// Windows 路径需要转义反斜杠或使用正斜杠
|
|
||||||
const listContent = segmentPaths.map(p => {
|
|
||||||
// 将反斜杠替换为正斜杠,FFmpeg 更兼容
|
|
||||||
const normalizedPath = p.replace(/\\/g, '/');
|
|
||||||
return `file '${normalizedPath}'`;
|
|
||||||
}).join('\n');
|
|
||||||
|
|
||||||
await fs.writeFile(listPath, listContent);
|
|
||||||
console.log('合并列表内容:\n', listContent);
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
ffmpeg()
|
|
||||||
.input(listPath)
|
|
||||||
.inputOptions(['-f concat', '-safe 0'])
|
|
||||||
.outputOptions(['-c copy'])
|
|
||||||
.output(outputPath)
|
|
||||||
.on('start', (commandLine) => {
|
|
||||||
console.log('FFmpeg 合并命令:', commandLine);
|
|
||||||
})
|
|
||||||
.on('end', async () => {
|
|
||||||
// 清理列表文件
|
|
||||||
try {
|
|
||||||
await fs.unlink(listPath);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('清理列表文件失败:', e);
|
|
||||||
}
|
|
||||||
resolve(outputPath);
|
|
||||||
})
|
|
||||||
.on('error', (err, stdout, stderr) => {
|
|
||||||
console.error('FFmpeg 合并错误:', stderr);
|
|
||||||
reject(err);
|
|
||||||
})
|
|
||||||
.run();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/video/compose
|
|
||||||
* 合成视频
|
|
||||||
*
|
|
||||||
* Body: {
|
|
||||||
* slides: [{ imageUrl, audioUrl }],
|
|
||||||
* width: 1920,
|
|
||||||
* height: 1080
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
router.post('/compose', async (req, res) => {
|
|
||||||
const { slides, width = 1920, height = 1080 } = req.body;
|
|
||||||
|
|
||||||
if (!slides || !Array.isArray(slides) || slides.length === 0) {
|
|
||||||
return res.status(400).json({ error: '缺少 slides 参数或格式错误' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const taskId = uuidv4();
|
|
||||||
const taskDir = path.join(TEMP_DIR, taskId);
|
|
||||||
|
|
||||||
console.log(`[${taskId}] 开始处理视频合成任务,共 ${slides.length} 个片段`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 创建任务目录
|
|
||||||
await fs.mkdir(taskDir, { recursive: true });
|
|
||||||
|
|
||||||
const segmentPaths = [];
|
|
||||||
|
|
||||||
// 逐个处理片段
|
|
||||||
for (let i = 0; i < slides.length; i++) {
|
|
||||||
const slide = slides[i];
|
|
||||||
console.log(`[${taskId}] 处理第 ${i + 1}/${slides.length} 个片段`);
|
|
||||||
|
|
||||||
if (!slide.imageUrl || !slide.audioUrl) {
|
|
||||||
console.warn(`[${taskId}] 第 ${i + 1} 个片段缺少 imageUrl 或 audioUrl,跳过`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 下载图片和音频
|
|
||||||
const imagePath = path.join(taskDir, `image_${i}.png`);
|
|
||||||
const audioPath = path.join(taskDir, `audio_${i}.mp3`);
|
|
||||||
const segmentPath = path.join(taskDir, `segment_${i}.mp4`);
|
|
||||||
|
|
||||||
console.log(`[${taskId}] 下载图片: ${slide.imageUrl.substring(0, 50)}...`);
|
|
||||||
console.log(`[${taskId}] 下载音频: ${slide.audioUrl.substring(0, 50)}...`);
|
|
||||||
|
|
||||||
await downloadResource(slide.imageUrl, imagePath);
|
|
||||||
await downloadResource(slide.audioUrl, audioPath);
|
|
||||||
|
|
||||||
// 检查文件是否成功创建
|
|
||||||
try {
|
|
||||||
const imageStats = await fs.stat(imagePath);
|
|
||||||
const audioStats = await fs.stat(audioPath);
|
|
||||||
console.log(`[${taskId}] 图片大小: ${imageStats.size} bytes, 音频大小: ${audioStats.size} bytes`);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`[${taskId}] 文件检查失败:`, e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 合成片段
|
|
||||||
await composeSegment(imagePath, audioPath, segmentPath, width, height);
|
|
||||||
segmentPaths.push(segmentPath);
|
|
||||||
|
|
||||||
console.log(`[${taskId}] 第 ${i + 1} 个片段合成完成`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (segmentPaths.length === 0) {
|
|
||||||
throw new Error('没有有效的片段可以合并');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 合并所有片段
|
|
||||||
console.log(`[${taskId}] 开始合并 ${segmentPaths.length} 个片段`);
|
|
||||||
const finalPath = path.join(taskDir, 'output.mp4');
|
|
||||||
await mergeSegments(segmentPaths, finalPath);
|
|
||||||
|
|
||||||
console.log(`[${taskId}] 视频合成完成: ${finalPath}`);
|
|
||||||
|
|
||||||
// 返回视频文件
|
|
||||||
res.setHeader('Content-Type', 'video/mp4');
|
|
||||||
res.setHeader('Content-Disposition', 'attachment; filename="question-explanation.mp4"');
|
|
||||||
|
|
||||||
const videoBuffer = await fs.readFile(finalPath);
|
|
||||||
res.send(videoBuffer);
|
|
||||||
|
|
||||||
// 清理临时文件(延迟删除,确保响应已发送)
|
|
||||||
setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
await fs.rm(taskDir, { recursive: true, force: true });
|
|
||||||
console.log(`[${taskId}] 临时文件已清理`);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`[${taskId}] 清理临时文件失败:`, e);
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[${taskId}] 视频合成失败:`, error);
|
|
||||||
|
|
||||||
// 清理临时文件
|
|
||||||
try {
|
|
||||||
await fs.rm(taskDir, { recursive: true, force: true });
|
|
||||||
} catch (e) {
|
|
||||||
// 忽略清理错误
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(500).json({
|
|
||||||
error: '视频合成失败',
|
|
||||||
message: error.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/video/health
|
|
||||||
* 检查视频服务状态
|
|
||||||
*/
|
|
||||||
router.get('/health', (req, res) => {
|
|
||||||
// 检查 ffmpeg 是否可用
|
|
||||||
ffmpeg.getAvailableFormats((err, formats) => {
|
|
||||||
if (err) {
|
|
||||||
return res.status(500).json({
|
|
||||||
status: 'error',
|
|
||||||
message: 'FFmpeg 不可用',
|
|
||||||
error: err.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
status: 'ok',
|
|
||||||
message: '视频合成服务正常运行',
|
|
||||||
formats: formats ? Object.keys(formats).length : 'unknown'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
|
|
@ -227,75 +227,3 @@ export const AUDIO_TEXT_OPTIMIZE_PROMPT = `你是一位专业的文字编辑和
|
||||||
- 保持原文语言(中文/英文/日语)
|
- 保持原文语言(中文/英文/日语)
|
||||||
- 如果原文已经很好,只需做必要的格式调整`;
|
- 如果原文已经很好,只需做必要的格式调整`;
|
||||||
|
|
||||||
// ── 试题讲解生成配置 ──
|
|
||||||
// 文本分析模型(用于分析试题复杂度、拆分讲解点、生成讲解文本和图片提示词)
|
|
||||||
export const QUESTION_EXPLANATION_ANALYSIS_URL = "https://ark.cn-beijing.volces.com/api/v3/chat/completions";
|
|
||||||
export const QUESTION_EXPLANATION_ANALYSIS_MODEL = "doubao-seed-2-0-lite-260215";
|
|
||||||
export const QUESTION_EXPLANATION_ANALYSIS_TEMPERATURE = 0.7;
|
|
||||||
export const QUESTION_EXPLANATION_ANALYSIS_MAX_TOKENS = 4096;
|
|
||||||
|
|
||||||
// 试题分析 System Prompt
|
|
||||||
export const QUESTION_EXPLANATION_ANALYSIS_PROMPT = `你是一位经验丰富的教学专家,擅长将复杂的试题拆解为清晰的讲解步骤。
|
|
||||||
|
|
||||||
【任务目标】
|
|
||||||
分析试题复杂度,将其拆分为2-8个讲解点,并为每个讲解点生成详细的讲解文本和对应的图片生成提示词。
|
|
||||||
|
|
||||||
【分析流程】
|
|
||||||
1. 评估试题复杂度:
|
|
||||||
- 简单题(单一知识点):生成2-3个讲解点
|
|
||||||
- 中等题(多个知识点):生成4-5个讲解点
|
|
||||||
- 复杂题(综合应用):生成6-8个讲解点
|
|
||||||
|
|
||||||
2. 拆分讲解点:按照解题逻辑顺序拆分,确保每个讲解点聚焦一个核心概念或步骤
|
|
||||||
|
|
||||||
3. 为每个讲解点生成:
|
|
||||||
- 标题:简洁明了,概括该步骤的核心内容
|
|
||||||
- 讲解文本:详细的解释说明,语言通俗易懂,适合语音朗读(控制在80-150字之间)
|
|
||||||
- 图片提示词:用于生成配套讲解图片,要求视觉化呈现该步骤的核心内容
|
|
||||||
|
|
||||||
【输出格式 - 严格按JSON格式输出】
|
|
||||||
必须只输出纯JSON,不要包含任何其他文字、说明或markdown标记。格式如下:
|
|
||||||
{"complexity":"简单|中等|复杂","totalPoints":数字,"explanationPoints":[{"order":1,"title":"讲解点标题","explanationText":"详细的讲解文本内容...","imagePrompt":"清晰、具体的图片生成提示词,包含视觉元素描述..."}]}
|
|
||||||
|
|
||||||
【重要规则】
|
|
||||||
1. 【最高优先级】只输出JSON,不要有任何其他文字
|
|
||||||
2. 必须严格按JSON格式输出,所有字符串值必须用双引号包裹
|
|
||||||
3. 字符串内的双引号必须转义为 \\"
|
|
||||||
4. 字符串内的换行符必须转义为 \\n
|
|
||||||
5. 讲解文本要口语化,适合语音朗读,避免过于书面化
|
|
||||||
6. 图片提示词要具体、形象,便于AI生成直观的教学图示
|
|
||||||
7. 讲解点之间要有逻辑连贯性,形成完整的解题思路
|
|
||||||
8. 确保JSON格式正确,所有引号、逗号、括号匹配
|
|
||||||
|
|
||||||
【示例输出】
|
|
||||||
{"complexity":"简单","totalPoints":2,"explanationPoints":[{"order":1,"title":"理解题目","explanationText":"首先我们来看这道题目,题目要求我们...","imagePrompt":"一个清晰的题目展示卡片..."},{"order":2,"title":"解题思路","explanationText":"接下来我们分析解题思路...","imagePrompt":"解题步骤流程图..."}]}`;
|
|
||||||
|
|
||||||
// 图片生成模型配置(阿里云百炼)
|
|
||||||
export const QUESTION_EXPLANATION_IMAGE_URL = "/dashscope-api/api/v1/services/aigc/multimodal-generation/generation";
|
|
||||||
export const QUESTION_EXPLANATION_IMAGE_MODEL = "qwen-image-2.0-pro"; // 千问文生图模型,擅长文字渲染
|
|
||||||
export const QUESTION_EXPLANATION_IMAGE_SIZE = "1920*1080"; // 16:9 比例
|
|
||||||
|
|
||||||
// 图片统一风格描述(确保所有生成的图片风格一致)
|
|
||||||
export const QUESTION_EXPLANATION_IMAGE_STYLE = `【统一视觉风格要求】
|
|
||||||
1. 背景:使用深蓝色渐变背景(从深蓝色#1e293b到更深的蓝黑色#0f172a),营造专业学术氛围
|
|
||||||
2. 配色方案:主色调为深蓝色系,辅助色为紫色和蓝色(色值范围:紫色#8b5cf6到蓝色#3b82f6),强调色使用白色和淡蓝色
|
|
||||||
3. 字体样式:所有文字使用清晰易读的无衬线字体,标题文字使用白色或淡蓝色,重点内容可用亮色标注
|
|
||||||
4. 图形元素:使用简洁的几何图形、流程图、示意图等,线条清晰,颜色协调
|
|
||||||
5. 整体风格:现代简约、专业严谨的教学图示风格,避免过于花哨或复杂的装饰
|
|
||||||
6. 布局:采用居中对称或左右分栏布局,保持视觉平衡
|
|
||||||
7. 光影效果:适当使用柔和的光晕效果或渐变叠加,增强层次感
|
|
||||||
8. 所有图片必须严格遵循以上风格要求,确保整个讲解系列的视觉连贯性和专业性`;
|
|
||||||
|
|
||||||
// 语音合成模型配置(阿里云百炼)
|
|
||||||
export const QUESTION_EXPLANATION_TTS_URL = "/dashscope-api/api/v1/services/aigc/multimodal-generation/generation";
|
|
||||||
export const QUESTION_EXPLANATION_TTS_MODEL = "qwen3-tts-flash";
|
|
||||||
export const QUESTION_EXPLANATION_TTS_VOICE = "Cherry"; // 芊悦:阳光积极的小姐姐音色
|
|
||||||
export const QUESTION_EXPLANATION_TTS_LANGUAGE = "zh-CN";
|
|
||||||
export const QUESTION_EXPLANATION_TTS_FORMAT = "mp3";
|
|
||||||
export const QUESTION_EXPLANATION_TTS_SAMPLE_RATE = 24000;
|
|
||||||
|
|
||||||
// ── 视频合成配置 ──
|
|
||||||
export const VIDEO_WIDTH = 1920;
|
|
||||||
export const VIDEO_HEIGHT = 1080;
|
|
||||||
export const VIDEO_OUTPUT_FORMAT = "mp4";
|
|
||||||
export const VIDEO_FILENAME_PREFIX = "试题讲解_";
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import ProblemSolving from '../views/ProblemSolving.vue'
|
||||||
import QuestionGenerator from '../views/QuestionGenerator.vue'
|
import QuestionGenerator from '../views/QuestionGenerator.vue'
|
||||||
import QuestionVariant from '../views/QuestionVariant.vue'
|
import QuestionVariant from '../views/QuestionVariant.vue'
|
||||||
import AudioToText from '../views/AudioToText.vue'
|
import AudioToText from '../views/AudioToText.vue'
|
||||||
import QuestionExplanation from '../views/QuestionExplanation.vue'
|
|
||||||
import SpeakingEvaluation from '../views/SpeakingEvaluation.vue'
|
import SpeakingEvaluation from '../views/SpeakingEvaluation.vue'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
|
|
@ -65,11 +64,6 @@ const router = createRouter({
|
||||||
name: 'audio-to-text',
|
name: 'audio-to-text',
|
||||||
component: AudioToText
|
component: AudioToText
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/question-explanation',
|
|
||||||
name: 'question-explanation',
|
|
||||||
component: QuestionExplanation
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/speaking-evaluation',
|
path: '/speaking-evaluation',
|
||||||
name: 'speaking-evaluation',
|
name: 'speaking-evaluation',
|
||||||
|
|
|
||||||
|
|
@ -1,139 +0,0 @@
|
||||||
/**
|
|
||||||
* 视频合成模块 - 后端 FFmpeg 方案
|
|
||||||
*/
|
|
||||||
|
|
||||||
// 后端 API 地址
|
|
||||||
const API_BASE = '/api/video';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 合成视频 - 调用后端 API
|
|
||||||
* @param {Array} slides 讲解点数组,每个包含 imageUrl 和 audioUrl
|
|
||||||
* @param {Object} options 配置选项
|
|
||||||
* @param {Function} onProgress 进度回调 { stage, current, total, message }
|
|
||||||
* @returns {Promise<Blob>} 视频Blob
|
|
||||||
*/
|
|
||||||
export async function composeVideo(slides, options = {}, onProgress = null) {
|
|
||||||
const {
|
|
||||||
width = 1920,
|
|
||||||
height = 1080,
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
// 过滤有效的 slide,并使用原始完整 URL
|
|
||||||
const validSlides = slides
|
|
||||||
.filter(s => (s.originalImageUrl || s.imageUrl) && (s.originalAudioUrl || s.audioUrl))
|
|
||||||
.map(s => ({
|
|
||||||
// 优先使用原始完整 URL,否则使用当前 URL
|
|
||||||
imageUrl: s.originalImageUrl || s.imageUrl,
|
|
||||||
audioUrl: s.originalAudioUrl || s.audioUrl,
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (validSlides.length === 0) {
|
|
||||||
throw new Error('没有有效的讲解内容');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查 URL 格式
|
|
||||||
const invalidUrls = validSlides.filter(s =>
|
|
||||||
!s.imageUrl.startsWith('http') && !s.imageUrl.startsWith('data:')
|
|
||||||
);
|
|
||||||
if (invalidUrls.length > 0) {
|
|
||||||
console.warn('部分 URL 格式不正确:', invalidUrls);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 阶段1: 准备请求
|
|
||||||
if (onProgress) {
|
|
||||||
onProgress({
|
|
||||||
stage: 'preparing',
|
|
||||||
current: 0,
|
|
||||||
total: validSlides.length,
|
|
||||||
message: '正在准备视频合成请求...',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 阶段2: 发送请求到后端
|
|
||||||
if (onProgress) {
|
|
||||||
onProgress({
|
|
||||||
stage: 'composing',
|
|
||||||
current: 1,
|
|
||||||
total: validSlides.length,
|
|
||||||
message: `服务器正在合成视频 (${validSlides.length} 个片段)...`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE}/compose`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
slides: validSlides,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json().catch(() => ({ message: '请求失败' }));
|
|
||||||
throw new Error(error.message || `服务器错误: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 阶段3: 接收视频
|
|
||||||
if (onProgress) {
|
|
||||||
onProgress({
|
|
||||||
stage: 'merging',
|
|
||||||
current: validSlides.length,
|
|
||||||
total: validSlides.length,
|
|
||||||
message: '正在接收合成后的视频...',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const videoBlob = await response.blob();
|
|
||||||
|
|
||||||
if (onProgress) {
|
|
||||||
onProgress({
|
|
||||||
stage: 'completed',
|
|
||||||
current: 1,
|
|
||||||
total: 1,
|
|
||||||
message: '视频合成完成!',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return videoBlob;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('视频合成失败:', error);
|
|
||||||
throw new Error(`视频合成失败: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 下载视频
|
|
||||||
* @param {Blob} blob 视频Blob
|
|
||||||
* @param {string} filename 文件名
|
|
||||||
*/
|
|
||||||
export function downloadVideo(blob, filename = 'question-explanation.mp4') {
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = filename;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建预览URL
|
|
||||||
* @param {Blob} blob 视频Blob
|
|
||||||
* @returns {string} Blob URL
|
|
||||||
*/
|
|
||||||
export function createPreviewUrl(blob) {
|
|
||||||
return URL.createObjectURL(blob);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 释放预览URL
|
|
||||||
* @param {string} url Blob URL
|
|
||||||
*/
|
|
||||||
export function revokePreviewUrl(url) {
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
|
|
@ -85,14 +85,6 @@ const features = ref([
|
||||||
icon: "file-audio",
|
icon: "file-audio",
|
||||||
route: "/audio-to-text",
|
route: "/audio-to-text",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 11,
|
|
||||||
title: "AI试题讲解生成",
|
|
||||||
desc: "智能分析试题复杂度,自动生成图文并茂的幻灯片式讲解,每张图片配语音讲解,让解题过程清晰易懂。",
|
|
||||||
class: "card-11",
|
|
||||||
icon: "presentation",
|
|
||||||
route: "/question-explanation",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 12,
|
id: 12,
|
||||||
title: "听读评测",
|
title: "听读评测",
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -18,11 +18,6 @@ export default defineConfig({
|
||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
proxy: {
|
proxy: {
|
||||||
// 后端 API 代理
|
|
||||||
'/api': {
|
|
||||||
target: 'http://localhost:3001',
|
|
||||||
changeOrigin: true,
|
|
||||||
},
|
|
||||||
'/tts-api': {
|
'/tts-api': {
|
||||||
target: 'https://openspeech.bytedance.com',
|
target: 'https://openspeech.bytedance.com',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue