Compare commits

...

7 Commits

Author SHA1 Message Date
Kevin Wong
3129d45b25 更新 2026-02-09 14:47:19 +08:00
Kevin Wong
e226224119 更新 2026-02-08 19:54:11 +08:00
Kevin Wong
ee342cc40f 更新 2026-02-08 16:23:39 +08:00
Kevin Wong
1a291a03b8 更新 2026-02-08 10:46:08 +08:00
Kevin Wong
1e52346eb4 更新 2026-02-07 14:29:57 +08:00
Kevin Wong
945262a7fc 更新 2026-02-06 16:02:58 +08:00
Kevin Wong
be6a3436bb 更新 2026-02-05 12:03:55 +08:00
128 changed files with 11114 additions and 4690 deletions

17
.gitignore vendored
View File

@@ -20,11 +20,14 @@ node_modules/
out/
.turbo/
# ============ IDE ============
# ============ IDE / AI 工具 ============
.vscode/
.idea/
*.swp
*.swo
.agents/
.opencode/
.claude/
# ============ 系统文件 ============
.DS_Store
@@ -35,11 +38,21 @@ desktop.ini
backend/outputs/
backend/uploads/
backend/cookies/
backend/user_data/
backend/debug_screenshots/
*_cookies.json
# ============ MuseTalk ============
# ============ 模型权重 ============
models/*/checkpoints/
models/MuseTalk/models/
models/MuseTalk/results/
models/LatentSync/temp/
# ============ Remotion 构建 ============
remotion/dist/
# ============ 临时文件 ============
Temp/
# ============ 日志 ============
*.log

181
Docs/BACKEND_DEV.md Normal file
View File

@@ -0,0 +1,181 @@
# ViGent2 后端开发规范
本文档定义后端开发的结构规范、接口契约与实现习惯。目标是让新功能按统一范式落地,旧逻辑在修复时逐步抽离。
---
## 1. 模块化与分层原则
每个业务功能放入 `app/modules/<feature>/`,以“薄路由 + 厚服务/流程”组织代码。
- **router.py**:只做参数校验、权限校验、调用 service/workflow、返回统一响应。
- **schemas.py**Pydantic 请求/响应模型。
- **service.py**:业务逻辑与集成逻辑(非长流程)。
- **workflow.py**:长流程/重任务编排(视频生成、渲染、异步任务)。
- **__init__.py**:模块标记。
其它层级职责:
- **repositories/**数据读写Supabase不包含业务逻辑。
- **services/**外部依赖与基础能力TTS、Storage、Remotion 等)。
- **core/**:配置、安全、依赖注入、统一响应。
---
## 2. 目录结构(当前约定)
```
backend/
├── app/
│ ├── core/ # config、deps、security、response
│ ├── modules/ # 业务模块(路由 + 逻辑)
│ │ ├── videos/ # 视频生成任务router/schemas/service/workflow
│ │ ├── materials/ # 素材管理router/schemas/service
│ │ ├── publish/ # 多平台发布
│ │ ├── auth/ # 认证与会话
│ │ ├── ai/ # AI 功能(标题标签生成等)
│ │ ├── assets/ # 静态资源(字体/样式/BGM
│ │ ├── ref_audios/ # 声音克隆参考音频router/schemas/service
│ │ ├── login_helper/ # 扫码登录辅助
│ │ ├── tools/ # 工具接口router/schemas/service
│ │ └── admin/ # 管理员功能
│ ├── repositories/ # Supabase 数据访问
│ ├── services/ # 外部服务集成
│ │ ├── uploader/ # 平台发布器douyin/weixin
│ │ ├── qr_login_service.py
│ │ ├── publish_service.py
│ │ ├── remotion_service.py
│ │ ├── storage.py
│ │ └── ...
│ └── tests/
├── assets/ # 字体 / 样式 / bgm
├── user_data/ # 用户隔离数据Cookie 等)
├── scripts/
└── requirements.txt
```
---
## 3. 接口契约规范(统一响应)
所有 JSON API 返回统一结构:
```json
{
"success": true,
"message": "ok",
"data": { },
"code": 0
}
```
- 正常响应使用 `success_response`
- 错误通过 `HTTPException` 抛出,统一由全局异常处理返回 `{success:false, message, code}`
- 不再使用 `detail` 作为前端错误文案(前端已改为读 `message`)。
---
## 4. 认证与权限
- 认证方式:**HttpOnly Cookie** (`access_token`)。
- `get_current_user` / `get_current_user_optional` 位于 `core/deps.py`
- Session 单设备校验使用 `repositories/sessions.py`
---
## 5. 任务与状态
- 视频生成任务通过 `modules/videos/workflow.py` 统一编排。
- 任务状态通过 `modules/videos/task_store.py` 读写,**不要直接维护全局 dict**。
- 默认使用 Redis`REDIS_URL`),不可用自动回退内存。
---
## 6. 文件与存储
- 所有文件上传/下载/删除/移动通过 `services/storage.py`
- 需要重命名时使用 `move_file`,避免直接读写 Storage。
### Cookie 存储(用户隔离)
多平台扫码登录产生的 Cookie 按用户隔离存储:
```
backend/user_data/{user_uuid}/cookies/
├── douyin_cookies.json
├── weixin_cookies.json
└── ...
```
- `publish_service.py` 中通过 `_get_cookies_dir(user_id)` / `_get_cookie_path(user_id, platform)` 定位
- 会话 key 格式:`"{user_id}_{platform}"`,确保多用户并发登录互不干扰
- 登录成功后 Cookie 自动保存到对应路径,发布时自动加载
---
## 7. 代码约定
- 只在 router 做校验与响应拼装。
- 业务逻辑写在 service/workflow。
- 数据库访问写在 repositories。
- 统一使用 `loguru` 打日志。
---
## 8. 开发流程建议
- **新增功能**:先建模块,**必须**包含 `router.py + schemas.py + service.py`,不允许 router-only。
- **修复 Bug**:顺手把涉及的逻辑抽到对应 service/workflow渐进式改造
- **改旧模块**:改动哪部分就拆哪部分,不要求一次重构整个文件。
- **核心流程变更**:必跑冒烟(登录/生成/发布)。
> **渐进原则**:新代码高标准,旧代码逐步改。不做大规模一次性重构,避免引入回归风险。
---
## 9. 常用环境变量
- `SUPABASE_URL` / `SUPABASE_KEY`
- `SUPABASE_PUBLIC_URL`
- `REDIS_URL`
- `GLM_API_KEY`
- `LATENTSYNC_*`
- `CORS_ORIGINS` (CORS 白名单,默认 *)
### 微信视频号
- `WEIXIN_HEADLESS_MODE` (headful/headless-new)
- `WEIXIN_CHROME_PATH` / `WEIXIN_BROWSER_CHANNEL`
- `WEIXIN_USER_AGENT` / `WEIXIN_LOCALE` / `WEIXIN_TIMEZONE_ID`
- `WEIXIN_FORCE_SWIFTSHADER`
- `WEIXIN_TRANSCODE_MODE` (reencode/faststart/off)
### 抖音
- `DOUYIN_HEADLESS_MODE` (headful/headless-new默认 headless-new)
- `DOUYIN_CHROME_PATH` / `DOUYIN_BROWSER_CHANNEL`
- `DOUYIN_USER_AGENT` (默认 Chrome/144)
- `DOUYIN_LOCALE` / `DOUYIN_TIMEZONE_ID`
- `DOUYIN_FORCE_SWIFTSHADER`
- `DOUYIN_DEBUG_ARTIFACTS` / `DOUYIN_RECORD_VIDEO` / `DOUYIN_KEEP_SUCCESS_VIDEO`
- `DOUYIN_COOKIE` (抖音视频下载 Cookie)
---
## 10. Playwright 发布调试
- 诊断日志落盘:`backend/app/debug_screenshots/weixin_network.log` / `douyin_network.log`
- 关键失败截图:`backend/app/debug_screenshots/weixin_*.png` / `douyin_*.png`
- 视频号建议使用 headful + xvfb-run避免 headless 解码/指纹问题)
---
## 11. 最小新增模块示例
```
app/modules/foo/
├── router.py
├── schemas.py
├── service.py
└── workflow.py
```
router 仅调用 service/workflow 并返回 `success_response`

View File

@@ -1,6 +1,6 @@
# ViGent2 后端开发指南
本文档为后端开发人员提供架构概览接口规范以及开发流程指南
本文档提供后端架构概览接口规范。开发规范与分层约定见 `Docs/BACKEND_DEV.md`
---
@@ -13,18 +13,24 @@
```
backend/
├── app/
│ ├── api/ # API 路由定义 (endpoints)
│ ├── core/ # 核心配置 (config.py, security.py)
│ ├── models/ # Pydantic 数据模型 (schemas)
│ ├── services/ # 业务逻辑服务层
│ │ ├── auth_service.py # 用户认证服务
│ │ ├── glm_service.py # GLM-4 大模型服务
│ │ ├── lipsync_service.py # LatentSync 唇形同步
│ │ ├── publish_service.py # 社交媒体发布
│ │ ── voice_clone_service.py# Qwen3-TTS 声音克隆
│ ├── core/ # 核心配置 (config.py, security.py, response.py)
│ ├── modules/ # 业务模块 (router/service/workflow/schemas)
│ ├── videos/ # 视频生成任务router/schemas/service/workflow
│ ├── materials/ # 素材管理router/schemas/service
│ │ ├── publish/ # 多平台发布
│ │ ├── auth/ # 认证与会话
│ │ ├── ai/ # AI 功能(标题标签生成)
│ │ ├── assets/ # 静态资源(字体/样式/BGM
│ │ ── ref_audios/ # 声音克隆参考音频router/schemas/service
│ │ ├── login_helper/ # 扫码登录辅助
│ │ ├── tools/ # 工具接口router/schemas/service
│ │ └── admin/ # 管理员功能
│ ├── repositories/ # Supabase 数据访问
│ ├── services/ # 外部服务集成 (TTS/Remotion/Storage/Uploader 等)
│ └── tests/ # 单元测试与集成测试
├── scripts/ # 运维脚本 (watchdog.py, init_db.py)
├── assets/ # 资源库 (fonts, bgm, styles)
├── user_data/ # 用户隔离数据 (Cookie 等)
└── requirements.txt # 依赖清单
```
@@ -35,7 +41,7 @@ backend/
后端服务默认运行在 `8006` 端口。
- **文档地址**: `http://localhost:8006/docs` (Swagger UI)
- **认证方式**: Bearer Token (JWT)
- **认证方式**: HttpOnly Cookie (JWT)
### 核心模块
@@ -46,34 +52,75 @@ backend/
2. **视频生成 (Videos)**
* `POST /api/videos/generate`: 提交生成任务
* `GET /api/videos/tasks/{task_id}`: 查询任务状态
* `GET /api/videos/tasks/{task_id}`: 查询单个任务状态
* `GET /api/videos/tasks`: 获取用户所有任务列表
* `GET /api/videos/generated`: 获取历史视频列表
* `DELETE /api/videos/generated/{video_id}`: 删除历史视频
> **修正 (16:20)**:任务查询与历史列表接口已更新为 `/api/videos/tasks/{task_id}` 与 `/api/videos/generated`。
3. **素材管理 (Materials)**
* `POST /api/materials/upload`: 上传素材 (Direct Upload to Supabase)
* `POST /api/materials`: 上传素材
* `GET /api/materials`: 获取素材列表
* `PUT /api/materials/{material_id}`: 重命名素材
4. **社交发布 (Publish)**
* `POST /api/publish`: 发布视频到 B站/抖音/小红书
* `POST /api/publish`: 发布视频到 抖音/微信视频号/B站/小红书
* `POST /api/publish/login`: 扫码登录平台
* `GET /api/publish/login/status`: 查询登录状态(含刷脸验证二维码)
* `GET /api/publish/accounts`: 获取已登录账号列表
> 提示:视频号/抖音发布建议使用 headful + xvfb-run 运行后端。
5. **资源库 (Assets)**
* `GET /api/assets/subtitle-styles`: 字幕样式列表
* `GET /api/assets/title-styles`: 标题样式列表
* `GET /api/assets/bgm`: 背景音乐列表
6. **声音克隆 (Ref Audios)**
* `POST /api/ref-audios`: 上传参考音频 (multipart/form-data)
* `GET /api/ref-audios`: 获取参考音频列表
* `PUT /api/ref-audios/{id}`: 重命名参考音频
* `DELETE /api/ref-audios/{id}`: 删除参考音频
7. **AI 功能 (AI)**
* `POST /api/ai/generate-meta`: AI 生成标题和标签
8. **工具 (Tools)**
* `POST /api/tools/extract-script`: 从视频链接提取文案
9. **健康检查**
* `GET /api/lipsync/health`: LatentSync 服务健康状态
* `GET /api/voiceclone/health`: Qwen3-TTS 服务健康状态
### 统一响应结构
```json
{
"success": true,
"message": "ok",
"data": { },
"code": 0
}
```
---
## 🎛️ 视频生成扩展参数
`POST /api/videos/generate` 支持以下可选字段:
- `material_path`: 视频素材路径(单素材模式)
- `material_paths`: 多素材路径数组多机位模式≥2 个素材时按句子自动切换)
- `tts_mode`: TTS 模式 (`edgetts` / `voiceclone`)
- `voice`: EdgeTTS 音色 IDedgetts 模式)
- `ref_audio_id` / `ref_text`: 参考音频 ID 与文本voiceclone 模式)
- `title`: 片头标题文字
- `subtitle_style_id`: 字幕样式 ID
- `title_style_id`: 标题样式 ID
- `subtitle_font_size`: 字幕字号(覆盖样式默认值)
- `title_font_size`: 标题字号(覆盖样式默认值)
- `title_top_margin`: 标题距顶部像素
- `subtitle_bottom_margin`: 字幕距底部像素
- `enable_subtitles`: 是否启用字幕
- `bgm_id`: 背景音乐 ID
- `bgm_volume`: 背景音乐音量0-1默认 0.2
@@ -138,7 +185,7 @@ uvicorn app.main:app --host 0.0.0.0 --port 8006 --reload
1.`app/services/` 下创建新的 Service 类 (如 `NewTTSService`)。
2. 实现 `generate` 方法,可以使用 subprocess 调用,也可以是 HTTP 请求。
3. **重要**: 如果模型占用 GPU请务必使用 `asyncio.Lock` 进行并发控制,防止 OOM。
4.`app/api/` 中添加对应的路由调用
4.`app/modules/` 下创建对应模块,添加 router/service/schemas并在 `main.py` 注册路由
### 添加定时任务

View File

@@ -28,8 +28,17 @@ node --version
# 检查 FFmpeg
ffmpeg -version
# 检查 Chrome (视频号发布)
google-chrome --version
# 检查 Xvfb
xvfb-run --help
# 检查 pm2 (用于服务管理)
pm2 --version
# 检查 Redis (任务状态存储,推荐)
redis-server --version
```
如果缺少依赖:
@@ -37,8 +46,17 @@ pm2 --version
sudo apt update
sudo apt install ffmpeg
# 安装 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
```
---
@@ -96,6 +114,18 @@ pip install -r requirements.txt
playwright install chromium
```
> 提示:视频号发布建议使用系统 Chrome + xvfb-run避免 headless 解码失败)。
> 抖音发布同样建议 headful 模式 (`DOUYIN_HEADLESS_MODE=headful`)。
### 扫码登录注意事项
- **Cookie 按用户隔离**:每个用户的 Cookie 存储在 `backend/user_data/{uuid}/cookies/` 目录下,多用户并发登录互不干扰。
- **抖音 QR 登录关键教训**
- 扫码后绝对**不能重新加载 QR 页面**,否则会销毁会话 token
- 使用**新标签页**检测登录完成状态(检查 URL 包含 `creator-micro` + session cookies 存在)
- 抖音可能弹出**刷脸验证**,后端会自动提取验证二维码返回给前端展示
- **微信视频号发布**:标题、描述、标签统一写入"视频描述"字段
---
### 可选AI 标题/标签生成
@@ -160,6 +190,27 @@ cp .env.example .env
| `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) |
| `DOUYIN_HEADLESS_MODE` | headless-new | 抖音 Playwright 模式 (headful/headless-new) |
| `DOUYIN_CHROME_PATH` | `/usr/bin/google-chrome` | 抖音 Chrome 路径 |
| `DOUYIN_BROWSER_CHANNEL` | | 抖音 Chromium 通道 (可选) |
| `DOUYIN_USER_AGENT` | Chrome/144 UA | 抖音浏览器指纹 UA |
| `DOUYIN_LOCALE` | zh-CN | 抖音语言环境 |
| `DOUYIN_TIMEZONE_ID` | Asia/Shanghai | 抖音时区 |
| `DOUYIN_FORCE_SWIFTSHADER` | true | 强制软件 WebGL |
| `DOUYIN_DEBUG_ARTIFACTS` | false | 保留调试截图 |
| `DOUYIN_RECORD_VIDEO` | false | 录制浏览器操作视频 |
| `DOUYIN_KEEP_SUCCESS_VIDEO` | false | 成功后保留录屏 |
| `CORS_ORIGINS` | `*` | CORS 允许源 (生产环境建议白名单) |
| `DOUYIN_COOKIE` | 空 | 抖音视频下载 Cookie (文案提取功能) |
---
@@ -189,6 +240,12 @@ 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 覆盖
```
### 启动前端 (终端 2)
```bash
@@ -223,9 +280,19 @@ python -m scripts.server
1. 创建启动脚本 `run_backend.sh`:
```bash
cat > run_backend.sh << 'EOF'
#!/bin/bash
cd /home/rongye/ProgramFiles/ViGent2/backend
./venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8006
#!/usr/bin/env bash
set -e
BASE_DIR="$(cd "$(dirname "$0")" && pwd)"
export WEIXIN_HEADLESS_MODE=headful
export DOUYIN_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
```

View File

@@ -342,6 +342,6 @@ models/Qwen3-TTS/
## 🔗 相关文档
- [task_complete.md](../task_complete.md) - 任务总览
- [TASK_COMPLETE.md](../TASK_COMPLETE.md) - 任务总览
- [Day11.md](./Day11.md) - 上传架构重构
- [QWEN3_TTS_DEPLOY.md](../QWEN3_TTS_DEPLOY.md) - Qwen3-TTS 部署指南

View File

@@ -273,7 +273,7 @@ pm2 logs vigent2-qwen-tts --lines 50
## 🔗 相关文档
- [task_complete.md](../task_complete.md) - 任务总览
- [TASK_COMPLETE.md](../TASK_COMPLETE.md) - 任务总览
- [Day12.md](./Day12.md) - iOS 兼容与 Qwen3-TTS 部署
- [QWEN3_TTS_DEPLOY.md](../QWEN3_TTS_DEPLOY.md) - Qwen3-TTS 部署指南
- [SUBTITLE_DEPLOY.md](../SUBTITLE_DEPLOY.md) - 字幕功能部署指南

View File

@@ -397,6 +397,6 @@ if ((status === 401 || status === 403) && !isRedirecting && !isPublicPath) {
## 🔗 相关文档
- [task_complete.md](../task_complete.md) - 任务总览
- [TASK_COMPLETE.md](../TASK_COMPLETE.md) - 任务总览
- [Day13.md](./Day13.md) - 声音克隆功能集成 + 字幕功能
- [QWEN3_TTS_DEPLOY.md](../QWEN3_TTS_DEPLOY.md) - Qwen3-TTS 1.7B 部署指南

View File

@@ -342,7 +342,7 @@ pm2 restart vigent2-backend vigent2-frontend
## 🔗 相关文档
- [task_complete.md](../task_complete.md) - 任务总览
- [TASK_COMPLETE.md](../TASK_COMPLETE.md) - 任务总览
- [Day14.md](./Day14.md) - 模型升级 + AI 标题标签
- [AUTH_DEPLOY.md](../AUTH_DEPLOY.md) - 认证系统部署指南

View File

@@ -136,4 +136,4 @@ if service["failures"] >= service['threshold']:
- [x] `Docs/QWEN3_TTS_DEPLOY.md`: 添加 Flash Attention 安装指南
- [x] `Docs/DEPLOY_MANUAL.md`: 添加 Watchdog 部署说明
- [x] `Docs/task_complete.md`: 更新进度至 100% (Day 16)
- [x] `Docs/TASK_COMPLETE.md`: 更新进度至 100% (Day 16)

168
Docs/DevLogs/Day18.md Normal file
View File

@@ -0,0 +1,168 @@
# Day 18 - 后端模块化与规范完善
## 🧱 后端模块化重构 (10:10)
### 内容
- API 路由统一透传到 `modules/*`,路由仅负责参数/权限与响应
- 视频生成逻辑下沉 `workflow`,任务状态抽到 `task_store`
- `TaskStore` 支持 Redis 优先、不可用时自动回退内存
- Supabase 访问抽到 `repositories/*``deps/auth/admin` 全面改造
### 涉及文件
- `backend/app/modules/videos/router.py`
- `backend/app/modules/videos/workflow.py`
- `backend/app/modules/videos/task_store.py`
- `backend/app/modules/videos/service.py`
- `backend/app/modules/*/router.py`
- `backend/app/repositories/users.py`
- `backend/app/repositories/sessions.py`
- `backend/app/core/deps.py`
---
## ✅ 统一响应与异常处理 (11:00)
### 内容
- 统一 JSON 响应结构:`success/message/data/code`
- 全局异常处理中将 `detail` 转换为 `message`
### 涉及文件
- `backend/app/core/response.py`
- `backend/app/main.py`
---
## 🎞️ 素材重命名与存储操作 (11:40)
### 内容
- 新增素材重命名接口 `PUT /api/materials/{material_id}`
- Storage 增加 `move_file` 以支持重命名/移动
### 涉及文件
- `backend/app/modules/materials/router.py`
- `backend/app/services/storage.py`
---
## 🧾 平台列表调整 (12:10)
### 内容
- 平台顺序调整为:抖音 → 微信视频号 → B站 → 小红书
- 移除快手配置
### 涉及文件
- `backend/app/services/publish_service.py`
---
## 📘 后端开发规范补充 (12:30)
### 内容
- 新增 `BACKEND_DEV.md` 作为后端规范文档
- `BACKEND_README.md` 同步模块化结构与响应格式
### 涉及文件
- `Docs/BACKEND_DEV.md`
- `Docs/BACKEND_README.md`
---
## 🚀 发布管理进入体验优化 (13:10)
### 内容
- 首页预取 `/publish` 路由,进入发布管理时更快
- 发布页读取 `sessionStorage` 预取数据,首屏更快渲染
- 账号与作品列表增加骨架屏,避免空白等待
### 涉及文件
- `frontend/src/features/home/ui/HomePage.tsx`
- `frontend/src/features/home/model/useHomeController.ts`
- `frontend/src/features/publish/model/usePublishController.ts`
- `frontend/src/features/publish/ui/PublishPage.tsx`
---
## 📁 首页素材加载优化 (13:30)
### 内容
- 素材列表签名 URL 并发生成(并发上限 8缩短加载时间
- 素材列表增加加载骨架,数量根据上次素材数量动态调整
### 涉及文件
- `backend/app/modules/materials/router.py`
- `frontend/src/features/home/model/useMaterials.ts`
- `frontend/src/features/home/model/useHomeController.ts`
- `frontend/src/features/home/ui/HomePage.tsx`
- `frontend/src/features/home/ui/MaterialSelector.tsx`
---
## 🎬 预览加载体验优化 (14:00)
### 内容
- 预览视频设置 `preload="metadata"`,缩短首帧等待
- 发布页预览按钮悬停预取视频资源
### 涉及文件
- `frontend/src/components/VideoPreviewModal.tsx`
- `frontend/src/features/home/ui/PreviewPanel.tsx`
- `frontend/src/features/publish/ui/PublishPage.tsx`
---
## 📹 微信视频号发布接入 (16:30)
### 内容
- 新增视频号上传器 `WeixinUploader`,打通上传/标题/简介/标签/发布流程
- 视频号扫码登录配置完善iframe 扫码、候选二维码过滤)
- 发布平台与路由接入视频号
- 中文错误提示 + 关键节点截图保存到 `debug_screenshots`
### 涉及文件
- `backend/app/services/uploader/weixin_uploader.py`
- `backend/app/services/qr_login_service.py`
- `backend/app/services/publish_service.py`
- `backend/app/modules/publish/router.py`
- `backend/app/modules/login_helper/router.py`
---
## 🧪 视频号上传稳定性修复 (17:40)
### 内容
- 统一浏览器指纹UA/locale/timezone并支持系统 Chrome
- 增加 headful + xvfb-run 运行方案,避免 headless 检测与解码失败
- 强制 SwiftShader修复 WebGL context loss
- 上传前转码为兼容 MP4H.264 + AAC + faststart
- 增强上传状态判断与调试日志 `weixin_network.log`
### 涉及文件
- `backend/app/core/config.py`
- `backend/app/services/uploader/weixin_uploader.py`
- `backend/app/services/qr_login_service.py`
- `run_backend.sh`
---
## 🧾 发布诊断增强 (18:10)
### 内容
- 抖音发布新增网络日志与失败截图,便于定位上传/发布失败
- 视频号上传失败截图与网络日志落盘
### 涉及文件
- `backend/app/services/uploader/douyin_uploader.py`
- `backend/app/services/uploader/weixin_uploader.py`
- `backend/app/debug_screenshots/*`
---
## 🧩 发布页交互调整 (18:20)
### 内容
- 未选择平台时禁用发布按钮
- 移除定时发布 UI/参数,仅保留立即发布
### 涉及文件
- `frontend/src/features/publish/ui/PublishPage.tsx`
- `frontend/src/features/publish/model/usePublishController.ts`

485
Docs/DevLogs/Day19.md Normal file
View File

@@ -0,0 +1,485 @@
## 🛡️ 发布中防误刷新15:46合并
### 内容
- 发布按钮文案统一为:`正在发布...请勿刷新或关闭网页`
- 发布中启用浏览器 `beforeunload` 拦截,刷新/关闭页面会触发原生二次确认
- 适用于发布管理页全部平台(抖音 / 微信视频号 / B站 / 小红书)
- 后续优化已登记:发布任务状态恢复机制(任务化 + 状态持久化 + 前端轮询恢复)
### 涉及文件
- `frontend/src/features/publish/model/usePublishController.ts`
- `frontend/src/features/publish/ui/PublishPage.tsx`
---
## 🖼️ 发布成功截图稳定性优化15:26合并
### 内容
- 成功判定后先等待页面加载,再额外等待 `3s` 后截图,避免抓到半加载页面
- 针对“截图里页面内容只占 1/3”问题成功截图从 `full_page=True` 调整为视口截图 `full_page=False`
- 视频号成功截图前额外恢复 `zoom=1.0`,避免流程缩放影响最终截图比例
- 抖音成功截图同步应用相同策略,统一前端展示观感
### 涉及文件
- `backend/app/services/uploader/weixin_uploader.py`
- `backend/app/services/uploader/douyin_uploader.py`
---
## 🧪 视频号录屏 Debug 开关15:12已回收
### 内容
- 为视频号上传器新增 Playwright 录屏能力,开关受 `WEIXIN_DEBUG_ARTIFACTS && WEIXIN_RECORD_VIDEO` 控制
- 新增视频号录屏配置项:
- `WEIXIN_RECORD_VIDEO`
- `WEIXIN_KEEP_SUCCESS_VIDEO`
- `WEIXIN_RECORD_VIDEO_WIDTH`
- `WEIXIN_RECORD_VIDEO_HEIGHT`
- 上传流程在 `finally` 中统一保存录屏,失败必保留;成功录屏默认按开关清理
- 排障阶段临时开启过视频号 debug/录屏;当前已回收为默认关闭(`run_backend.sh` 设为 `false`
### 涉及文件
- `backend/app/services/uploader/weixin_uploader.py`
- `backend/app/core/config.py`
- `run_backend.sh`
- `Docs/DEPLOY_MANUAL.md`
---
## 🔁 后端启动脚本统一为 run_backend.sh (15:00)
### 内容
- 删除旧脚本 `run_backend_xvfb.sh`
-`run_backend.sh` 统一为 xvfb + headful 启动逻辑(不再保留非 xvfb 版本)
- 默认端口从 `8010` 统一为 `8006`
- 启动脚本默认关闭微信/抖音 debug 产物
- 更新部署手册中的启动与 pm2 示例,统一使用 `run_backend.sh`
### 涉及文件
- `run_backend.sh`
- `run_backend_xvfb.sh` (deleted)
- `Docs/DEPLOY_MANUAL.md`
---
## 🧾 视频号卡顿与文案未写入修复 (14:52)
### 内容
- 复盘日志确认视频号 `post_create` 请求已成功,但结果判定仅靠页面文案,导致长时间“等待发布结果”
- 发布判定优化:`post_create` 成功且页面进入 `post/list` 时立即判定成功
- 发布超时改为失败返回(不再 `success=true` 假成功)
- “标题+标签写在视频描述”进一步加强:先按 `视频描述` 标签定位输入框,再做 placeholder 与 contenteditable 兜底
- 视频号发布结果等待超时从 `180s` 收敛到 `90s`
### 涉及文件
- `backend/app/services/uploader/weixin_uploader.py`
---
## 🚦 视频号发布卡顿根因与快速判定 (14:45)
### 内容
- 定位到卡顿根因是实际请求已提交(`post_create` 成功)但结果判定仍在轮询文本提示,导致长时间等待
- 新增发布成功网络信号:监听 `post/post_create` 成功响应后标记已提交
- 若已提交且页面已回到内容列表(`/post/list`),立即判定发布成功,不再等满超时
- 新增发布接口失败信号:`post_create` 返回错误时立即失败返回
### 涉及文件
- `backend/app/services/uploader/weixin_uploader.py`
---
## 📸 视频号发布成功截图接入前端 (13:34)
### 内容
- 为微信视频号新增“发布成功截图”能力:发布成功后直接对当前成功页截图
- 截图存储沿用私有隔离目录:`private_outputs/publish_screenshots/{user_id}`
- 返回前端的 `screenshot_url` 使用鉴权接口:`/api/publish/screenshot/{filename}`
- 视频号上传器新增 `user_id` 透传,确保截图按用户隔离
### 涉及文件
- `backend/app/services/uploader/weixin_uploader.py`
- `backend/app/services/publish_service.py`
---
## ✍️ 视频号描述填充修正 + 关闭调试产物 (13:26)
### 内容
- 按最新规则调整视频号文案填充:标题和标签统一写入“视频描述”输入区
- 标签统一规范为 `#标签` 形式并去重
- 若未找到“视频描述”输入区,直接返回失败,避免“发布成功但标题/标签为空”
- 关闭视频号 debug 产物:新增 `WEIXIN_DEBUG_ARTIFACTS=false`,禁用调试日志与截图输出
- `run_backend.sh` 增加 `WEIXIN_DEBUG_ARTIFACTS=false`,启动脚本层面强制关闭
### 涉及文件
- `backend/app/services/uploader/weixin_uploader.py`
- `backend/app/core/config.py`
- `run_backend.sh`
---
## 🚫 强制关闭抖音调试产物 (13:15)
### 内容
- 进一步收紧为“默认不生成任何抖音 debug 截屏/日志/录屏”
- 录屏开关改为依赖 `DOUYIN_DEBUG_ARTIFACTS && DOUYIN_RECORD_VIDEO`,避免单独误开
- `run_backend.sh` 增加环境变量强制关闭:
- `DOUYIN_DEBUG_ARTIFACTS=false`
- `DOUYIN_RECORD_VIDEO=false`
- 仅保留给用户看的发布成功截图(私有目录 + 鉴权访问)
### 涉及文件
- `backend/app/services/uploader/douyin_uploader.py`
- `backend/app/core/config.py`
- `run_backend.sh`
---
## 🧹 关闭调试截屏/录屏并清理历史文件 (13:08)
### 内容
- 抖音调试产物默认关闭:
- `DOUYIN_DEBUG_ARTIFACTS=false`
- `DOUYIN_RECORD_VIDEO=false`
- 保留功能信号监听(上传提交/封面生成/发布接口状态)用于流程判断,不依赖调试文件
- 已删除现有抖音调试文件(`debug_screenshots` 下的 `douyin_*` 截图、日志与失败录屏)
- 继续保留并展示“给用户看的发布成功截图”(用户隔离 + 鉴权访问)
### 涉及文件
- `backend/app/core/config.py`
- `backend/app/services/uploader/douyin_uploader.py`
- `backend/app/debug_screenshots/douyin_*` (deleted)
- `backend/app/debug_screenshots/videos/douyin_*` (deleted)
---
## 🔒 成功截图用户隔离 (12:58)
### 内容
- 发布成功截图改为用户隔离存储,不再写入公开静态目录
- 存储目录迁移到私有路径:`private_outputs/publish_screenshots/{user_id}`
- 新增鉴权访问接口:`GET /api/publish/screenshot/{filename}`(必须登录,仅可访问本人截图)
- 返回给前端的 `screenshot_url` 改为鉴权接口地址,避免跨用户直接猜路径访问
### 涉及文件
- `backend/app/services/uploader/douyin_uploader.py`
- `backend/app/services/publish_service.py`
- `backend/app/modules/publish/router.py`
- `backend/app/core/config.py`
---
## 🎯 封面触发提速与审核中截图强化 (12:49)
### 内容
- 修复“上传完成后长时间不进入封面”:当出现 `重新上传+预览` 且已收到视频提交信号时,立即进入封面步骤
- 目标是减少“处理中”文案残留导致的额外等待
- 成功截图逻辑强化为优先“真实点击审核中标签”,新增文本点击兜底,不再只用可见即通过
- 若审核中列表未马上出现标题,自动刷新并再次进入审核中重查后再截图
### 涉及文件
- `backend/app/services/uploader/douyin_uploader.py`
---
## 🔐 登录态识别增强(避免误报上传失败) (12:41)
### 内容
- 针对“未触发文件选择弹窗”误报,新增登录页识别:
- URL 关键字:`passport/login/check_qrconnect/sso`
- 页面文本:`扫码登录/验证码登录/立即登录/抖音APP扫码登录`
- 登录控件:手机号/验证码输入框、登录按钮
- 上传阶段重试后若识别为登录页,直接返回 `Cookie 已失效,请重新登录`
- 避免把“实际掉登录”误判成“上传入口失效”
### 涉及文件
- `backend/app/services/uploader/douyin_uploader.py`
---
## ⏱️ 发布阶段超时与网络不佳快速失败 (12:30)
### 内容
- 针对“网络不佳后长时间卡住”增加发布阶段快速失败
- 上传完成后到发布结果设置总超时 `60s``POST_UPLOAD_STAGE_TIMEOUT`),超过直接失败
- 识别发布接口 `create_v2` 的 HTTP 错误(如 403并立即返回失败不再等待 180 秒
- 发布结果判定新增网络类失败文案匹配(`网络不佳/网络异常/请稍后重试`
- 阻塞弹窗关闭策略新增 `暂不设置`,避免“设置横封面获更多流量”弹窗阻塞点击发布
### 涉及文件
- `backend/app/services/uploader/douyin_uploader.py`
---
## 🧯 封面已完成但误判失败修复 (12:22)
### 内容
- 针对报错“封面为必填但未设置成功”新增页面态兜底,避免封面已完成却未点击发布
- 新增 `_is_cover_configured_on_page()`:通过 `横封面/竖封面` + 封面预览图判断页面已配置封面
- 当出现 `horizontal_switch_missed``no_cover_button` 时,若页面已配置封面则允许继续发布
- 封面必填主流程增加 `configured_fallback_continue` 兜底,降低误杀
### 涉及文件
- `backend/app/services/uploader/douyin_uploader.py`
---
## 🧾 成功截图切到审核中视图 (11:26)
### 内容
- 按需求将“发布成功截图”改为内容管理 `审核中/待审核` 视图,不再截“全部作品”
- 发布成功后先进入内容管理并点击 `审核中`(或 `待审核`)再截图
- 截图前额外尝试等待当前标题出现在审核中列表,便于确认是最新发布作品
- 发布超时兜底验证也改为优先在审核中列表查找标题
### 涉及文件
- `backend/app/services/uploader/douyin_uploader.py`
---
## ✅ 封面步骤按指定顺序强约束 (11:18)
### 内容
- 按确认流程收紧旧发布页封面链路:
- 作品描述填完 → 点击 `选择封面` → 点击 `设置横封面` → 点击 `完成` → 等待封面效果检测通过 → 才允许发布
- 新增 `require_horizontal` 约束:封面必填场景必须切换到横封面,否则直接失败重试
- 新增封面效果检测通过等待:优先 `cover/gen` 新请求信号,其次页面“检测通过”文案
- 避免因漏点 `设置横封面` 导致后续卡住或误发布
### 涉及文件
- `backend/app/services/uploader/douyin_uploader.py`
---
## 🧩 横封面点击漏判修复 (11:10)
### 内容
- 根据复现反馈修复“未点击设置横封面导致封面流程卡住”问题
- 新增 `_switch_to_horizontal_cover()`,扩展横封面入口选择器(`设置横封面/横封面/横版封面`
- 进入封面弹窗后先关闭阻塞弹窗再点击横封面,点击失败会重试一次
- 若页面存在横封面入口但始终未切换成功,直接返回失败并重试,避免长时间假等待
- 新增日志:`[douyin][cover] switched_horizontal ...``horizontal_switch_missed`
### 涉及文件
- `backend/app/services/uploader/douyin_uploader.py`
---
## ⚡ 横封面后直接完成优化 (11:03)
### 内容
- 根据实测反馈,在点击 `设置横封面` 后新增一次“立即点击完成”快速路径
- 若平台已自动选中横封面,将直接确认并退出弹窗,不再执行后续封面扫描
- 新增日志:`[douyin][cover] fast_confirm_after_switch ...`
### 涉及文件
- `backend/app/services/uploader/douyin_uploader.py`
---
## ⚙️ 封面步骤提速优化 (10:58)
### 内容
- 复盘日志确认旧发布页封面步骤存在明显耗时(示例:`required_by_text``cover selected` 约 35 秒)
- 新增封面“快速确认”路径:若平台已默认选中封面,直接确认并跳过多余扫描
- 收紧封面成功条件:仅“确认按钮点击成功”才算封面设置成功,避免误判
- 缩短不必要等待并新增封面耗时日志:`[douyin][cover] fast_confirm/selected=... confirmed=... elapsed=...`
### 涉及文件
- `backend/app/services/uploader/douyin_uploader.py`
---
## 🧾 发布成功截图前台展示 (10:48)
### 内容
- 按需求删除 `run_backend_xvfb_live.sh`,不再提供实时直播脚本
- 抖音发布成功时自动保存成功截图到 `outputs/publish_screenshots`
- 发布接口返回 `screenshot_url`,前端发布结果卡片直接展示截图并支持点击查看大图
- 发布结果不再 10 秒自动清空,方便用户确认“是否真正发布成功”
### 涉及文件
- `backend/app/services/uploader/douyin_uploader.py`
- `frontend/src/features/publish/model/usePublishController.ts`
- `frontend/src/features/publish/ui/PublishPage.tsx`
- `run_backend_xvfb_live.sh` (deleted)
---
## 🧬 抖音界面差异根因与环境对齐 (10:20)
### 内容
- 定位到 Playwright 与手动 Win11 Chrome 的环境指纹不一致Linux 平台 + 自动化上下文),可能触发不同灰度界面
- 抖音上传器新增独立浏览器配置项,不再复用 `WEIXIN_*` 配置
- 新增 `DOUYIN_*` 配置:`HEADLESS_MODE/USER_AGENT/LOCALE/TIMEZONE_ID/CHROME_PATH/BROWSER_CHANNEL/FORCE_SWIFTSHADER`
- 上传器启动改为 `_build_launch_options()`,可直接切换到系统 Chrome + headful推荐配合 xvfb
### 涉及文件
- `backend/app/services/uploader/douyin_uploader.py`
- `backend/app/core/config.py`
---
## 🪄 新旧发布页封面逻辑分流 (10:28)
### 内容
- 依据页面结构自动分流:
- 新版发布页(封面非必填):默认跳过封面设置
- 旧版发布页(出现 `设置封面` + `必填`):强制先设置封面
- 新增 `_is_cover_required()` 判断,避免在新页面做多余封面操作
- 若判定为非必填但点击发布失败,会回退尝试设置封面后再重试发布
### 涉及文件
- `backend/app/services/uploader/douyin_uploader.py`
---
## 📺 虚拟屏实时观看方案 (10:36)
### 内容
- 新增 `run_backend_xvfb_live.sh`,在 Xvfb 下同时启动后端与实时画面转码
- 通过 ffmpeg 抓取虚拟屏并输出 HLS`/outputs/live/live.m3u8`
- 适用于“边跑自动发布边实时观看”,不依赖 VNC
- 默认仍保留失败录屏HLS 用于过程实时观察
### 涉及文件
- `run_backend_xvfb_live.sh`
---
## 🎥 抖音后台录屏能力 (09:55)
### 内容
- 新增抖音自动发布过程录屏能力,便于定位“卡住在哪一步”
- 录屏文件保存目录:`backend/app/debug_screenshots/videos`
- 默认开启录屏,默认只保留失败录屏(成功录屏自动清理)
- 每次执行会在网络日志追加录屏保存记录(`[douyin][record]`
- 增加发布阶段关键标记日志:`publish_wait ready``publish_click try/clicked`
- 新增配置项:`DOUYIN_RECORD_VIDEO``DOUYIN_KEEP_SUCCESS_VIDEO``DOUYIN_RECORD_VIDEO_WIDTH``DOUYIN_RECORD_VIDEO_HEIGHT`
### 涉及文件
- `backend/app/services/uploader/douyin_uploader.py`
- `backend/app/core/config.py`
---
## 🚀 发布按钮等待逻辑修正 (10:00)
### 内容
- 根据线上反馈,发布页不再做冗长前置等待,改为“尽快尝试点击发布”
- 新增发布按钮定位策略role + text 多选择器),避免 `exact role` 匹配失败导致假等待
- 将发布按钮等待上限从上传超时300s独立为 `PUBLISH_BUTTON_TIMEOUT=60s`
- 点击发布阶段统一走 `_click_publish_button`,并持续记录 `publish_wait/publish_click` 日志
### 涉及文件
- `backend/app/services/uploader/douyin_uploader.py`
---
## 🧪 上传完成特征判定增强 (10:07)
### 内容
- 基于实测页面特征补齐“上传中/上传完成”判定:
- 上传中:`上传过程中请不要刷新``取消上传``已上传/当前速度/剩余时间`
- 上传完成:`重新上传` + `预览视频/预览封面/标题`
- 仅在确认上传完成后才允许执行发布点击,避免“未传完提前发布”
- 新增上传等待日志:`[douyin][upload_wait] ...`,可直观看到卡在上传中还是等完成信号
### 涉及文件
- `backend/app/services/uploader/douyin_uploader.py`
---
## ⏸️ 上传完成后延时发布 (10:10)
### 内容
- 根据实测反馈,增加“上传完成后固定等待 2 秒”再点发布
- 避免刚出现完成信号就立即点击,给前端状态收敛留缓冲
- 新增日志标记:`[douyin][upload_ready] wait_before_publish=2s`
### 涉及文件
- `backend/app/services/uploader/douyin_uploader.py`
---
## 🖼️ 恢复封面设置流程 (10:14)
### 内容
- 按实测需求恢复“上传完成后先设置封面,再发布”流程
- 封面设置改为最多尝试 2 次,成功写入 `[douyin][cover] selected`
- 若封面未设置成功则直接终止发布并保存截图 `cover_not_selected`
- 避免出现“未设封面就点击发布”的情况
### 涉及文件
- `backend/app/services/uploader/douyin_uploader.py`
---
## 🛠️ 抖音发布流程修复 (09:20)
### 内容
- 按最新页面流程改为先进入首页并点击 `高清发布`,再进入上传页
- 新增未发布草稿处理:检测到 `你还有上次未发布的视频` 时自动点击 `放弃`
- 上传策略改为优先点击 `上传视频` 并走 file chooser失败后再回退多 input 选择器
- 只有检测到 `基础信息/作品描述/发布设置/重新上传` 等发布态信号才继续,避免误判“已上传”
- 修复无扩展名视频临时文件策略:优先 hardlink失败时 copy移除 symlink 回退
- 适配当前智能封面流程:跳过手动封面操作
- 话题填写改为在简介/描述区域使用 `#标签` 形式追加
### 涉及文件
- `backend/app/services/uploader/douyin_uploader.py`
---
## ⚡ 抖音等待链路再收敛 (09:52)
### 内容
- 根据“选完视频即进入发布页”流程,移除独立的上传完成轮询阶段
- 改为在点击发布前统一等待“发布按钮可点击”,避免重复等待导致总时长偏长
- 新增 `publish_wait` 调试日志,按秒记录按钮可点击等待时长
- 超时文案改为明确提示“发布按钮长时间不可点击”
- 上传入口改为严格 file chooser 流程:只走“点击上传视频 → 选择文件 → 进入发布页”链路
- 移除直接 input 回退上传,避免绕开上传入口导致状态机异常
### 涉及文件
- `backend/app/services/uploader/douyin_uploader.py`
---
## 🧭 抖音卡慢环节定位与修复 (09:45)
### 内容
- 通过 `douyin_network.log` 定位到卡慢发生在“上传完成判定”阶段,而非真正提交发布接口
- 新增上传完成网络信号:`CommitUploadInner` 成功与封面生成成功信号写入日志
- 收紧“上传完成”判定,移除 `publish_button_enabled` 这种过早放行条件
- 仅在检测到 `重新上传/重新选择` 或上传提交信号后才进入下一步,降低误判导致的长等待
### 涉及文件
- `backend/app/services/uploader/douyin_uploader.py`
---
## ✅ 抖音发布结果判定修正 (09:38)
### 内容
- 修复“发布检测超时仍返回 success=true”的问题超时场景改为 `success=false`
- 优化超时返回文案,明确为“发布状态未知,需要后台确认”
- 下线过于宽松的管理页兜底判定(仅出现 `审核中` 不再当作发布成功)
- 超时时即使管理页出现同名标题也不直接判定成功,避免旧作品同名导致误报
### 涉及文件
- `backend/app/services/uploader/douyin_uploader.py`
---
## ⏱️ 抖音上传完成判定优化 (09:34)
### 内容
- 根据最新日志确认文件上传已开始并有分片上传请求成功,但流程长时间停留在“等待上传完成”
- 扩展“上传完成”判定条件,不再只依赖单一 `long-card + 重新上传` 选择器
- 新增上传完成信号:`重新上传/重新选择` 可见、发布按钮可用、`发布设置``预览视频` 可见
- 上传等待日志增加耗时秒数,便于判断是否真实卡住
### 涉及文件
- `backend/app/services/uploader/douyin_uploader.py`

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

@@ -0,0 +1,103 @@
## 🔧 代码质量与安全优化 (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 已自动编译
```
### 🎨 交互与体验优化 (17:00)
- [x] **UX-1**: PublishPage 图片加载优化 (`<img>``next/image`)
- [x] **UX-2**: 按钮 Loading 状态统一 (提取脚本弹窗 + 发布页)
- [x] **UX-3**: 骨架屏加载优化 (发布页加载中状态)
- [x] **UX-4**: 全局快捷键支持 (ESC 关闭弹窗, Enter 确认)
- [x] **UX-5**: 移除全局 GlobalTaskIndicator (视觉降噪)
- [x] **UX-6**: 视频生成完成自动刷新列表并选中最新
### 🐛 缺陷修复与回归治理 (17:30)
#### 严重缺陷修复
- [x] **BUG-1**: Remotion 渲染脚本路径解析错误 (导致标题字幕丢失)
- *原因*: `render.js` 预编译后使用了 `__dirname`,在 `dist` 目录下寻找源码失败。
- *修复*: 修改 `render.ts` 使用 `process.cwd()` 动态解析路径,并重新编译。
- [x] **BUG-2**: 发布页视频选择持久化失效 (Auth 异步竞态)
- *原因*: 页面加载时 `useAuth` 尚未返回用户 ID导致使用 `guest` Key 读取不到记录,随后被默认值覆盖。
- *修复*: 引入 `isVideoRestored` 状态机,强制等待 Auth 完成且 Video 列表加载完毕后,才执行恢复逻辑。
#### 回归问题治理
- [x] **REG-1**: 首页历史作品 ID 恢复后内容不显示
- *原因*: 持久化模块恢复了 ID`useGeneratedVideos` 未监听 ID 变化同步 URL。
- *修复*: 新增 `useEffect` 监听 `selectedVideoId` 变化并同步 `generatedVideo` URL。
- [x] **REG-2**: 首页/发布页“默认选中第一个”逻辑丢失
- *原因*: 重构移除旧逻辑后,新用户或无缓存用户进入页面无默认选中。
- *修复*: 在 `isRestored` 且无选中时,增加兜底逻辑自动选中列表第一项。
- [x] **REG-3**: 素材选择持久化失效 (闭包陷阱)
- *原因*: `useMaterials` 加载回调中捕获了旧的 `selectedMaterial` 状态,覆盖了已恢复的值。
- *修复*: 改为函数式状态更新 (`setState(prev => ...)`),确保基于最新状态判断。
- [x] **REF-1**: 持久化逻辑全站收敛与排查
- *优化*: 清理 `useBgm`, `useGeneratedVideos`, `useTitleSubtitleStyles` 中的冗余 `localStorage` 读取,统一由 `useHomePersistence` 管理。
- *排查*: 深度排查 `useRefAudios`, `useTitleSubtitleStyles` 等模块,确认逻辑健壮,无类似回归风险。

449
Docs/DevLogs/Day21.md Normal file
View File

@@ -0,0 +1,449 @@
## 🐛 缺陷修复:视频生成与持久化回归 (Day 21)
### 概述
本日修复 Day 20 优化后引入的 3 个回归缺陷Remotion 渲染崩溃容错、首页作品选择持久化、发布页作品选择持久化。
---
### 已完成修复
#### BUG-1: Remotion 渲染进程崩溃导致标题/字幕丢失
- **现象**: 视频生成后没有标题和字幕,回退到纯 FFmpeg 合成。
- **根因**: Remotion Node.js 进程在渲染完成100%)后以 SIGABRT (code -6) 退出Python 端将其视为失败。
- **修复**: `remotion_service.py` 在进程非零退出时,先检查输出文件是否存在且大小合理(>1KB若存在则视为成功。
- **文件**: `backend/app/services/remotion_service.py`
```python
if process.returncode != 0:
output_file = Path(output_path)
if output_file.exists() and output_file.stat().st_size > 1024:
logger.warning(
f"Remotion process exited with code {process.returncode}, "
f"but output file exists ({output_file.stat().st_size} bytes). Treating as success."
)
return output_path
raise RuntimeError(...)
```
#### BUG-2: 首页历史作品选择刷新后不保持
- **现象**: 用户选择某个历史作品后刷新页面,总是回到第一个视频。
- **根因**: `fetchGeneratedVideos()` 在初始加载时无条件自动选中第一个视频,覆盖了 `useHomePersistence` 的恢复值。
- **修复**: `fetchGeneratedVideos` 增加 `preferVideoId` 参数,仅在明确指定时才自动选中;新增 `"__latest__"` 哨兵值用于生成完成后选中最新。
- **文件**: `frontend/src/features/home/model/useGeneratedVideos.ts`, `frontend/src/features/home/model/useHomeController.ts`
```typescript
// 任务完成 → 自动选中最新
useEffect(() => {
if (prevIsGenerating.current && !isGenerating) {
if (currentTask?.status === "completed") {
void fetchGeneratedVideos("__latest__");
} else {
void fetchGeneratedVideos();
}
}
prevIsGenerating.current = isGenerating;
}, [isGenerating, currentTask, fetchGeneratedVideos]);
```
#### BUG-3: 发布页作品选择刷新后不保持(根因:签名 URL 不稳定)
- **现象**: 发布管理页选择视频后刷新,选择丢失(无任何视频被选中)。
- **根因**: 后端 `/api/videos/generated` 返回的 `path` 是 Supabase 签名 URL每次请求都会变化。发布页用 `path` 作为选择标识存入 localStorage刷新后新的 `path` 与保存值永远不匹配。首页不受影响是因为使用稳定的 `video.id`
- **修复**: 发布页全面改用 `id`(稳定标识)替代 `path`(签名 URL进行选择、持久化和比较。
- **文件**:
- `frontend/src/shared/types/publish.ts``PublishVideo` 新增 `id` 字段
- `frontend/src/features/publish/model/usePublishController.ts``selectedVideo` 存储 `id`,发布时根据 `id` 查找 `path`
- `frontend/src/features/publish/ui/PublishPage.tsx``key`/`onClick`/选中比较改用 `v.id`
- `frontend/src/features/home/model/useHomeController.ts` — 预取缓存加入 `id` 字段
```typescript
// 类型定义新增 id
export interface PublishVideo {
id: string; // 稳定标识符
name: string;
path: string; // 签名 URL仅用于播放/发布)
}
// 发布时根据 id 查找 path
const video = videos.find(v => v.id === selectedVideo);
await api.post('/api/publish', { video_path: video.path, ... });
```
---
### 涉及文件汇总
| 文件 | 变更 |
|------|------|
| `backend/app/services/remotion_service.py` | Remotion 崩溃容错 |
| `frontend/src/features/home/model/useGeneratedVideos.ts` | 首页视频选择不自动覆盖 |
| `frontend/src/features/home/model/useHomeController.ts` | 任务完成监听 + 预取缓存加 id |
| `frontend/src/shared/types/publish.ts` | PublishVideo 新增 id 字段 |
| `frontend/src/features/publish/model/usePublishController.ts` | 选择/持久化/发布改用 id |
| `frontend/src/features/publish/ui/PublishPage.tsx` | UI 选择比较改用 id |
### 关键教训
> **签名 URL 不可作为持久化标识**。Supabase Storage 的签名 URL 包含时间戳和签名参数,每次请求都不同。任何需要跨请求/跨刷新保持的标识,必须使用后端返回的稳定 `id` 字段。
### 重启要求
```bash
pm2 restart vigent2-backend # Remotion 容错
npm run build && pm2 restart vigent2-frontend # 前端持久化修复
```
---
## 🎨 浮动样式预览窗口优化 (Day 21)
### 概述
标题与字幕面板中的预览区域原本是内联折叠的,展开后调节下方滑块时看不到预览效果。改为 `position: fixed` 浮动窗口,固定在视口左上角,滚动页面时预览始终可见,边调边看。
### 已完成优化
#### 1. 新建浮动预览组件 `FloatingStylePreview.tsx`
- `createPortal(jsx, document.body)` 渲染到 body 层级,脱离面板 DOM 树
- `position: fixed` + 左上角固定定位,滚动时不移动
- `z-index: 150`(低于 VideoPreviewModal 的 200
- 顶部标题栏 + X 关闭按钮ESC 键关闭
- 桌面端固定宽度 280px移动端自适应最大 360px
- `previewScale = windowWidth / previewBaseWidth` 自行计算缩放
- `maxHeight: calc(100dvh - 32px)` 防止超出视口
#### 2. 修改 `TitleSubtitlePanel.tsx`
- 删除内联预览区域(`ref={previewContainerRef}` 整块 JSX
- 条件渲染 `<FloatingStylePreview />`,按钮文本保持"预览样式"/"收起预览"
- 移除 `previewScale``previewAspectRatio``previewContainerRef` props
- 保留 `previewBaseWidth/Height`(浮动窗口需要原始尺寸计算 scale
#### 3. 清理 `useHomeController.ts`
- 移除 `previewContainerWidth` 状态
- 移除 `titlePreviewContainerRef` ref
- 移除 ResizeObserver useEffect浮动窗口自管尺寸不再需要
#### 4. 简化 `HomePage.tsx` 传参
- 移除 `previewContainerWidth``titlePreviewContainerRef` 解构
- 移除 `previewScale``previewAspectRatio``previewContainerRef` prop 传递
#### 5. 移动端适配
- `ScriptEditor.tsx`:标题行改为 `flex-wrap`"AI生成标题标签"按钮不再溢出
- 预览默认比例从 1280×720 (16:9) 改为 1080×1920 (9:16),符合抖音竖屏视频
### 涉及文件汇总
| 文件 | 变更 |
|------|------|
| `frontend/src/features/home/ui/FloatingStylePreview.tsx` | **新建** 浮动预览组件 |
| `frontend/src/features/home/ui/TitleSubtitlePanel.tsx` | 移除内联预览,渲染浮动组件 |
| `frontend/src/features/home/model/useHomeController.ts` | 移除 preview 容器相关状态和 ResizeObserver |
| `frontend/src/features/home/ui/HomePage.tsx` | 简化 props 传递,默认比例改 9:16 |
| `frontend/src/features/home/ui/ScriptEditor.tsx` | 移动端按钮换行适配 |
### 重启要求
```bash
npm run build && pm2 restart vigent2-frontend
```
---
## 🔧 多平台发布体系重构:用户隔离与抖音刷脸验证 (Day 21)
### 概述
重构发布系统的两大核心问题:① 多用户场景下 Cookie/会话缺乏隔离,② 抖音登录新增刷脸验证步骤无法处理。同时修复了平台配置混用和微信视频号发布流程问题。
---
### 一、平台配置独立化
#### 问题
所有平台抖音、微信、B站、小红书共用 WEIXIN_* 配置,导致 User-Agent、Headless 模式等设置不匹配。
#### 修复 — `config.py`
- 新增 `DOUYIN_*` 独立配置项:`DOUYIN_HEADLESS_MODE``DOUYIN_USER_AGENT`Chrome/144`DOUYIN_LOCALE``DOUYIN_TIMEZONE_ID``DOUYIN_CHROME_PATH``DOUYIN_FORCE_SWIFTSHADER`、调试开关等
- 微信保持已有 `WEIXIN_*` 配置
- B站/小红书使用通用默认值
#### 修复 — `qr_login_service.py` 平台配置映射
```python
# 之前:所有平台都用 WEIXIN 设置
# 之后:每个平台独立配置
PLATFORM_CONFIGS = {
"douyin": { headless, user_agent, locale, timezone... },
"weixin": { headless, user_agent, locale, timezone... },
"bilibili": { 通用配置 },
"xiaohongshu": { 通用配置 },
}
```
---
### 二、用户隔离的 Cookie 管理
#### 问题
多用户共享同一套 Cookie 文件,用户 A 的登录态可能被用户 B 覆盖。
#### 修复 — `publish_service.py`
- `_get_cookies_dir(user_id)``backend/user_data/{uuid}/cookies/`
- `_get_cookie_path(user_id, platform)` → 按用户+平台返回独立 Cookie 文件路径
- `_get_session_key(user_id, platform)``"{user_id}_{platform}"` 格式的会话 key
- 登录/发布流程全链路传入 `user_id`,清理残留会话避免干扰
---
### 三、抖音刷脸验证二维码
#### 问题
抖音扫码登录后可能弹出刷脸验证窗口,内含新的二维码需要用户再次扫描,前端无法感知和展示。
#### 修复 — 后端 `qr_login_service.py`
- 扩展 QR 选择器:支持跨 iframe 搜索二维码元素
- 抖音 API 拦截:监听 `check_qrconnect` 响应,检测 `redirect_url`
- 检测 "完成验证" / "请前往APP完成验证" 文案
- 在验证弹窗内找到正方形二维码(排除头像),截图返回给前端
- API 确认后直接导航到 redirect_url不重新加载 QR 页,避免销毁会话)
#### 修复 — 后端 `publish_service.py`
- `get_login_session_status()` 新增 `face_verify_qr` 字段返回
- 登录成功且 Cookie 保存后自动清理会话
#### 修复 — 前端
- `usePublishController.ts`:新增 `faceVerifyQr` 状态,轮询时获取 `face_verify_qr` 字段
- `PublishPage.tsx`QR 弹窗优先展示刷脸验证二维码,附提示文案
```tsx
{faceVerifyQr ? (
<>
<Image src={`data:image/png;base64,${faceVerifyQr}`} />
<p>APP扫描上方二维码完成刷脸验证</p>
</>
) : /* 普通登录二维码 */ }
```
---
### 四、微信视频号发布流程优化
#### 修复 — `weixin_uploader.py`
- 添加 `user_id` 参数支持,发布截图目录隔离
- 新增 `post_create` API 响应监听,精准判断发布成功
- 发布结果判定URL 离开创建页 或 API 确认提交 → 视为成功
- 标题/标签处理改为统一写入"视频描述"字段(不再单独填写 title/tags
---
### 涉及文件汇总
| 文件 | 变更 |
|------|------|
| `backend/app/core/config.py` | 新增 DOUYIN_* 独立配置项 |
| `backend/app/services/qr_login_service.py` | 平台配置拆分、刷脸验证二维码、跨 iframe 选择器 |
| `backend/app/services/publish_service.py` | 用户隔离 Cookie 管理、刷脸验证状态返回 |
| `backend/app/services/uploader/weixin_uploader.py` | user_id 支持、post_create API 监听、描述字段合并 |
| `frontend/src/features/publish/model/usePublishController.ts` | faceVerifyQr 状态 |
| `frontend/src/features/publish/ui/PublishPage.tsx` | 刷脸验证二维码展示 |
### 重启要求
```bash
pm2 restart vigent2-backend # 发布服务 + QR登录
npm run build && pm2 restart vigent2-frontend # 刷脸验证UI
```
---
## 🏗️ 架构优化:前端结构微调 + 后端模块分层 (Day 21)
### 概述
根据架构审计结果,完成前端目录规范化和后端核心模块的分层补全。
### 一、前端结构微调
#### 1. ScriptExtractionModal 迁移
- `components/ScriptExtractionModal.tsx``features/home/ui/ScriptExtractionModal.tsx`
- 连带 `components/script-extraction/` 目录一并迁移到 `features/home/ui/script-extraction/`
- 更新 `HomePage.tsx` 的 import 路径
#### 2. contexts/ 目录归并
- `src/contexts/AuthContext.tsx``src/shared/contexts/AuthContext.tsx`
- `src/contexts/TaskContext.tsx``src/shared/contexts/TaskContext.tsx`
- 更新 6 处 importlayout.tsx, useHomeController.ts, usePublishController.ts, AccountSettingsDropdown.tsx, GlobalTaskIndicator.tsx
- 删除空的 `src/contexts/` 目录
#### 3. 清理重构遗留空目录
- 删除 `src/lib/``src/components/home/``src/hooks/`
### 二、后端模块分层补全
将 3 个 400+ 行的 router-only 模块拆分为 `router.py + schemas.py + service.py`
| 模块 | 改造前 | 改造后 router |
|------|--------|--------------|
| `materials/` | 416 行 | 63 行 |
| `tools/` | 417 行 | 33 行 |
| `ref_audios/` | 421 行 | 71 行 |
业务逻辑全部提取到 `service.py`,数据模型定义在 `schemas.py`router 只做参数校验 + 调用 service + 返回响应。
### 三、开发规范更新
`BACKEND_DEV.md` 第 8 节新增渐进原则:
- 新模块**必须**包含 `router.py + schemas.py + service.py`
- 改旧模块时顺手拆涉及的部分
- 新代码高标准,旧代码逐步改
### 涉及文件汇总
| 文件 | 变更 |
|------|------|
| `frontend/src/features/home/ui/ScriptExtractionModal.tsx` | 从 components/ 迁入 |
| `frontend/src/features/home/ui/script-extraction/` | 从 components/ 迁入 |
| `frontend/src/shared/contexts/AuthContext.tsx` | 从 contexts/ 迁入 |
| `frontend/src/shared/contexts/TaskContext.tsx` | 从 contexts/ 迁入 |
| `backend/app/modules/materials/schemas.py` | **新建** |
| `backend/app/modules/materials/service.py` | **新建** |
| `backend/app/modules/materials/router.py` | 精简为薄路由 |
| `backend/app/modules/tools/schemas.py` | **新建** |
| `backend/app/modules/tools/service.py` | **新建** |
| `backend/app/modules/tools/router.py` | 精简为薄路由 |
| `backend/app/modules/ref_audios/schemas.py` | **新建** |
| `backend/app/modules/ref_audios/service.py` | **新建** |
| `backend/app/modules/ref_audios/router.py` | 精简为薄路由 |
| `Docs/BACKEND_DEV.md` | 目录结构标注分层、新增渐进原则 |
| `Docs/BACKEND_README.md` | 目录结构标注分层 |
| `Docs/FRONTEND_DEV.md` | 更新目录结构contexts 迁移、ScriptExtractionModal 迁移) |
### 重启要求
```bash
pm2 restart vigent2-backend
npm run build && pm2 restart vigent2-frontend
```
---
## 🎬 多素材视频生成(多机位效果)
### 概述
支持用户上传多个不同角度的自拍视频,生成视频时按句子自动切换素材,最终效果类似多机位拍摄。单素材时走原有流程,无额外开销。
### 核心架构
#### 流水线变更
```
【单素材(不变)】
text → TTS → audio → LatentSync(1个素材+完整audio) → Whisper字幕 → Remotion → 成片
【多素材(新增)】
text → TTS → audio → Whisper字幕(提前) → 按素材数量均分时长(对齐字边界)
→ 对每段: 切分audio + LatentSync(素材[i]+音频片段[i])
→ FFmpeg拼接所有片段 → Remotion(完整字幕时间戳) → 成片
```
#### 素材切换逻辑(均分方案)
1. Whisper 对完整音频转录,得到字级别时间戳
2. 按素材数量**均分音频总时长**`total_duration / N`
3. 每个分割点对齐到最近的 Whisper 字边界,避免在字中间切分
4. 首段 start 扩展为 0.0,末段 end 扩展为音频结尾,确保完整覆盖
> **设计决策**:最初方案基于原始文案标点分句,但用户文案往往不含句号(只有逗号),导致只产生 1 段。改为均分方案后不依赖文案标点,对任何输入都能正确切分。
---
### 一、后端改动
#### 1. `backend/app/modules/videos/schemas.py`
- 新增 `material_paths: Optional[List[str]]` 字段
- 保留 `material_path: str` 向后兼容
#### 2. `backend/app/modules/videos/workflow.py`(核心改动)
**新增函数**
- `_split_equal(segments, material_paths)`: 按素材数量均分音频时长,对齐到最近的 Whisper 字边界
**修改 `process_video_generation()`**
- `is_multi = len(material_paths) > 1` 判断走多素材/单素材分支
- 多素材分支Whisper 提前 → 均分切分 → 音频切分 → 逐段 LatentSync → FFmpeg 拼接
#### 3. `backend/app/services/video_service.py`
- 新增 `concat_videos()`: FFmpeg concat demuxer (`-c copy`) 拼接视频片段
- 新增 `split_audio()`: FFmpeg 按时间范围切分音频 (`-ss` + `-t` + `-c copy`)
#### 4. `backend/scripts/watchdog.py`
- 健康检查阈值从 3 次提高到 5 次(容忍期 2.5 分钟)
- 新增重启后 120 秒冷却期,避免模型加载期间被误判为故障
- 启动时给所有服务 60 秒初始冷却期
---
### 二、前端改动
#### 1. 新增依赖
```bash
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
```
#### 2. `frontend/src/features/home/model/useMaterials.ts`
- `selectedMaterial: string``selectedMaterials: string[]`(多选)
- 新增 `toggleMaterial(id)`: 切换选中/取消至少保留1个
- 新增 `reorderMaterials(activeId, overId)`: 拖拽排序
- 上传格式扩展:新增 `.mkv/.webm/.flv/.wmv/.m4v/.ts/.mts`
#### 3. `frontend/src/features/home/ui/MaterialSelector.tsx`(重写)
- 素材列表每行增加复选框 + 序号徽标(①②③)
- 选中 ≥2 个时显示拖拽排序区(@dnd-kit `SortableContext`
- 每个排序项:拖拽把手 + 序号 + 素材名 + 移除按钮
- HTML input accept 改为 `video/*`
#### 4. `frontend/src/features/home/model/useHomeController.ts`
- 多素材 payload`material_paths` 数组 + `material_path` 向后兼容
- `enable_subtitles` 硬编码为 `true`(移除开关)
- 验证:至少选中 1 个素材
#### 5. `frontend/src/features/home/model/useHomePersistence.ts`
- 素材持久化改为 JSON 数组,向后兼容旧格式(单字符串)
- 移除 `enableSubtitles` 持久化
#### 6. `frontend/src/features/home/ui/TitleSubtitlePanel.tsx`
- 移除"逐字高亮字幕"开关,字幕样式区始终显示
#### 7. `frontend/src/features/home/ui/HomePage.tsx`
- 更新 props 传递(`selectedMaterials`, `toggleMaterial`, `reorderMaterials`
---
### 三、Bug 修复记录
#### BUG-1: 多素材只使用第一个视频(基于标点的分句方案失败)
- **现象**: 选了 2 个素材但生成的视频只使用第 1 个,日志显示 `Multi-material: 1 segments, 2 materials`
- **根因 v1**: 最初通过正则 `[。!?!?]` 在 Whisper 输出中分句,但 Whisper 不输出标点。
- **修复 v1**: 改为用原始文案标点分句——但用户文案往往只含逗号(,),无句末标点(。!?),仍退化为 1 段。
- **最终修复**: 彻底放弃基于标点的分句方案,改为 `_split_equal()` **按素材数量均分音频时长**,对齐到最近的 Whisper 字边界。不依赖任何标点符号,对所有文案均有效。
#### BUG-2: 口型对不上(音频时间偏移)
- **根因**: `split_audio` 用 Whisper 的 start/end 时间(如 0.11~7.21)切分音频,但 `compose()` 用完整原始音频0.0~结尾)合成,导致时间偏移。
- **修复**: 强制首段 start=0.0,末段 end=音频实际时长,确保切分音频完整覆盖。
#### BUG-3: min_segment_sec 过度合并导致退化(已随方案切换移除)
- **根因**: 旧方案中 2 个句子第 2 句不足 3 秒时,最短时长检查合并为 1 段,多素材退化为单素材。
- **状态**: 均分方案不存在此问题,相关代码已移除。
---
### 涉及文件汇总
| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `backend/app/modules/videos/schemas.py` | 修改 | 新增 material_paths 字段 |
| `backend/app/modules/videos/workflow.py` | 修改 | 多素材流水线核心逻辑 + 3个 Bug 修复 |
| `backend/app/services/video_service.py` | 修改 | 新增 concat_videos / split_audio |
| `backend/scripts/watchdog.py` | 修改 | 阈值优化 + 冷却期机制 |
| `frontend/package.json` | 修改 | 新增 @dnd-kit 依赖 |
| `frontend/src/features/home/model/useMaterials.ts` | 修改 | 多选 + 排序状态管理 |
| `frontend/src/features/home/ui/MaterialSelector.tsx` | 重写 | 多选复选框 + 拖拽排序 UI |
| `frontend/src/features/home/model/useHomeController.ts` | 修改 | 多素材 payload + 移除字幕开关 |
| `frontend/src/features/home/model/useHomePersistence.ts` | 修改 | JSON 数组持久化 |
| `frontend/src/features/home/ui/TitleSubtitlePanel.tsx` | 修改 | 移除字幕开关 |
| `frontend/src/features/home/ui/HomePage.tsx` | 修改 | 更新 props 传递 |
### 重启要求
```bash
pm2 restart vigent2-backend
npm run build && pm2 restart vigent2-frontend
```

221
Docs/DevLogs/Day22.md Normal file
View File

@@ -0,0 +1,221 @@
## 🔧 多素材生成优化与健壮性加固 (Day 22)
### 概述
对 Day 21 实现的多素材视频生成(多机位)功能进行全面审查,修复 6 个高优先级 Bug、完成 8 项体验优化,并将多素材流水线从"逐段 LatentSync"重构为"先拼接再推理"架构,推理次数从 N 次降为 1 次。
---
### 一、后端高优 Bug 修复
#### 1. `_split_equal()` 素材数 > 字符数边界溢出
- **问题**: 5 个素材但只有 2 个 Whisper 字符时,边界索引重复,部分素材被跳过
- **修复**: 加入 `n = min(n, len(all_chars))` 上限保护
- **文件**: `backend/app/modules/videos/workflow.py`
#### 2. 多素材 LatentSync 单段失败无 fallback
- **问题**: 单素材模式下 LatentSync 失败会 fallback 到原始素材,但多素材模式直接抛异常,整个任务失败
- **修复**: 多素材循环中加 try-except失败时 fallback 到原始素材片段
- **文件**: `backend/app/modules/videos/workflow.py`
#### 3. `num_segments == 0` 时 ZeroDivisionError
- **问题**: 所有 assignments 被跳过后 `i / num_segments` 触发除零
- **修复**: 循环前加 `if num_segments == 0` 检查并抛出明确错误
- **文件**: `backend/app/modules/videos/workflow.py`
#### 4. `split_audio` 未校验 duration > 0
- **问题**: `end <= start` 时 FFmpeg 行为异常
- **修复**: 加入 `if duration <= 0: raise ValueError(...)`
- **文件**: `backend/app/services/video_service.py`
#### 5. Whisper 失败时按时长均分兜底
- **问题**: Whisper 失败后直接退化为单素材,其他素材被浪费
- **修复**: 按 `audio_duration / len(material_paths)` 均分,不依赖字符对齐
- **文件**: `backend/app/modules/videos/workflow.py`
#### 6. `concat_videos` 空列表未检查
- **问题**: 传入空 `video_paths` 时 FFmpeg 报错
- **修复**: 加入 `if not video_paths: raise ValueError(...)`
- **文件**: `backend/app/services/video_service.py`
---
### 二、前端优化
#### 1. payload 构建非空断言修复
- `m!.path``m?.path` + `.filter(Boolean)`,防止素材被删后 crash
- **文件**: `frontend/src/features/home/model/useHomeController.ts`
#### 2. 生成按钮展示后端进度消息
- 新增 `message` prop生成中显示如"(正在处理片段 2/3...)"
- **文件**: `frontend/src/features/home/ui/GenerateActionBar.tsx`, `HomePage.tsx`
#### 3. 新上传素材自动选中
- 上传成功后对比前后素材列表,新增的 ID 自动追加到 `selectedMaterials`
- **文件**: `frontend/src/features/home/model/useMaterials.ts`
#### 4. Material 接口统一
- 三处 `interface Material` 重复定义提取到 `shared/types/material.ts`
- **文件**: `frontend/src/shared/types/material.ts` (新建), `useMaterials.ts`, `useHomeController.ts`, `MaterialSelector.tsx`
#### 5. 拖拽排序修复
- 移除 `DragOverlay``backdrop-blur` 创建新 containing block 导致定位错乱)
- 改为 `useSortable` 原生拖拽 + `CSS.Translate`,拖拽中元素高亮加阴影
- **文件**: `frontend/src/features/home/ui/MaterialSelector.tsx`
#### 6. 素材选择上限 4 个
- `toggleMaterial` 新增 `MAX_MATERIALS = 4` 限制
- UI 选满后未选中项变半透明禁用,提示文字改为"可多选最多4个"
- **文件**: `useMaterials.ts`, `MaterialSelector.tsx`
#### 7. 移动端排序区域响应式
- 素材列表 `max-h-64``max-h-48 sm:max-h-64`
- **文件**: `MaterialSelector.tsx`
#### 8. 多素材耗时提示
- 选中 ≥2 素材时生成按钮下方显示"多素材模式 (N 个机位),生成耗时较长"
- **文件**: `GenerateActionBar.tsx`, `HomePage.tsx`
---
### 三、核心架构重构:先拼接再推理
#### V1 (Day 21): 逐段 LatentSync
```
素材A → LatentSync(素材A, 音频片段1) → lipsync_A
素材B → LatentSync(素材B, 音频片段2) → lipsync_B
FFmpeg concat(lipsync_A, lipsync_B) → 最终视频
```
- 缺点N 个素材 = N 次 LatentSync 推理(每次 ~30s
#### V2 (Day 22): 先拼接再推理
```
素材A → prepare_segment(裁剪到3.67s) → prepared_A
素材B → prepare_segment(裁剪到4.00s) → prepared_B
FFmpeg concat(prepared_A, prepared_B) → concat_video (7.67s)
LatentSync(concat_video, 完整音频) → 最终视频
```
- 优点:只需 **1 次** LatentSync 推理,时间从 N×30s 降为 1×30s
#### 新增 `prepare_segment()` 方法
```python
def prepare_segment(self, video_path, target_duration, output_path, target_resolution=None):
# 素材时长 > 目标: 裁剪 (-t)
# 素材时长 < 目标: 循环 (-stream_loop) + 裁剪
# 分辨率一致: -c copy 无损 (不重编码)
# 分辨率不一致: scale + pad 统一到第一个素材分辨率
```
#### 分辨率处理策略
- 新增 `get_resolution()` 方法检测各素材分辨率
- 所有素材分辨率相同时:`-c copy` 无损裁剪(保持原画质)
- 分辨率不一致时:统一到第一个素材的分辨率,`force_original_aspect_ratio=decrease` + `pad` 居中
- LatentSync 只处理嘴部 512×512 区域,输出保持原分辨率
#### 时间对齐验证
| 环节 | 时间基准 | 对齐关系 |
|------|---------|---------|
| TTS 音频 | 原始时长 (7.67s) | 基准 |
| Whisper 字幕 | 基于 TTS 音频 | 时间戳对齐音频 |
| 均分切分 | assignments 总时长 = 音频时长 | 首段 start=0, 末段 end=audio_duration |
| prepare 各段 | `-t seg_dur` 精确截断 | 总和 ≈ 音频时长 |
| LatentSync | concat_video + 完整音频 | 内部 0.5s 容差 |
| compose | lipsync_video + 音频/BGM | `-shortest` 保证同步 |
| Remotion | 基于 captions_path 渲染字幕 | 时间戳对齐音频 |
---
### 涉及文件汇总
| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `backend/app/modules/videos/workflow.py` | 修改 | 6 个 Bug 修复 + 流水线重构(先拼接再推理)|
| `backend/app/services/video_service.py` | 修改 | 新增 `prepare_segment()``get_resolution()``split_audio` 校验,`concat_videos` 空列表检查 |
| `frontend/src/shared/types/material.ts` | 新建 | 统一 Material 接口 |
| `frontend/src/features/home/model/useMaterials.ts` | 修改 | 上传自动选中、素材上限 4 个 |
| `frontend/src/features/home/model/useHomeController.ts` | 修改 | payload 非空断言修复、Material 接口引用 |
| `frontend/src/features/home/ui/MaterialSelector.tsx` | 修改 | 拖拽修复、上限 4 个 UI、移动端响应式 |
| `frontend/src/features/home/ui/GenerateActionBar.tsx` | 修改 | 进度消息展示、多素材耗时提示 |
| `frontend/src/features/home/ui/HomePage.tsx` | 修改 | 传递 message、materialCount prop |
---
### 四、AI 多语言翻译
#### 功能
在文案编辑区新增「AI多语言」按钮支持将中文口播文案一键翻译为 9 种语言,并可随时还原原文。
#### 支持语言
英语 English、日语 日本語、韩语 한국어、法语 Français、德语 Deutsch、西班牙语 Español、俄语 Русский、意大利语 Italiano、葡萄牙语 Português
#### 实现
##### 后端
- **`backend/app/services/glm_service.py`** — 新增 `translate_text()` 方法,调用智谱 GLM APItemperature=0.3prompt 要求只返回译文、保持语气风格
- **`backend/app/modules/ai/router.py`** — 新增 `POST /api/ai/translate` 接口,接收 `{text, target_lang}`,返回 `{translated_text}`
##### 前端
- **`frontend/src/features/home/ui/ScriptEditor.tsx`** — 新增 `LANGUAGES` 列表9 种语言)、语言下拉菜单(点击外部自动关闭)、翻译中 loading 状态、「还原原文」按钮(翻译过后出现在菜单顶部)
- **`frontend/src/features/home/model/useHomeController.ts`** — 新增 `handleTranslate`(调用翻译 API、首次翻译保存原文`originalText` 状态、`handleRestoreOriginal`(恢复原文)
#### 涉及文件
| 文件 | 变更 | 说明 |
|------|------|------|
| `backend/app/services/glm_service.py` | 修改 | 新增 `translate_text()` 方法 |
| `backend/app/modules/ai/router.py` | 修改 | 新增 `/api/ai/translate` 接口 |
| `frontend/src/features/home/ui/ScriptEditor.tsx` | 修改 | 语言菜单 UI、翻译 loading、还原原文按钮 |
| `frontend/src/features/home/model/useHomeController.ts` | 修改 | `handleTranslate``originalText``handleRestoreOriginal` |
---
### 五、TTS 多语言支持
#### 背景
翻译功能实现后,用户可将中文文案翻译为其他语言。但翻译后生成视频时 TTS 仍只支持中文:
- **EdgeTTS**:声音列表只有 5 个 `zh-CN-*` 中文声音
- **声音克隆 (Qwen3-TTS)**`language` 参数硬编码为 `"Chinese"`
#### 实现方案
##### 1. 前端:语言感知的声音列表
- `VOICES` 从扁平数组扩展为 `Record<string, VoiceOption[]>`,覆盖 10 种语言zh-CN / en-US / ja-JP / ko-KR / fr-FR / de-DE / es-ES / ru-RU / it-IT / pt-BR每种语言 2 个声音(男/女)
- 新增 `LANG_TO_LOCALE` 映射:翻译目标语言名 → EdgeTTS locale`"English" → "en-US"`
- 新增 `textLang` 状态,跟踪当前文案语言,默认 `"zh-CN"`
##### 2. 翻译时自动切换声音
- `handleTranslate` 成功后:根据目标语言设置 `textLang`EdgeTTS 模式下自动切换 `voice` 为目标语言的默认声音
- `handleRestoreOriginal` 还原时:重置 `textLang``"zh-CN"`,恢复中文默认声音
- `VoiceSelector` 根据 `textLang` 动态显示对应语言的声音列表
##### 3. 声音克隆语言透传
- 前端:新增 `LOCALE_TO_QWEN_LANG` 映射(`zh-CN→"Chinese"`, `en-US→"English"`, 其他→`"Auto"`
- 生成请求 payload 加入 `language` 字段(仅声音克隆模式)
- 后端 `GenerateRequest` schema 新增 `language: str = "Chinese"` 字段
- `workflow.py``language="Chinese"` 硬编码改为 `language=req.language`
##### 4. Bug 修复textLang 持久化
- **问题**: `voice` 已持久化但 `textLang` 未持久化,刷新页面后 `voice` 恢复为英文声音但 `textLang` 默认回中文,导致 VoiceSelector 显示中文声音列表却选中英文声音,无高亮按钮
- **修复**: 在 `useHomePersistence` 中加入 `textLang` 的 localStorage 读写
#### 数据流
```
用户翻译 "English"
→ ScriptEditor.onTranslate("English")
→ LANG_TO_LOCALE["English"] = "en-US"
→ setTextLang("en-US"), setVoice("en-US-GuyNeural")
→ VoiceSelector 显示 VOICES["en-US"] = [Guy, Jenny]
→ 生成时:
EdgeTTS: payload.voice = "en-US-GuyNeural"
声音克隆: payload.language = "English" (via getQwenLanguage)
```
#### 涉及文件
| 文件 | 变更 | 说明 |
|------|------|------|
| `frontend/src/features/home/model/useHomeController.ts` | 修改 | VOICES 多语言 Record、textLang 状态、LANG_TO_LOCALE / LOCALE_TO_QWEN_LANG 映射、翻译自动切换 voice |
| `frontend/src/features/home/model/useHomePersistence.ts` | 修改 | textLang 持久化读写 |
| `backend/app/modules/videos/schemas.py` | 修改 | GenerateRequest 加 `language` 字段 |
| `backend/app/modules/videos/workflow.py` | 修改 | 声音克隆调用处用 `req.language` 替代硬编码 |

View File

@@ -389,7 +389,7 @@ if not qr_element:
## 📋 文档规则优化 (16:42 - 17:10)
**问题**Doc_Rules需要优化,避免误删历史内容、规范工具使用、防止任务清单遗漏
**问题**DOC_RULES需要优化,避免误删历史内容、规范工具使用、防止任务清单遗漏
**优化内容(最终版)**
@@ -411,7 +411,7 @@ if not qr_element:
- 移除无关项目组件
**修改文件**
- `Docs/Doc_Rules.md` - 包含检查清单的最终完善版
- `Docs/DOC_RULES.md` - 包含检查清单的最终完善版
---

View File

@@ -8,8 +8,8 @@
| 规则 | 说明 |
|------|------|
| **默认更新** | 更新 `DayN.md` |
| **按需更新** | `task_complete.md` 仅在用户**明确要求**时更新 |
| **默认更新** | 更新 `DayN.md``TASK_COMPLETE.md` |
| **按需更新** | 其他文档仅在内容变化涉及时更新 |
| **智能修改** | 错误→替换,改进→追加(见下方详细规则) |
| **先读后写** | 更新前先查看文件当前内容 |
| **日内合并** | 同一天的多次小修改合并为最终版本 |
@@ -23,13 +23,14 @@
| 优先级 | 文件路径 | 检查重点 |
| :---: | :--- | :--- |
| 🔥 **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` | **(前端架构)** 拆分计划与阶段目标 |
| 🔥 **High** | `Docs/TASK_COMPLETE.md` | **(任务总览)** 更新 `[x]`、进度条、时间线 |
| ⚡ **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/字幕等独立部署文档 |
---
@@ -94,7 +95,7 @@
### 必须执行的检查步骤
**1. 快速浏览全文**(使用 `view_file``grep_search`
**1. 快速浏览全文**(使用 `Read``Grep`
```markdown
# 检查是否存在:
- 同主题的旧章节?
@@ -141,82 +142,61 @@
> **核心原则**:使用正确的工具,避免字符编码问题
### ✅ 推荐工具:apply_patch
### ✅ 推荐工具:Edit / Read / Grep
**使用场景**
- 追加新章节到文件末尾
- 修改/替换现有章节内容
- 更新状态标记(🔄 → ✅)
- 修正错误内容
**使用场景**
- `Read`:更新前先查看文件当前内容
- `Edit`:精确替换现有内容、追加新章节
- `Grep`:搜索文件中是否已有相关章节
- `Write`:创建新文件(如 Day{N+1}.md
**优势**
- ✅ 自动处理字符编码Windows CRLF
- ✅ 精确替换,不会误删其他内容
- ✅ 有错误提示,方便调试
**注意事项**
**注意事项**
```markdown
1. **必须精确匹配**TargetContent 必须与文件完全一致
2. **处理换行符**文件使用 \r\n不要漏掉 \r
3. **合理范围**StartLine/EndLine 应覆盖目标内容
4. **先读后写**:编辑前先 view_file 确认内容
1. **先读后写**:编辑前先用 Read 确认内容
2. **精确匹配**Edit 的 old_string 必须与文件内容完全一致
3. **避免重复**编辑前用 Grep 检查是否已存在同主题章节
```
### ❌ 禁止使用:命令行工具
### ❌ 禁止使用:命令行工具修改文档
**禁止场景**
- ❌ 使用 `echo >>` 追加内容(编码问题)
- ❌ 使用 PowerShell 直接修改文档(破坏格式)
- ❌ 使用 sed/awk 等命令行工具
- ❌ 使用 `echo >>` 追加内容
- ❌ 使用 `sed` / `awk` 修改文档
- ❌ 使用 `cat <<EOF` 写入内容
**原因**
- 容易破坏 UTF-8 编码
- Windows CRLF vs Unix LF 混乱
- 容易破坏 UTF-8 编码和中文字符
- 难以追踪修改,容易出错
**唯一例外**:简单的全局文本替换(如批量更新日期),且必须使用 `-NoNewline` 参数
- 无法精确匹配替换位置
### 📝 最佳实践示例
**追加新章节**
```diff
*** Begin Patch
*** Update File: Docs/DevLogs/DayN.md
@@
## 🔗 相关文档
...
---
## 🆕 新章节
内容...
*** End Patch
```
**追加新章节**使用 `Edit` 工具,`old_string` 匹配文件末尾内容,`new_string` 包含原内容 + 新章节。
**修改现有内容**
```diff
*** Begin Patch
*** Update File: Docs/DevLogs/DayN.md
@@
-**状态**:🔄 待修复
+**状态**:✅ 已修复
*** End Patch
```
**修改现有内容**使用 `Edit` 工具精确替换。
```markdown
old_string: "**状态**:🔄 待修复"
new_string: "**状态**:✅ 已修复"
```
---
## 📁 文件结构
## 📁 文件结构
```
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 # 前端功能文档
├── DEPLOY_MANUAL.md # 部署手册
├── SUPABASE_DEPLOY.md # Supabase 部署文档
├── LATENTSYNC_DEPLOY.md # LatentSync 部署文档
├── QWEN3_TTS_DEPLOY.md # 声音克隆部署文档
├── SUBTITLE_DEPLOY.md # 字幕系统部署文档
└── DevLogs/
├── Day1.md # 开发日志
└── ...
@@ -224,17 +204,25 @@ ViGent2/Docs/
---
## 📅 DayN.md 更新规则(日常更新)
## 📅 DayN.md 更新规则(日常更新)
### 更新时机
> **边开发边记录,不要等到最后才写。**
- 每完成一个功能/修复后,**立即**追加到 DayN.md
- 避免积攒到对话末尾一次性补写,容易遗漏变更
- `TASK_COMPLETE.md` 同理,重要变更完成后及时同步
### 新建判断 (对话开始前)
1. **回顾进度**:查看 `task_complete.md` 了解当前状态
1. **回顾进度**:查看 `TASK_COMPLETE.md` 了解当前状态
2. **检查日期**:查看最新 `DayN.md`
- **今天 (与当前日期相同)** → 🚨 **绝对禁止创建新文件**,必须**追加**到现有 `DayN.md` 末尾!即使是完全不同的功能模块。
- **之前 (昨天或更早)** → 创建 `Day{N+1}.md`
### 追加格式
```markdown
---
### 追加格式
```markdown
---
## 🔧 [章节标题]
@@ -250,18 +238,18 @@ ViGent2/Docs/
- ✅ 修复了 xxx
```
### 快速修复格式
```markdown
## 🐛 [Bug 简述] (HH:MM)
### 快速修复格式
```markdown
## 🐛 [Bug 简述] (HH:MM)
**问题**:一句话描述
**修复**:修改了 `文件名` 中的 xxx
**状态**:✅ 已修复 / 🔄 待验证
```
### ⚠️ 注意
- **DayN.md 文件开头禁止使用 `---`**,避免被解析为 Front Matter。
- 分隔线只用于章节之间,不作为文件第一行。
**状态**:✅ 已修复 / 🔄 待验证
```
### ⚠️ 注意
- **DayN.md 文件开头禁止使用 `---`**,避免被解析为 Front Matter。
- 分隔线只用于章节之间,不作为文件第一行。
---
@@ -283,17 +271,17 @@ ViGent2/Docs/
---
## 📝 task_complete.md 更新规则(仅按需)
## 📝 TASK_COMPLETE.md 更新规则
> ⚠️ **仅当用户明确要求更新 `task_complete.md` 时才更新**
> 与 DayN.md 同步更新,记录重要变更时更新任务总览。
### 更新原则
- **格式一致性**:直接参考 `task_complete.md` 现有格式追加内容。
- **格式一致性**:直接参考 `TASK_COMPLETE.md` 现有格式追加内容。
- **进度更新**:仅在阶段性里程碑时更新进度百分比。
### 🔍 完整性检查清单 (必做)
每次更新 `task_complete.md` 时,必须**逐一检查**以下所有板块:
每次更新 `TASK_COMPLETE.md` 时,必须**逐一检查**以下所有板块:
1. **文件头部 & 导航**
- [ ] `更新时间`:必须是当天日期
@@ -316,4 +304,4 @@ ViGent2/Docs/
---
**最后更新**2026-02-04
**最后更新**2026-02-08

View File

@@ -2,22 +2,68 @@
## 目录结构
采用轻量 FSDFeature-Sliced Design结构
```
frontend/src/
├── app/ # Next.js App Router 页面
│ ├── page.tsx # 首页(视频生成)
│ ├── publish/ # 发布页
│ ├── admin/ # 管理员页面
│ ├── login/ # 登录页面
│ └── register/ # 注册页面
├── components/ # 可复用组件
│ ├── home/ # 首页拆分组件
└── ...
├── lib/ # 公共工具函数
├── axios.ts # Axios 实例(含 401/403 拦截器)
├── auth.ts # 认证相关函数
└── media.ts # API Base / URL / 日期等通用工具
└── proxy.ts # 路由代理(原 middleware
├── app/ # Next.js App Router 页面入口
│ ├── page.tsx # 首页(视频生成)
│ ├── publish/ # 发布管理
│ ├── admin/ # 管理员页面
│ ├── login/ # 登录
│ └── register/ # 注册
├── features/ # 功能模块(按业务拆分)
│ ├── home/
│ ├── model/ # 业务逻辑 hooks
├── useHomeController.ts # 主控制器
│ │ ├── useHomePersistence.ts # 持久化管理
│ │ ├── useBgm.ts
│ │ ├── useGeneratedVideos.ts
│ ├── useMaterials.ts
│ │ │ ├── useMediaPlayers.ts
│ │ │ ├── useRefAudios.ts
│ │ │ └── useTitleSubtitleStyles.ts
│ │ └── ui/ # UI 组件(纯 props + 回调)
│ │ ├── HomePage.tsx
│ │ ├── HomeHeader.tsx
│ │ ├── MaterialSelector.tsx
│ │ ├── ScriptEditor.tsx
│ │ ├── ScriptExtractionModal.tsx
│ │ ├── script-extraction/
│ │ │ └── useScriptExtraction.ts
│ │ ├── TitleSubtitlePanel.tsx
│ │ ├── FloatingStylePreview.tsx
│ │ ├── VoiceSelector.tsx
│ │ ├── RefAudioPanel.tsx
│ │ ├── BgmPanel.tsx
│ │ ├── GenerateActionBar.tsx
│ │ ├── PreviewPanel.tsx
│ │ └── HistoryList.tsx
│ └── publish/
│ ├── model/
│ │ └── usePublishController.ts
│ └── ui/
│ └── PublishPage.tsx
├── shared/ # 跨功能共享
│ ├── api/
│ │ ├── axios.ts # Axios 实例(含 401/403 拦截器)
│ │ └── types.ts # 统一响应类型
│ ├── lib/
│ │ ├── media.ts # API Base / URL / 日期等通用工具
│ │ ├── auth.ts # 认证相关函数
│ │ └── title.ts # 标题输入处理
│ ├── hooks/
│ │ ├── useTitleInput.ts
│ │ └── usePublishPrefetch.ts
│ ├── types/
│ │ ├── user.ts # User 类型定义
│ │ └── publish.ts # 发布相关类型
│ └── contexts/ # 全局 ContextAuth、Task
│ ├── AuthContext.tsx
│ └── TaskContext.tsx
├── components/ # 遗留通用组件
│ └── VideoPreviewModal.tsx
└── proxy.ts # Next.js middleware路由保护
```
---
@@ -202,12 +248,47 @@ import { formatDate } from '@/shared/lib/media';
---
## ⚡️ 体验优化规范
### 路由预取
- 首页进入发布管理时使用 `router.prefetch("/publish")`
- 只预取路由,不在首页渲染发布页组件
### 发布页数据预取缓存
- 使用 `sessionStorage` 保存最近的 `accounts/videos`
- 缓存 TTL 2 分钟,进入发布页先读缓存,随后后台刷新
### 骨架屏
- 账号列表、作品列表、素材列表在加载时显示骨架
- 骨架数量应与历史数据数量相近(避免加载时数量跳变)
### 预览加载优化
- 预览 `video` 使用 `preload="metadata"`
- 发布页预览按钮可进行短时 `preload` 预取
---
## 轻量 FSD 结构
- `app/`:页面入口,保持轻量
- `features/*/model`:业务逻辑与状态 (hooks)
- `features/*/ui`:功能 UI 组件
- `shared/`:通用工具、通用 hooks、API 实例
- `app/`:页面入口,保持轻量,只做组合与布局
- `features/*/model`:业务逻辑与状态Controller Hook + 子 Hook
- `features/*/ui`:功能 UI 组件(纯 props + 回调,不直接发 API
- `shared/api`Axios 实例与统一响应类型
- `shared/lib`通用工具函数media.ts / auth.ts / title.ts
- `shared/hooks`:跨功能通用 hooks
- `shared/types`跨功能实体类型User / PublishVideo 等)
- `shared/contexts`:全局 ContextAuthContext / TaskContext
- `components/`遗留通用组件VideoPreviewModal
## 类型定义规范
- 通用实体类型(如 User, Account, Video统一放置在 `src/shared/types/`
- 特定业务类型放在 feature 目录下的 types.ts 或 model 中。
- **禁止**在多个地方重复定义 User 接口,统一引用 `import { User } from '@/shared/types/user';`
---
@@ -226,6 +307,7 @@ import { formatDate } from '@/shared/lib/media';
- **恢复先于保存**:恢复完成前禁止写入(`isRestored` 保护)。
- 避免默认值覆盖用户选择(优先读取已保存值)。
- 优先使用 `useHomePersistence` 集中管理恢复/保存,页面内避免分散的 localStorage 读写。
- **禁止使用签名 URL 作为持久化标识**Supabase Storage 签名 URL 每次请求都变化,必须使用后端返回的稳定 `id` 字段。
- 如需新增持久化字段,必须加入恢复与保存逻辑,并更新本节。
---
@@ -240,6 +322,14 @@ import { formatDate } from '@/shared/lib/media';
---
## 发布页交互规则
- 发布按钮在未选择任何平台时禁用
- 仅保留"立即发布",不再提供定时发布 UI/参数
- **作品选择持久化**:使用 `video.id`(稳定标识)而非 `video.path`(签名 URL进行选择、比较和 localStorage 存储。发布时根据 `id` 查找对应 `path` 发送请求。
---
## 新增页面 Checklist
1. [ ] 导入 `import api from '@/shared/api/axios'`

View File

@@ -6,6 +6,7 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
### 1. 视频生成 (`/`)
- **素材管理**: 拖拽上传人物视频,实时预览。
- **素材重命名**: 支持在列表中直接重命名素材。
- **文案配音**: 集成 EdgeTTS支持多音色选择 (云溪 / 晓晓)。
- **AI 标题/标签**: 一键生成视频标题与标签 (Day 14)。
- **标题/字幕样式**: 样式选择 + 预览 + 字号调节 (Day 16)。
@@ -14,18 +15,21 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
- **预览一致性**: 标题/字幕预览按素材分辨率缩放,效果更接近成片 (Day 17)。
- **进度追踪**: 实时显示视频生成进度 (10% -> 100%)。
- **作品预览**: 生成完成后直接播放下载(作品预览 + 历史作品)。
- **预览优化**: 预览视频 `metadata` 预取,首帧加载更快。
- **本地保存**: 文案/标题/偏好由 `useHomePersistence` 统一持久化,刷新后恢复 (Day 14/17)。
- **选择持久化**: 首页/发布页作品选择均使用稳定 `id` 持久化,刷新保持用户选择;新视频生成后自动选中最新 (Day 21)。
### 2. 全自动发布 (`/publish`) [Day 7 新增]
- **多平台管理**: 统一管理 B站、抖音、小红书账号状态。
- **多平台管理**: 统一管理抖音、微信视频号、B站、小红书账号状态。
- **扫码登录**:
- 集成后端 Playwright 生成的 QR Code。
- 实时检测扫码状态 (Wait/Success)。
- Cookie 自动保存与状态同步。
- **发布配置**: 设置视频标题、标签、简介。
- **作品选择**: 卡片列表 + 搜索 + 预览弹窗。
- **选择持久化**: 使用稳定 `video.id` 持久化选择,刷新保持;新视频生成自动选中最新 (Day 21)。
- **预览兼容**: 签名 URL / 相对路径均可直接预览。
- **定时任务**: 支持 "立即发布" 或 "定时发布"
- **发布方式**: 支持 "立即发布"。
### 3. 声音克隆 [Day 13 新增]
- **TTS 模式选择**: EdgeTTS (预设音色) / 声音克隆 (自定义音色) 切换。

View File

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

View File

@@ -1,416 +0,0 @@
# 数字人口播视频生成系统 - 实现计划
## 项目目标
构建一个开源的数字人口播视频生成系统,功能包括:
- 上传静态人物视频 → 生成口播视频(唇形同步)
- TTS 配音或声音克隆
- 字幕自动生成与渲染
- AI 自动生成标题与标签
- 一键发布到多个社交平台
---
## 技术架构
```
┌─────────────────────────────────────────────────────────┐
│ 前端 (Next.js) │
│ 素材管理 | 视频生成 | 发布管理 | 任务状态 │
└─────────────────────────────────────────────────────────┘
│ REST API
┌─────────────────────────────────────────────────────────┐
│ 后端 (FastAPI) │
├─────────────────────────────────────────────────────────┤
│ 异步任务队列 (asyncio) │
│ ├── 视频生成任务 │
│ ├── TTS 配音任务 │
│ └── 自动发布任务 │
└─────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│LatentSync│ │ FFmpeg │ │Playwright│
│ 唇形同步 │ │ 视频合成 │ │ 自动发布 │
└──────────┘ └──────────┘ └──────────┘
```
---
## 技术选型
| 模块 | 技术选择 | 备选方案 |
|------|----------|----------|
| **前端框架** | 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 / 日期格式化。
- 作品预览弹窗统一样式,并支持素材/发布预览复用。
- 标题/字幕预览按素材分辨率缩放,效果更接近成片。
---
## 分阶段实施计划
### 阶段一:核心功能验证 (MVP)
> **目标**:验证 LatentSync + EdgeTTS 效果,跑通端到端流程
#### 1.1 环境搭建
参考 `models/LatentSync/DEPLOY.md` 完成 LatentSync 环境与权重部署。
#### 1.2 集成 EdgeTTS
```python
# tts_engine.py
import edge_tts
import asyncio
async def text_to_speech(text: str, voice: str = "zh-CN-YunxiNeural", output_path: str = "output.mp3"):
communicate = edge_tts.Communicate(text, voice)
await communicate.save(output_path)
return output_path
```
#### 1.3 端到端测试脚本
```python
# test_pipeline.py
"""
1. 文案 → EdgeTTS → 音频
2. 静态视频 + 音频 → LatentSync → 口播视频
3. 添加字幕 → FFmpeg → 最终视频
"""
```
#### 1.4 验证标准
- [ ] LatentSync 能正常推理
- [ ] 唇形与音频同步率 > 90%
- [ ] 单个视频生成时间 < 2 分钟
---
### 阶段二:后端 API 开发
> **目标**:将核心功能封装为 API支持异步任务
#### 2.1 项目结构
```
backend/
├── app/
│ ├── main.py # FastAPI 入口
│ ├── api/
│ │ ├── videos.py # 视频生成 API
│ │ ├── materials.py # 素材管理 API
│ │ └── publish.py # 发布管理 API
│ ├── services/
│ │ ├── tts_service.py # TTS 服务
│ │ ├── lipsync_service.py # 唇形同步服务
│ │ └── video_service.py # 视频合成服务
│ ├── tasks/
│ │ └── celery_tasks.py # Celery 异步任务
│ ├── models/
│ │ └── schemas.py # Pydantic 模型
│ └── core/
│ └── config.py # 配置管理
├── requirements.txt
└── docker-compose.yml # Redis + API
```
#### 2.2 核心 API 设计
| 端点 | 方法 | 功能 |
|------|------|------|
| `/api/materials` | POST | 上传视频素材 | ✅ |
| `/api/materials` | GET | 获取素材列表 | ✅ |
| `/api/videos/generate` | POST | 创建视频生成任务 | ✅ |
| `/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)
```
---
### 阶段三:前端 Web UI
> **目标**:提供用户友好的操作界面
#### 3.1 页面设计
| 页面 | 功能 |
|------|------|
| **素材库** | 上传/管理多场景视频素材 |
| **生成视频** | 输入文案、选择素材、生成预览 |
| **任务中心** | 查看生成进度、下载视频 |
| **发布管理** | 绑定平台、一键发布、定时发布 |
#### 3.2 技术实现
```bash
# 创建 Next.js 项目
npx create-next-app@latest frontend --typescript --tailwind --app
# 安装依赖
cd frontend
npm install axios swr
```
---
### 阶段四:社交媒体发布
> **目标**:集成 social-auto-upload支持多平台发布
#### 4.1 复用 social-auto-upload
```bash
# 复制模块
cp -r SuperIPAgent/social-auto-upload backend/social_upload
```
#### 4.2 Cookie 管理
```python
# 用户通过浏览器登录 → 保存 Cookie → 后续自动发布
```
#### 4.3 支持平台
- 抖音
- 小红书
- 微信视频号
- 快手
---
### 阶段五:优化与扩展
| 功能 | 实现方式 |
|------|----------|
| **声音克隆** | 集成 GPT-SoVITS用自己的声音 |
| **AI 标题/标签生成** | 调用大模型 API 自动生成标题与标签 ✅ |
| **批量生成** | 上传 Excel/CSV批量生成视频 |
| **字幕编辑器** | 可视化调整字幕样式、位置 |
| **Docker 部署** | 一键部署到云服务器 | ✅ |
---
### 阶段六MuseTalk 服务器部署 (Day 2-3) ✅
> **目标**:在双显卡服务器上部署 MuseTalk 环境
- [x] Conda 环境配置 (musetalk)
- [x] 模型权重下载 (~7GB)
- [x] Subprocess 调用方式实现
- [x] 健康检查功能
### 阶段七MuseTalk 完整修复 (Day 4) ✅
> **目标**:解决推理脚本的各种兼容性问题
- [x] 权重检测路径修复 (软链接)
- [x] 音视频长度不匹配修复
- [x] 推理脚本错误日志增强
- [x] 视频合成 MP4 生成验证
### 阶段八:前端功能增强 (Day 5) ✅
> **目标**:提升用户体验
- [x] Web 视频上传功能
- [x] 上传进度显示
- [x] 自动刷新素材列表
### 阶段九:唇形同步模型升级 (Day 6) ✅
> **目标**:从 MuseTalk 迁移到 LatentSync 1.6
- [x] MuseTalk → LatentSync 1.6 迁移
- [x] 后端代码适配 (config.py, lipsync_service.py)
- [x] Latent Diffusion 架构 (512x512 高清)
- [x] 服务器端到端验证
### 阶段十:性能优化 (Day 6) ✅
> **目标**:提升系统响应速度和稳定性
- [x] 视频预压缩优化 (1080p → 720p 自动适配)
- [x] 进度更新细化 (实时反馈)
- [x] **常驻模型服务** (Persistent Server, 0s 加载)
- [x] **GPU 并发控制** (串行队列防崩溃)
### 阶段十一:社交媒体发布完善 (Day 7) ✅
> **目标**:实现全自动扫码登录和多平台发布
- [x] QR码自动登录 (Playwright headless + Stealth)
- [x] 多平台上传器架构 (B站/抖音/小红书)
- [x] Cookie 自动管理
- [x] 定时发布功能
### 阶段十二:用户体验优化 (Day 8) ✅
> **目标**:提升文件管理和历史记录功能
- [x] 文件名保留 (时间戳前缀 + 原始名称)
- [x] 视频持久化 (历史视频列表 API)
- [x] 素材/视频删除功能
### 阶段十三:发布模块优化 (Day 9) ✅
> **目标**:代码质量优化 + 发布功能验证
- [x] B站/抖音登录+发布验证通过
- [x] 资源清理保障 (try-finally)
- [x] 超时保护 (消除无限循环)
- [x] 完整类型提示
### 阶段十四:用户认证系统 (Day 9) ✅
> **目标**:实现安全、隔离的多用户认证体系
- [x] Supabase 云数据库集成 (本地自托管)
- [x] JWT + HttpOnly Cookie 认证架构
- [x] 用户表与权限表设计 (RLS 准备)
- [x] 认证部署文档 (Docs/SUPABASE_DEPLOY.md)
### 阶段十五:部署稳定性优化 (Day 9) ✅
> **目标**:确保生产环境服务长期稳定
- [x] 依赖冲突修复 (bcrypt)
- [x] 前端构建修复 (Production Build)
- [x] PM2 进程守护配置
- [x] 部署手册更新 (Docs/DEPLOY_MANUAL.md)
### 阶段十六HTTPS 全栈部署 (Day 10) ✅
> **目标**:实现安全的公网 HTTPS 访问
- [x] 阿里云 Nginx 反向代理配置
- [x] Let's Encrypt SSL 证书集成
- [x] Supabase 自托管部署 (Docker)
- [x] 端口冲突解决 (3003/8008/8444)
- [x] Basic Auth 管理后台保护
### 阶段十七:声音克隆功能集成 (Day 13) ✅
> **目标**:实现用户自定义声音克隆能力
- [x] Qwen3-TTS HTTP 服务 (独立 FastAPI端口 8009)
- [x] 声音克隆服务封装 (voice_clone_service.py)
- [x] 参考音频管理 API (上传/列表/删除)
- [x] 前端 TTS 模式选择 UI
- [x] Supabase ref-audios Bucket 配置
- [x] 端到端测试验证
### 阶段十八:手机号登录迁移 (Day 15) ✅
> **目标**:将认证系统从邮箱迁移到手机号
- [x] 数据库 Schema 迁移 (email → phone)
- [x] 后端 API 适配 (auth.py/admin.py)
- [x] 11位手机号校验 (正则验证)
- [x] 修改密码功能 (/api/auth/change-password)
- [x] 账户设置下拉菜单 (修改密码 + 有效期显示 + 退出)
- [x] 前端登录/注册页面更新
- [x] 数据库迁移脚本 (migrate_to_phone.sql)
### 阶段十九:深度性能优化与服务守护 (Day 16) ✅
> **目标**:提升系统响应速度与服务稳定性
- [x] Flash Attention 2 集成 (Qwen3-TTS 加速 5x)
- [x] LatentSync 性能调优 (OMP 线程限制 + 原生 Flash Attn)
- [x] Watchdog 服务守护 (自动重启僵死服务)
- [x] 文档体系更新 (部署手册与运维指南)
---
## 项目目录结构 (最终)
---
## 开发时间估算
| 阶段 | 预计时间 | 说明 |
|------|----------|------|
| 阶段一 | 2-3 天 | 环境搭建 + 效果验证 |
| 阶段二 | 3-4 天 | 后端 API 开发 |
| 阶段三 | 3-4 天 | 前端 UI 开发 |
| 阶段四 | 2 天 | 社交发布集成 |
| 阶段五 | 按需 | 持续优化 |
**总计**:约 10-13 天可完成 MVP
---
## 验证计划
### 阶段一验证
1. 运行 `test_pipeline.py` 脚本
2. 检查生成视频的唇形同步效果
3. 确认音画同步
### 阶段二验证
1. 使用 Postman/curl 测试所有 API 端点
2. 验证任务队列正常工作
3. 检查视频生成完整流程
### 阶段三验证
1. 在浏览器中完成完整操作流程
2. 验证上传、生成、下载功能
3. 检查响应式布局
### 阶段四验证
1. 发布一个测试视频到抖音
2. 验证定时发布功能
3. 检查发布状态同步
---
## 硬件要求
| 配置 | 最低要求 | 推荐配置 |
|------|----------|----------|
| **GPU** | NVIDIA GTX 1060 6GB | RTX 3060 12GB+ |
| **内存** | 16GB | 32GB |
| **存储** | 100GB SSD | 500GB SSD |
| **CUDA** | 11.7+ | 12.0+ |
---
## 下一步行动
1. **确认你的 GPU 配置** - MuseTalk 需要 NVIDIA GPU
2. **选择开发起点** - 从阶段一开始验证效果
3. **确定项目位置** - 在哪个目录创建项目
---
> [!IMPORTANT]
> 请确认以上计划是否符合你的需求,有任何需要调整的地方请告诉我。

View File

@@ -1,8 +1,8 @@
# ViGent2 开发任务清单 (Task Log)
**项目**: ViGent2 数字人口播视频生成系统
**进度**: 100% (Day 17 - 前端重构与体验优化)
**更新时间**: 2026-02-04
**项目**: ViGent2 数字人口播视频生成系统
**进度**: 100% (Day 21 - 缺陷修复与持久化回归治理)
**更新时间**: 2026-02-08
---
@@ -10,29 +10,78 @@
> 这里记录了每一天的核心开发内容与 milestone。
### Day 17: 前端重构与体验优化 (Current) 🚀
- [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 21: 缺陷修复 + 浮动预览 + 发布重构 + 架构优化 + 多素材生成 (Current)
- [x] **Remotion 崩溃容错**: 渲染进程 SIGABRT 退出时检查输出文件,避免误判失败导致标题/字幕丢失
- [x] **首页作品选择持久化**: 修复 `fetchGeneratedVideos` 无条件覆盖恢复值的问题,新增 `preferVideoId` 参数控制选中逻辑
- [x] **发布页作品选择持久化**: 根因为签名 URL 不稳定,全面改用 `video.id` 替代 `path` 进行选择/持久化/比较
- [x] **预取缓存补全**: 首页预取发布页数据时加入 `id` 字段,确保缓存数据可用于持久化匹配
- [x] **浮动样式预览窗口**: 标题字幕预览改为 `position: fixed` 浮动窗口,固定左上角,滚动时始终可见
- [x] **移动端适配**: ScriptEditor 按钮换行、预览默认比例改为 9:16 竖屏
- [x] **多平台发布重构**: 平台配置独立化DOUYIN_*/WEIXIN_*)、用户隔离 Cookie 管理、抖音刷脸验证二维码、微信发布流程优化
- [x] **前端结构微调**: ScriptExtractionModal 迁移到 features/、contexts 迁移到 shared/contexts/、清理空目录
- [x] **后端模块分层**: materials/tools/ref_audios 三个模块补全 router+schemas+service 分层
- [x] **开发规范更新**: BACKEND_DEV.md 新增渐进原则、DOC_RULES.md 取消 TASK_COMPLETE.md 手动触发约束
- [x] **文档全面更新**: BACKEND_DEV/README、FRONTEND_DEV、DEPLOY_MANUAL、README.md 同步更新
- [x] **多素材视频生成(多机位效果)**: 支持多选素材 + 拖拽排序,按素材数量均分音频时长(对齐 Whisper 字边界)自动切换机位。逐段 LatentSync + FFmpeg 拼接。前端 @dnd-kit 拖拽排序 UI
- [x] **字幕开关移除**: 默认启用逐字高亮字幕,移除开关及相关死代码
- [x] **视频格式扩展**: 上传支持 mkv/webm/flv/wmv/m4v/ts/mts 等常见格式
- [x] **Watchdog 优化**: 健康检查阈值提高到 5 次,新增重启冷却期 120 秒,避免误重启
- [x] **多素材 Bug 修复**: 修复标点分句方案对无句末标点文案无效(改为均分方案)、音频时间偏移导致口型不对齐等缺陷
### Day 20: 代码质量与安全优化
- [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。
- [x] **缺陷修复**: 修复 Remotion 路径解析、发布页持久化竞态、首页选中回归、素材闭包陷阱。
### 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 位手机号注册/登录。
@@ -82,6 +131,7 @@
### 🔴 优先待办
- [ ] **批量生成架构**: 支持 Excel 导入,批量生产视频。
- [ ] **定时任务后台化**: 迁移前端触发的定时发布到后端 APScheduler。
- [ ] **发布任务恢复机制**: 发布任务化 + 状态持久化 + 前端断点恢复,解决刷新后状态丢失。
### 🔵 长期探索
- [ ] **容器化交付**: 提供完整的 Docker Compose 一键部署包。
@@ -97,7 +147,7 @@
| **Web UI** | 100% | ✅ 稳定 (移动端适配) |
| **唇形同步** | 100% | ✅ LatentSync 1.6 |
| **TTS 配音** | 100% | ✅ EdgeTTS + Qwen3 |
| **自动发布** | 100% | ✅ B站/抖音/小红书 |
| **自动发布** | 100% | ✅ 抖音/微信视频号/B站/小红书 |
| **用户认证** | 100% | ✅ 手机号 + JWT |
| **部署运维** | 100% | ✅ PM2 + Watchdog |
@@ -105,5 +155,5 @@
## 📎 相关文档
- [详细开发日志 (DevLogs)](file:///d:/CodingProjects/Antigravity/ViGent2/Docs/DevLogs/)
- [部署手册 (DEPLOY_MANUAL)](file:///d:/CodingProjects/Antigravity/ViGent2/Docs/DEPLOY_MANUAL.md)
- [详细开发日志 (DevLogs)](Docs/DevLogs/)
- [部署手册 (DEPLOY_MANUAL)](Docs/DEPLOY_MANUAL.md)

View File

@@ -15,22 +15,24 @@
## ✨ 功能特性
### 核心能力
- 🎬 **高清唇形同步** - LatentSync 1.6 驱动512×512 高分辨率 Latent Diffusion 模型。
- 🎙️ **多模态配音** - 支持 **EdgeTTS** (微软超自然语音) 和 **Qwen3-TTS** (3秒极速声音克隆)。
- 📝 **智能字幕** - 集成 faster-whisper + Remotion自动生成逐字高亮 (卡拉OK效果) 字幕。
- 🎨 **样式预设** - 标题/字幕样式选择 + 预览 + 字号调节,支持自定义字体库。
- 🖼️ **作品预览一致性** - 标题/字幕预览按素材分辨率缩放,效果更接近成片。
- 💾 **用户偏好持久化** - 首页状态统一恢复/保存,刷新后延续上次配置。
- 🎵 **背景音乐** - 试听 + 音量控制 + 混音,保持配音音量稳定。
- 🤖 **AI 辅助创作** - 内置 GLM-4.7-Flash支持 B站/抖音链接文案提取、AI 洗稿、标题/标签自动生成。
### 核心能力
- 🎬 **高清唇形同步** - LatentSync 1.6 驱动512×512 高分辨率 Latent Diffusion 模型。
- 🎙️ **多模态配音** - 支持 **EdgeTTS** (微软超自然语音) 和 **Qwen3-TTS** (3秒极速声音克隆)。
- 📝 **智能字幕** - 集成 faster-whisper + Remotion自动生成逐字高亮 (卡拉OK效果) 字幕。
- 🎨 **样式预设** - 标题/字幕样式选择 + 预览 + 字号调节,支持自定义字体库。
- 🖼️ **作品预览一致性** - 标题/字幕预览按素材分辨率缩放,效果更接近成片。
- 💾 **用户偏好持久化** - 首页状态统一恢复/保存,刷新后延续上次配置。
- 🎵 **背景音乐** - 试听 + 音量控制 + 混音,保持配音音量稳定。
- 🤖 **AI 辅助创作** - 内置 GLM-4.7-Flash支持 B站/抖音链接文案提取、AI 洗稿、标题/标签自动生成。
### 平台化功能
- 📱 **全自动发布** - 支持 B站、抖音、小红书定时发布扫码登录 + Cookie 持久化。
- 🖥️ **发布管理预览** - 支持签名 URL / 相对路径作品预览,确保可直接播放。
- 🔐 **认证与隔离** - 基于 Supabase 的用户隔离,支持手机号注册/登录、密码管理
- 📱 **全自动发布** - 支持抖音/微信视频号/B站/小红书立即发布扫码登录 + Cookie 持久化。
- 🖥️ **发布管理预览** - 支持签名 URL / 相对路径作品预览,确保可直接播放。
- 📸 **发布结果可视化** - 抖音/微信视频号发布成功后返回截图,发布页结果卡片可直接查看
- 🛡️ **发布防误操作** - 发布进行中自动提示“请勿刷新或关闭网页”,并拦截刷新/关页二次确认。
- 🔐 **认证与隔离** - 基于 Supabase 的用户隔离,支持手机号注册/登录、密码管理。
- 🛡️ **服务守护** - 内置 Watchdog 看门狗机制,自动监控并重启僵死服务,确保 7x24h 稳定运行。
- 🚀 **性能优化** - 视频预压缩、模型常驻服务(近实时加载)、双 GPU 流水线并发。
- 🚀 **性能优化** - 视频预压缩、模型常驻服务(近实时加载)、双 GPU 流水线并发。
---
@@ -38,7 +40,7 @@
| 领域 | 核心技术 | 说明 |
|------|----------|------|
| **前端** | Next.js 16 | TypeScript, TailwindCSS, SWR |
| **前端** | Next.js 16 | TypeScript, TailwindCSS, SWR |
| **后端** | FastAPI | Python 3.10, AsyncIO, PM2 |
| **数据库** | Supabase | PostgreSQL, Storage (本地/S3), Auth |
| **唇形同步** | LatentSync 1.6 | PyTorch 2.5, Diffusers, DeepCache |
@@ -56,10 +58,11 @@
- **[部署手册 (DEPLOY_MANUAL.md)](Docs/DEPLOY_MANUAL.md)** - 👈 **部署请看这里**!包含完整的环境搭建步骤。
- [参考音频服务部署 (QWEN3_TTS_DEPLOY.md)](Docs/QWEN3_TTS_DEPLOY.md) - 声音克隆模型部署指南。
- [LatentSync 部署指南](models/LatentSync/DEPLOY.md) - 唇形同步模型独立部署。
- [用户认证部署 (AUTH_DEPLOY.md)](Docs/AUTH_DEPLOY.md) - Supabase 与 Auth 系统配置。
- [Supabase 部署指南 (SUPABASE_DEPLOY.md)](Docs/SUPABASE_DEPLOY.md) - Supabase 与认证系统配置。
### 开发文档
- [后端开发指南](Docs/BACKEND_README.md) - 接口规范与开发流程。
- [后端开发规范](Docs/BACKEND_DEV.md) - 分层约定与开发习惯。
- [前端开发指南](Docs/FRONTEND_DEV.md) - UI 组件与页面规范。
- [开发日志 (DevLogs)](Docs/DevLogs/) - 每日开发进度与技术决策记录。
@@ -71,9 +74,11 @@
ViGent2/
├── backend/ # FastAPI 后端服务
│ ├── app/ # 核心业务逻辑
│ ├── scripts/ # 运维脚本 (Watchdog 等)
── tests/ # 测试用例
│ ├── assets/ # 字体 / 样式 / BGM
── user_data/ # 用户隔离数据 (Cookie 等)
│ └── scripts/ # 运维脚本 (Watchdog 等)
├── frontend/ # Next.js 前端应用
├── remotion/ # Remotion 视频渲染 (标题/字幕合成)
├── models/ # AI 模型仓库
│ ├── LatentSync/ # 唇形同步服务
│ └── Qwen3-TTS/ # 声音克隆服务

View File

@@ -15,7 +15,6 @@ DEFAULT_TTS_VOICE=zh-CN-YunxiNeural
# GPU 选择 (0=第一块GPU, 1=第二块GPU)
LATENTSYNC_GPU_ID=1
# 使用本地模式 (true) 或远程 API (false)
# 使用本地模式 (true) 或远程 API (false)
LATENTSYNC_LOCAL=true
@@ -66,3 +65,11 @@ ADMIN_PASSWORD=lam1988324
# 智谱 GLM API 配置 (用于生成标题和标签)
GLM_API_KEY=32440cd3f3444d1f8fe721304acea8bd.YXNLrk7eIJMKcg4t
GLM_MODEL=glm-4.7-flash
# =============== Supabase Storage 本地路径 ===============
# 确保存储卷映射正确,避免硬编码路径
SUPABASE_STORAGE_LOCAL_PATH=/home/rongye/ProgramFiles/Supabase/volumes/storage/stub/stub
# =============== 抖音视频下载 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

@@ -1,338 +0,0 @@
from fastapi import APIRouter, UploadFile, File, HTTPException, Request, BackgroundTasks, Depends
from app.core.config import settings
from app.core.deps import get_current_user
from app.services.storage import storage_service
import re
import time
import traceback
import os
import aiofiles
from pathlib import Path
from loguru import logger
from pydantic import BaseModel
from typing import Optional
import httpx
router = APIRouter()
def sanitize_filename(filename: str) -> str:
safe_name = re.sub(r'[<>:"/\\|?*]', '_', filename)
if len(safe_name) > 100:
ext = Path(safe_name).suffix
safe_name = safe_name[:100 - len(ext)] + ext
return safe_name
async def process_and_upload(temp_file_path: str, original_filename: str, content_type: str, user_id: str):
"""Background task to strip multipart headers and upload to Supabase"""
try:
logger.info(f"Processing raw upload: {temp_file_path} for user {user_id}")
# 1. Analyze file to find actual video content (strip multipart boundaries)
# This is a simplified manual parser for a SINGLE file upload.
# Structure:
# --boundary
# Content-Disposition: form-data; name="file"; filename="..."
# Content-Type: video/mp4
# \r\n\r\n
# [DATA]
# \r\n--boundary--
# We need to read the first few KB to find the header end
start_offset = 0
end_offset = 0
boundary = b""
file_size = os.path.getsize(temp_file_path)
with open(temp_file_path, 'rb') as f:
# Read first 4KB to find header
head = f.read(4096)
# Find boundary
first_line_end = head.find(b'\r\n')
if first_line_end == -1:
raise Exception("Could not find boundary in multipart body")
boundary = head[:first_line_end] # e.g. --boundary123
logger.info(f"Detected boundary: {boundary}")
# Find end of headers (\r\n\r\n)
header_end = head.find(b'\r\n\r\n')
if header_end == -1:
raise Exception("Could not find end of multipart headers")
start_offset = header_end + 4
logger.info(f"Video data starts at offset: {start_offset}")
# Find end boundary (read from end of file)
# It should be \r\n + boundary + -- + \r\n
# We seek to end-200 bytes
f.seek(max(0, file_size - 200))
tail = f.read()
# The closing boundary is usually --boundary--
# We look for the last occurrence of the boundary
last_boundary_pos = tail.rfind(boundary)
if last_boundary_pos != -1:
# The data ends before \r\n + boundary
# The tail buffer relative position needs to be converted to absolute
end_pos_in_tail = last_boundary_pos
# We also need to check for the preceding \r\n
if end_pos_in_tail >= 2 and tail[end_pos_in_tail-2:end_pos_in_tail] == b'\r\n':
end_pos_in_tail -= 2
# Absolute end offset
end_offset = (file_size - 200) + last_boundary_pos
# Correction for CRLF before boundary
# Actually, simply: read until (file_size - len(tail) + last_boundary_pos) - 2
end_offset = (max(0, file_size - 200) + last_boundary_pos) - 2
else:
logger.warning("Could not find closing boundary, assuming EOF")
end_offset = file_size
logger.info(f"Video data ends at offset: {end_offset}. Total video size: {end_offset - start_offset}")
# 2. Extract and Upload to Supabase
# Since we have the file on disk, we can just pass the file object (seeked) to upload_file?
# Or if upload_file expects bytes/path, checking storage.py...
# It takes `file_data` (bytes) or file-like?
# supabase-py's `upload` method handles parsing if we pass a file object.
# But we need to pass ONLY the video slice.
# So we create a generator or a sliced file object?
# Simpler: Read the slice into memory if < 1GB? Or copy to new temp file?
# Copying to new temp file is safer for memory.
video_path = temp_file_path + "_video.mp4"
with open(temp_file_path, 'rb') as src, open(video_path, 'wb') as dst:
src.seek(start_offset)
# Copy in chunks
bytes_to_copy = end_offset - start_offset
copied = 0
while copied < bytes_to_copy:
chunk_size = min(1024*1024*10, bytes_to_copy - copied) # 10MB chunks
chunk = src.read(chunk_size)
if not chunk:
break
dst.write(chunk)
copied += len(chunk)
logger.info(f"Extracted video content to {video_path}")
# 3. Upload to Supabase with user isolation
timestamp = int(time.time())
safe_name = re.sub(r'[^a-zA-Z0-9._-]', '', original_filename)
# 使用 user_id 作为目录前缀实现隔离
storage_path = f"{user_id}/{timestamp}_{safe_name}"
# Use storage service (this calls Supabase which might do its own http request)
# We read the cleaned video file
with open(video_path, 'rb') as f:
file_content = f.read() # Still reading into memory for simple upload call, but server has 32GB RAM so ok for 500MB
await storage_service.upload_file(
bucket=storage_service.BUCKET_MATERIALS,
path=storage_path,
file_data=file_content,
content_type=content_type
)
logger.info(f"Upload to Supabase complete: {storage_path}")
# Cleanup
os.remove(temp_file_path)
os.remove(video_path)
return storage_path
except Exception as e:
logger.error(f"Background upload processing failed: {e}\n{traceback.format_exc()}")
raise
@router.post("")
async def upload_material(
request: Request,
background_tasks: BackgroundTasks,
current_user: dict = Depends(get_current_user)
):
user_id = current_user["id"]
logger.info(f"ENTERED upload_material (Streaming Mode) for user {user_id}. Headers: {request.headers}")
filename = "unknown_video.mp4" # Fallback
content_type = "video/mp4"
# Try to parse filename from header if possible (unreliable in raw stream)
# We will rely on post-processing or client hint
# Frontend sends standard multipart.
# Create temp file
timestamp = int(time.time())
temp_filename = f"upload_{timestamp}.raw"
temp_path = os.path.join("/tmp", temp_filename) # Use /tmp on Linux
# Ensure /tmp exists (it does) but verify paths
if os.name == 'nt': # Local dev
temp_path = f"d:/tmp/{temp_filename}"
os.makedirs("d:/tmp", exist_ok=True)
try:
total_size = 0
last_log = 0
async with aiofiles.open(temp_path, 'wb') as f:
async for chunk in request.stream():
await f.write(chunk)
total_size += len(chunk)
# Log progress every 20MB
if total_size - last_log > 20 * 1024 * 1024:
logger.info(f"Receiving stream... Processed {total_size / (1024*1024):.2f} MB")
last_log = total_size
logger.info(f"Stream reception complete. Total size: {total_size} bytes. Saved to {temp_path}")
if total_size == 0:
raise HTTPException(400, "Received empty body")
# Attempt to extract filename from the saved file's first bytes?
# Or just accept it as "uploaded_video.mp4" for now to prove it works.
# We can try to regex the header in the file content we just wrote.
# Implemented in background task to return success immediately.
# Wait, if we return immediately, the user's UI might not show the file yet?
# The prompt says "Wait for upload".
# But to avoid User Waiting Timeout, maybe returning early is better?
# NO, user expects the file to be in the list.
# So we Must await the processing.
# But "Processing" (Strip + Upload to Supabase) takes time.
# Receiving took time.
# If we await Supabase upload, does it timeout?
# Supabase upload is outgoing. Usually faster/stable.
# Let's await the processing to ensure "List Materials" shows it.
# We need to extract the filename for the list.
# Quick extract filename from first 4kb
with open(temp_path, 'rb') as f:
head = f.read(4096).decode('utf-8', errors='ignore')
match = re.search(r'filename="([^"]+)"', head)
if match:
filename = match.group(1)
logger.info(f"Extracted filename from body: {filename}")
# Run processing sync (in await)
storage_path = await process_and_upload(temp_path, filename, content_type, user_id)
# Get signed URL (it exists now)
signed_url = await storage_service.get_signed_url(
bucket=storage_service.BUCKET_MATERIALS,
path=storage_path
)
size_mb = total_size / (1024 * 1024) # Approximate (includes headers)
# 从 storage_path 提取显示名
display_name = storage_path.split('/')[-1] # 去掉 user_id 前缀
if '_' in display_name:
parts = display_name.split('_', 1)
if parts[0].isdigit():
display_name = parts[1]
return {
"id": storage_path,
"name": display_name,
"path": signed_url,
"size_mb": size_mb,
"type": "video"
}
except Exception as e:
error_msg = f"Streaming upload failed: {str(e)}"
detail_msg = f"Exception: {repr(e)}\nArgs: {e.args}\n{traceback.format_exc()}"
logger.error(error_msg + "\n" + detail_msg)
# Write to debug file
try:
with open("debug_upload.log", "a") as logf:
logf.write(f"\n--- Error at {time.ctime()} ---\n")
logf.write(detail_msg)
logf.write("\n-----------------------------\n")
except:
pass
if os.path.exists(temp_path):
try:
os.remove(temp_path)
except:
pass
raise HTTPException(500, f"Upload failed. Check server logs. Error: {str(e)}")
@router.get("")
async def list_materials(current_user: dict = Depends(get_current_user)):
user_id = current_user["id"]
try:
# 只列出当前用户目录下的文件
files_obj = await storage_service.list_files(
bucket=storage_service.BUCKET_MATERIALS,
path=user_id
)
materials = []
for f in files_obj:
name = f.get('name')
if not name or name == '.emptyFolderPlaceholder':
continue
display_name = name
if '_' in name:
parts = name.split('_', 1)
if parts[0].isdigit():
display_name = parts[1]
# 完整路径包含 user_id
full_path = f"{user_id}/{name}"
signed_url = await storage_service.get_signed_url(
bucket=storage_service.BUCKET_MATERIALS,
path=full_path
)
metadata = f.get('metadata', {})
size = metadata.get('size', 0)
# created_at 在顶层,是 ISO 字符串
created_at_str = f.get('created_at', '')
created_at = 0
if created_at_str:
from datetime import datetime
try:
dt = datetime.fromisoformat(created_at_str.replace('Z', '+00:00'))
created_at = int(dt.timestamp())
except:
pass
materials.append({
"id": full_path, # ID 使用完整路径
"name": display_name,
"path": signed_url,
"size_mb": size / (1024 * 1024),
"type": "video",
"created_at": created_at
})
materials.sort(key=lambda x: x['id'], reverse=True)
return {"materials": materials}
except Exception as e:
logger.error(f"List materials failed: {e}")
return {"materials": []}
@router.delete("/{material_id:path}")
async def delete_material(material_id: str, current_user: dict = Depends(get_current_user)):
user_id = current_user["id"]
# 验证 material_id 属于当前用户
if not material_id.startswith(f"{user_id}/"):
raise HTTPException(403, "无权删除此素材")
try:
await storage_service.delete_file(
bucket=storage_service.BUCKET_MATERIALS,
path=material_id
)
return {"success": True, "message": "素材已删除"}
except Exception as e:
raise HTTPException(500, f"删除失败: {str(e)}")

View File

@@ -1,411 +0,0 @@
"""
参考音频管理 API
支持上传/列表/删除参考音频,用于 Qwen3-TTS 声音克隆
"""
from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Depends
from pydantic import BaseModel
from typing import List, Optional
from pathlib import Path
from loguru import logger
import time
import json
import subprocess
import tempfile
import os
import re
from app.core.deps import get_current_user
from app.services.storage import storage_service
router = APIRouter()
# 支持的音频格式
ALLOWED_AUDIO_EXTENSIONS = {'.wav', '.mp3', '.m4a', '.webm', '.ogg', '.flac', '.aac'}
# 参考音频 bucket
BUCKET_REF_AUDIOS = "ref-audios"
class RefAudioResponse(BaseModel):
id: str
name: str
path: str # signed URL for playback
ref_text: str
duration_sec: float
created_at: int
class RefAudioListResponse(BaseModel):
items: List[RefAudioResponse]
def sanitize_filename(filename: str) -> str:
"""清理文件名,移除特殊字符"""
safe_name = re.sub(r'[<>:"/\\|?*\s]', '_', filename)
if len(safe_name) > 50:
ext = Path(safe_name).suffix
safe_name = safe_name[:50 - len(ext)] + ext
return safe_name
def get_audio_duration(file_path: str) -> float:
"""获取音频时长 (秒)"""
try:
result = subprocess.run(
['ffprobe', '-v', 'quiet', '-show_entries', 'format=duration',
'-of', 'csv=p=0', file_path],
capture_output=True, text=True, timeout=10
)
return float(result.stdout.strip())
except Exception as e:
logger.warning(f"获取音频时长失败: {e}")
return 0.0
def convert_to_wav(input_path: str, output_path: str) -> bool:
"""将音频转换为 WAV 格式 (16kHz, mono)"""
try:
subprocess.run([
'ffmpeg', '-y', '-i', input_path,
'-ar', '16000', # 16kHz 采样率
'-ac', '1', # 单声道
'-acodec', 'pcm_s16le', # 16-bit PCM
output_path
], capture_output=True, timeout=60, check=True)
return True
except Exception as e:
logger.error(f"音频转换失败: {e}")
return False
@router.post("", response_model=RefAudioResponse)
async def upload_ref_audio(
file: UploadFile = File(...),
ref_text: str = Form(...),
user: dict = Depends(get_current_user)
):
"""
上传参考音频
- file: 音频文件 (支持 wav, mp3, m4a, webm 等)
- ref_text: 参考音频的转写文字 (必填)
"""
user_id = user["id"]
# 验证文件扩展名
ext = Path(file.filename).suffix.lower()
if ext not in ALLOWED_AUDIO_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=f"不支持的音频格式: {ext}。支持的格式: {', '.join(ALLOWED_AUDIO_EXTENSIONS)}"
)
# 验证 ref_text
if not ref_text or len(ref_text.strip()) < 2:
raise HTTPException(status_code=400, detail="参考文字不能为空")
try:
# 创建临时文件
with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp_input:
content = await file.read()
tmp_input.write(content)
tmp_input_path = tmp_input.name
# 转换为 WAV 格式
tmp_wav_path = tmp_input_path + ".wav"
if ext != '.wav':
if not convert_to_wav(tmp_input_path, tmp_wav_path):
raise HTTPException(status_code=500, detail="音频格式转换失败")
else:
# 即使是 wav 也要标准化格式
convert_to_wav(tmp_input_path, tmp_wav_path)
# 获取音频时长
duration = get_audio_duration(tmp_wav_path)
if duration < 1.0:
raise HTTPException(status_code=400, detail="音频时长过短,至少需要 1 秒")
if duration > 60.0:
raise HTTPException(status_code=400, detail="音频时长过长,最多 60 秒")
# 3. 处理重名逻辑 (Friendly Display Name)
original_name = file.filename
# 获取用户现有的所有参考音频列表 (为了检查文件名冲突)
# 注意: 这种列表方式在文件极多时性能一般,但考虑到单用户参考音频数量有限,目前可行
existing_files = await storage_service.list_files(BUCKET_REF_AUDIOS, user_id)
existing_names = set()
# 预加载所有现有的 display name
# 这里需要并发请求 metadata 可能会慢,优化: 仅检查 metadata 文件并解析
# 简易方案: 仅在 metadata 中读取 original_filename
# 但 list_files 返回的是 name我们需要 metadata
# 考虑到性能,这里使用一种妥协方案:
# 我们不做全量检查,而是简单的检查:如果用户上传 myvoice.wav
# 我们看看有没有 (timestamp)_myvoice.wav 这种其实并不能准确判断 display name 是否冲突
#
# 正确做法: 应该有个数据库表存 metadata。但目前是无数据库设计。
#
# 改用简单方案:
# 既然我们无法快速获取所有 display name
# 我们暂时只处理 "在新上传时original_filename 保持原样"
# 但用户希望 "如果在列表中看到重复的,自动加(1)"
#
# 鉴于无数据库架构的限制,要在上传时知道"已有的 display name" 成本太高(需遍历下载所有json)。
#
# 💡 替代方案:
# 我们不检查旧的。我们只保证**存储**唯一。
# 对于用户提到的 "新上传的文件名后加个数字" -> 这通常是指 "另存为" 的逻辑。
# 既然用户现在的痛点是 "显示了时间戳太丑",而我已经去掉了时间戳显示。
# 那么如果用户上传两个 "TEST.wav",列表里就会有两个 "TEST.wav" (但时间不同)。
# 这其实是可以接受的。
#
# 但如果用户强求 "自动重命名":
# 我们可以在这里做一个轻量级的 "同名检测"
# 检查有没有 *_{original_name} 的文件存在。
# 如果 storage 里已经有 123_abc.wav, 456_abc.wav
# 我们可以认为 abc.wav 已经存在。
dup_count = 0
search_suffix = f"_{original_name}" # 比如 _test.wav
for f in existing_files:
fname = f.get('name', '')
if fname.endswith(search_suffix):
dup_count += 1
final_display_name = original_name
if dup_count > 0:
name_stem = Path(original_name).stem
name_ext = Path(original_name).suffix
final_display_name = f"{name_stem}({dup_count}){name_ext}"
# 生成存储路径 (唯一ID)
timestamp = int(time.time())
safe_name = sanitize_filename(Path(file.filename).stem)
storage_path = f"{user_id}/{timestamp}_{safe_name}.wav"
# 上传 WAV 文件到 Supabase
with open(tmp_wav_path, 'rb') as f:
wav_data = f.read()
await storage_service.upload_file(
bucket=BUCKET_REF_AUDIOS,
path=storage_path,
file_data=wav_data,
content_type="audio/wav"
)
# 上传元数据 JSON
metadata = {
"ref_text": ref_text.strip(),
"original_filename": final_display_name, # 这里的名字如果有重复会自动加(1)
"duration_sec": duration,
"created_at": timestamp
}
metadata_path = f"{user_id}/{timestamp}_{safe_name}.json"
await storage_service.upload_file(
bucket=BUCKET_REF_AUDIOS,
path=metadata_path,
file_data=json.dumps(metadata, ensure_ascii=False).encode('utf-8'),
content_type="application/json"
)
# 获取签名 URL
signed_url = await storage_service.get_signed_url(BUCKET_REF_AUDIOS, storage_path)
# 清理临时文件
os.unlink(tmp_input_path)
if os.path.exists(tmp_wav_path):
os.unlink(tmp_wav_path)
return RefAudioResponse(
id=storage_path,
name=file.filename,
path=signed_url,
ref_text=ref_text.strip(),
duration_sec=duration,
created_at=timestamp
)
except HTTPException:
raise
except Exception as e:
logger.error(f"上传参考音频失败: {e}")
raise HTTPException(status_code=500, detail=f"上传失败: {str(e)}")
@router.get("", response_model=RefAudioListResponse)
async def list_ref_audios(user: dict = Depends(get_current_user)):
"""列出当前用户的所有参考音频"""
user_id = user["id"]
try:
# 列出用户目录下的文件
files = await storage_service.list_files(BUCKET_REF_AUDIOS, user_id)
# 过滤出 .wav 文件并获取对应的 metadata
items = []
for f in files:
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}"
ref_text = ""
duration_sec = 0.0
created_at = 0
original_filename = ""
try:
# 获取 metadata 内容
metadata_url = await storage_service.get_signed_url(BUCKET_REF_AUDIOS, metadata_path)
import httpx
async with httpx.AsyncClient() as client:
resp = await client.get(metadata_url)
if resp.status_code == 200:
metadata = resp.json()
ref_text = metadata.get("ref_text", "")
duration_sec = metadata.get("duration_sec", 0.0)
created_at = metadata.get("created_at", 0)
original_filename = metadata.get("original_filename", "")
except Exception as e:
logger.warning(f"读取 metadata 失败: {e}")
# 从文件名提取时间戳
try:
created_at = int(name.split("_")[0])
except:
pass
# 获取音频签名 URL
signed_url = await storage_service.get_signed_url(BUCKET_REF_AUDIOS, storage_path)
# 优先显示原始文件名 (去掉时间戳前缀)
display_name = original_filename if original_filename else name
# 如果原始文件名丢失,尝试从现有文件名中通过正则去掉时间戳
if not display_name or display_name == name:
# 匹配 "1234567890_filename.wav"
match = re.match(r'^\d+_(.+)$', name)
if match:
display_name = match.group(1)
items.append(RefAudioResponse(
id=storage_path,
name=display_name,
path=signed_url,
ref_text=ref_text,
duration_sec=duration_sec,
created_at=created_at
))
# 按创建时间倒序排列
items.sort(key=lambda x: x.created_at, reverse=True)
return RefAudioListResponse(items=items)
except Exception as e:
logger.error(f"列出参考音频失败: {e}")
raise HTTPException(status_code=500, detail=f"获取列表失败: {str(e)}")
@router.delete("/{audio_id:path}")
async def delete_ref_audio(audio_id: str, user: dict = Depends(get_current_user)):
"""删除参考音频"""
user_id = user["id"]
# 安全检查:确保只能删除自己的文件
if not audio_id.startswith(f"{user_id}/"):
raise HTTPException(status_code=403, detail="无权删除此文件")
try:
# 删除 WAV 文件
await storage_service.delete_file(BUCKET_REF_AUDIOS, audio_id)
# 删除 metadata JSON
metadata_path = audio_id.replace(".wav", ".json")
try:
await storage_service.delete_file(BUCKET_REF_AUDIOS, metadata_path)
except:
pass # metadata 可能不存在
return {"success": True, "message": "删除成功"}
except Exception as e:
logger.error(f"删除参考音频失败: {e}")
raise HTTPException(status_code=500, detail=f"删除失败: {str(e)}")
class RenameRequest(BaseModel):
new_name: str
@router.put("/{audio_id:path}")
async def rename_ref_audio(
audio_id: str,
request: RenameRequest,
user: dict = Depends(get_current_user)
):
"""重命名参考音频 (修改 metadata 中的 display name)"""
user_id = user["id"]
# 安全检查
if not audio_id.startswith(f"{user_id}/"):
raise HTTPException(status_code=403, detail="无权修改此文件")
new_name = request.new_name.strip()
if not new_name:
raise HTTPException(status_code=400, detail="新名称不能为空")
# 确保新名称有后缀 (保留原后缀或添加 .wav)
if not Path(new_name).suffix:
new_name += ".wav"
try:
# 1. 下载现有的 metadata
metadata_path = audio_id.replace(".wav", ".json")
try:
# 获取已有的 JSON
import httpx
metadata_url = await storage_service.get_signed_url(BUCKET_REF_AUDIOS, metadata_path)
if not metadata_url:
# 如果 json 不存在,则需要新建一个基础的
raise Exception("Metadata not found")
async with httpx.AsyncClient() as client:
resp = await client.get(metadata_url)
if resp.status_code == 200:
metadata = resp.json()
else:
raise Exception(f"Failed to fetch metadata: {resp.status_code}")
except Exception as e:
logger.warning(f"无法读取元数据: {e}, 将创建新的元数据")
# 兜底:如果读取失败,构建最小元数据
metadata = {
"ref_text": "", # 可能丢失
"duration_sec": 0.0,
"created_at": int(time.time()),
"original_filename": new_name
}
# 2. 更新 original_filename
metadata["original_filename"] = new_name
# 3. 覆盖上传 metadata
await storage_service.upload_file(
bucket=BUCKET_REF_AUDIOS,
path=metadata_path,
file_data=json.dumps(metadata, ensure_ascii=False).encode('utf-8'),
content_type="application/json"
)
return {"success": True, "name": new_name}
except Exception as e:
logger.error(f"重命名失败: {e}")
raise HTTPException(status_code=500, detail=f"重命名失败: {str(e)}")

View File

@@ -1,398 +0,0 @@
from fastapi import APIRouter, UploadFile, File, Form, HTTPException
from typing import Optional
import shutil
import os
import time
from pathlib import Path
from loguru import logger
import traceback
import re
import json
import requests
from urllib.parse import unquote
from app.services.whisper_service import whisper_service
from app.services.glm_service import glm_service
router = APIRouter()
@router.post("/extract-script")
async def extract_script_tool(
file: Optional[UploadFile] = File(None),
url: Optional[str] = Form(None),
rewrite: bool = Form(True)
):
"""
独立文案提取工具
支持上传视频/音频 OR 输入视频链接 -> 提取文字 -> (可选) AI洗稿
"""
if not file and not url:
raise HTTPException(400, "必须提供文件或视频链接")
temp_path = None
try:
timestamp = int(time.time())
temp_dir = Path("/tmp")
if os.name == 'nt':
temp_dir = Path("d:/tmp")
temp_dir.mkdir(parents=True, exist_ok=True)
# 1. 获取/保存文件
loop = asyncio.get_event_loop()
if file:
safe_filename = Path(file.filename).name.replace(" ", "_")
temp_path = temp_dir / f"tool_extract_{timestamp}_{safe_filename}"
# 文件 I/O 放入线程池
await loop.run_in_executor(None, lambda: shutil.copyfileobj(file.file, open(temp_path, "wb")))
logger.info(f"Tool processing upload file: {temp_path}")
else:
# URL 下载逻辑
# 自动提取文案中的链接 (支持 Douyin/Bilibili 等分享文案)
url_match = re.search(r'https?://[^\s]+', url)
if url_match:
extracted_url = url_match.group(0)
logger.info(f"Extracted URL from text: {extracted_url}")
url = extracted_url
logger.info(f"Tool downloading URL: {url}")
# 封装 yt-dlp 下载函数 (Blocking)
def _download_yt_dlp():
import yt_dlp
logger.info("Attempting download with yt-dlp...")
ydl_opts = {
'format': 'bestaudio/best',
'outtmpl': str(temp_dir / f"tool_download_{timestamp}_%(id)s.%(ext)s"),
'quiet': True,
'no_warnings': True,
'http_headers': {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
'Referer': 'https://www.douyin.com/',
}
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=True)
if 'requested_downloads' in info:
downloaded_file = info['requested_downloads'][0]['filepath']
else:
ext = info.get('ext', 'mp4')
id = info.get('id')
downloaded_file = str(temp_dir / f"tool_download_{timestamp}_{id}.{ext}")
return Path(downloaded_file)
# 先尝试 yt-dlp (Run in Executor)
try:
temp_path = await loop.run_in_executor(None, _download_yt_dlp)
logger.info(f"yt-dlp downloaded to: {temp_path}")
except Exception as e:
logger.warning(f"yt-dlp download failed: {e}. Trying manual Douyin fallback...")
# 失败则尝试手动解析 (Douyin Fallback)
if "douyin" in url:
manual_path = await download_douyin_manual(url, temp_dir, timestamp)
if manual_path:
temp_path = manual_path
logger.info(f"Manual Douyin fallback successful: {temp_path}")
else:
raise HTTPException(400, f"视频下载失败。yt-dlp 报错: {str(e)}")
elif "bilibili" in url:
manual_path = await download_bilibili_manual(url, temp_dir, timestamp)
if manual_path:
temp_path = manual_path
logger.info(f"Manual Bilibili fallback successful: {temp_path}")
else:
raise HTTPException(400, f"视频下载失败。yt-dlp 报错: {str(e)}")
else:
raise HTTPException(400, f"视频下载失败: {str(e)}")
if not temp_path or not temp_path.exists():
raise HTTPException(400, "文件获取失败")
# 1.5 安全转换: 强制转为 WAV (16k)
import subprocess
audio_path = temp_dir / f"extract_audio_{timestamp}.wav"
def _convert_audio():
try:
convert_cmd = [
'ffmpeg',
'-i', str(temp_path),
'-vn', # 忽略视频
'-acodec', 'pcm_s16le',
'-ar', '16000', # Whisper 推荐采样率
'-ac', '1', # 单声道
'-y', # 覆盖
str(audio_path)
]
# 捕获 stderr
subprocess.run(convert_cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
return True
except subprocess.CalledProcessError as e:
error_log = e.stderr.decode('utf-8', errors='ignore') if e.stderr else str(e)
logger.error(f"FFmpeg check/convert failed: {error_log}")
# 检查是否为 HTML
head = b""
try:
with open(temp_path, 'rb') as f:
head = f.read(100)
except: pass
if b'<!DOCTYPE html' in head or b'<html' in head:
raise ValueError("HTML_DETECTED")
raise ValueError("CONVERT_FAILED")
# 执行转换 (Run in Executor)
try:
await loop.run_in_executor(None, _convert_audio)
logger.info(f"Converted to WAV: {audio_path}")
target_path = audio_path
except ValueError as ve:
if str(ve) == "HTML_DETECTED":
raise HTTPException(400, "下载的文件是网页而非视频,请重试或手动上传。")
else:
raise HTTPException(400, "下载的文件已损坏或格式无法识别。")
# 2. 提取文案 (Whisper)
script = await whisper_service.transcribe(str(target_path))
# 3. AI 洗稿 (GLM)
rewritten = None
if rewrite:
if script and len(script.strip()) > 0:
logger.info("Rewriting script...")
rewritten = await glm_service.rewrite_script(script)
else:
logger.warning("No script extracted, skipping rewrite")
return {
"success": True,
"original_script": script,
"rewritten_script": rewritten
}
except HTTPException as he:
raise he
except Exception as e:
logger.error(f"Tool extract failed: {e}")
logger.error(traceback.format_exc())
# Friendly error message
msg = str(e)
if "Fresh cookies" in msg:
msg = "下载失败:目标平台开启了反爬验证,请过段时间重试或直接上传视频文件。"
raise HTTPException(500, f"提取失败: {msg}")
finally:
# 清理临时文件
if temp_path and temp_path.exists():
try:
os.remove(temp_path)
logger.info(f"Cleaned up temp file: {temp_path}")
except Exception as e:
logger.warning(f"Failed to cleanup temp file {temp_path}: {e}")
async def download_douyin_manual(url: str, temp_dir: Path, timestamp: int) -> Optional[Path]:
"""
手动下载抖音视频 (Fallback logic - Ported from SuperIPAgent/douyinDownloader)
使用特定的 User Profile URL 和硬编码 Cookie 绕过反爬
"""
logger.info(f"[SuperIPAgent] Starting download for: {url}")
try:
# 1. 提取 Modal ID (支持短链跳转)
headers = {
"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
logger.info(f"[SuperIPAgent] Final URL: {final_url}")
modal_id = None
match = re.search(r'/video/(\d+)', final_url)
if match:
modal_id = match.group(1)
if not modal_id:
logger.error("[SuperIPAgent] Could not extract modal_id")
return None
logger.info(f"[SuperIPAgent] Extracted modal_id: {modal_id}")
# 2. 构造特定请求 URL (Copy from SuperIPAgent)
# 使用特定用户的 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)
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",
"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)
# 4. 解析 RENDER_DATA
content_match = re.findall(r'<script id="RENDER_DATA" type="application/json">(.*?)</script>', response.text)
if not content_match:
# 尝试解码后再查找?或者结构变了
# 再尝试找 SSR_HYDRATED_DATA
if "SSR_HYDRATED_DATA" in response.text:
content_match = re.findall(r'<script id="SSR_HYDRATED_DATA" type="application/json">(.*?)</script>', response.text)
if not content_match:
logger.error(f"[SuperIPAgent] Could not find RENDER_DATA in page (len={len(response.text)})")
return None
content = unquote(content_match[0])
try:
data = json.loads(content)
except:
logger.error("[SuperIPAgent] JSON decode failed")
return None
# 5. 提取视频流
video_url = None
try:
# 路径通常是: app -> videoDetail -> video -> bitRateList -> playAddr -> src
if "app" in data and "videoDetail" in data["app"]:
info = data["app"]["videoDetail"]["video"]
if "bitRateList" in info and info["bitRateList"]:
video_url = info["bitRateList"][0]["playAddr"][0]["src"]
elif "playAddr" in info and info["playAddr"]:
video_url = info["playAddr"][0]["src"]
except Exception as e:
logger.error(f"[SuperIPAgent] Path extraction failed: {e}")
if not video_url:
logger.error("[SuperIPAgent] No video_url found")
return None
if video_url.startswith("//"):
video_url = "https:" + video_url
logger.info(f"[SuperIPAgent] Found video URL: {video_url[:50]}...")
# 6. 下载 (带 Header)
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
except Exception as e:
logger.error(f"[SuperIPAgent] Logic failed: {e}")
return None
async def download_bilibili_manual(url: str, temp_dir: Path, timestamp: int) -> Optional[Path]:
"""
手动下载 Bilibili 视频 (Fallback logic - Playwright Version)
B站通常音视频分离这里只提取音频即可因为只需要文案
"""
from playwright.async_api import async_playwright
logger.info(f"[Playwright] Starting Bilibili download for: {url}")
playwright = None
browser = None
try:
playwright = await async_playwright().start()
# Launch browser (ensure chromium is installed: playwright install chromium)
browser = await playwright.chromium.launch(headless=True, args=['--no-sandbox', '--disable-setuid-sandbox'])
# Mobile User Agent often gives single stream?
# But Bilibili mobile web is tricky. Desktop is fine.
context = await browser.new_context(
user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)
page = await context.new_page()
# Intercept audio responses?
# Bilibili streams are usually .m4s
# But finding the initial state is easier.
logger.info("[Playwright] Navigating to Bilibili...")
await page.goto(url, timeout=45000)
# Wait for video element (triggers loading)
try:
await page.wait_for_selector('video', timeout=15000)
except:
logger.warning("[Playwright] Video selector timeout")
# 1. Try extracting from __playinfo__
# window.__playinfo__ contains dash streams
playinfo = await page.evaluate("window.__playinfo__")
audio_url = None
if playinfo and "data" in playinfo and "dash" in playinfo["data"]:
dash = playinfo["data"]["dash"]
if "audio" in dash and dash["audio"]:
audio_url = dash["audio"][0]["baseUrl"]
logger.info(f"[Playwright] Found audio stream in __playinfo__: {audio_url[:50]}...")
# 2. If playinfo fails, try extracting video src (sometimes it's a blob, which we can't fetch easily without interception)
# But interception is complex. Let's try requests with Referer if we have URL.
if not audio_url:
logger.warning("[Playwright] Could not find audio in __playinfo__")
return None
# Download the audio stream
temp_path = temp_dir / f"bilibili_audio_{timestamp}.m4s" # usually m4s
try:
api_request = context.request
headers = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Referer": "https://www.bilibili.com/"
}
logger.info(f"[Playwright] Downloading audio stream...")
response = await api_request.get(audio_url, headers=headers)
if response.status == 200:
body = await response.body()
with open(temp_path, 'wb') as f:
f.write(body)
logger.info(f"[Playwright] Downloaded successfully: {temp_path}")
return temp_path
else:
logger.error(f"[Playwright] API Request failed: {response.status}")
return None
except Exception as e:
logger.error(f"[Playwright] Download logic error: {e}")
return None
except Exception as e:
logger.error(f"[Playwright] Bilibili download failed: {e}")
return None
finally:
if browser:
await browser.close()
if playwright:
await playwright.stop()

View File

@@ -1,478 +0,0 @@
from fastapi import APIRouter, HTTPException, BackgroundTasks, Depends, Request
from pydantic import BaseModel
from typing import Optional
from pathlib import Path
from loguru import logger
import uuid
import traceback
import time
import httpx
import os
from app.services.tts_service import TTSService
from app.services.video_service import VideoService
from app.services.lipsync_service import LipSyncService
from app.services.voice_clone_service import voice_clone_service
from app.services.assets_service import (
get_style,
get_default_style,
resolve_bgm_path,
prepare_style_for_remotion,
)
from app.services.storage import storage_service
from app.services.whisper_service import whisper_service
from app.services.remotion_service import remotion_service
from app.core.config import settings
from app.core.deps import get_current_user
router = APIRouter()
class GenerateRequest(BaseModel):
text: str
voice: str = "zh-CN-YunxiNeural"
material_path: str
# 声音克隆模式新增字段
tts_mode: str = "edgetts" # "edgetts" | "voiceclone"
ref_audio_id: Optional[str] = None # 参考音频 storage path
ref_text: Optional[str] = None # 参考音频的转写文字
# 字幕和标题功能
title: Optional[str] = None # 视频标题(片头显示)
enable_subtitles: bool = True # 是否启用逐字高亮字幕
subtitle_style_id: Optional[str] = None # 字幕样式 ID
title_style_id: Optional[str] = None # 标题样式 ID
subtitle_font_size: Optional[int] = None # 字幕字号(覆盖样式)
title_font_size: Optional[int] = None # 标题字号(覆盖样式)
bgm_id: Optional[str] = None # 背景音乐 ID
bgm_volume: Optional[float] = 0.2 # 背景音乐音量 (0-1)
tasks = {} # In-memory task store
# 缓存 LipSync 服务实例和健康状态
_lipsync_service: Optional[LipSyncService] = None
_lipsync_ready: Optional[bool] = None
_lipsync_last_check: float = 0
def _get_lipsync_service() -> LipSyncService:
"""获取或创建 LipSync 服务实例(单例模式,避免重复初始化)"""
global _lipsync_service
if _lipsync_service is None:
_lipsync_service = LipSyncService()
return _lipsync_service
async def _check_lipsync_ready(force: bool = False) -> bool:
"""检查 LipSync 是否就绪带缓存5分钟内不重复检查"""
global _lipsync_ready, _lipsync_last_check
now = time.time()
# 5分钟缓存
if not force and _lipsync_ready is not None and (now - _lipsync_last_check) < 300:
return bool(_lipsync_ready)
lipsync = _get_lipsync_service()
health = await lipsync.check_health()
_lipsync_ready = health.get("ready", False)
_lipsync_last_check = now
print(f"[LipSync] Health check: ready={_lipsync_ready}")
return bool(_lipsync_ready)
async def _download_material(path_or_url: str, temp_path: Path):
"""下载素材到临时文件 (流式下载,节省内存)"""
if path_or_url.startswith("http"):
# Download from URL
timeout = httpx.Timeout(None) # Disable timeout for large files
async with httpx.AsyncClient(timeout=timeout) as client:
async with client.stream("GET", path_or_url) as resp:
resp.raise_for_status()
with open(temp_path, "wb") as f:
async for chunk in resp.aiter_bytes():
f.write(chunk)
else:
# Local file (legacy or absolute path)
src = Path(path_or_url)
if not src.is_absolute():
src = settings.BASE_DIR.parent / path_or_url
if src.exists():
import shutil
shutil.copy(src, temp_path)
else:
raise FileNotFoundError(f"Material not found: {path_or_url}")
async def _process_video_generation(task_id: str, req: GenerateRequest, user_id: str):
temp_files = [] # Track files to clean up
try:
start_time = time.time()
tasks[task_id]["status"] = "processing"
tasks[task_id]["progress"] = 5
tasks[task_id]["message"] = "正在下载素材..."
# Prepare temp dir
temp_dir = settings.UPLOAD_DIR / "temp"
temp_dir.mkdir(parents=True, exist_ok=True)
# 0. Download Material
input_material_path = temp_dir / f"{task_id}_input.mp4"
temp_files.append(input_material_path)
await _download_material(req.material_path, input_material_path)
# 1. TTS - 进度 5% -> 25%
tasks[task_id]["message"] = "正在生成语音..."
tasks[task_id]["progress"] = 10
audio_path = temp_dir / f"{task_id}_audio.wav"
temp_files.append(audio_path)
if req.tts_mode == "voiceclone":
# 声音克隆模式
if not req.ref_audio_id or not req.ref_text:
raise ValueError("声音克隆模式需要提供参考音频和参考文字")
tasks[task_id]["message"] = "正在下载参考音频..."
# 从 Supabase 下载参考音频
ref_audio_local = temp_dir / f"{task_id}_ref.wav"
temp_files.append(ref_audio_local)
ref_audio_url = await storage_service.get_signed_url(
bucket="ref-audios",
path=req.ref_audio_id
)
await _download_material(ref_audio_url, ref_audio_local)
tasks[task_id]["message"] = "正在克隆声音 (Qwen3-TTS)..."
await voice_clone_service.generate_audio(
text=req.text,
ref_audio_path=str(ref_audio_local),
ref_text=req.ref_text,
output_path=str(audio_path),
language="Chinese"
)
else:
# EdgeTTS 模式 (默认)
tasks[task_id]["message"] = "正在生成语音 (EdgeTTS)..."
tts = TTSService()
await tts.generate_audio(req.text, req.voice, str(audio_path))
tts_time = time.time() - start_time
print(f"[Pipeline] TTS completed in {tts_time:.1f}s")
tasks[task_id]["progress"] = 25
# 2. LipSync - 进度 25% -> 85%
tasks[task_id]["message"] = "正在合成唇形 (LatentSync)..."
tasks[task_id]["progress"] = 30
lipsync = _get_lipsync_service()
lipsync_video_path = temp_dir / f"{task_id}_lipsync.mp4"
temp_files.append(lipsync_video_path)
# 使用缓存的健康检查结果
lipsync_start = time.time()
is_ready = await _check_lipsync_ready()
if is_ready:
print(f"[LipSync] Starting LatentSync inference...")
tasks[task_id]["progress"] = 35
tasks[task_id]["message"] = "正在运行 LatentSync 推理..."
await lipsync.generate(str(input_material_path), str(audio_path), str(lipsync_video_path))
else:
# Skip lipsync if not available
print(f"[LipSync] LatentSync not ready, copying original video")
tasks[task_id]["message"] = "唇形同步不可用,使用原始视频..."
import shutil
shutil.copy(str(input_material_path), lipsync_video_path)
lipsync_time = time.time() - lipsync_start
print(f"[Pipeline] LipSync completed in {lipsync_time:.1f}s")
tasks[task_id]["progress"] = 80
# 3. WhisperX 字幕对齐 - 进度 80% -> 85%
captions_path = None
if req.enable_subtitles:
tasks[task_id]["message"] = "正在生成字幕 (Whisper)..."
tasks[task_id]["progress"] = 82
captions_path = temp_dir / f"{task_id}_captions.json"
temp_files.append(captions_path)
try:
await whisper_service.align(
audio_path=str(audio_path),
text=req.text,
output_path=str(captions_path)
)
print(f"[Pipeline] Whisper alignment completed")
except Exception as e:
logger.warning(f"Whisper alignment failed, skipping subtitles: {e}")
captions_path = None
tasks[task_id]["progress"] = 85
# 3.5 背景音乐混音(不影响唇形与字幕对齐)
video = VideoService()
final_audio_path = audio_path
if req.bgm_id:
tasks[task_id]["message"] = "正在合成背景音乐..."
tasks[task_id]["progress"] = 86
bgm_path = resolve_bgm_path(req.bgm_id)
if bgm_path:
mix_output_path = temp_dir / f"{task_id}_audio_mix.wav"
temp_files.append(mix_output_path)
volume = req.bgm_volume if req.bgm_volume is not None else 0.2
volume = max(0.0, min(float(volume), 1.0))
try:
video.mix_audio(
voice_path=str(audio_path),
bgm_path=str(bgm_path),
output_path=str(mix_output_path),
bgm_volume=volume
)
final_audio_path = mix_output_path
except Exception as e:
logger.warning(f"BGM mix failed, fallback to voice only: {e}")
else:
logger.warning(f"BGM not found: {req.bgm_id}")
# 4. Remotion 视频合成(字幕 + 标题)- 进度 85% -> 95%
# 判断是否需要使用 Remotion有字幕或标题时使用
use_remotion = (captions_path and captions_path.exists()) or req.title
subtitle_style = None
title_style = None
if req.enable_subtitles:
subtitle_style = get_style("subtitle", req.subtitle_style_id) or get_default_style("subtitle")
if req.title:
title_style = get_style("title", req.title_style_id) or get_default_style("title")
if req.subtitle_font_size and req.enable_subtitles:
if subtitle_style is None:
subtitle_style = {}
subtitle_style["font_size"] = int(req.subtitle_font_size)
if req.title_font_size and req.title:
if title_style is None:
title_style = {}
title_style["font_size"] = int(req.title_font_size)
if use_remotion:
subtitle_style = prepare_style_for_remotion(
subtitle_style,
temp_dir,
f"{task_id}_subtitle_font"
)
title_style = prepare_style_for_remotion(
title_style,
temp_dir,
f"{task_id}_title_font"
)
final_output_local_path = temp_dir / f"{task_id}_output.mp4"
temp_files.append(final_output_local_path)
if use_remotion:
tasks[task_id]["message"] = "正在合成视频 (Remotion)..."
tasks[task_id]["progress"] = 87
# 先用 FFmpeg 合成音视频Remotion 需要带音频的视频)
composed_video_path = temp_dir / f"{task_id}_composed.mp4"
temp_files.append(composed_video_path)
await video.compose(str(lipsync_video_path), str(final_audio_path), str(composed_video_path))
# 检查 Remotion 是否可用
remotion_health = await remotion_service.check_health()
if remotion_health.get("ready"):
try:
def on_remotion_progress(percent):
# 映射 Remotion 进度到 87-95%
mapped = 87 + int(percent * 0.08)
tasks[task_id]["progress"] = mapped
await remotion_service.render(
video_path=str(composed_video_path),
output_path=str(final_output_local_path),
captions_path=str(captions_path) if captions_path else None,
title=req.title,
title_duration=3.0,
fps=25,
enable_subtitles=req.enable_subtitles,
subtitle_style=subtitle_style,
title_style=title_style,
on_progress=on_remotion_progress
)
print(f"[Pipeline] Remotion render completed")
except Exception as e:
logger.warning(f"Remotion render failed, using FFmpeg fallback: {e}")
# 回退到 FFmpeg 合成
import shutil
shutil.copy(str(composed_video_path), final_output_local_path)
else:
logger.warning(f"Remotion not ready: {remotion_health.get('error')}, using FFmpeg")
import shutil
shutil.copy(str(composed_video_path), final_output_local_path)
else:
# 不需要字幕和标题,直接用 FFmpeg 合成
tasks[task_id]["message"] = "正在合成最终视频..."
tasks[task_id]["progress"] = 90
await video.compose(str(lipsync_video_path), str(final_audio_path), str(final_output_local_path))
total_time = time.time() - start_time
# 4. Upload to Supabase with user isolation
tasks[task_id]["message"] = "正在上传结果..."
tasks[task_id]["progress"] = 95
# 使用 user_id 作为目录前缀实现隔离
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"
)
# Get Signed URL
signed_url = await storage_service.get_signed_url(
bucket=storage_service.BUCKET_OUTPUTS,
path=storage_path
)
print(f"[Pipeline] Total generation time: {total_time:.1f}s")
tasks[task_id]["status"] = "completed"
tasks[task_id]["progress"] = 100
tasks[task_id]["message"] = f"生成完成!耗时 {total_time:.0f}"
tasks[task_id]["output"] = storage_path
tasks[task_id]["download_url"] = signed_url
except Exception as e:
tasks[task_id]["status"] = "failed"
tasks[task_id]["message"] = f"错误: {str(e)}"
tasks[task_id]["error"] = traceback.format_exc()
logger.error(f"Generate video failed: {e}")
finally:
# Cleanup temp files
for f in temp_files:
try:
if f.exists():
f.unlink()
except Exception as e:
print(f"Error cleaning up {f}: {e}")
@router.post("/generate")
async def generate_video(
req: GenerateRequest,
background_tasks: BackgroundTasks,
current_user: dict = Depends(get_current_user)
):
user_id = current_user["id"]
task_id = str(uuid.uuid4())
tasks[task_id] = {"status": "pending", "task_id": task_id, "progress": 0, "user_id": user_id}
background_tasks.add_task(_process_video_generation, task_id, req, user_id)
return {"task_id": task_id}
@router.get("/tasks/{task_id}")
async def get_task(task_id: str):
return tasks.get(task_id, {"status": "not_found"})
@router.get("/tasks")
async def list_tasks():
return {"tasks": list(tasks.values())}
@router.get("/lipsync/health")
async def lipsync_health():
"""获取 LipSync 服务健康状态"""
lipsync = _get_lipsync_service()
return await lipsync.check_health()
@router.get("/voiceclone/health")
async def voiceclone_health():
"""获取声音克隆服务健康状态"""
return await voice_clone_service.check_health()
@router.get("/generated")
async def list_generated_videos(current_user: dict = Depends(get_current_user)):
"""从 Storage 读取当前用户生成的视频列表"""
user_id = current_user["id"]
try:
# 只列出当前用户目录下的文件
files_obj = await storage_service.list_files(
bucket=storage_service.BUCKET_OUTPUTS,
path=user_id
)
videos = []
for f in files_obj:
name = f.get('name')
if not name or name == '.emptyFolderPlaceholder':
continue
# 过滤非 output.mp4 文件
if not name.endswith("_output.mp4"):
continue
# 获取 ID (即文件名去除后缀)
video_id = Path(name).stem
# 完整路径包含 user_id
full_path = f"{user_id}/{name}"
# 获取签名链接
signed_url = await storage_service.get_signed_url(
bucket=storage_service.BUCKET_OUTPUTS,
path=full_path
)
metadata = f.get('metadata', {})
size = metadata.get('size', 0)
# created_at 在顶层,是 ISO 字符串,转换为 Unix 时间戳
created_at_str = f.get('created_at', '')
created_at = 0
if created_at_str:
from datetime import datetime
try:
dt = datetime.fromisoformat(created_at_str.replace('Z', '+00:00'))
created_at = int(dt.timestamp())
except:
pass
videos.append({
"id": video_id,
"name": name,
"path": signed_url, # Direct playable URL
"size_mb": size / (1024 * 1024),
"created_at": created_at
})
# Sort by created_at desc (newest first)
# Supabase API usually returns ISO string, simpler string sort works for ISO
videos.sort(key=lambda x: x.get("created_at", ""), reverse=True)
return {"videos": videos}
except Exception as e:
logger.error(f"List generated videos failed: {e}")
return {"videos": []}
@router.delete("/generated/{video_id}")
async def delete_generated_video(video_id: str, current_user: dict = Depends(get_current_user)):
"""删除生成的视频"""
user_id = current_user["id"]
try:
# video_id 通常是 uuid_output完整路径需要加上 user_id
storage_path = f"{user_id}/{video_id}.mp4"
await storage_service.delete_file(
bucket=storage_service.BUCKET_OUTPUTS,
path=storage_path
)
return {"success": True, "message": "视频已删除"}
except Exception as e:
raise HTTPException(500, f"删除失败: {str(e)}")

View File

@@ -3,14 +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"
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/144.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"
@@ -44,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,11 @@
"""
依赖注入模块:认证和用户获取
"""
from typing import Optional
from typing import Optional, Any, Dict, cast
from fastapi import Request, HTTPException, Depends, status
from app.core.security import decode_access_token, TokenData
from app.core.supabase import get_supabase
from app.repositories.sessions import get_session
from app.repositories.users import get_user_by_id
from loguru import logger
@@ -13,9 +14,9 @@ async def get_token_from_cookie(request: Request) -> Optional[str]:
return request.cookies.get("access_token")
async def get_current_user_optional(
request: Request
) -> Optional[dict]:
async def get_current_user_optional(
request: Request
) -> Optional[Dict[str, Any]]:
"""
获取当前用户 (可选,未登录返回 None)
"""
@@ -28,32 +29,22 @@ async def get_current_user_optional(
return None
# 验证 session_token 是否有效 (单设备登录检查)
try:
supabase = get_supabase()
result = supabase.table("user_sessions").select("*").eq(
"user_id", token_data.user_id
).eq(
"session_token", token_data.session_token
).execute()
if not result.data:
logger.warning(f"Session token 无效: user_id={token_data.user_id}")
return None
# 获取用户信息
user_result = supabase.table("users").select("*").eq(
"id", token_data.user_id
).single().execute()
return user_result.data
except Exception as e:
logger.error(f"获取用户信息失败: {e}")
return None
try:
session = get_session(token_data.user_id, token_data.session_token)
if not session:
logger.warning(f"Session token 无效: user_id={token_data.user_id}")
return None
user = get_user_by_id(token_data.user_id)
return cast(Optional[Dict[str, Any]], user)
except Exception as e:
logger.error(f"获取用户信息失败: {e}")
return None
async def get_current_user(
request: Request
) -> dict:
async def get_current_user(
request: Request
) -> Dict[str, Any]:
"""
获取当前用户 (必须登录)
@@ -75,53 +66,40 @@ async def get_current_user(
detail="Token 无效或已过期"
)
try:
supabase = get_supabase()
# 验证 session_token (单设备登录)
session_result = supabase.table("user_sessions").select("*").eq(
"user_id", token_data.user_id
).eq(
"session_token", token_data.session_token
).execute()
if not session_result.data:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="会话已失效,请重新登录(可能已在其他设备登录)"
)
# 获取用户信息
user_result = supabase.table("users").select("*").eq(
"id", token_data.user_id
).single().execute()
user = user_result.data
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户不存在"
)
# 检查授权是否过期
if user.get("expires_at"):
from datetime import datetime, timezone
expires_at = datetime.fromisoformat(user["expires_at"].replace("Z", "+00:00"))
if datetime.now(timezone.utc) > expires_at:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="授权已过期,请联系管理员续期"
)
return user
except HTTPException:
raise
except Exception as e:
logger.error(f"获取用户信息失败: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="服务器错误"
)
try:
session = get_session(token_data.user_id, token_data.session_token)
if not session:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="会话已失效,请重新登录(可能已在其他设备登录)"
)
user = get_user_by_id(token_data.user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户不存在"
)
user = cast(Dict[str, Any], user)
if user.get("expires_at"):
from datetime import datetime, timezone
expires_at = datetime.fromisoformat(user["expires_at"].replace("Z", "+00:00"))
if datetime.now(timezone.utc) > expires_at:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="授权已过期,请联系管理员续期"
)
return user
except HTTPException:
raise
except Exception as e:
logger.error(f"获取用户信息失败: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="服务器错误"
)
async def get_current_admin(

View File

@@ -0,0 +1,26 @@
from typing import Any, Dict, Optional
def success_response(
data: Any = None,
message: str = "ok",
code: int = 0,
success: bool = True,
) -> Dict[str, Any]:
return {
"success": success,
"message": message,
"data": data,
"code": code,
}
def error_response(message: str, code: int, data: Optional[Any] = None) -> Dict[str, Any]:
payload = {
"success": False,
"message": message,
"code": code,
}
if data is not None:
payload["data"] = data
return payload

View File

@@ -1,8 +1,20 @@
from fastapi import FastAPI
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.api import materials, videos, publish, login_helper, auth, admin, ref_audios, ai, tools, assets
from app.core.response import error_response
# 直接从 modules 导入路由,消除 api 转发层
from app.modules.materials.router import router as materials_router
from app.modules.videos.router import router as videos_router
from app.modules.publish.router import router as publish_router
from app.modules.login_helper.router import router as login_helper_router
from app.modules.auth.router import router as auth_router
from app.modules.admin.router import router as admin_router
from app.modules.ref_audios.router import router as ref_audios_router
from app.modules.ai.router import router as ai_router
from app.modules.tools.router import router as tools_router
from app.modules.assets.router import router as assets_router
from loguru import logger
import os
@@ -11,15 +23,33 @@ settings = config.settings
app = FastAPI(title="ViGent TalkingHead Agent")
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
@@ -32,35 +62,68 @@ class LoggingMiddleware(BaseHTTPMiddleware):
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"])
app.include_router(videos.router, prefix="/api/videos", tags=["Videos"])
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(materials_router, prefix="/api/materials", tags=["Materials"])
app.include_router(videos_router, prefix="/api/videos", tags=["Videos"])
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.on_event("startup")
@@ -76,27 +139,21 @@ async def init_admin():
return
try:
from app.core.supabase import get_supabase
from app.core.security import get_password_hash
supabase = get_supabase()
# 检查是否已存在
existing = supabase.table("users").select("id").eq("phone", admin_phone).execute()
if existing.data:
from app.repositories.users import create_user, user_exists_by_phone
if user_exists_by_phone(admin_phone):
logger.info(f"管理员账号已存在: {admin_phone}")
return
# 创建管理员
supabase.table("users").insert({
create_user({
"phone": admin_phone,
"password_hash": get_password_hash(admin_password),
"username": "Admin",
"role": "admin",
"is_active": True,
"expires_at": None # 永不过期
}).execute()
})
logger.success(f"管理员账号已创建: {admin_phone}")
except Exception as e:

View File

View File

@@ -3,10 +3,12 @@
"""
from fastapi import APIRouter, HTTPException, Depends, status
from pydantic import BaseModel
from typing import Optional, List
from typing import Optional, List, Any, cast
from datetime import datetime, timezone, timedelta
from app.core.supabase import get_supabase
from app.core.deps import get_current_admin
from app.core.deps import get_current_admin
from app.core.response import success_response
from app.repositories.sessions import delete_sessions
from app.repositories.users import get_user_by_id, list_users as list_users_repo, update_user
from loguru import logger
router = APIRouter(prefix="/api/admin", tags=["管理"])
@@ -26,25 +28,23 @@ class ActivateRequest(BaseModel):
expires_days: Optional[int] = None # 授权天数None 表示永久
@router.get("/users", response_model=List[UserListItem])
async def list_users(admin: dict = Depends(get_current_admin)):
@router.get("/users")
async def list_users(admin: dict = Depends(get_current_admin)):
"""获取所有用户列表"""
try:
supabase = get_supabase()
result = supabase.table("users").select("*").order("created_at", desc=True).execute()
return [
UserListItem(
id=u["id"],
phone=u["phone"],
username=u.get("username"),
role=u["role"],
is_active=u["is_active"],
expires_at=u.get("expires_at"),
created_at=u["created_at"]
)
for u in result.data
]
data = list_users_repo()
return success_response([
UserListItem(
id=u["id"],
phone=u["phone"],
username=u.get("username"),
role=u["role"],
is_active=u["is_active"],
expires_at=u.get("expires_at"),
created_at=u["created_at"]
).model_dump()
for u in data
])
except Exception as e:
logger.error(f"获取用户列表失败: {e}")
raise HTTPException(
@@ -67,32 +67,26 @@ async def activate_user(
request.expires_days: 授权天数 (None 表示永久)
"""
try:
supabase = get_supabase()
# 计算过期时间
expires_at = None
if request.expires_days:
expires_at = (datetime.now(timezone.utc) + timedelta(days=request.expires_days)).isoformat()
# 更新用户
result = supabase.table("users").update({
"is_active": True,
"role": "user",
"expires_at": expires_at
}).eq("id", user_id).execute()
if not result.data:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="用户不存在"
)
# 计算过期时间
expires_at = None
if request.expires_days:
expires_at = (datetime.now(timezone.utc) + timedelta(days=request.expires_days)).isoformat()
result = update_user(user_id, {
"is_active": True,
"role": "user",
"expires_at": expires_at
})
if not result:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="用户不存在"
)
logger.info(f"管理员 {admin['phone']} 激活用户 {user_id}, 有效期: {request.expires_days or '永久'}")
return {
"success": True,
"message": f"用户已激活,有效期: {request.expires_days or '永久'}"
}
return success_response(message=f"用户已激活,有效期: {request.expires_days or '永久'}")
except HTTPException:
raise
except Exception as e:
@@ -110,27 +104,20 @@ async def deactivate_user(
):
"""停用用户"""
try:
supabase = get_supabase()
# 不能停用管理员
user_result = supabase.table("users").select("role").eq("id", user_id).single().execute()
if user_result.data and user_result.data["role"] == "admin":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="不能停用管理员账号"
)
# 更新用户
result = supabase.table("users").update({
"is_active": False
}).eq("id", user_id).execute()
# 清除用户 session
supabase.table("user_sessions").delete().eq("user_id", user_id).execute()
# 不能停用管理员
user = cast(dict[str, Any], get_user_by_id(user_id) or {})
if user.get("role") == "admin":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="不能停用管理员账号"
)
update_user(user_id, {"is_active": False})
delete_sessions(user_id)
logger.info(f"管理员 {admin['phone']} 停用用户 {user_id}")
return {"success": True, "message": "用户已停用"}
return success_response(message="用户已停用")
except HTTPException:
raise
except Exception as e:
@@ -149,15 +136,12 @@ async def extend_user(
):
"""延长用户授权期限"""
try:
supabase = get_supabase()
if not request.expires_days:
# 设为永久
expires_at = None
else:
# 获取当前过期时间
user_result = supabase.table("users").select("expires_at").eq("id", user_id).single().execute()
user = user_result.data
if not request.expires_days:
# 设为永久
expires_at = None
else:
# 获取当前过期时间
user = cast(dict[str, Any], get_user_by_id(user_id) or {})
if user and user.get("expires_at"):
current_expires = datetime.fromisoformat(user["expires_at"].replace("Z", "+00:00"))
@@ -167,16 +151,11 @@ async def extend_user(
expires_at = (base_time + timedelta(days=request.expires_days)).isoformat()
result = supabase.table("users").update({
"expires_at": expires_at
}).eq("id", user_id).execute()
update_user(user_id, {"expires_at": expires_at})
logger.info(f"管理员 {admin['phone']} 延长用户 {user_id} 授权 {request.expires_days or '永久'}")
return {
"success": True,
"message": f"授权已延长 {request.expires_days or '永久'}"
}
return success_response(message=f"授权已延长 {request.expires_days or '永久'}")
except Exception as e:
logger.error(f"延长授权失败: {e}")
raise HTTPException(

View File

View File

@@ -7,6 +7,7 @@ from pydantic import BaseModel
from loguru import logger
from app.services.glm_service import glm_service
from app.core.response import success_response
router = APIRouter(prefix="/api/ai", tags=["AI"])
@@ -23,7 +24,34 @@ class GenerateMetaResponse(BaseModel):
tags: list[str]
@router.post("/generate-meta", response_model=GenerateMetaResponse)
class TranslateRequest(BaseModel):
"""翻译请求"""
text: str
target_lang: str
@router.post("/translate")
async def translate_text(req: TranslateRequest):
"""
AI 翻译文案
将文案翻译为指定目标语言
"""
if not req.text or not req.text.strip():
raise HTTPException(status_code=400, detail="文案不能为空")
if not req.target_lang or not req.target_lang.strip():
raise HTTPException(status_code=400, detail="目标语言不能为空")
try:
logger.info(f"Translating text to {req.target_lang}: {req.text[:50]}...")
translated = await glm_service.translate_text(req.text.strip(), req.target_lang.strip())
return success_response({"translated_text": translated})
except Exception as e:
logger.error(f"Translate failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/generate-meta")
async def generate_meta(req: GenerateMetaRequest):
"""
AI 生成视频标题和标签
@@ -36,10 +64,10 @@ async def generate_meta(req: GenerateMetaRequest):
try:
logger.info(f"Generating meta for text: {req.text[:50]}...")
result = await glm_service.generate_title_tags(req.text)
return GenerateMetaResponse(
return success_response(GenerateMetaResponse(
title=result.get("title", ""),
tags=result.get("tags", [])
)
).model_dump())
except Exception as e:
logger.error(f"Generate meta failed: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

View File

@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends
from app.core.deps import get_current_user
from app.services.assets_service import list_styles, list_bgm
from app.core.response import success_response
router = APIRouter()
@@ -9,14 +10,14 @@ router = APIRouter()
@router.get("/subtitle-styles")
async def list_subtitle_styles(current_user: dict = Depends(get_current_user)):
return {"styles": list_styles("subtitle")}
return success_response({"styles": list_styles("subtitle")})
@router.get("/title-styles")
async def list_title_styles(current_user: dict = Depends(get_current_user)):
return {"styles": list_styles("title")}
return success_response({"styles": list_styles("title")})
@router.get("/bgm")
async def list_bgm_items(current_user: dict = Depends(get_current_user)):
return {"bgm": list_bgm()}
return success_response({"bgm": list_bgm()})

View File

View File

@@ -3,18 +3,20 @@
"""
from fastapi import APIRouter, HTTPException, Response, status, Request
from pydantic import BaseModel, field_validator
from app.core.supabase import get_supabase
from app.core.security import (
get_password_hash,
verify_password,
create_access_token,
generate_session_token,
set_auth_cookie,
clear_auth_cookie,
decode_access_token
)
from app.core.security import (
get_password_hash,
verify_password,
create_access_token,
generate_session_token,
set_auth_cookie,
clear_auth_cookie,
decode_access_token
)
from app.repositories.sessions import create_session, delete_sessions
from app.repositories.users import create_user, get_user_by_id, get_user_by_phone, user_exists_by_phone, update_user
from app.core.response import success_response
from loguru import logger
from typing import Optional
from typing import Optional, Any, cast
import re
router = APIRouter(prefix="/api/auth", tags=["认证"])
@@ -74,36 +76,26 @@ async def register(request: RegisterRequest):
注册后状态为 pending需要管理员激活
"""
try:
supabase = get_supabase()
# 检查手机号是否已存在
existing = supabase.table("users").select("id").eq(
"phone", request.phone
).execute()
if existing.data:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="该手机号已注册"
)
if user_exists_by_phone(request.phone):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="该手机号已注册"
)
# 创建用户
password_hash = get_password_hash(request.password)
result = supabase.table("users").insert({
"phone": request.phone,
"password_hash": password_hash,
"username": request.username or f"用户{request.phone[-4:]}",
"role": "pending",
"is_active": False
}).execute()
create_user({
"phone": request.phone,
"password_hash": password_hash,
"username": request.username or f"用户{request.phone[-4:]}",
"role": "pending",
"is_active": False
})
logger.info(f"新用户注册: {request.phone}")
return {
"success": True,
"message": "注册成功,请等待管理员审核激活"
}
return success_response(message="注册成功,请等待管理员审核激活")
except HTTPException:
raise
except Exception as e:
@@ -124,19 +116,12 @@ async def login(request: LoginRequest, response: Response):
- 实现"后踢前"单设备登录
"""
try:
supabase = get_supabase()
# 查找用户
user_result = supabase.table("users").select("*").eq(
"phone", request.phone
).single().execute()
user = user_result.data
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="手机号或密码错误"
)
user = cast(dict[str, Any], get_user_by_phone(request.phone) or {})
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="手机号或密码错误"
)
# 验证密码
if not verify_password(request.password, user["password_hash"]):
@@ -166,15 +151,8 @@ async def login(request: LoginRequest, response: Response):
session_token = generate_session_token()
# 删除旧 session插入新 session
supabase.table("user_sessions").delete().eq(
"user_id", user["id"]
).execute()
supabase.table("user_sessions").insert({
"user_id": user["id"],
"session_token": session_token,
"device_info": None # 可以从 request headers 获取
}).execute()
delete_sessions(user["id"])
create_session(user["id"], session_token, None)
# 生成 JWT Token
token = create_access_token(user["id"], session_token)
@@ -184,18 +162,19 @@ async def login(request: LoginRequest, response: Response):
logger.info(f"用户登录: {request.phone}")
return {
"success": True,
"message": "登录成功",
"user": UserResponse(
id=user["id"],
phone=user["phone"],
username=user.get("username"),
role=user["role"],
is_active=user["is_active"],
expires_at=user.get("expires_at")
)
}
return success_response(
data={
"user": UserResponse(
id=user["id"],
phone=user["phone"],
username=user.get("username"),
role=user["role"],
is_active=user["is_active"],
expires_at=user.get("expires_at")
).model_dump()
},
message="登录成功",
)
except HTTPException:
raise
except Exception as e:
@@ -207,10 +186,10 @@ async def login(request: LoginRequest, response: Response):
@router.post("/logout")
async def logout(response: Response):
"""用户登出"""
clear_auth_cookie(response)
return {"success": True, "message": "已登出"}
async def logout(response: Response):
"""用户登出"""
clear_auth_cookie(response)
return success_response(message="已登出")
@router.post("/change-password")
@@ -238,19 +217,12 @@ async def change_password(request: ChangePasswordRequest, req: Request, response
)
try:
supabase = get_supabase()
# 获取用户信息
user_result = supabase.table("users").select("*").eq(
"id", token_data.user_id
).single().execute()
user = user_result.data
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户不存在"
)
user = cast(dict[str, Any], get_user_by_id(token_data.user_id) or {})
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户不存在"
)
# 验证当前密码
if not verify_password(request.old_password, user["password_hash"]):
@@ -261,22 +233,13 @@ async def change_password(request: ChangePasswordRequest, req: Request, response
# 更新密码
new_password_hash = get_password_hash(request.new_password)
supabase.table("users").update({
"password_hash": new_password_hash
}).eq("id", user["id"]).execute()
update_user(user["id"], {"password_hash": new_password_hash})
# 生成新的 session token使旧 token 失效
new_session_token = generate_session_token()
supabase.table("user_sessions").delete().eq(
"user_id", user["id"]
).execute()
supabase.table("user_sessions").insert({
"user_id": user["id"],
"session_token": new_session_token,
"device_info": None
}).execute()
delete_sessions(user["id"])
create_session(user["id"], new_session_token, None)
# 生成新的 JWT Token
new_token = create_access_token(user["id"], new_session_token)
@@ -284,10 +247,7 @@ async def change_password(request: ChangePasswordRequest, req: Request, response
logger.info(f"用户修改密码: {user['phone']}")
return {
"success": True,
"message": "密码修改成功"
}
return success_response(message="密码修改成功")
except HTTPException:
raise
except Exception as e:
@@ -316,23 +276,18 @@ async def get_me(request: Request):
detail="Token 无效"
)
supabase = get_supabase()
user_result = supabase.table("users").select("*").eq(
"id", token_data.user_id
).single().execute()
user = user_result.data
user = cast(dict[str, Any], get_user_by_id(token_data.user_id) or {})
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户不存在"
)
return UserResponse(
id=user["id"],
phone=user["phone"],
username=user.get("username"),
role=user["role"],
is_active=user["is_active"],
expires_at=user.get("expires_at")
)
return success_response(UserResponse(
id=user["id"],
phone=user["phone"],
username=user.get("username"),
role=user["role"],
is_active=user["is_active"],
expires_at=user.get("expires_at")
).model_dump())

View File

@@ -15,17 +15,19 @@ async def login_helper_page(platform: str, request: Request):
登录后JavaScript自动提取Cookie并POST回服务器
"""
platform_urls = {
"bilibili": "https://www.bilibili.com/",
"douyin": "https://creator.douyin.com/",
"xiaohongshu": "https://creator.xiaohongshu.com/"
}
platform_urls = {
"bilibili": "https://www.bilibili.com/",
"douyin": "https://creator.douyin.com/",
"xiaohongshu": "https://creator.xiaohongshu.com/",
"weixin": "https://channels.weixin.qq.com/"
}
platform_names = {
"bilibili": "B站",
"douyin": "抖音",
"xiaohongshu": "小红书"
}
platform_names = {
"bilibili": "B站",
"douyin": "抖音",
"xiaohongshu": "小红书",
"weixin": "微信视频号"
}
if platform not in platform_urls:
return "<h1>不支持的平台</h1>"

View File

@@ -0,0 +1,62 @@
from fastapi import APIRouter, HTTPException, Request, Depends
from loguru import logger
from app.core.deps import get_current_user
from app.core.response import success_response
from app.modules.materials.schemas import RenameMaterialRequest
from app.modules.materials import service
router = APIRouter()
@router.post("")
async def upload_material(
request: Request,
current_user: dict = Depends(get_current_user)
):
user_id = current_user["id"]
logger.info(f"Upload material request from user {user_id}")
try:
result = await service.upload_material(request, user_id)
return success_response(result)
except ValueError as e:
raise HTTPException(400, str(e))
except Exception as e:
raise HTTPException(500, f"Upload failed. Error: {str(e)}")
@router.get("")
async def list_materials(current_user: dict = Depends(get_current_user)):
user_id = current_user["id"]
materials = await service.list_materials(user_id)
return success_response({"materials": materials})
@router.delete("/{material_id:path}")
async def delete_material(material_id: str, current_user: dict = Depends(get_current_user)):
user_id = current_user["id"]
try:
await service.delete_material(material_id, user_id)
return success_response(message="素材已删除")
except PermissionError as e:
raise HTTPException(403, str(e))
except Exception as e:
raise HTTPException(500, f"删除失败: {str(e)}")
@router.put("/{material_id:path}")
async def rename_material(
material_id: str,
payload: RenameMaterialRequest,
current_user: dict = Depends(get_current_user)
):
user_id = current_user["id"]
try:
result = await service.rename_material(material_id, payload.new_name, user_id)
return success_response(result, message="重命名成功")
except PermissionError as e:
raise HTTPException(403, str(e))
except ValueError as e:
raise HTTPException(400, str(e))
except Exception as e:
raise HTTPException(500, f"重命名失败: {str(e)}")

View File

@@ -0,0 +1,14 @@
from pydantic import BaseModel
class RenameMaterialRequest(BaseModel):
new_name: str
class MaterialItem(BaseModel):
id: str
name: str
path: str
size_mb: float
type: str = "video"
created_at: int = 0

View File

@@ -0,0 +1,296 @@
import re
import os
import time
import asyncio
import traceback
import aiofiles
from pathlib import Path
from loguru import logger
from app.services.storage import storage_service
def sanitize_filename(filename: str) -> str:
safe_name = re.sub(r'[<>:"/\\|?*]', '_', filename)
if len(safe_name) > 100:
ext = Path(safe_name).suffix
safe_name = safe_name[:100 - len(ext)] + ext
return safe_name
def _extract_display_name(storage_name: str) -> str:
"""从存储文件名中提取显示名(去掉时间戳前缀)"""
if '_' in storage_name:
parts = storage_name.split('_', 1)
if parts[0].isdigit():
return parts[1]
return storage_name
async def _process_and_upload(temp_file_path: str, original_filename: str, content_type: str, user_id: str) -> str:
"""Strip multipart headers and upload to Supabase, return storage_path"""
try:
logger.info(f"Processing raw upload: {temp_file_path} for user {user_id}")
file_size = os.path.getsize(temp_file_path)
with open(temp_file_path, 'rb') as f:
head = f.read(4096)
first_line_end = head.find(b'\r\n')
if first_line_end == -1:
raise Exception("Could not find boundary in multipart body")
boundary = head[:first_line_end]
logger.info(f"Detected boundary: {boundary}")
header_end = head.find(b'\r\n\r\n')
if header_end == -1:
raise Exception("Could not find end of multipart headers")
start_offset = header_end + 4
logger.info(f"Video data starts at offset: {start_offset}")
f.seek(max(0, file_size - 200))
tail = f.read()
last_boundary_pos = tail.rfind(boundary)
if last_boundary_pos != -1:
end_offset = (max(0, file_size - 200) + last_boundary_pos) - 2
else:
logger.warning("Could not find closing boundary, assuming EOF")
end_offset = file_size
logger.info(f"Video data ends at offset: {end_offset}. Total video size: {end_offset - start_offset}")
video_path = temp_file_path + "_video.mp4"
with open(temp_file_path, 'rb') as src, open(video_path, 'wb') as dst:
src.seek(start_offset)
bytes_to_copy = end_offset - start_offset
copied = 0
while copied < bytes_to_copy:
chunk_size = min(1024 * 1024 * 10, bytes_to_copy - copied)
chunk = src.read(chunk_size)
if not chunk:
break
dst.write(chunk)
copied += len(chunk)
logger.info(f"Extracted video content to {video_path}")
timestamp = int(time.time())
safe_name = re.sub(r'[^a-zA-Z0-9._-]', '', original_filename)
storage_path = f"{user_id}/{timestamp}_{safe_name}"
with open(video_path, 'rb') as f:
file_content = f.read()
await storage_service.upload_file(
bucket=storage_service.BUCKET_MATERIALS,
path=storage_path,
file_data=file_content,
content_type=content_type
)
logger.info(f"Upload to Supabase complete: {storage_path}")
os.remove(temp_file_path)
os.remove(video_path)
return storage_path
except Exception as e:
logger.error(f"Background upload processing failed: {e}\n{traceback.format_exc()}")
raise
async def upload_material(request, user_id: str) -> dict:
"""接收流式上传并存储到 Supabase返回素材信息"""
filename = "unknown_video.mp4"
content_type = "video/mp4"
timestamp = int(time.time())
temp_filename = f"upload_{timestamp}.raw"
temp_path = os.path.join("/tmp", temp_filename)
if os.name == 'nt':
temp_path = f"d:/tmp/{temp_filename}"
os.makedirs("d:/tmp", exist_ok=True)
try:
total_size = 0
last_log = 0
async with aiofiles.open(temp_path, 'wb') as f:
async for chunk in request.stream():
await f.write(chunk)
total_size += len(chunk)
if total_size - last_log > 20 * 1024 * 1024:
logger.info(f"Receiving stream... Processed {total_size / (1024*1024):.2f} MB")
last_log = total_size
logger.info(f"Stream reception complete. Total size: {total_size} bytes. Saved to {temp_path}")
if total_size == 0:
raise ValueError("Received empty body")
with open(temp_path, 'rb') as f:
head = f.read(4096).decode('utf-8', errors='ignore')
match = re.search(r'filename="([^"]+)"', head)
if match:
filename = match.group(1)
logger.info(f"Extracted filename from body: {filename}")
storage_path = await _process_and_upload(temp_path, filename, content_type, user_id)
signed_url = await storage_service.get_signed_url(
bucket=storage_service.BUCKET_MATERIALS,
path=storage_path
)
size_mb = total_size / (1024 * 1024)
display_name = _extract_display_name(storage_path.split('/')[-1])
return {
"id": storage_path,
"name": display_name,
"path": signed_url,
"size_mb": size_mb,
"type": "video"
}
except Exception as e:
error_msg = f"Streaming upload failed: {str(e)}"
detail_msg = f"Exception: {repr(e)}\nArgs: {e.args}\n{traceback.format_exc()}"
logger.error(error_msg + "\n" + detail_msg)
try:
with open("debug_upload.log", "a") as logf:
logf.write(f"\n--- Error at {time.ctime()} ---\n")
logf.write(detail_msg)
logf.write("\n-----------------------------\n")
except:
pass
if os.path.exists(temp_path):
try:
os.remove(temp_path)
except:
pass
raise
async def list_materials(user_id: str) -> list[dict]:
"""列出用户的所有素材"""
try:
files_obj = await storage_service.list_files(
bucket=storage_service.BUCKET_MATERIALS,
path=user_id
)
semaphore = asyncio.Semaphore(8)
async def build_item(f):
name = f.get('name')
if not name or name == '.emptyFolderPlaceholder':
return None
display_name = _extract_display_name(name)
full_path = f"{user_id}/{name}"
async with semaphore:
signed_url = await storage_service.get_signed_url(
bucket=storage_service.BUCKET_MATERIALS,
path=full_path
)
metadata = f.get('metadata', {})
size = metadata.get('size', 0)
created_at_str = f.get('created_at', '')
created_at = 0
if created_at_str:
from datetime import datetime
try:
dt = datetime.fromisoformat(created_at_str.replace('Z', '+00:00'))
created_at = int(dt.timestamp())
except Exception:
pass
return {
"id": full_path,
"name": display_name,
"path": signed_url,
"size_mb": size / (1024 * 1024),
"type": "video",
"created_at": created_at
}
tasks = [build_item(f) for f in files_obj]
results = await asyncio.gather(*tasks, return_exceptions=True)
materials = []
for item in results:
if not item:
continue
if isinstance(item, Exception):
logger.warning(f"Material signed url build failed: {item}")
continue
materials.append(item)
materials.sort(key=lambda x: x['id'], reverse=True)
return materials
except Exception as e:
logger.error(f"List materials failed: {e}")
return []
async def delete_material(material_id: str, user_id: str) -> None:
"""删除素材"""
if not material_id.startswith(f"{user_id}/"):
raise PermissionError("无权删除此素材")
await storage_service.delete_file(
bucket=storage_service.BUCKET_MATERIALS,
path=material_id
)
async def rename_material(material_id: str, new_name_raw: str, user_id: str) -> dict:
"""重命名素材,返回更新后的素材信息"""
if not material_id.startswith(f"{user_id}/"):
raise PermissionError("无权重命名此素材")
new_name_raw = new_name_raw.strip() if new_name_raw else ""
if not new_name_raw:
raise ValueError("新名称不能为空")
old_name = material_id.split("/", 1)[1]
old_ext = Path(old_name).suffix
base_name = Path(new_name_raw).stem if Path(new_name_raw).suffix else new_name_raw
safe_base = sanitize_filename(base_name).strip()
if not safe_base:
raise ValueError("新名称无效")
new_filename = f"{safe_base}{old_ext}"
prefix = None
if "_" in old_name:
maybe_prefix, _ = old_name.split("_", 1)
if maybe_prefix.isdigit():
prefix = maybe_prefix
if prefix:
new_filename = f"{prefix}_{new_filename}"
new_path = f"{user_id}/{new_filename}"
if new_path != material_id:
await storage_service.move_file(
bucket=storage_service.BUCKET_MATERIALS,
from_path=material_id,
to_path=new_path
)
signed_url = await storage_service.get_signed_url(
bucket=storage_service.BUCKET_MATERIALS,
path=new_path
)
display_name = _extract_display_name(new_filename)
return {
"id": new_path,
"name": display_name,
"path": signed_url,
}

View File

View File

@@ -1,13 +1,17 @@
"""
发布管理 API (支持用户认证)
"""
from fastapi import APIRouter, HTTPException, BackgroundTasks, Depends, Request
from pydantic import BaseModel
from typing import List, Optional
from datetime import datetime
from loguru import logger
from app.services.publish_service import PublishService
from app.core.deps import get_current_user_optional
from fastapi import APIRouter, HTTPException, BackgroundTasks, Depends, Request
from fastapi.responses import FileResponse
from pydantic import BaseModel
from typing import List, Optional
from datetime import datetime
import re
from loguru import logger
from app.services.publish_service import PublishService
from app.core.response import success_response
from app.core.config import settings
from app.core.deps import get_current_user
router = APIRouter()
publish_service = PublishService()
@@ -29,7 +33,7 @@ class PublishResponse(BaseModel):
url: Optional[str] = None
# Supported platforms for validation
SUPPORTED_PLATFORMS = {"bilibili", "douyin", "xiaohongshu"}
SUPPORTED_PLATFORMS = {"bilibili", "douyin", "xiaohongshu", "weixin"}
def _get_user_id(request: Request) -> Optional[str]:
@@ -46,8 +50,8 @@ def _get_user_id(request: Request) -> Optional[str]:
return None
@router.post("", response_model=PublishResponse)
async def publish_video(request: PublishRequest, req: Request, background_tasks: BackgroundTasks):
@router.post("")
async def publish_video(request: PublishRequest, req: Request, background_tasks: BackgroundTasks):
"""发布视频到指定平台"""
# Validate platform
if request.platform not in SUPPORTED_PLATFORMS:
@@ -69,27 +73,23 @@ async def publish_video(request: PublishRequest, req: Request, background_tasks:
publish_time=request.publish_time,
user_id=user_id
)
return PublishResponse(
success=result.get("success", False),
message=result.get("message", ""),
platform=request.platform,
url=result.get("url")
)
message = result.get("message", "")
return success_response(result, message=message)
except Exception as e:
logger.error(f"发布失败: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/platforms")
async def list_platforms():
return {"platforms": [{**pinfo, "id": pid} for pid, pinfo in publish_service.PLATFORMS.items()]}
async def list_platforms():
return success_response({"platforms": [{**pinfo, "id": pid} for pid, pinfo in publish_service.PLATFORMS.items()]})
@router.get("/accounts")
async def list_accounts(req: Request):
user_id = _get_user_id(req)
return {"accounts": publish_service.get_accounts(user_id)}
async def list_accounts(req: Request):
user_id = _get_user_id(req)
return success_response({"accounts": publish_service.get_accounts(user_id)})
@router.post("/login/{platform}")
async def login_platform(platform: str, req: Request):
async def login_platform(platform: str, req: Request):
"""触发平台QR码登录"""
if platform not in SUPPORTED_PLATFORMS:
raise HTTPException(status_code=400, detail=f"不支持的平台: {platform}")
@@ -97,32 +97,33 @@ async def login_platform(platform: str, req: Request):
user_id = _get_user_id(req)
result = await publish_service.login(platform, user_id)
if result.get("success"):
return result
else:
raise HTTPException(status_code=400, detail=result.get("message"))
message = result.get("message", "")
return success_response(result, message=message)
@router.post("/logout/{platform}")
async def logout_platform(platform: str, req: Request):
async def logout_platform(platform: str, req: Request):
"""注销平台登录"""
if platform not in SUPPORTED_PLATFORMS:
raise HTTPException(status_code=400, detail=f"不支持的平台: {platform}")
user_id = _get_user_id(req)
result = publish_service.logout(platform, user_id)
return result
result = publish_service.logout(platform, user_id)
message = result.get("message", "")
return success_response(result, message=message)
@router.get("/login/status/{platform}")
async def get_login_status(platform: str, req: Request):
async def get_login_status(platform: str, req: Request):
"""检查登录状态 (优先检查活跃的扫码会话)"""
if platform not in SUPPORTED_PLATFORMS:
raise HTTPException(status_code=400, detail=f"不支持的平台: {platform}")
user_id = _get_user_id(req)
return publish_service.get_login_session_status(platform, user_id)
result = publish_service.get_login_session_status(platform, user_id)
message = result.get("message", "")
return success_response(result, message=message)
@router.post("/cookies/save/{platform}")
async def save_platform_cookie(platform: str, cookie_data: dict, req: Request):
@router.post("/cookies/save/{platform}")
async def save_platform_cookie(platform: str, cookie_data: dict, req: Request):
"""
保存从客户端浏览器提取的Cookie
@@ -140,7 +141,25 @@ async def save_platform_cookie(platform: str, cookie_data: dict, req: Request):
user_id = _get_user_id(req)
result = await publish_service.save_cookie_string(platform, cookie_string, user_id)
if result.get("success"):
return result
else:
raise HTTPException(status_code=400, detail=result.get("message"))
message = result.get("message", "")
return success_response(result, message=message)
@router.get("/screenshot/{filename}")
async def get_publish_screenshot(
filename: str,
current_user: dict = Depends(get_current_user),
):
if not re.match(r"^[A-Za-z0-9_.-]+$", filename):
raise HTTPException(status_code=400, detail="非法文件名")
user_id = str(current_user.get("id") or "")
if not user_id:
raise HTTPException(status_code=401, detail="未登录")
user_dir = re.sub(r"[^A-Za-z0-9_-]", "_", user_id)[:64] or "legacy"
file_path = settings.PUBLISH_SCREENSHOT_DIR / user_dir / filename
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail="截图不存在")
return FileResponse(path=str(file_path), media_type="image/png")

View File

@@ -0,0 +1,70 @@
"""参考音频管理 API"""
from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Depends
from loguru import logger
from app.core.deps import get_current_user
from app.core.response import success_response
from app.modules.ref_audios.schemas import RenameRequest
from app.modules.ref_audios import service
router = APIRouter()
@router.post("")
async def upload_ref_audio(
file: UploadFile = File(...),
ref_text: str = Form(...),
user: dict = Depends(get_current_user)
):
"""上传参考音频"""
try:
result = await service.upload_ref_audio(file, ref_text, user["id"])
return success_response(result)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"上传参考音频失败: {e}")
raise HTTPException(status_code=500, detail=f"上传失败: {str(e)}")
@router.get("")
async def list_ref_audios(user: dict = Depends(get_current_user)):
"""列出当前用户的所有参考音频"""
try:
result = await service.list_ref_audios(user["id"])
return success_response(result)
except Exception as e:
logger.error(f"列出参考音频失败: {e}")
raise HTTPException(status_code=500, detail=f"获取列表失败: {str(e)}")
@router.delete("/{audio_id:path}")
async def delete_ref_audio(audio_id: str, user: dict = Depends(get_current_user)):
"""删除参考音频"""
try:
await service.delete_ref_audio(audio_id, user["id"])
return success_response(message="删除成功")
except PermissionError as e:
raise HTTPException(status_code=403, detail=str(e))
except Exception as e:
logger.error(f"删除参考音频失败: {e}")
raise HTTPException(status_code=500, detail=f"删除失败: {str(e)}")
@router.put("/{audio_id:path}")
async def rename_ref_audio(
audio_id: str,
request: RenameRequest,
user: dict = Depends(get_current_user)
):
"""重命名参考音频"""
try:
result = await service.rename_ref_audio(audio_id, request.new_name, user["id"])
return success_response(result, message="重命名成功")
except PermissionError as e:
raise HTTPException(status_code=403, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"重命名失败: {e}")
raise HTTPException(status_code=500, detail=f"重命名失败: {str(e)}")

View File

@@ -0,0 +1,19 @@
from pydantic import BaseModel
from typing import List
class RefAudioResponse(BaseModel):
id: str
name: str
path: str
ref_text: str
duration_sec: float
created_at: int
class RefAudioListResponse(BaseModel):
items: List[RefAudioResponse]
class RenameRequest(BaseModel):
new_name: str

View File

@@ -0,0 +1,269 @@
import re
import os
import time
import json
import asyncio
import subprocess
import tempfile
from pathlib import Path
from typing import Optional
import httpx
from loguru import logger
from app.services.storage import storage_service
from app.modules.ref_audios.schemas import RefAudioResponse, RefAudioListResponse
ALLOWED_AUDIO_EXTENSIONS = {'.wav', '.mp3', '.m4a', '.webm', '.ogg', '.flac', '.aac'}
BUCKET_REF_AUDIOS = "ref-audios"
def sanitize_filename(filename: str) -> str:
"""清理文件名,移除特殊字符"""
safe_name = re.sub(r'[<>:"/\\|?*\s]', '_', filename)
if len(safe_name) > 50:
ext = Path(safe_name).suffix
safe_name = safe_name[:50 - len(ext)] + ext
return safe_name
def _get_audio_duration(file_path: str) -> float:
"""获取音频时长 (秒)"""
try:
result = subprocess.run(
['ffprobe', '-v', 'quiet', '-show_entries', 'format=duration',
'-of', 'csv=p=0', file_path],
capture_output=True, text=True, timeout=10
)
return float(result.stdout.strip())
except Exception as e:
logger.warning(f"获取音频时长失败: {e}")
return 0.0
def _convert_to_wav(input_path: str, output_path: str) -> bool:
"""将音频转换为 WAV 格式 (16kHz, mono)"""
try:
subprocess.run([
'ffmpeg', '-y', '-i', input_path,
'-ar', '16000',
'-ac', '1',
'-acodec', 'pcm_s16le',
output_path
], capture_output=True, timeout=60, check=True)
return True
except Exception as e:
logger.error(f"音频转换失败: {e}")
return False
async def upload_ref_audio(file, ref_text: str, user_id: str) -> dict:
"""上传参考音频:转码、获取时长、存储到 Supabase"""
if not file.filename:
raise ValueError("文件名无效")
filename = file.filename
ext = Path(filename).suffix.lower()
if ext not in ALLOWED_AUDIO_EXTENSIONS:
raise ValueError(f"不支持的音频格式: {ext}。支持的格式: {', '.join(ALLOWED_AUDIO_EXTENSIONS)}")
if not ref_text or len(ref_text.strip()) < 2:
raise ValueError("参考文字不能为空")
# 创建临时文件
with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp_input:
content = await file.read()
tmp_input.write(content)
tmp_input_path = tmp_input.name
try:
# 转换为 WAV 格式
tmp_wav_path = tmp_input_path + ".wav"
if not _convert_to_wav(tmp_input_path, tmp_wav_path):
raise RuntimeError("音频格式转换失败")
# 获取音频时长
duration = _get_audio_duration(tmp_wav_path)
if duration < 1.0:
raise ValueError("音频时长过短,至少需要 1 秒")
if duration > 60.0:
raise ValueError("音频时长过长,最多 60 秒")
# 检查重名
existing_files = await storage_service.list_files(BUCKET_REF_AUDIOS, user_id)
dup_count = 0
search_suffix = f"_{filename}"
for f in existing_files:
fname = f.get('name', '')
if fname.endswith(search_suffix):
dup_count += 1
final_display_name = filename
if dup_count > 0:
name_stem = Path(filename).stem
name_ext = Path(filename).suffix
final_display_name = f"{name_stem}({dup_count}){name_ext}"
# 生成存储路径
timestamp = int(time.time())
safe_name = sanitize_filename(Path(filename).stem)
storage_path = f"{user_id}/{timestamp}_{safe_name}.wav"
# 上传 WAV 文件
with open(tmp_wav_path, 'rb') as f:
wav_data = f.read()
await storage_service.upload_file(
bucket=BUCKET_REF_AUDIOS,
path=storage_path,
file_data=wav_data,
content_type="audio/wav"
)
# 上传元数据 JSON
metadata = {
"ref_text": ref_text.strip(),
"original_filename": final_display_name,
"duration_sec": duration,
"created_at": timestamp
}
metadata_path = f"{user_id}/{timestamp}_{safe_name}.json"
await storage_service.upload_file(
bucket=BUCKET_REF_AUDIOS,
path=metadata_path,
file_data=json.dumps(metadata, ensure_ascii=False).encode('utf-8'),
content_type="application/json"
)
# 获取签名 URL
signed_url = await storage_service.get_signed_url(BUCKET_REF_AUDIOS, storage_path)
return RefAudioResponse(
id=storage_path,
name=filename,
path=signed_url,
ref_text=ref_text.strip(),
duration_sec=duration,
created_at=timestamp
).model_dump()
finally:
os.unlink(tmp_input_path)
if os.path.exists(tmp_input_path + ".wav"):
os.unlink(tmp_input_path + ".wav")
async def list_ref_audios(user_id: str) -> dict:
"""列出用户的所有参考音频"""
files = await storage_service.list_files(BUCKET_REF_AUDIOS, user_id)
wav_files = [f for f in files if f.get("name", "").endswith(".wav")]
if not wav_files:
return RefAudioListResponse(items=[]).model_dump()
async def fetch_audio_info(f):
name = f.get("name", "")
storage_path = f"{user_id}/{name}"
metadata_name = name.replace(".wav", ".json")
metadata_path = f"{user_id}/{metadata_name}"
ref_text = ""
duration_sec = 0.0
created_at = 0
original_filename = ""
try:
metadata_url = await storage_service.get_signed_url(BUCKET_REF_AUDIOS, metadata_path)
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.get(metadata_url)
if resp.status_code == 200:
metadata = resp.json()
ref_text = metadata.get("ref_text", "")
duration_sec = metadata.get("duration_sec", 0.0)
created_at = metadata.get("created_at", 0)
original_filename = metadata.get("original_filename", "")
except Exception as e:
logger.debug(f"读取 metadata 失败: {e}")
try:
created_at = int(name.split("_")[0])
except:
pass
signed_url = await storage_service.get_signed_url(BUCKET_REF_AUDIOS, storage_path)
display_name = original_filename if original_filename else name
if not display_name or display_name == name:
match = re.match(r'^\d+_(.+)$', name)
if match:
display_name = match.group(1)
return RefAudioResponse(
id=storage_path,
name=display_name,
path=signed_url,
ref_text=ref_text,
duration_sec=duration_sec,
created_at=created_at
)
items = await asyncio.gather(*[fetch_audio_info(f) for f in wav_files])
items = sorted(items, key=lambda x: x.created_at, reverse=True)
return RefAudioListResponse(items=items).model_dump()
async def delete_ref_audio(audio_id: str, user_id: str) -> None:
"""删除参考音频及其元数据"""
if not audio_id.startswith(f"{user_id}/"):
raise PermissionError("无权删除此文件")
await storage_service.delete_file(BUCKET_REF_AUDIOS, audio_id)
metadata_path = audio_id.replace(".wav", ".json")
try:
await storage_service.delete_file(BUCKET_REF_AUDIOS, metadata_path)
except:
pass
async def rename_ref_audio(audio_id: str, new_name: str, user_id: str) -> dict:
"""重命名参考音频(修改 metadata 中的 display name"""
if not audio_id.startswith(f"{user_id}/"):
raise PermissionError("无权修改此文件")
new_name = new_name.strip()
if not new_name:
raise ValueError("新名称不能为空")
if not Path(new_name).suffix:
new_name += ".wav"
# 下载现有 metadata
metadata_path = audio_id.replace(".wav", ".json")
try:
metadata_url = await storage_service.get_signed_url(BUCKET_REF_AUDIOS, metadata_path)
async with httpx.AsyncClient() as client:
resp = await client.get(metadata_url)
if resp.status_code == 200:
metadata = resp.json()
else:
raise Exception(f"Failed to fetch metadata: {resp.status_code}")
except Exception as e:
logger.warning(f"无法读取元数据: {e}, 将创建新的元数据")
metadata = {
"ref_text": "",
"duration_sec": 0.0,
"created_at": int(time.time()),
"original_filename": new_name
}
# 更新并覆盖上传
metadata["original_filename"] = new_name
await storage_service.upload_file(
bucket=BUCKET_REF_AUDIOS,
path=metadata_path,
file_data=json.dumps(metadata, ensure_ascii=False).encode('utf-8'),
content_type="application/json"
)
return {"name": new_name}

View File

View File

@@ -0,0 +1,32 @@
from fastapi import APIRouter, UploadFile, File, Form, HTTPException
from typing import Optional
import traceback
from loguru import logger
from app.core.response import success_response
from app.modules.tools import service
router = APIRouter()
@router.post("/extract-script")
async def extract_script_tool(
file: Optional[UploadFile] = File(None),
url: Optional[str] = Form(None),
rewrite: bool = Form(True)
):
"""独立文案提取工具"""
try:
result = await service.extract_script(file=file, url=url, rewrite=rewrite)
return success_response(result)
except ValueError as e:
raise HTTPException(400, str(e))
except HTTPException:
raise
except Exception as e:
logger.error(f"Tool extract failed: {e}")
logger.error(traceback.format_exc())
msg = str(e)
if "Fresh cookies" in msg:
msg = "下载失败:目标平台开启了反爬验证,请过段时间重试或直接上传视频文件。"
raise HTTPException(500, f"提取失败: {msg}")

View File

@@ -0,0 +1,7 @@
from pydantic import BaseModel
from typing import Optional
class ExtractScriptResponse(BaseModel):
original_script: Optional[str] = None
rewritten_script: Optional[str] = None

View File

@@ -0,0 +1,355 @@
import asyncio
import os
import re
import json
import time
import shutil
import subprocess
import traceback
from pathlib import Path
from typing import Optional, Any
from urllib.parse import unquote
import httpx
from loguru import logger
from app.services.whisper_service import whisper_service
from app.services.glm_service import glm_service
async def extract_script(file=None, url: Optional[str] = None, rewrite: bool = True) -> dict:
"""
文案提取:上传文件或视频链接 -> Whisper 转写 -> (可选) GLM 洗稿
"""
if not file and not url:
raise ValueError("必须提供文件或视频链接")
temp_path = None
try:
timestamp = int(time.time())
temp_dir = Path("/tmp")
if os.name == 'nt':
temp_dir = Path("d:/tmp")
temp_dir.mkdir(parents=True, exist_ok=True)
loop = asyncio.get_event_loop()
# 1. 获取/保存文件
if file:
filename = file.filename
if not filename:
raise ValueError("文件名无效")
safe_filename = Path(filename).name.replace(" ", "_")
temp_path = temp_dir / f"tool_extract_{timestamp}_{safe_filename}"
await loop.run_in_executor(None, lambda: shutil.copyfileobj(file.file, open(temp_path, "wb")))
logger.info(f"Tool processing upload file: {temp_path}")
else:
temp_path = await _download_video(url, temp_dir, timestamp)
if not temp_path or not temp_path.exists():
raise ValueError("文件获取失败")
# 1.5 安全转换: 强制转为 WAV (16k)
audio_path = temp_dir / f"extract_audio_{timestamp}.wav"
try:
await loop.run_in_executor(None, lambda: _convert_to_wav(temp_path, audio_path))
logger.info(f"Converted to WAV: {audio_path}")
except ValueError as ve:
if str(ve) == "HTML_DETECTED":
raise ValueError("下载的文件是网页而非视频,请重试或手动上传。")
else:
raise ValueError("下载的文件已损坏或格式无法识别。")
# 2. 提取文案 (Whisper)
script = await whisper_service.transcribe(str(audio_path))
# 3. AI 洗稿 (GLM)
rewritten = None
if rewrite and script and len(script.strip()) > 0:
logger.info("Rewriting script...")
rewritten = await glm_service.rewrite_script(script)
return {
"original_script": script,
"rewritten_script": rewritten
}
finally:
if temp_path and temp_path.exists():
try:
os.remove(temp_path)
logger.info(f"Cleaned up temp file: {temp_path}")
except Exception as e:
logger.warning(f"Failed to cleanup temp file {temp_path}: {e}")
def _convert_to_wav(input_path: Path, output_path: Path) -> None:
"""FFmpeg 转换为 16k WAV"""
try:
convert_cmd = [
'ffmpeg',
'-i', str(input_path),
'-vn',
'-acodec', 'pcm_s16le',
'-ar', '16000',
'-ac', '1',
'-y',
str(output_path)
]
subprocess.run(convert_cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
except subprocess.CalledProcessError as e:
error_log = e.stderr.decode('utf-8', errors='ignore') if e.stderr else str(e)
logger.error(f"FFmpeg check/convert failed: {error_log}")
head = b""
try:
with open(input_path, 'rb') as f:
head = f.read(100)
except:
pass
if b'<!DOCTYPE html' in head or b'<html' in head:
raise ValueError("HTML_DETECTED")
raise ValueError("CONVERT_FAILED")
async def _download_video(url: str, temp_dir: Path, timestamp: int) -> Path:
"""下载视频yt-dlp 优先,失败回退手动解析)"""
url_value = url
url_match = re.search(r'https?://[^\s]+', url_value)
if url_match:
extracted_url = url_match.group(0)
logger.info(f"Extracted URL from text: {extracted_url}")
url_value = extracted_url
logger.info(f"Tool downloading URL: {url_value}")
loop = asyncio.get_event_loop()
# 先尝试 yt-dlp
try:
temp_path = await loop.run_in_executor(None, lambda: _download_yt_dlp(url_value, temp_dir, timestamp))
logger.info(f"yt-dlp downloaded to: {temp_path}")
return temp_path
except Exception as e:
logger.warning(f"yt-dlp download failed: {e}. Trying manual fallback...")
if "douyin" in url_value:
manual_path = await _download_douyin_manual(url_value, temp_dir, timestamp)
if manual_path:
return manual_path
raise ValueError(f"视频下载失败。yt-dlp 报错: {str(e)}")
elif "bilibili" in url_value:
manual_path = await _download_bilibili_manual(url_value, temp_dir, timestamp)
if manual_path:
return manual_path
raise ValueError(f"视频下载失败。yt-dlp 报错: {str(e)}")
else:
raise ValueError(f"视频下载失败: {str(e)}")
def _download_yt_dlp(url_value: str, temp_dir: Path, timestamp: int) -> Path:
"""yt-dlp 下载(阻塞调用,应在线程池中运行)"""
import yt_dlp
logger.info("Attempting download with yt-dlp...")
ydl_opts = {
'format': 'bestaudio/best',
'outtmpl': str(temp_dir / f"tool_download_{timestamp}_%(id)s.%(ext)s"),
'quiet': True,
'no_warnings': True,
'http_headers': {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
'Referer': 'https://www.douyin.com/',
}
}
with yt_dlp.YoutubeDL() as ydl_raw:
ydl: Any = ydl_raw
ydl.params.update(ydl_opts)
info = ydl.extract_info(url_value, download=True)
if 'requested_downloads' in info:
downloaded_file = info['requested_downloads'][0]['filepath']
else:
ext = info.get('ext', 'mp4')
id = info.get('id')
downloaded_file = str(temp_dir / f"tool_download_{timestamp}_{id}.{ext}")
return Path(downloaded_file)
async def _download_douyin_manual(url: str, temp_dir: Path, timestamp: int) -> Optional[Path]:
"""手动下载抖音视频 (Fallback)"""
logger.info(f"[SuperIPAgent] Starting download for: {url}")
try:
headers = {
"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"
}
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
match = re.search(r'/video/(\d+)', final_url)
if match:
modal_id = match.group(1)
if not modal_id:
logger.error("[SuperIPAgent] Could not extract modal_id")
return None
logger.info(f"[SuperIPAgent] Extracted modal_id: {modal_id}")
target_url = f"https://www.douyin.com/user/MS4wLjABAAAAN_s_hups7LD0N4qnrM3o2gI0vuG3pozNaEolz2_py3cHTTrpVr1Z4dukFD9SOlwY?from_tab_name=main&modal_id={modal_id}"
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": 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...")
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(target_url, headers=headers_with_cookie)
content_match = re.findall(r'<script id="RENDER_DATA" type="application/json">(.*?)</script>', response.text)
if not content_match:
if "SSR_HYDRATED_DATA" in response.text:
content_match = re.findall(r'<script id="SSR_HYDRATED_DATA" type="application/json">(.*?)</script>', response.text)
if not content_match:
logger.error(f"[SuperIPAgent] Could not find RENDER_DATA in page (len={len(response.text)})")
return None
content = unquote(content_match[0])
try:
data = json.loads(content)
except:
logger.error("[SuperIPAgent] JSON decode failed")
return None
video_url = None
try:
if "app" in data and "videoDetail" in data["app"]:
info = data["app"]["videoDetail"]["video"]
if "bitRateList" in info and info["bitRateList"]:
video_url = info["bitRateList"][0]["playAddr"][0]["src"]
elif "playAddr" in info and info["playAddr"]:
video_url = info["playAddr"][0]["src"]
except Exception as e:
logger.error(f"[SuperIPAgent] Path extraction failed: {e}")
if not video_url:
logger.error("[SuperIPAgent] No video_url found")
return None
if video_url.startswith("//"):
video_url = "https:" + video_url
logger.info(f"[SuperIPAgent] Found video URL: {video_url[:50]}...")
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',
}
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}")
return None
async def _download_bilibili_manual(url: str, temp_dir: Path, timestamp: int) -> Optional[Path]:
"""手动下载 Bilibili 视频 (Playwright Fallback)"""
from playwright.async_api import async_playwright
logger.info(f"[Playwright] Starting Bilibili download for: {url}")
playwright = None
browser = None
try:
playwright = await async_playwright().start()
browser = await playwright.chromium.launch(headless=True, args=['--no-sandbox', '--disable-setuid-sandbox'])
context = await browser.new_context(
user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)
page = await context.new_page()
logger.info("[Playwright] Navigating to Bilibili...")
await page.goto(url, timeout=45000)
try:
await page.wait_for_selector('video', timeout=15000)
except:
logger.warning("[Playwright] Video selector timeout")
playinfo = await page.evaluate("window.__playinfo__")
audio_url = None
if playinfo and "data" in playinfo and "dash" in playinfo["data"]:
dash = playinfo["data"]["dash"]
if "audio" in dash and dash["audio"]:
audio_url = dash["audio"][0]["baseUrl"]
logger.info(f"[Playwright] Found audio stream in __playinfo__: {audio_url[:50]}...")
if not audio_url:
logger.warning("[Playwright] Could not find audio in __playinfo__")
return None
temp_path = temp_dir / f"bilibili_audio_{timestamp}.m4s"
try:
api_request = context.request
headers = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Referer": "https://www.bilibili.com/"
}
logger.info(f"[Playwright] Downloading audio stream...")
response = await api_request.get(audio_url, headers=headers)
if response.status == 200:
body = await response.body()
with open(temp_path, 'wb') as f:
f.write(body)
logger.info(f"[Playwright] Downloaded successfully: {temp_path}")
return temp_path
else:
logger.error(f"[Playwright] API Request failed: {response.status}")
return None
except Exception as e:
logger.error(f"[Playwright] Download logic error: {e}")
return None
except Exception as e:
logger.error(f"[Playwright] Bilibili download failed: {e}")
return None
finally:
if browser:
await browser.close()
if playwright:
await playwright.stop()

View File

View File

@@ -0,0 +1,64 @@
from fastapi import APIRouter, BackgroundTasks, Depends
import uuid
from app.core.deps import get_current_user
from app.core.response import success_response
from .schemas import GenerateRequest
from .task_store import create_task, get_task, list_tasks
from .workflow import process_video_generation, get_lipsync_health, get_voiceclone_health
from .service import list_generated_videos, delete_generated_video
router = APIRouter()
@router.post("/generate")
async def generate_video(
req: GenerateRequest,
background_tasks: BackgroundTasks,
current_user: dict = Depends(get_current_user)
):
user_id = current_user["id"]
task_id = str(uuid.uuid4())
create_task(task_id, user_id)
background_tasks.add_task(process_video_generation, task_id, req, user_id)
return success_response({"task_id": task_id})
@router.get("/tasks/{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(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")
async def lipsync_health():
return success_response(await get_lipsync_health())
@router.get("/voiceclone/health")
async def voiceclone_health():
return success_response(await get_voiceclone_health())
@router.get("/generated")
async def list_generated(current_user: dict = Depends(get_current_user)):
return success_response(await list_generated_videos(current_user["id"]))
@router.delete("/generated/{video_id}")
async def delete_generated(video_id: str, current_user: dict = Depends(get_current_user)):
result = await delete_generated_video(current_user["id"], video_id)
return success_response(result, message="视频已删除")

View File

@@ -0,0 +1,23 @@
from pydantic import BaseModel
from typing import Optional, List
class GenerateRequest(BaseModel):
text: str
voice: str = "zh-CN-YunxiNeural"
material_path: str
material_paths: Optional[List[str]] = None
tts_mode: str = "edgetts"
ref_audio_id: Optional[str] = None
ref_text: Optional[str] = None
language: str = "zh-CN"
title: Optional[str] = None
enable_subtitles: bool = True
subtitle_style_id: Optional[str] = None
title_style_id: Optional[str] = None
subtitle_font_size: Optional[int] = None
title_font_size: Optional[int] = None
title_top_margin: Optional[int] = None
subtitle_bottom_margin: Optional[int] = None
bgm_id: Optional[str] = None
bgm_volume: Optional[float] = 0.2

View File

@@ -0,0 +1,87 @@
from fastapi import HTTPException
import asyncio
from pathlib import Path
from loguru import logger
from app.services.storage import storage_service
async def list_generated_videos(user_id: str) -> dict:
"""从 Storage 读取当前用户生成的视频列表"""
try:
files_obj = await storage_service.list_files(
bucket=storage_service.BUCKET_OUTPUTS,
path=user_id
)
semaphore = asyncio.Semaphore(8)
async def build_item(f):
name = f.get("name")
if not name or name == ".emptyFolderPlaceholder":
return None
if not name.endswith("_output.mp4"):
return None
video_id = Path(name).stem
full_path = f"{user_id}/{name}"
async with semaphore:
signed_url = await storage_service.get_signed_url(
bucket=storage_service.BUCKET_OUTPUTS,
path=full_path
)
metadata = f.get("metadata", {})
size = metadata.get("size", 0)
created_at_str = f.get("created_at", "")
created_at = 0
if created_at_str:
from datetime import datetime
try:
dt = datetime.fromisoformat(created_at_str.replace("Z", "+00:00"))
created_at = int(dt.timestamp())
except Exception:
pass
return {
"id": video_id,
"name": name,
"path": signed_url,
"size_mb": size / (1024 * 1024),
"created_at": created_at
}
tasks = [build_item(f) for f in files_obj]
results = await asyncio.gather(*tasks, return_exceptions=True)
videos = []
for item in results:
if not item:
continue
if isinstance(item, Exception):
logger.warning(f"Signed url build failed: {item}")
continue
videos.append(item)
videos.sort(key=lambda x: x.get("created_at", ""), reverse=True)
return {"videos": videos}
except Exception as e:
logger.error(f"List generated videos failed: {e}")
return {"videos": []}
async def delete_generated_video(user_id: str, video_id: str) -> dict:
"""删除生成的视频"""
try:
storage_path = f"{user_id}/{video_id}.mp4"
await storage_service.delete_file(
bucket=storage_service.BUCKET_OUTPUTS,
path=storage_path
)
return {"video_id": video_id}
except Exception as e:
raise HTTPException(500, f"删除失败: {str(e)}")

View File

@@ -0,0 +1,118 @@
from typing import Any, Dict, List
import json
from loguru import logger
from app.core.config import settings
try:
import redis
except Exception: # pragma: no cover - optional dependency
redis = None
class InMemoryTaskStore:
def __init__(self) -> None:
self._tasks: Dict[str, Dict[str, Any]] = {}
def create(self, task_id: str, user_id: str) -> Dict[str, Any]:
task = {
"status": "pending",
"task_id": task_id,
"progress": 0,
"user_id": user_id,
}
self._tasks[task_id] = task
return task
def get(self, task_id: str) -> Dict[str, Any]:
return self._tasks.get(task_id, {"status": "not_found"})
def list(self) -> List[Dict[str, Any]]:
return list(self._tasks.values())
def update(self, task_id: str, updates: Dict[str, Any]) -> Dict[str, Any]:
task = self._tasks.get(task_id)
if not task:
task = {"status": "pending", "task_id": task_id}
self._tasks[task_id] = task
task.update(updates)
return task
class RedisTaskStore:
def __init__(self, client: "redis.Redis") -> None:
self._client = client
self._index_key = "vigent:tasks:index"
def _key(self, task_id: str) -> str:
return f"vigent:tasks:{task_id}"
def create(self, task_id: str, user_id: str) -> Dict[str, Any]:
task = {
"status": "pending",
"task_id": task_id,
"progress": 0,
"user_id": user_id,
}
self._client.set(self._key(task_id), json.dumps(task, ensure_ascii=False))
self._client.sadd(self._index_key, task_id)
return task
def get(self, task_id: str) -> Dict[str, Any]:
raw = self._client.get(self._key(task_id))
if not raw:
return {"status": "not_found"}
return json.loads(raw)
def list(self) -> List[Dict[str, Any]]:
task_ids = list(self._client.smembers(self._index_key) or [])
if not task_ids:
return []
keys = [self._key(task_id) for task_id in task_ids]
raw_items = self._client.mget(keys)
tasks = []
for raw in raw_items:
if raw:
try:
tasks.append(json.loads(raw))
except Exception:
continue
return tasks
def update(self, task_id: str, updates: Dict[str, Any]) -> Dict[str, Any]:
task = self.get(task_id)
if task.get("status") == "not_found":
task = {"status": "pending", "task_id": task_id}
task.update(updates)
self._client.set(self._key(task_id), json.dumps(task, ensure_ascii=False))
self._client.sadd(self._index_key, task_id)
return task
def _build_task_store():
if redis is None:
logger.warning("Redis not available, using in-memory task store")
return InMemoryTaskStore()
try:
client = redis.Redis.from_url(settings.REDIS_URL, decode_responses=True)
client.ping()
logger.info("Using Redis task store")
return RedisTaskStore(client)
except Exception as e:
logger.warning(f"Redis connection failed, using in-memory task store: {e}")
return InMemoryTaskStore()
task_store = _build_task_store()
def create_task(task_id: str, user_id: str) -> Dict[str, Any]:
return task_store.create(task_id, user_id)
def get_task(task_id: str) -> Dict[str, Any]:
return task_store.get(task_id)
def list_tasks() -> List[Dict[str, Any]]:
return task_store.list()

View File

@@ -0,0 +1,566 @@
from typing import Optional, Any, List
from pathlib import Path
import time
import traceback
import httpx
from loguru import logger
from app.core.config import settings
from app.services.tts_service import TTSService
from app.services.video_service import VideoService
from app.services.lipsync_service import LipSyncService
from app.services.voice_clone_service import voice_clone_service
from app.services.assets_service import (
get_style,
get_default_style,
resolve_bgm_path,
prepare_style_for_remotion,
)
from app.services.storage import storage_service
from app.services.whisper_service import whisper_service
from app.services.remotion_service import remotion_service
from .schemas import GenerateRequest
from .task_store import task_store
def _locale_to_whisper_lang(locale: str) -> str:
"""'en-US''en', 'zh-CN''zh'"""
return locale.split("-")[0] if "-" in locale else locale
def _locale_to_qwen_lang(locale: str) -> str:
"""'zh-CN''Chinese', 'en-US''English', 其他 → 'Auto'"""
mapping = {"zh": "Chinese", "en": "English"}
return mapping.get(locale.split("-")[0], "Auto")
_lipsync_service: Optional[LipSyncService] = None
_lipsync_ready: Optional[bool] = None
_lipsync_last_check: float = 0
def _get_lipsync_service() -> LipSyncService:
"""获取或创建 LipSync 服务实例(单例模式,避免重复初始化)"""
global _lipsync_service
if _lipsync_service is None:
_lipsync_service = LipSyncService()
return _lipsync_service
async def _check_lipsync_ready(force: bool = False) -> bool:
"""检查 LipSync 是否就绪带缓存5分钟内不重复检查"""
global _lipsync_ready, _lipsync_last_check
now = time.time()
if not force and _lipsync_ready is not None and (now - _lipsync_last_check) < 300:
return bool(_lipsync_ready)
lipsync = _get_lipsync_service()
health = await lipsync.check_health()
_lipsync_ready = health.get("ready", False)
_lipsync_last_check = now
print(f"[LipSync] Health check: ready={_lipsync_ready}")
return bool(_lipsync_ready)
async def _download_material(path_or_url: str, temp_path: Path):
"""下载素材到临时文件 (流式下载,节省内存)"""
if path_or_url.startswith("http"):
timeout = httpx.Timeout(None)
async with httpx.AsyncClient(timeout=timeout) as client:
async with client.stream("GET", path_or_url) as resp:
resp.raise_for_status()
with open(temp_path, "wb") as f:
async for chunk in resp.aiter_bytes():
f.write(chunk)
else:
src = Path(path_or_url)
if not src.is_absolute():
src = settings.BASE_DIR.parent / path_or_url
if src.exists():
import shutil
shutil.copy(src, temp_path)
else:
raise FileNotFoundError(f"Material not found: {path_or_url}")
def _update_task(task_id: str, **updates: Any) -> None:
task_store.update(task_id, updates)
# ── 多素材辅助函数 ──
def _split_equal(segments: List[dict], material_paths: List[str]) -> List[dict]:
"""按素材数量均分音频时长,对齐到最近的 Whisper 字边界。
Args:
segments: Whisper 产出的 segment 列表, 每个包含 words (字级时间戳)
material_paths: 素材路径列表
Returns:
[{"material_path": "...", "start": 0.0, "end": 5.2, "index": 0}, ...]
"""
# 展平所有 Whisper 字符
all_chars: List[dict] = []
for seg in segments:
for w in seg.get("words", []):
all_chars.append(w)
n = len(material_paths)
if not all_chars or n == 0:
return [{"material_path": material_paths[0] if material_paths else "",
"start": 0.0, "end": 99999.0, "index": 0}]
# 素材数不能超过字符数,否则边界会重复
if n > len(all_chars):
logger.warning(f"[MultiMat] 素材数({n}) > 字符数({len(all_chars)}),裁剪为 {len(all_chars)}")
n = len(all_chars)
total_start = all_chars[0]["start"]
total_end = all_chars[-1]["end"]
seg_dur = (total_end - total_start) / n
# 计算 N-1 个分割点,对齐到最近的字边界
boundaries = [0] # 第一段从第 0 个字开始
for i in range(1, n):
target_time = total_start + i * seg_dur
# 找到 start 时间最接近 target_time 的字
best_idx = boundaries[-1] + 1 # 至少比上一个边界后移 1
best_diff = float("inf")
for j in range(boundaries[-1] + 1, len(all_chars)):
diff = abs(all_chars[j]["start"] - target_time)
if diff < best_diff:
best_diff = diff
best_idx = j
elif diff > best_diff:
break # 时间递增,差值开始变大后可以停了
boundaries.append(min(best_idx, len(all_chars) - 1))
boundaries.append(len(all_chars)) # 最后一段到末尾
# 按边界生成分配结果
assignments: List[dict] = []
for i in range(n):
s_idx = boundaries[i]
e_idx = boundaries[i + 1]
if s_idx >= len(all_chars) or s_idx >= e_idx:
continue
assignments.append({
"material_path": material_paths[i],
"start": all_chars[s_idx]["start"],
"end": all_chars[e_idx - 1]["end"],
"text": "".join(c["word"] for c in all_chars[s_idx:e_idx]),
"index": len(assignments),
})
if not assignments:
return [{"material_path": material_paths[0], "start": 0.0, "end": 99999.0, "index": 0}]
logger.info(f"[MultiMat] 均分 {len(all_chars)} 字为 {len(assignments)}")
for a in assignments:
dur = a["end"] - a["start"]
logger.info(f"{a['index']}: [{a['start']:.2f}-{a['end']:.2f}s] ({dur:.1f}s) {a['text'][:20]}")
return assignments
async def process_video_generation(task_id: str, req: GenerateRequest, user_id: str):
temp_files = []
try:
start_time = time.time()
# ── 确定素材列表 ──
material_paths: List[str] = []
if req.material_paths and len(req.material_paths) > 1:
material_paths = req.material_paths
else:
material_paths = [req.material_path]
is_multi = len(material_paths) > 1
_update_task(task_id, status="processing", progress=5, message="正在下载素材...")
temp_dir = settings.UPLOAD_DIR / "temp"
temp_dir.mkdir(parents=True, exist_ok=True)
# 单素材模式:下载主素材
if not is_multi:
input_material_path = temp_dir / f"{task_id}_input.mp4"
temp_files.append(input_material_path)
await _download_material(material_paths[0], input_material_path)
_update_task(task_id, message="正在生成语音...", progress=10)
audio_path = temp_dir / f"{task_id}_audio.wav"
temp_files.append(audio_path)
if req.tts_mode == "voiceclone":
if not req.ref_audio_id or not req.ref_text:
raise ValueError("声音克隆模式需要提供参考音频和参考文字")
_update_task(task_id, message="正在下载参考音频...")
ref_audio_local = temp_dir / f"{task_id}_ref.wav"
temp_files.append(ref_audio_local)
ref_audio_url = await storage_service.get_signed_url(
bucket="ref-audios",
path=req.ref_audio_id
)
await _download_material(ref_audio_url, ref_audio_local)
_update_task(task_id, message="正在克隆声音 (Qwen3-TTS)...")
await voice_clone_service.generate_audio(
text=req.text,
ref_audio_path=str(ref_audio_local),
ref_text=req.ref_text,
output_path=str(audio_path),
language=_locale_to_qwen_lang(req.language)
)
else:
_update_task(task_id, message="正在生成语音 (EdgeTTS)...")
tts = TTSService()
await tts.generate_audio(req.text, req.voice, str(audio_path))
tts_time = time.time() - start_time
print(f"[Pipeline] TTS completed in {tts_time:.1f}s")
lipsync = _get_lipsync_service()
lipsync_video_path = temp_dir / f"{task_id}_lipsync.mp4"
temp_files.append(lipsync_video_path)
video = VideoService()
captions_path = None
if is_multi:
# ══════════════════════════════════════
# 多素材流水线
# ══════════════════════════════════════
_update_task(task_id, progress=12, message="正在生成字幕 (Whisper)...")
captions_path = temp_dir / f"{task_id}_captions.json"
temp_files.append(captions_path)
try:
captions_data = await whisper_service.align(
audio_path=str(audio_path),
text=req.text,
output_path=str(captions_path),
language=_locale_to_whisper_lang(req.language),
)
print(f"[Pipeline] Whisper alignment completed (multi-material)")
except Exception as e:
logger.warning(f"Whisper alignment failed: {e}")
captions_data = None
captions_path = None
_update_task(task_id, progress=15, message="正在分配素材...")
if captions_data and captions_data.get("segments"):
assignments = _split_equal(captions_data["segments"], material_paths)
else:
# Whisper 失败 → 按时长均分(不依赖字符对齐)
logger.warning("[MultiMat] Whisper 无数据,按时长均分")
audio_dur = video._get_duration(str(audio_path))
if audio_dur <= 0:
audio_dur = 30.0 # 安全兜底
seg_dur = audio_dur / len(material_paths)
assignments = [
{"material_path": material_paths[i], "start": i * seg_dur,
"end": (i + 1) * seg_dur, "index": i}
for i in range(len(material_paths))
]
# 扩展段覆盖完整音频范围首段从0开始末段到音频结尾
audio_duration = video._get_duration(str(audio_path))
if assignments and audio_duration > 0:
assignments[0]["start"] = 0.0
assignments[-1]["end"] = audio_duration
num_segments = len(assignments)
print(f"[Pipeline] Multi-material: {num_segments} segments, {len(material_paths)} materials")
if num_segments == 0:
raise RuntimeError("Multi-material: no valid segments after splitting")
lipsync_start = time.time()
# ── 第一步:下载所有素材并检测分辨率 ──
material_locals: List[Path] = []
resolutions = []
for i, assignment in enumerate(assignments):
material_local = temp_dir / f"{task_id}_material_{i}.mp4"
temp_files.append(material_local)
await _download_material(assignment["material_path"], material_local)
material_locals.append(material_local)
resolutions.append(video.get_resolution(str(material_local)))
# 分辨率不一致时,统一到第一个素材的分辨率
base_res = resolutions[0] if resolutions else (0, 0)
need_scale = any(r != base_res for r in resolutions) and base_res[0] > 0
if need_scale:
logger.info(f"[MultiMat] 素材分辨率不一致,统一到 {base_res[0]}x{base_res[1]}")
# ── 第二步:裁剪每段素材到对应时长 ──
prepared_segments: List[Path] = []
for i, assignment in enumerate(assignments):
seg_progress = 15 + int((i / num_segments) * 30) # 15% → 45%
seg_dur = assignment["end"] - assignment["start"]
_update_task(
task_id,
progress=seg_progress,
message=f"正在准备素材 {i+1}/{num_segments}..."
)
prepared_path = temp_dir / f"{task_id}_prepared_{i}.mp4"
temp_files.append(prepared_path)
video.prepare_segment(
str(material_locals[i]), seg_dur, str(prepared_path),
target_resolution=base_res if need_scale else None
)
prepared_segments.append(prepared_path)
# ── 第二步:拼接所有素材片段 ──
_update_task(task_id, progress=50, message="正在拼接素材片段...")
concat_path = temp_dir / f"{task_id}_concat.mp4"
temp_files.append(concat_path)
video.concat_videos(
[str(p) for p in prepared_segments],
str(concat_path)
)
# ── 第三步:一次 LatentSync 推理 ──
is_ready = await _check_lipsync_ready()
if is_ready:
_update_task(task_id, progress=55, message="正在合成唇形 (LatentSync)...")
print(f"[LipSync] Multi-material: single LatentSync on concatenated video")
try:
await lipsync.generate(str(concat_path), str(audio_path), str(lipsync_video_path))
except Exception as e:
logger.warning(f"[LipSync] Failed, fallback to concat without lipsync: {e}")
import shutil
shutil.copy(str(concat_path), str(lipsync_video_path))
else:
print(f"[LipSync] Not ready, using concatenated video without lipsync")
import shutil
shutil.copy(str(concat_path), str(lipsync_video_path))
lipsync_time = time.time() - lipsync_start
print(f"[Pipeline] Multi-material prepare + concat + LipSync completed in {lipsync_time:.1f}s")
_update_task(task_id, progress=80)
# 如果用户关闭了字幕,清除 captions_pathWhisper 仅用于句子切分)
if not req.enable_subtitles:
captions_path = None
else:
# ══════════════════════════════════════
# 单素材流水线(原有逻辑)
# ══════════════════════════════════════
_update_task(task_id, progress=25)
_update_task(task_id, message="正在合成唇形 (LatentSync)...", progress=30)
lipsync_start = time.time()
is_ready = await _check_lipsync_ready()
if is_ready:
print(f"[LipSync] Starting LatentSync inference...")
_update_task(task_id, progress=35, message="正在运行 LatentSync 推理...")
await lipsync.generate(str(input_material_path), str(audio_path), str(lipsync_video_path))
else:
print(f"[LipSync] LatentSync not ready, copying original video")
_update_task(task_id, message="唇形同步不可用,使用原始视频...")
import shutil
shutil.copy(str(input_material_path), lipsync_video_path)
lipsync_time = time.time() - lipsync_start
print(f"[Pipeline] LipSync completed in {lipsync_time:.1f}s")
_update_task(task_id, progress=80)
# 单素材模式Whisper 在 LatentSync 之后
if req.enable_subtitles:
_update_task(task_id, message="正在生成字幕 (Whisper)...", progress=82)
captions_path = temp_dir / f"{task_id}_captions.json"
temp_files.append(captions_path)
try:
await whisper_service.align(
audio_path=str(audio_path),
text=req.text,
output_path=str(captions_path),
language=_locale_to_whisper_lang(req.language),
)
print(f"[Pipeline] Whisper alignment completed")
except Exception as e:
logger.warning(f"Whisper alignment failed, skipping subtitles: {e}")
captions_path = None
_update_task(task_id, progress=85)
final_audio_path = audio_path
if req.bgm_id:
_update_task(task_id, message="正在合成背景音乐...", progress=86)
bgm_path = resolve_bgm_path(req.bgm_id)
if bgm_path:
mix_output_path = temp_dir / f"{task_id}_audio_mix.wav"
temp_files.append(mix_output_path)
volume = req.bgm_volume if req.bgm_volume is not None else 0.2
volume = max(0.0, min(float(volume), 1.0))
try:
video.mix_audio(
voice_path=str(audio_path),
bgm_path=str(bgm_path),
output_path=str(mix_output_path),
bgm_volume=volume
)
final_audio_path = mix_output_path
except Exception as e:
logger.warning(f"BGM mix failed, fallback to voice only: {e}")
else:
logger.warning(f"BGM not found: {req.bgm_id}")
use_remotion = (captions_path and captions_path.exists()) or req.title
subtitle_style = None
title_style = None
if req.enable_subtitles:
subtitle_style = get_style("subtitle", req.subtitle_style_id) or get_default_style("subtitle")
if req.title:
title_style = get_style("title", req.title_style_id) or get_default_style("title")
if req.subtitle_font_size and req.enable_subtitles:
if subtitle_style is None:
subtitle_style = {}
subtitle_style["font_size"] = int(req.subtitle_font_size)
if req.title_font_size and req.title:
if title_style is None:
title_style = {}
title_style["font_size"] = int(req.title_font_size)
if req.title_top_margin is not None and req.title:
if title_style is None:
title_style = {}
title_style["top_margin"] = int(req.title_top_margin)
if req.subtitle_bottom_margin is not None and req.enable_subtitles:
if subtitle_style is None:
subtitle_style = {}
subtitle_style["bottom_margin"] = int(req.subtitle_bottom_margin)
if use_remotion:
subtitle_style = prepare_style_for_remotion(
subtitle_style,
temp_dir,
f"{task_id}_subtitle_font"
)
title_style = prepare_style_for_remotion(
title_style,
temp_dir,
f"{task_id}_title_font"
)
final_output_local_path = temp_dir / f"{task_id}_output.mp4"
temp_files.append(final_output_local_path)
if use_remotion:
_update_task(task_id, message="正在合成视频 (Remotion)...", progress=87)
composed_video_path = temp_dir / f"{task_id}_composed.mp4"
temp_files.append(composed_video_path)
await video.compose(str(lipsync_video_path), str(final_audio_path), str(composed_video_path))
remotion_health = await remotion_service.check_health()
if remotion_health.get("ready"):
try:
def on_remotion_progress(percent):
mapped = 87 + int(percent * 0.08)
_update_task(task_id, progress=mapped)
await remotion_service.render(
video_path=str(composed_video_path),
output_path=str(final_output_local_path),
captions_path=str(captions_path) if captions_path else None,
title=req.title,
title_duration=3.0,
fps=25,
enable_subtitles=req.enable_subtitles,
subtitle_style=subtitle_style,
title_style=title_style,
on_progress=on_remotion_progress
)
print(f"[Pipeline] Remotion render completed")
except Exception as e:
logger.warning(f"Remotion render failed, using FFmpeg fallback: {e}")
import shutil
shutil.copy(str(composed_video_path), final_output_local_path)
else:
logger.warning(f"Remotion not ready: {remotion_health.get('error')}, using FFmpeg")
import shutil
shutil.copy(str(composed_video_path), final_output_local_path)
else:
_update_task(task_id, message="正在合成最终视频...", progress=90)
await video.compose(str(lipsync_video_path), str(final_audio_path), str(final_output_local_path))
total_time = time.time() - start_time
_update_task(task_id, message="正在上传结果...", progress=95)
storage_path = f"{user_id}/{task_id}_output.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,
path=storage_path
)
print(f"[Pipeline] Total generation time: {total_time:.1f}s")
_update_task(
task_id,
status="completed",
progress=100,
message=f"生成完成!耗时 {total_time:.0f}",
output=storage_path,
download_url=signed_url,
)
except Exception as e:
_update_task(
task_id,
status="failed",
message=f"错误: {str(e)}",
error=traceback.format_exc(),
)
logger.error(f"Generate video failed: {e}")
finally:
for f in temp_files:
try:
if f.exists():
f.unlink()
except Exception as e:
print(f"Error cleaning up {f}: {e}")
async def get_lipsync_health():
lipsync = _get_lipsync_service()
return await lipsync.check_health()
async def get_voiceclone_health():
return await voice_clone_service.check_health()

View File

View File

@@ -0,0 +1,31 @@
from typing import Any, Dict, List, Optional, cast
from app.core.supabase import get_supabase
def get_session(user_id: str, session_token: str) -> Optional[Dict[str, Any]]:
supabase = get_supabase()
result = (
supabase.table("user_sessions")
.select("*")
.eq("user_id", user_id)
.eq("session_token", session_token)
.execute()
)
data = cast(List[Dict[str, Any]], result.data or [])
return data[0] if data else None
def delete_sessions(user_id: str) -> None:
supabase = get_supabase()
supabase.table("user_sessions").delete().eq("user_id", user_id).execute()
def create_session(user_id: str, session_token: str, device_info: Optional[str] = None) -> List[Dict[str, Any]]:
supabase = get_supabase()
result = supabase.table("user_sessions").insert({
"user_id": user_id,
"session_token": session_token,
"device_info": device_info,
}).execute()
return cast(List[Dict[str, Any]], result.data or [])

View File

@@ -0,0 +1,39 @@
from typing import Any, Dict, List, Optional, cast
from app.core.supabase import get_supabase
def get_user_by_phone(phone: str) -> Optional[Dict[str, Any]]:
supabase = get_supabase()
result = supabase.table("users").select("*").eq("phone", phone).single().execute()
return cast(Optional[Dict[str, Any]], result.data or None)
def get_user_by_id(user_id: str) -> Optional[Dict[str, Any]]:
supabase = get_supabase()
result = supabase.table("users").select("*").eq("id", user_id).single().execute()
return cast(Optional[Dict[str, Any]], result.data or None)
def user_exists_by_phone(phone: str) -> bool:
supabase = get_supabase()
result = supabase.table("users").select("id").eq("phone", phone).execute()
return bool(result.data)
def create_user(payload: Dict[str, Any]) -> List[Dict[str, Any]]:
supabase = get_supabase()
result = supabase.table("users").insert(payload).execute()
return cast(List[Dict[str, Any]], result.data or [])
def list_users() -> List[Dict[str, Any]]:
supabase = get_supabase()
result = supabase.table("users").select("*").order("created_at", desc=True).execute()
return cast(List[Dict[str, Any]], result.data or [])
def update_user(user_id: str, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
supabase = get_supabase()
result = supabase.table("users").update(payload).eq("id", user_id).execute()
return cast(List[Dict[str, Any]], result.data or [])

View File

@@ -43,6 +43,7 @@ class GLMService:
要求:
1. 标题要简洁有力能吸引观众点击不超过10个字
2. 标签要与内容相关便于搜索和推荐只要3个
3. 标题和标签必须使用与口播文案相同的语言(如文案是英文就用英文,日文就用日文)
请严格按以下JSON格式返回不要包含其他内容
{{"title": "标题", "tags": ["标签1", "标签2", "标签3"]}}"""
@@ -51,7 +52,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 +100,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"},
@@ -114,6 +121,49 @@ class GLMService:
async def translate_text(self, text: str, target_lang: str) -> str:
"""
将文案翻译为指定语言
Args:
text: 原始文案
target_lang: 目标语言(如 English, 日本語 等)
Returns:
翻译后的文案
"""
prompt = f"""请将以下文案翻译为{target_lang}
原文:
{text}
要求:
1. 只返回翻译后的文案,不要添加任何解释或说明
2. 保持原文的语气和风格
3. 翻译要自然流畅,符合目标语言的表达习惯"""
try:
client = self._get_client()
logger.info(f"Using GLM to translate text to {target_lang}")
import asyncio
response = await asyncio.to_thread(
client.chat.completions.create,
model=settings.GLM_MODEL,
messages=[{"role": "user", "content": prompt}],
thinking={"type": "disabled"},
max_tokens=2000,
temperature=0.3
)
content = response.choices[0].message.content
logger.info("GLM translation completed")
return content.strip()
except Exception as e:
logger.error(f"GLM translate error: {e}")
raise Exception(f"AI 翻译失败: {str(e)}")
def _parse_json_response(self, content: str) -> dict:
"""解析 GLM 返回的 JSON 内容"""
# 尝试直接解析

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

@@ -18,6 +18,7 @@ from app.services.storage import storage_service
from .uploader.bilibili_uploader import BilibiliUploader
from .uploader.douyin_uploader import DouyinUploader
from .uploader.xiaohongshu_uploader import XiaohongshuUploader
from .uploader.weixin_uploader import WeixinUploader
class PublishService:
@@ -25,11 +26,10 @@ class PublishService:
# 支持的平台配置
PLATFORMS: Dict[str, Dict[str, Any]] = {
"bilibili": {"name": "B站", "url": "https://member.bilibili.com/platform/upload/video/frame", "enabled": True},
"douyin": {"name": "抖音", "url": "https://creator.douyin.com/", "enabled": True},
"weixin": {"name": "微信视频号", "url": "https://channels.weixin.qq.com/", "enabled": True},
"bilibili": {"name": "B站", "url": "https://member.bilibili.com/platform/upload/video/frame", "enabled": True},
"xiaohongshu": {"name": "小红书", "url": "https://creator.xiaohongshu.com/", "enabled": True},
"weixin": {"name": "微信视频号", "url": "https://channels.weixin.qq.com/", "enabled": False},
"kuaishou": {"name": "快手", "url": "https://cp.kuaishou.com/", "enabled": False},
}
def __init__(self) -> None:
@@ -182,7 +182,8 @@ class PublishService:
tags=tags,
publish_date=publish_time,
account_file=str(account_file),
description=description
description=description,
user_id=user_id,
)
elif platform == "xiaohongshu":
uploader = XiaohongshuUploader(
@@ -193,6 +194,16 @@ class PublishService:
account_file=str(account_file),
description=description
)
elif platform == "weixin":
uploader = WeixinUploader(
title=title,
file_path=local_video_path,
tags=tags,
publish_date=publish_time,
account_file=str(account_file),
description=description,
user_id=user_id,
)
else:
logger.warning(f"[发布] {platform} 上传功能尚未实现")
return {
@@ -225,30 +236,38 @@ class PublishService:
async def login(self, platform: str, user_id: Optional[str] = None) -> Dict[str, Any]:
"""
启动QR码登录流程
Args:
platform: 平台 ID
user_id: 用户 ID (用于 Cookie 隔离)
Returns:
dict: 包含二维码base64图片
"""
if platform not in self.PLATFORMS:
return {"success": False, "message": "不支持的平台"}
try:
from .qr_login_service import QRLoginService
# 获取用户专属的 Cookie 目录
cookies_dir = self._get_cookies_dir(user_id)
# 清理旧的活跃会话(避免残留会话干扰新登录)
session_key = self._get_session_key(platform, user_id)
if session_key in self.active_login_sessions:
old_service = self.active_login_sessions.pop(session_key)
try:
await old_service._cleanup()
except Exception:
pass
# 创建QR登录服务
qr_service = QRLoginService(platform, cookies_dir)
# 存储活跃会话 (带用户隔离)
session_key = self._get_session_key(platform, user_id)
self.active_login_sessions[session_key] = qr_service
# 启动登录并获取二维码
result = await qr_service.start_login()
@@ -262,27 +281,28 @@ class PublishService:
}
def get_login_session_status(self, platform: str, user_id: Optional[str] = None) -> Dict[str, Any]:
"""获取活跃登录会话的状态"""
"""获取活跃登录会话的状态(仅用于扫码轮询)"""
session_key = self._get_session_key(platform, user_id)
# 1. 如果有活跃的扫码会话,优先检查它
# 只检查活跃的扫码会话,不检查 Cookie 文件
# Cookie 文件检查会导致"重新登录"时误判为已登录
if session_key in self.active_login_sessions:
qr_service = self.active_login_sessions[session_key]
status = qr_service.get_login_status()
# 如果登录成功且Cookie已保存清理会话
if status["success"] and status["cookies_saved"]:
del self.active_login_sessions[session_key]
return {"success": True, "message": "登录成功"}
return {"success": False, "message": "等待扫码..."}
# 2. 检查本地Cookie文件是否存在
cookie_file = self._get_cookie_path(platform, user_id)
if cookie_file.exists():
return {"success": True, "message": "已登录 (历史状态)"}
return {"success": False, "message": "未登录"}
# 刷脸验证:传递新二维码给前端
result: Dict[str, Any] = {"success": False, "message": "等待扫码..."}
if status.get("face_verify_qr"):
result["face_verify_qr"] = status["face_verify_qr"]
return result
# 没有活跃会话 → 返回 False前端不应在无会话时轮询
return {"success": False, "message": "无活跃登录会话"}
def logout(self, platform: str, user_id: Optional[str] = None) -> Dict[str, Any]:
"""

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@ Remotion 视频渲染服务
import asyncio
import json
import os
import subprocess
from pathlib import Path
from typing import Optional
@@ -52,13 +53,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)])
@@ -106,6 +115,16 @@ class RemotionService:
process.wait()
if process.returncode != 0:
# Remotion 渲染可能在完成输出后进程崩溃 (如 SIGABRT code -6)
# 如果输出文件已存在且大小合理,视为成功
output_file = Path(output_path)
if output_file.exists() and output_file.stat().st_size > 1024:
logger.warning(
f"Remotion process exited with code {process.returncode}, "
f"but output file exists ({output_file.stat().st_size} bytes). Treating as success."
)
return output_path
error_msg = "\n".join(output_lines[-20:]) # 最后 20 行
raise RuntimeError(f"Remotion render failed (code {process.returncode}):\n{error_msg}")

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 本地存储根目录(从环境变量读取,支持不同部署环境)
_default_storage_path = "/var/lib/supabase/storage" # 生产环境默认路径
SUPABASE_STORAGE_LOCAL_PATH = Path(os.getenv("SUPABASE_STORAGE_LOCAL_PATH", _default_storage_path))
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:
@@ -152,6 +194,19 @@ class StorageService:
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]:
"""异步列出文件"""
try:

View File

@@ -4,6 +4,7 @@ Platform uploader base classes and utilities
from .base_uploader import BaseUploader
from .bilibili_uploader import BilibiliUploader
from .douyin_uploader import DouyinUploader
from .xiaohongshu_uploader import XiaohongshuUploader
from .xiaohongshu_uploader import XiaohongshuUploader
from .weixin_uploader import WeixinUploader
__all__ = ['BaseUploader', 'BilibiliUploader', 'DouyinUploader', 'XiaohongshuUploader']
__all__ = ['BaseUploader', 'BilibiliUploader', 'DouyinUploader', 'XiaohongshuUploader', 'WeixinUploader']

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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,
@@ -133,3 +138,109 @@ class VideoService:
return output_path
else:
raise RuntimeError("FFmpeg composition failed")
def concat_videos(self, video_paths: list, output_path: str) -> str:
"""使用 FFmpeg concat demuxer 拼接多个视频片段"""
if not video_paths:
raise ValueError("No video segments to concat")
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
# 生成 concat list 文件
list_path = Path(output_path).parent / f"{Path(output_path).stem}_concat.txt"
with open(list_path, "w", encoding="utf-8") as f:
for vp in video_paths:
f.write(f"file '{vp}'\n")
cmd = [
"ffmpeg", "-y",
"-f", "concat",
"-safe", "0",
"-i", str(list_path),
"-c", "copy",
output_path,
]
try:
if self._run_ffmpeg(cmd):
return output_path
else:
raise RuntimeError("FFmpeg concat failed")
finally:
try:
list_path.unlink(missing_ok=True)
except Exception:
pass
def split_audio(self, audio_path: str, start: float, end: float, output_path: str) -> str:
"""用 FFmpeg 按时间范围切分音频"""
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
duration = end - start
if duration <= 0:
raise ValueError(f"Invalid audio split range: start={start}, end={end}, duration={duration}")
cmd = [
"ffmpeg", "-y",
"-ss", str(start),
"-t", str(duration),
"-i", audio_path,
"-c", "copy",
output_path,
]
if self._run_ffmpeg(cmd):
return output_path
raise RuntimeError(f"FFmpeg audio split failed: {start}-{end}")
def get_resolution(self, file_path: str) -> tuple:
"""获取视频分辨率,返回 (width, height)"""
cmd = [
'ffprobe', '-v', 'error',
'-select_streams', 'v:0',
'-show_entries', 'stream=width,height',
'-of', 'csv=p=0',
file_path
]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
parts = result.stdout.strip().split(',')
return (int(parts[0]), int(parts[1]))
except Exception:
return (0, 0)
def prepare_segment(self, video_path: str, target_duration: float, output_path: str,
target_resolution: tuple = None) -> str:
"""将素材视频裁剪或循环到指定时长(无音频)。
target_resolution: (width, height) 如需统一分辨率则传入,否则保持原分辨率。
"""
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
video_dur = self._get_duration(video_path)
if video_dur <= 0:
video_dur = target_duration
needs_loop = target_duration > video_dur
needs_scale = target_resolution is not None
cmd = ["ffmpeg", "-y"]
if needs_loop:
loop_count = int(target_duration / video_dur) + 1
cmd.extend(["-stream_loop", str(loop_count)])
cmd.extend(["-i", video_path, "-t", str(target_duration), "-an"])
if needs_scale:
w, h = target_resolution
cmd.extend(["-vf", f"scale={w}:{h}:force_original_aspect_ratio=decrease,pad={w}:{h}:(ow-iw)/2:(oh-ih)/2"])
# 需要循环或缩放时必须重编码,否则用 stream copy 保持原画质
if needs_loop or needs_scale:
cmd.extend(["-c:v", "libx264", "-preset", "fast", "-crf", "18"])
else:
cmd.extend(["-c:v", "copy"])
cmd.append(output_path)
if self._run_ffmpeg(cmd):
return output_path
raise RuntimeError(f"FFmpeg prepare_segment failed: {video_path}")

View File

@@ -48,7 +48,7 @@ class VoiceCloneService:
"""
# 使用锁确保串行执行,避免 GPU 显存溢出
async with self._lock:
logger.info(f"🎤 Voice Clone: {text[:30]}...")
logger.info(f"🎤 Voice Clone: {text[:30]}... (language={language})")
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
# 读取参考音频

View File

@@ -20,16 +20,23 @@ MAX_CHARS_PER_LINE = 12
def split_word_to_chars(word: str, start: float, end: float) -> list:
"""
将词拆分成单个字符,时间戳线性插值
将词拆分成单个字符,时间戳线性插值
保留英文词前的空格Whisper 输出如 " Hello"),用于正确重建英文字幕。
Args:
word: 词文本
word: 词文本(可能含前导空格)
start: 词开始时间
end: 词结束时间
Returns:
单字符列表,每个包含 word/start/end
"""
# 保留前导空格(英文 Whisper 输出常见 " Hello" 形式)
leading_space = ""
if word and not word[0].strip():
leading_space = " "
word = word.lstrip()
tokens = []
ascii_buffer = ""
@@ -54,7 +61,8 @@ def split_word_to_chars(word: str, start: float, end: float) -> list:
return []
if len(tokens) == 1:
return [{"word": tokens[0], "start": start, "end": end}]
w = leading_space + tokens[0] if leading_space else tokens[0]
return [{"word": w, "start": start, "end": end}]
# 线性插值时间戳
duration = end - start
@@ -64,8 +72,11 @@ def split_word_to_chars(word: str, start: float, end: float) -> list:
for i, token in enumerate(tokens):
token_start = start + i * token_duration
token_end = start + (i + 1) * token_duration
w = token
if i == 0 and leading_space:
w = leading_space + w
result.append({
"word": token,
"word": w,
"start": round(token_start, 3),
"end": round(token_end, 3)
})
@@ -108,7 +119,7 @@ def split_segment_to_lines(words: List[dict], max_chars: int = MAX_CHARS_PER_LIN
if should_break and current_words:
segments.append({
"text": current_text,
"text": current_text.strip(),
"start": current_words[0]["start"],
"end": current_words[-1]["end"],
"words": current_words.copy()
@@ -119,7 +130,7 @@ def split_segment_to_lines(words: List[dict], max_chars: int = MAX_CHARS_PER_LIN
# 处理剩余的字
if current_words:
segments.append({
"text": current_text,
"text": current_text.strip(),
"start": current_words[0]["start"],
"end": current_words[-1]["end"],
"words": current_words.copy()
@@ -162,7 +173,8 @@ class WhisperService:
self,
audio_path: str,
text: str,
output_path: Optional[str] = None
output_path: Optional[str] = None,
language: str = "zh",
) -> dict:
"""
对音频进行转录,生成字级别时间戳
@@ -171,12 +183,16 @@ class WhisperService:
audio_path: 音频文件路径
text: 原始文本(用于参考,但实际使用 whisper 转录结果)
output_path: 可选,输出 JSON 文件路径
language: 语言代码 (zh/en 等)
Returns:
包含字级别时间戳的字典
"""
import asyncio
# 英文等西文需要更大的每行字数
max_chars = 40 if language != "zh" else MAX_CHARS_PER_LINE
def _do_transcribe():
model = self._load_model()
@@ -185,7 +201,7 @@ class WhisperService:
# 转录并获取字级别时间戳
segments_iter, info = model.transcribe(
audio_path,
language="zh",
language=language,
word_timestamps=True, # 启用字级别时间戳
vad_filter=True, # 启用 VAD 过滤静音
)
@@ -198,9 +214,10 @@ class WhisperService:
all_words = []
if segment.words:
for word_info in segment.words:
word_text = word_info.word.strip()
if word_text:
word_text = word_info.word
if word_text.strip():
# 将词拆分成单字,时间戳线性插值
# 保留前导空格用于英文词间距
chars = split_word_to_chars(
word_text,
word_info.start,
@@ -210,7 +227,7 @@ class WhisperService:
# 将长段落按标点和字数拆分成多行
if all_words:
line_segments = split_segment_to_lines(all_words, MAX_CHARS_PER_LINE)
line_segments = split_segment_to_lines(all_words, max_chars)
all_segments.extend(line_segments)
logger.info(f"Generated {len(all_segments)} subtitle segments")

View File

@@ -8,12 +8,16 @@
"name": "frontend",
"version": "0.1.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@supabase/supabase-js": "^2.93.1",
"axios": "^1.13.4",
"lucide-react": "^0.563.0",
"next": "16.1.1",
"react": "19.2.3",
"react-dom": "19.2.3",
"sonner": "^2.0.7",
"swr": "^2.3.8"
},
"devDependencies": {
@@ -280,6 +284,59 @@
"node": ">=6.9.0"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
@@ -6006,6 +6063,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/sonner": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",

View File

@@ -9,12 +9,16 @@
"lint": "eslint"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@supabase/supabase-js": "^2.93.1",
"axios": "^1.13.4",
"lucide-react": "^0.563.0",
"next": "16.1.1",
"react": "19.2.3",
"react-dom": "19.2.3",
"sonner": "^2.0.7",
"swr": "^2.3.8"
},
"devDependencies": {

View File

@@ -0,0 +1 @@
<svg fill="#00A1D6" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Bilibili</title><path d="M17.813 4.653h.854c1.51.054 2.769.578 3.773 1.574 1.004.995 1.524 2.249 1.56 3.76v7.36c-.036 1.51-.556 2.769-1.56 3.773s-2.262 1.524-3.773 1.56H5.333c-1.51-.036-2.769-.556-3.773-1.56S.036 18.858 0 17.347v-7.36c.036-1.511.556-2.765 1.56-3.76 1.004-.996 2.262-1.52 3.773-1.574h.774l-1.174-1.12a1.234 1.234 0 0 1-.373-.906c0-.356.124-.658.373-.907l.027-.027c.267-.249.573-.373.92-.373.347 0 .653.124.92.373L9.653 4.44c.071.071.134.142.187.213h4.267a.836.836 0 0 1 .16-.213l2.853-2.747c.267-.249.573-.373.92-.373.347 0 .662.151.929.4.267.249.391.551.391.907 0 .355-.124.657-.373.906zM5.333 7.24c-.746.018-1.373.276-1.88.773-.506.498-.769 1.13-.786 1.894v7.52c.017.764.28 1.395.786 1.893.507.498 1.134.756 1.88.773h13.334c.746-.017 1.373-.275 1.88-.773.506-.498.769-1.129.786-1.893v-7.52c-.017-.765-.28-1.396-.786-1.894-.507-.497-1.134-.755-1.88-.773zM8 11.107c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c0-.373.129-.689.386-.947.258-.257.574-.386.947-.386zm8 0c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c.017-.391.15-.711.4-.96.249-.249.56-.373.933-.373Z"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1 @@
<svg fill="#000000" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>TikTok</title><path d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z"/></svg>

After

Width:  |  Height:  |  Size: 723 B

View File

@@ -0,0 +1 @@
<svg fill="#07C160" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>WeChat</title><path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.27-.027-.407-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg fill="#FF2442" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Xiaohongshu</title><path d="M22.405 9.879c.002.016.01.02.07.019h.725a.797.797 0 0 0 .78-.972.794.794 0 0 0-.884-.618.795.795 0 0 0-.692.794c0 .101-.002.666.001.777zm-11.509 4.808c-.203.001-1.353.004-1.685.003a2.528 2.528 0 0 1-.766-.126.025.025 0 0 0-.03.014L7.7 16.127a.025.025 0 0 0 .01.032c.111.06.336.124.495.124.66.01 1.32.002 1.981 0 .01 0 .02-.006.023-.015l.712-1.545a.025.025 0 0 0-.024-.036zM.477 9.91c-.071 0-.076.002-.076.01a.834.834 0 0 0-.01.08c-.027.397-.038.495-.234 3.06-.012.24-.034.389-.135.607-.026.057-.033.042.003.112.046.092.681 1.523.787 1.74.008.015.011.02.017.02.008 0 .033-.026.047-.044.147-.187.268-.391.371-.606.306-.635.44-1.325.486-1.706.014-.11.021-.22.03-.33l.204-2.616.022-.293c.003-.029 0-.033-.03-.034zm7.203 3.757a1.427 1.427 0 0 1-.135-.607c-.004-.084-.031-.39-.235-3.06a.443.443 0 0 0-.01-.082c-.004-.011-.052-.008-.076-.008h-1.48c-.03.001-.034.005-.03.034l.021.293c.076.982.153 1.964.233 2.946.05.4.186 1.085.487 1.706.103.215.223.419.37.606.015.018.037.051.048.049.02-.003.742-1.642.804-1.765.036-.07.03-.055.003-.112zm3.861-.913h-.872a.126.126 0 0 1-.116-.178l1.178-2.625a.025.025 0 0 0-.023-.035l-1.318-.003a.148.148 0 0 1-.135-.21l.876-1.954a.025.025 0 0 0-.023-.035h-1.56c-.01 0-.02.006-.024.015l-.926 2.068c-.085.169-.314.634-.399.938a.534.534 0 0 0-.02.191.46.46 0 0 0 .23.378.981.981 0 0 0 .46.119h.59c.041 0-.688 1.482-.834 1.972a.53.53 0 0 0-.023.172.465.465 0 0 0 .23.398c.15.092.342.12.475.12l1.66-.001c.01 0 .02-.006.023-.015l.575-1.28a.025.025 0 0 0-.024-.035zm-6.93-4.937H3.1a.032.032 0 0 0-.034.033c0 1.048-.01 2.795-.01 6.829 0 .288-.269.262-.28.262h-.74c-.04.001-.044.004-.04.047.001.037.465 1.064.555 1.263.01.02.03.033.051.033.157.003.767.009.938-.014.153-.02.3-.06.438-.132.3-.156.49-.419.595-.765.052-.172.075-.353.075-.533.002-2.33 0-4.66-.007-6.991a.032.032 0 0 0-.032-.032zm11.784 6.896c0-.014-.01-.021-.024-.022h-1.465c-.048-.001-.049-.002-.05-.049v-4.66c0-.072-.005-.07.07-.07h.863c.08 0 .075.004.075-.074V8.393c0-.082.006-.076-.08-.076h-3.5c-.064 0-.075-.006-.075.073v1.445c0 .083-.006.077.08.077h.854c.075 0 .07-.004.07.07v4.624c0 .095.008.084-.085.084-.37 0-1.11-.002-1.304 0-.048.001-.06.03-.06.03l-.697 1.519s-.014.025-.008.036c.006.01.013.008.058.008 1.748.003 3.495.002 5.243.002.03-.001.034-.006.035-.033v-1.539zm4.177-3.43c0 .013-.007.023-.02.024-.346.006-.692.004-1.037.004-.014-.002-.022-.01-.022-.024-.005-.434-.007-.869-.01-1.303 0-.072-.006-.071.07-.07l.733-.003c.041 0 .081.002.12.015.093.025.16.107.165.204.006.431.002 1.153.001 1.153zm2.67.244a1.953 1.953 0 0 0-.883-.222h-.18c-.04-.001-.04-.003-.042-.04V10.21c0-.132-.007-.263-.025-.394a1.823 1.823 0 0 0-.153-.53 1.533 1.533 0 0 0-.677-.71 2.167 2.167 0 0 0-1-.258c-.153-.003-.567 0-.72 0-.07 0-.068.004-.068-.065V7.76c0-.031-.01-.041-.046-.039H17.93s-.016 0-.023.007c-.006.006-.008.012-.008.023v.546c-.008.036-.057.015-.082.022h-.95c-.022.002-.028.008-.03.032v1.481c0 .09-.004.082.082.082h.913c.082 0 .072.128.072.128V11.19s.003.117-.06.117h-1.482c-.068 0-.06.082-.06.082v1.445s-.01.068.064.068h1.457c.082 0 .076-.006.076.079v3.225c0 .088-.007.081.082.081h1.43c.09 0 .082.007.082-.08v-3.27c0-.029.006-.035.033-.035l2.323-.003c.098 0 .191.02.28.061a.46.46 0 0 1 .274.407c.008.395.003.79.003 1.185 0 .259-.107.367-.33.367h-1.218c-.023.002-.029.008-.028.033.184.437.374.871.57 1.303a.045.045 0 0 0 .04.026c.17.005.34.002.51.003.15-.002.517.004.666-.01a2.03 2.03 0 0 0 .408-.075c.59-.18.975-.698.976-1.313v-1.981c0-.128-.01-.254-.034-.38 0 .078-.029-.641-.724-.998z"/></svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@@ -2,8 +2,11 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { getCurrentUser, User } from "@/shared/lib/auth";
import api from "@/shared/api/axios";
import Link from 'next/link';
import { getCurrentUser, User } from "@/shared/lib/auth";
import api from "@/shared/api/axios";
import { ApiResponse, unwrap } from "@/shared/api/types";
import { toast } from "sonner";
interface UserListItem {
id: string;
@@ -17,7 +20,7 @@ interface UserListItem {
export default function AdminPage() {
const router = useRouter();
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [, setCurrentUser] = useState<User | null>(null);
const [users, setUsers] = useState<UserListItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
@@ -27,6 +30,7 @@ export default function AdminPage() {
useEffect(() => {
checkAdmin();
fetchUsers();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const checkAdmin = async () => {
@@ -40,9 +44,9 @@ export default function AdminPage() {
const fetchUsers = async () => {
try {
const { data } = await api.get('/api/admin/users');
setUsers(data);
} catch (err) {
const { data: res } = await api.get<ApiResponse<UserListItem[]>>('/api/admin/users');
setUsers(unwrap(res));
} catch {
setError('获取用户列表失败');
} finally {
setLoading(false);
@@ -56,7 +60,7 @@ export default function AdminPage() {
expires_days: expireDays || null
});
fetchUsers();
} catch (err) {
} catch {
// axios interceptor handles 401/403
} finally {
setActivatingId(null);
@@ -69,8 +73,8 @@ export default function AdminPage() {
try {
await api.post(`/api/admin/users/${userId}/deactivate`);
fetchUsers();
} catch (err) {
alert('操作失败');
} catch {
toast.error('操作失败');
}
};
@@ -105,9 +109,9 @@ export default function AdminPage() {
<div className="max-w-6xl mx-auto">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold text-white"></h1>
<a href="/" className="text-purple-300 hover:text-purple-200">
<Link href="/" className="text-purple-300 hover:text-purple-200">
</a>
</Link>
</div>
{error && (

View File

@@ -1,9 +1,10 @@
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { AuthProvider } from "@/contexts/AuthContext";
import { TaskProvider } from "@/contexts/TaskContext";
import GlobalTaskIndicator from "@/components/GlobalTaskIndicator";
import { AuthProvider } from "@/shared/contexts/AuthContext";
import { TaskProvider } from "@/shared/contexts/TaskContext";
import { Toaster } from "sonner";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -39,10 +40,18 @@ export default function RootLayout({
>
<AuthProvider>
<TaskProvider>
<GlobalTaskIndicator />
{children}
</TaskProvider>
</AuthProvider>
<Toaster
position="top-center"
richColors
closeButton
toastOptions={{
duration: 3000,
className: "text-sm",
}}
/>
</body>
</html>
);

View File

@@ -2,7 +2,7 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { login } from "@/shared/lib/auth";
import { login } from "@/shared/lib/auth";
export default function LoginPage() {
const router = useRouter();
@@ -30,7 +30,7 @@ export default function LoginPage() {
} else {
setError(result.message || '登录失败');
}
} catch (err) {
} catch {
setError('网络错误,请稍后重试');
} finally {
setLoading(false);

View File

@@ -2,10 +2,10 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { register } from "@/shared/lib/auth";
import { register } from "@/shared/lib/auth";
export default function RegisterPage() {
const router = useRouter();
useRouter(); // 保留以便后续扩展
const [phone, setPhone] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
@@ -43,7 +43,7 @@ export default function RegisterPage() {
} else {
setError(result.message || '注册失败');
}
} catch (err) {
} catch {
setError('网络错误,请稍后重试');
} finally {
setLoading(false);

View File

@@ -1,8 +1,9 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { useAuth } from "@/shared/contexts/AuthContext";
import api from "@/shared/api/axios";
import { ApiResponse } from "@/shared/api/types";
// 账户设置下拉菜单组件
export default function AccountSettingsDropdown() {
@@ -43,7 +44,7 @@ export default function AccountSettingsDropdown() {
if (confirm('确定要退出登录吗?')) {
try {
await api.post('/api/auth/logout');
} catch (e) { }
} catch { }
window.location.href = '/login';
}
};
@@ -65,24 +66,25 @@ export default function AccountSettingsDropdown() {
setLoading(true);
try {
const res = await api.post('/api/auth/change-password', {
const { data: res } = await api.post<ApiResponse<null>>('/api/auth/change-password', {
old_password: oldPassword,
new_password: newPassword
});
if (res.data.success) {
setSuccess('密码修改成功,正在跳转登录页...');
if (res.success) {
setSuccess(res.message || '密码修改成功,正在跳转登录页...');
// 清除登录状态并跳转
setTimeout(async () => {
try {
await api.post('/api/auth/logout');
} catch (e) { }
} catch { }
window.location.href = '/login';
}, 1500);
} else {
setError(res.data.message || '修改失败');
setError(res.message || '修改失败');
}
} catch (err: any) {
setError(err.response?.data?.detail || '修改失败,请重试');
} catch (err: unknown) {
const axiosErr = err as { response?: { data?: { message?: string } } };
setError(axiosErr.response?.data?.message || '修改失败,请重试');
} finally {
setLoading(false);
}

View File

@@ -1,12 +1,15 @@
"use client";
import { useTask } from "@/contexts/TaskContext";
import { useTask } from "@/shared/contexts/TaskContext";
import Link from "next/link";
import { usePathname } from "next/navigation";
export default function GlobalTaskIndicator() {
const { currentTask, isGenerating } = useTask();
const pathname = usePathname();
if (!isGenerating) return null;
// 首页已有专门的进度条展示,因此在首页不显示顶部全局进度条
if (!isGenerating || pathname === "/") return null;
return (
<div className="fixed top-0 left-0 right-0 z-50 bg-gradient-to-r from-purple-600 to-pink-600 text-white shadow-lg">

View File

@@ -1,424 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import api from "@/shared/api/axios";
interface ScriptExtractionModalProps {
isOpen: boolean;
onClose: () => void;
onApply?: (text: string) => void;
}
export default function ScriptExtractionModal({
isOpen,
onClose,
onApply
}: ScriptExtractionModalProps) {
const [isLoading, setIsLoading] = useState(false);
const [script, setScript] = useState("");
const [rewrittenScript, setRewrittenScript] = useState("");
const [error, setError] = useState<string | null>(null);
const [doRewrite, setDoRewrite] = useState(true);
const [step, setStep] = useState<'config' | 'processing' | 'result'>('config');
const [dragActive, setDragActive] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
// New state for URL mode
const [activeTab, setActiveTab] = useState<'file' | 'url'>('url');
const [inputUrl, setInputUrl] = useState("");
// Reset state when modal opens
useEffect(() => {
if (isOpen) {
setStep('config');
setScript("");
setRewrittenScript("");
setError(null);
setIsLoading(false);
setSelectedFile(null);
setInputUrl("");
setActiveTab('url');
}
}, [isOpen]);
const handleDrag = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === "dragenter" || e.type === "dragover") {
setDragActive(true);
} else if (e.type === "dragleave") {
setDragActive(false);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
handleFile(e.dataTransfer.files[0]);
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
handleFile(e.target.files[0]);
}
};
const handleFile = (file: File) => {
const validTypes = ['.mp4', '.mov', '.avi', '.mp3', '.wav', '.m4a'];
const ext = file.name.toLowerCase().slice(file.name.lastIndexOf('.'));
if (!validTypes.includes(ext)) {
setError(`不支持的文件格式 ${ext},请上传视频或音频文件`);
return;
}
setSelectedFile(file);
setError(null);
};
const handleExtract = async () => {
if (activeTab === 'file' && !selectedFile) {
setError("请先上传文件");
return;
}
if (activeTab === 'url' && !inputUrl.trim()) {
setError("请先输入视频链接");
return;
}
setIsLoading(true);
setStep('processing');
setError(null);
try {
const formData = new FormData();
if (activeTab === 'file' && selectedFile) {
formData.append('file', selectedFile);
} else if (activeTab === 'url') {
formData.append('url', inputUrl.trim());
}
formData.append('rewrite', doRewrite ? 'true' : 'false');
const { data } = await api.post('/api/tools/extract-script', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 180000 // 3 minutes timeout
});
if (data.success) {
setScript(data.original_script);
setRewrittenScript(data.rewritten_script || "");
setStep('result');
} else {
setError("提取失败:未知错误");
setStep('config');
}
} catch (err: any) {
console.error(err);
const msg = err.response?.data?.detail || err.message || "请求失败";
setError(msg);
setStep('config');
} finally {
setIsLoading(false);
}
};
const copyToClipboard = (text: string) => {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(() => {
alert("已复制到剪贴板");
}).catch(err => {
console.error('Async: Could not copy text: ', err);
fallbackCopyTextToClipboard(text);
});
} else {
fallbackCopyTextToClipboard(text);
}
};
const fallbackCopyTextToClipboard = (text: string) => {
var textArea = document.createElement("textarea");
textArea.value = text;
// Avoid scrolling to bottom
textArea.style.top = "0";
textArea.style.left = "0";
textArea.style.position = "fixed";
textArea.style.opacity = "0";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
var successful = document.execCommand('copy');
var msg = successful ? 'successful' : 'unsuccessful';
if (successful) {
alert("已复制到剪贴板");
} else {
alert("复制失败,请手动复制");
}
} catch (err) {
console.error('Fallback: Oops, unable to copy', err);
alert("复制失败,请手动复制");
}
document.body.removeChild(textArea);
};
// Close when clicking outside - DISABLED as per user request
// const modalRef = useRef<HTMLDivElement>(null);
// const handleBackdropClick = (e: React.MouseEvent) => {
// if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
// onClose();
// }
// };
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4 animate-in fade-in duration-200"
>
<div
// ref={modalRef}
className="bg-[#1a1a1a] border border-white/10 rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col shadow-2xl"
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-white/10 bg-white/5">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
📜
</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-white transition-colors text-2xl leading-none"
>
&times;
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{step === 'config' && (
<div className="space-y-6">
{/* Tabs */}
<div className="flex p-1 bg-white/5 rounded-xl border border-white/10">
<button
onClick={() => setActiveTab('url')}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-all ${activeTab === 'url'
? 'bg-purple-600 text-white shadow-lg'
: 'text-gray-400 hover:text-white hover:bg-white/5'
}`}
>
🔗
</button>
<button
onClick={() => setActiveTab('file')}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-all ${activeTab === 'file'
? 'bg-purple-600 text-white shadow-lg'
: 'text-gray-400 hover:text-white hover:bg-white/5'
}`}
>
📂
</button>
</div>
{/* URL Input Area */}
{activeTab === 'url' && (
<div className="space-y-2 py-4">
<div className="relative">
<input
type="text"
value={inputUrl}
onChange={(e) => setInputUrl(e.target.value)}
placeholder="请粘贴抖音、B站等主流平台视频链接..."
className="w-full bg-black/20 border border-white/10 rounded-xl px-4 py-4 text-white placeholder-gray-500 focus:outline-none focus:border-purple-500 transition-colors"
/>
{inputUrl && (
<button
onClick={() => setInputUrl("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white p-1"
>
</button>
)}
</div>
<p className="text-xs text-gray-400 px-1">
B站等主流平台分享链接
</p>
</div>
)}
{/* File Upload Area */}
{activeTab === 'file' && (
<div
className={`
relative border-2 border-dashed rounded-xl p-8 text-center transition-all cursor-pointer
${dragActive ? 'border-purple-500 bg-purple-500/10' : 'border-white/20 hover:border-white/40 hover:bg-white/5'}
${selectedFile ? 'bg-purple-900/10 border-purple-500/50' : ''}
`}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
>
<input
type="file"
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
onChange={handleFileChange}
accept=".mp4,.mov,.avi,.mp3,.wav,.m4a"
/>
{selectedFile ? (
<div className="flex flex-col items-center">
<div className="text-4xl mb-2">📄</div>
<div className="font-medium text-white break-all max-w-xs">{selectedFile.name}</div>
<div className="text-sm text-gray-400 mt-1">{(selectedFile.size / (1024 * 1024)).toFixed(1)} MB</div>
<div className="mt-4 text-xs text-purple-400"></div>
</div>
) : (
<div className="flex flex-col items-center">
<div className="text-4xl mb-2">📤</div>
<div className="font-medium text-white"></div>
<div className="text-sm text-gray-400 mt-2"> MP4, MOV, MP3, WAV </div>
</div>
)}
</div>
)}
{/* Options */}
<div className="bg-white/5 rounded-xl p-4 border border-white/10">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={doRewrite}
onChange={e => setDoRewrite(e.target.checked)}
className="w-5 h-5 accent-purple-600 rounded"
/>
<div>
<div className="text-white font-medium"> AI 稿</div>
<div className="text-xs text-gray-400">稿</div>
</div>
</label>
</div>
{error && (
<div className="p-3 bg-red-500/20 text-red-200 rounded-lg text-sm text-center">
{error}
</div>
)}
<div className="flex justify-center pt-2">
<button
onClick={handleExtract}
className="w-full sm:w-auto px-10 py-3 bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-xl font-bold hover:shadow-lg hover:from-purple-500 hover:to-pink-500 transition-all transform hover:-translate-y-0.5 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={activeTab === 'file' ? !selectedFile : !inputUrl.trim()}
>
{activeTab === 'url' ? '🔗 解析并提取' : '🚀 开始提取'}
</button>
</div>
</div>
)}
{step === 'processing' && (
<div className="flex flex-col items-center justify-center py-20">
<div className="relative w-20 h-20 mb-6">
<div className="absolute inset-0 border-4 border-purple-500/30 rounded-full"></div>
<div className="absolute inset-0 border-4 border-t-purple-500 rounded-full animate-spin"></div>
</div>
<h4 className="text-xl font-medium text-white mb-2">...</h4>
<p className="text-sm text-gray-400 text-center max-w-sm px-4">
{activeTab === 'url' && "正在下载视频..."}<br />
{doRewrite ? "正在进行语音识别和 AI 智能改写..." : "正在进行语音识别..."}<br />
<span className="opacity-75"></span>
</p>
</div>
)}
{step === 'result' && (
<div className="space-y-6">
{rewrittenScript && (
<div className="space-y-2">
<div className="flex justify-between items-center">
<h4 className="font-semibold text-purple-300 flex items-center gap-2">
AI 稿 <span className="text-xs font-normal text-purple-400/70">()</span>
</h4>
{onApply && (
<button
onClick={() => {
onApply(rewrittenScript);
onClose();
}}
className="text-xs bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white px-3 py-1.5 rounded-lg transition-colors flex items-center gap-1 shadow-sm"
>
📥
</button>
)}
<button
onClick={() => copyToClipboard(rewrittenScript)}
className="text-xs bg-purple-600 hover:bg-purple-500 text-white px-3 py-1.5 rounded-lg transition-colors flex items-center gap-1"
>
📋
</button>
</div>
<div className="bg-purple-900/10 border border-purple-500/20 rounded-xl p-4 max-h-60 overflow-y-auto custom-scrollbar">
<p className="text-gray-200 text-sm leading-relaxed whitespace-pre-wrap">
{rewrittenScript}
</p>
</div>
</div>
)}
<div className="space-y-2">
<div className="flex justify-between items-center">
<h4 className="font-semibold text-gray-400 flex items-center gap-2">
🎙
</h4>
{onApply && (
<button
onClick={() => {
onApply(script);
onClose();
}}
className="text-xs bg-white/10 hover:bg-white/20 text-white px-3 py-1.5 rounded-lg transition-colors flex items-center gap-1"
>
📥
</button>
)}
<button
onClick={() => copyToClipboard(script)}
className="text-xs bg-white/10 hover:bg-white/20 text-white px-3 py-1.5 rounded-lg transition-colors"
>
</button>
</div>
<div className="bg-white/5 border border-white/10 rounded-xl p-4 max-h-40 overflow-y-auto custom-scrollbar">
<p className="text-gray-400 text-sm leading-relaxed whitespace-pre-wrap">
{script}
</p>
</div>
</div>
<div className="flex justify-center pt-4">
<button
onClick={() => {
setStep('config');
setScript("");
setRewrittenScript("");
setSelectedFile(null);
setInputUrl("");
// Keep current tab active
}}
className="px-6 py-2 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors"
>
</button>
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -71,6 +71,7 @@ export default function VideoPreviewModal({
src={videoUrl}
controls
autoPlay
preload="metadata"
className="w-full h-full max-h-[80vh] object-contain"
/>
</div>

View File

@@ -1,5 +1,6 @@
import { useCallback, useState } from "react";
import api from "@/shared/api/axios";
import { ApiResponse, unwrap } from "@/shared/api/types";
export interface BgmItem {
id: string;
@@ -8,13 +9,15 @@ export interface BgmItem {
}
interface UseBgmOptions {
storageKey: string;
selectedBgmId: string;
setSelectedBgmId: React.Dispatch<React.SetStateAction<string>>;
}
export const useBgm = ({
storageKey,
// selectedBgmId 用于参数类型推断,不在此 hook 内部直接使用
// eslint-disable-next-line @typescript-eslint/no-unused-vars
selectedBgmId,
setSelectedBgmId,
}: UseBgmOptions) => {
@@ -26,25 +29,25 @@ export const useBgm = ({
setBgmLoading(true);
setBgmError("");
try {
const { data } = await api.get('/api/assets/bgm');
const items: BgmItem[] = Array.isArray(data.bgm) ? data.bgm : [];
const { data: res } = await api.get<ApiResponse<{ bgm: BgmItem[] }>>('/api/assets/bgm');
const payload = unwrap(res);
const items: BgmItem[] = Array.isArray(payload.bgm) ? payload.bgm : [];
setBgmList(items);
const savedBgmId = localStorage.getItem(`vigent_${storageKey}_bgmId`);
setSelectedBgmId((prev) => {
if (prev && items.some((item) => item.id === prev)) return prev;
if (savedBgmId && items.some((item) => item.id === savedBgmId)) return savedBgmId;
return items[0]?.id || "";
});
} catch (error: any) {
const message = error?.response?.data?.detail || error?.message || '加载失败';
} catch (error: unknown) {
const axiosErr = error as { response?: { data?: { message?: string } }; message?: string };
const message = axiosErr?.response?.data?.message || axiosErr?.message || '加载失败';
setBgmError(message);
setBgmList([]);
console.error("获取背景音乐失败:", error);
} finally {
setBgmLoading(false);
}
}, [setSelectedBgmId, storageKey]);
}, [setSelectedBgmId]);
return {
bgmList,

View File

@@ -1,5 +1,7 @@
import { useCallback, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import api from "@/shared/api/axios";
import { ApiResponse, unwrap } from "@/shared/api/types";
import { toast } from "sonner";
interface GeneratedVideo {
id: string;
@@ -10,7 +12,7 @@ interface GeneratedVideo {
}
interface UseGeneratedVideosOptions {
storageKey: string;
selectedVideoId: string | null;
setSelectedVideoId: React.Dispatch<React.SetStateAction<string | null>>;
setGeneratedVideo: React.Dispatch<React.SetStateAction<string | null>>;
@@ -18,7 +20,7 @@ interface UseGeneratedVideosOptions {
}
export const useGeneratedVideos = ({
storageKey,
selectedVideoId,
setSelectedVideoId,
setGeneratedVideo,
@@ -28,36 +30,49 @@ export const useGeneratedVideos = ({
const fetchGeneratedVideos = useCallback(async (preferVideoId?: string) => {
try {
const { data } = await api.get('/api/videos/generated');
const videos: GeneratedVideo[] = data.videos || [];
const { data: res } = await api.get<ApiResponse<{ videos: GeneratedVideo[] }>>(
'/api/videos/generated'
);
const payload = unwrap(res);
const videos: GeneratedVideo[] = payload.videos || [];
setGeneratedVideos(videos);
const savedSelectedVideoId = localStorage.getItem(`vigent_${storageKey}_selectedVideoId`);
const currentId = preferVideoId || selectedVideoId || savedSelectedVideoId || null;
let nextId: string | null = null;
let nextUrl: string | null = null;
if (currentId) {
const found = videos.find(v => v.id === currentId);
if (found) {
nextId = found.id;
nextUrl = resolveMediaUrl(found.path);
// 只在明确指定 preferVideoId 时才自动选中
// "__latest__" 表示选中最新的(第一个),用于新视频生成完成后
// 其他值表示选中指定 ID 的视频
// 不传则不设置选中项,由 useHomePersistence 恢复
if (preferVideoId && videos.length > 0) {
if (preferVideoId === "__latest__") {
setSelectedVideoId(videos[0].id);
setGeneratedVideo(resolveMediaUrl(videos[0].path));
} else {
const found = videos.find(v => v.id === preferVideoId);
if (found) {
setSelectedVideoId(found.id);
setGeneratedVideo(resolveMediaUrl(found.path));
}
}
}
if (!nextId && videos.length > 0) {
nextId = videos[0].id;
nextUrl = resolveMediaUrl(videos[0].path);
}
if (nextId) {
setSelectedVideoId(nextId);
setGeneratedVideo(nextUrl);
}
} catch (error) {
console.error("获取历史视频失败:", error);
}
}, [resolveMediaUrl, selectedVideoId, setGeneratedVideo, setSelectedVideoId, storageKey]);
}, [resolveMediaUrl, setGeneratedVideo, setSelectedVideoId]);
// 【核心修复】当 selectedVideoId 变化时(例如从持久化恢复),自动同步 generatedVideo (URL)
// 之前的逻辑只在 fetch 时设置,导致外部恢复 ID 后 URL 不同步
useEffect(() => {
if (!selectedVideoId || generatedVideos.length === 0) {
// 如果没有选中 ID或者列表为空不要轻易置空 URL除非明确需要
// 这里保持现状,由 fetchGeneratedVideos 或 deleteVideo 处理置空
return;
}
const video = generatedVideos.find(v => v.id === selectedVideoId);
if (video) {
const url = resolveMediaUrl(video.path);
setGeneratedVideo(url);
}
}, [selectedVideoId, generatedVideos, resolveMediaUrl, setGeneratedVideo]);
const deleteVideo = useCallback(async (videoId: string) => {
if (!confirm("确定要删除这个视频吗?")) return;
@@ -69,7 +84,7 @@ export const useGeneratedVideos = ({
}
fetchGeneratedVideos();
} catch (error) {
alert("删除失败: " + error);
toast.error("删除失败: " + error);
}
}, [fetchGeneratedVideos, selectedVideoId, setGeneratedVideo, setSelectedVideoId]);

View File

@@ -11,8 +11,11 @@ import {
} from "@/shared/lib/media";
import { clampTitle } from "@/shared/lib/title";
import { useTitleInput } from "@/shared/hooks/useTitleInput";
import { useAuth } from "@/contexts/AuthContext";
import { useTask } from "@/contexts/TaskContext";
import { useAuth } from "@/shared/contexts/AuthContext";
import { useTask } from "@/shared/contexts/TaskContext";
import { toast } from "sonner";
import { usePublishPrefetch } from "@/shared/hooks/usePublishPrefetch";
import { PublishAccount } from "@/shared/types/publish";
import { useBgm } from "@/features/home/model/useBgm";
import { useGeneratedVideos } from "@/features/home/model/useGeneratedVideos";
import { useHomePersistence } from "@/features/home/model/useHomePersistence";
@@ -20,14 +23,68 @@ import { useMaterials } from "@/features/home/model/useMaterials";
import { useMediaPlayers } from "@/features/home/model/useMediaPlayers";
import { useRefAudios } from "@/features/home/model/useRefAudios";
import { useTitleSubtitleStyles } from "@/features/home/model/useTitleSubtitleStyles";
import { ApiResponse, unwrap } from "@/shared/api/types";
const VOICES: Record<string, { id: string; name: string }[]> = {
"zh-CN": [
{ id: "zh-CN-YunxiNeural", name: "云溪 (男声-年轻)" },
{ id: "zh-CN-YunjianNeural", name: "云健 (男声-新闻)" },
{ id: "zh-CN-YunyangNeural", name: "云扬 (男声-专业)" },
{ id: "zh-CN-XiaoxiaoNeural", name: "晓晓 (女声-活泼)" },
{ id: "zh-CN-XiaoyiNeural", name: "晓伊 (女声-温柔)" },
],
"en-US": [
{ id: "en-US-GuyNeural", name: "Guy (Male)" },
{ id: "en-US-JennyNeural", name: "Jenny (Female)" },
],
"ja-JP": [
{ id: "ja-JP-KeitaNeural", name: "圭太 (男声)" },
{ id: "ja-JP-NanamiNeural", name: "七海 (女声)" },
],
"ko-KR": [
{ id: "ko-KR-InJoonNeural", name: "인준 (男声)" },
{ id: "ko-KR-SunHiNeural", name: "선히 (女声)" },
],
"fr-FR": [
{ id: "fr-FR-HenriNeural", name: "Henri (Male)" },
{ id: "fr-FR-DeniseNeural", name: "Denise (Female)" },
],
"de-DE": [
{ id: "de-DE-ConradNeural", name: "Conrad (Male)" },
{ id: "de-DE-KatjaNeural", name: "Katja (Female)" },
],
"es-ES": [
{ id: "es-ES-AlvaroNeural", name: "Álvaro (Male)" },
{ id: "es-ES-ElviraNeural", name: "Elvira (Female)" },
],
"ru-RU": [
{ id: "ru-RU-DmitryNeural", name: "Дмитрий (Male)" },
{ id: "ru-RU-SvetlanaNeural", name: "Светлана (Female)" },
],
"it-IT": [
{ id: "it-IT-DiegoNeural", name: "Diego (Male)" },
{ id: "it-IT-ElsaNeural", name: "Elsa (Female)" },
],
"pt-BR": [
{ id: "pt-BR-AntonioNeural", name: "Antonio (Male)" },
{ id: "pt-BR-FranciscaNeural", name: "Francisca (Female)" },
],
};
const LANG_TO_LOCALE: Record<string, string> = {
"中文": "zh-CN",
"English": "en-US",
"日本語": "ja-JP",
"한국어": "ko-KR",
"Français": "fr-FR",
"Deutsch": "de-DE",
"Español": "es-ES",
"Русский": "ru-RU",
"Italiano": "it-IT",
"Português": "pt-BR",
};
const VOICES = [
{ id: "zh-CN-YunxiNeural", name: "云溪 (男声-年轻)" },
{ id: "zh-CN-YunjianNeural", name: "云健 (男声-新闻)" },
{ id: "zh-CN-YunyangNeural", name: "云扬 (男声-专业)" },
{ id: "zh-CN-XiaoxiaoNeural", name: "晓晓 (女声-活泼)" },
{ id: "zh-CN-XiaoyiNeural", name: "晓伊 (女声-温柔)" },
];
const FIXED_REF_TEXT =
"其实生活中有许多美好的瞬间,比如清晨的阳光,或者一杯温热的清茶。希望这次生成的音色能够自然、流畅,完美还原出我最真实的声音状态。";
@@ -64,33 +121,38 @@ interface RefAudio {
created_at: number;
}
import type { Material } from "@/shared/types/material";
export const useHomeController = () => {
const apiBase = getApiBaseUrl();
const [selectedMaterial, setSelectedMaterial] = useState<string>("");
const [selectedMaterials, setSelectedMaterials] = useState<string[]>([]);
const [previewMaterial, setPreviewMaterial] = useState<string | null>(null);
const [text, setText] = useState<string>("");
const [voice, setVoice] = useState<string>("zh-CN-YunxiNeural");
const [textLang, setTextLang] = useState<string>("zh-CN");
// 使用全局任务状态
const { currentTask, isGenerating, startTask } = useTask();
const prevIsGenerating = useRef(isGenerating);
const [generatedVideo, setGeneratedVideo] = useState<string | null>(null);
const [selectedVideoId, setSelectedVideoId] = useState<string | null>(null);
// 字幕和标题相关状态
const [videoTitle, setVideoTitle] = useState<string>("");
const [enableSubtitles, setEnableSubtitles] = useState<boolean>(true);
const [selectedSubtitleStyleId, setSelectedSubtitleStyleId] = useState<string>("");
const [selectedTitleStyleId, setSelectedTitleStyleId] = useState<string>("");
const [subtitleFontSize, setSubtitleFontSize] = useState<number>(60);
const [titleFontSize, setTitleFontSize] = useState<number>(90);
const [subtitleFontSize, setSubtitleFontSize] = useState<number>(80);
const [titleFontSize, setTitleFontSize] = useState<number>(120);
const [subtitleSizeLocked, setSubtitleSizeLocked] = useState<boolean>(false);
const [titleSizeLocked, setTitleSizeLocked] = useState<boolean>(false);
const [titleTopMargin, setTitleTopMargin] = useState<number>(62);
const [subtitleBottomMargin, setSubtitleBottomMargin] = useState<number>(80);
const [showStylePreview, setShowStylePreview] = useState<boolean>(false);
const [materialDimensions, setMaterialDimensions] = useState<{ width: number; height: number } | null>(null);
const [previewContainerWidth, setPreviewContainerWidth] = useState<number>(0);
// 背景音乐相关状态
const [selectedBgmId, setSelectedBgmId] = useState<string>("");
@@ -105,9 +167,11 @@ export const useHomeController = () => {
// 音频预览与重命名状态
const [editingAudioId, setEditingAudioId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const [editingMaterialId, setEditingMaterialId] = useState<string | null>(null);
const [editMaterialName, setEditMaterialName] = useState("");
const bgmItemRefs = useRef<Record<string, HTMLDivElement | null>>({});
const bgmListContainerRef = useRef<HTMLDivElement | null>(null);
const titlePreviewContainerRef = useRef<HTMLDivElement | null>(null);
const materialItemRefs = useRef<Record<string, HTMLDivElement | null>>({});
const videoItemRefs = useRef<Record<string, HTMLDivElement | null>>({});
@@ -134,14 +198,54 @@ export const useHomeController = () => {
await api.put(`/api/ref-audios/${encodeURIComponent(audioId)}`, { new_name: editName });
setEditingAudioId(null);
fetchRefAudios(); // 刷新列表
} catch (err: any) {
alert("重命名失败: " + err);
} catch (err: unknown) {
toast.error("重命名失败: " + String(err));
}
};
const startMaterialEditing = (material: Material, e: React.MouseEvent) => {
e.stopPropagation();
setEditingMaterialId(material.id);
const nameWithoutExt = material.name.substring(0, material.name.lastIndexOf("."));
setEditMaterialName(nameWithoutExt || material.name);
};
const cancelMaterialEditing = (e: React.MouseEvent) => {
e.stopPropagation();
setEditingMaterialId(null);
setEditMaterialName("");
};
const saveMaterialEditing = async (materialId: string, e: React.MouseEvent) => {
e.stopPropagation();
if (!editMaterialName.trim()) return;
try {
const { data: res } = await api.put<ApiResponse<{ id: string }>>(
`/api/materials/${encodeURIComponent(materialId)}`,
{ new_name: editMaterialName.trim() }
);
const payload = unwrap(res);
if (selectedMaterials.includes(materialId) && payload?.id) {
setSelectedMaterials((prev) => prev.map((x) => (x === materialId ? payload.id : x)));
}
setEditingMaterialId(null);
setEditMaterialName("");
fetchMaterials();
} catch (err: unknown) {
const axiosErr = err as { response?: { data?: { message?: string } }; message?: string };
const errorMsg = axiosErr.response?.data?.message || axiosErr.message || String(err);
toast.error(`重命名失败: ${errorMsg}`);
}
};
// AI 生成标题标签
const [isGeneratingMeta, setIsGeneratingMeta] = useState(false);
// AI 多语言翻译
const [isTranslating, setIsTranslating] = useState(false);
const [originalText, setOriginalText] = useState<string | null>(null);
// 在线录音相关
const [isRecording, setIsRecording] = useState(false);
const [recordedBlob, setRecordedBlob] = useState<Blob | null>(null);
@@ -158,19 +262,26 @@ export const useHomeController = () => {
// 获取存储 key 的前缀(登录用户使用 userId未登录使用 guest
const storageKey = userId || "guest";
// 使用共用的发布预加载 hook
const { updatePrefetch: updatePublishPrefetch } = usePublishPrefetch();
const {
materials,
fetchError,
isFetching,
lastMaterialCount,
isUploading,
uploadProgress,
uploadError,
setUploadError,
fetchMaterials,
toggleMaterial,
reorderMaterials,
deleteMaterial,
handleUpload,
} = useMaterials({
selectedMaterial,
setSelectedMaterial,
selectedMaterials,
setSelectedMaterials,
});
const {
@@ -180,7 +291,7 @@ export const useHomeController = () => {
refreshTitleStyles,
} = useTitleSubtitleStyles({
isAuthLoading,
storageKey,
setSelectedSubtitleStyleId,
setSelectedTitleStyleId,
});
@@ -206,7 +317,7 @@ export const useHomeController = () => {
bgmError,
fetchBgmList,
} = useBgm({
storageKey,
selectedBgmId,
setSelectedBgmId,
});
@@ -229,13 +340,48 @@ export const useHomeController = () => {
fetchGeneratedVideos,
deleteVideo,
} = useGeneratedVideos({
storageKey,
selectedVideoId,
setSelectedVideoId,
setGeneratedVideo,
resolveMediaUrl,
});
useEffect(() => {
if (isAuthLoading || !userId) return;
let active = true;
const prefetchAccounts = async () => {
try {
const { data: res } = await api.get<ApiResponse<{ accounts: PublishAccount[] }>>(
"/api/publish/accounts"
);
if (!active) return;
const payload = unwrap(res);
updatePublishPrefetch({ accounts: payload.accounts || [] });
} catch (error) {
console.error("预取账号失败:", error);
}
};
void prefetchAccounts();
return () => {
active = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAuthLoading, userId]);
useEffect(() => {
if (generatedVideos.length === 0) return;
const prefetched = generatedVideos.map((video) => ({
id: video.id,
name: formatDate(video.created_at) + ` (${video.size_mb.toFixed(1)}MB)`,
path: video.path.startsWith("/") ? video.path.slice(1) : video.path,
}));
updatePublishPrefetch({ videos: prefetched });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [generatedVideos]);
const { isRestored } = useHomePersistence({
isAuthLoading,
storageKey,
@@ -243,14 +389,14 @@ export const useHomeController = () => {
setText,
videoTitle,
setVideoTitle,
enableSubtitles,
setEnableSubtitles,
ttsMode,
setTtsMode,
voice,
setVoice,
selectedMaterial,
setSelectedMaterial,
textLang,
setTextLang,
selectedMaterials,
setSelectedMaterials,
selectedSubtitleStyleId,
setSelectedSubtitleStyleId,
selectedTitleStyleId,
@@ -261,6 +407,10 @@ export const useHomeController = () => {
setTitleFontSize,
setSubtitleSizeLocked,
setTitleSizeLocked,
titleTopMargin,
setTitleTopMargin,
subtitleBottomMargin,
setSubtitleBottomMargin,
selectedBgmId,
setSelectedBgmId,
bgmVolume,
@@ -295,10 +445,24 @@ export const useHomeController = () => {
refreshTitleStyles(),
fetchBgmList(),
]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAuthLoading]);
// 监听任务完成,自动刷新视频列表并选中最新
useEffect(() => {
const material = materials.find((item) => item.id === selectedMaterial);
if (prevIsGenerating.current && !isGenerating) {
if (currentTask?.status === "completed") {
void fetchGeneratedVideos("__latest__");
} else {
void fetchGeneratedVideos();
}
}
prevIsGenerating.current = isGenerating;
}, [isGenerating, currentTask, fetchGeneratedVideos]);
useEffect(() => {
const firstSelected = selectedMaterials[0];
const material = materials.find((item) => item.id === firstSelected);
if (!material?.path) {
setMaterialDimensions(null);
return;
@@ -338,24 +502,8 @@ export const useHomeController = () => {
video.removeEventListener("loadedmetadata", handleLoaded);
video.removeEventListener("error", handleError);
};
}, [materials, selectedMaterial]);
}, [materials, selectedMaterials]);
useEffect(() => {
if (!titlePreviewContainerRef.current) return;
const container = titlePreviewContainerRef.current;
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
setPreviewContainerWidth(entry.contentRect.width);
}
});
resizeObserver.observe(container);
return () => {
resizeObserver.disconnect();
};
}, []);
useEffect(() => {
if (subtitleSizeLocked || subtitleStyles.length === 0) return;
@@ -377,16 +525,8 @@ export const useHomeController = () => {
}
}, [titleStyles, selectedTitleStyleId, titleSizeLocked]);
useEffect(() => {
if (!enableBgm || selectedBgmId || bgmList.length === 0) return;
const savedBgmId = localStorage.getItem(`vigent_${storageKey}_bgmId`);
const savedItem = savedBgmId && bgmList.find((item) => item.id === savedBgmId);
if (savedItem) {
setSelectedBgmId(savedBgmId);
return;
}
setSelectedBgmId(bgmList[0].id);
}, [enableBgm, selectedBgmId, bgmList, storageKey, setSelectedBgmId]);
// 移除重复的 BGM 持久化恢复逻辑 (已统一移动到 useHomePersistence 中)
// useEffect(() => { ... })
useEffect(() => {
if (!selectedBgmId) return;
@@ -398,12 +538,30 @@ export const useHomeController = () => {
}, [selectedBgmId, bgmList]);
useEffect(() => {
if (!selectedMaterial) return;
const target = materialItemRefs.current[selectedMaterial];
const firstSelected = selectedMaterials[0];
if (!firstSelected) return;
const target = materialItemRefs.current[firstSelected];
if (target) {
target.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
}, [selectedMaterial, materials]);
}, [selectedMaterials, materials]);
// 【修复】历史视频默认选中逻辑
// 当持久化恢复完成,且列表加载完毕,如果没选中任何视频,默认选中第一个
useEffect(() => {
if (isRestored && generatedVideos.length > 0 && !selectedVideoId) {
const firstId = generatedVideos[0].id;
setSelectedVideoId(firstId);
setGeneratedVideo(resolveMediaUrl(generatedVideos[0].path));
}
}, [isRestored, generatedVideos, selectedVideoId, setSelectedVideoId, setGeneratedVideo, resolveMediaUrl]);
// 【修复】BGM 默认选中逻辑
useEffect(() => {
if (isRestored && bgmList.length > 0 && !selectedBgmId && enableBgm) {
setSelectedBgmId(bgmList[0].id);
}
}, [isRestored, bgmList, selectedBgmId, enableBgm, setSelectedBgmId]);
useEffect(() => {
if (!selectedVideoId) return;
@@ -468,7 +626,7 @@ export const useHomeController = () => {
setRecordingTime((prev) => prev + 1);
}, 1000);
} catch (err) {
alert("无法访问麦克风,请检查权限设置");
toast.error("无法访问麦克风,请检查权限设置");
console.error(err);
}
};
@@ -506,46 +664,100 @@ export const useHomeController = () => {
// AI 生成标题和标签
const handleGenerateMeta = async () => {
if (!text.trim()) {
alert("请先输入口播文案");
toast.error("请先输入口播文案");
return;
}
setIsGeneratingMeta(true);
try {
const { data } = await api.post("/api/ai/generate-meta", { text: text.trim() });
const { data: res } = await api.post<ApiResponse<{ title?: string; tags?: string[] }>>(
"/api/ai/generate-meta",
{ text: text.trim() }
);
const payload = unwrap(res);
// 更新首页标题
const nextTitle = clampTitle(data.title || "");
const nextTitle = clampTitle(payload.title || "");
titleInput.commitValue(nextTitle);
// 同步到发布页 localStorage
localStorage.setItem(`vigent_${storageKey}_publish_tags`, JSON.stringify(data.tags || []));
} catch (err: any) {
localStorage.setItem(`vigent_${storageKey}_publish_tags`, JSON.stringify(payload.tags || []));
} catch (err: unknown) {
console.error("AI generate meta failed:", err);
const errorMsg = err.response?.data?.detail || err.message || String(err);
alert(`AI 生成失败: ${errorMsg}`);
const axiosErr = err as { response?: { data?: { message?: string } }; message?: string };
const errorMsg = axiosErr.response?.data?.message || axiosErr.message || String(err);
toast.error(`AI 生成失败: ${errorMsg}`);
} finally {
setIsGeneratingMeta(false);
}
};
// AI 多语言翻译
const handleTranslate = async (targetLang: string) => {
if (!text.trim()) {
toast.error("请先输入口播文案");
return;
}
// 首次翻译时保存原文
if (originalText === null) {
setOriginalText(text);
}
setIsTranslating(true);
try {
const { data: res } = await api.post<ApiResponse<{ translated_text: string }>>(
"/api/ai/translate",
{ text: text.trim(), target_lang: targetLang }
);
const payload = unwrap(res);
setText(payload.translated_text || "");
// 根据翻译目标语言更新 textLang 并自动切换声音
const locale = LANG_TO_LOCALE[targetLang] || "zh-CN";
setTextLang(locale);
if (ttsMode === "edgetts") {
const langVoices = VOICES[locale] || VOICES["zh-CN"];
setVoice(langVoices[0].id);
}
} catch (err: unknown) {
console.error("AI translate failed:", err);
const axiosErr = err as { response?: { data?: { message?: string } }; message?: string };
const errorMsg = axiosErr.response?.data?.message || axiosErr.message || String(err);
toast.error(`AI 翻译失败: ${errorMsg}`);
} finally {
setIsTranslating(false);
}
};
const handleRestoreOriginal = () => {
if (originalText !== null) {
setText(originalText);
setOriginalText(null);
setTextLang("zh-CN");
if (ttsMode === "edgetts") {
setVoice(VOICES["zh-CN"][0].id);
}
}
};
// 生成视频
const handleGenerate = async () => {
if (!selectedMaterial || !text.trim()) {
alert("请先选择素材并填写文案");
if (selectedMaterials.length === 0 || !text.trim()) {
toast.error("请先选择素材并填写文案");
return;
}
// 声音克隆模式校验
if (ttsMode === "voiceclone") {
if (!selectedRefAudio) {
alert("请选择或上传参考音频");
toast.error("请选择或上传参考音频");
return;
}
}
if (enableBgm && !selectedBgmId) {
alert("请选择背景音乐");
toast.error("请选择背景音乐");
return;
}
@@ -553,26 +765,33 @@ export const useHomeController = () => {
try {
// 查找选中的素材对象以获取路径
const materialObj = materials.find((m) => m.id === selectedMaterial);
if (!materialObj) {
alert("素材数据异常");
const firstMaterialObj = materials.find((m) => m.id === selectedMaterials[0]);
if (!firstMaterialObj) {
toast.error("素材数据异常");
return;
}
// 构建请求参数
const payload: Record<string, any> = {
material_path: materialObj.path,
const payload: Record<string, unknown> = {
material_path: firstMaterialObj.path,
text: text,
tts_mode: ttsMode,
title: videoTitle.trim() || undefined,
enable_subtitles: enableSubtitles,
enable_subtitles: true,
};
if (enableSubtitles && selectedSubtitleStyleId) {
// 多素材
if (selectedMaterials.length > 1) {
payload.material_paths = selectedMaterials
.map((id) => materials.find((x) => x.id === id)?.path)
.filter((path): path is string => !!path);
}
if (selectedSubtitleStyleId) {
payload.subtitle_style_id = selectedSubtitleStyleId;
}
if (enableSubtitles && subtitleFontSize) {
if (subtitleFontSize) {
payload.subtitle_font_size = Math.round(subtitleFontSize);
}
@@ -584,11 +803,19 @@ export const useHomeController = () => {
payload.title_font_size = Math.round(titleFontSize);
}
if (videoTitle.trim()) {
payload.title_top_margin = Math.round(titleTopMargin);
}
payload.subtitle_bottom_margin = Math.round(subtitleBottomMargin);
if (enableBgm && selectedBgmId) {
payload.bgm_id = selectedBgmId;
payload.bgm_volume = bgmVolume;
}
payload.language = textLang;
if (ttsMode === "edgetts") {
payload.voice = voice;
} else {
@@ -597,9 +824,12 @@ export const useHomeController = () => {
}
// 创建生成任务
const { data } = await api.post("/api/videos/generate", payload);
const { data: res } = await api.post<ApiResponse<{ task_id: string }>>(
"/api/videos/generate",
payload
);
const taskId = data.task_id;
const taskId = unwrap(res).task_id;
// 保存任务ID到 localStorage以便页面切换后恢复
localStorage.setItem(`vigent_${storageKey}_current_task`, taskId);
@@ -644,6 +874,8 @@ export const useHomeController = () => {
setPreviewMaterial,
materials,
fetchError,
isFetching,
lastMaterialCount,
isUploading,
uploadProgress,
uploadError,
@@ -651,15 +883,26 @@ export const useHomeController = () => {
fetchMaterials,
deleteMaterial,
handleUpload,
selectedMaterial,
setSelectedMaterial,
selectedMaterials,
toggleMaterial,
reorderMaterials,
handlePreviewMaterial,
editingMaterialId,
editMaterialName,
setEditMaterialName,
startMaterialEditing,
saveMaterialEditing,
cancelMaterialEditing,
text,
setText,
extractModalOpen,
setExtractModalOpen,
handleGenerateMeta,
isGeneratingMeta,
handleTranslate,
isTranslating,
originalText,
handleRestoreOriginal,
showStylePreview,
setShowStylePreview,
videoTitle,
@@ -676,19 +919,20 @@ export const useHomeController = () => {
subtitleFontSize,
setSubtitleFontSize,
setSubtitleSizeLocked,
enableSubtitles,
setEnableSubtitles,
titleTopMargin,
setTitleTopMargin,
subtitleBottomMargin,
setSubtitleBottomMargin,
resolveAssetUrl,
getFontFormat,
buildTextShadow,
previewContainerWidth,
materialDimensions,
titlePreviewContainerRef,
ttsMode,
setTtsMode,
voices: VOICES,
voices: VOICES[textLang] || VOICES["zh-CN"],
voice,
setVoice,
textLang,
refAudios,
selectedRefAudio,
handleSelectRefAudio,

View File

@@ -17,14 +17,14 @@ interface UseHomePersistenceOptions {
setText: React.Dispatch<React.SetStateAction<string>>;
videoTitle: string;
setVideoTitle: React.Dispatch<React.SetStateAction<string>>;
enableSubtitles: boolean;
setEnableSubtitles: React.Dispatch<React.SetStateAction<boolean>>;
ttsMode: 'edgetts' | 'voiceclone';
setTtsMode: React.Dispatch<React.SetStateAction<'edgetts' | 'voiceclone'>>;
voice: string;
setVoice: React.Dispatch<React.SetStateAction<string>>;
selectedMaterial: string;
setSelectedMaterial: React.Dispatch<React.SetStateAction<string>>;
textLang: string;
setTextLang: React.Dispatch<React.SetStateAction<string>>;
selectedMaterials: string[];
setSelectedMaterials: React.Dispatch<React.SetStateAction<string[]>>;
selectedSubtitleStyleId: string;
setSelectedSubtitleStyleId: React.Dispatch<React.SetStateAction<string>>;
selectedTitleStyleId: string;
@@ -35,6 +35,10 @@ interface UseHomePersistenceOptions {
setTitleFontSize: React.Dispatch<React.SetStateAction<number>>;
setSubtitleSizeLocked: React.Dispatch<React.SetStateAction<boolean>>;
setTitleSizeLocked: React.Dispatch<React.SetStateAction<boolean>>;
titleTopMargin: number;
setTitleTopMargin: React.Dispatch<React.SetStateAction<number>>;
subtitleBottomMargin: number;
setSubtitleBottomMargin: React.Dispatch<React.SetStateAction<number>>;
selectedBgmId: string;
setSelectedBgmId: React.Dispatch<React.SetStateAction<string>>;
bgmVolume: number;
@@ -53,14 +57,14 @@ export const useHomePersistence = ({
setText,
videoTitle,
setVideoTitle,
enableSubtitles,
setEnableSubtitles,
ttsMode,
setTtsMode,
voice,
setVoice,
selectedMaterial,
setSelectedMaterial,
textLang,
setTextLang,
selectedMaterials,
setSelectedMaterials,
selectedSubtitleStyleId,
setSelectedSubtitleStyleId,
selectedTitleStyleId,
@@ -71,6 +75,10 @@ export const useHomePersistence = ({
setTitleFontSize,
setSubtitleSizeLocked,
setTitleSizeLocked,
titleTopMargin,
setTitleTopMargin,
subtitleBottomMargin,
setSubtitleBottomMargin,
selectedBgmId,
setSelectedBgmId,
bgmVolume,
@@ -88,9 +96,9 @@ export const useHomePersistence = ({
const savedText = localStorage.getItem(`vigent_${storageKey}_text`);
const savedTitle = localStorage.getItem(`vigent_${storageKey}_title`);
const savedSubtitles = localStorage.getItem(`vigent_${storageKey}_subtitles`);
const savedTtsMode = localStorage.getItem(`vigent_${storageKey}_ttsMode`);
const savedVoice = localStorage.getItem(`vigent_${storageKey}_voice`);
const savedTextLang = localStorage.getItem(`vigent_${storageKey}_textLang`);
const savedMaterial = localStorage.getItem(`vigent_${storageKey}_material`);
const savedSubtitleStyle = localStorage.getItem(`vigent_${storageKey}_subtitleStyle`);
const savedTitleStyle = localStorage.getItem(`vigent_${storageKey}_titleStyle`);
@@ -100,14 +108,28 @@ export const useHomePersistence = ({
const savedSelectedVideoId = localStorage.getItem(`vigent_${storageKey}_selectedVideoId`);
const savedBgmVolume = localStorage.getItem(`vigent_${storageKey}_bgmVolume`);
const savedEnableBgm = localStorage.getItem(`vigent_${storageKey}_enableBgm`);
const savedTitleTopMargin = localStorage.getItem(`vigent_${storageKey}_titleTopMargin`);
const savedSubtitleBottomMargin = localStorage.getItem(`vigent_${storageKey}_subtitleBottomMargin`);
setText(savedText || "大家好,欢迎来到我的频道,今天给大家分享一些有趣的内容。");
setVideoTitle(savedTitle ? clampTitle(savedTitle) : "");
setEnableSubtitles(savedSubtitles !== null ? savedSubtitles === 'true' : true);
setTtsMode((savedTtsMode as 'edgetts' | 'voiceclone') || 'edgetts');
setVoice(savedVoice || "zh-CN-YunxiNeural");
if (savedTextLang) setTextLang(savedTextLang);
if (savedMaterial) setSelectedMaterial(savedMaterial);
if (savedMaterial) {
try {
const parsed = JSON.parse(savedMaterial);
if (Array.isArray(parsed)) {
setSelectedMaterials(parsed);
} else {
setSelectedMaterials([savedMaterial]);
}
} catch {
// 旧格式: 单字符串
setSelectedMaterials([savedMaterial]);
}
}
if (savedSubtitleStyle) setSelectedSubtitleStyleId(savedSubtitleStyle);
if (savedTitleStyle) setSelectedTitleStyleId(savedTitleStyle);
@@ -132,22 +154,34 @@ export const useHomePersistence = ({
if (savedEnableBgm !== null) setEnableBgm(savedEnableBgm === 'true');
if (savedSelectedVideoId) setSelectedVideoId(savedSelectedVideoId);
if (savedTitleTopMargin) {
const parsed = parseInt(savedTitleTopMargin, 10);
if (!Number.isNaN(parsed)) setTitleTopMargin(parsed);
}
if (savedSubtitleBottomMargin) {
const parsed = parseInt(savedSubtitleBottomMargin, 10);
if (!Number.isNaN(parsed)) setSubtitleBottomMargin(parsed);
}
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsRestored(true);
}, [
isAuthLoading,
setBgmVolume,
setEnableBgm,
setEnableSubtitles,
setSelectedBgmId,
setSelectedMaterial,
setSelectedMaterials,
setSelectedSubtitleStyleId,
setSelectedTitleStyleId,
setSelectedVideoId,
setSubtitleFontSize,
setSubtitleSizeLocked,
setText,
setTextLang,
setTitleFontSize,
setTitleSizeLocked,
setTitleTopMargin,
setSubtitleBottomMargin,
setTtsMode,
setVideoTitle,
setVoice,
@@ -170,10 +204,6 @@ export const useHomePersistence = ({
return () => clearTimeout(timeout);
}, [videoTitle, storageKey, isRestored]);
useEffect(() => {
if (isRestored) localStorage.setItem(`vigent_${storageKey}_subtitles`, String(enableSubtitles));
}, [enableSubtitles, storageKey, isRestored]);
useEffect(() => {
if (isRestored) localStorage.setItem(`vigent_${storageKey}_ttsMode`, ttsMode);
}, [ttsMode, storageKey, isRestored]);
@@ -183,10 +213,14 @@ export const useHomePersistence = ({
}, [voice, storageKey, isRestored]);
useEffect(() => {
if (isRestored && selectedMaterial) {
localStorage.setItem(`vigent_${storageKey}_material`, selectedMaterial);
if (isRestored) localStorage.setItem(`vigent_${storageKey}_textLang`, textLang);
}, [textLang, storageKey, isRestored]);
useEffect(() => {
if (isRestored && selectedMaterials.length > 0) {
localStorage.setItem(`vigent_${storageKey}_material`, JSON.stringify(selectedMaterials));
}
}, [selectedMaterial, storageKey, isRestored]);
}, [selectedMaterials, storageKey, isRestored]);
useEffect(() => {
if (isRestored && selectedSubtitleStyleId) {
@@ -212,6 +246,18 @@ export const useHomePersistence = ({
}
}, [titleFontSize, storageKey, isRestored]);
useEffect(() => {
if (isRestored) {
localStorage.setItem(`vigent_${storageKey}_titleTopMargin`, String(titleTopMargin));
}
}, [titleTopMargin, storageKey, isRestored]);
useEffect(() => {
if (isRestored) {
localStorage.setItem(`vigent_${storageKey}_subtitleBottomMargin`, String(subtitleBottomMargin));
}
}, [subtitleBottomMargin, storageKey, isRestored]);
useEffect(() => {
if (isRestored) {
localStorage.setItem(`vigent_${storageKey}_bgmId`, selectedBgmId);

View File

@@ -1,25 +1,22 @@
import { useCallback, useState } from "react";
import api from "@/shared/api/axios";
interface Material {
id: string;
name: string;
scene: string;
size_mb: number;
path: string;
}
import { ApiResponse, unwrap } from "@/shared/api/types";
import { toast } from "sonner";
import type { Material } from "@/shared/types/material";
interface UseMaterialsOptions {
selectedMaterial: string;
setSelectedMaterial: React.Dispatch<React.SetStateAction<string>>;
selectedMaterials: string[];
setSelectedMaterials: React.Dispatch<React.SetStateAction<string[]>>;
}
export const useMaterials = ({
selectedMaterial,
setSelectedMaterial,
selectedMaterials,
setSelectedMaterials,
}: UseMaterialsOptions) => {
const [materials, setMaterials] = useState<Material[]>([]);
const [fetchError, setFetchError] = useState<string | null>(null);
const [isFetching, setIsFetching] = useState(false);
const [lastMaterialCount, setLastMaterialCount] = useState(0);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadError, setUploadError] = useState<string | null>(null);
@@ -27,44 +24,82 @@ export const useMaterials = ({
const fetchMaterials = useCallback(async () => {
try {
setFetchError(null);
setIsFetching(true);
const { data } = await api.get(`/api/materials?t=${new Date().getTime()}`);
const nextMaterials = data.materials || [];
const { data: res } = await api.get<ApiResponse<{ materials: Material[] }>>(
`/api/materials?t=${new Date().getTime()}`
);
const payload = unwrap(res);
const nextMaterials = payload.materials || [];
setMaterials(nextMaterials);
setLastMaterialCount(nextMaterials.length);
const nextSelected = nextMaterials.find((item: Material) => item.id === selectedMaterial)?.id
|| nextMaterials[0]?.id
|| "";
if (nextSelected !== selectedMaterial) {
setSelectedMaterial(nextSelected);
}
setSelectedMaterials((prev) => {
// 保留已选中且仍存在的
const existingIds = new Set(nextMaterials.map((m) => m.id));
const kept = prev.filter((id) => existingIds.has(id));
if (kept.length > 0) return kept;
// 否则默认选中第一个
return nextMaterials[0]?.id ? [nextMaterials[0].id] : [];
});
} catch (error) {
console.error("获取素材失败:", error);
setFetchError(String(error));
} finally {
setIsFetching(false);
}
}, [selectedMaterial, setSelectedMaterial]);
}, [setSelectedMaterials]);
const MAX_MATERIALS = 4;
const toggleMaterial = useCallback((id: string) => {
setSelectedMaterials((prev) => {
if (prev.includes(id)) {
// 不能取消最后一个
if (prev.length <= 1) return prev;
return prev.filter((x) => x !== id);
}
if (prev.length >= MAX_MATERIALS) return prev;
return [...prev, id];
});
}, [setSelectedMaterials]);
const reorderMaterials = useCallback((activeId: string, overId: string) => {
setSelectedMaterials((prev) => {
const oldIndex = prev.indexOf(activeId);
const newIndex = prev.indexOf(overId);
if (oldIndex === -1 || newIndex === -1) return prev;
const next = [...prev];
next.splice(oldIndex, 1);
next.splice(newIndex, 0, activeId);
return next;
});
}, [setSelectedMaterials]);
const deleteMaterial = useCallback(async (materialId: string) => {
if (!confirm("确定要删除这个素材吗?")) return;
try {
await api.delete(`/api/materials/${materialId}`);
fetchMaterials();
if (selectedMaterial === materialId) {
setSelectedMaterial("");
if (selectedMaterials.includes(materialId)) {
setSelectedMaterials((prev) => {
const next = prev.filter((id) => id !== materialId);
return next.length > 0 ? next : [];
});
}
} catch (error) {
alert("删除失败: " + error);
toast.error("删除失败: " + error);
}
}, [fetchMaterials, selectedMaterial, setSelectedMaterial]);
}, [fetchMaterials, selectedMaterials, setSelectedMaterials]);
const handleUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const validTypes = ['.mp4', '.mov', '.avi'];
const validTypes = ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.flv', '.wmv', '.m4v', '.ts', '.mts'];
const ext = file.name.toLowerCase().slice(file.name.lastIndexOf('.'));
if (!validTypes.includes(ext)) {
setUploadError('支持 MP4、MOV、AVI 格式');
setUploadError('支持的视频格式');
return;
}
@@ -88,11 +123,27 @@ export const useMaterials = ({
setUploadProgress(100);
setIsUploading(false);
fetchMaterials();
} catch (err: any) {
// 上传后重新拉列表并自动选中新素材
const { data: res } = await api.get<ApiResponse<{ materials: Material[] }>>(
`/api/materials?t=${new Date().getTime()}`
);
const payload = unwrap(res);
const nextMaterials = payload.materials || [];
setMaterials(nextMaterials);
setLastMaterialCount(nextMaterials.length);
// 找出新增的素材 ID 并自动选中
const oldIds = new Set(materials.map((m) => m.id));
const newIds = nextMaterials.filter((m) => !oldIds.has(m.id)).map((m) => m.id);
if (newIds.length > 0) {
setSelectedMaterials((prev) => [...prev, ...newIds]);
}
} catch (err: unknown) {
console.error("Upload failed:", err);
setIsUploading(false);
const errorMsg = err.response?.data?.detail || err.message || String(err);
const axiosErr = err as { response?: { data?: { message?: string } }; message?: string };
const errorMsg = axiosErr.response?.data?.message || axiosErr.message || String(err);
setUploadError(`上传失败: ${errorMsg}`);
}
@@ -102,11 +153,15 @@ export const useMaterials = ({
return {
materials,
fetchError,
isFetching,
lastMaterialCount,
isUploading,
uploadProgress,
uploadError,
setUploadError,
fetchMaterials,
toggleMaterial,
reorderMaterials,
deleteMaterial,
handleUpload,
};

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { BgmItem } from "@/features/home/model/useBgm";
import { toast } from "sonner";
interface RefAudio {
id: string;
@@ -64,12 +65,12 @@ export const useMediaPlayers = ({
const audioUrl = resolveMediaUrl(audio.path) || audio.path;
if (!audioUrl) {
alert("无法播放该参考音频");
toast.error("无法播放该参考音频");
return;
}
const player = new Audio(audioUrl);
player.onended = () => setPlayingAudioId(null);
player.play().catch((err) => alert("播放失败: " + err));
player.play().catch((err) => toast.error("播放失败: " + err));
audioPlayerRef.current = player;
setPlayingAudioId(audio.id);
}, [playingAudioId, resolveMediaUrl, stopAudio, stopBgm]);
@@ -81,7 +82,7 @@ export const useMediaPlayers = ({
const bgmUrl = resolveBgmUrl(bgm.id);
if (!bgmUrl) {
alert("无法播放该背景音乐");
toast.error("无法播放该背景音乐");
return;
}
@@ -96,7 +97,7 @@ export const useMediaPlayers = ({
const player = new Audio(bgmUrl);
player.volume = Math.max(0, Math.min(bgmVolume, 1));
player.onended = () => setPlayingBgmId(null);
player.play().catch((err) => alert("播放失败: " + err));
player.play().catch((err) => toast.error("播放失败: " + err));
bgmPlayerRef.current = player;
setPlayingBgmId(bgm.id);
}, [bgmVolume, playingBgmId, resolveBgmUrl, setEnableBgm, setSelectedBgmId, stopAudio, stopBgm]);

View File

@@ -1,5 +1,7 @@
import { useCallback, useState } from "react";
import api from "@/shared/api/axios";
import { ApiResponse, unwrap } from "@/shared/api/types";
import { toast } from "sonner";
interface RefAudio {
id: string;
@@ -29,8 +31,9 @@ export const useRefAudios = ({
const fetchRefAudios = useCallback(async () => {
try {
const { data } = await api.get('/api/ref-audios');
const items: RefAudio[] = data.items || [];
const { data: res } = await api.get<ApiResponse<{ items: RefAudio[] }>>('/api/ref-audios');
const payload = unwrap(res);
const items: RefAudio[] = payload.items || [];
items.sort((a, b) => b.created_at - a.created_at);
setRefAudios(items);
} catch (error) {
@@ -49,18 +52,20 @@ export const useRefAudios = ({
formData.append('file', file);
formData.append('ref_text', refTextInput);
const { data } = await api.post('/api/ref-audios', formData, {
const { data: res } = await api.post<ApiResponse<RefAudio>>('/api/ref-audios', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
const payload = unwrap(res);
await fetchRefAudios();
setSelectedRefAudio(data);
setRefText(data.ref_text);
setSelectedRefAudio(payload);
setRefText(payload.ref_text);
setIsUploadingRef(false);
} catch (err: any) {
} catch (err: unknown) {
console.error("Upload ref audio failed:", err);
setIsUploadingRef(false);
const errorMsg = err.response?.data?.detail || err.message || String(err);
const axiosErr = err as { response?: { data?: { message?: string } }; message?: string };
const errorMsg = axiosErr.response?.data?.message || axiosErr.message || String(err);
setUploadRefError(`上传失败: ${errorMsg}`);
}
}, [fetchRefAudios, fixedRefText, setRefText, setSelectedRefAudio]);
@@ -75,7 +80,7 @@ export const useRefAudios = ({
setRefText('');
}
} catch (error) {
alert("删除失败: " + error);
toast.error("删除失败: " + error);
}
}, [fetchRefAudios, selectedRefAudio, setRefText, setSelectedRefAudio]);

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from "react";
import api from "@/shared/api/axios";
import { ApiResponse, unwrap } from "@/shared/api/types";
export interface SubtitleStyleOption {
id: string;
@@ -33,14 +34,14 @@ export interface TitleStyleOption {
interface UseTitleSubtitleStylesOptions {
isAuthLoading: boolean;
storageKey: string;
setSelectedSubtitleStyleId: React.Dispatch<React.SetStateAction<string>>;
setSelectedTitleStyleId: React.Dispatch<React.SetStateAction<string>>;
}
export const useTitleSubtitleStyles = ({
isAuthLoading,
storageKey,
setSelectedSubtitleStyleId,
setSelectedTitleStyleId,
}: UseTitleSubtitleStylesOptions) => {
@@ -49,43 +50,47 @@ export const useTitleSubtitleStyles = ({
const refreshSubtitleStyles = useCallback(async () => {
try {
const { data } = await api.get('/api/assets/subtitle-styles');
const styles: SubtitleStyleOption[] = data.styles || [];
const { data: res } = await api.get<ApiResponse<{ styles: SubtitleStyleOption[] }>>(
'/api/assets/subtitle-styles'
);
const payload = unwrap(res);
const styles: SubtitleStyleOption[] = payload.styles || [];
setSubtitleStyles(styles);
const savedStyleId = localStorage.getItem(`vigent_${storageKey}_subtitleStyle`);
setSelectedSubtitleStyleId((prev) => {
if (prev && styles.some((s) => s.id === prev)) return prev;
if (savedStyleId && styles.some((s) => s.id === savedStyleId)) return savedStyleId;
const defaultStyle = styles.find((s) => s.is_default) || styles[0];
return defaultStyle?.id || "";
});
} catch (error) {
console.error("获取字幕样式失败:", error);
}
}, [setSelectedSubtitleStyleId, storageKey]);
}, [setSelectedSubtitleStyleId]);
const refreshTitleStyles = useCallback(async () => {
try {
const { data } = await api.get('/api/assets/title-styles');
const styles: TitleStyleOption[] = data.styles || [];
const { data: res } = await api.get<ApiResponse<{ styles: TitleStyleOption[] }>>(
'/api/assets/title-styles'
);
const payload = unwrap(res);
const styles: TitleStyleOption[] = payload.styles || [];
setTitleStyles(styles);
const savedStyleId = localStorage.getItem(`vigent_${storageKey}_titleStyle`);
setSelectedTitleStyleId((prev) => {
if (prev && styles.some((s) => s.id === prev)) return prev;
if (savedStyleId && styles.some((s) => s.id === savedStyleId)) return savedStyleId;
const defaultStyle = styles.find((s) => s.is_default) || styles[0];
return defaultStyle?.id || "";
});
} catch (error) {
console.error("获取标题样式失败:", error);
}
}, [setSelectedTitleStyleId, storageKey]);
}, [setSelectedTitleStyleId]);
useEffect(() => {
if (isAuthLoading) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
refreshSubtitleStyles();
// eslint-disable-next-line react-hooks/set-state-in-effect
refreshTitleStyles();
}, [isAuthLoading, refreshSubtitleStyles, refreshTitleStyles]);

View File

@@ -0,0 +1,226 @@
import { useEffect } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
interface SubtitleStyleOption {
id: string;
label: string;
font_family?: string;
font_file?: string;
font_size?: number;
highlight_color?: string;
normal_color?: string;
stroke_color?: string;
stroke_size?: number;
letter_spacing?: number;
bottom_margin?: number;
is_default?: boolean;
}
interface TitleStyleOption {
id: string;
label: string;
font_family?: string;
font_file?: string;
font_size?: number;
color?: string;
stroke_color?: string;
stroke_size?: number;
letter_spacing?: number;
font_weight?: number;
top_margin?: number;
is_default?: boolean;
}
interface FloatingStylePreviewProps {
onClose: () => void;
videoTitle: string;
titleStyles: TitleStyleOption[];
selectedTitleStyleId: string;
titleFontSize: number;
subtitleStyles: SubtitleStyleOption[];
selectedSubtitleStyleId: string;
subtitleFontSize: number;
titleTopMargin: number;
subtitleBottomMargin: number;
enableSubtitles: boolean;
resolveAssetUrl: (path?: string | null) => string | null;
getFontFormat: (fontFile?: string) => string;
buildTextShadow: (color: string, size: number) => string;
previewBaseWidth: number;
previewBaseHeight: number;
}
const DESKTOP_WIDTH = 280;
export function FloatingStylePreview({
onClose,
videoTitle,
titleStyles,
selectedTitleStyleId,
titleFontSize,
subtitleStyles,
selectedSubtitleStyleId,
subtitleFontSize,
titleTopMargin,
subtitleBottomMargin,
enableSubtitles,
resolveAssetUrl,
getFontFormat,
buildTextShadow,
previewBaseWidth,
previewBaseHeight,
}: FloatingStylePreviewProps) {
const isMobile = typeof window !== "undefined" && window.innerWidth < 640;
const windowWidth = isMobile
? Math.min(window.innerWidth - 32, 360)
: DESKTOP_WIDTH;
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
const previewScale = windowWidth / previewBaseWidth;
const previewHeight = previewBaseHeight * previewScale;
const activeSubtitleStyle = subtitleStyles.find((s) => s.id === selectedSubtitleStyleId)
|| subtitleStyles.find((s) => s.is_default)
|| subtitleStyles[0];
const activeTitleStyle = titleStyles.find((s) => s.id === selectedTitleStyleId)
|| titleStyles.find((s) => s.is_default)
|| titleStyles[0];
const previewTitleText = videoTitle.trim() || "这里是标题预览";
const subtitleHighlightText = "最近一个叫Cloudbot";
const subtitleNormalText = "的开源项目在GitHub上彻底火了";
const subtitleHighlightColor = activeSubtitleStyle?.highlight_color || "#FFE600";
const subtitleNormalColor = activeSubtitleStyle?.normal_color || "#FFFFFF";
const subtitleStrokeColor = activeSubtitleStyle?.stroke_color || "#000000";
const subtitleStrokeSize = activeSubtitleStyle?.stroke_size ?? 3;
const subtitleLetterSpacing = activeSubtitleStyle?.letter_spacing ?? 2;
const subtitleFontFamilyName = `SubtitlePreview-${activeSubtitleStyle?.id || "default"}`;
const subtitleFontUrl = activeSubtitleStyle?.font_file
? resolveAssetUrl(`fonts/${activeSubtitleStyle.font_file}`)
: null;
const titleColor = activeTitleStyle?.color || "#FFFFFF";
const titleStrokeColor = activeTitleStyle?.stroke_color || "#000000";
const titleStrokeSize = activeTitleStyle?.stroke_size ?? 8;
const titleLetterSpacing = activeTitleStyle?.letter_spacing ?? 4;
const titleFontWeight = activeTitleStyle?.font_weight ?? 900;
const titleFontFamilyName = `TitlePreview-${activeTitleStyle?.id || "default"}`;
const titleFontUrl = activeTitleStyle?.font_file
? resolveAssetUrl(`fonts/${activeTitleStyle.font_file}`)
: null;
const content = (
<div
style={{
position: "fixed",
left: "16px",
top: "16px",
width: `${windowWidth}px`,
zIndex: 150,
maxHeight: "calc(100dvh - 32px)",
overflow: "hidden",
}}
className="rounded-xl border border-white/20 bg-gray-900/95 backdrop-blur-md shadow-2xl"
>
{/* 标题栏 */}
<div
className="flex items-center justify-between px-3 py-2 border-b border-white/10 select-none"
>
<div className="flex items-center gap-2 text-sm text-gray-300">
<span></span>
</div>
<button
onClick={onClose}
className="p-1 rounded hover:bg-white/10 text-gray-400 hover:text-white transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
{/* 预览内容 */}
<div
className="relative overflow-hidden rounded-b-xl"
style={{ height: `${previewHeight}px` }}
>
{(titleFontUrl || subtitleFontUrl) && (
<style>{`
${titleFontUrl ? `@font-face { font-family: '${titleFontFamilyName}'; src: url('${titleFontUrl}') format('${getFontFormat(activeTitleStyle?.font_file)}'); font-weight: 400; font-style: normal; }` : ''}
${subtitleFontUrl ? `@font-face { font-family: '${subtitleFontFamilyName}'; src: url('${subtitleFontUrl}') format('${getFontFormat(activeSubtitleStyle?.font_file)}'); font-weight: 400; font-style: normal; }` : ''}
`}</style>
)}
<div className="absolute inset-0 opacity-20 bg-gradient-to-br from-purple-500/40 via-transparent to-pink-500/30" />
<div
className="absolute top-0 left-0"
style={{
width: `${previewBaseWidth}px`,
height: `${previewBaseHeight}px`,
transform: `scale(${previewScale})`,
transformOrigin: 'top left',
}}
>
<div
className="w-full text-center"
style={{
position: 'absolute',
top: `${titleTopMargin}px`,
left: 0,
right: 0,
color: titleColor,
fontSize: `${titleFontSize}px`,
fontWeight: titleFontWeight,
fontFamily: titleFontUrl
? `'${titleFontFamilyName}', "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif`
: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif',
textShadow: buildTextShadow(titleStrokeColor, titleStrokeSize),
letterSpacing: `${titleLetterSpacing}px`,
lineHeight: 1.2,
opacity: videoTitle.trim() ? 1 : 0.7,
padding: '0 5%',
}}
>
{previewTitleText}
</div>
<div
className="w-full text-center"
style={{
position: 'absolute',
bottom: `${subtitleBottomMargin}px`,
left: 0,
right: 0,
fontSize: `${subtitleFontSize}px`,
fontFamily: subtitleFontUrl
? `'${subtitleFontFamilyName}', "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif`
: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif',
textShadow: buildTextShadow(subtitleStrokeColor, subtitleStrokeSize),
letterSpacing: `${subtitleLetterSpacing}px`,
lineHeight: 1.35,
padding: '0 6%',
}}
>
{enableSubtitles ? (
<>
<span style={{ color: subtitleHighlightColor }}>{subtitleHighlightText}</span>
<span style={{ color: subtitleNormalColor }}>{subtitleNormalText}</span>
</>
) : (
<span className="text-gray-400 text-sm"></span>
)}
</div>
</div>
</div>
</div>
);
return createPortal(content, document.body);
}

Some files were not shown because too many files have changed in this diff Show More