docs(deploy): 简化和优化部署文档结构与内容

- 精简环境准备部分,去除后端和 FFmpeg 安装说明,聚焦前端依赖安装
- 调整关键配置说明,删除后端相关代理和跨域配置,保留前端代理重点
- 优化部署方案,移除复杂的后端服务部署和代理设置,仅保留纯前端部署示例
- 更新部署验证清单,聚焦前端功能和路由验证
- 精简常见问题,去除后端服务相关故障信息
- 删除后端服务、Docker、性能监控等章节,文档更聚焦轻量部署
- 更新页面路由清单,保持核心页面及功能介绍
This commit is contained in:
cc 2026-04-16 14:45:38 +08:00
parent 54c0bfef68
commit 63056170cc
12 changed files with 31 additions and 3934 deletions

462
DEPLOY.md
View File

@ -3,12 +3,8 @@
## 项目概述
- **项目名称**AI 英语学习辅助平台
- **技术栈**
- 前端Vue 3 + Vite + Vue RouterHistory 模式)
- 后端Express.js + FFmpeg视频合成服务
- **项目结构**
- 前端纯静态文件HTML / CSS / JS可部署到任意静态托管服务
- 后端Node.js 服务,需要 FFmpeg 环境,提供视频合成 API
- **技术栈**Vue 3 + Vite + Vue RouterHistory 模式)
- **项目结构**纯前端静态文件HTML / CSS / JS可部署到任意静态托管服务
---
@ -18,61 +14,21 @@
| 工具 | 版本要求 | 用途 |
|------|----------|------|
| Node.js | >= 18.x推荐 20.x LTS | 前端构建 & 后端服务运行 |
| Node.js | >= 18.x推荐 20.x LTS | 前端构建 |
| npm | >= 9.x | 包管理器 |
| FFmpeg | 最新稳定版 | 后端视频合成(仅后端服务需要) |
```bash
# 检查 Node.js 和 npm 版本
node -v
npm -v
# 检查 FFmpeg 是否已安装(后端服务需要)
ffmpeg -version
```
### 1.2 安装 FFmpeg后端服务必需
#### 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 安装依赖
### 1.2 安装依赖
```bash
# 安装前端依赖
cd AI_Demo
npm install
# 安装后端依赖
cd server
npm install
```
---
@ -96,7 +52,7 @@ npm install
### 2.2 跨域响应头要求(重要)
项目使用了 `@ffmpeg/ffmpeg`WebAssembly需要服务器返回以下响应头,否则视频讲解功能将无法运行
项目使用了 `@ffmpeg/ffmpeg`WebAssembly需要服务器返回以下响应头
```
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 语音合成 | 需要反向代理 |
| `/ark-api` | `https://ark.cn-beijing.volces.com` | 火山引擎 Ark 大模型 | 需要反向代理 |
| `/dashscope-api` | `https://dashscope.aliyuncs.com` | 阿里云百炼 | 需要反向代理 |
| `/asr-ws` | `wss://openspeech.bytedance.com` | 豆包 ASR WebSocket | 需要反向代理+注入鉴权 Header |
> **后端服务说明**`/api/video` 接口由本项目的后端服务(`server/`)提供,不需要跨域代理,但需要确保后端服务正常运行。
> **ASR WebSocket 特殊说明**`/asr-ws` 代理需要在代理层注入以下鉴权 Header浏览器原生 WebSocket 不支持自定义 Header
> - `X-Api-App-Key`
> - `X-Api-Access-Key`
@ -152,22 +105,6 @@ dist/
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;
index index.html;
# 跨域响应头FFmpeg WASM 必需)
# 跨域响应头
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
@ -203,26 +140,6 @@ server {
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
location /tts-api/ {
proxy_pass https://openspeech.bytedance.com/;
@ -282,34 +199,7 @@ nginx -t
systemctl reload nginx
```
#### 4.A.4 启动后端服务
后端服务需要使用进程管理器(如 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推荐
#### 4.A.4 配置 HTTPS推荐
```bash
# 使用 Certbot 申请免费 SSL 证书
@ -319,32 +209,9 @@ certbot --nginx -d your-domain.com
---
### 方案 BNode.js + Express 部署(前后端分离)
### 方案 BNode.js + Express 部署
适用于需要在 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 前端静态服务器
适用于需要在 Node.js 环境中自定义响应头的场景。
创建前端静态服务器文件 `frontend-server.js`(项目根目录):
@ -359,20 +226,6 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url))
const app = express()
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 代理
app.use('/tts-api', createProxyMiddleware({
target: 'https://openspeech.bytedance.com',
@ -417,7 +270,6 @@ app.get('*', (req, res) => {
app.listen(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
```
现在有两个服务运行:
- **前端服务**`http://localhost:3000`(提供静态文件和代理)
- **后端服务**`http://localhost:3001`(视频合成 API
前端服务运行在 `http://localhost:3000`(提供静态文件和代理)
---
### 方案 CEdgeOne Pages / Vercel / Netlify纯静态托管
> **注意**:此类平台**不支持服务端代理**`/tts-api`、`/ark-api`、`/dashscope-api`、`/asr-ws` 等接口将因跨域或缺少鉴权 Header 而失败。
> 建议仅用于演示或配合独立后端服务使用
> 建议仅用于演示。
#### 部署步骤(以 Vercel 为例)
@ -478,16 +328,7 @@ pm2 save
## 五、部署后验证清单
### 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 前端服务验证
### 5.1 部署验证
| 验证项 | 检查方法 |
|--------|----------|
@ -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` 响应头。
**解决**:参考第二节 2.2,在 Nginx 或 Node.js 服务器中添加对应响应头。
@ -531,31 +365,9 @@ pm2 save
**原因**:代理未注入豆包 ASR 鉴权 Header或 WebSocket 升级配置缺失。
**解决**:确认 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
@ -579,7 +391,6 @@ NODE_OPTIONS=--max-old-space-size=4096 npm run build
| `/question-generator` | 题目生成 | 自动生成练习题 |
| `/question-variant` | 题目变体 | 生成相似题目变体 |
| `/audio-to-text` | 语音转文字 | 音频转文本功能 |
| `/question-explanation` | 视频讲解 | 视频合成讲解(使用后端服务)|
---
@ -598,30 +409,11 @@ NODE_OPTIONS=--max-old-space-size=4096 npm run build
| marked | ^17.0.5 |
| 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/
├── dist/ # 前端构建产物(部署此目录)
├── server/ # 后端服务
│ ├── routes/ # 路由模块
│ │ └── video.js # 视频合成 API
│ ├── temp/ # 临时文件目录(自动创建)
│ ├── index.js # 后端服务入口
│ ├── package.json # 后端依赖配置
│ └── README.md # 后端服务文档
├── src/ # 前端源码
│ ├── config/index.js # API 密钥配置(部署前确认)
│ ├── router/index.js # 路由定义
@ -635,8 +427,7 @@ AI_Demo/
│ │ ├── ProblemSolving.vue # 解题指导
│ │ ├── QuestionGenerator.vue # 题目生成
│ │ ├── QuestionVariant.vue # 题目变体
│ │ ├── AudioToText.vue # 语音转文字
│ │ └── QuestionExplanation.vue # 视频讲解
│ │ └── AudioToText.vue # 语音转文字
│ ├── components/ # 公共组件
│ ├── assets/ # 静态资源
│ └── MainLayout.vue # 布局组件
@ -647,217 +438,42 @@ AI_Demo/
---
## 十、后端服务详细说明
## 十、Docker 部署(可选)
### 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`:音频 URLMP3 格式)
- `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`(如需创建):
### 10.1 构建镜像
```dockerfile
FROM node:20-alpine
# 安装 FFmpeg
RUN apk add --no-cache ffmpeg
WORKDIR /app
# 复制依赖文件
COPY server/package*.json ./
COPY package*.json ./
RUN npm ci --only=production
# 复制源码
COPY server/ ./
COPY . ./
# 创建临时目录
RUN mkdir -p temp
# 构建
RUN npm run build
EXPOSE 3001
EXPOSE 80
CMD ["node", "index.js"]
```
### 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
CMD ["nginx", "-g", "daemon off;"]
```
---
## 十、安全建议
## 十一、安全建议
1. **HTTPS 强制**:生产环境务必启用 HTTPS
2. **API 密钥保护**:不要将 API 密钥提交到代码仓库
3. **文件上传限制**:限制视频合成的图片和音频大小
4. **频率限制**:对 `/api/video/compose` 接口添加速率限制
5. **临时文件清理**:定期检查并清理异常终止的临时文件
6. **日志审计**:记录所有视频合成请求的来源和处理结果
3. **输入验证**:对用户输入进行验证和过滤
4. **日志审计**:记录关键操作和错误信息
---
## 十、更新与维护
## 十二、更新与维护
### 更新前端
@ -868,24 +484,8 @@ npm run build
# 重新部署 dist/ 目录
```
### 更新后端
```bash
cd server
git pull
npm install
pm2 restart ai-demo-server
```
### 查看服务状态
```bash
pm2 status
pm2 logs
```
---
**文档版本**v2.0
**更新日期**2024-01-01
**更新内容**新增后端视频合成服务部署说明
**文档版本**v2.1
**更新日期**2025-04-16
**更新内容**:移除视频讲解功能及相关后端服务

View File

@ -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`

View File

@ -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`);
});

1020
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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"
}
}

View File

@ -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;

View File

@ -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 = "试题讲解_";

View File

@ -9,7 +9,6 @@ import ProblemSolving from '../views/ProblemSolving.vue'
import QuestionGenerator from '../views/QuestionGenerator.vue'
import QuestionVariant from '../views/QuestionVariant.vue'
import AudioToText from '../views/AudioToText.vue'
import QuestionExplanation from '../views/QuestionExplanation.vue'
import SpeakingEvaluation from '../views/SpeakingEvaluation.vue'
const router = createRouter({
@ -65,11 +64,6 @@ const router = createRouter({
name: 'audio-to-text',
component: AudioToText
},
{
path: '/question-explanation',
name: 'question-explanation',
component: QuestionExplanation
},
{
path: '/speaking-evaluation',
name: 'speaking-evaluation',

View File

@ -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);
}

View File

@ -85,14 +85,6 @@ const features = ref([
icon: "file-audio",
route: "/audio-to-text",
},
{
id: 11,
title: "AI试题讲解生成",
desc: "智能分析试题复杂度,自动生成图文并茂的幻灯片式讲解,每张图片配语音讲解,让解题过程清晰易懂。",
class: "card-11",
icon: "presentation",
route: "/question-explanation",
},
{
id: 12,
title: "听读评测",

File diff suppressed because it is too large Load Diff

View File

@ -18,11 +18,6 @@ export default defineConfig({
server: {
host: '0.0.0.0',
proxy: {
// 后端 API 代理
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
'/tts-api': {
target: 'https://openspeech.bytedance.com',
changeOrigin: true,