Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee342cc40f | ||
|
|
1a291a03b8 | ||
|
|
1e52346eb4 | ||
|
|
945262a7fc |
@@ -19,7 +19,6 @@
|
||||
- **repositories/**:数据读写(Supabase),不包含业务逻辑。
|
||||
- **services/**:外部依赖与基础能力(TTS、Storage、Remotion 等)。
|
||||
- **core/**:配置、安全、依赖注入、统一响应。
|
||||
- **api/**:仅做 router 透传,保持 `/api/*` 路由稳定。
|
||||
|
||||
---
|
||||
|
||||
@@ -28,18 +27,29 @@
|
||||
```
|
||||
backend/
|
||||
├── app/
|
||||
│ ├── api/ # 兼容路由入口,透传到 modules
|
||||
│ ├── core/ # config、deps、security、response
|
||||
│ ├── modules/ # 业务模块
|
||||
│ │ ├── videos/
|
||||
│ │ ├── materials/
|
||||
│ │ ├── publish/
|
||||
│ │ ├── auth/
|
||||
│ │ └── ...
|
||||
│ ├── modules/ # 业务模块(路由 + 逻辑)
|
||||
│ │ ├── videos/ # 视频生成任务
|
||||
│ │ ├── materials/ # 素材管理
|
||||
│ │ ├── publish/ # 多平台发布
|
||||
│ │ ├── auth/ # 认证与会话
|
||||
│ │ ├── ai/ # AI 功能(标题标签生成等)
|
||||
│ │ ├── assets/ # 静态资源(字体/样式/BGM)
|
||||
│ │ ├── ref_audios/ # 声音克隆参考音频
|
||||
│ │ ├── login_helper/ # 扫码登录辅助
|
||||
│ │ ├── tools/ # 工具接口
|
||||
│ │ └── admin/ # 管理员功能
|
||||
│ ├── repositories/ # Supabase 数据访问
|
||||
│ ├── services/ # 外部服务集成
|
||||
│ │ ├── uploader/ # 平台发布器(douyin/weixin)
|
||||
│ │ ├── qr_login_service.py
|
||||
│ │ ├── publish_service.py
|
||||
│ │ ├── remotion_service.py
|
||||
│ │ ├── storage.py
|
||||
│ │ └── ...
|
||||
│ └── tests/
|
||||
├── assets/ # 字体 / 样式 / bgm
|
||||
├── user_data/ # 用户隔离数据(Cookie 等)
|
||||
├── scripts/
|
||||
└── requirements.txt
|
||||
```
|
||||
@@ -86,6 +96,21 @@ backend/
|
||||
- 所有文件上传/下载/删除/移动通过 `services/storage.py`。
|
||||
- 需要重命名时使用 `move_file`,避免直接读写 Storage。
|
||||
|
||||
### Cookie 存储(用户隔离)
|
||||
|
||||
多平台扫码登录产生的 Cookie 按用户隔离存储:
|
||||
|
||||
```
|
||||
backend/user_data/{user_uuid}/cookies/
|
||||
├── douyin_cookies.json
|
||||
├── weixin_cookies.json
|
||||
└── ...
|
||||
```
|
||||
|
||||
- `publish_service.py` 中通过 `_get_cookies_dir(user_id)` / `_get_cookie_path(user_id, platform)` 定位
|
||||
- 会话 key 格式:`"{user_id}_{platform}"`,确保多用户并发登录互不干扰
|
||||
- 登录成功后 Cookie 自动保存到对应路径,发布时自动加载
|
||||
|
||||
---
|
||||
|
||||
## 7. 代码约定
|
||||
@@ -112,10 +137,35 @@ backend/
|
||||
- `REDIS_URL`
|
||||
- `GLM_API_KEY`
|
||||
- `LATENTSYNC_*`
|
||||
- `CORS_ORIGINS` (CORS 白名单,默认 *)
|
||||
|
||||
### 微信视频号
|
||||
- `WEIXIN_HEADLESS_MODE` (headful/headless-new)
|
||||
- `WEIXIN_CHROME_PATH` / `WEIXIN_BROWSER_CHANNEL`
|
||||
- `WEIXIN_USER_AGENT` / `WEIXIN_LOCALE` / `WEIXIN_TIMEZONE_ID`
|
||||
- `WEIXIN_FORCE_SWIFTSHADER`
|
||||
- `WEIXIN_TRANSCODE_MODE` (reencode/faststart/off)
|
||||
|
||||
### 抖音
|
||||
- `DOUYIN_HEADLESS_MODE` (headful/headless-new,默认 headless-new)
|
||||
- `DOUYIN_CHROME_PATH` / `DOUYIN_BROWSER_CHANNEL`
|
||||
- `DOUYIN_USER_AGENT` (默认 Chrome/144)
|
||||
- `DOUYIN_LOCALE` / `DOUYIN_TIMEZONE_ID`
|
||||
- `DOUYIN_FORCE_SWIFTSHADER`
|
||||
- `DOUYIN_DEBUG_ARTIFACTS` / `DOUYIN_RECORD_VIDEO` / `DOUYIN_KEEP_SUCCESS_VIDEO`
|
||||
- `DOUYIN_COOKIE` (抖音视频下载 Cookie)
|
||||
|
||||
---
|
||||
|
||||
## 10. 最小新增模块示例
|
||||
## 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/
|
||||
|
||||
@@ -13,14 +13,24 @@
|
||||
```
|
||||
backend/
|
||||
├── app/
|
||||
│ ├── api/ # 兼容路由入口 (透传到 modules)
|
||||
│ ├── core/ # 核心配置 (config.py, security.py, response.py)
|
||||
│ ├── modules/ # 业务模块 (router/service/workflow/schemas)
|
||||
│ │ ├── videos/ # 视频生成任务
|
||||
│ │ ├── materials/ # 素材管理
|
||||
│ │ ├── publish/ # 多平台发布
|
||||
│ │ ├── auth/ # 认证与会话
|
||||
│ │ ├── ai/ # AI 功能(标题标签生成)
|
||||
│ │ ├── assets/ # 静态资源(字体/样式/BGM)
|
||||
│ │ ├── ref_audios/ # 声音克隆参考音频
|
||||
│ │ ├── login_helper/ # 扫码登录辅助
|
||||
│ │ ├── tools/ # 工具接口(文案提取等)
|
||||
│ │ └── admin/ # 管理员功能
|
||||
│ ├── repositories/ # Supabase 数据访问
|
||||
│ ├── services/ # 外部服务集成 (TTS/Remotion/Storage 等)
|
||||
│ ├── services/ # 外部服务集成 (TTS/Remotion/Storage/Uploader 等)
|
||||
│ └── tests/ # 单元测试与集成测试
|
||||
├── scripts/ # 运维脚本 (watchdog.py, init_db.py)
|
||||
├── assets/ # 资源库 (fonts, bgm, styles)
|
||||
├── user_data/ # 用户隔离数据 (Cookie 等)
|
||||
└── requirements.txt # 依赖清单
|
||||
```
|
||||
|
||||
@@ -42,25 +52,45 @@ backend/
|
||||
|
||||
2. **视频生成 (Videos)**
|
||||
* `POST /api/videos/generate`: 提交生成任务
|
||||
* `GET /api/videos/tasks/{task_id}`: 查询任务状态
|
||||
* `GET /api/videos/tasks/{task_id}`: 查询单个任务状态
|
||||
* `GET /api/videos/tasks`: 获取用户所有任务列表
|
||||
* `GET /api/videos/generated`: 获取历史视频列表
|
||||
* `DELETE /api/videos/generated/{video_id}`: 删除历史视频
|
||||
|
||||
> **修正 (16:20)**:任务查询与历史列表接口已更新为 `/api/videos/tasks/{task_id}` 与 `/api/videos/generated`。
|
||||
|
||||
3. **素材管理 (Materials)**
|
||||
* `POST /api/materials`: 上传素材
|
||||
* `GET /api/materials`: 获取素材列表
|
||||
* `PUT /api/materials/{material_id}`: 重命名素材
|
||||
|
||||
4. **社交发布 (Publish)**
|
||||
* `POST /api/publish`: 发布视频到 B站/抖音/小红书
|
||||
* `POST /api/publish`: 发布视频到 抖音/微信视频号/B站/小红书
|
||||
* `POST /api/publish/login`: 扫码登录平台
|
||||
* `GET /api/publish/login/status`: 查询登录状态(含刷脸验证二维码)
|
||||
* `GET /api/publish/accounts`: 获取已登录账号列表
|
||||
|
||||
> 提示:视频号/抖音发布建议使用 headful + xvfb-run 运行后端。
|
||||
|
||||
5. **资源库 (Assets)**
|
||||
* `GET /api/assets/subtitle-styles`: 字幕样式列表
|
||||
* `GET /api/assets/title-styles`: 标题样式列表
|
||||
* `GET /api/assets/bgm`: 背景音乐列表
|
||||
|
||||
6. **声音克隆 (Ref Audios)**
|
||||
* `POST /api/ref-audios`: 上传参考音频 (multipart/form-data)
|
||||
* `GET /api/ref-audios`: 获取参考音频列表
|
||||
* `PUT /api/ref-audios/{id}`: 重命名参考音频
|
||||
* `DELETE /api/ref-audios/{id}`: 删除参考音频
|
||||
|
||||
7. **AI 功能 (AI)**
|
||||
* `POST /api/ai/generate-meta`: AI 生成标题和标签
|
||||
|
||||
8. **工具 (Tools)**
|
||||
* `POST /api/tools/extract-script`: 从视频链接提取文案
|
||||
|
||||
9. **健康检查**
|
||||
* `GET /api/lipsync/health`: LatentSync 服务健康状态
|
||||
* `GET /api/voiceclone/health`: Qwen3-TTS 服务健康状态
|
||||
|
||||
### 统一响应结构
|
||||
|
||||
```json
|
||||
@@ -78,10 +108,17 @@ backend/
|
||||
|
||||
`POST /api/videos/generate` 支持以下可选字段:
|
||||
|
||||
- `tts_mode`: TTS 模式 (`edgetts` / `voiceclone`)
|
||||
- `voice`: EdgeTTS 音色 ID(edgetts 模式)
|
||||
- `ref_audio_id` / `ref_text`: 参考音频 ID 与文本(voiceclone 模式)
|
||||
- `title`: 片头标题文字
|
||||
- `subtitle_style_id`: 字幕样式 ID
|
||||
- `title_style_id`: 标题样式 ID
|
||||
- `subtitle_font_size`: 字幕字号(覆盖样式默认值)
|
||||
- `title_font_size`: 标题字号(覆盖样式默认值)
|
||||
- `title_top_margin`: 标题距顶部像素
|
||||
- `subtitle_bottom_margin`: 字幕距底部像素
|
||||
- `enable_subtitles`: 是否启用字幕
|
||||
- `bgm_id`: 背景音乐 ID
|
||||
- `bgm_volume`: 背景音乐音量(0-1,默认 0.2)
|
||||
|
||||
@@ -146,7 +183,7 @@ uvicorn app.main:app --host 0.0.0.0 --port 8006 --reload
|
||||
1. 在 `app/services/` 下创建新的 Service 类 (如 `NewTTSService`)。
|
||||
2. 实现 `generate` 方法,可以使用 subprocess 调用,也可以是 HTTP 请求。
|
||||
3. **重要**: 如果模型占用 GPU,请务必使用 `asyncio.Lock` 进行并发控制,防止 OOM。
|
||||
4. 在 `app/api/` 中添加对应的路由调用。
|
||||
4. 在 `app/modules/` 下创建对应模块,添加 router/service/schemas,并在 `main.py` 注册路由。
|
||||
|
||||
### 添加定时任务
|
||||
|
||||
|
||||
@@ -28,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,18 @@ pip install -r requirements.txt
|
||||
playwright install chromium
|
||||
```
|
||||
|
||||
> 提示:视频号发布建议使用系统 Chrome + xvfb-run(避免 headless 解码失败)。
|
||||
> 抖音发布同样建议 headful 模式 (`DOUYIN_HEADLESS_MODE=headful`)。
|
||||
|
||||
### 扫码登录注意事项
|
||||
|
||||
- **Cookie 按用户隔离**:每个用户的 Cookie 存储在 `backend/user_data/{uuid}/cookies/` 目录下,多用户并发登录互不干扰。
|
||||
- **抖音 QR 登录关键教训**:
|
||||
- 扫码后绝对**不能重新加载 QR 页面**,否则会销毁会话 token
|
||||
- 使用**新标签页**检测登录完成状态(检查 URL 包含 `creator-micro` + session cookies 存在)
|
||||
- 抖音可能弹出**刷脸验证**,后端会自动提取验证二维码返回给前端展示
|
||||
- **微信视频号发布**:标题、描述、标签统一写入"视频描述"字段
|
||||
|
||||
---
|
||||
|
||||
### 可选:AI 标题/标签生成
|
||||
@@ -161,9 +188,29 @@ cp .env.example .env
|
||||
| `LATENTSYNC_GPU_ID` | 1 | GPU 选择 (0 或 1) |
|
||||
| `LATENTSYNC_USE_SERVER` | false | 设为 true 以启用常驻服务加速 |
|
||||
| `LATENTSYNC_INFERENCE_STEPS` | 20 | 推理步数 (20-50) |
|
||||
| `LATENTSYNC_GUIDANCE_SCALE` | 1.5 | 引导系数 (1.0-3.0) |
|
||||
| `DEBUG` | true | 生产环境改为 false |
|
||||
| `REDIS_URL` | `redis://localhost:6379/0` | 任务状态存储(不可用时回退内存) |
|
||||
| `LATENTSYNC_GUIDANCE_SCALE` | 1.5 | 引导系数 (1.0-3.0) |
|
||||
| `DEBUG` | true | 生产环境改为 false |
|
||||
| `REDIS_URL` | `redis://localhost:6379/0` | 任务状态存储(不可用时回退内存) |
|
||||
| `WEIXIN_HEADLESS_MODE` | headless-new | 视频号 Playwright 模式 (headful/headless-new) |
|
||||
| `WEIXIN_CHROME_PATH` | `/usr/bin/google-chrome` | 系统 Chrome 路径 |
|
||||
| `WEIXIN_BROWSER_CHANNEL` | | Chromium 通道 (可选) |
|
||||
| `WEIXIN_USER_AGENT` | Chrome 120 UA | 视频号浏览器指纹 UA |
|
||||
| `WEIXIN_LOCALE` | zh-CN | 视频号语言环境 |
|
||||
| `WEIXIN_TIMEZONE_ID` | Asia/Shanghai | 视频号时区 |
|
||||
| `WEIXIN_FORCE_SWIFTSHADER` | true | 强制软件 WebGL,避免 context lost |
|
||||
| `WEIXIN_TRANSCODE_MODE` | reencode | 上传前转码 (reencode/faststart/off) |
|
||||
| `DOUYIN_HEADLESS_MODE` | headless-new | 抖音 Playwright 模式 (headful/headless-new) |
|
||||
| `DOUYIN_CHROME_PATH` | `/usr/bin/google-chrome` | 抖音 Chrome 路径 |
|
||||
| `DOUYIN_BROWSER_CHANNEL` | | 抖音 Chromium 通道 (可选) |
|
||||
| `DOUYIN_USER_AGENT` | Chrome/144 UA | 抖音浏览器指纹 UA |
|
||||
| `DOUYIN_LOCALE` | zh-CN | 抖音语言环境 |
|
||||
| `DOUYIN_TIMEZONE_ID` | Asia/Shanghai | 抖音时区 |
|
||||
| `DOUYIN_FORCE_SWIFTSHADER` | true | 强制软件 WebGL |
|
||||
| `DOUYIN_DEBUG_ARTIFACTS` | false | 保留调试截图 |
|
||||
| `DOUYIN_RECORD_VIDEO` | false | 录制浏览器操作视频 |
|
||||
| `DOUYIN_KEEP_SUCCESS_VIDEO` | false | 成功后保留录屏 |
|
||||
| `CORS_ORIGINS` | `*` | CORS 允许源 (生产环境建议白名单) |
|
||||
| `DOUYIN_COOKIE` | 空 | 抖音视频下载 Cookie (文案提取功能) |
|
||||
|
||||
---
|
||||
|
||||
@@ -193,6 +240,12 @@ source venv/bin/activate
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8006
|
||||
```
|
||||
|
||||
推荐使用项目脚本启动后端(已内置 xvfb + headful 发布环境):
|
||||
```bash
|
||||
cd /home/rongye/ProgramFiles/ViGent2
|
||||
./run_backend.sh # 默认 8006,可用 PORT 覆盖
|
||||
```
|
||||
|
||||
### 启动前端 (终端 2)
|
||||
|
||||
```bash
|
||||
@@ -227,9 +280,19 @@ python -m scripts.server
|
||||
1. 创建启动脚本 `run_backend.sh`:
|
||||
```bash
|
||||
cat > run_backend.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
cd /home/rongye/ProgramFiles/ViGent2/backend
|
||||
./venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8006
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
BASE_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
export WEIXIN_HEADLESS_MODE=headful
|
||||
export DOUYIN_HEADLESS_MODE=headful
|
||||
export WEIXIN_DEBUG_ARTIFACTS=false
|
||||
export WEIXIN_RECORD_VIDEO=false
|
||||
export DOUYIN_DEBUG_ARTIFACTS=false
|
||||
export DOUYIN_RECORD_VIDEO=false
|
||||
PORT=${PORT:-8006}
|
||||
cd "$BASE_DIR/backend"
|
||||
exec xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" \
|
||||
./venv/bin/uvicorn app.main:app --host 0.0.0.0 --port "$PORT"
|
||||
EOF
|
||||
chmod +x run_backend.sh
|
||||
```
|
||||
|
||||
@@ -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
|
||||
- 上传前转码为兼容 MP4(H.264 + AAC + faststart)
|
||||
- 增强上传状态判断与调试日志 `weixin_network.log`
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/core/config.py`
|
||||
- `backend/app/services/uploader/weixin_uploader.py`
|
||||
- `backend/app/services/qr_login_service.py`
|
||||
- `run_backend.sh`
|
||||
|
||||
---
|
||||
|
||||
## 🧾 发布诊断增强 (18:10)
|
||||
|
||||
### 内容
|
||||
- 抖音发布新增网络日志与失败截图,便于定位上传/发布失败
|
||||
- 视频号上传失败截图与网络日志落盘
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
- `backend/app/services/uploader/weixin_uploader.py`
|
||||
- `backend/app/debug_screenshots/*`
|
||||
|
||||
---
|
||||
|
||||
## 🧩 发布页交互调整 (18:20)
|
||||
|
||||
### 内容
|
||||
- 未选择平台时禁用发布按钮
|
||||
- 移除定时发布 UI/参数,仅保留立即发布
|
||||
|
||||
### 涉及文件
|
||||
- `frontend/src/features/publish/ui/PublishPage.tsx`
|
||||
- `frontend/src/features/publish/model/usePublishController.ts`
|
||||
|
||||
485
Docs/DevLogs/Day19.md
Normal file
485
Docs/DevLogs/Day19.md
Normal file
@@ -0,0 +1,485 @@
|
||||
## 🛡️ 发布中防误刷新(15:46,合并)
|
||||
|
||||
### 内容
|
||||
- 发布按钮文案统一为:`正在发布...请勿刷新或关闭网页`
|
||||
- 发布中启用浏览器 `beforeunload` 拦截,刷新/关闭页面会触发原生二次确认
|
||||
- 适用于发布管理页全部平台(抖音 / 微信视频号 / B站 / 小红书)
|
||||
- 后续优化已登记:发布任务状态恢复机制(任务化 + 状态持久化 + 前端轮询恢复)
|
||||
|
||||
### 涉及文件
|
||||
- `frontend/src/features/publish/model/usePublishController.ts`
|
||||
- `frontend/src/features/publish/ui/PublishPage.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 🖼️ 发布成功截图稳定性优化(15:26,合并)
|
||||
|
||||
### 内容
|
||||
- 成功判定后先等待页面加载,再额外等待 `3s` 后截图,避免抓到半加载页面
|
||||
- 针对“截图里页面内容只占 1/3”问题,成功截图从 `full_page=True` 调整为视口截图 `full_page=False`
|
||||
- 视频号成功截图前额外恢复 `zoom=1.0`,避免流程缩放影响最终截图比例
|
||||
- 抖音成功截图同步应用相同策略,统一前端展示观感
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/weixin_uploader.py`
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
|
||||
---
|
||||
|
||||
## 🧪 视频号录屏 Debug 开关(15:12,已回收)
|
||||
|
||||
### 内容
|
||||
- 为视频号上传器新增 Playwright 录屏能力,开关受 `WEIXIN_DEBUG_ARTIFACTS && WEIXIN_RECORD_VIDEO` 控制
|
||||
- 新增视频号录屏配置项:
|
||||
- `WEIXIN_RECORD_VIDEO`
|
||||
- `WEIXIN_KEEP_SUCCESS_VIDEO`
|
||||
- `WEIXIN_RECORD_VIDEO_WIDTH`
|
||||
- `WEIXIN_RECORD_VIDEO_HEIGHT`
|
||||
- 上传流程在 `finally` 中统一保存录屏,失败必保留;成功录屏默认按开关清理
|
||||
- 排障阶段临时开启过视频号 debug/录屏;当前已回收为默认关闭(`run_backend.sh` 设为 `false`)
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/weixin_uploader.py`
|
||||
- `backend/app/core/config.py`
|
||||
- `run_backend.sh`
|
||||
- `Docs/DEPLOY_MANUAL.md`
|
||||
|
||||
---
|
||||
|
||||
## 🔁 后端启动脚本统一为 run_backend.sh (15:00)
|
||||
|
||||
### 内容
|
||||
- 删除旧脚本 `run_backend_xvfb.sh`
|
||||
- 将 `run_backend.sh` 统一为 xvfb + headful 启动逻辑(不再保留非 xvfb 版本)
|
||||
- 默认端口从 `8010` 统一为 `8006`
|
||||
- 启动脚本默认关闭微信/抖音 debug 产物
|
||||
- 更新部署手册中的启动与 pm2 示例,统一使用 `run_backend.sh`
|
||||
|
||||
### 涉及文件
|
||||
- `run_backend.sh`
|
||||
- `run_backend_xvfb.sh` (deleted)
|
||||
- `Docs/DEPLOY_MANUAL.md`
|
||||
|
||||
---
|
||||
|
||||
## 🧾 视频号卡顿与文案未写入修复 (14:52)
|
||||
|
||||
### 内容
|
||||
- 复盘日志确认视频号 `post_create` 请求已成功,但结果判定仅靠页面文案,导致长时间“等待发布结果”
|
||||
- 发布判定优化:`post_create` 成功且页面进入 `post/list` 时立即判定成功
|
||||
- 发布超时改为失败返回(不再 `success=true` 假成功)
|
||||
- “标题+标签写在视频描述”进一步加强:先按 `视频描述` 标签定位输入框,再做 placeholder 与 contenteditable 兜底
|
||||
- 视频号发布结果等待超时从 `180s` 收敛到 `90s`
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/weixin_uploader.py`
|
||||
|
||||
---
|
||||
|
||||
## 🚦 视频号发布卡顿根因与快速判定 (14:45)
|
||||
|
||||
### 内容
|
||||
- 定位到卡顿根因是实际请求已提交(`post_create` 成功)但结果判定仍在轮询文本提示,导致长时间等待
|
||||
- 新增发布成功网络信号:监听 `post/post_create` 成功响应后标记已提交
|
||||
- 若已提交且页面已回到内容列表(`/post/list`),立即判定发布成功,不再等满超时
|
||||
- 新增发布接口失败信号:`post_create` 返回错误时立即失败返回
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/weixin_uploader.py`
|
||||
|
||||
---
|
||||
|
||||
## 📸 视频号发布成功截图接入前端 (13:34)
|
||||
|
||||
### 内容
|
||||
- 为微信视频号新增“发布成功截图”能力:发布成功后直接对当前成功页截图
|
||||
- 截图存储沿用私有隔离目录:`private_outputs/publish_screenshots/{user_id}`
|
||||
- 返回前端的 `screenshot_url` 使用鉴权接口:`/api/publish/screenshot/{filename}`
|
||||
- 视频号上传器新增 `user_id` 透传,确保截图按用户隔离
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/weixin_uploader.py`
|
||||
- `backend/app/services/publish_service.py`
|
||||
|
||||
---
|
||||
|
||||
## ✍️ 视频号描述填充修正 + 关闭调试产物 (13:26)
|
||||
|
||||
### 内容
|
||||
- 按最新规则调整视频号文案填充:标题和标签统一写入“视频描述”输入区
|
||||
- 标签统一规范为 `#标签` 形式并去重
|
||||
- 若未找到“视频描述”输入区,直接返回失败,避免“发布成功但标题/标签为空”
|
||||
- 关闭视频号 debug 产物:新增 `WEIXIN_DEBUG_ARTIFACTS=false`,禁用调试日志与截图输出
|
||||
- `run_backend.sh` 增加 `WEIXIN_DEBUG_ARTIFACTS=false`,启动脚本层面强制关闭
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/weixin_uploader.py`
|
||||
- `backend/app/core/config.py`
|
||||
- `run_backend.sh`
|
||||
|
||||
---
|
||||
|
||||
## 🚫 强制关闭抖音调试产物 (13:15)
|
||||
|
||||
### 内容
|
||||
- 进一步收紧为“默认不生成任何抖音 debug 截屏/日志/录屏”
|
||||
- 录屏开关改为依赖 `DOUYIN_DEBUG_ARTIFACTS && DOUYIN_RECORD_VIDEO`,避免单独误开
|
||||
- `run_backend.sh` 增加环境变量强制关闭:
|
||||
- `DOUYIN_DEBUG_ARTIFACTS=false`
|
||||
- `DOUYIN_RECORD_VIDEO=false`
|
||||
- 仅保留给用户看的发布成功截图(私有目录 + 鉴权访问)
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
- `backend/app/core/config.py`
|
||||
- `run_backend.sh`
|
||||
|
||||
---
|
||||
|
||||
## 🧹 关闭调试截屏/录屏并清理历史文件 (13:08)
|
||||
|
||||
### 内容
|
||||
- 抖音调试产物默认关闭:
|
||||
- `DOUYIN_DEBUG_ARTIFACTS=false`
|
||||
- `DOUYIN_RECORD_VIDEO=false`
|
||||
- 保留功能信号监听(上传提交/封面生成/发布接口状态)用于流程判断,不依赖调试文件
|
||||
- 已删除现有抖音调试文件(`debug_screenshots` 下的 `douyin_*` 截图、日志与失败录屏)
|
||||
- 继续保留并展示“给用户看的发布成功截图”(用户隔离 + 鉴权访问)
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/core/config.py`
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
- `backend/app/debug_screenshots/douyin_*` (deleted)
|
||||
- `backend/app/debug_screenshots/videos/douyin_*` (deleted)
|
||||
|
||||
---
|
||||
|
||||
## 🔒 成功截图用户隔离 (12:58)
|
||||
|
||||
### 内容
|
||||
- 发布成功截图改为用户隔离存储,不再写入公开静态目录
|
||||
- 存储目录迁移到私有路径:`private_outputs/publish_screenshots/{user_id}`
|
||||
- 新增鉴权访问接口:`GET /api/publish/screenshot/{filename}`(必须登录,仅可访问本人截图)
|
||||
- 返回给前端的 `screenshot_url` 改为鉴权接口地址,避免跨用户直接猜路径访问
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
- `backend/app/services/publish_service.py`
|
||||
- `backend/app/modules/publish/router.py`
|
||||
- `backend/app/core/config.py`
|
||||
|
||||
---
|
||||
|
||||
## 🎯 封面触发提速与审核中截图强化 (12:49)
|
||||
|
||||
### 内容
|
||||
- 修复“上传完成后长时间不进入封面”:当出现 `重新上传+预览` 且已收到视频提交信号时,立即进入封面步骤
|
||||
- 目标是减少“处理中”文案残留导致的额外等待
|
||||
- 成功截图逻辑强化为优先“真实点击审核中标签”,新增文本点击兜底,不再只用可见即通过
|
||||
- 若审核中列表未马上出现标题,自动刷新并再次进入审核中重查后再截图
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
|
||||
---
|
||||
|
||||
## 🔐 登录态识别增强(避免误报上传失败) (12:41)
|
||||
|
||||
### 内容
|
||||
- 针对“未触发文件选择弹窗”误报,新增登录页识别:
|
||||
- URL 关键字:`passport/login/check_qrconnect/sso`
|
||||
- 页面文本:`扫码登录/验证码登录/立即登录/抖音APP扫码登录` 等
|
||||
- 登录控件:手机号/验证码输入框、登录按钮
|
||||
- 上传阶段重试后若识别为登录页,直接返回 `Cookie 已失效,请重新登录`
|
||||
- 避免把“实际掉登录”误判成“上传入口失效”
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
|
||||
---
|
||||
|
||||
## ⏱️ 发布阶段超时与网络不佳快速失败 (12:30)
|
||||
|
||||
### 内容
|
||||
- 针对“网络不佳后长时间卡住”增加发布阶段快速失败
|
||||
- 上传完成后到发布结果设置总超时 `60s`(`POST_UPLOAD_STAGE_TIMEOUT`),超过直接失败
|
||||
- 识别发布接口 `create_v2` 的 HTTP 错误(如 403)并立即返回失败,不再等待 180 秒
|
||||
- 发布结果判定新增网络类失败文案匹配(`网络不佳/网络异常/请稍后重试`)
|
||||
- 阻塞弹窗关闭策略新增 `暂不设置`,避免“设置横封面获更多流量”弹窗阻塞点击发布
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
|
||||
---
|
||||
|
||||
## 🧯 封面已完成但误判失败修复 (12:22)
|
||||
|
||||
### 内容
|
||||
- 针对报错“封面为必填但未设置成功”新增页面态兜底,避免封面已完成却未点击发布
|
||||
- 新增 `_is_cover_configured_on_page()`:通过 `横封面/竖封面` + 封面预览图判断页面已配置封面
|
||||
- 当出现 `horizontal_switch_missed` 或 `no_cover_button` 时,若页面已配置封面则允许继续发布
|
||||
- 封面必填主流程增加 `configured_fallback_continue` 兜底,降低误杀
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
|
||||
---
|
||||
|
||||
## 🧾 成功截图切到审核中视图 (11:26)
|
||||
|
||||
### 内容
|
||||
- 按需求将“发布成功截图”改为内容管理 `审核中/待审核` 视图,不再截“全部作品”
|
||||
- 发布成功后先进入内容管理并点击 `审核中`(或 `待审核`)再截图
|
||||
- 截图前额外尝试等待当前标题出现在审核中列表,便于确认是最新发布作品
|
||||
- 发布超时兜底验证也改为优先在审核中列表查找标题
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
|
||||
---
|
||||
|
||||
## ✅ 封面步骤按指定顺序强约束 (11:18)
|
||||
|
||||
### 内容
|
||||
- 按确认流程收紧旧发布页封面链路:
|
||||
- 作品描述填完 → 点击 `选择封面` → 点击 `设置横封面` → 点击 `完成` → 等待封面效果检测通过 → 才允许发布
|
||||
- 新增 `require_horizontal` 约束:封面必填场景必须切换到横封面,否则直接失败重试
|
||||
- 新增封面效果检测通过等待:优先 `cover/gen` 新请求信号,其次页面“检测通过”文案
|
||||
- 避免因漏点 `设置横封面` 导致后续卡住或误发布
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
|
||||
---
|
||||
|
||||
## 🧩 横封面点击漏判修复 (11:10)
|
||||
|
||||
### 内容
|
||||
- 根据复现反馈修复“未点击设置横封面导致封面流程卡住”问题
|
||||
- 新增 `_switch_to_horizontal_cover()`,扩展横封面入口选择器(`设置横封面/横封面/横版封面`)
|
||||
- 进入封面弹窗后先关闭阻塞弹窗再点击横封面,点击失败会重试一次
|
||||
- 若页面存在横封面入口但始终未切换成功,直接返回失败并重试,避免长时间假等待
|
||||
- 新增日志:`[douyin][cover] switched_horizontal ...`、`horizontal_switch_missed`
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
|
||||
---
|
||||
|
||||
## ⚡ 横封面后直接完成优化 (11:03)
|
||||
|
||||
### 内容
|
||||
- 根据实测反馈,在点击 `设置横封面` 后新增一次“立即点击完成”快速路径
|
||||
- 若平台已自动选中横封面,将直接确认并退出弹窗,不再执行后续封面扫描
|
||||
- 新增日志:`[douyin][cover] fast_confirm_after_switch ...`
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 封面步骤提速优化 (10:58)
|
||||
|
||||
### 内容
|
||||
- 复盘日志确认旧发布页封面步骤存在明显耗时(示例:`required_by_text` 到 `cover selected` 约 35 秒)
|
||||
- 新增封面“快速确认”路径:若平台已默认选中封面,直接确认并跳过多余扫描
|
||||
- 收紧封面成功条件:仅“确认按钮点击成功”才算封面设置成功,避免误判
|
||||
- 缩短不必要等待并新增封面耗时日志:`[douyin][cover] fast_confirm/selected=... confirmed=... elapsed=...`
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
|
||||
---
|
||||
|
||||
## 🧾 发布成功截图前台展示 (10:48)
|
||||
|
||||
### 内容
|
||||
- 按需求删除 `run_backend_xvfb_live.sh`,不再提供实时直播脚本
|
||||
- 抖音发布成功时自动保存成功截图到 `outputs/publish_screenshots`
|
||||
- 发布接口返回 `screenshot_url`,前端发布结果卡片直接展示截图并支持点击查看大图
|
||||
- 发布结果不再 10 秒自动清空,方便用户确认“是否真正发布成功”
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
- `frontend/src/features/publish/model/usePublishController.ts`
|
||||
- `frontend/src/features/publish/ui/PublishPage.tsx`
|
||||
- `run_backend_xvfb_live.sh` (deleted)
|
||||
|
||||
---
|
||||
|
||||
## 🧬 抖音界面差异根因与环境对齐 (10:20)
|
||||
|
||||
### 内容
|
||||
- 定位到 Playwright 与手动 Win11 Chrome 的环境指纹不一致(Linux 平台 + 自动化上下文),可能触发不同灰度界面
|
||||
- 抖音上传器新增独立浏览器配置项,不再复用 `WEIXIN_*` 配置
|
||||
- 新增 `DOUYIN_*` 配置:`HEADLESS_MODE/USER_AGENT/LOCALE/TIMEZONE_ID/CHROME_PATH/BROWSER_CHANNEL/FORCE_SWIFTSHADER`
|
||||
- 上传器启动改为 `_build_launch_options()`,可直接切换到系统 Chrome + headful(推荐配合 xvfb)
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
- `backend/app/core/config.py`
|
||||
|
||||
---
|
||||
|
||||
## 🪄 新旧发布页封面逻辑分流 (10:28)
|
||||
|
||||
### 内容
|
||||
- 依据页面结构自动分流:
|
||||
- 新版发布页(封面非必填):默认跳过封面设置
|
||||
- 旧版发布页(出现 `设置封面` + `必填`):强制先设置封面
|
||||
- 新增 `_is_cover_required()` 判断,避免在新页面做多余封面操作
|
||||
- 若判定为非必填但点击发布失败,会回退尝试设置封面后再重试发布
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
|
||||
---
|
||||
|
||||
## 📺 虚拟屏实时观看方案 (10:36)
|
||||
|
||||
### 内容
|
||||
- 新增 `run_backend_xvfb_live.sh`,在 Xvfb 下同时启动后端与实时画面转码
|
||||
- 通过 ffmpeg 抓取虚拟屏并输出 HLS:`/outputs/live/live.m3u8`
|
||||
- 适用于“边跑自动发布边实时观看”,不依赖 VNC
|
||||
- 默认仍保留失败录屏,HLS 用于过程实时观察
|
||||
|
||||
### 涉及文件
|
||||
- `run_backend_xvfb_live.sh`
|
||||
|
||||
---
|
||||
|
||||
## 🎥 抖音后台录屏能力 (09:55)
|
||||
|
||||
### 内容
|
||||
- 新增抖音自动发布过程录屏能力,便于定位“卡住在哪一步”
|
||||
- 录屏文件保存目录:`backend/app/debug_screenshots/videos`
|
||||
- 默认开启录屏,默认只保留失败录屏(成功录屏自动清理)
|
||||
- 每次执行会在网络日志追加录屏保存记录(`[douyin][record]`)
|
||||
- 增加发布阶段关键标记日志:`publish_wait ready`、`publish_click try/clicked`
|
||||
- 新增配置项:`DOUYIN_RECORD_VIDEO`、`DOUYIN_KEEP_SUCCESS_VIDEO`、`DOUYIN_RECORD_VIDEO_WIDTH`、`DOUYIN_RECORD_VIDEO_HEIGHT`
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
- `backend/app/core/config.py`
|
||||
|
||||
---
|
||||
|
||||
## 🚀 发布按钮等待逻辑修正 (10:00)
|
||||
|
||||
### 内容
|
||||
- 根据线上反馈,发布页不再做冗长前置等待,改为“尽快尝试点击发布”
|
||||
- 新增发布按钮定位策略(role + text 多选择器),避免 `exact role` 匹配失败导致假等待
|
||||
- 将发布按钮等待上限从上传超时(300s)独立为 `PUBLISH_BUTTON_TIMEOUT=60s`
|
||||
- 点击发布阶段统一走 `_click_publish_button`,并持续记录 `publish_wait/publish_click` 日志
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
|
||||
---
|
||||
|
||||
## 🧪 上传完成特征判定增强 (10:07)
|
||||
|
||||
### 内容
|
||||
- 基于实测页面特征补齐“上传中/上传完成”判定:
|
||||
- 上传中:`上传过程中请不要刷新`、`取消上传`、`已上传/当前速度/剩余时间`
|
||||
- 上传完成:`重新上传` + `预览视频/预览封面/标题`
|
||||
- 仅在确认上传完成后才允许执行发布点击,避免“未传完提前发布”
|
||||
- 新增上传等待日志:`[douyin][upload_wait] ...`,可直观看到卡在上传中还是等完成信号
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
|
||||
---
|
||||
|
||||
## ⏸️ 上传完成后延时发布 (10:10)
|
||||
|
||||
### 内容
|
||||
- 根据实测反馈,增加“上传完成后固定等待 2 秒”再点发布
|
||||
- 避免刚出现完成信号就立即点击,给前端状态收敛留缓冲
|
||||
- 新增日志标记:`[douyin][upload_ready] wait_before_publish=2s`
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
|
||||
---
|
||||
|
||||
## 🖼️ 恢复封面设置流程 (10:14)
|
||||
|
||||
### 内容
|
||||
- 按实测需求恢复“上传完成后先设置封面,再发布”流程
|
||||
- 封面设置改为最多尝试 2 次,成功写入 `[douyin][cover] selected`
|
||||
- 若封面未设置成功则直接终止发布并保存截图 `cover_not_selected`
|
||||
- 避免出现“未设封面就点击发布”的情况
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 抖音发布流程修复 (09:20)
|
||||
|
||||
### 内容
|
||||
- 按最新页面流程改为先进入首页并点击 `高清发布`,再进入上传页
|
||||
- 新增未发布草稿处理:检测到 `你还有上次未发布的视频` 时自动点击 `放弃`
|
||||
- 上传策略改为优先点击 `上传视频` 并走 file chooser,失败后再回退多 input 选择器
|
||||
- 只有检测到 `基础信息/作品描述/发布设置/重新上传` 等发布态信号才继续,避免误判“已上传”
|
||||
- 修复无扩展名视频临时文件策略:优先 hardlink,失败时 copy,移除 symlink 回退
|
||||
- 适配当前智能封面流程:跳过手动封面操作
|
||||
- 话题填写改为在简介/描述区域使用 `#标签` 形式追加
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
|
||||
---
|
||||
|
||||
## ⚡ 抖音等待链路再收敛 (09:52)
|
||||
|
||||
### 内容
|
||||
- 根据“选完视频即进入发布页”流程,移除独立的上传完成轮询阶段
|
||||
- 改为在点击发布前统一等待“发布按钮可点击”,避免重复等待导致总时长偏长
|
||||
- 新增 `publish_wait` 调试日志,按秒记录按钮可点击等待时长
|
||||
- 超时文案改为明确提示“发布按钮长时间不可点击”
|
||||
- 上传入口改为严格 file chooser 流程:只走“点击上传视频 → 选择文件 → 进入发布页”链路
|
||||
- 移除直接 input 回退上传,避免绕开上传入口导致状态机异常
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
|
||||
---
|
||||
|
||||
## 🧭 抖音卡慢环节定位与修复 (09:45)
|
||||
|
||||
### 内容
|
||||
- 通过 `douyin_network.log` 定位到卡慢发生在“上传完成判定”阶段,而非真正提交发布接口
|
||||
- 新增上传完成网络信号:`CommitUploadInner` 成功与封面生成成功信号写入日志
|
||||
- 收紧“上传完成”判定,移除 `publish_button_enabled` 这种过早放行条件
|
||||
- 仅在检测到 `重新上传/重新选择` 或上传提交信号后才进入下一步,降低误判导致的长等待
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
|
||||
---
|
||||
|
||||
## ✅ 抖音发布结果判定修正 (09:38)
|
||||
|
||||
### 内容
|
||||
- 修复“发布检测超时仍返回 success=true”的问题,超时场景改为 `success=false`
|
||||
- 优化超时返回文案,明确为“发布状态未知,需要后台确认”
|
||||
- 下线过于宽松的管理页兜底判定(仅出现 `审核中` 不再当作发布成功)
|
||||
- 超时时即使管理页出现同名标题也不直接判定成功,避免旧作品同名导致误报
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
|
||||
---
|
||||
|
||||
## ⏱️ 抖音上传完成判定优化 (09:34)
|
||||
|
||||
### 内容
|
||||
- 根据最新日志确认文件上传已开始并有分片上传请求成功,但流程长时间停留在“等待上传完成”
|
||||
- 扩展“上传完成”判定条件,不再只依赖单一 `long-card + 重新上传` 选择器
|
||||
- 新增上传完成信号:`重新上传/重新选择` 可见、发布按钮可用、`发布设置` 或 `预览视频` 可见
|
||||
- 上传等待日志增加耗时秒数,便于判断是否真实卡住
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
103
Docs/DevLogs/Day20.md
Normal file
103
Docs/DevLogs/Day20.md
Normal file
@@ -0,0 +1,103 @@
|
||||
## 🔧 代码质量与安全优化 (13:30)
|
||||
|
||||
### 概述
|
||||
本日进行项目全面代码审查与优化,共处理 27 项优化点,完成 18 项核心修复。
|
||||
|
||||
### 已完成优化
|
||||
|
||||
#### 功能性修复
|
||||
- [x] **P0-1**: LatentSync 回退逻辑空实现 → 改为 `raise RuntimeError`
|
||||
- [x] **P1-1**: 任务状态接口无用户归属校验 → 添加用户认证依赖
|
||||
- [x] **P1-2**: 前端 User 类型定义重复 → 统一到 `shared/types/user.ts`
|
||||
|
||||
#### 性能优化
|
||||
- [x] **P1-3**: 参考音频列表 N+1 查询 → 使用 `asyncio.gather` 并发
|
||||
- [x] **P1-4**: 视频上传整读内存 → 新增 `upload_file_from_path` 流式处理
|
||||
- [x] **P1-5**: async 路由内同步阻塞 → `httpx.AsyncClient` 替换 `requests`
|
||||
- [x] **P2-2**: GLM 服务同步调用 → `asyncio.to_thread` 包装
|
||||
- [x] **P2-3**: Remotion 渲染启动慢 → 预编译 JS + `build:render` 脚本
|
||||
|
||||
#### 安全修复
|
||||
- [x] **P1-8**: 硬编码 Cookie → 移至环境变量 `DOUYIN_COOKIE`
|
||||
- [x] **P1-9**: 请求日志打印完整 headers → 敏感信息脱敏
|
||||
- [x] **P2-10**: ffprobe 使用 `shell=True` → 改为参数列表
|
||||
- [x] **P2-11**: CORS 配置 `*` + credentials → 从 `CORS_ORIGINS` 环境变量读取
|
||||
|
||||
#### 配置优化
|
||||
- [x] **P2-5**: 存储服务硬编码路径 → 环境变量 `SUPABASE_STORAGE_LOCAL_PATH`
|
||||
- [x] **P3-3**: Remotion `execSync` 同步调用 → promisified `exec` 异步
|
||||
- [x] **P3-5**: LatentSync 相对路径 → 基于 `__file__` 绝对路径
|
||||
|
||||
### 暂不处理(收益有限)
|
||||
- [~] **P1-6**: useHomeController 超大文件 (884行)
|
||||
- [~] **P1-7**: 抖音/微信上传器重复代码(流程差异大)
|
||||
|
||||
### 低优先级(后续处理)
|
||||
- [~] **P2-6~P2-9**: API 转发壳、前端 API 客户端混用、ESLint、重复逻辑
|
||||
- [~] **P3-1~P3-4**: 阻塞式交互、Modal 过大、样式兼容层
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/latentsync_service.py` - 回退逻辑
|
||||
- `backend/app/modules/videos/router.py` - 任务状态认证
|
||||
- `backend/app/modules/tools/router.py` - httpx 异步、Cookie 配置化
|
||||
- `backend/app/services/glm_service.py` - 异步包装
|
||||
- `backend/app/services/storage.py` - 流式上传、路径配置化
|
||||
- `backend/app/services/video_service.py` - ffprobe 安全调用
|
||||
- `backend/app/main.py` - CORS 配置、日志脱敏
|
||||
- `backend/app/core/config.py` - 新增配置项
|
||||
- `remotion/render.ts` - 异步 exec
|
||||
- `remotion/package.json` - build:render 脚本
|
||||
- `models/LatentSync/scripts/server.py` - 绝对路径
|
||||
- `frontend/src/shared/types/user.ts` - 统一类型定义
|
||||
|
||||
### 新增环境变量
|
||||
```bash
|
||||
# .env 新增配置(均有默认值,无需必填)
|
||||
CORS_ORIGINS=* # CORS 白名单
|
||||
SUPABASE_STORAGE_LOCAL_PATH=/path/to/... # 本地存储路径
|
||||
DOUYIN_COOKIE=... # 抖音视频下载 Cookie
|
||||
```
|
||||
|
||||
### 重启要求
|
||||
```bash
|
||||
pm2 restart vigent2-backend
|
||||
pm2 restart vigent2-latentsync
|
||||
# Remotion 已自动编译
|
||||
```
|
||||
|
||||
### 🎨 交互与体验优化 (17:00)
|
||||
|
||||
- [x] **UX-1**: PublishPage 图片加载优化 (`<img>` → `next/image`)
|
||||
- [x] **UX-2**: 按钮 Loading 状态统一 (提取脚本弹窗 + 发布页)
|
||||
- [x] **UX-3**: 骨架屏加载优化 (发布页加载中状态)
|
||||
- [x] **UX-4**: 全局快捷键支持 (ESC 关闭弹窗, Enter 确认)
|
||||
- [x] **UX-5**: 移除全局 GlobalTaskIndicator (视觉降噪)
|
||||
- [x] **UX-6**: 视频生成完成自动刷新列表并选中最新
|
||||
|
||||
### 🐛 缺陷修复与回归治理 (17:30)
|
||||
|
||||
#### 严重缺陷修复
|
||||
- [x] **BUG-1**: Remotion 渲染脚本路径解析错误 (导致标题字幕丢失)
|
||||
- *原因*: `render.js` 预编译后使用了 `__dirname`,在 `dist` 目录下寻找源码失败。
|
||||
- *修复*: 修改 `render.ts` 使用 `process.cwd()` 动态解析路径,并重新编译。
|
||||
|
||||
- [x] **BUG-2**: 发布页视频选择持久化失效 (Auth 异步竞态)
|
||||
- *原因*: 页面加载时 `useAuth` 尚未返回用户 ID,导致使用 `guest` Key 读取不到记录,随后被默认值覆盖。
|
||||
- *修复*: 引入 `isVideoRestored` 状态机,强制等待 Auth 完成且 Video 列表加载完毕后,才执行恢复逻辑。
|
||||
|
||||
#### 回归问题治理
|
||||
- [x] **REG-1**: 首页历史作品 ID 恢复后内容不显示
|
||||
- *原因*: 持久化模块恢复了 ID,但 `useGeneratedVideos` 未监听 ID 变化同步 URL。
|
||||
- *修复*: 新增 `useEffect` 监听 `selectedVideoId` 变化并同步 `generatedVideo` URL。
|
||||
|
||||
- [x] **REG-2**: 首页/发布页“默认选中第一个”逻辑丢失
|
||||
- *原因*: 重构移除旧逻辑后,新用户或无缓存用户进入页面无默认选中。
|
||||
- *修复*: 在 `isRestored` 且无选中时,增加兜底逻辑自动选中列表第一项。
|
||||
|
||||
- [x] **REG-3**: 素材选择持久化失效 (闭包陷阱)
|
||||
- *原因*: `useMaterials` 加载回调中捕获了旧的 `selectedMaterial` 状态,覆盖了已恢复的值。
|
||||
- *修复*: 改为函数式状态更新 (`setState(prev => ...)`),确保基于最新状态判断。
|
||||
|
||||
- [x] **REF-1**: 持久化逻辑全站收敛与排查
|
||||
- *优化*: 清理 `useBgm`, `useGeneratedVideos`, `useTitleSubtitleStyles` 中的冗余 `localStorage` 读取,统一由 `useHomePersistence` 管理。
|
||||
- *排查*: 深度排查 `useRefAudios`, `useTitleSubtitleStyles` 等模块,确认逻辑健壮,无类似回归风险。
|
||||
248
Docs/DevLogs/Day21.md
Normal file
248
Docs/DevLogs/Day21.md
Normal file
@@ -0,0 +1,248 @@
|
||||
## 🐛 缺陷修复:视频生成与持久化回归 (Day 21)
|
||||
|
||||
### 概述
|
||||
本日修复 Day 20 优化后引入的 3 个回归缺陷:Remotion 渲染崩溃容错、首页作品选择持久化、发布页作品选择持久化。
|
||||
|
||||
---
|
||||
|
||||
### 已完成修复
|
||||
|
||||
#### BUG-1: Remotion 渲染进程崩溃导致标题/字幕丢失
|
||||
- **现象**: 视频生成后没有标题和字幕,回退到纯 FFmpeg 合成。
|
||||
- **根因**: Remotion Node.js 进程在渲染完成(100%)后以 SIGABRT (code -6) 退出,Python 端将其视为失败。
|
||||
- **修复**: `remotion_service.py` 在进程非零退出时,先检查输出文件是否存在且大小合理(>1KB),若存在则视为成功。
|
||||
- **文件**: `backend/app/services/remotion_service.py`
|
||||
|
||||
```python
|
||||
if process.returncode != 0:
|
||||
output_file = Path(output_path)
|
||||
if output_file.exists() and output_file.stat().st_size > 1024:
|
||||
logger.warning(
|
||||
f"Remotion process exited with code {process.returncode}, "
|
||||
f"but output file exists ({output_file.stat().st_size} bytes). Treating as success."
|
||||
)
|
||||
return output_path
|
||||
raise RuntimeError(...)
|
||||
```
|
||||
|
||||
#### BUG-2: 首页历史作品选择刷新后不保持
|
||||
- **现象**: 用户选择某个历史作品后刷新页面,总是回到第一个视频。
|
||||
- **根因**: `fetchGeneratedVideos()` 在初始加载时无条件自动选中第一个视频,覆盖了 `useHomePersistence` 的恢复值。
|
||||
- **修复**: `fetchGeneratedVideos` 增加 `preferVideoId` 参数,仅在明确指定时才自动选中;新增 `"__latest__"` 哨兵值用于生成完成后选中最新。
|
||||
- **文件**: `frontend/src/features/home/model/useGeneratedVideos.ts`, `frontend/src/features/home/model/useHomeController.ts`
|
||||
|
||||
```typescript
|
||||
// 任务完成 → 自动选中最新
|
||||
useEffect(() => {
|
||||
if (prevIsGenerating.current && !isGenerating) {
|
||||
if (currentTask?.status === "completed") {
|
||||
void fetchGeneratedVideos("__latest__");
|
||||
} else {
|
||||
void fetchGeneratedVideos();
|
||||
}
|
||||
}
|
||||
prevIsGenerating.current = isGenerating;
|
||||
}, [isGenerating, currentTask, fetchGeneratedVideos]);
|
||||
```
|
||||
|
||||
#### BUG-3: 发布页作品选择刷新后不保持(根因:签名 URL 不稳定)
|
||||
- **现象**: 发布管理页选择视频后刷新,选择丢失(无任何视频被选中)。
|
||||
- **根因**: 后端 `/api/videos/generated` 返回的 `path` 是 Supabase 签名 URL,每次请求都会变化。发布页用 `path` 作为选择标识存入 localStorage,刷新后新的 `path` 与保存值永远不匹配。首页不受影响是因为使用稳定的 `video.id`。
|
||||
- **修复**: 发布页全面改用 `id`(稳定标识)替代 `path`(签名 URL)进行选择、持久化和比较。
|
||||
- **文件**:
|
||||
- `frontend/src/shared/types/publish.ts` — `PublishVideo` 新增 `id` 字段
|
||||
- `frontend/src/features/publish/model/usePublishController.ts` — `selectedVideo` 存储 `id`,发布时根据 `id` 查找 `path`
|
||||
- `frontend/src/features/publish/ui/PublishPage.tsx` — `key`/`onClick`/选中比较改用 `v.id`
|
||||
- `frontend/src/features/home/model/useHomeController.ts` — 预取缓存加入 `id` 字段
|
||||
|
||||
```typescript
|
||||
// 类型定义新增 id
|
||||
export interface PublishVideo {
|
||||
id: string; // 稳定标识符
|
||||
name: string;
|
||||
path: string; // 签名 URL(仅用于播放/发布)
|
||||
}
|
||||
|
||||
// 发布时根据 id 查找 path
|
||||
const video = videos.find(v => v.id === selectedVideo);
|
||||
await api.post('/api/publish', { video_path: video.path, ... });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 涉及文件汇总
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `backend/app/services/remotion_service.py` | Remotion 崩溃容错 |
|
||||
| `frontend/src/features/home/model/useGeneratedVideos.ts` | 首页视频选择不自动覆盖 |
|
||||
| `frontend/src/features/home/model/useHomeController.ts` | 任务完成监听 + 预取缓存加 id |
|
||||
| `frontend/src/shared/types/publish.ts` | PublishVideo 新增 id 字段 |
|
||||
| `frontend/src/features/publish/model/usePublishController.ts` | 选择/持久化/发布改用 id |
|
||||
| `frontend/src/features/publish/ui/PublishPage.tsx` | UI 选择比较改用 id |
|
||||
|
||||
### 关键教训
|
||||
|
||||
> **签名 URL 不可作为持久化标识**。Supabase Storage 的签名 URL 包含时间戳和签名参数,每次请求都不同。任何需要跨请求/跨刷新保持的标识,必须使用后端返回的稳定 `id` 字段。
|
||||
|
||||
### 重启要求
|
||||
```bash
|
||||
pm2 restart vigent2-backend # Remotion 容错
|
||||
npm run build && pm2 restart vigent2-frontend # 前端持久化修复
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 浮动样式预览窗口优化 (Day 21)
|
||||
|
||||
### 概述
|
||||
标题与字幕面板中的预览区域原本是内联折叠的,展开后调节下方滑块时看不到预览效果。改为 `position: fixed` 浮动窗口,固定在视口左上角,滚动页面时预览始终可见,边调边看。
|
||||
|
||||
### 已完成优化
|
||||
|
||||
#### 1. 新建浮动预览组件 `FloatingStylePreview.tsx`
|
||||
- `createPortal(jsx, document.body)` 渲染到 body 层级,脱离面板 DOM 树
|
||||
- `position: fixed` + 左上角固定定位,滚动时不移动
|
||||
- `z-index: 150`(低于 VideoPreviewModal 的 200)
|
||||
- 顶部标题栏 + X 关闭按钮,ESC 键关闭
|
||||
- 桌面端固定宽度 280px,移动端自适应(最大 360px)
|
||||
- `previewScale = windowWidth / previewBaseWidth` 自行计算缩放
|
||||
- `maxHeight: calc(100dvh - 32px)` 防止超出视口
|
||||
|
||||
#### 2. 修改 `TitleSubtitlePanel.tsx`
|
||||
- 删除内联预览区域(`ref={previewContainerRef}` 整块 JSX)
|
||||
- 条件渲染 `<FloatingStylePreview />`,按钮文本保持"预览样式"/"收起预览"
|
||||
- 移除 `previewScale`、`previewAspectRatio`、`previewContainerRef` props
|
||||
- 保留 `previewBaseWidth/Height`(浮动窗口需要原始尺寸计算 scale)
|
||||
|
||||
#### 3. 清理 `useHomeController.ts`
|
||||
- 移除 `previewContainerWidth` 状态
|
||||
- 移除 `titlePreviewContainerRef` ref
|
||||
- 移除 ResizeObserver useEffect(浮动窗口自管尺寸,不再需要)
|
||||
|
||||
#### 4. 简化 `HomePage.tsx` 传参
|
||||
- 移除 `previewContainerWidth`、`titlePreviewContainerRef` 解构
|
||||
- 移除 `previewScale`、`previewAspectRatio`、`previewContainerRef` prop 传递
|
||||
|
||||
#### 5. 移动端适配
|
||||
- `ScriptEditor.tsx`:标题行改为 `flex-wrap`,"AI生成标题标签"按钮不再溢出
|
||||
- 预览默认比例从 1280×720 (16:9) 改为 1080×1920 (9:16),符合抖音竖屏视频
|
||||
|
||||
### 涉及文件汇总
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `frontend/src/features/home/ui/FloatingStylePreview.tsx` | **新建** 浮动预览组件 |
|
||||
| `frontend/src/features/home/ui/TitleSubtitlePanel.tsx` | 移除内联预览,渲染浮动组件 |
|
||||
| `frontend/src/features/home/model/useHomeController.ts` | 移除 preview 容器相关状态和 ResizeObserver |
|
||||
| `frontend/src/features/home/ui/HomePage.tsx` | 简化 props 传递,默认比例改 9:16 |
|
||||
| `frontend/src/features/home/ui/ScriptEditor.tsx` | 移动端按钮换行适配 |
|
||||
|
||||
### 重启要求
|
||||
```bash
|
||||
npm run build && pm2 restart vigent2-frontend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 多平台发布体系重构:用户隔离与抖音刷脸验证 (Day 21)
|
||||
|
||||
### 概述
|
||||
重构发布系统的两大核心问题:① 多用户场景下 Cookie/会话缺乏隔离,② 抖音登录新增刷脸验证步骤无法处理。同时修复了平台配置混用和微信视频号发布流程问题。
|
||||
|
||||
---
|
||||
|
||||
### 一、平台配置独立化
|
||||
|
||||
#### 问题
|
||||
所有平台(抖音、微信、B站、小红书)共用 WEIXIN_* 配置,导致 User-Agent、Headless 模式等设置不匹配。
|
||||
|
||||
#### 修复 — `config.py`
|
||||
- 新增 `DOUYIN_*` 独立配置项:`DOUYIN_HEADLESS_MODE`、`DOUYIN_USER_AGENT`(Chrome/144)、`DOUYIN_LOCALE`、`DOUYIN_TIMEZONE_ID`、`DOUYIN_CHROME_PATH`、`DOUYIN_FORCE_SWIFTSHADER`、调试开关等
|
||||
- 微信保持已有 `WEIXIN_*` 配置
|
||||
- B站/小红书使用通用默认值
|
||||
|
||||
#### 修复 — `qr_login_service.py` 平台配置映射
|
||||
```python
|
||||
# 之前:所有平台都用 WEIXIN 设置
|
||||
# 之后:每个平台独立配置
|
||||
PLATFORM_CONFIGS = {
|
||||
"douyin": { headless, user_agent, locale, timezone... },
|
||||
"weixin": { headless, user_agent, locale, timezone... },
|
||||
"bilibili": { 通用配置 },
|
||||
"xiaohongshu": { 通用配置 },
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 二、用户隔离的 Cookie 管理
|
||||
|
||||
#### 问题
|
||||
多用户共享同一套 Cookie 文件,用户 A 的登录态可能被用户 B 覆盖。
|
||||
|
||||
#### 修复 — `publish_service.py`
|
||||
- `_get_cookies_dir(user_id)` → `backend/user_data/{uuid}/cookies/`
|
||||
- `_get_cookie_path(user_id, platform)` → 按用户+平台返回独立 Cookie 文件路径
|
||||
- `_get_session_key(user_id, platform)` → `"{user_id}_{platform}"` 格式的会话 key
|
||||
- 登录/发布流程全链路传入 `user_id`,清理残留会话避免干扰
|
||||
|
||||
---
|
||||
|
||||
### 三、抖音刷脸验证二维码
|
||||
|
||||
#### 问题
|
||||
抖音扫码登录后可能弹出刷脸验证窗口,内含新的二维码需要用户再次扫描,前端无法感知和展示。
|
||||
|
||||
#### 修复 — 后端 `qr_login_service.py`
|
||||
- 扩展 QR 选择器:支持跨 iframe 搜索二维码元素
|
||||
- 抖音 API 拦截:监听 `check_qrconnect` 响应,检测 `redirect_url`
|
||||
- 检测 "完成验证" / "请前往APP完成验证" 文案
|
||||
- 在验证弹窗内找到正方形二维码(排除头像),截图返回给前端
|
||||
- API 确认后直接导航到 redirect_url(不重新加载 QR 页,避免销毁会话)
|
||||
|
||||
#### 修复 — 后端 `publish_service.py`
|
||||
- `get_login_session_status()` 新增 `face_verify_qr` 字段返回
|
||||
- 登录成功且 Cookie 保存后自动清理会话
|
||||
|
||||
#### 修复 — 前端
|
||||
- `usePublishController.ts`:新增 `faceVerifyQr` 状态,轮询时获取 `face_verify_qr` 字段
|
||||
- `PublishPage.tsx`:QR 弹窗优先展示刷脸验证二维码,附提示文案
|
||||
|
||||
```tsx
|
||||
{faceVerifyQr ? (
|
||||
<>
|
||||
<Image src={`data:image/png;base64,${faceVerifyQr}`} />
|
||||
<p>需要身份验证,请用抖音APP扫描上方二维码完成刷脸验证</p>
|
||||
</>
|
||||
) : /* 普通登录二维码 */ }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 四、微信视频号发布流程优化
|
||||
|
||||
#### 修复 — `weixin_uploader.py`
|
||||
- 添加 `user_id` 参数支持,发布截图目录隔离
|
||||
- 新增 `post_create` API 响应监听,精准判断发布成功
|
||||
- 发布结果判定:URL 离开创建页 或 API 确认提交 → 视为成功
|
||||
- 标题/标签处理改为统一写入"视频描述"字段(不再单独填写 title/tags)
|
||||
|
||||
---
|
||||
|
||||
### 涉及文件汇总
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `backend/app/core/config.py` | 新增 DOUYIN_* 独立配置项 |
|
||||
| `backend/app/services/qr_login_service.py` | 平台配置拆分、刷脸验证二维码、跨 iframe 选择器 |
|
||||
| `backend/app/services/publish_service.py` | 用户隔离 Cookie 管理、刷脸验证状态返回 |
|
||||
| `backend/app/services/uploader/weixin_uploader.py` | user_id 支持、post_create API 监听、描述字段合并 |
|
||||
| `frontend/src/features/publish/model/usePublishController.ts` | faceVerifyQr 状态 |
|
||||
| `frontend/src/features/publish/ui/PublishPage.tsx` | 刷脸验证二维码展示 |
|
||||
|
||||
### 重启要求
|
||||
```bash
|
||||
pm2 restart vigent2-backend # 发布服务 + QR登录
|
||||
npm run build && pm2 restart vigent2-frontend # 刷脸验证UI
|
||||
```
|
||||
@@ -24,12 +24,13 @@
|
||||
| :---: | :--- | :--- |
|
||||
| 🔥 **High** | `Docs/DevLogs/DayN.md` | **(最新日志)** 详细记录变更、修复、代码片段 |
|
||||
| 🔥 **High** | `Docs/task_complete.md` | **(任务总览)** 更新 `[x]`、进度条、时间线 |
|
||||
| ⚡ **Med** | `README.md` | **(项目主页)** 功能特性、技术栈、最新截图 |
|
||||
| ⚡ **Med** | `Docs/DEPLOY_MANUAL.md` | **(部署手册)** 环境变量、依赖包、启动命令变更 |
|
||||
| ⚡ **Med** | `Docs/FRONTEND_DEV.md` | **(前端规范)** API封装、日期格式化、新页面规范 |
|
||||
| ⚡ **Med** | `Docs/FRONTEND_README.md` | **(前端文档)** 功能说明、页面变更 |
|
||||
| 🧊 **Low** | `Docs/implementation_plan.md` | **(实施计划)** 核对计划与实际实现的差异 |
|
||||
| 🧊 **Low** | `Docs/architecture_plan.md` | **(前端架构)** 拆分计划与阶段目标 |
|
||||
| ⚡ **Med** | `README.md` | **(项目主页)** 功能特性、技术栈、最新截图 |
|
||||
| ⚡ **Med** | `Docs/DEPLOY_MANUAL.md` | **(部署手册)** 环境变量、依赖包、启动命令变更 |
|
||||
| ⚡ **Med** | `Docs/BACKEND_DEV.md` | **(后端规范)** 接口契约、模块划分、环境变量 |
|
||||
| ⚡ **Med** | `Docs/BACKEND_README.md` | **(后端文档)** 接口说明、架构设计 |
|
||||
| ⚡ **Med** | `Docs/FRONTEND_DEV.md` | **(前端规范)** API封装、日期格式化、新页面规范 |
|
||||
| ⚡ **Med** | `Docs/FRONTEND_README.md` | **(前端文档)** 功能说明、页面变更 |
|
||||
| 🧊 **Low** | `Docs/*_DEPLOY.md` | **(子系统部署)** LatentSync/Qwen3/字幕等独立部署文档 |
|
||||
|
||||
---
|
||||
|
||||
@@ -94,7 +95,7 @@
|
||||
|
||||
### 必须执行的检查步骤
|
||||
|
||||
**1. 快速浏览全文**(使用 `view_file` 或 `grep_search`)
|
||||
**1. 快速浏览全文**(使用 `Read` 或 `Grep`)
|
||||
```markdown
|
||||
# 检查是否存在:
|
||||
- 同主题的旧章节?
|
||||
@@ -141,82 +142,61 @@
|
||||
|
||||
> **核心原则**:使用正确的工具,避免字符编码问题
|
||||
|
||||
### ✅ 推荐工具:apply_patch
|
||||
### ✅ 推荐工具:Edit / Read / Grep
|
||||
|
||||
**使用场景**:
|
||||
- 追加新章节到文件末尾
|
||||
- 修改/替换现有章节内容
|
||||
- 更新状态标记(🔄 → ✅)
|
||||
- 修正错误内容
|
||||
**使用场景**:
|
||||
- `Read`:更新前先查看文件当前内容
|
||||
- `Edit`:精确替换现有内容、追加新章节
|
||||
- `Grep`:搜索文件中是否已有相关章节
|
||||
- `Write`:创建新文件(如 Day{N+1}.md)
|
||||
|
||||
**优势**:
|
||||
- ✅ 自动处理字符编码(Windows CRLF)
|
||||
- ✅ 精确替换,不会误删其他内容
|
||||
- ✅ 有错误提示,方便调试
|
||||
|
||||
**注意事项**:
|
||||
**注意事项**:
|
||||
```markdown
|
||||
1. **必须精确匹配**:TargetContent 必须与文件完全一致
|
||||
2. **处理换行符**:文件使用 \r\n,不要漏掉 \r
|
||||
3. **合理范围**:StartLine/EndLine 应覆盖目标内容
|
||||
4. **先读后写**:编辑前先 view_file 确认内容
|
||||
1. **先读后写**:编辑前先用 Read 确认内容
|
||||
2. **精确匹配**:Edit 的 old_string 必须与文件内容完全一致
|
||||
3. **避免重复**:编辑前用 Grep 检查是否已存在同主题章节
|
||||
```
|
||||
|
||||
### ❌ 禁止使用:命令行工具
|
||||
### ❌ 禁止使用:命令行工具修改文档
|
||||
|
||||
**禁止场景**:
|
||||
- ❌ 使用 `echo >>` 追加内容(编码问题)
|
||||
- ❌ 使用 PowerShell 直接修改文档(破坏格式)
|
||||
- ❌ 使用 sed/awk 等命令行工具
|
||||
- ❌ 使用 `echo >>` 追加内容
|
||||
- ❌ 使用 `sed` / `awk` 修改文档
|
||||
- ❌ 使用 `cat <<EOF` 写入内容
|
||||
|
||||
**原因**:
|
||||
- 容易破坏 UTF-8 编码
|
||||
- Windows CRLF vs Unix LF 混乱
|
||||
- 容易破坏 UTF-8 编码和中文字符
|
||||
- 难以追踪修改,容易出错
|
||||
|
||||
**唯一例外**:简单的全局文本替换(如批量更新日期),且必须使用 `-NoNewline` 参数
|
||||
- 无法精确匹配替换位置
|
||||
|
||||
### 📝 最佳实践示例
|
||||
|
||||
**追加新章节**:
|
||||
```diff
|
||||
*** Begin Patch
|
||||
*** Update File: Docs/DevLogs/DayN.md
|
||||
@@
|
||||
## 🔗 相关文档
|
||||
|
||||
...
|
||||
---
|
||||
|
||||
## 🆕 新章节
|
||||
内容...
|
||||
*** End Patch
|
||||
```
|
||||
**追加新章节**:使用 `Edit` 工具,`old_string` 匹配文件末尾内容,`new_string` 包含原内容 + 新章节。
|
||||
|
||||
**修改现有内容**:
|
||||
```diff
|
||||
*** Begin Patch
|
||||
*** Update File: Docs/DevLogs/DayN.md
|
||||
@@
|
||||
-**状态**:🔄 待修复
|
||||
+**状态**:✅ 已修复
|
||||
*** End Patch
|
||||
```
|
||||
**修改现有内容**:使用 `Edit` 工具精确替换。
|
||||
```markdown
|
||||
old_string: "**状态**:🔄 待修复"
|
||||
new_string: "**状态**:✅ 已修复"
|
||||
```
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 📁 文件结构
|
||||
## 📁 文件结构
|
||||
|
||||
```
|
||||
ViGent2/Docs/
|
||||
├── task_complete.md # 任务总览(仅按需更新)
|
||||
├── Doc_Rules.md # 本文件
|
||||
├── FRONTEND_DEV.md # 前端开发规范
|
||||
├── FRONTEND_README.md # 前端功能文档
|
||||
├── architecture_plan.md # 前端拆分计划
|
||||
├── DEPLOY_MANUAL.md # 部署手册
|
||||
├── SUPABASE_DEPLOY.md # Supabase 部署文档
|
||||
ViGent2/Docs/
|
||||
├── task_complete.md # 任务总览(仅按需更新)
|
||||
├── Doc_Rules.md # 本文件
|
||||
├── BACKEND_DEV.md # 后端开发规范
|
||||
├── BACKEND_README.md # 后端功能文档
|
||||
├── FRONTEND_DEV.md # 前端开发规范
|
||||
├── FRONTEND_README.md # 前端功能文档
|
||||
├── DEPLOY_MANUAL.md # 部署手册
|
||||
├── SUPABASE_DEPLOY.md # Supabase 部署文档
|
||||
├── LatentSync_DEPLOY.md # LatentSync 部署文档
|
||||
├── QWEN3_TTS_DEPLOY.md # 声音克隆部署文档
|
||||
├── SUBTITLE_DEPLOY.md # 字幕系统部署文档
|
||||
└── DevLogs/
|
||||
├── Day1.md # 开发日志
|
||||
└── ...
|
||||
@@ -224,7 +204,7 @@ ViGent2/Docs/
|
||||
|
||||
---
|
||||
|
||||
## 📅 DayN.md 更新规则(日常更新)
|
||||
## 📅 DayN.md 更新规则(日常更新)
|
||||
|
||||
### 新建判断 (对话开始前)
|
||||
1. **回顾进度**:查看 `task_complete.md` 了解当前状态
|
||||
@@ -232,9 +212,9 @@ ViGent2/Docs/
|
||||
- **今天 (与当前日期相同)** → 🚨 **绝对禁止创建新文件**,必须**追加**到现有 `DayN.md` 末尾!即使是完全不同的功能模块。
|
||||
- **之前 (昨天或更早)** → 创建 `Day{N+1}.md`
|
||||
|
||||
### 追加格式
|
||||
```markdown
|
||||
---
|
||||
### 追加格式
|
||||
```markdown
|
||||
---
|
||||
|
||||
## 🔧 [章节标题]
|
||||
|
||||
@@ -250,18 +230,18 @@ ViGent2/Docs/
|
||||
- ✅ 修复了 xxx
|
||||
```
|
||||
|
||||
### 快速修复格式
|
||||
```markdown
|
||||
## 🐛 [Bug 简述] (HH:MM)
|
||||
### 快速修复格式
|
||||
```markdown
|
||||
## 🐛 [Bug 简述] (HH:MM)
|
||||
|
||||
**问题**:一句话描述
|
||||
**修复**:修改了 `文件名` 中的 xxx
|
||||
**状态**:✅ 已修复 / 🔄 待验证
|
||||
```
|
||||
|
||||
### ⚠️ 注意
|
||||
- **DayN.md 文件开头禁止使用 `---`**,避免被解析为 Front Matter。
|
||||
- 分隔线只用于章节之间,不作为文件第一行。
|
||||
**状态**:✅ 已修复 / 🔄 待验证
|
||||
```
|
||||
|
||||
### ⚠️ 注意
|
||||
- **DayN.md 文件开头禁止使用 `---`**,避免被解析为 Front Matter。
|
||||
- 分隔线只用于章节之间,不作为文件第一行。
|
||||
|
||||
---
|
||||
|
||||
@@ -316,4 +296,4 @@ ViGent2/Docs/
|
||||
|
||||
---
|
||||
|
||||
**最后更新**:2026-02-04
|
||||
**最后更新**:2026-02-08
|
||||
|
||||
@@ -2,22 +2,65 @@
|
||||
|
||||
## 目录结构
|
||||
|
||||
采用轻量 FSD(Feature-Sliced Design)结构:
|
||||
|
||||
```
|
||||
frontend/src/
|
||||
├── app/ # Next.js App Router 页面
|
||||
│ ├── page.tsx # 首页(视频生成)
|
||||
│ ├── publish/ # 发布页面
|
||||
│ ├── admin/ # 管理员页面
|
||||
│ ├── login/ # 登录页面
|
||||
│ └── register/ # 注册页面
|
||||
├── components/ # 可复用组件
|
||||
│ ├── home/ # 首页拆分组件
|
||||
│ └── ...
|
||||
├── lib/ # 公共工具函数
|
||||
│ ├── axios.ts # Axios 实例(含 401/403 拦截器)
|
||||
│ ├── auth.ts # 认证相关函数
|
||||
│ └── media.ts # API Base / URL / 日期等通用工具
|
||||
└── proxy.ts # 路由代理(原 middleware)
|
||||
├── app/ # Next.js App Router 页面入口
|
||||
│ ├── page.tsx # 首页(视频生成)
|
||||
│ ├── publish/ # 发布管理页
|
||||
│ ├── admin/ # 管理员页面
|
||||
│ ├── login/ # 登录
|
||||
│ └── register/ # 注册
|
||||
├── features/ # 功能模块(按业务拆分)
|
||||
│ ├── home/
|
||||
│ │ ├── model/ # 业务逻辑 hooks
|
||||
│ │ │ ├── useHomeController.ts # 主控制器
|
||||
│ │ │ ├── useHomePersistence.ts # 持久化管理
|
||||
│ │ │ ├── useBgm.ts
|
||||
│ │ │ ├── useGeneratedVideos.ts
|
||||
│ │ │ ├── useMaterials.ts
|
||||
│ │ │ ├── useMediaPlayers.ts
|
||||
│ │ │ ├── useRefAudios.ts
|
||||
│ │ │ └── useTitleSubtitleStyles.ts
|
||||
│ │ └── ui/ # UI 组件(纯 props + 回调)
|
||||
│ │ ├── HomePage.tsx
|
||||
│ │ ├── HomeHeader.tsx
|
||||
│ │ ├── MaterialSelector.tsx
|
||||
│ │ ├── ScriptEditor.tsx
|
||||
│ │ ├── TitleSubtitlePanel.tsx
|
||||
│ │ ├── FloatingStylePreview.tsx
|
||||
│ │ ├── VoiceSelector.tsx
|
||||
│ │ ├── RefAudioPanel.tsx
|
||||
│ │ ├── BgmPanel.tsx
|
||||
│ │ ├── GenerateActionBar.tsx
|
||||
│ │ ├── PreviewPanel.tsx
|
||||
│ │ └── HistoryList.tsx
|
||||
│ └── publish/
|
||||
│ ├── model/
|
||||
│ │ └── usePublishController.ts
|
||||
│ └── ui/
|
||||
│ └── PublishPage.tsx
|
||||
├── shared/ # 跨功能共享
|
||||
│ ├── api/
|
||||
│ │ ├── axios.ts # Axios 实例(含 401/403 拦截器)
|
||||
│ │ └── types.ts # 统一响应类型
|
||||
│ ├── lib/
|
||||
│ │ ├── media.ts # API Base / URL / 日期等通用工具
|
||||
│ │ ├── auth.ts # 认证相关函数
|
||||
│ │ └── title.ts # 标题输入处理
|
||||
│ ├── hooks/
|
||||
│ │ ├── useTitleInput.ts
|
||||
│ │ └── usePublishPrefetch.ts
|
||||
│ ├── types/
|
||||
│ │ ├── user.ts # User 类型定义
|
||||
│ │ └── publish.ts # 发布相关类型
|
||||
│ └── contexts/ # 已迁移的 Context
|
||||
├── contexts/ # 全局 Context(Auth、Task)
|
||||
├── components/ # 遗留通用组件
|
||||
│ ├── VideoPreviewModal.tsx
|
||||
│ └── ScriptExtractionModal.tsx
|
||||
└── proxy.ts # Next.js middleware(路由保护)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -228,10 +271,21 @@ import { formatDate } from '@/shared/lib/media';
|
||||
|
||||
## 轻量 FSD 结构
|
||||
|
||||
- `app/`:页面入口,保持轻量
|
||||
- `features/*/model`:业务逻辑与状态 (hooks)
|
||||
- `features/*/ui`:功能 UI 组件
|
||||
- `shared/`:通用工具、通用 hooks、API 实例
|
||||
- `app/`:页面入口,保持轻量,只做组合与布局
|
||||
- `features/*/model`:业务逻辑与状态(Controller Hook + 子 Hook)
|
||||
- `features/*/ui`:功能 UI 组件(纯 props + 回调,不直接发 API)
|
||||
- `shared/api`:Axios 实例与统一响应类型
|
||||
- `shared/lib`:通用工具函数(media.ts / auth.ts / title.ts)
|
||||
- `shared/hooks`:跨功能通用 hooks
|
||||
- `shared/types`:跨功能实体类型(User / PublishVideo 等)
|
||||
- `contexts/`:全局 Context(AuthContext / TaskContext)
|
||||
- `components/`:遗留通用组件(VideoPreviewModal 等)
|
||||
|
||||
## 类型定义规范
|
||||
|
||||
- 通用实体类型(如 User, Account, Video)统一放置在 `src/shared/types/`。
|
||||
- 特定业务类型放在 feature 目录下的 types.ts 或 model 中。
|
||||
- **禁止**在多个地方重复定义 User 接口,统一引用 `import { User } from '@/shared/types/user';`。
|
||||
|
||||
---
|
||||
|
||||
@@ -250,6 +304,7 @@ import { formatDate } from '@/shared/lib/media';
|
||||
- **恢复先于保存**:恢复完成前禁止写入(`isRestored` 保护)。
|
||||
- 避免默认值覆盖用户选择(优先读取已保存值)。
|
||||
- 优先使用 `useHomePersistence` 集中管理恢复/保存,页面内避免分散的 localStorage 读写。
|
||||
- **禁止使用签名 URL 作为持久化标识**:Supabase Storage 签名 URL 每次请求都变化,必须使用后端返回的稳定 `id` 字段。
|
||||
- 如需新增持久化字段,必须加入恢复与保存逻辑,并更新本节。
|
||||
|
||||
---
|
||||
@@ -264,6 +319,14 @@ import { formatDate } from '@/shared/lib/media';
|
||||
|
||||
---
|
||||
|
||||
## 发布页交互规则
|
||||
|
||||
- 发布按钮在未选择任何平台时禁用
|
||||
- 仅保留"立即发布",不再提供定时发布 UI/参数
|
||||
- **作品选择持久化**:使用 `video.id`(稳定标识)而非 `video.path`(签名 URL)进行选择、比较和 localStorage 存储。发布时根据 `id` 查找对应 `path` 发送请求。
|
||||
|
||||
---
|
||||
|
||||
## 新增页面 Checklist
|
||||
|
||||
1. [ ] 导入 `import api from '@/shared/api/axios'`
|
||||
|
||||
@@ -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 (预设音色) / 声音克隆 (自定义音色) 切换。
|
||||
|
||||
@@ -52,6 +52,9 @@ cd /home/rongye/ProgramFiles/ViGent2/remotion
|
||||
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 预编译渲染脚本 (生产环境必须)
|
||||
npm run build:render
|
||||
```
|
||||
|
||||
### 步骤 3: 重启后端服务
|
||||
|
||||
@@ -1,416 +0,0 @@
|
||||
# 数字人口播视频生成系统 - 实现计划
|
||||
|
||||
## 项目目标
|
||||
|
||||
构建一个开源的数字人口播视频生成系统,功能包括:
|
||||
- 上传静态人物视频 → 生成口播视频(唇形同步)
|
||||
- TTS 配音或声音克隆
|
||||
- 字幕自动生成与渲染
|
||||
- AI 自动生成标题与标签
|
||||
- 一键发布到多个社交平台
|
||||
|
||||
---
|
||||
|
||||
## 技术架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 前端 (Next.js) │
|
||||
│ 素材管理 | 视频生成 | 发布管理 | 任务状态 │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
│ REST API
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 后端 (FastAPI) │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ 异步任务队列 (asyncio) │
|
||||
│ ├── 视频生成任务 │
|
||||
│ ├── TTS 配音任务 │
|
||||
│ └── 自动发布任务 │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│LatentSync│ │ FFmpeg │ │Playwright│
|
||||
│ 唇形同步 │ │ 视频合成 │ │ 自动发布 │
|
||||
└──────────┘ └──────────┘ └──────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术选型
|
||||
|
||||
| 模块 | 技术选择 | 备选方案 |
|
||||
|------|----------|----------|
|
||||
| **前端框架** | Next.js 16 | Vue 3 + Vite |
|
||||
| **UI 组件库** | TailwindCSS (自定义组件) | Ant Design |
|
||||
| **后端框架** | FastAPI | Flask |
|
||||
| **任务队列** | FastAPI BackgroundTasks (asyncio) | Celery + Redis |
|
||||
| **唇形同步** | **LatentSync 1.6** | MuseTalk / Wav2Lip |
|
||||
| **TTS 配音** | EdgeTTS | CosyVoice |
|
||||
| **声音克隆** | **Qwen3-TTS 1.7B** ✅ | GPT-SoVITS |
|
||||
| **视频处理** | FFmpeg | MoviePy |
|
||||
| **自动发布** | Playwright | 自行实现 |
|
||||
| **数据库** | Supabase (PostgreSQL) | MySQL |
|
||||
| **文件存储** | Supabase Storage | 阿里云 OSS |
|
||||
|
||||
> **修正 (18:10)**:当前实现采用 Next.js 16、FastAPI BackgroundTasks 与 Supabase Storage/Auth,自动发布基于 Playwright。
|
||||
|
||||
---
|
||||
|
||||
## ✅ 现状补充 (Day 17)
|
||||
|
||||
- 前端已拆分为组件化结构(`features/home/ui/`),主页面逻辑集中。
|
||||
- 通用工具 `media.ts` 统一处理 API Base / 资源 URL / 日期格式化。
|
||||
- 作品预览弹窗统一样式,并支持素材/发布预览复用。
|
||||
- 标题/字幕预览按素材分辨率缩放,效果更接近成片。
|
||||
|
||||
---
|
||||
|
||||
## 分阶段实施计划
|
||||
|
||||
### 阶段一:核心功能验证 (MVP)
|
||||
|
||||
> **目标**:验证 LatentSync + EdgeTTS 效果,跑通端到端流程
|
||||
|
||||
#### 1.1 环境搭建
|
||||
|
||||
参考 `models/LatentSync/DEPLOY.md` 完成 LatentSync 环境与权重部署。
|
||||
|
||||
#### 1.2 集成 EdgeTTS
|
||||
|
||||
```python
|
||||
# tts_engine.py
|
||||
import edge_tts
|
||||
import asyncio
|
||||
|
||||
async def text_to_speech(text: str, voice: str = "zh-CN-YunxiNeural", output_path: str = "output.mp3"):
|
||||
communicate = edge_tts.Communicate(text, voice)
|
||||
await communicate.save(output_path)
|
||||
return output_path
|
||||
```
|
||||
|
||||
#### 1.3 端到端测试脚本
|
||||
|
||||
```python
|
||||
# test_pipeline.py
|
||||
"""
|
||||
1. 文案 → EdgeTTS → 音频
|
||||
2. 静态视频 + 音频 → LatentSync → 口播视频
|
||||
3. 添加字幕 → FFmpeg → 最终视频
|
||||
"""
|
||||
```
|
||||
|
||||
#### 1.4 验证标准
|
||||
- [ ] LatentSync 能正常推理
|
||||
- [ ] 唇形与音频同步率 > 90%
|
||||
- [ ] 单个视频生成时间 < 2 分钟
|
||||
|
||||
---
|
||||
|
||||
### 阶段二:后端 API 开发
|
||||
|
||||
> **目标**:将核心功能封装为 API,支持异步任务
|
||||
|
||||
#### 2.1 项目结构
|
||||
|
||||
```
|
||||
backend/
|
||||
├── app/
|
||||
│ ├── main.py # FastAPI 入口
|
||||
│ ├── api/
|
||||
│ │ ├── videos.py # 视频生成 API
|
||||
│ │ ├── materials.py # 素材管理 API
|
||||
│ │ └── publish.py # 发布管理 API
|
||||
│ ├── services/
|
||||
│ │ ├── tts_service.py # TTS 服务
|
||||
│ │ ├── lipsync_service.py # 唇形同步服务
|
||||
│ │ └── video_service.py # 视频合成服务
|
||||
│ ├── tasks/
|
||||
│ │ └── celery_tasks.py # Celery 异步任务
|
||||
│ ├── models/
|
||||
│ │ └── schemas.py # Pydantic 模型
|
||||
│ └── core/
|
||||
│ └── config.py # 配置管理
|
||||
├── requirements.txt
|
||||
└── docker-compose.yml # Redis + API
|
||||
```
|
||||
|
||||
#### 2.2 核心 API 设计
|
||||
|
||||
| 端点 | 方法 | 功能 |
|
||||
|------|------|------|
|
||||
| `/api/materials` | POST | 上传视频素材 | ✅ |
|
||||
| `/api/materials` | GET | 获取素材列表 | ✅ |
|
||||
| `/api/videos/generate` | POST | 创建视频生成任务 | ✅ |
|
||||
| `/api/videos/tasks/{id}` | GET | 查询任务状态 | ✅ |
|
||||
| `/api/videos/generated` | GET | 获取历史作品列表 | ✅ |
|
||||
| `/api/publish` | POST | 发布到社交平台 | ✅ |
|
||||
|
||||
#### 2.3 BackgroundTasks 任务定义
|
||||
|
||||
```python
|
||||
# app/api/videos.py
|
||||
background_tasks.add_task(_process_video_generation, task_id, req, user_id)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 阶段三:前端 Web UI
|
||||
|
||||
> **目标**:提供用户友好的操作界面
|
||||
|
||||
#### 3.1 页面设计
|
||||
|
||||
| 页面 | 功能 |
|
||||
|------|------|
|
||||
| **素材库** | 上传/管理多场景视频素材 |
|
||||
| **生成视频** | 输入文案、选择素材、生成预览 |
|
||||
| **任务中心** | 查看生成进度、下载视频 |
|
||||
| **发布管理** | 绑定平台、一键发布、定时发布 |
|
||||
|
||||
#### 3.2 技术实现
|
||||
|
||||
```bash
|
||||
# 创建 Next.js 项目
|
||||
npx create-next-app@latest frontend --typescript --tailwind --app
|
||||
|
||||
# 安装依赖
|
||||
cd frontend
|
||||
npm install axios swr
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 阶段四:社交媒体发布
|
||||
|
||||
> **目标**:集成 social-auto-upload,支持多平台发布
|
||||
|
||||
#### 4.1 复用 social-auto-upload
|
||||
|
||||
```bash
|
||||
# 复制模块
|
||||
cp -r SuperIPAgent/social-auto-upload backend/social_upload
|
||||
```
|
||||
|
||||
#### 4.2 Cookie 管理
|
||||
|
||||
```python
|
||||
# 用户通过浏览器登录 → 保存 Cookie → 后续自动发布
|
||||
```
|
||||
|
||||
#### 4.3 支持平台
|
||||
- 抖音
|
||||
- 小红书
|
||||
- 微信视频号
|
||||
- 快手
|
||||
|
||||
---
|
||||
|
||||
### 阶段五:优化与扩展
|
||||
|
||||
| 功能 | 实现方式 |
|
||||
|------|----------|
|
||||
| **声音克隆** | 集成 GPT-SoVITS,用自己的声音 |
|
||||
| **AI 标题/标签生成** | 调用大模型 API 自动生成标题与标签 ✅ |
|
||||
| **批量生成** | 上传 Excel/CSV,批量生成视频 |
|
||||
| **字幕编辑器** | 可视化调整字幕样式、位置 |
|
||||
| **Docker 部署** | 一键部署到云服务器 | ✅ |
|
||||
|
||||
---
|
||||
|
||||
### 阶段六:MuseTalk 服务器部署 (Day 2-3) ✅
|
||||
|
||||
> **目标**:在双显卡服务器上部署 MuseTalk 环境
|
||||
|
||||
- [x] Conda 环境配置 (musetalk)
|
||||
- [x] 模型权重下载 (~7GB)
|
||||
- [x] Subprocess 调用方式实现
|
||||
- [x] 健康检查功能
|
||||
|
||||
### 阶段七:MuseTalk 完整修复 (Day 4) ✅
|
||||
|
||||
> **目标**:解决推理脚本的各种兼容性问题
|
||||
|
||||
- [x] 权重检测路径修复 (软链接)
|
||||
- [x] 音视频长度不匹配修复
|
||||
- [x] 推理脚本错误日志增强
|
||||
- [x] 视频合成 MP4 生成验证
|
||||
|
||||
### 阶段八:前端功能增强 (Day 5) ✅
|
||||
|
||||
> **目标**:提升用户体验
|
||||
|
||||
- [x] Web 视频上传功能
|
||||
- [x] 上传进度显示
|
||||
- [x] 自动刷新素材列表
|
||||
|
||||
### 阶段九:唇形同步模型升级 (Day 6) ✅
|
||||
|
||||
> **目标**:从 MuseTalk 迁移到 LatentSync 1.6
|
||||
|
||||
- [x] MuseTalk → LatentSync 1.6 迁移
|
||||
- [x] 后端代码适配 (config.py, lipsync_service.py)
|
||||
- [x] Latent Diffusion 架构 (512x512 高清)
|
||||
- [x] 服务器端到端验证
|
||||
|
||||
### 阶段十:性能优化 (Day 6) ✅
|
||||
|
||||
> **目标**:提升系统响应速度和稳定性
|
||||
|
||||
- [x] 视频预压缩优化 (1080p → 720p 自动适配)
|
||||
- [x] 进度更新细化 (实时反馈)
|
||||
- [x] **常驻模型服务** (Persistent Server, 0s 加载)
|
||||
- [x] **GPU 并发控制** (串行队列防崩溃)
|
||||
|
||||
### 阶段十一:社交媒体发布完善 (Day 7) ✅
|
||||
|
||||
> **目标**:实现全自动扫码登录和多平台发布
|
||||
|
||||
- [x] QR码自动登录 (Playwright headless + Stealth)
|
||||
- [x] 多平台上传器架构 (B站/抖音/小红书)
|
||||
- [x] Cookie 自动管理
|
||||
- [x] 定时发布功能
|
||||
|
||||
### 阶段十二:用户体验优化 (Day 8) ✅
|
||||
|
||||
> **目标**:提升文件管理和历史记录功能
|
||||
|
||||
- [x] 文件名保留 (时间戳前缀 + 原始名称)
|
||||
- [x] 视频持久化 (历史视频列表 API)
|
||||
- [x] 素材/视频删除功能
|
||||
|
||||
### 阶段十三:发布模块优化 (Day 9) ✅
|
||||
|
||||
> **目标**:代码质量优化 + 发布功能验证
|
||||
|
||||
- [x] B站/抖音登录+发布验证通过
|
||||
- [x] 资源清理保障 (try-finally)
|
||||
- [x] 超时保护 (消除无限循环)
|
||||
- [x] 完整类型提示
|
||||
|
||||
### 阶段十四:用户认证系统 (Day 9) ✅
|
||||
|
||||
> **目标**:实现安全、隔离的多用户认证体系
|
||||
|
||||
- [x] Supabase 云数据库集成 (本地自托管)
|
||||
- [x] JWT + HttpOnly Cookie 认证架构
|
||||
- [x] 用户表与权限表设计 (RLS 准备)
|
||||
- [x] 认证部署文档 (Docs/SUPABASE_DEPLOY.md)
|
||||
|
||||
### 阶段十五:部署稳定性优化 (Day 9) ✅
|
||||
|
||||
> **目标**:确保生产环境服务长期稳定
|
||||
|
||||
- [x] 依赖冲突修复 (bcrypt)
|
||||
- [x] 前端构建修复 (Production Build)
|
||||
- [x] PM2 进程守护配置
|
||||
- [x] 部署手册更新 (Docs/DEPLOY_MANUAL.md)
|
||||
|
||||
### 阶段十六:HTTPS 全栈部署 (Day 10) ✅
|
||||
|
||||
> **目标**:实现安全的公网 HTTPS 访问
|
||||
|
||||
- [x] 阿里云 Nginx 反向代理配置
|
||||
- [x] Let's Encrypt SSL 证书集成
|
||||
- [x] Supabase 自托管部署 (Docker)
|
||||
- [x] 端口冲突解决 (3003/8008/8444)
|
||||
- [x] Basic Auth 管理后台保护
|
||||
|
||||
### 阶段十七:声音克隆功能集成 (Day 13) ✅
|
||||
|
||||
> **目标**:实现用户自定义声音克隆能力
|
||||
|
||||
- [x] Qwen3-TTS HTTP 服务 (独立 FastAPI,端口 8009)
|
||||
- [x] 声音克隆服务封装 (voice_clone_service.py)
|
||||
- [x] 参考音频管理 API (上传/列表/删除)
|
||||
- [x] 前端 TTS 模式选择 UI
|
||||
- [x] Supabase ref-audios Bucket 配置
|
||||
- [x] 端到端测试验证
|
||||
|
||||
### 阶段十八:手机号登录迁移 (Day 15) ✅
|
||||
|
||||
> **目标**:将认证系统从邮箱迁移到手机号
|
||||
|
||||
- [x] 数据库 Schema 迁移 (email → phone)
|
||||
- [x] 后端 API 适配 (auth.py/admin.py)
|
||||
- [x] 11位手机号校验 (正则验证)
|
||||
- [x] 修改密码功能 (/api/auth/change-password)
|
||||
- [x] 账户设置下拉菜单 (修改密码 + 有效期显示 + 退出)
|
||||
- [x] 前端登录/注册页面更新
|
||||
- [x] 数据库迁移脚本 (migrate_to_phone.sql)
|
||||
|
||||
### 阶段十九:深度性能优化与服务守护 (Day 16) ✅
|
||||
|
||||
> **目标**:提升系统响应速度与服务稳定性
|
||||
|
||||
- [x] Flash Attention 2 集成 (Qwen3-TTS 加速 5x)
|
||||
- [x] LatentSync 性能调优 (OMP 线程限制 + 原生 Flash Attn)
|
||||
- [x] Watchdog 服务守护 (自动重启僵死服务)
|
||||
- [x] 文档体系更新 (部署手册与运维指南)
|
||||
|
||||
---
|
||||
|
||||
## 项目目录结构 (最终)
|
||||
|
||||
---
|
||||
|
||||
## 开发时间估算
|
||||
|
||||
| 阶段 | 预计时间 | 说明 |
|
||||
|------|----------|------|
|
||||
| 阶段一 | 2-3 天 | 环境搭建 + 效果验证 |
|
||||
| 阶段二 | 3-4 天 | 后端 API 开发 |
|
||||
| 阶段三 | 3-4 天 | 前端 UI 开发 |
|
||||
| 阶段四 | 2 天 | 社交发布集成 |
|
||||
| 阶段五 | 按需 | 持续优化 |
|
||||
|
||||
**总计**:约 10-13 天可完成 MVP
|
||||
|
||||
---
|
||||
|
||||
## 验证计划
|
||||
|
||||
### 阶段一验证
|
||||
1. 运行 `test_pipeline.py` 脚本
|
||||
2. 检查生成视频的唇形同步效果
|
||||
3. 确认音画同步
|
||||
|
||||
### 阶段二验证
|
||||
1. 使用 Postman/curl 测试所有 API 端点
|
||||
2. 验证任务队列正常工作
|
||||
3. 检查视频生成完整流程
|
||||
|
||||
### 阶段三验证
|
||||
1. 在浏览器中完成完整操作流程
|
||||
2. 验证上传、生成、下载功能
|
||||
3. 检查响应式布局
|
||||
|
||||
### 阶段四验证
|
||||
1. 发布一个测试视频到抖音
|
||||
2. 验证定时发布功能
|
||||
3. 检查发布状态同步
|
||||
|
||||
---
|
||||
|
||||
## 硬件要求
|
||||
|
||||
| 配置 | 最低要求 | 推荐配置 |
|
||||
|------|----------|----------|
|
||||
| **GPU** | NVIDIA GTX 1060 6GB | RTX 3060 12GB+ |
|
||||
| **内存** | 16GB | 32GB |
|
||||
| **存储** | 100GB SSD | 500GB SSD |
|
||||
| **CUDA** | 11.7+ | 12.0+ |
|
||||
|
||||
---
|
||||
|
||||
## 下一步行动
|
||||
|
||||
1. **确认你的 GPU 配置** - MuseTalk 需要 NVIDIA GPU
|
||||
2. **选择开发起点** - 从阶段一开始验证效果
|
||||
3. **确定项目位置** - 在哪个目录创建项目
|
||||
|
||||
---
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 请确认以上计划是否符合你的需求,有任何需要调整的地方请告诉我。
|
||||
@@ -1,8 +1,8 @@
|
||||
# ViGent2 开发任务清单 (Task Log)
|
||||
|
||||
**项目**: ViGent2 数字人口播视频生成系统
|
||||
**进度**: 100% (Day 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 Hook,Page 仅组合渲染。
|
||||
- [x] **通用工具抽取**: `media.ts` 统一 API Base / URL / 日期格式化。
|
||||
- [x] **交互优化**: 选择项持久化、列表内定位、刷新回顶部、最新作品优先预览。
|
||||
- [x] **发布页改造**: 作品列表卡片化 + 搜索 + 预览弹窗。
|
||||
- [x] **预览体验**: 预览弹窗统一头部样式与提示文案。
|
||||
- [x] **预览一致性**: 标题/字幕预览按素材分辨率缩放。
|
||||
- [x] **标题同步与限制**: 片头标题同步发布标题,输入法合成态兼容,限制 15 字。
|
||||
- [x] **样式默认与持久化**: 默认样式与字号调整,刷新保留用户选择。
|
||||
- [x] **性能微优化**: 列表渲染优化 + 并行请求 + localStorage 防抖。
|
||||
- [x] **资源能力**: 字体/BGM 资源库 + `/api/assets` 接入。
|
||||
- [x] **音频与字幕修复**: BGM 混音稳定性与字幕断句优化。
|
||||
- [x] **持久化修复**: 接入 `useHomePersistence`,恢复 `isRestored` 逻辑并通过构建。
|
||||
- [x] **预览与选择修复**: 发布预览兼容签名 URL,音频试听路径解析,素材/BGM 回退有效项。
|
||||
- [x] **体验细节优化**: 录音预览 URL 回收,预览弹窗滚动恢复,全局任务提示挂载。
|
||||
|
||||
### Day 16: 深度性能优化
|
||||
- [x] **Qwen-TTS 加速**: 集成 Flash Attention 2,模型加载速度提升至 8.9s。
|
||||
- [x] **服务守护**: 开发 `Watchdog` 看门狗机制,自动监控并重启僵死服务。
|
||||
- [x] **LatentSync 性能确认**: 验证 DeepCache + 原生 Flash Attn 生效。
|
||||
- [x] **文档重构**: 全面更新 README、部署手册及后端文档。
|
||||
### Day 21: 缺陷修复与持久化回归治理 (Current)
|
||||
- [x] **Remotion 崩溃容错**: 渲染进程 SIGABRT 退出时检查输出文件,避免误判失败导致标题/字幕丢失。
|
||||
- [x] **首页作品选择持久化**: 修复 `fetchGeneratedVideos` 无条件覆盖恢复值的问题,新增 `preferVideoId` 参数控制选中逻辑。
|
||||
- [x] **发布页作品选择持久化**: 根因为签名 URL 不稳定,全面改用 `video.id` 替代 `path` 进行选择/持久化/比较。
|
||||
- [x] **预取缓存补全**: 首页预取发布页数据时加入 `id` 字段,确保缓存数据可用于持久化匹配。
|
||||
|
||||
### Day 20: 代码质量与安全优化
|
||||
- [x] **功能性修复**: LatentSync 回退逻辑、任务状态接口认证、User 类型统一。
|
||||
- [x] **性能优化**: N+1 查询修复、视频上传流式处理、httpx 异步替换、GLM 异步包装。
|
||||
- [x] **安全修复**: 硬编码 Cookie 配置化、日志敏感信息脱敏、ffprobe 安全调用、CORS 配置化。
|
||||
- [x] **配置优化**: 存储路径环境变量化、Remotion 预编译加速、LatentSync 绝对路径。
|
||||
- [x] **文档更新**: 更新 Doc_Rules.md 清单,补齐后端与部署文档;更新 SUBTITLE_DEPLOY.md, FRONTEND_DEV.md, implementation_plan.md。
|
||||
- [x] **缺陷修复**: 修复 Remotion 路径解析、发布页持久化竞态、首页选中回归、素材闭包陷阱。
|
||||
|
||||
### Day 19: 自动发布稳定性与发布体验优化 🚀
|
||||
- [x] **抖音发布稳定性**: 上传入口、封面流程、发布重试、登录失效识别与网络失败快速返回全面增强。
|
||||
- [x] **视频号发布修复**: 标题+标签统一写入“视频描述”,`post_create` 成功信号快速判定,超时改为失败返回。
|
||||
- [x] **成功截图闭环**: 抖音/视频号发布成功截图接入前端,支持用户隔离存储与鉴权访问。
|
||||
- [x] **截图观感优化**: 成功截图延后 3 秒并改为视口截图,修复“截图内容仅占 1/3”问题。
|
||||
- [x] **调试能力开关化**: 新增视频号录屏配置,默认可按环境变量开关,失败排障更直观。
|
||||
- [x] **启动链路统一**: 合并为 `run_backend.sh`(xvfb + headful),统一端口 `8006`,减少多进程混淆。
|
||||
- [x] **发布页防误操作**: 发布中按钮提示“请勿刷新或关闭网页”,并启用刷新/关页二次确认拦截。
|
||||
- [ ] **后续优化**: 发布任务状态恢复机制(任务化 + 状态持久化 + 前端轮询恢复)。
|
||||
|
||||
### Day 18: 后端模块化与规范完善
|
||||
- [x] **模块化迁移**: 路由透传 `modules/*`,业务逻辑集中到 service/workflow。
|
||||
- [x] **视频生成拆分**: 生成流程下沉 workflow,任务状态统一 TaskStore。
|
||||
- [x] **Redis 任务存储**: Redis 优先,不可用自动回退内存。
|
||||
- [x] **仓储层抽离**: Supabase 访问统一 `repositories/*`,deps/auth/admin 全面替换。
|
||||
- [x] **响应规范**: 统一 `success/message/data/code` + 全局异常处理。
|
||||
- [x] **素材重命名**: 新增重命名接口与 Storage `move_file`。
|
||||
- [x] **平台顺序调整**: 抖音/微信视频号/B站/小红书,移除快手。
|
||||
- [x] **后端开发规范**: 新增 `BACKEND_DEV.md`,README 同步模块化结构。
|
||||
- [x] **发布管理体验**: 首页预取路由 + 发布页骨架与缓存,进入更快。
|
||||
- [x] **素材加载优化**: 素材列表并发签名 URL,骨架数量动态。
|
||||
- [x] **预览加载优化**: `preload="metadata"` + hover 预取。
|
||||
|
||||
### Day 17: 前端重构与体验优化
|
||||
- [x] **UI 组件拆分**: 首页拆分为独立组件,降低 `page.tsx` 复杂度。
|
||||
- [x] **轻量 FSD 迁移**: `app` 页面轻量化,逻辑集中到 `features/*/model`,通用能力下沉 `shared/*`。
|
||||
- [x] **Controller Hooks**: Home/Publish 页面逻辑集中到 Controller Hook,Page 仅组合渲染。
|
||||
- [x] **通用工具抽取**: `media.ts` 统一 API Base / URL / 日期格式化。
|
||||
- [x] **交互优化**: 选择项持久化、列表内定位、刷新回顶部、最新作品优先预览。
|
||||
- [x] **发布页改造**: 作品列表卡片化 + 搜索 + 预览弹窗。
|
||||
- [x] **预览体验**: 预览弹窗统一头部样式与提示文案。
|
||||
- [x] **预览一致性**: 标题/字幕预览按素材分辨率缩放。
|
||||
- [x] **标题同步与限制**: 片头标题同步发布标题,输入法合成态兼容,限制 15 字。
|
||||
- [x] **样式默认与持久化**: 默认样式与字号调整,刷新保留用户选择。
|
||||
- [x] **性能微优化**: 列表渲染优化 + 并行请求 + localStorage 防抖。
|
||||
- [x] **资源能力**: 字体/BGM 资源库 + `/api/assets` 接入。
|
||||
- [x] **音频与字幕修复**: BGM 混音稳定性与字幕断句优化。
|
||||
- [x] **持久化修复**: 接入 `useHomePersistence`,恢复 `isRestored` 逻辑并通过构建。
|
||||
- [x] **预览与选择修复**: 发布预览兼容签名 URL,音频试听路径解析,素材/BGM 回退有效项。
|
||||
- [x] **体验细节优化**: 录音预览 URL 回收,预览弹窗滚动恢复,全局任务提示挂载。
|
||||
|
||||
### Day 16: 深度性能优化
|
||||
- [x] **Qwen-TTS 加速**: 集成 Flash Attention 2,模型加载速度提升至 8.9s。
|
||||
- [x] **服务守护**: 开发 `Watchdog` 看门狗机制,自动监控并重启僵死服务。
|
||||
- [x] **LatentSync 性能确认**: 验证 DeepCache + 原生 Flash Attn 生效。
|
||||
- [x] **文档重构**: 全面更新 README、部署手册及后端文档。
|
||||
|
||||
### Day 15: 手机号认证迁移
|
||||
- [x] **认证系统升级**: 从邮箱迁移至 11 位手机号注册/登录。
|
||||
@@ -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)
|
||||
|
||||
46
README.md
46
README.md
@@ -15,22 +15,24 @@
|
||||
|
||||
## ✨ 功能特性
|
||||
|
||||
### 核心能力
|
||||
- 🎬 **高清唇形同步** - LatentSync 1.6 驱动,512×512 高分辨率 Latent Diffusion 模型。
|
||||
- 🎙️ **多模态配音** - 支持 **EdgeTTS** (微软超自然语音) 和 **Qwen3-TTS** (3秒极速声音克隆)。
|
||||
- 📝 **智能字幕** - 集成 faster-whisper + Remotion,自动生成逐字高亮 (卡拉OK效果) 字幕。
|
||||
- 🎨 **样式预设** - 标题/字幕样式选择 + 预览 + 字号调节,支持自定义字体库。
|
||||
- 🖼️ **作品预览一致性** - 标题/字幕预览按素材分辨率缩放,效果更接近成片。
|
||||
- 💾 **用户偏好持久化** - 首页状态统一恢复/保存,刷新后延续上次配置。
|
||||
- 🎵 **背景音乐** - 试听 + 音量控制 + 混音,保持配音音量稳定。
|
||||
- 🤖 **AI 辅助创作** - 内置 GLM-4.7-Flash,支持 B站/抖音链接文案提取、AI 洗稿、标题/标签自动生成。
|
||||
### 核心能力
|
||||
- 🎬 **高清唇形同步** - LatentSync 1.6 驱动,512×512 高分辨率 Latent Diffusion 模型。
|
||||
- 🎙️ **多模态配音** - 支持 **EdgeTTS** (微软超自然语音) 和 **Qwen3-TTS** (3秒极速声音克隆)。
|
||||
- 📝 **智能字幕** - 集成 faster-whisper + Remotion,自动生成逐字高亮 (卡拉OK效果) 字幕。
|
||||
- 🎨 **样式预设** - 标题/字幕样式选择 + 预览 + 字号调节,支持自定义字体库。
|
||||
- 🖼️ **作品预览一致性** - 标题/字幕预览按素材分辨率缩放,效果更接近成片。
|
||||
- 💾 **用户偏好持久化** - 首页状态统一恢复/保存,刷新后延续上次配置。
|
||||
- 🎵 **背景音乐** - 试听 + 音量控制 + 混音,保持配音音量稳定。
|
||||
- 🤖 **AI 辅助创作** - 内置 GLM-4.7-Flash,支持 B站/抖音链接文案提取、AI 洗稿、标题/标签自动生成。
|
||||
|
||||
### 平台化功能
|
||||
- 📱 **全自动发布** - 支持抖音/B站/小红书定时发布,微信视频号预留配置;扫码登录 + Cookie 持久化。
|
||||
- 🖥️ **发布管理预览** - 支持签名 URL / 相对路径作品预览,确保可直接播放。
|
||||
- 🔐 **认证与隔离** - 基于 Supabase 的用户隔离,支持手机号注册/登录、密码管理。
|
||||
### 平台化功能
|
||||
- 📱 **全自动发布** - 支持抖音/微信视频号/B站/小红书立即发布;扫码登录 + Cookie 持久化。
|
||||
- 🖥️ **发布管理预览** - 支持签名 URL / 相对路径作品预览,确保可直接播放。
|
||||
- 📸 **发布结果可视化** - 抖音/微信视频号发布成功后返回截图,发布页结果卡片可直接查看。
|
||||
- 🛡️ **发布防误操作** - 发布进行中自动提示“请勿刷新或关闭网页”,并拦截刷新/关页二次确认。
|
||||
- 🔐 **认证与隔离** - 基于 Supabase 的用户隔离,支持手机号注册/登录、密码管理。
|
||||
- 🛡️ **服务守护** - 内置 Watchdog 看门狗机制,自动监控并重启僵死服务,确保 7x24h 稳定运行。
|
||||
- 🚀 **性能优化** - 视频预压缩、模型常驻服务(近实时加载)、双 GPU 流水线并发。
|
||||
- 🚀 **性能优化** - 视频预压缩、模型常驻服务(近实时加载)、双 GPU 流水线并发。
|
||||
|
||||
---
|
||||
|
||||
@@ -38,7 +40,7 @@
|
||||
|
||||
| 领域 | 核心技术 | 说明 |
|
||||
|------|----------|------|
|
||||
| **前端** | Next.js 16 | TypeScript, TailwindCSS, SWR |
|
||||
| **前端** | Next.js 16 | TypeScript, TailwindCSS, SWR |
|
||||
| **后端** | FastAPI | Python 3.10, AsyncIO, PM2 |
|
||||
| **数据库** | Supabase | PostgreSQL, Storage (本地/S3), Auth |
|
||||
| **唇形同步** | LatentSync 1.6 | PyTorch 2.5, Diffusers, DeepCache |
|
||||
@@ -56,11 +58,11 @@
|
||||
- **[部署手册 (DEPLOY_MANUAL.md)](Docs/DEPLOY_MANUAL.md)** - 👈 **部署请看这里**!包含完整的环境搭建步骤。
|
||||
- [参考音频服务部署 (QWEN3_TTS_DEPLOY.md)](Docs/QWEN3_TTS_DEPLOY.md) - 声音克隆模型部署指南。
|
||||
- [LatentSync 部署指南](models/LatentSync/DEPLOY.md) - 唇形同步模型独立部署。
|
||||
- [用户认证部署 (AUTH_DEPLOY.md)](Docs/AUTH_DEPLOY.md) - Supabase 与 Auth 系统配置。
|
||||
- [Supabase 部署指南 (SUPABASE_DEPLOY.md)](Docs/SUPABASE_DEPLOY.md) - Supabase 与认证系统配置。
|
||||
|
||||
### 开发文档
|
||||
- [后端开发指南](Docs/BACKEND_README.md) - 接口规范与开发流程。
|
||||
- [后端开发规范](Docs/BACKEND_DEV.md) - 分层约定与开发习惯。
|
||||
### 开发文档
|
||||
- [后端开发指南](Docs/BACKEND_README.md) - 接口规范与开发流程。
|
||||
- [后端开发规范](Docs/BACKEND_DEV.md) - 分层约定与开发习惯。
|
||||
- [前端开发指南](Docs/FRONTEND_DEV.md) - UI 组件与页面规范。
|
||||
- [开发日志 (DevLogs)](Docs/DevLogs/) - 每日开发进度与技术决策记录。
|
||||
|
||||
@@ -72,9 +74,11 @@
|
||||
ViGent2/
|
||||
├── backend/ # FastAPI 后端服务
|
||||
│ ├── app/ # 核心业务逻辑
|
||||
│ ├── scripts/ # 运维脚本 (Watchdog 等)
|
||||
│ └── tests/ # 测试用例
|
||||
│ ├── assets/ # 字体 / 样式 / BGM
|
||||
│ ├── user_data/ # 用户隔离数据 (Cookie 等)
|
||||
│ └── scripts/ # 运维脚本 (Watchdog 等)
|
||||
├── frontend/ # Next.js 前端应用
|
||||
├── remotion/ # Remotion 视频渲染 (标题/字幕合成)
|
||||
├── models/ # AI 模型仓库
|
||||
│ ├── LatentSync/ # 唇形同步服务
|
||||
│ └── Qwen3-TTS/ # 声音克隆服务
|
||||
|
||||
@@ -15,7 +15,6 @@ DEFAULT_TTS_VOICE=zh-CN-YunxiNeural
|
||||
# GPU 选择 (0=第一块GPU, 1=第二块GPU)
|
||||
LATENTSYNC_GPU_ID=1
|
||||
|
||||
# 使用本地模式 (true) 或远程 API (false)
|
||||
# 使用本地模式 (true) 或远程 API (false)
|
||||
LATENTSYNC_LOCAL=true
|
||||
|
||||
@@ -66,3 +65,11 @@ ADMIN_PASSWORD=lam1988324
|
||||
# 智谱 GLM API 配置 (用于生成标题和标签)
|
||||
GLM_API_KEY=32440cd3f3444d1f8fe721304acea8bd.YXNLrk7eIJMKcg4t
|
||||
GLM_MODEL=glm-4.7-flash
|
||||
|
||||
# =============== Supabase Storage 本地路径 ===============
|
||||
# 确保存储卷映射正确,避免硬编码路径
|
||||
SUPABASE_STORAGE_LOCAL_PATH=/home/rongye/ProgramFiles/Supabase/volumes/storage/stub/stub
|
||||
|
||||
# =============== 抖音视频下载 Cookie ===============
|
||||
# 用于从抖音 URL 提取视频文案功能,会过期需要定期更新
|
||||
DOUYIN_COOKIE=douyin.com; device_web_cpu_core=10; device_web_memory_size=8; __ac_nonce=06760391f00b9b51264ae; __ac_signature=_02B4Z6wo00f019a5ceAAAIDAhEZR-X3jjWfWmXVAAJLXd4; ttwid=1%7C7MTKBSMsP4eOv9h5NAh8p0E-NYIud09ftNmB0mjLpWc%7C1734359327%7C8794abeabbd47447e1f56e5abc726be089f2a0344d6343b5f75f23e7b0f0028f; UIFID_TEMP=0de8750d2b188f4235dbfd208e44abbb976428f0720eb983255afefa45d39c0c6532e1d4768dd8587bf919f866ff1396912bcb2af71efee56a14a2a9f37b74010d0a0413795262f6d4afe02a032ac7ab; s_v_web_id=verify_m4r4ribr_c7krmY1z_WoeI_43po_ATpO_I4o8U1bex2D7; hevc_supported=true; home_can_add_dy_2_desktop=%220%22; dy_swidth=2560; dy_sheight=1440; stream_recommend_feed_params=%22%7B%5C%22cookie_enabled%5C%22%3Atrue%2C%5C%22screen_width%5C%22%3A2560%2C%5C%22screen_height%5C%22%3A1440%2C%5C%22browser_online%5C%22%3Atrue%2C%5C%22cpu_core_num%5C%22%3A10%2C%5C%22device_memory%5C%22%3A8%2C%5C%22downlink%5C%22%3A10%2C%5C%22effective_type%5C%22%3A%5C%224g%5C%22%2C%5C%22round_trip_time%5C%22%3A50%7D%22; strategyABtestKey=%221734359328.577%22; csrf_session_id=2f53aed9aa6974e83aa9a1014180c3a4; fpk1=U2FsdGVkX1/IpBh0qdmlKAVhGyYHgur4/VtL9AReZoeSxadXn4juKvsakahRGqjxOPytHWspYoBogyhS/V6QSw==; fpk2=0845b309c7b9b957afd9ecf775a4c21f; passport_csrf_token=d80e0c5b2fa2328219856be5ba7e671e; passport_csrf_token_default=d80e0c5b2fa2328219856be5ba7e671e; odin_tt=3c891091d2eb0f4718c1d5645bc4a0017032d4d5aa989decb729e9da2ad570918cbe5e9133dc6b145fa8c758de98efe32ff1f81aa0d611e838cc73ab08ef7d3f6adf66ab4d10e8372ddd628f94f16b8e; volume_info=%7B%22isUserMute%22%3Afalse%2C%22isMute%22%3Afalse%2C%22volume%22%3A0.5%7D; bd_ticket_guard_client_web_domain=2; FORCE_LOGIN=%7B%22videoConsumedRemainSeconds%22%3A180%7D; UIFID=0de8750d2b188f4235dbfd208e44abbb976428f0720eb983255afefa45d39c0c6532e1d4768dd8587bf919f866ff139655a3c2b735923234f371c699560c657923fd3d6c5b63ab7bb9b83423b6cb4787e2ce66a7fbc4ecb24c8570f520fe6de068bbb95115023c0c6c1b6ee31b49fb7e3996fb8349f43a3fd8b7a61cd9e18e8fe65eb6a7c13de4c0960d84e344b644725db3eb2fa6b7caf821de1b50527979f2; is_dash_user=1; biz_trace_id=b57a241f; bd_ticket_guard_client_data=eyJiZC10aWNrZXQtZ3VhcmQtdmVyc2lvbiI6MiwiYmQtdGlja2V0LWd1YXJkLWl0ZXJhdGlvbi12ZXJzaW9uIjoxLCJiZC10aWNrZXQtZ3VhcmQtcmVlLXB1YmxpYy1rZXkiOiJCTEo2R0lDalVoWW1XcHpGOFdrN0Vrc0dXcCtaUzNKY1g4NGNGY2k0TTl1TEowNjdUb21mbFU5aDdvWVBGamhNRWNRQWtKdnN1MnM3RmpTWnlJQXpHMjA9IiwiYmQtdGlja2V0LWd1YXJkLXdlYi12ZXJzaW9uIjoyfQ%3D%3D; download_guide=%221%2F20241216%2F0%22; sdk_source_info=7e276470716a68645a606960273f276364697660272927676c715a6d6069756077273f276364697660272927666d776a68605a607d71606b766c6a6b5a7666776c7571273f275e58272927666a6b766a69605a696c6061273f27636469766027292762696a6764695a7364776c6467696076273f275e5827292771273f273d33323131333c3036313632342778; bit_env=RiOY4jzzpxZoVCl6zdVSVhVRjdwHRTxqcqWdqMBZLPGjMdB4Tax1kAELHNTVAAh72KuhumewE4Lq6f0-VJ2UpJrkrhSxoPw9LUb3zQrq1OSwbeSPHkRlRgRQvO89sItdGUyq1oFr0XyRCnMYG87KSeWyc4x0czGR0o50hTDoDLG5rJVoRcdQOLvjiAegsqyytKF59sPX_QM9qffK2SqYsg0hCggURc_AI6kguDDE5DvG0bnyz1utw4z1eEnIoLrkGDqzqBZj4dOAr0BVU6ofbsS-pOQ2u2PM1dLP9FlBVBlVaqYVgHJeSLsR5k76BRTddUjTb4zEilVIEwAMJWGN4I1BxVt6fC9B5tBQpuT0lj3n3eKXCKXZsd8FrEs5_pbfDsxV-e_WMiXI2ff4qxiTC0U73sfo9OpicKICtZjdq8qsHxJuu6wVR36zvXeL2Wch5C6MzprNvkivv0l8nbh2mSgy1nabZr3dmU6NcR-Bg3Q3xTWUlR9aAUmpopC-cNuXjgLpT-Lw1AYGilSUnCvosth1Gfypq-b0MpgmdSDgTrQ%3D; gulu_source_res=eyJwX2luIjoiMDhjOGQ3ZTJiODQyNjZkZWI5Y2VkMGJiODNlNmY1ZWY0ZjMyNTE2ZmYyZjAzNDMzZjI0OWU1Y2Q1NTczNTk5NyJ9; passport_auth_mix_state=hp9bc3dgb1tm5wd8p82zawus27g0e3ue; IsDouyinActive=false
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
from . import admin
|
||||
from . import ai
|
||||
from . import assets
|
||||
from . import auth
|
||||
from . import login_helper
|
||||
from . import materials
|
||||
from . import publish
|
||||
from . import ref_audios
|
||||
from . import tools
|
||||
from . import videos
|
||||
@@ -1 +0,0 @@
|
||||
from app.modules.admin.router import router
|
||||
@@ -1 +0,0 @@
|
||||
from app.modules.ai.router import router
|
||||
@@ -1 +0,0 @@
|
||||
from app.modules.assets.router import router
|
||||
@@ -1 +0,0 @@
|
||||
from app.modules.auth.router import router
|
||||
@@ -1 +0,0 @@
|
||||
from app.modules.login_helper.router import router
|
||||
@@ -1 +0,0 @@
|
||||
from app.modules.materials.router import router
|
||||
@@ -1 +0,0 @@
|
||||
from app.modules.publish.router import router
|
||||
@@ -1 +0,0 @@
|
||||
from app.modules.ref_audios.router import router
|
||||
@@ -1 +0,0 @@
|
||||
from app.modules.tools.router import router
|
||||
@@ -1 +0,0 @@
|
||||
from app.modules.videos.router import router
|
||||
@@ -3,14 +3,46 @@ from pathlib import Path
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# 基础路径配置
|
||||
BASE_DIR: Path = Path(__file__).resolve().parent.parent
|
||||
UPLOAD_DIR: Path = BASE_DIR.parent / "uploads"
|
||||
OUTPUT_DIR: Path = BASE_DIR.parent / "outputs"
|
||||
ASSETS_DIR: Path = BASE_DIR.parent / "assets"
|
||||
BASE_DIR: Path = Path(__file__).resolve().parent.parent
|
||||
UPLOAD_DIR: Path = BASE_DIR.parent / "uploads"
|
||||
OUTPUT_DIR: Path = BASE_DIR.parent / "outputs"
|
||||
ASSETS_DIR: Path = BASE_DIR.parent / "assets"
|
||||
PUBLISH_SCREENSHOT_DIR: Path = BASE_DIR.parent / "private_outputs" / "publish_screenshots"
|
||||
|
||||
# 数据库/缓存
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
DEBUG: bool = True
|
||||
|
||||
# Playwright 配置
|
||||
WEIXIN_HEADLESS_MODE: str = "headless-new"
|
||||
WEIXIN_USER_AGENT: str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
WEIXIN_LOCALE: str = "zh-CN"
|
||||
WEIXIN_TIMEZONE_ID: str = "Asia/Shanghai"
|
||||
WEIXIN_CHROME_PATH: str = "/usr/bin/google-chrome"
|
||||
WEIXIN_BROWSER_CHANNEL: str = ""
|
||||
WEIXIN_FORCE_SWIFTSHADER: bool = True
|
||||
WEIXIN_TRANSCODE_MODE: str = "reencode"
|
||||
WEIXIN_DEBUG_ARTIFACTS: bool = False
|
||||
WEIXIN_RECORD_VIDEO: bool = False
|
||||
WEIXIN_KEEP_SUCCESS_VIDEO: bool = False
|
||||
WEIXIN_RECORD_VIDEO_WIDTH: int = 1280
|
||||
WEIXIN_RECORD_VIDEO_HEIGHT: int = 720
|
||||
|
||||
# Douyin Playwright 配置
|
||||
DOUYIN_HEADLESS_MODE: str = "headless-new"
|
||||
DOUYIN_USER_AGENT: str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36"
|
||||
DOUYIN_LOCALE: str = "zh-CN"
|
||||
DOUYIN_TIMEZONE_ID: str = "Asia/Shanghai"
|
||||
DOUYIN_CHROME_PATH: str = "/usr/bin/google-chrome"
|
||||
DOUYIN_BROWSER_CHANNEL: str = ""
|
||||
DOUYIN_FORCE_SWIFTSHADER: bool = True
|
||||
|
||||
# Douyin 调试录屏
|
||||
DOUYIN_DEBUG_ARTIFACTS: bool = False
|
||||
DOUYIN_RECORD_VIDEO: bool = False
|
||||
DOUYIN_KEEP_SUCCESS_VIDEO: bool = False
|
||||
DOUYIN_RECORD_VIDEO_WIDTH: int = 1280
|
||||
DOUYIN_RECORD_VIDEO_HEIGHT: int = 720
|
||||
|
||||
# TTS 配置
|
||||
DEFAULT_TTS_VOICE: str = "zh-CN-YunxiNeural"
|
||||
@@ -44,6 +76,12 @@ class Settings(BaseSettings):
|
||||
GLM_API_KEY: str = ""
|
||||
GLM_MODEL: str = "glm-4.7-flash"
|
||||
|
||||
# CORS 配置 (逗号分隔的域名列表,* 表示允许所有)
|
||||
CORS_ORIGINS: str = "*"
|
||||
|
||||
# 抖音 Cookie (用于视频下载功能,会过期需要定期更新)
|
||||
DOUYIN_COOKIE: str = ""
|
||||
|
||||
@property
|
||||
def LATENTSYNC_DIR(self) -> Path:
|
||||
"""LatentSync 目录路径 (动态计算)"""
|
||||
|
||||
@@ -1,10 +1,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:
|
||||
|
||||
@@ -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>"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -249,16 +249,17 @@ async def list_ref_audios(user: dict = Depends(get_current_user)):
|
||||
# 列出用户目录下的文件
|
||||
files = await storage_service.list_files(BUCKET_REF_AUDIOS, user_id)
|
||||
|
||||
# 过滤出 .wav 文件并获取对应的 metadata
|
||||
items = []
|
||||
for f in files:
|
||||
# 过滤出 .wav 文件
|
||||
wav_files = [f for f in files if f.get("name", "").endswith(".wav")]
|
||||
|
||||
if not wav_files:
|
||||
return success_response(RefAudioListResponse(items=[]).model_dump())
|
||||
|
||||
# 并发获取所有 metadata 和签名 URL
|
||||
async def fetch_audio_info(f):
|
||||
"""获取单个音频的信息(metadata + signed URL)"""
|
||||
name = f.get("name", "")
|
||||
if not name.endswith(".wav"):
|
||||
continue
|
||||
|
||||
storage_path = f"{user_id}/{name}"
|
||||
|
||||
# 尝试读取 metadata
|
||||
metadata_name = name.replace(".wav", ".json")
|
||||
metadata_path = f"{user_id}/{metadata_name}"
|
||||
|
||||
@@ -271,7 +272,7 @@ async def list_ref_audios(user: dict = Depends(get_current_user)):
|
||||
# 获取 metadata 内容
|
||||
metadata_url = await storage_service.get_signed_url(BUCKET_REF_AUDIOS, metadata_path)
|
||||
import httpx
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
resp = await client.get(metadata_url)
|
||||
if resp.status_code == 200:
|
||||
metadata = resp.json()
|
||||
@@ -280,7 +281,7 @@ async def list_ref_audios(user: dict = Depends(get_current_user)):
|
||||
created_at = metadata.get("created_at", 0)
|
||||
original_filename = metadata.get("original_filename", "")
|
||||
except Exception as e:
|
||||
logger.warning(f"读取 metadata 失败: {e}")
|
||||
logger.debug(f"读取 metadata 失败: {e}")
|
||||
# 从文件名提取时间戳
|
||||
try:
|
||||
created_at = int(name.split("_")[0])
|
||||
@@ -299,17 +300,21 @@ async def list_ref_audios(user: dict = Depends(get_current_user)):
|
||||
if match:
|
||||
display_name = match.group(1)
|
||||
|
||||
items.append(RefAudioResponse(
|
||||
return RefAudioResponse(
|
||||
id=storage_path,
|
||||
name=display_name,
|
||||
path=signed_url,
|
||||
ref_text=ref_text,
|
||||
duration_sec=duration_sec,
|
||||
created_at=created_at
|
||||
))
|
||||
)
|
||||
|
||||
# 使用 asyncio.gather 并发获取所有音频信息
|
||||
import asyncio
|
||||
items = await asyncio.gather(*[fetch_audio_info(f) for f in wav_files])
|
||||
|
||||
# 按创建时间倒序排列
|
||||
items.sort(key=lambda x: x.created_at, reverse=True)
|
||||
items = sorted(items, key=lambda x: x.created_at, reverse=True)
|
||||
|
||||
return success_response(RefAudioListResponse(items=items).model_dump())
|
||||
|
||||
|
||||
@@ -210,6 +210,8 @@ async def download_douyin_manual(url: str, temp_dir: Path, timestamp: int) -> Op
|
||||
手动下载抖音视频 (Fallback logic - Ported from SuperIPAgent/douyinDownloader)
|
||||
使用特定的 User Profile URL 和硬编码 Cookie 绕过反爬
|
||||
"""
|
||||
import httpx
|
||||
|
||||
logger.info(f"[SuperIPAgent] Starting download for: {url}")
|
||||
|
||||
try:
|
||||
@@ -218,9 +220,11 @@ async def download_douyin_manual(url: str, temp_dir: Path, timestamp: int) -> Op
|
||||
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
||||
}
|
||||
|
||||
# 如果是短链或重定向
|
||||
resp = requests.get(url, headers=headers, allow_redirects=True, timeout=10)
|
||||
final_url = resp.url
|
||||
# 如果是短链或重定向 - 使用异步 httpx
|
||||
async with httpx.AsyncClient(follow_redirects=True, timeout=10.0) as client:
|
||||
resp = await client.get(url, headers=headers)
|
||||
final_url = str(resp.url)
|
||||
|
||||
logger.info(f"[SuperIPAgent] Final URL: {final_url}")
|
||||
|
||||
modal_id = None
|
||||
@@ -238,16 +242,21 @@ async def download_douyin_manual(url: str, temp_dir: Path, timestamp: int) -> Op
|
||||
# 使用特定用户的 Profile 页 + modal_id 参数,配合特定 Cookie
|
||||
target_url = f"https://www.douyin.com/user/MS4wLjABAAAAN_s_hups7LD0N4qnrM3o2gI0vuG3pozNaEolz2_py3cHTTrpVr1Z4dukFD9SOlwY?from_tab_name=main&modal_id={modal_id}"
|
||||
|
||||
# 3. 使用硬编码 Cookie (Copy from SuperIPAgent)
|
||||
# 3. 使用配置的 Cookie (从环境变量 DOUYIN_COOKIE 读取)
|
||||
from app.core.config import settings
|
||||
if not settings.DOUYIN_COOKIE:
|
||||
logger.warning("[SuperIPAgent] DOUYIN_COOKIE 未配置,视频下载可能失败")
|
||||
|
||||
headers_with_cookie = {
|
||||
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
||||
"cookie": "douyin.com; device_web_cpu_core=10; device_web_memory_size=8; __ac_nonce=06760391f00b9b51264ae; __ac_signature=_02B4Z6wo00f019a5ceAAAIDAhEZR-X3jjWfWmXVAAJLXd4; ttwid=1%7C7MTKBSMsP4eOv9h5NAh8p0E-NYIud09ftNmB0mjLpWc%7C1734359327%7C8794abeabbd47447e1f56e5abc726be089f2a0344d6343b5f75f23e7b0f0028f; UIFID_TEMP=0de8750d2b188f4235dbfd208e44abbb976428f0720eb983255afefa45d39c0c6532e1d4768dd8587bf919f866ff1396912bcb2af71efee56a14a2a9f37b74010d0a0413795262f6d4afe02a032ac7ab; s_v_web_id=verify_m4r4ribr_c7krmY1z_WoeI_43po_ATpO_I4o8U1bex2D7; hevc_supported=true; home_can_add_dy_2_desktop=%220%22; dy_swidth=2560; dy_sheight=1440; stream_recommend_feed_params=%22%7B%5C%22cookie_enabled%5C%22%3Atrue%2C%5C%22screen_width%5C%22%3A2560%2C%5C%22screen_height%5C%22%3A1440%2C%5C%22browser_online%5C%22%3Atrue%2C%5C%22cpu_core_num%5C%22%3A10%2C%5C%22device_memory%5C%22%3A8%2C%5C%22downlink%5C%22%3A10%2C%5C%22effective_type%5C%22%3A%5C%224g%5C%22%2C%5C%22round_trip_time%5C%22%3A50%7D%22; strategyABtestKey=%221734359328.577%22; csrf_session_id=2f53aed9aa6974e83aa9a1014180c3a4; fpk1=U2FsdGVkX1/IpBh0qdmlKAVhGyYHgur4/VtL9AReZoeSxadXn4juKvsakahRGqjxOPytHWspYoBogyhS/V6QSw==; fpk2=0845b309c7b9b957afd9ecf775a4c21f; passport_csrf_token=d80e0c5b2fa2328219856be5ba7e671e; passport_csrf_token_default=d80e0c5b2fa2328219856be5ba7e671e; odin_tt=3c891091d2eb0f4718c1d5645bc4a0017032d4d5aa989decb729e9da2ad570918cbe5e9133dc6b145fa8c758de98efe32ff1f81aa0d611e838cc73ab08ef7d3f6adf66ab4d10e8372ddd628f94f16b8e; volume_info=%7B%22isUserMute%22%3Afalse%2C%22isMute%22%3Afalse%2C%22volume%22%3A0.5%7D; bd_ticket_guard_client_web_domain=2; FORCE_LOGIN=%7B%22videoConsumedRemainSeconds%22%3A180%7D; UIFID=0de8750d2b188f4235dbfd208e44abbb976428f0720eb983255afefa45d39c0c6532e1d4768dd8587bf919f866ff139655a3c2b735923234f371c699560c657923fd3d6c5b63ab7bb9b83423b6cb4787e2ce66a7fbc4ecb24c8570f520fe6de068bbb95115023c0c6c1b6ee31b49fb7e3996fb8349f43a3fd8b7a61cd9e18e8fe65eb6a7c13de4c0960d84e344b644725db3eb2fa6b7caf821de1b50527979f2; is_dash_user=1; biz_trace_id=b57a241f; bd_ticket_guard_client_data=eyJiZC10aWNrZXQtZ3VhcmQtdmVyc2lvbiI6MiwiYmQtdGlja2V0LWd1YXJkLWl0ZXJhdGlvbi12ZXJzaW9uIjoxLCJiZC10aWNrZXQtZ3VhcmQtcmVlLXB1YmxpYy1rZXkiOiJCTEo2R0lDalVoWW1XcHpGOFdrN0Vrc0dXcCtaUzNKY1g4NGNGY2k0TTl1TEowNjdUb21mbFU5aDdvWVBGamhNRWNRQWtKdnN1MnM3RmpTWnlJQXpHMjA9IiwiYmQtdGlja2V0LWd1YXJkLXdlYi12ZXJzaW9uIjoyfQ%3D%3D; download_guide=%221%2F20241216%2F0%22; sdk_source_info=7e276470716a68645a606960273f276364697660272927676c715a6d6069756077273f276364697660272927666d776a68605a607d71606b766c6a6b5a7666776c7571273f275e58272927666a6b766a69605a696c6061273f27636469766027292762696a6764695a7364776c6467696076273f275e5827292771273f273d33323131333c3036313632342778; bit_env=RiOY4jzzpxZoVCl6zdVSVhVRjdwHRTxqcqWdqMBZLPGjMdB4Tax1kAELHNTVAAh72KuhumewE4Lq6f0-VJ2UpJrkrhSxoPw9LUb3zQrq1OSwbeSPHkRlRgRQvO89sItdGUyq1oFr0XyRCnMYG87KSeWyc4x0czGR0o50hTDoDLG5rJVoRcdQOLvjiAegsqyytKF59sPX_QM9qffK2SqYsg0hCggURc_AI6kguDDE5DvG0bnyz1utw4z1eEnIoLrkGDqzqBZj4dOAr0BVU6ofbsS-pOQ2u2PM1dLP9FlBVBlVaqYVgHJeSLsR5k76BRTddUjTb4zEilVIEwAMJWGN4I1BxVt6fC9B5tBQpuT0lj3n3eKXCKXZsd8FrEs5_pbfDsxV-e_WMiXI2ff4qxiTC0U73sfo9OpicKICtZjdq8qsHxJuu6wVR36zvXeL2Wch5C6MzprNvkivv0l8nbh2mSgy1nabZr3dmU6NcR-Bg3Q3xTWUlR9aAUmpopC-cNuXjgLpT-Lw1AYGilSUnCvosth1Gfypq-b0MpgmdSDgTrQ%3D; gulu_source_res=eyJwX2luIjoiMDhjOGQ3ZTJiODQyNjZkZWI5Y2VkMGJiODNlNmY1ZWY0ZjMyNTE2ZmYyZjAzNDMzZjI0OWU1Y2Q1NTczNTk5NyJ9; passport_auth_mix_state=hp9bc3dgb1tm5wd8p82zawus27g0e3ue; IsDouyinActive=false",
|
||||
"cookie": settings.DOUYIN_COOKIE,
|
||||
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||
}
|
||||
|
||||
logger.info(f"[SuperIPAgent] Requesting page with Cookie...")
|
||||
# 必须 verify=False 否则有些环境会报错
|
||||
response = requests.get(target_url, headers=headers_with_cookie, timeout=10)
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(target_url, headers=headers_with_cookie)
|
||||
|
||||
# 4. 解析 RENDER_DATA
|
||||
content_match = re.findall(r'<script id="RENDER_DATA" type="application/json">(.*?)</script>', response.text)
|
||||
@@ -290,24 +299,25 @@ async def download_douyin_manual(url: str, temp_dir: Path, timestamp: int) -> Op
|
||||
|
||||
logger.info(f"[SuperIPAgent] Found video URL: {video_url[:50]}...")
|
||||
|
||||
# 6. 下载 (带 Header)
|
||||
# 6. 下载 (带 Header) - 使用异步 httpx
|
||||
temp_path = temp_dir / f"douyin_manual_{timestamp}.mp4"
|
||||
download_headers = {
|
||||
'Referer': 'https://www.douyin.com/',
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||
}
|
||||
|
||||
dl_resp = requests.get(video_url, headers=download_headers, stream=True, timeout=60)
|
||||
if dl_resp.status_code == 200:
|
||||
with open(temp_path, 'wb') as f:
|
||||
for chunk in dl_resp.iter_content(chunk_size=1024):
|
||||
f.write(chunk)
|
||||
|
||||
logger.info(f"[SuperIPAgent] Downloaded successfully: {temp_path}")
|
||||
return temp_path
|
||||
else:
|
||||
logger.error(f"[SuperIPAgent] Download failed: {dl_resp.status_code}")
|
||||
return None
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
async with client.stream("GET", video_url, headers=download_headers) as dl_resp:
|
||||
if dl_resp.status_code == 200:
|
||||
with open(temp_path, 'wb') as f:
|
||||
async for chunk in dl_resp.aiter_bytes(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
|
||||
logger.info(f"[SuperIPAgent] Downloaded successfully: {temp_path}")
|
||||
return temp_path
|
||||
else:
|
||||
logger.error(f"[SuperIPAgent] Download failed: {dl_resp.status_code}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[SuperIPAgent] Logic failed: {e}")
|
||||
|
||||
@@ -27,13 +27,20 @@ async def generate_video(
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}")
|
||||
async def get_task_status(task_id: str):
|
||||
return success_response(get_task(task_id))
|
||||
async def get_task_status(task_id: str, current_user: dict = Depends(get_current_user)):
|
||||
task = get_task(task_id)
|
||||
# 验证任务归属:只能查看自己的任务
|
||||
if task.get("status") != "not_found" and task.get("user_id") != current_user["id"]:
|
||||
return success_response({"status": "not_found"})
|
||||
return success_response(task)
|
||||
|
||||
|
||||
@router.get("/tasks")
|
||||
async def list_tasks_view():
|
||||
return success_response({"tasks": list_tasks()})
|
||||
async def list_tasks_view(current_user: dict = Depends(get_current_user)):
|
||||
# 只返回当前用户的任务
|
||||
all_tasks = list_tasks()
|
||||
user_tasks = [t for t in all_tasks if t.get("user_id") == current_user["id"]]
|
||||
return success_response({"tasks": user_tasks})
|
||||
|
||||
|
||||
@router.get("/lipsync/health")
|
||||
|
||||
@@ -15,5 +15,7 @@ class GenerateRequest(BaseModel):
|
||||
title_style_id: Optional[str] = None
|
||||
subtitle_font_size: Optional[int] = None
|
||||
title_font_size: Optional[int] = None
|
||||
title_top_margin: Optional[int] = None
|
||||
subtitle_bottom_margin: Optional[int] = None
|
||||
bgm_id: Optional[str] = None
|
||||
bgm_volume: Optional[float] = 0.2
|
||||
|
||||
@@ -216,6 +216,16 @@ async def process_video_generation(task_id: str, req: GenerateRequest, user_id:
|
||||
title_style = {}
|
||||
title_style["font_size"] = int(req.title_font_size)
|
||||
|
||||
if req.title_top_margin is not None and req.title:
|
||||
if title_style is None:
|
||||
title_style = {}
|
||||
title_style["top_margin"] = int(req.title_top_margin)
|
||||
|
||||
if req.subtitle_bottom_margin is not None and req.enable_subtitles:
|
||||
if subtitle_style is None:
|
||||
subtitle_style = {}
|
||||
subtitle_style["bottom_margin"] = int(req.subtitle_bottom_margin)
|
||||
|
||||
if use_remotion:
|
||||
subtitle_style = prepare_style_for_remotion(
|
||||
subtitle_style,
|
||||
@@ -277,14 +287,12 @@ async def process_video_generation(task_id: str, req: GenerateRequest, user_id:
|
||||
_update_task(task_id, message="正在上传结果...", progress=95)
|
||||
|
||||
storage_path = f"{user_id}/{task_id}_output.mp4"
|
||||
with open(final_output_local_path, "rb") as f:
|
||||
file_data = f.read()
|
||||
await storage_service.upload_file(
|
||||
bucket=storage_service.BUCKET_OUTPUTS,
|
||||
path=storage_path,
|
||||
file_data=file_data,
|
||||
content_type="video/mp4"
|
||||
)
|
||||
await storage_service.upload_file_from_path(
|
||||
bucket=storage_service.BUCKET_OUTPUTS,
|
||||
storage_path=storage_path,
|
||||
local_file_path=str(final_output_local_path),
|
||||
content_type="video/mp4"
|
||||
)
|
||||
|
||||
signed_url = await storage_service.get_signed_url(
|
||||
bucket=storage_service.BUCKET_OUTPUTS,
|
||||
|
||||
@@ -51,7 +51,10 @@ class GLMService:
|
||||
client = self._get_client()
|
||||
logger.info(f"Calling GLM API with model: {settings.GLM_MODEL}")
|
||||
|
||||
response = client.chat.completions.create(
|
||||
# 使用 asyncio.to_thread 包装同步 SDK 调用,避免阻塞事件循环
|
||||
import asyncio
|
||||
response = await asyncio.to_thread(
|
||||
client.chat.completions.create,
|
||||
model=settings.GLM_MODEL,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
thinking={"type": "disabled"}, # 禁用思考模式,加快响应
|
||||
@@ -96,7 +99,10 @@ class GLMService:
|
||||
client = self._get_client()
|
||||
logger.info(f"Using GLM to rewrite script")
|
||||
|
||||
response = client.chat.completions.create(
|
||||
# 使用 asyncio.to_thread 包装同步 SDK 调用,避免阻塞事件循环
|
||||
import asyncio
|
||||
response = await asyncio.to_thread(
|
||||
client.chat.completions.create,
|
||||
model=settings.GLM_MODEL,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
thinking={"type": "disabled"},
|
||||
|
||||
@@ -398,18 +398,23 @@ class LipSyncService:
|
||||
raise e
|
||||
|
||||
async def _local_generate_subprocess(self, video_path: str, audio_path: str, output_path: str) -> str:
|
||||
"""原有的 subprocess 逻辑提取为独立方法"""
|
||||
logger.info("🔄 调用 LatentSync 推理 (subprocess)...")
|
||||
# ... (此处仅为占位符提示,实际代码需要调整结构以避免重复,
|
||||
# 但鉴于原有 _local_generate 的结构,最简单的方法是在 _local_generate 内部做判断,
|
||||
# 如果 use_server 失败,可以 retry 或者 _local_generate 不做拆分,直接在里面写逻辑)
|
||||
# 为了最小化改动且保持安全,上面的 _call_persistent_server 如果失败,
|
||||
# 最好不要自动回退(可能导致双重资源消耗),而是直接报错让用户检查服务。
|
||||
# 但为了用户体验,我们可以允许回退。
|
||||
# *修正策略*:
|
||||
# 我将不拆分 _local_generate_subprocess,而是将 subprocess 逻辑保留在 _local_generate 的后半部分。
|
||||
# 如果 self.use_server 为 True,先尝试调用 server,成功则 return,失败则继续往下走。
|
||||
pass
|
||||
"""
|
||||
原有的 subprocess 回退逻辑
|
||||
|
||||
注意:subprocess 回退已被禁用,原因如下:
|
||||
1. subprocess 模式需要重新加载模型,消耗大量时间和显存
|
||||
2. 如果常驻服务不可用,应该让用户知道并修复服务,而非静默回退
|
||||
3. 避免双重资源消耗导致的 GPU OOM
|
||||
|
||||
如果常驻服务不可用,请检查:
|
||||
- 服务是否启动: python scripts/server.py (在 models/LatentSync 目录)
|
||||
- 端口是否被占用: lsof -i:8007
|
||||
- GPU 显存是否充足: nvidia-smi
|
||||
"""
|
||||
raise RuntimeError(
|
||||
"LatentSync 常驻服务不可用,无法进行唇形同步。"
|
||||
"请确保 LatentSync 服务已启动 (cd models/LatentSync && python scripts/server.py)"
|
||||
)
|
||||
|
||||
async def _remote_generate(
|
||||
self,
|
||||
|
||||
@@ -18,18 +18,19 @@ from app.services.storage import storage_service
|
||||
from .uploader.bilibili_uploader import BilibiliUploader
|
||||
from .uploader.douyin_uploader import DouyinUploader
|
||||
from .uploader.xiaohongshu_uploader import XiaohongshuUploader
|
||||
from .uploader.weixin_uploader import WeixinUploader
|
||||
|
||||
|
||||
class PublishService:
|
||||
"""Social media publishing service (with user isolation)"""
|
||||
|
||||
# 支持的平台配置
|
||||
PLATFORMS: Dict[str, Dict[str, Any]] = {
|
||||
"douyin": {"name": "抖音", "url": "https://creator.douyin.com/", "enabled": True},
|
||||
"weixin": {"name": "微信视频号", "url": "https://channels.weixin.qq.com/", "enabled": False},
|
||||
"bilibili": {"name": "B站", "url": "https://member.bilibili.com/platform/upload/video/frame", "enabled": True},
|
||||
"xiaohongshu": {"name": "小红书", "url": "https://creator.xiaohongshu.com/", "enabled": True},
|
||||
}
|
||||
PLATFORMS: Dict[str, Dict[str, Any]] = {
|
||||
"douyin": {"name": "抖音", "url": "https://creator.douyin.com/", "enabled": True},
|
||||
"weixin": {"name": "微信视频号", "url": "https://channels.weixin.qq.com/", "enabled": True},
|
||||
"bilibili": {"name": "B站", "url": "https://member.bilibili.com/platform/upload/video/frame", "enabled": True},
|
||||
"xiaohongshu": {"name": "小红书", "url": "https://creator.xiaohongshu.com/", "enabled": True},
|
||||
}
|
||||
|
||||
def __init__(self) -> None:
|
||||
# 存储活跃的登录会话,用于跟踪登录状态
|
||||
@@ -181,7 +182,8 @@ class PublishService:
|
||||
tags=tags,
|
||||
publish_date=publish_time,
|
||||
account_file=str(account_file),
|
||||
description=description
|
||||
description=description,
|
||||
user_id=user_id,
|
||||
)
|
||||
elif platform == "xiaohongshu":
|
||||
uploader = XiaohongshuUploader(
|
||||
@@ -192,6 +194,16 @@ class PublishService:
|
||||
account_file=str(account_file),
|
||||
description=description
|
||||
)
|
||||
elif platform == "weixin":
|
||||
uploader = WeixinUploader(
|
||||
title=title,
|
||||
file_path=local_video_path,
|
||||
tags=tags,
|
||||
publish_date=publish_time,
|
||||
account_file=str(account_file),
|
||||
description=description,
|
||||
user_id=user_id,
|
||||
)
|
||||
else:
|
||||
logger.warning(f"[发布] {platform} 上传功能尚未实现")
|
||||
return {
|
||||
@@ -224,30 +236,38 @@ class PublishService:
|
||||
async def login(self, platform: str, user_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
启动QR码登录流程
|
||||
|
||||
|
||||
Args:
|
||||
platform: 平台 ID
|
||||
user_id: 用户 ID (用于 Cookie 隔离)
|
||||
|
||||
|
||||
Returns:
|
||||
dict: 包含二维码base64图片
|
||||
"""
|
||||
if platform not in self.PLATFORMS:
|
||||
return {"success": False, "message": "不支持的平台"}
|
||||
|
||||
|
||||
try:
|
||||
from .qr_login_service import QRLoginService
|
||||
|
||||
|
||||
# 获取用户专属的 Cookie 目录
|
||||
cookies_dir = self._get_cookies_dir(user_id)
|
||||
|
||||
|
||||
# 清理旧的活跃会话(避免残留会话干扰新登录)
|
||||
session_key = self._get_session_key(platform, user_id)
|
||||
if session_key in self.active_login_sessions:
|
||||
old_service = self.active_login_sessions.pop(session_key)
|
||||
try:
|
||||
await old_service._cleanup()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 创建QR登录服务
|
||||
qr_service = QRLoginService(platform, cookies_dir)
|
||||
|
||||
|
||||
# 存储活跃会话 (带用户隔离)
|
||||
session_key = self._get_session_key(platform, user_id)
|
||||
self.active_login_sessions[session_key] = qr_service
|
||||
|
||||
|
||||
# 启动登录并获取二维码
|
||||
result = await qr_service.start_login()
|
||||
|
||||
@@ -261,27 +281,28 @@ class PublishService:
|
||||
}
|
||||
|
||||
def get_login_session_status(self, platform: str, user_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""获取活跃登录会话的状态"""
|
||||
"""获取活跃登录会话的状态(仅用于扫码轮询)"""
|
||||
session_key = self._get_session_key(platform, user_id)
|
||||
|
||||
# 1. 如果有活跃的扫码会话,优先检查它
|
||||
|
||||
# 只检查活跃的扫码会话,不检查 Cookie 文件
|
||||
# Cookie 文件检查会导致"重新登录"时误判为已登录
|
||||
if session_key in self.active_login_sessions:
|
||||
qr_service = self.active_login_sessions[session_key]
|
||||
status = qr_service.get_login_status()
|
||||
|
||||
|
||||
# 如果登录成功且Cookie已保存,清理会话
|
||||
if status["success"] and status["cookies_saved"]:
|
||||
del self.active_login_sessions[session_key]
|
||||
return {"success": True, "message": "登录成功"}
|
||||
|
||||
return {"success": False, "message": "等待扫码..."}
|
||||
|
||||
# 2. 检查本地Cookie文件是否存在
|
||||
cookie_file = self._get_cookie_path(platform, user_id)
|
||||
if cookie_file.exists():
|
||||
return {"success": True, "message": "已登录 (历史状态)"}
|
||||
|
||||
return {"success": False, "message": "未登录"}
|
||||
|
||||
# 刷脸验证:传递新二维码给前端
|
||||
result: Dict[str, Any] = {"success": False, "message": "等待扫码..."}
|
||||
if status.get("face_verify_qr"):
|
||||
result["face_verify_qr"] = status["face_verify_qr"]
|
||||
return result
|
||||
|
||||
# 没有活跃会话 → 返回 False(前端不应在无会话时轮询)
|
||||
return {"success": False, "message": "无活跃登录会话"}
|
||||
|
||||
def logout(self, platform: str, user_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ Remotion 视频渲染服务
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
@@ -52,13 +53,21 @@ class RemotionService:
|
||||
输出视频路径
|
||||
"""
|
||||
# 构建命令参数
|
||||
cmd = [
|
||||
"npx", "ts-node", "render.ts",
|
||||
# 优先使用预编译的 JS 文件(更快),如果不存在则回退到 ts-node
|
||||
compiled_js = self.remotion_dir / "dist" / "render.js"
|
||||
if compiled_js.exists():
|
||||
cmd = ["node", "dist/render.js"]
|
||||
logger.info("Using pre-compiled render.js for faster startup")
|
||||
else:
|
||||
cmd = ["npx", "ts-node", "render.ts"]
|
||||
logger.warning("Using ts-node (slower). Run 'npm run build:render' to compile for faster startup.")
|
||||
|
||||
cmd.extend([
|
||||
"--video", str(video_path),
|
||||
"--output", str(output_path),
|
||||
"--fps", str(fps),
|
||||
"--enableSubtitles", str(enable_subtitles).lower()
|
||||
]
|
||||
])
|
||||
|
||||
if captions_path:
|
||||
cmd.extend(["--captions", str(captions_path)])
|
||||
@@ -106,6 +115,16 @@ class RemotionService:
|
||||
process.wait()
|
||||
|
||||
if process.returncode != 0:
|
||||
# Remotion 渲染可能在完成输出后进程崩溃 (如 SIGABRT code -6)
|
||||
# 如果输出文件已存在且大小合理,视为成功
|
||||
output_file = Path(output_path)
|
||||
if output_file.exists() and output_file.stat().st_size > 1024:
|
||||
logger.warning(
|
||||
f"Remotion process exited with code {process.returncode}, "
|
||||
f"but output file exists ({output_file.stat().st_size} bytes). Treating as success."
|
||||
)
|
||||
return output_path
|
||||
|
||||
error_msg = "\n".join(output_lines[-20:]) # 最后 20 行
|
||||
raise RuntimeError(f"Remotion render failed (code {process.returncode}):\n{error_msg}")
|
||||
|
||||
|
||||
@@ -7,9 +7,12 @@ from pathlib import Path
|
||||
import asyncio
|
||||
import functools
|
||||
import os
|
||||
import shutil
|
||||
|
||||
# Supabase Storage 本地存储根目录
|
||||
SUPABASE_STORAGE_LOCAL_PATH = Path("/home/rongye/ProgramFiles/Supabase/volumes/storage/stub/stub")
|
||||
# Supabase Storage 本地存储根目录(从环境变量读取,支持不同部署环境)
|
||||
# Supabase Storage 本地存储根目录(从环境变量读取,支持不同部署环境)
|
||||
_default_storage_path = "/var/lib/supabase/storage" # 生产环境默认路径
|
||||
SUPABASE_STORAGE_LOCAL_PATH = Path(os.getenv("SUPABASE_STORAGE_LOCAL_PATH", _default_storage_path))
|
||||
|
||||
class StorageService:
|
||||
def __init__(self):
|
||||
@@ -100,6 +103,45 @@ class StorageService:
|
||||
logger.error(f"Storage upload failed: {e}")
|
||||
raise e
|
||||
|
||||
async def upload_file_from_path(self, bucket: str, storage_path: str, local_file_path: str, content_type: str) -> str:
|
||||
"""
|
||||
从本地文件路径上传文件到 Supabase Storage
|
||||
|
||||
使用分块读取减少内存峰值,避免大文件整读入内存
|
||||
|
||||
Args:
|
||||
bucket: 存储桶名称
|
||||
storage_path: Storage 中的目标路径
|
||||
local_file_path: 本地文件的绝对路径
|
||||
content_type: MIME 类型
|
||||
"""
|
||||
local_file = Path(local_file_path)
|
||||
if not local_file.exists():
|
||||
raise FileNotFoundError(f"本地文件不存在: {local_file_path}")
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
file_size = local_file.stat().st_size
|
||||
|
||||
# 分块读取文件,避免大文件整读入内存
|
||||
# 虽然最终还是需要拼接成 bytes 传给 SDK,但分块读取可以减少 IO 压力
|
||||
def read_file_chunked():
|
||||
chunks = []
|
||||
chunk_size = 10 * 1024 * 1024 # 10MB per chunk
|
||||
with open(local_file_path, "rb") as f:
|
||||
while True:
|
||||
chunk = f.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks)
|
||||
|
||||
if file_size > 50 * 1024 * 1024: # 大于 50MB 记录日志
|
||||
logger.info(f"大文件上传: {file_size / 1024 / 1024:.1f}MB")
|
||||
|
||||
file_data = await loop.run_in_executor(None, read_file_chunked)
|
||||
|
||||
return await self.upload_file(bucket, storage_path, file_data, content_type)
|
||||
|
||||
async def get_signed_url(self, bucket: str, path: str, expires_in: int = 3600) -> str:
|
||||
"""异步获取签名访问链接"""
|
||||
try:
|
||||
@@ -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]:
|
||||
"""异步列出文件"""
|
||||
|
||||
@@ -4,6 +4,7 @@ Platform uploader base classes and utilities
|
||||
from .base_uploader import BaseUploader
|
||||
from .bilibili_uploader import BilibiliUploader
|
||||
from .douyin_uploader import DouyinUploader
|
||||
from .xiaohongshu_uploader import XiaohongshuUploader
|
||||
from .xiaohongshu_uploader import XiaohongshuUploader
|
||||
from .weixin_uploader import WeixinUploader
|
||||
|
||||
__all__ = ['BaseUploader', 'BilibiliUploader', 'DouyinUploader', 'XiaohongshuUploader']
|
||||
__all__ = ['BaseUploader', 'BilibiliUploader', 'DouyinUploader', 'XiaohongshuUploader', 'WeixinUploader']
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1429
backend/app/services/uploader/weixin_uploader.py
Normal file
1429
backend/app/services/uploader/weixin_uploader.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,10 @@
|
||||
"""
|
||||
视频合成服务
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import json
|
||||
import shlex
|
||||
import os
|
||||
import subprocess
|
||||
import json
|
||||
import shlex
|
||||
from pathlib import Path
|
||||
from loguru import logger
|
||||
from typing import Optional
|
||||
@@ -13,18 +13,18 @@ class VideoService:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def _run_ffmpeg(self, cmd: list) -> bool:
|
||||
cmd_str = ' '.join(shlex.quote(str(c)) for c in cmd)
|
||||
logger.debug(f"FFmpeg CMD: {cmd_str}")
|
||||
try:
|
||||
# Synchronous call for BackgroundTasks compatibility
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
shell=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding='utf-8',
|
||||
)
|
||||
def _run_ffmpeg(self, cmd: list) -> bool:
|
||||
cmd_str = ' '.join(shlex.quote(str(c)) for c in cmd)
|
||||
logger.debug(f"FFmpeg CMD: {cmd_str}")
|
||||
try:
|
||||
# Synchronous call for BackgroundTasks compatibility
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
shell=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding='utf-8',
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.error(f"FFmpeg Error: {result.stderr}")
|
||||
return False
|
||||
@@ -33,51 +33,56 @@ class VideoService:
|
||||
logger.error(f"FFmpeg Exception: {e}")
|
||||
return False
|
||||
|
||||
def _get_duration(self, file_path: str) -> float:
|
||||
# Synchronous call for BackgroundTasks compatibility
|
||||
cmd = f'ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "{file_path}"'
|
||||
def _get_duration(self, file_path: str) -> float:
|
||||
# Synchronous call for BackgroundTasks compatibility
|
||||
# 使用参数列表形式避免 shell=True 的命令注入风险
|
||||
cmd = [
|
||||
'ffprobe', '-v', 'error',
|
||||
'-show_entries', 'format=duration',
|
||||
'-of', 'default=noprint_wrappers=1:nokey=1',
|
||||
file_path
|
||||
]
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return float(result.stdout.strip())
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
def mix_audio(
|
||||
self,
|
||||
voice_path: str,
|
||||
bgm_path: str,
|
||||
output_path: str,
|
||||
bgm_volume: float = 0.2
|
||||
) -> str:
|
||||
"""混合人声与背景音乐"""
|
||||
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
volume = max(0.0, min(float(bgm_volume), 1.0))
|
||||
filter_complex = (
|
||||
f"[0:a]volume=1.0[a0];"
|
||||
f"[1:a]volume={volume}[a1];"
|
||||
f"[a0][a1]amix=inputs=2:duration=first:dropout_transition=2:normalize=0[aout]"
|
||||
)
|
||||
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-i", voice_path,
|
||||
"-stream_loop", "-1", "-i", bgm_path,
|
||||
"-filter_complex", filter_complex,
|
||||
"-map", "[aout]",
|
||||
"-c:a", "pcm_s16le",
|
||||
"-shortest",
|
||||
output_path,
|
||||
]
|
||||
|
||||
if self._run_ffmpeg(cmd):
|
||||
return output_path
|
||||
raise RuntimeError("FFmpeg audio mix failed")
|
||||
return 0.0
|
||||
|
||||
def mix_audio(
|
||||
self,
|
||||
voice_path: str,
|
||||
bgm_path: str,
|
||||
output_path: str,
|
||||
bgm_volume: float = 0.2
|
||||
) -> str:
|
||||
"""混合人声与背景音乐"""
|
||||
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
volume = max(0.0, min(float(bgm_volume), 1.0))
|
||||
filter_complex = (
|
||||
f"[0:a]volume=1.0[a0];"
|
||||
f"[1:a]volume={volume}[a1];"
|
||||
f"[a0][a1]amix=inputs=2:duration=first:dropout_transition=2:normalize=0[aout]"
|
||||
)
|
||||
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-i", voice_path,
|
||||
"-stream_loop", "-1", "-i", bgm_path,
|
||||
"-filter_complex", filter_complex,
|
||||
"-map", "[aout]",
|
||||
"-c:a", "pcm_s16le",
|
||||
"-shortest",
|
||||
output_path,
|
||||
]
|
||||
|
||||
if self._run_ffmpeg(cmd):
|
||||
return output_path
|
||||
raise RuntimeError("FFmpeg audio mix failed")
|
||||
|
||||
async def compose(
|
||||
self,
|
||||
|
||||
11
frontend/package-lock.json
generated
11
frontend/package-lock.json
generated
@@ -14,6 +14,7 @@
|
||||
"next": "16.1.1",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"sonner": "^2.0.7",
|
||||
"swr": "^2.3.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -6006,6 +6007,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/sonner": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
|
||||
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
|
||||
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"next": "16.1.1",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"sonner": "^2.0.7",
|
||||
"swr": "^2.3.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { getCurrentUser, User } from "@/shared/lib/auth";
|
||||
import api from "@/shared/api/axios";
|
||||
import { ApiResponse, unwrap } from "@/shared/api/types";
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { getCurrentUser, User } from "@/shared/lib/auth";
|
||||
import api from "@/shared/api/axios";
|
||||
import { ApiResponse, unwrap } from "@/shared/api/types";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface UserListItem {
|
||||
id: string;
|
||||
@@ -18,7 +20,7 @@ interface UserListItem {
|
||||
|
||||
export default function AdminPage() {
|
||||
const router = useRouter();
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
const [, setCurrentUser] = useState<User | null>(null);
|
||||
const [users, setUsers] = useState<UserListItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
@@ -28,6 +30,7 @@ export default function AdminPage() {
|
||||
useEffect(() => {
|
||||
checkAdmin();
|
||||
fetchUsers();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const checkAdmin = async () => {
|
||||
@@ -41,9 +44,9 @@ export default function AdminPage() {
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const { data: res } = await api.get<ApiResponse<UserListItem[]>>('/api/admin/users');
|
||||
setUsers(unwrap(res));
|
||||
} catch (err) {
|
||||
const { data: res } = await api.get<ApiResponse<UserListItem[]>>('/api/admin/users');
|
||||
setUsers(unwrap(res));
|
||||
} catch {
|
||||
setError('获取用户列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -57,7 +60,7 @@ export default function AdminPage() {
|
||||
expires_days: expireDays || null
|
||||
});
|
||||
fetchUsers();
|
||||
} catch (err) {
|
||||
} catch {
|
||||
// axios interceptor handles 401/403
|
||||
} finally {
|
||||
setActivatingId(null);
|
||||
@@ -70,8 +73,8 @@ export default function AdminPage() {
|
||||
try {
|
||||
await api.post(`/api/admin/users/${userId}/deactivate`);
|
||||
fetchUsers();
|
||||
} catch (err) {
|
||||
alert('操作失败');
|
||||
} catch {
|
||||
toast.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -106,9 +109,9 @@ export default function AdminPage() {
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-white">用户管理</h1>
|
||||
<a href="/" className="text-purple-300 hover:text-purple-200">
|
||||
<Link href="/" className="text-purple-300 hover:text-purple-200">
|
||||
← 返回首页
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -3,7 +3,8 @@ import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { AuthProvider } from "@/contexts/AuthContext";
|
||||
import { TaskProvider } from "@/contexts/TaskContext";
|
||||
import GlobalTaskIndicator from "@/components/GlobalTaskIndicator";
|
||||
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -39,10 +40,18 @@ export default function RootLayout({
|
||||
>
|
||||
<AuthProvider>
|
||||
<TaskProvider>
|
||||
<GlobalTaskIndicator />
|
||||
{children}
|
||||
</TaskProvider>
|
||||
</AuthProvider>
|
||||
<Toaster
|
||||
position="top-center"
|
||||
richColors
|
||||
closeButton
|
||||
toastOptions={{
|
||||
duration: 3000,
|
||||
className: "text-sm",
|
||||
}}
|
||||
/>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { login } from "@/shared/lib/auth";
|
||||
import { login } from "@/shared/lib/auth";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
@@ -30,7 +30,7 @@ export default function LoginPage() {
|
||||
} else {
|
||||
setError(result.message || '登录失败');
|
||||
}
|
||||
} catch (err) {
|
||||
} catch {
|
||||
setError('网络错误,请稍后重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { register } from "@/shared/lib/auth";
|
||||
import { register } from "@/shared/lib/auth";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
useRouter(); // 保留以便后续扩展
|
||||
const [phone, setPhone] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
@@ -43,7 +43,7 @@ export default function RegisterPage() {
|
||||
} else {
|
||||
setError(result.message || '注册失败');
|
||||
}
|
||||
} catch (err) {
|
||||
} catch {
|
||||
setError('网络错误,请稍后重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
@@ -44,7 +44,7 @@ export default function AccountSettingsDropdown() {
|
||||
if (confirm('确定要退出登录吗?')) {
|
||||
try {
|
||||
await api.post('/api/auth/logout');
|
||||
} catch (e) { }
|
||||
} catch { }
|
||||
window.location.href = '/login';
|
||||
}
|
||||
};
|
||||
@@ -76,14 +76,15 @@ export default function AccountSettingsDropdown() {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await api.post('/api/auth/logout');
|
||||
} catch (e) { }
|
||||
} catch { }
|
||||
window.location.href = '/login';
|
||||
}, 1500);
|
||||
} else {
|
||||
setError(res.message || '修改失败');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || '修改失败,请重试');
|
||||
} catch (err: unknown) {
|
||||
const axiosErr = err as { response?: { data?: { message?: string } } };
|
||||
setError(axiosErr.response?.data?.message || '修改失败,请重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
import { useTask } from "@/contexts/TaskContext";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
export default function GlobalTaskIndicator() {
|
||||
const { currentTask, isGenerating } = useTask();
|
||||
const pathname = usePathname();
|
||||
|
||||
if (!isGenerating) return null;
|
||||
// 首页已有专门的进度条展示,因此在首页不显示顶部全局进度条
|
||||
if (!isGenerating || pathname === "/") return null;
|
||||
|
||||
return (
|
||||
<div className="fixed top-0 left-0 right-0 z-50 bg-gradient-to-r from-purple-600 to-pink-600 text-white shadow-lg">
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import api from "@/shared/api/axios";
|
||||
import { ApiResponse, unwrap } from "@/shared/api/types";
|
||||
import { useEffect, useCallback } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useScriptExtraction } from "./script-extraction/useScriptExtraction";
|
||||
|
||||
interface ScriptExtractionModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -13,177 +13,66 @@ interface ScriptExtractionModalProps {
|
||||
export default function ScriptExtractionModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onApply
|
||||
onApply,
|
||||
}: ScriptExtractionModalProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [script, setScript] = useState("");
|
||||
const [rewrittenScript, setRewrittenScript] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [doRewrite, setDoRewrite] = useState(true);
|
||||
const [step, setStep] = useState<'config' | 'processing' | 'result'>('config');
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const {
|
||||
isLoading,
|
||||
script,
|
||||
rewrittenScript,
|
||||
error,
|
||||
doRewrite,
|
||||
step,
|
||||
dragActive,
|
||||
selectedFile,
|
||||
activeTab,
|
||||
inputUrl,
|
||||
setDoRewrite,
|
||||
setActiveTab,
|
||||
setInputUrl,
|
||||
handleDrag,
|
||||
handleDrop,
|
||||
handleFileChange,
|
||||
handleExtract,
|
||||
copyToClipboard,
|
||||
resetToConfig,
|
||||
clearSelectedFile,
|
||||
clearInputUrl,
|
||||
} = useScriptExtraction({ isOpen });
|
||||
|
||||
// New state for URL mode
|
||||
const [activeTab, setActiveTab] = useState<'file' | 'url'>('url');
|
||||
const [inputUrl, setInputUrl] = useState("");
|
||||
// 快捷键:ESC 关闭,Enter 提交(仅在 config 步骤)
|
||||
const canExtract = (activeTab === "file" && selectedFile) || (activeTab === "url" && inputUrl.trim());
|
||||
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
} else if (e.key === "Enter" && !e.shiftKey && step === "config" && canExtract && !isLoading) {
|
||||
e.preventDefault();
|
||||
handleExtract();
|
||||
}
|
||||
}, [onClose, step, canExtract, isLoading, handleExtract]);
|
||||
|
||||
// Reset state when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setStep('config');
|
||||
setScript("");
|
||||
setRewrittenScript("");
|
||||
setError(null);
|
||||
setIsLoading(false);
|
||||
setSelectedFile(null);
|
||||
setInputUrl("");
|
||||
setActiveTab('url');
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleDrag = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.type === "dragenter" || e.type === "dragover") {
|
||||
setDragActive(true);
|
||||
} else if (e.type === "dragleave") {
|
||||
setDragActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||
handleFile(e.dataTransfer.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
handleFile(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFile = (file: File) => {
|
||||
const validTypes = ['.mp4', '.mov', '.avi', '.mp3', '.wav', '.m4a'];
|
||||
const ext = file.name.toLowerCase().slice(file.name.lastIndexOf('.'));
|
||||
if (!validTypes.includes(ext)) {
|
||||
setError(`不支持的文件格式 ${ext},请上传视频或音频文件`);
|
||||
return;
|
||||
}
|
||||
setSelectedFile(file);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleExtract = async () => {
|
||||
if (activeTab === 'file' && !selectedFile) {
|
||||
setError("请先上传文件");
|
||||
return;
|
||||
}
|
||||
if (activeTab === 'url' && !inputUrl.trim()) {
|
||||
setError("请先输入视频链接");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setStep('processing');
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
if (activeTab === 'file' && selectedFile) {
|
||||
formData.append('file', selectedFile);
|
||||
} else if (activeTab === 'url') {
|
||||
formData.append('url', inputUrl.trim());
|
||||
}
|
||||
formData.append('rewrite', doRewrite ? 'true' : 'false');
|
||||
|
||||
const { data: res } = await api.post<ApiResponse<{ original_script: string; rewritten_script?: string }>>(
|
||||
'/api/tools/extract-script',
|
||||
formData,
|
||||
{
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 180000 // 3 minutes timeout
|
||||
});
|
||||
|
||||
const payload = unwrap(res);
|
||||
setScript(payload.original_script);
|
||||
setRewrittenScript(payload.rewritten_script || "");
|
||||
setStep('result');
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
const msg = err.response?.data?.message || err.message || "请求失败";
|
||||
setError(msg);
|
||||
setStep('config');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
alert("已复制到剪贴板");
|
||||
}).catch(err => {
|
||||
console.error('Async: Could not copy text: ', err);
|
||||
fallbackCopyTextToClipboard(text);
|
||||
});
|
||||
} else {
|
||||
fallbackCopyTextToClipboard(text);
|
||||
}
|
||||
};
|
||||
|
||||
const fallbackCopyTextToClipboard = (text: string) => {
|
||||
var textArea = document.createElement("textarea");
|
||||
textArea.value = text;
|
||||
|
||||
// Avoid scrolling to bottom
|
||||
textArea.style.top = "0";
|
||||
textArea.style.left = "0";
|
||||
textArea.style.position = "fixed";
|
||||
textArea.style.opacity = "0";
|
||||
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
|
||||
try {
|
||||
var successful = document.execCommand('copy');
|
||||
var msg = successful ? 'successful' : 'unsuccessful';
|
||||
if (successful) {
|
||||
alert("已复制到剪贴板");
|
||||
} else {
|
||||
alert("复制失败,请手动复制");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Fallback: Oops, unable to copy', err);
|
||||
alert("复制失败,请手动复制");
|
||||
}
|
||||
|
||||
document.body.removeChild(textArea);
|
||||
};
|
||||
|
||||
// Close when clicking outside - DISABLED as per user request
|
||||
// const modalRef = useRef<HTMLDivElement>(null);
|
||||
// const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
// if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
|
||||
// onClose();
|
||||
// }
|
||||
// };
|
||||
if (!isOpen) return;
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen, handleKeyDown]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleApplyAndClose = (text: string) => {
|
||||
onApply?.(text);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleExtractNext = () => {
|
||||
resetToConfig();
|
||||
clearSelectedFile();
|
||||
clearInputUrl();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4 animate-in fade-in duration-200"
|
||||
>
|
||||
<div
|
||||
// ref={modalRef}
|
||||
className="bg-[#1a1a1a] border border-white/10 rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col shadow-2xl"
|
||||
>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4 animate-in fade-in duration-200">
|
||||
<div className="bg-[#1a1a1a] border border-white/10 rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-white/10 bg-white/5">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
@@ -199,25 +88,24 @@ export default function ScriptExtractionModal({
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{step === 'config' && (
|
||||
{step === "config" && (
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex p-1 bg-white/5 rounded-xl border border-white/10">
|
||||
<button
|
||||
onClick={() => setActiveTab('url')}
|
||||
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-all ${activeTab === 'url'
|
||||
? 'bg-purple-600 text-white shadow-lg'
|
||||
: 'text-gray-400 hover:text-white hover:bg-white/5'
|
||||
onClick={() => setActiveTab("url")}
|
||||
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-all ${activeTab === "url"
|
||||
? "bg-purple-600 text-white shadow-lg"
|
||||
: "text-gray-400 hover:text-white hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
🔗 粘贴链接
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('file')}
|
||||
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-all ${activeTab === 'file'
|
||||
? 'bg-purple-600 text-white shadow-lg'
|
||||
: 'text-gray-400 hover:text-white hover:bg-white/5'
|
||||
onClick={() => setActiveTab("file")}
|
||||
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-all ${activeTab === "file"
|
||||
? "bg-purple-600 text-white shadow-lg"
|
||||
: "text-gray-400 hover:text-white hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
📂 上传文件
|
||||
@@ -225,7 +113,7 @@ export default function ScriptExtractionModal({
|
||||
</div>
|
||||
|
||||
{/* URL Input Area */}
|
||||
{activeTab === 'url' && (
|
||||
{activeTab === "url" && (
|
||||
<div className="space-y-2 py-4">
|
||||
<div className="relative">
|
||||
<input
|
||||
@@ -237,119 +125,150 @@ export default function ScriptExtractionModal({
|
||||
/>
|
||||
{inputUrl && (
|
||||
<button
|
||||
onClick={() => setInputUrl("")}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white p-1"
|
||||
onClick={clearInputUrl}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 px-1">
|
||||
支持抖音、B站等主流平台分享链接,自动解析下载并提取文案。
|
||||
<p className="text-xs text-gray-500 pl-1">
|
||||
支持抖音、B站、微博、小红书等主流平台视频链接
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File Upload Area */}
|
||||
{activeTab === 'file' && (
|
||||
{activeTab === "file" && (
|
||||
<div
|
||||
className={`
|
||||
relative border-2 border-dashed rounded-xl p-8 text-center transition-all cursor-pointer
|
||||
${dragActive ? 'border-purple-500 bg-purple-500/10' : 'border-white/20 hover:border-white/40 hover:bg-white/5'}
|
||||
${selectedFile ? 'bg-purple-900/10 border-purple-500/50' : ''}
|
||||
`}
|
||||
className={`border-2 border-dashed rounded-xl p-8 text-center transition-colors ${dragActive
|
||||
? "border-purple-500 bg-purple-500/10"
|
||||
: "border-white/10 hover:border-white/20"
|
||||
}`}
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
onChange={handleFileChange}
|
||||
accept=".mp4,.mov,.avi,.mp3,.wav,.m4a"
|
||||
/>
|
||||
|
||||
{selectedFile ? (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="text-4xl mb-2">📄</div>
|
||||
<div className="font-medium text-white break-all max-w-xs">{selectedFile.name}</div>
|
||||
<div className="text-sm text-gray-400 mt-1">{(selectedFile.size / (1024 * 1024)).toFixed(1)} MB</div>
|
||||
<div className="mt-4 text-xs text-purple-400">点击更换文件</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-white">{selectedFile.name}</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
{(selectedFile.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
<button
|
||||
onClick={clearSelectedFile}
|
||||
className="text-xs text-purple-400 hover:text-purple-300"
|
||||
>
|
||||
重新选择
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="text-4xl mb-2">📤</div>
|
||||
<div className="font-medium text-white">点击上传或拖拽文件到此处</div>
|
||||
<div className="text-sm text-gray-400 mt-2">支持 MP4, MOV, MP3, WAV 等音视频格式</div>
|
||||
<div className="space-y-4">
|
||||
<div className="text-4xl">📁</div>
|
||||
<p className="text-gray-400">
|
||||
拖放视频/音频文件到此处,或
|
||||
<label className="text-purple-400 hover:text-purple-300 cursor-pointer">
|
||||
点击选择
|
||||
<input
|
||||
type="file"
|
||||
accept=".mp4,.mov,.avi,.mp3,.wav,.m4a"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
支持 MP4, MOV, AVI, MP3, WAV, M4A
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Options */}
|
||||
<div className="bg-white/5 rounded-xl p-4 border border-white/10">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<div className="flex items-center gap-3 bg-white/5 rounded-xl p-4 border border-white/10">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={doRewrite}
|
||||
onChange={e => setDoRewrite(e.target.checked)}
|
||||
className="w-5 h-5 accent-purple-600 rounded"
|
||||
onChange={(e) => setDoRewrite(e.target.checked)}
|
||||
className="w-4 h-4 rounded bg-white/10 border-white/20 text-purple-500 focus:ring-purple-500"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-white font-medium">启用 AI 洗稿</div>
|
||||
<div className="text-xs text-gray-400">自动将提取的文案重写为更自然流畅的口播稿</div>
|
||||
</div>
|
||||
<span className="text-sm text-gray-300">
|
||||
AI 智能改写(去口语化)
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="p-3 bg-red-500/20 text-red-200 rounded-lg text-sm text-center">
|
||||
❌ {error}
|
||||
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-4">
|
||||
<p className="text-red-400 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-center pt-2">
|
||||
{/* Action Button */}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 py-3 px-4 bg-white/10 hover:bg-white/20 text-white rounded-xl transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExtract}
|
||||
className="w-full sm:w-auto px-10 py-3 bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-xl font-bold hover:shadow-lg hover:from-purple-500 hover:to-pink-500 transition-all transform hover:-translate-y-0.5 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={activeTab === 'file' ? !selectedFile : !inputUrl.trim()}
|
||||
disabled={
|
||||
(activeTab === "file" && !selectedFile) ||
|
||||
(activeTab === "url" && !inputUrl.trim()) ||
|
||||
isLoading
|
||||
}
|
||||
className="flex-1 py-3 px-4 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-xl transition-all font-medium shadow-lg flex items-center justify-center gap-2"
|
||||
>
|
||||
{activeTab === 'url' ? '🔗 解析并提取' : '🚀 开始提取'}
|
||||
{isLoading ? <Loader2 className="w-5 h-5 animate-spin" /> : null}
|
||||
开始提取
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'processing' && (
|
||||
{step === "processing" && (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<div className="relative w-20 h-20 mb-6">
|
||||
<div className="absolute inset-0 border-4 border-purple-500/30 rounded-full"></div>
|
||||
<div className="absolute inset-0 border-4 border-t-purple-500 rounded-full animate-spin"></div>
|
||||
</div>
|
||||
<h4 className="text-xl font-medium text-white mb-2">正在处理中...</h4>
|
||||
<h4 className="text-xl font-medium text-white mb-2">
|
||||
正在处理中...
|
||||
</h4>
|
||||
<p className="text-sm text-gray-400 text-center max-w-sm px-4">
|
||||
{activeTab === 'url' && "正在下载视频..."}<br />
|
||||
{doRewrite ? "正在进行语音识别和 AI 智能改写..." : "正在进行语音识别..."}<br />
|
||||
<span className="opacity-75">大文件可能需要几分钟,请不要关闭窗口</span>
|
||||
{activeTab === "url" && "正在下载视频..."}
|
||||
<br />
|
||||
{doRewrite
|
||||
? "正在进行语音识别和 AI 智能改写..."
|
||||
: "正在进行语音识别..."}
|
||||
<br />
|
||||
<span className="opacity-75">
|
||||
大文件可能需要几分钟,请不要关闭窗口
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'result' && (
|
||||
{step === "result" && (
|
||||
<div className="space-y-6">
|
||||
{rewrittenScript && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<h4 className="font-semibold text-purple-300 flex items-center gap-2">
|
||||
✨ AI 洗稿结果 <span className="text-xs font-normal text-purple-400/70">(推荐)</span>
|
||||
✨ AI 洗稿结果{" "}
|
||||
<span className="text-xs font-normal text-purple-400/70">
|
||||
(推荐)
|
||||
</span>
|
||||
</h4>
|
||||
{onApply && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onApply(rewrittenScript);
|
||||
onClose();
|
||||
}}
|
||||
onClick={() => handleApplyAndClose(rewrittenScript)}
|
||||
className="text-xs bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white px-3 py-1.5 rounded-lg transition-colors flex items-center gap-1 shadow-sm"
|
||||
>
|
||||
📥 填入
|
||||
@@ -377,10 +296,7 @@ export default function ScriptExtractionModal({
|
||||
</h4>
|
||||
{onApply && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onApply(script);
|
||||
onClose();
|
||||
}}
|
||||
onClick={() => handleApplyAndClose(script)}
|
||||
className="text-xs bg-white/10 hover:bg-white/20 text-white px-3 py-1.5 rounded-lg transition-colors flex items-center gap-1"
|
||||
>
|
||||
📥 填入
|
||||
@@ -402,14 +318,7 @@ export default function ScriptExtractionModal({
|
||||
|
||||
<div className="flex justify-center pt-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
setStep('config');
|
||||
setScript("");
|
||||
setRewrittenScript("");
|
||||
setSelectedFile(null);
|
||||
setInputUrl("");
|
||||
// Keep current tab active
|
||||
}}
|
||||
onClick={handleExtractNext}
|
||||
className="px-6 py-2 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors"
|
||||
>
|
||||
提取下一个
|
||||
|
||||
210
frontend/src/components/script-extraction/useScriptExtraction.ts
Normal file
210
frontend/src/components/script-extraction/useScriptExtraction.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import api from "@/shared/api/axios";
|
||||
import { ApiResponse, unwrap } from "@/shared/api/types";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export type ExtractionStep = "config" | "processing" | "result";
|
||||
export type InputTab = "file" | "url";
|
||||
|
||||
const VALID_FILE_TYPES = [".mp4", ".mov", ".avi", ".mp3", ".wav", ".m4a"];
|
||||
|
||||
interface UseScriptExtractionOptions {
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export const useScriptExtraction = ({ isOpen }: UseScriptExtractionOptions) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [script, setScript] = useState("");
|
||||
const [rewrittenScript, setRewrittenScript] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [doRewrite, setDoRewrite] = useState(true);
|
||||
const [step, setStep] = useState<ExtractionStep>("config");
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<InputTab>("url");
|
||||
const [inputUrl, setInputUrl] = useState("");
|
||||
|
||||
// Reset state when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setStep("config");
|
||||
setScript("");
|
||||
setRewrittenScript("");
|
||||
setError(null);
|
||||
setIsLoading(false);
|
||||
setSelectedFile(null);
|
||||
setInputUrl("");
|
||||
setActiveTab("url");
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleDrag = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.type === "dragenter" || e.type === "dragover") {
|
||||
setDragActive(true);
|
||||
} else if (e.type === "dragleave") {
|
||||
setDragActive(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleFile = useCallback((file: File) => {
|
||||
const ext = file.name.toLowerCase().slice(file.name.lastIndexOf("."));
|
||||
if (!VALID_FILE_TYPES.includes(ext)) {
|
||||
setError(`不支持的文件格式 ${ext},请上传视频或音频文件`);
|
||||
return;
|
||||
}
|
||||
setSelectedFile(file);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
if (e.dataTransfer.files?.[0]) {
|
||||
handleFile(e.dataTransfer.files[0]);
|
||||
}
|
||||
},
|
||||
[handleFile]
|
||||
);
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files?.[0]) {
|
||||
handleFile(e.target.files[0]);
|
||||
}
|
||||
},
|
||||
[handleFile]
|
||||
);
|
||||
|
||||
const handleExtract = useCallback(async () => {
|
||||
if (activeTab === "file" && !selectedFile) {
|
||||
setError("请先上传文件");
|
||||
return;
|
||||
}
|
||||
if (activeTab === "url" && !inputUrl.trim()) {
|
||||
setError("请先输入视频链接");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setStep("processing");
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
if (activeTab === "file" && selectedFile) {
|
||||
formData.append("file", selectedFile);
|
||||
} else if (activeTab === "url") {
|
||||
formData.append("url", inputUrl.trim());
|
||||
}
|
||||
formData.append("rewrite", doRewrite ? "true" : "false");
|
||||
|
||||
const { data: res } = await api.post<
|
||||
ApiResponse<{ original_script: string; rewritten_script?: string }>
|
||||
>("/api/tools/extract-script", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
timeout: 180000, // 3 minutes timeout
|
||||
});
|
||||
|
||||
const payload = unwrap(res);
|
||||
setScript(payload.original_script);
|
||||
setRewrittenScript(payload.rewritten_script || "");
|
||||
setStep("result");
|
||||
} catch (err: unknown) {
|
||||
console.error(err);
|
||||
const axiosErr = err as {
|
||||
response?: { data?: { message?: string } };
|
||||
message?: string;
|
||||
};
|
||||
const msg =
|
||||
axiosErr.response?.data?.message || axiosErr.message || "请求失败";
|
||||
setError(msg);
|
||||
setStep("config");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [activeTab, selectedFile, inputUrl, doRewrite]);
|
||||
|
||||
const copyToClipboard = useCallback((text: string) => {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => {
|
||||
toast.success("已复制到剪贴板");
|
||||
})
|
||||
.catch(() => {
|
||||
fallbackCopyTextToClipboard(text);
|
||||
});
|
||||
} else {
|
||||
fallbackCopyTextToClipboard(text);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fallbackCopyTextToClipboard = (text: string) => {
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = text;
|
||||
textArea.style.top = "0";
|
||||
textArea.style.left = "0";
|
||||
textArea.style.position = "fixed";
|
||||
textArea.style.opacity = "0";
|
||||
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
|
||||
try {
|
||||
const successful = document.execCommand("copy");
|
||||
if (successful) {
|
||||
toast.success("已复制到剪贴板");
|
||||
} else {
|
||||
toast.error("复制失败,请手动复制");
|
||||
}
|
||||
} catch {
|
||||
toast.error("复制失败,请手动复制");
|
||||
}
|
||||
|
||||
document.body.removeChild(textArea);
|
||||
};
|
||||
|
||||
const resetToConfig = useCallback(() => {
|
||||
setStep("config");
|
||||
}, []);
|
||||
|
||||
const clearSelectedFile = useCallback(() => {
|
||||
setSelectedFile(null);
|
||||
}, []);
|
||||
|
||||
const clearInputUrl = useCallback(() => {
|
||||
setInputUrl("");
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// State
|
||||
isLoading,
|
||||
script,
|
||||
rewrittenScript,
|
||||
error,
|
||||
doRewrite,
|
||||
step,
|
||||
dragActive,
|
||||
selectedFile,
|
||||
activeTab,
|
||||
inputUrl,
|
||||
// Setters
|
||||
setDoRewrite,
|
||||
setActiveTab,
|
||||
setInputUrl,
|
||||
// Handlers
|
||||
handleDrag,
|
||||
handleDrop,
|
||||
handleFileChange,
|
||||
handleExtract,
|
||||
copyToClipboard,
|
||||
resetToConfig,
|
||||
clearSelectedFile,
|
||||
clearInputUrl,
|
||||
};
|
||||
};
|
||||
@@ -3,15 +3,8 @@
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from "react";
|
||||
import api from "@/shared/api/axios";
|
||||
import { ApiResponse, unwrap } from "@/shared/api/types";
|
||||
import { User } from "@/shared/types/user";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
phone: string;
|
||||
username: string | null;
|
||||
role: string;
|
||||
is_active: boolean;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
userId: string | null;
|
||||
|
||||
@@ -87,7 +87,9 @@ export function TaskProvider({ children }: { children: ReactNode }) {
|
||||
const savedTaskId = localStorage.getItem(taskKey);
|
||||
if (savedTaskId) {
|
||||
console.log("[TaskContext] 恢复任务:", savedTaskId);
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setTaskId(savedTaskId);
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setIsGenerating(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,13 +9,15 @@ export interface BgmItem {
|
||||
}
|
||||
|
||||
interface UseBgmOptions {
|
||||
storageKey: string;
|
||||
|
||||
selectedBgmId: string;
|
||||
setSelectedBgmId: React.Dispatch<React.SetStateAction<string>>;
|
||||
}
|
||||
|
||||
export const useBgm = ({
|
||||
storageKey,
|
||||
|
||||
// selectedBgmId 用于参数类型推断,不在此 hook 内部直接使用
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
selectedBgmId,
|
||||
setSelectedBgmId,
|
||||
}: UseBgmOptions) => {
|
||||
@@ -32,21 +34,20 @@ export const useBgm = ({
|
||||
const items: BgmItem[] = Array.isArray(payload.bgm) ? payload.bgm : [];
|
||||
setBgmList(items);
|
||||
|
||||
const savedBgmId = localStorage.getItem(`vigent_${storageKey}_bgmId`);
|
||||
setSelectedBgmId((prev) => {
|
||||
if (prev && items.some((item) => item.id === prev)) return prev;
|
||||
if (savedBgmId && items.some((item) => item.id === savedBgmId)) return savedBgmId;
|
||||
return items[0]?.id || "";
|
||||
});
|
||||
} catch (error: any) {
|
||||
const message = error?.response?.data?.message || error?.message || '加载失败';
|
||||
} catch (error: unknown) {
|
||||
const axiosErr = error as { response?: { data?: { message?: string } }; message?: string };
|
||||
const message = axiosErr?.response?.data?.message || axiosErr?.message || '加载失败';
|
||||
setBgmError(message);
|
||||
setBgmList([]);
|
||||
console.error("获取背景音乐失败:", error);
|
||||
} finally {
|
||||
setBgmLoading(false);
|
||||
}
|
||||
}, [setSelectedBgmId, storageKey]);
|
||||
}, [setSelectedBgmId]);
|
||||
|
||||
return {
|
||||
bgmList,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import api from "@/shared/api/axios";
|
||||
import { ApiResponse, unwrap } from "@/shared/api/types";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface GeneratedVideo {
|
||||
id: string;
|
||||
@@ -11,7 +12,7 @@ interface GeneratedVideo {
|
||||
}
|
||||
|
||||
interface UseGeneratedVideosOptions {
|
||||
storageKey: string;
|
||||
|
||||
selectedVideoId: string | null;
|
||||
setSelectedVideoId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
setGeneratedVideo: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
@@ -19,7 +20,7 @@ interface UseGeneratedVideosOptions {
|
||||
}
|
||||
|
||||
export const useGeneratedVideos = ({
|
||||
storageKey,
|
||||
|
||||
selectedVideoId,
|
||||
setSelectedVideoId,
|
||||
setGeneratedVideo,
|
||||
@@ -36,32 +37,42 @@ export const useGeneratedVideos = ({
|
||||
const videos: GeneratedVideo[] = payload.videos || [];
|
||||
setGeneratedVideos(videos);
|
||||
|
||||
const savedSelectedVideoId = localStorage.getItem(`vigent_${storageKey}_selectedVideoId`);
|
||||
const currentId = preferVideoId || selectedVideoId || savedSelectedVideoId || null;
|
||||
let nextId: string | null = null;
|
||||
let nextUrl: string | null = null;
|
||||
|
||||
if (currentId) {
|
||||
const found = videos.find(v => v.id === currentId);
|
||||
if (found) {
|
||||
nextId = found.id;
|
||||
nextUrl = resolveMediaUrl(found.path);
|
||||
// 只在明确指定 preferVideoId 时才自动选中
|
||||
// "__latest__" 表示选中最新的(第一个),用于新视频生成完成后
|
||||
// 其他值表示选中指定 ID 的视频
|
||||
// 不传则不设置选中项,由 useHomePersistence 恢复
|
||||
if (preferVideoId && videos.length > 0) {
|
||||
if (preferVideoId === "__latest__") {
|
||||
setSelectedVideoId(videos[0].id);
|
||||
setGeneratedVideo(resolveMediaUrl(videos[0].path));
|
||||
} else {
|
||||
const found = videos.find(v => v.id === preferVideoId);
|
||||
if (found) {
|
||||
setSelectedVideoId(found.id);
|
||||
setGeneratedVideo(resolveMediaUrl(found.path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!nextId && videos.length > 0) {
|
||||
nextId = videos[0].id;
|
||||
nextUrl = resolveMediaUrl(videos[0].path);
|
||||
}
|
||||
|
||||
if (nextId) {
|
||||
setSelectedVideoId(nextId);
|
||||
setGeneratedVideo(nextUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取历史视频失败:", error);
|
||||
}
|
||||
}, [resolveMediaUrl, selectedVideoId, setGeneratedVideo, setSelectedVideoId, storageKey]);
|
||||
}, [resolveMediaUrl, setGeneratedVideo, setSelectedVideoId]);
|
||||
|
||||
// 【核心修复】当 selectedVideoId 变化时(例如从持久化恢复),自动同步 generatedVideo (URL)
|
||||
// 之前的逻辑只在 fetch 时设置,导致外部恢复 ID 后 URL 不同步
|
||||
useEffect(() => {
|
||||
if (!selectedVideoId || generatedVideos.length === 0) {
|
||||
// 如果没有选中 ID,或者列表为空,不要轻易置空 URL,除非明确需要
|
||||
// 这里保持现状,由 fetchGeneratedVideos 或 deleteVideo 处理置空
|
||||
return;
|
||||
}
|
||||
|
||||
const video = generatedVideos.find(v => v.id === selectedVideoId);
|
||||
if (video) {
|
||||
const url = resolveMediaUrl(video.path);
|
||||
setGeneratedVideo(url);
|
||||
}
|
||||
}, [selectedVideoId, generatedVideos, resolveMediaUrl, setGeneratedVideo]);
|
||||
|
||||
const deleteVideo = useCallback(async (videoId: string) => {
|
||||
if (!confirm("确定要删除这个视频吗?")) return;
|
||||
@@ -73,7 +84,7 @@ export const useGeneratedVideos = ({
|
||||
}
|
||||
fetchGeneratedVideos();
|
||||
} catch (error) {
|
||||
alert("删除失败: " + error);
|
||||
toast.error("删除失败: " + error);
|
||||
}
|
||||
}, [fetchGeneratedVideos, selectedVideoId, setGeneratedVideo, setSelectedVideoId]);
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ import { clampTitle } from "@/shared/lib/title";
|
||||
import { useTitleInput } from "@/shared/hooks/useTitleInput";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useTask } from "@/contexts/TaskContext";
|
||||
import { toast } from "sonner";
|
||||
import { usePublishPrefetch } from "@/shared/hooks/usePublishPrefetch";
|
||||
import { PublishAccount } from "@/shared/types/publish";
|
||||
import { useBgm } from "@/features/home/model/useBgm";
|
||||
import { useGeneratedVideos } from "@/features/home/model/useGeneratedVideos";
|
||||
import { useHomePersistence } from "@/features/home/model/useHomePersistence";
|
||||
@@ -30,26 +33,7 @@ const VOICES = [
|
||||
{ id: "zh-CN-XiaoyiNeural", name: "晓伊 (女声-温柔)" },
|
||||
];
|
||||
|
||||
const PUBLISH_PREFETCH_KEY = "vigent_publish_prefetch_v1";
|
||||
const PUBLISH_PREFETCH_TTL = 2 * 60 * 1000;
|
||||
|
||||
interface PublishAccount {
|
||||
platform: string;
|
||||
name: string;
|
||||
logged_in: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface PublishVideo {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface PublishPrefetchCache {
|
||||
ts: number;
|
||||
accounts?: PublishAccount[];
|
||||
videos?: PublishVideo[];
|
||||
}
|
||||
|
||||
const FIXED_REF_TEXT =
|
||||
"其实生活中有许多美好的瞬间,比如清晨的阳光,或者一杯温热的清茶。希望这次生成的音色能够自然、流畅,完美还原出我最真实的声音状态。";
|
||||
@@ -105,6 +89,7 @@ export const useHomeController = () => {
|
||||
|
||||
// 使用全局任务状态
|
||||
const { currentTask, isGenerating, startTask } = useTask();
|
||||
const prevIsGenerating = useRef(isGenerating);
|
||||
|
||||
const [generatedVideo, setGeneratedVideo] = useState<string | null>(null);
|
||||
const [selectedVideoId, setSelectedVideoId] = useState<string | null>(null);
|
||||
@@ -114,13 +99,15 @@ export const useHomeController = () => {
|
||||
const [enableSubtitles, setEnableSubtitles] = useState<boolean>(true);
|
||||
const [selectedSubtitleStyleId, setSelectedSubtitleStyleId] = useState<string>("");
|
||||
const [selectedTitleStyleId, setSelectedTitleStyleId] = useState<string>("");
|
||||
const [subtitleFontSize, setSubtitleFontSize] = useState<number>(60);
|
||||
const [titleFontSize, setTitleFontSize] = useState<number>(90);
|
||||
const [subtitleFontSize, setSubtitleFontSize] = useState<number>(80);
|
||||
const [titleFontSize, setTitleFontSize] = useState<number>(120);
|
||||
const [subtitleSizeLocked, setSubtitleSizeLocked] = useState<boolean>(false);
|
||||
const [titleSizeLocked, setTitleSizeLocked] = useState<boolean>(false);
|
||||
const [titleTopMargin, setTitleTopMargin] = useState<number>(62);
|
||||
const [subtitleBottomMargin, setSubtitleBottomMargin] = useState<number>(80);
|
||||
const [showStylePreview, setShowStylePreview] = useState<boolean>(false);
|
||||
const [materialDimensions, setMaterialDimensions] = useState<{ width: number; height: number } | null>(null);
|
||||
const [previewContainerWidth, setPreviewContainerWidth] = useState<number>(0);
|
||||
|
||||
|
||||
// 背景音乐相关状态
|
||||
const [selectedBgmId, setSelectedBgmId] = useState<string>("");
|
||||
@@ -139,7 +126,7 @@ export const useHomeController = () => {
|
||||
const [editMaterialName, setEditMaterialName] = useState("");
|
||||
const bgmItemRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
const bgmListContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const titlePreviewContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const materialItemRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
const videoItemRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
|
||||
@@ -166,8 +153,8 @@ export const useHomeController = () => {
|
||||
await api.put(`/api/ref-audios/${encodeURIComponent(audioId)}`, { new_name: editName });
|
||||
setEditingAudioId(null);
|
||||
fetchRefAudios(); // 刷新列表
|
||||
} catch (err: any) {
|
||||
alert("重命名失败: " + err);
|
||||
} catch (err: unknown) {
|
||||
toast.error("重命名失败: " + String(err));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -200,9 +187,10 @@ export const useHomeController = () => {
|
||||
setEditingMaterialId(null);
|
||||
setEditMaterialName("");
|
||||
fetchMaterials();
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.response?.data?.message || err.message || String(err);
|
||||
alert(`重命名失败: ${errorMsg}`);
|
||||
} catch (err: unknown) {
|
||||
const axiosErr = err as { response?: { data?: { message?: string } }; message?: string };
|
||||
const errorMsg = axiosErr.response?.data?.message || axiosErr.message || String(err);
|
||||
toast.error(`重命名失败: ${errorMsg}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -225,26 +213,8 @@ export const useHomeController = () => {
|
||||
// 获取存储 key 的前缀(登录用户使用 userId,未登录使用 guest)
|
||||
const storageKey = userId || "guest";
|
||||
|
||||
const readPublishPrefetch = () => {
|
||||
if (typeof window === "undefined") return null;
|
||||
const raw = sessionStorage.getItem(PUBLISH_PREFETCH_KEY);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const cache = JSON.parse(raw) as PublishPrefetchCache;
|
||||
if (!cache?.ts) return null;
|
||||
if (Date.now() - cache.ts > PUBLISH_PREFETCH_TTL) return null;
|
||||
return cache;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const updatePublishPrefetch = (patch: Partial<PublishPrefetchCache>) => {
|
||||
if (typeof window === "undefined") return;
|
||||
const existing = readPublishPrefetch() || { ts: Date.now() };
|
||||
const next = { ...existing, ...patch, ts: Date.now() };
|
||||
sessionStorage.setItem(PUBLISH_PREFETCH_KEY, JSON.stringify(next));
|
||||
};
|
||||
// 使用共用的发布预加载 hook
|
||||
const { updatePrefetch: updatePublishPrefetch } = usePublishPrefetch();
|
||||
|
||||
const {
|
||||
materials,
|
||||
@@ -270,7 +240,7 @@ export const useHomeController = () => {
|
||||
refreshTitleStyles,
|
||||
} = useTitleSubtitleStyles({
|
||||
isAuthLoading,
|
||||
storageKey,
|
||||
|
||||
setSelectedSubtitleStyleId,
|
||||
setSelectedTitleStyleId,
|
||||
});
|
||||
@@ -296,7 +266,7 @@ export const useHomeController = () => {
|
||||
bgmError,
|
||||
fetchBgmList,
|
||||
} = useBgm({
|
||||
storageKey,
|
||||
|
||||
selectedBgmId,
|
||||
setSelectedBgmId,
|
||||
});
|
||||
@@ -319,7 +289,7 @@ export const useHomeController = () => {
|
||||
fetchGeneratedVideos,
|
||||
deleteVideo,
|
||||
} = useGeneratedVideos({
|
||||
storageKey,
|
||||
|
||||
selectedVideoId,
|
||||
setSelectedVideoId,
|
||||
setGeneratedVideo,
|
||||
@@ -347,15 +317,18 @@ export const useHomeController = () => {
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isAuthLoading, userId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (generatedVideos.length === 0) return;
|
||||
const prefetched = generatedVideos.map((video) => ({
|
||||
id: video.id,
|
||||
name: formatDate(video.created_at) + ` (${video.size_mb.toFixed(1)}MB)`,
|
||||
path: video.path.startsWith("/") ? video.path.slice(1) : video.path,
|
||||
}));
|
||||
updatePublishPrefetch({ videos: prefetched });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [generatedVideos]);
|
||||
|
||||
const { isRestored } = useHomePersistence({
|
||||
@@ -383,6 +356,10 @@ export const useHomeController = () => {
|
||||
setTitleFontSize,
|
||||
setSubtitleSizeLocked,
|
||||
setTitleSizeLocked,
|
||||
titleTopMargin,
|
||||
setTitleTopMargin,
|
||||
subtitleBottomMargin,
|
||||
setSubtitleBottomMargin,
|
||||
selectedBgmId,
|
||||
setSelectedBgmId,
|
||||
bgmVolume,
|
||||
@@ -417,8 +394,21 @@ export const useHomeController = () => {
|
||||
refreshTitleStyles(),
|
||||
fetchBgmList(),
|
||||
]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isAuthLoading]);
|
||||
|
||||
// 监听任务完成,自动刷新视频列表并选中最新
|
||||
useEffect(() => {
|
||||
if (prevIsGenerating.current && !isGenerating) {
|
||||
if (currentTask?.status === "completed") {
|
||||
void fetchGeneratedVideos("__latest__");
|
||||
} else {
|
||||
void fetchGeneratedVideos();
|
||||
}
|
||||
}
|
||||
prevIsGenerating.current = isGenerating;
|
||||
}, [isGenerating, currentTask, fetchGeneratedVideos]);
|
||||
|
||||
useEffect(() => {
|
||||
const material = materials.find((item) => item.id === selectedMaterial);
|
||||
if (!material?.path) {
|
||||
@@ -462,25 +452,6 @@ export const useHomeController = () => {
|
||||
};
|
||||
}, [materials, selectedMaterial]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showStylePreview) return;
|
||||
const container = titlePreviewContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
setPreviewContainerWidth(container.getBoundingClientRect().width);
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
setPreviewContainerWidth(entry.contentRect.width);
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserver.observe(container);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [showStylePreview]);
|
||||
|
||||
useEffect(() => {
|
||||
if (subtitleSizeLocked || subtitleStyles.length === 0) return;
|
||||
@@ -502,16 +473,8 @@ export const useHomeController = () => {
|
||||
}
|
||||
}, [titleStyles, selectedTitleStyleId, titleSizeLocked]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enableBgm || selectedBgmId || bgmList.length === 0) return;
|
||||
const savedBgmId = localStorage.getItem(`vigent_${storageKey}_bgmId`);
|
||||
const savedItem = savedBgmId && bgmList.find((item) => item.id === savedBgmId);
|
||||
if (savedItem) {
|
||||
setSelectedBgmId(savedBgmId);
|
||||
return;
|
||||
}
|
||||
setSelectedBgmId(bgmList[0].id);
|
||||
}, [enableBgm, selectedBgmId, bgmList, storageKey, setSelectedBgmId]);
|
||||
// 移除重复的 BGM 持久化恢复逻辑 (已统一移动到 useHomePersistence 中)
|
||||
// useEffect(() => { ... })
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedBgmId) return;
|
||||
@@ -530,6 +493,23 @@ export const useHomeController = () => {
|
||||
}
|
||||
}, [selectedMaterial, materials]);
|
||||
|
||||
// 【修复】历史视频默认选中逻辑
|
||||
// 当持久化恢复完成,且列表加载完毕,如果没选中任何视频,默认选中第一个
|
||||
useEffect(() => {
|
||||
if (isRestored && generatedVideos.length > 0 && !selectedVideoId) {
|
||||
const firstId = generatedVideos[0].id;
|
||||
setSelectedVideoId(firstId);
|
||||
setGeneratedVideo(resolveMediaUrl(generatedVideos[0].path));
|
||||
}
|
||||
}, [isRestored, generatedVideos, selectedVideoId, setSelectedVideoId, setGeneratedVideo, resolveMediaUrl]);
|
||||
|
||||
// 【修复】BGM 默认选中逻辑
|
||||
useEffect(() => {
|
||||
if (isRestored && bgmList.length > 0 && !selectedBgmId && enableBgm) {
|
||||
setSelectedBgmId(bgmList[0].id);
|
||||
}
|
||||
}, [isRestored, bgmList, selectedBgmId, enableBgm, setSelectedBgmId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedVideoId) return;
|
||||
const target = videoItemRefs.current[selectedVideoId];
|
||||
@@ -593,7 +573,7 @@ export const useHomeController = () => {
|
||||
setRecordingTime((prev) => prev + 1);
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
alert("无法访问麦克风,请检查权限设置");
|
||||
toast.error("无法访问麦克风,请检查权限设置");
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
@@ -631,7 +611,7 @@ export const useHomeController = () => {
|
||||
// AI 生成标题和标签
|
||||
const handleGenerateMeta = async () => {
|
||||
if (!text.trim()) {
|
||||
alert("请先输入口播文案");
|
||||
toast.error("请先输入口播文案");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -649,10 +629,11 @@ export const useHomeController = () => {
|
||||
|
||||
// 同步到发布页 localStorage
|
||||
localStorage.setItem(`vigent_${storageKey}_publish_tags`, JSON.stringify(payload.tags || []));
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
console.error("AI generate meta failed:", err);
|
||||
const errorMsg = err.response?.data?.message || err.message || String(err);
|
||||
alert(`AI 生成失败: ${errorMsg}`);
|
||||
const axiosErr = err as { response?: { data?: { message?: string } }; message?: string };
|
||||
const errorMsg = axiosErr.response?.data?.message || axiosErr.message || String(err);
|
||||
toast.error(`AI 生成失败: ${errorMsg}`);
|
||||
} finally {
|
||||
setIsGeneratingMeta(false);
|
||||
}
|
||||
@@ -661,20 +642,20 @@ export const useHomeController = () => {
|
||||
// 生成视频
|
||||
const handleGenerate = async () => {
|
||||
if (!selectedMaterial || !text.trim()) {
|
||||
alert("请先选择素材并填写文案");
|
||||
toast.error("请先选择素材并填写文案");
|
||||
return;
|
||||
}
|
||||
|
||||
// 声音克隆模式校验
|
||||
if (ttsMode === "voiceclone") {
|
||||
if (!selectedRefAudio) {
|
||||
alert("请选择或上传参考音频");
|
||||
toast.error("请选择或上传参考音频");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (enableBgm && !selectedBgmId) {
|
||||
alert("请选择背景音乐");
|
||||
toast.error("请选择背景音乐");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -684,12 +665,12 @@ export const useHomeController = () => {
|
||||
// 查找选中的素材对象以获取路径
|
||||
const materialObj = materials.find((m) => m.id === selectedMaterial);
|
||||
if (!materialObj) {
|
||||
alert("素材数据异常");
|
||||
toast.error("素材数据异常");
|
||||
return;
|
||||
}
|
||||
|
||||
// 构建请求参数
|
||||
const payload: Record<string, any> = {
|
||||
const payload: Record<string, unknown> = {
|
||||
material_path: materialObj.path,
|
||||
text: text,
|
||||
tts_mode: ttsMode,
|
||||
@@ -713,6 +694,14 @@ export const useHomeController = () => {
|
||||
payload.title_font_size = Math.round(titleFontSize);
|
||||
}
|
||||
|
||||
if (videoTitle.trim()) {
|
||||
payload.title_top_margin = Math.round(titleTopMargin);
|
||||
}
|
||||
|
||||
if (enableSubtitles) {
|
||||
payload.subtitle_bottom_margin = Math.round(subtitleBottomMargin);
|
||||
}
|
||||
|
||||
if (enableBgm && selectedBgmId) {
|
||||
payload.bgm_id = selectedBgmId;
|
||||
payload.bgm_volume = bgmVolume;
|
||||
@@ -816,14 +805,16 @@ export const useHomeController = () => {
|
||||
subtitleFontSize,
|
||||
setSubtitleFontSize,
|
||||
setSubtitleSizeLocked,
|
||||
titleTopMargin,
|
||||
setTitleTopMargin,
|
||||
subtitleBottomMargin,
|
||||
setSubtitleBottomMargin,
|
||||
enableSubtitles,
|
||||
setEnableSubtitles,
|
||||
resolveAssetUrl,
|
||||
getFontFormat,
|
||||
buildTextShadow,
|
||||
previewContainerWidth,
|
||||
materialDimensions,
|
||||
titlePreviewContainerRef,
|
||||
ttsMode,
|
||||
setTtsMode,
|
||||
voices: VOICES,
|
||||
|
||||
@@ -35,6 +35,10 @@ interface UseHomePersistenceOptions {
|
||||
setTitleFontSize: React.Dispatch<React.SetStateAction<number>>;
|
||||
setSubtitleSizeLocked: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setTitleSizeLocked: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
titleTopMargin: number;
|
||||
setTitleTopMargin: React.Dispatch<React.SetStateAction<number>>;
|
||||
subtitleBottomMargin: number;
|
||||
setSubtitleBottomMargin: React.Dispatch<React.SetStateAction<number>>;
|
||||
selectedBgmId: string;
|
||||
setSelectedBgmId: React.Dispatch<React.SetStateAction<string>>;
|
||||
bgmVolume: number;
|
||||
@@ -71,6 +75,10 @@ export const useHomePersistence = ({
|
||||
setTitleFontSize,
|
||||
setSubtitleSizeLocked,
|
||||
setTitleSizeLocked,
|
||||
titleTopMargin,
|
||||
setTitleTopMargin,
|
||||
subtitleBottomMargin,
|
||||
setSubtitleBottomMargin,
|
||||
selectedBgmId,
|
||||
setSelectedBgmId,
|
||||
bgmVolume,
|
||||
@@ -100,6 +108,8 @@ export const useHomePersistence = ({
|
||||
const savedSelectedVideoId = localStorage.getItem(`vigent_${storageKey}_selectedVideoId`);
|
||||
const savedBgmVolume = localStorage.getItem(`vigent_${storageKey}_bgmVolume`);
|
||||
const savedEnableBgm = localStorage.getItem(`vigent_${storageKey}_enableBgm`);
|
||||
const savedTitleTopMargin = localStorage.getItem(`vigent_${storageKey}_titleTopMargin`);
|
||||
const savedSubtitleBottomMargin = localStorage.getItem(`vigent_${storageKey}_subtitleBottomMargin`);
|
||||
|
||||
setText(savedText || "大家好,欢迎来到我的频道,今天给大家分享一些有趣的内容。");
|
||||
setVideoTitle(savedTitle ? clampTitle(savedTitle) : "");
|
||||
@@ -132,6 +142,16 @@ export const useHomePersistence = ({
|
||||
if (savedEnableBgm !== null) setEnableBgm(savedEnableBgm === 'true');
|
||||
if (savedSelectedVideoId) setSelectedVideoId(savedSelectedVideoId);
|
||||
|
||||
if (savedTitleTopMargin) {
|
||||
const parsed = parseInt(savedTitleTopMargin, 10);
|
||||
if (!Number.isNaN(parsed)) setTitleTopMargin(parsed);
|
||||
}
|
||||
if (savedSubtitleBottomMargin) {
|
||||
const parsed = parseInt(savedSubtitleBottomMargin, 10);
|
||||
if (!Number.isNaN(parsed)) setSubtitleBottomMargin(parsed);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setIsRestored(true);
|
||||
}, [
|
||||
isAuthLoading,
|
||||
@@ -148,6 +168,8 @@ export const useHomePersistence = ({
|
||||
setText,
|
||||
setTitleFontSize,
|
||||
setTitleSizeLocked,
|
||||
setTitleTopMargin,
|
||||
setSubtitleBottomMargin,
|
||||
setTtsMode,
|
||||
setVideoTitle,
|
||||
setVoice,
|
||||
@@ -212,6 +234,18 @@ export const useHomePersistence = ({
|
||||
}
|
||||
}, [titleFontSize, storageKey, isRestored]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRestored) {
|
||||
localStorage.setItem(`vigent_${storageKey}_titleTopMargin`, String(titleTopMargin));
|
||||
}
|
||||
}, [titleTopMargin, storageKey, isRestored]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRestored) {
|
||||
localStorage.setItem(`vigent_${storageKey}_subtitleBottomMargin`, String(subtitleBottomMargin));
|
||||
}
|
||||
}, [subtitleBottomMargin, storageKey, isRestored]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRestored) {
|
||||
localStorage.setItem(`vigent_${storageKey}_bgmId`, selectedBgmId);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import api from "@/shared/api/axios";
|
||||
import { ApiResponse, unwrap } from "@/shared/api/types";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Material {
|
||||
id: string;
|
||||
@@ -40,19 +41,20 @@ export const useMaterials = ({
|
||||
setMaterials(nextMaterials);
|
||||
setLastMaterialCount(nextMaterials.length);
|
||||
|
||||
const nextSelected = nextMaterials.find((item: Material) => item.id === selectedMaterial)?.id
|
||||
|| nextMaterials[0]?.id
|
||||
|| "";
|
||||
if (nextSelected !== selectedMaterial) {
|
||||
setSelectedMaterial(nextSelected);
|
||||
}
|
||||
setSelectedMaterial((prev) => {
|
||||
// 如果当前选中的素材在列表中依然存在,保持选中
|
||||
const exists = nextMaterials.some((item) => item.id === prev);
|
||||
if (exists) return prev;
|
||||
// 否则默认选中第一个
|
||||
return nextMaterials[0]?.id || "";
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("获取素材失败:", error);
|
||||
setFetchError(String(error));
|
||||
} finally {
|
||||
setIsFetching(false);
|
||||
}
|
||||
}, [selectedMaterial, setSelectedMaterial]);
|
||||
}, [setSelectedMaterial]);
|
||||
|
||||
const deleteMaterial = useCallback(async (materialId: string) => {
|
||||
if (!confirm("确定要删除这个素材吗?")) return;
|
||||
@@ -63,7 +65,7 @@ export const useMaterials = ({
|
||||
setSelectedMaterial("");
|
||||
}
|
||||
} catch (error) {
|
||||
alert("删除失败: " + error);
|
||||
toast.error("删除失败: " + error);
|
||||
}
|
||||
}, [fetchMaterials, selectedMaterial, setSelectedMaterial]);
|
||||
|
||||
@@ -99,10 +101,11 @@ export const useMaterials = ({
|
||||
setUploadProgress(100);
|
||||
setIsUploading(false);
|
||||
fetchMaterials();
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
console.error("Upload failed:", err);
|
||||
setIsUploading(false);
|
||||
const errorMsg = err.response?.data?.message || err.message || String(err);
|
||||
const axiosErr = err as { response?: { data?: { message?: string } }; message?: string };
|
||||
const errorMsg = axiosErr.response?.data?.message || axiosErr.message || String(err);
|
||||
setUploadError(`上传失败: ${errorMsg}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { BgmItem } from "@/features/home/model/useBgm";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface RefAudio {
|
||||
id: string;
|
||||
@@ -64,12 +65,12 @@ export const useMediaPlayers = ({
|
||||
|
||||
const audioUrl = resolveMediaUrl(audio.path) || audio.path;
|
||||
if (!audioUrl) {
|
||||
alert("无法播放该参考音频");
|
||||
toast.error("无法播放该参考音频");
|
||||
return;
|
||||
}
|
||||
const player = new Audio(audioUrl);
|
||||
player.onended = () => setPlayingAudioId(null);
|
||||
player.play().catch((err) => alert("播放失败: " + err));
|
||||
player.play().catch((err) => toast.error("播放失败: " + err));
|
||||
audioPlayerRef.current = player;
|
||||
setPlayingAudioId(audio.id);
|
||||
}, [playingAudioId, resolveMediaUrl, stopAudio, stopBgm]);
|
||||
@@ -81,7 +82,7 @@ export const useMediaPlayers = ({
|
||||
|
||||
const bgmUrl = resolveBgmUrl(bgm.id);
|
||||
if (!bgmUrl) {
|
||||
alert("无法播放该背景音乐");
|
||||
toast.error("无法播放该背景音乐");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -96,7 +97,7 @@ export const useMediaPlayers = ({
|
||||
const player = new Audio(bgmUrl);
|
||||
player.volume = Math.max(0, Math.min(bgmVolume, 1));
|
||||
player.onended = () => setPlayingBgmId(null);
|
||||
player.play().catch((err) => alert("播放失败: " + err));
|
||||
player.play().catch((err) => toast.error("播放失败: " + err));
|
||||
bgmPlayerRef.current = player;
|
||||
setPlayingBgmId(bgm.id);
|
||||
}, [bgmVolume, playingBgmId, resolveBgmUrl, setEnableBgm, setSelectedBgmId, stopAudio, stopBgm]);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import api from "@/shared/api/axios";
|
||||
import { ApiResponse, unwrap } from "@/shared/api/types";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface RefAudio {
|
||||
id: string;
|
||||
@@ -60,10 +61,11 @@ export const useRefAudios = ({
|
||||
setSelectedRefAudio(payload);
|
||||
setRefText(payload.ref_text);
|
||||
setIsUploadingRef(false);
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
console.error("Upload ref audio failed:", err);
|
||||
setIsUploadingRef(false);
|
||||
const errorMsg = err.response?.data?.message || err.message || String(err);
|
||||
const axiosErr = err as { response?: { data?: { message?: string } }; message?: string };
|
||||
const errorMsg = axiosErr.response?.data?.message || axiosErr.message || String(err);
|
||||
setUploadRefError(`上传失败: ${errorMsg}`);
|
||||
}
|
||||
}, [fetchRefAudios, fixedRefText, setRefText, setSelectedRefAudio]);
|
||||
@@ -78,7 +80,7 @@ export const useRefAudios = ({
|
||||
setRefText('');
|
||||
}
|
||||
} catch (error) {
|
||||
alert("删除失败: " + error);
|
||||
toast.error("删除失败: " + error);
|
||||
}
|
||||
}, [fetchRefAudios, selectedRefAudio, setRefText, setSelectedRefAudio]);
|
||||
|
||||
|
||||
@@ -34,14 +34,14 @@ export interface TitleStyleOption {
|
||||
|
||||
interface UseTitleSubtitleStylesOptions {
|
||||
isAuthLoading: boolean;
|
||||
storageKey: string;
|
||||
|
||||
setSelectedSubtitleStyleId: React.Dispatch<React.SetStateAction<string>>;
|
||||
setSelectedTitleStyleId: React.Dispatch<React.SetStateAction<string>>;
|
||||
}
|
||||
|
||||
export const useTitleSubtitleStyles = ({
|
||||
isAuthLoading,
|
||||
storageKey,
|
||||
|
||||
setSelectedSubtitleStyleId,
|
||||
setSelectedTitleStyleId,
|
||||
}: UseTitleSubtitleStylesOptions) => {
|
||||
@@ -57,17 +57,15 @@ export const useTitleSubtitleStyles = ({
|
||||
const styles: SubtitleStyleOption[] = payload.styles || [];
|
||||
setSubtitleStyles(styles);
|
||||
|
||||
const savedStyleId = localStorage.getItem(`vigent_${storageKey}_subtitleStyle`);
|
||||
setSelectedSubtitleStyleId((prev) => {
|
||||
if (prev && styles.some((s) => s.id === prev)) return prev;
|
||||
if (savedStyleId && styles.some((s) => s.id === savedStyleId)) return savedStyleId;
|
||||
const defaultStyle = styles.find((s) => s.is_default) || styles[0];
|
||||
return defaultStyle?.id || "";
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("获取字幕样式失败:", error);
|
||||
}
|
||||
}, [setSelectedSubtitleStyleId, storageKey]);
|
||||
}, [setSelectedSubtitleStyleId]);
|
||||
|
||||
const refreshTitleStyles = useCallback(async () => {
|
||||
try {
|
||||
@@ -78,21 +76,21 @@ export const useTitleSubtitleStyles = ({
|
||||
const styles: TitleStyleOption[] = payload.styles || [];
|
||||
setTitleStyles(styles);
|
||||
|
||||
const savedStyleId = localStorage.getItem(`vigent_${storageKey}_titleStyle`);
|
||||
setSelectedTitleStyleId((prev) => {
|
||||
if (prev && styles.some((s) => s.id === prev)) return prev;
|
||||
if (savedStyleId && styles.some((s) => s.id === savedStyleId)) return savedStyleId;
|
||||
const defaultStyle = styles.find((s) => s.is_default) || styles[0];
|
||||
return defaultStyle?.id || "";
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("获取标题样式失败:", error);
|
||||
}
|
||||
}, [setSelectedTitleStyleId, storageKey]);
|
||||
}, [setSelectedTitleStyleId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthLoading) return;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
refreshSubtitleStyles();
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
refreshTitleStyles();
|
||||
}, [isAuthLoading, refreshSubtitleStyles, refreshTitleStyles]);
|
||||
|
||||
|
||||
226
frontend/src/features/home/ui/FloatingStylePreview.tsx
Normal file
226
frontend/src/features/home/ui/FloatingStylePreview.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import { useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
interface SubtitleStyleOption {
|
||||
id: string;
|
||||
label: string;
|
||||
font_family?: string;
|
||||
font_file?: string;
|
||||
font_size?: number;
|
||||
highlight_color?: string;
|
||||
normal_color?: string;
|
||||
stroke_color?: string;
|
||||
stroke_size?: number;
|
||||
letter_spacing?: number;
|
||||
bottom_margin?: number;
|
||||
is_default?: boolean;
|
||||
}
|
||||
|
||||
interface TitleStyleOption {
|
||||
id: string;
|
||||
label: string;
|
||||
font_family?: string;
|
||||
font_file?: string;
|
||||
font_size?: number;
|
||||
color?: string;
|
||||
stroke_color?: string;
|
||||
stroke_size?: number;
|
||||
letter_spacing?: number;
|
||||
font_weight?: number;
|
||||
top_margin?: number;
|
||||
is_default?: boolean;
|
||||
}
|
||||
|
||||
interface FloatingStylePreviewProps {
|
||||
onClose: () => void;
|
||||
videoTitle: string;
|
||||
titleStyles: TitleStyleOption[];
|
||||
selectedTitleStyleId: string;
|
||||
titleFontSize: number;
|
||||
subtitleStyles: SubtitleStyleOption[];
|
||||
selectedSubtitleStyleId: string;
|
||||
subtitleFontSize: number;
|
||||
titleTopMargin: number;
|
||||
subtitleBottomMargin: number;
|
||||
enableSubtitles: boolean;
|
||||
resolveAssetUrl: (path?: string | null) => string | null;
|
||||
getFontFormat: (fontFile?: string) => string;
|
||||
buildTextShadow: (color: string, size: number) => string;
|
||||
previewBaseWidth: number;
|
||||
previewBaseHeight: number;
|
||||
}
|
||||
|
||||
const DESKTOP_WIDTH = 280;
|
||||
|
||||
export function FloatingStylePreview({
|
||||
onClose,
|
||||
videoTitle,
|
||||
titleStyles,
|
||||
selectedTitleStyleId,
|
||||
titleFontSize,
|
||||
subtitleStyles,
|
||||
selectedSubtitleStyleId,
|
||||
subtitleFontSize,
|
||||
titleTopMargin,
|
||||
subtitleBottomMargin,
|
||||
enableSubtitles,
|
||||
resolveAssetUrl,
|
||||
getFontFormat,
|
||||
buildTextShadow,
|
||||
previewBaseWidth,
|
||||
previewBaseHeight,
|
||||
}: FloatingStylePreviewProps) {
|
||||
const isMobile = typeof window !== "undefined" && window.innerWidth < 640;
|
||||
const windowWidth = isMobile
|
||||
? Math.min(window.innerWidth - 32, 360)
|
||||
: DESKTOP_WIDTH;
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
const previewScale = windowWidth / previewBaseWidth;
|
||||
const previewHeight = previewBaseHeight * previewScale;
|
||||
|
||||
const activeSubtitleStyle = subtitleStyles.find((s) => s.id === selectedSubtitleStyleId)
|
||||
|| subtitleStyles.find((s) => s.is_default)
|
||||
|| subtitleStyles[0];
|
||||
|
||||
const activeTitleStyle = titleStyles.find((s) => s.id === selectedTitleStyleId)
|
||||
|| titleStyles.find((s) => s.is_default)
|
||||
|| titleStyles[0];
|
||||
|
||||
const previewTitleText = videoTitle.trim() || "这里是标题预览";
|
||||
const subtitleHighlightText = "最近,一个叫Cloudbot";
|
||||
const subtitleNormalText = "的开源项目在GitHub上彻底火了";
|
||||
|
||||
const subtitleHighlightColor = activeSubtitleStyle?.highlight_color || "#FFE600";
|
||||
const subtitleNormalColor = activeSubtitleStyle?.normal_color || "#FFFFFF";
|
||||
const subtitleStrokeColor = activeSubtitleStyle?.stroke_color || "#000000";
|
||||
const subtitleStrokeSize = activeSubtitleStyle?.stroke_size ?? 3;
|
||||
const subtitleLetterSpacing = activeSubtitleStyle?.letter_spacing ?? 2;
|
||||
const subtitleFontFamilyName = `SubtitlePreview-${activeSubtitleStyle?.id || "default"}`;
|
||||
const subtitleFontUrl = activeSubtitleStyle?.font_file
|
||||
? resolveAssetUrl(`fonts/${activeSubtitleStyle.font_file}`)
|
||||
: null;
|
||||
|
||||
const titleColor = activeTitleStyle?.color || "#FFFFFF";
|
||||
const titleStrokeColor = activeTitleStyle?.stroke_color || "#000000";
|
||||
const titleStrokeSize = activeTitleStyle?.stroke_size ?? 8;
|
||||
const titleLetterSpacing = activeTitleStyle?.letter_spacing ?? 4;
|
||||
const titleFontWeight = activeTitleStyle?.font_weight ?? 900;
|
||||
const titleFontFamilyName = `TitlePreview-${activeTitleStyle?.id || "default"}`;
|
||||
const titleFontUrl = activeTitleStyle?.font_file
|
||||
? resolveAssetUrl(`fonts/${activeTitleStyle.font_file}`)
|
||||
: null;
|
||||
|
||||
const content = (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: "16px",
|
||||
top: "16px",
|
||||
width: `${windowWidth}px`,
|
||||
zIndex: 150,
|
||||
maxHeight: "calc(100dvh - 32px)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
className="rounded-xl border border-white/20 bg-gray-900/95 backdrop-blur-md shadow-2xl"
|
||||
>
|
||||
{/* 标题栏 */}
|
||||
<div
|
||||
className="flex items-center justify-between px-3 py-2 border-b border-white/10 select-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-300">
|
||||
<span>样式预览</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded hover:bg-white/10 text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 预览内容 */}
|
||||
<div
|
||||
className="relative overflow-hidden rounded-b-xl"
|
||||
style={{ height: `${previewHeight}px` }}
|
||||
>
|
||||
{(titleFontUrl || subtitleFontUrl) && (
|
||||
<style>{`
|
||||
${titleFontUrl ? `@font-face { font-family: '${titleFontFamilyName}'; src: url('${titleFontUrl}') format('${getFontFormat(activeTitleStyle?.font_file)}'); font-weight: 400; font-style: normal; }` : ''}
|
||||
${subtitleFontUrl ? `@font-face { font-family: '${subtitleFontFamilyName}'; src: url('${subtitleFontUrl}') format('${getFontFormat(activeSubtitleStyle?.font_file)}'); font-weight: 400; font-style: normal; }` : ''}
|
||||
`}</style>
|
||||
)}
|
||||
<div className="absolute inset-0 opacity-20 bg-gradient-to-br from-purple-500/40 via-transparent to-pink-500/30" />
|
||||
<div
|
||||
className="absolute top-0 left-0"
|
||||
style={{
|
||||
width: `${previewBaseWidth}px`,
|
||||
height: `${previewBaseHeight}px`,
|
||||
transform: `scale(${previewScale})`,
|
||||
transformOrigin: 'top left',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-full text-center"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: `${titleTopMargin}px`,
|
||||
left: 0,
|
||||
right: 0,
|
||||
color: titleColor,
|
||||
fontSize: `${titleFontSize}px`,
|
||||
fontWeight: titleFontWeight,
|
||||
fontFamily: titleFontUrl
|
||||
? `'${titleFontFamilyName}', "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif`
|
||||
: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif',
|
||||
textShadow: buildTextShadow(titleStrokeColor, titleStrokeSize),
|
||||
letterSpacing: `${titleLetterSpacing}px`,
|
||||
lineHeight: 1.2,
|
||||
opacity: videoTitle.trim() ? 1 : 0.7,
|
||||
padding: '0 5%',
|
||||
}}
|
||||
>
|
||||
{previewTitleText}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="w-full text-center"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: `${subtitleBottomMargin}px`,
|
||||
left: 0,
|
||||
right: 0,
|
||||
fontSize: `${subtitleFontSize}px`,
|
||||
fontFamily: subtitleFontUrl
|
||||
? `'${subtitleFontFamilyName}', "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif`
|
||||
: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif',
|
||||
textShadow: buildTextShadow(subtitleStrokeColor, subtitleStrokeSize),
|
||||
letterSpacing: `${subtitleLetterSpacing}px`,
|
||||
lineHeight: 1.35,
|
||||
padding: '0 6%',
|
||||
}}
|
||||
>
|
||||
{enableSubtitles ? (
|
||||
<>
|
||||
<span style={{ color: subtitleHighlightColor }}>{subtitleHighlightText}</span>
|
||||
<span style={{ color: subtitleNormalColor }}>{subtitleNormalText}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-gray-400 text-sm">字幕已关闭</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(content, document.body);
|
||||
}
|
||||
@@ -65,14 +65,16 @@ export function HomePage() {
|
||||
subtitleFontSize,
|
||||
setSubtitleFontSize,
|
||||
setSubtitleSizeLocked,
|
||||
titleTopMargin,
|
||||
setTitleTopMargin,
|
||||
subtitleBottomMargin,
|
||||
setSubtitleBottomMargin,
|
||||
enableSubtitles,
|
||||
setEnableSubtitles,
|
||||
resolveAssetUrl,
|
||||
getFontFormat,
|
||||
buildTextShadow,
|
||||
previewContainerWidth,
|
||||
materialDimensions,
|
||||
titlePreviewContainerRef,
|
||||
ttsMode,
|
||||
setTtsMode,
|
||||
voices,
|
||||
@@ -201,20 +203,17 @@ export function HomePage() {
|
||||
setSubtitleFontSize(value);
|
||||
setSubtitleSizeLocked(true);
|
||||
}}
|
||||
titleTopMargin={titleTopMargin}
|
||||
onTitleTopMarginChange={setTitleTopMargin}
|
||||
subtitleBottomMargin={subtitleBottomMargin}
|
||||
onSubtitleBottomMarginChange={setSubtitleBottomMargin}
|
||||
enableSubtitles={enableSubtitles}
|
||||
onToggleSubtitles={setEnableSubtitles}
|
||||
resolveAssetUrl={resolveAssetUrl}
|
||||
getFontFormat={getFontFormat}
|
||||
buildTextShadow={buildTextShadow}
|
||||
previewScale={previewContainerWidth && (materialDimensions?.width || 1280)
|
||||
? previewContainerWidth / (materialDimensions?.width || 1280)
|
||||
: 1}
|
||||
previewAspectRatio={materialDimensions
|
||||
? `${materialDimensions.width} / ${materialDimensions.height}`
|
||||
: "16 / 9"}
|
||||
previewBaseWidth={materialDimensions?.width || 1280}
|
||||
previewBaseHeight={materialDimensions?.height || 720}
|
||||
previewContainerRef={titlePreviewContainerRef}
|
||||
previewBaseWidth={materialDimensions?.width || 1080}
|
||||
previewBaseHeight={materialDimensions?.height || 1920}
|
||||
/>
|
||||
|
||||
{/* 配音方式选择 */}
|
||||
|
||||
@@ -70,6 +70,7 @@ export function RefAudioPanel({
|
||||
|
||||
useEffect(() => {
|
||||
if (!recordedBlob) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setRecordedUrl(null);
|
||||
return;
|
||||
}
|
||||
@@ -162,8 +163,8 @@ export function RefAudioPanel({
|
||||
className="w-full bg-black/50 text-white text-xs px-1 py-0.5 rounded border border-purple-500 focus:outline-none"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') onSaveEditing(audio.id, e as any);
|
||||
if (e.key === 'Escape') onCancelEditing(e as any);
|
||||
if (e.key === 'Enter') onSaveEditing(audio.id, e as unknown as MouseEvent);
|
||||
if (e.key === 'Escape') onCancelEditing(e as unknown as MouseEvent);
|
||||
}}
|
||||
/>
|
||||
<button onClick={(e) => onSaveEditing(audio.id, e)} className="text-green-400 hover:text-green-300 text-xs">
|
||||
|
||||
@@ -16,12 +16,12 @@ export function ScriptEditor({
|
||||
isGeneratingMeta,
|
||||
}: ScriptEditorProps) {
|
||||
return (
|
||||
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
|
||||
<div className="flex justify-between items-center gap-2 mb-4">
|
||||
<div className="bg-white/5 rounded-2xl p-4 sm:p-6 border border-white/10 backdrop-blur-sm">
|
||||
<div className="flex flex-wrap justify-between items-center gap-2 mb-4">
|
||||
<h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2 whitespace-nowrap">
|
||||
✍️ 文案提取与编辑
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 flex-shrink-0">
|
||||
<button
|
||||
onClick={onOpenExtractModal}
|
||||
className="px-2 py-1 text-xs rounded transition-all whitespace-nowrap bg-purple-600 hover:bg-purple-700 text-white flex items-center gap-1"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { RefObject } from "react";
|
||||
import { Eye } from "lucide-react";
|
||||
import { FloatingStylePreview } from "@/features/home/ui/FloatingStylePreview";
|
||||
|
||||
interface SubtitleStyleOption {
|
||||
id: string;
|
||||
@@ -48,16 +48,17 @@ interface TitleSubtitlePanelProps {
|
||||
onSelectSubtitleStyle: (id: string) => void;
|
||||
subtitleFontSize: number;
|
||||
onSubtitleFontSizeChange: (value: number) => void;
|
||||
titleTopMargin: number;
|
||||
onTitleTopMarginChange: (value: number) => void;
|
||||
subtitleBottomMargin: number;
|
||||
onSubtitleBottomMarginChange: (value: number) => void;
|
||||
enableSubtitles: boolean;
|
||||
onToggleSubtitles: (value: boolean) => void;
|
||||
resolveAssetUrl: (path?: string | null) => string | null;
|
||||
getFontFormat: (fontFile?: string) => string;
|
||||
buildTextShadow: (color: string, size: number) => string;
|
||||
previewScale?: number;
|
||||
previewAspectRatio?: string;
|
||||
previewBaseWidth?: number;
|
||||
previewBaseHeight?: number;
|
||||
previewContainerRef?: RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export function TitleSubtitlePanel({
|
||||
@@ -77,51 +78,18 @@ export function TitleSubtitlePanel({
|
||||
onSelectSubtitleStyle,
|
||||
subtitleFontSize,
|
||||
onSubtitleFontSizeChange,
|
||||
titleTopMargin,
|
||||
onTitleTopMarginChange,
|
||||
subtitleBottomMargin,
|
||||
onSubtitleBottomMarginChange,
|
||||
enableSubtitles,
|
||||
onToggleSubtitles,
|
||||
resolveAssetUrl,
|
||||
getFontFormat,
|
||||
buildTextShadow,
|
||||
previewScale = 1,
|
||||
previewAspectRatio = '16 / 9',
|
||||
previewBaseWidth = 1280,
|
||||
previewBaseHeight = 720,
|
||||
previewContainerRef,
|
||||
previewBaseWidth = 1080,
|
||||
previewBaseHeight = 1920,
|
||||
}: TitleSubtitlePanelProps) {
|
||||
const activeSubtitleStyle = subtitleStyles.find((s) => s.id === selectedSubtitleStyleId)
|
||||
|| subtitleStyles.find((s) => s.is_default)
|
||||
|| subtitleStyles[0];
|
||||
|
||||
const activeTitleStyle = titleStyles.find((s) => s.id === selectedTitleStyleId)
|
||||
|| titleStyles.find((s) => s.is_default)
|
||||
|| titleStyles[0];
|
||||
|
||||
const previewTitleText = videoTitle.trim() || "这里是标题预览";
|
||||
const subtitleHighlightText = "最近,一个叫Cloudbot";
|
||||
const subtitleNormalText = "的开源项目在GitHub上彻底火了";
|
||||
|
||||
const subtitleHighlightColor = activeSubtitleStyle?.highlight_color || "#FFE600";
|
||||
const subtitleNormalColor = activeSubtitleStyle?.normal_color || "#FFFFFF";
|
||||
const subtitleStrokeColor = activeSubtitleStyle?.stroke_color || "#000000";
|
||||
const subtitleStrokeSize = activeSubtitleStyle?.stroke_size ?? 3;
|
||||
const subtitleLetterSpacing = activeSubtitleStyle?.letter_spacing ?? 2;
|
||||
const subtitleBottomMargin = activeSubtitleStyle?.bottom_margin ?? 0;
|
||||
const subtitleFontFamilyName = `SubtitlePreview-${activeSubtitleStyle?.id || "default"}`;
|
||||
const subtitleFontUrl = activeSubtitleStyle?.font_file
|
||||
? resolveAssetUrl(`fonts/${activeSubtitleStyle.font_file}`)
|
||||
: null;
|
||||
|
||||
const titleColor = activeTitleStyle?.color || "#FFFFFF";
|
||||
const titleStrokeColor = activeTitleStyle?.stroke_color || "#000000";
|
||||
const titleStrokeSize = activeTitleStyle?.stroke_size ?? 8;
|
||||
const titleLetterSpacing = activeTitleStyle?.letter_spacing ?? 4;
|
||||
const titleTopMargin = activeTitleStyle?.top_margin ?? 0;
|
||||
const titleFontWeight = activeTitleStyle?.font_weight ?? 900;
|
||||
const titleFontFamilyName = `TitlePreview-${activeTitleStyle?.id || "default"}`;
|
||||
const titleFontUrl = activeTitleStyle?.font_file
|
||||
? resolveAssetUrl(`fonts/${activeTitleStyle.font_file}`)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="bg-white/5 rounded-2xl p-4 sm:p-6 border border-white/10 backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between mb-4 gap-2">
|
||||
@@ -138,78 +106,24 @@ export function TitleSubtitlePanel({
|
||||
</div>
|
||||
|
||||
{showStylePreview && (
|
||||
<div
|
||||
ref={previewContainerRef}
|
||||
className="mb-4 rounded-xl border border-white/10 bg-black/40 relative overflow-hidden"
|
||||
style={{ aspectRatio: previewAspectRatio, minHeight: '180px' }}
|
||||
>
|
||||
{(titleFontUrl || subtitleFontUrl) && (
|
||||
<style>{`
|
||||
${titleFontUrl ? `@font-face { font-family: '${titleFontFamilyName}'; src: url('${titleFontUrl}') format('${getFontFormat(activeTitleStyle?.font_file)}'); font-weight: 400; font-style: normal; }` : ''}
|
||||
${subtitleFontUrl ? `@font-face { font-family: '${subtitleFontFamilyName}'; src: url('${subtitleFontUrl}') format('${getFontFormat(activeSubtitleStyle?.font_file)}'); font-weight: 400; font-style: normal; }` : ''}
|
||||
`}</style>
|
||||
)}
|
||||
<div className="absolute inset-0 opacity-20 bg-gradient-to-br from-purple-500/40 via-transparent to-pink-500/30" />
|
||||
<div
|
||||
className="absolute top-0 left-0"
|
||||
style={{
|
||||
width: `${previewBaseWidth}px`,
|
||||
height: `${previewBaseHeight}px`,
|
||||
transform: `scale(${previewScale})`,
|
||||
transformOrigin: 'top left',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-full text-center"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: `${titleTopMargin}px`,
|
||||
left: 0,
|
||||
right: 0,
|
||||
color: titleColor,
|
||||
fontSize: `${titleFontSize}px`,
|
||||
fontWeight: titleFontWeight,
|
||||
fontFamily: titleFontUrl
|
||||
? `'${titleFontFamilyName}', "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif`
|
||||
: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif',
|
||||
textShadow: buildTextShadow(titleStrokeColor, titleStrokeSize),
|
||||
letterSpacing: `${titleLetterSpacing}px`,
|
||||
lineHeight: 1.2,
|
||||
opacity: videoTitle.trim() ? 1 : 0.7,
|
||||
padding: '0 5%',
|
||||
}}
|
||||
>
|
||||
{previewTitleText}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="w-full text-center"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: `${subtitleBottomMargin}px`,
|
||||
left: 0,
|
||||
right: 0,
|
||||
fontSize: `${subtitleFontSize}px`,
|
||||
fontFamily: subtitleFontUrl
|
||||
? `'${subtitleFontFamilyName}', "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif`
|
||||
: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif',
|
||||
textShadow: buildTextShadow(subtitleStrokeColor, subtitleStrokeSize),
|
||||
letterSpacing: `${subtitleLetterSpacing}px`,
|
||||
lineHeight: 1.35,
|
||||
padding: '0 6%',
|
||||
}}
|
||||
>
|
||||
{enableSubtitles ? (
|
||||
<>
|
||||
<span style={{ color: subtitleHighlightColor }}>{subtitleHighlightText}</span>
|
||||
<span style={{ color: subtitleNormalColor }}>{subtitleNormalText}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-gray-400 text-sm">字幕已关闭</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FloatingStylePreview
|
||||
onClose={onTogglePreview}
|
||||
videoTitle={videoTitle}
|
||||
titleStyles={titleStyles}
|
||||
selectedTitleStyleId={selectedTitleStyleId}
|
||||
titleFontSize={titleFontSize}
|
||||
subtitleStyles={subtitleStyles}
|
||||
selectedSubtitleStyleId={selectedSubtitleStyleId}
|
||||
subtitleFontSize={subtitleFontSize}
|
||||
titleTopMargin={titleTopMargin}
|
||||
subtitleBottomMargin={subtitleBottomMargin}
|
||||
enableSubtitles={enableSubtitles}
|
||||
resolveAssetUrl={resolveAssetUrl}
|
||||
getFontFormat={getFontFormat}
|
||||
buildTextShadow={buildTextShadow}
|
||||
previewBaseWidth={previewBaseWidth}
|
||||
previewBaseHeight={previewBaseHeight}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
@@ -249,14 +163,26 @@ export function TitleSubtitlePanel({
|
||||
<label className="text-xs text-gray-400 mb-2 block">标题字号: {titleFontSize}px</label>
|
||||
<input
|
||||
type="range"
|
||||
min="48"
|
||||
max="110"
|
||||
min="60"
|
||||
max="150"
|
||||
step="1"
|
||||
value={titleFontSize}
|
||||
onChange={(e) => onTitleFontSizeChange(parseInt(e.target.value, 10))}
|
||||
className="w-full accent-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<label className="text-xs text-gray-400 mb-2 block">标题位置: {titleTopMargin}px</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="300"
|
||||
step="1"
|
||||
value={titleTopMargin}
|
||||
onChange={(e) => onTitleTopMarginChange(parseInt(e.target.value, 10))}
|
||||
className="w-full accent-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -284,7 +210,7 @@ export function TitleSubtitlePanel({
|
||||
<label className="text-xs text-gray-400 mb-2 block">字幕字号: {subtitleFontSize}px</label>
|
||||
<input
|
||||
type="range"
|
||||
min="32"
|
||||
min="40"
|
||||
max="90"
|
||||
step="1"
|
||||
value={subtitleFontSize}
|
||||
@@ -292,6 +218,18 @@ export function TitleSubtitlePanel({
|
||||
className="w-full accent-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<label className="text-xs text-gray-400 mb-2 block">字幕位置: {subtitleBottomMargin}px</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="300"
|
||||
step="1"
|
||||
value={subtitleBottomMargin}
|
||||
onChange={(e) => onSubtitleBottomMarginChange(parseInt(e.target.value, 10))}
|
||||
className="w-full accent-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState, useRef, useCallback } from "react";
|
||||
import useSWR from "swr";
|
||||
import api from "@/shared/api/axios";
|
||||
import { ApiResponse, unwrap } from "@/shared/api/types";
|
||||
@@ -6,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; face_verify_qr?: string }>>(url).then((res) => unwrap(res.data));
|
||||
|
||||
export const usePublishController = () => {
|
||||
const apiBase = getApiBaseUrl();
|
||||
@@ -45,43 +32,29 @@ 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 [faceVerifyQr, setFaceVerifyQr] = useState<string | null>(null);
|
||||
|
||||
// 使用全局认证状态
|
||||
const { userId, isLoading: isAuthLoading } = useAuth();
|
||||
// 是否已从 localStorage 恢复完成
|
||||
const [isRestored, setIsRestored] = useState(false);
|
||||
const { isGenerating } = useTask();
|
||||
const prevIsGenerating = useRef(isGenerating);
|
||||
const { readPrefetch, updatePrefetch } = usePublishPrefetch();
|
||||
|
||||
// ---- 视频选择持久化:用 ref 而非 state,彻底避免 effect 竞态 ----
|
||||
const videoRestoredRef = useRef(false);
|
||||
const titleRestoredRef = useRef(false);
|
||||
|
||||
const getStorageKey = useCallback(() => userId || "guest", [userId]);
|
||||
|
||||
const titleInput = useTitleInput({
|
||||
value: title,
|
||||
onChange: setTitle,
|
||||
});
|
||||
|
||||
const readPrefetch = () => {
|
||||
if (typeof window === "undefined") return null;
|
||||
const raw = sessionStorage.getItem(PREFETCH_KEY);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const cache = JSON.parse(raw) as PublishPrefetchCache;
|
||||
if (!cache?.ts) return null;
|
||||
if (Date.now() - cache.ts > PREFETCH_TTL) return null;
|
||||
return cache;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const updatePrefetch = (patch: Partial<PublishPrefetchCache>) => {
|
||||
if (typeof window === "undefined") return;
|
||||
const existing = readPrefetch() || { ts: Date.now() };
|
||||
const next = { ...existing, ...patch, ts: Date.now() };
|
||||
sessionStorage.setItem(PREFETCH_KEY, JSON.stringify(next));
|
||||
};
|
||||
// ---- 数据加载 ----
|
||||
|
||||
const fetchAccounts = async () => {
|
||||
try {
|
||||
@@ -96,21 +69,20 @@ export const usePublishController = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchVideos = async () => {
|
||||
const fetchVideos = async (autoSelectLatest = false) => {
|
||||
try {
|
||||
const { data: res } = await api.get<ApiResponse<{ videos: any[] }>>(
|
||||
"/api/videos/generated"
|
||||
);
|
||||
const payload = unwrap(res);
|
||||
|
||||
const nextVideos = (payload.videos || []).map((v: any) => ({
|
||||
id: v.id as string,
|
||||
name: formatDate(v.created_at) + ` (${v.size_mb.toFixed(1)}MB)`,
|
||||
path: v.path.startsWith("/") ? v.path.slice(1) : v.path,
|
||||
}));
|
||||
|
||||
setVideos(nextVideos);
|
||||
if (nextVideos.length > 0) {
|
||||
setSelectedVideo(nextVideos[0].path);
|
||||
if (nextVideos.length > 0 && autoSelectLatest) {
|
||||
setSelectedVideo(nextVideos[0].id);
|
||||
}
|
||||
updatePrefetch({ videos: nextVideos });
|
||||
} catch (error) {
|
||||
@@ -118,91 +90,137 @@ export const usePublishController = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
const cache = readPrefetch();
|
||||
if (cache?.accounts) {
|
||||
setAccounts(cache.accounts);
|
||||
setIsAccountsLoading(false);
|
||||
}
|
||||
if (cache?.videos) {
|
||||
setVideos(cache.videos);
|
||||
if (!selectedVideo && cache.videos.length > 0) {
|
||||
setSelectedVideo(cache.videos[0].path);
|
||||
}
|
||||
setIsVideosLoading(false);
|
||||
}
|
||||
|
||||
if (cache?.accounts) { setAccounts(cache.accounts); setIsAccountsLoading(false); }
|
||||
if (cache?.videos) { setVideos(cache.videos); setIsVideosLoading(false); }
|
||||
if (!cache?.accounts) setIsAccountsLoading(true);
|
||||
if (!cache?.videos) setIsVideosLoading(true);
|
||||
|
||||
let active = true;
|
||||
void Promise.allSettled([
|
||||
fetchAccounts(),
|
||||
fetchVideos(),
|
||||
]).finally(() => {
|
||||
void Promise.allSettled([fetchAccounts(), fetchVideos(false)]).finally(() => {
|
||||
if (!active) return;
|
||||
setIsAccountsLoading(false);
|
||||
setIsVideosLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
return () => { active = false; };
|
||||
}, []);
|
||||
|
||||
// ---- 视频选择恢复(唯一一个 effect,条件极简) ----
|
||||
// 等 auth 完成 + videos 有数据 → 恢复一次,之后再也不跑
|
||||
useEffect(() => {
|
||||
if (isAuthLoading || videos.length === 0 || videoRestoredRef.current) return;
|
||||
videoRestoredRef.current = true;
|
||||
|
||||
const key = getStorageKey();
|
||||
const saved = localStorage.getItem(`vigent_${key}_publish_selected_video`);
|
||||
if (saved && videos.some(v => v.id === saved)) {
|
||||
setSelectedVideo(saved);
|
||||
} else {
|
||||
setSelectedVideo(videos[0].id);
|
||||
}
|
||||
}, [isAuthLoading, videos, getStorageKey]);
|
||||
|
||||
// ---- 视频选择保存 ----
|
||||
useEffect(() => {
|
||||
if (!videoRestoredRef.current || !selectedVideo || isAuthLoading) return;
|
||||
localStorage.setItem(`vigent_${getStorageKey()}_publish_selected_video`, selectedVideo);
|
||||
}, [selectedVideo, isAuthLoading, getStorageKey]);
|
||||
|
||||
// ---- 任务完成 → 自动选中最新 ----
|
||||
useEffect(() => {
|
||||
if (prevIsGenerating.current && !isGenerating) {
|
||||
void fetchVideos(true);
|
||||
}
|
||||
prevIsGenerating.current = isGenerating;
|
||||
}, [isGenerating]);
|
||||
|
||||
// ---- 标题/标签恢复与保存 ----
|
||||
useEffect(() => {
|
||||
if (isAuthLoading || titleRestoredRef.current) return;
|
||||
titleRestoredRef.current = true;
|
||||
|
||||
const key = getStorageKey();
|
||||
const savedTitle = localStorage.getItem(`vigent_${key}_publish_title`);
|
||||
const savedTags = localStorage.getItem(`vigent_${key}_publish_tags`);
|
||||
if (savedTitle) setTitle(clampTitle(savedTitle));
|
||||
if (savedTags) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedTags);
|
||||
setTags(Array.isArray(parsed) ? parsed.join(", ") : savedTags);
|
||||
} catch { setTags(savedTags); }
|
||||
}
|
||||
}, [isAuthLoading, getStorageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!titleRestoredRef.current || isAuthLoading) return;
|
||||
const key = getStorageKey();
|
||||
const t = setTimeout(() => localStorage.setItem(`vigent_${key}_publish_title`, title), 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [title, isAuthLoading, getStorageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!titleRestoredRef.current || isAuthLoading) return;
|
||||
const key = getStorageKey();
|
||||
const t = setTimeout(() => localStorage.setItem(`vigent_${key}_publish_tags`, tags), 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [tags, isAuthLoading, getStorageKey]);
|
||||
|
||||
// ---- 页面滚动 ----
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if ("scrollRestoration" in window.history) {
|
||||
window.history.scrollRestoration = "manual";
|
||||
}
|
||||
if ("scrollRestoration" in window.history) window.history.scrollRestoration = "manual";
|
||||
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
|
||||
}, []);
|
||||
|
||||
// 获取存储 key 的前缀(登录用户使用 userId,未登录使用 guest)
|
||||
const storageKey = userId || "guest";
|
||||
|
||||
// 从 localStorage 恢复用户输入(等待认证完成后)
|
||||
// ---- 发布防误操作 ----
|
||||
useEffect(() => {
|
||||
if (isAuthLoading) return;
|
||||
if (!isPublishing) return;
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault();
|
||||
event.returnValue = "发布进行中,请勿刷新页面";
|
||||
};
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
}, [isPublishing]);
|
||||
|
||||
// 从 localStorage 恢复用户输入(带用户隔离,未登录用户使用 guest)
|
||||
const savedTitle = localStorage.getItem(`vigent_${storageKey}_publish_title`);
|
||||
const savedTags = localStorage.getItem(`vigent_${storageKey}_publish_tags`);
|
||||
|
||||
if (savedTitle) setTitle(clampTitle(savedTitle));
|
||||
if (savedTags) {
|
||||
// 兼容 JSON 数组格式(AI 生成)和字符串格式(手动输入)
|
||||
try {
|
||||
const parsed = JSON.parse(savedTags);
|
||||
if (Array.isArray(parsed)) {
|
||||
setTags(parsed.join(", "));
|
||||
} else {
|
||||
setTags(savedTags);
|
||||
// ---- SWR Polling for Login Status ----
|
||||
useSWR(
|
||||
qrPlatform ? `${apiBase}/api/publish/login/status/${qrPlatform}` : null,
|
||||
fetcher,
|
||||
{
|
||||
refreshInterval: 2000,
|
||||
onSuccess: (data) => {
|
||||
if (data.success) {
|
||||
setQrCodeImage(null);
|
||||
setQrPlatform(null);
|
||||
setFaceVerifyQr(null);
|
||||
toast.success("✅ 登录成功!");
|
||||
fetchAccounts();
|
||||
} else if (data.face_verify_qr && !faceVerifyQr) {
|
||||
// 刷脸验证:后端捕获了新二维码页面截图
|
||||
setFaceVerifyQr(data.face_verify_qr);
|
||||
}
|
||||
} catch {
|
||||
setTags(savedTags);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// 恢复完成后才允许保存
|
||||
setIsRestored(true);
|
||||
}, [storageKey, isAuthLoading]);
|
||||
|
||||
// 保存用户输入到 localStorage(恢复完成后才保存,未登录用户也可保存)
|
||||
useEffect(() => {
|
||||
if (!isRestored) return;
|
||||
const timeout = setTimeout(() => {
|
||||
localStorage.setItem(`vigent_${storageKey}_publish_title`, title);
|
||||
}, 300);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [title, storageKey, isRestored]);
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRestored) return;
|
||||
const timeout = setTimeout(() => {
|
||||
localStorage.setItem(`vigent_${storageKey}_publish_tags`, tags);
|
||||
}, 300);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [tags, storageKey, isRestored]);
|
||||
let timer: NodeJS.Timeout;
|
||||
if (qrPlatform) {
|
||||
timer = setTimeout(() => {
|
||||
if (qrPlatform) {
|
||||
setQrPlatform(null);
|
||||
setQrCodeImage(null);
|
||||
toast.error("登录超时,请重试");
|
||||
}
|
||||
}, 180000);
|
||||
}
|
||||
return () => clearTimeout(timer);
|
||||
}, [qrPlatform]);
|
||||
|
||||
// ---- 操作函数 ----
|
||||
|
||||
const togglePlatform = (platform: string) => {
|
||||
if (selectedPlatforms.includes(platform)) {
|
||||
@@ -214,80 +232,40 @@ export const usePublishController = () => {
|
||||
|
||||
const handlePublish = async () => {
|
||||
if (!selectedVideo || !title || selectedPlatforms.length === 0) {
|
||||
alert("请选择视频、填写标题并选择至少一个平台");
|
||||
toast.error("请选择视频、填写标题并选择至少一个平台");
|
||||
return;
|
||||
}
|
||||
const video = videos.find(v => v.id === selectedVideo);
|
||||
if (!video) {
|
||||
toast.error("未找到选中的视频");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPublishing(true);
|
||||
setPublishResults([]);
|
||||
|
||||
const tagList = tags.split(/[,,\s]+/).filter((t) => t.trim());
|
||||
|
||||
for (const platform of selectedPlatforms) {
|
||||
try {
|
||||
const { data: res } = await api.post<ApiResponse<any>>("/api/publish", {
|
||||
video_path: selectedVideo,
|
||||
platform,
|
||||
title,
|
||||
tags: tagList,
|
||||
description: "",
|
||||
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 +273,15 @@ export const usePublishController = () => {
|
||||
try {
|
||||
const { data: res } = await api.post<ApiResponse<any>>(`/api/publish/login/${platform}`);
|
||||
const result = unwrap(res);
|
||||
|
||||
if (result.success && result.qr_code) {
|
||||
setQrCodeImage(result.qr_code);
|
||||
} else {
|
||||
setQrPlatform(null);
|
||||
alert(result.message || "登录失败");
|
||||
toast.error(result.message || "登录失败");
|
||||
}
|
||||
} catch (error: any) {
|
||||
setQrPlatform(null);
|
||||
alert(`登录失败: ${error.response?.data?.message || error.message}`);
|
||||
toast.error(`登录失败: ${error.response?.data?.message || error.message}`);
|
||||
} finally {
|
||||
setIsLoadingQR(false);
|
||||
}
|
||||
@@ -315,14 +292,10 @@ export const usePublishController = () => {
|
||||
try {
|
||||
const { data: res } = await api.post<ApiResponse<any>>(`/api/publish/logout/${platform}`);
|
||||
const result = unwrap(res);
|
||||
if (result.success) {
|
||||
alert("已注销");
|
||||
fetchAccounts();
|
||||
} else {
|
||||
alert(result.message || "注销失败");
|
||||
}
|
||||
if (result.success) { toast.success("已注销"); fetchAccounts(); }
|
||||
else { toast.error(result.message || "注销失败"); }
|
||||
} catch (error: any) {
|
||||
alert(`注销失败: ${error.response?.data?.message || error.message}`);
|
||||
toast.error(`注销失败: ${error.response?.data?.message || error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -339,55 +312,27 @@ export const usePublishController = () => {
|
||||
return videos.filter((v) => v.name.toLowerCase().includes(query));
|
||||
}, [videos, videoFilter]);
|
||||
|
||||
const handlePreviewVideo = (path: string) => {
|
||||
const previewPath = isAbsoluteUrl(path)
|
||||
? path
|
||||
: path.startsWith("/")
|
||||
? path
|
||||
: `/${path}`;
|
||||
const handlePreviewVideo = (videoId: string) => {
|
||||
const video = videos.find(v => v.id === videoId);
|
||||
if (!video) return;
|
||||
const previewPath = isAbsoluteUrl(video.path) ? video.path : video.path.startsWith("/") ? video.path : `/${video.path}`;
|
||||
setPreviewVideoUrl(resolveMediaUrl(previewPath) || previewPath);
|
||||
};
|
||||
|
||||
const closeQrModal = () => {
|
||||
setQrCodeImage(null);
|
||||
setQrPlatform(null);
|
||||
setFaceVerifyQr(null);
|
||||
};
|
||||
|
||||
return {
|
||||
apiBase,
|
||||
accounts,
|
||||
videos,
|
||||
isAccountsLoading,
|
||||
isVideosLoading,
|
||||
selectedVideo,
|
||||
setSelectedVideo,
|
||||
videoFilter,
|
||||
setVideoFilter,
|
||||
previewVideoUrl,
|
||||
setPreviewVideoUrl,
|
||||
selectedPlatforms,
|
||||
title,
|
||||
titleInput,
|
||||
tags,
|
||||
setTags,
|
||||
isPublishing,
|
||||
publishResults,
|
||||
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, faceVerifyQr,
|
||||
fetchAccounts, fetchVideos, togglePlatform, handlePublish,
|
||||
handleLogin, handleLogout, platformIcons, filteredVideos,
|
||||
handlePreviewVideo, closeQrModal,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import VideoPreviewModal from "@/components/VideoPreviewModal";
|
||||
import AccountSettingsDropdown from "@/components/AccountSettingsDropdown";
|
||||
import { usePublishController } from "@/features/publish/model/usePublishController";
|
||||
@@ -9,10 +10,9 @@ import {
|
||||
RotateCcw,
|
||||
LogOut,
|
||||
QrCode,
|
||||
Rocket,
|
||||
Clock,
|
||||
Search,
|
||||
Eye,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
|
||||
export function PublishPage() {
|
||||
@@ -33,13 +33,10 @@ export function PublishPage() {
|
||||
setTags,
|
||||
isPublishing,
|
||||
publishResults,
|
||||
scheduleMode,
|
||||
setScheduleMode,
|
||||
publishTime,
|
||||
setPublishTime,
|
||||
qrCodeImage,
|
||||
qrPlatform,
|
||||
isLoadingQR,
|
||||
faceVerifyQr,
|
||||
togglePlatform,
|
||||
handlePublish,
|
||||
handleLogin,
|
||||
@@ -67,12 +64,29 @@ export function PublishPage() {
|
||||
<div className="animate-spin w-16 h-16 border-4 border-purple-500 border-t-transparent rounded-full" />
|
||||
<p className="text-gray-600 mt-4">正在获取二维码...</p>
|
||||
</div>
|
||||
) : faceVerifyQr ? (
|
||||
<>
|
||||
<Image
|
||||
src={`data:image/png;base64,${faceVerifyQr}`}
|
||||
alt="Face Verify QR"
|
||||
width={400}
|
||||
height={300}
|
||||
className="w-full h-auto rounded-lg"
|
||||
unoptimized
|
||||
/>
|
||||
<p className="text-center text-orange-600 font-medium mt-4">
|
||||
需要身份验证,请用抖音APP扫描上方二维码完成刷脸验证
|
||||
</p>
|
||||
</>
|
||||
) : qrCodeImage ? (
|
||||
<>
|
||||
<img
|
||||
<Image
|
||||
src={`data:image/png;base64,${qrCodeImage}`}
|
||||
alt="QR Code"
|
||||
width={280}
|
||||
height={280}
|
||||
className="w-full h-auto"
|
||||
unoptimized
|
||||
/>
|
||||
<p className="text-center text-gray-600 mt-4">
|
||||
请使用手机扫码登录
|
||||
@@ -151,9 +165,11 @@ export function PublishPage() {
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{platformIcons[account.platform] ? (
|
||||
<img
|
||||
<Image
|
||||
src={platformIcons[account.platform].src}
|
||||
alt={platformIcons[account.platform].alt}
|
||||
width={28}
|
||||
height={28}
|
||||
className="h-7 w-7"
|
||||
/>
|
||||
) : (
|
||||
@@ -245,9 +261,9 @@ export function PublishPage() {
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto hide-scrollbar" style={{ contentVisibility: "auto" }}>
|
||||
{filteredVideos.map((v) => (
|
||||
<div
|
||||
key={v.path}
|
||||
onClick={() => setSelectedVideo(v.path)}
|
||||
className={`p-3 rounded-lg border transition-all flex items-center justify-between group cursor-pointer ${selectedVideo === v.path
|
||||
key={v.id}
|
||||
onClick={() => setSelectedVideo(v.id)}
|
||||
className={`p-3 rounded-lg border transition-all flex items-center justify-between group cursor-pointer ${selectedVideo === v.id
|
||||
? "border-purple-500 bg-purple-500/20"
|
||||
: "border-white/10 bg-white/5 hover:border-white/30"
|
||||
}`}
|
||||
@@ -259,7 +275,7 @@ export function PublishPage() {
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePreviewVideo(v.path);
|
||||
handlePreviewVideo(v.id);
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
const src = v.path.startsWith("/") ? v.path : `/${v.path}`;
|
||||
@@ -275,7 +291,7 @@ export function PublishPage() {
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
{selectedVideo === v.path && (
|
||||
{selectedVideo === v.id && (
|
||||
<span className="text-xs text-purple-300">已选</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -337,9 +353,11 @@ export function PublishPage() {
|
||||
>
|
||||
<span className="block mb-1">
|
||||
{platformIcons[account.platform] ? (
|
||||
<img
|
||||
<Image
|
||||
src={platformIcons[account.platform].src}
|
||||
alt={platformIcons[account.platform].alt}
|
||||
width={28}
|
||||
height={28}
|
||||
className="h-7 w-7 mx-auto"
|
||||
/>
|
||||
) : (
|
||||
@@ -352,58 +370,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 +396,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>
|
||||
|
||||
@@ -37,7 +37,7 @@ api.interceptors.response.use(
|
||||
// 调用 logout API 清除 HttpOnly cookie
|
||||
try {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// 忽略错误
|
||||
}
|
||||
|
||||
|
||||
53
frontend/src/shared/hooks/usePublishPrefetch.ts
Normal file
53
frontend/src/shared/hooks/usePublishPrefetch.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useCallback } from "react";
|
||||
import { PublishPrefetchCache } from "@/shared/types/publish";
|
||||
|
||||
const PREFETCH_KEY = "vigent_publish_prefetch_v1";
|
||||
const PREFETCH_TTL = 2 * 60 * 1000; // 2 分钟
|
||||
|
||||
/**
|
||||
* 发布预加载缓存 hook
|
||||
* 用于在首页预加载发布页所需的账号和视频数据
|
||||
*/
|
||||
export const usePublishPrefetch = () => {
|
||||
const readPrefetch = useCallback((): PublishPrefetchCache | null => {
|
||||
if (typeof window === "undefined") return null;
|
||||
const raw = sessionStorage.getItem(PREFETCH_KEY);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const cache = JSON.parse(raw) as PublishPrefetchCache;
|
||||
if (!cache?.ts) return null;
|
||||
if (Date.now() - cache.ts > PREFETCH_TTL) return null;
|
||||
return cache;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updatePrefetch = useCallback((patch: Partial<PublishPrefetchCache>) => {
|
||||
if (typeof window === "undefined") return;
|
||||
const existing = (() => {
|
||||
const raw = sessionStorage.getItem(PREFETCH_KEY);
|
||||
if (!raw) return { ts: Date.now() };
|
||||
try {
|
||||
const cache = JSON.parse(raw) as PublishPrefetchCache;
|
||||
if (!cache?.ts || Date.now() - cache.ts > PREFETCH_TTL) return { ts: Date.now() };
|
||||
return cache;
|
||||
} catch {
|
||||
return { ts: Date.now() };
|
||||
}
|
||||
})();
|
||||
const next = { ...existing, ...patch, ts: Date.now() };
|
||||
sessionStorage.setItem(PREFETCH_KEY, JSON.stringify(next));
|
||||
}, []);
|
||||
|
||||
const clearPrefetch = useCallback(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
sessionStorage.removeItem(PREFETCH_KEY);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
readPrefetch,
|
||||
updatePrefetch,
|
||||
clearPrefetch,
|
||||
};
|
||||
};
|
||||
@@ -1,20 +1,13 @@
|
||||
/**
|
||||
* 认证工具函数
|
||||
*/
|
||||
|
||||
const API_BASE = typeof window === 'undefined'
|
||||
? (process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8006')
|
||||
: '';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
phone: string;
|
||||
username: string | null;
|
||||
role: string;
|
||||
is_active: boolean;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 认证工具函数
|
||||
* 统一使用 axios 实例,与其他 API 调用保持一致的错误处理
|
||||
*/
|
||||
import api from "@/shared/api/axios";
|
||||
import { User } from "@/shared/types/user";
|
||||
|
||||
// Re-export User 类型以保持向后兼容
|
||||
export type { User };
|
||||
|
||||
export interface AuthResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
@@ -27,94 +20,69 @@ interface ApiResponse<T> {
|
||||
data: T;
|
||||
code: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
*/
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
*/
|
||||
export async function register(phone: string, password: string, username?: string): Promise<AuthResponse> {
|
||||
const res = await fetch(`${API_BASE}/api/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ phone, password, username })
|
||||
const { data: payload } = await api.post<ApiResponse<null>>('/api/auth/register', {
|
||||
phone, password, username
|
||||
});
|
||||
const payload = await res.json();
|
||||
const data = payload as ApiResponse<null>;
|
||||
return { success: data.success, message: data.message };
|
||||
return { success: payload.success, message: payload.message };
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
*/
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
*/
|
||||
export async function login(phone: string, password: string): Promise<AuthResponse> {
|
||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ phone, password })
|
||||
const { data: payload } = await api.post<ApiResponse<{ user?: User }>>('/api/auth/login', {
|
||||
phone, password
|
||||
});
|
||||
const payload = await res.json();
|
||||
const data = payload as ApiResponse<{ user?: User }>;
|
||||
return { success: data.success, message: data.message, user: data.data?.user };
|
||||
return { success: payload.success, message: payload.message, user: payload.data?.user };
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登出
|
||||
*/
|
||||
|
||||
/**
|
||||
* 用户登出
|
||||
*/
|
||||
export async function logout(): Promise<AuthResponse> {
|
||||
const res = await fetch(`${API_BASE}/api/auth/logout`, {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
const payload = await res.json();
|
||||
const data = payload as ApiResponse<null>;
|
||||
return { success: data.success, message: data.message };
|
||||
const { data: payload } = await api.post<ApiResponse<null>>('/api/auth/logout');
|
||||
return { success: payload.success, message: payload.message };
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改密码
|
||||
*/
|
||||
|
||||
/**
|
||||
* 修改密码
|
||||
*/
|
||||
export async function changePassword(oldPassword: string, newPassword: string): Promise<AuthResponse> {
|
||||
const res = await fetch(`${API_BASE}/api/auth/change-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ old_password: oldPassword, new_password: newPassword })
|
||||
const { data: payload } = await api.post<ApiResponse<null>>('/api/auth/change-password', {
|
||||
old_password: oldPassword, new_password: newPassword
|
||||
});
|
||||
const payload = await res.json();
|
||||
const data = payload as ApiResponse<null>;
|
||||
return { success: data.success, message: data.message };
|
||||
return { success: payload.success, message: payload.message };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户
|
||||
*/
|
||||
|
||||
/**
|
||||
* 获取当前用户
|
||||
*/
|
||||
export async function getCurrentUser(): Promise<User | null> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/auth/me`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const payload = await res.json();
|
||||
const data = payload as ApiResponse<User>;
|
||||
return data.data || null;
|
||||
const { data: payload } = await api.get<ApiResponse<User>>('/api/auth/me');
|
||||
return payload.data || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已登录
|
||||
*/
|
||||
export async function isAuthenticated(): Promise<boolean> {
|
||||
const user = await getCurrentUser();
|
||||
return user !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否是管理员
|
||||
*/
|
||||
export async function isAdmin(): Promise<boolean> {
|
||||
const user = await getCurrentUser();
|
||||
return user?.role === 'admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已登录
|
||||
*/
|
||||
export async function isAuthenticated(): Promise<boolean> {
|
||||
const user = await getCurrentUser();
|
||||
return user !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否是管理员
|
||||
*/
|
||||
export async function isAdmin(): Promise<boolean> {
|
||||
const user = await getCurrentUser();
|
||||
return user?.role === 'admin';
|
||||
}
|
||||
|
||||
35
frontend/src/shared/types/publish.ts
Normal file
35
frontend/src/shared/types/publish.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 发布相关共用类型定义
|
||||
* 用于 useHomeController 和 usePublishController 之间共享
|
||||
*/
|
||||
|
||||
/** 发布平台账号 */
|
||||
export interface PublishAccount {
|
||||
platform: string;
|
||||
name: string;
|
||||
logged_in: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/** 可发布的视频 */
|
||||
export interface PublishVideo {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
/** 发布预加载缓存 */
|
||||
export interface PublishPrefetchCache {
|
||||
ts: number;
|
||||
accounts?: PublishAccount[];
|
||||
videos?: PublishVideo[];
|
||||
}
|
||||
|
||||
/** 发布结果 */
|
||||
export interface PublishResult {
|
||||
platform: string;
|
||||
success: boolean;
|
||||
message: string;
|
||||
url?: string | null;
|
||||
screenshot_url?: string;
|
||||
}
|
||||
13
frontend/src/shared/types/user.ts
Normal file
13
frontend/src/shared/types/user.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 用户类型定义
|
||||
* 统一管理用户相关类型,避免重复定义
|
||||
*/
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
phone: string;
|
||||
username: string | null;
|
||||
role: string;
|
||||
is_active: boolean;
|
||||
expires_at: string | null;
|
||||
}
|
||||
@@ -65,14 +65,15 @@ async def lifespan(app: FastAPI):
|
||||
# --- 模型加载逻辑 (参考 inference.py) ---
|
||||
print("⏳ 正在加载 LatentSync 模型...")
|
||||
|
||||
# 默认配置路径 (相对于根目录)
|
||||
unet_config_path = "configs/unet/stage2_512.yaml"
|
||||
ckpt_path = "checkpoints/latentsync_unet.pt"
|
||||
# 使用绝对路径,确保可以从任意目录启动
|
||||
latentsync_root = Path(__file__).resolve().parent.parent # scripts -> LatentSync 根目录
|
||||
unet_config_path = latentsync_root / "configs" / "unet" / "stage2_512.yaml"
|
||||
ckpt_path = latentsync_root / "checkpoints" / "latentsync_unet.pt"
|
||||
|
||||
if not os.path.exists(unet_config_path):
|
||||
print(f"⚠️ 找不到配置文件: {unet_config_path},请确保在 models/LatentSync 根目录运行")
|
||||
if not unet_config_path.exists():
|
||||
print(f"⚠️ 找不到配置文件: {unet_config_path}")
|
||||
|
||||
config = OmegaConf.load(unet_config_path)
|
||||
config = OmegaConf.load(str(unet_config_path))
|
||||
|
||||
# Check GPU
|
||||
is_fp16_supported = torch.cuda.is_available() and torch.cuda.get_device_capability()[0] > 7
|
||||
@@ -85,13 +86,13 @@ async def lifespan(app: FastAPI):
|
||||
else:
|
||||
print("⚠️ 警告: 未检测到 GPU,将使用 CPU 进行推理 (速度极慢)")
|
||||
|
||||
scheduler = DDIMScheduler.from_pretrained("configs")
|
||||
scheduler = DDIMScheduler.from_pretrained(str(latentsync_root / "configs"))
|
||||
|
||||
# Whisper Model
|
||||
if config.model.cross_attention_dim == 768:
|
||||
whisper_path = "checkpoints/whisper/small.pt"
|
||||
whisper_path = str(latentsync_root / "checkpoints" / "whisper" / "small.pt")
|
||||
else:
|
||||
whisper_path = "checkpoints/whisper/tiny.pt"
|
||||
whisper_path = str(latentsync_root / "checkpoints" / "whisper" / "tiny.pt")
|
||||
|
||||
audio_encoder = Audio2Feature(
|
||||
model_path=whisper_path,
|
||||
@@ -108,7 +109,7 @@ async def lifespan(app: FastAPI):
|
||||
# UNet
|
||||
unet, _ = UNet3DConditionModel.from_pretrained(
|
||||
OmegaConf.to_container(config.model),
|
||||
ckpt_path,
|
||||
str(ckpt_path),
|
||||
device="cpu", # Load to CPU first to save memory during init
|
||||
)
|
||||
unet = unet.to(dtype=dtype)
|
||||
@@ -129,6 +130,7 @@ async def lifespan(app: FastAPI):
|
||||
models["pipeline"] = pipeline
|
||||
models["config"] = config
|
||||
models["dtype"] = dtype
|
||||
models["latentsync_root"] = latentsync_root
|
||||
|
||||
print("✅ LatentSync 模型加载完成,服务就绪!")
|
||||
yield
|
||||
@@ -167,6 +169,7 @@ async def generate_lipsync(req: LipSyncRequest):
|
||||
pipeline = models["pipeline"]
|
||||
config = models["config"]
|
||||
dtype = models["dtype"]
|
||||
latentsync_root = models["latentsync_root"]
|
||||
|
||||
# Set seed
|
||||
if req.seed != -1:
|
||||
@@ -185,7 +188,7 @@ async def generate_lipsync(req: LipSyncRequest):
|
||||
weight_dtype=dtype,
|
||||
width=config.data.resolution,
|
||||
height=config.data.resolution,
|
||||
mask_image_path=config.data.mask_image_path,
|
||||
mask_image_path=str(latentsync_root / config.data.mask_image_path),
|
||||
temp_dir=req.temp_dir,
|
||||
)
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
"scripts": {
|
||||
"start": "remotion studio",
|
||||
"build": "remotion bundle",
|
||||
"render": "npx ts-node render.ts"
|
||||
"build:render": "npx tsc render.ts --outDir dist --esModuleInterop --skipLibCheck",
|
||||
"render": "npx ts-node render.ts",
|
||||
"render:fast": "node dist/render.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"remotion": "^4.0.0",
|
||||
|
||||
@@ -16,6 +16,8 @@ interface RenderOptions {
|
||||
captionsPath?: string;
|
||||
title?: string;
|
||||
titleDuration?: number;
|
||||
subtitleStyle?: Record<string, unknown>;
|
||||
titleStyle?: Record<string, unknown>;
|
||||
outputPath: string;
|
||||
fps?: number;
|
||||
enableSubtitles?: boolean;
|
||||
@@ -53,6 +55,20 @@ async function parseArgs(): Promise<RenderOptions> {
|
||||
case 'enableSubtitles':
|
||||
options.enableSubtitles = value === 'true';
|
||||
break;
|
||||
case 'subtitleStyle':
|
||||
try {
|
||||
options.subtitleStyle = JSON.parse(value);
|
||||
} catch (e) {
|
||||
console.warn('Invalid subtitleStyle JSON');
|
||||
}
|
||||
break;
|
||||
case 'titleStyle':
|
||||
try {
|
||||
options.titleStyle = JSON.parse(value);
|
||||
} catch (e) {
|
||||
console.warn('Invalid titleStyle JSON');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,20 +100,22 @@ async function main() {
|
||||
let videoWidth = 1280;
|
||||
let videoHeight = 720;
|
||||
try {
|
||||
// 使用 ffprobe 获取视频时长
|
||||
const { execSync } = require('child_process');
|
||||
const ffprobeOutput = execSync(
|
||||
`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${options.videoPath}"`,
|
||||
{ encoding: 'utf-8' }
|
||||
// 使用 promisified exec 异步获取视频信息,避免阻塞主线程
|
||||
const { promisify } = require('util');
|
||||
const { exec } = require('child_process');
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// 获取视频时长
|
||||
const { stdout: durationOutput } = await execAsync(
|
||||
`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${options.videoPath}"`
|
||||
);
|
||||
const durationInSeconds = parseFloat(ffprobeOutput.trim());
|
||||
const durationInSeconds = parseFloat(durationOutput.trim());
|
||||
durationInFrames = Math.ceil(durationInSeconds * fps);
|
||||
console.log(`Video duration: ${durationInSeconds}s (${durationInFrames} frames at ${fps}fps)`);
|
||||
|
||||
// 使用 ffprobe 获取视频尺寸
|
||||
const dimensionsOutput = execSync(
|
||||
`ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=s=x:p=0 "${options.videoPath}"`,
|
||||
{ encoding: 'utf-8' }
|
||||
// 获取视频尺寸
|
||||
const { stdout: dimensionsOutput } = await execAsync(
|
||||
`ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=s=x:p=0 "${options.videoPath}"`
|
||||
);
|
||||
const [width, height] = dimensionsOutput.trim().split('x').map(Number);
|
||||
if (width && height) {
|
||||
@@ -116,8 +134,14 @@ async function main() {
|
||||
|
||||
// Bundle the Remotion project
|
||||
console.log('Bundling Remotion project...');
|
||||
|
||||
// 修复: 使用 process.cwd() 解析 src/index.ts,确保在 dist/render.js 和 ts-node 下都能找到
|
||||
// 假设脚本总是在 remotion 根目录下运行 (由 python service 保证)
|
||||
const entryPoint = path.resolve(process.cwd(), 'src/index.ts');
|
||||
console.log(`Entry point: ${entryPoint}`);
|
||||
|
||||
const bundleLocation = await bundle({
|
||||
entryPoint: path.resolve(__dirname, './src/index.ts'),
|
||||
entryPoint,
|
||||
webpackOverride: (config) => config,
|
||||
publicDir,
|
||||
});
|
||||
@@ -131,6 +155,8 @@ async function main() {
|
||||
captions,
|
||||
title: options.title,
|
||||
titleDuration: options.titleDuration || 3,
|
||||
subtitleStyle: options.subtitleStyle,
|
||||
titleStyle: options.titleStyle,
|
||||
enableSubtitles: options.enableSubtitles !== false,
|
||||
},
|
||||
});
|
||||
@@ -153,6 +179,8 @@ async function main() {
|
||||
captions,
|
||||
title: options.title,
|
||||
titleDuration: options.titleDuration || 3,
|
||||
subtitleStyle: options.subtitleStyle,
|
||||
titleStyle: options.titleStyle,
|
||||
enableSubtitles: options.enableSubtitles !== false,
|
||||
},
|
||||
onProgress: ({ progress }) => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import { AbsoluteFill, Composition } from 'remotion';
|
||||
import { VideoLayer } from './components/VideoLayer';
|
||||
import { Title } from './components/Title';
|
||||
import { Subtitles } from './components/Subtitles';
|
||||
import { Title, TitleStyle } from './components/Title';
|
||||
import { Subtitles, SubtitleStyle } from './components/Subtitles';
|
||||
import { CaptionsData } from './utils/captions';
|
||||
|
||||
export interface VideoProps {
|
||||
@@ -12,6 +12,8 @@ export interface VideoProps {
|
||||
title?: string;
|
||||
titleDuration?: number;
|
||||
enableSubtitles?: boolean;
|
||||
subtitleStyle?: SubtitleStyle;
|
||||
titleStyle?: TitleStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,6 +27,8 @@ export const Video: React.FC<VideoProps> = ({
|
||||
title,
|
||||
titleDuration = 3,
|
||||
enableSubtitles = true,
|
||||
subtitleStyle,
|
||||
titleStyle,
|
||||
}) => {
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: 'black' }}>
|
||||
@@ -33,12 +37,12 @@ export const Video: React.FC<VideoProps> = ({
|
||||
|
||||
{/* 中层:字幕 */}
|
||||
{enableSubtitles && captions && (
|
||||
<Subtitles captions={captions} />
|
||||
<Subtitles captions={captions} style={subtitleStyle} />
|
||||
)}
|
||||
|
||||
{/* 顶层:标题 */}
|
||||
{title && (
|
||||
<Title title={title} duration={titleDuration} />
|
||||
<Title title={title} duration={titleDuration} style={titleStyle} />
|
||||
)}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
|
||||
@@ -1,28 +1,59 @@
|
||||
import React from 'react';
|
||||
import { AbsoluteFill, useCurrentFrame, useVideoConfig } from 'remotion';
|
||||
import { AbsoluteFill, useCurrentFrame, useVideoConfig, staticFile } from 'remotion';
|
||||
import {
|
||||
CaptionsData,
|
||||
getCurrentSegment,
|
||||
getCurrentWordIndex,
|
||||
} from '../utils/captions';
|
||||
|
||||
export interface SubtitleStyle {
|
||||
font_file?: string;
|
||||
fontFamily?: string;
|
||||
font_family?: string;
|
||||
fontSize?: number;
|
||||
font_size?: number;
|
||||
highlightColor?: string;
|
||||
highlight_color?: string;
|
||||
normalColor?: string;
|
||||
normal_color?: string;
|
||||
strokeColor?: string;
|
||||
stroke_color?: string;
|
||||
strokeSize?: number;
|
||||
stroke_size?: number;
|
||||
letterSpacing?: number;
|
||||
letter_spacing?: number;
|
||||
bottomMargin?: number;
|
||||
bottom_margin?: number;
|
||||
}
|
||||
|
||||
interface SubtitlesProps {
|
||||
captions: CaptionsData;
|
||||
highlightColor?: string;
|
||||
normalColor?: string;
|
||||
fontSize?: number;
|
||||
style?: SubtitleStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
* 逐字高亮字幕组件
|
||||
* 根据时间戳逐字高亮显示字幕(无背景,纯文字描边)
|
||||
*/
|
||||
export const Subtitles: React.FC<SubtitlesProps> = ({
|
||||
captions,
|
||||
highlightColor = '#FFFF00',
|
||||
normalColor = '#FFFFFF',
|
||||
fontSize = 52,
|
||||
}) => {
|
||||
const getFontFormat = (fontFile?: string) => {
|
||||
if (!fontFile) return 'truetype';
|
||||
const ext = fontFile.split('.').pop()?.toLowerCase();
|
||||
if (ext === 'otf') return 'opentype';
|
||||
return 'truetype';
|
||||
};
|
||||
|
||||
const buildTextShadow = (color: string, size: number) => {
|
||||
return [
|
||||
`-${size}px -${size}px 0 ${color}`,
|
||||
`${size}px -${size}px 0 ${color}`,
|
||||
`-${size}px ${size}px 0 ${color}`,
|
||||
`${size}px ${size}px 0 ${color}`,
|
||||
`0 0 ${size * 4}px rgba(0,0,0,0.9)`,
|
||||
`0 4px 8px rgba(0,0,0,0.6)`
|
||||
].join(',');
|
||||
};
|
||||
|
||||
export const Subtitles: React.FC<SubtitlesProps> = ({ captions, style }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
@@ -38,45 +69,62 @@ export const Subtitles: React.FC<SubtitlesProps> = ({
|
||||
// 获取当前高亮字的索引
|
||||
const currentWordIndex = getCurrentWordIndex(currentSegment, currentTimeInSeconds);
|
||||
|
||||
const fontFile = style?.font_file;
|
||||
const fontFamily = style?.fontFamily || style?.font_family;
|
||||
const fontSize = style?.fontSize || style?.font_size || 52;
|
||||
const highlightColor = style?.highlightColor || style?.highlight_color || '#FFFF00';
|
||||
const normalColor = style?.normalColor || style?.normal_color || '#FFFFFF';
|
||||
const strokeColor = style?.strokeColor || style?.stroke_color || '#000000';
|
||||
const strokeSize = style?.strokeSize || style?.stroke_size || 3;
|
||||
const letterSpacing = style?.letterSpacing || style?.letter_spacing || 2;
|
||||
const bottomMargin = style?.bottomMargin || style?.bottom_margin;
|
||||
const fontFamilyName = fontFamily || 'SubtitleFont';
|
||||
const fontFamilyCss = fontFile
|
||||
? `'${fontFamilyName}'`
|
||||
: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif';
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
paddingBottom: '6%',
|
||||
paddingBottom: typeof bottomMargin === 'number' ? `${bottomMargin}px` : '6%',
|
||||
}}
|
||||
>
|
||||
{fontFile && (
|
||||
<style>{`
|
||||
@font-face {
|
||||
font-family: '${fontFamilyName}';
|
||||
src: url('${staticFile(fontFile)}') format('${getFontFormat(fontFile)}');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
`}</style>
|
||||
)}
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: `${fontSize}px`,
|
||||
fontFamily: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif',
|
||||
fontFamily: fontFamilyCss,
|
||||
fontWeight: 800,
|
||||
lineHeight: 1.4,
|
||||
textAlign: 'center',
|
||||
maxWidth: '90%',
|
||||
wordBreak: 'keep-all',
|
||||
letterSpacing: '2px',
|
||||
letterSpacing: `${letterSpacing}px`,
|
||||
}}
|
||||
>
|
||||
{currentSegment.words.map((word, index) => {
|
||||
const isHighlighted = index <= currentWordIndex;
|
||||
return (
|
||||
<span
|
||||
key={`${word.word}-${index}`}
|
||||
style={{
|
||||
color: isHighlighted ? highlightColor : normalColor,
|
||||
textShadow: `
|
||||
-3px -3px 0 #000,
|
||||
3px -3px 0 #000,
|
||||
-3px 3px 0 #000,
|
||||
3px 3px 0 #000,
|
||||
0 0 12px rgba(0,0,0,0.9),
|
||||
0 4px 8px rgba(0,0,0,0.6)
|
||||
`,
|
||||
transition: 'color 0.05s ease',
|
||||
}}
|
||||
>
|
||||
key={`${word.word}-${index}`}
|
||||
style={{
|
||||
color: isHighlighted ? highlightColor : normalColor,
|
||||
textShadow: buildTextShadow(strokeColor, strokeSize),
|
||||
transition: 'color 0.05s ease',
|
||||
}}
|
||||
>
|
||||
{word.word}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -4,22 +4,62 @@ import {
|
||||
interpolate,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
staticFile,
|
||||
} from 'remotion';
|
||||
|
||||
export interface TitleStyle {
|
||||
font_file?: string;
|
||||
fontFamily?: string;
|
||||
font_family?: string;
|
||||
fontSize?: number;
|
||||
font_size?: number;
|
||||
color?: string;
|
||||
strokeColor?: string;
|
||||
stroke_color?: string;
|
||||
strokeSize?: number;
|
||||
stroke_size?: number;
|
||||
letterSpacing?: number;
|
||||
letter_spacing?: number;
|
||||
topMargin?: number;
|
||||
top_margin?: number;
|
||||
fontWeight?: number;
|
||||
font_weight?: number;
|
||||
}
|
||||
|
||||
interface TitleProps {
|
||||
title: string;
|
||||
duration?: number; // 标题显示时长(秒)
|
||||
fadeOutStart?: number; // 开始淡出的时间(秒)
|
||||
style?: TitleStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
* 片头标题组件
|
||||
* 在视频顶部显示标题,带淡入淡出效果
|
||||
*/
|
||||
const getFontFormat = (fontFile?: string) => {
|
||||
if (!fontFile) return 'truetype';
|
||||
const ext = fontFile.split('.').pop()?.toLowerCase();
|
||||
if (ext === 'otf') return 'opentype';
|
||||
return 'truetype';
|
||||
};
|
||||
|
||||
const buildTextShadow = (color: string, size: number) => {
|
||||
return [
|
||||
`-${size}px -${size}px 0 ${color}`,
|
||||
`${size}px -${size}px 0 ${color}`,
|
||||
`-${size}px ${size}px 0 ${color}`,
|
||||
`${size}px ${size}px 0 ${color}`,
|
||||
`0 0 ${size * 2}px rgba(0,0,0,0.7)`,
|
||||
`0 4px 8px rgba(0,0,0,0.6)`
|
||||
].join(',');
|
||||
};
|
||||
|
||||
export const Title: React.FC<TitleProps> = ({
|
||||
title,
|
||||
duration = 3,
|
||||
fadeOutStart = 2,
|
||||
style,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
@@ -57,33 +97,52 @@ export const Title: React.FC<TitleProps> = ({
|
||||
{ extrapolateRight: 'clamp' }
|
||||
);
|
||||
|
||||
const fontFile = style?.font_file;
|
||||
const fontFamily = style?.fontFamily || style?.font_family;
|
||||
const fontSize = style?.fontSize || style?.font_size || 72;
|
||||
const color = style?.color || '#FFFFFF';
|
||||
const strokeColor = style?.strokeColor || style?.stroke_color || '#000000';
|
||||
const strokeSize = style?.strokeSize || style?.stroke_size || 8;
|
||||
const letterSpacing = style?.letterSpacing || style?.letter_spacing || 4;
|
||||
const topMargin = style?.topMargin || style?.top_margin;
|
||||
const fontWeight = style?.fontWeight || style?.font_weight || 900;
|
||||
const fontFamilyName = fontFamily || 'TitleFont';
|
||||
const fontFamilyCss = fontFile
|
||||
? `'${fontFamilyName}'`
|
||||
: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif';
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'center',
|
||||
paddingTop: '6%',
|
||||
paddingTop: typeof topMargin === 'number' ? `${topMargin}px` : '6%',
|
||||
opacity,
|
||||
}}
|
||||
>
|
||||
{fontFile && (
|
||||
<style>{`
|
||||
@font-face {
|
||||
font-family: '${fontFamilyName}';
|
||||
src: url('${staticFile(fontFile)}') format('${getFontFormat(fontFile)}');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
`}</style>
|
||||
)}
|
||||
<h1
|
||||
style={{
|
||||
transform: `translateY(${translateY}px)`,
|
||||
textAlign: 'center',
|
||||
color: '#FFFFFF',
|
||||
fontSize: '72px',
|
||||
fontWeight: 900,
|
||||
fontFamily: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif',
|
||||
textShadow: `
|
||||
0 0 10px rgba(0,0,0,0.9),
|
||||
0 0 20px rgba(0,0,0,0.7),
|
||||
0 4px 8px rgba(0,0,0,0.8),
|
||||
0 8px 16px rgba(0,0,0,0.5)
|
||||
`,
|
||||
color,
|
||||
fontSize: `${fontSize}px`,
|
||||
fontWeight,
|
||||
fontFamily: fontFamilyCss,
|
||||
textShadow: buildTextShadow(strokeColor, strokeSize),
|
||||
margin: 0,
|
||||
padding: '0 5%',
|
||||
lineHeight: 1.3,
|
||||
letterSpacing: '4px',
|
||||
letterSpacing: `${letterSpacing}px`,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
|
||||
@@ -1,4 +1,22 @@
|
||||
#!/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"
|
||||
|
||||
# 加载 .env 文件(让 os.getenv 也能读到)
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
fi
|
||||
|
||||
exec xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" \
|
||||
./venv/bin/uvicorn app.main:app --host 0.0.0.0 --port "$PORT"
|
||||
|
||||
Reference in New Issue
Block a user