Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee342cc40f | ||
|
|
1a291a03b8 | ||
|
|
1e52346eb4 |
@@ -19,7 +19,6 @@
|
||||
- **repositories/**:数据读写(Supabase),不包含业务逻辑。
|
||||
- **services/**:外部依赖与基础能力(TTS、Storage、Remotion 等)。
|
||||
- **core/**:配置、安全、依赖注入、统一响应。
|
||||
- **api/**:仅做 router 透传,保持 `/api/*` 路由稳定。
|
||||
|
||||
---
|
||||
|
||||
@@ -28,18 +27,29 @@
|
||||
```
|
||||
backend/
|
||||
├── app/
|
||||
│ ├── api/ # 兼容路由入口,透传到 modules
|
||||
│ ├── core/ # config、deps、security、response
|
||||
│ ├── modules/ # 业务模块
|
||||
│ │ ├── videos/
|
||||
│ │ ├── materials/
|
||||
│ │ ├── publish/
|
||||
│ │ ├── auth/
|
||||
│ │ └── ...
|
||||
│ ├── modules/ # 业务模块(路由 + 逻辑)
|
||||
│ │ ├── videos/ # 视频生成任务
|
||||
│ │ ├── materials/ # 素材管理
|
||||
│ │ ├── publish/ # 多平台发布
|
||||
│ │ ├── auth/ # 认证与会话
|
||||
│ │ ├── ai/ # AI 功能(标题标签生成等)
|
||||
│ │ ├── assets/ # 静态资源(字体/样式/BGM)
|
||||
│ │ ├── ref_audios/ # 声音克隆参考音频
|
||||
│ │ ├── login_helper/ # 扫码登录辅助
|
||||
│ │ ├── tools/ # 工具接口
|
||||
│ │ └── 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
|
||||
```
|
||||
@@ -86,6 +96,21 @@ backend/
|
||||
- 所有文件上传/下载/删除/移动通过 `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. 代码约定
|
||||
@@ -112,12 +137,24 @@ backend/
|
||||
- `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 发布调试
|
||||
|
||||
@@ -13,14 +13,24 @@
|
||||
```
|
||||
backend/
|
||||
├── app/
|
||||
│ ├── api/ # 兼容路由入口 (透传到 modules)
|
||||
│ ├── core/ # 核心配置 (config.py, security.py, response.py)
|
||||
│ ├── modules/ # 业务模块 (router/service/workflow/schemas)
|
||||
│ │ ├── videos/ # 视频生成任务
|
||||
│ │ ├── materials/ # 素材管理
|
||||
│ │ ├── publish/ # 多平台发布
|
||||
│ │ ├── auth/ # 认证与会话
|
||||
│ │ ├── ai/ # AI 功能(标题标签生成)
|
||||
│ │ ├── assets/ # 静态资源(字体/样式/BGM)
|
||||
│ │ ├── ref_audios/ # 声音克隆参考音频
|
||||
│ │ ├── login_helper/ # 扫码登录辅助
|
||||
│ │ ├── tools/ # 工具接口(文案提取等)
|
||||
│ │ └── admin/ # 管理员功能
|
||||
│ ├── repositories/ # Supabase 数据访问
|
||||
│ ├── services/ # 外部服务集成 (TTS/Remotion/Storage 等)
|
||||
│ ├── services/ # 外部服务集成 (TTS/Remotion/Storage/Uploader 等)
|
||||
│ └── tests/ # 单元测试与集成测试
|
||||
├── scripts/ # 运维脚本 (watchdog.py, init_db.py)
|
||||
├── assets/ # 资源库 (fonts, bgm, styles)
|
||||
├── user_data/ # 用户隔离数据 (Cookie 等)
|
||||
└── requirements.txt # 依赖清单
|
||||
```
|
||||
|
||||
@@ -42,12 +52,11 @@ 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`: 上传素材
|
||||
* `GET /api/materials`: 获取素材列表
|
||||
@@ -55,14 +64,33 @@ backend/
|
||||
|
||||
4. **社交发布 (Publish)**
|
||||
* `POST /api/publish`: 发布视频到 抖音/微信视频号/B站/小红书
|
||||
* `POST /api/publish/login`: 扫码登录平台
|
||||
* `GET /api/publish/login/status`: 查询登录状态(含刷脸验证二维码)
|
||||
* `GET /api/publish/accounts`: 获取已登录账号列表
|
||||
|
||||
> 提示:视频号发布建议使用 headful + xvfb-run 运行后端,避免 headless 解码失败。
|
||||
> 提示:视频号/抖音发布建议使用 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
|
||||
@@ -80,10 +108,17 @@ backend/
|
||||
|
||||
`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)
|
||||
|
||||
@@ -148,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` 注册路由。
|
||||
|
||||
### 添加定时任务
|
||||
|
||||
|
||||
@@ -25,38 +25,38 @@ python3 --version
|
||||
# 检查 Node.js 版本 (需要 18+)
|
||||
node --version
|
||||
|
||||
# 检查 FFmpeg
|
||||
ffmpeg -version
|
||||
|
||||
# 检查 Chrome (视频号发布)
|
||||
google-chrome --version
|
||||
|
||||
# 检查 Xvfb
|
||||
xvfb-run --help
|
||||
# 检查 FFmpeg
|
||||
ffmpeg -version
|
||||
|
||||
# 检查 pm2 (用于服务管理)
|
||||
pm2 --version
|
||||
|
||||
# 检查 Redis (任务状态存储,推荐)
|
||||
redis-server --version
|
||||
# 检查 Chrome (视频号发布)
|
||||
google-chrome --version
|
||||
|
||||
# 检查 Xvfb
|
||||
xvfb-run --help
|
||||
|
||||
# 检查 pm2 (用于服务管理)
|
||||
pm2 --version
|
||||
|
||||
# 检查 Redis (任务状态存储,推荐)
|
||||
redis-server --version
|
||||
```
|
||||
|
||||
如果缺少依赖:
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install ffmpeg
|
||||
|
||||
# 安装 Xvfb (视频号发布)
|
||||
sudo apt install xvfb
|
||||
sudo apt update
|
||||
sudo apt install ffmpeg
|
||||
|
||||
# 安装 pm2
|
||||
npm install -g pm2
|
||||
|
||||
# 安装 Chrome (视频号发布)
|
||||
wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | sudo gpg --dearmor -o /usr/share/keyrings/google-linux-signing-keyring.gpg
|
||||
printf "deb [arch=amd64 signed-by=/usr/share/keyrings/google-linux-signing-keyring.gpg] http://dl.google.com/linux/chrome/deb/ stable main\n" | sudo tee /etc/apt/sources.list.d/google-chrome.list > /dev/null
|
||||
sudo apt update
|
||||
sudo apt install -y google-chrome-stable
|
||||
# 安装 Xvfb (视频号发布)
|
||||
sudo apt install xvfb
|
||||
|
||||
# 安装 pm2
|
||||
npm install -g pm2
|
||||
|
||||
# 安装 Chrome (视频号发布)
|
||||
wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | sudo gpg --dearmor -o /usr/share/keyrings/google-linux-signing-keyring.gpg
|
||||
printf "deb [arch=amd64 signed-by=/usr/share/keyrings/google-linux-signing-keyring.gpg] http://dl.google.com/linux/chrome/deb/ stable main\n" | sudo tee /etc/apt/sources.list.d/google-chrome.list > /dev/null
|
||||
sudo apt update
|
||||
sudo apt install -y google-chrome-stable
|
||||
```
|
||||
|
||||
---
|
||||
@@ -110,11 +110,21 @@ pip install torch torchvision torchaudio --index-url https://download.pytorch.or
|
||||
# 安装 Python 依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 安装 Playwright 浏览器(社交发布需要)
|
||||
playwright install chromium
|
||||
```
|
||||
|
||||
> 提示:视频号发布建议使用系统 Chrome + xvfb-run(避免 headless 解码失败)。
|
||||
# 安装 Playwright 浏览器(社交发布需要)
|
||||
playwright install chromium
|
||||
```
|
||||
|
||||
> 提示:视频号发布建议使用系统 Chrome + xvfb-run(避免 headless 解码失败)。
|
||||
> 抖音发布同样建议 headful 模式 (`DOUYIN_HEADLESS_MODE=headful`)。
|
||||
|
||||
### 扫码登录注意事项
|
||||
|
||||
- **Cookie 按用户隔离**:每个用户的 Cookie 存储在 `backend/user_data/{uuid}/cookies/` 目录下,多用户并发登录互不干扰。
|
||||
- **抖音 QR 登录关键教训**:
|
||||
- 扫码后绝对**不能重新加载 QR 页面**,否则会销毁会话 token
|
||||
- 使用**新标签页**检测登录完成状态(检查 URL 包含 `creator-micro` + session cookies 存在)
|
||||
- 抖音可能弹出**刷脸验证**,后端会自动提取验证二维码返回给前端展示
|
||||
- **微信视频号发布**:标题、描述、标签统一写入"视频描述"字段
|
||||
|
||||
---
|
||||
|
||||
@@ -178,17 +188,29 @@ cp .env.example .env
|
||||
| `LATENTSYNC_GPU_ID` | 1 | GPU 选择 (0 或 1) |
|
||||
| `LATENTSYNC_USE_SERVER` | false | 设为 true 以启用常驻服务加速 |
|
||||
| `LATENTSYNC_INFERENCE_STEPS` | 20 | 推理步数 (20-50) |
|
||||
| `LATENTSYNC_GUIDANCE_SCALE` | 1.5 | 引导系数 (1.0-3.0) |
|
||||
| `DEBUG` | true | 生产环境改为 false |
|
||||
| `REDIS_URL` | `redis://localhost:6379/0` | 任务状态存储(不可用时回退内存) |
|
||||
| `WEIXIN_HEADLESS_MODE` | headless-new | 视频号 Playwright 模式 (headful/headless-new) |
|
||||
| `WEIXIN_CHROME_PATH` | `/usr/bin/google-chrome` | 系统 Chrome 路径 |
|
||||
| `WEIXIN_BROWSER_CHANNEL` | | Chromium 通道 (可选) |
|
||||
| `WEIXIN_USER_AGENT` | Chrome 120 UA | 视频号浏览器指纹 UA |
|
||||
| `WEIXIN_LOCALE` | zh-CN | 视频号语言环境 |
|
||||
| `WEIXIN_TIMEZONE_ID` | Asia/Shanghai | 视频号时区 |
|
||||
| `WEIXIN_FORCE_SWIFTSHADER` | true | 强制软件 WebGL,避免 context lost |
|
||||
| `WEIXIN_TRANSCODE_MODE` | reencode | 上传前转码 (reencode/faststart/off) |
|
||||
| `LATENTSYNC_GUIDANCE_SCALE` | 1.5 | 引导系数 (1.0-3.0) |
|
||||
| `DEBUG` | true | 生产环境改为 false |
|
||||
| `REDIS_URL` | `redis://localhost:6379/0` | 任务状态存储(不可用时回退内存) |
|
||||
| `WEIXIN_HEADLESS_MODE` | headless-new | 视频号 Playwright 模式 (headful/headless-new) |
|
||||
| `WEIXIN_CHROME_PATH` | `/usr/bin/google-chrome` | 系统 Chrome 路径 |
|
||||
| `WEIXIN_BROWSER_CHANNEL` | | Chromium 通道 (可选) |
|
||||
| `WEIXIN_USER_AGENT` | Chrome 120 UA | 视频号浏览器指纹 UA |
|
||||
| `WEIXIN_LOCALE` | zh-CN | 视频号语言环境 |
|
||||
| `WEIXIN_TIMEZONE_ID` | Asia/Shanghai | 视频号时区 |
|
||||
| `WEIXIN_FORCE_SWIFTSHADER` | true | 强制软件 WebGL,避免 context lost |
|
||||
| `WEIXIN_TRANSCODE_MODE` | reencode | 上传前转码 (reencode/faststart/off) |
|
||||
| `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 (文案提取功能) |
|
||||
|
||||
---
|
||||
|
||||
@@ -210,19 +232,19 @@ npm run build
|
||||
|
||||
> 💡 先手动启动测试,确认一切正常后再配置 pm2 常驻服务。
|
||||
|
||||
### 启动后端 (终端 1)
|
||||
### 启动后端 (终端 1)
|
||||
|
||||
```bash
|
||||
cd /home/rongye/ProgramFiles/ViGent2/backend
|
||||
source venv/bin/activate
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8006
|
||||
```
|
||||
|
||||
推荐使用项目脚本启动后端(已内置 xvfb + headful 发布环境):
|
||||
```bash
|
||||
cd /home/rongye/ProgramFiles/ViGent2
|
||||
./run_backend.sh # 默认 8006,可用 PORT 覆盖
|
||||
```
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8006
|
||||
```
|
||||
|
||||
推荐使用项目脚本启动后端(已内置 xvfb + headful 发布环境):
|
||||
```bash
|
||||
cd /home/rongye/ProgramFiles/ViGent2
|
||||
./run_backend.sh # 默认 8006,可用 PORT 覆盖
|
||||
```
|
||||
|
||||
### 启动前端 (终端 2)
|
||||
|
||||
@@ -255,29 +277,30 @@ python -m scripts.server
|
||||
|
||||
建议使用 Shell 脚本启动以避免环境问题。
|
||||
|
||||
1. 创建启动脚本 `run_backend.sh`:
|
||||
```bash
|
||||
cat > run_backend.sh << 'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
BASE_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
export WEIXIN_HEADLESS_MODE=headful
|
||||
export WEIXIN_DEBUG_ARTIFACTS=false
|
||||
export WEIXIN_RECORD_VIDEO=false
|
||||
export DOUYIN_DEBUG_ARTIFACTS=false
|
||||
export DOUYIN_RECORD_VIDEO=false
|
||||
PORT=${PORT:-8006}
|
||||
cd "$BASE_DIR/backend"
|
||||
exec xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" \
|
||||
./venv/bin/uvicorn app.main:app --host 0.0.0.0 --port "$PORT"
|
||||
EOF
|
||||
chmod +x run_backend.sh
|
||||
```
|
||||
1. 创建启动脚本 `run_backend.sh`:
|
||||
```bash
|
||||
cat > run_backend.sh << 'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
BASE_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
export WEIXIN_HEADLESS_MODE=headful
|
||||
export 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
|
||||
```
|
||||
|
||||
2. 使用 pm2 启动:
|
||||
```bash
|
||||
pm2 start ./run_backend.sh --name vigent2-backend
|
||||
```
|
||||
2. 使用 pm2 启动:
|
||||
```bash
|
||||
pm2 start ./run_backend.sh --name vigent2-backend
|
||||
```
|
||||
|
||||
### 2. 启动前端服务 (Next.js)
|
||||
|
||||
|
||||
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` 等模块,确认逻辑健壮,无类似回归风险。
|
||||
248
Docs/DevLogs/Day21.md
Normal file
248
Docs/DevLogs/Day21.md
Normal file
@@ -0,0 +1,248 @@
|
||||
## 🐛 缺陷修复:视频生成与持久化回归 (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
|
||||
```
|
||||
@@ -24,12 +24,13 @@
|
||||
| :---: | :--- | :--- |
|
||||
| 🔥 **High** | `Docs/DevLogs/DayN.md` | **(最新日志)** 详细记录变更、修复、代码片段 |
|
||||
| 🔥 **High** | `Docs/task_complete.md` | **(任务总览)** 更新 `[x]`、进度条、时间线 |
|
||||
| ⚡ **Med** | `README.md` | **(项目主页)** 功能特性、技术栈、最新截图 |
|
||||
| ⚡ **Med** | `Docs/DEPLOY_MANUAL.md` | **(部署手册)** 环境变量、依赖包、启动命令变更 |
|
||||
| ⚡ **Med** | `Docs/FRONTEND_DEV.md` | **(前端规范)** API封装、日期格式化、新页面规范 |
|
||||
| ⚡ **Med** | `Docs/FRONTEND_README.md` | **(前端文档)** 功能说明、页面变更 |
|
||||
| 🧊 **Low** | `Docs/implementation_plan.md` | **(实施计划)** 核对计划与实际实现的差异 |
|
||||
| 🧊 **Low** | `Docs/architecture_plan.md` | **(前端架构)** 拆分计划与阶段目标 |
|
||||
| ⚡ **Med** | `README.md` | **(项目主页)** 功能特性、技术栈、最新截图 |
|
||||
| ⚡ **Med** | `Docs/DEPLOY_MANUAL.md` | **(部署手册)** 环境变量、依赖包、启动命令变更 |
|
||||
| ⚡ **Med** | `Docs/BACKEND_DEV.md` | **(后端规范)** 接口契约、模块划分、环境变量 |
|
||||
| ⚡ **Med** | `Docs/BACKEND_README.md` | **(后端文档)** 接口说明、架构设计 |
|
||||
| ⚡ **Med** | `Docs/FRONTEND_DEV.md` | **(前端规范)** API封装、日期格式化、新页面规范 |
|
||||
| ⚡ **Med** | `Docs/FRONTEND_README.md` | **(前端文档)** 功能说明、页面变更 |
|
||||
| 🧊 **Low** | `Docs/*_DEPLOY.md` | **(子系统部署)** LatentSync/Qwen3/字幕等独立部署文档 |
|
||||
|
||||
---
|
||||
|
||||
@@ -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,7 +204,7 @@ ViGent2/Docs/
|
||||
|
||||
---
|
||||
|
||||
## 📅 DayN.md 更新规则(日常更新)
|
||||
## 📅 DayN.md 更新规则(日常更新)
|
||||
|
||||
### 新建判断 (对话开始前)
|
||||
1. **回顾进度**:查看 `task_complete.md` 了解当前状态
|
||||
@@ -232,9 +212,9 @@ ViGent2/Docs/
|
||||
- **今天 (与当前日期相同)** → 🚨 **绝对禁止创建新文件**,必须**追加**到现有 `DayN.md` 末尾!即使是完全不同的功能模块。
|
||||
- **之前 (昨天或更早)** → 创建 `Day{N+1}.md`
|
||||
|
||||
### 追加格式
|
||||
```markdown
|
||||
---
|
||||
### 追加格式
|
||||
```markdown
|
||||
---
|
||||
|
||||
## 🔧 [章节标题]
|
||||
|
||||
@@ -250,18 +230,18 @@ ViGent2/Docs/
|
||||
- ✅ 修复了 xxx
|
||||
```
|
||||
|
||||
### 快速修复格式
|
||||
```markdown
|
||||
## 🐛 [Bug 简述] (HH:MM)
|
||||
### 快速修复格式
|
||||
```markdown
|
||||
## 🐛 [Bug 简述] (HH:MM)
|
||||
|
||||
**问题**:一句话描述
|
||||
**修复**:修改了 `文件名` 中的 xxx
|
||||
**状态**:✅ 已修复 / 🔄 待验证
|
||||
```
|
||||
|
||||
### ⚠️ 注意
|
||||
- **DayN.md 文件开头禁止使用 `---`**,避免被解析为 Front Matter。
|
||||
- 分隔线只用于章节之间,不作为文件第一行。
|
||||
**状态**:✅ 已修复 / 🔄 待验证
|
||||
```
|
||||
|
||||
### ⚠️ 注意
|
||||
- **DayN.md 文件开头禁止使用 `---`**,避免被解析为 Front Matter。
|
||||
- 分隔线只用于章节之间,不作为文件第一行。
|
||||
|
||||
---
|
||||
|
||||
@@ -316,4 +296,4 @@ ViGent2/Docs/
|
||||
|
||||
---
|
||||
|
||||
**最后更新**:2026-02-04
|
||||
**最后更新**:2026-02-08
|
||||
|
||||
@@ -2,22 +2,65 @@
|
||||
|
||||
## 目录结构
|
||||
|
||||
采用轻量 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
|
||||
│ │ ├── 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
|
||||
├── contexts/ # 全局 Context(Auth、Task)
|
||||
├── components/ # 遗留通用组件
|
||||
│ ├── VideoPreviewModal.tsx
|
||||
│ └── ScriptExtractionModal.tsx
|
||||
└── proxy.ts # Next.js middleware(路由保护)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -228,10 +271,21 @@ import { formatDate } from '@/shared/lib/media';
|
||||
|
||||
## 轻量 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 等)
|
||||
- `contexts/`:全局 Context(AuthContext / TaskContext)
|
||||
- `components/`:遗留通用组件(VideoPreviewModal 等)
|
||||
|
||||
## 类型定义规范
|
||||
|
||||
- 通用实体类型(如 User, Account, Video)统一放置在 `src/shared/types/`。
|
||||
- 特定业务类型放在 feature 目录下的 types.ts 或 model 中。
|
||||
- **禁止**在多个地方重复定义 User 接口,统一引用 `import { User } from '@/shared/types/user';`。
|
||||
|
||||
---
|
||||
|
||||
@@ -250,6 +304,7 @@ import { formatDate } from '@/shared/lib/media';
|
||||
- **恢复先于保存**:恢复完成前禁止写入(`isRestored` 保护)。
|
||||
- 避免默认值覆盖用户选择(优先读取已保存值)。
|
||||
- 优先使用 `useHomePersistence` 集中管理恢复/保存,页面内避免分散的 localStorage 读写。
|
||||
- **禁止使用签名 URL 作为持久化标识**:Supabase Storage 签名 URL 每次请求都变化,必须使用后端返回的稳定 `id` 字段。
|
||||
- 如需新增持久化字段,必须加入恢复与保存逻辑,并更新本节。
|
||||
|
||||
---
|
||||
@@ -267,7 +322,8 @@ import { formatDate } from '@/shared/lib/media';
|
||||
## 发布页交互规则
|
||||
|
||||
- 发布按钮在未选择任何平台时禁用
|
||||
- 仅保留“立即发布”,不再提供定时发布 UI/参数
|
||||
- 仅保留"立即发布",不再提供定时发布 UI/参数
|
||||
- **作品选择持久化**:使用 `video.id`(稳定标识)而非 `video.path`(签名 URL)进行选择、比较和 localStorage 存储。发布时根据 `id` 查找对应 `path` 发送请求。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
|
||||
- **作品预览**: 生成完成后直接播放下载(作品预览 + 历史作品)。
|
||||
- **预览优化**: 预览视频 `metadata` 预取,首帧加载更快。
|
||||
- **本地保存**: 文案/标题/偏好由 `useHomePersistence` 统一持久化,刷新后恢复 (Day 14/17)。
|
||||
- **选择持久化**: 首页/发布页作品选择均使用稳定 `id` 持久化,刷新保持用户选择;新视频生成后自动选中最新 (Day 21)。
|
||||
|
||||
### 2. 全自动发布 (`/publish`) [Day 7 新增]
|
||||
- **多平台管理**: 统一管理抖音、微信视频号、B站、小红书账号状态。
|
||||
@@ -26,6 +27,7 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
|
||||
- Cookie 自动保存与状态同步。
|
||||
- **发布配置**: 设置视频标题、标签、简介。
|
||||
- **作品选择**: 卡片列表 + 搜索 + 预览弹窗。
|
||||
- **选择持久化**: 使用稳定 `video.id` 持久化选择,刷新保持;新视频生成自动选中最新 (Day 21)。
|
||||
- **预览兼容**: 签名 URL / 相对路径均可直接预览。
|
||||
- **发布方式**: 仅支持 "立即发布"。
|
||||
|
||||
|
||||
@@ -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 19 - 自动发布稳定性与发布体验优化)
|
||||
**更新时间**: 2026-02-06
|
||||
**项目**: ViGent2 数字人口播视频生成系统
|
||||
**进度**: 100% (Day 21 - 缺陷修复与持久化回归治理)
|
||||
**更新时间**: 2026-02-08
|
||||
|
||||
---
|
||||
|
||||
@@ -10,52 +10,66 @@
|
||||
|
||||
> 这里记录了每一天的核心开发内容与 milestone。
|
||||
|
||||
### Day 19: 自动发布稳定性与发布体验优化 (Current) 🚀
|
||||
- [x] **抖音发布稳定性**: 上传入口、封面流程、发布重试、登录失效识别与网络失败快速返回全面增强。
|
||||
- [x] **视频号发布修复**: 标题+标签统一写入“视频描述”,`post_create` 成功信号快速判定,超时改为失败返回。
|
||||
- [x] **成功截图闭环**: 抖音/视频号发布成功截图接入前端,支持用户隔离存储与鉴权访问。
|
||||
- [x] **截图观感优化**: 成功截图延后 3 秒并改为视口截图,修复“截图内容仅占 1/3”问题。
|
||||
- [x] **调试能力开关化**: 新增视频号录屏配置,默认可按环境变量开关,失败排障更直观。
|
||||
- [x] **启动链路统一**: 合并为 `run_backend.sh`(xvfb + headful),统一端口 `8006`,减少多进程混淆。
|
||||
- [x] **发布页防误操作**: 发布中按钮提示“请勿刷新或关闭网页”,并启用刷新/关页二次确认拦截。
|
||||
- [ ] **后续优化**: 发布任务状态恢复机制(任务化 + 状态持久化 + 前端轮询恢复)。
|
||||
|
||||
### Day 18: 后端模块化与规范完善
|
||||
- [x] **模块化迁移**: 路由透传 `modules/*`,业务逻辑集中到 service/workflow。
|
||||
- [x] **视频生成拆分**: 生成流程下沉 workflow,任务状态统一 TaskStore。
|
||||
- [x] **Redis 任务存储**: Redis 优先,不可用自动回退内存。
|
||||
- [x] **仓储层抽离**: Supabase 访问统一 `repositories/*`,deps/auth/admin 全面替换。
|
||||
- [x] **响应规范**: 统一 `success/message/data/code` + 全局异常处理。
|
||||
- [x] **素材重命名**: 新增重命名接口与 Storage `move_file`。
|
||||
- [x] **平台顺序调整**: 抖音/微信视频号/B站/小红书,移除快手。
|
||||
- [x] **后端开发规范**: 新增 `BACKEND_DEV.md`,README 同步模块化结构。
|
||||
- [x] **发布管理体验**: 首页预取路由 + 发布页骨架与缓存,进入更快。
|
||||
- [x] **素材加载优化**: 素材列表并发签名 URL,骨架数量动态。
|
||||
- [x] **预览加载优化**: `preload="metadata"` + hover 预取。
|
||||
|
||||
### Day 17: 前端重构与体验优化
|
||||
- [x] **UI 组件拆分**: 首页拆分为独立组件,降低 `page.tsx` 复杂度。
|
||||
- [x] **轻量 FSD 迁移**: `app` 页面轻量化,逻辑集中到 `features/*/model`,通用能力下沉 `shared/*`。
|
||||
- [x] **Controller Hooks**: Home/Publish 页面逻辑集中到 Controller Hook,Page 仅组合渲染。
|
||||
- [x] **通用工具抽取**: `media.ts` 统一 API Base / URL / 日期格式化。
|
||||
- [x] **交互优化**: 选择项持久化、列表内定位、刷新回顶部、最新作品优先预览。
|
||||
- [x] **发布页改造**: 作品列表卡片化 + 搜索 + 预览弹窗。
|
||||
- [x] **预览体验**: 预览弹窗统一头部样式与提示文案。
|
||||
- [x] **预览一致性**: 标题/字幕预览按素材分辨率缩放。
|
||||
- [x] **标题同步与限制**: 片头标题同步发布标题,输入法合成态兼容,限制 15 字。
|
||||
- [x] **样式默认与持久化**: 默认样式与字号调整,刷新保留用户选择。
|
||||
- [x] **性能微优化**: 列表渲染优化 + 并行请求 + localStorage 防抖。
|
||||
- [x] **资源能力**: 字体/BGM 资源库 + `/api/assets` 接入。
|
||||
- [x] **音频与字幕修复**: BGM 混音稳定性与字幕断句优化。
|
||||
- [x] **持久化修复**: 接入 `useHomePersistence`,恢复 `isRestored` 逻辑并通过构建。
|
||||
- [x] **预览与选择修复**: 发布预览兼容签名 URL,音频试听路径解析,素材/BGM 回退有效项。
|
||||
- [x] **体验细节优化**: 录音预览 URL 回收,预览弹窗滚动恢复,全局任务提示挂载。
|
||||
|
||||
### Day 16: 深度性能优化
|
||||
- [x] **Qwen-TTS 加速**: 集成 Flash Attention 2,模型加载速度提升至 8.9s。
|
||||
- [x] **服务守护**: 开发 `Watchdog` 看门狗机制,自动监控并重启僵死服务。
|
||||
- [x] **LatentSync 性能确认**: 验证 DeepCache + 原生 Flash Attn 生效。
|
||||
- [x] **文档重构**: 全面更新 README、部署手册及后端文档。
|
||||
### Day 21: 缺陷修复与持久化回归治理 (Current)
|
||||
- [x] **Remotion 崩溃容错**: 渲染进程 SIGABRT 退出时检查输出文件,避免误判失败导致标题/字幕丢失。
|
||||
- [x] **首页作品选择持久化**: 修复 `fetchGeneratedVideos` 无条件覆盖恢复值的问题,新增 `preferVideoId` 参数控制选中逻辑。
|
||||
- [x] **发布页作品选择持久化**: 根因为签名 URL 不稳定,全面改用 `video.id` 替代 `path` 进行选择/持久化/比较。
|
||||
- [x] **预取缓存补全**: 首页预取发布页数据时加入 `id` 字段,确保缓存数据可用于持久化匹配。
|
||||
|
||||
### 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 位手机号注册/登录。
|
||||
@@ -102,10 +116,10 @@
|
||||
|
||||
## 🛤️ 后续规划 (Roadmap)
|
||||
|
||||
### 🔴 优先待办
|
||||
- [ ] **批量生成架构**: 支持 Excel 导入,批量生产视频。
|
||||
- [ ] **定时任务后台化**: 迁移前端触发的定时发布到后端 APScheduler。
|
||||
- [ ] **发布任务恢复机制**: 发布任务化 + 状态持久化 + 前端断点恢复,解决刷新后状态丢失。
|
||||
### 🔴 优先待办
|
||||
- [ ] **批量生成架构**: 支持 Excel 导入,批量生产视频。
|
||||
- [ ] **定时任务后台化**: 迁移前端触发的定时发布到后端 APScheduler。
|
||||
- [ ] **发布任务恢复机制**: 发布任务化 + 状态持久化 + 前端断点恢复,解决刷新后状态丢失。
|
||||
|
||||
### 🔵 长期探索
|
||||
- [ ] **容器化交付**: 提供完整的 Docker Compose 一键部署包。
|
||||
@@ -121,7 +135,7 @@
|
||||
| **Web UI** | 100% | ✅ 稳定 (移动端适配) |
|
||||
| **唇形同步** | 100% | ✅ LatentSync 1.6 |
|
||||
| **TTS 配音** | 100% | ✅ EdgeTTS + Qwen3 |
|
||||
| **自动发布** | 100% | ✅ 抖音/微信视频号/B站/小红书 |
|
||||
| **自动发布** | 100% | ✅ 抖音/微信视频号/B站/小红书 |
|
||||
| **用户认证** | 100% | ✅ 手机号 + JWT |
|
||||
| **部署运维** | 100% | ✅ PM2 + Watchdog |
|
||||
|
||||
@@ -129,5 +143,5 @@
|
||||
|
||||
## 📎 相关文档
|
||||
|
||||
- [详细开发日志 (DevLogs)](Docs/DevLogs/)
|
||||
- [部署手册 (DEPLOY_MANUAL)](Docs/DEPLOY_MANUAL.md)
|
||||
- [详细开发日志 (DevLogs)](Docs/DevLogs/)
|
||||
- [部署手册 (DEPLOY_MANUAL)](Docs/DEPLOY_MANUAL.md)
|
||||
|
||||
50
README.md
50
README.md
@@ -15,24 +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 的用户隔离,支持手机号注册/登录、密码管理。
|
||||
- 🛡️ **服务守护** - 内置 Watchdog 看门狗机制,自动监控并重启僵死服务,确保 7x24h 稳定运行。
|
||||
- 🚀 **性能优化** - 视频预压缩、模型常驻服务(近实时加载)、双 GPU 流水线并发。
|
||||
### 平台化功能
|
||||
- 📱 **全自动发布** - 支持抖音/微信视频号/B站/小红书立即发布;扫码登录 + Cookie 持久化。
|
||||
- 🖥️ **发布管理预览** - 支持签名 URL / 相对路径作品预览,确保可直接播放。
|
||||
- 📸 **发布结果可视化** - 抖音/微信视频号发布成功后返回截图,发布页结果卡片可直接查看。
|
||||
- 🛡️ **发布防误操作** - 发布进行中自动提示“请勿刷新或关闭网页”,并拦截刷新/关页二次确认。
|
||||
- 🔐 **认证与隔离** - 基于 Supabase 的用户隔离,支持手机号注册/登录、密码管理。
|
||||
- 🛡️ **服务守护** - 内置 Watchdog 看门狗机制,自动监控并重启僵死服务,确保 7x24h 稳定运行。
|
||||
- 🚀 **性能优化** - 视频预压缩、模型常驻服务(近实时加载)、双 GPU 流水线并发。
|
||||
|
||||
---
|
||||
|
||||
@@ -40,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 |
|
||||
@@ -58,11 +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/BACKEND_README.md) - 接口规范与开发流程。
|
||||
- [后端开发规范](Docs/BACKEND_DEV.md) - 分层约定与开发习惯。
|
||||
- [前端开发指南](Docs/FRONTEND_DEV.md) - UI 组件与页面规范。
|
||||
- [开发日志 (DevLogs)](Docs/DevLogs/) - 每日开发进度与技术决策记录。
|
||||
|
||||
@@ -74,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,10 +0,0 @@
|
||||
from . import admin
|
||||
from . import ai
|
||||
from . import assets
|
||||
from . import auth
|
||||
from . import login_helper
|
||||
from . import materials
|
||||
from . import publish
|
||||
from . import ref_audios
|
||||
from . import tools
|
||||
from . import videos
|
||||
@@ -1 +0,0 @@
|
||||
from app.modules.admin.router import router
|
||||
@@ -1 +0,0 @@
|
||||
from app.modules.ai.router import router
|
||||
@@ -1 +0,0 @@
|
||||
from app.modules.assets.router import router
|
||||
@@ -1 +0,0 @@
|
||||
from app.modules.auth.router import router
|
||||
@@ -1 +0,0 @@
|
||||
from app.modules.login_helper.router import router
|
||||
@@ -1 +0,0 @@
|
||||
from app.modules.materials.router import router
|
||||
@@ -1 +0,0 @@
|
||||
from app.modules.publish.router import router
|
||||
@@ -1 +0,0 @@
|
||||
from app.modules.ref_audios.router import router
|
||||
@@ -1 +0,0 @@
|
||||
from app.modules.tools.router import router
|
||||
@@ -1 +0,0 @@
|
||||
from app.modules.videos.router import router
|
||||
@@ -3,46 +3,46 @@ from pathlib import Path
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# 基础路径配置
|
||||
BASE_DIR: Path = Path(__file__).resolve().parent.parent
|
||||
UPLOAD_DIR: Path = BASE_DIR.parent / "uploads"
|
||||
OUTPUT_DIR: Path = BASE_DIR.parent / "outputs"
|
||||
ASSETS_DIR: Path = BASE_DIR.parent / "assets"
|
||||
PUBLISH_SCREENSHOT_DIR: Path = BASE_DIR.parent / "private_outputs" / "publish_screenshots"
|
||||
BASE_DIR: Path = Path(__file__).resolve().parent.parent
|
||||
UPLOAD_DIR: Path = BASE_DIR.parent / "uploads"
|
||||
OUTPUT_DIR: Path = BASE_DIR.parent / "outputs"
|
||||
ASSETS_DIR: Path = BASE_DIR.parent / "assets"
|
||||
PUBLISH_SCREENSHOT_DIR: Path = BASE_DIR.parent / "private_outputs" / "publish_screenshots"
|
||||
|
||||
# 数据库/缓存
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
DEBUG: bool = True
|
||||
|
||||
# Playwright 配置
|
||||
WEIXIN_HEADLESS_MODE: str = "headless-new"
|
||||
WEIXIN_USER_AGENT: str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
WEIXIN_LOCALE: str = "zh-CN"
|
||||
WEIXIN_TIMEZONE_ID: str = "Asia/Shanghai"
|
||||
WEIXIN_CHROME_PATH: str = "/usr/bin/google-chrome"
|
||||
WEIXIN_BROWSER_CHANNEL: str = ""
|
||||
WEIXIN_FORCE_SWIFTSHADER: bool = True
|
||||
WEIXIN_TRANSCODE_MODE: str = "reencode"
|
||||
WEIXIN_DEBUG_ARTIFACTS: bool = False
|
||||
WEIXIN_RECORD_VIDEO: bool = False
|
||||
WEIXIN_KEEP_SUCCESS_VIDEO: bool = False
|
||||
WEIXIN_RECORD_VIDEO_WIDTH: int = 1280
|
||||
WEIXIN_RECORD_VIDEO_HEIGHT: int = 720
|
||||
|
||||
# Douyin Playwright 配置
|
||||
DOUYIN_HEADLESS_MODE: str = "headless-new"
|
||||
DOUYIN_USER_AGENT: str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
DOUYIN_LOCALE: str = "zh-CN"
|
||||
DOUYIN_TIMEZONE_ID: str = "Asia/Shanghai"
|
||||
DOUYIN_CHROME_PATH: str = "/usr/bin/google-chrome"
|
||||
DOUYIN_BROWSER_CHANNEL: str = ""
|
||||
DOUYIN_FORCE_SWIFTSHADER: bool = True
|
||||
|
||||
# Douyin 调试录屏
|
||||
DOUYIN_DEBUG_ARTIFACTS: bool = False
|
||||
DOUYIN_RECORD_VIDEO: bool = False
|
||||
DOUYIN_KEEP_SUCCESS_VIDEO: bool = False
|
||||
DOUYIN_RECORD_VIDEO_WIDTH: int = 1280
|
||||
DOUYIN_RECORD_VIDEO_HEIGHT: int = 720
|
||||
# 数据库/缓存
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
DEBUG: bool = True
|
||||
|
||||
# Playwright 配置
|
||||
WEIXIN_HEADLESS_MODE: str = "headless-new"
|
||||
WEIXIN_USER_AGENT: str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
WEIXIN_LOCALE: str = "zh-CN"
|
||||
WEIXIN_TIMEZONE_ID: str = "Asia/Shanghai"
|
||||
WEIXIN_CHROME_PATH: str = "/usr/bin/google-chrome"
|
||||
WEIXIN_BROWSER_CHANNEL: str = ""
|
||||
WEIXIN_FORCE_SWIFTSHADER: bool = True
|
||||
WEIXIN_TRANSCODE_MODE: str = "reencode"
|
||||
WEIXIN_DEBUG_ARTIFACTS: bool = False
|
||||
WEIXIN_RECORD_VIDEO: bool = False
|
||||
WEIXIN_KEEP_SUCCESS_VIDEO: bool = False
|
||||
WEIXIN_RECORD_VIDEO_WIDTH: int = 1280
|
||||
WEIXIN_RECORD_VIDEO_HEIGHT: int = 720
|
||||
|
||||
# Douyin Playwright 配置
|
||||
DOUYIN_HEADLESS_MODE: str = "headless-new"
|
||||
DOUYIN_USER_AGENT: str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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"
|
||||
@@ -76,6 +76,12 @@ class Settings(BaseSettings):
|
||||
GLM_API_KEY: str = ""
|
||||
GLM_MODEL: str = "glm-4.7-flash"
|
||||
|
||||
# CORS 配置 (逗号分隔的域名列表,* 表示允许所有)
|
||||
CORS_ORIGINS: str = "*"
|
||||
|
||||
# 抖音 Cookie (用于视频下载功能,会过期需要定期更新)
|
||||
DOUYIN_COOKIE: str = ""
|
||||
|
||||
@property
|
||||
def LATENTSYNC_DIR(self) -> Path:
|
||||
"""LatentSync 目录路径 (动态计算)"""
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from app.core import config
|
||||
from app.core.response import error_response
|
||||
from app.api import materials, videos, publish, login_helper, auth, admin, ref_audios, ai, tools, assets
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from app.core import config
|
||||
from app.core.response import error_response
|
||||
# 直接从 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
|
||||
|
||||
@@ -12,17 +22,34 @@ settings = config.settings
|
||||
|
||||
app = FastAPI(title="ViGent TalkingHead Agent")
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi import Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
import time
|
||||
import traceback
|
||||
|
||||
class LoggingMiddleware(BaseHTTPMiddleware):
|
||||
# 敏感 header 名称列表(小写)
|
||||
SENSITIVE_HEADERS = {'authorization', 'cookie', 'set-cookie', 'x-api-key', 'api-key'}
|
||||
|
||||
def _sanitize_headers(self, headers: dict) -> dict:
|
||||
"""脱敏处理请求头,隐藏敏感信息"""
|
||||
sanitized = {}
|
||||
for key, value in headers.items():
|
||||
if key.lower() in self.SENSITIVE_HEADERS:
|
||||
# 显示前8个字符 + 掩码
|
||||
if len(value) > 8:
|
||||
sanitized[key] = value[:8] + "..." + f"[{len(value)} chars]"
|
||||
else:
|
||||
sanitized[key] = "[REDACTED]"
|
||||
else:
|
||||
sanitized[key] = value
|
||||
return sanitized
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
start_time = time.time()
|
||||
logger.info(f"START Request: {request.method} {request.url}")
|
||||
logger.info(f"HEADERS: {dict(request.headers)}")
|
||||
logger.debug(f"HEADERS: {self._sanitize_headers(dict(request.headers))}")
|
||||
try:
|
||||
response = await call_next(request)
|
||||
process_time = time.time() - start_time
|
||||
@@ -33,65 +60,70 @@ class LoggingMiddleware(BaseHTTPMiddleware):
|
||||
logger.error(f"EXCEPTION during request {request.method} {request.url}: {str(e)}\n{traceback.format_exc()}")
|
||||
raise e
|
||||
|
||||
app.add_middleware(LoggingMiddleware)
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content=error_response("参数校验失败", 422, data=exc.errors()),
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_exception_handler(request: Request, exc: HTTPException):
|
||||
detail = exc.detail
|
||||
message = detail if isinstance(detail, str) else "请求失败"
|
||||
data = detail if not isinstance(detail, str) else None
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=error_response(message, exc.status_code, data=data),
|
||||
headers=exc.headers,
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def unhandled_exception_handler(request: Request, exc: Exception):
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=error_response("服务器内部错误", 500),
|
||||
)
|
||||
app.add_middleware(LoggingMiddleware)
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content=error_response("参数校验失败", 422, data=exc.errors()),
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_exception_handler(request: Request, exc: HTTPException):
|
||||
detail = exc.detail
|
||||
message = detail if isinstance(detail, str) else "请求失败"
|
||||
data = detail if not isinstance(detail, str) else None
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=error_response(message, exc.status_code, data=data),
|
||||
headers=exc.headers,
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def unhandled_exception_handler(request: Request, exc: Exception):
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=error_response("服务器内部错误", 500),
|
||||
)
|
||||
|
||||
# CORS 配置:从环境变量读取允许的域名
|
||||
# 当使用 credentials 时,不能使用 * 通配符
|
||||
cors_origins = settings.CORS_ORIGINS.split(",") if settings.CORS_ORIGINS != "*" else ["*"]
|
||||
allow_credentials = settings.CORS_ORIGINS != "*" # 使用 * 时不能 allow_credentials
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_origins=cors_origins,
|
||||
allow_credentials=allow_credentials,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Create dirs
|
||||
settings.UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
settings.OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
(settings.UPLOAD_DIR / "materials").mkdir(exist_ok=True)
|
||||
settings.ASSETS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
settings.UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
settings.OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
(settings.UPLOAD_DIR / "materials").mkdir(exist_ok=True)
|
||||
settings.ASSETS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
app.mount("/outputs", StaticFiles(directory=str(settings.OUTPUT_DIR)), name="outputs")
|
||||
app.mount("/uploads", StaticFiles(directory=str(settings.UPLOAD_DIR)), name="uploads")
|
||||
app.mount("/assets", StaticFiles(directory=str(settings.ASSETS_DIR)), name="assets")
|
||||
app.mount("/outputs", StaticFiles(directory=str(settings.OUTPUT_DIR)), name="outputs")
|
||||
app.mount("/uploads", StaticFiles(directory=str(settings.UPLOAD_DIR)), name="uploads")
|
||||
app.mount("/assets", StaticFiles(directory=str(settings.ASSETS_DIR)), name="assets")
|
||||
|
||||
# 注册路由
|
||||
app.include_router(materials.router, prefix="/api/materials", tags=["Materials"])
|
||||
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")
|
||||
@@ -107,21 +139,21 @@ async def init_admin():
|
||||
return
|
||||
|
||||
try:
|
||||
from app.core.security import get_password_hash
|
||||
from app.repositories.users import create_user, user_exists_by_phone
|
||||
|
||||
if user_exists_by_phone(admin_phone):
|
||||
logger.info(f"管理员账号已存在: {admin_phone}")
|
||||
return
|
||||
|
||||
create_user({
|
||||
"phone": admin_phone,
|
||||
"password_hash": get_password_hash(admin_password),
|
||||
"username": "Admin",
|
||||
"role": "admin",
|
||||
"is_active": True,
|
||||
"expires_at": None # 永不过期
|
||||
})
|
||||
from app.core.security import get_password_hash
|
||||
from app.repositories.users import create_user, user_exists_by_phone
|
||||
|
||||
if user_exists_by_phone(admin_phone):
|
||||
logger.info(f"管理员账号已存在: {admin_phone}")
|
||||
return
|
||||
|
||||
create_user({
|
||||
"phone": admin_phone,
|
||||
"password_hash": get_password_hash(admin_password),
|
||||
"username": "Admin",
|
||||
"role": "admin",
|
||||
"is_active": True,
|
||||
"expires_at": None # 永不过期
|
||||
})
|
||||
|
||||
logger.success(f"管理员账号已创建: {admin_phone}")
|
||||
except Exception as e:
|
||||
|
||||
@@ -249,16 +249,17 @@ async def list_ref_audios(user: dict = Depends(get_current_user)):
|
||||
# 列出用户目录下的文件
|
||||
files = await storage_service.list_files(BUCKET_REF_AUDIOS, user_id)
|
||||
|
||||
# 过滤出 .wav 文件并获取对应的 metadata
|
||||
items = []
|
||||
for f in files:
|
||||
# 过滤出 .wav 文件
|
||||
wav_files = [f for f in files if f.get("name", "").endswith(".wav")]
|
||||
|
||||
if not wav_files:
|
||||
return success_response(RefAudioListResponse(items=[]).model_dump())
|
||||
|
||||
# 并发获取所有 metadata 和签名 URL
|
||||
async def fetch_audio_info(f):
|
||||
"""获取单个音频的信息(metadata + signed URL)"""
|
||||
name = f.get("name", "")
|
||||
if not name.endswith(".wav"):
|
||||
continue
|
||||
|
||||
storage_path = f"{user_id}/{name}"
|
||||
|
||||
# 尝试读取 metadata
|
||||
metadata_name = name.replace(".wav", ".json")
|
||||
metadata_path = f"{user_id}/{metadata_name}"
|
||||
|
||||
@@ -271,7 +272,7 @@ async def list_ref_audios(user: dict = Depends(get_current_user)):
|
||||
# 获取 metadata 内容
|
||||
metadata_url = await storage_service.get_signed_url(BUCKET_REF_AUDIOS, metadata_path)
|
||||
import httpx
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
resp = await client.get(metadata_url)
|
||||
if resp.status_code == 200:
|
||||
metadata = resp.json()
|
||||
@@ -280,7 +281,7 @@ async def list_ref_audios(user: dict = Depends(get_current_user)):
|
||||
created_at = metadata.get("created_at", 0)
|
||||
original_filename = metadata.get("original_filename", "")
|
||||
except Exception as e:
|
||||
logger.warning(f"读取 metadata 失败: {e}")
|
||||
logger.debug(f"读取 metadata 失败: {e}")
|
||||
# 从文件名提取时间戳
|
||||
try:
|
||||
created_at = int(name.split("_")[0])
|
||||
@@ -299,17 +300,21 @@ async def list_ref_audios(user: dict = Depends(get_current_user)):
|
||||
if match:
|
||||
display_name = match.group(1)
|
||||
|
||||
items.append(RefAudioResponse(
|
||||
return RefAudioResponse(
|
||||
id=storage_path,
|
||||
name=display_name,
|
||||
path=signed_url,
|
||||
ref_text=ref_text,
|
||||
duration_sec=duration_sec,
|
||||
created_at=created_at
|
||||
))
|
||||
)
|
||||
|
||||
# 使用 asyncio.gather 并发获取所有音频信息
|
||||
import asyncio
|
||||
items = await asyncio.gather(*[fetch_audio_info(f) for f in wav_files])
|
||||
|
||||
# 按创建时间倒序排列
|
||||
items.sort(key=lambda x: x.created_at, reverse=True)
|
||||
items = sorted(items, key=lambda x: x.created_at, reverse=True)
|
||||
|
||||
return success_response(RefAudioListResponse(items=items).model_dump())
|
||||
|
||||
|
||||
@@ -210,6 +210,8 @@ async def download_douyin_manual(url: str, temp_dir: Path, timestamp: int) -> Op
|
||||
手动下载抖音视频 (Fallback logic - Ported from SuperIPAgent/douyinDownloader)
|
||||
使用特定的 User Profile URL 和硬编码 Cookie 绕过反爬
|
||||
"""
|
||||
import httpx
|
||||
|
||||
logger.info(f"[SuperIPAgent] Starting download for: {url}")
|
||||
|
||||
try:
|
||||
@@ -218,9 +220,11 @@ async def download_douyin_manual(url: str, temp_dir: Path, timestamp: int) -> Op
|
||||
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
||||
}
|
||||
|
||||
# 如果是短链或重定向
|
||||
resp = requests.get(url, headers=headers, allow_redirects=True, timeout=10)
|
||||
final_url = resp.url
|
||||
# 如果是短链或重定向 - 使用异步 httpx
|
||||
async with httpx.AsyncClient(follow_redirects=True, timeout=10.0) as client:
|
||||
resp = await client.get(url, headers=headers)
|
||||
final_url = str(resp.url)
|
||||
|
||||
logger.info(f"[SuperIPAgent] Final URL: {final_url}")
|
||||
|
||||
modal_id = None
|
||||
@@ -238,16 +242,21 @@ async def download_douyin_manual(url: str, temp_dir: Path, timestamp: int) -> Op
|
||||
# 使用特定用户的 Profile 页 + modal_id 参数,配合特定 Cookie
|
||||
target_url = f"https://www.douyin.com/user/MS4wLjABAAAAN_s_hups7LD0N4qnrM3o2gI0vuG3pozNaEolz2_py3cHTTrpVr1Z4dukFD9SOlwY?from_tab_name=main&modal_id={modal_id}"
|
||||
|
||||
# 3. 使用硬编码 Cookie (Copy from SuperIPAgent)
|
||||
# 3. 使用配置的 Cookie (从环境变量 DOUYIN_COOKIE 读取)
|
||||
from app.core.config import settings
|
||||
if not settings.DOUYIN_COOKIE:
|
||||
logger.warning("[SuperIPAgent] DOUYIN_COOKIE 未配置,视频下载可能失败")
|
||||
|
||||
headers_with_cookie = {
|
||||
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
||||
"cookie": "douyin.com; device_web_cpu_core=10; device_web_memory_size=8; __ac_nonce=06760391f00b9b51264ae; __ac_signature=_02B4Z6wo00f019a5ceAAAIDAhEZR-X3jjWfWmXVAAJLXd4; ttwid=1%7C7MTKBSMsP4eOv9h5NAh8p0E-NYIud09ftNmB0mjLpWc%7C1734359327%7C8794abeabbd47447e1f56e5abc726be089f2a0344d6343b5f75f23e7b0f0028f; UIFID_TEMP=0de8750d2b188f4235dbfd208e44abbb976428f0720eb983255afefa45d39c0c6532e1d4768dd8587bf919f866ff1396912bcb2af71efee56a14a2a9f37b74010d0a0413795262f6d4afe02a032ac7ab; s_v_web_id=verify_m4r4ribr_c7krmY1z_WoeI_43po_ATpO_I4o8U1bex2D7; hevc_supported=true; home_can_add_dy_2_desktop=%220%22; dy_swidth=2560; dy_sheight=1440; stream_recommend_feed_params=%22%7B%5C%22cookie_enabled%5C%22%3Atrue%2C%5C%22screen_width%5C%22%3A2560%2C%5C%22screen_height%5C%22%3A1440%2C%5C%22browser_online%5C%22%3Atrue%2C%5C%22cpu_core_num%5C%22%3A10%2C%5C%22device_memory%5C%22%3A8%2C%5C%22downlink%5C%22%3A10%2C%5C%22effective_type%5C%22%3A%5C%224g%5C%22%2C%5C%22round_trip_time%5C%22%3A50%7D%22; strategyABtestKey=%221734359328.577%22; csrf_session_id=2f53aed9aa6974e83aa9a1014180c3a4; fpk1=U2FsdGVkX1/IpBh0qdmlKAVhGyYHgur4/VtL9AReZoeSxadXn4juKvsakahRGqjxOPytHWspYoBogyhS/V6QSw==; fpk2=0845b309c7b9b957afd9ecf775a4c21f; passport_csrf_token=d80e0c5b2fa2328219856be5ba7e671e; passport_csrf_token_default=d80e0c5b2fa2328219856be5ba7e671e; odin_tt=3c891091d2eb0f4718c1d5645bc4a0017032d4d5aa989decb729e9da2ad570918cbe5e9133dc6b145fa8c758de98efe32ff1f81aa0d611e838cc73ab08ef7d3f6adf66ab4d10e8372ddd628f94f16b8e; volume_info=%7B%22isUserMute%22%3Afalse%2C%22isMute%22%3Afalse%2C%22volume%22%3A0.5%7D; bd_ticket_guard_client_web_domain=2; FORCE_LOGIN=%7B%22videoConsumedRemainSeconds%22%3A180%7D; UIFID=0de8750d2b188f4235dbfd208e44abbb976428f0720eb983255afefa45d39c0c6532e1d4768dd8587bf919f866ff139655a3c2b735923234f371c699560c657923fd3d6c5b63ab7bb9b83423b6cb4787e2ce66a7fbc4ecb24c8570f520fe6de068bbb95115023c0c6c1b6ee31b49fb7e3996fb8349f43a3fd8b7a61cd9e18e8fe65eb6a7c13de4c0960d84e344b644725db3eb2fa6b7caf821de1b50527979f2; is_dash_user=1; biz_trace_id=b57a241f; bd_ticket_guard_client_data=eyJiZC10aWNrZXQtZ3VhcmQtdmVyc2lvbiI6MiwiYmQtdGlja2V0LWd1YXJkLWl0ZXJhdGlvbi12ZXJzaW9uIjoxLCJiZC10aWNrZXQtZ3VhcmQtcmVlLXB1YmxpYy1rZXkiOiJCTEo2R0lDalVoWW1XcHpGOFdrN0Vrc0dXcCtaUzNKY1g4NGNGY2k0TTl1TEowNjdUb21mbFU5aDdvWVBGamhNRWNRQWtKdnN1MnM3RmpTWnlJQXpHMjA9IiwiYmQtdGlja2V0LWd1YXJkLXdlYi12ZXJzaW9uIjoyfQ%3D%3D; download_guide=%221%2F20241216%2F0%22; sdk_source_info=7e276470716a68645a606960273f276364697660272927676c715a6d6069756077273f276364697660272927666d776a68605a607d71606b766c6a6b5a7666776c7571273f275e58272927666a6b766a69605a696c6061273f27636469766027292762696a6764695a7364776c6467696076273f275e5827292771273f273d33323131333c3036313632342778; bit_env=RiOY4jzzpxZoVCl6zdVSVhVRjdwHRTxqcqWdqMBZLPGjMdB4Tax1kAELHNTVAAh72KuhumewE4Lq6f0-VJ2UpJrkrhSxoPw9LUb3zQrq1OSwbeSPHkRlRgRQvO89sItdGUyq1oFr0XyRCnMYG87KSeWyc4x0czGR0o50hTDoDLG5rJVoRcdQOLvjiAegsqyytKF59sPX_QM9qffK2SqYsg0hCggURc_AI6kguDDE5DvG0bnyz1utw4z1eEnIoLrkGDqzqBZj4dOAr0BVU6ofbsS-pOQ2u2PM1dLP9FlBVBlVaqYVgHJeSLsR5k76BRTddUjTb4zEilVIEwAMJWGN4I1BxVt6fC9B5tBQpuT0lj3n3eKXCKXZsd8FrEs5_pbfDsxV-e_WMiXI2ff4qxiTC0U73sfo9OpicKICtZjdq8qsHxJuu6wVR36zvXeL2Wch5C6MzprNvkivv0l8nbh2mSgy1nabZr3dmU6NcR-Bg3Q3xTWUlR9aAUmpopC-cNuXjgLpT-Lw1AYGilSUnCvosth1Gfypq-b0MpgmdSDgTrQ%3D; gulu_source_res=eyJwX2luIjoiMDhjOGQ3ZTJiODQyNjZkZWI5Y2VkMGJiODNlNmY1ZWY0ZjMyNTE2ZmYyZjAzNDMzZjI0OWU1Y2Q1NTczNTk5NyJ9; passport_auth_mix_state=hp9bc3dgb1tm5wd8p82zawus27g0e3ue; IsDouyinActive=false",
|
||||
"cookie": settings.DOUYIN_COOKIE,
|
||||
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||
}
|
||||
|
||||
logger.info(f"[SuperIPAgent] Requesting page with Cookie...")
|
||||
# 必须 verify=False 否则有些环境会报错
|
||||
response = requests.get(target_url, headers=headers_with_cookie, timeout=10)
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(target_url, headers=headers_with_cookie)
|
||||
|
||||
# 4. 解析 RENDER_DATA
|
||||
content_match = re.findall(r'<script id="RENDER_DATA" type="application/json">(.*?)</script>', response.text)
|
||||
@@ -290,24 +299,25 @@ async def download_douyin_manual(url: str, temp_dir: Path, timestamp: int) -> Op
|
||||
|
||||
logger.info(f"[SuperIPAgent] Found video URL: {video_url[:50]}...")
|
||||
|
||||
# 6. 下载 (带 Header)
|
||||
# 6. 下载 (带 Header) - 使用异步 httpx
|
||||
temp_path = temp_dir / f"douyin_manual_{timestamp}.mp4"
|
||||
download_headers = {
|
||||
'Referer': 'https://www.douyin.com/',
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||
}
|
||||
|
||||
dl_resp = requests.get(video_url, headers=download_headers, stream=True, timeout=60)
|
||||
if dl_resp.status_code == 200:
|
||||
with open(temp_path, 'wb') as f:
|
||||
for chunk in dl_resp.iter_content(chunk_size=1024):
|
||||
f.write(chunk)
|
||||
|
||||
logger.info(f"[SuperIPAgent] Downloaded successfully: {temp_path}")
|
||||
return temp_path
|
||||
else:
|
||||
logger.error(f"[SuperIPAgent] Download failed: {dl_resp.status_code}")
|
||||
return None
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
async with client.stream("GET", video_url, headers=download_headers) as dl_resp:
|
||||
if dl_resp.status_code == 200:
|
||||
with open(temp_path, 'wb') as f:
|
||||
async for chunk in dl_resp.aiter_bytes(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
|
||||
logger.info(f"[SuperIPAgent] Downloaded successfully: {temp_path}")
|
||||
return temp_path
|
||||
else:
|
||||
logger.error(f"[SuperIPAgent] Download failed: {dl_resp.status_code}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[SuperIPAgent] Logic failed: {e}")
|
||||
|
||||
@@ -27,13 +27,20 @@ async def generate_video(
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}")
|
||||
async def get_task_status(task_id: str):
|
||||
return success_response(get_task(task_id))
|
||||
async def get_task_status(task_id: str, current_user: dict = Depends(get_current_user)):
|
||||
task = get_task(task_id)
|
||||
# 验证任务归属:只能查看自己的任务
|
||||
if task.get("status") != "not_found" and task.get("user_id") != current_user["id"]:
|
||||
return success_response({"status": "not_found"})
|
||||
return success_response(task)
|
||||
|
||||
|
||||
@router.get("/tasks")
|
||||
async def list_tasks_view():
|
||||
return success_response({"tasks": list_tasks()})
|
||||
async def list_tasks_view(current_user: dict = Depends(get_current_user)):
|
||||
# 只返回当前用户的任务
|
||||
all_tasks = list_tasks()
|
||||
user_tasks = [t for t in all_tasks if t.get("user_id") == current_user["id"]]
|
||||
return success_response({"tasks": user_tasks})
|
||||
|
||||
|
||||
@router.get("/lipsync/health")
|
||||
|
||||
@@ -15,5 +15,7 @@ class GenerateRequest(BaseModel):
|
||||
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
|
||||
|
||||
@@ -216,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,
|
||||
@@ -277,14 +287,12 @@ async def process_video_generation(task_id: str, req: GenerateRequest, user_id:
|
||||
_update_task(task_id, message="正在上传结果...", progress=95)
|
||||
|
||||
storage_path = f"{user_id}/{task_id}_output.mp4"
|
||||
with open(final_output_local_path, "rb") as f:
|
||||
file_data = f.read()
|
||||
await storage_service.upload_file(
|
||||
bucket=storage_service.BUCKET_OUTPUTS,
|
||||
path=storage_path,
|
||||
file_data=file_data,
|
||||
content_type="video/mp4"
|
||||
)
|
||||
await storage_service.upload_file_from_path(
|
||||
bucket=storage_service.BUCKET_OUTPUTS,
|
||||
storage_path=storage_path,
|
||||
local_file_path=str(final_output_local_path),
|
||||
content_type="video/mp4"
|
||||
)
|
||||
|
||||
signed_url = await storage_service.get_signed_url(
|
||||
bucket=storage_service.BUCKET_OUTPUTS,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -17,20 +17,20 @@ from app.services.storage import storage_service
|
||||
# Import platform uploaders
|
||||
from .uploader.bilibili_uploader import BilibiliUploader
|
||||
from .uploader.douyin_uploader import DouyinUploader
|
||||
from .uploader.xiaohongshu_uploader import XiaohongshuUploader
|
||||
from .uploader.weixin_uploader import WeixinUploader
|
||||
from .uploader.xiaohongshu_uploader import XiaohongshuUploader
|
||||
from .uploader.weixin_uploader import WeixinUploader
|
||||
|
||||
|
||||
class PublishService:
|
||||
"""Social media publishing service (with user isolation)"""
|
||||
|
||||
# 支持的平台配置
|
||||
PLATFORMS: Dict[str, Dict[str, Any]] = {
|
||||
"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},
|
||||
}
|
||||
PLATFORMS: Dict[str, Dict[str, Any]] = {
|
||||
"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},
|
||||
}
|
||||
|
||||
def __init__(self) -> None:
|
||||
# 存储活跃的登录会话,用于跟踪登录状态
|
||||
@@ -175,36 +175,36 @@ class PublishService:
|
||||
tid=kwargs.get('tid', 122),
|
||||
copyright=kwargs.get('copyright', 1)
|
||||
)
|
||||
elif platform == "douyin":
|
||||
uploader = DouyinUploader(
|
||||
title=title,
|
||||
file_path=local_video_path,
|
||||
tags=tags,
|
||||
publish_date=publish_time,
|
||||
account_file=str(account_file),
|
||||
description=description,
|
||||
user_id=user_id,
|
||||
)
|
||||
elif platform == "xiaohongshu":
|
||||
uploader = XiaohongshuUploader(
|
||||
title=title,
|
||||
file_path=local_video_path,
|
||||
tags=tags,
|
||||
publish_date=publish_time,
|
||||
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:
|
||||
elif platform == "douyin":
|
||||
uploader = DouyinUploader(
|
||||
title=title,
|
||||
file_path=local_video_path,
|
||||
tags=tags,
|
||||
publish_date=publish_time,
|
||||
account_file=str(account_file),
|
||||
description=description,
|
||||
user_id=user_id,
|
||||
)
|
||||
elif platform == "xiaohongshu":
|
||||
uploader = XiaohongshuUploader(
|
||||
title=title,
|
||||
file_path=local_video_path,
|
||||
tags=tags,
|
||||
publish_date=publish_time,
|
||||
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 {
|
||||
"success": False,
|
||||
@@ -236,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()
|
||||
|
||||
@@ -273,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]:
|
||||
"""
|
||||
|
||||
@@ -1,59 +1,67 @@
|
||||
"""
|
||||
QR码自动登录服务
|
||||
后端Playwright无头模式获取二维码,前端扫码后自动保存Cookie
|
||||
"""
|
||||
"""
|
||||
QR码自动登录服务
|
||||
后端Playwright无头模式获取二维码,前端扫码后自动保存Cookie
|
||||
"""
|
||||
import asyncio
|
||||
import time
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any, List, Sequence, Mapping, Union
|
||||
from playwright.async_api import async_playwright, Page, Frame, BrowserContext, Browser, Playwright as PW
|
||||
from loguru import logger
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
|
||||
|
||||
class QRLoginService:
|
||||
"""QR码登录服务"""
|
||||
|
||||
# 登录监控超时 (秒)
|
||||
LOGIN_TIMEOUT = 120
|
||||
|
||||
"""QR码登录服务"""
|
||||
|
||||
# 登录监控超时 (秒)
|
||||
LOGIN_TIMEOUT = 180
|
||||
|
||||
def __init__(self, platform: str, cookies_dir: Path) -> None:
|
||||
self.platform = platform
|
||||
self.cookies_dir = cookies_dir
|
||||
self.qr_code_image: Optional[str] = None
|
||||
self.login_success: bool = False
|
||||
self.cookies_data: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Playwright 资源 (手动管理生命周期)
|
||||
self.playwright: Optional[PW] = None
|
||||
self.browser: Optional[Browser] = None
|
||||
self.context: Optional[BrowserContext] = None
|
||||
|
||||
# 每个平台使用多个选择器 (使用逗号分隔,Playwright会同时等待它们)
|
||||
self.cookies_dir = cookies_dir
|
||||
self.qr_code_image: Optional[str] = None
|
||||
self.login_success: bool = False
|
||||
self.cookies_data: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Playwright 资源 (手动管理生命周期)
|
||||
self.playwright: Optional[PW] = None
|
||||
self.browser: Optional[Browser] = None
|
||||
self.context: Optional[BrowserContext] = None
|
||||
|
||||
# 抖音 check_qrconnect API 响应拦截
|
||||
self._qr_api_confirmed: bool = False
|
||||
self._qr_redirect_url: Optional[str] = None
|
||||
self._douyin_needs_verify: bool = False # 需要APP验证
|
||||
|
||||
# 刷脸验证二维码(点击刷脸后页面展示新二维码,需要前端再次展示给用户)
|
||||
self._face_verify_qr: Optional[str] = None # base64 截图
|
||||
|
||||
# 每个平台使用多个选择器 (使用逗号分隔,Playwright会同时等待它们)
|
||||
self.platform_configs = {
|
||||
"bilibili": {
|
||||
"url": "https://passport.bilibili.com/login",
|
||||
"qr_selectors": [
|
||||
"div[class*='qrcode'] canvas", # 常见canvas二维码
|
||||
"div[class*='qrcode'] img", # 常见图片二维码
|
||||
".qrcode-img img", # 旧版
|
||||
".login-scan-box img", # 扫码框
|
||||
"div[class*='scan'] img"
|
||||
],
|
||||
"success_indicator": "https://www.bilibili.com/"
|
||||
},
|
||||
"douyin": {
|
||||
"url": "https://creator.douyin.com/",
|
||||
"qr_selectors": [
|
||||
".qrcode img", # 优先尝试
|
||||
"img[alt='qrcode']",
|
||||
"canvas[class*='qr']",
|
||||
"img[src*='qr']"
|
||||
],
|
||||
"success_indicator": "https://creator.douyin.com/creator-micro"
|
||||
},
|
||||
"bilibili": {
|
||||
"url": "https://passport.bilibili.com/login",
|
||||
"qr_selectors": [
|
||||
"div[class*='qrcode'] canvas", # 常见canvas二维码
|
||||
"div[class*='qrcode'] img", # 常见图片二维码
|
||||
".qrcode-img img", # 旧版
|
||||
".login-scan-box img", # 扫码框
|
||||
"div[class*='scan'] img"
|
||||
],
|
||||
"success_indicator": "https://www.bilibili.com/"
|
||||
},
|
||||
"douyin": {
|
||||
"url": "https://creator.douyin.com/",
|
||||
"qr_selectors": [
|
||||
".qrcode img", # 优先尝试
|
||||
"img[alt='qrcode']",
|
||||
"canvas[class*='qr']",
|
||||
"img[src*='qr']"
|
||||
],
|
||||
"success_indicator": "https://creator.douyin.com/creator-micro"
|
||||
},
|
||||
"xiaohongshu": {
|
||||
"url": "https://creator.xiaohongshu.com/",
|
||||
"qr_selectors": [
|
||||
@@ -79,10 +87,15 @@ class QRLoginService:
|
||||
}
|
||||
|
||||
def _resolve_headless_mode(self) -> str:
|
||||
if self.platform != "weixin":
|
||||
return "headless"
|
||||
mode = (settings.WEIXIN_HEADLESS_MODE or "").strip().lower()
|
||||
return mode or "headful"
|
||||
# 抖音和微信使用 headful 模式(xvfb 虚拟显示),避免反爬检测
|
||||
# 其他平台使用 headless-new
|
||||
if self.platform == "douyin":
|
||||
mode = (settings.DOUYIN_HEADLESS_MODE or "").strip().lower()
|
||||
return mode or "headful"
|
||||
if self.platform == "weixin":
|
||||
mode = (settings.WEIXIN_HEADLESS_MODE or "").strip().lower()
|
||||
return mode or "headful"
|
||||
return "headless-new"
|
||||
|
||||
def _is_square_bbox(self, bbox: Optional[Dict[str, float]], min_side: int = 100) -> bool:
|
||||
if not bbox:
|
||||
@@ -158,20 +171,20 @@ class QRLoginService:
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
async def start_login(self) -> Dict[str, Any]:
|
||||
"""
|
||||
启动登录流程
|
||||
|
||||
Returns:
|
||||
dict: 包含二维码base64和状态
|
||||
"""
|
||||
if self.platform not in self.platform_configs:
|
||||
return {"success": False, "message": "不支持的平台"}
|
||||
|
||||
config = self.platform_configs[self.platform]
|
||||
|
||||
try:
|
||||
|
||||
async def start_login(self) -> Dict[str, Any]:
|
||||
"""
|
||||
启动登录流程
|
||||
|
||||
Returns:
|
||||
dict: 包含二维码base64和状态
|
||||
"""
|
||||
if self.platform not in self.platform_configs:
|
||||
return {"success": False, "message": "不支持的平台"}
|
||||
|
||||
config = self.platform_configs[self.platform]
|
||||
|
||||
try:
|
||||
# 1. 启动 Playwright (不使用 async with,手动管理生命周期)
|
||||
self.playwright = await async_playwright().start()
|
||||
|
||||
@@ -180,46 +193,66 @@ class QRLoginService:
|
||||
launch_args = [
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--no-sandbox',
|
||||
'--disable-dev-shm-usage'
|
||||
'--disable-dev-shm-usage',
|
||||
]
|
||||
if headless and mode in ("new", "headless-new", "headless_new"):
|
||||
launch_args.append("--headless=new")
|
||||
if not headless:
|
||||
# headful 模式下 xvfb 没有 GPU,需要软件渲染
|
||||
launch_args.extend([
|
||||
'--use-gl=swiftshader',
|
||||
'--disable-gpu',
|
||||
])
|
||||
|
||||
# Stealth模式启动浏览器
|
||||
launch_options: Dict[str, Any] = {
|
||||
"headless": headless,
|
||||
"args": launch_args,
|
||||
}
|
||||
if self.platform == "weixin":
|
||||
# 根据平台选择对应的浏览器配置
|
||||
if self.platform == "douyin":
|
||||
chrome_path = (settings.DOUYIN_CHROME_PATH or "").strip()
|
||||
browser_channel = (settings.DOUYIN_BROWSER_CHANNEL or "").strip()
|
||||
user_agent = settings.DOUYIN_USER_AGENT
|
||||
locale = settings.DOUYIN_LOCALE
|
||||
timezone_id = settings.DOUYIN_TIMEZONE_ID
|
||||
elif self.platform == "weixin":
|
||||
chrome_path = (settings.WEIXIN_CHROME_PATH or "").strip()
|
||||
if chrome_path:
|
||||
if Path(chrome_path).exists():
|
||||
launch_options["executable_path"] = chrome_path
|
||||
else:
|
||||
logger.warning(f"[weixin] WEIXIN_CHROME_PATH not found: {chrome_path}")
|
||||
else:
|
||||
channel = (settings.WEIXIN_BROWSER_CHANNEL or "").strip()
|
||||
if channel:
|
||||
launch_options["channel"] = channel
|
||||
browser_channel = (settings.WEIXIN_BROWSER_CHANNEL or "").strip()
|
||||
user_agent = settings.WEIXIN_USER_AGENT
|
||||
locale = settings.WEIXIN_LOCALE
|
||||
timezone_id = settings.WEIXIN_TIMEZONE_ID
|
||||
else:
|
||||
# B站、小红书等:使用通用默认值
|
||||
chrome_path = (settings.WEIXIN_CHROME_PATH or "").strip()
|
||||
browser_channel = ""
|
||||
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
locale = "zh-CN"
|
||||
timezone_id = "Asia/Shanghai"
|
||||
|
||||
if chrome_path and Path(chrome_path).exists():
|
||||
launch_options["executable_path"] = chrome_path
|
||||
elif browser_channel:
|
||||
launch_options["channel"] = browser_channel
|
||||
|
||||
self.browser = await self.playwright.chromium.launch(**launch_options)
|
||||
|
||||
|
||||
# 配置真实浏览器特征
|
||||
self.context = await self.browser.new_context(
|
||||
viewport={'width': 1920, 'height': 1080},
|
||||
user_agent=settings.WEIXIN_USER_AGENT,
|
||||
locale=settings.WEIXIN_LOCALE,
|
||||
timezone_id=settings.WEIXIN_TIMEZONE_ID
|
||||
user_agent=user_agent,
|
||||
locale=locale,
|
||||
timezone_id=timezone_id
|
||||
)
|
||||
|
||||
page = await self.context.new_page()
|
||||
|
||||
# 注入stealth.js
|
||||
stealth_path = Path(__file__).parent / 'uploader' / 'stealth.min.js'
|
||||
if stealth_path.exists():
|
||||
await page.add_init_script(path=str(stealth_path))
|
||||
logger.debug(f"[{self.platform}] Stealth模式已启用")
|
||||
|
||||
|
||||
page = await self.context.new_page()
|
||||
|
||||
# 注入stealth.js
|
||||
stealth_path = Path(__file__).parent / 'uploader' / 'stealth.min.js'
|
||||
if stealth_path.exists():
|
||||
await page.add_init_script(path=str(stealth_path))
|
||||
logger.debug(f"[{self.platform}] Stealth模式已启用")
|
||||
|
||||
urls_to_try = [config["url"]]
|
||||
if self.platform == "weixin":
|
||||
urls_to_try = [
|
||||
@@ -228,6 +261,60 @@ class QRLoginService:
|
||||
]
|
||||
|
||||
qr_image = None
|
||||
|
||||
# 抖音:拦截 QR 登录相关 API 响应,检测登录成功
|
||||
if self.platform == "douyin":
|
||||
async def _on_douyin_qr_response(response):
|
||||
try:
|
||||
url = response.url or ""
|
||||
if "check_qrconnect" not in url.lower():
|
||||
return
|
||||
|
||||
body = None
|
||||
try:
|
||||
body = await response.json()
|
||||
except Exception:
|
||||
try:
|
||||
text = await response.text()
|
||||
import re as _re
|
||||
m = _re.search(r'\{.*\}', text, _re.DOTALL)
|
||||
if m:
|
||||
body = json.loads(m.group())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not body:
|
||||
return
|
||||
|
||||
data = body.get("data", {})
|
||||
redirect_url = data.get("redirect_url", "")
|
||||
status_val = data.get("status", "")
|
||||
desc = data.get("description", body.get("description", ""))
|
||||
|
||||
logger.info(
|
||||
f"[douyin][qr-poll] status={status_val} "
|
||||
f"desc={desc[:60]} redirect={'yes' if redirect_url else 'no'}"
|
||||
)
|
||||
|
||||
# 检测需要APP验证
|
||||
if "完成验证" in desc or "验证后" in desc:
|
||||
self._douyin_needs_verify = True
|
||||
logger.warning("[douyin] 需要APP验证")
|
||||
|
||||
if self._qr_api_confirmed:
|
||||
return
|
||||
|
||||
# 检测登录成功:出现 redirect_url
|
||||
if redirect_url:
|
||||
self._qr_redirect_url = redirect_url
|
||||
self._qr_api_confirmed = True
|
||||
logger.success(f"[douyin] 登录确认!redirect_url={redirect_url[:120]}")
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"[douyin][qr-poll] error: {e}")
|
||||
|
||||
page.on("response", _on_douyin_qr_response)
|
||||
|
||||
for url in urls_to_try:
|
||||
logger.info(f"[{self.platform}] 打开登录页: {url}")
|
||||
wait_until = "domcontentloaded" if self.platform == "weixin" else "networkidle"
|
||||
@@ -240,72 +327,94 @@ class QRLoginService:
|
||||
qr_image = await self._extract_qr_code(page, config["qr_selectors"])
|
||||
if qr_image:
|
||||
break
|
||||
|
||||
if not qr_image:
|
||||
await self._cleanup()
|
||||
return {"success": False, "message": "未找到二维码"}
|
||||
|
||||
logger.info(f"[{self.platform}] 二维码已获取,等待扫码...")
|
||||
|
||||
# 启动后台监控任务 (浏览器保持开启)
|
||||
asyncio.create_task(
|
||||
self._monitor_login_status(page, config["success_indicator"])
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"qr_code": qr_image,
|
||||
"message": "请扫码登录"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"[{self.platform}] 启动登录失败: {e}")
|
||||
await self._cleanup()
|
||||
return {"success": False, "message": f"启动失败: {str(e)}"}
|
||||
|
||||
async def _extract_qr_code(self, page: Page, selectors: List[str]) -> Optional[str]:
|
||||
"""
|
||||
提取二维码图片 (优化策略顺序)
|
||||
根据日志分析:抖音和B站使用 Text 策略成功率最高
|
||||
"""
|
||||
qr_element = None
|
||||
|
||||
# 针对抖音和B站:优先使用 Text 策略 (成功率最高,速度最快)
|
||||
if self.platform in ("douyin", "bilibili"):
|
||||
# 尝试最多2次 (首次 + 1次重试)
|
||||
for attempt in range(2):
|
||||
if attempt > 0:
|
||||
logger.info(f"[{self.platform}] 等待页面加载后重试...")
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# 策略1: Text (优先,成功率最高)
|
||||
qr_element = await self._try_text_strategy(page)
|
||||
if qr_element:
|
||||
try:
|
||||
screenshot = await qr_element.screenshot()
|
||||
return base64.b64encode(screenshot).decode()
|
||||
except Exception as e:
|
||||
logger.warning(f"[{self.platform}] Text策略截图失败: {e}")
|
||||
qr_element = None
|
||||
|
||||
# 策略2: CSS (备用)
|
||||
if not qr_element:
|
||||
try:
|
||||
combined_selector = ", ".join(selectors)
|
||||
logger.debug(f"[{self.platform}] 策略2(CSS): 开始等待...")
|
||||
# 增加超时到5秒,抖音页面加载较慢
|
||||
el = await page.wait_for_selector(combined_selector, state="visible", timeout=5000)
|
||||
if el:
|
||||
logger.info(f"[{self.platform}] 策略2(CSS): 匹配成功")
|
||||
screenshot = await el.screenshot()
|
||||
return base64.b64encode(screenshot).decode()
|
||||
except Exception as e:
|
||||
logger.warning(f"[{self.platform}] 策略2(CSS) 失败: {e}")
|
||||
|
||||
# 如果已成功,退出循环
|
||||
if qr_element:
|
||||
break
|
||||
else:
|
||||
|
||||
if not qr_image:
|
||||
await self._cleanup()
|
||||
return {"success": False, "message": "未找到二维码"}
|
||||
|
||||
logger.info(f"[{self.platform}] 二维码已获取,等待扫码...")
|
||||
|
||||
# 启动后台监控任务 (浏览器保持开启)
|
||||
asyncio.create_task(
|
||||
self._monitor_login_status(page, config["success_indicator"])
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"qr_code": qr_image,
|
||||
"message": "请扫码登录"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"[{self.platform}] 启动登录失败: {e}")
|
||||
await self._cleanup()
|
||||
return {"success": False, "message": f"启动失败: {str(e)}"}
|
||||
|
||||
async def _extract_qr_code(self, page: Page, selectors: List[str]) -> Optional[str]:
|
||||
"""
|
||||
提取二维码图片 (优化策略顺序)
|
||||
抖音:CSS 优先(Text 策略每次超时 15 秒)
|
||||
B站:Text 优先
|
||||
其他:CSS -> Text
|
||||
"""
|
||||
qr_element = None
|
||||
|
||||
if self.platform == "douyin":
|
||||
# 抖音:CSS 优先,Text 备用(CSS 成功率高且快)
|
||||
for attempt in range(2):
|
||||
if attempt > 0:
|
||||
logger.info(f"[{self.platform}] 等待页面加载后重试...")
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# 策略1: CSS (快速)
|
||||
try:
|
||||
combined_selector = ", ".join(selectors)
|
||||
logger.debug(f"[{self.platform}] 策略CSS: 开始等待...")
|
||||
el = await page.wait_for_selector(combined_selector, state="visible", timeout=5000)
|
||||
if el:
|
||||
logger.info(f"[{self.platform}] 策略CSS: 匹配成功")
|
||||
screenshot = await el.screenshot()
|
||||
return base64.b64encode(screenshot).decode()
|
||||
except Exception as e:
|
||||
logger.warning(f"[{self.platform}] 策略CSS 失败: {e}")
|
||||
|
||||
# 策略2: Text (备用)
|
||||
qr_element = await self._try_text_strategy(page)
|
||||
if qr_element:
|
||||
try:
|
||||
screenshot = await qr_element.screenshot()
|
||||
return base64.b64encode(screenshot).decode()
|
||||
except Exception as e:
|
||||
logger.warning(f"[{self.platform}] Text策略截图失败: {e}")
|
||||
|
||||
elif self.platform == "bilibili":
|
||||
# B站:Text 优先
|
||||
for attempt in range(2):
|
||||
if attempt > 0:
|
||||
logger.info(f"[{self.platform}] 等待页面加载后重试...")
|
||||
await asyncio.sleep(2)
|
||||
|
||||
qr_element = await self._try_text_strategy(page)
|
||||
if qr_element:
|
||||
try:
|
||||
screenshot = await qr_element.screenshot()
|
||||
return base64.b64encode(screenshot).decode()
|
||||
except Exception as e:
|
||||
logger.warning(f"[{self.platform}] Text策略截图失败: {e}")
|
||||
qr_element = None
|
||||
|
||||
if not qr_element:
|
||||
try:
|
||||
combined_selector = ", ".join(selectors)
|
||||
logger.debug(f"[{self.platform}] 策略CSS: 开始等待...")
|
||||
el = await page.wait_for_selector(combined_selector, state="visible", timeout=5000)
|
||||
if el:
|
||||
logger.info(f"[{self.platform}] 策略CSS: 匹配成功")
|
||||
screenshot = await el.screenshot()
|
||||
return base64.b64encode(screenshot).decode()
|
||||
except Exception as e:
|
||||
logger.warning(f"[{self.platform}] 策略CSS 失败: {e}")
|
||||
else:
|
||||
# 其他平台 (小红书/微信等):保持原顺序 CSS -> Text
|
||||
# 策略1: CSS 选择器
|
||||
try:
|
||||
@@ -328,36 +437,31 @@ class QRLoginService:
|
||||
logger.info(f"[{self.platform}] 策略1(CSS): 匹配成功")
|
||||
except Exception as e:
|
||||
logger.warning(f"[{self.platform}] 策略1(CSS) 失败: {e}")
|
||||
|
||||
# 策略2: Text
|
||||
|
||||
# 策略2: Text
|
||||
if not qr_element:
|
||||
qr_element = await self._try_text_strategy(page)
|
||||
|
||||
if not qr_element and self.platform == "weixin":
|
||||
qr_element = await self._try_text_strategy_in_frames(page)
|
||||
|
||||
# 如果找到元素,截图返回
|
||||
if qr_element:
|
||||
try:
|
||||
screenshot = await qr_element.screenshot()
|
||||
return base64.b64encode(screenshot).decode()
|
||||
except Exception as e:
|
||||
logger.error(f"[{self.platform}] 截图失败: {e}")
|
||||
|
||||
# 所有策略失败
|
||||
logger.error(f"[{self.platform}] 所有QR码提取策略失败")
|
||||
|
||||
# 保存调试截图
|
||||
debug_dir = Path(__file__).parent.parent.parent / 'debug_screenshots'
|
||||
debug_dir.mkdir(exist_ok=True)
|
||||
await page.screenshot(path=str(debug_dir / f"{self.platform}_debug.png"))
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# 如果找到元素,截图返回
|
||||
if qr_element:
|
||||
try:
|
||||
screenshot = await qr_element.screenshot()
|
||||
return base64.b64encode(screenshot).decode()
|
||||
except Exception as e:
|
||||
logger.error(f"[{self.platform}] 截图失败: {e}")
|
||||
|
||||
# 所有策略失败
|
||||
logger.error(f"[{self.platform}] 所有QR码提取策略失败")
|
||||
|
||||
return None
|
||||
|
||||
async def _try_text_strategy(self, page: Union[Page, Frame]) -> Optional[Any]:
|
||||
"""基于文本查找二维码图片"""
|
||||
try:
|
||||
logger.debug(f"[{self.platform}] 策略Text: 开始搜索...")
|
||||
"""基于文本查找二维码图片"""
|
||||
try:
|
||||
logger.debug(f"[{self.platform}] 策略Text: 开始搜索...")
|
||||
keywords = [
|
||||
"扫码登录",
|
||||
"二维码",
|
||||
@@ -368,138 +472,265 @@ class QRLoginService:
|
||||
"请使用微信扫码",
|
||||
"视频号"
|
||||
]
|
||||
|
||||
for kw in keywords:
|
||||
try:
|
||||
text_el = page.get_by_text(kw, exact=False).first
|
||||
await text_el.wait_for(state="visible", timeout=2000)
|
||||
|
||||
# 向上查找图片
|
||||
parent = text_el
|
||||
for _ in range(5):
|
||||
parent = parent.locator("..")
|
||||
|
||||
for kw in keywords:
|
||||
try:
|
||||
text_el = page.get_by_text(kw, exact=False).first
|
||||
await text_el.wait_for(state="visible", timeout=2000)
|
||||
|
||||
# 向上查找图片
|
||||
parent = text_el
|
||||
for _ in range(5):
|
||||
parent = parent.locator("..")
|
||||
candidates = parent.locator("img, canvas")
|
||||
min_side = 120 if self.platform == "weixin" else 100
|
||||
best = await self._pick_best_candidate(candidates, min_side=min_side)
|
||||
if best:
|
||||
logger.info(f"[{self.platform}] 策略Text: 成功")
|
||||
return best
|
||||
except Exception:
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.warning(f"[{self.platform}] 策略Text 失败: {e}")
|
||||
return None
|
||||
|
||||
async def _monitor_login_status(self, page: Page, success_url: str):
|
||||
"""监控登录状态"""
|
||||
try:
|
||||
logger.info(f"[{self.platform}] 开始监控登录状态...")
|
||||
except Exception:
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.warning(f"[{self.platform}] 策略Text 失败: {e}")
|
||||
return None
|
||||
|
||||
async def _monitor_login_status(self, page: Page, success_url: str):
|
||||
"""监控登录状态 — 简洁版
|
||||
|
||||
策略:
|
||||
1. 监听页面 URL 变化和 session cookie 出现(通用,适用所有平台)
|
||||
2. 抖音特殊:如果 API 拦截到 redirect_url,直接导航过去拿 cookie
|
||||
3. 抖音特殊:如果需要APP验证且JS轮询停了,等用户验证完后
|
||||
用 page.goto 重新访问首页,让服务器分配 session
|
||||
"""
|
||||
try:
|
||||
logger.info(f"[{self.platform}] 开始监控登录状态...")
|
||||
key_cookies = {
|
||||
"bilibili": ["SESSDATA"],
|
||||
"douyin": ["sessionid"],
|
||||
"douyin": ["sessionid", "sessionid_ss", "sid_guard", "sid_tt", "uid_tt"],
|
||||
"xiaohongshu": ["web_session"],
|
||||
"weixin": [
|
||||
"wxuin",
|
||||
"wxsid",
|
||||
"pass_ticket",
|
||||
"webwx_data_ticket",
|
||||
"uin",
|
||||
"skey",
|
||||
"p_uin",
|
||||
"p_skey",
|
||||
"pac_uid",
|
||||
],
|
||||
"weixin": ["wxuin", "wxsid", "pass_ticket", "uin", "skey",
|
||||
"p_uin", "p_skey", "pac_uid"],
|
||||
}
|
||||
target_cookies = key_cookies.get(self.platform, [])
|
||||
|
||||
for i in range(self.LOGIN_TIMEOUT):
|
||||
await asyncio.sleep(1)
|
||||
|
||||
try:
|
||||
if not self.context: break # 避免意外关闭
|
||||
|
||||
cookies = [dict(cookie) for cookie in await self.context.cookies()]
|
||||
current_url = page.url
|
||||
has_cookie = any((c.get('name') in target_cookies) for c in cookies) if target_cookies else False
|
||||
|
||||
if i % 5 == 0:
|
||||
logger.debug(f"[{self.platform}] 等待登录... HasCookie: {has_cookie}")
|
||||
|
||||
if success_url in current_url or has_cookie:
|
||||
logger.success(f"[{self.platform}] 登录成功!")
|
||||
self.login_success = True
|
||||
await asyncio.sleep(2) # 缓冲
|
||||
|
||||
# 保存Cookie
|
||||
final_cookies = [dict(cookie) for cookie in await self.context.cookies()]
|
||||
await self._save_cookies(final_cookies)
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[{self.platform}] 监控循环警告: {e}")
|
||||
break
|
||||
|
||||
if not self.login_success:
|
||||
logger.warning(f"[{self.platform}] 登录超时")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[{self.platform}] 监控异常: {e}")
|
||||
finally:
|
||||
await self._cleanup()
|
||||
|
||||
async def _cleanup(self) -> None:
|
||||
"""清理资源"""
|
||||
if self.context:
|
||||
try:
|
||||
await self.context.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.context = None
|
||||
if self.browser:
|
||||
try:
|
||||
await self.browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.browser = None
|
||||
if self.playwright:
|
||||
try:
|
||||
await self.playwright.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self.playwright = None
|
||||
|
||||
initial_url = page.url
|
||||
_verify_detected_at: Optional[int] = None # 检测到需要验证的时间点(循环计数)
|
||||
|
||||
for i in range(self.LOGIN_TIMEOUT):
|
||||
await asyncio.sleep(1)
|
||||
if not self.context:
|
||||
break
|
||||
|
||||
try:
|
||||
# ── 检查 session cookie ──
|
||||
cookies = [dict(c) for c in await self.context.cookies()]
|
||||
cookie_names = [c.get("name") for c in cookies]
|
||||
has_session = any(n in cookie_names for n in target_cookies) if target_cookies else False
|
||||
current_url = page.url
|
||||
|
||||
# 每10秒打一次日志
|
||||
if i % 10 == 0:
|
||||
logger.info(
|
||||
f"[{self.platform}] 等待登录... i={i} "
|
||||
f"URL={current_url[:80]} session={has_session} "
|
||||
f"cookies={len(cookies)}"
|
||||
)
|
||||
|
||||
# ── 成功条件:有 session cookie ──
|
||||
if has_session:
|
||||
logger.success(f"[{self.platform}] 登录成功!检测到session cookie")
|
||||
self.login_success = True
|
||||
await asyncio.sleep(2)
|
||||
final = [dict(c) for c in await self.context.cookies()]
|
||||
await self._save_cookies(final)
|
||||
break
|
||||
|
||||
# ── 成功条件:URL 跳转到目标页 ──
|
||||
if success_url in current_url:
|
||||
logger.success(f"[{self.platform}] 登录成功!URL={current_url[:80]}")
|
||||
self.login_success = True
|
||||
await asyncio.sleep(2)
|
||||
final = [dict(c) for c in await self.context.cookies()]
|
||||
await self._save_cookies(final)
|
||||
break
|
||||
|
||||
# ── 抖音:API 拦截到 redirect_url → 直接导航 ──
|
||||
if self.platform == "douyin" and self._qr_api_confirmed and self._qr_redirect_url:
|
||||
logger.info(f"[douyin] 导航到 redirect_url...")
|
||||
try:
|
||||
await page.goto(self._qr_redirect_url, wait_until="domcontentloaded", timeout=30000)
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(3)
|
||||
# 重置,下一轮循环会检查 cookie
|
||||
self._qr_api_confirmed = False
|
||||
self._qr_redirect_url = None
|
||||
continue
|
||||
|
||||
# ── 抖音:需要APP验证,点击"手机刷脸验证"选项 ──
|
||||
if self.platform == "douyin" and self._douyin_needs_verify:
|
||||
if _verify_detected_at is None:
|
||||
_verify_detected_at = i
|
||||
logger.info("[douyin] 检测到身份验证弹窗,将点击手机刷脸验证...")
|
||||
|
||||
elapsed = i - _verify_detected_at
|
||||
# 第一次:点击"手机刷脸验证"选项
|
||||
if elapsed == 2:
|
||||
try:
|
||||
clicked = await page.evaluate("""() => {
|
||||
// 查找身份验证弹窗中的选项
|
||||
const allEls = document.querySelectorAll('div, span, p, a, li');
|
||||
for (const el of allEls) {
|
||||
const text = (el.textContent || '').trim();
|
||||
// 点击"手机刷脸验证"
|
||||
if (text.includes('刷脸验证') && text.length < 30) {
|
||||
el.click();
|
||||
return '刷脸验证';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}""")
|
||||
if clicked:
|
||||
logger.info(f"[douyin] 已点击验证选项: {clicked}")
|
||||
else:
|
||||
logger.warning("[douyin] 未找到验证选项")
|
||||
except Exception as e:
|
||||
logger.warning(f"[douyin] 点击验证选项异常: {e}")
|
||||
|
||||
# 点击后等待新二维码出现,提取弹窗内二维码截图
|
||||
if elapsed == 5 and not self._face_verify_qr:
|
||||
try:
|
||||
# 用 JS 在"刷脸验证"弹窗内找最大的正方形 img(即二维码,跳过头像)
|
||||
qr_selector = await page.evaluate("""() => {
|
||||
// 找到包含"刷脸验证"文字的弹窗
|
||||
const allEls = document.querySelectorAll('div, h2, h3, span, p');
|
||||
let modal = null;
|
||||
for (const el of allEls) {
|
||||
const text = (el.textContent || '').trim();
|
||||
if (text.includes('刷脸验证') && text.length < 20) {
|
||||
modal = el;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
if (!modal.parentElement) break;
|
||||
modal = modal.parentElement;
|
||||
if (modal.offsetWidth > 250 && modal.offsetHeight > 250) break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!modal) return null;
|
||||
|
||||
// 用 offsetWidth/Height(显示尺寸)而非 naturalWidth(源文件可能很大)
|
||||
const imgs = modal.querySelectorAll('img');
|
||||
let best = null;
|
||||
let bestArea = 0;
|
||||
for (const img of imgs) {
|
||||
const w = img.offsetWidth;
|
||||
const h = img.offsetHeight;
|
||||
if (w < 80 || h < 80) continue;
|
||||
const ratio = Math.abs(w - h) / Math.max(w, h);
|
||||
if (ratio > 0.3) continue;
|
||||
const area = w * h;
|
||||
if (area > bestArea) {
|
||||
bestArea = area;
|
||||
best = img;
|
||||
}
|
||||
}
|
||||
if (best) {
|
||||
best.setAttribute('data-face-qr', 'true');
|
||||
return 'img[data-face-qr="true"]';
|
||||
}
|
||||
return null;
|
||||
}""")
|
||||
|
||||
if qr_selector:
|
||||
qr_el = page.locator(qr_selector).first
|
||||
if await qr_el.is_visible():
|
||||
screenshot = await qr_el.screenshot()
|
||||
self._face_verify_qr = base64.b64encode(screenshot).decode()
|
||||
logger.info("[douyin] 刷脸弹窗内二维码截图已捕获")
|
||||
else:
|
||||
logger.warning("[douyin] 二维码元素不可见")
|
||||
|
||||
if not self._face_verify_qr:
|
||||
# 兜底:整页截图
|
||||
logger.warning("[douyin] 未在弹窗内找到二维码,使用全页截图")
|
||||
screenshot = await page.screenshot()
|
||||
self._face_verify_qr = base64.b64encode(screenshot).decode()
|
||||
except Exception as e:
|
||||
logger.warning(f"[douyin] 截取刷脸二维码异常: {e}")
|
||||
|
||||
# 之后每10秒打一次日志
|
||||
if elapsed > 0 and elapsed % 10 == 0:
|
||||
logger.info(f"[douyin] 等待用户完成手机验证... ({elapsed}s)")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[{self.platform}] 监控异常: {e}")
|
||||
|
||||
if not self.login_success:
|
||||
logger.warning(f"[{self.platform}] 登录超时")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[{self.platform}] 监控异常: {e}")
|
||||
finally:
|
||||
await self._cleanup()
|
||||
|
||||
async def _cleanup(self) -> None:
|
||||
"""清理资源"""
|
||||
if self.context:
|
||||
try:
|
||||
await self.context.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.context = None
|
||||
if self.browser:
|
||||
try:
|
||||
await self.browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.browser = None
|
||||
if self.playwright:
|
||||
try:
|
||||
await self.playwright.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self.playwright = None
|
||||
|
||||
async def _save_cookies(self, cookies: Sequence[Mapping[str, Any]]) -> None:
|
||||
"""保存Cookie到文件"""
|
||||
try:
|
||||
cookie_file = self.cookies_dir / f"{self.platform}_cookies.json"
|
||||
|
||||
if self.platform == "bilibili":
|
||||
# Bilibili 使用简单格式 (biliup库需要)
|
||||
"""保存Cookie到文件"""
|
||||
try:
|
||||
cookie_file = self.cookies_dir / f"{self.platform}_cookies.json"
|
||||
|
||||
if self.platform == "bilibili":
|
||||
# Bilibili 使用简单格式 (biliup库需要)
|
||||
cookie_dict = {c.get('name'): c.get('value') for c in cookies if c.get('name')}
|
||||
required = ['SESSDATA', 'bili_jct', 'DedeUserID', 'DedeUserID__ckMd5']
|
||||
cookie_dict = {k: v for k, v in cookie_dict.items() if k in required}
|
||||
|
||||
with open(cookie_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(cookie_dict, f, indent=2)
|
||||
self.cookies_data = cookie_dict
|
||||
else:
|
||||
# Douyin/Xiaohongshu 使用 Playwright storage_state 完整格式
|
||||
# 这样可以直接用 browser.new_context(storage_state=file)
|
||||
storage_state = {
|
||||
"cookies": cookies,
|
||||
"origins": []
|
||||
}
|
||||
with open(cookie_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(storage_state, f, indent=2)
|
||||
self.cookies_data = storage_state
|
||||
|
||||
logger.success(f"[{self.platform}] Cookie已保存")
|
||||
except Exception as e:
|
||||
logger.error(f"[{self.platform}] 保存Cookie失败: {e}")
|
||||
|
||||
def get_login_status(self) -> Dict[str, Any]:
|
||||
"""获取登录状态"""
|
||||
return {
|
||||
"success": self.login_success,
|
||||
"cookies_saved": self.cookies_data is not None
|
||||
}
|
||||
required = ['SESSDATA', 'bili_jct', 'DedeUserID', 'DedeUserID__ckMd5']
|
||||
cookie_dict = {k: v for k, v in cookie_dict.items() if k in required}
|
||||
|
||||
with open(cookie_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(cookie_dict, f, indent=2)
|
||||
self.cookies_data = cookie_dict
|
||||
else:
|
||||
# Douyin/Xiaohongshu 使用 Playwright storage_state 完整格式
|
||||
# 这样可以直接用 browser.new_context(storage_state=file)
|
||||
storage_state = {
|
||||
"cookies": cookies,
|
||||
"origins": []
|
||||
}
|
||||
with open(cookie_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(storage_state, f, indent=2)
|
||||
self.cookies_data = storage_state
|
||||
|
||||
logger.success(f"[{self.platform}] Cookie已保存")
|
||||
except Exception as e:
|
||||
logger.error(f"[{self.platform}] 保存Cookie失败: {e}")
|
||||
|
||||
def get_login_status(self) -> Dict[str, Any]:
|
||||
"""获取登录状态"""
|
||||
result: Dict[str, Any] = {
|
||||
"success": self.login_success,
|
||||
"cookies_saved": self.cookies_data is not None
|
||||
}
|
||||
# 刷脸验证:返回新二维码截图给前端展示
|
||||
if self._face_verify_qr:
|
||||
result["face_verify_qr"] = self._face_verify_qr
|
||||
return result
|
||||
|
||||
@@ -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:
|
||||
@@ -139,8 +181,8 @@ class StorageService:
|
||||
logger.error(f"Get public URL failed: {e}")
|
||||
return ""
|
||||
|
||||
async def delete_file(self, bucket: str, path: str):
|
||||
"""异步删除文件"""
|
||||
async def delete_file(self, bucket: str, path: str):
|
||||
"""异步删除文件"""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(
|
||||
@@ -149,21 +191,21 @@ class StorageService:
|
||||
)
|
||||
logger.info(f"Deleted file: {bucket}/{path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Delete file failed: {e}")
|
||||
pass
|
||||
|
||||
async def move_file(self, bucket: str, from_path: str, to_path: str):
|
||||
"""异步移动/重命名文件"""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.supabase.storage.from_(bucket).move(from_path, to_path)
|
||||
)
|
||||
logger.info(f"Moved file: {bucket}/{from_path} -> {to_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Move file failed: {e}")
|
||||
raise e
|
||||
logger.error(f"Delete file failed: {e}")
|
||||
pass
|
||||
|
||||
async def move_file(self, bucket: str, from_path: str, to_path: str):
|
||||
"""异步移动/重命名文件"""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.supabase.storage.from_(bucket).move(from_path, to_path)
|
||||
)
|
||||
logger.info(f"Moved file: {bucket}/{from_path} -> {to_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Move file failed: {e}")
|
||||
raise e
|
||||
|
||||
async def list_files(self, bucket: str, path: str) -> List[Any]:
|
||||
"""异步列出文件"""
|
||||
|
||||
@@ -127,6 +127,22 @@ class WeixinUploader(BaseUploader):
|
||||
return False
|
||||
|
||||
def _attach_debug_listeners(self, page) -> None:
|
||||
# post_create 响应监听始终注册(不依赖 debug 开关)
|
||||
def log_post_create(response):
|
||||
try:
|
||||
url = response.url or ""
|
||||
if "/post/post_create" in url:
|
||||
if response.status < 400:
|
||||
self._post_create_submitted = True
|
||||
logger.info("[weixin][publish] post_create API ok")
|
||||
else:
|
||||
self._publish_api_error = f"发布请求失败(HTTP {response.status})"
|
||||
logger.warning(f"[weixin][publish] post_create_failed status={response.status}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
page.on("response", log_post_create)
|
||||
|
||||
if not self._debug_artifacts_enabled():
|
||||
return
|
||||
|
||||
@@ -1210,15 +1226,7 @@ class WeixinUploader(BaseUploader):
|
||||
return False
|
||||
|
||||
async def _wait_for_publish_result(self, page):
|
||||
success_texts = [
|
||||
"\u53d1\u5e03\u6210\u529f",
|
||||
"\u53d1\u5e03\u5b8c\u6210",
|
||||
"\u5df2\u53d1\u5e03",
|
||||
"\u5ba1\u6838\u4e2d",
|
||||
"\u5f85\u5ba1\u6838",
|
||||
"\u63d0\u4ea4\u6210\u529f",
|
||||
"\u5df2\u63d0\u4ea4",
|
||||
]
|
||||
"""点击发表后等待结果:页面离开创建页即视为成功"""
|
||||
failure_texts = [
|
||||
"\u53d1\u5e03\u5931\u8d25",
|
||||
"\u53d1\u5e03\u5f02\u5e38",
|
||||
@@ -1229,38 +1237,33 @@ class WeixinUploader(BaseUploader):
|
||||
"\u7f51\u7edc\u5f02\u5e38",
|
||||
]
|
||||
|
||||
# 记录点击发表时的 URL,用于判断是否跳转
|
||||
create_url = page.url
|
||||
start_time = time.time()
|
||||
last_capture = -1
|
||||
|
||||
while time.time() - start_time < self.PUBLISH_TIMEOUT:
|
||||
current_url = page.url
|
||||
|
||||
# API 层面报错 → 直接失败
|
||||
if self._publish_api_error:
|
||||
return False, self._publish_api_error, False
|
||||
|
||||
if self._post_create_submitted and (
|
||||
"/post/list" in current_url
|
||||
or "/platform/post/list" in current_url
|
||||
):
|
||||
return True, "发布成功:已进入内容列表", False
|
||||
# 核心判定:URL 离开了创建页(跳转到列表页或其他页面)→ 发布成功
|
||||
if current_url != create_url and "/post/create" not in current_url:
|
||||
logger.info(f"[weixin] page navigated away from create page: {current_url}")
|
||||
return True, "发布成功:页面已跳转", False
|
||||
|
||||
if "channels.weixin.qq.com/platform" in current_url:
|
||||
for text in success_texts:
|
||||
if await self._is_text_visible(page, text, exact=False):
|
||||
return True, f"发布成功:{text}", False
|
||||
# post_create API 已确认成功 → 也视为成功
|
||||
if self._post_create_submitted:
|
||||
logger.info("[weixin] post_create API confirmed success")
|
||||
return True, "发布成功:API 已确认", False
|
||||
|
||||
# 检查页面上的失败文案
|
||||
for text in failure_texts:
|
||||
if await self._is_text_visible(page, text, exact=False):
|
||||
return False, f"发布失败:{text}", False
|
||||
|
||||
for text in success_texts:
|
||||
if await self._is_text_visible(page, text, exact=False):
|
||||
return True, f"发布成功:{text}", False
|
||||
|
||||
logger.info("[weixin] waiting for publish result...")
|
||||
elapsed = int(time.time() - start_time)
|
||||
if elapsed % 20 == 0 and elapsed != last_capture:
|
||||
last_capture = elapsed
|
||||
await self._save_debug_screenshot(page, "publish_waiting")
|
||||
await asyncio.sleep(self.POLL_INTERVAL)
|
||||
|
||||
return False, "发布超时", True
|
||||
|
||||
@@ -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,10 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { getCurrentUser, User } from "@/shared/lib/auth";
|
||||
import api from "@/shared/api/axios";
|
||||
import { ApiResponse, unwrap } from "@/shared/api/types";
|
||||
import { useRouter } from 'next/navigation';
|
||||
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;
|
||||
@@ -18,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('');
|
||||
@@ -28,6 +30,7 @@ export default function AdminPage() {
|
||||
useEffect(() => {
|
||||
checkAdmin();
|
||||
fetchUsers();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const checkAdmin = async () => {
|
||||
@@ -41,9 +44,9 @@ export default function AdminPage() {
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const { data: res } = await api.get<ApiResponse<UserListItem[]>>('/api/admin/users');
|
||||
setUsers(unwrap(res));
|
||||
} catch (err) {
|
||||
const { data: res } = await api.get<ApiResponse<UserListItem[]>>('/api/admin/users');
|
||||
setUsers(unwrap(res));
|
||||
} catch {
|
||||
setError('获取用户列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -57,7 +60,7 @@ export default function AdminPage() {
|
||||
expires_days: expireDays || null
|
||||
});
|
||||
fetchUsers();
|
||||
} catch (err) {
|
||||
} catch {
|
||||
// axios interceptor handles 401/403
|
||||
} finally {
|
||||
setActivatingId(null);
|
||||
@@ -70,8 +73,8 @@ export default function AdminPage() {
|
||||
try {
|
||||
await api.post(`/api/admin/users/${userId}/deactivate`);
|
||||
fetchUsers();
|
||||
} catch (err) {
|
||||
alert('操作失败');
|
||||
} catch {
|
||||
toast.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -106,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 && (
|
||||
|
||||
@@ -3,7 +3,8 @@ 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 { 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);
|
||||
|
||||
@@ -44,7 +44,7 @@ export default function AccountSettingsDropdown() {
|
||||
if (confirm('确定要退出登录吗?')) {
|
||||
try {
|
||||
await api.post('/api/auth/logout');
|
||||
} catch (e) { }
|
||||
} catch { }
|
||||
window.location.href = '/login';
|
||||
}
|
||||
};
|
||||
@@ -76,14 +76,15 @@ export default function AccountSettingsDropdown() {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await api.post('/api/auth/logout');
|
||||
} catch (e) { }
|
||||
} catch { }
|
||||
window.location.href = '/login';
|
||||
}, 1500);
|
||||
} else {
|
||||
setError(res.message || '修改失败');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || '修改失败,请重试');
|
||||
} catch (err: unknown) {
|
||||
const axiosErr = err as { response?: { data?: { message?: string } } };
|
||||
setError(axiosErr.response?.data?.message || '修改失败,请重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
import { useTask } from "@/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,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import api from "@/shared/api/axios";
|
||||
import { ApiResponse, unwrap } from "@/shared/api/types";
|
||||
import { useEffect, useCallback } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useScriptExtraction } from "./script-extraction/useScriptExtraction";
|
||||
|
||||
interface ScriptExtractionModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -13,177 +13,66 @@ interface ScriptExtractionModalProps {
|
||||
export default function ScriptExtractionModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onApply
|
||||
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);
|
||||
const {
|
||||
isLoading,
|
||||
script,
|
||||
rewrittenScript,
|
||||
error,
|
||||
doRewrite,
|
||||
step,
|
||||
dragActive,
|
||||
selectedFile,
|
||||
activeTab,
|
||||
inputUrl,
|
||||
setDoRewrite,
|
||||
setActiveTab,
|
||||
setInputUrl,
|
||||
handleDrag,
|
||||
handleDrop,
|
||||
handleFileChange,
|
||||
handleExtract,
|
||||
copyToClipboard,
|
||||
resetToConfig,
|
||||
clearSelectedFile,
|
||||
clearInputUrl,
|
||||
} = useScriptExtraction({ isOpen });
|
||||
|
||||
// New state for URL mode
|
||||
const [activeTab, setActiveTab] = useState<'file' | 'url'>('url');
|
||||
const [inputUrl, setInputUrl] = useState("");
|
||||
// 快捷键:ESC 关闭,Enter 提交(仅在 config 步骤)
|
||||
const canExtract = (activeTab === "file" && selectedFile) || (activeTab === "url" && inputUrl.trim());
|
||||
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
} else if (e.key === "Enter" && !e.shiftKey && step === "config" && canExtract && !isLoading) {
|
||||
e.preventDefault();
|
||||
handleExtract();
|
||||
}
|
||||
}, [onClose, step, canExtract, isLoading, handleExtract]);
|
||||
|
||||
// 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: res } = await api.post<ApiResponse<{ original_script: string; rewritten_script?: string }>>(
|
||||
'/api/tools/extract-script',
|
||||
formData,
|
||||
{
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 180000 // 3 minutes timeout
|
||||
});
|
||||
|
||||
const payload = unwrap(res);
|
||||
setScript(payload.original_script);
|
||||
setRewrittenScript(payload.rewritten_script || "");
|
||||
setStep('result');
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
const msg = err.response?.data?.message || 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;
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen, handleKeyDown]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleApplyAndClose = (text: string) => {
|
||||
onApply?.(text);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleExtractNext = () => {
|
||||
resetToConfig();
|
||||
clearSelectedFile();
|
||||
clearInputUrl();
|
||||
};
|
||||
|
||||
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"
|
||||
>
|
||||
<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 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">
|
||||
@@ -199,25 +88,24 @@ export default function ScriptExtractionModal({
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{step === 'config' && (
|
||||
{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'
|
||||
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'
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
📂 上传文件
|
||||
@@ -225,7 +113,7 @@ export default function ScriptExtractionModal({
|
||||
</div>
|
||||
|
||||
{/* URL Input Area */}
|
||||
{activeTab === 'url' && (
|
||||
{activeTab === "url" && (
|
||||
<div className="space-y-2 py-4">
|
||||
<div className="relative">
|
||||
<input
|
||||
@@ -237,119 +125,150 @@ export default function ScriptExtractionModal({
|
||||
/>
|
||||
{inputUrl && (
|
||||
<button
|
||||
onClick={() => setInputUrl("")}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white p-1"
|
||||
onClick={clearInputUrl}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 px-1">
|
||||
支持抖音、B站等主流平台分享链接,自动解析下载并提取文案。
|
||||
<p className="text-xs text-gray-500 pl-1">
|
||||
支持抖音、B站、微博、小红书等主流平台视频链接
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File Upload Area */}
|
||||
{activeTab === 'file' && (
|
||||
{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' : ''}
|
||||
`}
|
||||
className={`border-2 border-dashed rounded-xl p-8 text-center transition-colors ${dragActive
|
||||
? "border-purple-500 bg-purple-500/10"
|
||||
: "border-white/10 hover:border-white/20"
|
||||
}`}
|
||||
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 className="space-y-2">
|
||||
<p className="text-white">{selectedFile.name}</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
{(selectedFile.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
<button
|
||||
onClick={clearSelectedFile}
|
||||
className="text-xs text-purple-400 hover:text-purple-300"
|
||||
>
|
||||
重新选择
|
||||
</button>
|
||||
</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 className="space-y-4">
|
||||
<div className="text-4xl">📁</div>
|
||||
<p className="text-gray-400">
|
||||
拖放视频/音频文件到此处,或
|
||||
<label className="text-purple-400 hover:text-purple-300 cursor-pointer">
|
||||
点击选择
|
||||
<input
|
||||
type="file"
|
||||
accept=".mp4,.mov,.avi,.mp3,.wav,.m4a"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
支持 MP4, MOV, AVI, MP3, WAV, M4A
|
||||
</p>
|
||||
</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">
|
||||
<div className="flex items-center gap-3 bg-white/5 rounded-xl p-4 border border-white/10">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={doRewrite}
|
||||
onChange={e => setDoRewrite(e.target.checked)}
|
||||
className="w-5 h-5 accent-purple-600 rounded"
|
||||
onChange={(e) => setDoRewrite(e.target.checked)}
|
||||
className="w-4 h-4 rounded bg-white/10 border-white/20 text-purple-500 focus:ring-purple-500"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-white font-medium">启用 AI 洗稿</div>
|
||||
<div className="text-xs text-gray-400">自动将提取的文案重写为更自然流畅的口播稿</div>
|
||||
</div>
|
||||
<span className="text-sm text-gray-300">
|
||||
AI 智能改写(去口语化)
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="p-3 bg-red-500/20 text-red-200 rounded-lg text-sm text-center">
|
||||
❌ {error}
|
||||
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-4">
|
||||
<p className="text-red-400 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-center pt-2">
|
||||
{/* Action Button */}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 py-3 px-4 bg-white/10 hover:bg-white/20 text-white rounded-xl transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<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()}
|
||||
disabled={
|
||||
(activeTab === "file" && !selectedFile) ||
|
||||
(activeTab === "url" && !inputUrl.trim()) ||
|
||||
isLoading
|
||||
}
|
||||
className="flex-1 py-3 px-4 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-xl transition-all font-medium shadow-lg flex items-center justify-center gap-2"
|
||||
>
|
||||
{activeTab === 'url' ? '🔗 解析并提取' : '🚀 开始提取'}
|
||||
{isLoading ? <Loader2 className="w-5 h-5 animate-spin" /> : null}
|
||||
开始提取
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'processing' && (
|
||||
{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>
|
||||
<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>
|
||||
{activeTab === "url" && "正在下载视频..."}
|
||||
<br />
|
||||
{doRewrite
|
||||
? "正在进行语音识别和 AI 智能改写..."
|
||||
: "正在进行语音识别..."}
|
||||
<br />
|
||||
<span className="opacity-75">
|
||||
大文件可能需要几分钟,请不要关闭窗口
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'result' && (
|
||||
{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>
|
||||
✨ AI 洗稿结果{" "}
|
||||
<span className="text-xs font-normal text-purple-400/70">
|
||||
(推荐)
|
||||
</span>
|
||||
</h4>
|
||||
{onApply && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onApply(rewrittenScript);
|
||||
onClose();
|
||||
}}
|
||||
onClick={() => handleApplyAndClose(rewrittenScript)}
|
||||
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"
|
||||
>
|
||||
📥 填入
|
||||
@@ -377,10 +296,7 @@ export default function ScriptExtractionModal({
|
||||
</h4>
|
||||
{onApply && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onApply(script);
|
||||
onClose();
|
||||
}}
|
||||
onClick={() => handleApplyAndClose(script)}
|
||||
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"
|
||||
>
|
||||
📥 填入
|
||||
@@ -402,14 +318,7 @@ export default function ScriptExtractionModal({
|
||||
|
||||
<div className="flex justify-center pt-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
setStep('config');
|
||||
setScript("");
|
||||
setRewrittenScript("");
|
||||
setSelectedFile(null);
|
||||
setInputUrl("");
|
||||
// Keep current tab active
|
||||
}}
|
||||
onClick={handleExtractNext}
|
||||
className="px-6 py-2 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors"
|
||||
>
|
||||
提取下一个
|
||||
|
||||
210
frontend/src/components/script-extraction/useScriptExtraction.ts
Normal file
210
frontend/src/components/script-extraction/useScriptExtraction.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import api from "@/shared/api/axios";
|
||||
import { ApiResponse, unwrap } from "@/shared/api/types";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export type ExtractionStep = "config" | "processing" | "result";
|
||||
export type InputTab = "file" | "url";
|
||||
|
||||
const VALID_FILE_TYPES = [".mp4", ".mov", ".avi", ".mp3", ".wav", ".m4a"];
|
||||
|
||||
interface UseScriptExtractionOptions {
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export const useScriptExtraction = ({ isOpen }: UseScriptExtractionOptions) => {
|
||||
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<ExtractionStep>("config");
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<InputTab>("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 = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.type === "dragenter" || e.type === "dragover") {
|
||||
setDragActive(true);
|
||||
} else if (e.type === "dragleave") {
|
||||
setDragActive(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleFile = useCallback((file: File) => {
|
||||
const ext = file.name.toLowerCase().slice(file.name.lastIndexOf("."));
|
||||
if (!VALID_FILE_TYPES.includes(ext)) {
|
||||
setError(`不支持的文件格式 ${ext},请上传视频或音频文件`);
|
||||
return;
|
||||
}
|
||||
setSelectedFile(file);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
if (e.dataTransfer.files?.[0]) {
|
||||
handleFile(e.dataTransfer.files[0]);
|
||||
}
|
||||
},
|
||||
[handleFile]
|
||||
);
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files?.[0]) {
|
||||
handleFile(e.target.files[0]);
|
||||
}
|
||||
},
|
||||
[handleFile]
|
||||
);
|
||||
|
||||
const handleExtract = useCallback(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: res } = await api.post<
|
||||
ApiResponse<{ original_script: string; rewritten_script?: string }>
|
||||
>("/api/tools/extract-script", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
timeout: 180000, // 3 minutes timeout
|
||||
});
|
||||
|
||||
const payload = unwrap(res);
|
||||
setScript(payload.original_script);
|
||||
setRewrittenScript(payload.rewritten_script || "");
|
||||
setStep("result");
|
||||
} catch (err: unknown) {
|
||||
console.error(err);
|
||||
const axiosErr = err as {
|
||||
response?: { data?: { message?: string } };
|
||||
message?: string;
|
||||
};
|
||||
const msg =
|
||||
axiosErr.response?.data?.message || axiosErr.message || "请求失败";
|
||||
setError(msg);
|
||||
setStep("config");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [activeTab, selectedFile, inputUrl, doRewrite]);
|
||||
|
||||
const copyToClipboard = useCallback((text: string) => {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => {
|
||||
toast.success("已复制到剪贴板");
|
||||
})
|
||||
.catch(() => {
|
||||
fallbackCopyTextToClipboard(text);
|
||||
});
|
||||
} else {
|
||||
fallbackCopyTextToClipboard(text);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fallbackCopyTextToClipboard = (text: string) => {
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = text;
|
||||
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 {
|
||||
const successful = document.execCommand("copy");
|
||||
if (successful) {
|
||||
toast.success("已复制到剪贴板");
|
||||
} else {
|
||||
toast.error("复制失败,请手动复制");
|
||||
}
|
||||
} catch {
|
||||
toast.error("复制失败,请手动复制");
|
||||
}
|
||||
|
||||
document.body.removeChild(textArea);
|
||||
};
|
||||
|
||||
const resetToConfig = useCallback(() => {
|
||||
setStep("config");
|
||||
}, []);
|
||||
|
||||
const clearSelectedFile = useCallback(() => {
|
||||
setSelectedFile(null);
|
||||
}, []);
|
||||
|
||||
const clearInputUrl = useCallback(() => {
|
||||
setInputUrl("");
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// State
|
||||
isLoading,
|
||||
script,
|
||||
rewrittenScript,
|
||||
error,
|
||||
doRewrite,
|
||||
step,
|
||||
dragActive,
|
||||
selectedFile,
|
||||
activeTab,
|
||||
inputUrl,
|
||||
// Setters
|
||||
setDoRewrite,
|
||||
setActiveTab,
|
||||
setInputUrl,
|
||||
// Handlers
|
||||
handleDrag,
|
||||
handleDrop,
|
||||
handleFileChange,
|
||||
handleExtract,
|
||||
copyToClipboard,
|
||||
resetToConfig,
|
||||
clearSelectedFile,
|
||||
clearInputUrl,
|
||||
};
|
||||
};
|
||||
@@ -3,15 +3,8 @@
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from "react";
|
||||
import api from "@/shared/api/axios";
|
||||
import { ApiResponse, unwrap } from "@/shared/api/types";
|
||||
import { User } from "@/shared/types/user";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
phone: string;
|
||||
username: string | null;
|
||||
role: string;
|
||||
is_active: boolean;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
userId: string | null;
|
||||
|
||||
@@ -87,7 +87,9 @@ export function TaskProvider({ children }: { children: ReactNode }) {
|
||||
const savedTaskId = localStorage.getItem(taskKey);
|
||||
if (savedTaskId) {
|
||||
console.log("[TaskContext] 恢复任务:", savedTaskId);
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setTaskId(savedTaskId);
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setIsGenerating(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,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) => {
|
||||
@@ -32,21 +34,20 @@ export const useBgm = ({
|
||||
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?.message || 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,6 +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;
|
||||
@@ -11,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>>;
|
||||
@@ -19,7 +20,7 @@ interface UseGeneratedVideosOptions {
|
||||
}
|
||||
|
||||
export const useGeneratedVideos = ({
|
||||
storageKey,
|
||||
|
||||
selectedVideoId,
|
||||
setSelectedVideoId,
|
||||
setGeneratedVideo,
|
||||
@@ -36,32 +37,42 @@ export const useGeneratedVideos = ({
|
||||
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;
|
||||
@@ -73,7 +84,7 @@ export const useGeneratedVideos = ({
|
||||
}
|
||||
fetchGeneratedVideos();
|
||||
} catch (error) {
|
||||
alert("删除失败: " + error);
|
||||
toast.error("删除失败: " + error);
|
||||
}
|
||||
}, [fetchGeneratedVideos, selectedVideoId, setGeneratedVideo, setSelectedVideoId]);
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ import { clampTitle } from "@/shared/lib/title";
|
||||
import { useTitleInput } from "@/shared/hooks/useTitleInput";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useTask } from "@/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";
|
||||
@@ -30,26 +33,7 @@ const VOICES = [
|
||||
{ id: "zh-CN-XiaoyiNeural", name: "晓伊 (女声-温柔)" },
|
||||
];
|
||||
|
||||
const PUBLISH_PREFETCH_KEY = "vigent_publish_prefetch_v1";
|
||||
const PUBLISH_PREFETCH_TTL = 2 * 60 * 1000;
|
||||
|
||||
interface PublishAccount {
|
||||
platform: string;
|
||||
name: string;
|
||||
logged_in: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface PublishVideo {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface PublishPrefetchCache {
|
||||
ts: number;
|
||||
accounts?: PublishAccount[];
|
||||
videos?: PublishVideo[];
|
||||
}
|
||||
|
||||
const FIXED_REF_TEXT =
|
||||
"其实生活中有许多美好的瞬间,比如清晨的阳光,或者一杯温热的清茶。希望这次生成的音色能够自然、流畅,完美还原出我最真实的声音状态。";
|
||||
@@ -105,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);
|
||||
@@ -114,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>("");
|
||||
@@ -139,7 +126,7 @@ export const useHomeController = () => {
|
||||
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>>({});
|
||||
|
||||
@@ -166,8 +153,8 @@ 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));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -200,9 +187,10 @@ export const useHomeController = () => {
|
||||
setEditingMaterialId(null);
|
||||
setEditMaterialName("");
|
||||
fetchMaterials();
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.response?.data?.message || err.message || String(err);
|
||||
alert(`重命名失败: ${errorMsg}`);
|
||||
} 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}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -225,26 +213,8 @@ export const useHomeController = () => {
|
||||
// 获取存储 key 的前缀(登录用户使用 userId,未登录使用 guest)
|
||||
const storageKey = userId || "guest";
|
||||
|
||||
const readPublishPrefetch = () => {
|
||||
if (typeof window === "undefined") return null;
|
||||
const raw = sessionStorage.getItem(PUBLISH_PREFETCH_KEY);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const cache = JSON.parse(raw) as PublishPrefetchCache;
|
||||
if (!cache?.ts) return null;
|
||||
if (Date.now() - cache.ts > PUBLISH_PREFETCH_TTL) return null;
|
||||
return cache;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const updatePublishPrefetch = (patch: Partial<PublishPrefetchCache>) => {
|
||||
if (typeof window === "undefined") return;
|
||||
const existing = readPublishPrefetch() || { ts: Date.now() };
|
||||
const next = { ...existing, ...patch, ts: Date.now() };
|
||||
sessionStorage.setItem(PUBLISH_PREFETCH_KEY, JSON.stringify(next));
|
||||
};
|
||||
// 使用共用的发布预加载 hook
|
||||
const { updatePrefetch: updatePublishPrefetch } = usePublishPrefetch();
|
||||
|
||||
const {
|
||||
materials,
|
||||
@@ -270,7 +240,7 @@ export const useHomeController = () => {
|
||||
refreshTitleStyles,
|
||||
} = useTitleSubtitleStyles({
|
||||
isAuthLoading,
|
||||
storageKey,
|
||||
|
||||
setSelectedSubtitleStyleId,
|
||||
setSelectedTitleStyleId,
|
||||
});
|
||||
@@ -296,7 +266,7 @@ export const useHomeController = () => {
|
||||
bgmError,
|
||||
fetchBgmList,
|
||||
} = useBgm({
|
||||
storageKey,
|
||||
|
||||
selectedBgmId,
|
||||
setSelectedBgmId,
|
||||
});
|
||||
@@ -319,7 +289,7 @@ export const useHomeController = () => {
|
||||
fetchGeneratedVideos,
|
||||
deleteVideo,
|
||||
} = useGeneratedVideos({
|
||||
storageKey,
|
||||
|
||||
selectedVideoId,
|
||||
setSelectedVideoId,
|
||||
setGeneratedVideo,
|
||||
@@ -347,15 +317,18 @@ export const useHomeController = () => {
|
||||
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({
|
||||
@@ -383,6 +356,10 @@ export const useHomeController = () => {
|
||||
setTitleFontSize,
|
||||
setSubtitleSizeLocked,
|
||||
setTitleSizeLocked,
|
||||
titleTopMargin,
|
||||
setTitleTopMargin,
|
||||
subtitleBottomMargin,
|
||||
setSubtitleBottomMargin,
|
||||
selectedBgmId,
|
||||
setSelectedBgmId,
|
||||
bgmVolume,
|
||||
@@ -417,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) {
|
||||
@@ -462,25 +452,6 @@ export const useHomeController = () => {
|
||||
};
|
||||
}, [materials, selectedMaterial]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showStylePreview) return;
|
||||
const container = titlePreviewContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
setPreviewContainerWidth(container.getBoundingClientRect().width);
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
setPreviewContainerWidth(entry.contentRect.width);
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserver.observe(container);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [showStylePreview]);
|
||||
|
||||
useEffect(() => {
|
||||
if (subtitleSizeLocked || subtitleStyles.length === 0) return;
|
||||
@@ -502,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;
|
||||
@@ -530,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];
|
||||
@@ -593,7 +573,7 @@ export const useHomeController = () => {
|
||||
setRecordingTime((prev) => prev + 1);
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
alert("无法访问麦克风,请检查权限设置");
|
||||
toast.error("无法访问麦克风,请检查权限设置");
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
@@ -631,7 +611,7 @@ export const useHomeController = () => {
|
||||
// AI 生成标题和标签
|
||||
const handleGenerateMeta = async () => {
|
||||
if (!text.trim()) {
|
||||
alert("请先输入口播文案");
|
||||
toast.error("请先输入口播文案");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -649,10 +629,11 @@ export const useHomeController = () => {
|
||||
|
||||
// 同步到发布页 localStorage
|
||||
localStorage.setItem(`vigent_${storageKey}_publish_tags`, JSON.stringify(payload.tags || []));
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
console.error("AI generate meta failed:", err);
|
||||
const errorMsg = err.response?.data?.message || 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);
|
||||
}
|
||||
@@ -661,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;
|
||||
}
|
||||
|
||||
@@ -684,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,
|
||||
@@ -713,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;
|
||||
@@ -816,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,6 +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;
|
||||
@@ -40,19 +41,20 @@ export const useMaterials = ({
|
||||
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;
|
||||
@@ -63,7 +65,7 @@ export const useMaterials = ({
|
||||
setSelectedMaterial("");
|
||||
}
|
||||
} catch (error) {
|
||||
alert("删除失败: " + error);
|
||||
toast.error("删除失败: " + error);
|
||||
}
|
||||
}, [fetchMaterials, selectedMaterial, setSelectedMaterial]);
|
||||
|
||||
@@ -99,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?.message || 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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,6 +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;
|
||||
@@ -60,10 +61,11 @@ export const useRefAudios = ({
|
||||
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?.message || 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]);
|
||||
@@ -78,7 +80,7 @@ export const useRefAudios = ({
|
||||
setRefText('');
|
||||
}
|
||||
} catch (error) {
|
||||
alert("删除失败: " + error);
|
||||
toast.error("删除失败: " + error);
|
||||
}
|
||||
}, [fetchRefAudios, selectedRefAudio, setRefText, setSelectedRefAudio]);
|
||||
|
||||
|
||||
@@ -34,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) => {
|
||||
@@ -57,17 +57,15 @@ export const useTitleSubtitleStyles = ({
|
||||
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 {
|
||||
@@ -78,21 +76,21 @@ export const useTitleSubtitleStyles = ({
|
||||
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);
|
||||
}
|
||||
@@ -65,14 +65,16 @@ export function HomePage() {
|
||||
subtitleFontSize,
|
||||
setSubtitleFontSize,
|
||||
setSubtitleSizeLocked,
|
||||
titleTopMargin,
|
||||
setTitleTopMargin,
|
||||
subtitleBottomMargin,
|
||||
setSubtitleBottomMargin,
|
||||
enableSubtitles,
|
||||
setEnableSubtitles,
|
||||
resolveAssetUrl,
|
||||
getFontFormat,
|
||||
buildTextShadow,
|
||||
previewContainerWidth,
|
||||
materialDimensions,
|
||||
titlePreviewContainerRef,
|
||||
ttsMode,
|
||||
setTtsMode,
|
||||
voices,
|
||||
@@ -201,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}
|
||||
/>
|
||||
|
||||
{/* 配音方式选择 */}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -16,12 +16,12 @@ export function ScriptEditor({
|
||||
isGeneratingMeta,
|
||||
}: ScriptEditorProps) {
|
||||
return (
|
||||
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
|
||||
<div className="flex justify-between items-center gap-2 mb-4">
|
||||
<div className="bg-white/5 rounded-2xl p-4 sm:p-6 border border-white/10 backdrop-blur-sm">
|
||||
<div className="flex flex-wrap justify-between items-center gap-2 mb-4">
|
||||
<h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2 whitespace-nowrap">
|
||||
✍️ 文案提取与编辑
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 flex-shrink-0">
|
||||
<button
|
||||
onClick={onOpenExtractModal}
|
||||
className="px-2 py-1 text-xs rounded transition-all whitespace-nowrap bg-purple-600 hover:bg-purple-700 text-white flex items-center gap-1"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { RefObject } from "react";
|
||||
import { Eye } from "lucide-react";
|
||||
import { FloatingStylePreview } from "@/features/home/ui/FloatingStylePreview";
|
||||
|
||||
interface SubtitleStyleOption {
|
||||
id: string;
|
||||
@@ -48,16 +48,17 @@ interface TitleSubtitlePanelProps {
|
||||
onSelectSubtitleStyle: (id: string) => void;
|
||||
subtitleFontSize: number;
|
||||
onSubtitleFontSizeChange: (value: number) => void;
|
||||
titleTopMargin: number;
|
||||
onTitleTopMarginChange: (value: number) => void;
|
||||
subtitleBottomMargin: number;
|
||||
onSubtitleBottomMarginChange: (value: number) => void;
|
||||
enableSubtitles: boolean;
|
||||
onToggleSubtitles: (value: boolean) => void;
|
||||
resolveAssetUrl: (path?: string | null) => string | null;
|
||||
getFontFormat: (fontFile?: string) => string;
|
||||
buildTextShadow: (color: string, size: number) => string;
|
||||
previewScale?: number;
|
||||
previewAspectRatio?: string;
|
||||
previewBaseWidth?: number;
|
||||
previewBaseHeight?: number;
|
||||
previewContainerRef?: RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export function TitleSubtitlePanel({
|
||||
@@ -77,51 +78,18 @@ export function TitleSubtitlePanel({
|
||||
onSelectSubtitleStyle,
|
||||
subtitleFontSize,
|
||||
onSubtitleFontSizeChange,
|
||||
titleTopMargin,
|
||||
onTitleTopMarginChange,
|
||||
subtitleBottomMargin,
|
||||
onSubtitleBottomMarginChange,
|
||||
enableSubtitles,
|
||||
onToggleSubtitles,
|
||||
resolveAssetUrl,
|
||||
getFontFormat,
|
||||
buildTextShadow,
|
||||
previewScale = 1,
|
||||
previewAspectRatio = '16 / 9',
|
||||
previewBaseWidth = 1280,
|
||||
previewBaseHeight = 720,
|
||||
previewContainerRef,
|
||||
previewBaseWidth = 1080,
|
||||
previewBaseHeight = 1920,
|
||||
}: TitleSubtitlePanelProps) {
|
||||
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 subtitleBottomMargin = activeSubtitleStyle?.bottom_margin ?? 0;
|
||||
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 titleTopMargin = activeTitleStyle?.top_margin ?? 0;
|
||||
const titleFontWeight = activeTitleStyle?.font_weight ?? 900;
|
||||
const titleFontFamilyName = `TitlePreview-${activeTitleStyle?.id || "default"}`;
|
||||
const titleFontUrl = activeTitleStyle?.font_file
|
||||
? resolveAssetUrl(`fonts/${activeTitleStyle.font_file}`)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="bg-white/5 rounded-2xl p-4 sm:p-6 border border-white/10 backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between mb-4 gap-2">
|
||||
@@ -138,78 +106,24 @@ export function TitleSubtitlePanel({
|
||||
</div>
|
||||
|
||||
{showStylePreview && (
|
||||
<div
|
||||
ref={previewContainerRef}
|
||||
className="mb-4 rounded-xl border border-white/10 bg-black/40 relative overflow-hidden"
|
||||
style={{ aspectRatio: previewAspectRatio, minHeight: '180px' }}
|
||||
>
|
||||
{(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>
|
||||
<FloatingStylePreview
|
||||
onClose={onTogglePreview}
|
||||
videoTitle={videoTitle}
|
||||
titleStyles={titleStyles}
|
||||
selectedTitleStyleId={selectedTitleStyleId}
|
||||
titleFontSize={titleFontSize}
|
||||
subtitleStyles={subtitleStyles}
|
||||
selectedSubtitleStyleId={selectedSubtitleStyleId}
|
||||
subtitleFontSize={subtitleFontSize}
|
||||
titleTopMargin={titleTopMargin}
|
||||
subtitleBottomMargin={subtitleBottomMargin}
|
||||
enableSubtitles={enableSubtitles}
|
||||
resolveAssetUrl={resolveAssetUrl}
|
||||
getFontFormat={getFontFormat}
|
||||
buildTextShadow={buildTextShadow}
|
||||
previewBaseWidth={previewBaseWidth}
|
||||
previewBaseHeight={previewBaseHeight}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
@@ -249,14 +163,26 @@ export function TitleSubtitlePanel({
|
||||
<label className="text-xs text-gray-400 mb-2 block">标题字号: {titleFontSize}px</label>
|
||||
<input
|
||||
type="range"
|
||||
min="48"
|
||||
max="110"
|
||||
min="60"
|
||||
max="150"
|
||||
step="1"
|
||||
value={titleFontSize}
|
||||
onChange={(e) => onTitleFontSizeChange(parseInt(e.target.value, 10))}
|
||||
className="w-full accent-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<label className="text-xs text-gray-400 mb-2 block">标题位置: {titleTopMargin}px</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="300"
|
||||
step="1"
|
||||
value={titleTopMargin}
|
||||
onChange={(e) => onTitleTopMarginChange(parseInt(e.target.value, 10))}
|
||||
className="w-full accent-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -284,7 +210,7 @@ export function TitleSubtitlePanel({
|
||||
<label className="text-xs text-gray-400 mb-2 block">字幕字号: {subtitleFontSize}px</label>
|
||||
<input
|
||||
type="range"
|
||||
min="32"
|
||||
min="40"
|
||||
max="90"
|
||||
step="1"
|
||||
value={subtitleFontSize}
|
||||
@@ -292,6 +218,18 @@ export function TitleSubtitlePanel({
|
||||
className="w-full accent-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<label className="text-xs text-gray-400 mb-2 block">字幕位置: {subtitleBottomMargin}px</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="300"
|
||||
step="1"
|
||||
value={subtitleBottomMargin}
|
||||
onChange={(e) => onSubtitleBottomMarginChange(parseInt(e.target.value, 10))}
|
||||
className="w-full accent-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState, useRef, useCallback } from "react";
|
||||
import useSWR from "swr";
|
||||
import api from "@/shared/api/axios";
|
||||
import { ApiResponse, unwrap } from "@/shared/api/types";
|
||||
@@ -6,38 +6,17 @@ import { formatDate, getApiBaseUrl, isAbsoluteUrl, resolveMediaUrl } from "@/sha
|
||||
import { clampTitle } from "@/shared/lib/title";
|
||||
import { useTitleInput } from "@/shared/hooks/useTitleInput";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
interface Account {
|
||||
platform: string;
|
||||
name: string;
|
||||
logged_in: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface Video {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface PublishResult {
|
||||
platform: string;
|
||||
success: boolean;
|
||||
message: string;
|
||||
url?: string | null;
|
||||
screenshot_url?: string;
|
||||
}
|
||||
import { useTask } from "@/contexts/TaskContext";
|
||||
import { toast } from "sonner";
|
||||
import { usePublishPrefetch } from "@/shared/hooks/usePublishPrefetch";
|
||||
import {
|
||||
PublishAccount as Account,
|
||||
PublishVideo as Video,
|
||||
PublishResult,
|
||||
} from "@/shared/types/publish";
|
||||
|
||||
const fetcher = (url: string) =>
|
||||
api.get<ApiResponse<any>>(url).then((res) => unwrap(res.data));
|
||||
|
||||
const PREFETCH_KEY = "vigent_publish_prefetch_v1";
|
||||
const PREFETCH_TTL = 2 * 60 * 1000;
|
||||
|
||||
type PublishPrefetchCache = {
|
||||
ts: number;
|
||||
accounts?: Account[];
|
||||
videos?: Video[];
|
||||
};
|
||||
api.get<ApiResponse<{ success?: boolean; face_verify_qr?: string }>>(url).then((res) => unwrap(res.data));
|
||||
|
||||
export const usePublishController = () => {
|
||||
const apiBase = getApiBaseUrl();
|
||||
@@ -57,37 +36,25 @@ export const usePublishController = () => {
|
||||
const [qrCodeImage, setQrCodeImage] = useState<string | null>(null);
|
||||
const [qrPlatform, setQrPlatform] = useState<string | null>(null);
|
||||
const [isLoadingQR, setIsLoadingQR] = useState(false);
|
||||
const [faceVerifyQr, setFaceVerifyQr] = useState<string | null>(null);
|
||||
|
||||
// 使用全局认证状态
|
||||
const { userId, isLoading: isAuthLoading } = useAuth();
|
||||
// 是否已从 localStorage 恢复完成
|
||||
const [isRestored, setIsRestored] = useState(false);
|
||||
const { isGenerating } = useTask();
|
||||
const prevIsGenerating = useRef(isGenerating);
|
||||
const { readPrefetch, updatePrefetch } = usePublishPrefetch();
|
||||
|
||||
// ---- 视频选择持久化:用 ref 而非 state,彻底避免 effect 竞态 ----
|
||||
const videoRestoredRef = useRef(false);
|
||||
const titleRestoredRef = useRef(false);
|
||||
|
||||
const getStorageKey = useCallback(() => userId || "guest", [userId]);
|
||||
|
||||
const titleInput = useTitleInput({
|
||||
value: title,
|
||||
onChange: setTitle,
|
||||
});
|
||||
|
||||
const readPrefetch = () => {
|
||||
if (typeof window === "undefined") return null;
|
||||
const raw = sessionStorage.getItem(PREFETCH_KEY);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const cache = JSON.parse(raw) as PublishPrefetchCache;
|
||||
if (!cache?.ts) return null;
|
||||
if (Date.now() - cache.ts > PREFETCH_TTL) return null;
|
||||
return cache;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const updatePrefetch = (patch: Partial<PublishPrefetchCache>) => {
|
||||
if (typeof window === "undefined") return;
|
||||
const existing = readPrefetch() || { ts: Date.now() };
|
||||
const next = { ...existing, ...patch, ts: Date.now() };
|
||||
sessionStorage.setItem(PREFETCH_KEY, JSON.stringify(next));
|
||||
};
|
||||
// ---- 数据加载 ----
|
||||
|
||||
const fetchAccounts = async () => {
|
||||
try {
|
||||
@@ -102,21 +69,20 @@ export const usePublishController = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchVideos = async () => {
|
||||
const fetchVideos = async (autoSelectLatest = false) => {
|
||||
try {
|
||||
const { data: res } = await api.get<ApiResponse<{ videos: any[] }>>(
|
||||
"/api/videos/generated"
|
||||
);
|
||||
const payload = unwrap(res);
|
||||
|
||||
const nextVideos = (payload.videos || []).map((v: any) => ({
|
||||
id: v.id as string,
|
||||
name: formatDate(v.created_at) + ` (${v.size_mb.toFixed(1)}MB)`,
|
||||
path: v.path.startsWith("/") ? v.path.slice(1) : v.path,
|
||||
}));
|
||||
|
||||
setVideos(nextVideos);
|
||||
if (nextVideos.length > 0) {
|
||||
setSelectedVideo(nextVideos[0].path);
|
||||
if (nextVideos.length > 0 && autoSelectLatest) {
|
||||
setSelectedVideo(nextVideos[0].id);
|
||||
}
|
||||
updatePrefetch({ videos: nextVideos });
|
||||
} catch (error) {
|
||||
@@ -124,91 +90,137 @@ export const usePublishController = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
const cache = readPrefetch();
|
||||
if (cache?.accounts) {
|
||||
setAccounts(cache.accounts);
|
||||
setIsAccountsLoading(false);
|
||||
}
|
||||
if (cache?.videos) {
|
||||
setVideos(cache.videos);
|
||||
if (!selectedVideo && cache.videos.length > 0) {
|
||||
setSelectedVideo(cache.videos[0].path);
|
||||
}
|
||||
setIsVideosLoading(false);
|
||||
}
|
||||
|
||||
if (cache?.accounts) { setAccounts(cache.accounts); setIsAccountsLoading(false); }
|
||||
if (cache?.videos) { setVideos(cache.videos); setIsVideosLoading(false); }
|
||||
if (!cache?.accounts) setIsAccountsLoading(true);
|
||||
if (!cache?.videos) setIsVideosLoading(true);
|
||||
|
||||
let active = true;
|
||||
void Promise.allSettled([
|
||||
fetchAccounts(),
|
||||
fetchVideos(),
|
||||
]).finally(() => {
|
||||
void Promise.allSettled([fetchAccounts(), fetchVideos(false)]).finally(() => {
|
||||
if (!active) return;
|
||||
setIsAccountsLoading(false);
|
||||
setIsVideosLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
return () => { active = false; };
|
||||
}, []);
|
||||
|
||||
// ---- 视频选择恢复(唯一一个 effect,条件极简) ----
|
||||
// 等 auth 完成 + videos 有数据 → 恢复一次,之后再也不跑
|
||||
useEffect(() => {
|
||||
if (isAuthLoading || videos.length === 0 || videoRestoredRef.current) return;
|
||||
videoRestoredRef.current = true;
|
||||
|
||||
const key = getStorageKey();
|
||||
const saved = localStorage.getItem(`vigent_${key}_publish_selected_video`);
|
||||
if (saved && videos.some(v => v.id === saved)) {
|
||||
setSelectedVideo(saved);
|
||||
} else {
|
||||
setSelectedVideo(videos[0].id);
|
||||
}
|
||||
}, [isAuthLoading, videos, getStorageKey]);
|
||||
|
||||
// ---- 视频选择保存 ----
|
||||
useEffect(() => {
|
||||
if (!videoRestoredRef.current || !selectedVideo || isAuthLoading) return;
|
||||
localStorage.setItem(`vigent_${getStorageKey()}_publish_selected_video`, selectedVideo);
|
||||
}, [selectedVideo, isAuthLoading, getStorageKey]);
|
||||
|
||||
// ---- 任务完成 → 自动选中最新 ----
|
||||
useEffect(() => {
|
||||
if (prevIsGenerating.current && !isGenerating) {
|
||||
void fetchVideos(true);
|
||||
}
|
||||
prevIsGenerating.current = isGenerating;
|
||||
}, [isGenerating]);
|
||||
|
||||
// ---- 标题/标签恢复与保存 ----
|
||||
useEffect(() => {
|
||||
if (isAuthLoading || titleRestoredRef.current) return;
|
||||
titleRestoredRef.current = true;
|
||||
|
||||
const key = getStorageKey();
|
||||
const savedTitle = localStorage.getItem(`vigent_${key}_publish_title`);
|
||||
const savedTags = localStorage.getItem(`vigent_${key}_publish_tags`);
|
||||
if (savedTitle) setTitle(clampTitle(savedTitle));
|
||||
if (savedTags) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedTags);
|
||||
setTags(Array.isArray(parsed) ? parsed.join(", ") : savedTags);
|
||||
} catch { setTags(savedTags); }
|
||||
}
|
||||
}, [isAuthLoading, getStorageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!titleRestoredRef.current || isAuthLoading) return;
|
||||
const key = getStorageKey();
|
||||
const t = setTimeout(() => localStorage.setItem(`vigent_${key}_publish_title`, title), 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [title, isAuthLoading, getStorageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!titleRestoredRef.current || isAuthLoading) return;
|
||||
const key = getStorageKey();
|
||||
const t = setTimeout(() => localStorage.setItem(`vigent_${key}_publish_tags`, tags), 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [tags, isAuthLoading, getStorageKey]);
|
||||
|
||||
// ---- 页面滚动 ----
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if ("scrollRestoration" in window.history) {
|
||||
window.history.scrollRestoration = "manual";
|
||||
}
|
||||
if ("scrollRestoration" in window.history) window.history.scrollRestoration = "manual";
|
||||
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
|
||||
}, []);
|
||||
|
||||
// 获取存储 key 的前缀(登录用户使用 userId,未登录使用 guest)
|
||||
const storageKey = userId || "guest";
|
||||
|
||||
// 从 localStorage 恢复用户输入(等待认证完成后)
|
||||
// ---- 发布防误操作 ----
|
||||
useEffect(() => {
|
||||
if (isAuthLoading) return;
|
||||
if (!isPublishing) return;
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault();
|
||||
event.returnValue = "发布进行中,请勿刷新页面";
|
||||
};
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
}, [isPublishing]);
|
||||
|
||||
// 从 localStorage 恢复用户输入(带用户隔离,未登录用户使用 guest)
|
||||
const savedTitle = localStorage.getItem(`vigent_${storageKey}_publish_title`);
|
||||
const savedTags = localStorage.getItem(`vigent_${storageKey}_publish_tags`);
|
||||
|
||||
if (savedTitle) setTitle(clampTitle(savedTitle));
|
||||
if (savedTags) {
|
||||
// 兼容 JSON 数组格式(AI 生成)和字符串格式(手动输入)
|
||||
try {
|
||||
const parsed = JSON.parse(savedTags);
|
||||
if (Array.isArray(parsed)) {
|
||||
setTags(parsed.join(", "));
|
||||
} else {
|
||||
setTags(savedTags);
|
||||
// ---- SWR Polling for Login Status ----
|
||||
useSWR(
|
||||
qrPlatform ? `${apiBase}/api/publish/login/status/${qrPlatform}` : null,
|
||||
fetcher,
|
||||
{
|
||||
refreshInterval: 2000,
|
||||
onSuccess: (data) => {
|
||||
if (data.success) {
|
||||
setQrCodeImage(null);
|
||||
setQrPlatform(null);
|
||||
setFaceVerifyQr(null);
|
||||
toast.success("✅ 登录成功!");
|
||||
fetchAccounts();
|
||||
} else if (data.face_verify_qr && !faceVerifyQr) {
|
||||
// 刷脸验证:后端捕获了新二维码页面截图
|
||||
setFaceVerifyQr(data.face_verify_qr);
|
||||
}
|
||||
} catch {
|
||||
setTags(savedTags);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// 恢复完成后才允许保存
|
||||
setIsRestored(true);
|
||||
}, [storageKey, isAuthLoading]);
|
||||
|
||||
// 保存用户输入到 localStorage(恢复完成后才保存,未登录用户也可保存)
|
||||
useEffect(() => {
|
||||
if (!isRestored) return;
|
||||
const timeout = setTimeout(() => {
|
||||
localStorage.setItem(`vigent_${storageKey}_publish_title`, title);
|
||||
}, 300);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [title, storageKey, isRestored]);
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRestored) return;
|
||||
const timeout = setTimeout(() => {
|
||||
localStorage.setItem(`vigent_${storageKey}_publish_tags`, tags);
|
||||
}, 300);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [tags, storageKey, isRestored]);
|
||||
let timer: NodeJS.Timeout;
|
||||
if (qrPlatform) {
|
||||
timer = setTimeout(() => {
|
||||
if (qrPlatform) {
|
||||
setQrPlatform(null);
|
||||
setQrCodeImage(null);
|
||||
toast.error("登录超时,请重试");
|
||||
}
|
||||
}, 180000);
|
||||
}
|
||||
return () => clearTimeout(timer);
|
||||
}, [qrPlatform]);
|
||||
|
||||
// ---- 操作函数 ----
|
||||
|
||||
const togglePlatform = (platform: string) => {
|
||||
if (selectedPlatforms.includes(platform)) {
|
||||
@@ -220,96 +232,40 @@ export const usePublishController = () => {
|
||||
|
||||
const handlePublish = async () => {
|
||||
if (!selectedVideo || !title || selectedPlatforms.length === 0) {
|
||||
alert("请选择视频、填写标题并选择至少一个平台");
|
||||
toast.error("请选择视频、填写标题并选择至少一个平台");
|
||||
return;
|
||||
}
|
||||
const video = videos.find(v => v.id === selectedVideo);
|
||||
if (!video) {
|
||||
toast.error("未找到选中的视频");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPublishing(true);
|
||||
setPublishResults([]);
|
||||
|
||||
const tagList = tags.split(/[,,\s]+/).filter((t) => t.trim());
|
||||
|
||||
for (const platform of selectedPlatforms) {
|
||||
try {
|
||||
const { data: res } = await api.post<ApiResponse<any>>("/api/publish", {
|
||||
video_path: selectedVideo,
|
||||
platform,
|
||||
title,
|
||||
tags: tagList,
|
||||
description: "",
|
||||
video_path: video.path, platform, title, tags: tagList, description: "",
|
||||
});
|
||||
|
||||
const result = unwrap(res);
|
||||
const screenshotUrl =
|
||||
typeof result.screenshot_url === "string"
|
||||
? resolveMediaUrl(result.screenshot_url) || result.screenshot_url
|
||||
: undefined;
|
||||
const nextResult: PublishResult = {
|
||||
const screenshotUrl = typeof result.screenshot_url === "string"
|
||||
? resolveMediaUrl(result.screenshot_url) || result.screenshot_url : undefined;
|
||||
setPublishResults((prev) => [...prev, {
|
||||
platform: result.platform || platform,
|
||||
success: Boolean(result.success),
|
||||
message: result.message || "",
|
||||
url: result.url,
|
||||
screenshot_url: screenshotUrl,
|
||||
};
|
||||
setPublishResults((prev) => [...prev, nextResult]);
|
||||
}]);
|
||||
} catch (error: any) {
|
||||
const message = error.response?.data?.message || String(error);
|
||||
setPublishResults((prev) => [
|
||||
...prev,
|
||||
{ platform, success: false, message },
|
||||
]);
|
||||
setPublishResults((prev) => [...prev, { platform, success: false, message }]);
|
||||
}
|
||||
}
|
||||
|
||||
setIsPublishing(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPublishing) return;
|
||||
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault();
|
||||
event.returnValue = "发布进行中,请勿刷新页面";
|
||||
};
|
||||
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => {
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
};
|
||||
}, [isPublishing]);
|
||||
|
||||
// SWR Polling for Login Status
|
||||
useSWR(
|
||||
qrPlatform ? `${apiBase}/api/publish/login/status/${qrPlatform}` : null,
|
||||
fetcher,
|
||||
{
|
||||
refreshInterval: 2000,
|
||||
onSuccess: (data) => {
|
||||
if (data.success) {
|
||||
setQrCodeImage(null);
|
||||
setQrPlatform(null);
|
||||
alert("✅ 登录成功!");
|
||||
fetchAccounts();
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Timeout logic for QR code (business logic: stop after 2 mins)
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout;
|
||||
if (qrPlatform) {
|
||||
timer = setTimeout(() => {
|
||||
if (qrPlatform) {
|
||||
setQrPlatform(null);
|
||||
setQrCodeImage(null);
|
||||
alert("登录超时,请重试");
|
||||
}
|
||||
}, 120000);
|
||||
}
|
||||
return () => clearTimeout(timer);
|
||||
}, [qrPlatform]);
|
||||
|
||||
const handleLogin = async (platform: string) => {
|
||||
setIsLoadingQR(true);
|
||||
setQrPlatform(platform);
|
||||
@@ -317,16 +273,15 @@ export const usePublishController = () => {
|
||||
try {
|
||||
const { data: res } = await api.post<ApiResponse<any>>(`/api/publish/login/${platform}`);
|
||||
const result = unwrap(res);
|
||||
|
||||
if (result.success && result.qr_code) {
|
||||
setQrCodeImage(result.qr_code);
|
||||
} else {
|
||||
setQrPlatform(null);
|
||||
alert(result.message || "登录失败");
|
||||
toast.error(result.message || "登录失败");
|
||||
}
|
||||
} catch (error: any) {
|
||||
setQrPlatform(null);
|
||||
alert(`登录失败: ${error.response?.data?.message || error.message}`);
|
||||
toast.error(`登录失败: ${error.response?.data?.message || error.message}`);
|
||||
} finally {
|
||||
setIsLoadingQR(false);
|
||||
}
|
||||
@@ -337,14 +292,10 @@ export const usePublishController = () => {
|
||||
try {
|
||||
const { data: res } = await api.post<ApiResponse<any>>(`/api/publish/logout/${platform}`);
|
||||
const result = unwrap(res);
|
||||
if (result.success) {
|
||||
alert("已注销");
|
||||
fetchAccounts();
|
||||
} else {
|
||||
alert(result.message || "注销失败");
|
||||
}
|
||||
if (result.success) { toast.success("已注销"); fetchAccounts(); }
|
||||
else { toast.error(result.message || "注销失败"); }
|
||||
} catch (error: any) {
|
||||
alert(`注销失败: ${error.response?.data?.message || error.message}`);
|
||||
toast.error(`注销失败: ${error.response?.data?.message || error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -361,51 +312,27 @@ export const usePublishController = () => {
|
||||
return videos.filter((v) => v.name.toLowerCase().includes(query));
|
||||
}, [videos, videoFilter]);
|
||||
|
||||
const handlePreviewVideo = (path: string) => {
|
||||
const previewPath = isAbsoluteUrl(path)
|
||||
? path
|
||||
: path.startsWith("/")
|
||||
? path
|
||||
: `/${path}`;
|
||||
const handlePreviewVideo = (videoId: string) => {
|
||||
const video = videos.find(v => v.id === videoId);
|
||||
if (!video) return;
|
||||
const previewPath = isAbsoluteUrl(video.path) ? video.path : video.path.startsWith("/") ? video.path : `/${video.path}`;
|
||||
setPreviewVideoUrl(resolveMediaUrl(previewPath) || previewPath);
|
||||
};
|
||||
|
||||
const closeQrModal = () => {
|
||||
setQrCodeImage(null);
|
||||
setQrPlatform(null);
|
||||
setFaceVerifyQr(null);
|
||||
};
|
||||
|
||||
return {
|
||||
apiBase,
|
||||
accounts,
|
||||
videos,
|
||||
isAccountsLoading,
|
||||
isVideosLoading,
|
||||
selectedVideo,
|
||||
setSelectedVideo,
|
||||
videoFilter,
|
||||
setVideoFilter,
|
||||
previewVideoUrl,
|
||||
setPreviewVideoUrl,
|
||||
selectedPlatforms,
|
||||
title,
|
||||
titleInput,
|
||||
tags,
|
||||
setTags,
|
||||
isPublishing,
|
||||
publishResults,
|
||||
qrCodeImage,
|
||||
qrPlatform,
|
||||
isLoadingQR,
|
||||
fetchAccounts,
|
||||
fetchVideos,
|
||||
togglePlatform,
|
||||
handlePublish,
|
||||
handleLogin,
|
||||
handleLogout,
|
||||
platformIcons,
|
||||
filteredVideos,
|
||||
handlePreviewVideo,
|
||||
closeQrModal,
|
||||
apiBase, accounts, videos, isAccountsLoading, isVideosLoading,
|
||||
selectedVideo, setSelectedVideo, videoFilter, setVideoFilter,
|
||||
previewVideoUrl, setPreviewVideoUrl, selectedPlatforms,
|
||||
title, titleInput, tags, setTags,
|
||||
isPublishing, publishResults, qrCodeImage, qrPlatform, isLoadingQR, faceVerifyQr,
|
||||
fetchAccounts, fetchVideos, togglePlatform, handlePublish,
|
||||
handleLogin, handleLogout, platformIcons, filteredVideos,
|
||||
handlePreviewVideo, closeQrModal,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import VideoPreviewModal from "@/components/VideoPreviewModal";
|
||||
import AccountSettingsDropdown from "@/components/AccountSettingsDropdown";
|
||||
import { usePublishController } from "@/features/publish/model/usePublishController";
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
QrCode,
|
||||
Search,
|
||||
Eye,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
|
||||
export function PublishPage() {
|
||||
@@ -34,6 +36,7 @@ export function PublishPage() {
|
||||
qrCodeImage,
|
||||
qrPlatform,
|
||||
isLoadingQR,
|
||||
faceVerifyQr,
|
||||
togglePlatform,
|
||||
handlePublish,
|
||||
handleLogin,
|
||||
@@ -61,12 +64,29 @@ export function PublishPage() {
|
||||
<div className="animate-spin w-16 h-16 border-4 border-purple-500 border-t-transparent rounded-full" />
|
||||
<p className="text-gray-600 mt-4">正在获取二维码...</p>
|
||||
</div>
|
||||
) : faceVerifyQr ? (
|
||||
<>
|
||||
<Image
|
||||
src={`data:image/png;base64,${faceVerifyQr}`}
|
||||
alt="Face Verify QR"
|
||||
width={400}
|
||||
height={300}
|
||||
className="w-full h-auto rounded-lg"
|
||||
unoptimized
|
||||
/>
|
||||
<p className="text-center text-orange-600 font-medium mt-4">
|
||||
需要身份验证,请用抖音APP扫描上方二维码完成刷脸验证
|
||||
</p>
|
||||
</>
|
||||
) : qrCodeImage ? (
|
||||
<>
|
||||
<img
|
||||
<Image
|
||||
src={`data:image/png;base64,${qrCodeImage}`}
|
||||
alt="QR Code"
|
||||
width={280}
|
||||
height={280}
|
||||
className="w-full h-auto"
|
||||
unoptimized
|
||||
/>
|
||||
<p className="text-center text-gray-600 mt-4">
|
||||
请使用手机扫码登录
|
||||
@@ -145,9 +165,11 @@ export function PublishPage() {
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{platformIcons[account.platform] ? (
|
||||
<img
|
||||
<Image
|
||||
src={platformIcons[account.platform].src}
|
||||
alt={platformIcons[account.platform].alt}
|
||||
width={28}
|
||||
height={28}
|
||||
className="h-7 w-7"
|
||||
/>
|
||||
) : (
|
||||
@@ -239,9 +261,9 @@ export function PublishPage() {
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto hide-scrollbar" style={{ contentVisibility: "auto" }}>
|
||||
{filteredVideos.map((v) => (
|
||||
<div
|
||||
key={v.path}
|
||||
onClick={() => setSelectedVideo(v.path)}
|
||||
className={`p-3 rounded-lg border transition-all flex items-center justify-between group cursor-pointer ${selectedVideo === v.path
|
||||
key={v.id}
|
||||
onClick={() => setSelectedVideo(v.id)}
|
||||
className={`p-3 rounded-lg border transition-all flex items-center justify-between group cursor-pointer ${selectedVideo === v.id
|
||||
? "border-purple-500 bg-purple-500/20"
|
||||
: "border-white/10 bg-white/5 hover:border-white/30"
|
||||
}`}
|
||||
@@ -253,7 +275,7 @@ export function PublishPage() {
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePreviewVideo(v.path);
|
||||
handlePreviewVideo(v.id);
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
const src = v.path.startsWith("/") ? v.path : `/${v.path}`;
|
||||
@@ -269,7 +291,7 @@ export function PublishPage() {
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
{selectedVideo === v.path && (
|
||||
{selectedVideo === v.id && (
|
||||
<span className="text-xs text-purple-300">已选</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -331,9 +353,11 @@ export function PublishPage() {
|
||||
>
|
||||
<span className="block mb-1">
|
||||
{platformIcons[account.platform] ? (
|
||||
<img
|
||||
<Image
|
||||
src={platformIcons[account.platform].src}
|
||||
alt={platformIcons[account.platform].alt}
|
||||
width={28}
|
||||
height={28}
|
||||
className="h-7 w-7 mx-auto"
|
||||
/>
|
||||
) : (
|
||||
@@ -352,7 +376,12 @@ export function PublishPage() {
|
||||
disabled={isPublishing || selectedPlatforms.length === 0}
|
||||
className="w-full py-4 bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-xl font-bold text-lg hover:shadow-lg hover:from-purple-500 hover:to-pink-500 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isPublishing ? "正在发布...请勿刷新或关闭网页" : "立即发布"}
|
||||
{isPublishing ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
正在发布...请勿刷新或关闭网页
|
||||
</span>
|
||||
) : "立即发布"}
|
||||
</button>
|
||||
|
||||
{/* 发布结果 */}
|
||||
@@ -367,15 +396,17 @@ export function PublishPage() {
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{platformIcons[result.platform] ? (
|
||||
<img
|
||||
src={platformIcons[result.platform].src}
|
||||
alt={platformIcons[result.platform].alt}
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-lg">🌐</span>
|
||||
)}
|
||||
{platformIcons[result.platform] ? (
|
||||
<Image
|
||||
src={platformIcons[result.platform].src}
|
||||
alt={platformIcons[result.platform].alt}
|
||||
width={20}
|
||||
height={20}
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-lg">🌐</span>
|
||||
)}
|
||||
<span className={`font-medium ${result.success ? "text-green-400" : "text-red-400"}`}>
|
||||
{result.success ? "发布成功" : "发布失败"}
|
||||
</span>
|
||||
@@ -390,10 +421,13 @@ export function PublishPage() {
|
||||
rel="noreferrer"
|
||||
className="block"
|
||||
>
|
||||
<img
|
||||
<Image
|
||||
src={result.screenshot_url}
|
||||
alt="发布成功截图"
|
||||
width={400}
|
||||
height={300}
|
||||
className="w-full rounded-md border border-white/10"
|
||||
unoptimized
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,7 @@ api.interceptors.response.use(
|
||||
// 调用 logout API 清除 HttpOnly cookie
|
||||
try {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// 忽略错误
|
||||
}
|
||||
|
||||
|
||||
53
frontend/src/shared/hooks/usePublishPrefetch.ts
Normal file
53
frontend/src/shared/hooks/usePublishPrefetch.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useCallback } from "react";
|
||||
import { PublishPrefetchCache } from "@/shared/types/publish";
|
||||
|
||||
const PREFETCH_KEY = "vigent_publish_prefetch_v1";
|
||||
const PREFETCH_TTL = 2 * 60 * 1000; // 2 分钟
|
||||
|
||||
/**
|
||||
* 发布预加载缓存 hook
|
||||
* 用于在首页预加载发布页所需的账号和视频数据
|
||||
*/
|
||||
export const usePublishPrefetch = () => {
|
||||
const readPrefetch = useCallback((): PublishPrefetchCache | null => {
|
||||
if (typeof window === "undefined") return null;
|
||||
const raw = sessionStorage.getItem(PREFETCH_KEY);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const cache = JSON.parse(raw) as PublishPrefetchCache;
|
||||
if (!cache?.ts) return null;
|
||||
if (Date.now() - cache.ts > PREFETCH_TTL) return null;
|
||||
return cache;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updatePrefetch = useCallback((patch: Partial<PublishPrefetchCache>) => {
|
||||
if (typeof window === "undefined") return;
|
||||
const existing = (() => {
|
||||
const raw = sessionStorage.getItem(PREFETCH_KEY);
|
||||
if (!raw) return { ts: Date.now() };
|
||||
try {
|
||||
const cache = JSON.parse(raw) as PublishPrefetchCache;
|
||||
if (!cache?.ts || Date.now() - cache.ts > PREFETCH_TTL) return { ts: Date.now() };
|
||||
return cache;
|
||||
} catch {
|
||||
return { ts: Date.now() };
|
||||
}
|
||||
})();
|
||||
const next = { ...existing, ...patch, ts: Date.now() };
|
||||
sessionStorage.setItem(PREFETCH_KEY, JSON.stringify(next));
|
||||
}, []);
|
||||
|
||||
const clearPrefetch = useCallback(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
sessionStorage.removeItem(PREFETCH_KEY);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
readPrefetch,
|
||||
updatePrefetch,
|
||||
clearPrefetch,
|
||||
};
|
||||
};
|
||||
@@ -1,20 +1,13 @@
|
||||
/**
|
||||
* 认证工具函数
|
||||
*/
|
||||
|
||||
const API_BASE = typeof window === 'undefined'
|
||||
? (process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8006')
|
||||
: '';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
phone: string;
|
||||
username: string | null;
|
||||
role: string;
|
||||
is_active: boolean;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 认证工具函数
|
||||
* 统一使用 axios 实例,与其他 API 调用保持一致的错误处理
|
||||
*/
|
||||
import api from "@/shared/api/axios";
|
||||
import { User } from "@/shared/types/user";
|
||||
|
||||
// Re-export User 类型以保持向后兼容
|
||||
export type { User };
|
||||
|
||||
export interface AuthResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
@@ -27,94 +20,69 @@ interface ApiResponse<T> {
|
||||
data: T;
|
||||
code: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
*/
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
*/
|
||||
export async function register(phone: string, password: string, username?: string): Promise<AuthResponse> {
|
||||
const res = await fetch(`${API_BASE}/api/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ phone, password, username })
|
||||
const { data: payload } = await api.post<ApiResponse<null>>('/api/auth/register', {
|
||||
phone, password, username
|
||||
});
|
||||
const payload = await res.json();
|
||||
const data = payload as ApiResponse<null>;
|
||||
return { success: data.success, message: data.message };
|
||||
return { success: payload.success, message: payload.message };
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
*/
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
*/
|
||||
export async function login(phone: string, password: string): Promise<AuthResponse> {
|
||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ phone, password })
|
||||
const { data: payload } = await api.post<ApiResponse<{ user?: User }>>('/api/auth/login', {
|
||||
phone, password
|
||||
});
|
||||
const payload = await res.json();
|
||||
const data = payload as ApiResponse<{ user?: User }>;
|
||||
return { success: data.success, message: data.message, user: data.data?.user };
|
||||
return { success: payload.success, message: payload.message, user: payload.data?.user };
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登出
|
||||
*/
|
||||
|
||||
/**
|
||||
* 用户登出
|
||||
*/
|
||||
export async function logout(): Promise<AuthResponse> {
|
||||
const res = await fetch(`${API_BASE}/api/auth/logout`, {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
const payload = await res.json();
|
||||
const data = payload as ApiResponse<null>;
|
||||
return { success: data.success, message: data.message };
|
||||
const { data: payload } = await api.post<ApiResponse<null>>('/api/auth/logout');
|
||||
return { success: payload.success, message: payload.message };
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改密码
|
||||
*/
|
||||
|
||||
/**
|
||||
* 修改密码
|
||||
*/
|
||||
export async function changePassword(oldPassword: string, newPassword: string): Promise<AuthResponse> {
|
||||
const res = await fetch(`${API_BASE}/api/auth/change-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ old_password: oldPassword, new_password: newPassword })
|
||||
const { data: payload } = await api.post<ApiResponse<null>>('/api/auth/change-password', {
|
||||
old_password: oldPassword, new_password: newPassword
|
||||
});
|
||||
const payload = await res.json();
|
||||
const data = payload as ApiResponse<null>;
|
||||
return { success: data.success, message: data.message };
|
||||
return { success: payload.success, message: payload.message };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户
|
||||
*/
|
||||
|
||||
/**
|
||||
* 获取当前用户
|
||||
*/
|
||||
export async function getCurrentUser(): Promise<User | null> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/auth/me`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const payload = await res.json();
|
||||
const data = payload as ApiResponse<User>;
|
||||
return data.data || null;
|
||||
const { data: payload } = await api.get<ApiResponse<User>>('/api/auth/me');
|
||||
return payload.data || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已登录
|
||||
*/
|
||||
export async function isAuthenticated(): Promise<boolean> {
|
||||
const user = await getCurrentUser();
|
||||
return user !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否是管理员
|
||||
*/
|
||||
export async function isAdmin(): Promise<boolean> {
|
||||
const user = await getCurrentUser();
|
||||
return user?.role === 'admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已登录
|
||||
*/
|
||||
export async function isAuthenticated(): Promise<boolean> {
|
||||
const user = await getCurrentUser();
|
||||
return user !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否是管理员
|
||||
*/
|
||||
export async function isAdmin(): Promise<boolean> {
|
||||
const user = await getCurrentUser();
|
||||
return user?.role === 'admin';
|
||||
}
|
||||
|
||||
35
frontend/src/shared/types/publish.ts
Normal file
35
frontend/src/shared/types/publish.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 发布相关共用类型定义
|
||||
* 用于 useHomeController 和 usePublishController 之间共享
|
||||
*/
|
||||
|
||||
/** 发布平台账号 */
|
||||
export interface PublishAccount {
|
||||
platform: string;
|
||||
name: string;
|
||||
logged_in: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/** 可发布的视频 */
|
||||
export interface PublishVideo {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
/** 发布预加载缓存 */
|
||||
export interface PublishPrefetchCache {
|
||||
ts: number;
|
||||
accounts?: PublishAccount[];
|
||||
videos?: PublishVideo[];
|
||||
}
|
||||
|
||||
/** 发布结果 */
|
||||
export interface PublishResult {
|
||||
platform: string;
|
||||
success: boolean;
|
||||
message: string;
|
||||
url?: string | null;
|
||||
screenshot_url?: string;
|
||||
}
|
||||
13
frontend/src/shared/types/user.ts
Normal file
13
frontend/src/shared/types/user.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 用户类型定义
|
||||
* 统一管理用户相关类型,避免重复定义
|
||||
*/
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
phone: string;
|
||||
username: string | null;
|
||||
role: string;
|
||||
is_active: boolean;
|
||||
expires_at: string | null;
|
||||
}
|
||||
@@ -65,14 +65,15 @@ async def lifespan(app: FastAPI):
|
||||
# --- 模型加载逻辑 (参考 inference.py) ---
|
||||
print("⏳ 正在加载 LatentSync 模型...")
|
||||
|
||||
# 默认配置路径 (相对于根目录)
|
||||
unet_config_path = "configs/unet/stage2_512.yaml"
|
||||
ckpt_path = "checkpoints/latentsync_unet.pt"
|
||||
# 使用绝对路径,确保可以从任意目录启动
|
||||
latentsync_root = Path(__file__).resolve().parent.parent # scripts -> LatentSync 根目录
|
||||
unet_config_path = latentsync_root / "configs" / "unet" / "stage2_512.yaml"
|
||||
ckpt_path = latentsync_root / "checkpoints" / "latentsync_unet.pt"
|
||||
|
||||
if not os.path.exists(unet_config_path):
|
||||
print(f"⚠️ 找不到配置文件: {unet_config_path},请确保在 models/LatentSync 根目录运行")
|
||||
if not unet_config_path.exists():
|
||||
print(f"⚠️ 找不到配置文件: {unet_config_path}")
|
||||
|
||||
config = OmegaConf.load(unet_config_path)
|
||||
config = OmegaConf.load(str(unet_config_path))
|
||||
|
||||
# Check GPU
|
||||
is_fp16_supported = torch.cuda.is_available() and torch.cuda.get_device_capability()[0] > 7
|
||||
@@ -85,13 +86,13 @@ async def lifespan(app: FastAPI):
|
||||
else:
|
||||
print("⚠️ 警告: 未检测到 GPU,将使用 CPU 进行推理 (速度极慢)")
|
||||
|
||||
scheduler = DDIMScheduler.from_pretrained("configs")
|
||||
scheduler = DDIMScheduler.from_pretrained(str(latentsync_root / "configs"))
|
||||
|
||||
# Whisper Model
|
||||
if config.model.cross_attention_dim == 768:
|
||||
whisper_path = "checkpoints/whisper/small.pt"
|
||||
whisper_path = str(latentsync_root / "checkpoints" / "whisper" / "small.pt")
|
||||
else:
|
||||
whisper_path = "checkpoints/whisper/tiny.pt"
|
||||
whisper_path = str(latentsync_root / "checkpoints" / "whisper" / "tiny.pt")
|
||||
|
||||
audio_encoder = Audio2Feature(
|
||||
model_path=whisper_path,
|
||||
@@ -108,7 +109,7 @@ async def lifespan(app: FastAPI):
|
||||
# UNet
|
||||
unet, _ = UNet3DConditionModel.from_pretrained(
|
||||
OmegaConf.to_container(config.model),
|
||||
ckpt_path,
|
||||
str(ckpt_path),
|
||||
device="cpu", # Load to CPU first to save memory during init
|
||||
)
|
||||
unet = unet.to(dtype=dtype)
|
||||
@@ -129,6 +130,7 @@ async def lifespan(app: FastAPI):
|
||||
models["pipeline"] = pipeline
|
||||
models["config"] = config
|
||||
models["dtype"] = dtype
|
||||
models["latentsync_root"] = latentsync_root
|
||||
|
||||
print("✅ LatentSync 模型加载完成,服务就绪!")
|
||||
yield
|
||||
@@ -167,6 +169,7 @@ async def generate_lipsync(req: LipSyncRequest):
|
||||
pipeline = models["pipeline"]
|
||||
config = models["config"]
|
||||
dtype = models["dtype"]
|
||||
latentsync_root = models["latentsync_root"]
|
||||
|
||||
# Set seed
|
||||
if req.seed != -1:
|
||||
@@ -185,7 +188,7 @@ async def generate_lipsync(req: LipSyncRequest):
|
||||
weight_dtype=dtype,
|
||||
width=config.data.resolution,
|
||||
height=config.data.resolution,
|
||||
mask_image_path=config.data.mask_image_path,
|
||||
mask_image_path=str(latentsync_root / config.data.mask_image_path),
|
||||
temp_dir=req.temp_dir,
|
||||
)
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
"scripts": {
|
||||
"start": "remotion studio",
|
||||
"build": "remotion bundle",
|
||||
"render": "npx ts-node render.ts"
|
||||
"build:render": "npx tsc render.ts --outDir dist --esModuleInterop --skipLibCheck",
|
||||
"render": "npx ts-node render.ts",
|
||||
"render:fast": "node dist/render.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"remotion": "^4.0.0",
|
||||
|
||||
@@ -16,6 +16,8 @@ interface RenderOptions {
|
||||
captionsPath?: string;
|
||||
title?: string;
|
||||
titleDuration?: number;
|
||||
subtitleStyle?: Record<string, unknown>;
|
||||
titleStyle?: Record<string, unknown>;
|
||||
outputPath: string;
|
||||
fps?: number;
|
||||
enableSubtitles?: boolean;
|
||||
@@ -53,6 +55,20 @@ async function parseArgs(): Promise<RenderOptions> {
|
||||
case 'enableSubtitles':
|
||||
options.enableSubtitles = value === 'true';
|
||||
break;
|
||||
case 'subtitleStyle':
|
||||
try {
|
||||
options.subtitleStyle = JSON.parse(value);
|
||||
} catch (e) {
|
||||
console.warn('Invalid subtitleStyle JSON');
|
||||
}
|
||||
break;
|
||||
case 'titleStyle':
|
||||
try {
|
||||
options.titleStyle = JSON.parse(value);
|
||||
} catch (e) {
|
||||
console.warn('Invalid titleStyle JSON');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,20 +100,22 @@ async function main() {
|
||||
let videoWidth = 1280;
|
||||
let videoHeight = 720;
|
||||
try {
|
||||
// 使用 ffprobe 获取视频时长
|
||||
const { execSync } = require('child_process');
|
||||
const ffprobeOutput = execSync(
|
||||
`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${options.videoPath}"`,
|
||||
{ encoding: 'utf-8' }
|
||||
// 使用 promisified exec 异步获取视频信息,避免阻塞主线程
|
||||
const { promisify } = require('util');
|
||||
const { exec } = require('child_process');
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// 获取视频时长
|
||||
const { stdout: durationOutput } = await execAsync(
|
||||
`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${options.videoPath}"`
|
||||
);
|
||||
const durationInSeconds = parseFloat(ffprobeOutput.trim());
|
||||
const durationInSeconds = parseFloat(durationOutput.trim());
|
||||
durationInFrames = Math.ceil(durationInSeconds * fps);
|
||||
console.log(`Video duration: ${durationInSeconds}s (${durationInFrames} frames at ${fps}fps)`);
|
||||
|
||||
// 使用 ffprobe 获取视频尺寸
|
||||
const dimensionsOutput = execSync(
|
||||
`ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=s=x:p=0 "${options.videoPath}"`,
|
||||
{ encoding: 'utf-8' }
|
||||
// 获取视频尺寸
|
||||
const { stdout: dimensionsOutput } = await execAsync(
|
||||
`ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=s=x:p=0 "${options.videoPath}"`
|
||||
);
|
||||
const [width, height] = dimensionsOutput.trim().split('x').map(Number);
|
||||
if (width && height) {
|
||||
@@ -116,8 +134,14 @@ async function main() {
|
||||
|
||||
// Bundle the Remotion project
|
||||
console.log('Bundling Remotion project...');
|
||||
|
||||
// 修复: 使用 process.cwd() 解析 src/index.ts,确保在 dist/render.js 和 ts-node 下都能找到
|
||||
// 假设脚本总是在 remotion 根目录下运行 (由 python service 保证)
|
||||
const entryPoint = path.resolve(process.cwd(), 'src/index.ts');
|
||||
console.log(`Entry point: ${entryPoint}`);
|
||||
|
||||
const bundleLocation = await bundle({
|
||||
entryPoint: path.resolve(__dirname, './src/index.ts'),
|
||||
entryPoint,
|
||||
webpackOverride: (config) => config,
|
||||
publicDir,
|
||||
});
|
||||
@@ -131,6 +155,8 @@ async function main() {
|
||||
captions,
|
||||
title: options.title,
|
||||
titleDuration: options.titleDuration || 3,
|
||||
subtitleStyle: options.subtitleStyle,
|
||||
titleStyle: options.titleStyle,
|
||||
enableSubtitles: options.enableSubtitles !== false,
|
||||
},
|
||||
});
|
||||
@@ -153,6 +179,8 @@ async function main() {
|
||||
captions,
|
||||
title: options.title,
|
||||
titleDuration: options.titleDuration || 3,
|
||||
subtitleStyle: options.subtitleStyle,
|
||||
titleStyle: options.titleStyle,
|
||||
enableSubtitles: options.enableSubtitles !== false,
|
||||
},
|
||||
onProgress: ({ progress }) => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import { AbsoluteFill, Composition } from 'remotion';
|
||||
import { VideoLayer } from './components/VideoLayer';
|
||||
import { Title } from './components/Title';
|
||||
import { Subtitles } from './components/Subtitles';
|
||||
import { Title, TitleStyle } from './components/Title';
|
||||
import { Subtitles, SubtitleStyle } from './components/Subtitles';
|
||||
import { CaptionsData } from './utils/captions';
|
||||
|
||||
export interface VideoProps {
|
||||
@@ -12,6 +12,8 @@ export interface VideoProps {
|
||||
title?: string;
|
||||
titleDuration?: number;
|
||||
enableSubtitles?: boolean;
|
||||
subtitleStyle?: SubtitleStyle;
|
||||
titleStyle?: TitleStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,6 +27,8 @@ export const Video: React.FC<VideoProps> = ({
|
||||
title,
|
||||
titleDuration = 3,
|
||||
enableSubtitles = true,
|
||||
subtitleStyle,
|
||||
titleStyle,
|
||||
}) => {
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: 'black' }}>
|
||||
@@ -33,12 +37,12 @@ export const Video: React.FC<VideoProps> = ({
|
||||
|
||||
{/* 中层:字幕 */}
|
||||
{enableSubtitles && captions && (
|
||||
<Subtitles captions={captions} />
|
||||
<Subtitles captions={captions} style={subtitleStyle} />
|
||||
)}
|
||||
|
||||
{/* 顶层:标题 */}
|
||||
{title && (
|
||||
<Title title={title} duration={titleDuration} />
|
||||
<Title title={title} duration={titleDuration} style={titleStyle} />
|
||||
)}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
|
||||
@@ -1,28 +1,59 @@
|
||||
import React from 'react';
|
||||
import { AbsoluteFill, useCurrentFrame, useVideoConfig } from 'remotion';
|
||||
import { AbsoluteFill, useCurrentFrame, useVideoConfig, staticFile } from 'remotion';
|
||||
import {
|
||||
CaptionsData,
|
||||
getCurrentSegment,
|
||||
getCurrentWordIndex,
|
||||
} from '../utils/captions';
|
||||
|
||||
export interface SubtitleStyle {
|
||||
font_file?: string;
|
||||
fontFamily?: string;
|
||||
font_family?: string;
|
||||
fontSize?: number;
|
||||
font_size?: number;
|
||||
highlightColor?: string;
|
||||
highlight_color?: string;
|
||||
normalColor?: string;
|
||||
normal_color?: string;
|
||||
strokeColor?: string;
|
||||
stroke_color?: string;
|
||||
strokeSize?: number;
|
||||
stroke_size?: number;
|
||||
letterSpacing?: number;
|
||||
letter_spacing?: number;
|
||||
bottomMargin?: number;
|
||||
bottom_margin?: number;
|
||||
}
|
||||
|
||||
interface SubtitlesProps {
|
||||
captions: CaptionsData;
|
||||
highlightColor?: string;
|
||||
normalColor?: string;
|
||||
fontSize?: number;
|
||||
style?: SubtitleStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
* 逐字高亮字幕组件
|
||||
* 根据时间戳逐字高亮显示字幕(无背景,纯文字描边)
|
||||
*/
|
||||
export const Subtitles: React.FC<SubtitlesProps> = ({
|
||||
captions,
|
||||
highlightColor = '#FFFF00',
|
||||
normalColor = '#FFFFFF',
|
||||
fontSize = 52,
|
||||
}) => {
|
||||
const getFontFormat = (fontFile?: string) => {
|
||||
if (!fontFile) return 'truetype';
|
||||
const ext = fontFile.split('.').pop()?.toLowerCase();
|
||||
if (ext === 'otf') return 'opentype';
|
||||
return 'truetype';
|
||||
};
|
||||
|
||||
const buildTextShadow = (color: string, size: number) => {
|
||||
return [
|
||||
`-${size}px -${size}px 0 ${color}`,
|
||||
`${size}px -${size}px 0 ${color}`,
|
||||
`-${size}px ${size}px 0 ${color}`,
|
||||
`${size}px ${size}px 0 ${color}`,
|
||||
`0 0 ${size * 4}px rgba(0,0,0,0.9)`,
|
||||
`0 4px 8px rgba(0,0,0,0.6)`
|
||||
].join(',');
|
||||
};
|
||||
|
||||
export const Subtitles: React.FC<SubtitlesProps> = ({ captions, style }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
@@ -38,45 +69,62 @@ export const Subtitles: React.FC<SubtitlesProps> = ({
|
||||
// 获取当前高亮字的索引
|
||||
const currentWordIndex = getCurrentWordIndex(currentSegment, currentTimeInSeconds);
|
||||
|
||||
const fontFile = style?.font_file;
|
||||
const fontFamily = style?.fontFamily || style?.font_family;
|
||||
const fontSize = style?.fontSize || style?.font_size || 52;
|
||||
const highlightColor = style?.highlightColor || style?.highlight_color || '#FFFF00';
|
||||
const normalColor = style?.normalColor || style?.normal_color || '#FFFFFF';
|
||||
const strokeColor = style?.strokeColor || style?.stroke_color || '#000000';
|
||||
const strokeSize = style?.strokeSize || style?.stroke_size || 3;
|
||||
const letterSpacing = style?.letterSpacing || style?.letter_spacing || 2;
|
||||
const bottomMargin = style?.bottomMargin || style?.bottom_margin;
|
||||
const fontFamilyName = fontFamily || 'SubtitleFont';
|
||||
const fontFamilyCss = fontFile
|
||||
? `'${fontFamilyName}'`
|
||||
: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif';
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
paddingBottom: '6%',
|
||||
paddingBottom: typeof bottomMargin === 'number' ? `${bottomMargin}px` : '6%',
|
||||
}}
|
||||
>
|
||||
{fontFile && (
|
||||
<style>{`
|
||||
@font-face {
|
||||
font-family: '${fontFamilyName}';
|
||||
src: url('${staticFile(fontFile)}') format('${getFontFormat(fontFile)}');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
`}</style>
|
||||
)}
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: `${fontSize}px`,
|
||||
fontFamily: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif',
|
||||
fontFamily: fontFamilyCss,
|
||||
fontWeight: 800,
|
||||
lineHeight: 1.4,
|
||||
textAlign: 'center',
|
||||
maxWidth: '90%',
|
||||
wordBreak: 'keep-all',
|
||||
letterSpacing: '2px',
|
||||
letterSpacing: `${letterSpacing}px`,
|
||||
}}
|
||||
>
|
||||
{currentSegment.words.map((word, index) => {
|
||||
const isHighlighted = index <= currentWordIndex;
|
||||
return (
|
||||
<span
|
||||
key={`${word.word}-${index}`}
|
||||
style={{
|
||||
color: isHighlighted ? highlightColor : normalColor,
|
||||
textShadow: `
|
||||
-3px -3px 0 #000,
|
||||
3px -3px 0 #000,
|
||||
-3px 3px 0 #000,
|
||||
3px 3px 0 #000,
|
||||
0 0 12px rgba(0,0,0,0.9),
|
||||
0 4px 8px rgba(0,0,0,0.6)
|
||||
`,
|
||||
transition: 'color 0.05s ease',
|
||||
}}
|
||||
>
|
||||
key={`${word.word}-${index}`}
|
||||
style={{
|
||||
color: isHighlighted ? highlightColor : normalColor,
|
||||
textShadow: buildTextShadow(strokeColor, strokeSize),
|
||||
transition: 'color 0.05s ease',
|
||||
}}
|
||||
>
|
||||
{word.word}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -4,22 +4,62 @@ import {
|
||||
interpolate,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
staticFile,
|
||||
} from 'remotion';
|
||||
|
||||
export interface TitleStyle {
|
||||
font_file?: string;
|
||||
fontFamily?: string;
|
||||
font_family?: string;
|
||||
fontSize?: number;
|
||||
font_size?: number;
|
||||
color?: string;
|
||||
strokeColor?: string;
|
||||
stroke_color?: string;
|
||||
strokeSize?: number;
|
||||
stroke_size?: number;
|
||||
letterSpacing?: number;
|
||||
letter_spacing?: number;
|
||||
topMargin?: number;
|
||||
top_margin?: number;
|
||||
fontWeight?: number;
|
||||
font_weight?: number;
|
||||
}
|
||||
|
||||
interface TitleProps {
|
||||
title: string;
|
||||
duration?: number; // 标题显示时长(秒)
|
||||
fadeOutStart?: number; // 开始淡出的时间(秒)
|
||||
style?: TitleStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
* 片头标题组件
|
||||
* 在视频顶部显示标题,带淡入淡出效果
|
||||
*/
|
||||
const getFontFormat = (fontFile?: string) => {
|
||||
if (!fontFile) return 'truetype';
|
||||
const ext = fontFile.split('.').pop()?.toLowerCase();
|
||||
if (ext === 'otf') return 'opentype';
|
||||
return 'truetype';
|
||||
};
|
||||
|
||||
const buildTextShadow = (color: string, size: number) => {
|
||||
return [
|
||||
`-${size}px -${size}px 0 ${color}`,
|
||||
`${size}px -${size}px 0 ${color}`,
|
||||
`-${size}px ${size}px 0 ${color}`,
|
||||
`${size}px ${size}px 0 ${color}`,
|
||||
`0 0 ${size * 2}px rgba(0,0,0,0.7)`,
|
||||
`0 4px 8px rgba(0,0,0,0.6)`
|
||||
].join(',');
|
||||
};
|
||||
|
||||
export const Title: React.FC<TitleProps> = ({
|
||||
title,
|
||||
duration = 3,
|
||||
fadeOutStart = 2,
|
||||
style,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
@@ -57,33 +97,52 @@ export const Title: React.FC<TitleProps> = ({
|
||||
{ extrapolateRight: 'clamp' }
|
||||
);
|
||||
|
||||
const fontFile = style?.font_file;
|
||||
const fontFamily = style?.fontFamily || style?.font_family;
|
||||
const fontSize = style?.fontSize || style?.font_size || 72;
|
||||
const color = style?.color || '#FFFFFF';
|
||||
const strokeColor = style?.strokeColor || style?.stroke_color || '#000000';
|
||||
const strokeSize = style?.strokeSize || style?.stroke_size || 8;
|
||||
const letterSpacing = style?.letterSpacing || style?.letter_spacing || 4;
|
||||
const topMargin = style?.topMargin || style?.top_margin;
|
||||
const fontWeight = style?.fontWeight || style?.font_weight || 900;
|
||||
const fontFamilyName = fontFamily || 'TitleFont';
|
||||
const fontFamilyCss = fontFile
|
||||
? `'${fontFamilyName}'`
|
||||
: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif';
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'center',
|
||||
paddingTop: '6%',
|
||||
paddingTop: typeof topMargin === 'number' ? `${topMargin}px` : '6%',
|
||||
opacity,
|
||||
}}
|
||||
>
|
||||
{fontFile && (
|
||||
<style>{`
|
||||
@font-face {
|
||||
font-family: '${fontFamilyName}';
|
||||
src: url('${staticFile(fontFile)}') format('${getFontFormat(fontFile)}');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
`}</style>
|
||||
)}
|
||||
<h1
|
||||
style={{
|
||||
transform: `translateY(${translateY}px)`,
|
||||
textAlign: 'center',
|
||||
color: '#FFFFFF',
|
||||
fontSize: '72px',
|
||||
fontWeight: 900,
|
||||
fontFamily: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif',
|
||||
textShadow: `
|
||||
0 0 10px rgba(0,0,0,0.9),
|
||||
0 0 20px rgba(0,0,0,0.7),
|
||||
0 4px 8px rgba(0,0,0,0.8),
|
||||
0 8px 16px rgba(0,0,0,0.5)
|
||||
`,
|
||||
color,
|
||||
fontSize: `${fontSize}px`,
|
||||
fontWeight,
|
||||
fontFamily: fontFamilyCss,
|
||||
textShadow: buildTextShadow(strokeColor, strokeSize),
|
||||
margin: 0,
|
||||
padding: '0 5%',
|
||||
lineHeight: 1.3,
|
||||
letterSpacing: '4px',
|
||||
letterSpacing: `${letterSpacing}px`,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
|
||||
@@ -10,5 +10,13 @@ export DOUYIN_RECORD_VIDEO=false
|
||||
PORT=${PORT:-8006}
|
||||
|
||||
cd "$BASE_DIR/backend"
|
||||
|
||||
# 加载 .env 文件(让 os.getenv 也能读到)
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
fi
|
||||
|
||||
exec xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" \
|
||||
./venv/bin/uvicorn app.main:app --host 0.0.0.0 --port "$PORT"
|
||||
|
||||
Reference in New Issue
Block a user