Compare commits

...

1 Commits

Author SHA1 Message Date
Kevin Wong
1e52346eb4 更新 2026-02-07 14:29:57 +08:00
29 changed files with 955 additions and 590 deletions

View File

@@ -117,6 +117,9 @@ backend/
- `WEIXIN_USER_AGENT` / `WEIXIN_LOCALE` / `WEIXIN_TIMEZONE_ID`
- `WEIXIN_FORCE_SWIFTSHADER`
- `WEIXIN_TRANSCODE_MODE` (reencode/faststart/off)
- `CORS_ORIGINS` (CORS 白名单,默认 *)
- `SUPABASE_STORAGE_LOCAL_PATH` (本地存储路径)
- `DOUYIN_COOKIE` (抖音视频下载 Cookie)
---

View File

@@ -25,38 +25,38 @@ python3 --version
# 检查 Node.js 版本 (需要 18+)
node --version
# 检查 FFmpeg
ffmpeg -version
# 检查 Chrome (视频号发布)
google-chrome --version
# 检查 Xvfb
xvfb-run --help
# 检查 FFmpeg
ffmpeg -version
# 检查 pm2 (用于服务管理)
pm2 --version
# 检查 Redis (任务状态存储,推荐)
redis-server --version
# 检查 Chrome (视频号发布)
google-chrome --version
# 检查 Xvfb
xvfb-run --help
# 检查 pm2 (用于服务管理)
pm2 --version
# 检查 Redis (任务状态存储,推荐)
redis-server --version
```
如果缺少依赖:
```bash
sudo apt update
sudo apt install ffmpeg
# 安装 Xvfb (视频号发布)
sudo apt install xvfb
sudo apt update
sudo apt install ffmpeg
# 安装 pm2
npm install -g pm2
# 安装 Chrome (视频号发布)
wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | sudo gpg --dearmor -o /usr/share/keyrings/google-linux-signing-keyring.gpg
printf "deb [arch=amd64 signed-by=/usr/share/keyrings/google-linux-signing-keyring.gpg] http://dl.google.com/linux/chrome/deb/ stable main\n" | sudo tee /etc/apt/sources.list.d/google-chrome.list > /dev/null
sudo apt update
sudo apt install -y google-chrome-stable
# 安装 Xvfb (视频号发布)
sudo apt install xvfb
# 安装 pm2
npm install -g pm2
# 安装 Chrome (视频号发布)
wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | sudo gpg --dearmor -o /usr/share/keyrings/google-linux-signing-keyring.gpg
printf "deb [arch=amd64 signed-by=/usr/share/keyrings/google-linux-signing-keyring.gpg] http://dl.google.com/linux/chrome/deb/ stable main\n" | sudo tee /etc/apt/sources.list.d/google-chrome.list > /dev/null
sudo apt update
sudo apt install -y google-chrome-stable
```
---
@@ -110,11 +110,11 @@ pip install torch torchvision torchaudio --index-url https://download.pytorch.or
# 安装 Python 依赖
pip install -r requirements.txt
# 安装 Playwright 浏览器(社交发布需要)
playwright install chromium
```
> 提示:视频号发布建议使用系统 Chrome + xvfb-run避免 headless 解码失败)。
# 安装 Playwright 浏览器(社交发布需要)
playwright install chromium
```
> 提示:视频号发布建议使用系统 Chrome + xvfb-run避免 headless 解码失败)。
---
@@ -178,17 +178,20 @@ cp .env.example .env
| `LATENTSYNC_GPU_ID` | 1 | GPU 选择 (0 或 1) |
| `LATENTSYNC_USE_SERVER` | false | 设为 true 以启用常驻服务加速 |
| `LATENTSYNC_INFERENCE_STEPS` | 20 | 推理步数 (20-50) |
| `LATENTSYNC_GUIDANCE_SCALE` | 1.5 | 引导系数 (1.0-3.0) |
| `DEBUG` | true | 生产环境改为 false |
| `REDIS_URL` | `redis://localhost:6379/0` | 任务状态存储(不可用时回退内存) |
| `WEIXIN_HEADLESS_MODE` | headless-new | 视频号 Playwright 模式 (headful/headless-new) |
| `WEIXIN_CHROME_PATH` | `/usr/bin/google-chrome` | 系统 Chrome 路径 |
| `WEIXIN_BROWSER_CHANNEL` | | Chromium 通道 (可选) |
| `WEIXIN_USER_AGENT` | Chrome 120 UA | 视频号浏览器指纹 UA |
| `WEIXIN_LOCALE` | zh-CN | 视频号语言环境 |
| `WEIXIN_TIMEZONE_ID` | Asia/Shanghai | 视频号时区 |
| `WEIXIN_FORCE_SWIFTSHADER` | true | 强制软件 WebGL避免 context lost |
| `WEIXIN_TRANSCODE_MODE` | reencode | 上传前转码 (reencode/faststart/off) |
| `LATENTSYNC_GUIDANCE_SCALE` | 1.5 | 引导系数 (1.0-3.0) |
| `DEBUG` | true | 生产环境改为 false |
| `REDIS_URL` | `redis://localhost:6379/0` | 任务状态存储(不可用时回退内存) |
| `WEIXIN_HEADLESS_MODE` | headless-new | 视频号 Playwright 模式 (headful/headless-new) |
| `WEIXIN_CHROME_PATH` | `/usr/bin/google-chrome` | 系统 Chrome 路径 |
| `WEIXIN_BROWSER_CHANNEL` | | Chromium 通道 (可选) |
| `WEIXIN_USER_AGENT` | Chrome 120 UA | 视频号浏览器指纹 UA |
| `WEIXIN_LOCALE` | zh-CN | 视频号语言环境 |
| `WEIXIN_TIMEZONE_ID` | Asia/Shanghai | 视频号时区 |
| `WEIXIN_FORCE_SWIFTSHADER` | true | 强制软件 WebGL避免 context lost |
| `WEIXIN_TRANSCODE_MODE` | reencode | 上传前转码 (reencode/faststart/off) |
| `CORS_ORIGINS` | `*` | CORS 允许源 (生产环境建议白名单) |
| `SUPABASE_STORAGE_LOCAL_PATH` | 默认路径 | Supabase 本地存储路径 |
| `DOUYIN_COOKIE` | 空 | 抖音视频下载 Cookie (文案提取功能) |
---
@@ -210,19 +213,19 @@ npm run build
> 💡 先手动启动测试,确认一切正常后再配置 pm2 常驻服务。
### 启动后端 (终端 1)
### 启动后端 (终端 1)
```bash
cd /home/rongye/ProgramFiles/ViGent2/backend
source venv/bin/activate
uvicorn app.main:app --host 0.0.0.0 --port 8006
```
推荐使用项目脚本启动后端(已内置 xvfb + headful 发布环境):
```bash
cd /home/rongye/ProgramFiles/ViGent2
./run_backend.sh # 默认 8006可用 PORT 覆盖
```
uvicorn app.main:app --host 0.0.0.0 --port 8006
```
推荐使用项目脚本启动后端(已内置 xvfb + headful 发布环境):
```bash
cd /home/rongye/ProgramFiles/ViGent2
./run_backend.sh # 默认 8006可用 PORT 覆盖
```
### 启动前端 (终端 2)
@@ -255,29 +258,29 @@ python -m scripts.server
建议使用 Shell 脚本启动以避免环境问题。
1. 创建启动脚本 `run_backend.sh`:
```bash
cat > run_backend.sh << 'EOF'
#!/usr/bin/env bash
set -e
BASE_DIR="$(cd "$(dirname "$0")" && pwd)"
export WEIXIN_HEADLESS_MODE=headful
export WEIXIN_DEBUG_ARTIFACTS=false
export WEIXIN_RECORD_VIDEO=false
export DOUYIN_DEBUG_ARTIFACTS=false
export DOUYIN_RECORD_VIDEO=false
PORT=${PORT:-8006}
cd "$BASE_DIR/backend"
exec xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" \
./venv/bin/uvicorn app.main:app --host 0.0.0.0 --port "$PORT"
EOF
chmod +x run_backend.sh
```
1. 创建启动脚本 `run_backend.sh`:
```bash
cat > run_backend.sh << 'EOF'
#!/usr/bin/env bash
set -e
BASE_DIR="$(cd "$(dirname "$0")" && pwd)"
export WEIXIN_HEADLESS_MODE=headful
export WEIXIN_DEBUG_ARTIFACTS=false
export WEIXIN_RECORD_VIDEO=false
export DOUYIN_DEBUG_ARTIFACTS=false
export DOUYIN_RECORD_VIDEO=false
PORT=${PORT:-8006}
cd "$BASE_DIR/backend"
exec xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" \
./venv/bin/uvicorn app.main:app --host 0.0.0.0 --port "$PORT"
EOF
chmod +x run_backend.sh
```
2. 使用 pm2 启动:
```bash
pm2 start ./run_backend.sh --name vigent2-backend
```
2. 使用 pm2 启动:
```bash
pm2 start ./run_backend.sh --name vigent2-backend
```
### 2. 启动前端服务 (Next.js)

66
Docs/DevLogs/Day20.md Normal file
View File

@@ -0,0 +1,66 @@
## 🔧 代码质量与安全优化 (13:30)
### 概述
本日进行项目全面代码审查与优化,共处理 27 项优化点,完成 18 项核心修复。
### 已完成优化
#### 功能性修复
- [x] **P0-1**: LatentSync 回退逻辑空实现 → 改为 `raise RuntimeError`
- [x] **P1-1**: 任务状态接口无用户归属校验 → 添加用户认证依赖
- [x] **P1-2**: 前端 User 类型定义重复 → 统一到 `shared/types/user.ts`
#### 性能优化
- [x] **P1-3**: 参考音频列表 N+1 查询 → 使用 `asyncio.gather` 并发
- [x] **P1-4**: 视频上传整读内存 → 新增 `upload_file_from_path` 流式处理
- [x] **P1-5**: async 路由内同步阻塞 → `httpx.AsyncClient` 替换 `requests`
- [x] **P2-2**: GLM 服务同步调用 → `asyncio.to_thread` 包装
- [x] **P2-3**: Remotion 渲染启动慢 → 预编译 JS + `build:render` 脚本
#### 安全修复
- [x] **P1-8**: 硬编码 Cookie → 移至环境变量 `DOUYIN_COOKIE`
- [x] **P1-9**: 请求日志打印完整 headers → 敏感信息脱敏
- [x] **P2-10**: ffprobe 使用 `shell=True` → 改为参数列表
- [x] **P2-11**: CORS 配置 `*` + credentials → 从 `CORS_ORIGINS` 环境变量读取
#### 配置优化
- [x] **P2-5**: 存储服务硬编码路径 → 环境变量 `SUPABASE_STORAGE_LOCAL_PATH`
- [x] **P3-3**: Remotion `execSync` 同步调用 → promisified `exec` 异步
- [x] **P3-5**: LatentSync 相对路径 → 基于 `__file__` 绝对路径
### 暂不处理(收益有限)
- [~] **P1-6**: useHomeController 超大文件 (884行)
- [~] **P1-7**: 抖音/微信上传器重复代码(流程差异大)
### 低优先级(后续处理)
- [~] **P2-6~P2-9**: API 转发壳、前端 API 客户端混用、ESLint、重复逻辑
- [~] **P3-1~P3-4**: 阻塞式交互、Modal 过大、样式兼容层
### 涉及文件
- `backend/app/services/latentsync_service.py` - 回退逻辑
- `backend/app/modules/videos/router.py` - 任务状态认证
- `backend/app/modules/tools/router.py` - httpx 异步、Cookie 配置化
- `backend/app/services/glm_service.py` - 异步包装
- `backend/app/services/storage.py` - 流式上传、路径配置化
- `backend/app/services/video_service.py` - ffprobe 安全调用
- `backend/app/main.py` - CORS 配置、日志脱敏
- `backend/app/core/config.py` - 新增配置项
- `remotion/render.ts` - 异步 exec
- `remotion/package.json` - build:render 脚本
- `models/LatentSync/scripts/server.py` - 绝对路径
- `frontend/src/shared/types/user.ts` - 统一类型定义
### 新增环境变量
```bash
# .env 新增配置(均有默认值,无需必填)
CORS_ORIGINS=* # CORS 白名单
SUPABASE_STORAGE_LOCAL_PATH=/path/to/... # 本地存储路径
DOUYIN_COOKIE=... # 抖音视频下载 Cookie
```
### 重启要求
```bash
pm2 restart vigent2-backend
pm2 restart vigent2-latentsync
# Remotion 已自动编译
```

View File

@@ -24,12 +24,15 @@
| :---: | :--- | :--- |
| 🔥 **High** | `Docs/DevLogs/DayN.md` | **(最新日志)** 详细记录变更、修复、代码片段 |
| 🔥 **High** | `Docs/task_complete.md` | **(任务总览)** 更新 `[x]`、进度条、时间线 |
| ⚡ **Med** | `README.md` | **(项目主页)** 功能特性、技术栈、最新截图 |
| ⚡ **Med** | `Docs/DEPLOY_MANUAL.md` | **(部署手册)** 环境变量、依赖包、启动命令变更 |
| ⚡ **Med** | `Docs/FRONTEND_DEV.md` | **(端规范)** API封装、日期格式化、新页面规范 |
| ⚡ **Med** | `Docs/FRONTEND_README.md` | **(端文档)** 功能说明、页面变更 |
| 🧊 **Low** | `Docs/implementation_plan.md` | **(实施计划)** 核对计划与实际实现的差异 |
| 🧊 **Low** | `Docs/architecture_plan.md` | **(前端架构)** 拆分计划与阶段目标 |
| ⚡ **Med** | `README.md` | **(项目主页)** 功能特性、技术栈、最新截图 |
| ⚡ **Med** | `Docs/DEPLOY_MANUAL.md` | **(部署手册)** 环境变量、依赖包、启动命令变更 |
| ⚡ **Med** | `Docs/BACKEND_DEV.md` | **(端规范)** 接口契约、模块划分、环境变量 |
| ⚡ **Med** | `Docs/BACKEND_README.md` | **(端文档)** 接口说明、架构设计 |
| **Med** | `Docs/FRONTEND_DEV.md` | **(前端规范)** API封装、日期格式化、新页面规范 |
| **Med** | `Docs/FRONTEND_README.md` | **(前端文档)** 功能说明、页面变更 |
| 🧊 **Low** | `Docs/*_DEPLOY.md` | **(子系统部署)** LatentSync/Qwen3/字幕等独立部署文档 |
| 🧊 **Low** | `Docs/implementation_plan.md` | **(实施计划)** 核对计划与实际实现的差异 |
| 🧊 **Low** | `Docs/architecture_plan.md` | **(前端架构)** 拆分计划与阶段目标 |
---
@@ -141,20 +144,20 @@
> **核心原则**:使用正确的工具,避免字符编码问题
### ✅ 推荐工具apply_patch
### ✅ 推荐工具apply_patch
**使用场景**
**使用场景**
- 追加新章节到文件末尾
- 修改/替换现有章节内容
- 更新状态标记(🔄 → ✅)
- 修正错误内容
**优势**
**优势**
- ✅ 自动处理字符编码Windows CRLF
- ✅ 精确替换,不会误删其他内容
- ✅ 有错误提示,方便调试
**注意事项**
**注意事项**
```markdown
1. **必须精确匹配**TargetContent 必须与文件完全一致
2. **处理换行符**:文件使用 \r\n不要漏掉 \r
@@ -178,45 +181,51 @@
### 📝 最佳实践示例
**追加新章节**
```diff
*** Begin Patch
*** Update File: Docs/DevLogs/DayN.md
@@
## 🔗 相关文档
...
---
## 🆕 新章节
内容...
*** End Patch
```
**追加新章节**
```diff
*** Begin Patch
*** Update File: Docs/DevLogs/DayN.md
@@
## 🔗 相关文档
...
---
**修改现有内容**
```diff
*** Begin Patch
*** Update File: Docs/DevLogs/DayN.md
@@
-**状态**:🔄 待修复
+**状态**:✅ 已修复
*** End Patch
```
## 🆕 新章节
内容...
*** End Patch
```
**修改现有内容**
```diff
*** Begin Patch
*** Update File: Docs/DevLogs/DayN.md
@@
-**状态**:🔄 待修复
+**状态**:✅ 已修复
*** End Patch
```
---
## 📁 文件结构
## 📁 文件结构
```
ViGent2/Docs/
├── task_complete.md # 任务总览(仅按需更新)
├── Doc_Rules.md # 本文件
├── FRONTEND_DEV.md # 端开发规范
├── FRONTEND_README.md # 端功能文档
├── architecture_plan.md # 前端拆分计划
├── DEPLOY_MANUAL.md # 部署手册
├── SUPABASE_DEPLOY.md # Supabase 部署文档
ViGent2/Docs/
├── task_complete.md # 任务总览(仅按需更新)
├── Doc_Rules.md # 本文件
├── BACKEND_DEV.md # 端开发规范
├── BACKEND_README.md # 端功能文档
├── FRONTEND_DEV.md # 前端开发规范
├── FRONTEND_README.md # 前端功能文档
├── architecture_plan.md # 前端拆分计划
├── implementation_plan.md # 实施计划
├── DEPLOY_MANUAL.md # 部署手册
├── SUPABASE_DEPLOY.md # Supabase 部署文档
├── LatentSync_DEPLOY.md # LatentSync 部署文档
├── QWEN3_TTS_DEPLOY.md # 声音克隆部署文档
├── SUBTITLE_DEPLOY.md # 字幕系统部署文档
└── DevLogs/
├── Day1.md # 开发日志
└── ...
@@ -224,7 +233,7 @@ ViGent2/Docs/
---
## 📅 DayN.md 更新规则(日常更新)
## 📅 DayN.md 更新规则(日常更新)
### 新建判断 (对话开始前)
1. **回顾进度**:查看 `task_complete.md` 了解当前状态
@@ -232,9 +241,9 @@ ViGent2/Docs/
- **今天 (与当前日期相同)** → 🚨 **绝对禁止创建新文件**,必须**追加**到现有 `DayN.md` 末尾!即使是完全不同的功能模块。
- **之前 (昨天或更早)** → 创建 `Day{N+1}.md`
### 追加格式
```markdown
---
### 追加格式
```markdown
---
## 🔧 [章节标题]
@@ -250,18 +259,18 @@ ViGent2/Docs/
- ✅ 修复了 xxx
```
### 快速修复格式
```markdown
## 🐛 [Bug 简述] (HH:MM)
### 快速修复格式
```markdown
## 🐛 [Bug 简述] (HH:MM)
**问题**:一句话描述
**修复**:修改了 `文件名` 中的 xxx
**状态**:✅ 已修复 / 🔄 待验证
```
### ⚠️ 注意
- **DayN.md 文件开头禁止使用 `---`**,避免被解析为 Front Matter。
- 分隔线只用于章节之间,不作为文件第一行。
**状态**:✅ 已修复 / 🔄 待验证
```
### ⚠️ 注意
- **DayN.md 文件开头禁止使用 `---`**,避免被解析为 Front Matter。
- 分隔线只用于章节之间,不作为文件第一行。
---
@@ -316,4 +325,4 @@ ViGent2/Docs/
---
**最后更新**2026-02-04
**最后更新**2026-02-07

View File

@@ -233,6 +233,12 @@ import { formatDate } from '@/shared/lib/media';
- `features/*/ui`:功能 UI 组件
- `shared/`:通用工具、通用 hooks、API 实例
## 类型定义规范
- 通用实体类型(如 User, Account, Video统一放置在 `src/shared/types/`
- 特定业务类型放在 feature 目录下的 types.ts 或 model 中。
- **禁止**在多个地方重复定义 User 接口,统一引用 `import { User } from '@/shared/types/user';`
---
## 用户偏好持久化

View File

@@ -52,6 +52,9 @@ cd /home/rongye/ProgramFiles/ViGent2/remotion
# 安装依赖
npm install
# 预编译渲染脚本 (生产环境必须)
npm run build:render
```
### 步骤 3: 重启后端服务

View File

@@ -42,28 +42,28 @@
| 模块 | 技术选择 | 备选方案 |
|------|----------|----------|
| **前端框架** | Next.js 16 | Vue 3 + Vite |
| **UI 组件库** | TailwindCSS (自定义组件) | Ant Design |
| **后端框架** | FastAPI | Flask |
| **任务队列** | FastAPI BackgroundTasks (asyncio) | Celery + Redis |
| **唇形同步** | **LatentSync 1.6** | MuseTalk / Wav2Lip |
| **TTS 配音** | EdgeTTS | CosyVoice |
| **声音克隆** | **Qwen3-TTS 1.7B** ✅ | GPT-SoVITS |
| **视频处理** | FFmpeg | MoviePy |
| **自动发布** | Playwright | 自行实现 |
| **数据库** | Supabase (PostgreSQL) | MySQL |
| **文件存储** | Supabase Storage | 阿里云 OSS |
> **修正 (18:10)**:当前实现采用 Next.js 16、FastAPI BackgroundTasks 与 Supabase Storage/Auth自动发布基于 Playwright。
---
## ✅ 现状补充 (Day 17)
- 前端已拆分为组件化结构(`features/home/ui/`),主页面逻辑集中。
- 通用工具 `media.ts` 统一处理 API Base / 资源 URL / 日期格式化。
- 作品预览弹窗统一样式,并支持素材/发布预览复用。
- 标题/字幕预览按素材分辨率缩放,效果更接近成片。
| **前端框架** | Next.js 16 | Vue 3 + Vite |
| **UI 组件库** | TailwindCSS (自定义组件) | Ant Design |
| **后端框架** | FastAPI | Flask |
| **任务队列** | FastAPI BackgroundTasks (asyncio) | Celery + Redis |
| **唇形同步** | **LatentSync 1.6** | MuseTalk / Wav2Lip |
| **TTS 配音** | EdgeTTS | CosyVoice |
| **声音克隆** | **Qwen3-TTS 1.7B** ✅ | GPT-SoVITS |
| **视频处理** | FFmpeg | MoviePy |
| **自动发布** | Playwright | 自行实现 |
| **数据库** | Supabase (PostgreSQL) | MySQL |
| **文件存储** | Supabase Storage | 阿里云 OSS |
> **修正 (18:10)**:当前实现采用 Next.js 16、FastAPI BackgroundTasks 与 Supabase Storage/Auth自动发布基于 Playwright。
---
## ✅ 现状补充 (Day 17)
- 前端已拆分为组件化结构(`features/home/ui/`),主页面逻辑集中。
- 通用工具 `media.ts` 统一处理 API Base / 资源 URL / 日期格式化。
- 作品预览弹窗统一样式,并支持素材/发布预览复用。
- 标题/字幕预览按素材分辨率缩放,效果更接近成片。
---
@@ -71,11 +71,11 @@
### 阶段一:核心功能验证 (MVP)
> **目标**:验证 LatentSync + EdgeTTS 效果,跑通端到端流程
> **目标**:验证 LatentSync + EdgeTTS 效果,跑通端到端流程
#### 1.1 环境搭建
参考 `models/LatentSync/DEPLOY.md` 完成 LatentSync 环境与权重部署。
#### 1.1 环境搭建
参考 `models/LatentSync/DEPLOY.md` 完成 LatentSync 环境与权重部署。
#### 1.2 集成 EdgeTTS
@@ -96,13 +96,13 @@ async def text_to_speech(text: str, voice: str = "zh-CN-YunxiNeural", output_pat
# test_pipeline.py
"""
1. 文案 → EdgeTTS → 音频
2. 静态视频 + 音频 → LatentSync → 口播视频
2. 静态视频 + 音频 → LatentSync → 口播视频
3. 添加字幕 → FFmpeg → 最终视频
"""
```
#### 1.4 验证标准
- [ ] LatentSync 能正常推理
- [ ] LatentSync 能正常推理
- [ ] 唇形与音频同步率 > 90%
- [ ] 单个视频生成时间 < 2 分钟
@@ -140,19 +140,19 @@ backend/
| 端点 | 方法 | 功能 |
|------|------|------|
| `/api/materials` | POST | 上传视频素材 | ✅ |
| `/api/materials` | POST | 上传视频素材 | ✅ |
| `/api/materials` | GET | 获取素材列表 | ✅ |
| `/api/videos/generate` | POST | 创建视频生成任务 | ✅ |
| `/api/videos/tasks/{id}` | GET | 查询任务状态 | ✅ |
| `/api/videos/generated` | GET | 获取历史作品列表 | ✅ |
| `/api/videos/tasks/{id}` | GET | 查询任务状态 | ✅ |
| `/api/videos/generated` | GET | 获取历史作品列表 | ✅ |
| `/api/publish` | POST | 发布到社交平台 | ✅ |
#### 2.3 BackgroundTasks 任务定义
```python
# app/api/videos.py
background_tasks.add_task(_process_video_generation, task_id, req, user_id)
```
#### 2.3 BackgroundTasks 任务定义
```python
# app/api/videos.py
background_tasks.add_task(_process_video_generation, task_id, req, user_id)
```
---
@@ -164,7 +164,7 @@ background_tasks.add_task(_process_video_generation, task_id, req, user_id)
| 页面 | 功能 |
|------|------|
| **素材库** | 上传/管理多场景视频素材 |
| **素材库** | 上传/管理多场景视频素材 |
| **生成视频** | 输入文案、选择素材、生成预览 |
| **任务中心** | 查看生成进度、下载视频 |
| **发布管理** | 绑定平台、一键发布、定时发布 |
@@ -175,9 +175,9 @@ background_tasks.add_task(_process_video_generation, task_id, req, user_id)
# 创建 Next.js 项目
npx create-next-app@latest frontend --typescript --tailwind --app
# 安装依赖
cd frontend
npm install axios swr
# 安装依赖
cd frontend
npm install axios swr
```
---
@@ -369,6 +369,17 @@ cp -r SuperIPAgent/social-auto-upload backend/social_upload
---
### 阶段二十:代码质量与安全优化 (Day 20) ✅
> **目标**:全面提升代码健壮性、安全性与配置灵活性
- [x] **安全性修复**:硬编码 Cookie/Key 移除ffprobe 安全调用,日志脱敏
- [x] **配置化改造**存储路径、CORS、录屏开关全面环境变量化
- [x] **性能优化**API 异步改造 (httpx/asyncio),大文件流式上传
- [x] **构建优化**Remotion 预编译,统一启动脚本 `run_backend.sh`
---
## 验证计划
### 阶段一验证

View File

@@ -1,8 +1,8 @@
# ViGent2 开发任务清单 (Task Log)
**项目**: ViGent2 数字人口播视频生成系统
**进度**: 100% (Day 19 - 自动发布稳定性与发布体验优化)
**更新时间**: 2026-02-06
**进度**: 100% (Day 20 - 代码质量与安全优化)
**更新时间**: 2026-02-07
---
@@ -10,52 +10,59 @@
> 这里记录了每一天的核心开发内容与 milestone。
### Day 19: 自动发布稳定性与发布体验优化 (Current) 🚀
- [x] **抖音发布稳定性**: 上传入口、封面流程、发布重试、登录失效识别与网络失败快速返回全面增强
- [x] **视频号发布修复**: 标题+标签统一写入“视频描述”,`post_create` 成功信号快速判定,超时改为失败返回
- [x] **成功截图闭环**: 抖音/视频号发布成功截图接入前端,支持用户隔离存储与鉴权访问
- [x] **截图观感优化**: 成功截图延后 3 秒并改为视口截图,修复“截图内容仅占 1/3”问题
- [x] **调试能力开关化**: 新增视频号录屏配置,默认可按环境变量开关,失败排障更直观
- [x] **启动链路统一**: 合并为 `run_backend.sh`xvfb + headful统一端口 `8006`,减少多进程混淆。
- [x] **发布页防误操作**: 发布中按钮提示“请勿刷新或关闭网页”,并启用刷新/关页二次确认拦截。
- [ ] **后续优化**: 发布任务状态恢复机制(任务化 + 状态持久化 + 前端轮询恢复)
### Day 18: 后端模块化与规范完善
- [x] **模块化迁移**: 路由透传 `modules/*`,业务逻辑集中到 service/workflow
- [x] **视频生成拆分**: 生成流程下沉 workflow任务状态统一 TaskStore
- [x] **Redis 任务存储**: Redis 优先,不可用自动回退内存
- [x] **仓储层抽离**: Supabase 访问统一 `repositories/*`deps/auth/admin 全面替换
- [x] **响应规范**: 统一 `success/message/data/code` + 全局异常处理
- [x] **素材重命名**: 新增重命名接口与 Storage `move_file`
- [x] **平台顺序调整**: 抖音/微信视频号/B站/小红书,移除快手。
- [x] **后端开发规范**: 新增 `BACKEND_DEV.md`README 同步模块化结构
- [x] **发布管理体验**: 首页预取路由 + 发布页骨架与缓存,进入更快
- [x] **素材加载优化**: 素材列表并发签名 URL骨架数量动态
- [x] **预览加载优化**: `preload="metadata"` + hover 预取
### Day 17: 前端重构与体验优化
- [x] **UI 组件拆分**: 首页拆分为独立组件,降低 `page.tsx` 复杂度
- [x] **轻量 FSD 迁移**: `app` 页面轻量化,逻辑集中到 `features/*/model`,通用能力下沉 `shared/*`
- [x] **Controller Hooks**: Home/Publish 页面逻辑集中到 Controller HookPage 仅组合渲染
- [x] **通用工具抽取**: `media.ts` 统一 API Base / URL / 日期格式化
- [x] **交互优化**: 选择项持久化、列表内定位、刷新回顶部、最新作品优先预览
- [x] **发布页改造**: 作品列表卡片化 + 搜索 + 预览弹窗。
- [x] **预览体验**: 预览弹窗统一头部样式与提示文案。
- [x] **预览一致性**: 标题/字幕预览按素材分辨率缩放
- [x] **标题同步与限制**: 片头标题同步发布标题,输入法合成态兼容,限制 15 字
- [x] **样式默认与持久化**: 默认样式与字号调整,刷新保留用户选择
- [x] **性能微优化**: 列表渲染优化 + 并行请求 + localStorage 防抖
- [x] **资源能力**: 字体/BGM 资源库 + `/api/assets` 接入
- [x] **音频与字幕修复**: BGM 混音稳定性与字幕断句优化
- [x] **持久化修复**: 接入 `useHomePersistence`,恢复 `isRestored` 逻辑并通过构建
- [x] **预览与选择修复**: 发布预览兼容签名 URL音频试听路径解析素材/BGM 回退有效项
- [x] **体验细节优化**: 录音预览 URL 回收,预览弹窗滚动恢复,全局任务提示挂载
### Day 16: 深度性能优化
- [x] **Qwen-TTS 加速**: 集成 Flash Attention 2模型加载速度提升至 8.9s
- [x] **服务守护**: 开发 `Watchdog` 看门狗机制,自动监控并重启僵死服务
- [x] **LatentSync 性能确认**: 验证 DeepCache + 原生 Flash Attn 生效
- [x] **文档重构**: 全面更新 README、部署手册及后端文档
### Day 20: 代码质量与安全优化 (Current)
- [x] **功能性修复**: LatentSync 回退逻辑、任务状态接口认证、User 类型统一
- [x] **性能优化**: N+1 查询修复、视频上传流式处理、httpx 异步替换、GLM 异步包装
- [x] **安全修复**: 硬编码 Cookie 配置化、日志敏感信息脱敏、ffprobe 安全调用、CORS 配置化
- [x] **配置优化**: 存储路径环境变量化、Remotion 预编译加速、LatentSync 绝对路径
- [x] **文档更新**: 更新 Doc_Rules.md 清单,补齐后端与部署文档;更新 SUBTITLE_DEPLOY.md, FRONTEND_DEV.md, implementation_plan.md
### Day 19: 自动发布稳定性与发布体验优化 🚀
- [x] **抖音发布稳定性**: 上传入口、封面流程、发布重试、登录失效识别与网络失败快速返回全面增强
- [x] **视频号发布修复**: 标题+标签统一写入“视频描述”,`post_create` 成功信号快速判定,超时改为失败返回。
- [x] **成功截图闭环**: 抖音/视频号发布成功截图接入前端,支持用户隔离存储与鉴权访问。
- [x] **截图观感优化**: 成功截图延后 3 秒并改为视口截图,修复“截图内容仅占 1/3”问题
- [x] **调试能力开关化**: 新增视频号录屏配置,默认可按环境变量开关,失败排障更直观
- [x] **启动链路统一**: 合并为 `run_backend.sh`xvfb + headful统一端口 `8006`,减少多进程混淆
- [x] **发布页防误操作**: 发布中按钮提示“请勿刷新或关闭网页”,并启用刷新/关页二次确认拦截
- [ ] **后续优化**: 发布任务状态恢复机制(任务化 + 状态持久化 + 前端轮询恢复)
### Day 18: 后端模块化与规范完善
- [x] **模块化迁移**: 路由透传 `modules/*`,业务逻辑集中到 service/workflow
- [x] **视频生成拆分**: 生成流程下沉 workflow任务状态统一 TaskStore
- [x] **Redis 任务存储**: Redis 优先,不可用自动回退内存
- [x] **仓储层抽离**: Supabase 访问统一 `repositories/*`deps/auth/admin 全面替换
- [x] **响应规范**: 统一 `success/message/data/code` + 全局异常处理。
- [x] **素材重命名**: 新增重命名接口与 Storage `move_file`
- [x] **平台顺序调整**: 抖音/微信视频号/B站/小红书,移除快手
- [x] **后端开发规范**: 新增 `BACKEND_DEV.md`README 同步模块化结构
- [x] **发布管理体验**: 首页预取路由 + 发布页骨架与缓存,进入更快
- [x] **素材加载优化**: 素材列表并发签名 URL骨架数量动态
- [x] **预览加载优化**: `preload="metadata"` + hover 预取
### Day 17: 前端重构与体验优化
- [x] **UI 组件拆分**: 首页拆分为独立组件,降低 `page.tsx` 复杂度
- [x] **轻量 FSD 迁移**: `app` 页面轻量化,逻辑集中到 `features/*/model`,通用能力下沉 `shared/*`
- [x] **Controller Hooks**: Home/Publish 页面逻辑集中到 Controller HookPage 仅组合渲染
- [x] **通用工具抽取**: `media.ts` 统一 API Base / URL / 日期格式化
- [x] **交互优化**: 选择项持久化、列表内定位、刷新回顶部、最新作品优先预览
- [x] **发布页改造**: 作品列表卡片化 + 搜索 + 预览弹窗
- [x] **预览体验**: 预览弹窗统一头部样式与提示文案
- [x] **预览一致性**: 标题/字幕预览按素材分辨率缩放
- [x] **标题同步与限制**: 片头标题同步发布标题,输入法合成态兼容,限制 15 字
- [x] **样式默认与持久化**: 默认样式与字号调整,刷新保留用户选择。
- [x] **性能微优化**: 列表渲染优化 + 并行请求 + localStorage 防抖。
- [x] **资源能力**: 字体/BGM 资源库 + `/api/assets` 接入
- [x] **音频与字幕修复**: BGM 混音稳定性与字幕断句优化
- [x] **持久化修复**: 接入 `useHomePersistence`,恢复 `isRestored` 逻辑并通过构建
- [x] **预览与选择修复**: 发布预览兼容签名 URL音频试听路径解析素材/BGM 回退有效项
- [x] **体验细节优化**: 录音预览 URL 回收,预览弹窗滚动恢复,全局任务提示挂载。
### Day 16: 深度性能优化
- [x] **Qwen-TTS 加速**: 集成 Flash Attention 2模型加载速度提升至 8.9s。
- [x] **服务守护**: 开发 `Watchdog` 看门狗机制,自动监控并重启僵死服务。
- [x] **LatentSync 性能确认**: 验证 DeepCache + 原生 Flash Attn 生效。
- [x] **文档重构**: 全面更新 README、部署手册及后端文档。
### Day 15: 手机号认证迁移
- [x] **认证系统升级**: 从邮箱迁移至 11 位手机号注册/登录。
@@ -102,10 +109,10 @@
## 🛤️ 后续规划 (Roadmap)
### 🔴 优先待办
- [ ] **批量生成架构**: 支持 Excel 导入,批量生产视频。
- [ ] **定时任务后台化**: 迁移前端触发的定时发布到后端 APScheduler。
- [ ] **发布任务恢复机制**: 发布任务化 + 状态持久化 + 前端断点恢复,解决刷新后状态丢失。
### 🔴 优先待办
- [ ] **批量生成架构**: 支持 Excel 导入,批量生产视频。
- [ ] **定时任务后台化**: 迁移前端触发的定时发布到后端 APScheduler。
- [ ] **发布任务恢复机制**: 发布任务化 + 状态持久化 + 前端断点恢复,解决刷新后状态丢失。
### 🔵 长期探索
- [ ] **容器化交付**: 提供完整的 Docker Compose 一键部署包。
@@ -121,7 +128,7 @@
| **Web UI** | 100% | ✅ 稳定 (移动端适配) |
| **唇形同步** | 100% | ✅ LatentSync 1.6 |
| **TTS 配音** | 100% | ✅ EdgeTTS + Qwen3 |
| **自动发布** | 100% | ✅ 抖音/微信视频号/B站/小红书 |
| **自动发布** | 100% | ✅ 抖音/微信视频号/B站/小红书 |
| **用户认证** | 100% | ✅ 手机号 + JWT |
| **部署运维** | 100% | ✅ PM2 + Watchdog |
@@ -129,5 +136,5 @@
## 📎 相关文档
- [详细开发日志 (DevLogs)](Docs/DevLogs/)
- [部署手册 (DEPLOY_MANUAL)](Docs/DEPLOY_MANUAL.md)
- [详细开发日志 (DevLogs)](Docs/DevLogs/)
- [部署手册 (DEPLOY_MANUAL)](Docs/DEPLOY_MANUAL.md)

View File

@@ -66,3 +66,7 @@ ADMIN_PASSWORD=lam1988324
# 智谱 GLM API 配置 (用于生成标题和标签)
GLM_API_KEY=32440cd3f3444d1f8fe721304acea8bd.YXNLrk7eIJMKcg4t
GLM_MODEL=glm-4.7-flash
# =============== 抖音视频下载 Cookie ===============
# 用于从抖音 URL 提取视频文案功能,会过期需要定期更新
DOUYIN_COOKIE=douyin.com; device_web_cpu_core=10; device_web_memory_size=8; __ac_nonce=06760391f00b9b51264ae; __ac_signature=_02B4Z6wo00f019a5ceAAAIDAhEZR-X3jjWfWmXVAAJLXd4; ttwid=1%7C7MTKBSMsP4eOv9h5NAh8p0E-NYIud09ftNmB0mjLpWc%7C1734359327%7C8794abeabbd47447e1f56e5abc726be089f2a0344d6343b5f75f23e7b0f0028f; UIFID_TEMP=0de8750d2b188f4235dbfd208e44abbb976428f0720eb983255afefa45d39c0c6532e1d4768dd8587bf919f866ff1396912bcb2af71efee56a14a2a9f37b74010d0a0413795262f6d4afe02a032ac7ab; s_v_web_id=verify_m4r4ribr_c7krmY1z_WoeI_43po_ATpO_I4o8U1bex2D7; hevc_supported=true; home_can_add_dy_2_desktop=%220%22; dy_swidth=2560; dy_sheight=1440; stream_recommend_feed_params=%22%7B%5C%22cookie_enabled%5C%22%3Atrue%2C%5C%22screen_width%5C%22%3A2560%2C%5C%22screen_height%5C%22%3A1440%2C%5C%22browser_online%5C%22%3Atrue%2C%5C%22cpu_core_num%5C%22%3A10%2C%5C%22device_memory%5C%22%3A8%2C%5C%22downlink%5C%22%3A10%2C%5C%22effective_type%5C%22%3A%5C%224g%5C%22%2C%5C%22round_trip_time%5C%22%3A50%7D%22; strategyABtestKey=%221734359328.577%22; csrf_session_id=2f53aed9aa6974e83aa9a1014180c3a4; fpk1=U2FsdGVkX1/IpBh0qdmlKAVhGyYHgur4/VtL9AReZoeSxadXn4juKvsakahRGqjxOPytHWspYoBogyhS/V6QSw==; fpk2=0845b309c7b9b957afd9ecf775a4c21f; passport_csrf_token=d80e0c5b2fa2328219856be5ba7e671e; passport_csrf_token_default=d80e0c5b2fa2328219856be5ba7e671e; odin_tt=3c891091d2eb0f4718c1d5645bc4a0017032d4d5aa989decb729e9da2ad570918cbe5e9133dc6b145fa8c758de98efe32ff1f81aa0d611e838cc73ab08ef7d3f6adf66ab4d10e8372ddd628f94f16b8e; volume_info=%7B%22isUserMute%22%3Afalse%2C%22isMute%22%3Afalse%2C%22volume%22%3A0.5%7D; bd_ticket_guard_client_web_domain=2; FORCE_LOGIN=%7B%22videoConsumedRemainSeconds%22%3A180%7D; UIFID=0de8750d2b188f4235dbfd208e44abbb976428f0720eb983255afefa45d39c0c6532e1d4768dd8587bf919f866ff139655a3c2b735923234f371c699560c657923fd3d6c5b63ab7bb9b83423b6cb4787e2ce66a7fbc4ecb24c8570f520fe6de068bbb95115023c0c6c1b6ee31b49fb7e3996fb8349f43a3fd8b7a61cd9e18e8fe65eb6a7c13de4c0960d84e344b644725db3eb2fa6b7caf821de1b50527979f2; is_dash_user=1; biz_trace_id=b57a241f; bd_ticket_guard_client_data=eyJiZC10aWNrZXQtZ3VhcmQtdmVyc2lvbiI6MiwiYmQtdGlja2V0LWd1YXJkLWl0ZXJhdGlvbi12ZXJzaW9uIjoxLCJiZC10aWNrZXQtZ3VhcmQtcmVlLXB1YmxpYy1rZXkiOiJCTEo2R0lDalVoWW1XcHpGOFdrN0Vrc0dXcCtaUzNKY1g4NGNGY2k0TTl1TEowNjdUb21mbFU5aDdvWVBGamhNRWNRQWtKdnN1MnM3RmpTWnlJQXpHMjA9IiwiYmQtdGlja2V0LWd1YXJkLXdlYi12ZXJzaW9uIjoyfQ%3D%3D; download_guide=%221%2F20241216%2F0%22; sdk_source_info=7e276470716a68645a606960273f276364697660272927676c715a6d6069756077273f276364697660272927666d776a68605a607d71606b766c6a6b5a7666776c7571273f275e58272927666a6b766a69605a696c6061273f27636469766027292762696a6764695a7364776c6467696076273f275e5827292771273f273d33323131333c3036313632342778; bit_env=RiOY4jzzpxZoVCl6zdVSVhVRjdwHRTxqcqWdqMBZLPGjMdB4Tax1kAELHNTVAAh72KuhumewE4Lq6f0-VJ2UpJrkrhSxoPw9LUb3zQrq1OSwbeSPHkRlRgRQvO89sItdGUyq1oFr0XyRCnMYG87KSeWyc4x0czGR0o50hTDoDLG5rJVoRcdQOLvjiAegsqyytKF59sPX_QM9qffK2SqYsg0hCggURc_AI6kguDDE5DvG0bnyz1utw4z1eEnIoLrkGDqzqBZj4dOAr0BVU6ofbsS-pOQ2u2PM1dLP9FlBVBlVaqYVgHJeSLsR5k76BRTddUjTb4zEilVIEwAMJWGN4I1BxVt6fC9B5tBQpuT0lj3n3eKXCKXZsd8FrEs5_pbfDsxV-e_WMiXI2ff4qxiTC0U73sfo9OpicKICtZjdq8qsHxJuu6wVR36zvXeL2Wch5C6MzprNvkivv0l8nbh2mSgy1nabZr3dmU6NcR-Bg3Q3xTWUlR9aAUmpopC-cNuXjgLpT-Lw1AYGilSUnCvosth1Gfypq-b0MpgmdSDgTrQ%3D; gulu_source_res=eyJwX2luIjoiMDhjOGQ3ZTJiODQyNjZkZWI5Y2VkMGJiODNlNmY1ZWY0ZjMyNTE2ZmYyZjAzNDMzZjI0OWU1Y2Q1NTczNTk5NyJ9; passport_auth_mix_state=hp9bc3dgb1tm5wd8p82zawus27g0e3ue; IsDouyinActive=false

View File

@@ -3,46 +3,46 @@ from pathlib import Path
class Settings(BaseSettings):
# 基础路径配置
BASE_DIR: Path = Path(__file__).resolve().parent.parent
UPLOAD_DIR: Path = BASE_DIR.parent / "uploads"
OUTPUT_DIR: Path = BASE_DIR.parent / "outputs"
ASSETS_DIR: Path = BASE_DIR.parent / "assets"
PUBLISH_SCREENSHOT_DIR: Path = BASE_DIR.parent / "private_outputs" / "publish_screenshots"
BASE_DIR: Path = Path(__file__).resolve().parent.parent
UPLOAD_DIR: Path = BASE_DIR.parent / "uploads"
OUTPUT_DIR: Path = BASE_DIR.parent / "outputs"
ASSETS_DIR: Path = BASE_DIR.parent / "assets"
PUBLISH_SCREENSHOT_DIR: Path = BASE_DIR.parent / "private_outputs" / "publish_screenshots"
# 数据库/缓存
REDIS_URL: str = "redis://localhost:6379/0"
DEBUG: bool = True
# Playwright 配置
WEIXIN_HEADLESS_MODE: str = "headless-new"
WEIXIN_USER_AGENT: str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
WEIXIN_LOCALE: str = "zh-CN"
WEIXIN_TIMEZONE_ID: str = "Asia/Shanghai"
WEIXIN_CHROME_PATH: str = "/usr/bin/google-chrome"
WEIXIN_BROWSER_CHANNEL: str = ""
WEIXIN_FORCE_SWIFTSHADER: bool = True
WEIXIN_TRANSCODE_MODE: str = "reencode"
WEIXIN_DEBUG_ARTIFACTS: bool = False
WEIXIN_RECORD_VIDEO: bool = False
WEIXIN_KEEP_SUCCESS_VIDEO: bool = False
WEIXIN_RECORD_VIDEO_WIDTH: int = 1280
WEIXIN_RECORD_VIDEO_HEIGHT: int = 720
# Douyin Playwright 配置
DOUYIN_HEADLESS_MODE: str = "headless-new"
DOUYIN_USER_AGENT: str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
DOUYIN_LOCALE: str = "zh-CN"
DOUYIN_TIMEZONE_ID: str = "Asia/Shanghai"
DOUYIN_CHROME_PATH: str = "/usr/bin/google-chrome"
DOUYIN_BROWSER_CHANNEL: str = ""
DOUYIN_FORCE_SWIFTSHADER: bool = True
# Douyin 调试录屏
DOUYIN_DEBUG_ARTIFACTS: bool = False
DOUYIN_RECORD_VIDEO: bool = False
DOUYIN_KEEP_SUCCESS_VIDEO: bool = False
DOUYIN_RECORD_VIDEO_WIDTH: int = 1280
DOUYIN_RECORD_VIDEO_HEIGHT: int = 720
# 数据库/缓存
REDIS_URL: str = "redis://localhost:6379/0"
DEBUG: bool = True
# Playwright 配置
WEIXIN_HEADLESS_MODE: str = "headless-new"
WEIXIN_USER_AGENT: str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
WEIXIN_LOCALE: str = "zh-CN"
WEIXIN_TIMEZONE_ID: str = "Asia/Shanghai"
WEIXIN_CHROME_PATH: str = "/usr/bin/google-chrome"
WEIXIN_BROWSER_CHANNEL: str = ""
WEIXIN_FORCE_SWIFTSHADER: bool = True
WEIXIN_TRANSCODE_MODE: str = "reencode"
WEIXIN_DEBUG_ARTIFACTS: bool = False
WEIXIN_RECORD_VIDEO: bool = False
WEIXIN_KEEP_SUCCESS_VIDEO: bool = False
WEIXIN_RECORD_VIDEO_WIDTH: int = 1280
WEIXIN_RECORD_VIDEO_HEIGHT: int = 720
# Douyin Playwright 配置
DOUYIN_HEADLESS_MODE: str = "headless-new"
DOUYIN_USER_AGENT: str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
DOUYIN_LOCALE: str = "zh-CN"
DOUYIN_TIMEZONE_ID: str = "Asia/Shanghai"
DOUYIN_CHROME_PATH: str = "/usr/bin/google-chrome"
DOUYIN_BROWSER_CHANNEL: str = ""
DOUYIN_FORCE_SWIFTSHADER: bool = True
# Douyin 调试录屏
DOUYIN_DEBUG_ARTIFACTS: bool = False
DOUYIN_RECORD_VIDEO: bool = False
DOUYIN_KEEP_SUCCESS_VIDEO: bool = False
DOUYIN_RECORD_VIDEO_WIDTH: int = 1280
DOUYIN_RECORD_VIDEO_HEIGHT: int = 720
# TTS 配置
DEFAULT_TTS_VOICE: str = "zh-CN-YunxiNeural"
@@ -76,6 +76,12 @@ class Settings(BaseSettings):
GLM_API_KEY: str = ""
GLM_MODEL: str = "glm-4.7-flash"
# CORS 配置 (逗号分隔的域名列表,* 表示允许所有)
CORS_ORIGINS: str = "*"
# 抖音 Cookie (用于视频下载功能,会过期需要定期更新)
DOUYIN_COOKIE: str = ""
@property
def LATENTSYNC_DIR(self) -> Path:
"""LatentSync 目录路径 (动态计算)"""

View File

@@ -1,10 +1,10 @@
from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.core import config
from app.core.response import error_response
from app.api import materials, videos, publish, login_helper, auth, admin, ref_audios, ai, tools, assets
from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.core import config
from app.core.response import error_response
from app.api import materials, videos, publish, login_helper, auth, admin, ref_audios, ai, tools, assets
from loguru import logger
import os
@@ -12,17 +12,34 @@ settings = config.settings
app = FastAPI(title="ViGent TalkingHead Agent")
from fastapi import Request
from fastapi.exceptions import RequestValidationError
from fastapi import Request
from fastapi.exceptions import RequestValidationError
from starlette.middleware.base import BaseHTTPMiddleware
import time
import traceback
class LoggingMiddleware(BaseHTTPMiddleware):
# 敏感 header 名称列表(小写)
SENSITIVE_HEADERS = {'authorization', 'cookie', 'set-cookie', 'x-api-key', 'api-key'}
def _sanitize_headers(self, headers: dict) -> dict:
"""脱敏处理请求头,隐藏敏感信息"""
sanitized = {}
for key, value in headers.items():
if key.lower() in self.SENSITIVE_HEADERS:
# 显示前8个字符 + 掩码
if len(value) > 8:
sanitized[key] = value[:8] + "..." + f"[{len(value)} chars]"
else:
sanitized[key] = "[REDACTED]"
else:
sanitized[key] = value
return sanitized
async def dispatch(self, request: Request, call_next):
start_time = time.time()
logger.info(f"START Request: {request.method} {request.url}")
logger.info(f"HEADERS: {dict(request.headers)}")
logger.debug(f"HEADERS: {self._sanitize_headers(dict(request.headers))}")
try:
response = await call_next(request)
process_time = time.time() - start_time
@@ -33,53 +50,58 @@ class LoggingMiddleware(BaseHTTPMiddleware):
logger.error(f"EXCEPTION during request {request.method} {request.url}: {str(e)}\n{traceback.format_exc()}")
raise e
app.add_middleware(LoggingMiddleware)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
return JSONResponse(
status_code=422,
content=error_response("参数校验失败", 422, data=exc.errors()),
)
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
detail = exc.detail
message = detail if isinstance(detail, str) else "请求失败"
data = detail if not isinstance(detail, str) else None
return JSONResponse(
status_code=exc.status_code,
content=error_response(message, exc.status_code, data=data),
headers=exc.headers,
)
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception):
return JSONResponse(
status_code=500,
content=error_response("服务器内部错误", 500),
)
app.add_middleware(LoggingMiddleware)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
return JSONResponse(
status_code=422,
content=error_response("参数校验失败", 422, data=exc.errors()),
)
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
detail = exc.detail
message = detail if isinstance(detail, str) else "请求失败"
data = detail if not isinstance(detail, str) else None
return JSONResponse(
status_code=exc.status_code,
content=error_response(message, exc.status_code, data=data),
headers=exc.headers,
)
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception):
return JSONResponse(
status_code=500,
content=error_response("服务器内部错误", 500),
)
# CORS 配置:从环境变量读取允许的域名
# 当使用 credentials 时,不能使用 * 通配符
cors_origins = settings.CORS_ORIGINS.split(",") if settings.CORS_ORIGINS != "*" else ["*"]
allow_credentials = settings.CORS_ORIGINS != "*" # 使用 * 时不能 allow_credentials
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_origins=cors_origins,
allow_credentials=allow_credentials,
allow_methods=["*"],
allow_headers=["*"],
)
# Create dirs
settings.UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
settings.OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
(settings.UPLOAD_DIR / "materials").mkdir(exist_ok=True)
settings.ASSETS_DIR.mkdir(parents=True, exist_ok=True)
settings.UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
settings.OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
(settings.UPLOAD_DIR / "materials").mkdir(exist_ok=True)
settings.ASSETS_DIR.mkdir(parents=True, exist_ok=True)
app.mount("/outputs", StaticFiles(directory=str(settings.OUTPUT_DIR)), name="outputs")
app.mount("/uploads", StaticFiles(directory=str(settings.UPLOAD_DIR)), name="uploads")
app.mount("/assets", StaticFiles(directory=str(settings.ASSETS_DIR)), name="assets")
app.mount("/outputs", StaticFiles(directory=str(settings.OUTPUT_DIR)), name="outputs")
app.mount("/uploads", StaticFiles(directory=str(settings.UPLOAD_DIR)), name="uploads")
app.mount("/assets", StaticFiles(directory=str(settings.ASSETS_DIR)), name="assets")
# 注册路由
app.include_router(materials.router, prefix="/api/materials", tags=["Materials"])
@@ -88,10 +110,10 @@ app.include_router(publish.router, prefix="/api/publish", tags=["Publish"])
app.include_router(login_helper.router, prefix="/api", tags=["LoginHelper"])
app.include_router(auth.router) # /api/auth
app.include_router(admin.router) # /api/admin
app.include_router(ref_audios.router, prefix="/api/ref-audios", tags=["RefAudios"])
app.include_router(ai.router) # /api/ai
app.include_router(tools.router, prefix="/api/tools", tags=["Tools"])
app.include_router(assets.router, prefix="/api/assets", tags=["Assets"])
app.include_router(ref_audios.router, prefix="/api/ref-audios", tags=["RefAudios"])
app.include_router(ai.router) # /api/ai
app.include_router(tools.router, prefix="/api/tools", tags=["Tools"])
app.include_router(assets.router, prefix="/api/assets", tags=["Assets"])
@app.on_event("startup")
@@ -107,21 +129,21 @@ async def init_admin():
return
try:
from app.core.security import get_password_hash
from app.repositories.users import create_user, user_exists_by_phone
if user_exists_by_phone(admin_phone):
logger.info(f"管理员账号已存在: {admin_phone}")
return
create_user({
"phone": admin_phone,
"password_hash": get_password_hash(admin_password),
"username": "Admin",
"role": "admin",
"is_active": True,
"expires_at": None # 永不过期
})
from app.core.security import get_password_hash
from app.repositories.users import create_user, user_exists_by_phone
if user_exists_by_phone(admin_phone):
logger.info(f"管理员账号已存在: {admin_phone}")
return
create_user({
"phone": admin_phone,
"password_hash": get_password_hash(admin_password),
"username": "Admin",
"role": "admin",
"is_active": True,
"expires_at": None # 永不过期
})
logger.success(f"管理员账号已创建: {admin_phone}")
except Exception as e:

View File

@@ -249,16 +249,17 @@ async def list_ref_audios(user: dict = Depends(get_current_user)):
# 列出用户目录下的文件
files = await storage_service.list_files(BUCKET_REF_AUDIOS, user_id)
# 过滤出 .wav 文件并获取对应的 metadata
items = []
for f in files:
# 过滤出 .wav 文件
wav_files = [f for f in files if f.get("name", "").endswith(".wav")]
if not wav_files:
return success_response(RefAudioListResponse(items=[]).model_dump())
# 并发获取所有 metadata 和签名 URL
async def fetch_audio_info(f):
"""获取单个音频的信息metadata + signed URL"""
name = f.get("name", "")
if not name.endswith(".wav"):
continue
storage_path = f"{user_id}/{name}"
# 尝试读取 metadata
metadata_name = name.replace(".wav", ".json")
metadata_path = f"{user_id}/{metadata_name}"
@@ -271,7 +272,7 @@ async def list_ref_audios(user: dict = Depends(get_current_user)):
# 获取 metadata 内容
metadata_url = await storage_service.get_signed_url(BUCKET_REF_AUDIOS, metadata_path)
import httpx
async with httpx.AsyncClient() as client:
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.get(metadata_url)
if resp.status_code == 200:
metadata = resp.json()
@@ -280,7 +281,7 @@ async def list_ref_audios(user: dict = Depends(get_current_user)):
created_at = metadata.get("created_at", 0)
original_filename = metadata.get("original_filename", "")
except Exception as e:
logger.warning(f"读取 metadata 失败: {e}")
logger.debug(f"读取 metadata 失败: {e}")
# 从文件名提取时间戳
try:
created_at = int(name.split("_")[0])
@@ -299,17 +300,21 @@ async def list_ref_audios(user: dict = Depends(get_current_user)):
if match:
display_name = match.group(1)
items.append(RefAudioResponse(
return RefAudioResponse(
id=storage_path,
name=display_name,
path=signed_url,
ref_text=ref_text,
duration_sec=duration_sec,
created_at=created_at
))
)
# 使用 asyncio.gather 并发获取所有音频信息
import asyncio
items = await asyncio.gather(*[fetch_audio_info(f) for f in wav_files])
# 按创建时间倒序排列
items.sort(key=lambda x: x.created_at, reverse=True)
items = sorted(items, key=lambda x: x.created_at, reverse=True)
return success_response(RefAudioListResponse(items=items).model_dump())

View File

@@ -210,6 +210,8 @@ async def download_douyin_manual(url: str, temp_dir: Path, timestamp: int) -> Op
手动下载抖音视频 (Fallback logic - Ported from SuperIPAgent/douyinDownloader)
使用特定的 User Profile URL 和硬编码 Cookie 绕过反爬
"""
import httpx
logger.info(f"[SuperIPAgent] Starting download for: {url}")
try:
@@ -218,9 +220,11 @@ async def download_douyin_manual(url: str, temp_dir: Path, timestamp: int) -> Op
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
}
# 如果是短链或重定向
resp = requests.get(url, headers=headers, allow_redirects=True, timeout=10)
final_url = resp.url
# 如果是短链或重定向 - 使用异步 httpx
async with httpx.AsyncClient(follow_redirects=True, timeout=10.0) as client:
resp = await client.get(url, headers=headers)
final_url = str(resp.url)
logger.info(f"[SuperIPAgent] Final URL: {final_url}")
modal_id = None
@@ -238,16 +242,21 @@ async def download_douyin_manual(url: str, temp_dir: Path, timestamp: int) -> Op
# 使用特定用户的 Profile 页 + modal_id 参数,配合特定 Cookie
target_url = f"https://www.douyin.com/user/MS4wLjABAAAAN_s_hups7LD0N4qnrM3o2gI0vuG3pozNaEolz2_py3cHTTrpVr1Z4dukFD9SOlwY?from_tab_name=main&modal_id={modal_id}"
# 3. 使用硬编码 Cookie (Copy from SuperIPAgent)
# 3. 使用配置的 Cookie (从环境变量 DOUYIN_COOKIE 读取)
from app.core.config import settings
if not settings.DOUYIN_COOKIE:
logger.warning("[SuperIPAgent] DOUYIN_COOKIE 未配置,视频下载可能失败")
headers_with_cookie = {
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
"cookie": "douyin.com; device_web_cpu_core=10; device_web_memory_size=8; __ac_nonce=06760391f00b9b51264ae; __ac_signature=_02B4Z6wo00f019a5ceAAAIDAhEZR-X3jjWfWmXVAAJLXd4; ttwid=1%7C7MTKBSMsP4eOv9h5NAh8p0E-NYIud09ftNmB0mjLpWc%7C1734359327%7C8794abeabbd47447e1f56e5abc726be089f2a0344d6343b5f75f23e7b0f0028f; UIFID_TEMP=0de8750d2b188f4235dbfd208e44abbb976428f0720eb983255afefa45d39c0c6532e1d4768dd8587bf919f866ff1396912bcb2af71efee56a14a2a9f37b74010d0a0413795262f6d4afe02a032ac7ab; s_v_web_id=verify_m4r4ribr_c7krmY1z_WoeI_43po_ATpO_I4o8U1bex2D7; hevc_supported=true; home_can_add_dy_2_desktop=%220%22; dy_swidth=2560; dy_sheight=1440; stream_recommend_feed_params=%22%7B%5C%22cookie_enabled%5C%22%3Atrue%2C%5C%22screen_width%5C%22%3A2560%2C%5C%22screen_height%5C%22%3A1440%2C%5C%22browser_online%5C%22%3Atrue%2C%5C%22cpu_core_num%5C%22%3A10%2C%5C%22device_memory%5C%22%3A8%2C%5C%22downlink%5C%22%3A10%2C%5C%22effective_type%5C%22%3A%5C%224g%5C%22%2C%5C%22round_trip_time%5C%22%3A50%7D%22; strategyABtestKey=%221734359328.577%22; csrf_session_id=2f53aed9aa6974e83aa9a1014180c3a4; fpk1=U2FsdGVkX1/IpBh0qdmlKAVhGyYHgur4/VtL9AReZoeSxadXn4juKvsakahRGqjxOPytHWspYoBogyhS/V6QSw==; fpk2=0845b309c7b9b957afd9ecf775a4c21f; passport_csrf_token=d80e0c5b2fa2328219856be5ba7e671e; passport_csrf_token_default=d80e0c5b2fa2328219856be5ba7e671e; odin_tt=3c891091d2eb0f4718c1d5645bc4a0017032d4d5aa989decb729e9da2ad570918cbe5e9133dc6b145fa8c758de98efe32ff1f81aa0d611e838cc73ab08ef7d3f6adf66ab4d10e8372ddd628f94f16b8e; volume_info=%7B%22isUserMute%22%3Afalse%2C%22isMute%22%3Afalse%2C%22volume%22%3A0.5%7D; bd_ticket_guard_client_web_domain=2; FORCE_LOGIN=%7B%22videoConsumedRemainSeconds%22%3A180%7D; UIFID=0de8750d2b188f4235dbfd208e44abbb976428f0720eb983255afefa45d39c0c6532e1d4768dd8587bf919f866ff139655a3c2b735923234f371c699560c657923fd3d6c5b63ab7bb9b83423b6cb4787e2ce66a7fbc4ecb24c8570f520fe6de068bbb95115023c0c6c1b6ee31b49fb7e3996fb8349f43a3fd8b7a61cd9e18e8fe65eb6a7c13de4c0960d84e344b644725db3eb2fa6b7caf821de1b50527979f2; is_dash_user=1; biz_trace_id=b57a241f; bd_ticket_guard_client_data=eyJiZC10aWNrZXQtZ3VhcmQtdmVyc2lvbiI6MiwiYmQtdGlja2V0LWd1YXJkLWl0ZXJhdGlvbi12ZXJzaW9uIjoxLCJiZC10aWNrZXQtZ3VhcmQtcmVlLXB1YmxpYy1rZXkiOiJCTEo2R0lDalVoWW1XcHpGOFdrN0Vrc0dXcCtaUzNKY1g4NGNGY2k0TTl1TEowNjdUb21mbFU5aDdvWVBGamhNRWNRQWtKdnN1MnM3RmpTWnlJQXpHMjA9IiwiYmQtdGlja2V0LWd1YXJkLXdlYi12ZXJzaW9uIjoyfQ%3D%3D; download_guide=%221%2F20241216%2F0%22; sdk_source_info=7e276470716a68645a606960273f276364697660272927676c715a6d6069756077273f276364697660272927666d776a68605a607d71606b766c6a6b5a7666776c7571273f275e58272927666a6b766a69605a696c6061273f27636469766027292762696a6764695a7364776c6467696076273f275e5827292771273f273d33323131333c3036313632342778; bit_env=RiOY4jzzpxZoVCl6zdVSVhVRjdwHRTxqcqWdqMBZLPGjMdB4Tax1kAELHNTVAAh72KuhumewE4Lq6f0-VJ2UpJrkrhSxoPw9LUb3zQrq1OSwbeSPHkRlRgRQvO89sItdGUyq1oFr0XyRCnMYG87KSeWyc4x0czGR0o50hTDoDLG5rJVoRcdQOLvjiAegsqyytKF59sPX_QM9qffK2SqYsg0hCggURc_AI6kguDDE5DvG0bnyz1utw4z1eEnIoLrkGDqzqBZj4dOAr0BVU6ofbsS-pOQ2u2PM1dLP9FlBVBlVaqYVgHJeSLsR5k76BRTddUjTb4zEilVIEwAMJWGN4I1BxVt6fC9B5tBQpuT0lj3n3eKXCKXZsd8FrEs5_pbfDsxV-e_WMiXI2ff4qxiTC0U73sfo9OpicKICtZjdq8qsHxJuu6wVR36zvXeL2Wch5C6MzprNvkivv0l8nbh2mSgy1nabZr3dmU6NcR-Bg3Q3xTWUlR9aAUmpopC-cNuXjgLpT-Lw1AYGilSUnCvosth1Gfypq-b0MpgmdSDgTrQ%3D; gulu_source_res=eyJwX2luIjoiMDhjOGQ3ZTJiODQyNjZkZWI5Y2VkMGJiODNlNmY1ZWY0ZjMyNTE2ZmYyZjAzNDMzZjI0OWU1Y2Q1NTczNTk5NyJ9; passport_auth_mix_state=hp9bc3dgb1tm5wd8p82zawus27g0e3ue; IsDouyinActive=false",
"cookie": settings.DOUYIN_COOKIE,
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
}
logger.info(f"[SuperIPAgent] Requesting page with Cookie...")
# 必须 verify=False 否则有些环境会报错
response = requests.get(target_url, headers=headers_with_cookie, timeout=10)
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(target_url, headers=headers_with_cookie)
# 4. 解析 RENDER_DATA
content_match = re.findall(r'<script id="RENDER_DATA" type="application/json">(.*?)</script>', response.text)
@@ -290,24 +299,25 @@ async def download_douyin_manual(url: str, temp_dir: Path, timestamp: int) -> Op
logger.info(f"[SuperIPAgent] Found video URL: {video_url[:50]}...")
# 6. 下载 (带 Header)
# 6. 下载 (带 Header) - 使用异步 httpx
temp_path = temp_dir / f"douyin_manual_{timestamp}.mp4"
download_headers = {
'Referer': 'https://www.douyin.com/',
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
}
dl_resp = requests.get(video_url, headers=download_headers, stream=True, timeout=60)
if dl_resp.status_code == 200:
with open(temp_path, 'wb') as f:
for chunk in dl_resp.iter_content(chunk_size=1024):
f.write(chunk)
logger.info(f"[SuperIPAgent] Downloaded successfully: {temp_path}")
return temp_path
else:
logger.error(f"[SuperIPAgent] Download failed: {dl_resp.status_code}")
return None
async with httpx.AsyncClient(timeout=60.0) as client:
async with client.stream("GET", video_url, headers=download_headers) as dl_resp:
if dl_resp.status_code == 200:
with open(temp_path, 'wb') as f:
async for chunk in dl_resp.aiter_bytes(chunk_size=8192):
f.write(chunk)
logger.info(f"[SuperIPAgent] Downloaded successfully: {temp_path}")
return temp_path
else:
logger.error(f"[SuperIPAgent] Download failed: {dl_resp.status_code}")
return None
except Exception as e:
logger.error(f"[SuperIPAgent] Logic failed: {e}")

View File

@@ -27,13 +27,20 @@ async def generate_video(
@router.get("/tasks/{task_id}")
async def get_task_status(task_id: str):
return success_response(get_task(task_id))
async def get_task_status(task_id: str, current_user: dict = Depends(get_current_user)):
task = get_task(task_id)
# 验证任务归属:只能查看自己的任务
if task.get("status") != "not_found" and task.get("user_id") != current_user["id"]:
return success_response({"status": "not_found"})
return success_response(task)
@router.get("/tasks")
async def list_tasks_view():
return success_response({"tasks": list_tasks()})
async def list_tasks_view(current_user: dict = Depends(get_current_user)):
# 只返回当前用户的任务
all_tasks = list_tasks()
user_tasks = [t for t in all_tasks if t.get("user_id") == current_user["id"]]
return success_response({"tasks": user_tasks})
@router.get("/lipsync/health")

View File

@@ -277,14 +277,12 @@ async def process_video_generation(task_id: str, req: GenerateRequest, user_id:
_update_task(task_id, message="正在上传结果...", progress=95)
storage_path = f"{user_id}/{task_id}_output.mp4"
with open(final_output_local_path, "rb") as f:
file_data = f.read()
await storage_service.upload_file(
bucket=storage_service.BUCKET_OUTPUTS,
path=storage_path,
file_data=file_data,
content_type="video/mp4"
)
await storage_service.upload_file_from_path(
bucket=storage_service.BUCKET_OUTPUTS,
storage_path=storage_path,
local_file_path=str(final_output_local_path),
content_type="video/mp4"
)
signed_url = await storage_service.get_signed_url(
bucket=storage_service.BUCKET_OUTPUTS,

View File

@@ -51,7 +51,10 @@ class GLMService:
client = self._get_client()
logger.info(f"Calling GLM API with model: {settings.GLM_MODEL}")
response = client.chat.completions.create(
# 使用 asyncio.to_thread 包装同步 SDK 调用,避免阻塞事件循环
import asyncio
response = await asyncio.to_thread(
client.chat.completions.create,
model=settings.GLM_MODEL,
messages=[{"role": "user", "content": prompt}],
thinking={"type": "disabled"}, # 禁用思考模式,加快响应
@@ -96,7 +99,10 @@ class GLMService:
client = self._get_client()
logger.info(f"Using GLM to rewrite script")
response = client.chat.completions.create(
# 使用 asyncio.to_thread 包装同步 SDK 调用,避免阻塞事件循环
import asyncio
response = await asyncio.to_thread(
client.chat.completions.create,
model=settings.GLM_MODEL,
messages=[{"role": "user", "content": prompt}],
thinking={"type": "disabled"},

View File

@@ -398,18 +398,23 @@ class LipSyncService:
raise e
async def _local_generate_subprocess(self, video_path: str, audio_path: str, output_path: str) -> str:
"""原有的 subprocess 逻辑提取为独立方法"""
logger.info("🔄 调用 LatentSync 推理 (subprocess)...")
# ... (此处仅为占位符提示,实际代码需要调整结构以避免重复,
# 但鉴于原有 _local_generate 的结构,最简单的方法是在 _local_generate 内部做判断,
# 如果 use_server 失败,可以 retry 或者 _local_generate 不做拆分,直接在里面写逻辑)
# 为了最小化改动且保持安全,上面的 _call_persistent_server 如果失败,
# 最好不要自动回退(可能导致双重资源消耗),而是直接报错让用户检查服务。
# 但为了用户体验,我们可以允许回退。
# *修正策略*:
# 我将不拆分 _local_generate_subprocess而是将 subprocess 逻辑保留在 _local_generate 的后半部分。
# 如果 self.use_server 为 True先尝试调用 server成功则 return失败则继续往下走。
pass
"""
原有的 subprocess 回退逻辑
注意subprocess 回退已被禁用,原因如下:
1. subprocess 模式需要重新加载模型,消耗大量时间和显存
2. 如果常驻服务不可用,应该让用户知道并修复服务,而非静默回退
3. 避免双重资源消耗导致的 GPU OOM
如果常驻服务不可用,请检查:
- 服务是否启动: python scripts/server.py (在 models/LatentSync 目录)
- 端口是否被占用: lsof -i:8007
- GPU 显存是否充足: nvidia-smi
"""
raise RuntimeError(
"LatentSync 常驻服务不可用,无法进行唇形同步。"
"请确保 LatentSync 服务已启动 (cd models/LatentSync && python scripts/server.py)"
)
async def _remote_generate(
self,

View File

@@ -52,13 +52,21 @@ class RemotionService:
输出视频路径
"""
# 构建命令参数
cmd = [
"npx", "ts-node", "render.ts",
# 优先使用预编译的 JS 文件(更快),如果不存在则回退到 ts-node
compiled_js = self.remotion_dir / "dist" / "render.js"
if compiled_js.exists():
cmd = ["node", "dist/render.js"]
logger.info("Using pre-compiled render.js for faster startup")
else:
cmd = ["npx", "ts-node", "render.ts"]
logger.warning("Using ts-node (slower). Run 'npm run build:render' to compile for faster startup.")
cmd.extend([
"--video", str(video_path),
"--output", str(output_path),
"--fps", str(fps),
"--enableSubtitles", str(enable_subtitles).lower()
]
])
if captions_path:
cmd.extend(["--captions", str(captions_path)])

View File

@@ -7,9 +7,12 @@ from pathlib import Path
import asyncio
import functools
import os
import shutil
# Supabase Storage 本地存储根目录
SUPABASE_STORAGE_LOCAL_PATH = Path("/home/rongye/ProgramFiles/Supabase/volumes/storage/stub/stub")
# Supabase Storage 本地存储根目录(从环境变量读取,支持不同部署环境)
SUPABASE_STORAGE_LOCAL_PATH = Path(
os.getenv("SUPABASE_STORAGE_LOCAL_PATH", "/home/rongye/ProgramFiles/Supabase/volumes/storage/stub/stub")
)
class StorageService:
def __init__(self):
@@ -100,6 +103,45 @@ class StorageService:
logger.error(f"Storage upload failed: {e}")
raise e
async def upload_file_from_path(self, bucket: str, storage_path: str, local_file_path: str, content_type: str) -> str:
"""
从本地文件路径上传文件到 Supabase Storage
使用分块读取减少内存峰值,避免大文件整读入内存
Args:
bucket: 存储桶名称
storage_path: Storage 中的目标路径
local_file_path: 本地文件的绝对路径
content_type: MIME 类型
"""
local_file = Path(local_file_path)
if not local_file.exists():
raise FileNotFoundError(f"本地文件不存在: {local_file_path}")
loop = asyncio.get_running_loop()
file_size = local_file.stat().st_size
# 分块读取文件,避免大文件整读入内存
# 虽然最终还是需要拼接成 bytes 传给 SDK但分块读取可以减少 IO 压力
def read_file_chunked():
chunks = []
chunk_size = 10 * 1024 * 1024 # 10MB per chunk
with open(local_file_path, "rb") as f:
while True:
chunk = f.read(chunk_size)
if not chunk:
break
chunks.append(chunk)
return b"".join(chunks)
if file_size > 50 * 1024 * 1024: # 大于 50MB 记录日志
logger.info(f"大文件上传: {file_size / 1024 / 1024:.1f}MB")
file_data = await loop.run_in_executor(None, read_file_chunked)
return await self.upload_file(bucket, storage_path, file_data, content_type)
async def get_signed_url(self, bucket: str, path: str, expires_in: int = 3600) -> str:
"""异步获取签名访问链接"""
try:
@@ -139,8 +181,8 @@ class StorageService:
logger.error(f"Get public URL failed: {e}")
return ""
async def delete_file(self, bucket: str, path: str):
"""异步删除文件"""
async def delete_file(self, bucket: str, path: str):
"""异步删除文件"""
try:
loop = asyncio.get_running_loop()
await loop.run_in_executor(
@@ -149,21 +191,21 @@ class StorageService:
)
logger.info(f"Deleted file: {bucket}/{path}")
except Exception as e:
logger.error(f"Delete file failed: {e}")
pass
async def move_file(self, bucket: str, from_path: str, to_path: str):
"""异步移动/重命名文件"""
try:
loop = asyncio.get_running_loop()
await loop.run_in_executor(
None,
lambda: self.supabase.storage.from_(bucket).move(from_path, to_path)
)
logger.info(f"Moved file: {bucket}/{from_path} -> {to_path}")
except Exception as e:
logger.error(f"Move file failed: {e}")
raise e
logger.error(f"Delete file failed: {e}")
pass
async def move_file(self, bucket: str, from_path: str, to_path: str):
"""异步移动/重命名文件"""
try:
loop = asyncio.get_running_loop()
await loop.run_in_executor(
None,
lambda: self.supabase.storage.from_(bucket).move(from_path, to_path)
)
logger.info(f"Moved file: {bucket}/{from_path} -> {to_path}")
except Exception as e:
logger.error(f"Move file failed: {e}")
raise e
async def list_files(self, bucket: str, path: str) -> List[Any]:
"""异步列出文件"""

View File

@@ -1,10 +1,10 @@
"""
视频合成服务
"""
import os
import subprocess
import json
import shlex
import os
import subprocess
import json
import shlex
from pathlib import Path
from loguru import logger
from typing import Optional
@@ -13,18 +13,18 @@ class VideoService:
def __init__(self):
pass
def _run_ffmpeg(self, cmd: list) -> bool:
cmd_str = ' '.join(shlex.quote(str(c)) for c in cmd)
logger.debug(f"FFmpeg CMD: {cmd_str}")
try:
# Synchronous call for BackgroundTasks compatibility
result = subprocess.run(
cmd,
shell=False,
capture_output=True,
text=True,
encoding='utf-8',
)
def _run_ffmpeg(self, cmd: list) -> bool:
cmd_str = ' '.join(shlex.quote(str(c)) for c in cmd)
logger.debug(f"FFmpeg CMD: {cmd_str}")
try:
# Synchronous call for BackgroundTasks compatibility
result = subprocess.run(
cmd,
shell=False,
capture_output=True,
text=True,
encoding='utf-8',
)
if result.returncode != 0:
logger.error(f"FFmpeg Error: {result.stderr}")
return False
@@ -33,51 +33,56 @@ class VideoService:
logger.error(f"FFmpeg Exception: {e}")
return False
def _get_duration(self, file_path: str) -> float:
# Synchronous call for BackgroundTasks compatibility
cmd = f'ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "{file_path}"'
def _get_duration(self, file_path: str) -> float:
# Synchronous call for BackgroundTasks compatibility
# 使用参数列表形式避免 shell=True 的命令注入风险
cmd = [
'ffprobe', '-v', 'error',
'-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1',
file_path
]
try:
result = subprocess.run(
cmd,
shell=True,
capture_output=True,
text=True,
)
return float(result.stdout.strip())
except Exception:
return 0.0
def mix_audio(
self,
voice_path: str,
bgm_path: str,
output_path: str,
bgm_volume: float = 0.2
) -> str:
"""混合人声与背景音乐"""
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
volume = max(0.0, min(float(bgm_volume), 1.0))
filter_complex = (
f"[0:a]volume=1.0[a0];"
f"[1:a]volume={volume}[a1];"
f"[a0][a1]amix=inputs=2:duration=first:dropout_transition=2:normalize=0[aout]"
)
cmd = [
"ffmpeg", "-y",
"-i", voice_path,
"-stream_loop", "-1", "-i", bgm_path,
"-filter_complex", filter_complex,
"-map", "[aout]",
"-c:a", "pcm_s16le",
"-shortest",
output_path,
]
if self._run_ffmpeg(cmd):
return output_path
raise RuntimeError("FFmpeg audio mix failed")
return 0.0
def mix_audio(
self,
voice_path: str,
bgm_path: str,
output_path: str,
bgm_volume: float = 0.2
) -> str:
"""混合人声与背景音乐"""
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
volume = max(0.0, min(float(bgm_volume), 1.0))
filter_complex = (
f"[0:a]volume=1.0[a0];"
f"[1:a]volume={volume}[a1];"
f"[a0][a1]amix=inputs=2:duration=first:dropout_transition=2:normalize=0[aout]"
)
cmd = [
"ffmpeg", "-y",
"-i", voice_path,
"-stream_loop", "-1", "-i", bgm_path,
"-filter_complex", filter_complex,
"-map", "[aout]",
"-c:a", "pcm_s16le",
"-shortest",
output_path,
]
if self._run_ffmpeg(cmd):
return output_path
raise RuntimeError("FFmpeg audio mix failed")
async def compose(
self,

View File

@@ -3,15 +3,8 @@
import { createContext, useContext, useState, useEffect, ReactNode } from "react";
import api from "@/shared/api/axios";
import { ApiResponse, unwrap } from "@/shared/api/types";
import { User } from "@/shared/types/user";
interface User {
id: string;
phone: string;
username: string | null;
role: string;
is_active: boolean;
expires_at: string | null;
}
interface AuthContextType {
userId: string | null;

View File

@@ -1,20 +1,15 @@
/**
* 认证工具函数
*/
const API_BASE = typeof window === 'undefined'
? (process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8006')
: '';
export interface User {
id: string;
phone: string;
username: string | null;
role: string;
is_active: boolean;
expires_at: string | null;
}
/**
* 认证工具函数
*/
import { User } from "@/shared/types/user";
// Re-export User 类型以保持向后兼容
export type { User };
const API_BASE = typeof window === 'undefined'
? (process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8006')
: '';
export interface AuthResponse {
success: boolean;
message: string;
@@ -27,10 +22,10 @@ interface ApiResponse<T> {
data: T;
code: number;
}
/**
* 用户注册
*/
/**
* 用户注册
*/
export async function register(phone: string, password: string, username?: string): Promise<AuthResponse> {
const res = await fetch(`${API_BASE}/api/auth/register`, {
method: 'POST',
@@ -42,10 +37,10 @@ export async function register(phone: string, password: string, username?: strin
const data = payload as ApiResponse<null>;
return { success: data.success, message: data.message };
}
/**
* 用户登录
*/
/**
* 用户登录
*/
export async function login(phone: string, password: string): Promise<AuthResponse> {
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: 'POST',
@@ -57,10 +52,10 @@ export async function login(phone: string, password: string): Promise<AuthRespon
const data = payload as ApiResponse<{ user?: User }>;
return { success: data.success, message: data.message, user: data.data?.user };
}
/**
* 用户登出
*/
/**
* 用户登出
*/
export async function logout(): Promise<AuthResponse> {
const res = await fetch(`${API_BASE}/api/auth/logout`, {
method: 'POST',
@@ -70,10 +65,10 @@ export async function logout(): Promise<AuthResponse> {
const data = payload as ApiResponse<null>;
return { success: data.success, message: data.message };
}
/**
* 修改密码
*/
/**
* 修改密码
*/
export async function changePassword(oldPassword: string, newPassword: string): Promise<AuthResponse> {
const res = await fetch(`${API_BASE}/api/auth/change-password`, {
method: 'POST',
@@ -85,10 +80,10 @@ export async function changePassword(oldPassword: string, newPassword: string):
const data = payload as ApiResponse<null>;
return { success: data.success, message: data.message };
}
/**
* 获取当前用户
*/
/**
* 获取当前用户
*/
export async function getCurrentUser(): Promise<User | null> {
try {
const res = await fetch(`${API_BASE}/api/auth/me`, {
@@ -102,19 +97,19 @@ export async function getCurrentUser(): Promise<User | null> {
return null;
}
}
/**
* 检查是否已登录
*/
export async function isAuthenticated(): Promise<boolean> {
const user = await getCurrentUser();
return user !== null;
}
/**
* 检查是否是管理员
*/
export async function isAdmin(): Promise<boolean> {
const user = await getCurrentUser();
return user?.role === 'admin';
}
/**
* 检查是否已登录
*/
export async function isAuthenticated(): Promise<boolean> {
const user = await getCurrentUser();
return user !== null;
}
/**
* 检查是否是管理员
*/
export async function isAdmin(): Promise<boolean> {
const user = await getCurrentUser();
return user?.role === 'admin';
}

View File

@@ -0,0 +1,13 @@
/**
* 用户类型定义
* 统一管理用户相关类型,避免重复定义
*/
export interface User {
id: string;
phone: string;
username: string | null;
role: string;
is_active: boolean;
expires_at: string | null;
}

View File

@@ -65,14 +65,15 @@ async def lifespan(app: FastAPI):
# --- 模型加载逻辑 (参考 inference.py) ---
print("⏳ 正在加载 LatentSync 模型...")
# 默认配置路径 (相对于根目录)
unet_config_path = "configs/unet/stage2_512.yaml"
ckpt_path = "checkpoints/latentsync_unet.pt"
# 使用绝对路径,确保可以从任意目录启动
latentsync_root = Path(__file__).resolve().parent.parent # scripts -> LatentSync 根目录
unet_config_path = latentsync_root / "configs" / "unet" / "stage2_512.yaml"
ckpt_path = latentsync_root / "checkpoints" / "latentsync_unet.pt"
if not os.path.exists(unet_config_path):
print(f"⚠️ 找不到配置文件: {unet_config_path},请确保在 models/LatentSync 根目录运行")
if not unet_config_path.exists():
print(f"⚠️ 找不到配置文件: {unet_config_path}")
config = OmegaConf.load(unet_config_path)
config = OmegaConf.load(str(unet_config_path))
# Check GPU
is_fp16_supported = torch.cuda.is_available() and torch.cuda.get_device_capability()[0] > 7
@@ -85,13 +86,13 @@ async def lifespan(app: FastAPI):
else:
print("⚠️ 警告: 未检测到 GPU将使用 CPU 进行推理 (速度极慢)")
scheduler = DDIMScheduler.from_pretrained("configs")
scheduler = DDIMScheduler.from_pretrained(str(latentsync_root / "configs"))
# Whisper Model
if config.model.cross_attention_dim == 768:
whisper_path = "checkpoints/whisper/small.pt"
whisper_path = str(latentsync_root / "checkpoints" / "whisper" / "small.pt")
else:
whisper_path = "checkpoints/whisper/tiny.pt"
whisper_path = str(latentsync_root / "checkpoints" / "whisper" / "tiny.pt")
audio_encoder = Audio2Feature(
model_path=whisper_path,
@@ -108,7 +109,7 @@ async def lifespan(app: FastAPI):
# UNet
unet, _ = UNet3DConditionModel.from_pretrained(
OmegaConf.to_container(config.model),
ckpt_path,
str(ckpt_path),
device="cpu", # Load to CPU first to save memory during init
)
unet = unet.to(dtype=dtype)
@@ -129,6 +130,7 @@ async def lifespan(app: FastAPI):
models["pipeline"] = pipeline
models["config"] = config
models["dtype"] = dtype
models["latentsync_root"] = latentsync_root
print("✅ LatentSync 模型加载完成,服务就绪!")
yield
@@ -167,6 +169,7 @@ async def generate_lipsync(req: LipSyncRequest):
pipeline = models["pipeline"]
config = models["config"]
dtype = models["dtype"]
latentsync_root = models["latentsync_root"]
# Set seed
if req.seed != -1:
@@ -185,7 +188,7 @@ async def generate_lipsync(req: LipSyncRequest):
weight_dtype=dtype,
width=config.data.resolution,
height=config.data.resolution,
mask_image_path=config.data.mask_image_path,
mask_image_path=str(latentsync_root / config.data.mask_image_path),
temp_dir=req.temp_dir,
)

View File

@@ -5,7 +5,9 @@
"scripts": {
"start": "remotion studio",
"build": "remotion bundle",
"render": "npx ts-node render.ts"
"build:render": "npx tsc render.ts --outDir dist --esModuleInterop --skipLibCheck",
"render": "npx ts-node render.ts",
"render:fast": "node dist/render.js"
},
"dependencies": {
"remotion": "^4.0.0",

View File

@@ -16,6 +16,8 @@ interface RenderOptions {
captionsPath?: string;
title?: string;
titleDuration?: number;
subtitleStyle?: Record<string, unknown>;
titleStyle?: Record<string, unknown>;
outputPath: string;
fps?: number;
enableSubtitles?: boolean;
@@ -53,6 +55,20 @@ async function parseArgs(): Promise<RenderOptions> {
case 'enableSubtitles':
options.enableSubtitles = value === 'true';
break;
case 'subtitleStyle':
try {
options.subtitleStyle = JSON.parse(value);
} catch (e) {
console.warn('Invalid subtitleStyle JSON');
}
break;
case 'titleStyle':
try {
options.titleStyle = JSON.parse(value);
} catch (e) {
console.warn('Invalid titleStyle JSON');
}
break;
}
}
@@ -84,20 +100,22 @@ async function main() {
let videoWidth = 1280;
let videoHeight = 720;
try {
// 使用 ffprobe 获取视频时长
const { execSync } = require('child_process');
const ffprobeOutput = execSync(
`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${options.videoPath}"`,
{ encoding: 'utf-8' }
// 使用 promisified exec 异步获取视频信息,避免阻塞主线程
const { promisify } = require('util');
const { exec } = require('child_process');
const execAsync = promisify(exec);
// 获取视频时长
const { stdout: durationOutput } = await execAsync(
`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${options.videoPath}"`
);
const durationInSeconds = parseFloat(ffprobeOutput.trim());
const durationInSeconds = parseFloat(durationOutput.trim());
durationInFrames = Math.ceil(durationInSeconds * fps);
console.log(`Video duration: ${durationInSeconds}s (${durationInFrames} frames at ${fps}fps)`);
// 使用 ffprobe 获取视频尺寸
const dimensionsOutput = execSync(
`ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=s=x:p=0 "${options.videoPath}"`,
{ encoding: 'utf-8' }
// 获取视频尺寸
const { stdout: dimensionsOutput } = await execAsync(
`ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=s=x:p=0 "${options.videoPath}"`
);
const [width, height] = dimensionsOutput.trim().split('x').map(Number);
if (width && height) {
@@ -131,6 +149,8 @@ async function main() {
captions,
title: options.title,
titleDuration: options.titleDuration || 3,
subtitleStyle: options.subtitleStyle,
titleStyle: options.titleStyle,
enableSubtitles: options.enableSubtitles !== false,
},
});
@@ -153,6 +173,8 @@ async function main() {
captions,
title: options.title,
titleDuration: options.titleDuration || 3,
subtitleStyle: options.subtitleStyle,
titleStyle: options.titleStyle,
enableSubtitles: options.enableSubtitles !== false,
},
onProgress: ({ progress }) => {

View File

@@ -1,8 +1,8 @@
import React from 'react';
import { AbsoluteFill, Composition } from 'remotion';
import { VideoLayer } from './components/VideoLayer';
import { Title } from './components/Title';
import { Subtitles } from './components/Subtitles';
import { Title, TitleStyle } from './components/Title';
import { Subtitles, SubtitleStyle } from './components/Subtitles';
import { CaptionsData } from './utils/captions';
export interface VideoProps {
@@ -12,6 +12,8 @@ export interface VideoProps {
title?: string;
titleDuration?: number;
enableSubtitles?: boolean;
subtitleStyle?: SubtitleStyle;
titleStyle?: TitleStyle;
}
/**
@@ -25,6 +27,8 @@ export const Video: React.FC<VideoProps> = ({
title,
titleDuration = 3,
enableSubtitles = true,
subtitleStyle,
titleStyle,
}) => {
return (
<AbsoluteFill style={{ backgroundColor: 'black' }}>
@@ -33,12 +37,12 @@ export const Video: React.FC<VideoProps> = ({
{/* 中层:字幕 */}
{enableSubtitles && captions && (
<Subtitles captions={captions} />
<Subtitles captions={captions} style={subtitleStyle} />
)}
{/* 顶层:标题 */}
{title && (
<Title title={title} duration={titleDuration} />
<Title title={title} duration={titleDuration} style={titleStyle} />
)}
</AbsoluteFill>
);

View File

@@ -1,28 +1,59 @@
import React from 'react';
import { AbsoluteFill, useCurrentFrame, useVideoConfig } from 'remotion';
import { AbsoluteFill, useCurrentFrame, useVideoConfig, staticFile } from 'remotion';
import {
CaptionsData,
getCurrentSegment,
getCurrentWordIndex,
} from '../utils/captions';
export interface SubtitleStyle {
font_file?: string;
fontFamily?: string;
font_family?: string;
fontSize?: number;
font_size?: number;
highlightColor?: string;
highlight_color?: string;
normalColor?: string;
normal_color?: string;
strokeColor?: string;
stroke_color?: string;
strokeSize?: number;
stroke_size?: number;
letterSpacing?: number;
letter_spacing?: number;
bottomMargin?: number;
bottom_margin?: number;
}
interface SubtitlesProps {
captions: CaptionsData;
highlightColor?: string;
normalColor?: string;
fontSize?: number;
style?: SubtitleStyle;
}
/**
* 逐字高亮字幕组件
* 根据时间戳逐字高亮显示字幕(无背景,纯文字描边)
*/
export const Subtitles: React.FC<SubtitlesProps> = ({
captions,
highlightColor = '#FFFF00',
normalColor = '#FFFFFF',
fontSize = 52,
}) => {
const getFontFormat = (fontFile?: string) => {
if (!fontFile) return 'truetype';
const ext = fontFile.split('.').pop()?.toLowerCase();
if (ext === 'otf') return 'opentype';
return 'truetype';
};
const buildTextShadow = (color: string, size: number) => {
return [
`-${size}px -${size}px 0 ${color}`,
`${size}px -${size}px 0 ${color}`,
`-${size}px ${size}px 0 ${color}`,
`${size}px ${size}px 0 ${color}`,
`0 0 ${size * 4}px rgba(0,0,0,0.9)`,
`0 4px 8px rgba(0,0,0,0.6)`
].join(',');
};
export const Subtitles: React.FC<SubtitlesProps> = ({ captions, style }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
@@ -38,45 +69,62 @@ export const Subtitles: React.FC<SubtitlesProps> = ({
// 获取当前高亮字的索引
const currentWordIndex = getCurrentWordIndex(currentSegment, currentTimeInSeconds);
const fontFile = style?.font_file;
const fontFamily = style?.fontFamily || style?.font_family;
const fontSize = style?.fontSize || style?.font_size || 52;
const highlightColor = style?.highlightColor || style?.highlight_color || '#FFFF00';
const normalColor = style?.normalColor || style?.normal_color || '#FFFFFF';
const strokeColor = style?.strokeColor || style?.stroke_color || '#000000';
const strokeSize = style?.strokeSize || style?.stroke_size || 3;
const letterSpacing = style?.letterSpacing || style?.letter_spacing || 2;
const bottomMargin = style?.bottomMargin || style?.bottom_margin;
const fontFamilyName = fontFamily || 'SubtitleFont';
const fontFamilyCss = fontFile
? `'${fontFamilyName}'`
: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif';
return (
<AbsoluteFill
style={{
justifyContent: 'flex-end',
alignItems: 'center',
paddingBottom: '6%',
paddingBottom: typeof bottomMargin === 'number' ? `${bottomMargin}px` : '6%',
}}
>
{fontFile && (
<style>{`
@font-face {
font-family: '${fontFamilyName}';
src: url('${staticFile(fontFile)}') format('${getFontFormat(fontFile)}');
font-weight: 400;
font-style: normal;
}
`}</style>
)}
<p
style={{
margin: 0,
fontSize: `${fontSize}px`,
fontFamily: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif',
fontFamily: fontFamilyCss,
fontWeight: 800,
lineHeight: 1.4,
textAlign: 'center',
maxWidth: '90%',
wordBreak: 'keep-all',
letterSpacing: '2px',
letterSpacing: `${letterSpacing}px`,
}}
>
{currentSegment.words.map((word, index) => {
const isHighlighted = index <= currentWordIndex;
return (
<span
key={`${word.word}-${index}`}
style={{
color: isHighlighted ? highlightColor : normalColor,
textShadow: `
-3px -3px 0 #000,
3px -3px 0 #000,
-3px 3px 0 #000,
3px 3px 0 #000,
0 0 12px rgba(0,0,0,0.9),
0 4px 8px rgba(0,0,0,0.6)
`,
transition: 'color 0.05s ease',
}}
>
key={`${word.word}-${index}`}
style={{
color: isHighlighted ? highlightColor : normalColor,
textShadow: buildTextShadow(strokeColor, strokeSize),
transition: 'color 0.05s ease',
}}
>
{word.word}
</span>
);

View File

@@ -4,22 +4,62 @@ import {
interpolate,
useCurrentFrame,
useVideoConfig,
staticFile,
} from 'remotion';
export interface TitleStyle {
font_file?: string;
fontFamily?: string;
font_family?: string;
fontSize?: number;
font_size?: number;
color?: string;
strokeColor?: string;
stroke_color?: string;
strokeSize?: number;
stroke_size?: number;
letterSpacing?: number;
letter_spacing?: number;
topMargin?: number;
top_margin?: number;
fontWeight?: number;
font_weight?: number;
}
interface TitleProps {
title: string;
duration?: number; // 标题显示时长(秒)
fadeOutStart?: number; // 开始淡出的时间(秒)
style?: TitleStyle;
}
/**
* 片头标题组件
* 在视频顶部显示标题,带淡入淡出效果
*/
const getFontFormat = (fontFile?: string) => {
if (!fontFile) return 'truetype';
const ext = fontFile.split('.').pop()?.toLowerCase();
if (ext === 'otf') return 'opentype';
return 'truetype';
};
const buildTextShadow = (color: string, size: number) => {
return [
`-${size}px -${size}px 0 ${color}`,
`${size}px -${size}px 0 ${color}`,
`-${size}px ${size}px 0 ${color}`,
`${size}px ${size}px 0 ${color}`,
`0 0 ${size * 2}px rgba(0,0,0,0.7)`,
`0 4px 8px rgba(0,0,0,0.6)`
].join(',');
};
export const Title: React.FC<TitleProps> = ({
title,
duration = 3,
fadeOutStart = 2,
style,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
@@ -57,33 +97,52 @@ export const Title: React.FC<TitleProps> = ({
{ extrapolateRight: 'clamp' }
);
const fontFile = style?.font_file;
const fontFamily = style?.fontFamily || style?.font_family;
const fontSize = style?.fontSize || style?.font_size || 72;
const color = style?.color || '#FFFFFF';
const strokeColor = style?.strokeColor || style?.stroke_color || '#000000';
const strokeSize = style?.strokeSize || style?.stroke_size || 8;
const letterSpacing = style?.letterSpacing || style?.letter_spacing || 4;
const topMargin = style?.topMargin || style?.top_margin;
const fontWeight = style?.fontWeight || style?.font_weight || 900;
const fontFamilyName = fontFamily || 'TitleFont';
const fontFamilyCss = fontFile
? `'${fontFamilyName}'`
: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif';
return (
<AbsoluteFill
style={{
justifyContent: 'flex-start',
alignItems: 'center',
paddingTop: '6%',
paddingTop: typeof topMargin === 'number' ? `${topMargin}px` : '6%',
opacity,
}}
>
{fontFile && (
<style>{`
@font-face {
font-family: '${fontFamilyName}';
src: url('${staticFile(fontFile)}') format('${getFontFormat(fontFile)}');
font-weight: 400;
font-style: normal;
}
`}</style>
)}
<h1
style={{
transform: `translateY(${translateY}px)`,
textAlign: 'center',
color: '#FFFFFF',
fontSize: '72px',
fontWeight: 900,
fontFamily: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif',
textShadow: `
0 0 10px rgba(0,0,0,0.9),
0 0 20px rgba(0,0,0,0.7),
0 4px 8px rgba(0,0,0,0.8),
0 8px 16px rgba(0,0,0,0.5)
`,
color,
fontSize: `${fontSize}px`,
fontWeight,
fontFamily: fontFamilyCss,
textShadow: buildTextShadow(strokeColor, strokeSize),
margin: 0,
padding: '0 5%',
lineHeight: 1.3,
letterSpacing: '4px',
letterSpacing: `${letterSpacing}px`,
}}
>
{title}