Compare commits

..

3 Commits

Author SHA1 Message Date
Kevin Wong
1a291a03b8 更新 2026-02-08 10:46:08 +08:00
Kevin Wong
1e52346eb4 更新 2026-02-07 14:29:57 +08:00
Kevin Wong
945262a7fc 更新 2026-02-06 16:02:58 +08:00
79 changed files with 5501 additions and 1613 deletions

View File

@@ -19,7 +19,6 @@
- **repositories/**数据读写Supabase不包含业务逻辑。
- **services/**外部依赖与基础能力TTS、Storage、Remotion 等)。
- **core/**:配置、安全、依赖注入、统一响应。
- **api/**:仅做 router 透传,保持 `/api/*` 路由稳定。
---
@@ -28,9 +27,8 @@
```
backend/
├── app/
│ ├── api/ # 兼容路由入口,透传到 modules
│ ├── core/ # config、deps、security、response
│ ├── modules/ # 业务模块
│ ├── modules/ # 业务模块(路由 + 逻辑)
│ │ ├── videos/
│ │ ├── materials/
│ │ ├── publish/
@@ -112,10 +110,26 @@ backend/
- `REDIS_URL`
- `GLM_API_KEY`
- `LATENTSYNC_*`
- `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)
- `CORS_ORIGINS` (CORS 白名单,默认 *)
- `SUPABASE_STORAGE_LOCAL_PATH` (本地存储路径)
- `DOUYIN_COOKIE` (抖音视频下载 Cookie)
---
## 10. 最小新增模块示例
## 10. Playwright 发布调试
- 诊断日志落盘:`backend/app/debug_screenshots/weixin_network.log` / `douyin_network.log`
- 关键失败截图:`backend/app/debug_screenshots/weixin_*.png` / `douyin_*.png`
- 视频号建议使用 headful + xvfb-run避免 headless 解码/指纹问题)
---
## 11. 最小新增模块示例
```
app/modules/foo/

View File

@@ -13,7 +13,6 @@
```
backend/
├── app/
│ ├── api/ # 兼容路由入口 (透传到 modules)
│ ├── core/ # 核心配置 (config.py, security.py, response.py)
│ ├── modules/ # 业务模块 (router/service/workflow/schemas)
│ ├── repositories/ # Supabase 数据访问
@@ -54,7 +53,9 @@ backend/
* `PUT /api/materials/{material_id}`: 重命名素材
4. **社交发布 (Publish)**
* `POST /api/publish`: 发布视频到 B站/抖音/小红书
* `POST /api/publish`: 发布视频到 抖音/微信视频号/B站/小红书
> 提示:视频号发布建议使用 headful + xvfb-run 运行后端,避免 headless 解码失败。
5. **资源库 (Assets)**
* `GET /api/assets/subtitle-styles`: 字幕样式列表
@@ -146,7 +147,7 @@ uvicorn app.main:app --host 0.0.0.0 --port 8006 --reload
1.`app/services/` 下创建新的 Service 类 (如 `NewTTSService`)。
2. 实现 `generate` 方法,可以使用 subprocess 调用,也可以是 HTTP 请求。
3. **重要**: 如果模型占用 GPU请务必使用 `asyncio.Lock` 进行并发控制,防止 OOM。
4.`app/api/` 中添加对应的路由调用
4.`app/modules/` 下创建对应模块,添加 router/service/schemas并在 `main.py` 注册路由
### 添加定时任务

View File

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

View File

@@ -107,3 +107,62 @@
- `frontend/src/components/VideoPreviewModal.tsx`
- `frontend/src/features/home/ui/PreviewPanel.tsx`
- `frontend/src/features/publish/ui/PublishPage.tsx`
---
## 📹 微信视频号发布接入 (16:30)
### 内容
- 新增视频号上传器 `WeixinUploader`,打通上传/标题/简介/标签/发布流程
- 视频号扫码登录配置完善iframe 扫码、候选二维码过滤)
- 发布平台与路由接入视频号
- 中文错误提示 + 关键节点截图保存到 `debug_screenshots`
### 涉及文件
- `backend/app/services/uploader/weixin_uploader.py`
- `backend/app/services/qr_login_service.py`
- `backend/app/services/publish_service.py`
- `backend/app/modules/publish/router.py`
- `backend/app/modules/login_helper/router.py`
---
## 🧪 视频号上传稳定性修复 (17:40)
### 内容
- 统一浏览器指纹UA/locale/timezone并支持系统 Chrome
- 增加 headful + xvfb-run 运行方案,避免 headless 检测与解码失败
- 强制 SwiftShader修复 WebGL context loss
- 上传前转码为兼容 MP4H.264 + AAC + faststart
- 增强上传状态判断与调试日志 `weixin_network.log`
### 涉及文件
- `backend/app/core/config.py`
- `backend/app/services/uploader/weixin_uploader.py`
- `backend/app/services/qr_login_service.py`
- `run_backend.sh`
---
## 🧾 发布诊断增强 (18:10)
### 内容
- 抖音发布新增网络日志与失败截图,便于定位上传/发布失败
- 视频号上传失败截图与网络日志落盘
### 涉及文件
- `backend/app/services/uploader/douyin_uploader.py`
- `backend/app/services/uploader/weixin_uploader.py`
- `backend/app/debug_screenshots/*`
---
## 🧩 发布页交互调整 (18:20)
### 内容
- 未选择平台时禁用发布按钮
- 移除定时发布 UI/参数,仅保留立即发布
### 涉及文件
- `frontend/src/features/publish/ui/PublishPage.tsx`
- `frontend/src/features/publish/model/usePublishController.ts`

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

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

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

@@ -0,0 +1,90 @@
## 🔧 代码质量与安全优化 (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: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] **REF-1**: 持久化逻辑全站收敛
- *优化*: 清理 `useBgm`, `useGeneratedVideos`, `useTitleSubtitleStyles` 中的冗余 `localStorage` 读取。
- *优化*: 修复 `useMaterials` 中的闭包陷阱(使用函数式更新),防止覆盖已恢复的状态。

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

@@ -0,0 +1,92 @@
## 🐛 缺陷修复:视频生成与持久化回归 (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 # 前端持久化修复
```

View File

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

View File

@@ -15,7 +15,7 @@ frontend/src/
│ └── ...
├── lib/ # 公共工具函数
│ ├── axios.ts # Axios 实例(含 401/403 拦截器)
│ ├── auth.ts # 认证相关函数
│ ├── auth.ts # 认证相关函数(统一使用 axios
│ └── media.ts # API Base / URL / 日期等通用工具
└── proxy.ts # 路由代理(原 middleware
```
@@ -233,6 +233,12 @@ import { formatDate } from '@/shared/lib/media';
- `features/*/ui`:功能 UI 组件
- `shared/`:通用工具、通用 hooks、API 实例
## 类型定义规范
- 通用实体类型(如 User, Account, Video统一放置在 `src/shared/types/`
- 特定业务类型放在 feature 目录下的 types.ts 或 model 中。
- **禁止**在多个地方重复定义 User 接口,统一引用 `import { User } from '@/shared/types/user';`
---
## 用户偏好持久化
@@ -250,6 +256,7 @@ import { formatDate } from '@/shared/lib/media';
- **恢复先于保存**:恢复完成前禁止写入(`isRestored` 保护)。
- 避免默认值覆盖用户选择(优先读取已保存值)。
- 优先使用 `useHomePersistence` 集中管理恢复/保存,页面内避免分散的 localStorage 读写。
- **禁止使用签名 URL 作为持久化标识**Supabase Storage 签名 URL 每次请求都变化,必须使用后端返回的稳定 `id` 字段。
- 如需新增持久化字段,必须加入恢复与保存逻辑,并更新本节。
---
@@ -264,6 +271,14 @@ import { formatDate } from '@/shared/lib/media';
---
## 发布页交互规则
- 发布按钮在未选择任何平台时禁用
- 仅保留"立即发布",不再提供定时发布 UI/参数
- **作品选择持久化**:使用 `video.id`(稳定标识)而非 `video.path`(签名 URL进行选择、比较和 localStorage 存储。发布时根据 `id` 查找对应 `path` 发送请求。
---
## 新增页面 Checklist
1. [ ] 导入 `import api from '@/shared/api/axios'`

View File

@@ -17,6 +17,7 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
- **作品预览**: 生成完成后直接播放下载(作品预览 + 历史作品)。
- **预览优化**: 预览视频 `metadata` 预取,首帧加载更快。
- **本地保存**: 文案/标题/偏好由 `useHomePersistence` 统一持久化,刷新后恢复 (Day 14/17)。
- **选择持久化**: 首页/发布页作品选择均使用稳定 `id` 持久化,刷新保持用户选择;新视频生成后自动选中最新 (Day 21)。
### 2. 全自动发布 (`/publish`) [Day 7 新增]
- **多平台管理**: 统一管理抖音、微信视频号、B站、小红书账号状态。
@@ -26,8 +27,9 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
- Cookie 自动保存与状态同步。
- **发布配置**: 设置视频标题、标签、简介。
- **作品选择**: 卡片列表 + 搜索 + 预览弹窗。
- **选择持久化**: 使用稳定 `video.id` 持久化选择,刷新保持;新视频生成自动选中最新 (Day 21)。
- **预览兼容**: 签名 URL / 相对路径均可直接预览。
- **定时任务**: 支持 "立即发布" 或 "定时发布"
- **发布方式**: 支持 "立即发布"。
### 3. 声音克隆 [Day 13 新增]
- **TTS 模式选择**: EdgeTTS (预设音色) / 声音克隆 (自定义音色) 切换。

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
# ViGent2 开发任务清单 (Task Log)
**项目**: ViGent2 数字人口播视频生成系统
**进度**: 100% (Day 18 - 后端模块化与规范完善)
**更新时间**: 2026-02-05
**项目**: ViGent2 数字人口播视频生成系统
**进度**: 100% (Day 21 - 缺陷修复与持久化回归治理)
**更新时间**: 2026-02-08
---
@@ -10,42 +10,66 @@
> 这里记录了每一天的核心开发内容与 milestone。
### Day 18: 后端模块化与规范完善 (Current) 🚀
- [x] **模块化迁移**: 路由透传 `modules/*`,业务逻辑集中到 service/workflow
- [x] **视频生成拆分**: 生成流程下沉 workflow任务状态统一 TaskStore
- [x] **Redis 任务存储**: Redis 优先,不可用自动回退内存
- [x] **仓储层抽离**: Supabase 访问统一 `repositories/*`deps/auth/admin 全面替换
- [x] **响应规范**: 统一 `success/message/data/code` + 全局异常处理。
- [x] **素材重命名**: 新增重命名接口与 Storage `move_file`
- [x] **平台顺序调整**: 抖音/微信视频号/B站/小红书,移除快手
- [x] **后端开发规范**: 新增 `BACKEND_DEV.md`README 同步模块化结构
- [x] **发布管理体验**: 首页预取路由 + 发布页骨架与缓存,进入更快
- [x] **素材加载优化**: 素材列表并发签名 URL骨架数量动态
- [x] **预览加载优化**: `preload="metadata"` + hover 预取
### Day 17: 前端重构与体验优化
- [x] **UI 组件拆分**: 首页拆分为独立组件,降低 `page.tsx` 复杂度。
- [x] **轻量 FSD 迁移**: `app` 页面轻量化,逻辑集中到 `features/*/model`,通用能力下沉 `shared/*`
- [x] **Controller Hooks**: Home/Publish 页面逻辑集中到 Controller HookPage 仅组合渲染
- [x] **通用工具抽取**: `media.ts` 统一 API Base / URL / 日期格式化
- [x] **交互优化**: 选择项持久化、列表内定位、刷新回顶部、最新作品优先预览
- [x] **发布页改造**: 作品列表卡片化 + 搜索 + 预览弹窗
- [x] **预览体验**: 预览弹窗统一头部样式与提示文案
- [x] **预览一致性**: 标题/字幕预览按素材分辨率缩放
- [x] **标题同步与限制**: 片头标题同步发布标题,输入法合成态兼容,限制 15 字
- [x] **样式默认与持久化**: 默认样式与字号调整,刷新保留用户选择。
- [x] **性能微优化**: 列表渲染优化 + 并行请求 + localStorage 防抖。
- [x] **资源能力**: 字体/BGM 资源库 + `/api/assets` 接入
- [x] **音频与字幕修复**: BGM 混音稳定性与字幕断句优化
- [x] **持久化修复**: 接入 `useHomePersistence`,恢复 `isRestored` 逻辑并通过构建
- [x] **预览与选择修复**: 发布预览兼容签名 URL音频试听路径解析素材/BGM 回退有效项
- [x] **体验细节优化**: 录音预览 URL 回收,预览弹窗滚动恢复,全局任务提示挂载
### Day 16: 深度性能优化
- [x] **Qwen-TTS 加速**: 集成 Flash Attention 2模型加载速度提升至 8.9s
- [x] **服务守护**: 开发 `Watchdog` 看门狗机制,自动监控并重启僵死服务
- [x] **LatentSync 性能确认**: 验证 DeepCache + 原生 Flash Attn 生效
- [x] **文档重构**: 全面更新 README、部署手册及后端文档
### Day 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 HookPage 仅组合渲染。
- [x] **通用工具抽取**: `media.ts` 统一 API Base / URL / 日期格式化。
- [x] **交互优化**: 选择项持久化、列表内定位、刷新回顶部、最新作品优先预览。
- [x] **发布页改造**: 作品列表卡片化 + 搜索 + 预览弹窗。
- [x] **预览体验**: 预览弹窗统一头部样式与提示文案。
- [x] **预览一致性**: 标题/字幕预览按素材分辨率缩放。
- [x] **标题同步与限制**: 片头标题同步发布标题,输入法合成态兼容,限制 15 字。
- [x] **样式默认与持久化**: 默认样式与字号调整,刷新保留用户选择。
- [x] **性能微优化**: 列表渲染优化 + 并行请求 + localStorage 防抖。
- [x] **资源能力**: 字体/BGM 资源库 + `/api/assets` 接入。
- [x] **音频与字幕修复**: BGM 混音稳定性与字幕断句优化。
- [x] **持久化修复**: 接入 `useHomePersistence`,恢复 `isRestored` 逻辑并通过构建。
- [x] **预览与选择修复**: 发布预览兼容签名 URL音频试听路径解析素材/BGM 回退有效项。
- [x] **体验细节优化**: 录音预览 URL 回收,预览弹窗滚动恢复,全局任务提示挂载。
### Day 16: 深度性能优化
- [x] **Qwen-TTS 加速**: 集成 Flash Attention 2模型加载速度提升至 8.9s。
- [x] **服务守护**: 开发 `Watchdog` 看门狗机制,自动监控并重启僵死服务。
- [x] **LatentSync 性能确认**: 验证 DeepCache + 原生 Flash Attn 生效。
- [x] **文档重构**: 全面更新 README、部署手册及后端文档。
### Day 15: 手机号认证迁移
- [x] **认证系统升级**: 从邮箱迁移至 11 位手机号注册/登录。
@@ -95,6 +119,7 @@
### 🔴 优先待办
- [ ] **批量生成架构**: 支持 Excel 导入,批量生产视频。
- [ ] **定时任务后台化**: 迁移前端触发的定时发布到后端 APScheduler。
- [ ] **发布任务恢复机制**: 发布任务化 + 状态持久化 + 前端断点恢复,解决刷新后状态丢失。
### 🔵 长期探索
- [ ] **容器化交付**: 提供完整的 Docker Compose 一键部署包。
@@ -110,7 +135,7 @@
| **Web UI** | 100% | ✅ 稳定 (移动端适配) |
| **唇形同步** | 100% | ✅ LatentSync 1.6 |
| **TTS 配音** | 100% | ✅ EdgeTTS + Qwen3 |
| **自动发布** | 100% | ✅ B站/抖音/小红书 |
| **自动发布** | 100% | ✅ 抖音/微信视频号/B站/小红书 |
| **用户认证** | 100% | ✅ 手机号 + JWT |
| **部署运维** | 100% | ✅ PM2 + Watchdog |
@@ -118,5 +143,5 @@
## 📎 相关文档
- [详细开发日志 (DevLogs)](file:///d:/CodingProjects/Antigravity/ViGent2/Docs/DevLogs/)
- [部署手册 (DEPLOY_MANUAL)](file:///d:/CodingProjects/Antigravity/ViGent2/Docs/DEPLOY_MANUAL.md)
- [详细开发日志 (DevLogs)](Docs/DevLogs/)
- [部署手册 (DEPLOY_MANUAL)](Docs/DEPLOY_MANUAL.md)

View File

@@ -26,10 +26,12 @@
- 🤖 **AI 辅助创作** - 内置 GLM-4.7-Flash支持 B站/抖音链接文案提取、AI 洗稿、标题/标签自动生成。
### 平台化功能
- 📱 **全自动发布** - 支持抖音/B站/小红书定时发布,微信视频号预留配置;扫码登录 + Cookie 持久化。
- 📱 **全自动发布** - 支持抖音/微信视频号/B站/小红书立即发布;扫码登录 + Cookie 持久化。
- 🖥️ **发布管理预览** - 支持签名 URL / 相对路径作品预览,确保可直接播放。
- 📸 **发布结果可视化** - 抖音/微信视频号发布成功后返回截图,发布页结果卡片可直接查看。
- 🛡️ **发布防误操作** - 发布进行中自动提示“请勿刷新或关闭网页”,并拦截刷新/关页二次确认。
- 🔐 **认证与隔离** - 基于 Supabase 的用户隔离,支持手机号注册/登录、密码管理。
- 🛡️ **服务守护** - 内置 Watchdog 看门狗机制,自动监控并重启僵死服务,确保 7x24h 稳定运行。
- 🛡️ **服务守护** - 内置 Watchdog 看门狗机制,自动监控并重启僵死服务,确保 7x24h 稳定运行。
- 🚀 **性能优化** - 视频预压缩、模型常驻服务(近实时加载)、双 GPU 流水线并发。
---

View File

@@ -15,7 +15,6 @@ DEFAULT_TTS_VOICE=zh-CN-YunxiNeural
# GPU 选择 (0=第一块GPU, 1=第二块GPU)
LATENTSYNC_GPU_ID=1
# 使用本地模式 (true) 或远程 API (false)
# 使用本地模式 (true) 或远程 API (false)
LATENTSYNC_LOCAL=true
@@ -66,3 +65,11 @@ ADMIN_PASSWORD=lam1988324
# 智谱 GLM API 配置 (用于生成标题和标签)
GLM_API_KEY=32440cd3f3444d1f8fe721304acea8bd.YXNLrk7eIJMKcg4t
GLM_MODEL=glm-4.7-flash
# =============== Supabase Storage 本地路径 ===============
# 确保存储卷映射正确,避免硬编码路径
SUPABASE_STORAGE_LOCAL_PATH=/home/rongye/ProgramFiles/Supabase/volumes/storage/stub/stub
# =============== 抖音视频下载 Cookie ===============
# 用于从抖音 URL 提取视频文案功能,会过期需要定期更新
DOUYIN_COOKIE=douyin.com; device_web_cpu_core=10; device_web_memory_size=8; __ac_nonce=06760391f00b9b51264ae; __ac_signature=_02B4Z6wo00f019a5ceAAAIDAhEZR-X3jjWfWmXVAAJLXd4; ttwid=1%7C7MTKBSMsP4eOv9h5NAh8p0E-NYIud09ftNmB0mjLpWc%7C1734359327%7C8794abeabbd47447e1f56e5abc726be089f2a0344d6343b5f75f23e7b0f0028f; UIFID_TEMP=0de8750d2b188f4235dbfd208e44abbb976428f0720eb983255afefa45d39c0c6532e1d4768dd8587bf919f866ff1396912bcb2af71efee56a14a2a9f37b74010d0a0413795262f6d4afe02a032ac7ab; s_v_web_id=verify_m4r4ribr_c7krmY1z_WoeI_43po_ATpO_I4o8U1bex2D7; hevc_supported=true; home_can_add_dy_2_desktop=%220%22; dy_swidth=2560; dy_sheight=1440; stream_recommend_feed_params=%22%7B%5C%22cookie_enabled%5C%22%3Atrue%2C%5C%22screen_width%5C%22%3A2560%2C%5C%22screen_height%5C%22%3A1440%2C%5C%22browser_online%5C%22%3Atrue%2C%5C%22cpu_core_num%5C%22%3A10%2C%5C%22device_memory%5C%22%3A8%2C%5C%22downlink%5C%22%3A10%2C%5C%22effective_type%5C%22%3A%5C%224g%5C%22%2C%5C%22round_trip_time%5C%22%3A50%7D%22; strategyABtestKey=%221734359328.577%22; csrf_session_id=2f53aed9aa6974e83aa9a1014180c3a4; fpk1=U2FsdGVkX1/IpBh0qdmlKAVhGyYHgur4/VtL9AReZoeSxadXn4juKvsakahRGqjxOPytHWspYoBogyhS/V6QSw==; fpk2=0845b309c7b9b957afd9ecf775a4c21f; passport_csrf_token=d80e0c5b2fa2328219856be5ba7e671e; passport_csrf_token_default=d80e0c5b2fa2328219856be5ba7e671e; odin_tt=3c891091d2eb0f4718c1d5645bc4a0017032d4d5aa989decb729e9da2ad570918cbe5e9133dc6b145fa8c758de98efe32ff1f81aa0d611e838cc73ab08ef7d3f6adf66ab4d10e8372ddd628f94f16b8e; volume_info=%7B%22isUserMute%22%3Afalse%2C%22isMute%22%3Afalse%2C%22volume%22%3A0.5%7D; bd_ticket_guard_client_web_domain=2; FORCE_LOGIN=%7B%22videoConsumedRemainSeconds%22%3A180%7D; UIFID=0de8750d2b188f4235dbfd208e44abbb976428f0720eb983255afefa45d39c0c6532e1d4768dd8587bf919f866ff139655a3c2b735923234f371c699560c657923fd3d6c5b63ab7bb9b83423b6cb4787e2ce66a7fbc4ecb24c8570f520fe6de068bbb95115023c0c6c1b6ee31b49fb7e3996fb8349f43a3fd8b7a61cd9e18e8fe65eb6a7c13de4c0960d84e344b644725db3eb2fa6b7caf821de1b50527979f2; is_dash_user=1; biz_trace_id=b57a241f; bd_ticket_guard_client_data=eyJiZC10aWNrZXQtZ3VhcmQtdmVyc2lvbiI6MiwiYmQtdGlja2V0LWd1YXJkLWl0ZXJhdGlvbi12ZXJzaW9uIjoxLCJiZC10aWNrZXQtZ3VhcmQtcmVlLXB1YmxpYy1rZXkiOiJCTEo2R0lDalVoWW1XcHpGOFdrN0Vrc0dXcCtaUzNKY1g4NGNGY2k0TTl1TEowNjdUb21mbFU5aDdvWVBGamhNRWNRQWtKdnN1MnM3RmpTWnlJQXpHMjA9IiwiYmQtdGlja2V0LWd1YXJkLXdlYi12ZXJzaW9uIjoyfQ%3D%3D; download_guide=%221%2F20241216%2F0%22; sdk_source_info=7e276470716a68645a606960273f276364697660272927676c715a6d6069756077273f276364697660272927666d776a68605a607d71606b766c6a6b5a7666776c7571273f275e58272927666a6b766a69605a696c6061273f27636469766027292762696a6764695a7364776c6467696076273f275e5827292771273f273d33323131333c3036313632342778; bit_env=RiOY4jzzpxZoVCl6zdVSVhVRjdwHRTxqcqWdqMBZLPGjMdB4Tax1kAELHNTVAAh72KuhumewE4Lq6f0-VJ2UpJrkrhSxoPw9LUb3zQrq1OSwbeSPHkRlRgRQvO89sItdGUyq1oFr0XyRCnMYG87KSeWyc4x0czGR0o50hTDoDLG5rJVoRcdQOLvjiAegsqyytKF59sPX_QM9qffK2SqYsg0hCggURc_AI6kguDDE5DvG0bnyz1utw4z1eEnIoLrkGDqzqBZj4dOAr0BVU6ofbsS-pOQ2u2PM1dLP9FlBVBlVaqYVgHJeSLsR5k76BRTddUjTb4zEilVIEwAMJWGN4I1BxVt6fC9B5tBQpuT0lj3n3eKXCKXZsd8FrEs5_pbfDsxV-e_WMiXI2ff4qxiTC0U73sfo9OpicKICtZjdq8qsHxJuu6wVR36zvXeL2Wch5C6MzprNvkivv0l8nbh2mSgy1nabZr3dmU6NcR-Bg3Q3xTWUlR9aAUmpopC-cNuXjgLpT-Lw1AYGilSUnCvosth1Gfypq-b0MpgmdSDgTrQ%3D; gulu_source_res=eyJwX2luIjoiMDhjOGQ3ZTJiODQyNjZkZWI5Y2VkMGJiODNlNmY1ZWY0ZjMyNTE2ZmYyZjAzNDMzZjI0OWU1Y2Q1NTczNTk5NyJ9; passport_auth_mix_state=hp9bc3dgb1tm5wd8p82zawus27g0e3ue; IsDouyinActive=false

View File

@@ -1,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

View File

@@ -1 +0,0 @@
from app.modules.admin.router import router

View File

@@ -1 +0,0 @@
from app.modules.ai.router import router

View File

@@ -1 +0,0 @@
from app.modules.assets.router import router

View File

@@ -1 +0,0 @@
from app.modules.auth.router import router

View File

@@ -1 +0,0 @@
from app.modules.login_helper.router import router

View File

@@ -1 +0,0 @@
from app.modules.materials.router import router

View File

@@ -1 +0,0 @@
from app.modules.publish.router import router

View File

@@ -1 +0,0 @@
from app.modules.ref_audios.router import router

View File

@@ -1 +0,0 @@
from app.modules.tools.router import router

View File

@@ -1 +0,0 @@
from app.modules.videos.router import router

View File

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

View File

@@ -1,10 +1,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:

View File

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

View File

@@ -1,13 +1,17 @@
"""
发布管理 API (支持用户认证)
"""
from fastapi import APIRouter, HTTPException, BackgroundTasks, Depends, Request
from pydantic import BaseModel
from typing import List, Optional
from datetime import datetime
from fastapi import APIRouter, HTTPException, BackgroundTasks, Depends, Request
from fastapi.responses import FileResponse
from pydantic import BaseModel
from typing import List, Optional
from datetime import datetime
import re
from loguru import logger
from app.services.publish_service import PublishService
from app.core.response import success_response
from app.core.config import settings
from app.core.deps import get_current_user
router = APIRouter()
publish_service = PublishService()
@@ -29,7 +33,7 @@ class PublishResponse(BaseModel):
url: Optional[str] = None
# Supported platforms for validation
SUPPORTED_PLATFORMS = {"bilibili", "douyin", "xiaohongshu"}
SUPPORTED_PLATFORMS = {"bilibili", "douyin", "xiaohongshu", "weixin"}
def _get_user_id(request: Request) -> Optional[str]:
@@ -118,7 +122,7 @@ async def get_login_status(platform: str, req: Request):
message = result.get("message", "")
return success_response(result, message=message)
@router.post("/cookies/save/{platform}")
@router.post("/cookies/save/{platform}")
async def save_platform_cookie(platform: str, cookie_data: dict, req: Request):
"""
保存从客户端浏览器提取的Cookie
@@ -139,3 +143,23 @@ async def save_platform_cookie(platform: str, cookie_data: dict, req: Request):
message = result.get("message", "")
return success_response(result, message=message)
@router.get("/screenshot/{filename}")
async def get_publish_screenshot(
filename: str,
current_user: dict = Depends(get_current_user),
):
if not re.match(r"^[A-Za-z0-9_.-]+$", filename):
raise HTTPException(status_code=400, detail="非法文件名")
user_id = str(current_user.get("id") or "")
if not user_id:
raise HTTPException(status_code=401, detail="未登录")
user_dir = re.sub(r"[^A-Za-z0-9_-]", "_", user_id)[:64] or "legacy"
file_path = settings.PUBLISH_SCREENSHOT_DIR / user_dir / filename
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail="截图不存在")
return FileResponse(path=str(file_path), media_type="image/png")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,7 +17,8 @@ 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.xiaohongshu_uploader import XiaohongshuUploader
from .uploader.weixin_uploader import WeixinUploader
class PublishService:
@@ -26,7 +27,7 @@ class PublishService:
# 支持的平台配置
PLATFORMS: Dict[str, Dict[str, Any]] = {
"douyin": {"name": "抖音", "url": "https://creator.douyin.com/", "enabled": True},
"weixin": {"name": "微信视频号", "url": "https://channels.weixin.qq.com/", "enabled": False},
"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},
}
@@ -174,25 +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
)
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
)
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,

View File

@@ -2,23 +2,25 @@
QR码自动登录服务
后端Playwright无头模式获取二维码前端扫码后自动保存Cookie
"""
import asyncio
import asyncio
import time
import base64
import json
from pathlib import Path
from typing import Optional, Dict, Any, List
from playwright.async_api import async_playwright, Page, BrowserContext, Browser, Playwright as PW
from loguru import logger
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:
class QRLoginService:
"""QR码登录服务"""
# 登录监控超时 (秒)
LOGIN_TIMEOUT = 120
def __init__(self, platform: str, cookies_dir: Path) -> None:
self.platform = platform
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
@@ -30,7 +32,7 @@ class QRLoginService:
self.context: Optional[BrowserContext] = None
# 每个平台使用多个选择器 (使用逗号分隔Playwright会同时等待它们)
self.platform_configs = {
self.platform_configs = {
"bilibili": {
"url": "https://passport.bilibili.com/login",
"qr_selectors": [
@@ -52,17 +54,110 @@ class QRLoginService:
],
"success_indicator": "https://creator.douyin.com/creator-micro"
},
"xiaohongshu": {
"url": "https://creator.xiaohongshu.com/",
"qr_selectors": [
".qrcode img",
"img[alt*='二维码']",
"canvas.qr-code",
"img[class*='qr']"
],
"success_indicator": "https://creator.xiaohongshu.com/publish"
}
}
"xiaohongshu": {
"url": "https://creator.xiaohongshu.com/",
"qr_selectors": [
".qrcode img",
"img[alt*='二维码']",
"canvas.qr-code",
"img[class*='qr']"
],
"success_indicator": "https://creator.xiaohongshu.com/publish"
},
"weixin": {
"url": "https://channels.weixin.qq.com/platform/",
"qr_selectors": [
"div[class*='qrcode'] img",
"img[alt*='二维码']",
"img[src*='qr']",
"canvas",
"svg",
"img[class*='qr']"
],
"success_indicator": "https://channels.weixin.qq.com/platform"
}
}
def _resolve_headless_mode(self) -> str:
if self.platform != "weixin":
return "headless"
mode = (settings.WEIXIN_HEADLESS_MODE or "").strip().lower()
return mode or "headful"
def _is_square_bbox(self, bbox: Optional[Dict[str, float]], min_side: int = 100) -> bool:
if not bbox:
return False
width = bbox.get("width", 0)
height = bbox.get("height", 0)
if width < min_side or height < min_side:
return False
if height == 0:
return False
ratio = width / height
return 0.75 <= ratio <= 1.33
async def _pick_best_candidate(self, locator, min_side: int = 100):
best = None
best_area = 0
try:
count = await locator.count()
except Exception:
return None
for i in range(count):
try:
candidate = locator.nth(i)
if not await candidate.is_visible():
continue
bbox = await candidate.bounding_box()
if not self._is_square_bbox(bbox, min_side=min_side):
continue
area = bbox["width"] * bbox["height"]
if area > best_area:
best = candidate
best_area = area
except Exception:
continue
return best
async def _find_qr_in_frames(self, page: Page, selectors: List[str], min_side: int):
combined_selector = ", ".join(selectors)
for frame in page.frames:
if frame == page.main_frame:
continue
try:
locator = frame.locator(combined_selector)
candidate = await self._pick_best_candidate(locator, min_side=min_side)
if candidate:
return candidate
except Exception:
continue
return None
async def _scan_qr_candidates(self, page: Page, selectors: List[str], min_side: int):
combined_selector = ", ".join(selectors)
try:
locator = page.locator(combined_selector)
candidate = await self._pick_best_candidate(locator, min_side=min_side)
if candidate:
return candidate
except Exception:
pass
return await self._find_qr_in_frames(page, selectors, min_side=min_side)
async def _try_text_strategy_in_frames(self, page: Page):
for frame in page.frames:
if frame == page.main_frame:
continue
try:
candidate = await self._try_text_strategy(frame)
if candidate:
return candidate
except Exception:
continue
return None
async def start_login(self) -> Dict[str, Any]:
"""
@@ -77,26 +172,45 @@ class QRLoginService:
config = self.platform_configs[self.platform]
try:
# 1. 启动 Playwright (不使用 async with手动管理生命周期)
self.playwright = await async_playwright().start()
# 1. 启动 Playwright (不使用 async with手动管理生命周期)
self.playwright = await async_playwright().start()
mode = self._resolve_headless_mode()
headless = mode not in ("headful", "false", "0", "no")
launch_args = [
'--disable-blink-features=AutomationControlled',
'--no-sandbox',
'--disable-dev-shm-usage'
]
if headless and mode in ("new", "headless-new", "headless_new"):
launch_args.append("--headless=new")
# Stealth模式启动浏览器
launch_options: Dict[str, Any] = {
"headless": headless,
"args": launch_args,
}
if 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
self.browser = await self.playwright.chromium.launch(**launch_options)
# Stealth模式启动浏览器
self.browser = await self.playwright.chromium.launch(
headless=True,
args=[
'--disable-blink-features=AutomationControlled',
'--no-sandbox',
'--disable-dev-shm-usage'
]
)
# 配置真实浏览器特征
self.context = await self.browser.new_context(
viewport={'width': 1920, 'height': 1080},
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'
)
# 配置真实浏览器特征
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
)
page = await self.context.new_page()
@@ -106,14 +220,26 @@ class QRLoginService:
await page.add_init_script(path=str(stealth_path))
logger.debug(f"[{self.platform}] Stealth模式已启用")
logger.info(f"[{self.platform}] 打开登录页...")
await page.goto(config["url"], wait_until='networkidle')
# 等待页面加载 (缩短等待)
await asyncio.sleep(2)
# 提取二维码 (并行策略)
qr_image = await self._extract_qr_code(page, config["qr_selectors"])
urls_to_try = [config["url"]]
if self.platform == "weixin":
urls_to_try = [
"https://channels.weixin.qq.com/platform/",
"https://channels.weixin.qq.com/",
]
qr_image = None
for url in urls_to_try:
logger.info(f"[{self.platform}] 打开登录页: {url}")
wait_until = "domcontentloaded" if self.platform == "weixin" else "networkidle"
await page.goto(url, wait_until=wait_until)
# 等待页面加载
await asyncio.sleep(1 if self.platform == "weixin" else 2)
# 提取二维码 (并行策略)
qr_image = await self._extract_qr_code(page, config["qr_selectors"])
if qr_image:
break
if not qr_image:
await self._cleanup()
@@ -180,21 +306,35 @@ class QRLoginService:
if qr_element:
break
else:
# 其他平台 (小红书等):保持原顺序 CSS -> Text
# 策略1: CSS 选择器
try:
combined_selector = ", ".join(selectors)
logger.debug(f"[{self.platform}] 策略1(CSS): 开始等待...")
el = await page.wait_for_selector(combined_selector, state="visible", timeout=5000)
if el:
logger.info(f"[{self.platform}] 策略1(CSS): 匹配成功")
qr_element = el
except Exception as e:
logger.warning(f"[{self.platform}] 策略1(CSS) 失败: {e}")
# 其他平台 (小红书/微信等):保持原顺序 CSS -> Text
# 策略1: CSS 选择器
try:
combined_selector = ", ".join(selectors)
logger.debug(f"[{self.platform}] 策略1(CSS): 开始等待...")
if self.platform == "weixin":
min_side = 120
start_time = time.monotonic()
while time.monotonic() - start_time < 12:
qr_element = await self._scan_qr_candidates(page, selectors, min_side=min_side)
if qr_element:
logger.info(f"[{self.platform}] 策略1(CSS): 匹配成功")
break
await asyncio.sleep(0.5)
else:
await page.wait_for_selector(combined_selector, state="visible", timeout=5000)
locator = page.locator(combined_selector)
qr_element = await self._pick_best_candidate(locator, min_side=100)
if qr_element:
logger.info(f"[{self.platform}] 策略1(CSS): 匹配成功")
except Exception as e:
logger.warning(f"[{self.platform}] 策略1(CSS) 失败: {e}")
# 策略2: Text
if not qr_element:
qr_element = await self._try_text_strategy(page)
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:
@@ -214,11 +354,20 @@ class QRLoginService:
return None
async def _try_text_strategy(self, page: Page) -> Optional[Any]:
async def _try_text_strategy(self, page: Union[Page, Frame]) -> Optional[Any]:
"""基于文本查找二维码图片"""
try:
logger.debug(f"[{self.platform}] 策略Text: 开始搜索...")
keywords = ["扫码登录", "二维码", "打开抖音", "抖音APP", "使用APP扫码"]
keywords = [
"扫码登录",
"二维码",
"打开抖音",
"抖音APP",
"使用APP扫码",
"微信扫码",
"请使用微信扫码",
"视频号"
]
for kw in keywords:
try:
@@ -229,15 +378,12 @@ class QRLoginService:
parent = text_el
for _ in range(5):
parent = parent.locator("..")
imgs = parent.locator("img")
for i in range(await imgs.count()):
img = imgs.nth(i)
if await img.is_visible():
bbox = await img.bounding_box()
if bbox and bbox['width'] > 100:
logger.info(f"[{self.platform}] 策略Text: 成功")
return img
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:
@@ -248,8 +394,23 @@ class QRLoginService:
"""监控登录状态"""
try:
logger.info(f"[{self.platform}] 开始监控登录状态...")
key_cookies = {"bilibili": "SESSDATA", "douyin": "sessionid", "xiaohongshu": "web_session"}
target_cookie = key_cookies.get(self.platform, "")
key_cookies = {
"bilibili": ["SESSDATA"],
"douyin": ["sessionid"],
"xiaohongshu": ["web_session"],
"weixin": [
"wxuin",
"wxsid",
"pass_ticket",
"webwx_data_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)
@@ -257,9 +418,9 @@ class QRLoginService:
try:
if not self.context: break # 避免意外关闭
cookies = await self.context.cookies()
cookies = [dict(cookie) for cookie in await self.context.cookies()]
current_url = page.url
has_cookie = any(c['name'] == target_cookie for c in cookies)
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}")
@@ -270,7 +431,7 @@ class QRLoginService:
await asyncio.sleep(2) # 缓冲
# 保存Cookie
final_cookies = await self.context.cookies()
final_cookies = [dict(cookie) for cookie in await self.context.cookies()]
await self._save_cookies(final_cookies)
break
@@ -307,14 +468,14 @@ class QRLoginService:
pass
self.playwright = None
async def _save_cookies(self, cookies: List[Dict[str, Any]]) -> 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_dict = {c['name']: c['value'] for c in cookies}
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}

View File

@@ -5,6 +5,7 @@ Remotion 视频渲染服务
import asyncio
import json
import os
import subprocess
from pathlib import Path
from typing import Optional
@@ -52,13 +53,21 @@ class RemotionService:
输出视频路径
"""
# 构建命令参数
cmd = [
"npx", "ts-node", "render.ts",
# 优先使用预编译的 JS 文件(更快),如果不存在则回退到 ts-node
compiled_js = self.remotion_dir / "dist" / "render.js"
if compiled_js.exists():
cmd = ["node", "dist/render.js"]
logger.info("Using pre-compiled render.js for faster startup")
else:
cmd = ["npx", "ts-node", "render.ts"]
logger.warning("Using ts-node (slower). Run 'npm run build:render' to compile for faster startup.")
cmd.extend([
"--video", str(video_path),
"--output", str(output_path),
"--fps", str(fps),
"--enableSubtitles", str(enable_subtitles).lower()
]
])
if captions_path:
cmd.extend(["--captions", str(captions_path)])
@@ -106,6 +115,16 @@ class RemotionService:
process.wait()
if process.returncode != 0:
# Remotion 渲染可能在完成输出后进程崩溃 (如 SIGABRT code -6)
# 如果输出文件已存在且大小合理,视为成功
output_file = Path(output_path)
if output_file.exists() and output_file.stat().st_size > 1024:
logger.warning(
f"Remotion process exited with code {process.returncode}, "
f"but output file exists ({output_file.stat().st_size} bytes). Treating as success."
)
return output_path
error_msg = "\n".join(output_lines[-20:]) # 最后 20 行
raise RuntimeError(f"Remotion render failed (code {process.returncode}):\n{error_msg}")

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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",

View File

@@ -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": {

View File

@@ -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 && (

View File

@@ -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>
);

View File

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

View File

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

View File

@@ -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);
}

View File

@@ -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">

View File

@@ -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"
>

View 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,
};
};

View File

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

View File

@@ -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);
}
}

View File

@@ -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,

View File

@@ -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]);

View File

@@ -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);
@@ -166,8 +151,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 +185,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 +211,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 +238,7 @@ export const useHomeController = () => {
refreshTitleStyles,
} = useTitleSubtitleStyles({
isAuthLoading,
storageKey,
setSelectedSubtitleStyleId,
setSelectedTitleStyleId,
});
@@ -296,7 +264,7 @@ export const useHomeController = () => {
bgmError,
fetchBgmList,
} = useBgm({
storageKey,
selectedBgmId,
setSelectedBgmId,
});
@@ -319,7 +287,7 @@ export const useHomeController = () => {
fetchGeneratedVideos,
deleteVideo,
} = useGeneratedVideos({
storageKey,
selectedVideoId,
setSelectedVideoId,
setGeneratedVideo,
@@ -347,15 +315,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({
@@ -417,8 +388,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) {
@@ -502,16 +486,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 +506,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 +586,7 @@ export const useHomeController = () => {
setRecordingTime((prev) => prev + 1);
}, 1000);
} catch (err) {
alert("无法访问麦克风,请检查权限设置");
toast.error("无法访问麦克风,请检查权限设置");
console.error(err);
}
};
@@ -631,7 +624,7 @@ export const useHomeController = () => {
// AI 生成标题和标签
const handleGenerateMeta = async () => {
if (!text.trim()) {
alert("请先输入口播文案");
toast.error("请先输入口播文案");
return;
}
@@ -649,10 +642,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 +655,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 +678,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,

View File

@@ -132,6 +132,7 @@ export const useHomePersistence = ({
if (savedEnableBgm !== null) setEnableBgm(savedEnableBgm === 'true');
if (savedSelectedVideoId) setSelectedVideoId(savedSelectedVideoId);
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsRestored(true);
}, [
isAuthLoading,

View File

@@ -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}`);
}

View File

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

View File

@@ -1,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]);

View File

@@ -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]);

View File

@@ -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">

View File

@@ -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,30 +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;
}
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 }>>(url).then((res) => unwrap(res.data));
export const usePublishController = () => {
const apiBase = getApiBaseUrl();
@@ -45,43 +32,28 @@ export const usePublishController = () => {
const [title, setTitle] = useState<string>("");
const [tags, setTags] = useState<string>("");
const [isPublishing, setIsPublishing] = useState(false);
const [publishResults, setPublishResults] = useState<any[]>([]);
const [scheduleMode, setScheduleMode] = useState<"now" | "scheduled">("now");
const [publishTime, setPublishTime] = useState<string>("");
const [publishResults, setPublishResults] = useState<PublishResult[]>([]);
const [qrCodeImage, setQrCodeImage] = useState<string | null>(null);
const [qrPlatform, setQrPlatform] = useState<string | null>(null);
const [isLoadingQR, setIsLoadingQR] = useState(false);
// 使用全局认证状态
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 {
@@ -96,21 +68,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) {
@@ -118,91 +89,133 @@ 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);
toast.success("✅ 登录成功!");
fetchAccounts();
}
} 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("登录超时,请重试");
}
}, 120000);
}
return () => clearTimeout(timer);
}, [qrPlatform]);
// ---- 操作函数 ----
const togglePlatform = (platform: string) => {
if (selectedPlatforms.includes(platform)) {
@@ -214,80 +227,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: "",
publish_time: scheduleMode === "scheduled" && publishTime
? new Date(publishTime).toISOString()
: null,
video_path: video.path, platform, title, tags: tagList, description: "",
});
const result = unwrap(res);
setPublishResults((prev) => [...prev, result]);
// 发布成功后10秒自动清除结果
if (result.success) {
setTimeout(() => {
setPublishResults((prev) => prev.filter((r) => r !== result));
}, 10000);
}
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,
}]);
} 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);
};
// 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);
@@ -295,16 +268,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);
}
@@ -315,14 +287,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}`);
}
};
@@ -339,12 +307,10 @@ 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);
};
@@ -354,40 +320,13 @@ export const usePublishController = () => {
};
return {
apiBase,
accounts,
videos,
isAccountsLoading,
isVideosLoading,
selectedVideo,
setSelectedVideo,
videoFilter,
setVideoFilter,
previewVideoUrl,
setPreviewVideoUrl,
selectedPlatforms,
title,
titleInput,
tags,
setTags,
isPublishing,
publishResults,
scheduleMode,
setScheduleMode,
publishTime,
setPublishTime,
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,
fetchAccounts, fetchVideos, togglePlatform, handlePublish,
handleLogin, handleLogout, platformIcons, filteredVideos,
handlePreviewVideo, closeQrModal,
};
};

View File

@@ -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";
@@ -9,10 +10,9 @@ import {
RotateCcw,
LogOut,
QrCode,
Rocket,
Clock,
Search,
Eye,
Loader2,
} from "lucide-react";
export function PublishPage() {
@@ -33,10 +33,6 @@ export function PublishPage() {
setTags,
isPublishing,
publishResults,
scheduleMode,
setScheduleMode,
publishTime,
setPublishTime,
qrCodeImage,
qrPlatform,
isLoadingQR,
@@ -69,10 +65,13 @@ export function PublishPage() {
</div>
) : 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">
使
@@ -151,9 +150,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"
/>
) : (
@@ -245,9 +246,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"
}`}
@@ -259,7 +260,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}`;
@@ -275,7 +276,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>
@@ -337,9 +338,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,58 +355,18 @@ export function PublishPage() {
</div>
</div>
{/* 定时发布 */}
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
</h2>
<div className="space-y-4">
<div className="flex items-center gap-3">
<button
onClick={() => setScheduleMode("now")}
className={`flex-1 p-3 rounded-xl border-2 transition-all ${scheduleMode === "now"
? "border-purple-500 bg-purple-500/20"
: "border-white/10 bg-white/5 hover:border-white/30"
}`}
>
<Rocket className="h-5 w-5 mx-auto mb-1" />
<span className="text-white text-sm"></span>
</button>
<button
onClick={() => setScheduleMode("scheduled")}
className={`flex-1 p-3 rounded-xl border-2 transition-all ${scheduleMode === "scheduled"
? "border-purple-500 bg-purple-500/20"
: "border-white/10 bg-white/5 hover:border-white/30"
}`}
>
<Clock className="h-5 w-5 mx-auto mb-1" />
<span className="text-white text-sm"></span>
</button>
</div>
{scheduleMode === "scheduled" && (
<input
type="datetime-local"
value={publishTime}
onChange={(e) => setPublishTime(e.target.value)}
className="w-full p-3 bg-black/30 border border-white/10 rounded-xl text-white"
/>
)}
</div>
</div>
{/* 发布按钮 */}
<button
onClick={handlePublish}
disabled={isPublishing || (scheduleMode === "scheduled" && !publishTime)}
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
? "正在发布..."
: scheduleMode === "scheduled"
? "定时发布"
: "立即发布"}
{isPublishing ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="h-5 w-5 animate-spin" />
...
</span>
) : "立即发布"}
</button>
{/* 发布结果 */}
@@ -418,20 +381,42 @@ 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>
</div>
<p className="text-sm text-gray-300">{result.message}</p>
{result.success && result.screenshot_url && (
<div className="mt-3 rounded-lg border border-white/10 bg-black/20 p-2">
<p className="text-xs text-gray-400 mb-2"></p>
<a
href={result.screenshot_url}
target="_blank"
rel="noreferrer"
className="block"
>
<Image
src={result.screenshot_url}
alt="发布成功截图"
width={400}
height={300}
className="w-full rounded-md border border-white/10"
unoptimized
/>
</a>
</div>
)}
</div>
))}
</div>

View File

@@ -37,7 +37,7 @@ api.interceptors.response.use(
// 调用 logout API 清除 HttpOnly cookie
try {
await fetch('/api/auth/logout', { method: 'POST' });
} catch (e) {
} catch {
// 忽略错误
}

View 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,
};
};

View File

@@ -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';
}

View 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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,14 @@
#!/bin/bash
# 启动 ViGent2 后端 (FastAPI)
cd "$(dirname "$0")/backend"
./venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8006
#!/usr/bin/env bash
set -e
BASE_DIR="$(cd "$(dirname "$0")" && pwd)"
export WEIXIN_HEADLESS_MODE=headful
export 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"