From 1e52346eb48919a4def06b39ddd45133bdc6aa53 Mon Sep 17 00:00:00 2001 From: Kevin Wong Date: Sat, 7 Feb 2026 14:29:57 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Docs/BACKEND_DEV.md | 3 + Docs/DEPLOY_MANUAL.md | 149 +++++++++++----------- Docs/DevLogs/Day20.md | 66 ++++++++++ Docs/Doc_Rules.md | 121 ++++++++++-------- Docs/FRONTEND_DEV.md | 6 + Docs/SUBTITLE_DEPLOY.md | 3 + Docs/implementation_plan.md | 93 ++++++++------ Docs/task_complete.md | 117 +++++++++-------- backend/.env.example | 4 + backend/app/core/config.py | 84 ++++++------ backend/app/main.py | 156 +++++++++++++---------- backend/app/modules/ref_audios/router.py | 31 +++-- backend/app/modules/tools/router.py | 48 ++++--- backend/app/modules/videos/router.py | 15 ++- backend/app/modules/videos/workflow.py | 14 +- backend/app/services/glm_service.py | 10 +- backend/app/services/lipsync_service.py | 29 +++-- backend/app/services/remotion_service.py | 14 +- backend/app/services/storage.py | 80 +++++++++--- backend/app/services/video_service.py | 111 ++++++++-------- frontend/src/contexts/AuthContext.tsx | 9 +- frontend/src/shared/lib/auth.ts | 101 +++++++-------- frontend/src/shared/types/user.ts | 13 ++ models/LatentSync/scripts/server.py | 25 ++-- remotion/package.json | 4 +- remotion/render.ts | 42 ++++-- remotion/src/Video.tsx | 12 +- remotion/src/components/Subtitles.tsx | 102 +++++++++++---- remotion/src/components/Title.tsx | 83 ++++++++++-- 29 files changed, 955 insertions(+), 590 deletions(-) create mode 100644 Docs/DevLogs/Day20.md create mode 100644 frontend/src/shared/types/user.ts diff --git a/Docs/BACKEND_DEV.md b/Docs/BACKEND_DEV.md index 5039aa5..5994777 100644 --- a/Docs/BACKEND_DEV.md +++ b/Docs/BACKEND_DEV.md @@ -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) --- diff --git a/Docs/DEPLOY_MANUAL.md b/Docs/DEPLOY_MANUAL.md index 7c3a8f8..2aadcc5 100644 --- a/Docs/DEPLOY_MANUAL.md +++ b/Docs/DEPLOY_MANUAL.md @@ -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) diff --git a/Docs/DevLogs/Day20.md b/Docs/DevLogs/Day20.md new file mode 100644 index 0000000..9d82d0a --- /dev/null +++ b/Docs/DevLogs/Day20.md @@ -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 已自动编译 +``` diff --git a/Docs/Doc_Rules.md b/Docs/Doc_Rules.md index 096b6ed..e54f078 100644 --- a/Docs/Doc_Rules.md +++ b/Docs/Doc_Rules.md @@ -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 diff --git a/Docs/FRONTEND_DEV.md b/Docs/FRONTEND_DEV.md index 1f2b5a4..82130c1 100644 --- a/Docs/FRONTEND_DEV.md +++ b/Docs/FRONTEND_DEV.md @@ -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';`。 + --- ## 用户偏好持久化 diff --git a/Docs/SUBTITLE_DEPLOY.md b/Docs/SUBTITLE_DEPLOY.md index 106b794..7327c37 100644 --- a/Docs/SUBTITLE_DEPLOY.md +++ b/Docs/SUBTITLE_DEPLOY.md @@ -52,6 +52,9 @@ cd /home/rongye/ProgramFiles/ViGent2/remotion # 安装依赖 npm install + +# 预编译渲染脚本 (生产环境必须) +npm run build:render ``` ### 步骤 3: 重启后端服务 diff --git a/Docs/implementation_plan.md b/Docs/implementation_plan.md index b02a6a9..36bc011 100644 --- a/Docs/implementation_plan.md +++ b/Docs/implementation_plan.md @@ -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` + +--- + ## 验证计划 ### 阶段一验证 diff --git a/Docs/task_complete.md b/Docs/task_complete.md index 38a1ab9..77e9166 100644 --- a/Docs/task_complete.md +++ b/Docs/task_complete.md @@ -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 Hook,Page 仅组合渲染。 -- [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 Hook,Page 仅组合渲染。 +- [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) diff --git a/backend/.env.example b/backend/.env.example index 3b20391..f410b07 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 diff --git a/backend/app/core/config.py b/backend/app/core/config.py index edc37e0..06a8be1 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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 目录路径 (动态计算)""" diff --git a/backend/app/main.py b/backend/app/main.py index 8644f89..0d868ad 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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: diff --git a/backend/app/modules/ref_audios/router.py b/backend/app/modules/ref_audios/router.py index 790c597..8f57129 100644 --- a/backend/app/modules/ref_audios/router.py +++ b/backend/app/modules/ref_audios/router.py @@ -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()) diff --git a/backend/app/modules/tools/router.py b/backend/app/modules/tools/router.py index 5b6d478..59d3de8 100644 --- a/backend/app/modules/tools/router.py +++ b/backend/app/modules/tools/router.py @@ -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'', 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}") diff --git a/backend/app/modules/videos/router.py b/backend/app/modules/videos/router.py index 3901b75..781852e 100644 --- a/backend/app/modules/videos/router.py +++ b/backend/app/modules/videos/router.py @@ -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") diff --git a/backend/app/modules/videos/workflow.py b/backend/app/modules/videos/workflow.py index 8b67620..68e94a9 100644 --- a/backend/app/modules/videos/workflow.py +++ b/backend/app/modules/videos/workflow.py @@ -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, diff --git a/backend/app/services/glm_service.py b/backend/app/services/glm_service.py index e1887e5..05a2e5e 100644 --- a/backend/app/services/glm_service.py +++ b/backend/app/services/glm_service.py @@ -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"}, diff --git a/backend/app/services/lipsync_service.py b/backend/app/services/lipsync_service.py index 68ad2d4..189de32 100644 --- a/backend/app/services/lipsync_service.py +++ b/backend/app/services/lipsync_service.py @@ -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, diff --git a/backend/app/services/remotion_service.py b/backend/app/services/remotion_service.py index ca1462a..bfc5730 100644 --- a/backend/app/services/remotion_service.py +++ b/backend/app/services/remotion_service.py @@ -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)]) diff --git a/backend/app/services/storage.py b/backend/app/services/storage.py index 54c214c..c5edc82 100644 --- a/backend/app/services/storage.py +++ b/backend/app/services/storage.py @@ -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]: """异步列出文件""" diff --git a/backend/app/services/video_service.py b/backend/app/services/video_service.py index 5b43645..f098225 100644 --- a/backend/app/services/video_service.py +++ b/backend/app/services/video_service.py @@ -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, diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index 0c34730..5fc2df6 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -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; diff --git a/frontend/src/shared/lib/auth.ts b/frontend/src/shared/lib/auth.ts index fb66e06..f7fc6b1 100644 --- a/frontend/src/shared/lib/auth.ts +++ b/frontend/src/shared/lib/auth.ts @@ -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 { data: T; code: number; } - -/** - * 用户注册 - */ + +/** + * 用户注册 + */ export async function register(phone: string, password: string, username?: string): Promise { 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; return { success: data.success, message: data.message }; } - -/** - * 用户登录 - */ + +/** + * 用户登录 + */ export async function login(phone: string, password: string): Promise { const res = await fetch(`${API_BASE}/api/auth/login`, { method: 'POST', @@ -57,10 +52,10 @@ export async function login(phone: string, password: string): Promise; return { success: data.success, message: data.message, user: data.data?.user }; } - -/** - * 用户登出 - */ + +/** + * 用户登出 + */ export async function logout(): Promise { const res = await fetch(`${API_BASE}/api/auth/logout`, { method: 'POST', @@ -70,10 +65,10 @@ export async function logout(): Promise { const data = payload as ApiResponse; return { success: data.success, message: data.message }; } - -/** - * 修改密码 - */ + +/** + * 修改密码 + */ export async function changePassword(oldPassword: string, newPassword: string): Promise { 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; return { success: data.success, message: data.message }; } - -/** - * 获取当前用户 - */ + +/** + * 获取当前用户 + */ export async function getCurrentUser(): Promise { try { const res = await fetch(`${API_BASE}/api/auth/me`, { @@ -102,19 +97,19 @@ export async function getCurrentUser(): Promise { return null; } } - -/** - * 检查是否已登录 - */ -export async function isAuthenticated(): Promise { - const user = await getCurrentUser(); - return user !== null; -} - -/** - * 检查是否是管理员 - */ -export async function isAdmin(): Promise { - const user = await getCurrentUser(); - return user?.role === 'admin'; -} + +/** + * 检查是否已登录 + */ +export async function isAuthenticated(): Promise { + const user = await getCurrentUser(); + return user !== null; +} + +/** + * 检查是否是管理员 + */ +export async function isAdmin(): Promise { + const user = await getCurrentUser(); + return user?.role === 'admin'; +} diff --git a/frontend/src/shared/types/user.ts b/frontend/src/shared/types/user.ts new file mode 100644 index 0000000..5f44d08 --- /dev/null +++ b/frontend/src/shared/types/user.ts @@ -0,0 +1,13 @@ +/** + * 用户类型定义 + * 统一管理用户相关类型,避免重复定义 + */ + +export interface User { + id: string; + phone: string; + username: string | null; + role: string; + is_active: boolean; + expires_at: string | null; +} diff --git a/models/LatentSync/scripts/server.py b/models/LatentSync/scripts/server.py index 838bfaa..47f153d 100644 --- a/models/LatentSync/scripts/server.py +++ b/models/LatentSync/scripts/server.py @@ -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, ) diff --git a/remotion/package.json b/remotion/package.json index 4d7b503..1a9d82f 100644 --- a/remotion/package.json +++ b/remotion/package.json @@ -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", diff --git a/remotion/render.ts b/remotion/render.ts index 84626ed..8aee1e7 100644 --- a/remotion/render.ts +++ b/remotion/render.ts @@ -16,6 +16,8 @@ interface RenderOptions { captionsPath?: string; title?: string; titleDuration?: number; + subtitleStyle?: Record; + titleStyle?: Record; outputPath: string; fps?: number; enableSubtitles?: boolean; @@ -53,6 +55,20 @@ async function parseArgs(): Promise { 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 }) => { diff --git a/remotion/src/Video.tsx b/remotion/src/Video.tsx index 5efd752..a10acb1 100644 --- a/remotion/src/Video.tsx +++ b/remotion/src/Video.tsx @@ -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 = ({ title, titleDuration = 3, enableSubtitles = true, + subtitleStyle, + titleStyle, }) => { return ( @@ -33,12 +37,12 @@ export const Video: React.FC = ({ {/* 中层:字幕 */} {enableSubtitles && captions && ( - + )} {/* 顶层:标题 */} {title && ( - + <Title title={title} duration={titleDuration} style={titleStyle} /> )} </AbsoluteFill> ); diff --git a/remotion/src/components/Subtitles.tsx b/remotion/src/components/Subtitles.tsx index b54edc2..93287d2 100644 --- a/remotion/src/components/Subtitles.tsx +++ b/remotion/src/components/Subtitles.tsx @@ -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> ); diff --git a/remotion/src/components/Title.tsx b/remotion/src/components/Title.tsx index 6fd816b..08990d8 100644 --- a/remotion/src/components/Title.tsx +++ b/remotion/src/components/Title.tsx @@ -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}