Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
091f78174e | ||
|
|
190fc2e590 | ||
|
|
48bc78fe38 |
@@ -2,6 +2,12 @@
|
||||
|
||||
本文档定义后端开发的结构规范、接口契约与实现习惯。目标是让新功能按统一范式落地,旧逻辑在修复时逐步抽离。
|
||||
|
||||
## 文档定位
|
||||
|
||||
- 本文档只定义后端开发规范与工程约束(分层职责、契约、流程、代码习惯)。
|
||||
- 接口说明、部署运行与环境配置示例请查看 `Docs/BACKEND_README.md`。
|
||||
- 历史变更请记录在 `Docs/DevLogs/` 与 `Docs/TASK_COMPLETE.md`,不要写入本规范文档。
|
||||
|
||||
---
|
||||
|
||||
## 1. 模块化与分层原则
|
||||
@@ -43,7 +49,7 @@ backend/
|
||||
│ │ └── admin/ # 管理员功能
|
||||
│ ├── repositories/ # Supabase 数据访问
|
||||
│ ├── services/ # 外部服务集成
|
||||
│ │ ├── uploader/ # 平台发布器(douyin/weixin)
|
||||
│ │ ├── uploader/ # 平台发布器(douyin/weixin/xiaohongshu/bilibili)
|
||||
│ │ ├── qr_login_service.py
|
||||
│ │ ├── publish_service.py
|
||||
│ │ ├── remotion_service.py
|
||||
@@ -80,7 +86,7 @@ backend/
|
||||
- `custom_assignments` 每项使用 `material_path/start/end/source_start/source_end?`,并以时间轴可见段为准。
|
||||
- `output_aspect_ratio` 仅允许 `9:16` / `16:9`,默认 `9:16`。
|
||||
- 标题显示模式参数:
|
||||
- `title_display_mode`: `short` / `persistent`(默认 `short`)
|
||||
- `title_display_mode`: `short` / `persistent`(默认 `short`,对主标题与副标题统一生效)
|
||||
- `title_duration`: 默认 `4.0`(秒),仅 `short` 模式生效
|
||||
- 片头副标题参数:
|
||||
- `secondary_title`: 副标题文字(可选,限 20 字),仅在视频画面中显示,不参与发布标题
|
||||
@@ -162,7 +168,7 @@ backend/user_data/{user_uuid}/cookies/
|
||||
- `MUSETALK_BATCH_SIZE` (推理批大小,默认 32)
|
||||
- `MUSETALK_VERSION` (v15)
|
||||
- `MUSETALK_USE_FLOAT16` (半精度,默认 true)
|
||||
- `LIPSYNC_DURATION_THRESHOLD` (秒,>=此值用 MuseTalk,默认 120)
|
||||
- `LIPSYNC_DURATION_THRESHOLD` (秒,>=此值用 MuseTalk;代码默认 120,本仓库当前 `.env` 配置 100)
|
||||
|
||||
### 微信视频号
|
||||
- `WEIXIN_HEADLESS_MODE` (headful/headless-new)
|
||||
@@ -179,6 +185,14 @@ backend/user_data/{user_uuid}/cookies/
|
||||
- `DOUYIN_FORCE_SWIFTSHADER`
|
||||
- `DOUYIN_DEBUG_ARTIFACTS` / `DOUYIN_RECORD_VIDEO` / `DOUYIN_KEEP_SUCCESS_VIDEO`
|
||||
|
||||
### 小红书
|
||||
- `XIAOHONGSHU_HEADLESS_MODE` (headful/headless-new,默认 headless-new)
|
||||
- `XIAOHONGSHU_CHROME_PATH` / `XIAOHONGSHU_BROWSER_CHANNEL`
|
||||
- `XIAOHONGSHU_USER_AGENT`
|
||||
- `XIAOHONGSHU_LOCALE` / `XIAOHONGSHU_TIMEZONE_ID`
|
||||
- `XIAOHONGSHU_FORCE_SWIFTSHADER`
|
||||
- `XIAOHONGSHU_DEBUG_ARTIFACTS`
|
||||
|
||||
### 支付宝
|
||||
- `ALIPAY_APP_ID` / `ALIPAY_PRIVATE_KEY_PATH` / `ALIPAY_PUBLIC_KEY_PATH`
|
||||
- `ALIPAY_NOTIFY_URL` / `ALIPAY_RETURN_URL`
|
||||
@@ -191,8 +205,9 @@ backend/user_data/{user_uuid}/cookies/
|
||||
## 10. Playwright 发布调试
|
||||
|
||||
- 诊断日志落盘:`backend/app/debug_screenshots/weixin_network.log` / `douyin_network.log`
|
||||
- 关键失败截图:`backend/app/debug_screenshots/weixin_*.png` / `douyin_*.png`
|
||||
- 关键失败截图:`backend/app/debug_screenshots/weixin_*.png` / `douyin_*.png` / `xiaohongshu_*.png`
|
||||
- 视频号建议使用 headful + xvfb-run(避免 headless 解码/指纹问题)
|
||||
- 发布专项实现细节(登录链路、成功判定、排障)统一维护在 `Docs/PUBLISH_DEPLOY.md`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
# ViGent2 后端开发指南
|
||||
|
||||
本文档提供后端架构概览与接口规范。开发规范与分层约定见 `Docs/BACKEND_DEV.md`。
|
||||
本文档提供后端架构概览、接口说明与运行配置。
|
||||
|
||||
## 📌 文档定位
|
||||
|
||||
- 本文档用于说明后端服务能力、接口与部署运行方式(面向使用与联调)。
|
||||
- 开发规范、分层约束与代码实现习惯请查看 `Docs/BACKEND_DEV.md`。
|
||||
- 历史变更与里程碑请查看 `Docs/DevLogs/` 与 `Docs/TASK_COMPLETE.md`。
|
||||
|
||||
---
|
||||
|
||||
@@ -8,7 +14,7 @@
|
||||
|
||||
后端采用 **FastAPI** 框架,基于 Python 3.10+ 构建,主要负责业务逻辑处理、AI 任务调度以及与各微服务组件的交互。
|
||||
|
||||
### 目录结构
|
||||
### 目录结构(概览)
|
||||
|
||||
```
|
||||
backend/
|
||||
@@ -36,6 +42,8 @@ backend/
|
||||
└── requirements.txt # 依赖清单
|
||||
```
|
||||
|
||||
> 详细分层职责(router/service/workflow/repositories)与开发约束请查看 `Docs/BACKEND_DEV.md`。
|
||||
|
||||
---
|
||||
|
||||
## 🔌 API 接口规范
|
||||
@@ -56,6 +64,7 @@ backend/
|
||||
|
||||
2. **视频生成 (Videos)**
|
||||
* `POST /api/videos/generate`: 提交生成任务
|
||||
* `GET/POST /api/videos/voice-preview`: 生成音色试听短音频(返回二进制音频流)
|
||||
* `GET /api/videos/tasks/{task_id}`: 查询单个任务状态
|
||||
* `GET /api/videos/tasks`: 获取用户所有任务列表
|
||||
* `GET /api/videos/generated`: 获取历史视频列表
|
||||
@@ -69,11 +78,14 @@ backend/
|
||||
|
||||
4. **社交发布 (Publish)**
|
||||
* `POST /api/publish`: 发布视频到 抖音/微信视频号/B站/小红书
|
||||
* `POST /api/publish/login`: 扫码登录平台
|
||||
* `GET /api/publish/login/status`: 查询登录状态(含刷脸验证二维码)
|
||||
* `POST /api/publish/login/{platform}`: 获取平台二维码并启动扫码登录
|
||||
* `GET /api/publish/login/status/{platform}`: 轮询登录状态(含抖音刷脸验证二维码)
|
||||
* `POST /api/publish/logout/{platform}`: 注销平台登录(删除 Cookie)
|
||||
* `POST /api/publish/cookies/save/{platform}`: 保存客户端提取的 Cookie
|
||||
* `GET /api/publish/accounts`: 获取已登录账号列表
|
||||
* `GET /api/publish/screenshot/{filename}`: 获取发布成功截图(需登录)
|
||||
|
||||
> 提示:视频号/抖音发布建议使用 headful + xvfb-run 运行后端。
|
||||
> 提示:视频号/抖音发布建议使用 headful + xvfb-run 运行后端。发布专项实现与部署说明见 `Docs/PUBLISH_DEPLOY.md`。
|
||||
|
||||
5. **资源库 (Assets)**
|
||||
* `GET /api/assets/subtitle-styles`: 字幕样式列表
|
||||
@@ -138,9 +150,13 @@ backend/
|
||||
- `speed`: 语速(声音克隆模式,默认 1.0,范围 0.8-1.2)
|
||||
- `custom_assignments`: 自定义素材分配数组(每项含 `material_path` / `start` / `end` / `source_start` / `source_end?`),存在时优先按时间轴可见段生成
|
||||
- `output_aspect_ratio`: 输出画面比例(`9:16` 或 `16:9`,默认 `9:16`)
|
||||
- `language`: TTS 语言(默认自动检测,声音克隆时透传给 CosyVoice 3.0)
|
||||
- `lipsync_model`: 唇形模型路由模式(`default` / `fast` / `advanced`)
|
||||
- `default`: 阈值路由(`LIPSYNC_DURATION_THRESHOLD`)
|
||||
- `fast`: 强制 MuseTalk,不可用时回退 LatentSync
|
||||
- `advanced`: 强制 LatentSync
|
||||
- `language`: TTS 语言区域(默认 `zh-CN`;会映射为 Whisper 的 `zh/en/...` 与 CosyVoice 的 `Chinese/English/Auto`)
|
||||
- `title`: 片头标题文字
|
||||
- `title_display_mode`: 标题显示模式(`short` / `persistent`,默认 `short`)
|
||||
- `title_display_mode`: 标题显示模式(`short` / `persistent`,默认 `short`;该模式对主标题与副标题统一生效)
|
||||
- `title_duration`: 标题显示时长(秒,默认 `4.0`;`short` 模式生效)
|
||||
- `subtitle_style_id`: 字幕样式 ID
|
||||
- `title_style_id`: 标题样式 ID
|
||||
@@ -161,7 +177,7 @@ backend/
|
||||
- 多素材片段在拼接前统一重编码,并强制 `25fps + CFR`,减少段边界时间基不一致导致的画面卡顿。
|
||||
- concat 流程启用 `+genpts` 重建时间戳,提升拼接后时间轴连续性。
|
||||
- 对带旋转元数据的 MOV 素材会先做方向归一化,再进入分辨率判断和后续流程。
|
||||
- compose 阶段(视频轨+音频轨合并)使用 `-c:v copy` 流复制替代重编码,几乎瞬间完成。
|
||||
- compose 阶段(视频轨+音频轨合并)在**无需循环视频**时使用 `-c:v copy` 流复制;需要循环时才重编码。
|
||||
- FFmpeg 子进程设有超时保护:`_run_ffmpeg()` 600 秒、`_get_duration()` 30 秒,防止畸形文件导致永久挂起。
|
||||
|
||||
### 全局并发控制
|
||||
@@ -203,7 +219,7 @@ pip install -r requirements.txt
|
||||
|
||||
### 3. 环境变量配置
|
||||
|
||||
复制 `.env.example` 到 `.env` 并配置必要的 Key:
|
||||
当前仓库使用 `backend/.env` 作为运行配置基准;请按你的环境替换敏感值并核对以下关键项(生产环境请勿提交真实密钥):
|
||||
|
||||
```ini
|
||||
# Supabase
|
||||
@@ -220,7 +236,13 @@ LATENTSYNC_GPU_ID=1
|
||||
MUSETALK_GPU_ID=0
|
||||
MUSETALK_API_URL=http://localhost:8011
|
||||
MUSETALK_BATCH_SIZE=32
|
||||
LIPSYNC_DURATION_THRESHOLD=120
|
||||
LIPSYNC_DURATION_THRESHOLD=100
|
||||
|
||||
# MuseTalk 可调参数(示例)
|
||||
MUSETALK_DETECT_EVERY=2
|
||||
MUSETALK_BLEND_CACHE_EVERY=2
|
||||
MUSETALK_ENCODE_CRF=14
|
||||
MUSETALK_ENCODE_PRESET=slow
|
||||
```
|
||||
|
||||
### 4. 启动服务
|
||||
@@ -232,51 +254,11 @@ uvicorn app.main:app --host 0.0.0.0 --port 8006 --reload
|
||||
|
||||
---
|
||||
|
||||
## 🧩 服务集成指南
|
||||
## 🧩 开发约定与测试
|
||||
|
||||
### 集成新模型
|
||||
|
||||
如果需要集成新的 AI 模型 (例如新的 TTS 引擎):
|
||||
|
||||
1. 在 `app/services/` 下创建新的 Service 类 (如 `NewTTSService`)。
|
||||
2. 实现 `generate` 方法,可以使用 subprocess 调用,也可以是 HTTP 请求。
|
||||
3. **重要**: 如果模型占用 GPU,请务必使用 `asyncio.Lock` 进行并发控制,防止 OOM。
|
||||
4. 在 `app/modules/` 下创建对应模块,添加 router/service/schemas,并在 `main.py` 注册路由。
|
||||
|
||||
### 唇形同步混合路由
|
||||
|
||||
`lipsync_service.py` 实现了 LatentSync + MuseTalk 混合路由:
|
||||
- 短视频 (<`LIPSYNC_DURATION_THRESHOLD`s) → LatentSync 1.6 (GPU1, 端口 8007)
|
||||
- 长视频 (>=阈值) → MuseTalk 1.5 (GPU0, 端口 8011)
|
||||
- MuseTalk 不可用时自动回退到 LatentSync
|
||||
- 路由逻辑对 workflow 完全透明
|
||||
|
||||
### 添加定时任务
|
||||
|
||||
目前推荐使用 **APScheduler** 或 **Crontab** 来管理定时任务。
|
||||
社交媒体的定时发布功能目前依赖 `playwright` 的延迟执行,未来计划迁移到 Celery 队列。
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ 错误处理
|
||||
|
||||
全项目统一使用 `Loguru` 进行日志记录。
|
||||
|
||||
```python
|
||||
from loguru import logger
|
||||
|
||||
try:
|
||||
# 业务逻辑
|
||||
except Exception as e:
|
||||
logger.error(f"操作失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="服务器内部错误")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 测试
|
||||
|
||||
运行测试套件:
|
||||
- 新增模块、分层职责、统一响应、错误处理与调试规范请查看 `Docs/BACKEND_DEV.md`。
|
||||
- 建议在核心流程变更后做基础冒烟:登录、视频生成、发布。
|
||||
- 测试命令:
|
||||
|
||||
```bash
|
||||
pytest
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
| 端口 | 8010 |
|
||||
| GPU | 0 (CUDA_VISIBLE_DEVICES=0) |
|
||||
| 推理精度 | FP16 (自动混合精度) |
|
||||
| PM2 名称 | vigent2-cosyvoice (id=15) |
|
||||
| PM2 名称 | vigent2-cosyvoice |
|
||||
| Conda 环境 | cosyvoice (Python 3.10) |
|
||||
| 启动脚本 | `run_cosyvoice.sh` |
|
||||
| 服务脚本 | `models/CosyVoice/cosyvoice_server.py` |
|
||||
|
||||
@@ -97,7 +97,7 @@ python -m scripts.server # 测试能否启动,Ctrl+C 退出
|
||||
|
||||
### 3b. MuseTalk 1.5 (长视频唇形同步, GPU0)
|
||||
|
||||
> MuseTalk 是单步潜空间修复模型(非扩散模型),推理速度接近实时,适合 >=120s 的长视频。与 CosyVoice 共享 GPU0,fp16 推理约需 4-8GB 显存。合成阶段使用 NVENC GPU 硬编码(h264_nvenc)+ 纯 numpy blending,避免双重编码和 PIL 转换开销。
|
||||
> MuseTalk 是单步潜空间修复模型(非扩散模型),推理速度接近实时,适合达到路由阈值的长视频(本仓库当前 `.env` 示例为 >=100s)。与 CosyVoice 共享 GPU0,fp16 推理约需 4-8GB 显存。合成阶段已改为 FFmpeg rawvideo 管道直编码(`libx264` + 可配 CRF/preset)并保留 numpy blending,减少中间有损文件。
|
||||
|
||||
请参考详细的独立部署指南:
|
||||
**[MuseTalk 部署指南](MUSETALK_DEPLOY.md)**
|
||||
@@ -136,17 +136,21 @@ pip install -r requirements.txt
|
||||
playwright install chromium
|
||||
```
|
||||
|
||||
> 提示:视频号发布建议使用系统 Chrome + xvfb-run(避免 headless 解码失败)。
|
||||
> 抖音发布同样建议 headful 模式 (`DOUYIN_HEADLESS_MODE=headful`)。
|
||||
> 提示:视频号发布建议使用系统 Chrome + xvfb-run(避免 headless 解码失败)。
|
||||
> 抖音发布同样建议 headful 模式 (`DOUYIN_HEADLESS_MODE=headful`)。
|
||||
> 四平台发布专项实现说明请见 `Docs/PUBLISH_DEPLOY.md`。
|
||||
|
||||
### 扫码登录注意事项
|
||||
|
||||
- **Cookie 按用户隔离**:每个用户的 Cookie 存储在 `backend/user_data/{uuid}/cookies/` 目录下,多用户并发登录互不干扰。
|
||||
- **抖音 QR 登录关键教训**:
|
||||
- 扫码后绝对**不能重新加载 QR 页面**,否则会销毁会话 token
|
||||
- 使用**新标签页**检测登录完成状态(检查 URL 包含 `creator-micro` + session cookies 存在)
|
||||
- 抖音可能弹出**刷脸验证**,后端会自动提取验证二维码返回给前端展示
|
||||
- **微信视频号发布**:标题、描述、标签统一写入"视频描述"字段
|
||||
- **抖音 QR 登录关键教训**:
|
||||
- 扫码后绝对**不能重新加载 QR 页面**,否则会销毁会话 token
|
||||
- 使用**新标签页**检测登录完成状态(检查 URL 包含 `creator-micro` + session cookies 存在)
|
||||
- 抖音可能弹出**刷脸验证**,后端会自动提取验证二维码返回给前端展示
|
||||
- **小红书 QR 登录关键点**:
|
||||
- 创作平台默认可能是短信登录视图,需先切换到扫码登录再抓取二维码
|
||||
- 扫码后可能跳转 `creator.xiaohongshu.com/new/home`,不一定命中旧 `publish` 成功指示 URL
|
||||
- **微信视频号发布**:标题、描述、标签统一写入"视频描述"字段
|
||||
|
||||
---
|
||||
|
||||
@@ -195,24 +199,21 @@ playwright install chromium
|
||||
## 步骤 7: 配置环境变量
|
||||
|
||||
|
||||
```bash
|
||||
cd /home/rongye/ProgramFiles/ViGent2/backend
|
||||
|
||||
# 复制配置模板
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
> 💡 **说明**:`.env.example` 已包含正确的默认配置,直接复制即可使用。
|
||||
> 如需自定义,可编辑 `.env` 修改以下参数:
|
||||
|
||||
| 配置项 | 默认值 | 说明 |
|
||||
|--------|--------|------|
|
||||
| `SUPABASE_URL` | `http://localhost:8008` | Supabase API 内部地址 |
|
||||
| `SUPABASE_PUBLIC_URL` | `https://api.hbyrkj.top` | Supabase API 公网地址 (前端访问) |
|
||||
| `LATENTSYNC_GPU_ID` | 1 | GPU 选择 (0 或 1) |
|
||||
| `LATENTSYNC_USE_SERVER` | false | 设为 true 以启用常驻服务加速 |
|
||||
| `LATENTSYNC_INFERENCE_STEPS` | 20 | 推理步数 (16-50) |
|
||||
| `LATENTSYNC_GUIDANCE_SCALE` | 2.0 | 引导系数 (1.0-3.0) |
|
||||
```bash
|
||||
cd /home/rongye/ProgramFiles/ViGent2/backend
|
||||
```
|
||||
|
||||
> 💡 **说明**:当前仓库直接使用 `backend/.env`。请按你的环境替换敏感值并确认以下参数。
|
||||
> 如需自定义,可编辑 `.env` 修改以下参数:
|
||||
|
||||
| 配置项 | 当前示例值 | 说明 |
|
||||
|--------|------------|------|
|
||||
| `SUPABASE_URL` | `http://localhost:8008` | Supabase API 内部地址 |
|
||||
| `SUPABASE_PUBLIC_URL` | `https://api.hbyrkj.top` | Supabase API 公网地址 (前端访问) |
|
||||
| `LATENTSYNC_GPU_ID` | 1 | GPU 选择 (0 或 1) |
|
||||
| `LATENTSYNC_USE_SERVER` | true | 设为 true 以启用常驻服务加速 |
|
||||
| `LATENTSYNC_INFERENCE_STEPS` | 30 | 推理步数 (16-50) |
|
||||
| `LATENTSYNC_GUIDANCE_SCALE` | 1.9 | 引导系数 (1.0-3.0) |
|
||||
| `LATENTSYNC_ENABLE_DEEPCACHE` | true | DeepCache 推理加速 |
|
||||
| `LATENTSYNC_SEED` | 1247 | 固定随机种子(可复现) |
|
||||
| `DEBUG` | true | 生产环境改为 false |
|
||||
@@ -229,19 +230,26 @@ cp .env.example .env
|
||||
| `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 | 成功后保留录屏 |
|
||||
| `DOUYIN_LOCALE` | zh-CN | 抖音语言环境 |
|
||||
| `DOUYIN_TIMEZONE_ID` | Asia/Shanghai | 抖音时区 |
|
||||
| `DOUYIN_FORCE_SWIFTSHADER` | true | 强制软件 WebGL |
|
||||
| `XIAOHONGSHU_HEADLESS_MODE` | headless-new | 小红书 Playwright 模式 (headful/headless-new) |
|
||||
| `XIAOHONGSHU_CHROME_PATH` | `/usr/bin/google-chrome` | 小红书 Chrome 路径 |
|
||||
| `XIAOHONGSHU_BROWSER_CHANNEL` | | 小红书 Chromium 通道 (可选) |
|
||||
| `XIAOHONGSHU_USER_AGENT` | Chrome/144 UA | 小红书浏览器指纹 UA |
|
||||
| `XIAOHONGSHU_LOCALE` | zh-CN | 小红书语言环境 |
|
||||
| `XIAOHONGSHU_TIMEZONE_ID` | Asia/Shanghai | 小红书时区 |
|
||||
| `XIAOHONGSHU_FORCE_SWIFTSHADER` | true | 强制软件 WebGL |
|
||||
| `DOUYIN_DEBUG_ARTIFACTS` | false | 保留调试截图 |
|
||||
| `DOUYIN_RECORD_VIDEO` | false | 录制浏览器操作视频 |
|
||||
| `DOUYIN_KEEP_SUCCESS_VIDEO` | false | 成功后保留录屏 |
|
||||
| `CORS_ORIGINS` | `*` | CORS 允许源 (生产环境建议白名单) |
|
||||
| `MUSETALK_GPU_ID` | 0 | MuseTalk GPU 编号 |
|
||||
| `MUSETALK_API_URL` | `http://localhost:8011` | MuseTalk 常驻服务地址 |
|
||||
| `MUSETALK_BATCH_SIZE` | 32 | MuseTalk 推理批大小 |
|
||||
| `MUSETALK_VERSION` | v15 | MuseTalk 模型版本 |
|
||||
| `MUSETALK_USE_FLOAT16` | true | MuseTalk 半精度加速 |
|
||||
| `LIPSYNC_DURATION_THRESHOLD` | 120 | 秒,>=此值用 MuseTalk,<此值用 LatentSync |
|
||||
| `LIPSYNC_DURATION_THRESHOLD` | 100 | 秒,>=此值用 MuseTalk,<此值用 LatentSync(代码默认 120,建议在 `.env` 显式配置) |
|
||||
| `ALIPAY_APP_ID` | 空 | 支付宝应用 APPID |
|
||||
| `ALIPAY_PRIVATE_KEY_PATH` | 空 | 应用私钥 PEM 文件路径 |
|
||||
| `ALIPAY_PUBLIC_KEY_PATH` | 空 | 支付宝公钥 PEM 文件路径 |
|
||||
@@ -402,7 +410,7 @@ curl http://localhost:8010/health
|
||||
|
||||
### 5. 启动 MuseTalk 长视频唇形同步服务
|
||||
|
||||
> 长视频 (>=120s) 自动路由到 MuseTalk。MuseTalk 不可用时自动回退 LatentSync。
|
||||
> 达到阈值(当前 `.env` 示例为 >=100s)自动路由到 MuseTalk。MuseTalk 不可用时自动回退 LatentSync。
|
||||
> 详细部署步骤见 [MuseTalk 部署指南](MUSETALK_DEPLOY.md)。
|
||||
|
||||
1. 启动脚本位于项目根目录: `run_musetalk.sh`
|
||||
|
||||
405
Docs/DevLogs/Day30.md
Normal file
405
Docs/DevLogs/Day30.md
Normal file
@@ -0,0 +1,405 @@
|
||||
## Remotion 缓存修复 + 编码流水线质量优化 + 唇形同步容错 + 统一下拉交互 (Day 30)
|
||||
|
||||
### 概述
|
||||
|
||||
本轮最终合并为五大方面:(1) Remotion bundle 缓存导致标题/字幕丢失的严重 Bug;(2) 全面优化 LatentSync + MuseTalk 双引擎编码流水线,消除冗余有损编码;(3) 增强 LatentSync 的鲁棒性,允许素材中部分帧检测不到人脸时继续推理而非中断任务;(4) 唇形模型选择全链路透传(默认/快速/高级);(5) 首页与发布页选择器统一为 SelectPopover 交互,并修复遮挡、定位与预览层级问题。
|
||||
|
||||
---
|
||||
|
||||
## ✅ 改动内容
|
||||
|
||||
### 1. Remotion Bundle 缓存 404 修复(严重 Bug)
|
||||
|
||||
- **问题**: 生成的视频没有标题和字幕,Remotion 渲染失败后静默回退到 FFmpeg(无文字叠加能力)
|
||||
- **根因**: Remotion 的 bundle 缓存机制只在首次打包时复制 `publicDir`(视频/字体所在目录)。代码稳定后缓存持续命中,新生成的视频和字体文件不在旧缓存的 `public/` 目录 → Remotion HTTP server 返回 404 → 渲染失败
|
||||
- **尝试**: 先用 `fs.symlinkSync` 符号链接,但 Remotion 内部 HTTP server 不支持跟随符号链接
|
||||
- **最终方案**: 使用 `fs.linkSync` 硬链接(同文件系统零拷贝,对应用完全透明),跨文件系统时自动回退为 `fs.copyFileSync`
|
||||
|
||||
**文件**: `remotion/render.ts`
|
||||
|
||||
```typescript
|
||||
function ensureInCachedPublic(cachedPublicDir, srcAbsPath, fileName) {
|
||||
// 检查是否已存在且为同一 inode
|
||||
// 优先硬链接(零拷贝),跨文件系统回退为复制
|
||||
try {
|
||||
fs.linkSync(srcAbsPath, cachedPath);
|
||||
} catch {
|
||||
fs.copyFileSync(srcAbsPath, cachedPath);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
使用缓存 bundle 时,自动将当前渲染所需的文件(视频 + 字体)硬链接到缓存的 `public/` 目录:
|
||||
- 视频文件(`videoFileName`)
|
||||
- 字体文件(从 `subtitleStyle` / `titleStyle` / `secondaryTitleStyle` 的 `font_file` 字段提取)
|
||||
|
||||
---
|
||||
|
||||
### 2. 视频编码流水线质量优化
|
||||
|
||||
对完整流水线做全面审查,发现从素材上传到最终输出,视频最多经历 **5-6 次有损重编码**,而官方 LatentSync demo 只有 1-2 次。
|
||||
|
||||
#### 优化前编码链路
|
||||
|
||||
| # | 阶段 | CRF | 问题 |
|
||||
|---|------|-----|------|
|
||||
| 1 | 方向归一化 | 23 | 条件触发 |
|
||||
| 2 | `prepare_segment` 缩放+时长 | 23 | 必经,质量偏低 |
|
||||
| 3 | LatentSync `read_video` FPS 转换 | 18 | **即使已是 25fps 也重编码** |
|
||||
| 4 | LatentSync `imageio` 写帧 | 13 | 模型输出 |
|
||||
| 5 | LatentSync final mux | 18 | **CRF13 刚写完立刻 CRF18 重编码** |
|
||||
| 6 | compose | copy | Day29 已优化 |
|
||||
| 7 | 多素材 concat | 23 | **段参数已统一,不需要重编码** |
|
||||
| 8 | Remotion 渲染 | ~18 | 必经(叠加文字) |
|
||||
|
||||
#### 优化措施
|
||||
|
||||
##### 2a. LatentSync `read_video` 跳过冗余 FPS 重编码
|
||||
|
||||
**文件**: `models/LatentSync/latentsync/utils/util.py`
|
||||
|
||||
- 原代码无条件执行 `ffmpeg -r 25 -crf 18`,即使输入视频已是 25fps
|
||||
- 新增 FPS 检测:`abs(current_fps - 25.0) < 0.5` 时直接使用原文件
|
||||
- 我们的 `prepare_segment` 已统一输出 25fps,此步完全多余
|
||||
|
||||
```python
|
||||
cap = cv2.VideoCapture(video_path)
|
||||
current_fps = cap.get(cv2.CAP_PROP_FPS)
|
||||
cap.release()
|
||||
|
||||
if abs(current_fps - 25.0) < 0.5:
|
||||
print(f"Video already at {current_fps:.1f}fps, skipping FPS conversion")
|
||||
target_video_path = video_path
|
||||
else:
|
||||
# 仅非 25fps 时才重编码
|
||||
command = f"ffmpeg ... -r 25 -crf 18 ..."
|
||||
```
|
||||
|
||||
##### 2b. LatentSync final mux 流复制替代重编码
|
||||
|
||||
**文件**: `models/LatentSync/latentsync/pipelines/lipsync_pipeline.py`
|
||||
|
||||
- 原代码:`imageio` 以 CRF 13 高质量写完帧后,final mux 又用 `libx264 -crf 18` 完整重编码
|
||||
- 修复:改为 `-c:v copy` 流复制,仅 mux 音频轨,视频零损失
|
||||
|
||||
```diff
|
||||
- ffmpeg ... -c:v libx264 -crf 18 -c:a aac -q:v 0 -q:a 0
|
||||
+ ffmpeg ... -c:v copy -c:a aac -q:a 0
|
||||
```
|
||||
|
||||
##### 2c. `prepare_segment` + `normalize_orientation` CRF 23 → 18
|
||||
|
||||
**文件**: `backend/app/services/video_service.py`
|
||||
|
||||
- `normalize_orientation`:CRF 23 → 18
|
||||
- `prepare_segment` trim 临时文件:CRF 23 → 18
|
||||
- `prepare_segment` 主命令:CRF 23 → 18
|
||||
- CRF 18 是"高质量"级别,与 LatentSync 内部标准一致
|
||||
|
||||
##### 2d. 多素材 concat 流复制
|
||||
|
||||
**文件**: `backend/app/services/video_service.py`
|
||||
|
||||
- 原代码用 `libx264 -crf 23` 重编码拼接
|
||||
- 所有段已由 `prepare_segment` 统一为相同分辨率/帧率/编码参数
|
||||
- 改为 `-c:v copy` 流复制,消除一次完整重编码
|
||||
|
||||
```diff
|
||||
- -vsync cfr -r 25 -c:v libx264 -preset fast -crf 23 -pix_fmt yuv420p
|
||||
+ -c:v copy
|
||||
```
|
||||
|
||||
#### 优化后编码链路
|
||||
|
||||
| # | 阶段 | CRF | 状态 |
|
||||
|---|------|-----|------|
|
||||
| 1 | 方向归一化 | **18** | 提质(条件触发) |
|
||||
| 2 | `prepare_segment` | **18** | 提质(必经) |
|
||||
| 3 | ~~LatentSync FPS 转换~~ | - | **已消除** |
|
||||
| 4 | LatentSync 模型输出 | 13 | 不变(不可避免) |
|
||||
| 5 | ~~LatentSync final mux~~ | - | **已消除(copy)** |
|
||||
| 6 | compose | copy | 不变 |
|
||||
| 7 | ~~多素材 concat~~ | - | **已消除(copy)** |
|
||||
| 8 | Remotion 渲染 | ~18 | 不变(不可避免) |
|
||||
|
||||
**总计:5-6 次有损编码 → 3 次**(prepare_segment → LatentSync 模型输出 → Remotion),质量损失减少近一半。
|
||||
|
||||
---
|
||||
|
||||
## 📁 修改文件清单
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `remotion/render.ts` | bundle 缓存使用时硬链接视频+字体到 public 目录 |
|
||||
| `models/LatentSync/latentsync/utils/util.py` | `read_video` 检测 FPS,25fps 时跳过重编码 |
|
||||
| `models/LatentSync/latentsync/pipelines/lipsync_pipeline.py` | final mux `-c:v copy`;无脸帧容错(affine_transform + restore_video) |
|
||||
| `backend/app/services/video_service.py` | `normalize_orientation` CRF 23→18;`prepare_segment` CRF 23→18;`concat_videos` `-c:v copy` |
|
||||
| `backend/app/modules/videos/workflow.py` | 单素材 LatentSync 异常时回退原视频 |
|
||||
|
||||
---
|
||||
|
||||
### 3. LatentSync 无脸帧容错
|
||||
|
||||
- **问题**: 素材中如果有部分帧检测不到人脸(转头、遮挡、空镜头),`affine_transform` 会抛异常导致整个推理任务失败
|
||||
- **改动**:
|
||||
- `affine_transform_video`: 单帧异常时 catch 住,用最近有效帧的 face/box/affine_matrix 填充(保证 tensor batch 维度完整),全部帧无脸时仍 raise
|
||||
- `restore_video`: 新增 `valid_face_flags` 参数,无脸帧直接保留原画面(不做嘴型替换)
|
||||
- `loop_video`: `valid_face_flags` 跟随循环和翻转
|
||||
- `workflow.py`: 单素材路径 `lipsync.generate()` 整体异常时 copy 原视频继续流程,任务不会失败
|
||||
|
||||
---
|
||||
|
||||
### 4. MuseTalk 编码链路优化
|
||||
|
||||
#### 4a. FFmpeg rawvideo 管道直编码(消除中间有损文件)
|
||||
|
||||
**文件**: `models/MuseTalk/scripts/server.py`
|
||||
|
||||
- **原流程**: UNet 推理帧 → `cv2.VideoWriter(mp4v)` 写中间文件(有损) → FFmpeg 重编码+音频 mux(又一次有损)
|
||||
- **新流程**: UNet 推理帧 → FFmpeg rawvideo stdin 管道 → 一次 libx264 编码+音频 mux
|
||||
|
||||
```python
|
||||
ffmpeg_cmd = [
|
||||
"ffmpeg", "-y", "-v", "warning",
|
||||
"-f", "rawvideo", "-pix_fmt", "bgr24",
|
||||
"-s", f"{w}x{h}", "-r", str(fps),
|
||||
"-i", "-", # stdin 管道输入
|
||||
"-i", audio_path,
|
||||
"-c:v", "libx264", "-preset", ENCODE_PRESET, "-crf", str(ENCODE_CRF),
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-c:a", "copy", "-shortest",
|
||||
output_vid_path,
|
||||
]
|
||||
ffmpeg_proc = subprocess.Popen(ffmpeg_cmd, stdin=subprocess.PIPE, ...)
|
||||
# 每帧直接 pipe_in.write(frame.tobytes())
|
||||
```
|
||||
|
||||
关键实现细节:
|
||||
- `-pix_fmt bgr24` 匹配 OpenCV 原生帧格式,零转换开销
|
||||
- `np.ascontiguousarray` 确保帧内存连续
|
||||
- `BrokenPipeError` 捕获 + return code 检查覆盖异常路径
|
||||
- `pipe_in.close()` 在 `ffmpeg_proc.wait()` 之前,正确发送 EOF
|
||||
- 合成 fallback(resize 失败、mask 失败、blending 失败)均通过 `_write_pipe_frame` 输出原帧
|
||||
|
||||
#### 4b. MuseTalk 参数环境变量化 + 质量优先档
|
||||
|
||||
**文件**: `models/MuseTalk/scripts/server.py` + `backend/.env`
|
||||
|
||||
所有推理与编码参数从硬编码改为 `.env` 可配置,当前使用"质量优先"档:
|
||||
|
||||
| 参数 | 原默认值 | 质量优先值 | 作用 |
|
||||
|------|----------|-----------|------|
|
||||
| `MUSETALK_DETECT_EVERY` | 5 | **2** | 人脸检测频率 ↑2.5x,画面跟踪更稳 |
|
||||
| `MUSETALK_BLEND_CACHE_EVERY` | 5 | **2** | mask 更新更频,面部边缘融合更干净 |
|
||||
| `MUSETALK_EXTRA_MARGIN` | 15 | **14** | 下巴区域微调 |
|
||||
| `MUSETALK_BLEND_MODE` | auto | **jaw** | v1.5 显式 jaw 模式 |
|
||||
| `MUSETALK_ENCODE_CRF` | 18 | **14** | 接近视觉无损(输出还要进 Remotion 再编码) |
|
||||
| `MUSETALK_ENCODE_PRESET` | medium | **slow** | 同 CRF 下压缩效率更高 |
|
||||
| `MUSETALK_AUDIO_PADDING` | 2/2 | 2/2 | 不变 |
|
||||
| `MUSETALK_FACEPARSING_CHEEK` | 90/90 | 90/90 | 不变 |
|
||||
|
||||
新增可配置参数完整列表:`DETECT_EVERY`、`BLEND_CACHE_EVERY`、`AUDIO_PADDING_LEFT/RIGHT`、`EXTRA_MARGIN`、`DELAY_FRAME`、`BLEND_MODE`、`FACEPARSING_LEFT/RIGHT_CHEEK_WIDTH`、`ENCODE_CRF`、`ENCODE_PRESET`。
|
||||
|
||||
---
|
||||
|
||||
### 5. Workflow 异步防阻塞 + compose 跳过优化
|
||||
|
||||
#### 5a. 阻塞调用线程池化
|
||||
|
||||
**文件**: `backend/app/modules/videos/workflow.py`
|
||||
|
||||
workflow 中多处同步 FFmpeg 调用会阻塞 asyncio 事件循环,导致其他 API 请求(健康检查、任务状态查询)无法响应。新增通用辅助函数 `_run_blocking()`,将所有阻塞调用统一走线程池:
|
||||
|
||||
```python
|
||||
async def _run_blocking(func, *args):
|
||||
"""在线程池执行阻塞函数,避免卡住事件循环。"""
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(None, func, *args)
|
||||
```
|
||||
|
||||
已改造的阻塞调用点:
|
||||
|
||||
| 调用 | 位置 | 说明 |
|
||||
|------|------|------|
|
||||
| `video.normalize_orientation()` | 单素材旋转归一化 | FFmpeg 旋转/转码 |
|
||||
| `video.prepare_segment()` | 多素材片段准备 | FFmpeg 缩放+时长裁剪,配合 `asyncio.gather` 多段并行 |
|
||||
| `video.concat_videos()` | 多素材拼接 | FFmpeg concat |
|
||||
| `video.prepare_segment()` | 单素材 prepare | FFmpeg 缩放+时长裁剪 |
|
||||
| `video.mix_audio()` | BGM 混音 | FFmpeg 音频混合 |
|
||||
| `video._get_duration()` | 音频/视频时长探测 (3处) | ffprobe 子进程 |
|
||||
|
||||
#### 5b. `prepare_segment` 同分辨率跳过 scale
|
||||
|
||||
**文件**: `backend/app/modules/videos/workflow.py`
|
||||
|
||||
原来无论素材分辨率是否已匹配目标,都强制传 `target_resolution` 给 `prepare_segment`,触发 scale filter + libx264 重编码。优化后逐素材比对分辨率:
|
||||
|
||||
- **多素材**: 逐段判断,分辨率匹配的传 `None`(`prepare_target_res = None if res == base_res else base_res`),走 `-c:v copy` 分支
|
||||
- **单素材**: 先 `get_resolution` 比对,匹配则传 `None`
|
||||
|
||||
当分辨率匹配且无截取、不需要循环、不需要变帧率时,`prepare_segment` 内部走 `-c:v copy`,完全零损编码。
|
||||
|
||||
#### 5c. `_get_duration()` 线程池化
|
||||
|
||||
**文件**: `backend/app/modules/videos/workflow.py`
|
||||
|
||||
3 处 `video._get_duration()` 同步 ffprobe 调用改为 `await _run_blocking(video._get_duration, ...)`,避免阻塞事件循环。
|
||||
|
||||
#### 5d. compose 循环场景 CRF 统一
|
||||
|
||||
**文件**: `backend/app/services/video_service.py`
|
||||
|
||||
`compose()` 在视频需要循环时的编码从 CRF 23 提升到 CRF 18,与全流水线质量标准统一。
|
||||
|
||||
#### 5e. 多素材片段校验
|
||||
|
||||
**文件**: `backend/app/modules/videos/workflow.py`
|
||||
|
||||
多素材 `prepare_segment` 完成后新增片段数量一致性校验,避免空片段进入 concat 导致异常。
|
||||
|
||||
#### 5f. compose() 内部防阻塞
|
||||
|
||||
**文件**: `backend/app/services/video_service.py`
|
||||
|
||||
`compose()` 改为 `async def`,内部的 `_get_duration()` 和 `_run_ffmpeg()` 都通过 `loop.run_in_executor` 在线程池执行。
|
||||
|
||||
#### 5g. 无需二次 compose 直接透传
|
||||
|
||||
**文件**: `backend/app/modules/videos/workflow.py`
|
||||
|
||||
当没有 BGM 时(`final_audio_path == audio_path`),LatentSync/MuseTalk 输出已包含正确音轨,跳过多余的 compose 步骤:
|
||||
|
||||
```python
|
||||
needs_audio_compose = str(final_audio_path) != str(audio_path)
|
||||
```
|
||||
|
||||
- **Remotion 路径**: 音频没变则跳过 pre-compose,直接用 lipsync 输出进 Remotion
|
||||
- **非 Remotion 路径**: 音频没变则 `shutil.copy` 直接透传 lipsync 输出,不再走 compose
|
||||
|
||||
---
|
||||
|
||||
### 6. 唇形模型选择全链路
|
||||
|
||||
前端“生成视频”按钮右侧新增模型选择,下拉值全链路透传到后端路由与推理服务。
|
||||
|
||||
#### 模型选项
|
||||
|
||||
| 选项 | 值 | 路由逻辑 |
|
||||
|------|------|------|
|
||||
| 默认模型 | `default` | 保持阈值路由(`LIPSYNC_DURATION_THRESHOLD`,当前建议 100s) |
|
||||
| 快速模型 | `fast` | 强制 MuseTalk,不可用时回退 LatentSync |
|
||||
| 高级模型 | `advanced` | 强制 LatentSync |
|
||||
|
||||
#### 最终 UI 形态
|
||||
|
||||
- 模型按钮由原生 `<select>` 升级为统一 `SelectPopover`
|
||||
- 触发器文案改为业务语义(`默认模型 / 快速模型 / 高级模型` + `按时长智能路由 / 速度优先 / 质量优先`)
|
||||
- 选择状态持久化到 `useHomePersistence`(`lipsyncModelMode`)
|
||||
|
||||
#### 数据流
|
||||
|
||||
```
|
||||
前端 SelectPopover → setLipsyncModelMode("fast") → localStorage 持久化
|
||||
↓
|
||||
用户点击"生成视频" → handleGenerate()
|
||||
→ payload.lipsync_model = lipsyncModelMode
|
||||
→ POST /api/videos/generate { ..., lipsync_model: "fast" }
|
||||
→ workflow: req.lipsync_model 透传给 lipsync.generate(model_mode=...)
|
||||
→ lipsync_service.generate(): 按 model_mode 路由
|
||||
→ fast: 强制 MuseTalk → 回退 LatentSync
|
||||
→ advanced: 强制 LatentSync
|
||||
→ default: 阈值策略
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. 首页/发布页统一下拉交互(SelectPopover)
|
||||
|
||||
#### 7a. 统一改造范围
|
||||
|
||||
首页与发布页的业务选择项统一迁移到 `SelectPopover`:
|
||||
|
||||
- 首页:音色、参考音频、配音列表、素材选择、BGM 选择、作品选择、标题显示模式、标题/副标题/字幕样式、时间轴画面比例、唇形模型
|
||||
- 发布页:选择发布作品(搜索 + 预览)
|
||||
|
||||
例外:`ScriptEditor` 的“历史文案 / AI多语言”按产品要求恢复为原有轻量菜单,不强制统一。
|
||||
|
||||
#### 7b. 关键交互修复
|
||||
|
||||
- **遮挡修复**:桌面端面板改为 `Portal + fixed`,脱离局部 stacking context,彻底解决被卡片遮挡
|
||||
- **上拉/下拉自适应**:底部空间不足时自动上拉,避免菜单显示不全
|
||||
- **同宽展示**:面板宽度与触发器保持一致
|
||||
- **风格统一**:面板背景加实(高不透明度),滚动条隐藏但可滚动
|
||||
- **已选定位**:再次打开下拉时自动滚动到已选项(`data-popover-selected="true"`)
|
||||
- **预览协同**:
|
||||
- 下拉内点“预览”不强制关闭,支持连续预览
|
||||
- 视频预览弹窗层级高于下拉,避免被遮挡
|
||||
- 预览弹窗打开时,下拉不会因外部点击/Esc被误关闭;关闭预览后仍可继续操作
|
||||
|
||||
#### 7c. BGM 面板收敛
|
||||
|
||||
- BGM 改为与“发布作品”同款选择器(搜索 + 列表 + 试听 + 选中态)
|
||||
- 按产品要求移除首页 BGM 音量滑杆
|
||||
- 生成请求统一使用固定 `bgm_volume=0.2`
|
||||
|
||||
---
|
||||
|
||||
## 📁 总修改文件清单
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `remotion/render.ts` | bundle 缓存使用时硬链接视频+字体到 public 目录 |
|
||||
| `models/LatentSync/latentsync/utils/util.py` | `read_video` 检测 FPS,25fps 时跳过重编码 |
|
||||
| `models/LatentSync/latentsync/pipelines/lipsync_pipeline.py` | final mux `-c:v copy`;无脸帧容错 |
|
||||
| `backend/app/services/video_service.py` | CRF 23→18;`concat_videos` copy;`compose()` 异步化 + 循环 CRF 18 |
|
||||
| `backend/app/modules/videos/workflow.py` | 线程池化;同分辨率跳过 scale;compose 跳过;片段校验;模型选择透传 |
|
||||
| `backend/app/modules/videos/schemas.py` | 新增 `lipsync_model` 字段 |
|
||||
| `backend/app/services/lipsync_service.py` | `generate()` 新增 `model_mode` 三路分支路由 |
|
||||
| `models/MuseTalk/scripts/server.py` | FFmpeg rawvideo 管道;参数环境变量化 |
|
||||
| `backend/.env` | MuseTalk 推理/融合/编码参数可配;路由阈值与质量档调优 |
|
||||
| `frontend/src/shared/ui/SelectPopover.tsx` | 新增统一选择器:Portal+fixed、防遮挡、上拉/下拉自适应、同宽、隐藏滚动条、已选定位、预览协同 |
|
||||
| `frontend/src/features/home/ui/HomePage.tsx` | 配音卡层级修复;传递统一下拉状态 |
|
||||
| `frontend/src/features/home/model/useHomeController.ts` | `lipsyncModelMode` 透传;BGM 固定 `bgm_volume=0.2` |
|
||||
| `frontend/src/features/home/model/useHomePersistence.ts` | 模型模式等新增字段持久化 |
|
||||
| `frontend/src/features/home/ui/GenerateActionBar.tsx` | 模型选择改为 SelectPopover(速度/质量语义文案) |
|
||||
| `frontend/src/features/home/ui/VoiceSelector.tsx` | 音色选择统一为 SelectPopover(音色名+语言) |
|
||||
| `frontend/src/features/home/ui/RefAudioPanel.tsx` | 参考音频选择统一为 SelectPopover(含试听/重命名/删除/重识别) |
|
||||
| `frontend/src/features/home/ui/GeneratedAudiosPanel.tsx` | 配音列表、语速、语气统一为 SelectPopover |
|
||||
| `frontend/src/features/home/ui/MaterialSelector.tsx` | 素材选择改为发布页同款下拉(搜索/多选/预览/重命名/删除) |
|
||||
| `frontend/src/features/home/ui/BgmPanel.tsx` | BGM 选择改为发布页同款下拉(搜索+试听),移除音量滑杆 |
|
||||
| `frontend/src/features/home/ui/HistoryList.tsx` | 首页作品选择改为下拉(搜索+删除+选中态) |
|
||||
| `frontend/src/features/home/ui/TitleSubtitlePanel.tsx` | 标题显示模式与样式选择统一为 SelectPopover |
|
||||
| `frontend/src/features/home/ui/TimelineEditor.tsx` | 画面比例选择统一为 SelectPopover(单行按钮) |
|
||||
| `frontend/src/features/publish/ui/PublishPage.tsx` | 发布作品选择改为 SelectPopover;预览时下拉保持打开 |
|
||||
| `frontend/src/components/VideoPreviewModal.tsx` | 提升层级并添加预览标记,与下拉联动 |
|
||||
| `frontend/src/features/home/ui/ScriptEditor.tsx` | 历史文案/AI多语言恢复原轻量菜单(产品例外) |
|
||||
| `Docs/FRONTEND_DEV.md` | 新增 SelectPopover 规范、预览层级规范、持久化字段修订 |
|
||||
|
||||
---
|
||||
|
||||
## 🔍 验证
|
||||
|
||||
1. **标题字幕恢复**: 生成视频应有标题和逐字高亮字幕(Remotion 渲染成功,非 FFmpeg 回退)
|
||||
2. **Remotion 日志**: 应出现 `Hardlinked into cached bundle:` 或 `Copied into cached bundle:` 而非 404
|
||||
3. **LatentSync FPS 跳过**: 日志应出现 `Video already at 25.0fps, skipping FPS conversion`
|
||||
4. **LatentSync mux**: FFmpeg 日志中 final mux 应为 `-c:v copy`
|
||||
5. **画质对比**: 同一素材+音频,优化后生成的视频嘴型区域(尤其牙齿)应比优化前更清晰
|
||||
6. **多素材拼接**: concat 步骤应为流复制,耗时从秒级降到毫秒级
|
||||
7. **无脸帧容错**: 包含转头/遮挡帧的素材不再导致任务失败,无脸帧保留原画面
|
||||
8. **MuseTalk 管道编码**: 日志中不应出现中间 mp4v 文件,合成阶段直接管道写入
|
||||
9. **MuseTalk 质量参数**: `curl localhost:8011/health` 确认服务在线,生成视频嘴型边缘更清晰
|
||||
10. **事件循环不阻塞**: 生成视频期间,`/api/tasks/{id}` 等接口应正常响应,不出现超时
|
||||
11. **compose 跳过**: 无 BGM 时日志应出现 `Audio unchanged, skip pre-Remotion compose`
|
||||
12. **同分辨率跳过 scale**: 素材已是目标分辨率时,`prepare_segment` 应走 `-c:v copy`(日志中无 scale filter)
|
||||
13. **compose 循环 CRF**: 循环场景编码应为 CRF 18(非 23)
|
||||
14. **模型选择 UI**: 生成按钮右侧应出现默认模型/快速模型/高级模型下拉
|
||||
15. **模型选择持久化**: 切换模型后刷新页面,下拉应恢复上次选择
|
||||
16. **快速模型路由**: 选择"快速模型"时,后端日志应出现 `强制快速模型:MuseTalk`
|
||||
17. **高级模型路由**: 选择"高级模型"时,后端日志应出现 `强制高级模型:LatentSync`
|
||||
18. **默认模型不变**: 选择"默认模型"时行为与改动前完全一致(阈值路由)
|
||||
19. **统一下拉样式**: 首页/发布页业务选择项均为同款 SelectPopover(触发器 + 面板 + 选中态)
|
||||
20. **上拉自适应**: 页面底部打开下拉时应自动上拉,不出现被截断
|
||||
21. **已选定位**: 任意下拉再次打开时应自动定位到已选项,而非列表顶端
|
||||
22. **预览层级**: 视频预览弹窗应始终覆盖在下拉之上,不被菜单遮挡
|
||||
23. **连续预览**: 下拉内点击预览后菜单保持打开,关闭预览后可继续点击其他预览项
|
||||
24. **BGM 行为**: 首页 BGM 不再显示音量滑杆,生成请求固定 `bgm_volume=0.2`
|
||||
453
Docs/DevLogs/Day31.md
Normal file
453
Docs/DevLogs/Day31.md
Normal file
@@ -0,0 +1,453 @@
|
||||
## 文档分层收敛 + 音色试听修复 + 录音弹窗重构 + 弹窗体系统一 (Day 31)
|
||||
|
||||
### 概述
|
||||
|
||||
今天的工作聚焦四件事:
|
||||
|
||||
1. 清理并收敛根目录文档(README/DEV 职责边界、历史内容归档、参数描述与代码对齐)
|
||||
2. 完成 EdgeTTS 音色列表「一键试听」能力,并修复浏览器端试听失败问题
|
||||
3. 重构声音克隆录音交互:录音入口下沉到参考音频区域底部右侧,流程改为弹窗
|
||||
4. 抽离统一弹窗基座 `AppModal`,将主要弹窗迁移到同一视觉和交互规范
|
||||
|
||||
---
|
||||
|
||||
## ✅ 1) 文档体系与内容一致性优化
|
||||
|
||||
### 1.1 README / DEV 边界明确
|
||||
|
||||
- 为 `FRONTEND_README.md`、`BACKEND_README.md`、`FRONTEND_DEV.md`、`BACKEND_DEV.md` 增加「文档定位」
|
||||
- README 只保留稳定说明(功能、接口、运行),DEV 保留规范(约束、分层、Checklist)
|
||||
- 将 README 中偏日志化内容(如 Day 标注)清理为稳定表述
|
||||
|
||||
### 1.2 部署与参数文档对齐当前代码
|
||||
|
||||
- 将唇形路由阈值文案统一为阈值驱动,并以当前 `.env` 示例 `100` 为参考
|
||||
- 修正旧编码描述(将 MuseTalk 合成描述对齐为 rawvideo 管道 + `libx264`)
|
||||
- 修复文档中不存在的 `.env.example` 指引,改为基于 `backend/.env` 的说明
|
||||
- 将 Qwen3-TTS 文档标注为「历史归档(已停用)」并指向 CosyVoice 3.0
|
||||
|
||||
---
|
||||
|
||||
## ✅ 2) 音色试听能力落地与故障修复
|
||||
|
||||
### 2.1 功能实现
|
||||
|
||||
- 音色下拉项新增试听按钮(播放/暂停/加载态)
|
||||
- 新增后端试听接口:`/api/videos/voice-preview`
|
||||
- 试听文本按音色 locale 自动选择固定示例文案(9 国语言 + 中文兜底)
|
||||
|
||||
### 2.2 兼容与稳定性调整
|
||||
|
||||
- 保留 `POST /api/videos/voice-preview`(兼容)
|
||||
- 新增 `GET /api/videos/voice-preview?voice=...`,前端改为直接播放 GET 音频流,减少浏览器自动播放策略干扰
|
||||
|
||||
```python
|
||||
@router.get("/voice-preview")
|
||||
async def preview_voice_get(voice: str, current_user: dict = Depends(get_current_user)):
|
||||
voice_value = voice.strip()
|
||||
if not voice_value:
|
||||
raise HTTPException(status_code=400, detail="voice 不能为空")
|
||||
text = _get_preview_text_for_voice(voice_value)
|
||||
return await _render_voice_preview(voice=voice_value, text=text)
|
||||
```
|
||||
|
||||
### 2.3 本次线上问题结论(已修复)
|
||||
|
||||
- 现象:浏览器端试听请求 404
|
||||
- 根因:新增 GET 路由后,后端进程未重启,运行中的代码仍是旧版本
|
||||
- 处理:`pm2 restart vigent2-backend` 后路由生效
|
||||
- 补充:`curl` 返回 401(无 auth cookie)属于预期;浏览器同源请求会自动带 cookie
|
||||
|
||||
---
|
||||
|
||||
## ✅ 3) 录音交互重构(声音克隆)
|
||||
|
||||
### 3.1 入口重排
|
||||
|
||||
- 去掉参考音频面板内的独立录音大块区域
|
||||
- 将「上传音频 / 录音」入口放到「我的参考音频」区域底部右侧
|
||||
|
||||
### 3.2 录音流程改为弹窗
|
||||
|
||||
- 录音弹窗支持:开始录音 / 停止录音 / 状态计时 / 试听
|
||||
- 保留并强化「使用此录音」和「弃用本次录音」
|
||||
- 关闭弹窗时若仍在录音,会先停止录音再关闭
|
||||
- 修正弹窗挂载位置:从局部组件渲染改为 `AppModal` Portal 到 `document.body`,确保是全页面弹窗体验
|
||||
- 参考音频区按钮文案更新:`录音` -> `在线录音`
|
||||
|
||||
### 3.4 文案区按钮视觉统一
|
||||
|
||||
- 统一「文案提取与编辑」区按钮尺寸与圆角(`px-3 py-1.5 text-xs rounded-lg`)
|
||||
- 将 `AI智能改写`、`保存文案` 按钮改为与上传/在线录音同等级的视觉规格
|
||||
- 同步统一图标尺寸与禁用态样式,消除“底部按钮偏小”问题
|
||||
|
||||
### 3.5 录音试听条 UI 美化
|
||||
|
||||
- 将录音完成后的原生白色 `<audio controls>` 替换为项目深色风格的自定义试听条
|
||||
- 新试听条包含:播放/暂停按钮、进度拖拽、当前时长/总时长显示
|
||||
- 统一配色到当前页面(深色底 + 绿色强调),避免与整体 UI 风格割裂
|
||||
|
||||
### 3.6 录音上传关闭时机优化
|
||||
|
||||
- 原逻辑:点击「使用此录音」后,需等待上传+识别完成才关闭弹窗(体感卡顿)
|
||||
- 新逻辑:点击后立即关闭弹窗,上传/识别在后台继续进行
|
||||
- 状态反馈仍在参考音频区域显示(上传识别中的提示 + 失败错误提示)
|
||||
|
||||
---
|
||||
|
||||
## ✅ 5) 发布管理抖音登录「无法获取二维码」修复
|
||||
|
||||
### 问题定位
|
||||
|
||||
- 现象:发布管理中点击抖音登录,前端提示无法获取二维码
|
||||
- 后端日志显示根因:
|
||||
- `Page.goto: Timeout 30000ms exceeded`
|
||||
- 导航目标:`https://creator.douyin.com/`
|
||||
- 等待条件:`wait_until="networkidle"`
|
||||
|
||||
### 修复方案
|
||||
|
||||
- 抖音登录页改为与微信一致的更稳策略:`wait_until="domcontentloaded"`
|
||||
- 对抖音导航超时增加容错:即使 `goto` 超时,也继续执行二维码提取流程(避免长连接导致误失败)
|
||||
|
||||
### 验证
|
||||
|
||||
- 本地接口冒烟:`POST /api/publish/login/douyin` 返回 `success=true` 且包含 `qr_code`
|
||||
- 已重启后端进程使修复生效:`pm2 restart vigent2-backend`
|
||||
|
||||
### 3.3 状态逻辑补齐
|
||||
|
||||
- 新增 `discardRecording()`:清空本次录音与计时
|
||||
- 开始新录音前先清空旧录音,避免旧状态残留
|
||||
|
||||
---
|
||||
|
||||
## ✅ 4) 弹窗 UI/UX 统一(AppModal)
|
||||
|
||||
新增统一弹窗基座:`frontend/src/shared/ui/AppModal.tsx`
|
||||
|
||||
- 统一遮罩:`bg-black/80 + backdrop-blur-sm`
|
||||
- 统一容器:深色半透明背景、`border-white/10`、`rounded-2xl`、重阴影
|
||||
- 统一 Header:标题/副标题/关闭按钮
|
||||
- 统一行为:ESC 关闭、背景滚动锁定、按需控制 overlay 点击关闭
|
||||
- 统一挂载:通过 Portal 渲染到 `document.body`,避免出现“看起来只在配音区弹出”的层叠问题
|
||||
- 统一可访问性:补齐 `role="dialog"` + `aria-modal="true"`
|
||||
- 统一焦点管理:打开弹窗自动聚焦,关闭后恢复到打开前焦点元素
|
||||
- 统一滚动锁计数:支持多弹窗并存,避免一个弹窗关闭后提前恢复页面滚动
|
||||
|
||||
已迁移弹窗:
|
||||
|
||||
- 视频预览(`VideoPreviewModal`)
|
||||
- 文案提取(`ScriptExtractionModal`)
|
||||
- AI 改写(`RewriteModal`)
|
||||
- 截取设置(`ClipTrimmer`)
|
||||
- 录音弹窗(`RefAudioPanel` 内)
|
||||
- 修改密码弹窗(`AccountSettingsDropdown`)
|
||||
- 发布管理扫码登录弹窗(`PublishPage` 内 QR 登录弹窗)
|
||||
|
||||
---
|
||||
|
||||
## ✅ 6) 微信视频号登录二维码观感优化(“能扫但像被截断”)
|
||||
|
||||
### 问题现象
|
||||
|
||||
- 微信视频号登录二维码可扫码成功,但视觉上像“边缘不完整/被切掉”,观感不佳
|
||||
|
||||
### 修复方案
|
||||
|
||||
- 后端二维码提取策略增强(`qr_login_service.py`):
|
||||
- 优先导出二维码原始 PNG 数据(`canvas.toDataURL('image/png')` / `img[data:image/png]`),减少二次截图导致的边缘损失
|
||||
- 微信回退截图时改为“按二维码 bbox 外扩留白裁剪”,避免贴边截取带来的不完整感
|
||||
- 仅接受 PNG Data URL,避免把非 PNG(如 SVG 片段)直接当二维码返回造成边角异常
|
||||
- 前端扫码弹窗展示优化(`PublishPage.tsx`):
|
||||
- 取消二维码图片本体圆角裁切,改为外层白底容器 + 内边距(模拟 quiet zone)
|
||||
- 同步调整二维码显示宽度与边框,提升完整感与观感一致性
|
||||
|
||||
### 验证
|
||||
|
||||
- 本地接口冒烟:`POST /api/publish/login/weixin` 返回 `success=true` 且包含 `qr_code`
|
||||
- 解码后图片尺寸为 `1000x1000`,扫码仍正常
|
||||
- 前后端进程已重启使修复生效:
|
||||
- `pm2 restart vigent2-frontend`
|
||||
- `pm2 restart vigent2-backend`
|
||||
|
||||
---
|
||||
|
||||
## ✅ 7) 发布流程性能与日志可读性优化(双平台发布场景)
|
||||
|
||||
### 7.1 发布请求并发优化(前端)
|
||||
|
||||
- 原逻辑:发布页按平台串行 `for...of await`,多平台总耗时为各平台耗时累加
|
||||
- 新逻辑:引入受限并发执行(并发度=2),两平台可并行发布,显著缩短总等待时长
|
||||
- 结果列表仍按用户选择的平台顺序回填,避免并发返回导致顺序抖动
|
||||
|
||||
### 7.2 微信上传日志噪声优化(后端)
|
||||
|
||||
- 原逻辑:`set_input_files` 后若立即读不到 `input.files[0]` 就直接打 warning:`[weixin][file_input] empty`
|
||||
- 新逻辑:先轮询确认“是否已进入上传中状态”,再决定是否告警;非最后一次重试只记 info,最后一次才 warning
|
||||
- 效果:减少误报警(实际已开始上传时不再刷 warning),排障日志更干净
|
||||
|
||||
### 验证
|
||||
|
||||
- `python -m py_compile backend/app/services/uploader/weixin_uploader.py` ✅
|
||||
- `npm run build`(frontend)✅
|
||||
- 服务重启:`pm2 restart vigent2-frontend && pm2 restart vigent2-backend` ✅
|
||||
|
||||
---
|
||||
|
||||
## ✅ 8) 小红书发布链路对齐改造(启动模式 / Cookie 格式 / 成功截图)
|
||||
|
||||
### 8.1 启动模式与反检测参数对齐
|
||||
|
||||
- 在 `config.py` 新增小红书 Playwright 配置:
|
||||
- `XIAOHONGSHU_HEADLESS_MODE`(默认 `headless-new`)
|
||||
- `XIAOHONGSHU_USER_AGENT / LOCALE / TIMEZONE_ID`
|
||||
- `XIAOHONGSHU_CHROME_PATH / BROWSER_CHANNEL`
|
||||
- `XIAOHONGSHU_FORCE_SWIFTSHADER / DEBUG_ARTIFACTS`
|
||||
- `xiaohongshu_uploader.py` 改为与抖音/微信一致的可配置启动策略,并保留反检测基础参数(`--disable-blink-features=AutomationControlled`)
|
||||
|
||||
### 8.2 小红书 uploader 重构增强
|
||||
|
||||
- 重写小红书 uploader 主流程(参考抖音/微信模式):
|
||||
- 上传入口/文件 input 多选择器回退
|
||||
- 上传中/成功/失败状态轮询判定
|
||||
- 标题与正文/话题填充容错
|
||||
- 发布按钮多选择器与可点击检查
|
||||
- 发布成功判定从“仅 URL”增强为“多信号组合”:
|
||||
- URL 跳转判定
|
||||
- 页面成功/失败文案判定
|
||||
- 发布 API 响应监听(`publish` / `note create` 类接口)
|
||||
- 发布成功后补齐截图能力并返回 `screenshot_url`(路径格式与抖音/微信一致):
|
||||
- `/api/publish/screenshot/{filename}`
|
||||
|
||||
### 8.3 Cookie 保存格式统一
|
||||
|
||||
- `publish_service.save_cookie_string()` 调整:
|
||||
- `bilibili` 继续使用原有简化 cookie dict(兼容既有上传库)
|
||||
- 非 `bilibili` 平台统一保存为 Playwright `storage_state`:
|
||||
- `{"cookies": [...], "origins": []}`
|
||||
- 补充平台默认 domain(抖音/微信/小红书),使 cookie 文件可直接用于 `browser.new_context(storage_state=...)`
|
||||
|
||||
### 8.4 验证与生效
|
||||
|
||||
- `python -m py_compile backend/app/core/config.py backend/app/services/publish_service.py backend/app/services/uploader/xiaohongshu_uploader.py` ✅
|
||||
- `pm2 restart vigent2-backend` ✅
|
||||
|
||||
---
|
||||
|
||||
## ✅ 9) 小红书登录二维码修复(默认短信登录需先切换)
|
||||
|
||||
### 问题现象
|
||||
|
||||
- 小红书创作平台 `https://creator.xiaohongshu.com/` 默认落在“短信登录”视图
|
||||
- 二维码需要先点击右上角切换图标才会出现,导致后端直接按二维码选择器抓取失败
|
||||
|
||||
### 修复方案(`qr_login_service.py`)
|
||||
|
||||
- 新增 `_ensure_xiaohongshu_qr_mode()`:
|
||||
- 先检测是否处于短信登录(`input[placeholder*='手机号']`)
|
||||
- 自动点击登录卡片右上角切换图标(优先稳定选择器,失败后用几何位置兜底)
|
||||
- 切换后等待二维码渲染再进入提取流程
|
||||
- 扩展小红书二维码选择器集合:
|
||||
- 增加登录卡片内二维码图片选择器(包含当前页面结构)
|
||||
- 保留通用 `img[src*='qr'/'qrcode']` 兜底
|
||||
- 提高小红书候选过滤阈值(`min_side=120`),避免误选右上角切换小图标
|
||||
- 文本策略补充小红书关键词(如 `APP扫一扫登录`)
|
||||
|
||||
### 验证
|
||||
|
||||
- 本地接口冒烟:`POST /api/publish/login/xiaohongshu` 返回 `success=true` 且 `qr_code` 非空
|
||||
- 后端日志确认修复链路生效:
|
||||
- `已点击登录方式切换,等待二维码渲染`
|
||||
- `策略1(CSS): 匹配成功`
|
||||
|
||||
---
|
||||
|
||||
## ✅ 10) 小红书发布上传阶段修复(“发布笔记 - 上传视频”场景)
|
||||
|
||||
### 问题现象
|
||||
|
||||
- 小红书发布在“上传视频”阶段失败,页面停留在发布页,前端提示发布失败
|
||||
- 后端日志显示 `set_input_files` 触发成功,但短时间内未检测到上传状态,导致重复触发上传并误判失败
|
||||
- 进一步定位到上传文件实际是 Supabase 本地对象文件(无后缀),日志里 `file_input type=` 为空,平台可能无法正确识别视频 MIME
|
||||
|
||||
### 修复方案(`xiaohongshu_uploader.py`)
|
||||
|
||||
- 新增上传启动探测窗口 `UPLOAD_SIGNAL_TIMEOUT=12s`:
|
||||
- `set_input_files` 成功后给上传状态留出启动时间
|
||||
- 检测到“上传中/处理中/转码中”等信号即进入后续上传轮询
|
||||
- 启动窗口内未出现明显信号时,不再立即判失败,转入主上传监控阶段继续等待
|
||||
- 修正失败判定词:
|
||||
- 从失败关键词中移除 `重新上传`(该文案在小红书页面常作为正常状态/操作入口,不能直接视为失败)
|
||||
- 增补上传文件诊断日志:
|
||||
- 输出 `file_input` 选中文件名/大小/类型,便于确认文件是否真正注入 input
|
||||
- 上传失败命中时记录明确告警日志,便于线上快速定位
|
||||
- 增加无后缀视频文件兜底:
|
||||
- 若原文件无后缀且父目录名带后缀(如 `xxx.mp4/<uuid>`),自动在 `/tmp/vigent_uploads` 生成同名临时文件(硬链接/软链接/复制兜底)
|
||||
- 上传改用带后缀临时文件,提升站点 MIME 识别稳定性
|
||||
- 任务结束后自动清理临时上传文件
|
||||
|
||||
### 10.1 二次定位与加固(卡住复现后)
|
||||
|
||||
- 复现日志显示:即使传入了带后缀临时路径,`file_input` 中仍出现无后缀文件名,且长时间停留在 `等待上传状态...`
|
||||
- 根因进一步确认:此前在跨设备场景下会走 `symlink` 回退,浏览器实际取到原始目标文件名(无后缀),导致站点识别失败
|
||||
- 加固修复:
|
||||
- 去掉 `symlink` 回退,仅保留 `hardlink -> copy`,确保最终上传文件名稳定带 `.mp4`
|
||||
- 新增 `file_input` 文件名后缀一致性校验:若与预期后缀不一致,直接重试并在最终失败时提前返回(不再无意义长时间等待)
|
||||
- 新增上传空转超时保护(`UPLOAD_IDLE_TIMEOUT=90s`):长时间无有效上传信号时提前失败并保留调试截图,避免前端“看起来卡死”
|
||||
- 优化失败文案为“未能触发有效视频上传,请确认发布页状态及视频文件格式”
|
||||
|
||||
### 10.2 实时发布验证(修复后)
|
||||
|
||||
- 重新发起 `POST /api/publish`(小红书),后端完整走通上传+发布,接口返回 `200`
|
||||
- 本次实测耗时约 `45.77s`,属于上传与发布等待区间内的正常时长
|
||||
- 发布成功截图可访问:`GET /api/publish/screenshot/xiaohongshu_success_20260303_115944_633.png` 返回 `200`
|
||||
- 关键日志链路:`正在上传` -> `已设置上传文件` -> `等待发布结果` -> `Cookie 更新完毕`
|
||||
|
||||
### 验证
|
||||
|
||||
- `python -m py_compile backend/app/services/uploader/xiaohongshu_uploader.py` ✅
|
||||
- `pm2 restart vigent2-backend` ✅
|
||||
- `curl http://127.0.0.1:8006/health` 返回 `{"status":"ok"}` ✅
|
||||
|
||||
---
|
||||
|
||||
## ✅ 11) 首页「AI生成标题标签」按钮位置优化(迁移到四、标题与字幕)
|
||||
|
||||
### 设计结论
|
||||
|
||||
- 将 `AI生成标题标签` 从「一、文案提取与编辑」迁移到「四、标题与字幕」
|
||||
- 标题区改为两行:
|
||||
- 第一行:`四、标题与字幕` 标题 + 右侧 `AI生成标题标签`
|
||||
- 第二行:右对齐放置 `标题短暂显示/常驻显示` + `预览样式`
|
||||
- 显示语义补充:`标题短暂显示/常驻显示` 对主标题与副标题统一生效(常驻=主/副标题都常驻)
|
||||
- 不额外增加提示文案,保持界面简洁
|
||||
- `AI生成标题标签` 外观对齐 `在线录音` 按钮的圆角与尺寸(`rounded-lg` + 同级按钮尺寸),颜色保留原蓝色渐变
|
||||
|
||||
### 结果
|
||||
|
||||
- 标题相关动作集中到同一板块,避免用户在「一」和「四」之间来回跳转
|
||||
- 行内层级更明确:AI 动作在标题同层,配置项与预览在下一行
|
||||
- AI 按钮圆角与尺寸更柔和,配色仍保持原蓝色渐变,视觉更统一
|
||||
|
||||
### 验证
|
||||
|
||||
- `npm run build`(frontend)✅
|
||||
- `pm2 restart vigent2-frontend` ✅
|
||||
|
||||
---
|
||||
|
||||
## ✅ 12) 文案编辑框右下角扩展角标(弹出大编辑器)
|
||||
|
||||
### 设计与实现
|
||||
|
||||
- 在「一、文案提取与编辑」主输入框右下角新增角标按钮(点击后打开扩展编辑器)
|
||||
- 扩展编辑器使用 `AppModal`,提供更大编辑空间(高约 `66vh`)
|
||||
- 主输入框与弹窗内输入框共享同一份 `text` 状态,双向实时同步
|
||||
- 为避免角标遮挡正文,主输入框增加右下内边距(`pr-9 pb-8`)
|
||||
- 角标样式进一步极简化:仅保留双箭头图标,去掉外框容器并贴近输入框边缘
|
||||
- 角标位置微调为更协调的“上移+右移”:`right-0.5 bottom-2`,并固定点击区域 `h-5 w-5`
|
||||
- 修复扩展编辑输入焦点丢失:`AppModal` 改为使用 `onCloseRef` 处理 ESC,避免父组件重渲染时 effect 误清理导致 textarea 失焦
|
||||
- 移除扩展编辑输入框紫色聚焦边框,改为中性边框高亮(`focus:border-white/25`)
|
||||
|
||||
### 验证
|
||||
|
||||
- `npm run build`(frontend)✅
|
||||
- `pm2 restart vigent2-frontend` ✅
|
||||
|
||||
---
|
||||
|
||||
## 📁 今日主要修改文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `backend/app/modules/videos/router.py` | 新增/增强 `voice-preview` GET+POST,试听文本 locale 路由,临时文件清理 |
|
||||
| `backend/app/modules/videos/schemas.py` | 新增 `VoicePreviewRequest` |
|
||||
| `frontend/src/features/home/ui/VoiceSelector.tsx` | 音色下拉增加试听按钮,改为 GET 音频流播放 |
|
||||
| `frontend/src/features/home/model/useHomeController.ts` | 录音状态重置、`discardRecording` |
|
||||
| `frontend/src/features/home/ui/HomePage.tsx` | 透传录音弃用动作;将 `AI生成标题标签` 事件改为传入 `TitleSubtitlePanel` |
|
||||
| `frontend/src/features/home/ui/RefAudioPanel.tsx` | 上传/录音入口重排;录音改弹窗;使用/弃用流程 |
|
||||
| `frontend/src/features/home/ui/ScriptEditor.tsx` | 文案编辑区按钮视觉统一;移除 `AI生成标题标签`(职责回归标题板块);新增输入框右下角扩展角标与大编辑弹窗;角标改为双箭头极简贴边样式并微调到 `right-0.5 bottom-2`;输入框去除紫色聚焦边框 |
|
||||
| `frontend/src/features/home/ui/TitleSubtitlePanel.tsx` | 标题区改为“首行标题+AI、次行右对齐设置+预览”;AI按钮外观对齐在线录音按钮(软圆角) |
|
||||
| `frontend/src/features/home/ui/RefAudioPanel.tsx` | 录音完成试听条改为自定义深色播放器(替换原生白色控制条) |
|
||||
| `frontend/src/features/home/ui/RefAudioPanel.tsx` | 使用录音后弹窗立即关闭,上传识别后台进行(提升交互流畅度) |
|
||||
| `frontend/src/features/publish/model/usePublishController.ts` | 发布改为受限并发(并发度=2),缩短多平台发布总耗时 |
|
||||
| `backend/app/core/config.py` | 新增小红书 Playwright 配置(headless/UA/locale/timezone/chrome/debug) |
|
||||
| `backend/app/services/uploader/xiaohongshu_uploader.py` | 按抖音/微信模式重构;补充上传启动容错窗口、无后缀文件兜底(hardlink/copy)、后缀一致性校验、空转超时保护与上传诊断日志 |
|
||||
| `backend/app/services/publish_service.py` | `save_cookie_string` 非 bilibili 统一存储为 Playwright `storage_state`;小红书 uploader 透传 `user_id` |
|
||||
| `backend/app/services/qr_login_service.py` | 抖音导航超时容错 + 微信二维码提取增强 + 小红书登录自动切换到扫码模式并提取二维码 |
|
||||
| `backend/app/services/uploader/weixin_uploader.py` | `file_input empty` 告警策略优化:先检测上传信号,非最后一次重试降级为 info |
|
||||
| `frontend/src/shared/ui/AppModal.tsx` | 统一弹窗组件 + 无障碍语义 + 焦点管理 + 多弹窗滚动锁计数;新增 `onCloseRef` 避免回调引用变化引发的意外失焦 |
|
||||
| `frontend/src/components/VideoPreviewModal.tsx` | 迁移到 `AppModal` |
|
||||
| `frontend/src/features/home/ui/ScriptExtractionModal.tsx` | 迁移到 `AppModal` |
|
||||
| `frontend/src/features/home/ui/RewriteModal.tsx` | 迁移到 `AppModal` |
|
||||
| `frontend/src/features/home/ui/ClipTrimmer.tsx` | 迁移到 `AppModal` |
|
||||
| `frontend/src/components/AccountSettingsDropdown.tsx` | 修改密码弹窗迁移到 `AppModal` |
|
||||
| `frontend/src/features/publish/ui/PublishPage.tsx` | 扫码登录(QR)弹窗迁移到 `AppModal` + 二维码白底留白容器优化(避免边缘观感被裁) |
|
||||
| `Docs/FRONTEND_DEV.md` | 新增统一弹窗规范(AppModal)和录音交互规范;补充文案扩展编辑也统一走 AppModal |
|
||||
| `Docs/FRONTEND_README.md` | 增补录音入口与弹窗交互说明;明确“标题常驻显示”对主/副标题同时生效;补充文案输入框扩展编辑器说明 |
|
||||
| `Docs/BACKEND_README.md` | 增补 `voice-preview` 接口说明;更新发布 API 路径(`/login/{platform}` 等)并链接发布专项文档;补充 `title_display_mode` 对主/副标题统一生效说明 |
|
||||
| `Docs/BACKEND_DEV.md` | 更新后端规范中的发布器覆盖范围与小红书配置项;补充发布专项文档指引;补充 `title_display_mode` 主/副标题统一生效约定 |
|
||||
| `Docs/PUBLISH_DEPLOY.md` | 新增多平台发布专项文档(登录实现、自动化发布流程、部署要点与排障) |
|
||||
| `Docs/DEPLOY_MANUAL.md` | 部署参数与扫码说明补充小红书要点;新增发布专项文档入口 |
|
||||
| `README.md` | 文档中心新增 `PUBLISH_DEPLOY.md` 入口;发布结果可视化描述补齐小红书 |
|
||||
| `Docs/TASK_COMPLETE.md` | 新增 Day31 任务汇总,更新 Current 标签与更新时间 |
|
||||
| `Docs/DOC_RULES.md` | 增补“发布相关三检”(路由真值/专项文档/入口回写)、敏感信息处理规范,更新工具规范为 `Read/Grep/apply_patch`,并对齐 TASK_COMPLETE 检查清单 |
|
||||
| `Docs/SUBTITLE_DEPLOY.md` | 与当前阈值/参数说明对齐 |
|
||||
| `Docs/LATENTSYNC_DEPLOY.md` | 与当前阈值/参数说明对齐 |
|
||||
| `Docs/COSYVOICE3_DEPLOY.md` | TTS 部署说明与当前运行路径对齐 |
|
||||
| `Docs/QWEN3_TTS_DEPLOY.md` | 标注为历史归档并指向 CosyVoice 3.0 |
|
||||
|
||||
---
|
||||
|
||||
## 🔍 验证记录
|
||||
|
||||
- `python -m py_compile backend/app/modules/videos/router.py backend/app/modules/videos/schemas.py` ✅
|
||||
- `python -m py_compile backend/app/services/qr_login_service.py` ✅
|
||||
- `python -m py_compile backend/app/services/uploader/weixin_uploader.py` ✅
|
||||
- `python -m py_compile backend/app/core/config.py backend/app/services/publish_service.py backend/app/services/uploader/xiaohongshu_uploader.py` ✅
|
||||
- `POST /api/publish/login/xiaohongshu` 冒烟返回 `success=true` + `qr_code` ✅
|
||||
- `python -m py_compile backend/app/services/uploader/xiaohongshu_uploader.py`(上传阶段修复后)✅
|
||||
- `pm2 restart vigent2-backend`(上传阶段修复后)✅
|
||||
- `curl http://127.0.0.1:8006/health` 返回 `{"status":"ok"}` ✅
|
||||
- `backend/venv/bin/python` 本地探针验证 `_prepare_upload_file()`:临时文件非 symlink、后缀 `.mp4`、清理成功 ✅
|
||||
- 小红书发布实测:`POST /api/publish` 返回 `200`(`Duration: 45.77s`)且成功截图接口返回 `200` ✅
|
||||
- 新增 `Docs/PUBLISH_DEPLOY.md`(抖音/微信/B站/小红书登录与发布实现说明)✅
|
||||
- `npm run build`(frontend)✅
|
||||
- `POST /api/publish/login/weixin` 冒烟返回 `success=true` + `qr_code` ✅
|
||||
- `npx eslint` 定向检查以下文件通过:
|
||||
- `VoiceSelector.tsx`
|
||||
- `RefAudioPanel.tsx`
|
||||
- `HomePage.tsx`
|
||||
- `useHomeController.ts`
|
||||
- `AppModal.tsx`
|
||||
- `VideoPreviewModal.tsx`
|
||||
- `ScriptExtractionModal.tsx`
|
||||
- `RewriteModal.tsx`
|
||||
- `AccountSettingsDropdown.tsx`
|
||||
- `ClipTrimmer.tsx` 仍有仓库既有 lint 规则项(`react-hooks/set-state-in-effect`),与本次弹窗风格迁移无关
|
||||
- 音色试听线上问题经后端重启后已恢复可用(浏览器同源携带 cookie)
|
||||
|
||||
---
|
||||
|
||||
## ☑️ Day31 覆盖核对(今日新增补充)
|
||||
|
||||
已对照今天新增改动做二次核对,以下内容已写入本日志:
|
||||
|
||||
- `AppModal` 的可访问性与焦点/滚动锁稳健性增强
|
||||
- 微信视频号二维码“观感不完整”问题的后端提取修复
|
||||
- 发布页二维码展示样式优化(白底留白、去除本体圆角裁切)
|
||||
- 小红书 uploader 对齐重构(启动参数、发布判定、成功截图)
|
||||
- 小红书“上传阶段卡住”二次定位与加固(文件名后缀一致性 + 空转超时)并完成实测发布成功
|
||||
- 形成发布专项文档 `Docs/PUBLISH_DEPLOY.md`,沉淀四平台登录与自动化发布实现
|
||||
- 回写 `Docs/BACKEND_README.md` / `Docs/BACKEND_DEV.md` / `Docs/DEPLOY_MANUAL.md`,统一发布 API 与部署说明口径
|
||||
- 回写 `README.md`,补充发布专项文档入口与小红书发布成功截图能力描述
|
||||
- 回写 `Docs/TASK_COMPLETE.md`,补齐 Day31 任务完成记录
|
||||
- 回写 `Docs/DOC_RULES.md`,同步文档更新规则到当前文档结构与工具链
|
||||
- 首页「AI生成标题标签」按钮迁移到「四、标题与字幕」并固定标题同层最右;显示方式与预览下沉到下一行右侧
|
||||
- 文案输入框右下角新增扩展角标,支持弹出大编辑器进行长文案编辑
|
||||
- 文档补充:`标题短暂显示/常驻显示` 对主标题与副标题统一生效(常驻=主/副标题全程显示)
|
||||
- 非 bilibili 平台 cookie 保存为 `storage_state` 格式
|
||||
- 小红书登录二维码自动切换(短信登录 -> 扫码登录)与提取修复
|
||||
- 对应构建/重启/冒烟验证记录
|
||||
- 今日运行期产物(`backend/user_data/**/cookies/*.json`、`watchdog.log`)为会话副产物,不属于代码/文档变更项
|
||||
@@ -6,13 +6,14 @@
|
||||
|
||||
## ⚡ 核心原则
|
||||
|
||||
| 规则 | 说明 |
|
||||
|------|------|
|
||||
| **默认更新** | 更新 `DayN.md` 和 `TASK_COMPLETE.md` |
|
||||
| **按需更新** | 其他文档仅在内容变化涉及时更新 |
|
||||
| **智能修改** | 错误→替换,改进→追加(见下方详细规则) |
|
||||
| **先读后写** | 更新前先查看文件当前内容 |
|
||||
| **日内合并** | 同一天的多次小修改合并为最终版本 |
|
||||
| 规则 | 说明 |
|
||||
|------|------|
|
||||
| **默认更新** | 更新 `DayN.md` 和 `TASK_COMPLETE.md` |
|
||||
| **按需更新** | 其他文档仅在内容变化涉及时更新 |
|
||||
| **链路对齐** | 新增/重构文档后,回写入口文档(`README.md` 或对应 `*_README.md`) |
|
||||
| **智能修改** | 错误→替换,改进→追加(见下方详细规则) |
|
||||
| **先读后写** | 更新前先查看文件当前内容 |
|
||||
| **日内合并** | 同一天的多次小修改合并为最终版本 |
|
||||
|
||||
---
|
||||
|
||||
@@ -20,17 +21,19 @@
|
||||
|
||||
> **每次提交重要变更时,请核对以下文件是否需要同步:**
|
||||
|
||||
| 优先级 | 文件路径 | 检查重点 |
|
||||
| :---: | :--- | :--- |
|
||||
| 🔥 **High** | `Docs/DevLogs/DayN.md` | **(最新日志)** 详细记录变更、修复、代码片段 |
|
||||
| 🔥 **High** | `Docs/TASK_COMPLETE.md` | **(任务总览)** 更新 `[x]`、进度条、时间线 |
|
||||
| ⚡ **Med** | `README.md` | **(项目主页)** 功能特性、技术栈、最新截图 |
|
||||
| ⚡ **Med** | `Docs/DEPLOY_MANUAL.md` | **(部署手册)** 环境变量、依赖包、启动命令变更 |
|
||||
| ⚡ **Med** | `Docs/BACKEND_DEV.md` | **(后端规范)** 接口契约、模块划分、环境变量 |
|
||||
| ⚡ **Med** | `Docs/BACKEND_README.md` | **(后端文档)** 接口说明、架构设计 |
|
||||
| ⚡ **Med** | `Docs/FRONTEND_DEV.md` | **(前端规范)** API封装、日期格式化、新页面规范 |
|
||||
| ⚡ **Med** | `Docs/FRONTEND_README.md` | **(前端文档)** 功能说明、页面变更 |
|
||||
| 🧊 **Low** | `Docs/*_DEPLOY.md` | **(子系统部署)** LatentSync/CosyVoice/字幕等独立部署文档 |
|
||||
| 优先级 | 文件路径 | 检查重点 |
|
||||
| :---: | :--- | :--- |
|
||||
| 🔥 **High** | `Docs/DevLogs/DayN.md` | **(最新日志)** 详细记录变更、修复、代码片段 |
|
||||
| 🔥 **High** | `Docs/TASK_COMPLETE.md` | **(任务总览)** 更新 Day Current、`[x]` 与更新时间 |
|
||||
| ⚡ **Med** | `README.md` | **(项目主页)** 功能特性、技术栈、最新截图 |
|
||||
| ⚡ **Med** | `Docs/DEPLOY_MANUAL.md` | **(部署手册)** 环境变量、依赖包、启动命令变更 |
|
||||
| ⚡ **Med** | `Docs/PUBLISH_DEPLOY.md` | **(发布专项)** 四平台登录/发布实现、排障、验收流程 |
|
||||
| ⚡ **Med** | `Docs/BACKEND_DEV.md` | **(后端规范)** 接口契约、模块划分、环境变量 |
|
||||
| ⚡ **Med** | `Docs/BACKEND_README.md` | **(后端文档)** 接口说明、架构设计 |
|
||||
| ⚡ **Med** | `Docs/FRONTEND_DEV.md` | **(前端规范)** API封装、日期格式化、新页面规范 |
|
||||
| ⚡ **Med** | `Docs/FRONTEND_README.md` | **(前端文档)** 功能说明、页面变更 |
|
||||
| 🧊 **Low** | `Docs/DOC_RULES.md` | **(规则文档)** 文档结构变化或流程变化时同步更新 |
|
||||
| 🧊 **Low** | `Docs/*_DEPLOY.md` | **(子系统部署)** LatentSync/CosyVoice/字幕等独立部署文档 |
|
||||
|
||||
---
|
||||
|
||||
@@ -89,7 +92,7 @@
|
||||
|
||||
---
|
||||
|
||||
## 🔍 更新前检查清单
|
||||
## 🔍 更新前检查清单
|
||||
|
||||
> **核心原则**:追加前先查找,避免重复和遗漏
|
||||
|
||||
@@ -112,12 +115,20 @@
|
||||
| **有待验证状态** | 更新状态标记 |
|
||||
| **全新独立内容** | 追加到末尾 |
|
||||
|
||||
**3. 必须更新的内容**
|
||||
**3. 必须更新的内容**
|
||||
|
||||
- ✅ **状态标记**:`🔄 待验证` → `✅ 已修复` / `❌ 失败`
|
||||
- ✅ **进度百分比**:更新为最新值
|
||||
- ✅ **文件修改列表**:补充新修改的文件
|
||||
- ❌ **禁止**:创建重复的章节标题
|
||||
- ✅ **文件修改列表**:补充新修改的文件
|
||||
- ❌ **禁止**:创建重复的章节标题
|
||||
|
||||
### 发布相关变更的三检(新增)
|
||||
|
||||
若涉及抖音/微信/B站/小红书发布或扫码登录,额外执行:
|
||||
|
||||
1. **路由真值检查**:以 `backend/app/modules/publish/router.py` 为准校验 API 路径,避免文档写成旧路径(例如 `/screenshots/`)。
|
||||
2. **专项文档对齐**:更新 `Docs/PUBLISH_DEPLOY.md` 中对应平台章节(登录、发布判定、排障)。
|
||||
3. **入口文档回写**:至少回写一处入口文档(`README.md` 或 `Docs/BACKEND_README.md` / `Docs/DEPLOY_MANUAL.md`)。
|
||||
|
||||
### 示例场景
|
||||
|
||||
@@ -138,23 +149,23 @@
|
||||
|
||||
---
|
||||
|
||||
## ️ 工具使用规范
|
||||
## ️ 工具使用规范
|
||||
|
||||
> **核心原则**:使用正确的工具,避免字符编码问题
|
||||
|
||||
### ✅ 推荐工具:Edit / Read / Grep
|
||||
### ✅ 推荐工具:Read / Grep / apply_patch
|
||||
|
||||
**使用场景**:
|
||||
- `Read`:更新前先查看文件当前内容
|
||||
- `Edit`:精确替换现有内容、追加新章节
|
||||
- `Grep`:搜索文件中是否已有相关章节
|
||||
- `Write`:创建新文件(如 Day{N+1}.md)
|
||||
**使用场景**:
|
||||
- `Read`:更新前先查看文件当前内容
|
||||
- `apply_patch`:精确替换现有内容、追加新章节
|
||||
- `Grep`:搜索文件中是否已有相关章节
|
||||
- `Write`:创建新文件(如 Day{N+1}.md)
|
||||
|
||||
**注意事项**:
|
||||
```markdown
|
||||
1. **先读后写**:编辑前先用 Read 确认内容
|
||||
2. **精确匹配**:Edit 的 old_string 必须与文件内容完全一致
|
||||
3. **避免重复**:编辑前用 Grep 检查是否已存在同主题章节
|
||||
1. **先读后写**:编辑前先用 Read 确认内容
|
||||
2. **精确匹配**:`apply_patch` 的上下文必须与文件内容一致
|
||||
3. **避免重复**:编辑前用 Grep 检查是否已存在同主题章节
|
||||
```
|
||||
|
||||
### ❌ 禁止使用:命令行工具修改文档
|
||||
@@ -171,13 +182,14 @@
|
||||
|
||||
### 📝 最佳实践示例
|
||||
|
||||
**追加新章节**:使用 `Edit` 工具,`old_string` 匹配文件末尾内容,`new_string` 包含原内容 + 新章节。
|
||||
|
||||
**修改现有内容**:使用 `Edit` 工具精确替换。
|
||||
```markdown
|
||||
old_string: "**状态**:🔄 待修复"
|
||||
new_string: "**状态**:✅ 已修复"
|
||||
```
|
||||
**追加新章节**:使用 `apply_patch`,以文件末尾稳定上下文为锚点追加。
|
||||
|
||||
**修改现有内容**:使用 `apply_patch` 精确替换。
|
||||
```markdown
|
||||
@@
|
||||
-**状态**:🔄 待修复
|
||||
+**状态**:✅ 已修复
|
||||
```
|
||||
|
||||
|
||||
---
|
||||
@@ -191,11 +203,12 @@ ViGent2/Docs/
|
||||
├── BACKEND_DEV.md # 后端开发规范
|
||||
├── BACKEND_README.md # 后端功能文档
|
||||
├── FRONTEND_DEV.md # 前端开发规范
|
||||
├── FRONTEND_README.md # 前端功能文档
|
||||
├── DEPLOY_MANUAL.md # 部署手册
|
||||
├── SUPABASE_DEPLOY.md # Supabase 部署文档
|
||||
├── LATENTSYNC_DEPLOY.md # LatentSync 部署文档
|
||||
├── COSYVOICE3_DEPLOY.md # 声音克隆部署文档
|
||||
├── FRONTEND_README.md # 前端功能文档
|
||||
├── DEPLOY_MANUAL.md # 部署手册
|
||||
├── PUBLISH_DEPLOY.md # 多平台发布专项文档
|
||||
├── SUPABASE_DEPLOY.md # Supabase 部署文档
|
||||
├── LATENTSYNC_DEPLOY.md # LatentSync 部署文档
|
||||
├── COSYVOICE3_DEPLOY.md # 声音克隆部署文档
|
||||
├── ALIPAY_DEPLOY.md # 支付宝付费部署文档
|
||||
├── SUBTITLE_DEPLOY.md # 字幕系统部署文档
|
||||
└── DevLogs/
|
||||
@@ -254,16 +267,21 @@ ViGent2/Docs/
|
||||
|
||||
---
|
||||
|
||||
## 📏 内容简洁性规则
|
||||
## 📏 内容简洁性规则
|
||||
|
||||
### 代码示例长度控制
|
||||
- **原则**:只展示关键代码片段(10-20行以内)
|
||||
- **超长代码**:使用 `// ... 省略 ...` 或仅列出文件名+行号
|
||||
- **完整代码**:引用文件链接,而非粘贴全文
|
||||
|
||||
### 调试信息处理
|
||||
- **临时调试**:验证后删除(如调试日志、测试截图)
|
||||
- **有价值信息**:保留(如错误日志、性能数据)
|
||||
### 调试信息处理
|
||||
- **临时调试**:验证后删除(如调试日志、测试截图)
|
||||
- **有价值信息**:保留(如错误日志、性能数据)
|
||||
|
||||
### 敏感信息处理
|
||||
- **禁止落盘**:Cookie 值、Token、密钥、完整手机号、支付凭证。
|
||||
- **日志引用**:仅记录必要关键词与结论,避免粘贴大段原始日志。
|
||||
- **路径引用**:优先给相对路径与文件名,不记录无关个人目录信息。
|
||||
|
||||
### 状态标记更新
|
||||
- **🔄 待验证** → 验证后更新为 **✅ 已修复** 或 **❌ 失败**
|
||||
@@ -280,29 +298,29 @@ ViGent2/Docs/
|
||||
- **格式一致性**:直接参考 `TASK_COMPLETE.md` 现有格式追加内容。
|
||||
- **进度更新**:仅在阶段性里程碑时更新进度百分比。
|
||||
|
||||
### 🔍 完整性检查清单 (必做)
|
||||
|
||||
每次更新 `TASK_COMPLETE.md` 时,必须**逐一检查**以下所有板块:
|
||||
|
||||
1. **文件头部 & 导航**
|
||||
- [ ] `更新时间`:必须是当天日期
|
||||
- [ ] `整体进度`:简述当前状态
|
||||
- [ ] `快速导航`:Day 范围与文档一致
|
||||
|
||||
2. **核心任务区**
|
||||
- [ ] `已完成任务`:添加新的 [x] 项目
|
||||
- [ ] `后续规划`:管理三色板块 (优先/债务/未来)
|
||||
|
||||
3. **统计与回顾**
|
||||
- [ ] `进度统计`:更新对应模块状态和百分比
|
||||
- [ ] `里程碑`:若有重大进展,追加 `## Milestone N`
|
||||
|
||||
4. **底部链接**
|
||||
- [ ] `时间线`:追加今日概括
|
||||
- [ ] `相关文档`:更新 DayLog 链接范围
|
||||
|
||||
> **口诀**:头尾时间要对齐,任务规划两手抓,里程碑上别落下。
|
||||
### 🔍 完整性检查清单 (必做)
|
||||
|
||||
每次更新 `TASK_COMPLETE.md` 时,必须**逐一检查**以下板块:
|
||||
|
||||
1. **文件头部**
|
||||
- [ ] `更新时间`:必须是当天日期
|
||||
- [ ] `整体进度`:与当前 Day 状态一致(例如 Day31)
|
||||
|
||||
2. **当日 Current 区块**
|
||||
- [ ] 新增/更新 `Day N (Current)` 标题
|
||||
- [ ] 关键任务以 `[x]` 列出(避免仅写结论)
|
||||
- [ ] 前一天 Day 标题取消 `(Current)` 标记
|
||||
|
||||
3. **Roadmap 与模块状态**
|
||||
- [ ] 如有已完成长期事项,及时从待办迁移到已完成
|
||||
- [ ] 模块完成度有变化时同步更新
|
||||
|
||||
4. **相关文档链接**
|
||||
- [ ] 新增的核心文档(如 `PUBLISH_DEPLOY.md`)要在相关位置可追溯
|
||||
- [ ] 若 DayN 记录了“文档回写”,`TASK_COMPLETE.md` 的当日条目也要体现
|
||||
|
||||
> **口诀**:头部日期、当日 Current、模块状态、链接可追溯。
|
||||
|
||||
---
|
||||
|
||||
**最后更新**:2026-02-11
|
||||
**最后更新**:2026-03-03
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 前端开发规范
|
||||
|
||||
## 文档定位
|
||||
|
||||
- 本文档只定义前端开发规范与约束(结构、交互、持久化、接口调用、Checklist)。
|
||||
- 功能说明与启动方式请查看 `Docs/FRONTEND_README.md`。
|
||||
- 历史变更请记录在 `Docs/DevLogs/` 与 `Docs/TASK_COMPLETE.md`,不要写入本规范文档。
|
||||
|
||||
## 目录结构
|
||||
|
||||
采用轻量 FSD(Feature-Sliced Design)结构:
|
||||
@@ -62,6 +68,8 @@ frontend/src/
|
||||
│ ├── hooks/
|
||||
│ │ ├── useTitleInput.ts
|
||||
│ │ └── usePublishPrefetch.ts
|
||||
│ ├── ui/
|
||||
│ │ └── SelectPopover.tsx # 统一下拉/BottomSheet 选择器
|
||||
│ ├── types/
|
||||
│ │ ├── user.ts # User 类型定义
|
||||
│ │ └── publish.ts # 发布相关类型
|
||||
@@ -180,6 +188,41 @@ body {
|
||||
|
||||
---
|
||||
|
||||
## 统一下拉选择器规范 (SelectPopover)
|
||||
|
||||
首页/发布页的业务选择项(音色、参考音频、配音、素材、BGM、作品、样式、模型、画面比例)统一使用 `@/shared/ui/SelectPopover`:
|
||||
|
||||
- 桌面端使用 Popover,移动端自动切换 BottomSheet
|
||||
- 触发器与面板风格统一:`border-white/10 + bg-black/25`(或同级变体)
|
||||
- 下拉项选中态统一:`border-purple-500 bg-purple-500/20`
|
||||
- 选中项需添加 `data-popover-selected="true"`,确保再次打开时自动滚动定位到已选项
|
||||
- 底部空间不足时自动上拉;滚动条隐藏但保留滚动能力
|
||||
|
||||
### 视频预览与下拉层级
|
||||
|
||||
- 下拉菜单层级应低于视频预览弹窗,避免遮挡预览内容
|
||||
- 在下拉内点击“预览”时,不强制关闭下拉(便于连续预览)
|
||||
- 关闭预览后,用户可继续在下拉内操作;点击外部时下拉正常收起
|
||||
|
||||
### 例外说明
|
||||
|
||||
- `ScriptEditor` 的“历史文案 / AI多语言”保持原有轻量菜单样式,不强制迁移到 `SelectPopover`
|
||||
|
||||
---
|
||||
|
||||
## 统一弹窗规范 (AppModal)
|
||||
|
||||
所有居中弹窗(如视频预览、文案提取、AI 改写、文案扩展编辑、录音、密码修改)统一使用 `@/shared/ui/AppModal` + `AppModalHeader`:
|
||||
|
||||
- 统一遮罩与层级:`fixed inset-0` + `bg-black/80` + `backdrop-blur-sm` + 明确 `z-index`
|
||||
- 统一挂载位置:通过 Portal 挂载到 `document.body`,避免局部容器/层叠上下文影响,确保是全页面弹窗
|
||||
- 统一容器风格:`border-white/10`、深色半透明背景、圆角 `rounded-2xl`、重阴影
|
||||
- 统一关闭行为:支持 `ESC`;是否允许点击遮罩关闭通过 `closeOnOverlay` 显式配置
|
||||
- 统一滚动策略:弹窗打开时锁定背景滚动(`lockBodyScroll`),内容区自行滚动
|
||||
- 特殊层级场景(例如视频预览压过下拉)使用更高 `z-index`(如 `z-[320]`)
|
||||
|
||||
---
|
||||
|
||||
## API 请求规范
|
||||
|
||||
### 必须使用 `api` (axios 实例)
|
||||
@@ -346,6 +389,7 @@ useEffect(() => {
|
||||
- `shared/api`:Axios 实例与统一响应类型
|
||||
- `shared/lib`:通用工具函数(media.ts / auth.ts / title.ts)
|
||||
- `shared/hooks`:跨功能通用 hooks
|
||||
- `shared/ui`:跨功能通用 UI(如 SelectPopover)
|
||||
- `shared/types`:跨功能实体类型(User / PublishVideo 等)
|
||||
- `shared/contexts`:全局 Context(AuthContext / TaskContext)
|
||||
- `components/`:遗留通用组件(VideoPreviewModal)
|
||||
@@ -366,11 +410,14 @@ useEffect(() => {
|
||||
- 标题样式 ID / 字幕样式 ID
|
||||
- 标题字号 / 字幕字号
|
||||
- 标题显示模式(`short` / `persistent`)
|
||||
- 背景音乐选择 / 音量 / 开关状态
|
||||
- 唇形模型模式(`default` / `fast` / `advanced`)
|
||||
- 背景音乐选择 / 开关状态(当前前端不提供音量滑杆,生成时使用固定音量)
|
||||
- 输出画面比例(`9:16` / `16:9`)
|
||||
- 素材选择 / 历史作品选择
|
||||
- 选中配音 ID (`selectedAudioId`)
|
||||
- 选中参考音频 ID (`selectedRefAudio` 对应 id)
|
||||
- 语速 (`speed`,声音克隆模式)
|
||||
- 语气 (`emotion`,声音克隆模式)
|
||||
- 时间轴段信息 (`useTimelineEditor` 的 localStorage)
|
||||
|
||||
### 历史文案(独立持久化)
|
||||
@@ -406,6 +453,7 @@ useEffect(() => {
|
||||
- 发布按钮在未选择任何平台时禁用
|
||||
- 仅保留"立即发布",不再提供定时发布 UI/参数
|
||||
- **作品选择持久化**:使用 `video.id`(稳定标识)而非 `video.path`(签名 URL)进行选择、比较和 localStorage 存储。发布时根据 `id` 查找对应 `path` 发送请求。
|
||||
- **新作品优先级**:检测到“刚生成的新视频”时,页面首次恢复优先选中最新视频;之后用户手动改选会继续按持久化值恢复。
|
||||
|
||||
---
|
||||
|
||||
@@ -457,6 +505,10 @@ await api.post('/api/videos/generate', {
|
||||
|
||||
使用 `MediaRecorder` API 录制音频,格式为 `audio/webm`,上传后后端自动转换为 WAV (16kHz mono)。
|
||||
|
||||
- 录音入口放在“我的参考音频”区域底部右侧(与“上传音频”并排)。
|
||||
- 录音交互使用弹窗:开始/停止 -> 试听 -> 使用此录音 / 弃用本次录音。
|
||||
- 关闭录音弹窗时如仍在录制,会先停止录音再关闭。
|
||||
|
||||
```typescript
|
||||
// 录音需要用户授权麦克风
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
@@ -472,5 +524,5 @@ const mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
|
||||
### UI 结构
|
||||
|
||||
配音方式使用 Tab 切换:
|
||||
- **EdgeTTS 音色** - 预设音色 2x3 网格
|
||||
- **声音克隆** - 参考音频列表 + 在线录音 + 语速下拉菜单 (5 档: 较慢/稍慢/正常/稍快/较快)
|
||||
- **EdgeTTS 音色** - 统一下拉选择(显示“音色名 + 语言”)
|
||||
- **声音克隆** - 参考音频选择器(含试听/重命名/删除/重识别)+ 底部右侧上传/录音入口(录音弹窗)+ 语速/语气下拉
|
||||
|
||||
@@ -2,45 +2,54 @@
|
||||
|
||||
ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
|
||||
|
||||
## 📌 文档定位
|
||||
|
||||
- 本文档用于说明前端功能、运行方式与目录概览(面向使用与协作)。
|
||||
- 开发规范与实现约束请查看 `Docs/FRONTEND_DEV.md`。
|
||||
- 历史变更与里程碑请查看 `Docs/DevLogs/` 与 `Docs/TASK_COMPLETE.md`。
|
||||
|
||||
## ✨ 核心功能
|
||||
|
||||
### 1. 视频生成 (`/`)
|
||||
- **一、文案提取与编辑**: 文案输入/提取/翻译/保存。
|
||||
- **一、文案提取与编辑**: 文案输入/提取/翻译/保存;输入框右下角支持一键扩展到大编辑器。
|
||||
- **二、配音**: 配音方式(EdgeTTS/声音克隆)+ 配音列表(生成/试听/管理)合并为一个板块。
|
||||
- **三、素材编辑**: 视频素材(上传/选择/管理)+ 时间轴编辑(波形/色块/拖拽排序)合并为一个板块。
|
||||
- **四、标题与字幕**: 片头标题/副标题/字幕样式配置;短暂显示/常驻显示;样式预览使用视频片头帧作为真实背景 (Day 28)。
|
||||
- **五、背景音乐**: 试听 + 音量控制 + 选择持久化。
|
||||
- **四、标题与字幕**: 片头标题/副标题/字幕样式配置;短暂显示/常驻显示;样式预览使用视频片头帧作为真实背景。
|
||||
- **五、背景音乐**: 试听 + 搜索选择 + 选择持久化(无音量滑杆,生成时固定混音系数)。
|
||||
- **六、作品**(右栏): 作品列表 + 作品预览合并为一个板块。
|
||||
- **进度追踪**: 实时显示视频生成进度 (10% -> 100%)。
|
||||
- **作品预览**: 生成完成后直接播放下载(作品预览 + 历史作品)。
|
||||
- **预览优化**: 预览视频 `metadata` 预取,首帧加载更快。
|
||||
- **本地保存**: 文案/标题/偏好由 `useHomePersistence` 统一持久化,刷新后恢复 (Day 14/17)。
|
||||
- **历史文案**: 手动保存/加载/删除历史文案,独立 localStorage 持久化 (Day 23)。
|
||||
- **选择持久化**: 首页/发布页作品选择均使用稳定 `id` 持久化,刷新保持用户选择;新视频生成后自动选中最新 (Day 21)。
|
||||
- **AI 多语言翻译**: 支持 9 种目标语言翻译文案 + 还原原文 (Day 22)。
|
||||
- **本地保存**: 文案/标题/偏好由 `useHomePersistence` 统一持久化,刷新后恢复。
|
||||
- **历史文案**: 手动保存/加载/删除历史文案,独立 localStorage 持久化。
|
||||
- **选择持久化**: 首页/发布页作品选择均使用稳定 `id` 持久化;新视频生成后优先选中最新,后续用户手动选择持续持久化恢复。
|
||||
- **统一下拉交互**: 首页/发布页业务选择器统一为 SelectPopover(支持自动上拉、已选定位、移动端 BottomSheet);`ScriptEditor` 的“历史文案 / AI多语言”为产品例外,保留原轻量菜单。
|
||||
- **AI 多语言翻译**: 支持 9 种目标语言翻译文案 + 还原原文。
|
||||
|
||||
### 2. 全自动发布 (`/publish`) [Day 7 新增]
|
||||
### 2. 全自动发布 (`/publish`)
|
||||
- **多平台管理**: 统一管理抖音、微信视频号、B站、小红书账号状态。
|
||||
- **扫码登录**:
|
||||
- 集成后端 Playwright 生成的 QR Code。
|
||||
- 实时检测扫码状态 (Wait/Success)。
|
||||
- Cookie 自动保存与状态同步。
|
||||
- **发布配置**: 设置视频标题、标签、简介。
|
||||
- **作品选择**: 卡片列表 + 搜索 + 预览弹窗。
|
||||
- **选择持久化**: 使用稳定 `video.id` 持久化选择,刷新保持;新视频生成自动选中最新 (Day 21)。
|
||||
- **作品选择**: SelectPopover 下拉 + 搜索 + 预览弹窗(下拉内可连续预览,不强制收起)。
|
||||
- **选择持久化**: 使用稳定 `video.id` 持久化选择,刷新保持;新视频生成自动选中最新。
|
||||
- **预览兼容**: 签名 URL / 相对路径均可直接预览。
|
||||
- **发布方式**: 仅支持 "立即发布"。
|
||||
|
||||
### 3. 声音克隆 [Day 13 新增]
|
||||
- **TTS 模式选择**: EdgeTTS (预设音色) / 声音克隆 (自定义音色) 切换。
|
||||
### 3. 声音克隆
|
||||
- **TTS 模式选择**: EdgeTTS / 声音克隆切换,音色选择统一下拉(显示音色名 + 语言)。
|
||||
- **音色试听**: EdgeTTS 音色列表支持一键试听,按音色 locale 自动选择对应语言的固定示例文案。
|
||||
- **参考音频管理**: 上传/列表/重命名/删除参考音频,上传后自动 Whisper 转写 ref_text + 超 10s 自动截取。
|
||||
- **录音入口**: 参考音频区域底部右侧提供“上传音频 / 录音”入口;录音采用弹窗流程(录制 -> 试听 -> 使用/弃用)。
|
||||
- **重新识别**: 旧参考音频可重新转写并截取 (RotateCw 按钮)。
|
||||
- **一键克隆**: 选择参考音频后自动调用 CosyVoice 3.0 服务。
|
||||
- **语速控制**: 声音克隆模式下支持 5 档语速 (0.8-1.2),选择持久化 (Day 23)。
|
||||
- **语气控制**: 声音克隆模式下支持 4 种语气 (正常/欢快/低沉/严肃),基于 CosyVoice3 `inference_instruct2`,选择持久化 (Day 29)。
|
||||
- **多语言支持**: EdgeTTS 10 语言声音列表,声音克隆 language 透传 (Day 22)。
|
||||
- **语速控制**: 声音克隆模式下支持 5 档语速 (0.8-1.2),统一下拉,选择持久化。
|
||||
- **语气控制**: 声音克隆模式下支持 4 种语气 (正常/欢快/低沉/严肃),统一下拉,选择持久化。
|
||||
- **多语言支持**: EdgeTTS 10 语言声音列表,声音克隆 language 透传。
|
||||
|
||||
### 4. 配音前置 + 时间轴编排 [Day 23 新增]
|
||||
### 4. 配音前置 + 时间轴编排
|
||||
- **配音独立生成**: 先生成配音 → 选中配音 → 再选素材 → 生成视频。
|
||||
- **配音管理面板**: 生成/试听/改名/删除/选中,异步生成 + 进度轮询。
|
||||
- **时间轴编辑器**: wavesurfer.js 音频波形 + 色块可视化素材分配,拖拽分割线调整各段时长。
|
||||
@@ -50,21 +59,22 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
|
||||
- **时间轴语义对齐**: 超出音频时仅保留可见段并截齐末段,超出段不参与生成;不足音频时最后可见段自动循环补齐。
|
||||
- **画面比例控制**: 时间轴顶部支持 `9:16 / 16:9` 输出比例选择,设置持久化并透传后端。
|
||||
|
||||
### 5. 字幕与标题 [Day 13 新增]
|
||||
- **片头标题**: 可选输入,限制 15 字;支持”短暂显示 / 常驻显示”,默认短暂显示(4 秒),对标题和副标题同时生效。
|
||||
- **片头副标题**: 可选输入,限制 20 字;显示在主标题下方,用于补充说明或悬念引导;独立样式配置(字体/字号/颜色/间距),可由 AI 同时生成;与标题共享显示模式设定;仅在视频画面中显示,不参与发布标题 (Day 25)。
|
||||
### 5. 字幕与标题
|
||||
- **片头标题**: 可选输入,限制 15 字;支持”短暂显示 / 常驻显示”,默认短暂显示(4 秒);`常驻显示` 时主标题与副标题都会全程显示。
|
||||
- **片头副标题**: 可选输入,限制 20 字;显示在主标题下方,用于补充说明或悬念引导;独立样式配置(字体/字号/颜色/间距),可由 AI 同时生成;与标题共享显示模式设定;仅在视频画面中显示,不参与发布标题。
|
||||
- **标题同步**: 首页片头标题修改会同步到发布信息标题。
|
||||
- **逐字高亮字幕**: 卡拉OK效果,默认开启,可关闭。
|
||||
- **逐字高亮字幕**: 卡拉OK效果,默认开启。
|
||||
- **自动对齐**: 基于 faster-whisper 生成字级别时间戳。
|
||||
- **样式预设**: 标题/字幕/副标题样式选择 + 预览 + 字号调节 (Day 16/25)。
|
||||
- **默认样式**: 标题 90px 站酷快乐体;字幕 60px 经典黄字 + DingTalkJinBuTi (Day 17)。
|
||||
- **样式持久化**: 标题/字幕/副标题样式与字号刷新保留 (Day 17/25)。
|
||||
- **样式预设**: 标题/字幕/副标题样式选择 + 预览 + 字号调节。
|
||||
- **默认样式**: 标题 90px 站酷快乐体;字幕 60px 经典黄字 + DingTalkJinBuTi。
|
||||
- **样式持久化**: 标题/字幕/副标题样式与字号刷新保留。
|
||||
|
||||
### 6. 背景音乐 [Day 16 新增]
|
||||
- **试听预览**: 点击试听即选中,音量滑块实时生效。
|
||||
- **混音控制**: 仅影响 BGM,配音保持原音量。
|
||||
### 6. 背景音乐
|
||||
- **试听预览**: 下拉列表内可直接试听。
|
||||
- **选择体验**: 发布页同款搜索选择器,打开时自动定位到当前已选。
|
||||
- **混音控制**: 当前前端不提供音量滑杆,生成时固定 `bgm_volume=0.2`,保持配音音量稳定。
|
||||
|
||||
### 7. 账户设置 [Day 15 新增]
|
||||
### 7. 账户设置
|
||||
- **手机号登录**: 11位中国手机号验证登录。
|
||||
- **账户下拉菜单**: 显示手机号(中间四位脱敏)+ 有效期 + 修改密码 + 安全退出。
|
||||
- **修改密码**: 弹窗输入当前密码与新密码,修改后强制重新登录。
|
||||
@@ -76,10 +86,10 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
|
||||
- **到期续费**: 会员到期后登录自动跳转付费页续费,流程与首次开通一致。
|
||||
- **管理员激活**: 管理员手动激活功能并存,两种方式互不影响。
|
||||
|
||||
### 8. 文案提取助手 (`ScriptExtractionModal`) [Day 15 新增]
|
||||
### 9. 文案提取助手 (`ScriptExtractionModal`)
|
||||
- **多源提取**: 支持文件拖拽上传与 URL 粘贴 (B站/抖音/TikTok)。
|
||||
- **AI 智能改写**: 集成 GLM-4.7-Flash,自动改写为口播文案。
|
||||
- **自定义提示词**: 可自定义改写提示词,留空使用默认;设置持久化到 localStorage (Day 25)。
|
||||
- **自定义提示词**: 可自定义改写提示词,留空使用默认;设置持久化到 localStorage。
|
||||
- **一键填入**: 提取结果直接填充至视频生成输入框。
|
||||
- **智能交互**: 实时进度展示,防误触设计。
|
||||
|
||||
@@ -92,7 +102,7 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
|
||||
- **音频波形**: wavesurfer.js (时间轴编辑器)
|
||||
- **API**: Axios 实例 `@/shared/api/axios` (对接后端 FastAPI :8006)
|
||||
|
||||
## 🚀 开发指南
|
||||
## 🚀 快速开始
|
||||
|
||||
### 安装依赖
|
||||
|
||||
@@ -140,11 +150,10 @@ src/
|
||||
- **URL 统一工具**: `@/shared/lib/media` 提供 `resolveMediaUrl` / `resolveAssetUrl`
|
||||
- **代理配置**: Next.js Rewrites (如需) 或直接 CORS。
|
||||
|
||||
## 🎨 设计规范
|
||||
## 🎨 UI 说明(概览)
|
||||
|
||||
- **主色调**: 深紫/黑色系 (Dark Mode)
|
||||
- **交互**: 悬停微动画 (Hover Effects);操作按钮默认半透明可见 (opacity-40),hover 时全亮,兼顾触屏设备
|
||||
- **响应式**: 适配桌面端与移动端;发布页平台卡片响应式布局(移动端紧凑/桌面端宽松)
|
||||
- **滚动体验**: 列表滚动条统一隐藏 (hide-scrollbar);刷新后自动回到顶部(禁用浏览器滚动恢复 + 列表 scroll 时间门控)
|
||||
- **样式预览**: 浮动预览窗口,桌面端左上角 280px,移动端右下角 160px(不遮挡控件)
|
||||
- **输入辅助**: 标题/副标题输入框实时字数计数器,超限变红
|
||||
- 业务选择器统一使用 `SelectPopover`(桌面 Popover / 移动端 BottomSheet);`ScriptEditor` 的“历史文案 / AI多语言”保留原轻量菜单。
|
||||
- 业务弹窗统一使用 `AppModal`(统一遮罩、头部、关闭行为与滚动策略)。
|
||||
- 视频预览弹窗层级高于下拉菜单;下拉内支持连续预览。
|
||||
- 页面同时适配桌面端与移动端;长列表统一隐藏滚动条。
|
||||
- 详细 UI 规范、持久化规范与交互约束请查看 `Docs/FRONTEND_DEV.md`。
|
||||
|
||||
@@ -137,11 +137,9 @@ CUDA_VISIBLE_DEVICES=1 python -m scripts.inference \
|
||||
└── DEPLOY.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 步骤 7: 性能优化 (预加载模型服务)
|
||||
---
|
||||
|
||||
## 步骤 6: 性能优化(预加载模型服务)
|
||||
|
||||
为了消除每次生成视频时 30-40秒 的模型加载时间,建议运行常驻服务。
|
||||
|
||||
@@ -201,6 +199,29 @@ LatentSync 1.6 需要 ~18GB VRAM。如果遇到 OOM 错误:
|
||||
- `inference_steps`: 增加到 30-50 可提高质量
|
||||
- `guidance_scale`: 增加可改善唇同步,但过高可能导致抖动
|
||||
|
||||
### 编码流水线优化(当前实现)
|
||||
|
||||
LatentSync 内部默认流程有两处冗余编码已优化:
|
||||
|
||||
1. **`read_video` FPS 转换**: 原代码无条件 `ffmpeg -r 25 -crf 18`,现已改为检测 FPS,25fps 时跳过(我们的 `prepare_segment` 已输出 25fps)
|
||||
2. **final mux 双重编码**: 原代码 `imageio` CRF 13 写帧后又用 `libx264 -crf 18` 重编码做 mux,现已改为 `-c:v copy` 流复制
|
||||
|
||||
这两项优化位于:
|
||||
- `latentsync/utils/util.py` — `read_video()` 函数
|
||||
- `latentsync/pipelines/lipsync_pipeline.py` — final mux 命令
|
||||
|
||||
---
|
||||
|
||||
### 无脸帧容错(当前实现)
|
||||
|
||||
素材中部分帧检测不到人脸(转头、遮挡、空镜头)时,不再中断整次推理:
|
||||
|
||||
- `affine_transform_video`: 单帧异常时用最近有效帧填充,全部帧无脸时仍报错
|
||||
- `restore_video`: 无脸帧保留原画面,不做嘴型替换
|
||||
- 后端 `workflow.py`: LatentSync 整体异常时自动回退原视频,任务不会失败
|
||||
|
||||
改动位于 `latentsync/pipelines/lipsync_pipeline.py`。
|
||||
|
||||
---
|
||||
|
||||
## 参考链接
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# MuseTalk 部署指南
|
||||
|
||||
> **更新时间**:2026-02-27
|
||||
> **更新时间**:2026-03-02
|
||||
> **适用版本**:MuseTalk v1.5 (常驻服务模式)
|
||||
> **架构**:FastAPI 常驻服务 + PM2 进程管理
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
|
||||
MuseTalk 作为 **混合唇形同步方案** 的长视频引擎:
|
||||
|
||||
- **短视频 (<120s)** → LatentSync 1.6 (GPU1, 端口 8007)
|
||||
- **长视频 (>=120s)** → MuseTalk 1.5 (GPU0, 端口 8011)
|
||||
- **短视频 (<100s,按当前 `.env` 示例)** → LatentSync 1.6 (GPU1, 端口 8007)
|
||||
- **长视频 (>=100s,按当前 `.env` 示例)** → MuseTalk 1.5 (GPU0, 端口 8011)
|
||||
- 路由阈值由 `LIPSYNC_DURATION_THRESHOLD` 控制
|
||||
- MuseTalk 不可用时自动回退到 LatentSync
|
||||
|
||||
@@ -173,17 +173,36 @@ curl http://localhost:8011/health
|
||||
`backend/.env` 中的相关变量:
|
||||
|
||||
```ini
|
||||
# MuseTalk 配置
|
||||
# MuseTalk 基础配置
|
||||
MUSETALK_GPU_ID=0 # GPU 编号 (与 CosyVoice 共存)
|
||||
MUSETALK_API_URL=http://localhost:8011 # 常驻服务地址
|
||||
MUSETALK_BATCH_SIZE=32 # 推理批大小
|
||||
MUSETALK_VERSION=v15 # 模型版本
|
||||
MUSETALK_USE_FLOAT16=true # 半精度加速
|
||||
|
||||
# 推理质量参数
|
||||
MUSETALK_DETECT_EVERY=2 # 人脸检测降频间隔 (帧,越小越准但更慢)
|
||||
MUSETALK_BLEND_CACHE_EVERY=2 # BiSeNet mask 缓存更新间隔 (帧)
|
||||
MUSETALK_AUDIO_PADDING_LEFT=2 # Whisper 时序上下文 (左)
|
||||
MUSETALK_AUDIO_PADDING_RIGHT=2 # Whisper 时序上下文 (右)
|
||||
MUSETALK_EXTRA_MARGIN=14 # v1.5 下巴区域扩展像素
|
||||
MUSETALK_DELAY_FRAME=0 # 音频-口型对齐偏移 (帧)
|
||||
MUSETALK_BLEND_MODE=jaw # 融合模式: auto / jaw / raw
|
||||
MUSETALK_FACEPARSING_LEFT_CHEEK_WIDTH=90 # 面颊宽度 (仅 v1.5)
|
||||
MUSETALK_FACEPARSING_RIGHT_CHEEK_WIDTH=90
|
||||
|
||||
# 编码质量参数
|
||||
MUSETALK_ENCODE_CRF=14 # CRF 越小越清晰 (14≈接近视觉无损)
|
||||
MUSETALK_ENCODE_PRESET=slow # x264 preset (slow=高压缩效率)
|
||||
|
||||
# 混合唇形同步路由
|
||||
LIPSYNC_DURATION_THRESHOLD=120 # 秒, >=此值用 MuseTalk
|
||||
LIPSYNC_DURATION_THRESHOLD=100 # 秒, >=此值用 MuseTalk
|
||||
```
|
||||
|
||||
> **参数档位参考**:
|
||||
> - 速度优先:`DETECT_EVERY=5, BLEND_CACHE_EVERY=5, ENCODE_CRF=18, ENCODE_PRESET=medium`
|
||||
> - 质量优先(当前):`DETECT_EVERY=2, BLEND_CACHE_EVERY=2, ENCODE_CRF=14, ENCODE_PRESET=slow`
|
||||
|
||||
---
|
||||
|
||||
## 相关文件
|
||||
@@ -207,22 +226,36 @@ LIPSYNC_DURATION_THRESHOLD=120 # 秒, >=此值用 MuseTalk
|
||||
|--------|------|
|
||||
| `MUSETALK_BATCH_SIZE` 8→32 | RTX 3090 显存充裕,UNet 推理加速 ~3x |
|
||||
| cv2.VideoCapture 直读帧 | 跳过 ffmpeg→PNG→imread 链路 |
|
||||
| 人脸检测降频 (每5帧) | DWPose + FaceAlignment 只在采样帧运行,中间帧线性插值 bbox |
|
||||
| BiSeNet mask 缓存 (每5帧) | `get_image_prepare_material` 每 5 帧运行,中间帧用 `get_image_blending` 复用 |
|
||||
| cv2.VideoWriter 直写 | 跳过逐帧 PNG 写盘 + ffmpeg 重编码 |
|
||||
| 人脸检测降频 (每N帧) | DWPose + FaceAlignment 只在采样帧运行,中间帧线性插值 bbox |
|
||||
| BiSeNet mask 缓存 (每N帧) | `get_image_prepare_material` 每 N 帧运行,中间帧复用 |
|
||||
| FFmpeg rawvideo 管道直编码 | 原 `cv2.VideoWriter(mp4v)` 中间有损文件改为 stdin 管道直写,消除一次冗余有损编码 |
|
||||
| 参数环境变量化 | 所有推理/编码参数从 `.env` 读取,支持速度优先/质量优先快速切换 |
|
||||
| 每阶段计时 | 7 个阶段精确计时,方便后续调优 |
|
||||
|
||||
### 编码链路
|
||||
|
||||
```
|
||||
UNet 推理帧 (raw BGR24)
|
||||
→ FFmpeg rawvideo stdin 管道
|
||||
→ 一次 libx264 编码 (CRF 14, preset slow) + 音频 mux
|
||||
→ 最终输出 .mp4
|
||||
```
|
||||
|
||||
与旧流程对比:消除了 `cv2.VideoWriter(mp4v)` 中间有损文件,编码次数从 2 次减至 1 次。
|
||||
|
||||
### 调优参数
|
||||
|
||||
`models/MuseTalk/scripts/server.py` 顶部可调:
|
||||
所有参数通过 `backend/.env` 配置(修改后需重启 MuseTalk 服务生效):
|
||||
|
||||
```python
|
||||
DETECT_EVERY = 5 # 人脸检测降频间隔 (帧)
|
||||
BLEND_CACHE_EVERY = 5 # BiSeNet mask 缓存间隔 (帧)
|
||||
```ini
|
||||
MUSETALK_DETECT_EVERY=2 # 人脸检测降频间隔 (帧),质量优先用 2,速度优先用 5
|
||||
MUSETALK_BLEND_CACHE_EVERY=2 # BiSeNet mask 缓存间隔 (帧)
|
||||
MUSETALK_ENCODE_CRF=14 # 编码质量 (14≈接近视觉无损,18=高质量)
|
||||
MUSETALK_ENCODE_PRESET=slow # 编码速度 (slow=高压缩效率,medium=平衡)
|
||||
```
|
||||
|
||||
> 对于口播视频 (人脸几乎不动),5 帧间隔的插值误差可忽略。
|
||||
> 如人脸运动剧烈的场景,可降低为 2-3。
|
||||
> 对于口播视频 (人脸几乎不动),detect_every=5 的插值误差可忽略。
|
||||
> 如人脸运动剧烈或追求最佳质量,使用 detect_every=2。
|
||||
|
||||
---
|
||||
|
||||
|
||||
206
Docs/PUBLISH_DEPLOY.md
Normal file
206
Docs/PUBLISH_DEPLOY.md
Normal file
@@ -0,0 +1,206 @@
|
||||
# 多平台发布部署与实现说明(抖音 / 微信视频号 / B站 / 小红书)
|
||||
|
||||
## 1. 目标
|
||||
|
||||
本文件用于集中说明以下内容:
|
||||
|
||||
- 平台登录(扫码)如何实现
|
||||
- 自动化发布链路如何实现
|
||||
- 部署时必须具备的运行环境与配置
|
||||
- 常见故障如何快速定位
|
||||
|
||||
适用代码范围:`backend/app/modules/publish`、`backend/app/services/publish_service.py`、`backend/app/services/qr_login_service.py`、`backend/app/services/uploader/*`。
|
||||
|
||||
---
|
||||
|
||||
## 2. 总体架构
|
||||
|
||||
### 2.1 API 入口
|
||||
|
||||
- `POST /api/publish`:执行发布
|
||||
- `POST /api/publish/login/{platform}`:获取二维码并启动登录会话
|
||||
- `GET /api/publish/login/status/{platform}`:轮询扫码状态
|
||||
- `POST /api/publish/logout/{platform}`:注销并删除对应 Cookie
|
||||
- `POST /api/publish/cookies/save/{platform}`:手动保存浏览器 `document.cookie`
|
||||
- `GET /api/publish/accounts`:查询各平台是否已登录
|
||||
- `GET /api/publish/screenshot/{filename}`:读取发布成功截图(需登录)
|
||||
|
||||
核心路由文件:`backend/app/modules/publish/router.py`。
|
||||
|
||||
### 2.2 服务分层
|
||||
|
||||
- `PublishService`:平台路由、账号隔离、视频路径处理、调用具体 uploader
|
||||
- `QRLoginService`:Playwright 获取二维码、监控扫码结果、保存 Cookie
|
||||
- `*Uploader`:平台发布自动化(抖音/微信/小红书基于 Playwright,B站基于 biliup)
|
||||
|
||||
---
|
||||
|
||||
## 3. Cookie 与账号隔离
|
||||
|
||||
### 3.1 存储路径
|
||||
|
||||
- 用户隔离路径:`backend/user_data/{user_uuid}/cookies/{platform}_cookies.json`
|
||||
- 兼容旧版路径:`backend/app/cookies/{platform}_cookies.json`
|
||||
|
||||
路径管理文件:`backend/app/core/paths.py`。
|
||||
|
||||
### 3.2 Cookie 格式
|
||||
|
||||
- `bilibili`:简化字典格式(`SESSDATA` / `bili_jct` / `DedeUserID` / `DedeUserID__ckMd5`)
|
||||
- `douyin` / `weixin` / `xiaohongshu`:Playwright `storage_state` 格式(`cookies + origins`)
|
||||
|
||||
对应逻辑:`backend/app/services/publish_service.py` 与 `backend/app/services/qr_login_service.py`。
|
||||
|
||||
---
|
||||
|
||||
## 4. 运行与部署要求
|
||||
|
||||
### 4.1 系统依赖
|
||||
|
||||
- Python 3.10+
|
||||
- Node.js 18+
|
||||
- Playwright Chromium(`playwright install chromium`)
|
||||
- 系统 Chrome(建议)
|
||||
- Xvfb(建议,尤其抖音/微信 headful)
|
||||
|
||||
### 4.2 启动建议
|
||||
|
||||
- 推荐使用根目录脚本启动后端:`./run_backend.sh`
|
||||
- 脚本内置 `xvfb-run`,适合无物理桌面服务器场景
|
||||
|
||||
脚本:`run_backend.sh`。
|
||||
|
||||
### 4.3 环境变量(核心)
|
||||
|
||||
统一在 `backend/.env` 配置,配置定义见 `backend/app/core/config.py`。
|
||||
|
||||
- 抖音:`DOUYIN_HEADLESS_MODE`、`DOUYIN_CHROME_PATH`、`DOUYIN_USER_AGENT`、`DOUYIN_LOCALE`、`DOUYIN_TIMEZONE_ID`
|
||||
- 微信:`WEIXIN_HEADLESS_MODE`、`WEIXIN_CHROME_PATH`、`WEIXIN_USER_AGENT`、`WEIXIN_LOCALE`、`WEIXIN_TIMEZONE_ID`、`WEIXIN_TRANSCODE_MODE`
|
||||
- 小红书:`XIAOHONGSHU_HEADLESS_MODE`、`XIAOHONGSHU_CHROME_PATH`、`XIAOHONGSHU_USER_AGENT`、`XIAOHONGSHU_LOCALE`、`XIAOHONGSHU_TIMEZONE_ID`
|
||||
- 发布截图目录:`PUBLISH_SCREENSHOT_DIR`
|
||||
|
||||
说明:小红书这些配置当前用于发布 uploader;扫码登录服务里抖音/微信使用独立配置,B站/小红书登录走通用默认浏览器参数。
|
||||
|
||||
---
|
||||
|
||||
## 5. 登录实现(扫码)
|
||||
|
||||
统一由 `QRLoginService` 处理:
|
||||
|
||||
1. 打开平台登录页并提取二维码(CSS/Text 多策略)
|
||||
2. 前端展示二维码给用户扫码
|
||||
3. 后台监控 URL + Session Cookie 变化
|
||||
4. 登录成功后保存 Cookie 文件
|
||||
|
||||
关键文件:`backend/app/services/qr_login_service.py`。
|
||||
|
||||
### 5.1 抖音
|
||||
|
||||
- 登录页:`https://creator.douyin.com/`
|
||||
- 额外能力:监听 `check_qrconnect` 接口,支持识别 `redirect_url`
|
||||
- 特殊场景:若触发刷脸验证,会提取验证二维码 `face_verify_qr` 返回前端
|
||||
|
||||
### 5.2 微信视频号
|
||||
|
||||
- 登录页:`https://channels.weixin.qq.com/platform/`
|
||||
- 二维码提取支持 `img/canvas/svg` 等兜底选择器
|
||||
|
||||
### 5.3 小红书
|
||||
|
||||
- 登录页:`https://creator.xiaohongshu.com/`
|
||||
- 关键修复:默认可能落在短信登录页,先自动切换到扫码模式再提取二维码
|
||||
- 成功判定支持 `/new/home`,避免仅依赖旧 `success_indicator`
|
||||
|
||||
### 5.4 B站
|
||||
|
||||
- 登录页:`https://passport.bilibili.com/login`
|
||||
- 扫码成功后保存 B站所需核心 Cookie 字段
|
||||
|
||||
---
|
||||
|
||||
## 6. 自动化发布实现
|
||||
|
||||
### 6.1 抖音(Playwright)
|
||||
|
||||
文件:`backend/app/services/uploader/douyin_uploader.py`
|
||||
|
||||
- 使用 `storage_state` 打开浏览器上下文
|
||||
- 自动进入上传页,触发 file chooser 上传
|
||||
- 上传完成后填写标题/简介/话题,必要时处理封面
|
||||
- 发布成功判定:页面跳转、接口信号、管理页核验
|
||||
- 成功后回写 Cookie,并保存发布成功截图
|
||||
|
||||
### 6.2 微信视频号(Playwright)
|
||||
|
||||
文件:`backend/app/services/uploader/weixin_uploader.py`
|
||||
|
||||
- 进入视频号创作平台,自动定位上传入口
|
||||
- 标题/描述/标签按当前产品规则统一写入“视频描述”字段
|
||||
- 发布成功判定:`post_create` API 或页面离开创建页
|
||||
- 成功后回写 Cookie,并保存发布成功截图
|
||||
|
||||
### 6.3 小红书(Playwright)
|
||||
|
||||
文件:`backend/app/services/uploader/xiaohongshu_uploader.py`
|
||||
|
||||
- 自动进入发布页并触发上传
|
||||
- 上传阶段增强:
|
||||
- `UPLOAD_SIGNAL_TIMEOUT` 启动探测窗口
|
||||
- 无后缀视频文件自动准备带后缀临时文件(`hardlink/copy`)
|
||||
- 文件名后缀一致性校验
|
||||
- `UPLOAD_IDLE_TIMEOUT` 空转超时保护,避免长时间“假卡住”
|
||||
- 发布成功判定:URL 跳转 + 成功文案 + 发布 API 信号
|
||||
- 成功后回写 Cookie,并返回成功截图 URL
|
||||
|
||||
### 6.4 B站(biliup)
|
||||
|
||||
文件:`backend/app/services/uploader/bilibili_uploader.py`
|
||||
|
||||
- 使用 biliup SDK,不依赖 Playwright 发布流程
|
||||
- 读取 B站 Cookie,调用 biliup 上传并提交
|
||||
- 返回 `bvid/aid` 对应链接(若 API 返回)
|
||||
|
||||
---
|
||||
|
||||
## 7. 调试与排障
|
||||
|
||||
### 7.1 后端日志
|
||||
|
||||
- PM2 输出日志:`~/.pm2/logs/vigent2-backend-out.log`
|
||||
- PM2 错误日志:`~/.pm2/logs/vigent2-backend-error.log`
|
||||
|
||||
### 7.2 常见问题
|
||||
|
||||
- 现象:登录二维码拿不到
|
||||
- 优先检查平台登录页是否改版(selector 失效)
|
||||
- 小红书需确认是否仍停留短信登录视图
|
||||
|
||||
- 现象:发布看起来卡住
|
||||
- 检查是否长期停留“等待上传状态/等待发布结果”
|
||||
- 小红书优先检查上传文件名后缀与 MIME 识别
|
||||
|
||||
- 现象:突然要求重新登录
|
||||
- 通常为 Cookie 失效或平台风控,需要重新扫码
|
||||
|
||||
### 7.3 调试产物
|
||||
|
||||
- 开启对应 `*_DEBUG_ARTIFACTS` 可输出调试截图/网络日志
|
||||
- 成功截图通过 `/api/publish/screenshot/{filename}` 回传前端
|
||||
|
||||
---
|
||||
|
||||
## 8. 建议的验收流程(每次部署后)
|
||||
|
||||
1. 健康检查:`curl http://127.0.0.1:8006/health`
|
||||
2. 登录检查:分别触发 4 个平台扫码登录并确认状态轮询可达成功
|
||||
3. 发布检查:四个平台各发 1 条测试视频(或最少覆盖当日变更平台)
|
||||
4. 截图检查:确认成功截图可通过 `/api/publish/screenshot/{filename}` 拉取
|
||||
5. 日志检查:确认无持续重试、无长时间空转、无明显 selector 失败风暴
|
||||
|
||||
---
|
||||
|
||||
## 9. 关联文档
|
||||
|
||||
- 总部署文档:`Docs/DEPLOY_MANUAL.md`
|
||||
- 后端说明:`Docs/BACKEND_README.md`
|
||||
- 当日变更记录:`Docs/DevLogs/Day31.md`
|
||||
@@ -1,6 +1,10 @@
|
||||
# Qwen3-TTS 1.7B 部署指南
|
||||
|
||||
> 本文档描述如何在 Ubuntu 服务器上部署 Qwen3-TTS 1.7B-Base 声音克隆模型。
|
||||
>
|
||||
> ⚠️ **状态:历史归档(已停用)**
|
||||
> 当前项目生产环境已切换到 CosyVoice 3.0,请优先参考 `Docs/COSYVOICE3_DEPLOY.md`。
|
||||
> 本文档仅保留用于回溯旧方案,不建议新部署继续使用。
|
||||
|
||||
## 系统要求
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
音频 → faster-whisper → 字幕JSON ─────────────────────────────────────────────┴→ Remotion合成 → 最终视频
|
||||
```
|
||||
|
||||
> **唇形同步路由**: 短视频 (<120s) 用 LatentSync 1.6 (GPU1),长视频 (>=120s) 用 MuseTalk 1.5 (GPU0),由 `LIPSYNC_DURATION_THRESHOLD` 控制。
|
||||
> **唇形同步路由**: 短视频 (<100s,按当前 `.env` 示例) 用 LatentSync 1.6 (GPU1),长视频 (>=100s,按当前 `.env` 示例) 用 MuseTalk 1.5 (GPU0),由 `LIPSYNC_DURATION_THRESHOLD` 控制。
|
||||
|
||||
## 系统要求
|
||||
|
||||
@@ -146,8 +146,8 @@ remotion/
|
||||
| 阶段 | 进度 | 说明 |
|
||||
|------|------|------|
|
||||
| 下载素材 | 0% → 5% | 从 Supabase 下载输入视频 |
|
||||
| TTS 语音生成 | 5% → 25% | EdgeTTS / Qwen3-TTS / 预生成配音下载 |
|
||||
| 唇形同步 | 25% → 80% | LatentSync 推理 |
|
||||
| TTS 语音生成 | 5% → 25% | EdgeTTS / CosyVoice / 预生成配音下载 |
|
||||
| 唇形同步 | 25% → 80% | LatentSync / MuseTalk(按阈值路由) |
|
||||
| 字幕对齐 | 80% → 85% | faster-whisper 生成字级别时间戳 |
|
||||
| Remotion 渲染 | 85% → 95% | 合成字幕和标题 |
|
||||
| 上传结果 | 95% → 100% | 上传到 Supabase Storage |
|
||||
@@ -241,6 +241,15 @@ const bundleLocation = await bundle({
|
||||
const videoUrl = staticFile(videoSrc); // 使用 staticFile
|
||||
```
|
||||
|
||||
**问题**: Remotion 渲染失败 - 404 视频文件找不到(bundle 缓存问题)
|
||||
|
||||
Remotion 使用 bundle 缓存加速打包。缓存命中时,新生成的视频/字体文件需要硬链接到缓存的 `public/` 目录。如果出现 404 错误,清除缓存重试:
|
||||
|
||||
```bash
|
||||
rm -rf /home/rongye/ProgramFiles/ViGent2/remotion/.remotion-bundle-cache
|
||||
pm2 restart vigent2-backend
|
||||
```
|
||||
|
||||
**问题**: Remotion 渲染失败
|
||||
|
||||
查看后端日志:
|
||||
@@ -296,3 +305,4 @@ WhisperService(device="cuda:0") # 或 "cuda:1"
|
||||
| 2026-02-27 | 1.3.0 | 架构图更新 MuseTalk 混合路由;Remotion 并发渲染从 8 提升到 16;GPU 分配说明更新 |
|
||||
| 2026-02-28 | 1.3.1 | MuseTalk 合成阶段优化:纯 numpy blending + FFmpeg pipe NVENC GPU 硬编码替代双重编码 |
|
||||
| 2026-02-28 | 1.4.0 | compose 流复制替代重编码;FFmpeg 超时保护 (600s/30s);Remotion 并发 16→4;Whisper 时间戳平滑 + 原文节奏映射;全局视频生成 Semaphore(2);Redis 任务 TTL |
|
||||
| 2026-03-02 | 1.5.0 | Remotion bundle 缓存修复(硬链接视频/字体到 cached public 目录);编码流水线优化 prepare_segment/normalize CRF 23→18;多素材 concat 改为流复制;MuseTalk 合成改为 rawvideo 管道 + `libx264`(可配 CRF/preset) |
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# ViGent2 开发任务清单 (Task Log)
|
||||
|
||||
**项目**: ViGent2 数字人口播视频生成系统
|
||||
**进度**: 100% (Day 29 - 视频流水线优化 + CosyVoice 语气控制)
|
||||
**更新时间**: 2026-02-28
|
||||
**进度**: 100% (Day 31 - 发布登录稳定性修复 + 文档体系补齐)
|
||||
**更新时间**: 2026-03-03
|
||||
|
||||
---
|
||||
|
||||
@@ -10,7 +10,51 @@
|
||||
|
||||
> 这里记录了每一天的核心开发内容与 milestone。
|
||||
|
||||
### Day 29: 视频流水线优化 + CosyVoice 语气控制 (Current)
|
||||
### Day 31: 文档体系收敛 + 音色试听 + 录音弹窗重构 + 发布登录稳定性修复 (Current)
|
||||
- [x] **文档体系收敛**: README/DEV 职责边界明确,部署参数与代码对齐,Qwen3-TTS 文档归档至历史状态。
|
||||
- [x] **音色试听能力**: 新增并启用 `GET/POST /api/videos/voice-preview`,前端改为直接播放 GET 音频流,修复线上 404(重启后端生效)。
|
||||
- [x] **录音交互重构**: 录音入口迁移到参考音频区底部,流程改为弹窗;支持录音后即时关闭弹窗、后台上传识别。
|
||||
- [x] **弹窗系统统一**: 抽离 `AppModal`,统一遮罩/焦点/滚动锁/Portal,可访问性补齐;主要弹窗完成迁移(预览、提取、改写、截取、录音、改密、发布登录)。
|
||||
- [x] **抖音扫码修复**: 登录页等待策略改为 `domcontentloaded`,并对导航超时容错,避免“无法获取二维码”。
|
||||
- [x] **微信二维码优化**: 后端优先导出原始 PNG,前端展示加入白底留白容器,修复“二维码边缘像被截断”的观感问题。
|
||||
- [x] **发布性能优化**: 发布页改为受限并发(并发度 2),多平台发布总等待时长明显下降。
|
||||
- [x] **微信上传日志降噪**: `file_input empty` 告警改为信号驱动,非最终重试降级为 info,减少误报警。
|
||||
- [x] **小红书发布重构**: 对齐抖音/微信上传架构,补齐启动配置、上传/发布多信号判定、成功截图与 `screenshot_url` 回传。
|
||||
- [x] **Cookie 格式统一**: 非 B 站平台统一保存为 Playwright `storage_state`,支持 uploader 直接加载上下文。
|
||||
- [x] **小红书扫码修复**: 自动从短信登录切换到扫码页并提取二维码,登录成功判定补齐 `/new/home` 路径。
|
||||
- [x] **小红书“上传卡住”修复**: 新增无后缀视频临时文件兜底(hardlink/copy)、文件名后缀一致性校验、上传空转超时保护(90s)。
|
||||
- [x] **实测闭环**: 小红书 `POST /api/publish` 实测成功(45.77s)并可访问成功截图接口。
|
||||
- [x] **文档补齐**: 新增 `Docs/PUBLISH_DEPLOY.md`,并回写 `README.md`、`BACKEND_README.md`、`BACKEND_DEV.md`、`DEPLOY_MANUAL.md`。
|
||||
- [x] **文档规则对齐**: 更新 `Docs/DOC_RULES.md`,补充发布相关“三检”与敏感信息处理规范,加入 `PUBLISH_DEPLOY.md` 检查项,工具规范改为 `Read/Grep/apply_patch`,并对齐 TASK_COMPLETE 检查清单。
|
||||
- [x] **首页交互微调**: `AI生成标题标签` 按钮迁移到“四、标题与字幕”标题同层最右;`标题显示方式 + 预览样式` 下沉到下一行右侧;AI按钮圆角/尺寸对齐“在线录音”,配色保留原蓝色渐变;文档明确 `title_display_mode` 对主/副标题统一生效。
|
||||
- [x] **文案编辑扩展**: 在文案输入框右下角新增扩展角标,点击后弹出大编辑器,主框与弹窗内文案实时同步;角标样式改为双箭头极简贴边并微调到 `right-0.5 bottom-2`;修复扩展输入框打字后失焦问题,移除紫色聚焦边框。
|
||||
|
||||
### Day 30: Remotion 缓存修复 + 编码流水线质量优化 + 唇形同步容错 + 统一下拉交互
|
||||
- [x] **Remotion 缓存 404 修复**: bundle 缓存命中时,新生成的视频/字体文件不在旧缓存 `public/` 目录 → 404 → 回退 FFmpeg(无标题字幕)。改为硬链接(`fs.linkSync`)当前渲染所需文件到缓存目录。
|
||||
- [x] **LatentSync `read_video` 跳过冗余 FPS 重编码**: 检测输入 FPS,已是 25fps 时跳过 `ffmpeg -r 25 -crf 18` 重编码。
|
||||
- [x] **LatentSync final mux 流复制**: `imageio` CRF 13 写帧后的 mux 步骤从 `libx264 -crf 18` 改为 `-c:v copy`,消除冗余双重编码。
|
||||
- [x] **`prepare_segment` + `normalize_orientation` CRF 提质**: CRF 23 → 18,与 LatentSync 内部质量标准统一。
|
||||
- [x] **多素材 concat 流复制**: 各段参数已统一,`concat_videos` 从 `libx264 -crf 23` 改为 `-c:v copy`。
|
||||
- [x] **编码次数总计**: 从 5-6 次有损编码降至 3 次(prepare_segment → LatentSync/MuseTalk 模型输出 → Remotion)。
|
||||
- [x] **LatentSync 无脸帧容错**: 素材部分帧检测不到人脸时不再中断推理,无脸帧保留原画面,单素材异常时回退原视频。
|
||||
- [x] **MuseTalk 管道直编码**: `cv2.VideoWriter(mp4v)` 中间有损文件改为 FFmpeg rawvideo stdin 管道,消除一次冗余有损编码。
|
||||
- [x] **MuseTalk 参数环境变量化**: 推理与编码参数(detect_every/blend_cache/CRF/preset 等)从硬编码迁移到 `backend/.env`,当前使用质量优先档(CRF 14, preset slow, detect_every 2, blend_cache_every 2)。
|
||||
- [x] **Workflow 异步防阻塞**: 新增 `_run_blocking()` 线程池辅助,5 处同步 FFmpeg 调用(旋转归一化/prepare_segment/concat/BGM 混音)改为 `await _run_blocking()`,事件循环不再被阻塞。
|
||||
- [x] **compose 跳过优化**: 无 BGM 时 `final_audio_path == audio_path`,跳过多余的 compose 步骤,Remotion 路径直接用 lipsync 输出,非 Remotion 路径 `shutil.copy` 透传。
|
||||
- [x] **compose() 异步化**: `compose()` 改为 `async def`,内部 `_get_duration` 和 `_run_ffmpeg` 走 `run_in_executor`。
|
||||
- [x] **同分辨率跳过 scale**: 多素材逐段比对分辨率,匹配的传 `None` 走 copy 分支;单素材同理。避免已是目标分辨率时的无效重编码。
|
||||
- [x] **`_get_duration()` 线程池化**: workflow 中 3 处同步 ffprobe 探测改为 `await _run_blocking()`。
|
||||
- [x] **compose 循环 CRF 统一**: 循环场景 CRF 23 → 18,与全流水线质量标准一致。
|
||||
- [x] **多素材片段校验**: prepare 完成后校验片段数量一致,防止空片段进入 concat。
|
||||
- [x] **唇形模型前端选择**: 生成按钮右侧新增模型下拉(默认模型/快速模型/高级模型),全链路透传 `lipsync_model` 到后端路由。默认保持阈值策略,快速强制 MuseTalk,高级强制 LatentSync,三种模式均有 LatentSync 兜底。选择 localStorage 持久化。
|
||||
- [x] **业务下拉统一组件化**: 新增 `SelectPopover`(桌面 Popover + 移动端 BottomSheet),覆盖首页/发布页主要业务选择器(音色、参考音频、配音、素材、BGM、作品、样式、模型、画面比例)。
|
||||
- [x] **下拉体验修复**: 统一处理遮挡(Portal + fixed)、自动上拉、触发器同宽、背景不透明、滚动条隐藏、再次打开定位到已选项。
|
||||
- [x] **预览联动修复**: 下拉内点击视频预览不强制收起菜单;预览弹窗层级高于下拉;关闭预览后可继续在菜单内连续预览。
|
||||
- [x] **BGM 交互收敛**: BGM 选择改为发布页同款(搜索 + 列表 + 试听);按产品要求移除首页音量滑杆,生成请求固定 `bgm_volume=0.2`。
|
||||
- [x] **例外回退**: `ScriptEditor` 的“历史文案 / AI多语言”恢复原轻量菜单样式(不强制统一 SelectPopover)。
|
||||
- [x] **文档同步**: Day30 / TASK_COMPLETE / FRONTEND_DEV / FRONTEND_README / README / BACKEND_README 同步更新到最终实现。
|
||||
|
||||
### Day 29: 视频流水线优化 + CosyVoice 语气控制
|
||||
- [x] **字幕同步修复**: Whisper 时间戳三步平滑(单调递增+重叠消除+间隙填补)+ 原文节奏映射(线性插值 + 单字时长钳位)。
|
||||
- [x] **LatentSync 嘴型参数调优**: inference_steps 16→20, guidance_scale 2.0, DeepCache 启用, Remotion concurrency 16→4。
|
||||
- [x] **compose 流复制**: 不循环时 `-c:v copy` 替代 libx264 重编码,compose 耗时从分钟级降到秒级。
|
||||
@@ -31,7 +75,7 @@
|
||||
- [x] **描边参数优化**: 所有预设 `stroke_size` 从 8 降至 4~5,配合原生描边视觉更干净。
|
||||
- [x] **TypeScript 类型修复**: Root.tsx `Composition` 泛型与 `calculateMetadata` 参数类型对齐;Video.tsx `VideoProps` 添加索引签名兼容 `Record<string, unknown>`;VideoLayer.tsx 移除 `OffthreadVideo` 不支持的 `loop` prop。
|
||||
- [x] **进度条文案还原**: 进度条从显示后端推送消息改回固定 `正在AI生成中...`。
|
||||
- [x] **MuseTalk 混合唇形同步**: 部署 MuseTalk 1.5 常驻服务 (GPU0, 端口 8011),按音频时长自动路由 — 短视频 (<120s) 走 LatentSync,长视频 (>=120s) 走 MuseTalk,MuseTalk 不可用时自动回退。
|
||||
- [x] **MuseTalk 混合唇形同步**: 部署 MuseTalk 1.5 常驻服务 (GPU0, 端口 8011),按音频时长自动路由(由 `LIPSYNC_DURATION_THRESHOLD` 控制;本仓库当前 `.env` 为 100)— 短视频走 LatentSync,长视频走 MuseTalk,MuseTalk 不可用时自动回退。
|
||||
- [x] **MuseTalk 推理性能优化**: server.py v2 重写 — cv2 直读帧(跳过 ffmpeg→PNG)、人脸检测降频(每5帧)、BiSeNet mask 缓存(每5帧)、cv2.VideoWriter 直写(跳过 PNG 写盘)、batch_size 8→32,预估 30min→8-10min (~3x)。
|
||||
- [x] **Remotion 并发渲染优化**: render.ts 新增 concurrency 参数,从默认 8 提升到 16 (56核 CPU),预估 5min→2-3min。
|
||||
|
||||
|
||||
16
README.md
16
README.md
@@ -16,7 +16,7 @@
|
||||
## ✨ 功能特性
|
||||
|
||||
### 核心能力
|
||||
- 🎬 **高清唇形同步** - 混合方案:短视频 (<120s) 用 LatentSync 1.6 (高质量 Latent Diffusion),长视频 (>=120s) 用 MuseTalk 1.5 (实时级单步推理),自动路由 + 回退。
|
||||
- 🎬 **高清唇形同步** - 混合方案:短视频(本仓库当前 `.env` 阈值 100s,可配)用 LatentSync 1.6(高质量 Latent Diffusion),长视频用 MuseTalk 1.5(实时级单步推理),自动路由 + 回退。前端可选模型:默认模型(阈值自动路由)/ 快速模型(速度优先)/ 高级模型(质量优先)。
|
||||
- 🎙️ **多模态配音** - 支持 **EdgeTTS** (微软超自然语音, 10 语言) 和 **CosyVoice 3.0** (3秒极速声音克隆, 9语言+18方言, 语速/语气可调)。上传参考音频自动 Whisper 转写 + 智能截取。配音前置工作流:先生成配音 → 选素材 → 生成视频。
|
||||
- 📝 **智能字幕** - 集成 faster-whisper + Remotion,自动生成逐字高亮 (卡拉OK效果) 字幕。
|
||||
- 🎨 **样式预设** - 12 种标题 + 8 种字幕样式预设,支持预览 + 字号调节 + 自定义字体库。CSS 原生描边渲染,清晰无重影。
|
||||
@@ -25,19 +25,20 @@
|
||||
- 🖼️ **作品预览一致性** - 标题/字幕预览与 Remotion 成片统一响应式缩放和自动换行,窄屏画布也稳定显示。
|
||||
- 🎞️ **多素材多机位** - 支持多选素材 + 时间轴编辑器 (wavesurfer.js 波形可视化),拖拽分割线调整时长、拖拽排序切换机位、按 `source_start/source_end` 截取片段。
|
||||
- 📐 **画面比例控制** - 时间轴一键切换 `9:16 / 16:9` 输出比例,生成链路全程按目标比例处理。
|
||||
- 💾 **用户偏好持久化** - 首页状态统一恢复/保存,刷新后延续上次配置。历史文案手动保存与加载。
|
||||
- 🎵 **背景音乐** - 试听 + 音量控制 + 混音,保持配音音量稳定。
|
||||
- 💾 **用户偏好持久化** - 首页状态统一恢复/保存,刷新后延续上次配置;新作品生成后优先选中最新,后续用户手动选择持续持久化。
|
||||
- 🎵 **背景音乐** - 试听 + 搜索选择 + 混音(当前前端固定混音系数,保持配音音量稳定)。
|
||||
- 🧩 **统一选择器交互** - 首页/发布页业务选择项统一 SelectPopover(桌面 Popover / 移动端 BottomSheet),支持自动上拉、已选定位与连续预览。
|
||||
- 🤖 **AI 辅助创作** - 内置 GLM-4.7-Flash,支持 B站/抖音链接文案提取、AI 智能改写(支持自定义提示词)、标题/标签自动生成、9 语言翻译。
|
||||
|
||||
### 平台化功能
|
||||
- 📱 **全自动发布** - 支持抖音/微信视频号/B站/小红书立即发布;扫码登录 + Cookie 持久化。
|
||||
- 🖥️ **发布管理预览** - 支持签名 URL / 相对路径作品预览,确保可直接播放。
|
||||
- 📸 **发布结果可视化** - 抖音/微信视频号发布成功后返回截图,发布页结果卡片可直接查看。
|
||||
- 📸 **发布结果可视化** - 抖音/微信视频号/小红书发布成功后返回截图,发布页结果卡片可直接查看。
|
||||
- 🛡️ **发布防误操作** - 发布进行中自动提示“请勿刷新或关闭网页”,并拦截刷新/关页二次确认。
|
||||
- 💳 **付费会员** - 支付宝电脑网站支付自动开通会员,到期自动停用并引导续费,管理员手动激活并存。
|
||||
- 🔐 **认证与隔离** - 基于 Supabase 的用户隔离,支持手机号注册/登录、密码管理。
|
||||
- 🛡️ **服务守护** - 内置 Watchdog 看门狗机制,自动监控并重启僵死服务,确保 7x24h 稳定运行。
|
||||
- 🚀 **性能优化** - compose 流复制免重编码、FFmpeg 超时保护、全局视频生成并发限制 (Semaphore(2))、Remotion 4 并发渲染、MuseTalk NVENC GPU 硬编码 + 纯 numpy blending、模型常驻服务、双 GPU 流水线并发、Redis 任务 TTL 自动清理。
|
||||
- 🚀 **性能优化** - 编码流水线从 5-6 次有损编码精简至 3 次(prepare_segment → 模型输出 → Remotion)、compose 流复制免重编码、同分辨率跳过 scale、FFmpeg 超时保护、全局视频生成并发限制 (Semaphore(2))、Remotion 4 并发渲染、MuseTalk rawvideo 管道直编码(消除中间有损文件)、模型常驻服务、双 GPU 流水线并发、Redis 任务 TTL 自动清理、workflow 阻塞调用线程池化。
|
||||
|
||||
---
|
||||
|
||||
@@ -60,8 +61,9 @@
|
||||
我们提供了详尽的开发与部署文档:
|
||||
|
||||
### 部署运维
|
||||
- **[部署手册 (DEPLOY_MANUAL.md)](Docs/DEPLOY_MANUAL.md)** - 👈 **部署请看这里**!包含完整的环境搭建步骤。
|
||||
- [参考音频服务部署 (COSYVOICE3_DEPLOY.md)](Docs/COSYVOICE3_DEPLOY.md) - 声音克隆模型部署指南。
|
||||
- **[部署手册 (DEPLOY_MANUAL.md)](Docs/DEPLOY_MANUAL.md)** - 👈 **部署请看这里**!包含完整的环境搭建步骤。
|
||||
- [多平台发布部署说明 (PUBLISH_DEPLOY.md)](Docs/PUBLISH_DEPLOY.md) - 抖音/微信视频号/B站/小红书登录与自动化发布专项文档。
|
||||
- [参考音频服务部署 (COSYVOICE3_DEPLOY.md)](Docs/COSYVOICE3_DEPLOY.md) - 声音克隆模型部署指南。
|
||||
- [LatentSync 部署指南 (LATENTSYNC_DEPLOY.md)](Docs/LATENTSYNC_DEPLOY.md) - 唇形同步模型独立部署。
|
||||
- [MuseTalk 部署指南 (MUSETALK_DEPLOY.md)](Docs/MUSETALK_DEPLOY.md) - 长视频唇形同步模型部署。
|
||||
- [Supabase 部署指南 (SUPABASE_DEPLOY.md)](Docs/SUPABASE_DEPLOY.md) - Supabase 与认证系统配置。
|
||||
|
||||
@@ -25,10 +25,10 @@ LATENTSYNC_USE_SERVER=true
|
||||
# LATENTSYNC_API_URL=http://localhost:8007
|
||||
|
||||
# 推理步数 (20-50, 越高质量越好,速度越慢)
|
||||
LATENTSYNC_INFERENCE_STEPS=20
|
||||
LATENTSYNC_INFERENCE_STEPS=30
|
||||
|
||||
# 引导系数 (1.0-3.0, 越高唇同步越准,但可能抖动)
|
||||
LATENTSYNC_GUIDANCE_SCALE=2.0
|
||||
LATENTSYNC_GUIDANCE_SCALE=1.9
|
||||
|
||||
# 启用 DeepCache 加速 (推荐开启)
|
||||
LATENTSYNC_ENABLE_DEEPCACHE=true
|
||||
@@ -52,9 +52,36 @@ MUSETALK_VERSION=v15
|
||||
# 半精度加速
|
||||
MUSETALK_USE_FLOAT16=true
|
||||
|
||||
# 人脸检测降频间隔(帧,越小质量越稳但更慢)
|
||||
MUSETALK_DETECT_EVERY=2
|
||||
|
||||
# BiSeNet mask 缓存更新间隔(帧,越小质量越稳但更慢)
|
||||
MUSETALK_BLEND_CACHE_EVERY=2
|
||||
|
||||
# Whisper 时序上下文(越大越平滑,口型响应会更钝)
|
||||
MUSETALK_AUDIO_PADDING_LEFT=2
|
||||
MUSETALK_AUDIO_PADDING_RIGHT=2
|
||||
|
||||
# v1.5 下巴区域扩展像素(越大越容易看到下唇/牙齿,也更易边缘不稳)
|
||||
MUSETALK_EXTRA_MARGIN=14
|
||||
|
||||
# 音频-口型对齐偏移(帧,正数=口型更晚,负数=口型更早)
|
||||
MUSETALK_DELAY_FRAME=0
|
||||
|
||||
# 融合模式:auto(按版本自动) / jaw / raw
|
||||
MUSETALK_BLEND_MODE=jaw
|
||||
|
||||
# FaceParsing 面颊宽度(仅 v1.5 生效,影响融合掩膜范围)
|
||||
MUSETALK_FACEPARSING_LEFT_CHEEK_WIDTH=90
|
||||
MUSETALK_FACEPARSING_RIGHT_CHEEK_WIDTH=90
|
||||
|
||||
# 最终编码质量(CRF 越小越清晰但体积更大)
|
||||
MUSETALK_ENCODE_CRF=14
|
||||
MUSETALK_ENCODE_PRESET=slow
|
||||
|
||||
# =============== 混合唇形同步路由 ===============
|
||||
# 音频时长 >= 此阈值(秒)用 MuseTalk,< 此阈值用 LatentSync
|
||||
LIPSYNC_DURATION_THRESHOLD=120
|
||||
LIPSYNC_DURATION_THRESHOLD=100
|
||||
|
||||
# =============== 上传配置 ===============
|
||||
# 最大上传文件大小 (MB)
|
||||
|
||||
@@ -37,12 +37,22 @@ class Settings(BaseSettings):
|
||||
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
|
||||
# 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
|
||||
|
||||
# Xiaohongshu Playwright 配置
|
||||
XIAOHONGSHU_HEADLESS_MODE: str = "headless-new"
|
||||
XIAOHONGSHU_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"
|
||||
XIAOHONGSHU_LOCALE: str = "zh-CN"
|
||||
XIAOHONGSHU_TIMEZONE_ID: str = "Asia/Shanghai"
|
||||
XIAOHONGSHU_CHROME_PATH: str = "/usr/bin/google-chrome"
|
||||
XIAOHONGSHU_BROWSER_CHANNEL: str = ""
|
||||
XIAOHONGSHU_FORCE_SWIFTSHADER: bool = True
|
||||
XIAOHONGSHU_DEBUG_ARTIFACTS: bool = False
|
||||
|
||||
# TTS 配置
|
||||
DEFAULT_TTS_VOICE: str = "zh-CN-YunxiNeural"
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends
|
||||
import os
|
||||
import tempfile
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from loguru import logger
|
||||
from starlette.background import BackgroundTask
|
||||
|
||||
from app.core.deps import get_current_user
|
||||
from app.core.response import success_response
|
||||
from app.services.tts_service import TTSService
|
||||
|
||||
from .schemas import GenerateRequest
|
||||
from .schemas import GenerateRequest, VoicePreviewRequest
|
||||
from .task_store import create_task, get_task, list_tasks
|
||||
from .workflow import process_video_generation, get_lipsync_health, get_voiceclone_health
|
||||
from .service import list_generated_videos, delete_generated_video
|
||||
@@ -12,6 +19,59 @@ from .service import list_generated_videos, delete_generated_video
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
PREVIEW_TEXTS = {
|
||||
"zh-CN": "你好,请选择你喜欢的音色吧。",
|
||||
"en-US": "Hello, please choose the voice you like.",
|
||||
"ja-JP": "こんにちは。お好きな音声を選んでください。",
|
||||
"ko-KR": "안녕하세요, 마음에 드는 음성을 선택해 주세요.",
|
||||
"fr-FR": "Bonjour, veuillez choisir la voix que vous preferez.",
|
||||
"de-DE": "Hallo, bitte waehlen Sie die Stimme, die Ihnen gefaellt.",
|
||||
"es-ES": "Hola, por favor elige la voz que mas te guste.",
|
||||
"ru-RU": "Zdravstvuite, pozhaluista, vyberite golos, kotoryi vam nravitsya.",
|
||||
"it-IT": "Ciao, scegli la voce che preferisci.",
|
||||
"pt-BR": "Ola, escolha a voz de que voce mais gosta.",
|
||||
}
|
||||
|
||||
|
||||
def _cleanup_temp_file(path: str) -> None:
|
||||
try:
|
||||
os.unlink(path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _get_voice_locale(voice: str) -> str:
|
||||
parts = voice.split("-")
|
||||
if len(parts) >= 2:
|
||||
return f"{parts[0]}-{parts[1]}"
|
||||
return "zh-CN"
|
||||
|
||||
|
||||
def _get_preview_text_for_voice(voice: str) -> str:
|
||||
locale = _get_voice_locale(voice)
|
||||
return PREVIEW_TEXTS.get(locale, PREVIEW_TEXTS["zh-CN"])
|
||||
|
||||
|
||||
async def _render_voice_preview(voice: str, text: str) -> FileResponse:
|
||||
tmp_file = tempfile.NamedTemporaryFile(prefix="voice_preview_", suffix=".mp3", delete=False)
|
||||
output_path = tmp_file.name
|
||||
tmp_file.close()
|
||||
|
||||
tts = TTSService()
|
||||
try:
|
||||
await tts.generate_audio(text=text, voice=voice, output_path=output_path)
|
||||
except Exception as e:
|
||||
_cleanup_temp_file(output_path)
|
||||
logger.error(f"音色试听生成失败: voice={voice}, error={e}")
|
||||
raise HTTPException(status_code=500, detail="音色试听生成失败,请稍后重试")
|
||||
|
||||
return FileResponse(
|
||||
path=output_path,
|
||||
media_type="audio/mpeg",
|
||||
filename="voice_preview.mp3",
|
||||
background=BackgroundTask(_cleanup_temp_file, output_path),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/generate")
|
||||
async def generate_video(
|
||||
@@ -62,3 +122,38 @@ async def list_generated(current_user: dict = Depends(get_current_user)):
|
||||
async def delete_generated(video_id: str, current_user: dict = Depends(get_current_user)):
|
||||
result = await delete_generated_video(current_user["id"], video_id)
|
||||
return success_response(result, message="视频已删除")
|
||||
|
||||
|
||||
@router.post("/voice-preview")
|
||||
async def preview_voice_post(
|
||||
req: VoicePreviewRequest,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
# 复用统一鉴权,接口本身不需要 user_id
|
||||
_ = current_user
|
||||
|
||||
voice = req.voice.strip()
|
||||
text = req.text.strip()
|
||||
|
||||
if not voice:
|
||||
raise HTTPException(status_code=400, detail="voice 不能为空")
|
||||
if not text:
|
||||
raise HTTPException(status_code=400, detail="text 不能为空")
|
||||
|
||||
return await _render_voice_preview(voice=voice, text=text)
|
||||
|
||||
|
||||
@router.get("/voice-preview")
|
||||
async def preview_voice_get(
|
||||
voice: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
# 复用统一鉴权,接口本身不需要 user_id
|
||||
_ = current_user
|
||||
|
||||
voice_value = voice.strip()
|
||||
if not voice_value:
|
||||
raise HTTPException(status_code=400, detail="voice 不能为空")
|
||||
|
||||
text = _get_preview_text_for_voice(voice_value)
|
||||
return await _render_voice_preview(voice=voice_value, text=text)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List, Literal
|
||||
|
||||
|
||||
@@ -38,3 +38,9 @@ class GenerateRequest(BaseModel):
|
||||
bgm_volume: Optional[float] = 0.2
|
||||
custom_assignments: Optional[List[CustomAssignment]] = None
|
||||
output_aspect_ratio: Literal["9:16", "16:9"] = "9:16"
|
||||
lipsync_model: Literal["default", "fast", "advanced"] = "default"
|
||||
|
||||
|
||||
class VoicePreviewRequest(BaseModel):
|
||||
voice: str
|
||||
text: str = Field(..., min_length=1, max_length=120)
|
||||
|
||||
@@ -94,6 +94,12 @@ def _update_task(task_id: str, **updates: Any) -> None:
|
||||
task_store.update(task_id, updates)
|
||||
|
||||
|
||||
async def _run_blocking(func, *args):
|
||||
"""在线程池执行阻塞函数,避免卡住事件循环。"""
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(None, func, *args)
|
||||
|
||||
|
||||
# ── 多素材辅助函数 ──
|
||||
|
||||
|
||||
@@ -214,7 +220,8 @@ async def _process_video_generation_inner(task_id: str, req: GenerateRequest, us
|
||||
|
||||
# 归一化旋转元数据(如 iPhone MOV 1920x1080 + rotation=-90)
|
||||
normalized_input_path = temp_dir / f"{task_id}_input_norm.mp4"
|
||||
normalized_result = video.normalize_orientation(
|
||||
normalized_result = await _run_blocking(
|
||||
video.normalize_orientation,
|
||||
str(input_material_path),
|
||||
str(normalized_input_path),
|
||||
)
|
||||
@@ -317,7 +324,7 @@ async def _process_video_generation_inner(task_id: str, req: GenerateRequest, us
|
||||
result = _split_equal(captions_data["segments"], material_paths)
|
||||
else:
|
||||
logger.warning("[MultiMat] Whisper 无数据,按时长均分")
|
||||
audio_dur = video._get_duration(str(audio_path))
|
||||
audio_dur = await _run_blocking(video._get_duration, str(audio_path))
|
||||
if audio_dur <= 0:
|
||||
audio_dur = 30.0
|
||||
seg_dur = audio_dur / len(material_paths)
|
||||
@@ -378,7 +385,7 @@ async def _process_video_generation_inner(task_id: str, req: GenerateRequest, us
|
||||
assignments, captions_path = await _whisper_and_split()
|
||||
|
||||
# 扩展段覆盖完整音频范围:首段从0开始,末段到音频结尾
|
||||
audio_duration = video._get_duration(str(audio_path))
|
||||
audio_duration = await _run_blocking(video._get_duration, str(audio_path))
|
||||
if assignments and audio_duration > 0:
|
||||
assignments[0]["start"] = 0.0
|
||||
assignments[-1]["end"] = audio_duration
|
||||
@@ -402,9 +409,7 @@ async def _process_video_generation_inner(task_id: str, req: GenerateRequest, us
|
||||
await _download_material(assignment["material_path"], material_local)
|
||||
|
||||
normalized_material = temp_dir / f"{task_id}_material_{i}_norm.mp4"
|
||||
loop = asyncio.get_event_loop()
|
||||
normalized_result = await loop.run_in_executor(
|
||||
None,
|
||||
normalized_result = await _run_blocking(
|
||||
video.normalize_orientation,
|
||||
str(material_local),
|
||||
str(normalized_material),
|
||||
@@ -432,22 +437,21 @@ async def _process_video_generation_inner(task_id: str, req: GenerateRequest, us
|
||||
logger.info(f"[MultiMat] 素材分辨率不一致,统一到 {base_res[0]}x{base_res[1]}")
|
||||
|
||||
# ── 第二步:并行裁剪每段素材到对应时长 ──
|
||||
prepared_segments: List[Path] = [None] * num_segments
|
||||
prepared_segments: List[Optional[Path]] = [None] * num_segments
|
||||
|
||||
async def _prepare_one_segment(i: int, assignment: dict):
|
||||
"""将单个素材裁剪/循环到对应时长"""
|
||||
seg_dur = assignment["end"] - assignment["start"]
|
||||
prepared_path = temp_dir / f"{task_id}_prepared_{i}.mp4"
|
||||
temp_files.append(prepared_path)
|
||||
prepare_target_res = None if resolutions[i] == base_res else base_res
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
await _run_blocking(
|
||||
video.prepare_segment,
|
||||
str(material_locals[i]),
|
||||
seg_dur,
|
||||
str(prepared_path),
|
||||
base_res,
|
||||
prepare_target_res,
|
||||
assignment.get("source_start", 0.0),
|
||||
assignment.get("source_end"),
|
||||
25,
|
||||
@@ -472,10 +476,14 @@ async def _process_video_generation_inner(task_id: str, req: GenerateRequest, us
|
||||
_update_task(task_id, progress=50, message="正在拼接素材片段...")
|
||||
concat_path = temp_dir / f"{task_id}_concat.mp4"
|
||||
temp_files.append(concat_path)
|
||||
video.concat_videos(
|
||||
[str(p) for p in prepared_segments],
|
||||
prepared_segment_paths = [str(p) for p in prepared_segments if p is not None]
|
||||
if len(prepared_segment_paths) != num_segments:
|
||||
raise RuntimeError("Multi-material: prepared segments mismatch")
|
||||
await _run_blocking(
|
||||
video.concat_videos,
|
||||
prepared_segment_paths,
|
||||
str(concat_path),
|
||||
target_fps=25,
|
||||
25,
|
||||
)
|
||||
|
||||
# ── 第三步:一次 LatentSync 推理 ──
|
||||
@@ -485,7 +493,12 @@ async def _process_video_generation_inner(task_id: str, req: GenerateRequest, us
|
||||
_update_task(task_id, progress=55, message="正在合成唇形 (LatentSync)...")
|
||||
print(f"[LipSync] Multi-material: single LatentSync on concatenated video")
|
||||
try:
|
||||
await lipsync.generate(str(concat_path), str(audio_path), str(lipsync_video_path))
|
||||
await lipsync.generate(
|
||||
str(concat_path),
|
||||
str(audio_path),
|
||||
str(lipsync_video_path),
|
||||
model_mode=req.lipsync_model,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[LipSync] Failed, fallback to concat without lipsync: {e}")
|
||||
import shutil
|
||||
@@ -519,18 +532,22 @@ async def _process_video_generation_inner(task_id: str, req: GenerateRequest, us
|
||||
single_source_end = req.custom_assignments[0].source_end
|
||||
|
||||
_update_task(task_id, progress=20, message="正在准备素材片段...")
|
||||
audio_dur = video._get_duration(str(audio_path))
|
||||
audio_dur = await _run_blocking(video._get_duration, str(audio_path))
|
||||
if audio_dur <= 0:
|
||||
audio_dur = 30.0
|
||||
single_res = await _run_blocking(video.get_resolution, str(input_material_path))
|
||||
single_target_res = None if single_res == target_resolution else target_resolution
|
||||
prepared_single_path = temp_dir / f"{task_id}_prepared_single.mp4"
|
||||
temp_files.append(prepared_single_path)
|
||||
video.prepare_segment(
|
||||
await _run_blocking(
|
||||
video.prepare_segment,
|
||||
str(input_material_path),
|
||||
audio_dur,
|
||||
str(prepared_single_path),
|
||||
target_resolution=target_resolution,
|
||||
source_start=single_source_start,
|
||||
source_end=single_source_end,
|
||||
single_target_res,
|
||||
single_source_start,
|
||||
single_source_end,
|
||||
None,
|
||||
)
|
||||
input_material_path = prepared_single_path
|
||||
|
||||
@@ -543,7 +560,18 @@ async def _process_video_generation_inner(task_id: str, req: GenerateRequest, us
|
||||
if is_ready:
|
||||
print(f"[LipSync] Starting LatentSync inference...")
|
||||
_update_task(task_id, progress=35, message="正在运行 LatentSync 推理...")
|
||||
await lipsync.generate(str(input_material_path), str(audio_path), str(lipsync_video_path))
|
||||
try:
|
||||
await lipsync.generate(
|
||||
str(input_material_path),
|
||||
str(audio_path),
|
||||
str(lipsync_video_path),
|
||||
model_mode=req.lipsync_model,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[LipSync] Failed on single-material, fallback to prepared video: {e}")
|
||||
_update_task(task_id, message="唇形同步失败,使用原始视频...")
|
||||
import shutil
|
||||
shutil.copy(str(input_material_path), str(lipsync_video_path))
|
||||
else:
|
||||
print(f"[LipSync] LatentSync not ready, copying original video")
|
||||
_update_task(task_id, message="唇形同步不可用,使用原始视频...")
|
||||
@@ -564,6 +592,7 @@ async def _process_video_generation_inner(task_id: str, req: GenerateRequest, us
|
||||
final_audio_path = audio_path
|
||||
_whisper_task = None
|
||||
_bgm_task = None
|
||||
mix_output_path: Optional[Path] = None
|
||||
|
||||
# 单素材模式下 Whisper 尚未执行,这里与 BGM 并行启动
|
||||
need_whisper = not is_multi and req.enable_subtitles and captions_path is None
|
||||
@@ -604,10 +633,8 @@ async def _process_video_generation_inner(task_id: str, req: GenerateRequest, us
|
||||
|
||||
async def _run_bgm():
|
||||
_update_task(task_id, message="正在合成背景音乐...", progress=86)
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
await _run_blocking(
|
||||
video.mix_audio,
|
||||
_voice_path,
|
||||
_bgm_path,
|
||||
@@ -633,7 +660,7 @@ async def _process_video_generation_inner(task_id: str, req: GenerateRequest, us
|
||||
captions_path = None
|
||||
result_idx += 1
|
||||
if _bgm_task is not None:
|
||||
if results[result_idx]:
|
||||
if results[result_idx] and mix_output_path is not None:
|
||||
final_audio_path = mix_output_path
|
||||
|
||||
|
||||
@@ -705,14 +732,19 @@ async def _process_video_generation_inner(task_id: str, req: GenerateRequest, us
|
||||
|
||||
final_output_local_path = temp_dir / f"{task_id}_output.mp4"
|
||||
temp_files.append(final_output_local_path)
|
||||
needs_audio_compose = str(final_audio_path) != str(audio_path)
|
||||
|
||||
if use_remotion:
|
||||
_update_task(task_id, message="正在合成视频 (Remotion)...", progress=87)
|
||||
remotion_input_path = lipsync_video_path
|
||||
|
||||
composed_video_path = temp_dir / f"{task_id}_composed.mp4"
|
||||
temp_files.append(composed_video_path)
|
||||
|
||||
await video.compose(str(lipsync_video_path), str(final_audio_path), str(composed_video_path))
|
||||
if needs_audio_compose:
|
||||
composed_video_path = temp_dir / f"{task_id}_composed.mp4"
|
||||
temp_files.append(composed_video_path)
|
||||
await video.compose(str(lipsync_video_path), str(final_audio_path), str(composed_video_path))
|
||||
remotion_input_path = composed_video_path
|
||||
else:
|
||||
logger.info("[Pipeline] Audio unchanged, skip pre-Remotion compose")
|
||||
|
||||
remotion_health = await remotion_service.check_health()
|
||||
if remotion_health.get("ready"):
|
||||
@@ -729,7 +761,7 @@ async def _process_video_generation_inner(task_id: str, req: GenerateRequest, us
|
||||
title_duration = max(0.5, min(float(req.title_duration or 4.0), 30.0))
|
||||
|
||||
await remotion_service.render(
|
||||
video_path=str(composed_video_path),
|
||||
video_path=str(remotion_input_path),
|
||||
output_path=str(final_output_local_path),
|
||||
captions_path=str(captions_path) if captions_path else None,
|
||||
title=req.title,
|
||||
@@ -747,15 +779,18 @@ async def _process_video_generation_inner(task_id: str, req: GenerateRequest, us
|
||||
except Exception as e:
|
||||
logger.warning(f"Remotion render failed, using FFmpeg fallback: {e}")
|
||||
import shutil
|
||||
shutil.copy(str(composed_video_path), final_output_local_path)
|
||||
shutil.copy(str(remotion_input_path), str(final_output_local_path))
|
||||
else:
|
||||
logger.warning(f"Remotion not ready: {remotion_health.get('error')}, using FFmpeg")
|
||||
import shutil
|
||||
shutil.copy(str(composed_video_path), final_output_local_path)
|
||||
shutil.copy(str(remotion_input_path), str(final_output_local_path))
|
||||
else:
|
||||
_update_task(task_id, message="正在合成最终视频...", progress=90)
|
||||
|
||||
await video.compose(str(lipsync_video_path), str(final_audio_path), str(final_output_local_path))
|
||||
if needs_audio_compose:
|
||||
await video.compose(str(lipsync_video_path), str(final_audio_path), str(final_output_local_path))
|
||||
else:
|
||||
import shutil
|
||||
shutil.copy(str(lipsync_video_path), str(final_output_local_path))
|
||||
|
||||
total_time = time.time() - start_time
|
||||
|
||||
|
||||
@@ -11,12 +11,12 @@ import asyncio
|
||||
import httpx
|
||||
from pathlib import Path
|
||||
from loguru import logger
|
||||
from typing import Optional
|
||||
from typing import Optional, Literal
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class LipSyncService:
|
||||
class LipSyncService:
|
||||
"""唇形同步服务 - LatentSync 1.6 + MuseTalk 1.5 混合方案"""
|
||||
|
||||
def __init__(self):
|
||||
@@ -121,47 +121,43 @@ class LipSyncService:
|
||||
logger.warning(f"⚠️ 视频循环异常: {e}")
|
||||
return video_path
|
||||
|
||||
async def generate(
|
||||
self,
|
||||
video_path: str,
|
||||
audio_path: str,
|
||||
output_path: str,
|
||||
fps: int = 25
|
||||
) -> str:
|
||||
"""生成唇形同步视频"""
|
||||
logger.info(f"🎬 唇形同步任务: {Path(video_path).name} + {Path(audio_path).name}")
|
||||
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if self.use_local:
|
||||
return await self._local_generate(video_path, audio_path, output_path, fps)
|
||||
else:
|
||||
return await self._remote_generate(video_path, audio_path, output_path, fps)
|
||||
async def generate(
|
||||
self,
|
||||
video_path: str,
|
||||
audio_path: str,
|
||||
output_path: str,
|
||||
fps: int = 25,
|
||||
model_mode: Literal["default", "fast", "advanced"] = "default",
|
||||
) -> str:
|
||||
"""生成唇形同步视频"""
|
||||
logger.info(f"🎬 唇形同步任务: {Path(video_path).name} + {Path(audio_path).name}")
|
||||
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
normalized_mode: Literal["default", "fast", "advanced"] = model_mode
|
||||
if normalized_mode not in ("default", "fast", "advanced"):
|
||||
normalized_mode = "default"
|
||||
logger.info(f"🧠 Lipsync 模式: {normalized_mode}")
|
||||
|
||||
if self.use_local:
|
||||
return await self._local_generate(video_path, audio_path, output_path, fps, normalized_mode)
|
||||
else:
|
||||
return await self._remote_generate(video_path, audio_path, output_path, fps, normalized_mode)
|
||||
|
||||
async def _local_generate(
|
||||
self,
|
||||
video_path: str,
|
||||
audio_path: str,
|
||||
output_path: str,
|
||||
fps: int
|
||||
) -> str:
|
||||
"""使用 subprocess 调用 LatentSync conda 环境"""
|
||||
|
||||
# 检查前置条件
|
||||
if not self._check_conda_env():
|
||||
logger.warning("⚠️ Conda 环境不可用,使用 Fallback")
|
||||
shutil.copy(video_path, output_path)
|
||||
return output_path
|
||||
|
||||
if not self._check_weights():
|
||||
logger.warning("⚠️ 模型权重不存在,使用 Fallback")
|
||||
shutil.copy(video_path, output_path)
|
||||
return output_path
|
||||
|
||||
logger.info("⏳ 等待 GPU 资源 (排队中)...")
|
||||
async with self._lock:
|
||||
# 使用临时目录存放中间文件
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir = Path(tmpdir)
|
||||
async def _local_generate(
|
||||
self,
|
||||
video_path: str,
|
||||
audio_path: str,
|
||||
output_path: str,
|
||||
fps: int,
|
||||
model_mode: Literal["default", "fast", "advanced"],
|
||||
) -> str:
|
||||
"""使用 subprocess 调用 LatentSync conda 环境"""
|
||||
|
||||
logger.info("⏳ 等待 GPU 资源 (排队中)...")
|
||||
async with self._lock:
|
||||
# 使用临时目录存放中间文件
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir = Path(tmpdir)
|
||||
|
||||
# 获取音频和视频时长
|
||||
audio_duration = self._get_media_duration(audio_path)
|
||||
@@ -176,24 +172,53 @@ class LipSyncService:
|
||||
str(looped_video),
|
||||
audio_duration
|
||||
)
|
||||
else:
|
||||
actual_video_path = video_path
|
||||
|
||||
# 混合路由: 长视频走 MuseTalk,短视频走 LatentSync
|
||||
if audio_duration and audio_duration >= settings.LIPSYNC_DURATION_THRESHOLD:
|
||||
logger.info(
|
||||
f"🔄 音频 {audio_duration:.1f}s >= {settings.LIPSYNC_DURATION_THRESHOLD}s,路由到 MuseTalk"
|
||||
)
|
||||
musetalk_result = await self._call_musetalk_server(
|
||||
actual_video_path, audio_path, output_path
|
||||
)
|
||||
if musetalk_result:
|
||||
return musetalk_result
|
||||
logger.warning("⚠️ MuseTalk 不可用,回退到 LatentSync(长视频,会较慢)")
|
||||
|
||||
if self.use_server:
|
||||
# 模式 A: 调用常驻服务 (加速模式)
|
||||
return await self._call_persistent_server(actual_video_path, audio_path, output_path)
|
||||
else:
|
||||
actual_video_path = video_path
|
||||
|
||||
# 模型路由
|
||||
force_musetalk = model_mode == "fast"
|
||||
force_latentsync = model_mode == "advanced"
|
||||
auto_to_musetalk = (
|
||||
model_mode == "default"
|
||||
and audio_duration is not None
|
||||
and audio_duration >= settings.LIPSYNC_DURATION_THRESHOLD
|
||||
)
|
||||
|
||||
if force_musetalk:
|
||||
logger.info("⚡ 强制快速模型:MuseTalk")
|
||||
musetalk_result = await self._call_musetalk_server(
|
||||
actual_video_path, audio_path, output_path
|
||||
)
|
||||
if musetalk_result:
|
||||
return musetalk_result
|
||||
logger.warning("⚠️ MuseTalk 不可用,快速模型回退到 LatentSync")
|
||||
elif auto_to_musetalk:
|
||||
logger.info(
|
||||
f"🔄 音频 {audio_duration:.1f}s >= {settings.LIPSYNC_DURATION_THRESHOLD}s,路由到 MuseTalk"
|
||||
)
|
||||
musetalk_result = await self._call_musetalk_server(
|
||||
actual_video_path, audio_path, output_path
|
||||
)
|
||||
if musetalk_result:
|
||||
return musetalk_result
|
||||
logger.warning("⚠️ MuseTalk 不可用,回退到 LatentSync(长视频,会较慢)")
|
||||
elif force_latentsync:
|
||||
logger.info("🎯 强制高级模型:LatentSync")
|
||||
|
||||
# 检查 LatentSync 前置条件(仅在需要回退或使用 LatentSync 时)
|
||||
if not self._check_conda_env():
|
||||
logger.warning("⚠️ Conda 环境不可用,使用 Fallback")
|
||||
shutil.copy(video_path, output_path)
|
||||
return output_path
|
||||
|
||||
if not self._check_weights():
|
||||
logger.warning("⚠️ 模型权重不存在,使用 Fallback")
|
||||
shutil.copy(video_path, output_path)
|
||||
return output_path
|
||||
|
||||
if self.use_server:
|
||||
# 模式 A: 调用常驻服务 (加速模式)
|
||||
return await self._call_persistent_server(actual_video_path, audio_path, output_path)
|
||||
|
||||
logger.info("🔄 调用 LatentSync 推理 (subprocess)...")
|
||||
|
||||
@@ -388,15 +413,18 @@ class LipSyncService:
|
||||
"请确保 LatentSync 服务已启动 (cd models/LatentSync && python scripts/server.py)"
|
||||
)
|
||||
|
||||
async def _remote_generate(
|
||||
self,
|
||||
video_path: str,
|
||||
audio_path: str,
|
||||
output_path: str,
|
||||
fps: int
|
||||
) -> str:
|
||||
"""调用远程 LatentSync API 服务"""
|
||||
logger.info(f"📡 调用远程 API: {self.api_url}")
|
||||
async def _remote_generate(
|
||||
self,
|
||||
video_path: str,
|
||||
audio_path: str,
|
||||
output_path: str,
|
||||
fps: int,
|
||||
model_mode: Literal["default", "fast", "advanced"],
|
||||
) -> str:
|
||||
"""调用远程 LatentSync API 服务"""
|
||||
if model_mode == "fast":
|
||||
logger.warning("⚠️ 远程模式未接入 MuseTalk,快速模型将使用远程 LatentSync")
|
||||
logger.info(f"📡 调用远程 API: {self.api_url}")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=600.0) as client:
|
||||
|
||||
@@ -21,16 +21,22 @@ from .uploader.xiaohongshu_uploader import XiaohongshuUploader
|
||||
from .uploader.weixin_uploader import WeixinUploader
|
||||
|
||||
|
||||
class PublishService:
|
||||
"""Social media publishing service (with user isolation)"""
|
||||
class PublishService:
|
||||
"""Social media publishing service (with user isolation)"""
|
||||
|
||||
# 支持的平台配置
|
||||
PLATFORMS: Dict[str, Dict[str, Any]] = {
|
||||
"douyin": {"name": "抖音", "url": "https://creator.douyin.com/", "enabled": True},
|
||||
"weixin": {"name": "微信视频号", "url": "https://channels.weixin.qq.com/", "enabled": True},
|
||||
"bilibili": {"name": "B站", "url": "https://member.bilibili.com/platform/upload/video/frame", "enabled": True},
|
||||
"xiaohongshu": {"name": "小红书", "url": "https://creator.xiaohongshu.com/", "enabled": True},
|
||||
}
|
||||
PLATFORMS: Dict[str, Dict[str, Any]] = {
|
||||
"douyin": {"name": "抖音", "url": "https://creator.douyin.com/", "enabled": True},
|
||||
"weixin": {"name": "微信视频号", "url": "https://channels.weixin.qq.com/", "enabled": True},
|
||||
"bilibili": {"name": "B站", "url": "https://member.bilibili.com/platform/upload/video/frame", "enabled": True},
|
||||
"xiaohongshu": {"name": "小红书", "url": "https://creator.xiaohongshu.com/", "enabled": True},
|
||||
}
|
||||
|
||||
COOKIE_DOMAINS: Dict[str, str] = {
|
||||
"douyin": ".douyin.com",
|
||||
"weixin": ".weixin.qq.com",
|
||||
"xiaohongshu": ".xiaohongshu.com",
|
||||
}
|
||||
|
||||
def __init__(self) -> None:
|
||||
# 存储活跃的登录会话,用于跟踪登录状态
|
||||
@@ -185,15 +191,16 @@ class PublishService:
|
||||
description=description,
|
||||
user_id=user_id,
|
||||
)
|
||||
elif platform == "xiaohongshu":
|
||||
uploader = XiaohongshuUploader(
|
||||
title=title,
|
||||
file_path=local_video_path,
|
||||
tags=tags,
|
||||
publish_date=publish_time,
|
||||
account_file=str(account_file),
|
||||
description=description
|
||||
)
|
||||
elif platform == "xiaohongshu":
|
||||
uploader = XiaohongshuUploader(
|
||||
title=title,
|
||||
file_path=local_video_path,
|
||||
tags=tags,
|
||||
publish_date=publish_time,
|
||||
account_file=str(account_file),
|
||||
description=description,
|
||||
user_id=user_id,
|
||||
)
|
||||
elif platform == "weixin":
|
||||
uploader = WeixinUploader(
|
||||
title=title,
|
||||
@@ -330,48 +337,88 @@ class PublishService:
|
||||
logger.exception(f"[登出] 失败: {e}")
|
||||
return {"success": False, "message": f"注销失败: {str(e)}"}
|
||||
|
||||
async def save_cookie_string(self, platform: str, cookie_string: str, user_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
保存从客户端浏览器提取的Cookie字符串
|
||||
async def save_cookie_string(self, platform: str, cookie_string: str, user_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
保存从客户端浏览器提取的Cookie字符串
|
||||
|
||||
Args:
|
||||
platform: 平台ID
|
||||
cookie_string: document.cookie 格式的Cookie字符串
|
||||
user_id: 用户 ID (用于 Cookie 隔离)
|
||||
"""
|
||||
try:
|
||||
account_file = self._get_cookie_path(platform, user_id)
|
||||
|
||||
# 解析Cookie字符串
|
||||
cookie_dict = {}
|
||||
for item in cookie_string.split('; '):
|
||||
if '=' in item:
|
||||
name, value = item.split('=', 1)
|
||||
cookie_dict[name] = value
|
||||
|
||||
# 对B站进行特殊处理
|
||||
if platform == "bilibili":
|
||||
bilibili_cookies = {}
|
||||
required_fields = ['SESSDATA', 'bili_jct', 'DedeUserID', 'DedeUserID__ckMd5']
|
||||
"""
|
||||
try:
|
||||
if platform not in self.PLATFORMS:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"不支持的平台: {platform}",
|
||||
}
|
||||
|
||||
account_file = self._get_cookie_path(platform, user_id)
|
||||
|
||||
# 解析Cookie字符串
|
||||
cookie_dict: Dict[str, str] = {}
|
||||
for item in cookie_string.split(';'):
|
||||
item = item.strip()
|
||||
if not item:
|
||||
continue
|
||||
if '=' in item:
|
||||
name, value = item.split('=', 1)
|
||||
cookie_dict[name.strip()] = value.strip()
|
||||
|
||||
if not cookie_dict:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Cookie 为空,请确认已完成登录",
|
||||
}
|
||||
|
||||
# 对B站进行特殊处理
|
||||
if platform == "bilibili":
|
||||
bilibili_cookies = {}
|
||||
required_fields = ['SESSDATA', 'bili_jct', 'DedeUserID', 'DedeUserID__ckMd5']
|
||||
|
||||
for field in required_fields:
|
||||
if field in cookie_dict:
|
||||
bilibili_cookies[field] = cookie_dict[field]
|
||||
|
||||
if len(bilibili_cookies) < 3:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Cookie不完整,请确保已登录"
|
||||
}
|
||||
|
||||
cookie_dict = bilibili_cookies
|
||||
|
||||
# 确保目录存在
|
||||
account_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 保存Cookie
|
||||
with open(account_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(cookie_dict, f, indent=2)
|
||||
if len(bilibili_cookies) < 3:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Cookie不完整,请确保已登录"
|
||||
}
|
||||
payload: Any = bilibili_cookies
|
||||
else:
|
||||
cookie_domain = self.COOKIE_DOMAINS.get(platform, "")
|
||||
if not cookie_domain:
|
||||
platform_url = self.PLATFORMS.get(platform, {}).get("url", "")
|
||||
host = re.sub(r"^https?://", "", platform_url).strip("/")
|
||||
cookie_domain = f".{host}" if host else ""
|
||||
|
||||
storage_cookies = []
|
||||
for name, value in cookie_dict.items():
|
||||
if not name:
|
||||
continue
|
||||
storage_cookies.append({
|
||||
"name": name,
|
||||
"value": value,
|
||||
"domain": cookie_domain,
|
||||
"path": "/",
|
||||
"httpOnly": False,
|
||||
"secure": True,
|
||||
"sameSite": "Lax",
|
||||
"expires": -1,
|
||||
})
|
||||
|
||||
payload = {
|
||||
"cookies": storage_cookies,
|
||||
"origins": [],
|
||||
}
|
||||
|
||||
# 确保目录存在
|
||||
account_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 保存Cookie
|
||||
with open(account_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(payload, f, indent=2)
|
||||
|
||||
logger.success(f"[登录] {platform} Cookie已保存 (user: {user_id or 'legacy'})")
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any, List, Sequence, Mapping, Union
|
||||
from playwright.async_api import async_playwright, Page, Frame, BrowserContext, Browser, Playwright as PW
|
||||
from urllib.parse import unquote_to_bytes
|
||||
from playwright.async_api import async_playwright, Page, Frame, BrowserContext, Browser, Playwright as PW, TimeoutError as PlaywrightTimeoutError
|
||||
from loguru import logger
|
||||
from app.core.config import settings
|
||||
|
||||
@@ -65,10 +66,16 @@ class QRLoginService:
|
||||
"xiaohongshu": {
|
||||
"url": "https://creator.xiaohongshu.com/",
|
||||
"qr_selectors": [
|
||||
".login-box-container img.css-1lhmg90",
|
||||
".login-box-container .css-dvxtzn img",
|
||||
".login-box-container img",
|
||||
"div[class*='login-box'] img",
|
||||
".qrcode img",
|
||||
"img[alt*='二维码']",
|
||||
"canvas.qr-code",
|
||||
"img[class*='qr']"
|
||||
"img[class*='qr']",
|
||||
"img[src*='qrcode']",
|
||||
"img[src*='qr']"
|
||||
],
|
||||
"success_indicator": "https://creator.xiaohongshu.com/publish"
|
||||
},
|
||||
@@ -109,6 +116,103 @@ class QRLoginService:
|
||||
ratio = width / height
|
||||
return 0.75 <= ratio <= 1.33
|
||||
|
||||
def _data_url_to_base64(self, data_url: str) -> Optional[str]:
|
||||
if not data_url or "," not in data_url:
|
||||
return None
|
||||
header, payload = data_url.split(",", 1)
|
||||
header_lower = header.lower()
|
||||
if not header_lower.startswith("data:image/png"):
|
||||
return None
|
||||
if ";base64" in header:
|
||||
return payload
|
||||
try:
|
||||
raw = unquote_to_bytes(payload)
|
||||
return base64.b64encode(raw).decode()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def _try_export_qr_data_url(self, qr_element) -> Optional[str]:
|
||||
"""优先导出元素原图,避免截图带来的缩放/裁切损失。"""
|
||||
try:
|
||||
data_url = await qr_element.evaluate("""async (el) => {
|
||||
const tag = (el.tagName || '').toLowerCase();
|
||||
|
||||
if (tag === 'canvas') {
|
||||
try {
|
||||
return el.toDataURL('image/png');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (tag === 'img') {
|
||||
const src = el.currentSrc || el.src || '';
|
||||
if (!src) return null;
|
||||
|
||||
if (src.startsWith('data:image/png')) {
|
||||
return src;
|
||||
}
|
||||
|
||||
if (src.startsWith('blob:')) {
|
||||
try {
|
||||
const resp = await fetch(src);
|
||||
const blob = await resp.blob();
|
||||
return await new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(typeof reader.result === 'string' ? reader.result : null);
|
||||
reader.onerror = () => resolve(null);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}""")
|
||||
|
||||
if not data_url:
|
||||
return None
|
||||
|
||||
return self._data_url_to_base64(data_url)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def _screenshot_qr_base64(self, page: Page, qr_element) -> Optional[str]:
|
||||
try:
|
||||
if self.platform == "weixin":
|
||||
bbox = await qr_element.bounding_box()
|
||||
viewport = page.viewport_size or {"width": 1920, "height": 1080}
|
||||
if bbox:
|
||||
pad = max(16, int(min(bbox.get("width", 0), bbox.get("height", 0)) * 0.08))
|
||||
x = max(0.0, bbox.get("x", 0.0) - pad)
|
||||
y = max(0.0, bbox.get("y", 0.0) - pad)
|
||||
max_width = float(viewport.get("width", 1920))
|
||||
max_height = float(viewport.get("height", 1080))
|
||||
width = min(max_width - x, bbox.get("width", 0.0) + pad * 2)
|
||||
height = min(max_height - y, bbox.get("height", 0.0) + pad * 2)
|
||||
if width > 8 and height > 8:
|
||||
clipped = await page.screenshot(
|
||||
clip={"x": x, "y": y, "width": width, "height": height},
|
||||
type="png",
|
||||
)
|
||||
return base64.b64encode(clipped).decode()
|
||||
|
||||
screenshot = await qr_element.screenshot(type="png")
|
||||
return base64.b64encode(screenshot).decode()
|
||||
except Exception as e:
|
||||
logger.warning(f"[{self.platform}] QR截图失败: {e}")
|
||||
return None
|
||||
|
||||
async def _capture_qr_base64(self, page: Page, qr_element) -> Optional[str]:
|
||||
data_url_base64 = await self._try_export_qr_data_url(qr_element)
|
||||
if data_url_base64:
|
||||
return data_url_base64
|
||||
return await self._screenshot_qr_base64(page, qr_element)
|
||||
|
||||
async def _pick_best_candidate(self, locator, min_side: int = 100):
|
||||
best = None
|
||||
best_area = 0
|
||||
@@ -160,6 +264,88 @@ class QRLoginService:
|
||||
|
||||
return await self._find_qr_in_frames(page, selectors, min_side=min_side)
|
||||
|
||||
async def _ensure_xiaohongshu_qr_mode(self, page: Page) -> None:
|
||||
"""小红书登录页默认短信登录,需要先切到扫码登录。"""
|
||||
if self.platform != "xiaohongshu":
|
||||
return
|
||||
|
||||
try:
|
||||
for _ in range(3):
|
||||
sms_mode = False
|
||||
try:
|
||||
sms_mode = await page.locator("input[placeholder*='手机号']").first.is_visible(timeout=800)
|
||||
except Exception:
|
||||
sms_mode = False
|
||||
|
||||
if not sms_mode:
|
||||
return
|
||||
|
||||
clicked = False
|
||||
|
||||
# 先尝试稳定选择器
|
||||
switch_selectors = [
|
||||
"img.css-wemwzq",
|
||||
".login-box-container img[style*='cursor: pointer']",
|
||||
]
|
||||
|
||||
for selector in switch_selectors:
|
||||
try:
|
||||
locator = page.locator(selector)
|
||||
count = await locator.count()
|
||||
for i in range(count):
|
||||
candidate = locator.nth(i)
|
||||
if not await candidate.is_visible():
|
||||
continue
|
||||
bbox = await candidate.bounding_box()
|
||||
if not bbox:
|
||||
continue
|
||||
if bbox.get("width", 0) < 24 or bbox.get("width", 0) > 96:
|
||||
continue
|
||||
if bbox.get("height", 0) < 24 or bbox.get("height", 0) > 96:
|
||||
continue
|
||||
try:
|
||||
await candidate.click(timeout=1200)
|
||||
except Exception:
|
||||
await candidate.evaluate("el => el.click()")
|
||||
clicked = True
|
||||
break
|
||||
if clicked:
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not clicked:
|
||||
# 兜底:在登录卡片右上角找可点击小图标
|
||||
clicked = bool(await page.evaluate("""() => {
|
||||
const phoneInput = Array.from(document.querySelectorAll('input'))
|
||||
.find((el) => (el.placeholder || '').includes('手机号'));
|
||||
const card = document.querySelector('.login-box-container') || phoneInput?.closest('div');
|
||||
if (!card) return false;
|
||||
|
||||
const cardRect = card.getBoundingClientRect();
|
||||
const imgs = Array.from(card.querySelectorAll('img'));
|
||||
for (const img of imgs) {
|
||||
const r = img.getBoundingClientRect();
|
||||
if (r.width < 24 || r.width > 96 || r.height < 24 || r.height > 96) continue;
|
||||
if (r.right < cardRect.right - 90) continue;
|
||||
if (r.top > cardRect.top + 90) continue;
|
||||
const style = getComputedStyle(img);
|
||||
if (style.cursor !== 'pointer') continue;
|
||||
img.click();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}"""))
|
||||
|
||||
if not clicked:
|
||||
logger.warning("[xiaohongshu] 未找到登录方式切换按钮,继续尝试二维码提取")
|
||||
return
|
||||
|
||||
logger.info("[xiaohongshu] 已点击登录方式切换,等待二维码渲染")
|
||||
await asyncio.sleep(1.5)
|
||||
except Exception as e:
|
||||
logger.warning(f"[xiaohongshu] 切换扫码登录模式失败: {e}")
|
||||
|
||||
async def _try_text_strategy_in_frames(self, page: Page):
|
||||
for frame in page.frames:
|
||||
if frame == page.main_frame:
|
||||
@@ -317,12 +503,22 @@ class QRLoginService:
|
||||
|
||||
for url in urls_to_try:
|
||||
logger.info(f"[{self.platform}] 打开登录页: {url}")
|
||||
wait_until = "domcontentloaded" if self.platform == "weixin" else "networkidle"
|
||||
await page.goto(url, wait_until=wait_until)
|
||||
wait_until = "domcontentloaded" if self.platform in ("weixin", "douyin") else "networkidle"
|
||||
try:
|
||||
await page.goto(url, wait_until=wait_until, timeout=30000)
|
||||
except PlaywrightTimeoutError as nav_err:
|
||||
# 抖音页存在长连接,偶发无法满足等待条件;超时后继续尝试提取二维码
|
||||
if self.platform == "douyin":
|
||||
logger.warning(f"[douyin] 页面加载超时,继续尝试提取二维码: {nav_err}")
|
||||
else:
|
||||
raise
|
||||
|
||||
# 等待页面加载
|
||||
await asyncio.sleep(1 if self.platform == "weixin" else 2)
|
||||
|
||||
if self.platform == "xiaohongshu":
|
||||
await self._ensure_xiaohongshu_qr_mode(page)
|
||||
|
||||
# 提取二维码 (并行策略)
|
||||
qr_image = await self._extract_qr_code(page, config["qr_selectors"])
|
||||
if qr_image:
|
||||
@@ -373,8 +569,9 @@ class QRLoginService:
|
||||
el = await page.wait_for_selector(combined_selector, state="visible", timeout=5000)
|
||||
if el:
|
||||
logger.info(f"[{self.platform}] 策略CSS: 匹配成功")
|
||||
screenshot = await el.screenshot()
|
||||
return base64.b64encode(screenshot).decode()
|
||||
qr_base64 = await self._capture_qr_base64(page, el)
|
||||
if qr_base64:
|
||||
return qr_base64
|
||||
except Exception as e:
|
||||
logger.warning(f"[{self.platform}] 策略CSS 失败: {e}")
|
||||
|
||||
@@ -382,8 +579,9 @@ class QRLoginService:
|
||||
qr_element = await self._try_text_strategy(page)
|
||||
if qr_element:
|
||||
try:
|
||||
screenshot = await qr_element.screenshot()
|
||||
return base64.b64encode(screenshot).decode()
|
||||
qr_base64 = await self._capture_qr_base64(page, qr_element)
|
||||
if qr_base64:
|
||||
return qr_base64
|
||||
except Exception as e:
|
||||
logger.warning(f"[{self.platform}] Text策略截图失败: {e}")
|
||||
|
||||
@@ -397,8 +595,9 @@ class QRLoginService:
|
||||
qr_element = await self._try_text_strategy(page)
|
||||
if qr_element:
|
||||
try:
|
||||
screenshot = await qr_element.screenshot()
|
||||
return base64.b64encode(screenshot).decode()
|
||||
qr_base64 = await self._capture_qr_base64(page, qr_element)
|
||||
if qr_base64:
|
||||
return qr_base64
|
||||
except Exception as e:
|
||||
logger.warning(f"[{self.platform}] Text策略截图失败: {e}")
|
||||
qr_element = None
|
||||
@@ -410,12 +609,16 @@ class QRLoginService:
|
||||
el = await page.wait_for_selector(combined_selector, state="visible", timeout=5000)
|
||||
if el:
|
||||
logger.info(f"[{self.platform}] 策略CSS: 匹配成功")
|
||||
screenshot = await el.screenshot()
|
||||
return base64.b64encode(screenshot).decode()
|
||||
qr_base64 = await self._capture_qr_base64(page, el)
|
||||
if qr_base64:
|
||||
return qr_base64
|
||||
except Exception as e:
|
||||
logger.warning(f"[{self.platform}] 策略CSS 失败: {e}")
|
||||
else:
|
||||
# 其他平台 (小红书/微信等):保持原顺序 CSS -> Text
|
||||
if self.platform == "xiaohongshu":
|
||||
await self._ensure_xiaohongshu_qr_mode(page)
|
||||
|
||||
# 策略1: CSS 选择器
|
||||
try:
|
||||
combined_selector = ", ".join(selectors)
|
||||
@@ -432,7 +635,8 @@ class QRLoginService:
|
||||
else:
|
||||
await page.wait_for_selector(combined_selector, state="visible", timeout=5000)
|
||||
locator = page.locator(combined_selector)
|
||||
qr_element = await self._pick_best_candidate(locator, min_side=100)
|
||||
min_side = 120 if self.platform == "xiaohongshu" else 100
|
||||
qr_element = await self._pick_best_candidate(locator, min_side=min_side)
|
||||
if qr_element:
|
||||
logger.info(f"[{self.platform}] 策略1(CSS): 匹配成功")
|
||||
except Exception as e:
|
||||
@@ -448,8 +652,9 @@ class QRLoginService:
|
||||
# 如果找到元素,截图返回
|
||||
if qr_element:
|
||||
try:
|
||||
screenshot = await qr_element.screenshot()
|
||||
return base64.b64encode(screenshot).decode()
|
||||
qr_base64 = await self._capture_qr_base64(page, qr_element)
|
||||
if qr_base64:
|
||||
return qr_base64
|
||||
except Exception as e:
|
||||
logger.error(f"[{self.platform}] 截图失败: {e}")
|
||||
|
||||
@@ -465,6 +670,8 @@ class QRLoginService:
|
||||
keywords = [
|
||||
"扫码登录",
|
||||
"二维码",
|
||||
"APP扫一扫登录",
|
||||
"可用小红书扫码",
|
||||
"打开抖音",
|
||||
"抖音APP",
|
||||
"使用APP扫码",
|
||||
@@ -483,7 +690,7 @@ class QRLoginService:
|
||||
for _ in range(5):
|
||||
parent = parent.locator("..")
|
||||
candidates = parent.locator("img, canvas")
|
||||
min_side = 120 if self.platform == "weixin" else 100
|
||||
min_side = 120 if self.platform in ("weixin", "xiaohongshu") else 100
|
||||
best = await self._pick_best_candidate(candidates, min_side=min_side)
|
||||
if best:
|
||||
logger.info(f"[{self.platform}] 策略Text: 成功")
|
||||
@@ -554,6 +761,22 @@ class QRLoginService:
|
||||
await self._save_cookies(final)
|
||||
break
|
||||
|
||||
# ── 小红书特殊:扫码后常跳转到 /new/home,不一定命中 success_indicator ──
|
||||
if self.platform == "xiaohongshu":
|
||||
lowered_url = current_url.lower()
|
||||
xhs_logged_in = (
|
||||
lowered_url.startswith("https://creator.xiaohongshu.com/new/")
|
||||
or "/publish/publish" in lowered_url
|
||||
or "/publish/success" in lowered_url
|
||||
) and "/login" not in lowered_url
|
||||
if xhs_logged_in:
|
||||
logger.success(f"[xiaohongshu] 登录成功!URL={current_url[:120]}")
|
||||
self.login_success = True
|
||||
await asyncio.sleep(2)
|
||||
final = [dict(c) for c in await self.context.cookies()]
|
||||
await self._save_cookies(final)
|
||||
break
|
||||
|
||||
# ── 抖音:API 拦截到 redirect_url → 直接导航 ──
|
||||
if self.platform == "douyin" and self._qr_api_confirmed and self._qr_redirect_url:
|
||||
logger.info(f"[douyin] 导航到 redirect_url...")
|
||||
|
||||
@@ -847,13 +847,22 @@ class WeixinUploader(BaseUploader):
|
||||
logger.info(text)
|
||||
self._append_debug_log(text)
|
||||
return True
|
||||
text = "[weixin][file_input] empty"
|
||||
logger.warning(text)
|
||||
self._append_debug_log(text)
|
||||
await asyncio.sleep(0.5)
|
||||
if await self._is_upload_in_progress(page):
|
||||
upload_started = False
|
||||
for _ in range(3):
|
||||
await asyncio.sleep(0.4)
|
||||
if await self._is_upload_in_progress(page):
|
||||
upload_started = True
|
||||
break
|
||||
if upload_started:
|
||||
logger.info("[weixin] upload started after file input set")
|
||||
return True
|
||||
|
||||
text = "[weixin][file_input] empty after set_input_files and no upload signal"
|
||||
if attempt + 1 >= self.MAX_CLICK_RETRIES:
|
||||
logger.warning(text)
|
||||
else:
|
||||
logger.info(text)
|
||||
self._append_debug_log(text)
|
||||
except Exception as e:
|
||||
logger.warning(f"[weixin] failed to read file input info: {e}")
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,201 +1,775 @@
|
||||
"""
|
||||
Xiaohongshu (小红书) uploader using Playwright
|
||||
Based on social-auto-upload implementation
|
||||
"""
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict, Any
|
||||
import asyncio
|
||||
|
||||
from playwright.async_api import Playwright, async_playwright
|
||||
from loguru import logger
|
||||
|
||||
from .base_uploader import BaseUploader
|
||||
from .cookie_utils import set_init_script
|
||||
|
||||
|
||||
class XiaohongshuUploader(BaseUploader):
|
||||
"""Xiaohongshu video uploader using Playwright"""
|
||||
|
||||
# 超时配置 (秒)
|
||||
UPLOAD_TIMEOUT = 300 # 视频上传超时
|
||||
PUBLISH_TIMEOUT = 120 # 发布检测超时
|
||||
POLL_INTERVAL = 1 # 轮询间隔
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
title: str,
|
||||
file_path: str,
|
||||
tags: List[str],
|
||||
publish_date: Optional[datetime] = None,
|
||||
account_file: Optional[str] = None,
|
||||
description: str = ""
|
||||
):
|
||||
super().__init__(title, file_path, tags, publish_date, account_file, description)
|
||||
self.upload_url = "https://creator.xiaohongshu.com/publish/publish?from=homepage&target=video"
|
||||
|
||||
async def set_schedule_time(self, page, publish_date):
|
||||
"""Set scheduled publish time"""
|
||||
try:
|
||||
logger.info("[小红书] 正在设置定时发布时间...")
|
||||
|
||||
# Click "定时发布" label
|
||||
label_element = page.locator("label:has-text('定时发布')")
|
||||
await label_element.click()
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Format time
|
||||
publish_date_hour = publish_date.strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
# Fill datetime input
|
||||
await page.locator('.el-input__inner[placeholder="选择日期和时间"]').click()
|
||||
await page.keyboard.press("Control+KeyA")
|
||||
await page.keyboard.type(str(publish_date_hour))
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await asyncio.sleep(1)
|
||||
logger.info(f"[小红书] 已设置定时发布: {publish_date_hour}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[小红书] 设置定时发布失败: {e}")
|
||||
|
||||
async def upload(self, playwright: Playwright) -> dict:
|
||||
"""Main upload logic with guaranteed resource cleanup"""
|
||||
browser = None
|
||||
context = None
|
||||
try:
|
||||
# Launch browser (headless for server deployment)
|
||||
browser = await playwright.chromium.launch(headless=True)
|
||||
context = await browser.new_context(
|
||||
viewport={"width": 1600, "height": 900},
|
||||
storage_state=self.account_file
|
||||
)
|
||||
context = await set_init_script(context)
|
||||
|
||||
page = await context.new_page()
|
||||
|
||||
# Go to upload page
|
||||
await page.goto(self.upload_url)
|
||||
logger.info(f"[小红书] 正在上传: {self.file_path.name}")
|
||||
|
||||
# Upload video file
|
||||
await page.locator("div[class^='upload-content'] input[class='upload-input']").set_input_files(str(self.file_path))
|
||||
|
||||
# Wait for upload to complete (with timeout)
|
||||
import time
|
||||
upload_start = time.time()
|
||||
while time.time() - upload_start < self.UPLOAD_TIMEOUT:
|
||||
try:
|
||||
upload_input = await page.wait_for_selector('input.upload-input', timeout=3000)
|
||||
preview_new = await upload_input.query_selector(
|
||||
'xpath=following-sibling::div[contains(@class, "preview-new")]'
|
||||
)
|
||||
|
||||
if preview_new:
|
||||
stage_elements = await preview_new.query_selector_all('div.stage')
|
||||
upload_success = False
|
||||
|
||||
for stage in stage_elements:
|
||||
text_content = await page.evaluate('(element) => element.textContent', stage)
|
||||
if '上传成功' in text_content:
|
||||
upload_success = True
|
||||
break
|
||||
|
||||
if upload_success:
|
||||
logger.info("[小红书] 检测到上传成功标识")
|
||||
break
|
||||
else:
|
||||
logger.info("[小红书] 未找到上传成功标识,继续等待...")
|
||||
else:
|
||||
logger.info("[小红书] 未找到预览元素,继续等待...")
|
||||
|
||||
await asyncio.sleep(self.POLL_INTERVAL)
|
||||
|
||||
except Exception as e:
|
||||
logger.info(f"[小红书] 检测过程: {str(e)},重新尝试...")
|
||||
await asyncio.sleep(0.5)
|
||||
else:
|
||||
logger.error("[小红书] 视频上传超时")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "视频上传超时",
|
||||
"url": None
|
||||
}
|
||||
|
||||
# Fill title and tags
|
||||
await asyncio.sleep(1)
|
||||
logger.info("[小红书] 正在填充标题和话题...")
|
||||
|
||||
title_container = page.locator('div.plugin.title-container').locator('input.d-text')
|
||||
if await title_container.count():
|
||||
await title_container.fill(self.title[:30])
|
||||
|
||||
# Add tags
|
||||
css_selector = ".tiptap"
|
||||
for tag in self.tags:
|
||||
await page.type(css_selector, "#" + tag)
|
||||
await page.press(css_selector, "Space")
|
||||
|
||||
logger.info(f"[小红书] 总共添加 {len(self.tags)} 个话题")
|
||||
|
||||
# Set scheduled publish time if needed
|
||||
if self.publish_date != 0:
|
||||
await self.set_schedule_time(page, self.publish_date)
|
||||
|
||||
# Click publish button (with timeout)
|
||||
publish_start = time.time()
|
||||
while time.time() - publish_start < self.PUBLISH_TIMEOUT:
|
||||
try:
|
||||
if self.publish_date != 0:
|
||||
await page.locator('button:has-text("定时发布")').click()
|
||||
else:
|
||||
await page.locator('button:has-text("发布")').click()
|
||||
|
||||
await page.wait_for_url(
|
||||
"https://creator.xiaohongshu.com/publish/success?**",
|
||||
timeout=3000
|
||||
)
|
||||
logger.success("[小红书] 视频发布成功")
|
||||
break
|
||||
except Exception:
|
||||
logger.info("[小红书] 视频正在发布中...")
|
||||
await asyncio.sleep(0.5)
|
||||
else:
|
||||
logger.warning("[小红书] 发布检测超时,请手动确认")
|
||||
|
||||
# Save updated cookies
|
||||
await context.storage_state(path=self.account_file)
|
||||
logger.success("[小红书] Cookie 更新完毕")
|
||||
|
||||
await asyncio.sleep(2)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "发布成功,待审核" if self.publish_date == 0 else "已设置定时发布",
|
||||
"url": None
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"[小红书] 上传失败: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"上传失败: {str(e)}",
|
||||
"url": None
|
||||
}
|
||||
finally:
|
||||
# 确保资源释放
|
||||
if context:
|
||||
try:
|
||||
await context.close()
|
||||
except Exception:
|
||||
pass
|
||||
if browser:
|
||||
try:
|
||||
await browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def main(self) -> Dict[str, Any]:
|
||||
"""Execute upload"""
|
||||
async with async_playwright() as playwright:
|
||||
return await self.upload(playwright)
|
||||
"""
|
||||
Xiaohongshu (小红书) uploader using Playwright.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict, Any
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
|
||||
from playwright.async_api import Playwright, async_playwright
|
||||
from loguru import logger
|
||||
|
||||
from .base_uploader import BaseUploader
|
||||
from .cookie_utils import set_init_script
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class XiaohongshuUploader(BaseUploader):
|
||||
"""Xiaohongshu video uploader using Playwright"""
|
||||
|
||||
UPLOAD_TIMEOUT = 420
|
||||
UPLOAD_IDLE_TIMEOUT = 90
|
||||
UPLOAD_SIGNAL_TIMEOUT = 12
|
||||
PUBLISH_TIMEOUT = 120
|
||||
PAGE_READY_TIMEOUT = 60
|
||||
POLL_INTERVAL = 2
|
||||
MAX_CLICK_RETRIES = 3
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
title: str,
|
||||
file_path: str,
|
||||
tags: List[str],
|
||||
publish_date: Optional[datetime] = None,
|
||||
account_file: Optional[str] = None,
|
||||
description: str = "",
|
||||
user_id: Optional[str] = None,
|
||||
):
|
||||
super().__init__(title, file_path, tags, publish_date, account_file, description)
|
||||
self.user_id = user_id
|
||||
self.upload_url = "https://creator.xiaohongshu.com/publish/publish?from=homepage&target=video"
|
||||
self._publish_api_submitted = False
|
||||
self._publish_api_error: Optional[str] = None
|
||||
self._temp_upload_paths: List[Path] = []
|
||||
|
||||
def _track_temp_upload_path(self, path: Path) -> None:
|
||||
self._temp_upload_paths.append(path)
|
||||
|
||||
def _prepare_upload_file(self) -> Path:
|
||||
src = self.file_path
|
||||
if src.suffix:
|
||||
return src
|
||||
|
||||
parent_suffix = Path(src.parent.name).suffix
|
||||
if not parent_suffix:
|
||||
return src
|
||||
|
||||
temp_dir = Path("/tmp/vigent_uploads")
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
target = temp_dir / src.parent.name
|
||||
|
||||
try:
|
||||
if target.exists():
|
||||
target.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
os.link(src, target)
|
||||
logger.info(f"[小红书] using hardlink upload file: {target}")
|
||||
except Exception:
|
||||
try:
|
||||
shutil.copy2(src, target)
|
||||
logger.info(f"[小红书] using copied upload file: {target}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[小红书] 构建带后缀上传文件失败,回退原文件: {e}")
|
||||
return src
|
||||
|
||||
self._track_temp_upload_path(target)
|
||||
return target
|
||||
|
||||
def _cleanup_upload_file(self) -> None:
|
||||
if not self._temp_upload_paths:
|
||||
return
|
||||
|
||||
paths = list(self._temp_upload_paths)
|
||||
self._temp_upload_paths = []
|
||||
for path in paths:
|
||||
try:
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
except Exception as e:
|
||||
logger.warning(f"[小红书] 清理临时上传文件失败: {e}")
|
||||
|
||||
def _resolve_headless_mode(self) -> str:
|
||||
mode = (settings.XIAOHONGSHU_HEADLESS_MODE or "").strip().lower()
|
||||
return mode or "headless-new"
|
||||
|
||||
def _build_launch_options(self) -> Dict[str, Any]:
|
||||
mode = self._resolve_headless_mode()
|
||||
args = [
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
]
|
||||
|
||||
headless = mode not in ("headful", "false", "0", "no")
|
||||
if headless and mode in ("new", "headless-new", "headless_new"):
|
||||
args.append("--headless=new")
|
||||
|
||||
if settings.XIAOHONGSHU_FORCE_SWIFTSHADER or headless:
|
||||
args.extend([
|
||||
"--enable-unsafe-swiftshader",
|
||||
"--use-gl=swiftshader",
|
||||
])
|
||||
|
||||
options: Dict[str, Any] = {"headless": headless, "args": args}
|
||||
chrome_path = (settings.XIAOHONGSHU_CHROME_PATH or "").strip()
|
||||
if chrome_path:
|
||||
if Path(chrome_path).exists():
|
||||
options["executable_path"] = chrome_path
|
||||
else:
|
||||
logger.warning(f"[小红书] XIAOHONGSHU_CHROME_PATH 不存在: {chrome_path}")
|
||||
else:
|
||||
channel = (settings.XIAOHONGSHU_BROWSER_CHANNEL or "").strip()
|
||||
if channel:
|
||||
options["channel"] = channel
|
||||
|
||||
return options
|
||||
|
||||
def _debug_artifacts_enabled(self) -> bool:
|
||||
return bool(settings.DEBUG and settings.XIAOHONGSHU_DEBUG_ARTIFACTS)
|
||||
|
||||
async def _save_debug_screenshot(self, page, name: str) -> None:
|
||||
if not self._debug_artifacts_enabled():
|
||||
return
|
||||
try:
|
||||
debug_dir = Path(__file__).parent.parent.parent / "debug_screenshots"
|
||||
debug_dir.mkdir(exist_ok=True)
|
||||
safe_name = name.replace("/", "_").replace(" ", "_")
|
||||
file_path = debug_dir / f"xiaohongshu_{safe_name}.png"
|
||||
await page.screenshot(path=str(file_path), full_page=True)
|
||||
logger.info(f"[小红书] saved debug screenshot: {file_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[小红书] 保存调试截图失败: {e}")
|
||||
|
||||
def _publish_screenshot_dir(self) -> Path:
|
||||
user_key = re.sub(r"[^A-Za-z0-9_-]", "_", self.user_id or "legacy")[:64] or "legacy"
|
||||
target = settings.PUBLISH_SCREENSHOT_DIR / user_key
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
return target
|
||||
|
||||
async def _save_publish_success_screenshot(self, page) -> Optional[str]:
|
||||
try:
|
||||
timestamp = time.strftime("%Y%m%d_%H%M%S", time.localtime())
|
||||
filename = f"xiaohongshu_success_{timestamp}_{int(time.time() * 1000) % 1000:03d}.png"
|
||||
file_path = self._publish_screenshot_dir() / filename
|
||||
await page.screenshot(path=str(file_path), full_page=False)
|
||||
return f"/api/publish/screenshot/{filename}"
|
||||
except Exception as e:
|
||||
logger.warning(f"[小红书] 保存发布成功截图失败: {e}")
|
||||
return None
|
||||
|
||||
def _attach_publish_listener(self, page) -> None:
|
||||
ignore_tokens = ("report", "collect", "analytics", "monitor", "perf")
|
||||
|
||||
def on_response(response):
|
||||
try:
|
||||
request = response.request
|
||||
if request.method not in ("POST", "PUT"):
|
||||
return
|
||||
|
||||
url = (response.url or "").lower()
|
||||
if "xiaohongshu.com" not in url or "api" not in url:
|
||||
return
|
||||
if not any(token in url for token in ("publish", "note/create", "note/publish", "note/save")):
|
||||
return
|
||||
if any(token in url for token in ignore_tokens):
|
||||
return
|
||||
|
||||
if response.status < 400:
|
||||
self._publish_api_submitted = True
|
||||
logger.info("[小红书][publish] publish API ok")
|
||||
else:
|
||||
self._publish_api_error = f"发布请求失败(HTTP {response.status})"
|
||||
logger.warning(f"[小红书][publish] publish API failed status={response.status}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
page.on("response", on_response)
|
||||
|
||||
async def _is_text_visible(self, page, text: str, exact: bool = False) -> bool:
|
||||
try:
|
||||
return await page.get_by_text(text, exact=exact).first.is_visible()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def _first_existing_locator(self, page, selectors: List[str], require_visible: bool = True):
|
||||
for selector in selectors:
|
||||
locator = page.locator(selector)
|
||||
try:
|
||||
if await locator.count() == 0:
|
||||
continue
|
||||
candidate = locator.first
|
||||
if require_visible and not await candidate.is_visible():
|
||||
continue
|
||||
return candidate
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
async def _is_login_page(self, page) -> bool:
|
||||
url = page.url.lower()
|
||||
if "login" in url or "signin" in url:
|
||||
return True
|
||||
if await self._is_text_visible(page, "扫码登录", exact=False):
|
||||
return True
|
||||
if await self._is_text_visible(page, "立即登录", exact=False):
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _go_to_publish_page(self, page):
|
||||
await page.goto(self.upload_url, wait_until="domcontentloaded", timeout=self.PAGE_READY_TIMEOUT * 1000)
|
||||
await asyncio.sleep(2)
|
||||
return page
|
||||
|
||||
async def _find_file_input(self, page):
|
||||
selectors = [
|
||||
"input[type='file'][accept*='video']",
|
||||
"div[class*='upload'] input[type='file']",
|
||||
"input.upload-input",
|
||||
"input[type='file']",
|
||||
]
|
||||
return await self._first_existing_locator(page, selectors, require_visible=False)
|
||||
|
||||
async def _open_upload_entry(self, page) -> None:
|
||||
selectors = [
|
||||
"button:has-text('上传视频')",
|
||||
"button:has-text('上传')",
|
||||
"div[role='button']:has-text('上传视频')",
|
||||
"div[role='button']:has-text('上传')",
|
||||
"span:has-text('上传视频')",
|
||||
]
|
||||
target = await self._first_existing_locator(page, selectors)
|
||||
if not target:
|
||||
return
|
||||
try:
|
||||
await target.scroll_into_view_if_needed()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await target.click(timeout=2000)
|
||||
except Exception:
|
||||
try:
|
||||
await target.evaluate("el => el.click()")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _is_upload_in_progress(self, page) -> bool:
|
||||
in_progress_texts = [
|
||||
"上传中",
|
||||
"正在上传",
|
||||
"处理中",
|
||||
"视频处理中",
|
||||
"转码中",
|
||||
"请稍候",
|
||||
"上传进度",
|
||||
"校验中",
|
||||
"准备中",
|
||||
]
|
||||
for text in in_progress_texts:
|
||||
if await self._is_text_visible(page, text, exact=False):
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _is_upload_success(self, page) -> bool:
|
||||
success_texts = [
|
||||
"上传成功",
|
||||
"上传完成",
|
||||
"处理完成",
|
||||
"转码完成",
|
||||
"可发布",
|
||||
]
|
||||
for text in success_texts:
|
||||
if await self._is_text_visible(page, text, exact=False):
|
||||
return True
|
||||
return await self._is_publish_button_enabled(page)
|
||||
|
||||
async def _upload_failed_reason(self, page) -> Optional[str]:
|
||||
failure_texts = [
|
||||
"上传失败",
|
||||
"上传异常",
|
||||
"上传出错",
|
||||
"上传超时",
|
||||
"网络异常",
|
||||
]
|
||||
for text in failure_texts:
|
||||
if await self._is_text_visible(page, text, exact=False):
|
||||
return f"上传失败:{text}"
|
||||
return None
|
||||
|
||||
async def _upload_video(self, page) -> bool:
|
||||
page = await self._go_to_publish_page(page)
|
||||
await self._save_debug_screenshot(page, "publish_page")
|
||||
|
||||
upload_path = self._prepare_upload_file()
|
||||
try:
|
||||
upload_size = upload_path.stat().st_size
|
||||
logger.info(
|
||||
f"[小红书][upload_file] path={upload_path} "
|
||||
f"size={upload_size} suffix={upload_path.suffix}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[小红书] 读取上传文件信息失败: {e}")
|
||||
|
||||
for attempt in range(self.MAX_CLICK_RETRIES):
|
||||
file_input = await self._find_file_input(page)
|
||||
if not file_input:
|
||||
await self._open_upload_entry(page)
|
||||
await asyncio.sleep(1)
|
||||
file_input = await self._find_file_input(page)
|
||||
|
||||
if not file_input:
|
||||
logger.info(f"[小红书] 未找到上传文件 input,准备重试 ({attempt + 1}/{self.MAX_CLICK_RETRIES})")
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
|
||||
try:
|
||||
await file_input.set_input_files(str(upload_path))
|
||||
logger.info(f"[小红书] 已设置上传文件: {upload_path.name}")
|
||||
|
||||
try:
|
||||
file_info = await file_input.evaluate(
|
||||
"""
|
||||
(input) => {
|
||||
const file = input && input.files ? input.files[0] : null;
|
||||
if (!file) return null;
|
||||
return { name: file.name, size: file.size, type: file.type };
|
||||
}
|
||||
"""
|
||||
)
|
||||
if file_info:
|
||||
selected_name = str(file_info.get("name") or "")
|
||||
logger.info(
|
||||
"[小红书][file_input] "
|
||||
f"name={selected_name} "
|
||||
f"size={file_info.get('size')} "
|
||||
f"type={file_info.get('type')}"
|
||||
)
|
||||
if upload_path.suffix and selected_name and not selected_name.lower().endswith(upload_path.suffix.lower()):
|
||||
logger.warning(
|
||||
"[小红书] file input 文件名后缀与上传文件不一致,"
|
||||
f"expect=*{upload_path.suffix} actual={selected_name}"
|
||||
)
|
||||
if attempt + 1 < self.MAX_CLICK_RETRIES:
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
await self._save_debug_screenshot(page, "upload_input_name_mismatch")
|
||||
return False
|
||||
|
||||
if not str(file_info.get("type") or "").strip():
|
||||
logger.warning("[小红书] file input MIME 为空,可能影响站点识别")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
signal_detected = False
|
||||
bootstrap_error: Optional[str] = None
|
||||
deadline = time.time() + self.UPLOAD_SIGNAL_TIMEOUT
|
||||
while time.time() < deadline:
|
||||
bootstrap_error = await self._upload_failed_reason(page)
|
||||
if bootstrap_error:
|
||||
break
|
||||
if await self._is_upload_in_progress(page) or await self._is_upload_success(page):
|
||||
signal_detected = True
|
||||
break
|
||||
await asyncio.sleep(0.6)
|
||||
|
||||
if bootstrap_error:
|
||||
logger.warning(f"[小红书] 上传启动阶段失败: {bootstrap_error}")
|
||||
if attempt + 1 < self.MAX_CLICK_RETRIES:
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
return False
|
||||
|
||||
if signal_detected:
|
||||
return True
|
||||
|
||||
logger.info("[小红书] 未立即检测到上传状态,进入后续上传监控")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"[小红书] set_input_files 失败: {e}")
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await self._save_debug_screenshot(page, "upload_input_missing")
|
||||
return False
|
||||
|
||||
async def _wait_for_upload_complete(self, page) -> tuple[bool, str]:
|
||||
start = time.time()
|
||||
idle_start = start
|
||||
while time.time() - start < self.UPLOAD_TIMEOUT:
|
||||
reason = await self._upload_failed_reason(page)
|
||||
if reason:
|
||||
logger.warning(f"[小红书] 上传失败检测: {reason}")
|
||||
return False, reason
|
||||
|
||||
if await self._is_upload_success(page):
|
||||
return True, "上传完成"
|
||||
|
||||
if await self._is_upload_in_progress(page):
|
||||
idle_start = time.time()
|
||||
logger.info("[小红书] 视频上传进行中...")
|
||||
else:
|
||||
if time.time() - idle_start > self.UPLOAD_IDLE_TIMEOUT:
|
||||
await self._save_debug_screenshot(page, "upload_idle_timeout")
|
||||
return False, "未检测到有效上传进度(疑似上传控件未生效)"
|
||||
logger.info("[小红书] 等待上传状态...")
|
||||
|
||||
await asyncio.sleep(self.POLL_INTERVAL)
|
||||
|
||||
return False, "视频上传超时"
|
||||
|
||||
def _normalize_tags(self, tags: List[str]) -> List[str]:
|
||||
normalized: List[str] = []
|
||||
seen = set()
|
||||
for raw in tags:
|
||||
item = (raw or "").strip().lstrip("#")
|
||||
if not item:
|
||||
continue
|
||||
lowered = item.lower()
|
||||
if lowered in seen:
|
||||
continue
|
||||
seen.add(lowered)
|
||||
normalized.append(item)
|
||||
return normalized
|
||||
|
||||
async def _fill_title(self, page) -> bool:
|
||||
selectors = [
|
||||
"input[placeholder*='标题']",
|
||||
"div.plugin.title-container input",
|
||||
"input.d-text",
|
||||
]
|
||||
target = await self._first_existing_locator(page, selectors)
|
||||
if not target:
|
||||
return False
|
||||
|
||||
try:
|
||||
await target.click(timeout=1500)
|
||||
await target.fill((self.title or "")[:30])
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def _fill_description(self, page, text: str) -> bool:
|
||||
selectors = [
|
||||
".tiptap[contenteditable='true']",
|
||||
"[contenteditable='true'][data-placeholder*='描述']",
|
||||
"[contenteditable='true'][role='textbox']",
|
||||
"textarea[placeholder*='描述']",
|
||||
"textarea[placeholder*='正文']",
|
||||
]
|
||||
target = await self._first_existing_locator(page, selectors)
|
||||
if not target:
|
||||
return False
|
||||
|
||||
try:
|
||||
await target.click(timeout=1500)
|
||||
await page.keyboard.press("Control+KeyA")
|
||||
await page.keyboard.type(text)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def set_schedule_time(self, page, publish_date: datetime) -> bool:
|
||||
try:
|
||||
toggle = await self._first_existing_locator(
|
||||
page,
|
||||
[
|
||||
"label:has-text('定时发布')",
|
||||
"span:has-text('定时发布')",
|
||||
"div:has-text('定时发布')",
|
||||
],
|
||||
)
|
||||
if not toggle:
|
||||
return False
|
||||
|
||||
try:
|
||||
await toggle.click(timeout=2000)
|
||||
except Exception:
|
||||
await toggle.evaluate("el => el.click()")
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
date_input = await self._first_existing_locator(
|
||||
page,
|
||||
[
|
||||
"input[placeholder*='日期和时间']",
|
||||
"input[placeholder*='发布时间']",
|
||||
"input[placeholder*='选择日期']",
|
||||
],
|
||||
)
|
||||
if not date_input:
|
||||
return False
|
||||
|
||||
value = publish_date.strftime("%Y-%m-%d %H:%M")
|
||||
await date_input.click(timeout=2000)
|
||||
await page.keyboard.press("Control+KeyA")
|
||||
await page.keyboard.type(value)
|
||||
await page.keyboard.press("Enter")
|
||||
logger.info(f"[小红书] 已设置定时发布: {value}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"[小红书] 设置定时发布时间失败: {e}")
|
||||
return False
|
||||
|
||||
async def _find_publish_button(self, page, scheduled: bool):
|
||||
selectors = [
|
||||
"button:has-text('定时发布')",
|
||||
"div[role='button']:has-text('定时发布')",
|
||||
] if scheduled else [
|
||||
"button:has-text('发布')",
|
||||
"button:has-text('立即发布')",
|
||||
"div[role='button']:has-text('发布')",
|
||||
]
|
||||
|
||||
for selector in selectors:
|
||||
locator = page.locator(selector)
|
||||
try:
|
||||
if await locator.count() == 0:
|
||||
continue
|
||||
candidate = locator.first
|
||||
if not await candidate.is_visible():
|
||||
continue
|
||||
return candidate
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
async def _is_publish_button_enabled(self, page) -> bool:
|
||||
buttons = [
|
||||
await self._find_publish_button(page, scheduled=False),
|
||||
await self._find_publish_button(page, scheduled=True),
|
||||
]
|
||||
for button in buttons:
|
||||
if not button:
|
||||
continue
|
||||
try:
|
||||
if await button.is_enabled():
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
return False
|
||||
|
||||
async def _click_publish(self, page, scheduled: bool) -> tuple[bool, str]:
|
||||
for _ in range(self.MAX_CLICK_RETRIES):
|
||||
button = await self._find_publish_button(page, scheduled)
|
||||
if not button:
|
||||
await asyncio.sleep(0.8)
|
||||
continue
|
||||
|
||||
try:
|
||||
if not await button.is_enabled():
|
||||
await asyncio.sleep(0.8)
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
await button.click(timeout=2000)
|
||||
return True, "发布按钮点击成功"
|
||||
except Exception:
|
||||
try:
|
||||
await button.evaluate("el => el.click()")
|
||||
return True, "发布按钮 JS 点击成功"
|
||||
except Exception:
|
||||
await asyncio.sleep(0.8)
|
||||
|
||||
return False, "未找到可点击的发布按钮"
|
||||
|
||||
async def _wait_for_publish_result(self, page) -> tuple[bool, str, bool]:
|
||||
create_url = page.url
|
||||
success_url_tokens = [
|
||||
"/publish/success",
|
||||
"/publish/result",
|
||||
"/publish/published",
|
||||
]
|
||||
success_texts = [
|
||||
"发布成功",
|
||||
"发布完成",
|
||||
"审核中",
|
||||
"查看笔记",
|
||||
"去查看",
|
||||
]
|
||||
failure_texts = [
|
||||
"发布失败",
|
||||
"发布异常",
|
||||
"发布出错",
|
||||
"网络异常",
|
||||
"请完善",
|
||||
"请补充",
|
||||
]
|
||||
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < self.PUBLISH_TIMEOUT:
|
||||
if self._publish_api_error:
|
||||
return False, self._publish_api_error, False
|
||||
|
||||
current_url = page.url
|
||||
lowered_url = current_url.lower()
|
||||
if any(token in lowered_url for token in success_url_tokens):
|
||||
return True, f"发布成功:跳转到 {current_url}", False
|
||||
|
||||
if current_url != create_url and "/publish/publish" not in lowered_url:
|
||||
return True, f"发布成功:页面已跳转 {current_url}", False
|
||||
|
||||
if self._publish_api_submitted:
|
||||
return True, "发布成功:API 已确认", False
|
||||
|
||||
for text in failure_texts:
|
||||
if await self._is_text_visible(page, text, exact=False):
|
||||
return False, f"发布失败:{text}", False
|
||||
|
||||
for text in success_texts:
|
||||
if await self._is_text_visible(page, text, exact=False):
|
||||
return True, f"发布成功:检测到文案 {text}", False
|
||||
|
||||
logger.info("[小红书] 等待发布结果...")
|
||||
await asyncio.sleep(self.POLL_INTERVAL)
|
||||
|
||||
return False, "发布超时", True
|
||||
|
||||
async def upload(self, playwright: Playwright) -> Dict[str, Any]:
|
||||
browser = None
|
||||
context = None
|
||||
page = None
|
||||
try:
|
||||
launch_options = self._build_launch_options()
|
||||
browser = await playwright.chromium.launch(**launch_options)
|
||||
context = await browser.new_context(
|
||||
storage_state=self.account_file,
|
||||
viewport={"width": 1600, "height": 900},
|
||||
device_scale_factor=1,
|
||||
user_agent=settings.XIAOHONGSHU_USER_AGENT,
|
||||
locale=settings.XIAOHONGSHU_LOCALE,
|
||||
timezone_id=settings.XIAOHONGSHU_TIMEZONE_ID,
|
||||
)
|
||||
context = await set_init_script(context)
|
||||
|
||||
page = await context.new_page()
|
||||
self._attach_publish_listener(page)
|
||||
|
||||
await self._go_to_publish_page(page)
|
||||
if await self._is_login_page(page):
|
||||
return {
|
||||
"success": False,
|
||||
"message": "登录失效,请重新扫码登录小红书",
|
||||
"url": None,
|
||||
}
|
||||
|
||||
logger.info(f"[小红书] 正在上传: {self.file_path.name}")
|
||||
if not await self._upload_video(page):
|
||||
return {
|
||||
"success": False,
|
||||
"message": "未能触发有效视频上传,请确认发布页状态及视频文件格式",
|
||||
"url": None,
|
||||
}
|
||||
|
||||
upload_success, upload_reason = await self._wait_for_upload_complete(page)
|
||||
if not upload_success:
|
||||
await self._save_debug_screenshot(page, "upload_failed")
|
||||
return {
|
||||
"success": False,
|
||||
"message": upload_reason,
|
||||
"url": None,
|
||||
}
|
||||
|
||||
await asyncio.sleep(1)
|
||||
title_filled = await self._fill_title(page)
|
||||
if not title_filled:
|
||||
logger.warning("[小红书] 未找到标题输入框,尝试在正文中补充标题")
|
||||
|
||||
normalized_tags = self._normalize_tags(self.tags)
|
||||
body_parts: List[str] = []
|
||||
if self.description:
|
||||
body_parts.append(self.description.strip())
|
||||
if not title_filled and self.title:
|
||||
body_parts.insert(0, self.title.strip())
|
||||
if normalized_tags:
|
||||
body_parts.append(" ".join([f"#{tag}" for tag in normalized_tags]))
|
||||
body_text = "\n".join([part for part in body_parts if part]).strip()
|
||||
|
||||
if body_text:
|
||||
body_ok = await self._fill_description(page, body_text)
|
||||
if not body_ok:
|
||||
logger.warning("[小红书] 未找到正文输入框,跳过正文/话题填充")
|
||||
|
||||
if self.publish_date != 0 and isinstance(self.publish_date, datetime):
|
||||
if not await self.set_schedule_time(page, self.publish_date):
|
||||
return {
|
||||
"success": False,
|
||||
"message": "未找到定时发布控件,请检查小红书发布页结构",
|
||||
"url": None,
|
||||
}
|
||||
|
||||
clicked, click_reason = await self._click_publish(page, scheduled=self.publish_date != 0)
|
||||
if not clicked:
|
||||
await self._save_debug_screenshot(page, "publish_button_not_clickable")
|
||||
return {
|
||||
"success": False,
|
||||
"message": click_reason,
|
||||
"url": None,
|
||||
}
|
||||
|
||||
publish_success, publish_reason, is_timeout = await self._wait_for_publish_result(page)
|
||||
|
||||
await context.storage_state(path=self.account_file)
|
||||
logger.success("[小红书] Cookie 更新完毕")
|
||||
|
||||
if publish_success:
|
||||
await asyncio.sleep(2)
|
||||
screenshot_url = await self._save_publish_success_screenshot(page)
|
||||
return {
|
||||
"success": True,
|
||||
"message": "发布成功,待审核" if self.publish_date == 0 else "已设置定时发布",
|
||||
"url": None,
|
||||
"screenshot_url": screenshot_url,
|
||||
}
|
||||
|
||||
if is_timeout:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"发布状态未知(检测超时),请到小红书创作中心确认: {publish_reason}",
|
||||
"url": None,
|
||||
}
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"message": publish_reason,
|
||||
"url": None,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"[小红书] 上传失败: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"上传失败: {str(e)}",
|
||||
"url": None,
|
||||
}
|
||||
finally:
|
||||
self._cleanup_upload_file()
|
||||
|
||||
if page:
|
||||
try:
|
||||
if not page.is_closed():
|
||||
await page.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if context:
|
||||
try:
|
||||
await context.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if browser:
|
||||
try:
|
||||
await browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def main(self) -> Dict[str, Any]:
|
||||
async with async_playwright() as playwright:
|
||||
return await self.upload(playwright)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
视频合成服务
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import subprocess
|
||||
import json
|
||||
@@ -96,7 +97,7 @@ class VideoService:
|
||||
"-map", "0:a?",
|
||||
"-c:v", "libx264",
|
||||
"-preset", "fast",
|
||||
"-crf", "23",
|
||||
"-crf", "18",
|
||||
"-c:a", "copy",
|
||||
"-movflags", "+faststart",
|
||||
output_path,
|
||||
@@ -199,9 +200,10 @@ class VideoService:
|
||||
"""合成视频"""
|
||||
# Ensure output dir
|
||||
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
video_duration = self._get_duration(video_path)
|
||||
audio_duration = self._get_duration(audio_path)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
video_duration = await loop.run_in_executor(None, self._get_duration, video_path)
|
||||
audio_duration = await loop.run_in_executor(None, self._get_duration, audio_path)
|
||||
|
||||
# Audio loop if needed
|
||||
loop_count = 1
|
||||
@@ -228,7 +230,7 @@ class VideoService:
|
||||
# 不需要循环时用流复制(几乎瞬间完成),需要循环时才重编码
|
||||
if loop_count > 1:
|
||||
cmd.extend([
|
||||
"-c:v", "libx264", "-preset", "fast", "-crf", "23",
|
||||
"-c:v", "libx264", "-preset", "fast", "-crf", "18",
|
||||
])
|
||||
else:
|
||||
cmd.extend(["-c:v", "copy"])
|
||||
@@ -242,7 +244,8 @@ class VideoService:
|
||||
|
||||
cmd.append(output_path)
|
||||
|
||||
if self._run_ffmpeg(cmd):
|
||||
ok = await loop.run_in_executor(None, self._run_ffmpeg, cmd)
|
||||
if ok:
|
||||
return output_path
|
||||
else:
|
||||
raise RuntimeError("FFmpeg composition failed")
|
||||
@@ -267,12 +270,7 @@ class VideoService:
|
||||
"-fflags", "+genpts",
|
||||
"-i", str(list_path),
|
||||
"-an",
|
||||
"-vsync", "cfr",
|
||||
"-r", str(target_fps),
|
||||
"-c:v", "libx264",
|
||||
"-preset", "fast",
|
||||
"-crf", "23",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-c:v", "copy",
|
||||
"-movflags", "+faststart",
|
||||
output_path,
|
||||
]
|
||||
@@ -346,6 +344,7 @@ class VideoService:
|
||||
needs_loop = target_duration > available
|
||||
needs_scale = target_resolution is not None
|
||||
needs_fps = bool(target_fps and target_fps > 0)
|
||||
target_fps_value = int(target_fps) if needs_fps and target_fps is not None else None
|
||||
has_source_end = clip_end < video_dur
|
||||
|
||||
# 当需要循环且存在截取范围时,先裁剪出片段,再循环裁剪后的文件
|
||||
@@ -360,7 +359,7 @@ class VideoService:
|
||||
"-i", video_path,
|
||||
"-t", str(available),
|
||||
"-an",
|
||||
"-c:v", "libx264", "-preset", "fast", "-crf", "23",
|
||||
"-c:v", "libx264", "-preset", "fast", "-crf", "18",
|
||||
trim_temp,
|
||||
]
|
||||
if not self._run_ffmpeg(trim_cmd):
|
||||
@@ -380,20 +379,20 @@ class VideoService:
|
||||
cmd.extend(["-i", actual_input, "-t", str(target_duration), "-an"])
|
||||
|
||||
filters = []
|
||||
if needs_fps:
|
||||
filters.append(f"fps={int(target_fps)}")
|
||||
if target_fps_value is not None:
|
||||
filters.append(f"fps={target_fps_value}")
|
||||
if needs_scale:
|
||||
w, h = target_resolution
|
||||
filters.append(f"scale={w}:{h}:force_original_aspect_ratio=decrease,pad={w}:{h}:(ow-iw)/2:(oh-ih)/2")
|
||||
|
||||
if filters:
|
||||
cmd.extend(["-vf", ",".join(filters)])
|
||||
if needs_fps:
|
||||
cmd.extend(["-vsync", "cfr", "-r", str(int(target_fps))])
|
||||
if target_fps_value is not None:
|
||||
cmd.extend(["-vsync", "cfr", "-r", str(target_fps_value)])
|
||||
|
||||
# 需要循环、缩放或指定起点时必须重编码,否则用 stream copy 保持原画质
|
||||
if needs_loop or needs_scale or source_start > 0 or has_source_end or needs_fps:
|
||||
cmd.extend(["-c:v", "libx264", "-preset", "fast", "-crf", "23"])
|
||||
cmd.extend(["-c:v", "libx264", "-preset", "fast", "-crf", "18"])
|
||||
else:
|
||||
cmd.extend(["-c:v", "copy"])
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
|
||||
import { useAuth } from "@/shared/contexts/AuthContext";
|
||||
import api from "@/shared/api/axios";
|
||||
import { ApiResponse } from "@/shared/api/types";
|
||||
import { AppModal, AppModalHeader } from "@/shared/ui/AppModal";
|
||||
|
||||
// 账户设置下拉菜单组件
|
||||
export default function AccountSettingsDropdown() {
|
||||
@@ -90,6 +91,15 @@ export default function AccountSettingsDropdown() {
|
||||
}
|
||||
};
|
||||
|
||||
const closePasswordModal = () => {
|
||||
setShowPasswordModal(false);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
setOldPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
@@ -137,81 +147,83 @@ export default function AccountSettingsDropdown() {
|
||||
|
||||
{/* 修改密码弹窗 */}
|
||||
{showPasswordModal && (
|
||||
<div className="fixed inset-0 z-[200] flex items-start justify-center pt-20 bg-black/60 backdrop-blur-sm p-4">
|
||||
<div className="w-full max-w-md p-6 bg-gray-900 border border-white/10 rounded-2xl shadow-2xl mx-4">
|
||||
<h3 className="text-xl font-bold text-white mb-4">修改密码</h3>
|
||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-300 mb-1">当前密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={oldPassword}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="输入当前密码"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-300 mb-1">新密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="至少6位"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-300 mb-1">确认新密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="再次输入新密码"
|
||||
/>
|
||||
</div>
|
||||
<AppModal
|
||||
isOpen={showPasswordModal}
|
||||
onClose={closePasswordModal}
|
||||
zIndexClassName="z-[200]"
|
||||
panelClassName="w-full max-w-md rounded-2xl border border-white/10 bg-[#171821]/95 shadow-[0_24px_80px_rgba(0,0,0,0.55)] overflow-hidden"
|
||||
closeOnOverlay={false}
|
||||
>
|
||||
<AppModalHeader
|
||||
title="修改密码"
|
||||
subtitle="修改后将自动退出并重新登录"
|
||||
onClose={closePasswordModal}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="p-2 bg-red-500/20 border border-red-500/50 rounded text-red-200 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="p-2 bg-green-500/20 border border-green-500/50 rounded text-green-200 text-sm">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleChangePassword} className="space-y-4 p-5">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-300 mb-1">当前密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={oldPassword}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="输入当前密码"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-300 mb-1">新密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="至少6位"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-300 mb-1">确认新密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="再次输入新密码"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowPasswordModal(false);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
setOldPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
}}
|
||||
className="flex-1 py-2 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex-1 py-2 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? '修改中...' : '确认修改'}
|
||||
</button>
|
||||
{error && (
|
||||
<div className="p-2 bg-red-500/20 border border-red-500/50 rounded text-red-200 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="p-2 bg-green-500/20 border border-green-500/50 rounded text-green-200 text-sm">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closePasswordModal}
|
||||
className="flex-1 py-2 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex-1 py-2 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? '修改中...' : '确认修改'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</AppModal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { X, Video } from "lucide-react";
|
||||
import { Video } from "lucide-react";
|
||||
import { AppModal, AppModalHeader } from "@/shared/ui/AppModal";
|
||||
|
||||
interface VideoPreviewModalProps {
|
||||
videoUrl: string | null;
|
||||
@@ -16,66 +16,34 @@ export default function VideoPreviewModal({
|
||||
title = "视频预览",
|
||||
subtitle = "ESC 关闭 · 点击空白关闭",
|
||||
}: VideoPreviewModalProps) {
|
||||
useEffect(() => {
|
||||
if (!videoUrl) return;
|
||||
// 按 ESC 关闭
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.addEventListener('keydown', handleEsc);
|
||||
// 禁止背景滚动
|
||||
document.body.style.overflow = 'hidden';
|
||||
if (!videoUrl) return null;
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEsc);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
};
|
||||
}, [videoUrl, onClose]);
|
||||
return (
|
||||
<AppModal
|
||||
isOpen={Boolean(videoUrl)}
|
||||
onClose={onClose}
|
||||
zIndexClassName="z-[320]"
|
||||
panelClassName="relative w-full max-w-4xl rounded-2xl border border-white/10 bg-[#171821]/95 shadow-[0_24px_80px_rgba(0,0,0,0.55)] overflow-hidden flex flex-col"
|
||||
closeOnOverlay
|
||||
>
|
||||
<div data-video-preview-open="true" className="flex flex-col">
|
||||
<AppModalHeader
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
icon={<Video className="h-5 w-5" />}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
if (!videoUrl) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4 animate-in fade-in duration-200"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="relative w-full max-w-4xl bg-gray-900 border border-white/10 rounded-2xl shadow-2xl overflow-hidden flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 py-3 border-b border-white/10 bg-gradient-to-r from-white/5 via-white/0 to-white/5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-9 w-9 rounded-lg bg-white/10 flex items-center justify-center text-white">
|
||||
<Video className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-400">
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-gray-400 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-black flex items-center justify-center min-h-[50vh] max-h-[80vh]">
|
||||
<video
|
||||
src={videoUrl}
|
||||
controls
|
||||
autoPlay
|
||||
preload="metadata"
|
||||
className="w-full h-full max-h-[80vh] object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-black flex items-center justify-center min-h-[50vh] max-h-[80vh]">
|
||||
<video
|
||||
src={videoUrl}
|
||||
controls
|
||||
autoPlay
|
||||
preload="metadata"
|
||||
className="w-full h-full max-h-[80vh] object-contain"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
</div>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -124,6 +124,8 @@ interface RefAudio {
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
type LipsyncModelMode = "default" | "fast" | "advanced";
|
||||
|
||||
import type { Material } from "@/shared/types/material";
|
||||
|
||||
export const useHomeController = () => {
|
||||
@@ -155,6 +157,7 @@ export const useHomeController = () => {
|
||||
const [titleDisplayMode, setTitleDisplayMode] = useState<"short" | "persistent">("short");
|
||||
const [subtitleBottomMargin, setSubtitleBottomMargin] = useState<number>(80);
|
||||
const [outputAspectRatio, setOutputAspectRatio] = useState<"9:16" | "16:9">("9:16");
|
||||
const [lipsyncModelMode, setLipsyncModelMode] = useState<LipsyncModelMode>("default");
|
||||
const [showStylePreview, setShowStylePreview] = useState<boolean>(false);
|
||||
const [materialDimensions, setMaterialDimensions] = useState<{ width: number; height: number } | null>(null);
|
||||
|
||||
@@ -492,6 +495,8 @@ export const useHomeController = () => {
|
||||
setSubtitleBottomMargin,
|
||||
outputAspectRatio,
|
||||
setOutputAspectRatio,
|
||||
lipsyncModelMode,
|
||||
setLipsyncModelMode,
|
||||
selectedBgmId,
|
||||
setSelectedBgmId,
|
||||
bgmVolume,
|
||||
@@ -730,6 +735,9 @@ export const useHomeController = () => {
|
||||
// 开始录音
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
setRecordedBlob(null);
|
||||
setRecordingTime(0);
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const mediaRecorder = new MediaRecorder(stream, { mimeType: "audio/webm" });
|
||||
const chunks: BlobPart[] = [];
|
||||
@@ -743,7 +751,6 @@ export const useHomeController = () => {
|
||||
|
||||
mediaRecorder.start();
|
||||
setIsRecording(true);
|
||||
setRecordingTime(0);
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
|
||||
// 计时器
|
||||
@@ -779,6 +786,11 @@ export const useHomeController = () => {
|
||||
setRecordingTime(0);
|
||||
};
|
||||
|
||||
const discardRecording = () => {
|
||||
setRecordedBlob(null);
|
||||
setRecordingTime(0);
|
||||
};
|
||||
|
||||
// 格式化录音时长
|
||||
const formatRecordingTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
@@ -934,6 +946,7 @@ export const useHomeController = () => {
|
||||
text: selectedAudio.text || text,
|
||||
generated_audio_id: selectedAudio.id,
|
||||
language: selectedAudio.language || textLang,
|
||||
lipsync_model: lipsyncModelMode,
|
||||
title: videoTitle.trim() || undefined,
|
||||
enable_subtitles: true,
|
||||
output_aspect_ratio: outputAspectRatio,
|
||||
@@ -1034,7 +1047,7 @@ export const useHomeController = () => {
|
||||
|
||||
if (enableBgm && selectedBgmId) {
|
||||
payload.bgm_id = selectedBgmId;
|
||||
payload.bgm_volume = bgmVolume;
|
||||
payload.bgm_volume = 0.2;
|
||||
}
|
||||
|
||||
// 创建生成任务
|
||||
@@ -1154,6 +1167,8 @@ export const useHomeController = () => {
|
||||
setSubtitleBottomMargin,
|
||||
outputAspectRatio,
|
||||
setOutputAspectRatio,
|
||||
lipsyncModelMode,
|
||||
setLipsyncModelMode,
|
||||
resolveAssetUrl,
|
||||
getFontFormat,
|
||||
buildTextShadow,
|
||||
@@ -1190,6 +1205,7 @@ export const useHomeController = () => {
|
||||
startRecording,
|
||||
stopRecording,
|
||||
useRecording,
|
||||
discardRecording,
|
||||
formatRecordingTime,
|
||||
bgmList,
|
||||
bgmLoading,
|
||||
|
||||
@@ -52,6 +52,8 @@ interface UseHomePersistenceOptions {
|
||||
setSubtitleBottomMargin: React.Dispatch<React.SetStateAction<number>>;
|
||||
outputAspectRatio: '9:16' | '16:9';
|
||||
setOutputAspectRatio: React.Dispatch<React.SetStateAction<'9:16' | '16:9'>>;
|
||||
lipsyncModelMode: 'default' | 'fast' | 'advanced';
|
||||
setLipsyncModelMode: React.Dispatch<React.SetStateAction<'default' | 'fast' | 'advanced'>>;
|
||||
selectedBgmId: string;
|
||||
setSelectedBgmId: React.Dispatch<React.SetStateAction<string>>;
|
||||
bgmVolume: number;
|
||||
@@ -111,6 +113,8 @@ export const useHomePersistence = ({
|
||||
setSubtitleBottomMargin,
|
||||
outputAspectRatio,
|
||||
setOutputAspectRatio,
|
||||
lipsyncModelMode,
|
||||
setLipsyncModelMode,
|
||||
selectedBgmId,
|
||||
setSelectedBgmId,
|
||||
bgmVolume,
|
||||
@@ -156,6 +160,7 @@ export const useHomePersistence = ({
|
||||
const savedTitleDisplayMode = localStorage.getItem(`vigent_${storageKey}_titleDisplayMode`);
|
||||
const savedSubtitleBottomMargin = localStorage.getItem(`vigent_${storageKey}_subtitleBottomMargin`);
|
||||
const savedOutputAspectRatio = localStorage.getItem(`vigent_${storageKey}_outputAspectRatio`);
|
||||
const savedLipsyncModelMode = localStorage.getItem(`vigent_${storageKey}_lipsyncModelMode`);
|
||||
const savedSpeed = localStorage.getItem(`vigent_${storageKey}_speed`);
|
||||
const savedEmotion = localStorage.getItem(`vigent_${storageKey}_emotion`);
|
||||
|
||||
@@ -235,6 +240,14 @@ export const useHomePersistence = ({
|
||||
setOutputAspectRatio(savedOutputAspectRatio);
|
||||
}
|
||||
|
||||
if (
|
||||
savedLipsyncModelMode === 'default'
|
||||
|| savedLipsyncModelMode === 'fast'
|
||||
|| savedLipsyncModelMode === 'advanced'
|
||||
) {
|
||||
setLipsyncModelMode(savedLipsyncModelMode);
|
||||
}
|
||||
|
||||
if (savedSpeed) {
|
||||
const parsed = parseFloat(savedSpeed);
|
||||
if (!Number.isNaN(parsed)) setSpeed(parsed);
|
||||
@@ -270,6 +283,7 @@ export const useHomePersistence = ({
|
||||
setTitleDisplayMode,
|
||||
setSubtitleBottomMargin,
|
||||
setOutputAspectRatio,
|
||||
setLipsyncModelMode,
|
||||
setTtsMode,
|
||||
setVideoTitle,
|
||||
setVideoSecondaryTitle,
|
||||
@@ -385,6 +399,12 @@ export const useHomePersistence = ({
|
||||
}
|
||||
}, [outputAspectRatio, storageKey, isRestored]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRestored) {
|
||||
localStorage.setItem(`vigent_${storageKey}_lipsyncModelMode`, lipsyncModelMode);
|
||||
}
|
||||
}, [lipsyncModelMode, storageKey, isRestored]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRestored) {
|
||||
localStorage.setItem(`vigent_${storageKey}_bgmId`, selectedBgmId);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { RefObject, MouseEvent } from "react";
|
||||
import { RefreshCw, Play, Pause } from "lucide-react";
|
||||
import { type RefObject, type MouseEvent, useCallback, useMemo, useState } from "react";
|
||||
import { RefreshCw, Play, Pause, ChevronDown, Check, Search } from "lucide-react";
|
||||
import { SelectPopover } from "@/shared/ui/SelectPopover";
|
||||
|
||||
interface BgmItem {
|
||||
id: string;
|
||||
@@ -18,8 +19,6 @@ interface BgmPanelProps {
|
||||
onSelectBgm: (id: string) => void;
|
||||
playingBgmId: string | null;
|
||||
onTogglePreview: (bgm: BgmItem, event: MouseEvent) => void;
|
||||
bgmVolume: number;
|
||||
onVolumeChange: (value: number) => void;
|
||||
bgmListContainerRef: RefObject<HTMLDivElement | null>;
|
||||
registerBgmItemRef: (id: string, element: HTMLDivElement | null) => void;
|
||||
}
|
||||
@@ -35,11 +34,31 @@ export function BgmPanel({
|
||||
onSelectBgm,
|
||||
playingBgmId,
|
||||
onTogglePreview,
|
||||
bgmVolume,
|
||||
onVolumeChange,
|
||||
bgmListContainerRef,
|
||||
registerBgmItemRef,
|
||||
}: BgmPanelProps) {
|
||||
const [bgmFilter, setBgmFilter] = useState("");
|
||||
const selectedBgm = bgmList.find((item) => item.id === selectedBgmId) || null;
|
||||
const canSelectBgm = enableBgm && !bgmLoading && !bgmError && bgmList.length > 0;
|
||||
const filteredBgmList = useMemo(() => {
|
||||
const query = bgmFilter.trim().toLowerCase();
|
||||
if (!query) return bgmList;
|
||||
return bgmList.filter((bgm) => bgm.name.toLowerCase().includes(query));
|
||||
}, [bgmFilter, bgmList]);
|
||||
|
||||
const handleOpenBgmPopover = useCallback(() => {
|
||||
setBgmFilter("");
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const container = bgmListContainerRef.current;
|
||||
if (!container) return;
|
||||
const selectedRow = container.querySelector<HTMLElement>("[data-bgm-selected='true']");
|
||||
selectedRow?.scrollIntoView({ block: "nearest", behavior: "auto" });
|
||||
});
|
||||
});
|
||||
}, [bgmListContainerRef]);
|
||||
|
||||
return (
|
||||
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
@@ -79,57 +98,108 @@ export function BgmPanel({
|
||||
) : bgmList.length === 0 ? (
|
||||
<div className="text-center py-4 text-gray-500 text-sm">暂无背景音乐,请先导入素材</div>
|
||||
) : (
|
||||
<div
|
||||
ref={bgmListContainerRef}
|
||||
className={`space-y-2 max-h-64 overflow-y-auto hide-scrollbar ${enableBgm ? '' : 'opacity-70'}`}
|
||||
>
|
||||
{bgmList.map((bgm) => (
|
||||
<div
|
||||
key={bgm.id}
|
||||
ref={(el) => registerBgmItemRef(bgm.id, el)}
|
||||
className={`p-3 rounded-lg border transition-all flex items-center justify-between group ${selectedBgmId === bgm.id
|
||||
? "border-purple-500 bg-purple-500/20"
|
||||
: "border-white/10 bg-white/5 hover:border-white/30"
|
||||
}`}
|
||||
>
|
||||
<button onClick={() => onSelectBgm(bgm.id)} className="flex-1 text-left">
|
||||
<div className="text-white text-sm truncate">{bgm.name}</div>
|
||||
<div className="text-xs text-gray-400">.{bgm.ext || 'audio'}</div>
|
||||
<div className={!enableBgm ? "opacity-70" : ""}>
|
||||
<p className="mb-2 text-xs text-gray-400">曲目选择</p>
|
||||
<SelectPopover
|
||||
sheetTitle="选择背景音乐"
|
||||
disabled={!canSelectBgm}
|
||||
onOpen={handleOpenBgmPopover}
|
||||
trigger={({ open, toggle }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
disabled={!canSelectBgm}
|
||||
className={`w-full rounded-xl border px-3 py-2.5 text-left transition-colors ${canSelectBgm
|
||||
? "border-white/10 bg-black/25 hover:border-white/30"
|
||||
: "border-white/10 bg-black/20 text-gray-500 cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center justify-between gap-3">
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm text-white">
|
||||
{selectedBgm?.name || "请选择背景音乐"}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs text-gray-400">
|
||||
{selectedBgm ? `.${selectedBgm.ext || "audio"}` : "未选择"}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown className={`h-4 w-4 text-gray-300 transition-transform ${open ? "rotate-180" : ""}`} />
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-2 pl-2">
|
||||
<button
|
||||
onClick={(e) => onTogglePreview(bgm, e)}
|
||||
className="p-1 text-gray-500 hover:text-purple-400 transition-colors"
|
||||
title="试听"
|
||||
>
|
||||
{playingBgmId === bgm.id ? (
|
||||
<Pause className="h-4 w-4" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
{selectedBgmId === bgm.id && (
|
||||
<span className="text-xs text-purple-300">已选</span>
|
||||
)}
|
||||
>
|
||||
{({ close }) => (
|
||||
<div className="space-y-2">
|
||||
<div className="rounded-lg border border-white/10 bg-black/30 px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Search className="h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={bgmFilter}
|
||||
onChange={(e) => setBgmFilter(e.target.value)}
|
||||
placeholder="搜索背景音乐..."
|
||||
className="w-full bg-transparent text-sm text-white placeholder-gray-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredBgmList.length === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-gray-400">没有匹配的背景音乐</div>
|
||||
) : (
|
||||
<div
|
||||
ref={bgmListContainerRef}
|
||||
className="space-y-1"
|
||||
style={{ contentVisibility: "auto" }}
|
||||
>
|
||||
{filteredBgmList.map((bgm) => {
|
||||
const isSelected = selectedBgmId === bgm.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={bgm.id}
|
||||
ref={(el) => registerBgmItemRef(bgm.id, el)}
|
||||
data-popover-selected={isSelected ? "true" : undefined}
|
||||
data-bgm-selected={isSelected ? "true" : "false"}
|
||||
className={`flex items-center justify-between gap-2 rounded-lg border px-3 py-2 transition-colors ${isSelected
|
||||
? "border-purple-500 bg-purple-500/20"
|
||||
: "border-white/10 bg-white/5 hover:border-white/30"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelectBgm(bgm.id);
|
||||
close();
|
||||
}}
|
||||
className="min-w-0 flex-1 text-left"
|
||||
>
|
||||
<span className="block truncate text-sm text-white">{bgm.name}</span>
|
||||
<span className="mt-0.5 block text-xs text-gray-400">.{bgm.ext || "audio"}</span>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2 pl-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => onTogglePreview(bgm, e)}
|
||||
className="p-1 text-gray-400 hover:text-purple-300 transition-colors"
|
||||
title="试听"
|
||||
>
|
||||
{playingBgmId === bgm.id ? (
|
||||
<Pause className="h-4 w-4" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
{isSelected && <Check className="h-4 w-4 text-purple-300" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{enableBgm && (
|
||||
<div className="mt-4">
|
||||
<label className="text-sm text-gray-300 mb-2 block">音量</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={bgmVolume}
|
||||
onChange={(e) => onVolumeChange(parseFloat(e.target.value))}
|
||||
className="w-full accent-purple-500"
|
||||
/>
|
||||
<div className="text-xs text-gray-400 mt-1">当前: {Math.round(bgmVolume * 100)}%</div>
|
||||
)}
|
||||
</SelectPopover>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { X, Play, Pause } from "lucide-react";
|
||||
import type { TimelineSegment } from "@/features/home/model/useTimelineEditor";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Play, Pause } from "lucide-react";
|
||||
import type { TimelineSegment } from "@/features/home/model/useTimelineEditor";
|
||||
import { AppModal, AppModalHeader } from "@/shared/ui/AppModal";
|
||||
|
||||
interface ClipTrimmerProps {
|
||||
isOpen: boolean;
|
||||
@@ -153,21 +154,18 @@ export function ClipTrimmer({
|
||||
const endPct = duration > 0 ? (effectiveEnd / duration) * 100 : 100;
|
||||
const playheadPct = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={onClose}>
|
||||
<div
|
||||
className="bg-gray-900 border border-white/10 rounded-2xl w-full max-w-lg mx-4 overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-white/10">
|
||||
<h3 className="text-white font-semibold text-sm">
|
||||
截取设置 - {segment.materialName}
|
||||
</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-white">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
return (
|
||||
<AppModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
panelClassName="w-full max-w-lg mx-4 rounded-2xl border border-white/10 bg-[#171821]/95 shadow-[0_24px_80px_rgba(0,0,0,0.55)] overflow-hidden"
|
||||
closeOnOverlay
|
||||
>
|
||||
<AppModalHeader
|
||||
title={`截取设置 - ${segment.materialName}`}
|
||||
subtitle="拖拽起止点,精确控制素材片段"
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
{/* Video preview */}
|
||||
<div className="px-5 pt-4">
|
||||
@@ -287,7 +285,6 @@ export function ClipTrimmer({
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import { Rocket } from "lucide-react";
|
||||
import { Rocket, ChevronDown, Check } from "lucide-react";
|
||||
import { SelectPopover } from "@/shared/ui/SelectPopover";
|
||||
|
||||
type LipsyncModelMode = "default" | "fast" | "advanced";
|
||||
|
||||
const MODEL_OPTIONS: Array<{ value: LipsyncModelMode; label: string; desc: string }> = [
|
||||
{ value: "default", label: "默认模型", desc: "按时长智能路由" },
|
||||
{ value: "fast", label: "快速模型", desc: "速度优先" },
|
||||
{ value: "advanced", label: "高级模型", desc: "质量优先" },
|
||||
];
|
||||
|
||||
interface GenerateActionBarProps {
|
||||
isGenerating: boolean;
|
||||
progress: number;
|
||||
disabled: boolean;
|
||||
materialCount?: number;
|
||||
modelMode: LipsyncModelMode;
|
||||
onModelModeChange: (value: LipsyncModelMode) => void;
|
||||
onGenerate: () => void;
|
||||
}
|
||||
|
||||
@@ -13,45 +24,102 @@ export function GenerateActionBar({
|
||||
progress,
|
||||
disabled,
|
||||
materialCount = 1,
|
||||
modelMode,
|
||||
onModelModeChange,
|
||||
onGenerate,
|
||||
}: GenerateActionBarProps) {
|
||||
const currentModel = MODEL_OPTIONS.find((opt) => opt.value === modelMode) || MODEL_OPTIONS[0];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={onGenerate}
|
||||
disabled={disabled}
|
||||
className={`w-full py-4 rounded-xl font-bold text-lg transition-all ${disabled
|
||||
? "bg-gray-600 cursor-not-allowed text-gray-400"
|
||||
: "bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white shadow-lg hover:shadow-purple-500/25"
|
||||
}`}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<span className="flex items-center justify-center gap-3">
|
||||
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
生成中... {progress}%
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Rocket className="h-5 w-5" />
|
||||
生成视频
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onGenerate}
|
||||
disabled={disabled}
|
||||
className={`flex-1 py-4 rounded-xl font-bold text-lg transition-all ${disabled
|
||||
? "bg-gray-600 cursor-not-allowed text-gray-400"
|
||||
: "bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white shadow-lg hover:shadow-purple-500/25"
|
||||
}`}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<span className="flex items-center justify-center gap-3">
|
||||
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
生成中... {progress}%
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Rocket className="h-5 w-5" />
|
||||
生成视频
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<SelectPopover
|
||||
sheetTitle="选择唇形模型"
|
||||
disabled={isGenerating}
|
||||
trigger={({ open, toggle }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
disabled={isGenerating}
|
||||
className="h-[58px] min-w-[152px] rounded-xl border border-white/15 bg-black/30 px-3 text-left text-sm text-gray-200 transition-colors hover:border-white/30 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title="选择唇形模型"
|
||||
>
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm text-white">{currentModel.label}</span>
|
||||
<span className="mt-0.5 block text-xs text-gray-400">{currentModel.desc}</span>
|
||||
</span>
|
||||
<ChevronDown className={`h-4 w-4 text-gray-300 transition-transform ${open ? "rotate-180" : ""}`} />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{({ close }) => (
|
||||
<div className="space-y-1">
|
||||
{MODEL_OPTIONS.map((opt) => {
|
||||
const isSelected = opt.value === modelMode;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
data-popover-selected={isSelected ? "true" : undefined}
|
||||
onClick={() => {
|
||||
onModelModeChange(opt.value);
|
||||
close();
|
||||
}}
|
||||
className={`flex w-full items-center justify-between rounded-lg border px-3 py-2 text-left transition-colors ${isSelected
|
||||
? "border-purple-500 bg-purple-500/20"
|
||||
: "border-white/10 bg-white/5 hover:border-white/30"
|
||||
}`}
|
||||
>
|
||||
<span>
|
||||
<span className="block text-sm text-white">{opt.label}</span>
|
||||
<span className="mt-0.5 block text-xs text-gray-400">{opt.desc}</span>
|
||||
</span>
|
||||
{isSelected && <Check className="h-4 w-4 text-purple-300" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</SelectPopover>
|
||||
</div>
|
||||
{!isGenerating && materialCount >= 2 && (
|
||||
<p className="text-xs text-gray-400 text-center mt-1.5">
|
||||
多素材模式 ({materialCount} 个机位),生成耗时较长
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { Play, Pause, Pencil, Trash2, Check, X, RefreshCw, Mic, ChevronDown } from "lucide-react";
|
||||
import type { GeneratedAudio } from "@/features/home/model/useGeneratedAudios";
|
||||
import { useState, useRef, useCallback, useEffect, useMemo } from "react";
|
||||
import { Play, Pause, Pencil, Trash2, Check, X, RefreshCw, Mic, ChevronDown, Search } from "lucide-react";
|
||||
import type { GeneratedAudio } from "@/features/home/model/useGeneratedAudios";
|
||||
import { SelectPopover } from "@/shared/ui/SelectPopover";
|
||||
|
||||
interface AudioTask {
|
||||
status: string;
|
||||
@@ -47,14 +48,12 @@ export function GeneratedAudiosPanel({
|
||||
onEmotionChange,
|
||||
embedded = false,
|
||||
}: GeneratedAudiosPanelProps) {
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [playingId, setPlayingId] = useState<string | null>(null);
|
||||
const [speedOpen, setSpeedOpen] = useState(false);
|
||||
const [emotionOpen, setEmotionOpen] = useState(false);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const speedRef = useRef<HTMLDivElement>(null);
|
||||
const emotionRef = useRef<HTMLDivElement>(null);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [playingId, setPlayingId] = useState<string | null>(null);
|
||||
const [audioFilter, setAudioFilter] = useState("");
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const audioListContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const stopPlaying = useCallback(() => {
|
||||
if (audioRef.current) {
|
||||
@@ -75,28 +74,6 @@ export function GeneratedAudiosPanel({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Close speed dropdown on click outside
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (speedRef.current && !speedRef.current.contains(e.target as Node)) {
|
||||
setSpeedOpen(false);
|
||||
}
|
||||
};
|
||||
if (speedOpen) document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [speedOpen]);
|
||||
|
||||
// Close emotion dropdown on click outside
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (emotionRef.current && !emotionRef.current.contains(e.target as Node)) {
|
||||
setEmotionOpen(false);
|
||||
}
|
||||
};
|
||||
if (emotionOpen) document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [emotionOpen]);
|
||||
|
||||
const togglePlay = (audio: GeneratedAudio, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (playingId === audio.id) {
|
||||
@@ -148,7 +125,26 @@ export function GeneratedAudiosPanel({
|
||||
{ value: "sad", label: "低沉" },
|
||||
{ value: "angry", label: "严肃" },
|
||||
] as const;
|
||||
const currentEmotionLabel = emotionOptions.find((o) => o.value === emotion)?.label ?? "正常";
|
||||
const currentEmotionLabel = emotionOptions.find((o) => o.value === emotion)?.label ?? "正常";
|
||||
const selectedAudio = generatedAudios.find((audio) => audio.id === selectedAudioId) || null;
|
||||
const filteredAudios = useMemo(() => {
|
||||
const query = audioFilter.trim().toLowerCase();
|
||||
if (!query) return generatedAudios;
|
||||
return generatedAudios.filter((audio) => audio.name.toLowerCase().includes(query));
|
||||
}, [audioFilter, generatedAudios]);
|
||||
|
||||
const handleOpenAudioPopover = useCallback(() => {
|
||||
setAudioFilter("");
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const container = audioListContainerRef.current;
|
||||
if (!container) return;
|
||||
const selectedRow = container.querySelector<HTMLElement>("[data-audio-selected='true']");
|
||||
selectedRow?.scrollIntoView({ block: "nearest", behavior: "auto" });
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
@@ -156,62 +152,88 @@ export function GeneratedAudiosPanel({
|
||||
<>
|
||||
{/* Row 1: 语气 + 语速 + 生成配音 (right-aligned) */}
|
||||
<div className="flex justify-end items-center gap-1.5 mb-3">
|
||||
{ttsMode === "voiceclone" && (
|
||||
<div ref={emotionRef} className="relative">
|
||||
<button
|
||||
onClick={() => setEmotionOpen((v) => !v)}
|
||||
className="px-2 py-1 text-xs bg-white/10 hover:bg-white/20 rounded text-gray-300 whitespace-nowrap flex items-center gap-1 transition-all"
|
||||
>
|
||||
语气: {currentEmotionLabel}
|
||||
<ChevronDown className={`h-3 w-3 transition-transform ${emotionOpen ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
{emotionOpen && (
|
||||
<div className="absolute right-0 top-full mt-1 bg-gray-800 border border-white/20 rounded-lg shadow-xl py-1 z-50 min-w-[80px]">
|
||||
{emotionOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => { onEmotionChange(opt.value); setEmotionOpen(false); }}
|
||||
className={`w-full text-left px-3 py-1.5 text-xs transition-colors ${
|
||||
emotion === opt.value
|
||||
? "bg-purple-600/40 text-purple-200"
|
||||
: "text-gray-300 hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{ttsMode === "voiceclone" && (
|
||||
<div ref={speedRef} className="relative">
|
||||
<button
|
||||
onClick={() => setSpeedOpen((v) => !v)}
|
||||
className="px-2 py-1 text-xs bg-white/10 hover:bg-white/20 rounded text-gray-300 whitespace-nowrap flex items-center gap-1 transition-all"
|
||||
>
|
||||
语速: {currentSpeedLabel}
|
||||
<ChevronDown className={`h-3 w-3 transition-transform ${speedOpen ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
{speedOpen && (
|
||||
<div className="absolute right-0 top-full mt-1 bg-gray-800 border border-white/20 rounded-lg shadow-xl py-1 z-50 min-w-[80px]">
|
||||
{speedOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => { onSpeedChange(opt.value); setSpeedOpen(false); }}
|
||||
className={`w-full text-left px-3 py-1.5 text-xs transition-colors ${
|
||||
speed === opt.value
|
||||
? "bg-purple-600/40 text-purple-200"
|
||||
: "text-gray-300 hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{ttsMode === "voiceclone" && (
|
||||
<SelectPopover
|
||||
sheetTitle="选择语气"
|
||||
trigger={({ open, toggle }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className="rounded-lg border border-white/10 bg-black/25 px-2.5 py-1.5 text-xs text-gray-200 whitespace-nowrap flex items-center gap-1 transition-colors hover:border-white/30"
|
||||
>
|
||||
语气: {currentEmotionLabel}
|
||||
<ChevronDown className={`h-3 w-3 transition-transform ${open ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{({ close }) => (
|
||||
<div className="space-y-1">
|
||||
{emotionOptions.map((opt) => {
|
||||
const isSelected = emotion === opt.value;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
data-popover-selected={isSelected ? "true" : undefined}
|
||||
onClick={() => {
|
||||
onEmotionChange(opt.value);
|
||||
close();
|
||||
}}
|
||||
className={`flex w-full items-center justify-between rounded-lg border px-3 py-2 text-left text-xs transition-colors ${isSelected
|
||||
? "border-purple-500 bg-purple-500/20 text-purple-200"
|
||||
: "border-white/10 bg-white/5 text-gray-300 hover:border-white/30"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
{isSelected && <Check className="h-3.5 w-3.5 text-purple-300" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</SelectPopover>
|
||||
)}
|
||||
{ttsMode === "voiceclone" && (
|
||||
<SelectPopover
|
||||
sheetTitle="选择语速"
|
||||
trigger={({ open, toggle }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className="rounded-lg border border-white/10 bg-black/25 px-2.5 py-1.5 text-xs text-gray-200 whitespace-nowrap flex items-center gap-1 transition-colors hover:border-white/30"
|
||||
>
|
||||
语速: {currentSpeedLabel}
|
||||
<ChevronDown className={`h-3 w-3 transition-transform ${open ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{({ close }) => (
|
||||
<div className="space-y-1">
|
||||
{speedOptions.map((opt) => {
|
||||
const isSelected = speed === opt.value;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
data-popover-selected={isSelected ? "true" : undefined}
|
||||
onClick={() => {
|
||||
onSpeedChange(opt.value);
|
||||
close();
|
||||
}}
|
||||
className={`flex w-full items-center justify-between rounded-lg border px-3 py-2 text-left text-xs transition-colors ${isSelected
|
||||
? "border-purple-500 bg-purple-500/20 text-purple-200"
|
||||
: "border-white/10 bg-white/5 text-gray-300 hover:border-white/30"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
{isSelected && <Check className="h-3.5 w-3.5 text-purple-300" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</SelectPopover>
|
||||
)}
|
||||
<button
|
||||
onClick={onGenerateAudio}
|
||||
disabled={isGeneratingAudio || !canGenerate}
|
||||
@@ -245,62 +267,88 @@ export function GeneratedAudiosPanel({
|
||||
配音列表
|
||||
</h2>
|
||||
<div className="flex gap-1.5">
|
||||
{ttsMode === "voiceclone" && (
|
||||
<div ref={emotionRef} className="relative">
|
||||
<button
|
||||
onClick={() => setEmotionOpen((v) => !v)}
|
||||
className="px-2 py-1 text-xs bg-white/10 hover:bg-white/20 rounded text-gray-300 whitespace-nowrap flex items-center gap-1 transition-all"
|
||||
>
|
||||
语气: {currentEmotionLabel}
|
||||
<ChevronDown className={`h-3 w-3 transition-transform ${emotionOpen ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
{emotionOpen && (
|
||||
<div className="absolute right-0 top-full mt-1 bg-gray-800 border border-white/20 rounded-lg shadow-xl py-1 z-50 min-w-[80px]">
|
||||
{emotionOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => { onEmotionChange(opt.value); setEmotionOpen(false); }}
|
||||
className={`w-full text-left px-3 py-1.5 text-xs transition-colors ${
|
||||
emotion === opt.value
|
||||
? "bg-purple-600/40 text-purple-200"
|
||||
: "text-gray-300 hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{ttsMode === "voiceclone" && (
|
||||
<div ref={speedRef} className="relative">
|
||||
<button
|
||||
onClick={() => setSpeedOpen((v) => !v)}
|
||||
className="px-2 py-1 text-xs bg-white/10 hover:bg-white/20 rounded text-gray-300 whitespace-nowrap flex items-center gap-1 transition-all"
|
||||
>
|
||||
语速: {currentSpeedLabel}
|
||||
<ChevronDown className={`h-3 w-3 transition-transform ${speedOpen ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
{speedOpen && (
|
||||
<div className="absolute right-0 top-full mt-1 bg-gray-800 border border-white/20 rounded-lg shadow-xl py-1 z-50 min-w-[80px]">
|
||||
{speedOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => { onSpeedChange(opt.value); setSpeedOpen(false); }}
|
||||
className={`w-full text-left px-3 py-1.5 text-xs transition-colors ${
|
||||
speed === opt.value
|
||||
? "bg-purple-600/40 text-purple-200"
|
||||
: "text-gray-300 hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{ttsMode === "voiceclone" && (
|
||||
<SelectPopover
|
||||
sheetTitle="选择语气"
|
||||
trigger={({ open, toggle }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className="rounded-lg border border-white/10 bg-black/25 px-2.5 py-1.5 text-xs text-gray-200 whitespace-nowrap flex items-center gap-1 transition-colors hover:border-white/30"
|
||||
>
|
||||
语气: {currentEmotionLabel}
|
||||
<ChevronDown className={`h-3 w-3 transition-transform ${open ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{({ close }) => (
|
||||
<div className="space-y-1">
|
||||
{emotionOptions.map((opt) => {
|
||||
const isSelected = emotion === opt.value;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
data-popover-selected={isSelected ? "true" : undefined}
|
||||
onClick={() => {
|
||||
onEmotionChange(opt.value);
|
||||
close();
|
||||
}}
|
||||
className={`flex w-full items-center justify-between rounded-lg border px-3 py-2 text-left text-xs transition-colors ${isSelected
|
||||
? "border-purple-500 bg-purple-500/20 text-purple-200"
|
||||
: "border-white/10 bg-white/5 text-gray-300 hover:border-white/30"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
{isSelected && <Check className="h-3.5 w-3.5 text-purple-300" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</SelectPopover>
|
||||
)}
|
||||
{ttsMode === "voiceclone" && (
|
||||
<SelectPopover
|
||||
sheetTitle="选择语速"
|
||||
trigger={({ open, toggle }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className="rounded-lg border border-white/10 bg-black/25 px-2.5 py-1.5 text-xs text-gray-200 whitespace-nowrap flex items-center gap-1 transition-colors hover:border-white/30"
|
||||
>
|
||||
语速: {currentSpeedLabel}
|
||||
<ChevronDown className={`h-3 w-3 transition-transform ${open ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{({ close }) => (
|
||||
<div className="space-y-1">
|
||||
{speedOptions.map((opt) => {
|
||||
const isSelected = speed === opt.value;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
data-popover-selected={isSelected ? "true" : undefined}
|
||||
onClick={() => {
|
||||
onSpeedChange(opt.value);
|
||||
close();
|
||||
}}
|
||||
className={`flex w-full items-center justify-between rounded-lg border px-3 py-2 text-left text-xs transition-colors ${isSelected
|
||||
? "border-purple-500 bg-purple-500/20 text-purple-200"
|
||||
: "border-white/10 bg-white/5 text-gray-300 hover:border-white/30"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
{isSelected && <Check className="h-3.5 w-3.5 text-purple-300" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</SelectPopover>
|
||||
)}
|
||||
<button
|
||||
onClick={onGenerateAudio}
|
||||
disabled={isGeneratingAudio || !canGenerate}
|
||||
@@ -349,87 +397,142 @@ export function GeneratedAudiosPanel({
|
||||
)}
|
||||
|
||||
{/* 配音列表 */}
|
||||
{generatedAudios.length === 0 ? (
|
||||
<div className="text-center py-6 text-gray-400">
|
||||
<p className="text-sm">暂无配音</p>
|
||||
<p className="text-xs mt-1 text-gray-500">点击「生成配音」创建</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-48 sm:max-h-56 overflow-y-auto hide-scrollbar">
|
||||
{generatedAudios.map((audio) => {
|
||||
const isSelected = selectedAudioId === audio.id;
|
||||
return (
|
||||
<div
|
||||
key={audio.id}
|
||||
onClick={() => onSelectAudio(audio)}
|
||||
className={`p-3 rounded-lg border transition-all cursor-pointer flex items-center justify-between group ${
|
||||
isSelected
|
||||
? "border-purple-500 bg-purple-500/20"
|
||||
: "border-white/10 bg-white/5 hover:border-white/30"
|
||||
}`}
|
||||
>
|
||||
{editingId === audio.id ? (
|
||||
<div className="flex-1 flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
className="flex-1 bg-black/40 border border-white/20 rounded-md px-2 py-1 text-xs text-white"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") saveEditing(audio.id, e as unknown as React.MouseEvent);
|
||||
if (e.key === "Escape") cancelEditing(e as unknown as React.MouseEvent);
|
||||
}}
|
||||
/>
|
||||
<button onClick={(e) => saveEditing(audio.id, e)} className="p-1 text-green-400 hover:text-green-300" title="保存">
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
<button onClick={cancelEditing} className="p-1 text-gray-400 hover:text-white" title="取消">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-white text-sm truncate">{audio.name}</div>
|
||||
<div className="text-gray-400 text-xs">{audio.duration_sec.toFixed(1)}s</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 pl-2 opacity-40 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={(e) => togglePlay(audio, e)}
|
||||
className="p-1 text-gray-500 hover:text-purple-400 transition-colors"
|
||||
title={playingId === audio.id ? "暂停" : "播放"}
|
||||
>
|
||||
{playingId === audio.id ? (
|
||||
<Pause className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => startEditing(audio, e)}
|
||||
className="p-1 text-gray-500 hover:text-white transition-colors"
|
||||
title="重命名"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteAudio(audio.id);
|
||||
}}
|
||||
className="p-1 text-gray-500 hover:text-red-400 transition-colors"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{generatedAudios.length === 0 ? (
|
||||
<div className="text-center py-6 text-gray-400">
|
||||
<p className="text-sm">暂无配音</p>
|
||||
<p className="text-xs mt-1 text-gray-500">点击「生成配音」创建</p>
|
||||
</div>
|
||||
) : (
|
||||
<SelectPopover
|
||||
sheetTitle="选择配音"
|
||||
onOpen={handleOpenAudioPopover}
|
||||
trigger={({ open, toggle }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className="w-full rounded-xl border border-white/10 bg-black/25 px-3 py-2.5 text-left transition-colors hover:border-white/30"
|
||||
>
|
||||
<span className="flex items-center justify-between gap-3">
|
||||
<span className="min-w-0">
|
||||
<span className="block text-xs text-gray-400">当前配音</span>
|
||||
<span className="mt-0.5 block truncate text-sm text-white">
|
||||
{selectedAudio ? selectedAudio.name : "请选择配音"}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown className={`h-4 w-4 text-gray-300 transition-transform ${open ? "rotate-180" : ""}`} />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{({ close }) => (
|
||||
<div className="space-y-2">
|
||||
<div className="rounded-lg border border-white/10 bg-black/30 px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Search className="h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={audioFilter}
|
||||
onChange={(e) => setAudioFilter(e.target.value)}
|
||||
placeholder="搜索配音..."
|
||||
className="w-full bg-transparent text-sm text-white placeholder-gray-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredAudios.length === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-gray-400">没有匹配的配音</div>
|
||||
) : (
|
||||
<div ref={audioListContainerRef} className="space-y-1" style={{ contentVisibility: "auto" }}>
|
||||
{filteredAudios.map((audio) => {
|
||||
const isSelected = selectedAudioId === audio.id;
|
||||
return (
|
||||
<div
|
||||
key={audio.id}
|
||||
data-popover-selected={isSelected ? "true" : undefined}
|
||||
data-audio-selected={isSelected ? "true" : "false"}
|
||||
className={`flex items-center justify-between gap-2 rounded-lg border px-3 py-2 transition-colors ${isSelected
|
||||
? "border-purple-500 bg-purple-500/20"
|
||||
: "border-white/10 bg-white/5 hover:border-white/30"
|
||||
}`}
|
||||
>
|
||||
{editingId === audio.id ? (
|
||||
<div className="flex-1 flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
className="flex-1 bg-black/40 border border-white/20 rounded-md px-2 py-1 text-xs text-white"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") saveEditing(audio.id, e as unknown as React.MouseEvent);
|
||||
if (e.key === "Escape") cancelEditing(e as unknown as React.MouseEvent);
|
||||
}}
|
||||
/>
|
||||
<button type="button" onClick={(e) => saveEditing(audio.id, e)} className="p-1 text-green-400 hover:text-green-300" title="保存">
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
<button type="button" onClick={cancelEditing} className="p-1 text-gray-400 hover:text-white" title="取消">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelectAudio(audio);
|
||||
close();
|
||||
}}
|
||||
className="min-w-0 flex-1 text-left"
|
||||
>
|
||||
<span className="block truncate text-sm text-white">{audio.name}</span>
|
||||
<span className="mt-0.5 block text-xs text-gray-400">{audio.duration_sec.toFixed(1)}s</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{editingId !== audio.id && (
|
||||
<div className="flex items-center gap-1 pl-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => togglePlay(audio, e)}
|
||||
className="p-1 text-gray-400 hover:text-purple-300 transition-colors"
|
||||
title={playingId === audio.id ? "暂停" : "播放"}
|
||||
>
|
||||
{playingId === audio.id ? (
|
||||
<Pause className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => startEditing(audio, e)}
|
||||
className="p-1 text-gray-400 hover:text-white transition-colors"
|
||||
title="重命名"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteAudio(audio.id);
|
||||
}}
|
||||
className="p-1 text-gray-400 hover:text-red-400 transition-colors"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{isSelected && <Check className="h-3.5 w-3.5 text-purple-300" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SelectPopover>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { RefreshCw, Trash2 } from "lucide-react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { RefreshCw, Trash2, Search, ChevronDown, Check } from "lucide-react";
|
||||
import { SelectPopover } from "@/shared/ui/SelectPopover";
|
||||
|
||||
interface GeneratedVideo {
|
||||
id: string;
|
||||
@@ -29,6 +31,29 @@ export function HistoryList({
|
||||
formatDate,
|
||||
embedded = false,
|
||||
}: HistoryListProps) {
|
||||
const [videoFilter, setVideoFilter] = useState("");
|
||||
const videoListContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const selectedVideo = generatedVideos.find((v) => v.id === selectedVideoId) || null;
|
||||
const filteredVideos = useMemo(() => {
|
||||
const query = videoFilter.trim().toLowerCase();
|
||||
if (!query) return generatedVideos;
|
||||
return generatedVideos.filter((v) => formatDate(v.created_at).toLowerCase().includes(query));
|
||||
}, [generatedVideos, videoFilter, formatDate]);
|
||||
|
||||
const handleOpenVideoPopover = useCallback(() => {
|
||||
setVideoFilter("");
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const container = videoListContainerRef.current;
|
||||
if (!container) return;
|
||||
const selectedRow = container.querySelector<HTMLElement>("[data-video-selected='true']");
|
||||
selectedRow?.scrollIntoView({ block: "nearest", behavior: "auto" });
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{!embedded && (
|
||||
@@ -48,36 +73,98 @@ export function HistoryList({
|
||||
<p>暂无生成的作品</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="space-y-2 max-h-64 overflow-y-auto hide-scrollbar"
|
||||
style={{ contentVisibility: 'auto' }}
|
||||
>
|
||||
{generatedVideos.map((v) => (
|
||||
<div
|
||||
key={v.id}
|
||||
ref={(el) => registerVideoRef(v.id, el)}
|
||||
className={`p-3 rounded-lg border transition-all flex items-center justify-between group ${selectedVideoId === v.id
|
||||
? "border-purple-500 bg-purple-500/20"
|
||||
: "border-white/10 bg-white/5 hover:border-white/30"
|
||||
}`}
|
||||
<SelectPopover
|
||||
sheetTitle="选择作品"
|
||||
onOpen={handleOpenVideoPopover}
|
||||
trigger={({ open, toggle }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className="w-full rounded-xl border border-white/10 bg-black/25 px-3 py-2.5 text-left transition-colors hover:border-white/30"
|
||||
>
|
||||
<button onClick={() => onSelectVideo(v)} className="flex-1 text-left">
|
||||
<div className="text-white text-sm truncate">{formatDate(v.created_at)}</div>
|
||||
<div className="text-gray-400 text-xs">{v.size_mb.toFixed(1)} MB</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteVideo(v.id);
|
||||
}}
|
||||
className="p-1 text-gray-500 hover:text-red-400 opacity-40 group-hover:opacity-100 transition-opacity"
|
||||
title="删除视频"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="flex items-center justify-between gap-3">
|
||||
<span className="min-w-0">
|
||||
<span className="block text-xs text-gray-400">当前作品</span>
|
||||
<span className="mt-0.5 block truncate text-sm text-white">
|
||||
{selectedVideo ? formatDate(selectedVideo.created_at) : "请选择作品"}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown className={`h-4 w-4 text-gray-300 transition-transform ${open ? "rotate-180" : ""}`} />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{({ close }) => (
|
||||
<div className="space-y-2">
|
||||
<div className="rounded-lg border border-white/10 bg-black/30 px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Search className="h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={videoFilter}
|
||||
onChange={(e) => setVideoFilter(e.target.value)}
|
||||
placeholder="搜索作品..."
|
||||
className="w-full bg-transparent text-sm text-white placeholder-gray-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredVideos.length === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-gray-400">没有匹配的作品</div>
|
||||
) : (
|
||||
<div
|
||||
ref={videoListContainerRef}
|
||||
className="space-y-1"
|
||||
style={{ contentVisibility: "auto" }}
|
||||
>
|
||||
{filteredVideos.map((v) => {
|
||||
const isSelected = selectedVideoId === v.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={v.id}
|
||||
ref={(el) => registerVideoRef(v.id, el)}
|
||||
data-popover-selected={isSelected ? "true" : undefined}
|
||||
data-video-selected={isSelected ? "true" : "false"}
|
||||
className={`flex items-center justify-between gap-2 rounded-lg border px-3 py-2 transition-colors ${isSelected
|
||||
? "border-purple-500 bg-purple-500/20"
|
||||
: "border-white/10 bg-white/5 hover:border-white/30"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelectVideo(v);
|
||||
close();
|
||||
}}
|
||||
className="min-w-0 flex-1 text-left"
|
||||
>
|
||||
<span className="block truncate text-sm text-white">{formatDate(v.created_at)}</span>
|
||||
<span className="mt-0.5 block text-xs text-gray-400">{v.size_mb.toFixed(1)} MB</span>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2 pl-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteVideo(v.id);
|
||||
}}
|
||||
className="p-1 text-gray-400 hover:text-red-400"
|
||||
title="删除视频"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
{isSelected && <Check className="h-4 w-4 text-purple-300" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SelectPopover>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -97,6 +97,8 @@ export function HomePage() {
|
||||
setTitleDisplayMode,
|
||||
outputAspectRatio,
|
||||
setOutputAspectRatio,
|
||||
lipsyncModelMode,
|
||||
setLipsyncModelMode,
|
||||
resolveAssetUrl,
|
||||
getFontFormat,
|
||||
buildTextShadow,
|
||||
@@ -130,6 +132,7 @@ export function HomePage() {
|
||||
startRecording,
|
||||
stopRecording,
|
||||
useRecording,
|
||||
discardRecording,
|
||||
formatRecordingTime,
|
||||
bgmList,
|
||||
bgmLoading,
|
||||
@@ -141,8 +144,6 @@ export function HomePage() {
|
||||
setSelectedBgmId,
|
||||
playingBgmId,
|
||||
toggleBgmPreview,
|
||||
bgmVolume,
|
||||
setBgmVolume,
|
||||
bgmListContainerRef,
|
||||
registerBgmItemRef,
|
||||
currentTask,
|
||||
@@ -222,8 +223,6 @@ export function HomePage() {
|
||||
onChangeText={setText}
|
||||
onOpenExtractModal={() => setExtractModalOpen(true)}
|
||||
onOpenRewriteModal={() => setRewriteModalOpen(true)}
|
||||
onGenerateMeta={handleGenerateMeta}
|
||||
isGeneratingMeta={isGeneratingMeta}
|
||||
onTranslate={handleTranslate}
|
||||
isTranslating={isTranslating}
|
||||
hasOriginalText={originalText !== null}
|
||||
@@ -235,7 +234,7 @@ export function HomePage() {
|
||||
/>
|
||||
|
||||
{/* 二、配音 */}
|
||||
<div className="bg-white/5 rounded-2xl p-4 sm:p-6 border border-white/10 backdrop-blur-sm">
|
||||
<div className="relative z-20 bg-white/5 rounded-2xl p-4 sm:p-6 border border-white/10 backdrop-blur-sm">
|
||||
<h2 className="text-base sm:text-lg font-semibold text-white mb-4">
|
||||
二、配音
|
||||
</h2>
|
||||
@@ -274,6 +273,7 @@ export function HomePage() {
|
||||
onStartRecording={startRecording}
|
||||
onStopRecording={stopRecording}
|
||||
onUseRecording={useRecording}
|
||||
onDiscardRecording={discardRecording}
|
||||
formatRecordingTime={formatRecordingTime}
|
||||
/>
|
||||
)}
|
||||
@@ -360,6 +360,9 @@ export function HomePage() {
|
||||
<TitleSubtitlePanel
|
||||
showStylePreview={showStylePreview}
|
||||
onTogglePreview={() => setShowStylePreview((prev) => !prev)}
|
||||
onGenerateMeta={handleGenerateMeta}
|
||||
isGeneratingMeta={isGeneratingMeta}
|
||||
canGenerateMeta={!!text.trim()}
|
||||
videoTitle={videoTitle}
|
||||
onTitleChange={titleInput.handleChange}
|
||||
onTitleCompositionStart={titleInput.handleCompositionStart}
|
||||
@@ -419,8 +422,6 @@ export function HomePage() {
|
||||
onSelectBgm={setSelectedBgmId}
|
||||
playingBgmId={playingBgmId}
|
||||
onTogglePreview={toggleBgmPreview}
|
||||
bgmVolume={bgmVolume}
|
||||
onVolumeChange={setBgmVolume}
|
||||
bgmListContainerRef={bgmListContainerRef}
|
||||
registerBgmItemRef={registerBgmItemRef}
|
||||
/>
|
||||
@@ -431,6 +432,8 @@ export function HomePage() {
|
||||
progress={currentTask?.progress || 0}
|
||||
materialCount={selectedMaterials.length}
|
||||
disabled={isGenerating || selectedMaterials.length === 0 || !selectedAudio}
|
||||
modelMode={lipsyncModelMode}
|
||||
onModelModeChange={setLipsyncModelMode}
|
||||
onGenerate={handleGenerate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type ChangeEvent, type MouseEvent, useMemo } from "react";
|
||||
import { Upload, RefreshCw, Eye, Trash2, X, Pencil, Check } from "lucide-react";
|
||||
import { type ChangeEvent, type MouseEvent, useCallback, useMemo, useRef, useState } from "react";
|
||||
import { Upload, RefreshCw, Eye, Trash2, X, Pencil, Check, Search, ChevronDown } from "lucide-react";
|
||||
import type { Material } from "@/shared/types/material";
|
||||
import { SelectPopover } from "@/shared/ui/SelectPopover";
|
||||
|
||||
interface MaterialSelectorProps {
|
||||
materials: Material[];
|
||||
@@ -53,8 +54,46 @@ export function MaterialSelector({
|
||||
registerMaterialRef,
|
||||
embedded = false,
|
||||
}: MaterialSelectorProps) {
|
||||
const [materialFilter, setMaterialFilter] = useState("");
|
||||
const materialListContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const selectedSet = useMemo(() => new Set(selectedMaterials), [selectedMaterials]);
|
||||
const isFull = selectedMaterials.length >= 4;
|
||||
const selectedMaterialItems = useMemo(
|
||||
() => selectedMaterials.map((id) => materials.find((m) => m.id === id)).filter((m): m is Material => Boolean(m)),
|
||||
[materials, selectedMaterials],
|
||||
);
|
||||
const filteredMaterials = useMemo(() => {
|
||||
const query = materialFilter.trim().toLowerCase();
|
||||
if (!query) return materials;
|
||||
return materials.filter((m) => (m.scene || m.name).toLowerCase().includes(query));
|
||||
}, [materialFilter, materials]);
|
||||
|
||||
const selectedSummary = useMemo(() => {
|
||||
if (selectedMaterialItems.length === 0) {
|
||||
return "请选择素材(最多4个)";
|
||||
}
|
||||
const names = selectedMaterialItems
|
||||
.slice(0, 2)
|
||||
.map((m) => m.scene || m.name)
|
||||
.join("、");
|
||||
if (selectedMaterialItems.length > 2) {
|
||||
return `${names} +${selectedMaterialItems.length - 2}`;
|
||||
}
|
||||
return names;
|
||||
}, [selectedMaterialItems]);
|
||||
|
||||
const handleOpenMaterialPopover = useCallback(() => {
|
||||
setMaterialFilter("");
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const container = materialListContainerRef.current;
|
||||
if (!container) return;
|
||||
const selectedRow = container.querySelector<HTMLElement>("[data-material-selected='true']");
|
||||
selectedRow?.scrollIntoView({ block: "nearest", behavior: "auto" });
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
@@ -151,100 +190,146 @@ export function MaterialSelector({
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="space-y-2 max-h-48 sm:max-h-64 overflow-y-auto hide-scrollbar"
|
||||
style={{ contentVisibility: 'auto' }}
|
||||
<SelectPopover
|
||||
sheetTitle="选择视频素材"
|
||||
onOpen={handleOpenMaterialPopover}
|
||||
trigger={({ open, toggle }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className="w-full rounded-xl border border-white/10 bg-black/25 px-3 py-2.5 text-left transition-colors hover:border-white/30"
|
||||
>
|
||||
<span className="flex items-center justify-between gap-3">
|
||||
<span className="min-w-0">
|
||||
<span className="block text-xs text-gray-400">已选 {selectedMaterials.length}/4 个素材</span>
|
||||
<span className="mt-0.5 block truncate text-sm text-white">{selectedSummary}</span>
|
||||
</span>
|
||||
<ChevronDown className={`h-4 w-4 text-gray-300 transition-transform ${open ? "rotate-180" : ""}`} />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{materials.map((m) => {
|
||||
const isSelected = selectedSet.has(m.id);
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
ref={(el) => registerMaterialRef(m.id, el)}
|
||||
className={`p-3 rounded-lg border transition-all flex items-center justify-between group ${isSelected
|
||||
? "border-purple-500 bg-purple-500/20"
|
||||
: isFull
|
||||
? "border-white/5 bg-white/[0.02] opacity-50 cursor-not-allowed"
|
||||
: "border-white/10 bg-white/5 hover:border-white/30"
|
||||
}`}
|
||||
>
|
||||
{editingMaterialId === m.id ? (
|
||||
<div className="flex-1 flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
value={editMaterialName}
|
||||
onChange={(e) => onEditNameChange(e.target.value)}
|
||||
className="flex-1 bg-black/40 border border-white/20 rounded-md px-2 py-1 text-xs text-white"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => onSaveEditing(m.id, e)}
|
||||
className="p-1 text-green-400 hover:text-green-300"
|
||||
title="保存"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancelEditing}
|
||||
className="p-1 text-gray-400 hover:text-white"
|
||||
title="取消"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => onToggleMaterial(m.id)} disabled={isFull && !isSelected} className="flex-1 text-left flex items-center gap-2">
|
||||
{/* 复选框 */}
|
||||
<span
|
||||
className={`flex-shrink-0 w-4 h-4 rounded border flex items-center justify-center text-[10px] ${isSelected
|
||||
? "border-purple-500 bg-purple-500 text-white"
|
||||
: "border-white/30 text-transparent"
|
||||
}`}
|
||||
>
|
||||
{isSelected ? "✓" : ""}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-white text-sm truncate">{m.scene || m.name}</div>
|
||||
<div className="text-gray-400 text-xs">{m.size_mb.toFixed(1)} MB</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-2 pl-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (m.path) {
|
||||
onPreviewMaterial(m.path);
|
||||
}
|
||||
}}
|
||||
className="p-1 text-gray-500 hover:text-white opacity-40 group-hover:opacity-100 transition-opacity"
|
||||
title="预览视频"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
{editingMaterialId !== m.id && (
|
||||
<button
|
||||
onClick={(e) => onStartEditing(m, e)}
|
||||
className="p-1 text-gray-500 hover:text-white opacity-40 group-hover:opacity-100 transition-opacity"
|
||||
title="重命名"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteMaterial(m.id);
|
||||
}}
|
||||
className="p-1 text-gray-500 hover:text-red-400 opacity-40 group-hover:opacity-100 transition-opacity"
|
||||
title="删除素材"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
{() => (
|
||||
<div className="space-y-2">
|
||||
<div className="rounded-lg border border-white/10 bg-black/30 px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Search className="h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={materialFilter}
|
||||
onChange={(e) => setMaterialFilter(e.target.value)}
|
||||
placeholder="搜索素材名称..."
|
||||
className="w-full bg-transparent text-sm text-white placeholder-gray-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{filteredMaterials.length === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-gray-400">没有匹配的素材</div>
|
||||
) : (
|
||||
<div
|
||||
ref={materialListContainerRef}
|
||||
className="space-y-1"
|
||||
style={{ contentVisibility: "auto" }}
|
||||
>
|
||||
{filteredMaterials.map((m) => {
|
||||
const isSelected = selectedSet.has(m.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
ref={(el) => registerMaterialRef(m.id, el)}
|
||||
data-popover-selected={isSelected ? "true" : undefined}
|
||||
data-material-selected={isSelected ? "true" : "false"}
|
||||
className={`flex items-center justify-between gap-2 rounded-lg border px-3 py-2 transition-colors ${isSelected
|
||||
? "border-purple-500 bg-purple-500/20"
|
||||
: isFull
|
||||
? "border-white/5 bg-white/[0.02] opacity-50"
|
||||
: "border-white/10 bg-white/5 hover:border-white/30"
|
||||
}`}
|
||||
>
|
||||
{editingMaterialId === m.id ? (
|
||||
<div className="flex-1 flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
value={editMaterialName}
|
||||
onChange={(e) => onEditNameChange(e.target.value)}
|
||||
className="flex-1 rounded-md border border-white/20 bg-black/40 px-2 py-1 text-xs text-white"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => onSaveEditing(m.id, e)}
|
||||
className="p-1 text-green-400 hover:text-green-300"
|
||||
title="保存"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancelEditing}
|
||||
className="p-1 text-gray-400 hover:text-white"
|
||||
title="取消"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleMaterial(m.id)}
|
||||
disabled={isFull && !isSelected}
|
||||
className="min-w-0 flex-1 text-left"
|
||||
>
|
||||
<span className="block truncate text-sm text-white">{m.scene || m.name}</span>
|
||||
<span className="mt-0.5 block text-xs text-gray-400">{m.size_mb.toFixed(1)} MB</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 pl-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (m.path) {
|
||||
onPreviewMaterial(m.path);
|
||||
}
|
||||
}}
|
||||
className="p-1 text-gray-400 hover:text-purple-300"
|
||||
title="预览视频"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
{editingMaterialId !== m.id && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => onStartEditing(m, e)}
|
||||
className="p-1 text-gray-400 hover:text-white"
|
||||
title="重命名"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteMaterial(m.id);
|
||||
}}
|
||||
className="p-1 text-gray-400 hover:text-red-400"
|
||||
title="删除素材"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
{isSelected && <Check className="h-4 w-4 text-purple-300" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SelectPopover>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { MouseEvent } from "react";
|
||||
import { Upload, RefreshCw, Play, Pause, Pencil, Trash2, Check, X, Mic, Square, RotateCw } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ChangeEvent, MouseEvent } from "react";
|
||||
import { Upload, RefreshCw, Play, Pause, Pencil, Trash2, Check, X, Mic, Square, RotateCw, Search, ChevronDown } from "lucide-react";
|
||||
import { SelectPopover } from "@/shared/ui/SelectPopover";
|
||||
import { AppModal, AppModalHeader } from "@/shared/ui/AppModal";
|
||||
|
||||
interface RefAudio {
|
||||
id: string;
|
||||
@@ -36,7 +38,8 @@ interface RefAudioPanelProps {
|
||||
recordingTime: number;
|
||||
onStartRecording: () => void;
|
||||
onStopRecording: () => void;
|
||||
onUseRecording: () => void;
|
||||
onUseRecording: () => void | Promise<void>;
|
||||
onDiscardRecording: () => void;
|
||||
formatRecordingTime: (seconds: number) => string;
|
||||
}
|
||||
|
||||
@@ -68,9 +71,26 @@ export function RefAudioPanel({
|
||||
onStartRecording,
|
||||
onStopRecording,
|
||||
onUseRecording,
|
||||
onDiscardRecording,
|
||||
formatRecordingTime,
|
||||
}: RefAudioPanelProps) {
|
||||
const [recordedUrl, setRecordedUrl] = useState<string | null>(null);
|
||||
const [refAudioFilter, setRefAudioFilter] = useState("");
|
||||
const [recordingModalOpen, setRecordingModalOpen] = useState(false);
|
||||
const [recordedPreviewPlaying, setRecordedPreviewPlaying] = useState(false);
|
||||
const [recordedPreviewCurrentTime, setRecordedPreviewCurrentTime] = useState(0);
|
||||
const [recordedPreviewDuration, setRecordedPreviewDuration] = useState(0);
|
||||
const refAudioListContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const recordedAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
const stopRecordedPreview = useCallback(() => {
|
||||
const player = recordedAudioRef.current;
|
||||
if (!player) return;
|
||||
player.pause();
|
||||
player.currentTime = 0;
|
||||
setRecordedPreviewPlaying(false);
|
||||
setRecordedPreviewCurrentTime(0);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!recordedBlob) {
|
||||
@@ -88,45 +108,95 @@ export function RefAudioPanel({
|
||||
const needsRetranscribe = (audio: RefAudio) =>
|
||||
audio.ref_text.startsWith(OLD_FIXED_REF_TEXT);
|
||||
|
||||
const selectedRefAudioLabel = selectedRefAudio?.name || "请选择参考音频";
|
||||
const filteredRefAudios = useMemo(() => {
|
||||
const query = refAudioFilter.trim().toLowerCase();
|
||||
if (!query) return refAudios;
|
||||
return refAudios.filter((audio) => audio.name.toLowerCase().includes(query));
|
||||
}, [refAudioFilter, refAudios]);
|
||||
|
||||
const handleOpenRefAudioPopover = useCallback(() => {
|
||||
setRefAudioFilter("");
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const container = refAudioListContainerRef.current;
|
||||
if (!container) return;
|
||||
const selectedRow = container.querySelector<HTMLElement>("[data-ref-selected='true']");
|
||||
selectedRow?.scrollIntoView({ block: "nearest", behavior: "auto" });
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const closeRecordingModal = () => {
|
||||
stopRecordedPreview();
|
||||
if (isRecording) {
|
||||
onStopRecording();
|
||||
}
|
||||
setRecordingModalOpen(false);
|
||||
};
|
||||
|
||||
const handleUseRecordingAndClose = () => {
|
||||
stopRecordedPreview();
|
||||
setRecordingModalOpen(false);
|
||||
void onUseRecording();
|
||||
};
|
||||
|
||||
const handleToggleRecordedPreview = () => {
|
||||
const player = recordedAudioRef.current;
|
||||
if (!player) return;
|
||||
|
||||
if (player.paused) {
|
||||
player.play().catch(() => {
|
||||
setRecordedPreviewPlaying(false);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
player.pause();
|
||||
};
|
||||
|
||||
const handleRecordedSeek = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const player = recordedAudioRef.current;
|
||||
if (!player) return;
|
||||
const nextTime = Number(event.target.value);
|
||||
player.currentTime = Number.isFinite(nextTime) ? nextTime : 0;
|
||||
setRecordedPreviewCurrentTime(Number.isFinite(nextTime) ? nextTime : 0);
|
||||
};
|
||||
|
||||
const totalRecordedPreviewTime =
|
||||
Number.isFinite(recordedPreviewDuration) && recordedPreviewDuration > 0
|
||||
? recordedPreviewDuration
|
||||
: recordingTime;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm text-gray-300">📁 我的参考音频 <span className="text-xs text-gray-500 font-normal">(上传3-10秒语音样本)</span></span>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="file"
|
||||
id="ref-audio-upload"
|
||||
accept=".wav,.mp3,.m4a,.webm,.ogg,.flac,.aac"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
onUploadRefAudio(file);
|
||||
}
|
||||
e.target.value = '';
|
||||
}}
|
||||
className="hidden"
|
||||
/>
|
||||
<label
|
||||
htmlFor="ref-audio-upload"
|
||||
className={`px-2 py-1 text-xs rounded cursor-pointer transition-all flex items-center gap-1 ${isUploadingRef
|
||||
? "bg-gray-600 cursor-not-allowed text-gray-400"
|
||||
: "bg-purple-600 hover:bg-purple-700 text-white"
|
||||
}`}
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
上传
|
||||
</label>
|
||||
<button
|
||||
onClick={onFetchRefAudios}
|
||||
className="px-2 py-1 text-xs bg-white/10 hover:bg-white/20 rounded text-gray-300 flex items-center gap-1"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={onFetchRefAudios}
|
||||
className="px-2 py-1 text-xs bg-white/10 hover:bg-white/20 rounded text-gray-300 flex items-center gap-1"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
id="ref-audio-upload"
|
||||
accept=".wav,.mp3,.m4a,.webm,.ogg,.flac,.aac"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
onUploadRefAudio(file);
|
||||
}
|
||||
e.target.value = "";
|
||||
}}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{isUploadingRef && (
|
||||
<div className="mb-2 p-2 bg-purple-500/10 rounded text-sm text-purple-300">
|
||||
⏳ 上传并识别中...
|
||||
@@ -147,146 +217,316 @@ export function RefAudioPanel({
|
||||
暂无参考音频,请上传或录制
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2" style={{ contentVisibility: 'auto' }}>
|
||||
{refAudios.map((audio) => (
|
||||
<div
|
||||
key={audio.id}
|
||||
className={`p-2 rounded-lg border transition-all relative group cursor-pointer ${selectedRefAudio?.id === audio.id
|
||||
? "border-purple-500 bg-purple-500/20"
|
||||
: "border-white/10 bg-white/5 hover:border-white/30"
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (editingAudioId !== audio.id) {
|
||||
onSelectRefAudio(audio);
|
||||
}
|
||||
}}
|
||||
<SelectPopover
|
||||
sheetTitle="选择参考音频"
|
||||
onOpen={handleOpenRefAudioPopover}
|
||||
trigger={({ open, toggle }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className="w-full rounded-xl border border-white/10 bg-black/25 px-3 py-2.5 text-left transition-colors hover:border-white/30"
|
||||
>
|
||||
{editingAudioId === audio.id ? (
|
||||
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<span className="flex items-center justify-between gap-3">
|
||||
<span className="min-w-0">
|
||||
<span className="block text-xs text-gray-400">当前参考音频</span>
|
||||
<span className="mt-0.5 block truncate text-sm text-white">{selectedRefAudioLabel}</span>
|
||||
</span>
|
||||
<ChevronDown className={`h-4 w-4 text-gray-300 transition-transform ${open ? "rotate-180" : ""}`} />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{({ close }) => (
|
||||
<div className="space-y-2">
|
||||
<div className="rounded-lg border border-white/10 bg-black/30 px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Search className="h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e) => onEditNameChange(e.target.value)}
|
||||
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 unknown as MouseEvent);
|
||||
if (e.key === 'Escape') onCancelEditing(e as unknown as MouseEvent);
|
||||
}}
|
||||
value={refAudioFilter}
|
||||
onChange={(e) => setRefAudioFilter(e.target.value)}
|
||||
placeholder="搜索参考音频..."
|
||||
className="w-full bg-transparent text-sm text-white placeholder-gray-500 outline-none"
|
||||
/>
|
||||
<button onClick={(e) => onSaveEditing(audio.id, e)} className="text-green-400 hover:text-green-300 text-xs">
|
||||
<Check className="h-3 w-3" />
|
||||
</button>
|
||||
<button onClick={(e) => onCancelEditing(e)} className="text-gray-400 hover:text-gray-300 text-xs">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredRefAudios.length === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-gray-400">没有匹配的参考音频</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex justify-between items-start mb-1">
|
||||
<div className="text-white text-xs truncate pr-1 flex-1" title={audio.name}>
|
||||
{audio.name}
|
||||
</div>
|
||||
<div className="flex gap-1 opacity-40 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={(e) => onTogglePlayPreview(audio, e)}
|
||||
className="text-gray-400 hover:text-purple-400 text-xs"
|
||||
title="试听"
|
||||
<div ref={refAudioListContainerRef} className="space-y-1" style={{ contentVisibility: "auto" }}>
|
||||
{filteredRefAudios.map((audio) => {
|
||||
const isSelected = selectedRefAudio?.id === audio.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={audio.id}
|
||||
data-popover-selected={isSelected ? "true" : undefined}
|
||||
data-ref-selected={isSelected ? "true" : "false"}
|
||||
className={`flex items-center justify-between gap-2 rounded-lg border px-3 py-2 transition-colors ${isSelected
|
||||
? "border-purple-500 bg-purple-500/20"
|
||||
: "border-white/10 bg-white/5 hover:border-white/30"
|
||||
}`}
|
||||
>
|
||||
{playingAudioId === audio.id ? (
|
||||
<Pause className="h-3.5 w-3.5" />
|
||||
{editingAudioId === audio.id ? (
|
||||
<div className="flex-1 flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e) => onEditNameChange(e.target.value)}
|
||||
className="w-full rounded border border-purple-500 bg-black/50 px-2 py-1 text-xs text-white focus:outline-none"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onSaveEditing(audio.id, e as unknown as MouseEvent);
|
||||
if (e.key === "Escape") onCancelEditing(e as unknown as MouseEvent);
|
||||
}}
|
||||
/>
|
||||
<button type="button" onClick={(e) => onSaveEditing(audio.id, e)} className="text-green-400 hover:text-green-300">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button type="button" onClick={(e) => onCancelEditing(e)} className="text-gray-400 hover:text-gray-300">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelectRefAudio(audio);
|
||||
close();
|
||||
}}
|
||||
className="min-w-0 flex-1 text-left"
|
||||
>
|
||||
<span className="block truncate text-sm text-white" title={audio.name}>{audio.name}</span>
|
||||
<span className="mt-0.5 block text-xs text-gray-400">
|
||||
{audio.duration_sec.toFixed(1)}s
|
||||
{needsRetranscribe(audio) && (
|
||||
<span className="ml-1 text-yellow-500" title="需要重新识别文字">⚠</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRetranscribe(audio.id);
|
||||
}}
|
||||
disabled={retranscribingId === audio.id}
|
||||
className="text-gray-400 hover:text-cyan-400 text-xs disabled:opacity-50"
|
||||
title="重新识别文字"
|
||||
>
|
||||
<RotateCw className={`h-3.5 w-3.5 ${retranscribingId === audio.id ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => onStartEditing(audio, e)}
|
||||
className="text-gray-400 hover:text-blue-400 text-xs"
|
||||
title="重命名"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteRefAudio(audio.id);
|
||||
}}
|
||||
className="text-gray-400 hover:text-red-400 text-xs"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-gray-400 text-xs">
|
||||
{audio.duration_sec.toFixed(1)}s
|
||||
{needsRetranscribe(audio) && (
|
||||
<span className="text-yellow-500 ml-1" title="需要重新识别文字">⚠</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
{editingAudioId !== audio.id && (
|
||||
<div className="flex items-center gap-1 pl-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => onTogglePlayPreview(audio, e)}
|
||||
className="text-gray-400 hover:text-purple-300"
|
||||
title="试听"
|
||||
>
|
||||
{playingAudioId === audio.id ? (
|
||||
<Pause className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRetranscribe(audio.id);
|
||||
}}
|
||||
disabled={retranscribingId === audio.id}
|
||||
className="text-gray-400 hover:text-cyan-400 disabled:opacity-50"
|
||||
title="重新识别文字"
|
||||
>
|
||||
<RotateCw className={`h-3.5 w-3.5 ${retranscribingId === audio.id ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => onStartEditing(audio, e)}
|
||||
className="text-gray-400 hover:text-blue-400"
|
||||
title="重命名"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteRefAudio(audio.id);
|
||||
}}
|
||||
className="text-gray-400 hover:text-red-400"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{isSelected && <Check className="h-3.5 w-3.5 text-purple-300" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SelectPopover>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/10 pt-4">
|
||||
<span className="text-sm text-gray-300 mb-2 block">🎤 或在线录音 <span className="text-xs text-gray-500">(建议 3-10 秒,超出将自动截取)</span></span>
|
||||
<div className="flex gap-2 items-center">
|
||||
{!isRecording ? (
|
||||
<button
|
||||
onClick={onStartRecording}
|
||||
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
开始录音
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onStopRecording}
|
||||
className="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
停止
|
||||
</button>
|
||||
)}
|
||||
{isRecording && (
|
||||
<span className="text-red-400 text-sm animate-pulse">
|
||||
🔴 录音中 {formatRecordingTime(recordingTime)}
|
||||
<div className="mt-3 flex flex-wrap items-center justify-end gap-2">
|
||||
{recordedBlob && !isRecording && (
|
||||
<span className="mr-auto text-xs text-emerald-300/90">
|
||||
已录制 {formatRecordingTime(recordingTime)},可点击“在线录音”处理
|
||||
</span>
|
||||
)}
|
||||
<label
|
||||
htmlFor="ref-audio-upload"
|
||||
className={`px-3 py-1.5 text-xs rounded-lg cursor-pointer transition-all inline-flex items-center gap-1.5 ${isUploadingRef
|
||||
? "bg-gray-600 cursor-not-allowed text-gray-400 pointer-events-none"
|
||||
: "bg-purple-600 hover:bg-purple-700 text-white"
|
||||
}`}
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
上传音频
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRecordingModalOpen(true)}
|
||||
disabled={isUploadingRef}
|
||||
className="px-3 py-1.5 text-xs rounded-lg transition-colors bg-red-600 hover:bg-red-700 text-white disabled:bg-gray-600 disabled:text-gray-400 inline-flex items-center gap-1.5"
|
||||
>
|
||||
<Mic className="h-3.5 w-3.5" />
|
||||
在线录音
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{recordedBlob && !isRecording && (
|
||||
<div className="mt-3 p-3 bg-green-500/10 border border-green-500/30 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-green-300 text-sm">✅ 录音完成 ({formatRecordingTime(recordingTime)})</span>
|
||||
<audio src={recordedUrl || ''} controls className="h-8" />
|
||||
</div>
|
||||
<button
|
||||
onClick={onUseRecording}
|
||||
disabled={isUploadingRef}
|
||||
className="px-3 py-1 bg-green-600 hover:bg-green-700 text-white rounded text-sm disabled:bg-gray-600"
|
||||
>
|
||||
使用此录音
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{recordingModalOpen && (
|
||||
<AppModal
|
||||
isOpen={recordingModalOpen}
|
||||
onClose={closeRecordingModal}
|
||||
panelClassName="w-full max-w-lg rounded-2xl border border-white/10 bg-[#171821]/95 shadow-[0_24px_80px_rgba(0,0,0,0.55)] overflow-hidden"
|
||||
closeOnOverlay={false}
|
||||
>
|
||||
<AppModalHeader
|
||||
title="🎤 在线录音"
|
||||
subtitle="建议录制 3-10 秒,超出会自动截取到可用长度"
|
||||
onClose={closeRecordingModal}
|
||||
/>
|
||||
|
||||
<div className="space-y-4 p-4 sm:p-5">
|
||||
<div className="rounded-xl border border-white/10 bg-black/25 p-3 sm:p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{!isRecording ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStartRecording}
|
||||
disabled={isUploadingRef}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium bg-red-600 hover:bg-red-700 text-white transition-colors disabled:bg-gray-600 disabled:text-gray-400 inline-flex items-center gap-2"
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
{recordedBlob ? "重新录音" : "开始录音"}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStopRecording}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium bg-gray-600 hover:bg-gray-700 text-white transition-colors inline-flex items-center gap-2"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
停止录音
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isRecording ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-red-400/40 bg-red-500/10 px-3 py-1 text-xs text-red-300 animate-pulse">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-red-400" />
|
||||
录音中 {formatRecordingTime(recordingTime)}
|
||||
</span>
|
||||
) : recordedBlob ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-400/30 bg-emerald-500/10 px-3 py-1 text-xs text-emerald-300">
|
||||
已录制 {formatRecordingTime(recordingTime)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!recordedBlob && !isRecording && (
|
||||
<p className="mt-3 text-xs text-gray-500">点击“开始录音”后允许麦克风权限,结束后可试听并确认上传</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{recordedBlob && !isRecording && (
|
||||
<div className="space-y-3 rounded-xl border border-emerald-500/30 bg-emerald-500/10 p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm text-emerald-200">✅ 录音完成,可先试听再使用</span>
|
||||
<span className="text-xs text-emerald-300/80">{formatRecordingTime(recordingTime)}</span>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-white/10 bg-black/35 px-3 py-2.5">
|
||||
<audio
|
||||
key={recordedUrl || "recorded-preview"}
|
||||
ref={recordedAudioRef}
|
||||
src={recordedUrl || ""}
|
||||
className="hidden"
|
||||
onPlay={() => setRecordedPreviewPlaying(true)}
|
||||
onPause={() => setRecordedPreviewPlaying(false)}
|
||||
onEnded={() => {
|
||||
setRecordedPreviewPlaying(false);
|
||||
setRecordedPreviewCurrentTime(0);
|
||||
}}
|
||||
onTimeUpdate={(event) => setRecordedPreviewCurrentTime(event.currentTarget.currentTime || 0)}
|
||||
onLoadedMetadata={(event) => setRecordedPreviewDuration(event.currentTarget.duration || 0)}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleRecordedPreview}
|
||||
disabled={!recordedUrl}
|
||||
className="h-8 w-8 shrink-0 rounded-full bg-white/10 hover:bg-white/20 text-emerald-200 disabled:text-gray-500 disabled:bg-white/5 inline-flex items-center justify-center transition-colors"
|
||||
title={recordedPreviewPlaying ? "暂停试听" : "播放试听"}
|
||||
>
|
||||
{recordedPreviewPlaying ? (
|
||||
<Pause className="h-4 w-4" />
|
||||
) : (
|
||||
<Play className="h-4 w-4 translate-x-[1px]" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={Math.max(totalRecordedPreviewTime, 0.1)}
|
||||
step={0.01}
|
||||
value={Math.min(recordedPreviewCurrentTime, totalRecordedPreviewTime || 0)}
|
||||
onChange={handleRecordedSeek}
|
||||
className="w-full h-1.5 cursor-pointer appearance-none rounded-full bg-white/15 accent-emerald-400"
|
||||
/>
|
||||
<div className="mt-1 flex items-center justify-between text-[11px] text-emerald-200/80">
|
||||
<span>{formatRecordingTime(Math.floor(recordedPreviewCurrentTime))}</span>
|
||||
<span>{formatRecordingTime(Math.floor(totalRecordedPreviewTime))}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDiscardRecording}
|
||||
disabled={isUploadingRef}
|
||||
className="px-3 py-1.5 rounded-lg text-sm bg-white/10 hover:bg-white/20 text-gray-200 transition-colors disabled:bg-white/5 disabled:text-gray-500"
|
||||
>
|
||||
弃用本次录音
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUseRecordingAndClose}
|
||||
disabled={isUploadingRef}
|
||||
className="px-3 py-1.5 rounded-lg text-sm bg-green-600 hover:bg-green-700 text-white transition-colors disabled:bg-gray-600 disabled:text-gray-400"
|
||||
>
|
||||
使用此录音
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppModal>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Loader2, Sparkles } from "lucide-react";
|
||||
import api from "@/shared/api/axios";
|
||||
import { ApiResponse, unwrap } from "@/shared/api/types";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Loader2, Sparkles } from "lucide-react";
|
||||
import api from "@/shared/api/axios";
|
||||
import { ApiResponse, unwrap } from "@/shared/api/types";
|
||||
import { AppModal, AppModalHeader } from "@/shared/ui/AppModal";
|
||||
|
||||
const CUSTOM_PROMPT_KEY = "vigent_rewriteCustomPrompt";
|
||||
|
||||
@@ -82,37 +83,23 @@ export default function RewriteModal({
|
||||
setError(null);
|
||||
};
|
||||
|
||||
// ESC to close
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4 animate-in fade-in duration-200">
|
||||
<div 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">
|
||||
<Sparkles className="h-5 w-5 text-purple-400" />
|
||||
AI 智能改写
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-white transition-colors text-2xl leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-5">
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<AppModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
panelClassName="w-full max-w-2xl max-h-[90vh] rounded-2xl border border-white/10 bg-[#171821]/95 shadow-[0_24px_80px_rgba(0,0,0,0.55)] overflow-hidden flex flex-col"
|
||||
closeOnOverlay={false}
|
||||
>
|
||||
<AppModalHeader
|
||||
title="AI 智能改写"
|
||||
icon={<Sparkles className="h-5 w-5 text-purple-300" />}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-5">
|
||||
{/* Custom Prompt */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-gray-300">
|
||||
@@ -206,8 +193,7 @@ export default function RewriteModal({
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</div>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { FileText, History, Languages, Loader2, RotateCcw, Save, Sparkles, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { FileText, History, Languages, Loader2, Maximize2, RotateCcw, Save, Sparkles, Trash2 } from "lucide-react";
|
||||
import type { SavedScript } from "@/features/home/model/useSavedScripts";
|
||||
import { AppModal, AppModalHeader } from "@/shared/ui/AppModal";
|
||||
|
||||
const LANGUAGES = [
|
||||
{ code: "English", label: "英语 English" },
|
||||
@@ -19,8 +20,6 @@ interface ScriptEditorProps {
|
||||
onChangeText: (value: string) => void;
|
||||
onOpenExtractModal: () => void;
|
||||
onOpenRewriteModal: () => void;
|
||||
onGenerateMeta: () => void;
|
||||
isGeneratingMeta: boolean;
|
||||
onTranslate: (targetLang: string) => void;
|
||||
isTranslating: boolean;
|
||||
hasOriginalText: boolean;
|
||||
@@ -36,8 +35,6 @@ export function ScriptEditor({
|
||||
onChangeText,
|
||||
onOpenExtractModal,
|
||||
onOpenRewriteModal,
|
||||
onGenerateMeta,
|
||||
isGeneratingMeta,
|
||||
onTranslate,
|
||||
isTranslating,
|
||||
hasOriginalText,
|
||||
@@ -47,10 +44,17 @@ export function ScriptEditor({
|
||||
onLoadScript,
|
||||
onDeleteScript,
|
||||
}: ScriptEditorProps) {
|
||||
const actionBtnBase = "px-3 py-1.5 text-xs rounded-lg transition-colors whitespace-nowrap inline-flex items-center gap-1.5";
|
||||
const actionBtnDisabled = "bg-gray-600 cursor-not-allowed text-gray-400";
|
||||
|
||||
const [showLangMenu, setShowLangMenu] = useState(false);
|
||||
const langMenuRef = useRef<HTMLDivElement>(null);
|
||||
const [showHistoryMenu, setShowHistoryMenu] = useState(false);
|
||||
const historyMenuRef = useRef<HTMLDivElement>(null);
|
||||
const [isExpandedEditorOpen, setIsExpandedEditorOpen] = useState(false);
|
||||
const handleCloseExpandedEditor = useCallback(() => {
|
||||
setIsExpandedEditorOpen(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showLangMenu) return;
|
||||
@@ -95,7 +99,7 @@ export function ScriptEditor({
|
||||
<div className="relative" ref={historyMenuRef}>
|
||||
<button
|
||||
onClick={() => setShowHistoryMenu((prev) => !prev)}
|
||||
className="h-7 px-2.5 text-xs rounded transition-all whitespace-nowrap bg-gray-600 hover:bg-gray-500 text-white inline-flex items-center gap-1"
|
||||
className={`${actionBtnBase} bg-gray-600 hover:bg-gray-500 text-white`}
|
||||
>
|
||||
<History className="h-3.5 w-3.5" />
|
||||
历史文案
|
||||
@@ -137,7 +141,7 @@ export function ScriptEditor({
|
||||
</div>
|
||||
<button
|
||||
onClick={onOpenExtractModal}
|
||||
className="h-7 px-2.5 text-xs rounded transition-all whitespace-nowrap bg-purple-600 hover:bg-purple-700 text-white inline-flex items-center gap-1"
|
||||
className={`${actionBtnBase} bg-purple-600 hover:bg-purple-700 text-white`}
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
文案提取助手
|
||||
@@ -146,9 +150,9 @@ export function ScriptEditor({
|
||||
<button
|
||||
onClick={() => setShowLangMenu((prev) => !prev)}
|
||||
disabled={isTranslating || !text.trim()}
|
||||
className={`h-7 px-2.5 text-xs rounded transition-all whitespace-nowrap inline-flex items-center gap-1 ${
|
||||
className={`${actionBtnBase} ${
|
||||
isTranslating || !text.trim()
|
||||
? "bg-gray-600 cursor-not-allowed text-gray-400"
|
||||
? actionBtnDisabled
|
||||
: "bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white"
|
||||
}`}
|
||||
>
|
||||
@@ -190,63 +194,75 @@ export function ScriptEditor({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onGenerateMeta}
|
||||
disabled={isGeneratingMeta || !text.trim()}
|
||||
className={`h-7 px-2.5 text-xs rounded transition-all whitespace-nowrap inline-flex items-center gap-1 ${isGeneratingMeta || !text.trim()
|
||||
? "bg-gray-600 cursor-not-allowed text-gray-400"
|
||||
: "bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-700 hover:to-cyan-700 text-white"
|
||||
}`}
|
||||
>
|
||||
{isGeneratingMeta ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
生成中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
AI生成标题标签
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => onChangeText(e.target.value)}
|
||||
placeholder="请输入你想说的话..."
|
||||
className="w-full h-40 bg-black/30 border border-white/10 rounded-xl p-4 text-white placeholder-gray-500 resize-none focus:outline-none focus:border-purple-500 transition-colors hide-scrollbar"
|
||||
/>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => onChangeText(e.target.value)}
|
||||
placeholder="请输入你想说的话..."
|
||||
className="w-full h-40 bg-black/30 border border-white/10 rounded-xl p-4 pr-6 pb-6 text-white placeholder-gray-500 resize-none focus:outline-none focus:border-white/25 transition-colors hide-scrollbar"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpandedEditorOpen(true)}
|
||||
className="absolute right-0.5 bottom-2 h-5 w-5 text-gray-400/85 hover:text-white focus:outline-none transition-colors inline-flex items-center justify-center"
|
||||
aria-label="扩展文案编辑器"
|
||||
title="扩展编辑"
|
||||
>
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2 text-sm text-gray-400">
|
||||
<span>{text.length} 字</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onOpenRewriteModal}
|
||||
disabled={!text.trim()}
|
||||
className={`px-2.5 py-1 text-xs rounded transition-all flex items-center gap-1 ${
|
||||
className={`${actionBtnBase} ${
|
||||
!text.trim()
|
||||
? "bg-gray-700 cursor-not-allowed text-gray-500"
|
||||
: "bg-purple-600/80 hover:bg-purple-600 text-white"
|
||||
? "bg-gray-600 cursor-not-allowed text-gray-400"
|
||||
: "bg-purple-600 hover:bg-purple-700 text-white"
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="h-3 w-3" />
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
AI智能改写
|
||||
</button>
|
||||
<button
|
||||
onClick={onSaveScript}
|
||||
disabled={!text.trim()}
|
||||
className={`px-2.5 py-1 text-xs rounded transition-all flex items-center gap-1 ${
|
||||
className={`${actionBtnBase} ${
|
||||
!text.trim()
|
||||
? "bg-gray-700 cursor-not-allowed text-gray-500"
|
||||
: "bg-amber-600/80 hover:bg-amber-600 text-white"
|
||||
? "bg-gray-600 cursor-not-allowed text-gray-400"
|
||||
: "bg-amber-600 hover:bg-amber-700 text-white"
|
||||
}`}
|
||||
>
|
||||
<Save className="h-3 w-3" />
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
保存文案
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AppModal
|
||||
isOpen={isExpandedEditorOpen}
|
||||
onClose={handleCloseExpandedEditor}
|
||||
panelClassName="w-full max-w-5xl max-h-[92vh] rounded-2xl border border-white/10 bg-[#171821]/95 shadow-[0_24px_80px_rgba(0,0,0,0.55)] overflow-hidden flex flex-col"
|
||||
>
|
||||
<AppModalHeader
|
||||
title="扩展文案编辑"
|
||||
subtitle="在更大空间里编写与调整文案"
|
||||
onClose={handleCloseExpandedEditor}
|
||||
actions={<span className="text-xs text-gray-400 tabular-nums">{text.length} 字</span>}
|
||||
/>
|
||||
<div className="flex-1 p-4 sm:p-5">
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => onChangeText(e.target.value)}
|
||||
placeholder="请输入你想说的话..."
|
||||
className="w-full h-[66vh] min-h-[320px] bg-black/30 border border-white/10 rounded-xl p-4 text-white placeholder-gray-500 resize-none focus:outline-none focus:border-white/25 transition-colors hide-scrollbar"
|
||||
/>
|
||||
</div>
|
||||
</AppModal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useCallback } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useScriptExtraction } from "./script-extraction/useScriptExtraction";
|
||||
import { AppModal, AppModalHeader } from "@/shared/ui/AppModal";
|
||||
|
||||
interface ScriptExtractionModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -36,17 +37,15 @@ export default function ScriptExtractionModal({
|
||||
clearInputUrl,
|
||||
} = useScriptExtraction({ isOpen });
|
||||
|
||||
// 快捷键:ESC 关闭,Enter 提交(仅在 config 步骤)
|
||||
// 快捷键: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) {
|
||||
if (e.key === "Enter" && !e.shiftKey && step === "config" && canExtract && !isLoading) {
|
||||
e.preventDefault();
|
||||
handleExtract();
|
||||
}
|
||||
}, [onClose, step, canExtract, isLoading, handleExtract]);
|
||||
}, [step, canExtract, isLoading, handleExtract]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
@@ -68,20 +67,13 @@ export default function ScriptExtractionModal({
|
||||
};
|
||||
|
||||
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 className="bg-[#1a1a1a] border border-white/10 rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-white/10 bg-white/5">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
📜 文案提取助手
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-white transition-colors text-2xl leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<AppModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
panelClassName="w-full max-w-2xl max-h-[90vh] rounded-2xl border border-white/10 bg-[#171821]/95 shadow-[0_24px_80px_rgba(0,0,0,0.55)] overflow-hidden flex flex-col"
|
||||
closeOnOverlay={false}
|
||||
>
|
||||
<AppModalHeader title="📜 文案提取助手" onClose={onClose} />
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
@@ -277,7 +269,6 @@ export default function ScriptExtractionModal({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useRef, useCallback, useState, useMemo } from "react";
|
||||
import WaveSurfer from "wavesurfer.js";
|
||||
import { ChevronDown, GripVertical } from "lucide-react";
|
||||
import { ChevronDown, GripVertical, Check } from "lucide-react";
|
||||
import type { TimelineSegment } from "@/features/home/model/useTimelineEditor";
|
||||
import type { Material } from "@/shared/types/material";
|
||||
import { SelectPopover } from "@/shared/ui/SelectPopover";
|
||||
|
||||
interface TimelineEditorProps {
|
||||
audioDuration: number;
|
||||
@@ -51,9 +52,7 @@ export function TimelineEditor({
|
||||
const [dragFromIdx, setDragFromIdx] = useState<number | null>(null);
|
||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
||||
|
||||
// Aspect ratio dropdown
|
||||
const [ratioOpen, setRatioOpen] = useState(false);
|
||||
const ratioRef = useRef<HTMLDivElement>(null);
|
||||
// Aspect ratio options
|
||||
const ratioOptions = [
|
||||
{ value: "9:16" as const, label: "竖屏 9:16" },
|
||||
{ value: "16:9" as const, label: "横屏 16:9" },
|
||||
@@ -61,16 +60,6 @@ export function TimelineEditor({
|
||||
const currentRatioLabel =
|
||||
ratioOptions.find((opt) => opt.value === outputAspectRatio)?.label ?? "竖屏 9:16";
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ratioRef.current && !ratioRef.current.contains(e.target as Node)) {
|
||||
setRatioOpen(false);
|
||||
}
|
||||
};
|
||||
if (ratioOpen) document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [ratioOpen]);
|
||||
|
||||
// Create / recreate wavesurfer when audioUrl changes
|
||||
useEffect(() => {
|
||||
if (!waveRef.current || !audioUrl) return;
|
||||
@@ -188,37 +177,49 @@ export function TimelineEditor({
|
||||
<h3 className="text-sm font-medium text-gray-400">时间轴编辑</h3>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||
<div ref={ratioRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRatioOpen((v) => !v)}
|
||||
className="px-2 py-1 text-xs bg-white/10 hover:bg-white/20 rounded text-gray-300 whitespace-nowrap flex items-center gap-1 transition-all"
|
||||
title="设置输出画面比例"
|
||||
<div className="shrink-0">
|
||||
<SelectPopover
|
||||
sheetTitle="设置输出画面比例"
|
||||
trigger={({ open, toggle }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className="rounded-lg border border-white/10 bg-black/25 px-2.5 py-1.5 text-left transition-colors hover:border-white/30"
|
||||
title="设置输出画面比例"
|
||||
>
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-xs text-white">画面: {currentRatioLabel}</span>
|
||||
<ChevronDown className={`h-3.5 w-3.5 text-gray-300 transition-transform ${open ? "rotate-180" : ""}`} />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
画面: {currentRatioLabel}
|
||||
<ChevronDown className={`h-3 w-3 transition-transform ${ratioOpen ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
{ratioOpen && (
|
||||
<div className="absolute right-0 top-full mt-1 bg-gray-800 border border-white/20 rounded-lg shadow-xl py-1 z-50 min-w-[106px]">
|
||||
{ratioOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onOutputAspectRatioChange(opt.value);
|
||||
setRatioOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-1.5 text-xs transition-colors ${
|
||||
outputAspectRatio === opt.value
|
||||
? "bg-purple-600/40 text-purple-200"
|
||||
: "text-gray-300 hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{({ close }) => (
|
||||
<div className="space-y-1">
|
||||
{ratioOptions.map((opt) => {
|
||||
const isSelected = outputAspectRatio === opt.value;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
data-popover-selected={isSelected ? "true" : undefined}
|
||||
onClick={() => {
|
||||
onOutputAspectRatioChange(opt.value);
|
||||
close();
|
||||
}}
|
||||
className={`flex w-full items-center justify-between rounded-lg border px-3 py-2 text-left transition-colors ${isSelected
|
||||
? "border-purple-500 bg-purple-500/20"
|
||||
: "border-white/10 bg-white/5 hover:border-white/30"
|
||||
}`}
|
||||
>
|
||||
<span className="text-xs text-white">{opt.label}</span>
|
||||
{isSelected && <Check className="h-3.5 w-3.5 text-purple-300" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</SelectPopover>
|
||||
</div>
|
||||
|
||||
{audioUrl && (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ChevronDown, Eye } from "lucide-react";
|
||||
import { ChevronDown, Eye, Check, Loader2, Sparkles } from "lucide-react";
|
||||
import { FloatingStylePreview } from "@/features/home/ui/FloatingStylePreview";
|
||||
import { SelectPopover } from "@/shared/ui/SelectPopover";
|
||||
|
||||
interface SubtitleStyleOption {
|
||||
id: string;
|
||||
@@ -34,6 +35,9 @@ interface TitleStyleOption {
|
||||
interface TitleSubtitlePanelProps {
|
||||
showStylePreview: boolean;
|
||||
onTogglePreview: () => void;
|
||||
onGenerateMeta: () => void;
|
||||
isGeneratingMeta: boolean;
|
||||
canGenerateMeta: boolean;
|
||||
videoTitle: string;
|
||||
onTitleChange: (value: string) => void;
|
||||
onTitleCompositionStart?: () => void;
|
||||
@@ -75,6 +79,9 @@ interface TitleSubtitlePanelProps {
|
||||
export function TitleSubtitlePanel({
|
||||
showStylePreview,
|
||||
onTogglePreview,
|
||||
onGenerateMeta,
|
||||
isGeneratingMeta,
|
||||
canGenerateMeta,
|
||||
videoTitle,
|
||||
onTitleChange,
|
||||
onTitleCompositionStart,
|
||||
@@ -112,33 +119,100 @@ export function TitleSubtitlePanel({
|
||||
previewBaseHeight = 1920,
|
||||
previewBackgroundUrl,
|
||||
}: TitleSubtitlePanelProps) {
|
||||
const titleDisplayOptions: Array<{ value: "short" | "persistent"; label: string }> = [
|
||||
{ value: "short", label: "标题短暂显示" },
|
||||
{ value: "persistent", label: "标题常驻显示" },
|
||||
];
|
||||
const currentTitleDisplay = titleDisplayOptions.find((opt) => opt.value === titleDisplayMode) || titleDisplayOptions[0];
|
||||
|
||||
const currentTitleStyle = titleStyles.find((style) => style.id === selectedTitleStyleId) || titleStyles[0] || null;
|
||||
const currentSecondaryTitleStyle = titleStyles.find((style) => style.id === selectedSecondaryTitleStyleId) || titleStyles[0] || null;
|
||||
const currentSubtitleStyle = subtitleStyles.find((style) => style.id === selectedSubtitleStyleId) || subtitleStyles[0] || 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">
|
||||
<h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
|
||||
四、标题与字幕
|
||||
</h2>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="relative shrink-0">
|
||||
<select
|
||||
value={titleDisplayMode}
|
||||
onChange={(e) => onTitleDisplayModeChange(e.target.value as "short" | "persistent")}
|
||||
className="appearance-none rounded-lg border border-white/15 bg-black/35 px-2.5 py-1.5 pr-7 text-xs text-gray-200 outline-none transition-colors hover:border-white/25 focus:border-purple-500"
|
||||
aria-label="标题显示方式"
|
||||
>
|
||||
<option value="short">标题短暂显示</option>
|
||||
<option value="persistent">标题常驻显示</option>
|
||||
</select>
|
||||
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-400" />
|
||||
</div>
|
||||
<div className="mb-4 space-y-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
|
||||
四、标题与字幕
|
||||
</h2>
|
||||
<button
|
||||
onClick={onTogglePreview}
|
||||
className="px-2 py-1 text-xs bg-white/10 hover:bg-white/20 rounded text-gray-300 flex items-center gap-1"
|
||||
onClick={onGenerateMeta}
|
||||
disabled={isGeneratingMeta || !canGenerateMeta}
|
||||
className={`px-3 py-1.5 text-xs rounded-lg transition-colors inline-flex items-center gap-1.5 ${
|
||||
isGeneratingMeta || !canGenerateMeta
|
||||
? "bg-gray-600 cursor-not-allowed text-gray-400"
|
||||
: "bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-700 hover:to-cyan-700 text-white"
|
||||
}`}
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
{showStylePreview ? "收起预览" : "预览样式"}
|
||||
{isGeneratingMeta ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
生成中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
AI生成标题标签
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
||||
<div className="shrink-0">
|
||||
<SelectPopover
|
||||
sheetTitle="标题显示方式"
|
||||
trigger={({ open, toggle }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className="min-w-[146px] rounded-lg border border-white/10 bg-black/25 px-2.5 py-1.5 text-left text-xs text-gray-200 transition-colors hover:border-white/30"
|
||||
aria-label="标题显示方式"
|
||||
>
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className="whitespace-nowrap">{currentTitleDisplay.label}</span>
|
||||
<ChevronDown className={`h-3.5 w-3.5 text-gray-400 transition-transform ${open ? "rotate-180" : ""}`} />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{({ close }) => (
|
||||
<div className="space-y-1">
|
||||
{titleDisplayOptions.map((opt) => {
|
||||
const isSelected = opt.value === titleDisplayMode;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
data-popover-selected={isSelected ? "true" : undefined}
|
||||
onClick={() => {
|
||||
onTitleDisplayModeChange(opt.value);
|
||||
close();
|
||||
}}
|
||||
className={`flex w-full items-center justify-between rounded-lg border px-3 py-2 text-left transition-colors ${isSelected
|
||||
? "border-purple-500 bg-purple-500/20"
|
||||
: "border-white/10 bg-white/5 hover:border-white/30"
|
||||
}`}
|
||||
>
|
||||
<span className="text-xs text-white whitespace-nowrap">{opt.label}</span>
|
||||
{isSelected && <Check className="h-3.5 w-3.5 text-purple-300" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</SelectPopover>
|
||||
</div>
|
||||
<button
|
||||
onClick={onTogglePreview}
|
||||
className="px-2 py-1 text-xs bg-white/10 hover:bg-white/20 rounded text-gray-300 flex items-center gap-1"
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
{showStylePreview ? "收起预览" : "预览样式"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showStylePreview && (
|
||||
@@ -203,17 +277,48 @@ export function TitleSubtitlePanel({
|
||||
<div className="mb-4 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-sm text-gray-300 shrink-0 w-20">标题样式</label>
|
||||
<div className="relative w-1/3 min-w-[100px]">
|
||||
<select
|
||||
value={selectedTitleStyleId}
|
||||
onChange={(e) => onSelectTitleStyle(e.target.value)}
|
||||
className="w-full appearance-none rounded-lg border border-white/15 bg-black/35 px-3 py-2 pr-8 text-sm text-gray-200 outline-none transition-colors hover:border-white/25 focus:border-purple-500"
|
||||
<div className="w-1/3 min-w-[130px]">
|
||||
<SelectPopover
|
||||
sheetTitle="标题样式"
|
||||
trigger={({ open, toggle }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className="w-full rounded-lg border border-white/15 bg-black/35 px-3 py-2 text-left text-sm text-gray-200 transition-colors hover:border-white/25"
|
||||
>
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className="truncate">{currentTitleStyle?.label || "请选择"}</span>
|
||||
<ChevronDown className={`h-3.5 w-3.5 text-gray-400 transition-transform ${open ? "rotate-180" : ""}`} />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{titleStyles.map((style) => (
|
||||
<option key={style.id} value={style.id}>{style.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="pointer-events-none absolute right-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-400" />
|
||||
{({ close }) => (
|
||||
<div className="space-y-1">
|
||||
{titleStyles.map((style) => {
|
||||
const isSelected = selectedTitleStyleId === style.id;
|
||||
return (
|
||||
<button
|
||||
key={style.id}
|
||||
type="button"
|
||||
data-popover-selected={isSelected ? "true" : undefined}
|
||||
onClick={() => {
|
||||
onSelectTitleStyle(style.id);
|
||||
close();
|
||||
}}
|
||||
className={`flex w-full items-center justify-between rounded-lg border px-3 py-2 text-left transition-colors ${isSelected
|
||||
? "border-purple-500 bg-purple-500/20"
|
||||
: "border-white/10 bg-white/5 hover:border-white/30"
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm text-white">{style.label}</span>
|
||||
{isSelected && <Check className="h-4 w-4 text-purple-300" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</SelectPopover>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -231,17 +336,48 @@ export function TitleSubtitlePanel({
|
||||
<div className="mb-4 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-sm text-gray-300 shrink-0 w-20">副标题样式</label>
|
||||
<div className="relative w-1/3 min-w-[100px]">
|
||||
<select
|
||||
value={selectedSecondaryTitleStyleId}
|
||||
onChange={(e) => onSelectSecondaryTitleStyle(e.target.value)}
|
||||
className="w-full appearance-none rounded-lg border border-white/15 bg-black/35 px-3 py-2 pr-8 text-sm text-gray-200 outline-none transition-colors hover:border-white/25 focus:border-purple-500"
|
||||
<div className="w-1/3 min-w-[130px]">
|
||||
<SelectPopover
|
||||
sheetTitle="副标题样式"
|
||||
trigger={({ open, toggle }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className="w-full rounded-lg border border-white/15 bg-black/35 px-3 py-2 text-left text-sm text-gray-200 transition-colors hover:border-white/25"
|
||||
>
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className="truncate">{currentSecondaryTitleStyle?.label || "请选择"}</span>
|
||||
<ChevronDown className={`h-3.5 w-3.5 text-gray-400 transition-transform ${open ? "rotate-180" : ""}`} />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{titleStyles.map((style) => (
|
||||
<option key={style.id} value={style.id}>{style.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="pointer-events-none absolute right-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-400" />
|
||||
{({ close }) => (
|
||||
<div className="space-y-1">
|
||||
{titleStyles.map((style) => {
|
||||
const isSelected = selectedSecondaryTitleStyleId === style.id;
|
||||
return (
|
||||
<button
|
||||
key={style.id}
|
||||
type="button"
|
||||
data-popover-selected={isSelected ? "true" : undefined}
|
||||
onClick={() => {
|
||||
onSelectSecondaryTitleStyle(style.id);
|
||||
close();
|
||||
}}
|
||||
className={`flex w-full items-center justify-between rounded-lg border px-3 py-2 text-left transition-colors ${isSelected
|
||||
? "border-purple-500 bg-purple-500/20"
|
||||
: "border-white/10 bg-white/5 hover:border-white/30"
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm text-white">{style.label}</span>
|
||||
{isSelected && <Check className="h-4 w-4 text-purple-300" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</SelectPopover>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -259,17 +395,48 @@ export function TitleSubtitlePanel({
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-sm text-gray-300 shrink-0 w-20">字幕样式</label>
|
||||
<div className="relative w-1/3 min-w-[100px]">
|
||||
<select
|
||||
value={selectedSubtitleStyleId}
|
||||
onChange={(e) => onSelectSubtitleStyle(e.target.value)}
|
||||
className="w-full appearance-none rounded-lg border border-white/15 bg-black/35 px-3 py-2 pr-8 text-sm text-gray-200 outline-none transition-colors hover:border-white/25 focus:border-purple-500"
|
||||
<div className="w-1/3 min-w-[130px]">
|
||||
<SelectPopover
|
||||
sheetTitle="字幕样式"
|
||||
trigger={({ open, toggle }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className="w-full rounded-lg border border-white/15 bg-black/35 px-3 py-2 text-left text-sm text-gray-200 transition-colors hover:border-white/25"
|
||||
>
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className="truncate">{currentSubtitleStyle?.label || "请选择"}</span>
|
||||
<ChevronDown className={`h-3.5 w-3.5 text-gray-400 transition-transform ${open ? "rotate-180" : ""}`} />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{subtitleStyles.map((style) => (
|
||||
<option key={style.id} value={style.id}>{style.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="pointer-events-none absolute right-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-400" />
|
||||
{({ close }) => (
|
||||
<div className="space-y-1">
|
||||
{subtitleStyles.map((style) => {
|
||||
const isSelected = selectedSubtitleStyleId === style.id;
|
||||
return (
|
||||
<button
|
||||
key={style.id}
|
||||
type="button"
|
||||
data-popover-selected={isSelected ? "true" : undefined}
|
||||
onClick={() => {
|
||||
onSelectSubtitleStyle(style.id);
|
||||
close();
|
||||
}}
|
||||
className={`flex w-full items-center justify-between rounded-lg border px-3 py-2 text-left transition-colors ${isSelected
|
||||
? "border-purple-500 bg-purple-500/20"
|
||||
: "border-white/10 bg-white/5 hover:border-white/30"
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm text-white">{style.label}</span>
|
||||
{isSelected && <Check className="h-4 w-4 text-purple-300" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</SelectPopover>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -1,11 +1,34 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Mic, Volume2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from "react";
|
||||
import { Check, ChevronDown, Loader2, Mic, Pause, Play, Volume2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { SelectPopover } from "@/shared/ui/SelectPopover";
|
||||
|
||||
interface VoiceOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const LOCALE_LABELS: Record<string, string> = {
|
||||
"zh-CN": "中文",
|
||||
"en-US": "English",
|
||||
"ja-JP": "日本語",
|
||||
"ko-KR": "한국어",
|
||||
"fr-FR": "Français",
|
||||
"de-DE": "Deutsch",
|
||||
"es-ES": "Español",
|
||||
"ru-RU": "Русский",
|
||||
"it-IT": "Italiano",
|
||||
"pt-BR": "Português",
|
||||
};
|
||||
|
||||
const getLocaleFromVoiceId = (voiceId: string) => {
|
||||
const parts = voiceId.split("-");
|
||||
if (parts.length >= 2) {
|
||||
return `${parts[0]}-${parts[1]}`;
|
||||
}
|
||||
return voiceId;
|
||||
};
|
||||
|
||||
interface VoiceSelectorProps {
|
||||
ttsMode: "edgetts" | "voiceclone";
|
||||
onSelectTtsMode: (mode: "edgetts" | "voiceclone") => void;
|
||||
@@ -25,6 +48,102 @@ export function VoiceSelector({
|
||||
voiceCloneSlot,
|
||||
embedded = false,
|
||||
}: VoiceSelectorProps) {
|
||||
const selectedVoice = voices.find((v) => v.id === voice) ?? voices[0];
|
||||
const selectedLocale = selectedVoice ? getLocaleFromVoiceId(selectedVoice.id) : "";
|
||||
const selectedLangLabel = LOCALE_LABELS[selectedLocale] ?? selectedLocale;
|
||||
|
||||
const [previewingVoiceId, setPreviewingVoiceId] = useState<string | null>(null);
|
||||
const [previewLoadingVoiceId, setPreviewLoadingVoiceId] = useState<string | null>(null);
|
||||
const previewPlayerRef = useRef<HTMLAudioElement | null>(null);
|
||||
const previewRequestIdRef = useRef(0);
|
||||
|
||||
const stopVoicePreview = useCallback(() => {
|
||||
previewRequestIdRef.current += 1;
|
||||
|
||||
if (previewPlayerRef.current) {
|
||||
previewPlayerRef.current.pause();
|
||||
previewPlayerRef.current.src = "";
|
||||
previewPlayerRef.current.currentTime = 0;
|
||||
previewPlayerRef.current = null;
|
||||
}
|
||||
setPreviewingVoiceId(null);
|
||||
setPreviewLoadingVoiceId(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => {
|
||||
stopVoicePreview();
|
||||
}, [stopVoicePreview]);
|
||||
|
||||
useEffect(() => {
|
||||
if (ttsMode !== "edgetts") {
|
||||
stopVoicePreview();
|
||||
}
|
||||
}, [ttsMode, stopVoicePreview]);
|
||||
|
||||
const handleVoicePreview = useCallback(async (voiceId: string, e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (previewingVoiceId === voiceId) {
|
||||
stopVoicePreview();
|
||||
return;
|
||||
}
|
||||
|
||||
stopVoicePreview();
|
||||
setPreviewLoadingVoiceId(voiceId);
|
||||
const requestId = ++previewRequestIdRef.current;
|
||||
|
||||
try {
|
||||
const audioUrl = `/api/videos/voice-preview?voice=${encodeURIComponent(voiceId)}`;
|
||||
const player = new Audio(audioUrl);
|
||||
previewPlayerRef.current = player;
|
||||
let errorNotified = false;
|
||||
|
||||
const notifyPreviewError = () => {
|
||||
if (errorNotified) return;
|
||||
errorNotified = true;
|
||||
toast.error("音色试听失败,请稍后重试");
|
||||
};
|
||||
|
||||
player.onplaying = () => {
|
||||
if (requestId === previewRequestIdRef.current) {
|
||||
setPreviewLoadingVoiceId(null);
|
||||
setPreviewingVoiceId(voiceId);
|
||||
}
|
||||
};
|
||||
|
||||
player.onended = () => {
|
||||
if (previewPlayerRef.current === player) {
|
||||
previewPlayerRef.current = null;
|
||||
setPreviewingVoiceId(null);
|
||||
setPreviewLoadingVoiceId(null);
|
||||
}
|
||||
};
|
||||
|
||||
player.onerror = () => {
|
||||
if (previewPlayerRef.current === player) {
|
||||
previewPlayerRef.current = null;
|
||||
setPreviewingVoiceId(null);
|
||||
setPreviewLoadingVoiceId(null);
|
||||
notifyPreviewError();
|
||||
}
|
||||
};
|
||||
|
||||
await player.play();
|
||||
|
||||
if (requestId !== previewRequestIdRef.current) {
|
||||
player.pause();
|
||||
player.src = "";
|
||||
player.currentTime = 0;
|
||||
}
|
||||
} catch {
|
||||
toast.error("音色试听失败,请稍后重试");
|
||||
} finally {
|
||||
if (requestId === previewRequestIdRef.current) {
|
||||
setPreviewLoadingVoiceId(null);
|
||||
}
|
||||
}
|
||||
}, [previewingVoiceId, stopVoicePreview]);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<div className="flex gap-2 mb-4">
|
||||
@@ -51,19 +170,86 @@ export function VoiceSelector({
|
||||
</div>
|
||||
|
||||
{ttsMode === "edgetts" && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{voices.map((v) => (
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => onSelectVoice(v.id)}
|
||||
className={`p-3 rounded-xl border-2 transition-all text-left ${voice === v.id
|
||||
? "border-purple-500 bg-purple-500/20"
|
||||
: "border-white/10 bg-white/5 hover:border-white/30"
|
||||
}`}
|
||||
>
|
||||
<span className="text-white text-sm">{v.name}</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-gray-400">音色选择</p>
|
||||
<SelectPopover
|
||||
sheetTitle="选择声音"
|
||||
trigger={({ open, toggle }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className="w-full rounded-xl border border-white/10 bg-black/25 px-3 py-2.5 text-left hover:border-white/30 transition-colors"
|
||||
>
|
||||
<span className="flex items-center justify-between gap-3">
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm text-white">
|
||||
{selectedVoice?.name || "请选择声音"}
|
||||
</span>
|
||||
<span className="block text-xs text-gray-400">
|
||||
{selectedLangLabel || "未识别语言"}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown className={`h-4 w-4 text-gray-300 transition-transform ${open ? "rotate-180" : ""}`} />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{({ close }) => (
|
||||
<div className="space-y-1">
|
||||
{voices.map((v) => {
|
||||
const isSelected = voice === v.id;
|
||||
const isPreviewing = previewingVoiceId === v.id;
|
||||
const isPreviewLoading = previewLoadingVoiceId === v.id;
|
||||
const locale = getLocaleFromVoiceId(v.id);
|
||||
const langLabel = LOCALE_LABELS[locale] ?? locale;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={v.id}
|
||||
data-popover-selected={isSelected ? "true" : undefined}
|
||||
className={`flex w-full items-center justify-between rounded-lg border px-3 py-2 text-left transition-colors ${isSelected
|
||||
? "border-purple-500 bg-purple-500/20"
|
||||
: "border-white/10 bg-white/5 hover:border-white/30"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
stopVoicePreview();
|
||||
onSelectVoice(v.id);
|
||||
close();
|
||||
}}
|
||||
className="min-w-0 flex-1 text-left"
|
||||
>
|
||||
<span className="block truncate text-sm text-white">{v.name}</span>
|
||||
<span className="mt-0.5 block text-xs text-gray-400">{langLabel}</span>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2 pl-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
void handleVoicePreview(v.id, e);
|
||||
}}
|
||||
className="p-1 text-gray-400 hover:text-purple-300 transition-colors"
|
||||
title={isPreviewing ? "停止试听" : "试听"}
|
||||
>
|
||||
{isPreviewLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : isPreviewing ? (
|
||||
<Pause className="h-4 w-4" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
{isSelected && <Check className="h-4 w-4 text-purple-300" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</SelectPopover>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -231,6 +231,29 @@ export const usePublishController = () => {
|
||||
|
||||
// ---- 操作函数 ----
|
||||
|
||||
const runWithConcurrency = async <T,>(
|
||||
taskFactories: Array<() => Promise<T>>,
|
||||
concurrency: number
|
||||
): Promise<T[]> => {
|
||||
if (taskFactories.length === 0) return [];
|
||||
|
||||
const results: T[] = new Array(taskFactories.length);
|
||||
let nextIndex = 0;
|
||||
|
||||
const worker = async () => {
|
||||
while (true) {
|
||||
const currentIndex = nextIndex;
|
||||
nextIndex += 1;
|
||||
if (currentIndex >= taskFactories.length) return;
|
||||
results[currentIndex] = await taskFactories[currentIndex]();
|
||||
}
|
||||
};
|
||||
|
||||
const workerCount = Math.min(Math.max(concurrency, 1), taskFactories.length);
|
||||
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
||||
return results;
|
||||
};
|
||||
|
||||
const togglePlatform = (platform: string) => {
|
||||
if (selectedPlatforms.includes(platform)) {
|
||||
setSelectedPlatforms(selectedPlatforms.filter((p) => p !== platform));
|
||||
@@ -252,7 +275,8 @@ export const usePublishController = () => {
|
||||
setIsPublishing(true);
|
||||
setPublishResults([]);
|
||||
const tagList = tags.split(/[,,\s]+/).filter((t) => t.trim());
|
||||
for (const platform of selectedPlatforms) {
|
||||
|
||||
const publishOnePlatform = async (platform: string): Promise<PublishResult> => {
|
||||
try {
|
||||
const { data: res } = await api.post<ApiResponse<any>>("/api/publish", {
|
||||
video_path: video.path, platform, title, tags: tagList, description: "",
|
||||
@@ -260,19 +284,26 @@ export const usePublishController = () => {
|
||||
const result = unwrap(res);
|
||||
const screenshotUrl = typeof result.screenshot_url === "string"
|
||||
? resolveMediaUrl(result.screenshot_url) || result.screenshot_url : undefined;
|
||||
setPublishResults((prev) => [...prev, {
|
||||
return {
|
||||
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 }]);
|
||||
return { platform, success: false, message };
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const taskFactories = selectedPlatforms.map((platform) => () => publishOnePlatform(platform));
|
||||
const results = await runWithConcurrency(taskFactories, 2);
|
||||
setPublishResults(results);
|
||||
} finally {
|
||||
setIsPublishing(false);
|
||||
}
|
||||
setIsPublishing(false);
|
||||
};
|
||||
|
||||
const handleLogin = async (platform: string) => {
|
||||
|
||||
@@ -4,9 +4,13 @@ import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import VideoPreviewModal from "@/components/VideoPreviewModal";
|
||||
import AccountSettingsDropdown from "@/components/AccountSettingsDropdown";
|
||||
import { SelectPopover } from "@/shared/ui/SelectPopover";
|
||||
import { AppModal, AppModalHeader } from "@/shared/ui/AppModal";
|
||||
import { usePublishController } from "@/features/publish/model/usePublishController";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Check,
|
||||
ChevronDown,
|
||||
RotateCcw,
|
||||
LogOut,
|
||||
QrCode,
|
||||
@@ -18,6 +22,7 @@ import {
|
||||
export function PublishPage() {
|
||||
const {
|
||||
accounts,
|
||||
videos,
|
||||
isAccountsLoading,
|
||||
isVideosLoading,
|
||||
selectedVideo,
|
||||
@@ -47,6 +52,8 @@ export function PublishPage() {
|
||||
closeQrModal,
|
||||
} = usePublishController();
|
||||
|
||||
const selectedVideoItem = videos.find((v) => v.id === selectedVideo) || null;
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh">
|
||||
<VideoPreviewModal
|
||||
@@ -56,51 +63,69 @@ export function PublishPage() {
|
||||
/>
|
||||
{/* QR码弹窗 */}
|
||||
{qrPlatform && (
|
||||
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-2xl p-8 max-w-md min-w-[320px]">
|
||||
<h2 className="text-2xl font-bold mb-4 text-center">🔐 扫码登录 {qrPlatform}</h2>
|
||||
<AppModal
|
||||
isOpen={Boolean(qrPlatform)}
|
||||
onClose={closeQrModal}
|
||||
panelClassName="w-full max-w-md rounded-2xl border border-white/10 bg-[#171821]/95 shadow-[0_24px_80px_rgba(0,0,0,0.55)] overflow-hidden"
|
||||
closeOnOverlay={false}
|
||||
>
|
||||
<AppModalHeader
|
||||
title={`🔐 扫码登录 ${qrPlatform}`}
|
||||
subtitle="请使用手机扫码完成登录验证"
|
||||
icon={<QrCode className="h-5 w-5 text-purple-300" />}
|
||||
onClose={closeQrModal}
|
||||
/>
|
||||
|
||||
<div className="p-5 space-y-4">
|
||||
{isLoadingQR ? (
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<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>
|
||||
<Loader2 className="h-14 w-14 animate-spin text-purple-400" />
|
||||
<p className="text-gray-300 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扫描上方二维码完成刷脸验证
|
||||
<div className="space-y-3">
|
||||
<div className="mx-auto w-fit rounded-xl border border-white/10 bg-white p-2 shadow-[0_10px_30px_rgba(0,0,0,0.35)]">
|
||||
<Image
|
||||
src={`data:image/png;base64,${faceVerifyQr}`}
|
||||
alt="Face Verify QR"
|
||||
width={400}
|
||||
height={300}
|
||||
className="h-auto w-[min(82vw,400px)] border border-black/5"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<p className="text-center text-amber-300 text-sm font-medium">
|
||||
需要身份验证,请用抖音 APP 扫描上方二维码完成刷脸验证
|
||||
</p>
|
||||
</>
|
||||
</div>
|
||||
) : qrCodeImage ? (
|
||||
<>
|
||||
<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">
|
||||
请使用手机扫码登录
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
<div className="space-y-3">
|
||||
<div className="mx-auto w-fit rounded-xl border border-white/10 bg-white p-3 shadow-[0_10px_30px_rgba(0,0,0,0.35)]">
|
||||
<Image
|
||||
src={`data:image/png;base64,${qrCodeImage}`}
|
||||
alt="QR Code"
|
||||
width={300}
|
||||
height={300}
|
||||
className="h-auto w-[min(74vw,300px)] border border-black/5"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<p className="text-center text-gray-300 text-sm">请使用手机扫码登录</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-200">
|
||||
二维码获取失败,请重试
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={closeQrModal}
|
||||
className="w-full mt-4 px-4 py-2 bg-gray-200 rounded-lg hover:bg-gray-300"
|
||||
className="w-full px-4 py-2.5 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</AppModal>
|
||||
)}
|
||||
|
||||
{/* Header - 统一样式 */}
|
||||
@@ -227,76 +252,112 @@ export function PublishPage() {
|
||||
{/* 选择视频 */}
|
||||
<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">八、选择发布作品</h2>
|
||||
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Search className="text-gray-400 w-4 h-4" />
|
||||
<input
|
||||
type="text"
|
||||
value={videoFilter}
|
||||
onChange={(e) => setVideoFilter(e.target.value)}
|
||||
placeholder="搜索视频名称..."
|
||||
className="flex-1 bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isVideosLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 2 }).map((_, index) => (
|
||||
<div
|
||||
key={`video-skeleton-${index}`}
|
||||
className="p-3 rounded-lg border border-white/10 bg-white/5 animate-pulse"
|
||||
>
|
||||
<div className="h-4 w-40 bg-white/10 rounded" />
|
||||
<div className="h-3 w-24 bg-white/5 rounded mt-2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : filteredVideos.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
暂无可发布的视频
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto hide-scrollbar" style={{ contentVisibility: "auto" }}>
|
||||
{filteredVideos.map((v) => (
|
||||
<div
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-white">{v.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pl-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePreviewVideo(v.id);
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
const src = v.path.startsWith("/") ? v.path : `/${v.path}`;
|
||||
const prefetch = document.createElement("link");
|
||||
prefetch.rel = "preload";
|
||||
prefetch.as = "video";
|
||||
prefetch.href = src;
|
||||
document.head.appendChild(prefetch);
|
||||
setTimeout(() => prefetch.remove(), 2000);
|
||||
}}
|
||||
className="p-1 text-gray-500 hover:text-purple-400 transition-colors"
|
||||
title="预览"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
{selectedVideo === v.id && (
|
||||
<span className="text-xs text-purple-300">已选</span>
|
||||
)}
|
||||
<SelectPopover
|
||||
sheetTitle="选择发布作品"
|
||||
onOpen={() => setVideoFilter("")}
|
||||
trigger={({ open, toggle }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className="w-full rounded-xl border border-white/10 bg-black/25 px-3 py-2.5 text-left transition-colors hover:border-white/30"
|
||||
>
|
||||
<span className="flex items-center justify-between gap-3">
|
||||
<span className="min-w-0">
|
||||
<span className="block text-xs text-gray-400">当前作品</span>
|
||||
<span className="mt-0.5 block truncate text-sm text-white">
|
||||
{selectedVideoItem?.name || (isVideosLoading ? "正在加载作品..." : "请选择发布作品")}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown className={`h-4 w-4 text-gray-300 transition-transform ${open ? "rotate-180" : ""}`} />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{({ close }) => (
|
||||
<div className="space-y-2">
|
||||
<div className="rounded-lg border border-white/10 bg-black/30 px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Search className="h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={videoFilter}
|
||||
onChange={(e) => setVideoFilter(e.target.value)}
|
||||
placeholder="搜索视频名称..."
|
||||
className="w-full bg-transparent text-sm text-white placeholder-gray-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isVideosLoading ? (
|
||||
<div className="space-y-2 p-1">
|
||||
{Array.from({ length: 2 }).map((_, index) => (
|
||||
<div
|
||||
key={`video-skeleton-${index}`}
|
||||
className="p-3 rounded-lg border border-white/10 bg-white/5 animate-pulse"
|
||||
>
|
||||
<div className="h-4 w-40 bg-white/10 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : filteredVideos.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-gray-400">
|
||||
暂无可发布的视频
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 pb-1" style={{ contentVisibility: "auto" }}>
|
||||
{filteredVideos.map((v) => {
|
||||
const isSelected = selectedVideo === v.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={v.id}
|
||||
data-popover-selected={isSelected ? "true" : undefined}
|
||||
className={`flex items-center gap-2 rounded-lg border px-3 py-2 transition-colors ${isSelected
|
||||
? "border-purple-500 bg-purple-500/20"
|
||||
: "border-white/10 bg-white/5 hover:border-white/30"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedVideo(v.id);
|
||||
close();
|
||||
}}
|
||||
className="min-w-0 flex-1 text-left"
|
||||
>
|
||||
<span className="block truncate text-sm text-white">{v.name}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePreviewVideo(v.id);
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
const src = v.path.startsWith("/") ? v.path : `/${v.path}`;
|
||||
const prefetch = document.createElement("link");
|
||||
prefetch.rel = "preload";
|
||||
prefetch.as = "video";
|
||||
prefetch.href = src;
|
||||
document.head.appendChild(prefetch);
|
||||
setTimeout(() => prefetch.remove(), 2000);
|
||||
}}
|
||||
className="p-1 text-gray-400 hover:text-purple-300"
|
||||
title="预览"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{isSelected && <Check className="h-4 w-4 text-purple-300" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SelectPopover>
|
||||
</div>
|
||||
|
||||
{/* 填写信息 */}
|
||||
|
||||
137
frontend/src/shared/ui/AppModal.tsx
Normal file
137
frontend/src/shared/ui/AppModal.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
interface AppModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
zIndexClassName?: string;
|
||||
panelClassName?: string;
|
||||
closeOnOverlay?: boolean;
|
||||
lockBodyScroll?: boolean;
|
||||
}
|
||||
|
||||
export function AppModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
children,
|
||||
zIndexClassName = "z-[220]",
|
||||
panelClassName = "w-full max-w-2xl rounded-2xl border border-white/10 bg-[#171821]/95 shadow-[0_24px_80px_rgba(0,0,0,0.55)] overflow-hidden",
|
||||
closeOnOverlay = true,
|
||||
lockBodyScroll = true,
|
||||
}: AppModalProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const onCloseRef = useRef(onClose);
|
||||
|
||||
useEffect(() => {
|
||||
onCloseRef.current = onClose;
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleEsc = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onCloseRef.current();
|
||||
};
|
||||
|
||||
const previousActiveElement = document.activeElement as HTMLElement | null;
|
||||
|
||||
if (lockBodyScroll) {
|
||||
const openCount = Number(document.body.dataset.appModalOpenCount ?? "0");
|
||||
if (openCount === 0) {
|
||||
document.body.dataset.appModalPrevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
document.body.dataset.appModalOpenCount = String(openCount + 1);
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleEsc);
|
||||
requestAnimationFrame(() => containerRef.current?.focus());
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleEsc);
|
||||
|
||||
if (lockBodyScroll) {
|
||||
const openCount = Number(document.body.dataset.appModalOpenCount ?? "0");
|
||||
const nextCount = Math.max(0, openCount - 1);
|
||||
|
||||
if (nextCount === 0) {
|
||||
document.body.style.overflow = document.body.dataset.appModalPrevOverflow ?? "";
|
||||
delete document.body.dataset.appModalPrevOverflow;
|
||||
delete document.body.dataset.appModalOpenCount;
|
||||
} else {
|
||||
document.body.dataset.appModalOpenCount = String(nextCount);
|
||||
}
|
||||
}
|
||||
|
||||
previousActiveElement?.focus?.();
|
||||
};
|
||||
}, [isOpen, lockBodyScroll]);
|
||||
|
||||
if (!isOpen || typeof document === "undefined") return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={containerRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabIndex={-1}
|
||||
className={`fixed inset-0 ${zIndexClassName} flex items-center justify-center bg-black/80 backdrop-blur-sm p-4 animate-in fade-in duration-200`}
|
||||
onClick={closeOnOverlay ? onClose : undefined}
|
||||
>
|
||||
<div className={panelClassName} onClick={(event) => event.stopPropagation()}>
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
interface AppModalHeaderProps {
|
||||
title: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
onClose?: () => void;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
export function AppModalHeader({
|
||||
title,
|
||||
subtitle,
|
||||
icon,
|
||||
onClose,
|
||||
actions,
|
||||
}: AppModalHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 border-b border-white/10 bg-gradient-to-r from-white/[0.08] via-white/[0.03] to-white/[0.08] px-4 py-3">
|
||||
<div className="min-w-0 flex items-center gap-3">
|
||||
{icon ? (
|
||||
<div className="h-9 w-9 rounded-lg bg-white/10 text-white flex items-center justify-center">
|
||||
{icon}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-base font-semibold text-white">{title}</h3>
|
||||
{subtitle ? <p className="mt-0.5 text-xs text-gray-400">{subtitle}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{actions}
|
||||
{onClose ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="关闭弹窗"
|
||||
className="p-2 text-gray-400 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
233
frontend/src/shared/ui/SelectPopover.tsx
Normal file
233
frontend/src/shared/ui/SelectPopover.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
interface SelectPopoverTriggerContext {
|
||||
open: boolean;
|
||||
isMobile: boolean;
|
||||
toggle: () => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
interface SelectPopoverPanelContext {
|
||||
isMobile: boolean;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
interface SelectPopoverProps {
|
||||
trigger: (ctx: SelectPopoverTriggerContext) => ReactNode;
|
||||
children: (ctx: SelectPopoverPanelContext) => ReactNode;
|
||||
sheetTitle?: string;
|
||||
disabled?: boolean;
|
||||
panelClassName?: string;
|
||||
onOpen?: () => void;
|
||||
}
|
||||
|
||||
const MOBILE_QUERY = "(max-width: 639px)";
|
||||
|
||||
export function SelectPopover({
|
||||
trigger,
|
||||
children,
|
||||
sheetTitle,
|
||||
disabled = false,
|
||||
panelClassName = "",
|
||||
onOpen,
|
||||
}: SelectPopoverProps) {
|
||||
type DesktopRect = {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
maxHeight: number;
|
||||
direction: "up" | "down";
|
||||
};
|
||||
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
const desktopScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const mobileScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [desktopRect, setDesktopRect] = useState<DesktopRect | null>(null);
|
||||
const isOpen = open && !disabled;
|
||||
|
||||
const canUseDOM = typeof window !== "undefined" && typeof document !== "undefined";
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const mq = window.matchMedia(MOBILE_QUERY);
|
||||
const handleChange = () => setIsMobile(mq.matches);
|
||||
handleChange();
|
||||
|
||||
if (mq.addEventListener) {
|
||||
mq.addEventListener("change", handleChange);
|
||||
return () => mq.removeEventListener("change", handleChange);
|
||||
}
|
||||
|
||||
mq.addListener(handleChange);
|
||||
return () => mq.removeListener(handleChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || isMobile) return;
|
||||
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
if (canUseDOM && document.querySelector("[data-video-preview-open='true']")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = event.target as Node;
|
||||
const clickedTrigger = containerRef.current?.contains(target) ?? false;
|
||||
const clickedPanel = panelRef.current?.contains(target) ?? false;
|
||||
if (!clickedTrigger && !clickedPanel) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handlePointerDown);
|
||||
return () => document.removeEventListener("mousedown", handlePointerDown);
|
||||
}, [isOpen, isMobile, canUseDOM]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const previewOpen = canUseDOM && Boolean(document.querySelector("[data-video-preview-open='true']"));
|
||||
if (event.key === "Escape" && !previewOpen) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen, canUseDOM]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
onOpen?.();
|
||||
}
|
||||
}, [isOpen, onOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !canUseDOM) return;
|
||||
|
||||
let raf1 = 0;
|
||||
let raf2 = 0;
|
||||
const scrollSelectedIntoView = () => {
|
||||
const container = isMobile ? mobileScrollRef.current : desktopScrollRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const selectedEl = container.querySelector<HTMLElement>(
|
||||
"[data-popover-selected='true'], [aria-selected='true']",
|
||||
);
|
||||
selectedEl?.scrollIntoView({ block: "nearest", behavior: "auto" });
|
||||
};
|
||||
|
||||
raf1 = window.requestAnimationFrame(() => {
|
||||
raf2 = window.requestAnimationFrame(scrollSelectedIntoView);
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (raf1) window.cancelAnimationFrame(raf1);
|
||||
if (raf2) window.cancelAnimationFrame(raf2);
|
||||
};
|
||||
}, [isOpen, isMobile, canUseDOM]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || isMobile || !canUseDOM) return;
|
||||
|
||||
const updateDesktopRect = () => {
|
||||
const triggerEl = containerRef.current;
|
||||
if (!triggerEl) return;
|
||||
|
||||
const viewportPadding = 8;
|
||||
const gap = 8;
|
||||
const preferredMaxHeight = 352;
|
||||
const rect = triggerEl.getBoundingClientRect();
|
||||
const width = rect.width;
|
||||
const maxLeft = Math.max(viewportPadding, window.innerWidth - width - viewportPadding);
|
||||
const left = Math.min(Math.max(viewportPadding, rect.left), maxLeft);
|
||||
|
||||
const spaceBelow = window.innerHeight - rect.bottom - gap - viewportPadding;
|
||||
const spaceAbove = rect.top - gap - viewportPadding;
|
||||
const openUp = spaceBelow < 220 && spaceAbove > spaceBelow;
|
||||
const direction: "up" | "down" = openUp ? "up" : "down";
|
||||
const chosenSpace = openUp ? spaceAbove : spaceBelow;
|
||||
const maxHeight = Math.max(120, Math.min(preferredMaxHeight, Math.floor(chosenSpace)));
|
||||
const top = openUp
|
||||
? Math.max(viewportPadding, rect.top - gap)
|
||||
: Math.min(rect.bottom + gap, window.innerHeight - viewportPadding);
|
||||
|
||||
setDesktopRect({ left, top, width, maxHeight, direction });
|
||||
};
|
||||
|
||||
updateDesktopRect();
|
||||
window.addEventListener("resize", updateDesktopRect);
|
||||
window.addEventListener("scroll", updateDesktopRect, true);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", updateDesktopRect);
|
||||
window.removeEventListener("scroll", updateDesktopRect, true);
|
||||
};
|
||||
}, [isOpen, isMobile, canUseDOM]);
|
||||
|
||||
const close = () => setOpen(false);
|
||||
const toggle = () => {
|
||||
if (disabled) return;
|
||||
setOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
const desktopPanel = canUseDOM && isOpen && !isMobile && desktopRect
|
||||
? createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={`fixed z-[260] overflow-hidden rounded-2xl border border-white/20 bg-[#130f20]/95 backdrop-blur-md shadow-[0_20px_48px_rgba(8,10,20,0.5)] ${panelClassName}`}
|
||||
style={{
|
||||
left: desktopRect.left,
|
||||
top: desktopRect.top,
|
||||
width: desktopRect.width,
|
||||
transform: desktopRect.direction === "up" ? "translateY(-100%)" : undefined,
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
>
|
||||
<div ref={desktopScrollRef} className="hide-scrollbar overflow-y-auto p-2" style={{ maxHeight: desktopRect.maxHeight }}>
|
||||
{children({ isMobile: false, close })}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null;
|
||||
|
||||
const mobileSheet = canUseDOM && isOpen && isMobile
|
||||
? createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[220] bg-black/60"
|
||||
onMouseDown={close}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div
|
||||
className="fixed inset-x-0 bottom-0 max-h-[78dvh] overflow-hidden rounded-t-3xl border-t border-white/20 bg-[#130f20]/95"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mx-auto mt-2 h-1.5 w-12 rounded-full bg-white/20" />
|
||||
{sheetTitle && (
|
||||
<div className="px-5 pt-3 pb-2 text-sm font-medium text-gray-300">{sheetTitle}</div>
|
||||
)}
|
||||
<div ref={mobileScrollRef} className="hide-scrollbar max-h-[calc(78dvh-56px)] overflow-y-auto p-3">{children({ isMobile: true, close })}</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="relative" ref={containerRef}>
|
||||
{trigger({ open: isOpen, isMobile, toggle, close })}
|
||||
{desktopPanel}
|
||||
{mobileSheet}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -253,21 +253,58 @@ class LipsyncPipeline(DiffusionPipeline):
|
||||
faces = []
|
||||
boxes = []
|
||||
affine_matrices = []
|
||||
valid_face_flags = []
|
||||
print(f"Affine transforming {len(video_frames)} faces...")
|
||||
for frame in tqdm.tqdm(video_frames):
|
||||
face, box, affine_matrix = self.image_processor.affine_transform(frame)
|
||||
faces.append(face)
|
||||
boxes.append(box)
|
||||
affine_matrices.append(affine_matrix)
|
||||
try:
|
||||
face, box, affine_matrix = self.image_processor.affine_transform(frame)
|
||||
faces.append(face)
|
||||
boxes.append(box)
|
||||
affine_matrices.append(affine_matrix)
|
||||
valid_face_flags.append(True)
|
||||
except Exception:
|
||||
faces.append(None)
|
||||
boxes.append(None)
|
||||
affine_matrices.append(None)
|
||||
valid_face_flags.append(False)
|
||||
|
||||
valid_indices = [i for i, flag in enumerate(valid_face_flags) if flag]
|
||||
if not valid_indices:
|
||||
raise RuntimeError("Face not detected in any frame")
|
||||
|
||||
for i in range(len(faces)):
|
||||
if faces[i] is not None:
|
||||
continue
|
||||
nearest_idx = min(valid_indices, key=lambda idx: abs(idx - i))
|
||||
faces[i] = faces[nearest_idx].clone()
|
||||
boxes[i] = boxes[nearest_idx]
|
||||
affine_matrices[i] = affine_matrices[nearest_idx]
|
||||
|
||||
missing_count = len(valid_face_flags) - len(valid_indices)
|
||||
if missing_count > 0:
|
||||
print(
|
||||
f"Warning: face not detected in {missing_count}/{len(valid_face_flags)} frames. "
|
||||
"Those frames will keep original content."
|
||||
)
|
||||
|
||||
faces = torch.stack(faces)
|
||||
return faces, boxes, affine_matrices
|
||||
return faces, boxes, affine_matrices, valid_face_flags
|
||||
|
||||
def restore_video(self, faces: torch.Tensor, video_frames: np.ndarray, boxes: list, affine_matrices: list):
|
||||
def restore_video(
|
||||
self,
|
||||
faces: torch.Tensor,
|
||||
video_frames: np.ndarray,
|
||||
boxes: list,
|
||||
affine_matrices: list,
|
||||
valid_face_flags: Optional[list] = None,
|
||||
):
|
||||
video_frames = video_frames[: len(faces)]
|
||||
out_frames = []
|
||||
print(f"Restoring {len(faces)} faces...")
|
||||
for index, face in enumerate(tqdm.tqdm(faces)):
|
||||
if valid_face_flags is not None and not valid_face_flags[index]:
|
||||
out_frames.append(video_frames[index])
|
||||
continue
|
||||
x1, y1, x2, y2 = boxes[index]
|
||||
height = int(y2 - y1)
|
||||
width = int(x2 - x1)
|
||||
@@ -281,33 +318,37 @@ class LipsyncPipeline(DiffusionPipeline):
|
||||
def loop_video(self, whisper_chunks: list, video_frames: np.ndarray):
|
||||
# If the audio is longer than the video, we need to loop the video
|
||||
if len(whisper_chunks) > len(video_frames):
|
||||
faces, boxes, affine_matrices = self.affine_transform_video(video_frames)
|
||||
faces, boxes, affine_matrices, valid_face_flags = self.affine_transform_video(video_frames)
|
||||
num_loops = math.ceil(len(whisper_chunks) / len(video_frames))
|
||||
loop_video_frames = []
|
||||
loop_faces = []
|
||||
loop_boxes = []
|
||||
loop_affine_matrices = []
|
||||
loop_valid_face_flags = []
|
||||
for i in range(num_loops):
|
||||
if i % 2 == 0:
|
||||
loop_video_frames.append(video_frames)
|
||||
loop_faces.append(faces)
|
||||
loop_boxes += boxes
|
||||
loop_affine_matrices += affine_matrices
|
||||
loop_valid_face_flags += valid_face_flags
|
||||
else:
|
||||
loop_video_frames.append(video_frames[::-1])
|
||||
loop_faces.append(faces.flip(0))
|
||||
loop_boxes += boxes[::-1]
|
||||
loop_affine_matrices += affine_matrices[::-1]
|
||||
loop_valid_face_flags += valid_face_flags[::-1]
|
||||
|
||||
video_frames = np.concatenate(loop_video_frames, axis=0)[: len(whisper_chunks)]
|
||||
faces = torch.cat(loop_faces, dim=0)[: len(whisper_chunks)]
|
||||
boxes = loop_boxes[: len(whisper_chunks)]
|
||||
affine_matrices = loop_affine_matrices[: len(whisper_chunks)]
|
||||
valid_face_flags = loop_valid_face_flags[: len(whisper_chunks)]
|
||||
else:
|
||||
video_frames = video_frames[: len(whisper_chunks)]
|
||||
faces, boxes, affine_matrices = self.affine_transform_video(video_frames)
|
||||
faces, boxes, affine_matrices, valid_face_flags = self.affine_transform_video(video_frames)
|
||||
|
||||
return video_frames, faces, boxes, affine_matrices
|
||||
return video_frames, faces, boxes, affine_matrices, valid_face_flags
|
||||
|
||||
@torch.no_grad()
|
||||
def __call__(
|
||||
@@ -367,7 +408,7 @@ class LipsyncPipeline(DiffusionPipeline):
|
||||
audio_samples = read_audio(audio_path)
|
||||
video_frames = read_video(video_path, use_decord=False)
|
||||
|
||||
video_frames, faces, boxes, affine_matrices = self.loop_video(whisper_chunks, video_frames)
|
||||
video_frames, faces, boxes, affine_matrices, valid_face_flags = self.loop_video(whisper_chunks, video_frames)
|
||||
|
||||
synced_video_frames = []
|
||||
|
||||
@@ -457,7 +498,13 @@ class LipsyncPipeline(DiffusionPipeline):
|
||||
)
|
||||
synced_video_frames.append(decoded_latents)
|
||||
|
||||
synced_video_frames = self.restore_video(torch.cat(synced_video_frames), video_frames, boxes, affine_matrices)
|
||||
synced_video_frames = self.restore_video(
|
||||
torch.cat(synced_video_frames),
|
||||
video_frames,
|
||||
boxes,
|
||||
affine_matrices,
|
||||
valid_face_flags=valid_face_flags,
|
||||
)
|
||||
|
||||
audio_samples_remain_length = int(synced_video_frames.shape[0] / video_fps * audio_sample_rate)
|
||||
audio_samples = audio_samples[:audio_samples_remain_length].cpu().numpy()
|
||||
@@ -473,5 +520,5 @@ class LipsyncPipeline(DiffusionPipeline):
|
||||
|
||||
sf.write(os.path.join(temp_dir, "audio.wav"), audio_samples, audio_sample_rate)
|
||||
|
||||
command = f"ffmpeg -y -loglevel error -nostdin -i {os.path.join(temp_dir, 'video.mp4')} -i {os.path.join(temp_dir, 'audio.wav')} -c:v libx264 -crf 18 -c:a aac -q:v 0 -q:a 0 {video_out_path}"
|
||||
command = f"ffmpeg -y -loglevel error -nostdin -i {os.path.join(temp_dir, 'video.mp4')} -i {os.path.join(temp_dir, 'audio.wav')} -c:v copy -c:a aac -q:a 0 {video_out_path}"
|
||||
subprocess.run(command, shell=True)
|
||||
|
||||
@@ -49,11 +49,22 @@ def read_video(video_path: str, change_fps=True, use_decord=True):
|
||||
if os.path.exists(temp_dir):
|
||||
shutil.rmtree(temp_dir)
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
command = (
|
||||
f"ffmpeg -loglevel error -y -nostdin -i {video_path} -r 25 -crf 18 {os.path.join(temp_dir, 'video.mp4')}"
|
||||
)
|
||||
subprocess.run(command, shell=True)
|
||||
target_video_path = os.path.join(temp_dir, "video.mp4")
|
||||
|
||||
# 检测输入视频 FPS,已是 25fps 时跳过重编码
|
||||
cap = cv2.VideoCapture(video_path)
|
||||
current_fps = cap.get(cv2.CAP_PROP_FPS)
|
||||
cap.release()
|
||||
|
||||
if abs(current_fps - 25.0) < 0.5:
|
||||
# 已是 25fps,直接使用原文件(避免一次有损重编码)
|
||||
print(f"Video already at {current_fps:.1f}fps, skipping FPS conversion")
|
||||
target_video_path = video_path
|
||||
else:
|
||||
command = (
|
||||
f"ffmpeg -loglevel error -y -nostdin -i {video_path} -r 25 -crf 18 {os.path.join(temp_dir, 'video.mp4')}"
|
||||
)
|
||||
subprocess.run(command, shell=True)
|
||||
target_video_path = os.path.join(temp_dir, "video.mp4")
|
||||
else:
|
||||
target_video_path = video_path
|
||||
|
||||
|
||||
@@ -4,14 +4,14 @@ MuseTalk v1.5 常驻推理服务 (优化版 v2)
|
||||
- GPU: 从 backend/.env 读取 MUSETALK_GPU_ID (默认 0)
|
||||
- 架构: FastAPI + lifespan (与 LatentSync server.py 同模式)
|
||||
|
||||
优化项 (vs v1):
|
||||
1. cv2.VideoCapture 直读帧 (跳过 ffmpeg→PNG→imread)
|
||||
2. 人脸检测降频 (每 N 帧检测, 中间插值 bbox)
|
||||
3. BiSeNet mask 缓存 (每 N 帧更新, 中间复用)
|
||||
4. cv2.VideoWriter 直写视频 (跳过逐帧 PNG 写盘)
|
||||
5. batch_size 8→32
|
||||
6. 每阶段计时
|
||||
"""
|
||||
优化项 (vs v1):
|
||||
1. cv2.VideoCapture 直读帧 (跳过 ffmpeg→PNG→imread)
|
||||
2. 人脸检测降频 (每 N 帧检测, 中间插值 bbox)
|
||||
3. BiSeNet mask 缓存 (每 N 帧更新, 中间复用)
|
||||
4. FFmpeg rawvideo 管道直编码 (去掉中间有损 mp4v)
|
||||
5. batch_size 8→32
|
||||
6. 每阶段计时
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
@@ -84,17 +84,28 @@ from musetalk.utils.utils import get_file_type, get_video_fps, datagen, load_all
|
||||
from musetalk.utils.preprocessing import get_landmark_and_bbox, read_imgs, coord_placeholder
|
||||
|
||||
# --- 从 .env 读取额外配置 ---
|
||||
def load_env_config():
|
||||
"""读取 MuseTalk 相关环境变量"""
|
||||
config = {
|
||||
"batch_size": 32,
|
||||
"version": "v15",
|
||||
"use_float16": True,
|
||||
}
|
||||
try:
|
||||
env_path = musetalk_root.parent.parent / "backend" / ".env"
|
||||
if env_path.exists():
|
||||
with open(env_path, "r", encoding="utf-8") as f:
|
||||
def load_env_config():
|
||||
"""读取 MuseTalk 相关环境变量"""
|
||||
config = {
|
||||
"batch_size": 32,
|
||||
"version": "v15",
|
||||
"use_float16": True,
|
||||
"detect_every": 5,
|
||||
"blend_cache_every": 5,
|
||||
"audio_padding_left": 2,
|
||||
"audio_padding_right": 2,
|
||||
"extra_margin": 15,
|
||||
"delay_frame": 0,
|
||||
"blend_mode": "auto",
|
||||
"faceparsing_left_cheek_width": 90,
|
||||
"faceparsing_right_cheek_width": 90,
|
||||
"encode_crf": 18,
|
||||
"encode_preset": "medium",
|
||||
}
|
||||
try:
|
||||
env_path = musetalk_root.parent.parent / "backend" / ".env"
|
||||
if env_path.exists():
|
||||
with open(env_path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line.startswith("MUSETALK_BATCH_SIZE="):
|
||||
@@ -105,22 +116,78 @@ def load_env_config():
|
||||
val = line.split("=")[1].strip().split("#")[0].strip()
|
||||
if val:
|
||||
config["version"] = val
|
||||
elif line.startswith("MUSETALK_USE_FLOAT16="):
|
||||
val = line.split("=")[1].strip().split("#")[0].strip().lower()
|
||||
config["use_float16"] = val in ("true", "1", "yes")
|
||||
except Exception as e:
|
||||
print(f"⚠️ 读取额外配置失败: {e}")
|
||||
return config
|
||||
|
||||
env_config = load_env_config()
|
||||
elif line.startswith("MUSETALK_USE_FLOAT16="):
|
||||
val = line.split("=")[1].strip().split("#")[0].strip().lower()
|
||||
config["use_float16"] = val in ("true", "1", "yes")
|
||||
elif line.startswith("MUSETALK_DETECT_EVERY="):
|
||||
val = line.split("=")[1].strip().split("#")[0].strip()
|
||||
if val:
|
||||
config["detect_every"] = max(1, int(val))
|
||||
elif line.startswith("MUSETALK_BLEND_CACHE_EVERY="):
|
||||
val = line.split("=")[1].strip().split("#")[0].strip()
|
||||
if val:
|
||||
config["blend_cache_every"] = max(1, int(val))
|
||||
elif line.startswith("MUSETALK_AUDIO_PADDING_LEFT="):
|
||||
val = line.split("=")[1].strip().split("#")[0].strip()
|
||||
if val:
|
||||
config["audio_padding_left"] = max(0, int(val))
|
||||
elif line.startswith("MUSETALK_AUDIO_PADDING_RIGHT="):
|
||||
val = line.split("=")[1].strip().split("#")[0].strip()
|
||||
if val:
|
||||
config["audio_padding_right"] = max(0, int(val))
|
||||
elif line.startswith("MUSETALK_EXTRA_MARGIN="):
|
||||
val = line.split("=")[1].strip().split("#")[0].strip()
|
||||
if val:
|
||||
config["extra_margin"] = max(0, int(val))
|
||||
elif line.startswith("MUSETALK_DELAY_FRAME="):
|
||||
val = line.split("=")[1].strip().split("#")[0].strip()
|
||||
if val:
|
||||
config["delay_frame"] = int(val)
|
||||
elif line.startswith("MUSETALK_BLEND_MODE="):
|
||||
val = line.split("=")[1].strip().split("#")[0].strip().lower()
|
||||
if val in ("auto", "jaw", "raw"):
|
||||
config["blend_mode"] = val
|
||||
elif line.startswith("MUSETALK_FACEPARSING_LEFT_CHEEK_WIDTH="):
|
||||
val = line.split("=")[1].strip().split("#")[0].strip()
|
||||
if val:
|
||||
config["faceparsing_left_cheek_width"] = max(0, int(val))
|
||||
elif line.startswith("MUSETALK_FACEPARSING_RIGHT_CHEEK_WIDTH="):
|
||||
val = line.split("=")[1].strip().split("#")[0].strip()
|
||||
if val:
|
||||
config["faceparsing_right_cheek_width"] = max(0, int(val))
|
||||
elif line.startswith("MUSETALK_ENCODE_CRF="):
|
||||
val = line.split("=")[1].strip().split("#")[0].strip()
|
||||
if val:
|
||||
config["encode_crf"] = min(51, max(0, int(val)))
|
||||
elif line.startswith("MUSETALK_ENCODE_PRESET="):
|
||||
val = line.split("=")[1].strip().split("#")[0].strip().lower()
|
||||
if val in (
|
||||
"ultrafast", "superfast", "veryfast", "faster", "fast",
|
||||
"medium", "slow", "slower", "veryslow"
|
||||
):
|
||||
config["encode_preset"] = val
|
||||
except Exception as e:
|
||||
print(f"⚠️ 读取额外配置失败: {e}")
|
||||
return config
|
||||
|
||||
env_config = load_env_config()
|
||||
|
||||
# 全局模型缓存
|
||||
models = {}
|
||||
|
||||
# ===================== 优化参数 =====================
|
||||
DETECT_EVERY = 5 # 人脸检测降频: 每 N 帧检测一次
|
||||
BLEND_CACHE_EVERY = 5 # BiSeNet mask 缓存: 每 N 帧更新一次
|
||||
# ====================================================
|
||||
# ===================== 优化参数 =====================
|
||||
DETECT_EVERY = int(env_config["detect_every"]) # 人脸检测降频: 每 N 帧检测一次
|
||||
BLEND_CACHE_EVERY = int(env_config["blend_cache_every"]) # BiSeNet mask 缓存: 每 N 帧更新一次
|
||||
AUDIO_PADDING_LEFT = int(env_config["audio_padding_left"])
|
||||
AUDIO_PADDING_RIGHT = int(env_config["audio_padding_right"])
|
||||
EXTRA_MARGIN = int(env_config["extra_margin"])
|
||||
DELAY_FRAME = int(env_config["delay_frame"])
|
||||
BLEND_MODE = str(env_config["blend_mode"])
|
||||
FACEPARSING_LEFT_CHEEK_WIDTH = int(env_config["faceparsing_left_cheek_width"])
|
||||
FACEPARSING_RIGHT_CHEEK_WIDTH = int(env_config["faceparsing_right_cheek_width"])
|
||||
ENCODE_CRF = int(env_config["encode_crf"])
|
||||
ENCODE_PRESET = str(env_config["encode_preset"])
|
||||
# ====================================================
|
||||
|
||||
|
||||
def run_ffmpeg(cmd):
|
||||
@@ -191,11 +258,14 @@ async def lifespan(app: FastAPI):
|
||||
whisper = whisper.to(device=device, dtype=weight_dtype).eval()
|
||||
whisper.requires_grad_(False)
|
||||
|
||||
# FaceParsing
|
||||
if version == "v15":
|
||||
fp = FaceParsing(left_cheek_width=90, right_cheek_width=90)
|
||||
else:
|
||||
fp = FaceParsing()
|
||||
# FaceParsing
|
||||
if version == "v15":
|
||||
fp = FaceParsing(
|
||||
left_cheek_width=FACEPARSING_LEFT_CHEEK_WIDTH,
|
||||
right_cheek_width=FACEPARSING_RIGHT_CHEEK_WIDTH,
|
||||
)
|
||||
else:
|
||||
fp = FaceParsing()
|
||||
|
||||
# 恢复工作目录
|
||||
os.chdir(original_cwd)
|
||||
@@ -211,9 +281,13 @@ async def lifespan(app: FastAPI):
|
||||
models["version"] = version
|
||||
models["timesteps"] = torch.tensor([0], device=device)
|
||||
|
||||
print("✅ MuseTalk v1.5 模型加载完成,服务就绪!")
|
||||
print(f"⚙️ 优化参数: batch_size={env_config['batch_size']}, "
|
||||
f"detect_every={DETECT_EVERY}, blend_cache_every={BLEND_CACHE_EVERY}")
|
||||
print("✅ MuseTalk v1.5 模型加载完成,服务就绪!")
|
||||
print(f"⚙️ 优化参数: batch_size={env_config['batch_size']}, "
|
||||
f"detect_every={DETECT_EVERY}, blend_cache_every={BLEND_CACHE_EVERY}, "
|
||||
f"audio_padding=({AUDIO_PADDING_LEFT},{AUDIO_PADDING_RIGHT}), extra_margin={EXTRA_MARGIN}, "
|
||||
f"delay_frame={DELAY_FRAME}, blend_mode={BLEND_MODE}, "
|
||||
f"faceparsing_cheek=({FACEPARSING_LEFT_CHEEK_WIDTH},{FACEPARSING_RIGHT_CHEEK_WIDTH}), "
|
||||
f"encode=libx264/{ENCODE_PRESET}/crf{ENCODE_CRF}")
|
||||
yield
|
||||
models.clear()
|
||||
torch.cuda.empty_cache()
|
||||
@@ -354,15 +428,15 @@ def _detect_faces_subsampled(frames, detect_every=5):
|
||||
# 核心推理 (优化版)
|
||||
# =====================================================================
|
||||
@torch.no_grad()
|
||||
def _run_inference(req: LipSyncRequest) -> dict:
|
||||
"""
|
||||
优化版推理逻辑:
|
||||
1. cv2.VideoCapture 直读帧 (跳过 ffmpeg→PNG→imread)
|
||||
2. 人脸检测降频 (每 N 帧, 中间插值)
|
||||
3. BiSeNet mask 缓存 (每 N 帧更新)
|
||||
4. cv2.VideoWriter 直写 (跳过逐帧 PNG)
|
||||
5. 每阶段计时
|
||||
def _run_inference(req: LipSyncRequest) -> dict:
|
||||
"""
|
||||
优化版推理逻辑:
|
||||
1. cv2.VideoCapture 直读帧 (跳过 ffmpeg→PNG→imread)
|
||||
2. 人脸检测降频 (每 N 帧, 中间插值)
|
||||
3. BiSeNet mask 缓存 (每 N 帧更新)
|
||||
4. FFmpeg rawvideo 管道直编码 (无中间有损文件)
|
||||
5. 每阶段计时
|
||||
"""
|
||||
vae = models["vae"]
|
||||
unet = models["unet"]
|
||||
pe = models["pe"]
|
||||
@@ -411,12 +485,12 @@ def _run_inference(req: LipSyncRequest) -> dict:
|
||||
# ===== Phase 2: Whisper 音频特征 =====
|
||||
t0 = time.time()
|
||||
whisper_input_features, librosa_length = audio_processor.get_audio_feature(audio_path)
|
||||
whisper_chunks = audio_processor.get_whisper_chunk(
|
||||
whisper_input_features, device, weight_dtype, whisper, librosa_length,
|
||||
fps=fps,
|
||||
audio_padding_length_left=2,
|
||||
audio_padding_length_right=2,
|
||||
)
|
||||
whisper_chunks = audio_processor.get_whisper_chunk(
|
||||
whisper_input_features, device, weight_dtype, whisper, librosa_length,
|
||||
fps=fps,
|
||||
audio_padding_length_left=AUDIO_PADDING_LEFT,
|
||||
audio_padding_length_right=AUDIO_PADDING_RIGHT,
|
||||
)
|
||||
timings["2_whisper"] = time.time() - t0
|
||||
print(f"🎵 Whisper 特征 [{timings['2_whisper']:.1f}s]")
|
||||
|
||||
@@ -427,12 +501,12 @@ def _run_inference(req: LipSyncRequest) -> dict:
|
||||
print(f"🔍 人脸检测 [{timings['3_face']:.1f}s]")
|
||||
|
||||
# ===== Phase 4: VAE 潜空间编码 =====
|
||||
t0 = time.time()
|
||||
input_latent_list = []
|
||||
extra_margin = 15
|
||||
for bbox, frame in zip(coord_list, frames):
|
||||
if bbox == coord_placeholder:
|
||||
continue
|
||||
t0 = time.time()
|
||||
input_latent_list = []
|
||||
extra_margin = EXTRA_MARGIN
|
||||
for bbox, frame in zip(coord_list, frames):
|
||||
if bbox == coord_placeholder:
|
||||
continue
|
||||
x1, y1, x2, y2 = bbox
|
||||
if version == "v15":
|
||||
y2 = min(y2 + extra_margin, frame.shape[0])
|
||||
@@ -453,13 +527,13 @@ def _run_inference(req: LipSyncRequest) -> dict:
|
||||
input_latent_list_cycle = input_latent_list + input_latent_list[::-1]
|
||||
|
||||
video_num = len(whisper_chunks)
|
||||
gen = datagen(
|
||||
whisper_chunks=whisper_chunks,
|
||||
vae_encode_latents=input_latent_list_cycle,
|
||||
batch_size=batch_size,
|
||||
delay_frame=0,
|
||||
device=device,
|
||||
)
|
||||
gen = datagen(
|
||||
whisper_chunks=whisper_chunks,
|
||||
vae_encode_latents=input_latent_list_cycle,
|
||||
batch_size=batch_size,
|
||||
delay_frame=DELAY_FRAME,
|
||||
device=device,
|
||||
)
|
||||
|
||||
res_frame_list = []
|
||||
total_batches = int(np.ceil(float(video_num) / batch_size))
|
||||
@@ -479,21 +553,44 @@ def _run_inference(req: LipSyncRequest) -> dict:
|
||||
timings["5_unet"] = time.time() - t0
|
||||
print(f"✅ UNet 推理: {len(res_frame_list)} 帧 [{timings['5_unet']:.1f}s]")
|
||||
|
||||
# ===== Phase 6: 合成 (cv2.VideoWriter + 纯 numpy blending) =====
|
||||
t0 = time.time()
|
||||
|
||||
h, w = frames[0].shape[:2]
|
||||
temp_raw_path = output_vid_path + ".raw.mp4"
|
||||
|
||||
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
|
||||
writer = cv2.VideoWriter(temp_raw_path, fourcc, fps, (w, h))
|
||||
|
||||
if not writer.isOpened():
|
||||
raise RuntimeError(f"cv2.VideoWriter 打开失败: {temp_raw_path}")
|
||||
|
||||
cached_mask = None
|
||||
cached_crop_box = None
|
||||
blend_mode = "jaw" if version == "v15" else "raw"
|
||||
# ===== Phase 6: 合成并写入 FFmpeg rawvideo 管道 =====
|
||||
t0 = time.time()
|
||||
|
||||
h, w = frames[0].shape[:2]
|
||||
ffmpeg_cmd = [
|
||||
"ffmpeg", "-y", "-v", "warning",
|
||||
"-f", "rawvideo",
|
||||
"-pix_fmt", "bgr24",
|
||||
"-s", f"{w}x{h}",
|
||||
"-r", str(fps),
|
||||
"-i", "-",
|
||||
"-i", audio_path,
|
||||
"-c:v", "libx264", "-preset", ENCODE_PRESET, "-crf", str(ENCODE_CRF), "-pix_fmt", "yuv420p",
|
||||
"-c:a", "copy", "-shortest",
|
||||
output_vid_path,
|
||||
]
|
||||
ffmpeg_proc = subprocess.Popen(
|
||||
ffmpeg_cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
pipe_in = ffmpeg_proc.stdin
|
||||
if pipe_in is None:
|
||||
raise RuntimeError("FFmpeg 管道初始化失败")
|
||||
|
||||
def _write_pipe_frame(frame: np.ndarray):
|
||||
try:
|
||||
pipe_in.write(np.ascontiguousarray(frame, dtype=np.uint8).tobytes())
|
||||
except BrokenPipeError as exc:
|
||||
raise RuntimeError("FFmpeg 管道写入失败") from exc
|
||||
|
||||
cached_mask = None
|
||||
cached_crop_box = None
|
||||
if BLEND_MODE == "auto":
|
||||
blend_mode = "jaw" if version == "v15" else "raw"
|
||||
else:
|
||||
blend_mode = BLEND_MODE
|
||||
|
||||
for i in tqdm(range(len(res_frame_list)), desc="合成"):
|
||||
res_frame = res_frame_list[i]
|
||||
@@ -503,26 +600,26 @@ def _run_inference(req: LipSyncRequest) -> dict:
|
||||
x1, y1, x2, y2 = bbox
|
||||
if version == "v15":
|
||||
y2 = min(y2 + extra_margin, ori_frame.shape[0])
|
||||
adjusted_bbox = (x1, y1, x2, y2)
|
||||
|
||||
try:
|
||||
res_frame = cv2.resize(res_frame.astype(np.uint8), (x2 - x1, y2 - y1))
|
||||
except Exception:
|
||||
writer.write(ori_frame)
|
||||
continue
|
||||
adjusted_bbox = (x1, y1, x2, y2)
|
||||
|
||||
try:
|
||||
res_frame = cv2.resize(res_frame.astype(np.uint8), (x2 - x1, y2 - y1))
|
||||
except Exception:
|
||||
_write_pipe_frame(ori_frame)
|
||||
continue
|
||||
|
||||
# 每 N 帧更新 BiSeNet 人脸解析 mask, 其余帧复用缓存
|
||||
if i % BLEND_CACHE_EVERY == 0 or cached_mask is None:
|
||||
try:
|
||||
cached_mask, cached_crop_box = get_image_prepare_material(
|
||||
ori_frame, adjusted_bbox, mode=blend_mode, fp=fp)
|
||||
except Exception:
|
||||
# 如果 prepare 失败, 用完整方式
|
||||
combine_frame = get_image(
|
||||
ori_frame, res_frame, list(adjusted_bbox),
|
||||
mode=blend_mode, fp=fp)
|
||||
writer.write(combine_frame)
|
||||
continue
|
||||
except Exception:
|
||||
# 如果 prepare 失败, 用完整方式
|
||||
combine_frame = get_image(
|
||||
ori_frame, res_frame, list(adjusted_bbox),
|
||||
mode=blend_mode, fp=fp)
|
||||
_write_pipe_frame(combine_frame)
|
||||
continue
|
||||
|
||||
try:
|
||||
combine_frame = get_image_blending_fast(
|
||||
@@ -532,35 +629,25 @@ def _run_inference(req: LipSyncRequest) -> dict:
|
||||
try:
|
||||
combine_frame = get_image_blending(
|
||||
ori_frame, res_frame, adjusted_bbox, cached_mask, cached_crop_box)
|
||||
except Exception:
|
||||
combine_frame = get_image(
|
||||
ori_frame, res_frame, list(adjusted_bbox),
|
||||
mode=blend_mode, fp=fp)
|
||||
|
||||
writer.write(combine_frame)
|
||||
|
||||
writer.release()
|
||||
timings["6_blend"] = time.time() - t0
|
||||
print(f"🎨 合成 [{timings['6_blend']:.1f}s]")
|
||||
|
||||
# ===== Phase 7: FFmpeg H.264 编码 + 合并音频 =====
|
||||
t0 = time.time()
|
||||
cmd = [
|
||||
"ffmpeg", "-y", "-v", "warning",
|
||||
"-i", temp_raw_path, "-i", audio_path,
|
||||
"-c:v", "libx264", "-crf", "18", "-pix_fmt", "yuv420p",
|
||||
"-c:a", "copy", "-shortest",
|
||||
output_vid_path
|
||||
]
|
||||
if not run_ffmpeg(cmd):
|
||||
raise RuntimeError("FFmpeg 重编码+音频合并失败")
|
||||
|
||||
# 清理临时文件
|
||||
if os.path.exists(temp_raw_path):
|
||||
os.unlink(temp_raw_path)
|
||||
|
||||
timings["7_encode"] = time.time() - t0
|
||||
print(f"🔊 编码+音频 [{timings['7_encode']:.1f}s]")
|
||||
except Exception:
|
||||
combine_frame = get_image(
|
||||
ori_frame, res_frame, list(adjusted_bbox),
|
||||
mode=blend_mode, fp=fp)
|
||||
|
||||
_write_pipe_frame(combine_frame)
|
||||
|
||||
pipe_in.close()
|
||||
timings["6_blend"] = time.time() - t0
|
||||
print(f"🎨 合成 [{timings['6_blend']:.1f}s]")
|
||||
|
||||
# ===== Phase 7: 等待 FFmpeg 编码完成 =====
|
||||
t0 = time.time()
|
||||
return_code = ffmpeg_proc.wait()
|
||||
if return_code != 0:
|
||||
raise RuntimeError("FFmpeg 编码+音频合并失败")
|
||||
|
||||
timings["7_encode"] = time.time() - t0
|
||||
print(f"🔊 编码+音频 [{timings['7_encode']:.1f}s]")
|
||||
|
||||
# ===== 汇总 =====
|
||||
total_time = time.time() - t_total
|
||||
|
||||
@@ -185,9 +185,50 @@ async function main() {
|
||||
const currentHash = getSourceHash();
|
||||
let bundleLocation: string;
|
||||
|
||||
// 辅助函数: 确保文件在缓存 public 目录中可访问 (硬链接 > 复制)
|
||||
function ensureInCachedPublic(cachedPublicDir: string, srcAbsPath: string, fileName: string) {
|
||||
const cachedPath = path.join(cachedPublicDir, fileName);
|
||||
// 已存在且大小一致,跳过
|
||||
try {
|
||||
if (fs.existsSync(cachedPath)) {
|
||||
const srcStat = fs.statSync(srcAbsPath);
|
||||
const cachedStat = fs.statSync(cachedPath);
|
||||
if (srcStat.size === cachedStat.size && srcStat.ino === cachedStat.ino) return;
|
||||
}
|
||||
} catch { /* file doesn't exist or broken, will recreate */ }
|
||||
// 移除旧的文件/链接
|
||||
try { fs.unlinkSync(cachedPath); } catch { /* doesn't exist, fine */ }
|
||||
// 优先硬链接(零拷贝,对应用透明),跨文件系统时回退为复制
|
||||
try {
|
||||
fs.linkSync(srcAbsPath, cachedPath);
|
||||
console.log(`Hardlinked into cached bundle: ${fileName}`);
|
||||
} catch {
|
||||
fs.copyFileSync(srcAbsPath, cachedPath);
|
||||
console.log(`Copied into cached bundle: ${fileName}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (fs.existsSync(hashFile) && fs.readFileSync(hashFile, 'utf-8') === currentHash) {
|
||||
bundleLocation = BUNDLE_CACHE_DIR;
|
||||
console.log('Using cached bundle');
|
||||
// 确保当前渲染所需的文件在缓存 bundle 的 public 目录中可访问
|
||||
const cachedPublicDir = path.join(BUNDLE_CACHE_DIR, 'public');
|
||||
if (!fs.existsSync(cachedPublicDir)) {
|
||||
fs.mkdirSync(cachedPublicDir, { recursive: true });
|
||||
}
|
||||
// 1) 视频文件
|
||||
ensureInCachedPublic(cachedPublicDir, path.resolve(options.videoPath), videoFileName);
|
||||
// 2) 字体文件 (从 subtitleStyle / titleStyle / secondaryTitleStyle 中提取)
|
||||
const styleSources = [options.subtitleStyle, options.titleStyle, options.secondaryTitleStyle];
|
||||
for (const style of styleSources) {
|
||||
const fontFile = (style as Record<string, unknown>)?.font_file as string | undefined;
|
||||
if (fontFile) {
|
||||
const fontSrcPath = path.join(publicDir, fontFile);
|
||||
if (fs.existsSync(fontSrcPath)) {
|
||||
ensureInCachedPublic(cachedPublicDir, path.resolve(fontSrcPath), fontFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('Bundling Remotion project...');
|
||||
console.log(`Entry point: ${entryPoint}`);
|
||||
|
||||
Reference in New Issue
Block a user