Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e226224119 | ||
|
|
ee342cc40f | ||
|
|
1a291a03b8 | ||
|
|
1e52346eb4 | ||
|
|
945262a7fc | ||
|
|
be6a3436bb |
17
.gitignore
vendored
17
.gitignore
vendored
@@ -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
181
Docs/BACKEND_DEV.md
Normal 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`。
|
||||
@@ -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,73 @@ 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` 支持以下可选字段:
|
||||
|
||||
- `tts_mode`: TTS 模式 (`edgetts` / `voiceclone`)
|
||||
- `voice`: EdgeTTS 音色 ID(edgetts 模式)
|
||||
- `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 +183,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` 注册路由。
|
||||
|
||||
### 添加定时任务
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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 部署指南
|
||||
|
||||
@@ -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) - 字幕功能部署指南
|
||||
|
||||
@@ -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 部署指南
|
||||
|
||||
@@ -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) - 认证系统部署指南
|
||||
|
||||
|
||||
@@ -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
168
Docs/DevLogs/Day18.md
Normal 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
|
||||
- 上传前转码为兼容 MP4(H.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
485
Docs/DevLogs/Day19.md
Normal 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
103
Docs/DevLogs/Day20.md
Normal 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` 等模块,确认逻辑健壮,无类似回归风险。
|
||||
317
Docs/DevLogs/Day21.md
Normal file
317
Docs/DevLogs/Day21.md
Normal file
@@ -0,0 +1,317 @@
|
||||
## 🐛 缺陷修复:视频生成与持久化回归 (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 处 import(layout.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
|
||||
```
|
||||
@@ -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` - 包含检查清单的最终完善版
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,22 +2,68 @@
|
||||
|
||||
## 目录结构
|
||||
|
||||
采用轻量 FSD(Feature-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/ # 全局 Context(Auth、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`:全局 Context(AuthContext / 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'`
|
||||
|
||||
@@ -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 (预设音色) / 声音克隆 (自定义音色) 切换。
|
||||
|
||||
@@ -52,6 +52,9 @@ cd /home/rongye/ProgramFiles/ViGent2/remotion
|
||||
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 预编译渲染脚本 (生产环境必须)
|
||||
npm run build:render
|
||||
```
|
||||
|
||||
### 步骤 3: 重启后端服务
|
||||
|
||||
@@ -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]
|
||||
> 请确认以上计划是否符合你的需求,有任何需要调整的地方请告诉我。
|
||||
@@ -1,8 +1,8 @@
|
||||
# ViGent2 开发任务清单 (Task Log)
|
||||
|
||||
**项目**: ViGent2 数字人口播视频生成系统
|
||||
**进度**: 100% (Day 17 - 前端重构与体验优化)
|
||||
**更新时间**: 2026-02-04
|
||||
**项目**: ViGent2 数字人口播视频生成系统
|
||||
**进度**: 100% (Day 21 - 缺陷修复与持久化回归治理)
|
||||
**更新时间**: 2026-02-08
|
||||
|
||||
---
|
||||
|
||||
@@ -10,29 +10,73 @@
|
||||
|
||||
> 这里记录了每一天的核心开发内容与 milestone。
|
||||
|
||||
### Day 17: 前端重构与体验优化 (Current) 🚀
|
||||
- [x] **UI 组件拆分**: 首页拆分为独立组件,降低 `page.tsx` 复杂度。
|
||||
- [x] **轻量 FSD 迁移**: `app` 页面轻量化,逻辑集中到 `features/*/model`,通用能力下沉 `shared/*`。
|
||||
- [x] **Controller Hooks**: Home/Publish 页面逻辑集中到 Controller Hook,Page 仅组合渲染。
|
||||
- [x] **通用工具抽取**: `media.ts` 统一 API Base / URL / 日期格式化。
|
||||
- [x] **交互优化**: 选择项持久化、列表内定位、刷新回顶部、最新作品优先预览。
|
||||
- [x] **发布页改造**: 作品列表卡片化 + 搜索 + 预览弹窗。
|
||||
- [x] **预览体验**: 预览弹窗统一头部样式与提示文案。
|
||||
- [x] **预览一致性**: 标题/字幕预览按素材分辨率缩放。
|
||||
- [x] **标题同步与限制**: 片头标题同步发布标题,输入法合成态兼容,限制 15 字。
|
||||
- [x] **样式默认与持久化**: 默认样式与字号调整,刷新保留用户选择。
|
||||
- [x] **性能微优化**: 列表渲染优化 + 并行请求 + localStorage 防抖。
|
||||
- [x] **资源能力**: 字体/BGM 资源库 + `/api/assets` 接入。
|
||||
- [x] **音频与字幕修复**: BGM 混音稳定性与字幕断句优化。
|
||||
- [x] **持久化修复**: 接入 `useHomePersistence`,恢复 `isRestored` 逻辑并通过构建。
|
||||
- [x] **预览与选择修复**: 发布预览兼容签名 URL,音频试听路径解析,素材/BGM 回退有效项。
|
||||
- [x] **体验细节优化**: 录音预览 URL 回收,预览弹窗滚动恢复,全局任务提示挂载。
|
||||
|
||||
### Day 16: 深度性能优化
|
||||
- [x] **Qwen-TTS 加速**: 集成 Flash Attention 2,模型加载速度提升至 8.9s。
|
||||
- [x] **服务守护**: 开发 `Watchdog` 看门狗机制,自动监控并重启僵死服务。
|
||||
- [x] **LatentSync 性能确认**: 验证 DeepCache + 原生 Flash Attn 生效。
|
||||
- [x] **文档重构**: 全面更新 README、部署手册及后端文档。
|
||||
### Day 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 同步更新。
|
||||
|
||||
### 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 Hook,Page 仅组合渲染。
|
||||
- [x] **通用工具抽取**: `media.ts` 统一 API Base / URL / 日期格式化。
|
||||
- [x] **交互优化**: 选择项持久化、列表内定位、刷新回顶部、最新作品优先预览。
|
||||
- [x] **发布页改造**: 作品列表卡片化 + 搜索 + 预览弹窗。
|
||||
- [x] **预览体验**: 预览弹窗统一头部样式与提示文案。
|
||||
- [x] **预览一致性**: 标题/字幕预览按素材分辨率缩放。
|
||||
- [x] **标题同步与限制**: 片头标题同步发布标题,输入法合成态兼容,限制 15 字。
|
||||
- [x] **样式默认与持久化**: 默认样式与字号调整,刷新保留用户选择。
|
||||
- [x] **性能微优化**: 列表渲染优化 + 并行请求 + localStorage 防抖。
|
||||
- [x] **资源能力**: 字体/BGM 资源库 + `/api/assets` 接入。
|
||||
- [x] **音频与字幕修复**: BGM 混音稳定性与字幕断句优化。
|
||||
- [x] **持久化修复**: 接入 `useHomePersistence`,恢复 `isRestored` 逻辑并通过构建。
|
||||
- [x] **预览与选择修复**: 发布预览兼容签名 URL,音频试听路径解析,素材/BGM 回退有效项。
|
||||
- [x] **体验细节优化**: 录音预览 URL 回收,预览弹窗滚动恢复,全局任务提示挂载。
|
||||
|
||||
### Day 16: 深度性能优化
|
||||
- [x] **Qwen-TTS 加速**: 集成 Flash Attention 2,模型加载速度提升至 8.9s。
|
||||
- [x] **服务守护**: 开发 `Watchdog` 看门狗机制,自动监控并重启僵死服务。
|
||||
- [x] **LatentSync 性能确认**: 验证 DeepCache + 原生 Flash Attn 生效。
|
||||
- [x] **文档重构**: 全面更新 README、部署手册及后端文档。
|
||||
|
||||
### Day 15: 手机号认证迁移
|
||||
- [x] **认证系统升级**: 从邮箱迁移至 11 位手机号注册/登录。
|
||||
@@ -82,6 +126,7 @@
|
||||
### 🔴 优先待办
|
||||
- [ ] **批量生成架构**: 支持 Excel 导入,批量生产视频。
|
||||
- [ ] **定时任务后台化**: 迁移前端触发的定时发布到后端 APScheduler。
|
||||
- [ ] **发布任务恢复机制**: 发布任务化 + 状态持久化 + 前端断点恢复,解决刷新后状态丢失。
|
||||
|
||||
### 🔵 长期探索
|
||||
- [ ] **容器化交付**: 提供完整的 Docker Compose 一键部署包。
|
||||
@@ -97,7 +142,7 @@
|
||||
| **Web UI** | 100% | ✅ 稳定 (移动端适配) |
|
||||
| **唇形同步** | 100% | ✅ LatentSync 1.6 |
|
||||
| **TTS 配音** | 100% | ✅ EdgeTTS + Qwen3 |
|
||||
| **自动发布** | 100% | ✅ B站/抖音/小红书 |
|
||||
| **自动发布** | 100% | ✅ 抖音/微信视频号/B站/小红书 |
|
||||
| **用户认证** | 100% | ✅ 手机号 + JWT |
|
||||
| **部署运维** | 100% | ✅ PM2 + Watchdog |
|
||||
|
||||
@@ -105,5 +150,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)
|
||||
|
||||
39
README.md
39
README.md
@@ -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/ # 声音克隆服务
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
|
||||
|
||||
@@ -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)}")
|
||||
@@ -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()
|
||||
@@ -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 目录路径 (动态计算)"""
|
||||
|
||||
@@ -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(
|
||||
|
||||
26
backend/app/core/response.py
Normal file
26
backend/app/core/response.py
Normal 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
|
||||
@@ -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:
|
||||
|
||||
0
backend/app/modules/admin/__init__.py
Normal file
0
backend/app/modules/admin/__init__.py
Normal 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(
|
||||
0
backend/app/modules/ai/__init__.py
Normal file
0
backend/app/modules/ai/__init__.py
Normal 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,7 @@ class GenerateMetaResponse(BaseModel):
|
||||
tags: list[str]
|
||||
|
||||
|
||||
@router.post("/generate-meta", response_model=GenerateMetaResponse)
|
||||
@router.post("/generate-meta")
|
||||
async def generate_meta(req: GenerateMetaRequest):
|
||||
"""
|
||||
AI 生成视频标题和标签
|
||||
@@ -36,10 +37,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))
|
||||
0
backend/app/modules/assets/__init__.py
Normal file
0
backend/app/modules/assets/__init__.py
Normal 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()})
|
||||
0
backend/app/modules/auth/__init__.py
Normal file
0
backend/app/modules/auth/__init__.py
Normal 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())
|
||||
0
backend/app/modules/login_helper/__init__.py
Normal file
0
backend/app/modules/login_helper/__init__.py
Normal 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>"
|
||||
0
backend/app/modules/materials/__init__.py
Normal file
0
backend/app/modules/materials/__init__.py
Normal file
62
backend/app/modules/materials/router.py
Normal file
62
backend/app/modules/materials/router.py
Normal 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)}")
|
||||
14
backend/app/modules/materials/schemas.py
Normal file
14
backend/app/modules/materials/schemas.py
Normal 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
|
||||
296
backend/app/modules/materials/service.py
Normal file
296
backend/app/modules/materials/service.py
Normal 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,
|
||||
}
|
||||
0
backend/app/modules/publish/__init__.py
Normal file
0
backend/app/modules/publish/__init__.py
Normal 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")
|
||||
0
backend/app/modules/ref_audios/__init__.py
Normal file
0
backend/app/modules/ref_audios/__init__.py
Normal file
70
backend/app/modules/ref_audios/router.py
Normal file
70
backend/app/modules/ref_audios/router.py
Normal 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)}")
|
||||
19
backend/app/modules/ref_audios/schemas.py
Normal file
19
backend/app/modules/ref_audios/schemas.py
Normal 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
|
||||
269
backend/app/modules/ref_audios/service.py
Normal file
269
backend/app/modules/ref_audios/service.py
Normal 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}
|
||||
0
backend/app/modules/tools/__init__.py
Normal file
0
backend/app/modules/tools/__init__.py
Normal file
32
backend/app/modules/tools/router.py
Normal file
32
backend/app/modules/tools/router.py
Normal 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}")
|
||||
7
backend/app/modules/tools/schemas.py
Normal file
7
backend/app/modules/tools/schemas.py
Normal 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
|
||||
355
backend/app/modules/tools/service.py
Normal file
355
backend/app/modules/tools/service.py
Normal 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()
|
||||
0
backend/app/modules/videos/__init__.py
Normal file
0
backend/app/modules/videos/__init__.py
Normal file
64
backend/app/modules/videos/router.py
Normal file
64
backend/app/modules/videos/router.py
Normal 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="视频已删除")
|
||||
21
backend/app/modules/videos/schemas.py
Normal file
21
backend/app/modules/videos/schemas.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class GenerateRequest(BaseModel):
|
||||
text: str
|
||||
voice: str = "zh-CN-YunxiNeural"
|
||||
material_path: str
|
||||
tts_mode: str = "edgetts"
|
||||
ref_audio_id: Optional[str] = None
|
||||
ref_text: Optional[str] = None
|
||||
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
|
||||
87
backend/app/modules/videos/service.py
Normal file
87
backend/app/modules/videos/service.py
Normal 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)}")
|
||||
118
backend/app/modules/videos/task_store.py
Normal file
118
backend/app/modules/videos/task_store.py
Normal 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()
|
||||
@@ -1,13 +1,11 @@
|
||||
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 typing import Optional, Any
|
||||
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
|
||||
@@ -21,199 +19,164 @@ from app.services.assets_service import (
|
||||
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分钟缓存
|
||||
|
||||
from .schemas import GenerateRequest
|
||||
from .task_store import task_store
|
||||
|
||||
|
||||
_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"):
|
||||
# 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 背景音乐混音(不影响唇形与字幕对齐)
|
||||
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)
|
||||
|
||||
|
||||
async def process_video_generation(task_id: str, req: GenerateRequest, user_id: str):
|
||||
temp_files = []
|
||||
try:
|
||||
start_time = time.time()
|
||||
_update_task(task_id, status="processing", progress=5, message="正在下载素材...")
|
||||
|
||||
temp_dir = settings.UPLOAD_DIR / "temp"
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
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)
|
||||
|
||||
_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="Chinese"
|
||||
)
|
||||
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")
|
||||
_update_task(task_id, progress=25)
|
||||
|
||||
_update_task(task_id, message="正在合成唇形 (LatentSync)...", 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...")
|
||||
_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)
|
||||
|
||||
captions_path = None
|
||||
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)
|
||||
)
|
||||
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)
|
||||
|
||||
video = VideoService()
|
||||
final_audio_path = audio_path
|
||||
if req.bgm_id:
|
||||
tasks[task_id]["message"] = "正在合成背景音乐..."
|
||||
tasks[task_id]["progress"] = 86
|
||||
_update_task(task_id, message="正在合成背景音乐...", progress=86)
|
||||
|
||||
bgm_path = resolve_bgm_path(req.bgm_id)
|
||||
if bgm_path:
|
||||
@@ -233,9 +196,7 @@ async def _process_video_generation(task_id: str, req: GenerateRequest, user_id:
|
||||
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
|
||||
@@ -255,6 +216,16 @@ async def _process_video_generation(task_id: str, req: GenerateRequest, user_id:
|
||||
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,
|
||||
@@ -266,29 +237,25 @@ async def _process_video_generation(task_id: str, req: GenerateRequest, user_id:
|
||||
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)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
_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 是否可用
|
||||
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
|
||||
|
||||
|
||||
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),
|
||||
@@ -301,178 +268,69 @@ async def _process_video_generation(task_id: str, req: GenerateRequest, user_id:
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
# 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)}")
|
||||
|
||||
|
||||
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()
|
||||
0
backend/app/repositories/__init__.py
Normal file
0
backend/app/repositories/__init__.py
Normal file
31
backend/app/repositories/sessions.py
Normal file
31
backend/app/repositories/sessions.py
Normal 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 [])
|
||||
39
backend/app/repositories/users.py
Normal file
39
backend/app/repositories/users.py
Normal 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 [])
|
||||
@@ -51,7 +51,10 @@ class GLMService:
|
||||
client = self._get_client()
|
||||
logger.info(f"Calling GLM API with model: {settings.GLM_MODEL}")
|
||||
|
||||
response = client.chat.completions.create(
|
||||
# 使用 asyncio.to_thread 包装同步 SDK 调用,避免阻塞事件循环
|
||||
import asyncio
|
||||
response = await asyncio.to_thread(
|
||||
client.chat.completions.create,
|
||||
model=settings.GLM_MODEL,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
thinking={"type": "disabled"}, # 禁用思考模式,加快响应
|
||||
@@ -96,7 +99,10 @@ class GLMService:
|
||||
client = self._get_client()
|
||||
logger.info(f"Using GLM to rewrite script")
|
||||
|
||||
response = client.chat.completions.create(
|
||||
# 使用 asyncio.to_thread 包装同步 SDK 调用,避免阻塞事件循环
|
||||
import asyncio
|
||||
response = await asyncio.to_thread(
|
||||
client.chat.completions.create,
|
||||
model=settings.GLM_MODEL,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
thinking={"type": "disabled"},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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}")
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
1429
backend/app/services/uploader/weixin_uploader.py
Normal file
1429
backend/app/services/uploader/weixin_uploader.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
11
frontend/package-lock.json
generated
11
frontend/package-lock.json
generated
@@ -14,6 +14,7 @@
|
||||
"next": "16.1.1",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"sonner": "^2.0.7",
|
||||
"swr": "^2.3.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -6006,6 +6007,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",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"next": "16.1.1",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"sonner": "^2.0.7",
|
||||
"swr": "^2.3.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
1
frontend/public/platforms/bilibili.svg
Normal file
1
frontend/public/platforms/bilibili.svg
Normal 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 |
1
frontend/public/platforms/douyin.svg
Normal file
1
frontend/public/platforms/douyin.svg
Normal 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 |
1
frontend/public/platforms/wechat.svg
Normal file
1
frontend/public/platforms/wechat.svg
Normal 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 |
1
frontend/public/platforms/xiaohongshu.svg
Normal file
1
frontend/public/platforms/xiaohongshu.svg
Normal 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 |
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
×
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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,6 +23,7 @@ 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 = [
|
||||
{ id: "zh-CN-YunxiNeural", name: "云溪 (男声-年轻)" },
|
||||
@@ -29,6 +33,8 @@ const VOICES = [
|
||||
{ id: "zh-CN-XiaoyiNeural", name: "晓伊 (女声-温柔)" },
|
||||
];
|
||||
|
||||
|
||||
|
||||
const FIXED_REF_TEXT =
|
||||
"其实生活中有许多美好的瞬间,比如清晨的阳光,或者一杯温热的清茶。希望这次生成的音色能够自然、流畅,完美还原出我最真实的声音状态。";
|
||||
|
||||
@@ -64,6 +70,14 @@ interface RefAudio {
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
interface Material {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
size_mb: number;
|
||||
scene?: string;
|
||||
}
|
||||
|
||||
export const useHomeController = () => {
|
||||
const apiBase = getApiBaseUrl();
|
||||
|
||||
@@ -75,6 +89,7 @@ export const useHomeController = () => {
|
||||
|
||||
// 使用全局任务状态
|
||||
const { currentTask, isGenerating, startTask } = useTask();
|
||||
const prevIsGenerating = useRef(isGenerating);
|
||||
|
||||
const [generatedVideo, setGeneratedVideo] = useState<string | null>(null);
|
||||
const [selectedVideoId, setSelectedVideoId] = useState<string | null>(null);
|
||||
@@ -84,13 +99,15 @@ export const useHomeController = () => {
|
||||
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 +122,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,8 +153,44 @@ 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 (selectedMaterial === materialId && payload?.id) {
|
||||
setSelectedMaterial(payload.id);
|
||||
}
|
||||
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}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -158,9 +213,14 @@ export const useHomeController = () => {
|
||||
// 获取存储 key 的前缀(登录用户使用 userId,未登录使用 guest)
|
||||
const storageKey = userId || "guest";
|
||||
|
||||
// 使用共用的发布预加载 hook
|
||||
const { updatePrefetch: updatePublishPrefetch } = usePublishPrefetch();
|
||||
|
||||
const {
|
||||
materials,
|
||||
fetchError,
|
||||
isFetching,
|
||||
lastMaterialCount,
|
||||
isUploading,
|
||||
uploadProgress,
|
||||
uploadError,
|
||||
@@ -180,7 +240,7 @@ export const useHomeController = () => {
|
||||
refreshTitleStyles,
|
||||
} = useTitleSubtitleStyles({
|
||||
isAuthLoading,
|
||||
storageKey,
|
||||
|
||||
setSelectedSubtitleStyleId,
|
||||
setSelectedTitleStyleId,
|
||||
});
|
||||
@@ -206,7 +266,7 @@ export const useHomeController = () => {
|
||||
bgmError,
|
||||
fetchBgmList,
|
||||
} = useBgm({
|
||||
storageKey,
|
||||
|
||||
selectedBgmId,
|
||||
setSelectedBgmId,
|
||||
});
|
||||
@@ -229,13 +289,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,
|
||||
@@ -261,6 +356,10 @@ export const useHomeController = () => {
|
||||
setTitleFontSize,
|
||||
setSubtitleSizeLocked,
|
||||
setTitleSizeLocked,
|
||||
titleTopMargin,
|
||||
setTitleTopMargin,
|
||||
subtitleBottomMargin,
|
||||
setSubtitleBottomMargin,
|
||||
selectedBgmId,
|
||||
setSelectedBgmId,
|
||||
bgmVolume,
|
||||
@@ -295,8 +394,21 @@ export const useHomeController = () => {
|
||||
refreshTitleStyles(),
|
||||
fetchBgmList(),
|
||||
]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isAuthLoading]);
|
||||
|
||||
// 监听任务完成,自动刷新视频列表并选中最新
|
||||
useEffect(() => {
|
||||
if (prevIsGenerating.current && !isGenerating) {
|
||||
if (currentTask?.status === "completed") {
|
||||
void fetchGeneratedVideos("__latest__");
|
||||
} else {
|
||||
void fetchGeneratedVideos();
|
||||
}
|
||||
}
|
||||
prevIsGenerating.current = isGenerating;
|
||||
}, [isGenerating, currentTask, fetchGeneratedVideos]);
|
||||
|
||||
useEffect(() => {
|
||||
const material = materials.find((item) => item.id === selectedMaterial);
|
||||
if (!material?.path) {
|
||||
@@ -340,22 +452,6 @@ export const useHomeController = () => {
|
||||
};
|
||||
}, [materials, selectedMaterial]);
|
||||
|
||||
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 +473,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;
|
||||
@@ -405,6 +493,23 @@ export const useHomeController = () => {
|
||||
}
|
||||
}, [selectedMaterial, 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;
|
||||
const target = videoItemRefs.current[selectedVideoId];
|
||||
@@ -468,7 +573,7 @@ export const useHomeController = () => {
|
||||
setRecordingTime((prev) => prev + 1);
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
alert("无法访问麦克风,请检查权限设置");
|
||||
toast.error("无法访问麦克风,请检查权限设置");
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
@@ -506,24 +611,29 @@ 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);
|
||||
}
|
||||
@@ -532,20 +642,20 @@ export const useHomeController = () => {
|
||||
// 生成视频
|
||||
const handleGenerate = async () => {
|
||||
if (!selectedMaterial || !text.trim()) {
|
||||
alert("请先选择素材并填写文案");
|
||||
toast.error("请先选择素材并填写文案");
|
||||
return;
|
||||
}
|
||||
|
||||
// 声音克隆模式校验
|
||||
if (ttsMode === "voiceclone") {
|
||||
if (!selectedRefAudio) {
|
||||
alert("请选择或上传参考音频");
|
||||
toast.error("请选择或上传参考音频");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (enableBgm && !selectedBgmId) {
|
||||
alert("请选择背景音乐");
|
||||
toast.error("请选择背景音乐");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -555,12 +665,12 @@ export const useHomeController = () => {
|
||||
// 查找选中的素材对象以获取路径
|
||||
const materialObj = materials.find((m) => m.id === selectedMaterial);
|
||||
if (!materialObj) {
|
||||
alert("素材数据异常");
|
||||
toast.error("素材数据异常");
|
||||
return;
|
||||
}
|
||||
|
||||
// 构建请求参数
|
||||
const payload: Record<string, any> = {
|
||||
const payload: Record<string, unknown> = {
|
||||
material_path: materialObj.path,
|
||||
text: text,
|
||||
tts_mode: ttsMode,
|
||||
@@ -584,6 +694,14 @@ export const useHomeController = () => {
|
||||
payload.title_font_size = Math.round(titleFontSize);
|
||||
}
|
||||
|
||||
if (videoTitle.trim()) {
|
||||
payload.title_top_margin = Math.round(titleTopMargin);
|
||||
}
|
||||
|
||||
if (enableSubtitles) {
|
||||
payload.subtitle_bottom_margin = Math.round(subtitleBottomMargin);
|
||||
}
|
||||
|
||||
if (enableBgm && selectedBgmId) {
|
||||
payload.bgm_id = selectedBgmId;
|
||||
payload.bgm_volume = bgmVolume;
|
||||
@@ -597,9 +715,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 +765,8 @@ export const useHomeController = () => {
|
||||
setPreviewMaterial,
|
||||
materials,
|
||||
fetchError,
|
||||
isFetching,
|
||||
lastMaterialCount,
|
||||
isUploading,
|
||||
uploadProgress,
|
||||
uploadError,
|
||||
@@ -654,6 +777,12 @@ export const useHomeController = () => {
|
||||
selectedMaterial,
|
||||
setSelectedMaterial,
|
||||
handlePreviewMaterial,
|
||||
editingMaterialId,
|
||||
editMaterialName,
|
||||
setEditMaterialName,
|
||||
startMaterialEditing,
|
||||
saveMaterialEditing,
|
||||
cancelMaterialEditing,
|
||||
text,
|
||||
setText,
|
||||
extractModalOpen,
|
||||
@@ -676,14 +805,16 @@ export const useHomeController = () => {
|
||||
subtitleFontSize,
|
||||
setSubtitleFontSize,
|
||||
setSubtitleSizeLocked,
|
||||
titleTopMargin,
|
||||
setTitleTopMargin,
|
||||
subtitleBottomMargin,
|
||||
setSubtitleBottomMargin,
|
||||
enableSubtitles,
|
||||
setEnableSubtitles,
|
||||
resolveAssetUrl,
|
||||
getFontFormat,
|
||||
buildTextShadow,
|
||||
previewContainerWidth,
|
||||
materialDimensions,
|
||||
titlePreviewContainerRef,
|
||||
ttsMode,
|
||||
setTtsMode,
|
||||
voices: VOICES,
|
||||
|
||||
@@ -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;
|
||||
@@ -71,6 +75,10 @@ export const useHomePersistence = ({
|
||||
setTitleFontSize,
|
||||
setSubtitleSizeLocked,
|
||||
setTitleSizeLocked,
|
||||
titleTopMargin,
|
||||
setTitleTopMargin,
|
||||
subtitleBottomMargin,
|
||||
setSubtitleBottomMargin,
|
||||
selectedBgmId,
|
||||
setSelectedBgmId,
|
||||
bgmVolume,
|
||||
@@ -100,6 +108,8 @@ 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) : "");
|
||||
@@ -132,6 +142,16 @@ 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,
|
||||
@@ -148,6 +168,8 @@ export const useHomePersistence = ({
|
||||
setText,
|
||||
setTitleFontSize,
|
||||
setTitleSizeLocked,
|
||||
setTitleTopMargin,
|
||||
setSubtitleBottomMargin,
|
||||
setTtsMode,
|
||||
setVideoTitle,
|
||||
setVoice,
|
||||
@@ -212,6 +234,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);
|
||||
|
||||
@@ -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 Material {
|
||||
id: string;
|
||||
@@ -20,6 +22,8 @@ export const useMaterials = ({
|
||||
}: 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,22 +31,30 @@ 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);
|
||||
}
|
||||
setSelectedMaterial((prev) => {
|
||||
// 如果当前选中的素材在列表中依然存在,保持选中
|
||||
const exists = nextMaterials.some((item) => item.id === prev);
|
||||
if (exists) return prev;
|
||||
// 否则默认选中第一个
|
||||
return nextMaterials[0]?.id || "";
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("获取素材失败:", error);
|
||||
setFetchError(String(error));
|
||||
} finally {
|
||||
setIsFetching(false);
|
||||
}
|
||||
}, [selectedMaterial, setSelectedMaterial]);
|
||||
}, [setSelectedMaterial]);
|
||||
|
||||
const deleteMaterial = useCallback(async (materialId: string) => {
|
||||
if (!confirm("确定要删除这个素材吗?")) return;
|
||||
@@ -53,7 +65,7 @@ export const useMaterials = ({
|
||||
setSelectedMaterial("");
|
||||
}
|
||||
} catch (error) {
|
||||
alert("删除失败: " + error);
|
||||
toast.error("删除失败: " + error);
|
||||
}
|
||||
}, [fetchMaterials, selectedMaterial, setSelectedMaterial]);
|
||||
|
||||
@@ -89,10 +101,11 @@ export const useMaterials = ({
|
||||
setUploadProgress(100);
|
||||
setIsUploading(false);
|
||||
fetchMaterials();
|
||||
} catch (err: any) {
|
||||
} 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,6 +115,8 @@ export const useMaterials = ({
|
||||
return {
|
||||
materials,
|
||||
fetchError,
|
||||
isFetching,
|
||||
lastMaterialCount,
|
||||
isUploading,
|
||||
uploadProgress,
|
||||
uploadError,
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
226
frontend/src/features/home/ui/FloatingStylePreview.tsx
Normal file
226
frontend/src/features/home/ui/FloatingStylePreview.tsx
Normal 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);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import VideoPreviewModal from "@/components/VideoPreviewModal";
|
||||
import ScriptExtractionModal from "@/components/ScriptExtractionModal";
|
||||
import ScriptExtractionModal from "./ScriptExtractionModal";
|
||||
import { useHomeController } from "@/features/home/model/useHomeController";
|
||||
import { BgmPanel } from "@/features/home/ui/BgmPanel";
|
||||
import { GenerateActionBar } from "@/features/home/ui/GenerateActionBar";
|
||||
@@ -15,6 +17,7 @@ import { TitleSubtitlePanel } from "@/features/home/ui/TitleSubtitlePanel";
|
||||
import { VoiceSelector } from "@/features/home/ui/VoiceSelector";
|
||||
|
||||
export function HomePage() {
|
||||
const router = useRouter();
|
||||
const {
|
||||
apiBase,
|
||||
registerMaterialRef,
|
||||
@@ -22,6 +25,8 @@ export function HomePage() {
|
||||
setPreviewMaterial,
|
||||
materials,
|
||||
fetchError,
|
||||
isFetching,
|
||||
lastMaterialCount,
|
||||
isUploading,
|
||||
uploadProgress,
|
||||
uploadError,
|
||||
@@ -32,6 +37,12 @@ export function HomePage() {
|
||||
selectedMaterial,
|
||||
setSelectedMaterial,
|
||||
handlePreviewMaterial,
|
||||
editingMaterialId,
|
||||
editMaterialName,
|
||||
setEditMaterialName,
|
||||
startMaterialEditing,
|
||||
saveMaterialEditing,
|
||||
cancelMaterialEditing,
|
||||
text,
|
||||
setText,
|
||||
extractModalOpen,
|
||||
@@ -54,14 +65,16 @@ export function HomePage() {
|
||||
subtitleFontSize,
|
||||
setSubtitleFontSize,
|
||||
setSubtitleSizeLocked,
|
||||
titleTopMargin,
|
||||
setTitleTopMargin,
|
||||
subtitleBottomMargin,
|
||||
setSubtitleBottomMargin,
|
||||
enableSubtitles,
|
||||
setEnableSubtitles,
|
||||
resolveAssetUrl,
|
||||
getFontFormat,
|
||||
buildTextShadow,
|
||||
previewContainerWidth,
|
||||
materialDimensions,
|
||||
titlePreviewContainerRef,
|
||||
ttsMode,
|
||||
setTtsMode,
|
||||
voices,
|
||||
@@ -119,6 +132,10 @@ export function HomePage() {
|
||||
formatDate,
|
||||
} = useHomeController();
|
||||
|
||||
useEffect(() => {
|
||||
router.prefetch("/publish");
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh">
|
||||
<HomeHeader />
|
||||
@@ -131,6 +148,10 @@ export function HomePage() {
|
||||
<MaterialSelector
|
||||
materials={materials}
|
||||
selectedMaterial={selectedMaterial}
|
||||
isFetching={isFetching}
|
||||
lastMaterialCount={lastMaterialCount}
|
||||
editingMaterialId={editingMaterialId}
|
||||
editMaterialName={editMaterialName}
|
||||
isUploading={isUploading}
|
||||
uploadProgress={uploadProgress}
|
||||
uploadError={uploadError}
|
||||
@@ -140,6 +161,10 @@ export function HomePage() {
|
||||
onRefresh={fetchMaterials}
|
||||
onSelectMaterial={setSelectedMaterial}
|
||||
onPreviewMaterial={handlePreviewMaterial}
|
||||
onStartEditing={startMaterialEditing}
|
||||
onEditNameChange={setEditMaterialName}
|
||||
onSaveEditing={saveMaterialEditing}
|
||||
onCancelEditing={cancelMaterialEditing}
|
||||
onDeleteMaterial={deleteMaterial}
|
||||
onClearUploadError={() => setUploadError(null)}
|
||||
registerMaterialRef={registerMaterialRef}
|
||||
@@ -178,20 +203,17 @@ export function HomePage() {
|
||||
setSubtitleFontSize(value);
|
||||
setSubtitleSizeLocked(true);
|
||||
}}
|
||||
titleTopMargin={titleTopMargin}
|
||||
onTitleTopMarginChange={setTitleTopMargin}
|
||||
subtitleBottomMargin={subtitleBottomMargin}
|
||||
onSubtitleBottomMarginChange={setSubtitleBottomMargin}
|
||||
enableSubtitles={enableSubtitles}
|
||||
onToggleSubtitles={setEnableSubtitles}
|
||||
resolveAssetUrl={resolveAssetUrl}
|
||||
getFontFormat={getFontFormat}
|
||||
buildTextShadow={buildTextShadow}
|
||||
previewScale={previewContainerWidth && (materialDimensions?.width || 1280)
|
||||
? previewContainerWidth / (materialDimensions?.width || 1280)
|
||||
: 1}
|
||||
previewAspectRatio={materialDimensions
|
||||
? `${materialDimensions.width} / ${materialDimensions.height}`
|
||||
: "16 / 9"}
|
||||
previewBaseWidth={materialDimensions?.width || 1280}
|
||||
previewBaseHeight={materialDimensions?.height || 720}
|
||||
previewContainerRef={titlePreviewContainerRef}
|
||||
previewBaseWidth={materialDimensions?.width || 1080}
|
||||
previewBaseHeight={materialDimensions?.height || 1920}
|
||||
/>
|
||||
|
||||
{/* 配音方式选择 */}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ChangeEvent } from "react";
|
||||
import { Upload, RefreshCw, Eye, Trash2, X } from "lucide-react";
|
||||
import type { ChangeEvent, MouseEvent } from "react";
|
||||
import { Upload, RefreshCw, Eye, Trash2, X, Pencil, Check } from "lucide-react";
|
||||
|
||||
interface Material {
|
||||
id: string;
|
||||
@@ -12,6 +12,10 @@ interface Material {
|
||||
interface MaterialSelectorProps {
|
||||
materials: Material[];
|
||||
selectedMaterial: string;
|
||||
isFetching: boolean;
|
||||
lastMaterialCount: number;
|
||||
editingMaterialId: string | null;
|
||||
editMaterialName: string;
|
||||
isUploading: boolean;
|
||||
uploadProgress: number;
|
||||
uploadError: string | null;
|
||||
@@ -21,6 +25,10 @@ interface MaterialSelectorProps {
|
||||
onRefresh: () => void;
|
||||
onSelectMaterial: (id: string) => void;
|
||||
onPreviewMaterial: (path: string) => void;
|
||||
onStartEditing: (material: Material, event: MouseEvent) => void;
|
||||
onEditNameChange: (value: string) => void;
|
||||
onSaveEditing: (materialId: string, event: MouseEvent) => void;
|
||||
onCancelEditing: (event: MouseEvent) => void;
|
||||
onDeleteMaterial: (id: string) => void;
|
||||
onClearUploadError: () => void;
|
||||
registerMaterialRef: (id: string, element: HTMLDivElement | null) => void;
|
||||
@@ -29,6 +37,10 @@ interface MaterialSelectorProps {
|
||||
export function MaterialSelector({
|
||||
materials,
|
||||
selectedMaterial,
|
||||
isFetching,
|
||||
lastMaterialCount,
|
||||
editingMaterialId,
|
||||
editMaterialName,
|
||||
isUploading,
|
||||
uploadProgress,
|
||||
uploadError,
|
||||
@@ -38,6 +50,10 @@ export function MaterialSelector({
|
||||
onRefresh,
|
||||
onSelectMaterial,
|
||||
onPreviewMaterial,
|
||||
onStartEditing,
|
||||
onEditNameChange,
|
||||
onSaveEditing,
|
||||
onCancelEditing,
|
||||
onDeleteMaterial,
|
||||
onClearUploadError,
|
||||
registerMaterialRef,
|
||||
@@ -109,6 +125,18 @@ export function MaterialSelector({
|
||||
<br />
|
||||
API: {apiBase}/api/materials/
|
||||
</div>
|
||||
) : isFetching && materials.length === 0 ? (
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto hide-scrollbar" style={{ contentVisibility: 'auto' }}>
|
||||
{Array.from({ length: Math.min(4, Math.max(1, lastMaterialCount || 1)) }).map((_, index) => (
|
||||
<div
|
||||
key={`material-skeleton-${index}`}
|
||||
className="p-3 rounded-lg border border-white/10 bg-white/5 animate-pulse"
|
||||
>
|
||||
<div className="h-4 w-40 bg-white/10 rounded" />
|
||||
<div className="h-3 w-20 bg-white/5 rounded mt-2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : materials.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
<div className="text-5xl mb-4">📁</div>
|
||||
@@ -131,10 +159,35 @@ export function MaterialSelector({
|
||||
: "border-white/10 bg-white/5 hover:border-white/30"
|
||||
}`}
|
||||
>
|
||||
<button onClick={() => onSelectMaterial(m.id)} className="flex-1 text-left">
|
||||
<div className="text-white text-sm truncate">{m.scene || m.name}</div>
|
||||
<div className="text-gray-400 text-xs">{m.size_mb.toFixed(1)} MB</div>
|
||||
</button>
|
||||
{editingMaterialId === m.id ? (
|
||||
<div className="flex-1 flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
value={editMaterialName}
|
||||
onChange={(e) => onEditNameChange(e.target.value)}
|
||||
className="flex-1 bg-black/40 border border-white/20 rounded-md px-2 py-1 text-xs text-white"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => onSaveEditing(m.id, e)}
|
||||
className="p-1 text-green-400 hover:text-green-300"
|
||||
title="保存"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancelEditing}
|
||||
className="p-1 text-gray-400 hover:text-white"
|
||||
title="取消"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => onSelectMaterial(m.id)} className="flex-1 text-left">
|
||||
<div className="text-white text-sm truncate">{m.scene || m.name}</div>
|
||||
<div className="text-gray-400 text-xs">{m.size_mb.toFixed(1)} MB</div>
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-2 pl-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
@@ -148,6 +201,15 @@ export function MaterialSelector({
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
{editingMaterialId !== m.id && (
|
||||
<button
|
||||
onClick={(e) => onStartEditing(m, e)}
|
||||
className="p-1 text-gray-500 hover:text-white opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="重命名"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -40,7 +40,7 @@ export function PreviewPanel({
|
||||
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">🎥 作品预览</h2>
|
||||
<div className="aspect-video bg-black/50 rounded-xl overflow-hidden flex items-center justify-center">
|
||||
{generatedVideo ? (
|
||||
<video src={generatedVideo} controls className="w-full h-full object-contain" />
|
||||
<video src={generatedVideo} controls preload="metadata" className="w-full h-full object-contain" />
|
||||
) : (
|
||||
<div className="text-gray-500 text-center">
|
||||
<div className="text-5xl mb-4">📹</div>
|
||||
|
||||
@@ -70,6 +70,7 @@ export function RefAudioPanel({
|
||||
|
||||
useEffect(() => {
|
||||
if (!recordedBlob) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setRecordedUrl(null);
|
||||
return;
|
||||
}
|
||||
@@ -162,8 +163,8 @@ export function RefAudioPanel({
|
||||
className="w-full bg-black/50 text-white text-xs px-1 py-0.5 rounded border border-purple-500 focus:outline-none"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') onSaveEditing(audio.id, e as any);
|
||||
if (e.key === 'Escape') onCancelEditing(e as any);
|
||||
if (e.key === 'Enter') onSaveEditing(audio.id, e as unknown as MouseEvent);
|
||||
if (e.key === 'Escape') onCancelEditing(e as unknown as MouseEvent);
|
||||
}}
|
||||
/>
|
||||
<button onClick={(e) => onSaveEditing(audio.id, e)} className="text-green-400 hover:text-green-300 text-xs">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user